Skip to content

fix(shell): compound read-only probes behind the operand gates - #126

Open
asto18089 wants to merge 3 commits into
fix/shell-stdin-eoffrom
fix/live-test-findings-subagent-shell
Open

asto18089 wants to merge 3 commits into
fix/shell-stdin-eoffrom
fix/live-test-findings-subagent-shell

Conversation

@asto18089

@asto18089 asto18089 commented Oct 27, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

No-Issue: issues are disabled on this repository; the defects below were reproduced from a live multi-agent capability-test session transcript (2026-10-27, parent and children on deepseek/deepseek-flash, macOS arm64 / zsh; 33 test cases, 10 subagent instances; every material claim in the transcript was re-verified parent-side with independent reads and command runs). Each fix carries red/green tests; the load-bearing pins are mutation-verified. Eighteen adversarial review rounds found and closed the workspace-boundary gaps enumerated below — a cd symlink escape, a cd/gh pipeline-stage escape, glob operands expanding over outside symlinks, a gh network-policy bypass, a cd wildcard the shell expands differently from the gate, .* expanding through .., an unbounded in-host glob walk, a second sed -e script, a zsh =cmd operand, and (round 4) a cd onto a regular file, =-bearing and whitespace-bearing operands judged under the wrong name, a glob budget that was per operand and blind to zero-match scans, repository textconv executed by shell-lane git stages and by git blame on both lanes, and two network ceilings keyed on the whole command, and (round 5, a fresh 17-lane review with a code-executing red team on zsh/bash/sh/dash) a Windows path normalization that ran on unix and let a \\?\ spelling carry a live ? glob past the charset gate, the operand gate's own verbatim-prefix strip on unix, a cd =cmd spelling zsh reinterprets, a cd onto a directory the shell cannot enter, zsh's recursive **/, cmd.exe %NAME% expansion, and option words whose glob the shell expands whole — and two authority gaps in fix 3 (an over-wide re-key, and gate execution obtained through a read-only role parent via delegation), and (round 7, a fresh 12-lane review with a code-executing red team) a repository-declared git content filter executing under admitted git status/git diff/git blame — outside content crossing and attacker-chosen commands running under the branch's own child env — and a gh stage whose glob the shell expands into the invocation's HOST/OWNER/REPO position at runtime, past a host-scoped network policy, and (round 11, a fresh 11-lane review) a .git gitfile consumed as one line while git reads the whole value — interior newlines naming a gitdir whose content filters executed under an admitted read — and a git -C operand judged as a literal spelling while the shell expands it, the chained git -C x -C * shape binding the second -C to an outside symlink and rendering an outside repository's committed content — and (round 12, a fresh 12-lane review) a strip model one byte short of git's — the .git/commondir link value's trailing \r/\n run is stripped in any order and count, not one \n plus one \r, so a second trailing newline sent discovery to a decoy-or-nothing scan while the hostile repository's content filter ran under the admitted read — and a glob .. synthesis judged at the lexical parent while dash resolves the expanded .. physically through a followed directory symlink, reading an outside file the gate called inert — and (round 13, a fresh 13-lane review) a .git DIRECTORY that returned from the walk-up unvalidated while git's own discovery skips a directory failing is_git_directory and keeps walking — one empty directory between the stage root and a hostile repository sent discovery to a scan-nothing admit while the ANCESTOR's content filter executed under the admitted read (including a bogus dir at the workspace root with the hostile repository above it, reachable by a workspace-scoped writer alone) — and a gitdir validity check approximating git's predicate in both directions — admitting gitdirs git fatals on (garbage HEAD text, a missing refs/) while refusing gitdirs git runs (a dangling-HEAD symlink) under a false message — and a .git link value read past the first NUL byte where git's C-string read ends it; and (round 18, a fresh 11-lane review with an executing red team) an absolute glob operand whose literal spelling canonicalized inside the workspace while the shell's expansion matched an outside symlink (executed leak), a #-comment tail the gate's tokenizer dropped while the cmd.exe lane — which has no comment syntax — passed it through as live unjudged argv, an ungated test helper that went dead-code on the windows test lane under -Dwarnings (required check red), and the Windows/ohos interact write that could wedge the session's shell lock indefinitely — and (rounds 14–17, spanning the pre-split rounds) the walk-up-symlinked-gitfile / ref:-control-char / commondir-NUL escapes, the post--- -a=b re-arm and the planted --ext-diff re-arm, the git-stage-glob bypass and the cmd ,/= delimiters, and the continued-core.worktree relocation — and (round 19) the quoted-escape-decode core.worktree relocation — every known one is fixed and pinned in this slice's tree (the fix-3 authority pair and the docs hunks are slice 3's — see Split), see "Review rounds" below.

Topic-disjoint from #119 (merged), #123, #124, #125. Shared-file hunk positions against the other open PRs are re-swept every round and recorded under "Merge order" below (as of round 20: #123 and #124 have merged to pinvou3-clean and this branch's stack has been rebased onto them — the round-20 lane verified the rebase faithful, see the Split-time note; #136's conflict shapes stay under "Merge order").

Split

This PR is a slice of the original #126, split along its three named fixes (2026-11-04; the full pre-split content is preserved branch-exact on archive/p126-pre-split-8705ccd85, head tree 8705ccd852052):

Slice PR Content Stacks on
1 #139 stdin EOF for foreground/sync runs, bounded writes, verifier gate-job EOF pinvou3-clean
2 this PR compound read-only probe lane: grammar, operand gate, glob walker, git-discovery hardening #139
3 #140 the test role's honest contract + probe lane this PR — base-follows this PR's head (it re-aligned onto the round-20 head mid-round-21; it must follow the round-21 wave head before it merges — see Merge order)

The 18 review rounds below reviewed the combined tree (f1eb10efc..baf9ce81c); every finding is re-anchored to the slice that owns it, and each slice re-ran its own gates (receipts under Testing). The three slices cannot be reordered: 3 depends on 2's probe-lane admission for the Verifier, and 2's seams build on 1's stdin lifecycle contracts.

1. stdin EOF for foreground and sync commands — moved to slice 1 (#139): the fix, its pins, the guidance sentence, and the verifier gate-job EOF live there; this PR's seams assume its stdin contracts.

2. The bounded read-only grammar refused compound probes (;, &&, cd … && …, od)

Live evidence: read-only children were refused wc -c f; wc -l f (sequence), cd dir && wc -c f, and od -c f, with [shell.readonly.command]; planner and explore both burned steps rewriting probes around the refusals (the transcript records the rewrites; #123's transcript separately recorded a 13-retry loop against the old one-line refusal).

Fix (classifier, command_safety.rs):

  • compound sequences a; b, a && b, a || b — admitted iff every segment individually passes the same is_agent_readonly_segment gate, via a new shared splitter readonly_sequence_segments (None on a bare &, which still refuses; empty segments refuse; the split is deliberately naive like the existing | split, and a quoted-separator mis-split that still classifies is refused at the seam by the operand gate's stricter parse);
  • cd <one relative path> — at any position in the sequence, never a pipeline stage (a stage runs in a subshell and persists nowhere the gate could follow), no flags/../absolute/~/=/wildcard, with one shape rule (readonly_cd_operand_admitted) shared by the classifier and the execution seam; the operand is additionally canonicalized against the workspace at the execution seam (same [shell.readonly.cwd.outside_workspace] refusal family); at most 32 sequence segments, since the seam resolves one cd per segment against the filesystem;
  • read-only od dumps — display radices -b -c -d -o -s -x -h, -v, -A restricted to d|o|x|n|none, numeric -j/-N only, -- handled; piped in either direction;
  • clustered short options in the text-filter grammar (cut -d, -f1, comm -12, sort -rn, sort -k2) — expanded before the exact match, and only when every letter is an admitted switch or the first letter an admitted value option; the parent's parallel classifier does not use this grammar.

Execution (shell.rs): compounds route through the shell lane pipelines already use (pipefail prefix only when a pipe is present); a command whose leading segment is cd also takes the shell lane — cd is a builtin and most Linux hosts ship no /usr/bin/cd binary, so the direct-argv lane would have failed an admitted probe with a not-found exec error (macOS happens to ship one, which is why the router is pinned host-independently). On Windows, cmd.exe treats ; as an argument delimiter, so the seam refuses ;-sequences for a resolved cmd shell ([shell.readonly.sequence.unsupported_shell], a pure per-shell-kind decision pinned on every host; PowerShell runs them; &&/|| parse on PowerShell 7+, and the Windows PowerShell 5.1 parse error is a safe nothing-ran failure — precision fixed in round 13, whose predicate's own doc concedes the 5.1 case). The operand-containment gate splits with the same shared splitter and judges every sequence segment and, inside a segment, every pipeline stage on its own: the gh exemption applies to a gh stage only (gh …; ls /etc and gh … | cat /etc/passwd are operand-checked where the old whole-command parse skipped everything), a cd stage is refused ([shell.readonly.cwd.pipeline_stage]), and a * operand — the only wildcard the charset admits — is expanded against the live working directory the way the shell expands it (component by component, hidden entries only when asked for, symlinked directories followed) and every match is judged by its resolved location. The gh network-policy guard judges the same stages, so a gh read anywhere in a compound consults the decider exactly like a bare one — and since round 4 the no-network subagent pre-check and the fleet envelope pre-filter consult that same per-stage predicate (command_reaches_github_readonly). Git log/show/diff/blame stages on the shell lane receive the same in-process hardening flags the direct-argv lane splices (--no-ext-diff --no-textconv [--no-show-signature], --no-textconv for blame), re-emitted from the argv the gate parsed with the separators copied through byte for byte (round 4).

Review rounds (each escape proven end-to-end under ShellPolicy::ReadOnly against the unfixed code, then fixed and pinned red/green):

  • Round 1 — cd sub; cat link, with sub/link a symlink to an outside file, read the file because every segment's operands resolved against the dispatch-time cwd. The gate now folds the drift left-to-right: after an admitted cd, later segments resolve against the canonicalized destination, exactly where the shell resolves them (readonly_operand_gate_refuses_relative_operands_after_cd_cwd_drift, readonly_operand_gate_keeps_admitting_operands_under_cd_destination).

  • Round 2 — cd sub | cat /etc/passwd returned the file: the gate judged a segment by its first program, and a cd (or gh, a gap that predates this PR) stage returned early with every operand after the | unchecked, while the shell ran the stage in a subshell. The classifier now refuses cd as a pipeline stage and the gate judges stages one by one (agent_readonly_shell_rejects_unsafe_compound_cd_and_od_shapes, readonly_operand_gate_judges_every_pipeline_stage_on_its_own).

  • Round 2 — cd sub; cat * returned an outside file through an in-workspace symlink: the literal spelling * exists on no disk, so the operand check skipped it (the pipeline form cat * | head leaked the same way before this PR). Glob operands are now expanded and judged match by match (readonly_operand_gate_judges_glob_expansions_by_resolved_location); both rounds are also pinned through the tool (readonly_shell_refuses_pipeline_cd_and_glob_escapes_end_to_end).

  • Round 2 — pwd; gh repo view never consulted a Deny network policy because the guard classified the whole command as "not a gh read". It now judges every stage (readonly_github_shell_calls_obey_the_host_network_policy_before_spawn).

  • Round 3 — cd cdt*; cat secret.txt read outside on bash/sh: the seam canonicalized the operand spelling (a literal cdt* directory) while the shell entered the first glob match, a sibling symlink pointing out. cd is now admitted only as one relative path — a wildcard in the operand refuses at the classifier (agent_readonly_shell_rejects_unsafe_compound_cd_and_od_shapes).

  • Round 3 — cat .*/secret.txt read outside on bash/sh: sh/dash expand .* to . and .. (modern bash and zsh do not), and read_dir never yields them, so the walker never saw the parent directory. It now synthesizes both candidates at every level a dot-component matches and judges them by resolved location (readonly_glob_walker_judges_the_dot_and_dotdot_candidates).

  • Round 3 — the glob walk ran in the host process before any child existed to time out, and an ancestor-pointing directory symlink made a deep pattern re-enter the same directory exponentially (10.4 s measured at pattern depth 14). Matches are judged as they are produced, re-entries deduplicate by (directory identity, remaining pattern length), and match/node caps (4096 / 50 000) refuse instead of exploring; the gate runs on the blocking pool (readonly_glob_walk_is_bounded_by_the_match_and_node_caps).

  • Round 3 — gh pr list --search '*' and pwd; gh repo view * skipped a Deny network policy: the guard's per-stage predicate reused the whole-command parallel classifier, whose charset refuses * while the agent grammar admits it. The stage check is now token-level (github_stage_predicate_sees_glob_operand_stages, plus the compound loop in readonly_github_shell_calls_obey_the_host_network_policy_before_spawn).

  • Round 3 — sed -n 5p -e w/tmp/owned Cargo.toml was admitted end to end: GNU sed parses a later -e as a second script (write/read/exec outside the workspace); BSD sed fails closed. Every token after the script must now be a plain input file (agent_readonly_shell_rejects_mutation_and_injection); multi-file reads stay admitted.

  • Round 3 — cat =ls | wc -l read the resolved absolute path of ls on zsh hosts (default EQUALS expansion) while the operand gate skipped the spelling as a nonexistent relative value after its =-split. =cmd operands now refuse outright, before any shell runs (readonly_operands_are_workspace_bounded_and_symlink_aware).

  • Round 3 (authority — moved to slice 3 (fix(fleet): the test role states its real contract #140): the registry carve-out fix and both pins named below live there; this slice's tree contains none of it) — a Verifier child of a read-only role parent (Scout/Reviewer/Planner) kept the built-in Run surface and executed cargo test where the base refused it at [role.posture.denied]: the carve-out keyed on the role alone, so the read-only shell a parent clamped onto the child still unlocked the gates. The registry now records whether the child's shell was full after the parent clamp but before the role's own narrowing, and only such a Verifier keeps the gates — delegation moves work, never authority (clamped_verifier_child_of_read_only_parent_holds_no_run_surface; verifier_child_keeps_the_default_and_selection_bounded_run_surface still pins the direct case, and the fleet ceilings and shell-less sessions are unchanged).

  • Round 3 — the zh FLEET copy promised fleet members the classifier-gated bash probe lane; the fleet envelope defines bounded verification and Bash authority as mutually exclusive (normalized()), so the sentence now scopes the lane to the agent nesting path. Round 3 also aligned the model-facing routing description (sequences and cd-prefixed commands take the shell lane; a standalone cd affects only its own command), qualified the foreground-stdin guidance sentence to the non-tty lanes, scoped the POSIX pipefail prefix and the cmd.exe ; refusal into pure per-shell-kind decisions (cmd/PowerShell no longer receive the prefix at all), admitted gh -q spellings, added the 32-segment cap to the refusal copy, restored the zh verifier bullet the English update left behind, and pinned the previously-masked behaviors (piped-cd tag, live-cwd fold, hidden-entry exclusion, dangling glob matches, guidance sentence, unix gating).

  • Round 4 — cd notes.txt; cat link (a regular file as the cd destination) read an outside symlink at the root on bash and zsh: the seam canonicalized the file and folded later segments' cwd onto it, every later relative operand skipped judgement at the exists() miss under notes.txt/…, while the shell's cd failed and ;/|| continued in the original directory. The destination must now be a directory ([shell.readonly.cwd.not_a_directory]; readonly_operand_gate_refuses_cd_onto_a_regular_file).

  • Round 4 — the gate judged only the text after an operand's first =, so cat ax=b.out (an outside symlink by that name) was judged as the inside decoy b.out on the direct lane too, and the glob a*=b* was walked as b* while the shell matched ax=b.out. Only --prefixed tokens split now; a bare operand is judged as the whole word (readonly_operand_gate_judges_equals_bearing_operands_as_whole_words). The gate also trimmed every spelling while the program opened it untrimmed — cat ' x ' read an outside symlink named x — so operands are now judged byte for byte as parsed (readonly_operand_gate_judges_the_parsed_spelling_without_trimming).

  • Round 4 — the glob caps were per operand: a ~1 KB command of 500 * tokens over a 2000-entry directory bought ~25 s of admitted host CPU, and a zero-match pattern (zz*) moved neither counter while still scanning every entry (8000 of them: 8 s). One ReadonlyGlobBudget per command now counts matches, entered nodes, and examined entries (4096 / 50 000 / 500 000) across every segment, stage, and operand (readonly_glob_budget_is_shared_across_operands, readonly_glob_budget_counts_examined_entries).

  • Round 4 — the shell lane handed the shell the raw compound, so git show HEAD | cat and pwd; git show HEAD executed a repository-configured diff.<driver>.textconv (a marker was written outside the workspace) while bare git show HEAD was spliced on the direct lane; the GIT_* environment hardening pins global and system configuration, not the repository's. And git blame applies textconv by default yet was in the no-splice set on both lanes. One splice table (splice_git_read_hardening) now serves both lanes, and harden_readonly_shell_git_stages rewrites every Git log/show/diff/blame stage before the shell binds the compound, refusing a stage whose token cannot be re-quoted portably ([shell.readonly.git.unquotable_stage]) rather than running it unhardened (readonly_shell_lane_hardens_git_diff_rendering_stages; end-to-end readonly_shell_lane_git_show_does_not_run_repository_textconv, whose fixture is proven live by raw-shell controls for both show and blame first).

  • Round 4 — reject_network_reaching_input (a child with network_tool = false) and the fleet envelope pre-filter still used the whole-command gh predicate and passed pwd; gh repo view and gh pr list --search '*'; both now consult the per-stage command_reaches_github_readonly, the whole-command is_github_readonly_command is deleted, and the three inlined | splits are one readonly_pipeline_stages (compound cases added to a_network_denied_child_cannot_address_a_remote_location_through_any_tool; command_reaches_github_readonly_recognizes_gh_reads_per_stage). Round 4 also pinned the seam's fail-closed handling of quoted separators (readonly_operand_gate_fails_closed_on_quoted_separators), sized the match-cap fixture by a literal so a raised cap reddens the pin instead of hanging it, qualified the routing description for cmd.exe ;-sequences, interpolated the segment cap into the refusal copy, and scoped the Verifier roster promise to in-session delegations (the fleet durable-execution path grants that role no bash lane).

  • Round 5 — pwd; cat a\\?\b read a planted outside symlink named a\?b on zsh, bash, sh, and dash: normalize_windows_command_paths stripped \\?\ before the charset gate refused ? and doubled backslashes of Windows-looking words, on every host, so the gate judged pwd; cat ab (a nonexistent name, skipped) while the POSIX shell parsed the raw text and expanded a\?b as a one-character glob; the operand gate's own verbatim-prefix strip had the same shape (cat \\.\secret judged as secret, the program opened the real ten-character name — the direct lane too). Both transforms now run only when compiled for Windows, where Path::canonicalize genuinely yields those spellings; on unix the spellings are plain POSIX file names and fail closed at the charset and shape gates (windows_path_normalization_stays_off_unix_lanes, agent_readonly_shell_refuses_verbatim_spellings_unix_lanes; the Windows-side admission pin is now #[cfg(windows)]). The Windows lane itself is unchanged and its judged-vs-run divergence for verbatim spellings was not exercised here (no Windows host).

  • Round 5 — cd =ls; cat link on zsh: the cd rule refused flags, \, *, ~, absolute and .. but not =, and the seam's cd arm never ran the operand gate's = refusal, so zsh's EQUALS expansion made the shell's cd /bin/ls fail and ;/|| continue at the root while the gate had folded onto a literal =ls directory. The cd operand shape rule is now one function shared by both layers, refusing =-prefixed and empty operands (readonly_operand_gate_refuses_cd_operands_the_shell_reinterprets, [shell.readonly.cwd.shape]).

  • Round 5 — cd vault; cat link with a mode-000 directory, on every POSIX shell: canonicalize and is_dir need search permission only on the ancestors while chdir needs it on the destination, so the round-4 directory check passed and the fold lied again (commit c42312a's "TOCTOU race" framing was wrong — a permission-denied cd is deterministic). The seam now proves the destination enterable by resolving <dir>/., exactly the permission chdir needs, without listing it ([shell.readonly.cwd.unsearchable]; readonly_operand_gate_refuses_cd_destinations_the_shell_cannot_enter, end-to-end readonly_shell_refuses_unsearchable_cd_destinations_end_to_end). The race between that probe and the shell's cd is the residual no pre-spawn check can close.

  • Round 5 — pwd; cat **/*.txt on zsh read a depth-two outside symlink: the walker consumes one directory level per component (bash without globstar, sh, dash agree) while zsh expands **/ to every depth. A **/*** component followed by more pattern is refused; a trailing ** is a plain * everywhere and stays admitted. The walk is also capped at 32 components, since every .* level enters the synthesized .. and the recursion depth was otherwise unbounded (readonly_operand_gate_refuses_recursive_glob_components).

  • Round 5 — the shell pathname-expands an option word whole, so --exclude-from=zz* matched a planted --exclude-from=zzq while the gate walked only zz*; an option carrying a wildcard is now judged by its full spelling as well (readonly_operand_gate_judges_dash_prefixed_glob_operands_as_whole_words; the shape is not reachable through the current grammar, which admits no --opt=value option — defense in depth at the seam).

  • Round 5 — the set -o pipefail prefix was prepended for every non-Windows kind including Custom (any $SHELL) and Sh; /bin/sh is dash on Debian/Ubuntu and dash exits the whole -c payload on it (set: Illegal option -o pipefail, status 2), so every admitted pipeline and — because || contains | — every || sequence ran nothing there. The prefix is now keyed on the executable's file name (bash, zsh, ksh family); other binaries run unprefixed and report the last stage's status, which is reporting fidelity, not a boundary. cmd.exe additionally refuses any %-bearing compound (it expands %NAME% before any program sees the word — the twin of zsh's =cmd), and %/^ moved out of the Git stage re-quoter's bare set (readonly_shell_lane_decisions_per_shell_kind now covers /bin/sh, dash, bash, zsh, and a % compound on cmd). The round-3 claim that the prefix had been "scoped into pure per-shell-kind decisions" was keyed on the wrong thing.

  • Round 5 also: the interact refusal said "already at end-of-file" for every task without a writable stdin, which is true only when a stdin pipe was closed (not for PTY/combined_output, persistent, or finished tasks) — the task now records whether a pipe was really closed and states the accurate reason; the verifier's background gate jobs get end-of-file right after spawn instead of idling on an open pipe until the 600 s gate timeout (no dedicated pin — the gate harness has no stdin-reading gate fixture); the operand-gate refusals name the "read-only shell" rather than a Scout shell the Verifier does not hold; the [shell.readonly.command] copy states the enter rule and the new refused shapes and is now pinned on its load-bearing claims; the fleet envelope's Bash admission is the parallel classifier, so compound gh reads fail closed there with or without a network grant (pinned — compound probes are an in-session agent capability, not a fleet one); agent_readonly_command_needs_shell_for_cd's doc and both SUBAGENTS docs say cd is admitted at any sequence position; two gh predicates lost an unneeded pub(crate).

  • Round 6 (fresh 11-lane review) — the cwd fold ignored sequence connectors: an admitted cd folds unconditionally, but a cd joined by &&/|| runs only when the predecessor's exit status allows it, so cat nope && cd sub; cat link (and true || cd sub; cat link) left the shell at the original directory — where a planted root symlink awaited — while the gate judged link under sub/ and skipped it at the exists() miss (proven live on bash and sh through the tool; the third member of the fold-lies family, the cd-NEVER-RUNS twin of round 4's cd-FAILS cases). The splitter now carries each segment's connector (readonly_sequence_segments_with_connectors); the classifier refuses a conditionally-gated cd statically (agent_readonly_shell_rejects_conditionally_gated_cd) and the seam re-checks it at the fold ([shell.readonly.cwd.conditional]; readonly_operand_gate_refuses_a_cd_its_connector_may_not_run, end-to-end readonly_shell_refuses_short_circuit_cd_escape_end_to_end); the refusal copy names the shape. A cd its connector always runs (first segment, or joined by ;) still folds, so the advertised cd dir && wc -c f and the ;-drift pin are unchanged. Also from round 6: cmd.exe consumes a bare ^ as an escape before any program sees the word (cat a^b | wc -c opens ab, a spelling the gate skipped as nonexistent), so readonly_compound_refused_by_shell_kind now refuses ^-bearing compounds for Cmd next to % and drops the un-actionable PowerShell advice from that copy; the generic stdin refusal states accurate reasons per state (a terminal command exposes a writable stdin only while it runs and until its stdin is closed — the old copy claimed combined-output commands never expose one); the dot-glob ./.. synthesis is attributed to sh/dash (modern bash does not expand them — comments and the walker test doc); the genuine two-cd fold gained a discriminating pin (cd sub; cd deep; cat link — the positive control was previously non-moving and did not bite); the gh stage's sequence-form operand exemption is pinned (gh issue list; cat <outside>); the runtime-reachable pwd; cat 50%off.txt shape of the cmd % refusal is pinned (the old pin used a single command that routes direct-argv); the gate's own piped pass refuses an empty stage; ZDOTDIR/GLOBIGNORE join the cleared startup env; the GIT_CONFIG_COUNT block documents its git >= 2.31 floor; the -truncate doc line on find's mutating-primary list is now claimed (pre-existing refusal, doc-only); the zh FLEET doubled bracket is fixed; the SUBAGENTS cd sentence (en/zh) scopes "any position" to positions the connector always runs.

  • Round 7 (fresh 12-lane review) — two boundary gaps found and closed here. (1) Repository content filters: a worktree .gitattributes assigning filter=<name> plus repo config defining filter.<name>.clean makes that command run (file content on stdin) whenever an admitted git status/git diff/git blame converts worktree content — under the branch's own child env, outside content crossed onto the command's output and attacker-chosen commands executed (first-hand reproduced; the round-4 --no-textconv/--no-ext-diff splice addresses a different mechanism and the GIT_CONFIG_COUNT block pinned no filter; git status is in the no-splice arm). The lane now discovers every filter the repositories a stage can reach declare — mirroring the operand gate's cd fold, so a nested repo entered by cd is scanned too, and resolving each stage's -C preamble exactly as git applies it (git -C sub status runs against sub's repository without the shell moving; readonly_filter_discovery_follows_the_cd_fold_into_nested_repositories pins the fold, the -C redirect, and the interleaved --no-pager/-C walk) — and pins each declared conversion key to an inert helper: filter.<name>.clean/smudge → cat, filter.<name>.process → the inert helper, whose protocol handshake then fails loudly instead of running the configured command. Only keys the repository itself declared are pinned (introducing a process key git would not otherwise see breaks a working clean/smudge filter), and anything the scan cannot confidently enumerate — config include directives, the worktree-config extension, unspellable filter names, more than eight names — refuses ([shell.readonly.git.filter_config]). An attribute naming an undefined filter is inert (git skips the conversion, probed), so the config scan is complete; aliases cannot shadow the admitted subcommands (probed). End-to-end readonly_shell_lane_git_status_does_not_run_repository_content_filters with a raw-shell positive control and unit pins for the discovery walk (readonly_filter_discovery_pins_declared_filters, readonly_filter_discovery_refuses_configs_it_cannot_enumerate, readonly_filter_discovery_follows_the_cd_fold_into_nested_repositories). (2) The operand gate's gh stage exemption was glob-blind: pwd | gh repo view */*/* is admitted, and the shell expands a planted workspace entry into the invocation's HOST/OWNER/REPO position at runtime — a host-scoped network-policy bypass, since the guard evaluates only the hardcoded api.github.com and gh honors -R/positional hosts over the pinned GH_HOST (proven live without external contact via a .invalid host). A gh stage now refuses any wildcard after the program word (readonly_operand_gate_refuses_glob_operands_on_gh_stages; the exemption keeps holding for literal operands). Also from round 7: cd +N refuses (zsh's directory-stack selection reinterprets the spelling while the fold assumes entry — the + twin of round 5's =); the flags the git hardening splices (--no-ext-diff, --no-textconv, --no-show-signature) are admitted in the option tables they are spliced into, so a defensive spelling no longer loses the whole probe; gh -R ../foo refuses like ../../x already did; od -j/-N values must parse as u64 (a 200-digit spelling used to admit); the read-only grammar bounds command text at 64 KiB before its linear passes allocate; the glob walk gained a 10 s wall-clock deadline ([shell.readonly.operand.glob_deadline]) so a cold or network-backed filesystem cannot stretch one expansion past any watchdog (no child exists yet for the timeout to end); interact/cancel/wait are policy-checked before the action dispatch — a read-only shell holds no task surface, and a task addressed under the policy could belong to another agent in the session (readonly_shell_refuses_task_actions_at_the_policy); the guidance states the combined_output foreground stdin contract; the interact EOF message states the closed-pipe reason neutrally (an operator close_stdin produced the foreground wording); the en SUBAGENTS cd sentence lost a duplicated phrase (introduced by round 6's edit; the zh twin was already clean); the two-cd fold pin's doc now describes its discriminating cases accurately; the Verifier's legacy-Bash posture wall is pinned Verifier-labeled; and the fleet executor comment distinguishing the Run-only executor path from the registry's bounded lane is claimed here.

  • Round 8 (fresh 14-lane review: 12 static lanes, one test-execution lane, one mutation re-derivation lane) — the round-7 filter discovery was incomplete; five gaps each restored the original defect under the fix's own env, probe-verified on git 2.43 before fixing: (1) filter names merged case-insensitively (eq_ignore_ascii_case) while git matches an attribute value against a config subsection byte for byte, so a case-variant driver ran unpinned — names now dedupe byte-exactly and each spelling gets its own pins; (2) keys on a section-header line ([filter "x"] clean = v as one physical line) were skipped while git parses them, and the same spelling carried include/worktreeConfig past the header-level walls — the header remainder now parses as a key; (3) value continuation keyed on any trailing backslash silently swallowed whole [filter] sections — behind a backslash-terminated comment (git ends comments at the newline) and behind an escaped-backslash value (\\ ends a value, an odd count continues) — continuation now keys on the odd trailing-backslash count and comments never continue; (4) a .git link or commondir pointer holding an undecodable byte silently reported "no repository" while git parses bytes and still runs that repository's filters — both now refuse, and every discovery read (link, pointer, config, .gitmodules) is size-bounded before allocation; (5) submodules were never enumerated: git forks its own git status/git diff child into each initialized submodule and that child converts content under the submodule's own repository config — probe-pinned it fires on a mtime-only touch while the superproject reports a clean tree, and the env pins travel into that child intact, so enumeration is the whole fix — .gitmodules-registered submodule repositories now scan (recursively through nested registrations, bounded by a repository cap), and registrations the lane cannot resolve byte-faithfully refuse. Also from round 8: core.worktree — the next member of the same repo-config family — admits only when it resolves back to the worktree the repository was discovered in (the absorbed-submodule spelling git itself writes: worktree = ../../../sub relative to the absorbed gitdir) and refuses redirects; filter pins are gated on a git >= 2.31 version check exactly when they are armed (on older git the env block silently does not exist, and for status/ls-files/grep those pins are the only wall); the per-config 8-pin cap now fires during the parse instead of after every repository is scanned, bounding a crafted config to linear work; gh reads a three-component operand as HOST/OWNER/REPO, so single-label hosts (gh repo view myhost/o/r, resolvable through a DNS search domain) refuse like dotted ones already did; the 64 KiB command bound fires before the Windows path normalization allocates, and non-unix glob identity keys are byte-bounded; the -c-outranks-env-pins precedence — the classifier's refusal of -c-bearing git stages is load-bearing for the whole env-pin family — is recorded at the pin site with its own refusal pin; the empty process-override comment now states the probed behavior (it does take on current git, silently disarming the filter — the /usr/bin/false helper stays the honest wall); the filter E2E is unix-gated like its textconv sibling and gained a raw-shell git status control on a size-preserving fixture (size-differing dirt is decided from stat alone and never converts — the old fixture's status arm was vacuously green), and the worktree fixture now follows a real gitdir-link + commondir layout (the old one never executed the branches it claimed); the submodule twin E2E pins the new enumeration end to end, with the raw-shell control proving the submodule filter fires from the superproject before the pins hold.

  • Round 9 (fresh 14-lane review: 12 static lanes, one test-execution lane, one mutation re-derivation lane) — the round-8 parser still diverged from git's real config grammar in seven first-hand-proven shapes, each restoring unpinned filter execution under an admitted read; every one is fixed here against git 2.43 probes: (1) dotted section headers ([filter.dotted], [FILTER.EV], [filter.a.b]) armed real drivers the parser treated as inert two-part keys — the dotted form case-folds the subsection and splits at the first dot, and [filter.x "y"] concatenates; headers now parse in all four git shapes; (2) two section headers on one physical line re-target the section at the second [ while the parser pinned the decoy — the remainder after ] now loops; (3) a value followed by a trailing comment ending in an odd backslash over-continued (git ends the value at the unquoted comment; the backslash inside it is dead) — comments are now cut quote-aware with quote state carried across continuation lines before backslash parity is computed; (4) duplicate core.worktree validated the first value while git applies the last — the last is validated; (5) submodule enumeration keyed on .gitmodules registration although git forks its dirty-check child into ANY index gitlink holding a repository (probed: with .gitmodules deleted from the worktree but still in the index, git status fired the submodule's filter) — enumeration now runs the hardened git ls-files --stage -z (cleared env + the same sanitized PATH the child gets, capped stdout, unresolvable paths refuse) and .gitmodules is no longer consulted; (6) inline ;/# value comments were kept where git strips them (.gitmodules path = sub ; note decoded to sub upstream while the scanner joined a nonexistent directory) — subsumed by the comment-cut rewrite; (7) a leading UTF-8 BOM (which git skips) hid the entire first section — now stripped. Also from round 9: core.worktree validation is skipped for linked worktrees (a commondir gitdir resolves its worktree from the back-pointer and git ignores the key there — probed, so the old unconditional validation refused legitimate submodule worktrees); .git pointer files parse byte-exactly (literal gitdir: + exactly one space, byte-exact first-line value; the shapes git fatals on refuse); the operand gate judged git -C operands flat against the command cwd, so a chained git -C A -C escape status through a workspace symlink landed in an outside repository the flat spelling refuses — gate and discovery now share one cumulative -C root resolver (readonly_git_stage_root), and the false "every -C operand was already canonicalized" comment is gone; the git version probe ran under the host environment and PATH — it now uses the child's cleared env + sanitized PATH (a planted shim in an agent-writable PATH directory is never the probed binary; pinned), and because the fixed core.fsmonitor/hooksPath env pins are written for every read-only git stage, the git >= 2.31 gate now fires for every git stage, not only filter-armed ones; ZDOTDIR is pinned to / (an empty value lets zsh fall back to ~/.zshenv, whose operator options can widen admitted operands); the foreground stdin EOF write precedes the heavy-permit attach so no future fallible step can leak an open write end; od admits the glued -An/-j<N>/-N<N> spellings; the gh wildcard refusal copy and the sed separator comment state their real reasons; VERIFIER_AGENT_INTRO scopes the read-only shell to in-session delegations; the newline-in-command and symlinked-cd-fold behaviors gained pins; and the money E2E now declares a second filter through the dotted spelling (the raw-shell control proves git arms it; the tool leg proves the lane pins it). The round-10 wave (fresh 12-lane review: 10 static/probe lanes, one test-execution lane, one mutation re-derivation lane) — the discovery machinery itself ran repository config unpinned: a hostile repo's core.fsmonitor command executes inside the enumeration's own git ls-files --stage -z on git 2.43 (probe-pinned; an empty index suffices), on a child spawned directly by the tool process — full host authority, pre-admission, and a command later refused at bind time has already run its discovery execs. Every discovery git invocation now carries the fixed argv pins (-c core.fsmonitor=false -c core.hooksPath=<null> -c log.showSignature=false), which outrank every config file and need no version floor — so the version gate no longer has to run before the first discovery exec; the raw-shell control proves the hook fires under a plain git invocation and stays silent under the lane, at the discovery level and through the tool. Also from round 10: a -C ~ operand was judged by its literal spelling while the shell expands ~ against $HOME (with a planted literal ~ directory the resolver admitted a stage git ran outside the workspace — a PR-introduced regression; base judged every token and refused); the cumulative root resolver now refuses tilde spellings like the plain-operand and cd rules, and proves each -C destination enterable the way the cd fold does (a mode-000 destination passes canonicalize+is_dir and fails the child's chdir). A FIFO at a discovered metadata path (stat reports size 0, so a size cap never fires) blocked open until a writer appeared inside the pre-spawn discovery, where no watchdog bounds anything — pointer files and configs are now read through one bounded reader: non-blocking open on unix, the regular-file check and size cap on the opened handle's own metadata, refusal for anything else (the scan reads each repository's config before spawning its enumeration child, so the git-side block on a FIFO config is unreachable too). The .git link strips exactly the one trailing \r git strips at the newline (a CRLF-terminated link resolves and runs there — probed — while every other padded spelling still refuses); the commondir value is byte-exact with the same strip and must resolve (a padded spelling refuses instead of silently reading a config git never opens). Index gitlink paths carrying "/\\ are ordinary filename bytes consumed in-process on unix — scanned and pinned now, where the Windows-quoting reasoning refused them; Windows keeps the refusal (separator syntax there). A single glob-bearing command takes the shell lane — the direct-argv lane handed the program the literal *, so the expansion the operand gate walked could never happen; glob-free singles keep the direct lane. Inline stdin payloads past one pipe buffer (64 KiB) refuse before spawn: delivery is a blocking write under the shell-manager lock, and a non-reading child wedged the executor past any watchdog (the disclosed >64 KiB stall, now closed at the boundary — the schema advertises maxLength and the refusal points at background interact streaming; the read-only grammar keeps rejecting a stdin field outright). od -j/-N accept GNU's multiplicative suffixes with checked arithmetic (-N4k), and head/tail admit GNU's glued numeric value spellings (-n5, -c3) through the row-shared option tables — the parallel auto-approve classifier shares those tables, so its admitted set grows by exactly these spellings, pinned deliberately (the round-4 git hardening flags --no-ext-diff/--no-textconv/--no-show-signature ride the same shared tables; that earlier undisclosed growth is now pinned too). The version-gate refusal copy states what actually fires it (every read-only git stage, not only filter declarations); the version probe is memoized per sanitized PATH (one fork per process, not per command — spawn failures re-probe, deterministic outcomes cache); each config text parses once for both consumers (core.worktree validation and the filter accumulation re-parsed the same text twice); the discovered-filter name bound is a named constant; the schema's test: sentence scopes the read-only shell to in-session delegations like its roster and intro siblings; the glob-budget wall-clock bounds are loosened so a loaded runner cannot redden them; the discovery cd-fold's silent skip on a failed canonicalize carries its cannot-fire-here comment (the gate refuses any cd destination it could not resolve before discovery runs). Disclosed, unchanged: git ls-files --stage -z still runs once per scanned repository even with zero gitlinks (one discovery fork per admitted git command warm — the version probe is memoized) and a repository whose index listing exceeds the 16 MiB scan bound refuses every git evidence command — both fail-closed costs now stated here. The review's structural recommendation — replace the hand-rolled config parser with git config --list -z enumeration, keeping the hand parser only as the refusal fallback — is scheduled as the committed follow-up below.

  • Round 11 (fresh 11-lane review: 8 static/probe lanes, one test-execution lane, one mutation re-derivation lane, one merge-order lane) — two new escapes, both reproduced end to end before fixing, plus gate and copy drift. (1) The .git gitfile and commondir readers cut the link value at the first newline while git consumes the WHOLE file minus one trailing newline — interior newlines are ordinary path bytes, and a directory named with one is legal on POSIX. Proven against git 2.43: a link like gitdir: decoy/.git plus a second line resolves a gitdir literally named with that newline and runs THAT repository's filter.<name>.clean under an admitted read-only git status/git diff (marker-file proof), while the first-line cut scanned the nonexistent decoy, found nothing, and admitted the read. The readers now consume the whole value — the byte-exact mirror, so discovery pins the filters of exactly the repository git runs (the round-10 padding/CRLF refusals keep their honest pins; the old first-line pin whose justification claimed git refuses such links is rewritten). End-to-end readonly_shell_lane_git_diff_pins_filters_behind_a_multi_line_gitdir_link with a raw-shell control; readonly_git_commondir_follows_the_multiline_value_git_resolves pins the shared-directory twin. (2) git -C <value> operands were judged as literal spellings while the shell expands wildcard (and zsh EQUALS) spellings against the stage's working directory — and -C values are sliced off before the plain-operand walker, so nothing expansion-judged them. Proven end to end with the chained shape git -C x -C * show HEAD: a literal * directory planted INSIDE x/ canonicalizes fine, the expansion binds the second -C to the first-sorted outside symlink, the leftover entries land in git's subcommand and rev slots (attacker-named tags), and the outside repository's committed content renders; the flat git -C * shape had only survived-refused by subcommand-slot poisoning, a collation accident. The root resolver now refuses any wildcard and any =-prefixed -C value on the spelling itself (tilde already refused), like the cd and gh-stage rules — readonly_operand_gate_refuses_shell_expanded_git_c_operands pins all four shapes — and the two comments asserting the breached invariant as fact are rewritten. Also from round 11: the env-config version floor moves to 2.32 — the GIT_CONFIG_COUNT feed needs 2.31, but GIT_CONFIG_GLOBAL (the wall that keeps a repo .gitattributes from referencing a global-only filter) arrived in 2.32, so 2.31.x admitted under protection that partially did not take (comparator pin renamed git_env_config_support_needs_two_thirty_two and re-bounded); the upstream-CI clippy gate was found RED at the round-10 head (the glued-numeric helper tripped collapsible_if at command_safety.rs:1397; the body's "only the pre-existing turn_loop.rs:5499" claim was false) and is collapsed with a let-chain — the upstream invocation now reports only turn_loop.rs:5499, verified with the exact ci.yml flags; the 64 KiB inline-stdin cap's never-wedge invariant is stated as the Linux fact it is (Windows/macOS pipe buffers may be smaller; the cap stays a strict improvement) with its off-by-one comment fixed and the input/data schema aliases advertising the maxLength the runtime enforces; the guidance stdin sentence names tty alongside interactive and combined_output; the [shell.readonly.command] copy names the glued/cluster spellings it admits, the gh-stage glob refusal, and the new -C walls; refusal messages bound echoed filter names and core.worktree values at 200 bytes (a crafted config could otherwise buy unbounded error text through the symlink-following config reads); the git ls-files per-repository fail-closed cost is stated at its call site; splice_git_read_hardening reuses the one preamble walk instead of a private copy; the verifier background gate jobs' at-spawn stdin EOF gained its pin (run_verifiers_background_gate_jobs_get_stdin_eof_at_spawn — a cat gate completes instead of idling to the gate timeout); and the verifier-catalog test whose name denied the shell surface the fix-3 contract grants is renamed.

  • Round 12 (fresh 12-lane review: nine static/probe lanes, one live-E2E lane, one test-execution lane, one mutation lane; both escapes reproduced end to end by the reviewer with raw-git marker discriminators before fixing) — the strip model behind the round-11 mirror was itself an approximation: git consumes a .git gitfile/commondir value with an UNBOUNDED run of trailing \r/\n bytes stripped, in any order (probe-pinned with a marker-firing decoy discriminator: a value ending in \n\n, \r\r, or \n\r resolves the fully stripped target and runs THAT repository's filter while the armed decoy one byte longer stays cold), while the readers stripped exactly one \n plus one \r — a link one byte past the round-11 fixture sent discovery to a decoy-or-nothing scan while the child git ran the hostile repository. Both readers now strip the run; the padded space/tab refusals keep honest comments (git resolves and runs a repository found at a padded path — probe-pinned — so the refusal is the fail-closed side of a mirror the lane declines to follow); and the consumed value is validated (git fatals on a non-repository, so the lane refuses the same instead of admitting a read whose scan finds nothing and whose child dies at git's fatal). Six comments asserting the false one-strip/first-line model — two inside the round-11 fix itself, one on the rewritten pin's own doc header, one on the scan-cap constant — are rewritten to the probe-true model. (2) The glob .. synthesis judged the LEXICAL parent while the kernel resolves the shell's expanded .. PHYSICALLY through a followed directory symlink: cat sub/..*/secret_out with sub a symlink to an outside directory read the outside file on dash (probe-pinned; modern bash leaves ..* unexpanded, so the scope is exactly the sh/dash family the synthesis exists to cover) while the gate judged a nonexistent lexical path, called the operand inert, and admitted. The candidate is now the textual sub/.. under the walked directory, which canonicalizes exactly where the kernel resolves the shell's word (the round-3 dot-synthesis pin stayed green under the reverted mutation — its fixtures sit where lexical and physical coincide, which is why the shape survived). Also from round 12: the pre-spawn discovery git child is bounded — the enumeration child opens repository files itself, and a FIFO at .git/index or .git/HEAD blocked its own open forever (probe-pinned, timeout-kill exit 124) in the one pre-spawn region no watchdog bounds, where the bounded-reader wall covers only the files the tool opens; a watchdog thread kills the child at a 10 s bound and the command refuses. The interact stdin write is bounded the way the spawn payload is capped: a command that stops consuming stdin filled its pipe and held the shell-manager lock — every other shell in the session with it — forever, and the 64 KiB cap's own refusal copy steered straight into that wall; the write now runs on a worker thread with a 5 s deadline, abandons the payload to the background writer (the bytes still land if the child resumes reading), marks the task's stdin stalled, and refuses further writes (two writers could interleave); interact payloads also carry the 64 KiB per-call cap; Windows keeps a blocking write under the cap (disclosed — and corrected in round 13: the handle IS cloneable there via ChildStdin::try_clone, so this is a scope choice, not a platform necessity). The round-11 refusal-echo bound was unpinned — a neutering mutation left the whole suite green — and is pinned now with file-sized core.worktree and filter-name values whose position-marked content discriminates a truncated echo. Discovery consumed raw (non-canonicalized) roots and cwd while the gate canonicalized: a declared root spelled through a symlink flipped every -C-bearing git read to a refusal, and a divergent symlink inside the cwd spelling could send git's physical .git walk-up somewhere the lane's lexical one did not look; the root set and cwd are canonicalized once before both consumers, pinned through an aliased-workspace tool leg. The config parser carries the continuation state across a pre-section key's trailing backslash so the following section header is swallowed the way git swallows it (probe-pinned) instead of arming a driver git never runs. The parallel classifier's admitted set SHRANK with the shared gh host heuristic — base parallel-admitted gh repo view myhost/o/r and gh pr list -R ../secret for parent auto-approve; both refuse now — a behavior delta the byte-identical source hides, disclosed here and pinned. The absolute-outside-gitdir link leg (git runs that repository's filters; the lane scans and pins it by design) gained its own scan pin; the warm discovery-fork count in this body was one, not two (the version probe is memoized); the [shell.readonly.command] refusal copy now attributes seam-refused shapes to the execution seam instead of the grammar; the internal dispatch message no longer names a Scout shell no verifier holds; the tool description's pipefail list covers the ksh family the predicate admits; and the EOF-ordering comment states the real (infallible) close path.

  • Round 13 (fresh 13-lane review: two boundary red-team lanes with code-executing probes, discovery-subprocess, glob-walker, stdin/interact, a 104-shape config-parser differential, claims-vs-implementation, smuggling audit, pin quality, performance/economy, copy accuracy, merge-order + gates, and a fresh-mutation lane) — one BLOCKER, two MAJORs, fixed here. (1) The walk-up returned on ANY .git DIRECTORY while git's setup_git_directory_gently_1 treats a directory failing is_git_directory as non-fatal and CONTINUES upward: one empty sub/.git between the stage root and a hostile repository blinded the round-7 filter discovery, and an admitted git -C sub diff executed the ancestor's filter.<name>.clean — attacker command execution under ReadOnly with outside content crossing into the output — reproduced end to end through the tool by two independent reviewers and re-run by the coordinator, including the cross-boundary shape (a bogus .git at the workspace root, hostile repository ABOVE it: a workspace-scoped writer alone triggers it; raw control fires, marker proves execution). The walk-up now mirrors the discovery loop per level: a .git directory is a repository only when the SAME predicate read_gitfile applies — an is_git_directory mirror built from git 2.43's source (validate_headref: a refs/-prefixed symlink target validates WITHOUT existence, ref: plus an isspace skip plus a refs/-prefixed name, or a 40-hex detached HEAD; the commondir pointer resolved with the trailing-run strip and real-pathed; access(X_OK) on objects and refs) — and anything else continues upward, with a .git symlink treated directory-transparent like git's lstat-first read_gitfile. The mirror must be exact because both divergence directions leak: a directory the lane accepts while git rejects stops the walk where git continues to an ancestor whose filters then run unpinned, and a directory it rejects while git accepts walks past a repository the child runs. The old gitfile-branch validity check was an approximation in both directions — admitting garbage-HEAD-text and missing-refs gitdirs git fatals on, refusing a dangling-HEAD-symlink gitdir (git accepts; probe exit 0 with the filter firing) under a false (git itself fatals there) message — replaced by the shared predicate, and the link value now truncates at the first NUL like git's C-string read (gitdir: <target>\0junk runs the prefix repository — probed). Pins: the predicate unit with ten check-by-check legs and a raw-git control on the dangling-symlink acceptance, the bogus-dir walk-up variants (empty, HEAD-only, symlinked, garbage-HEAD — every one resolves the ANCESTOR), both end-to-end leak flips with raw controls, the NUL truncation, and a rename of the padded-commondir pin to the probe-true model plus a follows-a-resolving-pointer twin (a padded value that RESOLVES is followed exactly where git resolves). The remaining walk divergences are comment-disclosed and fail-safe: the bare-repository check (a bare repo converts no worktree content, so no filter can fire), filesystem boundaries (the child env clears GIT_DISCOVERY_ACROSS_FILESYSTEM, so the child may stop where the walk continues — those repositories are scanned, never run), and safe.directory (the child fatals after the scan pinned, failing the command honestly). (2) The Follow-up section's enumeration spelling was include-blind: git config --list -z --local lists only include.path=..., never the included filter.* records (probed; --local also never reads config.worktree), so the follow-up executed as written would have converted today's fail-closed include walls into silent under-scans — the round-7 class behind a green-looking scan. The section now spells the UNscoped --list -z under the hardened discovery env with origin filtering, and drops the incoherent keep-the-parser-as-fallback clause (when git cannot be invoked, every git evidence command refuses anyway). Also from round 13: the discovery watchdog poll quantum drops 25 ms to 2 ms (measured ~25 ms of join tax on every admitted git command) and is pinned as a production constant, with the bounded-child pin's elapsed window tightened to [9, 15] s; read_bounded_regular_file refuses a file that grew past the cap between fstat and read instead of parsing a silently truncated text (the one pre-spawn race family member whose failure direction was admission); the gitlink-path refusal echoes go through the 200-byte echo bound — a crafted index gitlink path is bounded only by the 16 MiB listing cap (probed: git accepts a 100 KB invalid-UTF-8 gitlink path and ls-files --stage -z emits it verbatim; the unbounded echo bought a ~300 KB refusal) — pinned with the crafted-index fixture; [core "sub"] worktree (git lists it as the inert key core.sub.worktree) no longer trips the relocation refusal; the glob walker documents and pins the two containment-load-bearing mechanisms fresh mutations exposed — the re-entry dedup's remaining-pattern-length component (dropping it admits cat s/*/z* over a workspace self-link on bash AND dash with the whole suite green) and the . half of the dot-synthesis (dropping it admits cat .*/..*/o.txt one level above the workspace on dash) — and discloses the judged-vs-run symlink-swap race the cd fold already disclosed; the canonical-roots drop direction is pinned; the tool description's routing sentence names glob-bearing singles (the round-10 routing change missed by three rewrites), the guidance stdin sentence states where tty commands actually run and who can write a combined_output terminal's stdin, the [shell.readonly.command] copy scopes the conditional-cd refusal to later segments (a first-segment cd under &&/|| is admitted — the classifier's own test says so), the cwd shape copy names the +/- prefixes it refuses, the "run this Scout" remnant is de-roled, the parser doc states its one-directional mirror honestly, and the sub-subagent chain (carve-out Verifier parent → Builder child) gains its no-extra-authority ceiling pin. The body's own numbers were corrected here too: the round-12 head ran 216 shell tests with TEN new pins (not 215/nine), the parallel-classifier growth is -q-only (base already carried --jq), and the fix(subagent): 固定任务身份与完整输出恢复 #124 shared-file count is five.

  • Round 14 (fresh 15-lane review: 13 static/probe lanes — classifier, stdin lifecycle, operand gate + glob walker, git splice + routing, a ~40-construct config-parser differential against git 2.43 probes, walk-up/discovery with git probes, test-role + fleet, test-quality audit, claims-vs-implementation, merge-order, performance/economy, docs/copy plus a per-hunk smuggling audit of all 13 non-big-three files — and two code-executing red-team lanes, 60 compound/cd/glob probes × bash and dash and 26 content-filter topology probes, all with raw-git positive controls, zero escapes outside the findings below, plus coordinator re-verification of every BLOCKER-class claim): two BLOCKER-class walk-up escapes, one more escape the review first failed to reproduce, and two MAJORs, fixed here. (1) The walk-up keyed a .git entry on its LSTAT kind: a symlink to a plain gitfile hit neither the directory arm nor the symlink_metadata().is_file() arm, so the walk resolved NO repository while git 2.43 stats through the link and applies the gitfile — probe-proven end to end (a raw git status in a directory whose .git is a symlink to a one-line gitfile resolved the target repository and executed ITS filter.<name>.clean, marker proof; the whole fixture fits inside the workspace, the round-13 BLOCKER's own reach) — the walk now keys on the FOLLOWED kind, and the false "a symlink to a FILE is never a gitfile" comment is rewritten. (2) The ref: skip accepted \f (Rust is_ascii_whitespace) where git 2.43 rejects it: a decoy .git directory with HEAD ref:\frefs/heads/main passed the lane's predicate — walk stopped, scan found nothing, read admitted — while the child's discovery rejected the decoy and resolved the hostile ANCESTOR (raw control: git rev-parse --git-dir from the decoy names the ancestor); the cross-boundary shape of the round-13 BLOCKER, one byte wide. The skip set is now exactly {SP, TAB, LF, CR}, and the "git skips isspace" comments (wrong in both directions — git also rejects \v) are rewritten, pin doc included. (3) The discovery shard's commondir claim — first reported "unconfirmed" by the review because its debunking fixtures never reached the pointer read (no HEAD, no index, no worktree content) — is CONFIRMED with complete fixtures and fixed: git consumes the value as a C string (an interior NUL resolves and runs the prefix repository, filter fired under a plain git status) and paths are bytes (a non-UTF-8 byte path names a repository git runs, filter fired), while the lane's whole-value canonicalize failed on the NUL and its from_utf8 declined the byte path; both commondir consumers now share one byte-exact pointer reader (strip the trailing run, truncate at the NUL, build the path from raw bytes on unix; Windows keeps the UTF-8 wall, where git itself fatals on such values, so declining stays fail-safe). (4) MAJOR: the unix PTY interact write now rides the 5 s bounded worker like a pipe write — the writer is a dup of the master fd, and take_writer is NOT called on unix: probe-pinned that dropping its box injects newline+VEOF into the master, an instant EOF for a terminal command that never receives an interact (Windows keeps the blocking write for both handle kinds, disclosed at the site); a close after a stall no longer claims an EOF the abandoned writer has not delivered — the flag stays down and the refusal names the stall. (5) MAJOR: the foreground stdin-EOF close is deliberately pipe-only — a terminal (combined_output) foreground command keeps its PTY writer attached, restoring the base interact surface a session member can write to while the command runs, which the unconditional close had silently destroyed against the guidance sentence that describes it; the detached-foreground EOF contract is unchanged (pipe lane, still pinned). Also here: the pre-spawn canonicalizations moved onto the blocking pool with the gate they feed (cold-cache realpath walks no longer stall an executor thread), the discovery reap polls try_wait so the watchdog can deliver its kill after stdout EOF, the cmd compound refusal names every refused connector family instead of ";"-separated, the classifier's quoted-separator comment states the seam-rests invariant, the fleet-envelope compound-gh leg names the wall that fires, and the od ---handling plus the read-only stdin-field rejection gain their pins. Mutations 3/3 red — walk-arm revert, is_ascii_whitespace restore, NUL-truncation drop — each reddening exactly its own pins, including the two leak-flip E2Es.

  • Round 15 (fresh 12-lane re-review from scratch after a rebase-freshness check — merge-base == pinvou3-clean tip == base f1eb10efc, rebase a no-op; lanes: claims, shell core, git area against live git 2.43 binary probes, command_safety, subagent/verifier authority, PTY/stdin lifecycle, test quality, per-hunk smuggling, one code-executing red-team lane, performance, docs/copy, cross-cutting/merge-order; the coordinator re-verified every blocker by direct trace and ran all gates): three BLOCKERs, one MAJOR copy-placement fix, and the missed pins, fixed here. (1) StdinWriter::flush's new PtyFile arm kept the base #[cfg(not(target_env = "ohos"))] while the variant and write_all's arm are #[cfg(unix)]: on Windows the arm names a variant that does not exist (E0599 — probe-proven with rustc at the exact attributes for x86_64-pc-windows-msvc) and on the ohos unix target both arms compile out leaving a non-exhaustive match (E0004); ci.yml runs windows-latest as a required check and ohos.yml runs a target cargo check on every crates/** PR, so the branch's Linux-only gates structurally could not see either. One-token fix (#[cfg(unix)]), and the two comments claiming the dup's drop "delivers EOF to the slave" / "closes the last master handle" are rewritten (the reader thread's clone is the live master handle; the dup's drop delivers nothing — the pipe-only EOF claim was already honest). (2) The operand gate judged post--- tokens with the option grammar's =-suffix rule while the classifier deliberately admits everything after -- as a plain operand: cat -- -a=b judged b while the program opened -a=b — a planted outside symlink spelled -a=b crossed the boundary under a name the gate never judged (raw-shell control fires, refused now; cat -- file stays admitted; unit + E2E leak-flip pins). (3) The glob walker judged matches by resolved location only: an expansion entry named like a flag lands in an option slot the gate never judged, and because the shell expands * inside the ALREADY-hardened command text, a planted --ext-diff file re-arms the external diff splice_git_read_hardening removed — git's last-flag-wins, and the attribute driver behind diff.<name>.command outranks the GIT_EXTERNAL_DIFF env pin (control-proven end to end with the exact hardened text under the pin set); a planted --show-signature re-arms gpg.program the same way. The walker now refuses dash-named matches outright (gh stages never reach it — their globs refuse earlier; the round-11 option-word union test pins the stronger refusal, and a plain-name outside match still refuses by location). Also fixed: the aligned read-only routing and stdin-contract copy moved onto the model-visible bash description — the round-3 alignment had landed only on the hidden uppercase Bash (model_visible() == false), the model-visible tool still carried the "only pipelines still go through the shell" sentence this lane's own routing falsified, and the catalog pin now pins the visible tool; PowerShell shell-lane compounds refuse ,-bearing commands (PowerShell argument mode splits a bare comma into several argv words the gate judged as one — readonly_shell_word's "reads back unchanged on PowerShell alike" claim corrected; per-kind decisions pinned); raw NUL bytes refuse at the charset gate (the gate's exists() silently skipped them and spawn failed closed with a generic error — the honest answer is the in-policy refusal; pinned); the ZDOTDIR/GLOBIGNORE startup walls pin at a pure table readonly_shell_startup_env_walls (the child env is not observable through the admitted grammar — env-reading commands refuse — so the pin lives at the source; the round-14 "gained their pins" od--- claim was wrong: od -- f had no pin anywhere and the stdin-field rejection half was pinned by a pre-existing row — od -- admission pinned now and the claim corrected); the post-stall close contract pins (a close after a stall must leave the stall refusal, never an EOF claim — the stdin_closed_at_eof stall conjunct is defense-in-depth under the stall-first check order and the pin reddens a reorder that would surface the false claim); the inline-stdin boundary anchors assert exact equality with the constant (the contains("65536") anchor survives a 640 KiB cap); the gh host heuristic's .. refusal scopes to path-like spellings so a --jq '..' recursive-descent filter admits (pinned; (/[-bearing filters stay charset-refused and a quoted pipe fails closed at the stage split); the round-14 receipt "upstream --all-targets clippy reports only the pre-existing turn_loop.rs:5499" was FALSE again at the round-14 head — &[outer.clone()] in tests.rs tripped clippy::unnecessary_clone under the exact ci.yml flags (two errors) — fixed to std::slice::from_ref and the receipt true at the new head; the E2E missing its #[cfg(unix)] gate (..._behind_a_nul_commondir_link, red-by-construction on Windows) carries it now; two DCO trailer/name drifts (db75f6526, dc889a214 rewrote as 09c405817/de3edb420), a third on 6f9b1f271 (now a6df582da), and a glued trailer on e03fd90bc (now afe5aa35b) are repaired by message-only rewrites with byte-identical trees. REFUTED this round, no change made: the shell-core lane's Windows \\.\ device-namespace MAJOR — the strip prefix r"\\." is three bytes, every device-namespace spelling keeps a leading \, and the shape gate refuses it fail-closed at head (the refuting implementation attempt was reverted). The red-team lane proved 0 new escapes with executed candidates: hostile BASHOPTS/SHELLOPTS env against cat * over a planted dot-symlink (raw control leaks; the child-env allowlist drops both variables independent of the startup pins), a raw NUL inside an operand (admit → spawn fails closed, manager mutex not poisoned), and a workspace-planted git shim against the pre-spawn discovery children (std resolves bare program names through the CHILD envp PATH, which discovery sanitizes). Mutations 7/7 red — post--- context drop, dash-named wall removal, PowerShell comma wall removal, ZDOTDIR pin emptied, NUL charset drop, visible-copy revert, .. heuristic widened — each reddening exactly its own pins.

  • Round 16 (fresh 13-lane review from scratch, rebase-freshness re-verified first — merge-base still == pinvou3-clean tip == base f1eb10efc, rebase a no-op, and no open fork PR head is an ancestor of the branch so no parent-PR stacking; lanes: claims-vs-implementation (58 sampled claims TRUE / 0 FALSE / 0 PARTIAL), shell execution core, classifier + operand gate + glob walker with a fresh adversarial sweep, a git-area differential against the live git 2.43 binary across ~120 shapes (walk-up predicate, gitfile/commondir readers, a 42-shape config-grammar differential, submodule enumeration, -C resolution, pin precedence), subagent/verifier/fleet authority, cfg-surface + PTY lifecycle with rustc probes on the ohos and windows targets, test-quality (196 test fns audited), per-hunk smuggling (97/97 hunks), performance/economy, docs/copy, cross-cutting (fresh git merge-tree against all 12 open PR heads, DCO 46/46, gate receipts reproduced), one code-executing red team (81 candidates executed end to end through the real tool entry on bash/sh/dash: 1 leak, 1 pre-disclosed find -L residual, 30 refused-at-wall, 25 classifier-blocked, 23 inert, 1 admitted-with-Deny-policy — zero other escapes), and a fresh-mutation lane re-proving 8 load-bearing walls red): four BLOCKERs, fixed in the same wave. (1) The operand gate resolved a git stage's wildcard against the accumulated -C stage root while the SHELL expands it against its own working directory: git -C .r diff * over a hidden clean repository judged .r's entries while the workspace-root --textconv expansion re-armed the repository's attribute diff.evil.textconv driver past the splice — git is last-flag-wins and diff.<driver>.textconv is not env-pinnable — executing attacker-chosen code under ReadOnly with outside content in the output (executed proof, marker + secret-in-output; the round-15 dash-named wall is bypassed purely by retargeting the stage). The walker now judges a git stage's globs at BOTH bases (stage root for git's pathspec resolution, shell cwd for what the shell splices), at both call sites, sharing the one command glob budget, with an E2E leak-flip + raw-shell control and piped AND unpiped unit shapes so neither site regresses alone (both single-site mutations red). (2) cmd.exe tokenizes , and = as argument delimiters when it passes a line to a program (mkdir a,b creates two directories), so cat a,b | wc -l ran with argv words the gate judged as one word — planted outside symlinks spelled a and b read unjudged on a cmd host (documented cmd semantics; no Windows host here, the round-5 verbatim-path precedent). The per-kind wall refuses both spellings for the Cmd kind (the admitted grammar carries no delimiter-bearing option, so nothing admitted is lost; quoted occurrences refuse too, mirroring the % wall), the multi-segment refusal copy now states the exact refusal surface instead of the overstated "gets no compound probes", the readonly_shell_word doc's false "reads back unchanged on cmd.exe alike" claim is corrected, and the round-15 comma pin — whose cmd leg asserted the false premise (Cmd "cat a,b" → is_none()) — is rewritten for both walls. (3) The round-15 cfg fix left StdinWriter::PtyFile alive on ohos with its only constructor inside the not(target_env = "ohos") PTY block: a never-constructed dead_code variant, fatal under the workspace deny(warnings) and ohos.yml's -Dwarnings (rustc probe on the real aarch64-unknown-linux-ohos target — the round-15 red replaced by a new one). The variant, both match arms, and the bounded-write site now carry the same all(unix, not(target_env = "ohos")) predicate as the constructor; probe-clean on ohos, windows, and the host under -D warnings. (4) Five new tests were red-by-construction off Linux (the round-15 class): two ungated symlink-planting tests fail the windows test-target COMPILE (std::os::unix::fs::symlink at fn level, E0433 probe-proven), one asserts unix-only gitlink resolution against the Windows refusal its own #[cfg(windows)] twin pins, one executes wc -c through the sanitized PATH (absent on windows runners), and one exercises the unix-only bounded stdin write against Windows blocking semantics (30 s guard wedge). All #[cfg(unix)] now, reasons at each site. Also in the wave: od -j/-N admit GNU's strtoll base-0 spellings (0x10 hex, 010 octal) the decimal-only parse refused, and the post--- corner admissions (a second -- as a plain operand, a bare -, an empty operand) are pinned. Verified TRUE and left alone this round: the PowerShell comma wall is complete against every charset-surviving PowerShell shape; post--- judgment survives every attack shape tried (second --, quoted --, mid-pipeline/sequence, cd/gh/git post---, dangling --); the dash-named wall is sound against re-arm (its false-refusal families — wc -c ./* refusing ./-file — are safe-direction, follow-up); the git-area differential found ZERO escape shapes — its six fail-safe false-refusal findings are the disclosed deliberate walls (padded gitfile targets, the non-UTF-8 gitfile wall vs the byte-faithful commondir reader asymmetry, include/includeIf, worktreeConfig, core.worktree without repositoryformatversion, unspellable filter names); find -L outside-name listing re-confirmed as the pre-disclosed residual; authority (clamp→capture→narrow ordering, chains, receipts, per-stage gh predicate in both pre-checks) clean; smuggling CLEAN; DCO 46/46; all gate receipts reproduce (fmt clean, fork-CI clippy clean under its exact flags, upstream --all-targets clippy reporting only the pre-existing turn_loop.rs:5499). Disclosed to the follow-up list below: the discovery read loop never re-checks its watchdog flag (a grandchild holding the killed child's stdout could out-wait it — unreachable under the pinned config today), the version probe runs unwatchdogged (trusted binary, memoized), discovery has no per-command aggregate budget (32 repositories × 10 s watchdog ≈ 5 min fail-closed worst case), and BackgroundShell::drop's PTY arm waits unbounded where the Process arm is bounded. Mutations 3/3 red (piped git site → unit piped shapes; unpiped git site → unit + E2E leak-flip; cmd delimiter wall → the rewritten per-kind pin), each reddening exactly its own pins.

  • Round 17 (fresh 13-lane review from scratch, rebase-freshness re-verified first — merge-base == pinvou3-clean tip == base f1eb10efc, rebase a no-op, base unmoved, fix(tui): repair residual model-facing copy (2026-10-07 audit batch) #136/chore(deps): bump undici 7.30.1, msal-node 7.1.2 #135 open and not in main; lanes: shell execution core, classifier/operand-gate/glob walker, a git-area differential against the live git 2.43 binary, subagent/verifier/fleet authority, cfg-surface with rustc probes on the installed aarch64-unknown-linux-ohos and x86_64-pc-windows-msvc targets, claims-vs-implementation (~55 claims sampled), test-quality, per-hunk smuggling (98 hunks), performance/economy, docs/copy + zh/en parity, cross-cutting (merge-tree vs all 17 open PR heads, DCO, live CI state), one code-executing red team (~96 candidates executed end to end through the real tool entry), and a fresh-mutation lane (10/10 red)): two BLOCKERs and one MAJOR, fixed in the same wave. (1) BackgroundShell::write_stdin's ohos fallback was the ELSE ARM of the #[cfg(all(unix, not(target_env = "ohos")))] bounded-write statement, and the second fallback was #[cfg(not(unix))] — both strip on ohos (unix = true, target_env = "ohos"), leaving NO write on ohos: the stdin binding unused, the Pipe field unread, the StdinWriter methods uncalled (all three fatal under the workspace deny(warnings) on ohos.yml's cargo check lane — probe-verified on the real target with three diagnostics; the round-16 audit covered the variant sites, one level above the stripped statement), while interact/spawn stdin payloads were silently discarded with Ok(()) — the in-code claim that ohos "falls through to the blocking write below" was false. The fallback now carries #[cfg(any(target_env = "ohos", not(unix)))]; spliced-verbatim probes compile clean on ohos, windows-msvc, and the host under -D warnings. (2) validated_core_worktree validated the RAW first physical line of a core.worktree value while git applies the continuation-joined, escape-decoded value (probe-proven on git 2.43): a workspace-internal hostile repository declaring worktree = w\ continued over a second line — with a planted .git/w\ symlink making the raw value canonicalize back to the discovered worktree — relocated the child's worktree outside the workspace past the wall, and an admitted git status listed the outside victim's files under ReadOnly (coordinator-replayed). The parser now flags continued settings (continued on ParsedGitConfigSetting) and the validation refuses them; pinned by a parser unit, a plain refusal case, a unix discriminating discovery fixture whose admission twin keys on the continuation itself (not the link spelling), and an end-to-end leak-flip with a raw relocation control. (3) MAJOR: the round-16 git-stage-glob E2E's raw-shell control ran under the ambient locale — under a strcoll locale bash sorts seed.txt before the planted --textconv and git diff <path> --textconv fatals ("must come before non-option arguments"; probe-proven), reddening the control on any host whose sh is bash (macOS runners); the control now pins LC_ALL=C. Also fixed here: the attached-path option wall no longer fires past -- (cat -- -a/b is a plain post-terminator operand judged by resolved location — the classifier admits it; both directions pinned); the 10 s glob-deadline magnitude is pinned like the sibling watchdog quantum; the quoted-glob-value over-walk (option-table-blind seam, safe direction) is documented at the walk site; the hidden read-only Bash description now names the cmd ,/= walls and scopes the glob-expansion sentence; the PowerShell/cmd delimiter refusal messages hedge the quoted-occurrence fail-closed shape. Verified TRUE and left alone: zero new boundary escapes (round-16 fixes re-verified live across 16 dual-base shapes incl. cd-fold variants), smuggling CLEAN (98/98 hunks, one comment-only nit now claimed here), authority clean (no path builds the carve-out without the clamp), performance clean (dual-base walks fire only when -C moves the base, sharing the one budget; canonicalizations all in the blocking pool), all five gate-receipt counts reproduce exactly (the lanes' static forkguard_ 243 over-count REFUTED by measurement: 240/0), ~55 sampled claims TRUE with no overclaim, merge-order recipes still byte-correct at today's heads (fix(subagent): release interrupted claims and inherit worktree isolation #123's head moved to 6c40e3bb21f0 with the identical three-file conflict shape; fix(tui): repair residual model-facing copy (2026-10-07 audit batch) #136's single-literal conflict unchanged at 7da8df2a5f6f; fix(tui): widen local-endpoint SSE open timeout to 180s #130–chore(deps): bump undici 7.30.1, msal-node 7.1.2 #135 and upstream refactor(skills): export install safety primitives #81/feat(memory): refuse sensitive notes at write time #82/feat(automation): archive terminal runs on delete #85/feat(execpolicy): builtin sensitive-path safety ruleset #87 all merge clean), zh/en parity holds, and the CI zombie-queue disclosure stands (still zero runs at head). New follow-ups disclosed: the fleet-durable Verifier catalog lists bash while the envelope refuses every call (pre-existing, fail-closed); the sub-subagent chain pin models the derivation rather than executing the spawn seam; the network-guard spawn ordering has no tool-entry-level pin; the stalled-tty bounded-write test's 15 s upper guard has 3× headroom over the 5 s deadline and flaked once under a heavily loaded host (passes 3/3 alone); the reviewed state's head commit carried a Signed-off-by identity differing from its author (advisory gate passes; normalize at squash). Mutations: neutering the continued-value wall reddens exactly its two fixtures; removing the attached-path guard reddens the post--- admission legs; the fixed write_stdin region probe-compiles clean on all three targets (the unfixed text failed ohos with three diagnostics).

  • Round 17b (follow-up wave on the same review, triaging the deferred minors): the valuable ones landed. (1) The classifier-bounded probe lane admits two shapes real tools accept that the row tables refused — POSIX getopt clusters for wc's all-switch row (wc -cl is exactly wc -c -l; a letter that is not an admitted switch still refuses exactly as the tool itself rejects it) and GNU grep's numeric context shorthand (grep -3 pat f is exactly grep -C 3 pat f, rewritten to the exact-match form in the option context only — past -- a -3 is a file name and stays an operand; rg does not accept the spelling and its row does not gain it). Both ride the shared row table, so the parallel auto-approve classifier's admitted set grows by exactly these spellings — that delta is disclosed here and pinned on both classifiers. (2) The evidence catalog no longer advertises the bash lane to a worker whose envelope grants no shell authority (enforce_tool_authority refuses every bash call for shell != ReadOnly, so the fleet-durable verifier was shown a surface it could never use); a read-only-shell envelope keeps the lane, pinned both directions. (3) The network-guard's before-spawn ordering gained its tool-entry leg: under a Deny policy an admitted gh read refuses with the policy error inside execute while a non-gh read runs untouched; the gh shape parses --limit client-side, so even the dropped-guard mutant path fails at argument parsing and can never reach the network. (4) The round-16 git-stage-glob E2E now drives BOTH call sites at tool level (unpiped and piped-with-wc), so neither site can regress behind the unit pins alone. (5) The bounded stdin-write tests' upper guards were REMOVED rather than widened — the load-bearing assertions are that the deadline fired (elapsed ≥ deadline) and further writes are refused; a fixed upper bound proved load-fragile at any multiple (3× flaked once under a 13-lane-loaded host, 6× starved twice under back-to-back saturated full-suite runs), while a broken-mechanism mutant never returns and trips the 60 s outer hang guard instead. (6) The 79d72fb78 head's mismatched Signed-off-by identity was repaired by a message-only rewrite with byte-identical trees (the round-15 trailer-repair precedent): the branch is now 1f26458c1 (round-16 wave, tree 82002b12a, trailer asto18089 <asto18089@users.noreply.github.com> matching its author) → 24e67da67 (round-17 wave, tree 4e565f224) → the round-17b wave; the pinned round-16/17 review receipts remain content-valid, with all SHAs they cite now superseded by the tree-identical rewrites. Deferred with reasons, recorded for follow-up: the test-suite dedup and the legacy cfg-gate reason comments (churn in the fix(subagent): release interrupted claims and inherit worktree isolation #123 conflict surface), the discovery double-parse and the per-entry star-match allocation (bounded economy below the noise floor), head --lines=3 (the no-equals grammar is a documented deliberate shape), the empty-named [filter ""] wall (exotic, fail-closed), the sub-subagent chain pin through the real spawn seam (the threaded runtime is unobservable without a real grandchild spawn — follow-up), and the 19 long commit subjects (no gate exists; the parent squash collapses them). The 7-line agent_readonly_bash_input comment translating legacy justification/sandbox_permissions calls into a deliberately-refused shape is claimed here (review-round-17 minor 11: real and desirable, previously unclaimed).

  • Round 18 (fresh 11-lane review from scratch, rebase-freshness re-verified — merge-base f1eb10efc == base tip, no parent stacking; an executing red team ran 61 tool-boundary probes, 60 fail-safe): three BLOCKERs and one MAJOR, fixed here. (1) The #5595 absolute branch canonicalized an absolute operand's LITERAL spelling and skipped the glob walk, so cat <ws>/x* with a planted x*-named entry (glob bytes are legal filename bytes) was admitted while the shell expanded the word onto an outside symlink — executed leak (raw controls read the outside secret through cat, head, and od); without the planted literal the spelling still refuses unresolved (the literal is load-bearing) and a plain absolute glob stays admitted. Absolute glob-bearing operands now walk the glob-bearing suffix from the literal's canonical parent through the same judge — resolved location, dash-named wall, shared command budget — a relative operand's matches get (readonly_operand_gate_judges_absolute_glob_expansions, end-to-end readonly_shell_lane_refuses_an_absolute_glob_whose_expansion_reaches_outside). (2) shlex — the tokenizer the classifier and the seam share — drops a #-word-start run to end of line as a POSIX comment, so cat f # x | wc -l was judged as the words cat and f alone while cmd.exe, which has no comment syntax, passes # and x as live argv words the gate never judged (a planted outside symlink named # opens; the mirror image of the ,/= delimiter wall this branch already carries). The cmd lane now refuses any occurrence of #; the POSIX and PowerShell lanes honor # comments and run LESS than the gate judged, so no wall there (readonly_compound_refusal_names_the_cmd_comment_wall — refuse-on-cmd and admit-on-POSIX both pinned). (3) the shell tests' git_helper was not cfg-gated while all four of its callers are #[cfg(unix)] fixtures — dead code on the windows test lane, which RUSTFLAGS: -Dwarnings compiles (Test (windows-latest) is a required check; probe-proven on x86_64-pc-windows-msvc). One #[cfg(unix)]. (4) MAJOR: the Windows/ohos interact fallback wrote deadline-free while execute_interact held the session shell-manager mutex, so an interact payload above the ConPTY input buffer against a task that stops consuming wedged cancel/kill/wait process-wide. Those platforms now bound by ownership — the portable-pty writer box exposes no cloneable fd, so the worker takes the writer, finishes the payload, and hands it back; a deadline timeout leaves the writer with the abandoned worker (the bytes still land if the command resumes), marks the stall, and returns with the lock released — probe-compiled -Dwarnings-clean on all three CI-gated targets and exercised on the host with consuming and non-reading children (bounded_stdin_write_owned_returns_the_writer_and_times_out_clean). Also here: the unix bounded write distinguishes a panicked worker (a writer failure, reported honestly) from a stall (the flag refuses further writes); git-filter discovery gains the aggregate wall-clock budget mirroring the glob walk's (the per-repo count and per-child watchdog bounded scans individually but not together — 32 slow repositories were minutes of pre-spawn host work; pinned with an injectable deadline); count options validate their separated values like their glued spellings (head -n foo refuses, head -n 10 admits; range and string-valued options unchanged); the parallel classifier's charset refuses NUL like the agent charset's; the four fixed spawn-env overrides come from one pinned fn (a dropped insertion site is dead code, a changed value reds the pin); the inline-payload guidance scopes end-of-file delivery to non-combined_output foreground runs; the verifier intro and the en/zh docs state the probe lane's grant conditionality (the reviewer row drops a bounded-verify promise no surface grants); the FIFO discovery test drops its load-fragile upper bound (round-17b precedent); the chmod-000 fixtures skip under a root runner; the verifier probe asserts wc's count as the first field, not a substring; stale comments updated (the posture carve-out includes the verifier, the discovery env arming applies to children that run before the version gate) and the walker threat model names the hardlink residual it shares with the CONFIGURATION.md disclosure. Mutations 4/4 red — the absolute-glob walk neutered, the # wall dropped, the aggregate check dropped, the separated-value validation dropped — each reddening exactly its own pin, and the review's leaked shapes now refuse at the tool boundary.

  • Round 19 (2026-11-04, a from-zero re-review of the SPLIT slice with 8 lanes: command_safety grammar, operand-gate/seam/routing, git hardening, split-fidelity+smuggling, test quality, an executing red team (~190 probes, 0 escapes), performance, body/merge-order/CI): one P1 and a set of body corrections, all fixed here. (1) P1: validated_core_worktree validated the RAW core.worktree spelling while git escape-decodes QUOTED values ("w\nx" chdir's to a name containing a real newline — probe-pinned against git 2.43; unquoted values keep backslashes literal) and strips the quotes, so a quoted-escaped value plus a decoy link named with the quote characters itself canonicalized the raw spelling back to the discovered worktree while git relocated through the decoded name to an outside symlink and git status -uall listed outside files — the same raw-vs-applied divergence class the round-17 continuation fix closed, one decode step deeper. Any \ or " in the raw value now refuses (readonly_filter_discovery_refuses_an_escaped_core_worktree_value + end-to-end leak-flip readonly_shell_lane_refuses_an_escaped_core_worktree_relocation, whose raw-shell control proves the relocation live before the wall stands; mutations 2/2 red on exactly these pins; the unquoted escape-free sibling keeps admitting with the decoy links present). (2) Split fidelity: slice 3's head tree is bit-for-bit the pre-split archive tree and every pre-split test absent from this slice lives on a sibling slice — nothing dropped, nothing smuggled (all 6 files' behavioral hunks map to body claims; the only unclaimed items are the 571 blank-line separators this slice strips and restores at composition, plus one orphaned section banner). (3) Test audit found no fake pins, no weakened coverage, no lost tests; the receipts' "1 ignored (pre-existing)" is accurate (the Linux-visible ignored exporter exists on main). (4) Performance: ~0.2–0.5 ms per admitted probe, pre-spawn scans bounded and fail-closed (the walk-up deadline residual stays disclosed under Follow-up), nothing on the non-read-only path; the Follow-up section's stale "≈5-minute worst case" figure is corrected below (the aggregate budget it predates is in this tree; honest worst ≈10–20 s). (5) Body corrections below: the fix(subagent): release interrupted claims and inherit worktree isolation #123/fix(tui): repair residual model-facing copy (2026-10-07 audit batch) #136 conflict shapes refreshed at the round-19 heads, the checklist docs item re-scoped to slice 3, the fix(fleet): the test role states its real contract #140 placeholder filled, the CI receipt updated (the fork CI ran green on the pre-round-19 head after the round-16 zombie-queue note, and re-runs on this head).

  • Round 20 (2026-11-05, a from-zero 9-lane re-review of the rebased slice: classifier grammar, execution seam, test quality, an executing red team (187 probes, 0 escapes, bash + dash children against hostile git/filter/textconv/worktree/commondir forests with raw-shell controls), performance, body-vs-tree, authority surface + refusal copy, gates, and rebase fidelity after the stack moved onto fix(subagent): release interrupted claims and inherit worktree isolation #123/fix(subagent): 固定任务身份与完整输出恢复 #124-included main): two P2s found and fixed in the wave — (1) BASHOPTS/SHELLOPTS were not in the spawn-env overrides while bash honors an inherited BASHOPTS=dotglob at startup, defeating the round-6 GLOBIGNORE="" wall (operator-env precondition, same class as the existing pins); (2) the three PR-introduced mode-000 skip notes tripped the crate-level deny(clippy::print_stderr) under --all-targets clippy — both fixed in commit 8f98b3e895 with the table pin widened to six keys and one shared #[allow]ed skip-note helper, plus the rebase's duplicated bounded-stdin doc comment removed. Verified good: every prior round's closure held (the round-19 escape-decode wall re-verified live at pin and E2E level with its admission twin); the registry-side per-stage gh predicate swap is confirmed defense-in-depth (compounds refuse one branch earlier at the arbitrary-execution wall, and the load-bearing consumers carry this PR's own per-stage pins); quoted-separator mis-splits remain classifier-admit/seam-refuse with parity proven; perf bounds re-measured (worst admitted pathological glob 10.4 ms, worst discovery 18 ms, common-path delta <0.1 ms); no fake pins, no dropped coverage, receipts exact. Open items: fix(fleet): the test role states its real contract #140 must be rebased onto this head before it merges; the body corrections above are applied in this update.

  • Round 21 (2026-11-05, a from-zero 9-lane re-review at the re-aligned stack: classifier grammar, execution seam, authority wiring (registry envelope + subagent precheck + delegation clamps), split-fidelity + smuggling, test quality with a fresh mutation battery, an executing red team (283 fresh probes across bash/dash/Sh-fallback against hostile git/filter/textconv/worktree forests with raw-shell controls: 0 escapes), performance (head-vs-base measurements), body accuracy, static gates): one P1 and body corrections, all fixed in the round-21 wave 6386b876b. (1) P1 — silent coverage loss; this corrects round-19 item (3) and the split-time note above: the round-20 rebase resolution of tools/subagent/tests.rs dropped six merged-fix(subagent): release interrupted claims and inherit worktree isolation #123/fix(subagent): 固定任务身份与完整输出恢复 #124 test fns (same_worktree_disjoint_sibling_blocks_unbounded_shell, same_worktree_ancestor_chain_keeps_unbounded_shell, same_worktree_gate_ignores_settled_and_foreign_worktree_peers, worktree_discovery_never_adopts_or_enumerates_an_ancestors_sibling_repos, normalize_claim_path_folds_backslashes_into_the_ledger_frame, scoped_mutation_paths_accept_absolute_targets_inside_the_workspace) plus the mod identity_selection; declaration — orphaning the still-present tests/identity_selection.rs (11 forkguard_agent_identity_* tests, byte-identical file, no longer compiled anywhere in the stack). All six exercise surfaces this slice does not touch; the wave restores them union-keep-both (the resolution this body already documents for this file) and all 18 restored tests pass, proving pure coverage loss rather than forced adaptation. The wave also re-pins the Do not retry variations guidance fragment (the one fragment the deleted enumeration pin carried that the replacement dropped) and corrects the readonly_shell_bound_command doc comment (the pipefail prefix keys on a plain contains('|'), so an || sequence receives the harmless no-op prefix too). (2) Performance re-measured head-vs-base: the round-19 ~0.2–0.5 ms per admitted probe figure holds for non-git probes (13–20 µs pre-spawn measured — better than claimed) but understates git-stage probes: discovery spawns one git ls-files --stage child per distinct repository, ≈3.4 ms/repo warm (32-repo cap ≈ 111 ms; the 10 s aggregate wall and per-child watchdog bound the pathological case). (3) Safe-direction P3s disclosed, not changed: GNU +N count spellings (tail -n +5) now refuse under the digit-validation class round 18 disclosed; git status -uall, grep -r/-rn/-e/--include, and du -sh refuse (availability; du -s -h, git status --porcelain, rg -g, git grep -e cover the need); git worktree list prints operator-registered linked-worktree paths (the documented name-only disclosure class). (4) The fresh mutation battery (6 production mutants) all turn their pins red. Receipts below.

The [shell.readonly.command] refusal copy (tools/subagent/mod.rs) is rewritten to enumerate the admitted families, the every-segment-must-admit rule for sequences (with the segment cap interpolated from its constant), the refused shapes, and the no-retry instruction.

The parent-side parallel auto-approve classifier (is_parallel_readonly_command) is the same classifier shape and pinned (parallel_classifier_still_refuses_compound_cd_and_od_forms); its admitted set did grow by gh -q reads — the shared gh option table already carried --jq at base, so only -q is new (precision fixed in round 13) — that behavior delta is deliberate and pinned by parallel_classifier_admits_gh_jq_reads_through_the_shared_gh_table (round 6); the classifier's complete admitted-set delta across this branch is the one disclosed where it landed — the glued head/tail spellings that grew it (round 10, pinned), the gh host heuristic that shrank it (round 12, pinned), and the round-5 unix removal of the verbatim-prefix strip that shrank it for \\?\-bearing words (the transform is a cfg!(windows) no-op on unix, so base's parallel-admitted cat \\?\secret now refuses at the parallel charset gate; disclosed here after the round-17 lane found the summary sentence omitting it) — and this sentence defers to those disclosures rather than repeating a stale summary of it.

3. the test role's honest contract — moved to slice 3 (#140): the role predicates, registry carve-out, receipts label, and role docs live there.

Merge order

(Historical — #123/#124/#129 are merged into pinvou3-clean; the recipes below were applied in the round-20/21 re-bases and verified in-tree.) Against #123 (head 71cc26218f at the round-13 wave head; it has moved repeatedly — 71221288d at round 10, force-pushed past 2071e94cd, re-verified each wave, latest git merge-tree re-run at the round-13 wave head): every shared file auto-merges except THREE files. (1) One string literal — the [shell.readonly.command] refusal copy, which #123 also rewrites. The two rewrites are semantically contradictory (its copy says "Not admitted: cd, &&, ;"; this branch admits all three and adds od/gh to the enumeration, and interpolates the segment cap and states the cd-is-a-directory and connector rules). Whichever PR merges second must hand-union the copy with the other's families — #123's tests pin SIX fragments (Admitted:, git branch (bare/flag-only, git worktree list (flags only), find (no -delete/-exec/-execdir, comm, npm view, Do not retry variations) of which this branch's copy satisfies only the last, and this branch's pins (Admitted families:, at most 32 segments, must be a directory the shell can enter, recursive \/`, "joined by &&/||", every segment of a sequence must individually be admitted) sit in subagent/tests.rs, which auto-merges and judges whichever copy wins — picking a side provably reddens the other side's pins. (2) **The Git hardening table** in shell.rs: #123 adds rev-parse | branch | worktreeto the no-splice arm of the old inlinematch, while this branch extracts that matchintosplice_git_read_hardening(returning whether anything was spliced) and movesblameto the spliced set with--no-textconv. Resolution is mechanical: "status" | "ls-files" | "grep" | "rev-parse" | "branch" | "worktree" => Ok(false)next to theblamearm. Note that #123's accompanying comment saysblame spawns no configured helper — round 4 proved otherwise (git blame applies textconv by default), so the comment must not survive the union. (3) **tools/shell/tests.rs** — #123's kill-path/claim-liveness test block and this branch's round-5 operand-seam pins are inserted at the same anchor; resolution is mechanical, keep both blocks. Against #124 (97a2660, five shared files, all auto-merging), #127 (f88448b), #128 (7fe08d5), and #129 (f83e8cd): merge-tree clean at the round-11 wave head, re-verified 2026-10-31 against every head OID quoted above, and re-verified again at the round-12 wave head (db75f65) and at the round-13 wave head (7cb6ece), and at the round-14 wave head (d3d2262) — #123 still conflicts in exactly the same three files (shell.rs, shell/tests.rs, subagent/mod.rs; its head has since moved forward to 3949470with the identical conflict shape and resolution recipes, merge-tree re-verified fresh at the round-14 wave head), #124/#127/#128/#129 still merge clean (at97a2660#124 shares FOUR files with this branch, all auto-merging — the FIVE figure this sentence once carried was itself the stale one; the closing clause was already right) (the mid-review corrections that said #128/#127 sharesubagent/mod.rs+subagent/tests.rswent stale themselves — at the cited heads #127 touches onlysession_manager.rsand #128 shares zero files with this branch, and #128 is a draft on a fork branch; #124 shares four files, all auto-merging; #129 shares none). Two notes on the #123 union: it is load-bearing, not cosmetic — #123's classifier admitsgit rev-parse/branch/worktree list, and without the extended Ok(false)arm those reachsplice_git_read_hardening's _ => Err and are refused on both lanes despite admission; and the refusal-copy union must keep the phrases this branch now pins (Admitted families:, at most 32 segments, must be a directory the shell can enter, recursive `/`, and "joined by &&/||") alongside the three fragments #123's tests pin. And as of the round-16 wave (2026-11-03), **#136** (fix/model-facing-copy-residue, opened 2026-11-02, after this section's last sweep) conflicts with this branch in tools/shell.rs— both PRs rewrite the model-visiblebashtool description literal; a hand-union of the two copy rewrites, simpler than the #123 recipe (and whoever unions must take this branch's routing sentences: #136's side still carries the stale "(only pipelines still go through the shell)" and drops the stdin-EOF sentence). Re-swept at the round-17 wave head (landed as64abd6c, content-identical tree 4e565f224to the localc5d898383, via the Git Data API during a git-443 outage): #123's head has since moved to 6c40e3bwith the identical three-file conflict shape — both recipes re-verified byte-correct there, including the disputed blame-spawns-no-helper comment still present on #123's side — #136's conflict is unchanged at7da8df2 (tools/shell/tests.rs` auto-merges), and #130–#135 plus upstream #81/#82/#85/#87 all merge clean with no shared conflicted region.

Split-time note (2026-11-04, updated at round 20): during the round-20 review the stack was rebased onto pinvou3-clean with #123 and #124 merged in: slice 1 (#139, fix/shell-stdin-eof) is now FOUR commits ending at 9148c1785f, and this PR's head moved from the round-19 head 3406aeb30 (tree d83ee5291a8d) to d6d41439d8, then the round-20 fix wave landed 8f98b3e895 on top. The round-20 fidelity lane compared every slice-2-added function between the two heads: 239 total — 238 byte-identical, the one delta the splice_git_read_hardening arm extension #123's classifier mechanically requires (#123 admits git rev-parse/branch --list/worktree list; the no-splice arm must list them or every such probe dies fail-closed at the seam), pinned both directions by readonly_argv_executes_the_git_orientation_probes_without_a_shell; the #123/#124 hunks land as insertion-only keep-both blocks, exactly as this section predicted. All eight round-19-verified critical elements (escape-decode wall, refusal copy, per-stage gh predicate, spawn walls, caps, per-kind walls, splice table, sequence splitter) are byte-intact at the new head. Merge order is slice 1 → this PR → slice 3 (#140, based on this branch). Round-19 merge-tree sweep at the round-19 head: #123 (6c40e3bb21) conflicts in FOUR files — shell.rs, shell/tests.rs, subagent/mod.rs, and now also tools/subagent/tests.rs (the three-file shape quoted below predates the split; the fourth file's hunks are #123-side insertions of comment+test blocks against empty slice-2-side lines — resolution keeps both sides); #136 (f989796f1f, moved again) conflicts in tools/shell.rs AND now tools/verifier.rs (its head moved past 7da8df2a5/a4ac4ab7058 and picked up a verifier.rs hunk — its union recipe below still applies for the shell.rs copy, and its verifier.rs hunk needs the same hand-union treatment); the direct merge into the pinvou3-clean tip is textually clean but violates the declared stack (slice 2 assumes slice 1's stdin contracts). Slice 1 and slice 3 re-sweep their own shared-file hunks as their round-1 step. Round-20 correction: #140's branch has NOT followed the rebase — it still sits on the superseded round-18-era stack, so its "based on this branch" claim is false until it is rebased onto this PR's head (8f98b3e895 or later); merging it as-is would resurrect the pre-round-19 slice-2 state and drop the escape-decode wall from the combined tree. This was resolved mid-round-21: #140 re-based onto this PR's round-20 head (1f9635a894, its head 6e2ed34fb8). Round-21 update: the whole stack was then re-aligned onto pinvou3-clean 11cba403dc (five main commits landed since the round-20 base: #129, #131, #133, #134, #135 — the last four touch tools/subagent/, overlapping this slice's files): slice 1 re-based as FOUR commits ending at 8c6c00dc0c (delta bit-identical), and this slice re-based as 7b754c7df + the round-20 wave 1f9635a894 (delta content-identical to the round-20 stack — the only divergence is one cosmetic blank-line-removal hunk in tools/subagent/tests.rs that #131's inserted test fn makes unpreservable; union kept both sides). The round-21 fix wave then landed 6386b876b (tree bc3415f3c9) — see Round 21 below. The round-20 fidelity sentence above ("the #123/#124 hunks land as insertion-only keep-both blocks") is corrected by round 21: the round-20 resolution dropped seven merged-#123/#124 test fns plus the mod identity_selection; line (see Round 21), and #140 must follow the round-21 wave head before it merges — the one open stack action, again.

Checked and left alone (from the same transcript)

  • message to an interrupted child ("only running children accept messages") and followup to a completed child ("cannot resume"): real gaps, deliberately not fixed here — fix(subagent): 固定任务身份与完整输出恢复 #124 is actively refactoring exactly these functions (queue_running_parent_message, followup_child, resume_from_checkpoint_with_policy → _by_id wrappers) and a colliding change would force a painful rebase. Design sketch for the fix(subagent): 固定任务身份与完整输出恢复 #124 follow-up: admit message for Interrupted(_) children with a continuable checkpoint; drain queued_mail[old_id] into the resume at resume_from_checkpoint_with_policy (resume re-dispatches under a new id) and append the notes to build_resume_prompt after the conversation tail, oldest first, followup text last; keep terminal statuses refused. The related P1 the transcript did reproduce — followup on an interrupted child contending with its own parked write claim — is already fixed by fix(subagent): release interrupted claims and inherit worktree isolation #123 and is not re-fixed here.
  • Write-capable children default to the workspace root, so parallel writers contend unless write_roots are disjoint: by design; the contention receipt already names the three recoveries.
  • MCP servers 401-ing at session boot (canva_mcp, qcc-company): bearer-token configuration on the host, not an engine defect.
  • find -L/find -follow list outside filenames (never content) through an in-workspace directory symlink: a single admitted command that predates this PR, left for a follow-up. (The git-compound splice gap that used to sit in this bullet was closed in round 4, above.)
  • npm view|show|info (admitted on base, byte-identical here) checks only its subcommand: --registry <url> or --userconfig <file> — and equally a project-level .npmrc inside the workspace — redirect the registry, and the gh network guard does not see npm — so a network_tool = false child can also run the URL-free npm view at all (the round-4 network ceilings key on gh reads only; round 6 review). A flag pin alone would be false security given .npmrc, so this is left for a follow-up that splices a pinned registry and userconfig into npm reads, consults the network decider for the registry host, and gates the arm on the caller's network posture.
  • A legacy worker record without a stored runtime_profile (pre-43cfb6bcd) is reconstructed from the role default on load, so a test child whose original parent was read-only regains the Run surface when resumed in a Full Access session. This equals the base behaviour (a Verifier with a Full shell always held Run there) and every record written since the field existed stores the clamped profile; left alone as base-equivalent and shrinking, noted for a follow-up that reconstructs under the resumer's ceiling.
  • The Windows shell lane keeps normalize_windows_command_paths and the verbatim-prefix strip; its judged-vs-run divergence for \\?\ spellings (cmd.exe/PowerShell wildcard semantics) was not exercised — no Windows host in this review.
  • A glob refusal echoes matched outside paths: the operand gate's expansion walker descends into symlinked directories pointing outside and names the matched path in its refusal, so a caller can enumerate outside directory entry names one refusal at a probe — the same name-only disclosure class the body already admits for ls -l dangling (round 14; inherent to expansion-based judgment, left alone).
  • Pre-existing, unchanged: # comments pass the charset (cat #f wedges on stdin on the background lane), and its admitted stdin-reading siblings wedge the same way — cat -, a bare grep pattern, a bare sed -n 5p, a bare od with no operand all block on the background lane's held-open stdin until the 120 s watchdog; a hard link inside the workspace to an outside inode reads outside (needs a writer to create); ls -l dangling discloses an outside target name; zsh NOMATCH aborts a zero-match compound opaquely (safe direction).
  • The sub-subagent chain (a carve-out Verifier parent → Builder/test child) holds no extra authority by code path — moved to slice 3 (fix(fleet): the test role states its real contract #140): the derive_child re-clamp mechanism and the sub_subagent_chain_of_a_carveout_verifier_holds_no_extra_authority pin (round 13; the stale "no dedicated pin" half corrected round 15) live there, not in this slice's tree.

Testing

Local toolchain stable; warm target reused via the same Cargo.lock.

  • cargo fmt --all -- --check — clean.
  • cargo clippy --workspace --all-features --locked with the fork-CI allow list — clean, and cargo clippy --workspace --all-targets --all-features --locked with the upstream CI allow list now reports ONLY the pre-existing collapsible_if at core/engine/turn_loop.rs:5499 (base code this branch does not touch; round-20 update: at the rebased head two further pre-existing collapsible_if — tools/verifier.rs:587, and tools/subagent/mod.rs:8036 carried in with the fix(subagent): release interrupted claims and inherit worktree isolation #123 rebase — plus, before the round-20 wave, a PR-introduced clippy::print_stderr trio in shell/tests.rs, fixed there with one shared #[allow]ed skip-note helper; no warning points into slice-2-owned logic). The round-9 review caught this branch's own cwd-fold if let pair tripping that lint under the upstream invocation (whose allow list deliberately excludes it); collapsed in the round-9 wave. The round-11 review caught the claim being false again at the round-10 wave head — the glued-numeric helper tripped the same lint at command_safety.rs:1397 — collapsed with a let-chain in the round-11 wave; at the wave head (f65a0d1ac) the upstream invocation reports only turn_loop.rs:5499, verified with the exact ci.yml flags; re-verified at the round-12 wave head (db75f6526) with the exact upstream flags (only the pre-existing turn_loop.rs:5499) and the exact fork-CI flags (clean). The round-14 head's receipt was false again — &[outer.clone()] in tests.rs tripped clippy::unnecessary_clone under the exact upstream flags (the round-15 review caught it) — fixed to std::slice::from_ref; at the round-15 wave head (922c286ec) the upstream invocation reports only the pre-existing turn_loop.rs:5499 and the fork-CI invocation is clean, both re-verified. Round-21 update at the re-aligned head (6386b876b): contents unchanged; main-side insertions shift the anchors (core/engine/turn_loop.rs:5611, tools/subagent/mod.rs:8058, tools/verifier.rs:587 unchanged), fork-CI flags clean, and the bare upstream invocation inventory is 9 pre-existing sites (approval.rs:183, verifier.rs:587, subagent/mod.rs:8058, remote_control.rs:2188, lib.rs:12445, turn_loop.rs:909, turn_loop.rs:5611, engine.rs:2019, context_report.rs:422), identical at base — none in this PR's files.
  • Targeted at the round-5 head: command_safety:: 82 passed / 0 failed; tools::shell:: 173 passed / 0 failed / 1 ignored (pre-existing); tools::subagent:: 608 / 0; fleet:: 361 / 0; tools::registry:: 48 / 0. At the round-6 head: command_safety:: 84 / 0; tools::shell:: 175 / 0 / 1 ignored; tools::subagent:: 608 / 0; fleet:: 361 / 0; tools::registry:: 48 / 0. At the round-7 head: command_safety:: 89 / 0; tools::shell:: 182 / 0 / 1 ignored; tools::subagent:: 608 / 0; fleet:: 361 / 0; tools::registry:: 48 / 0. At the round-8 head: command_safety:: 89 / 0; tools::shell:: 185 / 0 / 1 ignored; tools::subagent:: 608 / 0; fleet:: 361 / 0; tools::registry:: 48 / 0. At the round-9 head (93a28bde6): command_safety:: 91 / 0; tools::shell:: 193 / 0 / 1 ignored; tools::subagent:: 608 / 0; fleet:: 361 / 0; tools::registry:: 48 / 0. At the round-10 wave head (dc889a214): command_safety:: 94 / 0; tools::shell:: 203 / 0 / 1 ignored; tools::subagent:: 608 / 0; fleet:: 361 / 0; tools::registry:: 48 / 0. At the round-11 wave head (f65a0d1ac): command_safety:: 94 / 0; tools::shell:: 206 / 0 / 1 ignored; tools::subagent:: 608 / 0; tools::verifier:: 9 / 0; fleet:: 361 / 0; tools::registry:: 48 / 0 (the round-10 body said 203 shell tests; the isolated count at that head was 207 — the final commit's tests postdated the number, and one env-gated member is host-dependent). At the round-12 wave head (db75f6526): command_safety:: 94 / 0; tools::shell:: 216 / 0 / 1 ignored (ten new pins); tools::subagent:: 608 / 0; tools::verifier:: 9 / 0 + fleet:: 361 / 0 + tools::registry:: 48 / 0 (418 combined). At the round-13 wave head (7cb6ecea1): command_safety:: 94 / 0; tools::shell:: 228 / 0 / 1 ignored (twelve new pins); tools::subagent:: 609 / 0; tools::verifier:: 9 / 0; fleet:: 361 / 0; tools::registry:: 48 / 0 (1349 passed combined). At the round-14 wave head (d3d22629b, on top of ddcf5e8's two round-13 pin-gap tests): command_safety:: 94 / 0; tools::shell:: 239 / 0 / 1 ignored (nine new pins: two walk-up units, the skip-set unit, two commondir units, two leak-flip filter E2Es, the terminal-interact pin, the bounded-tty-write pin); tools::subagent:: 609 / 0; tools::registry:: 48 / 0; tools::verifier:: 9 / 0; fleet:: 361 / 0.
  • Full cargo test -p codewhale-tui --locked --lib (with the repo-required RUST_MIN_STACK=16777216) at the round-5 head: 12325 passed / 2 failed / 13 ignored, the two failures being client::operation_tests::forkguard_model_operation_auxiliary_retry_keys and tools::file::pdf_tests::contract_read_reports_huge_first_line_with_exact_bash_fallback — the known load-timing flake set (the run overlapped a per-commit compile check); both pass alone (2/2 in 0.13 s) and git diff origin/pinvou3-clean...HEAD touches neither client/ nor tools/file. Earlier heads: 12310/1, 12309/2, 12308/3 (round 4), 12303/0/13 on a quiet machine (round 3), always with the same rotating set. At the round-8 head the rotating set grew: remote_control::tests::separate_predispatch_crashes_on_one_run_get_distinct_recovery_turn_ids failed 3/3 full-suite runs (with runtime_chat_backlog_refreshes_immutable_config_before_new_catalog_or_prompt, restart_rehydrates_runtime_chat_ownership_until_terminal_cursor, and runtime_threads::tests::restart_reconciles_unresolved_dynamic_call_after_existing_turn_completion appearing in individual runs) — all 77 remote_control tests pass alone; none of those files is touched by this branch. The suite hard-aborts without the 16 MiB RUST_MIN_STACK the CI config sets — environment, not code. Every one of the eight round-5 commits builds on its own (cargo check --lib --tests per commit). At the round-9 head: 12347 passed / 2 failed / 13 ignored, the failures being remote_control::tests::separate_predispatch_crashes_on_one_run_get_distinct_recovery_turn_ids and remote_control::tests::restart_rehydrates_runtime_chat_ownership_until_terminal_cursor — both in the documented rotating set, each passing alone; the workspace lib tests (--exclude codewhale-tui) and doc tests are green. At the round-10 wave head: 12360 passed / 2 failed / 13 ignored — the same documented remote_control pair, all 83 remote_control tests passing alone (3.56 s); one earlier full-suite run on a loaded machine rotated a wider set (runtime_threads::tests::agent_mail_queue_is_durable_idempotent_redacted_and_workspace_scoped, a second tools::pdf member) — all pass alone in untouched files; fmt clean, fork-CI clippy clean under its exact allow list, upstream --all-targets clippy reporting only the pre-existing turn_loop.rs:5499, workspace libs (2150/0) and doctests green. At the round-11 wave head: 12363 passed / 3 failed / 13 ignored — one documented remote_control member plus remote_control::tests::runtime_chat_same_session_reconfigure_releases_then_reacquires_owner_lock and tui::ui::tests::missing_named_custom_provider_resume_leaves_current_session_wholly_unchanged, all three passing alone (3/3 in 0.09 s) in files this branch does not touch; workspace libs 2150/0 and doctests green at this head too. At the round-12 wave head: 12372 passed / 3 failed / 13 ignored — the same documented remote_control rotating set (restart_rehydrates_runtime_chat_ownership_until_terminal_cursor, runtime_chat_same_session_reconfigure_releases_then_reacquires_owner_lock, separate_predispatch_crashes_on_one_run_get_distinct_recovery_turn_ids), each passing alone (3/3 in 0.12 s); one earlier full-suite run on a loaded machine rotated 5 members of the same family; workspace libs (2150/0) and doctests green at this head. At the round-13 wave head (7cb6ecea1): 12384 passed / 5 failed / 13 ignored — the documented remote_control rotating set (four members) plus model_inventory::tests::ollama_default_prefers_live_local_tags_over_the_unresolved_marker, all five passing alone (5/5 in 0.13 s) in files this branch does not touch; workspace libs (exit 0) and doctests (exit 0) green.. At the round-14 wave head (d3d22629b): 12397 passed / 3 failed / 13 ignored — the documented remote_control rotating set (restart_rehydrates_runtime_chat_ownership_until_terminal_cursor, separate_predispatch_crashes_on_one_run_get_distinct_recovery_turn_ids, runtime_chat_same_session_reconfigure_releases_then_reacquires_owner_lock), all in untouched files and each passing alone in earlier runs of the same set; workspace libs and doctests green at this head; cargo fmt --all -- --check clean; fork-CI clippy (exact fork-ci.yml flags) clean; upstream --all-targets clippy (exact ci.yml flags) reporting only the pre-existing turn_loop.rs:5499. At the round-15 wave head (922c286ec): tools::shell:: 245/0/1-ignored, command_safety:: 95/0, forkguard_ 240/0, tools::subagent:: 609/0, tools::registry:: 48/0, full --lib 12404/3/13 (the documented rotating set: forkguard_model_operation_auxiliary_retry_keys plus two remote_control members, all in untouched files and each passing alone in earlier runs), fmt clean, both clippy shapes as above; all seven round-15 mutations red.
  • CI: dispatched at the round-10 wave head. The Pinvou fork CI dispatches on this branch have been zombie-queuing with zero jobs GitHub-side for 24+ hours (round 9 disclosed the same; 409 on cancel) — if this run stalls identically, the repo needs an admin look at Actions health; the local gates above stand in.
  • Mutations (each applied → expected pins redden → reverted → green): killing the sequence split; dropping the cd ParentDir check; skipping the cd operand containment; admitting any od -A value; forcing the cd shell-routing term false; restoring sync-lane Stdio::inherit(); restoring foreground open-stdin; discarding the operand-gate cwd fold; dropping the cd-as-pipeline-stage refusal; restoring the whole-segment gh/cd early return in the gate; disabling glob expansion; keying the network guard on the whole command; restoring the role_posture_permits re-key; disabling cluster expansion; round 3: dropping the cd wildcard check; disabling the ./.. synthesis; disabling the match cap; reverting the network guard to the whole-command predicate; disabling the sed trailing-flag refusal; disabling the =cmd refusal; forcing parent_shell_authority true; round 4: deleting the cd is_dir check; splitting bare operands on = again; re-adding the operand .trim(); giving each operand a fresh glob budget; making the entry counter a no-op; making the shell-lane git rewrite a no-op; moving blame back to the no-splice set; keying the no-network pre-check on the whole command. round 5: making the Windows path normalization unconditional again; making the operand verbatim-prefix strip unconditional; dropping the cd = refusal; dropping the cd enterability probe; dropping the ** refusal; dropping the glob component cap; dropping the whole-word option glob walk; keying pipefail on the kind again; dropping the cmd.exe % refusal; restoring %/^ to the bare re-quote set; never recording the stdin-EOF flag; dropping the enter rule from the refusal copy — 12/12 red, each reddening exactly its own pins, and the first, third, fourth and fifth reproduce the outside-content leak itself through the tool; round 6: making the seam's connector check a tautology (the gate pin and its fold discriminations redden); dropping the classifier's connector check (the classifier pin reddens; the seam wall still holds, as designed) — 2/2 red; round 7: disabling the filter discovery (the E2E filter pin reddens on the outside-content leak itself); dropping the discovered pins from GIT_CONFIG_COUNT (same pin, same leak); making the gh-stage wildcard refusal a no-op (the gh-glob pin reddens); ignoring the -C preamble in filter discovery (the fold/-C pin reddens) — 4/4 red, each reddening exactly its own pin. Round 8 (all applied → expected pins redden → reverted → green, each reddening exactly its own pin): case-folding the name dedupe (the two-case pin reddens); skipping the header-line remainder (the same-line pins redden); inverting the continuation parity (the escaped-backslash pin reddens); reporting an undecodable .git link as no repository (its refusal pin reddens); skipping submodule enumeration (both submodule pins redden, the E2E on the leak itself); no-op'ing the core.worktree validation (its refusal pin reddens); forcing the version gate true (its comparator pin reddens); reverting the gh host heuristic (the single-label pin reddens) — 8/8 red. Each round-4 mutation reddened exactly its own pin(s); three of them reproduce the outside-content leak itself and both textconv mutations show the repository's filter executing under the tool. The first cut's two inert pins (od -A, standalone-cd routing on macOS) were found by this process and replaced with pins that bite. Round 9 (all applied → expected pins redden → reverted → green): the dotted-header split skipped (the dotted pin reddens); same-line re-targeting removed (the two-headers assertion reddens); the comment cut disabled (the trailing-comment assertion reddens — the escaped-backslash assertions sit later in the same test and stop executing under it); core.worktree first-value restored (the last-wins refusal reddens); readonly_gitlink_paths enumerate-nothing variant applied (the submodule unit pin readonly_filter_discovery_scans_registered_submodules and the submodule E2E twin redden on the leak itself, the repository-content E2E stays green as designed; a validation-bypass variant would only redden the direct unit pin — the call site itself is covered end-to-end by the E2E); the -C chain flattened to the base cwd (the chained--C pin reddens at its admission control, and the shared discovery fold test reddens with it — the resolver is deliberately one function serving both callers); the version probe made env-inheriting (its shim pin stays green because the pin injects PATH through the test seam rather than the live environment — the guarded property is that the SUPPLIED PATH is sanitized — and the unsanitized-PATH variant of the same mutation reddens it) — 7/7 red with the stated isolation. Round 10 (all applied → expected pins redden → reverted → green, baseline 97 targeted pins green, each reddening exactly its own pin with one unrelated pin in the area confirmed green): the discovery argv pins dropped from the enumeration child (both fsmonitor pins redden on the marker leak — unit and tool); the tilde -C refusal deleted (the tilde pin reddens on the admitted escape); the -C enterability probe dropped (the mode-000 pin reddens); the bounded reader's regular-file check neutered (the FIFO pin reddens on its hang guard — the O_NONBLOCK open succeeds and the lane's own git child then blocks on the FIFO config, exactly the hang the wall exists to prevent); the commondir restore to trim() with the empty-refusal dropped (the padded pointer pin reddens); the .git link \r strip deleted (the CRLF pin reddens); the gitlink "/\\ refusal made unconditional again (the unix-bytes scan pin reddens, and the updated unit pin reddens with it); the stdin cap disabled (the oversized-payload pin reddens on the executed payload); the glob routing clause dropped (the single-glob expansion pin reddens — the literal spelling fails on the direct lane); the glued head/tail branch removed (its pin reddens); the od suffix parse reverted to plain u64 (its pin reddens) — 11/11 red. Round 11 (all applied → expected pins redden → reverted → green, against the committed wave): the gitfile reader restored to the first-line cut (the multi-line-gitdir E2E reddens on the hostile-filter leak); the -C wildcard wall neutered (its pin reddens at the chained admission); the -C = wall neutered (its pin reddens on git -C =pwd); the version floor restored to 2.31 (the comparator pin reddens on the 2.31.9 refusal); the gate-job stdin close deleted (the cat-gate pin reddens after the completion wait finds it still running); the commondir reader restored to the first-line cut (the whole-value pin reddens) — 6/6 red. Round 12 (all applied → expected pins redden → reverted → green, on the committed wave db75f6526, 10/10 red): the gitfile loop-strip restored to one \n+one \r (the multi-strip unit reddens on its one-strip decoys AND the two-newline E2E checkout leg reddens on the [shell.readonly.git.filter_config] refusal itself); the commondir loop-strip restored to one strip (its twin reddens on the decoy filter name decoylf); the .. synthesis restored to base.parent() (the symlinked-dotdot pin reddens; the round-3 dot-synthesis pin stays green — its fixtures sit where lexical and physical coincide, exactly why the shape survived); the discovery watchdog neutered (the FIFO-index pin reddens on its 30 s hang guard); the stdin deadline removed (recv() unbounded; the bounded-write pin reddens on its 30 s hang guard); the stall flag never set (the same pin reddens at the second-write 500 ms bound after a second full deadline — 10.03 s total confirms the mechanism); the echo cap removed (the echo pin reddens on the full 400-byte value echoed); the gitdir validity check removed (the dangling-link assertion returns Ok where it demands a refusal); the continuation reorder reverted (the swallowed-header pin reddens — the filter section armed where git swallows it — and all ten parser-family pins stay green, matching the prediction); the canonical-roots sharing reverted at the execute entry (the aliased-root tool leg reddens on the -C containment refusal where it demands success). Round 13 (all applied → expected pins redden → reverted → green, on the committed wave 7cb6ecea1, 11/11 red, each in its own worktree and private target): the directory branch reverted to unconditional return (the bogus-dir walk-up pin and both leak-flip E2Es redden — the cross-boundary one too); the predicate forced always-true (the predicate unit's negative legs, the padded-commondir-names-nothing refusal, the strip test's dangling leg, and the three walk pins — coherent, the walk shares the predicate); the predicate forced always-false (25 red, every walk-resolving pin); the NUL truncation removed (its pin alone); the poll quantum set to 1000 ms (the NEW constant pin reddens — the elapsed window deliberately still passes because the sleep-then-check loop lands the kill at ~10 s for any quantum up to the bound, which is exactly why the latency constant is pinned separately); the gitlink echo unbounded (its pin alone, refusal 300,241 bytes); the subsection filter dropped (its pin alone); the dedup remaining-length component removed (its pin alone — the round-3 dot pin STAYS GREEN, the documented blind spot); the . candidate removed (its pin alone — the round-3 dot pin STAYS GREEN); and session_shell_ceiling widened on the session flag (the chain pin alone).

Round-16 receipt at the wave head (79d72fb78): tools::shell:: 247 passed / 0 failed / 1 ignored; command_safety:: 96 / 0; forkguard_ 240 / 0; tools::subagent:: 609 / 0; tools::registry:: 48 / 0; fmt clean; fork-CI clippy clean under its exact flags; upstream --all-targets clippy under the exact ci.yml flags reporting only the pre-existing core/engine/turn_loop.rs:5499; mutations 3/3 red (piped git site, unpiped git site, cmd delimiter wall) with the round-15 mutation set still standing (the round-16 lane re-proved 8 walls red: post--- flip, dash-named wall, PowerShell comma wall, visible copy, NUL charset, ZDOTDIR pin, walk-up predicate, ref: skip set — each reddening exactly its own pins). Local ohos/windows cfg verification by rustc probes on the installed targets (as of round 16 no completed CI run existed for any head of this branch — the fork CI dispatches were zombie-queued, disclosed above; superseded by the round-19/round-20 receipts below).

Round-17b follow-up receipt at the wave head (565049c0b, content-identical tree 721d29136 to the local fbd0ed25f): tools::shell:: 252 / 0 / 1-ignored (the new network-guard tool-entry pin), command_safety:: 98 / 0 (the two grammar pins), tools::subagent:: 609 / 0, tools::registry:: 49 / 0 (the catalog pin), tools::verifier:: 9 / 0, forkguard_ 240 / 0; fmt clean; fork-CI clippy clean under its exact flags; upstream --all-targets clippy under the exact ci.yml flags reporting only the pre-existing core/engine/turn_loop.rs:5499; mutations 4/4 red (wc cluster neutered, grep rewrite neutered, catalog bash-drop reverted, network guard dropped — the last failing locally at gh's argument parsing, never touching the network).

Round-18 wave receipt at the wave head (3fab32959): tools::shell:: 258 passed / 0 failed / 1 ignored (six new pins: the absolute-glob unit + tool E2E, the cmd comment wall, the owned-writer bounded stdin helper, the aggregate discovery budget, the spawn-env overrides), command_safety:: 100 / 0 (two new pins: the separated count-value validation, the parallel-charset NUL refusal), tools::subagent:: 609 / 0, tools::registry:: 49 / 0, tools::verifier:: 9 / 0, forkguard_ 240 / 0; cargo fmt --all -- --check clean; fork-CI clippy clean under the exact workflow flags; the cfg-surface probe compiles -Dwarnings-clean on aarch64-unknown-linux-gnu, aarch64-unknown-linux-ohos, and x86_64-pc-windows-msvc; full cargo test -p codewhale-tui --locked --lib (repo-required RUST_MIN_STACK=16777216) 12422 passed / 4 failed / 13 ignored, the four failures all in the documented rotating remote_control flake family (untouched files; both spot-checked green in isolation); mutations 4/4 red as itemized in the round-18 entry. Rebase freshness re-verified at the wave: origin/pinvou3-clean still f1eb10efc (merge-base == base tip), the push fast-forwarded 565049c0b..3fab32959.

Round-19 receipt at the slice head (rebased onto #139's 6337b0e0a, head 3406aeb30, tree d83ee5291a8d — built and verified locally, then landed through the Git Data API during a git-443 outage and finally force-pushed as plain git once the wall lifted): tools::shell:: 262 / 0, command_safety:: 100 / 0, tools::subagent:: 601 / 0, tools::registry:: 48 / 0, tools::verifier:: 9 / 0 — 1019 passed / 0 failed / 1 ignored (the ignored exporter pre-existing; tools::verifier:: is the current filter spelling — the pre-split tests::verifier:: name no longer matches, which is why an earlier draft of this receipt mis-tallied the split; the registry 49→48 and subagent 609→601 drops vs the round-16 combined-tree receipt are the split's test moves — the role/registry pins live on slice 3); cargo fmt --all -- --check clean; fork-CI clippy clean under the exact workflow flags; the escape-decode wall mutated off → both its pins redden (2/2), restored → green. CI: the fork CI ran green on the round-18 head f5aaaafd408 (run 41816238004, check 15m29s incl. the full workspace test job). At the round-19 head the check job is green under dispatch (run 41845678082) with the Gitleaks tree leg clean; the dispatch's introduced-scan leg trips 8 PRE-EXISTING full-history findings (dispatch has no pull_request.base.sha, so the workflow's documented fallback scans all 7406 reachable commits — findings in native_memory-era tests, config/persistence.rs, extension-host fixtures, none in files this slice touches), reproduced locally with the pinned gitleaks 8.30.1: the introduced range scans 0 leaks; the Merge Queue's integration run with a well-defined base is the authoritative gate.

Round-21 receipt at the re-aligned stack + wave (7b754c7df + 1f9635a894 + 6386b876b, tree bc3415f3c9): readonly_ 150 / 0, command_safety:: 101 / 0, forkguard_ 285 / 0 (--test-threads=2; at the harness-default 20 threads the suite's global test-env locks convoy past 12 min — a pre-existing suite property, every stalled test passes in <0.1 s isolated), tools::subagent:: 648 / 0; cargo check -p codewhale-tui --all-targets clean under the workspace deny(warnings); gitleaks over the introduced range 8c6c00dc0c..6386b876b: no leaks. DCO note: 1f9635a894's author email (asto18089@126.com) differs from its Signed-off-by (asto18089@users.noreply.github.com) — the gate passes; normalize at squash.

Round-20 receipt at the rebased slice head + fix wave (d6d41439d8 + 8f98b3e895, tree c7c17bfff): tools::shell:: 270 passed / 0 failed / 1 ignored, tools::subagent:: 629 / 0, command_safety:: 100 / 0, tools::registry:: 48 / 0, tools::verifier:: 9 / 0 — 1056 passed / 0 failed / 1 ignored; cargo fmt --all -- --check clean; cargo clippy -p codewhale-tui --all-targets --all-features --locked inventory: zero warnings pointing into slice-2's six files (remaining: the three pre-existing collapsible_if and CI-allowed too_many_arguments in untouched files); gitleaks 8.30.1: the introduced-range scan clean, and the full-worktree scan's 8 findings are all pre-existing main content outside every introduced hunk (verified hunk-by-hunk, incl. the lib.rs:15034 fake sk-live-… marker inside a redaction test); the widened six-key pin reddens on a dropped BASHOPTS entry, and the renamed four-key pin no longer exists to go stale.

Round-17 wave receipt at the wave head (64abd6c66e, superseded by the tree-identical rewrite 24e67da67): tools::shell:: 251 passed / 0 failed / 1 ignored (four new pins), command_safety:: 96 / 0, forkguard_ 240 / 0, tools::subagent:: 609 / 0, tools::registry:: 48 / 0, tools::verifier:: 9 / 0; fmt clean; fork-CI clippy clean under its exact flags; upstream --all-targets clippy under the exact ci.yml flags reporting only the pre-existing core/engine/turn_loop.rs:5499; mutations 2/2 red (continued-value wall neutered → its two fixtures; attached-path guard removed → the post--- admission legs); spliced-verbatim write_stdin probe-compiles clean on ohos, windows-msvc, and the host under -D warnings.

Checklist

  • This PR adds a new layer/module/abstraction — it names or deletes the layer it replaces (no new layer: one shared splitter, one routing predicate, one per-stage operand helper, and one *-only glob walker in command_safety.rs/shell.rs — globset is already a dependency but only matches, so enumeration plus the shell dotfile/.. rules still need the small hand walker; the charset refuses every other wildcard; the Verifier joins two existing role predicates; round 4 adds one shared stage splitter readonly_pipeline_stages replacing three inlined splits, one gh-reach predicate command_reaches_github_readonly replacing the deleted whole-command is_github_readonly_command, one ReadonlyGlobBudget struct, and one shell-lane Git stage rewriter that reuses the direct lane's splice table splice_git_read_hardening; round 5 adds one shared cd operand shape rule readonly_cd_operand_admitted replacing the classifier's inline checks, one per-kind compound refusal readonly_compound_refused_by_shell_kind (the readonly_sequence_refused_by_shell_kind spelling quoted in earlier drafts was a phantom name — no such fn existed in any tree), one binary-keyed shell_binary_speaks_pipefail, one MAX_READONLY_GLOB_COMPONENTS cap, and one stdin_closed_at_eof bool on BackgroundShell; normalize_windows_command_paths becomes a Windows-only wrapper over the unconditional protected_windows_command_paths); round 12 adds one bounded discovery child (watchdog thread + wall-clock kill over the shared child lock), one bounded stdin write (worker thread + deadline + the stdin_write_stalled flag on BackgroundShell), the textual .. synthesis in the existing walker, and the loop-strip + gitdir-validation mirror in the existing readers
  • Updated docs or comments as needed (docs/SUBAGENTS.md, docs/zh_hans/SUBAGENTS.md, docs/zh_hans/FLEET.md, bash guidance, roster/schema/intro strings, refusal copy) — slice-scoped at the split: the three docs/ files, roster/schema/intro strings, and the Verifier intro live in slice 3 (fix(fleet): the test role states its real contract #140, verified present there); this slice carries the bash tool-description rewrite and the [shell.readonly.command] refusal copy, both in this diff
  • Added or updated tests where relevant (red/green per fix; mutation-verified pins)
  • Verified TUI behavior manually if UI changes (no UI change)
  • Harvested/co-authored credit uses a GitHub numeric noreply address (n/a)

Follow-up (this repository, scheduled by the round-10 review)

Round-15 disclosed follow-ups (reviewed, deliberate not-fixed): the repository-discovery walk-up has no wall-clock deadline — the one pre-spawn scan without the glob-deadline/discovery-watchdog treatment, so a hostile deep tree (or NFS workspace) stretches every git stage's pre-spawn cost on the blocking pool. Round-16 adds to the same list (all reviewed, deliberate not-fixed): the discovery read loop never re-checks its watchdog flag, so a grandchild holding the killed child's stdout could out-wait the bound (unreachable under the pinned config today — defense-in-depth); the version probe runs unwatchdogged (trusted binary resolving through the sanitized PATH, memoized per PATH); discovery has no per-command aggregate budget (32 scanned repositories × the 10 s watchdog ≈ a 5-minute fail-closed worst case on a crafted multi-submodule FIFO workspace) — STALE as written at round 16: round 18 added the aggregate budget (MAX_READONLY_DISCOVERY_WALL_CLOCK, 10 s) this item predates, and the round-19 performance lane measured the honest worst at ≈10–20 s fail-closed (FIFO variant ≈10 s; slow-children variant ≈10 s aggregate + one ≤10 s in-flight overshoot), so the item is closed as implemented — kept here only to correct the figure; BackgroundShell::drop's PTY arm waits unbounded where the Process arm uses the bounded helper; the dash-named match wall is position-blind (a final match starting with - refuses even where the expansion word could never parse as an option — wc -c ./*, cat -- * — safe direction; refining requires per-slot expansion knowledge worth its own change); the .git gitfile reader keeps the UTF-8 wall for non-UTF-8 byte targets while the commondir reader is byte-faithful on unix — an internal asymmetry, both fail-safe; the sanitized PATH is recomputed per discovery child and before the version-gate memo lookup (~2 redundant full canonicalization passes per admitted git command); two canonicalization sites remain on the executor thread under the manager lock (shell.rs env block and program resolution — pre-existing structure; the pooled roots could thread through); the discovery reap's watchdog-fired branch ends in an unbounded blocking wait() (D-state child only, disclosed at the site; wait_child_bounded exists); Windows stdin writers remain blocking under the manager lock (disclosed since round 12; ChildStdin::try_clone makes the pipe lane servable by the unix bounded worker); the two commondir consumers share the value reader but sit on different file-reading layers with different failure mappings (predicate declines, scan refuses — fail-safe both ways; one resolved-reader helper would unify); the git preamble skip (--no-pager/-C) is hand-rolled in three places; ! refuses outright where a mid-word ! is inert on every supported shell (fidelity polish); raw-git test controls bake git-2.43 semantics without the version gate the filter pins use and inherit ambient GIT_* env (fail-loud hygiene); BASHOPTS/SHELLOPTS belt-and-braces startup pins — implemented in the round-20 wave: the round-15 claim that the child-env allowlist already drops both was wrong (the spawn path inherits the operator's environment; only the listed overrides are applied), so both keys are now cleared in readonly_spawn_env_overrides and pinned by readonly_spawn_env_overrides_carry_the_six_fixed_keys.

Replace parse_git_config_settings and its two consumers with enumeration through git itself: the UNscoped git config --list -z in each discovered repository, run under the same hardened discovery environment the argv config pins now arm (the argv -c pins outrank every config file, so the enumeration inherits the fsmonitor/hooks walls without a version floor), with origin filtering over the listing. NOT the scoped --local spelling an earlier revision of this section named: --local lists only include.path=..., never the included filter.* records, and never reads config.worktree, so that spelling would convert today's fail-closed include walls into silent under-scans — the round-7 class behind a green-looking scan. The enumeration route dissolves the include/includeIf/worktreeConfig refusal walls into enumerated coverage (a functional improvement — repositories with includes stop refusing every git evidence command), inherits git's grammar permanently instead of chasing dialect shapes (rounds 7→8→9 found 1→5→7 escapes in successive approximations of the same ~200-line parser), and costs one extra child per scanned repository the lane already pays; when git cannot be invoked at all, every git evidence command refuses anyway, so there is no parser fallback to keep. Probe -c-entry visibility in the listing and includes-of-includes before deleting the parser; the 17-shape differential this branch's probes cover becomes the regression suite.

Follow-up (parent repository)

pinvou-agent picks this up when its CodeWhale gitlink advances to the squash of this PR on pinvou3-clean, together with the outstanding fork-guard.sh register/fingerprint updates already noted by #115–#125.

@asto18089 asto18089 left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fresh review — verdict: CHANGES_REQUESTED (2 BLOCKER, 4 MAJOR)

Reviewed from scratch at head faae44b7b (5 DCO commits, 10 files, +960/−78). The branch already rides the pinvou3-clean tip 6e1ee88ee (merge-base == base), so no rebase was needed. CI is green on this head. Twelve independent review lanes plus my own reproductions; every BLOCKER/MAJOR below was reproduced by execution (not inferred), with HEAD-vs-base comparisons where "new vs pre-existing" matters.

The three fixes are real and root-caused: the stdin-EOF diagnosis is correct and both pins bite red/green; compound probes were a genuine live pain point; the test role contract was dishonest and is now honest. But the compound grammar opens two workspace-boundary content reads that the PR's own cd-symlink hardening does not cover, the posture re-keying changes more than the body claims, and one hunk is out of scope.

BLOCKER-1 — cd as a pipeline stage escapes the operand gate (arbitrary file read under ShellPolicy::ReadOnly)

enforce_readonly_segment_operands (crates/tui/src/tools/shell.rs:4156-4174) returns early in its cd branch after checking only argv[1]. The gate splits on ;/&&/|| but never applies the | split the classifier applies, and is_agent_readonly_segment admits cd as any pipeline stage (command_safety.rs:689) — contrary to its own doc, which says "standalone segment or sequence prefix" (command_safety.rs:565). So for cd sub | cat /etc/passwd the gate sees ["cd","sub","|","cat","/etc/passwd"], resolves sub, returns Ok, and /etc/passwd is never inspected; the shell runs the pipeline with cd in a subshell.

Reproduced end-to-end through LowercaseBashTool with ShellPolicy::ReadOnly (HEAD): cd sub | cat /etc/passwd → success=true, content ## # User Database …. Same for cd sub | cat <ws>/link (symlink → outside file, content returned). Base refuses the whole shape at the classifier. The body's claim that the gate "splits with the same shared splitter and judges every segment" is false for pipelines.

Fix: enforce the documented position rule — refuse cd as a pipeline stage (in the classifier and/or the gate) — or make the cd branch fall through to per-token checking of the remaining stage(s). Add a pin for cd sub | cat /etc/passwd and cd sub | cat; cat link.

BLOCKER-2 — glob operands expand past containment; the sequence/cd forms are new

The operand loop checks the literal spelling; * never exists on disk, so joined.exists() (shell.rs:4239-4240) skips it, and the shell lane then expands the glob — including symlinks that point outside. Reproduced (HEAD): cd sub; cat * and cd sub && cat * with sub/link → /tmp/outside/secret.txt → success=true, content inner\nTOPSECRET-OUTSIDE. Also cd sub; head -c 100 l*, cd sub; od -c *, and metadata oracles (wc -c *, stat *, ls -la *, file *).

Honest attribution: the pipeline form (cat * | head) leaks at base too (same gate skip, same shell lane), so the core gap pre-exists. This PR (a) newly makes it reachable through every sequence and post-cd form, and (b) adds a doc comment that is false as written — command_safety.rs:572-573: "glob * arguments, expanded by the shell only against workspace paths the operand gate already confines". Since the PR's stated review-round MAJOR was exactly the sibling escape (cd sub; cat link), this one belongs in the same change.

Fix: refuse operands containing */? at the gate (or expand and verify each match), and correct the doc comment.

MAJOR-1 — gh network policy is bypassed by any compound

enforce_readonly_github_network_policy (shell.rs:3887-3910) decides via is_github_readonly_command(command) on the whole command, which requires is_parallel_readonly_command and therefore returns false for any compound → return Ok(()) with the decider never consulted. Reproduced under a Deny-all NetworkPolicyDecider: bare gh repo view → blocked with the policy message; pwd; gh repo view and pwd && gh repo view → gh executed. Pre-existing via pipelines (gh repo view | head hits the same whole-command keying), but the PR newly admits the ;/&&/|| vectors and rewrote exactly this gh exemption per-segment in the operand gate (shell.rs:4146-4149) while leaving its twin network seam whole-command. Fix: evaluate the network policy per segment with the same splitter (and per pipeline stage), i.e. if any segment is a gh read, consult the decider.

MAJOR-2 — the posture re-keying changes capability for six roles, not one; the truth-table claim is false

posture_permits_tool's Required branch (mod.rs:15291-15293) and shell_is_denied (mod.rs:15445-15447) now key on role_posture_permits(role, Required), which is true for Worker, Scout, Reviewer, Builder, Verifier (all for_role().shell == Full, worker_profile.rs:212-237) and unconditionally for Custom (mod.rs:14512). The old key was runtime.shell == Full. So every one of those roles flips whenever its runtime shell is ReadOnly, not just the Verifier.

Executed matrix at base and HEAD (private worktrees, same scratch harness): for runtime shell ReadOnly, shell_is_denied and the Required posture flip true→false / false→true for general, implement, custom, test, explore and reviewer; the Run catalog flips false→true for general/implement/custom/test (explore/reviewer stay catalog- and dispatch-blocked by bounded_inspection). Concrete reachable path — the plain agent-tool nesting (Scout parent → Builder or read-only-start Custom child, derive_child → write=false, shell=ReadOnly, no ChildAuthority deny list): Run {"action":"tests","args":"--lib x"} at HEAD → OK … exit_code 101 … could not find Cargo.toml (cargo was forked); at base → [role.posture.denied]. Run {"action":"verifiers"} likewise executes at HEAD (gate scan ran). Custom commands, automation, raw shell and writes still refuse (envelope write_denied/unbounded), and the Fleet path stays closed because ChildAuthority::clamp installs the verification deny list + sentinel (pinned by a_read_only_parents_delegation_never_widens_authority). Scout/Reviewer remain blocked by bounded_inspection. The (write=true, shell=ReadOnly) cell — where the new envelope would admit the whole Executes class (MCP/automation/plugins) — is not reachable by any operator path (traced: presets, manifests, spawn schema, engine turn clamp only reaches ToolContext), so that is latent, not a merge risk.

Net: the body's "an eight-role truth table was verified pre/post … the only delta is Verifier's bounded bash lane" is false by execution, and the "Checked and left alone" paragraph's "stays bounded and fail-closed" understates it — a read-only parent's Builder/Custom child now starts the workspace test runner that the parent cannot. That is an authority widening through delegation on the plain path, unpinned. Fix: grant the carve-out only to the Verifier (e.g. matches!(runtime.shell, Full) || (matches!(agent_type, Verifier) && runtime.shell != None) in both seams), which makes the claim true and keeps the intended Verifier surface; pin the Builder/Custom-under-read-only-parent cells. If the broader re-key is actually intended, say so in the body and pin it.

MAJOR-3 — unconditional foreground stdin close breaks interact after a foreground→background detach

execute_foreground_via_background now closes the write end unconditionally (shell.rs:4466-4472) before the poll loop that services detach requests (take_foreground_background_request, shell.rs:4487; UI trigger request_active_foreground_shell_background, tui/ui.rs:1095-1106). Probed: foreground sleep 30 → detach → write_stdin("hello\n") → Err(stdin is not available for task …). Pre-PR a no-payload foreground run kept the writer (pipe or PTY), so feeding input after detach worked. The new comment "no later interact call" (shell.rs:4455-4459) is false for this path; no pin covers it (detach tests at tests.rs:3340/3404 never interact). Fix: close the write end only when the command finishes without a detach, or re-open/keep the writer when a detach request is observed; pin detach-then-interact.

MAJOR-4 — out-of-scope hunk: the reviewer-prompt sentence

REVIEW_AGENT_INTRO gains a line-number-discipline sentence (mod.rs:16588) plus two assertions in verifier_and_reviewer_intros_state_the_evidence_contract. It edits the reviewer's prompt, which is none of the three fixes, and the body's enabler ("wc -l — now actually available to read-only roles") is false for the reviewer: Scout/Reviewer/Planner already had the bounded bash lane at base (allows_bounded_readonly_bash at 6e1ee88ee). Disclosed, one sentence + test, trivially separable — please move it to its own PR.

MINOR

  • set -o pipefail prefix on compounds with a pipe (shell.rs:2193-2194) fails on dash/fish hosts ($SHELL or /bin/sh is used verbatim, shell_dispatcher.rs:487-550); pre-existing for bare pipelines, newly reachable for a; b | c. Fail-closed, host-dependent. Source-derived, not reproduced (host is zsh).
  • Git compounds take the shell lane and lose hardened_readonly_argv's --no-ext-diff --no-textconv --no-show-signature splice (shell.rs:2198 vs :2200); env hardening still applies. Pre-existing for git log | wc.
  • readonly_sequence_segments doc (command_safety.rs:622-628) says the charset gate "has already refused every metacharacter that could make a quoted separator behave differently" — quotes and backslash are not refused; the conclusion (mis-split → seam refusal) holds and was verified (cat "x;cat y" admitted by classifier, refused by seam), but the stated reason is wrong.
  • The cd fold is O(depth²) in nested-cd chains (cd a;cd a;…: 125 → 15 ms, 250 → 83 ms, 400 → 308 ms measured, debug), PATH_MAX-bounded and fail-closed, with no command-length cap upstream. Flat chains are linear (10k segments → 72 ms). Cheap hardening: cap segment count or command bytes at the tool boundary.
  • Attached short-option values are refused for advertised families: du -sh ., cut -d, -f1 f, comm -12 a b, od -cv f refuse; the separated spellings admit. A model pays one refusal + rewrite on the most common spelling.
  • Stale copy the PR missed: docs/SUBAGENTS.md:281-283 (test when-to-use bullet still describes only the verification surface); docs/zh_hans/FLEET.md:124 ("只保留…有界验证工具" is now false for the verifier); fleet/executor.rs:241-246 (3-role list + comment "Verifiers keep the dedicated Run surface" now inaccurate); fleet/role.rs:644 retains legacy Bash only for S/R/P while the registry path carves the verifier via a second mechanism; worker_runtime.rs:1588 label pin still lists three roles (the PR's declared follow-up).
  • The guidance sentence (guidance.rs:88-92) is unconditional, but foreground combined_output:true runs under a PTY (shell.rs:5496). On macOS the PTY writer drop still delivers EOF (verified: cat exits in ~57 ms); on Linux the reader's dup may keep the master open — unproven here.
  • Four enumeration sites now list Scout | Reviewer | Planner | Verifier while role_requires_read_only_shell already exists and is imported in mod.rs; two sibling sites already diverged (above).

NIT

assert!(!custom.to_string().is_empty()) (subagent/tests.rs:8341) is vacuous after expect_err; prose-matching pins at :8319/:8354 ("not available" || "not allowed") vs the tag-based pin used for the lane refusal; bash_lane_role reads as "is lane role" but means "must not be granted here" (the literal "bash" argument is load-bearing — passing name would let Bash re-enter); two naive splitters now coexist (readonly_sequence_segments :629 and split_command_segments :1587) with different empty-segment/& contracts and no cross-reference; od re-implements the option-loop scaffolding of agent_text_filter_options_match; refusal copy lists ; unconditionally while cmd.exe refuses it at the seam; gh … reads is a family summary (gh repo list, gh release download refuse).

Verified OK (by execution)

  • Fix 1 root cause confirmed from base: execute_sync_sandboxed inherited stdin (hunk is in that function, shell.rs:2368-2377); foreground kept the pipe. Payload path still writes+flushes before close (shell.rs:2887-2892); manager close is idempotent on PTY/persist/completed lanes. Background interact pins green. Red/green: removing the Stdio::null() branch reddens exactly the sync pin; restoring the if stdin_data.is_some() guard reddens exactly the foreground pin.
  • All 8 claimed mutations redden real pins (no inert pins); m8 reddens exactly the drift pin; m5 reddens only the classifier-level routing pin on macOS (/usr/bin/cd exists), confirming the body's host-independence rationale. Three extra mutations show the Verifier re-key and bash_lane_role are load-bearing.
  • Verifier lane: canonical bash probe runs (wc -c → 10); Bash/BASH/exec_shell refuse at role_blocks_unhardened_process_tool before any normalization; rm -rf refuses with [shell.readonly.command]; Run default/selection admitted; custom Run refused; is_parallel_readonly_command byte-identical to base.
  • Classifier probe matrix (65 inputs) fails closed on every tricky separator/quote shape; the PR's own pinned escapes (cd sub; cat link, cd sub && od -c link) are refused end-to-end.
  • Every family in the new refusal copy is actually admitted as described; nothing promised is refused. en/zh doc edits are faithful.
  • Smuggle audit: all hunks map to Fix 1/2/3 except MAJOR-4; DCO 5/5; merge-order claims exact (git merge-tree: one string-literal conflict vs #123, clean vs #124/#125).
  • Gates (macOS arm64, stable 1.99.1): cargo fmt --check clean; clippy (CONTRIBUTING all-targets form) → only the pre-existing repo_law.rs:638 cloned_ref_to_slice_refs; targeted counts exactly as claimed (79/0, 148/0/1, 606/0, 361/0); full --lib twice: 12287/1 and 12286/2, failures = the named flake class, each 1/1 alone.
  • Windows cfg block: names exist (ShellKind::Cmd, global_dispatcher().kind()); force-compiled on macOS by flipping the cfg — compiles clean.

Net

Direction is right and the fixes are real, but BLOCKER-1/2 are workspace-boundary content reads introduced (or newly reached) by this change, MAJOR-1 bypasses a network policy on the same new surface, MAJOR-2 contradicts the body's central claim, MAJOR-3 regresses detach+interact, and MAJOR-4 should be its own PR. Please fix and re-request; a fix wave that refuses cd as a pipeline stage, refuses glob operands at the gate, applies the network policy per segment, and narrows the posture carve-out to the Verifier would close everything above the MINOR line.

@asto18089

Copy link
Copy Markdown
Collaborator Author

Fix wave for review 5669186887 — head 3a297ded4 (5 DCO commits on top of faae44b7b, base pinvou3-clean unchanged at 6e1ee88ee, no rebase needed)

Every BLOCKER and MAJOR is fixed and pinned; each new pin was shown to redden under a reverse mutation that restores the old behaviour, then go green again. PR body refreshed so its claims match the code (the "only delta is the Verifier" truth-table claim and the "gate judges every segment" claim are gone; the review rounds are now written up as such).

Finding Fix Commit Pins (all red under reverse mutation)
BLOCKER-1 cd as a pipeline stage skipped every operand after the | Classifier refuses cd as a pipeline stage (a stage runs in a subshell and persists nowhere the gate could fold); the gate judges every stage on its own. While doing this I found the same shape via gh: gh issue list | cat /etc/passwd skipped the gate too because the gh exemption covered the whole segment — that one predates this PR and is closed by the same per-stage walk (gh now exempts only its own stage). c5e2b48a6 agent_readonly_shell_rejects_unsafe_compound_cd_and_od_shapes (classifier), readonly_operand_gate_judges_every_pipeline_stage_on_its_own (gate, cd + gh cases), readonly_shell_refuses_pipeline_cd_and_glob_escapes_end_to_end
BLOCKER-2 glob operands expanded over outside symlinks * operands (the only wildcard the charset admits) are expanded against the live cwd the way the shell does — component by component, hidden entries only when the component asks, symlinked dirs followed — and every match is judged by resolved location. Closes the pre-existing cat * | head form as well; the false "already confines" doc comment is corrected. No glob library: with ?/[/{ charset-refused, a *-only matcher is ~40 lines. c5e2b48a6 readonly_operand_gate_judges_glob_expansions_by_resolved_location, the end-to-end pin above
MAJOR-1 gh network policy bypassed by compounds The guard judges every sequence segment and pipeline stage with the classifier's splits; a gh read anywhere consults the decider. Also closes the pre-existing gh … | head form. c5e2b48a6 readonly_github_shell_calls_obey_the_host_network_policy_before_spawn (compound cases)
MAJOR-2 posture re-key flipped six roles holds_shell_authority keys on the held shell policy plus one named exception (Verifier with a non-None shell); role_posture_permits is no longer consulted for shell authority. Builder/Worker/Custom children of a read-only parent are back to refusing Run at posture, envelope and catalog. allows_bounded_readonly_bash and the catalog branch read the role set from role_requires_read_only_shell instead of enumerating it; bash_lane_role renamed to say what it does. e52aa813c read_only_shell_children_of_other_roles_hold_no_shell_authority (new), the existing Verifier pins stay green
MAJOR-3 detach-then-interact regression Kept the EOF-at-spawn contract (reverting it reintroduces the $SHELL wedge; a pipe cannot deliver EOF and later accept input). The false "no later interact call" comment is replaced by the real contract, interact on a detached foreground command now says the stdin is at EOF and points at background=true, and the behaviour is pinned instead of accidental. 3a297ded4 detached_foreground_command_reports_its_closed_stdin_on_interact
MAJOR-4 reviewer-intro sentence out of scope Dropped from this PR (sentence + its two assertions); can be proposed on its own. 22fbe0bfb —

MINORs taken: clustered short options in the text-filter grammar (cut -d, -f1, comm -12, sort -rn; expansion only when every letter is admitted — 09aab42fe, pinned by agent_text_filters_admit_clustered_short_options); 32-segment cap on sequences for the quadratic nested-cd fold (agent_readonly_shell_bounds_the_number_of_sequence_segments); splitter doc now states why quoted separators mis-split instead of claiming they cannot; verifier added to the narrowed-shell label pin; vacuous !is_empty() assert replaced by one naming the two walls that own the custom-Run refusal (the dispatch refusal comes from the approval wall, not the unbounded guard, which the vacuous assert had hidden); docs/SUBAGENTS.md test bullet, docs/zh_hans/FLEET.md, and the fleet/executor.rs comment updated; refusal copy names the two newly refused shapes.

Deliberately not changed, disclosed in the body's "Checked and left alone": find -L/-follow outside-filename listing and the git compounds' missing --no-ext-diff --no-textconv --no-show-signature splice (both predate this PR; env hardening still applies to git); set -o pipefail on dash/fish hosts (same lane bare pipelines already used); the Linux PTY/combined_output corner of the guidance sentence (true on macOS, unproven on Linux here); the executor-path ToolShellAuthority list still excludes the Verifier (comment now says so — a behaviour change there deserves its own look).

Gates at 3a297ded4 (macOS arm64, stable 1.99.1): cargo fmt --check clean; clippy (CONTRIBUTING all-targets form) exit 0 with the documented repo_law.rs:638 baseline allowed; command_safety:: 81/0, tools::shell:: 152/0/1 ignored, tools::subagent:: 607/0, fleet:: 361/0; full --lib 12292/3, the three in runtime_threads::tests (known load-timing class, module untouched, each 1/1 alone, 183/0 at --test-threads=2). Merge-order note unchanged: still exactly one string-literal conflict with #123 (the refusal copy), clean against #124/#125.

@asto18089 asto18089 left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fresh review (round 3, from scratch) — head 3dbb1f44c on pinvou3-clean 6e1ee88ee

Verdict: CHANGES REQUESTED (posted as a comment because author == reviewer). No blocker, 6 MAJOR (4 new, 2 pre-existing-but-inside-the-PR's-claimed-closure), 2 pre-existing escapes in the same gate that should ride along, and a set of minors.

Setup. Merge-base == pinvou3-clean tip, so the rebase is a no-op; CI green at head. 15 independent review lanes (classifier adversarial, operand-gate live probes under three shells, authority matrix at base and head, two mutation batteries, base-vs-head defect reproduction, design, docs, scope, gates, perf). Every finding below was re-executed by me, not just reported by a lane. Earlier rounds were not consulted.

What holds up (verified)

  • All three defects reproduce at base and are fixed at head with independent probes identical in both trees: foreground cat / $SHELL wedge 3071 ms → 30 ms; sync lane TimedOut on a held-open pipe → Completed in 11 ms; wc -c f; wc -l f / cd sub && … / od -c f / cut -d, -f1 f refused → admitted and executed; verifier bash absent from catalog and refused → admitted and executes, Run surface permitted in both trees.
  • Gates: cargo fmt clean; strict all-targets clippy reports exactly the recorded repo_law.rs:638 baseline and nothing else; command_safety:: 81/0, tools::shell:: 152/0/1, tools::subagent:: 607/0, fleet:: 361/0 — digit-for-digit as the body claims; full lib 12295/0/13 (with the repo-required RUST_MIN_STACK=16777216; without it the suite aborts in a pre-existing runtime_api test — environment, not this PR); rest of workspace 2311/0.
  • Mutations: 31 of 35 reverse mutations redden exactly their named pins (17/20 classifier+gate+network, 14/15 stdin+authority), including the three authority-widening variants and both mutations that equal base behavior. The 4 survivors are pin-strength gaps listed under minors, none a live hole.
  • No smuggling: every hunk attributes to F1/F2/F3; 11 commits DCO-signed, conventional, messages match diffs; the secret→outside_path rename commit is pure test churn. Merge-tree claims exact: #123 conflicts on precisely the [shell.readonly.command] literal, #124/#125 clean; is_parallel_readonly_command byte-identical (same md5 base/head); #119 is in base.
  • Hardening parity on the widened shell lane was probed, not assumed: with a repo-local diff.<driver>.textconv / diff.external / core.fsmonitor hook, the shared env (GIT_EXTERNAL_DIFF=<inert>, GIT_CONFIG_COUNT core.fsmonitor=false …) stops the driver from executing on both lanes (shell lane fails loud with "external diff died" instead of showing the raw diff — nit only).
  • Quoted-separator mis-splits are fail-closed as documented: every cat "a;b"-family shape either refuses at the classifier or dies at the seam's shlex; I could not construct an input where both halves classify, shlex accepts, and the shell runs a non-admitted command.

MAJOR — must change before merge

M1 — NEW — cd <glob> is judged as a literal while the shell cd's into the first match → outside read.
is_agent_readonly_cd (command_safety.rs:1019-1034) admits *; the seam's cd branch (shell.rs:4194-4213) canonicalizes the spelling cwd/cdt*. With a literal dir cdt* (inside) and a sibling cdt -> .. (outside, sorts first), bash/sh glob cdt* → [cdt, cdt*], cd takes the first and ignores the rest. Executed under SHELL=/bin/bash, /bin/sh, and $SHELL unset: cd cdt*; cat secret.txt | head -5 → success=true, stdout = the outside secret. zsh fails closed. Base refuses all ; compounds, so this is introduced by the PR's own surface. Fix: refuse * in the cd operand at the classifier (the doc already says "one relative path"); optionally also judge a glob cd operand through the walker and refuse multi-match.

M2 — PRE-EXISTING leak, but inside the closure this PR claims — .* expands to ./.. on bash/sh; the walker never sees ...
collect_readonly_glob_matches (shell.rs:4352-4395) enumerates with read_dir (:4374), which never yields ./..; bash and /bin/sh (macOS fallback when $SHELL is unset) expand .* to . .. .h. Executed: cat .*/secret.txt | head -5, pwd; cat .*/secret.txt | head -5, sed -n '1,5p' .*/secret.txt | head -5, od -c .*/secret.txt | head -4 all return the outside content under ReadOnly on bash/sh (the od route is new at head). The body's "expands each one … the way the shell expands it" is false for this spelling. Fix: when a component starts with ., synthesize . and .. as candidates (readonly_star_match(".*", "..") already returns true) so they are location-judged; zsh hosts then over-refuse .*, which is fine.

M3 — PRE-EXISTING for the bare form, NEW over-claim — a * in a gh stage makes the network guard skip the policy.
enforce_readonly_github_network_policy (shell.rs:3900-3905) keys each stage on is_github_readonly_command, which calls is_parallel_readonly_command first (command_safety.rs:1086-1089) whose charset refuses * (:482) — while the agent classifier admits * and a gh segment falls through readonly_tokens_admitted with no charset gate. Executed: gh pr list --search '*' and pwd; gh repo view * → classifier true, guard sees no gh stage → a Deny network policy is never consulted; the gh operand exemption also skips the *. Contradicts "a gh read anywhere in a compound consults the decider exactly like a bare one". Fix: recognize gh stages on tokens (shell_words + GITHUB_READONLY_PREFIXES + option/host tables), not via the parallel gate; pin pwd; gh pr list --search '*' under Deny.

M4 — NEW — the glob walker is an unbounded, symlink-cycling, in-host walk with no cap, no visited-set, no timeout, and no spawn_blocking.
collect_readonly_glob_matches follows symlinked dirs via is_dir() (:4369/:4391), collects all matches before judging (:4335-4336), canonicalizes each, and runs inside async fn execute (:5163 → :5375; grep -c spawn_blocking shell.rs = 0). The child has a watchdog; the gate has none. Measured on a 30-file workspace with two -> . symlinks (test profile): depth 6 → 23 ms, 8 → 108 ms, 10 → 507 ms, 12 → 2.3 s, 14 → 10.4 s (×4.5 per +2; minutes at depth ≥ 16, multi-GB match vector by depth 20). Reachable by a read-only child's command choice (cat */*/*/*/*/*/*/*/*/*/*/*) in any workspace holding an ancestor-pointing symlink (pnpm/Bazel link farms, docs -> . mirrors). MAX_READONLY_SEQUENCE_SEGMENTS does not bound this (32 × cat * over a 1k-entry dir is already 309 ms). Fix: (dev, ino) visited-set, hard match cap (fail closed), judge-while-walking with early return, and spawn_blocking around the gate.

M5 — NEW, deliberate but undisclosed as delegation widening — a read-only role parent now obtains test-runner execution via agent(type=test).
holds_shell_authority (subagent/mod.rs:15430-15434) = Full || (Verifier && shell != None). On the in-process path a Verifier child of a Scout/Reviewer/Planner lands at ShellPolicy::ReadOnly and keeps Run. Executed at head: parent ∈ {Scout, Reviewer, Planner} → test child: posture_run=true, envelope_denies=false, delegation_refusal=None, catalog offers Run and bash, and Run {"action":"tests"} reached RunTool and ran cargo (exit 101 "could not find Cargo.toml" — execution-stage, i.e. the whole permission stack passed). Same probe at base: [role.posture.denied]. Walls that do hold: shell-less sessions (allow_shell=false → None, refused both trees), Builder/Worker/Custom children, the exact-fleet sentinel, reject_unbounded_verification. So this is bounded (default/selection gates + classifier bash) and the operator ceiling is respected — but it is a child gaining a capability its parent lacks, and the PR edits the very paragraphs that still say the opposite: docs/SUBAGENTS.md:93-96 ("delegating to obtain shell is therefore mechanically useless … the only shell path a read-only parent has"), docs/zh_hans/SUBAGENTS.md:40, and the comment in fleet/exact.rs:1374-1376. Decide one: (a) keep the invariant — carve out only when the parent's live shell was Full (i.e. the ReadOnly came from the role's own narrowing, not from the clamp), or (b) accept the exception and state it explicitly in those three places. I recommend (a); (b) is acceptable if sign-off is explicit.

M6 — NEW — the roster and docs/zh_hans/FLEET.md:124 promise fleet members a bash lane the fleet-executor path never grants.
authority_envelope_for_worker (fleet/executor.rs:238-255) gives the Verifier ToolShellAuthority::None (the shell branch names only Scout/Reviewer/Planner — the in-code comment says so), and enforce_tool_authority (tools/registry.rs:565-588) refuses every bash spelling unless shell == ReadOnly. So a TOML-fleet verifier/test member gets Run and no bash, while FleetRole::Verifier.description() (fleet/role.rs:196) and the new zh FLEET.md sentence ("以及分类器门控的只读取证探针(规范的 bash…)") promise it. For an "honest test role" PR this is the one dishonest sentence. Fix: extend the executor's shell branch to the Verifier (mirrors the registry) or qualify both texts with the path.

PRE-EXISTING escapes in the same gate — recommend fixing here (each is a few lines)

P1 — sed -n <range>p -e w/<abs> f: everything after tokens[2] is unchecked. is_agent_readonly_sed (command_safety.rs:984-1002) reads only -n and the first script; the operand gate treats w/tmp/owned as a nonexistent relative path and skips it. Executed: classifier true, gate Ok. GNU sed (getopt permutation) runs the second script → write (w), read (r), or exec (e) outside the workspace on Linux; BSD sed fails closed (executed on this host). The rewritten doc bullet (:581-582, "no script verbs … can appear in a two-token range script") re-asserts the false claim. Fix: tokens.len() == 3.

P2 — zsh =cmd operands pass the gate and read absolute paths on the shell lane. The =-split (shell.rs:4237-4240) turns =ls into value ls, nonexistent → skipped (:4300); zsh's default EQUALS expands =ls to /bin/ls. Executed: cat =ls | wc -c → 252512 (bytes of /bin/ls) under ReadOnly with zsh. Base leaked via pipes; the PR adds pwd; cat =ls and cd . && cat =ls. Fix: refuse operands whose spelling starts with =.

MINOR

  • shell.rs:5005 tool description still says a pipeline "is the one exception" that runs through the shell; sequences and cd-prefixed commands do now too, and a standalone cd sub affects only that one call — the model is not told.
  • guidance.rs:88-91 foreground-stdin sentence over-generalizes: a foreground combined_output run is on the PTY lane where the close is a no-op (stdin stays a live terminal → still wedges), and the non-Unix interactive: true foreground lane inherits the console. Also the sentence has no pin (mutation M4 of the stdin battery stayed green).
  • docs/zh_hans/SUBAGENTS.md:123 verifier bullet did not receive the shell-posture sentences the English bullet got.
  • Pin-strength gaps (mutation survivors): the gate-side pipeline-stage cd arm (shell.rs:4179-4183) is masked by a disjunctive assertion (cwd.pipeline_stage || operand.outside_workspace) — add a case whose only defect is the piped cd; the cd resolution base is unpinned because every gate test uses cwd == roots[0] — add one with a nested cwd; the hidden-entry rule (:4381-4384) is unpinned in the drop direction; the classifier-lane gate's only pin lives inside scout_shell_respects_parent_shell_and_network_ceilings.
  • verifier_child_executes_a_bounded_readonly_bash_probe_for_evidence runs wc -c without #[cfg(unix)] while its shell-side sibling is gated; the 3000 ms / 2900 ms foreground EOF margin is 100 ms; the e2e refusal test pins content absence but no refusal tag; [shell.readonly.operand.unresolved] (dangling symlink / non-UTF-8 match), od end-to-end, and the Windows [shell.readonly.sequence.unsupported_shell] branch have no tests.
  • Direct-argv lane validates globs then execs the literal * — single-command globs (wc -c *.md) have never actually worked; the gate's expansion is inert there. Follow-up: expand in Rust on that lane.
  • Windows: set -o pipefail; is still prepended for cmd/PowerShell pipelines (pre-existing; the new cmd ; refusal states the principle this violates); Stdio::null() in the sync lane conflicts with the project's own hooks/executor.rs:2179-2181 note that NUL is insufficient on Windows (unpinned there).
  • od -A d / -N 8 values are judged as path operands (false refusal if a same-named outside symlink exists); od bypasses the cluster expander its siblings use; -h is BSD-only.
  • Comment at shell.rs:4630 says the PTY lane "carries no pipe handle" — it does (StdinWriter::Pty), the drop is merely inert. piped (shell.rs:2196) now holds non-piped commands — rename. MAX_READONLY_SEQUENCE_SEGMENTS needs no pub(crate). Refusal copy omits the 32-segment cap. gh -q alias missing. Doc bullet under-reports find primaries (-truncate).

Disposition

Fixes 1 and 3 are sound in mechanism; fix 2's compound/glob surface needs M1–M4 closed and the two pre-existing seam holes (P1, P2) should go with them since this PR is the hardening of exactly that gate. M5 needs a decision (my vote: keep the invariant), M6 a one-branch or one-sentence fix. Re-review after the fix wave; all repro probes are in my worktrees (~/pinvou-worktrees/cw-pr126-{L2,L4,L7,L13,L14}) and can be turned into pins.

@asto18089

Copy link
Copy Markdown
Collaborator Author

Fix wave for the fresh-review comment (5673056115): all six MAJORs, both pre-existing escapes, and the valuable minors are fixed and pushed as 9 DCO commits (26da4fcec..5b3eec687); body refreshed with a Round-3 section.

MAJORs

  • M1 cd <glob> (gate judged the spelling, shell entered the first expansion) — cd operands now refuse * at the classifier (26da4fcec).
  • M2 .* → .. on bash/sh — the walker synthesizes ./.. candidates at every level a dot-component matches and judges them (e60c3027b); zsh hosts then fail closed on .*, the safe direction.
  • M3 gh * skipping the network policy — the per-stage check is token-level (is_github_readonly_stage); the whole-command parallel predicate is unchanged for its own callers (845ff7210).
  • M4 unbounded in-host glob walk — judge-while-walking + (dev, ino, remaining-pattern) re-entry dedup + 4096-match / 50 000-node caps + spawn_blocking (e60c3027b). Measured: the depth-14 case that took 10.4 s now completes the full expansion in milliseconds; the cycle no longer explodes.
  • M5 verifier carve-out widening through a read-only role parent — the registry now records parent_shell_authority (full shell after the parent clamp, before the role's own narrowing) and keys the carve-out on it; the scout→test child is refused at [role.posture.denied] again while the direct full-parent case keeps its gates (ad24bc717). With the invariant restored, the existing "delegating to obtain shell is mechanically useless" docs hold as written — no doc exception needed.
  • M6 false fleet-member bash promise — kept the envelope contract (bounded verification and Bash authority are mutually exclusive by normalized()), corrected the zh FLEET sentence to scope the lane to the agent nesting path (8d35ba1af).

Pre-existing escapes (fixed here, as recommended)

  • P1 sed -n 5p -e w/tmp/x f — everything after the script must be a plain input file (a868182f9).
  • P2 zsh =cmd operands — refused outright at the gate (488891140).

Minors (e271e10a5 + 5b3eec687): routing description now names sequences/cd (the "one exception" sentence is gone); guidance stdin sentence qualified to non-tty lanes and pinned; pipefail prefix and cmd.exe ; refusal are pure per-shell-kind decisions with cross-platform unit tests (Windows shells no longer receive the prefix); gh -q admitted; refusal copy names the 32-segment cap and the cd-wildcard refusal; zh verifier bullet restored; verifier probe test unix-gated; EOF test margin widened to 2000/1900 ms; e2e refusals now assert boundary tags; od runs end to end; the three masked pins (piped-cd tag, live-cwd fold, hidden-entry exclusion) got dedicated tests.

Verification: fmt clean; strict all-targets clippy clean with only the recorded repo_law.rs:638 baseline; targeted command_safety:: 82/0, tools::shell:: 158/0/1, tools::subagent:: 608/0, fleet:: 361/0; full lib 12303/0/13 under the repo-required RUST_MIN_STACK=16777216 (the earlier abort without it is environment, not code); workspace minus tui 2311/0. Seven new reverse mutations each redden exactly their pin (cd wildcard check; ./.. synthesis; match cap; guard predicate; sed trailing-flag refusal; = refusal; forced parent_shell_authority). The #123 merge conflict is still the single [shell.readonly.command] literal; #124/#125 remain merge-tree clean.

Not done (deliberately): the walker node/match caps refuse huge-but-legitimate expansions instead of paging them (fail-closed trade, documented in the tag copy); teaching the gate od/filter option-value consumption (false-refusal polish, follow-up); Windows runtime verification of the sync-lane Stdio::null() (untestable on this host, pinned logically by the hooks rationale).

@asto18089 asto18089 left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fresh review (round 4) — head 5b3eec687 on pinvou3-clean@6e1ee88ee

Reviewed from scratch by 13 independent lanes (stdin lane, classifier grammar, execution seam, operand gate + glob walker, gh network guard, verifier carve-out, docs/copy parity, claim audit, smuggling/hygiene, full gates, design quality, adversarial escape hunt, and an independent blocker-reproduction lane that re-derived the two escapes below without reading the reports that found them). Base pinvou3-clean has not moved since the PR was cut (merge-base = base tip), so no rebase was needed.

Verdict: changes requested — 2 BLOCKER, 2 MAJOR, ~14 MINOR, plus pre-existing items for follow-up. Fix 1 (stdin EOF) and fix 3 (verifier authority) hold completely, mutation-verified. Fix 2's grammar and gate are well-built but the new compound surface still has two deterministic one-call workspace escapes and one unbounded in-host cost, all reproduced end-to-end under ShellPolicy::ReadOnly through BashTool::execute on both /bin/bash and /bin/zsh.

BLOCKER

B1 — cd <regular file>; cat link reads outside the workspace (introduced by this PR).
crates/tui/src/tools/shell.rs:4226-4244 canonicalizes the cd operand and folds the live cwd onto the result with no is_dir() check, so an existing regular file is accepted as the destination. Every later segment then resolves its relative operands under …/notes.txt/…, where nothing exists, and the if joined.exists() skip at shell.rs:4341-4353 passes them unjudged (the glob walk's read_dir failure at 4530-4532 is likewise inert). At runtime cd notes.txt fails with "not a directory", ; and || continue in the original cwd, and the unjudged operand resolves at the workspace root where the planted outside symlink lives. Reproduced with success=true and the outside marker returned for cd notes.txt; cat link, cd notes.txt || cat link, and cd notes.txt; head -c 40 link *; cat link alone is correctly refused, so the fold is the load-bearing bypass. Base refused every ;/|| sequence outright, so this surface is entirely new. The body's "later segments resolve … exactly where the shell resolves them" is false whenever the runtime cd fails. Fix: require resolved.is_dir() in the cd arm (refuse otherwise), and consider making a later-segment operand that fails to exist under a folded cwd refuse rather than skip.

B2 — the operand gate judges only the text after the first =; the shell opens the whole word (root pre-existing; coverage claim and new reach introduced by this PR).
shell.rs:4279-4282: token.split_once('=').map_or(token, |(_, value)| value) — for a non-flag operand this judges the wrong path. With an in-workspace symlink named ax=b.out → outside file: cat ax=b.out is judged as b.out (nonexistent → skipped) and leaks via the direct-argv lane with success=true — on base as well as here. cat x=nope, cat x=y (RHS decoy in-workspace), cat x=y=z, sed -n 1p x=nope all leak the same way. The PR's new glob walker (4336-4338) walks only the RHS pattern, so cat a*=b* | head -c 40 admits (walker pattern b* matches nothing) while bash/zsh pathname-expand the whole word to ax=b.out — the body's "expanded the way the shell expands it … every match judged by its resolved location" fails for any =-bearing token. The round-3 =cmd refusal at 4269-4277 covers only a leading =; the sequence admission newly reaches pwd; cat a*=b* and cd sub; cat x=nope, both refused on base. Fix: split on = only for --prefixed tokens (--opt=path); judge a bare operand as the whole word. The split sits in the exact loop this PR edits, which is why this is blocking rather than a follow-up.

MAJOR

M1 — glob caps are per operand; the aggregate in-host walk is unbounded and untimed (introduced by this PR).
ReadonlyGlobWalk is rebuilt per operand (shell.rs:4392-4411), and the gate's spawn_blocking at 5540 has no timeout, so the round-3 4096/50 000 caps bound one walk only. Measured: cat fan/f* × 1000 over a 2000-dir fan → 19.7 s of host work, admitted; 500 * × 2000 entries → 24.5 s per ~1 KB command; 300 globs end-to-end → 5.8 s wall even though the child then failed exec with E2BIG. A ~20 KB command over a few thousand files is minutes of deterministic host CPU before any watchdog starts. Fix: one shared node/match budget per command (counter threaded through all operands), or a small cap on glob operands per command, and/or a timeout around the gate.

M2 — the "Checked and left alone" disclosure understates a code-execution hole, and its compensating-control claim does not hold (escape pre-existing).
The body says git compounds take the shell lane "without the direct-argv lane's --no-ext-diff --no-textconv --no-show-signature splice (env hardening still applies)". With a repo-local diff.<driver>.textconv plus a .gitattributes diff=driver line, git show HEAD | cat executes the textconv program — a marker file was written outside the workspace — despite GIT_CONFIG_GLOBAL=/dev/null, GIT_EXTERNAL_DIFF, GIT_ATTR_NOSYSTEM and the GIT_CONFIG_COUNT pins (shell.rs:5581-5622); the direct lane's splice is what makes bare git show HEAD safe, and the shell lane has none. Reproduced on base too, so pre-existing — but this PR's sequence admission routes pwd; git show HEAD to the same lane, and a body that otherwise proves every escape end-to-end should not describe an arbitrary-command surface as hardened. Either splice the flags into shell-lane git stages (token-level stage rewrite, same place the gh guard already walks stages), refuse git as a compound stage, or correct the disclosure to say textconv execution remains reachable and defer explicitly. Realistic-threat note: .git/config is not cloned, so the planted config is the same class of workspace-content threat as the planted symlinks the round-1 fix targets.

MINOR

  1. Copy — cd is admitted as any whole non-piped segment (cat f; cd sub, pwd; cd sub && ls — the latter pinned admitted), not only "standalone or as a sequence prefix" (command_safety.rs:572-573, body). Behaviour is safe (fold is left-to-right); wording understates.
  2. Copy — the cmd.exe ; refusal is a runtime ShellKind decision (shell.rs:2164-2171, 3888), not "cfg-gated" as the body says twice.
  3. Copy — the model-facing routing description (shell.rs:5163) says ;/&&/|| sequences "run through the detected shell" without qualifying that cmd.exe ; sequences are refused at the seam; its pipefail half is qualified, the ; half is not.
  4. Copy — the refusal copy hardcodes "32" (tools/subagent/mod.rs:15777) separately from MAX_READONLY_SEQUENCE_SEGMENTS.
  5. Copy — the Verifier roster/schema/intro strings promise the bounded read-only evidence shell unconditionally, but fleet durable-exec verifiers get shell: None (fleet/executor.rs:238-263, disclosed only in a code comment).
  6. Body — the pinned-behaviour list ends with "wider EOF margin"; no commit or pin matches (the EOF test's 2900/3000 ms margin dates from eac3379dd).
  7. Body — the Testing section attributes the same "12283/5 and 12286/2" earlier runs to two disjoint flake memberships.
  8. Design — the stage | split is inlined at three judge sites (command_safety.rs:637; shell.rs:3935, 4206); the shared sequence splitter is exactly what closed the round-2/3 divergence, and the stage split deserves the same single helper.
  9. Design — the classifier over-admits quoted separators (cat "x;cat y" classifies true via shell_words' quote-trim fallback, command_safety.rs:1735-1742); safety rests entirely on the seam's fail-closed shlex re-parse (verified). Worth a comment and a pin on that invariant.
  10. Test — the match-cap pin sizes its fixture from the constant it pins (shell/tests.rs:1551), so mutating the constant hangs the test instead of reddening it.
  11. Test — no pin for the sub-subagent chain (carve-out Verifier parent → Builder/test child); the capture point at subagent/mod.rs:14666 is correct by code path only.
  12. Test polish — the symlink fixture is repeated across ~10 gate tests; several refusal pins accept an OR of tags; the glob-bounds test takes ~15 s wall.
  13. History — three in-range self-correction pairs (eac3379dd→3a297ded4/e271e10a5, e52aa813c→8d35ba1af, 4e06ac862→22fbe0bfb) and e271e10a5 is a six-file grab-bag; disclosed, bisectability only.
  14. Design note — the walker implements full shell parity (dot synthesis, symlink follow, dedup); a final-component-only glob contract would be roughly half the code. The measured 10.4 s blowup motivates the choice; the declined trade-off is not recorded anywhere.

Pre-existing, for follow-up (not introduced here)

  • npm view|show|info (command_safety.rs:1018-1023, byte-identical to base) checks only the subcommand: npm view x --registry http://host/… and --userconfig <file-in-workspace> are admitted, invisible to the gh-only network guard, and every run writes ~/.npm. One lane misattributed this as new; it is not — but the compound admission now reaches it from any sequence position.
  • reject_network_reaching_input (tools/subagent/mod.rs:16161-16167) still uses the whole-command gh predicate; a profile-network-denied subagent passes it for glob/compound gh reads, leaving the execute-time guard as the last bash-specific layer. The stage predicate this PR added is a drop-in.
  • Fleet envelope gh pre-filter (tools/registry.rs:576) keeps whole-command granularity; harmless only because a deny-all decider is always installed when network_access == Some(false).
  • Pipeline stage count is uncapped (the 32-cap counts sequence segments only); an admitted relative fifo operand blocks to the 120 s watchdog; the PTY foreground lane still wedges stdin readers (disclosed; guidance correctly excludes it).
  • docs/SUBAGENTS.md:70,72,218-220 (zh mirror) label Scout/Reviewer "bounded verify", but Run is unreachable for them (bounded_inspection); the zh SUBAGENTS.md sync marker points at an August revision.

What holds

  • Fix 1 — root cause verified against base (spawn_background_sandboxed keeps the writer; close only if stdin_data.is_some(); sync lane inherits). Three mutations each redden exactly the claimed pin (restore conditional close → 3000 ms timeout; drop Stdio::null() → TimedOut; drop the background writer → test_write_stdin_streams_output). PTY/interactive lanes byte-identical. Detach-interact message pinned.
  • Fix 3 — full authority matrix (parent role/shell × child role × nesting path) shows no child holding more process authority than its parent. Forcing parent_shell_authority = true reddens exactly clamped_verifier_child_of_read_only_parent_holds_no_run_surface; restoring the role_posture_permits re-key reddens exactly the two round-2/3 pins while the direct-case pin stays green; the two legacy-Bash walls are independent (each alone still denies). Receipts label read_only; restore re-derives from the single spawn chokepoint.
  • Fix 2 structure — is_parallel_readonly_command byte-identical to base and pinned; one shared sequence splitter with six call sites; the round-2 first-program early return is structurally gone; ./.. synthesis, cd-wildcard refusal, pipeline-stage cd refusal, cwd fold on a real directory, the token-level gh stage predicate, sed third-token pin and the leading-= refusal all re-probed and hold; mutations on the cd-pipeline-stage term, dot synthesis, match cap and the stage predicate each bite. ~110-input grammar matrix: no admitted write path, redirects/&/prefix wrappers/?/{} all refuse.
  • gh guard — every stage judged; pwd; gh repo view * and gh pr list --search '*' consult a Deny decider; bare-command behaviour identical to base.
  • Claims — 40 hold, 1 partial (item 6), 2 fail (the glob "every match judged" sentence per B2; the git "env hardening still applies" sentence per M2). #123 conflict is exactly the one [shell.readonly.command] literal; #124/#125 merge-tree clean; #124 really refactors the three deferred functions; #123 really fixes the followup-on-interrupted contention.
  • Gates at head — fmt clean; clippy under the CONTRIBUTING allow list = only the recorded repo_law.rs:638 baseline; command_safety:: 82/0, tools::shell:: 158/0/1 ignored (pre-existing), tools::subagent:: 608/0, fleet:: 361/0; full lib RUST_MIN_STACK=16777216 12303/0/13 (338 s); workspace-minus-tui 2311/0.
  • Smuggling — none; every hunk attributes to a claimed fix; 20/20 commits DCO-signed and conventional; no secrets; VERSION/changelog untouched; en/zh docs parity 1:1.

Lane reports: ~/pinvou-worktrees/reports/cw-pr126-r4/ (L1–L15).

@asto18089

Copy link
Copy Markdown
Collaborator Author

Round-4 disposition — fix wave pushed, head 10f3b1311

Every BLOCKER and MAJOR from the round-4 review (5680406343) is fixed and pinned, plus three more holes the post-fix adversarial re-probe found in the same functions. Seven DCO commits on top of 5b3eec687; each intermediate commit compiles; 8/8 inverse mutations redden exactly their pins (three reproduce the outside-content leak itself; both textconv mutations show the repository's filter executing under the tool).

Finding Fix Commit Pin
B1 cd notes.txt; cat link (regular file as cd destination) destination must be a directory, [shell.readonly.cwd.not_a_directory] 9c53f92fc readonly_operand_gate_refuses_cd_onto_a_regular_file; e2e cases
B2 gate judged only the RHS of the first = split only --prefixed tokens; bare operands judged whole (globs too) 9c53f92fc readonly_operand_gate_judges_equals_bearing_operands_as_whole_words; e2e
M1 glob caps per operand one ReadonlyGlobBudget per command across segments/stages/operands 9c53f92fc readonly_glob_budget_is_shared_across_operands
M2 shell-lane git stages ran repo textconv (body claim "env hardening still applies" wrong) harden_readonly_shell_git_stages re-emits every log/show/diff stage with the direct lane's splice; one table splice_git_read_hardening; unquotable token refuses 4c7a9e2ef readonly_shell_lane_hardens_git_diff_rendering_stages; e2e readonly_shell_lane_git_show_does_not_run_repository_textconv (fixture proven live first)
re-probe: gate trimmed spellings, cat ' x ' leaked on both lanes (pre-existing) judge operands byte-for-byte as parsed c28f94102 readonly_operand_gate_judges_the_parsed_spelling_without_trimming; e2e
re-probe: git blame applies textconv by default, no splice on either lane (pre-existing) blame → --no-textconv in the shared table f5f235419 blame cases in both git pins, second raw-shell control
re-probe: zero-match globs consumed no budget (8000 zz* = 8 s) third counter: 500 000 examined entries per command 10f3b1311 readonly_glob_budget_counts_examined_entries
pre-existing: reject_network_reaching_input + fleet pre-filter whole-command gh predicate command_reaches_github_readonly per stage at all three ceilings; whole-command predicate deleted; one readonly_pipeline_stages replaces three inlined `split(' ')` 7ecc376f5
minors 1–5, 8–10 (cd wording, cmd ; qualification, hardcoded 32, Verifier roster scope, stage-split helper, quoted-separator invariant pin, match-cap fixture literal) as listed a0fc5435f, 7ecc376f5, 9c53f92fc readonly_operand_gate_fails_closed_on_quoted_separators
minors 6–7, 14 (body: phantom "wider EOF margin", flake narrative, walker trade-off note) body rewritten; design note in the walker doc body / 9c53f92fc —

Deferred with disclosure (now in "Checked and left alone"): npm view registry/userconfig redirect — a flag pin alone is false security because a project .npmrc redirects just the same; needs a pinned-registry splice plus a network-decider consult (separate topic). Sub-subagent chain pin (minor 11): invariant holds by code path, dedicated test deferred. Minors 12–13 (fixture repetition, history churn) not acted on.

Gates at 10f3b1311: fmt clean; clippy under the CONTRIBUTING allow list = only the recorded repo_law.rs:638 baseline; command_safety:: 82/0, tools::shell:: 166/0/1 ignored, tools::subagent:: 608/0, fleet:: 361/0; full lib (12311 tests, RUST_MIN_STACK=16777216) three runs 12310/1, 12309/2, 12308/3 — every failure in the known load-timing flake set (forkguard_model_operation_auxiliary_retry_keys, omitted_runtime_models_use_the_active_provider_default, pdf_tests::contract_read_*), each passes alone, none of those modules in the diff; workspace-minus-tui 2311/0.

Merge order: #125 and #114 merged mid-wave; merge-tree against the advanced base is clean, so the branch stays on its base per CONTRIBUTING. Against #123 there is now a second mechanical conflict in the git hardening table (its rev-parse|branch|worktree no-splice additions vs this branch's blame splice) — resolution spelled out in the body; note #123's comment that blame spawns no helper is the misconception round 4 disproved.

Lane reports: ~/pinvou-worktrees/reports/cw-pr126-r4/ (V1-mutations.md, V2-adversarial-reprobe.md).

@asto18089
asto18089 force-pushed the fix/live-test-findings-subagent-shell branch from 10f3b13 to 6fe3760 Compare October 28, 2026 12:42

@asto18089 asto18089 left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Round 5 — fresh from-scratch review at 6fe376008 (rebased)

Rebase: the branch was rebased from 6e1ee88ee onto the current pinvou3-clean = f1eb10efc (#125, incl. #114). Pure replay — git range-diff shows all 27 commits =; no conflicts; CI on 6fe376008 is fully green (Analyze ×4, DCO, CodeQL, Gitleaks, Type Check, check 19m41s, gate, link); mergeable: MERGEABLE, mergeStateStatus: CLEAN.

Method: 17 independent review lanes starting from zero (prior rounds used only as a list of claims to re-verify, never as evidence): 7 claim-verification lanes (fix 1 / classifier / execution seam / glob walker + budget / Verifier authority / rounds 1–3 / round 4), smuggling + history, engineering quality, shell-semantics + path-containment bug hunt, test quality, docs/i18n, merge topology, and a code-executing red team that drove every candidate end-to-end through LowercaseBashTool under ShellPolicy::ReadOnly with planted outside symlinks, under /bin/zsh, /bin/bash, /bin/sh and /bin/dash separately. Gates re-run locally on the rebased head: cargo fmt clean; command_safety:: 82/0, tools::shell:: 166/0 (1 pre-existing ignore), tools::subagent:: 608/0, fleet:: 361/0; clippy with the CI allow-list flags clean for every file this PR touches (the only local hit is collapsible_if at core/engine/turn_loop.rs:5499, base code the PR does not touch; CI's clippy on this head is green).

Verdict: changes requested (posted as a comment — self-authored PR)

The three fixes are real, root-caused, and complete for the shapes they name; every one of the fifteen round 1–4 escapes is genuinely closed and honestly pinned; there are zero smuggled hunks; the pins are unusually mutation-aware; the engineering is justified rather than reinvented. But the red team confirmed five new workspace-boundary escapes through the tool of exactly the classes this PR declares load-bearing (judged-vs-executed name mismatch, cd-fold-lies-when-cd-fails, walker-vs-shell glob parity) — one of them with a git-shippable fixture that leaks on all four shells. By this PR's own round-1–4 standard they must be fixed and pinned here before merge.

Answers to the four review questions:

  1. Useful / complete / root-caused — yes for the stated defects (stdin EOF root cause confirmed on base at shell.rs base:2732/2758/4363 and base:2338; classifier/seam/authority claims verified line by line; see "Verified" below). Not complete as a boundary: see B1, M1–M4.
  2. Elegant / no reinvention / not over-engineered — yes. The hand walker was checked against globset, glob, ignore::WalkBuilder, globwalk, walkdir (all present in the lock): none supplies budget + bash ./.. synthesis + pattern-position-aware cycle dedup + fail-closed per-match judging; readonly_shell_word vs shlex::try_quote is a cross-shell requirement shlex cannot meet; the three budget counters are each separately load-bearing and pinned. One real design defect: the cd operand shape rules live in two places with different code (classifier is_agent_readonly_cd vs the seam cd arm) and have already diverged (M1).
  3. Smuggled changes — none. Every hunk in the 14-file diff maps to a body claim or a named round item; the single unclaimed hunk is the -truncate doc-comment bullet (command_safety.rs:584, comment-only, already in MUTATING_PRIMARIES on base). All 27 commits are conventional + DCO-signed, no merges/fixups. Splitting into stdin / grammar+seam / Verifier would be cleaner for revertability but the bundle is one live-session topic with shared files and copy — recommendation, not a requirement.
  4. Bug-free — no. Five confirmed escapes + one availability break below.

Confirmed escapes (red team, literal OUTSIDE_MARKER_R5 in tool output)

B1 — BLOCKER — \\?\ is stripped from the text the gate judges but not from the text the shell runs; the raw ? reaches the shell as a live glob.
command_safety.rs:422 command.replace(r"\\?\", "") runs inside is_agent_readonly_shell_command (:596-597) before the charset gate (:600-608, which refuses ?) and before every shlex parse (shell.rs:4044, :4160, :4368); but the shell lane hands the shell the raw text — harden_readonly_shell_git_stages copies non-git stages byte for byte from the original string (shell.rs:4146-4154). Fixture: ln -s <outside> 'a\?b' (4-char name; a symlink git can ship). Tool input pwd; cat a\\?\b → gate judges pwd; cat ab (ab nonexistent → skipped), shell parses a\?b = literal \ + single-char glob → LEAK on zsh, bash, sh, dash. Same for cat a\\?\b; pwd, cat Cargo.toml | cat a\\?\b (zsh/bash/sh), and word-start pwd; cat \\?\secret with a symlink named \?secret (gate judges secret). The direct lane is safe only by asymmetry (hardened_readonly_argv execs the stripped ["ab"]). The strip is pinned as desirable platform-independent behaviour by agent_readonly_shell_admits_windows_verbatim_paths (command_safety.rs:2375-2388), which is why nothing caught it. Fix: run the charset gate (and classification) on the raw command, or apply normalize_windows_command_paths only for Windows shell kinds; and make the seam judge exactly the bytes the shell receives. Pin red/green on all lanes.

M1 — MAJOR — cd =ls; cat link on zsh. is_agent_readonly_cd (command_safety.rs:1071-1089) checks -/\/*/absolute/~/.. but not =; the seam's cd arm (shell.rs:4330-4362) returns before enforce_readonly_argv_operands, so the round-3 =-prefix refusal at shell.rs:4392-4396 never applies to a cd operand. With a planted directory literally named =ls and link → outside: gate folds cwd onto W/=ls, judges =ls/link (missing → skipped); zsh EQUALS-expands =ls → /bin/ls, cd fails, ;/|| continue at W → LEAK (zsh): zsh:cd:1: not a directory: /bin/ls + marker. bash/sh enter the literal dir (safe). Three independent lanes converged on this. Fix: refuse =-leading cd operands in both layers (or route the cd arm through the same operand-shape function the argv gate uses, so there is one rule). Pin cd =ls; cat link and cd =ls || cat link.

M2 — MAJOR — cd <unsearchable-dir>; cat link on every shell. The round-4 is_dir() check (shell.rs:4356) and canonicalize both succeed for a directory with no search bit (stat needs x only on the ancestors); the shell's chdir needs x on the leaf and fails deterministically, so ;/|| continue in the original cwd while the gate folded every later operand under the destination (exists() miss → skipped, shell.rs:4471-4483). Fixture chmod 000 vault + link → outside: cd vault; cat link → LEAK on zsh, bash, sh, dash; cd vault || cat link leaks on zsh/bash/sh. Also cd vault; cat * (the walker's read_dir fails → zero matches → inert, the shell expands * at W). This falsifies commit c42312a76's "a failing one is a TOCTOU race rather than a one-call escape" — EACCES is deterministic. Precondition needs a non-repo actor (git cannot ship a mode), which is inside the stated threat model (attached roots are agent-writable, shell.rs:4211-4214). Fix: prove the shell's chdir will succeed before folding (std::fs::read_dir(&resolved).is_ok() needs the same x bit; or access(X_OK)), refuse otherwise under the cwd.* family; and judge the || branch in the original cwd (its RHS runs only when cd failed). Pin.

M3 — MAJOR — cat \\.\secret (direct lane) on every shell. The operand-level verbatim-prefix strip shell.rs:4419-4422 (strip_prefix(r"\\?\").or_else(strip_prefix(r"\\.\"))) fires on unix too; \ and . are admitted by the charset, so a POSIX file literally named \\.\secret is judged as secret (missing → skipped) while hardened_readonly_argv execs the real 10-char name → LEAK on zsh, bash, sh, dash; wc -c \\.\secret discloses the outside byte count. The comment at shell.rs:4413-4418 ("On unix hosts such spellings stay fail-closed below") is false. Fix: apply both strips only under cfg!(windows) (or only when the remainder parses as a Windows path) so on unix the \-bearing spelling hits the existing shape refusal. Pin.

M4 — MAJOR — zsh recursive **/. The walker has no ** case (shell.rs:4660-4747): ** reduces to a single-level *, and a non-final component only descends one level. zsh (macOS default, the PR's own evidence platform) expands **/ to every depth. Fixture sub/deep/evil.txt → outside: pwd; cat **/*.txt, grep -n OUTSIDE **/*.txt | head -n 5, head -n 2 **/evil.txt | cat → LEAK (zsh); bash/sh/dash treat ** as * (safe). No test mentions **. Fix (fail-closed, zero cost on bash/dash): refuse any ** component when a later component exists; or implement real recursion under the existing budget. Pin.

Other MAJOR

M5 — availability — set -o pipefail aborts every admitted pipeline and every ||-sequence on dash hosts. readonly_shell_bound_command (shell.rs:3905-3920) prepends the prefix for every non-Windows kind including Custom (= any $SHELL, shell_dispatcher.rs:525-550), and binds whenever the raw text contains | — which || does. /bin/dash -c 'set -o pipefail; …' → set: Illegal option -o pipefail, exit 2, nothing runs (dash exits on a failed special builtin; verified live and through the tool: Failed to execute tool: /bin/dash: 1: set: Illegal option -o pipefail). On Debian/Ubuntu hosts where the resolved shell is dash, every admitted pipeline and || compound fails. Pre-existing on base (the base prefixed unconditionally), but the round-3 bullet says the prefix was "scoped … into pure per-shell-kind decisions" and the comment at shell.rs:3902-3904 claims "every non-Windows kind honors it" — both overstated. Fix: key the prefix on the resolved binary's basename (sh/bash/zsh/ksh yes; dash/ash no) or probe capability once at detection; and don't let || trigger it. (Note: once scoped, dash becomes exposed to the M2 || / M4 pipeline spellings that the abort currently masks — fix those first.)

M6 — Windows cmd.exe only, not executable here — %VAR% operands. The charset (command_safety.rs:602-608) admits %; cmd.exe expands %USERPROFILE%\… before exec; the gate judges the literal as a nonexistent relative path and skips it. Pipelines already reached cmd on base; this PR additionally routes &&-compounds there (;-sequences are refused for cmd, && is not — shell.rs:3892-3898). Analog of the zsh =cmd hole this PR refused outright. readonly_shell_word's bare set also keeps % and ^ (shell.rs:4186-4197) — cmd metacharacters. Fix: refuse %-bearing operands (matching the =cmd precedent) and drop %/^ from the bare set.


MINOR

  1. Third network ceiling unpinned for compounds — registry.rs:578 (fleet envelope pre-filter) is exercised only with bare gh commands (registry/tests.rs:818, 885); reverting that one line to a whole-command predicate stays green. Add pwd; gh repo view → deny to the envelope test. The seam (shell/tests.rs:2140) and no-network pre-check (subagent/tests.rs:20015) sites are pinned.
  2. The rewritten [shell.readonly.command] copy is unpinned beyond the tag (subagent/tests.rs:8226, 8925) — yet the body names it as the #123 hand-union hazard. Pin "at most 32 segments", "must be a directory", and the admitted-families lead-in so a bad union reddens.
  3. Legacy worker record without a stored runtime_profile regains Run on resume — normalize_worker_record (subagent/mod.rs:1451-1455) rebuilds for_role(Verifier) = Full shell with no knowledge of the original parent clamp; resume_from_checkpoint_with_policy preserves it (:5946-5949, 6018), so parent_shell_authority recomputes true in any Full-Access session for a test child whose original parent was read-only. Records created after 43cfb6bcd are unaffected. Reconstruct clamped, or refuse the carve-out for synthesized profiles.
  4. --prefixed token containing = and *: the gate walks only the post-= text (shell.rs:4406-4412) while the shell expands the whole word — git ls-files --exclude-from=zz* vs a planted --exclude-from=zzq (only path-consuming --opt= option in the tables). Refuse or judge both.
  5. Glob recursion depth = pattern component count, no cap (shell.rs:4682-4702: the .. candidate is entered unjudged when non-leaf); a ~20 KB .*/.*/… command recurses ~10 000 frames on a blocking thread — crash/DoS risk, threshold UNVERIFIED. Cap components (32–64).
  6. interact refusal attribution (shell.rs:1381-1384): "its stdin is already at end-of-file (foreground commands run that way…)" also fires for PTY (combined_output) foreground tasks, persist:true services (Stdio::null()), and any completed task — the cause is wrong there, the advice still right. Vary by lane or drop the parenthetical.
  7. Verifier gate lane holds an open stdin pipe forever (verifier.rs:571-581 → piped branch, nothing closes it) — the same disease fix 1 cures for foreground; pre-existing, bounded by the 600 s gate timeout. One-line close after spawn.
  8. Doc drift: agent_readonly_command_needs_shell_for_cd doc says "leading segment" (command_safety.rs:723-724) but routes any segment-leading cd (:729-737); body + docs/SUBAGENTS.md:100 + zh:40 say cd is "standalone or as a sequence prefix" while the classifier admits it at any sequence position; docs/SUBAGENTS.md:93 / zh:40 "(only bounded-inspection roles keep the classified read-only shell)" is now false (Verifier joined); new [shell.readonly.operand.shape]/.outside_workspace messages say "Read-only Scout shell" to a Verifier child; shell.rs:4177 "characters no shell interprets (* stays bare…)" is self-contradictory.
  9. Body: the Testing paragraph cites "the recorded cloned_ref_to_slice_refs baseline at repo_law.rs:638" — that lint name exists nowhere in the tree at HEAD or base, and repo_law.rs:638 is inside an unrelated test; delete or re-anchor. "Fifteen workspace-boundary gaps … all are fixed" should read "fifteen known gaps". The Merge-order closing sentence ("the branch stays on its original base") is stale after this rebase.
  10. Test coverage asymmetries: every walker/compound/textconv/cd-fold/sync-stdin pin is #[cfg(unix)]; the Windows Stdio::null() sync lane and the entered_paths dedup stand-in never execute; cmd.exe ; refusal is pinned only as a pure function. No test runs one shape under two shells (the ./.. and =cmd divergence claims are reasoned, not demonstrated — the gate-level pins are at the right layer, so this is a note, not a defect). Dedup and node-budget regressions turn the cycle pin into a ~600 s slow-red rather than a fast one, and neither is in the 29-mutation list.
  11. Visibility: is_github_readonly_tokens / is_github_readonly_stage are pub(crate) with no callers outside command_safety.rs.
  12. Pre-existing, noted for follow-up: # comments (cat #f wedges on stdin on the background lane); hard link inside W to an outside inode reads outside (needs a writer); ls -l dangling discloses the outside target name; zsh NOMATCH aborts the whole compound opaquely (safe direction; copy could say so).

Merge order — the #123 union is load-bearing, not cosmetic

Re-verified at the new head against the current heads: #123 f8f204b13 conflicts in exactly the two regions the body names (shell.rs splice match; subagent/mod.rs refusal literal); #124 moved to 97a266091 (force-pushed) — still merge-tree clean both directions; #127/#128/#87/#85/#82/#81 share zero files. One addition to the body's prescription: post-merge, #123's classifier admits git rev-parse / branch / worktree list, and those reach splice_git_read_hardening's _ => Err(...) arm (shell.rs:4126-4129) — so a bare git rev-parse --show-toplevel on the direct lane would be refused despite admission, and any compound containing such a stage dies in harden_readonly_shell_git_stages. The arm "status" | "ls-files" | "grep" | "rev-parse" | "branch" | "worktree" => Ok(false) beside the spliced blame arm fixes both lanes (verified correct and complete: #123's only shell.rs change is that match; git branch --edit-description is refused by #123's grammar). #123's tests pin three prose fragments of the refusal copy ("Admitted:", "git branch (bare/flag-only", "git worktree list (flags only)") — the union text must keep them; a ready-made union is in the review artifacts.

Verified true (no need to re-litigate)

Fix 1 root cause and completeness (every spawn path enumerated; PTY/interactive/background/persist lanes behave as stated); is_parallel_readonly_command byte-identical to base; sequence splitter semantics incl. |&, ;;, &> fail-closed; od/sed/cluster grammars tight (sort -o, uniq second operand, tail -f, grep -f/-r, sed -f/--expression/-s/-i, script verbs w/r/e all unrepresentable); gh table is read-only (no api/create/clone/download), no wrapper commands admitted; every round 1–4 escape existed pre-fix and is closed with a non-vacuous pin (29/29 claimed mutations traced to a specific failing assertion by reading); dedup key (dev, ino, remaining-length) is sound (no under-match constructible); budget threaded once per command across segments/stages/operands with all three counters refusing; gate on spawn_blocking; splice order safe (no overriding git flag is admittable; git -c/--git-dir/--exec-path refused); env hardening identical on both lanes; Verifier authority model: order clamp → capture → narrowing verified, round-2 regression closed, both legacy-Bash walls independent, fleet envelope XOR, sub-subagent re-clamps; EN↔zh docs semantically identical; refusal copy enumerates exactly the admitted families; static test counts 82/608/361 exact. Free-form red-team hunt: 53 further shapes (cd sub || cat link, cat sub/../link, cat -- -*, cat ' x ', find . -name '*', 33 segments, cd out; …, gh writes, …) — zero leaks, every refusal with the expected tag.

Artifacts

Lane reports L1–L17 and raw per-shell logs: ~/pinvou-worktrees/reports/cw-pr126-r5/. Reproducible red probes (not part of the PR) left in a scratch worktree at crates/tui/src/tools/shell/probe_r5.rs; run SHELL=/bin/zsh PROBE_R5_EXPECT_SHELL=/bin/zsh cargo test -p codewhale-tui --lib -- probe_r5 (repeat per shell) — probe_r5_c1_*/c3/c4 red on all four shells, c2/c5 red under zsh.

@asto18089

Copy link
Copy Markdown
Collaborator Author

Round-5 fix wave is pushed — head is now d4dbe8f55 (8 DCO commits on top of the rebase). Every finding from the round-5 review is addressed; the five confirmed escapes are closed and re-verified with the red team's own probes.

Confirmed escapes

Finding Fix Verification
B1 \\?\ normalization ran on unix and carried a live ? glob past the charset gate; M3 \\.\ operand strip on unix normalize_windows_command_paths and the operand verbatim-prefix strips now run only on Windows hosts, where Path::canonicalize actually yields those spellings; on unix the spellings fail closed at the charset and shape gates. The unconditional transform stays available for its unit tests; the Windows-side admission pin is now #[cfg(windows)], with unix refusal pins alongside (f0a5cf0a0) mutation "normalize unconditional" reds 3 pins incl. the end-to-end leak; "strip unconditional" reds its gate pin; re-run of probe_r5_c1/c4 on zsh/bash/sh/dash: refused everywhere, OUTSIDE_MARKER_R5 absent, raw-shell controls still leak
M1 zsh cd =ls; cat link one cd operand shape rule (readonly_cd_operand_admitted) shared by classifier and seam, refusing =-prefixed and empty operands (49cbcd58a) mutation reds 3 pins incl. the end-to-end leak on zsh; probe_r5_c2 refused on all shells
M2 cd <unsearchable dir> fold lie (deterministic EACCES, not a race) the seam proves the destination enterable by resolving <dir>/. — exactly the permission chdir needs — before folding; searchable-but-unreadable dirs stay admitted (49cbcd58a) mutation reds both pins incl. the end-to-end leak on all shells; probe_r5_c3 refused everywhere; cd vault; pwd with a searchable dir still admitted
M4 zsh **/ recursion vs the single-level walker **/*** followed by more pattern is refused; a trailing ** stays admitted; the walk is capped at 32 components (786054e16) mutation reds 2 pins incl. the end-to-end leak on zsh; probe_r5_c5 refused on all shells while the zsh control still recurses
M6 cmd.exe %NAME% %-bearing compounds refused on cmd; %/^ moved out of the Git stage re-quoter's bare set into double quotes (fe8baad4b) per-shell-kind pin reds on both reverts (Windows-host behaviour itself remains untestable here — disclosed in the body)

Other MAJOR

  • M5 dash aborts every pipeline and || sequence on set -o pipefail: the prefix is now keyed on the resolved binary's file name (bash, zsh, ksh family); /bin/sh, dash and everything else run unprefixed and report the last stage's status (fe8baad4b). Re-verified end-to-end under SHELL=/bin/dash: wc -c top.txt | head -n 1 now runs and succeeds; zsh/bash/sh keep the prefix.

MINORs

  • Whole-word option globs (--exclude-from=zz*) are judged as whole words at the seam (786054e16) — unreachable through the current classifier grammar; defense in depth, pinned.
  • The interact stdin message records whether a stdin pipe was really closed and states the accurate reason for PTY/persistent/finished tasks (40bf2fb57); the verifier's gate jobs get EOF right after spawn instead of idling to the 600 s timeout (same commit; no dedicated pin — the gate harness has no stdin-reading fixture; disclosed in the body).
  • The [shell.readonly.command] copy states the enter rule and the new refused shapes, and its load-bearing claims (admitted families, every-segment rule, interpolated cap, directory rule, **/ refusal) are pinned so a bad fix(subagent): release interrupted claims and inherit worktree isolation #123 union reddens (d4dbe8f55).
  • Fleet envelope: pinned that compound gh reads fail closed there with or without a network grant. The review's premise that reverting registry.rs:578 alone would stay green was true but for a different reason than assumed — the envelope admits Bash through the parallel classifier, which refuses compounds outright, so that call site never sees them; the pin documents the fail-closed behaviour on both sides (d4dbe8f55).
  • Doc drift: the routing doc and both SUBAGENTS docs (EN + zh) say cd is admitted at any sequence position; the stale "only bounded-inspection roles keep the read-only shell" parenthetical is fixed; refusals no longer say "Scout shell" (5c849403f); the self-contradictory bare-set comment is rewritten; the "Round N" test comment describes behaviour; two gh predicates are private (d4dbe8f55).
  • Body: the nonexistent cloned_ref_to_slice_refs clippy-baseline citation is removed; "fifteen gaps … all fixed" is now "twenty-two known gaps … every known one fixed"; the Merge-order paragraph records the rebase and the now-load-bearing fix(subagent): release interrupted claims and inherit worktree isolation #123 union (its rev-parse/branch/worktree probes hit the splicer's _ => Err without the extended arm, and the copy union must keep this branch's four pinned phrases alongside fix(subagent): release interrupted claims and inherit worktree isolation #123's three).

Left alone, disclosed in the body

Legacy worker records without a stored runtime_profile regaining Run on resume (base-equivalent, shrinking window — needs a reconstruct-under-ceiling follow-up); the Windows lane's judged-vs-run divergence for verbatim spellings (no Windows host here); # comments, hard links, dangling-symlink name disclosure, zsh NOMATCH abort (all pre-existing).

Verification

cargo fmt clean; clippy with the CI allow-list clean for every file the branch touches (the only local hit is the pre-existing collapsible_if at core/engine/turn_loop.rs:5499, base code CI's toolchain does not report); targeted suites 82/0, 173/0, 608/0, 361/0, 48/0; full lib 12325 passed / 2 failed / 13 ignored with both failures in the documented load-timing flake set (client/, tools/file — both pass alone, neither module touched by the diff). All 12 inverse mutations redden exactly their own pins, four of them reproducing the outside-content leak through the tool. Every one of the eight commits builds on its own (cargo check --lib --tests per commit). The red team's probe suite re-run at d4dbe8f55 on zsh/bash/sh/dash: all five candidates refused with the expected tags, OUTSIDE_MARKER_R5 absent from every tool output, in-harness raw-shell controls still leaking (fixtures live), ledger/hunt/nomatch tripwires green. Reports and logs: ~/pinvou-worktrees/reports/cw-pr126-r5/ (mutations.log, L17-postfix.md, intermediate-compile-final.log). CI on d4dbe8f55 is running.

@asto18089 asto18089 left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fresh review (round 6, from scratch) — head d4dbe8f55 on pinvou3-clean@f1eb10efc

Verdict: CHANGES_REQUESTED (posted as a comment because author == reviewer). One BLOCKER. Everything else the eleven lanes probed held up: the claims audit found every auditable body claim true (all 39 named tests exist, the five targeted-suite counts are exact, the merge-order/union notes re-verified against the current heads of #123/#124/#127/#128, CI green on this head, DCO 35/35, the round-5 rebase proven a byte-identical replay by range-diff), the red team found no second escape, and the local gates were re-run clean (cargo fmt clean; command_safety 82, tools::shell 173+1 ignored, tools::subagent 608, fleet 361, registry 48 — each matching the body exactly).

Rebase: none needed — merge-base == origin/pinvou3-clean == f1eb10efc, so the branch already rides the latest tip.

BLOCKER — the cwd fold ignores &&/|| short-circuit: a cd the shell never executes still folds, and later operands are then skipped unjudged

readonly_sequence_segments (command_safety.rs:711-737) returns bare segments and discards the connector; the gate's fold loop (tools/shell.rs:4333-4337) folds on every admitted cd segment unconditionally; and the cd arm's own hardening (not_a_directory, unsearchable) reasons only about the cd failing — never about it not running. The round-4/5 closures are exactly the fail cases; the never-runs case is open:

cat nope && cd sub; cat link      # cat nope fails → && short-circuits the cd
true || cd sub; cat link          # true succeeds → || short-circuits the cd

First-hand reproduction (not traced — executed):

  • Gate: driving the real enforce_readonly_workspace_operands (workspace with sub/ and a root symlink link → <outside>/secret.txt) returns Ok/admitted for both commands — the gate folds onto sub/, judges sub/link (does not exist) at the exists() skip, and admits.
  • Shell: bash -c 'cat nope && cd sub; cat link' and sh -c '…' both print the outside file's contents (TOPSECRET-OUTSIDE); the fold-truthful control cd sub; cat link correctly errors.

So an admitted read-only command reads outside-workspace content — the third member of the "the fold lies about the shell's cwd" family this PR claims closed, and a direct falsification of the round-1 claim that later segments resolve "exactly where the shell resolves them" (true only while the cd runs).

Suggested fix: make the splitter retain each segment's preceding connector and fold only a cd whose predecessor is ; or sequence-start; a cd right of &&/|| either refuses or — strictly safer — judges every later operand under both the pre-cd and post-cd working directories (fail-closed union). Neither the advertised cd dir && wc -c f (cd first) nor the pinned cd sub; cat link drift test is affected. The git-stage hardener, network guard, and router consume the same splitter but do not fold cwd, so they are untouched by the fix.

MINOR

  • M1 — cmd.exe ^: judged-vs-run divergence survives on the shell lane (tools/shell.rs:3900-3928, command_safety.rs:620-627). readonly_compound_refused_by_shell_kind refuses % and ;-sequences for Cmd but not ^, and the charset admits ^: on a cmd host, cat a^b | wc -c routes to the shell lane, cmd consumes the ^, and cat opens ab.txt — a spelling the operand gate skipped as nonexistent, plantable as an outside symlink. Pre-existing for pipes on base, but this PR extends the same lane (;-sequences, cd compounds) without closing the sibling, and the body's Windows disclosure names only \\?\ spellings. Same family: the re-quoter double-quotes a %NAME%-bearing git-stage token, and cmd expands % inside double quotes, so readonly_shell_word's "reads back as the same single word" invariant is false there. Fix: refuse ^-bearing (and keep %) compounds for ShellKind::Cmd exactly like the % arm, and extend the disclosure.
  • M2 — "Parent-side parallel auto-approve classifier is byte-for-byte unchanged" is false at the behavior level. The source is identical, but the round-3 gh -q admission edited the shared readonly_options_are_allowed gh table (command_safety.rs:1214-1230), which is_parallel_readonly_command → readonly_tokens_admitted also serves — so parent auto-approve now admits gh … -q … reads base refused. Safe direction (-q = --jq), but neither pin covers gh. Scope the claim or pin the delta.
  • M3 — the interact stdin refusal misstates the combined-output lane (tools/shell.rs:~1398). "interactive, combined-output, and persistent commands never expose one" — a background combined_output task is routed to the PTY branch (tty = tty || (combined_output && background), shell.rs:5690) and does expose a Pty stdin writer while running. The EOF variant likewise attributes closure to "foreground commands", though verifier gate jobs set the same flag. Copy accuracy only.
  • M4 — the ./.. dot-glob attribution to bash is wrong. "bash expands .* to . and .." (comments shell.rs:4603/4813-4815, test doc tests.rs:1515-1518, body round 3) is false on bash ≥4 (probed 5.2: no dot-entries, ..* stays literal); it is dash/sh that produce the full cross-product (probed). The synthesis is required for the dash lane, so the code is right and conservative elsewhere — only the attribution needs rewording.
  • M5 — the no-network blind spot on admitted npm reads is unlisted. A child with network_tool = false can run the admitted, URL-free npm view left-pad: reject_network_reaching_input and the envelope pre-filter consult only command_reaches_github_readonly. Pre-existing on base; but the body's "left alone" bullet covers only the --registry/.npmrc redirect angle, not the plain no-network-child angle — add it to the disclosure or to the follow-up.
  • M6 — one unclaimed hunk (doc-only, verified accurate): -truncate added to find's mutating-primary doc list (command_safety.rs:602) — pre-existing refusal the doc omitted. Mention it in the body or drop the line; behavior unchanged.

NIT ledger (no action required to merge; carry as follow-ups)

Unpinned verifier gate-job stdin close (feasible via a cat gate fixture); grandchild carve-out chain (Verifier→test) unpinned; VERIFIER_AGENT_INTRO statically promises selections a parent-clamped Verifier cannot run; reject_unbounded_verification's Unbounded copy overstates when shell=false (pre-existing); genuine multi-hop fold (cd a; cd b; cat f) unpinned — the positive control uses a non-moving second cd; unresolvable cd destinations report outside_workspace though they may be inside-but-unresolvable; the gate's own piped arm admits an empty stage (unreachable today, one-line tighten); documented-but-worth-listing safe refusals (cut -d-, sort -t-, sort -rk2, od attached-value forms); cmd refusal copy advises a shell the model cannot select; zh FLEET doubled bracket );(; UI-facing detach refusal speaks model-ese; generic stdin-refusal parenthetical loose in two states (model-closed tty, detached combined_output); gh …; cat <outside> sequence form untracked by an operand-gate pin (pipeline form is); runtime-reachable pwd; cat 50%off.txt shape of the cmd % refusal unpinned (the pin uses a single command that never reaches the function); PowerShell flattens a bare , in re-quoted git tokens (degrades to git errors, safe); ZDOTDIR/GLOBIGNORE not in the cleared-env set (operator-side only); the fsmonitor/hooks env override rides GIT_CONFIG_COUNT (git ≥ 2.31 — worth one comment sentence); git pathspec magic :(glob)** dodges the pattern-shaped recursive-glob check (git clamps pathspecs to the worktree, currently inert); walker micro-costs (per-entry OsString, double stat per candidate dir, exists()+canonicalize() double resolution).

Claims verification (high level)

Fix 1 (stdin EOF: root cause, foreground close, sync Stdio::null, kept background pipe, PTY untouched, flag + accurate refusal, gate jobs, guidance): VERIFIED, including a systematic stdin-pipe sweep — no leaked pipe anywhere in the touched code. Fix 2 (splitter/cap/cd/od/clusters; per-stage operand gate, cwd fold, glob walker + shared budget + dot synthesis + ** refusal + component cap; =cmd; no-trim/whole-word; quoted-separator fail-closed; Windows-only normalization; git splice incl. blame — --no-ext-diff proven load-bearing by live probe; binary-keyed pipefail — dash Illegal option reproduced; cmd ;/%; gh per-stage predicate at all three consumers with gh api/mutations correctly excluded): VERIFIED — with B1 the one hole. Fix 3 (Verifier joins, parent-grant-keyed single carve-out, other-roles-hold-none, three independent legacy-Bash walls, receipts read_only, docs en/zh parity, legacy runtime_profile base-equivalence): VERIFIED. Performance: all previously measured exploits now bounded two orders of magnitude lower, counters checked before the work they bound, gate on the blocking pool. Smuggling: none beyond M6. The merge-order section re-verified against current heads: #123 conflicts still exactly the two claimed regions (copy union + the Ok(false) arm — both still required, and #123's "blame spawns no configured helper" comment is still present and must not survive), #124 clean at 97a266091, #127/#128 zero shared files.

Disposition

One BLOCKER (short-circuit cwd fold) must be fixed and pinned red/green (mutation-verified like the others) before merge; M1/M2/M3/M4 are small enough to ride the same fix wave; M5/M6 are body/copy-level. Once B1 lands, this is approve-grade: the surrounding engineering — root-causing, red/green + mutation discipline, claims honesty, merge-order preparation — is exemplary.

@asto18089

Copy link
Copy Markdown
Collaborator Author

Round-6 fix wave — head e03fd90bc (one DCO commit on top of d4dbe8f55, base unchanged)

Every finding from the round-6 review (5688323577) that was worth code is fixed and pinned; the rest are disclosed in the refreshed body.

BLOCKER — fixed

Short-circuit cwd fold (cat nope && cd sub; cat link / true || cd sub; cat link — the cd-NEVER-RUNS twin of round 4/5's cd-FAILS cases): the sequence splitter now carries each segment's connector (readonly_sequence_segments_with_connectors; the plain readonly_sequence_segments wraps it for the consumers that don't fold cwd). The classifier refuses a conditionally-gated cd statically, and the seam re-checks it at the fold with its own [shell.readonly.cwd.conditional] tag — the same two-layer pattern the cd shape rule already uses. A cd its connector always runs (sequence start, or joined by ;) still folds, so the advertised cd dir && wc -c f, the ;-drift pin, and the multi-hop fold are unchanged. The refusal copy names the new shape (and its pin asserts the phrase — added to the merge-order union list in the body).

Pins: classifier agent_readonly_shell_rejects_conditionally_gated_cd (5 refused / 3 admitted controls); gate readonly_operand_gate_refuses_a_cd_its_connector_may_not_run (tag asserted; ;-fold still judged under the destination — asserted to be the containment refusal, not the conditional one; cd-first && control); end-to-end readonly_shell_refuses_short_circuit_cd_escape_end_to_end (no leak through the tool). Mutations: making the seam check a tautology reds the gate pin; dropping the classifier check reds the classifier pin while the seam wall still holds (defense in depth as designed). Also fixed en route: the multi-hop fold pin (cd sub; cd deep; cat link) is now discriminating — the old positive control used a non-moving second cd and survived the fold being deleted.

MINORs — fixed

  • M1 cmd ^: readonly_compound_refused_by_shell_kind now refuses ^-bearing compounds for Cmd next to % (cmd consumes bare ^ pre-parse; POSIX shells treat it literally — pinned per kind, including the runtime-reachable pwd; cat a^b and pwd; cat 50%off.txt shapes); the un-actionable "run it through PowerShell" advice is dropped from that copy (the read-only schema exposes no shell choice).
  • M3 stdin copy: the generic refusal now states accurate reasons per state ("a terminal command exposes one only while it runs and only until its stdin is closed"); the EOF branch is unchanged and still pinned.
  • M4 attribution: the ./.. dot-glob synthesis is attributed to sh/dash (modern bash does not expand them — probed 5.2) in both walker comments and the walker test doc, with the over-approximation direction stated.
  • M2/M5/M6: body-level — the parallel-classifier claim is scoped to the source with the gh -q behavior delta pinned deliberately (parallel_classifier_admits_gh_jq_reads_through_the_shared_gh_table); the npm left-alone bullet now names the no-network-child angle; the -truncate doc hunk is claimed.

NITs fixed

empty pipeline stage refused in the gate's own pass (doc now self-satisfied); gh stage's sequence-form operand exemption pinned (gh issue list; cat <outside>, pwd; gh issue list; cat link); ZDOTDIR/GLOBIGNORE join the cleared startup env; the GIT_CONFIG_COUNT block documents the git ≥ 2.31 floor and which subcommands rely on it alone; zh FLEET doubled bracket fixed; SUBAGENTS cd sentence (en/zh) now scopes "any position" to positions the connector always runs.

Left as disclosed (round-6 ledger)

verifier gate-job stdin close unpinned (feasible fixture, follow-up); carve-out Verifier → test grandchild pin (fails closed by trace; follow-up test); VERIFIER_AGENT_INTRO static promise vs clamped posture (one conditional sentence; follow-up); UI-facing detach refusal copy; walker micro-costs (the walker is load-bearing security code — micro-opts not worth the churn); reject_unbounded_verification Unbounded wording (pre-existing, out of diff); npm ceiling and the sub-subagent-chain pin stay follow-ups with the body's list.

Verification

cargo fmt --check clean; clippy on every touched file clean (the local toolchain's remaining complaints are the disclosed pre-existing set in core/engine/, lib.rs, context_report.rs); targeted suites at the new head: command_safety:: 84/0, tools::shell:: 175/0 +1 ignored, tools::subagent:: 608/0, fleet:: 361/0, tools::registry:: 48/0; full --lib 12322 passed / 13 ignored with 2 failures in untouched modules (remote_control, clipboard) — the documented rotating load-timing flake set, both pass alone (2/2, 0.09 s). Body refreshed: round-6 section, corrected claims, updated counts, extended merge-order union note.

@asto18089 asto18089 left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Round-7 fresh review — CHANGES_REQUESTED (1 BLOCKER, 1 MAJOR)

Fresh 12-lane review at head e03fd90bc on base f1eb10efc. Rebase status: none needed — the base tip still equals the merge-base, git merge-tree --write-tree origin/pinvou3-clean e03fd90bc is clean, and all CI legs are green at exactly this head. Lanes: stdin-EOF, classifier grammar soundness (~150 adversarial shapes), seam/execution, glob walker against a live bash/sh/dash oracle, the round-6 connector fix re-verification plus a fold-family hunt, network guard, verifier-role authority, a code-executing red team, smuggling/scope (hunk-by-hunk), performance (measured), a claims-vs-reality audit, and an independent mutation re-run of this head's "2/2 red" claim. The two boundary findings below are proven end to end first-hand; the minors were verified in code (and where cheap, live).

BLOCKER B1 — repository-configured filter.<driver>.clean executes under admitted git status / git diff / git blame: outside content crosses the boundary and attacker-chosen commands run

Round 4 closed four siblings of the "repo config turns nominal reads into programs" family (textconv via argv splice, ext-diff, fsmonitor/hooksPath via env pins) and stated the invariant: "Repository config and attributes are attacker-controlled evidence inputs; a nominal git diff/log/show must not turn them into programs." The fifth sibling is missed: content filters. A worktree .gitattributes (* filter=x) plus repo .git/config ([filter "x"] clean = <command>) makes git run <command> — with file content on stdin — whenever a nominal read converts worktree content. The GIT_CONFIG_COUNT block (shell.rs:5973-5983) pins only core.fsmonitor, core.hooksPath, log.showSignature; the round-4 splice (--no-textconv --no-ext-diff) addresses a different mechanism and does not help; git status/ls-files/grep are in the no-splice arm and rely on env pins only.

First-hand reproduction, with the exact child environment from shell.rs:5917-5983 (all GIT_*/GH_* pins, sanitized PATH, GIT_CONFIG_NOSYSTEM, GIT_CONFIG_GLOBAL=/dev/null, GIT_ATTR_NOSYSTEM=1) applied to a fixture repo whose .gitattributes is * filter=redsq and whose repo config defines filter.redsq.clean = sh -c 'cat <outside-file> >&2; touch <outside-marker>; cat':

  • git status -s (admitted, operand-free) → the outside file's content appears on the command's stderr and the marker file is created (arbitrary command execution).
  • git diff --stat, git diff HEAD → same; the filter fires once per converted input.
  • git blame note.txt → fires even with the round-4 splice applied (git blame --no-textconv note.txt: marker read, command executed), and through the shell lane as git blame note.txt | wc -l.
  • Compound form pwd; git status -s → same on the shell lane.

All commands are in the admitted table and every operand is inside the workspace — the operand gate has no visibility into this mechanism at all. Under SandboxPolicy::ReadOnly the child keeps full-disk read by design, and on hosts where the sandbox backend is unavailable the child is unsandboxed, so the write/exec proof stands.

Fix direction: git has no env/argv switch that disables worktree attribute filters (GIT_ATTR_NOSYSTEM is system-only; core.attributesFile does not shadow the worktree .gitattributes; filter.* cannot be wildcard-pinned). Realistic walls: refuse the git stage when the repository's config/attributes declare any filter (pre-scan, same shape as the unquotable-stage refusal), or pin filter.<discovered-name>.clean/smudge to cat per discovered name via additional GIT_CONFIG_KEY_n entries with fail-closed on unresolvable names. The same pre-scan should note merge.<driver> (unreachable from the admitted subcommands today) and gpg.* (already closed by log.showSignature=false + the splice).

MAJOR M1 — gh stages are exempt from the operand gate's glob expansion, so an admitted compound redirects gh to a planted host past a host-scoped network policy

The operand gate exempts gh stages from all operand judgment (Some("gh") => {} shell.rs:4385, early return shell.rs:4405) on the stated rationale that gh reads consume no local path operands and their host is judged separately. But gh read verbs do accept HOST/OWNER/REPO operands, and the classifier refuses exactly those spellings (github_command_targets_unsupported_host, command_safety.rs:1345) — the seam's exemption means a glob hides the host from that check while the shell expands it at runtime:

  1. pwd | gh repo view */*/*, pwd; gh repo view */*/*, cd fixtures; gh pr list -R */*/* are all admitted (probe-verified against the real classifier).
  2. bash/sh/dash expand */*/* against the folded cwd; a planted example.invalid/o/r directory tree yields exactly that token (live-verified; set -- */*/*).
  3. The guard evaluates only the hardcoded host api.github.com (shell.rs:3994), so a host-scoped policy (allow github.com, default-deny) passes, and GH_HOST=github.com pinning does not help: live gh repo view example.invalid/o/r under GH_HOST=github.com attempts https://example.invalid/... (verified; no external contact made — .invalid).
  4. Effect: host-scoped network-policy bypass plus attacker-chosen response content injected into a read-only agent's context. Mitigations that keep this at MAJOR rather than BLOCKER: Deny-default policies still refuse (the predicate over-recognizes gh stages); the fleet envelope and the no-network pre-check refuse all predicate-true gh reads regardless of host; no credentials are sent to the foreign host (unauthenticated). Undisclosed and untested by the PR — the gh-exemption pins all use literal operands.

One-line fix: in the operand gate's gh arms, refuse a gh stage whose post-verb tokens contain * (gh reads never legitimately name workspace files) — or expand and run each expansion through github_command_targets_unsupported_host.

Minors

  • M2 — docs/SUBAGENTS.md:100-101 garbled by the head commit. The round-6 cd-sentence edit produced "— and a workspace-confined / a workspace-confined cd <one relative path> in any sequence position…" (duplicated phrase; the zh twin in docs/zh_hans/SUBAGENTS.md is clean). One-line fix: delete the dangling fragment.
  • M3 — cd +N is the zsh twin of the round-5 =ls case. readonly_cd_operand_admitted (command_safety.rs:1157-1175) refuses -/=/~-prefixed cd operands but not +; cd +2; pwd classifies. On zsh, cd +N selects the Nth directory-stack entry — empty in a non-interactive -c shell — so the cd fails and a ;-sequence continues at the original cwd while the seam folds onto <cwd>/+2 (the cd-NEVER-RUNS lie again). Exploitation needs a workspace directory literally named +2 and a zsh host (not live-provable here — no zsh). One-line fix: refuse +-prefixed cd operands next to -/=/~.
  • M4 — combined_output foreground stdin contract. The PR drops the PTY writer at spawn, which removes the base behavior where a detached combined_output command could still be fed via interact, while combined_output foreground stdin-readers still wedge to the watchdog; the guidance sentence implies combined_output foreground commands have a usable terminal stdin, but theirs is one nobody can write to and that never EOFs. State the combined_output foreground stdin contract in the guidance copy.
  • M5 — the seam's own hardening flags are classifier-refused if spelled explicitly. git diff --no-ext-diff HEAD refuses (--no-ext-diff absent from the switch table) while the bare form is admitted and spliced. Fail-safe, usability only: add --no-ext-diff --no-textconv to the git diff/git log/git show switch lists.
  • M6 — gh -R ../foo admits where -R ../../x refuses. The host heuristic requires ≥2 path parts; a single-suffix relative repo slips it. Not a boundary break (still api.github.com), but arbitrary; treat any ..-leading -R value as unsupported.
  • M7 — od -j/-N huge decimals admitted. Digit-only check admits -j 999… (200 digits); GNU od fails safe at runtime. Parse to u64 and bound.
  • M8 — glob-walker over-approximations (all fail-closed, fine to keep if documented). Trailing-slash globs (wc */) judge regular files the shell would not expand (shell.rs:4895-4899); quoted stars are walked (cat '*' over-refuses); .* synthesis is unconditional on hosts where ./.. are never produced; dangling glob matches refuse where the shell's open would merely ENOENT (pinned semantics). Each is safe-direction; a sentence in the copy or docs would make them deliberate.
  • M9 — the gate's glob walk has no deadline or cancellation. Measured worst case before refusal: ≈3 s warm host CPU (500k-entry cap; 239k entries / 1.48 s on a /usr-scale zero-match probe), unkillable by the cancel token and not covered by the heavy-command permit (which keys on cargo/rustc only), so a looping agent burns host seconds per dispatch indefinitely. Bounded, hence MINOR; a per-command deadline checked in examine()/enter() is a two-line hardening.
  • M10 — no command-length cap. A 10 MB a;a;a;… allocates ~80 MB of transient slices before the 32-segment refusal (linear, input-bounded; ~8 linear whole-command passes per dispatch otherwise). An upstream length cap would close it.

Nits

  • fleet/executor.rs:246-249 comment rewrite is unclaimed by the body (accurate; one sentence in the body would cover it).
  • The second legacy-Bash wall (posture_permits_tool's bash_must_use_classifier_lane) is pinned only through a Scout fixture; a one-line Verifier-labeled assertion would label it.
  • No direct agent-grammar classifier pin for ?/[/{ spellings (charset refusal; one-look confirmation).
  • interact/cancel/wait dispatch before the ShellPolicy::ReadOnly match inside BashTool::execute — not exploitable today (the posture gate refuses read-only inputs shaped for those actions), but hoisting the policy match above the action dispatch is a one-line hardening.
  • The interact EOF message's parenthetical ("foreground commands run that way…") is slightly off when the flag was set by an operator interact --close-stdin; neutral wording would cover both.
  • The two-cd pin's doc comment misstates the fold-discard case (the gate refuses earlier at cd deep's canonicalize; the admit-instead outcome belongs to the partial-fold mutations). Pin still bites; doc only.

What verified clean this round

  • Round-6 fix (head commit) is sound. Classifier and seam split with the same connector-carrying splitter and cannot disagree on any command the seam accepts — every mis-split vector (quoted ;, escaped \;, & &, |||, empty segments, trailing backslashes) either refuses at the classifier or is caught by the seam's strict shlex, and shlex 2.0.1 proved POSIX-faithful on every dequoting nuance checked against live shells. No new BLOCKER member of the fold-lies family found (CDPATH is cleared; physical-vs-logical cwd, +N aside, checked; 32-segment fold accumulation correct; UTF-8 bytes cannot fake separators).
  • stdin-EOF fix verified end to end, including verifier gate jobs, the stdin_closed_at_eof lifecycle per task state, the write-then-close ordering, kill/cleanup paths, and the background lane's kept pipe; 6/6 named pins green and mutation-re-derived red by this review (shared-target provenance caveat handled).
  • Classifier grammar: no command form found that both layers accept and that writes, executes, or reads outside (redirects/heredocs/process-substitution refused at the charset; sort -o and every cluster form refused; sed's script-pinning survives all -e/-f/script-verb smugglings; find's ten mutating primaries refused; exec wrappers absent; #-comment semantics shlex-exact vs live shells).
  • Seam/execution: routing is a pure host-independent predicate with no judge/route divergence; pipefail keying and the cmd %/^/; refusals are pure per-shell-kind decisions pinned on every host (dash rationale reproduced live); the git re-quoter is exactly closed over the POSIX double-quote special set (11 nasty tokens byte-identical through bash/dash/sh); no git read reaches the shell lane unhardened (nothing outside the seven admitted subcommands classifies); no alternate entry point bypasses the operand gate under ReadOnly.
  • Glob walker: a faithful model of bash/sh/dash expansion (hidden-entry, symlink-following with resolution-at-judgment, multi-star ordering, trailing **, .*/.. synthesis at every level on sh/dash, GLOBIGNORE cleared both sides), single choke point on the blocking pool, budget genuinely shared and refuse-don't-truncate; two of the six load-bearing pins mutation-proven to bite in a throwaway harness.
  • Verifier authority (fix 3): the carve-out is Verifier-only on every path (spawn, nested chain, resume, fleet), the role set is single-sourced, receipts say read_only, legacy Bash stays triple-walled, and the disclosed residual holes (legacy record reconstruction, sub-subagent pin absence) are accurately disclosed.
  • Network guard architecture: one shared stage splitter, token-level per-stage predicate, all three consumers converted, is_github_readonly_command deleted with zero stale references; the parallel-classifier delta is exactly the gh -q table addition as claimed; npm disclosure accurate.
  • Claims audit: 43/43 cited tests exist verbatim, all five suite counts match digit-for-digit (84/175+1/608/361/48; fmt clean), the #123 merge-tree still yields exactly the two claimed conflict regions with the described mechanical resolution, #124 is clean at the cited head, #127/#128 disjoint, 36 commits / zero merges / all DCO-signed, CI green at this exact head.
  • Mutation re-run of the head's claims: both claimed mutations redden (seam-tautology → the [shell.readonly.cwd.conditional] gate pin; classifier-drop → its classifier pin plus the end-to-end pin), and a full no-fold mutation reddens both fold pins, so the fold-coverage claim is real. Two precision notes for the body's Testing section: (1) the seam-tautology mutation reddens only the gate pin — the end-to-end pin stays green because the classifier wall refuses first, so the seam check is observable solely via the gate pin's tag assertion; (2) under the classifier mutation the end-to-end pin reddens for a wording technicality — its boundary marker is the case-sensitive contains("read-only"), which cannot attribute a refusal to a layer. Neither is a boundary weakness; consider an attribute-on-tag assertion in the end-to-end pin.

Verdict

CHANGES_REQUESTED: the PR's substance is strong and honestly reported — but B1 leaves the read-only lane's core claim ("a nominal read must not become a program") broken by a mechanism the PR's own round-4 invariant already condemns, and M1 leaves an undisclosed host-scoped policy bypass one glob away from an admitted compound. Both fixes are small, local to splice_git_read_hardening/the gh arms of the operand gate, and naturally pin red/green with the PR's existing harness. The remaining minors/nits are follow-up sized.

@asto18089 asto18089 closed this Oct 29, 2026
@asto18089 asto18089 reopened this Oct 29, 2026
@asto18089
asto18089 force-pushed the fix/live-test-findings-subagent-shell branch from 9ca48b0 to 2fada9a Compare October 29, 2026 10:24
@asto18089

Copy link
Copy Markdown
Collaborator Author

Round-7 fix wave landed (2fada9a31)

All round-7 findings worth acting on are fixed in one commit on the same branch; the details below are the per-finding disposition. New pins: command_safety:: 84 → 89, tools::shell:: 175 → 182; fmt clean, clippy clean under the CI allow list, tools:: 2131/0, full lib suite green apart from the known rotating client::/remote_control:: flake set (each passes alone; none in a touched file). Mutations for the three new boundary behaviors: 4/4 red, each reddening exactly its own pin.

B1 (BLOCKER, filter execution) — fixed by discovery + per-key env pins. enforce_readonly_workspace_operands passing is now followed by discover_readonly_git_filter_pins: it scans the config of every repository the command's git stages can run under — folding each admitted cd exactly like the operand gate, and resolving each stage's -C preamble the way git applies it cumulatively (git -C sub status runs against sub's repository without the shell moving) — and the child env pins every declared conversion key to an inert helper: filter.<name>.clean/smudge → cat, filter.<name>.process → the inert helper (the protocol handshake then fails loudly; process= does not take, probed). Only declared keys are pinned — pinning process for a clean-only repo would introduce a protocol filter and break the working read (that exact failure mode showed up in the first E2E run and is now pinned by the partial-declaration case). Completeness rests on two probed facts: an attribute naming an undefined filter is inert (git skips conversion), and repo aliases cannot shadow the admitted subcommands. Anything the scan cannot confidently enumerate refuses fail-closed (include/includeIf, the extensions.worktreeConfig extension, unspellable names, >8 names: [shell.readonly.git.filter_config]). Pins: end-to-end readonly_shell_lane_git_status_does_not_run_repository_content_filters (fixture repo with an armed clean filter reading an outside secret; raw-shell positive control proves the fixture fires; tool runs must leak nothing and execute nothing), plus readonly_filter_discovery_pins_declared_filters, readonly_filter_discovery_refuses_configs_it_cannot_enumerate, and readonly_filter_discovery_follows_the_cd_fold_into_nested_repositories (fold, -C redirect, cd+-C .. compensation, interleaved --no-pager/-C). Mutations: disabling discovery and dropping the discovered keys from GIT_CONFIG_COUNT both redden the E2E pin on the outside-content leak itself; ignoring -C in the walk reddens the fold/-C pin.

M1 (MAJOR, gh glob host redirect) — fixed. A gh stage now refuses any wildcard after the program word ([shell.readonly.operand.gh_glob]): the shell would expand a workspace entry into the invocation's HOST/OWNER/REPO position, past the host the policy evaluated. The exemption keeps holding for literal operands (readonly_operand_gate_refuses_glob_operands_on_gh_stages covers piped/;-joined/-R/search forms and the surviving literal exemption; mutation: refusal → no-op reddens the pin).

Minors fixed: cd +N refuses (zsh directory-stack twin of =ls, same shape-rule line); the splice flags (--no-ext-diff --no-textconv --no-show-signature) are now admitted in the git option tables they get spliced into; gh -R ../foo refuses like ../../x; od -j/-N values must parse as u64; the read-only grammar bounds command text at 64 KiB (agent_readonly_shell_rejects_oversized_commands); the glob walk gained a 10 s wall-clock deadline checked in judge/enter/examine (readonly_glob_walk_refuses_once_its_deadline_has_passed, pinned via an expired-budget constructor rather than real waiting); interact/cancel/wait are policy-checked before the action dispatch — a read-only shell holds no task surface, and a task addressed under the policy could belong to another agent (readonly_shell_refuses_task_actions_at_the_policy); the combined_output foreground stdin contract is stated in the guidance and pinned; the interact EOF message states the closed-pipe reason neutrally; the en SUBAGENTS garbled cd sentence is repaired; the two-cd fold pin's doc now describes its discriminating cases accurately; the Verifier's legacy-Bash posture wall is pinned Verifier-labeled; the fleet executor comment is claimed in the body.

Declined on purpose: the walker's fail-closed over-approximations (trailing-/, quoted stars, unconditional .* synthesis, dangling refusal) stay as-is — safe direction, each now covered by the review record; the pre-existing >64 KB stdin-payload write under the manager lock and the fish-^ Custom-shell note remain follow-ups (the latter needs a fish host to pin honestly); sort-to-$TMPDIR is inherent to admitting sort.

Follow-ups carried on the body's ledger: the discovery scan does not model worktree commondir transitively (one level, which covers linked worktrees and submodules) or git reinit-style exotic layouts — the fail-closed refusal list covers the shapes that could hide config, and anything unreadable refuses. CI: the dispatched Pinvou fork CI run on this head is the authoritative gate (PR push events are being dropped for this branch — the codewhale-hq#699 pathology; the close/reopen retrigger did not materialize checks either).

@asto18089 asto18089 left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Round 8 — fresh 14-lane re-review (12 static + test-execution + mutation re-derivation)

Head 2fada9a31 (unchanged since the round-7 fix wave), base f1eb10efc = origin/pinvou3-clean tip = merge-base, so the required rebase onto the latest base is a no-op — the branch is already on top of the latest pinvou3-clean (upstream main is a different line; this fork PR targets pinvou3-clean as in rounds 1–7). CI: both workflow_dispatch runs are queued at the exact head in a runner-congestion window; in the meantime this review re-ran the claimed gates locally (exact counts + full suite + 4/4 mutations, below).

Verdict: CHANGES_REQUESTED. Everything outside the round-7 filter-discovery code re-verified merge-grade: no smuggled changes, fixes 1+3 sound, round-6 fold intact, the gh-glob fix sound, docs/claims accurate, counts exact, mutations bite. But the round-7 BLOCKER fix's central claim — "discovers every filter the repositories a stage can reach declare" — fails five independent ways, each first-hand proven to restore the original defect (attacker-chosen command executing under an admitted git status/git diff, with the fix's own env in place).

BLOCKER-class — the round-7 filter discovery is incomplete (B-1 … B-5)

B-1. Filter names merged case-insensitively; git matches them case-sensitively. shell.rs:4640 (within-config dedupe) and :4484 (cross-repo merge) use eq_ignore_ascii_case, but git config subsections and the attribute→driver lookup are byte-sensitive (re-probed: attr filter=mix runs [filter "mix"], filter=Mix runs [filter "Mix"]). Repo config declaring [filter "Mix"] clean = cat plus [filter "mix"] clean = sh evil.sh (attr * filter=mix) yields a single pin spelled filter.Mix.*; under the tool's exact env the lowercase driver still executes (marker written). Reproduced end-to-end by three independent lanes, single- and cross-repo. The same fold also over-pins across cases: a case-variant declaring only process arms a process pin for the first-seen name and breaks a working clean/smudge filter — the exact failure the only-declared-keys design says it avoids. Fix: dedupe byte-exact (or refuse case-colliding names, mirroring the unspellable-name refusal); pin a two-case red/green fixture.

B-2. Same-line section keys are invisible to the scan. armed_git_filter_names continues after a […] header (shell.rs:4581-4601), discarding the rest of the line; git parses [filter "sl"] clean = <cmd> on one line (probed). Proven end-to-end with the fix's exact env: the repo's clean filter runs (marker written). Blast radius is wider: same-line [include] path = … and [extensions] worktreeconfig = … are likewise read by git but unseen, so the same spelling also defeats the two fail-closed refusals (included-config filter fired in the probe). Fix: parse the post-] remainder through the key parser, or refuse a header line with trailing content.

B-3. Continuation handling diverges from git — silently (no refusal). continuation = raw.trim_end().ends_with('\\') is set before the comment check and without backslash counting (shell.rs:4570-4577). Byte-probed on git 2.43: (a) a comment line ending in \ does NOT continue (comments end at the newline) yet the walker skips the next line — a [filter "evil"] section hidden behind # note \ arms and runs (HIDDEN_EVIL marker, zero pins, no refusal); (b) an unquoted value ending in \\ is git-escaped (value\, line ends) yet the walker swallows the next line (BSLASH_EVIL). Both violate the body's own contract — "anything the scan cannot confidently enumerate … refuses" — without refusing. Fix: continuation only inside unquoted values, only for an odd trailing-backslash count, never after a comment line; or fail closed on any \-terminated comment/value ambiguity.

B-4. A non-UTF-8 byte in the .git gitdir file silently reports "no repository". read_to_string(&candidate).ok()? (shell.rs:4507); git parses the file byte-wise. Proven end-to-end: a linked worktree whose gitdir path contains one 0xE9 byte runs the main repo's clean filter under git status (WORKTREE_EVIL) while the scan returns Ok with no pins and no error — fail-open where this code's convention is refuse. Fix: read bytes (or map the decode error to the filter_config refusal).

B-5. Submodules are never enumerated — the superproject's admitted reads execute the submodule's filters. This lane dispute was settled with a correctly-armed fixture on git 2.43 (an earlier probe's zero-result was fixture poisoning — tee MARKER >/dev/null is not an identity filter; git reads the filter's stdout, so the redirect hashes every file as the empty blob). Setup: submodule's .gitattributes * filter=evil + config armed only in super/.git/modules/sub/config (super config has zero filter lines). Results: superproject git status -s, git status, git diff, git diff --ignore-submodules=none, --porcelain=v2 (plain and --ignore-submodules=none) all execute the submodule's clean filter whenever a submodule file is stat-dirty with preserved size — including mtime-only touch while git reports "nothing to commit, working tree clean" — and git diff --submodule=diff fires even for size-differing dirt (it runs a second in-submodule child). GIT_TRACE/strace show the forked child (cd sub; unset GIT_PREFIX; GIT_DIR=.git git status --porcelain=2) and that it inherits GIT_CONFIG_KEY_n/VALUE_n/COUNT intact — so pins derived from a correct enumeration would suppress it; the single-repo scan never reads .gitmodules or .git/modules/*//config, while the doc comment (shell.rs:4331-4345) claims completeness ("every repository a stage can reach"). Fix: enumerate registered submodules' gitdirs (.gitmodules + .git/modules/*), or refuse stages in repositories with initialized submodules.

Fixture-methodology note for the fixes (bites every red/green pin in this family): git status fires clean only for stat-dirty-with-matching-size (racy) entries — size-differing dirt is decided by stat alone without conversion, and a settled index silences the fire until the next touch; git diff converts the worktree side unconditionally. Control sizes carefully and re-touch before each run.

M-1 (same family, pre-existing on base — fix or disclose). core.worktree in repo config redirects admitted reads outside the workspace: git status --porcelain lists outside entries, git ls-files -o lists them, and with an index entry seeded under a matching name git diff displays the outside file's content (all probed; no hunks touch this on base or head). This is the next member of the same "repo config is attacker-controlled" family rounds 4/7 close, and unlike filters it needs no enumeration — a core.worktree= env pin (same GIT_CONFIG mechanism, outranks repo config, probed) or a config-scan refusal would close it cheaply. If deferred, give it a body bullet with the npm bullet's rigor.

M-2 (load-bearing refusal, undocumented). Command-line -c key=value outranks the entire GIT_CONFIG_* env family — probed for filter pins and the core.hooksPath pin, under three placements including --no-pager -c and -C . -c. Unreachable today only because the classifier refuses every pre-subcommand token except --no-pager/-C (command_safety.rs:1063-1074). That refusal is now load-bearing for the whole round-4+round-7 pin wall and nothing records the dependency; a future admission of -c would silently neutralize both fixes. Add a comment at the pin construction and a pin asserting -c-bearing git stages refuse.

HIGH — tests/CI

  • T-1. Dead fixture: the worktree gitdir:-link/commondir branches have zero red coverage. readonly_filter_discovery_pins_declared_filters's worktree sub-case (shell/tests.rs:1329-1341) writes repo/.git-file, but the scan reads repo/.git — a directory — first, so the link branch (candidate.is_file()) and the commondir join are never executed; deleting both production branches leaves the suite green. Use a real git worktree add checkout whose .git is a file.
  • T-2. The filter E2E breaks the required Windows CI leg. readonly_shell_lane_git_status_does_not_run_repository_content_filters (shell/tests.rs:1541) hard-depends on sh (positive control and filter command) with no #[cfg(unix)], unlike its sibling readonly_shell_lane_git_show_does_not_run_repository_textconv (cfg-gated) and ~62 other sites in the file; windows-latest runs cargo nextest run --workspace with no platform filter. Gate it before merge.

Performance (new code; the rest of the lane found the read-only lane clean — sub-millisecond common case, nothing on the full-shell/background paths)

  • P-1. Quadratic filter-name accumulation, cap deferred. The per-config find over an unbounded names vec (shell.rs:4638-4646) and the cross-repo merge (:4481-4491) run before the >8-pin refusal (:4420): a crafted 256 KiB config (~13k names) costs ~0.3–0.8 s per admitted git status, and a 32-nested-repo compound minutes — on the blocking pool, with no child to watchdog. The same "admitted command buys host work" class round 4 closed for globs. Fix: refuse inside armed_git_filter_names once names exceed the cap (behavior-preserving) and/or HashSet dedupe.
  • P-2. Unbounded reads before size checks: the .git link file (shell.rs:4507), commondir (:4527), and the config itself (:4540-4548 reads fully, checks 256 KiB only after) — a planted multi-GB .git/commondir buys a full-size transient per scanned repo per command. metadata().len() first, or a capped buffer.

Minors

  1. The code comment (shell.rs:6439-6440) and body claim an empty filter.<name>.process override is ignored and the attacker command ran — inverted on git 2.43: the empty override takes (silent disarm, rc=0, repo command not run; probed 3/3 + strace). The /usr/bin/false helper design is still correct; the stated justification is factually wrong and should not be cited.
  2. git < 2.31 silently ignores the whole GIT_CONFIG_COUNT block (comment shell.rs:6415-6419): on old-git hosts the filter wall is absent for status/ls-files/grep and env is the only vehicle (-c is refused). Consider a version probe/refusal or a louder disclosure.
  3. Cross-repo merge ORs armed flags across repos sharing a name (shell.rs:4481-4492): a clean-only repo's working filter degrades to a loud handshake failure when a sibling repo declares process for the same name (availability, fail-closed direction).
  4. The 256 KiB config scan-cap refusal is untested (deleting the check leaves the suite green).
  5. Deadline insertion points in judge/enter are individually unpinned — only removing examine's check reddens the deadline test; a multi-component pattern would cover the others.
  6. The E2E's git status -s arm has no raw-shell positive control (its own comment concedes status may decide from stat); only the diff arms are proven live. Add a git status -s control with a size-preserving-dirty fixture.
  7. Windows-only: the 64 KiB cap is checked after the allocating normalize (the cap comment's "bounds both layers" is untrue on that cfg), and glob re-entry keys store full path text on non-unix (~100–400 MB worst-case transient at the node cap). Cheap bounds exist for both.
  8. Pre-existing literal-host residual: gh repo view myhost/o/r (single-label, no dot/colon) classifies as OWNER/REPO while gh routes HOST/OWNER/REPO — the wildcard-free remainder of the round-7 gh MAJOR; fold into the disclosed npm follow-up.
  9. gh pr list --search '*' is now seam-refused everywhere (quoted star over-refusal — safe direction) but command_safety.rs:712/:1239 still cite it as the canonical admitted shape; stale as end-to-end descriptions.
  10. The full-suite flaky declaration is stale at this head: remote_control::tests::separate_predispatch_crashes_on_one_run_get_distinct_recovery_turn_ids failed 3/3 full-suite runs here (plus two remote_control/runtime_threads siblings once each, all passing alone); the branch touches neither file (pre-existing load flakiness), but merge-queue CI will churn — extend the body's flaky list or fix.

Nits: the refusal copy interpolates the 32-segment cap but not the new 64 KiB bound; the discovery has_git_stage pre-scan duplicates the shlex parse its loop repeats; the deadline comment's "bounds its wall time" holds only between operations; en FLEET.md has no twin of the changed zh member-permissions paragraph (pre-existing divergence); 20/37 commit subjects exceed the parent repo's 50-char guideline (convention doc absent from this repo — informational).

Verified sound this round (re-derived first-hand; do not re-litigate)

  • Smuggling: none. All 37 commits touch exactly the 16 claimed files (4878/233), every hunk in scope, DCO 37/37, #124's topic functions untouched, no secrets/unsafe/new deps; both disclosed out-of-lane deltas (gh -q/--jq classifier admission, Windows-only normalization) match the body. Deletions all map to disclosed replacements.
  • Merge order: re-verified fresh today — #123 conflicts confined to exactly the two described regions with the described content (the mechanical union and the pinned-phrase copy union are correct and load-bearing; #123's "blame spawns no configured helper" comment must indeed not survive), #124 clean at 97a266091, #127/#128 share zero files.
  • Fixes 1 and 3: every stdin wiring site verified (foreground unconditional close, sync null(), background keeps the pipe, PTY spawn untouched, gate-job EOF, stdin_closed_at_eof lifecycle and messages); verifier carve-out ordering (clamp → capture → narrow) and Verifier-only-ness re-derived, including a behavior-equivalence argument for the dropped posture term; receipts truthful; en/zh copy consistent; every "checked and left alone" claim matches the code.
  • Round-6 integrity: the connector-aware fold is intact and all three consumers (classifier, seam, discovery walk) share the one splitter; discovery runs strictly behind the gate in the same closure; prior-round pins intact (one E2E wall assertion widened read-only-lane-wide; the leak guard remains the load-bearing assert — acceptable).
  • Round-7 MAJOR (gh glob): both arms refuse post-verb wildcards, keyed per gh stage, literal exemption kept, fail-closed through all network pre-checks; the parent classifier's charset refuses * independently.
  • Round-7 minors: 13/13 verified present, correct, honestly claimed (incl. cd +N, spliced flags admitted, od u64, 64 KiB cap before allocation on unix, policy-before-task-actions, en SUBAGENTS dedup).
  • Claims audit: all 21 checkable body claims hold, incl. the 15 test-count numbers (89 / 182+1i / 608 / 361 / 48 — executed here, exact), five E2E/pin tests run live and passing, and the 12.3k full suite green at this head modulo the pre-existing flake above.
  • Mutations: the four claimed round-7 mutations re-derived and applied in a dedicated worktree — 4/4 red with exact isolation (discovery no-op → E2E + both discovery pins; env-pin drop → E2E only; gh-glob no-op → gate pin only, classifier pin stays green; -C ignored → the -C pin only), each reverted to green, worktree clean.

Required before merge

B-1 … B-5 (each with a red/green pin per shape — mind the size/racy fixture gotchas above), T-1, T-2; strongly recommended in the same wave since they are one-to-three-line fixes: P-1, P-2, the M-2 comment+pin; M-1 fix or disclose with follow-up rigor. The mechanism itself (gate-then-discovery ordering, env-pin transport incl. into git's forked submodule children, fail-closed refusals, only-declared-keys rule within one repo, cat//usr/bin/false inert values) is sound — the gaps are enumeration completeness and parser fidelity, not architecture.

@asto18089

Copy link
Copy Markdown
Collaborator Author

Round-8 fix wave — all review findings addressed

Landed as one commit (6f9b1f2712, content-identical tree 87862d063d to the local 201aa8ce1, landed via the Git Data API during the 443 flap), rebased-free on top of 2fada9a31 (base f1eb10efc unchanged — the review's rebase check was a no-op and stays one).

Per-finding resolution:

  • B-1 case-folded name dedupe → fixed. Both merge points (armed_git_filter_names, cross-repo merge) dedupe byte-exactly; a two-case fixture ([filter "Mix"] + [filter "mix"]) pins that each spelling gets its own pins.
  • B-2 same-line section keys → fixed. The header's post-] remainder now parses as a key line; the same-line spelling hits the include/worktreeConfig walls (both refusal arms pinned) and arms filters the way git does (pin).
  • B-3 continuation divergence → fixed. Continuation keys on the odd trailing-backslash count (escaped-backslash values end their line), comments never continue, a header line with a dangling continuation refuses. Pinned three ways: backslash-terminated comment → the hidden [filter] is discovered (git parses it), value\\ → next section discovered, value \ → the value continues.
  • B-4 non-UTF-8 gitdir link → fixed. The link file (and the commondir pointer) now read bytes, decode the first line exactly as git consumes it, and refuse on undecodable bytes instead of reporting "no repository"; the oversized variants refuse on metadata() before any read (P-2).
  • B-5 submodules → fixed. .gitmodules-registered submodule repositories scan (byte-faithful path resolution; quoted/escaped/escaping/out-of-workspace registrations refuse), recursively through nested registrations, bounded by a 32-repository scan cap alongside the existing pin cap. The unit pin covers absorbed-layout gitdir links, nesting, and unresolvable registrations; the new #[cfg(unix)] E2E builds a real git submodule add fixture whose raw-shell control proves the submodule filter fires from the superproject (on a clean-reported tree, size-preserving dirty) before the tool's pins hold.
  • M-1 core.worktree → fixed by validation, not blanket refusal. The review's E2E run exposed that a blanket refusal breaks every absorbed submodule: git itself writes core.worktree = ../../../sub into .git/modules/sub/config. A declared core.worktree now admits only when it resolves (relative to the gitdir, git's rule) back to exactly the worktree the repository was discovered in, and refuses every redirect — pinned resolve-and-refuse plus resolve-and-admit.
  • M-2 -c precedence → documented and pinned. The dependency is recorded at the discover doc comment and the env-pin construction; the classifier refusal pins gained git -c filter.evil.clean=evil status/diff cases.
  • P-1 quadratic parse → fixed. The 8-pin cap fires during the per-config parse (>= MAX before each new name), so a crafted config costs linear work; the cross-repo merge is bounded by the caps (≤8 names × ≤32 repositories).
  • P-2 unbounded reads → fixed. Link, commondir, config, and .gitmodules are metadata()-bounded (4 KiB for the two pointer files, 256 KiB for the two configs) with post-read re-checks.
  • m-1 inverted comment → corrected (empty process override does take on current git; the /usr/bin/false helper is the honest wall because it fails loudly everywhere). The PR body never carried the inverted claim.
  • m-2 git < 2.31 floor → gated. When discovery arms any pin, git --version is checked (git_env_config_supported, comparator unit-pinned across 2.43/2.31.1-Apple/2.31.0-windows/2.31/2.30.9/1.9/bare-major/garbage) and the command refuses naming the version gap, since env is the only vehicle for status/ls-files/grep.
  • m-4/m-6 → pinned. Oversized-config refusal and the E2E git status -s raw-shell control (size-preserving fixture — the review's vacuous-green finding) both landed; the diff fixture also switched to size-preserving dirt for the same reason.
  • m-7 → fixed. The 64 KiB bound fires before the Windows normalize (cfg'd, keeping unix byte-identical; the post-normalize check stays load-bearing because backslash doubling can grow text), and non-unix glob identity keys are byte-bounded (8 MiB cap on the entered-key bytes, cfg(not(unix)) field on the budget).
  • m-8 single-label gh hosts → fixed. A three-slash-component operand is what gh reads as HOST/OWNER/REPO, so its first component is now host-checked regardless of dots (gh repo view myhost/owner/repo, -R myhost/o/r, a/b/c refuse; owner/repo, github.com/owner/repo, two-component branch spellings stay admitted). No admitted grammar subcommand takes positional multi-slash non-repo operands, so the over-refusal surface is slash-bearing --search/tag spellings — fail-closed.
  • m-9 stale docs → corrected. Both predicate doc comments now state that the seam refuses wildcard-bearing gh stages while the predicate over-recognizes them for the pre-checks.
  • T-1 dead worktree fixture → rewritten as a real gitdir-link checkout (<checkout>/.git file → <main>/.git/worktrees/w → commondir → shared config), so deleting the link or commondir branches now reddens.
  • T-2 → #[cfg(unix)] on the filter E2E, matching its textconv sibling.
  • m-10 flaky list → body updated with the round-8 full-suite observation (the remote_control set; all 77 pass alone; no touched files).

Deliberately not changed (from the review, with reasons):

  • m-5 (deadline judge/enter insertion points unpinned): not pinnable — the walk's flow order puts examine before judge/enter for every entry, so with an expired budget examine's check always fires first; the judge/enter checks are defense-in-depth duplicates, and any fixture that reddens on their removal would redden examine's too. Left as-is rather than writing a pin that can't discriminate.
  • m-3 (cross-repo process over-pin): inherent to the env mechanism — pins are command-scoped and cannot be repo-scoped; the merged over-pin degrades to a loud handshake failure (fail-closed), left disclosed.
  • refusal-copy 64 KiB mention: left out to keep the one-[shell.readonly.command]-literal fix(subagent): release interrupted claims and inherit worktree isolation #123 union surface minimal; oversized input is already anomalous.
  • en FLEET paragraph twin / 50-char commit subjects: pre-existing structural divergence and a history rewrite respectively — out of this wave.

Verification at the new head: targeted command_safety:: 89/0, tools::shell:: 185/0/1i, tools::subagent:: 608/0, fleet:: 361/0, tools::registry:: 48/0 (round-8 deltas: +3 test fns, +#-free); full --lib suite 12 336 passed / 3 failed / 13 ignored, the 3 being the remote_control load-flaky set above (77/77 alone; branch touches none of those files); cargo fmt clean; clippy clean for every touched file except the pre-existing local collapsible_if in the round-6 cd-fold (untouched hunks, same documented class as turn_loop.rs:5499). Mutations: 8/8 red, each reddening exactly its own pin — case-folding the dedupe; skipping the header remainder; inverting the continuation parity; undecodable-link → no-repository; skipping submodule enumeration (both submodule pins, the E2E on the leak itself); no-op'ing the core.worktree validation; forcing the version gate true; reverting the gh host heuristic — each reverted to green, worktree clean. CI dispatched at 6f9b1f2712 (run 40928318791); the two earlier queued dispatches are pinned to the old head and can be cancelled.

PR body updated: round-8 bullet in Review rounds, round-8-head counts, the flaky-set note, and the 8/8 mutation list.

@asto18089 asto18089 left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Round 9 — fresh 14-lane re-review (12 static lanes, one test-execution lane, one mutation re-derivation lane)

Verdict: CHANGES_REQUESTED. The round-8 fix wave's pins are real — 8/8 mutations re-derived red with exact isolation, the quoted-form [filter "x"] defense verified end-to-end, fixes 1 and 3 sound, counts exact, hygiene clean. But the hand-rolled config parser/enumerator still diverges from git's actual grammar in seven first-hand-proven ways (three proven end-to-end through the tool), so the headline claim — content filters discovered from every reachable repository and pinned inert — does not hold; plus one chained -C boundary leak, one unsanitized exec in the version gate, and a merge-order section that no longer matches the forced-pushed #123.

Rebase: no-op — origin/pinvou3-clean tip f1eb10efc == merge-base == PR base (re-verified 2026-10-30); MERGEABLE, base/head OIDs unchanged.

BLOCKERs — filter discovery still incomplete (each restores the round-7/8 defect class; all first-hand verified against git 2.43 with probe repos; B1/B2/B3 additionally driven end-to-end through the tool)

  1. Dotted section headers [filter.x] arm a live driver the parser treats as inert. Git accepts [filter.x], [FILTER.EV] (dotted-form subsections are lowercased), and [filter.a.b] (subsection = everything after the first dot; attribute filter=a.b matches) as three/four-part keys — probed: with repo config [filter.x] clean = tee marker and .gitattributes *.s filter=x, a committed, size-preserving-dirty file makes plain git status write the marker. The parser requires a quoted subsection: an unquoted header yields (base = "filter.x", subsection = None), and armed_git_filter_names skips it as inert (setting.base != "filter", tools/shell.rs:4951) — zero pins, and the E2E through the tool admits the status and the filter runs (raw-shell control fired, tool fired). The same shape in .gitmodules ([submodule.s]) is honored by real git and invisible to gitmodules_submodule_paths (setting.base != "submodule", tools/shell.rs:5058) — the round-8 submodule gap restored through a first-class spelling. Every E2E fixture uses only the quoted form, so the suite stays green. Fix nuance: dotted-form subsections are case-folded by git; quoted-form subsections are byte-exact — the round-8 byte-exact dedupe must not be regressed while adding the folded dotted path.

  2. Two section headers on one physical line: git re-targets the section, the parser pins the decoy. [filter "decoy"] [filter "evil"] + next line clean = … — git's char-based parser starts a new section after the first ] (probed via git config --list), so clean arms filter "evil". The parser takes the whole remainder as a key line under decoy and arms filter "decoy" — with the lane's pins active the E2E still writes the marker: the pins name the wrong driver and evil runs unpinned. The round-8 "same-line key" fix (tools/shell.rs:4898) introduced exactly this remainder-as-key handling; a remainder beginning with [ must re-enter header parsing.

  3. A value followed by a trailing comment that ends in an odd backslash over-continues. clean = cat ; note \ + smudge = evil: git ends the value at the mid-line comment (comments end at the newline; the backslash inside the comment is dead), so BOTH keys are live. The parser computes git_config_line_continues(raw) on the raw line without stripping the trailing comment (tools/shell.rs:4936), consumes smudge = evil as continuation text, and pins only smudge — the real clean runs unpinned (E2E: marker written with pins active). The round-8 parity fix is correct for full-line comments; this value-then-comment shape was missed.

  4. Duplicate core.worktree: git honors the LAST value, the validator checks the FIRST. validated_core_worktree uses .find(...) (tools/shell.rs:5020). Probed in the absorbed-submodule form the walk scans: worktree = ../../../sub followed by worktree = /outside → git rev-parse --show-toplevel = /outside, while the validator resolves the first value back to the discovered worktree and admits. A crafted config restores the out-of-workspace read class. Validate the last value.

  5. Submodule fork-in does not require .gitmodules; enumeration is .gitmodules-driven only. Git forks its dirty-check child into any index gitlink whose path holds a repository — registration only refines the label. Probed: submodule with hostile clean filter armed in its repo config, .gitmodules deleted from the worktree (index still has it) → git status -s fires the filter. The scanner returns early on .gitmodules NotFound (tools/shell.rs:4614), so nothing is scanned and no pins are armed — unpinned execution under the fix's own env. Enumeration must be index-driven (gitlink entries via git ls-files --stage/index walk), not registration-driven.

  6. Config values keep inline comments; git strips them. path = sub ; note decodes to sub in git (fork happens) while the scanner joins a nonexistent directory and silently dedup-skips — fail-open mis-scan on the .gitmodules path case (tools/shell.rs:4915, :5067). The repo-config direction over-arms pins (benign — pin values are fixed literals); core.worktree over-refuses (fail-closed).

  7. A UTF-8 BOM before the first header hides that whole section. Git skips the BOM (probed: config readable, filters fire); str::trim does not strip U+FEFF, so the line never matches starts_with('[') and — before any header — is skipped, taking the section's keys with it. A BOM-prefixed [filter "evil"] (or [submodule "…"]) runs unpinned (probed). Mid-file BOM makes git fatal, so only the leading-BOM shape matters.

MAJORs

  1. Chained git -C A -C escape status defeats the operand gate's flat judging. Every relative operand is judged against the same effective cwd (tools/shell.rs:5432), but git applies -C cumulatively: probed, git -C <ws>/A -C escape status -s with A/escape a symlink to an outside repo lands there and lists outside file names — while the flat form of the same target, git -C A/escape status -s, is refused at [shell.readonly.operand.outside_workspace]. That inconsistency proves the flat judgment is the bug (E2E through the tool: chained form admitted, outside name crossed). Filter conversion stays covered (discovery follows the same chain), so the leak is boundary/names — but git diff in the chained repo would cross converted outside content. The doc comment at tools/shell.rs:4536-4538 ("every -C operand was already canonicalized inside the workspace") is materially false for this shape; resolution must chain -C values the way git does (or refuse a -C whose accumulated path escapes).

  2. The git version probe execs under the host env/PATH, not the child's sanitized env. ensure_git_supports_env_config_pins (tools/shell.rs:4487) runs Command::new("git").arg("--version") with no env or PATH handling, uncached, whenever pins are armed — while the child lane runs a sanitized PATH and refuses workspace-root binaries under the lane's own invariant that attached roots are untrusted for programs and PATH. If the operator's host PATH carries an agent-writable directory ahead of the system git, a repo that declares any filter plus a planted git shim in that directory executes attacker code under the full tool-process env. The probe should resolve git the way the child lane does. (Secondary: it may version-check a different binary than the child runs.)

  3. The merge-order section is stale against the force-pushed #123 (2071e94cd). Fresh git merge-tree from the common base conflicts in three files, not the two described regions: the refusal-copy string and the hardening table are exactly as described (verified #123's side still carries "Not admitted: cd, &&, ;" and the blame-spawns-no-helper comment), plus tools/shell/tests.rs — #123's kill-path test block vs this branch's round-5 seam pins at the same anchor (merged-tree tests.rs:7139-7614). "#127/#128 share zero files" is also stale: #128 (7fe08d55a) now shares subagent/mod.rs + subagent/tests.rs (still auto-merges). #124 (97a266091) and #129 (f83e8cd84) are clean. Anyone unioning per the current text will miss tests.rs; the resolution is mechanical (keep both blocks). The load-bearing note itself verifies against head code (splice_git_read_hardening's _ => Err would refuse #123's admitted rev-parse/branch/worktree list on both lanes).

Claim-accuracy fixes needed before merge

  • The clippy claim is false at this head under the invocation the body quotes. cargo clippy --workspace --all-targets --all-features --locked with ci.yml's allow list exits 101 with exactly two collapsible_if errors — core/engine/turn_loop.rs:5499 (disclosed, pre-existing, untouched) and tools/shell.rs:4461-4465, which blame shows was introduced by this branch's own commit 2fada9a31 in a touched file. The fork CI leg passes because fork-ci.yml still allows the lint and omits --all-targets (both invocations verified locally first-hand). Upstream deliberately removed that allowance (ci.yml:353-356: the allowances "were hiding exactly the class of problem that reached the gate"). One mechanical collapse fixes it; the Testing bullet needs correcting either way.
  • Full-suite bookkeeping: the 12 336/3/13 tui-lib claim reproduced exactly and everything passes alone, but this round's third flake was tui::clipboard::tests::wl_paste_helper_reads_text_from_stdout — outside the documented rotating set (passes alone in 0.00 s; clipboard.rs untouched by the diff). Update the body's flaky list.

Structural recommendation (fourth consecutive round of parser divergence)

Round 7 found one gap in this parser, round 8 five, round 9 seven — each a new dialect shape in the same ~200 hand-rolled lines, each restoring arbitrary command execution under an admitted read. The quoted-form defense now stands (mutation re-derivation 8/8 red with exact isolation; the env-pin mechanism verified end-to-end including into git's forked submodule children and nested submodules; the version gate uniform across all seven admitted subcommands; single-value core.worktree validation correct in every discovery form). The convergent fix is to stop hand-parsing and enumerate through git itself: git config --list -z --local in each discovered repository (git's own parser; every dialect shape above collapses into "git said so"; includes become enumerable instead of unscanable) and git config --blob :gitmodules --list -z or git ls-files --stage gitlink enumeration for submodules — keeping the hand parser only as the refusal path when git cannot be invoked. That converts an unbounded dialect chase into one bounded integration.

Verified sound this round (for the record)

Fix 1 holds end-to-end (foreground close with no reachable early-error leak; sync Stdio::null(); background pipe kept; PTY untouched; stdin_closed_at_eof recorded/consumed correctly; gate jobs EOF at spawn — the two named red/green tests pass). Routing, pipefail keying, per-shell-kind refusals, cfg-gated Windows transforms, and child-env construction all verified with live probes (dash pipefail abort, missing /usr/bin/cd, with_env_var override semantics — no hostile GIT_CONFIG_* collision is possible). The cd fold, connector semantics, and cd shape rules are sound with no new bypass (classifier and seam share one splitter and one shape predicate; the residual pre-spawn TOCTOU is honestly documented). The operand gate and glob walker are sound in the boundary direction (every walker match judged by resolved location; over-approximations only refuse; ./.. synthesis matches dash parity). The classifier held against ~80 adversarial spellings with real-binary ground truth (git grep -O/--open-files-in-pager provably exec a pager and are refused; sed w/r/e verbs including glued w/tmp/x and 2wp refused; sort -o/-rno, uniq's second operand, wc --files0-from refused; -c precedence probe confirms the refusal is the only wall for the env-pin family); the parallel classifier is byte-identical to base with exactly the pinned deltas. Fix 3's capture point is exactly where claimed, the carve-out is literally Verifier-typed, delegation cannot launder authority, receipts are truthful, and the copy matches the classifier. Targeted counts 89/185+1i/608/361/48 exact; fmt clean; full suite green modulo the flaky set; 38/38 DCO; exactly the 16 claimed files; the −328 removals all accounted for; docs en/zh consistent; the left-alone list intact (nothing half-fixed, nothing smuggled).

MINORs (fix or disclose)

  • The version gate fires only when filter pins are armed; core.fsmonitor/hooksPath pins ride the same ≥ 2.31 floor (tools/shell.rs:6907) — a filter-free repo declaring fsmonitor on a pre-2.31 git runs the hook under admitted status/ls-files/grep, the arms where env pins are the only wall (fsmonitor exec probed live on 2.43; the gate should key on any emitted GIT_CONFIG_KEY_n).
  • Unpinned walls (this repo's own history: refactors drop walls and only tests notice): the 32-repository cap, the total 8-pin cap, commondir undecodable/oversize refusals, .gitmodules oversize, the non-unix glob key bound, the agent grammar's \n/\r charset refusal, and the cwd fold through an in-workspace symlinked cd destination (the exact case the canonicalize fold exists for).
  • Fail-closed availability with no pinned off-ramp: >32 resolvable submodule registrations or >9 declared filters refuse every git evidence command under that repo; a linked submodule worktree (git worktree add of a submodule) refuses because core.worktree is validated against a worktree git ignores there (probed).
  • .git pointer targets are trimmed while git consumes the line byte-exactly — a crafted sibling directory named with trailing spaces can get the scanner to pin repo A while git runs repo A (contrived, fail-open).
  • ZDOTDIR="" does not stop zsh sourcing the default ~/.zshenv, where operator EXTENDED_GLOB/DOT_GLOB could widen admitted literal operands (a^b, dotfiles) past the walker — zsh-only, operator opt-in, unprobed here (no zsh on this host); worth a zsh -f-style suppression or a probe on a zsh host.

NITs

sed ;-refusal comment names the charset gate as the wall (real walls: mis-split + seam shlex, command_safety.rs:1134); gh wildcard refusal copy says "the shell would expand" where quoted spellings are refused by the guard's quote-stripped over-approximation (shell.rs:5156); od -An glued spelling refuses while -A n admits; quoted-separator filenames (cat 'a;b') are unreadable through the lane; a dangling symlink inside a glob match refuses the whole probe; Windows-looking POSIX filenames (C:x, any \) refuse on unix; the refusal copy's family enumeration omits the pre-existing codewhale --version family; VERIFIER_AGENT_INTRO's read-only-shell sentence lacks the "for in-session delegations" qualifier its siblings carry; the foreground stdin close is sequenced after the (currently infallible) attach_heavy_permit.

Pre-existing (base behavior, unchanged here — follow-up material)

The parent-side parallel auto-approve classifier's charset admits ( and ), so zsh process substitution cat =(cmd) passes the parent's read-only/auto-approve path on a macOS-zsh host (the agent grammar this PR hardens refuses parens; flagged by two lanes); inline stdin payloads > 64 KiB against a non-reading live child can stall the executor thread and shell-manager lock watchdog-blind; the fleet envelope projects a read-only Bash schema to shell-less verifiers whose every call refuses.

CI state

Every Pinvou fork CI workflow_dispatch on this branch for 24+ hours is zombie-queued with zero jobs (the stale ones return 409 and cannot even be cancelled); a fresh dispatch at this head (run 40992820404) is zombie-queued identically — this needs repo-admin attention (runner policy/Actions health), and until it resolves there is no green required-gate signal at head. Local gates stand in as reported above. MERGEABLE/BLOCKED-on-pending-checks, base f1eb10efc.

@asto18089

Copy link
Copy Markdown
Collaborator Author

Round-9 fix wave — all review findings addressed

Landed as one DCO commit (93a28bde6, on top of 6f9b1f271, base pinvou3-clean unchanged — rebase was a no-op at f1eb10efc).

The seven proven parser/enumerator divergences (BLOCKERs)

All first-hand reproduced against git 2.43 in the review, each now mirrored and pinned:

  1. Dotted section headers ([filter.x], [FILTER.EV], [filter.a.b], and the combined [filter.x "y"]) armed real drivers the parser treated as inert two-part keys. Headers now parse in all four git shapes — dotted subsections case-folded with the split at the first dot, quoted names byte-exact, combined forms concatenated.
  2. Two headers on one physical line re-targeted the section at the second [ while the parser pinned the decoy; the remainder after ] now loops.
  3. A trailing comment ending in an odd backslash over-continued and swallowed the next key; comments are now cut quote-aware (#/; outside double quotes), with quote state carried across continuation lines, before backslash parity is computed.
  4. Duplicate core.worktree: the validator now checks the LAST value (git applies the last).
  5. Submodule enumeration is index-driven: git forks into any index gitlink holding a repository — .gitmodules is only a label (probed: deleted from the worktree, still in the index, git status still fired the submodule's filter). The scan now runs hardened git ls-files --stage -z (cleared env + the child's sanitized PATH, capped stdout, unresolvable paths refuse); .gitmodules is no longer consulted.
  6. Inline value comments are now stripped exactly where git strips them (subsumed by the comment-cut rewrite).
  7. A leading UTF-8 BOM hid the whole first section; now stripped like git does.

MAJORs

Also fixed (minors/nits from round 9)

core.worktree validation skipped for linked worktrees (commondir gitdirs — git resolves their worktree from the back-pointer and ignores the key there, probed); .git pointer files parsed byte-exactly (literal gitdir: + one space; git's fatal shapes refuse); ZDOTDIR pinned to / (empty let zsh fall back to ~/.zshenv); the foreground stdin EOF write moved before the heavy-permit attach; od admits glued -An/-j<N>/-N<N>; the gh wildcard refusal copy and the sed separator comment state their real reasons; VERIFIER_AGENT_INTRO scopes the read-only shell to in-session delegations; newline-in-command and symlinked-cd-fold behaviors pinned; the touched-file collapsible_if collapsed so the upstream --all-targets clippy invocation reports only the pre-existing untouched turn_loop.rs:5499.

New pins: readonly_filter_discovery_pins_dotted_header_filters, readonly_filter_discovery_parses_bom_prefixed_config, readonly_filter_discovery_admits_linked_submodule_worktrees, readonly_git_dir_link_is_parsed_byte_exactly, readonly_gitlink_paths_refuse_unresolvable_path_spellings, readonly_operand_gate_judges_chained_git_c_cumulatively, git_version_probe_ignores_host_path_shims, readonly_operand_gate_folds_through_in_workspace_symlink_destination, agent_readonly_shell_rejects_newline_separators, agent_readonly_shell_admits_glued_od_forms, plus the trailing-comment/two-headers/BOM/last-wins assertions inside the existing discovery tests, and the money E2E now declares a second filter through the dotted spelling (the raw-shell control proves git arms it; the tool leg proves the lane pins it — writing that fixture correctly took two corrections the controls caught: gitattributes matching is later-line-wins so the fixture file is dotted.d, and a config value containing ; must be written quoted or git cuts it at the comment).

Verification

  • cargo fmt --all -- --check clean; clippy clean under the fork-CI invocation; under the upstream --all-targets invocation only the pre-existing untouched turn_loop.rs:5499 remains.
  • Targeted at the new head: command_safety:: 91/0; tools::shell:: 193/0 + 1 ignored; tools::subagent:: 608/0; fleet:: 361/0; tools::registry:: 48/0. Full tui lib: 12347 passed / 2 failed / 13 ignored — both failures the documented rotating remote_control pair, each passing alone; workspace (--exclude codewhale-tui) and doc tests green. Exactly +10 executed tests vs the parent head, all new ones passing.
  • Mutations, 7/7 red with the stated isolation (each applied → its pin reddens → reverted → green): dotted split skipped; same-line re-targeting removed; comment cut disabled; core.worktree first-value restored; readonly_gitlink_paths no-op'd (unit pin + submodule E2E twin redden on the leak itself; the repository-content E2E stays green as designed); -C chain flattened (admission control reddens; the shared discovery fold test reddens with it — one resolver serves both callers by design); version probe made env-inheriting (its shim pin stays green because the pin injects PATH through the test seam — the guarded property is that the SUPPLIED PATH is sanitized — and the unsanitized-PATH variant of the same mutation reddens it).
  • Refusal walls re-verified green at the new head (include/includeIf, worktreeConfig, unspellable names, pin caps, undecodable/oversized pointers).
  • CI: dispatched at the new head (run 41019813515). Note: every Pinvou fork CI dispatch on this branch for the past day has been zombie-queuing with zero jobs (GitHub-side scheduling, 409 on cancel) — if this run also stalls, the repo needs an admin look at Actions health; the local gates above stand in.

@asto18089 asto18089 left a comment •

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(补发说明:本 review 正文因 gh api -f 误用未真正发出——只留下了文件路径字面量,原稿已被 /tmp 清理且无存档副本。该轮从零复审(R10,2026-10-30)的完整处置已按账本 CodeWhale#126 条目收口:30 条意见全部甄别记账,修复项以当时提交落地、其余按证据反驳或非阻断记账;权威结论以本 PR 线程内后续讨论与账本/报告为准。占位补发而非虚构原文。

@asto18089

Copy link
Copy Markdown
Collaborator Author

Round-10 fix wave — all review findings addressed

Landed as one DCO commit (dc889a214, on top of 93a28bde6, base pinvou3-clean unchanged — rebase was a no-op at f1eb10efc, re-verified via API at wave time).

The BLOCKER: discovery children executed repository config

apply_discovery_git_env pinned only system/global configuration off; repo-local .git/config stayed fully live, and on git 2.43 a repo-local core.fsmonitor command executes inside the enumeration's own git ls-files --stage -z (probe-pinned; an empty index suffices) — attacker-chosen command execution under the tool process's full authority (the discovery children are spawned directly via std::process::Command, never SandboxManager.prepare), before anything is admitted; a command later refused at bind time had already run its discovery execs. Every discovery git invocation now carries the fixed argv pins (-c core.fsmonitor=false -c core.hooksPath=<null> -c log.showSignature=false) — argv -c outranks every config file and works on every git, unlike the GIT_CONFIG_COUNT mechanism (which needs ≥ 2.31), so the pins do not wait on the version gate and the gate no longer has to run before the first discovery exec. Pinned at two levels, each with a raw-shell control proving the hostile hook fires under a plain git invocation before the lane leg asserts silence: readonly_filter_discovery_child_does_not_execute_repo_fsmonitor (discovery level) and readonly_shell_lane_git_status_does_not_execute_repo_fsmonitor_during_discovery (tool level).

The MAJORs

  • Tilde -C: the resolver judged the spelling literally while the shell expands ~ against $HOME (a planted literal ~ directory admitted a stage git ran outside the workspace — PR-introduced; base judged every token and refused). readonly_git_stage_root now refuses tilde spellings like the plain-operand and cd rules, and proves each -C destination enterable the way the cd fold does (a mode-000 destination passes canonicalize+is_dir and fails the child's chdir). Pins: readonly_operand_gate_refuses_tilde_git_c_operands, readonly_git_c_operand_refuses_unsearchable_stage_root.
  • FIFO at .git/config (stat size 0 bypasses every size cap; open blocks until a writer appears, inside the pre-spawn discovery where nothing is watchdogged): one bounded reader now serves the config, the commondir pointer, and the .git link — non-blocking open on unix, the regular-file check and size cap on the OPENED handle's own metadata, refusal for anything else. Ordering note verified: the scan reads each repository's config before spawning its enumeration child, so the git-side block on a FIFO config is unreachable. Pin: readonly_filter_discovery_refuses_fifo_config_without_hanging (thread + 5 s hang guard).
  • Merge-order stale: re-derived at fix(subagent): release interrupted claims and inherit worktree isolation #123's new head 71221288d (it force-pushed past 2071e94cd at 07:55Z) — the same three files conflict (copy in subagent/mod.rs, hardening table in shell.rs, tests.rs anchor) with the same mechanical recipes; fix(subagent): release interrupted claims and inherit worktree isolation #123's new inline block confirms the extended no-splice arm recipe verbatim and its copy now advertises git rev-parse/branch/worktree list, making the Ok(false) arm extension even more load-bearing. Also corrected: perf(session): reuse metadata fallback buffer #127 shares subagent/mod.rs + subagent/tests.rs (contrary to the earlier "shares none"), auto-merging — merge-tree verified at the named heads.
  • Structural (parser): scheduled as a committed follow-up in the body — replace parse_git_config_settings with git config --list -z --local enumeration through the hardened discovery environment, keeping the hand parser only as the refusal fallback; dissolves the include/includeIf/worktreeConfig walls into enumerated coverage and ends the dialect chase.

Also fixed (minors/nits from round 10)

.git link strips exactly the one trailing \r git strips at the newline (CRLF links admit like git — probed; every other padded spelling still refuses); commondir is byte-exact with the same strip and must resolve (a padded pointer refuses instead of silently reading a config git never opens; the empty-value refusal matches git's fatal); the lossy-decode docs now state the real wall (replacement-marker names refuse at the unspellable-name wall); gitlink paths with "/\ are ordinary unix filename bytes consumed in-process — scanned and pinned now (Windows keeps the refusal; the doc states symlink-outside gitlinks scan-and-pin); the version-gate refusal copy states what fires it (every read-only git stage, not only filter declarations); the version probe is memoized per sanitized PATH (one fork per process; spawn failures re-probe); a single glob-bearing command takes the shell lane (the direct-argv lane handed the program the literal *, so the judged expansion could never happen); inline stdin payloads past one pipe buffer refuse before spawn (the disclosed >64 KiB executor wedge, closed at the boundary — schema advertises maxLength, the refusal points at background interact streaming); od -j/-N accept GNU multiplicative suffixes with checked arithmetic (-N4k); head/tail admit glued numeric value spellings (-n5, -c3); the parallel classifier's shared-table growth (git hardening flags from round 4, now the glued forms too) is pinned deliberately; each config text parses once for both consumers; the name bound is a named constant; the schema's test: sentence scopes the shell to in-session delegations; the glob-budget wall-clock bounds are loosened (30 s) so a loaded runner cannot redden them; the discovery cd-fold's silent skip carries its cannot-fire-here comment; the mutation-5 body claim now names the enumerate-nothing variant precisely; the left-alone list names the admitted stdin-reading siblings of the cat #f wedge; the 16 MiB index-scan cliff and the per-repo ls-files cost are disclosed in the body.

Verification

  • cargo fmt --all -- --check clean; fork-CI clippy clean under its exact allow list; upstream --all-targets clippy reports only the pre-existing untouched turn_loop.rs:5499.
  • Targeted at the wave head: command_safety:: 94/0 (was 91); tools::shell:: 203/0/1i (was 193); tools::subagent:: 608/0; fleet:: 361/0; tools::registry:: 48/0. Full tui lib: 12360 passed / 2 failed / 13 ignored — the same documented rotating remote_control pair, all 83 passing alone; one earlier run on a loaded machine rotated a wider set (all pass alone, untouched files). Workspace libs 2150/0; doctests green.
  • Mutations, 11/11 red with the stated isolation (each applied → its pin reddens → reverted → green; one unrelated pin per area confirmed green): discovery argv pins dropped (both fsmonitor pins redden on the marker leak — unit and tool); tilde refusal deleted (admitted escape); -C enterability probe dropped; the bounded reader's regular-file check neutered (the FIFO pin reddens on its hang guard — the lane's own git child then blocks on the FIFO config, exactly the hang the wall exists to prevent); commondir restored to trim() with the empty-refusal dropped; the .git link \r strip deleted; the gitlink "/\ refusal made unconditional again (scan pin and updated unit pin both redden); stdin cap disabled (oversized payload executes); glob routing clause dropped (literal spelling fails on the direct lane); glued head/tail branch removed; od suffix parse reverted to plain u64.
  • Merge-order: git merge-tree --write-tree 71221288d dc889a214 — the three conflicts and resolutions re-derived at the wave head; fix(subagent): 固定任务身份与完整输出恢复 #124/fix: honor local model reasoning ladders #128/feat(engine): backport run completion and GNU LLVM bindings #129/perf(session): reuse metadata fallback buffer #127 merge-tree clean claims re-verified at the named heads.
  • CI: dispatched at the wave head (run 41049062151). The branch's Pinvou fork CI dispatches have been zombie-queuing with zero jobs GitHub-side for 24+ hours (round 9 disclosed the same; 409 on cancel) — if this run stalls identically, the repo still needs an admin look at Actions health; the local gates above stand in.

Not taken, deliberately: the PowerShell --% stop-parsing probe (no Windows host — the % refusal stays cmd-only as disclosed) and the sub-subagent-chain dedicated pin (the two-level derivation harness the disclosed follow-up needs is out of scope for this wave; the code path holds and the body keeps the disclosure).

@asto18089

Copy link
Copy Markdown
Collaborator Author

Round-11 fresh review — CHANGES_REQUESTED (2 BLOCKERs, 1 MAJOR)

Fresh 11-lane review (9 static/probe lanes, one test-execution lane, one mutation re-derivation lane) at dc889a214, based on pinvou3-clean f1eb10efc. Rebase status: base tip is unchanged at f1eb10efc and merge-base equals it, so the branch is already based on the latest base — nothing to do. #125 is merged (it is f1eb10efc); #123/#124/#127/#128/#129 sit at the exact OIDs your Merge-order section quotes. Every load-bearing finding below was re-proven first-hand by the reviewing agent, not taken from a lane report on faith.

The good news first, because it matters: the round-9/10 fix waves hold up. 6/6 sampled mutations re-derived RED with exactly their own pins and reverted green (discovery fsmonitor argv pins both call sites, sequence splitter, cd cwd-fold, filter-discovery E2E on the leak itself, version-gate comparator, blame splice row — blame is doubly pinned). All targeted suites green (command_safety 94, tools::shell 207, subagent 608, fleet 361, registry 48, workspace libs 2150, doctests green, fmt clean, fork-CI clippy clean with its exact allow list); full tui suite 12364/2/13 with exactly your documented remote_control flake pair, each passing alone. No smuggling: all 16 files inventoried hunk-by-hunk, every hunk serves the three fixes or their tests, no dead code, docs en↔zh↔code consistent. The config parser survived 33 first-hand grammar probes against git 2.43 — every remaining divergence fails closed. The operand gate held everywhere it was attacked: budgets refuse at the boundary (exactly-4096 matches admitted, 4097 refused), budget genuinely shared across segments/operands, the gate is pre-spawn on every dispatch path, the read-only task surface is unreachable. The -c-precedence and blame-textconv claims both survived live attack; the GH_HOST pin makes gh refuse non-matching remotes, which is stronger than the PR claims.

But round 11 found two new first-hand-proven escape classes, both in code this PR introduced or extends, and one false gate claim.

BLOCKER 1 — multi-line .git gitfile / commondir: hostile filters execute unpinned under an admitted read

readonly_git_dir_for (shell.rs:5090-5093) cuts the gitfile at the first \n, and the comment (5125-5127) asserts "Content after the first line is ignored, exactly like git consuming one line." That is factually wrong about git: git's gitfile parser uses the entire value after gitdir: minus the trailing newline — interior newlines are ordinary path bytes, and a directory whose name contains \n is legal on POSIX.

Proven end-to-end on this host (git 2.43.0), re-proven by the reviewer:

  • Hostile repo at a directory literally named repo\nevil, declaring filter.pwn.clean = a script that touches a marker and cats; worktree .git containing gitdir: /tmp/…/repo\nevil/.git\n plus a second line. Raw git status -s with size-preserving dirt (23-byte replacement for a 23-byte blob — the same fixture subtlety your own E2E discloses) executed the hostile filter (marker created). Arbitrary command execution under the branch's own threat model.
  • The lane's discovery reads only the first line → the decoy target it scans does not exist → no pins, no refusal (lane L5 ran your real discover_readonly_git_filter_pins against exactly this fixture: Ok([])).
  • The same first-line cut is in the commondir reader (shell.rs:5182-5197).
  • The wrong assumption is enshrined in readonly_git_dir_link_is_parsed_byte_exactly (tests.rs:2032-2048), whose justification comment — "a repository behind such a link is one the host git itself refuses to run in" — the probe disproves (with the newline-named gitdir present, git resolves and runs there).

Fix: refuse any gitdir/commondir value that still contains \n/\r after the one trailing-newline/\r strip (git treats those as path bytes no legitimate setup has; every other padded spelling already refuses). Rewrite the pinning test against git's real behavior and delete the false comments. Alternatively, land the scheduled git config --list -z follow-up in this PR — either closes the class. Note round 10 hardened exactly this reader family for FIFOs, CRLF, and padding; the interior-newline shape is the survivor.

BLOCKER 2 — git -C <glob>: judged as a literal spelling, executed as a shell expansion (PR-introduced; the chained shape completes to an outside read)

Three convergent lanes found the admission; the red-team lane completed it, and the reviewer re-proved the runtime leg:

  • Classifier: is_agent_readonly_git (command_safety.rs:1070-1096) strips -C <val> pairs blind — the value is never inspected, so * survives to git.
  • Seam: readonly_git_stage_root (shell.rs:4685-4747) judges each -C operand by literal canonicalize — tilde is refused, wildcards are not — so -C x -C * passes whenever a literal directory named * exists inside x/. The glob walker never sees -C values (sliced off at shell.rs:5753/5878), and the re-quoter keeps * bare on the explicit premise that "the operand gate judged its expansion" — false for -C operands.
  • Runtime (re-proven live): workspace containing x/* (literal dir), x/sh → outside repo (tags named x, zz, show), and root entries show, zz. git -C x -C * show --no-ext-diff --no-textconv HEAD → the shell expands the * to sh show x zz → git chdirs x → x/sh (the unjudged outside symlink) → subcommand show → prints the outside repository's committed patch (OUTSIDE-SECRET-PATCH-CONTENT in the output). The --no-pager variant leaks identically.

The flat git -C * … form is currently stopped only because the pattern's self-match poisons git's subcommand slot (git: '*' is not a git command) — a collation accident, not a gate property, and the chained shape defeats it because the planted literal lives inside x/, outside the expansion directory. Two comments assert the breached invariant as fact and must not survive: shell.rs:5740-5757 ("judged exactly like the flat git A/escape it executes as") and shell.rs:4681-4684. The sibling cd rule refuses exactly this spelling (readonly_cd_operand_admitted); -C must too.

Fix: refuse wildcard characters in -C operands in readonly_git_stage_root (mirroring the cd/gh-stage rules), plus a regression pin for the chained shape specifically, plus the two comment fixes. One line plus test.

MAJOR — upstream CI clippy gate is red at this head, and the body's claim about it is false

cargo clippy --workspace --all-targets --all-features --locked -- -D warnings -A clippy::uninlined_format_args -A clippy::too_many_arguments -A clippy::unnecessary_map_or (the exact ci.yml invocation) exits 101 with two collapsible_if errors: the documented pre-existing core/engine/turn_loop.rs:5499 and command_safety.rs:1397 — glued_numeric_value_option, added by this PR's final commit. The Testing section's claim "reports ONLY the pre-existing collapsible_if at core/engine/turn_loop.rs:5499" is false as written; upstream CI would reject this branch (fork CI passes only because its allow list includes collapsible_if). Collapse the nest (or allow it with the same justification style the codebase uses elsewhere) and refresh the claim. Also stale: the body says tools::shell 203 and full suite 12360; at this head they are 207 and 12364 (the final commit's tests; all other counts reproduce exactly).

MINOR

  1. Version-floor imprecision (narrow window): the env-pin block needs git ≥ 2.31 (GIT_CONFIG_COUNT), but GIT_CONFIG_GLOBAL/GIT_CONFIG_NOSYSTEM arrived in 2.32 (release-notes-derived; no 2.31 host available to execute on). On exactly 2.31.x the gate passes while the operator's global config still loads and discovery scans only repository config — a filter defined in global config and referenced by repo .gitattributes would run unpinned. Operator-controlled rather than repo-controlled, so exposure is thin; consider pinning the floor at 2.32 or documenting the window at the gate.
  2. 64 KiB stdin cap's never-wedges invariant is a Linux pipe fact stated as universal (shell.rs:6680-6686; the schema copy at :7022 states the cap unconditionally): on Windows (system-decided pipe buffers) and macOS (best-effort 64 KiB auto-grow) a payload in (buffer_size, 65536] to a non-reading child can still wedge the executor under the manager lock. Strict improvement over base (no cap at all); qualify the comment/copy. The cap comment is also off by one ("one byte short of never-wedging" vs > allowing exactly 65536).
  3. Verifier background-gate EOF-at-spawn (verifier.rs:582-593) is unpinned — the only covering test runs --list, doesn't read stdin, and passes unchanged on base. Add a pin or fold it into the disclosed-no-pin list explicitly.
  4. Guidance stdin sentence omits tty: true (guidance.rs:88-93): a foreground tty command runs on the PTY lane where nothing delivers EOF, so "without interactive or combined_output … receive EOF" overclaims. Should read "without interactive, tty, or combined_output".
  5. Refusal copy under-reports the admitted set (subagent/mod.rs:15774-15777): glued od spellings (-An, -j8), GNU suffixes (-N4k), clustered text-filter options, glued head/tail numerics, codewhale --version probes; and gh-stage globs are refused outright, not "when expansion leaves the workspace". Fail-usable drift; everything the copy does state matches the code.
  6. Unbounded echo of config-derived values in refusals (shell.rs:5573-5576 filter names, :5613-5622 raw core.worktree value) combined with symlink-following config reads gives a narrow model-visible read-back of outside files whose content happens to look like git config. Truncate the echoed value.
  7. Disclosed cost not stated where it fires: "ls-files runs once per scanned repository even with zero gitlinks" appears in the body but not in the enumeration comment (shell.rs:5035-5040).
  8. The -C/--no-pager preamble walk lives in three copies (splice table inline loop shell.rs:4136-4146, readonly_git_preamble_len :4660-4670, the resolver) under a "same walk" comment — dedupe or cross-reference so they cannot silently diverge.
  9. Stale test name: the_verifier_catalog_offers_the_verification_surface_and_no_shell (subagent/tests.rs:20480) contradicts the new contract (the verifier catalog now offers a bounded shell surface); it passes only because it asserts absence of the legacy spellings.
  10. Merge-order section staleness (doc-only, conclusions unaffected): at the cited heads, perf(session): reuse metadata fallback buffer #127 (f88448b79) touches only session_manager.rs and fix: honor local model reasoning ladders #128 (7fe08d55a, a draft on a fork branch) shares zero files — both merge-tree clean, so "auto-merges" holds, but the shared-file lists are wrong (the section's own staleness correction went stale). And "fix(subagent): release interrupted claims and inherit worktree isolation #123's three test-pinned fragments" is six (Admitted:, git branch (bare/flag-only, git worktree list (flags only), find (no -delete/-exec/-execdir, comm, npm view, Do not retry variations) — this branch's copy satisfies only the last, so the hand-union requirement is real and stronger than stated; both pin suites land in subagent/tests.rs via auto-merge, so picking a side provably reddens the other side's tests. Refresh the section so the second merger works from accurate notes.

Disclosed and accepted as before: the npm registry/userconfig/.npmrc redirect gap (pre-existing, byte-identical at base, follow-up designed in the body), the hard-link residual, the untested sub-subagent chain, the legacy-runtime_profile resume residue.

Verified sound this round (per lane)

  • Fix 1 root-caused and complete: base provably never closed the no-payload foreground pipe and closed the payload case only after a fallible attach; the head closes unconditionally before the attach on both foreground paths, sync lane Stdio::null(), PTY/persistent/background lanes exactly as claimed; stdin-refusal copy truthful in every state transition constructed; all five fix-1 tests would fail on base (reasoned from assertions, spot-ran).
  • Classifier: every divergence constructed (quoting, escaping, comments, permutation, --, case folding, env prefixes, |&, glued forms) fails closed at the classifier or at the seam's strict shlex — the naive-splitter/strict-seam handoff verified by execution.
  • Operand gate: connector-aware fold exact (static + seam re-check), destination walls complete, glob walker shell-faithful under hard shared budgets with refusal-not-truncation at every cap and the 10 s deadline, byte-exact operand judging, verbatim/tilde/=/+ refusals; dispatch completeness — enumerated every foreground/background/sync/PTY entry point, none reaches a read-only command without the full gate; interact/cancel/wait and write_stdin unreachable under ReadOnly.
  • Filter discovery: 33 first-hand grammar probes (dotted headers, quote/continuation interplay, include walls, env precedence) — parser faithful or fails closed everywhere except the BLOCKER-1 readers; submodule enumeration, bounded FIFO-safe reads, fsmonitor argv pins, version gate, and both E2E twins with live positive controls verified.
  • Git hardening: splice table correct per subcommand with the _ => Err tripwire; -c precedence empirically true (and GIT_CONFIG_PARAMETERS="" load-bearing); version probe child-shaped and shim-proof; memoization keyed per sanitized PATH with spawn-failure re-probe.
  • Network/roles: per-stage predicate at all three call sites, whole-command classifier gone; 46-spelling gh probe — every host-steering spelling refused, over-recognition fail-closed only; Verifier carve-out Verifier-only with the round-3 regression still fixed and the capture ordering between clamp and narrowing correct; receipts/copy/schema/docs match code in en+zh.
  • Mutations: 6/6 sampled RED→green with intended-cause verification, no dead pins.
  • Gates: all suites/formats/clippy legs as claimed except the upstream clippy MAJOR above; full-suite failures exactly the documented pair.
  • Hygiene: no smuggling (whole-PR verdict clean); no dead code; docs en↔zh↔code consistent.
  • Merge order: fix(subagent): release interrupted claims and inherit worktree isolation #123's three-file conflict set exactly as claimed, both union claims verified real and test-enforced; base unmoved; GitHub mergeable/CLEAN.

Both blockers are in-scope for this PR — the -C admission is new in fix 2 and the gitfile reader is this PR's own discovery machinery — so they belong here, not in a follow-up. The structural recommendation stands unchanged: the git config --list -z follow-up would have dissolved BLOCKER 1's entire class; if the third grammar escape in the reader family changes the deferral calculus for you, promoting it into this PR remains the alternative to the newline wall.

@asto18089

Copy link
Copy Markdown
Collaborator Author

Round-11 fix wave landed (f65a0d1ac, pushed as a fast-forward on this branch)

All round-11 review findings fixed in one commit, each with a red/green pin; the six new walls are mutation-verified (applied → pin reddens → reverted → green).

BLOCKER 1 — multi-line .git gitfile / commondir (hostile filters executed under an admitted read). The readers now consume the WHOLE link value minus one trailing newline, exactly like git — the byte-exact mirror, so discovery scans (and pins) exactly the repository git runs. The round-11 review's probe fixture is now an end-to-end pin with a raw-shell control: readonly_shell_lane_git_diff_pins_filters_behind_a_multi_line_gitdir_link (control leaks TOPSECRET-NEWLINE-FILTER through the newline-named gitdir; the tool leg leaks nothing and runs nothing), plus readonly_git_commondir_follows_the_multiline_value_git_resolves for the shared-directory twin, and the old first-line pin whose justification comment claimed git refuses such links (readonly_git_dir_link_is_parsed_byte_exactly) is rewritten against git's real behavior. Note the deliberate design choice: since the lane can now mirror git byte-for-byte, the newline shape follows git instead of refusing (same posture as the CRLF admit), which pins the hostile filters inert rather than guessing at shapes git might resolve.

BLOCKER 2 — git -C <glob> judged as a literal while the shell expands it (chained git -C x -C * outside read). The root resolver now refuses any wildcard and any =-prefixed -C value on the spelling itself, before any filesystem probe (tilde was already refused), mirroring the cd and gh-stage rules — one function serving both the operand gate and the discovery fold. Pinned by readonly_operand_gate_refuses_shell_expanded_git_c_operands (chained, --no-pager-chained, flat, and =pwd shapes; literal chains still admit). The two comments that asserted the breached invariant as fact ("judged exactly like the flat git A/escape it executes as") are rewritten at both sites.

MAJOR — upstream CI clippy. The PR-new collapsible_if at command_safety.rs:1397 is collapsed with a let-chain (edition 2024). Verified with the exact ci.yml invocation: upstream clippy now reports ONLY the pre-existing core/engine/turn_loop.rs:5499; fork-CI clippy clean under its allow list; fmt clean.

Minors fixed: version floor 2.31 → 2.32 (GIT_CONFIG_GLOBAL, which keeps a repo .gitattributes from referencing a global-only filter, arrived in 2.32; comparator pin renamed git_env_config_support_needs_two_thirty_two with the 2.31.x window explicitly refused); the 64 KiB cap's never-wedge invariant stated as the Linux fact it is + off-by-one comment fixed + input/data schema aliases advertise maxLength; the guidance stdin sentence names tty alongside interactive/combined_output (pin updated); the [shell.readonly.command] refusal copy names the glued/cluster spellings, the gh-stage glob refusal, and the new -C walls (all six pinned phrases byte-identical); echoed filter names/core.worktree values bounded at 200 bytes; the per-repository git ls-files fail-closed cost stated at its call site; splice_git_read_hardening reuses the one preamble walk; the verifier gate-job stdin EOF gained its pin (run_verifiers_background_gate_jobs_get_stdin_eof_at_spawn — a cat gate completes instead of idling 30 s+ to the gate timeout); the stale verifier-catalog test renamed.

Mutations (6/6 red → reverted → green): first-line cut restored (E2E reddens on the hostile-filter leak); -C wildcard wall neutered (pin reddens at the chained admission); -C = wall neutered (pin reddens on git -C =pwd); floor back to 2.31 (comparator pin reddens on 2.31.9); gate-job close deleted (cat-gate pin reddens after the 30 s wait); commondir cut restored (whole-value pin reddens).

Gates at f65a0d1ac: targeted suites 94 / 206 / 608 / 9 / 361 / 48 (all 0 failed, 1 pre-existing ignored); full tui suite 12363/3/13 with all three failures passing alone (3/3 in 0.09 s) in files this branch does not touch (one documented remote_control member + two new rotation members in remote_control/tui::ui); workspace libs 2150/0; doctests green. The PR body now carries the round-11 review-round paragraph, corrected Testing numbers (including the honest note that the round-10 body's "upstream clippy reports ONLY turn_loop.rs:5499" claim was false at that head), the 2.32 floor rationale, and the merge-order corrections from the round-11 merge-order lane (#127/#128 share zero files at the cited heads; #123's copy union is six pinned fragments, not three — subagent/tests.rs auto-merges and judges whichever copy wins, so picking a side provably reddens the other side's pins).

The scheduled follow-up stands: replacing the hand config parser with git config --list -z enumeration would have dissolved BLOCKER 1's entire class; the mirror fix closes it without that rewrite, and the follow-up remains as scheduled.

@asto18089

Copy link
Copy Markdown
Collaborator Author

Round 12 (fresh 12-lane review at f65a0d1ac, base unmoved)

Summary

Rebased check: pinvou3-clean is unmoved at f1eb10efc (= merge-base with the head), so the branch is current with its base; origin/main is an ancestor of the base. Every gate in the Testing section reproduces exactly at head (all six targeted counts, 12363/3/13 full suite with the failures inside the documented rotating untouched-file set, 2150/0 workspace libs, doctests, fmt, fork-CI clippy clean, upstream-flag clippy reporting exactly the pre-existing turn_loop.rs:5499). Runtime test-list delta base→head is +92 (95 added / 3 renamed), every added test traceable to the branch diff — no smuggled changes, no phantom claims (16-file inventory, every hunk tied to a body claim; 24 sampled body assertions all CONFIRMED). Merge-order section re-verified fresh: all five quoted head OIDs are unchanged; merge-tree vs #123 conflicts in exactly the three claimed files with the analysis holding. No(Blocker) — two new BLOCKERs found and both reproduced end to end; four MAJORs; minors below.

Verdict: CHANGES_REQUESTED. Two new BLOCKERs found, both reproduced end to end first-hand (raw git probes for one leg, tool-trace + raw-shell control for the other); four MAJORs; minors below. The BLOCKERs are one-byte and one-component extensions of shapes rounds 7–11 already fixed, in the same two families (link-strip model, glob .. synthesis) — the waves keep closing the exact shape probed while the adjacent unprobed shape carries the same defect. The most reliable way to stop the series is likely the scheduled git config --list -z follow-up plus a property-style strip/normalize mirror test, not another per-shape pin.

BLOCKER 1 — git strips ALL trailing \r/\n bytes from a .git gitfile / commondir value; the readers strip exactly one of each, so a multi-trailing-newline link sends the scan to the wrong repository while git runs the hostile one

readonly_git_dir_for (shell.rs:5153-5154) and the commondir reader (shell.rs:5264-5265) each strip exactly one \n and one preceding \r; the inner comments (shell.rs:5134-5136, 5249-5250) and the round-11 body paragraph assert "exactly one trailing \n (plus one \r)" as the probe-pinned git model. Git 2.43 strips an unbounded run of trailing \r/\n in any order — proven first-hand this round with a marker-firing discriminator:

  • Hostile repo H armed with filter.pwn.clean → marker MH; decoy gitdir H/.git\n (path with interior newline after .git) armed with filter.decoy.clean → marker MHNL. Worktree .git = gitdir: /abs/H/.git\n\n (two trailing newlines). Raw git -C wt status --short → MH fired, MHNL cold: git resolved H/.git (the full strip), not H/.git\n (the one-strip value). The same holds for \r\r, \n\r, \r\n\r, \n\n\n endings and for commondir (probe-identical).
  • Lane trace at head: the one-strip yields H/.git\n; there is no validity check on a gitfile target (shell.rs:5177-5182 returns it unconditionally), so discovery scans the decoy — or nothing when no decoy exists — pins no filter.pwn, and admits; the child git re-reads the same link, resolves H/.git, and runs the hostile filter under the admitted read. Same class as the round-7/round-11 escapes, one byte past the round-11 fix. (On git < 2.32 the version gate masks this; on 2.32+ it is live.)

Fix: mirror the loop — strip while the value ends in \n or \r — in both readers, and mirror git's validate step (resolve the consumed value and refuse when it is not the repository git would run; that also closes the admit-scan-nothing NIT). Re-pin with the discriminating endings (\n\n, \r\r, \n\r, \r\n\r); the current E2E's value ends in exactly one \n, so no existing pin bites on this shape.

BLOCKER 2 — glob .. synthesis judges the lexical parent; sh/dash resolve the expanded .. physically through a followed symlink

collect_readonly_glob_matches synthesizes the dot-component candidate as base.parent() (shell.rs:6397), the lexical parent of the walked directory. dash existence-checks the expanded word physically: with W/sub a symlink to an outside directory and cat sub/..*/secret_out, dash expands to sub/.., resolves sub/.. through the symlink, and reads the outside file — reproduced first-hand (/bin/sh -c 'cat sub/..*/secret_out' prints the outside secret; modern bash leaves ..* unexpanded, so the scope is exactly the sh/dash family the synthesis exists to cover). The gate's trace: component ..* matches the synthesized name .., candidate = W (lexical parent of W/sub), W/secret_out does not exist → "no match is inert" → admitted; the shell then reads outside.

The dot-synthesis pin (readonly_glob_walker_judges_the_dot_and_dotdot_candidates) only descends a real directory, where lexical and physical parents coincide — that is why the round-3 mutation bites while this shape survives.

Fix: synthesize the candidate as the textual path (base.join("..")) so the judge/canonicalize resolves it exactly as the kernel resolves the shell's expanded word (level-1 behavior unchanged), and add a symlinked-descent pin on the sub/..* shape.

MAJOR 1 — the pre-spawn discovery git child can block forever on a FIFO at .git/index or .git/HEAD; the round-10 bounded-reader wall stops at the files the tool opens, not the files the child opens

run_discovery_git (shell.rs:4902-4956) spawns git ls-files --stage -z with no timeout, reads stdout to EOF in a blocking loop, and only then kills the child — the kill/wait runs after the read loop returns. read_bounded_regular_file's O_NONBLOCK + regular-file walls cover the link/commondir/config reads the tool performs; the child itself opens .git/index and .git/HEAD, and nothing bounds them. Proven first-hand: git ls-files --stage -z blocks (timeout-killed, exit 124) on a FIFO at either path. The body's disclosure covers only "the git-side block on a FIFO config" (true — config is read tool-side before the fork), but round 10's stated defect was "a FIFO at a discovered metadata path blocked open inside the pre-spawn discovery, where no watchdog bounds anything", and index/HEAD are exactly such paths, reachable from repo content at any admitted git read. Fix: stat-check index/HEAD (per scanned repo incl. submodules) through the same regular-file-only bounded path before forking, or give the discovery child a wall-clock kill.

MAJOR 2 — six comments still assert the false pre-round-11 strip model, two of them inside the round-11 fix's own code

The round-11 wave rewrote the test body but the false model survives as fact in: the readonly_git_dir_for fn doc ("first-line value … any content after the first line ignored"; "ends in whitespace or \r is one git rejects" — both disproved, shell.rs:5101-5106); the bounded-read comment ("git … consumes its first line", shell.rs:5120, contradicting shell.rs:5134 eleven lines below); READONLY_GIT_META_SCAN_CAP's doc ("git consumes a single line from each", shell.rs:4311-4312); the rewritten pin's own doc header ("byte-exact first-line value with no trailing whitespace", tests.rs:1993-1997, above a body that pins the opposite); "git fatals on the trailing whitespace" (tests.rs:2007-2009 — it fatals because the padded path is absent; a repo at the padded path runs, MHSPACE probe) and the commondir twin's "byte-exact … padded spelling fatals" (tests.rs:2437-2440 — padded-with-trailing-space commondir resolves and runs when the target exists). A maintainer reading the fn doc "knows" git ignores everything past the first line — the exact premise that produced BLOCKER 1 — and could reintroduce a first-line cut with every test green. Rewrite all six to the probe-true model together with the BLOCKER 1 fix.

MAJOR 3 — the interact lane keeps the exact uncapped blocking-write wedge the 64 KiB cap exists to close, and the cap's refusal copy steers users into it

The run path refuses >64 KiB inline stdin with the rationale "a blocking write while the caller holds the shell-manager lock … would wedge the executor thread and every other shell in the session" and points at "feed it through action=interact in chunks" (shell.rs:7312-7336). execute_interact performs that same write (write_stdin_for_session → BackgroundShell::write_stdin's blocking write_all, under the same sync lock) with no size check (shell.rs:8295-8316). A background child that never reads stdin (e.g. sleep 600): the first ≤64 KiB chunk completes into an empty pipe, so the task looks healthy; the second chunk blocks forever — background tasks have no watchdog — wedging the executor and every other shell operation. Pre-existing at base, but the PR's claim ("the disclosed >64 KiB stall, now closed at the boundary") is only true of the run boundary, and the schema/copy present interact as the safe alternative. Fix: apply the same cap (or a non-blocking/erroring write) to the interact payload, or disclose the residual wedge in "Checked and left alone".

MAJOR 4 — the 200-byte refusal-echo cap is an unpinned behavior (mutation survives)

readonly_refusal_echo (shell.rs:5208, consumed at 5648/5688/5697) is the round-11 wall bounding file-sized filter names and core.worktree values echoed into refusal text ("kept well short of an outside-file read-back channel", per its own doc). This round's mutation lane forced it to return the value unchanged: the entire suite stays green — no test constructs a >200-byte filter name or core.worktree value, and the only test touching its call sites uses short values. The body's round-11 note ("refusal messages bound echoed filter names and core.worktree values at 200 bytes") describes a real but pin-less defense; the same round that added it pinned every other wall it added. Fix: one unit pin driving a >200-byte value through discovery and asserting the echoed text is bounded at the char boundary. (All seven other mutations this round — the six round-11 pins plus a fresh neutering of the glob deadline — reddened exactly their named pins and recovered green on revert.)

MINORs

  1. Refusal-copy misattribution — the [shell.readonly.command] copy fires only when the static classifier refuses, but several of its listed "Refused shapes" are classifier-admitted and refused only at the seam with different messages: recursive **/ mid-pattern, "any glob on a gh stage", -C wildcard/tilde/= (classifier admits all three; the gate/resolver refuses). Every shape is refused at head, and the copy fails safe, but the message's premise ("input did not match the bounded read-only shell grammar") is wrong for these shapes.
  2. Discovery consumes raw roots/cwd while the gate canonicalizes (shell.rs:7456 vs 5704-5713): a non-canonical declared root (e.g. /tmp → /private/tmp) flips every -C-bearing git read to a refusal on such a session (fail-closed), and a divergent symlink inside the canonical workspace spelling can under-scan the physical walk-up repo. The production subagent path canonicalizes the workspace first (subagent/mod.rs:234), so reachability is conditional; one canonicalize at the discovery entry closes both.
  3. Parser family, all fail-closed (12,000-config differential fuzz vs git config --list, zero fail-open shapes): a pre-section key line ending in an odd backslash never sets continuation, so the lane parses a following section header git swallows into the value (pins inert filters / fires include walls where git executes neither); an empty filter name (filter=) is genuinely armed by git and the lane refuses every git evidence command in such a repo instead of pinning it; core.worktree = (empty value) is ignored by git and refused by the lane; the git-fatal family (unknown escapes, unterminated quotes, [filter ."x"]) is parsed straight through in the safe direction. All subsumed by the scheduled git config --list -z follow-up — worth landing that sooner now that the parser has survived three rounds of dialect-chasing.
  4. The absolute-outside-gitdir .git-file leg has no pin of its own (raw proof exists: such a checkout runs the outside repo's filter under admitted reads; the lane handles it by design but no test exercises an absolute outside target — a regression that stopped scanning the linked gitdir would not redden anything).
  5. Per-admission parsing cost: the command text is independently split/lexed ~9-11 times across six sites, readonly_git_stage_root resolves each -C twice (gate + discovery), and the sanitized PATH is recomputed at four unmemoized sites (the version probe got a memo; this did not). All in-memory/stat-only and bounded — a unification candidate for the follow-up, not a defect.
  6. "Two discovery forks per admitted git command warm" (body): warm pays one (the version probe is memoized); conservative direction, correct the number when updating the body.
  7. The parallel auto-approve classifier also SHRANK at head, undisclosed as such: the shared gh-host heuristic (github_command_targets_unsupported_host) gained the ..-prefix check, the three-component/single-label rule, and dotted two-component operands, so base-admitted parent auto-approvals like gh repo view myhost/o/r and gh pr list -R ../secret now refuse there too. Fail-closed and consistent with the agent grammar, but the body's parallel-classifier paragraph discloses only growth ("source-identical… its admitted set did grow by gh -q/--jq"); add the shrink (one sentence + the two commands pinned in parallel_classifier_stays_unchanged_for_parent_auto_approve, which currently pins only refusals that also refused at base).

NITs

  1. "Could not resolve the Scout workspace before shell dispatch" (shell.rs:5706) — pre-existing role-named internal message carried through the de-Scouting pass (base had it; refusals were rewritten, this execution_failed message was not).
  2. The read-only tool description says the pipefail prefix lands on "bash, zsh, ksh" while shell_binary_speaks_pipefail also admits ksh93/mksh (shell.rs:7064 vs 3975-3988).
  3. The EOF-ordering comment ("ordered first so no intermediate failure can leave the pipe open") overlooks the task_id.ok_or_else(...)? between spawn and close — de-facto infallible (spawn_background_sandboxed always returns Some on Ok), comment precision only; the round-9 ordering (EOF before heavy-permit attach) has no dedicated pin for the same reason.
  4. The ..-synthesis test doc's "judged by resolved location" (tests.rs:3433) is false for the BLOCKER 2 shape until the fix lands (fold into that fix).
  5. od -j/-N suffix set is wider than GNU od's (1e admitted where GNU errors, 0x8 refused where GNU runs) — both directions benign, no boundary impact.
  6. The token-level gh host scan over-refuses slash/dot-bearing literal values (gh pr list --search a/b/c) — deliberate over-refusal, noted only because it also feeds the parallel classifier (MINOR 7).

Verified sound this round (checked hard, first-hand where marked)

  • Both r11 fixes' surviving cores: the interior-newline whole-value consumption (probe-proven: a newline-named gitdir's filter fires raw and the E2E pins it), the chained git -C x -C * wall (raw leak reproduced, tool refuses, pin's fixture matches the proof shape), the tilde/= walls and the *-only-wall soundness (?/[/braces are charset-refused upstream of every -C value).
  • Splice/no-splice split complete: the classifier admits exactly the 7 git subcommands the table covers; global-flag wall (--git-dir=, -c, --bare, …) refuses before the resolver; positive --textconv/--ext-diff spellings refuse so last-wins override is impossible; no-splice trio covered by the unconditional env pins + discovery argv pins (fsmonitor/hook firing silenced at all three legs, raw-proven).
  • All four filter E2Es pass with biting raw-shell controls; 15/15 family pins; tools::shell:: 206/0/1 at head.
  • Classifier/config-parser grammar: 33-probe grammar family survived a fresh 12,000-config differential fuzz with zero fail-open shapes; continuation parity, header forms, last-wins core.worktree, include walls all re-derived against git 2.43 probes.
  • Fix 1 mechanics and pins all bite (foreground EOF, sync-lane null, PTY untouched, interact reasons, detached-foreground copy); the 64 KiB run-boundary cap is boundary-exact (65536 admitted) with the Linux-fact comment honest; schema advertises maxLength on all three spellings.
  • Fix 3 authority matrix derived cell-by-cell: no read-only-clamped non-Verifier child holds process authority in any (parent state × role × nesting path) combination; the Verifier keeps exactly the promised bounded Run; fleet receipts emit read_only at every site; the legacy-record reconstruction disclosure is accurate.
  • Gates: every claimed count reproduced exactly (six targeted suites, full suite with in-set flake rotation, workspace libs, doctests, fmt, both clippy flavors — upstream-flag clippy reports exactly the pre-existing turn_loop.rs:5499, completeness proven with --cap-lints warn enumeration); runtime-list delta +92 all traceable to the diff.
  • Mutation re-derivation (8 at head, isolated red→revert→green, binary-mtime-checked): the six round-11 pins all bite (first-line cut reddens the multi-line E2E on the leak itself — git diff returned content converted through the newline-named hostile repository's filter; commondir cut, -C wildcard wall, -C = wall, 2.32 floor, verifier gate-job EOF each redden exactly their pin); the fresh glob-deadline neutering reddens its pin. The one survivor is MAJOR 4.
  • Merge order: all five cross-PR heads unchanged; merge-tree re-run — fix(subagent): release interrupted claims and inherit worktree isolation #123 conflicts in exactly the three claimed files with the union analysis holding (including the load-bearing rev-parse/branch/worktree admission note); fix(subagent): 固定任务身份与完整输出恢复 #124/perf(session): reuse metadata fallback buffer #127/fix: honor local model reasoning ladders #128/feat(engine): backport run completion and GNU LLVM bindings #129 merge cleanly with the claimed shared-file sets.
  • No smuggled changes; no phantom claims (16 files fully classified; 24 sampled body assertions CONFIRMED, including constants, renames, deletions, and is_parallel_readonly_command byte-identity outside the disclosed shared-table growth).

Body updates needed with the fixes

  • Round-11 paragraph: the "exactly one trailing \n (plus one \r)" model is false (BLOCKER 1) — add a round-12 paragraph with the all-strip model, the two BLOCKERs, MAJOR 1-3, and the new mutations.
  • "Disclosed, unchanged": the interact-lane residual if MAJOR 3 is disclosed rather than fixed; the warm-fork count (one, not two).
  • Testing section: refresh counts at the fix head.

@asto18089
asto18089 force-pushed the fix/live-test-findings-subagent-shell branch from 5178856 to db75f65 Compare November 2, 2026 02:55
@asto18089

Copy link
Copy Markdown
Collaborator Author

Round-12 fix wave landed at db75f6526 (single commit, force-with-lease push replacing the round-11 head).

What was fixed

BLOCKER 1 — the strip model behind the round-11 mirror was one byte short. git consumes a .git gitfile/commondir value with an UNBOUNDED run of trailing \r/\n bytes stripped in any order (probe-pinned with a marker-firing decoy discriminator: gitdir: <hostile>\n\n resolves the fully stripped target and runs that repository's filter while the armed decoy one byte longer stays cold), while the readers stripped exactly one \n plus one \r — a second trailing newline sent discovery to a decoy-or-nothing scan while the hostile filter ran. Both readers now strip the run, keep the padded-space refusals with honest comments (git runs a repository found at a padded path — probe-pinned — the refusal is the fail-closed side), and validate the consumed value (git fatals on a non-repository; the lane refuses the same instead of admitting a scan of nothing). Six comments asserting the false one-strip/first-line model — two inside the round-11 fix itself — are rewritten.

BLOCKER 2 — glob .. synthesis judged the lexical parent. The kernel resolves the shell's expanded .. physically through a followed directory symlink: cat sub/..*/secret_out with sub → outside read the outside file on dash (probe-pinned; modern bash leaves ..* unexpanded, so the scope is exactly the sh/dash family the synthesis covers) while the gate judged a nonexistent lexical path and admitted. The candidate is now the textual sub/.. under the walked directory, canonicalizing exactly where the kernel resolves it. The round-3 dot-synthesis pin stays green under the reverted mutation — its fixtures sit where lexical and physical coincide, which is why the shape survived round 3.

MAJORs. (1) The pre-spawn discovery git child is bounded: it opens .git/index/HEAD itself and a FIFO at either blocked its open forever (probe-pinned, exit 124) where no watchdog bounds anything; a watchdog thread now kills the child at 10 s and the command refuses. (2) The interact stdin write is bounded: the same blocking write the 64 KiB spawn cap exists to close ran unbounded on interact (the cap's own refusal copy steered into it); the write now runs on a worker thread with a 5 s deadline, abandons the payload to the background writer (it still lands if the child resumes), marks the task's stdin stalled, and refuses further writes; interact payloads carry the 64 KiB per-call cap; Windows keeps the blocking write under the cap (no handle clone there — disclosed). (3) The round-11 refusal-echo bound was unpinned (a neutering mutation left the suite green) — pinned now with position-marked file-sized core.worktree and filter-name values. (4) Discovery consumed raw roots/cwd while the gate canonicalized (an aliased declared root flipped every -C-bearing git read to a refusal; a divergent cwd symlink could under-scan) — canonicalized once before both consumers, pinned through an aliased-workspace tool leg.

Minors and nits. The config parser carries the continuation state across a pre-section key's backslash (a following header is swallowed the way git swallows it — probe-pinned — instead of arming a driver git never runs); the parallel classifier's SHRINK with the shared gh host heuristic is disclosed and pinned (gh repo view myhost/o/r, gh pr list -R ../secret base-parallel-admitted, now refused); the absolute-outside-gitdir link leg gained its own scan pin; the [shell.readonly.command] copy attributes seam-refused shapes to the execution seam; the internal dispatch message no longer names a Scout shell; the pipefail list covers the ksh family; the EOF-ordering comment states the real close path; the warm discovery-fork count in the body was one, not two.

Verification

  • cargo fmt --all -- --check clean; upstream-flag clippy at db75f6526 reports only the pre-existing turn_loop.rs:5499; fork-CI-flag clippy clean; CI dispatched (run 41380562879).
  • Targeted: command_safety:: 94/0; tools::shell:: 215/0/1 ignored (+9 pins); tools::subagent:: 608/0; tools::verifier:: 9/0 + fleet:: 361/0 + tools::registry:: 48/0.
  • Full codewhale-tui --lib: 12372/3/13 — the documented remote_control rotating set, each passing alone; workspace libs 2150/0; doctests green.
  • Mutations, 10/10 red → revert → green on the committed wave: gitfile loop-strip → one strip (unit decoys + the two-newline E2E leg refuse where success is demanded); commondir loop-strip → one strip (decoy filter name); .. synthesis → base.parent() (symlinked-dotdot pin reds; round-3 dot pin stays green as predicted); watchdog neutered (FIFO-index pin reds on its 30 s hang guard); stdin deadline removed (bounded-write pin reds on its hang guard); stall flag never set (same pin reds at the second-write bound, 10.03 s ≈ two deadlines); echo cap removed (full 400-byte value echoed); validity check removed (dangling link returns Ok); continuation order reverted (swallowed-header pin reds, ten family pins stay green); canonical-roots sharing reverted (aliased-root tool leg refuses where it demands success).
  • Merge order re-verified at db75f6526: fix(subagent): release interrupted claims and inherit worktree isolation #123 still conflicts in exactly the same three files (same resolution instructions hold), fix(subagent): 固定任务身份与完整输出恢复 #124/perf(session): reuse metadata fallback buffer #127/fix: honor local model reasoning ladders #128/feat(engine): backport run completion and GNU LLVM bindings #129 still merge clean.

The round-12 review's structural recommendation stands: the scheduled git config --list -z enumeration follow-up remains the way to stop re-deriving git's config grammar one escape at a time.

@asto18089

Copy link
Copy Markdown
Collaborator Author

Round 13 — fresh 13-lane review at db75f6526

Verdict: CHANGES_REQUESTED — one new BLOCKER (reproduced end to end three independent times), two MAJORs, 21 minors, ~15 nits. Everything else this round attacked came up sound; the round-12 wave's own claims mostly verified exactly.

Scope: fresh from-scratch re-review at head db75f6526, base pinvou3-clean @ f1eb10efc (unmoved; merge-base = base tip; origin/main is an ancestor of the base, 3434 commits behind, 0 ahead — so the required rebase is genuinely a no-op; nothing to conflict-resolve). 13 lanes: two independent boundary red-team lanes with code-executing probes, discovery-subprocess, glob walker, stdin/interact, config-parser differential fuzz (104 shapes vs git 2.43), claims-vs-implementation, smuggling audit, pin-quality + fresh mutations (9 run), performance/economy, copy/docs accuracy, merge-order + full gates. The fork-CI dispatch (run 41380562879) is still zombie-queued with zero jobs — the body's line-112 disclosure already covers this systemic condition; local gates stand in.


BLOCKER 1 — an ordinary .git directory stops the lane's walk-up where git keeps walking: the ancestor repository's content filter runs under an admitted read

readonly_git_dir_for (shell.rs:5234-5237) returns on ANY .git directory with no validity check — round 12 added the validity wall only to the gitfile branch (:5312-5320). But git 2.43's setup_git_directory_gently_1 treats a .git directory that fails is_git_directory as explicitly NON-fatal and continues walking up (the source's own "NEEDSWORK" comment says so). So one empty directory named .git anywhere on the walk-up path between cwd and a hostile repository — creatable by anyone with workspace write access, no git knowledge needed — blinds the round-7 filter discovery: the lane scans the bogus dir's absent config (zero pins) and admits, while the child git resolves the ancestor and runs its filter.<name>.clean.

Reproduced end to end, three independent reproductions (two reviewer lanes + this reviewer's own runs):

  • Raw control (this reviewer, outer = repo with filter.evil.clean = sh -c 'touch <outside-marker>; cat', .gitattributes *.txt filter=evil, dirty seed.txt, outer/sub/.git = empty dir): git -C outer/sub diff exits 0 and creates the outside marker.
  • Tool E2E (reviewer lane, run again by this reviewer on head): under ShellPolicy::ReadOnly, git -C outer/sub diff returns success=true marker=true content=TOPSECRET-… — attacker-chosen command executed under the admitted read and outside content crossed into model-visible output. cd sub; git diff same.
  • Cross-boundary variant: bogus .git dir at the workspace root + hostile repository ABOVE the workspace root — plain git diff from the workspace root leaks the same way (tool-proven).
  • Variant enumeration (unit-level): empty dir, HEAD-only dir, symlink-to-empty-dir all returned by the lane as "the repository" while the gitfile branch refuses its dangling target — the asymmetry is exactly at :5235. (Two members of the family are fail-safe on this host: mode-000 .git → the lane's config read hits EACCES and refuses; bogus .git at a registered submodule gitlink → git's own child fatals, no conversion. The reachable shapes above are not.)

Every wave since round 7 sits downstream of this lying walk-up (-C chains, the cd fold, multi-line links, loop-strip) — none cover it. The walk-up's own doc (:5227-5230, "The resolved target must be a git directory — git fatals there too") is probe-false for the directory branch and documents the invariant the code breaks.

Fix direction (pick one, then pin): (a) mirror git — on a .git directory that fails the repository check, continue the walk-up exactly as setup_git_directory_gently_1 does, so discovery scans the same repository git resolves (this also subsumes MAJOR 1's both directions); or (b) refuse on an invalid .git directory (fail-closed; costs availability for degenerate trees); or (c) dissolve the whole hand mirror with one hardened-git rev-parse --absolute-git-dir probe per stage root under the discovery env you already spawn (same exec exposure you already pay, one extra fork per scanned repository) — the mirror has now leaked one shape per round (11, 12, 13) and git's own walk-up is the only implementation that can't drift. Pin with the bogus-dir family: empty, HEAD-only, symlinked, cross-boundary-above-workspace, plus a differential harness asserting readonly_git_dir_for(cwd) == the gitdir a hardened git rev-parse --show-toplevel resolves.

MAJOR 1 — the round-12 gitdir validity check is not git's predicate: it admits gitdirs git fatals on and refuses gitdirs git runs — the exact claim the check was added for is defeated in both directions

shell.rs:5312-5313 checks HEAD exists + (objects dir or commondir). Probes (git 2.43, first-hand):

  • Admits where git fatals: gitdir with garbage HEAD text → git fatal: not a git repository, lane Ok (admits); refs/ removed → git fatals, lane admits. The commit's claim — "the lane refuses the same instead of admitting a read whose scan finds nothing and whose child dies at git's fatal" — is false for one-component mutations of a valid gitdir. Fail-safe (the child dies at setup, no filter runs), hence not a BLOCKER — but the check is an approximation of is_git_directory (which validates HEAD's format + common/objects + refs), not the predicate.
  • Refuses where git runs: gitdir whose HEAD is a dangling symlink (refs/heads/nope) — git's validate_headref accepts a refs/ symlink without target existence, probe exit 0 with the filter firing — while the lane's Path::exists() refuses, under the message "(git itself fatals there)" (:5316), which is false for this shape. Same false message for interior-NUL link values (git truncates at the NUL and RUNS the prefix repository; the lane refuses). Objects-as-executable-file: git's access(X_OK) accepts and the filter fires before git dies on the object DB; lane refuses.

Both directions share B-1's root cause (hand-mirroring git's setup semantics). Fold the fix into B-1(a)/(c); if you keep the hand mirror, correct the comment and the message copy to the probe-true model, and pin the two refuse-side shapes.

MAJOR 2 — the body's scheduled follow-up spelling is include-blind; executed as written it silently re-opens round 7

The Follow-up section says git config --list -z --local "dissolves the include/includeIf/worktreeConfig refusal walls into enumerated coverage". Probed: a repo whose .git/config carries [include] path = inc.cfg declaring filter.incf.* — git config --list --local lists ONLY include.path=inc.cfg, never filter.incf.* (unscoped --list lists both; git add fires the include-declared filter). --local likewise never reads config.worktree. So the plan of record, executed as written, converts today's fail-closed walls into silent under-scans — the round-7 BLOCKER class with a green-looking scan. The correct spelling is the UNscoped --list -z under the hardened discovery env (argv pins included), with origin filtering to drop the env pins' own -c entries and global/system records (the body's "probe -c-entry visibility" note shows partial awareness; the include blindness is not acknowledged). Also "keeping the hand parser only as the refusal fallback when git cannot be invoked" is incoherent — when git cannot be invoked every git evidence command refuses anyway; delete the parser rather than keeping a dead fallback. Please correct the body (and the eventual follow-up commit) before this text guides anyone.


Minors (21)

Glob walker (boundary-load-bearing, unpinned — proven by fresh mutations that admit live escapes while the whole committed suite stays green):

  1. m-glob-1 — the re-entry dedup key's remaining-pattern-length component (shell.rs:6479) is containment-critical: mutating it to (dev, ino) admits cat s/*/z* (workspace self-link + outside leaf; bash AND dash expand it and read outside); zero committed pins redden. Add the shape as a pin.
  2. m-glob-2 — the . half of the dot-synthesis (:6551-6554) is load-bearing: dropping it admits cat .*/..*/o.txt (file one level above the workspace) on dash; the existing dot-synthesis pin stays green (its fixtures are reachable via read_dir hidden entries). Add a .*-first pin.
  3. m-glob-3 — the walk's judged-vs-run TOCTOU (a workspace writer swapping a judged-inside symlink before the child opens it) is undisclosed where the identical residual is explicitly disclosed for the cd fold (:6080-6082); one sentence in the walker doc brings parity.
  4. n-glob-4 — trailing-slash glob over-refusal (cat x*/ refuses on outside-symlink FILES the shells wouldn't read) is a safe direction carrying no comment, unlike its dangling-match sibling.

Discovery:
5. m-disc-1 — the watchdog pin bounds "terminates ≤ 30 s", not "kills at the 10 s bound": poll 25 ms → 8 s lands the kill at ~16 s and every pin stays green (proven). Pin the elapsed window (e.g. ∈ [9, 15] s).
6. m-disc-2 — read_bounded_regular_file (:4940-4965) checks the cap on the fstat then take(cap+1)s: a file ≤ cap at fstat that grows past cap before the read parses as a COMPLETE config — the one member of the pre-spawn race family whose failure direction is admission, not refusal. Add a post-read length check (you already read cap+1, so over-cap is detectable) and refuse.
7. m-disc-3 — the 25 ms poll quantum is paid by every admitted git command: measured discovery git status ≈ 26.9 ms/iter vs 1.3 ms raw fork — the join after reap waits out a full quantum. Shorten the poll or notify the watchdog on reap; also worth a one-line comment that a D-state child (kill-proof) is bounded only by the blocking-pool, not the 10 s.

Echo discipline:
8. m-echo-1 — validated_git_metadata_path (:5126-5139) interpolates the gitlink path {path:?} UNBOUNDED in both refusal arms, fed from the 16 MiB index listing — a crafted mode-160000 entry with a ~100 KB / invalid-UTF-8 path echoes ~6× its bytes into model-visible refusal text. Round 11 bounded exactly this class at 200 B elsewhere; route both arms through readonly_refusal_echo and pin with a size-discriminating fixture (probed: git update-index --index-info accepts 100 KB and \xff paths; ls-files --stage -z emits them verbatim).

Copy/comments (model-facing sentences contradicting behavior):
9. m-copy-1 — [shell.readonly.command] (subagent/mod.rs:15774) lists cd "joined by &&/||" as refused; the classifier admits a FIRST-segment cd under both connectors (its own test asserts cd sub && wc -c f admitted) and the docs say so. Add "as a later segment".
10. m-copy-2 — the tool description's routing sentence (shell.rs:7231) says single commands run as argv, but round 10 made every *-bearing single take the shell lane; three routing-copy rewrites missed the fourth case.
11. m-copy-3 — shell.rs:7818 still tells the reader to "run this Scout" — a Verifier now holds this lane; the round-12 de-roling fixed the dispatch message but not this one.
12. m-copy-4 — the guidance stdin sentence (guidance.rs:88-95) describes a tty foreground command that cannot exist (:7544 forces tty commands background) and overclaims "no one can write to" the lane's stdin (interact can, per the tool's own copy).
13. m-copy-5 — the Windows bounded-write disclosure's justification ("Windows cannot clone the child stdin handle") is false: ChildStdin::try_clone() is stable cross-platform (since 1.9). The residual (Windows write stays blocking under the per-call cap) is real and disclosed, but it is a choice, not a platform necessity — either implement the same worker-thread shape there or re-word the comment honestly.
14. m-copy-6 — body: "&&/|| parse on every supported Windows shell" is contradicted by the predicate's own doc (shell.rs:3975-3979: PS 5.1 parse-error is a safe nothing-ran).

Body numbers/merge-order (all re-verified):
15. m-num-1 — tools::shell:: at this head is 216 / 0 / 1 from clean private targets (three independent counts), not 215; the wave adds 10 pins, not 9 (the canonical-roots pin is missing from the commit message's list). Every other module count matches exactly.
16. m-num-2 — #123 moved again (now 71cc26218f); the conflict analysis re-verified at the new head: same THREE files (shell.rs, shell/tests.rs, subagent/mod.rs), same load-bearing union — merge-tree run by this reviewer. #124 shares FIVE files at 97a266091 (body says four), all auto-merging; #127/#128/#129 clean as claimed.
17. m-num-3 — the documented full-suite flake set is incomplete: this round's runs surfaced model_inventory::tests::ollama_default_prefers_live_local_tags_over_the_unresolved_marker (passes alone; untouched file), not in any enumerated rotating set. 12373/3/13 → 12375/1/13 across two clean runs, total stable.
18. m-num-4 — the gh --jq half of the disclosed parallel-classifier growth predates the PR (base's shared gh table already listed --jq; the growth is -q only), so the round-6 pin half-pins pre-existing behavior as growth.

Other:
19. m-worktree-1 — validated_core_worktree (:5815-5818) ignores the subsection: inert [core "sub"] worktree = … (git lists it as core.sub.worktree, git status runs fine) refuses every git evidence command. One subsection.is_none() aligns it.
20. m-pin-1 — the sub-subagent chain (carve-out Verifier parent → Builder/test child) still has no pin (body-disclosed follow-up); it is the exact code the round-3 re-key bug lived in.
21. m-spawn-1 — a background spawn with stdin payload can EPIPE-fail against a fast-exiting child (honest error, pre-existing at base, µs window) — on the record because the bounded-write wave touched this call; no pin covers the failure framing.

Nits (compact)

Stale test name …refuses_padded_commondir_like_git_fatals (round-10 name asserts the model round 12 rewrote); the caps pin's fan leg is pre-refused by the 32-component cap so it no longer exercises the node budget (confirmed: survives the m-glob-1 mutation in 0.21 s); echo pin accepts a [200, 300) band rather than the constant (the security property is still pinned); the round-12 refusal-copy re-attribution pin's fragments survive the OLD copy too, so it cannot guard its own fix in a #123 union; new timing pins have 3× headroom (defensible; note the loaded-runner standard applied elsewhere); shell.rs is 8994 lines with two cohesive subsystems inline (operand gate + walker; git discovery + parser + bounded reader) where the existing mod guidance; pattern offers a seam — and the scheduled parser replacement would land inside one scoped module then; the "twenty-eight gaps" arithmetic only works by collapsing rounds 8-10 into round 7's item; the env-gated sync-lane helper test passes vacuously without its env var and its doc doesn't say so; the new Windows-only gitlink pin has never executed anywhere (disclosed); parser over-parses 11 git-fatal shapes (benign over-pin; one doc line would say so); valueless last core.worktree skips validation (caught one layer down by the enumeration child's fatal); D-state-child note; version probe has no timeout (safe: strace shows --version opens nothing repo-relevant); zombie on the poisoned-lock path (unreachable); EOF-ordering "cannot error" is slightly overstated (record-eviction case); cd refusal-copy enumerations lag the shared shape rule (+, - flags unlisted).

What held (the short list)

Strip model faithful end to end (unbounded \r/\n any order — matrix-probed against raw git with marker-firing decoys; padded space/tab refusals probe-true; prefix/BOM/interior-newline semantics match; commondir twin byte-exact). Textual .. == kernel resolution (dash probes at first/mid/post-symlink positions, root case terminates via dedup, hidden rules match both shells, ** trailing = one level confirmed). Budget/deadline/caps refuse rather than truncate; charset walls fire pre-canonicalize; routing sends every *-bearing command to the shell lane. Watchdog: kill at the bound measured 10.05-10.06 s on FIFO index AND HEAD-only paths (child-opened files covered), reaping clean on every path, stdout-before-Arc means no partial scan, timed_out ordering load-bearing (mutation-proven). Discovery exec/network surface: strace shows exactly one execve and zero sockets under the argv pins even with hostile protocol.*/remote config; include/includeIf/worktreeConfig/unspellable/>8 walls all fire with raw-git controls proving them load-bearing; version probe is shim-proof (child-PATH resolution verified). Parser: 104-shape differential vs git 2.43 — zero filter-misses, case folding exact, core.worktree semantics exact (no ~ expansion, no unquote, linked-worktree skip matches real git worktree add). Interact: EPIPE honest (no false stall), two-writer protection real, abandoned worker reclaims on kill, caps off-by-one clean both directions, policy wall pre-dispatch with no existence oracle; the PTY-wedge hypothesis was REFUTED by measurement (tty layer absorbs the capped payload without a reader). EOF contract complete across every spawn path (foreground/background/sync/PTY/persist/gate-jobs/fleet/discovery). Fix-2 judged-vs-run verified over five compound families on bash AND dash through the real tool with discriminating filesystems and raw controls (incl. size-preserving dirt — the round-8 vacuous-fixture trap avoided). Fix-3: delegation invariant structurally sound (clamp → capture → narrow), all named pins green, five copy surfaces consistent. Smuggling: ZERO unmapped hunks over 16 files/+8720/−453; is_parallel_readonly_command byte-identical to base with exactly the disclosed admitted-set deltas; no debug leftovers; no test-only production API. Gates: fmt clean; fork-CI clippy clean under its exact flags; upstream clippy exactly the pre-existing turn_loop.rs:5499; targeted 94/216/608/9/361/48; full suite 12373-12375 passed with the rotating-set failures all passing alone in untouched files; workspace libs 2150/0; doctests green.

Mutation scoreboard (fresh, 9 run in dedicated per-lane worktrees/targets)

2 live mutation-only escapes with zero committed pins reddening (m-glob-1, m-glob-2); 5 pins proved they bite (bounded-write deadline-masking, watchdog message routing, echo truncation under a subtler wrong impl, first-dot header split, -C enterability); 1 refutation of this round's own reviewer claim (the round-12 validity check IS pinned — its strip-test dangling leg reddens when the check is neutered; the check's defect is the approximation itself, MAJOR 1); 1 identity finding (extra-root canonicalize already ships best-effort-drop, and the fail-closed direction is untested in both ways). The author's round-12 mutation claims spot-checked where lanes re-derived them: confirmed.

Merge order

Re-verified at current sibling heads: #123 71cc26218f — same 3-file conflict, same load-bearing union (this branch's five pinned copy fragments vs #123's six, picking a side reddens the other); #124 97a266091 5 shared files all auto-merge; #127 f88448b79, #129 f83e8cd84 clean; #128 7fe08d55a clean, draft on a fork branch.

Structural recommendation

The link mirror has now produced one escape per approximation across rounds 11 → 12 → 13 (first-line cut → one-strip → walk-up stop + predicate approximation), and the config parser produced 13 across 7 → 8 → 9 → 12 before being scheduled for deletion. The pattern is consistent: every hand-mirrored corner of git's setup/config semantics drifts. B-1's fix (c) — resolve each stage root with one hardened git rev-parse --absolute-git-dir child and delete readonly_git_dir_for — removes the last mirror at a cost you already pay per scanned repository; combined with the corrected unscoped git config --list -z enumeration, the lane would consult git for both questions it currently hand-approximates, and this entire finding family (B-1, MAJOR 1, MAJOR 2, m-worktree-1, the false-comment class) dissolves at the root instead of being pinned shape by shape.

asto18089 added a commit that referenced this pull request Nov 4, 2026
Second slice of the #126 split: the bounded read-only probe lane.

- Sequences (`a; b`, `a && b`, `a || b`) admit iff every segment
  individually passes the read-only gate, via one shared splitter;
  `cd <one relative path>` is admitted inside a sequence with its
  operand shape shared by classifier and execution seam, and routed
  to the shell lane (it is a builtin); read-only `od` dumps and
  clustered short text-filter options join the grammar.
- Every admitted operand is judged at the execution seam: the cwd
  folds left-to-right through admitted `cd`s and connector
  conditionals, glob operands are expanded and judged match by
  match under shared caps and a wall-clock budget (dot/dot-dot
  synthesis, `**` refusal, absolute-spelling walks included), and
  `=cmd`, tilde, verbatim and post-dash wildcard spellings refuse.
- Admitted git reads are hardened: repository `filter.<name>` /
  textconv conversions are pinned to inert helpers via argv config,
  discovery enumerates registered submodules and linked worktrees
  byte-exactly like git (multiline/NUL/CRLF/BOM values, padded
  links, quoted gitlink spellings), under bounded scans, a watchdog,
  and a git-version floor for env-config pins.
  `core.worktree` values carrying quotes or backslashes refuse:
  git escape-decodes quoted values, so the raw spelling validated
  here is not the string git applies (round-19 review).
- gh reads judge every stage (compound and `*`-bearing stages obey
  the host network policy and the worker envelope); the pipefail
  prefix keys on the shell kind, and cmd.exe/PowerShell delimiter
  walls are stated in refusals.
- The `[shell.readonly.command]` refusal enumerates admitted
  families, the every-segment rule, and refused shapes; the worker
  catalog's read-only gh pre-filter uses the same per-stage
  predicate.

Tests: 114 new pins across classifier, operand gate, glob walker,
git discovery/parser differential (incl. the escape-decode
worktree wall, mutation-verified 2/2 red), network guards, and
lane routing; every listed escape was reproduced end-to-end
against the unfixed code before fixing (18 review rounds).
Targeted: `tools::shell::` + `command_safety::` +
`tools::subagent::` + `tools::registry::` + `tests::verifier::`
1010 passed / 0 failed / 1 ignored (pre-existing); fmt clean;
fork-CI clippy flags clean.

Split of #126, rebased onto fix/shell-stdin-eof
@6337b0e0a (the stdin-review round); full pre-split content
preserved on archive/p126-pre-split-8705ccd85.
Signed-off-by: asto18089 <asto18089@users.noreply.github.com>
@asto18089
asto18089 force-pushed the fix/live-test-findings-subagent-shell branch from e1e2458 to 456babf Compare November 4, 2026 13:56

@asto18089 asto18089 left a comment •

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Round 19 — from-zero re-review of the split slice (8 lanes), rebase + 1×P1 fixed

Re-review conducted from scratch after the split, per the four review goals (usefulness/completeness, elegance, no smuggling, bug-freedom). Rebase: the parent #139 moved to 6337b0e0a mid-review; this slice is rebased onto it at head 3406aeb30 (tree d83ee5291a8d — built and verified locally, landed via the Git Data API during a git-443 outage, and finally force-pushed as plain git once the wall lifted). The conflict resolutions are byte-identical to the independent concurrent rebase (e1e2458dd) — the two rebases disagreed on nothing; my head adds only the round-19 wall + pins.

The one P1 — found and fixed in this wave

validated_core_worktree validated the RAW core.worktree spelling, but git escape-decodes quoted values and strips the quotes: probe-pinned against git 2.43, a config value spelled "w\nx" chdir's to a name containing a real newline (unquoted values keep backslashes literal — the divergence is quote-scoped). With a decoy link named .git/"w\nx" (quote characters are legal filename bytes) canonicalizing the raw spelling back to the discovered worktree, the raw-vs-applied divergence passed validation while the child relocated through the decoded name to an outside symlink — git status -uall lists outside files (raw-shell control reproduced the relocation live). Same raw-vs-applied class the round-17 continuation fix closed, one decode step deeper.

Fix: any \ or " in the raw value refuses; unquoted escape-free spellings keep admitting (admission twin pins this with the decoy links present, so the wall keys on the divergence, not on the links). Pins: readonly_filter_discovery_refuses_an_escaped_core_worktree_value + end-to-end leak-flip readonly_shell_lane_refuses_an_escaped_core_worktree_relocation. Mutations 2/2 red on exactly these pins, restore green.

The other seven lanes — verdicts

  • command_safety grammar — sound; fail-closed held in every adversarial shape constructed; 3×P3 nits (doc wording, comment drift, one undisclosed fail-closed parallel-surface shrink).
  • Operand gate / seam / routing — CLEAN; judged-vs-executed equivalence verified against dash/bash/zsh ground truth including the ||-fold semantics, env allowlist (no BASH_ENV/ZDOTDIR/GLOBIGNORE/CDPATH reach the child), pipefail keying, cmd/PowerShell walls. P3: compounds assume sh-family semantics for Custom $SHELL (follow-up: key compound admission on the resolved binary like pipefail already does); two disclosed over-refusals.
  • Git hardening — 40 ground-truth fixture classes probed against the real git binary (gitfile strips, commondir, walk-up predicate decoys ×13, config dialect table, fsmonitor/hooks/textconv/process-filter firing and pinning, version floor); all match except the P1 above. Disclosed residuals re-graded: all P3/acceptable.
  • Split fidelity / smuggling — SPLIT FAITHFUL: slice 3's head tree is bit-for-bit the pre-split archive tree; every pre-split test absent from this slice verified present on a sibling slice (dropped coverage: empty); every behavioral hunk of all 6 files maps to a body claim. No smuggled changes.
  • Test quality — no fake pins, no weakened coverage (571 deletions in subagent/tests.rs are pure blank-line churn; 0 base tests deleted in shell/tests.rs); 22 named pins sampled: all exist, all bind to named production entry points, all plausible-red-on-revert.
  • Executing red team — ~190 probes through the real gates (separator/quoting, cd shapes, walker, program flags incl. every write path, env, caps): 0 new escapes; near-misses all safe-direction and now documented.
  • Performance — ~0.2–0.5 ms per admitted probe; walker 4096/50k/500k + 10 s deadline; discovery 32-repo cap + per-child watchdog + 10 s aggregate budget; nothing on the non-read-only path. The Follow-up section's stale "≈5-minute worst case" figure corrected in the body (honest worst ≈10–20 s, fail-closed).

Receipts at 456babf28b

  • Targeted: tools::shell:: 262/0, command_safety:: 100/0, tools::subagent:: 601/0, tools::registry:: 48/0, tools::verifier:: 9/0 — 1019 passed / 0 failed / 1 ignored (the ignored exporter pre-existing; tools::verifier:: is the current filter spelling — the pre-split tests::verifier:: name no longer matches, which is why the first draft of this receipt mis-tallied the split).
  • cargo fmt --all -- --check clean; fork-CI clippy clean under the exact workflow flags.
  • Merge-order re-sweep at this head: #123 (6c40e3bb21) conflicts in FOUR files (adds subagent/tests.rs, #123-side insertions, keep-both resolution); #136 (f989796f1f, moved again) now conflicts in shell.rs + verifier.rs; direct main-tip merge textually clean but stack-order forbids it. Body updated accordingly (also: #P3NUM → #140, checklist docs item re-scoped to slice 3, round-16 stale CI/zombie note updated — the fork CI ran green on f5aaaafd408).

Known residuals unchanged and disclosed in the body (TOCTOU between the enterability probe and the shell's cd, hardlink invisibility, find -L name disclosure, Windows-lane verbatim divergence unexercised — no Windows host).

CI state at the final head

The rebase landed while the git-443 wall was up (Git Data API route, tree-verified byte-equal), then force-pushed as plain git once it lifted. Two process facts worth recording for the queue:

  • On the stacked base (fix/shell-stdin-eof), Pinvou fork CI's pull_request: branches:[pinvou3-clean] filter cannot fire; the dispatched run (41845678082) shows check (fmt + clippy + full workspace test job) green on this tree, and the tree-scan Gitleaks leg "no leaks found".
  • The same dispatch run's Gitleaks "introduced commits" leg failed only because workflow_dispatch has no pull_request.base.sha — the workflow's documented fallback scans ALL 7406 reachable commits and trips 8 pre-existing findings (generic-api-key/jwt in native_memory-era commits, config/persistence.rs, extension-host fixtures — none in files this PR touches). Reproduced locally with the workflow-pinned gitleaks 8.30.1 (sha256-verified): the introduced range 6337b0e0a..3406aeb30 scans 0 leaks, the full-HEAD scan reproduces exactly the same 8. The Merge Queue's integration run (well-defined base) is the authoritative gate, per convention.

asto18089 added a commit that referenced this pull request Nov 4, 2026
Second slice of the #126 split: the bounded read-only probe lane.

- Sequences (`a; b`, `a && b`, `a || b`) admit iff every segment
  individually passes the read-only gate, via one shared splitter;
  `cd <one relative path>` is admitted inside a sequence with its
  operand shape shared by classifier and execution seam, and routed
  to the shell lane (it is a builtin); read-only `od` dumps and
  clustered short text-filter options join the grammar.
- Every admitted operand is judged at the execution seam: the cwd
  folds left-to-right through admitted `cd`s and connector
  conditionals, glob operands are expanded and judged match by
  match under shared caps and a wall-clock budget (dot/dot-dot
  synthesis, `**` refusal, absolute-spelling walks included), and
  `=cmd`, tilde, verbatim and post-dash wildcard spellings refuse.
- Admitted git reads are hardened: repository `filter.<name>` /
  textconv conversions are pinned to inert helpers via argv config,
  discovery enumerates registered submodules and linked worktrees
  byte-exactly like git (multiline/NUL/CRLF/BOM values, padded
  links, quoted gitlink spellings), under bounded scans, a watchdog,
  and a git-version floor for env-config pins.
  `core.worktree` values carrying quotes or backslashes refuse:
  git escape-decodes quoted values, so the raw spelling validated
  here is not the string git applies (round-19 review).
- gh reads judge every stage (compound and `*`-bearing stages obey
  the host network policy and the worker envelope); the pipefail
  prefix keys on the shell kind, and cmd.exe/PowerShell delimiter
  walls are stated in refusals.
- The `[shell.readonly.command]` refusal enumerates admitted
  families, the every-segment rule, and refused shapes; the worker
  catalog's read-only gh pre-filter uses the same per-stage
  predicate.

Tests: 114 new pins across classifier, operand gate, glob walker,
git discovery/parser differential (incl. the escape-decode
worktree wall, mutation-verified 2/2 red), network guards, and
lane routing; every listed escape was reproduced end-to-end
against the unfixed code before fixing (18 review rounds).
Targeted: `tools::shell::` + `command_safety::` +
`tools::subagent::` + `tools::registry::` + `tools::verifier::`
1019 passed / 0 failed / 1 ignored (the ignored test pre-existing;
`tools::verifier::` is the current filter name — the pre-split
`tests::verifier::` spelling no longer matches); fmt clean;
fork-CI clippy flags clean.

Split of #126, rebased onto fix/shell-stdin-eof
@6337b0e0a (the stdin-review round); full pre-split content
preserved on archive/p126-pre-split-8705ccd85.
Signed-off-by: asto18089 <asto18089@users.noreply.github.com>
@asto18089
asto18089 force-pushed the fix/live-test-findings-subagent-shell branch from 456babf to 945c98a Compare November 4, 2026 14:04
asto18089 added a commit that referenced this pull request Nov 4, 2026
Second slice of the #126 split: the bounded read-only probe lane.

- Sequences (`a; b`, `a && b`, `a || b`) admit iff every segment
  individually passes the read-only gate, via one shared splitter;
  `cd <one relative path>` is admitted inside a sequence with its
  operand shape shared by classifier and execution seam, and routed
  to the shell lane (it is a builtin); read-only `od` dumps and
  clustered short text-filter options join the grammar.
- Every admitted operand is judged at the execution seam: the cwd
  folds left-to-right through admitted `cd`s and connector
  conditionals, glob operands are expanded and judged match by
  match under shared caps and a wall-clock budget (dot/dot-dot
  synthesis, `**` refusal, absolute-spelling walks included), and
  `=cmd`, tilde, verbatim and post-dash wildcard spellings refuse.
- Admitted git reads are hardened: repository `filter.<name>` /
  textconv conversions are pinned to inert helpers via argv config,
  discovery enumerates registered submodules and linked worktrees
  byte-exactly like git (multiline/NUL/CRLF/BOM values, padded
  links, quoted gitlink spellings), under bounded scans, a watchdog,
  and a git-version floor for env-config pins.
  `core.worktree` values carrying quotes or backslashes refuse:
  git escape-decodes quoted values, so the raw spelling validated
  here is not the string git applies (round-19 review).
- gh reads judge every stage (compound and `*`-bearing stages obey
  the host network policy and the worker envelope); the pipefail
  prefix keys on the shell kind, and cmd.exe/PowerShell delimiter
  walls are stated in refusals.
- The `[shell.readonly.command]` refusal enumerates admitted
  families, the every-segment rule, and refused shapes; the worker
  catalog's read-only gh pre-filter uses the same per-stage
  predicate.

Tests: 114 new pins across classifier, operand gate, glob walker,
git discovery/parser differential (incl. the escape-decode
worktree wall, mutation-verified 2/2 red), network guards, and
lane routing; every listed escape was reproduced end-to-end
against the unfixed code before fixing (18 review rounds).
Targeted: `tools::shell::` + `command_safety::` +
`tools::subagent::` + `tools::registry::` + `tools::verifier::`
1019 passed / 0 failed / 1 ignored (the ignored test pre-existing;
`tools::verifier::` is the current filter name — the pre-split
`tests::verifier::` spelling no longer matches); fmt clean;
fork-CI clippy flags clean.

Split of #126, rebased onto fix/shell-stdin-eof
@6337b0e0a (the stdin-review round); full pre-split content
preserved on archive/p126-pre-split-8705ccd85.
Signed-off-by: asto18089 <asto18089@users.noreply.github.com>
@asto18089
asto18089 force-pushed the fix/live-test-findings-subagent-shell branch from 945c98a to 3406aeb Compare November 4, 2026 14:43
asto18089 added a commit that referenced this pull request Nov 4, 2026
Third slice of the #126 split: the `test` role's honest surface.

- The roster description, the type schema, VERIFIER_AGENT_INTRO, and
  both SUBAGENTS role tables state the real contract: built-in
  verification gates with test-selection arguments, plus the
  classifier-bounded read-only shell for evidence gathering; custom
  commands stay refused.
- The Verifier joins `role_requires_read_only_shell` and
  `allows_bounded_readonly_bash` (the latter now reads that one
  predicate), gaining the same bounded probe lane as the inspection
  roles; the carve-out is Verifier-only and keys on a recorded
  parent-shell grant, so a read-only-parent's child never regains
  authority through the role name alone (chain pins cover the
  sub-subagent path).
- Shell authority (`holds_shell_authority`) keys on the held policy
  plus the grant sentinel; fleet receipts report the verifier's
  effective shell as `read_only`; the worker catalog omits bash from
  envelopes without read-only shell authority and pins the refusal
  copy's load-bearing claims.

Tests: verifier contract/authority/carve-out pins incl. the
sub-subagent chain, catalog and receipts labels; the deleted
no-shell verifier catalog test is replaced by the probe-lane
variant. Targeted: `tools::subagent::` + `tools::registry::` +
`fleet::` suites green; tree of this commit equals the reviewed
#126 head tree byte-exact (8705ccd). Split of
#126 (stacks on fix/live-test-findings-subagent-shell
after fix/shell-stdin-eof); full content preserved on
archive/p126-pre-split-8705ccd85.
Signed-off-by: asto18089 <asto18089@users.noreply.github.com>
asto18089 added a commit that referenced this pull request Nov 4, 2026
Second slice of the #126 split: the bounded read-only probe lane.

- Sequences (`a; b`, `a && b`, `a || b`) admit iff every segment
  individually passes the read-only gate, via one shared splitter;
  `cd <one relative path>` is admitted inside a sequence with its
  operand shape shared by classifier and execution seam, and routed
  to the shell lane (it is a builtin); read-only `od` dumps and
  clustered short text-filter options join the grammar.
- Every admitted operand is judged at the execution seam: the cwd
  folds left-to-right through admitted `cd`s and connector
  conditionals, glob operands are expanded and judged match by
  match under shared caps and a wall-clock budget (dot/dot-dot
  synthesis, `**` refusal, absolute-spelling walks included), and
  `=cmd`, tilde, verbatim and post-dash wildcard spellings refuse.
- Admitted git reads are hardened: repository `filter.<name>` /
  textconv conversions are pinned to inert helpers via argv config,
  discovery enumerates registered submodules and linked worktrees
  byte-exactly like git (multiline/NUL/CRLF/BOM values, padded
  links, quoted gitlink spellings), under bounded scans, a watchdog,
  and a git-version floor for env-config pins.
  `core.worktree` values carrying quotes or backslashes refuse:
  git escape-decodes quoted values, so the raw spelling validated
  here is not the string git applies (round-19 review).
- gh reads judge every stage (compound and `*`-bearing stages obey
  the host network policy and the worker envelope); the pipefail
  prefix keys on the shell kind, and cmd.exe/PowerShell delimiter
  walls are stated in refusals.
- The `[shell.readonly.command]` refusal enumerates admitted
  families, the every-segment rule, and refused shapes; the worker
  catalog's read-only gh pre-filter uses the same per-stage
  predicate.

Tests: 114 new pins across classifier, operand gate, glob walker,
git discovery/parser differential (incl. the escape-decode
worktree wall, mutation-verified 2/2 red), network guards, and
lane routing; every listed escape was reproduced end-to-end
against the unfixed code before fixing (18 review rounds).
Targeted: `tools::shell::` + `command_safety::` +
`tools::subagent::` + `tools::registry::` + `tools::verifier::`
1019 passed / 0 failed / 1 ignored (the ignored test pre-existing;
`tools::verifier::` is the current filter name — the pre-split
`tests::verifier::` spelling no longer matches); fmt clean;
fork-CI clippy flags clean.

Split of #126, rebased onto fix/shell-stdin-eof
@6337b0e0a (the stdin-review round); full pre-split content
preserved on archive/p126-pre-split-8705ccd85.
Signed-off-by: asto18089 <asto18089@users.noreply.github.com>
@asto18089
asto18089 force-pushed the fix/live-test-findings-subagent-shell branch from 3406aeb to f063d51 Compare November 4, 2026 17:03
asto18089 added a commit that referenced this pull request Nov 4, 2026
Third slice of the #126 split: the `test` role's honest surface.

- The roster description, the type schema, VERIFIER_AGENT_INTRO, and
  both SUBAGENTS role tables state the real contract: built-in
  verification gates with test-selection arguments, plus the
  classifier-bounded read-only shell for evidence gathering; custom
  commands stay refused.
- The Verifier joins `role_requires_read_only_shell` and
  `allows_bounded_readonly_bash` (the latter now reads that one
  predicate), gaining the same bounded probe lane as the inspection
  roles; the carve-out is Verifier-only and keys on a recorded
  parent-shell grant, so a read-only-parent's child never regains
  authority through the role name alone (chain pins cover the
  sub-subagent path).
- Shell authority (`holds_shell_authority`) keys on the held policy
  plus the grant sentinel; fleet receipts report the verifier's
  effective shell as `read_only`; the worker catalog omits bash from
  envelopes without read-only shell authority and pins the refusal
  copy's load-bearing claims.

Tests: verifier contract/authority/carve-out pins incl. the
sub-subagent chain, catalog and receipts labels; the deleted
no-shell verifier catalog test is replaced by the probe-lane
variant. Targeted: `tools::subagent::` + `tools::registry::` +
`fleet::` suites green; tree of this commit equals the reviewed
#126 head tree byte-exact (8705ccd). Split of
#126 (stacks on fix/live-test-findings-subagent-shell
after fix/shell-stdin-eof); full content preserved on
archive/p126-pre-split-8705ccd85.
Signed-off-by: asto18089 <asto18089@users.noreply.github.com>
@asto18089

Copy link
Copy Markdown
Collaborator Author

Re-aligned again onto #139's wave 2 (a2ec04f — the round-1 Option B decision: explicit interact close_stdin now writes \n+VEOF through the PTY dup): this branch is now f063d51. Note there was a push race between two sessions (your 3406aeb was based on wave 1); this rebase preserved your conflict resolutions and your 2 new tests verbatim — tools::shell:: 262 passed / 0 failed / 1 ignored at f063d51. If you have local state, pull/rebase on this head before pushing again.

asto18089 added a commit that referenced this pull request Nov 4, 2026
First slice of the #126 split: the foreground/sync stdin lifecycle.

- Foreground and sync shell commands without a stdin payload no longer
  hold the write end open for the process lifetime: foreground closes
  it right after spawn, the sync lane spawns with `Stdio::null()`, so
  a stdin-reading probe (`cat`, a bare `$SHELL`) gets end-of-file and
  exits instead of idling until the 120 s watchdog.
- A foreground command later detached to the background reports its
  closed stdin on `interact` instead of an opaque refusal.
- stdin writes are bounded everywhere (64 KiB inline cap, owned-writer
  fallback for Windows/ohos, 5 s deadline with a stall flag), a PTY
  writer no longer injects EOF on drop (dup'd master fd), and the
  verifier's background gate jobs are handed EOF at spawn instead of
  idling on the held-open pipe.
- bash guidance and the schema describe the foreground stdin contract;
  the model-facing description carries the same sentence.

Tests: 11 new pins (foreground EOF, sync-lane EOF, detached-foreground
interact receipt, PTY interact surface, stalled-tty bound, interact/
inline caps, bounded owned write) + the verifier gate-job EOF pin.
Targeted: `tools::shell::` 152 passed / 0 failed / 1 ignored
(pre-existing), `tests::verifier::` green; fmt clean; fork-CI clippy
flags clean; upstream all-targets clippy reports only the pre-existing
core/engine/turn_loop.rs:5499 collapsible_if (base code).

Split of #126; full content preserved on
archive/p126-pre-split-8705ccd85. Stacks: none (base f1eb10e).
Signed-off-by: asto18089 <asto18089@users.noreply.github.com>
@asto18089
asto18089 force-pushed the fix/shell-stdin-eof branch from a2ec04f to 9148c17 Compare November 4, 2026 17:47
asto18089 added a commit that referenced this pull request Nov 4, 2026
Second slice of the #126 split: the bounded read-only probe lane.

- Sequences (`a; b`, `a && b`, `a || b`) admit iff every segment
  individually passes the read-only gate, via one shared splitter;
  `cd <one relative path>` is admitted inside a sequence with its
  operand shape shared by classifier and execution seam, and routed
  to the shell lane (it is a builtin); read-only `od` dumps and
  clustered short text-filter options join the grammar.
- Every admitted operand is judged at the execution seam: the cwd
  folds left-to-right through admitted `cd`s and connector
  conditionals, glob operands are expanded and judged match by
  match under shared caps and a wall-clock budget (dot/dot-dot
  synthesis, `**` refusal, absolute-spelling walks included), and
  `=cmd`, tilde, verbatim and post-dash wildcard spellings refuse.
- Admitted git reads are hardened: repository `filter.<name>` /
  textconv conversions are pinned to inert helpers via argv config,
  discovery enumerates registered submodules and linked worktrees
  byte-exactly like git (multiline/NUL/CRLF/BOM values, padded
  links, quoted gitlink spellings), under bounded scans, a watchdog,
  and a git-version floor for env-config pins.
  `core.worktree` values carrying quotes or backslashes refuse:
  git escape-decodes quoted values, so the raw spelling validated
  here is not the string git applies (round-19 review).
- gh reads judge every stage (compound and `*`-bearing stages obey
  the host network policy and the worker envelope); the pipefail
  prefix keys on the shell kind, and cmd.exe/PowerShell delimiter
  walls are stated in refusals.
- The `[shell.readonly.command]` refusal enumerates admitted
  families, the every-segment rule, and refused shapes; the worker
  catalog's read-only gh pre-filter uses the same per-stage
  predicate.

Tests: 114 new pins across classifier, operand gate, glob walker,
git discovery/parser differential (incl. the escape-decode
worktree wall, mutation-verified 2/2 red), network guards, and
lane routing; every listed escape was reproduced end-to-end
against the unfixed code before fixing (18 review rounds).
Targeted: `tools::shell::` + `command_safety::` +
`tools::subagent::` + `tools::registry::` + `tools::verifier::`
1019 passed / 0 failed / 1 ignored (the ignored test pre-existing;
`tools::verifier::` is the current filter name — the pre-split
`tests::verifier::` spelling no longer matches); fmt clean;
fork-CI clippy flags clean.

Split of #126, rebased onto fix/shell-stdin-eof
@6337b0e0a (the stdin-review round); full pre-split content
preserved on archive/p126-pre-split-8705ccd85.
Signed-off-by: asto18089 <asto18089@users.noreply.github.com>
@asto18089
asto18089 force-pushed the fix/live-test-findings-subagent-shell branch from f063d51 to d6d4143 Compare November 4, 2026 18:23
asto18089 added a commit that referenced this pull request Nov 4, 2026
Third slice of the #126 split: the `test` role's honest surface.

- The roster description, the type schema, VERIFIER_AGENT_INTRO, and
  both SUBAGENTS role tables state the real contract: built-in
  verification gates with test-selection arguments, plus the
  classifier-bounded read-only shell for evidence gathering; custom
  commands stay refused.
- The Verifier joins `role_requires_read_only_shell` and
  `allows_bounded_readonly_bash` (the latter now reads that one
  predicate), gaining the same bounded probe lane as the inspection
  roles; the carve-out is Verifier-only and keys on a recorded
  parent-shell grant, so a read-only-parent's child never regains
  authority through the role name alone (chain pins cover the
  sub-subagent path).
- Shell authority (`holds_shell_authority`) keys on the held policy
  plus the grant sentinel; fleet receipts report the verifier's
  effective shell as `read_only`; the worker catalog omits bash from
  envelopes without read-only shell authority and pins the refusal
  copy's load-bearing claims.

Tests: verifier contract/authority/carve-out pins incl. the
sub-subagent chain, catalog and receipts labels; the deleted
no-shell verifier catalog test is replaced by the probe-lane
variant. Targeted: `tools::subagent::` + `tools::registry::` +
`fleet::` suites green; tree of this commit equals the reviewed
#126 head tree byte-exact (8705ccd). Split of
#126 (stacks on fix/live-test-findings-subagent-shell
after fix/shell-stdin-eof); full content preserved on
archive/p126-pre-split-8705ccd85.
Signed-off-by: asto18089 <asto18089@users.noreply.github.com>
@asto18089

Copy link
Copy Markdown
Collaborator Author

Re-aligned onto the rebased #139 (9148c17, now on main 449edbf after #123/#124 landed): this branch is d6d4143. Your resolutions are preserved verbatim; the #123 collisions were resolved to your state (your hardener call site + your refusal copy), with two semantic reconciliations where main's newer content demanded it: (1) the git-read hardener now also passes rev-parse/branch/worktree unspliced (main's #123/#124 widening — main's new orientation probes in shell/tests.rs pin it), while blame KEEPS its --no-textconv splice (this slice's empirical textconv pin); (2) main's new refusal-enumeration subagent test was not carried into this branch (its expectations describe main's grammar state) — that reconciliation is #126 re-sweep material. Gates at d6d4143: tools::shell:: 270/0/1, tools::subagent:: 629/0, fmt clean, fork-CI clippy flags green.

@asto18089 asto18089 left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Round 20 — from-zero re-review of the rebased slice (9 lanes), rebase verified, 2×P2 fixed in-wave

Re-review conducted from scratch per the four review goals (usefulness/completeness, elegance, no smuggling, bug-freedom), with one complication handled mid-flight: the branch was rewritten under us by a concurrent session — the stack was rebased onto pinvou3-clean with #123/#124 merged (slice 1 re-split into four commits, #139 now 9148c1785f; slice 2 re-headed d6d41439d8), and the round-19-era head 3406aeb30 I started from is no longer the PR head. Both states were reviewed; the rebase was itself treated as new review surface.

Rebase fidelity (the new surface)

A dedicated lane compared every slice-2-added function between 3406aeb30 (tree d83ee5291a8d) and d6d41439d8 (tree 6dc3c39c10): 239 total — 238 byte-identical; the one delta is the splice_git_read_hardening arm extension (#123's classifier admits git rev-parse/branch --list/worktree list, so the no-splice arm must list them or every such probe dies fail-closed at the seam), pinned both directions by readonly_argv_executes_the_git_orientation_probes_without_a_shell. The #123/#124 hunks land as insertion-only keep-both blocks exactly as the body's merge-order section predicted; hunk-attribution ledger: 135 hunks, zero slice-2 behavior drift, registry.rs blob-identical. All eight round-19-critical elements verified byte-intact at the new head (escape-decode wall + both its pins, refusal copy with {max_segments}, per-stage gh predicate and all three consumers, spawn walls, caps, per-kind walls, splice table, sequence splitter). One rebase artifact found and fixed: a duplicated bounded-stdin doc comment landed above the cd-operand test; the copy is removed in the wave.

Findings and dispositions

  • P2 (fixed in-wave) — BASHOPTS/SHELLOPTS were not in readonly_spawn_env_overrides; bash honors an inherited BASHOPTS=dotglob at startup (probed on this host's bash 5.2), defeating the round-6 GLOBIGNORE="" wall and widening * over dotfiles. Operator-env precondition (same class as the existing ZDOTDIR/GLOBIGNORE pins), so not attacker-reachable through the grammar — but the pin family exists precisely for this class. Fixed in 8f98b3e895: both keys cleared, table pin widened to readonly_spawn_env_overrides_carry_the_six_fixed_keys. The round-15 follow-up note claiming "the child-env allowlist already drops both" was wrong and is corrected in the body.
  • P2 (fixed in-wave) — the three PR-introduced mode-000 root-runner skip notes used eprintln!, tripping the crate-level #![deny(clippy::print_stderr)] under --all-targets clippy (the repo's own ci.yml gate form; the fork-CI lib-only form did not compile tests, which is why earlier receipts stayed green). Fixed: one shared #[allow]ed skip-note helper; the strict clippy inventory now shows zero warnings pointing into slice-2's six files. Remaining warnings are three pre-existing collapsible_if (turn_loop.rs:5499, verifier.rs:587, and subagent/mod.rs:8036 carried in with #123, in #123's own hunks) plus CI-allowed too_many_arguments in untouched files.
  • P2 → P3 (downgraded after repro) — the registry-layer per-stage gh predicate swap (enforce_tool_authority) has no test that reddens if broken; repro confirmed the mutation-kill gap, but the layer is fail-closed one branch earlier (compounds refuse at the arbitrary-execution wall before the gh branch is reached), and the predicate's load-bearing consumers (the seam's network guard and the subagent pre-check) carry this PR's own per-stage pins (readonly_github_shell_calls_obey_the_host_network_policy_before_spawn, a_network_denied_child_cannot_address_a_remote_location_through_any_tool). Coverage note, not a hole; a small registry closure pin remains nice-to-have.
  • P3 ledger (no action required this round, disclosed in the body) — discovery aggregate wall-clock is deadline + one child watchdog (~20 s honest worst, comment wording); .git walk-up has no per-level deadline (single-syscall residual, same disclosed class); objects/info/alternates content-crossing residual deserves one disclosure sentence; two doc-comment inaccuracies (GIT_DISCOVERY_ACROSS_FILESYSTEM mechanism claim, padded-gitfile probe narrative); refusal copy promises tr/uniq operand shapes their caps refuse; quoted-separator over-recognition can mislabel a refusal reason for no-network children (outcome-safe, seam fails closed); copy-only pins are brittle to copy edits; subagent/tests.rs 571-blank-line churn.
  • Body corrections (applied in this update, 19 edits) — Summary enumeration completed through rounds 14–19 and scoped to this slice; round-3 authority bullet and the sub-subagent-chain bullet re-anchored to #140; Split row 3 + Merge order corrected (#140's branch is stale on the superseded round-18-era stack and must be rebased onto this head before it merges — merging it as-is would resurrect the pre-round-19 slice-2 state and drop the escape-decode wall); clippy claim updated (three pre-existing collapsible_if at the new head); round-16 zombie-CI sentence qualified as historical; receipt deltas annotated; phantom readonly_sequence_refused_by_shell_kind name marked; the spliced round-15/16 follow-up sentence repaired; the BASHOPTS/SHELLOPTS item moved to implemented.

Red team and performance (fresh, on the new head's content)

  • Executing red team: 187 tool-path probes, 0 escapes, 0 unexpected false refusals, under bash and dash children, against hostile forests (outside-symlink lattices, cd drift chains, glob farms, textconv/blame repos, include/includeIf, gitfile pads/CRLF/NUL, commondir chains, quoted-escape core.worktree) with raw-shell positive controls proving every fixture fires. The round-19 P1 closure re-verified live at both the pin and E2E levels, with the unquoted admission twin still admitting. zsh unavailable on this host — zsh-specific shapes probed at the shell-agnostic gate level.
  • Performance: all bounds re-measured — worst admitted pathological glob compound 10.4 ms (round-4's 25 s blowup stays dead; cycle dedup holds at depth 30), 1M-entry single dir refuses at the entry cap in ~140 ms, worst discovery 18 ms (240 KiB config) with the 300 KiB read bound refusing before parse, classifier linear at the 61 KiB/32-segment bound (2.6 ms), common-path pre-spawn delta vs parent <0.1 ms p50. Timing pins all ≥3x margin; two pre-existing <2x windows noted for a follow-up.

Gates (local reproduction at d6d41439d8 + 8f98b3e895, tree c7c17bfff)

  • tools::shell:: 270/0/1, tools::subagent:: 629/0, command_safety:: 100/0, tools::registry:: 48/0, tools::verifier:: 9/0 — 1056 passed / 0 failed / 1 ignored, receipt-exact before and after the wave.
  • cargo fmt --all -- --check clean; strict --all-targets --all-features clippy inventory clean for slice-2's six files (see ledger above).
  • gitleaks 8.30.1: the introduced-range scan is clean; the full-worktree scan's 8 findings are all pre-existing main content outside every introduced hunk (verified hunk-by-hunk, including the lib.rs:15034 fake sk-live-… marker inside a redaction test). As before, the fork-CI dispatch's introduced-scan leg needs a well-defined base — the Merge Queue integration run is the authoritative gate.

Verdict

Content: all four review goals hold — the PR does exactly what it claims (compound read-only probes admitted with the operand gate, glob walker, git hardening and discovery walls all enforced and pinned), elegantly (shared splitters, one budget, one splice table, no reinvention), with zero smuggling (the rebase's 36-file delta is fully attributed #123/#124 content), and no known functional or performance defect. No P0/P1 open in this slice. The one open action is on the sibling slice: rebase #140 onto this head (8f98b3e895 or later) before it merges, then the stack can proceed to the Merge Queue. This round's fixes are landed as 8f98b3e895; the body now matches reality at that head.

asto18089 added a commit that referenced this pull request Nov 5, 2026
Third slice of the #126 split: the `test` role's honest surface.

- The roster description, the type schema, VERIFIER_AGENT_INTRO, and
  both SUBAGENTS role tables state the real contract: built-in
  verification gates with test-selection arguments, plus the
  classifier-bounded read-only shell for evidence gathering; custom
  commands stay refused.
- The Verifier joins `role_requires_read_only_shell` and
  `allows_bounded_readonly_bash` (the latter now reads that one
  predicate), gaining the same bounded probe lane as the inspection
  roles; the carve-out is Verifier-only and keys on a recorded
  parent-shell grant, so a read-only-parent's child never regains
  authority through the role name alone (chain pins cover the
  sub-subagent path).
- Shell authority (`holds_shell_authority`) keys on the held policy
  plus the grant sentinel; fleet receipts report the verifier's
  effective shell as `read_only`; the worker catalog omits bash from
  envelopes without read-only shell authority and pins the refusal
  copy's load-bearing claims.

Tests: verifier contract/authority/carve-out pins incl. the
sub-subagent chain, catalog and receipts labels; the deleted
no-shell verifier catalog test is replaced by the probe-lane
variant. Targeted: `tools::subagent::` + `tools::registry::` +
`fleet::` suites green; tree of this commit equals the reviewed
#126 head tree byte-exact (8705ccd). Split of
#126 (stacks on fix/live-test-findings-subagent-shell
after fix/shell-stdin-eof); full content preserved on
archive/p126-pre-split-8705ccd85.
Signed-off-by: asto18089 <asto18089@users.noreply.github.com>
asto18089 and others added 2 commits November 5, 2026 10:20
Second slice of the #126 split: the bounded read-only probe lane.

- Sequences (`a; b`, `a && b`, `a || b`) admit iff every segment
  individually passes the read-only gate, via one shared splitter;
  `cd <one relative path>` is admitted inside a sequence with its
  operand shape shared by classifier and execution seam, and routed
  to the shell lane (it is a builtin); read-only `od` dumps and
  clustered short text-filter options join the grammar.
- Every admitted operand is judged at the execution seam: the cwd
  folds left-to-right through admitted `cd`s and connector
  conditionals, glob operands are expanded and judged match by
  match under shared caps and a wall-clock budget (dot/dot-dot
  synthesis, `**` refusal, absolute-spelling walks included), and
  `=cmd`, tilde, verbatim and post-dash wildcard spellings refuse.
- Admitted git reads are hardened: repository `filter.<name>` /
  textconv conversions are pinned to inert helpers via argv config,
  discovery enumerates registered submodules and linked worktrees
  byte-exactly like git (multiline/NUL/CRLF/BOM values, padded
  links, quoted gitlink spellings), under bounded scans, a watchdog,
  and a git-version floor for env-config pins.
  `core.worktree` values carrying quotes or backslashes refuse:
  git escape-decodes quoted values, so the raw spelling validated
  here is not the string git applies (round-19 review).
- gh reads judge every stage (compound and `*`-bearing stages obey
  the host network policy and the worker envelope); the pipefail
  prefix keys on the shell kind, and cmd.exe/PowerShell delimiter
  walls are stated in refusals.
- The `[shell.readonly.command]` refusal enumerates admitted
  families, the every-segment rule, and refused shapes; the worker
  catalog's read-only gh pre-filter uses the same per-stage
  predicate.

Tests: 114 new pins across classifier, operand gate, glob walker,
git discovery/parser differential (incl. the escape-decode
worktree wall, mutation-verified 2/2 red), network guards, and
lane routing; every listed escape was reproduced end-to-end
against the unfixed code before fixing (18 review rounds).
Targeted: `tools::shell::` + `command_safety::` +
`tools::subagent::` + `tools::registry::` + `tools::verifier::`
1019 passed / 0 failed / 1 ignored (the ignored test pre-existing;
`tools::verifier::` is the current filter name — the pre-split
`tests::verifier::` spelling no longer matches); fmt clean;
fork-CI clippy flags clean.

Split of #126, rebased onto fix/shell-stdin-eof
@6337b0e0a (the stdin-review round); full pre-split content
preserved on archive/p126-pre-split-8705ccd85.
Signed-off-by: asto18089 <asto18089@users.noreply.github.com>
Round-20 review wave on the compound read-only probe slice:

- the fixed spawn-env overrides now clear BASHOPTS/SHELLOPTS: bash
  honors both at startup from the inherited process environment, so an
  operator-exported BASHOPTS=dotglob would widen * over the dotfiles
  the GLOBIGNORE wall keeps cold (same class as the round-6 ZDOTDIR and
  GLOBIGNORE pins); the table pin now covers six fixed keys
- the three mode-000 root-runner skip notes route through one shared
  helper with a single targeted clippy::print_stderr exemption, keeping
  the crate-level deny lint clean under --all-targets clippy
- rebase repair: a duplicated bounded-stdin doc comment block landed
  above the cd-operand test in the #123/#124 rebase; the copy is gone
  and the separating blank line restored

Signed-off-by: asto18089 <asto18089@users.noreply.github.com>
asto18089 added a commit that referenced this pull request Nov 5, 2026
First slice of the #126 split: the foreground/sync stdin lifecycle.

- Foreground and sync shell commands without a stdin payload no longer
  hold the write end open for the process lifetime: foreground closes
  it right after spawn, the sync lane spawns with `Stdio::null()`, so
  a stdin-reading probe (`cat`, a bare `$SHELL`) gets end-of-file and
  exits instead of idling until the 120 s watchdog.
- A foreground command later detached to the background reports its
  closed stdin on `interact` instead of an opaque refusal.
- stdin writes are bounded everywhere (64 KiB inline cap, owned-writer
  fallback for Windows/ohos, 5 s deadline with a stall flag), a PTY
  writer no longer injects EOF on drop (dup'd master fd), and the
  verifier's background gate jobs are handed EOF at spawn instead of
  idling on the held-open pipe.
- bash guidance and the schema describe the foreground stdin contract;
  the model-facing description carries the same sentence.

Tests: 11 new pins (foreground EOF, sync-lane EOF, detached-foreground
interact receipt, PTY interact surface, stalled-tty bound, interact/
inline caps, bounded owned write) + the verifier gate-job EOF pin.
Targeted: `tools::shell::` 152 passed / 0 failed / 1 ignored
(pre-existing), `tests::verifier::` green; fmt clean; fork-CI clippy
flags clean; upstream all-targets clippy reports only the pre-existing
core/engine/turn_loop.rs:5499 collapsible_if (base code).

Split of #126; full content preserved on
archive/p126-pre-split-8705ccd85. Stacks: none (base f1eb10e).
Signed-off-by: asto18089 <asto18089@users.noreply.github.com>
@asto18089
asto18089 force-pushed the fix/shell-stdin-eof branch from 9148c17 to 8c6c00d Compare November 5, 2026 02:23
@asto18089
asto18089 force-pushed the fix/live-test-findings-subagent-shell branch from 8f98b3e to 1f9635a Compare November 5, 2026 02:24
@asto18089

Copy link
Copy Markdown
Collaborator Author

Heads-up from the #140 round-2 review: the forkguard* test family (core::engine / client::operation — 250 tests, none touched by any slice) flakily hangs the lib suite when run with the default thread count at the current stack. Recipe and attribution:

  • Reproduce (hangs, 3× observed, one run stuck 7h+ inside the forkguard block at ~2540/12500):

    CARGO_TARGET_DIR=<fresh> RUST_MIN_STACK=16777216 \
      cargo test -p codewhale-tui --locked --lib -- forkguard
    
  • Same filter with CI's thread count passes clean:

    ... cargo test -p codewhale-tui --locked --lib -- --test-threads 2 forkguard
    → 250 passed; 0 failed; finished in 5.81s
    

So it is a parallelism-dependent race, invisible under the fork-CI invocation (--test-threads 2) but guaranteed to bite anyone running the suite locally with default threads on a many-core box.

Attribution (all runs on this board, 20 hardware threads):

  • pre-re-push stack (3406aeb304 + slice-3 round-2 fixes): full lib, default threads → 12429 passed / 0 failed / 224s — clean, so the hang arrived with the latest re-push;
  • slice-3 head 15b563920 without slice-3's round-2 commit: forkguard subset, default threads → hangs (so not caused by the round-2 fixes);
  • at slice-3's round-2 head 75f9a592c: same behavior (hangs on default threads, clean under --test-threads 2);
  • one earlier full-lib run at the re-pushed head did complete (12485 passed / 2 failed under heavy cross-session build contention, failure names lost to a truncated log) — so the hang is flaky, not deterministic, under default threads.

Given slices 1/2 own the touched surfaces (slice-1's new interact close_stdin PTY-EOF commits and slice-2's peer-gate/worktree-lane rework are the plausible candidates), flagging here rather than fixing: worth a bisect and a look at whatever the forkguard tests await that a 20-thread interleaving can starve. Happy to hand over the logs (/tmp/pr140-r2/full-lib-combined-run2.log) if useful.

asto18089 added a commit that referenced this pull request Nov 5, 2026
Third slice of the #126 split: the `test` role's honest surface.

- The roster description, the type schema, VERIFIER_AGENT_INTRO, and
  both SUBAGENTS role tables state the real contract: built-in
  verification gates with test-selection arguments, plus the
  classifier-bounded read-only shell for evidence gathering; custom
  commands stay refused.
- The Verifier joins `role_requires_read_only_shell` and
  `allows_bounded_readonly_bash` (the latter now reads that one
  predicate), gaining the same bounded probe lane as the inspection
  roles; the carve-out is Verifier-only and keys on a recorded
  parent-shell grant, so a read-only-parent's child never regains
  authority through the role name alone (chain pins cover the
  sub-subagent path).
- Shell authority (`holds_shell_authority`) keys on the held policy
  plus the grant sentinel; fleet receipts report the verifier's
  effective shell as `read_only`; the worker catalog omits bash from
  envelopes without read-only shell authority and pins the refusal
  copy's load-bearing claims.

Tests: verifier contract/authority/carve-out pins incl. the
sub-subagent chain, catalog and receipts labels; the deleted
no-shell verifier catalog test is replaced by the probe-lane
variant. Targeted: `tools::subagent::` + `tools::registry::` +
`fleet::` suites green; tree of this commit equals the reviewed
#126 (stacks on fix/live-test-findings-subagent-shell
after fix/shell-stdin-eof); full content preserved on
archive/p126-pre-split-8705ccd85.
Signed-off-by: asto18089 <asto18089@users.noreply.github.com>
…nd close round-21 review findings

The round-20 rebase of this branch onto the #123/#124-merged main resolved
subagent/tests.rs by dropping six merged #123/#124 test fns and the
`mod identity_selection;` declaration, silently orphaning the 11
forkguard_agent_identity_* tests in the still-present
tests/identity_selection.rs (26 KB, byte-identical, no longer compiled).
The dropped fns exercise surfaces this slice does not touch (same-worktree
peer gating, ancestor walk-up non-adoption, claim-path normalization,
scoped mutation paths) and all 18 tests pass restored, so the deletion was
pure coverage loss, not a forced adaptation. This restores them
union-keep-both — the resolution the body already documents for this file.

Also: pin the "Do not retry variations" guidance fragment in the
replacement enumeration test (the one fragment the deleted
shell_readonly_refusal_enumerates_the_admitted_families pin carried that
the replacement dropped), and correct the readonly_shell_bound_command doc
comment, which said the pipefail prefix keys on "a pipeline" while the
predicate is a plain `contains('|')` (an || sequence matches and receives
the harmless no-op prefix).

Receipts at this head: readonly_ 150/0, command_safety 101/0, forkguard
285/0 (--test-threads=2), tools::subagent:: 648/0, rustfmt clean on both
touched files.

Signed-off-by: asto18089 <asto18089@users.noreply.github.com>

@asto18089 asto18089 left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Round 21 — fresh 9-lane re-review at the re-aligned stack (head 6386b876b, tree bc3415f3c9, base 8c6c00dc0c)

Rebase performed first (per review protocol)

pinvou3-clean moved five commits past the round-20 base (449edbf0b → 11cba403dc: #129, #131, #133, #134, #135 — the last four touch tools/subagent/, overlapping this slice's files). The stack was re-aligned: #139's four commits re-based onto 11cba403dc as 8c6c00dc0c (delta bit-identical, verified against the true merge-base 449edbf0b), then this slice's two commits replayed as 7b754c7df + 1f9635a894. Fidelity: the four non-overlap files are blob-identical to the round-20 head; subagent/mod.rs delta content-identical; subagent/tests.rs delta differs by exactly one cosmetic blank-line-removal hunk that #131's inserted test fn makes unpreservable — union kept both sides. rustfmt parse-checks ruled out union-brace breakage before push.

The one P1 — found by two independent lanes, verified, fixed in-wave (6386b876b)

The round-20 rebase resolution of tools/subagent/tests.rs silently dropped coverage instead of keeping both sides: six merged-#123/#124 test fns (same_worktree_disjoint_sibling_blocks_unbounded_shell, same_worktree_ancestor_chain_keeps_unbounded_shell, same_worktree_gate_ignores_settled_and_foreign_worktree_peers, worktree_discovery_never_adopts_or_enumerates_an_ancestors_sibling_repos, normalize_claim_path_folds_backslashes_into_the_ledger_frame, scoped_mutation_paths_accept_absolute_targets_inside_the_workspace) plus the mod identity_selection; declaration — orphaning the still-present tests/identity_selection.rs (11 forkguard_agent_identity_* tests, byte-identical 26 KB file, no longer compiled anywhere in the stack; also absent from slice 3's tree, verified at its head). Traced: absent from the pre-split archive because the archive predates #123/#124, present at base and on main, deleted by this branch's own delta since the round-20 rewrite. A fn-set audit (base vs head) confirms these seven are the only deletions. All six fns exercise surfaces this slice does not touch (same-worktree peer gating, ancestor walk-up non-adoption, claim-path normalization, scoped mutation paths). The fix wave restores them union-keep-both — the resolution the body already documents — plus re-pins the Do not retry variations guidance fragment (the one fragment the deleted enumeration pin carried that the replacement dropped) and corrects the readonly_shell_bound_command doc comment (the pipefail prefix keys on a plain contains('|'), so an || sequence receives the harmless no-op prefix; doc said "a pipeline"). All 18 restored tests pass — pure coverage loss, not forced adaptation.

Lane results

Lane Verdict
L1 classifier grammar PASS — 65 hand-traced inputs through a compiled harness; seam walls verified for every deliberate classifier admission; 3×P3
L2 execution seam PASS — env wall complete (BASH_ENV/ENV/ZDOTDIR/GLOBIGNORE/CDPATH/RIPGREP_CONFIG_PATH/BASHOPTS/SHELLOPTS + child_env allowlist double-wall + sanitized PATH); splice re-quoting sound; cd fold/containment verified; 108 readonly E2Es green; 3×P3
L3 authority wiring PASS — predicate swap uniform at all 3 consumers, 0 dead refs; every route judges pre-spawn; child shell min-clamped (min_with(parent)), posture denials inheritable; no delegation leak
L4 split fidelity + smuggling SMUGGLING: none — 595 hunks classified; registry.rs/mod.rs deltas are exactly the claimed wiring; blank-line churn carries zero content; the P1 above is its one finding
L5 test quality + mutations new pins high quality; 6/6 production mutations turned their pins red (escape-decode wall, BASHOPTS env pin, compound .any flip, empty-segment flip, cd * flip, gh predicate swap); conflict regions compile and pass
L6 executing red team 0 escapes — 222 tool-entry + 49 classifier + 12 spawn-env probes on bash, dash, and the unset-SHELL fallback; quoted-escape/real-newline core.worktree, -C wildcards/symlink chains, filter/textconv/fsmonitor fixtures (raw-shell positive controls fire, lane never does), per-stage gh Deny-policy compounds, hostile parent env incl. workspace-internal startup files: all held
L7 performance PASS — no regression vs base on common paths; new costs bounded (glob caps + 10 s deadline; discovery 32-repo cap + watchdog + 10 s aggregate wall); one claim-precision nit (below)
L8 body accuracy all substantive section-2 claims TRUE in the delta; stale anchors + two body-vs-tree contradictions — corrected in the body this round
L9 static gates PASS — receipts below

P3 ledger (disclosed in the body, not changed)

  1. GNU +N count spellings (tail -n +5) newly refuse — digit-validation class round 18 disclosed didn't name this family (base admitted it).
  2. Availability refusals: git status -uall, grep -r/-rn/-e/--include, du -sh (alternatives cover the need: du -s -h, git status --porcelain, rg -g, git grep -e).
  3. git worktree list prints operator-registered linked-worktree paths — the documented name-only disclosure class.
  4. command_reaches_github_readonly runs pre-admission on unbounded input (linear, ~7 µs typical — bounding note only).
  5. The 0.2–0.5 ms per admitted probe figure understates git-stage probes: ≈3.4 ms/repo warm discovery (32-repo cap ≈ 111 ms; walls bound the pathological case) — body corrected.
  6. Pre-existing, unchanged (flagged for the record, out of this delta): a network-denied Full-shell child can reach a gh read through task_shell_start/task_shell_wait (not in the precheck's name scope) unless a network_policy decider is configured; the forkguard suite's global test-env locks convoy at high thread counts.

Receipts at 6386b876b (tree bc3415f3c9)

  • readonly_ 150/0 · command_safety:: 101/0 · forkguard_ 285/0 (--test-threads=2) · tools::subagent:: 648/0 · readonly_operand_gate 23/0 · registry 202/0/2 pre-existing ignores.
  • cargo check -p codewhale-tui --all-targets clean under workspace deny(warnings); rustfmt clean on all touched files; fork-CI clippy clean; upstream-CI clippy variant reports only pre-existing sites (identical inventory at base, none in this PR's files).
  • gitleaks over the introduced range 8c6c00dc0c..6386b876b: no leaks.
  • PR body refreshed: round-21 bullet + receipt, split-time note round-21 update (stack anchors 8c6c00dc0c/7b754c7df/1f9635a894/6386b876b/tree bc3415f3c9), the two keep-both claims corrected, #140 row/split-table updated, merge-order section marked historical, clippy anchors refreshed.

Open items

  1. #140 must follow the round-21 wave head (6386b876b) — it re-based onto 1f9635a894 mid-review (round-3 fixes, head 6e2ed34fb8) and is now behind by exactly the fix commit; one mechanical base-follow before merge, else the combined tree loses this round's restored coverage.
  2. DCO advisory: 1f9635a894 author email (@126.com) ≠ its Signed-off-by (@users.noreply.github.com) — gate passes; normalize at squash.

Verdict: with the P1 fixed in-wave, the slice is useful, root-cause-complete against its claims, elegant (one splitter family serving classifier/gate/hardening/network-guard/discovery), smuggling-free, and bug-free per all nine lanes — COMMENTED (no outstanding blocker in this slice's own delta).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants