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,162 @@
1
+ /**
2
+ * Security assurance cases: start one from the Shield template, run the checks that produce
3
+ * evidence, verify a case against them.
4
+ */
5
+ 'use strict';
6
+
7
+ const fs = require('node:fs');
8
+ const path = require('node:path');
9
+ const assurance = require('../lib/assurance');
10
+
11
+ const ACTIONS = ['init', 'run', 'verify'];
12
+
13
+ function print(json, payload, lines) {
14
+ if (json) console.log(JSON.stringify({ schemaVersion: 1, ...payload }, null, 2));
15
+ else for (const line of lines) console.log(line);
16
+ }
17
+
18
+ /** `--emit-check` writes a JSON file atomically, never over the case or its ledger. */
19
+ function writeCheck(projectDir, value, kase, ledger, result) {
20
+ const file = path.resolve(projectDir, value);
21
+ if (!/\.json$/i.test(file)) throw new Error('--emit-check writes a .json file');
22
+ const protectedFiles = [path.resolve(projectDir, kase.file), ledger];
23
+ if (protectedFiles.some((taken) => path.relative(taken, file) === ''))
24
+ throw new Error('--emit-check must not overwrite the case or its ledger');
25
+ if (fs.existsSync(file) && !fs.statSync(file).isFile())
26
+ throw new Error(`--emit-check: ${file} is not a file`);
27
+ fs.mkdirSync(path.dirname(file), { recursive: true });
28
+ const temporary = `${file}.${process.pid}.tmp`;
29
+ fs.writeFileSync(temporary, `${JSON.stringify(result, null, 2)}\n`, { flag: 'wx' });
30
+ try {
31
+ fs.renameSync(temporary, file);
32
+ } catch (error) {
33
+ fs.rmSync(temporary, { force: true });
34
+ throw error;
35
+ }
36
+ return file;
37
+ }
38
+
39
+ function initAction(projectDir, caseFile, json) {
40
+ const { file, id } = assurance.initCase(projectDir, caseFile);
41
+ print(json, { action: 'init', case: id, file }, [
42
+ `${file}: case ${id} started from the Shield template.`,
43
+ 'Replace every example claim and check with those of the project, commit it, then run it.',
44
+ ]);
45
+ }
46
+
47
+ async function runAction(projectDir, kase, options, json) {
48
+ const { file, results, head, authenticated } = await assurance.runChecks(projectDir, kase, {
49
+ dir: options.dir,
50
+ only: options.check,
51
+ });
52
+ const failed = results.filter((result) => result.failures.length);
53
+ print(
54
+ json,
55
+ {
56
+ action: 'run',
57
+ case: kase.id,
58
+ ledger: file,
59
+ head,
60
+ authenticated,
61
+ results: results.map(({ check, failures, record }) => ({
62
+ check,
63
+ status: failures.length ? 'failed' : 'passed',
64
+ failures,
65
+ sequence: record.sequence,
66
+ exitCode: record.exitCode,
67
+ revision: record.revision,
68
+ dirty: record.dirty,
69
+ sha256: record.sha256,
70
+ })),
71
+ },
72
+ [
73
+ `${file}`,
74
+ ...results.map(
75
+ ({ check, failures, record }) =>
76
+ ` ${failures.length ? 'failed' : 'passed'} ${check.padEnd(24)} exit ${record.exitCode ?? '-'} in ${Math.round(record.durationMs / 100) / 10} s${failures.length ? ` — ${failures.join('; ')}` : ''}`
77
+ ),
78
+ ...(results.some(({ record }) => record.dirty !== false)
79
+ ? [
80
+ ' Ran on uncommitted changes, untracked files or outside git: a case bound to a commit will not accept these runs.',
81
+ ]
82
+ : []),
83
+ `Recorded ${results.length} run(s)${authenticated ? ', authenticated by key' : ''}; ledger head ${head}.`,
84
+ `Keep the head outside the ledger and verify with bmad-plus assurance verify ${kase.file} --ledger-head ${head}.`,
85
+ ]
86
+ );
87
+ process.exitCode = failed.length ? 1 : 0;
88
+ }
89
+
90
+ function verifyAction(projectDir, kase, options, json) {
91
+ const verdict = assurance.verifyCase(projectDir, kase, {
92
+ dir: options.dir,
93
+ head: options.ledgerHead ?? null,
94
+ });
95
+ let check = null;
96
+ if (options.emitCheck)
97
+ check = writeCheck(
98
+ projectDir,
99
+ options.emitCheck,
100
+ kase,
101
+ assurance.ledgerFile(projectDir, options.dir, kase.id),
102
+ assurance.checkResult(verdict)
103
+ );
104
+ print(
105
+ json,
106
+ { action: 'verify', ...verdict, check },
107
+ [
108
+ `${kase.id}: ${verdict.status} — ${verdict.claims.filter((c) => c.status === 'supported').length}/${verdict.claims.length} claim(s) supported, ${verdict.ledger.runs} recorded run(s)`,
109
+ ...verdict.reasons.map((reason) => ` - ${reason}`),
110
+ ...Object.entries(verdict.checks)
111
+ .filter(([, judged]) => judged.status !== 'passed')
112
+ .map(([id, judged]) => ` ${judged.status.padEnd(7)} ${id}: ${judged.reasons.join('; ')}`),
113
+ ...verdict.claims
114
+ .filter((claim) => claim.status !== 'supported')
115
+ .map((claim) => ` unsupported claim ${claim.id}: ${claim.reasons.join('; ')}`),
116
+ ...(verdict.controls.supported.length
117
+ ? [` controls with executed evidence: ${verdict.controls.supported.join(', ')}`]
118
+ : []),
119
+ check ? ` check: ${check}` : '',
120
+ ].filter(Boolean)
121
+ );
122
+ process.exitCode = verdict.exitCode;
123
+ }
124
+
125
+ module.exports = {
126
+ command: 'assurance <action> <case>',
127
+ description: 'Security assurance case bound to executed checks: init, run, verify',
128
+ options: [
129
+ ['-d, --directory <path>', 'Project directory'],
130
+ ['--dir <path>', 'Evidence folder inside the project', assurance.DEFAULT_DIR],
131
+ ['--check <id>', 'run: only this check (repeatable)', (v, all) => [...all, v], []],
132
+ ['--ledger-head <sha256>', 'verify: fail unless the ledger still holds this record'],
133
+ ['--emit-check <file>', 'verify: also write the verdict as a JSON check result for CI'],
134
+ ['--json', 'Machine-readable output'],
135
+ ],
136
+ action: async (action, caseFile, options = {}) => {
137
+ const projectDir = path.resolve(options.directory || process.cwd());
138
+ const json = Boolean(options.json);
139
+ const settings = { dir: options.dir || assurance.DEFAULT_DIR, check: options.check || [] };
140
+ try {
141
+ if (!ACTIONS.includes(action))
142
+ throw new Error(`unknown action "${action}" (${ACTIONS.join(', ')})`);
143
+ if (action === 'init') return initAction(projectDir, caseFile, json);
144
+ const kase = assurance.loadCase(projectDir, caseFile);
145
+ if (action === 'run') await runAction(projectDir, kase, settings, json);
146
+ else
147
+ verifyAction(
148
+ projectDir,
149
+ kase,
150
+ { ...settings, emitCheck: options.emitCheck, ledgerHead: options.ledgerHead },
151
+ json
152
+ );
153
+ } catch (error) {
154
+ if (json)
155
+ console.log(
156
+ JSON.stringify({ schemaVersion: 1, status: 'error', message: error.message }, null, 2)
157
+ );
158
+ else console.error(`assurance: ${error.message}`);
159
+ process.exitCode = 3;
160
+ }
161
+ },
162
+ };
@@ -169,23 +169,30 @@ function rulesAction(projectDir, target, json) {
169
169
  action: 'rules',
170
170
  path: file,
171
171
  sha256: ruleset.sha256,
172
+ packFiles: ruleset.packFiles,
172
173
  projectFile: ruleset.projectFile,
173
174
  disabled: ruleset.disabled,
174
- rules: listed.map(({ id, title, group, layer, globs, source }) => ({
175
+ rules: listed.map(({ id, title, group, layer, pack, globs, controls, source }) => ({
175
176
  id,
176
177
  title,
177
178
  group,
178
179
  layer,
180
+ ...(pack ? { pack } : {}),
179
181
  globs,
182
+ controls,
180
183
  source,
181
184
  })),
185
+ controls: reviewRules.controlsOf(
186
+ ruleset,
187
+ listed.map((rule) => rule.id)
188
+ ),
182
189
  },
183
190
  [
184
- `rule set ${ruleset.sha256.slice(0, 12)}${ruleset.projectFile ? ` (built-in + ${ruleset.projectFile})` : ' (built-in)'}`,
191
+ `rule set ${ruleset.sha256.slice(0, 12)} (${['built-in', ...ruleset.packFiles, ...(ruleset.projectFile ? [ruleset.projectFile] : [])].join(' + ')})`,
185
192
  ...(file ? [`${file} gets ${listed.length} rule(s):`] : []),
186
193
  ...listed.map(
187
194
  (rule) =>
188
- ` ${rule.id.padEnd(24)} ${rule.group.padEnd(12)} ${rule.layer.padEnd(8)} ${rule.globs.join(' ')}`
195
+ ` ${rule.id.padEnd(24)} ${rule.group.padEnd(12)} ${rule.layer.padEnd(8)} ${rule.globs.join(' ')}${rule.controls.length ? `\n ${''.padEnd(24)} controls: ${rule.controls.join(' ')}` : ''}`
189
196
  ),
190
197
  ...ruleset.disabled.map((id) => ` ${id.padEnd(24)} disabled by the project`),
191
198
  ]
@@ -0,0 +1,393 @@
1
+ /**
2
+ * The AI processing register: which AI tools a project uses, what data they see, for what
3
+ * purpose, on which legal basis, for how long and where it goes. `checkRegister` compares it
4
+ * with the integrations present in the project (the adapters BMAD+ installs, the tools'
5
+ * own configuration folders, declared MCP servers) and warns about any it does not cover.
6
+ * The gate is soft: an unregistered tool is a warning, never a failure.
7
+ */
8
+ 'use strict';
9
+
10
+ const fs = require('node:fs');
11
+ const path = require('node:path');
12
+ const yaml = require('js-yaml');
13
+ const { DERIVED } = require('./packs');
14
+
15
+ const SCHEMA = 'bmad-plus/ai-processing-register/1';
16
+ const CHECK_SCHEMA = 'bmad-plus/ai-register-check/1';
17
+ const REGISTER_FILE = '_bmad/ai-processing-register.yaml';
18
+ const MAX_BYTES = 256 * 1024;
19
+ const MAX_TEXT = 2000;
20
+ const REVIEW_INTERVAL_DAYS = 365;
21
+ const TEMPLATE = path.join(
22
+ __dirname,
23
+ '..',
24
+ '..',
25
+ '..',
26
+ 'src',
27
+ 'bmad-plus',
28
+ 'packs',
29
+ 'pack-shield',
30
+ 'shared',
31
+ 'ai-processing-register-template.yaml'
32
+ );
33
+ const BOM = String.fromCharCode(0xfeff);
34
+
35
+ const REGISTER_KEYS = ['schema', 'controller', 'reviewed', 'tools'];
36
+ const ENTRY_KEYS = [
37
+ 'id',
38
+ 'name',
39
+ 'provider',
40
+ 'integrations',
41
+ 'purpose',
42
+ 'data',
43
+ 'personalData',
44
+ 'legalBasis',
45
+ 'retention',
46
+ 'transfers',
47
+ 'agreement',
48
+ ];
49
+ /** GDPR Art. 6(1)(a) to (f). */
50
+ const LEGAL_BASES = [
51
+ 'consent',
52
+ 'contract',
53
+ 'legal-obligation',
54
+ 'vital-interests',
55
+ 'public-task',
56
+ 'legitimate-interests',
57
+ ];
58
+ /** GDPR Art. 45, 46(2)(c), 47 and 49. */
59
+ const TRANSFER_MECHANISMS = [
60
+ 'adequacy-decision',
61
+ 'standard-contractual-clauses',
62
+ 'binding-corporate-rules',
63
+ 'derogation',
64
+ ];
65
+ const ENTRY_ID = /^[a-z0-9][a-z0-9-]{0,63}$/;
66
+ /** A tool id, or mcp: and a server name exactly as declared, control characters excepted. */
67
+ const INTEGRATION_ID = /^(?:[a-z0-9][a-z0-9.-]{0,63}|mcp:[^\p{Cc}]{1,256})$/u;
68
+
69
+ /**
70
+ * Where an AI tool shows it is set up in a project, beyond the adapter files the registry
71
+ * declares. Each marker names the integration ids that cover it.
72
+ */
73
+ const TOOL_MARKERS = [
74
+ { ids: ['claude-code'], paths: ['.claude'] },
75
+ { ids: ['cursor'], paths: ['.cursor', '.cursorrules'] },
76
+ { ids: ['gemini-cli', 'antigravity'], paths: ['.gemini'] },
77
+ { ids: ['codex-cli'], paths: ['.codex'] },
78
+ { ids: ['opencode'], paths: ['.opencode', 'opencode.json'] },
79
+ { ids: ['aider'], paths: ['.aider.conf.yml'] },
80
+ { ids: ['github-copilot'], paths: ['.github/copilot-instructions.md'] },
81
+ { ids: ['windsurf'], paths: ['.windsurf', '.windsurfrules'] },
82
+ { ids: ['continue'], paths: ['.continue'] },
83
+ ];
84
+
85
+ /** Project files that declare MCP servers, and the key that holds them. */
86
+ const MCP_FILES = [
87
+ { file: '.mcp.json', key: 'mcpServers' },
88
+ { file: '.cursor/mcp.json', key: 'mcpServers' },
89
+ { file: '.gemini/settings.json', key: 'mcpServers' },
90
+ { file: '.vscode/mcp.json', key: 'servers' },
91
+ ];
92
+
93
+ function fail(message) {
94
+ throw new Error(message);
95
+ }
96
+
97
+ function only(value, keys, where) {
98
+ if (!value || typeof value !== 'object' || Array.isArray(value))
99
+ fail(`${where}: must be a mapping`);
100
+ const unknown = Object.keys(value).filter((key) => !keys.includes(key));
101
+ if (unknown.length) fail(`${where}: unknown key(s) ${unknown.join(', ')}`);
102
+ }
103
+
104
+ function text(value, where) {
105
+ if (typeof value !== 'string' || !value.trim() || value.length > MAX_TEXT)
106
+ fail(`${where}: expected text of 1 to ${MAX_TEXT} characters`);
107
+ return value.trim();
108
+ }
109
+
110
+ function list(value, where, check) {
111
+ if (!Array.isArray(value) || !value.length) fail(`${where}: must be a non-empty list`);
112
+ return value.map((item, index) => check(item, `${where} ${index + 1}`));
113
+ }
114
+
115
+ /** A calendar date written YYYY-MM-DD; YAML may already have parsed it as a Date. */
116
+ function isoDate(value, where) {
117
+ const written = value instanceof Date ? value.toISOString().slice(0, 10) : value;
118
+ if (
119
+ typeof written !== 'string' ||
120
+ !/^\d{4}-\d{2}-\d{2}$/.test(written) ||
121
+ new Date(`${written}T00:00:00Z`).toISOString().slice(0, 10) !== written
122
+ )
123
+ fail(`${where}: expected a date written YYYY-MM-DD`);
124
+ return written;
125
+ }
126
+
127
+ function parseEntry(raw, index) {
128
+ const at = `tool ${raw?.id ?? index + 1}`;
129
+ only(raw, ENTRY_KEYS, at);
130
+ if (typeof raw.id !== 'string' || !ENTRY_ID.test(raw.id)) fail(`${at}: invalid id`);
131
+ if (typeof raw.personalData !== 'boolean')
132
+ fail(`${at}: personalData must say true or false whether the tool sees personal data`);
133
+ if (raw.personalData && !LEGAL_BASES.includes(raw.legalBasis))
134
+ fail(`${at}: legalBasis must be one of ${LEGAL_BASES.join(', ')}`);
135
+ if (!raw.personalData && raw.legalBasis !== undefined)
136
+ fail(`${at}: legalBasis applies only when the tool sees personal data`);
137
+ if (!Array.isArray(raw.transfers))
138
+ fail(`${at}: transfers must list where data goes outside the EEA ([] for nowhere)`);
139
+ return {
140
+ id: raw.id,
141
+ name: text(raw.name, `${at}: name`),
142
+ provider: text(raw.provider, `${at}: provider`),
143
+ integrations: list(raw.integrations, `${at}: integration`, (id, where) => {
144
+ if (typeof id !== 'string' || !INTEGRATION_ID.test(id))
145
+ fail(`${where}: "${id}" is not an integration id (a tool id, or mcp:<server>)`);
146
+ return id;
147
+ }),
148
+ purpose: text(raw.purpose, `${at}: purpose`),
149
+ data: list(raw.data, `${at}: data category`, text),
150
+ personalData: raw.personalData,
151
+ legalBasis: raw.legalBasis ?? null,
152
+ retention: text(raw.retention, `${at}: retention`),
153
+ transfers: raw.transfers.map((transfer, i) => {
154
+ const where = `${at}: transfer ${i + 1}`;
155
+ only(transfer, ['to', 'mechanism'], where);
156
+ if (!TRANSFER_MECHANISMS.includes(transfer.mechanism))
157
+ fail(`${where}: mechanism must be one of ${TRANSFER_MECHANISMS.join(', ')}`);
158
+ return { to: text(transfer.to, `${where}: to`), mechanism: transfer.mechanism };
159
+ }),
160
+ agreement: raw.agreement === undefined ? null : text(raw.agreement, `${at}: agreement`),
161
+ };
162
+ }
163
+
164
+ /** Validate a parsed register. Throws on the first defect. */
165
+ function parseRegister(doc) {
166
+ only(doc, REGISTER_KEYS, 'register');
167
+ if (doc.schema !== SCHEMA) fail(`register: schema must be "${SCHEMA}"`);
168
+ const tools = Array.isArray(doc.tools) ? doc.tools : fail('register: tools must be a list');
169
+ const entries = tools.map(parseEntry);
170
+ const ids = new Set();
171
+ const covered = new Map();
172
+ for (const entry of entries) {
173
+ if (ids.has(entry.id)) fail(`tool ${entry.id}: duplicate id`);
174
+ ids.add(entry.id);
175
+ for (const integration of entry.integrations) {
176
+ if (covered.has(integration))
177
+ fail(`tool ${entry.id}: ${integration} is already covered by ${covered.get(integration)}`);
178
+ covered.set(integration, entry.id);
179
+ }
180
+ }
181
+ return {
182
+ controller: text(doc.controller, 'register: controller'),
183
+ reviewed: isoDate(doc.reviewed, 'register: reviewed'),
184
+ tools: entries,
185
+ };
186
+ }
187
+
188
+ /** A name shortened and with its control characters escaped, safe to print. */
189
+ function printable(name) {
190
+ const shown = name.length > 64 ? `${name.slice(0, 64)}...` : name;
191
+ return shown.replace(/\p{Cc}/gu, (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, '0')}`);
192
+ }
193
+
194
+ /** The index just past the JSON string that opens at `start`. */
195
+ function stringEnd(text, start) {
196
+ let end = start + 1;
197
+ while (end < text.length && text[end] !== '"') end += text[end] === '\\' ? 2 : 1;
198
+ return end + 1;
199
+ }
200
+
201
+ const CLOSING = /\s*[}\]]/y;
202
+
203
+ /**
204
+ * JSON with comments, as VS Code and Gemini write their settings: line and block comments,
205
+ * then trailing commas, are removed outside strings before the text is parsed as JSON.
206
+ */
207
+ function parseJsonc(source) {
208
+ let bare = '';
209
+ for (let i = 0; i < source.length;) {
210
+ if (source[i] === '"') {
211
+ const end = stringEnd(source, i);
212
+ bare += source.slice(i, end);
213
+ i = end;
214
+ } else if (source.startsWith('//', i)) {
215
+ const end = source.indexOf('\n', i);
216
+ i = end === -1 ? source.length : end;
217
+ } else if (source.startsWith('/*', i)) {
218
+ const end = source.indexOf('*/', i + 2);
219
+ if (end === -1) fail('a block comment is not closed');
220
+ bare += ' ';
221
+ i = end + 2;
222
+ } else bare += source[i++];
223
+ }
224
+ let json = '';
225
+ for (let i = 0; i < bare.length;) {
226
+ if (bare[i] === '"') {
227
+ const end = stringEnd(bare, i);
228
+ json += bare.slice(i, end);
229
+ i = end;
230
+ continue;
231
+ }
232
+ CLOSING.lastIndex = i + 1;
233
+ if (bare[i] !== ',' || !CLOSING.test(bare)) json += bare[i];
234
+ i += 1;
235
+ }
236
+ return JSON.parse(json);
237
+ }
238
+
239
+ /** A regular file or folder inside the project, reached without a symbolic link. */
240
+ function present(root, relative) {
241
+ let current = root;
242
+ for (const part of relative.split('/')) {
243
+ current = path.join(current, part);
244
+ try {
245
+ if (fs.lstatSync(current).isSymbolicLink()) return false;
246
+ } catch {
247
+ return false;
248
+ }
249
+ }
250
+ return true;
251
+ }
252
+
253
+ function readProjectFile(root, relative) {
254
+ if (!present(root, relative)) return null;
255
+ const file = path.join(root, ...relative.split('/'));
256
+ const stat = fs.statSync(file);
257
+ if (!stat.isFile()) return null;
258
+ if (stat.size > MAX_BYTES) fail(`${relative} exceeds ${MAX_BYTES} bytes`);
259
+ const content = fs.readFileSync(file, 'utf8');
260
+ return content.startsWith(BOM) ? content.slice(1) : content;
261
+ }
262
+
263
+ /**
264
+ * The AI integrations present in the project, each with the ids that would cover it and
265
+ * the files that show it. Unreadable MCP declarations come back as warnings.
266
+ */
267
+ function detectIntegrations(projectDir, { adapters = DERIVED.targets.adapters } = {}) {
268
+ const root = path.resolve(projectDir);
269
+ const found = new Map();
270
+ const add = (ids, source) => {
271
+ const key = ids.join('|');
272
+ const entry = found.get(key) || { ids, sources: [] };
273
+ if (!entry.sources.includes(source)) entry.sources.push(source);
274
+ found.set(key, entry);
275
+ };
276
+ // One adapter file can serve several tools (GEMINI.md): any of them covers it.
277
+ const byFile = new Map();
278
+ for (const { tool, file } of adapters) byFile.set(file, [...(byFile.get(file) || []), tool]);
279
+ for (const [file, tools] of byFile) if (present(root, file)) add(tools, file);
280
+ for (const { ids, paths } of TOOL_MARKERS)
281
+ for (const marker of paths) if (present(root, marker)) add(ids, marker);
282
+ const warnings = [];
283
+ for (const { file, key } of MCP_FILES) {
284
+ let doc;
285
+ try {
286
+ const content = readProjectFile(root, file);
287
+ if (content === null) continue;
288
+ doc = parseJsonc(content);
289
+ } catch (error) {
290
+ warnings.push(`${file} cannot be read (${error.message}); its MCP servers are not checked`);
291
+ continue;
292
+ }
293
+ const servers = doc && typeof doc[key] === 'object' && doc[key] ? Object.keys(doc[key]) : [];
294
+ for (const server of servers) {
295
+ // Only a name the register can hold is reported, and never with raw control characters.
296
+ if (INTEGRATION_ID.test(`mcp:${server}`)) add([`mcp:${server}`], file);
297
+ else
298
+ warnings.push(
299
+ `${file} declares the MCP server "${printable(server)}", whose name cannot be registered; it is not checked`
300
+ );
301
+ }
302
+ }
303
+ return { integrations: [...found.values()], warnings };
304
+ }
305
+
306
+ /**
307
+ * Compare the register with the project. Returns the detected integrations with the entry
308
+ * covering each, the register entries matching nothing found here, the warnings, and any
309
+ * error that made the register unreadable.
310
+ */
311
+ function checkRegister(projectDir, { now = new Date(), adapters } = {}) {
312
+ const root = path.resolve(projectDir);
313
+ const detected = detectIntegrations(root, adapters ? { adapters } : {});
314
+ const warnings = [...detected.warnings];
315
+ const errors = [];
316
+ let register = null;
317
+ try {
318
+ const source = readProjectFile(root, REGISTER_FILE);
319
+ if (source !== null) register = parseRegister(yaml.load(source));
320
+ } catch (error) {
321
+ errors.push(`${REGISTER_FILE}: ${error.message}`);
322
+ }
323
+ const covering = new Map();
324
+ for (const entry of register?.tools || [])
325
+ for (const id of entry.integrations) covering.set(id, entry.id);
326
+ const integrations = detected.integrations.map(({ ids, sources }) => ({
327
+ ids,
328
+ sources,
329
+ coveredBy: ids.map((id) => covering.get(id)).find(Boolean) || null,
330
+ }));
331
+ const missing = integrations.filter((item) => !item.coveredBy);
332
+ if (!register && !errors.length && integrations.length)
333
+ warnings.push(`no AI processing register at ${REGISTER_FILE}`);
334
+ for (const item of missing)
335
+ warnings.push(
336
+ `${item.ids.join(' or ')} is set up (${item.sources.join(', ')}) but not in the AI processing register`
337
+ );
338
+ if (register) {
339
+ const age = Math.floor((now - new Date(`${register.reviewed}T00:00:00Z`)) / 86400000);
340
+ if (age > REVIEW_INTERVAL_DAYS)
341
+ warnings.push(`the register was last reviewed on ${register.reviewed}, ${age} days ago`);
342
+ }
343
+ const seen = new Set(integrations.flatMap((item) => item.ids));
344
+ return {
345
+ schema: CHECK_SCHEMA,
346
+ registerFile: REGISTER_FILE,
347
+ register: register ? { controller: register.controller, reviewed: register.reviewed } : null,
348
+ status: errors.length ? 'error' : warnings.length ? 'warning' : 'ok',
349
+ integrations,
350
+ unmatched: (register?.tools || [])
351
+ .filter((entry) => !entry.integrations.some((id) => seen.has(id)))
352
+ .map((entry) => entry.id),
353
+ warnings,
354
+ errors,
355
+ };
356
+ }
357
+
358
+ /**
359
+ * Start the register from the Shield template, never over an existing one. The template's
360
+ * entries are examples: `check` keeps warning until they describe the project's own tools.
361
+ */
362
+ function initRegister(projectDir) {
363
+ const [folder, name] = REGISTER_FILE.split('/');
364
+ const target = path.join(path.resolve(projectDir), folder);
365
+ let stat = null;
366
+ try {
367
+ stat = fs.lstatSync(target);
368
+ } catch (error) {
369
+ if (error.code !== 'ENOENT') throw error;
370
+ }
371
+ // A link in place of the folder would write the register outside the project.
372
+ if (stat && !stat.isDirectory()) fail(`${folder} is not a folder`);
373
+ fs.mkdirSync(target, { recursive: true });
374
+ try {
375
+ fs.writeFileSync(path.join(target, name), fs.readFileSync(TEMPLATE, 'utf8'), { flag: 'wx' });
376
+ } catch (error) {
377
+ if (error.code === 'EEXIST') fail(`${REGISTER_FILE} already exists`);
378
+ throw error;
379
+ }
380
+ return REGISTER_FILE;
381
+ }
382
+
383
+ module.exports = {
384
+ SCHEMA,
385
+ CHECK_SCHEMA,
386
+ REGISTER_FILE,
387
+ LEGAL_BASES,
388
+ TRANSFER_MECHANISMS,
389
+ parseRegister,
390
+ initRegister,
391
+ detectIntegrations,
392
+ checkRegister,
393
+ };