Skip to content

fix(hooks): block pipe-to-shell / eval RCE in denylist (#311) - #323

Merged
dngioidev merged 1 commit into
mainfrom
fix/311-denylist-pipe-to-shell
Aug 1, 2026
Merged

fix(hooks): block pipe-to-shell / eval RCE in denylist (#311)#323
dngioidev merged 1 commit into
mainfrom
fix/311-denylist-pipe-to-shell

Conversation

@dngioidev

Copy link
Copy Markdown
Owner

Closes #311

Problem

segments() in plugin/hooks/denylist.mjs splits the command on the pipe separator, so a downloader piped into an interpreter (curl | sh, wget -O- | bash, echo | base64 -d | sh) split into segments that individually matched no rule — remote code execution was not blocked. There was also no eval-of-substitution rule. The agy host inherits the gap via plugin/hooks/agy-deny.mjs, which reuses check().

Fix

  • Added a full-string rule path in check(): rules tagged scope:'full' test the whole command (defeating the deliberate pipe split); all existing rules stay per-segment with unchanged behavior.
  • pipe-to-shell rule: a downloader/decoder (curl|wget|fetch|base64) whose pipeline feeds a shell interpreter (sh|bash|zsh|dash|ash|ksh). The interpreter match is anchored to the command consuming the pipe, so benign pipes still pass.
  • eval-exec rule: eval of a command-substitution / backtick / base64-decoded payload.
  • Return shape {blocked, rule, msg} and the fail-open guard are preserved. Fix flows to the agy host automatically via check().

Acceptance criteria

  • AC.1 — a downloader (curl/wget/fetch) piped into an interpreter is blocked on the full command line, on both the Claude and agy hosts. Verified: tests/hooks/denylist.test.mjs "AC.1: curl/wget/fetch piped into sh/bash/zsh/dash is blocked" (7 payloads) + tests/agy/emit.test.mjs AC-289.2 spawns the emitted agy-deny.mjs and asserts decision: deny / pipe-to-shell.
  • AC.2 — eval of a command-substitution or base64-decoded payload is blocked. Verified: "AC.2: base64-decode piped into an interpreter is blocked" + "AC.2: eval of a command-substitution or decoded payload is blocked".
  • AC.3 — adversarial tests cover downloader-to-interpreter, base64-decode-to-interpreter, and eval-of-substitution classes, and confirm benign pipes still PASS. Verified: "AC.3: benign pipes still pass — grep | wc -l and friends are not RCE" (8 benign commands incl. curl | jq, cat | base64, tee run.sh).
  • AC.4 — fail-open preserved: check() never throws and a hook error never blocks the session. Verified: "AC.4: garbage input never throws" + the handle() journal-failure-still-blocks cases.

Verification

  • pnpm verify: 555/555 passing (50 files). tests/hooks/denylist.test.mjs: 36/36.
  • Security: new regexes use no nested quantifiers → no ReDoS / catastrophic backtracking. Interpreter anchored to the pipe-consuming command to avoid false positives on .sh filenames and benign pipelines.

🤖 Generated with Claude Code

https://claude.ai/code/session_01SATRHKa6mDHDuirhP6QuwL

segments() splits the command on the pipe separator so a downloader piped
into an interpreter (curl | sh, wget -O- | bash, echo | base64 -d | sh)
split into segments that individually matched no rule — remote code
execution was NOT blocked. There was also no eval-of-substitution rule.

Add a full-string rule path in check(): rules tagged scope:'full' test the
whole command, defeating the pipe split; all existing rules stay per-segment.
Two new full-string rules:
  - pipe-to-shell: a downloader/decoder (curl|wget|fetch|base64) whose
    pipeline feeds a shell interpreter (sh|bash|zsh|dash|ash|ksh), anchored
    to the interpreter consuming the pipe so benign pipes (grep | wc -l,
    curl | jq, cat | base64, tee notes.sh) still pass.
  - eval-exec: eval of a command-substitution / backtick / decoded payload.

Return shape {blocked, rule, msg} and the fail-open guard are preserved. The
agy host inherits the fix via check(); emit test asserts agy parity. Regexes
use no nested quantifiers (no ReDoS).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SATRHKa6mDHDuirhP6QuwL
@dngioidev
dngioidev merged commit 27803d7 into main Aug 1, 2026
6 checks passed
@dngioidev
dngioidev deleted the fix/311-denylist-pipe-to-shell branch August 1, 2026 16:14
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.

denylist: block pipe-to-shell / remote code execution

1 participant