toolroll 0.9.69 → 0.9.71
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/CHANGELOG.md +67 -0
- package/README.md +43 -17
- package/THIRD_PARTY_NOTICES.md +21 -1
- package/dist/agent-fence.d.ts +184 -115
- package/dist/agent-fence.js +418 -535
- package/dist/agentconfig.js +6 -1
- package/dist/browser/THIRD_PARTY_NOTICES.txt +21 -0
- package/dist/builder.js +24 -4
- package/dist/check-access.d.ts +6 -9
- package/dist/check-access.js +8 -11
- package/dist/coding-provider.d.ts +14 -0
- package/dist/coding-provider.js +33 -1
- package/dist/coding-workspace.d.ts +15 -0
- package/dist/coding-workspace.js +9 -2
- package/dist/contracts/console-api.d.ts +1 -0
- package/dist/contracts/console-api.js +1 -0
- package/dist/doctor.d.ts +7 -0
- package/dist/doctor.js +4 -0
- package/dist/egress.d.ts +2 -0
- package/dist/egress.js +13 -4
- package/dist/exec.d.ts +17 -3
- package/dist/exec.js +54 -17
- package/dist/flow-actions.d.ts +1 -0
- package/dist/flow-actions.js +3 -2
- package/dist/flow-code.d.ts +31 -2
- package/dist/flow-code.js +57 -5
- package/dist/flow-steps.d.ts +4 -0
- package/dist/flow-steps.js +9 -5
- package/dist/flow-triggers.d.ts +4 -0
- package/dist/flow-triggers.js +8 -3
- package/dist/invoke.d.ts +25 -8
- package/dist/invoke.js +115 -115
- package/dist/operate.js +46 -0
- package/dist/project-delete-ui.d.ts +7 -6
- package/dist/project-delete-ui.js +37 -3
- package/dist/project-sandbox.d.ts +59 -0
- package/dist/project-sandbox.js +83 -0
- package/dist/project-tools.d.ts +29 -5
- package/dist/project-tools.js +43 -7
- package/dist/release.d.ts +1 -1
- package/dist/release.js +1 -0
- package/dist/sandbox-launch.d.ts +1 -0
- package/dist/sandbox-launch.js +148 -0
- package/dist/sandbox-runtime.d.ts +109 -0
- package/dist/sandbox-runtime.js +281 -0
- package/dist/scope.d.ts +28 -0
- package/dist/scope.js +25 -5
- package/dist/scout-browser-lock.d.ts +69 -0
- package/dist/scout-browser-lock.js +69 -0
- package/dist/scout-browser.d.ts +60 -1
- package/dist/scout-browser.js +200 -4
- package/dist/scout.d.ts +9 -4
- package/dist/scout.js +14 -4
- package/dist/serve.js +3 -1
- package/dist/server/render-tasks.js +4 -1
- package/dist/server/route-table.js +1 -0
- package/dist/server/settings.js +27 -0
- package/dist/store.d.ts +31 -1
- package/dist/store.js +83 -4
- package/dist/subagent-tools.d.ts +1 -0
- package/dist/subagent-tools.js +7 -5
- package/dist/tool-broker.d.ts +2 -1
- package/dist/tool-broker.js +2 -2
- package/dist/tool-launcher.js +10 -16
- package/package.json +2 -1
- package/scripts/provider-canary.mjs +5 -6
- package/dist/egress-bridge.d.ts +0 -17
- package/dist/egress-bridge.js +0 -111
package/dist/store.d.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { type WritesMode } from "./writes-mode.js";
|
|
2
2
|
import { type CheckAccess } from "./check-access.js";
|
|
3
|
+
import { type ProjectSandbox } from "./project-sandbox.js";
|
|
4
|
+
import type { FencePolicy } from "./agent-fence.js";
|
|
3
5
|
import { type NetworkProfile, type ProjectNetwork, type RunNetworkRecord } from "./egress.js";
|
|
4
6
|
import { type AcpLaunch } from "./acp-attest.js";
|
|
5
7
|
import { type ChatBinding } from "./chat-core.js";
|
|
@@ -180,7 +182,7 @@ export type SealedLedgerEntry = LedgerEntry & {
|
|
|
180
182
|
hash: string;
|
|
181
183
|
} | null;
|
|
182
184
|
};
|
|
183
|
-
export declare const SCHEMA_VERSION =
|
|
185
|
+
export declare const SCHEMA_VERSION = 133;
|
|
184
186
|
/** v118: the tables merged into `credential`, in the order they move (a person's API tokens keep their ids first). */
|
|
185
187
|
export declare const V118_MERGED_TABLES: Readonly<Record<string, CredentialKind>>;
|
|
186
188
|
/**
|
|
@@ -321,6 +323,8 @@ export declare const CONFINEMENT_SCHEMA = "\nCREATE TABLE IF NOT EXISTS project_
|
|
|
321
323
|
export declare const PROJECT_WRITES_SCHEMA = "\nCREATE TABLE IF NOT EXISTS project_writes (\n repo TEXT PRIMARY KEY,\n mode TEXT NOT NULL CHECK (mode IN ('workspace', 'anywhere')),\n updated_at TEXT NOT NULL,\n updated_by TEXT NOT NULL\n);\n";
|
|
322
324
|
/** v132: how far a project's checks reach beyond the check fence (check-access.ts CheckAccess). */
|
|
323
325
|
export declare const PROJECT_CHECK_ACCESS_SCHEMA = "\nCREATE TABLE IF NOT EXISTS project_check_access (\n repo TEXT PRIMARY KEY,\n level TEXT NOT NULL CHECK (level IN ('sandboxed', 'sign-ins', 'unsandboxed')),\n updated_at TEXT NOT NULL,\n updated_by TEXT NOT NULL\n);\n";
|
|
326
|
+
/** v133: a project's sandbox settings (project-sandbox.ts): JSON lists, each sorted, distinct and valid. */
|
|
327
|
+
export declare const PROJECT_SANDBOX_SCHEMA = "\nCREATE TABLE IF NOT EXISTS project_sandbox (\n repo TEXT PRIMARY KEY,\n read_deny_json TEXT NOT NULL DEFAULT '[]',\n mach_lookup_json TEXT NOT NULL DEFAULT '[]',\n updated_at TEXT NOT NULL,\n updated_by TEXT NOT NULL\n);\n";
|
|
324
328
|
/** v129: a finished run's How it ran, derived from its own records on first view (display only: never a verdict). Its
|
|
325
329
|
* parser version says when to derive again; `steps` says how complete the step detail was, and why not. */
|
|
326
330
|
export declare const RUN_EFFICIENCY_SCHEMA = "\nCREATE TABLE IF NOT EXISTS run_efficiency (\n run INTEGER PRIMARY KEY REFERENCES run(id) ON DELETE CASCADE,\n parser_version INTEGER NOT NULL,\n steps TEXT NOT NULL CHECK (steps IN ('complete','truncated','expired','absent')),\n steps_reason TEXT,\n payload_json TEXT NOT NULL CHECK (length(payload_json) <= 16384),\n derived_at TEXT NOT NULL\n);\n";
|
|
@@ -6138,6 +6142,9 @@ export declare class Store {
|
|
|
6138
6142
|
recordRunFence(run: number, fence: {
|
|
6139
6143
|
method: string;
|
|
6140
6144
|
paths: number;
|
|
6145
|
+
policy?: 1 | 2;
|
|
6146
|
+
digest?: string;
|
|
6147
|
+
engine?: string;
|
|
6141
6148
|
}, now: Date): void;
|
|
6142
6149
|
/** What one attempt's network was and what its proxy saw (egress.ts), kept beside its fence. */
|
|
6143
6150
|
recordRunNetwork(run: number, network: RunNetworkRecord, now: Date): void;
|
|
@@ -6145,6 +6152,9 @@ export declare class Store {
|
|
|
6145
6152
|
runFence(run: number): {
|
|
6146
6153
|
method: string;
|
|
6147
6154
|
paths: number;
|
|
6155
|
+
policy?: 1 | 2;
|
|
6156
|
+
digest?: string;
|
|
6157
|
+
engine?: string;
|
|
6148
6158
|
} | null;
|
|
6149
6159
|
runTools(run: number): {
|
|
6150
6160
|
tools: {
|
|
@@ -7201,6 +7211,26 @@ export declare class Store {
|
|
|
7201
7211
|
before: CheckAccess;
|
|
7202
7212
|
after: CheckAccess;
|
|
7203
7213
|
} | null;
|
|
7214
|
+
/** The scout's browser ran outside Toolroll's fence under its launch contract (scout-browser.ts): which exception. */
|
|
7215
|
+
recordRunBrowser(run: number, browser: {
|
|
7216
|
+
containment: string;
|
|
7217
|
+
tool: string;
|
|
7218
|
+
}, now: Date): void;
|
|
7219
|
+
runBrowser(run: number): {
|
|
7220
|
+
containment: string;
|
|
7221
|
+
tool: string;
|
|
7222
|
+
} | null;
|
|
7223
|
+
/** A project's sandbox settings (project-sandbox.ts): its saved lists, or none. A damaged row keeps what it can read
|
|
7224
|
+
* of its read denies (never fewer) and grants nothing else. */
|
|
7225
|
+
projectSandbox(repo: string): ProjectSandbox;
|
|
7226
|
+
/** An approver changed a project's sandbox settings (their password was proved by the caller): saved whole, and in
|
|
7227
|
+
* the ledger as before → after. The account is proved again inside the write. Refused: null, nothing written. */
|
|
7228
|
+
setProjectSandbox(repo: string, wanted: ProjectSandbox, by: string, now: Date): {
|
|
7229
|
+
before: ProjectSandbox;
|
|
7230
|
+
after: ProjectSandbox;
|
|
7231
|
+
} | null;
|
|
7232
|
+
/** The fence policy a flow was made under (agent-fence.ts FencePolicy): v2 for one made from 0.9.70; v1 otherwise. */
|
|
7233
|
+
flowFencePolicy(flow: number): FencePolicy;
|
|
7204
7234
|
}
|
|
7205
7235
|
/** The receipt a bot's first pairing writes over pre-pairing history: an
|
|
7206
7236
|
* explicit skip, with no delivered timestamp and no attempt, so suppressed
|
package/dist/store.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { WRITES_DEFAULT } from "./writes-mode.js";
|
|
2
2
|
import { checkAccessOf, widerThan } from "./check-access.js";
|
|
3
|
+
import { PROJECT_SANDBOX_DEFAULT, sandboxOf } from "./project-sandbox.js";
|
|
3
4
|
import { networkFromValue, PROJECT_NETWORK_DEFAULT, projectNetworkFromValue, runNetworkFromValue } from "./egress.js";
|
|
4
5
|
import { isAcpProfile, isAcpProvider } from "./acp-agents.js";
|
|
5
6
|
import { ACP_CONFORMANCE_VERSION, launchFromJson } from "./acp-attest.js";
|
|
@@ -721,7 +722,10 @@ const REGISTERED_AT_FILING = "again, as it was filed";
|
|
|
721
722
|
// agent-baseline-hardening.md arc 2) — only their workspace unless an approver chose anywhere.
|
|
722
723
|
// v132 adds, and changes no saved row: how far a project's checks reach beyond the check fence (project_check_access;
|
|
723
724
|
// check-access.ts, docs/plans/check-access.md) — sandboxed unless an instance operator widened it.
|
|
724
|
-
|
|
725
|
+
// v133 adds, and changes no saved row: a project's sandbox settings (project_sandbox; project-sandbox.ts,
|
|
726
|
+
// docs/plans/sandbox-parity.md C9) — what its builds may not read, the macOS services and protected files they may — and
|
|
727
|
+
// the fence policy a flow was made under (flow.fence_policy; saved flows read NULL: v1, the home configuration denied).
|
|
728
|
+
export const SCHEMA_VERSION = 133;
|
|
725
729
|
/** v118: the tables merged into `credential`, in the order they move (a person's API tokens keep their ids first). */
|
|
726
730
|
export const V118_MERGED_TABLES = Object.freeze({ api_token: "person", coordinator_credential: "coordinator", lead_credential: "lead" });
|
|
727
731
|
/** The foreign keys that named a merged table, exactly as their DDL wrote them; each names `credential(id)` instead. */
|
|
@@ -854,7 +858,7 @@ const V115_DROPPED_REFERENCES = /\s+REFERENCES (?:contestant|contest|tournament_
|
|
|
854
858
|
* unchanged), and adds the nullable column in V125_ADDED_COLUMNS, checked the same way. v126 only adds a new table
|
|
855
859
|
* (task_source) and the columns in V126_ADDED_COLUMNS (null or 0), checked the same way.
|
|
856
860
|
*/
|
|
857
|
-
export const UPDATE_SAFE_MIGRATIONS = Object.freeze([108, 109, 110, 111, 112, 113, 114, 115, 116, 117, 118, 119, 120, 121, 122, 123, 124, 125, 126, 127, 128, 129, 130, 131, 132]);
|
|
861
|
+
export const UPDATE_SAFE_MIGRATIONS = Object.freeze([108, 109, 110, 111, 112, 113, 114, 115, 116, 117, 118, 119, 120, 121, 122, 123, 124, 125, 126, 127, 128, 129, 130, 131, 132, 133]);
|
|
858
862
|
/** v119: the columns it adds, by table, with the value every row saved before it reads (toolroll-update's HISTORY_RULES). */
|
|
859
863
|
export const V119_ADDED_COLUMNS = Object.freeze({
|
|
860
864
|
task_ref: { planning_generation: { definition: "INTEGER NOT NULL DEFAULT 0", saved: "0" } },
|
|
@@ -1000,6 +1004,16 @@ CREATE TABLE IF NOT EXISTS project_check_access (
|
|
|
1000
1004
|
updated_by TEXT NOT NULL
|
|
1001
1005
|
);
|
|
1002
1006
|
`;
|
|
1007
|
+
/** v133: a project's sandbox settings (project-sandbox.ts): JSON lists, each sorted, distinct and valid. */
|
|
1008
|
+
export const PROJECT_SANDBOX_SCHEMA = `
|
|
1009
|
+
CREATE TABLE IF NOT EXISTS project_sandbox (
|
|
1010
|
+
repo TEXT PRIMARY KEY,
|
|
1011
|
+
read_deny_json TEXT NOT NULL DEFAULT '[]',
|
|
1012
|
+
mach_lookup_json TEXT NOT NULL DEFAULT '[]',
|
|
1013
|
+
updated_at TEXT NOT NULL,
|
|
1014
|
+
updated_by TEXT NOT NULL
|
|
1015
|
+
);
|
|
1016
|
+
`;
|
|
1003
1017
|
/** v129: a finished run's How it ran, derived from its own records on first view (display only: never a verdict). Its
|
|
1004
1018
|
* parser version says when to derive again; `steps` says how complete the step detail was, and why not. */
|
|
1005
1019
|
export const RUN_EFFICIENCY_SCHEMA = `
|
|
@@ -3940,6 +3954,10 @@ function initializeStore(db, file) {
|
|
|
3940
3954
|
db.exec(PROJECT_CHECK_ACCESS_SCHEMA);
|
|
3941
3955
|
addColumn(db, "run", "sandbox_access", "TEXT");
|
|
3942
3956
|
addColumn(db, "run", "sandbox_network", "TEXT");
|
|
3957
|
+
// v133: a project's sandbox settings (a new table), and the fence policy each flow was made under (saved flows: NULL,
|
|
3958
|
+
// read as v1).
|
|
3959
|
+
db.exec(PROJECT_SANDBOX_SCHEMA);
|
|
3960
|
+
addColumn(db, "flow", "fence_policy", "INTEGER");
|
|
3943
3961
|
// v123: a flow that runs on its own (flow-unattended.ts): its grants and daily runs (new tables), and which authority
|
|
3944
3962
|
// armed a plan's unchanged-plan approval (plan_authorization.basis, V123_ADDED_COLUMNS: saved rows read the mode).
|
|
3945
3963
|
db.exec(FLOW_UNATTENDED_SCHEMA);
|
|
@@ -6750,14 +6768,18 @@ export class Store {
|
|
|
6750
6768
|
// all) seals only on a person's password ceremony — never a mode, a grant, a chat confirmation or a carry.
|
|
6751
6769
|
const profileText = this.db.prepare("SELECT profile_json FROM task_scope WHERE task_id = ?").get(taskId)?.["profile_json"];
|
|
6752
6770
|
let sealedAccess;
|
|
6771
|
+
// SANDBOX WIDENINGS (v133, project-sandbox.ts): extra macOS services seal, likewise,
|
|
6772
|
+
// only on a person's own password yes to exactly them.
|
|
6773
|
+
let sandboxWidened = false;
|
|
6753
6774
|
try {
|
|
6754
6775
|
const stored = typeof profileText === "string" ? JSON.parse(profileText) : null;
|
|
6755
6776
|
sealedAccess = stored?.profile?.["checkAccess"];
|
|
6777
|
+
sandboxWidened = stored?.profile?.["machLookup"] !== undefined;
|
|
6756
6778
|
}
|
|
6757
6779
|
catch {
|
|
6758
6780
|
sealedAccess = "unreadable";
|
|
6759
6781
|
}
|
|
6760
|
-
if (sealedAccess !== undefined)
|
|
6782
|
+
if (sealedAccess !== undefined || sandboxWidened)
|
|
6761
6783
|
return false;
|
|
6762
6784
|
}
|
|
6763
6785
|
// SEPARATION OF DUTIES (v102), enforced in the primitive so no road
|
|
@@ -15253,7 +15275,8 @@ export class Store {
|
|
|
15253
15275
|
// ---- flows (v81) -------------------------------------------------------------
|
|
15254
15276
|
createFlow(flow, now) {
|
|
15255
15277
|
const stamp = now.toISOString();
|
|
15256
|
-
|
|
15278
|
+
// A flow made from 0.9.70 on runs under fence policy v2 (agent-fence.ts FencePolicy); one made before stays v1.
|
|
15279
|
+
return Number(this.db.prepare("INSERT INTO flow (repo, name, definition_json, revision, state, created_by, created_at, updated_by, updated_at, fence_policy) VALUES (?, ?, ?, 1, 'active', ?, ?, ?, ?, 2)")
|
|
15257
15280
|
.run(flow.repo, flow.name, flowDefinitionForStore(flow.definitionJson), flow.by, stamp, flow.by, stamp).lastInsertRowid);
|
|
15258
15281
|
}
|
|
15259
15282
|
/** Hand a flow to another person: its "the owner decides" zones ask them from now on. */
|
|
@@ -19070,6 +19093,62 @@ export class Store {
|
|
|
19070
19093
|
return { before, after };
|
|
19071
19094
|
});
|
|
19072
19095
|
}
|
|
19096
|
+
// ---- sandbox parity (docs/plans/sandbox-parity.md). Kept last in the class: the freshness tripwire pins positions.
|
|
19097
|
+
/** The scout's browser ran outside Toolroll's fence under its launch contract (scout-browser.ts): which exception. */
|
|
19098
|
+
recordRunBrowser(run, browser, now) {
|
|
19099
|
+
const json = JSON.stringify(browser);
|
|
19100
|
+
this.db.prepare(`INSERT INTO run_tool (run, tools_json, created_at) VALUES (?, json_object('browser', json(?)), ?)
|
|
19101
|
+
ON CONFLICT (run) DO UPDATE SET tools_json = json_set(tools_json, '$.browser', json(?))`).run(run, json, now.toISOString(), json);
|
|
19102
|
+
}
|
|
19103
|
+
runBrowser(run) {
|
|
19104
|
+
const row = this.db.prepare("SELECT json_extract(tools_json, '$.browser') AS browser FROM run_tool WHERE run = ?").get(run);
|
|
19105
|
+
if (row === undefined || row["browser"] === null)
|
|
19106
|
+
return null;
|
|
19107
|
+
try {
|
|
19108
|
+
const parsed = JSON.parse(String(row["browser"]));
|
|
19109
|
+
return typeof parsed.containment === "string" && typeof parsed.tool === "string" ? { containment: parsed.containment, tool: parsed.tool } : null;
|
|
19110
|
+
}
|
|
19111
|
+
catch {
|
|
19112
|
+
return null;
|
|
19113
|
+
}
|
|
19114
|
+
}
|
|
19115
|
+
/** A project's sandbox settings (project-sandbox.ts): its saved lists, or none. A damaged row keeps what it can read
|
|
19116
|
+
* of its read denies (never fewer) and grants nothing else. */
|
|
19117
|
+
projectSandbox(repo) {
|
|
19118
|
+
const row = this.db.prepare("SELECT read_deny_json, mach_lookup_json FROM project_sandbox WHERE repo = ?").get(repo);
|
|
19119
|
+
if (row === undefined)
|
|
19120
|
+
return { ...PROJECT_SANDBOX_DEFAULT };
|
|
19121
|
+
const read = (text) => { try {
|
|
19122
|
+
return typeof text === "string" ? JSON.parse(text) : null;
|
|
19123
|
+
}
|
|
19124
|
+
catch {
|
|
19125
|
+
return null;
|
|
19126
|
+
} };
|
|
19127
|
+
return sandboxOf({ readDeny: read(row["read_deny_json"]), machLookup: read(row["mach_lookup_json"]) });
|
|
19128
|
+
}
|
|
19129
|
+
/** An approver changed a project's sandbox settings (their password was proved by the caller): saved whole, and in
|
|
19130
|
+
* the ledger as before → after. The account is proved again inside the write. Refused: null, nothing written. */
|
|
19131
|
+
setProjectSandbox(repo, wanted, by, now) {
|
|
19132
|
+
return this.transact(() => {
|
|
19133
|
+
const account = this.accountOf(by);
|
|
19134
|
+
if (account === null || account.revokedAt !== null || account.role !== "approver" || !this.accountCanAccess(by, repo))
|
|
19135
|
+
return null;
|
|
19136
|
+
const before = this.projectSandbox(repo);
|
|
19137
|
+
const clean = sandboxOf(wanted);
|
|
19138
|
+
this.db.prepare(`INSERT INTO project_sandbox (repo, read_deny_json, mach_lookup_json, updated_at, updated_by) VALUES (?, ?, ?, ?, ?)
|
|
19139
|
+
ON CONFLICT (repo) DO UPDATE SET read_deny_json = excluded.read_deny_json, mach_lookup_json = excluded.mach_lookup_json,
|
|
19140
|
+
updated_at = excluded.updated_at, updated_by = excluded.updated_by`)
|
|
19141
|
+
.run(repo, JSON.stringify(clean.readDeny), JSON.stringify(clean.machLookup), now.toISOString(), by);
|
|
19142
|
+
const after = this.projectSandbox(repo);
|
|
19143
|
+
const words = (one) => `read-deny ${one.readDeny.length === 0 ? "none" : one.readDeny.join(",")}; mach ${one.machLookup.length === 0 ? "none" : one.machLookup.join(",")}`;
|
|
19144
|
+
this.recordPolicy(by, repo, "sandbox", words(before), words(after), now);
|
|
19145
|
+
return { before, after };
|
|
19146
|
+
});
|
|
19147
|
+
}
|
|
19148
|
+
/** The fence policy a flow was made under (agent-fence.ts FencePolicy): v2 for one made from 0.9.70; v1 otherwise. */
|
|
19149
|
+
flowFencePolicy(flow) {
|
|
19150
|
+
return this.db.prepare("SELECT fence_policy FROM flow WHERE id = ?").get(flow)?.["fence_policy"] === 2 ? 2 : 1;
|
|
19151
|
+
}
|
|
19073
19152
|
}
|
|
19074
19153
|
/** A stored count or id that must be a whole, non-negative number — a
|
|
19075
19154
|
* NULL, an empty string, a fraction, or text reads as null (v48 authority repair). */
|
package/dist/subagent-tools.d.ts
CHANGED
|
@@ -13,6 +13,7 @@ export type ToolIo = {
|
|
|
13
13
|
callTool?: ToolCaller;
|
|
14
14
|
listTools?: ToolLister;
|
|
15
15
|
toolHome?: string | undefined;
|
|
16
|
+
fenceAvailable?: () => boolean;
|
|
16
17
|
};
|
|
17
18
|
/** Actions whose names say they only read start as "do it"; everything else asks first until its manager says otherwise. */
|
|
18
19
|
export declare const READ_VERB: RegExp;
|
package/dist/subagent-tools.js
CHANGED
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
import { redactSecretLines, scanForSecrets } from "./evidence.js";
|
|
20
20
|
import { scrubSecrets } from "./flow-secrets.js";
|
|
21
21
|
import { toolRefusal } from "./policy.js";
|
|
22
|
-
import { callProjectTool, listProjectToolActions, projectToolsOf, readToolSecrets } from "./project-tools.js";
|
|
22
|
+
import { callProjectTool, listProjectToolActions, projectToolsOf, readToolSecrets, toolServerFor } from "./project-tools.js";
|
|
23
23
|
import { ALL_CREDENTIAL_ENV } from "./provider.js";
|
|
24
24
|
/** Actions whose names say they only read start as "do it"; everything else asks first until its manager says otherwise. */
|
|
25
25
|
export const READ_VERB = /^(get|list|search|find|read|fetch|lookup|look_up|query|show|describe|count|check|view|retrieve)([_.-]|$)/i;
|
|
@@ -113,13 +113,15 @@ export async function refreshGrants(store, mate, now, io) {
|
|
|
113
113
|
const tool = tools.find(one => one.name === grant.tool);
|
|
114
114
|
if (tool === undefined)
|
|
115
115
|
continue;
|
|
116
|
-
const listed = await listed_(tool, mate.repo, io);
|
|
116
|
+
const listed = await listed_(store, tool, mate.repo, io);
|
|
117
117
|
if (!listed.ok)
|
|
118
118
|
continue;
|
|
119
119
|
store.saveSubagentGrant({ subagent: mate.id, tool: grant.tool, ...withActions(grant.rules, listed.actions), listedAt: now.toISOString() }, grant.updatedBy, now);
|
|
120
120
|
}
|
|
121
121
|
}
|
|
122
|
-
const listed_ = (tool, repo, io) => (io.listTools ?? ((spec, values) => listProjectToolActions(spec, values, { timeoutMs: 60_000, omitEnv: ALL_CREDENTIAL_ENV })))(tool.spec, readToolSecrets(repo, tool.name, io.toolHome));
|
|
122
|
+
const listed_ = (store, tool, repo, io) => (io.listTools ?? ((spec, values) => listProjectToolActions(spec, values, { timeoutMs: 60_000, omitEnv: ALL_CREDENTIAL_ENV, server: serverOf(store, repo, io) })))(tool.spec, readToolSecrets(repo, tool.name, io.toolHome));
|
|
123
|
+
/** Where a subagent's tool server starts: in the server fence (project-tools.ts toolServerFor), never bare. */
|
|
124
|
+
const serverOf = (store, repo, io) => toolServerFor(store, repo, { ...(io.toolHome === undefined ? {} : { home: io.toolHome }), ...(io.fenceAvailable === undefined ? {} : { available: io.fenceAvailable }) });
|
|
123
125
|
/** Names only, from the tool's last test: what a grant starts with when it can't be listed right now. */
|
|
124
126
|
const namesOnly = (tool) => (tool.lastTest?.tools ?? []).map(name => ({ name, about: "", input: null, readOnly: false }));
|
|
125
127
|
/** Let a subagent use one of the project's tools. Its actions start at their defaults: reading is free, everything else asks first. */
|
|
@@ -129,7 +131,7 @@ export async function grantTool(store, mate, tool, by, now, io = {}) {
|
|
|
129
131
|
return { ok: false, said: `This project has no tool called ${tool}. Add it on the Tools page first.` };
|
|
130
132
|
if (store.subagentGrant(mate.id, tool) !== null)
|
|
131
133
|
return { ok: true, said: `It can already use ${tool}.` };
|
|
132
|
-
const listed = await listed_(found, mate.repo, io);
|
|
134
|
+
const listed = await listed_(store, found, mate.repo, io);
|
|
133
135
|
return grantListed(store, mate, found, listed.ok ? listed.actions : null, by, now);
|
|
134
136
|
}
|
|
135
137
|
/** A grant from what the tool offers (or, not reachable now, the names its last test found). */
|
|
@@ -219,7 +221,7 @@ export async function makeCall(store, call, repo, io, now, options = {}) {
|
|
|
219
221
|
const values = readToolSecrets(repo, tool.name, io.toolHome);
|
|
220
222
|
let answer;
|
|
221
223
|
try {
|
|
222
|
-
answer = await (io.callTool ?? ((spec, secrets, name, given) => callProjectTool(spec, secrets, name, given, { timeoutMs: 120_000, omitEnv: ALL_CREDENTIAL_ENV })))(tool.spec, values, call.action, call.input);
|
|
224
|
+
answer = await (io.callTool ?? ((spec, secrets, name, given) => callProjectTool(spec, secrets, name, given, { timeoutMs: 120_000, omitEnv: ALL_CREDENTIAL_ENV, server: serverOf(store, repo, io) })))(tool.spec, values, call.action, call.input);
|
|
223
225
|
}
|
|
224
226
|
catch (error) {
|
|
225
227
|
answer = { ok: false, problem: error instanceof Error ? error.message : "It couldn't be reached." };
|
package/dist/tool-broker.d.ts
CHANGED
|
@@ -19,9 +19,10 @@ export type BrokerOptions = {
|
|
|
19
19
|
/** Why a local server may not start unwrapped (no fence for it here), read when it would; null when it may. */
|
|
20
20
|
unwrapped?: () => string | null;
|
|
21
21
|
/** How a local server is started inside its run's fence, read when it starts (the fence is fixed after the tools). */
|
|
22
|
-
wrap?: () => ((file: string, args: readonly string[]) => {
|
|
22
|
+
wrap?: () => ((file: string, args: readonly string[], cwd?: string) => {
|
|
23
23
|
file: string;
|
|
24
24
|
args: string[];
|
|
25
|
+
env?: Record<string, string>;
|
|
25
26
|
}) | null;
|
|
26
27
|
};
|
|
27
28
|
export type ToolBroker = {
|
package/dist/tool-broker.js
CHANGED
|
@@ -139,8 +139,8 @@ class LocalServer {
|
|
|
139
139
|
return null;
|
|
140
140
|
}
|
|
141
141
|
}
|
|
142
|
-
const { file, args } = wrap === null ? { file: this.tool.spec.command, args: [...this.tool.spec.args] } : wrap(this.tool.spec.command, this.tool.spec.args);
|
|
143
|
-
const child = spawn(file, args, { env, ...(this.options.cwd === undefined ? {} : { cwd: this.options.cwd }), stdio: ["pipe", "pipe", "ignore"], detached: process.platform !== "win32" });
|
|
142
|
+
const { file, args, env: sandboxEnv } = wrap === null ? { file: this.tool.spec.command, args: [...this.tool.spec.args], env: undefined } : wrap(this.tool.spec.command, this.tool.spec.args, this.options.cwd);
|
|
143
|
+
const child = spawn(file, args, { env: { ...env, ...(sandboxEnv ?? {}) }, ...(this.options.cwd === undefined ? {} : { cwd: this.options.cwd }), stdio: ["pipe", "pipe", "ignore"], detached: process.platform !== "win32" });
|
|
144
144
|
this.child = child;
|
|
145
145
|
this.initialized = false;
|
|
146
146
|
child.stdout?.setEncoding("utf8");
|
package/dist/tool-launcher.js
CHANGED
|
@@ -5,23 +5,15 @@
|
|
|
5
5
|
* against codex-cli 0.156), so nothing rides Codex's environment. A build's
|
|
6
6
|
* launch file names its run's tool broker (tool-broker.ts) and that launch's
|
|
7
7
|
* token, and this process relays stdio to it over HTTP; the tool's own
|
|
8
|
-
* secrets stay in the broker. A launch file naming a local program
|
|
9
|
-
*
|
|
8
|
+
* secrets stay in the broker. A launch file naming a local program is refused
|
|
9
|
+
* (docs/plans/sandbox-parity.md C2): Codex starts this launcher outside its
|
|
10
|
+
* command sandbox, so a program started here would run unfenced; every local
|
|
11
|
+
* server runs in the broker, inside the server fence.
|
|
10
12
|
*/
|
|
11
|
-
import { spawn } from "node:child_process";
|
|
12
13
|
import { readFileSync } from "node:fs";
|
|
13
14
|
import { createInterface } from "node:readline";
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
const forward = (signal) => { try {
|
|
17
|
-
child.kill(signal);
|
|
18
|
-
}
|
|
19
|
-
catch { /* gone */ } };
|
|
20
|
-
for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"])
|
|
21
|
-
process.on(signal, () => forward(signal));
|
|
22
|
-
child.on("error", error => { process.stderr.write(`the tool could not start: ${error.message}\n`); process.exit(127); });
|
|
23
|
-
child.on("exit", (code, signal) => process.exit(code ?? (signal === null ? 1 : 128)));
|
|
24
|
-
}
|
|
15
|
+
/** The refusal for a launch file naming a local program: nothing is started (126, "could not be executed"). */
|
|
16
|
+
const LOCAL_LAUNCH_REFUSED = "This tool's launch names a local program, which only Toolroll's tool broker may start (inside its fence); nothing was started.";
|
|
25
17
|
/** stdio ↔ streamable HTTP: each line from Codex is POSTed; JSON or event-stream answers go back as lines. */
|
|
26
18
|
function relayWeb(spec, values) {
|
|
27
19
|
const headers = { "content-type": "application/json", accept: "application/json, text/event-stream" };
|
|
@@ -98,5 +90,7 @@ catch {
|
|
|
98
90
|
}
|
|
99
91
|
if (launch.spec.transport === "http")
|
|
100
92
|
relayWeb(launch.spec, launch.values);
|
|
101
|
-
else
|
|
102
|
-
|
|
93
|
+
else {
|
|
94
|
+
process.stderr.write(`${LOCAL_LAUNCH_REFUSED}\n`);
|
|
95
|
+
process.exit(126);
|
|
96
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "toolroll",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.71",
|
|
4
4
|
"description": "A control plane for unattended coding agents — queue tasks, walk away, come back to pull requests. Agents build in leased worktrees, never touch your default branch, and interrupt you only for decisions that need a human.",
|
|
5
5
|
"directories": {
|
|
6
6
|
"doc": "docs"
|
|
@@ -125,6 +125,7 @@
|
|
|
125
125
|
}
|
|
126
126
|
},
|
|
127
127
|
"dependencies": {
|
|
128
|
+
"@anthropic-ai/sandbox-runtime": "0.0.79",
|
|
128
129
|
"@slack/socket-mode": "3.1.0",
|
|
129
130
|
"@xterm/addon-serialize": "0.14.0",
|
|
130
131
|
"@xterm/headless": "6.0.0",
|
|
@@ -313,7 +313,7 @@ async function runtimeIdentity() {
|
|
|
313
313
|
export async function checkCheckoutGit({ provider, worktree, databaseFile, repository, home = homedir() }, execute = run) {
|
|
314
314
|
if (!["claude", "codex", "gemini"].includes(provider)) return null;
|
|
315
315
|
if (typeof worktree !== "string" || worktree === "") throw new Error("the builder did not retain its worktree for the Git fence check");
|
|
316
|
-
const { agentFence, taskBoundary, trustedWorktreeBinding, checkoutGitEnv, HARDENED_GIT_ARGS, fenceGrants, codexFenceArgv, macosFenceAvailable,
|
|
316
|
+
const { agentFence, taskBoundary, trustedWorktreeBinding, checkoutGitEnv, HARDENED_GIT_ARGS, fenceGrants, codexFenceArgv, macosFenceAvailable, linuxFenceAvailable, sandboxedSpawn } = await import(join(root, "dist", "agent-fence.js"));
|
|
317
317
|
const binding = trustedWorktreeBinding(repository, worktree);
|
|
318
318
|
const boundary = taskBoundary({ worktree, provider, home, binding });
|
|
319
319
|
const fence = agentFence({ databaseFile, worktree, home, boundary, binding });
|
|
@@ -329,16 +329,15 @@ export async function checkCheckoutGit({ provider, worktree, databaseFile, repos
|
|
|
329
329
|
if (control.code !== 0) throw new Error(`unfenced git ${args.join(" ")} failed: ${control.stderr}`);
|
|
330
330
|
const wrapped = provider === "codex"
|
|
331
331
|
? { file: "codex", args: ["sandbox", ...codexFenceArgv(fence, false, grants), "-P", "standing-orders", "--", "git", ...gitArgs] }
|
|
332
|
-
: macosFenceAvailable() ?
|
|
333
|
-
: linuxFenceAvailable() ? linuxFenced("git", gitArgs, fence, grants) : null;
|
|
332
|
+
: macosFenceAvailable() || linuxFenceAvailable() ? sandboxedSpawn("git", gitArgs, fence, grants, worktree) : null;
|
|
334
333
|
if (wrapped === null || fence.length === 0) { skipped = true; if (args[0] === "rev-parse") head = control.stdout.trim(); continue; }
|
|
335
334
|
const checked = await execute(wrapped.file, wrapped.args, { cwd: worktree,
|
|
336
|
-
env });
|
|
335
|
+
env: { ...env, ...(wrapped.env ?? {}) } });
|
|
337
336
|
if (checked.code !== 0) {
|
|
338
337
|
const trivial = provider === "codex"
|
|
339
338
|
? { file: "codex", args: ["sandbox", ...codexFenceArgv(fence, false, grants), "-P", "standing-orders", "--", "true"] }
|
|
340
|
-
:
|
|
341
|
-
const sandbox = await execute(trivial.file, trivial.args, { cwd: worktree, env });
|
|
339
|
+
: sandboxedSpawn("true", [], fence, grants, worktree);
|
|
340
|
+
const sandbox = await execute(trivial.file, trivial.args, { cwd: worktree, env: { ...env, ...(trivial.env ?? {}) } });
|
|
342
341
|
throw new Error(sandbox.code === 0 ? `The build fence blocks this checkout's own Git data. git ${args.join(" ")}: ${checked.stderr}` : `The provider sandbox could not run its control: ${sandbox.stderr}`);
|
|
343
342
|
}
|
|
344
343
|
if (checked.stdout !== control.stdout) throw new Error(`fenced git ${args.join(" ")} disagrees with its control`);
|
package/dist/egress-bridge.d.ts
DELETED
|
@@ -1,17 +0,0 @@
|
|
|
1
|
-
/** The prefix of a bridge folder in the temp folder (agent-fence.ts RUN_FOLDER_PREFIXES denies every other run's). */
|
|
2
|
-
export declare const BRIDGE_PREFIX = "so-net-";
|
|
3
|
-
/** The forwarder, run inside the namespace as `node forward.mjs <folder> <port>... -- <command>`. Zero dependencies. */
|
|
4
|
-
export declare const FORWARDER_SOURCE: string;
|
|
5
|
-
/** Whether a bridge made under `root` keeps every socket path within the limit (its folder and `<port>.sock`). */
|
|
6
|
-
export declare function bridgeFits(root: string): boolean;
|
|
7
|
-
export type EgressBridge = {
|
|
8
|
-
folder: string;
|
|
9
|
-
forwarder: string;
|
|
10
|
-
close: () => Promise<void>;
|
|
11
|
-
};
|
|
12
|
-
/**
|
|
13
|
-
* Serve one socket per port (each relayed to that port on the host's loopback) in a fresh owner-only folder under
|
|
14
|
-
* `root`: the plane's own bridge folder beside the database (agent-fence.ts BRIDGE_DIR), which every build's fence
|
|
15
|
-
* hides from launch, so no other build can change the forwarder or reach the sockets.
|
|
16
|
-
*/
|
|
17
|
-
export declare function startEgressBridge(ports: readonly number[], root?: string): Promise<EgressBridge>;
|
package/dist/egress-bridge.js
DELETED
|
@@ -1,111 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* How a Linux build with a network of its own reaches its proxy and tool broker (agent-fence.ts linuxFenced). The
|
|
3
|
-
* plane serves one unix socket per port in a folder of its own (`<folder>/<port>.sock`, each relayed to that port on
|
|
4
|
-
* the host's loopback); bubblewrap binds the folder read-only into the build's namespace, where the forwarder below
|
|
5
|
-
* listens on the namespace's own 127.0.0.1 at the same ports and relays each connection to its socket. Only those
|
|
6
|
-
* ports cross: nothing else of the host's network is reachable, and the relay never opens a connection of its own.
|
|
7
|
-
*
|
|
8
|
-
* The forwarder is written into the folder at launch (the plane's own file, read-only to the build), so it never runs
|
|
9
|
-
* from an installation path a fence might hide or a build could change.
|
|
10
|
-
*/
|
|
11
|
-
import { chmodSync, lstatSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
12
|
-
import { connect, createServer } from "node:net";
|
|
13
|
-
import { tmpdir } from "node:os";
|
|
14
|
-
import { join } from "node:path";
|
|
15
|
-
/** The prefix of a bridge folder in the temp folder (agent-fence.ts RUN_FOLDER_PREFIXES denies every other run's). */
|
|
16
|
-
export const BRIDGE_PREFIX = "so-net-";
|
|
17
|
-
/** The forwarder, run inside the namespace as `node forward.mjs <folder> <port>... -- <command>`. Zero dependencies. */
|
|
18
|
-
export const FORWARDER_SOURCE = String.raw `import { connect, createServer } from "node:net";
|
|
19
|
-
import { spawn } from "node:child_process";
|
|
20
|
-
const argv = process.argv.slice(2);
|
|
21
|
-
const split = argv.indexOf("--");
|
|
22
|
-
if (split < 1 || split === argv.length - 1) { process.stderr.write("toolroll forwarder: usage <folder> <port>... -- <command>\n"); process.exit(2); }
|
|
23
|
-
const folder = argv[0];
|
|
24
|
-
const ports = argv.slice(1, split).map(Number);
|
|
25
|
-
const command = argv.slice(split + 1);
|
|
26
|
-
let ready = 0;
|
|
27
|
-
for (const port of ports) {
|
|
28
|
-
const server = createServer(client => {
|
|
29
|
-
const upstream = connect(folder + "/" + port + ".sock");
|
|
30
|
-
client.on("error", () => upstream.destroy());
|
|
31
|
-
upstream.on("error", () => client.destroy());
|
|
32
|
-
client.pipe(upstream);
|
|
33
|
-
upstream.pipe(client);
|
|
34
|
-
});
|
|
35
|
-
server.on("error", error => { process.stderr.write("toolroll forwarder: " + error.message + "\n"); process.exit(70); });
|
|
36
|
-
server.listen(port, "127.0.0.1", () => { if (++ready === ports.length) start(); });
|
|
37
|
-
}
|
|
38
|
-
if (ports.length === 0) start();
|
|
39
|
-
function start() {
|
|
40
|
-
const child = spawn(command[0], command.slice(1), { stdio: "inherit" });
|
|
41
|
-
for (const signal of ["SIGTERM", "SIGINT", "SIGHUP"]) process.on(signal, () => child.kill(signal));
|
|
42
|
-
child.on("error", error => { process.stderr.write("toolroll forwarder: " + error.message + "\n"); process.exit(127); });
|
|
43
|
-
child.on("exit", (code, signal) => {
|
|
44
|
-
if (signal === null) process.exit(code ?? 1);
|
|
45
|
-
// Die the way the agent died: this process's own handlers (which only pass a signal on) step aside first.
|
|
46
|
-
process.removeAllListeners(signal);
|
|
47
|
-
process.kill(process.pid, signal);
|
|
48
|
-
setTimeout(() => process.exit(128), 1000).unref();
|
|
49
|
-
});
|
|
50
|
-
}
|
|
51
|
-
`;
|
|
52
|
-
/** The longest a unix socket's path may be here (macOS 104 bytes, Linux 108, with room to spare). */
|
|
53
|
-
const SOCKET_PATH_MAX = 100;
|
|
54
|
-
/** Whether a bridge made under `root` keeps every socket path within the limit (its folder and `<port>.sock`). */
|
|
55
|
-
export function bridgeFits(root) {
|
|
56
|
-
return join(root, `${BRIDGE_PREFIX}XXXXXX`, "65535.sock").length <= SOCKET_PATH_MAX;
|
|
57
|
-
}
|
|
58
|
-
/**
|
|
59
|
-
* Serve one socket per port (each relayed to that port on the host's loopback) in a fresh owner-only folder under
|
|
60
|
-
* `root`: the plane's own bridge folder beside the database (agent-fence.ts BRIDGE_DIR), which every build's fence
|
|
61
|
-
* hides from launch, so no other build can change the forwarder or reach the sockets.
|
|
62
|
-
*/
|
|
63
|
-
export async function startEgressBridge(ports, root = tmpdir()) {
|
|
64
|
-
// The root must be a real folder of this user's alone, never a link a build could have planted before it existed.
|
|
65
|
-
const held = lstatSync(root);
|
|
66
|
-
if (held.isSymbolicLink() || !held.isDirectory() || (process.getuid !== undefined && held.uid !== process.getuid()) || (root !== tmpdir() && (held.mode & 0o077) !== 0)) {
|
|
67
|
-
throw new Error(`the bridge folder ${root} isn't a private folder of this user's`);
|
|
68
|
-
}
|
|
69
|
-
if (!bridgeFits(root))
|
|
70
|
-
throw new Error(`the bridge folder ${root} is too deep for a socket path`);
|
|
71
|
-
const folder = mkdtempSync(join(root, BRIDGE_PREFIX));
|
|
72
|
-
chmodSync(folder, 0o700);
|
|
73
|
-
const forwarder = join(folder, "forward.mjs");
|
|
74
|
-
writeFileSync(forwarder, FORWARDER_SOURCE, { mode: 0o500, flag: "wx" });
|
|
75
|
-
const servers = [];
|
|
76
|
-
const sockets = new Set();
|
|
77
|
-
try {
|
|
78
|
-
for (const port of ports) {
|
|
79
|
-
const server = createServer(client => {
|
|
80
|
-
sockets.add(client);
|
|
81
|
-
const upstream = connect(port, "127.0.0.1");
|
|
82
|
-
sockets.add(upstream);
|
|
83
|
-
const drop = () => { client.destroy(); upstream.destroy(); };
|
|
84
|
-
client.on("error", drop);
|
|
85
|
-
upstream.on("error", drop);
|
|
86
|
-
client.on("close", () => { sockets.delete(client); upstream.destroy(); });
|
|
87
|
-
upstream.on("close", () => { sockets.delete(upstream); client.destroy(); });
|
|
88
|
-
client.pipe(upstream);
|
|
89
|
-
upstream.pipe(client);
|
|
90
|
-
});
|
|
91
|
-
await new Promise((done, fail) => { server.once("error", fail); server.listen(join(folder, `${port}.sock`), () => { server.off("error", fail); done(); }); });
|
|
92
|
-
servers.push(server);
|
|
93
|
-
}
|
|
94
|
-
}
|
|
95
|
-
catch (error) {
|
|
96
|
-
for (const server of servers)
|
|
97
|
-
server.close();
|
|
98
|
-
rmSync(folder, { recursive: true, force: true });
|
|
99
|
-
throw error;
|
|
100
|
-
}
|
|
101
|
-
return {
|
|
102
|
-
folder,
|
|
103
|
-
forwarder,
|
|
104
|
-
close: async () => {
|
|
105
|
-
for (const socket of sockets)
|
|
106
|
-
socket.destroy();
|
|
107
|
-
await Promise.all(servers.map(server => new Promise(done => server.close(() => done()))));
|
|
108
|
-
rmSync(folder, { recursive: true, force: true });
|
|
109
|
-
},
|
|
110
|
-
};
|
|
111
|
-
}
|