@clear-capabilities/agentic-security-scanner 0.167.0 → 0.169.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.
@@ -135,10 +135,16 @@ export const RUNTIME_OBSERVATION_FIELDS = Object.freeze([
135
135
  'firstObservedAt', 'lastObservedAt', 'matchMethod', 'matchConfidence', 'retention', 'importedAt',
136
136
  ]);
137
137
 
138
- // Only one adapter is actually implemented today — an unimplemented
139
- // adapter name must be rejected, not accepted on faith that it will
140
- // someday exist (RO/3b).
141
- export const RUNTIME_OBSERVATION_ADAPTERS = Object.freeze(['native-jsonl']);
138
+ // An unimplemented adapter name must be rejected, not accepted on faith
139
+ // that it will someday exist (RO/3b). The four PRD "Runtime-Corroborated
140
+ // Security Twin" adapters (`observation-adapters.js`) were added
141
+ // alongside `native-jsonl` as the real P0 telemetry-input gap that PRD
142
+ // closes — OTLP's own richer, evolving attribute surface stays deferred
143
+ // (`'otel-json'` here is this codebase's own minimal OTLP/JSON reader,
144
+ // never the full semantic-conventions vocabulary).
145
+ export const RUNTIME_OBSERVATION_ADAPTERS = Object.freeze([
146
+ 'native-jsonl', 'otel-json', 'envoy-access-log', 'k8s-audit-log', 'http-access-log',
147
+ ]);
142
148
 
143
149
  // FR-505's own four named metadata families: service/workload identity,
144
150
  // endpoint/destination identity, protocol/TLS metadata, and schema/
@@ -160,8 +166,23 @@ export const RUNTIME_ATTRIBUTE_KEYS = Object.freeze([
160
166
  'network.protocol', 'network.transport', 'tls.version', 'tls.cipher', 'tls.verified',
161
167
  // schema/attribute NAMES already approved for telemetry
162
168
  'schema.name', 'schema.attributeNames',
169
+ // the one security-DECISION field (PRD "Runtime-Corroborated Security
170
+ // Twin" §P0-1's "security decision" family) — added for the K8s audit
171
+ // adapter, which is defined by the exact allow/deny fact this key
172
+ // records. A closed two-value enum, never a free-form identifier: see
173
+ // RUNTIME_SECURITY_DECISION_VALUES below and its own dedicated check in
174
+ // validateObservationAttributes.
175
+ 'network.securityDecision',
163
176
  ]);
164
177
 
178
+ // The closed enum for 'network.securityDecision' — deliberately narrower
179
+ // than the generic identifier-value check every other scalar attribute
180
+ // gets, since this field has exactly two legitimate values and nothing
181
+ // else should ever be accepted under it (not 'allow'/'deny', not 'true'/
182
+ // 'false', not a raw HTTP status code — an adapter normalizes to one of
183
+ // these two strings itself).
184
+ export const RUNTIME_SECURITY_DECISION_VALUES = Object.freeze(['allowed', 'denied']);
185
+
165
186
  // The one attribute key whose value is an array rather than a scalar —
166
187
  // itself must be a member of RUNTIME_ATTRIBUTE_KEYS.
167
188
  export const RUNTIME_ARRAY_ATTRIBUTE_KEYS = Object.freeze(['schema.attributeNames']);
@@ -272,6 +293,18 @@ export function validateObservationAttributes(attributes) {
272
293
  }
273
294
  continue;
274
295
  }
296
+ if (key === 'network.securityDecision') {
297
+ // A closed two-value enum, deliberately narrower than the generic
298
+ // identifier-value check below — this field has exactly two
299
+ // legitimate values, and accepting anything identifier-shaped
300
+ // ('unknown', 'maybe', a raw status code string) would let an
301
+ // adapter smuggle an unreviewed vocabulary through a field whose
302
+ // whole purpose is a binary allow/deny fact.
303
+ if (!RUNTIME_SECURITY_DECISION_VALUES.includes(value)) {
304
+ err(`$.attributes["${key}"]`, `must be one of ${RUNTIME_SECURITY_DECISION_VALUES.join('|')} — this field records a closed allow/deny fact, never a free-form identifier`);
305
+ }
306
+ continue;
307
+ }
275
308
  const t = typeof value;
276
309
  if (t === 'string') {
277
310
  if (!_isIdentifierValue(value)) {
@@ -121,6 +121,9 @@ export const ARTIFACT_REGISTRY = [
121
121
  { name: 'shadow-findings.json', kind: 'file', classification: 'generated', retentionClass: 'scan' },
122
122
  { name: 'reproducers', kind: 'dir', classification: 'operator-config', source: 'src/posture/proof-fabric/cli.js (loadReproducer)', note: 'Operator-supplied serialized reproducers read by `triage exploit --prove`. Input, never written by the scanner, so reset leaves it alone.' },
123
123
  { name: 'proofs', kind: 'dir', classification: 'generated', retentionClass: 'evidence', confidential: true, source: 'src/posture/proof-fabric/store.js (saveProof)', note: 'Universal Exploit Proof Fabric evidence: hashed records of exploit reproductions. Never holds plaintext credentials, per-run nonces or raw secrets (the runner redacts before persisting). Encrypted per the encryption policy via maybeEncryptForWrite, and purged by retention like any evidence-class artifact; a legal hold on `proofs` blocks the purge.' },
124
+ { name: 'fleet-state.json', kind: 'file', classification: 'generated', retentionClass: 'scan', source: 'src/posture/fleet/cli.js, src/posture/fleet.js (loadFleetState/_saveFleetState)', note: 'Per-repository resume checkpoint for `fleet scan` — which fleet repositories are already complete, so a resumed run skips them. Scanner-managed; a plain reset may delete it.' },
125
+ { name: 'fleet-rollup.json', kind: 'file', classification: 'generated', retentionClass: 'scan', source: 'src/posture/fleet/cli.js', note: 'The last fleet rollup (rollupFleet output), kept only so the NEXT `fleet scan` can compute a delta against it and so `fleet status` has something to read without re-scanning.' },
126
+ { name: 'fleet-graph.json', kind: 'file', classification: 'generated', retentionClass: 'scan', source: 'src/posture/fleet/cli.js, src/posture/fleet/fleet-graph.js', note: 'The cross-repository dependency graph built from each fleet repository\'s own manifest, consumed by `fleet query --depends-on`. Rebuilt on every `fleet scan`.' },
124
127
  { name: 'mcp-audit.log', kind: 'file', classification: 'generated', retentionClass: 'evidence' },
125
128
  { name: 'egress-audit.log', kind: 'file', classification: 'generated', retentionClass: 'evidence', note: "FR-604 per-call egress audit log — hash-chained NDJSON written by egress/audit.js's recordEgressCall, never read as config" },
126
129
  { name: 'hook-throttle.json', kind: 'file', classification: 'generated', retentionClass: 'cache' },
@@ -0,0 +1,103 @@
1
+ // `agentic-security fleet init|scan|status|resume|query`.
2
+ //
3
+ // Reuses the existing posture/fleet.js (runFleet: bounded concurrency, resumable checkpoint, per-repo isolation,
4
+ // honest rollup) for the scanning half — this module adds only the manifest-driven entry point and the
5
+ // cross-repository dependency graph (fleet-graph.js) that runFleet has no notion of.
6
+ import fs from 'node:fs';
7
+ import path from 'node:path';
8
+ import { loadFleetManifest, starterManifestYaml } from './fleet-manifest.js';
9
+ import { buildFleetDependencyGraph, blastRadius } from './fleet-graph.js';
10
+ import { runFleet, rollupFleet, renderFleetSummary } from '../fleet.js';
11
+ import { statePath, ensureStateDir, safeWriteState } from '../state-dir.js';
12
+
13
+ const GRAPH_FILE = 'fleet-graph.json';
14
+ const STATE_FILE = 'fleet-state.json';
15
+
16
+ function graphPath(root) { return statePath(root, GRAPH_FILE); }
17
+ function statePathFor(root) { return statePath(root, STATE_FILE); }
18
+
19
+ export async function cmdFleet(args, root) {
20
+ const sub = args._[1];
21
+ if (sub === 'init') return cmdInit(args, root);
22
+ if (sub === 'scan') return cmdScan(args, root);
23
+ if (sub === 'status') return cmdStatus(args, root);
24
+ if (sub === 'resume') return cmdScan(args, root, { resumeOnly: true });
25
+ if (sub === 'query') return cmdQuery(args, root);
26
+ console.error('usage: fleet init | scan <manifest.yaml> | status <manifest.yaml> | resume <manifest.yaml> | query --depends-on <repo-id> [<manifest.yaml>]');
27
+ return 4;
28
+ }
29
+
30
+ function manifestArg(args) {
31
+ const m = args._[2];
32
+ return m && !String(m).startsWith('--') ? m : 'fleet.yaml';
33
+ }
34
+
35
+ async function cmdInit(args, root) {
36
+ const target = path.resolve(root, manifestArg(args) === 'fleet.yaml' ? 'fleet.yaml' : manifestArg(args));
37
+ if (fs.existsSync(target) && !args.flags.force) { console.error(`${target} already exists. Pass --force to overwrite.`); return 4; }
38
+ fs.writeFileSync(target, starterManifestYaml(args.flags.organization || 'example'));
39
+ console.log(`Wrote ${target}. Edit it to list your repositories, then run: agentic-security fleet scan ${path.basename(target)}`);
40
+ return 0;
41
+ }
42
+
43
+ async function cmdScan(args, root, { resumeOnly = false } = {}) {
44
+ const manifestFile = path.resolve(root, manifestArg(args));
45
+ const loaded = loadFleetManifest(manifestFile);
46
+ if (!loaded.ok) { console.error(`Invalid fleet manifest: ${loaded.errors.join('; ')}`); return 4; }
47
+ const { manifest } = loaded;
48
+
49
+ if (!ensureStateDir(root)) { console.error('No safe state directory for this project.'); return 4; }
50
+ const prevRollupFile = statePath(root, 'fleet-rollup.json');
51
+ let previous = null;
52
+ try { previous = JSON.parse(fs.readFileSync(prevRollupFile, 'utf8')); } catch { /* first run */ }
53
+
54
+ const { runScan: runScanFn } = await import('../../runScan.js');
55
+ // runScanFn resolves to { scan, meta } already — runFleet's worker expects exactly that shape from runScan, so
56
+ // this must NOT wrap `scan` a second time (an earlier draft did, and every repo silently reported 0 findings:
57
+ // `_summarise` reads `scan.findings` defensively rather than throwing on the wrong shape).
58
+ const runScan = (repoPath) => runScanFn(repoPath, { provenance: false });
59
+
60
+ console.error(`[fleet] scanning ${manifest.repositories.length} repository(ies) for ${manifest.organization} (${manifest.workers} worker(s))...`);
61
+ const result = await runFleet({
62
+ repos: manifest.repositories.map((r) => r.id), concurrency: manifest.workers,
63
+ stateFile: statePathFor(root), resume: true, previous,
64
+ runScan: async (repoId) => runScan(manifest.repositories.find((r) => r.id === repoId).path),
65
+ onProgress: (entry) => { console.error(`[fleet] ${entry.ok ? 'done' : 'FAILED'}: ${entry.repo}${entry.ok ? ` (${entry.total} finding(s))` : ` — ${entry.error}`}`); },
66
+ });
67
+ if (resumeOnly && result.skipped.length === manifest.repositories.length) console.error('[fleet] nothing to resume — every repository was already complete.');
68
+
69
+ safeWriteState(prevRollupFile, JSON.stringify(result.rollup, null, 2));
70
+ const graph = buildFleetDependencyGraph(manifest.repositories);
71
+ safeWriteState(graphPath(root), JSON.stringify({ organization: manifest.organization, builtAt: new Date().toISOString(), ...graph }, null, 2));
72
+
73
+ console.log(renderFleetSummary(result.rollup));
74
+ console.log(`\nRepositories requested: ${manifest.repositories.length}`);
75
+ console.log(`Complete: ${result.results.filter((r) => r.ok).length + result.skipped.length}`);
76
+ console.log(`Failed: ${result.results.filter((r) => !r.ok).length}`);
77
+ const anyFailed = result.results.some((r) => !r.ok);
78
+ return anyFailed ? 1 : 0;
79
+ }
80
+
81
+ async function cmdStatus(args, root) {
82
+ const f = statePath(root, 'fleet-rollup.json');
83
+ if (!fs.existsSync(f)) { console.error('No fleet scan has been run yet. Run `agentic-security fleet scan <manifest.yaml>` first.'); return 4; }
84
+ const rollup = JSON.parse(fs.readFileSync(f, 'utf8'));
85
+ if (args.flags.json) console.log(JSON.stringify(rollup, null, 2));
86
+ else console.log(renderFleetSummary(rollup));
87
+ return 0;
88
+ }
89
+
90
+ async function cmdQuery(args, root) {
91
+ const target = args.flags['depends-on'];
92
+ if (!target) { console.error('usage: fleet query --depends-on <repo-id>'); return 4; }
93
+ const f = graphPath(root);
94
+ if (!fs.existsSync(f)) { console.error('No fleet dependency graph yet. Run `agentic-security fleet scan <manifest.yaml>` first.'); return 4; }
95
+ const graph = JSON.parse(fs.readFileSync(f, 'utf8'));
96
+ if (!graph.nodes.some((n) => n.id === target)) { console.error(`${target} is not a repository in the last fleet scan.`); return 4; }
97
+ const radius = blastRadius(graph, target);
98
+ if (args.flags.json) { console.log(JSON.stringify({ target, blastRadius: radius }, null, 2)); return 0; }
99
+ if (!radius.length) { console.log(`No fleet repository depends on ${target} (by declared package name).`); return 0; }
100
+ console.log(`Repositories depending on ${target} (directly or transitively):`);
101
+ for (const r of radius) console.log(` ${r.id} (depth ${r.depth}, via ${r.path.slice(1).join(' -> ')})`);
102
+ return 0;
103
+ }
@@ -0,0 +1,142 @@
1
+ // The cross-repository dependency graph for the fleet feature (PRD 4).
2
+ //
3
+ // Reads each repo's own manifest to learn (a) the package name it publishes under, if any, and (b) the package
4
+ // names it depends on. An edge A -> B means "A declares a dependency that resolves to B's own published name" —
5
+ // i.e. B is upstream of A, and a defect in B can affect A. This is a real, lightweight cross-repo dependency graph
6
+ // built purely from manifests already on disk; it does not need deployment config (Kubernetes/compose/Terraform)
7
+ // the way the existing deployment-boundary graph does, so a fleet of plain application repos works out of the box.
8
+ import fs from 'node:fs';
9
+ import path from 'node:path';
10
+
11
+ const MANIFESTS = Object.freeze([
12
+ { file: 'package.json', ecosystem: 'npm' },
13
+ { file: 'go.mod', ecosystem: 'go' },
14
+ { file: 'Cargo.toml', ecosystem: 'cargo' },
15
+ { file: 'requirements.txt', ecosystem: 'pypi' },
16
+ { file: 'pyproject.toml', ecosystem: 'pypi' },
17
+ ]);
18
+
19
+ function readBounded(p, maxBytes = 1024 * 1024) {
20
+ try {
21
+ const st = fs.statSync(p);
22
+ if (!st.isFile() || st.size > maxBytes) return null;
23
+ return fs.readFileSync(p, 'utf8');
24
+ } catch { return null; }
25
+ }
26
+
27
+ function parsePackageJson(text) {
28
+ let j;
29
+ try { j = JSON.parse(text); } catch { return { name: null, deps: new Set() }; }
30
+ const deps = new Set([...Object.keys(j.dependencies || {}), ...Object.keys(j.devDependencies || {}), ...Object.keys(j.peerDependencies || {})]);
31
+ return { name: typeof j.name === 'string' ? j.name : null, deps };
32
+ }
33
+
34
+ function parseGoMod(text) {
35
+ const m = /^module\s+(\S+)/m.exec(text);
36
+ const deps = new Set();
37
+ const block = /require\s*\(([\s\S]*?)\)/.exec(text);
38
+ const lines = block ? block[1].split('\n') : text.split('\n').filter((l) => /^require\s/.test(l.trim()));
39
+ for (const raw of lines) {
40
+ const l = raw.replace(/^require\s+/, '').trim();
41
+ const dm = /^(\S+)\s+v?\S+/.exec(l);
42
+ if (dm) deps.add(dm[1]);
43
+ }
44
+ return { name: m ? m[1] : null, deps };
45
+ }
46
+
47
+ function parseCargoToml(text) {
48
+ const nm = /\[package\][\s\S]*?\bname\s*=\s*"([^"]+)"/.exec(text);
49
+ const deps = new Set();
50
+ const section = /\[dependencies\]([\s\S]*?)(\n\[|$)/.exec(text);
51
+ if (section) {
52
+ for (const line of section[1].split('\n')) {
53
+ const dm = /^\s*([A-Za-z0-9_-]+)\s*=/.exec(line);
54
+ if (dm) deps.add(dm[1]);
55
+ }
56
+ }
57
+ return { name: nm ? nm[1] : null, deps };
58
+ }
59
+
60
+ function parseRequirementsTxt(text) {
61
+ const deps = new Set();
62
+ for (const raw of text.split('\n')) {
63
+ const l = raw.trim();
64
+ if (!l || l.startsWith('#') || l.startsWith('-')) continue;
65
+ const dm = /^([A-Za-z0-9._-]+)/.exec(l);
66
+ if (dm) deps.add(dm[1].toLowerCase());
67
+ }
68
+ return { name: null, deps };
69
+ }
70
+
71
+ function parsePyprojectToml(text) {
72
+ const nm = /\[project\][\s\S]*?\bname\s*=\s*"([^"]+)"/.exec(text);
73
+ const deps = new Set();
74
+ const section = /dependencies\s*=\s*\[([\s\S]*?)\]/.exec(text);
75
+ if (section) {
76
+ for (const m of section[1].matchAll(/"([A-Za-z0-9._-]+)/g)) deps.add(m[1].toLowerCase());
77
+ }
78
+ return { name: nm ? nm[1] : null, deps };
79
+ }
80
+
81
+ const PARSERS = { 'package.json': parsePackageJson, 'go.mod': parseGoMod, 'Cargo.toml': parseCargoToml, 'requirements.txt': parseRequirementsTxt, 'pyproject.toml': parsePyprojectToml };
82
+
83
+ /** One repo's declared identity and dependency names, merged across every manifest file it has. */
84
+ export function readRepoManifest(repoPath) {
85
+ let name = null;
86
+ const deps = new Set();
87
+ const ecosystems = new Set();
88
+ for (const { file, ecosystem } of MANIFESTS) {
89
+ const text = readBounded(path.join(repoPath, file));
90
+ if (text == null) continue;
91
+ const parsed = PARSERS[file](text);
92
+ if (parsed.name && !name) name = parsed.name;
93
+ for (const d of parsed.deps) deps.add(d);
94
+ ecosystems.add(ecosystem);
95
+ }
96
+ return { name, deps, ecosystems: [...ecosystems] };
97
+ }
98
+
99
+ /**
100
+ * Build the fleet dependency graph. `repos`: [{id, path}]. An edge {from, to} means `from` depends on `to` (so a
101
+ * defect in `to` can affect `from`). Matching is by declared package NAME only — two repos that happen to share a
102
+ * name under different ecosystems are not disambiguated, a disclosed limit, not a guess.
103
+ */
104
+ export function buildFleetDependencyGraph(repos) {
105
+ const manifests = new Map();
106
+ for (const r of repos) manifests.set(r.id, { ...r, manifest: readRepoManifest(r.path) });
107
+
108
+ const byName = new Map();
109
+ for (const [id, r] of manifests) if (r.manifest.name) {
110
+ if (!byName.has(r.manifest.name)) byName.set(r.manifest.name, []);
111
+ byName.get(r.manifest.name).push(id);
112
+ }
113
+
114
+ const edges = [];
115
+ for (const [id, r] of manifests) {
116
+ for (const dep of r.manifest.deps) {
117
+ const targets = byName.get(dep);
118
+ if (!targets) continue;
119
+ for (const to of targets) if (to !== id) edges.push({ from: id, to, via: dep });
120
+ }
121
+ }
122
+ const nodes = [...manifests.values()].map((r) => ({ id: r.id, path: r.path, declaredName: r.manifest.name, ecosystems: r.manifest.ecosystems }));
123
+ return { nodes, edges };
124
+ }
125
+
126
+ /** Transitive downstream closure: which fleet repos depend on `repoId`, directly or transitively, with the shortest path to each. */
127
+ export function blastRadius(graph, repoId) {
128
+ const incoming = new Map();
129
+ for (const e of graph.edges) { if (!incoming.has(e.to)) incoming.set(e.to, []); incoming.get(e.to).push(e.from); }
130
+ const best = new Map();
131
+ const queue = [{ id: repoId, path: [repoId] }];
132
+ while (queue.length) {
133
+ const { id, path: p } = queue.shift();
134
+ for (const from of incoming.get(id) || []) {
135
+ if (best.has(from)) continue;
136
+ const newPath = [...p, from];
137
+ best.set(from, { depth: newPath.length - 1, path: newPath });
138
+ queue.push({ id: from, path: newPath });
139
+ }
140
+ }
141
+ return [...best.entries()].map(([id, v]) => ({ id, ...v })).sort((a, b) => a.depth - b.depth || (a.id < b.id ? -1 : 1));
142
+ }
@@ -0,0 +1,77 @@
1
+ // The fleet manifest (PRD 4 "Fleet Manifest"). `fleet.yaml`:
2
+ //
3
+ // organization: example
4
+ // repositories:
5
+ // - id: frontend
6
+ // path: ../frontend
7
+ // workers: 4
8
+ // perRepoTimeout: 20m
9
+ //
10
+ // Parsing reuses util/yaml.js (the one YAML entry point in this codebase). Validation is closed-world: an unknown
11
+ // top-level key, a duplicate repository id, or a path that does not resolve to a real directory all refuse the
12
+ // whole manifest rather than silently skipping one repo.
13
+ import fs from 'node:fs';
14
+ import path from 'node:path';
15
+ import { load as loadYaml } from '../../util/yaml.js';
16
+
17
+ const FLEET_MANIFEST_SCHEMA_KEYS = Object.freeze(['organization', 'repositories', 'workers', 'perRepoTimeout']);
18
+ const DURATION_RE = /^(\d+)(s|m|h)$/;
19
+
20
+ export function parseDuration(s, fallbackMs) {
21
+ if (typeof s === 'number' && s > 0) return s * 1000;
22
+ const m = DURATION_RE.exec(String(s ?? ''));
23
+ if (!m) return fallbackMs;
24
+ const n = Number(m[1]);
25
+ return m[2] === 's' ? n * 1000 : m[2] === 'm' ? n * 60000 : n * 3600000;
26
+ }
27
+
28
+ /** Validate a parsed manifest object against the manifest's own base directory (for path resolution). */
29
+ export function validateFleetManifest(doc, { baseDir = '.' } = {}) {
30
+ const errors = [];
31
+ if (!doc || typeof doc !== 'object' || Array.isArray(doc)) return { ok: false, errors: ['manifest must be a YAML mapping'] };
32
+ for (const k of Object.keys(doc)) if (!FLEET_MANIFEST_SCHEMA_KEYS.includes(k)) errors.push(`unknown top-level key: ${k}`);
33
+ if (typeof doc.organization !== 'string' || !doc.organization) errors.push('organization is required and must be a string');
34
+ if (!Array.isArray(doc.repositories) || doc.repositories.length === 0) errors.push('repositories must be a non-empty array');
35
+ const seen = new Set();
36
+ const repos = [];
37
+ for (const [i, r] of (Array.isArray(doc.repositories) ? doc.repositories : []).entries()) {
38
+ if (!r || typeof r !== 'object') { errors.push(`repositories[${i}] must be a mapping`); continue; }
39
+ if (typeof r.id !== 'string' || !/^[a-z][a-z0-9-]{0,63}$/.test(r.id)) { errors.push(`repositories[${i}].id must be a lowercase kebab-case string`); continue; }
40
+ if (seen.has(r.id)) { errors.push(`duplicate repository id: ${r.id}`); continue; }
41
+ seen.add(r.id);
42
+ if (typeof r.path !== 'string' || !r.path) { errors.push(`repositories[${i}].path is required`); continue; }
43
+ const abs = path.resolve(baseDir, r.path);
44
+ let isDir = false;
45
+ try { isDir = fs.statSync(abs).isDirectory(); } catch { /* reported below */ }
46
+ if (!isDir) errors.push(`repositories[${i}].path does not resolve to a directory: ${r.path}`);
47
+ repos.push({ id: r.id, path: abs });
48
+ }
49
+ if (doc.workers != null && !(Number.isInteger(doc.workers) && doc.workers >= 1 && doc.workers <= 32)) errors.push('workers must be an integer in 1..32');
50
+ if (doc.perRepoTimeout != null && parseDuration(doc.perRepoTimeout, null) == null) errors.push(`perRepoTimeout is not a valid duration: ${doc.perRepoTimeout}`);
51
+ if (errors.length) return { ok: false, errors };
52
+ return {
53
+ ok: true, errors: [],
54
+ manifest: {
55
+ organization: doc.organization, repositories: repos,
56
+ workers: doc.workers ?? 4, perRepoTimeoutMs: parseDuration(doc.perRepoTimeout, 20 * 60000),
57
+ },
58
+ };
59
+ }
60
+
61
+ export function loadFleetManifest(filePath) {
62
+ let text;
63
+ try { text = fs.readFileSync(filePath, 'utf8'); } catch (e) { return { ok: false, errors: [`cannot read ${filePath}: ${e.message}`] }; }
64
+ let doc;
65
+ try { doc = loadYaml(text); } catch (e) { return { ok: false, errors: [`invalid YAML: ${e.message}`] }; }
66
+ return validateFleetManifest(doc, { baseDir: path.dirname(path.resolve(filePath)) });
67
+ }
68
+
69
+ export function starterManifestYaml(organization = 'example') {
70
+ return `organization: ${organization}
71
+ repositories:
72
+ - id: example-repo
73
+ path: .
74
+ workers: 4
75
+ perRepoTimeout: 20m
76
+ `;
77
+ }