Skip to content

fix(shell): count the reads in a shell command list - #1262

Merged
jeff-r2026 merged 7 commits into
Tencent:mainfrom
SaulMoro:fix/shell-list-skill-reads
Oct 22, 2026
Merged

jeff-r2026 merged 7 commits into
Tencent:mainfrom
SaulMoro:fix/shell-list-skill-reads

Conversation

@SaulMoro

@SaulMoro SaulMoro commented Oct 21, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

A file read inside a shell command list (pwd; cat …/SKILL.md, test -f X && cat X, cd d && cat a.md) now counts as a read for skill use and recall adoption. Whether an item of a list ran cannot be known without running the shell, so only lists in a small closed grammar count. Every other list stays a plain shell call with no paths, as on main.

 classifyShellCommand(command, flavor, succeeded)
   one command or one pipeline   -> as on main
-  a list (; && || newline)      -> shell, no paths
+  a list in the grammar below   -> read of its last && chain's reads (they ran, and succeeded when the call exited 0)
+    no exit status (Codex)      -> only that chain's first read, after guards that cannot fail
+  any other list                -> shell, no paths
A list counts only if every item is one of Any of these anywhere makes the whole list shell
a read main already accepts, by its bare name, with no assignment |, ||, &, an empty command, a final &&
pwd, true, : (no arguments), echo … $ (but a $HOME/ or ${HOME}/ path prefix), a backtick, (, ), {, }, <, #, a line continuation, an unclosed quote
ls …, test or [ ] with one of -e -f -d -r -s and one operand any other command: eval, exec, exit, source, ., set, trap, alias, a function, a reserved word, a path (/bin/true), .exe
cd <dir>, one literal operand: later relative reads in its && chain move under it a redirect other than 2>/dev/null or 2>&1

Joined by ;, && or newlines. A read before the last ; counts for nothing, since it may have failed with the call still exiting 0. After a cd in an earlier chain only absolute and ~ paths count. classifyToolCall keeps main's failed-read rule: a counted read's failure fails the call, so error lines are read only when the status is unknown.

Supersedes #1257, whose author (@007Ghz) is credited as co-author on the commits.

Evidence

Hook payloads replayed through the built dist/cli.js hook-dispatch post-tool-use --bg-only with an isolated HOME and hello-skill installed. Skill uses recorded by main, the previous head 45b10457, and this head 85c906ac:

                                                              main  45b1  85c9
codex   cat S                                                   1     1     1
codex   pwd; cat S                                              0     1     1
claude  test -f S && cat S                                      0     1     1
claude  cd <skill dir> && cat SKILL.md                          0     0     1
codex   test -f S && cat S          (no exit status)            0     1     0
claude  cat S; echo ok                                          0     1     0
claude  pwd --invalid && cat S; echo ok                         0     1     0
claude  /nonexistent/true && cat S; echo ok                     0     1     0
claude  eval 'exit 0'; cat S                                    0     1     0
claude  cat() { echo placeholder; }; cat S                      0     1     0
claude  echo "$(case x in x) echo "a; cat S; echo b";; esac)"   0     1     0
claude  a heredoc whose body is `cat S`                         0     0     0

One table test in src/__tests__/tool-call.test.ts specifies the grammar: the counted shapes, and every list raised against earlier heads of this PR reading nothing. Run against 45b10457, 47 of the file's 218 tests fail. At 85c906ac all 218 pass.

Gates at 85c906ac: npx tsc --noEmit and npm run lint pass. npx vitest run gives 11565 passed and 1 failed. The failure is hooks-reconcile-scope.test.ts "fires inside the project … under a real shell" (ENOTDIR on .git/hooks, because the local checkout is a linked git worktree), and it fails the same way with main's src. The diff-based e2e selection matched 0 of 141 e2e files.

Merge Danger

Door: two-way. Reverting restores main's handling, where no list is a read.

Blast Radius: attribution

  • Skill use and recall adoption gain the reads of lists in this grammar. Nothing outside it changes from main.
  • Undercounts by design: reads before the last ;, pipelines inside a list, and, with no exit status, a read after test, ls, cd or another read in its chain.
  • cd d follows CDPATH when the shell has one set, so a relative read after it could be credited under the wrong directory.

Co-authored-by: 007Ghz 77567709+007Ghz@users.noreply.github.com

@SaulMoro
SaulMoro marked this pull request as ready for review October 21, 2026 09:33
SaulMoro and others added 2 commits October 21, 2026 11:35
A file a command list reads (pwd; cat X, test -f X && cat X, a newline list)
is not a read today, so skill use and recall adoption miss it for every tool.

Co-authored-by: 007Ghz <77567709+007Ghz@users.noreply.github.com>
classifyShellCommand now reads each pipeline of a list. A read after ||
credits nothing, error lines are filtered for any non-simple call, and a
relative path stops counting after a command that may change the cwd.

Co-authored-by: 007Ghz <77567709+007Ghz@users.noreply.github.com>
@SaulMoro
SaulMoro force-pushed the fix/shell-list-skill-reads branch from 3fffbe3 to 5fc7e89 Compare October 21, 2026 09:35
@SaulMoro

Copy link
Copy Markdown
Collaborator Author

@007Ghz @007Ghz You are credited as co-author.

@github-actions

Copy link
Copy Markdown
  • [P1 blocking] src/utils/shell-command.ts:480: Heredoc bodies are classified as executable commands. For cat <<'EOF'\ncat /absolute/path/SKILL.md\nEOF, the shell only prints the literal cat … line, but classifyList credits SKILL.md as read. An installed skill therefore gets usage credit without being opened. Exclude heredoc contents from list classification.

  • [P2 non-blocking] src/utils/tool-call.ts:229: Replacing the basename with commandName() drops the .exe diagnostic prefix previously recognized. For cat.exe a.md b.md with unknown status and output # a\ncat.exe: b.md: Permission denied, b.md now remains credited. Preserve the original basename alongside the extension-free name.

  • [P2 non-blocking] src/utils/tool-call.ts:284: Error filtering now rejects successful pipeline reads whose file content contains diagnostic examples. If doc.md contains only cat: missing.md: No such file or directory, a successful cat doc.md | head is returned as status: 'failure', paths: []. Treating matching stdout as definitive failure loses genuine reads.

Testing documentation is sufficient: the description records representative built-CLI hook replays and targeted e2e runs. I only read the specified diff; nothing was run, built, or installed.

…ostics

Skip heredoc bodies, command substitutions, compound blocks, and function
definitions when crediting shell list reads. Match command diagnostics by
the invoked command spelling, basename with .exe, and basename without
.exe, reading diagnostics only from stderr when separate or from merged
output when exit status is unknown.

Co-authored-by: 007Ghz <77567709+007Ghz@users.noreply.github.com>
@github-actions

Copy link
Copy Markdown
  • [P1 blocking] src/utils/shell-command.ts:610: false && cat /abs/SKILL.md; echo ok credits SKILL.md, although cat never executes and the overall call succeeds. Checking only for a preceding || does not establish execution; skipped && branches must not receive skill-use or recall credit.

  • [P1 blocking] src/utils/shell-command.ts:613: exit 0; cat /abs/SKILL.md also credits an unopened skill. exit merely sets moved, which still permits subsequent absolute paths, despite terminating the shell successfully before the reader runs. Stop crediting subsequent commands after an unconditional shell exit.

  • [P1 blocking] src/utils/shell-command.ts:108: Nested quotes inside a double-quoted command substitution still expose literal text as commands. For echo "$(echo "x; cat /abs/SKILL.md; echo x")", the shell only prints text, but the inner opening quote closes the tokenizer’s outer quote, exposing cat to list classification and falsely crediting the skill. Track substitution nesting within double quotes.

All three earlier findings are resolved in the current diff. Testing documentation is sufficient: the description records representative built-CLI hook replays at the specified head.

Only the specified diff was read; nothing was run, built, or installed.

…commands, and nested substitutions

Track delimiter and substitution nesting via a stack in simpleCommands so
inner double quotes inside `$(...)` do not prematurely close outer double
quotes. In classifyList, fail closed on commands following shell-terminating
commands (`exit`, `exec`, `return`, `logout`, `break`, `continue`, `throw`),
after `false &&`, or on `&&` links in non-final conditional chains where prior
commands may fail.

Co-authored-by: 007Ghz <77567709+007Ghz@users.noreply.github.com>
@github-actions

Copy link
Copy Markdown
  • [P1 blocking] src/utils/shell-command.ts:637: if true; then exit 0; fi; cat /abs/SKILL.md credits the skill although the shell exits successfully before reading it. Compound bodies bypass termination tracking, so subsequent commands are incorrectly assumed to execute.

  • [P1 blocking] src/utils/shell-command.ts:506: printf does not always succeed. For printf '%' && cat /abs/SKILL.md; echo ok, the invalid format makes printf fail and skips cat, but the whitelist credits the unopened skill while the overall call succeeds.

  • [P2 non-blocking] src/utils/shell-command.ts:635: A trailing semicolon incorrectly makes an AND-chain “followed.” test -f /abs/SKILL.md && cat /abs/SKILL.md; receives no credit even when the file exists and the read succeeds. A trailing delimiter cannot mask the chain’s exit status.

  • [P2 non-blocking] src/utils/shell-command.ts:655: Pipeline-local exits incorrectly terminate attribution for the parent shell. echo ok | exit 0; cat /abs/SKILL.md executes the final read, but terminated suppresses its credit. An exit inside a pipeline does not terminate the enclosing shell.

  • [P2 non-blocking] src/utils/shell-command.ts:656: An unexecuted false still sets chainSkipsAnd. In true || false && cat /abs/SKILL.md, shell AND/OR operators associate left-to-right, so cat executes successfully; the classifier nevertheless excludes it.

All six findings from the earlier passes are resolved in the current diff.

Testing documentation is sufficient: the description records representative built-CLI hook replays. Its older recorded commit is a note, not a blocking finding.

Only the specified diff was read; nothing was run, built, or installed.

…andalone terminations

Track left-to-right AND/OR pipeline execution states so skipped or conditional
branches (including printf failures and negated commands) do not falsely credit
unopened reads. Allow non-masking trailing delimiters (like semicolons) on final
AND-chains, exclude subshell pipeline exits from parent termination, and detect
shell termination commands inside compound bodies.

Co-authored-by: 007Ghz <77567709+007Ghz@users.noreply.github.com>
@github-actions

Copy link
Copy Markdown
  • [P1 blocking] src/utils/shell-command.ts:553: Failed stderr redirections are treated as successful guards. In true 2>/nonexistent/err && cat /abs/SKILL.md; echo ok, the redirection fails and cat never executes, but isUnconditionalZero() returns true and credits the unopened skill. The overall call succeeds.

  • [P1 blocking] src/utils/shell-command.ts:555: Assignment-only commands do not always succeed. X=$(false) && cat /abs/SKILL.md; echo ok skips cat, because the assignment inherits the substitution’s failing status. Treating every command without a command word as successful nevertheless credits the unopened skill.

  • [P2 non-blocking] src/utils/shell-command.ts:651: Every compound command is assigned a known failing status. if true; then true; fi && cat /abs/SKILL.md successfully reads the file, but the forced NONZERO state suppresses its credit. An unclassified compound command’s status is unknown, not necessarily failure.

  • [P2 non-blocking] src/utils/shell-command.ts:653: Termination detection scans arguments and deferred function bodies as executable commands. Both if true; then echo exit; fi; cat /abs/SKILL.md and f() { exit 0; }; cat /abs/SKILL.md execute the final read, but scanning the argument or uncalled function sets terminated and suppresses attribution.

  • [P2 non-blocking] src/utils/shell-command.ts:685: exec without a command does not terminate the shell. exec 2>/tmp/teamai-review.log; cat /abs/SKILL.md changes the shell’s stderr and then reads the file, but unconditional termination tracking drops that read.

  • [P2 non-blocking] src/utils/shell-command.ts:671: An unknown-status guard prevents tracking a successful fallback. With /missing absent, test -f /missing || true && cat /abs/SKILL.md; echo ok executes true and reads the skill, but the classifier skips updating the fallback’s status and excludes cat.

All eleven findings from the earlier passes are resolved in the current diff.

Testing documentation is sufficient: the description records representative built-CLI hook replays. The older recorded commit is a note, not a blocking finding.

Only the specified diff was read; nothing was run, built, or installed.

…ferred functions, and fallback states

Disallow redirections on unconditional zero guards so failed stderr redirections
fail closed. Reject assignments containing command substitutions from zero state.
Treat compound commands as UNKNOWN condition state to allow valid subsequent guard
executions, ignore argument tokens and function bodies when detecting shell
termination, avoid shell termination for standalone exec without command arguments,
and allow unconditional zero fallback commands after || to restore ZERO state.

Co-authored-by: 007Ghz <77567709+007Ghz@users.noreply.github.com>
@github-actions

Copy link
Copy Markdown
  • [P1 blocking] src/utils/shell-command.ts:559: Known-success guards are not unconditional. Both pwd --invalid && cat /abs/SKILL.md; echo ok and /nonexistent/true && cat /abs/SKILL.md; echo ok skip cat, but the whitelist credits the unopened skill. Account for arguments and invocation, not just the basename.

  • [P1 blocking] src/utils/shell-command.ts:726: eval 'exit 0'; cat /abs/SKILL.md credits the skill although the shell exits successfully before reading it. Marking an unknown command as potentially changing only the cwd does not account for shell-affecting commands such as eval.

  • [P1 blocking] src/utils/shell-command.ts:617: cat() { echo placeholder; }; cat /abs/SKILL.md credits an unopened skill. The definition shadows the reader, but subsequent classification still treats cat as the executable that opens files. Track shadowed reader names or fail closed after such definitions.

  • [P1 blocking] src/utils/shell-command.ts:141: A case-pattern ) prematurely closes command-substitution tracking. echo "$(case x in x) echo "a; cat /abs/SKILL.md; echo b";; esac)" only prints literal text, but the tokenizer exposes the literal cat as a top-level read and credits the skill.

  • [P2 non-blocking] src/utils/tool-call.ts:293: Diagnostics from later reads can erase an earlier successful read in another directory. With a.md present only in the initial cwd, cat a.md; cd subdir; cat a.md; echo ok successfully reads it first, but the later stderr diagnostic naming a.md removes that credit. Associate diagnostics with the relevant invocation and cwd.

  • [P2 non-blocking] src/utils/shell-command.ts:638: Subshell-bodied function definitions leave inFnDepth set indefinitely. f() (true); cat /abs/SKILL.md reads the skill, but the final command is treated as still inside the deferred function and receives no credit.

  • [P2 non-blocking] src/utils/shell-command.ts:646: An argument named esac prematurely closes a case block. case x in x) echo esac;; esac; cat /abs/SKILL.md performs the final read, but the actual closing esac then appears unmatched and classification returns no paths.

The previously reported scenarios are resolved in the current diff.

Testing documentation is sufficient: the description records representative built-CLI hook replays. The older recorded commit is a note, not a blocking finding.

Only the specified diff was read; nothing was run, built, or installed.

Whether an item of a shell list ran cannot be known from its text and the
final exit status without running the shell, so the list simulator is gone.
A list is a read only when it is plain words joined by `;`, `&&` or
newlines, each item a read or a guard that cannot end, replace or
reconfigure the shell (pwd, true, :, echo, ls, a one-file test or [ ], cd).
Only the reads of its last `&&` chain count; with no exit status, only the
first one, after guards that cannot fail. Everything else is a plain shell
call, as on main, and diagnostics are main's again.

Co-authored-by: 007Ghz <77567709+007Ghz@users.noreply.github.com>
@github-actions

Copy link
Copy Markdown

No new findings. The previously reported scenarios are resolved in the current diff.

Testing documentation is sufficient: the description records representative built-CLI hook replays at 85c906ac. No additional provider × agent coverage is required.

Only the specified diff was read; nothing was run, built, or installed.

@jeff-r2026
jeff-r2026 merged commit eeff737 into Tencent:main Oct 22, 2026
16 checks passed
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