bmad-plus 0.21.0 → 0.22.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 (46) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README.md +13 -13
  3. package/SECURITY.md +62 -0
  4. package/osint-agent-package/skills/bmad-osint-investigate/osint/scripts/_http.py +68 -24
  5. package/package.json +1 -1
  6. package/readme-international/README.de.md +13 -13
  7. package/readme-international/README.es.md +13 -13
  8. package/readme-international/README.fr.md +13 -13
  9. package/src/bmad-plus/packs/pack-dev-studio/categories/implementation/code-review.md +3 -1
  10. package/src/bmad-plus/packs/pack-seo/SKILL.md +3 -1
  11. package/src/bmad-plus/packs/pack-seo/ref/cwv-thresholds.md +2 -2
  12. package/src/bmad-plus/packs/pack-seo/requirements.txt +1 -1
  13. package/src/bmad-plus/packs/pack-seo/scripts/seo_apis.py +72 -30
  14. package/src/bmad-plus/packs/pack-seo/scripts/seo_report.py +5 -6
  15. package/src/bmad-plus/packs/pack-seo/scripts/seo_screenshot.py +176 -14
  16. package/src/bmad-plus/packs/pack-shield/README.md +12 -0
  17. package/src/bmad-plus/packs/pack-shield/SKILL.md +7 -1
  18. package/src/bmad-plus/packs/pack-shield/review-rules/access-control.md +10 -0
  19. package/src/bmad-plus/packs/pack-shield/review-rules/ai-integrations.md +10 -0
  20. package/src/bmad-plus/packs/pack-shield/review-rules/change-and-supply-chain.md +10 -0
  21. package/src/bmad-plus/packs/pack-shield/review-rules/cryptography.md +10 -0
  22. package/src/bmad-plus/packs/pack-shield/review-rules/index.yaml +134 -0
  23. package/src/bmad-plus/packs/pack-shield/review-rules/logging.md +10 -0
  24. package/src/bmad-plus/packs/pack-shield/review-rules/personal-data.md +10 -0
  25. package/src/bmad-plus/packs/pack-shield/shared/ai-processing-register-template.yaml +53 -0
  26. package/src/bmad-plus/packs/pack-shield/shared/ai-processing-register.md +32 -0
  27. package/src/bmad-plus/packs/pack-shield/shared/assurance-case-template.yaml +87 -0
  28. package/src/bmad-plus/packs/pack-shield/shared/assurance-case.md +50 -0
  29. package/src/bmad-plus/packs/pack-shield/shield-orchestrator.md +24 -1
  30. package/src/bmad-plus/skills/bmad-plus-uat/SKILL.md +1 -0
  31. package/src/bmad-plus/skills/bmad-plus-uat/template/page.html +5 -4
  32. package/tools/build/generate-adapters.js +7 -0
  33. package/tools/build/generate.js +14 -0
  34. package/tools/cli/bmad-plus-cli.js +2 -0
  35. package/tools/cli/commands/ai-register.js +63 -0
  36. package/tools/cli/commands/assurance.js +162 -0
  37. package/tools/cli/commands/review.js +10 -3
  38. package/tools/cli/lib/ai-register.js +393 -0
  39. package/tools/cli/lib/assurance.js +822 -0
  40. package/tools/cli/lib/control-refs.js +132 -0
  41. package/tools/cli/lib/installation-health.js +17 -0
  42. package/tools/cli/lib/packs.js +60 -2
  43. package/tools/cli/lib/page-origins.js +582 -0
  44. package/tools/cli/lib/review-rules.js +92 -24
  45. package/tools/cli/lib/review.js +28 -1
  46. package/tools/cli/lib/uat.js +22 -5
@@ -0,0 +1,822 @@
1
+ /**
2
+ * Security assurance cases bound to executed checks. A case states claims, argues each one
3
+ * and supports it with evidence. The only admissible evidence is a check this module ran and
4
+ * recorded in a hash-chained ledger: the command, its exit code, a digest of its output and
5
+ * the digests of the artifacts it left. `verifyCase` refuses a claim whose evidence is
6
+ * missing, failed or stale, and a case never admits evidence that is only asserted.
7
+ *
8
+ * The chain shows an accidental or careless edit. Against someone who can write the ledger
9
+ * it needs two things from outside the file: a key (BMAD_PLUS_ASSURANCE_KEY) that
10
+ * authenticates every record, and the head digest `run` reports, which verify checks is
11
+ * still in the chain so that removed runs are noticed.
12
+ */
13
+ 'use strict';
14
+
15
+ const fs = require('node:fs');
16
+ const path = require('node:path');
17
+ const crypto = require('node:crypto');
18
+ const { spawn, spawnSync } = require('node:child_process');
19
+ const yaml = require('js-yaml');
20
+ const { parseControls } = require('./control-refs');
21
+ const { redact } = require('./redact');
22
+
23
+ const CASE_SCHEMA = 'bmad-plus/assurance-case/1';
24
+ const RUN_SCHEMA = 'bmad-plus/assurance-run/1';
25
+ const VERDICT_SCHEMA = 'bmad-plus/assurance-verdict/1';
26
+ const CHECK_SCHEMA = 'bmad-plus/assurance-check/1';
27
+ const DEFAULT_DIR = '_bmad-output/assurance';
28
+ const LEDGER = 'runs.jsonl';
29
+ const KEY_VARIABLE = 'BMAD_PLUS_ASSURANCE_KEY';
30
+ const MIN_KEY_LENGTH = 32;
31
+ const TEMPLATE = path.join(
32
+ __dirname,
33
+ '..',
34
+ '..',
35
+ '..',
36
+ 'src',
37
+ 'bmad-plus',
38
+ 'packs',
39
+ 'pack-shield',
40
+ 'shared',
41
+ 'assurance-case-template.yaml'
42
+ );
43
+
44
+ const ID = /^[a-z0-9][a-z0-9.-]{0,63}$/;
45
+ const CLAIM_ID = /^[A-Za-z0-9][A-Za-z0-9.-]{0,31}$/;
46
+ const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]{0,63}$/;
47
+ const DIGEST = /^[0-9a-f]{64}$/;
48
+ const CASE_KEYS = ['schema', 'id', 'title', 'scope', 'freshness', 'checks', 'claims'];
49
+ const FRESHNESS_KEYS = ['maxAgeDays', 'sameCommit'];
50
+ const CHECK_KEYS = ['id', 'run', 'cwd', 'env', 'timeoutSeconds', 'expect', 'artifacts'];
51
+ const CLAIM_KEYS = ['id', 'claim', 'parent', 'argument', 'controls', 'evidence'];
52
+ const EVIDENCE_KEYS = ['check', 'shows'];
53
+
54
+ const MAX_CASE_BYTES = 256 * 1024;
55
+ const MAX_LEDGER_BYTES = 16 * 1024 * 1024;
56
+ const MAX_TEXT = 4000;
57
+ const EXCERPT_BYTES = 2048;
58
+ const DEFAULT_TIMEOUT_SECONDS = 600;
59
+ const MAX_TIMEOUT_SECONDS = 6 * 3600;
60
+ const DEFAULT_MAX_AGE_DAYS = 30;
61
+ const DAY_MS = 24 * 3600 * 1000;
62
+ const CLOCK_SKEW_MS = 5 * 60 * 1000;
63
+ const EXIT = { supported: 0, unsupported: 1 };
64
+ const BOM = String.fromCharCode(0xfeff);
65
+
66
+ /**
67
+ * What a check inherits from the caller: enough to find programs and a temporary folder.
68
+ * Anything else, credentials included, reaches it only when the case names the variable.
69
+ */
70
+ const ENVIRONMENT_KEYS = [
71
+ 'PATH',
72
+ 'HOME',
73
+ 'TEMP',
74
+ 'TMP',
75
+ 'TMPDIR',
76
+ 'SYSTEMROOT',
77
+ 'WINDIR',
78
+ 'SYSTEMDRIVE',
79
+ 'USERPROFILE',
80
+ 'APPDATA',
81
+ 'LOCALAPPDATA',
82
+ 'PATHEXT',
83
+ 'COMSPEC',
84
+ ];
85
+
86
+ const sha256 = (value) => crypto.createHash('sha256').update(value).digest('hex');
87
+ const hmac = (key, value) => crypto.createHmac('sha256', key).update(value).digest('hex');
88
+
89
+ function fail(message) {
90
+ throw new Error(message);
91
+ }
92
+
93
+ function only(value, keys, where) {
94
+ if (!value || typeof value !== 'object' || Array.isArray(value))
95
+ fail(`${where}: must be a mapping`);
96
+ const unknown = Object.keys(value).filter((key) => !keys.includes(key));
97
+ if (unknown.length) fail(`${where}: unknown key(s) ${unknown.join(', ')}`);
98
+ return value;
99
+ }
100
+
101
+ function text(value, where) {
102
+ if (typeof value !== 'string' || !value.trim() || value.length > MAX_TEXT || value.includes('\0'))
103
+ fail(`${where}: expected text of 1 to ${MAX_TEXT} characters`);
104
+ return value.trim();
105
+ }
106
+
107
+ /** A project-relative path written with `/` that cannot leave the project. */
108
+ function relativePath(value, where) {
109
+ if (
110
+ typeof value !== 'string' ||
111
+ !value ||
112
+ value.includes('\\') ||
113
+ value.includes('\0') ||
114
+ path.posix.isAbsolute(value) ||
115
+ /^[A-Za-z]:/.test(value) ||
116
+ value.split('/').some((part) => part === '..' || part === '')
117
+ )
118
+ fail(`${where}: "${value}" must be a relative path inside the project, written with /`);
119
+ return value;
120
+ }
121
+
122
+ /** The absolute path of a project-relative one; no component may be a symbolic link. */
123
+ function resolveInside(projectDir, relative, where) {
124
+ const root = fs.realpathSync(projectDir);
125
+ const target = path.resolve(root, relative);
126
+ let current = root;
127
+ for (const part of path.relative(root, target).split(path.sep).filter(Boolean)) {
128
+ current = path.join(current, part);
129
+ let stat = null;
130
+ try {
131
+ stat = fs.lstatSync(current);
132
+ } catch (error) {
133
+ if (error.code !== 'ENOENT') throw error;
134
+ }
135
+ if (stat && stat.isSymbolicLink()) fail(`${where}: ${relative} passes through a symbolic link`);
136
+ }
137
+ return target;
138
+ }
139
+
140
+ function readBounded(file, maxBytes, where) {
141
+ const stat = fs.lstatSync(file);
142
+ if (!stat.isFile()) fail(`${where}: ${file} is not a regular file`);
143
+ if (stat.size > maxBytes) fail(`${where}: ${file} exceeds ${maxBytes} bytes`);
144
+ const content = fs.readFileSync(file, 'utf8');
145
+ return content.startsWith(BOM) ? content.slice(1) : content;
146
+ }
147
+
148
+ /** SHA-256 of a file read in chunks, or null when it is absent or not a regular file. */
149
+ function fileDigest(file) {
150
+ let fd;
151
+ try {
152
+ if (!fs.lstatSync(file).isFile()) return null;
153
+ fd = fs.openSync(file, 'r');
154
+ } catch (error) {
155
+ if (error.code === 'ENOENT') return null;
156
+ throw error;
157
+ }
158
+ try {
159
+ const hash = crypto.createHash('sha256');
160
+ const buffer = Buffer.alloc(1024 * 1024);
161
+ let read;
162
+ while ((read = fs.readSync(fd, buffer, 0, buffer.length, null)) > 0)
163
+ hash.update(buffer.subarray(0, read));
164
+ return hash.digest('hex');
165
+ } finally {
166
+ fs.closeSync(fd);
167
+ }
168
+ }
169
+
170
+ function parseCheck(raw, index, where) {
171
+ const at = `${where}: check ${raw?.id ?? index + 1}`;
172
+ only(raw, CHECK_KEYS, at);
173
+ if (typeof raw.id !== 'string' || !ID.test(raw.id)) fail(`${at}: invalid id`);
174
+ if (
175
+ !Array.isArray(raw.run) ||
176
+ !raw.run.length ||
177
+ !raw.run[0] ||
178
+ !raw.run.every(
179
+ (arg) => typeof arg === 'string' && arg.length <= MAX_TEXT && !arg.includes('\0')
180
+ )
181
+ )
182
+ fail(`${at}: run must be the command as a list of arguments; no shell is involved`);
183
+ const cwd = relativePath(raw.cwd ?? '.', `${at}: cwd`);
184
+ const env = raw.env ?? [];
185
+ if (!Array.isArray(env) || !env.every((name) => typeof name === 'string' && ENV_NAME.test(name)))
186
+ fail(`${at}: env must list environment variable names`);
187
+ // A check that could read the key could write records the key authenticates.
188
+ if (env.some((name) => name.toUpperCase() === KEY_VARIABLE))
189
+ fail(`${at}: ${KEY_VARIABLE} is never passed to a check`);
190
+ const timeoutSeconds = raw.timeoutSeconds ?? DEFAULT_TIMEOUT_SECONDS;
191
+ if (
192
+ !Number.isInteger(timeoutSeconds) ||
193
+ timeoutSeconds < 1 ||
194
+ timeoutSeconds > MAX_TIMEOUT_SECONDS
195
+ )
196
+ fail(`${at}: timeoutSeconds must be a whole number from 1 to ${MAX_TIMEOUT_SECONDS}`);
197
+ const expected = only(raw.expect ?? {}, ['exitCode'], `${at}: expect`);
198
+ const exitCode = expected.exitCode ?? 0;
199
+ if (!Number.isInteger(exitCode) || exitCode < 0 || exitCode > 255)
200
+ fail(`${at}: expect.exitCode must be a whole number from 0 to 255`);
201
+ const artifacts = raw.artifacts ?? [];
202
+ if (!Array.isArray(artifacts)) fail(`${at}: artifacts must be a list of paths`);
203
+ for (const file of artifacts) relativePath(file, `${at}: artifact`);
204
+ if (new Set(artifacts).size !== artifacts.length) fail(`${at}: an artifact is listed twice`);
205
+ // What was executed: a different command, folder or inherited variable is another check.
206
+ const command = { run: [...raw.run], cwd, env: [...new Set(env)].sort() };
207
+ return {
208
+ id: raw.id,
209
+ ...command,
210
+ timeoutSeconds,
211
+ exitCode,
212
+ artifacts: [...artifacts],
213
+ commandSha256: sha256(JSON.stringify(command)),
214
+ };
215
+ }
216
+
217
+ function parseClaim(raw, index, where, checks, claims) {
218
+ const at = `${where}: claim ${raw?.id ?? index + 1}`;
219
+ only(raw, CLAIM_KEYS, at);
220
+ if (typeof raw.id !== 'string' || !CLAIM_ID.test(raw.id)) fail(`${at}: invalid id`);
221
+ if (claims.has(raw.id)) fail(`${at}: duplicate id`);
222
+ if (raw.parent !== undefined && !claims.has(raw.parent))
223
+ fail(`${at}: parent ${raw.parent} must be a claim declared before it`);
224
+ const evidence = raw.evidence ?? [];
225
+ if (!Array.isArray(evidence)) fail(`${at}: evidence must be a list`);
226
+ return {
227
+ id: raw.id,
228
+ parent: raw.parent ?? null,
229
+ claim: text(raw.claim, `${at}: claim`),
230
+ argument: text(raw.argument, `${at}: argument`),
231
+ controls: raw.controls === undefined ? [] : parseControls(raw.controls, at),
232
+ evidence: evidence.map((item, i) => {
233
+ const where2 = `${at}: evidence ${i + 1}`;
234
+ if (!item || typeof item !== 'object' || typeof item.check !== 'string')
235
+ fail(
236
+ `${where2}: evidence must name a check that runs (check: <id>); a statement, document or link is only asserted`
237
+ );
238
+ only(item, EVIDENCE_KEYS, where2);
239
+ if (!checks.has(item.check)) fail(`${where2}: check ${item.check} is not declared`);
240
+ return { check: item.check, shows: text(item.shows, `${where2}: shows`) };
241
+ }),
242
+ };
243
+ }
244
+
245
+ /** Validate a parsed case document. Throws on the first defect, with where it is. */
246
+ function parseCase(doc, where = 'assurance case') {
247
+ only(doc, CASE_KEYS, where);
248
+ if (doc.schema !== CASE_SCHEMA) fail(`${where}: schema must be "${CASE_SCHEMA}"`);
249
+ if (typeof doc.id !== 'string' || !ID.test(doc.id))
250
+ fail(`${where}: id must use lowercase letters, digits, dots and dashes`);
251
+ const freshness = only(doc.freshness ?? {}, FRESHNESS_KEYS, `${where}: freshness`);
252
+ const maxAgeDays = freshness.maxAgeDays ?? DEFAULT_MAX_AGE_DAYS;
253
+ if (!Number.isInteger(maxAgeDays) || maxAgeDays < 1 || maxAgeDays > 366)
254
+ fail(`${where}: freshness.maxAgeDays must be a whole number of days from 1 to 366`);
255
+ const sameCommit = freshness.sameCommit ?? true;
256
+ if (typeof sameCommit !== 'boolean') fail(`${where}: freshness.sameCommit must be true or false`);
257
+ if (!Array.isArray(doc.checks) || !doc.checks.length)
258
+ fail(`${where}: checks must list the commands that produce the evidence`);
259
+ const checks = new Map();
260
+ doc.checks.forEach((raw, index) => {
261
+ const check = parseCheck(raw, index, where);
262
+ if (checks.has(check.id)) fail(`${where}: check ${check.id}: duplicate id`);
263
+ checks.set(check.id, check);
264
+ });
265
+ if (!Array.isArray(doc.claims) || !doc.claims.length)
266
+ fail(`${where}: claims must list at least one claim`);
267
+ const claims = new Map();
268
+ doc.claims.forEach((raw, index) => {
269
+ const claim = parseClaim(raw, index, where, checks, claims);
270
+ claims.set(claim.id, claim);
271
+ });
272
+ const parents = new Set([...claims.values()].map((claim) => claim.parent).filter(Boolean));
273
+ for (const claim of claims.values())
274
+ if (!claim.evidence.length && !parents.has(claim.id))
275
+ fail(
276
+ `${where}: claim ${claim.id} is only asserted; support it with evidence from a check or with sub-claims`
277
+ );
278
+ return {
279
+ id: doc.id,
280
+ title: text(doc.title, `${where}: title`),
281
+ scope: text(doc.scope, `${where}: scope`),
282
+ freshness: { maxAgeDays, sameCommit },
283
+ checks,
284
+ claims,
285
+ };
286
+ }
287
+
288
+ /** Read and validate a case file inside the project. */
289
+ function loadCase(projectDir, file) {
290
+ const relative = relativePath(String(file).split(path.sep).join('/'), 'case file');
291
+ const absolute = resolveInside(projectDir, relative, 'case file');
292
+ if (!fs.existsSync(absolute)) fail(`no assurance case at ${relative}`);
293
+ const source = readBounded(absolute, MAX_CASE_BYTES, relative);
294
+ let doc;
295
+ try {
296
+ doc = yaml.load(source);
297
+ } catch (error) {
298
+ fail(`${relative}: ${error.message}`);
299
+ }
300
+ return { ...parseCase(doc, relative), file: relative, sha256: sha256(source) };
301
+ }
302
+
303
+ /**
304
+ * Start a case from the Shield template at a new `<id>.yaml` path, the id taken from the
305
+ * file name. Never overwrites; the result is checked to load before it is written.
306
+ */
307
+ function initCase(projectDir, file) {
308
+ const relative = relativePath(String(file).split(path.sep).join('/'), 'case file');
309
+ const id = path.posix.basename(relative).replace(/\.ya?ml$/i, '');
310
+ if (id === path.posix.basename(relative) || !ID.test(id))
311
+ fail(`case file: name it <id>.yaml with an id of lowercase letters, digits, dots and dashes`);
312
+ const absolute = resolveInside(projectDir, relative, 'case file');
313
+ const source = readBounded(TEMPLATE, MAX_CASE_BYTES, 'template').replace(
314
+ /^id: .*$/m,
315
+ `id: ${id}`
316
+ );
317
+ parseCase(yaml.load(source), 'template');
318
+ fs.mkdirSync(path.dirname(absolute), { recursive: true });
319
+ try {
320
+ fs.writeFileSync(absolute, source, { flag: 'wx' });
321
+ } catch (error) {
322
+ if (error.code === 'EEXIST') fail(`${relative} already exists`);
323
+ throw error;
324
+ }
325
+ return { file: relative, id };
326
+ }
327
+
328
+ function ledgerFile(projectDir, dir, caseId) {
329
+ relativePath(dir, '--dir');
330
+ return resolveInside(projectDir, `${dir}/${caseId}/${LEDGER}`, 'ledger');
331
+ }
332
+
333
+ /** The ledger key from the option, else the environment; null when neither sets one. */
334
+ function ledgerKey(key = process.env[KEY_VARIABLE]) {
335
+ if (key === null || key === undefined || key === '') return null;
336
+ if (typeof key !== 'string' || key.length < MIN_KEY_LENGTH)
337
+ fail(`${KEY_VARIABLE} must hold at least ${MIN_KEY_LENGTH} characters`);
338
+ return key;
339
+ }
340
+
341
+ /**
342
+ * The recorded runs, in order. Each record names the digest of the one before it and carries
343
+ * its own digest, so an edited or reordered record, or one removed before the last, breaks
344
+ * the chain. Removing the last records leaves a valid chain: only a head kept outside the
345
+ * file (verify --ledger-head) shows it. The digests are unkeyed, so whoever can write the
346
+ * file can also write a valid record; with a key, every record must carry its HMAC. Reading
347
+ * stops at the first break; everything after it is unusable.
348
+ */
349
+ function readLedger(file, { key = null } = {}) {
350
+ if (!fs.existsSync(file)) return { records: [], errors: [] };
351
+ const lines = readBounded(file, MAX_LEDGER_BYTES, 'ledger').split('\n');
352
+ const records = [];
353
+ let previous = null;
354
+ for (const [index, line] of lines.entries()) {
355
+ if (!line.trim()) continue;
356
+ const at = `record ${records.length + 1} (line ${index + 1})`;
357
+ let record;
358
+ try {
359
+ record = JSON.parse(line);
360
+ } catch {
361
+ return { records, errors: [`${at} is not JSON`] };
362
+ }
363
+ const { sha256: recorded, hmac: mac, ...body } = record || {};
364
+ if (body.schema !== RUN_SCHEMA) return { records, errors: [`${at} is not ${RUN_SCHEMA}`] };
365
+ if (body.sequence !== records.length + 1)
366
+ return { records, errors: [`${at} is out of sequence`] };
367
+ if (body.previous !== previous)
368
+ return { records, errors: [`${at} does not follow the record before it`] };
369
+ if (sha256(JSON.stringify(body)) !== recorded)
370
+ return { records, errors: [`${at} was altered after it was written`] };
371
+ if (
372
+ key &&
373
+ !(
374
+ typeof mac === 'string' &&
375
+ DIGEST.test(mac) &&
376
+ crypto.timingSafeEqual(
377
+ Buffer.from(mac, 'hex'),
378
+ Buffer.from(hmac(key, JSON.stringify(body)), 'hex')
379
+ )
380
+ )
381
+ )
382
+ return { records, errors: [`${at} is not authenticated by ${KEY_VARIABLE}`] };
383
+ records.push(record);
384
+ previous = recorded;
385
+ }
386
+ return { records, errors: [] };
387
+ }
388
+
389
+ function git(projectDir, args) {
390
+ const result = spawnSync('git', args, {
391
+ cwd: projectDir,
392
+ encoding: 'utf8',
393
+ windowsHide: true,
394
+ shell: false,
395
+ timeout: 10000,
396
+ maxBuffer: 16 * 1024 * 1024,
397
+ });
398
+ return result.status === 0 && !result.error ? result.stdout : null;
399
+ }
400
+
401
+ /** What a case writes, relative to the project: its ledger folder and declared artifacts. */
402
+ function outputsOf(kase, dir) {
403
+ return [
404
+ `${path.posix.join(dir, kase.id)}/`,
405
+ ...[...kase.checks.values()].flatMap((check) => check.artifacts),
406
+ ];
407
+ }
408
+
409
+ /**
410
+ * The commit checked out and whether the working tree differs from it: a tracked file
411
+ * changed, or an untracked file git does not ignore, since that can decide a check as
412
+ * surely as a committed one. The case's own outputs do not count. Nulls outside git.
413
+ */
414
+ function revisionOf(projectDir, outputs = []) {
415
+ const head = git(projectDir, ['rev-parse', '--verify', 'HEAD']);
416
+ if (!head || !/^[0-9a-f]{40,64}$/.test(head.trim())) return { revision: null, dirty: null };
417
+ const tracked = git(projectDir, ['status', '--porcelain', '--untracked-files=no']);
418
+ const untracked = git(projectDir, ['ls-files', '--others', '--exclude-standard', '-z']);
419
+ if (tracked === null || untracked === null) return { revision: head.trim(), dirty: null };
420
+ const isOutput = (file) =>
421
+ outputs.some((output) => (output.endsWith('/') ? file.startsWith(output) : file === output));
422
+ return {
423
+ revision: head.trim(),
424
+ dirty: tracked.trim() !== '' || untracked.split('\0').some((file) => file && !isOutput(file)),
425
+ };
426
+ }
427
+
428
+ function checkEnvironment(names) {
429
+ const inherited = Object.keys(process.env);
430
+ const values = {};
431
+ for (const key of [...ENVIRONMENT_KEYS, ...names]) {
432
+ // Windows variable names are case-insensitive; elsewhere only the exact name counts.
433
+ const source =
434
+ inherited.find((name) => name === key) ??
435
+ (process.platform === 'win32'
436
+ ? inherited.find((name) => name.toUpperCase() === key.toUpperCase())
437
+ : undefined);
438
+ if (source !== undefined) values[key] = process.env[source];
439
+ }
440
+ return values;
441
+ }
442
+
443
+ /**
444
+ * Run one check without a shell. The whole output is hashed as it arrives; only its last
445
+ * bytes are kept, passed through the redaction floor, as a reading aid.
446
+ */
447
+ function execute(projectDir, check, now) {
448
+ const cwd = resolveInside(projectDir, check.cwd, `check ${check.id}: cwd`);
449
+ if (!fs.existsSync(cwd) || !fs.statSync(cwd).isDirectory())
450
+ fail(`check ${check.id}: cwd ${check.cwd} is not a folder`);
451
+ const startedAt = now();
452
+ return new Promise((resolve) => {
453
+ const hash = crypto.createHash('sha256');
454
+ let bytes = 0;
455
+ let tail = Buffer.alloc(0);
456
+ let timedOut = false;
457
+ let settled = false;
458
+ let error = null;
459
+ let timer = null;
460
+ const finish = (exitCode, signal) => {
461
+ if (settled) return;
462
+ settled = true;
463
+ clearTimeout(timer);
464
+ const endedAt = now();
465
+ resolve({
466
+ startedAt: startedAt.toISOString(),
467
+ endedAt: endedAt.toISOString(),
468
+ durationMs: endedAt - startedAt,
469
+ exitCode: timedOut || error ? null : exitCode,
470
+ signal: signal || null,
471
+ timedOut,
472
+ error,
473
+ output: {
474
+ bytes,
475
+ sha256: hash.digest('hex'),
476
+ excerpt: redact(tail.toString('utf8'), { maxLength: EXCERPT_BYTES }).text,
477
+ truncated: bytes > tail.length,
478
+ },
479
+ });
480
+ };
481
+ const collect = (chunk) => {
482
+ hash.update(chunk);
483
+ bytes += chunk.length;
484
+ tail = Buffer.concat([tail, chunk]);
485
+ if (tail.length > EXCERPT_BYTES) tail = tail.subarray(tail.length - EXCERPT_BYTES);
486
+ };
487
+ let child;
488
+ try {
489
+ child = spawn(check.run[0], check.run.slice(1), {
490
+ cwd,
491
+ env: checkEnvironment(check.env),
492
+ shell: false,
493
+ windowsHide: true,
494
+ stdio: ['ignore', 'pipe', 'pipe'],
495
+ });
496
+ } catch (spawnError) {
497
+ error = spawnError.message;
498
+ finish(null, null);
499
+ return;
500
+ }
501
+ child.stdout.on('data', collect);
502
+ child.stderr.on('data', collect);
503
+ child.on('error', (spawnError) => {
504
+ error = spawnError.message;
505
+ finish(null, null);
506
+ });
507
+ child.on('close', finish);
508
+ timer = setTimeout(() => {
509
+ timedOut = true;
510
+ // Only the child started here is signalled; a descendant holding the pipes open must
511
+ // not keep the run waiting, so the streams are closed and the run settles now.
512
+ child.kill('SIGKILL');
513
+ child.stdout.destroy();
514
+ child.stderr.destroy();
515
+ finish(null, 'SIGKILL');
516
+ }, check.timeoutSeconds * 1000);
517
+ });
518
+ }
519
+
520
+ /** Each declared artifact's digest and modification time, or null when it is absent. */
521
+ function artifactStates(projectDir, check) {
522
+ return Object.fromEntries(
523
+ check.artifacts.map((file) => {
524
+ const absolute = resolveInside(projectDir, file, `check ${check.id}: artifact`);
525
+ const digest = fileDigest(absolute);
526
+ return [file, digest && { digest, mtime: fs.lstatSync(absolute, { bigint: true }).mtimeNs }];
527
+ })
528
+ );
529
+ }
530
+
531
+ /**
532
+ * The digest of each artifact the run wrote. A file left as it was before the run, same
533
+ * bytes and same modification time, was not produced by it and is recorded as null.
534
+ */
535
+ function producedArtifacts(projectDir, check, before) {
536
+ const after = artifactStates(projectDir, check);
537
+ return Object.fromEntries(
538
+ check.artifacts.map((file) => {
539
+ const was = before[file];
540
+ const is = after[file];
541
+ const untouched = was && is && was.digest === is.digest && was.mtime === is.mtime;
542
+ return [file, is && !untouched ? is.digest : null];
543
+ })
544
+ );
545
+ }
546
+
547
+ /** Whether a recorded run did what its check expects, before any question of freshness. */
548
+ function runFailures(check, run) {
549
+ const failures = [];
550
+ if (run.error) failures.push(`it did not start: ${run.error}`);
551
+ else if (run.timedOut) failures.push(`it timed out after ${check.timeoutSeconds} s`);
552
+ else if (run.exitCode !== check.exitCode)
553
+ failures.push(`exit code ${run.exitCode ?? 'none'}, expected ${check.exitCode}`);
554
+ for (const file of check.artifacts)
555
+ if (run.artifacts?.[file] === null)
556
+ failures.push(`artifact ${file} was not produced by the run`);
557
+ return failures;
558
+ }
559
+
560
+ /**
561
+ * Execute the case's checks (or the named ones) and append one record per check to the
562
+ * ledger, authenticated when a key is set. A lock file keeps two runs from interleaving
563
+ * their records. Returns the new head, to be kept outside the ledger.
564
+ */
565
+ async function runChecks(projectDir, kase, { dir = DEFAULT_DIR, only: ids = [], now, key } = {}) {
566
+ const clock = now || (() => new Date());
567
+ const secret = ledgerKey(key);
568
+ const outputs = outputsOf(kase, dir);
569
+ const selected = ids.length
570
+ ? ids.map((id) => kase.checks.get(id) || fail(`check ${id} is not declared in ${kase.id}`))
571
+ : [...kase.checks.values()];
572
+ const file = ledgerFile(projectDir, dir, kase.id);
573
+ fs.mkdirSync(path.dirname(file), { recursive: true });
574
+ const lock = `${file}.lock`;
575
+ let handle;
576
+ try {
577
+ handle = fs.openSync(lock, 'wx');
578
+ } catch (error) {
579
+ if (error.code === 'EEXIST')
580
+ fail(`another run holds ${lock}; delete it only if no run is in progress`);
581
+ throw error;
582
+ }
583
+ try {
584
+ const ledger = readLedger(file, { key: secret });
585
+ if (ledger.errors.length)
586
+ fail(
587
+ `the ledger ${file} is not intact (${ledger.errors[0]}); keep it for inspection and record into another --dir`
588
+ );
589
+ let previous = ledger.records.length ? ledger.records.at(-1).sha256 : null;
590
+ let sequence = ledger.records.length;
591
+ const results = [];
592
+ for (const check of selected) {
593
+ const { revision, dirty } = revisionOf(projectDir, outputs);
594
+ const before = artifactStates(projectDir, check);
595
+ const outcome = await execute(projectDir, check, clock);
596
+ const body = {
597
+ schema: RUN_SCHEMA,
598
+ case: kase.id,
599
+ check: check.id,
600
+ sequence: ++sequence,
601
+ commandSha256: check.commandSha256,
602
+ run: check.run,
603
+ cwd: check.cwd,
604
+ env: check.env,
605
+ revision,
606
+ dirty,
607
+ ...outcome,
608
+ artifacts: producedArtifacts(projectDir, check, before),
609
+ previous,
610
+ };
611
+ const serialized = JSON.stringify(body);
612
+ const record = {
613
+ ...body,
614
+ ...(secret ? { hmac: hmac(secret, serialized) } : {}),
615
+ sha256: sha256(serialized),
616
+ };
617
+ const fd = fs.openSync(file, 'a');
618
+ try {
619
+ fs.writeSync(fd, `${JSON.stringify(record)}\n`);
620
+ fs.fsyncSync(fd);
621
+ } finally {
622
+ fs.closeSync(fd);
623
+ }
624
+ previous = record.sha256;
625
+ results.push({ check: check.id, failures: runFailures(check, record), record });
626
+ }
627
+ return { file, results, head: previous, authenticated: Boolean(secret) };
628
+ } finally {
629
+ fs.closeSync(handle);
630
+ fs.rmSync(lock, { force: true });
631
+ }
632
+ }
633
+
634
+ /** passed, failed, stale or missing — with every reason, for one check's latest run. */
635
+ function judgeCheck(projectDir, kase, check, run, state, now) {
636
+ if (!run) return { status: 'missing', reasons: ['it never ran'], run: null };
637
+ const stale = [];
638
+ if (run.commandSha256 !== check.commandSha256) stale.push('its command changed since it ran');
639
+ if (kase.freshness.sameCommit) {
640
+ if (run.dirty !== false)
641
+ stale.push('it ran on uncommitted changes, untracked files or outside git');
642
+ else if (run.revision !== state.revision)
643
+ stale.push(
644
+ `it ran at ${String(run.revision).slice(0, 12)}, not at ${String(state.revision).slice(0, 12)}`
645
+ );
646
+ }
647
+ const ended = Date.parse(run.endedAt);
648
+ if (!Number.isFinite(ended) || ended > now.getTime() + CLOCK_SKEW_MS)
649
+ stale.push('its end time is missing or in the future');
650
+ else if (now.getTime() - ended > kase.freshness.maxAgeDays * DAY_MS)
651
+ stale.push(
652
+ `it ran ${Math.floor((now.getTime() - ended) / DAY_MS)} days ago; the case accepts ${kase.freshness.maxAgeDays}`
653
+ );
654
+ for (const file of check.artifacts) {
655
+ const recorded = run.artifacts?.[file];
656
+ if (recorded === undefined) stale.push(`artifact ${file} was not recorded by that run`);
657
+ else if (
658
+ recorded !== null &&
659
+ fileDigest(resolveInside(projectDir, file, `check ${check.id}: artifact`)) !== recorded
660
+ )
661
+ stale.push(`artifact ${file} changed since the run`);
662
+ }
663
+ const failures = runFailures(check, run);
664
+ return {
665
+ status: failures.length ? 'failed' : stale.length ? 'stale' : 'passed',
666
+ reasons: [...failures, ...stale],
667
+ run: {
668
+ sequence: run.sequence,
669
+ sha256: run.sha256,
670
+ endedAt: run.endedAt,
671
+ exitCode: run.exitCode,
672
+ revision: run.revision,
673
+ },
674
+ };
675
+ }
676
+
677
+ /**
678
+ * The verdict on a case: every check judged on its latest run, every claim supported only
679
+ * when all its evidence passed and all its sub-claims are supported. `head` is a record
680
+ * digest kept from an earlier `run`: the ledger must still contain it.
681
+ */
682
+ function verifyCase(projectDir, kase, { dir = DEFAULT_DIR, now, key, head: anchor = null } = {}) {
683
+ const at = (now || (() => new Date()))();
684
+ if (anchor !== null && !(typeof anchor === 'string' && DIGEST.test(anchor)))
685
+ fail('the ledger head must be a record digest of 64 lowercase hexadecimal characters');
686
+ const secret = ledgerKey(key);
687
+ const file = ledgerFile(projectDir, dir, kase.id);
688
+ const ledger = readLedger(file, { key: secret });
689
+ const state = revisionOf(projectDir, outputsOf(kase, dir));
690
+ const reasons = [];
691
+ if (ledger.errors.length) reasons.push(`the ledger is not intact: ${ledger.errors[0]}`);
692
+ else if (anchor && !ledger.records.some((record) => record.sha256 === anchor))
693
+ reasons.push(
694
+ `the ledger no longer holds the head ${anchor.slice(0, 12)} it was anchored to: runs were removed or the ledger replaced`
695
+ );
696
+ if (kase.freshness.sameCommit) {
697
+ if (!state.revision)
698
+ reasons.push(
699
+ 'there is no commit to bind the evidence to (freshness.sameCommit: false for a project outside git)'
700
+ );
701
+ else if (state.dirty)
702
+ reasons.push(
703
+ 'the working tree has uncommitted changes or untracked files: the evidence describes the commit, not this working tree'
704
+ );
705
+ }
706
+ const latest = new Map();
707
+ if (!ledger.errors.length)
708
+ for (const record of ledger.records)
709
+ if (record.case === kase.id) latest.set(record.check, record);
710
+ const checks = {};
711
+ for (const check of kase.checks.values())
712
+ checks[check.id] = judgeCheck(projectDir, kase, check, latest.get(check.id), state, at);
713
+
714
+ const judged = new Map();
715
+ const declared = [...kase.claims.values()];
716
+ // Sub-claims are declared after their parent, so the reverse order judges children first.
717
+ for (const claim of [...declared].reverse()) {
718
+ const why = [
719
+ ...claim.evidence
720
+ .filter(({ check }) => checks[check].status !== 'passed')
721
+ .map(({ check }) => `evidence ${check} is ${checks[check].status}`),
722
+ ...declared
723
+ .filter((child) => child.parent === claim.id && judged.get(child.id).status !== 'supported')
724
+ .map((child) => `sub-claim ${child.id} is unsupported`),
725
+ ];
726
+ judged.set(claim.id, {
727
+ id: claim.id,
728
+ parent: claim.parent,
729
+ claim: claim.claim,
730
+ status: why.length ? 'unsupported' : 'supported',
731
+ controls: claim.controls,
732
+ evidence: claim.evidence.map((item) => ({ ...item, status: checks[item.check].status })),
733
+ reasons: why,
734
+ });
735
+ }
736
+ const claims = declared.map((claim) => judged.get(claim.id));
737
+ const status =
738
+ !reasons.length && claims.every((claim) => claim.status === 'supported')
739
+ ? 'supported'
740
+ : 'unsupported';
741
+ const controlsOf = (wanted) => [
742
+ ...new Set(claims.filter((c) => c.status === wanted).flatMap((c) => c.controls)),
743
+ ];
744
+ return {
745
+ schema: VERDICT_SCHEMA,
746
+ case: kase.id,
747
+ caseSha256: kase.sha256 || null,
748
+ revision: state.revision,
749
+ status,
750
+ exitCode: EXIT[status],
751
+ reasons,
752
+ ledger: {
753
+ file: path.relative(fs.realpathSync(projectDir), file).split(path.sep).join('/'),
754
+ runs: ledger.records.length,
755
+ head: ledger.records.length ? ledger.records.at(-1).sha256 : null,
756
+ anchor,
757
+ authenticated: Boolean(secret),
758
+ },
759
+ checks,
760
+ claims,
761
+ controls: { supported: controlsOf('supported'), unsupported: controlsOf('unsupported') },
762
+ };
763
+ }
764
+
765
+ /** The verdict as a CI check result; like the review check, it holds no timestamp. */
766
+ function checkResult(verdict) {
767
+ const lines = [
768
+ `Assurance case \`${verdict.case}\`: **${verdict.status}** at \`${String(verdict.revision).slice(0, 12)}\`.`,
769
+ `Ledger: ${verdict.ledger.runs} run(s), ${verdict.ledger.authenticated ? 'authenticated by key' : 'not keyed'}, ${verdict.ledger.anchor ? `anchored at \`${verdict.ledger.anchor.slice(0, 12)}\`` : 'not anchored'}.`,
770
+ '',
771
+ ...verdict.reasons.map((reason) => `- ${reason}`),
772
+ ...(verdict.reasons.length ? [''] : []),
773
+ '| Claim | Status | Why |',
774
+ '| --- | --- | --- |',
775
+ ...verdict.claims.map(
776
+ (claim) =>
777
+ `| ${claim.id} | ${claim.status} | ${claim.reasons.join('; ').replace(/\|/g, '/') || '—'} |`
778
+ ),
779
+ ];
780
+ return {
781
+ schema: CHECK_SCHEMA,
782
+ case: verdict.case,
783
+ caseSha256: verdict.caseSha256,
784
+ revision: verdict.revision,
785
+ status: verdict.status,
786
+ exitCode: verdict.exitCode,
787
+ reasons: verdict.reasons,
788
+ ledger: {
789
+ runs: verdict.ledger.runs,
790
+ head: verdict.ledger.head,
791
+ anchor: verdict.ledger.anchor,
792
+ authenticated: verdict.ledger.authenticated,
793
+ },
794
+ claims: verdict.claims.map(({ id, status, reasons }) => ({ id, status, reasons })),
795
+ controls: verdict.controls,
796
+ github: {
797
+ name: `bmad-plus assurance ${verdict.case}`,
798
+ conclusion: verdict.status === 'supported' ? 'success' : 'failure',
799
+ output: {
800
+ title: `${verdict.claims.filter((c) => c.status === 'supported').length}/${verdict.claims.length} claims supported`,
801
+ summary: lines.join('\n'),
802
+ },
803
+ },
804
+ };
805
+ }
806
+
807
+ module.exports = {
808
+ CASE_SCHEMA,
809
+ RUN_SCHEMA,
810
+ VERDICT_SCHEMA,
811
+ CHECK_SCHEMA,
812
+ DEFAULT_DIR,
813
+ KEY_VARIABLE,
814
+ parseCase,
815
+ loadCase,
816
+ initCase,
817
+ readLedger,
818
+ ledgerFile,
819
+ runChecks,
820
+ verifyCase,
821
+ checkResult,
822
+ };