@alvin0/ai-agent-sdk-sandbox 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js ADDED
@@ -0,0 +1,1310 @@
1
+ //#region src/errors.ts
2
+ /** Fail-closed error surface. Silent unconfined passthrough is never legal. */
3
+ /** Thrown when no backend on this host can enforce a confining policy. */
4
+ var SandboxUnavailableError = class extends Error {
5
+ platform;
6
+ code = "SANDBOX_UNAVAILABLE";
7
+ /** Runner ids that were considered for this platform, in chain order. */
8
+ attempted;
9
+ constructor(platform, attempted = [], detail) {
10
+ const options = attempted.length === 0 ? "no runner is available" : `tried ${attempted.join(", ")}`;
11
+ super(`No sandbox backend can confine this execution on ${platform}: ${options}${detail === void 0 ? "" : ` (${detail})`}`);
12
+ this.platform = platform;
13
+ this.name = "SandboxUnavailableError";
14
+ this.attempted = Object.freeze([...attempted]);
15
+ }
16
+ };
17
+ /** Thrown by the in-process fence when a mutation leaves the permitted roots. */
18
+ var SandboxDeniedError = class extends Error {
19
+ path;
20
+ mode;
21
+ writableRoots;
22
+ code = "SANDBOX_DENIED";
23
+ constructor(path, mode, writableRoots) {
24
+ const permitted = writableRoots.length === 0 ? "nothing is writable" : `writable roots: ${writableRoots.join(", ")}`;
25
+ super(`Sandbox mode '${mode}' denies writing ${path} (${permitted})`);
26
+ this.path = path;
27
+ this.mode = mode;
28
+ this.writableRoots = writableRoots;
29
+ this.name = "SandboxDeniedError";
30
+ }
31
+ };
32
+ /** Thrown when a policy is structurally unusable before any backend is asked. */
33
+ var SandboxPolicyError = class extends Error {
34
+ code = "SANDBOX_POLICY_INVALID";
35
+ constructor(message) {
36
+ super(message);
37
+ this.name = "SandboxPolicyError";
38
+ }
39
+ };
40
+
41
+ //#endregion
42
+ //#region src/approval.ts
43
+ /**
44
+ * The minted set. A `WeakSet` is the whole mechanism: membership cannot be
45
+ * forged, serialized, or reached from inside a tool payload.
46
+ */
47
+ const MINTED = /* @__PURE__ */ new WeakSet();
48
+ /** Approvals already consumed. Separate from minting, so the refusal differs. */
49
+ const SPENT = /* @__PURE__ */ new WeakSet();
50
+ /**
51
+ * Mint an approval. Call this only after the host has actually authorized the
52
+ * escalation; the SDK cannot tell an approved grant from a requested one, which
53
+ * is precisely why this call has to be the place the distinction is made.
54
+ */
55
+ function approveSandboxEscalation(grant) {
56
+ const approval = Object.freeze({
57
+ ...grant,
58
+ approved: true
59
+ });
60
+ MINTED.add(approval);
61
+ return approval;
62
+ }
63
+ /** Whether a value is an approval this module minted. */
64
+ function isSandboxApproval(value) {
65
+ return typeof value === "object" && value !== null && MINTED.has(value);
66
+ }
67
+ /** Whether a minted approval has already been used up. */
68
+ function isSandboxApprovalSpent(approval) {
69
+ return SPENT.has(approval);
70
+ }
71
+ /**
72
+ * Accept an approval, or refuse it loudly.
73
+ * @throws SandboxPolicyError when the value was not minted here — which is what
74
+ * a forged approval arriving through a tool payload looks like.
75
+ */
76
+ function requireSandboxApproval(value, now = Date.now()) {
77
+ if (!isSandboxApproval(value)) throw new SandboxPolicyError("Sandbox escalation requires an approval minted by approveSandboxEscalation(); a plain object cannot widen a policy, because a tool payload can contain one");
78
+ if (value.expiresAt !== void 0 && now > value.expiresAt) throw new SandboxPolicyError("This sandbox approval has expired; an escalation outliving the moment it was granted is an escalation nobody is still watching");
79
+ if (SPENT.has(value)) throw new SandboxPolicyError("This sandbox approval was already used; a grant given for one operation cannot be replayed for the next");
80
+ if ((value.scope ?? "single-call") === "single-call") SPENT.add(value);
81
+ return value;
82
+ }
83
+
84
+ //#endregion
85
+ //#region src/exec.ts
86
+ /** The outcome each capability maps to, before a deployment adjusts it. */
87
+ const DEFAULT_EXEC_OUTCOMES = Object.freeze({
88
+ observe: "allow",
89
+ use: "allow-scoped",
90
+ modify: "ask-approval",
91
+ "service-control": "ask-approval",
92
+ "package-install": "ask-approval",
93
+ privilege: "ask-approval",
94
+ credential: "deny",
95
+ critical: "deny",
96
+ unknown: "ask-approval"
97
+ });
98
+ /** Commands that only report state. */
99
+ const OBSERVE = /* @__PURE__ */ new Set([
100
+ "uname",
101
+ "uptime",
102
+ "hostname",
103
+ "date",
104
+ "whoami",
105
+ "id",
106
+ "df",
107
+ "du",
108
+ "free",
109
+ "vmstat",
110
+ "top",
111
+ "ps",
112
+ "pgrep",
113
+ "lsof",
114
+ "ss",
115
+ "netstat",
116
+ "ifconfig",
117
+ "ip",
118
+ "arch",
119
+ "sw_vers",
120
+ "sysctl",
121
+ "lscpu",
122
+ "lsblk",
123
+ "nproc",
124
+ "env",
125
+ "printenv",
126
+ "pwd",
127
+ "which",
128
+ "whereis",
129
+ "cat",
130
+ "head",
131
+ "tail",
132
+ "less",
133
+ "more",
134
+ "wc",
135
+ "stat",
136
+ "file",
137
+ "ls",
138
+ "find",
139
+ "grep",
140
+ "rg",
141
+ "awk",
142
+ "sed",
143
+ "sort",
144
+ "uniq",
145
+ "cut",
146
+ "diff",
147
+ "md5sum",
148
+ "sha256sum",
149
+ "journalctl",
150
+ "dmesg",
151
+ "log",
152
+ "tree",
153
+ "readlink",
154
+ "realpath",
155
+ "basename",
156
+ "dirname",
157
+ "echo",
158
+ "vm_stat",
159
+ "iostat",
160
+ "mpstat",
161
+ "sar",
162
+ "pmap",
163
+ "swapon",
164
+ "getconf",
165
+ "sysctl",
166
+ "launchctl",
167
+ "pgrep",
168
+ "pidof",
169
+ "w",
170
+ "last",
171
+ "lsattr",
172
+ "mount",
173
+ "blkid"
174
+ ]);
175
+ /** Project tooling: runs inside a workspace and is scoped by the file policy. */
176
+ const USE = /* @__PURE__ */ new Set([
177
+ "node",
178
+ "npm",
179
+ "npx",
180
+ "pnpm",
181
+ "yarn",
182
+ "bun",
183
+ "deno",
184
+ "tsc",
185
+ "vitest",
186
+ "jest",
187
+ "python",
188
+ "python3",
189
+ "pip",
190
+ "pip3",
191
+ "pytest",
192
+ "ruby",
193
+ "bundle",
194
+ "go",
195
+ "cargo",
196
+ "rustc",
197
+ "java",
198
+ "mvn",
199
+ "gradle",
200
+ "make",
201
+ "cmake",
202
+ "git",
203
+ "docker",
204
+ "kubectl",
205
+ "terraform",
206
+ "gh",
207
+ "curl",
208
+ "wget",
209
+ "jq"
210
+ ]);
211
+ /** Commands that change files. */
212
+ const MODIFY = /* @__PURE__ */ new Set([
213
+ "cp",
214
+ "mv",
215
+ "rm",
216
+ "mkdir",
217
+ "rmdir",
218
+ "touch",
219
+ "ln",
220
+ "chmod",
221
+ "chown",
222
+ "chgrp",
223
+ "truncate",
224
+ "dd",
225
+ "tee",
226
+ "install",
227
+ "patch",
228
+ "tar",
229
+ "unzip",
230
+ "zip"
231
+ ]);
232
+ /** Commands that start, stop or signal running services. */
233
+ const SERVICE_CONTROL = /* @__PURE__ */ new Set([
234
+ "systemctl",
235
+ "service",
236
+ "launchctl",
237
+ "initctl",
238
+ "kill",
239
+ "killall",
240
+ "pkill",
241
+ "supervisorctl",
242
+ "brew",
243
+ "nginx",
244
+ "apachectl",
245
+ "pm2"
246
+ ]);
247
+ /** Commands that install software. */
248
+ const PACKAGE_INSTALL = /* @__PURE__ */ new Set([
249
+ "apt",
250
+ "apt-get",
251
+ "dpkg",
252
+ "yum",
253
+ "dnf",
254
+ "rpm",
255
+ "pacman",
256
+ "apk",
257
+ "snap",
258
+ "port"
259
+ ]);
260
+ /** Commands that run as another, more powerful, identity. */
261
+ const PRIVILEGE = /* @__PURE__ */ new Set([
262
+ "sudo",
263
+ "su",
264
+ "doas",
265
+ "pkexec",
266
+ "runas"
267
+ ]);
268
+ /** Commands whose effect cannot be undone from inside a session. */
269
+ const CRITICAL = /* @__PURE__ */ new Set([
270
+ "reboot",
271
+ "shutdown",
272
+ "halt",
273
+ "poweroff",
274
+ "mkfs",
275
+ "fdisk",
276
+ "parted",
277
+ "iptables",
278
+ "nft",
279
+ "ufw",
280
+ "pfctl",
281
+ "usermod",
282
+ "useradd",
283
+ "userdel",
284
+ "passwd",
285
+ "visudo",
286
+ "csrutil",
287
+ "spctl"
288
+ ]);
289
+ /**
290
+ * Programs that read a credential without naming where it lives.
291
+ *
292
+ * Found by running real model output through this classifier: asked to show
293
+ * AWS credentials, a model proposed `aws configure list` and `aws sts
294
+ * get-caller-identity`. Neither names `~/.aws/credentials`, so a rule that
295
+ * matches credential PATHS sees nothing — the secret is read inside the tool.
296
+ * The program is the signal here, not its arguments.
297
+ */
298
+ const CREDENTIAL_TOOLS = /* @__PURE__ */ new Set([
299
+ "aws",
300
+ "gcloud",
301
+ "az",
302
+ "doctl",
303
+ "heroku",
304
+ "op",
305
+ "vault",
306
+ "pass",
307
+ "keyring",
308
+ "security",
309
+ "gpg",
310
+ "ssh-add",
311
+ "ssh-agent",
312
+ "kubelogin",
313
+ "aws-vault"
314
+ ]);
315
+ /** Subcommands of those tools that only report non-secret state. */
316
+ const CREDENTIAL_TOOL_SAFE_VERBS = Object.freeze({});
317
+ /** Shell keywords that lead a segment without being the command. */
318
+ const SHELL_KEYWORDS = /* @__PURE__ */ new Set([
319
+ "if",
320
+ "then",
321
+ "else",
322
+ "elif",
323
+ "fi",
324
+ "do",
325
+ "done",
326
+ "while",
327
+ "until",
328
+ "for",
329
+ "case",
330
+ "esac",
331
+ "in",
332
+ "{",
333
+ "}",
334
+ "(",
335
+ ")",
336
+ "!",
337
+ "time",
338
+ "exec"
339
+ ]);
340
+ /** Paths whose contents authorize something, wherever they are read from. */
341
+ const CREDENTIAL_PATTERN = /(^|\/)(\.ssh|\.aws|\.gnupg|\.kube|\.docker\/config\.json|\.npmrc|\.netrc|\.git-credentials|credentials|id_[a-z0-9]+|.*\.pem|.*\.key|\.env(\.[a-z]+)?)(\/|$)/i;
342
+ /** Paths whose modification changes who may do what on the machine. */
343
+ const CRITICAL_PATH_PATTERN = /(^|\/)(etc\/(sudoers|shadow|passwd|ssh\/sshd_config|pam\.d)|boot|sys\/kernel|proc\/sys)(\/|$)/i;
344
+ /** Shells whose `-c` argument is another command entirely. */
345
+ const SHELLS = /* @__PURE__ */ new Set([
346
+ "sh",
347
+ "bash",
348
+ "zsh",
349
+ "dash",
350
+ "ksh",
351
+ "fish",
352
+ "pwsh",
353
+ "powershell"
354
+ ]);
355
+ /** Wrappers that run another command without changing what it does. */
356
+ const TRANSPARENT = /* @__PURE__ */ new Set([
357
+ "env",
358
+ "nice",
359
+ "ionice",
360
+ "nohup",
361
+ "stdbuf",
362
+ "time",
363
+ "timeout",
364
+ "command"
365
+ ]);
366
+ /**
367
+ * Classify one command.
368
+ * @param argv - the exact argv, program first. A shell string is read through.
369
+ * @param outcomes - the capability-to-outcome mapping a deployment uses.
370
+ */
371
+ function classifyExec(argv, outcomes = DEFAULT_EXEC_OUTCOMES) {
372
+ const parts = splitCommands(argv).map((part) => classifySingle(part, outcomes)).filter((part) => part.program !== "(empty)");
373
+ if (parts.length === 0) return decide("unknown", "(empty)", "no command to classify", [], outcomes);
374
+ if (parts.length === 1) return parts[0];
375
+ const worst = parts.reduce((left, right) => CAPABILITY_RANK[right.capability] > CAPABILITY_RANK[left.capability] ? right : left);
376
+ return decide(worst.capability, worst.program, `a chain of ${String(parts.length)} commands, decided by its riskiest: ${worst.reason}`, parts, outcomes);
377
+ }
378
+ /** Consequence order, used to pick the decisive command in a chain. */
379
+ const CAPABILITY_RANK = Object.freeze({
380
+ observe: 0,
381
+ use: 1,
382
+ unknown: 2,
383
+ modify: 3,
384
+ "service-control": 4,
385
+ "package-install": 5,
386
+ privilege: 6,
387
+ credential: 7,
388
+ critical: 8
389
+ });
390
+ /** Build a classification with its outcome resolved. */
391
+ function decide(capability, program, reason, parts, outcomes) {
392
+ return Object.freeze({
393
+ capability,
394
+ outcome: outcomes[capability],
395
+ program,
396
+ reason,
397
+ parts: Object.freeze(parts)
398
+ });
399
+ }
400
+ /** Classify a single command that contains no further commands. */
401
+ function classifySingle(argv, outcomes) {
402
+ const redirect = argv.findIndex((token) => token === ">" || token === ">>");
403
+ if (redirect >= 0) {
404
+ const target = argv[redirect + 1] ?? "";
405
+ const inner = classifySingle(argv.slice(0, redirect), outcomes);
406
+ const written = classifySingle(["tee", target], outcomes);
407
+ return CAPABILITY_RANK[written.capability] > CAPABILITY_RANK[inner.capability] ? decide(written.capability, inner.program, `redirects output into ${target}`, [], outcomes) : decide(inner.capability, inner.program, inner.reason, [], outcomes);
408
+ }
409
+ const stripped = stripWrappers(argv);
410
+ const program = basename(stripped[0] ?? "");
411
+ const args = stripped.slice(1);
412
+ if (program === "") return decide("unknown", "(empty)", "no program named", [], outcomes);
413
+ const touched = args.filter((argument) => !argument.startsWith("-"));
414
+ const credential = touched.find((argument) => CREDENTIAL_PATTERN.test(argument));
415
+ if (credential !== void 0) return decide("credential", program, `names a credential path: ${credential}`, [], outcomes);
416
+ const critical = touched.find((argument) => CRITICAL_PATH_PATTERN.test(argument));
417
+ if (critical !== void 0) return decide("critical", program, `names a path that governs access: ${critical}`, [], outcomes);
418
+ const inPlace = IN_PLACE_FLAGS[program];
419
+ if (inPlace !== void 0 && args.some((argument) => inPlace.some((flag) => argument === flag || argument.startsWith(`${flag}`) && flag === "-i"))) return decide("modify", program, "edits its input in place", [], outcomes);
420
+ if (program === "find" && args.some((argument) => argument === "-delete" || argument === "-exec" || argument === "-execdir" || argument === "-ok" || argument === "-okdir")) return decide("modify", program, "runs an action that may change files", [], outcomes);
421
+ if (program === "awk" && args.some((argument) => /(^|[^A-Za-z_])system\s*\(/.test(argument))) return decide("unknown", program, "evaluates another command dynamically", [], outcomes);
422
+ if (program === "rm" && touched.some((argument) => ROOTISH.has(argument.replace(/\/+$/, "") || "/"))) return decide("critical", program, `removes a system root: ${touched.join(" ")}`, [], outcomes);
423
+ if (CREDENTIAL_TOOLS.has(program)) {
424
+ const verb = touched[0] ?? "";
425
+ if (!(CREDENTIAL_TOOL_SAFE_VERBS[program]?.has(verb) ?? false)) return decide("credential", program, `reads a credential the command never names${verb === "" ? "" : ` (${verb})`}`, [], outcomes);
426
+ }
427
+ if (PRIVILEGE.has(program)) return decide("privilege", program, "runs as another identity", [], outcomes);
428
+ if (CRITICAL.has(program)) return decide("critical", program, "changes the machine in a way a session cannot undo", [], outcomes);
429
+ if (PACKAGE_INSTALL.has(program)) return decide("package-install", program, "installs software", [], outcomes);
430
+ if (SERVICE_CONTROL.has(program)) return classifyServiceCommand(program, args, outcomes);
431
+ if (MODIFY.has(program)) return decide("modify", program, "changes files", [], outcomes);
432
+ if (USE.has(program)) return classifyToolCommand(program, args, outcomes);
433
+ if (program === "command" || program === "[" || program === "test" || program === "[[") return decide("observe", program, "tests for something without running it", [], outcomes);
434
+ if (OBSERVE.has(program)) return decide("observe", program, "reports state without changing it", [], outcomes);
435
+ return decide("unknown", program, "not a command this policy recognises", [], outcomes);
436
+ }
437
+ /**
438
+ * Where a program puts the verb. `systemctl restart nginx` names the action
439
+ * first; `service nginx restart` names the unit first, and reading position 0
440
+ * for both makes every `service` invocation look like a control action.
441
+ */
442
+ const VERB_POSITION = Object.freeze({
443
+ service: 1,
444
+ systemctl: 0,
445
+ launchctl: 0,
446
+ initctl: 0,
447
+ supervisorctl: 0,
448
+ brew: 0,
449
+ pm2: 0
450
+ });
451
+ /** Flags that turn a reading tool into a writing one. */
452
+ const IN_PLACE_FLAGS = Object.freeze({
453
+ sed: ["-i", "--in-place"],
454
+ perl: ["-i"],
455
+ awk: ["-i", "--in-place"],
456
+ ruby: ["-i"]
457
+ });
458
+ /** Paths whose wholesale removal is not something a session can undo. */
459
+ const ROOTISH = /* @__PURE__ */ new Set([
460
+ "/",
461
+ "/*",
462
+ "/etc",
463
+ "/usr",
464
+ "/bin",
465
+ "/sbin",
466
+ "/var",
467
+ "/boot",
468
+ "/System",
469
+ "/Library"
470
+ ]);
471
+ /** Verbs that only report a service's state. */
472
+ const SERVICE_READ_VERBS = /* @__PURE__ */ new Set([
473
+ "status",
474
+ "show",
475
+ "list",
476
+ "list-units",
477
+ "is-active",
478
+ "is-enabled",
479
+ "cat",
480
+ "get",
481
+ "services",
482
+ "print",
483
+ "info",
484
+ "ls",
485
+ "config",
486
+ "list-unit-files"
487
+ ]);
488
+ /**
489
+ * Separate observing a service from controlling one — the distinction the file
490
+ * seam cannot make, and the reason this module exists.
491
+ */
492
+ function classifyServiceCommand(program, args, outcomes) {
493
+ const verb = args.filter((argument) => !argument.startsWith("-"))[VERB_POSITION[program] ?? 0];
494
+ if (verb === void 0) return decide("observe", program, "lists services without naming an action", [], outcomes);
495
+ if (SERVICE_READ_VERBS.has(verb)) return decide("observe", program, `reports service state (${verb})`, [], outcomes);
496
+ if (program === "nginx" && args.includes("-t")) return decide("observe", program, "checks configuration without applying it", [], outcomes);
497
+ return decide("service-control", program, `changes a running service (${verb})`, [], outcomes);
498
+ }
499
+ /** Tool subcommands that install outside the workspace or raise privilege. */
500
+ function classifyToolCommand(program, args, outcomes) {
501
+ if ((args.includes("-g") || args.includes("--global") || args.includes("--location=global")) && (program === "npm" || program === "pnpm" || program === "yarn")) return decide("package-install", program, "installs outside the workspace", [], outcomes);
502
+ if (program === "docker" && args.some((argument) => argument === "run" || argument === "exec")) return decide("service-control", program, "starts or enters a container", [], outcomes);
503
+ return decide("use", program, "project tooling, scoped by the file policy", [], outcomes);
504
+ }
505
+ /** Remove wrappers, shell keywords and leading assignments without losing meaning. */
506
+ function stripWrappers(argv) {
507
+ let current = [...argv];
508
+ for (let guard = 0; guard < 8; guard++) {
509
+ while (current.length > 0 && SHELL_KEYWORDS.has(current[0] ?? "")) current.shift();
510
+ while (current.length > 0 && /^[A-Za-z_][A-Za-z0-9_]*=/.test(current[0] ?? "")) current.shift();
511
+ const program = basename(current[0] ?? "");
512
+ if (!TRANSPARENT.has(program)) break;
513
+ current = current.slice(1);
514
+ while (current.length > 0 && (current[0] ?? "").startsWith("-")) current.shift();
515
+ if (program === "timeout" && current.length > 0 && /^[0-9]/.test(current[0] ?? "")) current.shift();
516
+ }
517
+ return current;
518
+ }
519
+ /**
520
+ * Every command an argv actually runs.
521
+ *
522
+ * A shell invocation carries its real command in a string, and that string can
523
+ * hold several. Splitting it with a pattern cannot work: `grep -E 'a|b'` puts a
524
+ * separator inside a quoted word, and a pattern either splits there — inventing
525
+ * commands out of a regex — or refuses to split anywhere a quote appears. The
526
+ * script is therefore walked one character at a time, so a separator only
527
+ * separates when nothing is quoting it.
528
+ */
529
+ function splitCommands(argv) {
530
+ const stripped = stripWrappers(argv);
531
+ const program = basename(stripped[0] ?? "");
532
+ const flagIndex = stripped.findIndex((argument) => argument === "-c" || argument === "-Command");
533
+ let script;
534
+ if (SHELLS.has(program) && flagIndex >= 0) script = stripped[flagIndex + 1];
535
+ else if (stripped.some((token) => UNQUOTED_SEPARATOR.test(token))) script = stripped.join(" ");
536
+ if (script === void 0) return [stripped];
537
+ const commands = tokenizeScript(script);
538
+ if (hasDynamicShellSyntax(script)) return Object.freeze([...commands, Object.freeze(["(dynamic-shell-syntax)"])]);
539
+ return commands.length === 0 ? [stripped] : commands;
540
+ }
541
+ /** Syntax that can execute code our deliberately small tokenizer cannot see. */
542
+ function hasDynamicShellSyntax(script) {
543
+ let quote;
544
+ for (let index = 0; index < script.length; index++) {
545
+ const character = script[index] ?? "";
546
+ if (character === "\\" && quote !== "'") {
547
+ index += 1;
548
+ continue;
549
+ }
550
+ if (character === "'") {
551
+ if (quote === void 0) quote = "'";
552
+ else if (quote === "'") quote = void 0;
553
+ continue;
554
+ }
555
+ if (character === "\"") {
556
+ if (quote === void 0) quote = "\"";
557
+ else if (quote === "\"") quote = void 0;
558
+ continue;
559
+ }
560
+ if (quote === "'") continue;
561
+ if (character === "`") return true;
562
+ const pair = script.slice(index, index + 2);
563
+ if (pair === "$(" || pair === "<(" || pair === ">(") return true;
564
+ }
565
+ return quote !== void 0;
566
+ }
567
+ /** A separator that is not inside quotes, used only to decide whether to walk. */
568
+ const UNQUOTED_SEPARATOR = /^(&&|\|\||;|\|)$|[;|&]/;
569
+ /**
570
+ * Split a shell script into commands, respecting quotes and escapes.
571
+ *
572
+ * Deliberately not a shell parser: it does not expand, substitute, or
573
+ * understand control flow. It answers one question — which words belong to
574
+ * which command — and leaves the rest to the classifier, which treats anything
575
+ * it cannot read as something to ask about.
576
+ */
577
+ function tokenizeScript(script) {
578
+ const commands = [];
579
+ let command = [];
580
+ let word = "";
581
+ let quote;
582
+ let index = 0;
583
+ const endWord = () => {
584
+ if (word !== "") {
585
+ command.push(word);
586
+ word = "";
587
+ }
588
+ };
589
+ const endCommand = () => {
590
+ endWord();
591
+ if (command.length > 0) commands.push(command);
592
+ command = [];
593
+ };
594
+ while (index < script.length) {
595
+ const character = script[index] ?? "";
596
+ if (quote !== void 0) {
597
+ if (character === "\\" && quote === "\"" && index + 1 < script.length) {
598
+ word += script[index + 1] ?? "";
599
+ index += 2;
600
+ continue;
601
+ }
602
+ if (character === quote) quote = void 0;
603
+ else word += character;
604
+ index += 1;
605
+ continue;
606
+ }
607
+ if (character === "\"" || character === "'") {
608
+ quote = character;
609
+ index += 1;
610
+ continue;
611
+ }
612
+ if (character === "\\" && index + 1 < script.length) {
613
+ word += script[index + 1] ?? "";
614
+ index += 2;
615
+ continue;
616
+ }
617
+ if (character === " " || character === " ") {
618
+ endWord();
619
+ index += 1;
620
+ continue;
621
+ }
622
+ if (character === "\n") {
623
+ endCommand();
624
+ index += 1;
625
+ continue;
626
+ }
627
+ const pair = script.slice(index, index + 2);
628
+ if (pair === "&&" || pair === "||") {
629
+ endCommand();
630
+ index += 2;
631
+ continue;
632
+ }
633
+ if (pair === ">>") {
634
+ endWord();
635
+ command.push(">>");
636
+ index += 2;
637
+ continue;
638
+ }
639
+ if (character === ";" || character === "|" || character === "&") {
640
+ endCommand();
641
+ index += 1;
642
+ continue;
643
+ }
644
+ if (character === ">") {
645
+ endWord();
646
+ command.push(">");
647
+ index += 1;
648
+ continue;
649
+ }
650
+ if (character === "(" || character === ")" || character === "{" || character === "}") {
651
+ endWord();
652
+ index += 1;
653
+ continue;
654
+ }
655
+ word += character;
656
+ index += 1;
657
+ }
658
+ endCommand();
659
+ return commands;
660
+ }
661
+ /** The final path segment, so `/usr/bin/systemctl` decides like `systemctl`. */
662
+ function basename(value) {
663
+ const cleaned = value.replaceAll("\\", "/");
664
+ return cleaned.slice(cleaned.lastIndexOf("/") + 1);
665
+ }
666
+
667
+ //#endregion
668
+ //#region src/path.ts
669
+ const WIN32_DRIVE = /^[A-Za-z]:[\\/]/;
670
+ const WIN32_UNC = /^\\\\[^\\/]+[\\/][^\\/]+/;
671
+ /** Detect the dialect of an absolute path from its own shape. */
672
+ function detectFlavor(path) {
673
+ return WIN32_DRIVE.test(path) || WIN32_UNC.test(path) ? "win32" : "posix";
674
+ }
675
+ /** Whether the path is absolute in either dialect. */
676
+ function isAbsolutePath(path) {
677
+ return path.startsWith("/") || WIN32_DRIVE.test(path) || WIN32_UNC.test(path);
678
+ }
679
+ /**
680
+ * Collapse separators and resolve `.` / `..` lexically, without touching the
681
+ * filesystem. A `..` that would escape the root is dropped, matching how both
682
+ * bwrap and Seatbelt treat an over-popped absolute path.
683
+ */
684
+ function normalizePath(path) {
685
+ if (path === "") return path;
686
+ const flavor = detectFlavor(path);
687
+ const unified = flavor === "win32" ? path.replaceAll("\\", "/") : path;
688
+ const prefix = rootPrefix(unified, flavor);
689
+ const body = unified.slice(prefix.length);
690
+ const resolved = [];
691
+ for (const segment of body.split("/")) {
692
+ if (segment === "" || segment === ".") continue;
693
+ if (segment === "..") {
694
+ if (resolved.length > 0) resolved.pop();
695
+ else if (prefix === "") resolved.push("..");
696
+ continue;
697
+ }
698
+ resolved.push(segment);
699
+ }
700
+ const joined = resolved.join("/");
701
+ if (prefix === "") return joined === "" ? "." : joined;
702
+ return joined === "" ? prefix : `${prefix}${joined}`;
703
+ }
704
+ /** The absolute-root prefix of a path (`/`, `C:/`, `//server/share/`), or `''`. */
705
+ function rootPrefix(unified, flavor) {
706
+ if (flavor === "win32") {
707
+ if (WIN32_DRIVE.test(unified)) return `${unified.slice(0, 2).toUpperCase()}/`;
708
+ const unc = /^\/\/[^/]+\/[^/]+/.exec(unified);
709
+ if (unc?.[0] !== void 0) return `${unc[0]}/`;
710
+ }
711
+ return unified.startsWith("/") ? "/" : "";
712
+ }
713
+ /** Normalized segments below the root prefix; the root itself has none. */
714
+ function pathSegments(path) {
715
+ const normalized = normalizePath(path);
716
+ const prefix = rootPrefix(normalized, detectFlavor(normalized));
717
+ const body = normalized.slice(prefix.length);
718
+ return body === "" ? [] : body.split("/");
719
+ }
720
+ /**
721
+ * Specificity rank used to order overlapping policy entries. A deeper path is
722
+ * more specific, so it is applied later and wins over a broader ancestor.
723
+ */
724
+ function pathDepth(path) {
725
+ return pathSegments(path).length;
726
+ }
727
+ /** Case-fold a normalized path for comparison under its own dialect. */
728
+ function foldCase(path) {
729
+ return detectFlavor(path) === "win32" ? path.toLowerCase() : path;
730
+ }
731
+ /** Whether two paths identify the same location lexically. */
732
+ function samePath(left, right) {
733
+ return foldCase(normalizePath(left)) === foldCase(normalizePath(right));
734
+ }
735
+ /**
736
+ * Whether `candidate` is `root` itself or lies beneath it. Comparison is
737
+ * segment-wise, so `/repo-secrets` is never treated as inside `/repo`.
738
+ */
739
+ function containsPath(root, candidate) {
740
+ const base = foldCase(normalizePath(root));
741
+ const target = foldCase(normalizePath(candidate));
742
+ if (base === target) return true;
743
+ const prefix = base.endsWith("/") ? base : `${base}/`;
744
+ return target.startsWith(prefix);
745
+ }
746
+ /** Append relative segments to an absolute base, normalizing the result. */
747
+ function joinPath(base, ...parts) {
748
+ const separator = base.endsWith("/") ? "" : "/";
749
+ return normalizePath(parts.length === 0 ? base : `${base}${separator}${parts.join("/")}`);
750
+ }
751
+ /** The parent of a normalized path, or `undefined` at a filesystem root. */
752
+ function parentPath(path) {
753
+ const normalized = normalizePath(path);
754
+ const prefix = rootPrefix(normalized, detectFlavor(normalized));
755
+ if (normalized === prefix) return void 0;
756
+ const cut = normalized.lastIndexOf("/");
757
+ if (cut < 0) return void 0;
758
+ const parent = normalized.slice(0, cut);
759
+ return parent.length < prefix.length ? prefix : parent === "" ? prefix : parent;
760
+ }
761
+ /** Every ancestor of `path` from the filesystem root down to `path` itself. */
762
+ function ancestorPaths(path) {
763
+ const chain = [];
764
+ let current = normalizePath(path);
765
+ while (current !== void 0) {
766
+ chain.unshift(current);
767
+ current = parentPath(current);
768
+ }
769
+ return chain;
770
+ }
771
+ /** Drop paths already covered by a broader entry in the same list. */
772
+ function dedupeRoots(roots) {
773
+ const normalized = [...new Set(roots.map((root) => normalizePath(root)))];
774
+ normalized.sort((left, right) => pathDepth(left) - pathDepth(right) || left.localeCompare(right));
775
+ const kept = [];
776
+ for (const root of normalized) if (!kept.some((existing) => containsPath(existing, root))) kept.push(root);
777
+ return Object.freeze(kept);
778
+ }
779
+
780
+ //#endregion
781
+ //#region src/entries.ts
782
+ /**
783
+ * Nested filesystem carve-outs.
784
+ *
785
+ * A single writable root is not enough: an agent that may write in a repository
786
+ * must still be kept out of `.git`, and an operator must be able to reopen one
787
+ * directory beneath a denied parent. Entries express that as an overlapping
788
+ * list resolved by path specificity — the deepest matching entry wins, so
789
+ * `/repo = write`, `/repo/a = deny`, `/repo/a/b = write` behaves as written.
790
+ */
791
+ /**
792
+ * Order entries from broadest to narrowest so a consumer can apply them in
793
+ * sequence and let the most specific one win. Equal-depth entries keep a stable
794
+ * lexical order so a policy always produces the same backend profile.
795
+ */
796
+ function orderEntries(entries) {
797
+ return Object.freeze([...entries].map((entry) => Object.freeze({
798
+ path: normalizePath(entry.path),
799
+ access: entry.access
800
+ })).sort((left, right) => pathDepth(left.path) - pathDepth(right.path) || left.path.localeCompare(right.path)));
801
+ }
802
+ /**
803
+ * Resolve the effective access for one target against an ordered entry list.
804
+ * @param target - absolute path being evaluated.
805
+ * @param entries - carve-outs, in any order.
806
+ * @param fallback - access to use when no entry covers the target.
807
+ */
808
+ function accessFor(target, entries, fallback) {
809
+ let effective = fallback;
810
+ for (const entry of orderEntries(entries)) if (containsPath(entry.path, target)) effective = entry.access;
811
+ return effective;
812
+ }
813
+ /**
814
+ * Entries that carve a narrower rule *inside* `root`. A backend that grants
815
+ * `root` wholesale must re-apply these afterwards or the grant is too wide.
816
+ */
817
+ function entriesWithin(root, entries) {
818
+ return Object.freeze(orderEntries(entries).filter((entry) => containsPath(root, entry.path) && !containsPath(entry.path, root)));
819
+ }
820
+
821
+ //#endregion
822
+ //#region src/roots.ts
823
+ /**
824
+ * Directory names never writable inside a granted root. Writing `.git` lets a
825
+ * command install a hook that runs arbitrary code on the next git invocation,
826
+ * which defeats the point of confining the command; the credential files are
827
+ * there so a confined command cannot rewrite the caller's own authentication.
828
+ * They stay readable — this is a write boundary, not a read boundary.
829
+ */
830
+ const PROTECTED_SUBPATHS = Object.freeze([
831
+ ".git",
832
+ ".hg",
833
+ ".svn",
834
+ ".ssh",
835
+ ".aws",
836
+ ".npmrc",
837
+ ".netrc"
838
+ ]);
839
+ /**
840
+ * The baseline every policy starts from: the host is readable and nothing is
841
+ * writable. Layers only ever move a subtree away from this.
842
+ */
843
+ const BASELINE_ACCESS = "read";
844
+ const ORIGIN_RANK = Object.freeze({
845
+ mode: 0,
846
+ protected: 1,
847
+ entry: 2,
848
+ restriction: 3,
849
+ approval: 4
850
+ });
851
+ const ACCESS_RANK = Object.freeze({
852
+ deny: 0,
853
+ read: 1,
854
+ write: 2
855
+ });
856
+ function narrower(left, right) {
857
+ return ACCESS_RANK[left] <= ACCESS_RANK[right] ? left : right;
858
+ }
859
+ function collapseLayers(proposed, baseline) {
860
+ const sorted = [...proposed].sort((left, right) => pathDepth(left.path) - pathDepth(right.path) || ORIGIN_RANK[left.origin] - ORIGIN_RANK[right.origin] || left.path.localeCompare(right.path));
861
+ const lastAtPath = /* @__PURE__ */ new Map();
862
+ sorted.forEach((layer, index) => lastAtPath.set(layer.path, index));
863
+ const kept = [];
864
+ sorted.forEach((layer, index) => {
865
+ if (lastAtPath.get(layer.path) === index && accessInLayers(layer.path, kept, baseline) !== layer.access) kept.push(Object.freeze(layer));
866
+ });
867
+ return kept;
868
+ }
869
+ /**
870
+ * Resolve a policy into the ordered layers that express it.
871
+ *
872
+ * Layers are sorted broadest to narrowest, and an explicit entry wins a tie at
873
+ * equal depth so a deployment can deliberately reopen a protected subpath. A
874
+ * layer that would not change the access already in force is dropped, so the
875
+ * result carries no mount or profile rule that does nothing.
876
+ */
877
+ function grantLayers(policy, options = {}) {
878
+ const proposed = [];
879
+ if (policy.mode === "workspace-write") for (const root of [policy.workspaceRoot, ...options.tempRoots ?? []]) proposed.push({
880
+ path: normalizePath(root),
881
+ access: "write",
882
+ origin: "mode"
883
+ });
884
+ for (const denied of options.deniedPaths ?? []) proposed.push({
885
+ path: normalizePath(denied),
886
+ access: "deny",
887
+ origin: "protected"
888
+ });
889
+ if (options.protectSubpaths !== false) for (const layer of proposed.filter((candidate) => candidate.access === "write")) for (const name of PROTECTED_SUBPATHS) proposed.push({
890
+ path: joinPath(layer.path, name),
891
+ access: "read",
892
+ origin: "protected"
893
+ });
894
+ for (const entry of orderEntries(policy.entries ?? [])) proposed.push({
895
+ path: entry.path,
896
+ access: entry.access,
897
+ origin: "entry"
898
+ });
899
+ const baseline = policy.baseline ?? "read";
900
+ let kept = collapseLayers(proposed, baseline);
901
+ const restrictions = orderEntries(policy.restrictions ?? []);
902
+ if (restrictions.length > 0) {
903
+ const boundaries = orderEntries([...kept.map((layer) => ({
904
+ path: layer.path,
905
+ access: layer.access
906
+ })), ...restrictions]).map((entry) => entry.path);
907
+ const distinctBoundaries = [...new Set(boundaries)];
908
+ const intersected = [];
909
+ for (const path of distinctBoundaries) {
910
+ const access = narrower(accessInLayers(path, kept, baseline), accessFor(path, restrictions, "write"));
911
+ if (accessInLayers(path, intersected, baseline) !== access) intersected.push(Object.freeze({
912
+ path,
913
+ access,
914
+ origin: "restriction"
915
+ }));
916
+ }
917
+ kept = intersected;
918
+ }
919
+ kept = collapseLayers([...kept, ...orderEntries(policy.approvedEntries ?? []).map((entry) => ({
920
+ ...entry,
921
+ origin: "approval"
922
+ }))], baseline);
923
+ return Object.freeze(kept);
924
+ }
925
+ /**
926
+ * The access in force at one path, given layers already applied in order.
927
+ * The last layer whose subtree contains the path wins, which is what makes a
928
+ * narrower grant reopen a denied parent.
929
+ */
930
+ function accessInLayers(target, layers, baseline = BASELINE_ACCESS) {
931
+ let effective = baseline;
932
+ for (const layer of layers) if (containsPath(layer.path, target)) effective = layer.access;
933
+ return effective;
934
+ }
935
+ /**
936
+ * The writable subtrees and their re-denials, flattened from {@link grantLayers}
937
+ * for callers that only need the two lists. Order is preserved; a narrower grant
938
+ * beneath a denial appears in `roots` after the denial it reopens.
939
+ */
940
+ function writableRoots(policy, options = {}) {
941
+ const layers = grantLayers(policy, options);
942
+ return Object.freeze({
943
+ roots: Object.freeze(layers.filter((layer) => layer.access === "write").map((layer) => layer.path)),
944
+ denied: Object.freeze(layers.filter((layer) => layer.access !== "write").map((layer) => layer.path))
945
+ });
946
+ }
947
+ /** Subtrees whose contents must not be readable, for backends that can mask. */
948
+ function unreadablePaths(policy, options = {}) {
949
+ return Object.freeze(grantLayers(policy, options).filter((layer) => layer.access === "deny").map((layer) => layer.path));
950
+ }
951
+
952
+ //#endregion
953
+ //#region src/fence.ts
954
+ /**
955
+ * The in-process path fence.
956
+ *
957
+ * A process sandbox only governs what a *child process* does. Tools that read,
958
+ * write, or edit files inside the agent host bypass it entirely, so those calls
959
+ * are fenced here against the same {@link grantLayers} the kernel profiles are
960
+ * built from. The logic is pure; the filesystem facts it needs arrive through an
961
+ * injected {@link PathResolver}.
962
+ */
963
+ /**
964
+ * Build the fence for one policy.
965
+ * @param policy - the same policy the process backends receive.
966
+ * @param resolver - filesystem facts used to defeat symlinked paths.
967
+ * @param options - platform temp roots and protected-subpath behaviour.
968
+ */
969
+ function createFsFence(policy, resolver, options = {}) {
970
+ const layers = grantLayers(policy, options);
971
+ /**
972
+ * Layer paths are canonicalized too, not just the target.
973
+ *
974
+ * A workspace root is routinely reached through a symlink — `/tmp` IS
975
+ * `/private/tmp` on macOS, and `/home` is often a link. Canonicalizing only
976
+ * the target would then compare a resolved path against an unresolved layer
977
+ * and refuse writes inside the very workspace that was granted. Resolved once
978
+ * and reused, because a fence is built per call.
979
+ */
980
+ let canonical;
981
+ function layersOnce() {
982
+ canonical ??= Promise.all(layers.map(async (layer) => Object.freeze({
983
+ ...layer,
984
+ path: await canonicalize(layer.path, resolver)
985
+ })));
986
+ return canonical;
987
+ }
988
+ async function permits(path, want) {
989
+ const [target, resolved] = await Promise.all([canonicalize(path, resolver), layersOnce()]);
990
+ const access = accessInLayers(target, resolved, policy.baseline ?? "read");
991
+ if (want === "read") return access !== "deny";
992
+ if (access !== "write") return false;
993
+ return options.allowAliasedWrites === true || !await aliased(target);
994
+ }
995
+ /**
996
+ * Whether the target is reachable under a name this policy never saw.
997
+ *
998
+ * A hard link gives one inode two names. Judging the name inside the
999
+ * workspace says nothing about the other one, which may sit anywhere,
1000
+ * so a write through the inside name escapes a boundary made of paths. The
1001
+ * count is the only signal available without walking the whole filesystem;
1002
+ * when the host cannot report it, the check does not fire.
1003
+ */
1004
+ async function aliased(target) {
1005
+ if (resolver.hardLinkCount === void 0) return false;
1006
+ try {
1007
+ return await resolver.hardLinkCount(target) > 1;
1008
+ } catch {
1009
+ return false;
1010
+ }
1011
+ }
1012
+ return Object.freeze({
1013
+ writableRoots: Object.freeze(layers.filter((layer) => layer.access === "write").map((layer) => layer.path)),
1014
+ isWritable: (path) => permits(path, "write"),
1015
+ isReadable: (path) => permits(path, "read"),
1016
+ async assertWritable(path) {
1017
+ if (await permits(path, "write")) return;
1018
+ const writable = layers.filter((layer) => layer.access === "write").map((layer) => layer.path);
1019
+ throw new SandboxDeniedError(normalizePath(path), policy.mode, writable);
1020
+ },
1021
+ isAliased: (path) => canonicalize(path, resolver).then(aliased)
1022
+ });
1023
+ }
1024
+ /** Bound on link chasing, so a self-referential link cannot loop forever. */
1025
+ const SYMLINK_DEPTH_LIMIT = 32;
1026
+ /**
1027
+ * Resolve a path through its deepest existing ancestor.
1028
+ *
1029
+ * A path is checked before it exists (a file about to be created) and may pass
1030
+ * through a symlink that points outside the workspace. Canonicalizing the
1031
+ * deepest ancestor that does exist and re-appending the missing tail closes
1032
+ * both cases: a symlinked parent resolves to its real location, and a target
1033
+ * that does not exist yet is still judged where it would actually be created.
1034
+ */
1035
+ async function canonicalize(path, resolver, depth = 0) {
1036
+ const normalized = normalizePath(path);
1037
+ if (depth >= SYMLINK_DEPTH_LIMIT) return normalized;
1038
+ const chain = ancestorPaths(normalized);
1039
+ for (let index = chain.length - 1; index >= 0; index--) {
1040
+ const candidate = chain[index];
1041
+ if (candidate === void 0) continue;
1042
+ if (!await resolver.exists(candidate)) continue;
1043
+ const link = await resolver.readLink?.(candidate);
1044
+ if (link !== void 0) {
1045
+ const parent = parentPath(candidate) ?? candidate;
1046
+ const target = isAbsolutePath(link) ? link : `${parent}/${link}`;
1047
+ const tail = normalized.slice(candidate.length).replace(/^[\\/]+/, "");
1048
+ const resolved = await canonicalize(target, resolver, depth + 1);
1049
+ return tail === "" ? resolved : normalizePath(`${resolved}/${tail}`);
1050
+ }
1051
+ const real = await resolver.realpath(candidate);
1052
+ const tail = normalized.slice(candidate.length).replace(/^[\\/]+/, "");
1053
+ return tail === "" ? normalizePath(real) : normalizePath(`${real}/${tail}`);
1054
+ }
1055
+ return normalized;
1056
+ }
1057
+
1058
+ //#endregion
1059
+ //#region src/mode.ts
1060
+ /** Every mode, in widening order of authority. */
1061
+ const SANDBOX_MODES = Object.freeze([
1062
+ "read-only",
1063
+ "workspace-write",
1064
+ "danger-full-access"
1065
+ ]);
1066
+ /** Whether an arbitrary value is one of the known modes. */
1067
+ function isSandboxMode(value) {
1068
+ return typeof value === "string" && SANDBOX_MODES.includes(value);
1069
+ }
1070
+ /** Whether a mode still asks a provider to confine the execution. */
1071
+ function isConfinedMode(mode) {
1072
+ return mode !== "danger-full-access";
1073
+ }
1074
+ /** Rank used when a narrower mode must not be widened by a weaker source. */
1075
+ function modeAuthority(mode) {
1076
+ return SANDBOX_MODES.indexOf(mode);
1077
+ }
1078
+
1079
+ //#endregion
1080
+ //#region src/network.ts
1081
+ /**
1082
+ * Network reachability — a seam of its own, deliberately not a file-effect mode.
1083
+ *
1084
+ * `SandboxMode` governs file effects and says so. Folding network reachability
1085
+ * into it would make the mode claim something it does not decide, and a mode
1086
+ * that lies about its scope is worse than one with a narrow one. So network is
1087
+ * a second, independent axis carried on the same policy: a call can be
1088
+ * `read-only` on the filesystem and still reach the internet, or writable in
1089
+ * its workspace and reach nothing.
1090
+ *
1091
+ * The distinction matters because the two are enforced by different mechanisms
1092
+ * — mount bindings versus a network namespace — and a host can provide one
1093
+ * without the other.
1094
+ */
1095
+ /** Every network mode, in widening order of reach. */
1096
+ const NETWORK_MODES = Object.freeze([
1097
+ "deny",
1098
+ "loopback",
1099
+ "allow-all"
1100
+ ]);
1101
+ /** Whether an arbitrary value is one of the known network modes. */
1102
+ function isNetworkMode(value) {
1103
+ return typeof value === "string" && NETWORK_MODES.includes(value);
1104
+ }
1105
+ /** Rank used so an untrusted request can narrow reach but never widen it. */
1106
+ function networkAuthority(mode) {
1107
+ return NETWORK_MODES.indexOf(mode);
1108
+ }
1109
+ /**
1110
+ * Narrow a network mode toward a stricter one, refusing to widen.
1111
+ * @param ceiling - the reach already permitted.
1112
+ * @param requested - the reach being asked for.
1113
+ * @returns the stricter of the two.
1114
+ */
1115
+ function narrowNetwork(ceiling, requested) {
1116
+ if (requested === void 0) return ceiling;
1117
+ if (!isNetworkMode(requested)) throw new SandboxPolicyError(`Unknown network mode '${String(requested)}'`);
1118
+ return networkAuthority(requested) < networkAuthority(ceiling) ? requested : ceiling;
1119
+ }
1120
+
1121
+ //#endregion
1122
+ //#region src/classify.ts
1123
+ /**
1124
+ * Exit codes that are ordinary shell failures and never sandbox evidence:
1125
+ * 2 misuse of a builtin, 126 not executable, 127 command not found.
1126
+ */
1127
+ const SHELL_FAILURE_EXIT_CODES = Object.freeze([
1128
+ 2,
1129
+ 126,
1130
+ 127
1131
+ ]);
1132
+ /** POSIX `SIGSYS`, raised when a seccomp filter kills the process. */
1133
+ const SIGSYS_EXIT_CODE = 159;
1134
+ /**
1135
+ * Classify one confined command's outcome.
1136
+ *
1137
+ * Runner failure is tested first, then a seccomp kill (deterministic, no text
1138
+ * matching needed), then the backend's own denial dialect. A cross-backend
1139
+ * union of denial strings is deliberately not used: it would claim denials a
1140
+ * given backend never produces.
1141
+ */
1142
+ function classifyOutcome(outcome, input) {
1143
+ if (outcome.exitCode === 0) return Object.freeze({ kind: "success" });
1144
+ const lines = outcome.stderr.split(/\r?\n/);
1145
+ if (outcome.childStarted !== true) for (const rule of input.runnerFailureRules) {
1146
+ const evidence = matchRunnerFailure(outcome.exitCode, lines, rule);
1147
+ if (evidence !== void 0) return Object.freeze({
1148
+ kind: "runner-failure",
1149
+ evidence
1150
+ });
1151
+ }
1152
+ if (outcome.signal === "SIGSYS" || outcome.exitCode === SIGSYS_EXIT_CODE) return Object.freeze({
1153
+ kind: "denied",
1154
+ evidence: "process killed by SIGSYS (seccomp)"
1155
+ });
1156
+ if (SHELL_FAILURE_EXIT_CODES.includes(outcome.exitCode)) return Object.freeze({ kind: "command-failure" });
1157
+ const denial = matchSignature(lines, input.denialSignatures);
1158
+ return denial === void 0 ? Object.freeze({ kind: "command-failure" }) : Object.freeze({
1159
+ kind: "denied",
1160
+ evidence: denial
1161
+ });
1162
+ }
1163
+ /** Apply one runner-failure rule to a finished process. */
1164
+ function matchRunnerFailure(exitCode, lines, rule) {
1165
+ if (rule.allowedExitCodes !== void 0 && !rule.allowedExitCodes.includes(exitCode)) return void 0;
1166
+ const informational = new Set((rule.informationalLines ?? []).map((line) => line.trim().toLowerCase()));
1167
+ const excluded = (rule.excludedSignatures ?? []).map((signature) => signature.toLowerCase());
1168
+ return matchSignature(lines.filter((line) => {
1169
+ const normalized = line.trim().toLowerCase();
1170
+ if (informational.has(normalized)) return false;
1171
+ return !excluded.some((signature) => signature !== "" && normalized.includes(signature));
1172
+ }), rule.fatalSignatures);
1173
+ }
1174
+ /** The first line containing any signature, matched case-insensitively. */
1175
+ function matchSignature(lines, signatures) {
1176
+ if (signatures.length === 0) return void 0;
1177
+ for (const line of lines) {
1178
+ const haystack = line.toLowerCase();
1179
+ if (signatures.some((signature) => signature !== "" && haystack.includes(signature.toLowerCase()))) return line;
1180
+ }
1181
+ }
1182
+ /**
1183
+ * Append a short, factual note to stderr explaining a sandbox outcome, so a
1184
+ * reader never has to infer confinement from a bare error string.
1185
+ */
1186
+ function annotateStderr(stderr, classification, mode) {
1187
+ if (classification.kind === "success" || classification.kind === "command-failure") return stderr;
1188
+ const note = classification.kind === "runner-failure" ? `[sandbox] The sandbox runner failed before the command ran; the command did not execute. ${classification.evidence ?? ""}`.trim() : `[sandbox] Blocked by sandbox mode '${mode}'. ${classification.evidence ?? ""}`.trim();
1189
+ return stderr.endsWith("\n") || stderr === "" ? `${stderr}${note}\n` : `${stderr}\n${note}\n`;
1190
+ }
1191
+
1192
+ //#endregion
1193
+ //#region src/policy.ts
1194
+ /**
1195
+ * Resolve the complete policy for one capability call.
1196
+ *
1197
+ * Authority only ever decreases across untrusted inputs: the deployment default
1198
+ * and the session's mode set a ceiling, a request may narrow beneath it, and a
1199
+ * minted approval is the single path that raises it. A session cwd is its
1200
+ * `workspace-write` boundary; the configured root is the fallback for agentless
1201
+ * calls and sessions without a cwd.
1202
+ * @param request - the calling session's untrusted ask, plus any approval.
1203
+ * @param defaults - deployment mode, workspace root, and standing carve-outs.
1204
+ * @throws SandboxPolicyError when a request tries to widen without an approval.
1205
+ */
1206
+ function resolveSandboxPolicy(request, defaults) {
1207
+ const approval = request.approval === void 0 ? void 0 : requireSandboxApproval(request.approval);
1208
+ const ceiling = request.sessionMode ?? defaults.mode;
1209
+ if (!isSandboxMode(ceiling)) throw new SandboxPolicyError(`Unknown sandbox mode '${String(ceiling)}'`);
1210
+ const requested = request.mode;
1211
+ if (requested !== void 0 && !isSandboxMode(requested)) throw new SandboxPolicyError(`Unknown sandbox mode '${String(requested)}'`);
1212
+ const narrowed = requested !== void 0 && modeAuthority(requested) < modeAuthority(ceiling) ? requested : ceiling;
1213
+ const mode = approval?.mode ?? narrowed;
1214
+ const workspaceRoot = normalizePath(request.cwd ?? defaults.workspaceRoot);
1215
+ if (!isAbsolutePath(workspaceRoot)) throw new SandboxPolicyError(`Sandbox workspace root must be absolute, received '${workspaceRoot}'`);
1216
+ for (const entry of request.entries ?? []) if (entry.access === "write") throw new SandboxPolicyError(`A requested entry may not grant write access to '${entry.path}'; widening a policy requires an approval minted by approveSandboxEscalation()`);
1217
+ const entries = orderEntries(defaults.entries ?? []);
1218
+ const restrictions = orderEntries(request.entries ?? []);
1219
+ const approvedEntries = orderEntries(approval?.entries ?? []);
1220
+ const network = approval?.network ?? narrowNetwork(defaults.network ?? "allow-all", request.network);
1221
+ return Object.freeze({
1222
+ mode,
1223
+ workspaceRoot,
1224
+ network,
1225
+ ...defaults.baseline === void 0 ? {} : { baseline: defaults.baseline },
1226
+ ...entries.length === 0 ? {} : { entries },
1227
+ ...restrictions.length === 0 ? {} : { restrictions },
1228
+ ...approvedEntries.length === 0 ? {} : { approvedEntries },
1229
+ ...request.sessionId === void 0 ? {} : { sessionId: request.sessionId }
1230
+ });
1231
+ }
1232
+ /**
1233
+ * Narrow a resolved policy to the confining shape a provider accepts.
1234
+ * @returns the confining policy, or `undefined` under `danger-full-access`,
1235
+ * whose consumer spawns its original argv and never calls the provider.
1236
+ */
1237
+ function confiningPolicy(policy) {
1238
+ return isConfinedMode(policy.mode) ? policy : void 0;
1239
+ }
1240
+ /**
1241
+ * Narrow a policy toward a stricter mode without widening it. Used where a
1242
+ * consumer may tighten a caller's policy but must never loosen it.
1243
+ */
1244
+ function narrowPolicy(policy, mode) {
1245
+ return modeAuthority(mode) < modeAuthority(policy.mode) ? Object.freeze({
1246
+ ...policy,
1247
+ mode
1248
+ }) : policy;
1249
+ }
1250
+
1251
+ //#endregion
1252
+ //#region src/resources.ts
1253
+ /** Whether any limit is set at all, so a caller can skip supervision entirely. */
1254
+ function hasResourceLimits(limits) {
1255
+ return limits.wallClockMs !== void 0 || limits.memoryBytes !== void 0 || limits.processes !== void 0 || limits.cpuMs !== void 0;
1256
+ }
1257
+ /**
1258
+ * The first limit the usage exceeds, or `undefined` while it is within them.
1259
+ * Checked in the order a runaway usually announces itself.
1260
+ */
1261
+ function breachedLimit(usage, limits) {
1262
+ if (limits.wallClockMs !== void 0 && usage.wallClockMs > limits.wallClockMs) return "wall-clock";
1263
+ if (limits.processes !== void 0 && usage.peakProcesses > limits.processes) return "processes";
1264
+ if (limits.memoryBytes !== void 0 && usage.peakMemoryBytes > limits.memoryBytes) return "memory";
1265
+ if (limits.cpuMs !== void 0 && usage.cpuMs > limits.cpuMs) return "cpu";
1266
+ }
1267
+
1268
+ //#endregion
1269
+ //#region src/violation.ts
1270
+ /** Upper bound on retained evidence, so a violation never carries a log dump. */
1271
+ const SNIPPET_MAX_LENGTH = 512;
1272
+ const REASON_SIGNATURES = Object.freeze([
1273
+ ["operation-not-permitted", "operation not permitted"],
1274
+ ["permission-denied", "permission denied"],
1275
+ ["read-only-filesystem", "read-only file system"],
1276
+ ["read-only-filesystem", "erofs"],
1277
+ ["policy-denied", "seccomp"],
1278
+ ["policy-denied", "landlock"]
1279
+ ]);
1280
+ /**
1281
+ * Build a violation record from a classified outcome.
1282
+ * @returns the record, or `undefined` when the outcome was not a violation.
1283
+ */
1284
+ function sandboxViolation(classification, backend, mode) {
1285
+ if (classification.kind !== "denied" && classification.kind !== "runner-failure") return void 0;
1286
+ const evidence = classification.evidence ?? "";
1287
+ const reason = classification.kind === "runner-failure" ? "runner-failure" : reasonFor(evidence);
1288
+ const path = pathIn(evidence);
1289
+ return Object.freeze({
1290
+ backend,
1291
+ reason,
1292
+ mode,
1293
+ ...path === void 0 ? {} : { path },
1294
+ snippet: evidence.slice(0, SNIPPET_MAX_LENGTH)
1295
+ });
1296
+ }
1297
+ /** Map one evidence line to a normalized reason. */
1298
+ function reasonFor(evidence) {
1299
+ const haystack = evidence.toLowerCase();
1300
+ for (const [reason, signature] of REASON_SIGNATURES) if (haystack.includes(signature)) return reason;
1301
+ return "policy-denied";
1302
+ }
1303
+ /** Extract the first absolute path a denial message mentions, if any. */
1304
+ function pathIn(evidence) {
1305
+ return /(\/[^\s:'"]+|[A-Za-z]:[\\/][^\s:'"]*)/.exec(evidence)?.[0];
1306
+ }
1307
+
1308
+ //#endregion
1309
+ export { BASELINE_ACCESS, DEFAULT_EXEC_OUTCOMES, NETWORK_MODES, PROTECTED_SUBPATHS, SANDBOX_MODES, SandboxDeniedError, SandboxPolicyError, SandboxUnavailableError, accessFor, accessInLayers, ancestorPaths, annotateStderr, approveSandboxEscalation, breachedLimit, classifyExec, classifyOutcome, confiningPolicy, containsPath, createFsFence, dedupeRoots, detectFlavor, entriesWithin, grantLayers, hasResourceLimits, isAbsolutePath, isConfinedMode, isNetworkMode, isSandboxApproval, isSandboxApprovalSpent, isSandboxMode, joinPath, modeAuthority, narrowNetwork, narrowPolicy, networkAuthority, normalizePath, orderEntries, parentPath, pathDepth, pathSegments, requireSandboxApproval, resolveSandboxPolicy, samePath, sandboxViolation, splitCommands, tokenizeScript, unreadablePaths, writableRoots };
1310
+ //# sourceMappingURL=index.js.map