@foldrun/core 1.1.3 → 1.3.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,784 @@
1
+ // The scanner: rules about what a workspace's files could leak or let in.
2
+ //
3
+ // `foldrun check` already asks "will this run?" — a tool that exists, a step
4
+ // that names a real agent. This asks the other question a linter for agents
5
+ // has to: "is this safe to run?" A key pasted into a prompt, a knowledge file
6
+ // carrying hidden instructions, an agent that reads the open web, holds a
7
+ // secret and can post anywhere. Each is valid markdown; each is a finding.
8
+ //
9
+ // Shaped like eslint, gitleaks and trivy rather than like the rest of check:
10
+ // · every finding names a stable rule id (`secret/hardcoded`), so CI can
11
+ // track it, a person can ignore it with a reason, and SARIF can carry it;
12
+ // · pure — files in, findings out. No fs, no network, no model. The CLI,
13
+ // the deploy and the dashboard hand it the same {path, content} list and
14
+ // get the same answer;
15
+ // · a finding carries a fingerprint (rule, file, the line's text — not its
16
+ // number), so a baseline survives an edit above it.
17
+ //
18
+ // Every rule here is advisory today (`warn` or `info`). A new rule that fails
19
+ // a check, or refuses a deploy, that passed yesterday is a behaviour change;
20
+ // `--fail-on warn` and `--security` are how a CI opts in now.
21
+ //
22
+ // Ignoring one: a comment on the line before (or the same line) —
23
+ // <!-- foldrun-ignore secret/hardcoded: a revoked key kept as an example -->
24
+ // # foldrun-ignore supply-chain/pipe-to-shell: the vendor's pinned installer
25
+ // or `foldrun-ignore-file <rule>: <reason>` anywhere in a file for all of it.
26
+ // The reason is required: an ignore without one ignores nothing and is itself
27
+ // reported, because "why is this allowed?" is the question a reviewer asks.
28
+ import crypto from "node:crypto";
29
+ import matter from "gray-matter";
30
+ import { refNames } from "./refs.js";
31
+ import { resolveNetwork } from "./network.js";
32
+ export const OWASP_URL = "https://genai.owasp.org/resource/owasp-top-10-for-agentic-applications-for-2026";
33
+ export const SCAN_RULES = [
34
+ {
35
+ id: "secret/hardcoded",
36
+ category: "secret",
37
+ level: "warn",
38
+ title: "A credential written into a file",
39
+ help: "Anything in these files is deployed, versioned, readable by every agent and shown to the model. " +
40
+ "Put it in the vault (`foldrun secrets set NAME`), write `${NAME}` where it is used, and bind it to its sites " +
41
+ "(`foldrun secrets sites NAME api.example.com`) so it never enters a sandbox. Then rotate the key — it has already been copied.",
42
+ owasp: ["ASI03"],
43
+ },
44
+ {
45
+ id: "content/hidden-unicode",
46
+ category: "content",
47
+ level: "warn",
48
+ title: "Invisible characters that can hide instructions",
49
+ help: "Zero-width, bidirectional-override and Unicode tag characters render as nothing to a person and as text to a model — " +
50
+ "the standard way to smuggle an instruction past a reviewer. Delete them; nothing legitimate in a prompt needs them.",
51
+ owasp: ["ASI01", "ASI04", "ASI06"],
52
+ },
53
+ {
54
+ id: "content/injection-phrase",
55
+ category: "content",
56
+ level: "warn",
57
+ title: "Text that tries to override an agent's instructions",
58
+ help: "Knowledge, memory, skills and tool descriptions are read as data by every agent that loads them. " +
59
+ "A line like \"ignore previous instructions\" there is either an attack that arrived with the content or a copy-paste " +
60
+ "that will confuse the model. Remove it, or quote it as an example inside a fenced block.",
61
+ owasp: ["ASI01", "ASI06"],
62
+ },
63
+ {
64
+ id: "content/encoded-blob",
65
+ category: "content",
66
+ level: "info",
67
+ title: "A long encoded block in a prompt or document",
68
+ help: "A few hundred characters of base64 in markdown is unreadable to a reviewer and readable to a model. " +
69
+ "If it is data, move it to a file under knowledge/ or storage/ and link it; if it is an image, reference the file.",
70
+ owasp: ["ASI01", "ASI04"],
71
+ },
72
+ {
73
+ id: "security/lethal-trifecta",
74
+ category: "security",
75
+ level: "warn",
76
+ title: "Reads untrusted content, holds private data and can send it out",
77
+ help: "An agent with all three can be told — by any page it reads — to send what it holds anywhere it can reach. " +
78
+ "Take one leg away: take it off the open network (`network: { level: trusted, allow: [the sites it needs] }` — a bare list only adds sites, " +
79
+ "and an agent that reads the web stays open), split the reading and the sending into two agents, " +
80
+ "or guard its calls with a `hooks:` gate. An approval (`!`) on every outward step is the other answer.",
81
+ owasp: ["ASI01", "ASI02", "ASI03"],
82
+ },
83
+ {
84
+ id: "security/subagent-wider",
85
+ category: "security",
86
+ level: "info",
87
+ title: "A sub-agent declares tools its parent does not have",
88
+ help: "A sub-agent never gets more than its parent, so those tools are silently dropped inside the delegation — " +
89
+ "and if that rule ever changed, they would be a privilege escalation. Grant them to the parent or remove them.",
90
+ owasp: ["ASI03"],
91
+ },
92
+ {
93
+ id: "supply-chain/pipe-to-shell",
94
+ category: "supply-chain",
95
+ level: "warn",
96
+ title: "A download piped straight into a shell",
97
+ help: "`curl … | sh` runs whatever the server returns today, unreviewed, in the sandbox that holds this workspace. " +
98
+ "Declare the package under `runtime: system:` or `packages:`, or download, verify a checksum, then run.",
99
+ owasp: ["ASI04", "ASI05"],
100
+ },
101
+ {
102
+ id: "supply-chain/unpinned-mcp",
103
+ category: "supply-chain",
104
+ level: "warn",
105
+ title: "An MCP server fetched at its latest version on every run",
106
+ help: "`npx some-server` (or `@latest`) installs whatever was published last — a compromised release reaches every run the moment it ships. " +
107
+ "Pin it: `npx some-server@1.4.2`.",
108
+ owasp: ["ASI04"],
109
+ },
110
+ {
111
+ id: "supply-chain/unpinned-package",
112
+ category: "supply-chain",
113
+ level: "info",
114
+ title: "A runtime package with no version",
115
+ help: "An unpinned package is a different program after its next release. Pin a range (`pandas>=2,<3`, `lodash@^4`) " +
116
+ "so a run tomorrow uses what was tested today.",
117
+ owasp: ["ASI04"],
118
+ },
119
+ {
120
+ id: "config/unknown-field",
121
+ category: "config",
122
+ level: "warn",
123
+ title: "A frontmatter field foldrun does not read — likely a typo",
124
+ help: "A misspelt field is not an error to YAML, so it is silently ignored: `tolls: [web]` leaves the agent with no tools. " +
125
+ "Use the field the message suggests.",
126
+ },
127
+ {
128
+ id: "check/ignore-needs-reason",
129
+ category: "config",
130
+ level: "warn",
131
+ title: "A foldrun-ignore with no reason",
132
+ help: "Write why after a colon: `foldrun-ignore secret/hardcoded: revoked key kept as an example`. Without one it ignores nothing.",
133
+ },
134
+ ];
135
+ /**
136
+ * The rules `foldrun check` and the deploy have always had — "will this
137
+ * run?" — given ids so a finding from them can be filtered, ignored,
138
+ * baselined and carried in SARIF like the scanner's. Coarse on purpose, one
139
+ * per kind of file; the message says exactly what is wrong. Their level is
140
+ * each problem's own, so it is not fixed here.
141
+ */
142
+ export const CHECK_RULES = [
143
+ { id: "agent/config", category: "config", title: "An agent's frontmatter or references are wrong", help: "A field foldrun cannot read, or a tool, skill, colleague or hook it names that does not exist. The message says which and what to write." },
144
+ { id: "flow/config", category: "config", title: "A flow's steps or options are wrong", help: "A step naming no agent, an option value the parser cannot read, a verify: written as prose, or an arrangement that cannot have what its instruction asks for." },
145
+ { id: "flow/outward-ungated", category: "security", title: "A step that acts outside the workspace with no check and no approval", help: "Give the step a verify: that reads what it did, or an approval (`!`), so a wrong post or send is caught before it repeats.", owasp: ["ASI02", "ASI09"] },
146
+ { id: "tool/config", category: "config", title: "A tool's definition or program is wrong", help: "A run: program that does not exist, a package it imports that nothing declares, or a leftover type: field." },
147
+ { id: "skill/config", category: "config", title: "A skill does not follow the skill format", help: "SKILL.md needs a name and a description the model can choose it by." },
148
+ { id: "eval/config", category: "config", title: "An eval names nothing, or has no cases", help: "An eval names one agent or flow that exists and holds at least one case." },
149
+ { id: "hook/config", category: "config", title: "A hook is not a valid hook, or a hooks: name resolves to nothing", help: "hooks/<name>/hook.md with on:, and run.mjs beside it; a run refuses a step whose hook is missing rather than run it unguarded." },
150
+ { id: "knowledge/okf", category: "config", title: "A knowledge or memory document is not a conformant OKF bundle entry", help: "Each document needs a type:; dates are YYYY-MM-DD; stale documents are flagged so an agent does not treat them as current." },
151
+ { id: "workspace/config", category: "config", title: "A workspace or account setting cannot be read", help: "AGENTS.md defaults — limits:, network:, foldrun_version: — that would leave every agent on the level above, and paths a deploy refuses." },
152
+ { id: "library/config", category: "config", title: "A library entry is wrong", help: "The same rules as in a workspace, for the account's shared library." },
153
+ { id: "paths/workspace-spelling", category: "config", title: "An older spelling of a workspace path", help: "`../../storage/x` still works; `workspace/storage/x` reads the same in every tool, script and check." },
154
+ ];
155
+ /** The rule id of a check or deploy problem, from where it was found and
156
+ * the flow lint's tag when it has one. */
157
+ export function ruleForWhere(where, kind) {
158
+ if (kind === "outward")
159
+ return "flow/outward-ungated";
160
+ if (kind === "spelling")
161
+ return "paths/workspace-spelling";
162
+ if (/(?:^|\/)(?:knowledge|memory)\//.test(where))
163
+ return "knowledge/okf";
164
+ const lib = where.startsWith("library/");
165
+ const top = where.replace(/^library\//, "").split(/[/: ]/)[0] ?? "";
166
+ const byTop = {
167
+ agents: "agent/config", flows: "flow/config", tools: "tool/config", skills: "skill/config",
168
+ ".agents": "skill/config", ".claude": "agent/config", evals: "eval/config", hooks: "hook/config",
169
+ };
170
+ if (byTop[top])
171
+ return byTop[top];
172
+ return lib || top === "library" ? "library/config" : "workspace/config";
173
+ }
174
+ const RULE_BY_ID = new Map([...SCAN_RULES, ...CHECK_RULES].map((r) => [r.id, r]));
175
+ /** The rule behind an id — the scanner's or check's own — or undefined. */
176
+ export function scanRule(id) {
177
+ return RULE_BY_ID.get(id);
178
+ }
179
+ /** Where a rule's docs live — one page, an anchor per rule. */
180
+ export function ruleDocsUrl(id) {
181
+ return `https://docs.foldrun.io/checks#${id.replace(/\//g, "-")}`;
182
+ }
183
+ // ── fingerprints ───────────────────────────────────────────────────────────
184
+ /** rule + file + the text that was flagged, normalised. Not the line number:
185
+ * a baseline must survive a paragraph added above the finding. */
186
+ export function findingFingerprint(rule, file, text) {
187
+ return crypto.createHash("sha256").update(`${rule}\0${file}\0${text.trim().replace(/\s+/g, " ")}`).digest("hex").slice(0, 16);
188
+ }
189
+ // ── secrets ────────────────────────────────────────────────────────────────
190
+ /** Shapes of real credentials, from the issuers' documented formats — the
191
+ * same families gitleaks' default rules cover for the services agents use. */
192
+ const SECRET_SHAPES = [
193
+ [/\bsk-ant-[A-Za-z0-9_-]{20,}/g, "an Anthropic key"],
194
+ [/\bsk-or-v1-[a-f0-9]{48,}\b/g, "an OpenRouter key"],
195
+ [/\bsk-(?:proj-|svcacct-|admin-)?[A-Za-z0-9_-]{32,}/g, "an OpenAI-style API key"],
196
+ [/\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/g, "an AWS access key"],
197
+ [/\bgh[pousr]_[A-Za-z0-9]{36,}\b/g, "a GitHub token"],
198
+ [/\bgithub_pat_[A-Za-z0-9_]{50,}\b/g, "a GitHub token"],
199
+ [/\bglpat-[A-Za-z0-9_-]{20,}\b/g, "a GitLab token"],
200
+ [/\bxox[abposr]-[A-Za-z0-9-]{10,}/g, "a Slack token"],
201
+ [/https:\/\/hooks\.slack\.com\/services\/T[A-Z0-9]+\/B[A-Z0-9]+\/[A-Za-z0-9]{20,}/g, "a Slack webhook URL"],
202
+ [/https:\/\/(?:ptb\.|canary\.)?discord(?:app)?\.com\/api\/webhooks\/\d+\/[A-Za-z0-9_-]{40,}/g, "a Discord webhook URL"],
203
+ [/\bAIza[0-9A-Za-z_-]{35}\b/g, "a Google API key"],
204
+ [/\bGOCSPX-[A-Za-z0-9_-]{24,}/g, "a Google OAuth client secret"],
205
+ [/\b(?:sk|rk)_(?:live|test)_[A-Za-z0-9]{20,}/g, "a Stripe key"],
206
+ [/\bwhsec_[A-Za-z0-9+/=]{24,}/g, "a webhook signing secret"],
207
+ [/\bSG\.[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{40,}\b/g, "a SendGrid key"],
208
+ [/\bSK[0-9a-f]{32}\b/g, "a Twilio API key"],
209
+ [/\b\d{8,10}:AA[A-Za-z0-9_-]{33}\b/g, "a Telegram bot token"],
210
+ [/\bnpm_[A-Za-z0-9]{36}\b/g, "an npm token"],
211
+ [/\bhf_[A-Za-z0-9]{34,}\b/g, "a Hugging Face token"],
212
+ [/\blin_api_[A-Za-z0-9]{40}\b/g, "a Linear key"],
213
+ [/\bdop_v1_[a-f0-9]{64}\b/g, "a DigitalOcean token"],
214
+ [/\bshp(?:at|ca|pa|ss)_[a-fA-F0-9]{32}\b/g, "a Shopify token"],
215
+ [/\bre_[A-Za-z0-9]{8,}_[A-Za-z0-9]{16,}\b/g, "a Resend key"],
216
+ [/\beyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/g, "a JSON web token"],
217
+ [/-----BEGIN (?:[A-Z]+ )?PRIVATE KEY-----/g, "a private key"],
218
+ ];
219
+ /** `api_key: abc…`, `"token": "…"`, `PASSWORD=…` — a named credential with a
220
+ * literal value. The value test is strict on purpose: a generic rule that
221
+ * cries wolf on `token: ${API_TOKEN}` gets the whole scanner ignored. */
222
+ const ASSIGNED = /\b([A-Za-z0-9_]*(?:api[_-]?key|apikey|secret|client[_-]?secret|access[_-]?token|auth[_-]?token|bearer[_-]?token|token|passwd|password|private[_-]?key))["']?\s*[:=]\s*["']?([A-Za-z0-9_\-+/=.]{16,})["']?/gi;
223
+ const BEARER = /\bBearer\s+([A-Za-z0-9_\-.=+/]{20,})/g;
224
+ const URL_PASSWORD = /\b[a-z][a-z0-9+.-]*:\/\/[^\s/:@'"]+:([^\s@/'"]{6,})@[^\s'"]+/gi;
225
+ const PLACEHOLDER = /example|sample|dummy|placeholder|changeme|your[_-]|xxxx|\*\*\*\*|<[^>]*>|\.\.\.|redacted|insert|replace|todo|fake|test[_-]?key|not[_-]?a[_-]?real/i;
226
+ /** Shannon entropy, bits per character. */
227
+ function entropy(s) {
228
+ const counts = new Map();
229
+ for (const ch of s)
230
+ counts.set(ch, (counts.get(ch) ?? 0) + 1);
231
+ let h = 0;
232
+ for (const n of counts.values()) {
233
+ const p = n / s.length;
234
+ h -= p * Math.log2(p);
235
+ }
236
+ return h;
237
+ }
238
+ /** A name in code — `x25519_private_key`, `self.session_token`,
239
+ * `MLKEM768X25519PrivateKey` — rather than random characters. Split on
240
+ * separators and case and digit boundaries: an identifier comes apart into
241
+ * words, a key into fragments. */
242
+ function identifierLike(value) {
243
+ if (!/^[A-Za-z_$][\w$.-]*$/.test(value))
244
+ return false;
245
+ const parts = value.split(/[_.$-]+|(?<=[a-z])(?=[A-Z])|(?<=[A-Za-z])(?=\d)|(?<=\d)(?=[A-Za-z])/).filter(Boolean);
246
+ if (parts.length < 2)
247
+ return false;
248
+ const words = parts.filter((p) => /^[A-Za-z]+$/.test(p));
249
+ if (!parts.every((p) => /^[A-Za-z]+$/.test(p) || /^\d{1,5}$/.test(p)))
250
+ return false;
251
+ const long = words.filter((w) => w.length >= 3 && /[aeiouy]/i.test(w));
252
+ return long.length >= 2 && long.length >= words.length / 2;
253
+ }
254
+ /** Looks like a real credential rather than a name, a reference or a sample. */
255
+ function credentialLike(value) {
256
+ if (/^\$\{?[A-Za-z_]/.test(value))
257
+ return false; // ${NAME}, $NAME
258
+ if (PLACEHOLDER.test(value))
259
+ return false;
260
+ if (/^(?:true|false|null|none|required|optional)$/i.test(value))
261
+ return false;
262
+ // An identifier, a dotted path or a file name is a reference to a value.
263
+ if (/^[a-z_][a-z0-9_]*$/i.test(value) && !/\d/.test(value))
264
+ return false;
265
+ if (identifierLike(value))
266
+ return false;
267
+ if (!/[0-9]/.test(value) || !/[A-Za-z]/.test(value))
268
+ return false;
269
+ if (/^(.)\1+$/.test(value))
270
+ return false;
271
+ return entropy(value) >= 3.5;
272
+ }
273
+ /** What a finding may show of a secret: enough to find it, not to use it. */
274
+ export function redactSecret(secret) {
275
+ return secret.length <= 8 ? "****" : `${secret.slice(0, 4)}…${secret.slice(-2)}`;
276
+ }
277
+ /** Every credential in a piece of text, as [value, what]. One reading for
278
+ * the secret rule and for any other rule that quotes the line. */
279
+ function credentialsIn(line) {
280
+ const out = [];
281
+ const seen = new Set();
282
+ const add = (v, what) => {
283
+ if (!seen.has(v)) {
284
+ seen.add(v);
285
+ out.push([v, what]);
286
+ }
287
+ };
288
+ for (const [re, what] of SECRET_SHAPES) {
289
+ if (what === "a private key")
290
+ continue; // a header names a key; it is not one
291
+ for (const m of line.matchAll(re)) {
292
+ if (!PLACEHOLDER.test(m[0]) && entropy(m[0]) >= 3)
293
+ add(m[0], what);
294
+ }
295
+ }
296
+ for (const m of line.matchAll(ASSIGNED))
297
+ if (credentialLike(m[2]))
298
+ add(m[2], `\`${m[1]}\` with a literal value`);
299
+ for (const m of line.matchAll(BEARER))
300
+ if (credentialLike(m[1]))
301
+ add(m[1], "a bearer token");
302
+ return out;
303
+ }
304
+ /** The text with every credential in it redacted — for a finding that
305
+ * quotes source, so no message ever carries a key whole. */
306
+ export function redactText(text) {
307
+ let out = text;
308
+ for (const [v] of credentialsIn(text).sort((a, b) => b[0].length - a[0].length))
309
+ out = out.split(v).join(redactSecret(v));
310
+ return out;
311
+ }
312
+ function scanSecrets(file, lines, push) {
313
+ for (let i = 0; i < lines.length; i++) {
314
+ const line = lines[i];
315
+ if (line.length > 4000)
316
+ continue; // a minified bundle; not where keys are pasted
317
+ const said = new Set();
318
+ const hit = (value, what) => {
319
+ if (said.has(value))
320
+ return;
321
+ said.add(value);
322
+ push("secret/hardcoded", file.path, i + 1, line, `${what} (${redactSecret(value)}) is written into the file — move it to the vault and write \`\${NAME}\``);
323
+ };
324
+ for (const [re, what] of SECRET_SHAPES) {
325
+ for (const m of line.matchAll(re)) {
326
+ const v = m[0];
327
+ if (what === "a private key") {
328
+ // The header alone is a constant in every crypto library; a key
329
+ // has its base64 body after it, on the next line or after a \n.
330
+ const after = line.slice((m.index ?? 0) + v.length) + "\n" + (lines[i + 1] ?? "");
331
+ if (!/^(?:\\n|\s)*[A-Za-z0-9+/=]{40,}/.test(after.replace(/^["',]*/, "")))
332
+ continue;
333
+ }
334
+ else if (PLACEHOLDER.test(v) || entropy(v) < 3)
335
+ continue;
336
+ hit(v, what);
337
+ }
338
+ }
339
+ if (said.size)
340
+ continue;
341
+ for (const m of line.matchAll(ASSIGNED)) {
342
+ if (credentialLike(m[2]))
343
+ hit(m[2], `\`${m[1]}\` with a literal value`);
344
+ }
345
+ for (const m of line.matchAll(BEARER)) {
346
+ if (credentialLike(m[1]))
347
+ hit(m[1], "a bearer token");
348
+ }
349
+ for (const m of line.matchAll(URL_PASSWORD)) {
350
+ const pw = m[1];
351
+ // `user:password@host` in a docstring, `{password}` in a template.
352
+ if (/[{}%<>$()\\]/.test(pw) || PLACEHOLDER.test(pw) || /^(?:pass(?:word|wd)?|secret|pwd|token|\*+)$/i.test(pw))
353
+ continue;
354
+ if (/^[a-z]+$/i.test(pw) && pw.length < 20)
355
+ continue;
356
+ hit(pw, "a password in a URL");
357
+ }
358
+ }
359
+ }
360
+ // ── content: what a model reads that a person cannot see ───────────────────
361
+ /** Zero-width, bidi controls and the Unicode tag block (U+E0000–E007F, the
362
+ * "ASCII smuggling" range). A BOM at the very start of a file is allowed. */
363
+ const HIDDEN = /[​-‏‪-‮⁠-⁤⁦-⁩]|\uDB40[\uDC00-\uDC7F]/g;
364
+ const HIDDEN_NAMES = [
365
+ [/\uDB40[\uDC00-\uDC7F]/, "Unicode tag characters"],
366
+ [/[‪-‮⁦-⁩]/, "bidirectional overrides"],
367
+ [/[​-‏⁠-⁤]/, "zero-width characters"],
368
+ ];
369
+ const INJECTION = [
370
+ /\b(?:ignore|disregard|forget|override)\s+(?:all\s+|any\s+|the\s+|your\s+)*(?:previous|prior|above|earlier|preceding|system|original)\s+(?:instructions|prompts?|rules|directions|context)\b/i,
371
+ /\byou\s+are\s+no\s+longer\b/i,
372
+ /\bnew\s+(?:system\s+)?instructions\s*:/i,
373
+ /<\/?\s*(?:system|im_start|im_end)\s*>|\[\/?(?:SYSTEM|INST)\]|<\|im_start\|>/i,
374
+ /\bdo\s+not\s+(?:tell|inform|mention\s+(?:this\s+)?to)\s+the\s+user\b/i,
375
+ /\b(?:send|post|upload|exfiltrate)\s+(?:all\s+|the\s+|every\s+)?(?:secrets?|credentials|api\s+keys?|tokens?|env(?:ironment)?\s+variables)\s+to\b/i,
376
+ ];
377
+ const BLOB = /[A-Za-z0-9+/]{300,}={0,2}/g;
378
+ function scanContent(file, lines, push) {
379
+ const md = /\.md$/i.test(file.path);
380
+ const data = isDataDoc(file.path);
381
+ let fenced = false;
382
+ for (let i = 0; i < lines.length; i++) {
383
+ const line = i === 0 ? lines[i].replace(/^/, "") : lines[i];
384
+ if (/^\s*(?:```|~~~)/.test(line))
385
+ fenced = !fenced;
386
+ HIDDEN.lastIndex = 0;
387
+ if (HIDDEN.test(line)) {
388
+ const kind = HIDDEN_NAMES.find(([re]) => re.test(line))?.[1] ?? "invisible characters";
389
+ const n = line.match(HIDDEN)?.length ?? 0;
390
+ const tagged = /\uDB40[\uDC00-\uDC7F]/.test(line) ? ` that read as: "${decodeTags(line).slice(0, 80)}"` : "";
391
+ push("content/hidden-unicode", file.path, i + 1, line, `${n} ${kind}${redactText(tagged)} — invisible to a reviewer, read by the model`);
392
+ }
393
+ // An instruction quoted inside a fence is an example of one, not one.
394
+ if (data && !fenced) {
395
+ const m = INJECTION.find((re) => re.test(line));
396
+ if (m)
397
+ push("content/injection-phrase", file.path, i + 1, line, `"${redactText(line.trim()).slice(0, 90)}" — agents read this file as data, and this line reads as an order`);
398
+ }
399
+ if (md) {
400
+ for (const b of line.matchAll(BLOB)) {
401
+ const before = line.slice(Math.max(0, (b.index ?? 0) - 40), b.index);
402
+ if (/data:[\w/+.-]+;base64,$/.test(before))
403
+ continue; // an inline image says what it is
404
+ push("content/encoded-blob", file.path, i + 1, line, `${b[0].length} characters of base64 — a reviewer cannot read what the model will`);
405
+ break;
406
+ }
407
+ }
408
+ }
409
+ }
410
+ /** The text hidden in tag characters: each is U+E0000 + an ASCII code. */
411
+ function decodeTags(s) {
412
+ let out = "";
413
+ for (const ch of s) {
414
+ const cp = ch.codePointAt(0);
415
+ if (cp >= 0xe0020 && cp <= 0xe007e)
416
+ out += String.fromCharCode(cp - 0xe0000);
417
+ }
418
+ return out;
419
+ }
420
+ /** Files an agent reads as material rather than as its own instructions. */
421
+ function isDataDoc(p) {
422
+ const rel = p.replace(/^library\//, "");
423
+ return /^(?:knowledge|memory|skills)\//.test(rel) || /^agents\/[^/]+\/(?:knowledge|memory|skills)\//.test(rel) || /^tools\/.+\.md$/.test(rel);
424
+ }
425
+ // ── supply chain ───────────────────────────────────────────────────────────
426
+ const PIPE_TO_SHELL = /\b(?:curl|wget)\b[^|\n]*\|\s*(?:sudo\s+)?(?:ba|z|da)?sh\b/;
427
+ function scanSupplyChain(file, lines, push) {
428
+ for (let i = 0; i < lines.length; i++) {
429
+ if (PIPE_TO_SHELL.test(lines[i])) {
430
+ push("supply-chain/pipe-to-shell", file.path, i + 1, lines[i], "downloads and runs a script unreviewed");
431
+ }
432
+ }
433
+ }
434
+ /** `npx pkg`, `npx -y pkg@latest`, `uvx pkg` — the package argument, if it
435
+ * carries no exact version. */
436
+ function unpinnedRunner(command, args) {
437
+ const parts = [command, ...(Array.isArray(args) ? args : [])].filter((x) => typeof x === "string");
438
+ const words = parts.flatMap((p) => p.split(/\s+/)).filter(Boolean);
439
+ const runner = words.findIndex((w) => /^(?:npx|bunx|pnpx|uvx)$/.test(w) || w === "dlx");
440
+ if (runner < 0)
441
+ return null;
442
+ const pkg = words.slice(runner + 1).find((w) => !w.startsWith("-"));
443
+ if (!pkg)
444
+ return null;
445
+ if (/==/.test(pkg))
446
+ return null; // uvx pkg==1.2.0
447
+ const at = pkg.lastIndexOf("@");
448
+ const version = at > 0 ? pkg.slice(at + 1) : "";
449
+ if (!version || version === "latest" || version === "next")
450
+ return pkg;
451
+ // uvx pins with ==; a bare name there is unpinned too.
452
+ return null;
453
+ }
454
+ function unpinned(pkg) {
455
+ const s = pkg.trim();
456
+ if (!s || s.startsWith("-") || /^(?:git\+|https?:|file:|\.|\/)/.test(s))
457
+ return false;
458
+ // pip: ==, >=, ~=, <, !=; npm: name@range; a scoped npm name has a leading @.
459
+ return !/[=<>~!]/.test(s) && !/.@./.test(s.replace(/^@/, "x"));
460
+ }
461
+ // ── frontmatter ────────────────────────────────────────────────────────────
462
+ /** What foldrun reads in an agent's frontmatter — the fields documented in
463
+ * agents.md, the Claude Code subagent fields an import carries, and the
464
+ * older spellings check already teaches about. Used only to suggest the
465
+ * field a typo meant: an unknown key that is nothing like any of these is
466
+ * left alone, since another tool may own it. */
467
+ const AGENT_FIELDS = [
468
+ "name", "description", "model", "effort", "size", "tools", "disallowedTools", "skills", "hooks", "scripts",
469
+ "apis", "mcpServers", "agents", "subagents", "secrets", "limits", "network", "provider", "permissionMode",
470
+ "runtime", "ask", "web", "budget", "timezone", "language", "region", "currency", "units", "schedule", "color",
471
+ "memory", "foldrun_version", "use", "kind", "type", "notify", "triggers", "inputs", "outputs",
472
+ ];
473
+ const FLOW_FIELDS = [
474
+ "name", "description", "trigger", "schedule", "timezone", "language", "region", "currency", "units", "model",
475
+ "effort", "overlap", "priority", "throttle", "debounce", "sla", "catchup", "pause_when", "disable_after",
476
+ "approvers", "approve_within", "notify", "at", "every", "budget", "limits", "size", "hooks", "inputs",
477
+ "webhook", "signing_secret", "signature", "idempotency", "filter", "after", "tags", "network", "secrets",
478
+ ];
479
+ const TOOL_FIELDS = [
480
+ "name", "description", "transport", "type", "base", "methods", "headers", "query", "auth", "run", "args",
481
+ "command", "url", "env", "outward", "runtime", "secrets", "network", "timeout", "spec", "operations", "body",
482
+ "oauth", "limits", "input", "output", "schema", "kind",
483
+ ];
484
+ function editDistance(a, b) {
485
+ const d = Array.from({ length: a.length + 1 }, (_, i) => [i, ...Array(b.length).fill(0)]);
486
+ for (let j = 1; j <= b.length; j++)
487
+ d[0][j] = j;
488
+ for (let i = 1; i <= a.length; i++) {
489
+ for (let j = 1; j <= b.length; j++) {
490
+ d[i][j] = Math.min(d[i - 1][j] + 1, d[i][j - 1] + 1, d[i - 1][j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
491
+ }
492
+ }
493
+ return d[a.length][b.length];
494
+ }
495
+ /** The known field a key was probably meant to be, or null. */
496
+ export function nearestField(key, known) {
497
+ if (known.includes(key))
498
+ return null;
499
+ const lower = key.toLowerCase().replace(/[-_]/g, "");
500
+ let best = null;
501
+ let bestD = Infinity;
502
+ for (const k of known) {
503
+ const kl = k.toLowerCase().replace(/[-_]/g, "");
504
+ if (kl === lower)
505
+ return k; // disallowed_tools for disallowedTools
506
+ const d = editDistance(lower, kl);
507
+ if (d < bestD)
508
+ [best, bestD] = [k, d];
509
+ }
510
+ const allowed = key.length <= 4 ? 1 : 2;
511
+ return bestD <= allowed ? best : null;
512
+ }
513
+ function keyLine(lines, key) {
514
+ const re = new RegExp(`^${key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*:`);
515
+ const i = lines.findIndex((l) => re.test(l));
516
+ return i >= 0 ? i + 1 : undefined;
517
+ }
518
+ /** Read-only: gray-matter caches by content, so two files with the same
519
+ * text share one object, and writing to it would leak between them. */
520
+ function frontOf(content) {
521
+ if (!/^?---\r?\n/.test(content))
522
+ return null;
523
+ try {
524
+ return matter(content).data;
525
+ }
526
+ catch {
527
+ return null; // a frontmatter YAML cannot read is check's finding, not ours
528
+ }
529
+ }
530
+ function docKind(p) {
531
+ const rel = p.replace(/^library\//, "");
532
+ if (/^agents\/[^/]+\/agent\.md$/.test(rel) || /^\.(?:claude|agents)\/agents\/[^/]+\.md$/.test(rel))
533
+ return "agent";
534
+ if (/^flows\/[^/]+\.md$/.test(rel))
535
+ return "flow";
536
+ if (/^tools\/[^/]+\/tool\.md$/.test(rel) || /^tools\/[^/]+\.md$/.test(rel))
537
+ return "tool";
538
+ return null;
539
+ }
540
+ // ── agents: what one can reach, taken together ─────────────────────────────
541
+ const WRITE_METHODS = /\b(?:POST|PUT|PATCH|DELETE)\b/i;
542
+ function scanAgents(agents, outward, toolDefs, push, networks) {
543
+ const byName = new Map(agents.map((a) => [a.name, a]));
544
+ for (const a of agents) {
545
+ const d = a.data;
546
+ const tools = refNames(d.tools);
547
+ const denied = new Set(refNames(d.disallowedTools));
548
+ const has = (t) => tools.includes(t) && !denied.has(t);
549
+ // Untrusted: it reads the open web. (A webhook body is the other source;
550
+ // that is a flow's trigger, and the flow-level rule is a later one.)
551
+ const readsWeb = has("web") || tools.some((t) => /browse|fetch|scrape|crawl/i.test(t) && has(t));
552
+ // Private: a secret it spends itself, an API or MCP server it holds.
553
+ const secrets = Array.isArray(d.secrets) ? d.secrets.map(String) : [];
554
+ const apis = Array.isArray(d.apis) ? d.apis : [];
555
+ const mcp = d.mcpServers && typeof d.mcpServers === "object" ? Object.keys(d.mcpServers) : [];
556
+ const privateLeg = [...secrets.map((s) => `secret ${s}`), ...apis.map((x) => `api ${x?.name ?? "?"}`), ...mcp.map((m) => `MCP ${m}`)];
557
+ // Out: a sandbox open to every site, a tool marked outward, or an API it
558
+ // may write to. The level is the runtime's own resolution — agent, then
559
+ // workspace, then account, and open by default for an agent that reads
560
+ // the web; a bare list of sites adds to a level, it does not set one.
561
+ const openNet = resolveNetwork({ account: networks.account, workspace: networks.workspace, agent: d.network, readsWeb }).level === "open";
562
+ const outwardTools = tools.filter((t) => outward.has(t) && !denied.has(t));
563
+ const writeApis = apis.filter((x) => WRITE_METHODS.test(JSON.stringify(x?.methods ?? x?.method ?? "")));
564
+ const outLeg = [...(openNet ? ["an open network"] : []), ...outwardTools.map((t) => `outward tool ${t}`), ...writeApis.map((x) => `writes to api ${x?.name ?? "?"}`)];
565
+ const gated = refNames(d.hooks).length > 0 || d.permissionMode === "plan";
566
+ if (readsWeb && privateLeg.length && outLeg.length && !gated) {
567
+ push("security/lethal-trifecta", a.file, a.line, `trifecta ${a.name}`, `${a.name} reads the web, holds ${privateLeg.slice(0, 3).join(", ")}${privateLeg.length > 3 ? ", …" : ""} and can reach ${outLeg.slice(0, 2).join(" and ")} — ` +
568
+ `a page it reads can tell it where to send what it holds. Take it off the open network (network: { level: trusted, allow: [...] }), split it, or add a hooks: gate`);
569
+ }
570
+ // Sub-agents declaring tools the parent lacks.
571
+ for (const s of refNames(d.subagents)) {
572
+ const sub = byName.get(s);
573
+ if (!sub || s === a.name)
574
+ continue;
575
+ const extra = refNames(sub.data.tools).filter((t) => !tools.includes(t));
576
+ if (extra.length && tools.length) {
577
+ push("security/subagent-wider", a.file, keyLine(a.lines, "subagents"), `subagent ${s} ${extra.join(",")}`, `subagents: [${s}] — ${s} declares ${extra.join(", ")}, which ${a.name} does not have; inside the delegation they are dropped`);
578
+ }
579
+ }
580
+ // MCP servers launched by a package runner at no fixed version.
581
+ for (const [name, spec] of Object.entries((d.mcpServers ?? {}))) {
582
+ const pkg = spec && typeof spec === "object" ? unpinnedRunner(spec.command, spec.args) : null;
583
+ if (pkg)
584
+ push("supply-chain/unpinned-mcp", a.file, keyLine(a.lines, "mcpServers"), `mcp ${name} ${pkg}`, `mcpServers.${name} runs \`${pkg}\` — whatever was published last; pin a version`);
585
+ }
586
+ runtimePackages(d.runtime, a.file, a.lines, push);
587
+ }
588
+ for (const [file, { data: def, lines }] of toolDefs) {
589
+ const pkg = unpinnedRunner(def.command, def.args);
590
+ if (pkg)
591
+ push("supply-chain/unpinned-mcp", file, keyLine(lines, "command"), `mcp ${pkg}`, `command runs \`${pkg}\` — whatever was published last; pin a version`);
592
+ runtimePackages(def.runtime, file, lines, push);
593
+ }
594
+ }
595
+ function runtimePackages(runtime, file, lines, push) {
596
+ if (!runtime || typeof runtime !== "object")
597
+ return;
598
+ const r = runtime;
599
+ for (const key of ["packages", "npm"]) {
600
+ const list = Array.isArray(r[key]) ? r[key].map(String) : [];
601
+ const loose = list.filter(unpinned);
602
+ if (loose.length) {
603
+ push("supply-chain/unpinned-package", file, keyLine(lines, "runtime"), `runtime ${key} ${loose.join(",")}`, `runtime: ${key}: ${loose.slice(0, 4).join(", ")}${loose.length > 4 ? ", …" : ""} — no version; pin a range`);
604
+ }
605
+ }
606
+ }
607
+ // ── ignores ────────────────────────────────────────────────────────────────
608
+ const IGNORE = /foldrun-ignore(-file)?\s+([a-z0-9*][a-z0-9/*,\s-]*?)\s*(?::\s*(.*?))?\s*(?:-->|\*\/)?\s*$/i;
609
+ /** Memory is written by the agents themselves, so an ignore there could be
610
+ * written by whatever an agent read — an injection that silences its own
611
+ * finding. Only people's files may carry one. */
612
+ const AGENT_WRITTEN = /^(?:library\/)?(?:agents\/[^/]+\/)?memory\//;
613
+ function readIgnores(file, lines, push) {
614
+ const out = { file: new Set(), lines: new Map() };
615
+ if (AGENT_WRITTEN.test(file.path))
616
+ return out;
617
+ for (let i = 0; i < lines.length; i++) {
618
+ const m = IGNORE.exec(lines[i]);
619
+ if (!m)
620
+ continue;
621
+ const rules = m[2].split(/[\s,]+/).filter(Boolean);
622
+ if (!m[3]?.trim()) {
623
+ push("check/ignore-needs-reason", file.path, i + 1, lines[i], `foldrun-ignore ${rules.join(", ")} has no reason, so it ignores nothing — add one after a colon`);
624
+ continue;
625
+ }
626
+ if (m[1]) {
627
+ for (const r of rules)
628
+ out.file.add(r);
629
+ continue;
630
+ }
631
+ for (const at of [i + 1, i + 2]) {
632
+ const set = out.lines.get(at) ?? new Set();
633
+ for (const r of rules)
634
+ set.add(r);
635
+ out.lines.set(at, set);
636
+ }
637
+ }
638
+ return out;
639
+ }
640
+ function ignored(ig, rule, line) {
641
+ const covers = (set) => set.has(rule) || set.has("*") || set.has(rule.split("/")[0]) || set.has(`${rule.split("/")[0]}/*`);
642
+ return covers(ig.file) || (line !== undefined && !!ig.lines.get(line) && covers(ig.lines.get(line)));
643
+ }
644
+ /** Installed and built code: someone else's files, and a crypto library's
645
+ * constants are not this workspace's keys. */
646
+ const VENDORED = /(?:^|\/)(?:node_modules|\.venv|venv|site-packages|__pycache__|vendor|dist|build|\.next)\//;
647
+ /** Text files only — a PNG read as UTF-8 is noise to every rule here. */
648
+ function isText(f) {
649
+ return !VENDORED.test(f.path) && !f.content.slice(0, 8000).includes("\u0000") && !/\.(?:png|jpe?g|gif|webp|ico|pdf|zip|gz|woff2?|ttf|mp[34]|mov|wasm)$/i.test(f.path);
650
+ }
651
+ /**
652
+ * Every finding in these files, ignores applied. Findings are sorted by file
653
+ * then line, and one rule says one thing once per line.
654
+ */
655
+ export function scanFiles(files, options = {}) {
656
+ const want = options.only?.length ? new Set(options.only) : null;
657
+ const enabled = (rule) => !want || want.has(rule) || want.has(scanRule(rule)?.category ?? "") || rule === "check/ignore-needs-reason";
658
+ const findings = [];
659
+ const seen = new Set();
660
+ const ignoresByFile = new Map();
661
+ const push = (rule, file, line, text, message) => {
662
+ if (!enabled(rule))
663
+ return;
664
+ const ig = ignoresByFile.get(file);
665
+ if (ig && ignored(ig, rule, line))
666
+ return;
667
+ const key = `${rule}\0${file}\0${line ?? ""}\0${message}`;
668
+ if (seen.has(key))
669
+ return;
670
+ seen.add(key);
671
+ findings.push({ rule, level: scanRule(rule)?.level ?? "warn", file, ...(line ? { line } : {}), message, fingerprint: findingFingerprint(rule, file, text) });
672
+ };
673
+ const outward = new Set(options.outwardTools ?? []);
674
+ const agents = [];
675
+ const toolDefs = new Map();
676
+ for (const f of files.filter(isText)) {
677
+ const lines = f.content.split(/\r?\n/);
678
+ ignoresByFile.set(f.path, readIgnores(f, lines, push));
679
+ scanSecrets(f, lines, push);
680
+ scanContent(f, lines, push);
681
+ scanSupplyChain(f, lines, push);
682
+ const kind = docKind(f.path);
683
+ if (!kind)
684
+ continue;
685
+ const data = frontOf(f.content);
686
+ if (!data)
687
+ continue;
688
+ const known = kind === "agent" ? AGENT_FIELDS : kind === "flow" ? FLOW_FIELDS : TOOL_FIELDS;
689
+ for (const key of Object.keys(data)) {
690
+ const near = nearestField(key, known);
691
+ if (near && near !== key)
692
+ push("config/unknown-field", f.path, keyLine(lines, key), `field ${key}`, `\`${key}:\` is not a field foldrun reads — did you mean \`${near}:\`? As written it is ignored`);
693
+ }
694
+ if (kind === "agent") {
695
+ const rel = f.path.replace(/^library\//, "");
696
+ const name = /^agents\/([^/]+)\//.exec(rel)?.[1] ?? rel.replace(/^.*\/|\.md$/g, "");
697
+ agents.push({ name, file: f.path, line: 1, data, lines });
698
+ }
699
+ else if (kind === "tool") {
700
+ const name = typeof data.name === "string" ? data.name : f.path.replace(/^(?:library\/)?tools\//, "").replace(/(?:\/tool)?\.md$/, "");
701
+ if (data.outward === true)
702
+ outward.add(name);
703
+ toolDefs.set(f.path, { data, lines });
704
+ }
705
+ }
706
+ const workspaceMd = files.find((f) => f.path === "AGENTS.md");
707
+ scanAgents(agents, outward, toolDefs, push, { account: options.accountNetwork, workspace: workspaceMd ? frontOf(workspaceMd.content)?.network : undefined });
708
+ return findings.sort((a, b) => a.file.localeCompare(b.file) || (a.line ?? 0) - (b.line ?? 0) || a.rule.localeCompare(b.rule));
709
+ }
710
+ /**
711
+ * Drop the findings a `foldrun-ignore` comment (with a reason) covers — for
712
+ * findings that did not come from scanFiles, such as check's own rules, so
713
+ * one comment syntax works for every rule. Ignores without a reason are
714
+ * reported by scanFiles itself, once.
715
+ */
716
+ export function filterIgnored(findings, files) {
717
+ const byFile = new Map();
718
+ const quiet = () => { };
719
+ for (const f of files) {
720
+ if (f.content.includes("foldrun-ignore"))
721
+ byFile.set(f.path, readIgnores(f, f.content.split(/\r?\n/), quiet));
722
+ }
723
+ return findings.filter((x) => {
724
+ const ig = byFile.get(x.file);
725
+ return !ig || !ignored(ig, x.rule, x.line);
726
+ });
727
+ }
728
+ /** A baseline that accepts every finding here as known. */
729
+ export function makeBaseline(findings, created = new Date().toISOString()) {
730
+ const uniq = new Map(findings.map((f) => [`${f.rule}\0${f.file}\0${f.fingerprint}`, { rule: f.rule, file: f.file, fingerprint: f.fingerprint }]));
731
+ return { version: 1, created, findings: [...uniq.values()].sort((a, b) => a.file.localeCompare(b.file) || a.rule.localeCompare(b.rule)) };
732
+ }
733
+ /** Findings not in the baseline, and how many it hid. A baseline that does
734
+ * not parse hides nothing — failing open would hide every new finding. */
735
+ export function applyBaseline(findings, baseline) {
736
+ const b = baseline;
737
+ if (!b || b.version !== 1 || !Array.isArray(b.findings))
738
+ return { kept: findings, baselined: 0 };
739
+ // An entry that is not {rule, file, fingerprint} hides nothing.
740
+ const entries = b.findings.filter((f) => !!f && typeof f === "object" && typeof f.rule === "string" && typeof f.file === "string" && typeof f.fingerprint === "string");
741
+ const known = new Set(entries.map((f) => `${f.rule}\0${f.file}\0${f.fingerprint}`));
742
+ const kept = findings.filter((f) => !known.has(`${f.rule}\0${f.file}\0${f.fingerprint}`));
743
+ return { kept, baselined: findings.length - kept.length };
744
+ }
745
+ export function toSarif(findings, opts = {}) {
746
+ const ids = [...new Set(findings.map((f) => f.rule))].sort();
747
+ const extra = new Map((opts.rules ?? []).map((r) => [r.id, r]));
748
+ const rules = ids.map((id) => {
749
+ const r = scanRule(id) ?? extra.get(id);
750
+ return {
751
+ id,
752
+ name: id.replace(/[/-](\w)/g, (_, c) => c.toUpperCase()),
753
+ shortDescription: { text: r?.title ?? id },
754
+ ...(r?.help ? { help: { text: r.help } } : {}),
755
+ helpUri: ruleDocsUrl(id),
756
+ properties: { tags: ["foldrun", id.split("/")[0], ...((scanRule(id)?.owasp ?? []).map((o) => `owasp-agentic/${o}`))] },
757
+ };
758
+ });
759
+ return {
760
+ $schema: "https://json.schemastore.org/sarif-2.1.0.json",
761
+ version: "2.1.0",
762
+ runs: [
763
+ {
764
+ tool: { driver: { name: "foldrun check", informationUri: "https://docs.foldrun.io/checks", ...(opts.version ? { version: opts.version } : {}), rules } },
765
+ ...(opts.root ? { originalUriBaseIds: { SRCROOT: { uri: opts.root.endsWith("/") ? opts.root : `${opts.root}/` } } } : {}),
766
+ results: findings.map((f) => ({
767
+ ruleId: f.rule,
768
+ ruleIndex: ids.indexOf(f.rule),
769
+ level: f.level === "warn" ? "warning" : f.level === "info" ? "note" : "error",
770
+ message: { text: f.message },
771
+ locations: [
772
+ {
773
+ physicalLocation: {
774
+ artifactLocation: { uri: f.file, ...(opts.root ? { uriBaseId: "SRCROOT" } : {}) },
775
+ ...(f.line ? { region: { startLine: f.line } } : {}),
776
+ },
777
+ },
778
+ ],
779
+ partialFingerprints: { "foldrun/v1": f.fingerprint },
780
+ })),
781
+ },
782
+ ],
783
+ };
784
+ }