@openparachute/agent 0.2.3-rc.2 → 0.2.3-rc.4

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 (58) hide show
  1. package/package.json +4 -1
  2. package/src/transports/vault.ts +19 -1
  3. package/web/ui/dist/assets/{index-5KEwEhfi.js → index-0e7eQymr.js} +1 -1
  4. package/web/ui/dist/assets/index-tvKbxee4.css +1 -0
  5. package/web/ui/dist/index.html +2 -2
  6. package/src/_parked/interactive-spawn.test.ts +0 -324
  7. package/src/_parked/interactive-spawn.ts +0 -701
  8. package/src/agent-defs.test.ts +0 -1504
  9. package/src/agent-mcp-config.test.ts +0 -115
  10. package/src/agents.test.ts +0 -360
  11. package/src/auth.test.ts +0 -46
  12. package/src/backends/attached-queue.test.ts +0 -376
  13. package/src/backends/programmatic.test.ts +0 -1715
  14. package/src/backends/registry.test.ts +0 -1494
  15. package/src/backends/stream-json.test.ts +0 -570
  16. package/src/channel-backend-wiring.test.ts +0 -237
  17. package/src/credentials.test.ts +0 -274
  18. package/src/cron.test.ts +0 -342
  19. package/src/daemon-agent-def-api.test.ts +0 -166
  20. package/src/daemon-agent-defs-api.test.ts +0 -953
  21. package/src/daemon-agent-env-api.test.ts +0 -338
  22. package/src/daemon-attached-queue-store.test.ts +0 -65
  23. package/src/daemon-config-api.test.ts +0 -962
  24. package/src/daemon-jobs-api.test.ts +0 -271
  25. package/src/daemon-vault-chat.test.ts +0 -250
  26. package/src/daemon.test.ts +0 -746
  27. package/src/def-vaults.test.ts +0 -136
  28. package/src/delivery-state.test.ts +0 -110
  29. package/src/effective-env.test.ts +0 -114
  30. package/src/grants.test.ts +0 -638
  31. package/src/hub-jwt.test.ts +0 -161
  32. package/src/jobs.test.ts +0 -245
  33. package/src/mcp-http.test.ts +0 -265
  34. package/src/mint-token.test.ts +0 -152
  35. package/src/module-manifest.test.ts +0 -158
  36. package/src/programmatic-wiring.test.ts +0 -838
  37. package/src/registry.test.ts +0 -227
  38. package/src/resolve-port.test.ts +0 -64
  39. package/src/routing.test.ts +0 -184
  40. package/src/runner.test.ts +0 -506
  41. package/src/sandbox/config.test.ts +0 -150
  42. package/src/sandbox/egress.test.ts +0 -113
  43. package/src/sandbox/live-seatbelt.test.ts +0 -277
  44. package/src/sandbox/mounts.test.ts +0 -154
  45. package/src/sandbox/sandbox.test.ts +0 -168
  46. package/src/services-manifest.test.ts +0 -106
  47. package/src/spa-serve.test.ts +0 -116
  48. package/src/spawn-agent-cli.test.ts +0 -172
  49. package/src/spawn-agent.test.ts +0 -1218
  50. package/src/spawn-deps.test.ts +0 -54
  51. package/src/terminal-assets.test.ts +0 -50
  52. package/src/terminal.test.ts +0 -530
  53. package/src/transports/http-ui.test.ts +0 -455
  54. package/src/transports/telegram.test.ts +0 -174
  55. package/src/transports/vault.test.ts +0 -2012
  56. package/src/ui-kit.test.ts +0 -178
  57. package/web/ui/dist/assets/index-C-iWdFFV.css +0 -1
  58. 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
- });