@celilo/cli 2.2.1 → 2.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CELILO_CORE_MODULES.md +1 -0
- package/CELILO_SUBSYSTEMS.md +1 -1
- package/README.md +1 -1
- package/drizzle/0032_module_jail_policies.sql +28 -0
- package/drizzle/0033_build_bus_hook_runs.sql +37 -0
- package/drizzle/meta/_journal.json +15 -1
- package/package.json +3 -3
- package/src/__integration__/container-services-cli.integration.test.ts +1 -1
- package/src/api/sessions.test.ts +3 -3
- package/src/api-clients/proxmox.ts +10 -7
- package/src/cli/backup-rename.test.ts +3 -3
- package/src/cli/cli.test.ts +1 -1
- package/src/cli/commands/events.test.ts +2 -2
- package/src/cli/commands/firewall-interface-list.test.ts +2 -2
- package/src/cli/commands/machine-list.test.ts +57 -0
- package/src/cli/commands/machine-list.ts +35 -4
- package/src/cli/commands/module-health.ts +1 -0
- package/src/cli/commands/module-import-registry.test.ts +1 -1
- package/src/cli/commands/module-jail.test.ts +242 -0
- package/src/cli/commands/module-jail.ts +227 -0
- package/src/cli/commands/module-list-jail.test.ts +136 -0
- package/src/cli/commands/module-list.ts +30 -3
- package/src/cli/commands/module-publish.test.ts +6 -6
- package/src/cli/commands/module-update.test.ts +22 -7
- package/src/cli/commands/module-update.ts +4 -1
- package/src/cli/commands/module-upgrade.test.ts +115 -1
- package/src/cli/commands/module-upgrade.ts +23 -1
- package/src/cli/commands/module-verify.test.ts +1 -1
- package/src/cli/commands/publish/execute.ts +4 -1
- package/src/cli/commands/publish/index.ts +11 -1
- package/src/cli/commands/publish/module-registry.test.ts +24 -1
- package/src/cli/commands/publish/module-registry.ts +23 -4
- package/src/cli/commands/publish/plan.ts +1 -1
- package/src/cli/commands/publish/types.ts +7 -0
- package/src/cli/commands/registry-owner.test.ts +1 -1
- package/src/cli/commands/registry-token.test.ts +1 -1
- package/src/cli/commands/system-audit.ts +20 -6
- package/src/cli/commands/system-doctor-remediation-gate.test.ts +295 -0
- package/src/cli/commands/system-doctor.test.ts +76 -10
- package/src/cli/commands/system-doctor.ts +34 -3
- package/src/cli/commands/system-init-deprecation.test.ts +1 -1
- package/src/cli/commands/system-update.ts +5 -0
- package/src/cli/completion.ts +8 -2
- package/src/cli/flag-surface-gate.test.ts +279 -0
- package/src/cli/index.ts +68 -2
- package/src/cli/parser.test.ts +37 -1
- package/src/cli/restore-command.test.ts +3 -3
- package/src/cli/restore-migration-failure.test.ts +2 -2
- package/src/cli/tui/audit-state.ts +2 -0
- package/src/config/paths.test.ts +19 -19
- package/src/db/client.test.ts +46 -1
- package/src/db/client.ts +26 -0
- package/src/db/schema.ts +63 -0
- package/src/hooks/capability-loader-firewall.test.ts +9 -1
- package/src/hooks/capability-loader.ts +8 -0
- package/src/hooks/executor.test.ts +86 -12
- package/src/hooks/executor.ts +31 -1
- package/src/hooks/hook-jail-unreachability.test.ts +17 -18
- package/src/hooks/hook-trespass.test.ts +9 -2
- package/src/hooks/jail.test.ts +105 -17
- package/src/hooks/jail.ts +47 -10
- package/src/manifest/contracts/v1.ts +13 -0
- package/src/policy/module-script-scan.ts +52 -68
- package/src/policy/no-hand-built-ssh.test.ts +5 -1
- package/src/policy/no-swallowed-refusal.test.ts +14 -14
- package/src/registry/client.test.ts +2 -2
- package/src/secrets/storage.test.ts +1 -1
- package/src/services/alerting/keys.test.ts +4 -0
- package/src/services/alerting/keys.ts +12 -2
- package/src/services/audit/health.test.ts +97 -2
- package/src/services/audit/health.ts +64 -2
- package/src/services/audit/index.test.ts +22 -0
- package/src/services/audit/index.ts +8 -2
- package/src/services/audit/jail-exemptions.test.ts +42 -0
- package/src/services/audit/jail-exemptions.ts +44 -0
- package/src/services/audit/module-integrity.test.ts +23 -1
- package/src/services/audit/module-integrity.ts +7 -2
- package/src/services/audit/types.ts +2 -1
- package/src/services/backup-create.ts +9 -0
- package/src/services/backup-envelope-roundtrip.test.ts +1 -1
- package/src/services/backup-in-flight-refusal.test.ts +1 -1
- package/src/services/build-bus/hook-dispatch-mgmt.test.ts +48 -0
- package/src/services/build-bus/hook-dispatcher.ts +49 -1
- package/src/services/bus-ensure-flow.test.ts +1 -1
- package/src/services/bus-interview-park.test.ts +2 -2
- package/src/services/bus-interview.test.ts +2 -2
- package/src/services/bus-secret-flow.test.ts +1 -1
- package/src/services/celilo-events.test.ts +1 -1
- package/src/services/celilo-mgmt-hooks.test.ts +23 -5
- package/src/services/container-service.test.ts +1 -1
- package/src/services/cross-module-read.test.ts +2 -2
- package/src/services/deploy-preflight.ts +7 -0
- package/src/services/deployed-systems.ts +1 -1
- package/src/services/fleet-checks.test.ts +123 -4
- package/src/services/fleet-checks.ts +176 -3
- package/src/services/health-runner.test.ts +87 -2
- package/src/services/health-runner.ts +40 -11
- package/src/services/infrastructure-selector.test.ts +1 -1
- package/src/services/jail-exemptions.test.ts +125 -0
- package/src/services/jail-exemptions.ts +81 -0
- package/src/services/machine-pool.test.ts +1 -1
- package/src/services/module-deploy.ts +12 -4
- package/src/services/module-subscriptions.test.ts +2 -2
- package/src/services/network-discovery.test.ts +64 -1
- package/src/services/network-discovery.ts +30 -5
- package/src/services/responder-probe.test.ts +1 -1
- package/src/services/restore-from-file.test.ts +4 -4
- package/src/services/restore-preflight.test.ts +1 -1
- package/src/services/ssh-key-manager.test.ts +2 -2
- package/src/services/system-state-stage.test.ts +2 -2
- package/src/services/update/orchestrator.test.ts +2 -0
- package/src/test-utils/bus-responder.ts +1 -1
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Doctor remediation-arrow gate (celilo#1308).
|
|
3
|
+
*
|
|
4
|
+
* `celilo system doctor` renders `→ <command>` remediation lines under failing
|
|
5
|
+
* checks. Those strings are built by hand in three files and were never checked
|
|
6
|
+
* against the CLI's own command table, so doctor prescribed commands that
|
|
7
|
+
* cannot run:
|
|
8
|
+
*
|
|
9
|
+
* - `celilo module update <module-id>` — `module update` takes a PATH, so a
|
|
10
|
+
* module id names an argument form that does not exist (the lunacycle case
|
|
11
|
+
* in #1308).
|
|
12
|
+
* - `celilo system doctor --deep` — the doctor's own instruction, rejected by
|
|
13
|
+
* `checkFlags` because `deep` was never declared on `system doctor`.
|
|
14
|
+
* - `celilo capability chain <module> <var>` — a command the ISS-0115 rework
|
|
15
|
+
* removed from the registry.
|
|
16
|
+
*
|
|
17
|
+
* The gate scans the three files that produce the doctor's `→` surface
|
|
18
|
+
* (system-doctor.ts, fleet-checks.ts, module-integrity.ts) for `celilo ...`
|
|
19
|
+
* command spans and validates each against COMMANDS from @celilo/core:
|
|
20
|
+
*
|
|
21
|
+
* 1. every command word must resolve along the registry tree,
|
|
22
|
+
* 2. every flag must be declared on the leaf command,
|
|
23
|
+
* 3. positional count must fit the leaf command's declared args,
|
|
24
|
+
* 4. a value known to be a module id must never fill a path slot (an arg whose
|
|
25
|
+
* completion is `directories` or `files`), variadic or not.
|
|
26
|
+
*
|
|
27
|
+
* Commands are parsed, never executed. A span is "strict" when a quote or
|
|
28
|
+
* backtick delimits it (the author wrote a command); a `celilo ...` occurrence
|
|
29
|
+
* without a delimiter is a hint embedded in a sentence and is only validated
|
|
30
|
+
* when everything after the command path is argument-shaped — trailing plain
|
|
31
|
+
* words mean the line talks ABOUT celilo ("celilo knows where its control
|
|
32
|
+
* plane runs"), which is prose, not a prescription.
|
|
33
|
+
*
|
|
34
|
+
* Reach floors: the scan asserts it resolved a real number of command spans
|
|
35
|
+
* and contains the anchors it exists to police, because a scan that resolves
|
|
36
|
+
* nothing exits 0 and proves nothing.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
import { describe, expect, it } from 'bun:test';
|
|
40
|
+
import { readFileSync } from 'node:fs';
|
|
41
|
+
import { join } from 'node:path';
|
|
42
|
+
import { type ArgDef, COMMANDS, type CommandDef } from '@celilo/core';
|
|
43
|
+
|
|
44
|
+
const SOURCES = [
|
|
45
|
+
join(import.meta.dir, 'system-doctor.ts'),
|
|
46
|
+
join(import.meta.dir, '../../services/fleet-checks.ts'),
|
|
47
|
+
join(import.meta.dir, '../../services/audit/module-integrity.ts'),
|
|
48
|
+
];
|
|
49
|
+
|
|
50
|
+
/** Completion kinds whose value is a filesystem path, not an id. */
|
|
51
|
+
const PATH_COMPLETIONS = new Set(['directories', 'files']);
|
|
52
|
+
|
|
53
|
+
const INTERP = '\u0000';
|
|
54
|
+
|
|
55
|
+
/** `${...}` expressions found by masking, in order. */
|
|
56
|
+
const interps: string[] = [];
|
|
57
|
+
|
|
58
|
+
function maskInterpolations(source: string): string {
|
|
59
|
+
interps.length = 0;
|
|
60
|
+
return source.replace(/\$\{([^{}]*)\}/g, (_m, expr: string) => {
|
|
61
|
+
interps.push(expr);
|
|
62
|
+
return `${INTERP}${interps.length - 1}${INTERP}`;
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function interpText(masked: string): string {
|
|
67
|
+
const m = masked.match(new RegExp(`${INTERP}(\\d+)${INTERP}`));
|
|
68
|
+
return m ? (interps[Number(m[1])] ?? masked) : masked;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
interface ParsedToken {
|
|
72
|
+
kind: 'word' | 'flag' | 'positional';
|
|
73
|
+
text: string;
|
|
74
|
+
/** True when the token's runtime value is a module id. */
|
|
75
|
+
isModuleId: boolean;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* One `celilo ...` occurrence: the tokens after the word `celilo`, plus
|
|
80
|
+
* whether a quote delimited it (strict).
|
|
81
|
+
*/
|
|
82
|
+
interface Span {
|
|
83
|
+
tokens: ParsedToken[];
|
|
84
|
+
strict: boolean;
|
|
85
|
+
raw: string;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const FLAG = /^--[a-z0-9][a-z0-9-]*/;
|
|
89
|
+
const PLACEHOLDER = /^<[^<>\s]+>/;
|
|
90
|
+
|
|
91
|
+
function tokenize(text: string): ParsedToken[] {
|
|
92
|
+
const tokens: ParsedToken[] = [];
|
|
93
|
+
let rest = text;
|
|
94
|
+
while (rest.length > 0) {
|
|
95
|
+
rest = rest.replace(/^[\s,;]+/, '');
|
|
96
|
+
if (rest.length === 0) break;
|
|
97
|
+
let m = rest.match(FLAG);
|
|
98
|
+
if (m) {
|
|
99
|
+
tokens.push({ kind: 'flag', text: m[0].slice(2), isModuleId: false });
|
|
100
|
+
rest = rest.slice(m[0].length);
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
m = rest.match(PLACEHOLDER);
|
|
104
|
+
if (m) {
|
|
105
|
+
tokens.push({ kind: 'positional', text: m[0], isModuleId: false });
|
|
106
|
+
rest = rest.slice(m[0].length);
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
m = rest.match(/^\u0000(\d+)\u0000/);
|
|
110
|
+
if (m) {
|
|
111
|
+
const expr = interps[Number(m[1])] ?? '';
|
|
112
|
+
rest = rest.slice(m[0].length);
|
|
113
|
+
// ANSI chrome (${ANSI.reset} and friends) renders as an escape code,
|
|
114
|
+
// not as a value the operator types.
|
|
115
|
+
if (/^ANSI\.\w+$/.test(expr.trim())) continue;
|
|
116
|
+
tokens.push({ kind: 'positional', text: expr, isModuleId: /\bmoduleId\b/.test(expr) });
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
m = rest.match(/^[a-z][a-z0-9-]*(?:\.\u0000\d+\u0000[a-z0-9-]*)*/);
|
|
120
|
+
if (m) {
|
|
121
|
+
// A word that carries an interpolation (a key built at runtime, like
|
|
122
|
+
// network.<zone>.subnet) is a value, never a subcommand.
|
|
123
|
+
const isInterp = m[0].includes(INTERP);
|
|
124
|
+
const isModuleId = isInterp
|
|
125
|
+
? (m[0].match(/\u0000(\d+)\u0000/g) ?? []).some((mark) =>
|
|
126
|
+
/\bmoduleId\b/.test(interpText(mark)),
|
|
127
|
+
)
|
|
128
|
+
: false;
|
|
129
|
+
tokens.push({ kind: isInterp ? 'positional' : 'word', text: m[0], isModuleId });
|
|
130
|
+
rest = rest.slice(m[0].length);
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
break; // punctuation, a closing quote, prose — the command part is over
|
|
134
|
+
}
|
|
135
|
+
return tokens;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Extract every `celilo ` occurrence with its span, per the rules above. */
|
|
139
|
+
function extractSpans(maskedLine: string): Span[] {
|
|
140
|
+
const spans: Span[] = [];
|
|
141
|
+
const re = /\bcelilo(?=\s)/g;
|
|
142
|
+
let m: RegExpExecArray | null = re.exec(maskedLine);
|
|
143
|
+
while (m !== null) {
|
|
144
|
+
const before = m.index > 0 ? maskedLine[m.index - 1] : '';
|
|
145
|
+
const delimiter = before === '`' || before === "'" || before === '"' ? before : null;
|
|
146
|
+
const start = m.index + 'celilo'.length;
|
|
147
|
+
let content: string;
|
|
148
|
+
let strict: boolean;
|
|
149
|
+
if (delimiter) {
|
|
150
|
+
const closer = maskedLine.indexOf(delimiter, start);
|
|
151
|
+
strict = true;
|
|
152
|
+
content = closer === -1 ? maskedLine.slice(start) : maskedLine.slice(start, closer);
|
|
153
|
+
if (closer !== -1) re.lastIndex = closer + 1;
|
|
154
|
+
} else {
|
|
155
|
+
strict = false;
|
|
156
|
+
content = maskedLine.slice(start);
|
|
157
|
+
}
|
|
158
|
+
const tokens = tokenize(content);
|
|
159
|
+
if (tokens.length > 0) spans.push({ tokens, strict, raw: content.trim() });
|
|
160
|
+
if (re.lastIndex <= m.index) re.lastIndex = start;
|
|
161
|
+
m = re.exec(maskedLine);
|
|
162
|
+
}
|
|
163
|
+
return spans;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function walkPath(tokens: ParsedToken[]): { path: CommandDef[]; rest: ParsedToken[] } {
|
|
167
|
+
const path: CommandDef[] = [];
|
|
168
|
+
let node: CommandDef | undefined = COMMANDS.find((c) => c.name === tokens[0]?.text);
|
|
169
|
+
if (!node) return { path, rest: tokens };
|
|
170
|
+
path.push(node);
|
|
171
|
+
let i = 1;
|
|
172
|
+
while (i < tokens.length && tokens[i].kind === 'word') {
|
|
173
|
+
const next: CommandDef | undefined = node.subcommands?.find((s) => s.name === tokens[i].text);
|
|
174
|
+
if (!next) break;
|
|
175
|
+
node = next;
|
|
176
|
+
path.push(next);
|
|
177
|
+
i++;
|
|
178
|
+
}
|
|
179
|
+
return { path, rest: tokens.slice(i) };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Which declared arg the i-th positional fills (variadic absorbs the tail). */
|
|
183
|
+
function slotFor(args: ArgDef[], i: number): ArgDef | undefined {
|
|
184
|
+
const variadicIndex = args.findIndex((a) => a.variadic);
|
|
185
|
+
const fixed = variadicIndex === -1 ? args.length : variadicIndex;
|
|
186
|
+
if (i < fixed) return args[i];
|
|
187
|
+
return variadicIndex === -1 ? undefined : args[variadicIndex];
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function validateSpan(span: Span): string[] {
|
|
191
|
+
const { path, rest } = walkPath(span.tokens);
|
|
192
|
+
if (path.length === 0) return []; // first word is not a command: prose about celilo
|
|
193
|
+
|
|
194
|
+
// A hint embedded in prose is only a prescription when everything after the
|
|
195
|
+
// command path is argument-shaped. Trailing plain words mean the sentence
|
|
196
|
+
// continues ("celilo system migrate` to apply pending migrations...").
|
|
197
|
+
if (!span.strict && rest.some((t) => t.kind === 'word')) return [];
|
|
198
|
+
|
|
199
|
+
const name = `celilo ${path.map((c) => c.name).join(' ')}`;
|
|
200
|
+
const leaf = path[path.length - 1];
|
|
201
|
+
const violations: string[] = [];
|
|
202
|
+
|
|
203
|
+
for (const t of rest.filter((t) => t.kind === 'word')) {
|
|
204
|
+
violations.push(`${name}: unknown command word '${t.text}'`);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const declaredFlags = new Set([...(leaf.flags ?? []).map((f) => f.name), 'help', 'h']);
|
|
208
|
+
for (const t of rest.filter((t) => t.kind === 'flag')) {
|
|
209
|
+
if (!declaredFlags.has(t.text)) {
|
|
210
|
+
violations.push(`${name}: flag --${t.text} is not declared in the command registry`);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const args = leaf.args ?? [];
|
|
215
|
+
const positionals = rest.filter((t) => t.kind === 'positional');
|
|
216
|
+
if (!args.some((a) => a.variadic) && positionals.length > args.length) {
|
|
217
|
+
violations.push(
|
|
218
|
+
`${name}: ${positionals.length} positional value(s) but only ${args.length} declared arg(s)`,
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
positionals.forEach((token, i) => {
|
|
222
|
+
const slot = slotFor(args, i);
|
|
223
|
+
if (!slot) return;
|
|
224
|
+
if (
|
|
225
|
+
token.isModuleId &&
|
|
226
|
+
slot.completion !== undefined &&
|
|
227
|
+
PATH_COMPLETIONS.has(slot.completion)
|
|
228
|
+
) {
|
|
229
|
+
violations.push(
|
|
230
|
+
`${name}: module id '${token.text}' fills the '${slot.name}' slot, which takes a path (${slot.completion})`,
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
return violations;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
describe('doctor remediation arrow gate (celilo#1308)', () => {
|
|
239
|
+
it('flags a module id passed into a path slot, even a variadic one', () => {
|
|
240
|
+
// Rule 7.6 anchor: the exact defect shape from #1308 (`module update
|
|
241
|
+
// <module-id>`), pinned synthetically so the rule cannot silently stop
|
|
242
|
+
// matching the registry.
|
|
243
|
+
maskInterpolations(''); // reset
|
|
244
|
+
interps.push('result.moduleId');
|
|
245
|
+
const violations = validateSpan({
|
|
246
|
+
tokens: tokenize(`module update ${INTERP}0${INTERP}`),
|
|
247
|
+
strict: true,
|
|
248
|
+
raw: 'module update ${result.moduleId}',
|
|
249
|
+
});
|
|
250
|
+
expect(violations.some((v) => v.includes('takes a path'))).toBe(true);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it('every command the doctor prescribes exists, with declared flags and fitting args', () => {
|
|
254
|
+
const violations: string[] = [];
|
|
255
|
+
let resolvedCount = 0;
|
|
256
|
+
const resolvedHeads = new Set<string>();
|
|
257
|
+
|
|
258
|
+
for (const file of SOURCES) {
|
|
259
|
+
const raw = readFileSync(file, 'utf8');
|
|
260
|
+
// Strip block comments and whole-line // comments so doc headers that
|
|
261
|
+
// mention commands historically are not prescribed commands now.
|
|
262
|
+
const stripped = raw.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '');
|
|
263
|
+
const masked = maskInterpolations(stripped);
|
|
264
|
+
const shortFile = file.split('/').pop() ?? file;
|
|
265
|
+
|
|
266
|
+
for (const line of masked.split('\n')) {
|
|
267
|
+
for (const span of extractSpans(line)) {
|
|
268
|
+
const found = validateSpan(span);
|
|
269
|
+
if (found.length === 0 && span.strict) resolvedCount++;
|
|
270
|
+
for (const v of found) violations.push(`[${shortFile}] ${v} (span: "${span.raw}")`);
|
|
271
|
+
const head = span.tokens
|
|
272
|
+
.slice(0, 2)
|
|
273
|
+
.map((t) => t.text)
|
|
274
|
+
.join(' ');
|
|
275
|
+
if (head) resolvedHeads.add(head);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Reach floors. A scan that resolves nothing proves nothing.
|
|
281
|
+
expect(resolvedCount).toBeGreaterThanOrEqual(12);
|
|
282
|
+
for (const anchor of [
|
|
283
|
+
'system doctor',
|
|
284
|
+
'module verify',
|
|
285
|
+
'monitor add',
|
|
286
|
+
'events repair',
|
|
287
|
+
'system migrate',
|
|
288
|
+
]) {
|
|
289
|
+
const found = [...resolvedHeads].some((h) => h.startsWith(anchor));
|
|
290
|
+
expect(found).toBe(true);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
expect(violations).toEqual([]);
|
|
294
|
+
});
|
|
295
|
+
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, test } from 'bun:test';
|
|
2
|
-
import type
|
|
2
|
+
import { type JailModeRecord, resolveJailPolicy } from '../../hooks/jail';
|
|
3
3
|
import {
|
|
4
4
|
type HookExecutionInput,
|
|
5
5
|
assembleHookExecutionSection,
|
|
@@ -62,12 +62,13 @@ describe('renderHookExecutionSection (hook-process-boundary task 4.6)', () => {
|
|
|
62
62
|
renderHookExecutionSection({
|
|
63
63
|
availability: { backend: 'bubblewrap' },
|
|
64
64
|
policy: 'auto',
|
|
65
|
-
source: '
|
|
66
|
-
envValue:
|
|
65
|
+
source: 'env',
|
|
66
|
+
envValue: 'auto',
|
|
67
67
|
configValue: undefined,
|
|
68
68
|
record: jailedRecord,
|
|
69
69
|
host: HOST,
|
|
70
70
|
monitored: true,
|
|
71
|
+
exemptions: [],
|
|
71
72
|
...over,
|
|
72
73
|
});
|
|
73
74
|
|
|
@@ -96,10 +97,31 @@ describe('renderHookExecutionSection (hook-process-boundary task 4.6)', () => {
|
|
|
96
97
|
expect(r.failCount).toBe(0);
|
|
97
98
|
});
|
|
98
99
|
|
|
99
|
-
test('
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
100
|
+
test('an off policy warns, and the reason claims no source of its own', () => {
|
|
101
|
+
// This test used to be named "CELILO_HOOK_JAIL=off is reported as the
|
|
102
|
+
// operator's own act" and asserted that string. It stopped being true when
|
|
103
|
+
// ce-rez7 made `off` the DEFAULT: on a host where nobody set anything, the
|
|
104
|
+
// doctor named a variable that was unset and an act nobody performed. The
|
|
105
|
+
// source belongs on the source line, which is rendered separately.
|
|
106
|
+
const fromDefault = render({
|
|
107
|
+
policy: 'off',
|
|
108
|
+
source: 'default',
|
|
109
|
+
envValue: undefined,
|
|
110
|
+
record: undefined,
|
|
111
|
+
});
|
|
112
|
+
expect(text(fromDefault)).toContain('hooks run unjailed');
|
|
113
|
+
expect(text(fromDefault)).toContain('policy default (off)');
|
|
114
|
+
expect(text(fromDefault)).not.toContain('CELILO_HOOK_JAIL=off');
|
|
115
|
+
expect(fromDefault.warnCount).toBe(1);
|
|
116
|
+
|
|
117
|
+
const fromEnv = render({
|
|
118
|
+
policy: 'off',
|
|
119
|
+
source: 'env',
|
|
120
|
+
envValue: 'off',
|
|
121
|
+
record: undefined,
|
|
122
|
+
});
|
|
123
|
+
expect(text(fromEnv)).toContain('policy from CELILO_HOOK_JAIL=off (environment override)');
|
|
124
|
+
expect(fromEnv.warnCount).toBe(1);
|
|
103
125
|
});
|
|
104
126
|
|
|
105
127
|
test('auto deferral on sandbox-exec reads as unjailed, not jailed (ce-29z)', () => {
|
|
@@ -119,7 +141,7 @@ describe('renderHookExecutionSection (hook-process-boundary task 4.6)', () => {
|
|
|
119
141
|
availability: { backend: 'none', reason: 'the profile did not load' },
|
|
120
142
|
record: undefined,
|
|
121
143
|
});
|
|
122
|
-
expect(text(r)).toContain('
|
|
144
|
+
expect(text(r)).toContain("the hook jail policy is 'required' and no jail is available");
|
|
123
145
|
expect(r.failCount).toBe(1);
|
|
124
146
|
});
|
|
125
147
|
|
|
@@ -180,9 +202,21 @@ describe('renderHookExecutionSection (hook-process-boundary task 4.6)', () => {
|
|
|
180
202
|
});
|
|
181
203
|
|
|
182
204
|
test('the source line names the default and how to change it (D4)', () => {
|
|
183
|
-
|
|
205
|
+
// Ask the resolver what the default IS rather than naming it. The literal
|
|
206
|
+
// that stood here said `auto`, and it kept passing after ce-rez7 flipped
|
|
207
|
+
// the default to `off`, because it rendered a { policy, source } pair the
|
|
208
|
+
// resolver can no longer produce. A correct assertion about a state that
|
|
209
|
+
// never arrives.
|
|
210
|
+
const fallback = resolveJailPolicy(undefined, undefined, undefined);
|
|
211
|
+
const r = render({
|
|
212
|
+
policy: fallback.policy,
|
|
213
|
+
source: fallback.source,
|
|
214
|
+
envValue: undefined,
|
|
215
|
+
record: undefined,
|
|
216
|
+
});
|
|
217
|
+
expect(fallback.source).toBe('default');
|
|
184
218
|
expect(text(r)).toContain(
|
|
185
|
-
|
|
219
|
+
`policy default (${fallback.policy}) — set hooks.jail_policy or CELILO_HOOK_JAIL to change`,
|
|
186
220
|
);
|
|
187
221
|
});
|
|
188
222
|
|
|
@@ -218,10 +252,42 @@ describe('renderHookExecutionSection (hook-process-boundary task 4.6)', () => {
|
|
|
218
252
|
record: () => undefined,
|
|
219
253
|
host: () => HOST,
|
|
220
254
|
monitored: () => true,
|
|
255
|
+
exemptions: () => [],
|
|
221
256
|
});
|
|
222
257
|
expect(r.lines[0]).toBe('Hook execution');
|
|
223
258
|
expect(r.lines[1]).toContain("hooks.jail_policy='alwayssafe' is not a hook jail policy");
|
|
224
259
|
expect(r.failCount).toBe(1);
|
|
225
260
|
expect(r.warnCount).toBe(0);
|
|
226
261
|
});
|
|
262
|
+
|
|
263
|
+
test('an exemption is counted and named (per-module-jail-policy task 3.2)', () => {
|
|
264
|
+
const r = render({
|
|
265
|
+
exemptions: [
|
|
266
|
+
{ moduleId: 'caddy', policy: 'off', systemPolicy: 'auto' },
|
|
267
|
+
{ moduleId: 'vault', policy: 'auto', systemPolicy: 'auto' },
|
|
268
|
+
],
|
|
269
|
+
});
|
|
270
|
+
expect(text(r)).toContain("2 modules run under a weaker jail policy than the system's");
|
|
271
|
+
expect(text(r)).toContain('caddy (off)');
|
|
272
|
+
expect(text(r)).toContain('vault (auto)');
|
|
273
|
+
expect(r.warnCount).toBe(1);
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
test('one exemption reads as singular', () => {
|
|
277
|
+
const r = render({
|
|
278
|
+
exemptions: [{ moduleId: 'caddy', policy: 'off', systemPolicy: 'auto' }],
|
|
279
|
+
});
|
|
280
|
+
expect(text(r)).toContain('1 module runs under a weaker jail policy');
|
|
281
|
+
expect(text(r)).not.toContain('modules run');
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
test('no exemptions render no exemption line (per-module-jail-policy task 3.4)', () => {
|
|
285
|
+
// The negative half: a surface that reports an exemption nobody set is
|
|
286
|
+
// as bad as one that hides a real one. With no rows the section must be
|
|
287
|
+
// word-for-word what it was before this slice.
|
|
288
|
+
const r = render({});
|
|
289
|
+
expect(text(r)).not.toContain('weaker jail policy');
|
|
290
|
+
expect(text(r)).not.toContain('exempt');
|
|
291
|
+
expect(r.warnCount).toBe(0);
|
|
292
|
+
});
|
|
227
293
|
});
|
|
@@ -60,6 +60,7 @@ import {
|
|
|
60
60
|
checkSubscribers,
|
|
61
61
|
runFleetChecks,
|
|
62
62
|
} from '../../services/fleet-checks';
|
|
63
|
+
import { type JailExemption, collectJailExemptions } from '../../services/jail-exemptions';
|
|
63
64
|
import { resyncAllSubscriptions } from '../../services/module-subscriptions';
|
|
64
65
|
import { checkAllPrerequisites, failingPrerequisites } from '../../system/prereqs';
|
|
65
66
|
import type { CommandResult } from '../types';
|
|
@@ -368,6 +369,12 @@ export interface HookExecutionInput {
|
|
|
368
369
|
* ask (a dev box), which also suppresses the add-the-monitor hint.
|
|
369
370
|
*/
|
|
370
371
|
monitored: boolean | null;
|
|
372
|
+
/**
|
|
373
|
+
* Modules running under a recorded policy weaker than the system's
|
|
374
|
+
* (per-module-jail-policy task 3.2), from the shared collector. Empty is
|
|
375
|
+
* the normal state and renders nothing.
|
|
376
|
+
*/
|
|
377
|
+
exemptions: readonly JailExemption[];
|
|
371
378
|
}
|
|
372
379
|
|
|
373
380
|
/**
|
|
@@ -408,8 +415,11 @@ export function renderHookExecutionSection(input: HookExecutionInput): {
|
|
|
408
415
|
} else if (input.source === 'config') {
|
|
409
416
|
lines.push(` ${ANSI.dim}policy from system config (hooks.jail_policy)${ANSI.reset}`);
|
|
410
417
|
} else {
|
|
418
|
+
// Read the default off `policy` rather than naming it. The literal that
|
|
419
|
+
// stood here said `auto` and stayed saying it after ce-rez7 flipped the
|
|
420
|
+
// default to `off`, so the doctor named the one value not in play.
|
|
411
421
|
lines.push(
|
|
412
|
-
` ${ANSI.dim}policy default (
|
|
422
|
+
` ${ANSI.dim}policy default (${policy}) — set hooks.jail_policy or CELILO_HOOK_JAIL to change${ANSI.reset}`,
|
|
413
423
|
);
|
|
414
424
|
}
|
|
415
425
|
if (
|
|
@@ -428,7 +438,7 @@ export function renderHookExecutionSection(input: HookExecutionInput): {
|
|
|
428
438
|
const liveJailed = policy !== 'off' && availability.backend !== 'none' && !autoDeferred;
|
|
429
439
|
if (policy === 'required' && availability.backend === 'none') {
|
|
430
440
|
lines.push(
|
|
431
|
-
` ${ANSI.red}✗${ANSI.reset}
|
|
441
|
+
` ${ANSI.red}✗${ANSI.reset} the hook jail policy is 'required' and no jail is available — every module hook on this host fails rather than run unjailed`,
|
|
432
442
|
);
|
|
433
443
|
lines.push(` ${ANSI.dim}${availability.reason ?? 'no reason was recorded'}${ANSI.reset}`);
|
|
434
444
|
failCount++;
|
|
@@ -449,6 +459,18 @@ export function renderHookExecutionSection(input: HookExecutionInput): {
|
|
|
449
459
|
lines.push(` ${ANSI.green}✔${ANSI.reset} hooks run jailed (${availability.backend})`);
|
|
450
460
|
}
|
|
451
461
|
|
|
462
|
+
// Per-module exemptions (per-module-jail-policy task 3.2). Loud and
|
|
463
|
+
// countable is the whole design: the count AND the names, every time, and
|
|
464
|
+
// nothing at all when there are none.
|
|
465
|
+
if (input.exemptions.length > 0) {
|
|
466
|
+
const n = input.exemptions.length;
|
|
467
|
+
const named = input.exemptions.map((e) => `${e.moduleId} (${e.policy})`).join(', ');
|
|
468
|
+
lines.push(
|
|
469
|
+
` ${ANSI.yellow}⚠${ANSI.reset} ${n} module${n === 1 ? '' : 's'} run${n === 1 ? 's' : ''} under a weaker jail policy than the system's: ${named}`,
|
|
470
|
+
);
|
|
471
|
+
warnCount++;
|
|
472
|
+
}
|
|
473
|
+
|
|
452
474
|
const regressed =
|
|
453
475
|
record && record.host === host && record.mode === 'unjailed' && record.lastJailed;
|
|
454
476
|
if (regressed && record.lastJailed) {
|
|
@@ -498,6 +520,8 @@ export interface HookExecutionIO {
|
|
|
498
520
|
record: () => JailModeRecord | undefined;
|
|
499
521
|
host: () => string;
|
|
500
522
|
monitored: () => boolean | null;
|
|
523
|
+
/** Modules exempted from the system's jail policy (per-module-jail-policy task 3.2). */
|
|
524
|
+
exemptions: () => JailExemption[];
|
|
501
525
|
}
|
|
502
526
|
|
|
503
527
|
/**
|
|
@@ -516,7 +540,12 @@ export function assembleHookExecutionSection(io: HookExecutionIO): {
|
|
|
516
540
|
try {
|
|
517
541
|
const envValue = io.envValue();
|
|
518
542
|
const configValue = io.configValue();
|
|
519
|
-
|
|
543
|
+
// The module step of the four-step precedence stays out of the headline
|
|
544
|
+
// resolution — the headline is the policy the EXECUTOR would use for a
|
|
545
|
+
// module with no row of its own. Per-module rows are surfaced separately
|
|
546
|
+
// as exemptions (per-module-jail-policy task 3.2), which is the shape the
|
|
547
|
+
// operator acts on.
|
|
548
|
+
const resolved = resolveJailPolicy(envValue, undefined, configValue);
|
|
520
549
|
return renderHookExecutionSection({
|
|
521
550
|
availability: io.availability(),
|
|
522
551
|
policy: resolved.policy,
|
|
@@ -526,6 +555,7 @@ export function assembleHookExecutionSection(io: HookExecutionIO): {
|
|
|
526
555
|
record: io.record(),
|
|
527
556
|
host: io.host(),
|
|
528
557
|
monitored: io.monitored(),
|
|
558
|
+
exemptions: io.exemptions(),
|
|
529
559
|
});
|
|
530
560
|
} catch (error) {
|
|
531
561
|
return {
|
|
@@ -845,6 +875,7 @@ export async function handleSystemDoctor(
|
|
|
845
875
|
host: () => hostname(),
|
|
846
876
|
monitored: () =>
|
|
847
877
|
existsSync(getDbPath()) ? findMonitorByTarget(getDb(), HOOK_JAIL_CHECK) !== undefined : null,
|
|
878
|
+
exemptions: () => (existsSync(getDbPath()) ? collectJailExemptions(getDb()) : []),
|
|
848
879
|
});
|
|
849
880
|
lines.push(...hookExec.lines);
|
|
850
881
|
lines.push('');
|
|
@@ -42,7 +42,7 @@ describe('celilo system init deprecation banner', () => {
|
|
|
42
42
|
tmpDir = mkdtempSync(join(tmpdir(), 'celilo-deprecation-test-'));
|
|
43
43
|
savedSuppress = process.env.CELILO_SUPPRESS_DEPRECATION;
|
|
44
44
|
process.env.CELILO_DB_PATH = join(tmpDir, 'init.db');
|
|
45
|
-
process.env.CELILO_SUPPRESS_DEPRECATION
|
|
45
|
+
delete process.env.CELILO_SUPPRESS_DEPRECATION;
|
|
46
46
|
});
|
|
47
47
|
|
|
48
48
|
afterEach(() => {
|
|
@@ -34,6 +34,7 @@ import { makeJournalReader, readAppliedMigrations } from '../../services/audit/s
|
|
|
34
34
|
import { createModuleBackup, createSystemStateBackup } from '../../services/backup-create';
|
|
35
35
|
import { BACKUP_SCHEDULE_CONFIG_KEY } from '../../services/backup-schedule';
|
|
36
36
|
import { runAllHealthChecks, runModuleHealthCheck } from '../../services/health-runner';
|
|
37
|
+
import { collectJailExemptions } from '../../services/jail-exemptions';
|
|
37
38
|
import { configOverride, parseStoredConfigValue } from '../../services/module-config';
|
|
38
39
|
import { deployModule } from '../../services/module-deploy';
|
|
39
40
|
import { buildModuleGraph } from '../../services/update/dep-graph';
|
|
@@ -576,6 +577,7 @@ export async function handleSystemUpdate(
|
|
|
576
577
|
manifest: m.manifestData as ModuleManifest,
|
|
577
578
|
})),
|
|
578
579
|
},
|
|
580
|
+
jailExemptions: { exemptions: collectJailExemptions(db) },
|
|
579
581
|
health: { results: healthResults },
|
|
580
582
|
backups: {
|
|
581
583
|
modules: upgradableModules.map((m) => ({
|
|
@@ -613,6 +615,9 @@ export async function handleSystemUpdate(
|
|
|
613
615
|
// flow's partial audit does no probing. `system audit` and the scheduled
|
|
614
616
|
// monitor own this check.
|
|
615
617
|
publicDns: { records: [], probe: unusedPublicDnsProbe },
|
|
618
|
+
// Disk probing is one SSH per system; the update flow's partial audit
|
|
619
|
+
// does no probing. `system audit` and the monitor sweep own this check.
|
|
620
|
+
diskSpace: { results: [] },
|
|
616
621
|
transportReads: { statuses: [], now: new Date(), staleAfterMs: 30 * 60_000 },
|
|
617
622
|
trustedSources: { firewalls: [] },
|
|
618
623
|
};
|
package/src/cli/completion.ts
CHANGED
|
@@ -171,6 +171,7 @@ export async function getCompletions(words: string[], current: number): Promise<
|
|
|
171
171
|
if (command === 'module' && currentIndex === 1) {
|
|
172
172
|
const subcommands = [
|
|
173
173
|
'check',
|
|
174
|
+
'changeset',
|
|
174
175
|
'import',
|
|
175
176
|
'list',
|
|
176
177
|
'publish',
|
|
@@ -187,7 +188,6 @@ export async function getCompletions(words: string[], current: number): Promise<
|
|
|
187
188
|
'build',
|
|
188
189
|
'generate',
|
|
189
190
|
'deploy',
|
|
190
|
-
'destroy',
|
|
191
191
|
'health',
|
|
192
192
|
'logs',
|
|
193
193
|
'journal',
|
|
@@ -201,6 +201,7 @@ export async function getCompletions(words: string[], current: number): Promise<
|
|
|
201
201
|
'validate',
|
|
202
202
|
'pause',
|
|
203
203
|
'unpause',
|
|
204
|
+
'jail',
|
|
204
205
|
];
|
|
205
206
|
return filterSuggestions(subcommands, args[1] || '');
|
|
206
207
|
}
|
|
@@ -470,7 +471,6 @@ export async function getCompletions(words: string[], current: number): Promise<
|
|
|
470
471
|
const moduleCommands = [
|
|
471
472
|
'generate',
|
|
472
473
|
'deploy',
|
|
473
|
-
'destroy',
|
|
474
474
|
'logs',
|
|
475
475
|
'journal',
|
|
476
476
|
'remove',
|
|
@@ -483,6 +483,7 @@ export async function getCompletions(words: string[], current: number): Promise<
|
|
|
483
483
|
'audit', // deprecation alias for `verify`
|
|
484
484
|
'pause',
|
|
485
485
|
'unpause',
|
|
486
|
+
'jail',
|
|
486
487
|
];
|
|
487
488
|
if (moduleCommands.includes(args[1] || '')) {
|
|
488
489
|
const db = getDb();
|
|
@@ -492,6 +493,11 @@ export async function getCompletions(words: string[], current: number): Promise<
|
|
|
492
493
|
}
|
|
493
494
|
}
|
|
494
495
|
|
|
496
|
+
// Module jail <module-id> - complete with policy values
|
|
497
|
+
if (command === 'module' && args[1] === 'jail' && currentIndex === 3) {
|
|
498
|
+
return filterSuggestions(['auto', 'off', 'required'], args[3] || '');
|
|
499
|
+
}
|
|
500
|
+
|
|
495
501
|
// Module run-hook <module-id> - complete with hook names from manifest
|
|
496
502
|
if (command === 'module' && args[1] === 'run-hook' && currentIndex === 3) {
|
|
497
503
|
const db = getDb();
|