@foldrun/core 1.2.0 → 1.4.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.
package/src/scan.ts CHANGED
@@ -29,6 +29,8 @@
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";
33
+ import { frontmatterProblems, schemaKindOf } from "./frontmatter-schema.ts";
32
34
 
33
35
  export type ScanLevel = "error" | "warn" | "info";
34
36
  export type ScanCategory = "secret" | "security" | "content" | "supply-chain" | "config";
@@ -69,6 +71,9 @@ export interface ScanOptions {
69
71
  outwardTools?: Iterable<string>;
70
72
  /** Only these rule ids (or categories, `secret`, `security`, …). */
71
73
  only?: string[];
74
+ /** The account AGENTS.md's `network:` — the level above the workspace's,
75
+ * which the files of one workspace do not carry. */
76
+ accountNetwork?: unknown;
72
77
  }
73
78
 
74
79
  export const OWASP_URL = "https://genai.owasp.org/resource/owasp-top-10-for-agentic-applications-for-2026";
@@ -123,7 +128,8 @@ export const SCAN_RULES: readonly ScanRule[] = [
123
128
  title: "Reads untrusted content, holds private data and can send it out",
124
129
  help:
125
130
  "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, " +
131
+ "Take one leg away: take it off the open network (`network: { level: trusted, allow: [the sites it needs] }` — a bare list only adds sites, " +
132
+ "and an agent that reads the web stays open), split the reading and the sending into two agents, " +
127
133
  "or guard its calls with a `hooks:` gate. An approval (`!`) on every outward step is the other answer.",
128
134
  owasp: ["ASI01", "ASI02", "ASI03"],
129
135
  },
@@ -171,10 +177,34 @@ export const SCAN_RULES: readonly ScanRule[] = [
171
177
  id: "config/unknown-field",
172
178
  category: "config",
173
179
  level: "warn",
174
- title: "A frontmatter field foldrun does not read — likely a typo",
180
+ title: "A frontmatter field foldrun does not read here",
175
181
  help:
176
182
  "A misspelt field is not an error to YAML, so it is silently ignored: `tolls: [web]` leaves the agent with no tools. " +
177
- "Use the field the message suggests.",
183
+ "A near-miss names the field it meant; a field read in another kind of file says where it belongs (warnings). " +
184
+ "A field like nothing foldrun reads is a note, since another tool may own it.",
185
+ },
186
+ {
187
+ id: "config/wrong-type",
188
+ category: "config",
189
+ level: "warn",
190
+ title: "A frontmatter field holding the wrong kind of value",
191
+ help: "A list where one name was meant, a map where a word was meant: the runtime does not read it. The message says what the field takes.",
192
+ },
193
+ {
194
+ id: "config/invalid-value",
195
+ category: "config",
196
+ level: "warn",
197
+ title: "A frontmatter value that does nothing",
198
+ help:
199
+ "`effort: extreme`, `size: giant`, `trigger: sometimes`, a cron the scheduler cannot read: each is ignored or falls back, " +
200
+ "so the agent or flow quietly does something else. The message lists what the field takes.",
201
+ },
202
+ {
203
+ id: "config/missing-field",
204
+ category: "config",
205
+ level: "warn",
206
+ title: "A required frontmatter field is missing",
207
+ help: "A skill with no description is never loaded: the model chooses skills by their descriptions.",
178
208
  },
179
209
  {
180
210
  id: "check/ignore-needs-reason",
@@ -329,6 +359,36 @@ export function redactSecret(secret: string): string {
329
359
  return secret.length <= 8 ? "****" : `${secret.slice(0, 4)}…${secret.slice(-2)}`;
330
360
  }
331
361
 
362
+ /** Every credential in a piece of text, as [value, what]. One reading for
363
+ * the secret rule and for any other rule that quotes the line. */
364
+ function credentialsIn(line: string): [string, string][] {
365
+ const out: [string, string][] = [];
366
+ const seen = new Set<string>();
367
+ const add = (v: string, what: string) => {
368
+ if (!seen.has(v)) {
369
+ seen.add(v);
370
+ out.push([v, what]);
371
+ }
372
+ };
373
+ for (const [re, what] of SECRET_SHAPES) {
374
+ if (what === "a private key") continue; // a header names a key; it is not one
375
+ for (const m of line.matchAll(re)) {
376
+ if (!PLACEHOLDER.test(m[0]) && entropy(m[0]) >= 3) add(m[0], what);
377
+ }
378
+ }
379
+ for (const m of line.matchAll(ASSIGNED)) if (credentialLike(m[2]!)) add(m[2]!, `\`${m[1]}\` with a literal value`);
380
+ for (const m of line.matchAll(BEARER)) if (credentialLike(m[1]!)) add(m[1]!, "a bearer token");
381
+ return out;
382
+ }
383
+
384
+ /** The text with every credential in it redacted — for a finding that
385
+ * quotes source, so no message ever carries a key whole. */
386
+ export function redactText(text: string): string {
387
+ let out = text;
388
+ for (const [v] of credentialsIn(text).sort((a, b) => b[0].length - a[0].length)) out = out.split(v).join(redactSecret(v));
389
+ return out;
390
+ }
391
+
332
392
  function scanSecrets(file: ScanFile, lines: string[], push: Push) {
333
393
  for (let i = 0; i < lines.length; i++) {
334
394
  const line = lines[i]!;
@@ -403,12 +463,12 @@ function scanContent(file: ScanFile, lines: string[], push: Push) {
403
463
  const kind = HIDDEN_NAMES.find(([re]) => re.test(line))?.[1] ?? "invisible characters";
404
464
  const n = line.match(HIDDEN)?.length ?? 0;
405
465
  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`);
466
+ push("content/hidden-unicode", file.path, i + 1, line, `${n} ${kind}${redactText(tagged)} — invisible to a reviewer, read by the model`);
407
467
  }
408
468
  // An instruction quoted inside a fence is an example of one, not one.
409
469
  if (data && !fenced) {
410
470
  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`);
471
+ 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
472
  }
413
473
  if (md) {
414
474
  for (const b of line.matchAll(BLOB)) {
@@ -475,29 +535,6 @@ function unpinned(pkg: string): boolean {
475
535
 
476
536
  // ── frontmatter ────────────────────────────────────────────────────────────
477
537
 
478
- /** What foldrun reads in an agent's frontmatter — the fields documented in
479
- * agents.md, the Claude Code subagent fields an import carries, and the
480
- * older spellings check already teaches about. Used only to suggest the
481
- * field a typo meant: an unknown key that is nothing like any of these is
482
- * left alone, since another tool may own it. */
483
- const AGENT_FIELDS = [
484
- "name", "description", "model", "effort", "size", "tools", "disallowedTools", "skills", "hooks", "scripts",
485
- "apis", "mcpServers", "agents", "subagents", "secrets", "limits", "network", "provider", "permissionMode",
486
- "runtime", "ask", "web", "budget", "timezone", "language", "region", "currency", "units", "schedule", "color",
487
- "memory", "foldrun_version", "use", "kind", "type", "notify", "triggers", "inputs", "outputs",
488
- ];
489
- const FLOW_FIELDS = [
490
- "name", "description", "trigger", "schedule", "timezone", "language", "region", "currency", "units", "model",
491
- "effort", "overlap", "priority", "throttle", "debounce", "sla", "catchup", "pause_when", "disable_after",
492
- "approvers", "approve_within", "notify", "at", "every", "budget", "limits", "size", "hooks", "inputs",
493
- "webhook", "signing_secret", "signature", "idempotency", "filter", "after", "tags", "network", "secrets",
494
- ];
495
- const TOOL_FIELDS = [
496
- "name", "description", "transport", "type", "base", "methods", "headers", "query", "auth", "run", "args",
497
- "command", "url", "env", "outward", "runtime", "secrets", "network", "timeout", "spec", "operations", "body",
498
- "oauth", "limits", "input", "output", "schema", "kind",
499
- ];
500
-
501
538
  function editDistance(a: string, b: string): number {
502
539
  const d = Array.from({ length: a.length + 1 }, (_, i) => [i, ...Array<number>(b.length).fill(0)]);
503
540
  for (let j = 1; j <= b.length; j++) d[0]![j] = j;
@@ -568,15 +605,13 @@ interface ToolFacts {
568
605
  lines: string[];
569
606
  }
570
607
 
571
- function scanAgents(agents: AgentFacts[], outward: Set<string>, toolDefs: Map<string, ToolFacts>, push: Push) {
608
+ function scanAgents(agents: AgentFacts[], outward: Set<string>, toolDefs: Map<string, ToolFacts>, push: Push, networks: { account?: unknown; workspace?: unknown }) {
572
609
  const byName = new Map(agents.map((a) => [a.name, a]));
573
610
  for (const a of agents) {
574
611
  const d = a.data;
575
612
  const tools = refNames(d.tools);
576
613
  const denied = new Set(refNames(d.disallowedTools));
577
614
  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
615
 
581
616
  // Untrusted: it reads the open web. (A webhook body is the other source;
582
617
  // that is a flow's trigger, and the flow-level rule is a later one.)
@@ -586,9 +621,11 @@ function scanAgents(agents: AgentFacts[], outward: Set<string>, toolDefs: Map<st
586
621
  const apis = Array.isArray(d.apis) ? d.apis : [];
587
622
  const mcp = d.mcpServers && typeof d.mcpServers === "object" ? Object.keys(d.mcpServers as object) : [];
588
623
  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);
624
+ // Out: a sandbox open to every site, a tool marked outward, or an API it
625
+ // may write to. The level is the runtime's own resolution — agent, then
626
+ // workspace, then account, and open by default for an agent that reads
627
+ // the web; a bare list of sites adds to a level, it does not set one.
628
+ const openNet = resolveNetwork({ account: networks.account, workspace: networks.workspace, agent: d.network, readsWeb }).level === "open";
592
629
  const outwardTools = tools.filter((t) => outward.has(t) && !denied.has(t));
593
630
  const writeApis = apis.filter((x: any) => WRITE_METHODS.test(JSON.stringify(x?.methods ?? x?.method ?? "")));
594
631
  const outLeg = [...(openNet ? ["an open network"] : []), ...outwardTools.map((t) => `outward tool ${t}`), ...writeApis.map((x: any) => `writes to api ${x?.name ?? "?"}`)];
@@ -598,7 +635,7 @@ function scanAgents(agents: AgentFacts[], outward: Set<string>, toolDefs: Map<st
598
635
  push(
599
636
  "security/lethal-trifecta", a.file, a.line, `trifecta ${a.name}`,
600
637
  `${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`,
638
+ `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
639
  );
603
640
  }
604
641
 
@@ -686,7 +723,7 @@ function ignored(ig: Ignores, rule: string, line?: number): boolean {
686
723
 
687
724
  // ── the scan ───────────────────────────────────────────────────────────────
688
725
 
689
- type Push = (rule: string, file: string, line: number | undefined, text: string, message: string) => void;
726
+ type Push = (rule: string, file: string, line: number | undefined, text: string, message: string, level?: ScanLevel) => void;
690
727
 
691
728
  /** Installed and built code: someone else's files, and a crypto library's
692
729
  * constants are not this workspace's keys. */
@@ -708,14 +745,14 @@ export function scanFiles(files: ScanFile[], options: ScanOptions = {}): ScanFin
708
745
  const seen = new Set<string>();
709
746
  const ignoresByFile = new Map<string, Ignores>();
710
747
 
711
- const push: Push = (rule, file, line, text, message) => {
748
+ const push: Push = (rule, file, line, text, message, level) => {
712
749
  if (!enabled(rule)) return;
713
750
  const ig = ignoresByFile.get(file);
714
751
  if (ig && ignored(ig, rule, line)) return;
715
752
  const key = `${rule}\0${file}\0${line ?? ""}\0${message}`;
716
753
  if (seen.has(key)) return;
717
754
  seen.add(key);
718
- findings.push({ rule, level: scanRule(rule)?.level ?? "warn", file, ...(line ? { line } : {}), message, fingerprint: findingFingerprint(rule, file, text) });
755
+ findings.push({ rule, level: level ?? scanRule(rule)?.level ?? "warn", file, ...(line ? { line } : {}), message, fingerprint: findingFingerprint(rule, file, text) });
719
756
  };
720
757
 
721
758
  const outward = new Set(options.outwardTools ?? []);
@@ -729,15 +766,21 @@ export function scanFiles(files: ScanFile[], options: ScanOptions = {}): ScanFin
729
766
  scanContent(f, lines, push);
730
767
  scanSupplyChain(f, lines, push);
731
768
 
732
- const kind = docKind(f.path);
733
- if (!kind) continue;
734
- const data = frontOf(f.content);
769
+ const schema = schemaKindOf(f.path);
770
+ // A skill with no frontmatter at all is the clearest one that never
771
+ // loads: read it as empty, so its required fields are reported.
772
+ const data = schema || docKind(f.path) ? (frontOf(f.content) ?? (schema === "skill" && !/^\uFEFF?---\r?\n/.test(f.content) ? {} : null)) : null;
735
773
  if (!data) continue;
736
- const known = kind === "agent" ? AGENT_FIELDS : kind === "flow" ? FLOW_FIELDS : TOOL_FIELDS;
737
- for (const key of Object.keys(data)) {
738
- const near = nearestField(key, known);
739
- if (near && near !== key) 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`);
774
+ if (schema) {
775
+ for (const p of frontmatterProblems(schema, data, nearestField)) {
776
+ // Fingerprint the key's own line, not the parsed value: a YAML alias
777
+ // can make the value circular, and JSON of it would throw.
778
+ const at = keyLine(lines, p.key);
779
+ push(p.rule, f.path, at, `${p.rule} ${at ? lines[at - 1] : p.key}`, p.message, p.level);
780
+ }
740
781
  }
782
+ const kind = docKind(f.path);
783
+ if (!kind) continue;
741
784
  if (kind === "agent") {
742
785
  const rel = f.path.replace(/^library\//, "");
743
786
  const name = /^agents\/([^/]+)\//.exec(rel)?.[1] ?? rel.replace(/^.*\/|\.md$/g, "");
@@ -748,7 +791,8 @@ export function scanFiles(files: ScanFile[], options: ScanOptions = {}): ScanFin
748
791
  toolDefs.set(f.path, { data, lines });
749
792
  }
750
793
  }
751
- scanAgents(agents, outward, toolDefs, push);
794
+ const workspaceMd = files.find((f) => f.path === "AGENTS.md");
795
+ scanAgents(agents, outward, toolDefs, push, { account: options.accountNetwork, workspace: workspaceMd ? frontOf(workspaceMd.content)?.network : undefined });
752
796
 
753
797
  return findings.sort((a, b) => a.file.localeCompare(b.file) || (a.line ?? 0) - (b.line ?? 0) || a.rule.localeCompare(b.rule));
754
798
  }
@@ -791,7 +835,12 @@ export function makeBaseline(findings: Pick<ScanFinding, "rule" | "file" | "fing
791
835
  export function applyBaseline<T extends Pick<ScanFinding, "rule" | "file" | "fingerprint">>(findings: T[], baseline: unknown): { kept: T[]; baselined: number } {
792
836
  const b = baseline as ScanBaseline | null;
793
837
  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}`));
838
+ // An entry that is not {rule, file, fingerprint} hides nothing.
839
+ const entries = (b.findings as unknown[]).filter(
840
+ (f): f is ScanBaseline["findings"][number] =>
841
+ !!f && typeof f === "object" && typeof (f as any).rule === "string" && typeof (f as any).file === "string" && typeof (f as any).fingerprint === "string",
842
+ );
843
+ const known = new Set(entries.map((f) => `${f.rule}\0${f.file}\0${f.fingerprint}`));
795
844
  const kept = findings.filter((f) => !known.has(`${f.rule}\0${f.file}\0${f.fingerprint}`));
796
845
  return { kept, baselined: findings.length - kept.length };
797
846
  }
package/src/store.ts CHANGED
@@ -21,6 +21,7 @@
21
21
  // An account is a named directory (per OKF: tenancy is a hosting concern).
22
22
 
23
23
  import { expandPlatformVars } from "./host-env.ts";
24
+ import { EFFORT_LEVELS, FLOW_TRIGGERS } from "./frontmatter-fields.ts";
24
25
  import type { BrowserPodOutcome } from "./browser-pod.ts";
25
26
  import fs from "node:fs";
26
27
  import path from "node:path";
@@ -1082,7 +1083,7 @@ export function resolveModel(value: unknown): string {
1082
1083
  // model is which brain, effort is how long it thinks before answering. The
1083
1084
  // five levels are the SDK's own union — we neither invent our own scale nor
1084
1085
  // narrow theirs, so a level added upstream is one line here.
1085
- export const EFFORT_LEVELS = ["low", "medium", "high", "xhigh", "max"] as const;
1086
+ export { EFFORT_LEVELS } from "./frontmatter-fields.ts";
1086
1087
  export type Effort = (typeof EFFORT_LEVELS)[number];
1087
1088
 
1088
1089
  const EFFORT_ALIASES: Record<string, Effort> = {
@@ -1464,7 +1465,8 @@ export interface FlowInfo {
1464
1465
  steps: FlowStep[];
1465
1466
  }
1466
1467
 
1467
- export const FLOW_TRIGGERS = ["manual", "schedule", "webhook", "email", "flow", "once", "watch", "storage"] as const;
1468
+ // The list lives with every other frontmatter field (frontmatter-fields.ts).
1469
+ export { FLOW_TRIGGERS } from "./frontmatter-fields.ts";
1468
1470
  export type FlowTrigger = (typeof FLOW_TRIGGERS)[number];
1469
1471
 
1470
1472
  // Skills and memory files belonging to one agent — for the graph view and
@@ -1624,7 +1626,7 @@ const OPTION_RE = /^(\s+)([a-z_-]+):(.*)$/;
1624
1626
  * publish" is prose that happens to contain a colon, and it used to be
1625
1627
  * swallowed as an unknown option, silently, along with any typo of a real
1626
1628
  * key. The consistency suite reads this list against the docs. */
1627
- const STEP_OPTION_KEYS = new Set([
1629
+ export const STEP_OPTION_KEYS: ReadonlySet<string> = new Set([
1628
1630
  "approve", "ask", "case", "delegate", "each", "effort", "else", "limits", "loop", "max", "max_turns", "model",
1629
1631
  "on-fail", "onfail", "output", "parallel", "preview", "retry", "schema", "timeout", "until", "verify", "wait", "when",
1630
1632
  ]);