@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/dist/src/completions.js +23 -4
- package/dist/src/cron.d.ts +17 -0
- package/dist/src/cron.js +107 -0
- package/dist/src/frontmatter-fields.d.ts +27 -0
- package/dist/src/frontmatter-fields.js +197 -0
- package/dist/src/frontmatter-schema.d.ts +15 -0
- package/dist/src/frontmatter-schema.js +156 -0
- package/dist/src/scan.d.ts +6 -0
- package/dist/src/scan.js +94 -47
- package/dist/src/store.d.ts +9 -2
- package/dist/src/store.js +5 -3
- package/package.json +1 -1
- package/src/completions.ts +24 -4
- package/src/cron.ts +120 -0
- package/src/frontmatter-fields.ts +225 -0
- package/src/frontmatter-schema.ts +167 -0
- package/src/scan.ts +96 -47
- package/src/store.ts +5 -3
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:
|
|
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
|
|
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
|
-
"
|
|
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
|
|
590
|
-
//
|
|
591
|
-
|
|
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.
|
|
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
|
|
733
|
-
|
|
734
|
-
|
|
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
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
]);
|