@shield-agent/kya 0.32.9 → 0.33.2
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/.cursor-plugin/plugin.json +1 -1
- package/.mcp.json +1 -1
- package/LIMITATIONS.md +2 -2
- package/README.md +178 -12
- package/amp/settings.example.json +1 -1
- package/claude/claude_desktop_config.example.json +1 -1
- package/cline/cline_mcp_settings.example.json +1 -1
- package/copilot/mcp-config.example.json +1 -1
- package/data/iocs.json +17 -0
- package/dist/cli.d.ts +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +149 -16
- package/dist/cli.js.map +1 -1
- package/dist/commands/connect.d.ts +1 -1
- package/dist/commands/connect.d.ts.map +1 -1
- package/dist/commands/connect.js +11 -3
- package/dist/commands/connect.js.map +1 -1
- package/dist/commands/dash.d.ts.map +1 -1
- package/dist/commands/dash.js +25 -1
- package/dist/commands/dash.js.map +1 -1
- package/dist/commands/doctor.d.ts +8 -0
- package/dist/commands/doctor.d.ts.map +1 -0
- package/dist/commands/doctor.js +26 -0
- package/dist/commands/doctor.js.map +1 -0
- package/dist/commands/eval-tool.d.ts +3 -0
- package/dist/commands/eval-tool.d.ts.map +1 -1
- package/dist/commands/eval-tool.js +2 -1
- package/dist/commands/eval-tool.js.map +1 -1
- package/dist/commands/export.d.ts +33 -0
- package/dist/commands/export.d.ts.map +1 -0
- package/dist/commands/export.js +121 -0
- package/dist/commands/export.js.map +1 -0
- package/dist/commands/hook.d.ts.map +1 -1
- package/dist/commands/hook.js +120 -26
- package/dist/commands/hook.js.map +1 -1
- package/dist/commands/hosted-hook.d.ts +8 -1
- package/dist/commands/hosted-hook.d.ts.map +1 -1
- package/dist/commands/hosted-hook.js +15 -5
- package/dist/commands/hosted-hook.js.map +1 -1
- package/dist/commands/map.d.ts +21 -0
- package/dist/commands/map.d.ts.map +1 -0
- package/dist/commands/map.js +76 -0
- package/dist/commands/map.js.map +1 -0
- package/dist/commands/notify-flush.d.ts.map +1 -1
- package/dist/commands/notify-flush.js +7 -1
- package/dist/commands/notify-flush.js.map +1 -1
- package/dist/commands/posture.d.ts +21 -0
- package/dist/commands/posture.d.ts.map +1 -0
- package/dist/commands/posture.js +177 -0
- package/dist/commands/posture.js.map +1 -0
- package/dist/commands/receipt-serve.d.ts +7 -0
- package/dist/commands/receipt-serve.d.ts.map +1 -1
- package/dist/commands/receipt-serve.js +50 -0
- package/dist/commands/receipt-serve.js.map +1 -1
- package/dist/commands/start.d.ts.map +1 -1
- package/dist/commands/start.js +5 -1
- package/dist/commands/start.js.map +1 -1
- package/dist/commands/team.d.ts.map +1 -1
- package/dist/commands/team.js +30 -11
- package/dist/commands/team.js.map +1 -1
- package/dist/commands/wire-hooks.d.ts +5 -1
- package/dist/commands/wire-hooks.d.ts.map +1 -1
- package/dist/commands/wire-hooks.js +23 -8
- package/dist/commands/wire-hooks.js.map +1 -1
- package/dist/commands/wrap.d.ts +38 -1
- package/dist/commands/wrap.d.ts.map +1 -1
- package/dist/commands/wrap.js +82 -6
- package/dist/commands/wrap.js.map +1 -1
- package/dist/daily-slot.d.ts +2 -0
- package/dist/daily-slot.d.ts.map +1 -0
- package/dist/daily-slot.js +29 -0
- package/dist/daily-slot.js.map +1 -0
- package/dist/dash/dash.d.ts.map +1 -1
- package/dist/dash/dash.js +10 -0
- package/dist/dash/dash.js.map +1 -1
- package/dist/dash/entitlement.d.ts +1 -1
- package/dist/dash/entitlement.d.ts.map +1 -1
- package/dist/dash/entitlement.js +1 -0
- package/dist/dash/entitlement.js.map +1 -1
- package/dist/dash/input.d.ts +1 -1
- package/dist/dash/input.d.ts.map +1 -1
- package/dist/dash/input.js +3 -0
- package/dist/dash/input.js.map +1 -1
- package/dist/dash/render.js +1 -1
- package/dist/dash/render.js.map +1 -1
- package/dist/dash/screens/free.js +1 -1
- package/dist/dash/screens/free.js.map +1 -1
- package/dist/device-id.d.ts +5 -0
- package/dist/device-id.d.ts.map +1 -0
- package/dist/device-id.js +78 -0
- package/dist/device-id.js.map +1 -0
- package/dist/fs-atomic.d.ts +9 -3
- package/dist/fs-atomic.d.ts.map +1 -1
- package/dist/fs-atomic.js +17 -11
- package/dist/fs-atomic.js.map +1 -1
- package/dist/gate/config-gen.d.ts +26 -2
- package/dist/gate/config-gen.d.ts.map +1 -1
- package/dist/gate/config-gen.js +53 -4
- package/dist/gate/config-gen.js.map +1 -1
- package/dist/gate/otlp-receiver.d.ts.map +1 -1
- package/dist/gate/otlp-receiver.js +3 -1
- package/dist/gate/otlp-receiver.js.map +1 -1
- package/dist/guard-pack.d.ts +58 -0
- package/dist/guard-pack.d.ts.map +1 -0
- package/dist/guard-pack.js +753 -0
- package/dist/guard-pack.js.map +1 -0
- package/dist/ioc/exec.d.ts +19 -0
- package/dist/ioc/exec.d.ts.map +1 -0
- package/dist/ioc/exec.js +49 -0
- package/dist/ioc/exec.js.map +1 -0
- package/dist/ioc/format.d.ts +4 -0
- package/dist/ioc/format.d.ts.map +1 -0
- package/dist/ioc/format.js +23 -0
- package/dist/ioc/format.js.map +1 -0
- package/dist/ioc/list.d.ts +29 -0
- package/dist/ioc/list.d.ts.map +1 -0
- package/dist/ioc/list.js +25 -0
- package/dist/ioc/list.js.map +1 -0
- package/dist/ioc/result.d.ts +18 -0
- package/dist/ioc/result.d.ts.map +1 -0
- package/dist/ioc/result.js +26 -0
- package/dist/ioc/result.js.map +1 -0
- package/dist/ioc/scan.d.ts +21 -0
- package/dist/ioc/scan.d.ts.map +1 -0
- package/dist/ioc/scan.js +247 -0
- package/dist/ioc/scan.js.map +1 -0
- package/dist/ioc/spawn.d.ts +26 -0
- package/dist/ioc/spawn.d.ts.map +1 -0
- package/dist/ioc/spawn.js +52 -0
- package/dist/ioc/spawn.js.map +1 -0
- package/dist/map/build.d.ts +58 -0
- package/dist/map/build.d.ts.map +1 -0
- package/dist/map/build.js +439 -0
- package/dist/map/build.js.map +1 -0
- package/dist/map/heat.d.ts +44 -0
- package/dist/map/heat.d.ts.map +1 -0
- package/dist/map/heat.js +96 -0
- package/dist/map/heat.js.map +1 -0
- package/dist/map/load.d.ts +38 -0
- package/dist/map/load.d.ts.map +1 -0
- package/dist/map/load.js +38 -0
- package/dist/map/load.js.map +1 -0
- package/dist/map/page.d.ts +90 -0
- package/dist/map/page.d.ts.map +1 -0
- package/dist/map/page.js +374 -0
- package/dist/map/page.js.map +1 -0
- package/dist/map/target-class.d.ts +22 -0
- package/dist/map/target-class.d.ts.map +1 -0
- package/dist/map/target-class.js +151 -0
- package/dist/map/target-class.js.map +1 -0
- package/dist/map/types.d.ts +102 -0
- package/dist/map/types.d.ts.map +1 -0
- package/dist/map/types.js +3 -0
- package/dist/map/types.js.map +1 -0
- package/dist/mcp/protocol.d.ts.map +1 -1
- package/dist/mcp/protocol.js +16 -5
- package/dist/mcp/protocol.js.map +1 -1
- package/dist/never-code.d.ts +7 -0
- package/dist/never-code.d.ts.map +1 -0
- package/dist/never-code.js +9 -0
- package/dist/never-code.js.map +1 -0
- package/dist/never-list-input.d.ts +48 -12
- package/dist/never-list-input.d.ts.map +1 -1
- package/dist/never-list-input.js +696 -29
- package/dist/never-list-input.js.map +1 -1
- package/dist/notify/dispatch.d.ts +4 -0
- package/dist/notify/dispatch.d.ts.map +1 -1
- package/dist/notify/dispatch.js +3 -1
- package/dist/notify/dispatch.js.map +1 -1
- package/dist/offline-evaluate.d.ts +26 -1
- package/dist/offline-evaluate.d.ts.map +1 -1
- package/dist/offline-evaluate.js +33 -4
- package/dist/offline-evaluate.js.map +1 -1
- package/dist/otel/exporter.d.ts +15 -2
- package/dist/otel/exporter.d.ts.map +1 -1
- package/dist/otel/exporter.js +52 -6
- package/dist/otel/exporter.js.map +1 -1
- package/dist/otel/map-attrs-optin.d.ts +39 -0
- package/dist/otel/map-attrs-optin.d.ts.map +1 -0
- package/dist/otel/map-attrs-optin.js +109 -0
- package/dist/otel/map-attrs-optin.js.map +1 -0
- package/dist/parse-args.d.ts +2 -0
- package/dist/parse-args.d.ts.map +1 -1
- package/dist/parse-args.js +4 -2
- package/dist/parse-args.js.map +1 -1
- package/dist/posture/card.d.ts +26 -0
- package/dist/posture/card.d.ts.map +1 -0
- package/dist/posture/card.js +46 -0
- package/dist/posture/card.js.map +1 -0
- package/dist/posture/collect.d.ts +19 -0
- package/dist/posture/collect.d.ts.map +1 -0
- package/dist/posture/collect.js +182 -0
- package/dist/posture/collect.js.map +1 -0
- package/dist/posture/send.d.ts +24 -0
- package/dist/posture/send.d.ts.map +1 -0
- package/dist/posture/send.js +162 -0
- package/dist/posture/send.js.map +1 -0
- package/dist/posture/spawn.d.ts +20 -0
- package/dist/posture/spawn.d.ts.map +1 -0
- package/dist/posture/spawn.js +33 -0
- package/dist/posture/spawn.js.map +1 -0
- package/dist/posture/store.d.ts +34 -0
- package/dist/posture/store.d.ts.map +1 -0
- package/dist/posture/store.js +107 -0
- package/dist/posture/store.js.map +1 -0
- package/dist/posture/validate.d.ts +33 -0
- package/dist/posture/validate.d.ts.map +1 -0
- package/dist/posture/validate.js +232 -0
- package/dist/posture/validate.js.map +1 -0
- package/dist/project-policy.d.ts +23 -0
- package/dist/project-policy.d.ts.map +1 -0
- package/dist/project-policy.js +95 -0
- package/dist/project-policy.js.map +1 -0
- package/dist/receipt/daemon.d.ts +9 -9
- package/dist/receipt/daemon.d.ts.map +1 -1
- package/dist/receipt/daemon.js +26 -11
- package/dist/receipt/daemon.js.map +1 -1
- package/dist/receipt/embeddings.d.ts +28 -10
- package/dist/receipt/embeddings.d.ts.map +1 -1
- package/dist/receipt/embeddings.js +221 -35
- package/dist/receipt/embeddings.js.map +1 -1
- package/dist/receipt/gate-page.d.ts.map +1 -1
- package/dist/receipt/gate-page.js +5 -22
- package/dist/receipt/gate-page.js.map +1 -1
- package/dist/receipt/live-server.d.ts +8 -0
- package/dist/receipt/live-server.d.ts.map +1 -1
- package/dist/receipt/live-server.js +136 -4
- package/dist/receipt/live-server.js.map +1 -1
- package/dist/receipt/render-receipt.d.ts +7 -1
- package/dist/receipt/render-receipt.d.ts.map +1 -1
- package/dist/receipt/render-receipt.js +118 -13
- package/dist/receipt/render-receipt.js.map +1 -1
- package/dist/receipt/search.d.ts +5 -0
- package/dist/receipt/search.d.ts.map +1 -1
- package/dist/receipt/search.js +23 -2
- package/dist/receipt/search.js.map +1 -1
- package/dist/receipt/semantic-setup.d.ts +21 -0
- package/dist/receipt/semantic-setup.d.ts.map +1 -0
- package/dist/receipt/semantic-setup.js +111 -0
- package/dist/receipt/semantic-setup.js.map +1 -0
- package/dist/receipt/semantic-status.d.ts +7 -0
- package/dist/receipt/semantic-status.d.ts.map +1 -0
- package/dist/receipt/semantic-status.js +26 -0
- package/dist/receipt/semantic-status.js.map +1 -0
- package/dist/sdk/govern.d.ts.map +1 -1
- package/dist/sdk/govern.js +7 -3
- package/dist/sdk/govern.js.map +1 -1
- package/dist/telemetry.d.ts.map +1 -1
- package/dist/telemetry.js +3 -23
- package/dist/telemetry.js.map +1 -1
- package/dist/trail-map-fields.d.ts +10 -0
- package/dist/trail-map-fields.d.ts.map +1 -0
- package/dist/trail-map-fields.js +53 -0
- package/dist/trail-map-fields.js.map +1 -0
- package/dist/trail-summary.d.ts.map +1 -1
- package/dist/trail-summary.js +11 -3
- package/dist/trail-summary.js.map +1 -1
- package/dist/trail.d.ts +9 -1
- package/dist/trail.d.ts.map +1 -1
- package/dist/trail.js +24 -1
- package/dist/trail.js.map +1 -1
- package/droid/README.md +1 -1
- package/droid/mcp.example.json +1 -1
- package/gemini/settings.example.json +1 -1
- package/grok/README.md +1 -1
- package/kilo/kilo.example.json +1 -1
- package/kimi/mcp.example.json +1 -1
- package/kiro/mcp.example.json +1 -1
- package/manifest.json +1 -1
- package/mastracode/mcp.example.json +1 -1
- package/mcp.json +1 -1
- package/openai/codex.config.example.toml +1 -1
- package/opencode/opencode.example.json +1 -1
- package/package.json +8 -4
- package/qwen/settings.example.json +1 -1
- package/server.json +2 -2
- package/vendor/cytoscape/LICENSE +19 -0
- package/vendor/cytoscape/README.md +7 -0
- package/vendor/cytoscape/cytoscape.min.js +31 -0
package/dist/never-list-input.js
CHANGED
|
@@ -27,18 +27,21 @@ function keptCmds(seg) {
|
|
|
27
27
|
return (seg.kept ??= commandsAt(seg.tokens, true));
|
|
28
28
|
}
|
|
29
29
|
/** A command word and the tokens after it. Copied once, when the segment is built. */
|
|
30
|
-
class Cmd {
|
|
30
|
+
export class Cmd {
|
|
31
31
|
name;
|
|
32
32
|
raw;
|
|
33
33
|
tokens;
|
|
34
34
|
start;
|
|
35
|
+
coprocName;
|
|
35
36
|
cached;
|
|
36
37
|
/** `raw` is the word as written (a download substitution is checked on it). */
|
|
37
|
-
|
|
38
|
+
/** `coprocName` is true for the NAME of `coproc NAME { ... }`: it never runs, so the persistence rules skip it. */
|
|
39
|
+
constructor(name, raw, tokens, start, coprocName = false) {
|
|
38
40
|
this.name = name;
|
|
39
41
|
this.raw = raw;
|
|
40
42
|
this.tokens = tokens;
|
|
41
43
|
this.start = start;
|
|
44
|
+
this.coprocName = coprocName;
|
|
42
45
|
}
|
|
43
46
|
/** Arguments, copied on first use: most candidate words are never asked for them. */
|
|
44
47
|
get args() {
|
|
@@ -156,7 +159,7 @@ function commandsAt(tokens, keepShells) {
|
|
|
156
159
|
// `coproc NAME { ...` or `coproc NAME for ...`: NAME is a candidate and the body is scanned too.
|
|
157
160
|
// Otherwise coproc is a wrapper: `coproc sudo -u root rm ...` and `coproc git push ...` run the command.
|
|
158
161
|
if (t === "coproc" && i + 2 < tokens.length && isCoprocName(tokens[i + 1]) && COPROC_BODY.has(tokens[i + 2])) {
|
|
159
|
-
out.push(new Cmd(tokens[i + 1], tokens[i + 1], tokens, i + 2));
|
|
162
|
+
out.push(new Cmd(tokens[i + 1], tokens[i + 1], tokens, i + 2, true));
|
|
160
163
|
i++; // NAME is a candidate; the words after it are scanned next
|
|
161
164
|
continue;
|
|
162
165
|
}
|
|
@@ -180,7 +183,7 @@ function commandAt(tokens, keepShells) {
|
|
|
180
183
|
const cs = commandsAt(tokens, keepShells);
|
|
181
184
|
return cs[cs.length - 1];
|
|
182
185
|
}
|
|
183
|
-
function hasCommand(sh, pred) {
|
|
186
|
+
export function hasCommand(sh, pred) {
|
|
184
187
|
// Indexed loops: this runs once per rule per segment, so no iterator objects.
|
|
185
188
|
const segs = sh.segments;
|
|
186
189
|
for (let i = 0; i < segs.length; i++) {
|
|
@@ -220,7 +223,7 @@ function resolveDots(p) {
|
|
|
220
223
|
* Canonical form for matching. Repeated slashes and `/.` go, `~`, `~user` and
|
|
221
224
|
* `$HOME` are expanded when the home directory is known, and `..` is resolved.
|
|
222
225
|
*/
|
|
223
|
-
function normalizePath(w, home) {
|
|
226
|
+
export function normalizePath(w, home) {
|
|
224
227
|
let p = w.replace(/\/{2,}/g, "/").replace(/\/\.(?=\/|$)/g, "");
|
|
225
228
|
if (p === "")
|
|
226
229
|
p = "/";
|
|
@@ -240,12 +243,27 @@ function normalizePath(w, home) {
|
|
|
240
243
|
p = p.slice(0, -1);
|
|
241
244
|
return p;
|
|
242
245
|
}
|
|
243
|
-
/**
|
|
244
|
-
|
|
246
|
+
/**
|
|
247
|
+
* A relative path that, joined to the working directory, is a strict ancestor of it
|
|
248
|
+
* (`..`, `../*`, `../../*`): the delete reaches above the project root. The project root
|
|
249
|
+
* itself (`.`, `./*`, `*`) is not above it. Needs a known absolute cwd, else false.
|
|
250
|
+
*/
|
|
251
|
+
function aboveCwd(word, cwd) {
|
|
252
|
+
if (!cwd || !cwd.startsWith("/") || word.startsWith("/") || word.startsWith("~") || word.includes("$"))
|
|
253
|
+
return false;
|
|
254
|
+
const root = cwd.length > 1 ? cwd.replace(/\/+$/, "") : cwd;
|
|
255
|
+
const target = resolveDots(`${root}/${word}`.replace(/\/{2,}/g, "/")).replace(/\/\.?\*$/, "") || "/";
|
|
256
|
+
return target !== root && (target === "/" || root.startsWith(`${target}/`));
|
|
257
|
+
}
|
|
258
|
+
/** Root, home, home contents, a top-level system directory (with optional `/*`), or a path above the working directory. */
|
|
259
|
+
function isProtected(word, sh) {
|
|
260
|
+
const home = sh.home;
|
|
245
261
|
const p = normalizePath(word, home);
|
|
246
262
|
if (ROOT_SET.has(p) || TOP_LEVEL.test(p) || USER_HOME.test(p))
|
|
247
263
|
return true;
|
|
248
|
-
|
|
264
|
+
if (home !== undefined && (p === home || p === `${home}/*` || p === `${home}/.*`))
|
|
265
|
+
return true;
|
|
266
|
+
return aboveCwd(word, sh.cwd);
|
|
249
267
|
}
|
|
250
268
|
function isRecursive(flags) {
|
|
251
269
|
return flags.some((f) => f === "--recursive" || (/^-[A-Za-z]+$/.test(f) && /[rR]/.test(f)));
|
|
@@ -267,7 +285,7 @@ function findDeleteProtected(c, sh) {
|
|
|
267
285
|
break;
|
|
268
286
|
starts.push(t);
|
|
269
287
|
}
|
|
270
|
-
if (!starts.some((p) => isProtected(p, sh
|
|
288
|
+
if (!starts.some((p) => isProtected(p, sh)))
|
|
271
289
|
return false;
|
|
272
290
|
if (c.args.includes("-delete"))
|
|
273
291
|
return true;
|
|
@@ -279,7 +297,7 @@ function rmRecursiveProtected(c, sh) {
|
|
|
279
297
|
return false;
|
|
280
298
|
const flags = c.args.filter((t) => t.startsWith("-"));
|
|
281
299
|
const paths = c.args.filter((t) => !t.startsWith("-"));
|
|
282
|
-
return isRecursive(flags) && paths.some((p) => isProtected(p, sh
|
|
300
|
+
return isRecursive(flags) && paths.some((p) => isProtected(p, sh));
|
|
283
301
|
}
|
|
284
302
|
function chmodChownRecursiveProtected(c, sh) {
|
|
285
303
|
if (c.name !== "chmod" && c.name !== "chown")
|
|
@@ -290,7 +308,7 @@ function chmodChownRecursiveProtected(c, sh) {
|
|
|
290
308
|
return false;
|
|
291
309
|
if (c.name === "chmod" && !rest.some((t) => /^0?777$/.test(t)))
|
|
292
310
|
return false;
|
|
293
|
-
return rest.some((p) => isProtected(p, sh
|
|
311
|
+
return rest.some((p) => isProtected(p, sh));
|
|
294
312
|
}
|
|
295
313
|
const PROTECTED_BRANCH = (b) => {
|
|
296
314
|
const n = b.toLowerCase();
|
|
@@ -311,7 +329,7 @@ const GIT_GLOBAL_FLAG = new Set([
|
|
|
311
329
|
* The subcommand index, and whether every option before it is known. An unknown option makes
|
|
312
330
|
* `known` false, so no later word is exempted by the subcommand (fail closed).
|
|
313
331
|
*/
|
|
314
|
-
function gitSubInfo(a) {
|
|
332
|
+
export function gitSubInfo(a) {
|
|
315
333
|
let i = 0;
|
|
316
334
|
let known = true;
|
|
317
335
|
while (i < a.length && a[i].startsWith("-")) {
|
|
@@ -468,7 +486,7 @@ function globSecret(p) {
|
|
|
468
486
|
return ssh ? base.startsWith("id_") && !base.endsWith(".pub") : base === "credentials";
|
|
469
487
|
}
|
|
470
488
|
/** Private key, cloud or package credential, or a real `.env` file (templates are not secret). Globs that could match one count too. */
|
|
471
|
-
function isSecretPath(raw, home) {
|
|
489
|
+
export function isSecretPath(raw, home) {
|
|
472
490
|
const p = normalizePath(raw, home);
|
|
473
491
|
const base = p.slice(p.lastIndexOf("/") + 1);
|
|
474
492
|
if (/(^|\/)\.ssh\/id_/.test(p) && !base.endsWith(".pub"))
|
|
@@ -708,6 +726,17 @@ function nestedNetExec(sh) {
|
|
|
708
726
|
}
|
|
709
727
|
// Fork bomb: `:(){ :|:& };:` or any one-word name. The name is bounded so the scan stays linear.
|
|
710
728
|
const FORK_BOMB = /(^|[\s;&|(){}])(:|[A-Za-z_][\w.-]{0,63})\s*\(\s*\)\s*\{\s*\2\s*\|\s*\2\s*&/;
|
|
729
|
+
// ksh and bash `function NAME { NAME|NAME& }`, with or without the `()`.
|
|
730
|
+
const FORK_BOMB_FUNCTION = /(^|[\s;&|(){}])function\s+([A-Za-z_][\w.-]{0,63})\s*(?:\(\s*\)\s*)?\{\s*\2\s*\|\s*\2\s*&/;
|
|
731
|
+
/**
|
|
732
|
+
* Tested on the whole text and on each token: quoted code given to `bash -c`, `sh -c`,
|
|
733
|
+
* `eval` or `sudo bash -c` is one token, and the quote in front of it is not a boundary
|
|
734
|
+
* for the raw text.
|
|
735
|
+
*/
|
|
736
|
+
function forkBomb(sh) {
|
|
737
|
+
const bomb = (t) => FORK_BOMB.test(t) || FORK_BOMB_FUNCTION.test(t);
|
|
738
|
+
return bomb(sh.raw) || sh.segments.some((seg) => seg.tokens.some((t) => t.includes("|") && bomb(t)));
|
|
739
|
+
}
|
|
711
740
|
const ANSI_ESC = {
|
|
712
741
|
n: "\n", t: "\t", r: "\r", a: "\x07", b: "\b", e: "\x1b", f: "\f", v: "\v",
|
|
713
742
|
"\\": "\\", "'": "'", '"': '"', "?": "?",
|
|
@@ -1070,14 +1099,589 @@ function segmentsOf(text, scope, depth) {
|
|
|
1070
1099
|
}
|
|
1071
1100
|
return segs;
|
|
1072
1101
|
}
|
|
1073
|
-
|
|
1074
|
-
|
|
1102
|
+
let lastParsed;
|
|
1103
|
+
/** One-entry memo: the never-list rules and the guard pack parse the same command back to back. */
|
|
1104
|
+
export function parseShell(cmd, home, cwd) {
|
|
1105
|
+
if (lastParsed && lastParsed.raw === cmd && lastParsed.home === home && lastParsed.cwd === cwd)
|
|
1106
|
+
return lastParsed;
|
|
1107
|
+
return (lastParsed = { raw: cmd, segments: segmentsOf(cmd, new Map(), 0), home, cwd });
|
|
1108
|
+
}
|
|
1109
|
+
/**
|
|
1110
|
+
* Command operands: the words after the command word with redirections removed (`>`, `>>`, `<` and
|
|
1111
|
+
* their target, and an fd digit before a redirect). `cp k FILE 2>/dev/null` has the operands k and FILE.
|
|
1112
|
+
*/
|
|
1113
|
+
function operandsOf(tokens) {
|
|
1114
|
+
const out = [];
|
|
1115
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
1116
|
+
const t = tokens[i];
|
|
1117
|
+
if (REDIRECT_OPS.has(t)) {
|
|
1118
|
+
i++;
|
|
1119
|
+
continue;
|
|
1120
|
+
}
|
|
1121
|
+
if (/^\d+$/.test(t) && REDIRECT_OPS.has(tokens[i + 1] ?? ""))
|
|
1122
|
+
continue;
|
|
1123
|
+
out.push(t);
|
|
1124
|
+
}
|
|
1125
|
+
return out;
|
|
1126
|
+
}
|
|
1127
|
+
/** Persistence paths, matched on the normalized path (see normalizePath). */
|
|
1128
|
+
// Case-insensitive (`i`): macOS file systems are case-insensitive by default, so `~/.SSH/Authorized_Keys` is the same file.
|
|
1129
|
+
const AUTHORIZED_KEYS = /(^|\/)\.ssh\/authorized_keys2?$/i;
|
|
1130
|
+
const CRON_PATH = /^\/(etc\/cron|var\/spool\/cron)/i;
|
|
1131
|
+
// Directory forms end with `(\/|$)`: normalizePath drops a trailing slash, and `cp x ~/Library/LaunchAgents/` writes inside.
|
|
1132
|
+
const LAUNCH_PATH = /(^|\/)Library\/Launch(Agents|Daemons)(\/|$)/i;
|
|
1133
|
+
const SYSTEMD_USER_PATH = /(^|\/)\.config\/systemd\/user(\/|$)|^\/etc\/systemd\/user(\/|$)/i;
|
|
1134
|
+
const SHELL_RC_PATH = /(^|\/)\.(zshrc|zshenv|zprofile|zlogin|bashrc|bash_profile|bash_login|profile)$/i;
|
|
1135
|
+
/** A curl or wget word (not part of a longer name). Used on a whole command that writes a shell rc file. */
|
|
1136
|
+
const NET_WORD = /(^|[^A-Za-z0-9_])(curl|wget)(?![A-Za-z0-9_])/;
|
|
1137
|
+
/**
|
|
1138
|
+
* The candidates the persistence and tunnel rules read: the last MAX_CANDIDATES command words of a segment,
|
|
1139
|
+
* without coproc NAMEs. The real command is the last candidate, so it is always read. Earlier candidates come
|
|
1140
|
+
* from expansions (`$(:)tee`) and are bounded here, so a long run of them stays linear (`coproc tee {` repeated was quadratic).
|
|
1141
|
+
*/
|
|
1142
|
+
const MAX_CANDIDATES = 16;
|
|
1143
|
+
/** Like hasCommand, over the bounded candidates. Allocates nothing: this runs for every rule on every command. */
|
|
1144
|
+
function hasCandidate(sh, pred) {
|
|
1145
|
+
const segs = sh.segments;
|
|
1146
|
+
for (let i = 0; i < segs.length; i++) {
|
|
1147
|
+
const cs = segs[i].cmds;
|
|
1148
|
+
let n = 0;
|
|
1149
|
+
for (let k = cs.length - 1; k >= 0 && n < MAX_CANDIDATES; k--) {
|
|
1150
|
+
const c = cs[k];
|
|
1151
|
+
if (c.coprocName)
|
|
1152
|
+
continue;
|
|
1153
|
+
n++;
|
|
1154
|
+
if (pred(c))
|
|
1155
|
+
return true;
|
|
1156
|
+
}
|
|
1157
|
+
}
|
|
1158
|
+
return false;
|
|
1159
|
+
}
|
|
1160
|
+
/**
|
|
1161
|
+
* Paths that a segment writes: redirect targets, tee operands, the destination of cp, mv and install,
|
|
1162
|
+
* the files of `sed -i`, and dd `of=`. Only the shapes named here count (a python `open(..., 'w')` does not).
|
|
1163
|
+
*/
|
|
1164
|
+
function writtenPaths(seg, out) {
|
|
1165
|
+
const t = seg.tokens;
|
|
1166
|
+
for (let i = 0; i + 1 < t.length; i++)
|
|
1167
|
+
if (t[i] === ">" || t[i] === ">>")
|
|
1168
|
+
out.push(t[i + 1]);
|
|
1169
|
+
const cs = seg.cmds;
|
|
1170
|
+
let n = 0;
|
|
1171
|
+
for (let k = cs.length - 1; k >= 0 && n < MAX_CANDIDATES; k--) {
|
|
1172
|
+
const c = cs[k];
|
|
1173
|
+
if (c.coprocName)
|
|
1174
|
+
continue;
|
|
1175
|
+
n++;
|
|
1176
|
+
if (c.name === "tee") {
|
|
1177
|
+
for (const a of operandsOf(c.args))
|
|
1178
|
+
if (!a.startsWith("-"))
|
|
1179
|
+
out.push(a);
|
|
1180
|
+
}
|
|
1181
|
+
else if (c.name === "cp" || c.name === "mv" || c.name === "install") {
|
|
1182
|
+
copyTargets(c, out);
|
|
1183
|
+
}
|
|
1184
|
+
else if (c.name === "curl" || c.name === "wget") {
|
|
1185
|
+
downloadTargets(c, out);
|
|
1186
|
+
}
|
|
1187
|
+
else if (c.name === "sed") {
|
|
1188
|
+
if (c.args.some((a) => /^-[A-Za-z]*i/.test(a) || a.startsWith("--in-place"))) {
|
|
1189
|
+
out.push(...operandsOf(c.args).filter((a) => !a.startsWith("-")).slice(1));
|
|
1190
|
+
}
|
|
1191
|
+
}
|
|
1192
|
+
else if (c.name === "dd") {
|
|
1193
|
+
for (const a of c.args)
|
|
1194
|
+
if (a.startsWith("of="))
|
|
1195
|
+
out.push(a.slice(3));
|
|
1196
|
+
}
|
|
1197
|
+
}
|
|
1198
|
+
}
|
|
1199
|
+
/** The last path part of a path, with trailing slashes removed (`~/.ssh/` gives `.ssh`). */
|
|
1200
|
+
function baseName(p) {
|
|
1201
|
+
const s = p.replace(/\/+$/, "");
|
|
1202
|
+
return s.slice(s.lastIndexOf("/") + 1);
|
|
1203
|
+
}
|
|
1204
|
+
/** Options of cp, mv and install that take a value (`install -m MODE`, `cp -S SUFFIX`): the value is not a path. */
|
|
1205
|
+
const COPY_VALUE_LETTERS = "Smog";
|
|
1206
|
+
/**
|
|
1207
|
+
* A long option named by `full`, or by a unique prefix of it of at least `min` characters (`--tar` for
|
|
1208
|
+
* `--target-directory`). The value is the text after `=`, or the next word (then `used` is true).
|
|
1209
|
+
*/
|
|
1210
|
+
function longOption(t, full, min, next) {
|
|
1211
|
+
const eq = t.indexOf("=");
|
|
1212
|
+
const name = eq >= 0 ? t.slice(0, eq) : t;
|
|
1213
|
+
if (name.length < min || !full.startsWith(name))
|
|
1214
|
+
return undefined;
|
|
1215
|
+
return eq >= 0 ? { value: t.slice(eq + 1), used: false } : { value: next, used: true };
|
|
1216
|
+
}
|
|
1217
|
+
/** `dir/base`, with one slash between them. */
|
|
1218
|
+
function joinPath(dir, base) {
|
|
1219
|
+
return dir.endsWith("/") ? dir + base : `${dir}/${base}`;
|
|
1220
|
+
}
|
|
1221
|
+
/**
|
|
1222
|
+
* cp, mv and install destinations. A destination that is a directory receives each source under its basename, so
|
|
1223
|
+
* both the destination and `DIR/basename` are tested. `-t DIR`, `-tDIR`, `--target-directory DIR`,
|
|
1224
|
+
* `--target-directory=DIR` and any unique prefix of the long option name the directory first.
|
|
1225
|
+
*/
|
|
1226
|
+
function copyTargets(c, out) {
|
|
1227
|
+
const ops = operandsOf(c.args);
|
|
1228
|
+
let target;
|
|
1229
|
+
const plain = [];
|
|
1230
|
+
for (let i = 0; i < ops.length; i++) {
|
|
1231
|
+
const a = ops[i];
|
|
1232
|
+
if (a === "--")
|
|
1233
|
+
continue;
|
|
1234
|
+
if (a.startsWith("--")) {
|
|
1235
|
+
const t = longOption(a, "--target-directory", 3, ops[i + 1]);
|
|
1236
|
+
if (t !== undefined) {
|
|
1237
|
+
target = t.value;
|
|
1238
|
+
if (t.used)
|
|
1239
|
+
i++;
|
|
1240
|
+
}
|
|
1241
|
+
continue;
|
|
1242
|
+
}
|
|
1243
|
+
if (a.length > 1 && a.startsWith("-")) {
|
|
1244
|
+
for (let k = 1; k < a.length; k++) {
|
|
1245
|
+
const ch = a[k];
|
|
1246
|
+
if (ch === "t") {
|
|
1247
|
+
if (k < a.length - 1)
|
|
1248
|
+
target = a.slice(k + 1);
|
|
1249
|
+
else {
|
|
1250
|
+
target = ops[i + 1];
|
|
1251
|
+
i++;
|
|
1252
|
+
}
|
|
1253
|
+
break;
|
|
1254
|
+
}
|
|
1255
|
+
if (COPY_VALUE_LETTERS.includes(ch)) {
|
|
1256
|
+
if (k === a.length - 1)
|
|
1257
|
+
i++;
|
|
1258
|
+
break;
|
|
1259
|
+
}
|
|
1260
|
+
if (!/[A-Za-z]/.test(ch))
|
|
1261
|
+
break;
|
|
1262
|
+
}
|
|
1263
|
+
continue;
|
|
1264
|
+
}
|
|
1265
|
+
plain.push(a);
|
|
1266
|
+
}
|
|
1267
|
+
if (target !== undefined)
|
|
1268
|
+
pushDestination(out, target, plain);
|
|
1269
|
+
else if (plain.length >= 2)
|
|
1270
|
+
pushDestination(out, plain[plain.length - 1], plain.slice(0, -1));
|
|
1271
|
+
}
|
|
1272
|
+
function pushDestination(out, dest, sources) {
|
|
1273
|
+
out.push(dest);
|
|
1274
|
+
for (const src of sources) {
|
|
1275
|
+
const base = baseName(src);
|
|
1276
|
+
if (base !== "")
|
|
1277
|
+
out.push(joinPath(dest, base));
|
|
1278
|
+
}
|
|
1075
1279
|
}
|
|
1076
1280
|
/**
|
|
1077
|
-
*
|
|
1078
|
-
*
|
|
1079
|
-
*
|
|
1080
|
-
*
|
|
1281
|
+
* Write targets of curl and wget. curl: `-o`/`--output`, and `--output-dir DIR` (with `-O`/`--remote-name`, the file
|
|
1282
|
+
* is the URL basename in that directory). wget: `-O`/`--output-document`, and `-P`/`--directory-prefix DIR` (wget
|
|
1283
|
+
* writes the URL basename in that directory unless -O is given). A value can be attached (`-o/path`, `-sSo~/x`,
|
|
1284
|
+
* `--output=/x`), and a long option can be a unique prefix (`--out`). Extra paths only add tests, so this fails closed.
|
|
1285
|
+
*/
|
|
1286
|
+
function downloadTargets(c, out) {
|
|
1287
|
+
const curl = c.name === "curl";
|
|
1288
|
+
const FILE_OPT = curl ? "--output" : "--output-document";
|
|
1289
|
+
const DIR_OPT = curl ? "--output-dir" : "--directory-prefix";
|
|
1290
|
+
const FILE_LETTER = curl ? "o" : "O";
|
|
1291
|
+
const DIR_LETTER = curl ? "" : "P";
|
|
1292
|
+
const a = c.args;
|
|
1293
|
+
const files = [];
|
|
1294
|
+
const urls = [];
|
|
1295
|
+
let dir;
|
|
1296
|
+
let remote = false;
|
|
1297
|
+
for (let i = 0; i < a.length; i++) {
|
|
1298
|
+
const t = a[i];
|
|
1299
|
+
if (t.startsWith("--")) {
|
|
1300
|
+
const name = t.split("=")[0];
|
|
1301
|
+
if (curl && name.length >= 4 && ("--remote-name".startsWith(name) || "--remote-name-all".startsWith(name) || "--remote-header-name".startsWith(name) || "--content-disposition".startsWith(name)))
|
|
1302
|
+
remote = true;
|
|
1303
|
+
const f = longOption(t, FILE_OPT, 3, a[i + 1]);
|
|
1304
|
+
const d = longOption(t, DIR_OPT, 3, a[i + 1]);
|
|
1305
|
+
if (f?.value !== undefined)
|
|
1306
|
+
files.push(f.value);
|
|
1307
|
+
if (d?.value !== undefined)
|
|
1308
|
+
dir = d.value;
|
|
1309
|
+
if (f?.used || d?.used)
|
|
1310
|
+
i++;
|
|
1311
|
+
continue;
|
|
1312
|
+
}
|
|
1313
|
+
if (t.length > 1 && t.startsWith("-")) {
|
|
1314
|
+
for (let k = 1; k < t.length; k++) {
|
|
1315
|
+
const ch = t[k];
|
|
1316
|
+
if (ch === FILE_LETTER || (DIR_LETTER !== "" && ch === DIR_LETTER)) {
|
|
1317
|
+
const used = k === t.length - 1;
|
|
1318
|
+
const v = used ? a[i + 1] : t.slice(k + 1);
|
|
1319
|
+
if (ch === FILE_LETTER) {
|
|
1320
|
+
if (v !== undefined)
|
|
1321
|
+
files.push(v);
|
|
1322
|
+
}
|
|
1323
|
+
else {
|
|
1324
|
+
dir = v;
|
|
1325
|
+
}
|
|
1326
|
+
if (used)
|
|
1327
|
+
i++;
|
|
1328
|
+
break;
|
|
1329
|
+
}
|
|
1330
|
+
if (curl && (ch === "O" || ch === "J"))
|
|
1331
|
+
remote = true;
|
|
1332
|
+
if (!/[A-Za-z]/.test(ch))
|
|
1333
|
+
break;
|
|
1334
|
+
}
|
|
1335
|
+
continue;
|
|
1336
|
+
}
|
|
1337
|
+
urls.push(t); // any operand: a scheme-less host is a URL too
|
|
1338
|
+
}
|
|
1339
|
+
for (const f of files) {
|
|
1340
|
+
out.push(f);
|
|
1341
|
+
if (dir !== undefined && dir !== "")
|
|
1342
|
+
out.push(joinPath(dir, f));
|
|
1343
|
+
}
|
|
1344
|
+
const named = curl ? remote : files.length === 0;
|
|
1345
|
+
if (named) {
|
|
1346
|
+
// The server can choose the file name, so the directory is a write target. `authorized_keys` is joined onto it
|
|
1347
|
+
// too: the rule then tests the normalized path, so `~/.SSH`, `$HOME/.ssh`, `./../.ssh` all resolve to a .ssh
|
|
1348
|
+
// directory. Each name is pushed bare and joined onto the directory (extra paths only add tests).
|
|
1349
|
+
const d = dir ?? "";
|
|
1350
|
+
if (d !== "") {
|
|
1351
|
+
out.push(d);
|
|
1352
|
+
out.push(joinPath(d, "authorized_keys"));
|
|
1353
|
+
}
|
|
1354
|
+
for (const u of urls) {
|
|
1355
|
+
const base = baseName(u.replace(/[?#].*$/, ""));
|
|
1356
|
+
if (base === "")
|
|
1357
|
+
continue;
|
|
1358
|
+
out.push(base);
|
|
1359
|
+
if (d !== "")
|
|
1360
|
+
out.push(joinPath(d, base));
|
|
1361
|
+
}
|
|
1362
|
+
}
|
|
1363
|
+
}
|
|
1364
|
+
/** Normalized written paths of one command, computed once for all rules that ask (they all read the same list). */
|
|
1365
|
+
const WRITTEN_CACHE = new WeakMap();
|
|
1366
|
+
function writtenIn(sh) {
|
|
1367
|
+
let out = WRITTEN_CACHE.get(sh);
|
|
1368
|
+
if (out === undefined) {
|
|
1369
|
+
const raw = [];
|
|
1370
|
+
const segs = sh.segments;
|
|
1371
|
+
for (let i = 0; i < segs.length; i++) {
|
|
1372
|
+
const seg = segs[i];
|
|
1373
|
+
// `>|` and `>&` end the segment at the operator, so the target is the first word of the next segment.
|
|
1374
|
+
const prev = i > 0 ? segs[i - 1].tokens : undefined;
|
|
1375
|
+
const last = prev?.[prev.length - 1];
|
|
1376
|
+
if (seg.tokens.length > 0 && (last === ">" || last === ">>"))
|
|
1377
|
+
raw.push(seg.tokens[0]);
|
|
1378
|
+
writtenPaths(seg, raw);
|
|
1379
|
+
}
|
|
1380
|
+
out = raw.map((w) => normalizePath(w, sh.home));
|
|
1381
|
+
WRITTEN_CACHE.set(sh, out);
|
|
1382
|
+
}
|
|
1383
|
+
return out;
|
|
1384
|
+
}
|
|
1385
|
+
/** True when a segment of the command writes to a path that `pred` accepts (after normalizePath). */
|
|
1386
|
+
function writesTo(sh, pred) {
|
|
1387
|
+
for (const p of writtenIn(sh))
|
|
1388
|
+
if (pred(p))
|
|
1389
|
+
return true;
|
|
1390
|
+
return false;
|
|
1391
|
+
}
|
|
1392
|
+
/** crontab that installs a table: `crontab -`, `crontab -e` or `crontab FILE`. `-l` and `-r` do not. */
|
|
1393
|
+
function cronWrites(c) {
|
|
1394
|
+
if (c.name !== "crontab")
|
|
1395
|
+
return false;
|
|
1396
|
+
const ops = operandsOf(c.args);
|
|
1397
|
+
for (let i = 0; i < ops.length; i++) {
|
|
1398
|
+
const t = ops[i];
|
|
1399
|
+
if (t === "-u") {
|
|
1400
|
+
i++;
|
|
1401
|
+
continue;
|
|
1402
|
+
}
|
|
1403
|
+
if (t === "-" || t === "-e" || !t.startsWith("-"))
|
|
1404
|
+
return true;
|
|
1405
|
+
}
|
|
1406
|
+
return false;
|
|
1407
|
+
}
|
|
1408
|
+
/** `at TIME` or `at -f FILE` schedules a job. `at -l` (list) and `at -d` or `-r` (remove) do not. */
|
|
1409
|
+
function atSchedules(c) {
|
|
1410
|
+
if (c.name !== "at")
|
|
1411
|
+
return false;
|
|
1412
|
+
const ops = operandsOf(c.args);
|
|
1413
|
+
if (ops.includes("-d") || ops.includes("-r"))
|
|
1414
|
+
return false;
|
|
1415
|
+
return ops.includes("-f") || ops.some((t) => !t.startsWith("-"));
|
|
1416
|
+
}
|
|
1417
|
+
/** The first operand that is not an option (the subcommand of launchctl and systemctl). */
|
|
1418
|
+
function firstSubcommand(c) {
|
|
1419
|
+
return operandsOf(c.args).find((t) => !t.startsWith("-"));
|
|
1420
|
+
}
|
|
1421
|
+
/** `launchctl load` or `launchctl bootstrap` starts a LaunchAgent or LaunchDaemon. `launchctl list` does not. */
|
|
1422
|
+
function launchLoads(c) {
|
|
1423
|
+
if (c.name !== "launchctl")
|
|
1424
|
+
return false;
|
|
1425
|
+
const sub = firstSubcommand(c);
|
|
1426
|
+
return sub === "load" || sub === "bootstrap";
|
|
1427
|
+
}
|
|
1428
|
+
/** `systemctl --user enable` or `link` makes a user unit start at login. `--user status` does not. */
|
|
1429
|
+
function systemdUserEnables(c) {
|
|
1430
|
+
if (c.name !== "systemctl")
|
|
1431
|
+
return false;
|
|
1432
|
+
const ops = operandsOf(c.args);
|
|
1433
|
+
if (!ops.includes("--user"))
|
|
1434
|
+
return false;
|
|
1435
|
+
const sub = firstSubcommand(c);
|
|
1436
|
+
return sub === "enable" || sub === "link";
|
|
1437
|
+
}
|
|
1438
|
+
/** Option letters that take a value in ssh (`-p 22`, `-oKey=val`, `-L 8080:x:80`). */
|
|
1439
|
+
const SSH_VALUE_LETTERS = "BbcDEeFIiJLlmOopQRSWw";
|
|
1440
|
+
/**
|
|
1441
|
+
* Every ssh option in the command, wherever it stands. OpenSSH parses options after the destination too
|
|
1442
|
+
* (`ssh host -R 9000:localhost:22 -N`), so every operand is read until `--`. Returns the option letters, the
|
|
1443
|
+
* values of `-o` options (`RemoteForward=...`), the `-F` config files, and the destination (the first operand
|
|
1444
|
+
* that is not an option). A value option takes the rest of its word, or the next word: `-fNR host`, `-R8080:...`,
|
|
1445
|
+
* `-oRemoteForward=...`, `-F~/x`.
|
|
1446
|
+
*/
|
|
1447
|
+
function sshShape(c) {
|
|
1448
|
+
const ops = operandsOf(c.args);
|
|
1449
|
+
const letters = new Set();
|
|
1450
|
+
const opts = [];
|
|
1451
|
+
const configs = [];
|
|
1452
|
+
const libs = [];
|
|
1453
|
+
let dest;
|
|
1454
|
+
let noMoreOptions = false;
|
|
1455
|
+
for (let i = 0; i < ops.length; i++) {
|
|
1456
|
+
const t = ops[i];
|
|
1457
|
+
if (!noMoreOptions && t === "--") {
|
|
1458
|
+
noMoreOptions = true;
|
|
1459
|
+
continue;
|
|
1460
|
+
}
|
|
1461
|
+
if (noMoreOptions || !t.startsWith("-") || t === "-") {
|
|
1462
|
+
if (dest === undefined)
|
|
1463
|
+
dest = t;
|
|
1464
|
+
continue;
|
|
1465
|
+
}
|
|
1466
|
+
for (let k = 1; k < t.length; k++) {
|
|
1467
|
+
const ch = t[k];
|
|
1468
|
+
letters.add(ch);
|
|
1469
|
+
if (SSH_VALUE_LETTERS.includes(ch)) {
|
|
1470
|
+
const attached = k < t.length - 1;
|
|
1471
|
+
const value = attached ? t.slice(k + 1) : ops[i + 1];
|
|
1472
|
+
if (!attached)
|
|
1473
|
+
i++;
|
|
1474
|
+
if (ch === "o" && value !== undefined)
|
|
1475
|
+
opts.push(value);
|
|
1476
|
+
if (ch === "F" && value !== undefined)
|
|
1477
|
+
configs.push(value);
|
|
1478
|
+
if (ch === "I" && value !== undefined)
|
|
1479
|
+
libs.push(value);
|
|
1480
|
+
break;
|
|
1481
|
+
}
|
|
1482
|
+
}
|
|
1483
|
+
}
|
|
1484
|
+
return { letters, opts, configs, libs, dest };
|
|
1485
|
+
}
|
|
1486
|
+
/** The two jump shapes allowed as a ProxyCommand: `ssh -W %h:%p HOST` and `nc %h %p`. */
|
|
1487
|
+
const SSH_JUMP = /^(ssh -W %h:%p [A-Za-z0-9._@:-]+|nc %h %p)$/;
|
|
1488
|
+
/**
|
|
1489
|
+
* One -o option value: the keyword is the text before the first `=` or whitespace, lower-cased. Double quotes are
|
|
1490
|
+
* removed first, the way OpenSSH reads `-o "ProxyCommand=nc x 22"` and `-o '"ProxyCommand" nc x 22'`.
|
|
1491
|
+
* Undefined when no keyword can be read.
|
|
1492
|
+
*/
|
|
1493
|
+
function sshOptionKeyValue(v) {
|
|
1494
|
+
const m = /^([A-Za-z][A-Za-z0-9]*)\s*(?:=|\s)\s*([\s\S]*)$/.exec(v.replace(/"/g, "").trim());
|
|
1495
|
+
return m ? { key: m[1].toLowerCase(), value: m[2].trim() } : undefined;
|
|
1496
|
+
}
|
|
1497
|
+
/**
|
|
1498
|
+
* A -o option that runs a command and whose value is not one of the two jump shapes (`SSH_JUMP`), or a -o option
|
|
1499
|
+
* that cannot be read (fail closed). Gated keys: every keyword that ends in `command` (ProxyCommand, LocalCommand,
|
|
1500
|
+
* KnownHostsCommand, RemoteCommand), except PermitLocalCommand, a yes/no switch. This is an allowlist of the value,
|
|
1501
|
+
* not a word list. IdentityAgent names a socket and is not gated.
|
|
1502
|
+
*/
|
|
1503
|
+
function sshOptionDenied(v) {
|
|
1504
|
+
const kv = sshOptionKeyValue(v);
|
|
1505
|
+
if (!kv)
|
|
1506
|
+
return true;
|
|
1507
|
+
if (!kv.key.endsWith("command") || kv.key === "permitlocalcommand")
|
|
1508
|
+
return false;
|
|
1509
|
+
return !SSH_JUMP.test(kv.value);
|
|
1510
|
+
}
|
|
1511
|
+
/** -o keys that load a shared library (a provider) into ssh: only `none` or an empty value is allowed. */
|
|
1512
|
+
const CODE_PROVIDER_KEYS = new Set(["pkcs11provider", "securitykeyprovider"]);
|
|
1513
|
+
function providerValueDenied(value) {
|
|
1514
|
+
const v = value.replace(/"/g, "").trim().toLowerCase();
|
|
1515
|
+
return v !== "" && v !== "none";
|
|
1516
|
+
}
|
|
1517
|
+
function sshCodeLoadingDenied(c) {
|
|
1518
|
+
const shape = sshShape(c);
|
|
1519
|
+
if (shape.libs.some(providerValueDenied))
|
|
1520
|
+
return true;
|
|
1521
|
+
return shape.opts.some((v) => {
|
|
1522
|
+
const kv = sshOptionKeyValue(v);
|
|
1523
|
+
return kv !== undefined && CODE_PROVIDER_KEYS.has(kv.key) && providerValueDenied(kv.value);
|
|
1524
|
+
});
|
|
1525
|
+
}
|
|
1526
|
+
const IPV4 = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/;
|
|
1527
|
+
/** Octets of a dotted IPv4 literal, or undefined. */
|
|
1528
|
+
function ipv4Octets(s) {
|
|
1529
|
+
const m = IPV4.exec(s);
|
|
1530
|
+
if (!m)
|
|
1531
|
+
return undefined;
|
|
1532
|
+
const o = m.slice(1).map(Number);
|
|
1533
|
+
return o.every((x) => x <= 255) ? o : undefined;
|
|
1534
|
+
}
|
|
1535
|
+
/** A dotted IPv4 host, with an optional `user@` in front. A name such as `1.2.3.4.nip.io` is not one. */
|
|
1536
|
+
function isIpv4Host(d) {
|
|
1537
|
+
if (d === undefined)
|
|
1538
|
+
return false;
|
|
1539
|
+
return ipv4Octets(d.slice(d.lastIndexOf("@") + 1)) !== undefined;
|
|
1540
|
+
}
|
|
1541
|
+
/** Public IPv4: not loopback (127), private (10, 172.16/12, 192.168), link-local (169.254), or 0. */
|
|
1542
|
+
function isPublicIpv4(s) {
|
|
1543
|
+
const [a = -1, b = -1] = ipv4Octets(s) ?? [];
|
|
1544
|
+
if (a < 0)
|
|
1545
|
+
return false;
|
|
1546
|
+
return !(a === 0 || a === 10 || a === 127 || (a === 169 && b === 254) || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168));
|
|
1547
|
+
}
|
|
1548
|
+
const IPV4_IN_TEXT = /(?:^|[^\d.])((?:\d{1,3}\.){3}\d{1,3})(?![\d.])/g;
|
|
1549
|
+
/** True when a word holds a public dotted IPv4 literal (a URL `http://8.8.8.8:80/x` counts). */
|
|
1550
|
+
function hasPublicIpv4(s) {
|
|
1551
|
+
for (const m of s.matchAll(IPV4_IN_TEXT))
|
|
1552
|
+
if (isPublicIpv4(m[1]))
|
|
1553
|
+
return true;
|
|
1554
|
+
return false;
|
|
1555
|
+
}
|
|
1556
|
+
/** `ssh -R` (any destination, any position, or `RemoteForward` in an option): a reverse tunnel into this machine. */
|
|
1557
|
+
function sshReverseForward(c) {
|
|
1558
|
+
if (c.name !== "ssh")
|
|
1559
|
+
return false;
|
|
1560
|
+
const { letters, opts } = sshShape(c);
|
|
1561
|
+
return letters.has("R") || opts.some((v) => /^remoteforward/i.test(v));
|
|
1562
|
+
}
|
|
1563
|
+
/** `ssh -L ... -N` (or `-o LocalForward`) to a dotted IPv4 address. A tunnel to a host name is allowed (a normal dev tunnel). */
|
|
1564
|
+
function sshLocalToRawIp(c) {
|
|
1565
|
+
if (c.name !== "ssh")
|
|
1566
|
+
return false;
|
|
1567
|
+
const { letters, opts, dest } = sshShape(c);
|
|
1568
|
+
const forward = letters.has("L") || opts.some((v) => /^localforward/i.test(v));
|
|
1569
|
+
return forward && letters.has("N") && isIpv4Host(dest);
|
|
1570
|
+
}
|
|
1571
|
+
/** `ssh -w` opens a layer 3 tunnel device on both ends. Denied: it is a tunnel with no host check. */
|
|
1572
|
+
function sshTunnelDevice(c) {
|
|
1573
|
+
return c.name === "ssh" && sshShape(c).letters.has("w");
|
|
1574
|
+
}
|
|
1575
|
+
/** `ssh -o ProxyCommand=...` that runs a shell, netcat, socat, an interpreter or a download: a command run on connect. */
|
|
1576
|
+
function sshProxyExec(c) {
|
|
1577
|
+
return c.name === "ssh" && sshShape(c).opts.some(sshOptionDenied);
|
|
1578
|
+
}
|
|
1579
|
+
/**
|
|
1580
|
+
* `ssh -F FILE` when this same command writes FILE (a redirect, tee, cp and so on): the config can carry a
|
|
1581
|
+
* ProxyCommand that was written in the same call. A config written by an earlier call is not seen (documented limit).
|
|
1582
|
+
*/
|
|
1583
|
+
function sshConfigWritten(sh) {
|
|
1584
|
+
const written = writtenIn(sh);
|
|
1585
|
+
if (written.length === 0)
|
|
1586
|
+
return false;
|
|
1587
|
+
return hasCandidate(sh, (c) => c.name === "ssh" && sshShape(c).configs.some((f) => written.includes(normalizePath(f, sh.home))));
|
|
1588
|
+
}
|
|
1589
|
+
const NC_NAMES = new Set(["nc", "ncat", "netcat"]);
|
|
1590
|
+
/**
|
|
1591
|
+
* A netcat or socat listener or client that runs a program. For nc, ncat and netcat, `-e`, `-c` or `--exec`,
|
|
1592
|
+
* `--sh-exec` (also inside a short cluster such as `-nve`). `nc -z`, `nc -l PORT`, and `nc host port < file` are not matches.
|
|
1593
|
+
*/
|
|
1594
|
+
function netcatExec(c) {
|
|
1595
|
+
if (NC_NAMES.has(c.name)) {
|
|
1596
|
+
return operandsOf(c.args).some((a) => /^-[A-Za-z]*[ec]/.test(a) || a === "--exec" || a === "--sh-exec");
|
|
1597
|
+
}
|
|
1598
|
+
if (c.name === "socat")
|
|
1599
|
+
return operandsOf(c.args).some((a) => /(^|[,:])(EXEC|SYSTEM):/i.test(a));
|
|
1600
|
+
return false;
|
|
1601
|
+
}
|
|
1602
|
+
const DEV_TCP = /^\/dev\/(tcp|udp)\//;
|
|
1603
|
+
const REDIRECT_IN_OUT = new Set(["<", ">", ">>"]);
|
|
1604
|
+
/**
|
|
1605
|
+
* A bash network redirect: `/dev/tcp/HOST/PORT` as the target of `<`, `>` or `>>`, or as the first word of a
|
|
1606
|
+
* segment (`bash -i >& /dev/tcp/...` splits at the `&`, so the target starts the next segment). Text that
|
|
1607
|
+
* only names the path (`git commit -m "/dev/tcp"`) does not count.
|
|
1608
|
+
*/
|
|
1609
|
+
function devTcpRedirect(sh) {
|
|
1610
|
+
if (!sh.raw.includes("/dev/"))
|
|
1611
|
+
return false;
|
|
1612
|
+
for (const seg of sh.segments) {
|
|
1613
|
+
const t = seg.tokens;
|
|
1614
|
+
if (t.length > 0 && DEV_TCP.test(t[0]))
|
|
1615
|
+
return true;
|
|
1616
|
+
for (let i = 0; i + 1 < t.length; i++) {
|
|
1617
|
+
if (REDIRECT_IN_OUT.has(t[i]) && DEV_TCP.test(t[i + 1]))
|
|
1618
|
+
return true;
|
|
1619
|
+
}
|
|
1620
|
+
}
|
|
1621
|
+
return false;
|
|
1622
|
+
}
|
|
1623
|
+
/**
|
|
1624
|
+
* True when the words of one command dump the environment: `env` or `printenv` with no command after it,
|
|
1625
|
+
* bare `set` (no operands), `export -p`, `declare -p` and `compgen -e`. `set -e` and `env FOO=1 node x` are not dumps.
|
|
1626
|
+
*/
|
|
1627
|
+
function isEnvDump(tokens) {
|
|
1628
|
+
let dump = false;
|
|
1629
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
1630
|
+
const t = tokens[i];
|
|
1631
|
+
if (t.startsWith("-") || ASSIGN.test(t))
|
|
1632
|
+
continue;
|
|
1633
|
+
const name = wordName(t);
|
|
1634
|
+
if (name === "printenv")
|
|
1635
|
+
return true;
|
|
1636
|
+
if (name === "env") {
|
|
1637
|
+
dump = true;
|
|
1638
|
+
continue;
|
|
1639
|
+
}
|
|
1640
|
+
if (WRAPPERS.has(name))
|
|
1641
|
+
continue;
|
|
1642
|
+
if (name === "set")
|
|
1643
|
+
return i === tokens.length - 1;
|
|
1644
|
+
if (name === "export" || name === "declare")
|
|
1645
|
+
return tokens.slice(i + 1).includes("-p");
|
|
1646
|
+
if (name === "compgen")
|
|
1647
|
+
return tokens.slice(i + 1).includes("-e");
|
|
1648
|
+
return false;
|
|
1649
|
+
}
|
|
1650
|
+
return dump;
|
|
1651
|
+
}
|
|
1652
|
+
const RAW_IP_CLIENTS = new Set(["curl", "wget", "nc", "ncat", "netcat", "socat", "telnet"]);
|
|
1653
|
+
const VAR_REF = /\$\{?[A-Za-z_]/;
|
|
1654
|
+
/**
|
|
1655
|
+
* Environment names or values sent to a public raw IPv4 address. The command must have a client
|
|
1656
|
+
* (curl, wget, nc, socat, telnet) with a public IPv4 operand, and either an environment dump
|
|
1657
|
+
* (`env | ...`, `printenv`, bare `set`, `$(env)`) or a `$NAME` reference anywhere in the same command.
|
|
1658
|
+
* Linear: the client check runs first and stops most commands; then one scan for the dump, one for the reference.
|
|
1659
|
+
* Loopback and private addresses are allowed on purpose.
|
|
1660
|
+
*/
|
|
1661
|
+
function envToPublicIp(sh) {
|
|
1662
|
+
if (!hasCandidate(sh, (c) => RAW_IP_CLIENTS.has(c.name) && operandsOf(c.args).some(hasPublicIpv4)))
|
|
1663
|
+
return false;
|
|
1664
|
+
for (const seg of sh.segments)
|
|
1665
|
+
if (isEnvDump(seg.tokens))
|
|
1666
|
+
return true;
|
|
1667
|
+
for (const seg of sh.segments)
|
|
1668
|
+
if (seg.tokens.some((t) => VAR_REF.test(t)))
|
|
1669
|
+
return true;
|
|
1670
|
+
return false;
|
|
1671
|
+
}
|
|
1672
|
+
/** Path of one line of an apply_patch body (Codex): Add File, Update File, Delete File or Move to. */
|
|
1673
|
+
const PATCH_PATH = /^\*\*\* (?:Add File|Update File|Delete File|Move to): ([^\r\n]+)$/gm;
|
|
1674
|
+
/**
|
|
1675
|
+
* Words of the persistence, tunnel and exfiltration rules, searched in a whole command longer than MAX_SCAN_CHARS.
|
|
1676
|
+
* One alternation with no nested repeats, so the search is linear. It is coarse on purpose: a long command that
|
|
1677
|
+
* names one of these tools is denied.
|
|
1678
|
+
*/
|
|
1679
|
+
const LONG_INPUT_WORDS = /authorized_keys|LaunchAgents|LaunchDaemons|systemd\/user|\/etc\/cron|\/var\/spool\/cron|crontab|launchctl|systemctl|schtasks|ssh-copy-id|autossh|RemoteForward|LocalForward|ProxyCommand|\/dev\/(tcp|udp)\/|\bssh\b|\bnc\b|ncat|netcat|socat|\.(zshrc|zshenv|zprofile|zlogin|bashrc|bash_profile|bash_login|profile)\b/i;
|
|
1680
|
+
/**
|
|
1681
|
+
* THE rule table. One row per rule, one comment per row. The owner approved groups a to e
|
|
1682
|
+
* on 2026-10-29, with no changes. Groups f to h were added on 2026-10-30 and wait for owner
|
|
1683
|
+
* review. Changes to a row need owner approval. Matching is by parsed command word and exact
|
|
1684
|
+
* target, never by substring alone, so `~/proj/node_modules`, `/tmp/x` and `.env.example` stay allowed.
|
|
1081
1685
|
*/
|
|
1082
1686
|
export const NEVER_LIST_INPUT_RULES = [
|
|
1083
1687
|
// a. Recursive delete of home, root or a top-level system dir (also find -delete / -exec rm on those).
|
|
@@ -1100,10 +1704,34 @@ export const NEVER_LIST_INPUT_RULES = [
|
|
|
1100
1704
|
group: "d",
|
|
1101
1705
|
shell: (sh) => sh.segments.some((seg) => seg.tokens.some((t, i) => (t === ">" || t === ">>") && BLOCK_DEVICE.test(seg.tokens[i + 1] ?? ""))),
|
|
1102
1706
|
},
|
|
1103
|
-
{ id: "fork-bomb", group: "d", shell:
|
|
1707
|
+
{ id: "fork-bomb", group: "d", shell: forkBomb },
|
|
1104
1708
|
{ id: "chmod-chown-recursive-root", group: "d", shell: (sh) => hasCommand(sh, chmodChownRecursiveProtected) },
|
|
1105
1709
|
// e. Network download piped into a shell, or run as code through a substitution (`sh -c "$(curl ...)"`).
|
|
1106
1710
|
{ id: "pipe-network-to-shell", group: "e", shell: (sh) => pipeNetworkToShell(sh) || nestedNetExec(sh) },
|
|
1711
|
+
// f. Agent-driven persistence (added 2026-10-30). A write rule also runs on Write, Edit and apply_patch paths.
|
|
1712
|
+
{ id: "ssh-authorized-keys-write", group: "f", shell: (sh) => writesTo(sh, (p) => AUTHORIZED_KEYS.test(p)), writeFile: (p, home) => AUTHORIZED_KEYS.test(normalizePath(p, home)) },
|
|
1713
|
+
{ id: "ssh-copy-id", group: "f", shell: (sh) => hasCandidate(sh, (c) => c.name === "ssh-copy-id") },
|
|
1714
|
+
{ id: "persistence-cron", group: "f", shell: (sh) => hasCandidate(sh, cronWrites) || writesTo(sh, (p) => CRON_PATH.test(p)), writeFile: (p, home) => CRON_PATH.test(normalizePath(p, home)) },
|
|
1715
|
+
{ id: "persistence-at-job", group: "f", shell: (sh) => hasCandidate(sh, atSchedules) },
|
|
1716
|
+
{ id: "persistence-launchd", group: "f", shell: (sh) => hasCandidate(sh, launchLoads) || writesTo(sh, (p) => LAUNCH_PATH.test(p)), writeFile: (p, home) => LAUNCH_PATH.test(normalizePath(p, home)) },
|
|
1717
|
+
{ id: "persistence-systemd-user", group: "f", shell: (sh) => hasCandidate(sh, systemdUserEnables) || writesTo(sh, (p) => SYSTEMD_USER_PATH.test(p)), writeFile: (p, home) => SYSTEMD_USER_PATH.test(normalizePath(p, home)) },
|
|
1718
|
+
{ id: "persistence-schtasks-create", group: "f", shell: (sh) => hasCandidate(sh, (c) => c.name === "schtasks" && c.args.some((a) => a.toLowerCase() === "/create")) },
|
|
1719
|
+
// A download (curl, wget) in a command that writes a shell rc file: it runs at every shell start.
|
|
1720
|
+
{ id: "shell-rc-download-append", group: "f", shell: (sh) => NET_WORD.test(sh.raw) && writesTo(sh, (p) => SHELL_RC_PATH.test(p)) },
|
|
1721
|
+
// g. Reverse tunnels and reverse shells (added 2026-10-30).
|
|
1722
|
+
{ id: "ssh-reverse-forward", group: "g", shell: (sh) => hasCandidate(sh, sshReverseForward) },
|
|
1723
|
+
{ id: "ssh-local-forward-raw-ip", group: "g", shell: (sh) => hasCandidate(sh, sshLocalToRawIp) },
|
|
1724
|
+
{ id: "reverse-shell-netcat-exec", group: "g", shell: (sh) => hasCandidate(sh, netcatExec) },
|
|
1725
|
+
{ id: "reverse-shell-dev-tcp", group: "g", shell: devTcpRedirect },
|
|
1726
|
+
{ id: "autossh-tunnel", group: "g", shell: (sh) => hasCandidate(sh, (c) => c.name === "autossh") },
|
|
1727
|
+
{ id: "ssh-tunnel-device", group: "g", shell: (sh) => hasCandidate(sh, sshTunnelDevice) },
|
|
1728
|
+
{ id: "ssh-proxy-command-exec", group: "g", shell: (sh) => hasCandidate(sh, sshProxyExec) },
|
|
1729
|
+
{ id: "ssh-config-written", group: "g", shell: sshConfigWritten },
|
|
1730
|
+
{ id: "ssh-code-loading-option", group: "g", shell: (sh) => hasCandidate(sh, sshCodeLoadingDenied) },
|
|
1731
|
+
// Long command (over MAX_SCAN_CHARS): the parsed windows skip the middle, so the whole text is searched for these words.
|
|
1732
|
+
{ id: "long-input-persistence-words", group: "f", fullText: (text) => LONG_INPUT_WORDS.test(text) },
|
|
1733
|
+
// h. Exfiltration of environment names or values to a public raw IPv4 address (added 2026-10-30).
|
|
1734
|
+
{ id: "env-dump-to-raw-ip", group: "h", shell: envToPublicIp },
|
|
1107
1735
|
];
|
|
1108
1736
|
const WRITE_NAME = /write|edit|create|update|delete|remove|move|rename|patch|replace|save|append|mkdir|touch/;
|
|
1109
1737
|
const SHELL_NAME = /bash|shell|terminal|exec|command/;
|
|
@@ -1115,12 +1743,12 @@ const PATH_LIST_FIELDS = ["include", "paths"];
|
|
|
1115
1743
|
function lastSegment(toolId) {
|
|
1116
1744
|
return toolId.split("__").pop().trim().toLowerCase();
|
|
1117
1745
|
}
|
|
1118
|
-
function isShellTool(toolId) {
|
|
1746
|
+
export function isShellTool(toolId) {
|
|
1119
1747
|
const last = lastSegment(toolId);
|
|
1120
1748
|
return findHostToolTier(last) === "SHELL" || SHELL_NAME.test(last);
|
|
1121
1749
|
}
|
|
1122
1750
|
/** Tools that write. The file rule skips them: a write to `.env` is not a read of a secret. */
|
|
1123
|
-
function isWriteLike(toolId) {
|
|
1751
|
+
export function isWriteLike(toolId) {
|
|
1124
1752
|
const last = lastSegment(toolId);
|
|
1125
1753
|
if (findHostToolTier(last) === "WRITE" || WRITE_NAME.test(last))
|
|
1126
1754
|
return true;
|
|
@@ -1130,7 +1758,7 @@ function isWriteLike(toolId) {
|
|
|
1130
1758
|
const tier = mcpToolTier(mcp.server, mcp.tool);
|
|
1131
1759
|
return tier === "WRITE" || tier === "ADMIN";
|
|
1132
1760
|
}
|
|
1133
|
-
function fieldsOf(toolInput) {
|
|
1761
|
+
export function fieldsOf(toolInput) {
|
|
1134
1762
|
let v = toolInput;
|
|
1135
1763
|
if (typeof v === "string") {
|
|
1136
1764
|
if (v.length > MAX_JSON_CHARS)
|
|
@@ -1147,10 +1775,10 @@ function fieldsOf(toolInput) {
|
|
|
1147
1775
|
return v;
|
|
1148
1776
|
}
|
|
1149
1777
|
/** The first and last window of a long field. A short field is one window. */
|
|
1150
|
-
function windowsOf(s) {
|
|
1778
|
+
export function windowsOf(s) {
|
|
1151
1779
|
return s.length <= MAX_SCAN_CHARS ? [s] : [s.slice(0, MAX_SCAN_CHARS), s.slice(-MAX_SCAN_CHARS)];
|
|
1152
1780
|
}
|
|
1153
|
-
function commandStrings(f) {
|
|
1781
|
+
export function commandStrings(f) {
|
|
1154
1782
|
const v = f.command ?? f.cmd;
|
|
1155
1783
|
let s;
|
|
1156
1784
|
if (typeof v === "string")
|
|
@@ -1159,7 +1787,7 @@ function commandStrings(f) {
|
|
|
1159
1787
|
s = v.join(" ");
|
|
1160
1788
|
return s === undefined ? [] : windowsOf(s);
|
|
1161
1789
|
}
|
|
1162
|
-
function pathStrings(f, toolId) {
|
|
1790
|
+
export function pathStrings(f, toolId) {
|
|
1163
1791
|
const out = [];
|
|
1164
1792
|
for (const k of PATH_FIELDS) {
|
|
1165
1793
|
const v = f[k];
|
|
@@ -1179,6 +1807,30 @@ function pathStrings(f, toolId) {
|
|
|
1179
1807
|
}
|
|
1180
1808
|
return out;
|
|
1181
1809
|
}
|
|
1810
|
+
/** Paths that a write-like tool writes: its path fields, and for Codex apply_patch the paths named in the patch body. */
|
|
1811
|
+
function writePathStrings(f, toolId) {
|
|
1812
|
+
const out = pathStrings(f, toolId);
|
|
1813
|
+
if (lastSegment(toolId) === "apply_patch") {
|
|
1814
|
+
// The patch is one string, or an argv array whose items are lines or chunks. Each item is scanned on its own
|
|
1815
|
+
// (a joined array does not hide a line start), and the whole item is scanned, not a window: the header regex is
|
|
1816
|
+
// linear, and a long patch must not hide a header in its middle.
|
|
1817
|
+
const v = f.command ?? f.cmd;
|
|
1818
|
+
const items = typeof v === "string" ? [v] : Array.isArray(v) ? v.filter((x) => typeof x === "string") : [];
|
|
1819
|
+
for (const item of items)
|
|
1820
|
+
for (const m of item.matchAll(PATCH_PATH))
|
|
1821
|
+
out.push(m[1].trim());
|
|
1822
|
+
}
|
|
1823
|
+
return out;
|
|
1824
|
+
}
|
|
1825
|
+
/** The whole shell command text, unwindowed (the same join as commandStrings), or undefined when there is none. */
|
|
1826
|
+
function fullCommandText(f) {
|
|
1827
|
+
const v = f.command ?? f.cmd;
|
|
1828
|
+
if (typeof v === "string")
|
|
1829
|
+
return v;
|
|
1830
|
+
if (Array.isArray(v) && v.every((x) => typeof x === "string"))
|
|
1831
|
+
return v.join(" ");
|
|
1832
|
+
return undefined;
|
|
1833
|
+
}
|
|
1182
1834
|
/** Home directory for `~` and absolute home paths. Undefined when the OS has none. */
|
|
1183
1835
|
function safeHome() {
|
|
1184
1836
|
try {
|
|
@@ -1193,20 +1845,35 @@ function safeHome() {
|
|
|
1193
1845
|
* First matching input rule for this tool call, or undefined (allow). The caller
|
|
1194
1846
|
* denies with reasonCode NEVER_EVENT. Never throws: any error is an undefined result.
|
|
1195
1847
|
*/
|
|
1196
|
-
export function neverListInputMatch(toolId, toolInput, home = safeHome()) {
|
|
1848
|
+
export function neverListInputMatch(toolId, toolInput, home = safeHome(), cwd) {
|
|
1197
1849
|
try {
|
|
1198
1850
|
const f = fieldsOf(toolInput);
|
|
1199
1851
|
if (!f)
|
|
1200
1852
|
return undefined;
|
|
1201
1853
|
if (isShellTool(toolId)) {
|
|
1854
|
+
// Long command: the parsed windows do not cover the middle, so the whole text gets the word search first.
|
|
1855
|
+
const full = fullCommandText(f);
|
|
1856
|
+
if (full !== undefined && full.length > MAX_SCAN_CHARS) {
|
|
1857
|
+
for (const r of NEVER_LIST_INPUT_RULES)
|
|
1858
|
+
if (r.fullText?.(full))
|
|
1859
|
+
return { rule: r.id };
|
|
1860
|
+
}
|
|
1202
1861
|
for (const cmd of commandStrings(f)) {
|
|
1203
|
-
const sh = parseShell(cmd, home);
|
|
1862
|
+
const sh = parseShell(cmd, home, cwd);
|
|
1204
1863
|
for (const r of NEVER_LIST_INPUT_RULES)
|
|
1205
1864
|
if (r.shell?.(sh))
|
|
1206
1865
|
return { rule: r.id };
|
|
1207
1866
|
}
|
|
1208
1867
|
}
|
|
1209
|
-
if (
|
|
1868
|
+
if (isWriteLike(toolId)) {
|
|
1869
|
+
// Write tools: only the writeFile rules (persistence files). A write to `.env` is not a read of a secret.
|
|
1870
|
+
for (const p of writePathStrings(f, toolId)) {
|
|
1871
|
+
for (const r of NEVER_LIST_INPUT_RULES)
|
|
1872
|
+
if (r.writeFile?.(p, home))
|
|
1873
|
+
return { rule: r.id };
|
|
1874
|
+
}
|
|
1875
|
+
}
|
|
1876
|
+
else {
|
|
1210
1877
|
for (const p of pathStrings(f, toolId)) {
|
|
1211
1878
|
for (const r of NEVER_LIST_INPUT_RULES)
|
|
1212
1879
|
if (r.file?.(p, home))
|