activate-agentmd 2.4.0 → 2.5.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.
@@ -0,0 +1,169 @@
1
+ /**
2
+ * bundle.js
3
+ *
4
+ * Signed context bundles: a record, signed with the organisation's own key,
5
+ * of exactly which instruction files an agent in this repository reads —
6
+ * CLAUDE.md, AGENTS.md, rules, skills, hook settings, the installed
7
+ * standards — and the hash of each.
8
+ *
9
+ * Two questions it answers that nothing else does:
10
+ *
11
+ * - Has anything an agent reads changed since it was approved? `verify`
12
+ * names every modified, missing and unapproved file, so a PR that adds a
13
+ * rule or edits a skill fails until someone with the key signs off.
14
+ * - Which context was in play when this code was written? The digest is one
15
+ * hash over every covered file; `attest` records it next to the commit,
16
+ * which is the evidence an audit of agent-written code asks for.
17
+ *
18
+ * Agent Plugins 1.0 standardised how skills are packaged and explicitly left
19
+ * provenance and signing out. This is that missing piece, for the files an
20
+ * agent reads rather than the ones it installs.
21
+ *
22
+ * Trust: bundles are signed with the ORG's key, not Agent.md's — the license
23
+ * keyset is the wrong anchor for a customer's own context. Trusted public keys
24
+ * come from AGENTMD_BUNDLE_KEYS when set (use this in CI, from a secret, so a
25
+ * pull request cannot swap the key it is checked against), otherwise from
26
+ * `bundleKeys` in .agentmd/enterprise.json — which is itself covered, so
27
+ * editing the key list is a change that needs signing too.
28
+ *
29
+ * Limits, stated plainly: this is a gate in CI and in review, not runtime
30
+ * enforcement. Nothing here stops an agent from reading an unsigned file on a
31
+ * developer's machine; it stops that file from merging unnoticed.
32
+ */
33
+
34
+ "use strict";
35
+
36
+ const crypto = require("crypto");
37
+ const fs = require("fs");
38
+ const path = require("path");
39
+ const { sign, verifySignature } = require("./license");
40
+ const { findInstructionFiles } = require("./lint");
41
+
42
+ const SIG_FILE = path.join(".agentmd", "bundle.sig");
43
+ const TYPE = "agentmd-bundle";
44
+
45
+ /** Per-developer files: never committed, so never part of the approved set. */
46
+ const PERSONAL = /(^|\/)[^/]*\.local\.[^/]+$/;
47
+ /** Written by the CLI as it runs — state, not instructions. */
48
+ const NOT_CONTEXT = new Set([".agentmd/bundle.sig", ".agentmd/analytics.json"]);
49
+
50
+ const posix = (p) => p.split(path.sep).join("/");
51
+ const sha256 = (buf) => crypto.createHash("sha256").update(buf).digest("hex");
52
+
53
+ function listFiles(dir) {
54
+ const out = [];
55
+ const walk = (d) => {
56
+ let entries;
57
+ try { entries = fs.readdirSync(d, { withFileTypes: true }); } catch { return; }
58
+ for (const e of entries) {
59
+ const full = path.join(d, e.name);
60
+ if (e.isDirectory()) walk(full);
61
+ else if (e.isFile()) out.push(full);
62
+ }
63
+ };
64
+ walk(dir);
65
+ return out;
66
+ }
67
+
68
+ /** Every file an agent reads as instructions, relative and POSIX, sorted. */
69
+ function coveredFiles(cwd) {
70
+ const set = new Set(findInstructionFiles(cwd).map(posix));
71
+ for (const sub of ["presets", "overrides"]) {
72
+ for (const f of listFiles(path.join(cwd, ".agentmd", sub))) set.add(posix(path.relative(cwd, f)));
73
+ }
74
+ for (const f of [".agentmd/manifest.json", ".agentmd/enterprise.json"]) {
75
+ if (fs.existsSync(path.join(cwd, f))) set.add(f);
76
+ }
77
+ return [...set].filter((f) => !PERSONAL.test(f) && !NOT_CONTEXT.has(f)).sort();
78
+ }
79
+
80
+ /** The files and their hashes, plus one digest over the lot. */
81
+ function snapshot(cwd) {
82
+ const files = coveredFiles(cwd).map((p) => ({ path: p, sha256: sha256(fs.readFileSync(path.join(cwd, p))) }));
83
+ const digest = sha256(files.map((f) => `${f.sha256} ${f.path}\n`).join(""));
84
+ return { files, digest };
85
+ }
86
+
87
+ /** The current context digest, for audit records. Cheap: hashes a few files. */
88
+ const contextDigest = (cwd) => snapshot(cwd).digest;
89
+
90
+ /** A new Ed25519 key pair, base64 DER — the same format the license keyset uses. */
91
+ function keygen() {
92
+ const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519");
93
+ return {
94
+ publicKey: publicKey.export({ format: "der", type: "spki" }).toString("base64"),
95
+ privateKey: privateKey.export({ format: "der", type: "pkcs8" }).toString("base64"),
96
+ };
97
+ }
98
+
99
+ /** Sign what is on disk now and write .agentmd/bundle.sig. */
100
+ function signBundle(cwd, privateKeyB64, kid, { days } = {}) {
101
+ const { files, digest } = snapshot(cwd);
102
+ const iat = Math.floor(Date.now() / 1000);
103
+ const claims = { type: TYPE, v: 1, iat, digest, files };
104
+ if (days) claims.exp = iat + days * 86400;
105
+ const token = sign(claims, privateKeyB64, kid);
106
+ const out = path.join(cwd, SIG_FILE);
107
+ fs.mkdirSync(path.dirname(out), { recursive: true });
108
+ fs.writeFileSync(out, token + "\n", "utf-8");
109
+ return { files: files.length, digest, kid };
110
+ }
111
+
112
+ /** Trusted public keys: the environment wins, so CI can pin them from a secret. */
113
+ function trustedKeys(cwd, env = process.env) {
114
+ let keys = null;
115
+ if (env.AGENTMD_BUNDLE_KEYS) {
116
+ try { keys = JSON.parse(env.AGENTMD_BUNDLE_KEYS); } catch { keys = null; }
117
+ if (keys && !Array.isArray(keys)) keys = keys.keys || null;
118
+ return { keys: Array.isArray(keys) ? keys : [], source: "AGENTMD_BUNDLE_KEYS" };
119
+ }
120
+ try {
121
+ const cfg = JSON.parse(fs.readFileSync(path.join(cwd, ".agentmd", "enterprise.json"), "utf-8"));
122
+ keys = Array.isArray(cfg.bundleKeys) ? cfg.bundleKeys : [];
123
+ } catch { keys = []; }
124
+ return { keys, source: ".agentmd/enterprise.json" };
125
+ }
126
+
127
+ /**
128
+ * Check the signature and compare the signed hashes to the files on disk.
129
+ * Returns { valid, reason?, kid?, signedAt?, digest, modified, missing, unapproved }.
130
+ */
131
+ function verifyBundle(cwd, env = process.env) {
132
+ const current = snapshot(cwd);
133
+ const empty = { modified: [], missing: [], unapproved: [], digest: current.digest };
134
+
135
+ let token;
136
+ try { token = fs.readFileSync(path.join(cwd, SIG_FILE), "utf-8").trim(); } catch {
137
+ return { ...empty, valid: false, reason: `no ${posix(SIG_FILE)} — nothing has been signed` };
138
+ }
139
+ const { keys, source } = trustedKeys(cwd, env);
140
+ if (!keys.length) return { ...empty, valid: false, reason: `no trusted bundle keys in ${source}` };
141
+
142
+ const set = { keys: keys.map((k) => ({ alg: "ed25519", status: "active", ...k })), legacyKid: null };
143
+ const sig = verifySignature(token, set);
144
+ if (!sig.valid) return { ...empty, valid: false, kid: sig.kid, reason: `${posix(SIG_FILE)} ${sig.reason.replace(/^was not issued by Agent\.md/, "was not signed by a trusted key")}` };
145
+ const c = sig.claims;
146
+ if (c.type !== TYPE || !Array.isArray(c.files)) return { ...empty, valid: false, kid: sig.kid, reason: `${posix(SIG_FILE)} is not a context bundle` };
147
+ if (typeof c.exp === "number" && c.exp * 1000 < Date.now()) {
148
+ return { ...empty, valid: false, kid: sig.kid, reason: `the bundle expired on ${new Date(c.exp * 1000).toISOString().slice(0, 10)} — sign it again` };
149
+ }
150
+
151
+ const signed = new Map(c.files.map((f) => [f.path, f.sha256]));
152
+ const now = new Map(current.files.map((f) => [f.path, f.sha256]));
153
+ const modified = [...now].filter(([p, h]) => signed.has(p) && signed.get(p) !== h).map(([p]) => p);
154
+ const unapproved = [...now.keys()].filter((p) => !signed.has(p));
155
+ const missing = [...signed.keys()].filter((p) => !now.has(p));
156
+ const valid = !modified.length && !unapproved.length && !missing.length;
157
+
158
+ return {
159
+ valid,
160
+ reason: valid ? null : "the files an agent reads have changed since the bundle was signed",
161
+ kid: sig.kid,
162
+ signedAt: new Date(c.iat * 1000).toISOString(),
163
+ digest: current.digest,
164
+ signedDigest: c.digest,
165
+ modified, missing, unapproved,
166
+ };
167
+ }
168
+
169
+ module.exports = { coveredFiles, snapshot, contextDigest, keygen, signBundle, verifyBundle, trustedKeys, SIG_FILE };
@@ -40,6 +40,8 @@ const KNOWN = [
40
40
  "ssoProvider",
41
41
  "allowPublicRegistry",
42
42
  "pinnedStandards",
43
+ "bundleKeys",
44
+ "requireSignedContext",
43
45
  "$schema",
44
46
  ];
45
47
 
@@ -80,6 +82,9 @@ function load(cwd = process.cwd()) {
80
82
  // fetcher refuses one — this content becomes agent instructions.
81
83
  if (/^http:\/\//i.test(value)) problems.push(`${FILE}: \`${key}\` must be https, not http`);
82
84
  }
85
+ if (config.bundleKeys !== undefined && !(Array.isArray(config.bundleKeys) && config.bundleKeys.every((k) => k && typeof k.kid === "string" && typeof k.publicKey === "string"))) {
86
+ problems.push(`${FILE}: \`bundleKeys\` must be an array of { kid, publicKey }`);
87
+ }
83
88
  if (config.pinnedStandards !== undefined && !Array.isArray(config.pinnedStandards)) {
84
89
  problems.push(`${FILE}: \`pinnedStandards\` must be an array`);
85
90
  }
@@ -91,6 +96,7 @@ function load(cwd = process.cwd()) {
91
96
  privateRegistry: process.env.AGENTMD_PRIVATE_REGISTRY || config.privateRegistry || null,
92
97
  cloudUrl: process.env.AGENTMD_CLOUD_URL || config.cloudUrl || null,
93
98
  requireSso: config.requireSso === true,
99
+ requireSignedContext: config.requireSignedContext === true,
94
100
  allowPublicRegistry: config.allowPublicRegistry !== false,
95
101
  };
96
102
 
@@ -102,7 +108,7 @@ function init(cwd = process.cwd(), values = {}) {
102
108
  const file = path.join(cwd, FILE);
103
109
  if (fs.existsSync(file)) throw new Error(`${FILE} already exists`);
104
110
  const body = {
105
- $schema: "https://agent-dot-md.vercel.app/schema/enterprise.json",
111
+ $schema: "https://agentmd.pages.dev/schema/enterprise.json",
106
112
  privateRegistry: values.privateRegistry || "https://standards.example.internal/agentmd",
107
113
  cloudUrl: values.cloudUrl || null,
108
114
  licenseKey: values.licenseKey || "",
@@ -134,7 +134,13 @@ function publicKeyOf(record) {
134
134
  * not a stack trace. Reasons read as a predicate, because they are printed
135
135
  * after "That key …" and "The key from <source> …".
136
136
  */
137
- function verify(key, set = keyset()) {
137
+ /**
138
+ * The signature half of verification, shared by licenses and context bundles:
139
+ * well-formed, signed by a key in `set`, and that key not revoked. What the
140
+ * claims must say is the caller's business — a license needs a paid plan, a
141
+ * bundle needs to match the files on disk.
142
+ */
143
+ function verifySignature(key, set = keyset()) {
138
144
  if (typeof key !== "string" || !key.startsWith(PREFIX)) {
139
145
  return { valid: false, reason: "is not an Agent.md license key — one starts with `agmd_`" };
140
146
  }
@@ -186,6 +192,13 @@ function verify(key, set = keyset()) {
186
192
  ok = false;
187
193
  }
188
194
  if (!ok) return { valid: false, kid, reason: "was not issued by Agent.md — the signature does not match" };
195
+ return { valid: true, claims, kid };
196
+ }
197
+
198
+ function verify(key, set = keyset()) {
199
+ const sig = verifySignature(key, set);
200
+ if (!sig.valid) return sig;
201
+ const { claims, kid } = sig;
189
202
 
190
203
  if (!PAID_PLANS.includes(claims.plan)) {
191
204
  return { valid: false, kid, claims, reason: `is for the \`${claims.plan || "none"}\` plan, which does not include Pro features` };
@@ -229,6 +242,6 @@ function daysLeft(claims) {
229
242
  }
230
243
 
231
244
  module.exports = {
232
- verify, sign, fingerprint, daysLeft, keyset, findKey, canSign,
245
+ verify, verifySignature, sign, fingerprint, daysLeft, keyset, findKey, canSign,
233
246
  PAID_PLANS, PREFIX, VERIFIES,
234
247
  };
@@ -0,0 +1,150 @@
1
+ /**
2
+ * lint-text.js
3
+ *
4
+ * The content-only half of `agentmd lint`: the checks that need nothing but
5
+ * the text of one instruction file — secrets, prompt injection, invisible
6
+ * Unicode, hidden HTML-comment instructions, base64 payloads, bloat, /init
7
+ * fossils.
8
+ *
9
+ * It has no dependencies on purpose. The CLI runs it over files on disk; the
10
+ * website's /inspect page runs the very same function in the browser over a
11
+ * pasted CLAUDE.md. One implementation, so the two can never disagree about
12
+ * what counts as an injection. The checks that need the rest of the project
13
+ * (dead references, formatter config, version pins) stay in lint.js.
14
+ */
15
+
16
+ "use strict";
17
+
18
+
19
+ // ── Prose extraction ───────────────────────────────────────────────────────
20
+
21
+ /**
22
+ * Lines with code stripped: fenced blocks blanked, inline code removed. Line
23
+ * numbers are preserved so findings point at the right place.
24
+ */
25
+ function proseLines(text) {
26
+ let fence = false;
27
+ return text.split("\n").map((line) => {
28
+ if (/^\s*(```|~~~)/.test(line)) { fence = !fence; return ""; }
29
+ if (fence) return "";
30
+ return line.replace(/`[^`]*`/g, "").replace(/"[^"\n]{0,200}"/g, "\"\"");
31
+ });
32
+ }
33
+
34
+ /** A line that warns against something is teaching, not instructing. */
35
+ const NEGATED = /\b(never|don['’]?t|do not|must not|avoid|forbid|forbidden|disallow|block|reject|prevent|attack|malicious|example of)\b|❌|🚫|✗/i;
36
+
37
+ // ── Rules ──────────────────────────────────────────────────────────────────
38
+
39
+ const SECRETS = [
40
+ { name: "Anthropic API key", re: /\bsk-ant-(?:api|admin)\d{2}-[A-Za-z0-9_-]{20,}/ },
41
+ { name: "OpenAI API key", re: /\bsk-(?:proj-)?[A-Za-z0-9_-]{40,}/ },
42
+ { name: "GitHub token", re: /\bgh[pousr]_[A-Za-z0-9]{36,}/ },
43
+ { name: "GitHub fine-grained token", re: /\bgithub_pat_[A-Za-z0-9_]{60,}/ },
44
+ { name: "AWS access key id", re: /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/ },
45
+ { name: "Slack token", re: /\bxox[baprs]-[A-Za-z0-9-]{20,}/ },
46
+ { name: "Stripe live key", re: /\b[sr]k_live_[A-Za-z0-9]{20,}/ },
47
+ { name: "Google API key", re: /\bAIza[0-9A-Za-z_-]{35}\b/ },
48
+ { name: "private key", re: /-----BEGIN (?:RSA |EC |OPENSSH |DSA |PGP )?PRIVATE KEY-----/ },
49
+ // A generic assignment of a long opaque value to a secret-sounding name.
50
+ { name: "hard-coded secret", re: /\b(?:api[_-]?key|secret|password|passwd|token|auth[_-]?token)\b\s*[:=]\s*["']?[A-Za-z0-9_\-/+=]{24,}["']?/i },
51
+ ];
52
+
53
+ /** Placeholders people write on purpose. */
54
+ const PLACEHOLDER = /x{6,}|\*{4,}|<[A-Z_ -]+>|\$\{|\$[A-Z_]+|process\.env|os\.environ|getenv|your[-_ ]?(api[-_ ]?)?(key|token|secret)|example|placeholder|changeme|redacted|dummy|fake|test[-_]?key|\.\.\./i;
55
+
56
+ // Zero-width, bidi overrides and Unicode tag characters: invisible to the
57
+ // reviewer, read by the model. ZWJ (U+200D) is excluded — emoji use it.
58
+ const INVISIBLE = /[\u200B\u200C\u200E\u200F\u202A-\u202E\u2060-\u2064]|[\u{E0000}-\u{E007F}]/u;
59
+
60
+ const OVERRIDE_PHRASE = /\b(ignore|disregard|forget|override)\b[^.\n]{0,30}\b(previous|prior|above|earlier|all|system|your)\b[^.\n]{0,20}\b(instructions?|prompts?|rules?|guidelines?|context)\b/i;
61
+ const PERMISSION_BYPASS = /--dangerously-skip-permissions\b|--yolo\b|"?\bbypassPermissions\b"?|--no-sandbox\b|\bauto[-_ ]?approve\s+(all|every)/i;
62
+ const PIPE_TO_SHELL = /\b(curl|wget|iwr|Invoke-WebRequest)\b[^|\n]*\|\s*(sudo\s+)?(ba|z|da)?sh\b|\b(iex|Invoke-Expression)\b/i;
63
+ const HIDDEN_DIRECTIVE = /\b(curl|wget|upload|exfiltrat|send\b[^.]{0,40}\bto\b|post\b[^.]{0,40}\bto\b|ignore|disregard|do not (tell|mention|reveal|show)|don['’]?t (tell|mention|reveal|show)|without (telling|asking|mentioning)|secretly|silently)\b/i;
64
+ const BASE64_BLOB = /(?<!base64,)(?<![A-Za-z0-9+/])[A-Za-z0-9+/]{200,}={0,2}/;
65
+
66
+ const FOSSIL = /This file provides guidance to Claude Code \(claude\.ai\/code\) when working with code in this repository/;
67
+
68
+ function finding(file, line, severity, rule, message) {
69
+ return { file, line, severity, rule, message };
70
+ }
71
+
72
+ function lineOf(text, needle) {
73
+ const i = text.indexOf(needle);
74
+ return i === -1 ? 1 : text.slice(0, i).split("\n").length;
75
+ }
76
+
77
+ /**
78
+ * Check the text of one instruction file.
79
+ *
80
+ * file path shown in findings
81
+ * alwaysOn whether the agent loads it on every prompt (bloat applies only then)
82
+ * budget always-on token budget; bloat warns at twice it
83
+ */
84
+ function lintText(text, { file = "CLAUDE.md", alwaysOn = true, budget = 1500 } = {}) {
85
+ const out = [];
86
+ const raw = text.split("\n");
87
+ const prose = proseLines(text);
88
+
89
+ raw.forEach((line, i) => {
90
+ const n = i + 1;
91
+
92
+ // Secrets: every line, code included — a key in a code block is still a key.
93
+ for (const s of SECRETS) {
94
+ const m = s.re.exec(line);
95
+ if (m && !PLACEHOLDER.test(m[0])) {
96
+ out.push(finding(file, n, "error", "secret", `Looks like ${/^[aeiou]/i.test(s.name) ? "an" : "a"} ${s.name}. Instruction files are sent to the model on every session — rotate it, then read it from the environment.`));
97
+ break;
98
+ }
99
+ }
100
+
101
+ // Invisible characters: every line, code included — there is no
102
+ // legitimate reason to hide text from the person reviewing the file.
103
+ // A BOM at the very start of the file is the one exception.
104
+ const scan = i === 0 ? line.replace(/^\uFEFF/, "") : line;
105
+ if (INVISIBLE.test(scan)) {
106
+ out.push(finding(file, n, "error", "injection", "Invisible Unicode (zero-width, bidi override or tag characters) — the model reads it, a reviewer does not. Remove it."));
107
+ }
108
+
109
+ // Hidden directives inside HTML comments: rendered invisible on GitHub,
110
+ // delivered verbatim to the agent. Our own managed-block markers are fine.
111
+ for (const c of line.matchAll(/<!--([\s\S]*?)-->/g)) {
112
+ if (/agentmd:(start|end)/.test(c[1])) continue;
113
+ if (HIDDEN_DIRECTIVE.test(c[1])) {
114
+ out.push(finding(file, n, "error", "injection", "An HTML comment carries an instruction. GitHub hides comments from reviewers but the agent reads them — move it into the visible text or delete it."));
115
+ }
116
+ }
117
+
118
+ if (BASE64_BLOB.test(line)) {
119
+ out.push(finding(file, n, "warning", "injection", "A long base64 run — an agent can decode and follow it, a reviewer cannot read it. Link to the file instead."));
120
+ }
121
+
122
+ const p = prose[i];
123
+ if (!p || NEGATED.test(p)) return;
124
+ if (OVERRIDE_PHRASE.test(p)) {
125
+ out.push(finding(file, n, "error", "injection", "Tells the agent to ignore its other instructions — the signature of a prompt injection. Remove it, or quote it if you are documenting the attack."));
126
+ }
127
+ if (PERMISSION_BYPASS.test(p)) {
128
+ out.push(finding(file, n, "error", "injection", "Tells the agent to bypass its permission prompts. An instruction file is untrusted input; it must not grant itself authority."));
129
+ }
130
+ if (PIPE_TO_SHELL.test(p)) {
131
+ out.push(finding(file, n, "warning", "injection", "Pipes a download into a shell. If the agent is meant to run this, pin the script in the repo instead."));
132
+ }
133
+ });
134
+
135
+ // Bloat: only files the agent reads on every prompt. Reference files and
136
+ // skills load on demand, so their size costs nothing until they are used.
137
+ if (alwaysOn) {
138
+ const t = Math.ceil(text.length / 4);
139
+ if (raw.length >= 200 || t > budget * 2) {
140
+ out.push(finding(file, 1, "warning", "bloat", `${raw.length} lines, ~${t} tokens, loaded on every prompt. Move task-specific sections into on-demand rules or skills; keep only what the agent cannot infer from the code.`));
141
+ }
142
+ if (FOSSIL.test(text)) {
143
+ out.push(finding(file, lineOf(text, "This file provides guidance"), "notice", "fossil", "`/init` boilerplate. Generated files that are never edited go stale while the code moves on — review it or delete the line."));
144
+ }
145
+ }
146
+
147
+ return out;
148
+ }
149
+
150
+ module.exports = { lintText, proseLines, finding, lineOf, PIPE_TO_SHELL };
@@ -0,0 +1,213 @@
1
+ /**
2
+ * lint.js
3
+ *
4
+ * Checks the files a coding agent reads before it reads your code: CLAUDE.md,
5
+ * AGENTS.md, Cursor and Copilot rules, Claude skills and hook settings.
6
+ *
7
+ * Nothing else watches these. Secret scanners skip them, skill scanners stop
8
+ * at SKILL.md (snyk/agent-scan#301), and they go straight into the model's
9
+ * context — so a pasted key or a hidden instruction lands exactly where it
10
+ * does the most harm. The rules below come from what 2026 measured:
11
+ *
12
+ * secrets ~0.7% of public instruction files hold a live credential,
13
+ * mostly pasted by hand (Radware, "The New .env", Aug 2026)
14
+ * injection rule files are a prompt-injection channel; a malicious rule
15
+ * can exfiltrate while the agent still produces a correct patch
16
+ * (Aletheia, arXiv 2609.39678)
17
+ * bloat always-on files grow +226% over their lifetime, and noise
18
+ * costs instruction-following accuracy (arXiv 2608.11095)
19
+ * smells 91 of the 100 most-starred repos carry at least one: blind
20
+ * references, lint leakage, init fossils (arXiv 2606.15828)
21
+ *
22
+ * Precision over recall, everywhere. This runs as a CI gate, and one false
23
+ * positive teaches a team to ignore it. Injection rules therefore read prose
24
+ * only — not code fences, not inline code, not lines that warn *against* the
25
+ * thing ("never", "❌") — because security standards quote attacks to teach
26
+ * them, and ours do.
27
+ */
28
+
29
+ "use strict";
30
+
31
+ const fs = require("fs");
32
+ const path = require("path");
33
+ const { TARGETS, ALWAYS_ON_BUDGET, START, END } = require("./agents");
34
+ const { detectVersions, renderPins } = require("./versions");
35
+ const { cmd } = require("./invocation");
36
+ const { lintText, proseLines, finding, lineOf, PIPE_TO_SHELL } = require("./lint-text");
37
+
38
+ const SKIP_DIRS = new Set(["node_modules", ".git", "dist", "build", ".next", "out", "vendor", ".venv", "venv", "target", "coverage"]);
39
+ const MAX_DEPTH = 4;
40
+
41
+ /** Files read on every prompt. Bloat only matters for these. */
42
+ const ALWAYS_ON_NAMES = new Set(["CLAUDE.md", "CLAUDE.local.md", "AGENTS.md", "AGENTS.override.md", "GEMINI.md", ".cursorrules", ".windsurfrules", "copilot-instructions.md"]);
43
+
44
+ /** Instruction files anywhere in the tree (monorepos nest them). */
45
+ const NESTED_NAMES = new Set(["CLAUDE.md", "CLAUDE.local.md", "AGENTS.md", "AGENTS.override.md", "GEMINI.md"]);
46
+
47
+ // ── Discovery ──────────────────────────────────────────────────────────────
48
+
49
+ function walk(dir, depth, visit) {
50
+ let entries;
51
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
52
+ for (const e of entries) {
53
+ const full = path.join(dir, e.name);
54
+ if (e.isDirectory()) {
55
+ if (SKIP_DIRS.has(e.name) || depth >= MAX_DEPTH) continue;
56
+ walk(full, depth + 1, visit);
57
+ } else if (e.isFile()) {
58
+ visit(full, e.name);
59
+ }
60
+ }
61
+ }
62
+
63
+ function listDir(dir, test) {
64
+ try { return fs.readdirSync(dir).filter(test).map((f) => path.join(dir, f)); } catch { return []; }
65
+ }
66
+
67
+ /** Every agent-instruction file in the project, relative paths, deduplicated. */
68
+ function findInstructionFiles(cwd) {
69
+ const found = new Set();
70
+ const add = (abs) => { if (fs.existsSync(abs)) found.add(path.relative(cwd, abs)); };
71
+
72
+ for (const t of Object.values(TARGETS)) add(path.join(cwd, t.file));
73
+ for (const f of [".cursorrules", ".windsurfrules", "CLAUDE.local.md", "AGENTS.override.md"]) add(path.join(cwd, f));
74
+ for (const f of listDir(path.join(cwd, ".claude", "rules"), (n) => n.endsWith(".md"))) add(f);
75
+ for (const f of listDir(path.join(cwd, ".cursor", "rules"), (n) => n.endsWith(".mdc") || n.endsWith(".md"))) add(f);
76
+ for (const f of listDir(path.join(cwd, ".github", "instructions"), (n) => n.endsWith(".md"))) add(f);
77
+ for (const f of listDir(path.join(cwd, ".claude"), (n) => /^settings(\.local)?\.json$/.test(n))) add(f);
78
+
79
+ // Skills, at any depth under .claude/skills — they run scripts.
80
+ walk(path.join(cwd, ".claude", "skills"), 0, (abs, name) => { if (name === "SKILL.md") add(abs); });
81
+ // Nested instruction files in workspaces.
82
+ walk(cwd, 0, (abs, name) => { if (NESTED_NAMES.has(name)) add(abs); });
83
+
84
+ return [...found].sort();
85
+ }
86
+
87
+ const FORMATTER_FILES = [".prettierrc", ".prettierrc.json", ".prettierrc.js", ".prettierrc.cjs", ".prettierrc.yaml", ".prettierrc.yml", "prettier.config.js", "prettier.config.mjs", "biome.json", "biome.jsonc", ".editorconfig", "ruff.toml", ".ruff.toml", "rustfmt.toml", ".rustfmt.toml", "dprint.json", ".clang-format"];
88
+ const LINT_LEAK = /\b(indent(ation)?\s+(with|of|using|is)|\d\s+spaces?\b|tabs?\s+(for|not|over|instead)|semicolons?|trailing commas?|single quotes|double quotes|(max(imum)?\s+)?line length|lines? (under|below|shorter than|no longer than) \d+)\b/i;
89
+
90
+
91
+
92
+ function hasFormatter(cwd) {
93
+ if (FORMATTER_FILES.some((f) => fs.existsSync(path.join(cwd, f)))) return true;
94
+ try {
95
+ const py = fs.readFileSync(path.join(cwd, "pyproject.toml"), "utf-8");
96
+ if (/^\[tool\.(ruff|black)\b/m.test(py)) return true;
97
+ } catch {}
98
+ try {
99
+ const pkg = JSON.parse(fs.readFileSync(path.join(cwd, "package.json"), "utf-8"));
100
+ if (pkg.prettier) return true;
101
+ } catch {}
102
+ return false;
103
+ }
104
+
105
+ /** Hooks are commands the agent runs on its own: check what they execute. */
106
+ function lintSettings(file, text) {
107
+ const out = [];
108
+ let json;
109
+ try { json = JSON.parse(text); } catch { return out; }
110
+ const commands = [];
111
+ const visit = (v) => {
112
+ if (Array.isArray(v)) v.forEach(visit);
113
+ else if (v && typeof v === "object") {
114
+ if (typeof v.command === "string") commands.push(v.command);
115
+ Object.values(v).forEach(visit);
116
+ }
117
+ };
118
+ visit(json.hooks);
119
+ for (const c of commands) {
120
+ if (PIPE_TO_SHELL.test(c)) {
121
+ out.push(finding(file, lineOf(text, c), "error", "injection", "A hook pipes a download straight into a shell. Hooks run without a prompt — pin the script in the repo and run it from there."));
122
+ }
123
+ }
124
+ if (json.permissions && Array.isArray(json.permissions.allow) && json.permissions.allow.some((r) => /^Bash\(\s*\*?\s*\)$|^Bash$/.test(String(r)))) {
125
+ out.push(finding(file, lineOf(text, "\"allow\""), "warning", "permissions", "`Bash` is allowed without a pattern, so any instruction file can make the agent run any command. Scope it, e.g. `Bash(npm test:*)`."));
126
+ }
127
+ return out;
128
+ }
129
+
130
+
131
+ /** Check one instruction file. `ctx` carries the project-level facts. */
132
+ function lintFile(cwd, file, ctx) {
133
+ const abs = path.join(cwd, file);
134
+ let text;
135
+ try { text = fs.readFileSync(abs, "utf-8"); } catch { return []; }
136
+ if (file.endsWith(".json")) return lintSettings(file, text);
137
+
138
+ const out = [];
139
+ const raw = text.split("\n");
140
+ const prose = proseLines(text);
141
+ const base = path.basename(file);
142
+
143
+ // Everything that needs only the text: secrets, injection, bloat, fossils.
144
+ const alwaysOn = ALWAYS_ON_NAMES.has(base) || (file.endsWith(".mdc") && /^alwaysApply:\s*true/m.test(text));
145
+ out.push(...lintText(text, { file, alwaysOn, budget: ALWAYS_ON_BUDGET }));
146
+
147
+ // Blind references: `@file` imports and relative links that point nowhere.
148
+ prose.forEach((line, i) => {
149
+ const targets = [];
150
+ for (const m of line.matchAll(/(?:^|\s)@((?:[\w.-]+\/)*[\w.-]+\.[A-Za-z]{1,6})\b/g)) targets.push(m[1]);
151
+ for (const m of line.matchAll(/\]\((?!https?:|mailto:|#|\/)([^)\s]+)\)/g)) targets.push(m[1].split("#")[0]);
152
+ for (const t of targets) {
153
+ if (!t) continue;
154
+ const resolved = path.resolve(path.dirname(abs), decodeURI(t));
155
+ if (!fs.existsSync(resolved)) {
156
+ out.push(finding(file, i + 1, "warning", "blind-reference", `\`${t}\` does not exist. The agent will search for it, or guess.`));
157
+ }
158
+ }
159
+ });
160
+
161
+ // Lint leakage: formatting rules the formatter already enforces.
162
+ if (ctx.formatter && alwaysOn) {
163
+ prose.forEach((line, i) => {
164
+ if (LINT_LEAK.test(line)) {
165
+ out.push(finding(file, i + 1, "notice", "lint-leakage", "A formatting rule this project's formatter already enforces. It costs tokens on every prompt and changes nothing — delete it."));
166
+ }
167
+ });
168
+ }
169
+
170
+ // Stale pins: the managed block was written for different majors than the
171
+ // project now runs. This is the whole of "keep your agent's context in step
172
+ // with your dependencies" — a check in CI, not a bot.
173
+ const s = text.indexOf(START), e = text.indexOf(END);
174
+ if (s !== -1 && e > s && !file.endsWith(".mdc")) {
175
+ const block = text.slice(s, e);
176
+ const expected = ctx.pinBlock;
177
+ const stale = expected ? !block.includes(expected) : block.includes("### Version pins");
178
+ if (stale) {
179
+ out.push(finding(file, lineOf(text, START), "warning", "stale-pins", `The version pins in the agentmd block do not match the dependencies this project now runs. Run \`${cmd("link")}\`.`));
180
+ }
181
+ }
182
+
183
+ return out;
184
+ }
185
+
186
+ const contextFor = (cwd) => ({ formatter: hasFormatter(cwd), pinBlock: renderPins(detectVersions(cwd)) });
187
+
188
+ /** Lint every instruction file in `cwd`. */
189
+ function lintProject(cwd) {
190
+ const files = findInstructionFiles(cwd);
191
+ const ctx = contextFor(cwd);
192
+ const findings = [];
193
+ for (const f of files) findings.push(...lintFile(cwd, f, ctx));
194
+
195
+ // An org that requires signed context fails lint on any unsigned change.
196
+ // Required lazily: bundle.js depends on this module for file discovery.
197
+ let policy = {};
198
+ try { policy = JSON.parse(fs.readFileSync(path.join(cwd, ".agentmd", "enterprise.json"), "utf-8")); } catch {}
199
+ if (policy && policy.requireSignedContext === true) {
200
+ const r = require("./bundle").verifyBundle(cwd);
201
+ if (!r.valid) {
202
+ const changed = [...r.modified, ...r.unapproved, ...r.missing];
203
+ findings.push(finding(".agentmd/bundle.sig", 1, "error", "unsigned-context",
204
+ `This project requires signed context, and ${r.reason}${changed.length ? `: ${changed.join(", ")}` : ""}. Review, then \`${cmd("bundle sign")}\`.`));
205
+ }
206
+ }
207
+ return { files, findings };
208
+ }
209
+
210
+ /** Lint one file, given relative to `cwd`. Used by the editor hook. */
211
+ const lintPath = (cwd, file) => lintFile(cwd, file, contextFor(cwd));
212
+
213
+ module.exports = { lintProject, lintFile, lintPath, findInstructionFiles, proseLines };
@@ -41,6 +41,14 @@ function resolvePresetPath(cwd, filename) {
41
41
  if (typeof filename !== "string" || filename.length === 0) {
42
42
  throw new Error("Invalid preset filename");
43
43
  }
44
+ // Manifest entries store `.agentmd/presets/<name>.md`, and callers hand
45
+ // that straight in. It used to resolve to `.agentmd/presets/.agentmd/...`,
46
+ // a file that never exists — so `test` found nothing to test and model
47
+ // `review` reviewed against zero standards, silently, on every real
48
+ // install. Strip exactly that prefix; anything else is still checked below.
49
+ const posix = filename.split("\\").join("/");
50
+ const prefix = PRESETS_DIR.split(path.sep).join("/") + "/";
51
+ if (posix.startsWith(prefix)) filename = posix.slice(prefix.length);
44
52
  const dir = path.resolve(getPresetsDir(cwd));
45
53
  const target = path.resolve(dir, filename);
46
54
  if (target !== path.join(dir, path.basename(target)) || !target.startsWith(dir + path.sep)) {