@celilo/cli 1.4.0 → 1.6.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.
Files changed (55) hide show
  1. package/CELILO_SUBSYSTEMS.md +18 -4
  2. package/MODULE_PRIMITIVES.md +19 -6
  3. package/drizzle/0026_module_integrity_version.sql +20 -0
  4. package/drizzle/meta/_journal.json +8 -1
  5. package/package.json +2 -2
  6. package/src/cli/commands/module-audit.ts +5 -2
  7. package/src/cli/commands/module-update.test.ts +90 -2
  8. package/src/cli/commands/module-update.ts +112 -6
  9. package/src/cli/commands/module-verify.ts +77 -13
  10. package/src/cli/commands/system-audit.ts +17 -0
  11. package/src/cli/commands/system-doctor.ts +78 -2
  12. package/src/cli/commands/system-update.ts +33 -3
  13. package/src/cli/index.ts +2 -2
  14. package/src/cli/tui/audit-state.ts +11 -3
  15. package/src/cli/tui/audit-tui.tsx +10 -4
  16. package/src/cli/tui/icons.ts +9 -2
  17. package/src/cli/tui/modals/analyzing.tsx +3 -0
  18. package/src/db/schema.ts +5 -0
  19. package/src/manifest/json-schema-roundtrip.test.ts +12 -4
  20. package/src/manifest/schema.ts +23 -0
  21. package/src/module/import.ts +36 -35
  22. package/src/module/packaging/audit.ts +103 -28
  23. package/src/module/packaging/build.ts +41 -53
  24. package/src/module/packaging/classify-module-path.test.ts +104 -0
  25. package/src/module/packaging/extract.ts +31 -3
  26. package/src/module/packaging/generated-plane.test.ts +79 -0
  27. package/src/module/packaging/generated-plane.ts +134 -0
  28. package/src/module/packaging/host-plane.test.ts +132 -0
  29. package/src/module/packaging/host-plane.ts +135 -0
  30. package/src/module/packaging/package-rules.ts +62 -0
  31. package/src/policy/module-script-scan.test.ts +164 -0
  32. package/src/policy/module-script-scan.ts +143 -0
  33. package/src/policy/no-hand-built-ssh.test.ts +22 -62
  34. package/src/services/audit/cli-version.test.ts +6 -2
  35. package/src/services/audit/cli-version.ts +20 -6
  36. package/src/services/audit/detect-without-converge.test.ts +91 -0
  37. package/src/services/audit/detect-without-converge.ts +81 -0
  38. package/src/services/audit/disk-space.test.ts +5 -2
  39. package/src/services/audit/disk-space.ts +5 -3
  40. package/src/services/audit/health.test.ts +39 -0
  41. package/src/services/audit/index.test.ts +7 -1
  42. package/src/services/audit/index.ts +12 -0
  43. package/src/services/audit/module-integrity.test.ts +146 -0
  44. package/src/services/audit/module-integrity.ts +113 -0
  45. package/src/services/audit/module-versions.ts +4 -1
  46. package/src/services/audit/schema.test.ts +7 -2
  47. package/src/services/audit/schema.ts +19 -1
  48. package/src/services/audit/terraform-plan.ts +17 -2
  49. package/src/services/audit/types.test.ts +29 -0
  50. package/src/services/audit/types.ts +30 -4
  51. package/src/services/module-deploy.ts +21 -0
  52. package/src/services/restore-from-file.ts +4 -0
  53. package/src/services/update/orchestrator.test.ts +2 -0
  54. package/src/templates/copy-role-files.test.ts +69 -0
  55. package/src/templates/generator.ts +23 -1
@@ -0,0 +1,134 @@
1
+ /**
2
+ * The second plane: is what we would deploy built from what we installed?
3
+ *
4
+ * A module exists in four places — the published package, the installed tree,
5
+ * the generated project, and the host — and celilo reported a version for the
6
+ * first as though it described the last. Between them sit three copies and two
7
+ * transformations, and nothing verified any of them against the one before it
8
+ * (openspec/changes/module-integrity-rigor, D3).
9
+ *
10
+ * Only VERBATIM assets have a meaningful expected digest. Ansible templates
11
+ * most of what it writes, and a `.j2` in `generated/` is supposed to differ
12
+ * from its source. `ansible/roles/<role>/files/` is the exception: it holds
13
+ * static assets — built binaries, certs, blobs — copied byte for byte because
14
+ * they need no variable resolution and may not survive utf-8 round-tripping.
15
+ *
16
+ * That is exactly where celilo#925 lived. `copyAnsibleRoleFilesDirs` skipped an
17
+ * existing destination on bun, so a module's built binary landed in
18
+ * `generated/` once, at first generate, and no later version replaced it.
19
+ * `cp` reported no error, Ansible copied that first binary forever and reported
20
+ * `ok`, and the module's version field advanced past code that was never
21
+ * shipped. This comparison is local, takes milliseconds, needs no SSH, and is
22
+ * the check that turns that silence into a refusal.
23
+ */
24
+
25
+ import { existsSync } from 'node:fs';
26
+ import { readdir } from 'node:fs/promises';
27
+ import { join, relative } from 'node:path';
28
+ import { computeFileChecksum } from './checksum';
29
+
30
+ export interface VerbatimAssetDifference {
31
+ /** Path relative to the module root, identical in both trees. */
32
+ relPath: string;
33
+ /** `missing`: generation never produced it. `stale`: it holds other bytes. */
34
+ reason: 'missing' | 'stale';
35
+ installedDigest: string;
36
+ generatedDigest: string | null;
37
+ }
38
+
39
+ /**
40
+ * Pure. Both sides are digest maps keyed by the same module-relative path.
41
+ *
42
+ * An asset present in `generated` but absent from `installed` is NOT reported.
43
+ * That is a role the module no longer has, whose old output nothing includes,
44
+ * and failing a deploy for it would refuse a correct module for a stale file
45
+ * Ansible never reads.
46
+ */
47
+ export function compareVerbatimRoleAssets(
48
+ installed: ReadonlyMap<string, string>,
49
+ generated: ReadonlyMap<string, string>,
50
+ ): VerbatimAssetDifference[] {
51
+ const differences: VerbatimAssetDifference[] = [];
52
+ for (const [relPath, installedDigest] of installed) {
53
+ const generatedDigest = generated.get(relPath) ?? null;
54
+ if (generatedDigest === null) {
55
+ differences.push({ relPath, reason: 'missing', installedDigest, generatedDigest: null });
56
+ } else if (generatedDigest !== installedDigest) {
57
+ differences.push({ relPath, reason: 'stale', installedDigest, generatedDigest });
58
+ }
59
+ }
60
+ return differences;
61
+ }
62
+
63
+ /**
64
+ * Digest every `ansible/roles/<role>/files/**` asset under `root`, keyed by its
65
+ * path relative to `root`. The generated project mirrors that layout, so the
66
+ * two maps this produces are directly comparable.
67
+ */
68
+ export async function readVerbatimRoleAssets(root: string): Promise<Map<string, string>> {
69
+ const assets = new Map<string, string>();
70
+ const rolesDir = join(root, 'ansible', 'roles');
71
+ if (!existsSync(rolesDir)) return assets;
72
+
73
+ for (const role of await readdir(rolesDir, { withFileTypes: true })) {
74
+ if (!role.isDirectory()) continue;
75
+ const filesDir = join(rolesDir, role.name, 'files');
76
+ if (!existsSync(filesDir)) continue;
77
+ for (const filePath of await listFiles(filesDir)) {
78
+ assets.set(relative(root, filePath), await computeFileChecksum(filePath));
79
+ }
80
+ }
81
+ return assets;
82
+ }
83
+
84
+ async function listFiles(dir: string): Promise<string[]> {
85
+ const found: string[] = [];
86
+ for (const entry of await readdir(dir, { withFileTypes: true })) {
87
+ const full = join(dir, entry.name);
88
+ if (entry.isDirectory()) {
89
+ found.push(...(await listFiles(full)));
90
+ } else if (entry.isFile()) {
91
+ found.push(full);
92
+ }
93
+ }
94
+ return found;
95
+ }
96
+
97
+ /**
98
+ * One line per difference, naming the file. celilo#925 took days partly because
99
+ * nothing anywhere named the file that had gone stale.
100
+ */
101
+ export function describeVerbatimDifferences(differences: VerbatimAssetDifference[]): string[] {
102
+ return differences.map((d) =>
103
+ d.reason === 'missing'
104
+ ? `${d.relPath}: generation never produced it (installed ${d.installedDigest})`
105
+ : `${d.relPath}: generated holds ${d.generatedDigest}, installed is ${d.installedDigest}`,
106
+ );
107
+ }
108
+
109
+ /**
110
+ * The deploy pre-flight (D6). Returns the refusal message, or `null` to proceed.
111
+ *
112
+ * Local, milliseconds, no SSH, and it runs before anything contacts a system.
113
+ * It deliberately does NOT verify after the deploy: Ansible's `changed` is
114
+ * truthful about what Ansible did, and the lie in celilo#925 was upstream of
115
+ * Ansible.
116
+ */
117
+ export async function refuseIfGeneratedIsStale(
118
+ moduleId: string,
119
+ modulePath: string,
120
+ generatedPath: string,
121
+ ): Promise<string | null> {
122
+ const differences = compareVerbatimRoleAssets(
123
+ await readVerbatimRoleAssets(modulePath),
124
+ await readVerbatimRoleAssets(generatedPath),
125
+ );
126
+ if (differences.length === 0) return null;
127
+ return [
128
+ `Refusing to deploy ${moduleId}: the generated project does not match the installed module.`,
129
+ ` ${differences.length} verbatim asset(s) differ:`,
130
+ ...describeVerbatimDifferences(differences).map((line) => ` ${line}`),
131
+ '',
132
+ `Deploying would ship these bytes and report success. Run 'celilo module generate ${moduleId}' and try again.`,
133
+ ].join('\n');
134
+ }
@@ -0,0 +1,132 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import type { ModuleManifest } from '../../manifest/schema';
6
+ import { classifyHostRecap, verifyModuleOnHosts } from './host-plane';
7
+
8
+ const recap = (over: Partial<Parameters<typeof classifyHostRecap>[0]> = {}) => ({
9
+ host: 'vpn-manager',
10
+ changed: 0,
11
+ unreachable: 0,
12
+ failed: 0,
13
+ skipped: 0,
14
+ ...over,
15
+ });
16
+
17
+ describe('classifyHostRecap', () => {
18
+ test('nothing would change → converged', () => {
19
+ expect(classifyHostRecap(recap(), 'm').state).toBe('converged');
20
+ });
21
+
22
+ test('something would change → drift, and never blocked', () => {
23
+ const finding = classifyHostRecap(recap({ changed: 3 }), 'wireguard-manager');
24
+ expect(finding.state).toBe('drift');
25
+ expect(finding.detail).toContain('celilo module deploy wireguard-manager');
26
+ });
27
+
28
+ test('unreachable → unmeasured, not converged', () => {
29
+ expect(classifyHostRecap(recap({ unreachable: 1 }), 'm').state).toBe('unmeasured');
30
+ });
31
+
32
+ test('failed → unmeasured', () => {
33
+ expect(classifyHostRecap(recap({ failed: 1 }), 'm').state).toBe('unmeasured');
34
+ });
35
+
36
+ test('THE case that must not read as converged: check mode skipped tasks', () => {
37
+ // Ansible does not evaluate a task it cannot support — it SKIPS it. A role
38
+ // of `command:` / `shell:` tasks can finish with changed=0 having never
39
+ // been applied to the host at all. Two states would call that converged.
40
+ const finding = classifyHostRecap(recap({ skipped: 4 }), 'm');
41
+ expect(finding.state).toBe('unmeasured');
42
+ expect(finding.detail).toContain('4 task(s)');
43
+ });
44
+
45
+ test('a skip outranks a change, because the run as a whole was not measured', () => {
46
+ expect(classifyHostRecap(recap({ skipped: 1, changed: 2 }), 'm').state).toBe('unmeasured');
47
+ });
48
+ });
49
+
50
+ describe('verifyModuleOnHosts', () => {
51
+ function generatedTreeWithPlaybook(): string {
52
+ const root = mkdtempSync(join(tmpdir(), 'celilo-hostplane-'));
53
+ mkdirSync(join(root, 'ansible'), { recursive: true });
54
+ writeFileSync(join(root, 'ansible', 'playbook.yml'), '---\n- hosts: all\n');
55
+ return root;
56
+ }
57
+
58
+ const manifest = { id: 'm', name: 'M', version: '1.0.0' } as unknown as ModuleManifest;
59
+
60
+ test('a manifest opt-out is carried back, not silently dropped', async () => {
61
+ const optedOut = {
62
+ ...manifest,
63
+ verify: { deep: false, reason: 'the role is command:-driven' },
64
+ } as unknown as ModuleManifest;
65
+
66
+ const result = await verifyModuleOnHosts({
67
+ moduleId: 'm',
68
+ manifest: optedOut,
69
+ generatedPath: '/nonexistent',
70
+ execute: async () => {
71
+ throw new Error('must not run ansible for an opted-out module');
72
+ },
73
+ });
74
+ expect(result.optedOut?.reason).toBe('the role is command:-driven');
75
+ expect(result.findings).toEqual([]);
76
+ });
77
+
78
+ test('no generated playbook is unmeasured, not clean', async () => {
79
+ const result = await verifyModuleOnHosts({
80
+ moduleId: 'm',
81
+ manifest,
82
+ generatedPath: '/nonexistent',
83
+ execute: async () => {
84
+ throw new Error('must not run ansible without a playbook');
85
+ },
86
+ });
87
+ expect(result.findings.map((f) => f.state)).toEqual(['unmeasured']);
88
+ });
89
+
90
+ test('a run producing no PLAY RECAP is unmeasured, not clean', async () => {
91
+ // celilo#951's shape in another comparator: nothing measured, rendered as
92
+ // nothing wrong.
93
+ const root = generatedTreeWithPlaybook();
94
+ try {
95
+ const result = await verifyModuleOnHosts({
96
+ moduleId: 'm',
97
+ manifest,
98
+ generatedPath: root,
99
+ execute: async () => ({ success: false, output: 'ssh: connect refused', error: 'boom' }),
100
+ });
101
+ expect(result.findings.map((f) => f.state)).toEqual(['unmeasured']);
102
+ expect(result.findings[0]?.detail).toContain('no PLAY RECAP');
103
+ } finally {
104
+ rmSync(root, { recursive: true, force: true });
105
+ }
106
+ });
107
+
108
+ test('one finding per host in the recap', async () => {
109
+ const root = generatedTreeWithPlaybook();
110
+ try {
111
+ const result = await verifyModuleOnHosts({
112
+ moduleId: 'm',
113
+ manifest,
114
+ generatedPath: root,
115
+ execute: async () => ({
116
+ success: true,
117
+ output: [
118
+ 'PLAY RECAP *********',
119
+ 'vpn-manager : ok=10 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0',
120
+ 'edge : ok=4 changed=2 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0',
121
+ ].join('\n'),
122
+ }),
123
+ });
124
+ expect(result.findings).toEqual([
125
+ { hostname: 'vpn-manager', state: 'converged' },
126
+ expect.objectContaining({ hostname: 'edge', state: 'drift' }),
127
+ ]);
128
+ } finally {
129
+ rmSync(root, { recursive: true, force: true });
130
+ }
131
+ });
132
+ });
@@ -0,0 +1,135 @@
1
+ /**
2
+ * The third plane: is the code running on the host the code we generated?
3
+ *
4
+ * Measured by asking Ansible, not by hashing files. celilo does not know where
5
+ * Ansible puts what it writes — the destination lives in the role's
6
+ * `tasks/main.yml` `dest:`, which celilo does not parse and should not start
7
+ * parsing. Ansible already computes exactly this, for every task, including
8
+ * templated files whose content celilo could not predict
9
+ * (openspec/changes/module-integrity-rigor, D4).
10
+ *
11
+ * This SSHes to every system the module deploys, so it is gated behind
12
+ * `--deep` and never runs in a default pass.
13
+ *
14
+ * The honest caveat, in the spec and not only in the code: check mode does not
15
+ * evaluate a task it cannot support — it SKIPS it — so a role built from
16
+ * `command:` / `shell:` tasks can finish a check run with `changed=0` having
17
+ * never been applied to the host at all. So a skip is `unmeasured`, never a
18
+ * pass, and a finding here is `drift`, never `blocked`. A check that cries wolf
19
+ * is the disease; shipping one here would be an unusually stupid way to catch
20
+ * it.
21
+ */
22
+
23
+ import { existsSync } from 'node:fs';
24
+ import { join } from 'node:path';
25
+ import type { ModuleManifest } from '../../manifest/schema';
26
+ import { executeAnsible, parseAnsibleRecap } from '../../services/deploy-ansible';
27
+
28
+ /**
29
+ * `unmeasured` is not a hedge and must never collapse into `converged`.
30
+ * Absence of a change is not evidence of convergence when nothing was assessed.
31
+ */
32
+ export type HostPlaneState = 'converged' | 'drift' | 'unmeasured';
33
+
34
+ export interface HostPlaneFinding {
35
+ hostname: string;
36
+ state: HostPlaneState;
37
+ /** Why, in operator-facing words. Always set for anything but `converged`. */
38
+ detail?: string;
39
+ }
40
+
41
+ export interface HostPlaneResult {
42
+ findings: HostPlaneFinding[];
43
+ /**
44
+ * Set when the module declared `verify.deep: false`. Carried so callers can
45
+ * PRINT the opt-out — an opt-out nobody sees is a check that quietly
46
+ * disappeared.
47
+ */
48
+ optedOut?: { reason: string };
49
+ }
50
+
51
+ /**
52
+ * Classify one Ansible `PLAY RECAP` line. Pure, so the interesting decisions
53
+ * are testable without an SSH round trip.
54
+ */
55
+ export function classifyHostRecap(
56
+ recap: { host: string; changed: number; unreachable: number; failed: number; skipped: number },
57
+ moduleId: string,
58
+ ): HostPlaneFinding {
59
+ if (recap.unreachable > 0 || recap.failed > 0) {
60
+ return {
61
+ hostname: recap.host,
62
+ state: 'unmeasured',
63
+ detail: 'The host could not be evaluated — it did not answer, or the play errored on it.',
64
+ };
65
+ }
66
+ if (recap.skipped > 0) {
67
+ return {
68
+ hostname: recap.host,
69
+ state: 'unmeasured',
70
+ detail: `${recap.skipped} task(s) check mode cannot evaluate were skipped, so convergence was not measured. Prefer check-capable Ansible modules (copy, template, lineinfile, file, package, service), or give a command/shell task an honest changed_when:.`,
71
+ };
72
+ }
73
+ if (recap.changed > 0) {
74
+ return {
75
+ hostname: recap.host,
76
+ state: 'drift',
77
+ detail: `The playbook would change ${recap.changed} thing(s) on this host, so what is running is not what celilo generated. Run 'celilo module deploy ${moduleId}' to converge it.`,
78
+ };
79
+ }
80
+ return { hostname: recap.host, state: 'converged' };
81
+ }
82
+
83
+ /**
84
+ * Evaluate the module's generated playbook against its systems in check mode.
85
+ *
86
+ * `executeAnsible(..., { check: true })` is the existing plumbing and the only
87
+ * one — `--check` is one argument, not a second execution path.
88
+ */
89
+ export async function verifyModuleOnHosts(args: {
90
+ moduleId: string;
91
+ manifest: ModuleManifest;
92
+ generatedPath: string;
93
+ /** Injected in tests, exactly as `verifyAspectCoverage` does it. */
94
+ execute?: typeof executeAnsible;
95
+ }): Promise<HostPlaneResult> {
96
+ const { moduleId, manifest, generatedPath } = args;
97
+
98
+ if (manifest.verify?.deep === false) {
99
+ return { findings: [], optedOut: { reason: manifest.verify.reason } };
100
+ }
101
+
102
+ const playbookPath = join(generatedPath, 'ansible', 'playbook.yml');
103
+ if (!existsSync(playbookPath)) {
104
+ return {
105
+ findings: [
106
+ {
107
+ hostname: '(none)',
108
+ state: 'unmeasured',
109
+ detail: `No generated playbook at ${playbookPath}. Run 'celilo module generate ${moduleId}' first.`,
110
+ },
111
+ ],
112
+ };
113
+ }
114
+
115
+ const execute = args.execute ?? executeAnsible;
116
+ const result = await execute(generatedPath, { check: true, noInteractive: true });
117
+ const recaps = parseAnsibleRecap(result.output ?? '');
118
+
119
+ if (recaps.length === 0) {
120
+ // A run that produced no recap at all must not read as success. This is
121
+ // the shape celilo#951 got wrong in another comparator: nothing measured,
122
+ // rendered as nothing wrong.
123
+ return {
124
+ findings: [
125
+ {
126
+ hostname: '(none)',
127
+ state: 'unmeasured',
128
+ detail: `Ansible produced no PLAY RECAP, so nothing was measured.${result.error ? ` ${result.error}` : ''}`,
129
+ },
130
+ ],
131
+ };
132
+ }
133
+
134
+ return { findings: recaps.map((recap) => classifyHostRecap(recap, moduleId)) };
135
+ }
@@ -42,3 +42,65 @@ export function includeNodeModulesPath(relPath: string): boolean {
42
42
  if (nmIdx + 2 >= segments.length) return true; // node_modules/@celilo dir itself
43
43
  return segments[nmIdx + 2] === 'capabilities';
44
44
  }
45
+
46
+ /**
47
+ * What a module-relative path IS, for integrity purposes.
48
+ *
49
+ * - `package` the module's own content. Belongs in `checksums.json` and must
50
+ * match it. A mismatch is a real finding.
51
+ * - `derived` celilo writes or rewrites the on-disk copy, so its content is
52
+ * not a stable integrity claim. Never a finding.
53
+ * - `unknown` neither. Never packaged, never installed, and the only kind of
54
+ * `[EXTRA]` worth printing.
55
+ */
56
+ export type ModulePathClass = 'package' | 'derived' | 'unknown';
57
+
58
+ /**
59
+ * The one answer to "what belongs to a module", replacing the four divergent
60
+ * copies that used to decide it independently (`build.ts#shouldExclude`,
61
+ * `audit.ts#FRAMEWORK_OWNED_PATHS`, `extract.ts#scanDirectory`,
62
+ * `import.ts#copyModuleFiles`). Every disagreement between them became a
63
+ * `module verify` violation — 72 of them across two healthy modules.
64
+ *
65
+ * `scripts/node_modules/**` is the interesting case: it is `derived`, because
66
+ * `module import` runs `bun install` over it and the bytes on disk stop
67
+ * matching the package's. It is nonetheless SHIPPED, because a target may have
68
+ * no reachable registry (ISS-0046) — that carve-out lives in `build.ts`, which
69
+ * composes this function with `includeNodeModulesPath` rather than restating
70
+ * either rule.
71
+ */
72
+ export function classifyModulePath(relPath: string): ModulePathClass {
73
+ const segments = relPath.split('/');
74
+ const name = segments[segments.length - 1] ?? '';
75
+
76
+ // The module's own e2e/ tree is tests plus their deps, including a
77
+ // node_modules of its own. Excluded whole, before anything below.
78
+ if (segments[0] === 'e2e') return 'unknown';
79
+
80
+ // Anything under a `node_modules` segment is decided by the canonical rule
81
+ // and by nothing else. Ordering matters: a vendored dependency ships files
82
+ // named `*.test.ts` (`@celilo/capabilities/src/remote.test.ts` is on the
83
+ // fleet right now) and those are the DEPENDENCY's, not the module's.
84
+ if (segments.includes('node_modules')) {
85
+ return includeNodeModulesPath(relPath) ? 'derived' : 'unknown';
86
+ }
87
+
88
+ // Source-tree noise. A module's git repo has it, a module's install never
89
+ // should, so on an installed tree it is a real finding.
90
+ if (segments.some((s) => s === '.git' || s === '.next' || s === '.cache')) return 'unknown';
91
+ if (name === '.DS_Store') return 'unknown';
92
+ // scripts/tsconfig.json exists so tsc can check hooks in CI. Nothing on a
93
+ // target ever runs tsc.
94
+ if (name === 'tsconfig.json') return 'unknown';
95
+ if (name.endsWith('.netapp') || name.endsWith('.test.ts')) return 'unknown';
96
+
97
+ // Celilo's own output under the module's install root.
98
+ if (segments[0] === 'generated' || segments[0] === 'screenshots') return 'derived';
99
+ // A checksum manifest cannot list itself, nor the signature over it.
100
+ if (relPath === 'checksums.json' || relPath === 'signature.sig') return 'derived';
101
+ // Regenerated by `module import` from the manifest (HOOK_API_V2 Phase 2).
102
+ if (relPath === 'celilo/types.d.ts') return 'derived';
103
+ if (relPath === 'cookies.json') return 'derived';
104
+
105
+ return 'package';
106
+ }
@@ -0,0 +1,164 @@
1
+ /**
2
+ * The scan rules themselves, and the proof that packaging refuses a module that
3
+ * breaks them.
4
+ *
5
+ * `no-hand-built-ssh.test.ts` asserts the CURRENT tree is clean, which is a
6
+ * different claim: it passes both when the rules work and when they match
7
+ * nothing. These tests are the ones that fail if a rule stops catching things.
8
+ */
9
+
10
+ import { describe, expect, it } from 'bun:test';
11
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
12
+ import { tmpdir } from 'node:os';
13
+ import { join } from 'node:path';
14
+ import { buildModule } from '../module/packaging/build';
15
+ import { scanModuleDirectory, scanModuleScriptSource } from './module-script-scan';
16
+
17
+ const rules = (src: string) => scanModuleScriptSource('f.ts', src).map((v) => v.rule);
18
+
19
+ describe('module script scan — SSH rules', () => {
20
+ it('catches a hand-built ssh string', () => {
21
+ expect(rules("run(`ssh root@${ip} 'systemctl restart x'`);")).toContain(
22
+ 'raw ssh invocation (ssh … root@)',
23
+ );
24
+ });
25
+
26
+ it('catches StrictHostKeyChecking however it is invoked', () => {
27
+ expect(rules("const c = 'ssh -o StrictHostKeyChecking=no host';")).toContain(
28
+ 'raw ssh invocation (StrictHostKeyChecking)',
29
+ );
30
+ });
31
+
32
+ it('catches an ssh2 import', () => {
33
+ expect(rules("import { Client } from 'ssh2';")).toContain("'ssh2' import");
34
+ });
35
+
36
+ it('does not fire on ordinary module code', () => {
37
+ expect(rules('const x = probe(system, { kind: "systemd", unit: "caddy" }, run);')).toEqual([]);
38
+ });
39
+ });
40
+
41
+ describe('module script scan — raw-exec escape hatch', () => {
42
+ it('flags a runAppCommand call with no justification', () => {
43
+ expect(rules('const r = runAppCommand(system, "rm -f /tmp/x", run);')).toContain(
44
+ 'unjustified raw-exec escape hatch',
45
+ );
46
+ });
47
+
48
+ it('flags runAppCommandWithSecret too', () => {
49
+ expect(rules('const r = runAppCommandWithSecret(system, cli, secret, run);')).toContain(
50
+ 'unjustified raw-exec escape hatch',
51
+ );
52
+ });
53
+
54
+ it('accepts a call justified immediately above', () => {
55
+ const src = [
56
+ '// escape-hatch: forgejo admin user create is CLI-only, no HTTP API path.',
57
+ 'const r = runAppCommand(system, cmd, run);',
58
+ ].join('\n');
59
+ expect(rules(src)).toEqual([]);
60
+ });
61
+
62
+ it('accepts a call wrapped in waitFor, justified above the enclosing statement', () => {
63
+ // The real shape in modules/caddy-internal — the justification sits above
64
+ // `const ready = await waitFor(`, a few lines up from the call itself.
65
+ const src = [
66
+ '// escape-hatch: the command output IS the payload; no API to ask.',
67
+ 'const ready = await waitFor(',
68
+ ' () =>',
69
+ ' runAppCommand(target, CMD, run, {',
70
+ ' timeoutMs: 10_000,',
71
+ ' }).ok,',
72
+ ');',
73
+ ].join('\n');
74
+ expect(rules(src)).toEqual([]);
75
+ });
76
+
77
+ it('does not let a distant hatch launder a later call', () => {
78
+ const src = [
79
+ '// escape-hatch: justifies the call directly below it, and nothing else.',
80
+ 'const a = runAppCommand(system, one, run);',
81
+ ...Array(12).fill('doSomethingElse();'),
82
+ 'const b = runAppCommand(system, two, run);',
83
+ ].join('\n');
84
+ // Exactly one violation: the second call, which has no hatch in reach.
85
+ expect(rules(src)).toEqual(['unjustified raw-exec escape hatch']);
86
+ });
87
+
88
+ it('does not flag the import of runAppCommand', () => {
89
+ expect(rules("import { runAppCommand, probe } from '@celilo/capabilities';")).toEqual([]);
90
+ });
91
+ });
92
+
93
+ describe('module script scan — what it deliberately does not scan', () => {
94
+ let dir: string;
95
+
96
+ function write(rel: string, content: string): void {
97
+ const full = join(dir, rel);
98
+ mkdirSync(join(full, '..'), { recursive: true });
99
+ writeFileSync(full, content);
100
+ }
101
+
102
+ it('ignores bundled node_modules and test files', () => {
103
+ dir = mkdtempSync(join(tmpdir(), 'celilo-scan-test-'));
104
+ try {
105
+ write('manifest.yml', 'id: demo\n');
106
+ // @celilo/capabilities legitimately BUILDS the ssh string these rules ban.
107
+ // Scanning the bundled closure would fail every module in the fleet.
108
+ write(
109
+ 'scripts/node_modules/@celilo/capabilities/src/remote.ts',
110
+ 'const c = `ssh -o StrictHostKeyChecking=no root@${ip} ${cmd}`;',
111
+ );
112
+ write('scripts/setup.test.ts', "run('ssh root@host uptime');");
113
+ write('scripts/setup.ts', 'export const fine = 1;\n');
114
+ expect(scanModuleDirectory(dir)).toEqual([]);
115
+ } finally {
116
+ rmSync(dir, { recursive: true, force: true });
117
+ }
118
+ });
119
+ });
120
+
121
+ describe('packaging refuses a module that breaks the policy', () => {
122
+ let dir: string;
123
+
124
+ function write(rel: string, content: string): void {
125
+ const full = join(dir, rel);
126
+ mkdirSync(join(full, '..'), { recursive: true });
127
+ writeFileSync(full, content);
128
+ }
129
+
130
+ it('fails the build, naming the file, line and rule', async () => {
131
+ dir = mkdtempSync(join(tmpdir(), 'celilo-package-policy-'));
132
+ try {
133
+ write('manifest.yml', 'id: demo\nversion: 0.1.0\n');
134
+ write(
135
+ 'scripts/setup.ts',
136
+ ['export function bad(ip: string) {', ' return `ssh root@${ip} uptime`;', '}'].join('\n'),
137
+ );
138
+
139
+ const result = await buildModule({ sourceDir: dir });
140
+
141
+ expect(result.success).toBe(false);
142
+ expect(result.error).toContain('scripts/setup.ts:2');
143
+ expect(result.error).toContain('raw ssh invocation');
144
+ expect(result.error).toContain('MODULE_PRIMITIVES.md');
145
+ } finally {
146
+ rmSync(dir, { recursive: true, force: true });
147
+ }
148
+ });
149
+
150
+ it('fails the build for an unjustified escape hatch', async () => {
151
+ dir = mkdtempSync(join(tmpdir(), 'celilo-package-policy-'));
152
+ try {
153
+ write('manifest.yml', 'id: demo\nversion: 0.1.0\n');
154
+ write('scripts/setup.ts', 'const r = runAppCommand(system, "rm -rf /srv", run);\n');
155
+
156
+ const result = await buildModule({ sourceDir: dir });
157
+
158
+ expect(result.success).toBe(false);
159
+ expect(result.error).toContain('unjustified raw-exec escape hatch');
160
+ } finally {
161
+ rmSync(dir, { recursive: true, force: true });
162
+ }
163
+ });
164
+ });