toolroll 0.9.68 → 0.9.69
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 +34 -0
- package/dist/agent-fence.d.ts +58 -0
- package/dist/agent-fence.js +211 -19
- package/dist/agentconfig.d.ts +1 -1
- package/dist/agentconfig.js +6 -2
- package/dist/batch-checks.js +14 -26
- package/dist/builder.d.ts +9 -0
- package/dist/builder.js +27 -1
- package/dist/check-access.d.ts +54 -0
- package/dist/check-access.js +80 -0
- package/dist/contracts/console-api.d.ts +1 -0
- package/dist/contracts/console-api.js +1 -0
- package/dist/invoke.d.ts +39 -2
- package/dist/invoke.js +176 -21
- package/dist/operate.d.ts +4 -0
- package/dist/operate.js +73 -0
- package/dist/project-delete-ui.d.ts +16 -0
- package/dist/project-delete-ui.js +60 -1
- package/dist/result-follow-ups.js +10 -15
- package/dist/scope.d.ts +9 -0
- package/dist/scope.js +12 -5
- package/dist/serve.js +9 -0
- package/dist/server/http.d.ts +2 -0
- package/dist/server/render-results.d.ts +3 -0
- package/dist/server/render-results.js +1 -0
- package/dist/server/render-tasks.js +2 -1
- package/dist/server/route-table.js +1 -0
- package/dist/server/settings.js +37 -0
- package/dist/server/tasks.js +2 -0
- package/dist/store.d.ts +29 -2
- package/dist/store.js +86 -3
- package/dist/surface.js +2 -1
- package/package.json +1 -1
package/dist/operate.js
CHANGED
|
@@ -112,6 +112,8 @@ import { claimActor, currentActor, withActor } from "./actor.js";
|
|
|
112
112
|
import { parseLeadToken } from "./api-tokens.js";
|
|
113
113
|
import { MAX_NETWORK_HOSTS, normalizeHost } from "./egress.js";
|
|
114
114
|
import { isWritesMode, writesWords } from "./writes-mode.js";
|
|
115
|
+
import { linuxFenceAvailable, macosFenceAvailable } from "./agent-fence.js";
|
|
116
|
+
import { CHECK_ACCESS_WARNINGS, checkAccessRunsAs, checkAccessWords, isCheckAccess, signInHostsMissing, signInsUnavailableWords, widerThan } from "./check-access.js";
|
|
115
117
|
import { CLI_PASSWORD_SOURCE, withPasswordSource } from "./sign-in-guard.js";
|
|
116
118
|
import { admissionRecorded, admitProject, commandLineActor } from "./project-admission.js";
|
|
117
119
|
import { leadClaim, leadSay, noteLeadWork } from "./lead-voice.js";
|
|
@@ -265,6 +267,9 @@ export const OPERATE_HELP = `toolroll — operating the queue
|
|
|
265
267
|
where the project's builds may write: only their checkout, caches and temp
|
|
266
268
|
(workspace, the default), or anywhere outside Toolroll's fenced folders;
|
|
267
269
|
an approver changes it (new scopes seal it; approved ones keep theirs)
|
|
270
|
+
toolroll project check-access --repo <p> [sandboxed|sign-ins|unsandboxed]
|
|
271
|
+
how far the project's checks reach: sandboxed (the default), with model
|
|
272
|
+
and GitHub sign-ins, or not sandboxed; an instance operator widens it
|
|
268
273
|
toolroll config unconfined-agents [allow|refuse]
|
|
269
274
|
whether builds run where this machine can't confine them (instance operator)
|
|
270
275
|
toolroll project concurrency [<n>] --repo <p>
|
|
@@ -916,6 +921,8 @@ async function dispatchOn(command, positional, flags, repoList, base, options, e
|
|
|
916
921
|
...(options.flowTriggerIo === undefined ? {} : { flowTriggerIo: options.flowTriggerIo }),
|
|
917
922
|
...(options.flowStepIo === undefined ? {} : { flowStepIo: options.flowStepIo }),
|
|
918
923
|
...(options.flowEveryMs === undefined ? {} : { flowEveryMs: options.flowEveryMs }),
|
|
924
|
+
...(options.platform === undefined ? {} : { platform: options.platform }),
|
|
925
|
+
...(options.fenceAvailable === undefined ? {} : { fenceAvailable: options.fenceAvailable }),
|
|
919
926
|
...(options.flowFetch === undefined ? {} : { flowFetch: options.flowFetch }),
|
|
920
927
|
...(options.integrationIo === undefined ? {} : { integrationIo: options.integrationIo }),
|
|
921
928
|
...(options.shouldStop === undefined ? {} : { shouldStop: options.shouldStop }),
|
|
@@ -1171,6 +1178,8 @@ async function dispatch(command, positional, flags, context) {
|
|
|
1171
1178
|
return projectNetworkCommand(positional, flags, context);
|
|
1172
1179
|
if (positional[0] === "writes")
|
|
1173
1180
|
return projectWritesCommand(positional, flags, context);
|
|
1181
|
+
if (positional[0] === "check-access")
|
|
1182
|
+
return projectCheckAccessCommand(positional, flags, context);
|
|
1174
1183
|
if (positional[0] === "checks")
|
|
1175
1184
|
return projectChecksCommand(positional, flags, context);
|
|
1176
1185
|
if (positional[0] === "demo")
|
|
@@ -11567,3 +11576,67 @@ export function ownValueFlags(command, argv) {
|
|
|
11567
11576
|
return PROJECT_NETWORK_VALUE_FLAGS;
|
|
11568
11577
|
return undefined;
|
|
11569
11578
|
}
|
|
11579
|
+
// Last in the file, so the password tripwire's function positions (password-freshness.test.ts) stay as reviewed.
|
|
11580
|
+
/**
|
|
11581
|
+
* `project check-access --repo <p> [sandboxed|sign-ins|unsandboxed]` (check-access.ts): how far the project's setup
|
|
11582
|
+
* and check commands reach beyond the check fence. Widening is an instance operator's act with a password typed now
|
|
11583
|
+
* (never a remembered sign-in or a single sign-on recheck); any approver for the project may narrow it back to
|
|
11584
|
+
* sandboxed. New scopes seal it; approved ones keep theirs. The ledger keeps before → after.
|
|
11585
|
+
*/
|
|
11586
|
+
async function projectCheckAccessCommand(positional, flags, context) {
|
|
11587
|
+
const command = "project check-access";
|
|
11588
|
+
const allowed = new Set(["repo", "as", "token", "token-file", "token-env", "db", "json"]);
|
|
11589
|
+
for (const name of flags.keys())
|
|
11590
|
+
if (!allowed.has(name))
|
|
11591
|
+
return fail(context.write, context.json, command, "usage", `--${name} is not a project check-access option.`, EXIT.usage);
|
|
11592
|
+
const repoFlag = text(flags, "repo");
|
|
11593
|
+
const wanted = positional[1];
|
|
11594
|
+
if (positional.length > 2 || repoFlag === undefined || (wanted !== undefined && !isCheckAccess(wanted)))
|
|
11595
|
+
return fail(context.write, context.json, command, "usage", "Use project check-access --repo <project path> [sandboxed | sign-ins | unsandboxed].", EXIT.usage);
|
|
11596
|
+
const repo = await knownProject(context, repoFlag, { listed: true, canonical: true });
|
|
11597
|
+
if (repo === undefined)
|
|
11598
|
+
return fail(context.write, context.json, command, "not-found", "That isn't a project Toolroll knows.", EXIT.refused);
|
|
11599
|
+
const current = context.store.projectCheckAccess(repo);
|
|
11600
|
+
const platform = context.platform ?? process.platform;
|
|
11601
|
+
// How it actually runs here: with no OS fence and unconfined running allowed, without a sandbox whatever is saved; on
|
|
11602
|
+
// a saved sign-ins level (off for now) runs sandboxed. Said, never hidden.
|
|
11603
|
+
const fenced = context.fenceAvailable?.() ?? (macosFenceAvailable() || linuxFenceAvailable());
|
|
11604
|
+
const runsAs = (one) => checkAccessRunsAs(one, { platform, fenced, unconfinedAllowed: context.store.unfencedAgentsAllowed() });
|
|
11605
|
+
const here = runsAs(current);
|
|
11606
|
+
const words = (one) => {
|
|
11607
|
+
if (runsAs(one) === "unsandboxed" && one !== "unsandboxed")
|
|
11608
|
+
return [`${repo}: saved as ${checkAccessWords(one)}, but this machine can't sandbox checks, so they run without a sandbox.`];
|
|
11609
|
+
if (runsAs(one) !== one)
|
|
11610
|
+
return [`${repo}: saved with model and GitHub sign-ins, but its checks run sandboxed: sign-ins can't be shared with checks safely yet.`];
|
|
11611
|
+
const missing = one === "sign-ins" ? signInHostsMissing(context.store.projectNetwork(repo)) : [];
|
|
11612
|
+
return [`${repo}: checks run ${checkAccessWords(one)}.`, ...(one === "sandboxed" ? [] : [CHECK_ACCESS_WARNINGS[one]]),
|
|
11613
|
+
// Never added for it: the operator widens the network separately, with their own password.
|
|
11614
|
+
...(missing.length === 0 ? [] : [`Model sign-ins need these hosts in this project's network allowlist: ${missing.join(", ")}. Add them with: toolroll project network --repo ${repo} --allow ${missing.join(",")}`])];
|
|
11615
|
+
};
|
|
11616
|
+
if (wanted === undefined)
|
|
11617
|
+
return succeed(context.write, context.json, command, { repo, checkAccess: current, ...(here === current ? {} : { runsAs: here }), ...(here === "sign-ins" ? { missingHosts: signInHostsMissing(context.store.projectNetwork(repo)) } : {}) }, () => words(current));
|
|
11618
|
+
// Not available yet (check-access.ts signInsAvailable): refused before any password is asked for, nothing written.
|
|
11619
|
+
const unavailable = wanted === "sign-ins" ? signInsUnavailableWords(platform) : null;
|
|
11620
|
+
if (unavailable !== null)
|
|
11621
|
+
return fail(context.write, context.json, command, "refused", unavailable, EXIT.refused);
|
|
11622
|
+
const widening = widerThan(wanted, current);
|
|
11623
|
+
const acting = await askCredentials(flags, context);
|
|
11624
|
+
const proved = acting === null ? null : authenticateApprover(context.store, acting.name, acting.token, repo);
|
|
11625
|
+
if (widening) {
|
|
11626
|
+
// Wider reach exposes the operator's own sign-ins or machine: an instance operator, with a password typed for this.
|
|
11627
|
+
if (acting === null || proved === null || !proved.ok || proved.method !== "password-now" || !context.store.isInstanceOperator(acting.name)) {
|
|
11628
|
+
return fail(context.write, context.json, command, "refused", "An instance operator widens what a project's checks reach, with their password typed now: pass --as and --token.", EXIT.refused);
|
|
11629
|
+
}
|
|
11630
|
+
}
|
|
11631
|
+
else if (acting === null || proved === null || !proved.ok || !credentialProof(proved.method) || !context.store.accountCanAccess(acting.name, repo)) {
|
|
11632
|
+
return fail(context.write, context.json, command, "refused", "An approver for this project narrows what its checks reach, with their password: pass --as and --token.", EXIT.refused);
|
|
11633
|
+
}
|
|
11634
|
+
// The level is compared again inside the write: a change made while the password was asked for can't widen it here.
|
|
11635
|
+
const changed = context.store.setProjectCheckAccess(repo, wanted, acting.name, context.clock(), { may: widening ? "widen" : "narrow" });
|
|
11636
|
+
if (changed === null)
|
|
11637
|
+
return fail(context.write, context.json, command, "refused", "The project's check access changed meanwhile, and this would now widen it: an instance operator widens it, with their password typed now.", EXIT.refused);
|
|
11638
|
+
return succeed(context.write, context.json, command, { repo, before: changed.before, checkAccess: changed.after }, () => [
|
|
11639
|
+
...words(changed.after),
|
|
11640
|
+
"Scopes written from now on use this; scopes already approved keep what they were approved with.",
|
|
11641
|
+
]);
|
|
11642
|
+
}
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import type { ProjectNetwork } from "./egress.js";
|
|
8
8
|
import type { WritesMode } from "./writes-mode.js";
|
|
9
|
+
import { type CheckAccess } from "./check-access.js";
|
|
9
10
|
import { type Html } from "./html.js";
|
|
10
11
|
import { type ProjectHoldings } from "./project-delete.js";
|
|
11
12
|
export declare const PROJECT_DELETE_CSS: string;
|
|
@@ -50,6 +51,21 @@ export type ProjectSettingsView = {
|
|
|
50
51
|
setting: WritesMode;
|
|
51
52
|
canChange: boolean;
|
|
52
53
|
};
|
|
54
|
+
/** How far its checks reach (check-access.ts): the level, who changed it last, and whether this person may narrow it
|
|
55
|
+
* (an approver for the project) or also widen it (an instance operator). */
|
|
56
|
+
checkAccess?: {
|
|
57
|
+
setting: CheckAccess;
|
|
58
|
+
changed: {
|
|
59
|
+
by: string;
|
|
60
|
+
at: string;
|
|
61
|
+
} | null;
|
|
62
|
+
canNarrow: boolean;
|
|
63
|
+
canWiden: boolean;
|
|
64
|
+
/** This machine can't sandbox checks at all, and an operator allowed them to run anyway: every level runs unsandboxed. */
|
|
65
|
+
unconfinedHere?: boolean;
|
|
66
|
+
/** Why sign-ins can't be chosen (off for now), or null: they can. */
|
|
67
|
+
signInsUnavailable?: string | null;
|
|
68
|
+
};
|
|
53
69
|
};
|
|
54
70
|
/** The project's page: its path, what Toolroll holds for it, and (for an instance operator) Delete project. */
|
|
55
71
|
export declare function projectSettingsHtml(view: ProjectSettingsView, notice: {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { CHECK_ACCESS_WARNINGS, signInHostsMissing } from "./check-access.js";
|
|
1
2
|
import { html, postForm, reauth } from "./html.js";
|
|
2
3
|
import { holdingsWords } from "./project-delete.js";
|
|
3
4
|
export const PROJECT_DELETE_CSS = `.project-settings,.project-delete{max-width:720px;min-width:0}.project-settings .path,.project-delete .path{font-family:var(--font-mono);font-size:.8125rem;overflow-wrap:anywhere;word-break:break-all}` +
|
|
@@ -13,6 +14,21 @@ export const PROJECT_DELETE_CSS = `.project-settings,.project-delete{max-width:7
|
|
|
13
14
|
`.project-settings .build-access .tool-name{font:var(--so-typography-mono)}.project-settings .build-access details{margin:4px 0 0}.project-settings .build-access summary{min-height:44px;display:inline-flex;align-items:center;cursor:pointer;color:var(--so-muted);font:var(--so-typography-label)}` +
|
|
14
15
|
`.project-settings .build-access ul{margin:0 0 4px;padding-left:20px;font:var(--so-typography-body);color:var(--so-ink)}.project-settings .build-access li{margin:2px 0}.project-settings .build-access li .meta{color:var(--so-muted)}` +
|
|
15
16
|
`.project-settings .build-access a.manage-tools{display:inline-flex;align-items:center;justify-content:center;min-height:44px;margin-top:4px;padding:0 14px;white-space:nowrap;background:var(--so-paper);color:var(--so-ink);border:1px solid var(--so-input-line);border-radius:var(--so-radius-md);font:var(--so-typography-label);text-decoration:none}` +
|
|
17
|
+
// A radio or checkbox choice sits beside its words, at its natural size, not as a full-width grid row.
|
|
18
|
+
`.project-settings .build-access label.check{display:flex;align-items:center;gap:8px;min-height:44px;font:var(--so-typography-body);font-weight:500}` +
|
|
19
|
+
`.project-settings .build-access label.check input{width:auto;min-height:0;margin:0;flex:none}.project-settings .build-access form .meta{margin:-8px 0 0 26px}` +
|
|
20
|
+
// Check access: a warning shows only under the option chosen; the folds draw the console's own chevron (as a diff's).
|
|
21
|
+
`.project-settings .check-access-setting .option .warning{display:none;color:var(--so-ink)}.project-settings .check-access-setting .option:has(input:checked) .warning{display:block}` +
|
|
22
|
+
`.project-settings .check-access-setting form{margin-top:0;gap:10px}.project-settings .check-access-setting .option{display:grid;gap:2px}` +
|
|
23
|
+
`.project-settings .check-access-setting .option .meta{margin:0 0 0 26px}.project-settings .check-access-setting label.check{min-height:36px}` +
|
|
24
|
+
`.project-settings .check-access-setting details > summary{list-style:none;display:flex;align-items:center;gap:10px;min-height:40px;padding:0}` +
|
|
25
|
+
`.project-settings .check-access-setting details > summary::-webkit-details-marker{display:none}.project-settings .check-access-setting details > summary::marker{content:""}` +
|
|
26
|
+
`.project-settings .check-access-setting details > summary::before{content:"";flex:none;width:.35rem;height:.35rem;border-right:1.5px solid var(--so-muted);border-bottom:1.5px solid var(--so-muted);transform:rotate(-45deg);transition:transform .15s ease}` +
|
|
27
|
+
`.project-settings .check-access-setting details[open] > summary::before{transform:rotate(45deg) translateY(-.1rem)}` +
|
|
28
|
+
// Show hosts: a quiet fold in the line, not a card.
|
|
29
|
+
`.project-settings .check-access-setting details.hosts{border:0;background:none;padding:0;margin:0}.project-settings .check-access-setting details.hosts > summary{min-height:32px;font-size:.8125rem}` +
|
|
30
|
+
`.project-settings .check-access-setting details.hosts .tool-name{margin:0 0 4px;font-size:.75rem}` +
|
|
31
|
+
`.project-settings .check-access-setting button.primary{background:var(--so-signal);color:var(--so-on-signal);border-color:var(--so-signal)}` +
|
|
16
32
|
`.project-settings .build-access a.manage-tools:hover{background:var(--so-raised)}.project-settings .build-access a.manage-tools:focus-visible,.project-settings .build-access summary:focus-visible{outline:2px solid var(--so-ink);outline-offset:2px}`;
|
|
17
33
|
/** Where builds may write, said plainly; an approver changes it with their password. */
|
|
18
34
|
function writesHtml(view) {
|
|
@@ -33,6 +49,49 @@ function writesHtml(view) {
|
|
|
33
49
|
<button type="submit">Save</button>`, { hidden: { repo: view.repo } });
|
|
34
50
|
return html `<section class="build-access writes-setting" id="writes" aria-labelledby="writes-title"><h2 id="writes-title">Where builds write</h2>${said}<details><summary>Change</summary>${form}</details></section>`;
|
|
35
51
|
}
|
|
52
|
+
/** Check access (check-access.ts): how far the project's checks reach, said once. Collapsed: the level, what it means
|
|
53
|
+
* when it isn't the default, and who changed it. Inside Change: a line per level, and each wider level's warning under it
|
|
54
|
+
* only while it is chosen. An instance operator widens it with a password typed now; an approver narrows it. */
|
|
55
|
+
function checkAccessHtml(view) {
|
|
56
|
+
const access = view.checkAccess;
|
|
57
|
+
if (access === undefined)
|
|
58
|
+
return "";
|
|
59
|
+
const saved = access.setting;
|
|
60
|
+
// Sign-ins aren't available (off for now): a saved sign-ins level runs sandboxed, and the page says so.
|
|
61
|
+
const unavailable = access.signInsUnavailable ?? null;
|
|
62
|
+
const setting = unavailable !== null && saved === "sign-ins" ? "sandboxed" : saved;
|
|
63
|
+
const network = view.network?.setting ?? null;
|
|
64
|
+
const names = { sandboxed: "Sandboxed", "sign-ins": "Sandboxed, with model and GitHub sign-ins", unsandboxed: "Not sandboxed" };
|
|
65
|
+
const meaning = { sandboxed: null, "sign-ins": "Checks can read your Claude, Codex and GitHub sign-ins.", unsandboxed: "Checks run with your full access to this machine and to Toolroll." };
|
|
66
|
+
// The sign-ins never widen the network: how many hosts they'd still need, and where to add them (a change of its own).
|
|
67
|
+
const missing = setting === "sign-ins" && network !== null ? signInHostsMissing(network) : [];
|
|
68
|
+
const hosts = missing.length === 0 ? "" : html `<p class="meta" data-sign-in-hosts>${missing.length} sign-in ${missing.length === 1 ? "host isn't" : "hosts aren't"} allowed yet · <a href="#network">Add them</a></p><details class="hosts"><summary>Show hosts</summary><p class="tool-name">${missing.join(", ")}</p></details>`;
|
|
69
|
+
const changed = access.changed === null ? "" : html `<p class="meta">Changed by ${access.changed.by}, ${new Date(access.changed.at).toLocaleDateString("en-GB", { day: "numeric", month: "short", timeZone: "UTC" })}</p>`;
|
|
70
|
+
// Said before anything else: on a machine with no OS sandbox, every level runs without one.
|
|
71
|
+
const unconfined = access.unconfinedHere === true ? html `<p class="meta warning-line" data-unconfined-here>This machine can't sandbox checks, so they run without a sandbox whatever is chosen here.</p>` : "";
|
|
72
|
+
const runsSandboxed = setting !== saved && access.unconfinedHere !== true ? html `<p class="meta warning-line" data-sign-ins-unavailable>Saved with model and GitHub sign-ins, but checks run sandboxed: sign-ins can't be shared with checks safely yet.</p>` : "";
|
|
73
|
+
// No OS sandbox here, and unconfined running allowed: checks run without a sandbox whatever is saved, and that is
|
|
74
|
+
// what the page says first (never "sandboxed"); the saved level is named under it.
|
|
75
|
+
const head = access.unconfinedHere === true
|
|
76
|
+
? html `<h2 id="check-access-title">Check access</h2><p><strong>${names.unsandboxed}</strong></p>${saved === "unsandboxed" ? "" : html `<p class="meta" data-saved-level>Saved as ${names[saved]}.</p>`}${unconfined}${changed}`
|
|
77
|
+
: html `<h2 id="check-access-title">Check access</h2><p><strong>${names[setting]}</strong></p>${meaning[setting] === null ? "" : html `<p class="meta">${meaning[setting]}</p>`}${runsSandboxed}${hosts}${changed}`;
|
|
78
|
+
if (!access.canNarrow && !access.canWiden)
|
|
79
|
+
return html `<section class="build-access check-access-setting" id="check-access" aria-labelledby="check-access-title">${head}</section>`;
|
|
80
|
+
const option = (value, detail, warning) => {
|
|
81
|
+
const allowed = (value === setting || access.canWiden || value === "sandboxed") && !(value === "sign-ins" && unavailable !== null);
|
|
82
|
+
return html `<div class="option"><label class="check"><input type="radio" name="level" value="${value}"${setting === value ? html ` checked` : ""}${allowed ? "" : html ` disabled`}> ${names[value]}</label><p class="meta">${detail}</p>${warning === null ? "" : html `<p class="meta warning" data-check-access-warning="${value}">${warning}</p>`}</div>`;
|
|
83
|
+
};
|
|
84
|
+
const open = network?.mode === "open";
|
|
85
|
+
const form = postForm("/settings/project/check-access", html `
|
|
86
|
+
${option("sandboxed", "Can't read the files your sign-ins and credentials are kept in, Toolroll's folders or other projects.", null)}
|
|
87
|
+
${unavailable !== null ? option("sign-ins", unavailable, null) : option("sign-ins", `Also reads the files your Claude, Codex and GitHub sign-ins are kept in, on this project's network (${open ? "any host" : network === null || network.hosts.length === 0 ? "registries only" : `registries and ${network.hosts.length} ${network.hosts.length === 1 ? "host" : "hosts"}`}).`, CHECK_ACCESS_WARNINGS["sign-ins"])}
|
|
88
|
+
${option("unsandboxed", "For projects whose checks create sandboxes themselves, such as Toolroll's own.", CHECK_ACCESS_WARNINGS.unsandboxed)}
|
|
89
|
+
${open && unavailable === null ? html `<label class="check"><input type="checkbox" name="confirm-open" value="yes"> The network is open: with sign-ins, checks could send them to any host</label>` : ""}
|
|
90
|
+
${reauth(html `<label>Your Toolroll password<input type="password" name="password" autocomplete="current-password" required></label>`)}
|
|
91
|
+
<button type="submit" class="primary">Save</button>
|
|
92
|
+
<p class="meta">${access.canWiden ? "" : "Only an instance operator can widen this. "}Flow script steps always run sandboxed. Tasks already approved keep their level.</p>`, { hidden: { repo: view.repo } });
|
|
93
|
+
return html `<section class="build-access check-access-setting" id="check-access" aria-labelledby="check-access-title">${head}<details class="change"><summary>Change</summary>${form}</details></section>`;
|
|
94
|
+
}
|
|
36
95
|
/** Network: what the project's builds may reach, said plainly; an approver changes it with their password. */
|
|
37
96
|
function networkHtml(view) {
|
|
38
97
|
const network = view.network;
|
|
@@ -111,7 +170,7 @@ export function projectSettingsHtml(view, notice) {
|
|
|
111
170
|
const remote = html `<section class="build-access recorded-remote" aria-labelledby="recorded-remote-title"><h2 id="recorded-remote-title">Recorded remote</h2>${view.remoteUrl
|
|
112
171
|
? html `<p class="path">${view.remoteUrl}</p>`
|
|
113
172
|
: html `<p>No remote recorded. Landed work waits for your review.</p>`}<details><summary>Change remote</summary><p>An owner or instance operator can run:</p><p class="path">toolroll repos add <project> --remote <url></p></details></section>`;
|
|
114
|
-
const head = html `${note(notice)}<p class="path">${view.repo}</p>${held}${remote}${buildsHtml(view)}${accessHtml(view)}${networkHtml(view)}${writesHtml(view)}${liveViewHtml(view)}`;
|
|
173
|
+
const head = html `${note(notice)}<p class="path">${view.repo}</p>${held}${remote}${buildsHtml(view)}${accessHtml(view)}${networkHtml(view)}${writesHtml(view)}${checkAccessHtml(view)}${liveViewHtml(view)}`;
|
|
115
174
|
if (!view.canDelete)
|
|
116
175
|
return html `<section class="project-settings">${head}<p class="meta">An instance operator can delete a project.</p></section>`;
|
|
117
176
|
const body = view.running.length > 0
|
|
@@ -1,15 +1,4 @@
|
|
|
1
|
-
|
|
2
|
-
*
|
|
3
|
-
* Run checks runs the project's quick or full check on the result's exact
|
|
4
|
-
* commit, in a fresh checkout, after the build has finished. A person (or a
|
|
5
|
-
* pull request opening) asks; the ask is a ledger entry; a worker runs it
|
|
6
|
-
* once and seals its log and a receipt beside the result. A pass upgrades
|
|
7
|
-
* what the result's Checks row says; a failure stays visible. Nothing here
|
|
8
|
-
* rewrites the build's own check or reruns crew work.
|
|
9
|
-
*
|
|
10
|
-
* Add tests files a small task, unapproved like any other filing, to write
|
|
11
|
-
* tests for that change. */
|
|
12
|
-
import { fencedCheckoutCommands } from "./invoke.js";
|
|
1
|
+
import { checkoutRunner, ranAtLine, widestRan } from "./invoke.js";
|
|
13
2
|
import { mkdtempSync, rmSync } from "node:fs";
|
|
14
3
|
import { tmpdir } from "node:os";
|
|
15
4
|
import { join } from "node:path";
|
|
@@ -188,6 +177,8 @@ export async function runFollowUpCheck(store, root, request, deps = {}) {
|
|
|
188
177
|
const folder = mkdtempSync(join(deps.scratch ?? tmpdir(), "toolroll-checks-"));
|
|
189
178
|
const checkout = join(folder, "checkout");
|
|
190
179
|
const log = [];
|
|
180
|
+
// How each command actually ran (check-access.ts): the widest is on the receipt and in the log.
|
|
181
|
+
const ranAt = [];
|
|
191
182
|
const bound = (text) => { const hits = scanForSecrets(text); return boundStreamHeadTail(hits.length > 0 ? redactSecretLines(text, hits) : text, 24 * 1024); };
|
|
192
183
|
const note = (label, command, result) => log.push(`=== ${label} ===\n$ ${command}\n(exit ${result.code}${result.notFound ? ", could not start" : ""}${result.timedOut ? ", timed out" : ""})\n\n--- stdout ---\n${bound(result.stdout)}\n\n--- stderr ---\n${bound(result.stderr)}`);
|
|
193
184
|
let result;
|
|
@@ -199,10 +190,11 @@ export async function runFollowUpCheck(store, root, request, deps = {}) {
|
|
|
199
190
|
}
|
|
200
191
|
else {
|
|
201
192
|
const setup = store.liveWorktreeSetup(target.repo);
|
|
193
|
+
// Worked out again right before each command (check-access.ts): a narrowing meanwhile applies to the next one.
|
|
202
194
|
let ready = true;
|
|
203
195
|
if (setup !== null) {
|
|
204
196
|
const shell = approvedCommandShell(setup.command);
|
|
205
|
-
const prepared = await runWithIsolatedDatabase(
|
|
197
|
+
const prepared = await runWithIsolatedDatabase(checkoutRunner(store, target.repo, checkout, runner, [runId], level => ranAt.push(level)), shell.file, shell.args, { cwd: checkout, timeoutMs: setup.timeoutMs, processGroup: true, envAllowlist: SETUP_ENV_ALLOWLIST, omitEnv: SETUP_ENV_DENYLIST });
|
|
206
198
|
note("Approved project setup", setup.command, prepared);
|
|
207
199
|
if (prepared.code !== 0 || prepared.timedOut || prepared.notFound) {
|
|
208
200
|
ready = false;
|
|
@@ -217,7 +209,7 @@ export async function runFollowUpCheck(store, root, request, deps = {}) {
|
|
|
217
209
|
}
|
|
218
210
|
else {
|
|
219
211
|
const shell = approvedCommandShell(live.command);
|
|
220
|
-
const ran = await runWithIsolatedDatabase(
|
|
212
|
+
const ran = await runWithIsolatedDatabase(checkoutRunner(store, target.repo, checkout, runner, [runId], level => ranAt.push(level)), shell.file, shell.args, { cwd: checkout, timeoutMs: live.timeoutMs, processGroup: true, envAllowlist: SETUP_ENV_ALLOWLIST, omitEnv: SETUP_ENV_DENYLIST });
|
|
221
213
|
note(`${CHECK_LEVEL_WORDS[target.level]} check`, live.command, ran);
|
|
222
214
|
result = ran.timedOut ? { ran: false, failure: "The check ran out of time." } : ran.notFound ? { ran: false, failure: "The check couldn't start." } : { ran: true, exitCode: ran.code };
|
|
223
215
|
}
|
|
@@ -231,10 +223,13 @@ export async function runFollowUpCheck(store, root, request, deps = {}) {
|
|
|
231
223
|
rmSync(folder, { recursive: true, force: true });
|
|
232
224
|
}
|
|
233
225
|
const at = now();
|
|
226
|
+
const ranLine = ranAtLine(widestRan(ranAt));
|
|
227
|
+
if (ranLine !== null)
|
|
228
|
+
log.push(ranLine);
|
|
234
229
|
const text = log.length === 0 ? "No command started." : log.join("\n\n");
|
|
235
230
|
const logId = storeEvidence(store, root, runId, "structured-output", `follow-up-check-${request}.log`, Buffer.from(text, "utf8"), FOLLOW_UP_LOG, at, { captureStatus: "ok" });
|
|
236
231
|
const logArtifact = store.getArtifact(logId);
|
|
237
|
-
const receipt = JSON.stringify({ version: 1, request, run: runId, head: target.head, tested: target.head, level: target.level,
|
|
232
|
+
const receipt = JSON.stringify({ version: 1, request, run: runId, head: target.head, tested: target.head, level: target.level, checkAccess: widestRan(ranAt),
|
|
238
233
|
command: { repo: target.command.repo, command: target.command.command, digest: target.command.digest, approvedBy: target.command.approvedBy },
|
|
239
234
|
result, log: { artifactId: logArtifact.id, sha256: logArtifact.sha256, bytesStored: logArtifact.bytesStored, truncated: logArtifact.truncated } }, null, 1);
|
|
240
235
|
storeEvidence(store, root, runId, "structured-output", `follow-up-check-${request}.json`, Buffer.from(receipt, "utf8"), FOLLOW_UP_RECEIPT, at, { captureStatus: "ok" });
|
package/dist/scope.d.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { isAcpProfile, type AcpProviderId } from "./acp-agents.js";
|
|
|
4
4
|
export { isAcpProfile };
|
|
5
5
|
import { type NetworkProfile } from "./egress.js";
|
|
6
6
|
import { type WritesMode } from "./writes-mode.js";
|
|
7
|
+
import { type CheckAccess } from "./check-access.js";
|
|
7
8
|
import { type EvidenceKind } from "./contracts/acceptance-terms.js";
|
|
8
9
|
import { type AcceptanceCriterion, type AcceptanceProblem, type ScopeTerms } from "./contracts/scope.js";
|
|
9
10
|
import type { Store, Mutation } from "./store.js";
|
|
@@ -78,6 +79,8 @@ export type ClaudeProfile = {
|
|
|
78
79
|
/** Where the build may write (writes-mode.ts), resolved when the scope was written; absent on approvals sealed before
|
|
79
80
|
* write allowlists, which keep writing anywhere their fence allows. */
|
|
80
81
|
writes?: WritesMode;
|
|
82
|
+
/** How far its checks reach beyond the check fence (check-access.ts), sealed when wider than sandboxed; absent: sandboxed. */
|
|
83
|
+
checkAccess?: Exclude<CheckAccess, "sandboxed">;
|
|
81
84
|
};
|
|
82
85
|
export type CodexShapedProfile = {
|
|
83
86
|
provider: "codex" | "openrouter";
|
|
@@ -98,6 +101,8 @@ export type CodexShapedProfile = {
|
|
|
98
101
|
/** Where the build may write (writes-mode.ts), resolved when the scope was written; absent on approvals sealed before
|
|
99
102
|
* write allowlists, which keep writing anywhere their fence allows. */
|
|
100
103
|
writes?: WritesMode;
|
|
104
|
+
/** How far its checks reach beyond the check fence (check-access.ts), sealed when wider than sandboxed; absent: sandboxed. */
|
|
105
|
+
checkAccess?: Exclude<CheckAccess, "sandboxed">;
|
|
101
106
|
};
|
|
102
107
|
export type GeminiProfile = {
|
|
103
108
|
provider: "gemini";
|
|
@@ -120,6 +125,8 @@ export type GeminiProfile = {
|
|
|
120
125
|
/** Where the build may write (writes-mode.ts), resolved when the scope was written; absent on approvals sealed before
|
|
121
126
|
* write allowlists, which keep writing anywhere their fence allows. */
|
|
122
127
|
writes?: WritesMode;
|
|
128
|
+
/** How far its checks reach beyond the check fence (check-access.ts), sealed when wider than sandboxed; absent: sandboxed. */
|
|
129
|
+
checkAccess?: Exclude<CheckAccess, "sandboxed">;
|
|
123
130
|
};
|
|
124
131
|
/**
|
|
125
132
|
* An agent over the Agent Client Protocol (acp-agents.ts, docs/plans/acp-adapter.md).
|
|
@@ -143,6 +150,8 @@ export type AcpProfile = {
|
|
|
143
150
|
/** Where the build may write (writes-mode.ts), resolved when the scope was written; absent on approvals sealed before
|
|
144
151
|
* write allowlists, which keep writing anywhere their fence allows. */
|
|
145
152
|
writes?: WritesMode;
|
|
153
|
+
/** How far its checks reach beyond the check fence (check-access.ts), sealed when wider than sandboxed; absent: sandboxed. */
|
|
154
|
+
checkAccess?: Exclude<CheckAccess, "sandboxed">;
|
|
146
155
|
};
|
|
147
156
|
export type ExecutionProfile = ClaudeProfile | CodexShapedProfile | GeminiProfile | AcpProfile;
|
|
148
157
|
/** Whether a profile's clock is a no-progress window (an ACP profile has both clocks; its overall one is a deadline). */
|
package/dist/scope.js
CHANGED
|
@@ -41,6 +41,7 @@ export { isAcpProfile };
|
|
|
41
41
|
import { createHash, randomBytes, scryptSync, timingSafeEqual } from "node:crypto";
|
|
42
42
|
import { networkFromValue, networkWords } from "./egress.js";
|
|
43
43
|
import { isWritesMode, writesWords } from "./writes-mode.js";
|
|
44
|
+
import { checkAccessWords, isCheckAccess } from "./check-access.js";
|
|
44
45
|
import { z } from "zod";
|
|
45
46
|
import {} from "./contracts/acceptance-terms.js";
|
|
46
47
|
import { parseContract } from "./contracts/contract.js";
|
|
@@ -354,9 +355,14 @@ export function profileFromJson(json) {
|
|
|
354
355
|
const writes = p["writes"];
|
|
355
356
|
if (writes !== undefined && !isWritesMode(writes))
|
|
356
357
|
return null;
|
|
357
|
-
|
|
358
|
+
// Check access is sealed only when wider than sandboxed: an explicit "sandboxed", or any other word, is not this code's.
|
|
359
|
+
const sealedAccess = p["checkAccess"];
|
|
360
|
+
if (sealedAccess !== undefined && (sealedAccess === "sandboxed" || !isCheckAccess(sealedAccess)))
|
|
361
|
+
return null;
|
|
362
|
+
const checkAccess = sealedAccess;
|
|
363
|
+
const withNetwork = { ...(network === undefined ? {} : { network }), ...(writes === undefined ? {} : { writes }), ...(checkAccess === undefined ? {} : { checkAccess }) };
|
|
358
364
|
if (p["provider"] === "claude") {
|
|
359
|
-
if (exactKeys(p, [...PROVIDER_KEYS, "permissionArgv"], ["timeoutKind", "network", "writes"]) &&
|
|
365
|
+
if (exactKeys(p, [...PROVIDER_KEYS, "permissionArgv"], ["timeoutKind", "network", "writes", "checkAccess"]) &&
|
|
360
366
|
str(p["model"]) &&
|
|
361
367
|
(p["permissionArgv"] === "auto" || p["permissionArgv"] === "acceptEdits" || p["permissionArgv"] === "bypassPermissions") &&
|
|
362
368
|
num(p["maxTurns"]) && num(p["repairMaxTurns"]) &&
|
|
@@ -378,7 +384,7 @@ export function profileFromJson(json) {
|
|
|
378
384
|
return null;
|
|
379
385
|
}
|
|
380
386
|
if (p["provider"] === "codex" || p["provider"] === "openrouter") {
|
|
381
|
-
if (exactKeys(p, [...PROVIDER_KEYS, "sandboxMode"], ["timeoutKind", "network", "writes"]) &&
|
|
387
|
+
if (exactKeys(p, [...PROVIDER_KEYS, "sandboxMode"], ["timeoutKind", "network", "writes", "checkAccess"]) &&
|
|
382
388
|
str(p["model"]) &&
|
|
383
389
|
(p["sandboxMode"] === "workspace-write" || p["sandboxMode"] === "danger-full-access") &&
|
|
384
390
|
p["maxTurns"] === "unsupported" && p["repairMaxTurns"] === "unsupported" &&
|
|
@@ -400,7 +406,7 @@ export function profileFromJson(json) {
|
|
|
400
406
|
return null;
|
|
401
407
|
}
|
|
402
408
|
if (p["provider"] === "gemini") {
|
|
403
|
-
if (exactKeys(p, [...PROVIDER_KEYS, "approvalArgv"], ["timeoutKind", "network", "writes"]) &&
|
|
409
|
+
if (exactKeys(p, [...PROVIDER_KEYS, "approvalArgv"], ["timeoutKind", "network", "writes", "checkAccess"]) &&
|
|
404
410
|
str(p["model"]) &&
|
|
405
411
|
(p["approvalArgv"] === "auto_edit" || p["approvalArgv"] === "yolo") &&
|
|
406
412
|
p["maxTurns"] === "unsupported" && p["repairMaxTurns"] === "unsupported" &&
|
|
@@ -422,7 +428,7 @@ export function profileFromJson(json) {
|
|
|
422
428
|
return null;
|
|
423
429
|
}
|
|
424
430
|
if (typeof p["provider"] === "string" && isAcpProvider(p["provider"])) {
|
|
425
|
-
if (exactKeys(p, [...PROVIDER_KEYS, "permission", "idleTimeoutSeconds", "repairIdleTimeoutSeconds"], ["network", "writes"]) &&
|
|
431
|
+
if (exactKeys(p, [...PROVIDER_KEYS, "permission", "idleTimeoutSeconds", "repairIdleTimeoutSeconds"], ["network", "writes", "checkAccess"]) &&
|
|
426
432
|
str(p["model"]) &&
|
|
427
433
|
p["permission"] === "full" &&
|
|
428
434
|
p["maxTurns"] === "unsupported" && p["repairMaxTurns"] === "unsupported" &&
|
|
@@ -1271,6 +1277,7 @@ export function describeScope(scope, readiness = NO_READINESS) {
|
|
|
1271
1277
|
// What the build may reach (egress.ts), part of what the yes agrees to.
|
|
1272
1278
|
...(scope.profile === null || scope.profile === undefined ? [] : [` network ${networkWords(scope.profile.network ?? null).replace(/^Network: /, "")}`]),
|
|
1273
1279
|
...(scope.profile === null || scope.profile === undefined ? [] : [` writes ${writesWords(scope.profile.writes)}`]),
|
|
1280
|
+
...(scope.profile?.checkAccess === undefined ? [] : [` checks ${checkAccessWords(scope.profile.checkAccess)}`]),
|
|
1274
1281
|
` reference ${scope.digest}`,
|
|
1275
1282
|
` approved ${describeApproval(approval)}`,
|
|
1276
1283
|
];
|
package/dist/serve.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { taskMovedSince } from "./chat-ask.js";
|
|
2
|
+
import { linuxFenceAvailable, macosFenceAvailable } from "./agent-fence.js";
|
|
3
|
+
import { signInsUnavailableWords } from "./check-access.js";
|
|
2
4
|
import { LIVE_VIEW_CONSENT, liveCommandsShown, liveViewConsentDigest } from "./live-detail.js";
|
|
3
5
|
import { html, htmlString, jsonScript, textHtml } from "./html.js";
|
|
4
6
|
import { browserAssetsAvailable, browserWorkspaceDocument } from './browser-shell.js';
|
|
@@ -448,6 +450,13 @@ export function createDecisionServer(options) {
|
|
|
448
450
|
access: projectAccessOf(repo),
|
|
449
451
|
network: { setting: store.projectNetwork(repo), canChange: who.via === "cookie" && who.role === "approver" && !store.isDemo() && store.accountCanAccess(who.name, repo) },
|
|
450
452
|
writes: { setting: store.projectWrites(repo), canChange: who.via === "cookie" && who.role === "approver" && !store.isDemo() && store.accountCanAccess(who.name, repo) },
|
|
453
|
+
checkAccess: {
|
|
454
|
+
setting: store.projectCheckAccess(repo), changed: store.projectCheckAccessChange(repo),
|
|
455
|
+
canNarrow: who.via === "cookie" && who.role === "approver" && !store.isDemo() && store.accountCanAccess(who.name, repo),
|
|
456
|
+
canWiden: who.via === "cookie" && !store.isDemo() && store.isInstanceOperator(who.name),
|
|
457
|
+
unconfinedHere: !(options.fenceAvailable?.() ?? (macosFenceAvailable() || linuxFenceAvailable())) && store.unfencedAgentsAllowed(),
|
|
458
|
+
signInsUnavailable: signInsUnavailableWords(options.platform ?? process.platform),
|
|
459
|
+
},
|
|
451
460
|
liveView: (() => {
|
|
452
461
|
const commands = liveCommandsShown(store, repo);
|
|
453
462
|
const canChange = who.via === "cookie" && who.role === "approver" && !store.isDemo() && store.accountCanAccess(who.name, repo);
|
package/dist/server/http.d.ts
CHANGED
|
@@ -62,6 +62,8 @@ export type ServeOptions = {
|
|
|
62
62
|
leadByDefault?: boolean;
|
|
63
63
|
/** Test seam: the operating system the sign-in command is written for. */
|
|
64
64
|
platform?: NodeJS.Platform;
|
|
65
|
+
/** Test seam: whether this machine has an OS fence (macOS sandbox or bubblewrap) for checks. */
|
|
66
|
+
fenceAvailable?: () => boolean;
|
|
65
67
|
/** Extra Host values this server answers as (a Tailscale name, a LAN ip:port). */
|
|
66
68
|
allowedHosts?: readonly string[];
|
|
67
69
|
/**
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
/** Results and runs: the review cockpit, evidence, receipts, diffs and the result panel. */
|
|
2
|
+
import type { CheckAccess } from "../check-access.js";
|
|
2
3
|
import type { RunNetworkRecord } from "../egress.js";
|
|
3
4
|
import { type AssignmentSnapshot } from "../assignment.js";
|
|
4
5
|
import { type BrowserCheckItem, type BrowserNeedAction, type BrowserResultPanel, type BrowserResultView } from "../browser-workspace.js";
|
|
@@ -184,6 +185,8 @@ export type ReviewCockpitView = {
|
|
|
184
185
|
efficiency?: RunEfficiency | null;
|
|
185
186
|
/** What the run's network was and what its proxy saw (egress.ts): display only. */
|
|
186
187
|
network?: RunNetworkRecord | null;
|
|
188
|
+
/** How far its checks reached (check-access.ts): shown only when wider than sandboxed. */
|
|
189
|
+
checkAccess?: CheckAccess;
|
|
187
190
|
taskId: string;
|
|
188
191
|
title: string;
|
|
189
192
|
repo: string | null;
|
|
@@ -629,6 +629,7 @@ export function reviewCockpitDetailParts(view, csrf, noted, canRetryReview, tab,
|
|
|
629
629
|
record: run === null ? null : { build: run.id, href: `/r/${run.id}?record=1`, facts: [
|
|
630
630
|
{ label: "Agent", value: [run.provider, run.model].filter(Boolean).join(" · ") },
|
|
631
631
|
{ label: "Worker", value: run.runner },
|
|
632
|
+
...(view.checkAccess === undefined || view.checkAccess === "sandboxed" ? [] : [{ label: "Checks", value: view.checkAccess === "unsandboxed" ? "Checks ran without a sandbox" : "Checks ran with model and GitHub sign-ins" }]),
|
|
632
633
|
...(run.branch === null ? [] : [{ label: "Branch", value: run.branch }]),
|
|
633
634
|
...(run.finishedAt === null ? [] : [{ label: "Finished", value: when(run.finishedAt) }]),
|
|
634
635
|
...(run.ranMinutes === null ? [] : [{ label: "Ran", value: `${run.ranMinutes} min` }]),
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/** Task pages: approval, agents, plans, controls, decisions and the task body. */
|
|
2
2
|
import { networkWords } from "../egress.js";
|
|
3
3
|
import { writesWords } from "../writes-mode.js";
|
|
4
|
+
import { checkAccessWords } from "../check-access.js";
|
|
4
5
|
import { ACP_AGENTS, isAcpProfile } from "../acp-agents.js";
|
|
5
6
|
import { html, htmlString, joinHtml, postForm, replaceMarkup, reauth } from "../html.js";
|
|
6
7
|
import { closedQuestionReason, closedQuestionWords } from "../decision.js";
|
|
@@ -541,7 +542,7 @@ export function profileWords(scope) {
|
|
|
541
542
|
? 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>`
|
|
542
543
|
: 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>`;
|
|
543
544
|
// What the build may reach is part of what is approved (egress.ts): said on every approval form.
|
|
544
|
-
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
|
|
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>`}`;
|
|
545
546
|
}
|
|
546
547
|
export const ROLE_NOUN = { plan: "Planner", build: "Builder", repair: "Repair", review: "Reviewer" };
|
|
547
548
|
export function agentsStandingWords(view) {
|
|
@@ -284,6 +284,7 @@ post("settings", "settings.project-concurrency", "/settings/project/concurrency"
|
|
|
284
284
|
post("settings", "settings.project-live-view", "/settings/project/live-view", { scope: "step-up", callers: ["cookie"] });
|
|
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
|
+
post("settings", "settings.project-check-access", "/settings/project/check-access", { scope: "step-up", callers: ["cookie"] });
|
|
287
288
|
post("settings", "settings.project-delete", "/settings/project/delete", { scope: "step-up", ...operator("An instance operator deletes projects.", "/settings/project") });
|
|
288
289
|
// Lead sweeps (docs/plans/lead-fan-out.md): the table, and cancelling what isn't ready. The handler shows a sweep only in
|
|
289
290
|
// a project the reader can see, and acts only for an approver.
|
package/dist/server/settings.js
CHANGED
|
@@ -81,6 +81,7 @@ import { checkPolicy, parseList, policyParts } from "../policy.js";
|
|
|
81
81
|
import { parseProjectConcurrency, saveProjectConcurrency } from "../project-concurrency.js";
|
|
82
82
|
import { MAX_NETWORK_HOSTS, normalizeHost } from "../egress.js";
|
|
83
83
|
import { isWritesMode } from "../writes-mode.js";
|
|
84
|
+
import { isCheckAccess, signInsUnavailableWords, widerThan } from "../check-access.js";
|
|
84
85
|
import { projectDeleteConfirmHtml, projectSettingsHtml } from "../project-delete-ui.js";
|
|
85
86
|
import { deleteProject, holdingsWords } from "../project-delete.js";
|
|
86
87
|
import { applySavedKnowledge, changeKnowledge, knowledgeVersion, knowledgeView } from "../project-knowledge.js";
|
|
@@ -2485,6 +2486,7 @@ ${modeWords(terms)
|
|
|
2485
2486
|
"settings.project-live-view": projectLiveView,
|
|
2486
2487
|
"settings.project-network": projectNetwork,
|
|
2487
2488
|
"settings.project-writes": projectWrites,
|
|
2489
|
+
"settings.project-check-access": projectCheckAccess,
|
|
2488
2490
|
"settings.project-delete": projectDelete,
|
|
2489
2491
|
"settings.policy-send": policySave,
|
|
2490
2492
|
"settings.approval-send": approvalSave,
|
|
@@ -2587,5 +2589,40 @@ ${modeWords(terms)
|
|
|
2587
2589
|
const changed = store.setProjectWrites(repo, wanted, who.name, now);
|
|
2588
2590
|
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.");
|
|
2589
2591
|
}
|
|
2592
|
+
// Settings → Project → Check access (check-access.ts). Widening is an instance operator's act with a password typed now
|
|
2593
|
+
// (an identity-provider re-check doesn't count); any approver for the project narrows it. The level is compared again
|
|
2594
|
+
// inside the write, so a change made meanwhile can't turn a narrowing into a widening. The ledger keeps before → after.
|
|
2595
|
+
async function projectCheckAccess(ctx) {
|
|
2596
|
+
const { who, response, now, posted } = ctx;
|
|
2597
|
+
const body = readForm(posted, CONSOLE_FORMS.projectCheckAccess);
|
|
2598
|
+
const repo = body.get("repo") ?? "";
|
|
2599
|
+
if (!consoleProjects().includes(repo) || !visible(repo))
|
|
2600
|
+
return refuse(response, who, 403, "That project is outside your access.", "/settings/project");
|
|
2601
|
+
if (who.via !== "cookie" || who.role !== "approver" || store.isDemo() || !store.accountCanAccess(who.name, repo))
|
|
2602
|
+
return refuse(response, who, 403, "An approver for this project changes how far its checks reach.", "/settings/project");
|
|
2603
|
+
const back = sayTo(response, `/settings/project?repo=${encodeURIComponent(repo)}`, "#check-access");
|
|
2604
|
+
const wanted = body.get("level");
|
|
2605
|
+
if (!isCheckAccess(wanted))
|
|
2606
|
+
return back("problem", "Choose how far checks reach. Nothing changed.");
|
|
2607
|
+
// Not available yet (check-access.ts signInsAvailable): refused before the password, whatever the form sent.
|
|
2608
|
+
const unavailable = wanted === "sign-ins" ? signInsUnavailableWords(options.platform ?? process.platform) : null;
|
|
2609
|
+
if (unavailable !== null)
|
|
2610
|
+
return back("problem", `${unavailable} Nothing changed.`);
|
|
2611
|
+
const widening = widerThan(wanted, store.projectCheckAccess(repo));
|
|
2612
|
+
// Sign-ins with an open network: a second, explicit yes, said before the password.
|
|
2613
|
+
if (wanted === "sign-ins" && widening && store.projectNetwork(repo).mode === "open" && body.get("confirm-open") !== "yes") {
|
|
2614
|
+
return back("problem", "This project's network is open: confirm that its checks could send your sign-ins to any host. Nothing changed.");
|
|
2615
|
+
}
|
|
2616
|
+
const proved = authenticateCredential(who, body.get("password") ?? "");
|
|
2617
|
+
if (!proved.ok)
|
|
2618
|
+
return back("problem", "Enter your Toolroll password to change how far checks reach.");
|
|
2619
|
+
if (widening && (proved.method !== "password-now" || !store.isInstanceOperator(who.name))) {
|
|
2620
|
+
return back("problem", "Only an instance operator widens how far checks reach, with their password typed now. Nothing changed.");
|
|
2621
|
+
}
|
|
2622
|
+
const changed = store.setProjectCheckAccess(repo, wanted, who.name, now, { may: widening ? "widen" : "narrow" });
|
|
2623
|
+
if (changed === null)
|
|
2624
|
+
return back("problem", "Check access changed meanwhile, and this would now widen it. Nothing changed.");
|
|
2625
|
+
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
|
+
}
|
|
2590
2627
|
return { registrations, modelsView };
|
|
2591
2628
|
}
|
package/dist/server/tasks.js
CHANGED
|
@@ -2846,6 +2846,8 @@ export function createTasksHandlers(runtime) {
|
|
|
2846
2846
|
} })(),
|
|
2847
2847
|
// What its network was and what its proxy saw (egress.ts), display only.
|
|
2848
2848
|
network: store.runNetwork(run.id),
|
|
2849
|
+
// How far its checks reached (check-access.ts), display only.
|
|
2850
|
+
checkAccess: store.runCheckAccess(run.id),
|
|
2849
2851
|
reviewRetry: store.reviewRetryStateOf(run.id),
|
|
2850
2852
|
review: reviewFactsFor(run.id),
|
|
2851
2853
|
notes: store.notesForRun(run.id),
|
package/dist/store.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { type WritesMode } from "./writes-mode.js";
|
|
2
|
-
import { type
|
|
2
|
+
import { type CheckAccess } from "./check-access.js";
|
|
3
|
+
import { type NetworkProfile, type ProjectNetwork, type RunNetworkRecord } from "./egress.js";
|
|
3
4
|
import { type AcpLaunch } from "./acp-attest.js";
|
|
4
5
|
import { type ChatBinding } from "./chat-core.js";
|
|
5
6
|
import { ChatMessages, TELEGRAM_HOLD_REASONS, TELEGRAM_SKIPPED_ELSEWHERE, TELEGRAM_SKIPPED_OTHER_CHAT, TELEGRAM_SKIPPED_QUIET } from "./chat-messages.js";
|
|
@@ -179,7 +180,7 @@ export type SealedLedgerEntry = LedgerEntry & {
|
|
|
179
180
|
hash: string;
|
|
180
181
|
} | null;
|
|
181
182
|
};
|
|
182
|
-
export declare const SCHEMA_VERSION =
|
|
183
|
+
export declare const SCHEMA_VERSION = 132;
|
|
183
184
|
/** v118: the tables merged into `credential`, in the order they move (a person's API tokens keep their ids first). */
|
|
184
185
|
export declare const V118_MERGED_TABLES: Readonly<Record<string, CredentialKind>>;
|
|
185
186
|
/**
|
|
@@ -318,6 +319,8 @@ export declare const V129_ADDED_COLUMNS: Readonly<Record<string, Readonly<Record
|
|
|
318
319
|
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
320
|
/** v131: where a project's builds may write (an approver's setting, agent-fence.ts WritesMode). */
|
|
320
321
|
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
|
+
/** v132: how far a project's checks reach beyond the check fence (check-access.ts CheckAccess). */
|
|
323
|
+
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";
|
|
321
324
|
/** v129: a finished run's How it ran, derived from its own records on first view (display only: never a verdict). Its
|
|
322
325
|
* parser version says when to derive again; `steps` says how complete the step detail was, and why not. */
|
|
323
326
|
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";
|
|
@@ -7174,6 +7177,30 @@ export declare class Store {
|
|
|
7174
7177
|
* removed or expired — any of those means the composer's draft was accepted and must not be sent again). */
|
|
7175
7178
|
leadRequestReceived(session: number, request: string): boolean;
|
|
7176
7179
|
forgetQueuedLeadMessage(message: number): void;
|
|
7180
|
+
/** How far a project's checks reach beyond the check fence: its saved level, or sandboxed. */
|
|
7181
|
+
projectCheckAccess(repo: string): CheckAccess;
|
|
7182
|
+
/** Who last changed a project's check access, and when (null: never changed). */
|
|
7183
|
+
projectCheckAccessChange(repo: string): {
|
|
7184
|
+
by: string;
|
|
7185
|
+
at: string;
|
|
7186
|
+
} | null;
|
|
7187
|
+
/** The level a run's setup and checks ran at (its sealed approval's, or unsandboxed where no OS fence exists). Its
|
|
7188
|
+
* follow-up and batch checks never run wider than this. */
|
|
7189
|
+
stampRunCheckAccess(runId: number, level: CheckAccess, network?: NetworkProfile | null): void;
|
|
7190
|
+
/** The network a run's sign-ins checks ran under (null: none sealed; such a run's later checks run sandboxed). */
|
|
7191
|
+
runCheckNetwork(runId: number): NetworkProfile | null;
|
|
7192
|
+
/** The level a run's checks ran at: missing or unreadable reads as sandboxed. */
|
|
7193
|
+
runCheckAccess(runId: number): CheckAccess;
|
|
7194
|
+
/** A project's check access changed: saved, and in the ledger as before → after. `may` is what the caller proved:
|
|
7195
|
+
* "widen" only after an instance operator's password typed now; anything else may only narrow. The level is read again
|
|
7196
|
+
* and compared in the same transaction as the write, so a change made while the caller was proving itself can't turn
|
|
7197
|
+
* a narrowing into a widening. Refused: null, nothing written. */
|
|
7198
|
+
setProjectCheckAccess(repo: string, level: CheckAccess, by: string, now: Date, authority: {
|
|
7199
|
+
may: "narrow" | "widen";
|
|
7200
|
+
}): {
|
|
7201
|
+
before: CheckAccess;
|
|
7202
|
+
after: CheckAccess;
|
|
7203
|
+
} | null;
|
|
7177
7204
|
}
|
|
7178
7205
|
/** The receipt a bot's first pairing writes over pre-pairing history: an
|
|
7179
7206
|
* explicit skip, with no delivered timestamp and no attempt, so suppressed
|