@smooai/smooth-operator-core 0.20.4 → 0.22.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent.d.ts +30 -0
- package/dist/agent.d.ts.map +1 -1
- package/dist/agent.js +25 -0
- package/dist/agent.js.map +1 -1
- package/dist/denyPolicy.d.ts +99 -0
- package/dist/denyPolicy.d.ts.map +1 -0
- package/dist/denyPolicy.js +256 -0
- package/dist/denyPolicy.js.map +1 -0
- package/dist/humanGate.d.ts +9 -0
- package/dist/humanGate.d.ts.map +1 -1
- package/dist/humanGate.js +4 -0
- package/dist/humanGate.js.map +1 -1
- package/dist/index.d.ts +7 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -1
- package/dist/index.js.map +1 -1
- package/dist/permission.d.ts +146 -0
- package/dist/permission.d.ts.map +1 -0
- package/dist/permission.js +716 -0
- package/dist/permission.js.map +1 -0
- package/dist/permissionGrants.d.ts +89 -0
- package/dist/permissionGrants.d.ts.map +1 -0
- package/dist/permissionGrants.js +155 -0
- package/dist/permissionGrants.js.map +1 -0
- package/package.json +1 -1
|
@@ -0,0 +1,716 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Native tool-call permission gate for the TypeScript engine.
|
|
3
|
+
*
|
|
4
|
+
* The TypeScript sibling of the Rust reference engine's `permission.rs`. A
|
|
5
|
+
* {@link PermissionHook} runs the pure, deterministic {@link decide} classifier
|
|
6
|
+
* on every tool call and blocks (throws) on a **Deny**. An **Ask** is routed to
|
|
7
|
+
* a human approver (the existing {@link HumanGate} seam) when one is wired, and
|
|
8
|
+
* **fails closed** (blocks) when it is not.
|
|
9
|
+
*
|
|
10
|
+
* The classification model is ported natively from smooth's `auto_mode` /
|
|
11
|
+
* `smooth-narc::judge`. This is the security-critical core and is exhaustively
|
|
12
|
+
* tested — including adversarial compound-command and credential-path inputs.
|
|
13
|
+
*
|
|
14
|
+
* A stored grant ({@link PermissionGrants}) auto-approves a matching `Ask`
|
|
15
|
+
* without prompting, and answering "approve always" persists a new grant. A
|
|
16
|
+
* grant can only upgrade an `Ask` — it can **never** waive a `Deny`
|
|
17
|
+
* circuit-breaker. A consumer-supplied {@link DenyPolicy} is evaluated FIRST and
|
|
18
|
+
* is a circuit-breaker too: no grant waives it and no mode downgrades it.
|
|
19
|
+
*/
|
|
20
|
+
import { isApproved } from './humanGate.js';
|
|
21
|
+
/**
|
|
22
|
+
* How aggressively the hook enforces. Mirrors the Rust engine's `AutoMode` (a
|
|
23
|
+
* trimmed Claude Code `auto-mode` set). Selected via the `SMOOTH_AUTO_MODE` env
|
|
24
|
+
* var through {@link autoModeFromEnv}.
|
|
25
|
+
*/
|
|
26
|
+
export var AutoMode;
|
|
27
|
+
(function (AutoMode) {
|
|
28
|
+
/** Read-only allow, mutating ask, dangerous deny. Default. */
|
|
29
|
+
AutoMode["Ask"] = "ask";
|
|
30
|
+
/** Like {@link AutoMode.Ask} but filesystem-edit (`Write`) tools auto-approve. Mirrors `acceptEdits`. */
|
|
31
|
+
AutoMode["AcceptEdits"] = "accept-edits";
|
|
32
|
+
/** Like {@link AutoMode.Ask} but an unmatched verdict is a deny (fail-closed). Headless / CI posture (`dontAsk`). */
|
|
33
|
+
AutoMode["DenyUnmatched"] = "deny-unmatched";
|
|
34
|
+
/** Allow everything **except** the hard circuit-breakers. Escape hatch (`bypassPermissions`, which keeps its breakers). */
|
|
35
|
+
AutoMode["Bypass"] = "bypass";
|
|
36
|
+
})(AutoMode || (AutoMode = {}));
|
|
37
|
+
/** Parse a `SMOOTH_AUTO_MODE` value. Unknown / unset ⇒ {@link AutoMode.Ask}. */
|
|
38
|
+
export function autoModeFromValue(v) {
|
|
39
|
+
switch (v
|
|
40
|
+
?.trim()
|
|
41
|
+
.toLowerCase()
|
|
42
|
+
.replace(/[-_]/g, '')) {
|
|
43
|
+
case 'deny':
|
|
44
|
+
case 'denyunmatched':
|
|
45
|
+
case 'dontask':
|
|
46
|
+
case 'headless':
|
|
47
|
+
return AutoMode.DenyUnmatched;
|
|
48
|
+
case 'bypass':
|
|
49
|
+
case 'bypasspermissions':
|
|
50
|
+
case 'yolo':
|
|
51
|
+
return AutoMode.Bypass;
|
|
52
|
+
case 'acceptedits':
|
|
53
|
+
case 'acceptedit':
|
|
54
|
+
case 'edits':
|
|
55
|
+
return AutoMode.AcceptEdits;
|
|
56
|
+
default:
|
|
57
|
+
return AutoMode.Ask;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
/** Read the mode from the process `SMOOTH_AUTO_MODE` environment variable. */
|
|
61
|
+
export function autoModeFromEnv() {
|
|
62
|
+
return autoModeFromValue(process.env.SMOOTH_AUTO_MODE);
|
|
63
|
+
}
|
|
64
|
+
const ALLOW = { kind: 'allow' };
|
|
65
|
+
const deny = (reason) => ({ kind: 'deny', reason });
|
|
66
|
+
const ask = (reason) => ({ kind: 'ask', reason });
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
// Circuit-breaker data (ported from smooth-narc::judge + auto_mode)
|
|
69
|
+
// ---------------------------------------------------------------------------
|
|
70
|
+
/** Domains we never auto-approve — suffix match, case-insensitive. */
|
|
71
|
+
const DANGEROUS_DOMAIN_SUFFIXES = [
|
|
72
|
+
'.ngrok.io',
|
|
73
|
+
'.ngrok-free.app',
|
|
74
|
+
'etherscan.io',
|
|
75
|
+
'blockchain.info',
|
|
76
|
+
'binance.com',
|
|
77
|
+
'pastebin.com',
|
|
78
|
+
'termbin.com',
|
|
79
|
+
'transfer.sh',
|
|
80
|
+
];
|
|
81
|
+
/** Shell substrings that must never run — checked case-insensitively against each subcommand. */
|
|
82
|
+
const DANGEROUS_CLI_SUBSTRINGS = [
|
|
83
|
+
'rm -rf /',
|
|
84
|
+
'rm -rf ~',
|
|
85
|
+
':(){ :|:& };:',
|
|
86
|
+
'mkfs',
|
|
87
|
+
'dd if=/dev/zero of=/dev/',
|
|
88
|
+
'> /dev/sda',
|
|
89
|
+
'chmod -r 777 /',
|
|
90
|
+
'| sudo sh',
|
|
91
|
+
'systemctl mask',
|
|
92
|
+
];
|
|
93
|
+
/** Substrings meaning "this command touches a credential / sensitive path". A match is an immediate deny. */
|
|
94
|
+
const SENSITIVE_PATH_SUBSTRINGS = [
|
|
95
|
+
'.ssh/',
|
|
96
|
+
'.aws/credentials',
|
|
97
|
+
'.aws/config',
|
|
98
|
+
'.config/gh/',
|
|
99
|
+
'.config/gcloud',
|
|
100
|
+
'.gnupg',
|
|
101
|
+
'.kube/config',
|
|
102
|
+
'.docker/config.json',
|
|
103
|
+
'.npmrc',
|
|
104
|
+
'.pypirc',
|
|
105
|
+
'.netrc',
|
|
106
|
+
'/etc/shadow',
|
|
107
|
+
'id_rsa',
|
|
108
|
+
'id_ed25519',
|
|
109
|
+
'.smooth/providers.json',
|
|
110
|
+
'.smooth/auth/',
|
|
111
|
+
];
|
|
112
|
+
/** Read-only command binaries that are always safe. */
|
|
113
|
+
const SAFE_BASH_BINS = [
|
|
114
|
+
'ls',
|
|
115
|
+
'cat',
|
|
116
|
+
'head',
|
|
117
|
+
'tail',
|
|
118
|
+
'wc',
|
|
119
|
+
'grep',
|
|
120
|
+
'rg',
|
|
121
|
+
'fd',
|
|
122
|
+
'find',
|
|
123
|
+
'echo',
|
|
124
|
+
'pwd',
|
|
125
|
+
'which',
|
|
126
|
+
'whoami',
|
|
127
|
+
'date',
|
|
128
|
+
'true',
|
|
129
|
+
'test',
|
|
130
|
+
'dirname',
|
|
131
|
+
'basename',
|
|
132
|
+
'realpath',
|
|
133
|
+
'stat',
|
|
134
|
+
'file',
|
|
135
|
+
'cksum',
|
|
136
|
+
'sha256sum',
|
|
137
|
+
'md5sum',
|
|
138
|
+
];
|
|
139
|
+
/** `git` subcommands that only read. */
|
|
140
|
+
const SAFE_GIT_SUBCOMMANDS = ['status', 'log', 'diff', 'show', 'branch', 'remote', 'rev-parse', 'describe', 'blame', 'ls-files'];
|
|
141
|
+
/** Flags under which `git branch` / `git remote` stay read-only. */
|
|
142
|
+
const GIT_LIST_ONLY_FLAGS = ['-a', '-r', '-v', '-vv', '--all', '--list', '--verbose', '--show-current', '--merged', '--no-merged'];
|
|
143
|
+
/** Binaries that make outbound network requests. */
|
|
144
|
+
const NET_BASH_BINS = ['curl', 'wget', 'http', 'https', 'nc', 'ncat', 'telnet'];
|
|
145
|
+
/** Shell interpreters that execute piped stdin — the sink half of a `curl … | sh`. */
|
|
146
|
+
const SHELL_INTERPRETERS = ['sh', 'bash', 'zsh', 'dash', 'ksh'];
|
|
147
|
+
/** Env-var name fragments whose `$NAME` expansion is treated as secret exfiltration. Substring, case-insensitive. */
|
|
148
|
+
const SENSITIVE_VAR_FRAGMENTS = [
|
|
149
|
+
'secret',
|
|
150
|
+
'token',
|
|
151
|
+
'password',
|
|
152
|
+
'passwd',
|
|
153
|
+
'api_key',
|
|
154
|
+
'apikey',
|
|
155
|
+
'access_key',
|
|
156
|
+
'credential',
|
|
157
|
+
'private_key',
|
|
158
|
+
'aws_',
|
|
159
|
+
'ssh_',
|
|
160
|
+
'session',
|
|
161
|
+
];
|
|
162
|
+
/** Transparent command wrappers that don't change what runs. */
|
|
163
|
+
const WRAPPERS = ['timeout', 'nice', 'nohup', 'stdbuf', 'env'];
|
|
164
|
+
/** Split whitespace, dropping empty tokens (the `split_whitespace` equivalent). */
|
|
165
|
+
function tokenize(s) {
|
|
166
|
+
return s.split(/\s+/).filter((t) => t.length > 0);
|
|
167
|
+
}
|
|
168
|
+
/** Pull a string argument by any of the given keys, or `''` when absent. */
|
|
169
|
+
function strArg(args, keys) {
|
|
170
|
+
for (const k of keys) {
|
|
171
|
+
const v = args[k];
|
|
172
|
+
if (typeof v === 'string')
|
|
173
|
+
return v;
|
|
174
|
+
}
|
|
175
|
+
return '';
|
|
176
|
+
}
|
|
177
|
+
/** Match a domain against a suffix list (exact or subdomain), case-insensitive. */
|
|
178
|
+
export function domainMatchesSuffixList(domain, suffixes) {
|
|
179
|
+
const d = domain.toLowerCase();
|
|
180
|
+
return suffixes.some((suffix) => {
|
|
181
|
+
const s = suffix.toLowerCase();
|
|
182
|
+
return d === s || d.endsWith(`.${s}`) || (s.startsWith('.') && d.endsWith(s));
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* Split a shell command line into subcommands on the operators that sequence
|
|
187
|
+
* independent commands: `&&`, `||`, `;`, `|`, `&`, and newlines. Command /
|
|
188
|
+
* process substitution (`$(…)`, `` `…` ``, `<(…)`) is surfaced as its own
|
|
189
|
+
* segment so it can't ride in on a safe outer command.
|
|
190
|
+
*
|
|
191
|
+
* ponytail: substring split, not a real shell lexer — upgrade only if quoting
|
|
192
|
+
* edge-cases (`echo "a && b"`) start mattering for policy.
|
|
193
|
+
*/
|
|
194
|
+
export function splitCompound(command) {
|
|
195
|
+
let normalized = command.replace(/&&/g, '').replace(/\|\|/g, '');
|
|
196
|
+
if (normalized.includes('$(') || normalized.includes('<(') || normalized.includes('`')) {
|
|
197
|
+
normalized = normalized
|
|
198
|
+
.replace(/\$\(/g, '')
|
|
199
|
+
.replace(/<\(/g, '')
|
|
200
|
+
.replace(/[`)]/g, '');
|
|
201
|
+
}
|
|
202
|
+
return normalized
|
|
203
|
+
.split(/[;|&\n]/)
|
|
204
|
+
.map((s) => s.trim().replace(/^["']+|["']+$/g, '').trim())
|
|
205
|
+
.filter((s) => s.length > 0);
|
|
206
|
+
}
|
|
207
|
+
/** Strip leading command wrappers; returns the index of the real command token. */
|
|
208
|
+
function stripWrappers(tokens) {
|
|
209
|
+
let i = 0;
|
|
210
|
+
while (i < tokens.length && WRAPPERS.includes(tokens[i])) {
|
|
211
|
+
i += 1;
|
|
212
|
+
while (i < tokens.length && (tokens[i].startsWith('-') || /^[0-9]/.test(tokens[i])))
|
|
213
|
+
i += 1;
|
|
214
|
+
}
|
|
215
|
+
return i;
|
|
216
|
+
}
|
|
217
|
+
/** First meaningful token of a subcommand (after stripping wrappers). */
|
|
218
|
+
function commandBin(subcommand) {
|
|
219
|
+
const tokens = tokenize(subcommand);
|
|
220
|
+
const start = stripWrappers(tokens);
|
|
221
|
+
return tokens[start];
|
|
222
|
+
}
|
|
223
|
+
/** Pull a bare hostname out of a URL-ish or `host:port` token. */
|
|
224
|
+
export function hostFromToken(tok) {
|
|
225
|
+
const schemeIdx = tok.indexOf('://');
|
|
226
|
+
const afterScheme = schemeIdx >= 0 ? tok.slice(schemeIdx + 3) : tok;
|
|
227
|
+
const at = afterScheme.lastIndexOf('@');
|
|
228
|
+
const afterUserinfo = at >= 0 ? afterScheme.slice(at + 1) : afterScheme;
|
|
229
|
+
const host = (afterUserinfo.split(/[/:?#]/)[0] ?? '').trim();
|
|
230
|
+
if (host.length === 0)
|
|
231
|
+
return undefined;
|
|
232
|
+
if (host === 'localhost' || (host.includes('.') && !host.startsWith('.') && !host.endsWith('.'))) {
|
|
233
|
+
return host.toLowerCase();
|
|
234
|
+
}
|
|
235
|
+
return undefined;
|
|
236
|
+
}
|
|
237
|
+
/** Extract candidate hostnames from a single (already split) net-tool subcommand. */
|
|
238
|
+
export function extractHosts(subcommand) {
|
|
239
|
+
const tokens = tokenize(subcommand);
|
|
240
|
+
const start = stripWrappers(tokens);
|
|
241
|
+
const bin = tokens[start];
|
|
242
|
+
if (bin === undefined || !NET_BASH_BINS.includes(bin))
|
|
243
|
+
return [];
|
|
244
|
+
return tokens
|
|
245
|
+
.slice(start + 1)
|
|
246
|
+
.filter((t) => !t.startsWith('-'))
|
|
247
|
+
.map(hostFromToken)
|
|
248
|
+
.filter((h) => h !== undefined);
|
|
249
|
+
}
|
|
250
|
+
/** The effective binary of a pipe segment, skipping a leading `sudo` and the usual wrappers. */
|
|
251
|
+
function sinkBin(segment) {
|
|
252
|
+
const tokens = tokenize(segment);
|
|
253
|
+
let i = stripWrappers(tokens);
|
|
254
|
+
while (i < tokens.length && tokens[i] === 'sudo') {
|
|
255
|
+
i += 1;
|
|
256
|
+
while (i < tokens.length && tokens[i].startsWith('-'))
|
|
257
|
+
i += 1;
|
|
258
|
+
}
|
|
259
|
+
return tokens[i];
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* Does this whole command line pipe a network fetch into a shell interpreter
|
|
263
|
+
* (`curl … | sh`)? A hard circuit-breaker regardless of the specific host.
|
|
264
|
+
*/
|
|
265
|
+
function isPipeToShell(command) {
|
|
266
|
+
if (!command.includes('|'))
|
|
267
|
+
return false;
|
|
268
|
+
let sawFetch = false;
|
|
269
|
+
for (const seg of command.split('|')) {
|
|
270
|
+
const bin = sinkBin(seg.trim());
|
|
271
|
+
if (bin === undefined)
|
|
272
|
+
continue;
|
|
273
|
+
if (sawFetch && SHELL_INTERPRETERS.includes(bin))
|
|
274
|
+
return true;
|
|
275
|
+
if (NET_BASH_BINS.includes(bin))
|
|
276
|
+
sawFetch = true;
|
|
277
|
+
}
|
|
278
|
+
return false;
|
|
279
|
+
}
|
|
280
|
+
/**
|
|
281
|
+
* Strip leading transparent wrappers and any leading `sudo` from a single
|
|
282
|
+
* subcommand, returning the remaining command text. Used by the deny policy so a
|
|
283
|
+
* rule anchored on the real binary (`aws …`) still matches `sudo aws …` /
|
|
284
|
+
* `timeout 5 aws …`.
|
|
285
|
+
*/
|
|
286
|
+
export function stripWrappersAndSudo(subcommand) {
|
|
287
|
+
const tokens = tokenize(subcommand);
|
|
288
|
+
let i = stripWrappers(tokens);
|
|
289
|
+
while (i < tokens.length && tokens[i] === 'sudo') {
|
|
290
|
+
i += 1;
|
|
291
|
+
while (i < tokens.length && tokens[i].startsWith('-'))
|
|
292
|
+
i += 1;
|
|
293
|
+
}
|
|
294
|
+
return tokens.slice(i).join(' ');
|
|
295
|
+
}
|
|
296
|
+
/** Does the command reference a sensitive credential path? */
|
|
297
|
+
function referencesSensitivePath(command) {
|
|
298
|
+
const lower = command.toLowerCase();
|
|
299
|
+
if (SENSITIVE_PATH_SUBSTRINGS.some((p) => lower.includes(p.toLowerCase())))
|
|
300
|
+
return true;
|
|
301
|
+
// `.env` / `.envrc` / `.env.local` dotenv files are secret stores too.
|
|
302
|
+
// Token-scoped so `rg "process.env" src/` isn't flagged.
|
|
303
|
+
return lower.split(/\s+/).some((t) => {
|
|
304
|
+
const tt = t.replace(/^[";'()]+|[";'()]+$/g, '');
|
|
305
|
+
return tt.startsWith('.env') || tt.includes('/.env');
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
/** True if the text contains a `$NAME` / `${NAME}` expansion whose name matches a sensitive fragment. */
|
|
309
|
+
function containsSensitiveVarExpansion(text) {
|
|
310
|
+
const lower = text.toLowerCase();
|
|
311
|
+
let idx = 0;
|
|
312
|
+
for (;;) {
|
|
313
|
+
const rel = lower.indexOf('$', idx);
|
|
314
|
+
if (rel < 0)
|
|
315
|
+
break;
|
|
316
|
+
let j = rel + 1;
|
|
317
|
+
if (lower[j] === '{')
|
|
318
|
+
j += 1;
|
|
319
|
+
const nameStart = j;
|
|
320
|
+
while (j < lower.length && /[a-z0-9_]/.test(lower[j]))
|
|
321
|
+
j += 1;
|
|
322
|
+
const name = lower.slice(nameStart, j);
|
|
323
|
+
if (name.length > 0 && SENSITIVE_VAR_FRAGMENTS.some((f) => name.includes(f)))
|
|
324
|
+
return true;
|
|
325
|
+
idx = rel + 1;
|
|
326
|
+
}
|
|
327
|
+
return false;
|
|
328
|
+
}
|
|
329
|
+
/**
|
|
330
|
+
* Does this single (already split) subcommand reveal the process environment?
|
|
331
|
+
* Matches on intent, not a single binary name. Does NOT match the legitimate
|
|
332
|
+
* setter forms (`env FOO=bar cmd`, `export FOO=bar`, `set -euo pipefail`).
|
|
333
|
+
*/
|
|
334
|
+
function dumpsEnvironment(subcommand) {
|
|
335
|
+
const toks = tokenize(subcommand);
|
|
336
|
+
if (toks.length === 0)
|
|
337
|
+
return false;
|
|
338
|
+
const lower = subcommand.toLowerCase();
|
|
339
|
+
if (lower.includes('proc/') && lower.includes('/environ'))
|
|
340
|
+
return true;
|
|
341
|
+
// Skip transparent wrappers (but NOT `env`, the subject here).
|
|
342
|
+
let i = 0;
|
|
343
|
+
while (i < toks.length && (toks[i] === 'timeout' || toks[i] === 'nice' || toks[i] === 'nohup' || toks[i] === 'stdbuf')) {
|
|
344
|
+
i += 1;
|
|
345
|
+
while (i < toks.length && (toks[i].startsWith('-') || /^[0-9]/.test(toks[i])))
|
|
346
|
+
i += 1;
|
|
347
|
+
}
|
|
348
|
+
const bin = toks[i];
|
|
349
|
+
if (bin === undefined)
|
|
350
|
+
return false;
|
|
351
|
+
const rest = toks.slice(i + 1);
|
|
352
|
+
switch (bin) {
|
|
353
|
+
case 'printenv':
|
|
354
|
+
return true;
|
|
355
|
+
case 'env': {
|
|
356
|
+
let k = 0;
|
|
357
|
+
while (k < rest.length) {
|
|
358
|
+
const t = rest[k];
|
|
359
|
+
if (t === '-u' || t === '-S') {
|
|
360
|
+
k += 2;
|
|
361
|
+
}
|
|
362
|
+
else if (t.startsWith('-') || t.includes('=') || t === '-') {
|
|
363
|
+
k += 1;
|
|
364
|
+
}
|
|
365
|
+
else {
|
|
366
|
+
return false; // a bare command token → setter form
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
return true;
|
|
370
|
+
}
|
|
371
|
+
case 'export':
|
|
372
|
+
case 'declare':
|
|
373
|
+
case 'typeset':
|
|
374
|
+
return !rest.some((t) => t.includes('=')) && rest.every((t) => t.startsWith('-'));
|
|
375
|
+
case 'set':
|
|
376
|
+
return rest.length === 0;
|
|
377
|
+
case 'echo':
|
|
378
|
+
case 'printf':
|
|
379
|
+
return containsSensitiveVarExpansion(subcommand);
|
|
380
|
+
default:
|
|
381
|
+
return false;
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
/** Is this single subcommand a compiled-in safe read-only command? */
|
|
385
|
+
function isSafeReadonlyBash(subcommand) {
|
|
386
|
+
const bin = commandBin(subcommand);
|
|
387
|
+
if (bin === undefined)
|
|
388
|
+
return false;
|
|
389
|
+
if (bin === 'find') {
|
|
390
|
+
const FIND_ACTION_FLAGS = ['-exec', '-execdir', '-ok', '-okdir', '-delete', '-fprint', '-fprintf', '-fls'];
|
|
391
|
+
return !tokenize(subcommand).some((t) => FIND_ACTION_FLAGS.includes(t));
|
|
392
|
+
}
|
|
393
|
+
if (SAFE_BASH_BINS.includes(bin))
|
|
394
|
+
return true;
|
|
395
|
+
if (bin === 'git') {
|
|
396
|
+
const tokens = tokenize(subcommand);
|
|
397
|
+
const start = stripWrappers(tokens);
|
|
398
|
+
let j = start + 1;
|
|
399
|
+
while (j < tokens.length && tokens[j].startsWith('-'))
|
|
400
|
+
j += 2; // `-c key=val` / `-C dir`
|
|
401
|
+
const sub = tokens[j];
|
|
402
|
+
if (sub === undefined)
|
|
403
|
+
return false;
|
|
404
|
+
if (!SAFE_GIT_SUBCOMMANDS.includes(sub))
|
|
405
|
+
return false;
|
|
406
|
+
if (sub === 'branch' || sub === 'remote') {
|
|
407
|
+
return tokens.slice(j + 1).every((t) => GIT_LIST_ONLY_FLAGS.includes(t));
|
|
408
|
+
}
|
|
409
|
+
return true;
|
|
410
|
+
}
|
|
411
|
+
return false;
|
|
412
|
+
}
|
|
413
|
+
/** Evaluate a single bash subcommand against the layered policy. */
|
|
414
|
+
function decideBashSubcommand(subcommand) {
|
|
415
|
+
if (referencesSensitivePath(subcommand)) {
|
|
416
|
+
return deny(`command references a sensitive credential path: ${subcommand}`);
|
|
417
|
+
}
|
|
418
|
+
if (dumpsEnvironment(subcommand)) {
|
|
419
|
+
return deny(`command reveals the process environment (secret exfiltration risk): ${subcommand}`);
|
|
420
|
+
}
|
|
421
|
+
const lower = subcommand.toLowerCase();
|
|
422
|
+
const needle = DANGEROUS_CLI_SUBSTRINGS.find((n) => lower.includes(n.toLowerCase()));
|
|
423
|
+
if (needle !== undefined)
|
|
424
|
+
return deny(`command matches dangerous-cli pattern: ${needle}`);
|
|
425
|
+
const hosts = extractHosts(subcommand);
|
|
426
|
+
for (const host of hosts) {
|
|
427
|
+
if (domainMatchesSuffixList(host, DANGEROUS_DOMAIN_SUFFIXES)) {
|
|
428
|
+
return deny(`${host} is on the dangerous-domain deny list`);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
if (hosts.length > 0) {
|
|
432
|
+
return ask(`outbound request to ${hosts[0]} needs approval`);
|
|
433
|
+
}
|
|
434
|
+
if (isSafeReadonlyBash(subcommand))
|
|
435
|
+
return ALLOW;
|
|
436
|
+
const bin = commandBin(subcommand) ?? '';
|
|
437
|
+
return ask(`\`${bin}\` is not a known-safe command`);
|
|
438
|
+
}
|
|
439
|
+
/** Evaluate a whole (possibly compound) bash command line. The strictest verdict wins (deny > ask > allow). */
|
|
440
|
+
function decideBash(command) {
|
|
441
|
+
// Whole-line dangerous-substring scan FIRST — some breakers (the fork bomb,
|
|
442
|
+
// `| sudo sh`) contain the very operators splitCompound divides on.
|
|
443
|
+
const lowerLine = command.toLowerCase();
|
|
444
|
+
const needle = DANGEROUS_CLI_SUBSTRINGS.find((n) => lowerLine.includes(n.toLowerCase()));
|
|
445
|
+
if (needle !== undefined)
|
|
446
|
+
return deny(`command matches dangerous-cli pattern: ${needle}`);
|
|
447
|
+
if (isPipeToShell(command))
|
|
448
|
+
return deny(`pipe-to-shell execution is blocked: ${command}`);
|
|
449
|
+
const subs = splitCompound(command);
|
|
450
|
+
if (subs.length === 0)
|
|
451
|
+
return deny('empty command');
|
|
452
|
+
let pendingAsk;
|
|
453
|
+
for (const sub of subs) {
|
|
454
|
+
const v = decideBashSubcommand(sub);
|
|
455
|
+
if (v.kind === 'deny')
|
|
456
|
+
return v;
|
|
457
|
+
if (v.kind === 'ask' && pendingAsk === undefined)
|
|
458
|
+
pendingAsk = v.reason;
|
|
459
|
+
}
|
|
460
|
+
return pendingAsk === undefined ? ALLOW : ask(pendingAsk);
|
|
461
|
+
}
|
|
462
|
+
export function toolCategory(name) {
|
|
463
|
+
// Extension tools are dotted `<ext>.<tool>`; classify on the bare tool name.
|
|
464
|
+
const bare = name.includes('.') ? name.slice(name.lastIndexOf('.') + 1) : name;
|
|
465
|
+
const n = bare.toLowerCase();
|
|
466
|
+
if (n === 'bash' || n === 'shell' || n === 'shell_exec' || n === 'run_command')
|
|
467
|
+
return 'bash';
|
|
468
|
+
if (n.includes('write') || n.includes('edit') || n.includes('delete') || n.includes('remove') || n === 'apply_patch' || n === 'create_file') {
|
|
469
|
+
return 'write';
|
|
470
|
+
}
|
|
471
|
+
if (n.includes('fetch') || n.includes('download') || n.startsWith('http'))
|
|
472
|
+
return 'network';
|
|
473
|
+
if (n.startsWith('read') || n.startsWith('list') || n.startsWith('get') || n.includes('search') || n === 'grep' || n === 'glob')
|
|
474
|
+
return 'safe';
|
|
475
|
+
return 'unknown';
|
|
476
|
+
}
|
|
477
|
+
function decideInner(toolName, args) {
|
|
478
|
+
switch (toolCategory(toolName)) {
|
|
479
|
+
case 'bash': {
|
|
480
|
+
const cmd = strArg(args, ['cmd', 'command']).trim();
|
|
481
|
+
if (cmd.length === 0)
|
|
482
|
+
return deny('bash call with no command');
|
|
483
|
+
return decideBash(cmd);
|
|
484
|
+
}
|
|
485
|
+
case 'safe': {
|
|
486
|
+
// Read-only is not exfil-proof: the read path IS the exfil path.
|
|
487
|
+
for (const key of ['path', 'file', 'dir', 'directory']) {
|
|
488
|
+
const v = args[key];
|
|
489
|
+
if (typeof v === 'string' && referencesSensitivePath(v)) {
|
|
490
|
+
return deny(`${toolName} targets a sensitive credential path: ${v}`);
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
return ALLOW;
|
|
494
|
+
}
|
|
495
|
+
case 'network': {
|
|
496
|
+
const url = strArg(args, ['url', 'host']);
|
|
497
|
+
const host = hostFromToken(url) ?? url;
|
|
498
|
+
if (host.length === 0)
|
|
499
|
+
return deny(`${toolName} call with no url/host`);
|
|
500
|
+
if (domainMatchesSuffixList(host, DANGEROUS_DOMAIN_SUFFIXES))
|
|
501
|
+
return deny(`${host} is on the dangerous-domain deny list`);
|
|
502
|
+
return ask(`outbound request to ${host} needs approval`);
|
|
503
|
+
}
|
|
504
|
+
case 'write': {
|
|
505
|
+
const path = strArg(args, ['path', 'file']);
|
|
506
|
+
if (referencesSensitivePath(path))
|
|
507
|
+
return deny(`write to a sensitive credential path: ${path}`);
|
|
508
|
+
return ask(`\`${toolName}\` mutates the filesystem`);
|
|
509
|
+
}
|
|
510
|
+
case 'unknown':
|
|
511
|
+
return ask(`\`${toolName}\` is not a recognised safe tool`);
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
/**
|
|
515
|
+
* The pure, deterministic permission decision. No async, no I/O — the
|
|
516
|
+
* security-critical core, tested exhaustively.
|
|
517
|
+
*
|
|
518
|
+
* `args` is the raw tool-call argument object; the relevant field is pulled per
|
|
519
|
+
* category (`cmd` for bash, `path` for writes, `url`/`host` for network).
|
|
520
|
+
*/
|
|
521
|
+
export function decide(mode, toolName, args) {
|
|
522
|
+
const raw = decideInner(toolName, args);
|
|
523
|
+
// Deny always survives, every mode.
|
|
524
|
+
if (raw.kind === 'deny')
|
|
525
|
+
return raw;
|
|
526
|
+
// Bypass downgrades any surviving Ask to Allow (breakers already denied above).
|
|
527
|
+
if (mode === AutoMode.Bypass)
|
|
528
|
+
return ALLOW;
|
|
529
|
+
if (mode === AutoMode.AcceptEdits && raw.kind === 'ask' && toolCategory(toolName) === 'write')
|
|
530
|
+
return ALLOW;
|
|
531
|
+
if (mode === AutoMode.DenyUnmatched && raw.kind === 'ask')
|
|
532
|
+
return deny(`headless (no interactive approver): ${raw.reason}`);
|
|
533
|
+
return raw;
|
|
534
|
+
}
|
|
535
|
+
// ---------------------------------------------------------------------------
|
|
536
|
+
// Grant derivation — map an `Ask` to a persistable grant and check whether a
|
|
537
|
+
// stored grant already covers it. Never derives from a `Deny`.
|
|
538
|
+
// ---------------------------------------------------------------------------
|
|
539
|
+
/** The grant a single asking bash subcommand maps to. */
|
|
540
|
+
function bashSegmentGrant(sub) {
|
|
541
|
+
const host = extractHosts(sub)[0];
|
|
542
|
+
if (host !== undefined)
|
|
543
|
+
return { kind: 'network', host };
|
|
544
|
+
return { kind: 'bash', prefix: `${commandBin(sub) ?? ''} ` };
|
|
545
|
+
}
|
|
546
|
+
/**
|
|
547
|
+
* The grant that "approve always" would persist for this tool call, or
|
|
548
|
+
* `undefined` when the call is not an `Ask`.
|
|
549
|
+
*/
|
|
550
|
+
function grantQuery(toolName, args) {
|
|
551
|
+
switch (toolCategory(toolName)) {
|
|
552
|
+
case 'bash': {
|
|
553
|
+
const cmd = strArg(args, ['cmd', 'command']).trim();
|
|
554
|
+
for (const sub of splitCompound(cmd)) {
|
|
555
|
+
const v = decideBashSubcommand(sub);
|
|
556
|
+
if (v.kind === 'ask')
|
|
557
|
+
return bashSegmentGrant(sub);
|
|
558
|
+
if (v.kind === 'deny')
|
|
559
|
+
return undefined; // a deny sinks the line
|
|
560
|
+
}
|
|
561
|
+
return undefined;
|
|
562
|
+
}
|
|
563
|
+
case 'network': {
|
|
564
|
+
const url = strArg(args, ['url', 'host']);
|
|
565
|
+
const host = hostFromToken(url) ?? url;
|
|
566
|
+
return host.length > 0 ? { kind: 'network', host } : undefined;
|
|
567
|
+
}
|
|
568
|
+
case 'write':
|
|
569
|
+
case 'unknown':
|
|
570
|
+
return { kind: 'tool', tool: toolName };
|
|
571
|
+
case 'safe':
|
|
572
|
+
return undefined;
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
/** Is a single asking bash subcommand covered by a stored grant? */
|
|
576
|
+
function bashSegmentGranted(sub, grants) {
|
|
577
|
+
const host = extractHosts(sub)[0];
|
|
578
|
+
if (host !== undefined)
|
|
579
|
+
return grants.matchesHost(host);
|
|
580
|
+
return grants.matchesBash(sub);
|
|
581
|
+
}
|
|
582
|
+
/**
|
|
583
|
+
* Is this whole tool call already covered by stored grants? For compound bash,
|
|
584
|
+
* **every** asking segment must be granted (a granted first segment must not
|
|
585
|
+
* silently waive an ungranted second one).
|
|
586
|
+
*/
|
|
587
|
+
function coveredByGrants(grants, toolName, args) {
|
|
588
|
+
switch (toolCategory(toolName)) {
|
|
589
|
+
case 'bash': {
|
|
590
|
+
const cmd = strArg(args, ['cmd', 'command']).trim();
|
|
591
|
+
const subs = splitCompound(cmd);
|
|
592
|
+
if (subs.length === 0)
|
|
593
|
+
return false;
|
|
594
|
+
return subs.every((sub) => {
|
|
595
|
+
const v = decideBashSubcommand(sub);
|
|
596
|
+
if (v.kind === 'allow')
|
|
597
|
+
return true;
|
|
598
|
+
if (v.kind === 'deny')
|
|
599
|
+
return false; // never auto-allow a deny
|
|
600
|
+
return bashSegmentGranted(sub, grants);
|
|
601
|
+
});
|
|
602
|
+
}
|
|
603
|
+
case 'network': {
|
|
604
|
+
const url = strArg(args, ['url', 'host']);
|
|
605
|
+
const host = hostFromToken(url) ?? url;
|
|
606
|
+
return host.length > 0 && grants.matchesHost(host);
|
|
607
|
+
}
|
|
608
|
+
case 'write':
|
|
609
|
+
case 'unknown':
|
|
610
|
+
return grants.matchesTool(toolName);
|
|
611
|
+
case 'safe':
|
|
612
|
+
return false;
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
// ---------------------------------------------------------------------------
|
|
616
|
+
// The hook
|
|
617
|
+
// ---------------------------------------------------------------------------
|
|
618
|
+
/**
|
|
619
|
+
* A {@link ToolHook} that enforces {@link decide} on every tool call.
|
|
620
|
+
*
|
|
621
|
+
* **`Ask` routing**: with an approver wired via {@link PermissionHook.withApprover}
|
|
622
|
+
* (the existing {@link HumanGate} seam), an `Ask` verdict prompts a human and
|
|
623
|
+
* blocks until they approve; with no approver it **fails closed** (throws).
|
|
624
|
+
* {@link AutoMode.Bypass} / {@link AutoMode.AcceptEdits} downgrade eligible asks
|
|
625
|
+
* inside {@link decide} before they reach the approver. A `Deny` always blocks
|
|
626
|
+
* and is never routed to the human — circuit-breakers are not waivable. A
|
|
627
|
+
* consumer {@link DenyPolicy}, when attached, is evaluated FIRST and is a
|
|
628
|
+
* circuit-breaker of the same tier.
|
|
629
|
+
*/
|
|
630
|
+
export class PermissionHook {
|
|
631
|
+
autoMode;
|
|
632
|
+
approver;
|
|
633
|
+
grants;
|
|
634
|
+
denyPolicyRef;
|
|
635
|
+
constructor(autoMode = AutoMode.Ask) {
|
|
636
|
+
this.autoMode = autoMode;
|
|
637
|
+
}
|
|
638
|
+
/** Build a hook reading the mode from `SMOOTH_AUTO_MODE` (default `Ask`). */
|
|
639
|
+
static fromEnv() {
|
|
640
|
+
return new PermissionHook(autoModeFromEnv());
|
|
641
|
+
}
|
|
642
|
+
/**
|
|
643
|
+
* Wire an interactive approver. When set, an `Ask` verdict consults the
|
|
644
|
+
* {@link HumanGate} and blocks on its response — approve lets the call run,
|
|
645
|
+
* anything else blocks it. Answering with `remember: true`
|
|
646
|
+
* ({@link approveAlways}) persists a grant when {@link withGrants} is set.
|
|
647
|
+
*/
|
|
648
|
+
withApprover(gate) {
|
|
649
|
+
this.approver = gate;
|
|
650
|
+
return this;
|
|
651
|
+
}
|
|
652
|
+
/**
|
|
653
|
+
* Wire the in-memory allow-list. A matching grant auto-approves an `Ask`
|
|
654
|
+
* *before* prompting; an `approve always` answer adds a fresh grant so the
|
|
655
|
+
* next identical `Ask` is silent. The consumer owns persisting `grants`.
|
|
656
|
+
*/
|
|
657
|
+
withGrants(grants) {
|
|
658
|
+
this.grants = grants;
|
|
659
|
+
return this;
|
|
660
|
+
}
|
|
661
|
+
/**
|
|
662
|
+
* Attach a consumer {@link DenyPolicy}. Purely additive: with none attached
|
|
663
|
+
* enforcement is identical to before. When set it is evaluated **first** — a
|
|
664
|
+
* match is a hard deny (circuit-breaker tier) no grant can waive and no
|
|
665
|
+
* {@link AutoMode} can downgrade.
|
|
666
|
+
*/
|
|
667
|
+
withDenyPolicy(policy) {
|
|
668
|
+
this.denyPolicyRef = policy;
|
|
669
|
+
return this;
|
|
670
|
+
}
|
|
671
|
+
/** The mode this hook enforces. */
|
|
672
|
+
get mode() {
|
|
673
|
+
return this.autoMode;
|
|
674
|
+
}
|
|
675
|
+
async preCall(call) {
|
|
676
|
+
// Deny policy runs FIRST — a consumer deny is a circuit-breaker that wins
|
|
677
|
+
// over grants, ask, allow, and every mode (Bypass included). Never routed
|
|
678
|
+
// to a human, never grantable.
|
|
679
|
+
if (this.denyPolicyRef) {
|
|
680
|
+
const reason = this.denyPolicyRef.evaluate(call);
|
|
681
|
+
if (reason !== undefined)
|
|
682
|
+
throw new Error(`permission denied: ${reason}`);
|
|
683
|
+
}
|
|
684
|
+
const verdict = decide(this.autoMode, call.name, call.arguments);
|
|
685
|
+
if (verdict.kind === 'allow')
|
|
686
|
+
return;
|
|
687
|
+
// Deny is a circuit-breaker — never routed to a human, never grantable.
|
|
688
|
+
if (verdict.kind === 'deny')
|
|
689
|
+
throw new Error(`permission denied: ${verdict.reason}`);
|
|
690
|
+
// Ask: consult the persisted allow-list FIRST — a stored grant auto-approves silently.
|
|
691
|
+
if (this.grants && coveredByGrants(this.grants, call.name, call.arguments))
|
|
692
|
+
return;
|
|
693
|
+
if (this.approver === undefined) {
|
|
694
|
+
throw new Error(`permission requires approval (fail-closed, no approver): ${verdict.reason}`);
|
|
695
|
+
}
|
|
696
|
+
const response = await this.approver({
|
|
697
|
+
toolName: call.name,
|
|
698
|
+
arguments: call.arguments,
|
|
699
|
+
prompt: `Permission: ${verdict.reason}. Allow \`${call.name}\`?`,
|
|
700
|
+
});
|
|
701
|
+
if (!isApproved(response)) {
|
|
702
|
+
throw new Error(`user denied: ${response.reason ?? 'no reason given'}`);
|
|
703
|
+
}
|
|
704
|
+
if (response.remember === true)
|
|
705
|
+
this.persistGrant(call);
|
|
706
|
+
}
|
|
707
|
+
/** Add an approve-always grant to the in-memory allow-list. */
|
|
708
|
+
persistGrant(call) {
|
|
709
|
+
if (this.grants === undefined)
|
|
710
|
+
return;
|
|
711
|
+
const query = grantQuery(call.name, call.arguments);
|
|
712
|
+
if (query !== undefined)
|
|
713
|
+
this.grants.add(query);
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
//# sourceMappingURL=permission.js.map
|