@foldrun/core 1.2.0 → 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.
@@ -33,6 +33,9 @@ export interface ScanOptions {
33
33
  outwardTools?: Iterable<string>;
34
34
  /** Only these rule ids (or categories, `secret`, `security`, …). */
35
35
  only?: string[];
36
+ /** The account AGENTS.md's `network:` — the level above the workspace's,
37
+ * which the files of one workspace do not carry. */
38
+ accountNetwork?: unknown;
36
39
  }
37
40
  export declare const OWASP_URL = "https://genai.owasp.org/resource/owasp-top-10-for-agentic-applications-for-2026";
38
41
  export declare const SCAN_RULES: readonly ScanRule[];
@@ -60,6 +63,9 @@ export declare function ruleDocsUrl(id: string): string;
60
63
  export declare function findingFingerprint(rule: string, file: string, text: string): string;
61
64
  /** What a finding may show of a secret: enough to find it, not to use it. */
62
65
  export declare function redactSecret(secret: string): string;
66
+ /** The text with every credential in it redacted — for a finding that
67
+ * quotes source, so no message ever carries a key whole. */
68
+ export declare function redactText(text: string): string;
63
69
  /** The known field a key was probably meant to be, or null. */
64
70
  export declare function nearestField(key: string, known: readonly string[]): string | null;
65
71
  /**
package/dist/src/scan.js CHANGED
@@ -28,6 +28,7 @@
28
28
  import crypto from "node:crypto";
29
29
  import matter from "gray-matter";
30
30
  import { refNames } from "./refs.js";
31
+ import { resolveNetwork } from "./network.js";
31
32
  export const OWASP_URL = "https://genai.owasp.org/resource/owasp-top-10-for-agentic-applications-for-2026";
32
33
  export const SCAN_RULES = [
33
34
  {
@@ -74,7 +75,8 @@ export const SCAN_RULES = [
74
75
  level: "warn",
75
76
  title: "Reads untrusted content, holds private data and can send it out",
76
77
  help: "An agent with all three can be told — by any page it reads — to send what it holds anywhere it can reach. " +
77
- "Take one leg away: narrow `network:` to the sites it needs, split the reading and the sending into two agents, " +
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, " +
78
80
  "or guard its calls with a `hooks:` gate. An approval (`!`) on every outward step is the other answer.",
79
81
  owasp: ["ASI01", "ASI02", "ASI03"],
80
82
  },
@@ -272,6 +274,41 @@ function credentialLike(value) {
272
274
  export function redactSecret(secret) {
273
275
  return secret.length <= 8 ? "****" : `${secret.slice(0, 4)}…${secret.slice(-2)}`;
274
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
+ }
275
312
  function scanSecrets(file, lines, push) {
276
313
  for (let i = 0; i < lines.length; i++) {
277
314
  const line = lines[i];
@@ -351,13 +388,13 @@ function scanContent(file, lines, push) {
351
388
  const kind = HIDDEN_NAMES.find(([re]) => re.test(line))?.[1] ?? "invisible characters";
352
389
  const n = line.match(HIDDEN)?.length ?? 0;
353
390
  const tagged = /\uDB40[\uDC00-\uDC7F]/.test(line) ? ` that read as: "${decodeTags(line).slice(0, 80)}"` : "";
354
- push("content/hidden-unicode", file.path, i + 1, line, `${n} ${kind}${tagged} — invisible to a reviewer, read by the model`);
391
+ push("content/hidden-unicode", file.path, i + 1, line, `${n} ${kind}${redactText(tagged)} — invisible to a reviewer, read by the model`);
355
392
  }
356
393
  // An instruction quoted inside a fence is an example of one, not one.
357
394
  if (data && !fenced) {
358
395
  const m = INJECTION.find((re) => re.test(line));
359
396
  if (m)
360
- push("content/injection-phrase", file.path, i + 1, line, `"${line.trim().slice(0, 90)}" — agents read this file as data, and this line reads as an order`);
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`);
361
398
  }
362
399
  if (md) {
363
400
  for (const b of line.matchAll(BLOB)) {
@@ -502,15 +539,13 @@ function docKind(p) {
502
539
  }
503
540
  // ── agents: what one can reach, taken together ─────────────────────────────
504
541
  const WRITE_METHODS = /\b(?:POST|PUT|PATCH|DELETE)\b/i;
505
- function scanAgents(agents, outward, toolDefs, push) {
542
+ function scanAgents(agents, outward, toolDefs, push, networks) {
506
543
  const byName = new Map(agents.map((a) => [a.name, a]));
507
544
  for (const a of agents) {
508
545
  const d = a.data;
509
546
  const tools = refNames(d.tools);
510
547
  const denied = new Set(refNames(d.disallowedTools));
511
548
  const has = (t) => tools.includes(t) && !denied.has(t);
512
- const network = d.network;
513
- const netLevel = typeof network === "string" ? network : Array.isArray(network) ? "list" : network && typeof network === "object" ? "list" : null;
514
549
  // Untrusted: it reads the open web. (A webhook body is the other source;
515
550
  // that is a flow's trigger, and the flow-level rule is a later one.)
516
551
  const readsWeb = has("web") || tools.some((t) => /browse|fetch|scrape|crawl/i.test(t) && has(t));
@@ -519,16 +554,18 @@ function scanAgents(agents, outward, toolDefs, push) {
519
554
  const apis = Array.isArray(d.apis) ? d.apis : [];
520
555
  const mcp = d.mcpServers && typeof d.mcpServers === "object" ? Object.keys(d.mcpServers) : [];
521
556
  const privateLeg = [...secrets.map((s) => `secret ${s}`), ...apis.map((x) => `api ${x?.name ?? "?"}`), ...mcp.map((m) => `MCP ${m}`)];
522
- // Out: a sandbox open to every site (an agent that reads the web is open
523
- // unless it says otherwise), a tool marked outward, or an API it may write to.
524
- const openNet = netLevel === "open" || (readsWeb && netLevel === null);
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";
525
562
  const outwardTools = tools.filter((t) => outward.has(t) && !denied.has(t));
526
563
  const writeApis = apis.filter((x) => WRITE_METHODS.test(JSON.stringify(x?.methods ?? x?.method ?? "")));
527
564
  const outLeg = [...(openNet ? ["an open network"] : []), ...outwardTools.map((t) => `outward tool ${t}`), ...writeApis.map((x) => `writes to api ${x?.name ?? "?"}`)];
528
565
  const gated = refNames(d.hooks).length > 0 || d.permissionMode === "plan";
529
566
  if (readsWeb && privateLeg.length && outLeg.length && !gated) {
530
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 ")} — ` +
531
- `a page it reads can tell it where to send what it holds. Narrow network:, split it, or add a hooks: gate`);
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`);
532
569
  }
533
570
  // Sub-agents declaring tools the parent lacks.
534
571
  for (const s of refNames(d.subagents)) {
@@ -666,7 +703,8 @@ export function scanFiles(files, options = {}) {
666
703
  toolDefs.set(f.path, { data, lines });
667
704
  }
668
705
  }
669
- scanAgents(agents, outward, toolDefs, push);
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 });
670
708
  return findings.sort((a, b) => a.file.localeCompare(b.file) || (a.line ?? 0) - (b.line ?? 0) || a.rule.localeCompare(b.rule));
671
709
  }
672
710
  /**
@@ -698,7 +736,9 @@ export function applyBaseline(findings, baseline) {
698
736
  const b = baseline;
699
737
  if (!b || b.version !== 1 || !Array.isArray(b.findings))
700
738
  return { kept: findings, baselined: 0 };
701
- const known = new Set(b.findings.map((f) => `${f.rule}\0${f.file}\0${f.fingerprint}`));
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}`));
702
742
  const kept = findings.filter((f) => !known.has(`${f.rule}\0${f.file}\0${f.fingerprint}`));
703
743
  return { kept, baselined: findings.length - kept.length };
704
744
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@foldrun/core",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "description": "Agents are just folders. The format, the runner, the checks.",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "type": "module",
package/src/scan.ts CHANGED
@@ -29,6 +29,7 @@
29
29
  import crypto from "node:crypto";
30
30
  import matter from "gray-matter";
31
31
  import { refNames } from "./refs.ts";
32
+ import { resolveNetwork } from "./network.ts";
32
33
 
33
34
  export type ScanLevel = "error" | "warn" | "info";
34
35
  export type ScanCategory = "secret" | "security" | "content" | "supply-chain" | "config";
@@ -69,6 +70,9 @@ export interface ScanOptions {
69
70
  outwardTools?: Iterable<string>;
70
71
  /** Only these rule ids (or categories, `secret`, `security`, …). */
71
72
  only?: string[];
73
+ /** The account AGENTS.md's `network:` — the level above the workspace's,
74
+ * which the files of one workspace do not carry. */
75
+ accountNetwork?: unknown;
72
76
  }
73
77
 
74
78
  export const OWASP_URL = "https://genai.owasp.org/resource/owasp-top-10-for-agentic-applications-for-2026";
@@ -123,7 +127,8 @@ export const SCAN_RULES: readonly ScanRule[] = [
123
127
  title: "Reads untrusted content, holds private data and can send it out",
124
128
  help:
125
129
  "An agent with all three can be told — by any page it reads — to send what it holds anywhere it can reach. " +
126
- "Take one leg away: narrow `network:` to the sites it needs, split the reading and the sending into two agents, " +
130
+ "Take one leg away: take it off the open network (`network: { level: trusted, allow: [the sites it needs] }` — a bare list only adds sites, " +
131
+ "and an agent that reads the web stays open), split the reading and the sending into two agents, " +
127
132
  "or guard its calls with a `hooks:` gate. An approval (`!`) on every outward step is the other answer.",
128
133
  owasp: ["ASI01", "ASI02", "ASI03"],
129
134
  },
@@ -329,6 +334,36 @@ export function redactSecret(secret: string): string {
329
334
  return secret.length <= 8 ? "****" : `${secret.slice(0, 4)}…${secret.slice(-2)}`;
330
335
  }
331
336
 
337
+ /** Every credential in a piece of text, as [value, what]. One reading for
338
+ * the secret rule and for any other rule that quotes the line. */
339
+ function credentialsIn(line: string): [string, string][] {
340
+ const out: [string, string][] = [];
341
+ const seen = new Set<string>();
342
+ const add = (v: string, what: string) => {
343
+ if (!seen.has(v)) {
344
+ seen.add(v);
345
+ out.push([v, what]);
346
+ }
347
+ };
348
+ for (const [re, what] of SECRET_SHAPES) {
349
+ if (what === "a private key") continue; // a header names a key; it is not one
350
+ for (const m of line.matchAll(re)) {
351
+ if (!PLACEHOLDER.test(m[0]) && entropy(m[0]) >= 3) add(m[0], what);
352
+ }
353
+ }
354
+ for (const m of line.matchAll(ASSIGNED)) if (credentialLike(m[2]!)) add(m[2]!, `\`${m[1]}\` with a literal value`);
355
+ for (const m of line.matchAll(BEARER)) if (credentialLike(m[1]!)) add(m[1]!, "a bearer token");
356
+ return out;
357
+ }
358
+
359
+ /** The text with every credential in it redacted — for a finding that
360
+ * quotes source, so no message ever carries a key whole. */
361
+ export function redactText(text: string): string {
362
+ let out = text;
363
+ for (const [v] of credentialsIn(text).sort((a, b) => b[0].length - a[0].length)) out = out.split(v).join(redactSecret(v));
364
+ return out;
365
+ }
366
+
332
367
  function scanSecrets(file: ScanFile, lines: string[], push: Push) {
333
368
  for (let i = 0; i < lines.length; i++) {
334
369
  const line = lines[i]!;
@@ -403,12 +438,12 @@ function scanContent(file: ScanFile, lines: string[], push: Push) {
403
438
  const kind = HIDDEN_NAMES.find(([re]) => re.test(line))?.[1] ?? "invisible characters";
404
439
  const n = line.match(HIDDEN)?.length ?? 0;
405
440
  const tagged = /\uDB40[\uDC00-\uDC7F]/.test(line) ? ` that read as: "${decodeTags(line).slice(0, 80)}"` : "";
406
- push("content/hidden-unicode", file.path, i + 1, line, `${n} ${kind}${tagged} — invisible to a reviewer, read by the model`);
441
+ push("content/hidden-unicode", file.path, i + 1, line, `${n} ${kind}${redactText(tagged)} — invisible to a reviewer, read by the model`);
407
442
  }
408
443
  // An instruction quoted inside a fence is an example of one, not one.
409
444
  if (data && !fenced) {
410
445
  const m = INJECTION.find((re) => re.test(line));
411
- if (m) push("content/injection-phrase", file.path, i + 1, line, `"${line.trim().slice(0, 90)}" — agents read this file as data, and this line reads as an order`);
446
+ if (m) 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`);
412
447
  }
413
448
  if (md) {
414
449
  for (const b of line.matchAll(BLOB)) {
@@ -568,15 +603,13 @@ interface ToolFacts {
568
603
  lines: string[];
569
604
  }
570
605
 
571
- function scanAgents(agents: AgentFacts[], outward: Set<string>, toolDefs: Map<string, ToolFacts>, push: Push) {
606
+ function scanAgents(agents: AgentFacts[], outward: Set<string>, toolDefs: Map<string, ToolFacts>, push: Push, networks: { account?: unknown; workspace?: unknown }) {
572
607
  const byName = new Map(agents.map((a) => [a.name, a]));
573
608
  for (const a of agents) {
574
609
  const d = a.data;
575
610
  const tools = refNames(d.tools);
576
611
  const denied = new Set(refNames(d.disallowedTools));
577
612
  const has = (t: string) => tools.includes(t) && !denied.has(t);
578
- const network = d.network;
579
- const netLevel = typeof network === "string" ? network : Array.isArray(network) ? "list" : network && typeof network === "object" ? "list" : null;
580
613
 
581
614
  // Untrusted: it reads the open web. (A webhook body is the other source;
582
615
  // that is a flow's trigger, and the flow-level rule is a later one.)
@@ -586,9 +619,11 @@ function scanAgents(agents: AgentFacts[], outward: Set<string>, toolDefs: Map<st
586
619
  const apis = Array.isArray(d.apis) ? d.apis : [];
587
620
  const mcp = d.mcpServers && typeof d.mcpServers === "object" ? Object.keys(d.mcpServers as object) : [];
588
621
  const privateLeg = [...secrets.map((s) => `secret ${s}`), ...apis.map((x: any) => `api ${x?.name ?? "?"}`), ...mcp.map((m) => `MCP ${m}`)];
589
- // Out: a sandbox open to every site (an agent that reads the web is open
590
- // unless it says otherwise), a tool marked outward, or an API it may write to.
591
- const openNet = netLevel === "open" || (readsWeb && netLevel === null);
622
+ // Out: a sandbox open to every site, a tool marked outward, or an API it
623
+ // may write to. The level is the runtime's own resolution — agent, then
624
+ // workspace, then account, and open by default for an agent that reads
625
+ // the web; a bare list of sites adds to a level, it does not set one.
626
+ const openNet = resolveNetwork({ account: networks.account, workspace: networks.workspace, agent: d.network, readsWeb }).level === "open";
592
627
  const outwardTools = tools.filter((t) => outward.has(t) && !denied.has(t));
593
628
  const writeApis = apis.filter((x: any) => WRITE_METHODS.test(JSON.stringify(x?.methods ?? x?.method ?? "")));
594
629
  const outLeg = [...(openNet ? ["an open network"] : []), ...outwardTools.map((t) => `outward tool ${t}`), ...writeApis.map((x: any) => `writes to api ${x?.name ?? "?"}`)];
@@ -598,7 +633,7 @@ function scanAgents(agents: AgentFacts[], outward: Set<string>, toolDefs: Map<st
598
633
  push(
599
634
  "security/lethal-trifecta", a.file, a.line, `trifecta ${a.name}`,
600
635
  `${a.name} reads the web, holds ${privateLeg.slice(0, 3).join(", ")}${privateLeg.length > 3 ? ", …" : ""} and can reach ${outLeg.slice(0, 2).join(" and ")} — ` +
601
- `a page it reads can tell it where to send what it holds. Narrow network:, split it, or add a hooks: gate`,
636
+ `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`,
602
637
  );
603
638
  }
604
639
 
@@ -748,7 +783,8 @@ export function scanFiles(files: ScanFile[], options: ScanOptions = {}): ScanFin
748
783
  toolDefs.set(f.path, { data, lines });
749
784
  }
750
785
  }
751
- scanAgents(agents, outward, toolDefs, push);
786
+ const workspaceMd = files.find((f) => f.path === "AGENTS.md");
787
+ scanAgents(agents, outward, toolDefs, push, { account: options.accountNetwork, workspace: workspaceMd ? frontOf(workspaceMd.content)?.network : undefined });
752
788
 
753
789
  return findings.sort((a, b) => a.file.localeCompare(b.file) || (a.line ?? 0) - (b.line ?? 0) || a.rule.localeCompare(b.rule));
754
790
  }
@@ -791,7 +827,12 @@ export function makeBaseline(findings: Pick<ScanFinding, "rule" | "file" | "fing
791
827
  export function applyBaseline<T extends Pick<ScanFinding, "rule" | "file" | "fingerprint">>(findings: T[], baseline: unknown): { kept: T[]; baselined: number } {
792
828
  const b = baseline as ScanBaseline | null;
793
829
  if (!b || b.version !== 1 || !Array.isArray(b.findings)) return { kept: findings, baselined: 0 };
794
- const known = new Set(b.findings.map((f) => `${f.rule}\0${f.file}\0${f.fingerprint}`));
830
+ // An entry that is not {rule, file, fingerprint} hides nothing.
831
+ const entries = (b.findings as unknown[]).filter(
832
+ (f): f is ScanBaseline["findings"][number] =>
833
+ !!f && typeof f === "object" && typeof (f as any).rule === "string" && typeof (f as any).file === "string" && typeof (f as any).fingerprint === "string",
834
+ );
835
+ const known = new Set(entries.map((f) => `${f.rule}\0${f.file}\0${f.fingerprint}`));
795
836
  const kept = findings.filter((f) => !known.has(`${f.rule}\0${f.file}\0${f.fingerprint}`));
796
837
  return { kept, baselined: findings.length - kept.length };
797
838
  }