Repository navigation
fix: close guard hook git gaps and dispatch field-report defects - #355
Merged
Merged
Conversation
check_member's reserved-prefix refusal is a join-time admission rule (docs/multi-repo-work.md). Shared with validate/repair it made every locked member fail 'member refs collide with coordinator names' from build start onward -- permanently after a first ship, since the member milestone tag is never deleted. Refuse reserved refs only at add time. Fixes one item of #353.
The plan gate rejected any same-wave overlap without consulting the dependency graph it already built, while build_state.ready enforces the real invariant (overlap only among concurrent tasks) and the runtime dispatches same-wave dependencies in order. Skip the rejection when one task transitively depends on the other; unordered overlap, cycles, and later-wave dependencies are unchanged. Fixes one item of #353.
- git arguments may carry $var when the subcommand is read-only (log/show/diff/status); write-capable subcommands keep the refusal - archive context evaluates read-only-ness per pipeline segment, so cat | wc of allowlisted reads passes; heredoc bodies no longer flip a wrapped payload into archive context - cd targets expand tracked assignments (dir=/x && cd "$dir") - quoted destructive paths may contain ASCII spaces inside one shell unit; injection-relevant characters stay denied - unprovable archive commands (ssh, python -) deny with an honest not-provably-read-only reason instead of claiming a write Fixes one item of #353.
…cher _runtime_status still executed the legacy per-project .gsd-path/runtime/pipeline_state.py that current installs no longer write and migration removes, so every declared-runtime project -- stock or hotfixed -- rendered Unverified amber. Invoke the project launcher first and keep the legacy exec as the fallback. Fixes one item of #353.
--runtime-upgrade gains --runtime-provenance-source/-patch/-note flags storing owner-recorded audit metadata next to the digest. Provenance is metadata only: it never joins the hashed manifest input and never weakens validate_runtime; absent provenance equals stock. Upgrades carry an existing entry forward instead of wiping it, and a git checkout source records its HEAD when no patch ref is given. Fixes one item of #353.
- activate-task without --task-branch refuses serial activation while the task's parallel worktree is live, instead of silently flipping the primary copy and stranding finish on a sidecar it never had - finish recreates a deleted serial verify sidecar from the recorded base (ensure_verify_sidecar), lifting reproduction into isolation as reproduce_verify_sidecar; a stale registration or branch clears - serial landing tolerates uncommitted primary bookkeeping (BOOKKEEPING_PATHS), matching the parallel gate, attest, and the landing proof; unrelated dirt stays refused with a clearer message - sidecar retirement is submodule-aware: deinit, forced removal, then a long-path-safe delete fallback, instead of failing forever on worktrees whose submodules were ever initialized; dirty sidecars keep git's refusal without --force - retiring the last sidecar releases the pinned workspace receipt, so GSD_PATH_WORKTREE_ROOT applies again at the next milestone Fixes the remaining items of #353.
- a provenance entry carries forward only while the pinned digest is unchanged; upgrading to different bytes drops the stale hotfix record - --runtime-provenance-* errors whenever --runtime-upgrade is absent - the direct-delete fallback runs only for git's tree-deletion failures, so any other cleanup error leaves the sidecar intact (test_member_cleanup_surfaces_git_failures) and rmtree failures surface as IsolationError - fd-qualified redirections (2>) count as archive write attempts - release comment matches the idle-time release semantics
…ption Unbraced zsh expansion flags ($=x, $~x, $^x, $+x) matched no parameter syntax, so they never entered the parameter proof while zsh word-splits them exactly like the braced forms the proof denies. A backslash-escaped marker kept a proven-quoted flag although the shell executes the literal text and the token keeps the bare marker. Both now deny: any $-position covered by no recognized form routes into the proof chain, and an escaped marker is never a proven parameter.
The guard scanned -c/--config values only for parameters, so a literal core.fsmonitor path passed while git executes the configured program during an otherwise read-only subcommand (demonstrated archive write via git -c core.fsmonitor=<hook> status .project/archive). Keys whose value git runs as a program -- fsmonitor, hooksPath, editors, pagers, sshCommand, askPass, filter/diff/merge drivers -- are now denied with literal values as with parameters; ordinary settings keep working.
…iteral option words
This was referenced Oct 5, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Intent
Close the two findings from the GPT-6-SOL (codex CLI) second opinion of PR #355's branch: (1) a literal git -c core.fsmonitor= passed the guard while git executes the configured program during the read-only status subcommand, demonstrated as an archive write — fixed by denying -c/--config keys whose value git executes (fsmonitor, hooksPath, editors, pagers, sshCommand, askPass, filter/diff/merge drivers) with literal values as with parameters, ordinary settings unaffected; (2) worktree placement receipt release when idle was flagged as mid-milestone re-rooting — accepted as the documented design requested by issue #353 (a supported way to choose a shorter root), gated on zero linked worktrees so no stale path resolution exists; no change intended for item 2 beyond existing documentation.
What Changed
scripts/guard_hook.py,HOOKS.md): the hook now deniesgit -c/--configandgit configkeys whose value git executes or loads code through (for examplecore.fsmonitor,core.hooksPath, editors,pager.<cmd>,core.sshCommand, askpass, gpg programs, credential helpers, filter/diff/merge drivers,include.path). The denial applies to literal values and to parameters; ordinary settings still pass. The hook also parsesgit configmodes, matches denied git options by abbreviated prefix, accepts a parameter in a read-only git argument only inside plain double quotes, denies zsh expansion flags and non-literal git words, and unwraps command runners so the git checks reach the command they run.isolation.py,worktree_paths.py,integration.py,dispatch_driver.py): sidecar removal now handles initialized submodules, read-only files and tree-deletion failures, with a direct-delete fallback and Windows long-path support. Finish recreates a missing verify sidecar from the recorded base. Landing tolerates uncommitted bookkeeping paths, and serial activation is refused while the task has a live parallel worktree. The placement pin is released when no linked worktree remains, so the next allocation readsGSD_PATH_WORKTREE_ROOTagain (documented inDOCS.md).check_handoffs.pyallows same-wave file overlap when one task transitively depends on the other (planner docs and templates updated);members.pyvalidate and repair no longer reject the refs that Path creates; the daemon probe resolves status through the project launcher.gsd-path/status_runtime.pybefore the per-project runtime;install.pyadds--runtime-provenance-source|patch|notefor--runtime-upgrade, stored as metadata on the runtime declaration and outside the pinned digest.Risk Assessment
Testing
I first reproduced the reported archive write on real git. I then installed the base and HEAD guard hook in scratch projects and sent real hook events on stdin: HEAD denied all 37 executed-config and adversarial forms (base allowed 35 of them) and allowed all 17 ordinary and disabling forms. I also drove the placement pin and release on real git worktrees, and ran the 11 targeted tests; all passed. No baseline command ran before this step. The change has no UI, so the evidence is CLI transcripts and not screenshots.
git -c core.fsmonitor=<path> statuswith a literal value (from the project root, with an archive operand, and aftercdinto the archive); the guard denies it and names the keygit -c core.fsmonitor configures a program git executes. exploit_repro.txt shows that real git runs the program and writes into the…--config-env); the guard denies each one-ckey=, quotes,-C .first,/usr/bin/git,command/env/nice/sudo/timeoutrunners,sh -c, brace expansion, persisted…-c user.name=,-c color.ui=,core.fsmonitor=false, empty or bare key,pager.log=false,git config <key>read, plain runners, `git diff --…GSD_PATH_WORKTREE_ROOTwhile one linked worktree exists; the next sidecar stays in the pinned location (no re-root during a milestone)tests.test_worktree_placementalso passesGSD_PATH_WORKTREE_ROOTafter the last linked worktree retires; the next worktree is created below the new root<scratch>/moved;tests.test_worktree_placement.test_release_workspace_lets_a_new_root_apply_after_retirementalso passesEvidence: Exploit reproduction on real git (why the fix is necessary)
$ git --version git version 2.54.0 (Apple Git-157) $ ls .project/archive/001-mvp # before plan $ git -c core.fsmonitor=/tmp/scratch/h.sh status # the command base allowed $ ls .project/archive/001-mvp # after: git ran the program during read-only status PWNED.md planEvidence: Guard verdicts, base versus HEAD (54 commands)
base HEAD command ALLOWED DENIED git -c core.fsmonitor=/abs/h.sh status ALLOWED DENIED cd .project/archive/001-mvp && git -c core.fsmonitor=/abs/h.sh status ALLOWED DENIED git -c core.hooksPath=/tmp/hooks status ALLOWED DENIED git -c core.editor=/abs/h.sh log -1 ALLOWED DENIED git -c core.pager=/abs/h.sh log ALLOWED DENIED git -c core.sshCommand=/abs/h.sh ls-remote . ALLOWED DENIED git -c core.askPass=/abs/h.sh ls-remote . ALLOWED DENIED git -c filter.x.clean=/abs/h.sh status ALLOWED DENIED git -c diff.external=/abs/h.sh diff ALLOWED DENIED git -c merge.x.driver=/abs/h.sh status ALLOWED DENIED git -c CORE.FSMONITOR=/abs/h.sh status ALLOWED DENIED nice git -c core.fsmonitor=/abs/h.sh status ALLOWED DENIED sh -c 'git -c core.fsmonitor=/abs/h.sh status' ALLOWED DENIED git config core.fsmonitor /abs/h.sh ALLOWED DENIED git -c pager.log=/abs/h.sh log ALLOWED ALLOWED git status ALLOWED ALLOWED git -c user.name=Test log -1 ALLOWED ALLOWED git -c color.ui=never status ALLOWED ALLOWED git -c core.fsmonitor=false status ALLOWED ALLOWED git -c pager.log=false log -1 unexpected HEAD verdicts: 0 HEAD denial message for the reported command: gsd-path guard: GSD Path guard could not validate the tool request: git -c core.fsmonitor configures a program git executes; the guard cannot allow itEvidence: Guard driver script
Evidence: Worktree placement pin and release transcript
GSD_PATH_WORKTREE_ROOT=<scratch>/moved, linked worktrees = 1 T003 would be placed at: ~/.gsd-path/projects/<repo>/<id>/task/T003 -> pin HELD (not re-rooted mid-milestone) retired T002, linked worktrees = 0 T003 worktree: <scratch>/moved/<repo>/<id>/task/T003 -> pin RELEASED, new root appliedEvidence: Placement driver script
Pipeline
Updates from git push no-mistakes
... (15 earlier update rounds omitted to keep the PR body within GitHub's 65536-char limit; full history is in the run log.)
🔧 Fix applied.
3 issues (2 warnings, 1 info) still open:
scripts/guard_hook.py:2310- The round-7 rewrite of the line-continuation logic inshell_tokensis fail-open for two continuations in sequence, and it removes a denial that HEAD1 had.1 and ALLOWED on HEAD (regression in this round).joins(lines 2310-2313) tests the characters next to each continuation incommand, where each continuation is already a space. Thus, for\<newline>\<newline>, each continuation sees the other one as a space, no join is recorded, and the words on the two sides stayliteral == "bare". Probes from a project root with.project/archive/001-mvp:git config core.fsmoni\<newline>\<newline>tor /abs/h.shis DENIED on HEADgit c\<newline>\<newline>onfig core.fsmonitor /abs/h.shis ALLOWED on HEAD and HEAD~1, so the round-7 claim (a subcommand split by a line continuation is denied) does not hold for this form;git con\<newline>\<newline>\<newline>fig ...and the\r\nform are also ALLOWED. I ran the key form and the subcommand form with bash and zsh (git 2.54.0) in a scratch repository: each one setcore.fsmonitorto the program path. The owner instructions for rounds 6 and 7 require a DENIED verdict for a continuation that splits the key or the subcommand word, and require that each prior verdict stays. The new tests contain only one continuation, so they pass with this defect. The base commit ALLOWS these forms (it has nogit configcheck), so this is not a regression against base. The defect is in code that the fix rounds added and that exceeds the intent, which names only-c/--configkeys. The remedy needs the owner's decision: (a) revert the persisted-form check to the minimal fix (removedenied_git_config_argument,ShellToken.redirection,ShellToken.literal, the continuation mark logic,GIT_CONFIG_EXPANDING_WORD, the subcommand literal check, their tests and HOOKS.md text) and keep the-c/--config-envdenial; or (b) keep the check and repairjoins: find the adjacent characters inmarkedand skip a run of marks, so a run of continuations between two non-space characters is one join. Add the two probes above as DENIED regression tests if (b) is selected. I did not test remedy (b).scripts/guard_hook.py:2611- The intent failure stays reachable from the project root through a brace list in the value word of a git global option. The guard reads the word after-c,-C,--git-dir,--work-tree,--namespaceor--config-envas one value (lines 2608-2612), but the shell makes several arguments from an unquoted brace list. The round-7 literal-word check applies only to the subcommand word and thegit configarguments. Probe:git -c {core.fsmonitor=/abs/h.sh,status}is ALLOWED on HEAD. The guard reads the key as{core.fsmonitor, finds no subcommand, andgit_commandreturns None. I ran it with bash and zsh in a scratch repository: the shell rangit -c core.fsmonitor=/abs/h.sh statusand git executed the program. This is the sequence that intent item 1 closes (a literal git -c core.fsmonitor=<path> passed the guard while git executes the configured program during the read-only status subcommand). The same form is DENIED aftercd .project/archive/001-mvp(by other rules), and the plain, quoted, attached, escaped and single-continuation-cforms are all DENIED on HEAD. Also ALLOWED on HEAD:git -C {.,config,core.fsmonitor,/abs/h.sh},git -c {a.b=c,config,core.fsmonitor,/abs/h.sh},git --git-dir {.git,config,core.fsmonitor,/abs/h.sh}; the-Cform setcore.fsmonitorin the scratch repository with bash and zsh. The base commit ALLOWS all of these, so this is not a regression. The remedy needs the owner's decision because it changes the verdict for global option values of each git command: the earliest shared boundary is the global-option loop ingit_command; deny a separate value word that is bare and matchesGIT_CONFIG_EXPANDING_WORD(brace list or range) with the existingcannot be resolvedmessage. A narrower form covers only-cand--config-env, but then the-C/--git-dirforms stay open forgit config. The executable word has the same class of gap on base and HEAD ({git,config,core.fsmonitor,/abs/h.sh},g\<newline>it config ..., also{git,reset,--hard}); it comes from the base tokenizer, not from this change. I did not test the remedy.scripts/guard_hook.py:2639- The round-7 subcommand and comment rules do what the owner specified for the named probes, and the common forms keep their verdicts. I compared base, HEAD~1 and HEAD for about 180 commands. DENIED on HEAD:git {config,core.fsmonitor,/abs/h.sh},git con\<newline>fig ...,git con''fig ...,git config core.fsmonitor /tmp/h.sh # --get(also with a tab or a redirection before#). ALLOWED and unchanged:git statuswith;,|,&&,>,2>&1, a subshell,$(...), backticks,bash -c,eval,sudo,env, a quoted subcommand, a continuation after a space, a heredoc with a continuation in its body, and non-ASCII text. New denials on forms that base ALLOWS:git st\atus,git 'stat'us,git {status,log},git re\<newline>set --hard,git status\<newline>;ls(continuation directly between the subcommand and an operator), andgit config --get user.name # note(eachgit configwith a trailing comment). The owner instruction for round 7 requires the comment denial, and the other forms are not usual. The generated script copies inskills/*/scriptsare identical toscripts/. The new tests execute the guard and assert its verdict, so they obey the test-quality rule. I did not run the test suite.🔧 Fix applied.
4 issues (1 error, 2 warnings, 1 info) still open:
scripts/guard_hook.py:2685- An unquoted parameter in the value word of a git global option passes when the guard sees no subcommand word.git_commandreturns None at line 2685 before it raises forparameter_arguments, so nothing is inspected. Probes from a project root with.project/archive/001-mvp:read x < v.txt; git -c $x,git -c $x,git --git-dir $xandgit --git-dir=$xare ALLOWED on HEAD. The base commit DENIES all four (git argument $x cannot be resolved by the guard). The verdict changed in commit f6f2067 (fix: guard hook precision for read-only commands), which is in this change; each later commit keeps it. I ranread x < v.txt; git -c $xwith bash (git 2.54.0) in a scratch repository wherev.txtcontainscore.fsmonitor=/abs/h.sh status: bash split the value and git executed the program. zsh does not split an unquoted$x, so the path is bash only. This is the sequence that intent item 1 closes, and the intent requires the denialwith literal values as with parameters. The same commands with a visible subcommand (git -c $x status) are DENIED, and the form with an assigned value (x='core.fsmonitor=/abs/h.sh status'; git -c $x) is DENIED. Remedy: raise the existingcannot be resolvederror whenparameter_argumentsis not empty before thereturn Noneat line 2685, which restores the base verdict. Add the four probes as DENIED regression tests. I did not test the remedy.scripts/guard_hook.py:2646- The intent failure stays reachable through a brace list in a git global option word that takes no value. The round-8 fix appliesrequire_resolved_git_wordto the value words of-c,-C,--git-dir,--work-tree,--namespace,--config-envand to the subcommand word, but the loop at line 2646 skips each other word that starts with-without a check. Probes:git -{p,c} core.fsmonitor=/abs/h.sh statusandX=/abs/h.sh git --{no-pager,config-env=core.fsmonitor=X} statusare ALLOWED on HEAD. The guard reads-{p,c}as one option with no value and readscore.fsmonitor=/abs/h.shas the subcommand. I ran the two forms with bash and zsh (git 2.54.0) in a scratch repository: the shell madegit -p -c core.fsmonitor=/abs/h.sh statusand git executed the program in each of the four runs. The base commit ALLOWS these forms, so this is not a regression. The new tests contain a brace list only in a value word or the subcommand word, so they pass with this gap. The remedy, not the defect, needs the owner's decision, because it changes the verdict for the option words of each git command and continues the non-literal word machinery that the fix rounds built (this is one more round with a fail-open next to it): (a) applyrequire_resolved_git_wordto each word that the global-option loop reads, so a non-literal option word gets the existingcannot be resolvederror; or (b) stop here and accept that non-literal words outside the named forms stay open. Add the two probes as DENIED regression tests if (a) is selected. I did not test the remedy.scripts/guard_hook.py:251-credential.helperis a config key whose value git executes, and it is not inGIT_EXECUTED_CONFIG_KEYSorGIT_EXECUTED_CONFIG_SECTIONS. Probe:git -c credential.helper=/abs/h.sh ls-remote http://127.0.0.1:<port>/x.gitis ALLOWED on base and HEAD;git -c credential.https://x.helper=/abs/h.sh ls-remote originandgit config credential.helper /abs/h.share ALLOWED too. I ran the first form (git 2.54.0) against a local HTTP server that answers 401 withWWW-Authenticate: Basic: git executed the program duringls-remote, which is inCLOSED_READ_GIT_COMMANDS. The path needs a remote that asks for credentials. The intent criterion isdenying -c/--config keys whose value git executes; its list (fsmonitor, hooksPath, editors, pagers, sshCommand, askPass, filter/diff/merge drivers) does not name credential helpers. Thus the owner must decide if the list is complete or gives examples. The remedy needs the owner's decision becausecredential.helperhas common legitimate values (store,osxkeychain,cache) and a denial changes the verdict for them: addcredential.helperand thecredential.<url>.helperform to the executed keys (the empty disabling valuegit -c credential.helper= fetchthen still passes), or leave the key out as outside the intent list. I could not confirm an execution forpager.<cmd>orcore.alternateRefsCommand, so I do not report them.scripts/guard_hook.py:2313- The head commit does what the owner specified for the two round-8 closures. I compared base, HEAD1 and HEAD for about 140 commands. DENIED on HEAD:1 ALLOWS:git config core.fsmoni\<newline>\<newline>tor /abs/h.sh,git c\<newline>\<newline>onfig ...,git -c\<newline>\<newline>core.fsmonitor=/abs/h.sh status,git -c {core.fsmonitor=/abs/h.sh,status}, the brace forms after-C,--git-dir,--work-tree,--namespace, andgit -c 'a.b=c'{,config,...}. ALLOWED and unchanged:git -c user.name='A B' log -1,git -c user.name="A B" commit -m x,git -C "sub" status,git -Csub status,git --git-dir=.git --work-tree=. status, continuations after a space in multi-line git commands, andgit configreads. New denials on forms that HEADgit -c core.excludesFile=*.ign status,git -c log.date=format:%Y?%m log -1,git -c a.b='it'\''s' log -1,git -C sub\ dir status; the owner instruction for round 8 requires a denial for a backslash and for each sequence that can become several words, and these forms are not usual. Themixedstate is set only when the word text with its plain quotes removed equals the token, so a tokenizer offset error can only add a denial. The new tests execute the guard and assert its verdict, so they obey the test-quality rule. I did not run the test suite.🔧 Fix applied.
2 issues (1 warning, 1 info) still open:
scripts/guard_hook.py:439- The intent item 1 failure stays reachable when a program wrapper that the guard does not know comes beforegit.COMMAND_WRAPPERScontains onlybuiltin,call,command,exec, sogit_commandseesnice,nohup,timeout,sudoornoglobas the executable, returns None at line 2567, and no git check runs. Probes from a project root with.project/archive/001-mvp:nice git -c core.fsmonitor=/abs/h.sh status,nohup git -c core.fsmonitor=/abs/h.sh status,timeout 5 git -c core.fsmonitor=/abs/h.sh status,sudo git -c core.fsmonitor=/abs/h.sh status,noglob git -c core.fsmonitor=/abs/h.sh status,nice git config core.fsmonitor /abs/h.sh,nice git -{p,c} core.fsmonitor=/abs/h.sh statusandtimeout 5 git -c $xare ALLOWED on base, HEAD~1 and HEAD. I rannice git -c core.fsmonitor=<script> status(git 2.54.0) in a scratch repository: git executed the script and it created.project/archive/001-mvp/PWNED.md. This is not a regression and not caused by the head commit: the same gap exists for each git rule on the base commit (nice git reset --hard,sudo git push --force,nohup git clean -fdxare ALLOWED there). The guard also ALLOWS./h.sh,nice /abs/h.shandpython3 /abs/h.pydirectly, so the owner must decide if this path is in the scope of the intent. The remedy, not the defect, needs the owner's decision, because it changes the verdict of each git rule in the guard and extends the change beyond the-ckey denial: (a) unwrap these wrappers at the shared boundarywrapped_command_tokens(add them toCOMMAND_WRAPPERSwith their option and duration operands), which makes all git checks apply; or (b) stop here and accept the gap as a known limit of the guard. Add theniceandtimeout 5probes as DENIED regression tests if (a) is selected. I did not test the remedy.scripts/guard_hook.py:2655- The head commit does what the owner specified for the three round-9 closures. I compared base, HEAD1 and HEAD for about 180 commands in scratch repositories. (1)1 and DENIED on HEAD. (2)git -c $x,git --git-dir $x,git --git-dir=$xandbash -c 'git -c $x'are DENIED on base, ALLOWED on HEADgit -{p,c} core.fsmonitor=/abs/h.sh status, the--{no-pager,config-env=...}form,git -p{,} status, and the same forms after-C .,--git-dir .git,command,env,sh -c,evaland in$(...)are DENIED on HEAD. (3)git -c credential.helper=/abs/h.sh ls-remote ..., thecredential.<url>.helperform in each letter case, andgit config credential.helper /abs/h.share DENIED;git -c credential.helper= ls-remote origin,git config --get credential.helper,git config --unset credential.helperandgit -c credential.username=me fetchstay ALLOWED. Unchanged and ALLOWED:git --no-pager log -1,git -p log -1,git --exec-path=/usr/lib/git status,git --version,git -C sub,git --no-pager log --format="$t %H",git --no-pager show "HEAD:$f". Expected new denials from the owner's decision:git -c credential.helper=store fetch,git config --global credential.helper osxkeychain,git -c credential.helper='!gh auth git-credential' push. The only other new denial I found is a global option with a parameter and no subcommand (git --git-dir="$PWD/.git"), which the base commit denies too. The new tests execute the guard and assert its verdict, so they obey the test-quality rule. HOOKS.md agrees with the code. I did not run the test suite.🔧 Fix applied.
2 issues (1 warning, 1 info) still open:
scripts/guard_hook.py:2562- The round-10 check (MODELED_PROGRAMSand the new branch incommand_invocation) gives wrong verdicts in two directions. I ran the guard from HEAD~1 and from HEAD, each in a scratch repository, on about 75 commands.(1) New false denials. These commands are ALLOWED on HEAD~1 and DENIED on HEAD with
executable ... can run git in a way the guard cannot inspect; run git directly, without <program>:which git,type git,hash git,man git,whereis git,pgrep git,brew install git,apt-get install -y git,gh pr create --title git,gh repo clone cli/cli git,pytest tests/git,python3 scripts/tool.py git,diff a git,awk '{print}' git,make git,docker run --rm alpine git --version,ssh host git status. None of them runs git through a path that the guard could inspect, and most run no git at all. The message tells the user to run git withoutwhich, which is not a usable instruction. The check matches the basename of each argument, so a path argument such astests/gitalso denies.daemon/gsd_daemon/launch.py:269tells the user to runsudo apt install git; the same rule denies that form. This is a new guard false positive on read-only commands, the class that this branch removes.(2) The wrapper failure stays reachable. These commands are ALLOWED on HEAD:
nice sh -c 'git -c core.fsmonitor=/abs/h.sh status',g=git; nice $g -c core.fsmonitor=/abs/h.sh status,nice "$g" -c core.fsmonitor=/abs/h.sh status,nice gi{t,t} status,nice gi? -c core.fsmonitor=/abs/h.sh status,nice $(echo git) -c core.fsmonitor=/abs/h.sh status. The check reads only literal argument words, and it does not look behind a known wrapper that comes after the unknown program. I did not execute these forms in a shell; the round-10 review executed the plainnice git -c core.fsmonitor=<script> statusform. The new tests contain only the literalgitword, so they pass with this defect.This component is not required by the intent. The intent names
denying -c/--config keys whose value git executes ... with literal values as with parameters; it does not name programs before git. The guard also allows./h.sh,nice /abs/h.shandpython3 /abs/h.pydirectly, so the check does not close the execution path that it targets. The defects are in code that a fix round added beyond the original finding, so I report one finding and not more repairs. The remedy needs the owner's decision, because round 10 selected this rule: (a) recommended: revert commit 27cc609 (removeGIT_PROGRAMS,MODELED_PROGRAMS, the branch at line 2562, its tests and the HOOKS.md sentence) and accept the wrapper gap as a known limit that exists on the base commit for each git rule; or (b) narrower form: deny only when the executable is one of a named list of command-runner programs (nice,nohup,timeout,sudo,doas,noglob,timewith options) and deny each invocation of those programs that the guard cannot unwrap, which removes the false denials but keeps a list to maintain. If (b) is selected, addwhich git,man git,brew install gitandpytest tests/gitas ALLOWED regression tests andnice sh -c 'git -c core.fsmonitor=/abs/h.sh status'as a DENIED one.scripts/guard_hook.py:2562- The head commit gives the verdicts that the owner listed for round 10. DENIED on HEAD and ALLOWED on HEAD~1:nice -n 5 git status,nice 'git' -c core.fsmonitor=/abs/h.sh status,nice GIT ...,nice ./git status,sudo -u root git -c core.fsmonitor=/abs/h.sh status,env nice git ...,nice env git ...,nice command git ...,watch git status,time -p git -c core.fsmonitor=/abs/h.sh status. Unchanged and ALLOWED:ls vendor/git,cd vendor/git,mkdir git,find . -name git,echo git | tee out.txt,printf '%s\n' git,time git status,ls .git. Unchanged and DENIED:time git -c core.fsmonitor=/abs/h.sh status,xargs git -c core.fsmonitor=/abs/h.sh status,find . -exec git -c core.fsmonitor=/abs/h.sh status \;, and theif,!,{ }and( )forms. The new tests execute the guard and assert its verdict, so they obey the test-quality rule. I did not run the test suite.🔧 Fix applied.
2 issues (1 warning, 1 info) still open:
scripts/guard_hook.py:2551- The round-11 rule (EXEC_PREFIX_PROGRAMSand the branch incommand_invocation) still gives wrong verdicts in two directions. I ran the guard from base, HEAD~1 and HEAD, each in an initialized scratch tree.(1) False denials that stay. These commands are ALLOWED on base and DENIED on HEAD:
sudo apt install git,sudo apt-get install -y git,timeout 60 brew install git,sudo -u git psql,nice grep -r git README.md,sudo ls vendor/git,sudo rm -rf vendor/git,nohup npm run git,watch -n 5 which git. None of them runs git.daemon/gsd_daemon/launch.py:269anddaemon/app/src/screen.ts:49tell the user to runsudo apt install git. The check matches the basename of each argument, so a path argument such asvendor/gitdenies; the owner's rule saidexactly git or git.exe, but the verified round-10 denialsnice ./git statusandnice /usr/bin/git ...need the basename match.(2) The new
executable.startswith("-")condition is not in the owner's rule. It exists becausetimeis removed as a shell word before this check, so-pbecomes the executable. It denies each segment whose first word starts with-and that has a git word:time -p grep git README.md,time -p ls gitandtime -p make gitare DENIED on HEAD and ALLOWED on base, whiletime ls gitandtime which gitare ALLOWED. The message saysrun git directly, without -p, which is not a usable instruction.(3) The wrapper failure stays reachable. ALLOWED on HEAD:
doas git -c core.fsmonitor=/abs/h.sh status, and the same withionice,chronic,caffeinate,gtimeout 5,nice.exe,sudo.exe, andnice sh -c 'git -c core.fsmonitor=/abs/h.sh status'.noglob git -c core.fsmonitor=/abs/h.sh statusis DENIED on HEAD~1 and ALLOWED on HEAD: the round-10 instruction listednoglobas DENIED, the round-11 set does not contain it. HOOKS.md now states that these forms are not covered, so the document agrees with the code.This component is not required by the intent, which names
denying -c/--config keys whose value git executes, not programs before git. The owner selected the rule in rounds 10 and 11, so items (1) and (3) are mostly the result of that decision; item (2) and thenoglobchange are new. The remedy needs the owner's decision: (a) recommended: revert commits 27cc609 and 37ab072 (removeGIT_PROGRAMSuse in this branch,EXEC_PREFIX_PROGRAMS, the branch at line 2551, its tests and the HOOKS.md sentences) and accept the wrapper gap as a known limit that exists on the base commit for each git rule; or (b) accept the current verdicts as they are. I recommend no more repairs on this machinery.scripts/guard_hook.py:543- The head commit gives the verdicts that the owner listed for round 11. ALLOWED again on HEAD (DENIED on HEAD~1):which git, and the test listtype git,hash git,man git,whereis git,pgrep git,brew install git,apt-get install -y git,gh pr create --title git,gh repo clone cli/cli git,pytest tests/git,make git. DENIED on HEAD:nice git status,nice -- git status,nice git -c core.fsmonitor=/abs/h.sh status,/usr/bin/nice git status,NICE git status,'nice' git status,time -p git status,env env git status,ls | nice git status,echo x && sudo git push. Unchanged:git status,time git status,env FOO=1 git status,nice make,sudo systemctl statusALLOWED;git -c core.fsmonitor=/abs/h.sh status,time git -c core.fsmonitor=...andenv git -c core.fsmonitor=...DENIED by the-ckey rule. Theenvandtimeentries of the set apply only after another prefix (env env,env time), because both words are removed before the check. The changed tests execute the guard and assert its verdict, so they obey the test-quality rule. I did not run the test suite.🔧 Fix applied.
3 issues (1 error, 1 warning, 1 info) still open:
scripts/guard_hook.py:2543- The round-12 unwrap opens a new archive write path. The loop at line 2543 discards the options of the command runner and their values without a check, then the guard judges only the payload. Two runners write a file through their own options. I ran base, HEAD~1 and HEAD in scratch trees with.project/archive/001-mvp.(1)
time -o FILEwrites FILE (BSD and GNUtime). ALLOWED on HEAD, DENIED on base and HEAD~1:nice time -o .project/archive/001-mvp/N ls,env time -o .project/archive/001-mvp/N ls,/usr/bin/time -o .project/archive/001-mvp/N ls,timeout 5 time -o .project/archive/001-mvp/N ls,nice time --output=.project/archive/001-mvp/N ls,nice time -ao .project/archive/001-mvp/N ls,nice time -o .project/archive/001-mvp/N git status, andcd .project/archive/001-mvp && nice time -o N ls. I executednice time -o .project/archive/001-mvp/N lsandcd .project/archive/001-mvp && env time -o N2 lson macOS: each one created the file in the archive.(2)
sudo -e(sudoedit) opens each operand in an editor. ALLOWED on HEAD, DENIED on base and HEAD~1:sudo -e cat .project/archive/001-mvp/plan/PLAN.mdandcd .project/archive/001-mvp && sudo -e cat plan/PLAN.md. The guard reads them ascat <file>. I did not execute this form.The cause is one change: on base, a runner in archive context was an unknown program and was denied; on HEAD the payload (
ls,cat,git log) is a read command, so the segment passes, and the skipped runner words are not examined. The round-12 tests contain no runner option that names a file, so they pass with this defect.This defect is in machinery that fix rounds 10 to 12 built. The intent names
denying -c/--config keys whose value git executes; it does not name programs before git. Round 12 already recommended a revert, and this is the third round with a wrong verdict in this component, now a fail-open that the base commit does not have. The remedy needs the owner's decision: (a) recommended: revert commits 27cc609, 37ab072 and 71e75f8 (removeEXEC_PREFIX_VALUE_OPTIONS,TIMEOUT_DURATION, the-pskip and the unwrap branch incommand_invocation, their tests and the HOOKS.md sentences), and accept the runner gap for the git-crule as a known limit that the base commit has for each git rule; or (b) keep the unwrap and denytime -o/--output/-aandsudo -e/--edit, and deny each runner when the working directory or an argument is in the archive. I do not recommend (b): it adds one more rule set to this component.scripts/guard_hook.py:2560- The unwrap denies ordinary commands that base and HEAD1 allow.1, ALLOWED on base), becausetimeout "$T" makeandtimeout ${T:-5} makeare DENIED on HEAD withexecutable $T cannot be resolved by the guard; name the program literally:TIMEOUT_DURATION(line 2560) accepts only a literal number, so the duration parameter becomes the executable. The message names the wrong word. Also DENIED on HEAD and ALLOWED on base:nice $CMD,sudo "$CMD",nohup $CMD &,timeout 10 $RUNNER test,watch -n 2 $CMD,sudo $(which node) x.js. The second group agrees with the base rule for a bare$CMD(DENIED on base), so only the duration case is a clear error; the testtimeout $t git statusasserts that denial as intended. The opposite direction also exists but is not a regression against base:timeout .5 git -c core.fsmonitor=/abs/h.sh statusandwatch -q 3 git -c core.fsmonitor=/abs/h.sh statusare ALLOWED on HEAD (DENIED on HEAD.5and3become the executable. These defects are in the same round-12 unwrap component; the revert in findingguard-unwrap-wrapper-options-open-archive-writeremoves them. I recommend no separate repair.scripts/guard_hook.py:2499- The head commit gives the verdicts that the owner listed for round 12. ALLOWED on HEAD and DENIED on HEAD~1:sudo apt install git,sudo -u git psql. ALLOWED on all three:nice make,timeout 5 npm test,sudo -E -u root ls,nohup npm run build,time -p make,sudo --preserve-env=PATH make install,stdbuf -oL -eL python3 x.py. DENIED on HEAD by the ordinary git rule:nice git -c core.fsmonitor=/abs/h.sh status. Archive writes behind a runner stay DENIED:nice touch .project/archive/001-mvp/N,sudo -u root rm -rf .project/archive/001-mvp,sudo -D .project/archive/001-mvp touch N,cd .project/archive/001-mvp && nice git diff --output=N, and thewatchforms with>or;in a quoted word. The changed tests execute the guard and assert its verdict, so they obey the test-quality rule. I did not run the test suite.🔧 Fix applied.
3 issues (2 warnings, 1 info) still open:
scripts/guard_hook.py:2598- The round-13 runner option scan does not parse a short option with an attached value, so it gives wrong verdicts in the two directions. I ran base, HEAD~1 and HEAD in scratch trees.(1) New false denials. Line 2598 tests each letter of a short word against the file options, including the letters of an attached value.
sudo -upostgres psql,sudo -udeploy ls,sudo -ujenkins makeandsudo -uDeploy lsare DENIED on HEAD withsudo -upostgres writes, edits or enters a file or directory, because the user name containse,DorR. Base and HEAD~1 ALLOW them;sudo -u postgres psqlis ALLOWED on HEAD. The message is false: the command names no file.(2) The payload is skipped.
runner_option_in(line 2515) reads the last letter of the word as the option.sudo -uroot git -c core.fsmonitor=/abs/h.sh status,sudo -hlocalhost git -c core.fsmonitor=/abs/h.sh statusandsudo -uroot git config core.fsmonitor /abs/h.share ALLOWED on HEAD: the last lettertis the value option-t, so the guard takesgitas its value and judges a later word as the program.sudo -u root git -c core.fsmonitor=/abs/h.sh statusis DENIED. Base ALLOWS these forms, so this is not a regression against base, but HOOKS.md says that the guard checks the command a runner runs. HEAD~1 has the same defect.Minor items in the same code:
sudo -h(help) andnice -nare DENIED on HEAD withlacks a value(line 2608; ALLOWED on base and HEAD1), and1).timeout infinity makeandtimeout 1e3 makeare DENIED on HEAD (line 2623; GNU timeout accepts the two forms; ALLOWED on base and HEADThe round-13 tests contain no attached short value, so they pass with these defects. The defects are in the runner unwrap that fix rounds 10 to 13 built; the intent names only the git
-c/--configkeys. The owner kept the unwrap in rounds 12 and 13, so the remedy needs the owner's decision: (a) revert commits 27cc609, 37ab072, 71e75f8 and 181c357 and accept the runner gap that the base commit has; or (b) keep the unwrap and parse a short word from left to right: a file option letter denies, the first value option letter stops the scan, and the remaining text of the word (or the next word when no text remains) is its value. With (b), addsudo -upostgres psqlas an ALLOWED test andsudo -uroot git -c core.fsmonitor=/abs/h.sh statusas a DENIED test.scripts/guard_hook.py:2523- The new file option denial does not hold when the option word is a parameter in double quotes.unresolved_runner_wordaccepts each double-quoted parameter (line 2523), and the file option check then reads only the literal text.read x < v.txt; nice /usr/bin/time "-$x" ls,read x < v.txt; nice time "-$x" git statusandread x < v.txt; sudo "-$x" touch Nare ALLOWED on HEAD. I ran the first command with zsh in a scratch tree wherev.txtcontainso.project/archive/001-mvp/N:timecreated.project/archive/001-mvp/N. A valueD.project/archive/001-mvpgives the same result forsudo -D. Base and HEAD~1 also ALLOW these forms, so this is not a regression; but the commit and HOOKS.md claim thattime -o,sudo -e,sudo -D,sudo -Randwatch -sare denied, and HOOKS.md documents the exception (each other runner option must be a literal word or a parameter in double quotes).sudo "--chdir=$x" touch Nis DENIED, because the option name is literal. The componentparameter in double quotes in a runner option wordexceeds the round-13 instruction, which asked to deny a parameter where the unwrap expects a runner operand. The remedy narrows allowed commands (watch --interval="$t" lschanges to DENIED), so the owner must decide: the narrower form denies each parameter in a word that starts with-when the parameter comes before=or the word has no=, and keeps the double-quote exception only for a separate value word (sudo -u "$USER" ls). Add thenice time "-$x" lsprobe as a DENIED test and correct the HOOKS.md sentence. The revert in findingguard-runner-short-cluster-attached-valueoption (a) also removes this component.scripts/guard_hook.py:2578- The head commit gives the verdicts that the owner listed for round 13. DENIED on HEAD and ALLOWED on HEAD1:1). The round-12 allowed forms keep their verdict (nice time -o .project/archive/001-mvp/N ls, and theenv,timeout 5,/usr/bin/time,--output=and-aoforms.timeout "$T" makeis DENIED with a message that names the literal duration. With an archive working directory or an archive operand a runner is not unwrapped and has the base verdict again (cd .project/archive/001-mvp && nice ls,nice cat .project/archive/001-mvp/plan/PLAN.md: DENIED on base and HEAD, ALLOWED on HEADnice make,timeout 5 npm test,sudo -E -u root ls,time -p make,stdbuf -oL -eL python3 x.py). The intent item 1 holds:git -c core.fsmonitor=/abs/h.sh status,core.pagerandcore.hooksPathare DENIED andgit -c user.name=x logis ALLOWED. The owner's instruction also makes these commands DENIED outside the archive:/usr/bin/time -o /tmp/t.txt make,sudo -D /srv ls,sudo -R /mnt ls,nice -n $N make. The changed tests execute the guard and assert its verdict, so they obey the test-quality rule. I did not run the test suite.🔧 Fix applied.
2 issues (1 warning, 1 info) still open:
scripts/guard_hook.py:251- The intent item 1 namespagersin the set of-c/--configkeys whose value git executes, but the guard denies onlycore.pager. git also runs the value ofpager.<cmd>as the pager program when the value is not a boolean.GIT_EXECUTED_CONFIG_KEYS(line 251) andGIT_EXECUTED_CONFIG_SECTIONS(line 255) contain nopagersection. Probes on HEAD from a project root with.project/archive/001-mvp:git -c "pager.status=touch .project/archive/001-mvp/N" status,git -c pager.log=/abs/h.sh log,git -c PAGER.LOG=/abs/h.sh logand the persisted formgit config pager.log /abs/h.share ALLOWED, whilegit -c core.pager=/abs/h.sh logandgit config core.pager /abs/h.share DENIED. I rangit -c "pager.status=<script>" statuswith git 2.54.0 in a scratch repository: with a terminal on stdout (throughscript -q /dev/null) git ran the script; with no terminal git did not run it.core.pagerhas the same terminal condition and is denied, so the two keys must have the same verdict. Remedy: treat each key in thepagersection as an executed key, and let only a git boolean value pass (true,false,yes,no,on,off,1,0, empty; no case sensitivity), sogit -c pager.diff=false diffstays ALLOWED. Addgit -c pager.log=/abs/h.sh logandgit config pager.log /abs/h.shas DENIED tests andgit -c pager.diff=false diffas an ALLOWED test, and namepager.<cmd>in the HOOKS.md key list.scripts/guard_hook.py:2519- The head commit gives the verdicts that the owner listed for round 14. I compared base, HEAD1 and HEAD in scratch repositories. ALLOWED on HEAD:1 to DENIED are option words that are not fully literal (sudo -upostgres psql,sudo -udeploy ls,sudo -ujenkins make,sudo -u "$USER" ls,sudo -h,nice -n,timeout infinity make,timeout 1e3 make,stdbuf -oL -eL python3 x.py,watch -tn1 ls, and about 60 other usual runner forms. DENIED on HEAD:sudo -uroot git -c core.fsmonitor=/abs/h.sh status,sudo -hlocalhost git -c ...,nice time -oN ls,nice time -pao N ls,nice time "-o" F ls,nice time -'o' F ls,nice time $'-o' F ls,nice time -\o N ls,sudo -ED dir touch N, and the quoted parameter option words. The only verdict changes from HEADsudo --user="$USER" ls,nice -n"$N" make,stdbuf -o"$M" make,watch -n"$t" ls); the owner's round-14 instruction requires them. A quote, comment or continuation in a different part of the command does not cause a false denial of a literal runner option. One limit stays:sudo -s '<command string>'andsudo -i '<command string>'are ALLOWED on base, HEAD~1 and HEAD, the same class as thewatchone-word form that HOOKS.md already documents as not checked. The new tests execute the guard and assert its verdict, so they obey the test-quality rule. I did not run the test suite.🔧 Fix applied.
3 infos still open:
scripts/guard_hook.py:262- The head commit gives the verdicts that the owner listed for round 15. I compared base, HEAD1 and HEAD, each with its own1:scripts/tree in a scratch repository that contains.project/archive/001-mvp. DENIED on HEAD and ALLOWED on base and HEADgit -c "pager.status=touch .project/archive/001-mvp/N" status,git -c pager.log=/abs/h.sh log,git -c PAGER.LOG=/abs/h.sh log,git config pager.log /abs/h.sh,git --config-env=pager.log=X log. ALLOWED on HEAD:git -c pager.status=false status,git -c pager.log=false log,git -c pager.log= log,git -c pager.log log,git -c pager.branch=false branch,git config pager.log,git config --get pager.log,git config --unset pager.log,git -c color.pager=false log,git -c user.name=x log. The("",)suffix makesconfig_key_inmatch each key that has thepager.prefix and a non-empty remainder; it does not matchcore.pagerxorcolor.pager. The HOOKS.md sentence agrees with the code. The new tests execute the guard and assert its verdict, and the DENIED cases fail on HEAD~1, so they obey the test-quality rule. I did not run the test suite.scripts/guard_hook.py:2811- For the owner's awareness: the head commit also changes these commands from ALLOWED (base and HEAD1) to DENIED, although git executes nothing for them:1 for the same reason). The round-15 instruction asked forgit -c pager.log=true log,git -c pager.diff=no diff(also0,off),git config pager.log falseandgit config --global pager.branch false. The cause is the existing model:GIT_CONFIG_DISABLING_VALUEScontains only the empty value andfalse, and the persistedgit configform accepts no disabling value (git config core.fsmonitor falseis DENIED on HEADthe existing disabling-value exceptions (bare key, empty value, false), so the code does what the owner specified; I report no defect.git -c pager.<cmd>=false <cmd>andgit --no-pager <cmd>stay ALLOWED as the supported forms.scripts/guard_hook.py:262-PAGER=/abs/h.sh git logandexport PAGER=/abs/h.sh; git logare ALLOWED on base, HEAD~1 and HEAD, whileGIT_PAGER=/abs/h.sh git logis DENIED on all three. git usesPAGERas the pager program whenGIT_PAGERandcore.pagerare not set, with the same terminal condition ascore.pager. I did not execute this form, I only read the guard verdict. This is not a regression and it is not a-c/--configkey, so it is outside the stated intent; a denial would also block the usual formPAGER=cat git log. No action in this change.✅ **Test** - passed
✅ No issues found.
git -c core.fsmonitor=<path> statuswith a literal value (from the project root, with an archive operand, and aftercdinto the archive); the guard denies it and names the keygit -c core.fsmonitor configures a program git executes. exploit_repro.txt shows that real git runs the program and writes into the…--config-env); the guard denies each one-ckey=, quotes,-C .first,/usr/bin/git,command/env/nice/sudo/timeoutrunners,sh -c, brace expansion, persisted…-c user.name=,-c color.ui=,core.fsmonitor=false, empty or bare key,pager.log=false,git config <key>read, plain runners, `git diff --…GSD_PATH_WORKTREE_ROOTwhile one linked worktree exists; the next sidecar stays in the pinned location (no re-root during a milestone)tests.test_worktree_placementalso passesGSD_PATH_WORKTREE_ROOTafter the last linked worktree retires; the next worktree is created below the new root<scratch>/moved;tests.test_worktree_placement.test_release_workspace_lets_a_new_root_apply_after_retirementalso passesReproduced the reported write on real git 2.54.0:git -c core.fsmonitor=<script> statuscreated.project/archive/001-mvp/PWNED.mdin a scratch repositorypython3 drive_guard.py <base project> <head project> <hook>: sent 54 Bash hook events on stdin toscripts/guard_hook.py, installed at<project>/scripts/in two scratch projects (base 565e40a9 and HEAD 9b36e3ab), and compared the verdictspython3 drive_placement.py <worktree> <scratch>: ranisolation.isolate_task,sidecar_rootandretireon a real scratch repository on branchgsd-path/M001withGSD_PATH_WORKTREE_ROOTchangedpython3 -m unittest tests.test_worktree_placement tests.test_guard_hook.GuardHookTests.test_denies_executed_config_keys_but_allows_plain_settings tests.test_guard_hook.GuardHookTests.test_allows_parameters_in_read_only_git_arguments(11 tests, OK)✅ **Document** - passed
✅ No issues found.
✅ **Lint** - passed
✅ No issues found.
✅ **Push** - passed
✅ No issues found.