@esneiderbravo/speclaw 0.4.0 → 1.0.1

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 (48) hide show
  1. package/README.md +91 -72
  2. package/dist/cli/commands/index-build.js +12 -3
  3. package/dist/cli/commands/lawbook.js +1 -0
  4. package/dist/cli/commands/laws.js +149 -8
  5. package/dist/cli/commands/owners.js +44 -0
  6. package/dist/cli/commands/query.js +32 -10
  7. package/dist/cli/commands/update.js +39 -0
  8. package/dist/cli/commands/verify.js +8 -0
  9. package/dist/cli/index.js +13 -4
  10. package/dist/modules/compass/budget.js +128 -0
  11. package/dist/modules/compass/db.js +290 -30
  12. package/dist/modules/compass/embed-input.js +28 -0
  13. package/dist/modules/compass/embedder.js +3 -1
  14. package/dist/modules/compass/explore-rich.js +10 -5
  15. package/dist/modules/compass/extract.js +86 -0
  16. package/dist/modules/compass/hybrid.js +318 -0
  17. package/dist/modules/compass/indexer.js +204 -33
  18. package/dist/modules/compass/merkle.js +76 -0
  19. package/dist/modules/compass/pagerank.js +122 -0
  20. package/dist/modules/compass/rank.js +95 -0
  21. package/dist/modules/compass/register.js +8 -4
  22. package/dist/modules/foundation/assets/laws/laws-manifest.json +16 -7
  23. package/dist/modules/foundation/check.js +4 -2
  24. package/dist/modules/foundation/compile-laws.js +210 -0
  25. package/dist/modules/foundation/dialects/agentsmd.js +95 -0
  26. package/dist/modules/foundation/dialects/claude-cursor.js +45 -0
  27. package/dist/modules/foundation/dialects/coderabbit.js +27 -0
  28. package/dist/modules/foundation/dialects/copilot.js +35 -0
  29. package/dist/modules/foundation/dialects/index.js +5 -0
  30. package/dist/modules/foundation/dialects/types.js +58 -0
  31. package/dist/modules/foundation/doctor.js +220 -14
  32. package/dist/modules/foundation/graph.js +7 -3
  33. package/dist/modules/foundation/import-rules.js +67 -0
  34. package/dist/modules/foundation/integrity.js +307 -0
  35. package/dist/modules/foundation/laws-parse.js +131 -0
  36. package/dist/modules/foundation/laws.js +35 -32
  37. package/dist/modules/foundation/lock.js +283 -0
  38. package/dist/modules/foundation/ownership.js +4 -0
  39. package/dist/modules/foundation/scaffold.js +34 -6
  40. package/dist/modules/foundation/scan.js +227 -0
  41. package/dist/modules/foundation/seed-laws.js +263 -0
  42. package/dist/modules/foundation/verify.js +11 -3
  43. package/dist/modules/lawbook/coverage.js +45 -6
  44. package/dist/modules/lawbook/ears.js +417 -0
  45. package/dist/modules/lawbook/engine.js +29 -0
  46. package/dist/modules/lawbook/spec-items.js +4 -1
  47. package/dist/modules/team/owners.js +464 -0
  48. package/package.json +4 -3
@@ -0,0 +1,283 @@
1
+ /**
2
+ * speclaw.lock — committed digests for rule files (never under `.speclaw/`).
3
+ * A gitignored lock would be invisible in PR diffs and would not detect
4
+ * Rules File Backdoor edits. Like package-lock.json / go.sum.
5
+ */
6
+ // Covers: req~speclaw-lock~1
7
+ import crypto from "node:crypto";
8
+ import fs from "node:fs";
9
+ import path from "node:path";
10
+ import { pkgName, pkgVersion } from "../../shared/version.js";
11
+ export const LOCKFILE_NAME = "speclaw.lock";
12
+ export const LOCKFILE_VERSION = 1;
13
+ /** Delimited provenance block excluded from digests (self-reference). */
14
+ export const PROVENANCE_START = "<!-- speclaw:begin-provenance";
15
+ export const PROVENANCE_END = "speclaw:end-provenance -->";
16
+ /** Project-relative path of the committed lockfile. */
17
+ export function lockfilePath(projectPath) {
18
+ return path.join(projectPath, LOCKFILE_NAME);
19
+ }
20
+ /**
21
+ * Canonical bytes for hashing: LF endings, strip provenance, trim EOL spaces,
22
+ * ensure a single trailing newline.
23
+ */
24
+ export function canonicalize(raw) {
25
+ let text = raw.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
26
+ text = stripProvenanceBlock(text);
27
+ text = text
28
+ .split("\n")
29
+ .map((line) => line.replace(/[ \t]+$/g, ""))
30
+ .join("\n");
31
+ if (!text.endsWith("\n"))
32
+ text += "\n";
33
+ else if (text.endsWith("\n\n")) {
34
+ // collapse to exactly one trailing newline
35
+ text = text.replace(/\n+$/g, "\n");
36
+ }
37
+ return text;
38
+ }
39
+ /** Remove speclaw provenance HTML comment blocks. */
40
+ export function stripProvenanceBlock(text) {
41
+ const re = new RegExp(`${escapeRegExp(PROVENANCE_START)}[\\s\\S]*?${escapeRegExp(PROVENANCE_END)}\\n?`, "g");
42
+ return text.replace(re, "");
43
+ }
44
+ function escapeRegExp(s) {
45
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
46
+ }
47
+ /** sha256 digest with `sha256:` prefix. */
48
+ export function digestOf(canonical) {
49
+ const hex = crypto.createHash("sha256").update(canonical, "utf8").digest("hex");
50
+ return `sha256:${hex}`;
51
+ }
52
+ /** Digest raw file text after canonicalization. */
53
+ export function digestText(raw) {
54
+ return digestOf(canonicalize(raw));
55
+ }
56
+ /** Root hash over sorted path → digest pairs. */
57
+ export function rootDigest(files) {
58
+ const paths = Object.keys(files).sort();
59
+ let acc = "";
60
+ for (const p of paths) {
61
+ acc += `${p}\0${files[p].digest}\n`;
62
+ }
63
+ return digestOf(acc);
64
+ }
65
+ /** Read lockfile or null if missing. Throws on unknown version / parse error. */
66
+ export function readLockfile(projectPath) {
67
+ const abs = lockfilePath(projectPath);
68
+ if (!fs.existsSync(abs))
69
+ return null;
70
+ const raw = JSON.parse(fs.readFileSync(abs, "utf8"));
71
+ if (typeof raw.lockfileVersion !== "number") {
72
+ throw new Error("speclaw.lock: missing lockfileVersion");
73
+ }
74
+ if (raw.lockfileVersion > LOCKFILE_VERSION) {
75
+ throw new Error(`speclaw.lock: unsupported lockfileVersion ${raw.lockfileVersion} (max ${LOCKFILE_VERSION})`);
76
+ }
77
+ return {
78
+ lockfileVersion: raw.lockfileVersion,
79
+ generator: String(raw.generator ?? ""),
80
+ algorithm: "sha256",
81
+ root: String(raw.root ?? ""),
82
+ files: raw.files && typeof raw.files === "object" ? raw.files : {},
83
+ symlinks: raw.symlinks && typeof raw.symlinks === "object" ? raw.symlinks : {},
84
+ accepted: Array.isArray(raw.accepted) ? raw.accepted : [],
85
+ };
86
+ }
87
+ /** Write lockfile with stable JSON formatting. */
88
+ export function writeLockfile(projectPath, lock) {
89
+ const abs = lockfilePath(projectPath);
90
+ const body = JSON.stringify(lock, null, 2) + "\n";
91
+ fs.writeFileSync(abs, body);
92
+ }
93
+ /** Build a fresh lock object from file digests + symlinks. */
94
+ export function buildLock(opts) {
95
+ const files = { ...opts.files };
96
+ return {
97
+ lockfileVersion: LOCKFILE_VERSION,
98
+ generator: `${pkgName()}@${pkgVersion()}`,
99
+ algorithm: "sha256",
100
+ root: rootDigest(files),
101
+ files,
102
+ symlinks: { ...(opts.symlinks ?? {}) },
103
+ accepted: [...(opts.accepted ?? [])],
104
+ };
105
+ }
106
+ /** Integrity severity policy for a project-relative path. */
107
+ export function integrityPolicy(relPath) {
108
+ const n = relPath.split("\\").join("/");
109
+ // `.cursor/rules/` mirrors regenerable `ai-specs/` (gitignored) — lock/CI must
110
+ // not treat them as strict committed files; scan when present, never pin.
111
+ if (n === "AGENTS.md" ||
112
+ n === "CLAUDE.md" ||
113
+ n.startsWith(".github/instructions/") ||
114
+ n === ".coderabbit.yaml" ||
115
+ n === ".claude/rules/speclaw") {
116
+ return "strict";
117
+ }
118
+ if (n === "LAWS.md" || n === "docs/compass.md" || n.startsWith("docs/standards/")) {
119
+ return "advisory";
120
+ }
121
+ return "scan-only";
122
+ }
123
+ /** True when a path is an IDE mirror of regenerable (typically gitignored) content. */
124
+ export function isRegenerableIdeMirror(relPath) {
125
+ const n = relPath.split("\\").join("/");
126
+ return (n.startsWith(".cursor/rules/") ||
127
+ n.startsWith(".cursor/skills/") ||
128
+ n.startsWith(".cursor/commands/") ||
129
+ n.startsWith(".claude/skills/") ||
130
+ n.startsWith(".claude/commands/") ||
131
+ n.startsWith("ai-specs/"));
132
+ }
133
+ /** Discover candidate paths under the project for locking / scanning. */
134
+ export function discoverIntegrityPaths(projectPath) {
135
+ const files = [];
136
+ const symlinks = [];
137
+ const addFile = (rel) => {
138
+ const abs = path.join(projectPath, rel);
139
+ if (fs.existsSync(abs) && fs.statSync(abs).isFile())
140
+ files.push(rel.split("\\").join("/"));
141
+ };
142
+ for (const f of ["AGENTS.md", "CLAUDE.md", "LAWS.md", "docs/compass.md", ".coderabbit.yaml"]) {
143
+ addFile(f);
144
+ }
145
+ walkFiles(path.join(projectPath, "docs", "standards"), projectPath, files, (p) => p.endsWith(".md"));
146
+ walkFiles(path.join(projectPath, ".cursor", "rules"), projectPath, files, () => true);
147
+ walkFiles(path.join(projectPath, ".github", "instructions"), projectPath, files, () => true);
148
+ // Outside-pipeline / skills (scan-only)
149
+ for (const f of [".clinerules", ".windsurfrules", "BUGBOT.md", ".cursorrules"])
150
+ addFile(f);
151
+ walkFiles(path.join(projectPath, "ai-specs", "skills"), projectPath, files, (p) => p.endsWith("SKILL.md") || p.endsWith(".md"));
152
+ walkFiles(path.join(projectPath, "ai-specs", "agents"), projectPath, files, (p) => p.endsWith(".md"));
153
+ walkFiles(path.join(projectPath, ".claude", "skills"), projectPath, files, (p) => p.endsWith("SKILL.md") || p.endsWith(".md"));
154
+ const linkRel = ".claude/rules/speclaw";
155
+ const linkAbs = path.join(projectPath, linkRel);
156
+ try {
157
+ const st = fs.lstatSync(linkAbs);
158
+ if (st.isSymbolicLink()) {
159
+ symlinks.push({ path: linkRel, target: fs.readlinkSync(linkAbs) });
160
+ }
161
+ }
162
+ catch {
163
+ /* missing */
164
+ }
165
+ return { files: [...new Set(files)].sort(), symlinks };
166
+ }
167
+ function walkFiles(dir, projectPath, out, pred) {
168
+ if (!fs.existsSync(dir))
169
+ return;
170
+ const stack = [dir];
171
+ while (stack.length) {
172
+ const cur = stack.pop();
173
+ let entries;
174
+ try {
175
+ entries = fs.readdirSync(cur, { withFileTypes: true });
176
+ }
177
+ catch {
178
+ continue;
179
+ }
180
+ for (const e of entries) {
181
+ const full = path.join(cur, e.name);
182
+ if (e.isDirectory())
183
+ stack.push(full);
184
+ else if (e.isFile()) {
185
+ const rel = path.relative(projectPath, full).split(path.sep).join("/");
186
+ if (pred(rel))
187
+ out.push(rel);
188
+ }
189
+ }
190
+ }
191
+ }
192
+ /** Markers for the regenerable map block inside `docs/compass.md` (see compass/map.ts). */
193
+ export const COMPASS_MAP_START = "<!-- speclaw:map:start -->";
194
+ export const COMPASS_MAP_END = "<!-- speclaw:map:end -->";
195
+ /**
196
+ * Strip the regenerable map body between markers so integrity digests stay stable
197
+ * across `speclaw index` (which rewrites the map in CI before verify).
198
+ *
199
+ * @param text - Full docs/compass.md contents.
200
+ * @returns The same text with an empty map body, or `text` if markers are missing.
201
+ */
202
+ export function stripCompassMapBlock(text) {
203
+ const start = text.indexOf(COMPASS_MAP_START);
204
+ const end = text.indexOf(COMPASS_MAP_END);
205
+ if (start < 0 || end < 0 || end < start)
206
+ return text;
207
+ return text.slice(0, start + COMPASS_MAP_START.length) + "\n" + text.slice(end);
208
+ }
209
+ /**
210
+ * Path-specific bytes that feed {@link digestText}: speclaw-owned coderabbit
211
+ * region, regenerable Compass map body stripped, otherwise the file as-is.
212
+ *
213
+ * @param relPath - Project-relative path.
214
+ * @param raw - File contents.
215
+ * @returns Text to canonicalize and hash for this path.
216
+ */
217
+ export function prepareIntegrityText(relPath, raw) {
218
+ const n = relPath.split("\\").join("/");
219
+ if (n === ".coderabbit.yaml")
220
+ return extractSpeclawYamlBlock(raw) ?? raw;
221
+ if (n === "docs/compass.md")
222
+ return stripCompassMapBlock(raw);
223
+ return raw;
224
+ }
225
+ /**
226
+ * Snapshot digests for discovered files with ownership policy.
227
+ * For `.coderabbit.yaml`, digests only the speclaw delimited block when present.
228
+ * For `docs/compass.md`, digests with the regenerable map body stripped.
229
+ */
230
+ export function snapshotLockEntries(projectPath) {
231
+ const { files: paths, symlinks } = discoverIntegrityPaths(projectPath);
232
+ const files = {};
233
+ for (const rel of paths) {
234
+ const ownership = integrityPolicy(rel);
235
+ if (ownership === "scan-only")
236
+ continue; // locked only when previously accepted / explicit
237
+ const abs = path.join(projectPath, rel);
238
+ const raw = prepareIntegrityText(rel, fs.readFileSync(abs, "utf8"));
239
+ files[rel] = { digest: digestText(raw), ownership };
240
+ }
241
+ const symlinkMap = {};
242
+ for (const s of symlinks)
243
+ symlinkMap[s.path] = { target: s.target };
244
+ return { files, symlinks: symlinkMap };
245
+ }
246
+ /** Extract a speclaw-marked region from coderabbit yaml if present. */
247
+ export function extractSpeclawYamlBlock(raw) {
248
+ const m = /# speclaw:begin[\s\S]*?# speclaw:end/.exec(raw);
249
+ if (m)
250
+ return m[0];
251
+ const m2 = /<!-- speclaw:laws:start -->[\s\S]*?<!-- speclaw:laws:end -->/.exec(raw) ??
252
+ /<!-- speclaw:begin-provenance[\s\S]*?speclaw:end-provenance -->/.exec(raw);
253
+ return m2 ? m2[0] : null;
254
+ }
255
+ /** Create or refresh speclaw.lock from the current tree. */
256
+ export function refreshLockfile(projectPath) {
257
+ const prev = (() => {
258
+ try {
259
+ return readLockfile(projectPath);
260
+ }
261
+ catch {
262
+ return null;
263
+ }
264
+ })();
265
+ const { files, symlinks } = snapshotLockEntries(projectPath);
266
+ const lock = buildLock({
267
+ files,
268
+ symlinks,
269
+ accepted: prev?.accepted ?? [],
270
+ });
271
+ writeLockfile(projectPath, lock);
272
+ return lock;
273
+ }
274
+ /** Render a data-only provenance HTML comment (no imperatives). */
275
+ export function provenanceBlock(opts) {
276
+ const laws = (opts.lawIds ?? []).map((l) => ` law: ${l}`).join("\n");
277
+ return (`${PROVENANCE_START}\n` +
278
+ (laws ? laws + "\n" : "") +
279
+ (opts.source ? ` source: ${opts.source}\n` : "") +
280
+ ` digest: ${opts.digest}\n` +
281
+ ` generator: ${pkgName()}@${pkgVersion()}\n` +
282
+ `speclaw:end-provenance -->\n`);
283
+ }
@@ -1,6 +1,10 @@
1
1
  // Which scaffolded files speclaw owns vs the user owns. `speclaw update` uses
2
2
  // this split to decide what it may overwrite automatically (managed) and what it
3
3
  // must leave to the user's agent via a prompt (personalized).
4
+ //
5
+ // Integrity severity (`strict` / `advisory` / `scan-only` in lock.ts) is a
6
+ // separate axis: CLAUDE.md / AGENTS.md stay PERSONALIZED for update, but are
7
+ // `strict` for `verifyIntegrity` digest mismatches.
4
8
  /**
5
9
  * Project-relative trees that hold speclaw's workflow machinery. The user is not
6
10
  * meant to edit these, so `speclaw update` overwrites them with the current
@@ -8,8 +8,11 @@ import { installWorkflow } from "../lawbook/register.js";
8
8
  import { installPack, loadPacks } from "../tools/packs.js";
9
9
  import { readManifest, writeManifest } from "../../shared/manifest.js";
10
10
  import { pkgVersion } from "../../shared/version.js";
11
- import { mergeSeedLaws, readLawManifest, seedManifest, writeLawManifest, } from "./laws.js";
11
+ import { mergeSeedLaws, readLawManifest, seedManifestFor, writeLawManifest, } from "./laws.js";
12
12
  import { installHooks } from "./hooks.js";
13
+ import { compileLaws } from "./compile-laws.js";
14
+ import { refreshLockfile } from "./lock.js";
15
+ import { refreshOwnersIfConfigured } from "../team/owners.js";
13
16
  const ASSETS = assetsDir(import.meta.url);
14
17
  // Every {{var}} the foundation templates may reference. Ones the agent didn't
15
18
  // provide default to empty so a bare `scaffold` never leaves a raw {{tag}}.
@@ -25,19 +28,22 @@ const FOUNDATION_DEFAULTS = {
25
28
  documentation_extra: "",
26
29
  };
27
30
  /**
28
- * Ensure the project has a law manifest. Missing → seed. Present append any
29
- * shipped seed law whose `id` is absent (never overwrite a curated entry).
31
+ * Ensure the project has a law manifest. Missing → adapted seed for this tree.
32
+ * Present merge the adapted catalog (never overwrite a curated entry; prune
33
+ * unmodified dogfood laws whose required paths are absent).
30
34
  */
31
35
  function ensureLawManifest(projectPath, report) {
32
36
  const existing = readLawManifest(projectPath);
33
37
  if (!existing) {
34
- const seed = seedManifest();
38
+ const seed = seedManifestFor(projectPath);
35
39
  writeLawManifest(projectPath, seed);
36
40
  report.written.push(path.join(projectPath, ".speclaw", "laws-manifest.json"));
37
41
  return seed;
38
42
  }
39
- const { manifest, added } = mergeSeedLaws(existing);
40
- if (added.length > 0) {
43
+ const { manifest, added, removed } = mergeSeedLaws(existing, projectPath);
44
+ if (added.length > 0 ||
45
+ removed.length > 0 ||
46
+ JSON.stringify(manifest.laws) !== JSON.stringify(existing.laws)) {
41
47
  writeLawManifest(projectPath, manifest);
42
48
  report.written.push(path.join(projectPath, ".speclaw", "laws-manifest.json"));
43
49
  }
@@ -150,12 +156,34 @@ export function scaffold(projectPath, profile, packNames, agents = [], opts = {}
150
156
  // configured. The seam is the manifest: check-dispatcher enforces `path` laws;
151
157
  // executable-laws will extend the same manifest with more backends.
152
158
  const lawManifest = ensureLawManifest(projectPath, report);
159
+ try {
160
+ compileLaws({ projectPath, agents, writeManifest: false });
161
+ }
162
+ catch {
163
+ // Compilation must not fail scaffold; `speclaw laws compile` surfaces errors.
164
+ }
165
+ // Covers: req~lock-refresh-update~1
166
+ try {
167
+ refreshLockfile(projectPath);
168
+ }
169
+ catch {
170
+ // Lock refresh must not fail scaffold; `speclaw laws lock` surfaces errors.
171
+ }
153
172
  ensureVerifyWorkflow(projectPath, report);
154
173
  report.hooks = installHooks(projectPath, agents, lawManifest, report, {
155
174
  baselines: managedOpts.baselines,
156
175
  backup: managedOpts.backup,
157
176
  record,
158
177
  });
178
+ // Refresh CODEOWNERS managed block when the project declared team.owners —
179
+ // never invent owners when the key is absent.
180
+ try {
181
+ refreshOwnersIfConfigured(projectPath);
182
+ }
183
+ catch (err) {
184
+ // Surface as a soft note — invalid tokens should not abort scaffold/update.
185
+ report.skipped.push(`owners refresh skipped: ${err.message}`);
186
+ }
159
187
  // Record what was installed so `speclaw update` can re-apply these packs and
160
188
  // gate feature migrations by version, plus the managed-file baselines that let
161
189
  // a later update tell user edits from stale files.
@@ -0,0 +1,227 @@
1
+ /**
2
+ * Prompt-injection scanners for rule / skill files.
3
+ * Complements digests: hashes catch any edit; scanners catch known payloads.
4
+ */
5
+ // Covers: req~injection-scan~1
6
+ import fs from "node:fs";
7
+ import path from "node:path";
8
+ const OVERRIDE = /\b(ignore\s+previous\s+instructions|disregard\s+the\s+above|you\s+are\s+now|new\s+system\s+prompt|ignora\s+las\s+instrucciones\s+anteriores)\b/i;
9
+ const SHELL = /\b(curl\s+[^\n|]*\|\s*(ba)?sh|bash\s+-c|Invoke-Expression|iex\b|eval\s*\(|npm\s+run\s+[^\s]+.*\|\s*sh)\b/i;
10
+ const EXFIL = /\b(send\s+(this|the)\s+(repo|code|contents?)\s+to|exfiltrat|upload\s+to\s+https?:\/\/|read\s+(\.env|~\/\.ssh|~\/\.aws|\.npmrc)|exfiltra)\b/i;
11
+ const URL_RE = /https?:\/\/[^\s)>\]]+/gi;
12
+ const ZERO_WIDTH = /[\u200B-\u200D\uFEFF\u202A-\u202E]/;
13
+ const IMPERATIVE_HTML = /<!--[\s\S]{0,200}\b(run|execute|ignore|disregard|curl|bash|send)\b[\s\S]{0,200}-->/i;
14
+ /**
15
+ * Normalize text before detection (NFKC, strip zero-width/bidi, fold common
16
+ * Cyrillic lookalikes, collapse whitespace).
17
+ */
18
+ export function normalizeForScan(text) {
19
+ let t = text.normalize("NFKC");
20
+ t = t.replace(ZERO_WIDTH, "");
21
+ t = t.replace(/[\u0400-\u04FF]/g, (ch) => CYRILLIC_FOLD[ch] ?? ch);
22
+ t = t.replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&amp;/g, "&");
23
+ t = t.replace(/\\\*|\\_|\\`/g, (m) => m.slice(1));
24
+ t = t.replace(/\s+/g, " ").trim();
25
+ return t;
26
+ }
27
+ const CYRILLIC_FOLD = {
28
+ а: "a",
29
+ е: "e",
30
+ о: "o",
31
+ р: "p",
32
+ с: "c",
33
+ у: "y",
34
+ х: "x",
35
+ А: "A",
36
+ Е: "E",
37
+ О: "O",
38
+ Р: "P",
39
+ С: "C",
40
+ У: "Y",
41
+ Х: "X",
42
+ };
43
+ /** Scan one file's raw contents; returns findings with original line numbers. */
44
+ export function scanText(relPath, raw, opts = {}) {
45
+ const suppressions = opts.suppressions ?? [];
46
+ const allow = new Set((opts.allowHosts ?? []).map((h) => h.toLowerCase()));
47
+ const out = [];
48
+ const lines = raw.split(/\r?\n/);
49
+ const push = (f) => {
50
+ if (suppressions.some((s) => s.detector === f.detector && matchPath(s.path, f.path) && s.note.trim().length > 0)) {
51
+ return;
52
+ }
53
+ out.push(f);
54
+ };
55
+ if (ZERO_WIDTH.test(raw)) {
56
+ push({
57
+ detector: "injection/hidden-text",
58
+ severity: "error",
59
+ path: relPath,
60
+ line: 1,
61
+ excerpt: "zero-width or bidi control characters",
62
+ message: "Hidden / bidi control characters in a rule file.",
63
+ });
64
+ }
65
+ for (let i = 0; i < lines.length; i++) {
66
+ const line = lines[i];
67
+ const norm = normalizeForScan(line);
68
+ const ln = i + 1;
69
+ if (OVERRIDE.test(norm) || OVERRIDE.test(line)) {
70
+ push({
71
+ detector: "injection/instruction-override",
72
+ severity: "error",
73
+ path: relPath,
74
+ line: ln,
75
+ excerpt: clip(line),
76
+ message: "Instruction-override phrasing in a rule file.",
77
+ });
78
+ }
79
+ if (SHELL.test(norm) || SHELL.test(line)) {
80
+ push({
81
+ detector: "injection/shell-execution",
82
+ severity: "error",
83
+ path: relPath,
84
+ line: ln,
85
+ excerpt: clip(line),
86
+ message: "Shell-execution instruction in a rule file.",
87
+ });
88
+ }
89
+ if (EXFIL.test(norm) || EXFIL.test(line)) {
90
+ push({
91
+ detector: "injection/exfiltration",
92
+ severity: "error",
93
+ path: relPath,
94
+ line: ln,
95
+ excerpt: clip(line),
96
+ message: "Possible exfiltration instruction in a rule file.",
97
+ });
98
+ }
99
+ for (const m of line.matchAll(URL_RE)) {
100
+ try {
101
+ const host = new URL(m[0]).hostname.toLowerCase();
102
+ if (allow.size > 0 && !allow.has(host) && !host.endsWith(".github.com")) {
103
+ push({
104
+ detector: "injection/unallowlisted-url",
105
+ severity: "warn",
106
+ path: relPath,
107
+ line: ln,
108
+ excerpt: clip(m[0]),
109
+ message: `URL host not on allowlist: ${host}`,
110
+ });
111
+ }
112
+ }
113
+ catch {
114
+ /* ignore */
115
+ }
116
+ }
117
+ }
118
+ if (IMPERATIVE_HTML.test(raw)) {
119
+ const idx = raw.search(IMPERATIVE_HTML);
120
+ const snippet = raw.slice(Math.max(0, idx), idx + 220);
121
+ // Data-only speclaw markers (map/laws/provenance) are not agent instructions.
122
+ if (!/<!--\s*speclaw:/.test(snippet)) {
123
+ const line = lineOf(raw, idx);
124
+ push({
125
+ detector: "injection/imperative-html-comment",
126
+ severity: "warn",
127
+ path: relPath,
128
+ line,
129
+ excerpt: clip(raw.slice(idx, idx + 120)),
130
+ message: "HTML comment contains imperative language (visible to some agents).",
131
+ });
132
+ }
133
+ }
134
+ // External @import / @~/ / @C:\…
135
+ // External @import / @~/ / @C:\…
136
+ for (let i = 0; i < lines.length; i++) {
137
+ const line = lines[i];
138
+ const m = /@([~/][^\s)\]>"']+)/.exec(line) ??
139
+ /@([A-Za-z]:[^\s)\]>"']+)/.exec(line) ??
140
+ /@import\s+["']([^"']+)["']/.exec(line);
141
+ if (!m)
142
+ continue;
143
+ const target = m[1];
144
+ push({
145
+ detector: "injection/external-import",
146
+ severity: "warn",
147
+ path: relPath,
148
+ line: i + 1,
149
+ excerpt: clip(line),
150
+ message: `Import may resolve outside the working directory: ${target}`,
151
+ });
152
+ }
153
+ // Frontmatter ↔ body mismatch (skills)
154
+ const fm = /^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/.exec(raw);
155
+ if (fm) {
156
+ const front = fm[1];
157
+ const body = fm[2];
158
+ const frontDesc = /(?:^|\n)description:\s*["']?([^\n"']+)/i.exec(front)?.[1] ?? "";
159
+ if (frontDesc && SHELL.test(body) && !SHELL.test(frontDesc)) {
160
+ push({
161
+ detector: "injection/manifest-prose-mismatch",
162
+ severity: "warn",
163
+ path: relPath,
164
+ line: 1,
165
+ excerpt: clip(frontDesc),
166
+ message: "Skill frontmatter description and body disagree on shell risk.",
167
+ });
168
+ }
169
+ }
170
+ return out;
171
+ }
172
+ function matchPath(pattern, file) {
173
+ if (pattern === file)
174
+ return true;
175
+ if (pattern.endsWith("/**")) {
176
+ const prefix = pattern.slice(0, -3);
177
+ return file === prefix || file.startsWith(prefix + "/");
178
+ }
179
+ if (pattern.includes("*")) {
180
+ const re = new RegExp("^" + pattern.split("*").map(escapeRegExp).join(".*") + "$");
181
+ return re.test(file);
182
+ }
183
+ return false;
184
+ }
185
+ function escapeRegExp(s) {
186
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
187
+ }
188
+ function clip(s, n = 120) {
189
+ const t = s.trim();
190
+ return t.length <= n ? t : t.slice(0, n - 1) + "…";
191
+ }
192
+ function lineOf(text, index) {
193
+ if (index <= 0)
194
+ return 1;
195
+ return text.slice(0, index).split(/\r?\n/).length;
196
+ }
197
+ /** Load optional scan suppressions from lawbook/config.yaml (line-oriented). */
198
+ export function loadScanSuppressions(projectPath) {
199
+ const cfgPath = path.join(projectPath, "lawbook", "config.yaml");
200
+ if (!fs.existsSync(cfgPath))
201
+ return [];
202
+ const text = fs.readFileSync(cfgPath, "utf8");
203
+ // Very small subset: repeated blocks under scanSuppressions are not fully
204
+ // parsed; support a flat JSON-ish list in a comment-free line for v1:
205
+ // scanSuppressions: [{"detector":"…","path":"…","note":"…"}]
206
+ const m = /^\s*scanSuppressions\s*:\s*(\[[\s\S]*?\])\s*$/m.exec(text);
207
+ if (!m)
208
+ return [];
209
+ try {
210
+ const arr = JSON.parse(m[1]);
211
+ return arr.filter((s) => s.detector && s.path && s.note);
212
+ }
213
+ catch {
214
+ return [];
215
+ }
216
+ }
217
+ /** Scan a list of project-relative files that exist. */
218
+ export function scanPaths(projectPath, relPaths, opts = {}) {
219
+ const out = [];
220
+ for (const rel of relPaths) {
221
+ const abs = path.join(projectPath, rel);
222
+ if (!fs.existsSync(abs) || !fs.statSync(abs).isFile())
223
+ continue;
224
+ out.push(...scanText(rel, fs.readFileSync(abs, "utf8"), opts));
225
+ }
226
+ return out;
227
+ }