toolroll 0.9.65 → 0.9.67

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.
Files changed (49) hide show
  1. package/CHANGELOG.md +57 -0
  2. package/README.md +20 -2
  3. package/dist/agent-fence.d.ts +69 -2
  4. package/dist/agent-fence.js +291 -11
  5. package/dist/agentconfig.js +4 -1
  6. package/dist/batch-checks.js +3 -2
  7. package/dist/browser/THIRD_PARTY_NOTICES.txt +46 -0
  8. package/dist/browser/terminal.js +134 -23
  9. package/dist/browser/workspace.css +1 -1
  10. package/dist/builder.d.ts +4 -0
  11. package/dist/builder.js +54 -12
  12. package/dist/contracts/console-api.d.ts +1 -0
  13. package/dist/contracts/console-api.js +1 -0
  14. package/dist/invoke.d.ts +18 -0
  15. package/dist/invoke.js +137 -14
  16. package/dist/live-terminal.d.ts +24 -13
  17. package/dist/live-terminal.js +79 -42
  18. package/dist/operate.d.ts +2 -0
  19. package/dist/operate.js +60 -2
  20. package/dist/project-delete-ui.d.ts +6 -0
  21. package/dist/project-delete-ui.js +20 -1
  22. package/dist/release-adapters.d.ts +6 -0
  23. package/dist/release-adapters.js +70 -3
  24. package/dist/release.d.ts +7 -1
  25. package/dist/release.js +20 -7
  26. package/dist/result-follow-ups.js +3 -2
  27. package/dist/scope.d.ts +13 -0
  28. package/dist/scope.js +10 -5
  29. package/dist/serve.js +1 -0
  30. package/dist/server/chrome.d.ts +10 -1
  31. package/dist/server/chrome.js +45 -4
  32. package/dist/server/console.css +55 -4
  33. package/dist/server/render-pages.d.ts +28 -3
  34. package/dist/server/render-pages.js +78 -9
  35. package/dist/server/render-tasks.js +2 -1
  36. package/dist/server/route-table.js +1 -0
  37. package/dist/server/settings.js +21 -0
  38. package/dist/server/tasks.js +7 -4
  39. package/dist/store.d.ts +11 -1
  40. package/dist/store.js +33 -2
  41. package/dist/teardown-git.d.ts +22 -0
  42. package/dist/teardown-git.js +136 -2
  43. package/dist/tool-broker.d.ts +12 -0
  44. package/dist/tool-broker.js +66 -2
  45. package/dist/unfenced-test-support.d.ts +2 -0
  46. package/dist/unfenced-test-support.js +11 -0
  47. package/dist/writes-mode.d.ts +9 -0
  48. package/dist/writes-mode.js +8 -0
  49. package/package.json +5 -1
package/dist/store.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { type WritesMode } from "./writes-mode.js";
1
2
  import { type ProjectNetwork, type RunNetworkRecord } from "./egress.js";
2
3
  import { type AcpLaunch } from "./acp-attest.js";
3
4
  import { type ChatBinding } from "./chat-core.js";
@@ -178,7 +179,7 @@ export type SealedLedgerEntry = LedgerEntry & {
178
179
  hash: string;
179
180
  } | null;
180
181
  };
181
- export declare const SCHEMA_VERSION = 130;
182
+ export declare const SCHEMA_VERSION = 131;
182
183
  /** v118: the tables merged into `credential`, in the order they move (a person's API tokens keep their ids first). */
183
184
  export declare const V118_MERGED_TABLES: Readonly<Record<string, CredentialKind>>;
184
185
  /**
@@ -315,6 +316,8 @@ export declare const V129_ADDED_COLUMNS: Readonly<Record<string, Readonly<Record
315
316
  /** v130: a project's network (an approver's setting, egress.ts ProjectNetwork as JSON) and the installation's choice
316
317
  * whether an agent may run where no OS confinement exists (one row; 0 until an instance operator turns it on). */
317
318
  export declare const CONFINEMENT_SCHEMA = "\nCREATE TABLE IF NOT EXISTS project_network (\n repo TEXT PRIMARY KEY,\n network_json TEXT NOT NULL CHECK (length(network_json) <= 16384),\n updated_at TEXT NOT NULL,\n updated_by TEXT NOT NULL\n);\nCREATE TABLE IF NOT EXISTS confinement_setting (\n id INTEGER PRIMARY KEY CHECK (id = 1),\n unfenced_allowed INTEGER NOT NULL DEFAULT 0 CHECK (unfenced_allowed IN (0, 1)),\n updated_at TEXT,\n updated_by TEXT\n);\nINSERT OR IGNORE INTO confinement_setting (id, unfenced_allowed) VALUES (1, 0);\n";
319
+ /** v131: where a project's builds may write (an approver's setting, agent-fence.ts WritesMode). */
320
+ 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";
318
321
  /** v129: a finished run's How it ran, derived from its own records on first view (display only: never a verdict). Its
319
322
  * parser version says when to derive again; `steps` says how complete the step detail was, and why not. */
320
323
  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";
@@ -6884,6 +6887,13 @@ export declare class Store {
6884
6887
  before: ProjectNetwork;
6885
6888
  after: ProjectNetwork;
6886
6889
  };
6890
+ /** Where a project's builds may write (agent-fence.ts): its saved setting, or only their workspace. */
6891
+ projectWrites(repo: string): WritesMode;
6892
+ /** An approver changed where a project's builds may write: saved, and in the ledger as before → after. */
6893
+ setProjectWrites(repo: string, mode: WritesMode, by: string, now: Date): {
6894
+ before: WritesMode;
6895
+ after: WritesMode;
6896
+ };
6887
6897
  /** Whether this installation lets an agent run where no OS confinement exists (off unless an operator said so). */
6888
6898
  unfencedAgentsAllowed(): boolean;
6889
6899
  setUnfencedAgentsAllowed(allowed: boolean, by: string, now: Date): void;
package/dist/store.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { WRITES_DEFAULT } from "./writes-mode.js";
1
2
  import { PROJECT_NETWORK_DEFAULT, projectNetworkFromValue, runNetworkFromValue } from "./egress.js";
2
3
  import { isAcpProfile, isAcpProvider } from "./acp-agents.js";
3
4
  import { ACP_CONFORMANCE_VERSION, launchFromJson } from "./acp-attest.js";
@@ -715,7 +716,9 @@ const REGISTERED_AT_FILING = "again, as it was filed";
715
716
  // v130 adds, and changes no saved row: what a project's builds may reach on the network (project_network; egress.ts,
716
717
  // docs/plans/agent-baseline-hardening.md arc 1) and whether this installation lets an agent run with no OS confinement
717
718
  // (confinement_setting, one row; off unless an instance operator turns it on).
718
- export const SCHEMA_VERSION = 130;
719
+ // v131 adds, and changes no saved row: where a project's builds may write (project_writes; agent-fence.ts, docs/plans/
720
+ // agent-baseline-hardening.md arc 2) — only their workspace unless an approver chose anywhere.
721
+ export const SCHEMA_VERSION = 131;
719
722
  /** v118: the tables merged into `credential`, in the order they move (a person's API tokens keep their ids first). */
720
723
  export const V118_MERGED_TABLES = Object.freeze({ api_token: "person", coordinator_credential: "coordinator", lead_credential: "lead" });
721
724
  /** The foreign keys that named a merged table, exactly as their DDL wrote them; each names `credential(id)` instead. */
@@ -848,7 +851,7 @@ const V115_DROPPED_REFERENCES = /\s+REFERENCES (?:contestant|contest|tournament_
848
851
  * unchanged), and adds the nullable column in V125_ADDED_COLUMNS, checked the same way. v126 only adds a new table
849
852
  * (task_source) and the columns in V126_ADDED_COLUMNS (null or 0), checked the same way.
850
853
  */
851
- 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]);
854
+ 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]);
852
855
  /** v119: the columns it adds, by table, with the value every row saved before it reads (toolroll-update's HISTORY_RULES). */
853
856
  export const V119_ADDED_COLUMNS = Object.freeze({
854
857
  task_ref: { planning_generation: { definition: "INTEGER NOT NULL DEFAULT 0", saved: "0" } },
@@ -976,6 +979,15 @@ CREATE TABLE IF NOT EXISTS confinement_setting (
976
979
  );
977
980
  INSERT OR IGNORE INTO confinement_setting (id, unfenced_allowed) VALUES (1, 0);
978
981
  `;
982
+ /** v131: where a project's builds may write (an approver's setting, agent-fence.ts WritesMode). */
983
+ export const PROJECT_WRITES_SCHEMA = `
984
+ CREATE TABLE IF NOT EXISTS project_writes (
985
+ repo TEXT PRIMARY KEY,
986
+ mode TEXT NOT NULL CHECK (mode IN ('workspace', 'anywhere')),
987
+ updated_at TEXT NOT NULL,
988
+ updated_by TEXT NOT NULL
989
+ );
990
+ `;
979
991
  /** v129: a finished run's How it ran, derived from its own records on first view (display only: never a verdict). Its
980
992
  * parser version says when to derive again; `steps` says how complete the step detail was, and why not. */
981
993
  export const RUN_EFFICIENCY_SCHEMA = `
@@ -3915,6 +3927,8 @@ function initializeStore(db, file) {
3915
3927
  db.exec(RUN_EFFICIENCY_SCHEMA);
3916
3928
  // v130: a project's network and whether an agent may run unconfined (new tables only).
3917
3929
  db.exec(CONFINEMENT_SCHEMA);
3930
+ // v131: where a project's builds may write (a new table only).
3931
+ db.exec(PROJECT_WRITES_SCHEMA);
3918
3932
  // v123: a flow that runs on its own (flow-unattended.ts): its grants and daily runs (new tables), and which authority
3919
3933
  // armed a plan's unchanged-plan approval (plan_authorization.basis, V123_ADDED_COLUMNS: saved rows read the mode).
3920
3934
  db.exec(FLOW_UNATTENDED_SCHEMA);
@@ -18246,6 +18260,23 @@ export class Store {
18246
18260
  return { before, after };
18247
18261
  });
18248
18262
  }
18263
+ /** Where a project's builds may write (agent-fence.ts): its saved setting, or only their workspace. */
18264
+ projectWrites(repo) {
18265
+ const mode = this.db.prepare("SELECT mode FROM project_writes WHERE repo = ?").get(repo)?.["mode"];
18266
+ // A damaged row reads as the narrower setting, never the wider one.
18267
+ return mode === "anywhere" ? "anywhere" : WRITES_DEFAULT;
18268
+ }
18269
+ /** An approver changed where a project's builds may write: saved, and in the ledger as before → after. */
18270
+ setProjectWrites(repo, mode, by, now) {
18271
+ return this.transact(() => {
18272
+ const before = this.projectWrites(repo);
18273
+ this.db.prepare(`INSERT INTO project_writes (repo, mode, updated_at, updated_by) VALUES (?, ?, ?, ?)
18274
+ ON CONFLICT (repo) DO UPDATE SET mode = excluded.mode, updated_at = excluded.updated_at, updated_by = excluded.updated_by`).run(repo, mode, now.toISOString(), by);
18275
+ const after = this.projectWrites(repo);
18276
+ this.recordPolicy(by, repo, "build writes", before, after, now);
18277
+ return { before, after };
18278
+ });
18279
+ }
18249
18280
  /** Whether this installation lets an agent run where no OS confinement exists (off unless an operator said so). */
18250
18281
  unfencedAgentsAllowed() {
18251
18282
  return Number(this.db.prepare("SELECT unfenced_allowed FROM confinement_setting WHERE id = 1").get()?.["unfenced_allowed"] ?? 0) === 1;
@@ -8,7 +8,29 @@ export declare const REFUSED_CODE = 128;
8
8
  /** The command-running setting a `git config --list --show-scope -z` listing sets, or null. Only local and worktree
9
9
  * scope count: teardown commands never read system or global config. */
10
10
  export declare function commandSetting(listing: string): string | null;
11
+ /** The `-c` settings that carry the operator's own values (CARRIED_VALUES, LFS_FILTER) from a global/system listing. */
12
+ export declare function carriedSettings(listing: string): string[];
11
13
  /** Why a teardown command was refused, or null when `result` isn't a refusal. */
12
14
  export declare function refusal(result: ExecResult): string | null;
13
15
  /** The one way teardown runs git, including the target checkout immediately before removal. */
14
16
  export declare function teardownGit(runner?: Runner): Git;
17
+ /**
18
+ * Git on a build's checkout, as the plane runs it outside every fence (staging, committing and inspecting the build's
19
+ * work): the same fixed hardening as teardown (no hooks, fsmonitor, ext transport, credential helpers or automatic
20
+ * maintenance, no submodule recursion, no signing), refused outright when the checkout's own config includes a file
21
+ * or names a command — a filter, an external diff, a textconv, a pager — which a build could have planted. It reads no
22
+ * global or system config: only their values (identity, line endings, a standard git-lfs install) are carried over.
23
+ * It never enters a submodule or nested repository (whose config it never read).
24
+ */
25
+ /**
26
+ * Whether a checkout's own git config (its repository's config and its worktree's config.worktree) includes another
27
+ * file, read without git: every git command follows includes as it starts, so an include of the build's could point
28
+ * git at any file, a pipe that never ends among them. Each file is read only as the regular file it is, bounded; one
29
+ * that can't be read so counts as including.
30
+ */
31
+ export declare function checkoutIncludes(checkout: string): boolean;
32
+ /** Whether the checkout's .lfsconfig (read as git parses it, that file only) sets any git-lfs key that could name a
33
+ * program (anything but INERT_LFS). A file that
34
+ * isn't a plain one, or doesn't parse, counts as naming one. */
35
+ export declare function lfsExtensionIn(checkout: string): boolean;
36
+ export declare function checkoutGit(inner?: Runner): Runner;
@@ -10,7 +10,8 @@
10
10
  * any of them the command never runs. The refusal is a failed git result, so every caller's existing doubt applies and
11
11
  * nothing is removed.
12
12
  */
13
- import { existsSync, readdirSync } from "node:fs";
13
+ import { spawnSync } from "node:child_process";
14
+ import { existsSync, lstatSync, readdirSync, readFileSync } from "node:fs";
14
15
  import { join, resolve } from "node:path";
15
16
  import { run } from "./exec.js";
16
17
  import { ENV, ENV_ALLOWLIST } from "./remote-main.js";
@@ -31,6 +32,10 @@ const COMMAND_KEYS = [
31
32
  "diff.*.command", "diff.*.textconv", "merge.*.driver", "gpg.program", "gpg.*.program", "uploadpack.packobjectshook",
32
33
  "remote.*.uploadpack", "remote.*.receivepack", "remote.*.vcs", "pager.*", "include.path", "includeif.*.path",
33
34
  ].map(key => new RegExp(`^${key.replace(/\./g, "\\.").replace(/\*/g, ".+")}$`));
35
+ /** git-lfs settings that name no program (where it fetches, what, whether it checks locks, how it signs in); any other
36
+ * `lfs.*` key in a checkout's own config could (extensions, custom and standalone transfer agents), so it's refused. */
37
+ const INERT_LFS = /^lfs\.(url|pushurl|fetchinclude|fetchexclude|locksverify|repositoryformatversion|.+\.access|.+\.locksverify|setlockablereadonly|skipdownloaderrors|concurrenttransfers)$/;
38
+ const lfsCommand = (key) => key.startsWith("lfs.") && !INERT_LFS.test(key);
34
39
  /** core.fsmonitor as a boolean is git's own daemon; anything else is a command to run. */
35
40
  const BOOLEAN = /^(|true|false|yes|no|on|off|-?\d+)$/i;
36
41
  /** The command-running setting a `git config --list --show-scope -z` listing sets, or null. Only local and worktree
@@ -45,11 +50,40 @@ export function commandSetting(listing) {
45
50
  const newline = entry.indexOf("\n");
46
51
  const key = (newline < 0 ? entry : entry.slice(0, newline)).toLowerCase();
47
52
  const value = newline < 0 ? "" : entry.slice(newline + 1);
48
- if (key === "core.fsmonitor" ? !BOOLEAN.test(value.trim()) : COMMAND_KEYS.some(pattern => pattern.test(key)))
53
+ if (key === "core.fsmonitor" ? !BOOLEAN.test(value.trim()) : COMMAND_KEYS.some(pattern => pattern.test(key)) || lfsCommand(key))
49
54
  return key;
50
55
  }
51
56
  return null;
52
57
  }
58
+ /**
59
+ * What the plane's git on a build's checkout keeps of the operator's own (global and system) configuration: values,
60
+ * never commands — identity, line endings, ignore and case settings — and git-lfs's filters only as git-lfs itself
61
+ * installs them, exactly. Everything else there (filters, diff drivers, fsmonitor, signing and its key commands,
62
+ * whatever an include brings in) is left out, so nothing in it can run a file of the checkout's.
63
+ */
64
+ const CARRIED_VALUES = new Set(["user.name", "user.email", "core.autocrlf", "core.eol", "core.safecrlf", "core.excludesfile", "core.ignorecase",
65
+ "core.precomposeunicode", "core.quotepath", "core.filemode", "core.symlinks", "core.longpaths"]);
66
+ const LFS_FILTER = {
67
+ "filter.lfs.clean": "git-lfs clean -- %f", "filter.lfs.smudge": "git-lfs smudge -- %f", "filter.lfs.process": "git-lfs filter-process", "filter.lfs.required": "true",
68
+ };
69
+ /** The `-c` settings that carry the operator's own values (CARRIED_VALUES, LFS_FILTER) from a global/system listing. */
70
+ export function carriedSettings(listing) {
71
+ const fields = listing.split("\0");
72
+ const carried = new Map();
73
+ for (let at = 0; at + 1 < fields.length; at += 2) {
74
+ if (fields[at] !== "global" && fields[at] !== "system")
75
+ continue;
76
+ const entry = fields[at + 1];
77
+ const newline = entry.indexOf("\n");
78
+ if (newline < 0)
79
+ continue;
80
+ const key = entry.slice(0, newline).toLowerCase();
81
+ const value = entry.slice(newline + 1);
82
+ if (CARRIED_VALUES.has(key) || LFS_FILTER[key] === value)
83
+ carried.set(key, value);
84
+ }
85
+ return [...carried].flatMap(([key, value]) => ["-c", `${key}=${value}`]);
86
+ }
53
87
  /** Why a teardown command was refused, or null when `result` isn't a refusal. */
54
88
  export function refusal(result) {
55
89
  return result.code === REFUSED_CODE && result.stderr.startsWith(REFUSED_PREFIX) ? result.stderr.slice(REFUSED_PREFIX.length).trim() : null;
@@ -117,3 +151,103 @@ export function teardownGit(runner = run) {
117
151
  return runner("git", [...TEARDOWN_FLAGS, ...safeArgs], options(cwd));
118
152
  };
119
153
  }
154
+ /**
155
+ * Git on a build's checkout, as the plane runs it outside every fence (staging, committing and inspecting the build's
156
+ * work): the same fixed hardening as teardown (no hooks, fsmonitor, ext transport, credential helpers or automatic
157
+ * maintenance, no submodule recursion, no signing), refused outright when the checkout's own config includes a file
158
+ * or names a command — a filter, an external diff, a textconv, a pager — which a build could have planted. It reads no
159
+ * global or system config: only their values (identity, line endings, a standard git-lfs install) are carried over.
160
+ * It never enters a submodule or nested repository (whose config it never read).
161
+ */
162
+ /**
163
+ * Whether a checkout's own git config (its repository's config and its worktree's config.worktree) includes another
164
+ * file, read without git: every git command follows includes as it starts, so an include of the build's could point
165
+ * git at any file, a pipe that never ends among them. Each file is read only as the regular file it is, bounded; one
166
+ * that can't be read so counts as including.
167
+ */
168
+ export function checkoutIncludes(checkout) {
169
+ const regular = (path) => {
170
+ try {
171
+ const held = lstatSync(path);
172
+ if (!held.isFile() || held.size > 1024 * 1024)
173
+ return null;
174
+ return readFileSync(path, "utf8");
175
+ }
176
+ catch {
177
+ return null;
178
+ }
179
+ };
180
+ let gitdir = join(checkout, ".git");
181
+ try {
182
+ if (!lstatSync(gitdir).isDirectory()) {
183
+ // As git reads a .git file: "gitdir: " then the path, one line; anything else (more lines, stray space) refused.
184
+ const text = (regular(gitdir) ?? "").replace(/\r?\n$/, "");
185
+ if (!text.startsWith("gitdir: ") || /[\r\n]/.test(text))
186
+ return true;
187
+ const pointer = text.slice("gitdir: ".length);
188
+ if (pointer === "" || pointer !== pointer.trim())
189
+ return true;
190
+ gitdir = resolve(checkout, pointer);
191
+ }
192
+ }
193
+ catch {
194
+ return true;
195
+ }
196
+ const common = (() => { const named = regular(join(gitdir, "commondir"))?.trim(); return named === undefined || named === "" ? gitdir : resolve(gitdir, named); })();
197
+ for (const path of [join(common, "config"), join(gitdir, "config.worktree")]) {
198
+ if (!existsSync(path) && path.endsWith("config.worktree"))
199
+ continue;
200
+ const text = regular(path);
201
+ if (text === null || /^\s*\[\s*include(if)?\b/im.test(text))
202
+ return true;
203
+ // And as git itself parses it, includes not followed (only this one regular file, from a neutral folder, with no
204
+ // other config read): an include git sees by any spelling is refused.
205
+ const parsed = spawnSync("git", ["config", "--file", path, "--no-includes", "--name-only", "--list", "-z"], { cwd: "/", encoding: "utf8", timeout: 5_000, env: { PATH: process.env["PATH"] ?? "", GIT_CONFIG_NOSYSTEM: "1", GIT_CONFIG_GLOBAL: "/dev/null", HOME: "/nonexistent" } });
206
+ if (parsed.status !== 0 || parsed.stdout.split("\0").some(name => /^(include\.path|includeif\..*\.path)$/i.test(name)))
207
+ return true;
208
+ }
209
+ return false;
210
+ }
211
+ /** Whether the checkout's .lfsconfig (read as git parses it, that file only) sets any git-lfs key that could name a
212
+ * program (anything but INERT_LFS). A file that
213
+ * isn't a plain one, or doesn't parse, counts as naming one. */
214
+ export function lfsExtensionIn(checkout) {
215
+ const path = join(checkout, ".lfsconfig");
216
+ try {
217
+ if (!lstatSync(path).isFile())
218
+ return true;
219
+ }
220
+ catch {
221
+ return false;
222
+ }
223
+ const parsed = spawnSync("git", ["config", "--file", path, "--no-includes", "--name-only", "--list", "-z"], { cwd: "/", encoding: "utf8", timeout: 5_000, env: { PATH: process.env["PATH"] ?? "", GIT_CONFIG_NOSYSTEM: "1", GIT_CONFIG_GLOBAL: "/dev/null", HOME: "/nonexistent" } });
224
+ return parsed.status !== 0 || parsed.stdout.split("\0").filter(Boolean).some(name => lfsCommand(name.toLowerCase()) || /^include(if)?\./i.test(name));
225
+ }
226
+ export function checkoutGit(inner = run) {
227
+ return async (file, args, options) => {
228
+ if (file !== "git")
229
+ return inner(file, args, options);
230
+ const cwd = options?.cwd;
231
+ if (cwd === undefined || checkoutIncludes(cwd))
232
+ return refused("the checkout's git config includes another file, or couldn't be read as a plain file");
233
+ // git-lfs also reads .lfsconfig from the checkout: one that names an extension (a command it would run) is refused.
234
+ if (lfsExtensionIn(cwd))
235
+ return refused("the checkout's .lfsconfig sets a git-lfs key that can run a command");
236
+ // The checkout's own config may name no command.
237
+ const own = await inner("git", ["--no-optional-locks", "config", "--list", "--show-scope", "-z"], options);
238
+ if (own.code !== 0)
239
+ return refused("its config couldn't be read");
240
+ const key = commandSetting(own.stdout);
241
+ if (key !== null)
242
+ return refused(`the checkout's git config sets ${key}, which can run a command`);
243
+ // The operator's own config contributes values only (carriedSettings); git then reads no global or system config.
244
+ const carried = carriedSettings(own.stdout);
245
+ const env = { ...(options?.env ?? {}), GIT_CONFIG_NOSYSTEM: "1", GIT_CONFIG_GLOBAL: "/dev/null" };
246
+ // Never into a submodule or nested repository, whose own config this never read: status and diff skip them.
247
+ const safeArgs = [...args];
248
+ const command = safeArgs.findIndex(arg => arg === "status" || arg === "diff");
249
+ if (command >= 0 && !safeArgs.includes("--ignore-submodules=all"))
250
+ safeArgs.splice(command + 1, 0, "--ignore-submodules=all");
251
+ return inner("git", [...TEARDOWN_FLAGS, "-c", "diff.ignoreSubmodules=all", "-c", "commit.gpgsign=false", "-c", "tag.gpgsign=false", ...carried, ...safeArgs], { ...options, env });
252
+ };
253
+ }
@@ -11,7 +11,13 @@ export type BrokerOptions = {
11
11
  omitEnv?: readonly string[];
12
12
  /** Toolroll's own settings for a local server (the run's isolated database), passed as they are. */
13
13
  pass?: Readonly<Record<string, string>>;
14
+ /** Where a local server starts: a folder the build can't write (never its checkout). */
14
15
  cwd?: string;
16
+ /** Folders the build writes (its checkout, its caches): a server whose command, or a path it's given, resolves inside
17
+ * one is never started, since it would run the build's own bytes outside the build's limits. */
18
+ untrusted?: readonly string[];
19
+ /** Why a local server may not start unwrapped (no fence for it here), read when it would; null when it may. */
20
+ unwrapped?: () => string | null;
15
21
  /** How a local server is started inside its run's fence, read when it starts (the fence is fixed after the tools). */
16
22
  wrap?: () => ((file: string, args: readonly string[]) => {
17
23
  file: string;
@@ -35,5 +41,11 @@ export declare const SERVER_ENV: readonly string[];
35
41
  /** A local server's environment: the allowlisted names from `base` (and its LC_* locale), less `omit`, then `pass`,
36
42
  * then the tool's own values. */
37
43
  export declare function serverEnv(base: NodeJS.ProcessEnv, omit: readonly string[], pass: Readonly<Record<string, string>>, values: Readonly<Record<string, string>>): NodeJS.ProcessEnv;
44
+ /**
45
+ * The first of a server's command and path-like arguments that resolves inside an untrusted folder (a build's checkout
46
+ * or caches), or null. The command is resolved as spawn would: a path against `cwd`, a bare name through PATH; an
47
+ * argument counts when it names an existing path (relative to `cwd`) or is written as one (`./x`, `../x`, `/x`).
48
+ */
49
+ export declare function workspaceBytes(command: string, args: readonly string[], cwd: string | undefined, path: string | undefined, untrusted: readonly string[]): string | null;
38
50
  /** Start one launch's broker on a loopback port. */
39
51
  export declare function startToolBroker(tools: readonly BrokeredTool[], options?: BrokerOptions): Promise<ToolBroker>;
@@ -20,6 +20,8 @@
20
20
  import { spawn } from "node:child_process";
21
21
  import { randomBytes, timingSafeEqual } from "node:crypto";
22
22
  import { createServer } from "node:http";
23
+ import { accessSync, constants, existsSync, realpathSync } from "node:fs";
24
+ import { delimiter, isAbsolute, resolve, sep } from "node:path";
23
25
  const BODY_LIMIT = 4 * 1024 * 1024;
24
26
  const ANSWER_LIMIT = 32 * 1024 * 1024;
25
27
  const STOP_GRACE_MS = 2_000;
@@ -59,6 +61,54 @@ export function serverEnv(base, omit, pass, values) {
59
61
  delete env[name];
60
62
  return Object.assign(env, pass, values);
61
63
  }
64
+ /**
65
+ * The first of a server's command and path-like arguments that resolves inside an untrusted folder (a build's checkout
66
+ * or caches), or null. The command is resolved as spawn would: a path against `cwd`, a bare name through PATH; an
67
+ * argument counts when it names an existing path (relative to `cwd`) or is written as one (`./x`, `../x`, `/x`).
68
+ */
69
+ export function workspaceBytes(command, args, cwd, path, untrusted) {
70
+ if (untrusted.length === 0)
71
+ return null;
72
+ const base = cwd ?? process.cwd();
73
+ // Canonical on-disk names, folded where the filesystem ignores case (macOS, Windows), so `/Users/a/CHECKOUT` is the checkout.
74
+ const fold = process.platform === "darwin" || process.platform === "win32" ? (one) => one.toLowerCase() : (one) => one;
75
+ const real = (one) => { try {
76
+ return fold(realpathSync.native(one));
77
+ }
78
+ catch {
79
+ return fold(resolve(one));
80
+ } };
81
+ const roots = untrusted.map(real);
82
+ const within = (one) => { const at = real(one); return roots.some(root => at === root || at.startsWith(`${root}${sep}`)); };
83
+ // A root's name anywhere in an argument (inline code, a list of paths) counts, however it's spelled.
84
+ const mentions = (arg) => { const text = fold(arg); return roots.some(root => text.includes(root)) || untrusted.some(root => text.includes(fold(root))); };
85
+ const located = command.includes("/") || command.includes(sep) ? resolve(base, command)
86
+ : (path ?? "").split(delimiter).filter(Boolean).map(dir => resolve(base, dir, command)).find(one => { try {
87
+ accessSync(one, constants.X_OK);
88
+ return true;
89
+ }
90
+ catch {
91
+ return false;
92
+ } }) ?? null;
93
+ if (located !== null && within(located))
94
+ return located;
95
+ // A relative path once meant "in the checkout" (servers started there): it is refused rather than read elsewhere.
96
+ if (!isAbsolute(command) && (command.includes("/") || command.includes(sep)))
97
+ return `${command} (a relative path)`;
98
+ for (const arg of args) {
99
+ if (mentions(arg))
100
+ return `${arg} (names what this build writes)`;
101
+ // A flag's value (`--config=x`, `-c=x`) is checked as its own argument.
102
+ for (const piece of [arg, ...(arg.startsWith("-") && arg.includes("=") ? [arg.slice(arg.indexOf("=") + 1)] : [])]) {
103
+ if (piece.startsWith("./") || piece.startsWith("../"))
104
+ return `${piece} (a relative path)`;
105
+ const candidate = resolve(base, piece);
106
+ if ((isAbsolute(piece) || existsSync(candidate)) && within(candidate))
107
+ return candidate;
108
+ }
109
+ }
110
+ return null;
111
+ }
62
112
  /** A local server, started on first use and spoken to over its stdin and stdout. */
63
113
  class LocalServer {
64
114
  tool;
@@ -68,6 +118,7 @@ class LocalServer {
68
118
  buffer = "";
69
119
  next = 1;
70
120
  pending = new Map();
121
+ refusal = null;
71
122
  constructor(tool, options) {
72
123
  this.tool = tool;
73
124
  this.options = options;
@@ -75,7 +126,19 @@ class LocalServer {
75
126
  pid() { return this.child?.pid ?? null; }
76
127
  start() {
77
128
  const env = serverEnv(this.options.env ?? process.env, this.options.omitEnv ?? [], this.options.pass ?? {}, this.tool.values);
129
+ const planted = workspaceBytes(this.tool.spec.command, this.tool.spec.args, this.options.cwd, env["PATH"], this.options.untrusted ?? []);
130
+ if (planted !== null) {
131
+ this.refusal = `The tool's server would run ${planted}, inside what this build can write, so it was not started.`;
132
+ return null;
133
+ }
78
134
  const wrap = this.options.wrap?.() ?? null;
135
+ if (wrap === null) {
136
+ const why = this.options.unwrapped?.() ?? null;
137
+ if (why !== null) {
138
+ this.refusal = why;
139
+ return null;
140
+ }
141
+ }
79
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);
80
143
  const child = spawn(file, args, { env, ...(this.options.cwd === undefined ? {} : { cwd: this.options.cwd }), stdio: ["pipe", "pipe", "ignore"], detached: process.platform !== "win32" });
81
144
  this.child = child;
@@ -131,8 +194,9 @@ class LocalServer {
131
194
  // A new session (a reconnect) starts a fresh server: a server answers initialize once.
132
195
  if (message.method === "initialize" && this.initialized)
133
196
  this.stop();
134
- if (this.child === null)
135
- this.start();
197
+ if (this.child === null && this.start() === null) {
198
+ return Promise.resolve(isRequest(message) ? rpcError(message.id, this.refusal ?? "The tool could not start.") : null);
199
+ }
136
200
  if (message.method === "initialize")
137
201
  this.initialized = true;
138
202
  if (!isRequest(message)) {
@@ -0,0 +1,2 @@
1
+ import type { Store } from "./store.js";
2
+ export declare function allowWhereUnfenced<T extends Store>(store: T): T;
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Tests that run real commands on a machine with no OS fence (CI's Ubuntu runners, where unprivileged namespaces are
3
+ * off) allow unconfined agents on their own store, as an instance operator there would; anywhere a fence exists,
4
+ * nothing changes and the fence is what the test runs under.
5
+ */
6
+ import { linuxFenceAvailable, macosFenceAvailable } from "./agent-fence.js";
7
+ export function allowWhereUnfenced(store) {
8
+ if (!macosFenceAvailable() && !linuxFenceAvailable())
9
+ store.setUnfencedAgentsAllowed(true, "test", new Date(0));
10
+ return store;
11
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Where a build may write (docs/plans/agent-baseline-hardening.md arc 2). `workspace`: only its allowlist (agent-fence.ts
3
+ * WriteAllowlist), whatever its permission mode; `anywhere`: anywhere its fence doesn't deny, as builds did before.
4
+ */
5
+ export type WritesMode = "workspace" | "anywhere";
6
+ export declare const WRITES_DEFAULT: WritesMode;
7
+ export declare const isWritesMode: (value: unknown) => value is WritesMode;
8
+ /** What an approval card and the CLI say of where a build may write (absent: sealed before write allowlists). */
9
+ export declare function writesWords(writes: WritesMode | undefined): string;
@@ -0,0 +1,8 @@
1
+ export const WRITES_DEFAULT = "workspace";
2
+ export const isWritesMode = (value) => value === "workspace" || value === "anywhere";
3
+ /** What an approval card and the CLI say of where a build may write (absent: sealed before write allowlists). */
4
+ export function writesWords(writes) {
5
+ return writes === "workspace" ? "only its checkout, its caches and its temp folder"
6
+ : writes === "anywhere" ? "anywhere outside Toolroll's fenced folders (an approver's choice)"
7
+ : "anywhere outside Toolroll's fenced folders (approved before write limits)";
8
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toolroll",
3
- "version": "0.9.65",
3
+ "version": "0.9.67",
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"
@@ -84,6 +84,8 @@
84
84
  "@types/react-dom": "19.3.0",
85
85
  "@types/ws": "8.18.2",
86
86
  "@xterm/addon-fit": "0.11.0",
87
+ "@xterm/addon-unicode11": "0.9.0",
88
+ "@xterm/addon-webgl": "0.19.0",
87
89
  "@xterm/xterm": "6.0.0",
88
90
  "@xyflow/react": "12.12.0",
89
91
  "class-variance-authority": "0.7.1",
@@ -124,6 +126,8 @@
124
126
  },
125
127
  "dependencies": {
126
128
  "@slack/socket-mode": "3.1.0",
129
+ "@xterm/addon-serialize": "0.14.0",
130
+ "@xterm/headless": "6.0.0",
127
131
  "discord.js": "14.27.0",
128
132
  "imapflow": "2.2.5",
129
133
  "mailparser": "3.9.36",