@openparachute/agent 0.2.2 → 0.2.3-rc.10

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.
Files changed (72) hide show
  1. package/.parachute/module.json +3 -3
  2. package/package.json +4 -1
  3. package/src/agent-defs.ts +9 -0
  4. package/src/auth.ts +182 -14
  5. package/src/backends/registry.ts +65 -27
  6. package/src/daemon.ts +311 -12
  7. package/src/def-vault-triggers.ts +317 -0
  8. package/src/preflight.ts +139 -0
  9. package/src/spawn-agent.ts +16 -0
  10. package/src/step-up.ts +316 -0
  11. package/src/terminal-ui.ts +73 -0
  12. package/src/transports/http-ui.ts +10 -8
  13. package/src/transports/vault.ts +40 -22
  14. package/src/ui-kit.ts +6 -3
  15. package/src/ui-ticket.ts +121 -0
  16. package/web/ui/dist/assets/index-Dhr5Kl_d.css +1 -0
  17. package/web/ui/dist/assets/index-Di5MmFZR.js +60 -0
  18. package/web/ui/dist/index.html +2 -2
  19. package/src/_parked/interactive-spawn.test.ts +0 -324
  20. package/src/_parked/interactive-spawn.ts +0 -701
  21. package/src/agent-defs.test.ts +0 -1504
  22. package/src/agent-mcp-config.test.ts +0 -115
  23. package/src/agents.test.ts +0 -360
  24. package/src/auth.test.ts +0 -46
  25. package/src/backends/attached-queue.test.ts +0 -376
  26. package/src/backends/programmatic.test.ts +0 -1715
  27. package/src/backends/registry.test.ts +0 -1494
  28. package/src/backends/stream-json.test.ts +0 -570
  29. package/src/channel-backend-wiring.test.ts +0 -237
  30. package/src/credentials.test.ts +0 -274
  31. package/src/cron.test.ts +0 -342
  32. package/src/daemon-agent-def-api.test.ts +0 -166
  33. package/src/daemon-agent-defs-api.test.ts +0 -953
  34. package/src/daemon-agent-env-api.test.ts +0 -338
  35. package/src/daemon-attached-queue-store.test.ts +0 -65
  36. package/src/daemon-config-api.test.ts +0 -962
  37. package/src/daemon-jobs-api.test.ts +0 -271
  38. package/src/daemon-vault-chat.test.ts +0 -250
  39. package/src/daemon.test.ts +0 -746
  40. package/src/def-vaults.test.ts +0 -136
  41. package/src/delivery-state.test.ts +0 -110
  42. package/src/effective-env.test.ts +0 -114
  43. package/src/grants.test.ts +0 -638
  44. package/src/hub-jwt.test.ts +0 -161
  45. package/src/jobs.test.ts +0 -245
  46. package/src/mcp-http.test.ts +0 -265
  47. package/src/mint-token.test.ts +0 -152
  48. package/src/module-manifest.test.ts +0 -158
  49. package/src/programmatic-wiring.test.ts +0 -838
  50. package/src/registry.test.ts +0 -227
  51. package/src/resolve-port.test.ts +0 -64
  52. package/src/routing.test.ts +0 -184
  53. package/src/runner.test.ts +0 -506
  54. package/src/sandbox/config.test.ts +0 -150
  55. package/src/sandbox/egress.test.ts +0 -113
  56. package/src/sandbox/live-seatbelt.test.ts +0 -277
  57. package/src/sandbox/mounts.test.ts +0 -154
  58. package/src/sandbox/sandbox.test.ts +0 -168
  59. package/src/services-manifest.test.ts +0 -106
  60. package/src/spa-serve.test.ts +0 -116
  61. package/src/spawn-agent-cli.test.ts +0 -172
  62. package/src/spawn-agent.test.ts +0 -1218
  63. package/src/spawn-deps.test.ts +0 -54
  64. package/src/terminal-assets.test.ts +0 -50
  65. package/src/terminal.test.ts +0 -530
  66. package/src/transports/http-ui.test.ts +0 -455
  67. package/src/transports/telegram.test.ts +0 -174
  68. package/src/transports/vault.test.ts +0 -2011
  69. package/src/ui-kit.test.ts +0 -178
  70. package/web/ui/dist/assets/index-C-iWdFFV.css +0 -1
  71. package/web/ui/dist/assets/index-VFETBk0a.js +0 -60
  72. package/web/ui/tsconfig.json +0 -21
@@ -1,113 +0,0 @@
1
- import { describe, test, expect } from "bun:test";
2
- import {
3
- ANTHROPIC_EGRESS_HOSTS,
4
- baseEgressAllowlist,
5
- composeEgressAllowlist,
6
- hostFromOrigin,
7
- type EgressBaseInput,
8
- } from "./egress.ts";
9
-
10
- const BASE: EgressBaseInput = { hubOrigin: "https://hub.example.com" };
11
-
12
- describe("hostFromOrigin", () => {
13
- test("reduces an origin to its hostname (strips scheme + port + path)", () => {
14
- expect(hostFromOrigin("https://hub.example.com:1939/admin")).toBe("hub.example.com");
15
- });
16
- test("passes a bare host through", () => {
17
- expect(hostFromOrigin("registry.npmjs.org")).toBe("registry.npmjs.org");
18
- });
19
- test("strips a :port from a bare host:port", () => {
20
- expect(hostFromOrigin("127.0.0.1:1939")).toBe("127.0.0.1");
21
- });
22
- test("preserves loopback (a co-located dev hub is loopback)", () => {
23
- expect(hostFromOrigin("http://127.0.0.1:1939")).toBe("127.0.0.1");
24
- });
25
- test("returns null for empty / nullish input", () => {
26
- expect(hostFromOrigin("")).toBeNull();
27
- expect(hostFromOrigin(undefined)).toBeNull();
28
- expect(hostFromOrigin(" ")).toBeNull();
29
- });
30
- });
31
-
32
- describe("baseEgressAllowlist — the non-removable base", () => {
33
- test("always includes the Anthropic hosts + the hub host", () => {
34
- const base = baseEgressAllowlist(BASE);
35
- for (const h of ANTHROPIC_EGRESS_HOSTS) expect(base).toContain(h);
36
- expect(base).toContain("hub.example.com");
37
- });
38
-
39
- test("includes a distinct vault host when given", () => {
40
- const base = baseEgressAllowlist({ ...BASE, vaultOrigin: "https://vault.example.com" });
41
- expect(base).toContain("vault.example.com");
42
- });
43
-
44
- test("dedupes a vault origin equal to the hub origin", () => {
45
- const base = baseEgressAllowlist({
46
- hubOrigin: "https://h.example.com",
47
- vaultOrigin: "https://h.example.com",
48
- });
49
- expect(base.filter((h) => h === "h.example.com")).toHaveLength(1);
50
- });
51
- });
52
-
53
- describe("composeEgressAllowlist — base floor is non-removable, spec is additive", () => {
54
- test("an empty spec egress still gets the full base (weaver-style arm)", () => {
55
- const allow = composeEgressAllowlist(BASE, []);
56
- for (const h of ANTHROPIC_EGRESS_HOSTS) expect(allow).toContain(h);
57
- expect(allow).toContain("hub.example.com");
58
- });
59
-
60
- test("an undefined spec egress still gets the full base", () => {
61
- const allow = composeEgressAllowlist(BASE, undefined);
62
- expect(allow).toContain("api.anthropic.com");
63
- expect(allow).toContain("hub.example.com");
64
- });
65
-
66
- test("spec hosts are ADDED on top of the base", () => {
67
- const allow = composeEgressAllowlist(BASE, ["registry.npmjs.org", "pypi.org"]);
68
- // base present...
69
- expect(allow).toContain("api.anthropic.com");
70
- expect(allow).toContain("hub.example.com");
71
- // ...plus the additions
72
- expect(allow).toContain("registry.npmjs.org");
73
- expect(allow).toContain("pypi.org");
74
- });
75
-
76
- test("SECURITY: a spec that lists ONLY a foreign host CANNOT drop the base — the base is still present", () => {
77
- // A spec authored to omit the base entirely (the malicious-omit case).
78
- const allow = composeEgressAllowlist(BASE, ["evil.example.com"]);
79
- // The base floor survives regardless of what the spec listed.
80
- expect(allow).toContain("api.anthropic.com");
81
- expect(allow).toContain("hub.example.com");
82
- // The spec's own host is added (additive), not a replacement.
83
- expect(allow).toContain("evil.example.com");
84
- });
85
-
86
- test("SECURITY: a spec cannot REPLACE the Anthropic host with a look-alike — both end up present, base is not dropped", () => {
87
- // A spec that tries to "override" the Anthropic host by re-declaring a near-miss.
88
- const allow = composeEgressAllowlist(BASE, ["api.anthropic.com.evil.example.com"]);
89
- // The real Anthropic apex is still on the list (the base recomputed from code).
90
- expect(allow).toContain("api.anthropic.com");
91
- // The look-alike is just an additional (separate) host, not a substitution.
92
- expect(allow).toContain("api.anthropic.com.evil.example.com");
93
- // And the look-alike did not evict the real host.
94
- expect(allow.indexOf("api.anthropic.com")).toBeGreaterThanOrEqual(0);
95
- });
96
-
97
- test("the base always sorts FIRST (recomputed from code, prepended)", () => {
98
- const allow = composeEgressAllowlist(BASE, ["z-late.example.com"]);
99
- expect(allow[0]).toBe("api.anthropic.com");
100
- expect(allow[allow.length - 1]).toBe("z-late.example.com");
101
- });
102
-
103
- test("spec origins are normalized to hosts (full URL and bare host land the same)", () => {
104
- const a = composeEgressAllowlist(BASE, ["https://registry.npmjs.org"]);
105
- const b = composeEgressAllowlist(BASE, ["registry.npmjs.org"]);
106
- expect(a).toEqual(b);
107
- });
108
-
109
- test("dedupes a spec host that duplicates a base host", () => {
110
- const allow = composeEgressAllowlist(BASE, ["hub.example.com"]);
111
- expect(allow.filter((h) => h === "hub.example.com")).toHaveLength(1);
112
- });
113
- });
@@ -1,277 +0,0 @@
1
- /**
2
- * LIVE sandbox-runtime assertions on this host (Seatbelt on macOS).
3
- *
4
- * These run a REAL sandboxed process and assert it is genuinely confined:
5
- * - egress to a non-allowlisted host is DENIED;
6
- * - egress to an allowlisted host is permitted to ATTEMPT (proxy admits it);
7
- * - a read OUTSIDE the declared binds is DENIED;
8
- * - a read INSIDE a bind is permitted.
9
- *
10
- * The sandbox-runtime singleton starts host proxies on `initialize` and tears
11
- * them down on `reset`; it is process-global, so these cases serialize (one
12
- * describe block, sequential `initialize`→wrap→reset per case). Sandboxed in a
13
- * fresh temp workspace — NEVER the operator's live ~/.parachute.
14
- *
15
- * Skipped automatically when the platform can't sandbox (`isSupportedPlatform()`
16
- * false) or its deps aren't satisfied — so CI on an unsupported runner stays
17
- * green while a capable host (this Mac) exercises the real boundary. The
18
- * config-shape tests (egress/mounts/config.test.ts) ALWAYS run regardless.
19
- */
20
- import { describe, test, expect, afterEach } from "bun:test";
21
- import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from "node:fs";
22
- import { join } from "node:path";
23
- import { tmpdir, homedir } from "node:os";
24
- import { SandboxManager } from "@anthropic-ai/sandbox-runtime";
25
- import { buildSandboxConfig } from "./config.ts";
26
- import type { AgentSpec, BaseBinds } from "./types.ts";
27
-
28
- const CAN_SANDBOX =
29
- SandboxManager.isSupportedPlatform() && SandboxManager.checkDependencies().errors.length === 0;
30
-
31
- // A positive control: prove the harness can actually run a sandboxed process at
32
- // all before asserting on denials (negative-scans-need-positive-controls).
33
- const d = CAN_SANDBOX ? describe : describe.skip;
34
-
35
- async function runSandboxed(
36
- cfg: ReturnType<typeof buildSandboxConfig>,
37
- command: string,
38
- ): Promise<{ code: number; stdout: string; stderr: string }> {
39
- await SandboxManager.initialize(cfg);
40
- try {
41
- const { argv, env } = await SandboxManager.wrapWithSandboxArgv(command);
42
- const proc = Bun.spawn(argv, {
43
- env: env as Record<string, string>,
44
- stdout: "pipe",
45
- stderr: "pipe",
46
- });
47
- const [stdout, stderr, code] = await Promise.all([
48
- new Response(proc.stdout as ReadableStream<Uint8Array>).text(),
49
- new Response(proc.stderr as ReadableStream<Uint8Array>).text(),
50
- proc.exited,
51
- ]);
52
- return { code, stdout, stderr };
53
- } finally {
54
- await SandboxManager.reset();
55
- }
56
- }
57
-
58
- let workspace: string;
59
-
60
- afterEach(() => {
61
- if (workspace) rmSync(workspace, { recursive: true, force: true });
62
- });
63
-
64
- d("LIVE Seatbelt — network egress", () => {
65
- test("positive control: the harness can run a trivial sandboxed command", async () => {
66
- workspace = mkdtempSync(join(tmpdir(), "sbx-live-pos-"));
67
- const spec: AgentSpec = { name: "pos", channels: ["c"], network: "restricted", egress: [] };
68
- const cfg = buildSandboxConfig({
69
- spec,
70
- baseBinds: { workspace, runtimeReadOnly: [] },
71
- egressBase: { hubOrigin: "http://127.0.0.1:1939" },
72
- platform: "darwin",
73
- });
74
- const r = await runSandboxed(cfg, "echo sandbox-alive");
75
- expect(r.code).toBe(0);
76
- expect(r.stdout).toContain("sandbox-alive");
77
- }, 60_000);
78
-
79
- test("DENIED: curl to a host NOT in the allowlist is blocked", async () => {
80
- workspace = mkdtempSync(join(tmpdir(), "sbx-live-deny-"));
81
- // Allowlist the base only (anthropic + the loopback hub). example.com is NOT on it.
82
- const spec: AgentSpec = { name: "deny", channels: ["c"], network: "restricted", egress: [] };
83
- const cfg = buildSandboxConfig({
84
- spec,
85
- baseBinds: { workspace, runtimeReadOnly: [] },
86
- egressBase: { hubOrigin: "http://127.0.0.1:1939" },
87
- platform: "darwin",
88
- });
89
- const r = await runSandboxed(
90
- cfg,
91
- "curl -sS -m 8 -o /dev/null -w '%{http_code}' https://example.com",
92
- );
93
- // The egress proxy refuses a non-allowlisted host: curl exits non-zero
94
- // (commonly 56 "CONNECT tunnel failed, response 403"). The key assertion is
95
- // that the request did NOT succeed with a 2xx.
96
- expect(r.code).not.toBe(0);
97
- expect(r.stdout).not.toMatch(/^2\d\d$/);
98
- }, 60_000);
99
-
100
- test("a spec that adds an egress host gets that host on the allowlist (additive)", async () => {
101
- workspace = mkdtempSync(join(tmpdir(), "sbx-live-add-"));
102
- const spec: AgentSpec = { name: "add", channels: ["c"], network: "restricted", egress: ["example.com"] };
103
- const cfg = buildSandboxConfig({
104
- spec,
105
- baseBinds: { workspace, runtimeReadOnly: [] },
106
- egressBase: { hubOrigin: "http://127.0.0.1:1939" },
107
- platform: "darwin",
108
- });
109
- // We assert the CONFIG carries the host (a live fetch to example.com would be
110
- // a network-flaky external dependency; the denial test above is the live
111
- // boundary proof). The base floor is also present.
112
- expect(cfg.network.allowedDomains).toContain("example.com");
113
- expect(cfg.network.allowedDomains).toContain("api.anthropic.com");
114
- }, 60_000);
115
- });
116
-
117
- d("LIVE Seatbelt — filesystem read confinement", () => {
118
- test("DENIED: reading a file OUTSIDE the declared binds fails", async () => {
119
- workspace = mkdtempSync(join(tmpdir(), "sbx-live-read-"));
120
- // A secret OUTSIDE the workspace, under a sibling temp dir we do NOT bind.
121
- const secretDir = mkdtempSync(join(tmpdir(), "sbx-live-secret-"));
122
- const secretFile = join(secretDir, "secret.txt");
123
- writeFileSync(secretFile, "TOPSECRET-do-not-read");
124
- try {
125
- const spec: AgentSpec = { name: "read", channels: ["c"], network: "restricted", egress: [] };
126
- const cfg = buildSandboxConfig({
127
- spec,
128
- baseBinds: { workspace, runtimeReadOnly: [] },
129
- egressBase: { hubOrigin: "http://127.0.0.1:1939" },
130
- platform: "darwin",
131
- });
132
- // Deny the temp root so the unbound secret dir is unreadable; re-allow only
133
- // the workspace. (buildSandboxConfig denies /Users; the macOS temp dir lives
134
- // under /var/folders, so we additionally deny the secret's parent to make the
135
- // confinement assertion robust regardless of where the OS placed the tmp dir.)
136
- cfg.filesystem.denyRead = [...cfg.filesystem.denyRead, secretDir];
137
- const r = await runSandboxed(cfg, `cat ${secretFile}`);
138
- expect(r.code).not.toBe(0);
139
- expect(r.stdout).not.toContain("TOPSECRET");
140
- } finally {
141
- rmSync(secretDir, { recursive: true, force: true });
142
- }
143
- }, 60_000);
144
-
145
- test("PRODUCTION PATH: the spec-derived /Users deny blocks a read of a real home-dir file (no manual patch)", async () => {
146
- // The §4.5 scoped-read property is "an arm cannot read the operator's home."
147
- // Prove it through the PRODUCTION config — secret under the REAL home dir,
148
- // workspace also under home (so the re-allow path is exercised against the
149
- // real `/Users` deny), and NO manual `denyRead` patch. This is the test that
150
- // proves the deployed `denyRead:["/Users"]` actually confines reads.
151
- const home = homedir();
152
- workspace = mkdtempSync(join(home, ".sbx-live-ws-"));
153
- const secretDir = mkdtempSync(join(home, ".sbx-live-secret-"));
154
- const secretFile = join(secretDir, "home-secret.txt");
155
- writeFileSync(secretFile, "HOME-TOPSECRET-do-not-read");
156
- try {
157
- const spec: AgentSpec = { name: "homeread", channels: ["c"], network: "restricted", egress: [] };
158
- // buildSandboxConfig with platform "darwin" → denyRead:["/Users"],
159
- // allowRead:[workspace]. We pass the config THROUGH unmodified.
160
- const cfg = buildSandboxConfig({
161
- spec,
162
- baseBinds: { workspace, runtimeReadOnly: [] },
163
- egressBase: { hubOrigin: "http://127.0.0.1:1939" },
164
- platform: "darwin",
165
- });
166
- expect(cfg.filesystem.denyRead).toEqual(["/Users"]);
167
- expect(cfg.filesystem.allowRead).toContain(workspace);
168
- // The secret sits under /Users/<me>/… and is NOT in any bind → blocked.
169
- const r = await runSandboxed(cfg, `cat ${secretFile}`);
170
- expect(r.code).not.toBe(0);
171
- expect(r.stdout).not.toContain("HOME-TOPSECRET");
172
-
173
- // Positive control on the SAME production config: a file inside the
174
- // workspace (also under /Users, but re-allowed by allowRead) IS readable —
175
- // proving the deny didn't just break all reads.
176
- const insideFile = join(workspace, "inside.txt");
177
- writeFileSync(insideFile, "HOME-WORKSPACE-readable");
178
- const ok = await runSandboxed(cfg, `cat ${insideFile}`);
179
- expect(ok.code).toBe(0);
180
- expect(ok.stdout).toContain("HOME-WORKSPACE-readable");
181
- } finally {
182
- rmSync(secretDir, { recursive: true, force: true });
183
- }
184
- }, 60_000);
185
-
186
- test("DEFAULT POSTURE: ~/.parachute (operator.token's dir) is UNREADABLE, the workspace IS readable, network is OPEN", async () => {
187
- // The security guarantee Aaron asked for: the DEFAULT spawn (no filesystem/no
188
- // network knob) must (a) be unable to read the operator's secrets — modelled by
189
- // a decoy planted in the REAL ~/.parachute, exactly where operator.token lives —
190
- // while (b) still having the workspace readable and (c) the network fully OPEN.
191
- // Reads scoped by default; internet open by default.
192
- const home = homedir();
193
- workspace = mkdtempSync(join(home, ".sbx-live-default-ws-"));
194
- const decoy = join(home, ".parachute", `.sbx-live-decoy-${process.pid}.txt`);
195
- writeFileSync(decoy, "OPERATOR-TOKEN-DECOY-do-not-read");
196
- try {
197
- // DEFAULT spec: neither filesystem nor network set → workspace-scoped reads + open net.
198
- const spec: AgentSpec = { name: "deflt", channels: ["c"] };
199
- const cfg = buildSandboxConfig({
200
- spec,
201
- baseBinds: { workspace, runtimeReadOnly: [] },
202
- egressBase: { hubOrigin: "http://127.0.0.1:1939" },
203
- platform: "darwin",
204
- });
205
- // Defaults: scoped reads (/Users denied) + OPEN network (no allowedDomains).
206
- expect(cfg.filesystem.denyRead).toEqual(["/Users"]);
207
- expect((cfg.network as { allowedDomains?: string[] }).allowedDomains).toBeUndefined();
208
-
209
- // (a) the decoy in ~/.parachute is DENIED — operator.token is equally unreadable.
210
- const blocked = await runSandboxed(cfg, `cat ${decoy}`);
211
- expect(blocked.code).not.toBe(0);
212
- expect(blocked.stdout).not.toContain("OPERATOR-TOKEN-DECOY");
213
-
214
- // (b) positive control: a workspace file IS readable under the SAME config.
215
- const inside = join(workspace, "inside.txt");
216
- writeFileSync(inside, "WORKSPACE-readable-by-default");
217
- const ok = await runSandboxed(cfg, `cat ${inside}`);
218
- expect(ok.code).toBe(0);
219
- expect(ok.stdout).toContain("WORKSPACE-readable-by-default");
220
- } finally {
221
- rmSync(decoy, { force: true });
222
- }
223
- }, 60_000);
224
-
225
- test("ALLOWED: reading a file INSIDE the workspace bind succeeds", async () => {
226
- workspace = mkdtempSync(join(tmpdir(), "sbx-live-read-ok-"));
227
- const f = join(workspace, "inside.txt");
228
- writeFileSync(f, "READABLE-inside-workspace");
229
- const spec: AgentSpec = { name: "readok", channels: ["c"], network: "restricted", egress: [] };
230
- const cfg = buildSandboxConfig({
231
- spec,
232
- baseBinds: { workspace, runtimeReadOnly: [] },
233
- egressBase: { hubOrigin: "http://127.0.0.1:1939" },
234
- platform: "darwin",
235
- });
236
- const r = await runSandboxed(cfg, `cat ${f}`);
237
- expect(r.code).toBe(0);
238
- expect(r.stdout).toContain("READABLE-inside-workspace");
239
- }, 60_000);
240
-
241
- test("DENIED: writing OUTSIDE the workspace fails (write confinement)", async () => {
242
- workspace = mkdtempSync(join(tmpdir(), "sbx-live-write-"));
243
- const outsideDir = mkdtempSync(join(tmpdir(), "sbx-live-outside-"));
244
- const target = join(outsideDir, "should-not-exist.txt");
245
- try {
246
- const spec: AgentSpec = { name: "write", channels: ["c"], network: "restricted", egress: [] };
247
- const cfg = buildSandboxConfig({
248
- spec,
249
- baseBinds: { workspace, runtimeReadOnly: [] },
250
- egressBase: { hubOrigin: "http://127.0.0.1:1939" },
251
- platform: "darwin",
252
- });
253
- const r = await runSandboxed(cfg, `echo pwned > ${target}`);
254
- expect(r.code).not.toBe(0);
255
- // The file must not have been created (write was blocked at the OS level).
256
- expect(await Bun.file(target).exists()).toBe(false);
257
- } finally {
258
- rmSync(outsideDir, { recursive: true, force: true });
259
- }
260
- }, 60_000);
261
-
262
- test("ALLOWED: writing INSIDE the workspace succeeds", async () => {
263
- workspace = mkdtempSync(join(tmpdir(), "sbx-live-write-ok-"));
264
- const target = join(workspace, "out.txt");
265
- const spec: AgentSpec = { name: "writeok", channels: ["c"], network: "restricted", egress: [] };
266
- const cfg = buildSandboxConfig({
267
- spec,
268
- baseBinds: { workspace, runtimeReadOnly: [] },
269
- egressBase: { hubOrigin: "http://127.0.0.1:1939" },
270
- platform: "darwin",
271
- });
272
- const r = await runSandboxed(cfg, `echo written-inside > ${target}`);
273
- expect(r.code).toBe(0);
274
- const written = await Bun.file(target).text();
275
- expect(written).toContain("written-inside");
276
- }, 60_000);
277
- });
@@ -1,154 +0,0 @@
1
- import { describe, test, expect } from "bun:test";
2
- import {
3
- composeFilesystemView,
4
- homeTreeDenyRoot,
5
- sharedMounts,
6
- } from "./mounts.ts";
7
- import type { AgentMount, BaseBinds } from "./types.ts";
8
-
9
- const BASE: BaseBinds = {
10
- workspace: "/state/sessions/arm",
11
- runtimeReadOnly: ["/home/op/.claude"],
12
- };
13
-
14
- describe("homeTreeDenyRoot — platform nuance", () => {
15
- test("macOS denies /Users", () => {
16
- expect(homeTreeDenyRoot("darwin")).toBe("/Users");
17
- });
18
- test("Linux denies /home", () => {
19
- expect(homeTreeDenyRoot("linux")).toBe("/home");
20
- });
21
- });
22
-
23
- describe("composeFilesystemView — scoped reads + write confinement", () => {
24
- test("with no mounts: reads = workspace + runtime, writes = workspace only", () => {
25
- const fs = composeFilesystemView(BASE, undefined, "darwin");
26
- expect(fs.allowRead).toContain("/state/sessions/arm");
27
- expect(fs.allowRead).toContain("/home/op/.claude");
28
- expect(fs.allowWrite).toEqual(["/state/sessions/arm"]);
29
- });
30
-
31
- test("the home tree is denied for reads (scoped-read policy, §4.5)", () => {
32
- const fs = composeFilesystemView(BASE, undefined, "darwin");
33
- expect(fs.denyRead).toContain("/Users");
34
- });
35
-
36
- test("the home tree denied is platform-correct on Linux", () => {
37
- const fs = composeFilesystemView(BASE, undefined, "linux");
38
- expect(fs.denyRead).toContain("/home");
39
- });
40
-
41
- test("an ro mount is readable but NOT writable", () => {
42
- const mounts: AgentMount[] = [{ hostPath: "/refs/tree", mountPath: "/ref", mode: "ro" }];
43
- const fs = composeFilesystemView(BASE, mounts, "darwin");
44
- expect(fs.allowRead).toContain("/refs/tree");
45
- expect(fs.allowWrite).not.toContain("/refs/tree");
46
- });
47
-
48
- test("an rw mount is BOTH readable and writable", () => {
49
- const mounts: AgentMount[] = [{ hostPath: "/proj/foo", mountPath: "/work/foo", mode: "rw" }];
50
- const fs = composeFilesystemView(BASE, mounts, "darwin");
51
- expect(fs.allowRead).toContain("/proj/foo");
52
- expect(fs.allowWrite).toContain("/proj/foo");
53
- });
54
-
55
- test("SECURITY: a path OUTSIDE all binds is not in the read surface (scoped, not broad)", () => {
56
- const mounts: AgentMount[] = [{ hostPath: "/proj/foo", mountPath: "/work/foo", mode: "rw" }];
57
- const fs = composeFilesystemView(BASE, mounts, "darwin");
58
- // The operator's SSH dir / other vaults are NOT re-allowed within the denied home tree.
59
- expect(fs.allowRead).not.toContain("/Users/op/.ssh");
60
- expect(fs.allowRead).not.toContain("/Users/op/other-vault");
61
- // And nothing widened the deny away.
62
- expect(fs.denyRead).toEqual(["/Users"]);
63
- });
64
-
65
- test("SECURITY: writes are confined — only workspace + rw mounts, never an ro mount or arbitrary path", () => {
66
- const mounts: AgentMount[] = [
67
- { hostPath: "/refs/tree", mountPath: "/ref", mode: "ro" },
68
- { hostPath: "/proj/foo", mountPath: "/work/foo", mode: "rw" },
69
- ];
70
- const fs = composeFilesystemView(BASE, mounts, "darwin");
71
- expect(new Set(fs.allowWrite)).toEqual(new Set(["/state/sessions/arm", "/proj/foo"]));
72
- });
73
-
74
- test("the base binds are always present even if the spec declares none", () => {
75
- const fs = composeFilesystemView(BASE, [], "linux");
76
- expect(fs.allowRead).toContain("/state/sessions/arm");
77
- expect(fs.allowRead).toContain("/home/op/.claude");
78
- expect(fs.allowWrite).toContain("/state/sessions/arm");
79
- });
80
-
81
- test("dedupes a mount equal to the workspace", () => {
82
- const mounts: AgentMount[] = [
83
- { hostPath: "/state/sessions/arm", mountPath: "/work", mode: "rw" },
84
- ];
85
- const fs = composeFilesystemView(BASE, mounts, "darwin");
86
- expect(fs.allowWrite.filter((p) => p === "/state/sessions/arm")).toHaveLength(1);
87
- });
88
-
89
- // The working-directory axis (design 2026-06-16-agent-filesystem-and-sharing.md):
90
- // the spec's `workspace` (a shared real dir) is bound rw — readable + writable —
91
- // decoupled from the private home (which stays the per-agent session dir).
92
- describe("the working dir (workspace) as an rw working-root", () => {
93
- test("a working dir is BOTH readable and writable (an rw working-root)", () => {
94
- const fs = composeFilesystemView(BASE, undefined, "darwin", true, "/Users/op/Code/repo");
95
- expect(fs.allowRead).toContain("/Users/op/Code/repo");
96
- expect(fs.allowWrite).toContain("/Users/op/Code/repo");
97
- });
98
-
99
- test("the PRIVATE home is ALSO writable alongside the working dir (decoupled, both rw)", () => {
100
- const fs = composeFilesystemView(BASE, undefined, "darwin", true, "/Users/op/Code/repo");
101
- // The private session dir stays in the write surface (it holds .mcp.json/home/tmp).
102
- expect(fs.allowWrite).toContain("/state/sessions/arm");
103
- expect(fs.allowWrite).toContain("/Users/op/Code/repo");
104
- });
105
-
106
- test("unset working dir → only the private home is writable (today's behavior)", () => {
107
- const fs = composeFilesystemView(BASE, undefined, "darwin", true, undefined);
108
- expect(fs.allowWrite).toEqual(["/state/sessions/arm"]);
109
- });
110
-
111
- test("a blank working dir is ignored (treated as unset)", () => {
112
- const fs = composeFilesystemView(BASE, undefined, "darwin", true, "");
113
- expect(fs.allowWrite).toEqual(["/state/sessions/arm"]);
114
- });
115
-
116
- test("under filesystem 'full' (broad reads) the working dir is still in the write surface", () => {
117
- const fs = composeFilesystemView(BASE, undefined, "darwin", false, "/Users/op/Code/repo");
118
- expect(fs.denyRead).toEqual([]); // broad reads — no home-tree deny
119
- expect(fs.allowWrite).toContain("/Users/op/Code/repo");
120
- expect(fs.allowWrite).toContain("/state/sessions/arm");
121
- });
122
-
123
- test("a working dir equal to a declared mount dedupes in the write surface", () => {
124
- const mounts: AgentMount[] = [{ hostPath: "/Users/op/Code/repo", mountPath: "/repo", mode: "rw" }];
125
- const fs = composeFilesystemView(BASE, mounts, "darwin", true, "/Users/op/Code/repo");
126
- expect(fs.allowWrite.filter((p) => p === "/Users/op/Code/repo")).toHaveLength(1);
127
- });
128
- });
129
- });
130
-
131
- describe("sharedMounts — the named cross-session relaxation", () => {
132
- test("surfaces only mounts carrying a non-empty `shared` tag", () => {
133
- const mounts: AgentMount[] = [
134
- { hostPath: "/a", mountPath: "/a", mode: "ro" },
135
- { hostPath: "/cache", mountPath: "/cache", mode: "ro", shared: "build-cache" },
136
- { hostPath: "/b", mountPath: "/b", mode: "rw", shared: "" },
137
- ];
138
- const shared = sharedMounts(mounts);
139
- expect(shared).toHaveLength(1);
140
- expect(shared[0]!.shared).toBe("build-cache");
141
- });
142
-
143
- test("a shared mount is still bound like any other (honored in the fs view)", () => {
144
- const mounts: AgentMount[] = [
145
- { hostPath: "/cache", mountPath: "/cache", mode: "ro", shared: "build-cache" },
146
- ];
147
- const fs = composeFilesystemView(BASE, mounts, "darwin");
148
- expect(fs.allowRead).toContain("/cache");
149
- });
150
-
151
- test("empty input → no shared mounts", () => {
152
- expect(sharedMounts(undefined)).toEqual([]);
153
- });
154
- });