@celilo/cli 1.8.0 → 1.9.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 +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 +152 -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/executor.ts +234 -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-timeout.test.ts +103 -0
- package/src/hooks/hook-trespass.test.ts +201 -0
- package/src/hooks/injected-capabilities.test.ts +75 -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/validate-provider-views.test.ts +61 -0
- package/src/manifest/validate.ts +21 -14
- package/src/module/packaging/module-state-directory.test.ts +99 -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/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/setup-test-db.ts +0 -80
|
@@ -0,0 +1,201 @@
|
|
|
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
|
+
capabilities: {},
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
const outputs = await executeHookScript(TRESPASS_SCRIPT, context, 60_000, 60_000);
|
|
124
|
+
return {
|
|
125
|
+
report: outputs as unknown as TrespassReport,
|
|
126
|
+
lines: messages.map((m) => m.message),
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
describe('the child environment is an allow-list', () => {
|
|
131
|
+
// The trespass gate below proves nothing sensitive gets through. This is the
|
|
132
|
+
// complement: the six things that MUST get through, because a hook with no
|
|
133
|
+
// `PATH` cannot spawn anything and one with no `HOME` breaks more than it
|
|
134
|
+
// protects.
|
|
135
|
+
test('forwards the allow-list and the channel, and nothing else', () => {
|
|
136
|
+
process.env.CELILO_TEST_SECRET = 'must-not-cross';
|
|
137
|
+
try {
|
|
138
|
+
const env = hookChildEnv('/tmp/celilo-hook-x/s');
|
|
139
|
+
|
|
140
|
+
expect(env.CELILO_HOOK_SOCKET).toBe('/tmp/celilo-hook-x/s');
|
|
141
|
+
expect(env.CELILO_HOOK_PROTOCOL_VERSION).toBe('1');
|
|
142
|
+
expect(env.PATH).toBe(process.env.PATH as string);
|
|
143
|
+
expect(env.HOME).toBe(process.env.HOME as string);
|
|
144
|
+
expect(env.CELILO_TEST_SECRET).toBeUndefined();
|
|
145
|
+
|
|
146
|
+
const allowed = new Set([
|
|
147
|
+
'PATH',
|
|
148
|
+
'HOME',
|
|
149
|
+
'LANG',
|
|
150
|
+
'TZ',
|
|
151
|
+
'TMPDIR',
|
|
152
|
+
'CELILO_DEBUG',
|
|
153
|
+
'CELILO_HOOK_SOCKET',
|
|
154
|
+
'CELILO_HOOK_PROTOCOL_VERSION',
|
|
155
|
+
]);
|
|
156
|
+
expect(Object.keys(env).filter((name) => !allowed.has(name))).toEqual([]);
|
|
157
|
+
} finally {
|
|
158
|
+
delete process.env.CELILO_TEST_SECRET;
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test('omits an allow-listed variable the parent does not have', () => {
|
|
163
|
+
const saved = process.env.TZ;
|
|
164
|
+
delete process.env.TZ;
|
|
165
|
+
try {
|
|
166
|
+
// Absent, not the string "undefined" — which is what a naive copy
|
|
167
|
+
// produces and what a shell then happily uses as a timezone.
|
|
168
|
+
expect('TZ' in hookChildEnv('/tmp/s')).toBe(false);
|
|
169
|
+
} finally {
|
|
170
|
+
if (saved !== undefined) process.env.TZ = saved;
|
|
171
|
+
}
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
describe('hook process boundary — hello-trespass gate', () => {
|
|
176
|
+
test('stage 1: the environment is clean, and nothing else has changed', async () => {
|
|
177
|
+
const { report, lines } = await runTrespass();
|
|
178
|
+
|
|
179
|
+
// The evidence, printed whether the assertions pass or fail. Without it a
|
|
180
|
+
// red run says which assertion broke and not what the hook actually saw.
|
|
181
|
+
console.log(['', 'hello-trespass:', ...lines.slice(1)].join('\n'));
|
|
182
|
+
|
|
183
|
+
// What stage 1 claims. Empty because the child's environment is an
|
|
184
|
+
// allow-list, not `...process.env` (design D5).
|
|
185
|
+
expect(report.sensitive_env).toEqual([]);
|
|
186
|
+
|
|
187
|
+
// What stage 1 deliberately does NOT claim. The master key is still
|
|
188
|
+
// readable, because the hook computes the default path rather than
|
|
189
|
+
// reading it out of the environment. Stage 2's mount set is what removes
|
|
190
|
+
// it, by not binding the data directory at all.
|
|
191
|
+
expect(report.master_key.succeeded).toBe(true);
|
|
192
|
+
|
|
193
|
+
// Nor the sibling's tree: the module store is still one `..` away.
|
|
194
|
+
expect(report.sibling_write.succeeded).toBe(true);
|
|
195
|
+
|
|
196
|
+
// Nor the SSH key, which is what scopes reachability in stage 3 (D12).
|
|
197
|
+
// `attempted: false` means the host has no private key at all — a bare CI
|
|
198
|
+
// runner — which is an absent precondition, not a refusal.
|
|
199
|
+
expect(report.ssh_key.attempted && !report.ssh_key.succeeded).toBe(false);
|
|
200
|
+
});
|
|
201
|
+
});
|
|
@@ -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,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test fixture: a hook that exercises every shape a capability call can take
|
|
3
|
+
* across the process boundary.
|
|
4
|
+
*
|
|
5
|
+
* One test per SHAPE rather than per method: the broker is generic, so a
|
|
6
|
+
* per-method suite would prove the same thing thirty-seven times and drift the
|
|
7
|
+
* moment a capability gained a method.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { defineHook, isMissingProviderInputError } from '@celilo/capabilities';
|
|
11
|
+
|
|
12
|
+
interface DemoCapability {
|
|
13
|
+
providerModuleId?: string;
|
|
14
|
+
version?: string;
|
|
15
|
+
echo(request: Record<string, unknown>): Promise<unknown>;
|
|
16
|
+
boom(request: Record<string, unknown>): Promise<unknown>;
|
|
17
|
+
missingInput(request: Record<string, unknown>): Promise<unknown>;
|
|
18
|
+
returnsNothing(request: Record<string, unknown>): Promise<unknown>;
|
|
19
|
+
/** Never implemented by this provider — the absent-optional-method case. */
|
|
20
|
+
sometimesAbsent?(request: Record<string, unknown>): Promise<unknown>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export default defineHook({
|
|
24
|
+
hook: 'container_created',
|
|
25
|
+
requires: [],
|
|
26
|
+
handler: async (ctx) => {
|
|
27
|
+
// `demo` is a fixture capability, not a registry entry, so the typed
|
|
28
|
+
// capability map does not know it. The broker is generic and does not care.
|
|
29
|
+
const demo = (ctx.capabilities as unknown as Record<string, DemoCapability>).demo;
|
|
30
|
+
const outputs: Record<string, unknown> = {};
|
|
31
|
+
|
|
32
|
+
// Non-function properties are copied verbatim, which is what keeps
|
|
33
|
+
// `providerModuleId` readable — a hook names the provider in its errors.
|
|
34
|
+
outputs.providerModuleId = demo.providerModuleId;
|
|
35
|
+
outputs.version = demo.version;
|
|
36
|
+
|
|
37
|
+
// An optional method the provider did not implement must be ABSENT, not a
|
|
38
|
+
// proxy that throws, or `if (cap.registerTrustedSource)` answers wrongly.
|
|
39
|
+
outputs.optionalMethodAbsent = demo.sometimesAbsent === undefined;
|
|
40
|
+
|
|
41
|
+
outputs.returned = await demo.echo({ x: 1, nested: { y: [2, 3] } });
|
|
42
|
+
outputs.undefinedBecomesNull = await demo.returnsNothing({});
|
|
43
|
+
|
|
44
|
+
try {
|
|
45
|
+
await demo.boom({});
|
|
46
|
+
outputs.plainThrow = 'did not throw';
|
|
47
|
+
} catch (error) {
|
|
48
|
+
outputs.plainThrow = {
|
|
49
|
+
isError: error instanceof Error,
|
|
50
|
+
name: (error as Error).name,
|
|
51
|
+
message: (error as Error).message,
|
|
52
|
+
hasStack: typeof (error as Error).stack === 'string',
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
try {
|
|
57
|
+
await demo.missingInput({});
|
|
58
|
+
outputs.missingProviderInput = 'did not throw';
|
|
59
|
+
} catch (error) {
|
|
60
|
+
const e = error as Record<string, unknown>;
|
|
61
|
+
outputs.missingProviderInput = {
|
|
62
|
+
recognised: isMissingProviderInputError(error),
|
|
63
|
+
providerModuleId: e.providerModuleId,
|
|
64
|
+
ensureId: e.ensureId,
|
|
65
|
+
value: e.value,
|
|
66
|
+
humanContext: e.humanContext,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
try {
|
|
71
|
+
await (demo as unknown as { nope(): Promise<unknown> }).nope();
|
|
72
|
+
outputs.unknownMethod = 'did not throw';
|
|
73
|
+
} catch (error) {
|
|
74
|
+
outputs.unknownMethod = (error as Error).message;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return outputs;
|
|
78
|
+
},
|
|
79
|
+
});
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test fixture: a hook that outruns its total timeout and then writes a file.
|
|
3
|
+
*
|
|
4
|
+
* The marker is the harm, not the mechanism. `Promise.race` in the old
|
|
5
|
+
* executor rejected at the bound and cancelled nothing, so the hook kept its
|
|
6
|
+
* capability objects and went on doing work — registering DNS, opening ports —
|
|
7
|
+
* minutes after celilo reported the deploy failed. A test that only asserts
|
|
8
|
+
* the rejection cannot see that; it passed throughout (celilo#1003).
|
|
9
|
+
*
|
|
10
|
+
* The marker is written AFTER the sleep, so its existence after the bound is
|
|
11
|
+
* proof the hook was abandoned rather than killed.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { writeFileSync } from 'node:fs';
|
|
15
|
+
import { defineHook } from '@celilo/capabilities';
|
|
16
|
+
|
|
17
|
+
export default defineHook({
|
|
18
|
+
hook: 'container_created',
|
|
19
|
+
requires: [],
|
|
20
|
+
handler: async (ctx) => {
|
|
21
|
+
ctx.logger.info('runaway hook starting');
|
|
22
|
+
await new Promise((r) => setTimeout(r, Number(ctx.config.sleep_ms ?? 2000)));
|
|
23
|
+
writeFileSync(String(ctx.config.marker_path), 'the hook kept running\n');
|
|
24
|
+
return {};
|
|
25
|
+
},
|
|
26
|
+
});
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test fixture: a hook that traps SIGTERM and keeps going.
|
|
3
|
+
*
|
|
4
|
+
* SIGTERM is a request. This is the case that makes the grace period load
|
|
5
|
+
* bearing: without the SIGKILL that follows it, a hook can decline to die and
|
|
6
|
+
* the boundary buys nothing over the promise race it replaced.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { writeFileSync } from 'node:fs';
|
|
10
|
+
import { defineHook } from '@celilo/capabilities';
|
|
11
|
+
|
|
12
|
+
export default defineHook({
|
|
13
|
+
hook: 'container_created',
|
|
14
|
+
requires: [],
|
|
15
|
+
handler: async (ctx) => {
|
|
16
|
+
process.on('SIGTERM', () => ctx.logger.info('declining to die'));
|
|
17
|
+
ctx.logger.info('sigterm-ignoring hook starting');
|
|
18
|
+
await new Promise((r) => setTimeout(r, Number(ctx.config.sleep_ms ?? 20_000)));
|
|
19
|
+
writeFileSync(String(ctx.config.marker_path), 'survived SIGTERM\n');
|
|
20
|
+
return {};
|
|
21
|
+
},
|
|
22
|
+
});
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A module may not declare a provider view.
|
|
3
|
+
*
|
|
4
|
+
* `web_routes` and `firewall_registry` are in `KNOWN_CAPABILITY_NAMES`, so the
|
|
5
|
+
* name check that asks only "is this known?" accepts them. celilo injects both
|
|
6
|
+
* into the hooks of the module that PROVIDES the paired capability and never
|
|
7
|
+
* to a consumer, so a module requiring one would validate, publish, deploy, and
|
|
8
|
+
* then find the capability simply absent at hook time — with a pre-flight error
|
|
9
|
+
* naming a missing provider that was never going to exist.
|
|
10
|
+
*
|
|
11
|
+
* Registering the two names (celilo#1007) is what created this opening. They
|
|
12
|
+
* were invisible to every check before that, which was its own, worse problem.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { describe, expect, test } from 'bun:test';
|
|
16
|
+
import { PROVIDER_VIEW_CAPABILITIES } from '@celilo/capabilities';
|
|
17
|
+
import type { ModuleManifest } from './schema';
|
|
18
|
+
import { validateCapabilityNames } from './validate';
|
|
19
|
+
|
|
20
|
+
function manifestRequiring(name: string): ModuleManifest {
|
|
21
|
+
return {
|
|
22
|
+
requires: { capabilities: [{ name, version: '1.0.0' }] },
|
|
23
|
+
} as unknown as ModuleManifest;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function manifestOptionally(name: string): ModuleManifest {
|
|
27
|
+
return {
|
|
28
|
+
requires: { capabilities: [] },
|
|
29
|
+
optional: { capabilities: [{ name, version: '1.0.0' }] },
|
|
30
|
+
} as unknown as ModuleManifest;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
describe('provider views are not declarable', () => {
|
|
34
|
+
for (const view of PROVIDER_VIEW_CAPABILITIES) {
|
|
35
|
+
test(`requires.capabilities rejects '${view}'`, () => {
|
|
36
|
+
const result = validateCapabilityNames(manifestRequiring(view));
|
|
37
|
+
expect(result).not.toBeNull();
|
|
38
|
+
expect(result?.errors[0].message).toContain('provider view');
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test(`optional.capabilities rejects '${view}' too`, () => {
|
|
42
|
+
// The optional path is checked for the same reason the privileged-
|
|
43
|
+
// capability check covers it: otherwise the declaration is smuggled in
|
|
44
|
+
// through the soft-require door.
|
|
45
|
+
const result = validateCapabilityNames(manifestOptionally(view));
|
|
46
|
+
expect(result).not.toBeNull();
|
|
47
|
+
expect(result?.errors[0].message).toContain('provider view');
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test(`'${view}' is not offered in the suggestion list`, () => {
|
|
51
|
+
// The message for a genuine typo lists what an author CAN require, so a
|
|
52
|
+
// view must not appear there either.
|
|
53
|
+
const result = validateCapabilityNames(manifestRequiring('definitely_not_a_capability'));
|
|
54
|
+
expect(result?.errors[0].message).not.toContain(view);
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
test('a real capability still validates', () => {
|
|
59
|
+
expect(validateCapabilityNames(manifestRequiring('public_web'))).toBeNull();
|
|
60
|
+
});
|
|
61
|
+
});
|
package/src/manifest/validate.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { KNOWN_CAPABILITY_NAMES } from '@celilo/capabilities';
|
|
1
|
+
import { KNOWN_CAPABILITY_NAMES, isProviderView } from '@celilo/capabilities';
|
|
2
2
|
import { parse as parseYaml } from 'yaml';
|
|
3
3
|
import type { ZodError } from 'zod';
|
|
4
4
|
import { validateModuleZoneRequirements } from '../services/zone-policy';
|
|
@@ -146,24 +146,31 @@ export function validateCapabilityRequirements(
|
|
|
146
146
|
*/
|
|
147
147
|
export function validateCapabilityNames(manifest: ModuleManifest): ValidationError | null {
|
|
148
148
|
const errors: Array<{ path: string; message: string }> = [];
|
|
149
|
-
|
|
149
|
+
// Provider views are registry entries, so a bare membership test would accept
|
|
150
|
+
// them. They are framework-injected into the PROVIDER's own hooks and no
|
|
151
|
+
// module can ask for one, so requiring one is always a mistake, and a silent
|
|
152
|
+
// one: the manifest would validate and the capability would simply never
|
|
153
|
+
// arrive. Excluded from the suggestion list too, for the same reason.
|
|
154
|
+
const requirableNames: readonly string[] = KNOWN_CAPABILITY_NAMES.filter(
|
|
155
|
+
(name) => !isProviderView(name),
|
|
156
|
+
);
|
|
157
|
+
|
|
158
|
+
const checkName = (name: string, path: string): void => {
|
|
159
|
+
if (requirableNames.includes(name)) return;
|
|
160
|
+
errors.push({
|
|
161
|
+
path,
|
|
162
|
+
message: isProviderView(name)
|
|
163
|
+
? `'${name}' is a provider view, not a capability a module can declare. celilo injects it into the hooks of the module that PROVIDES the paired capability. Remove this declaration.`
|
|
164
|
+
: `Unknown capability '${name}'. Known capabilities: ${requirableNames.join(', ')}.`,
|
|
165
|
+
});
|
|
166
|
+
};
|
|
150
167
|
|
|
151
168
|
for (const required of manifest.requires.capabilities) {
|
|
152
|
-
|
|
153
|
-
errors.push({
|
|
154
|
-
path: `requires.capabilities.${required.name}`,
|
|
155
|
-
message: `Unknown capability '${required.name}'. Known capabilities: ${knownNames.join(', ')}.`,
|
|
156
|
-
});
|
|
157
|
-
}
|
|
169
|
+
checkName(required.name, `requires.capabilities.${required.name}`);
|
|
158
170
|
}
|
|
159
171
|
|
|
160
172
|
for (const opt of manifest.optional?.capabilities ?? []) {
|
|
161
|
-
|
|
162
|
-
errors.push({
|
|
163
|
-
path: `optional.capabilities.${opt.name}`,
|
|
164
|
-
message: `Unknown capability '${opt.name}'. Known capabilities: ${knownNames.join(', ')}.`,
|
|
165
|
-
});
|
|
166
|
-
}
|
|
173
|
+
checkName(opt.name, `optional.capabilities.${opt.name}`);
|
|
167
174
|
}
|
|
168
175
|
|
|
169
176
|
if (errors.length > 0) {
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { moduleIntegrity, modules } from '../../db/schema';
|
|
6
|
+
import { cleanupTestDatabase, setupTestDatabase } from '../../test-utils/database';
|
|
7
|
+
import { auditModule } from './audit';
|
|
8
|
+
import { classifyModulePath } from './package-rules';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The recurrence gate for celilo#1000: a hook has a sanctioned place to write,
|
|
12
|
+
* and what it writes there never becomes a `module audit` finding.
|
|
13
|
+
*
|
|
14
|
+
* **The names below are generated, deliberately.** The failure this gate exists
|
|
15
|
+
* for is not "we forgot to allow `state/cursor.json`". It is that the allow-list
|
|
16
|
+
* was a list of literals (`screenshots/`, `cookies.json`) patched in one at a
|
|
17
|
+
* time after each one bit someone, so it could only ever cover filenames
|
|
18
|
+
* somebody had already been surprised by. A test asserting a literal filename
|
|
19
|
+
* reproduces exactly that weakness. A hook writes what it needs to write, and
|
|
20
|
+
* the framework does not get to know the name in advance.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/** A name nothing in the codebase anticipates, and that no allow-list can hold. */
|
|
24
|
+
function unanticipatedName(seed: number): string {
|
|
25
|
+
return `${seed.toString(36)}-${(seed * 7919).toString(36)}.dat`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
describe('celilo#1000: state/ is the hook-writable directory', () => {
|
|
29
|
+
test('any name a hook invents under state/ is derived, at any depth', () => {
|
|
30
|
+
for (let seed = 1; seed <= 25; seed++) {
|
|
31
|
+
const name = unanticipatedName(seed);
|
|
32
|
+
for (const path of [`state/${name}`, `state/nested/${name}`, `state/a/b/c/${name}`]) {
|
|
33
|
+
expect(`${path} => ${classifyModulePath(path)}`).toBe(`${path} => derived`);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The contrast is the point, and `package` rather than `unknown` is what the
|
|
40
|
+
* contrast actually is. `classifyModulePath` defaults to `package`, meaning
|
|
41
|
+
* "this belongs to the module and must match `checksums.json`", so the same
|
|
42
|
+
* name one directory up is scanned, found absent from the checksums, and
|
|
43
|
+
* reported. That is the reporting this change exempts `state/` from, and
|
|
44
|
+
* exempts nothing else from. If this half ever goes green alongside the half
|
|
45
|
+
* above, the fix widened rather than named.
|
|
46
|
+
*/
|
|
47
|
+
test('the same names outside state/ are still checksum-bearing', () => {
|
|
48
|
+
for (let seed = 1; seed <= 25; seed++) {
|
|
49
|
+
const name = unanticipatedName(seed);
|
|
50
|
+
expect(`${name} => ${classifyModulePath(name)}`).toBe(`${name} => package`);
|
|
51
|
+
expect(`lib/${name} => ${classifyModulePath(`lib/${name}`)}`).toBe(`lib/${name} => package`);
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('a hook writing into state/ leaves module audit clean', async () => {
|
|
56
|
+
const db = await setupTestDatabase();
|
|
57
|
+
const root = mkdtempSync(join(tmpdir(), 'celilo-state-gate-'));
|
|
58
|
+
try {
|
|
59
|
+
// A minimal installed tree: one packaged file, recorded in checksums.
|
|
60
|
+
writeFileSync(join(root, 'manifest.yml'), 'id: state-gate\nversion: 1.0.0\n');
|
|
61
|
+
db.insert(modules)
|
|
62
|
+
.values({
|
|
63
|
+
id: 'state-gate',
|
|
64
|
+
name: 'state-gate',
|
|
65
|
+
version: '1.0.0',
|
|
66
|
+
sourcePath: root,
|
|
67
|
+
manifestData: { id: 'state-gate', version: '1.0.0' },
|
|
68
|
+
})
|
|
69
|
+
.run();
|
|
70
|
+
db.insert(moduleIntegrity)
|
|
71
|
+
.values({
|
|
72
|
+
moduleId: 'state-gate',
|
|
73
|
+
checksums: { 'manifest.yml': await xxhashOf(join(root, 'manifest.yml')) },
|
|
74
|
+
version: '1.0.0',
|
|
75
|
+
})
|
|
76
|
+
.run();
|
|
77
|
+
|
|
78
|
+
const before = await auditModule('state-gate', db);
|
|
79
|
+
expect(before.violations).toEqual([]);
|
|
80
|
+
|
|
81
|
+
// Now a hook runs and writes something nobody declared.
|
|
82
|
+
mkdirSync(join(root, 'state'), { recursive: true });
|
|
83
|
+
writeFileSync(join(root, 'state', unanticipatedName(42)), 'whatever the hook needed');
|
|
84
|
+
|
|
85
|
+
const after = await auditModule('state-gate', db);
|
|
86
|
+
expect(after.violations).toEqual([]);
|
|
87
|
+
expect(after.success).toBe(true);
|
|
88
|
+
} finally {
|
|
89
|
+
rmSync(root, { recursive: true, force: true });
|
|
90
|
+
await cleanupTestDatabase(db);
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
/** The audit's own hash, so the fixture's checksum is right by construction. */
|
|
96
|
+
async function xxhashOf(path: string): Promise<string> {
|
|
97
|
+
const { readFileSync } = await import('node:fs');
|
|
98
|
+
return Bun.hash.xxHash64(readFileSync(path)).toString(16);
|
|
99
|
+
}
|
|
@@ -94,8 +94,16 @@ export function classifyModulePath(relPath: string): ModulePathClass {
|
|
|
94
94
|
if (name === 'tsconfig.json') return 'unknown';
|
|
95
95
|
if (name.endsWith('.netapp') || name.endsWith('.test.ts')) return 'unknown';
|
|
96
96
|
|
|
97
|
-
// Celilo's own output under the module's install root
|
|
98
|
-
|
|
97
|
+
// Celilo's own output under the module's install root, plus the one directory
|
|
98
|
+
// a MODULE may write to. `state/` is celilo#1000: hooks had nowhere sanctioned
|
|
99
|
+
// to put anything, so whatever they wrote surfaced as an `extra` finding, and
|
|
100
|
+
// the two entries beside it here (`screenshots/`, `cookies.json`) are what
|
|
101
|
+
// that looked like being solved one filename at a time. `derived` already
|
|
102
|
+
// means exactly what a scratch location needs (writable, survives `module
|
|
103
|
+
// update`, not audited, not pruned), so this names a directory rather than
|
|
104
|
+
// adding machinery.
|
|
105
|
+
if (segments[0] === 'generated' || segments[0] === 'screenshots' || segments[0] === 'state')
|
|
106
|
+
return 'derived';
|
|
99
107
|
// A checksum manifest cannot list itself, nor the signature over it.
|
|
100
108
|
if (relPath === 'checksums.json' || relPath === 'signature.sig') return 'derived';
|
|
101
109
|
// Regenerated by `module import` from the manifest (HOOK_API_V2 Phase 2).
|
|
@@ -85,4 +85,12 @@ export const CAPABILITY_SHAPE_BASELINE: Readonly<Record<string, CapabilityShape>
|
|
|
85
85
|
version: '1.0.0',
|
|
86
86
|
hash: 'e152f0738c88a7105b6057037f350ce0bbcbfc5c4ccbccc3af60f1cf3f1904af',
|
|
87
87
|
},
|
|
88
|
+
web_routes: {
|
|
89
|
+
version: '1.0.0',
|
|
90
|
+
hash: 'bbf6435528a799e205270681e0ffc34f4007efbf04240138d4b69594a2515dbb',
|
|
91
|
+
},
|
|
92
|
+
firewall_registry: {
|
|
93
|
+
version: '2.0.0',
|
|
94
|
+
hash: 'db266155107b9eba88dbff576f979069e012e10a6dfb80de4544f809d1157bfb',
|
|
95
|
+
},
|
|
88
96
|
};
|
|
@@ -65,7 +65,19 @@ export function capabilitySubjects(): Map<string, { typeName: string; file: stri
|
|
|
65
65
|
const name = member.name.getText(source);
|
|
66
66
|
const typeName = member.type.getText(source);
|
|
67
67
|
const file = fileOfType.get(typeName);
|
|
68
|
-
if (file)
|
|
68
|
+
if (!file) {
|
|
69
|
+
// LOUD, not skipped. A member whose type is not a bare imported
|
|
70
|
+
// identifier — an intersection, a generic, a locally-declared type —
|
|
71
|
+
// would otherwise drop out of coverage silently, and the gate would go
|
|
72
|
+
// green having checked one capability fewer. That is the same shape as
|
|
73
|
+
// the bugs this whole change kept finding: an absence that reads as a
|
|
74
|
+
// pass. If a registry member legitimately needs a composite type, teach
|
|
75
|
+
// this resolver about it rather than letting it vanish.
|
|
76
|
+
throw new Error(
|
|
77
|
+
`capability-shape: '${name}' has type '${typeName}', which is not a bare identifier imported into capability-registry.ts, so its shape cannot be hashed. Teach capabilitySubjects() how to resolve it.`,
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
subjects.set(name, { typeName, file });
|
|
69
81
|
}
|
|
70
82
|
}
|
|
71
83
|
return subjects;
|