@scopebond/hook 0.5.0 → 0.6.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/README.md +52 -6
- package/dist/cli.js +64 -8
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/init.d.ts.map +1 -1
- package/dist/init.js +2 -9
- package/dist/init.js.map +1 -1
- package/dist/install.d.ts +18 -1
- package/dist/install.d.ts.map +1 -1
- package/dist/install.js +84 -19
- package/dist/install.js.map +1 -1
- package/dist/map.d.ts +6 -3
- package/dist/map.d.ts.map +1 -1
- package/dist/map.js +762 -60
- package/dist/map.js.map +1 -1
- package/dist/minimize.d.ts +6 -3
- package/dist/minimize.d.ts.map +1 -1
- package/dist/minimize.js +24 -4
- package/dist/minimize.js.map +1 -1
- package/dist/runtime.d.ts +4 -0
- package/dist/runtime.d.ts.map +1 -1
- package/dist/runtime.js +75 -9
- package/dist/runtime.js.map +1 -1
- package/dist/shell.d.ts +53 -4
- package/dist/shell.d.ts.map +1 -1
- package/dist/shell.js +737 -81
- package/dist/shell.js.map +1 -1
- package/package.json +3 -3
package/dist/map.js
CHANGED
|
@@ -5,72 +5,729 @@
|
|
|
5
5
|
// A shell tool call can carry several commands (`a && b`, `$(c)`, `bash -c '…'`);
|
|
6
6
|
// the mapper decomposes it and returns one intent per simple command, so the
|
|
7
7
|
// runtime evaluates every one and denies if any is out of policy. Non-shell tools
|
|
8
|
-
// map to a single intent (a one-element array)
|
|
8
|
+
// map to a single intent (a one-element array), or one per spelling of an
|
|
9
|
+
// ambiguous Windows short path.
|
|
9
10
|
import { digest, redactCommand, scrubParam, scrubSecrets } from "./minimize.js";
|
|
10
|
-
import { decomposeShell, parseGitPush } from "./shell.js";
|
|
11
|
+
import { canonProgram, decomposeShell, gitArgs, parseGitPush } from "./shell.js";
|
|
11
12
|
const basename = (t) => t.replace(/^.*[\\/]/, "");
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
|
|
13
|
+
// Canonicalize a path before any guard sees it:
|
|
14
|
+
// - Windows backslash separators become "/" (the taxonomy path space), so a guard
|
|
15
|
+
// written with "/" cannot be bypassed by `.scopebond\agent.key`;
|
|
16
|
+
// - NTFS alternate-stream suffixes (`.env::$DATA`) and the trailing dots/spaces
|
|
17
|
+
// Windows silently drops from a name (`.env.`, `agent.key `) are removed, so they
|
|
18
|
+
// cannot disguise the file they open;
|
|
19
|
+
// - `//` and `/./` collapse.
|
|
20
|
+
// Case is preserved (it is evidence); the starter policy matches case-insensitively.
|
|
21
|
+
// Linear (no regex backtracking on long runs of dots/spaces).
|
|
22
|
+
const trimDotsSpaces = (seg) => {
|
|
23
|
+
let end = seg.length;
|
|
24
|
+
while (end > 0 && (seg[end - 1] === "." || seg[end - 1] === " "))
|
|
25
|
+
end--;
|
|
26
|
+
return end === 0 ? seg : seg.slice(0, end);
|
|
27
|
+
};
|
|
28
|
+
const normPath = (s) => s.replace(/\\/g, "/")
|
|
29
|
+
.replace(/::?\$data$/i, "")
|
|
30
|
+
.split("/").map((seg) => (seg === "." || seg === ".." ? seg : trimDotsSpaces(seg))).join("/")
|
|
31
|
+
.replace(/\/(?:\.\/)+/g, "/").replace(/\/{2,}/g, "/");
|
|
16
32
|
const rel = (value, cwd) => {
|
|
17
33
|
const s = normPath(String(value ?? ""));
|
|
18
34
|
const c = cwd ? normPath(cwd) : undefined;
|
|
19
|
-
if (c && s.startsWith(c))
|
|
20
|
-
return s.slice(c.length
|
|
35
|
+
if (c && s.toLowerCase().startsWith(c.toLowerCase() + "/"))
|
|
36
|
+
return s.slice(c.length + 1);
|
|
37
|
+
if (c && s.toLowerCase() === c.toLowerCase())
|
|
38
|
+
return ".";
|
|
21
39
|
return s;
|
|
22
40
|
};
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
//
|
|
41
|
+
// Windows 8.3 short names: `CLAUDE~1` opens `.claude`, `SCOPEB~1` opens `.scopebond`,
|
|
42
|
+
// `SETTIN~1.JSO` opens `settings.json`. A short segment that could abbreviate a
|
|
43
|
+
// protected name is expanded to every protected name it could stand for, and the
|
|
44
|
+
// path is checked in each spelling. Only the names below are expanded; any other
|
|
45
|
+
// short name is left as written.
|
|
46
|
+
const SHORT_SEGMENT = /^([^.~\s/]{1,6})~\d{1,6}(?:\.([^.\s/]{1,3}))?$/;
|
|
47
|
+
const SHORT_TARGETS = [
|
|
48
|
+
".scopebond", ".claude", ".cursor", ".codex", ".github", ".git", ".husky", ".circleci", ".gitlab-ci.yml", ".gitlab-ci.yaml",
|
|
49
|
+
".mcp.json", "azure-pipelines.yml", "Jenkinsfile", "workflows", "actions", "settings.json", "settings.local.json", "hooks.json",
|
|
50
|
+
"config.toml", "agents", ".ssh", ".aws", ".kube", ".docker", ".azure", ".gnupg", ".config", ".env", ".env.local", ".env.production",
|
|
51
|
+
".envrc", ".npmrc", ".pypirc", ".netrc", ".git-credentials", "credentials", ".credentials.json", "id_ed25519", "config.json",
|
|
52
|
+
"accessTokens.json", "credentials.db", "attester.key", "policy.json", "cloud.json",
|
|
53
|
+
];
|
|
54
|
+
function abbreviates(prefix, ext, long) {
|
|
55
|
+
const name = long.replace(/^\.+/, "");
|
|
56
|
+
const dot = name.lastIndexOf(".");
|
|
57
|
+
const base = (dot > 0 ? name.slice(0, dot) : name).replace(/[.\s]/g, "").toUpperCase();
|
|
58
|
+
const longExt = dot > 0 ? name.slice(dot + 1).toUpperCase() : "";
|
|
59
|
+
return base.length > 0 && base.startsWith(prefix.toUpperCase()) && longExt.slice(0, 3) === ext.toUpperCase()
|
|
60
|
+
&& (base.length > 8 || long.startsWith(".") || longExt.length > 3 || name.includes(" "));
|
|
61
|
+
}
|
|
62
|
+
/** Every spelling a path could have with its short segments expanded (at most 16). */
|
|
63
|
+
function pathVariants(path) {
|
|
64
|
+
if (!path.includes("~"))
|
|
65
|
+
return [path];
|
|
66
|
+
let variants = [[]];
|
|
67
|
+
for (const seg of path.split("/")) {
|
|
68
|
+
const m = SHORT_SEGMENT.exec(seg);
|
|
69
|
+
const long = m ? SHORT_TARGETS.filter((t) => abbreviates(m[1], m[2] ?? "", t)) : [];
|
|
70
|
+
const choices = long.length ? long : [seg];
|
|
71
|
+
const next = [];
|
|
72
|
+
for (const v of variants)
|
|
73
|
+
for (const c of choices)
|
|
74
|
+
if (next.length < 16)
|
|
75
|
+
next.push([...v, c]);
|
|
76
|
+
variants = next;
|
|
77
|
+
}
|
|
78
|
+
return [...new Set(variants.map((v) => v.join("/")))];
|
|
79
|
+
}
|
|
80
|
+
/** One file intent per spelling of `path` (normally one). */
|
|
81
|
+
function pathIntents(action, path, source, extra = {}) {
|
|
82
|
+
return pathVariants(path).map((p) => ({ intent: { action_type: action, params: { path: p, ...extra } }, evaluated: true, source }));
|
|
83
|
+
}
|
|
84
|
+
// Locations the starter policy protects, as concrete sample paths (files, and the
|
|
85
|
+
// protected directories themselves). An operand the mapper cannot resolve
|
|
86
|
+
// statically — a glob (`.scope*/agent.key`, `.*/*`), a variable (`$D/agent.key`), a
|
|
87
|
+
// brace list or a command substitution — is tested against these: if it could name
|
|
88
|
+
// one, the intent carries that protected path so the guard sees it (the literal
|
|
89
|
+
// operand is kept as `pattern` for the record). Otherwise the literal is used. This
|
|
90
|
+
// keeps shell expansion from hiding a protected file from the path guard.
|
|
91
|
+
const PROTECTED_SAMPLES = [
|
|
92
|
+
".scopebond/agent.key", ".scopebond/attester.key", ".scopebond/policy.json", ".scopebond/cloud.json",
|
|
93
|
+
"agent.key", "attester.key", "server.key", "cert.pem", "id.p12", "id.pfx",
|
|
94
|
+
".env", ".env.local", ".env.production", ".envrc",
|
|
95
|
+
".ssh/id_rsa", ".ssh/id_ed25519", ".aws/credentials", ".npmrc", ".pypirc", ".netrc", ".git-credentials",
|
|
96
|
+
".kube/config", ".docker/config.json", ".config/gcloud/credentials.db", ".azure/accessTokens.json",
|
|
97
|
+
".config/gh/hosts.yml", ".claude/.credentials.json",
|
|
98
|
+
".claude/settings.json", ".claude/settings.local.json", ".claude/hooks/hook.sh", ".claude/agents/agent.md",
|
|
99
|
+
".cursor/hooks.json", ".codex/hooks.json", ".codex/config.toml", ".mcp.json",
|
|
100
|
+
".git/hooks/pre-commit", ".git/config", ".husky/pre-commit", ".github/workflows/ci.yml", ".github/actions/a/action.yml",
|
|
101
|
+
".gitlab-ci.yml", ".gitlab-ci.yaml", ".circleci/config.yml", "azure-pipelines.yml", "Jenkinsfile",
|
|
102
|
+
// the protected directories themselves (a recursive reader or copier given the directory)
|
|
103
|
+
".scopebond", ".ssh", ".aws", ".kube", ".docker", ".azure", ".gnupg", ".config/gcloud", ".config/gh",
|
|
104
|
+
];
|
|
105
|
+
const UNRESOLVED = /[*?[\]{}$`]/;
|
|
106
|
+
const EXPANSION = /\$\{[^}]*\}|\$\([^)]*\)|`[^`]*`|\$[A-Za-z0-9_]+|[*?[\]{}]/g;
|
|
107
|
+
/** Convert a shell word with globs/variables/braces into an anchored matcher. Glob
|
|
108
|
+
* metacharacters stay within one path segment; a variable or substitution may span
|
|
109
|
+
* segments. Like the shell, a segment wildcard does not match a leading dot. Linear:
|
|
110
|
+
* no nested quantifiers are generated. */
|
|
111
|
+
function wordMatcher(word) {
|
|
112
|
+
let re = "";
|
|
113
|
+
let segStart = true;
|
|
114
|
+
for (let i = 0; i < word.length; i++) {
|
|
115
|
+
const c = word[i];
|
|
116
|
+
const leadingDot = segStart ? "(?!\\.)" : "";
|
|
117
|
+
if (c === "*")
|
|
118
|
+
re += leadingDot + "[^/]*";
|
|
119
|
+
else if (c === "?")
|
|
120
|
+
re += leadingDot + "[^/]";
|
|
121
|
+
else if (c === "[") {
|
|
122
|
+
const end = word.indexOf("]", i + 1);
|
|
123
|
+
re += leadingDot + "[^/]";
|
|
124
|
+
if (end > i)
|
|
125
|
+
i = end;
|
|
126
|
+
}
|
|
127
|
+
else if (c === "{") {
|
|
128
|
+
const end = word.indexOf("}", i + 1);
|
|
129
|
+
re += "[^/]*";
|
|
130
|
+
if (end > i)
|
|
131
|
+
i = end;
|
|
132
|
+
}
|
|
133
|
+
else if (c === "$" || c === "`") {
|
|
134
|
+
// $VAR, ${VAR…}, $(…) or `…`: any text, possibly several segments.
|
|
135
|
+
if (word[i + 1] === "{" || word[i + 1] === "(") {
|
|
136
|
+
const close = word[i + 1] === "{" ? "}" : ")";
|
|
137
|
+
const end = word.indexOf(close, i + 2);
|
|
138
|
+
if (end > i)
|
|
139
|
+
i = end;
|
|
140
|
+
}
|
|
141
|
+
else if (c === "`") {
|
|
142
|
+
const end = word.indexOf("`", i + 1);
|
|
143
|
+
if (end > i)
|
|
144
|
+
i = end;
|
|
145
|
+
}
|
|
146
|
+
else
|
|
147
|
+
while (i + 1 < word.length && /[A-Za-z0-9_]/.test(word[i + 1]))
|
|
148
|
+
i++;
|
|
149
|
+
re += ".*";
|
|
150
|
+
}
|
|
151
|
+
else
|
|
152
|
+
re += c.replace(/[.+^()|\\]/g, "\\$&");
|
|
153
|
+
segStart = c === "/";
|
|
154
|
+
}
|
|
155
|
+
return new RegExp("^(?:" + re + ")$", "i");
|
|
156
|
+
}
|
|
157
|
+
// The protected sample an unresolvable operand could name, if any. The operand's
|
|
158
|
+
// last N segments are compared with each N-segment sample, so any prefix — `x/../`,
|
|
159
|
+
// `$HOME/`, an absolute path — cannot move the tail out of view. A tail made only of
|
|
160
|
+
// wildcards or variables (`*`, the last segment of `src/*`, `$FILE`) names nothing and
|
|
161
|
+
// is not compared — `cat logs/*` is not a read of `agent.key` — but a tail with any
|
|
162
|
+
// literal text, even a lone dot, is: `.*/*` can reach `.scopebond/agent.key`.
|
|
163
|
+
const MAX_WORD = 4096;
|
|
164
|
+
function protectedCandidate(word) {
|
|
165
|
+
// An absurdly long unresolvable operand is not worth analysing: treat it as if it
|
|
166
|
+
// could name the hook's own files (fail closed) rather than scan it.
|
|
167
|
+
if (word.length > MAX_WORD)
|
|
168
|
+
return PROTECTED_SAMPLES[0];
|
|
169
|
+
const segs = word.split("/");
|
|
170
|
+
const cache = new Map();
|
|
171
|
+
for (const sample of PROTECTED_SAMPLES) {
|
|
172
|
+
const n = sample.split("/").length;
|
|
173
|
+
if (segs.length < n)
|
|
174
|
+
continue;
|
|
175
|
+
let m = cache.get(n);
|
|
176
|
+
if (m === undefined) {
|
|
177
|
+
const tail = segs.slice(-n).join("/");
|
|
178
|
+
m = /[^/]/.test(tail.replace(EXPANSION, "")) ? wordMatcher(tail) : null;
|
|
179
|
+
cache.set(n, m);
|
|
180
|
+
}
|
|
181
|
+
if (m && m.test(sample))
|
|
182
|
+
return sample;
|
|
183
|
+
}
|
|
184
|
+
return undefined;
|
|
185
|
+
}
|
|
186
|
+
/** The file intents for one path operand, resolving it against a `cd` prefix. */
|
|
187
|
+
function fileIntent(action, word, dir, cwd) {
|
|
188
|
+
let w = word;
|
|
189
|
+
if (dir && !/^(?:[\\/]|~|[A-Za-z]:|\$)/.test(w))
|
|
190
|
+
w = dir.replace(/\/+$/, "") + "/" + w;
|
|
191
|
+
const path = rel(scrubParam(w), cwd);
|
|
192
|
+
if (UNRESOLVED.test(w)) {
|
|
193
|
+
const hit = protectedCandidate(normPath(w));
|
|
194
|
+
if (hit)
|
|
195
|
+
return pathIntents(action, hit, "shell", { pattern: path });
|
|
196
|
+
}
|
|
197
|
+
return pathIntents(action, path, "shell");
|
|
198
|
+
}
|
|
199
|
+
// Shell programs whose operands are files whose CONTENT they output, copy or send. A
|
|
200
|
+
// read of the signing keys or a secret file through the shell (`cat .scopebond/attester.key`,
|
|
201
|
+
// `grep -r . .scopebond`) must reach the same file.read guard as the Read tool.
|
|
202
|
+
// Metadata-only programs (`ls`, `stat`, `test`, `[`, `file`) are not readers.
|
|
26
203
|
const READERS = new Set([
|
|
27
|
-
"cat", "tac", "less", "more", "head", "tail", "nl", "od", "xxd", "hexdump",
|
|
28
|
-
"
|
|
204
|
+
"cat", "tac", "less", "more", "head", "tail", "nl", "od", "xxd", "hexdump", "strings", "base64", "base32",
|
|
205
|
+
"bat", "type", "get-content", "gc", "grep", "egrep", "fgrep", "rg", "ag", "ack", "awk", "gawk", "mawk", "nawk", "sort",
|
|
206
|
+
"uniq", "cut", "paste", "diff", "cmp", "comm", "wc", "jq", "yq", "sed", "iconv", "openssl", "gpg", "md5sum",
|
|
207
|
+
"sha1sum", "sha256sum", "sha512sum", "shasum", "source", ".", "select-string", "sls",
|
|
208
|
+
"import-csv", "tar", "bsdtar", "zip", "7z", "7za", "7zz", "gzip", "bzip2", "xz", "zstd", "zcat", "view", "code",
|
|
209
|
+
]);
|
|
210
|
+
// Editors read their operands and can write them (`vim -es -c ':wq' f`, `ed f`).
|
|
211
|
+
const EDITORS = new Set(["vi", "vim", "nvim", "ex", "ed", "nano", "pico", "emacs", "micro", "joe", "kak", "hx", "helix", "mcedit"]);
|
|
212
|
+
// Programs that copy or move: sources are read, the destination is written; `mv`
|
|
213
|
+
// also removes its sources, so they are writes too.
|
|
214
|
+
const COPIERS = new Set(["cp", "mv", "rsync", "scp", "install", "ln", "copy-item", "cpi", "copy", "move-item", "mi", "move", "robocopy", "xcopy", "rename-item", "ren"]);
|
|
215
|
+
const MOVERS = new Set(["mv", "move-item", "mi", "move", "rename-item", "ren"]);
|
|
216
|
+
// Programs whose operands are files they WRITE (or whose metadata they change).
|
|
217
|
+
const WRITERS = new Set(["tee", "touch", "truncate", "set-content", "sc", "add-content", "ac", "out-file", "new-item", "ni", "clear-content", "clc", "chmod", "chown", "chattr", "attrib", "icacls", "set-acl", "shred", "unlink", "sponge"]);
|
|
218
|
+
// Programs that send what they are given elsewhere (network, clipboard, mail): an
|
|
219
|
+
// operand naming a secret location is a read of it.
|
|
220
|
+
const UPLOADERS = new Set([
|
|
221
|
+
"curl", "wget", "http", "https", "xh", "httpie", "nc", "ncat", "netcat", "socat", "telnet", "ftp", "sftp", "lftp", "smbclient",
|
|
222
|
+
"aws", "gsutil", "gcloud", "az", "rclone", "s3cmd", "gh", "glab", "mail", "mailx", "sendmail", "mutt", "xclip", "xsel",
|
|
223
|
+
"pbcopy", "wl-copy", "clip", "set-clipboard", "croc", "wormhole",
|
|
29
224
|
]);
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
225
|
+
// Flags whose value is a file the program reads (`curl -T f`, `curl -d @f`) or writes (`-o f`).
|
|
226
|
+
// Read flags apply to readers, copiers and uploaders only (`test -f .env` reads nothing).
|
|
227
|
+
const READ_FLAGS = new Set(["-t", "--upload-file", "-k", "--config", "--input-file", "--post-file", "--body-file", "-in", "-inkey", "-key", "-infile", "--key", "--cert", "-f", "--file", "-literalpath", "-path", "-filepath", "-inputobject"]);
|
|
228
|
+
const WRITE_FLAGS = new Set(["-o", "--output", "-out", "--output-document", "-destination", "-outfile", "--target-directory"]);
|
|
229
|
+
// curl/wget request-body and form flags carry inline data, not a filename — a quoted
|
|
230
|
+
// JSON body must not be read as a glob over a protected path. Their value names a file
|
|
231
|
+
// only through `@`: `-d @.env`, `-F upload=@secret`, `--data-urlencode key@file`.
|
|
232
|
+
const CURL_DATA = new Set(["-d", "--data", "--data-ascii", "--data-binary", "--data-raw", "--json", "--post-data", "--body-data"]);
|
|
233
|
+
const CURL_URLENCODE = "--data-urlencode";
|
|
234
|
+
const CURL_FORM = new Set(["-F", "--form", "--form-string"]);
|
|
235
|
+
/** The file a curl/wget body/form value points at with `@`, or undefined for inline data. */
|
|
236
|
+
function curlFileRef(flag, value) {
|
|
237
|
+
if (value.startsWith("@"))
|
|
238
|
+
return value.slice(1).split(";")[0];
|
|
239
|
+
if (flag === CURL_URLENCODE) {
|
|
240
|
+
const m = /^[^=@]*@(.+)$/.exec(value);
|
|
241
|
+
if (m)
|
|
242
|
+
return m[1];
|
|
243
|
+
}
|
|
244
|
+
if (CURL_FORM.has(flag)) {
|
|
245
|
+
const m = /=@([^;]+)/.exec(value);
|
|
246
|
+
if (m)
|
|
247
|
+
return m[1];
|
|
248
|
+
}
|
|
249
|
+
return undefined;
|
|
250
|
+
}
|
|
251
|
+
// Names an uploader's operand, or `git add`'s, is checked against: a secret path sent
|
|
252
|
+
// or staged (`gh gist create .env`, `git add .env`) is a read of that path.
|
|
253
|
+
const SENSITIVE = /(?:^|[/\\])(?:\.scopebond(?:[/\\]|$)|\.env(?:\.[^/\\]*)?$|\.envrc$|\.ssh(?:[/\\]|$)|\.aws(?:[/\\]|$)|\.npmrc$|\.pypirc$|_?\.?netrc$|\.git-credentials$|\.kube(?:[/\\]|$)|\.docker(?:[/\\]|$)|\.azure(?:[/\\]|$)|\.gnupg(?:[/\\]|$)|\.config[/\\](?:gcloud|gh)(?:[/\\]|$)|\.credentials\.json$)|\.(?:key|pem|p12|pfx|jks|keystore)$/i;
|
|
254
|
+
const INTERPRETERS = new Set(["node", "deno", "bun", "python", "python3", "py", "ruby", "perl", "php", "osascript", "lua", "tclsh"]);
|
|
255
|
+
// Path literals inside inline code. Each alternative starts at a boundary (not after
|
|
256
|
+
// a word character — so `process.env` is not `.env`) and has no overlapping repeats,
|
|
257
|
+
// keeping the scan linear; input is capped per argument.
|
|
258
|
+
const SENSITIVE_IN_CODE = /(?<![\w$])(?:\.scopebond[\\/][\w.-]*|\.env(?:\.[\w-]+)?(?![\w.-])|\.envrc|\.ssh[\\/][\w.-]+|\.aws[\\/]credentials|\.claude[\\/]settings[\w.-]*|\.cursor[\\/]hooks\.json|\.codex[\\/](?:hooks\.json|config\.toml)|\.git[\\/](?:hooks[\\/][\w.-]*|config)|\.github[\\/](?:workflows|actions)[\\/][\w./-]*|\.npmrc|\.git-credentials)|(?<![\w.-])[\w-]+\.(?:key|pem|p12|pfx)(?![\w])/gi;
|
|
259
|
+
// A file or process API in the same inline snippet, required in call or member form
|
|
260
|
+
// (not a bare English word — "your .env file" must not read like `File`). Without one,
|
|
261
|
+
// a protected path in a string literal opens nothing. Covers Node (fs/child_process/
|
|
262
|
+
// streams), Python (open, os, subprocess, pathlib, shutil), Ruby/PHP (File/IO/fopen/
|
|
263
|
+
// file_get_contents) and PowerShell (Get-/Set-Content, Out-File, Invoke-*).
|
|
264
|
+
const FILE_API = /\b(?:open|fopen|readlink|read_file|readfile|read_to_string|readfilesync|writefile|writefilesync|appendfile|appendfilesync|createreadstream|createwritestream|openfile|opensync|copyfile|copyfilesync|rename|renamesync|unlink|unlinksync|popen|spawn|spawnsync|exec|execsync|execfile|execfilesync|system|shell_exec|proc_open|file_get_contents|file_put_contents|urlopen)\s*\(|\b(?:fs|io|os|subprocess|child_process|pathlib|shutil|File|Dir|IO|Pathname|Path|FileUtils)\s*\.\s*\w|\bimport\s+(?:os|subprocess|shutil|pathlib|io)\b|\brequire\s*\(\s*['"`](?:node:)?(?:fs|child_process)|\b(?:Get-Content|Set-Content|Out-File|Add-Content|Import-Csv|Invoke-\w+)\b/i;
|
|
265
|
+
const CODE_SCAN_LIMIT = 20000;
|
|
266
|
+
const GREP = { short: "efmABCdD", long: ["--regexp", "--file", "--max-count", "--after-context", "--before-context", "--context", "--include", "--exclude", "--exclude-dir", "--exclude-from", "--directories", "--devices", "--label", "--binary-files", "--group-separator"], read: ["-f", "--file", "--exclude-from"] };
|
|
267
|
+
const AWK = { short: "fvF", long: ["--file", "--assign", "--field-separator", "--include", "--load"], read: ["-f", "--file"] };
|
|
268
|
+
const VIM = { short: "cSuiTwWtq", long: ["--cmd"], read: ["-S", "-u"], write: ["-w", "-W"] };
|
|
269
|
+
const OPTIONS = new Map([
|
|
270
|
+
["grep", GREP], ["egrep", GREP], ["fgrep", GREP],
|
|
271
|
+
["rg", { short: "efgtTmABCjMErd", long: ["--regexp", "--file", "--glob", "--iglob", "--type", "--type-not", "--type-add", "--type-clear", "--max-count", "--after-context", "--before-context", "--context", "--threads", "--max-columns", "--encoding", "--replace", "--max-depth", "--max-filesize", "--pre", "--pre-glob", "--ignore-file", "--sort", "--sortr", "--colors", "--path-separator", "--context-separator", "--engine"], read: ["-f", "--file", "--ignore-file"] }],
|
|
272
|
+
["ag", { short: "ABCGgmp", long: ["--ignore", "--ignore-dir", "--file-search-regex", "--depth", "--pager", "--after", "--before", "--context", "--max-count", "--path-to-ignore"], read: ["-p", "--path-to-ignore"] }],
|
|
273
|
+
["ack", { short: "ABCm", long: ["--type", "--ignore-dir", "--ignore-file", "--match", "--max-count", "--output", "--after-context", "--before-context", "--context"] }],
|
|
274
|
+
["awk", AWK], ["gawk", AWK], ["mawk", AWK], ["nawk", AWK],
|
|
275
|
+
["jq", { short: "fL", long: ["--from-file", "--indent"], read: ["-f", "--from-file"] }],
|
|
276
|
+
["yq", { short: "Iop", long: ["--indent", "--output-format", "--input-format", "--expression", "--front-matter"] }],
|
|
277
|
+
["head", { short: "nc", long: ["--lines", "--bytes"] }],
|
|
278
|
+
["tail", { short: "nc", long: ["--lines", "--bytes", "--pid", "--sleep-interval", "--max-unchanged-stats"] }],
|
|
279
|
+
["sort", { short: "ktoST", long: ["--key", "--field-separator", "--output", "--buffer-size", "--temporary-directory", "--files0-from", "--batch-size", "--parallel"], read: ["--files0-from"] }],
|
|
280
|
+
["cut", { short: "bcdf", long: ["--bytes", "--characters", "--delimiter", "--fields", "--output-delimiter"] }],
|
|
281
|
+
["od", { short: "AjNStw" }], ["xxd", { short: "cglos" }], ["hexdump", { short: "efns", read: ["-f"] }],
|
|
282
|
+
["base64", { short: "w" }], ["base32", { short: "w" }],
|
|
283
|
+
["diff", { short: "CUFILXx", long: ["--label", "--ignore-matching-lines", "--show-function-line", "--exclude", "--exclude-from", "--from-file", "--to-file", "--horizon-lines"], read: ["-X", "--exclude-from", "--from-file", "--to-file"] }],
|
|
284
|
+
["nl", { short: "bdfhilnsvw" }],
|
|
285
|
+
["strings", { short: "nte", long: ["--bytes", "--radix", "--encoding", "--output-separator"] }],
|
|
286
|
+
["tar", { short: "fCTXbHKLNVg", long: ["--file", "--directory", "--files-from", "--exclude-from", "--exclude", "--transform", "--owner", "--group", "--mode", "--mtime", "--newer", "--after-date", "--label", "--format", "--blocking-factor"], read: ["-T", "-X", "--files-from", "--exclude-from"] }],
|
|
287
|
+
["unzip", { short: "dxP" }],
|
|
288
|
+
["sed", { short: "efl", optional: "i", long: ["--expression", "--file", "--line-length"], read: ["-f", "--file"] }],
|
|
289
|
+
["vi", VIM], ["vim", VIM], ["nvim", VIM], ["view", VIM], ["ex", VIM],
|
|
290
|
+
["ed", { short: "p" }],
|
|
291
|
+
["gpg", { short: "orRu", long: ["--output", "--recipient", "--local-user", "--homedir", "--default-key"] }],
|
|
292
|
+
]);
|
|
293
|
+
// Programs whose first operand is a pattern or program text, not a file — unless the
|
|
294
|
+
// pattern came from an option (`grep -e p`, `awk -f prog`, `jq -f filter`).
|
|
295
|
+
const PATTERN_FIRST = new Set(["grep", "egrep", "fgrep", "rg", "ag", "ack", "awk", "gawk", "mawk", "nawk", "jq", "yq", "select-string", "sls"]);
|
|
296
|
+
const PATTERN_OPTIONS = new Set(["-e", "-f", "--regexp", "--file", "--from-file", "--expression"]);
|
|
297
|
+
/** If `t` is an option that takes a value under `spec`, its name and any attached value. */
|
|
298
|
+
function valuedOption(spec, t) {
|
|
299
|
+
if (!spec)
|
|
300
|
+
return undefined;
|
|
301
|
+
if (t.startsWith("--"))
|
|
302
|
+
return spec.long?.includes(t) ? { name: t } : undefined;
|
|
303
|
+
for (let k = 1; k < t.length; k++) {
|
|
304
|
+
if (spec.optional?.includes(t[k]))
|
|
305
|
+
return undefined;
|
|
306
|
+
if (spec.short?.includes(t[k]))
|
|
307
|
+
return { name: "-" + t[k], attached: t.slice(k + 1) || undefined };
|
|
308
|
+
}
|
|
309
|
+
return undefined;
|
|
310
|
+
}
|
|
311
|
+
// `git config` keys whose value runs a program, redirects a push or reroutes a remote:
|
|
312
|
+
// setting one is a write to the protected git config.
|
|
313
|
+
const DANGEROUS_GIT_KEY = /^(?:core\.(?:hookspath|fsmonitor|sshcommand|pager|editor|askpass|gitproxy)|alias\..+|include\.path|includeif\..+|remote\..+\.(?:push|pushurl|url|receivepack|uploadpack)|url\..+\.(?:insteadof|pushinsteadof)|push\.default|remote\.pushdefault|credential\..*helper|filter\..+\.(?:clean|smudge|process)|diff\..+\.(?:textconv|command)|merge\..+\.driver|sequence\.editor|gpg\.(?:.+\.)?program)$/i;
|
|
314
|
+
/** The file a `git config …` invocation writes, or undefined when it only reads (or
|
|
315
|
+
* sets a harmless key). `--file F` writes F for any key; otherwise setting a
|
|
316
|
+
* dangerous key (or `--edit`) writes the git config (`--global` included, recorded as
|
|
317
|
+
* `.git/config` so the protected-write guard applies). */
|
|
318
|
+
function gitConfigWrite(args) {
|
|
319
|
+
let file;
|
|
320
|
+
let edit = false;
|
|
321
|
+
let mutate = false;
|
|
322
|
+
let query = false;
|
|
323
|
+
const positional = [];
|
|
324
|
+
const valued = new Set(["-f", "--file", "--blob", "--type", "--default", "--comment", "--value"]);
|
|
325
|
+
for (let i = 0; i < args.length; i++) {
|
|
326
|
+
const a = args[i];
|
|
327
|
+
if (a === "-f" || a === "--file") {
|
|
328
|
+
file = args[++i];
|
|
329
|
+
continue;
|
|
330
|
+
}
|
|
331
|
+
if (a.startsWith("--file=")) {
|
|
332
|
+
file = a.slice(7);
|
|
333
|
+
continue;
|
|
334
|
+
}
|
|
335
|
+
if (valued.has(a)) {
|
|
336
|
+
i++;
|
|
337
|
+
continue;
|
|
338
|
+
}
|
|
339
|
+
if (a === "-e" || a === "--edit") {
|
|
340
|
+
edit = true;
|
|
341
|
+
continue;
|
|
342
|
+
}
|
|
343
|
+
if (/^--(?:add|replace-all|unset|unset-all|rename-section|remove-section)$/.test(a)) {
|
|
344
|
+
mutate = true;
|
|
345
|
+
continue;
|
|
346
|
+
}
|
|
347
|
+
if (/^(?:--get|--get-all|--get-regexp|--get-urlmatch|-l|--list|--get-color|--get-colorbool)$/.test(a)) {
|
|
348
|
+
query = true;
|
|
349
|
+
continue;
|
|
350
|
+
}
|
|
351
|
+
if (a.startsWith("-"))
|
|
352
|
+
continue;
|
|
353
|
+
positional.push(a);
|
|
354
|
+
}
|
|
355
|
+
const verb = positional[0]?.toLowerCase();
|
|
356
|
+
if (verb === "edit")
|
|
357
|
+
edit = true;
|
|
358
|
+
else if (verb === "set" || verb === "unset" || verb === "rename-section" || verb === "remove-section") {
|
|
359
|
+
mutate = true;
|
|
360
|
+
positional.shift();
|
|
361
|
+
}
|
|
362
|
+
else if (verb === "get" || verb === "list")
|
|
363
|
+
query = true;
|
|
364
|
+
const key = positional[0] ?? "";
|
|
365
|
+
const writes = edit || (!query && (mutate || positional.length >= 2));
|
|
366
|
+
if (!writes)
|
|
367
|
+
return undefined;
|
|
368
|
+
if (file !== undefined)
|
|
369
|
+
return file;
|
|
370
|
+
const section = /^(?:alias|core|include|includeif|remote|url|filter|diff|merge|credential|push|gpg|sequence)(?:\.|$)/i;
|
|
371
|
+
return edit || DANGEROUS_GIT_KEY.test(key) || (mutate && positional.some((p) => section.test(p))) ? ".git/config" : undefined;
|
|
372
|
+
}
|
|
373
|
+
/** Split git subcommand arguments into positionals, honoring `--` and skipping the
|
|
374
|
+
* values of the given options. */
|
|
375
|
+
function gitPositionals(args, valued = []) {
|
|
376
|
+
const before = [];
|
|
377
|
+
for (let i = 0; i < args.length; i++) {
|
|
378
|
+
const a = args[i];
|
|
379
|
+
if (a === "--")
|
|
380
|
+
return { before, after: args.slice(i + 1) };
|
|
381
|
+
if (valued.includes(a)) {
|
|
382
|
+
i++;
|
|
383
|
+
continue;
|
|
384
|
+
}
|
|
385
|
+
if (a.startsWith("-"))
|
|
386
|
+
continue;
|
|
387
|
+
before.push(a);
|
|
388
|
+
}
|
|
389
|
+
return { before, after: null };
|
|
390
|
+
}
|
|
391
|
+
const isPathWord = (t) => t !== "" && !/^\d+$/.test(t) && !/^[a-z][a-z0-9+.-]*:\/\//i.test(t);
|
|
392
|
+
const isSensitiveOperand = (w) => SENSITIVE.test(w) || (UNRESOLVED.test(w) && protectedCandidate(normPath(w)) !== undefined);
|
|
393
|
+
/** Derive the file.read / file.write intents a simple shell command implies —
|
|
394
|
+
* operands of reader, copier, writer and editor programs, flag values that name
|
|
395
|
+
* files, redirection targets, the sensitive operands of uploaders and `git add`, and
|
|
396
|
+
* the paths git writes (`checkout -- p`, `restore`, `mv`, `rm`, `config`) — resolved
|
|
397
|
+
* against the directory an earlier `cd` in the same call moved to. These flow through
|
|
398
|
+
* the same protect-read / protect-write clauses as the native file tools; a false
|
|
399
|
+
* positive (a non-protected operand) is a harmless extra receipt the starter policy
|
|
400
|
+
* allows. `unknownTarget` is set when the command writes files its text does not
|
|
401
|
+
* name (`patch`, `git apply`, `tar x`, `unzip`). */
|
|
402
|
+
function fileOpsFromShell(sc, dir, cwd) {
|
|
36
403
|
const ops = [];
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
404
|
+
let unknownTarget = false;
|
|
405
|
+
const prog = canonProgram(sc.program);
|
|
406
|
+
const read = (w) => { if (w !== undefined && isPathWord(w))
|
|
407
|
+
ops.push(...fileIntent("file.read", w, dir, cwd)); };
|
|
408
|
+
const write = (w) => { if (w !== undefined && isPathWord(w))
|
|
409
|
+
ops.push(...fileIntent("file.write", w, dir, cwd)); };
|
|
410
|
+
const args = sc.argv;
|
|
411
|
+
const spec = OPTIONS.get(prog);
|
|
412
|
+
const flagFiles = READERS.has(prog) || EDITORS.has(prog) || COPIERS.has(prog) || UPLOADERS.has(prog);
|
|
413
|
+
// Operands (non-flag words) and flag values (`--flag=value`, `-T value`, `@file`, `if=f`).
|
|
414
|
+
const operands = [];
|
|
415
|
+
const given = new Set();
|
|
416
|
+
const flagValue = (name, value) => {
|
|
417
|
+
const lower = name.toLowerCase();
|
|
418
|
+
given.add(name);
|
|
419
|
+
if (spec?.write?.includes(name) || WRITE_FLAGS.has(lower))
|
|
420
|
+
write(value);
|
|
421
|
+
else if (spec?.read?.includes(name) || (flagFiles && READ_FLAGS.has(lower)))
|
|
422
|
+
read(value.replace(/^@/, ""));
|
|
423
|
+
else if (value.startsWith("@"))
|
|
424
|
+
read(value.slice(1));
|
|
425
|
+
};
|
|
426
|
+
for (let i = 0; i < args.length; i++) {
|
|
427
|
+
const t = args[i];
|
|
428
|
+
if (t === "--") {
|
|
429
|
+
operands.push(...args.slice(i + 1));
|
|
430
|
+
break;
|
|
431
|
+
}
|
|
432
|
+
const lower = t.toLowerCase();
|
|
433
|
+
const eq = t.indexOf("=");
|
|
434
|
+
if (prog === "curl" || prog === "wget") {
|
|
435
|
+
const flag = eq > 0 && t.startsWith("--") ? t.slice(0, eq) : t;
|
|
436
|
+
if (CURL_DATA.has(flag) || CURL_FORM.has(flag) || flag === CURL_URLENCODE) {
|
|
437
|
+
let value;
|
|
438
|
+
if (eq > 0 && t.startsWith("--"))
|
|
439
|
+
value = t.slice(eq + 1);
|
|
440
|
+
else
|
|
441
|
+
value = args[++i];
|
|
442
|
+
given.add(flag);
|
|
443
|
+
if (value !== undefined) {
|
|
444
|
+
const f = curlFileRef(flag, value);
|
|
445
|
+
if (f !== undefined)
|
|
446
|
+
read(f);
|
|
447
|
+
}
|
|
448
|
+
continue;
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
if (t.startsWith("-") && eq > 0) { // --flag=value
|
|
452
|
+
const flag = t.slice(0, eq);
|
|
453
|
+
const value = t.slice(eq + 1);
|
|
454
|
+
given.add(flag);
|
|
455
|
+
if (spec?.write?.includes(flag) || WRITE_FLAGS.has(flag.toLowerCase()))
|
|
456
|
+
write(value.replace(/^@/, ""));
|
|
457
|
+
else if (spec?.read?.includes(flag) || (flagFiles && READ_FLAGS.has(flag.toLowerCase())) || value.startsWith("@") || (UPLOADERS.has(prog) && SENSITIVE.test(value)))
|
|
458
|
+
read(value.replace(/^@/, ""));
|
|
459
|
+
continue;
|
|
460
|
+
}
|
|
461
|
+
if (t.startsWith("-") && t.length > 1) {
|
|
462
|
+
// jq's two-valued options: `--arg name value`, `--slurpfile name file`.
|
|
463
|
+
if (prog === "jq" && /^--(?:arg|argjson|slurpfile|rawfile)$/.test(t)) {
|
|
464
|
+
if (/file$/.test(t))
|
|
465
|
+
read(args[i + 2]);
|
|
466
|
+
i += 2;
|
|
467
|
+
continue;
|
|
468
|
+
}
|
|
469
|
+
const opt = valuedOption(spec, t);
|
|
470
|
+
if (opt) {
|
|
471
|
+
let value = opt.attached;
|
|
472
|
+
if (value === undefined && i + 1 < args.length)
|
|
473
|
+
value = args[++i];
|
|
474
|
+
if (value !== undefined)
|
|
475
|
+
flagValue(opt.name, value);
|
|
476
|
+
continue;
|
|
477
|
+
}
|
|
478
|
+
const value = args[i + 1];
|
|
479
|
+
if (value !== undefined && !value.startsWith("-")) {
|
|
480
|
+
if (COPIERS.has(prog) && t === "-t") {
|
|
481
|
+
write(value);
|
|
482
|
+
i++;
|
|
483
|
+
continue;
|
|
484
|
+
} // cp -t DIR
|
|
485
|
+
if (WRITERS.has(prog) && /^-(?:path|literalpath|filepath)$/.test(lower)) {
|
|
486
|
+
write(value);
|
|
487
|
+
i++;
|
|
488
|
+
continue;
|
|
489
|
+
} // Set-Content -Path f
|
|
490
|
+
if (WRITE_FLAGS.has(lower)) {
|
|
491
|
+
write(value);
|
|
492
|
+
i++;
|
|
493
|
+
continue;
|
|
494
|
+
}
|
|
495
|
+
if (flagFiles && READ_FLAGS.has(lower)) {
|
|
496
|
+
read(value.replace(/^@/, ""));
|
|
497
|
+
i++;
|
|
498
|
+
continue;
|
|
499
|
+
}
|
|
500
|
+
if (value.startsWith("@")) {
|
|
501
|
+
read(value.slice(1));
|
|
502
|
+
i++;
|
|
503
|
+
continue;
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
continue;
|
|
507
|
+
}
|
|
508
|
+
if (prog === "dd" && /^(?:if|of)=/.test(t)) {
|
|
509
|
+
(t.startsWith("of=") ? write : read)(t.slice(3));
|
|
510
|
+
continue;
|
|
511
|
+
}
|
|
512
|
+
if (t.startsWith("@")) {
|
|
513
|
+
read(t.slice(1));
|
|
514
|
+
continue;
|
|
42
515
|
}
|
|
516
|
+
operands.push(t);
|
|
517
|
+
}
|
|
518
|
+
if (PATTERN_FIRST.has(prog) && ![...given].some((g) => PATTERN_OPTIONS.has(g)) && !(prog === "yq" && operands.length === 1))
|
|
519
|
+
operands.shift();
|
|
520
|
+
const git = gitArgs(sc);
|
|
521
|
+
const inPlace = (a) => a === "--inplace" || a.startsWith("--in-place") || /^-[a-hj-z]*i/.test(a);
|
|
522
|
+
if (git) {
|
|
523
|
+
const sub = git.sub?.toLowerCase();
|
|
524
|
+
const a = git.args;
|
|
525
|
+
if (sub === "add") {
|
|
526
|
+
// Staging a secret is one commit away from publishing it.
|
|
527
|
+
const { before, after } = gitPositionals(a, ["--pathspec-from-file"]);
|
|
528
|
+
[...before, ...(after ?? [])].filter(isSensitiveOperand).forEach(read);
|
|
529
|
+
}
|
|
530
|
+
else if (sub === "checkout") {
|
|
531
|
+
const { before, after } = gitPositionals(a, ["-b", "-B", "--orphan", "--conflict", "--pathspec-from-file"]);
|
|
532
|
+
(after ?? before).forEach(write);
|
|
533
|
+
}
|
|
534
|
+
else if (sub === "restore") {
|
|
535
|
+
const { before, after } = gitPositionals(a, ["-s", "--source", "--pathspec-from-file"]);
|
|
536
|
+
[...before, ...(after ?? [])].forEach(write);
|
|
537
|
+
}
|
|
538
|
+
else if (sub === "mv") {
|
|
539
|
+
const { before, after } = gitPositionals(a);
|
|
540
|
+
const all = [...before, ...(after ?? [])];
|
|
541
|
+
all.slice(0, -1).forEach(read);
|
|
542
|
+
all.forEach(write);
|
|
543
|
+
}
|
|
544
|
+
else if (sub === "rm" && !a.includes("--cached")) {
|
|
545
|
+
const { before, after } = gitPositionals(a, ["--pathspec-from-file"]);
|
|
546
|
+
[...before, ...(after ?? [])].forEach(write);
|
|
547
|
+
}
|
|
548
|
+
else if (sub === "config")
|
|
549
|
+
write(gitConfigWrite(a));
|
|
550
|
+
else if (sub === "show" || sub === "cat-file") {
|
|
551
|
+
// `git show REV:path` prints that file's content from history.
|
|
552
|
+
for (const w of a) {
|
|
553
|
+
const m = /^(?:[^-:][^:]*)?:(?:\d:)?(.+)$/.exec(w);
|
|
554
|
+
if (m && !/^[a-z][a-z0-9+.-]*:\/\//i.test(w))
|
|
555
|
+
read(m[1]);
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
else if (sub === "apply" || sub === "am")
|
|
559
|
+
unknownTarget = true;
|
|
560
|
+
}
|
|
561
|
+
else if (prog === "find") {
|
|
562
|
+
// `find DIR … -exec cat {} +` reads DIR's files; `-fprint F` writes F.
|
|
563
|
+
const starts = [];
|
|
564
|
+
for (const w of args) {
|
|
565
|
+
if (/^[-(!]/.test(w) || w === "\\(")
|
|
566
|
+
break;
|
|
567
|
+
starts.push(w);
|
|
568
|
+
}
|
|
569
|
+
if (args.some((w) => /^-(?:exec|execdir|ok|okdir)$/.test(w)))
|
|
570
|
+
starts.forEach(read);
|
|
571
|
+
args.forEach((w, k) => { if (/^-(?:fprint0?|fprintf|fls)$/.test(w))
|
|
572
|
+
write(args[k + 1]); });
|
|
573
|
+
}
|
|
574
|
+
else if ((prog === "docker" || prog === "podman" || prog === "kubectl") && operands[0] === "cp") {
|
|
575
|
+
const local = (p) => p !== undefined && (!/^[^/\\]+:/.test(p) || /^[A-Za-z]:[\\/]/.test(p));
|
|
576
|
+
if (local(operands[1]))
|
|
577
|
+
read(operands[1]);
|
|
578
|
+
if (local(operands[2]))
|
|
579
|
+
write(operands[2]);
|
|
580
|
+
}
|
|
581
|
+
else if (prog === "patch") {
|
|
582
|
+
// The file a patch changes is named inside the patch; an explicit operand is written.
|
|
583
|
+
unknownTarget = true;
|
|
584
|
+
write(operands[0]);
|
|
43
585
|
}
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
586
|
+
else if ((prog === "tar" || prog === "bsdtar") && (args.some((w) => w === "--extract" || w === "--get" || /^-[A-Za-z]*x/.test(w)) || /^[A-Za-z]*x[A-Za-z]*$/.test(args[0] ?? ""))) {
|
|
587
|
+
unknownTarget = true;
|
|
588
|
+
args.forEach((w, k) => { if (w === "-C" || w === "--directory")
|
|
589
|
+
write(args[k + 1]);
|
|
590
|
+
else if (w.startsWith("--directory="))
|
|
591
|
+
write(w.slice(12)); });
|
|
592
|
+
operands.forEach(read);
|
|
593
|
+
}
|
|
594
|
+
else if (prog === "unzip" && !args.some((w) => /^-[ltvZpcz]$/.test(w))) {
|
|
595
|
+
unknownTarget = true;
|
|
596
|
+
args.forEach((w, k) => { if (w === "-d")
|
|
597
|
+
write(args[k + 1]); });
|
|
598
|
+
read(operands[0]);
|
|
599
|
+
}
|
|
600
|
+
else if (/^7z[arz]?$/.test(prog) && /^[xe]$/.test(operands[0] ?? "")) {
|
|
601
|
+
unknownTarget = true;
|
|
602
|
+
args.forEach((w) => { if (/^-o./.test(w))
|
|
603
|
+
write(w.slice(2)); });
|
|
604
|
+
read(operands[1]);
|
|
605
|
+
}
|
|
606
|
+
else if (prog === "cpio" && args.some((w) => w === "--extract" || /^-[A-Za-z]*i/.test(w))) {
|
|
607
|
+
unknownTarget = true;
|
|
608
|
+
}
|
|
609
|
+
else if (prog === "gpg" && args.some((w) => /^--export-secret-(?:sub)?keys$/.test(w))) {
|
|
610
|
+
read(".gnupg/private-keys-v1.d");
|
|
611
|
+
}
|
|
612
|
+
else if (READERS.has(prog)) {
|
|
613
|
+
operands.forEach(read);
|
|
614
|
+
if (prog === "yq" && args.some(inPlace))
|
|
615
|
+
operands.forEach(write);
|
|
616
|
+
// `sed -i 's/x/y/' f…` rewrites its operands in place; the first operand is the
|
|
617
|
+
// script unless `-e`/`-f` supplied one.
|
|
618
|
+
if (prog === "sed" && args.some((a) => /^-[A-Za-z]*i/.test(a) || a.startsWith("--in-place"))) {
|
|
619
|
+
const scriptGiven = args.some((a) => a === "-e" || a === "-f" || a.startsWith("--expression")) || given.has("-e") || given.has("-f");
|
|
620
|
+
(scriptGiven ? operands : operands.slice(1)).forEach(write);
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
else if (EDITORS.has(prog)) {
|
|
624
|
+
operands.forEach(read);
|
|
625
|
+
operands.forEach(write);
|
|
626
|
+
}
|
|
627
|
+
else if (COPIERS.has(prog) && operands.length) {
|
|
628
|
+
const hasTarget = args.some((a) => /^(?:-t|--target-directory(?:=.*)?|-destination)$/i.test(a) || /^--target-directory=/.test(a));
|
|
629
|
+
const sources = hasTarget ? operands : operands.slice(0, -1);
|
|
630
|
+
sources.forEach(read);
|
|
631
|
+
if (MOVERS.has(prog))
|
|
632
|
+
sources.forEach(write);
|
|
633
|
+
if (!hasTarget)
|
|
634
|
+
write(operands[operands.length - 1]);
|
|
635
|
+
}
|
|
636
|
+
else if (WRITERS.has(prog)) {
|
|
637
|
+
// A permission/owner spec is not a path: `chmod +x f`, `chmod 0755 f`,
|
|
638
|
+
// `chattr +i f`, `chown root:wheel f`, `attrib +r f` write f, not "+x".
|
|
639
|
+
let targets = operands;
|
|
640
|
+
if (prog === "chmod" || prog === "chattr" || prog === "attrib")
|
|
641
|
+
targets = targets.filter((o) => !/^[+\-=][rwxstugoa+-]*$|^[ugoa]+[+\-=][rwxXst]*$|^[0-7]{3,4}$/i.test(o));
|
|
642
|
+
else if (prog === "chown" || prog === "chgrp")
|
|
643
|
+
targets = targets.slice(1);
|
|
644
|
+
targets.forEach(write);
|
|
645
|
+
}
|
|
646
|
+
else if ((prog === "perl" || prog === "ruby") && args.some((a) => /^-[A-Za-z]*i/.test(a) || a.startsWith("--in-place"))) {
|
|
647
|
+
// `perl -i -pe 's/x/y/' f…`: the first operand is the script (unless -e/-f gave it).
|
|
648
|
+
const scriptGiven = args.some((a) => a === "-e" || a === "-f" || a.startsWith("--expression")) || given.has("-e") || given.has("-f");
|
|
649
|
+
(scriptGiven ? operands : operands.slice(1)).forEach(write);
|
|
650
|
+
}
|
|
651
|
+
else if (INTERPRETERS.has(prog)) {
|
|
652
|
+
// Inline code (`node -e "…"`, `python -c "…"`): best effort — a protected-looking
|
|
653
|
+
// path literal is recorded as a read and a write only when the same code also calls
|
|
654
|
+
// a file or process API, so a path merely named in a log string is not a finding.
|
|
655
|
+
// Code that assembles a path at run time is beyond a cooperative hook (use a gateway).
|
|
656
|
+
for (const w of args) {
|
|
657
|
+
const code = w.slice(0, CODE_SCAN_LIMIT);
|
|
658
|
+
if (!FILE_API.test(code))
|
|
659
|
+
continue;
|
|
660
|
+
for (const m of code.match(SENSITIVE_IN_CODE) ?? []) {
|
|
661
|
+
read(m);
|
|
662
|
+
write(m);
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
else if (UPLOADERS.has(prog)) {
|
|
667
|
+
operands.filter(isSensitiveOperand).forEach(read);
|
|
668
|
+
}
|
|
669
|
+
for (const r of sc.redirects) {
|
|
670
|
+
if (r.op.startsWith(">"))
|
|
671
|
+
write(r.target);
|
|
672
|
+
else if (r.op === "<>") {
|
|
673
|
+
read(r.target);
|
|
674
|
+
write(r.target);
|
|
675
|
+
}
|
|
676
|
+
else
|
|
677
|
+
read(r.target);
|
|
678
|
+
}
|
|
679
|
+
return { ops, unknownTarget };
|
|
680
|
+
}
|
|
681
|
+
// The hook's own CLI run by the agent to switch itself off or re-scope itself
|
|
682
|
+
// (`npx @scopebond/hook uninstall`, `scopebond trust`, `scopebond-hook init --force`).
|
|
683
|
+
const SELF_SUBCOMMANDS = new Set(["uninstall", "trust", "init", "install", "connect"]);
|
|
684
|
+
const isHookCli = (w) => /^scopebond(?:-hook)?(?:\.js)?$/.test(canonProgram(w)) || /^@scopebond\/hook(?:@[^/\s]*)?$/i.test(w) || /@scopebond[\\/]hook[\\/]dist[\\/]cli\.js$/i.test(w);
|
|
685
|
+
function selfDisable(sc) {
|
|
686
|
+
const all = [sc.programRaw, ...sc.argv];
|
|
687
|
+
for (let k = 0; k < all.length; k++) {
|
|
688
|
+
if (!isHookCli(all[k]))
|
|
48
689
|
continue;
|
|
49
|
-
const
|
|
50
|
-
if (
|
|
51
|
-
|
|
690
|
+
const sub = all.slice(k + 1).find((a) => !a.startsWith("-"));
|
|
691
|
+
if (sub && SELF_SUBCOMMANDS.has(sub.toLowerCase()))
|
|
692
|
+
return true;
|
|
52
693
|
}
|
|
53
|
-
return
|
|
694
|
+
return false;
|
|
54
695
|
}
|
|
55
696
|
/** Map one parsed simple command to the intents it implies: the git.push or
|
|
56
|
-
* shell.exec itself, plus any file reads/writes it performs. An opaque
|
|
57
|
-
* (unparseable
|
|
58
|
-
|
|
697
|
+
* shell.exec itself, plus any file reads/writes it performs. An opaque command
|
|
698
|
+
* (unparseable, or a program known only at run time) becomes a shell.exec with an
|
|
699
|
+
* empty program, evaluated, which the starter policy denies. */
|
|
700
|
+
function mapSimpleCommand(sc, dir, cwd) {
|
|
59
701
|
if (sc.opaque) {
|
|
60
702
|
return [{
|
|
61
703
|
intent: { action_type: "shell.exec", params: { command: redactCommand(sc.raw), program: "", ...(cwd ? { cwd } : {}) } },
|
|
62
|
-
evaluated:
|
|
704
|
+
evaluated: true, source: "shell",
|
|
63
705
|
}];
|
|
64
706
|
}
|
|
65
707
|
const push = parseGitPush(sc);
|
|
66
708
|
if (push) {
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
709
|
+
// One intent per pushed destination, so a protected branch cannot ride behind
|
|
710
|
+
// an allowed one (`git push origin feature main`).
|
|
711
|
+
return push.targets.map((t) => {
|
|
712
|
+
const params = { force: t.force };
|
|
713
|
+
if (push.remote !== undefined)
|
|
714
|
+
params.remote = scrubParam(push.remote);
|
|
715
|
+
if (t.ref !== undefined)
|
|
716
|
+
params.ref = scrubParam(t.ref);
|
|
717
|
+
if (t.del)
|
|
718
|
+
params.delete = true;
|
|
719
|
+
if (t.all)
|
|
720
|
+
params.all = true;
|
|
721
|
+
return { intent: { action_type: "git.push", params }, evaluated: true, source: "shell" };
|
|
722
|
+
});
|
|
73
723
|
}
|
|
724
|
+
const { ops: files, unknownTarget } = fileOpsFromShell(sc, dir, cwd);
|
|
725
|
+
// The hook disabling itself is a write to its own policy, which the starter protects.
|
|
726
|
+
if (selfDisable(sc))
|
|
727
|
+
files.push(...pathIntents("file.write", ".scopebond/policy.json", "shell"));
|
|
728
|
+
// A bare redirection (`> file`) runs no program: only its file effect is recorded.
|
|
729
|
+
if (!sc.program)
|
|
730
|
+
return files;
|
|
74
731
|
const exec = {
|
|
75
732
|
intent: {
|
|
76
733
|
action_type: "shell.exec",
|
|
@@ -81,16 +738,61 @@ function mapSimpleCommand(sc, cwd) {
|
|
|
81
738
|
},
|
|
82
739
|
evaluated: true, source: "shell",
|
|
83
740
|
};
|
|
84
|
-
|
|
741
|
+
// A command that writes files its text does not name (`patch`, `git apply`, `tar x`)
|
|
742
|
+
// cannot be judged here: its writes are recorded as a file.write with no path, not
|
|
743
|
+
// evaluated — observed in normal mode, denied by strict mode (like an apply_patch
|
|
744
|
+
// with no recognizable path).
|
|
745
|
+
if (unknownTarget)
|
|
746
|
+
files.push({ intent: { action_type: "file.write", params: { path: "" } }, evaluated: false, source: "shell" });
|
|
747
|
+
return [exec, ...files];
|
|
85
748
|
}
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
749
|
+
const CD = new Set(["cd", "pushd", "chdir", "set-location", "sl"]);
|
|
750
|
+
/** Decompose a shell command into the intents it will run. A `cd` earlier in the same
|
|
751
|
+
* call changes the directory later relative operands resolve against
|
|
752
|
+
* (`cd .scopebond && cp x policy.json` writes `.scopebond/policy.json`). An empty
|
|
753
|
+
* command yields a single un-evaluated placeholder (nothing to grant). */
|
|
754
|
+
function mapShell(command, cwd, dialect = "posix") {
|
|
755
|
+
// PowerShell: `\` is a path separator and the backtick is the escape character
|
|
756
|
+
// (``Re`move-Item`` runs Remove-Item). Normalize both before the POSIX parser sees
|
|
757
|
+
// them, so neither hides a program or mangles a Windows path.
|
|
758
|
+
const src = dialect === "powershell" ? command.replace(/`(.)/g, "$1").replace(/\\/g, "/") : command;
|
|
759
|
+
const commands = decomposeShell(src);
|
|
90
760
|
if (commands.length === 0) {
|
|
91
761
|
return [{ intent: { action_type: "shell.exec", params: { command: redactCommand(command), program: "" } }, evaluated: false, source: "shell" }];
|
|
92
762
|
}
|
|
93
|
-
|
|
763
|
+
const walk = (list, filesOnly) => {
|
|
764
|
+
let dir = "";
|
|
765
|
+
const out = [];
|
|
766
|
+
for (const sc of list) {
|
|
767
|
+
const mapped = mapSimpleCommand(sc, dir, cwd);
|
|
768
|
+
out.push(...(filesOnly ? mapped.filter((m) => m.intent.action_type.startsWith("file.")) : mapped));
|
|
769
|
+
if (!sc.opaque && CD.has(canonProgram(sc.program))) {
|
|
770
|
+
const target = sc.argv.find((a) => !a.startsWith("-"));
|
|
771
|
+
if (target === undefined || target === "~")
|
|
772
|
+
dir = "";
|
|
773
|
+
else if (/^(?:[\\/]|~[\\/]|[A-Za-z]:)/.test(target))
|
|
774
|
+
dir = normPath(target);
|
|
775
|
+
else
|
|
776
|
+
dir = normPath((dir ? dir + "/" : "") + target);
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
return out;
|
|
780
|
+
};
|
|
781
|
+
const out = walk(commands, false);
|
|
782
|
+
// A "Bash" command may really run under PowerShell or cmd (Codex and Cursor on
|
|
783
|
+
// Windows), where `\` separates paths instead of escaping. Read it both ways for
|
|
784
|
+
// file effects, so `.scopebond\policy.json` is not lost as `.scopebondpolicy.json`.
|
|
785
|
+
if (dialect === "posix" && src.includes("\\")) {
|
|
786
|
+
const seen = new Set(out.map((m) => `${m.intent.action_type} ${String(m.intent.params.path)}`));
|
|
787
|
+
for (const m of walk(decomposeShell(src.replace(/\\/g, "/")), true)) {
|
|
788
|
+
const key = `${m.intent.action_type} ${String(m.intent.params.path)}`;
|
|
789
|
+
if (!seen.has(key)) {
|
|
790
|
+
seen.add(key);
|
|
791
|
+
out.push(m);
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
return out;
|
|
94
796
|
}
|
|
95
797
|
function parseMcpName(name) {
|
|
96
798
|
if (!name.startsWith("mcp__"))
|
|
@@ -131,11 +833,7 @@ function mapApplyPatch(command, cwd) {
|
|
|
131
833
|
if (paths.length === 0) {
|
|
132
834
|
return one({ action_type: "file.write", params: { path: "" } }, false, "apply_patch");
|
|
133
835
|
}
|
|
134
|
-
return paths.
|
|
135
|
-
intent: { action_type: "file.write", params: { path } },
|
|
136
|
-
evaluated: true,
|
|
137
|
-
source: "apply_patch",
|
|
138
|
-
}));
|
|
836
|
+
return paths.flatMap((path) => pathIntents("file.write", path, "apply_patch"));
|
|
139
837
|
}
|
|
140
838
|
const one = (intent, evaluated, source) => [{ intent, evaluated, source }];
|
|
141
839
|
/** A bare `git push` (no ref) pushes the current branch. The mapper cannot know it,
|
|
@@ -158,14 +856,16 @@ export function mapClaudeToolUse(input) {
|
|
|
158
856
|
// some hosts/agents expose PowerShell or a generic Shell tool — mapping only Bash
|
|
159
857
|
// let a PowerShell command (e.g. `Remove-Item -Recurse -Force .`) fall through to
|
|
160
858
|
// an un-evaluated tool.<name> and be allowed.
|
|
161
|
-
if (name === "Bash" || name === "
|
|
859
|
+
if (name === "Bash" || name === "Shell")
|
|
162
860
|
return mapShell(String(ti.command ?? ""), cwd);
|
|
861
|
+
if (name === "PowerShell")
|
|
862
|
+
return mapShell(String(ti.command ?? ""), cwd, "powershell");
|
|
163
863
|
if (name === "Write" || name === "Edit" || name === "MultiEdit")
|
|
164
|
-
return
|
|
864
|
+
return pathIntents("file.write", rel(ti.file_path, cwd), name);
|
|
165
865
|
if (name === "NotebookEdit")
|
|
166
|
-
return
|
|
866
|
+
return pathIntents("file.write", rel(ti.notebook_path ?? ti.file_path, cwd), name);
|
|
167
867
|
if (name === "Read")
|
|
168
|
-
return
|
|
868
|
+
return pathIntents("file.read", rel(ti.file_path, cwd), name);
|
|
169
869
|
if (name === "WebFetch") {
|
|
170
870
|
const { host, path } = splitUrl(String(ti.url ?? ""));
|
|
171
871
|
return one({ action_type: "net.fetch", params: { host, path, method: "GET" } }, true, name);
|
|
@@ -182,7 +882,9 @@ export function mapCodexToolUse(input) {
|
|
|
182
882
|
const name = String(input?.tool_name ?? "");
|
|
183
883
|
const ti = (input?.tool_input ?? {});
|
|
184
884
|
const cwd = input?.cwd ? String(input.cwd) : undefined;
|
|
185
|
-
if (name === "
|
|
885
|
+
if (name === "PowerShell")
|
|
886
|
+
return mapShell(String(ti.command ?? ti.cmd ?? ""), cwd, "powershell");
|
|
887
|
+
if (name === "Bash" || name === "Shell" || name === "exec_command" || name === "unified_exec")
|
|
186
888
|
return mapShell(String(ti.command ?? ti.cmd ?? ""), cwd);
|
|
187
889
|
if (name === "apply_patch" || name === "Edit" || name === "Write")
|
|
188
890
|
return mapApplyPatch(String(ti.command ?? ti.patch ?? ""), cwd);
|
|
@@ -199,9 +901,9 @@ export function mapCursorEvent(event, payload) {
|
|
|
199
901
|
case "beforeShellExecution":
|
|
200
902
|
return mapShell(String(p.command ?? ""), cwd);
|
|
201
903
|
case "beforeReadFile":
|
|
202
|
-
return
|
|
904
|
+
return pathIntents("file.read", rel(p.path ?? p.file_path, cwd), event);
|
|
203
905
|
case "afterFileEdit":
|
|
204
|
-
return
|
|
906
|
+
return pathIntents("file.write", rel(p.path ?? p.file_path, cwd), event);
|
|
205
907
|
case "beforeMCPExecution": {
|
|
206
908
|
const server = String(p.server ?? p.server_name ?? "");
|
|
207
909
|
const tool = String(p.tool ?? p.tool_name ?? "");
|