@celilo/cli 2.0.0 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (103) hide show
  1. package/CELILO_CORE_MODULES.md +1 -1
  2. package/CELILO_SUBSYSTEMS.md +1 -0
  3. package/drizzle/0031_module_config_source.sql +20 -0
  4. package/drizzle/meta/_journal.json +8 -1
  5. package/package.json +2 -2
  6. package/schemas/system_config.json +2 -1
  7. package/src/capabilities/public-web-publish.test.ts +61 -0
  8. package/src/cli/commands/firewall-interface-list.test.ts +156 -7
  9. package/src/cli/commands/firewall-interface-list.ts +73 -7
  10. package/src/cli/commands/machine-add.ts +12 -55
  11. package/src/cli/commands/module-config.test.ts +20 -1
  12. package/src/cli/commands/module-import.ts +1 -1
  13. package/src/cli/commands/module-update.test.ts +82 -0
  14. package/src/cli/commands/module-update.ts +14 -4
  15. package/src/cli/commands/monitor.ts +2 -10
  16. package/src/cli/commands/restore.ts +16 -6
  17. package/src/cli/generate-zsh-completion.ts +1 -1
  18. package/src/cli/index.ts +4 -3
  19. package/src/cli/restore-migration-failure.test.ts +159 -0
  20. package/src/db/client.ts +5 -0
  21. package/src/db/migrate.test.ts +61 -135
  22. package/src/db/migrate.ts +7 -2
  23. package/src/db/schema.ts +10 -0
  24. package/src/hooks/broker.test.ts +106 -2
  25. package/src/hooks/broker.ts +91 -1
  26. package/src/hooks/capability-loader-firewall.test.ts +37 -0
  27. package/src/hooks/capability-loader.ts +16 -3
  28. package/src/hooks/define-hook.test.ts +4 -3
  29. package/src/hooks/executor.test.ts +19 -18
  30. package/src/hooks/executor.ts +88 -11
  31. package/src/hooks/hook-jail-toolchain-reach.test.ts +79 -29
  32. package/src/hooks/hook-jail-unreachability.test.ts +55 -29
  33. package/src/hooks/hook-protocol.ts +46 -1
  34. package/src/hooks/hook-runner.ts +36 -0
  35. package/src/hooks/hook-store-proxy.test.ts +109 -0
  36. package/src/hooks/hook-store-proxy.ts +85 -0
  37. package/src/hooks/hook-store.test.ts +162 -0
  38. package/src/hooks/hook-store.ts +290 -0
  39. package/src/hooks/hook-timeout.test.ts +3 -2
  40. package/src/hooks/hook-trespass.test.ts +94 -14
  41. package/src/hooks/jail.test.ts +1 -1
  42. package/src/hooks/jail.ts +194 -32
  43. package/src/hooks/mount-set.test.ts +296 -1
  44. package/src/hooks/mount-set.ts +216 -13
  45. package/src/hooks/run-named-hook.ts +2 -0
  46. package/src/hooks/test-fixtures/jail-probe-hook.ts +1 -1
  47. package/src/hooks/test-fixtures/on-restore-staging-hook.ts +26 -0
  48. package/src/hooks/test-fixtures/store-backed.ts +47 -0
  49. package/src/hooks/test-fixtures/store-writing-hook.ts +63 -0
  50. package/src/hooks/unjailed-lint.test.ts +27 -8
  51. package/src/manifest/schema.ts +1 -0
  52. package/src/module/packaging/build.ts +70 -2
  53. package/src/module/web-root.ts +17 -1
  54. package/src/policy/fixture-capability-coverage.test.ts +322 -0
  55. package/src/policy/module-script-scan.test.ts +42 -1
  56. package/src/policy/module-script-scan.ts +275 -5
  57. package/src/policy/no-hand-built-ssh.test.ts +34 -1
  58. package/src/policy/no-swallowed-refusal.test.ts +265 -0
  59. package/src/policy/no-tar-shell-out-in-services.test.ts +43 -0
  60. package/src/registry/client.test.ts +149 -0
  61. package/src/registry/client.ts +203 -11
  62. package/src/services/alerting/coverage-source.test.ts +86 -0
  63. package/src/services/alerting/coverage-source.ts +11 -1
  64. package/src/services/alerting/format.test.ts +57 -0
  65. package/src/services/alerting/format.ts +24 -0
  66. package/src/services/alerting/run-monitor.ts +2 -2
  67. package/src/services/backup-create.ts +7 -7
  68. package/src/services/backup-envelope-roundtrip.test.ts +45 -2
  69. package/src/services/backup-restore.ts +8 -4
  70. package/src/services/bus-interview.ts +37 -14
  71. package/src/services/config-provenance.ts +4 -0
  72. package/src/services/control-plane-bootstrap.test.ts +297 -0
  73. package/src/services/control-plane-bootstrap.ts +223 -0
  74. package/src/services/control-plane-health.test.ts +66 -0
  75. package/src/services/control-plane-health.ts +67 -0
  76. package/src/services/deploy-preflight.ts +8 -2
  77. package/src/services/deploy-validation.test.ts +22 -0
  78. package/src/services/deploy-validation.ts +8 -0
  79. package/src/services/deployed-systems.ts +12 -0
  80. package/src/services/dns-discovery.test.ts +147 -0
  81. package/src/services/dns-discovery.ts +134 -0
  82. package/src/services/fleet-checks.ts +6 -2
  83. package/src/services/fleet-key.test.ts +66 -2
  84. package/src/services/fleet-key.ts +54 -0
  85. package/src/services/health-runner.ts +36 -3
  86. package/src/services/module-config.ts +20 -2
  87. package/src/services/module-deploy.dns-repoint.test.ts +187 -0
  88. package/src/services/module-deploy.ts +214 -1
  89. package/src/services/module-validator/git-hygiene.test.ts +122 -3
  90. package/src/services/module-validator/git-hygiene.ts +83 -14
  91. package/src/services/remote-access.test.ts +88 -4
  92. package/src/services/remote-access.ts +52 -1
  93. package/src/services/restore-from-file.test.ts +20 -0
  94. package/src/services/restore-from-file.ts +21 -6
  95. package/src/services/static-content-converge.test.ts +140 -2
  96. package/src/services/static-content-converge.ts +55 -8
  97. package/src/services/system-config-schema-types.ts +1 -1
  98. package/src/services/system-config-validator.test.ts +36 -0
  99. package/src/services/system-config-validator.ts +11 -0
  100. package/src/services/trusted-sources.test.ts +30 -0
  101. package/src/services/trusted-sources.ts +47 -10
  102. package/src/templates/generator.ts +9 -2
  103. package/src/variables/context.ts +16 -5
@@ -31,8 +31,11 @@ import {
31
31
  listUnpublishedWorkspacePackages,
32
32
  planFallbacks,
33
33
  } from '../../../../scripts/workspace-fallback';
34
- import { executeHookScript, hookChildEnv } from './executor';
34
+ import { childPath, executeHookScript, hookChildEnv } from './executor';
35
+ import { HOOK_PROTOCOL_VERSION } from './hook-protocol';
36
+ import { detectJailBackend, jailPolicy } from './jail';
35
37
  import { createCapturingLogger } from './logger';
38
+ import { configStore, secretStore } from './test-fixtures/store-backed';
36
39
  import type { HookContext } from './types';
37
40
 
38
41
  const TRESPASS_SCRIPT = resolve(
@@ -150,11 +153,11 @@ afterAll(() => {
150
153
  rmSync(scratchHome, { recursive: true, force: true });
151
154
  });
152
155
 
153
- async function runTrespass(): Promise<{ report: TrespassReport; lines: string[] }> {
156
+ async function runTrespass(jail = false): Promise<{ report: TrespassReport; lines: string[] }> {
154
157
  const { logger, messages } = createCapturingLogger();
155
158
  const context: HookContext = {
156
- config: { sibling_module_id: 'hello-foo', other_system_ip: '' },
157
- secrets: {},
159
+ config: configStore({ sibling_module_id: 'hello-foo', other_system_ip: '' }),
160
+ secrets: secretStore(),
158
161
  systems: [],
159
162
  logger,
160
163
  debug: false,
@@ -163,14 +166,28 @@ async function runTrespass(): Promise<{ report: TrespassReport; lines: string[]
163
166
  capabilities: {},
164
167
  };
165
168
 
166
- const outputs = await executeHookScript(TRESPASS_SCRIPT, context, {
167
- timeoutMs: 60_000,
168
- idleTimeoutMs: 60_000,
169
- });
170
- return {
171
- report: outputs as unknown as TrespassReport,
172
- lines: messages.map((m) => m.message),
173
- };
169
+ // The jailed run jails under `required`, not the `auto` default. ce-29z
170
+ // made `auto` defer on sandbox-exec until D14 exists (undeclared host-path
171
+ // writes would break module hooks), so the test's jail is the operator's
172
+ // explicit act — exactly the bypass the deferral leaves open.
173
+ const savedPolicy = process.env.CELILO_HOOK_JAIL;
174
+ if (jail) process.env.CELILO_HOOK_JAIL = 'required';
175
+ try {
176
+ const outputs = await executeHookScript(TRESPASS_SCRIPT, context, {
177
+ timeoutMs: 60_000,
178
+ idleTimeoutMs: 60_000,
179
+ // The module's own tree, two levels above the hook script. Absent means
180
+ // "run unjailed", which is what the stage 1 assertions below need.
181
+ ...(jail ? { jail: { modulePath: dirname(dirname(TRESPASS_SCRIPT)), pathInputs: [] } } : {}),
182
+ });
183
+ return {
184
+ report: outputs as unknown as TrespassReport,
185
+ lines: messages.map((m) => m.message),
186
+ };
187
+ } finally {
188
+ if (savedPolicy === undefined) delete process.env.CELILO_HOOK_JAIL;
189
+ else process.env.CELILO_HOOK_JAIL = savedPolicy;
190
+ }
174
191
  }
175
192
 
176
193
  describe('the child environment is an allow-list', () => {
@@ -185,8 +202,11 @@ describe('the child environment is an allow-list', () => {
185
202
 
186
203
  expect(env.CELILO_HOOK_SOCKET).toBe('/tmp/celilo-hook-x/s');
187
204
  expect(env.CELILO_HOOK_REMOTE_SOCKET).toBe('/tmp/celilo-hook-x/r');
188
- expect(env.CELILO_HOOK_PROTOCOL_VERSION).toBe('1');
189
- expect(env.PATH).toBe(process.env.PATH as string);
205
+ // Pinned to the constant, not the literal: the version moves when the
206
+ // wire does (2 added the hook-owned-state store frames), and this line
207
+ // is part of the handshake's contract, not a second copy of the number.
208
+ expect(env.CELILO_HOOK_PROTOCOL_VERSION).toBe(String(HOOK_PROTOCOL_VERSION));
209
+ expect(env.PATH).toBe(childPath(process.env.PATH));
190
210
  expect(env.HOME).toBe(process.env.HOME as string);
191
211
  expect(env.CELILO_TEST_SECRET).toBeUndefined();
192
212
 
@@ -222,6 +242,25 @@ describe('the child environment is an allow-list', () => {
222
242
  if (saved !== undefined) process.env.TZ = saved;
223
243
  }
224
244
  });
245
+
246
+ // celilo#1300: a hook that spawns `celilo` by name must find the same CLI
247
+ // the parent runs, whatever the inherited PATH looks like.
248
+ describe('childPath', () => {
249
+ test('prepends the runtime directory so the child reaches the parent CLI', () => {
250
+ const child = childPath('/usr/local/bin:/usr/bin');
251
+ expect(child.startsWith(`${dirname(process.execPath)}:`)).toBe(true);
252
+ expect(child.endsWith('/usr/local/bin:/usr/bin')).toBe(true);
253
+ });
254
+
255
+ test('leaves an inherited PATH that already carries the directory unchanged', () => {
256
+ const already = `${dirname(process.execPath)}:/usr/bin`;
257
+ expect(childPath(already)).toBe(already);
258
+ });
259
+
260
+ test('substitutes the directory when the parent has no PATH at all', () => {
261
+ expect(childPath(undefined)).toBe(dirname(process.execPath));
262
+ });
263
+ });
225
264
  });
226
265
 
227
266
  describe('hook process boundary — hello-trespass gate', () => {
@@ -251,3 +290,44 @@ describe('hook process boundary — hello-trespass gate', () => {
251
290
  expect(report.ssh_key.attempted && !report.ssh_key.succeeded).toBe(false);
252
291
  });
253
292
  });
293
+
294
+ const availability = detectJailBackend();
295
+ const jailed = availability.backend !== 'none' && jailPolicy() !== 'off';
296
+
297
+ describe.skipIf(!jailed)(
298
+ `stage 2: the same hook, jailed (backend: ${availability.backend})`,
299
+ () => {
300
+ // Task 4.12's platform half that a Mac can run. The e2e stage
301
+ // (`e2e/tests/hook-jail-trespass.test.ts`) is the other one, because
302
+ // bubblewrap needs a Linux kernel and a container to hold it.
303
+ //
304
+ // The SAME fixture, run twice, is the whole design of this gate: the stage 1
305
+ // block above asserts these two trespasses SUCCEED, and this one asserts the
306
+ // jail refuses them. Neither reading is available from one run.
307
+ test('trespasses 1 and 2 are refused, and the environment is still clean', async () => {
308
+ const { report, lines } = await runTrespass(true);
309
+ console.log(['', 'hello-trespass (jailed):', ...lines.slice(1)].join('\n'));
310
+
311
+ // What stage 2 claims: celilo's data directory is not bound, so the master
312
+ // key is unreachable. `<store>` is not bound either, so a sibling is too.
313
+ expect(report.master_key.succeeded).toBe(false);
314
+ expect(report.sibling_write.succeeded).toBe(false);
315
+
316
+ // Unreachability, never an errno. bubblewrap removes the path and reports
317
+ // ENOENT; sandbox-exec denies it and reports EPERM. Pinning either one
318
+ // would go red on a platform whose jail works perfectly (D9).
319
+ expect(report.master_key.detail).toMatch(/ENOENT|EPERM/);
320
+
321
+ // Stage 1's claim has not regressed on the way to stage 2.
322
+ expect(report.sensitive_env).toEqual([]);
323
+ }, 120_000);
324
+
325
+ // Trespass 3 is deliberately NOT asserted here, and the reason is a property
326
+ // of this fixture rather than of the jail. `jailRequest` binds `~/.ssh` from
327
+ // `os.homedir()`, which on macOS reads the password database and ignores the
328
+ // `HOME` this file redirects — so the hook looks in the scratch home and the
329
+ // jail bound the real one, and the row reports a refusal that says nothing
330
+ // about stage 3. The e2e stage asserts it, where the two agree. Filed as
331
+ // celilo#1211.
332
+ },
333
+ );
@@ -137,7 +137,7 @@ describe('a mount whose source is missing is dropped, not fatal', () => {
137
137
  'auto',
138
138
  exists,
139
139
  );
140
- expect(skipped).toContain(missing);
140
+ expect(skipped.map((m) => m.path)).toContain(missing);
141
141
  expect(cmd.join(' ')).not.toContain(missing);
142
142
  });
143
143
 
package/src/hooks/jail.ts CHANGED
@@ -21,22 +21,32 @@
21
21
  * reads.
22
22
  */
23
23
 
24
- import { execFileSync } from 'node:child_process';
24
+ import { execFileSync, spawnSync } from 'node:child_process';
25
25
  import type { Dirent } from 'node:fs';
26
26
  import {
27
27
  existsSync,
28
28
  mkdirSync,
29
+ mkdtempSync,
29
30
  readFileSync,
30
31
  readdirSync,
31
32
  realpathSync,
32
33
  renameSync,
34
+ rmSync,
33
35
  statSync,
34
36
  writeFileSync,
35
37
  } from 'node:fs';
36
- import { hostname } from 'node:os';
38
+ import { hostname, tmpdir } from 'node:os';
37
39
  import { dirname, join } from 'node:path';
38
40
  import { getDataDir } from '../config/paths';
39
- import { type MountSet, type MountSetRequest, toBwrapArgs } from './mount-set';
41
+ import { HOOK_SOCKET_ENV } from './hook-protocol';
42
+ import {
43
+ type MountEntry,
44
+ type MountSet,
45
+ type MountSetRequest,
46
+ deriveMountSet,
47
+ toBwrapArgs,
48
+ toSandboxProfile,
49
+ } from './mount-set';
40
50
  import { JAIL_PROVIDED_PATHS } from './unjailed-lint';
41
51
 
42
52
  /**
@@ -123,13 +133,31 @@ export interface JailPlan {
123
133
  *
124
134
  * bubblewrap fails the whole jail on a bind whose SOURCE is missing, and
125
135
  * several rows are legitimately absent: `/lib64` does not exist on arm64,
126
- * and `<module>/generated` only appears once celilo has generated
127
- * something. Dropping them is the caller's job rather
128
- * than the derivation's, which is why they are reported rather than silently
129
- * filtered — a contract input landing here is a real defect and this is
130
- * where it becomes visible.
136
+ * and `<module>/generated` only appears once celilo has generated something.
137
+ * Dropping them is the caller's job rather than the derivation's, which is
138
+ * why they are reported rather than silently filtered.
139
+ *
140
+ * @psbanka - 2026-09: these carry the whole ROW now, not just the path. They
141
+ * used to be `string[]`, built by `.map((e) => e.path)`, which threw away the
142
+ * `reason` and left the caller to classify an absence by reading a path
143
+ * string. It could not, so it warned about every one of them: 96 identical
144
+ * lines in a single `cele2e run --all`, with the one case that means silent
145
+ * data loss hidden among them. A signal that repeats unchanged is one every
146
+ * reader learns to skip.
147
+ */
148
+ readonly skipped: readonly MountEntry[];
149
+ /**
150
+ * The directory to spawn the child in, when the backend cannot set it itself.
151
+ *
152
+ * bubblewrap takes `--chdir`, so its plans leave this absent and the child
153
+ * inherits celilo's cwd — which the jail has replaced anyway.
154
+ * `sandbox-exec` has no equivalent, so the child inherits the OPERATOR's cwd
155
+ * and the profile does not name it. Measured 2026-08-27: `bun` reads its
156
+ * working directory before running anything, so a cwd outside the profile
157
+ * kills it with `error: An unknown error occurred (Unexpected)` and no
158
+ * further diagnostic. Naming the mount set's `chdir` here is the fix.
131
159
  */
132
- readonly skipped: readonly string[];
160
+ readonly cwd?: string;
133
161
  }
134
162
 
135
163
  /**
@@ -186,6 +214,24 @@ const JAIL_NAMESPACE_ARGS = [
186
214
  */
187
215
  const BWRAP = 'bwrap';
188
216
 
217
+ /**
218
+ * The macOS jail builder.
219
+ *
220
+ * Pinned absolute, unlike `BWRAP`. There is no AppArmor-style profile to miss
221
+ * here, so `PATH` buys nothing and costs the one thing that matters: a
222
+ * `sandbox-exec` earlier on `PATH` would be handed every hook's profile and
223
+ * could report a jail it never built. The probe below would catch that, and
224
+ * pinning means it never has to.
225
+ */
226
+ const SANDBOX_EXEC = '/usr/bin/sandbox-exec';
227
+
228
+ /**
229
+ * The runner shim, named here as well as in the executor because the probe has
230
+ * to spawn the REAL one. A probe that starts a smaller program answers a
231
+ * question about a smaller program.
232
+ */
233
+ const HOOK_RUNNER_PATH = join(import.meta.dir, 'hook-runner.ts');
234
+
189
235
  /** How long to wait for the probe before calling the backend unavailable. */
190
236
  const PROBE_TIMEOUT_MS = 10_000;
191
237
 
@@ -213,17 +259,7 @@ export function detectJailBackend(): JailAvailability {
213
259
  }
214
260
 
215
261
  function probeJailBackend(): JailAvailability {
216
- if (process.platform === 'darwin') {
217
- // `sandbox-exec` is measured working (D8) but the backend that generates
218
- // its profile is task 4.8 and is not built. Claiming macOS is jailed
219
- // because the tool exists would be the fail-open jail D8 calls worse than
220
- // no jail at all.
221
- return {
222
- backend: 'none',
223
- reason:
224
- 'macOS has no hook jail yet: the sandbox-exec backend is designed but not built (hook-process-boundary task 4.8). Hooks run unjailed on this host.',
225
- };
226
- }
262
+ if (process.platform === 'darwin') return probeSandboxExec();
227
263
  if (process.platform !== 'linux') {
228
264
  return {
229
265
  backend: 'none',
@@ -243,6 +279,118 @@ function probeJailBackend(): JailAvailability {
243
279
  }
244
280
  }
245
281
 
282
+ /**
283
+ * What the probe plants and then tries to read. Its ABSENCE from the output is
284
+ * the evidence, so it is a string nothing else would print.
285
+ */
286
+ const SANDBOX_PROBE_MARKER = 'celilo-jail-probe-reached-the-planted-file';
287
+
288
+ /**
289
+ * Can this Mac jail? Measured by proving a DENIAL, never by finding the binary.
290
+ *
291
+ * This probe is shaped differently from bubblewrap's and the difference is the
292
+ * point. bubblewrap FAILS when it cannot build a namespace, loudly, with one of
293
+ * four distinct denials — so running it at all is the measurement.
294
+ * `sandbox-exec` does not fail. A profile it cannot apply to a path is simply
295
+ * not applied: the access succeeds and nothing anywhere reports an error (D8,
296
+ * measured, and re-measured 2026-08-27 in both directions — a rule naming
297
+ * `/tmp/x` where the kernel sees `/private/tmp/x` neither denies nor grants).
298
+ *
299
+ * So "sandbox-exec exists" is not evidence of anything, and neither is "the
300
+ * profile parsed". The only honest question is whether a file this probe
301
+ * planted, outside the profile, is actually unreachable from inside it. That is
302
+ * CLAUDE.md's reach probe applied to the jail itself: plant a marker, run the
303
+ * REAL machinery, and read which markers fired.
304
+ *
305
+ * Three outcomes, and the middle one is the one worth having:
306
+ *
307
+ * - the read was refused → `sandbox-exec`, the jail is real
308
+ * - the read SUCCEEDED → `none`, and the reason says fail-open.
309
+ * A jail that is believed and absent is worse than no jail (D8).
310
+ * - `sandbox-exec` never ran → `none`, with its own message
311
+ */
312
+ function probeSandboxExec(): JailAvailability {
313
+ let scratch: string | undefined;
314
+ try {
315
+ scratch = mkdtempSync(join(realpathOrSelf(tmpdir()), 'celilo-jail-probe-'));
316
+ const allowed = join(scratch, 'allowed');
317
+ const planted = join(scratch, 'planted');
318
+ mkdirSync(join(allowed, 'state'), { recursive: true });
319
+ writeFileSync(planted, SANDBOX_PROBE_MARKER);
320
+
321
+ // The REAL derivation, the REAL renderer, and the REAL shim path, so this
322
+ // clears the same bar the spawn does. Every shortcut here has already been
323
+ // measured to matter: a mount set built around `bun -e` omits the shim's
324
+ // `node_modules` rows, and the runtime then starts under the probe's
325
+ // profile and dies under the executor's.
326
+ const profile = toSandboxProfile(
327
+ deriveMountSet({
328
+ modulePath: allowed,
329
+ stateDir: join(allowed, 'state'),
330
+ socketDir: allowed,
331
+ runtimePath: process.execPath,
332
+ runnerPath: HOOK_RUNNER_PATH,
333
+ runtimeModulePaths: runtimeModulePathsFor(HOOK_RUNNER_PATH),
334
+ pathInputs: [],
335
+ }),
336
+ );
337
+ const run = (cmd: readonly string[]) =>
338
+ spawnSync(SANDBOX_EXEC, ['-p', profile, '--', ...cmd], {
339
+ cwd: allowed,
340
+ encoding: 'utf-8',
341
+ timeout: PROBE_TIMEOUT_MS,
342
+ });
343
+
344
+ // 1. Can the RUNTIME start? Spawn the actual shim with no socket in its
345
+ // environment: it refuses immediately with a known message, having
346
+ // already loaded itself, `@celilo/capabilities` and Zod. That is the
347
+ // whole of the startup path, and it is where a jail that denies the
348
+ // runtime something it needs shows up.
349
+ const started = run([process.execPath, HOOK_RUNNER_PATH]);
350
+ if (started.error) throw started.error;
351
+ if (!started.stderr?.includes(HOOK_SOCKET_ENV)) {
352
+ return {
353
+ backend: 'none',
354
+ reason: `sandbox-exec built a jail the hook runner cannot start in, so hooks run unjailed rather than failing one at a time. The runner exited ${started.status} saying: ${(started.stderr || started.stdout || '').trim().split('\n')[0] || '(nothing)'}`,
355
+ };
356
+ }
357
+
358
+ // 2. Does a DENIAL actually apply? `sandbox-exec` never fails on a rule it
359
+ // cannot match — the access simply succeeds and nothing reports an
360
+ // error (D8, measured). So the profile is not evidence; a file this
361
+ // probe planted outside the mount set, and could not read, is.
362
+ const denied = run([
363
+ process.execPath,
364
+ '-e',
365
+ `try{require('node:fs').readFileSync(${JSON.stringify(planted)});console.log('${SANDBOX_PROBE_MARKER}')}catch{console.log('refused')}`,
366
+ ]);
367
+ if (denied.error) throw denied.error;
368
+ if ((denied.stdout ?? '').includes(SANDBOX_PROBE_MARKER)) {
369
+ return {
370
+ backend: 'none',
371
+ reason:
372
+ 'sandbox-exec ran but did not enforce the profile: a file outside the mount set was still readable. Hooks run unjailed rather than appearing jailed and not being, which is the one outcome worse than no jail. This is usually an unresolved path in a rule — every path must be realpath()d before it is written.',
373
+ };
374
+ }
375
+
376
+ return { backend: 'sandbox-exec' };
377
+ } catch (error) {
378
+ const spawnFailure = (error as { code?: string } | null)?.code;
379
+ if (spawnFailure === 'ENOENT') {
380
+ return {
381
+ backend: 'none',
382
+ reason: `${SANDBOX_EXEC} is not on this macOS, so hooks run unjailed. It has carried a deprecation notice since 10.8; if Apple has removed it, this host has no hook jail backend.`,
383
+ };
384
+ }
385
+ return {
386
+ backend: 'none',
387
+ reason: `sandbox-exec could not run the probe, so hooks run unjailed: ${error instanceof Error ? error.message : String(error)}`,
388
+ };
389
+ } finally {
390
+ if (scratch) rmSync(scratch, { recursive: true, force: true });
391
+ }
392
+ }
393
+
246
394
  /**
247
395
  * Turn the probe's failure into something an operator can act on.
248
396
  *
@@ -467,21 +615,35 @@ export function planJailedSpawn(
467
615
  }
468
616
 
469
617
  // A tmpfs needs no source — bubblewrap creates it — so it is never dropped.
470
- // @psbanka-agent note (ce-29z, 2026-09): with SANDBOX_EXEC_AUTO_JAIL_ENABLED
471
- // still false, the only way to reach this line with a sandbox-exec
472
- // availability is policy 'required', and task 4.8 owns the spawn arm that
473
- // makes that real. Until it lands, do not flip the flag.
618
+ // The note below (ce-29z, 2026-09) is why 'required' is the only policy that
619
+ // reaches the sandbox-exec arm while SANDBOX_EXEC_AUTO_JAIL_ENABLED is
620
+ // false: the flag flip is D14's landing, never 4.8's.
474
621
  const present = set.entries.filter((e) => e.mode === 'tmpfs' || exists(e.path));
475
- const skipped = set.entries.filter((e) => !present.includes(e)).map((e) => e.path);
622
+ const skipped = set.entries.filter((e) => !present.includes(e));
623
+ const applied: MountSet = { ...set, entries: present };
624
+
625
+ // Dropping an absent path is a bubblewrap NEED — it fails the whole jail on a
626
+ // bind with no source — and is merely tidy for `sandbox-exec`, which happily
627
+ // names a path that is not there. Both backends drop the same rows anyway,
628
+ // because a mount set that means two things on two platforms is exactly the
629
+ // drift task 4.7 exists to prevent.
630
+ if (availability.backend === 'sandbox-exec') {
631
+ // `-p` rather than a profile FILE: nothing to create, nothing to clean
632
+ // up, and nothing whose own readability has to be reasoned about.
633
+ // `sandbox-exec` reads the profile before it applies the sandbox, so a
634
+ // file would not have needed to be in the mount set — but it would have
635
+ // needed a lifetime, and this has none.
636
+ return {
637
+ cmd: [SANDBOX_EXEC, '-p', toSandboxProfile(applied), '--', ...cmd],
638
+ mode: 'jailed',
639
+ backend: availability.backend,
640
+ skipped,
641
+ cwd: set.chdir,
642
+ };
643
+ }
476
644
 
477
645
  return {
478
- cmd: [
479
- BWRAP,
480
- ...JAIL_NAMESPACE_ARGS,
481
- ...toBwrapArgs({ ...set, entries: present }),
482
- '--',
483
- ...cmd,
484
- ],
646
+ cmd: [BWRAP, ...JAIL_NAMESPACE_ARGS, ...toBwrapArgs(applied), '--', ...cmd],
485
647
  mode: 'jailed',
486
648
  backend: availability.backend,
487
649
  skipped,