toolroll 0.9.69 → 0.9.70

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 (68) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/README.md +43 -17
  3. package/THIRD_PARTY_NOTICES.md +21 -1
  4. package/dist/agent-fence.d.ts +180 -115
  5. package/dist/agent-fence.js +413 -535
  6. package/dist/agentconfig.js +6 -1
  7. package/dist/browser/THIRD_PARTY_NOTICES.txt +21 -0
  8. package/dist/builder.js +24 -4
  9. package/dist/check-access.d.ts +6 -9
  10. package/dist/check-access.js +7 -10
  11. package/dist/coding-provider.d.ts +14 -0
  12. package/dist/coding-provider.js +33 -1
  13. package/dist/coding-workspace.d.ts +15 -0
  14. package/dist/coding-workspace.js +9 -2
  15. package/dist/contracts/console-api.d.ts +1 -0
  16. package/dist/contracts/console-api.js +1 -0
  17. package/dist/doctor.d.ts +7 -0
  18. package/dist/doctor.js +4 -0
  19. package/dist/egress.d.ts +2 -0
  20. package/dist/egress.js +13 -4
  21. package/dist/exec.d.ts +17 -3
  22. package/dist/exec.js +54 -17
  23. package/dist/flow-actions.d.ts +1 -0
  24. package/dist/flow-actions.js +3 -2
  25. package/dist/flow-code.d.ts +31 -2
  26. package/dist/flow-code.js +57 -5
  27. package/dist/flow-steps.d.ts +4 -0
  28. package/dist/flow-steps.js +9 -5
  29. package/dist/flow-triggers.d.ts +4 -0
  30. package/dist/flow-triggers.js +8 -3
  31. package/dist/invoke.d.ts +18 -8
  32. package/dist/invoke.js +98 -115
  33. package/dist/operate.js +46 -0
  34. package/dist/project-delete-ui.d.ts +7 -6
  35. package/dist/project-delete-ui.js +36 -2
  36. package/dist/project-sandbox.d.ts +59 -0
  37. package/dist/project-sandbox.js +83 -0
  38. package/dist/project-tools.d.ts +29 -5
  39. package/dist/project-tools.js +43 -7
  40. package/dist/release.d.ts +1 -1
  41. package/dist/release.js +1 -0
  42. package/dist/sandbox-launch.d.ts +1 -0
  43. package/dist/sandbox-launch.js +148 -0
  44. package/dist/sandbox-runtime.d.ts +62 -0
  45. package/dist/sandbox-runtime.js +140 -0
  46. package/dist/scope.d.ts +28 -0
  47. package/dist/scope.js +25 -5
  48. package/dist/scout-browser-lock.d.ts +69 -0
  49. package/dist/scout-browser-lock.js +69 -0
  50. package/dist/scout-browser.d.ts +60 -1
  51. package/dist/scout-browser.js +200 -4
  52. package/dist/scout.d.ts +9 -4
  53. package/dist/scout.js +14 -4
  54. package/dist/serve.js +3 -1
  55. package/dist/server/render-tasks.js +4 -1
  56. package/dist/server/route-table.js +1 -0
  57. package/dist/server/settings.js +27 -0
  58. package/dist/store.d.ts +31 -1
  59. package/dist/store.js +83 -4
  60. package/dist/subagent-tools.d.ts +1 -0
  61. package/dist/subagent-tools.js +7 -5
  62. package/dist/tool-broker.d.ts +2 -1
  63. package/dist/tool-broker.js +2 -2
  64. package/dist/tool-launcher.js +10 -16
  65. package/package.json +2 -1
  66. package/scripts/provider-canary.mjs +5 -6
  67. package/dist/egress-bridge.d.ts +0 -17
  68. package/dist/egress-bridge.js +0 -111
@@ -10,21 +10,32 @@
10
10
  *
11
11
  * Playwright MCP 0.0.82 has no per-client token for its HTTP transport; it answers only on 127.0.0.1, only for
12
12
  * that host name (its own host check), and only while this run's scout does.
13
+ *
14
+ * An explicit exception (docs/plans/sandbox-parity.md C4, owner Q-A): Toolroll can't fence this process without
15
+ * switching Chrome's sandbox off, so its launch is held to a contract instead, and refused when any part fails
16
+ * (`browserContractProblem`): the pinned Playwright MCP and its Chrome for Testing are installed in Toolroll's own
17
+ * helpers folder (inside the state folder every build's fence denies) and run by Toolroll's own Node; its arguments are
18
+ * exactly the expected ones (Chrome's sandbox on, the scout's proxy on, its image folder as its output and working
19
+ * folder); its environment is an allowlist. The run records which exception it ran under.
13
20
  */
14
21
  import { spawn } from "node:child_process";
15
22
  import { request as httpRequest } from "node:http";
16
23
  import { connect, createServer } from "node:net";
17
- import { readdirSync, rmSync } from "node:fs";
18
- import { join } from "node:path";
24
+ import { createHash } from "node:crypto";
25
+ import { lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from "node:fs";
26
+ import { dirname, join, relative, isAbsolute } from "node:path";
27
+ import { run } from "./exec.js";
28
+ import { SCOUT_BROWSER_LOCK, SCOUT_BROWSER_MANIFEST } from "./scout-browser-lock.js";
19
29
  import { sleep } from "./sleep.js";
20
30
  /** The public page the check opens through the scout's proxy. */
21
31
  export const PREFLIGHT_PAGE = "https://example.com/";
22
32
  const PREFLIGHT_TIMEOUT_MS = 60_000;
23
33
  const CLOSE_GRACE_MS = 5_000;
24
34
  const IMAGE = /\.(png|jpe?g)$/i;
25
- /** How a scout gets its browser: started for real, or, for a test's stub agent, a placeholder that starts nothing. */
35
+ /** How a scout gets its browser: started for real under its launch contract, or, for a test's stub agent, a placeholder
36
+ * that starts nothing. */
26
37
  export function starterFor(agentInjected) {
27
- return agentInjected ? unstarted : startScoutBrowser;
38
+ return agentInjected ? unstarted : startContractedBrowser;
28
39
  }
29
40
  const unstarted = async () => ({ ok: true, server: { url: "http://127.0.0.1:9/mcp", close: async () => undefined } });
30
41
  /** Start the browser tool outside the fence and check it opens a public page and saves a screenshot. */
@@ -227,3 +238,188 @@ function oneLine(text) {
227
238
  // eslint-disable-next-line no-control-regex
228
239
  return text.replace(/[\u0000-\u001f\u007f-\u009f]+/g, " ").trim().slice(0, 200);
229
240
  }
241
+ // ---- the launch contract (docs/plans/sandbox-parity.md C4) ----
242
+ /** The pinned browser tool, installed once into Toolroll's helpers folder. */
243
+ export const SCOUT_BROWSER_PACKAGE = "@playwright/mcp@0.0.82";
244
+ const HELPER_NAME = "scout-browser-0.0.82";
245
+ /** The names of the worker's environment the browser tool gets: an allowlist, never a denylist. */
246
+ export const SCOUT_BROWSER_ENV = ["PATH", "HOME", "USER", "LOGNAME", "LANG", "LC_ALL", "LC_CTYPE", "TZ", "TMPDIR",
247
+ "HTTP_PROXY", "HTTPS_PROXY", "http_proxy", "https_proxy"];
248
+ /** What the run records of the exception it ran under. */
249
+ export const SCOUT_BROWSER_CONTAINMENT = "Chrome's sandbox";
250
+ /** Where the pinned browser lives under `helpers`. */
251
+ export function scoutBrowserHelper(helpers) {
252
+ const folder = join(helpers, HELPER_NAME);
253
+ return { folder, cli: join(folder, "node_modules", "@playwright", "mcp", "cli.js"), browsers: join(folder, "browsers") };
254
+ }
255
+ /** The scout's browser tool: the pinned helper, run by Toolroll's own Node, headless, on its bundled Chrome. */
256
+ export function scoutBrowserTool(helpers) {
257
+ return { command: process.execPath, args: [scoutBrowserHelper(helpers).cli, "--headless", "--browser", "chromium"] };
258
+ }
259
+ /** A folder of Toolroll's own: a real folder (no link at it), this user's, private (0700). */
260
+ function privateFolder(path) {
261
+ try {
262
+ const held = lstatSync(path);
263
+ return held.isDirectory() && !held.isSymbolicLink() && (process.getuid === undefined || held.uid === process.getuid()) && (held.mode & 0o077) === 0;
264
+ }
265
+ catch {
266
+ return false;
267
+ }
268
+ }
269
+ const within = (child, parent) => { const rel = relative(parent, child); return rel !== "" && !rel.startsWith("..") && !isAbsolute(rel); };
270
+ /**
271
+ * Why a browser launch breaks its contract, or null when it holds. Every input it trusts is the plane's: the helpers
272
+ * folder and the proxy come from `options.contract`, never from the launch. The executable must be Toolroll's own
273
+ * Node; the script the pinned helper's, inside a private helpers folder; the arguments exactly the expected ones (no
274
+ * way to switch Chrome's sandbox off, point at another browser or executable, or drop the proxy); the working folder
275
+ * the image folder; the environment only allowlisted names.
276
+ */
277
+ export function browserContractProblem(launch, options) {
278
+ const contract = options.contract;
279
+ if (contract === undefined)
280
+ return "the browser has no launch contract";
281
+ const real = (path) => { try {
282
+ return realpathSync(path);
283
+ }
284
+ catch {
285
+ return null;
286
+ } };
287
+ if (real(launch.command) === null || real(launch.command) !== real(process.execPath))
288
+ return "the browser isn't run by Toolroll's own Node";
289
+ if (!privateFolder(contract.helpers))
290
+ return "Toolroll's helpers folder isn't a private folder of its own";
291
+ const helper = scoutBrowserHelper(contract.helpers);
292
+ const script = launch.args[0] === undefined ? null : real(launch.args[0]);
293
+ const helpers = real(contract.helpers);
294
+ if (script === null || helpers === null || !within(script, helpers) || script !== real(helper.cli))
295
+ return "the browser tool isn't the pinned one in Toolroll's helpers folder";
296
+ const expected = [launch.args[0], "--headless", "--browser", "chromium", "--isolated", "--output-dir", options.folder, "--proxy-server", contract.proxy, "--proxy-bypass", "<-loopback>"];
297
+ if (launch.args.length !== expected.length || launch.args.some((one, index) => one !== expected[index]))
298
+ return "the browser's arguments aren't the contract's (Chrome's sandbox and the scout's proxy stay on)";
299
+ const extra = Object.keys(options.env).filter(name => options.env[name] !== undefined && !SCOUT_BROWSER_ENV.includes(name));
300
+ if (extra.length > 0)
301
+ return `the browser's environment carries names outside its allowlist (${extra.slice(0, 5).join(", ")})`;
302
+ if (options.env["HTTPS_PROXY"] !== contract.proxy || options.env["HTTP_PROXY"] !== contract.proxy)
303
+ return "the browser's environment doesn't point at the scout's proxy";
304
+ return null;
305
+ }
306
+ /** The registry the helper is installed from: pinned, whatever any npm configuration says. */
307
+ const NPM_REGISTRY = "https://registry.npmjs.org/";
308
+ const sha256 = (text) => createHash("sha256").update(text).digest("hex");
309
+ /** The pinned lockfile's digest, recorded when the helper is installed and checked before every use. */
310
+ export const SCOUT_BROWSER_LOCK_DIGEST = sha256(JSON.stringify(SCOUT_BROWSER_LOCK));
311
+ /** The npm of the Node running Toolroll (`lib/node_modules/npm/bin/npm-cli.js` beside it), run by that Node by path:
312
+ * never an `npm` found on PATH. Null where this Node has none beside it (a bundled runtime). */
313
+ export function ownNpm(execPath = process.execPath) {
314
+ const cli = join(dirname(dirname(execPath)), "lib", "node_modules", "npm", "bin", "npm-cli.js");
315
+ try {
316
+ return realpathSync(cli);
317
+ }
318
+ catch {
319
+ return null;
320
+ }
321
+ }
322
+ /**
323
+ * Install the pinned browser into the helpers folder once. The worker runs this outside every fence, so nothing a build
324
+ * could have written may steer it: npm is Toolroll's own Node's, run by path; HOME is the staging folder and the user
325
+ * and global npm configurations are an empty file there (no ~/.npmrc, no global npmrc, no registry, script or Node
326
+ * option from either); the registry is pinned; install scripts never run; `npm ci` installs exactly the pinned lockfile
327
+ * (scout-browser-lock.ts) and refuses a tarball whose integrity differs; the installed versions are checked against it
328
+ * before ready.json is written. The npm cache and Chrome for Testing stay in the folder, never in the user's caches.
329
+ * Residual: Chrome for Testing comes from Playwright's own download over HTTPS, without a pinned hash.
330
+ */
331
+ export async function ensureScoutBrowserHelper(helpers, timeoutMs = 600_000) {
332
+ const helper = scoutBrowserHelper(helpers);
333
+ if (helperReady(helper.folder))
334
+ return { ok: true };
335
+ const npm = ownNpm();
336
+ if (npm === null)
337
+ return { ok: false, reason: "Toolroll's own Node has no npm beside it, so the pinned browser can't be installed" };
338
+ try {
339
+ mkdirSync(helpers, { recursive: true, mode: 0o700 });
340
+ }
341
+ catch { /* checked next */ }
342
+ if (!privateFolder(helpers))
343
+ return { ok: false, reason: "Toolroll's helpers folder isn't a private folder of its own" };
344
+ const staging = mkdtempSync(join(helpers, ".install-"));
345
+ try {
346
+ writeFileSync(join(staging, "package.json"), JSON.stringify(SCOUT_BROWSER_MANIFEST), { mode: 0o600 });
347
+ writeFileSync(join(staging, "package-lock.json"), JSON.stringify(SCOUT_BROWSER_LOCK), { mode: 0o600 });
348
+ const { env, args } = helperNpm(staging, npm);
349
+ const installed = await run(process.execPath, [...args, "ci", "--ignore-scripts", "--no-audit", "--no-fund"], { cwd: staging, env, envAllowlist: [], timeoutMs });
350
+ if (installed.code !== 0)
351
+ return { ok: false, reason: `the pinned browser couldn't be installed (npm exit ${installed.code})` };
352
+ const drift = lockDrift(staging);
353
+ if (drift !== null)
354
+ return { ok: false, reason: `the installed browser isn't the pinned one (${drift})` };
355
+ const browsers = await run(process.execPath, [join(staging, "node_modules", "playwright", "cli.js"), "install", "chromium"], { cwd: staging, env, envAllowlist: [], timeoutMs });
356
+ if (browsers.code !== 0)
357
+ return { ok: false, reason: `the pinned browser's Chrome couldn't be installed (exit ${browsers.code})` };
358
+ rmSync(join(staging, "npm-cache"), { recursive: true, force: true });
359
+ writeFileSync(join(staging, "ready.json"), JSON.stringify({ package: SCOUT_BROWSER_PACKAGE, lock: SCOUT_BROWSER_LOCK_DIGEST }), { mode: 0o600 });
360
+ try {
361
+ renameSync(staging, helper.folder);
362
+ }
363
+ catch {
364
+ if (!helperReady(helper.folder))
365
+ return { ok: false, reason: "the pinned browser couldn't be put in place" };
366
+ }
367
+ return { ok: true };
368
+ }
369
+ finally {
370
+ rmSync(staging, { recursive: true, force: true });
371
+ }
372
+ }
373
+ /** How the helper's npm runs in `staging`: Toolroll's own npm by path, with its home, user and global configuration
374
+ * the staging folder's own empty ones, the registry and cache pinned; the environment only these names. */
375
+ export function helperNpm(staging, npm) {
376
+ // Two empty files: npm refuses to load one file as both.
377
+ const user = join(staging, "user.npmrc"), global = join(staging, "global.npmrc");
378
+ for (const file of [user, global])
379
+ writeFileSync(file, "", { mode: 0o600 });
380
+ const env = {
381
+ PATH: [dirname(process.execPath), "/usr/bin", "/bin"].join(":"), HOME: staging, PLAYWRIGHT_BROWSERS_PATH: join(staging, "browsers"),
382
+ NPM_CONFIG_USERCONFIG: user, NPM_CONFIG_GLOBALCONFIG: global, npm_config_userconfig: user, npm_config_globalconfig: global,
383
+ };
384
+ return { env, args: [npm, "--no-update-notifier", `--registry=${NPM_REGISTRY}`, "--userconfig", user, "--globalconfig", global, "--cache", join(staging, "npm-cache")] };
385
+ }
386
+ /** Whether a helper folder holds the pinned install: its ready note names this lockfile, and its tree still matches. */
387
+ function helperReady(folder) {
388
+ try {
389
+ const ready = JSON.parse(readFileSync(join(folder, "ready.json"), "utf8"));
390
+ return ready.lock === SCOUT_BROWSER_LOCK_DIGEST && lockDrift(folder) === null;
391
+ }
392
+ catch {
393
+ return false;
394
+ }
395
+ }
396
+ /** What of an installed tree differs from the pinned lockfile (a package missing or at another version), or null. */
397
+ export function lockDrift(folder) {
398
+ for (const [path, entry] of Object.entries(SCOUT_BROWSER_LOCK.packages)) {
399
+ if (path === "" || entry.version === undefined)
400
+ continue;
401
+ let version;
402
+ try {
403
+ version = JSON.parse(readFileSync(join(folder, path, "package.json"), "utf8")).version;
404
+ }
405
+ catch {
406
+ version = undefined;
407
+ }
408
+ if (version !== entry.version)
409
+ return `${path.replace(/^node_modules\//, "")} is ${typeof version === "string" ? version : "missing"}, pinned ${entry.version}`;
410
+ }
411
+ return null;
412
+ }
413
+ /** The production starter: the pinned helper in place, then the launch held to its contract, then started. */
414
+ export async function startContractedBrowser(launch, options) {
415
+ if (options.contract === undefined)
416
+ return { ok: false, reason: "the browser has no launch contract" };
417
+ const installed = await ensureScoutBrowserHelper(options.contract.helpers);
418
+ if (!installed.ok)
419
+ return installed;
420
+ const env = { ...options.env, PLAYWRIGHT_BROWSERS_PATH: scoutBrowserHelper(options.contract.helpers).browsers };
421
+ const problem = browserContractProblem(launch, { ...options, env: options.env });
422
+ if (problem !== null)
423
+ return { ok: false, reason: problem };
424
+ return startScoutBrowser(launch, { ...options, env });
425
+ }
package/dist/scout.d.ts CHANGED
@@ -3,7 +3,6 @@ import type { Store } from "./store.js";
3
3
  import { type ParsedDecision, type Problem } from "./decision.js";
4
4
  import { type ParsedReport, type ReportProblem } from "./scout-report.js";
5
5
  import type { Runner } from "./builder.js";
6
- import { type ToolSpec } from "./project-tools.js";
7
6
  import * as browserCheck from "./scout-browser.js";
8
7
  /** The scout's own headless browser: the pinned Playwright tool, saving its
9
8
  * screenshots straight into the run's image folder. */
@@ -16,7 +15,10 @@ export declare const SCOUT_BROWSER = "toolroll-browser";
16
15
  * clean-tree proof.
17
16
  */
18
17
  export declare const SCOUT_ALLOWED_TOOLS: readonly string[];
19
- export declare function scoutBrowserLaunch(tool: Pick<ToolSpec, "command" | "args"> | null, imageFolder: string, proxy: string): browserCheck.BrowserLaunch | null;
18
+ export declare function scoutBrowserLaunch(tool: {
19
+ command: string | null;
20
+ args: readonly string[];
21
+ } | null, imageFolder: string, proxy: string): browserCheck.BrowserLaunch | null;
20
22
  /** What the fenced scout gets: the running browser's loopback address, nothing it could launch itself. */
21
23
  export declare function scoutBrowser(url: string): Record<string, unknown>;
22
24
  /** The scout's environment while its proxy runs (review 827): web fetch and the provider's own traffic go through
@@ -62,8 +64,11 @@ export type ScoutRequest = {
62
64
  outputDir?: string;
63
65
  /** The project's own demo or dev server, the one non-public address the scout's browser may open. */
64
66
  demoUrl?: string | null;
65
- /** The browser to launch; left out, the catalog's Playwright entry. Null runs the scout without one. */
66
- browserTool?: Pick<ToolSpec, "command" | "args"> | null;
67
+ /** The browser to launch; left out, the pinned helper in Toolroll's helpers folder. Null runs the scout without one. */
68
+ browserTool?: {
69
+ command: string | null;
70
+ args: readonly string[];
71
+ } | null;
67
72
  };
68
73
  export type ReportArtifact = {
69
74
  key: string;
package/dist/scout.js CHANGED
@@ -16,7 +16,7 @@ import { skillsContext } from "./project-skills.js";
16
16
  import { createHash, randomUUID } from "node:crypto";
17
17
  import { mkdtempSync, realpathSync, rmSync, unlinkSync } from "node:fs";
18
18
  import { homedir, tmpdir } from "node:os";
19
- import { join, relative, isAbsolute } from "node:path";
19
+ import { dirname, join, relative, isAbsolute } from "node:path";
20
20
  import { run } from "./exec.js";
21
21
  import { auditOf } from "./provider.js";
22
22
  import { currentClaim, heartbeat } from "./claim.js";
@@ -32,7 +32,7 @@ import { openLiveLog } from "./live.js";
32
32
  import { proveTreeUntouched, snapshotIgnored } from "./tree-proof.js";
33
33
  import { redactSecretLines, scanForSecrets } from "./evidence.js";
34
34
  import { CLAUDE_LIMITS } from "./scope.js";
35
- import { catalogTool, projectToolsOf, toolLaunchFor } from "./project-tools.js";
35
+ import { projectToolsOf, toolLaunchFor } from "./project-tools.js";
36
36
  import { grantOf, researchToolsOf } from "./mcp-connect.js";
37
37
  import { startScoutProxy } from "./scout-net.js";
38
38
  import * as browserCheck from "./scout-browser.js";
@@ -312,7 +312,11 @@ async function scoutWith(store, request, outputDir) {
312
312
  // Every scout runs behind its public-web-only proxy (reviews 826, 827): its browser, when this install has one,
313
313
  // and its web fetch both go through it. A proxy that cannot start leaves the scout without the web, reading only
314
314
  // the checkout, never with an unfiltered one.
315
- const browserTool = request.browserTool === undefined ? catalogTool("playwright") : request.browserTool;
315
+ // The pinned browser in Toolroll's helpers folder, run under its launch contract (scout-browser.ts); none where the
316
+ // plane has no state folder.
317
+ const database = store.databaseFile();
318
+ const helpers = database === null || database === "" || database === ":memory:" ? null : join(dirname(realpathSync(database)), "helpers");
319
+ const browserTool = request.browserTool === undefined ? (helpers === null ? null : browserCheck.scoutBrowserTool(helpers)) : request.browserTool;
316
320
  const demoUrl = request.demoUrl ?? null;
317
321
  let proxy = null;
318
322
  let browserServer = null;
@@ -324,9 +328,11 @@ async function scoutWith(store, request, outputDir) {
324
328
  // The preflight (run 2356, review 828): the worker starts the browser outside the fence and opens a public page
325
329
  // through the proxy before research begins. One that can't start is said once, in the brief and the report, and
326
330
  // the scout researches without it.
331
+ // Its environment is an allowlist of the worker's, with the scout's proxy (the launch contract).
327
332
  const started = launch === null || proxy === null ? null : await browserCheck.starterFor(agent !== undefined)(launch, {
328
- env: { ...withoutEnv(process.env, [...AGENT_ENV_DENYLIST, ...PROXY_BYPASS_ENV]), ...scoutProxyEnv(proxy.url) },
333
+ env: { ...Object.fromEntries(browserCheck.SCOUT_BROWSER_ENV.filter(name => !(name in scoutProxyEnv(proxy.url)) && process.env[name] !== undefined).map(name => [name, process.env[name]])), ...scoutProxyEnv(proxy.url) },
329
334
  folder: imageFolder,
335
+ ...(helpers === null ? {} : { contract: { helpers, proxy: proxy.url } }),
330
336
  }).catch((error) => ({ ok: false, reason: error instanceof Error ? error.message : String(error) }));
331
337
  if (started !== null && started.ok)
332
338
  browserServer = started.server;
@@ -367,6 +373,10 @@ async function scoutWith(store, request, outputDir) {
367
373
  clock,
368
374
  onStreamEvent: observe,
369
375
  });
376
+ // The run says which exception its browser ran under: Chrome's own sandbox, not Toolroll's fence (recorded after the
377
+ // gateway, whose tool record would replace it).
378
+ if (browserServer !== null)
379
+ store.recordRunBrowser(request.runId, { containment: browserCheck.SCOUT_BROWSER_CONTAINMENT, tool: browserCheck.SCOUT_BROWSER_PACKAGE }, new Date());
370
380
  }
371
381
  finally {
372
382
  if (pulseTimer !== undefined)
package/dist/serve.js CHANGED
@@ -16,6 +16,7 @@ import { boundProjectSummary, browserCrewFromIndex, browserCrewOf, browserNaviga
16
16
  import { sharedActionPayload } from './chat-actions.js';
17
17
  import { prepareCodingContext } from './coding-context.js';
18
18
  import { CodingWorkspace } from './coding-workspace.js';
19
+ import { codingSessionPermissions } from './coding-provider.js';
19
20
  import { CONSOLE_FORMS, readForm } from "./contracts/console-api.js";
20
21
  import { createFlowRooms, flowFingerprint } from "./flow-live.js";
21
22
  import {} from "./google-mail.js";
@@ -195,7 +196,7 @@ export function createDecisionServer(options) {
195
196
  const database = store.handle.prepare('PRAGMA database_list').all().find(row => row['name'] === 'main')?.['file'];
196
197
  if (typeof database === 'string' && database !== '') {
197
198
  try {
198
- coding = new CodingWorkspace({ database: `${database}.coding.sqlite`, worktreeRoot: join(dirname(database), 'coding-worktrees'), admissionPaused: () => updateAdmissionPaused(store.raw()), authorize: (actor, repo) => codingActorAllowed(actor) && codingProjectAllowed(repo), context: input => prepareCodingContext(store, { ...input, root: join(dirname(database), 'coding-context') }) });
199
+ coding = new CodingWorkspace({ database: `${database}.coding.sqlite`, worktreeRoot: join(dirname(database), 'coding-worktrees'), permissions: codingSessionPermissions(store, join(dirname(database), 'coding-worktrees')), admissionPaused: () => updateAdmissionPaused(store.raw()), authorize: (actor, repo) => codingActorAllowed(actor) && codingProjectAllowed(repo), context: input => prepareCodingContext(store, { ...input, root: join(dirname(database), 'coding-context') }) });
199
200
  }
200
201
  catch (error) {
201
202
  codingProblem = error instanceof Error ? error.message : 'The coding workspace could not open.';
@@ -450,6 +451,7 @@ export function createDecisionServer(options) {
450
451
  access: projectAccessOf(repo),
451
452
  network: { setting: store.projectNetwork(repo), canChange: who.via === "cookie" && who.role === "approver" && !store.isDemo() && store.accountCanAccess(who.name, repo) },
452
453
  writes: { setting: store.projectWrites(repo), canChange: who.via === "cookie" && who.role === "approver" && !store.isDemo() && store.accountCanAccess(who.name, repo) },
454
+ sandbox: { setting: store.projectSandbox(repo), canChange: who.via === "cookie" && who.role === "approver" && !store.isDemo() && store.accountCanAccess(who.name, repo) },
453
455
  checkAccess: {
454
456
  setting: store.projectCheckAccess(repo), changed: store.projectCheckAccessChange(repo),
455
457
  canNarrow: who.via === "cookie" && who.role === "approver" && !store.isDemo() && store.accountCanAccess(who.name, repo),
@@ -2,6 +2,7 @@
2
2
  import { networkWords } from "../egress.js";
3
3
  import { writesWords } from "../writes-mode.js";
4
4
  import { checkAccessWords } from "../check-access.js";
5
+ import { sandboxTermsWords } from "../project-sandbox.js";
5
6
  import { ACP_AGENTS, isAcpProfile } from "../acp-agents.js";
6
7
  import { html, htmlString, joinHtml, postForm, replaceMarkup, reauth } from "../html.js";
7
8
  import { closedQuestionReason, closedQuestionWords } from "../decision.js";
@@ -542,7 +543,9 @@ export function profileWords(scope) {
542
543
  ? html `<p class="meta">Runs on <span class="mono">${ACP_AGENTS[profile.provider].name} (via ACP) · ${profile.model}</span> — full access inside the OS fence; the agent runs any tool it chooses and nothing asks. No turn limit: stops after ${Math.round(profile.timeoutSeconds / 60)} min, or ${Math.round(profile.idleTimeoutSeconds / 60)} min silent; repairs on ${repair}, ${Math.round(profile.repairTimeoutSeconds / 60)} min, or ${Math.round(profile.repairIdleTimeoutSeconds / 60)} min silent.</p>`
543
544
  : html `<p class="meta">Runs on <span class="mono">${profile.provider} · ${profile.model}</span> — ${profile.sandboxMode === "danger-full-access" ? "FULL permissions via --dangerously-bypass-approvals-and-sandbox; nothing asks" : "workspace-write sandbox"}, no turn limit (${Math.round(profile.timeoutSeconds / 60)} min ${profile.timeoutKind === "idle" ? "without progress" : "per attempt"}); repairs on ${repair}, ${Math.round(profile.repairTimeoutSeconds / 60)} min</p>`;
544
545
  // What the build may reach is part of what is approved (egress.ts): said on every approval form.
545
- return html `${base}<p class="meta" data-scope-network>${networkWords(profile.network ?? null)}</p><p class="meta" data-scope-writes>Writes: ${writesWords(profile.writes)}.</p>${profile.checkAccess === undefined ? "" : html `<p class="meta" data-scope-checks>Checks: ${checkAccessWords(profile.checkAccess)}.</p>`}`;
546
+ // Its sandbox terms (project-sandbox.ts): what it reads always, and any widening, part of what the yes agrees to.
547
+ const terms = sandboxTermsWords(profile);
548
+ return html `${base}<p class="meta" data-scope-network>${networkWords(profile.network ?? null)}</p><p class="meta" data-scope-writes>Writes: ${writesWords(profile.writes)}.</p>${profile.checkAccess === undefined ? "" : html `<p class="meta" data-scope-checks>Checks: ${checkAccessWords(profile.checkAccess)}.</p>`}<p class="meta" data-scope-reads>Reads: ${terms.reads}.</p>${terms.mach === null ? "" : html `<p class="meta" data-scope-mach>${terms.mach.charAt(0).toUpperCase()}${terms.mach.slice(1)}.</p>`}`;
546
549
  }
547
550
  export const ROLE_NOUN = { plan: "Planner", build: "Builder", repair: "Repair", review: "Reviewer" };
548
551
  export function agentsStandingWords(view) {
@@ -285,6 +285,7 @@ post("settings", "settings.project-live-view", "/settings/project/live-view", {
285
285
  post("settings", "settings.project-network", "/settings/project/network", { scope: "step-up", callers: ["cookie"] });
286
286
  post("settings", "settings.project-writes", "/settings/project/writes", { scope: "step-up", callers: ["cookie"] });
287
287
  post("settings", "settings.project-check-access", "/settings/project/check-access", { scope: "step-up", callers: ["cookie"] });
288
+ post("settings", "settings.project-sandbox", "/settings/project/sandbox", { scope: "step-up", callers: ["cookie"] });
288
289
  post("settings", "settings.project-delete", "/settings/project/delete", { scope: "step-up", ...operator("An instance operator deletes projects.", "/settings/project") });
289
290
  // Lead sweeps (docs/plans/lead-fan-out.md): the table, and cancelling what isn't ready. The handler shows a sweep only in
290
291
  // a project the reader can see, and acts only for an approver.
@@ -82,6 +82,7 @@ import { parseProjectConcurrency, saveProjectConcurrency } from "../project-conc
82
82
  import { MAX_NETWORK_HOSTS, normalizeHost } from "../egress.js";
83
83
  import { isWritesMode } from "../writes-mode.js";
84
84
  import { isCheckAccess, signInsUnavailableWords, widerThan } from "../check-access.js";
85
+ import { parseMachLookup, parseReadDeny } from "../project-sandbox.js";
85
86
  import { projectDeleteConfirmHtml, projectSettingsHtml } from "../project-delete-ui.js";
86
87
  import { deleteProject, holdingsWords } from "../project-delete.js";
87
88
  import { applySavedKnowledge, changeKnowledge, knowledgeVersion, knowledgeView } from "../project-knowledge.js";
@@ -2487,6 +2488,7 @@ ${modeWords(terms)
2487
2488
  "settings.project-network": projectNetwork,
2488
2489
  "settings.project-writes": projectWrites,
2489
2490
  "settings.project-check-access": projectCheckAccess,
2491
+ "settings.project-sandbox": projectSandbox,
2490
2492
  "settings.project-delete": projectDelete,
2491
2493
  "settings.policy-send": policySave,
2492
2494
  "settings.approval-send": approvalSave,
@@ -2624,5 +2626,30 @@ ${modeWords(terms)
2624
2626
  return back("problem", "Check access changed meanwhile, and this would now widen it. Nothing changed.");
2625
2627
  return back("said", changed.before === changed.after ? "Nothing changed." : "Saved. Tasks scoped from now on use it; tasks already approved keep what they were approved with.");
2626
2628
  }
2629
+ // Settings → Project → Home folder (project-sandbox.ts, sandbox parity C9): what the project's builds and checks may not
2630
+ // read, and the macOS services they may also use. An approver for the project changes it with
2631
+ // their password typed now; the ledger keeps before → after. New scopes seal it; approved ones keep theirs.
2632
+ async function projectSandbox(ctx) {
2633
+ const { who, response, now, posted } = ctx;
2634
+ const body = readForm(posted, CONSOLE_FORMS.projectSandbox);
2635
+ const repo = body.get("repo") ?? "";
2636
+ if (!consoleProjects().includes(repo) || !visible(repo))
2637
+ return refuse(response, who, 403, "That project is outside your access.", "/settings/project");
2638
+ if (who.via !== "cookie" || who.role !== "approver" || store.isDemo() || !store.accountCanAccess(who.name, repo))
2639
+ return refuse(response, who, 403, "An approver for this project changes its sandbox.", "/settings/project");
2640
+ const back = sayTo(response, `/settings/project?repo=${encodeURIComponent(repo)}`, "#sandbox");
2641
+ const lines = (name) => (body.get(name) ?? "").split(/\r?\n/);
2642
+ const parsed = { readDeny: parseReadDeny(lines("read-deny")), machLookup: parseMachLookup(lines("mach")) };
2643
+ for (const one of Object.values(parsed))
2644
+ if (!one.ok)
2645
+ return back("problem", `${one.problem.charAt(0).toUpperCase()}${one.problem.slice(1)}. Nothing changed.`);
2646
+ if (!authenticateCredential(who, body.get("password") ?? "").ok)
2647
+ return back("problem", "Enter your Toolroll password to change the project's sandbox.");
2648
+ const wanted = { readDeny: parsed.readDeny.ok ? parsed.readDeny.list : [], machLookup: parsed.machLookup.ok ? parsed.machLookup.list : [] };
2649
+ const changed = store.setProjectSandbox(repo, wanted, who.name, now);
2650
+ if (changed === null)
2651
+ return back("problem", "An approver for this project changes its sandbox. Nothing changed.");
2652
+ return back("said", JSON.stringify(changed.before) === JSON.stringify(changed.after) ? "Nothing changed." : "Saved. Tasks scoped from now on use it; tasks already approved keep what they were approved with.");
2653
+ }
2627
2654
  return { registrations, modelsView };
2628
2655
  }
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 = 132;
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