@celilo/cli 2.1.0 → 2.2.1
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/drizzle/0031_module_config_source.sql +20 -0
- package/drizzle/meta/_journal.json +8 -1
- package/package.json +3 -3
- package/schemas/system_config.json +7 -1
- package/src/ansible/inventory.test.ts +2 -1
- package/src/api/sessions.test.ts +2 -1
- package/src/capabilities/public-web-publish.test.ts +61 -0
- package/src/cli/backup-rename.test.ts +2 -1
- package/src/cli/cli.test.ts +2 -1
- package/src/cli/commands/console-get-chain.test.ts +2 -1
- package/src/cli/commands/firewall-interface-list.test.ts +158 -8
- 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 +22 -2
- package/src/cli/commands/module-deploy.ts +8 -2
- package/src/cli/commands/module-generate.test.ts +53 -0
- package/src/cli/commands/module-generate.ts +31 -26
- package/src/cli/commands/module-import-aspect.test.ts +2 -1
- package/src/cli/commands/module-import-registry.test.ts +2 -1
- package/src/cli/commands/module-import.ts +1 -1
- package/src/cli/commands/module-operations.test.ts +2 -1
- package/src/cli/commands/module-publish.test.ts +5 -12
- package/src/cli/commands/module-update.test.ts +87 -4
- package/src/cli/commands/module-update.ts +14 -4
- package/src/cli/commands/module-upgrade.test.ts +15 -0
- package/src/cli/commands/module-upgrade.ts +54 -2
- package/src/cli/commands/module-verify.test.ts +2 -3
- package/src/cli/commands/module-verify.ts +0 -1
- package/src/cli/commands/monitor.ts +2 -10
- package/src/cli/commands/notify-config.test.ts +5 -3
- package/src/cli/commands/registry-owner.test.ts +2 -1
- package/src/cli/commands/registry-token.test.ts +2 -1
- package/src/cli/commands/restore.ts +16 -6
- package/src/cli/commands/system-apply-config-equivalence.test.ts +5 -7
- package/src/cli/commands/system-config.test.ts +148 -0
- package/src/cli/commands/system-config.ts +26 -1
- package/src/cli/commands/system-doctor.test.ts +71 -0
- package/src/cli/commands/system-doctor.ts +110 -24
- package/src/cli/commands/system-init-deprecation.test.ts +6 -3
- package/src/cli/commands/system-migrate.test.ts +2 -1
- package/src/cli/generate-zsh-completion.ts +1 -1
- package/src/cli/index.ts +6 -4
- package/src/cli/restore-command.test.ts +2 -1
- package/src/cli/restore-migration-failure.test.ts +160 -0
- package/src/config/paths.test.ts +3 -3
- package/src/db/client.ts +5 -0
- package/src/db/migrate.test.ts +62 -135
- package/src/db/migrate.ts +17 -12
- 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.test.ts +78 -0
- package/src/hooks/capability-loader.ts +52 -3
- package/src/hooks/define-hook.test.ts +4 -3
- package/src/hooks/executor.test.ts +106 -19
- package/src/hooks/executor.ts +120 -13
- package/src/hooks/hook-jail-toolchain-reach.test.ts +3 -2
- package/src/hooks/hook-jail-unreachability.test.ts +4 -3
- 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 +168 -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 +39 -5
- package/src/hooks/jail.test.ts +62 -3
- package/src/hooks/jail.ts +67 -8
- package/src/hooks/mount-set.test.ts +208 -0
- package/src/hooks/mount-set.ts +62 -14
- 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 +22 -6
- package/src/manifest/schema.ts +1 -0
- package/src/module/packaging/audit.ts +9 -26
- package/src/module/packaging/build-paths.test.ts +127 -0
- package/src/module/packaging/build-paths.ts +175 -0
- package/src/module/packaging/build.test.ts +71 -1
- package/src/module/packaging/build.ts +130 -2
- package/src/module/packaging/extract.ts +1 -5
- 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 +235 -5
- package/src/policy/no-hand-built-ssh.test.ts +34 -1
- package/src/policy/no-swallowed-refusal.test.ts +264 -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/ack.test.ts +2 -1
- package/src/services/alerting/cadence-migration.test.ts +3 -2
- package/src/services/alerting/coverage-source.test.ts +2 -1
- package/src/services/alerting/deferral.test.ts +2 -1
- package/src/services/alerting/delivery-loop.test.ts +2 -1
- package/src/services/alerting/deploy-hooks.test.ts +2 -1
- package/src/services/alerting/format.test.ts +57 -0
- package/src/services/alerting/format.ts +24 -0
- package/src/services/alerting/inbound-poller.test.ts +2 -1
- package/src/services/alerting/inbound.test.ts +2 -1
- package/src/services/alerting/notification-responder.test.ts +2 -1
- package/src/services/alerting/run-monitor.test.ts +2 -1
- package/src/services/alerting/run-monitor.ts +2 -2
- package/src/services/alerting/store.test.ts +2 -1
- package/src/services/alerting/sweep-runner.test.ts +2 -1
- package/src/services/alerting/tokens.test.ts +2 -1
- package/src/services/aspect-approvals.test.ts +2 -1
- package/src/services/aspect-reconcile.test.ts +4 -3
- package/src/services/aspect-runner.test.ts +2 -1
- package/src/services/audit/module-integrity.test.ts +0 -21
- package/src/services/audit/module-integrity.ts +0 -14
- package/src/services/backup-age-agreement.test.ts +2 -1
- package/src/services/backup-create.ts +7 -7
- package/src/services/backup-envelope-roundtrip.test.ts +47 -3
- package/src/services/backup-in-flight-refusal.test.ts +2 -1
- package/src/services/backup-restore.ts +8 -4
- package/src/services/bus-ensure-flow.test.ts +2 -1
- package/src/services/bus-interview-park.test.ts +2 -1
- package/src/services/bus-interview.ts +37 -14
- package/src/services/bus-secret-flow.test.ts +2 -1
- package/src/services/capability-table-rows.test.ts +2 -1
- package/src/services/config-provenance.ts +4 -0
- package/src/services/consumer-cleanup.test.ts +3 -2
- package/src/services/container-service.test.ts +2 -1
- package/src/services/control-plane-bootstrap.test.ts +123 -2
- package/src/services/control-plane-bootstrap.ts +51 -4
- package/src/services/cross-module-read.test.ts +2 -1
- package/src/services/deploy-preflight.ts +8 -2
- package/src/services/deploy-validation.test.ts +25 -2
- package/src/services/deploy-validation.ts +8 -0
- package/src/services/dns-discovery.test.ts +54 -0
- package/src/services/dns-discovery.ts +47 -5
- package/src/services/dns-internal-records.test.ts +3 -2
- package/src/services/dns-provider-backfill.test.ts +2 -1
- package/src/services/dns-registrations.test.ts +2 -1
- package/src/services/ensure-interview.test.ts +3 -2
- package/src/services/fleet-checks.test.ts +3 -2
- package/src/services/fleet-key.test.ts +68 -3
- package/src/services/fleet-key.ts +54 -0
- package/src/services/health-runner.ts +2 -0
- package/src/services/infrastructure-selector.test.ts +2 -1
- package/src/services/infrastructure-variable-resolver.test.ts +2 -1
- package/src/services/machine-pool.test.ts +2 -1
- package/src/services/module-config.test.ts +2 -1
- package/src/services/module-config.ts +20 -2
- package/src/services/module-deploy.dns-repoint.test.ts +188 -0
- package/src/services/module-deploy.ts +199 -21
- package/src/services/module-operations.test.ts +2 -1
- package/src/services/module-subscriptions.test.ts +2 -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/port-forwards.test.ts +2 -1
- package/src/services/programmatic-responder.aspect.test.ts +2 -1
- package/src/services/proxmox-reconcile.test.ts +2 -1
- package/src/services/restore-from-file.test.ts +23 -2
- package/src/services/restore-from-file.ts +21 -6
- package/src/services/restore-preflight.test.ts +2 -1
- package/src/services/secret-schema-loader.test.ts +2 -1
- package/src/services/ssh-key-manager.test.ts +2 -1
- package/src/services/static-content-converge.test.ts +144 -5
- package/src/services/static-content-converge.ts +87 -17
- 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/system-state-stage.test.ts +2 -1
- package/src/services/trusted-sources.test.ts +33 -2
- package/src/services/trusted-sources.ts +47 -10
- package/src/services/zone-detector.test.ts +2 -1
- package/src/templates/generator.ts +9 -2
- package/src/test-utils/bus-responder.ts +5 -3
- package/src/test-utils/db-path.ts +25 -0
- package/src/test-utils/integration.ts +7 -0
- package/src/test-utils/module-fixtures.ts +5 -6
- package/src/variables/context.ts +16 -5
- package/src/module/packaging/generated-plane.test.ts +0 -79
- package/src/module/packaging/generated-plane.ts +0 -134
|
@@ -31,9 +31,11 @@ import {
|
|
|
31
31
|
listUnpublishedWorkspacePackages,
|
|
32
32
|
planFallbacks,
|
|
33
33
|
} from '../../../../scripts/workspace-fallback';
|
|
34
|
-
import { executeHookScript, hookChildEnv } from './executor';
|
|
34
|
+
import { CLI_BIN_DIR, childPath, executeHookScript, hookChildEnv } from './executor';
|
|
35
|
+
import { HOOK_PROTOCOL_VERSION } from './hook-protocol';
|
|
35
36
|
import { detectJailBackend, jailPolicy } from './jail';
|
|
36
37
|
import { createCapturingLogger } from './logger';
|
|
38
|
+
import { configStore, secretStore } from './test-fixtures/store-backed';
|
|
37
39
|
import type { HookContext } from './types';
|
|
38
40
|
|
|
39
41
|
const TRESPASS_SCRIPT = resolve(
|
|
@@ -154,8 +156,8 @@ afterAll(() => {
|
|
|
154
156
|
async function runTrespass(jail = false): Promise<{ report: TrespassReport; lines: string[] }> {
|
|
155
157
|
const { logger, messages } = createCapturingLogger();
|
|
156
158
|
const context: HookContext = {
|
|
157
|
-
config: { sibling_module_id: 'hello-foo', other_system_ip: '' },
|
|
158
|
-
secrets:
|
|
159
|
+
config: configStore({ sibling_module_id: 'hello-foo', other_system_ip: '' }),
|
|
160
|
+
secrets: secretStore(),
|
|
159
161
|
systems: [],
|
|
160
162
|
logger,
|
|
161
163
|
debug: false,
|
|
@@ -200,8 +202,11 @@ describe('the child environment is an allow-list', () => {
|
|
|
200
202
|
|
|
201
203
|
expect(env.CELILO_HOOK_SOCKET).toBe('/tmp/celilo-hook-x/s');
|
|
202
204
|
expect(env.CELILO_HOOK_REMOTE_SOCKET).toBe('/tmp/celilo-hook-x/r');
|
|
203
|
-
|
|
204
|
-
|
|
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));
|
|
205
210
|
expect(env.HOME).toBe(process.env.HOME as string);
|
|
206
211
|
expect(env.CELILO_TEST_SECRET).toBeUndefined();
|
|
207
212
|
|
|
@@ -237,6 +242,35 @@ describe('the child environment is an allow-list', () => {
|
|
|
237
242
|
if (saved !== undefined) process.env.TZ = saved;
|
|
238
243
|
}
|
|
239
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 CLI bin and runtime directories so the child reaches the parent CLI', () => {
|
|
250
|
+
const child = childPath('/usr/local/bin:/usr/bin');
|
|
251
|
+
expect(child.startsWith(`${CLI_BIN_DIR}:${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 directories unchanged', () => {
|
|
256
|
+
const already = `${CLI_BIN_DIR}:${dirname(process.execPath)}:/usr/bin`;
|
|
257
|
+
expect(childPath(already)).toBe(already);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
test('substitutes both directories when the parent has no PATH at all', () => {
|
|
261
|
+
expect(childPath(undefined)).toBe(`${CLI_BIN_DIR}:${dirname(process.execPath)}`);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
// The fleet gate (ce-y0fd): the .deb layout runs the CLI through
|
|
265
|
+
// $CELILO_HOME/node_modules/@celilo/cli/bin/celilo, so the directory beside
|
|
266
|
+
// process.execPath holds only bun and holds no celilo. A PATH that resolves
|
|
267
|
+
// `celilo` must therefore come from the CLI package's own bin directory —
|
|
268
|
+
// and that directory must really contain the bin, or every prepended PATH
|
|
269
|
+
// entry is decoration over the same accident celilo#1300 recorded.
|
|
270
|
+
test('prepends a CLI bin directory that actually contains the celilo bin', () => {
|
|
271
|
+
expect(existsSync(join(CLI_BIN_DIR, 'celilo'))).toBe(true);
|
|
272
|
+
});
|
|
273
|
+
});
|
|
240
274
|
});
|
|
241
275
|
|
|
242
276
|
describe('hook process boundary — hello-trespass gate', () => {
|
package/src/hooks/jail.test.ts
CHANGED
|
@@ -27,6 +27,7 @@ import {
|
|
|
27
27
|
readJailMode,
|
|
28
28
|
realpathRequest,
|
|
29
29
|
recordJailMode,
|
|
30
|
+
resolveJailPolicy,
|
|
30
31
|
runtimeModulePathsFor,
|
|
31
32
|
} from './jail';
|
|
32
33
|
import { deriveMountSet } from './mount-set';
|
|
@@ -137,7 +138,7 @@ describe('a mount whose source is missing is dropped, not fatal', () => {
|
|
|
137
138
|
'auto',
|
|
138
139
|
exists,
|
|
139
140
|
);
|
|
140
|
-
expect(skipped).toContain(missing);
|
|
141
|
+
expect(skipped.map((m) => m.path)).toContain(missing);
|
|
141
142
|
expect(cmd.join(' ')).not.toContain(missing);
|
|
142
143
|
});
|
|
143
144
|
|
|
@@ -272,8 +273,12 @@ describe('jailPolicy reads the operator’s switch', () => {
|
|
|
272
273
|
}
|
|
273
274
|
};
|
|
274
275
|
|
|
275
|
-
test('unset is
|
|
276
|
-
expect(withEnv(undefined, jailPolicy)).toBe('
|
|
276
|
+
test('unset is off (peba, ce-rez7)', () => {
|
|
277
|
+
expect(withEnv(undefined, jailPolicy)).toBe('off');
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
test('an explicit auto still means auto', () => {
|
|
281
|
+
expect(withEnv('auto', jailPolicy)).toBe('auto');
|
|
277
282
|
});
|
|
278
283
|
|
|
279
284
|
test('a typo is refused rather than silently meaning auto', () => {
|
|
@@ -284,6 +289,60 @@ describe('jailPolicy reads the operator’s switch', () => {
|
|
|
284
289
|
});
|
|
285
290
|
});
|
|
286
291
|
|
|
292
|
+
describe('resolveJailPolicy applies D2 precedence (hook-jail-config-surface)', () => {
|
|
293
|
+
test('an env value set and non-empty wins over stored config', () => {
|
|
294
|
+
expect(resolveJailPolicy('required', 'off')).toEqual({ policy: 'required', source: 'env' });
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
test('an EMPTY STRING env value counts as unset and falls through to config', () => {
|
|
298
|
+
// jailPolicy() already treats '' as unset, so the resolver agrees rather
|
|
299
|
+
// than inventing a second rule for what counts as set.
|
|
300
|
+
expect(resolveJailPolicy('', 'off')).toEqual({ policy: 'off', source: 'config' });
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
test('stored config wins when the env var is absent', () => {
|
|
304
|
+
expect(resolveJailPolicy(undefined, 'off')).toEqual({ policy: 'off', source: 'config' });
|
|
305
|
+
expect(resolveJailPolicy(undefined, 'required')).toEqual({
|
|
306
|
+
policy: 'required',
|
|
307
|
+
source: 'config',
|
|
308
|
+
});
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
test('both absent resolves to off from the default (peba, ce-rez7)', () => {
|
|
312
|
+
expect(resolveJailPolicy(undefined, undefined)).toEqual({ policy: 'off', source: 'default' });
|
|
313
|
+
expect(resolveJailPolicy('', undefined)).toEqual({ policy: 'off', source: 'default' });
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
test('an explicit env auto still wins (peba, ce-rez7)', () => {
|
|
317
|
+
expect(resolveJailPolicy('auto', 'off')).toEqual({ policy: 'auto', source: 'env' });
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
test('a stored empty jail_policy THROWS, it does not resolve to auto (peba, ce-8832)', () => {
|
|
321
|
+
// The set-time pattern ^(auto|off|required)$ rejects '', so a stored one
|
|
322
|
+
// can only arrive via a restore from file or a hand edit to the DB — the
|
|
323
|
+
// untrusted paths D3 exists to catch. It is outside the enum and fails
|
|
324
|
+
// closed like any other bad stored value.
|
|
325
|
+
expect(() => resolveJailPolicy(undefined, '')).toThrow(/is not a hook jail policy/);
|
|
326
|
+
expect(() => resolveJailPolicy(undefined, '')).toThrow(/hooks\.jail_policy=''/);
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
test('a typo in the STORED row is refused, not silently treated as auto (D3)', () => {
|
|
330
|
+
// The DB row is a trust boundary like any other (Rule 3.7): restores carry
|
|
331
|
+
// foreign state, and a typo that silently meant `auto` reads as an armed
|
|
332
|
+
// jail when nothing is armed. The config path is no laxer than the env
|
|
333
|
+
// path.
|
|
334
|
+
expect(() => resolveJailPolicy(undefined, 'alwayssafe')).toThrow(/is not a hook jail policy/);
|
|
335
|
+
expect(() => resolveJailPolicy(undefined, 'alwayssafe')).toThrow(
|
|
336
|
+
/hooks\.jail_policy='alwayssafe'/,
|
|
337
|
+
);
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
test('a typo in the env value is still refused (unchanged jailPolicy behaviour)', () => {
|
|
341
|
+
expect(() => resolveJailPolicy('requried', undefined)).toThrow(/is not a hook jail policy/);
|
|
342
|
+
expect(() => resolveJailPolicy('requried', 'off')).toThrow(/CELILO_HOOK_JAIL='requried'/);
|
|
343
|
+
});
|
|
344
|
+
});
|
|
345
|
+
|
|
287
346
|
describe('realpath at the caller (task 4.2k)', () => {
|
|
288
347
|
test('a symlinked module root is resolved before it becomes a bind', () => {
|
|
289
348
|
// `mod.sourcePath` comes out of the database, and a database restored from
|
package/src/hooks/jail.ts
CHANGED
|
@@ -40,6 +40,7 @@ import { dirname, join } from 'node:path';
|
|
|
40
40
|
import { getDataDir } from '../config/paths';
|
|
41
41
|
import { HOOK_SOCKET_ENV } from './hook-protocol';
|
|
42
42
|
import {
|
|
43
|
+
type MountEntry,
|
|
43
44
|
type MountSet,
|
|
44
45
|
type MountSetRequest,
|
|
45
46
|
deriveMountSet,
|
|
@@ -132,13 +133,19 @@ export interface JailPlan {
|
|
|
132
133
|
*
|
|
133
134
|
* bubblewrap fails the whole jail on a bind whose SOURCE is missing, and
|
|
134
135
|
* several rows are legitimately absent: `/lib64` does not exist on arm64,
|
|
135
|
-
* and `<module>/generated` only appears once celilo has generated
|
|
136
|
-
*
|
|
137
|
-
*
|
|
138
|
-
*
|
|
139
|
-
*
|
|
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.
|
|
140
147
|
*/
|
|
141
|
-
readonly skipped: readonly
|
|
148
|
+
readonly skipped: readonly MountEntry[];
|
|
142
149
|
/**
|
|
143
150
|
* The directory to spawn the child in, when the backend cannot set it itself.
|
|
144
151
|
*
|
|
@@ -411,16 +418,68 @@ function unavailableReason(error: unknown): string {
|
|
|
411
418
|
* `CELILO_HOOK_JAIL=requried` that silently meant `auto` would read as the
|
|
412
419
|
* jail being enforced when it is not, which is the one mistake this variable
|
|
413
420
|
* exists to prevent.
|
|
421
|
+
*
|
|
422
|
+
* The unset/empty default is `off` (peba's ruling on ce-rez7): jailing must
|
|
423
|
+
* begin because someone set a switch, not because a package upgrade installed
|
|
424
|
+
* a backend. Turning the jail ON is a deliberate act.
|
|
414
425
|
*/
|
|
415
426
|
export function jailPolicy(): JailPolicy {
|
|
416
427
|
const raw = process.env.CELILO_HOOK_JAIL;
|
|
417
|
-
if (raw === undefined || raw === '') return '
|
|
428
|
+
if (raw === undefined || raw === '') return 'off';
|
|
418
429
|
if (raw === 'auto' || raw === 'off' || raw === 'required') return raw;
|
|
419
430
|
throw new Error(
|
|
420
431
|
`CELILO_HOOK_JAIL='${raw}' is not a hook jail policy. Use 'auto' (jail when a backend is available), 'required' (an unavailable jail is a hard failure), or 'off'.`,
|
|
421
432
|
);
|
|
422
433
|
}
|
|
423
434
|
|
|
435
|
+
/** Where the effective jail policy came from. Rendered by `system doctor` (D4). */
|
|
436
|
+
export type JailPolicySource = 'env' | 'config' | 'default';
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* Apply D2's precedence to the two sources: env wins, then stored config,
|
|
440
|
+
* then the default, which is `off` (peba's ruling on ce-rez7; see
|
|
441
|
+
* openspec/changes/hook-jail-config-surface/design.md for the reversal
|
|
442
|
+
* trigger).
|
|
443
|
+
*
|
|
444
|
+
* Pure, so both consumers (executor.ts and system-doctor.ts) can fetch the
|
|
445
|
+
* stored row themselves and stay testable without a database.
|
|
446
|
+
*
|
|
447
|
+
* The empty-string env value is treated as unset, matching `jailPolicy()`'s
|
|
448
|
+
* own handling — one rule for "what counts as set", not a second one here.
|
|
449
|
+
*
|
|
450
|
+
* The config path is deliberately stricter than the env path: a stored
|
|
451
|
+
* empty string THROWS (peba's ruling on ce-8832). The set-time pattern
|
|
452
|
+
* `^(auto|off|required)$` rejects `''`, so a stored one can only arrive via
|
|
453
|
+
* a restore from file or a hand edit to the DB — exactly the untrusted
|
|
454
|
+
* paths D3 exists to catch. Resolving it to a silent `auto` would let
|
|
455
|
+
* foreign state pick the jailing default with nobody told, and failing
|
|
456
|
+
* closed is the whole point. An empty CELILO_HOOK_JAIL stays "not set",
|
|
457
|
+
* because `jailPolicy()` relies on that and slice 1's safety rests on the
|
|
458
|
+
* two resolvers agreeing.
|
|
459
|
+
*/
|
|
460
|
+
export function resolveJailPolicy(
|
|
461
|
+
envValue: string | undefined,
|
|
462
|
+
configValue: string | undefined,
|
|
463
|
+
): { policy: JailPolicy; source: JailPolicySource } {
|
|
464
|
+
if (envValue !== undefined && envValue !== '') {
|
|
465
|
+
if (envValue === 'auto' || envValue === 'off' || envValue === 'required') {
|
|
466
|
+
return { policy: envValue, source: 'env' };
|
|
467
|
+
}
|
|
468
|
+
throw new Error(
|
|
469
|
+
`CELILO_HOOK_JAIL='${envValue}' is not a hook jail policy. Use 'auto' (jail when a backend is available), 'required' (an unavailable jail is a hard failure), or 'off'.`,
|
|
470
|
+
);
|
|
471
|
+
}
|
|
472
|
+
if (configValue !== undefined) {
|
|
473
|
+
if (configValue === 'auto' || configValue === 'off' || configValue === 'required') {
|
|
474
|
+
return { policy: configValue, source: 'config' };
|
|
475
|
+
}
|
|
476
|
+
throw new Error(
|
|
477
|
+
`hooks.jail_policy='${configValue}' is not a hook jail policy. Use 'auto' (jail when a backend is available), 'required' (an unavailable jail is a hard failure), or 'off'.`,
|
|
478
|
+
);
|
|
479
|
+
}
|
|
480
|
+
return { policy: 'off', source: 'default' };
|
|
481
|
+
}
|
|
482
|
+
|
|
424
483
|
/**
|
|
425
484
|
* Resolve every path in a mount-set request through the filesystem (task 4.2k).
|
|
426
485
|
*
|
|
@@ -612,7 +671,7 @@ export function planJailedSpawn(
|
|
|
612
671
|
// reaches the sandbox-exec arm while SANDBOX_EXEC_AUTO_JAIL_ENABLED is
|
|
613
672
|
// false: the flag flip is D14's landing, never 4.8's.
|
|
614
673
|
const present = set.entries.filter((e) => e.mode === 'tmpfs' || exists(e.path));
|
|
615
|
-
const skipped = set.entries.filter((e) => !present.includes(e))
|
|
674
|
+
const skipped = set.entries.filter((e) => !present.includes(e));
|
|
616
675
|
const applied: MountSet = { ...set, entries: present };
|
|
617
676
|
|
|
618
677
|
// Dropping an absent path is a bubblewrap NEED — it fails the whole jail on a
|
|
@@ -7,7 +7,11 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { describe, expect, test } from 'bun:test';
|
|
10
|
+
import { mkdirSync, mkdtempSync, rmSync } from 'node:fs';
|
|
11
|
+
import { tmpdir } from 'node:os';
|
|
12
|
+
import { join, resolve } from 'node:path';
|
|
10
13
|
import { BROWSER_ROOT } from '@celilo/capabilities';
|
|
14
|
+
import { planJailedSpawn, realpathRequest, runtimeModulePathsFor } from './jail';
|
|
11
15
|
import {
|
|
12
16
|
deriveMountSet,
|
|
13
17
|
forbiddenPaths,
|
|
@@ -104,6 +108,27 @@ describe('ordering is semantic', () => {
|
|
|
104
108
|
});
|
|
105
109
|
});
|
|
106
110
|
|
|
111
|
+
describe('a hook write to the module tree is refused at mount time (celilo#1265)', () => {
|
|
112
|
+
// The recurrence gate for celilo#1265: hello-private-foo failed at deploy
|
|
113
|
+
// because its hook wrote ca.crt into site/dist, which the jail binds only
|
|
114
|
+
// through the module tree's read-only row. The sanctioned writable directory
|
|
115
|
+
// is `<module>/state` (celilo#1000). This pins the DERIVED mount set, with
|
|
116
|
+
// no docker and no bubblewrap: no row may make the built web root writable,
|
|
117
|
+
// and the state dir must stay writable. If a carve-out for site/dist ever
|
|
118
|
+
// appears here, this fails before a fixture can fail at deploy again.
|
|
119
|
+
test('no entry makes site/dist writable; state stays writable', () => {
|
|
120
|
+
const set = deriveMountSet(BASE);
|
|
121
|
+
const webRoot = `${BASE.modulePath}/site/dist`;
|
|
122
|
+
const writableCovering = set.entries.filter(
|
|
123
|
+
(e) => e.mode === 'rw' && (e.path === webRoot || webRoot.startsWith(`${e.path}/`)),
|
|
124
|
+
);
|
|
125
|
+
expect(writableCovering).toEqual([]);
|
|
126
|
+
|
|
127
|
+
const state = set.entries.find((e) => e.path === BASE.stateDir);
|
|
128
|
+
expect(state?.mode).toBe('rw');
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
107
132
|
describe('contract inputs are bound at their declared access', () => {
|
|
108
133
|
test("'write' is read-write and 'read' is read-only", () => {
|
|
109
134
|
const set = deriveMountSet({
|
|
@@ -255,3 +280,186 @@ describe('name resolution inside the jail (celilo#1225)', () => {
|
|
|
255
280
|
expect(set.entries.some((e) => e.path === '/etc/shadow')).toBe(false);
|
|
256
281
|
});
|
|
257
282
|
});
|
|
283
|
+
|
|
284
|
+
describe('every row states what its own absence means (celilo#1244 family)', () => {
|
|
285
|
+
const rowFor = (set: ReturnType<typeof deriveMountSet>, path: string) =>
|
|
286
|
+
set.entries.find((e) => e.path === path);
|
|
287
|
+
|
|
288
|
+
test('a contract path input is REQUIRED, so dropping it can never be a warning', () => {
|
|
289
|
+
// The case the old undifferentiated warning buried. A hook whose declared
|
|
290
|
+
// write path is dropped writes into the run's private tmpfs and reports
|
|
291
|
+
// success over a directory discarded when it exits.
|
|
292
|
+
const set = deriveMountSet({
|
|
293
|
+
...BASE,
|
|
294
|
+
pathInputs: [{ name: 'backup_dir', value: '/srv/staged', access: 'write' }],
|
|
295
|
+
});
|
|
296
|
+
expect(rowFor(set, '/srv/staged')?.absence).toBe('required');
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
test('a runtime support directory is RUNTIME, so its absence is not reported', () => {
|
|
300
|
+
// /lib64 does not exist on arm64 and never will. A genuinely needed one
|
|
301
|
+
// fails the runtime, which is a louder and more specific signal than a
|
|
302
|
+
// warning that fired 96 times in one `cele2e run --all`.
|
|
303
|
+
const set = deriveMountSet(BASE);
|
|
304
|
+
expect(rowFor(set, '/lib64')?.absence).toBe('runtime');
|
|
305
|
+
expect(rowFor(set, '/usr/lib')?.absence).toBe('runtime');
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
test("the module's own tree and its state dir are REQUIRED", () => {
|
|
309
|
+
// Neither is ever legitimately absent: executor.ts mkdirSyncs the state dir
|
|
310
|
+
// before deriving the set, so an absent one means something is wrong that a
|
|
311
|
+
// dropped mount would only make more confusing later.
|
|
312
|
+
const set = deriveMountSet(BASE);
|
|
313
|
+
expect(rowFor(set, BASE.modulePath)?.absence).toBe('required');
|
|
314
|
+
expect(rowFor(set, BASE.stateDir)?.absence).toBe('required');
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
test('generated output is CONDITIONAL, because it appears only after generation', () => {
|
|
318
|
+
const set = deriveMountSet(BASE);
|
|
319
|
+
expect(rowFor(set, `${BASE.modulePath}/generated`)?.absence).toBe('conditional');
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
test('every row carries a policy, so a new mount cannot inherit a default', () => {
|
|
323
|
+
const set = deriveMountSet({
|
|
324
|
+
...BASE,
|
|
325
|
+
pathInputs: [{ name: 'cert', value: '/srv/certs', access: 'read' }],
|
|
326
|
+
});
|
|
327
|
+
for (const e of set.entries) {
|
|
328
|
+
expect(e.absence).toBeDefined();
|
|
329
|
+
}
|
|
330
|
+
});
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
describe('a jailed hook cannot spawn /bin/sh, by derivation (e2e-recovery lane B)', () => {
|
|
334
|
+
// The full derivation lives in openspec/changes/e2e-suite-recovery/
|
|
335
|
+
// jail-shell-derivation.md. The short form: the derivation names NO system
|
|
336
|
+
// executable directory. It binds the interpreter as one file, the runner
|
|
337
|
+
// shim's directory, celilo's own node_modules, the module tree, the broker
|
|
338
|
+
// socket, the declared inputs, and library/resolver support dirs
|
|
339
|
+
// (/usr/lib, /lib, /lib64, /etc/ssl, the three resolver files). /bin and
|
|
340
|
+
// /usr/bin are in none of those families, so a shell is ABSENT — bubblewrap
|
|
341
|
+
// builds a namespace where the lookup dies with ENOENT, and sandbox-exec
|
|
342
|
+
// denies the read under deny-default (EPERM) — which is the measured
|
|
343
|
+
// `ENOENT: no such file or directory, posix_spawn '/bin/sh'` from
|
|
344
|
+
// e2e-suite-recovery's proposal.md.
|
|
345
|
+
//
|
|
346
|
+
// The assertion is absence of COVERAGE, never a specific errno, for the same
|
|
347
|
+
// reason hook-jail-unreachability.test.ts refuses to match on one: ENOENT
|
|
348
|
+
// and EPERM are both the jail working, and a test pinned to one goes red on
|
|
349
|
+
// the other platform for a jail that was working perfectly.
|
|
350
|
+
//
|
|
351
|
+
// These are tests over the DERIVED set, computed against a real mirrored
|
|
352
|
+
// layout with the real code path (realpathRequest -> runtimeModulePathsFor
|
|
353
|
+
// -> deriveMountSet -> planJailedSpawn), not against hand-built arguments:
|
|
354
|
+
// a hand-built set proves what the author believed, not what the jail does.
|
|
355
|
+
|
|
356
|
+
const EXECUTABLE_DIRS = ['/bin', '/usr/bin', '/sbin', '/usr/sbin', '/usr/local/bin'];
|
|
357
|
+
const SHELL = '/bin/sh';
|
|
358
|
+
|
|
359
|
+
/** Mirror a real module layout into a temp dir and derive through the real path. */
|
|
360
|
+
function derivedForMirroredModule() {
|
|
361
|
+
const scratch = mkdtempSync(join(tmpdir(), 'celilo-laneb-'));
|
|
362
|
+
const modulePath = join(scratch, 'modules', 'caddy');
|
|
363
|
+
const stateDir = join(modulePath, 'state');
|
|
364
|
+
const screenshotDir = join(modulePath, 'screenshots', 'run1');
|
|
365
|
+
mkdirSync(join(modulePath, 'generated'), { recursive: true });
|
|
366
|
+
mkdirSync(stateDir, { recursive: true });
|
|
367
|
+
mkdirSync(screenshotDir, { recursive: true });
|
|
368
|
+
const socketDir = mkdtempSync(join(tmpdir(), 'celilo-laneb-sock-'));
|
|
369
|
+
const stagedInput = mkdtempSync(join(tmpdir(), 'celilo-laneb-stage-'));
|
|
370
|
+
const runnerPath = resolve(__dirname, 'hook-runner.ts');
|
|
371
|
+
|
|
372
|
+
const request = realpathRequest({
|
|
373
|
+
modulePath,
|
|
374
|
+
stateDir,
|
|
375
|
+
screenshotDir,
|
|
376
|
+
socketDir,
|
|
377
|
+
runtimePath: process.execPath,
|
|
378
|
+
runnerPath,
|
|
379
|
+
runtimeModulePaths: runtimeModulePathsFor(runnerPath),
|
|
380
|
+
pathInputs: [{ name: 'backup_dir', value: join(stagedInput, 'data'), access: 'write' }],
|
|
381
|
+
});
|
|
382
|
+
const set = deriveMountSet(request);
|
|
383
|
+
return {
|
|
384
|
+
set,
|
|
385
|
+
cleanup: () => {
|
|
386
|
+
rmSync(scratch, { recursive: true, force: true });
|
|
387
|
+
rmSync(socketDir, { recursive: true, force: true });
|
|
388
|
+
rmSync(stagedInput, { recursive: true, force: true });
|
|
389
|
+
},
|
|
390
|
+
};
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
test('nothing in the jail covers /bin/sh, on any backend', () => {
|
|
394
|
+
const { set, cleanup } = derivedForMirroredModule();
|
|
395
|
+
try {
|
|
396
|
+
// A row covers /bin/sh only if it IS the shell or an ancestor of it.
|
|
397
|
+
// No entry is: nothing mounts /bin, and / is never a row (its grant, on
|
|
398
|
+
// macOS, is a literal on the node alone, which confers nothing on
|
|
399
|
+
// contents — pinned separately below).
|
|
400
|
+
const covering = set.entries.filter(
|
|
401
|
+
(e) => e.path === SHELL || SHELL.startsWith(`${e.path}/`),
|
|
402
|
+
);
|
|
403
|
+
expect(covering).toEqual([]);
|
|
404
|
+
} finally {
|
|
405
|
+
cleanup();
|
|
406
|
+
}
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
test('no system executable directory is a row, and the interpreter is the one exception', () => {
|
|
410
|
+
const { set, cleanup } = derivedForMirroredModule();
|
|
411
|
+
try {
|
|
412
|
+
const paths = set.entries.map((e) => e.path);
|
|
413
|
+
expect(paths.filter((p) => EXECUTABLE_DIRS.includes(p))).toEqual([]);
|
|
414
|
+
|
|
415
|
+
// A file bind under an executable directory (the interpreter can live in
|
|
416
|
+
// /usr/local/bin) is legitimate ONLY as the single runtime row. Any
|
|
417
|
+
// wider row there would drag the directory's other contents in.
|
|
418
|
+
for (const e of set.entries) {
|
|
419
|
+
const dir = EXECUTABLE_DIRS.find((d) => e.path.startsWith(`${d}/`));
|
|
420
|
+
if (dir) expect(e.path).toBe(process.execPath);
|
|
421
|
+
}
|
|
422
|
+
} finally {
|
|
423
|
+
cleanup();
|
|
424
|
+
}
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
test('the rendered bubblewrap command exposes no shell path either', () => {
|
|
428
|
+
// Measure reach on the artifact the kernel actually receives, not on the
|
|
429
|
+
// intermediate set: planJailedSpawn filters absent sources and renders the
|
|
430
|
+
// argv, and that argv is the last thing a future edit could corrupt.
|
|
431
|
+
const { set, cleanup } = derivedForMirroredModule();
|
|
432
|
+
try {
|
|
433
|
+
const plan = planJailedSpawn(
|
|
434
|
+
[process.execPath, resolve(__dirname, 'hook-runner.ts')],
|
|
435
|
+
set,
|
|
436
|
+
{ backend: 'bubblewrap' },
|
|
437
|
+
'required',
|
|
438
|
+
);
|
|
439
|
+
expect(plan.mode).toBe('jailed');
|
|
440
|
+
expect(plan.cmd).not.toContain(SHELL);
|
|
441
|
+
expect(plan.cmd).not.toContain('/bin');
|
|
442
|
+
} finally {
|
|
443
|
+
cleanup();
|
|
444
|
+
}
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
test('the sandbox profile grants no read on /bin, as subpath or as literal', () => {
|
|
448
|
+
const { set, cleanup } = derivedForMirroredModule();
|
|
449
|
+
try {
|
|
450
|
+
const profile = toSandboxProfile(set);
|
|
451
|
+
// A subpath grant on /bin (or any executable directory) would make the
|
|
452
|
+
// shell readable, and (allow process*) does the rest — exec is not
|
|
453
|
+
// restricted; what the hook can READ is the boundary in both backends.
|
|
454
|
+
for (const dir of EXECUTABLE_DIRS) {
|
|
455
|
+
expect(profile).not.toContain(`(subpath "${dir}")`);
|
|
456
|
+
}
|
|
457
|
+
// /bin must not even appear as an ancestor literal: a literal permits
|
|
458
|
+
// stat and readdir of the node alone, but its presence would still mean
|
|
459
|
+
// something began mounting beneath /bin.
|
|
460
|
+
expect(profile).not.toContain('(literal "/bin")');
|
|
461
|
+
} finally {
|
|
462
|
+
cleanup();
|
|
463
|
+
}
|
|
464
|
+
});
|
|
465
|
+
});
|
package/src/hooks/mount-set.ts
CHANGED
|
@@ -39,6 +39,8 @@ export interface MountEntry {
|
|
|
39
39
|
readonly mode: MountMode;
|
|
40
40
|
/** Why this row exists. Surfaced by the unjailed lint and by `system doctor`. */
|
|
41
41
|
readonly reason: string;
|
|
42
|
+
/** What this row's absence from the host means. See `MountAbsence`. */
|
|
43
|
+
readonly absence: MountAbsence;
|
|
42
44
|
}
|
|
43
45
|
|
|
44
46
|
export interface MountSet {
|
|
@@ -154,8 +156,27 @@ const RESOLVER_FILES = ['/etc/resolv.conf', '/etc/nsswitch.conf', '/etc/hosts']
|
|
|
154
156
|
*/
|
|
155
157
|
const NEVER_MOUNT = ['/usr/bin/bwrap', '/usr/local/bin/bwrap', '/bin/bwrap'] as const;
|
|
156
158
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
+
/**
|
|
160
|
+
* What a row's ABSENCE from the host means. Required, never defaulted: a default
|
|
161
|
+
* lets the next mount be added without deciding which kind it is, and that is
|
|
162
|
+
* exactly how the undifferentiated `skipped` list came to exist.
|
|
163
|
+
*
|
|
164
|
+
* bubblewrap fails the whole jail on a bind whose source is missing, so an
|
|
165
|
+
* absent row must be dropped. The question this answers is whether dropping it
|
|
166
|
+
* is fine, fatal, or nobody's business.
|
|
167
|
+
*/
|
|
168
|
+
export type MountAbsence =
|
|
169
|
+
/** Fatal. The hook cannot do what it was asked without this. */
|
|
170
|
+
| 'required'
|
|
171
|
+
/** Fatal only for a module that declared it needs the facility. */
|
|
172
|
+
| 'declared-only'
|
|
173
|
+
/** Not reported. A genuinely needed one fails the runtime, which is louder. */
|
|
174
|
+
| 'runtime'
|
|
175
|
+
/** Expected at some lifecycle points. Debug at most. */
|
|
176
|
+
| 'conditional';
|
|
177
|
+
|
|
178
|
+
function entry(path: string, mode: MountMode, reason: string, absence: MountAbsence): MountEntry {
|
|
179
|
+
return { path, mode, reason, absence };
|
|
159
180
|
}
|
|
160
181
|
|
|
161
182
|
/**
|
|
@@ -182,21 +203,25 @@ export function deriveMountSet(request: MountSetRequest): MountSet {
|
|
|
182
203
|
// silently an empty tmpfs directory writes into it, returns success, and
|
|
183
204
|
// produces a backup containing NOTHING. It is found at restore. So the gate
|
|
184
205
|
// on this asserts the artifact is non-empty, never that the hook exited zero.
|
|
185
|
-
entries.push(entry('/tmp', 'tmpfs', 'private scratch, per run'));
|
|
206
|
+
entries.push(entry('/tmp', 'tmpfs', 'private scratch, per run', 'runtime'));
|
|
186
207
|
|
|
187
208
|
// 2. The runtime. Without it nothing runs, so it is not really a policy row.
|
|
188
|
-
entries.push(entry(request.runtimePath, 'ro', 'the interpreter'));
|
|
189
|
-
entries.push(
|
|
209
|
+
entries.push(entry(request.runtimePath, 'ro', 'the interpreter', 'required'));
|
|
210
|
+
entries.push(
|
|
211
|
+
entry(dirname(request.runnerPath), 'ro', 'the runner shim celilo spawns', 'required'),
|
|
212
|
+
);
|
|
190
213
|
// See MountSetRequest.runtimeModulePaths. Without these the shim starts and
|
|
191
214
|
// immediately dies on `Cannot find module`.
|
|
192
215
|
for (const dir of request.runtimeModulePaths ?? []) {
|
|
193
|
-
entries.push(
|
|
216
|
+
entries.push(
|
|
217
|
+
entry(resolve(dir), 'ro', "celilo's own dependencies, which the shim imports", 'required'),
|
|
218
|
+
);
|
|
194
219
|
}
|
|
195
220
|
for (const dir of RUNTIME_SUPPORT_DIRS) {
|
|
196
|
-
entries.push(entry(dir, 'ro', 'shared libraries and trust store'));
|
|
221
|
+
entries.push(entry(dir, 'ro', 'shared libraries and trust store', 'runtime'));
|
|
197
222
|
}
|
|
198
223
|
for (const file of RESOLVER_FILES) {
|
|
199
|
-
entries.push(entry(file, 'ro', 'name resolution — see RESOLVER_FILES'));
|
|
224
|
+
entries.push(entry(file, 'ro', 'name resolution — see RESOLVER_FILES', 'runtime'));
|
|
200
225
|
}
|
|
201
226
|
// The fleet browser, read-only (task 4.10).
|
|
202
227
|
//
|
|
@@ -225,7 +250,9 @@ export function deriveMountSet(request: MountSetRequest): MountSet {
|
|
|
225
250
|
// the browser rather than here. That interim landed 2026-08-31 (celilo#1215):
|
|
226
251
|
// the launch path in `test-fixtures/jail-toolchain-hook.ts` carries the flag,
|
|
227
252
|
// and `jail-browser-launch-flags.test.ts` pins it there.
|
|
228
|
-
entries.push(
|
|
253
|
+
entries.push(
|
|
254
|
+
entry(BROWSER_ROOT, 'ro', 'the fleet browser, when one is provisioned', 'declared-only'),
|
|
255
|
+
);
|
|
229
256
|
|
|
230
257
|
// 3. The module's own tree, read-only, then its writable directories carved
|
|
231
258
|
// on top. bubblewrap resolves that in the right order, which is why the
|
|
@@ -234,19 +261,36 @@ export function deriveMountSet(request: MountSetRequest): MountSet {
|
|
|
234
261
|
// D9 says "the module's own tree is bound read-only". These are the
|
|
235
262
|
// carved exceptions to that sentence, and there are three of them rather
|
|
236
263
|
// than the one D9's prose implies.
|
|
237
|
-
entries.push(entry(modulePath, 'ro', "the module's own tree"));
|
|
264
|
+
entries.push(entry(modulePath, 'ro', "the module's own tree", 'required'));
|
|
238
265
|
entries.push(
|
|
239
|
-
entry(
|
|
266
|
+
entry(
|
|
267
|
+
resolve(request.stateDir),
|
|
268
|
+
'rw',
|
|
269
|
+
'ctx.stateDir, the sanctioned writable directory',
|
|
270
|
+
'required',
|
|
271
|
+
),
|
|
240
272
|
);
|
|
241
273
|
entries.push(
|
|
242
|
-
entry(
|
|
274
|
+
entry(
|
|
275
|
+
join(modulePath, 'generated'),
|
|
276
|
+
'rw',
|
|
277
|
+
"celilo's generated output the hook may amend",
|
|
278
|
+
'conditional',
|
|
279
|
+
),
|
|
243
280
|
);
|
|
244
281
|
if (request.screenshotDir) {
|
|
245
|
-
entries.push(
|
|
282
|
+
entries.push(
|
|
283
|
+
entry(
|
|
284
|
+
resolve(request.screenshotDir),
|
|
285
|
+
'rw',
|
|
286
|
+
'ctx.screenshotDir, this run only',
|
|
287
|
+
'conditional',
|
|
288
|
+
),
|
|
289
|
+
);
|
|
246
290
|
}
|
|
247
291
|
|
|
248
292
|
// 4. The broker channel. Bound AFTER the tmpfs, per the note above.
|
|
249
|
-
entries.push(entry(resolve(request.socketDir), 'rw', 'the capability broker socket'));
|
|
293
|
+
entries.push(entry(resolve(request.socketDir), 'rw', 'the capability broker socket', 'required'));
|
|
250
294
|
|
|
251
295
|
// 5. Contract-declared path inputs, at the access the contract declares.
|
|
252
296
|
// Never inferred from the name — see ContractField.path.
|
|
@@ -256,6 +300,10 @@ export function deriveMountSet(request: MountSetRequest): MountSet {
|
|
|
256
300
|
resolve(input.value),
|
|
257
301
|
input.access === 'write' ? 'rw' : 'ro',
|
|
258
302
|
`contract input '${input.name}' (${input.access})`,
|
|
303
|
+
// Fatal, always. A hook whose declared WRITE path is dropped writes into
|
|
304
|
+
// the run's private tmpfs and reports success over a directory that is
|
|
305
|
+
// discarded when it exits. celilo#1248-adjacent; see jail.ts's note.
|
|
306
|
+
'required',
|
|
259
307
|
),
|
|
260
308
|
);
|
|
261
309
|
}
|
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
import { remoteAccessPolicy } from '../services/remote-access';
|
|
27
27
|
import { loadCapabilityFunctions } from './capability-loader';
|
|
28
28
|
import { invokeHook } from './executor';
|
|
29
|
+
import { createHookStores } from './hook-store';
|
|
29
30
|
import { loadHookConfigMap } from './load-hook-config';
|
|
30
31
|
import type { HookLogger, HookName, HookResult } from './types';
|
|
31
32
|
|
|
@@ -192,6 +193,7 @@ export async function runNamedHook(
|
|
|
192
193
|
requiredCapabilities,
|
|
193
194
|
systems: getModuleSystems(moduleId, db),
|
|
194
195
|
remoteAccess: remoteAccessPolicy(moduleId, db),
|
|
196
|
+
hookStores: () => createHookStores(db, moduleId),
|
|
195
197
|
timeoutMs: options.timeoutMs,
|
|
196
198
|
},
|
|
197
199
|
);
|