@celilo/cli 1.8.0 → 1.9.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/CELILO_CORE_MODULES.md +2 -0
- package/CELILO_SUBSYSTEMS.md +2 -0
- package/drizzle/0028_capability_bindings.sql +26 -0
- package/drizzle/0029_module_instances.sql +58 -0
- package/drizzle/meta/_journal.json +14 -0
- package/package.json +2 -2
- package/src/cli/commands/module-show.ts +1 -0
- package/src/db/foreign-keys.test.ts +101 -0
- package/src/db/schema.ts +161 -5
- package/src/hooks/broker.test.ts +153 -0
- package/src/hooks/broker.ts +307 -0
- package/src/hooks/capability-loader-bindings.test.ts +163 -0
- package/src/hooks/capability-loader-firewall.test.ts +108 -0
- package/src/hooks/capability-loader.test.ts +10 -2
- package/src/hooks/capability-loader.ts +59 -2
- package/src/hooks/define-hook.test.ts +1 -0
- package/src/hooks/executor.test.ts +7 -0
- package/src/hooks/executor.ts +245 -111
- package/src/hooks/hook-protocol.test.ts +192 -0
- package/src/hooks/hook-protocol.ts +275 -0
- package/src/hooks/hook-runner.ts +231 -0
- package/src/hooks/hook-state-dir.test.ts +109 -0
- package/src/hooks/hook-timeout.test.ts +104 -0
- package/src/hooks/hook-trespass.test.ts +202 -0
- package/src/hooks/injected-capabilities.test.ts +75 -0
- package/src/hooks/mount-set.test.ts +148 -0
- package/src/hooks/mount-set.ts +234 -0
- package/src/hooks/test-fixtures/capability-calling-hook.ts +79 -0
- package/src/hooks/test-fixtures/runaway-hook.ts +26 -0
- package/src/hooks/test-fixtures/sigterm-ignoring-hook.ts +22 -0
- package/src/manifest/contracts/v1.ts +21 -6
- package/src/manifest/validate-provider-views.test.ts +61 -0
- package/src/manifest/validate.ts +21 -14
- package/src/module/packaging/module-state-directory.test.ts +105 -0
- package/src/module/packaging/package-rules.ts +10 -2
- package/src/policy/capability-shape-baseline.ts +8 -0
- package/src/policy/capability-shape.ts +13 -1
- package/src/policy/module-business-baseline.ts +36 -0
- package/src/policy/module-dep-reachability.test.ts +167 -0
- package/src/services/alerting/ack.test.ts +2 -2
- package/src/services/alerting/deferral.test.ts +2 -2
- package/src/services/alerting/delivery-loop.test.ts +2 -2
- package/src/services/alerting/deploy-hooks.test.ts +2 -2
- package/src/services/alerting/inbound-poller.test.ts +2 -2
- package/src/services/alerting/inbound.test.ts +2 -2
- package/src/services/alerting/notification-responder.test.ts +2 -2
- package/src/services/alerting/run-monitor.test.ts +2 -2
- package/src/services/alerting/store.test.ts +2 -2
- package/src/services/alerting/sweep-runner.test.ts +2 -2
- package/src/services/alerting/tokens.test.ts +2 -2
- package/src/services/capability-bindings.test.ts +104 -0
- package/src/services/capability-bindings.ts +107 -0
- package/src/services/capability-table-rows.test.ts +2 -2
- package/src/services/consumer-cleanup.test.ts +40 -3
- package/src/services/dns-internal-records.test.ts +3 -3
- package/src/services/fleet-checks.test.ts +4 -4
- package/src/services/module-instances.test.ts +198 -0
- package/src/services/module-instances.ts +96 -0
- package/src/services/module-journal.test.ts +2 -2
- package/src/services/module-subscriptions.test.ts +1 -1
- package/src/services/port-forwards.test.ts +2 -2
- package/src/services/trusted-sources.test.ts +3 -3
- package/src/templates/ingress-ip.test.ts +31 -0
- package/src/test-utils/database.ts +31 -1
- package/src/test-utils/module-fixtures.ts +147 -35
- package/src/test-utils/setup-test-db.ts +0 -80
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* celilo#1003: a hook that times out must be KILLED, not abandoned.
|
|
3
|
+
*
|
|
4
|
+
* `module-lifecycle`'s spec has always required this:
|
|
5
|
+
*
|
|
6
|
+
* > WHEN a hook produces no output for longer than the idle timeout
|
|
7
|
+
* > THEN celilo SHALL terminate it rather than hang indefinitely
|
|
8
|
+
*
|
|
9
|
+
* The old executor raced the hook's promise against a timer and cancelled
|
|
10
|
+
* nothing, because a promise cannot be cancelled. The existing suite asserted
|
|
11
|
+
* the rejection, which held, and the requirement did not.
|
|
12
|
+
*
|
|
13
|
+
* So these tests assert the harm rather than the rejection: a marker file the
|
|
14
|
+
* hook writes only after its bound has passed. Both of them fail against the
|
|
15
|
+
* in-process executor and neither says anything about how the kill is
|
|
16
|
+
* implemented.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { afterEach, describe, expect, test } from 'bun:test';
|
|
20
|
+
import { execSync } from 'node:child_process';
|
|
21
|
+
import { existsSync, mkdtempSync, rmSync } from 'node:fs';
|
|
22
|
+
import { tmpdir } from 'node:os';
|
|
23
|
+
import { join } from 'node:path';
|
|
24
|
+
import { executeHookScript } from './executor';
|
|
25
|
+
import { createCapturingLogger } from './logger';
|
|
26
|
+
import type { HookContext } from './types';
|
|
27
|
+
|
|
28
|
+
const FIXTURES = join(__dirname, 'test-fixtures');
|
|
29
|
+
const dirs: string[] = [];
|
|
30
|
+
|
|
31
|
+
afterEach(() => {
|
|
32
|
+
for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true });
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
function scratch(): string {
|
|
36
|
+
const dir = mkdtempSync(join(tmpdir(), 'celilo-timeout-'));
|
|
37
|
+
dirs.push(dir);
|
|
38
|
+
return dir;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function contextFor(config: Record<string, unknown>): HookContext {
|
|
42
|
+
return {
|
|
43
|
+
config,
|
|
44
|
+
secrets: {},
|
|
45
|
+
systems: [],
|
|
46
|
+
logger: createCapturingLogger().logger,
|
|
47
|
+
debug: false,
|
|
48
|
+
screenshotDir: scratch(),
|
|
49
|
+
stateDir: scratch(),
|
|
50
|
+
capabilities: {},
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Hook processes still parented to this one. By ppid rather than by script
|
|
58
|
+
* name: matching the name picks up whatever shell has the filename in its own
|
|
59
|
+
* command line, which is a false positive that looks exactly like a real leak.
|
|
60
|
+
*/
|
|
61
|
+
function survivingHookProcesses(): string[] {
|
|
62
|
+
return execSync('ps -Ao ppid=,args=', { encoding: 'utf-8' })
|
|
63
|
+
.split('\n')
|
|
64
|
+
.filter(
|
|
65
|
+
(line) => Number.parseInt(line.trim(), 10) === process.pid && line.includes('hook-runner'),
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
describe('hook timeout is a kill, not a race', () => {
|
|
70
|
+
test('a hook that outruns its total timeout stops doing work', async () => {
|
|
71
|
+
const marker = join(scratch(), 'kept-running');
|
|
72
|
+
|
|
73
|
+
await expect(
|
|
74
|
+
executeHookScript(
|
|
75
|
+
join(FIXTURES, 'runaway-hook.ts'),
|
|
76
|
+
contextFor({ sleep_ms: 1500, marker_path: marker }),
|
|
77
|
+
400,
|
|
78
|
+
400,
|
|
79
|
+
),
|
|
80
|
+
).rejects.toThrow(/timeout/i);
|
|
81
|
+
|
|
82
|
+
// Past when the abandoned hook would have written it.
|
|
83
|
+
await sleep(2000);
|
|
84
|
+
expect(existsSync(marker)).toBe(false);
|
|
85
|
+
expect(survivingHookProcesses()).toEqual([]);
|
|
86
|
+
}, 15_000);
|
|
87
|
+
|
|
88
|
+
test('a hook that declines SIGTERM is killed anyway', async () => {
|
|
89
|
+
const marker = join(scratch(), 'survived');
|
|
90
|
+
|
|
91
|
+
await expect(
|
|
92
|
+
executeHookScript(
|
|
93
|
+
join(FIXTURES, 'sigterm-ignoring-hook.ts'),
|
|
94
|
+
contextFor({ sleep_ms: 6000, marker_path: marker }),
|
|
95
|
+
400,
|
|
96
|
+
400,
|
|
97
|
+
),
|
|
98
|
+
).rejects.toThrow(/timeout/i);
|
|
99
|
+
|
|
100
|
+
await sleep(6500);
|
|
101
|
+
expect(existsSync(marker)).toBe(false);
|
|
102
|
+
expect(survivingHookProcesses()).toEqual([]);
|
|
103
|
+
}, 20_000);
|
|
104
|
+
});
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The recurrence gate for openspec/changes/hook-process-boundary (celilo#1001).
|
|
3
|
+
*
|
|
4
|
+
* Runs `modules/hello-trespass`'s hook through `executeHookScript` and asserts
|
|
5
|
+
* exactly what the stage that has landed claims, and nothing more.
|
|
6
|
+
*
|
|
7
|
+
* **Stage 1 claims the environment. It does not claim the filesystem and it
|
|
8
|
+
* does not claim the network.** So this file asserts the sensitive environment
|
|
9
|
+
* is empty, and asserts the other three trespasses STILL SUCCEED. A gate that
|
|
10
|
+
* claimed more than its stage delivers would go green for the wrong reason and
|
|
11
|
+
* would have to be rewritten — quietly weakening it — the first time somebody
|
|
12
|
+
* noticed. Stage 2 flips the two filesystem rows and stage 3 flips the SSH row;
|
|
13
|
+
* each stage edits the assertion it earns.
|
|
14
|
+
*
|
|
15
|
+
* `HOME` is redirected to a scratch directory holding a planted master key and
|
|
16
|
+
* a planted SSH key, so the trespasses are deterministic and the operator's
|
|
17
|
+
* real key is never read (CLAUDE.md: never test against live data). `HOME` is
|
|
18
|
+
* on design D5's allow-list, so the child resolves the same planted paths the
|
|
19
|
+
* parent planted — which is the point of D5's own caveat: removing
|
|
20
|
+
* `CELILO_DATA_DIR` does not hide a path a hook can compute.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
|
|
24
|
+
import { execFileSync } from 'node:child_process';
|
|
25
|
+
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
26
|
+
import { tmpdir } from 'node:os';
|
|
27
|
+
import { dirname, join, resolve } from 'node:path';
|
|
28
|
+
import { executeHookScript, hookChildEnv } from './executor';
|
|
29
|
+
import { createCapturingLogger } from './logger';
|
|
30
|
+
import type { HookContext } from './types';
|
|
31
|
+
|
|
32
|
+
const TRESPASS_SCRIPT = resolve(
|
|
33
|
+
__dirname,
|
|
34
|
+
'../../../../modules/hello-trespass/scripts/trespass.ts',
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
interface TrespassOutcome {
|
|
38
|
+
attempted: boolean;
|
|
39
|
+
succeeded: boolean;
|
|
40
|
+
target: string;
|
|
41
|
+
detail: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface TrespassReport {
|
|
45
|
+
master_key: TrespassOutcome;
|
|
46
|
+
sibling_write: TrespassOutcome;
|
|
47
|
+
ssh_key: TrespassOutcome;
|
|
48
|
+
remote_exec: TrespassOutcome;
|
|
49
|
+
sensitive_env: string[];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
let scratchHome: string;
|
|
53
|
+
let realHome: string | undefined;
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Install the fixture module's own dependencies if they are not there.
|
|
57
|
+
*
|
|
58
|
+
* A `modules/<id>/scripts` directory is a standalone package, deliberately
|
|
59
|
+
* outside the root workspace globs so that a module resolves the PUBLISHED
|
|
60
|
+
* `@celilo/capabilities` it will actually run on the fleet rather than the
|
|
61
|
+
* workspace copy. bun links workspace dependencies into each package's own
|
|
62
|
+
* `node_modules` and never into the repo root, so there is nothing here for
|
|
63
|
+
* the fixture to walk up to: with no install of its own the hook dies with
|
|
64
|
+
* `Cannot find module '@celilo/capabilities'`.
|
|
65
|
+
*
|
|
66
|
+
* `bun run setup` does this for every module, and CI does it in its
|
|
67
|
+
* check-modules step — which runs LAST on purpose, because twenty-two installs
|
|
68
|
+
* is the slowest thing in that job. This gate is the first test to reach into
|
|
69
|
+
* a module's script tree, so it prepares the one fixture it needs instead of
|
|
70
|
+
* making every PR pay for all of them up front.
|
|
71
|
+
*
|
|
72
|
+
* It installs rather than skipping. A gate that quietly does not run is the
|
|
73
|
+
* exact failure this whole change exists to make impossible.
|
|
74
|
+
*/
|
|
75
|
+
function ensureFixtureInstalled(): void {
|
|
76
|
+
const scriptsDir = dirname(TRESPASS_SCRIPT);
|
|
77
|
+
if (existsSync(join(scriptsDir, 'node_modules', '@celilo', 'capabilities'))) return;
|
|
78
|
+
// `process.execPath`, not `bun` on PATH: this is the bun already running the
|
|
79
|
+
// suite, so it works wherever the suite does.
|
|
80
|
+
execFileSync(process.execPath, ['install'], { cwd: scriptsDir, stdio: 'pipe' });
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
beforeAll(() => {
|
|
84
|
+
ensureFixtureInstalled();
|
|
85
|
+
scratchHome = mkdtempSync(join(tmpdir(), 'celilo-trespass-home-'));
|
|
86
|
+
|
|
87
|
+
// The master key at getMasterKeyPath()'s DEFAULT location for this platform.
|
|
88
|
+
// Both branches are planted rather than the current one only, so the fixture
|
|
89
|
+
// reads a planted key whichever host runs the suite.
|
|
90
|
+
for (const dir of [
|
|
91
|
+
join(scratchHome, 'Library', 'Application Support', 'celilo'),
|
|
92
|
+
join(scratchHome, '.local', 'share', 'celilo'),
|
|
93
|
+
]) {
|
|
94
|
+
mkdirSync(dir, { recursive: true });
|
|
95
|
+
writeFileSync(join(dir, 'master.key'), 'not-the-real-key\n');
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
mkdirSync(join(scratchHome, '.ssh'), { recursive: true });
|
|
99
|
+
writeFileSync(join(scratchHome, '.ssh', 'id_ed25519'), 'not-a-real-key\n');
|
|
100
|
+
|
|
101
|
+
realHome = process.env.HOME;
|
|
102
|
+
process.env.HOME = scratchHome;
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
afterAll(() => {
|
|
106
|
+
if (realHome === undefined) delete process.env.HOME;
|
|
107
|
+
else process.env.HOME = realHome;
|
|
108
|
+
rmSync(scratchHome, { recursive: true, force: true });
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
async function runTrespass(): Promise<{ report: TrespassReport; lines: string[] }> {
|
|
112
|
+
const { logger, messages } = createCapturingLogger();
|
|
113
|
+
const context: HookContext = {
|
|
114
|
+
config: { sibling_module_id: 'hello-foo', other_system_ip: '' },
|
|
115
|
+
secrets: {},
|
|
116
|
+
systems: [],
|
|
117
|
+
logger,
|
|
118
|
+
debug: false,
|
|
119
|
+
screenshotDir: mkdtempSync(join(tmpdir(), 'celilo-trespass-artifacts-')),
|
|
120
|
+
stateDir: mkdtempSync(join(tmpdir(), 'celilo-trespass-artifacts-')),
|
|
121
|
+
capabilities: {},
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
const outputs = await executeHookScript(TRESPASS_SCRIPT, context, 60_000, 60_000);
|
|
125
|
+
return {
|
|
126
|
+
report: outputs as unknown as TrespassReport,
|
|
127
|
+
lines: messages.map((m) => m.message),
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
describe('the child environment is an allow-list', () => {
|
|
132
|
+
// The trespass gate below proves nothing sensitive gets through. This is the
|
|
133
|
+
// complement: the six things that MUST get through, because a hook with no
|
|
134
|
+
// `PATH` cannot spawn anything and one with no `HOME` breaks more than it
|
|
135
|
+
// protects.
|
|
136
|
+
test('forwards the allow-list and the channel, and nothing else', () => {
|
|
137
|
+
process.env.CELILO_TEST_SECRET = 'must-not-cross';
|
|
138
|
+
try {
|
|
139
|
+
const env = hookChildEnv('/tmp/celilo-hook-x/s');
|
|
140
|
+
|
|
141
|
+
expect(env.CELILO_HOOK_SOCKET).toBe('/tmp/celilo-hook-x/s');
|
|
142
|
+
expect(env.CELILO_HOOK_PROTOCOL_VERSION).toBe('1');
|
|
143
|
+
expect(env.PATH).toBe(process.env.PATH as string);
|
|
144
|
+
expect(env.HOME).toBe(process.env.HOME as string);
|
|
145
|
+
expect(env.CELILO_TEST_SECRET).toBeUndefined();
|
|
146
|
+
|
|
147
|
+
const allowed = new Set([
|
|
148
|
+
'PATH',
|
|
149
|
+
'HOME',
|
|
150
|
+
'LANG',
|
|
151
|
+
'TZ',
|
|
152
|
+
'TMPDIR',
|
|
153
|
+
'CELILO_DEBUG',
|
|
154
|
+
'CELILO_HOOK_SOCKET',
|
|
155
|
+
'CELILO_HOOK_PROTOCOL_VERSION',
|
|
156
|
+
]);
|
|
157
|
+
expect(Object.keys(env).filter((name) => !allowed.has(name))).toEqual([]);
|
|
158
|
+
} finally {
|
|
159
|
+
delete process.env.CELILO_TEST_SECRET;
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test('omits an allow-listed variable the parent does not have', () => {
|
|
164
|
+
const saved = process.env.TZ;
|
|
165
|
+
delete process.env.TZ;
|
|
166
|
+
try {
|
|
167
|
+
// Absent, not the string "undefined" — which is what a naive copy
|
|
168
|
+
// produces and what a shell then happily uses as a timezone.
|
|
169
|
+
expect('TZ' in hookChildEnv('/tmp/s')).toBe(false);
|
|
170
|
+
} finally {
|
|
171
|
+
if (saved !== undefined) process.env.TZ = saved;
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
describe('hook process boundary — hello-trespass gate', () => {
|
|
177
|
+
test('stage 1: the environment is clean, and nothing else has changed', async () => {
|
|
178
|
+
const { report, lines } = await runTrespass();
|
|
179
|
+
|
|
180
|
+
// The evidence, printed whether the assertions pass or fail. Without it a
|
|
181
|
+
// red run says which assertion broke and not what the hook actually saw.
|
|
182
|
+
console.log(['', 'hello-trespass:', ...lines.slice(1)].join('\n'));
|
|
183
|
+
|
|
184
|
+
// What stage 1 claims. Empty because the child's environment is an
|
|
185
|
+
// allow-list, not `...process.env` (design D5).
|
|
186
|
+
expect(report.sensitive_env).toEqual([]);
|
|
187
|
+
|
|
188
|
+
// What stage 1 deliberately does NOT claim. The master key is still
|
|
189
|
+
// readable, because the hook computes the default path rather than
|
|
190
|
+
// reading it out of the environment. Stage 2's mount set is what removes
|
|
191
|
+
// it, by not binding the data directory at all.
|
|
192
|
+
expect(report.master_key.succeeded).toBe(true);
|
|
193
|
+
|
|
194
|
+
// Nor the sibling's tree: the module store is still one `..` away.
|
|
195
|
+
expect(report.sibling_write.succeeded).toBe(true);
|
|
196
|
+
|
|
197
|
+
// Nor the SSH key, which is what scopes reachability in stage 3 (D12).
|
|
198
|
+
// `attempted: false` means the host has no private key at all — a bare CI
|
|
199
|
+
// runner — which is an absent precondition, not a refusal.
|
|
200
|
+
expect(report.ssh_key.attempted && !report.ssh_key.succeeded).toBe(false);
|
|
201
|
+
});
|
|
202
|
+
});
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every name the loader can put in `ctx.capabilities` must be a registered
|
|
3
|
+
* capability.
|
|
4
|
+
*
|
|
5
|
+
* This is the gate for the class of bug, not for its instances. Twice now a
|
|
6
|
+
* capability has been injected under a literal name that appeared in no
|
|
7
|
+
* registry and, in one case, had no declared type anywhere:
|
|
8
|
+
*
|
|
9
|
+
* - `web_routes`, whose two methods were synchronous. The hook process
|
|
10
|
+
* boundary's D2 measured the hook-facing surface as uniformly async by
|
|
11
|
+
* reading `CapabilityRegistry`, so it never saw them. caddy's `on_install`
|
|
12
|
+
* died on `{} is not iterable` when the async proxy handed it a Promise.
|
|
13
|
+
* - `firewall_registry`, which had no `FirewallRegistryCapability` type, no
|
|
14
|
+
* registry entry, and no contract version. It happens to be async, so it
|
|
15
|
+
* broke nothing, which is exactly why nobody found it.
|
|
16
|
+
*
|
|
17
|
+
* `type-invariants.test.ts` proves every REGISTERED capability's methods are
|
|
18
|
+
* async. That proof is only worth as much as the registry's completeness, and
|
|
19
|
+
* completeness is what this file checks. Together they close the loop: the
|
|
20
|
+
* registry names everything injected, and everything named is async.
|
|
21
|
+
*
|
|
22
|
+
* It reads the loader's source because the property is about what the code can
|
|
23
|
+
* assign, not about what one run happens to produce. A runtime test would need
|
|
24
|
+
* every provider deployed in every combination to see all the branches.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { describe, expect, test } from 'bun:test';
|
|
28
|
+
import { readFileSync } from 'node:fs';
|
|
29
|
+
import { join } from 'node:path';
|
|
30
|
+
import { KNOWN_CAPABILITY_NAMES } from '@celilo/capabilities';
|
|
31
|
+
|
|
32
|
+
const LOADER = join(__dirname, 'capability-loader.ts');
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Literal names assigned into the capability map, e.g. `result.web_routes =`.
|
|
36
|
+
*
|
|
37
|
+
* Computed assignments (`result[capName] =`) are deliberately not matched:
|
|
38
|
+
* `capName` is already a registry name, since it comes from the capability
|
|
39
|
+
* table keyed by the same list.
|
|
40
|
+
*/
|
|
41
|
+
function injectedCapabilityNames(source: string): string[] {
|
|
42
|
+
const names = new Set<string>();
|
|
43
|
+
for (const match of source.matchAll(/^\s*result\.([A-Za-z_][A-Za-z0-9_]*)\s*=/gm)) {
|
|
44
|
+
names.add(match[1]);
|
|
45
|
+
}
|
|
46
|
+
return [...names].sort();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
describe('capabilities injected into the hook context', () => {
|
|
50
|
+
test('every literally-named injection is a registered capability', () => {
|
|
51
|
+
const injected = injectedCapabilityNames(readFileSync(LOADER, 'utf-8'));
|
|
52
|
+
|
|
53
|
+
// If this is empty the regex has drifted and the gate is checking nothing,
|
|
54
|
+
// which is the failure mode that makes a green test worse than no test.
|
|
55
|
+
expect(injected.length).toBeGreaterThan(0);
|
|
56
|
+
|
|
57
|
+
const known: readonly string[] = KNOWN_CAPABILITY_NAMES;
|
|
58
|
+
const unregistered = injected.filter((name) => !known.includes(name));
|
|
59
|
+
|
|
60
|
+
expect(unregistered).toEqual([]);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('PROVE IT FAILS: an unregistered name is caught', () => {
|
|
64
|
+
const withNewInjection = `
|
|
65
|
+
result.public_web = createPublicWeb({});
|
|
66
|
+
result.brand_new_view = somethingUndeclared;
|
|
67
|
+
`;
|
|
68
|
+
const known: readonly string[] = KNOWN_CAPABILITY_NAMES;
|
|
69
|
+
const unregistered = injectedCapabilityNames(withNewInjection).filter(
|
|
70
|
+
(name) => !known.includes(name),
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
expect(unregistered).toEqual(['brand_new_view']);
|
|
74
|
+
});
|
|
75
|
+
});
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hermetic guards on the hook jail's mount set (design D9).
|
|
3
|
+
*
|
|
4
|
+
* `deriveMountSet` is pure, so every property D9 asserts is checkable here
|
|
5
|
+
* without spawning anything. The two that matter most are absence properties:
|
|
6
|
+
* what the jail does NOT contain is the acceptance criterion.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, expect, test } from 'bun:test';
|
|
10
|
+
import { deriveMountSet, forbiddenPaths, isForbidden, toBwrapArgs } from './mount-set';
|
|
11
|
+
|
|
12
|
+
const BASE = {
|
|
13
|
+
modulePath: '/var/celilo/modules/caddy',
|
|
14
|
+
stateDir: '/var/celilo/modules/caddy/state',
|
|
15
|
+
socketDir: '/tmp/celilo-hook-a1b2c3',
|
|
16
|
+
runtimePath: '/usr/local/bin/bun',
|
|
17
|
+
runnerPath: '/opt/celilo/src/hooks/hook-runner.ts',
|
|
18
|
+
pathInputs: [],
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const pathsOf = (r: Parameters<typeof deriveMountSet>[0]) =>
|
|
22
|
+
deriveMountSet(r).entries.map((e) => e.path);
|
|
23
|
+
|
|
24
|
+
describe('bwrap is never in the mount set', () => {
|
|
25
|
+
// The AppArmor profile grants userns to /usr/bin/bwrap for anyone on the
|
|
26
|
+
// box. A jailed hook that could exec it would be uid 0 with CAP_SYS_ADMIN in
|
|
27
|
+
// its own namespace. bwrap runs OUTSIDE the jail because it creates the
|
|
28
|
+
// jail, so withholding it costs nothing.
|
|
29
|
+
test('is absent from an ordinary derivation', () => {
|
|
30
|
+
expect(pathsOf(BASE).filter(isForbidden)).toEqual([]);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test('is dropped even when a contract input names it', () => {
|
|
34
|
+
// The realistic route back in is a developer debugging `bwrap: command not
|
|
35
|
+
// found` and adding it. This asserts the derivation refuses, rather than
|
|
36
|
+
// trusting nobody will ask.
|
|
37
|
+
const set = deriveMountSet({
|
|
38
|
+
...BASE,
|
|
39
|
+
pathInputs: [{ name: 'evil', value: '/usr/bin/bwrap', access: 'read' }],
|
|
40
|
+
});
|
|
41
|
+
expect(set.entries.map((e) => e.path)).not.toContain('/usr/bin/bwrap');
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test('every forbidden path is actually recognised', () => {
|
|
45
|
+
for (const p of forbiddenPaths()) expect(isForbidden(p)).toBe(true);
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe('what the jail must not contain', () => {
|
|
50
|
+
test("celilo's data directory and its secrets are absent", () => {
|
|
51
|
+
const paths = pathsOf(BASE);
|
|
52
|
+
// Absence, not a check. These simply do not exist inside the jail.
|
|
53
|
+
expect(paths).not.toContain('/var/celilo');
|
|
54
|
+
expect(paths).not.toContain('/var/celilo/master.key');
|
|
55
|
+
expect(paths).not.toContain('/var/celilo/celilo.db');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("a sibling module's tree is absent", () => {
|
|
59
|
+
const paths = pathsOf(BASE);
|
|
60
|
+
expect(paths).not.toContain('/var/celilo/modules');
|
|
61
|
+
expect(paths.some((p) => p.includes('/modules/technitium'))).toBe(false);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('the celilo CLI is absent, so a hook cannot re-enter it (celilo#1121)', () => {
|
|
65
|
+
expect(pathsOf(BASE)).not.toContain('/usr/local/bin/celilo');
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
describe('ordering is semantic', () => {
|
|
70
|
+
test('the tmpfs leads, so it cannot erase the socket or a staged input', () => {
|
|
71
|
+
// Every staged contract input and the broker socket live under os.tmpdir().
|
|
72
|
+
// Bind them before the tmpfs and they vanish — and a hook whose backup_dir
|
|
73
|
+
// is silently empty SUCCEEDS and produces a backup containing nothing.
|
|
74
|
+
const set = deriveMountSet({
|
|
75
|
+
...BASE,
|
|
76
|
+
pathInputs: [
|
|
77
|
+
{ name: 'backup_dir', value: '/tmp/celilo-backup-9f/envelope/data', access: 'write' },
|
|
78
|
+
],
|
|
79
|
+
});
|
|
80
|
+
const tmpfsAt = set.entries.findIndex((e) => e.mode === 'tmpfs' && e.path === '/tmp');
|
|
81
|
+
const socketAt = set.entries.findIndex((e) => e.path === BASE.socketDir);
|
|
82
|
+
const inputAt = set.entries.findIndex((e) => e.path.startsWith('/tmp/celilo-backup-'));
|
|
83
|
+
|
|
84
|
+
expect(tmpfsAt).toBeGreaterThanOrEqual(0);
|
|
85
|
+
expect(socketAt).toBeGreaterThan(tmpfsAt);
|
|
86
|
+
expect(inputAt).toBeGreaterThan(tmpfsAt);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('writable directories come after the read-only module tree', () => {
|
|
90
|
+
const set = deriveMountSet({ ...BASE, screenshotDir: `${BASE.modulePath}/screenshots/run1` });
|
|
91
|
+
const treeAt = set.entries.findIndex((e) => e.path === BASE.modulePath && e.mode === 'ro');
|
|
92
|
+
for (const carved of ['state', 'generated', 'screenshots/run1']) {
|
|
93
|
+
const at = set.entries.findIndex((e) => e.path === `${BASE.modulePath}/${carved}`);
|
|
94
|
+
expect(at).toBeGreaterThan(treeAt);
|
|
95
|
+
expect(set.entries[at]?.mode).toBe('rw');
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe('contract inputs are bound at their declared access', () => {
|
|
101
|
+
test("'write' is read-write and 'read' is read-only", () => {
|
|
102
|
+
const set = deriveMountSet({
|
|
103
|
+
...BASE,
|
|
104
|
+
pathInputs: [
|
|
105
|
+
{ name: 'backup_dir', value: '/tmp/stage/data', access: 'write' },
|
|
106
|
+
{ name: 'artifact_path', value: '/tmp/stage/db.sqlite', access: 'read' },
|
|
107
|
+
],
|
|
108
|
+
});
|
|
109
|
+
expect(set.entries.find((e) => e.path === '/tmp/stage/data')?.mode).toBe('rw');
|
|
110
|
+
expect(set.entries.find((e) => e.path === '/tmp/stage/db.sqlite')?.mode).toBe('ro');
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test('an undeclared input contributes nothing', () => {
|
|
114
|
+
// db_path was passed for months without being declared. A derivation that
|
|
115
|
+
// walks declarations cannot see it, which is the correct outcome here and
|
|
116
|
+
// the reason the contract has to declare what it passes (celilo#1118).
|
|
117
|
+
expect(pathsOf(BASE)).not.toContain('/var/celilo/celilo.db');
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
describe('paths are identical inside and outside', () => {
|
|
122
|
+
test('no entry is remapped', () => {
|
|
123
|
+
const args = toBwrapArgs(deriveMountSet(BASE));
|
|
124
|
+
for (let i = 0; i < args.length; i++) {
|
|
125
|
+
if (args[i] === '--bind' || args[i] === '--ro-bind') {
|
|
126
|
+
expect(args[i + 1]).toBe(args[i + 2] as string);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test('the jail names its own working directory', () => {
|
|
132
|
+
// The spawn sets no cwd, so the child inherits celilo's — a directory that
|
|
133
|
+
// usually does not exist inside the jail.
|
|
134
|
+
expect(deriveMountSet(BASE).chdir).toBe(BASE.modulePath);
|
|
135
|
+
expect(toBwrapArgs(deriveMountSet(BASE))).toContain('--chdir');
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
describe('~/.ssh is stage 2 only', () => {
|
|
140
|
+
test('bound read-only when supplied', () => {
|
|
141
|
+
const set = deriveMountSet({ ...BASE, sshDir: '/var/celilo/.ssh' });
|
|
142
|
+
expect(set.entries.find((e) => e.path === '/var/celilo/.ssh')?.mode).toBe('ro');
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test('absent when not supplied, which is what stage 3 does', () => {
|
|
146
|
+
expect(pathsOf(BASE).some((p) => p.endsWith('/.ssh'))).toBe(false);
|
|
147
|
+
});
|
|
148
|
+
});
|