@intentius/chant 0.52.1 → 0.53.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 (149) hide show
  1. package/dist/agents/checks.d.ts +35 -0
  2. package/dist/agents/checks.d.ts.map +1 -0
  3. package/dist/agents/discover.d.ts +86 -0
  4. package/dist/agents/discover.d.ts.map +1 -0
  5. package/dist/agents/importer.d.ts +46 -0
  6. package/dist/agents/importer.d.ts.map +1 -0
  7. package/dist/agents/index.d.ts +14 -0
  8. package/dist/agents/index.d.ts.map +1 -0
  9. package/dist/agents/types.d.ts +196 -0
  10. package/dist/agents/types.d.ts.map +1 -0
  11. package/dist/audit/catalog.d.ts +4 -1
  12. package/dist/audit/catalog.d.ts.map +1 -1
  13. package/dist/audit/report.d.ts +8 -0
  14. package/dist/audit/report.d.ts.map +1 -1
  15. package/dist/audit/rules-doc.d.ts.map +1 -1
  16. package/dist/cdk/advise.d.ts +29 -0
  17. package/dist/cdk/advise.d.ts.map +1 -0
  18. package/dist/cdk/assembly.d.ts +38 -0
  19. package/dist/cdk/assembly.d.ts.map +1 -0
  20. package/dist/cdk/graph.d.ts +68 -0
  21. package/dist/cdk/graph.d.ts.map +1 -0
  22. package/dist/cdk/tier-map.d.ts +44 -0
  23. package/dist/cdk/tier-map.d.ts.map +1 -0
  24. package/dist/cdk/types.d.ts +114 -0
  25. package/dist/cdk/types.d.ts.map +1 -0
  26. package/dist/cli/commands/audit-agents.d.ts +83 -0
  27. package/dist/cli/commands/audit-agents.d.ts.map +1 -0
  28. package/dist/cli/commands/carve-apply.d.ts.map +1 -1
  29. package/dist/cli/commands/carve-bridge.d.ts.map +1 -1
  30. package/dist/cli/commands/carve-emit.d.ts.map +1 -1
  31. package/dist/cli/commands/carve.d.ts +48 -6
  32. package/dist/cli/commands/carve.d.ts.map +1 -1
  33. package/dist/cli/commands/import-agents.d.ts +64 -0
  34. package/dist/cli/commands/import-agents.d.ts.map +1 -0
  35. package/dist/cli/handlers/carve-emit.d.ts.map +1 -1
  36. package/dist/cli/handlers/carve.d.ts +5 -4
  37. package/dist/cli/handlers/carve.d.ts.map +1 -1
  38. package/dist/cli/handlers/lifecycle.d.ts +10 -0
  39. package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
  40. package/dist/cli/handlers/misc.d.ts.map +1 -1
  41. package/dist/cli/main.d.ts.map +1 -1
  42. package/dist/cli/registry.d.ts +15 -0
  43. package/dist/cli/registry.d.ts.map +1 -1
  44. package/dist/identity.d.ts +196 -0
  45. package/dist/identity.d.ts.map +1 -0
  46. package/dist/index.d.ts +1 -0
  47. package/dist/index.d.ts.map +1 -1
  48. package/dist/lexicon.d.ts +52 -0
  49. package/dist/lexicon.d.ts.map +1 -1
  50. package/dist/terraform/adopt-state.d.ts +17 -63
  51. package/dist/terraform/adopt-state.d.ts.map +1 -1
  52. package/dist/terraform/aws-resources.d.ts +1 -1
  53. package/dist/terraform/bridge.d.ts.map +1 -1
  54. package/dist/terraform/carve-provider.d.ts +142 -0
  55. package/dist/terraform/carve-provider.d.ts.map +1 -0
  56. package/dist/terraform/carve.d.ts +36 -3
  57. package/dist/terraform/carve.d.ts.map +1 -1
  58. package/dist/terraform/emit-source.d.ts +25 -0
  59. package/dist/terraform/emit-source.d.ts.map +1 -0
  60. package/dist/terraform/graduate.d.ts +11 -1
  61. package/dist/terraform/graduate.d.ts.map +1 -1
  62. package/dist/terraform/providers/aws.d.ts +19 -0
  63. package/dist/terraform/providers/aws.d.ts.map +1 -0
  64. package/dist/terraform/providers/gcp.d.ts +41 -0
  65. package/dist/terraform/providers/gcp.d.ts.map +1 -0
  66. package/dist/terraform/providers/index.d.ts +15 -0
  67. package/dist/terraform/providers/index.d.ts.map +1 -0
  68. package/dist/terraform/providers/kubernetes.d.ts +29 -0
  69. package/dist/terraform/providers/kubernetes.d.ts.map +1 -0
  70. package/dist/terraform/score.d.ts +70 -4
  71. package/dist/terraform/score.d.ts.map +1 -1
  72. package/dist/terraform/tier-map.d.ts +38 -26
  73. package/dist/terraform/tier-map.d.ts.map +1 -1
  74. package/dist/terraform/types.d.ts +6 -0
  75. package/dist/terraform/types.d.ts.map +1 -1
  76. package/dist/yaml.d.ts.map +1 -1
  77. package/package.json +6 -1
  78. package/src/agents/checks.test.ts +228 -0
  79. package/src/agents/checks.ts +429 -0
  80. package/src/agents/discover.test.ts +310 -0
  81. package/src/agents/discover.ts +939 -0
  82. package/src/agents/importer.ts +49 -0
  83. package/src/agents/index.ts +29 -0
  84. package/src/agents/types.ts +207 -0
  85. package/src/audit/catalog.ts +90 -1
  86. package/src/audit/report.ts +9 -1
  87. package/src/audit/rules-doc.ts +6 -0
  88. package/src/cdk/__fixtures__/cdk.out/AppStack.template.json +171 -0
  89. package/src/cdk/__fixtures__/cdk.out/DataStack.template.json +90 -0
  90. package/src/cdk/__fixtures__/cdk.out/cdk.out +1 -0
  91. package/src/cdk/__fixtures__/cdk.out/manifest.json +30 -0
  92. package/src/cdk/__fixtures__/cdk.out/tree.json +201 -0
  93. package/src/cdk/__fixtures__/cdk.out-dummy/LookupStack.template.json +29 -0
  94. package/src/cdk/__fixtures__/cdk.out-dummy/manifest.json +26 -0
  95. package/src/cdk/advise.test.ts +208 -0
  96. package/src/cdk/advise.ts +44 -0
  97. package/src/cdk/assembly.ts +133 -0
  98. package/src/cdk/graph.test.ts +206 -0
  99. package/src/cdk/graph.ts +525 -0
  100. package/src/cdk/tier-map.ts +71 -0
  101. package/src/cdk/types.ts +115 -0
  102. package/src/cli/commands/audit-agents.test.ts +260 -0
  103. package/src/cli/commands/audit-agents.ts +387 -0
  104. package/src/cli/commands/carve-apply.ts +20 -4
  105. package/src/cli/commands/carve-bridge.test.ts +30 -0
  106. package/src/cli/commands/carve-bridge.ts +24 -2
  107. package/src/cli/commands/carve-emit-k8s.test.ts +262 -0
  108. package/src/cli/commands/carve-emit-provider.test.ts +207 -0
  109. package/src/cli/commands/carve-emit.test.ts +72 -1
  110. package/src/cli/commands/carve-emit.ts +55 -28
  111. package/src/cli/commands/carve.ts +139 -36
  112. package/src/cli/commands/import-agents.test.ts +208 -0
  113. package/src/cli/commands/import-agents.ts +196 -0
  114. package/src/cli/handlers/carve-emit.ts +8 -1
  115. package/src/cli/handlers/carve.ts +8 -7
  116. package/src/cli/handlers/lifecycle.test.ts +187 -1
  117. package/src/cli/handlers/lifecycle.ts +125 -1
  118. package/src/cli/handlers/misc.ts +111 -0
  119. package/src/cli/main.ts +29 -5
  120. package/src/cli/registry.ts +15 -0
  121. package/src/identity.test.ts +199 -0
  122. package/src/identity.ts +346 -0
  123. package/src/index.ts +1 -0
  124. package/src/lexicon.ts +65 -0
  125. package/src/terraform/__fixtures__/gcp-estate/main.tf +60 -0
  126. package/src/terraform/adopt-state.test.ts +131 -0
  127. package/src/terraform/adopt-state.ts +22 -167
  128. package/src/terraform/aws-resources.test.ts +55 -16
  129. package/src/terraform/aws-resources.ts +1 -1
  130. package/src/terraform/bridge.test.ts +12 -0
  131. package/src/terraform/bridge.ts +4 -1
  132. package/src/terraform/carve-provider.test.ts +155 -0
  133. package/src/terraform/carve-provider.ts +237 -0
  134. package/src/terraform/carve.test.ts +55 -1
  135. package/src/terraform/carve.ts +0 -0
  136. package/src/terraform/emit-source.ts +39 -0
  137. package/src/terraform/graduate.test.ts +37 -0
  138. package/src/terraform/graduate.ts +55 -7
  139. package/src/terraform/graph.ts +3 -3
  140. package/src/terraform/providers/aws.ts +169 -0
  141. package/src/terraform/providers/gcp.test.ts +228 -0
  142. package/src/terraform/providers/gcp.ts +329 -0
  143. package/src/terraform/providers/index.ts +21 -0
  144. package/src/terraform/providers/kubernetes.ts +224 -0
  145. package/src/terraform/score.ts +111 -25
  146. package/src/terraform/tier-map.ts +55 -90
  147. package/src/terraform/types.ts +6 -0
  148. package/src/yaml.test.ts +54 -0
  149. package/src/yaml.ts +24 -3
@@ -0,0 +1,429 @@
1
+ /**
2
+ * Checks over discovered agent configuration (the `AGT` rule family).
3
+ *
4
+ * The threat model these encode is specific, and worth stating because it is
5
+ * not the same as the CI threat model the rest of `chant audit` covers.
6
+ *
7
+ * An agent config is *executable surface that runs with the user's own
8
+ * credentials on the user's own machine, usually with no review step*. An MCP
9
+ * server entry is a command line that runs at session start; a skill is
10
+ * instructions the model will follow; a permission allowlist decides what runs
11
+ * without anyone being asked. All three are commonly installed with one
12
+ * command, are rarely re-read after the day they were added, and — for the
13
+ * remote-sourced ones — can change upstream without any local edit.
14
+ *
15
+ * So the checks concentrate on three questions:
16
+ * 1. **What executes, and is it pinned?** (AGT001, AGT004)
17
+ * 2. **What can read secrets or reach the network in the clear?** (AGT002, AGT003)
18
+ * 3. **What runs without asking, and over how many projects?** (AGT005, AGT006)
19
+ * Plus AGT007, which flags config that is silently shadowed — where what the
20
+ * user reads is not what the agent runs.
21
+ *
22
+ * Every check is read-only and pure over a scan result, so a caller can run
23
+ * them against a fixture tree with no machine access.
24
+ */
25
+
26
+ import type { AgentConfigSite, AgentFinding, AgentScanResult, McpServerDecl } from "./types";
27
+
28
+ /** Rule ids in this family, in report order. Kept in sync with `audit/catalog.ts` by a drift test. */
29
+ export const AGENT_RULE_IDS = [
30
+ "AGT001",
31
+ "AGT002",
32
+ "AGT003",
33
+ "AGT004",
34
+ "AGT005",
35
+ "AGT006",
36
+ "AGT007",
37
+ "AGT008",
38
+ ] as const;
39
+
40
+ /**
41
+ * Package runners that fetch and execute code from a registry at invocation
42
+ * time. These are the commands where an unpinned spec means "whatever upstream
43
+ * publishes next", evaluated every time the agent starts.
44
+ */
45
+ const PACKAGE_RUNNERS = new Set(["npx", "pnpm dlx", "dlx", "bunx", "uvx", "pipx"]);
46
+
47
+ /** Spec forms that pin to an immutable release. Anything else floats. */
48
+ const PINNED_SPEC = /@\d+\.\d+\.\d+|@sha256:|==\d|@[0-9a-f]{40}$/;
49
+
50
+ /**
51
+ * Value shapes that are credentials rather than references to credentials.
52
+ * A config that stores `${GITHUB_TOKEN}` is fine; one that stores the token
53
+ * itself puts a live secret in a file that syncs, backs up, and gets shared.
54
+ */
55
+ const SECRET_KEY = /(?:^|_)(?:token|secret|password|passwd|api_?key|access_?key|credential|private_?key)s?(?:$|_)/i;
56
+ /** Indirection — an env-var reference or a command substitution — not a literal. */
57
+ const INDIRECT_VALUE = /^\s*(?:\$\{[^}]+\}|\$[A-Za-z_][A-Za-z0-9_]*|\$\(.+\))\s*$/;
58
+
59
+ /**
60
+ * A key naming a *digest* rather than a credential.
61
+ *
62
+ * Digests are the case where the generic "long opaque string" heuristic gets it
63
+ * exactly backwards: `NODE_REPL_TRUSTED_..._SHA256S` is a pin on trusted code —
64
+ * publishing it costs nothing and removing it would weaken the config. Checked
65
+ * before any value heuristic so a hash is never reported as a leaked secret.
66
+ */
67
+ const DIGEST_KEY = /(?:sha\d*|checksum|digest|fingerprint|thumbprint|hash|etag)/i;
68
+
69
+ /**
70
+ * Vendor-issued credential prefixes. These are unambiguous: no other kind of
71
+ * value starts this way, so a match alone is enough to report.
72
+ */
73
+ const VENDOR_CREDENTIAL = /^(?:sk-|sk-ant-|ghp_|gho_|ghu_|ghs_|ghr_|github_pat_|xox[baprs]-|AKIA|ASIA|glpat-|glrt-|AIza|hf_|pplx-|dop_v1_|shpat_|npm_|rk_live_|pk_live_)/;
74
+
75
+ /**
76
+ * Header names that carry a credential without naming one. `Authorization` is
77
+ * the case that matters: it is where remote MCP servers keep their bearer
78
+ * tokens, and it matches none of the "looks like a secret" key heuristics.
79
+ */
80
+ const AUTH_KEY = /^(?:authorization|proxy-authorization|cookie|set-cookie|x-api-key|api-key|apikey|x-auth-token|auth)$/i;
81
+
82
+ /** An HTTP authorization value: a scheme followed by the credential itself. */
83
+ const AUTH_SCHEME_VALUE = /^\s*(?:Bearer|Basic|Token|ApiKey)\s+\S+/i;
84
+
85
+ /**
86
+ * A long opaque value that is *not* a hex digest. Pure hex is excluded because
87
+ * hashes, git shas, and content digests dominate that shape in agent config and
88
+ * are not secrets; real credentials in this length range are base64/base62 and
89
+ * mix in non-hex characters.
90
+ */
91
+ function looksHighEntropy(value: string): boolean {
92
+ if (value.length < 40) return false;
93
+ if (!/^[A-Za-z0-9+/_-]{40,}={0,2}$/.test(value)) return false;
94
+ if (/^[0-9a-f]+$/i.test(value)) return false; // a digest or sha, not a credential
95
+ return true;
96
+ }
97
+
98
+ /**
99
+ * Permission patterns that grant a whole tool with no argument constraint.
100
+ * `Bash(git status:*)` is a scoped grant; `Bash` and `Bash(*)` are not.
101
+ */
102
+ const BLANKET_PERMISSION = /^([A-Za-z]+)(?:\(\s*(?:\*|:\*)\s*\))?$/;
103
+
104
+ /** Instruction files past this size stop being reliably followed and start crowding the context window. */
105
+ const INSTRUCTION_SIZE_BUDGET = 32 * 1024;
106
+
107
+ function finding(
108
+ site: AgentConfigSite,
109
+ checkId: string,
110
+ severity: AgentFinding["severity"],
111
+ file: string,
112
+ message: string,
113
+ entity?: string,
114
+ ): AgentFinding {
115
+ return { checkId, severity, message, file, siteId: site.id, scope: site.scope, runtime: site.runtime, entity };
116
+ }
117
+
118
+ /** The argv a stdio MCP server actually runs, as one string. */
119
+ function commandLine(server: McpServerDecl): string {
120
+ return [server.command ?? "", ...(server.args ?? [])].join(" ").trim();
121
+ }
122
+
123
+ /**
124
+ * AGT001 — an MCP server executes an unpinned package from a registry.
125
+ *
126
+ * `npx -y some-server` resolves `latest` on every launch, so the code running
127
+ * with the user's filesystem and credentials is whatever was published most
128
+ * recently. This is the agent-config form of the unpinned-dependency problem
129
+ * OSSF Scorecard tracks for CI, with a shorter path to the user's data.
130
+ */
131
+ function checkUnpinnedMcp(site: AgentConfigSite): AgentFinding[] {
132
+ const out: AgentFinding[] = [];
133
+ for (const server of site.mcpServers) {
134
+ if (server.transport !== "stdio" || !server.command) continue;
135
+ const runner = server.command.split("/").pop() ?? server.command;
136
+ if (!PACKAGE_RUNNERS.has(runner)) continue;
137
+
138
+ // The package spec is the first argument that isn't a flag or a flag's value.
139
+ const args = server.args ?? [];
140
+ let spec: string | undefined;
141
+ for (let i = 0; i < args.length; i++) {
142
+ const arg = args[i];
143
+ if (arg === "--from" || arg === "-p" || arg === "--package" || arg === "--with") {
144
+ spec = args[++i];
145
+ break;
146
+ }
147
+ if (arg.startsWith("-")) continue;
148
+ spec = arg;
149
+ break;
150
+ }
151
+ if (spec === undefined) continue;
152
+ if (PINNED_SPEC.test(spec)) continue;
153
+
154
+ out.push(
155
+ finding(
156
+ site,
157
+ "AGT001",
158
+ "error",
159
+ server.source,
160
+ `MCP server "${server.name}" runs \`${commandLine(server)}\` — \`${spec}\` is unpinned, so a new upstream release executes on this machine with no review.`,
161
+ server.name,
162
+ ),
163
+ );
164
+ }
165
+ return out;
166
+ }
167
+
168
+ /** AGT002 — a literal credential is stored in agent config rather than referenced. */
169
+ function checkLiteralSecrets(site: AgentConfigSite): AgentFinding[] {
170
+ const out: AgentFinding[] = [];
171
+
172
+ const inspect = (source: string, entity: string, key: string, value: string): void => {
173
+ if (INDIRECT_VALUE.test(value)) return;
174
+ // A digest is a pin, not a credential — and it matches every "long opaque
175
+ // string" heuristic, so it has to be excluded before they run.
176
+ if (DIGEST_KEY.test(key)) return;
177
+ const looksSecretByKey = SECRET_KEY.test(key) || AUTH_KEY.test(key);
178
+ const looksSecretByValue = AUTH_SCHEME_VALUE.test(value) || VENDOR_CREDENTIAL.test(value) || looksHighEntropy(value);
179
+ if (!looksSecretByKey && !looksSecretByValue) return;
180
+ // A key that names a secret but holds a short, obviously-non-secret value
181
+ // (a boolean, a path) is configuration, not a credential.
182
+ if (looksSecretByKey && !looksSecretByValue && value.length < 20) return;
183
+ out.push(
184
+ finding(
185
+ site,
186
+ "AGT002",
187
+ "error",
188
+ source,
189
+ `\`${key}\` in "${entity}" holds a literal credential. Agent config files sync, back up, and get shared — reference the secret (\`\${${key}}\`) and keep the value in a secret store.`,
190
+ entity,
191
+ ),
192
+ );
193
+ };
194
+
195
+ /** Walk a nested passthrough structure (`headers`, and whatever else a harness accepted). */
196
+ const inspectDeep = (source: string, entity: string, key: string, value: unknown, depth = 0): void => {
197
+ if (depth > 4) return;
198
+ if (typeof value === "string") inspect(source, entity, key, value);
199
+ else if (Array.isArray(value)) for (const v of value) inspectDeep(source, entity, key, v, depth + 1);
200
+ else if (value && typeof value === "object") {
201
+ for (const [k, v] of Object.entries(value as Record<string, unknown>)) inspectDeep(source, entity, k, v, depth + 1);
202
+ }
203
+ };
204
+
205
+ for (const server of site.mcpServers) {
206
+ for (const [key, value] of Object.entries(server.env ?? {})) inspect(server.source, server.name, key, value);
207
+ // Credentials hide in the fields this model doesn't name — `headers` above
208
+ // all. Skipping them would clear a config whose bearer token is sitting in
209
+ // plain text one key over from where we looked.
210
+ for (const [key, value] of Object.entries(server.extra ?? {})) inspectDeep(server.source, server.name, key, value);
211
+ // A credential passed as an argument is worse than one in `env`: it is also
212
+ // visible to anyone who can list processes.
213
+ for (const arg of server.args ?? []) {
214
+ const match = /^--?([A-Za-z0-9_-]*(?:token|key|secret|password)[A-Za-z0-9_-]*)[=\s]+(.+)$/i.exec(arg);
215
+ if (match) inspect(server.source, server.name, match[1], match[2]);
216
+ else if (VENDOR_CREDENTIAL.test(arg) || looksHighEntropy(arg)) inspect(server.source, server.name, "argv", arg);
217
+ }
218
+ }
219
+
220
+ const envSource = site.sources.find((s) => s.endsWith(".json") || s.endsWith(".toml")) ?? site.root;
221
+ for (const [key, value] of Object.entries(site.env)) inspect(envSource, "session environment", key, value);
222
+
223
+ return out;
224
+ }
225
+
226
+ /** AGT003 — a remote MCP server is reached over cleartext HTTP. */
227
+ function checkCleartextMcp(site: AgentConfigSite): AgentFinding[] {
228
+ const out: AgentFinding[] = [];
229
+ for (const server of site.mcpServers) {
230
+ if (!server.url || !/^http:\/\//i.test(server.url)) continue;
231
+ // Loopback is not exposed to the network, so it isn't the same risk.
232
+ if (/^http:\/\/(?:localhost|127\.0\.0\.1|\[::1\])(?::\d+)?(?:\/|$)/i.test(server.url)) continue;
233
+ out.push(
234
+ finding(
235
+ site,
236
+ "AGT003",
237
+ "error",
238
+ server.source,
239
+ `MCP server "${server.name}" is reached over cleartext at ${server.url}. Tool calls and their results — including anything the agent read from this machine — cross the network unencrypted.`,
240
+ server.name,
241
+ ),
242
+ );
243
+ }
244
+ return out;
245
+ }
246
+
247
+ /**
248
+ * AGT004 — a skill or plugin is installed from a remote source with no version pin.
249
+ *
250
+ * Grouped by *source*, not by skill. One unpinned marketplace supplying twenty
251
+ * skills is one decision to revisit and one place to fix; twenty findings would
252
+ * bury the other rules without telling the reader anything the first one didn't.
253
+ */
254
+ function checkUnpinnedRemoteSkills(site: AgentConfigSite): AgentFinding[] {
255
+ const out: AgentFinding[] = [];
256
+
257
+ const bySource = new Map<string, typeof site.skills>();
258
+ for (const skill of site.skills) {
259
+ if (skill.origin === "local" || !skill.source || skill.ref) continue;
260
+ const group = bySource.get(skill.source) ?? [];
261
+ group.push(skill);
262
+ bySource.set(skill.source, group);
263
+ }
264
+ for (const [source, skills] of bySource) {
265
+ const names = skills.map((s) => s.name).sort();
266
+ const shown = names.slice(0, 5).join(", ") + (names.length > 5 ? `, +${names.length - 5} more` : "");
267
+ out.push(
268
+ finding(
269
+ site,
270
+ "AGT004",
271
+ "warning",
272
+ skills[0].path ?? site.root,
273
+ `${names.length} skill${names.length === 1 ? "" : "s"} come${names.length === 1 ? "s" : ""} from ${source} with no pinned ref, so their instructions can change upstream without any edit on this machine (${shown}).`,
274
+ source,
275
+ ),
276
+ );
277
+ }
278
+ for (const plugin of site.plugins) {
279
+ if (!plugin.enabled || !plugin.remote || !plugin.marketplace || plugin.ref) continue;
280
+ out.push(
281
+ finding(
282
+ site,
283
+ "AGT004",
284
+ "warning",
285
+ site.sources[0] ?? site.root,
286
+ `Plugin "${plugin.name}" tracks ${plugin.marketplace} with no pinned ref. Plugins can add skills, commands, and MCP servers at once, so an upstream change installs all three.`,
287
+ plugin.name,
288
+ ),
289
+ );
290
+ }
291
+ return out;
292
+ }
293
+
294
+ /** AGT005 — the config runs tools without asking. */
295
+ function checkBlanketPermissions(site: AgentConfigSite): AgentFinding[] {
296
+ const out: AgentFinding[] = [];
297
+ const perms = site.permissions;
298
+ if (!perms) return out;
299
+ const source = site.sources.find((s) => s.includes("settings")) ?? site.sources[0] ?? site.root;
300
+
301
+ if (perms.bypassesPrompts) {
302
+ out.push(
303
+ finding(
304
+ site,
305
+ "AGT005",
306
+ "error",
307
+ source,
308
+ `This config disables the confirmation prompt for dangerous operations${site.scope === "user" ? " for every project on this machine" : ""}.`,
309
+ ),
310
+ );
311
+ }
312
+
313
+ for (const entry of perms.allow ?? []) {
314
+ const match = BLANKET_PERMISSION.exec(entry.trim());
315
+ if (!match) continue;
316
+ out.push(
317
+ finding(
318
+ site,
319
+ "AGT005",
320
+ "warning",
321
+ source,
322
+ `\`${entry}\` allows every invocation of ${match[1]} with no argument constraint. Scope it (\`${match[1]}(<specific command>:*)\`) so an unexpected call still surfaces.`,
323
+ entry,
324
+ ),
325
+ );
326
+ }
327
+
328
+ return out;
329
+ }
330
+
331
+ /**
332
+ * AGT006 — user-scope configuration applies to every project.
333
+ *
334
+ * Informational rather than a defect: a user-scope config is often exactly
335
+ * what someone wants. It is reported because the blast radius is invisible at
336
+ * the point of editing — nothing in `~/.claude/settings.json` says "this also
337
+ * applies to the client repo you open next week".
338
+ */
339
+ function checkUserScopeBlastRadius(site: AgentConfigSite): AgentFinding[] {
340
+ if (site.scope !== "user") return [];
341
+ const carried: string[] = [];
342
+ if (site.mcpServers.length > 0) carried.push(`${site.mcpServers.length} MCP server${site.mcpServers.length === 1 ? "" : "s"}`);
343
+ if (site.instructions.length > 0) carried.push(`${site.instructions.length} instruction file${site.instructions.length === 1 ? "" : "s"}`);
344
+ if (site.skills.length > 0) carried.push(`${site.skills.length} skill${site.skills.length === 1 ? "" : "s"}`);
345
+ if (carried.length === 0) return [];
346
+ return [
347
+ finding(
348
+ site,
349
+ "AGT006",
350
+ "info",
351
+ site.sources[0] ?? site.root,
352
+ `User-scope ${site.runtime} config carries ${carried.join(", ")} into every project opened on this machine, including repos you don't own.`,
353
+ ),
354
+ ];
355
+ }
356
+
357
+ /**
358
+ * AGT007 — an MCP server is declared in more than one file at the same scope.
359
+ *
360
+ * The harness silently takes one and ignores the rest, so the file a user opens
361
+ * to check what a server does may not be the file that decides what it runs.
362
+ */
363
+ function checkShadowedDeclarations(site: AgentConfigSite, declarations: McpServerDecl[]): AgentFinding[] {
364
+ const bySource = new Map<string, Set<string>>();
365
+ for (const decl of declarations) {
366
+ if (!bySource.has(decl.name)) bySource.set(decl.name, new Set());
367
+ bySource.get(decl.name)!.add(decl.source);
368
+ }
369
+ const out: AgentFinding[] = [];
370
+ for (const [name, sources] of bySource) {
371
+ if (sources.size < 2) continue;
372
+ const winner = site.mcpServers.find((s) => s.name === name);
373
+ if (!winner) continue;
374
+ const losers = [...sources].filter((s) => s !== winner.source);
375
+ out.push(
376
+ finding(
377
+ site,
378
+ "AGT007",
379
+ "warning",
380
+ winner.source,
381
+ `MCP server "${name}" is declared in ${sources.size} files; ${winner.source} wins and ${losers.join(", ")} ${losers.length === 1 ? "is" : "are"} ignored.`,
382
+ name,
383
+ ),
384
+ );
385
+ }
386
+ return out;
387
+ }
388
+
389
+ /** AGT008 — an instruction file is large enough that the agent will not reliably follow all of it. */
390
+ function checkInstructionSize(site: AgentConfigSite): AgentFinding[] {
391
+ return site.instructions
392
+ .filter((file) => file.bytes > INSTRUCTION_SIZE_BUDGET)
393
+ .map((file) =>
394
+ finding(
395
+ site,
396
+ "AGT008",
397
+ "info",
398
+ file.path,
399
+ `${(file.bytes / 1024).toFixed(0)} KB of standing instructions load into every session. Past roughly ${INSTRUCTION_SIZE_BUDGET / 1024} KB, later rules compete with earlier ones for attention — move the situational parts into skills that load on demand.`,
400
+ ),
401
+ );
402
+ }
403
+
404
+ /**
405
+ * Run every agent-config check over a scan result.
406
+ *
407
+ * Findings are ordered by severity, then site, then rule — so the report leads
408
+ * with what executes unreviewed rather than with instruction-file hygiene.
409
+ */
410
+ export function checkAgentConfigs(scan: AgentScanResult): AgentFinding[] {
411
+ const findings: AgentFinding[] = [];
412
+ for (const site of scan.sites) {
413
+ findings.push(
414
+ ...checkUnpinnedMcp(site),
415
+ ...checkLiteralSecrets(site),
416
+ ...checkCleartextMcp(site),
417
+ ...checkUnpinnedRemoteSkills(site),
418
+ ...checkBlanketPermissions(site),
419
+ ...checkUserScopeBlastRadius(site),
420
+ ...checkShadowedDeclarations(site, scan.declarations[site.id] ?? []),
421
+ ...checkInstructionSize(site),
422
+ );
423
+ }
424
+
425
+ const rank = { error: 0, warning: 1, info: 2 } as const;
426
+ return findings.sort(
427
+ (a, b) => rank[a.severity] - rank[b.severity] || a.siteId.localeCompare(b.siteId) || a.checkId.localeCompare(b.checkId),
428
+ );
429
+ }