diff --git a/.changeset/fix-readonly-bash-exec-flag-escapes.md b/.changeset/fix-readonly-bash-exec-flag-escapes.md new file mode 100644 index 00000000000..1b9dec5002d --- /dev/null +++ b/.changeset/fix-readonly-bash-exec-flag-escapes.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Close read-only bash escapes where allowed commands could still run arbitrary programs via flags (`sort --compress-program`, `rg --pre`, `ag --pager`, `man -P`/`-H`). diff --git a/packages/opencode/src/kilocode/agent/index.ts b/packages/opencode/src/kilocode/agent/index.ts index 30a57506187..69337ee898d 100644 --- a/packages/opencode/src/kilocode/agent/index.ts +++ b/packages/opencode/src/kilocode/agent/index.ts @@ -109,24 +109,37 @@ export const readOnlyBash: Record = { "git branch -r *": "allow", "git remote -v *": "allow", "gh *": "ask", + // Everything below is a blocklist layered on the allowlist above: it catches ways + // an "allowed" read-only command can still write files, chain commands, or exec an + // arbitrary program. This is defense-in-depth, not a sandbox — the durable fix is + // OS-level sandboxing, not command-line string matching. + // `*` matches any run of characters (including spaces and empty), so each rule + // catches its operator anywhere. Broad forms subsume narrow ones: `*&*` covers + // `&&`, and `*>*` covers `>`, `>>`, `>|`, and `>(` in any spacing. "*\n*": "deny", "*<(*": "deny", "*|*": "deny", "*;*": "deny", - "*&&*": "deny", "*&*": "deny", "*$(*": "deny", "*`*": "deny", "*>*": "deny", - "* > *": "deny", - "*>>*": "deny", - "* >> *": "deny", - "*>|*": "deny", - "* >| *": "deny", + // Short -o is space-anchored (two forms) so it never matches filenames like + // `foo-o bar`; long flags use `*--flag*`, which is specific enough to bridge both + // "flag first" and "flag after args" positions in one rule. "sort -o *": "deny", "sort * -o *": "deny", - "sort --output*": "deny", - "sort * --output*": "deny", + "sort *--output*": "deny", + // Flags that make otherwise "read-only" commands exec an arbitrary program. + "sort *--compress-program*": "deny", + "sort *--files0-from*": "deny", + "rg *--pre *": "deny", + "rg *--pre=*": "deny", + "rg *--hostname-bin*": "deny", + "ag *--pager*": "deny", + "man *-P*": "deny", + "man *--pager*": "deny", + "man *-H*": "deny", } function askGuard(mcp: Record = {}) { diff --git a/packages/opencode/test/kilocode/ask-agent-permissions.test.ts b/packages/opencode/test/kilocode/ask-agent-permissions.test.ts index b64ea8d3f0e..04071890d8c 100644 --- a/packages/opencode/test/kilocode/ask-agent-permissions.test.ts +++ b/packages/opencode/test/kilocode/ask-agent-permissions.test.ts @@ -121,6 +121,16 @@ describe("Ask agent bash permissions", () => { "sort names.txt --output=names.txt", "echo ok\ntouch ask-bypass.txt", "cat <(touch ask-bypass.txt)", + // Exec-via-flag escapes on otherwise read-only commands + 'sort -S 1b --compress-program "sh" names.txt', + "sort --compress-program=sh names.txt", + "sort --files0-from=list names.txt", + "rg --pre sh -e . names.txt", + "rg --pre=sh -e . names.txt", + "ag --pager sh foo", + "man -P sh ls", + "man -Psh ls", + "man --pager=sh ls", ] for (const cmd of denied) {