@celilo/cli 2.2.0 → 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/schemas/system_config.json +5 -0
- package/src/__integration__/container-services-cli.integration.test.ts +1 -1
- package/src/ansible/inventory.test.ts +2 -1
- package/src/api/sessions.test.ts +5 -4
- package/src/api-clients/proxmox.ts +10 -7
- package/src/cli/backup-rename.test.ts +5 -4
- package/src/cli/cli.test.ts +3 -2
- package/src/cli/commands/console-get-chain.test.ts +2 -1
- package/src/cli/commands/events.test.ts +2 -2
- package/src/cli/commands/firewall-interface-list.test.ts +4 -3
- package/src/cli/commands/machine-list.test.ts +57 -0
- package/src/cli/commands/machine-list.ts +35 -4
- package/src/cli/commands/module-config.test.ts +2 -1
- package/src/cli/commands/module-deploy.ts +8 -2
- package/src/cli/commands/module-generate.test.ts +53 -0
- package/src/cli/commands/module-generate.ts +31 -26
- package/src/cli/commands/module-health.ts +1 -0
- package/src/cli/commands/module-import-aspect.test.ts +2 -1
- package/src/cli/commands/module-import-registry.test.ts +3 -2
- 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-operations.test.ts +2 -1
- package/src/cli/commands/module-publish.test.ts +11 -18
- package/src/cli/commands/module-update.test.ts +28 -12
- package/src/cli/commands/module-update.ts +4 -1
- package/src/cli/commands/module-upgrade.test.ts +130 -1
- package/src/cli/commands/module-upgrade.ts +77 -3
- package/src/cli/commands/module-verify.test.ts +3 -4
- package/src/cli/commands/module-verify.ts +0 -1
- package/src/cli/commands/notify-config.test.ts +5 -3
- 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 +3 -2
- package/src/cli/commands/registry-token.test.ts +3 -2
- package/src/cli/commands/system-apply-config-equivalence.test.ts +5 -7
- package/src/cli/commands/system-audit.ts +20 -6
- package/src/cli/commands/system-config.test.ts +148 -0
- package/src/cli/commands/system-config.ts +26 -1
- package/src/cli/commands/system-doctor-remediation-gate.test.ts +295 -0
- package/src/cli/commands/system-doctor.test.ts +143 -6
- package/src/cli/commands/system-doctor.ts +142 -25
- package/src/cli/commands/system-init-deprecation.test.ts +7 -4
- package/src/cli/commands/system-migrate.test.ts +2 -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 +70 -3
- package/src/cli/parser.test.ts +37 -1
- package/src/cli/restore-command.test.ts +5 -4
- package/src/cli/restore-migration-failure.test.ts +4 -3
- package/src/cli/tui/audit-state.ts +2 -0
- package/src/config/paths.test.ts +22 -22
- package/src/db/client.test.ts +46 -1
- package/src/db/client.ts +26 -0
- package/src/db/migrate.test.ts +2 -1
- package/src/db/migrate.ts +16 -16
- package/src/db/schema.ts +63 -0
- package/src/hooks/capability-loader-firewall.test.ts +9 -1
- package/src/hooks/capability-loader.test.ts +78 -0
- package/src/hooks/capability-loader.ts +45 -2
- package/src/hooks/executor.test.ts +161 -1
- package/src/hooks/executor.ts +87 -21
- package/src/hooks/hook-jail-unreachability.test.ts +17 -18
- package/src/hooks/hook-store.test.ts +6 -0
- package/src/hooks/hook-trespass.test.ts +26 -9
- package/src/hooks/jail.test.ts +152 -5
- package/src/hooks/jail.ts +93 -4
- package/src/manifest/contracts/v1.ts +13 -0
- package/src/module/packaging/audit.ts +9 -26
- package/src/module/packaging/build-paths.test.ts +127 -0
- package/src/module/packaging/build-paths.ts +175 -0
- package/src/module/packaging/build.test.ts +71 -1
- package/src/module/packaging/build.ts +60 -0
- package/src/module/packaging/extract.ts +1 -5
- package/src/policy/module-script-scan.ts +51 -107
- package/src/policy/no-hand-built-ssh.test.ts +5 -1
- package/src/policy/no-swallowed-refusal.test.ts +14 -15
- package/src/registry/client.test.ts +2 -2
- package/src/secrets/storage.test.ts +1 -1
- package/src/services/alerting/ack.test.ts +2 -1
- package/src/services/alerting/cadence-migration.test.ts +3 -2
- package/src/services/alerting/coverage-source.test.ts +2 -1
- package/src/services/alerting/deferral.test.ts +2 -1
- package/src/services/alerting/delivery-loop.test.ts +2 -1
- package/src/services/alerting/deploy-hooks.test.ts +2 -1
- package/src/services/alerting/inbound-poller.test.ts +2 -1
- package/src/services/alerting/inbound.test.ts +2 -1
- package/src/services/alerting/keys.test.ts +4 -0
- package/src/services/alerting/keys.ts +12 -2
- package/src/services/alerting/notification-responder.test.ts +2 -1
- package/src/services/alerting/run-monitor.test.ts +2 -1
- package/src/services/alerting/store.test.ts +2 -1
- package/src/services/alerting/sweep-runner.test.ts +2 -1
- package/src/services/alerting/tokens.test.ts +2 -1
- package/src/services/aspect-approvals.test.ts +2 -1
- package/src/services/aspect-reconcile.test.ts +4 -3
- package/src/services/aspect-runner.test.ts +2 -1
- package/src/services/audit/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 -22
- package/src/services/audit/module-integrity.ts +7 -16
- package/src/services/audit/types.ts +2 -1
- package/src/services/backup-age-agreement.test.ts +2 -1
- package/src/services/backup-create.ts +9 -0
- package/src/services/backup-envelope-roundtrip.test.ts +3 -2
- package/src/services/backup-in-flight-refusal.test.ts +3 -2
- 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 +3 -2
- package/src/services/bus-interview-park.test.ts +4 -3
- package/src/services/bus-interview.test.ts +2 -2
- package/src/services/bus-secret-flow.test.ts +3 -2
- package/src/services/capability-table-rows.test.ts +2 -1
- package/src/services/celilo-events.test.ts +1 -1
- package/src/services/celilo-mgmt-hooks.test.ts +23 -5
- package/src/services/consumer-cleanup.test.ts +3 -2
- package/src/services/container-service.test.ts +3 -2
- package/src/services/control-plane-bootstrap.test.ts +2 -1
- package/src/services/cross-module-read.test.ts +4 -3
- package/src/services/deploy-preflight.ts +7 -0
- package/src/services/deploy-validation.test.ts +3 -2
- package/src/services/deployed-systems.ts +1 -1
- package/src/services/dns-internal-records.test.ts +3 -2
- package/src/services/dns-provider-backfill.test.ts +2 -1
- package/src/services/dns-registrations.test.ts +2 -1
- package/src/services/ensure-interview.test.ts +3 -2
- package/src/services/fleet-checks.test.ts +126 -6
- package/src/services/fleet-checks.ts +176 -3
- package/src/services/fleet-key.test.ts +3 -2
- package/src/services/health-runner.test.ts +87 -2
- package/src/services/health-runner.ts +40 -11
- package/src/services/infrastructure-selector.test.ts +3 -2
- package/src/services/infrastructure-variable-resolver.test.ts +2 -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 +3 -2
- package/src/services/module-config.test.ts +2 -1
- package/src/services/module-deploy.dns-repoint.test.ts +2 -1
- package/src/services/module-deploy.ts +48 -24
- package/src/services/module-operations.test.ts +2 -1
- package/src/services/module-subscriptions.test.ts +4 -3
- package/src/services/network-discovery.test.ts +64 -1
- package/src/services/network-discovery.ts +30 -5
- package/src/services/port-forwards.test.ts +2 -1
- package/src/services/programmatic-responder.aspect.test.ts +2 -1
- package/src/services/proxmox-reconcile.test.ts +2 -1
- package/src/services/responder-probe.test.ts +1 -1
- package/src/services/restore-from-file.test.ts +7 -6
- package/src/services/restore-preflight.test.ts +3 -2
- package/src/services/secret-schema-loader.test.ts +2 -1
- package/src/services/ssh-key-manager.test.ts +4 -3
- package/src/services/static-content-converge.test.ts +5 -4
- package/src/services/static-content-converge.ts +33 -10
- package/src/services/system-state-stage.test.ts +4 -3
- package/src/services/trusted-sources.test.ts +3 -2
- package/src/services/update/orchestrator.test.ts +2 -0
- package/src/services/zone-detector.test.ts +2 -1
- package/src/test-utils/bus-responder.ts +6 -4
- package/src/test-utils/db-path.ts +25 -0
- package/src/test-utils/integration.ts +7 -0
- package/src/test-utils/module-fixtures.ts +5 -6
- package/src/module/packaging/generated-plane.test.ts +0 -79
- package/src/module/packaging/generated-plane.ts +0 -134
|
@@ -7,6 +7,7 @@ import { dirname, join } from 'node:path';
|
|
|
7
7
|
import { eq } from 'drizzle-orm';
|
|
8
8
|
import { getDb } from '../../db/client';
|
|
9
9
|
import { systemConfig } from '../../db/schema';
|
|
10
|
+
import { askConfirm, withInterviewSession } from '../../services/bus-interview';
|
|
10
11
|
import type { SystemConfigSchema } from '../../services/system-config-schema-types';
|
|
11
12
|
import { validateKey, validateValue } from '../../services/system-config-validator';
|
|
12
13
|
import { getArg, validateRequiredArgs } from '../parser';
|
|
@@ -47,7 +48,10 @@ function loadSchema(): SystemConfigSchema {
|
|
|
47
48
|
* @param args - Command arguments
|
|
48
49
|
* @returns Command result
|
|
49
50
|
*/
|
|
50
|
-
export function handleSystemConfigSet(
|
|
51
|
+
export async function handleSystemConfigSet(
|
|
52
|
+
args: string[],
|
|
53
|
+
flags: Record<string, boolean | string> = {},
|
|
54
|
+
): Promise<CommandResult> {
|
|
51
55
|
// Validate arguments
|
|
52
56
|
const error = validateRequiredArgs(args, 2);
|
|
53
57
|
if (error) {
|
|
@@ -82,6 +86,27 @@ export function handleSystemConfigSet(args: string[]): CommandResult {
|
|
|
82
86
|
return { success: false, error: valueValidation.error };
|
|
83
87
|
}
|
|
84
88
|
|
|
89
|
+
// D5 of hook-jail-config-surface: turning jailing off fleet-wide is a security
|
|
90
|
+
// posture change, so the write asks first. `auto` and `required` write
|
|
91
|
+
// directly — `auto` is the status quo default and `required` tightens the
|
|
92
|
+
// posture. `--force` skips the ask, matching the house flags.force convention
|
|
93
|
+
// (backup-delete, machine-remove, service-remove, ...).
|
|
94
|
+
if (key === 'hooks.jail_policy' && value === 'off' && !flags.force) {
|
|
95
|
+
const confirmed = await withInterviewSession(() =>
|
|
96
|
+
askConfirm({
|
|
97
|
+
scope: 'hooks-jail-policy',
|
|
98
|
+
key: 'set-off',
|
|
99
|
+
message:
|
|
100
|
+
'Set hooks.jail_policy to off? Hooks will run unjailed fleet-wide and the doctor will warn.',
|
|
101
|
+
defaultValue: false,
|
|
102
|
+
}),
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
if (!confirmed) {
|
|
106
|
+
return { success: false, error: 'Cancelled by user' };
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
85
110
|
const db = getDb();
|
|
86
111
|
|
|
87
112
|
// Store value in database
|
|
@@ -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,7 +1,8 @@
|
|
|
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
|
+
assembleHookExecutionSection,
|
|
5
6
|
compareVersions,
|
|
6
7
|
renderHookExecutionSection,
|
|
7
8
|
} from './system-doctor';
|
|
@@ -61,9 +62,13 @@ describe('renderHookExecutionSection (hook-process-boundary task 4.6)', () => {
|
|
|
61
62
|
renderHookExecutionSection({
|
|
62
63
|
availability: { backend: 'bubblewrap' },
|
|
63
64
|
policy: 'auto',
|
|
65
|
+
source: 'env',
|
|
66
|
+
envValue: 'auto',
|
|
67
|
+
configValue: undefined,
|
|
64
68
|
record: jailedRecord,
|
|
65
69
|
host: HOST,
|
|
66
70
|
monitored: true,
|
|
71
|
+
exemptions: [],
|
|
67
72
|
...over,
|
|
68
73
|
});
|
|
69
74
|
|
|
@@ -92,10 +97,31 @@ describe('renderHookExecutionSection (hook-process-boundary task 4.6)', () => {
|
|
|
92
97
|
expect(r.failCount).toBe(0);
|
|
93
98
|
});
|
|
94
99
|
|
|
95
|
-
test('
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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);
|
|
99
125
|
});
|
|
100
126
|
|
|
101
127
|
test('auto deferral on sandbox-exec reads as unjailed, not jailed (ce-29z)', () => {
|
|
@@ -115,7 +141,7 @@ describe('renderHookExecutionSection (hook-process-boundary task 4.6)', () => {
|
|
|
115
141
|
availability: { backend: 'none', reason: 'the profile did not load' },
|
|
116
142
|
record: undefined,
|
|
117
143
|
});
|
|
118
|
-
expect(text(r)).toContain('
|
|
144
|
+
expect(text(r)).toContain("the hook jail policy is 'required' and no jail is available");
|
|
119
145
|
expect(r.failCount).toBe(1);
|
|
120
146
|
});
|
|
121
147
|
|
|
@@ -153,4 +179,115 @@ describe('renderHookExecutionSection (hook-process-boundary task 4.6)', () => {
|
|
|
153
179
|
expect(text(r)).toContain('belongs to host "old-box"');
|
|
154
180
|
expect(r.failCount).toBe(0);
|
|
155
181
|
});
|
|
182
|
+
|
|
183
|
+
test('the source line names the environment when the env var won (D4)', () => {
|
|
184
|
+
const r = render({
|
|
185
|
+
policy: 'off',
|
|
186
|
+
source: 'env',
|
|
187
|
+
envValue: 'off',
|
|
188
|
+
record: undefined,
|
|
189
|
+
});
|
|
190
|
+
expect(text(r)).toContain('policy from CELILO_HOOK_JAIL=off (environment override)');
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test('the source line names system config when the stored key won (D4)', () => {
|
|
194
|
+
const r = render({
|
|
195
|
+
policy: 'required',
|
|
196
|
+
source: 'config',
|
|
197
|
+
configValue: 'required',
|
|
198
|
+
record: undefined,
|
|
199
|
+
});
|
|
200
|
+
expect(text(r)).toContain('policy from system config (hooks.jail_policy)');
|
|
201
|
+
expect(text(r)).not.toContain('policy from CELILO_HOOK_JAIL');
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test('the source line names the default and how to change it (D4)', () => {
|
|
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');
|
|
218
|
+
expect(text(r)).toContain(
|
|
219
|
+
`policy default (${fallback.policy}) — set hooks.jail_policy or CELILO_HOOK_JAIL to change`,
|
|
220
|
+
);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
test('env and config disagreeing renders the losing value (D4)', () => {
|
|
224
|
+
const r = render({
|
|
225
|
+
policy: 'required',
|
|
226
|
+
source: 'env',
|
|
227
|
+
envValue: 'required',
|
|
228
|
+
configValue: 'off',
|
|
229
|
+
record: undefined,
|
|
230
|
+
});
|
|
231
|
+
expect(text(r)).toContain(
|
|
232
|
+
"system config holds 'off' but CELILO_HOOK_JAIL='required' overrides it",
|
|
233
|
+
);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
test('env and config agreeing renders no disagreement line (D4)', () => {
|
|
237
|
+
const r = render({
|
|
238
|
+
policy: 'off',
|
|
239
|
+
source: 'env',
|
|
240
|
+
envValue: 'off',
|
|
241
|
+
configValue: 'off',
|
|
242
|
+
record: undefined,
|
|
243
|
+
});
|
|
244
|
+
expect(text(r)).not.toContain('system config holds');
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
test('a bad stored row renders as a failing check naming the row, not a crash', () => {
|
|
248
|
+
const r = assembleHookExecutionSection({
|
|
249
|
+
envValue: () => undefined,
|
|
250
|
+
configValue: () => 'alwayssafe',
|
|
251
|
+
availability: () => ({ backend: 'bubblewrap' }),
|
|
252
|
+
record: () => undefined,
|
|
253
|
+
host: () => HOST,
|
|
254
|
+
monitored: () => true,
|
|
255
|
+
exemptions: () => [],
|
|
256
|
+
});
|
|
257
|
+
expect(r.lines[0]).toBe('Hook execution');
|
|
258
|
+
expect(r.lines[1]).toContain("hooks.jail_policy='alwayssafe' is not a hook jail policy");
|
|
259
|
+
expect(r.failCount).toBe(1);
|
|
260
|
+
expect(r.warnCount).toBe(0);
|
|
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
|
+
});
|
|
156
293
|
});
|