Skip to content

Commit d4808dc

Browse files
authored
feat(gatekeeper): support safe fd redirects in chain parser (#95)
* feat(gatekeeper): support safe fd redirects in chain parser Allow fd-to-fd redirects (2>&1, >&2, <&3, >&-) and /dev/null redirects (>/dev/null, 2>/dev/null, >>/dev/null) in the chain parser instead of immediately classifying them as unparseable. Arbitrary file redirects (> file.txt, >> file.txt, < file.txt) remain blocked. * chore: update bun.lock * chore: apply AI code review suggestions - Add bounds check to whitespace-skipping while loop in /dev/null redirect parser (gemini-code-assist) - Add word-boundary check after /dev/null match to prevent approving paths like /dev/nullicious (cubic-dev-ai P1) - Add regression test for /dev/nullicious boundary case * chore: apply AI code review suggestions Replace ambiguous splitChainedCommands assertion for /dev/nullicious with evaluate(bash(...)) to explicitly verify the unsafe redirect is passed through * chore: update bun.lock after rebase
1 parent 2cd111f commit d4808dc

3 files changed

Lines changed: 124 additions & 6 deletions

File tree

plugins/gatekeeper/dist/pre-tool-use.js

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@
22
import process from "node:process";
33

44
// src/chain-parser.ts
5+
function isDigit(ch) {
6+
return ch !== undefined && ch >= "0" && ch <= "9";
7+
}
58
function parseChainedCommand(cmd) {
69
if (/\$\(|`|\n|<\(|>\(/.test(cmd)) {
710
return { kind: "unparseable" };
@@ -37,7 +40,33 @@ function parseChainedCommand(cmd) {
3740
current += ch;
3841
continue;
3942
}
40-
if (ch === "<" || ch === ">") {
43+
if (ch === ">" || ch === "<") {
44+
if (ch === "<") {
45+
if (cmd[i + 1] === "&" && (isDigit(cmd[i + 2]) || cmd[i + 2] === "-")) {
46+
current += cmd.slice(i, i + 3);
47+
i += 2;
48+
continue;
49+
}
50+
return { kind: "unparseable" };
51+
}
52+
let j = i + 1;
53+
if (cmd[j] === ">") {
54+
j++;
55+
}
56+
if (cmd[j] === "&" && (isDigit(cmd[j + 1]) || cmd[j + 1] === "-")) {
57+
current += cmd.slice(i, j + 2);
58+
i = j + 1;
59+
continue;
60+
}
61+
let k = j;
62+
while (k < cmd.length && (cmd[k] === " " || cmd[k] === "\t"))
63+
k++;
64+
const afterNull = cmd[k + 9];
65+
if (cmd.slice(k, k + 9) === "/dev/null" && (afterNull === undefined || afterNull === " " || afterNull === "\t" || afterNull === ";" || afterNull === "&" || afterNull === "|" || afterNull === ">" || afterNull === "<")) {
66+
current += cmd.slice(i, k + 9);
67+
i = k + 8;
68+
continue;
69+
}
4170
return { kind: "unparseable" };
4271
}
4372
const next = cmd[i + 1];

plugins/gatekeeper/src/chain-parser.ts

Lines changed: 49 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,10 @@
66
* caused the two functions to diverge and produce inconsistent security decisions.
77
*/
88

9+
function isDigit(ch: string | undefined): boolean {
10+
return ch !== undefined && ch >= '0' && ch <= '9'
11+
}
12+
913
export type ParseResult
1014
/**
1115
* Reject and send to AI review. Covers: subshell, backtick, newline,
@@ -23,7 +27,9 @@ export type ParseResult
2327
*
2428
* Security invariants guaranteed by this function:
2529
* - $(), backtick, newline, <(), >() → unparseable (subshell / process substitution)
26-
* - Unquoted < or > → unparseable (file redirect: arbitrary write/read)
30+
* - Unquoted < or > to files → unparseable (file redirect: arbitrary write/read)
31+
* - Safe fd redirects (2>&1, >&2, <&3, >&-) → allowed (fd-to-fd, no file I/O)
32+
* - >/dev/null, >>/dev/null, N>/dev/null → allowed (discard output, safe)
2733
* - || → unparseable (right side runs on LEFT failure; semantics differ)
2834
* - single | → unparseable (pipe: stdout→stdin, context-dependent)
2935
* - Lone & → unparseable (background execution; different semantics)
@@ -78,8 +84,48 @@ export function parseChainedCommand(cmd: string): ParseResult {
7884
continue
7985
}
8086

81-
// Redirect operators outside quotes → potential arbitrary file write/read
82-
if (ch === '<' || ch === '>') {
87+
// Redirect operators outside quotes — allow safe fd redirects and /dev/null
88+
if (ch === '>' || ch === '<') {
89+
// Check if preceded by a digit (e.g., the '2' in '2>&1' or '2>/dev/null')
90+
// We already consumed that digit into `current`, so just note it for context.
91+
92+
if (ch === '<') {
93+
// <&N or <&- → fd input redirect (safe)
94+
if (cmd[i + 1] === '&' && (isDigit(cmd[i + 2]) || cmd[i + 2] === '-')) {
95+
current += cmd.slice(i, i + 3)
96+
i += 2
97+
continue
98+
}
99+
// Any other < → unsafe
100+
return { kind: 'unparseable' }
101+
}
102+
103+
// ch === '>'
104+
let j = i + 1
105+
106+
// >> (append) — advance past second >
107+
if (cmd[j] === '>') {
108+
j++
109+
}
110+
111+
// >&N or >&- → fd-to-fd redirect (safe)
112+
if (cmd[j] === '&' && (isDigit(cmd[j + 1]) || cmd[j + 1] === '-')) {
113+
current += cmd.slice(i, j + 2)
114+
i = j + 1
115+
continue
116+
}
117+
118+
// > /dev/null or >> /dev/null (with optional whitespace)
119+
let k = j
120+
while (k < cmd.length && (cmd[k] === ' ' || cmd[k] === '\t')) k++
121+
const afterNull = cmd[k + 9]
122+
if (cmd.slice(k, k + 9) === '/dev/null' && (afterNull === undefined || afterNull === ' ' || afterNull === '\t' || afterNull === ';' || afterNull === '&' || afterNull === '|' || afterNull === '>' || afterNull === '<')) {
123+
current += cmd.slice(i, k + 9)
124+
i = k + 8
125+
continue
126+
}
127+
128+
// Any other > or >> → unsafe (arbitrary file write)
83129
return { kind: 'unparseable' }
84130
}
85131

plugins/gatekeeper/src/pre-tool-use.test.ts

Lines changed: 45 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -75,11 +75,40 @@ describe('splitChainedCommands', () => {
7575
expect(splitChainedCommands('echo hello > >(tee output.txt)')).toBeNull()
7676
})
7777

78-
test('should return null for redirect operators outside quotes', () => {
78+
test('should return null for unsafe redirect operators outside quotes', () => {
7979
expect(splitChainedCommands('echo test > file.txt')).toBeNull()
8080
expect(splitChainedCommands('echo test >> file.txt')).toBeNull()
8181
expect(splitChainedCommands('cat < file.txt')).toBeNull()
82+
})
83+
84+
test('should treat safe fd redirects as part of the command (single)', () => {
85+
// fd-to-fd redirects
86+
expect(splitChainedCommands('echo test 2>&1')).toBeNull() // null = single (no chain ops)
87+
expect(splitChainedCommands('echo test >&2')).toBeNull()
88+
expect(splitChainedCommands('cmd 2>&-')).toBeNull()
89+
expect(splitChainedCommands('cmd <&3')).toBeNull()
90+
expect(splitChainedCommands('cmd <&-')).toBeNull()
91+
// /dev/null redirects
8292
expect(splitChainedCommands('echo test 2>/dev/null')).toBeNull()
93+
expect(splitChainedCommands('echo test >/dev/null')).toBeNull()
94+
expect(splitChainedCommands('echo test >>/dev/null')).toBeNull()
95+
expect(splitChainedCommands('echo test 2>>/dev/null')).toBeNull()
96+
// /dev/null with whitespace
97+
expect(splitChainedCommands('echo test > /dev/null')).toBeNull()
98+
expect(splitChainedCommands('echo test 2> /dev/null')).toBeNull()
99+
// /dev/null — word-boundary check: must not approve /dev/nullicious etc.
100+
expect(evaluate(bash('echo test > /dev/nullicious'))).toBeNull() // unsafe redirect must remain passthrough
101+
})
102+
103+
test('should split chains containing safe fd redirects', () => {
104+
expect(splitChainedCommands('cd /path && bun test 2>&1')).toEqual([
105+
'cd /path',
106+
'bun test 2>&1',
107+
])
108+
expect(splitChainedCommands('cmd1 2>/dev/null && cmd2')).toEqual([
109+
'cmd1 2>/dev/null',
110+
'cmd2',
111+
])
83112
})
84113

85114
test('should return null when no chain operators present', () => {
@@ -711,12 +740,26 @@ describe('chain parsing: safe chains are allowed in Layer 1', () => {
711740
expectPassthrough(bash('npm test || npm install'))
712741
})
713742

714-
test('should passthrough redirect operators', () => {
743+
test('should passthrough unsafe redirect operators', () => {
715744
expectPassthrough(bash('echo test > file.txt'))
716745
expectPassthrough(bash('echo test >> file.txt'))
717746
expectPassthrough(bash('cat < file.txt'))
718747
})
719748

749+
test('should allow commands with safe fd redirects', () => {
750+
expectAllow(bash('echo test 2>&1'))
751+
expectAllow(bash('echo test 2>/dev/null'))
752+
expectAllow(bash('echo test >/dev/null'))
753+
})
754+
755+
test('should allow chain with safe fd redirects', () => {
756+
expectAllow(bash('npm test && bun test --bail --timeout 120000 src/test.ts 2>&1'))
757+
})
758+
759+
test('should passthrough pipe even with fd redirect before it', () => {
760+
expectPassthrough(bash('ls 2>&1 | grep foo'))
761+
})
762+
720763
test('should passthrough process substitution', () => {
721764
expectPassthrough(bash('diff <(cat /etc/passwd) /etc/hosts'))
722765
expectPassthrough(bash('cat <(whoami)'))

0 commit comments

Comments
 (0)