Repository navigation
Expand file tree
/
Copy pathpermit.ts
More file actions
177 lines (169 loc) · 7.25 KB
/
Copy pathpermit.ts
File metadata and controls
177 lines (169 loc) · 7.25 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
/**
* Decides which shell commands the agent of a command-line setup
* (`wdio session`, agent-browser, playwright-cli) may run.
*
* Claude Code's `Bash(prefix:*)` allow rules reject commands they can't
* parse with certainty, e.g. an unquoted `Selenium_(software)` in a URL, and
* in a headless run a rejected command can't be approved, so the agent gives
* up. This is a deliberate, small parser instead. A command is allowed when
*
* - every part of it (split on `&&`, `||`, `;`, `|` and newlines) is a
* call of the setup's tool, an `echo`/`printf` (printing, or piped into
* one), `true`, or a read-only filter (`head`, `grep`, …) reading a pipe,
* - it has no backticks or process substitution, and command substitution
* only of the tool's own commands (`SESSION="$(agent-browser session id)"`),
* - variable assignments (`export SESSION=…`) don't touch variables that
* change what runs (`PATH`, `NODE_OPTIONS`, …),
* - redirects only write into the run directory.
*
* Quoted strings and quoted heredoc bodies are data and are not inspected
* for structure, but double quotes and unquoted heredocs are still checked
* for substitutions, because the shell expands those.
*/
const WDIO_SESSION = /^(npx\s+(?:(?:--yes|-y)\s+)?)?wdio\s+session(?:\s|$)/
// not the commands that change the machine instead of the page, start a
// server, or hand the task to another model (`chat`, whose tokens we can't count)
const AGENT_BROWSER = /^(npx\s+(?:(?:--yes|-y)\s+)?)?agent-browser(?:\s|$)(?!\s*(?:install|upgrade|plugin|chat|dashboard)(?:\s|$))/
// not the commands that install things, open a dashboard window, or reach
// browsers outside the run (`kill-all` kills every browser process)
// (anywhere in the call, so a global option like `-s=name` in front doesn't hide them)
const PLAYWRIGHT_CLI = /^(npx\s+(?:(?:--yes|-y)\s+)?)?playwright-cli(?:\s|$)(?!(?:.*\s)?(?:install(?:-[\w-]+)?|show|kill-all|delete-data)(?:\s|$))/
const FEED = /^(echo|printf)(?:\s|$)/
const FILTER = /^(head|tail|grep|wc|sort|jq)(?:\s|$)/
// other tools have their own waits; a plain sleep between commands is harmless
const SLEEP = /^sleep\s+\d+(\.\d+)?$/
// `|| true` and `echo "---"` between commands only shape the output
const NOOP = /^(true|:)$/
// a heredoc fed to the tool (`cat <<'EOF' | agent-browser eval --stdin`), not a file
const HEREDOC_FEED = /^cat\s+<<-?\s*(""|\w+)$/
const SUBSTITUTION = /`|\$\(|<\(|>\(/
const ASSIGNMENT = /^(?:export\s+)?([A-Za-z_]\w*)=\S*$/
const LEADING_ASSIGNMENTS = /^(?:[A-Za-z_]\w*=\S*\s+)+/
const PROTECTED = /^(PATH|NODE_OPTIONS|NODE_PATH|BASH_ENV|ENV|IFS|HOME|SHELL|LD_\w*|DYLD_\w*)$/
/** `NAME=value` is harmless unless NAME changes which program runs or how */
function safeAssignments (part: string) {
return [...part.matchAll(/(?:^|\s)([A-Za-z_]\w*)=/g)].every(([, name]) => !PROTECTED.test(name))
}
function safeRedirectTarget (target: string) {
if (target === '/dev/null' || /^&\d$/.test(target)) {
return true
}
return /^[\w.-][\w./-]*$/.test(target) && !target.split('/').includes('..')
}
/** a filter like `grep x` must not name files: `grep x ~/.ssh/id_rsa` ignores the pipe */
function readsOnlyThePipe (part: string) {
return part.split(/\s+/).slice(1).every((arg) => arg.startsWith('-') || !/[/~]|\.\./.test(arg)) &&
part.split(/\s+/).slice(1).filter((arg) => !arg.startsWith('-')).length <= (/^(grep|jq)/.test(part) ? 1 : 0) + (/^(head|tail)\s+-n/.test(part) ? 1 : 0)
}
export const isWdioSessionCommand = (command: string) => isCommandOf(WDIO_SESSION, command)
export const isAgentBrowserCommand = (command: string) => isCommandOf(AGENT_BROWSER, command)
export const isPlaywrightCliCommand = (command: string) => isCommandOf(PLAYWRIGHT_CLI, command)
/**
* Reads the command left to right the way the shell quotes it: single-quoted
* text is literal, double-quoted text only expands `$(…)` and backticks.
* Returns the command with quoted text replaced by `""`, and the commands of
* every `$(…)`, or undefined for backticks and process substitution.
*/
function scan (command: string): { shell: string, substitutions: string[] } | undefined {
let shell = ''
const substitutions: string[] = []
let quote: '' | '\'' | '"' = ''
for (let i = 0; i < command.length; i++) {
const char = command[i]
if (quote === '\'') {
if (char === '\'') {
quote = ''
shell += '""'
}
continue
}
if (char === '\\') {
i++
if (!quote) {
shell += '_'
}
continue
}
if (char === '`') {
return undefined
}
if (char === '$' && command[i + 1] === '(') {
let depth = 0
let end = i + 1
for (; end < command.length; end++) {
depth += command[end] === '(' ? 1 : command[end] === ')' ? -1 : 0
if (depth === 0) {
break
}
}
if (depth !== 0) {
return undefined
}
substitutions.push(command.slice(i + 2, end))
if (!quote) {
shell += '""'
}
i = end
continue
}
if (quote === '"') {
if (char === '"') {
quote = ''
shell += '""'
}
continue
}
if (char === '\'' || char === '"') {
quote = char
continue
}
if ((char === '<' || char === '>') && command[i + 1] === '(') {
return undefined
}
shell += char
}
return quote ? undefined : { shell, substitutions }
}
function isCommandOf (tool: RegExp, command: string): boolean {
let unsafe = false
let shell = command.replace(/<<-?\s*(['"]?)(\w+)\1[^\n]*\n([\s\S]*?)\n[ \t]*\2[ \t]*(?=\n|$)/g, (match, quote: string, _tag: string, body: string) => {
unsafe ||= !quote && SUBSTITUTION.test(body)
return match.split('\n')[0]
})
const scanned = scan(shell)
if (unsafe || !scanned) {
return false
}
// `$(…)` may only hold the tool's own commands
if (!scanned.substitutions.every((inner) => isCommandOf(tool, inner))) {
return false
}
shell = scanned.shell
for (const [, target] of shell.matchAll(/\d*>>?\s*(&?[^\s;&|]*)/g)) {
if (!safeRedirectTarget(target)) {
return false
}
}
// keep the separators: filters must read a pipe, heredocs must feed one
const tokens = shell.split(/(&&|\|\||;|\||\n)/)
let sawTool = scanned.substitutions.length > 0
for (let i = 0; i < tokens.length; i += 2) {
const part = tokens[i].trim()
if (!part) {
continue
}
const before = tokens[i - 1]
const after = tokens[i + 1]
if (!safeAssignments(part)) {
return false
}
if (tool.test(part.replace(LEADING_ASSIGNMENTS, ''))) {
sawTool = true
} else if (ASSIGNMENT.test(part)) {
continue
} else if (!SLEEP.test(part) && !NOOP.test(part) && !FEED.test(part) && !(HEREDOC_FEED.test(part) && after === '|') && !(FILTER.test(part) && before === '|' && readsOnlyThePipe(part))) {
return false
}
}
return sawTool
}