@foldrun/core 2.6.0 → 2.8.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/capture.js +39 -1
- package/dist/src/delegate-tools.d.ts +45 -0
- package/dist/src/delegate-tools.js +153 -0
- package/dist/src/egress.d.ts +43 -2
- package/dist/src/frontmatter-fields.js +2 -1
- package/dist/src/oauth-presets.d.ts +7 -0
- package/dist/src/oauth-presets.js +7 -0
- package/dist/src/operator.d.ts +19 -0
- package/dist/src/operator.js +29 -0
- package/dist/src/platform.d.ts +8 -0
- package/dist/src/run-container.d.ts +9 -2
- package/dist/src/run-container.js +38 -2
- package/dist/src/runner.d.ts +21 -1
- package/dist/src/runner.js +362 -14
- package/dist/src/scan.js +17 -1
- package/dist/src/secret-files.js +23 -2
- package/dist/src/secrets.d.ts +62 -2
- package/dist/src/secrets.js +206 -20
- package/dist/src/starter.js +1 -0
- package/dist/src/store.d.ts +34 -0
- package/dist/src/store.js +12 -0
- package/dist/src/subagents.d.ts +14 -0
- package/dist/src/subagents.js +8 -0
- package/package.json +1 -1
- package/src/capture.ts +33 -1
- package/src/delegate-tools.ts +175 -0
- package/src/egress.ts +38 -3
- package/src/frontmatter-fields.ts +2 -1
- package/src/oauth-presets.ts +7 -0
- package/src/operator.ts +52 -0
- package/src/platform.ts +5 -0
- package/src/run-container.ts +46 -3
- package/src/runner.ts +362 -12
- package/src/scan.ts +19 -1
- package/src/secret-files.ts +22 -2
- package/src/secrets.ts +237 -23
- package/src/starter.ts +1 -0
- package/src/store.ts +38 -0
- package/src/subagents.ts +19 -0
package/src/runner.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { latestRevisionId } from "./history.ts";
|
|
|
7
7
|
import { recordCallIo, redactionsFor } from "./capture.ts";
|
|
8
8
|
import { AUTH_REFUSAL } from "./issues.ts";
|
|
9
9
|
import { hookNames } from "./hooks.ts";
|
|
10
|
+
import crypto from "node:crypto";
|
|
10
11
|
import fs from "node:fs";
|
|
11
12
|
import os from "node:os";
|
|
12
13
|
import path from "node:path";
|
|
@@ -36,7 +37,10 @@ const isolatedRun = () => {
|
|
|
36
37
|
return mode === "container" || (!!mode && mode in platform.isolation);
|
|
37
38
|
};
|
|
38
39
|
import { gatherConsults, buildConsultTools } from "./agent-tools.ts";
|
|
39
|
-
import { DELEGATE_TOOL, gatherSubagents, subagentPrompt, subagentTools, type SubagentSpec } from "./subagents.ts";
|
|
40
|
+
import { DELEGATE_TOOL, gatherSubagents, ownSandbox, subagentPrompt, subagentTools, type DelegateSpec, type SubagentSpec } from "./subagents.ts";
|
|
41
|
+
import { DELEGATE_SERVER, buildDelegateTools, delegatePromptLine, delegateToolNames, localDelegateChannel } from "./delegate-tools.ts";
|
|
42
|
+
import type { DelegationResult } from "./operator.ts";
|
|
43
|
+
import type { StepDelegation } from "./store.ts";
|
|
40
44
|
import { ASK_TOOL, PEER_TOOLS, applyOperatorEvent, askPromptLine, askTimeoutSec, buildAskTool, buildPeerTools, inboxReader, localPeerBus, peersPromptLine, type PeerInfo } from "./operator.ts";
|
|
41
45
|
import { sendPlainNotification } from "./notify.ts";
|
|
42
46
|
import { TOOL_MAP, BUILTIN_TOOLS, ownToolNames, toolRefs, legacyUseNames, legacyUseError, retiredToolNames, retiredToolError } from "./tool-names.ts";
|
|
@@ -71,7 +75,7 @@ import {
|
|
|
71
75
|
checkFormatVersion,
|
|
72
76
|
readRun,
|
|
73
77
|
writeRun,
|
|
74
|
-
|
|
78
|
+
isSplicedRecord,
|
|
75
79
|
runSummary,
|
|
76
80
|
verdictOf,
|
|
77
81
|
type FlowStep,
|
|
@@ -90,7 +94,7 @@ import {
|
|
|
90
94
|
import { canReadBack } from "./tool-output.ts";
|
|
91
95
|
import { PromptLayers, promptCacheKey } from "./prompt-layers.ts";
|
|
92
96
|
import { loadCatalog, checkModel, clampEffort, catalogCost, findModel, tokenPriceOf, type Catalog, type TokenPrice } from "./catalog.ts";
|
|
93
|
-
import { resolveSecrets, getSecret, materializeSecrets, secretSites } from "./secrets.ts";
|
|
97
|
+
import { resolveSecrets, getSecret, materializeSecrets, secretSites, isClientCertValue, clientCertOf, withoutHeldCerts, isIdentityValue, identityOf } from "./secrets.ts";
|
|
94
98
|
import { resolveNetwork, networkLine, networkEnforceWanted, proxyEnv, EGRESS_CA_ENV } from "./network.ts";
|
|
95
99
|
import { materializeFileSecrets, cleanupFileSecrets } from "./secret-files.ts";
|
|
96
100
|
import { buildApiTools, secretsUsedByApi } from "./api-tools.ts";
|
|
@@ -618,6 +622,9 @@ export function buildSubagentSpecs(o: {
|
|
|
618
622
|
try {
|
|
619
623
|
const tags = o.tags ?? [];
|
|
620
624
|
const own = agentContext(sub.dir, o.tenant, tags, { runId: o.runId, agent: sub.name }, o.flowTimezone, true);
|
|
625
|
+
// sandbox: own — delegated to as a step of its own, not inside this
|
|
626
|
+
// one (buildDelegateSpecs).
|
|
627
|
+
if (ownSandbox(own.front)) continue;
|
|
621
628
|
const description = typeof own.front.description === "string" ? own.front.description.trim() : "";
|
|
622
629
|
if (!description) {
|
|
623
630
|
o.push("error", `subagents: "${sub.name}" has no description — the model picks a sub-agent by it; not delegated to`);
|
|
@@ -649,6 +656,60 @@ export function buildSubagentSpecs(o: {
|
|
|
649
656
|
return out;
|
|
650
657
|
}
|
|
651
658
|
|
|
659
|
+
/**
|
|
660
|
+
* The `sandbox: own` sub-agents a step may hand jobs to: a name and a
|
|
661
|
+
* description each, all the parent's sandbox needs. What they may hold is
|
|
662
|
+
* read from their own file when a job arrives — never cut to the parent's,
|
|
663
|
+
* and never seen by it. `canRun` is false where nothing can start their
|
|
664
|
+
* sandbox (a sub-agent's own step: depth one; a step outside a run).
|
|
665
|
+
*/
|
|
666
|
+
export function buildDelegateSpecs(o: {
|
|
667
|
+
names: unknown;
|
|
668
|
+
self: string;
|
|
669
|
+
workspaceRoot: string;
|
|
670
|
+
canRun: boolean;
|
|
671
|
+
push: (type: "info" | "error", text: string) => void;
|
|
672
|
+
/** Told, per offered sub-agent, whether a tool it holds acts outside the
|
|
673
|
+
* workspace — a delegating step whose job may have sent something must
|
|
674
|
+
* not be re-run as if it had only read (launchStep's retry policy). */
|
|
675
|
+
outward?: (name: string, outward: boolean) => void;
|
|
676
|
+
tenant?: string;
|
|
677
|
+
tags?: string[];
|
|
678
|
+
}): DelegateSpec[] {
|
|
679
|
+
const out: DelegateSpec[] = [];
|
|
680
|
+
for (const sub of gatherSubagents(o.workspaceRoot, o.names).found) {
|
|
681
|
+
if (sub.name === o.self) continue; // buildSubagentSpecs says so
|
|
682
|
+
let front: Record<string, unknown>;
|
|
683
|
+
try {
|
|
684
|
+
front = matter(fs.readFileSync(path.join(sub.dir, "agent.md"), "utf8")).data as Record<string, unknown>;
|
|
685
|
+
} catch {
|
|
686
|
+
continue; // buildSubagentSpecs says so
|
|
687
|
+
}
|
|
688
|
+
if (!ownSandbox(front)) continue;
|
|
689
|
+
const description = typeof front.description === "string" ? front.description.trim() : "";
|
|
690
|
+
if (!description) {
|
|
691
|
+
o.push("error", `subagents: "${sub.name}" has no description — the model picks a sub-agent by it; not delegated to`);
|
|
692
|
+
continue;
|
|
693
|
+
}
|
|
694
|
+
if (!o.canRun) {
|
|
695
|
+
o.push("info", `subagents: "${sub.name}" runs in its own sandbox, which nothing here can start (a sub-agent cannot delegate again, and a step outside a run has no room for it) — not offered`);
|
|
696
|
+
continue;
|
|
697
|
+
}
|
|
698
|
+
if (o.outward) {
|
|
699
|
+
let acts = true; // unreadable is treated as able to act: the safe side
|
|
700
|
+
const tags = o.tags ?? [];
|
|
701
|
+
try {
|
|
702
|
+
acts = agentContext(sub.dir, o.tenant ?? "default", tags, { agent: sub.name }, null, true).outward;
|
|
703
|
+
} catch {
|
|
704
|
+
/* as above */
|
|
705
|
+
}
|
|
706
|
+
o.outward(sub.name, acts);
|
|
707
|
+
}
|
|
708
|
+
out.push({ name: sub.name, description });
|
|
709
|
+
}
|
|
710
|
+
return out;
|
|
711
|
+
}
|
|
712
|
+
|
|
652
713
|
export function sharedInstructions(agentDir: string, tenant: string): string | null {
|
|
653
714
|
const sections = [
|
|
654
715
|
["Everyone in this account", readAgentsMd(accountDir(tenant))?.body],
|
|
@@ -1485,7 +1546,8 @@ export function agentContext(
|
|
|
1485
1546
|
// the agent, and the date the platform believes it is.
|
|
1486
1547
|
{
|
|
1487
1548
|
...runtime.env,
|
|
1488
|
-
|
|
1549
|
+
// An unexported client certificate is the proxy's, never a script's.
|
|
1550
|
+
...withoutHeldCerts(secretEnv),
|
|
1489
1551
|
...(identity.runId ? { FOLDRUN_RUN_ID: identity.runId } : {}),
|
|
1490
1552
|
...(identity.agent ? { FOLDRUN_AGENT: identity.agent } : {}),
|
|
1491
1553
|
// And the workspace: a commit or a record made by a shared library
|
|
@@ -1766,6 +1828,9 @@ export function agentContext(
|
|
|
1766
1828
|
// asked for by name fails inside the container with "cannot find" while
|
|
1767
1829
|
// the host path, which uses this same merged spec, works fine.
|
|
1768
1830
|
runtime: { log: runtimeLog, error: runtimeError, executor, spec: runtimeSpec, dispose: runtime.dispose },
|
|
1831
|
+
// Whole: a bound client certificate goes from here into the egress
|
|
1832
|
+
// grant (runStep), which is where it is presented. Programs get theirs
|
|
1833
|
+
// filtered (withoutHeldCerts) where their environments are built.
|
|
1769
1834
|
secretEnv: { ...runtime.env, ...secretEnv },
|
|
1770
1835
|
formatWarning: checkFormatVersion(workspaceFrontmatter(agentDir, tenant).foldrun_version).warning,
|
|
1771
1836
|
providerEnv,
|
|
@@ -2022,6 +2087,10 @@ async function runStep(
|
|
|
2022
2087
|
/** The in-process colleagues' bus for this group (localPeerBus), when
|
|
2023
2088
|
* the step runs in this process rather than a pod. */
|
|
2024
2089
|
peerBus?: ReturnType<typeof localPeerBus>,
|
|
2090
|
+
/** Runs one job for a `sandbox: own` sub-agent as a step of its own and
|
|
2091
|
+
* answers with its reply (the drive loop's delegateFor). Absent for a
|
|
2092
|
+
* step that is itself a sub-agent's: depth one. */
|
|
2093
|
+
delegateJob?: (agent: string, job: string, cancelled: () => boolean) => Promise<DelegationResult>,
|
|
2025
2094
|
) {
|
|
2026
2095
|
// Secret values are injected into scripts as environment variables and
|
|
2027
2096
|
// substituted into API headers, so a model that reads one back — from a
|
|
@@ -2042,6 +2111,9 @@ async function runStep(
|
|
|
2042
2111
|
let fileDir: string | null = null;
|
|
2043
2112
|
let installDir: string | null = null;
|
|
2044
2113
|
let disposeRuntime: (() => void) | undefined;
|
|
2114
|
+
/** The delegate door served for this step (sandbox: own sub-agents), held
|
|
2115
|
+
* here so the finally can close it however the step ends. */
|
|
2116
|
+
let serving: { close(): Promise<void> } | null = null;
|
|
2045
2117
|
// Can this step's tools act outside the workspace? Set once its context is
|
|
2046
2118
|
// read; the verdict at the end of this function reads it.
|
|
2047
2119
|
let canActOutward = false;
|
|
@@ -2515,8 +2587,13 @@ async function runStep(
|
|
|
2515
2587
|
// Colleagues this agent may DELEGATE to: each with its own context and
|
|
2516
2588
|
// its own tools — resolved exactly as its own step's would be, then cut
|
|
2517
2589
|
// to what THIS step holds (subagents.ts). Built host-side, like consults.
|
|
2590
|
+
// A sandbox: own job is a sub-agent already: depth one, so neither kind
|
|
2591
|
+
// of delegation is offered to it.
|
|
2592
|
+
if (step.subagentOf && refNames(front.subagents).length) {
|
|
2593
|
+
push("info", `subagents: not offered — ${step.agent} is running a job for ${step.subagentOf}, and a sub-agent does not delegate again`);
|
|
2594
|
+
}
|
|
2518
2595
|
const subagents = buildSubagentSpecs({
|
|
2519
|
-
names: front.subagents, self: step.agent, workspaceRoot, tenant, tags, runId, flowTimezone,
|
|
2596
|
+
names: step.subagentOf ? [] : front.subagents, self: step.agent, workspaceRoot, tenant, tags, runId, flowTimezone,
|
|
2520
2597
|
parentAllowed: [...allowed, ...consultNames.map((n) => `mcp__${n}`)], push,
|
|
2521
2598
|
});
|
|
2522
2599
|
if (subagents.length) {
|
|
@@ -2525,6 +2602,29 @@ async function runStep(
|
|
|
2525
2602
|
allowed.push(DELEGATE_TOOL);
|
|
2526
2603
|
for (const s of subagents) push("info", `subagent: ${s.name} — ${s.tools.length ? s.tools.join(", ") : "no tools"}`);
|
|
2527
2604
|
}
|
|
2605
|
+
// sandbox: own sub-agents: a delegate_<name> tool each. Their jobs run as
|
|
2606
|
+
// steps of their own (delegate-tools.ts); dropped again below where no
|
|
2607
|
+
// door to the worker exists.
|
|
2608
|
+
let delegates = buildDelegateSpecs({
|
|
2609
|
+
names: step.subagentOf ? [] : front.subagents, self: step.agent, workspaceRoot, canRun: !!delegateJob && stepIndex !== undefined && stepIndex >= 0, push,
|
|
2610
|
+
tenant,
|
|
2611
|
+
tags,
|
|
2612
|
+
outward: (_name, acts) => void (canActOutward ||= acts),
|
|
2613
|
+
});
|
|
2614
|
+
const offerDelegates = (specs: DelegateSpec[]) => {
|
|
2615
|
+
for (const n of delegateToolNames(delegates)) if (allowed.includes(n)) allowed.splice(allowed.indexOf(n), 1);
|
|
2616
|
+
if (consultNames.includes(DELEGATE_SERVER)) consultNames.splice(consultNames.indexOf(DELEGATE_SERVER), 1);
|
|
2617
|
+
delegates = specs;
|
|
2618
|
+
if (!specs.length) return;
|
|
2619
|
+
allowed.push(...delegateToolNames(specs));
|
|
2620
|
+
consultNames.push(DELEGATE_SERVER);
|
|
2621
|
+
};
|
|
2622
|
+
offerDelegates(delegates);
|
|
2623
|
+
const delegateLine = delegates.length ? `\n\n${delegatePromptLine(delegates)}` : "";
|
|
2624
|
+
if (delegates.length) {
|
|
2625
|
+
prompt += delegateLine;
|
|
2626
|
+
for (const d of delegates) push("info", `subagent: ${d.name} — own sandbox, its own tools, secrets and network`);
|
|
2627
|
+
}
|
|
2528
2628
|
|
|
2529
2629
|
// The last provider refusal this step saw, for the fallback decision —
|
|
2530
2630
|
// watched on both paths, because a refusal reads the same from a pod
|
|
@@ -2686,17 +2786,57 @@ async function runStep(
|
|
|
2686
2786
|
readAgentsMd(accountDir(tenant))?.data?.network_enforce,
|
|
2687
2787
|
readAgentsMd(workspaceRootOf(agentDir))?.data?.network_enforce,
|
|
2688
2788
|
);
|
|
2789
|
+
// Keyless cloud identities the step declared: the proxy may mint tokens
|
|
2790
|
+
// for these audiences only, for this workload (oidc-issuer.ts).
|
|
2791
|
+
// A test run withholds them as it withholds every credential
|
|
2792
|
+
// (test-mode.ts): a token is a credential, however it is delivered.
|
|
2793
|
+
const declaredIdentities = Object.entries(liveSecrets).filter(([, v]) => typeof v === "string" && isIdentityValue(v));
|
|
2794
|
+
const identitiesHeld = testRun
|
|
2795
|
+
? withholdSecrets(Object.fromEntries(declaredIdentities), {
|
|
2796
|
+
outward: scriptSpecs.some((sc) => sc.outward),
|
|
2797
|
+
allow: scriptSpecs.some((sc) => sc.testMode === "allow"),
|
|
2798
|
+
}).withheld
|
|
2799
|
+
: [];
|
|
2800
|
+
const identities = Object.fromEntries(
|
|
2801
|
+
declaredIdentities.filter(([name]) => !identitiesHeld.includes(name)).map(([name, v]) => [name, { audience: identityOf(v).audience }]),
|
|
2802
|
+
);
|
|
2803
|
+
if (Object.keys(identities).length) grant.identities = identities;
|
|
2689
2804
|
const lease = await platform.egress.lease({
|
|
2690
2805
|
tenant,
|
|
2691
2806
|
runId: runId ?? "adhoc",
|
|
2692
2807
|
grant,
|
|
2693
2808
|
test: testRun,
|
|
2809
|
+
who: { workspace: workspaceName, agent: step.agent },
|
|
2694
2810
|
...(stepIndex !== undefined && stepIndex >= 0 ? { step: stepIndex } : {}),
|
|
2695
2811
|
// tools: [peers] — the roster the proxy serves, and the inboxes a
|
|
2696
2812
|
// colleague's message may be addressed to. Only when granted: a
|
|
2697
2813
|
// step without the tools has no reason to know its siblings.
|
|
2698
2814
|
...(peers && stepIndex !== undefined && stepIndex >= 0 ? { agent: step.agent, peers: siblings } : {}),
|
|
2815
|
+
// sandbox: own sub-agents: the only names the delegate door takes.
|
|
2816
|
+
...(delegates.length ? { delegates: delegates.map((d) => d.name) } : {}),
|
|
2699
2817
|
});
|
|
2818
|
+
// Serve those jobs while this step runs: each one becomes a step of
|
|
2819
|
+
// its own (delegateJob), and the door answers the long-poll with its
|
|
2820
|
+
// reply. No proxy, or a platform without the door: not offered — the
|
|
2821
|
+
// tools would only fail.
|
|
2822
|
+
if (delegates.length) {
|
|
2823
|
+
// Also: the executor must be able to stop ONE job — record its
|
|
2824
|
+
// sandbox (the resumable ones checkpoint it) and destroy it — or a job
|
|
2825
|
+
// whose parent stopped waiting would run on, sending and writing.
|
|
2826
|
+
const canStopAJob = !!platform.killSandbox && platform.sandboxResumable(isolation!);
|
|
2827
|
+
if (lease && platform.egress.delegations && delegateJob && stepIndex !== undefined && canStopAJob) {
|
|
2828
|
+
serving = platform.egress.delegations(
|
|
2829
|
+
// Re-attaching to a sandbox that outlived its driver: what it
|
|
2830
|
+
// queued before the restart is still its own, not a dead attempt's.
|
|
2831
|
+
{ tenant, runId: runId ?? "adhoc", step: stepIndex, resuming: !!step.sandbox && step.sandbox.kind === isolation },
|
|
2832
|
+
(j) => delegateJob(j.agent, j.job, j.cancelled ?? (() => false)),
|
|
2833
|
+
);
|
|
2834
|
+
} else {
|
|
2835
|
+
push("error", `subagents: ${delegates.map((d) => d.name).join(", ")} run in their own sandbox, and this install ${canStopAJob ? "has no door to start one from inside a step" : "cannot stop one job's sandbox on its own"} — not offered`);
|
|
2836
|
+
offerDelegates([]);
|
|
2837
|
+
prompt = prompt.replace(delegateLine, "");
|
|
2838
|
+
}
|
|
2839
|
+
}
|
|
2700
2840
|
// The forward door: every program in the sandbox sends through the
|
|
2701
2841
|
// proxy, which holds the site list and fills bound secrets. A proxy
|
|
2702
2842
|
// without the door (an older platform) leaves programs going direct.
|
|
@@ -2763,6 +2903,34 @@ async function runStep(
|
|
|
2763
2903
|
push("info", `test: ${name} withheld from the sandbox — TEST_MODE_WITHHELD`, { effect: { kind: "withheld", summary: `${name} withheld from the sandbox` } });
|
|
2764
2904
|
}
|
|
2765
2905
|
}
|
|
2906
|
+
// A keyless identity holds nothing to hand over: the sandbox gets the
|
|
2907
|
+
// path of a token file the driver keeps fresh through the proxy's
|
|
2908
|
+
// /identity door. Without a lease there is no door, and so no token.
|
|
2909
|
+
const identityDeclared = declared.filter((name) => typeof liveSecrets[name] === "string" && isIdentityValue(liveSecrets[name]!));
|
|
2910
|
+
for (const name of identityDeclared) delete sandboxSecrets[name];
|
|
2911
|
+
for (const name of identitiesHeld) {
|
|
2912
|
+
push("info", `test: ${name} withheld — no identity token on a test run`, { effect: { kind: "withheld", summary: `${name} withheld from the sandbox` } });
|
|
2913
|
+
}
|
|
2914
|
+
const identityNames = identityDeclared.filter((name) => !identitiesHeld.includes(name));
|
|
2915
|
+
if (identityNames.length) {
|
|
2916
|
+
if (lease) {
|
|
2917
|
+
const dir = "/tmp/foldrun-identity";
|
|
2918
|
+
sandboxSecrets.FOLDRUN_IDENTITIES = identityNames.join(",");
|
|
2919
|
+
sandboxSecrets.FOLDRUN_IDENTITY_DIR = dir;
|
|
2920
|
+
for (const name of identityNames) sandboxSecrets[`${name}_FILE`] = `${dir}/${name.toLowerCase()}.jwt`;
|
|
2921
|
+
push("info", `identity: ${identityNames.join(", ")} — a short-lived token in $NAME_FILE, renewed by the platform while the step runs`);
|
|
2922
|
+
} else {
|
|
2923
|
+
push("error", `identity: ${identityNames.join(", ")} needs a sandboxed step on the platform — no token here`);
|
|
2924
|
+
}
|
|
2925
|
+
}
|
|
2926
|
+
// A client certificate is the proxy's to present, to the exact sites it
|
|
2927
|
+
// is bound to; never the sandbox's, unless it was saved with `export`.
|
|
2928
|
+
for (const name of declared) {
|
|
2929
|
+
const v = liveSecrets[name];
|
|
2930
|
+
if (typeof v !== "string" || !isClientCertValue(v) || bound.includes(name) || clientCertOf(v).export) continue;
|
|
2931
|
+
delete sandboxSecrets[name];
|
|
2932
|
+
push("info", `secrets: ${name} is a client certificate — the egress proxy presents it to the sites it is bound to (\`foldrun secrets sites ${name} <host>\`); unbound, it stays out of the sandbox`);
|
|
2933
|
+
}
|
|
2766
2934
|
// On a test run the http tools always go through the proxy as
|
|
2767
2935
|
// placeholders, even when a script made the step materialise: the
|
|
2768
2936
|
// proxy is where the outward-write policy lives, and a header
|
|
@@ -2844,6 +3012,7 @@ async function runStep(
|
|
|
2844
3012
|
consults,
|
|
2845
3013
|
...(peers ? { peers: { self: step.agent, siblings } } : {}),
|
|
2846
3014
|
...(subagents.length ? { subagents } : {}),
|
|
3015
|
+
...(delegates.length ? { delegates } : {}),
|
|
2847
3016
|
timeoutSec: step.timeout,
|
|
2848
3017
|
budgetUsd: stepBudgetUsd,
|
|
2849
3018
|
budgetNote: stepBudgetNote,
|
|
@@ -3066,6 +3235,7 @@ async function runStep(
|
|
|
3066
3235
|
// to decide; a second go could send or charge twice.
|
|
3067
3236
|
step.image = outcome.image ?? null;
|
|
3068
3237
|
step.browserPod = outcome.browserPod ? { ...outcome.browserPod } : null;
|
|
3238
|
+
if (step.subagentOf && outcome.applied?.length) step.wrote = [...new Set([...(step.wrote ?? []), ...outcome.applied])];
|
|
3069
3239
|
if (outcome.status === "failed" && outcome.browserPod?.lost) {
|
|
3070
3240
|
const decision = podLossDecision(outcome.browserPod);
|
|
3071
3241
|
if (decision.rerun) {
|
|
@@ -3110,6 +3280,8 @@ async function runStep(
|
|
|
3110
3280
|
if (typeof line === "string") pushWatching("info", line);
|
|
3111
3281
|
else pushWatching("info", line.text, { effect: line.effect });
|
|
3112
3282
|
}
|
|
3283
|
+
await serving?.close();
|
|
3284
|
+
serving = null;
|
|
3113
3285
|
await lease.release();
|
|
3114
3286
|
}
|
|
3115
3287
|
// Nothing a test run left in storage/public/ reached storage/, so
|
|
@@ -3174,7 +3346,20 @@ async function runStep(
|
|
|
3174
3346
|
for (const name of held.withheld) {
|
|
3175
3347
|
push("info", `test: ${name} withheld from scripts — TEST_MODE_WITHHELD`, { effect: { kind: "withheld", summary: `${name} withheld from scripts` } });
|
|
3176
3348
|
}
|
|
3177
|
-
|
|
3349
|
+
// A client certificate not saved with `export` has no proxy here to
|
|
3350
|
+
// present it: it stays out of everything this step runs — tools,
|
|
3351
|
+
// consults, verify commands — not only out of the files.
|
|
3352
|
+
const stepSecrets = Object.fromEntries(
|
|
3353
|
+
Object.entries(held.env).filter(([name, v]) => {
|
|
3354
|
+
if (typeof v === "string" && isIdentityValue(v)) {
|
|
3355
|
+
push("error", `identity: ${name} needs a sandboxed step on the platform — no token here`);
|
|
3356
|
+
return false;
|
|
3357
|
+
}
|
|
3358
|
+
if (typeof v !== "string" || !isClientCertValue(v) || clientCertOf(v).export) return true;
|
|
3359
|
+
push("info", `secrets: ${name} is a client certificate — no egress proxy here to present it; save it with export to use it as files`);
|
|
3360
|
+
return false;
|
|
3361
|
+
}),
|
|
3362
|
+
);
|
|
3178
3363
|
// The model credential for this machine's Claude Code — the agent's
|
|
3179
3364
|
// own provider when it names one. The host allowlist drops it, so
|
|
3180
3365
|
// without this every in-process step ran on the machine's login.
|
|
@@ -3194,6 +3379,16 @@ async function runStep(
|
|
|
3194
3379
|
const peerChannel = peerBus && stepIndex !== undefined && stepIndex >= 0 ? peerBus.channelFor({ agent: step.agent, step: stepIndex }, siblings) : null;
|
|
3195
3380
|
const peerTools = peers ? buildPeerTools({ channel: peerChannel, self: step.agent, siblings, emit: push }) : null;
|
|
3196
3381
|
const localInbox = peerChannel ? inboxReader(peerChannel, push) : undefined;
|
|
3382
|
+
// sandbox: own sub-agents, here: no proxy between the step and this
|
|
3383
|
+
// process, so the job is handed to the drive loop directly.
|
|
3384
|
+
const delegateTools = delegates.length && delegateJob
|
|
3385
|
+
? buildDelegateTools({ specs: delegates, channel: localDelegateChannel(delegateJob), emit: push, timeoutSec: step.timeout })
|
|
3386
|
+
: null;
|
|
3387
|
+
// Said, not hidden: on this machine no step has a sandbox, so a job is
|
|
3388
|
+
// its own context, tools and secrets — but the same files and network.
|
|
3389
|
+
if (delegateTools?.server) {
|
|
3390
|
+
push("info", `subagents: ${delegates.map((d) => d.name).join(", ")} run here in this process — their own tools and secrets, but no sandbox: the same files and network as this step (on the platform each job gets a sandbox of its own)`);
|
|
3391
|
+
}
|
|
3197
3392
|
// @file secrets become 0600 paths here (host run), cleaned up in the
|
|
3198
3393
|
// finally — once, however many attempts follow.
|
|
3199
3394
|
const mat = materializeFileSecrets(agentDir, stepSecrets);
|
|
@@ -3235,6 +3430,7 @@ async function runStep(
|
|
|
3235
3430
|
...(deskTools.server ? { foldrun_desks: deskTools.server } : {}),
|
|
3236
3431
|
...(askTools ? { foldrun_ask: askTools.server } : {}),
|
|
3237
3432
|
...(peerTools ? { foldrun_peers: peerTools.server } : {}),
|
|
3433
|
+
...(delegateTools?.server ? { foldrun_delegate: delegateTools.server } : {}),
|
|
3238
3434
|
...(installTools ? { foldrun_install: installTools.server } : {}),
|
|
3239
3435
|
...mcpServers,
|
|
3240
3436
|
},
|
|
@@ -3353,6 +3549,15 @@ async function runStep(
|
|
|
3353
3549
|
step.status = "failed";
|
|
3354
3550
|
push("error", err instanceof Error ? err.message : String(err));
|
|
3355
3551
|
} finally {
|
|
3552
|
+
// The delegate door's service, when the step ended by throwing before
|
|
3553
|
+
// its lease was released: its jobs are settled, not left hanging.
|
|
3554
|
+
if (serving) {
|
|
3555
|
+
try {
|
|
3556
|
+
await serving.close();
|
|
3557
|
+
} catch {
|
|
3558
|
+
// best-effort — the door's records expire on their own
|
|
3559
|
+
}
|
|
3560
|
+
}
|
|
3356
3561
|
try {
|
|
3357
3562
|
cleanupFileSecrets(fileDir);
|
|
3358
3563
|
if (installDir) fs.rmSync(installDir, { recursive: true, force: true });
|
|
@@ -4038,7 +4243,14 @@ export function driveRun(
|
|
|
4038
4243
|
opts: { parkOnApproval?: boolean; effortOverride?: string | null } = {},
|
|
4039
4244
|
): Promise<void> {
|
|
4040
4245
|
drivingRuns.add(run.id);
|
|
4246
|
+
// The run's egress leases are renewed while it is driven, by whichever
|
|
4247
|
+
// door drove it — the queue, or a reconcile that picked it back up — so an
|
|
4248
|
+
// identity token is renewed for a live step however it was started.
|
|
4249
|
+
const renew = platform.egress.renew;
|
|
4250
|
+
const beat = renew ? setInterval(() => void renew(run.id).catch(() => undefined), 5 * 60_000) : null;
|
|
4251
|
+
beat?.unref?.();
|
|
4041
4252
|
return driveRunInner(tenant, workspace, run, modelOverride, tags, opts).finally(() => {
|
|
4253
|
+
if (beat) clearInterval(beat);
|
|
4042
4254
|
drivingRuns.delete(run.id);
|
|
4043
4255
|
});
|
|
4044
4256
|
}
|
|
@@ -4187,6 +4399,29 @@ function driveRunInner(
|
|
|
4187
4399
|
// last driver got, runStep passes that as `resume`, and the step
|
|
4188
4400
|
// carries on from where it was — a deploy no longer costs the run.
|
|
4189
4401
|
let orphaned = false;
|
|
4402
|
+
// A sandbox: own job whose driver went away is over: the reply it was
|
|
4403
|
+
// making went to a process that no longer exists, and the step that
|
|
4404
|
+
// asked is told it failed (the platform settles the door's side), so
|
|
4405
|
+
// its model can hand the job over again. Never resumed or relaunched.
|
|
4406
|
+
for (const step of run.steps) {
|
|
4407
|
+
if (!step.subagentOf || (step.status !== "running" && step.status !== "pending")) continue;
|
|
4408
|
+
// Its sandbox may still be executing — outward work included — while
|
|
4409
|
+
// the parent hands the job over again: destroy it before letting go
|
|
4410
|
+
// of the only reference to it.
|
|
4411
|
+
if (step.sandbox?.ref) {
|
|
4412
|
+
try {
|
|
4413
|
+
platform.killSandbox?.(step.sandbox);
|
|
4414
|
+
} catch {
|
|
4415
|
+
// a sandbox we cannot reach must not stop the run from resuming
|
|
4416
|
+
}
|
|
4417
|
+
}
|
|
4418
|
+
step.status = "failed";
|
|
4419
|
+
step.sandbox = null;
|
|
4420
|
+
step.events.push({ t: new Date().toISOString(), type: "error", text: "interrupted — the worker driving this job restarted; the step that asked for it was told it failed" });
|
|
4421
|
+
const parent = run.steps.find((s) => s.delegations?.some((d) => d.id === step.delegation));
|
|
4422
|
+
const entry = parent?.delegations?.find((d) => d.id === step.delegation);
|
|
4423
|
+
if (entry && entry.status === "running") entry.status = "failed";
|
|
4424
|
+
}
|
|
4190
4425
|
for (const step of run.steps) {
|
|
4191
4426
|
if (step.status !== "running") continue;
|
|
4192
4427
|
const resumable = !!step.sandbox && platform.sandboxResumable(step.sandbox.kind);
|
|
@@ -4324,7 +4559,7 @@ function driveRunInner(
|
|
|
4324
4559
|
// three handoffs below read the flow's steps only: counting the rescue
|
|
4325
4560
|
// too handed the next group the same output twice, or a stale one
|
|
4326
4561
|
// from an evaluator loop's earlier cycle.
|
|
4327
|
-
const flowStepsOf = (group: StepRecord[]) => group.filter((s) => !
|
|
4562
|
+
const flowStepsOf = (group: StepRecord[]) => group.filter((s) => !isSplicedRecord(s));
|
|
4328
4563
|
const dataOf = (all: StepRecord[]): unknown => {
|
|
4329
4564
|
const group = flowStepsOf(all);
|
|
4330
4565
|
const carrying = group.filter((s) => s.status === "completed" && s.data !== undefined);
|
|
@@ -4735,7 +4970,7 @@ function driveRunInner(
|
|
|
4735
4970
|
save();
|
|
4736
4971
|
if (freshGroup.every((s) => s.status !== "pending")) {
|
|
4737
4972
|
// everything in this group was rejected
|
|
4738
|
-
if (freshGroup.some((s) => s.status === "failed" && !s.optional && !s.carriedFrom && !
|
|
4973
|
+
if (freshGroup.some((s) => s.status === "failed" && !s.optional && !s.carriedFrom && !isSplicedRecord(s))) {
|
|
4739
4974
|
run.status = "failed";
|
|
4740
4975
|
break;
|
|
4741
4976
|
}
|
|
@@ -4753,7 +4988,7 @@ function driveRunInner(
|
|
|
4753
4988
|
// of the flow: one left pending by a drive that died is picked up by
|
|
4754
4989
|
// rescueStep below, never launched alongside the group (21 Oct 2026:
|
|
4755
4990
|
// a resumed run launched 77 leftover fixers at once).
|
|
4756
|
-
const launching = freshGroup.filter((s) => s.status === "pending" && !
|
|
4991
|
+
const launching = freshGroup.filter((s) => s.status === "pending" && !isSplicedRecord(s));
|
|
4757
4992
|
// tools: [peers] — what each step of the group is told about the
|
|
4758
4993
|
// others, and the bus they share when they run in this process.
|
|
4759
4994
|
const peerBus = localPeerBus();
|
|
@@ -4761,6 +4996,117 @@ function driveRunInner(
|
|
|
4761
4996
|
launching
|
|
4762
4997
|
.filter((s) => s !== step)
|
|
4763
4998
|
.map((s) => ({ agent: s.agent, step: run.steps.indexOf(s), instruction: (s.instruction ?? "").split("\n")[0].trim().slice(0, 200) }));
|
|
4999
|
+
// sandbox: own sub-agents (delegate-tools.ts): one job, run as a step
|
|
5000
|
+
// of its own — its own sandbox, tools, secrets and network, from its
|
|
5001
|
+
// own file — and answered with its reply. The record is APPENDED to
|
|
5002
|
+
// run.steps, in the parent's group: every running step's lease and
|
|
5003
|
+
// inbox are addressed by index, so nothing may shift under them.
|
|
5004
|
+
// Marked subagentOf, it is never launched, handed on or reset as a
|
|
5005
|
+
// step of the flow (isSplicedRecord). It gets no delegateJob of its
|
|
5006
|
+
// own: depth one.
|
|
5007
|
+
const delegateFor = (parent: StepRecord) => async (agent: string, job: string, cancelled: () => boolean): Promise<DelegationResult> => {
|
|
5008
|
+
const dir = path.join(pDir, "agents", agent);
|
|
5009
|
+
if (!fs.existsSync(path.join(dir, "agent.md"))) return { status: "failed", result: `${agent} is not an agent in this workspace` };
|
|
5010
|
+
// A stable id on both records: an index into run.steps moves when a
|
|
5011
|
+
// rescue or a fan-out is spliced in before the child.
|
|
5012
|
+
const id = `d_${crypto.randomBytes(5).toString("hex")}`;
|
|
5013
|
+
const child: StepRecord = {
|
|
5014
|
+
agent,
|
|
5015
|
+
subagentOf: parent.agent,
|
|
5016
|
+
delegation: id,
|
|
5017
|
+
instruction: job,
|
|
5018
|
+
group: parent.group,
|
|
5019
|
+
optional: true,
|
|
5020
|
+
timeout: parent.timeout,
|
|
5021
|
+
attempts: 1,
|
|
5022
|
+
status: "running",
|
|
5023
|
+
events: [],
|
|
5024
|
+
result: null,
|
|
5025
|
+
costUsd: null,
|
|
5026
|
+
};
|
|
5027
|
+
run.steps.push(child);
|
|
5028
|
+
const at = run.steps.length - 1;
|
|
5029
|
+
const entry: StepDelegation = { id, agent, status: "running" };
|
|
5030
|
+
(parent.delegations ??= []).push(entry);
|
|
5031
|
+
const started = Date.now();
|
|
5032
|
+
// The same budget rules as a step of the flow: nothing left means
|
|
5033
|
+
// not started (a zero ceiling would read as no cap at all), and
|
|
5034
|
+
// what it may spend is reserved while it runs, so two jobs at once
|
|
5035
|
+
// do not each get the agent's whole remaining budget.
|
|
5036
|
+
// The flow's budget: what is left after what has been spent AND
|
|
5037
|
+
// what every step and job running now holds — the parent's own
|
|
5038
|
+
// ceiling included. ceilingFor's equal share would hand the job a
|
|
5039
|
+
// second copy of an allowance its parent already holds.
|
|
5040
|
+
const held = [...reserved.values()].reduce((a, b) => a + b, 0);
|
|
5041
|
+
const flowLeft = run.budgetUsd && run.budgetUsd > 0 ? Math.max(0, run.budgetUsd - platform.runSpend(run) - held) : null;
|
|
5042
|
+
const ceiling = stepCeilingFor(
|
|
5043
|
+
flowLeft,
|
|
5044
|
+
agentBudgets.get(agent),
|
|
5045
|
+
run.steps
|
|
5046
|
+
.filter((s) => s.agent === agent)
|
|
5047
|
+
.reduce((sum, s) => sum + (s.costUsd ?? 0) + (s !== child && s.status === "running" ? (reserved.get(s) ?? 0) : 0), 0),
|
|
5048
|
+
agent,
|
|
5049
|
+
1,
|
|
5050
|
+
);
|
|
5051
|
+
if (ceiling.ceilingUsd === 0) {
|
|
5052
|
+
child.status = "failed";
|
|
5053
|
+
child.events.push({ t: new Date().toISOString(), type: "error", text: `over budget — nothing left to spend before this job (${ceiling.note}); it was not started` });
|
|
5054
|
+
entry.status = "failed";
|
|
5055
|
+
entry.ms = 0;
|
|
5056
|
+
save();
|
|
5057
|
+
return { status: "failed", result: `${agent} was not started: over budget (${ceiling.note})`, wrote: [] };
|
|
5058
|
+
}
|
|
5059
|
+
if (ceiling.ceilingUsd !== null) reserved.set(child, ceiling.ceilingUsd);
|
|
5060
|
+
save();
|
|
5061
|
+
// A running sandbox does not hear a stop flag: when the parent stops
|
|
5062
|
+
// waiting (its tool timed out, or its step ended), destroy the job's
|
|
5063
|
+
// sandbox, so nothing it does outlives the step that asked for it.
|
|
5064
|
+
const parentGone = () => run.stopRequested === true || cancelled() || (parent.status !== "running" && parent.status !== "pending");
|
|
5065
|
+
let killedIt = false;
|
|
5066
|
+
const watch = setInterval(() => {
|
|
5067
|
+
if (killedIt || !parentGone() || !child.sandbox?.ref) return;
|
|
5068
|
+
killedIt = true;
|
|
5069
|
+
child.events.push({ t: new Date().toISOString(), type: "error", text: "stopped — the step that asked for this job stopped waiting for it" });
|
|
5070
|
+
try {
|
|
5071
|
+
platform.killSandbox?.(child.sandbox);
|
|
5072
|
+
} catch {
|
|
5073
|
+
// its own deadline ends it
|
|
5074
|
+
}
|
|
5075
|
+
}, 2_000);
|
|
5076
|
+
watch.unref?.();
|
|
5077
|
+
try {
|
|
5078
|
+
await runStep(
|
|
5079
|
+
dir, tenant, child, null, save, modelOverride, tags, effortOverride, run.id, undefined,
|
|
5080
|
+
ceiling.ceilingUsd, ceiling.note, run.test === true,
|
|
5081
|
+
() => {
|
|
5082
|
+
save();
|
|
5083
|
+
// The parent's step ending or cancelling ends the job too —
|
|
5084
|
+
// here while it waits for room; the watch above once it runs.
|
|
5085
|
+
return parentGone();
|
|
5086
|
+
},
|
|
5087
|
+
flowTimezone, at,
|
|
5088
|
+
);
|
|
5089
|
+
} catch (err) {
|
|
5090
|
+
child.status = "failed";
|
|
5091
|
+
child.events.push({ t: new Date().toISOString(), type: "error", text: err instanceof Error ? err.message : String(err) });
|
|
5092
|
+
} finally {
|
|
5093
|
+
clearInterval(watch);
|
|
5094
|
+
reserved.delete(child);
|
|
5095
|
+
}
|
|
5096
|
+
// runStep mutates child.status; widen past TS's "running" narrowing.
|
|
5097
|
+
const ended: string = child.status;
|
|
5098
|
+
const status = ended === "completed" ? "completed" : ended === "skipped" ? "skipped" : "failed";
|
|
5099
|
+
entry.status = status;
|
|
5100
|
+
entry.costUsd = child.costUsd ?? null;
|
|
5101
|
+
entry.ms = Date.now() - started;
|
|
5102
|
+
save();
|
|
5103
|
+
const errors = child.events.filter((e) => e.type === "error").map((e) => e.text).join("\n");
|
|
5104
|
+
return {
|
|
5105
|
+
status,
|
|
5106
|
+
result: child.result ?? (errors || "(no reply)"),
|
|
5107
|
+
wrote: child.wrote ?? [],
|
|
5108
|
+
};
|
|
5109
|
+
};
|
|
4764
5110
|
// An agent's own `budget:` — the most it may spend in one run — is
|
|
4765
5111
|
// read at launch, so a cap raised mid-run applies to the next step.
|
|
4766
5112
|
const agentBudgets = new Map(listAgents(tenant, workspace).map((a) => [a.name, a.budget]));
|
|
@@ -4904,6 +5250,7 @@ function driveRunInner(
|
|
|
4904
5250
|
run.steps.indexOf(step),
|
|
4905
5251
|
siblingsOf(step),
|
|
4906
5252
|
peerBus,
|
|
5253
|
+
delegateFor(step),
|
|
4907
5254
|
).finally(() => reserved.delete(step));
|
|
4908
5255
|
// runStep mutates step.status; read it through a widened local
|
|
4909
5256
|
// so TS doesn't keep the "running" narrowing from above.
|
|
@@ -5027,8 +5374,9 @@ function driveRunInner(
|
|
|
5027
5374
|
// answered an earlier failure — an evaluator loop's previous cycle
|
|
5028
5375
|
// — not this one.
|
|
5029
5376
|
const leftovers: StepRecord[] = [];
|
|
5030
|
-
for (let j = run.steps.indexOf(step) + 1; j < run.steps.length &&
|
|
5377
|
+
for (let j = run.steps.indexOf(step) + 1; j < run.steps.length && isSplicedRecord(run.steps[j]); j++) {
|
|
5031
5378
|
const r = run.steps[j];
|
|
5379
|
+
if (r.subagentOf) continue;
|
|
5032
5380
|
if (r.agent === step.onFail && (r.rescueOf === undefined || r.rescueOf === step.agent) && (r.status === "pending" || r.status === "running")) {
|
|
5033
5381
|
leftovers.push(r);
|
|
5034
5382
|
}
|
|
@@ -5134,7 +5482,7 @@ function driveRunInner(
|
|
|
5134
5482
|
// A rescue record's own failure is already on the step it took over
|
|
5135
5483
|
// ("also failed"); counted here it would fail a group whose step a
|
|
5136
5484
|
// later rescue completed.
|
|
5137
|
-
const requiredFailed = freshGroup.some((s) => s.status === "failed" && !s.optional && !s.carriedFrom && !
|
|
5485
|
+
const requiredFailed = freshGroup.some((s) => s.status === "failed" && !s.optional && !s.carriedFrom && !isSplicedRecord(s));
|
|
5138
5486
|
if (requiredFailed) {
|
|
5139
5487
|
run.status = "failed";
|
|
5140
5488
|
break;
|
|
@@ -5213,7 +5561,7 @@ function driveRunInner(
|
|
|
5213
5561
|
// A rescue answered its own failure and stays as it ended: reset
|
|
5214
5562
|
// to pending it read as an interrupted rescue, and the next
|
|
5215
5563
|
// cycle's failure was handed the old one's instructions.
|
|
5216
|
-
if (
|
|
5564
|
+
if (isSplicedRecord(s)) continue;
|
|
5217
5565
|
s.status = "pending";
|
|
5218
5566
|
s.result = null;
|
|
5219
5567
|
}
|
|
@@ -5607,6 +5955,8 @@ export async function waitForRun(
|
|
|
5607
5955
|
*/
|
|
5608
5956
|
export function runResult(run: RunRecord): string | null {
|
|
5609
5957
|
for (let i = run.steps.length - 1; i >= 0; i--) {
|
|
5958
|
+
// A sandbox: own job's reply went to the step that asked (runSummary).
|
|
5959
|
+
if (run.steps[i].subagentOf) continue;
|
|
5610
5960
|
const r = run.steps[i].result;
|
|
5611
5961
|
if (r && r.trim()) return r;
|
|
5612
5962
|
}
|
package/src/scan.ts
CHANGED
|
@@ -133,7 +133,7 @@ export const SCAN_RULES: readonly ScanRule[] = [
|
|
|
133
133
|
help:
|
|
134
134
|
"An agent with all three can be told — by any page it reads — to send what it holds anywhere it can reach. " +
|
|
135
135
|
"Take one leg away: take it off the open network (`network: { level: trusted, allow: [the sites it needs] }` — a bare list only adds sites, " +
|
|
136
|
-
"and an agent that reads the web stays open), split the reading and the sending into two agents, " +
|
|
136
|
+
"and an agent that reads the web stays open), split the reading and the sending into two agents (a step each, or a reader sub-agent with \`sandbox: own\`), " +
|
|
137
137
|
"or guard its calls with a `hooks:` gate. An approval (`!`) on every outward step is the other answer.",
|
|
138
138
|
owasp: ["ASI01", "ASI02", "ASI03"],
|
|
139
139
|
},
|
|
@@ -147,6 +147,17 @@ export const SCAN_RULES: readonly ScanRule[] = [
|
|
|
147
147
|
"and if that rule ever changed, they would be a privilege escalation. Grant them to the parent or remove them.",
|
|
148
148
|
owasp: ["ASI03"],
|
|
149
149
|
},
|
|
150
|
+
{
|
|
151
|
+
id: "security/subagent-own-sandbox",
|
|
152
|
+
category: "security",
|
|
153
|
+
level: "info",
|
|
154
|
+
title: "A sub-agent works in its own sandbox, with tools its parent does not have",
|
|
155
|
+
help:
|
|
156
|
+
"`sandbox: own` on a sub-agent runs each job it is handed in a sandbox of its own, with its own tools, secrets and network — " +
|
|
157
|
+
"not cut to the parent's. That is the point (a reader with the web and no secrets, under a writer with secrets and no web), " +
|
|
158
|
+
"so this only says what it holds. Only its reply reaches the parent.",
|
|
159
|
+
owasp: ["ASI03"],
|
|
160
|
+
},
|
|
150
161
|
{
|
|
151
162
|
id: "supply-chain/pipe-to-shell",
|
|
152
163
|
category: "supply-chain",
|
|
@@ -725,6 +736,13 @@ function scanAgents(agents: AgentFacts[], outward: Set<string>, toolDefs: Map<st
|
|
|
725
736
|
const sub = byName.get(s);
|
|
726
737
|
if (!sub || s === a.name) continue;
|
|
727
738
|
const extra = refNames(sub.data.tools).filter((t) => !tools.includes(t));
|
|
739
|
+
if (sub.data.sandbox === "own") {
|
|
740
|
+
if (extra.length) {
|
|
741
|
+
push("security/subagent-own-sandbox", a.file, keyLine(a.lines, "subagents"), `subagent ${s} own ${extra.join(",")}`,
|
|
742
|
+
`subagents: [${s}] — ${s} runs in its own sandbox with ${extra.join(", ")}, which ${a.name} does not have; only its reply comes back`);
|
|
743
|
+
}
|
|
744
|
+
continue;
|
|
745
|
+
}
|
|
728
746
|
if (extra.length && tools.length) {
|
|
729
747
|
push("security/subagent-wider", a.file, keyLine(a.lines, "subagents"), `subagent ${s} ${extra.join(",")}`,
|
|
730
748
|
`subagents: [${s}] — ${s} declares ${extra.join(", ")}, which ${a.name} does not have; inside the delegation they are dropped`);
|