@foldrun/core 2.3.0 → 2.5.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/agent-activity.d.ts +5 -0
- package/dist/src/agent-activity.js +31 -0
- package/dist/src/agent-import.js +1 -1
- package/dist/src/agent-tools.js +1 -1
- package/dist/src/agents-md.js +1 -1
- package/dist/src/alerts.d.ts +60 -0
- package/dist/src/alerts.js +161 -0
- package/dist/src/browser-pod.js +4 -0
- package/dist/src/capture.d.ts +23 -0
- package/dist/src/capture.js +80 -0
- package/dist/src/compare.d.ts +57 -0
- package/dist/src/compare.js +116 -0
- package/dist/src/completions.js +1 -0
- package/dist/src/deploy.d.ts +36 -1
- package/dist/src/deploy.js +170 -11
- package/dist/src/egress.d.ts +16 -1
- package/dist/src/evals.d.ts +5 -1
- package/dist/src/evals.js +26 -18
- package/dist/src/frontmatter-fields.js +9 -0
- package/dist/src/frontmatter-schema.js +3 -0
- package/dist/src/history.d.ts +4 -0
- package/dist/src/history.js +15 -0
- package/dist/src/hooks.js +1 -1
- package/dist/src/insights.d.ts +30 -0
- package/dist/src/insights.js +152 -0
- package/dist/src/issues.d.ts +140 -0
- package/dist/src/issues.js +302 -0
- package/dist/src/library.js +1 -1
- package/dist/src/limits.d.ts +8 -0
- package/dist/src/limits.js +36 -5
- package/dist/src/linkable.js +1 -1
- package/dist/src/matter.d.ts +8 -0
- package/dist/src/matter.js +28 -0
- package/dist/src/model-credential.d.ts +14 -0
- package/dist/src/model-credential.js +40 -0
- package/dist/src/model-env.d.ts +14 -2
- package/dist/src/model-env.js +34 -9
- package/dist/src/network.d.ts +12 -0
- package/dist/src/network.js +25 -1
- package/dist/src/notify.d.ts +16 -0
- package/dist/src/notify.js +17 -7
- package/dist/src/okf.js +1 -1
- package/dist/src/online-checks.d.ts +76 -0
- package/dist/src/online-checks.js +432 -0
- package/dist/src/operator.d.ts +277 -0
- package/dist/src/operator.js +176 -2
- package/dist/src/otel-config.d.ts +18 -0
- package/dist/src/otel-config.js +69 -0
- package/dist/src/otel.d.ts +38 -0
- package/dist/src/otel.js +85 -0
- package/dist/src/outcome.d.ts +40 -0
- package/dist/src/outcome.js +77 -0
- package/dist/src/package.js +1 -1
- package/dist/src/platform.d.ts +1 -1
- package/dist/src/prompt-layers.d.ts +30 -0
- package/dist/src/prompt-layers.js +77 -0
- package/dist/src/provider-health.js +1 -1
- package/dist/src/providers.d.ts +5 -0
- package/dist/src/providers.js +2 -2
- package/dist/src/run-container.d.ts +16 -2
- package/dist/src/run-container.js +57 -5
- package/dist/src/runner.d.ts +53 -2
- package/dist/src/runner.js +356 -70
- package/dist/src/runs-filter.d.ts +48 -0
- package/dist/src/runs-filter.js +73 -0
- package/dist/src/scan.d.ts +19 -1
- package/dist/src/scan.js +60 -7
- package/dist/src/starter.js +4 -2
- package/dist/src/step-exec.d.ts +29 -1
- package/dist/src/step-exec.js +106 -6
- package/dist/src/store.d.ts +130 -3
- package/dist/src/store.js +145 -6
- package/dist/src/tokens.d.ts +38 -0
- package/dist/src/tokens.js +57 -0
- package/dist/src/tool-names.js +1 -1
- package/dist/src/tool-programs.js +1 -1
- package/dist/src/trace-tree.d.ts +40 -0
- package/dist/src/trace-tree.js +156 -0
- package/dist/src/translator.d.ts +8 -0
- package/dist/src/translator.js +7 -2
- package/dist/src/trigger-log.d.ts +1 -1
- package/dist/src/workspace-check.d.ts +18 -0
- package/dist/src/workspace-check.js +34 -0
- package/package.json +2 -2
- package/src/agent-activity.ts +32 -0
- package/src/agent-import.ts +1 -1
- package/src/agent-tools.ts +1 -1
- package/src/agents-md.ts +1 -1
- package/src/alerts.ts +190 -0
- package/src/browser-pod.ts +3 -0
- package/src/capture.ts +88 -0
- package/src/compare.ts +146 -0
- package/src/completions.ts +1 -0
- package/src/deploy.ts +184 -10
- package/src/egress.ts +12 -2
- package/src/evals.ts +26 -18
- package/src/frontmatter-fields.ts +9 -0
- package/src/frontmatter-schema.ts +3 -0
- package/src/history.ts +13 -0
- package/src/hooks.ts +1 -1
- package/src/insights.ts +174 -0
- package/src/issues.ts +396 -0
- package/src/library.ts +1 -1
- package/src/limits.ts +38 -5
- package/src/linkable.ts +1 -1
- package/src/matter.ts +33 -0
- package/src/model-credential.ts +45 -0
- package/src/model-env.ts +42 -10
- package/src/network.ts +25 -1
- package/src/notify.ts +22 -7
- package/src/okf.ts +1 -1
- package/src/online-checks.ts +450 -0
- package/src/operator.ts +213 -3
- package/src/otel-config.ts +67 -0
- package/src/otel.ts +100 -0
- package/src/outcome.ts +90 -0
- package/src/package.ts +1 -1
- package/src/platform.ts +1 -1
- package/src/prompt-layers.ts +90 -0
- package/src/provider-health.ts +1 -1
- package/src/providers.ts +7 -2
- package/src/run-container.ts +66 -7
- package/src/runner.ts +363 -46
- package/src/runs-filter.ts +88 -0
- package/src/scan.ts +68 -7
- package/src/starter.ts +4 -2
- package/src/step-exec.ts +147 -9
- package/src/store.ts +254 -8
- package/src/tokens.ts +68 -0
- package/src/tool-names.ts +1 -1
- package/src/tool-programs.ts +1 -1
- package/src/trace-tree.ts +187 -0
- package/src/translator.ts +13 -2
- package/src/trigger-log.ts +1 -0
- package/src/workspace-check.ts +41 -0
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
export interface RunFilter {
|
|
2
|
+
/** A step called a tool whose name contains this (`web`, `crm_export`). */
|
|
3
|
+
tool?: string;
|
|
4
|
+
/** A tool call errored. */
|
|
5
|
+
toolError?: boolean;
|
|
6
|
+
/** A step was this agent. */
|
|
7
|
+
agent?: string;
|
|
8
|
+
/** A step ran a model whose name contains this (`haiku`, `opus`). */
|
|
9
|
+
model?: string;
|
|
10
|
+
/** The run read at least this many input tokens, all steps together. */
|
|
11
|
+
minTokens?: number;
|
|
12
|
+
/** The run took at least this many seconds. */
|
|
13
|
+
minSecs?: number;
|
|
14
|
+
}
|
|
15
|
+
/** The query parameters a filter comes from: tool, tool_error, agent,
|
|
16
|
+
* model, min_tokens, min_secs. Unreadable numbers are refused in words. */
|
|
17
|
+
export declare function parseRunFilter(q: {
|
|
18
|
+
get(name: string): string | null;
|
|
19
|
+
}): {
|
|
20
|
+
filter: RunFilter;
|
|
21
|
+
problems: string[];
|
|
22
|
+
};
|
|
23
|
+
export declare const isEmptyFilter: (f: RunFilter) => boolean;
|
|
24
|
+
type FilterableStep = {
|
|
25
|
+
agent: string;
|
|
26
|
+
model?: string;
|
|
27
|
+
tools?: string[];
|
|
28
|
+
toolErrors?: number;
|
|
29
|
+
/** A whole record has events instead of the index's tools/toolErrors. */
|
|
30
|
+
events?: {
|
|
31
|
+
type: string;
|
|
32
|
+
text: string;
|
|
33
|
+
ms?: number;
|
|
34
|
+
err?: boolean;
|
|
35
|
+
}[];
|
|
36
|
+
tokens?: {
|
|
37
|
+
input?: number;
|
|
38
|
+
} | null;
|
|
39
|
+
startedAt?: string;
|
|
40
|
+
finishedAt?: string;
|
|
41
|
+
};
|
|
42
|
+
type FilterableRun = {
|
|
43
|
+
startedAt: string;
|
|
44
|
+
finishedAt?: string | null;
|
|
45
|
+
steps: FilterableStep[];
|
|
46
|
+
};
|
|
47
|
+
export declare function matchesRunFilter(run: FilterableRun, f: RunFilter, now?: number): boolean;
|
|
48
|
+
export {};
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// Search over runs (FOL-72 step 9): one filter for the runs API, the runs
|
|
2
|
+
// pages and `foldrun runs`, read off the run index — the tools each step
|
|
3
|
+
// called, its tool errors, its agent and model, tokens and how long it took.
|
|
4
|
+
// Pure.
|
|
5
|
+
/** The query parameters a filter comes from: tool, tool_error, agent,
|
|
6
|
+
* model, min_tokens, min_secs. Unreadable numbers are refused in words. */
|
|
7
|
+
export function parseRunFilter(q) {
|
|
8
|
+
const filter = {};
|
|
9
|
+
const problems = [];
|
|
10
|
+
const text = (k) => q.get(k)?.trim() || undefined;
|
|
11
|
+
const tool = text("tool");
|
|
12
|
+
if (tool)
|
|
13
|
+
filter.tool = tool.toLowerCase();
|
|
14
|
+
const te = text("tool_error");
|
|
15
|
+
if (te)
|
|
16
|
+
filter.toolError = te === "1" || te === "true" || te === "yes";
|
|
17
|
+
const agent = text("agent");
|
|
18
|
+
if (agent)
|
|
19
|
+
filter.agent = agent;
|
|
20
|
+
const model = text("model");
|
|
21
|
+
if (model)
|
|
22
|
+
filter.model = model.toLowerCase();
|
|
23
|
+
for (const [k, field] of [["min_tokens", "minTokens"], ["min_secs", "minSecs"]]) {
|
|
24
|
+
const v = text(k);
|
|
25
|
+
if (v === undefined)
|
|
26
|
+
continue;
|
|
27
|
+
// "50k" and "1.5k" are thousands (Codex review: "1.5k" read as 1.5).
|
|
28
|
+
const kilo = /^(\d+(?:\.\d+)?)k$/i.exec(v);
|
|
29
|
+
const n = kilo ? Number(kilo[1]) * 1000 : Number(v);
|
|
30
|
+
if (!Number.isFinite(n) || n < 0)
|
|
31
|
+
problems.push(`${k} is a number (${k === "min_tokens" ? "50000 or 50k" : "seconds"}), not ${v}`);
|
|
32
|
+
else
|
|
33
|
+
filter[field] = n;
|
|
34
|
+
}
|
|
35
|
+
return { filter, problems };
|
|
36
|
+
}
|
|
37
|
+
export const isEmptyFilter = (f) => Object.keys(f).length === 0;
|
|
38
|
+
/** The index's facts, or worked out from a whole record's events (Codex
|
|
39
|
+
* review: the workspace runs page reads whole records). */
|
|
40
|
+
function factsOf(s) {
|
|
41
|
+
if (s.tools !== undefined || s.toolErrors !== undefined || !s.events)
|
|
42
|
+
return { tools: s.tools ?? [], toolErrors: s.toolErrors ?? 0 };
|
|
43
|
+
const tools = new Set();
|
|
44
|
+
let errs = 0;
|
|
45
|
+
for (const e of s.events) {
|
|
46
|
+
if (e.type !== "tool")
|
|
47
|
+
continue;
|
|
48
|
+
if (e.ms == null)
|
|
49
|
+
tools.add(e.text);
|
|
50
|
+
else if (e.err)
|
|
51
|
+
errs++;
|
|
52
|
+
}
|
|
53
|
+
return { tools: [...tools], toolErrors: errs };
|
|
54
|
+
}
|
|
55
|
+
export function matchesRunFilter(run, f, now = Date.now()) {
|
|
56
|
+
const steps = (run.steps ?? []).map((s) => ({ ...s, ...factsOf(s) }));
|
|
57
|
+
if (f.tool && !steps.some((s) => (s.tools ?? []).some((t) => t.toLowerCase().includes(f.tool))))
|
|
58
|
+
return false;
|
|
59
|
+
if (f.toolError !== undefined && steps.some((s) => (s.toolErrors ?? 0) > 0) !== f.toolError)
|
|
60
|
+
return false;
|
|
61
|
+
if (f.agent && !steps.some((s) => s.agent === f.agent))
|
|
62
|
+
return false;
|
|
63
|
+
if (f.model && !steps.some((s) => (s.model ?? "").toLowerCase().includes(f.model)))
|
|
64
|
+
return false;
|
|
65
|
+
if (f.minTokens !== undefined && steps.reduce((n, s) => n + (s.tokens?.input ?? 0), 0) < f.minTokens)
|
|
66
|
+
return false;
|
|
67
|
+
if (f.minSecs !== undefined) {
|
|
68
|
+
const secs = ((run.finishedAt ? Date.parse(run.finishedAt) : now) - Date.parse(run.startedAt)) / 1000;
|
|
69
|
+
if (!(secs >= f.minSecs))
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
return true;
|
|
73
|
+
}
|
package/dist/src/scan.d.ts
CHANGED
|
@@ -25,6 +25,10 @@ export interface ScanFinding {
|
|
|
25
25
|
message: string;
|
|
26
26
|
/** Stable across edits elsewhere in the file: rule + file + line text. */
|
|
27
27
|
fingerprint: string;
|
|
28
|
+
/** This rule had more to say in this file than PER_FILE_RULE: this
|
|
29
|
+
* finding counts the rest, so what is not listed is not resolved. Its
|
|
30
|
+
* fingerprint is of everything it counts, so a change among them is new. */
|
|
31
|
+
truncated?: true;
|
|
28
32
|
}
|
|
29
33
|
export interface ScanOptions {
|
|
30
34
|
/** Tool names whose definition says `outward: true` — the CLI and the
|
|
@@ -66,11 +70,25 @@ export declare function redactSecret(secret: string): string;
|
|
|
66
70
|
/** The text with every credential in it redacted — for a finding that
|
|
67
71
|
* quotes source, so no message ever carries a key whole. */
|
|
68
72
|
export declare function redactText(text: string): string;
|
|
73
|
+
/**
|
|
74
|
+
* Text from outside the workspace — what a remote MCP server says about its
|
|
75
|
+
* tools — through the content rules with nothing the text itself can turn
|
|
76
|
+
* off: no foldrun-ignore, no binary or vendored-path skip, no fences
|
|
77
|
+
* (FOL-58, Codex review: each of those let a server silence its own finding).
|
|
78
|
+
*/
|
|
79
|
+
export declare function untrustedTextFindings(text: string): {
|
|
80
|
+
rule: string;
|
|
81
|
+
line: number;
|
|
82
|
+
message: string;
|
|
83
|
+
}[];
|
|
69
84
|
/** The known field a key was probably meant to be, or null. */
|
|
70
85
|
export declare function nearestField(key: string, known: readonly string[]): string | null;
|
|
86
|
+
/** How many times one rule is listed for one file before the rest are counted. */
|
|
87
|
+
export declare const PER_FILE_RULE = 100;
|
|
71
88
|
/**
|
|
72
89
|
* Every finding in these files, ignores applied. Findings are sorted by file
|
|
73
|
-
* then line, and one rule says one thing once per line
|
|
90
|
+
* then line, and one rule says one thing once per line, and at most
|
|
91
|
+
* PER_FILE_RULE times in one file.
|
|
74
92
|
*/
|
|
75
93
|
export declare function scanFiles(files: ScanFile[], options?: ScanOptions): ScanFinding[];
|
|
76
94
|
/**
|
package/dist/src/scan.js
CHANGED
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
// The reason is required: an ignore without one ignores nothing and is itself
|
|
27
27
|
// reported, because "why is this allowed?" is the question a reviewer asks.
|
|
28
28
|
import crypto from "node:crypto";
|
|
29
|
-
import matter from "
|
|
29
|
+
import matter from "./matter.js";
|
|
30
30
|
import { refNames } from "./refs.js";
|
|
31
31
|
import { resolveNetwork } from "./network.js";
|
|
32
32
|
import { frontmatterProblems, schemaKindOf } from "./frontmatter-schema.js";
|
|
@@ -117,6 +117,24 @@ export const SCAN_RULES = [
|
|
|
117
117
|
"so a run tomorrow uses what was tested today.",
|
|
118
118
|
owasp: ["ASI04"],
|
|
119
119
|
},
|
|
120
|
+
{
|
|
121
|
+
id: "supply-chain/known-vulnerability",
|
|
122
|
+
category: "supply-chain",
|
|
123
|
+
level: "warn",
|
|
124
|
+
title: "A pinned runtime package with known vulnerabilities",
|
|
125
|
+
help: "OSV.dev lists vulnerabilities for this exact version. Upgrade to a fixed one. An online check: `foldrun check --to <ws> --online`, " +
|
|
126
|
+
"Checks → Check online, and the daily rescan.",
|
|
127
|
+
owasp: ["ASI04"],
|
|
128
|
+
},
|
|
129
|
+
{
|
|
130
|
+
id: "content/mcp-tool-text",
|
|
131
|
+
category: "content",
|
|
132
|
+
level: "warn",
|
|
133
|
+
title: "An MCP server's tool text carries hidden or injected instructions",
|
|
134
|
+
help: "What a remote MCP server says about its tools reaches the model as instructions. Hidden characters or an order in a tool " +
|
|
135
|
+
"description is a server steering your agent. An online check, for servers reached by URL; a command server is never run to check it.",
|
|
136
|
+
owasp: ["ASI01", "ASI04"],
|
|
137
|
+
},
|
|
120
138
|
{
|
|
121
139
|
id: "config/unknown-field",
|
|
122
140
|
category: "config",
|
|
@@ -399,13 +417,15 @@ const INJECTION = [
|
|
|
399
417
|
/\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,
|
|
400
418
|
];
|
|
401
419
|
const BLOB = /[A-Za-z0-9+/]{300,}={0,2}/g;
|
|
402
|
-
function scanContent(file, lines, push) {
|
|
403
|
-
|
|
404
|
-
|
|
420
|
+
function scanContent(file, lines, push, untrusted = false) {
|
|
421
|
+
// Untrusted text (a remote server's) is read as data, every line: it
|
|
422
|
+
// cannot fence an order off as an "example" either.
|
|
423
|
+
const md = untrusted || /\.md$/i.test(file.path);
|
|
424
|
+
const data = untrusted || isDataDoc(file.path);
|
|
405
425
|
let fenced = false;
|
|
406
426
|
for (let i = 0; i < lines.length; i++) {
|
|
407
427
|
const line = i === 0 ? lines[i].replace(/^/, "") : lines[i];
|
|
408
|
-
if (/^\s*(?:```|~~~)/.test(line))
|
|
428
|
+
if (!untrusted && /^\s*(?:```|~~~)/.test(line))
|
|
409
429
|
fenced = !fenced;
|
|
410
430
|
HIDDEN.lastIndex = 0;
|
|
411
431
|
if (HIDDEN.test(line)) {
|
|
@@ -431,6 +451,17 @@ function scanContent(file, lines, push) {
|
|
|
431
451
|
}
|
|
432
452
|
}
|
|
433
453
|
}
|
|
454
|
+
/**
|
|
455
|
+
* Text from outside the workspace — what a remote MCP server says about its
|
|
456
|
+
* tools — through the content rules with nothing the text itself can turn
|
|
457
|
+
* off: no foldrun-ignore, no binary or vendored-path skip, no fences
|
|
458
|
+
* (FOL-58, Codex review: each of those let a server silence its own finding).
|
|
459
|
+
*/
|
|
460
|
+
export function untrustedTextFindings(text) {
|
|
461
|
+
const out = [];
|
|
462
|
+
scanContent({ path: "untrusted", content: text }, text.split(/\r?\n/), (rule, _file, line, _text, message) => out.push({ rule, line: line ?? 0, message }), true);
|
|
463
|
+
return out;
|
|
464
|
+
}
|
|
434
465
|
/** The text hidden in tag characters: each is U+E0000 + an ASCII code. */
|
|
435
466
|
function decodeTags(s) {
|
|
436
467
|
let out = "";
|
|
@@ -657,9 +688,12 @@ const RUNTIME_WRITTEN = /(?:^|\/)(?:\.cache|\.npm|\.local|\.pnpm-store|\.yarn|\.
|
|
|
657
688
|
function isText(f) {
|
|
658
689
|
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);
|
|
659
690
|
}
|
|
691
|
+
/** How many times one rule is listed for one file before the rest are counted. */
|
|
692
|
+
export const PER_FILE_RULE = 100;
|
|
660
693
|
/**
|
|
661
694
|
* Every finding in these files, ignores applied. Findings are sorted by file
|
|
662
|
-
* then line, and one rule says one thing once per line
|
|
695
|
+
* then line, and one rule says one thing once per line, and at most
|
|
696
|
+
* PER_FILE_RULE times in one file.
|
|
663
697
|
*/
|
|
664
698
|
export function scanFiles(files, options = {}) {
|
|
665
699
|
const want = options.only?.length ? new Set(options.only) : null;
|
|
@@ -667,6 +701,11 @@ export function scanFiles(files, options = {}) {
|
|
|
667
701
|
const findings = [];
|
|
668
702
|
const seen = new Set();
|
|
669
703
|
const ignoresByFile = new Map();
|
|
704
|
+
// One rule in one file says it PER_FILE_RULE times, then counts the rest:
|
|
705
|
+
// a million lines of hidden characters is one problem, not a million
|
|
706
|
+
// findings held in memory (Codex review of the 20 Oct rescan outage).
|
|
707
|
+
const perFileRule = new Map();
|
|
708
|
+
const more = new Map();
|
|
670
709
|
const push = (rule, file, line, text, message, level) => {
|
|
671
710
|
if (!enabled(rule))
|
|
672
711
|
return;
|
|
@@ -676,6 +715,16 @@ export function scanFiles(files, options = {}) {
|
|
|
676
715
|
const key = `${rule}\0${file}\0${line ?? ""}\0${message}`;
|
|
677
716
|
if (seen.has(key))
|
|
678
717
|
return;
|
|
718
|
+
const rf = `${rule}\0${file}`;
|
|
719
|
+
const said = perFileRule.get(rf) ?? 0;
|
|
720
|
+
if (said >= PER_FILE_RULE) {
|
|
721
|
+
const m = more.get(rf) ?? { rule, file, level, n: 0, hash: crypto.createHash("sha256") };
|
|
722
|
+
m.n++;
|
|
723
|
+
m.hash.update(`${findingFingerprint(rule, file, text)}\0`);
|
|
724
|
+
more.set(rf, m);
|
|
725
|
+
return;
|
|
726
|
+
}
|
|
727
|
+
perFileRule.set(rf, said + 1);
|
|
679
728
|
seen.add(key);
|
|
680
729
|
findings.push({ rule, level: level ?? scanRule(rule)?.level ?? "warn", file, ...(line ? { line } : {}), message, fingerprint: findingFingerprint(rule, file, text) });
|
|
681
730
|
};
|
|
@@ -719,8 +768,12 @@ export function scanFiles(files, options = {}) {
|
|
|
719
768
|
toolDefs.set(f.path, { data, lines });
|
|
720
769
|
}
|
|
721
770
|
}
|
|
722
|
-
|
|
771
|
+
// project.md is the older name the runtime still reads (Codex review, FOL-58).
|
|
772
|
+
const workspaceMd = files.find((f) => f.path === "AGENTS.md") ?? files.find((f) => f.path === "project.md");
|
|
723
773
|
scanAgents(agents, outward, toolDefs, push, { account: options.accountNetwork, workspace: workspaceMd ? frontOf(workspaceMd.content)?.network : undefined });
|
|
774
|
+
for (const m of more.values()) {
|
|
775
|
+
findings.push({ rule: m.rule, level: m.level ?? scanRule(m.rule)?.level ?? "warn", file: m.file, message: `and ${m.n} more like this in this file, not listed`, fingerprint: m.hash.digest("hex").slice(0, 16), truncated: true });
|
|
776
|
+
}
|
|
724
777
|
return findings.sort((a, b) => a.file.localeCompare(b.file) || (a.line ?? 0) - (b.line ?? 0) || a.rule.localeCompare(b.rule));
|
|
725
778
|
}
|
|
726
779
|
/**
|
package/dist/src/starter.js
CHANGED
|
@@ -357,7 +357,7 @@ All of it, \`/api/workspaces/<workspace>\` unless noted:
|
|
|
357
357
|
| \`/assistant\` | POST | ask the assistant, or have it write agents, flows, tools and skills — from the docs only; proposals come back, nothing is written |
|
|
358
358
|
|
|
359
359
|
Account-level, outside \`/workspaces\`: \`/api/secrets\`, \`/api/keys\`, \`/api/schedule\`,
|
|
360
|
-
\`/api/approvals\`, \`/api/usage\`, \`/api/library/<kind>\`, \`/api/team\`, \`/api/billing\`,
|
|
360
|
+
\`/api/approvals\`, \`/api/issues\` (what went wrong, open until resolved), \`/api/usage\`, \`/api/library/<kind>\`, \`/api/team\`, \`/api/billing\`,
|
|
361
361
|
\`/api/me/notifications\` (what mail you get; a key acts for whoever minted it),
|
|
362
362
|
\`/api/account/backups\` (snapshots, restore requests), \`/api/me/onboarding\` (getting started),
|
|
363
363
|
\`/api/healthz\` and \`/api/version\` (open), \`/api/changelog\` (release notes)
|
|
@@ -391,6 +391,7 @@ subflow), \`foldrun flow rm-step\` removes a step, and \`foldrun agent link
|
|
|
391
391
|
trigger: manual # manual | schedule | webhook
|
|
392
392
|
schedule: "0 9 * * 1" # cron, when trigger: schedule
|
|
393
393
|
timezone: Australia/Sydney
|
|
394
|
+
owner: ana@example.com # who answers for it; else the workspace's, else the account's
|
|
394
395
|
model: default # a default for every step here
|
|
395
396
|
effort: low
|
|
396
397
|
overlap: skip # skip | queue — what a second run does while one is live
|
|
@@ -448,10 +449,11 @@ by a model: what this role is for, what it must not do, what good looks like.
|
|
|
448
449
|
---
|
|
449
450
|
name: researcher
|
|
450
451
|
description: Finds and summarises sources.
|
|
452
|
+
owner: ana@example.com # who answers for it; else the workspace's, else the account's
|
|
451
453
|
model: default # fast | default | max
|
|
452
454
|
effort: low # how hard to think about it
|
|
453
455
|
size: large # small | large | heavy — the sandbox it rents
|
|
454
|
-
tools: [web, read, ask, my-folder-tool] # built-ins and your own tools/, one list; \`ask\` lets it ask you mid-step
|
|
456
|
+
tools: [web, read, ask, peers, my-folder-tool] # built-ins and your own tools/, one list; \`ask\` lets it ask you mid-step, \`peers\` lets it message the steps running beside it
|
|
455
457
|
disallowedTools: [Bash] # subtract from what it would otherwise have
|
|
456
458
|
skills: [house-style] # from skills/
|
|
457
459
|
hooks: [no-force-push] # from hooks/ or the library: code at every tool call
|
package/dist/src/step-exec.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { type HookDef, type HookRecord } from "./hooks.ts";
|
|
2
2
|
import type { McpServerConfig } from "@anthropic-ai/claude-agent-sdk";
|
|
3
|
-
import type
|
|
3
|
+
import { type Effort, type ModelCall } from "./store.ts";
|
|
4
4
|
import { type TokenPrice } from "./catalog.ts";
|
|
5
5
|
import type { TestEffect } from "./test-mode.ts";
|
|
6
6
|
import type { OperatorEvent } from "./operator.ts";
|
|
@@ -57,6 +57,11 @@ export interface ExecOutcome {
|
|
|
57
57
|
browserPod?: BrowserPodOutcome;
|
|
58
58
|
}
|
|
59
59
|
export interface ExecOptions {
|
|
60
|
+
/** What the step reports beyond its events (FOL-54 step 4): `metadata`
|
|
61
|
+
* sends each model call (`turn`); `full` also sends each tool call's input
|
|
62
|
+
* and output (`io`). Unset sends neither — the consult, the probe and any
|
|
63
|
+
* caller that does not store them. */
|
|
64
|
+
capture?: "metadata" | "full";
|
|
60
65
|
/** `hooks:` resolved host-side (hooks.ts): run at this step's own dispatch,
|
|
61
66
|
* inside its sandbox. `hookBase` is what every hook event carries. */
|
|
62
67
|
hooks?: HookDef[];
|
|
@@ -175,7 +180,30 @@ export type EventExtra = {
|
|
|
175
180
|
subagent?: string;
|
|
176
181
|
check?: boolean;
|
|
177
182
|
hook?: HookRecord;
|
|
183
|
+
/** A tool call's content, on its way to the runner (FOL-54 step 4): the
|
|
184
|
+
* runner masks it, stores it beside the run and keeps only `captured` on
|
|
185
|
+
* the event. Sent only under `capture: "full"`. */
|
|
186
|
+
io?: CallIo;
|
|
187
|
+
/** One model call, on its way to the runner's `step.calls` — not an event. */
|
|
188
|
+
turn?: ModelCall;
|
|
189
|
+
/** The model's first response of the step has begun streaming — the
|
|
190
|
+
* moment the prompt cache entry its request wrote becomes readable. Sent
|
|
191
|
+
* once; the runner's fan-out gate reads it. Not an event. */
|
|
192
|
+
responding?: true;
|
|
193
|
+
/** Set by the runner once it stored `io` (RunEvent.captured). */
|
|
194
|
+
captured?: boolean;
|
|
195
|
+
/** On a tool call: the model call (its message id) that asked for it. */
|
|
196
|
+
inCall?: string;
|
|
178
197
|
};
|
|
198
|
+
/** A tool call's input (on the call) or output (on its completion), capped. */
|
|
199
|
+
export interface CallIo {
|
|
200
|
+
input?: string;
|
|
201
|
+
output?: string;
|
|
202
|
+
/** The whole input's / output's length, in characters, before any cut. */
|
|
203
|
+
inputChars?: number;
|
|
204
|
+
outputChars?: number;
|
|
205
|
+
truncated?: boolean;
|
|
206
|
+
}
|
|
179
207
|
/** Conservative per-token rates for a model nothing else can price — an
|
|
180
208
|
* unknown id on a gateway with no catalogue. Opus-class, so a ceiling
|
|
181
209
|
* still means something. Only ever reached when `knownPrice` below does
|
package/dist/src/step-exec.js
CHANGED
|
@@ -12,13 +12,14 @@ import fs from "node:fs";
|
|
|
12
12
|
import os from "node:os";
|
|
13
13
|
import path from "node:path";
|
|
14
14
|
import { query } from "@anthropic-ai/claude-agent-sdk";
|
|
15
|
+
import { CAPTURE_CAP } from "./store.js";
|
|
15
16
|
import { costOf } from "./catalog.js";
|
|
16
17
|
import { spawn } from "node:child_process";
|
|
17
18
|
import { checkPaths, checkBash, isFilesystemTool, linkWorkspace, workspaceLinkEnv, resolveAgentPath, isWithin } from "./confine.js";
|
|
18
19
|
import { DELEGATE_TOOLS, subagentGuard, toAgentDefinitions } from "./subagents.js";
|
|
19
20
|
import { hostSafeEnv } from "./host-env.js";
|
|
20
21
|
import { validateSchema, describeSchemaErrors, looksLikeSchema } from "./json-schema.js";
|
|
21
|
-
import { CallCounter,
|
|
22
|
+
import { CallCounter, callKeysFor } from "./limits.js";
|
|
22
23
|
import { classifyCall, podAt, readPodEvents } from "./browser-pod.js";
|
|
23
24
|
/** The four counts from one SDK usage object (snake_case, as the API
|
|
24
25
|
* reports it). The API counts fresh input apart from the cache's; here
|
|
@@ -50,6 +51,14 @@ export function verifyBudgetMs(timeoutSec, elapsedMs) {
|
|
|
50
51
|
/** After an interrupt is asked for, how long the model loop gets to wind
|
|
51
52
|
* down on its own before the query is aborted outright. */
|
|
52
53
|
export const INTERRUPT_GRACE_MS = 10_000;
|
|
54
|
+
// The cap plus slack: the runner masks, then cuts to the cap (capture.ts), so
|
|
55
|
+
// a secret on the edge is masked whole.
|
|
56
|
+
const SEND_CAP = CAPTURE_CAP + 4096;
|
|
57
|
+
function capped(text) {
|
|
58
|
+
if (text.length <= SEND_CAP)
|
|
59
|
+
return { text, chars: text.length, truncated: false };
|
|
60
|
+
return { text: text.slice(0, SEND_CAP), chars: text.length, truncated: true };
|
|
61
|
+
}
|
|
53
62
|
/** The tag on every event a failed check writes — `verify:`, `output: json`,
|
|
54
63
|
* a schema — headline and detail alike. The runner's no-retry rule for
|
|
55
64
|
* outward steps reads it (actedThenFailedCheck); reading the wording of the
|
|
@@ -228,6 +237,10 @@ async function executeStepInner(opts, runQuery) {
|
|
|
228
237
|
...(opts.effort ? { effort: opts.effort } : {}),
|
|
229
238
|
systemPrompt: opts.systemPrompt,
|
|
230
239
|
...(opts.maxTurns ? { maxTurns: opts.maxTurns } : {}),
|
|
240
|
+
// The raw stream, only when model calls are recorded: an assistant
|
|
241
|
+
// message carries usage from the call's start (output a few tokens) and
|
|
242
|
+
// no stop reason; the call's message_delta has both (FOL-72).
|
|
243
|
+
...(opts.capture ? { includePartialMessages: true } : {}),
|
|
231
244
|
// Sub-agents: the SDK's own delegation. Every definition carries an
|
|
232
245
|
// explicit tool list (an absent one inherits all of ours).
|
|
233
246
|
...(subagents ? { agents: toAgentDefinitions(subagents) } : {}),
|
|
@@ -313,7 +326,7 @@ async function executeStepInner(opts, runQuery) {
|
|
|
313
326
|
// refused before it runs: the tool, and any paid API behind it,
|
|
314
327
|
// never sees it.
|
|
315
328
|
if (counter.active) {
|
|
316
|
-
const refusal = counter.take(
|
|
329
|
+
const refusal = counter.take(callKeysFor(hookInput.tool_name, hookInput.tool_input, opts.toolOwners));
|
|
317
330
|
if (refusal) {
|
|
318
331
|
emit("info", `${refusal.message} (${hookInput.tool_name} refused)`, via ? { subagent: via } : undefined);
|
|
319
332
|
return { hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: refusal.message } };
|
|
@@ -330,7 +343,7 @@ async function executeStepInner(opts, runQuery) {
|
|
|
330
343
|
for (const r of v.records)
|
|
331
344
|
record(r, via);
|
|
332
345
|
const refundCall = () => { if (counter.active)
|
|
333
|
-
counter.refund(
|
|
346
|
+
counter.refund(callKeysFor(hookInput.tool_name, hookInput.tool_input, opts.toolOwners)); };
|
|
334
347
|
if (v.decision === "deny") {
|
|
335
348
|
refundCall();
|
|
336
349
|
return { hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: v.reason } };
|
|
@@ -407,7 +420,7 @@ async function executeStepInner(opts, runQuery) {
|
|
|
407
420
|
const fromGrantedServer = opts.mcpNames.some((n) => toolName.startsWith(`mcp__${n}__`));
|
|
408
421
|
// A call the hook counted and this refuses never ran: give it back.
|
|
409
422
|
const refund = () => { if (counter.active)
|
|
410
|
-
counter.refund(
|
|
423
|
+
counter.refund(callKeysFor(toolName, input, opts.toolOwners)); };
|
|
411
424
|
if (!opts.allowed.includes(toolName) && !fromGrantedServer) {
|
|
412
425
|
refund();
|
|
413
426
|
return {
|
|
@@ -480,6 +493,29 @@ async function executeStepInner(opts, runQuery) {
|
|
|
480
493
|
// ceiling and on the bill.
|
|
481
494
|
const turns = new Map();
|
|
482
495
|
let anonymousTurns = 0;
|
|
496
|
+
// Each model call as a span (FOL-54 step 4), keyed like `turns`: a turn's
|
|
497
|
+
// blocks arrive as several messages under one id, so it is updated in place
|
|
498
|
+
// and sent again; the runner keeps the latest by id.
|
|
499
|
+
const spans = new Map();
|
|
500
|
+
// From the raw stream: which call each agent is in (message_start), and each
|
|
501
|
+
// call's final output tokens and stop reason (message_delta).
|
|
502
|
+
const streaming = new Map();
|
|
503
|
+
let saidResponding = false;
|
|
504
|
+
const finals = new Map();
|
|
505
|
+
const withFinal = (span) => {
|
|
506
|
+
const f = finals.get(span.id);
|
|
507
|
+
return {
|
|
508
|
+
...span,
|
|
509
|
+
...(f?.output !== undefined ? { tokens: { ...span.tokens, output: f.output } } : {}),
|
|
510
|
+
// Without the stream (an older CLI), a call that used tools stopped to use them.
|
|
511
|
+
...(f?.stop ? { stop: f.stop } : !span.stop && span.tools > 0 ? { stop: "tool_use" } : {}),
|
|
512
|
+
};
|
|
513
|
+
};
|
|
514
|
+
// When each agent's next request went out — the step's own, and each
|
|
515
|
+
// sub-agent's apart, so one's tool results do not restart another's clock.
|
|
516
|
+
const lastInputAt = new Map();
|
|
517
|
+
const sentAt = (via) => lastInputAt.get(via ?? "") ?? loopStartedAt;
|
|
518
|
+
let loopStartedAt = Date.now();
|
|
483
519
|
const specByName = new Map((subagents ?? []).map((s) => [s.name, s]));
|
|
484
520
|
const priceOf = (message) => {
|
|
485
521
|
const parent = message.parent_tool_use_id;
|
|
@@ -560,13 +596,65 @@ async function executeStepInner(opts, runQuery) {
|
|
|
560
596
|
};
|
|
561
597
|
try {
|
|
562
598
|
for await (const message of q) {
|
|
599
|
+
// The session is up: the first model call is timed from here, not from
|
|
600
|
+
// before the SDK and its MCP servers started.
|
|
601
|
+
if (message.type === "system" && message.subtype === "init")
|
|
602
|
+
loopStartedAt = Date.now();
|
|
603
|
+
if (message.type === "stream_event") {
|
|
604
|
+
const ev = message.event;
|
|
605
|
+
const agentKey = message.parent_tool_use_id ?? "";
|
|
606
|
+
if (ev?.type === "message_start" && ev.message?.id) {
|
|
607
|
+
streaming.set(agentKey, ev.message.id);
|
|
608
|
+
// The step's own first response has begun: its prompt is in the
|
|
609
|
+
// cache now, so siblings held back to read it may start.
|
|
610
|
+
if (!agentKey && !saidResponding) {
|
|
611
|
+
saidResponding = true;
|
|
612
|
+
emit("info", "", { responding: true });
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
if (ev?.type === "message_delta") {
|
|
616
|
+
const id = streaming.get(agentKey);
|
|
617
|
+
if (id) {
|
|
618
|
+
finals.set(id, {
|
|
619
|
+
...(typeof ev.usage?.output_tokens === "number" ? { output: ev.usage.output_tokens } : {}),
|
|
620
|
+
...(ev.delta?.stop_reason ? { stop: ev.delta.stop_reason } : {}),
|
|
621
|
+
});
|
|
622
|
+
const span = spans.get(id);
|
|
623
|
+
if (span) {
|
|
624
|
+
const final = withFinal(span);
|
|
625
|
+
spans.set(id, final);
|
|
626
|
+
emit("info", "", { turn: final });
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
continue;
|
|
631
|
+
}
|
|
563
632
|
if (ended)
|
|
564
633
|
break;
|
|
565
634
|
if (message.type === "assistant") {
|
|
566
635
|
const m = message.message;
|
|
567
636
|
const u = m.usage;
|
|
637
|
+
const turnKey = m.id ?? `turn-${anonymousTurns++}`;
|
|
568
638
|
if (u)
|
|
569
|
-
turns.set(
|
|
639
|
+
turns.set(turnKey, { u, price: priceOf(message) });
|
|
640
|
+
if (opts.capture && u) {
|
|
641
|
+
const t = usageFromSdk(u);
|
|
642
|
+
const prior = spans.get(turnKey);
|
|
643
|
+
const span = {
|
|
644
|
+
id: turnKey,
|
|
645
|
+
n: prior?.n ?? spans.size + 1,
|
|
646
|
+
at: prior?.at ?? new Date().toISOString(),
|
|
647
|
+
ms: prior?.ms ?? Date.now() - sentAt(subagentOf(message)),
|
|
648
|
+
...(m.model ? { model: m.model } : prior?.model ? { model: prior.model } : {}),
|
|
649
|
+
...(m.stop_reason ? { stop: m.stop_reason } : prior?.stop ? { stop: prior.stop } : {}),
|
|
650
|
+
tokens: { input: t.inputTokens, output: t.outputTokens, cacheRead: t.cacheReadTokens, cacheWrite: t.cacheWriteTokens },
|
|
651
|
+
tools: (prior?.tools ?? 0) + (m.content ?? []).filter((b) => b?.type === "tool_use").length,
|
|
652
|
+
...(subagentOf(message) ? { subagent: subagentOf(message) } : {}),
|
|
653
|
+
};
|
|
654
|
+
const final = withFinal(span);
|
|
655
|
+
spans.set(turnKey, final);
|
|
656
|
+
emit("info", "", { turn: final });
|
|
657
|
+
}
|
|
570
658
|
if (ceiling && u) {
|
|
571
659
|
spentUsd = turnsTotal();
|
|
572
660
|
if (spentUsd >= ceiling) {
|
|
@@ -590,6 +678,8 @@ async function executeStepInner(opts, runQuery) {
|
|
|
590
678
|
const input = block.input;
|
|
591
679
|
const to = String(input?.subagent_type ?? "subagent");
|
|
592
680
|
delegations.set(block.id, to);
|
|
681
|
+
// That sub-agent's first model call is timed from its job going out.
|
|
682
|
+
lastInputAt.set(to, Date.now());
|
|
593
683
|
// One line a person can read: who got the job, and what it was.
|
|
594
684
|
const job = String(input?.description ?? input?.prompt ?? "").replace(/\s+/g, " ").trim().slice(0, 160);
|
|
595
685
|
emit("info", `delegating to ${to}${job ? `: ${job}` : ""}`, { subagent: to });
|
|
@@ -597,7 +687,13 @@ async function executeStepInner(opts, runQuery) {
|
|
|
597
687
|
openCalls.set(block.id, { name: block.name, at: Date.now(), ...(via ? { subagent: via } : {}) });
|
|
598
688
|
if (pod)
|
|
599
689
|
ledger.set(block.id, classifyCall(block.name, block.input, pod));
|
|
600
|
-
|
|
690
|
+
const input = opts.capture === "full" ? capped(JSON.stringify(block.input ?? {})) : null;
|
|
691
|
+
emit("tool", block.name, {
|
|
692
|
+
call: block.id,
|
|
693
|
+
...(opts.capture ? { inCall: turnKey } : {}),
|
|
694
|
+
...(via ? { subagent: via } : {}),
|
|
695
|
+
...(input ? { io: { input: input.text, inputChars: input.chars, ...(input.truncated ? { truncated: true } : {}) } } : {}),
|
|
696
|
+
});
|
|
601
697
|
}
|
|
602
698
|
}
|
|
603
699
|
}
|
|
@@ -613,6 +709,7 @@ async function executeStepInner(opts, runQuery) {
|
|
|
613
709
|
if (!open)
|
|
614
710
|
continue;
|
|
615
711
|
openCalls.delete(block.tool_use_id);
|
|
712
|
+
const output = opts.capture === "full" ? capped(toolResultText(block.content)) : null;
|
|
616
713
|
emit("tool", open.name, {
|
|
617
714
|
call: block.tool_use_id,
|
|
618
715
|
ms: Date.now() - open.at,
|
|
@@ -621,6 +718,7 @@ async function executeStepInner(opts, runQuery) {
|
|
|
621
718
|
bytes: toolResultText(block.content).length,
|
|
622
719
|
...(block.is_error ? { err: true } : {}),
|
|
623
720
|
...(open.subagent ? { subagent: open.subagent } : {}),
|
|
721
|
+
...(output ? { io: { output: output.text, outputChars: output.chars, ...(output.truncated ? { truncated: true } : {}) } } : {}),
|
|
624
722
|
});
|
|
625
723
|
// Why it failed, on the trail. The span above carried only `err`,
|
|
626
724
|
// so a refused call left the agent's own guess as the only account
|
|
@@ -633,6 +731,8 @@ async function executeStepInner(opts, runQuery) {
|
|
|
633
731
|
}
|
|
634
732
|
readPod(block.tool_use_id);
|
|
635
733
|
}
|
|
734
|
+
// That agent's next model call is timed from here: the results went back.
|
|
735
|
+
lastInputAt.set(subagentOf(message) ?? "", Date.now());
|
|
636
736
|
}
|
|
637
737
|
}
|
|
638
738
|
else if (message.type === "result") {
|