Merge bugfix/push-guard-argv into develop

This commit is contained in:
bchanot
2026-10-11 23:42:21 +02:00
4 changed files with 214 additions and 18 deletions
+2 -2
View File
@@ -33,14 +33,14 @@ Plan `.claude/tasks/plans/2026-10-10-model-router-w3a-confirm-1201.md` r4, contr
- [x] W3-C residuals closed in W4-A (68d7ea6). Live smoke of `/route forget` from the terminal still unseen. - [x] W3-C residuals closed in W4-A (68d7ea6). Live smoke of `/route forget` from the terminal still unseen.
- [x] W4-A transition logs + residuals (2026-10-11, 68d7ea6, contract `2026-10-11-model-router-w4a-logs-residuals-1637`, plan r3): verbose = transitions only (main line, hand-off per dispatch, agent moves, answered-by anomaly), stream lines removed, host prefix respected; residuals of W3-A/B/C closed (a)-(g). 3 lenses (BLOCKER: agent moves decided at spawn) + 1 confirmation, feater + 4 rounds, verifier at the cap on coverage (user accepted), security PASS + 2 MEDIUM fixed. Kit 284 → 358. - [x] W4-A transition logs + residuals (2026-10-11, 68d7ea6, contract `2026-10-11-model-router-w4a-logs-residuals-1637`, plan r3): verbose = transitions only (main line, hand-off per dispatch, agent moves, answered-by anomaly), stream lines removed, host prefix respected; residuals of W3-A/B/C closed (a)-(g). 3 lenses (BLOCKER: agent moves decided at spawn) + 1 confirmation, feater + 4 rounds, verifier at the cap on coverage (user accepted), security PASS + 2 MEDIUM fixed. Kit 284 → 358.
- [ ] W4-A residuals (LOW, accepted): `readLayer` logs `String(err)` of a JSON parse error (a file fragment can appear in the local log); `logCall`/`noteMain` ui calls on the step path sit outside `safely` (fail-open on routing, no security impact). - [ ] W4-A residuals (LOW, accepted): `readLayer` logs `String(err)` of a JSON parse error (a file fragment can appear in the local log); `logCall`/`noteMain` ui calls on the step path sit outside `safely` (fail-open on routing, no security impact).
- [ ] push-guard hotfix: the PreToolUse hook denies a read-only command whose TEXT contains the push verb next to `git` (grep pattern, heredoc); match the command's own argv only. - [x] push-guard argv check (2026-10-11, bugfix/push-guard-argv): the text match is now a prefilter; the command is split into simple commands (awk tokenizer: quotes, escapes, separators, heredoc bodies) and a push must be git's own subcommand (`push`/`send-pack`; `subtree`, wrappers, assignments, no-awk keep the text verdict). grep/heredoc/`git log -S`/`git stash push` over-blocks closed; `"git" push` miss closed. Suite 98 → 124 checks.
- [x] W4-B classifier (2026-10-11, e54a2b5, contract `2026-10-11-model-router-w4b-classifier-1858`, plan r3): `$.model.classify` on plain composer prompts with no route in force → turn effort only (design→plan, analysis→reflect, coding→implement, bookkeeping→apply, chat→nothing), 3-s race, abort not counted, 3 failures pause `cooldownMinutes`, no dialog, no file write, `classify` config key, `/route classify on|off`, status + verbose line. 3 lenses (BLOCKER: unbounded await) + 1 confirmation, feater + 1 round, verifier CONFORME (2nd), security PASS. Kit 358 → 428. Deviation from the TODO line: no first-use confirmation on classified phases (noise per prompt; T3 already confirms the phase). - [x] W4-B classifier (2026-10-11, e54a2b5, contract `2026-10-11-model-router-w4b-classifier-1858`, plan r3): `$.model.classify` on plain composer prompts with no route in force → turn effort only (design→plan, analysis→reflect, coding→implement, bookkeeping→apply, chat→nothing), 3-s race, abort not counted, 3 failures pause `cooldownMinutes`, no dialog, no file write, `classify` config key, `/route classify on|off`, status + verbose line. 3 lenses (BLOCKER: unbounded await) + 1 confirmation, feater + 1 round, verifier CONFORME (2nd), security PASS. Kit 358 → 428. Deviation from the TODO line: no first-use confirmation on classified phases (noise per prompt; T3 already confirms the phase).
- [ ] W4-B residuals: `smallModelBusy` checks `cfg.models.haiku` but classify uses the engine's default small model (guard misses when they differ); abort paths judged by reading only; the 3-s tests pin the upper bound only. Live AC9 open: type a few plain prompts, read `route: classifier <phase>` (verbose line with `/route verbose on`), drop `bookkeeping` if it misfires. - [ ] W4-B residuals: `smallModelBusy` checks `cfg.models.haiku` but classify uses the engine's default small model (guard misses when they differ); abort paths judged by reading only; the 3-s tests pin the upper bound only. Live AC9 open: type a few plain prompts, read `route: classifier <phase>` (verbose line with `/route verbose on`), drop `bookkeeping` if it misfires.
- [ ] W3-A live checks still open: T1 (typed `/feat` etc.) dialog (T3 seen live 2026-10-11: orchestrate Keep); a parallel same-agent dispatch answered after > 10 s; what an unanswered dialog resolves to on the terminal (must be Later or nothing written). Record in the contract as `[gated]` when seen. - [ ] W3-A live checks still open: T1 (typed `/feat` etc.) dialog (T3 seen live 2026-10-11: orchestrate Keep); a parallel same-agent dispatch answered after > 10 s; what an unanswered dialog resolves to on the terminal (must be Later or nothing written). Record in the contract as `[gated]` when seen.
- [ ] W3-A residuals (security LOW, accepted): non-atomic cross-process write (two sessions, crash mid-write → file reads as failed, previous config kept); `host/path` of a private remote in the tracked file; a persistent write failure re-asks every use (`asked.delete` in the catch); `out.length` vs byte size at the cap; `changePatch` scope 'project' with a vanished key writes Everywhere (cwd change mid-dialog). Deferred: `/route set`, `/route confirm`, `(unconfirmed)` in show, dialog edits of phases, frontmatter auto-alignment, session-start warning when routing.json is dirty, per-machine `projects`. - [ ] W3-A residuals (security LOW, accepted): non-atomic cross-process write (two sessions, crash mid-write → file reads as failed, previous config kept); `host/path` of a private remote in the tracked file; a persistent write failure re-asks every use (`asked.delete` in the catch); `out.length` vs byte size at the cap; `changePatch` scope 'project' with a vanished key writes Everywhere (cwd change mid-dialog). Deferred: `/route set`, `/route confirm`, `(unconfirmed)` in show, dialog edits of phases, frontmatter auto-alignment, session-start warning when routing.json is dirty, per-machine `projects`.
- [ ] routing.json ships `confirmed` for verifier/feater (user Keeps) and phases verify/implement: every clone inherits them (by design: decisions travel). Revisit at release if unwanted. - [ ] routing.json ships `confirmed` for verifier/feater (user Keeps) and phases verify/implement: every clone inherits them (by design: decisions travel). Revisit at release if unwanted.
- [ ] W2 residuals: probe 4 unobserved (above); the push-guard hook denies a read-only grep (or a heredoc) whose TEXT contains the push verb next to `git` (security-auditor + orchestrator 2026-10-10, false positives, reworded); ~27 loose "sonnet pin"/"opus pin" shorthand sites kept (true by census); README config key list omits `tiers`/`fallback`/`cooldownMinutes`/`mainUpgrade`/`upgradeMaxTokens` (doc audit item 6, pre-existing); MIGRATION.md "Upgrading to 3.0.0" at release time; `skillCalls`/`spawning` counters global; offers keyed by name. - [ ] W2 residuals: probe 4 unobserved (above); (push-guard over-block: CLOSED 2026-10-11, argv check); ~27 loose "sonnet pin"/"opus pin" shorthand sites kept (true by census); README config key list omits `tiers`/`fallback`/`cooldownMinutes`/`mainUpgrade`/`upgradeMaxTokens` (doc audit item 6, pre-existing); MIGRATION.md "Upgrading to 3.0.0" at release time; `skillCalls`/`spawning` counters global; offers keyed by name.
- [ ] W3 optional: step heuristics, quota-aware downgrade, A/B (haiku classifier: done W4-B) - [ ] W3 optional: step heuristics, quota-aware downgrade, A/B (haiku classifier: done W4-B)
## 2026-09-30 — Higgsfield pack: CLI + skills in the install process, off by default (feature/higgsfield-pack) ## 2026-09-30 — Higgsfield pack: CLI + skills in the install process, off by default (feature/higgsfield-pack)
+1 -1
View File
@@ -9,7 +9,7 @@ Format follows [Keep a Changelog](https://keepachangelog.com/) and this project
### Added ### Added
- **model-router mod**: `mods/model-router/`, a Claude Code mod (function-hooks plugin), routes every repo skill and agent from phase rows (`plan`, `reflect`, `orchestrate`, `escalate`, `judge`, `implement`, `write`, `verify`, `explore`, `apply`, `mechanical`; built-ins Explore on sonnet/medium, Plan on opus/xhigh). A typed skill routes the main loop to its row, and a best-tier row holds across turns in a run slot until `/route clear`, `/route off`, a user `/model` or a typed skill on a non-best row. Agents get their row's model at spawn (within the tier, upward only) and its effort on every step; explicit Agent params win. Orchestrators declare their phases through the `mcp__model-router__route` tool. `ultrathink` in a prompt sets the turn's minimum effort and `/route effort=max` holds until `/route clear`; the built-in `/effort` is not a lever inside a run. `/route show` names the run slot (`main: run <phase>`), and a `null` row in the override drops a default row. The model gets a `route` tool and the user a `/route` command (`show|clear|off|on|reload|pending|ask on|off|forget <name|all|projects>|<phase>|model=<alias|id> effort=<level>|switch on|off|verbose on|off`). Optional per-machine config `~/.claude/model-router.json`, where `"enabled": false` turns it off on that machine. The spinner suffix and the status line show the route in force. It loads in every session through the tracked symlink `skills/model-router` (`model-router@skills-dir`). `make doctor` gains a Mods section; suite `make test suite=lib/tests/mods.test.sh`. Known limits: the main loop switches model only with `mainModelSwitch` on (default off, one cold-cache step per switch into another model), and the hooks send full model ids, so the `models` table has to follow new versions. - **model-router mod**: `mods/model-router/`, a Claude Code mod (function-hooks plugin), routes every repo skill and agent from phase rows (`plan`, `reflect`, `orchestrate`, `escalate`, `judge`, `implement`, `write`, `verify`, `explore`, `apply`, `mechanical`; built-ins Explore on sonnet/medium, Plan on opus/xhigh). A typed skill routes the main loop to its row, and a best-tier row holds across turns in a run slot until `/route clear`, `/route off`, a user `/model` or a typed skill on a non-best row. Agents get their row's model at spawn (within the tier, upward only) and its effort on every step; explicit Agent params win. Orchestrators declare their phases through the `mcp__model-router__route` tool. `ultrathink` in a prompt sets the turn's minimum effort and `/route effort=max` holds until `/route clear`; the built-in `/effort` is not a lever inside a run. `/route show` names the run slot (`main: run <phase>`), and a `null` row in the override drops a default row. The model gets a `route` tool and the user a `/route` command (`show|clear|off|on|reload|pending|ask on|off|forget <name|all|projects>|<phase>|model=<alias|id> effort=<level>|switch on|off|verbose on|off`). Optional per-machine config `~/.claude/model-router.json`, where `"enabled": false` turns it off on that machine. The spinner suffix and the status line show the route in force. It loads in every session through the tracked symlink `skills/model-router` (`model-router@skills-dir`). `make doctor` gains a Mods section; suite `make test suite=lib/tests/mods.test.sh`. Known limits: the main loop switches model only with `mainModelSwitch` on (default off, one cold-cache step per switch into another model), and the hooks send full model ids, so the `models` table has to follow new versions.
- **model-router first-use confirmation**: `mods/model-router/routing.json` (tracked) is the single source of the phase table and of every skill and agent row, and keeps the decisions: `confirmed`, `changed` (`from`/`to`), `projects` exceptions keyed by the origin remote reduced to `host/path` (credentials and local paths never stored), and `ask`. The first use of a rowed typed skill, a rowed agent spawn or a main-loop phase declared through the `route` tool opens a dialog with context: the skill's description (first sentence of its `SKILL.md` frontmatter) or the agent's, the phase with its `about` line (a new field on each of the 11 phases) and the model id and effort the next step really runs on. A row offers Later, Keep or Change; a main-loop phase Later or Keep. Change asks the model (fable, opus, sonnet, haiku with their tier), then the effort among those the phases of that model use (skipped when there is only one), then Everywhere or This project only; the pair maps to an existing phase (rows stay phase names, the same phase counts as Keep, a pair no phase offers is added by hand as a new phase). A skill-row change toasts the model and effort it now runs on, with the `/route switch on` hint when the main loop holds back a downgrade; a free-text answer counts as Later. One dialog at a time, never in headless (`-p`) runs or inside a sub-agent. Only a dialog answer, `/route ask on|off` or `/route forget` (which takes decisions back; a changed row returns to its shipped phase, the frontmatter floors stay yours to realign) writes the file (serialized, 64 KiB cap, never created when absent); each write asks you to commit it from the config repo. `/route pending` lists unconfirmed rows and phases. Layers: routing.json < `~/.claude/model-router.json` (its `ask` wins); a project's `.claude/model-router.json` is never read. Tests: `mods/model-router/hooks/register.test.ts`. - **model-router first-use confirmation**: `mods/model-router/routing.json` (tracked) is the single source of the phase table and of every skill and agent row, and keeps the decisions: `confirmed`, `changed` (`from`/`to`), `projects` exceptions keyed by the origin remote reduced to `host/path` (credentials and local paths never stored), and `ask`. The first use of a rowed typed skill, a rowed agent spawn or a main-loop phase declared through the `route` tool opens a dialog with context: the skill's description (first sentence of its `SKILL.md` frontmatter) or the agent's, the phase with its `about` line (a new field on each of the 11 phases) and the model id and effort the next step really runs on. A row offers Later, Keep or Change; a main-loop phase Later or Keep. Change asks the model (fable, opus, sonnet, haiku with their tier), then the effort among those the phases of that model use (skipped when there is only one), then Everywhere or This project only; the pair maps to an existing phase (rows stay phase names, the same phase counts as Keep, a pair no phase offers is added by hand as a new phase). A skill-row change toasts the model and effort it now runs on, with the `/route switch on` hint when the main loop holds back a downgrade; a free-text answer counts as Later. One dialog at a time, never in headless (`-p`) runs or inside a sub-agent. Only a dialog answer, `/route ask on|off` or `/route forget` (which takes decisions back; a changed row returns to its shipped phase, the frontmatter floors stay yours to realign) writes the file (serialized, 64 KiB cap, never created when absent); each write asks you to commit it from the config repo. `/route pending` lists unconfirmed rows and phases. Layers: routing.json < `~/.claude/model-router.json` (its `ask` wins); a project's `.claude/model-router.json` is never read. Tests: `mods/model-router/hooks/register.test.ts`.
- **Manual-push mode**: `git config gitflow.autopush false` (human-set) now stops every push the gitflow lib makes, not only the post-commit / post-merge hooks. `gitflow start` and `finish` branch, commit and merge locally and push nothing; `gitflow delete` leaves the `origin/` copy in place and prints `git push origin --delete <br>` for the user to run. `hooks/unpushed-guard.sh` stays silent at turn end in this mode and opens each session with one `ℹ manual push mode:` line counting the commits no remote holds across every local branch; an unparseable or unreadable `gitflow.autopush` value is treated as manual push mode too, and that line names it. `hooks/push-guard.sh` (PreToolUse, `Bash|Monitor`) refuses any `git push` Claude types while `gitflow.autopush` reads false in the session cwd or in a literal `-C`/`cd` directory the command names (global config counts outside a repo); the refusal tells the user to run it with `! git push`. It reads the mode through the same lib verb as every other reader and fails closed: an unparseable or unreadable value reads as manual, and an internal error, a missing `lib/gitflow.sh`, more than 20 distinct directory tokens in one command (capped before any token is classified), a `cd`/`-C` directory token mixing quoted and unquoted parts, or a payload jq cannot parse whose raw text looks like a push refuse the push (these pathological cases fire in auto mode too). Directory tokens are read as whole shell words, adjacent quoted segments and backslash escapes included. In manual mode it over-blocks any command where a `push` word follows a `git` token; the misses listed in its header fall to a new `autoMode.soft_deny` rule that no request in the turn clears. The session banner adds `🔒 push : manual (autopush=false) — ! git push` when the key reads false, and `🔒 push : manual (autopush bad) — ! git push` when the value is invalid. Skills read the mode through a new lib verb, `bash ~/.claude/lib/gitflow.sh push-mode`: it prints `auto`, `manual` or `invalid` (rc 0) and names an invalid value on stderr (printable characters only, 64 at most). It is the one reader a skill may call, since the `git config` read of the key is denied to Claude. Skills push nothing on their own, except the `/release-candidate` tag in auto-push mode on an explicit go. Every "on origin" or "not pushed" line they print comes from `git rev-list --count origin/<br>..<br>` read after the fact, with the complete `! git …` command when something is left for the user to push. An invalid value (anything but unset, true or false, or a read that fails) is manual push mode for every reader and is named where it is read (see Fixed). Tests: `lib/gitflow-test.sh` T11b (push-mode verb), T18m and T18q blocks, `lib/tests/unpushed-guard.test.sh` T10-T16, `lib/tests/push-guard.test.sh` (98 checks). - **Manual-push mode**: `git config gitflow.autopush false` (human-set) now stops every push the gitflow lib makes, not only the post-commit / post-merge hooks. `gitflow start` and `finish` branch, commit and merge locally and push nothing; `gitflow delete` leaves the `origin/` copy in place and prints `git push origin --delete <br>` for the user to run. `hooks/unpushed-guard.sh` stays silent at turn end in this mode and opens each session with one `ℹ manual push mode:` line counting the commits no remote holds across every local branch; an unparseable or unreadable `gitflow.autopush` value is treated as manual push mode too, and that line names it. `hooks/push-guard.sh` (PreToolUse, `Bash|Monitor`) refuses any `git push` Claude types while `gitflow.autopush` reads false in the session cwd or in a literal `-C`/`cd` directory the command names (global config counts outside a repo); the refusal tells the user to run it with `! git push`. It reads the mode through the same lib verb as every other reader and fails closed: an unparseable or unreadable value reads as manual, and an internal error, a missing `lib/gitflow.sh`, more than 20 distinct directory tokens in one command (capped before any token is classified), a `cd`/`-C` directory token mixing quoted and unquoted parts, or a payload jq cannot parse whose raw text looks like a push refuse the push (these pathological cases fire in auto mode too). Directory tokens are read as whole shell words, adjacent quoted segments and backslash escapes included. Once the text looks like a push, the command is split into simple commands (quotes, escapes, separators, heredoc bodies dropped) and a push must be git's own subcommand in one of them: `grep "git push"`, `git log -S "git push"`, `git config --get push.default`, `git stash push`, a heredoc quoting a push or a commit message naming one are allowed; `bash -c` scripts are re-checked, wrappers (`sudo`, `env`, `eval`, `xargs`, `timeout`, `git subtree`...) and assignments keep the text verdict. The misses listed in its header fall to a new `autoMode.soft_deny` rule that no request in the turn clears. The session banner adds `🔒 push : manual (autopush=false) — ! git push` when the key reads false, and `🔒 push : manual (autopush bad) — ! git push` when the value is invalid. Skills read the mode through a new lib verb, `bash ~/.claude/lib/gitflow.sh push-mode`: it prints `auto`, `manual` or `invalid` (rc 0) and names an invalid value on stderr (printable characters only, 64 at most). It is the one reader a skill may call, since the `git config` read of the key is denied to Claude. Skills push nothing on their own, except the `/release-candidate` tag in auto-push mode on an explicit go. Every "on origin" or "not pushed" line they print comes from `git rev-list --count origin/<br>..<br>` read after the fact, with the complete `! git …` command when something is left for the user to push. An invalid value (anything but unset, true or false, or a read that fails) is manual push mode for every reader and is named where it is read (see Fixed). Tests: `lib/gitflow-test.sh` T11b (push-mode verb), T18m and T18q blocks, `lib/tests/unpushed-guard.test.sh` T10-T16, `lib/tests/push-guard.test.sh` (98 checks).
- **model-router prompt classifier**: a plain prompt typed in the composer (12 characters or more, not a slash command, not mid-turn, never under `-p` or in a sub-agent) that no keyword rule, skill, run slot, `/route` or floor covers is labelled by one call to the engine's small model (`design`, `analysis`, `coding`, `bookkeeping`, `chat`), bounded to 3 s. The label maps to plan, reflect, implement or apply and that phase's effort becomes the turn's default: effort only, no model move, no dialog, no file write, gone at the end of the turn, replaced by any route call or typed skill, and beaten by a `/effort` you set yourself. Three failures in a row pause it for `cooldownMinutes` (one toast, `classifier: paused` in the status line). `"classify": false` in `~/.claude/model-router.json` and `/route classify on|off` turn it off; `/route show` prints `classify:`. The status line reads `route: classifier <phase>` and `/route verbose on` adds `(<phase>: classifier)`. - **model-router prompt classifier**: a plain prompt typed in the composer (12 characters or more, not a slash command, not mid-turn, never under `-p` or in a sub-agent) that no keyword rule, skill, run slot, `/route` or floor covers is labelled by one call to the engine's small model (`design`, `analysis`, `coding`, `bookkeeping`, `chat`), bounded to 3 s. The label maps to plan, reflect, implement or apply and that phase's effort becomes the turn's default: effort only, no model move, no dialog, no file write, gone at the end of the turn, replaced by any route call or typed skill, and beaten by a `/effort` you set yourself. Three failures in a row pause it for `cooldownMinutes` (one toast, `classifier: paused` in the status line). `"classify": false` in `~/.claude/model-router.json` and `/route classify on|off` turn it off; `/route show` prints `classify:`. The status line reads `route: classifier <phase>` and `/route verbose on` adds `(<phase>: classifier)`.
+170 -13
View File
@@ -18,11 +18,20 @@
# 'git push' reads as one mixed token, accepted, fail closed); a payload jq # 'git push' reads as one mixed token, accepted, fail closed); a payload jq
# cannot parse whose raw text (JSON escapes folded) looks like a push: static # cannot parse whose raw text (JSON escapes folded) looks like a push: static
# deny, mode-blind, so a description naming a push also denies there. # deny, mode-blind, so a description naming a push also denies there.
# OVER-BLOCKS in manual mode: any text carrying a later ` push` word after # ARGV CHECK: once the text looks like a push, the command is split into
# a `git` token (git subtree push, git stash push, git log -S "git push", # simple commands (quotes, escapes, `;` `&&` `||` `|` `(` `)` backticks,
# grep -rn "git push" skills/, git config --get push.default, git add # heredoc bodies dropped) and the push must sit in one of them: argv[0] is
# push.sh, git help push, a commit message quoting "git push"). # git (or */git) and its first non-option word is push or send-pack. A
# MISSES: "git" push, git "push", git pu\sh, git $'push', $g push; ~ / $VAR / # command whose argv[0] is a plain program (grep, cat, echo, sed, rg...) or a
# git subcommand other than push (log -S "git push", config --get
# push.default, add push.sh, help push, stash push, commit -m "git push")
# is proven not a push and allowed. `bash|sh|zsh -c <script>` recurses into
# the script (depth 3). A wrapper (sudo, env, exec, command, eval, xargs,
# timeout, nohup, find, ssh, alias, source, time, git subtree...) or an
# assignment whose value reads as a push keeps the text verdict (deny).
# awk missing, more than 2000 simple commands or a tokenizer failure keep
# the text verdict too (fail closed).
# MISSES: git pu\sh, git $'push', $g push; ~ / $VAR /
# $(...) in -C or cd (never resolved, never eval'd); --git-dir / GIT_DIR; a # $(...) in -C or cd (never resolved, never eval'd); --git-dir / GIT_DIR; a
# push hidden in a script, Makefile target, user alias or an alias planted # push hidden in a script, Makefile target, user alias or an alias planted
# by a redirect into .git/config; cumulative relative `cd a && cd b` (each # by a redirect into .git/config; cumulative relative `cd a && cd b` (each
@@ -69,18 +78,166 @@ bare=$(printf '%s' "$one" | sed -E "s/\"[^\"]*\"//g; s/'[^']*'//g")
# JSON quotes are syntax, not shell quoting: keep them for the loose regexes. # JSON quotes are syntax, not shell quoting: keep them for the loose regexes.
[ "$unparsed" = 1 ] && bare=$one [ "$unparsed" = 1 ] && bare=$one
# is_push: strict (full text), loose (quotes removed), alias definition. # Push regexes: strict (full text), loose (quotes removed), alias definition.
STRICT='(^|[^[:alnum:]_.-])git([[:space:]]+-[^[:space:]]+([[:space:]]+[^[:space:]-][^[:space:]]*)?)*[[:space:]]+(push|send-pack)([^[:alnum:]_-]|$)'
LOOSE='(^|[^[:alnum:]_.-])git[[:space:]]+([^|;&()]*[[:space:]])?(push|send-pack)([^[:alnum:]_-]|$)'
ALIAS_RE='alias\.[^=[:space:]]+=[^[:space:]]*push'
# is_push: the text prefilter; no match anywhere = not a push. The third
# form drops the quote characters only ("git" push, git "push").
is_push() { is_push() {
local strict loose alias_re local unq=${one//\"/}; unq=${unq//\'/}
strict='(^|[^[:alnum:]_.-])git([[:space:]]+-[^[:space:]]+([[:space:]]+[^[:space:]-][^[:space:]]*)?)*[[:space:]]+(push|send-pack)([^[:alnum:]_-]|$)' printf '%s' "$one" | grep -qE "$STRICT" && return 0
loose='(^|[^[:alnum:]_.-])git[[:space:]]+([^|;&()]*[[:space:]])?(push|send-pack)([^[:alnum:]_-]|$)' printf '%s' "$unq" | grep -qE "$STRICT" && return 0
alias_re='alias\.[^=[:space:]]+=[^[:space:]]*push' printf '%s' "$bare" | grep -qE "$LOOSE" && return 0
printf '%s' "$one" | grep -qE "$strict" && return 0 printf '%s' "$bare" | grep -qE "$ALIAS_RE"
printf '%s' "$bare" | grep -qE "$loose" && return 0 }
printf '%s' "$bare" | grep -qE "$alias_re"
# simple_cmds: stdin text -> one simple command per line, words joined by
# \037 (quotes removed, escapes resolved, heredoc bodies dropped). Pure
# awk, never eval'd.
simple_cmds() {
awk '
function endword() { if (have) { line = line (nw ? "\037" : "") word; nw++ }
word = ""; have = 0 }
function endcmd() { if (nw) print line; line = ""; nw = 0 }
function skip_heredocs( k, e, ln) {
for (k = 1; k <= nh; k++) while (i <= n) {
e = index(substr(txt, i), "\n")
if (e == 0) { ln = substr(txt, i); i = n + 1 }
else { ln = substr(txt, i, e - 1); i += e }
sub(/^\t+/, "", ln); if (ln == hd[k]) break
}
nh = 0 }
function read_delim( q, d) {
if (substr(txt, i, 1) == "-") i++
while (substr(txt, i, 1) ~ /[ \t]/) i++
d = ""
while (i <= n) { c = substr(txt, i, 1)
if (c ~ /[ \t\n;&|()<>]/) break
if (c == "\047" || c == "\"") { q = c; i++
while (i <= n && substr(txt, i, 1) != q) { d = d substr(txt, i, 1); i++ }
i++; continue }
if (c == "\\") { d = d substr(txt, i + 1, 1); i += 2; continue }
d = d c; i++ }
hd[++nh] = d }
BEGIN { RS = "\001" }
{ txt = txt $0 }
END {
n = length(txt); i = 1; nw = 0; nh = 0; word = ""; have = 0; line = ""
while (i <= n) { c = substr(txt, i, 1)
if (c == "\047") { j = index(substr(txt, i + 1), "\047"); have = 1
if (j == 0) { word = word substr(txt, i + 1); i = n + 1 }
else { word = word substr(txt, i + 1, j - 1); i += j + 1 }
continue }
if (c == "\"") { i++; have = 1
while (i <= n) { c = substr(txt, i, 1)
if (c == "\"") { i++; break }
if (c == "\\" && i < n) { d = substr(txt, i + 1, 1)
if (d ~ /["\\$`\n]/) { if (d != "\n") word = word d; i += 2; continue } }
word = word c; i++ }
continue }
if (c == "\\") { if (i < n) { d = substr(txt, i + 1, 1)
if (d != "\n") { word = word d; have = 1 } i += 2 } else i++
continue }
if (c == "\n") { endword(); endcmd(); i++
if (nh) skip_heredocs(); continue }
if (c == " " || c == "\t") { endword(); i++; continue }
if (c == "<" && substr(txt, i + 1, 1) == "<" && substr(txt, i + 2, 1) != "<") {
endword(); i += 2; read_delim(); continue }
if (c ~ /[;|()`]/) { endword(); endcmd(); i++; continue }
if (c == "&") { p = i > 1 ? substr(txt, i - 1, 1) : ""; d = substr(txt, i + 1, 1)
if (p == ">" || p == "<" || d == ">") { word = word c; have = 1; i++; continue }
endword(); endcmd(); i++; continue }
word = word c; have = 1; i++ }
endword(); endcmd() }'
}
# text_push <text>: the text verdict on one simple command (quotes gone).
text_push() { printf '%s' "$1" | grep -qE "$LOOSE|$ALIAS_RE"; }
# git_subcmd <words...>: prints git'"'"'s subcommand (first word after the
# global options; value-taking ones skip their argument).
git_subcmd() {
local -a a=("$@"); local i=1
while [ "$i" -lt "${#a[@]}" ]; do
case "${a[i]}" in
-C|-c|--git-dir|--work-tree|--namespace|--exec-path|--super-prefix|--config-env|--list-cmds)
i=$((i + 2)); continue ;;
-*) i=$((i + 1)); continue ;;
esac
printf '%s' "${a[i]}"; return 0
done
}
# shell_c <depth> <words...>: a shell'"'"'s -c script, re-checked as a command
# of its own; no -c = text verdict.
shell_c() {
local depth=$1; shift; local -a a=("$@"); local i=1
while [ "$i" -lt "${#a[@]}" ]; do
case "${a[i]}" in
--*) ;;
-*c*) argv_push "${a[i + 1]:-}" "$((depth + 1))"; return ;;
esac
i=$((i + 1))
done
text_push "$*"
}
# seg_push <line> <depth>: rc 0 when this simple command is a push or
# cannot be proven otherwise, rc 1 when it is proven not one.
seg_push() {
local depth=$2 i=0 sub; local -a w
IFS=$'\037' read -r -a w <<<"$1"
while [ "$i" -lt "${#w[@]}" ]; do
case "${w[i]}" in
'{'|'}'|'!'|if|then|else|elif|fi|do|done|while|until|for|in|case|'esac'|coproc|function|select)
i=$((i + 1)) ;;
[A-Za-z_]*=*)
case "${w[i]%%=*}" in *[!A-Za-z0-9_]*) break ;; esac
text_push "${w[i]#*=}" && return 0
i=$((i + 1)) ;;
*) break ;;
esac
done
[ "$i" -lt "${#w[@]}" ] || return 1
local -a a=("${w[@]:i}")
case "${a[0]}" in
git|*/git)
sub=$(git_subcmd "${a[@]}")
case "$sub" in
push|send-pack) return 0 ;;
subtree) text_push "${a[*]}"; return ;;
esac
printf '%s' "${a[*]}" | grep -qE "$ALIAS_RE" ;;
bash|sh|zsh|dash|ksh|*/bash|*/sh|*/zsh|*/dash|*/ksh) shell_c "$depth" "${a[@]}" ;;
eval|exec|command|builtin|env|sudo|doas|nohup|timeout|xargs|nice|ionice|time|stdbuf|script|chroot|ssh|find|watch|parallel|alias|source|.)
text_push "${a[*]}" ;;
*) return 1 ;;
esac
}
# argv_push <text> <depth>: rc 1 only when every simple command of <text>
# is proven not a push; anything unclear keeps the text verdict (rc 0).
argv_push() {
local text=$1 depth=$2 lines nseg line
[ "$depth" -le 3 ] || return 0
lines=$(printf '%s' "$text" | simple_cmds) || return 0
nseg=$(printf '%s\n' "$lines" | grep -c .)
[ "$nseg" -le 2000 ] || return 0
while IFS= read -r line; do
[ -n "$line" ] || continue
seg_push "$line" "$depth" && return 0
done <<<"$lines"
return 1
} }
is_push || exit 0 is_push || exit 0
# The text looked like a push: a parsed payload gets the argv check (an
# unparseable one keeps the static deny below).
if [ "$unparsed" = 0 ] && command -v awk >/dev/null 2>&1; then
argv_push "$cmd" 0 || exit 0
fi
# static_deny: the fixed fail-closed answer (no jq needed to build it). # static_deny: the fixed fail-closed answer (no jq needed to build it).
static_deny() { static_deny() {
+41 -2
View File
@@ -76,8 +76,10 @@ check T20-subtree "$(fire 'git subtree push --prefix=x origin main' "$M")" deny
check T21-cd-amp "$(fire "(cd $M&&git push)" "$WORK/plain")" deny check T21-cd-amp "$(fire "(cd $M&&git push)" "$WORK/plain")" deny
check T22-alias "$(fire 'git -c alias.p=push p' "$M")" deny check T22-alias "$(fire 'git -c alias.p=push p' "$M")" deny
check T23-send-pack "$(fire 'git send-pack origin' "$M")" deny check T23-send-pack "$(fire 'git send-pack origin' "$M")" deny
check T24-grep-overblock "$(fire 'grep -rn "git push" skills/' "$M")" deny # T24/T25 were over-blocks until the argv check: a push must be git's own
check T25-config-overblock "$(fire 'git config --get push.default' "$M")" deny # subcommand, in a simple command whose argv[0] is git.
check T24-grep-argv "$(fire 'grep -rn "git push" skills/' "$M")" allow
check T25-config-argv "$(fire 'git config --get push.default' "$M")" allow
# ── manual: allow ── # ── manual: allow ──
check T26-commit-msg "$(fire 'git status && git commit -m "fix push guard"' "$M")" allow check T26-commit-msg "$(fire 'git status && git commit -m "fix push guard"' "$M")" allow
@@ -88,6 +90,43 @@ check T30-echo "$(fire 'echo pushed' "$M")" allow
check T31-rg-C "$(fire 'rg -C 3 push src/' "$M")" allow check T31-rg-C "$(fire 'rg -C 3 push src/' "$M")" allow
check T32-branch "$(fire 'git branch --show-current' "$M")" allow check T32-branch "$(fire 'git branch --show-current' "$M")" allow
# ── argv check: proven non-pushes allowed, unclear forms still denied ──
check T59a-log-S "$(fire 'git log -S "git push" --oneline' "$M")" allow
check T59b-stash-push "$(fire 'git stash push -m wip' "$M")" allow
check T59c-help-push "$(fire 'git help push' "$M")" allow
check T59d-add-file "$(fire 'git add push.sh' "$M")" allow
check T59e-commit-msg "$(fire 'git commit -m "doc: git push flow"' "$M")" allow
check T59f-heredoc "$(fire $'cat >> n.md <<EOF\n- run git push origin main\nEOF\ngit status' "$M")" allow
check T59g-heredoc-tab "$(fire $'cat <<-EOF\n\tgit push\n\tEOF' "$M")" allow
check T59h-herestring "$(fire 'grep -c push <<<"git push"' "$M")" allow
check T59i-printf "$(fire 'printf "%s\n" "git push" > note.md' "$M")" allow
check T59j-bash-c-grep "$(fire "bash -lc 'grep \"git push\" x'" "$M")" allow
check T59k-rg "$(fire 'rg -n "git push" lib/' "$M")" allow
check T60a-heredoc-then-push "$(fire $'cat <<EOF; git push\nx\nEOF' "$M")" deny
check T60b-sudo "$(fire 'sudo git push' "$M")" deny
check T60c-timeout "$(fire 'timeout 5 git push' "$M")" deny
check T60d-eval-var "$(fire 'X="git push"; eval $X' "$M")" deny
check T60e-backtick "$(fire 'echo `git push`' "$M")" deny
check T60f-dollar-paren "$(fire 'echo $(git push)' "$M")" deny
check T60g-redirect "$(fire 'git push 2>&1' "$M")" deny
check T60h-if-bang "$(fire 'if ! git push; then echo no; fi' "$M")" deny
check T60i-env-assign "$(fire 'GIT_TRACE=1 git push' "$M")" deny
check T60j-global-opts "$(fire 'git -C /tmp -c x=y --no-pager push' "$M")" deny
check T60k-quoted-argv0 "$(fire '"git" push origin main' "$M")" deny
check T60l-sh-c-nested "$(fire "sh -c 'bash -c \"git push\"'" "$M")" deny
check T60m-xargs "$(fire 'echo origin | xargs git push' "$M")" deny
# T61: awk absent: the argv check is skipped, the text verdict stands.
mkdir -p "$WORK/noawk"
for tool in bash cat jq git grep sed sort head; do
real=$(command -v "$tool") || continue
case "$real" in /*) ln -sf "$real" "$WORK/noawk/$tool" ;; esac
done
payload61=$(jq -n --arg c 'grep -rn "git push" skills/' --arg d "$M" \
'{tool_input:{command:$c},cwd:$d}')
out61=$(printf '%s' "$payload61" | PATH="$WORK/noawk" "$(command -v bash)" "$H" 2>/dev/null); rc61=$?
check T61a-noawk-rc "$rc61" 0
check T61b-noawk-text-verdict "$(jq -r '.hookSpecificOutput.permissionDecision' <<<"$out61")" deny
# ── invalid value: fail closed ── # ── invalid value: fail closed ──
run 'git push' "$WORK/bad" run 'git push' "$WORK/bad"
check T33-invalid "$(verdict)" deny check T33-invalid "$(verdict)" deny