@celilo/cli 2.1.0 → 2.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/drizzle/0031_module_config_source.sql +20 -0
- package/drizzle/meta/_journal.json +8 -1
- package/package.json +2 -2
- package/schemas/system_config.json +2 -1
- package/src/capabilities/public-web-publish.test.ts +61 -0
- package/src/cli/commands/firewall-interface-list.test.ts +156 -7
- package/src/cli/commands/firewall-interface-list.ts +73 -7
- package/src/cli/commands/machine-add.ts +12 -55
- package/src/cli/commands/module-config.test.ts +20 -1
- package/src/cli/commands/module-import.ts +1 -1
- package/src/cli/commands/module-update.test.ts +82 -0
- package/src/cli/commands/module-update.ts +14 -4
- package/src/cli/commands/monitor.ts +2 -10
- package/src/cli/commands/restore.ts +16 -6
- package/src/cli/generate-zsh-completion.ts +1 -1
- package/src/cli/index.ts +4 -3
- package/src/cli/restore-migration-failure.test.ts +159 -0
- package/src/db/client.ts +5 -0
- package/src/db/migrate.test.ts +61 -135
- package/src/db/migrate.ts +7 -2
- package/src/db/schema.ts +10 -0
- package/src/hooks/broker.test.ts +106 -2
- package/src/hooks/broker.ts +91 -1
- package/src/hooks/capability-loader-firewall.test.ts +37 -0
- package/src/hooks/capability-loader.ts +15 -1
- package/src/hooks/define-hook.test.ts +4 -3
- package/src/hooks/executor.test.ts +19 -18
- package/src/hooks/executor.ts +82 -11
- 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 +162 -0
- package/src/hooks/hook-store.ts +290 -0
- package/src/hooks/hook-timeout.test.ts +3 -2
- package/src/hooks/hook-trespass.test.ts +29 -5
- package/src/hooks/jail.test.ts +1 -1
- package/src/hooks/jail.ts +14 -7
- 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/build.ts +70 -2
- package/src/module/web-root.ts +17 -1
- package/src/policy/fixture-capability-coverage.test.ts +322 -0
- package/src/policy/module-script-scan.test.ts +42 -1
- package/src/policy/module-script-scan.ts +275 -5
- package/src/policy/no-hand-built-ssh.test.ts +34 -1
- package/src/policy/no-swallowed-refusal.test.ts +265 -0
- package/src/policy/no-tar-shell-out-in-services.test.ts +43 -0
- package/src/registry/client.test.ts +149 -0
- package/src/registry/client.ts +203 -11
- package/src/services/alerting/format.test.ts +57 -0
- package/src/services/alerting/format.ts +24 -0
- package/src/services/alerting/run-monitor.ts +2 -2
- package/src/services/backup-create.ts +7 -7
- package/src/services/backup-envelope-roundtrip.test.ts +45 -2
- package/src/services/backup-restore.ts +8 -4
- package/src/services/bus-interview.ts +37 -14
- package/src/services/config-provenance.ts +4 -0
- package/src/services/control-plane-bootstrap.test.ts +121 -1
- package/src/services/control-plane-bootstrap.ts +51 -4
- package/src/services/deploy-preflight.ts +8 -2
- package/src/services/deploy-validation.test.ts +22 -0
- package/src/services/deploy-validation.ts +8 -0
- package/src/services/dns-discovery.test.ts +54 -0
- package/src/services/dns-discovery.ts +47 -5
- package/src/services/fleet-key.test.ts +66 -2
- package/src/services/fleet-key.ts +54 -0
- package/src/services/health-runner.ts +2 -0
- package/src/services/module-config.ts +20 -2
- package/src/services/module-deploy.dns-repoint.test.ts +187 -0
- package/src/services/module-deploy.ts +163 -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/restore-from-file.test.ts +20 -0
- package/src/services/restore-from-file.ts +21 -6
- package/src/services/static-content-converge.test.ts +140 -2
- package/src/services/static-content-converge.ts +55 -8
- package/src/services/system-config-schema-types.ts +1 -1
- package/src/services/system-config-validator.test.ts +36 -0
- package/src/services/system-config-validator.ts +11 -0
- package/src/services/trusted-sources.test.ts +30 -0
- package/src/services/trusted-sources.ts +47 -10
- package/src/templates/generator.ts +9 -2
- package/src/variables/context.ts +16 -5
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The broker side of the hook-owned-state accessor (hook-owned-state tasks
|
|
3
|
+
* 3.1-3.4, design option C).
|
|
4
|
+
*
|
|
5
|
+
* A hook runs out of process and reaches its own persistent state only through
|
|
6
|
+
* the wire (see `hook-protocol.ts` `StoreCallFrame`). This module is the thing
|
|
7
|
+
* those frames land on: two stores over `(db, moduleId, manifest, masterKey)`
|
|
8
|
+
* — `secrets`, validated against `manifest.secrets.declares` and encrypted,
|
|
9
|
+
* and `config`, validated against `variables.owns` where `source: hook` and
|
|
10
|
+
* stored plaintext in `module_configs`.
|
|
11
|
+
*
|
|
12
|
+
* Validation is against the MANIFEST, not the database. The whole point
|
|
13
|
+
* (design D2) is that a typo in a hook is a loud error naming the module and
|
|
14
|
+
* the declared set, never a phantom row some later reader mistakes for state.
|
|
15
|
+
*
|
|
16
|
+
* The transaction is applied inside one `db.transaction` (same mechanism as
|
|
17
|
+
* IPAM allocation in `variables/context.ts`). The buffering half lives
|
|
18
|
+
* child-side in the runner proxy, so a throwing `fn` sends no frame at all;
|
|
19
|
+
* what arrives here is a finished operation list that either applies whole or
|
|
20
|
+
* throws whole.
|
|
21
|
+
*
|
|
22
|
+
* Execution functions (Rule 10.1) — each method performs its own storage
|
|
23
|
+
* effects; construction is pure planning.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { and, eq } from 'drizzle-orm';
|
|
27
|
+
import { z } from 'zod';
|
|
28
|
+
import type { DbClient } from '../db/client';
|
|
29
|
+
import { moduleConfigs, modules, secrets } from '../db/schema';
|
|
30
|
+
import { type EncryptedSecret, decryptSecret, encryptSecret } from '../secrets/encryption';
|
|
31
|
+
import { getOrCreateMasterKey } from '../secrets/master-key';
|
|
32
|
+
import { deleteModuleConfig, upsertModuleConfig } from '../services/module-config';
|
|
33
|
+
import type { BufferedStoreOp } from './hook-protocol';
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The manifest surface the stores validate against, read out of
|
|
37
|
+
* `modules.manifestData` and narrowed. Everything else in the manifest is
|
|
38
|
+
* irrelevant here — this is deliberately a projection, not a parse of the
|
|
39
|
+
* whole schema, so a manifest feature the stores never read cannot break a
|
|
40
|
+
* hook run.
|
|
41
|
+
*/
|
|
42
|
+
const StoreManifestSchema = z.object({
|
|
43
|
+
secrets: z
|
|
44
|
+
.object({ declares: z.array(z.object({ name: z.string() }).passthrough()).optional() })
|
|
45
|
+
.optional(),
|
|
46
|
+
variables: z
|
|
47
|
+
.object({
|
|
48
|
+
owns: z
|
|
49
|
+
.array(z.object({ name: z.string(), source: z.string().optional() }).passthrough())
|
|
50
|
+
.optional(),
|
|
51
|
+
})
|
|
52
|
+
.optional(),
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
/** The two stores a hook may touch, keyed as the wire names them. */
|
|
56
|
+
export interface HookStores {
|
|
57
|
+
readonly secrets: HookStoreBackend;
|
|
58
|
+
readonly config: HookStoreBackend;
|
|
59
|
+
/** Diagnostic mirror of what each store validates against. */
|
|
60
|
+
readonly declaredSecretNames: string[];
|
|
61
|
+
readonly declaredConfigNames: string[];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* One store's answering half. Shaped like `HookStore` from
|
|
66
|
+
* `@celilo/capabilities` except that `transaction` takes the operation list
|
|
67
|
+
* the child buffered — the buffering happens there, the atomicity here.
|
|
68
|
+
*/
|
|
69
|
+
export interface HookStoreBackend {
|
|
70
|
+
get(name: string): Promise<string | undefined>;
|
|
71
|
+
set(name: string, value: string): Promise<void>;
|
|
72
|
+
delete(name: string): Promise<void>;
|
|
73
|
+
applyTransaction(ops: readonly BufferedStoreOp[]): Promise<void>;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Throw for an undeclared name, naming the module and the declared set
|
|
78
|
+
* (task 3.4 — ERROR, not warning, design D2).
|
|
79
|
+
*/
|
|
80
|
+
function undeclared(
|
|
81
|
+
moduleId: string,
|
|
82
|
+
kind: 'secret' | 'hook-owned config',
|
|
83
|
+
name: string,
|
|
84
|
+
declared: string[],
|
|
85
|
+
): Error {
|
|
86
|
+
const declaredList = declared.length > 0 ? declared.join(', ') : '(none)';
|
|
87
|
+
const where =
|
|
88
|
+
kind === 'secret'
|
|
89
|
+
? `Add '${name}' to secrets.declares in the module's manifest.yml.`
|
|
90
|
+
: `Add '${name}' to variables.owns with source: hook in the module's manifest.yml.`;
|
|
91
|
+
return new Error(
|
|
92
|
+
`Module '${moduleId}' has no declared ${kind} '${name}'. Declared ${kind} names: ${declaredList}. ${where}`,
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Secrets store: encrypted rows in the `secrets` table, keyed `(moduleId, name)`. */
|
|
97
|
+
function createSecretsStore(db: DbClient, moduleId: string, declared: string[]): HookStoreBackend {
|
|
98
|
+
const assertDeclared = (name: string) => {
|
|
99
|
+
if (!declared.includes(name)) throw undeclared(moduleId, 'secret', name, declared);
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
const upsert = (name: string, encrypted: EncryptedSecret) => {
|
|
103
|
+
const existing = db
|
|
104
|
+
.select()
|
|
105
|
+
.from(secrets)
|
|
106
|
+
.where(and(eq(secrets.moduleId, moduleId), eq(secrets.name, name)))
|
|
107
|
+
.get();
|
|
108
|
+
if (existing) {
|
|
109
|
+
db.update(secrets)
|
|
110
|
+
.set({
|
|
111
|
+
encryptedValue: encrypted.encryptedValue,
|
|
112
|
+
iv: encrypted.iv,
|
|
113
|
+
authTag: encrypted.authTag,
|
|
114
|
+
updatedAt: new Date(),
|
|
115
|
+
})
|
|
116
|
+
.where(eq(secrets.id, existing.id))
|
|
117
|
+
.run();
|
|
118
|
+
} else {
|
|
119
|
+
db.insert(secrets)
|
|
120
|
+
.values({ moduleId, name, ...encrypted })
|
|
121
|
+
.run();
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
return {
|
|
126
|
+
async get(name) {
|
|
127
|
+
assertDeclared(name);
|
|
128
|
+
const row = db
|
|
129
|
+
.select()
|
|
130
|
+
.from(secrets)
|
|
131
|
+
.where(and(eq(secrets.moduleId, moduleId), eq(secrets.name, name)))
|
|
132
|
+
.get();
|
|
133
|
+
if (!row) return undefined;
|
|
134
|
+
const masterKey = await getOrCreateMasterKey();
|
|
135
|
+
return decryptSecret(
|
|
136
|
+
{ encryptedValue: row.encryptedValue, iv: row.iv, authTag: row.authTag },
|
|
137
|
+
masterKey,
|
|
138
|
+
);
|
|
139
|
+
},
|
|
140
|
+
|
|
141
|
+
async set(name, value) {
|
|
142
|
+
assertDeclared(name);
|
|
143
|
+
const masterKey = await getOrCreateMasterKey();
|
|
144
|
+
upsert(name, encryptSecret(value, masterKey));
|
|
145
|
+
},
|
|
146
|
+
|
|
147
|
+
async delete(name) {
|
|
148
|
+
assertDeclared(name);
|
|
149
|
+
db.delete(secrets)
|
|
150
|
+
.where(and(eq(secrets.moduleId, moduleId), eq(secrets.name, name)))
|
|
151
|
+
.run();
|
|
152
|
+
},
|
|
153
|
+
|
|
154
|
+
async applyTransaction(ops) {
|
|
155
|
+
// Validate EVERY name before writing ANY row: a batch with one undeclared
|
|
156
|
+
// name must discard whole, not half-apply then throw (task 3.7).
|
|
157
|
+
for (const entry of ops) assertDeclared(entry.name);
|
|
158
|
+
const masterKey = await getOrCreateMasterKey();
|
|
159
|
+
db.transaction((tx) => {
|
|
160
|
+
for (const entry of ops) {
|
|
161
|
+
if (entry.op === 'set') {
|
|
162
|
+
upsertTx(
|
|
163
|
+
tx as unknown as DbClient,
|
|
164
|
+
entry.name,
|
|
165
|
+
encryptSecret(entry.value ?? '', masterKey),
|
|
166
|
+
);
|
|
167
|
+
} else {
|
|
168
|
+
tx.delete(secrets)
|
|
169
|
+
.where(and(eq(secrets.moduleId, moduleId), eq(secrets.name, entry.name)))
|
|
170
|
+
.run();
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
},
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
function upsertTx(tx: DbClient, name: string, encrypted: EncryptedSecret): void {
|
|
178
|
+
const existing = tx
|
|
179
|
+
.select()
|
|
180
|
+
.from(secrets)
|
|
181
|
+
.where(and(eq(secrets.moduleId, moduleId), eq(secrets.name, name)))
|
|
182
|
+
.get();
|
|
183
|
+
if (existing) {
|
|
184
|
+
tx.update(secrets)
|
|
185
|
+
.set({
|
|
186
|
+
encryptedValue: encrypted.encryptedValue,
|
|
187
|
+
iv: encrypted.iv,
|
|
188
|
+
authTag: encrypted.authTag,
|
|
189
|
+
updatedAt: new Date(),
|
|
190
|
+
})
|
|
191
|
+
.where(eq(secrets.id, existing.id))
|
|
192
|
+
.run();
|
|
193
|
+
} else {
|
|
194
|
+
tx.insert(secrets)
|
|
195
|
+
.values({ moduleId, name, ...encrypted })
|
|
196
|
+
.run();
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Config store: plaintext rows in `module_configs`, `source: hook`. */
|
|
202
|
+
function createConfigStore(db: DbClient, moduleId: string, declared: string[]): HookStoreBackend {
|
|
203
|
+
const assertDeclared = (name: string) => {
|
|
204
|
+
if (!declared.includes(name)) {
|
|
205
|
+
throw undeclared(moduleId, 'hook-owned config', name, declared);
|
|
206
|
+
}
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
return {
|
|
210
|
+
async get(name) {
|
|
211
|
+
assertDeclared(name);
|
|
212
|
+
const row = db
|
|
213
|
+
.select()
|
|
214
|
+
.from(moduleConfigs)
|
|
215
|
+
.where(and(eq(moduleConfigs.moduleId, moduleId), eq(moduleConfigs.key, name)))
|
|
216
|
+
.get();
|
|
217
|
+
// Hook-owned rows are always written through `set`, so `valueJson` holds
|
|
218
|
+
// a JSON string literal of exactly what the hook sent. Absent means never
|
|
219
|
+
// written; null valueJson is a legacy row shape no hook-owned key can
|
|
220
|
+
// have (the source column did not exist when those were written).
|
|
221
|
+
if (!row?.valueJson) return undefined;
|
|
222
|
+
const parsed: unknown = JSON.parse(row.valueJson);
|
|
223
|
+
return typeof parsed === 'string' ? parsed : JSON.stringify(parsed);
|
|
224
|
+
},
|
|
225
|
+
|
|
226
|
+
async set(name, value) {
|
|
227
|
+
assertDeclared(name);
|
|
228
|
+
upsertModuleConfig(db, moduleId, name, value, 'hook');
|
|
229
|
+
},
|
|
230
|
+
|
|
231
|
+
async delete(name) {
|
|
232
|
+
assertDeclared(name);
|
|
233
|
+
deleteModuleConfig(db, moduleId, name);
|
|
234
|
+
},
|
|
235
|
+
|
|
236
|
+
async applyTransaction(ops) {
|
|
237
|
+
for (const entry of ops) assertDeclared(entry.name);
|
|
238
|
+
db.transaction((tx) => {
|
|
239
|
+
// Same narrowing the secrets store applies below: drizzle hands the
|
|
240
|
+
// callback a transaction handle, and module-config's writers accept
|
|
241
|
+
// the full client it derives from.
|
|
242
|
+
const txDb = tx as unknown as DbClient;
|
|
243
|
+
for (const entry of ops) {
|
|
244
|
+
if (entry.op === 'set') {
|
|
245
|
+
upsertModuleConfig(txDb, moduleId, entry.name, entry.value ?? '', 'hook');
|
|
246
|
+
} else {
|
|
247
|
+
deleteModuleConfig(txDb, moduleId, entry.name);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
});
|
|
251
|
+
},
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Build both stores for a module from its stored manifest.
|
|
257
|
+
*
|
|
258
|
+
* The master key is NOT touched here — it loads lazily inside the operations
|
|
259
|
+
* that need it, so building stores for a hook that never writes a secret has
|
|
260
|
+
* no key-file side effect.
|
|
261
|
+
*
|
|
262
|
+
* Execution function — reads the module row. Throws when the module row or its
|
|
263
|
+
* manifest is missing, because a hook for a module celilo cannot describe is a
|
|
264
|
+
* run that cannot be attributed.
|
|
265
|
+
*/
|
|
266
|
+
export async function createHookStores(db: DbClient, moduleId: string): Promise<HookStores> {
|
|
267
|
+
const module = db.select().from(modules).where(eq(modules.id, moduleId)).get();
|
|
268
|
+
if (!module) {
|
|
269
|
+
throw new Error(`Cannot build hook stores: module '${moduleId}' not found.`);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const parsed = StoreManifestSchema.safeParse(module.manifestData);
|
|
273
|
+
if (!parsed.success) {
|
|
274
|
+
throw new Error(
|
|
275
|
+
`Cannot build hook stores for '${moduleId}': stored manifest does not match the expected shape (${parsed.error.issues[0]?.message ?? 'invalid'}).`,
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const declaredSecretNames = (parsed.data.secrets?.declares ?? []).map((s) => s.name);
|
|
280
|
+
const declaredConfigNames = (parsed.data.variables?.owns ?? [])
|
|
281
|
+
.filter((v) => v.source === 'hook')
|
|
282
|
+
.map((v) => v.name);
|
|
283
|
+
|
|
284
|
+
return {
|
|
285
|
+
secrets: createSecretsStore(db, moduleId, declaredSecretNames),
|
|
286
|
+
config: createConfigStore(db, moduleId, declaredConfigNames),
|
|
287
|
+
declaredSecretNames,
|
|
288
|
+
declaredConfigNames,
|
|
289
|
+
};
|
|
290
|
+
}
|
|
@@ -23,6 +23,7 @@ import { tmpdir } from 'node:os';
|
|
|
23
23
|
import { join } from 'node:path';
|
|
24
24
|
import { executeHookScript } from './executor';
|
|
25
25
|
import { createCapturingLogger } from './logger';
|
|
26
|
+
import { configStore, secretStore } from './test-fixtures/store-backed';
|
|
26
27
|
import type { HookContext } from './types';
|
|
27
28
|
|
|
28
29
|
const FIXTURES = join(__dirname, 'test-fixtures');
|
|
@@ -40,8 +41,8 @@ function scratch(): string {
|
|
|
40
41
|
|
|
41
42
|
function contextFor(config: Record<string, unknown>): HookContext {
|
|
42
43
|
return {
|
|
43
|
-
config,
|
|
44
|
-
secrets:
|
|
44
|
+
config: configStore(config),
|
|
45
|
+
secrets: secretStore(),
|
|
45
46
|
systems: [],
|
|
46
47
|
logger: createCapturingLogger().logger,
|
|
47
48
|
debug: false,
|
|
@@ -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 { 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,25 @@ 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 runtime directory so the child reaches the parent CLI', () => {
|
|
250
|
+
const child = childPath('/usr/local/bin:/usr/bin');
|
|
251
|
+
expect(child.startsWith(`${dirname(process.execPath)}:`)).toBe(true);
|
|
252
|
+
expect(child.endsWith('/usr/local/bin:/usr/bin')).toBe(true);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
test('leaves an inherited PATH that already carries the directory unchanged', () => {
|
|
256
|
+
const already = `${dirname(process.execPath)}:/usr/bin`;
|
|
257
|
+
expect(childPath(already)).toBe(already);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
test('substitutes the directory when the parent has no PATH at all', () => {
|
|
261
|
+
expect(childPath(undefined)).toBe(dirname(process.execPath));
|
|
262
|
+
});
|
|
263
|
+
});
|
|
240
264
|
});
|
|
241
265
|
|
|
242
266
|
describe('hook process boundary — hello-trespass gate', () => {
|
package/src/hooks/jail.test.ts
CHANGED
|
@@ -137,7 +137,7 @@ describe('a mount whose source is missing is dropped, not fatal', () => {
|
|
|
137
137
|
'auto',
|
|
138
138
|
exists,
|
|
139
139
|
);
|
|
140
|
-
expect(skipped).toContain(missing);
|
|
140
|
+
expect(skipped.map((m) => m.path)).toContain(missing);
|
|
141
141
|
expect(cmd.join(' ')).not.toContain(missing);
|
|
142
142
|
});
|
|
143
143
|
|
package/src/hooks/jail.ts
CHANGED
|
@@ -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
|
*
|
|
@@ -612,7 +619,7 @@ export function planJailedSpawn(
|
|
|
612
619
|
// reaches the sandbox-exec arm while SANDBOX_EXEC_AUTO_JAIL_ENABLED is
|
|
613
620
|
// false: the flag flip is D14's landing, never 4.8's.
|
|
614
621
|
const present = set.entries.filter((e) => e.mode === 'tmpfs' || exists(e.path));
|
|
615
|
-
const skipped = set.entries.filter((e) => !present.includes(e))
|
|
622
|
+
const skipped = set.entries.filter((e) => !present.includes(e));
|
|
616
623
|
const applied: MountSet = { ...set, entries: present };
|
|
617
624
|
|
|
618
625
|
// 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
|
+
});
|