@tinycloud/courier 0.2.0-beta.37 → 0.2.0-beta.38
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/README.md +331 -12
- package/dist/a2a.d.ts +6 -0
- package/dist/a2a.js +10 -0
- package/dist/a2a.js.map +1 -1
- package/dist/approval.d.ts +6 -5
- package/dist/approval.js +7 -16
- package/dist/approval.js.map +1 -1
- package/dist/args.js +1 -1
- package/dist/args.js.map +1 -1
- package/dist/broker-config.d.ts +24 -0
- package/dist/broker-config.js +99 -0
- package/dist/broker-config.js.map +1 -0
- package/dist/broker.d.ts +77 -0
- package/dist/broker.js +253 -0
- package/dist/broker.js.map +1 -0
- package/dist/commands.js +5 -2
- package/dist/commands.js.map +1 -1
- package/dist/consumed.d.ts +10 -0
- package/dist/consumed.js +54 -0
- package/dist/consumed.js.map +1 -0
- package/dist/guard/binaries.d.ts +17 -0
- package/dist/guard/binaries.js +89 -0
- package/dist/guard/binaries.js.map +1 -0
- package/dist/guard/cleanhome.d.ts +27 -0
- package/dist/guard/cleanhome.js +111 -0
- package/dist/guard/cleanhome.js.map +1 -0
- package/dist/guard/command.d.ts +13 -0
- package/dist/guard/command.js +158 -0
- package/dist/guard/command.js.map +1 -0
- package/dist/guard/expansion.d.ts +10 -0
- package/dist/guard/expansion.js +74 -0
- package/dist/guard/expansion.js.map +1 -0
- package/dist/guard/hook.d.ts +38 -0
- package/dist/guard/hook.js +124 -0
- package/dist/guard/hook.js.map +1 -0
- package/dist/guard/install.d.ts +67 -0
- package/dist/guard/install.js +193 -0
- package/dist/guard/install.js.map +1 -0
- package/dist/guard/network.d.ts +7 -0
- package/dist/guard/network.js +78 -0
- package/dist/guard/network.js.map +1 -0
- package/dist/guard/normalize.d.ts +28 -0
- package/dist/guard/normalize.js +257 -0
- package/dist/guard/normalize.js.map +1 -0
- package/dist/guard/paseo.d.ts +30 -0
- package/dist/guard/paseo.js +148 -0
- package/dist/guard/paseo.js.map +1 -0
- package/dist/guard/paths.d.ts +31 -0
- package/dist/guard/paths.js +180 -0
- package/dist/guard/paths.js.map +1 -0
- package/dist/guard/policy.d.ts +65 -0
- package/dist/guard/policy.js +1179 -0
- package/dist/guard/policy.js.map +1 -0
- package/dist/guard/shell.d.ts +55 -0
- package/dist/guard/shell.js +727 -0
- package/dist/guard/shell.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/keys.d.ts +4 -1
- package/dist/keys.js +7 -2
- package/dist/keys.js.map +1 -1
- package/dist/main.js +44 -4
- package/dist/main.js.map +1 -1
- package/dist/node.d.ts +1 -1
- package/dist/node.js +3 -3
- package/dist/node.js.map +1 -1
- package/dist/notify.d.ts +39 -0
- package/dist/notify.js +88 -0
- package/dist/notify.js.map +1 -0
- package/dist/pause.d.ts +26 -0
- package/dist/pause.js +99 -0
- package/dist/pause.js.map +1 -0
- package/dist/profile.d.ts +3 -3
- package/dist/profile.js +12 -12
- package/dist/profile.js.map +1 -1
- package/dist/wake.d.ts +17 -0
- package/dist/wake.js +74 -13
- package/dist/wake.js.map +1 -1
- package/dist/watch-cmd.d.ts +3 -0
- package/dist/watch-cmd.js +56 -2
- package/dist/watch-cmd.js.map +1 -1
- package/dist/watch.d.ts +70 -0
- package/dist/watch.js +292 -11
- package/dist/watch.js.map +1 -1
- package/package.json +1 -1
- package/skills/courier/SKILL.md +94 -8
|
@@ -0,0 +1,1179 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { networkTextRefusal, blockedDestination } from './network.js';
|
|
3
|
+
import { binaryName, gatedSource, registeredFile } from './binaries.js';
|
|
4
|
+
import { parseShell, wordLiteral } from './shell.js';
|
|
5
|
+
import { expansionRefusal } from './expansion.js';
|
|
6
|
+
import { credentialTextRefusal, protectedTextRefusal, realpathBest, unquoted, pathTextRefusal } from './paths.js';
|
|
7
|
+
import { parseArgs } from '../args.js';
|
|
8
|
+
import { ULID } from '../request.js';
|
|
9
|
+
import { normApi, normChangeset, normGhPrMerge, normGhRelease, normGhWorkflowRun, normPhala, normPublish, normPush, normScript, normWrangler, releaseIsReadOnly, } from './normalize.js';
|
|
10
|
+
/** The exec opt-out never starts a second agent CLI. */
|
|
11
|
+
export function startsAgentCli(command) {
|
|
12
|
+
const head = parseShell(command).cmds[0]?.words[0];
|
|
13
|
+
const literal = head ? wordLiteral(head) : undefined;
|
|
14
|
+
return literal !== undefined && AGENT_CLIS.has(progName(literal));
|
|
15
|
+
}
|
|
16
|
+
const MAX_DEPTH = 6;
|
|
17
|
+
const PLACEHOLDER = '\u0001';
|
|
18
|
+
// ---------- tables ----------
|
|
19
|
+
const SHELLS = new Set(['sh', 'bash', 'dash', 'zsh', 'ksh', 'mksh', 'ash', 'csh', 'tcsh', 'fish']);
|
|
20
|
+
/** Programs that start another coding agent. A woken agent may not launch one: it would run outside the guard. */
|
|
21
|
+
const AGENT_CLIS = new Set(['paseo', 'claude', 'codex', 'omp', 'opencode', 'gemini', 'cursor-agent', 'amp', 'aider', 'goose']);
|
|
22
|
+
/** Programs that run another program given in their arguments. */
|
|
23
|
+
const WRAPPERS = new Set([
|
|
24
|
+
'env', 'command', 'builtin', 'exec', 'nohup', 'time', 'nice', 'ionice', 'setsid', 'stdbuf', 'timeout',
|
|
25
|
+
'unbuffer', 'chronic', 'flock', 'script', 'strace', 'ltrace', 'taskset', 'chrt',
|
|
26
|
+
'watch', 'parallel', 'xargs', 'find', 'trap', 'caffeinate', 'xvfb-run',
|
|
27
|
+
'dbus-run-session', 'xdotool', 'nsjail', 'firejail', 'busybox', 'toybox', 'entr', 'cronic', 'cpulimit',
|
|
28
|
+
]);
|
|
29
|
+
/**
|
|
30
|
+
* Programs a woken agent never runs: they change user or namespace (sudo works without a password on some hosts), start
|
|
31
|
+
* a session that outlives the call (tmux, screen, cron, systemd, launchd, at), reach another machine or a loopback service
|
|
32
|
+
* such as the Paseo daemon (ssh, nc, socat), or hand out credentials.
|
|
33
|
+
*/
|
|
34
|
+
const NEVER_PROGS = new Set([
|
|
35
|
+
'sudo', 'doas', 'su', 'runuser', 'pkexec', 'setpriv', 'unshare', 'nsenter', 'chroot', 'machinectl', 'run0',
|
|
36
|
+
'tmux', 'screen', 'byobu', 'zellij', 'crontab', 'at', 'batch', 'systemctl', 'systemd-run', 'launchctl', 'loginctl',
|
|
37
|
+
'ssh', 'scp', 'sftp', 'rsync', 'nc', 'ncat', 'netcat', 'socat', 'telnet',
|
|
38
|
+
]);
|
|
39
|
+
/** Variables a woken agent may not set for a command: they redirect the profile, the home, the shell or the tools. */
|
|
40
|
+
const SENSITIVE_VAR = /^(COURIER_|TC_|XDG_|GH_|GITHUB_|GIT_|CLAUDE_|ANTHROPIC_|NPM_|NODE_|LD_|DYLD_|PASEO_|CLOUDFLARE_|WRANGLER_|PHALA_|AWS_|SSH_|BASH_|HOME$|PATH$|SHELL$|ENV$|ZDOTDIR$)/;
|
|
41
|
+
/** Wrappers that run the inner command more than once (or an unknown number of times): approvals cannot cover those. */
|
|
42
|
+
const REPEATERS = new Set(['xargs', 'parallel', 'find', 'watch', 'trap', 'at', 'batch', 'entr']);
|
|
43
|
+
const INTERPRETERS = new Set(['python', 'python2', 'python3', 'node', 'nodejs', 'deno', 'perl', 'ruby', 'php', 'lua', 'luajit', 'tsx', 'ts-node', 'osascript', 'bun']);
|
|
44
|
+
const SOURCE_EXT = /\.(py|js|mjs|cjs|ts|mts|rb|pl|php|lua|sh|bash|zsh)$/i;
|
|
45
|
+
const NPM_FAMILY = new Set(['npm', 'npx', 'pnpm', 'pnpx', 'yarn', 'bun', 'bunx', 'corepack']);
|
|
46
|
+
const CURLS = new Set(['curl', 'wget', 'http', 'https', 'httpie', 'xh', 'xhs', 'aria2c']);
|
|
47
|
+
const SCRIPT_NAME = /^(deploy|publish|release)(:|$)/i;
|
|
48
|
+
const PKG_NAMES = {
|
|
49
|
+
'@anthropic-ai/claude-code': 'claude', '@openai/codex': 'codex', '@tinycloud/cli': 'tinycloud', '@changesets/cli': 'changeset', '@phala/cli': 'phala',
|
|
50
|
+
};
|
|
51
|
+
const GIT_BUILTINS = new Set(('add am apply archive bisect blame branch bundle cat-file check-ignore checkout cherry-pick clean clone commit config describe diff ' +
|
|
52
|
+
'difftool fetch for-each-ref format-patch fsck gc grep hash-object help init log ls-files ls-remote ls-tree maintenance merge ' +
|
|
53
|
+
'merge-base mergetool mv name-rev notes pull push range-diff rebase reflog remote request-pull reset restore rev-list rev-parse ' +
|
|
54
|
+
'revert rm send-email shortlog show show-ref sparse-checkout stash status submodule switch symbolic-ref tag update-ref ' +
|
|
55
|
+
'version whatchanged worktree').split(' '));
|
|
56
|
+
// Pre-compiled patterns for GitHub API calls that merge PRs or write refs (REST and GraphQL), best effort.
|
|
57
|
+
const MERGE_API = [
|
|
58
|
+
/\/pulls\/[^\/\s]*\/merge\b/i,
|
|
59
|
+
/\/repos\/[^\/\s]+\/[^\/\s]+\/merges\b/i,
|
|
60
|
+
/\/git\/refs\/(heads\/)?(main|master)\b/i,
|
|
61
|
+
/\bmergePullRequest\b|\benablePullRequestAutoMerge\b|\bmergeBranch\b|\bupdateRef\b|\bcreateCommitOnBranch\b/i,
|
|
62
|
+
];
|
|
63
|
+
const mergeApi = (text) => MERGE_API.some((re) => re.test(text));
|
|
64
|
+
/** Phrases that mean a gated action when they appear inside code a script interpreter will run. */
|
|
65
|
+
const CODE_PHRASES = [
|
|
66
|
+
['gh pr merge', /\bgh pr merge\b/],
|
|
67
|
+
['gh workflow run', /\bgh workflow run\b/],
|
|
68
|
+
['gh release', /\bgh release\b/],
|
|
69
|
+
['publish', /\b(npm|bun|pnpm|yarn|changeset|npx|bunx) (\S+ )*?publish\b/],
|
|
70
|
+
['wrangler deploy', /\bwrangler (\S+ ){0,4}(deploy|publish)\b/],
|
|
71
|
+
['phala deploy', /\bphala (\S+ ){0,4}deploy\b/],
|
|
72
|
+
['git push to main', /\bgit (\S+ ){0,6}push (\S+ ){0,8}?\S*\b(main|master)\b/],
|
|
73
|
+
['tc secrets', /\b(tc|tinycloud) (\S+ ){0,4}secrets?\b/],
|
|
74
|
+
['secret bridge', /secret[-_]?bridge/],
|
|
75
|
+
];
|
|
76
|
+
function codeHits(text) {
|
|
77
|
+
const hits = [];
|
|
78
|
+
if (mergeApi(text))
|
|
79
|
+
hits.push('GitHub API merge or ref write');
|
|
80
|
+
const flat = text.replace(/["'`,\[\](){};]/g, ' ').replace(/\s+/g, ' ');
|
|
81
|
+
for (const [label, re] of CODE_PHRASES)
|
|
82
|
+
if (re.test(flat))
|
|
83
|
+
hits.push(label);
|
|
84
|
+
return hits;
|
|
85
|
+
}
|
|
86
|
+
const ASSIGN_BUILTINS = new Set(['export', 'declare', 'typeset', 'local', 'readonly']);
|
|
87
|
+
const READ_BUILTINS = new Set(['read', 'mapfile', 'readarray', 'getopts', 'unset', 'printf', 'let']);
|
|
88
|
+
function census(cmds) {
|
|
89
|
+
const c = { defs: new Map(), counts: new Map(), taintAll: false, ifs: false };
|
|
90
|
+
const bump = (name, by) => {
|
|
91
|
+
c.counts.set(name, (c.counts.get(name) ?? 0) + by);
|
|
92
|
+
if (name === 'IFS')
|
|
93
|
+
c.ifs = true;
|
|
94
|
+
};
|
|
95
|
+
for (const cmd of cmds) {
|
|
96
|
+
for (const a of cmd.assigns) {
|
|
97
|
+
bump(a.name, a.append ? 2 : 1);
|
|
98
|
+
c.defs.set(a.name, a.value);
|
|
99
|
+
}
|
|
100
|
+
for (const n of cmd.forVars)
|
|
101
|
+
bump(n, 2);
|
|
102
|
+
const head = cmd.words.length ? wordLiteral(cmd.words[0]) : undefined;
|
|
103
|
+
if (head === undefined && cmd.words.length) {
|
|
104
|
+
// A head decided at run time could be `eval`, `read`, ...: stop trusting any variable.
|
|
105
|
+
if (cmd.words[0].parts.some((p) => p.t === 'dyn' || p.t === 'var'))
|
|
106
|
+
c.taintAll = c.taintAll || false;
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
if (head === 'eval' || head === 'source' || head === '.')
|
|
110
|
+
c.taintAll = true;
|
|
111
|
+
if (head && ASSIGN_BUILTINS.has(head)) {
|
|
112
|
+
for (const w of cmd.words.slice(1)) {
|
|
113
|
+
const t = wordLiteral(w);
|
|
114
|
+
const m = t === undefined ? /^$/.exec('') : /^([A-Za-z_][A-Za-z0-9_]*)(\+?)=/.exec(t);
|
|
115
|
+
if (t === undefined)
|
|
116
|
+
c.taintAll = true;
|
|
117
|
+
else if (m && m[1])
|
|
118
|
+
bump(m[1], 2);
|
|
119
|
+
else if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(t))
|
|
120
|
+
bump(t, 2);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
if (head && READ_BUILTINS.has(head)) {
|
|
124
|
+
for (const w of cmd.words.slice(1)) {
|
|
125
|
+
const t = wordLiteral(w);
|
|
126
|
+
if (t === undefined)
|
|
127
|
+
c.taintAll = true;
|
|
128
|
+
else if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(t))
|
|
129
|
+
bump(t, 2);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return c;
|
|
134
|
+
}
|
|
135
|
+
/** Static value of variable `name`, or undefined when it can differ at run time. */
|
|
136
|
+
function lookup(name, c, cmd, resolving) {
|
|
137
|
+
if (c.taintAll || cmd.inLoop || cmd.inFunc)
|
|
138
|
+
return undefined;
|
|
139
|
+
if (c.counts.get(name) !== 1 || resolving.has(name))
|
|
140
|
+
return undefined;
|
|
141
|
+
const def = c.defs.get(name);
|
|
142
|
+
if (!def)
|
|
143
|
+
return undefined;
|
|
144
|
+
resolving.add(name);
|
|
145
|
+
let out = '';
|
|
146
|
+
for (const p of def.parts) {
|
|
147
|
+
if (p.t === 'lit')
|
|
148
|
+
out += p.s;
|
|
149
|
+
else if (p.t === 'var') {
|
|
150
|
+
const v = lookup(p.name, c, cmd, resolving);
|
|
151
|
+
if (v === undefined) {
|
|
152
|
+
resolving.delete(name);
|
|
153
|
+
return undefined;
|
|
154
|
+
}
|
|
155
|
+
out += v;
|
|
156
|
+
}
|
|
157
|
+
else {
|
|
158
|
+
resolving.delete(name);
|
|
159
|
+
return undefined;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
resolving.delete(name);
|
|
163
|
+
return out;
|
|
164
|
+
}
|
|
165
|
+
/** Expand one raw word into the words a shell would hand to the command (splitting only unquoted expansions). */
|
|
166
|
+
function expand(word, c, cmd) {
|
|
167
|
+
const fields = [];
|
|
168
|
+
let cur = { text: '', dyn: false, started: false };
|
|
169
|
+
const push = () => { if (cur.started)
|
|
170
|
+
fields.push({ value: cur.text, dynamic: cur.dyn }); cur = { text: '', dyn: false, started: false }; };
|
|
171
|
+
for (const p of word.parts) {
|
|
172
|
+
if (p.t === 'lit') {
|
|
173
|
+
cur.text += p.s;
|
|
174
|
+
if (p.s || p.q)
|
|
175
|
+
cur.started = true;
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
if (p.t === 'dyn') {
|
|
179
|
+
cur.text += PLACEHOLDER;
|
|
180
|
+
cur.dyn = true;
|
|
181
|
+
cur.started = true;
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
const v = lookup(p.name, c, cmd, new Set());
|
|
185
|
+
if (v === undefined || (!p.q && c.ifs)) {
|
|
186
|
+
cur.text += PLACEHOLDER;
|
|
187
|
+
cur.dyn = true;
|
|
188
|
+
cur.started = true;
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
if (p.q) {
|
|
192
|
+
cur.text += v;
|
|
193
|
+
cur.started = true;
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
const pieces = v.split(/[ \t\n]+/);
|
|
197
|
+
pieces.forEach((piece, idx) => {
|
|
198
|
+
if (idx > 0)
|
|
199
|
+
push();
|
|
200
|
+
if (piece) {
|
|
201
|
+
cur.text += piece;
|
|
202
|
+
cur.started = true;
|
|
203
|
+
if (/[*?[]/.test(piece))
|
|
204
|
+
cur.dyn = true;
|
|
205
|
+
}
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
push();
|
|
209
|
+
return fields;
|
|
210
|
+
}
|
|
211
|
+
const expandAll = (words, c, cmd) => words.flatMap((w) => expand(w, c, cmd));
|
|
212
|
+
function emit(sh, f) {
|
|
213
|
+
const id = f.kind === 'gated' ? `g|${f.key}` : `${f.kind}|${f.label}`;
|
|
214
|
+
if (sh.seen.has(id))
|
|
215
|
+
return;
|
|
216
|
+
sh.seen.add(id);
|
|
217
|
+
sh.findings.push(f);
|
|
218
|
+
}
|
|
219
|
+
const unresolved = (sh, label) => emit(sh, { kind: 'unresolved', label });
|
|
220
|
+
export function progName(value) {
|
|
221
|
+
return path.basename(value).toLowerCase();
|
|
222
|
+
}
|
|
223
|
+
/** Builtins are trusted only as exact shell words, never by a path's basename. */
|
|
224
|
+
const BUILTINS = new Set([':', '[', '[[', 'true', 'false', 'echo', 'printf', 'pwd', 'cd', 'pushd', 'popd', 'eval', 'source', '.', 'export', 'declare', 'typeset', 'readonly', 'local', 'unset', 'set', 'command', 'builtin', 'exec', 'trap']);
|
|
225
|
+
function program(word, sc) {
|
|
226
|
+
if (!sc.externalOnly && BUILTINS.has(word))
|
|
227
|
+
return word;
|
|
228
|
+
if (sc.cwdUnknown && !path.isAbsolute(word))
|
|
229
|
+
return undefined;
|
|
230
|
+
let found;
|
|
231
|
+
for (const cwd of sc.cwds) {
|
|
232
|
+
const name = sc.sh.env.resolveProgram?.(word, cwd) ?? binaryName(word, cwd, sc.sh.env.getenv('PATH') ?? '', sc.sh.env.binaries ?? []);
|
|
233
|
+
if (!name || (found && found !== name))
|
|
234
|
+
return undefined;
|
|
235
|
+
found = name;
|
|
236
|
+
}
|
|
237
|
+
return found;
|
|
238
|
+
}
|
|
239
|
+
function courierEntry(file, sc) {
|
|
240
|
+
const real = realpathBest(path.resolve(sc.cwds[0], file));
|
|
241
|
+
const kind = program(real, { ...sc, externalOnly: true });
|
|
242
|
+
return sc.sh.env.courierEntries?.includes(real) && (kind === 'courier' || kind === 'tc') ? kind : undefined;
|
|
243
|
+
}
|
|
244
|
+
const nctx = (sc) => ({ env: sc.sh.env, cwds: sc.cwds, cwdUnknown: sc.cwdUnknown, assigns: sc.assigns });
|
|
245
|
+
const branchName = (ref) => (ref.includes('*') ? '*' : ref.replace(/^\+/, '').replace(/^(refs\/)?(heads\/)?/, ''));
|
|
246
|
+
/** Record a gated command, keyed by what it does (verb + canonical target). Anything that cannot be named is refused. */
|
|
247
|
+
function gate(sc, label, top, normalise) {
|
|
248
|
+
if (top.some((w) => w.dynamic))
|
|
249
|
+
return unresolved(sc.sh, `${label}: write the command out literally (no variables, substitutions, globs or braces)`);
|
|
250
|
+
if (sc.inexact)
|
|
251
|
+
return unresolved(sc.sh, `${label} inside a loop, function or repeating runner (xargs, find, watch, trap): run it once, directly`);
|
|
252
|
+
const norm = normalise();
|
|
253
|
+
if (typeof norm === 'string')
|
|
254
|
+
return unresolved(sc.sh, `${label}: ${norm}`);
|
|
255
|
+
emit(sc.sh, { kind: 'gated', label, verb: norm.verb, target: norm.target, key: `${norm.verb}|${norm.target}`, display: `${norm.verb} ${norm.target}` });
|
|
256
|
+
}
|
|
257
|
+
const never = (sc, label) => emit(sc.sh, { kind: 'never', label });
|
|
258
|
+
const statics = (args) => args.filter((a) => !a.dynamic).map((a) => a.value);
|
|
259
|
+
// ---------- per-program rules ----------
|
|
260
|
+
function classifyGh(args, top, sc, io) {
|
|
261
|
+
const pos = [];
|
|
262
|
+
const valueFlags = new Set(['-R', '--repo', '--hostname']);
|
|
263
|
+
let subIdx = -1;
|
|
264
|
+
for (let i = 0; i < args.length && pos.length < 2; i++) {
|
|
265
|
+
const a = args[i];
|
|
266
|
+
if (!a.dynamic && a.value.startsWith('-')) {
|
|
267
|
+
if (valueFlags.has(a.value))
|
|
268
|
+
i++;
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
271
|
+
if (a.dynamic)
|
|
272
|
+
return unresolved(sc.sh, 'gh subcommand built at run time');
|
|
273
|
+
pos.push(a.value.toLowerCase());
|
|
274
|
+
subIdx = i;
|
|
275
|
+
}
|
|
276
|
+
const [group, sub] = pos;
|
|
277
|
+
const rest = args.slice(subIdx + 1);
|
|
278
|
+
if (group === 'pr' && sub === 'merge')
|
|
279
|
+
return gate(sc, 'gh pr merge', top, () => normGhPrMerge(rest, nctx(sc), args));
|
|
280
|
+
if (group === 'workflow' && sub === 'run')
|
|
281
|
+
return gate(sc, 'gh workflow run', top, () => normGhWorkflowRun(rest, nctx(sc), args));
|
|
282
|
+
if (group === 'release') {
|
|
283
|
+
if (releaseIsReadOnly(sub))
|
|
284
|
+
return;
|
|
285
|
+
return gate(sc, 'gh release', top, () => normGhRelease(rest, nctx(sc), args));
|
|
286
|
+
}
|
|
287
|
+
if ((group === 'repo' && sub === 'sync') || (group === 'run' && ['rerun', 'cancel', 'delete'].includes(sub)) || group === 'secret' || group === 'variable')
|
|
288
|
+
return unresolved(sc.sh, 'GitHub write command: your owner runs it');
|
|
289
|
+
if (['auth', 'config', 'extension'].includes(group))
|
|
290
|
+
return never(sc, 'GitHub credential/configuration tools are not available');
|
|
291
|
+
if (group === 'alias')
|
|
292
|
+
return unresolved(sc.sh, 'gh alias (can hide a gated command)');
|
|
293
|
+
if (group === 'api')
|
|
294
|
+
return classifyApi(args, top, sc, io, 'gh');
|
|
295
|
+
const allowed = { pr: ['view', 'list', 'checks', 'diff', 'status', 'create', 'comment'], issue: ['view', 'list', 'status', 'create', 'comment'], repo: ['view', 'list'], workflow: ['list', 'view'], run: ['list', 'view', 'watch', 'download'], search: ['code', 'commits', 'issues', 'prs', 'repos'] };
|
|
296
|
+
if (allowed[group]?.includes(sub) || ['help', 'version', 'status'].includes(group) || (!group && args.every(a => /^--(?:help|version)$/.test(a.value))))
|
|
297
|
+
return;
|
|
298
|
+
return unresolved(sc.sh, 'GitHub command outside the read/create/comment allowlist: your owner runs it');
|
|
299
|
+
}
|
|
300
|
+
/** Flags of `gh api` / curl that take a separate value (so the value is not a URL). */
|
|
301
|
+
const API_VALUE_FLAGS = new Set([
|
|
302
|
+
'-X', '--method', '-H', '--header', '-q', '--jq', '-t', '--template', '--hostname', '--cache', '-f', '-F', '--field', '--raw-field',
|
|
303
|
+
'--input', '-d', '--data', '--data-raw', '--data-binary', '--data-urlencode', '--json', '-o', '--output', '-u', '--user', '-A', '--user-agent',
|
|
304
|
+
'-e', '--referer', '-w', '-b', '--cookie', '-x', '--proxy', '--url-query', '-T', '--upload-file', '--request', '-O', '-K', '--config',
|
|
305
|
+
]);
|
|
306
|
+
/** Payload sources of an API call: `key=@file`, `@file`, `--input file`, `-K file`; stdin counts as unverifiable unless a here-doc supplies it. */
|
|
307
|
+
function apiText(args, sc, io) {
|
|
308
|
+
let text = '';
|
|
309
|
+
let unverifiable = false;
|
|
310
|
+
let dynamic = false;
|
|
311
|
+
const readRef = (ref) => {
|
|
312
|
+
if (ref === '-') {
|
|
313
|
+
if (io.heredocs.length || io.hereStrings.length)
|
|
314
|
+
text += `\n${io.heredocs.join('\n')}\n${io.hereStrings.join('\n')}`;
|
|
315
|
+
else if (io.piped || !io.stdinFile)
|
|
316
|
+
unverifiable = true;
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
for (const dir of sc.cwds) {
|
|
320
|
+
const r = sc.sh.env.readFile(path.resolve(dir, ref));
|
|
321
|
+
if (r.state === 'text') {
|
|
322
|
+
text += `\n${r.text}`;
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
if (r.state === 'unreadable') {
|
|
326
|
+
unverifiable = true;
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
};
|
|
331
|
+
for (let i = 0; i < args.length; i++) {
|
|
332
|
+
const a = args[i];
|
|
333
|
+
if (a.dynamic) {
|
|
334
|
+
dynamic = true;
|
|
335
|
+
text += `\n${a.value}`;
|
|
336
|
+
continue;
|
|
337
|
+
}
|
|
338
|
+
text += `\n${a.value}`;
|
|
339
|
+
const m = /(?:^|=)@(.+)$/.exec(a.value);
|
|
340
|
+
if (m)
|
|
341
|
+
readRef(m[1]);
|
|
342
|
+
if ((a.value === '--input' || a.value === '-K' || a.value === '--config') && args[i + 1] && !args[i + 1].dynamic)
|
|
343
|
+
readRef(args[i + 1].value);
|
|
344
|
+
}
|
|
345
|
+
return { text, unverifiable, dynamic };
|
|
346
|
+
}
|
|
347
|
+
function classifyApi(args, top, sc, io, tool) {
|
|
348
|
+
const info = apiText(args, sc, io);
|
|
349
|
+
if (mergeApi(info.text))
|
|
350
|
+
return gate(sc, 'GitHub API merge or ref write', top, () => normApi(info.text));
|
|
351
|
+
const github = tool === 'gh' || /github/i.test(info.text);
|
|
352
|
+
if (!github)
|
|
353
|
+
return;
|
|
354
|
+
let method;
|
|
355
|
+
let payload = false;
|
|
356
|
+
for (let i = 0; i < args.length; i++) {
|
|
357
|
+
const value = args[i].value;
|
|
358
|
+
if (['-X', '--method', '--request'].includes(value))
|
|
359
|
+
method = args[++i]?.value.toUpperCase();
|
|
360
|
+
else if (/^(--method=|--request=|-X.)/.test(value))
|
|
361
|
+
method = value.replace(/^(--method=|--request=|-X)/, '').toUpperCase();
|
|
362
|
+
else if (/^(-f|-F|--field|--raw-field|--input|-d|--data(?:-[a-z]+)?|--form(?:-string)?|--json|-T|--upload-file)(=|$)/.test(value) || /^-[fFdT].+/.test(value))
|
|
363
|
+
payload = true;
|
|
364
|
+
}
|
|
365
|
+
const verb = method ?? (payload ? 'POST' : 'GET');
|
|
366
|
+
// Only literal comment writes are allowed here. Other GitHub writes cannot be brokered in v0.
|
|
367
|
+
const comment = args.some(a => !a.dynamic && /^(https:\/\/api\.github\.com\/)?\/?repos\/[^/\s]+\/[^/\s]+\/(issues|pulls)\/[1-9][0-9]*\/comments$/.test(a.value));
|
|
368
|
+
if (!['GET', 'HEAD', 'OPTIONS'].includes(verb) && !(verb === 'POST' && comment))
|
|
369
|
+
return unresolved(sc.sh, 'GitHub API write outside the comment allowlist: your owner runs it');
|
|
370
|
+
if (/graphql/i.test(info.text))
|
|
371
|
+
return unresolved(sc.sh, 'GitHub GraphQL calls are not resolved by the guard');
|
|
372
|
+
if (info.unverifiable)
|
|
373
|
+
return unresolved(sc.sh, 'GitHub API call whose payload cannot be read here');
|
|
374
|
+
if (info.dynamic && /graphql/i.test(info.text))
|
|
375
|
+
return unresolved(sc.sh, 'GitHub GraphQL call built at run time');
|
|
376
|
+
// An endpoint that is entirely decided at run time could be a merge.
|
|
377
|
+
for (let i = 0; i < args.length; i++) {
|
|
378
|
+
const a = args[i];
|
|
379
|
+
if (!a.dynamic && API_VALUE_FLAGS.has(a.value)) {
|
|
380
|
+
i++;
|
|
381
|
+
continue;
|
|
382
|
+
}
|
|
383
|
+
if (a.dynamic && a.value.replace(/\u0001/g, '').trim() === '')
|
|
384
|
+
return unresolved(sc.sh, 'GitHub API endpoint built at run time');
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
function classifyCurl(args, top, sc, io) {
|
|
388
|
+
if (statics(args).some(a => /^(--resolve|--connect-to|--unix-socket|--abstract-unix-socket)(=|$)/.test(a)))
|
|
389
|
+
return never(sc, 'network destination overrides can reach local services');
|
|
390
|
+
if (args.some(a => !a.dynamic && blockedDestination(a.value)))
|
|
391
|
+
return never(sc, 'loopback, numeric IP or local daemon destination is not available');
|
|
392
|
+
classifyApi(args, top, sc, io, 'curl');
|
|
393
|
+
}
|
|
394
|
+
function executableGitKey(key) {
|
|
395
|
+
return /^(alias\.|include\.|includeif\.|core\.(askpass|fsmonitor|pager|editor|sshcommand|hookspath)$|pager\.|sequence\.editor$|diff\..*\.(textconv|command)$|filter\..*\.(clean|smudge|process)$|merge\..*\.driver$|credential\..*helper$|credential\.helper$|gpg\.program$|remote\..*\.(receivepack|uploadpack|pushurl)$|url\..*\.(insteadof|pushinsteadof)$)/i.test(key);
|
|
396
|
+
}
|
|
397
|
+
const GIT_DIR_VALUE_FLAGS = new Set(['--git-dir', '--work-tree', '--namespace', '--super-prefix', '--config-env', '--exec-path']);
|
|
398
|
+
function classifyGit(args, top, sc, io, depth = 0) {
|
|
399
|
+
if (args.some(a => !a.dynamic && (a.value === '-c' || a.value.startsWith('-c') || a.value.startsWith('--config-env'))))
|
|
400
|
+
return unresolved(sc.sh, 'git runtime config overrides can execute commands');
|
|
401
|
+
const probeArgs = [];
|
|
402
|
+
const dirs = [...sc.cwds];
|
|
403
|
+
let dirsUnknown = sc.cwdUnknown;
|
|
404
|
+
let i = 0;
|
|
405
|
+
for (; i < args.length; i++) {
|
|
406
|
+
const a = args[i];
|
|
407
|
+
if (a.dynamic)
|
|
408
|
+
return unresolved(sc.sh, 'git option or subcommand built at run time');
|
|
409
|
+
const v = a.value;
|
|
410
|
+
if (v === '-C') {
|
|
411
|
+
const d = args[++i];
|
|
412
|
+
if (!d || d.dynamic)
|
|
413
|
+
dirsUnknown = true;
|
|
414
|
+
else
|
|
415
|
+
for (let j = 0; j < dirs.length; j++)
|
|
416
|
+
dirs[j] = path.resolve(dirs[j], d.value);
|
|
417
|
+
continue;
|
|
418
|
+
}
|
|
419
|
+
if (GIT_DIR_VALUE_FLAGS.has(v)) {
|
|
420
|
+
const val = args[++i];
|
|
421
|
+
if (!val || val.dynamic)
|
|
422
|
+
return unresolved(sc.sh, 'git directory option built at run time');
|
|
423
|
+
if (v === '--git-dir' || v === '--work-tree')
|
|
424
|
+
probeArgs.push(v, val.value);
|
|
425
|
+
continue;
|
|
426
|
+
}
|
|
427
|
+
if (v.startsWith('--git-dir=') || v.startsWith('--work-tree=')) {
|
|
428
|
+
probeArgs.push(v);
|
|
429
|
+
continue;
|
|
430
|
+
}
|
|
431
|
+
if (v.startsWith('-'))
|
|
432
|
+
continue;
|
|
433
|
+
break;
|
|
434
|
+
}
|
|
435
|
+
const subWord = args[i];
|
|
436
|
+
if (!subWord)
|
|
437
|
+
return;
|
|
438
|
+
const sub = subWord.value.toLowerCase();
|
|
439
|
+
if (sub === 'credential')
|
|
440
|
+
return never(sc, 'git credential can expose the owner credential');
|
|
441
|
+
const rest = args.slice(i + 1);
|
|
442
|
+
const cfg = (key, dir) => {
|
|
443
|
+
const got = sc.sh.env.git(dir, [...probeArgs, 'config', '--get-all', key]);
|
|
444
|
+
return [...(got.ok ? got.out.split('\n').filter(Boolean) : [])];
|
|
445
|
+
};
|
|
446
|
+
if (!GIT_BUILTINS.has(sub) && depth < 3) {
|
|
447
|
+
const lookups = dirsUnknown ? [] : dirs.flatMap((d) => cfg(`alias.${sub}`, d).slice(-1));
|
|
448
|
+
for (const aliased of lookups) {
|
|
449
|
+
if (aliased.startsWith('!'))
|
|
450
|
+
analyzeScript(aliased.slice(1), sc, sc.inexact);
|
|
451
|
+
else
|
|
452
|
+
classifyGit([...aliased.split(/\s+/).filter(Boolean).map((value) => ({ value, dynamic: false })), ...rest], top, sc, io, depth + 1);
|
|
453
|
+
}
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
456
|
+
if (sub === 'send-pack')
|
|
457
|
+
return unresolved(sc.sh, 'git send-pack');
|
|
458
|
+
if (sub === 'config') {
|
|
459
|
+
if (statics(rest).some((s) => executableGitKey(s)))
|
|
460
|
+
unresolved(sc.sh, 'git command-running configuration is not allowed');
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
463
|
+
// `git config` itself is read-only here. Detect local helpers before invoking commands that could run them.
|
|
464
|
+
for (const dir of dirs) {
|
|
465
|
+
const configured = sc.sh.env.git(dir, [...probeArgs, 'config', '--get-regexp', '.*']);
|
|
466
|
+
if (configured.ok && configured.out.split('\n').some(line => executableGitKey(line.split(/\s+/)[0]) && !/^core\.hookspath\s+\/dev\/null$/i.test(line)))
|
|
467
|
+
return unresolved(sc.sh, 'existing git configuration can execute commands');
|
|
468
|
+
}
|
|
469
|
+
if (sub !== 'push')
|
|
470
|
+
return;
|
|
471
|
+
// ---- git push ----
|
|
472
|
+
if (rest.some((a) => a.dynamic))
|
|
473
|
+
return unresolved(sc.sh, 'git push with a branch, remote or option built at run time: write it out literally');
|
|
474
|
+
const pos = [];
|
|
475
|
+
let repoFlag = false;
|
|
476
|
+
let afterDashDash = false;
|
|
477
|
+
let gated = false;
|
|
478
|
+
for (let j = 0; j < rest.length; j++) {
|
|
479
|
+
const v = rest[j].value;
|
|
480
|
+
if (!afterDashDash && v === '--') {
|
|
481
|
+
afterDashDash = true;
|
|
482
|
+
continue;
|
|
483
|
+
}
|
|
484
|
+
if (!afterDashDash && v.startsWith('-') && v !== '-') {
|
|
485
|
+
if (v === '--tags' || v === '--follow-tags')
|
|
486
|
+
return unresolved(sc.sh, 'tag pushes can publish or deploy; your owner runs them');
|
|
487
|
+
if (v === '--all' || v === '--mirror' || v === '--branches')
|
|
488
|
+
gated = true;
|
|
489
|
+
if (v === '--repo')
|
|
490
|
+
repoFlag = true;
|
|
491
|
+
if (v.startsWith('--repo='))
|
|
492
|
+
repoFlag = true;
|
|
493
|
+
if (v === '-o' || v === '--push-option' || v === '--repo' || v === '--receive-pack' || v === '--exec')
|
|
494
|
+
j++;
|
|
495
|
+
continue;
|
|
496
|
+
}
|
|
497
|
+
pos.push(v);
|
|
498
|
+
}
|
|
499
|
+
// `--all`/`--mirror` are handled once the remote is known.
|
|
500
|
+
const remote = repoFlag ? undefined : pos[0];
|
|
501
|
+
const refspecs = repoFlag ? pos : pos.slice(1);
|
|
502
|
+
let implicit = refspecs.length === 0;
|
|
503
|
+
const hit = [];
|
|
504
|
+
if (gated)
|
|
505
|
+
return gate(sc, 'git push --all/--mirror (includes the default branch)', top, () => normPush(remote, ['*'], nctx(sc)));
|
|
506
|
+
for (const spec of refspecs) {
|
|
507
|
+
const bare = spec.replace(/^\+/, '');
|
|
508
|
+
const colon = bare.indexOf(':');
|
|
509
|
+
const dst = colon < 0 ? bare : bare.slice(colon + 1) || bare.slice(0, colon);
|
|
510
|
+
if (dst === 'HEAD' || dst === '@')
|
|
511
|
+
implicit = true;
|
|
512
|
+
else if (protectedRef(dst))
|
|
513
|
+
hit.push(branchName(dst));
|
|
514
|
+
}
|
|
515
|
+
if (hit.length)
|
|
516
|
+
return gate(sc, 'git push to main/master', top, () => normPush(remote, hit, nctx(sc)));
|
|
517
|
+
if (!implicit)
|
|
518
|
+
return unresolved(sc.sh, 'direct pushes (including tags) are not brokered in v0; your owner runs it');
|
|
519
|
+
if (dirsUnknown || !dirs.length)
|
|
520
|
+
return unresolved(sc.sh, 'git push without a branch from a directory that cannot be determined');
|
|
521
|
+
for (const dir of dirs) {
|
|
522
|
+
const head = sc.sh.env.git(dir, [...probeArgs, 'symbolic-ref', '-q', '--short', 'HEAD']);
|
|
523
|
+
if (!head.ok || !head.out)
|
|
524
|
+
return unresolved(sc.sh, 'git push without a branch and no current branch could be read');
|
|
525
|
+
const branch = head.out.trim();
|
|
526
|
+
const rem = remote ?? cfg(`branch.${branch}.remote`, dir).pop();
|
|
527
|
+
if (protectedRef(branch))
|
|
528
|
+
return gate(sc, 'git push from main/master', top, () => normPush(rem, [branch], nctx(sc)));
|
|
529
|
+
const pd = (cfg('push.default', dir).pop() ?? 'simple').toLowerCase();
|
|
530
|
+
if (pd === 'matching')
|
|
531
|
+
return gate(sc, 'git push (push.default=matching pushes main too)', top, () => normPush(rem, ['*'], nctx(sc)));
|
|
532
|
+
if ((pd === 'upstream' || pd === 'tracking') && cfg(`branch.${branch}.merge`, dir).some(protectedRef)) {
|
|
533
|
+
return gate(sc, 'git push to an upstream of main/master', top, () => normPush(rem, cfg(`branch.${branch}.merge`, dir).filter(protectedRef).map(branchName), nctx(sc)));
|
|
534
|
+
}
|
|
535
|
+
const remotes = new Set([remote, cfg(`branch.${branch}.remote`, dir).pop(), 'origin'].filter((r) => Boolean(r)));
|
|
536
|
+
for (const r of remotes) {
|
|
537
|
+
for (const spec of cfg(`remote.${r}.push`, dir)) {
|
|
538
|
+
const bare = spec.replace(/^\+/, '');
|
|
539
|
+
const dst = bare.includes(':') ? bare.slice(bare.indexOf(':') + 1) : bare;
|
|
540
|
+
if (protectedRef(dst))
|
|
541
|
+
return gate(sc, 'git push (configured refspec targets main/master)', top, () => normPush(rem, [branchName(dst)], nctx(sc)));
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
unresolved(sc.sh, 'direct pushes are not brokered in v0; your owner runs them');
|
|
546
|
+
}
|
|
547
|
+
function protectedRef(ref) {
|
|
548
|
+
const r = ref.replace(/^\+/, '');
|
|
549
|
+
if (r.includes('*'))
|
|
550
|
+
return true;
|
|
551
|
+
return /^(refs\/)?(heads\/)?(main|master)$/i.test(r);
|
|
552
|
+
}
|
|
553
|
+
function classifyNpm(prog, args, top, sc, io) {
|
|
554
|
+
// Package-launch strings name code to fetch/execute, not a verified local inode. They grant no tool exemption.
|
|
555
|
+
if (['npx', 'pnpx', 'bunx', 'corepack'].includes(prog) || statics(args).some(a => ['exec', 'dlx', 'x'].includes(a))) {
|
|
556
|
+
for (const arg of statics(args)) {
|
|
557
|
+
const pkg = arg.startsWith('@') ? arg.replace(/@[^/]*$/, '') : arg.replace(/@.*$/, '');
|
|
558
|
+
const name = PKG_NAMES[pkg] ?? pkg;
|
|
559
|
+
if (AGENT_CLIS.has(name) || ['tc', 'tinycloud'].includes(name))
|
|
560
|
+
return never(sc, 'package launcher can start an agent or reach an owner profile');
|
|
561
|
+
}
|
|
562
|
+
return unresolved(sc.sh, 'package-launched executable has no verified inode; run an installed tool directly');
|
|
563
|
+
}
|
|
564
|
+
const lits = statics(args);
|
|
565
|
+
if (lits.includes('publish'))
|
|
566
|
+
return gate(sc, `${prog} publish`, top, () => normPublish(prog, args.slice(args.findIndex((a) => !a.dynamic && a.value === 'publish') + 1), nctx(sc)));
|
|
567
|
+
if (lits.some(v => ['unpublish', 'dist-tag', 'deprecate', 'undeprecate', 'owner', 'access', 'token', 'logout', 'login', 'adduser'].includes(v)))
|
|
568
|
+
return unresolved(sc.sh, `${prog} registry write: your owner runs it`);
|
|
569
|
+
const positions = [];
|
|
570
|
+
let dirs = [...sc.cwds];
|
|
571
|
+
for (let i = 0; i < args.length; i++) {
|
|
572
|
+
const arg = args[i];
|
|
573
|
+
if (arg.dynamic)
|
|
574
|
+
return unresolved(sc.sh, `${prog} command built at run time`);
|
|
575
|
+
const v = arg.value;
|
|
576
|
+
if (/^(--filter|--workspace|--workspaces|--recursive|-r|-w)(=|$)/.test(v) || /^-w.+/.test(v))
|
|
577
|
+
return unresolved(sc.sh, 'workspace-selected scripts cannot be resolved safely');
|
|
578
|
+
if (['--prefix', '--cwd', '-C'].includes(v) || /^(--prefix|--cwd)=/.test(v)) {
|
|
579
|
+
const dir = v.includes('=') ? v.slice(v.indexOf('=') + 1) : args[++i]?.value;
|
|
580
|
+
if (!dir || sc.cwdUnknown)
|
|
581
|
+
return unresolved(sc.sh, 'package directory cannot be resolved');
|
|
582
|
+
dirs = dirs.map(d => path.resolve(d, dir));
|
|
583
|
+
continue;
|
|
584
|
+
}
|
|
585
|
+
if (v.startsWith('-'))
|
|
586
|
+
continue;
|
|
587
|
+
positions.push(v);
|
|
588
|
+
}
|
|
589
|
+
const [sub, name] = positions;
|
|
590
|
+
if (!sub || (prog === 'bun' && sub === 'test'))
|
|
591
|
+
return;
|
|
592
|
+
if (['install', 'i', 'ci', 'add', 'rebuild', 'version', 'prepare', 'pack'].includes(sub))
|
|
593
|
+
return unresolved(sc.sh, 'package lifecycle scripts can execute commands; your owner runs this');
|
|
594
|
+
const script = ['run', 'run-script'].includes(sub) ? name : ['test', 't', 'start', 'stop', 'restart'].includes(sub) ? (sub === 't' ? 'test' : sub) : ['bun', 'pnpm', 'yarn'].includes(prog) && !['x', 'exec', 'dlx', 'list', 'ls', 'info', 'why', 'outdated', 'remove', 'update', 'pm', 'repl', ...(prog === 'bun' ? ['build'] : [])].includes(sub) ? sub : undefined;
|
|
595
|
+
if (!script)
|
|
596
|
+
return;
|
|
597
|
+
if (SCRIPT_NAME.test(script))
|
|
598
|
+
return gate(sc, `${prog} script ${script}`, top, () => normScript(prog, script, { ...nctx(sc), cwds: dirs }));
|
|
599
|
+
if (sc.cwdUnknown)
|
|
600
|
+
return unresolved(sc.sh, 'package script directory cannot be determined');
|
|
601
|
+
if (sc.depth >= MAX_DEPTH)
|
|
602
|
+
return unresolved(sc.sh, 'package scripts nested too deeply');
|
|
603
|
+
for (const dir of dirs) {
|
|
604
|
+
const file = sc.sh.env.readFile(path.join(dir, 'package.json'));
|
|
605
|
+
if (file.state !== 'text')
|
|
606
|
+
return unresolved(sc.sh, 'package script cannot be read');
|
|
607
|
+
let pkg;
|
|
608
|
+
try {
|
|
609
|
+
pkg = JSON.parse(file.text);
|
|
610
|
+
}
|
|
611
|
+
catch {
|
|
612
|
+
return unresolved(sc.sh, 'package.json cannot be parsed');
|
|
613
|
+
}
|
|
614
|
+
const scripts = pkg?.scripts;
|
|
615
|
+
if (!scripts || typeof scripts[script] !== 'string')
|
|
616
|
+
return unresolved(sc.sh, `package script ${script} cannot be resolved`);
|
|
617
|
+
for (const key of [`pre${script}`, script, `post${script}`]) {
|
|
618
|
+
const body = scripts[key];
|
|
619
|
+
if (body === undefined)
|
|
620
|
+
continue;
|
|
621
|
+
if (typeof body !== 'string')
|
|
622
|
+
return unresolved(sc.sh, `package script ${key} is not text`);
|
|
623
|
+
analyzeScript(body, { ...sc, cwds: [dir], depth: sc.depth + 1 }, sc.inexact);
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
function classifyTool(prog, args, top, sc) {
|
|
628
|
+
const words = prog === 'wrangler' ? ['deploy', 'publish'] : prog === 'phala' ? ['deploy'] : ['publish'];
|
|
629
|
+
if ((prog === 'wrangler' && statics(args).some(v => ['rollback', 'delete', 'secret'].includes(v))) || (prog === 'phala' && statics(args).includes('cvms')))
|
|
630
|
+
return unresolved(sc.sh, `${prog} write command: your owner runs it`);
|
|
631
|
+
if (statics(args).some((s) => words.includes(s.toLowerCase()))) {
|
|
632
|
+
return gate(sc, `${prog} ${words[0]}`, top, () => (prog === 'wrangler' ? normWrangler(args, nctx(sc)) : prog === 'phala' ? normPhala(args) : normChangeset(nctx(sc))));
|
|
633
|
+
}
|
|
634
|
+
const positionals = args.filter((a) => a.dynamic || !a.value.startsWith('-')).slice(0, 2);
|
|
635
|
+
if (positionals.some((a) => a.dynamic))
|
|
636
|
+
return unresolved(sc.sh, `${prog} subcommand built at run time`);
|
|
637
|
+
const readOnly = { wrangler: ['--help', '--version', 'help', 'whoami', 'dev', 'tail', 'types'], phala: ['--help', '--version', 'help', 'status', 'list', 'logs'], changeset: ['--help', '--version', 'help', 'status'] };
|
|
638
|
+
const lits = statics(args);
|
|
639
|
+
if (lits.length && readOnly[prog].includes(lits[0]))
|
|
640
|
+
return;
|
|
641
|
+
if (prog === 'wrangler' && ['versions list', 'versions view', 'deployments list', 'deployments status'].includes(lits.slice(0, 2).join(' ')))
|
|
642
|
+
return;
|
|
643
|
+
return unresolved(sc.sh, `${prog} command outside the read-only allowlist: your owner runs it`);
|
|
644
|
+
}
|
|
645
|
+
function classifyTc(args, sc) {
|
|
646
|
+
// `tc` reaches an owner profile (any `--profile`, `TC_HOME`, the default one): a woken agent only gets `tc courier …`.
|
|
647
|
+
const first = args[0];
|
|
648
|
+
if (!first || first.dynamic || first.value.toLowerCase() !== 'courier') {
|
|
649
|
+
return never(sc, 'tc/tinycloud is not available to a woken agent (it reaches an owner profile); use `courier …` or `tc courier …`');
|
|
650
|
+
}
|
|
651
|
+
classifyCourier(args.slice(1), sc);
|
|
652
|
+
}
|
|
653
|
+
/** Subcommands for humans only: pairing, grants, the wake budget, the watcher, the broker's credential, the guard's own settings. */
|
|
654
|
+
const COURIER_HUMAN_ONLY = new Set(['pair', 'revoke', 'init', 'pause', 'resume', 'watch', 'broker', 'guard']);
|
|
655
|
+
function classifyCourier(args, sc) {
|
|
656
|
+
if (args.some((a) => a.dynamic))
|
|
657
|
+
return unresolved(sc.sh, 'courier command with words built at run time');
|
|
658
|
+
// The CLI's own parser decides what the subcommand is: `courier --peer bob pause` is `pause`, and everything after `--` is positional.
|
|
659
|
+
const parsed = parseArgs(args.map((a) => a.value));
|
|
660
|
+
const sub = parsed.positional[0]?.toLowerCase();
|
|
661
|
+
if (!sub)
|
|
662
|
+
return;
|
|
663
|
+
if (COURIER_HUMAN_ONLY.has(sub)) {
|
|
664
|
+
return never(sc, `courier ${sub} is for humans (pairing, grants, the wake budget, the watcher, the broker credential and the guard settings are never touched by an agent)`);
|
|
665
|
+
}
|
|
666
|
+
if (!['manifest', 'send', 'inbox', 'read', 'task', 'request', 'orders', 'approval', 'run-approved'].includes(sub))
|
|
667
|
+
return unresolved(sc.sh, 'Courier subcommand is outside the agent allowlist');
|
|
668
|
+
if (sub === 'approval' && !parsed.has('peek')) {
|
|
669
|
+
return never(sc, 'courier approval without --peek would use the approval up; check with --peek, and run the approved action with `courier run-approved <requestId>`');
|
|
670
|
+
}
|
|
671
|
+
if (sub === 'run-approved') {
|
|
672
|
+
if (parsed.positional.length !== 2 || !ULID.test(parsed.positional[1]) || parsed.flags.size > 0) {
|
|
673
|
+
return unresolved(sc.sh, 'courier run-approved takes exactly one request id and no flags: `courier run-approved <requestId>`');
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
// ---------- scripts and files ----------
|
|
678
|
+
function classifyScriptText(text, sc, label) {
|
|
679
|
+
if (text.includes('\0'))
|
|
680
|
+
return;
|
|
681
|
+
for (const hit of codeHits(text))
|
|
682
|
+
unresolved(sc.sh, `${label} contains ${hit}`);
|
|
683
|
+
}
|
|
684
|
+
function classifyFile(file, sc, asShell, io) {
|
|
685
|
+
if (file.dynamic)
|
|
686
|
+
return unresolved(sc.sh, 'script path built at run time');
|
|
687
|
+
if (/^\/(dev\/(stdin|fd\/\d+)|proc\/self\/fd\/\d+)$/.test(file.value)) {
|
|
688
|
+
// `bash /dev/stdin <<< "…"`, `. /dev/fd/3`: the commands come from stdin or a descriptor.
|
|
689
|
+
if (io && (io.heredocs.length || io.hereStrings.length)) {
|
|
690
|
+
for (const body of [...io.heredocs, ...io.hereStrings])
|
|
691
|
+
analyzeScript(body, sc, sc.inexact);
|
|
692
|
+
return;
|
|
693
|
+
}
|
|
694
|
+
return unresolved(sc.sh, 'commands read from stdin or a file descriptor');
|
|
695
|
+
}
|
|
696
|
+
if (sc.cwdUnknown && !path.isAbsolute(file.value))
|
|
697
|
+
return unresolved(sc.sh, 'script path relative to a directory that cannot be determined');
|
|
698
|
+
const candidates = path.isAbsolute(file.value) ? [file.value] : sc.cwds.map((d) => path.resolve(d, file.value));
|
|
699
|
+
for (const c of candidates) {
|
|
700
|
+
if (sc.written.has(c))
|
|
701
|
+
return unresolved(sc.sh, 'runs a file that this same command writes');
|
|
702
|
+
}
|
|
703
|
+
if (sc.depth + 1 > MAX_DEPTH)
|
|
704
|
+
return unresolved(sc.sh, 'scripts nested too deeply to inspect');
|
|
705
|
+
for (const c of candidates) {
|
|
706
|
+
const r = sc.sh.env.readFile(c);
|
|
707
|
+
if (r.state === 'absent')
|
|
708
|
+
continue;
|
|
709
|
+
if (r.state === 'unreadable')
|
|
710
|
+
return unresolved(sc.sh, 'script file cannot be read (too large or no access)');
|
|
711
|
+
if (r.text.includes('\0'))
|
|
712
|
+
return;
|
|
713
|
+
const shebang = /^#!\s*(\S+)(?:\s+(\S+))?/.exec(r.text);
|
|
714
|
+
const interp = shebang ? (progName(shebang[1]) === 'env' ? progName(shebang[2] ?? '') : progName(shebang[1])) : '';
|
|
715
|
+
if (asShell || (shebang ? SHELLS.has(interp) : !SOURCE_EXT.test(c) || /\.(sh|bash|zsh)$/i.test(c))) {
|
|
716
|
+
analyzeScript(r.text, sc, sc.inexact);
|
|
717
|
+
}
|
|
718
|
+
else {
|
|
719
|
+
classifyScriptText(r.text, sc, `${path.basename(c)}`);
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
function classifyShell(args, sc, io) {
|
|
724
|
+
let i = 0;
|
|
725
|
+
let c = false;
|
|
726
|
+
for (; i < args.length; i++) {
|
|
727
|
+
const a = args[i];
|
|
728
|
+
if (a.dynamic)
|
|
729
|
+
return unresolved(sc.sh, 'shell options built at run time');
|
|
730
|
+
const v = a.value;
|
|
731
|
+
if (v === '--') {
|
|
732
|
+
i++;
|
|
733
|
+
break;
|
|
734
|
+
}
|
|
735
|
+
if (!(v.startsWith('-') || v.startsWith('+')) || v === '-' || v === '+')
|
|
736
|
+
break;
|
|
737
|
+
if (/^-[A-Za-z]*c[A-Za-z]*$/.test(v))
|
|
738
|
+
c = true;
|
|
739
|
+
if (v === '-o' || v === '+o' || v === '-O' || v === '+O' || v === '--rcfile' || v === '--init-file')
|
|
740
|
+
i++;
|
|
741
|
+
}
|
|
742
|
+
const next = args[i];
|
|
743
|
+
if (c) {
|
|
744
|
+
if (!next)
|
|
745
|
+
return;
|
|
746
|
+
if (next.dynamic)
|
|
747
|
+
return unresolved(sc.sh, 'shell -c command built at run time');
|
|
748
|
+
return analyzeScript(next.value, sc, sc.inexact);
|
|
749
|
+
}
|
|
750
|
+
if (next)
|
|
751
|
+
return classifyFile(next, sc, true, io);
|
|
752
|
+
for (const body of [...io.heredocs, ...io.hereStrings])
|
|
753
|
+
analyzeScript(body, sc, sc.inexact);
|
|
754
|
+
if (io.stdinFile)
|
|
755
|
+
return classifyFile(io.stdinFile, sc, true, io);
|
|
756
|
+
if (io.otherStdin)
|
|
757
|
+
return unresolved(sc.sh, 'a shell reading commands from a file descriptor');
|
|
758
|
+
if (io.piped && !io.heredocs.length && !io.hereStrings.length)
|
|
759
|
+
unresolved(sc.sh, 'a shell reading commands from a pipe');
|
|
760
|
+
}
|
|
761
|
+
const INTERPRETER_PACKAGES = new Set([
|
|
762
|
+
...Object.keys(PKG_NAMES), ...AGENT_CLIS, ...NPM_FAMILY, 'gh', 'git', 'wrangler', 'changeset',
|
|
763
|
+
'semantic-release', 'lerna', 'turbo', 'nx', 'vercel', 'netlify-cli', 'flyctl',
|
|
764
|
+
'@getpaseo/cli', '@google/gemini-cli', 'opencode-ai', '@cloudflare/wrangler',
|
|
765
|
+
'@oh-my-pi/pi-coding-agent', 'phala',
|
|
766
|
+
]);
|
|
767
|
+
function scriptPackage(file, sc) {
|
|
768
|
+
for (let dir = path.dirname(file);; dir = path.dirname(dir)) {
|
|
769
|
+
const r = sc.sh.env.readFile(path.join(dir, 'package.json'));
|
|
770
|
+
if (r.state !== 'absent') {
|
|
771
|
+
if (r.state === 'text' && !r.text.includes('\0')) {
|
|
772
|
+
try {
|
|
773
|
+
const pkg = JSON.parse(r.text);
|
|
774
|
+
return typeof pkg?.name === 'string' ? pkg.name : undefined;
|
|
775
|
+
}
|
|
776
|
+
catch { /* refuse below */ }
|
|
777
|
+
}
|
|
778
|
+
unresolved(sc.sh, 'script package manifest cannot be inspected');
|
|
779
|
+
return;
|
|
780
|
+
}
|
|
781
|
+
if (path.dirname(dir) === dir)
|
|
782
|
+
return;
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
function interpreterFile(file, rest, sc, io) {
|
|
786
|
+
if (sc.depth >= MAX_DEPTH)
|
|
787
|
+
return unresolved(sc.sh, 'interpreter inputs nested too deeply to inspect');
|
|
788
|
+
if (file.dynamic || (sc.cwdUnknown && !path.isAbsolute(file.value)))
|
|
789
|
+
return unresolved(sc.sh, 'interpreter script path cannot be resolved');
|
|
790
|
+
for (const cwd of sc.cwds) {
|
|
791
|
+
const absolute = path.resolve(cwd, file.value);
|
|
792
|
+
const real = realpathBest(absolute);
|
|
793
|
+
const scope = { ...sc, depth: sc.depth + 1, cwds: [cwd], externalOnly: true };
|
|
794
|
+
if (program(real, scope)) {
|
|
795
|
+
const argv = [{ value: real, dynamic: false }, ...rest];
|
|
796
|
+
classify(argv, argv, scope, io);
|
|
797
|
+
continue;
|
|
798
|
+
}
|
|
799
|
+
const pkg = scriptPackage(real, sc);
|
|
800
|
+
if ((pkg && INTERPRETER_PACKAGES.has(pkg)) || /(?:^|\/)(?:@tinycloud\/cli|@getpaseo\/cli|@anthropic-ai\/claude-code|@openai\/codex|@google\/gemini-cli|opencode-ai|paseo|claude|codex|omp|opencode|gemini)\/(?:bin|dist|src)\//.test(real)) {
|
|
801
|
+
never(sc, 'agent and gated-tool package entrypoints are not available through an interpreter');
|
|
802
|
+
continue;
|
|
803
|
+
}
|
|
804
|
+
const r = sc.sh.env.readFile(real);
|
|
805
|
+
if (r.state !== 'text' || r.text.includes('\0'))
|
|
806
|
+
unresolved(sc.sh, `${file.value} cannot be inspected`);
|
|
807
|
+
else
|
|
808
|
+
classifyScriptText(r.text, sc, file.value);
|
|
809
|
+
if (sc.written.has(absolute) || sc.written.has(real))
|
|
810
|
+
unresolved(sc.sh, 'runs a file that this same command writes');
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
function classifyInterpreter(prog, args, sc, io) {
|
|
814
|
+
const entry = args[0];
|
|
815
|
+
const kind = entry && !entry.dynamic && !entry.value.startsWith('-') ? courierEntry(entry.value, sc) : undefined;
|
|
816
|
+
if ((prog === 'node' || prog === 'nodejs') && kind)
|
|
817
|
+
return kind === 'tc' ? classifyTc(args.slice(1), sc) : classifyCourier(args.slice(1), sc);
|
|
818
|
+
// These Bun subcommands were already inspected as package commands and script bodies.
|
|
819
|
+
if (prog === 'bun' && entry && ['run', 'test', 'publish', 'install', 'add', 'update', 'remove', 'x'].includes(entry.value))
|
|
820
|
+
return;
|
|
821
|
+
const node = ['node', 'nodejs', 'bun', 'tsx', 'ts-node'].includes(prog);
|
|
822
|
+
const python = /^python/.test(prog);
|
|
823
|
+
let inline = false;
|
|
824
|
+
let script = false;
|
|
825
|
+
let options = true;
|
|
826
|
+
for (let i = 0; i < args.length; i++) {
|
|
827
|
+
const arg = args[i];
|
|
828
|
+
if (arg.dynamic)
|
|
829
|
+
return unresolved(sc.sh, 'interpreter options or script path built at run time');
|
|
830
|
+
const v = arg.value;
|
|
831
|
+
if (options && v === '--') {
|
|
832
|
+
options = false;
|
|
833
|
+
continue;
|
|
834
|
+
}
|
|
835
|
+
if (options && v.startsWith('-') && v !== '-') {
|
|
836
|
+
const preload = node || prog === 'ruby' ? /^(?:-r|--require|--import|--loader|--experimental-loader)(?:=(.*))?$/.exec(v) : undefined;
|
|
837
|
+
const shortPreload = (node || prog === 'ruby') && /^-r.+/.test(v) ? v.slice(2) : undefined;
|
|
838
|
+
if (preload || shortPreload) {
|
|
839
|
+
const file = preload?.[1] !== undefined ? { value: preload[1], dynamic: false } : shortPreload ? { value: shortPreload, dynamic: false } : args[++i];
|
|
840
|
+
if (!file || file.dynamic || !/^(?:\.{1,2}\/|\/)/.test(file.value))
|
|
841
|
+
return unresolved(sc.sh, 'interpreter preload must name an inspectable file');
|
|
842
|
+
interpreterFile(file, args.slice(i + 1), sc, io);
|
|
843
|
+
continue;
|
|
844
|
+
}
|
|
845
|
+
const codeFlag = node ? /^(?:-e|-p|--eval|--print)(?:=(.*))?$/.exec(v)
|
|
846
|
+
: python ? /^-c$/.exec(v) : prog === 'php' ? /^(?:-r|--run)$/.exec(v) : /^-e$/.exec(v);
|
|
847
|
+
if (codeFlag) {
|
|
848
|
+
const code = codeFlag[1] !== undefined ? { value: codeFlag[1], dynamic: false } : args[++i];
|
|
849
|
+
if (!code || code.dynamic)
|
|
850
|
+
return unresolved(sc.sh, 'interpreter inline code cannot be inspected');
|
|
851
|
+
classifyScriptText(code.value, sc, `${prog} code`);
|
|
852
|
+
inline = true;
|
|
853
|
+
continue;
|
|
854
|
+
}
|
|
855
|
+
if ((python && /^-m/.test(v)) || (!node && /^-(?:M|m|r)/.test(v)))
|
|
856
|
+
return unresolved(sc.sh, 'interpreter module identity cannot be resolved');
|
|
857
|
+
if (python && ['-W', '-X'].includes(v)) {
|
|
858
|
+
const value = args[++i];
|
|
859
|
+
if (!value || value.dynamic || (v === '-X' && !['dev', 'utf8', 'faulthandler', 'importtime'].includes(value.value)))
|
|
860
|
+
return unresolved(sc.sh, 'interpreter option cannot be inspected');
|
|
861
|
+
continue;
|
|
862
|
+
}
|
|
863
|
+
if (/^(?:--(?:help|version|check|no-warnings|trace-warnings|enable-source-maps)|-[vVhBIiSsuOEb]+)$/.test(v))
|
|
864
|
+
continue;
|
|
865
|
+
return unresolved(sc.sh, 'interpreter option cannot be resolved safely');
|
|
866
|
+
}
|
|
867
|
+
if (!inline && v !== '-') {
|
|
868
|
+
interpreterFile(arg, args.slice(i + 1), sc, io);
|
|
869
|
+
script = true;
|
|
870
|
+
}
|
|
871
|
+
break;
|
|
872
|
+
}
|
|
873
|
+
if (!inline && !script && io.stdinFile)
|
|
874
|
+
interpreterFile(io.stdinFile, [], sc, io);
|
|
875
|
+
if (!inline && !script && io.otherStdin)
|
|
876
|
+
unresolved(sc.sh, 'interpreter input descriptor cannot be inspected');
|
|
877
|
+
for (const body of [...io.heredocs, ...io.hereStrings])
|
|
878
|
+
classifyScriptText(body, sc, `${prog} stdin`);
|
|
879
|
+
if (!inline && io.piped && (args.length === 0 || (args.length === 1 && args[0].value === '-')))
|
|
880
|
+
unresolved(sc.sh, `${prog} reading code from a pipe`);
|
|
881
|
+
}
|
|
882
|
+
function classifyWrapper(prog, args, top, sc, io) {
|
|
883
|
+
const inner = { ...sc, inexact: sc.inexact || REPEATERS.has(prog), externalOnly: !['command', 'builtin'].includes(prog) };
|
|
884
|
+
if (prog === 'find') {
|
|
885
|
+
for (let i = 0; i < args.length; i++) {
|
|
886
|
+
if (args[i].dynamic)
|
|
887
|
+
continue;
|
|
888
|
+
if (['-exec', '-execdir', '-ok', '-okdir'].includes(args[i].value)) {
|
|
889
|
+
let end = i + 1;
|
|
890
|
+
while (end < args.length && ![';', '+'].includes(args[end].value))
|
|
891
|
+
end++;
|
|
892
|
+
if (end === args.length)
|
|
893
|
+
return unresolved(sc.sh, 'find command terminator cannot be read');
|
|
894
|
+
classify(args.slice(i + 1, end), top, inner, io);
|
|
895
|
+
i = end;
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
return;
|
|
899
|
+
}
|
|
900
|
+
if (!['env', 'command', 'builtin', 'exec', 'xargs', 'nohup', 'nice', 'timeout'].includes(prog))
|
|
901
|
+
return unresolved(sc.sh, 'wrapper executable cannot be resolved safely; run the installed tool directly');
|
|
902
|
+
const values = new Set(prog === 'env' ? ['-u', '--unset', '-C', '--chdir'] : prog === 'xargs' ? ['-n', '-P', '-I', '-L', '-s', '--max-args', '--max-procs', '--replace', '--max-lines'] : prog === 'timeout' ? ['-s', '--signal', '-k', '--kill-after'] : prog === 'nice' ? ['-n', '--adjustment'] : []);
|
|
903
|
+
let i = 0;
|
|
904
|
+
for (; i < args.length; i++) {
|
|
905
|
+
const a = args[i];
|
|
906
|
+
if (a.dynamic)
|
|
907
|
+
return unresolved(sc.sh, 'wrapper command/option built at run time');
|
|
908
|
+
if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(a.value)) {
|
|
909
|
+
if (SENSITIVE_VAR.test(a.value.split('=')[0]))
|
|
910
|
+
return never(sc, 'changing the tool environment is not allowed');
|
|
911
|
+
if (prog === 'env')
|
|
912
|
+
continue;
|
|
913
|
+
}
|
|
914
|
+
if (a.value === '--') {
|
|
915
|
+
i++;
|
|
916
|
+
break;
|
|
917
|
+
}
|
|
918
|
+
if ((prog === 'env' && /^(?:-S|--split-string|-C|--chdir|-i$|--ignore-environment|-u|--unset)/.test(a.value)) || (prog === 'command' && a.value === '-p'))
|
|
919
|
+
return unresolved(sc.sh, 'wrapper changes executable lookup and cannot preserve its verified identity');
|
|
920
|
+
if (!a.value.startsWith('-'))
|
|
921
|
+
break;
|
|
922
|
+
if (values.has(a.value))
|
|
923
|
+
i++;
|
|
924
|
+
}
|
|
925
|
+
if (prog === 'timeout' && i < args.length)
|
|
926
|
+
i++;
|
|
927
|
+
if (i === args.length) {
|
|
928
|
+
if (prog === 'xargs')
|
|
929
|
+
classify([{ value: 'echo', dynamic: false }], top, inner, io);
|
|
930
|
+
return;
|
|
931
|
+
}
|
|
932
|
+
classify(args.slice(i), top, inner, io);
|
|
933
|
+
}
|
|
934
|
+
/** Classify one simple command. `top` is the whole command as typed (what an approval has to name). */
|
|
935
|
+
function classify(argv, top, sc, io) {
|
|
936
|
+
if (!argv.length)
|
|
937
|
+
return;
|
|
938
|
+
const head = argv[0];
|
|
939
|
+
if (head.dynamic)
|
|
940
|
+
return unresolved(sc.sh, 'executable identity cannot be resolved from a dynamic command word');
|
|
941
|
+
const prog = program(head.value, sc);
|
|
942
|
+
if (!prog) {
|
|
943
|
+
const before = sc.sh.findings.length;
|
|
944
|
+
classifyFile(head, sc, false, io);
|
|
945
|
+
if (sc.sh.findings.length === before)
|
|
946
|
+
unresolved(sc.sh, 'unregistered executable identity: run an installed tool or a verified interpreter explicitly');
|
|
947
|
+
return;
|
|
948
|
+
}
|
|
949
|
+
const args = argv.slice(1);
|
|
950
|
+
for (const arg of args)
|
|
951
|
+
if (!INTERPRETERS.has(prog) && !arg.dynamic && arg.value.includes('/') && registeredFile(arg.value, sc.cwds[0], sc.sh.env.binaries ?? []))
|
|
952
|
+
return never(sc, 'registered executable files cannot be read, rewritten or repurposed');
|
|
953
|
+
if (/^secret[-_]?bridge$/.test(prog))
|
|
954
|
+
return never(sc, 'Secret Bridge is retired and never allowed');
|
|
955
|
+
if (AGENT_CLIS.has(prog))
|
|
956
|
+
return never(sc, `${prog} would start another agent without the Courier guard; do the work yourself`);
|
|
957
|
+
if (NEVER_PROGS.has(prog))
|
|
958
|
+
return never(sc, `${prog} is not available to a woken agent (it changes user, outlives the call, or reaches another machine or a local service)`);
|
|
959
|
+
if (head.value.includes('=') && SENSITIVE_VAR.test(head.value.split('=')[0]))
|
|
960
|
+
return never(sc, `setting ${head.value.split('=')[0]} is not allowed for a woken agent`);
|
|
961
|
+
if (['ln', 'cp', 'mv', 'install', 'hardlink'].includes(prog)) {
|
|
962
|
+
for (const arg of args)
|
|
963
|
+
if (!arg.dynamic && !arg.value.startsWith('-') && gatedSource(arg.value, sc.cwds[0], sc.sh.env.binaries ?? []))
|
|
964
|
+
return never(sc, 'copying, linking or moving a gated executable is not available');
|
|
965
|
+
}
|
|
966
|
+
if (WRAPPERS.has(prog))
|
|
967
|
+
return classifyWrapper(prog, args, top, sc, io);
|
|
968
|
+
if (SHELLS.has(prog))
|
|
969
|
+
return classifyShell(args, sc, io);
|
|
970
|
+
switch (prog) {
|
|
971
|
+
case 'eval': {
|
|
972
|
+
if (args.some((a) => a.dynamic))
|
|
973
|
+
return unresolved(sc.sh, 'eval of text built at run time');
|
|
974
|
+
return analyzeScript(args.map((a) => a.value).join(' '), sc, sc.inexact);
|
|
975
|
+
}
|
|
976
|
+
case 'source':
|
|
977
|
+
case '.': {
|
|
978
|
+
const file = args.find((a) => a.dynamic || !a.value.startsWith('-'));
|
|
979
|
+
if (file)
|
|
980
|
+
classifyFile(file, sc, true, io);
|
|
981
|
+
return;
|
|
982
|
+
}
|
|
983
|
+
case 'gh': return classifyGh(args, top, sc, io);
|
|
984
|
+
case 'git': return classifyGit(args, top, sc, io);
|
|
985
|
+
case 'tc':
|
|
986
|
+
case 'tinycloud': return classifyTc(args, sc);
|
|
987
|
+
case 'export':
|
|
988
|
+
case 'declare':
|
|
989
|
+
case 'typeset':
|
|
990
|
+
case 'readonly':
|
|
991
|
+
case 'local':
|
|
992
|
+
case 'unset':
|
|
993
|
+
case 'set': {
|
|
994
|
+
for (const a of args) {
|
|
995
|
+
const name = a.value.replace(/^-+/, '').split('=')[0];
|
|
996
|
+
if (a.dynamic || (name && SENSITIVE_VAR.test(name)))
|
|
997
|
+
return never(sc, `changing ${name || 'the environment'} is not allowed for a woken agent`);
|
|
998
|
+
}
|
|
999
|
+
return;
|
|
1000
|
+
}
|
|
1001
|
+
case 'vercel':
|
|
1002
|
+
case 'netlify':
|
|
1003
|
+
case 'fly':
|
|
1004
|
+
case 'flyctl':
|
|
1005
|
+
case 'docker': {
|
|
1006
|
+
const readOnly = ['--help', '--version', 'help', 'version', 'info', 'status', 'list', 'ls', 'logs', 'inspect', 'ps', 'images'];
|
|
1007
|
+
if (args[0] && !args[0].dynamic && readOnly.includes(args[0].value))
|
|
1008
|
+
return;
|
|
1009
|
+
return unresolved(sc.sh, `${prog} command outside the read-only allowlist: your owner runs it`);
|
|
1010
|
+
}
|
|
1011
|
+
case 'semantic-release': return unresolved(sc.sh, 'semantic-release publishes from agent-controlled code; your owner runs it');
|
|
1012
|
+
case 'make':
|
|
1013
|
+
case 'gmake':
|
|
1014
|
+
case 'turbo':
|
|
1015
|
+
case 'nx':
|
|
1016
|
+
case 'just': return unresolved(sc.sh, 'task runner recipes cannot be resolved safely; run the literal commands instead');
|
|
1017
|
+
case 'courier': return classifyCourier(args, sc);
|
|
1018
|
+
case 'wrangler': return classifyTool('wrangler', args, top, sc);
|
|
1019
|
+
case 'phala': return classifyTool('phala', args, top, sc);
|
|
1020
|
+
case 'changeset': return classifyTool('changeset', args, top, sc);
|
|
1021
|
+
case 'lerna':
|
|
1022
|
+
if (statics(args).includes('publish'))
|
|
1023
|
+
gate(sc, 'lerna publish', top, () => 'lerna publish is not supported; use `changeset publish` or `npm publish` from a package directory');
|
|
1024
|
+
return;
|
|
1025
|
+
default:
|
|
1026
|
+
}
|
|
1027
|
+
if (NPM_FAMILY.has(prog)) {
|
|
1028
|
+
classifyNpm(prog, args, top, sc, io);
|
|
1029
|
+
if (prog !== 'bun')
|
|
1030
|
+
return;
|
|
1031
|
+
}
|
|
1032
|
+
if (CURLS.has(prog))
|
|
1033
|
+
return classifyCurl(args, top, sc, io);
|
|
1034
|
+
if (INTERPRETERS.has(prog))
|
|
1035
|
+
return classifyInterpreter(prog, args, sc, io);
|
|
1036
|
+
}
|
|
1037
|
+
const WRITERS = new Set(['tee', 'cp', 'mv', 'install', 'ln', 'hardlink', 'sponge', 'dd']);
|
|
1038
|
+
/** Resolve a command string into findings. */
|
|
1039
|
+
export function analyze(command, env) {
|
|
1040
|
+
const sh = { env, findings: [], seen: new Set() };
|
|
1041
|
+
const sc = { sh, depth: 0, cwds: [path.resolve(env.cwd)], cwdUnknown: false, written: new Set(), inexact: false, assigns: [] };
|
|
1042
|
+
analyzeScript(command, sc, false);
|
|
1043
|
+
return sh.findings;
|
|
1044
|
+
}
|
|
1045
|
+
function analyzeScript(text, parent, inexact) {
|
|
1046
|
+
const sh = parent.sh;
|
|
1047
|
+
if (parent.depth + 1 > MAX_DEPTH)
|
|
1048
|
+
return unresolved(sh, 'scripts nested too deeply to inspect');
|
|
1049
|
+
// Credential and guard-configuration paths are refused wherever they appear, read or write (R2, B1, B2).
|
|
1050
|
+
if (networkTextRefusal(unquoted(text)))
|
|
1051
|
+
return never(parent, 'loopback, numeric IP or local daemon requests are not available');
|
|
1052
|
+
const credential = credentialTextRefusal(text);
|
|
1053
|
+
if (credential)
|
|
1054
|
+
return never(parent, `refused: ${credential}`);
|
|
1055
|
+
const parsed = parseShell(text);
|
|
1056
|
+
if (parsed.errors.length)
|
|
1057
|
+
unresolved(sh, `could not read the command (${parsed.errors[0]})`);
|
|
1058
|
+
for (const command of parsed.cmds) {
|
|
1059
|
+
for (const assignment of command.assigns)
|
|
1060
|
+
if (SENSITIVE_VAR.test(assignment.name))
|
|
1061
|
+
return never(parent, `setting ${assignment.name} is not allowed for a woken agent`);
|
|
1062
|
+
const words = command.words.map(wordLiteral);
|
|
1063
|
+
if (words[0] && ['env', 'export', 'declare', 'typeset', 'readonly', 'local', 'set', 'unset'].includes(progName(words[0]))) {
|
|
1064
|
+
for (const word of words.slice(1))
|
|
1065
|
+
if (word && SENSITIVE_VAR.test(word.replace(/^-+/, '').split('=')[0]))
|
|
1066
|
+
return never(parent, 'changing the credential or tool environment is not allowed for a woken agent');
|
|
1067
|
+
}
|
|
1068
|
+
}
|
|
1069
|
+
const refusal = expansionRefusal(text, parsed);
|
|
1070
|
+
if (refusal)
|
|
1071
|
+
return unresolved(sh, refusal);
|
|
1072
|
+
// A literal installed CLI entry must be callable; arbitrary reads/writes of its install still refuse.
|
|
1073
|
+
const single = parsed.cmds.length === 1 ? parsed.cmds[0] : undefined;
|
|
1074
|
+
const words = single?.words.map(wordLiteral);
|
|
1075
|
+
if (single && words && words.every((w) => w !== undefined) && ['node', 'nodejs'].includes(program(words[0] ?? '', parent) ?? '') &&
|
|
1076
|
+
words[1] && courierEntry(words[1], parent)) {
|
|
1077
|
+
if (single.assigns.length || single.redirs.length || single.inLoop || single.inFunc || single.piped)
|
|
1078
|
+
return unresolved(sh, 'Courier entry must run as one literal command without redirects or wrappers');
|
|
1079
|
+
const protectedArgs = protectedTextRefusal(words.slice(2).join(' '), sh.env.literals);
|
|
1080
|
+
if (protectedArgs)
|
|
1081
|
+
return never(parent, `refused: ${protectedArgs}`);
|
|
1082
|
+
return classifyInterpreter(program(words[0], parent), words.slice(1).map((value) => ({ value: value, dynamic: false })), parent, { heredocs: [], hereStrings: [], piped: false, otherStdin: false });
|
|
1083
|
+
}
|
|
1084
|
+
if (single && words && words.every(w => w !== undefined) && courierEntry(words[0] ?? '', parent)) {
|
|
1085
|
+
if (single.assigns.length || single.redirs.length || single.inLoop || single.inFunc || single.piped)
|
|
1086
|
+
return unresolved(sh, 'Courier entry must run as one literal command without redirects or wrappers');
|
|
1087
|
+
const protectedArgs = protectedTextRefusal(words.slice(1).join(' '), sh.env.literals);
|
|
1088
|
+
if (protectedArgs)
|
|
1089
|
+
return never(parent, `refused: ${protectedArgs}`);
|
|
1090
|
+
const rest = words.slice(1).map(value => ({ value: value, dynamic: false }));
|
|
1091
|
+
return courierEntry(words[0], parent) === 'tc' ? classifyTc(rest, parent) : classifyCourier(rest, parent);
|
|
1092
|
+
}
|
|
1093
|
+
const pathRefusal = pathTextRefusal(text, parent.cwds[0], sh.env.getenv('HOME') ?? '/nonexistent-agent-home', sh.env.protectedRoots ?? sh.env.literals ?? [], sh.env.realHome);
|
|
1094
|
+
if (pathRefusal)
|
|
1095
|
+
return never(parent, `refused: ${pathRefusal}`);
|
|
1096
|
+
const protectedText = protectedTextRefusal(text, sh.env.literals);
|
|
1097
|
+
if (protectedText)
|
|
1098
|
+
return never(parent, `refused: ${protectedText}`);
|
|
1099
|
+
const sc = { ...parent, depth: parent.depth + 1, cwds: [...parent.cwds], written: new Set(parent.written), inexact: inexact || parent.inexact, assigns: [], externalOnly: false };
|
|
1100
|
+
const c = census(parsed.cmds);
|
|
1101
|
+
const resolved = parsed.cmds.map((cmd) => ({
|
|
1102
|
+
cmd,
|
|
1103
|
+
words: expandAll(cmd.words, c, cmd),
|
|
1104
|
+
redirs: cmd.redirs.map((r) => ({ op: r.op, target: expand(r.target, c, cmd) })),
|
|
1105
|
+
assigns: cmd.assigns.map((a) => ({ name: a.name, words: expand(a.value, c, cmd) })),
|
|
1106
|
+
here: cmd.hereStrings.map((h) => expand(h, c, cmd).map((w) => w.value).join(' ')),
|
|
1107
|
+
}));
|
|
1108
|
+
// Pass A: directories the command can move to, and files it writes.
|
|
1109
|
+
for (const r of resolved) {
|
|
1110
|
+
const head = r.words[0]?.dynamic ? undefined : program(r.words[0]?.value ?? '', sc);
|
|
1111
|
+
if (head === 'cd' || head === 'pushd' || head === 'popd') {
|
|
1112
|
+
const target = r.words.slice(1).find((w) => w.dynamic || !w.value.startsWith('-'));
|
|
1113
|
+
if (head === 'popd' || !target || target.dynamic || target.value === '-' || target.value.startsWith('~'))
|
|
1114
|
+
sc.cwdUnknown = true;
|
|
1115
|
+
else
|
|
1116
|
+
for (const d of [...sc.cwds])
|
|
1117
|
+
sc.cwds.push(path.resolve(d, target.value));
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
1120
|
+
const note = (w) => {
|
|
1121
|
+
if (!w)
|
|
1122
|
+
return;
|
|
1123
|
+
if (w.dynamic) {
|
|
1124
|
+
sc.cwdUnknown = sc.cwdUnknown || false;
|
|
1125
|
+
return;
|
|
1126
|
+
}
|
|
1127
|
+
for (const d of sc.cwds) {
|
|
1128
|
+
if (registeredFile(w.value, d, sc.sh.env.binaries ?? []))
|
|
1129
|
+
never(sc, 'registered executable files cannot be overwritten');
|
|
1130
|
+
sc.written.add(path.resolve(d, w.value));
|
|
1131
|
+
}
|
|
1132
|
+
};
|
|
1133
|
+
for (const r of resolved) {
|
|
1134
|
+
for (const red of r.redirs)
|
|
1135
|
+
if (['>', '>>', '>|', '&>', '&>>', '<>'].includes(red.op))
|
|
1136
|
+
note(red.target[0]);
|
|
1137
|
+
const head = r.words[0] && !r.words[0].dynamic ? program(r.words[0].value, sc) ?? '' : '';
|
|
1138
|
+
if (WRITERS.has(head))
|
|
1139
|
+
for (const w of r.words.slice(1)) {
|
|
1140
|
+
if (!w.dynamic && w.value.startsWith('of='))
|
|
1141
|
+
note({ value: w.value.slice(3), dynamic: false });
|
|
1142
|
+
else if (!w.value.startsWith('-'))
|
|
1143
|
+
note(w);
|
|
1144
|
+
}
|
|
1145
|
+
if (head === 'curl')
|
|
1146
|
+
r.words.forEach((w, i) => { if ((w.value === '-o' || w.value === '--output') && r.words[i + 1])
|
|
1147
|
+
note(r.words[i + 1]); });
|
|
1148
|
+
if (head === 'wget')
|
|
1149
|
+
r.words.forEach((w, i) => { if ((w.value === '-O' || w.value === '--output-document') && r.words[i + 1])
|
|
1150
|
+
note(r.words[i + 1]); });
|
|
1151
|
+
}
|
|
1152
|
+
// Pass B: classify every simple command.
|
|
1153
|
+
for (const r of resolved) {
|
|
1154
|
+
const cmdScope = {
|
|
1155
|
+
...sc,
|
|
1156
|
+
inexact: sc.inexact || r.cmd.inLoop || r.cmd.inFunc,
|
|
1157
|
+
assigns: r.assigns.map((a) => `${a.name}=${a.words.map((w) => w.value).join(' ')}`),
|
|
1158
|
+
};
|
|
1159
|
+
const stdin = r.redirs.find((x) => x.op === '<');
|
|
1160
|
+
const io = {
|
|
1161
|
+
heredocs: r.cmd.heredocs,
|
|
1162
|
+
hereStrings: r.here,
|
|
1163
|
+
piped: r.cmd.piped,
|
|
1164
|
+
otherStdin: r.redirs.some((x) => x.op === '<&' || x.op === '<>'),
|
|
1165
|
+
...(stdin?.target[0] ? { stdinFile: stdin.target[0] } : {}),
|
|
1166
|
+
};
|
|
1167
|
+
for (const a of r.assigns)
|
|
1168
|
+
if (SENSITIVE_VAR.test(a.name)) {
|
|
1169
|
+
never(cmdScope, `setting ${a.name} is not allowed for a woken agent`);
|
|
1170
|
+
}
|
|
1171
|
+
// Variables set for one command (`GH_REPO=x gh pr merge`) are part of what an approval has to name.
|
|
1172
|
+
if (r.words.length)
|
|
1173
|
+
classify(r.words, r.words, cmdScope, io);
|
|
1174
|
+
for (const red of r.redirs)
|
|
1175
|
+
if (red.op === '<' && !r.words.length)
|
|
1176
|
+
continue;
|
|
1177
|
+
}
|
|
1178
|
+
}
|
|
1179
|
+
//# sourceMappingURL=policy.js.map
|