@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.
- package/CELILO_CORE_MODULES.md +1 -1
- package/CELILO_SUBSYSTEMS.md +1 -0
- package/drizzle/0031_module_config_source.sql +20 -0
- package/drizzle/meta/_journal.json +8 -1
- package/package.json +2 -2
- package/schemas/system_config.json +2 -1
- package/src/capabilities/public-web-publish.test.ts +61 -0
- package/src/cli/commands/firewall-interface-list.test.ts +156 -7
- package/src/cli/commands/firewall-interface-list.ts +73 -7
- package/src/cli/commands/machine-add.ts +12 -55
- package/src/cli/commands/module-config.test.ts +20 -1
- package/src/cli/commands/module-import.ts +1 -1
- package/src/cli/commands/module-update.test.ts +82 -0
- package/src/cli/commands/module-update.ts +14 -4
- package/src/cli/commands/monitor.ts +2 -10
- package/src/cli/commands/restore.ts +16 -6
- package/src/cli/generate-zsh-completion.ts +1 -1
- package/src/cli/index.ts +4 -3
- package/src/cli/restore-migration-failure.test.ts +159 -0
- package/src/db/client.ts +5 -0
- package/src/db/migrate.test.ts +61 -135
- package/src/db/migrate.ts +7 -2
- package/src/db/schema.ts +10 -0
- package/src/hooks/broker.test.ts +106 -2
- package/src/hooks/broker.ts +91 -1
- package/src/hooks/capability-loader-firewall.test.ts +37 -0
- package/src/hooks/capability-loader.ts +16 -3
- package/src/hooks/define-hook.test.ts +4 -3
- package/src/hooks/executor.test.ts +19 -18
- package/src/hooks/executor.ts +88 -11
- package/src/hooks/hook-jail-toolchain-reach.test.ts +79 -29
- package/src/hooks/hook-jail-unreachability.test.ts +55 -29
- package/src/hooks/hook-protocol.ts +46 -1
- package/src/hooks/hook-runner.ts +36 -0
- package/src/hooks/hook-store-proxy.test.ts +109 -0
- package/src/hooks/hook-store-proxy.ts +85 -0
- package/src/hooks/hook-store.test.ts +162 -0
- package/src/hooks/hook-store.ts +290 -0
- package/src/hooks/hook-timeout.test.ts +3 -2
- package/src/hooks/hook-trespass.test.ts +94 -14
- package/src/hooks/jail.test.ts +1 -1
- package/src/hooks/jail.ts +194 -32
- package/src/hooks/mount-set.test.ts +296 -1
- package/src/hooks/mount-set.ts +216 -13
- package/src/hooks/run-named-hook.ts +2 -0
- package/src/hooks/test-fixtures/jail-probe-hook.ts +1 -1
- package/src/hooks/test-fixtures/on-restore-staging-hook.ts +26 -0
- package/src/hooks/test-fixtures/store-backed.ts +47 -0
- package/src/hooks/test-fixtures/store-writing-hook.ts +63 -0
- package/src/hooks/unjailed-lint.test.ts +27 -8
- package/src/manifest/schema.ts +1 -0
- package/src/module/packaging/build.ts +70 -2
- package/src/module/web-root.ts +17 -1
- package/src/policy/fixture-capability-coverage.test.ts +322 -0
- package/src/policy/module-script-scan.test.ts +42 -1
- package/src/policy/module-script-scan.ts +275 -5
- package/src/policy/no-hand-built-ssh.test.ts +34 -1
- package/src/policy/no-swallowed-refusal.test.ts +265 -0
- package/src/policy/no-tar-shell-out-in-services.test.ts +43 -0
- package/src/registry/client.test.ts +149 -0
- package/src/registry/client.ts +203 -11
- package/src/services/alerting/coverage-source.test.ts +86 -0
- package/src/services/alerting/coverage-source.ts +11 -1
- package/src/services/alerting/format.test.ts +57 -0
- package/src/services/alerting/format.ts +24 -0
- package/src/services/alerting/run-monitor.ts +2 -2
- package/src/services/backup-create.ts +7 -7
- package/src/services/backup-envelope-roundtrip.test.ts +45 -2
- package/src/services/backup-restore.ts +8 -4
- package/src/services/bus-interview.ts +37 -14
- package/src/services/config-provenance.ts +4 -0
- package/src/services/control-plane-bootstrap.test.ts +297 -0
- package/src/services/control-plane-bootstrap.ts +223 -0
- package/src/services/control-plane-health.test.ts +66 -0
- package/src/services/control-plane-health.ts +67 -0
- package/src/services/deploy-preflight.ts +8 -2
- package/src/services/deploy-validation.test.ts +22 -0
- package/src/services/deploy-validation.ts +8 -0
- package/src/services/deployed-systems.ts +12 -0
- package/src/services/dns-discovery.test.ts +147 -0
- package/src/services/dns-discovery.ts +134 -0
- package/src/services/fleet-checks.ts +6 -2
- package/src/services/fleet-key.test.ts +66 -2
- package/src/services/fleet-key.ts +54 -0
- package/src/services/health-runner.ts +36 -3
- package/src/services/module-config.ts +20 -2
- package/src/services/module-deploy.dns-repoint.test.ts +187 -0
- package/src/services/module-deploy.ts +214 -1
- package/src/services/module-validator/git-hygiene.test.ts +122 -3
- package/src/services/module-validator/git-hygiene.ts +83 -14
- package/src/services/remote-access.test.ts +88 -4
- package/src/services/remote-access.ts +52 -1
- package/src/services/restore-from-file.test.ts +20 -0
- package/src/services/restore-from-file.ts +21 -6
- package/src/services/static-content-converge.test.ts +140 -2
- package/src/services/static-content-converge.ts +55 -8
- package/src/services/system-config-schema-types.ts +1 -1
- package/src/services/system-config-validator.test.ts +36 -0
- package/src/services/system-config-validator.ts +11 -0
- package/src/services/trusted-sources.test.ts +30 -0
- package/src/services/trusted-sources.ts +47 -10
- package/src/templates/generator.ts +9 -2
- 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
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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
|
-
|
|
189
|
-
|
|
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
|
+
);
|
package/src/hooks/jail.test.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
*
|
|
128
|
-
*
|
|
129
|
-
*
|
|
130
|
-
*
|
|
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
|
|
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
|
-
//
|
|
471
|
-
//
|
|
472
|
-
//
|
|
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))
|
|
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,
|