toolroll 0.9.75 → 0.9.76

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 (55) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/dist/agent-fence.js +8 -0
  3. package/dist/approval-authority.js +6 -2
  4. package/dist/approval-rules-ui.d.ts +11 -0
  5. package/dist/approval-rules-ui.js +13 -1
  6. package/dist/batch-checks.js +3 -1
  7. package/dist/builder.js +1 -1
  8. package/dist/coding-workspace.js +7 -5
  9. package/dist/contracts/console-api.d.ts +1 -0
  10. package/dist/contracts/console-api.js +1 -0
  11. package/dist/deploy-gate.d.ts +3 -0
  12. package/dist/deploy-gate.js +3 -2
  13. package/dist/desktop-update.d.ts +1 -1
  14. package/dist/discover.js +6 -1
  15. package/dist/first-run.js +3 -1
  16. package/dist/git-incident.d.ts +49 -0
  17. package/dist/git-incident.js +69 -0
  18. package/dist/git-views.d.ts +20 -0
  19. package/dist/git-views.js +120 -0
  20. package/dist/grant.js +3 -1
  21. package/dist/operate.js +7 -1
  22. package/dist/planner.js +5 -1
  23. package/dist/project-knowledge.js +1 -1
  24. package/dist/project-learning.js +22 -2
  25. package/dist/pull-request-flow.js +4 -0
  26. package/dist/release-adapters.d.ts +24 -2
  27. package/dist/release-adapters.js +136 -10
  28. package/dist/release-authority.d.ts +46 -0
  29. package/dist/release-authority.js +64 -0
  30. package/dist/release-auto.d.ts +153 -0
  31. package/dist/release-auto.js +496 -0
  32. package/dist/release-cli.js +56 -2
  33. package/dist/release-coverage.js +1 -1
  34. package/dist/release.d.ts +105 -1
  35. package/dist/release.js +229 -9
  36. package/dist/repository-context.js +4 -6
  37. package/dist/result-follow-ups.js +3 -1
  38. package/dist/scope.d.ts +31 -2
  39. package/dist/scope.js +44 -3
  40. package/dist/scout.js +5 -1
  41. package/dist/server/render-tasks.d.ts +10 -0
  42. package/dist/server/render-tasks.js +23 -13
  43. package/dist/server/route-table.js +3 -0
  44. package/dist/server/settings.js +29 -2
  45. package/dist/server/task-views.js +8 -0
  46. package/dist/store.d.ts +1 -1
  47. package/dist/store.js +19 -4
  48. package/dist/task-base.js +1 -1
  49. package/dist/teardown-git.d.ts +38 -10
  50. package/dist/teardown-git.js +894 -108
  51. package/dist/test-infra.d.ts +26 -0
  52. package/dist/test-infra.js +321 -0
  53. package/dist/worktree.d.ts +4 -0
  54. package/dist/worktree.js +13 -7
  55. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,54 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.9.76 — 2026-10-28
4
+
5
+ - **Releases can approve themselves when they're green.** A new per-project
6
+ setting, **Release approval: Ask me / Approve automatically when checks
7
+ pass**, is off by default. Turning it on takes your typed password;
8
+ turning it off takes one click and withdraws every automatic approval not
9
+ yet merged. When it's on, a release task first reads "Checking
10
+ automatically" (`auto-check`), which lets its release check run and
11
+ nothing else. Once GitHub CI and the release check both pass on the exact
12
+ candidate, it approves itself (`auto-green`) and the release continues as
13
+ after your yes. You're told, not asked.
14
+ - **What an automatic approval must prove.** Up to two failed attempts per
15
+ CI job, and two failed release-check builds on the same commit, are
16
+ tolerated as flakes. Past that it asks you. The change must touch only
17
+ allowed paths: product code and its tests, test files under `test/`,
18
+ docs, the changelog and a version-only `package.json` bump. Scripts, CI,
19
+ configuration, dependencies, policy files and test helpers (found from the
20
+ tests' own imports) need a person. CI is read again before the deploy, the
21
+ CLI switch, the merge and the tag. Anything not green stops the release,
22
+ withdraws the approval and tells you.
23
+ - **A red CI after the merge holds publishing.** No tag, npm publish or
24
+ Homebrew update happens until a person runs
25
+ `toolroll release publish-ok <task>` with their password.
26
+ - **Stricter CI verdicts on every release, yours too.** Only checks on the
27
+ exact head count. Required checks must come from GitHub Actions, so
28
+ another app's same-name success can't hide a failure. A newer run of the
29
+ same workflow supersedes its older runs on the commit.
30
+ - **Toolroll's own git on agent checkouts runs against a frozen view.**
31
+ Every git command the plane runs on a checkout an agent wrote (staging,
32
+ committing, cleanup, pool refresh, scouts, planners, coding sessions, page
33
+ reads) runs against a private copy of the repository's settings taken
34
+ once. Changes the agent makes to them afterwards are never read.
35
+ - **Git settings are denied by default.** Only audited config sections and
36
+ variables pass. Aliases, URL rewrites that reach remote helpers, empty
37
+ filter names and `hook.*` are refused. Writes allow no network transport.
38
+ Local git-lfs installs still work.
39
+ - **Writes are bound to the project's recorded repository.** A checkout
40
+ whose `.git` points anywhere else is refused. Before git writes through a
41
+ view, every path it could write is walked for links and hard links. Plane
42
+ git writes on one checkout run one at a time. On git before 2.48, a
43
+ checkout added through the view is pointed at its repository, not at the
44
+ view.
45
+ - **A git incident is recorded and you're notified.** A swap seen after git
46
+ ran is logged and raised as an attention notification. The notification
47
+ names the project by Toolroll's own record, never by a path the agent
48
+ chose.
49
+ - **`discover --dirty` reads repositories through the same hardened git.**
50
+ - Database: schema v136 (unchanged).
51
+
3
52
  ## 0.9.75 — 2026-10-27
4
53
 
5
54
  - **Opening a page only shows it.** Pages no longer run checks, tidy up or
@@ -32,6 +32,7 @@ import { homedir, tmpdir } from "node:os";
32
32
  import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
33
33
  import { readMonitoring } from "./monitoring-settings.js";
34
34
  import { existingFolders } from "./names.js";
35
+ import { ensureGitViewsRoot, gitViewsRoot } from "./git-views.js";
35
36
  import { DEPS_FOLDER, linkedKey, readyCopy, sharedCopies } from "./shared-deps.js";
36
37
  import { SEATBELT_PROBE, sandboxRuntimeProblem, srtSpawn } from "./sandbox-runtime.js";
37
38
  import { protectedStorageDenies, protectedStorageNames } from "./protected-storage.js";
@@ -175,6 +176,13 @@ export function agentFence(options) {
175
176
  fence.add(join(state, entry));
176
177
  }
177
178
  }
179
+ // The plane's private views of repositories for its own git (git-views.ts): made first, so no build is the one to
180
+ // create them, and denied by name whether or not that worked (a sandbox that can't cover the path refuses to launch).
181
+ try {
182
+ ensureGitViewsRoot(home);
183
+ }
184
+ catch { /* fenced by name below either way */ }
185
+ fence.add(gitViewsRoot(home));
178
186
  for (const secrets of existingFolders(home, { dot: true }))
179
187
  fence.add(secrets);
180
188
  // v104: the folder the audit stream writes to holds the whole instance's history.
@@ -4,7 +4,9 @@
4
4
  *
5
5
  * An allowlist, never a deny-list: a person's yes (`password`, `chat`) stands unless a machine made that seal; a mode
6
6
  * or a flow grant stands while its signature does; a standing order's firing (`schedule`, or an old firing stored as
7
- * `password`) stands while its approver can still approve in the project. Everything else — the lead (`lead`, which
7
+ * `password`) stands while its approver can still approve in the project; an automatic release seal (`auto-check`,
8
+ * `auto-green`: docs/plans/auto-approve-green.md) stands on its own evidence row while the project's setting is on and
9
+ * the person who turned it on can still approve there. Everything else — the lead (`lead`, which
8
10
  * phase 1 never lets stand), a null basis, a word this code doesn't write — stands on nothing.
9
11
  *
10
12
  * The seal's origin is read from the acts recorded AT the seal's own instant, exactly as scope.ts sealOriginOf reads
@@ -12,6 +14,7 @@
12
14
  */
13
15
  import { grantStandsSql } from "./flow-unattended.js";
14
16
  import { accountReachesSql } from "./project-access.js";
17
+ import { autoSealSql } from "./release-authority.js";
15
18
  /** The proofs a person gives (src/person-proof.ts), as an SQL list. */
16
19
  const PERSON_PROOFS = "('password-now', 'sso-now', 'fresh-password', 'fresh-sso', 'session', 'cli-login', 'chat', 'passkey')";
17
20
  /** SQL over a `task_scope` alias and a repo expression: whether that row's approval authority stands at `now` (an SQL
@@ -25,7 +28,8 @@ export const authorityStandsSql = (scope, repo, now) => `COALESCE((((COALESCE(${
25
28
  WHERE om.repo = ${repo} AND om.revoked_at IS NULL AND om.absolute_expiry > ${now} AND om.digest = ${scope}.mode_digest
26
29
  AND signer.revoked_at IS NULL AND signer.role = 'approver' AND ${accountReachesSql("signer", repo)}))
27
30
  OR (${scope}.approval_basis = 'grant' AND ${grantStandsSql(`${scope}.mode_digest`, now, repo)})
28
- OR (${scope}.approval_basis = 'schedule' AND ${approverStandsSql(scope, repo)}))
31
+ OR (${scope}.approval_basis = 'schedule' AND ${approverStandsSql(scope, repo)})
32
+ OR (${scope}.approval_basis IN ('auto-check', 'auto-green') AND ${autoSealSql(scope, repo)} AND ${approverStandsSql(scope, repo)}))
29
33
  AND ${releaseStandsSql(scope, now)}), 0)`;
30
34
  /** SQL: a `carried` seal stands only on its own carry row — this task, this approved digest, this candidate, and the
31
35
  * reference `<authority>:<carry>` the carry transaction wrote (release-once.md). */
@@ -6,6 +6,8 @@
6
6
  */
7
7
  import { type Html } from "./html.js";
8
8
  import type { ApprovalRules } from "./approval-policy.js";
9
+ /** The rules' style, and the same for the Releases section under its own class (so a page holds one `.approval-rules`
10
+ * form, not two). */
9
11
  export declare const APPROVAL_RULES_CSS: string;
10
12
  export type ApprovalRulesView = {
11
13
  repo: string;
@@ -20,6 +22,15 @@ export type ApprovalRulesView = {
20
22
  };
21
23
  /** The rules in plain words, for anyone who can see the project. */
22
24
  export declare function rulesSummary(rules: ApprovalRules): string;
25
+ /** Settings → Approval rules → Releases (docs/plans/auto-approve-green.md): who approves this project's releases. */
26
+ export type ReleaseApprovalView = {
27
+ repo: string;
28
+ auto: boolean;
29
+ setBy: string | null;
30
+ at: string | null;
31
+ canChange: boolean;
32
+ };
33
+ export declare function releaseApprovalHtml(view: ReleaseApprovalView): Html;
23
34
  export declare function approvalRulesHtml(view: ApprovalRulesView, notice: {
24
35
  said?: string | null;
25
36
  problem?: string | null;
@@ -5,11 +5,14 @@
5
5
  * ledger, before → after.
6
6
  */
7
7
  import { html, postForm, reauth } from "./html.js";
8
- export const APPROVAL_RULES_CSS = `.approval-rules{max-width:720px;min-width:0}.approval-rules fieldset{border:0;padding:0;margin:18px 0 0;min-width:0}` +
8
+ const RULES_CSS = `.approval-rules{max-width:720px;min-width:0}.approval-rules fieldset{border:0;padding:0;margin:18px 0 0;min-width:0}` +
9
9
  `.approval-rules legend{font-weight:600;font-size:.875rem;margin-bottom:8px}.approval-rules .choice{display:grid;grid-template-columns:auto minmax(0,1fr);gap:10px;align-items:start;margin:0 0 10px;font-weight:400}` +
10
10
  `.approval-rules .choice input{margin:.2rem 0 0}.approval-rules .choice strong{display:block;font-size:.875rem;font-weight:500}.approval-rules .choice small{display:block;color:var(--muted-foreground);font-size:.8125rem;margin-top:2px}` +
11
11
  `.approval-rules textarea{min-height:88px;font-family:var(--font-mono);font-size:.8125rem}.approval-rules .paths{margin:4px 0 0 26px}` +
12
12
  `.approval-rules .current{margin:0 0 4px}.approval-rules .step-up{margin-top:16px}.approval-rules button[type=submit]{margin-top:14px}`;
13
+ /** The rules' style, and the same for the Releases section under its own class (so a page holds one `.approval-rules`
14
+ * form, not two). */
15
+ export const APPROVAL_RULES_CSS = RULES_CSS + RULES_CSS.replaceAll(".approval-rules", ".release-approval");
13
16
  /** The rules in plain words, for anyone who can see the project. */
14
17
  export function rulesSummary(rules) {
15
18
  const parts = [];
@@ -21,6 +24,15 @@ export function rulesSummary(rules) {
21
24
  parts.push(`Work touching ${rules.protectedPaths.join(", ")} is protected: two people approve it, never a subagent or an operating mode.`);
22
25
  return parts.length === 0 ? "No approval rules: anyone who can approve on this project can approve its work, including their own." : parts.join(" ");
23
26
  }
27
+ export function releaseApprovalHtml(view) {
28
+ const since = view.setBy === null ? "" : html ` <span class="meta">Changed by ${view.setBy} on ${(view.at ?? "").slice(0, 10)}.</span>`;
29
+ const current = html `<p class="current">${view.auto ? "Releases approve themselves once the pull request's CI and the release check pass on the exact commit. Anything red still asks you." : "Every release waits for your approval."}${since}</p>`;
30
+ const heading = html `<h2>Release approval</h2>`;
31
+ if (!view.canChange)
32
+ return html `<section class="release-approval">${heading}${current}<p class="meta">An approver of this project sets this.</p></section>`;
33
+ const choice = (value, title, hint) => html `<label class="choice"><input type="radio" name="action" value="${value}"${(view.auto ? "auto" : "ask") === value ? html ` checked` : ""}><span><strong>${title}</strong><small>${hint}</small></span></label>`;
34
+ return html `<section class="release-approval">${heading}${current}${postForm("/settings/release-approval", html `<fieldset aria-label="Release approval">${choice("ask", "Ask me", "A release deploys only after you approve it.")}${choice("auto", "Approve automatically when checks pass", "You're told when a release approves itself. A failed check, red CI or a CI failure after the deploy still stops and asks you.")}</fieldset><div class="step-up"><label>Your Toolroll password (to approve automatically)<input type="password" name="password" autocomplete="current-password"></label></div><button type="submit">Save</button>`, { hidden: { repo: view.repo } })}</section>`;
35
+ }
24
36
  export function approvalRulesHtml(view, notice) {
25
37
  const note = notice.problem ? html `<p class="problem" role="alert">${notice.problem}</p>` : notice.said ? html `<p role="status">${notice.said}</p>` : "";
26
38
  const changed = view.rules.updatedBy === null ? "" : html ` <span class="meta">Changed by ${view.rules.updatedBy} on ${(view.rules.updatedAt ?? "").slice(0, 10)}.</span>`;
@@ -3,6 +3,7 @@ import { mkdtempSync, rmSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import { run as execRun } from "./exec.js";
6
+ import { hardenedGit } from "./teardown-git.js";
6
7
  import { approvedCommandShell, SETUP_ENV_ALLOWLIST, SETUP_ENV_DENYLIST } from "./builder.js";
7
8
  import { runWithIsolatedDatabase } from "./child-database.js";
8
9
  import { boundStreamHeadTail, redactSecretLines, scanForSecrets, storeEvidence } from "./evidence.js";
@@ -86,7 +87,8 @@ function finishOne(store, one, repo, task, status, detail, at) {
86
87
  async function checkCohort(store, root, repo, cohort, deps) {
87
88
  const now = deps.now ?? (() => new Date());
88
89
  const runner = deps.runner ?? execRun;
89
- const git = deps.git ?? execRun;
90
+ // The project's repository and checkouts of builds' commits, both agent-writable: the hardened git (teardown-git.ts).
91
+ const git = deps.git ?? hardenedGit(execRun);
90
92
  const command = store.liveVerifyCommand(repo);
91
93
  if (command === null)
92
94
  return 0;
package/dist/builder.js CHANGED
@@ -255,7 +255,7 @@ const BRANCH_QUERIES = [
255
255
  function buildCheckoutGit(given, worktree, mainCheckout = null) {
256
256
  if (given !== run)
257
257
  return given;
258
- const hardened = checkoutGit(given);
258
+ const hardened = checkoutGit(given, "build", { repo: mainCheckout });
259
259
  const real = (path) => { try {
260
260
  return realpathSync(resolve(path));
261
261
  }
@@ -400,8 +400,10 @@ export class CodingWorkspace {
400
400
  }, 50);
401
401
  }
402
402
  }
403
- async git(repo, args) {
404
- const result = await run('git', ['--no-optional-locks', '-c', 'core.hooksPath=/dev/null', ...args], { cwd: repo, timeoutMs: 60_000, maxBuffer: 1_048_576 });
403
+ async git(repo, args, project = repo) {
404
+ // The project's repository and the session's checkout are agent-written: the hardened git, against a frozen view of
405
+ // the repository's config and info (teardown-git.ts checkoutGit).
406
+ const result = await checkoutGit(run, 'build', { repo: project })('git', ['--no-optional-locks', ...args], { cwd: repo, timeoutMs: 60_000, maxBuffer: 1_048_576 });
405
407
  if (result.code !== 0)
406
408
  throw Error('Git could not complete this workspace operation. Check the repository and try again.');
407
409
  return result.stdout;
@@ -520,13 +522,13 @@ export class CodingWorkspace {
520
522
  }
521
523
  async verifyWorktree(session) {
522
524
  const [branch, common, original] = await Promise.all([
523
- this.git(session.worktree, ['symbolic-ref', '--short', 'HEAD']),
524
- this.git(session.worktree, ['rev-parse', '--path-format=absolute', '--git-common-dir']),
525
+ this.git(session.worktree, ['symbolic-ref', '--short', 'HEAD'], session.repo),
526
+ this.git(session.worktree, ['rev-parse', '--path-format=absolute', '--git-common-dir'], session.repo),
525
527
  this.git(session.repo, ['rev-parse', '--path-format=absolute', '--git-common-dir']),
526
528
  ]);
527
529
  if (branch.trim() !== session.branch || realpathSync(common.trim()) !== realpathSync(original.trim()))
528
530
  throw Error('This checkout no longer matches the saved project and branch. Restore that checkout before resuming.');
529
- await this.git(session.worktree, ['merge-base', '--is-ancestor', session.base, 'HEAD']);
531
+ await this.git(session.worktree, ['merge-base', '--is-ancestor', session.base, 'HEAD'], session.repo);
530
532
  }
531
533
  priorProcessesGone() {
532
534
  const row = this.db.prepare('SELECT payload FROM coding_custody WHERE singleton=1').get();
@@ -72,6 +72,7 @@ export declare const CONSOLE_FORMS: {
72
72
  readonly code: FormContract<"acceptance" | "answers" | "base" | "candidate" | "decision" | "goal" | "model" | "password" | "prompt" | "repo" | "requestId" | "title" | "visual" | `question:${string}`>;
73
73
  readonly sweep: FormContract<"confirm" | "snapshot">;
74
74
  readonly passwordless: FormContract<"action" | "password">;
75
+ readonly releaseApproval: FormContract<"action" | "password" | "repo">;
75
76
  readonly term: FormContract<"password" | "program" | "repo">;
76
77
  readonly skillsRevise: FormContract<"feedback" | "nonce" | "repo" | "run">;
77
78
  readonly skillsChange: FormContract<"action" | "content" | "files" | "identity" | "method" | "nonce" | "repo" | "restore" | "revision" | "sample" | "sha" | "url">;
@@ -81,6 +81,7 @@ export const CONSOLE_FORMS = {
81
81
  code: formContract("POST /code, /code/start, /code/<session>/<act>, /code/<anything else>", ["repo", "password", "title", "model", "prompt", "requestId", "base", "candidate", "goal", "acceptance", "visual", "decision", "answers"], { prefixes: ["question:"] }),
82
82
  sweep: formContract("POST /sweeps/<sweep>/cancel-rest", ["confirm", "snapshot"]),
83
83
  passwordless: formContract("POST /settings/passwordless", ["action", "password"]),
84
+ releaseApproval: formContract("POST /settings/release-approval", ["repo", "action", "password"]),
84
85
  term: formContract("POST /term/start, /term/<terminal>/close, /term/<terminal>/remove", ["repo", "program", "password"]),
85
86
  skillsRevise: formContract("POST /settings/skills/revise", ["run", "repo", "feedback", "nonce"]),
86
87
  skillsChange: formContract("POST /settings/skills/import, /settings/skills/change", ["repo", "identity", "revision", "method", "content", "files", "url", "action", "sha", "sample", "nonce", "restore"]),
@@ -14,4 +14,7 @@ export declare function deploymentGate(store: Store, input: {
14
14
  runId: number;
15
15
  evidenceRoot: string;
16
16
  now: Date;
17
+ /** Inside `toolroll release` (its own reserved deploy): a release's own automatic approval also stands
18
+ * (docs/plans/auto-approve-green.md). Anywhere else only a person's approval deploys. */
19
+ releaseFlow?: boolean;
17
20
  }): DeploymentGate;
@@ -6,7 +6,7 @@
6
6
  * same checks for the deployment script, proven again there with the installed runtime's modules.)
7
7
  */
8
8
  import { assignmentOf } from "./assignment.js";
9
- import { liveReleaseOf, personApprovalOf } from "./scope.js";
9
+ import { liveReleaseOf, personApprovalOf, releaseApprovalOf } from "./scope.js";
10
10
  import { verificationEvidence } from "./verification-evidence.js";
11
11
  /** Whether run `runId` is a deployable candidate now, read only from the plane's records and this runtime's code. */
12
12
  export function deploymentGate(store, input) {
@@ -33,7 +33,8 @@ export function deploymentGate(store, input) {
33
33
  const release = liveReleaseOf(store, ref.externalId, "deploy", input.now);
34
34
  if (release.applies && !release.ok)
35
35
  return refuse(`This release can't deploy: ${release.why}.`);
36
- const anchorPerson = release.applies && release.ok ? personApprovalOf(store, release.authority.anchorTask) : null;
36
+ const anchorPerson = release.applies && release.ok
37
+ ? (input.releaseFlow === true ? releaseApprovalOf(store, release.authority.anchorTask, input.now) : personApprovalOf(store, release.authority.anchorTask)) : null;
37
38
  const person = anchorPerson ?? personApprovalOf(store, ref.externalId);
38
39
  if (!person.ok)
39
40
  return refuse(`A person has to approve this candidate's scope: ${person.why}.`);
@@ -97,7 +97,6 @@ export declare function sqliteLock(file: string, busyTimeoutMs?: number): Databa
97
97
  export declare function lockHeld(file: string): boolean;
98
98
  export declare const installationLock: (j: UpdateJournal) => DatabaseSync | null;
99
99
  export declare function desktopUpdateStatus(stateDir: string): {
100
- version?: never;
101
100
  active: boolean;
102
101
  phase: string;
103
102
  detail: string;
@@ -111,6 +110,7 @@ export declare function desktopUpdateStatus(stateDir: string): {
111
110
  wasRunning?: never;
112
111
  backupPath?: never;
113
112
  updateId?: never;
113
+ version?: never;
114
114
  buildId?: never;
115
115
  workDir?: never;
116
116
  } | {
package/dist/discover.js CHANGED
@@ -19,6 +19,7 @@
19
19
  */
20
20
  import { basename } from "node:path";
21
21
  import { run } from "./exec.js";
22
+ import { checkoutGit } from "./teardown-git.js";
22
23
  import { parseWorktrees, parseBranches } from "./git.js";
23
24
  import { findRepos } from "./scan.js";
24
25
  /** Enough to keep git busy without turning a laptop fan on. */
@@ -43,7 +44,11 @@ const BRANCH_FORMAT = "--format=%(refname:short)%09%(upstream:track)%09%(committ
43
44
  const BRANCH_FORMAT_CHEAP = "--format=%(refname:short)%09%09%(committerdate:iso8601-strict)";
44
45
  export async function inspectRepo(path, options = {}) {
45
46
  const { runner = run, dirty = false } = options;
46
- const git = (args, timeoutMs = REF_TIMEOUT_MS) => runner(GIT, [...READ_ONLY, ...args], { cwd: path, timeoutMs });
47
+ // Scanned and enrolled projects share their repository with every build, which can write its config, info and hooks
48
+ // (`--dirty`'s status would run a planted fsmonitor or filter): the hardened git, against a frozen view of the
49
+ // repository (teardown-git.ts checkoutGit). An injected runner (tests) is used as given.
50
+ const hardened = runner === run ? checkoutGit(run, "build") : runner;
51
+ const git = (args, timeoutMs = REF_TIMEOUT_MS) => hardened(GIT, [...READ_ONLY, ...args], { cwd: path, timeoutMs });
47
52
  const [worktrees, branchRead, head, remote, status] = await Promise.all([
48
53
  git(["worktree", "list", "--porcelain"]),
49
54
  readBranches(git),
package/dist/first-run.js CHANGED
@@ -6,6 +6,7 @@
6
6
  * the composer: nothing is filed until the person sends it and confirms.
7
7
  */
8
8
  import { run } from "./exec.js";
9
+ import { hardenedGit } from "./teardown-git.js";
9
10
  import { githubIssues } from "./issues.js";
10
11
  export const SIGN_IN_COMMAND = "claude auth login";
11
12
  export const SANDBOX_COMMAND = "npx toolroll demo";
@@ -95,7 +96,8 @@ export async function findFirstTasks(repo, runner = run) {
95
96
  let todos = [];
96
97
  if (issues.length < 3) {
97
98
  try {
98
- const result = await runner("git", [...TODO_GREP_ARGS], { cwd: repo, timeoutMs: 5_000 });
99
+ // The project's repository is shared with builds that can write its config: the hardened read (teardown-git.ts).
100
+ const result = await (runner === run ? hardenedGit(run, "read") : runner)("git", [...TODO_GREP_ARGS], { cwd: repo, timeoutMs: 5_000 });
99
101
  if (result.code === 0 || result.stdout !== "")
100
102
  todos = parseTodoLines(result.stdout);
101
103
  }
@@ -0,0 +1,49 @@
1
+ export type GitIncident = {
2
+ at: string;
3
+ checkout: string;
4
+ repository: string;
5
+ /** Folders of the repository found swapped, and where each now leads (null: its pointers changed instead). */
6
+ swapped: {
7
+ path: string;
8
+ leadsTo: string | null;
9
+ }[];
10
+ };
11
+ type Listener = (incident: GitIncident) => void;
12
+ /** Hear about every git incident (returns the unsubscribe). With none registered, incidents go to stderr. */
13
+ export declare function onGitIncident(listener: Listener): () => void;
14
+ /** Record and tell one incident. */
15
+ export declare function gitIncident(input: {
16
+ checkout: string;
17
+ repository: string;
18
+ swapped: GitIncident["swapped"];
19
+ record: string;
20
+ }): GitIncident;
21
+ /** What noticeGitIncidents needs of the store: its durable outbox, and its own project records. */
22
+ export type IncidentOutbox = {
23
+ enqueueNotification(notification: {
24
+ dedupeKey: string;
25
+ kind: string;
26
+ subject: string;
27
+ body: string;
28
+ pushClass?: "attention";
29
+ source?: {
30
+ project: string;
31
+ } | {
32
+ installation: true;
33
+ };
34
+ }, now: Date): boolean;
35
+ listProjects(): {
36
+ path: string;
37
+ name: string;
38
+ }[];
39
+ };
40
+ /**
41
+ * Every git incident from now on raises an attention notification in `outbox` (the console and the background worker
42
+ * register their store as they start; once per store): "Git incident in <project>: a folder of its repository changed
43
+ * while Toolroll's git ran". Nothing the agent chose reaches the text (paths in an agent-written repository can carry
44
+ * newlines and words of its own): the project is named only by the plane's own record of it (matched by real path,
45
+ * else "a project"), and the person is pointed to the incident record file, where the paths are. Returns the
46
+ * unsubscribe.
47
+ */
48
+ export declare function noticeGitIncidents(outbox: IncidentOutbox, now?: () => Date, record?: string): () => void;
49
+ export {};
@@ -0,0 +1,69 @@
1
+ /**
2
+ * A git incident: the plane's own git wrote through a view of a repository (teardown-git.ts gitView) while a folder of
3
+ * that repository the view links to was swapped for a link elsewhere, or its git pointers changed (the residual the
4
+ * view can't prevent: git takes paths, and a build that outlived its turn can still rename a folder of the shared
5
+ * `.git` mid-command). Seen after git ran, it is:
6
+ *
7
+ * - recorded, one JSON line in `~/.toolroll/git-incidents.jsonl` (beside the fenced views folder, git-views.ts);
8
+ * - told to every listener (the console's, when it registers one; otherwise the plane's stderr);
9
+ * - and the command is reported refused, so no caller takes its result as done.
10
+ *
11
+ * Nothing is rolled back automatically: where a swapped folder leads is the agent's choice, and the plane never runs
12
+ * git there on its say (a confused deputy); what git wrote is for a person, named in the record.
13
+ */
14
+ import { appendFileSync, realpathSync } from "node:fs";
15
+ import { basename, dirname } from "node:path";
16
+ const listeners = new Set();
17
+ /** Hear about every git incident (returns the unsubscribe). With none registered, incidents go to stderr. */
18
+ export function onGitIncident(listener) {
19
+ listeners.add(listener);
20
+ return () => listeners.delete(listener);
21
+ }
22
+ /** Record and tell one incident. */
23
+ export function gitIncident(input) {
24
+ const incident = { at: new Date().toISOString(), checkout: input.checkout, repository: input.repository, swapped: input.swapped };
25
+ try {
26
+ appendFileSync(input.record, `${JSON.stringify(incident)}\n`, { mode: 0o600 });
27
+ }
28
+ catch { /* still told */ }
29
+ if (listeners.size === 0)
30
+ process.stderr.write(`Toolroll git incident: ${JSON.stringify(incident)}\n`);
31
+ for (const listener of listeners) {
32
+ try {
33
+ listener(incident);
34
+ }
35
+ catch { /* every listener hears it */ }
36
+ }
37
+ return incident;
38
+ }
39
+ const noticed = new WeakSet();
40
+ /**
41
+ * Every git incident from now on raises an attention notification in `outbox` (the console and the background worker
42
+ * register their store as they start; once per store): "Git incident in <project>: a folder of its repository changed
43
+ * while Toolroll's git ran". Nothing the agent chose reaches the text (paths in an agent-written repository can carry
44
+ * newlines and words of its own): the project is named only by the plane's own record of it (matched by real path,
45
+ * else "a project"), and the person is pointed to the incident record file, where the paths are. Returns the
46
+ * unsubscribe.
47
+ */
48
+ export function noticeGitIncidents(outbox, now = () => new Date(), record = "~/.toolroll/git-incidents.jsonl") {
49
+ if (noticed.has(outbox))
50
+ return () => { };
51
+ noticed.add(outbox);
52
+ const stop = onGitIncident(incident => {
53
+ const main = basename(incident.repository) === ".git" ? dirname(incident.repository) : incident.repository;
54
+ const real = (path) => { try {
55
+ return realpathSync(path);
56
+ }
57
+ catch {
58
+ return null;
59
+ } };
60
+ const known = outbox.listProjects().find(project => real(project.path) !== null && real(project.path) === real(main)) ?? null;
61
+ outbox.enqueueNotification({
62
+ dedupeKey: `git-incident:${incident.at}:${known?.path ?? "unrecorded"}`, kind: "git-incident", pushClass: "attention",
63
+ source: known === null ? { installation: true } : { project: known.path },
64
+ subject: `Git incident in ${known === null ? "a project" : known.name}: a folder of its repository changed while Toolroll's git ran`,
65
+ body: `Toolroll stopped trusting that git command and changed nothing more. Nothing was rolled back: check the repository by hand. The checkout and the folders that changed are in ${record}.`,
66
+ }, now());
67
+ });
68
+ return () => { stop(); noticed.delete(outbox); };
69
+ }
@@ -0,0 +1,20 @@
1
+ /** The views folder under `home`. */
2
+ export declare function gitViewsRoot(home: string): string;
3
+ /**
4
+ * The views folder under `home`, made if it isn't there (owner-only), and proven to be this user's own real folder all
5
+ * the way from `home`: neither it nor `~/.toolroll` may be a link or anyone else's. Throws otherwise.
6
+ */
7
+ export declare function ensureGitViewsRoot(home: string): string;
8
+ /**
9
+ * One plane git command at a time on a checkout (Codex review of a0fa135d): each command's private GIT_DIR has its own
10
+ * HEAD, so two at once would each take their own HEAD lock and the later carry-back would undo the earlier. The lock
11
+ * is a file the plane makes exclusively in the fenced views folder, named by the checkout's real path, holding this
12
+ * process's id; one whose process is gone is taken over. Waits up to `timeoutMs`; returns the release.
13
+ */
14
+ export declare function lockCheckout(home: string, checkout: string, timeoutMs?: number): Promise<() => void>;
15
+ /**
16
+ * Run `work` holding the checkout's lock (lockCheckout). Re-entrant along one call chain: a hardened git nested in
17
+ * another on the same checkout (teardown's git run through the builder's hardened runner, a view within a view) runs
18
+ * under the lock its caller already holds instead of waiting on itself.
19
+ */
20
+ export declare function withCheckoutLock<T>(home: string, checkout: string, work: () => Promise<T>): Promise<T>;
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Where the plane makes its private views of a repository for git (teardown-git.ts gitView): a folder of its own under
3
+ * the home folder's `~/.toolroll`, which every fence denies to builds, read and write (agent-fence.ts agentFence names
4
+ * this folder by name, made first so no build can be the one to create it). A view in the system temp folder could be
5
+ * rewritten by a build that writes anywhere and outlived its turn (Codex review of e48e9d8d).
6
+ */
7
+ import { AsyncLocalStorage } from "node:async_hooks";
8
+ import { createHash } from "node:crypto";
9
+ import { closeSync, constants as fsConstants, lstatSync, mkdirSync, openSync, readFileSync, rmSync, writeSync } from "node:fs";
10
+ import { join } from "node:path";
11
+ /** The views folder under `home`. */
12
+ export function gitViewsRoot(home) {
13
+ return join(home, ".toolroll", "git-views");
14
+ }
15
+ /**
16
+ * The views folder under `home`, made if it isn't there (owner-only), and proven to be this user's own real folder all
17
+ * the way from `home`: neither it nor `~/.toolroll` may be a link or anyone else's. Throws otherwise.
18
+ */
19
+ export function ensureGitViewsRoot(home) {
20
+ const uid = process.getuid?.();
21
+ const base = join(home, ".toolroll");
22
+ const root = gitViewsRoot(home);
23
+ for (const dir of [base, root]) {
24
+ try {
25
+ mkdirSync(dir, { mode: 0o700 });
26
+ }
27
+ catch (error) {
28
+ if (error.code !== "EEXIST")
29
+ throw error;
30
+ }
31
+ const held = lstatSync(dir);
32
+ if (!held.isDirectory() || held.isSymbolicLink() || (uid !== undefined && held.uid !== uid))
33
+ throw new Error(`${dir} isn't a folder of this user's own`);
34
+ }
35
+ return root;
36
+ }
37
+ /**
38
+ * One plane git command at a time on a checkout (Codex review of a0fa135d): each command's private GIT_DIR has its own
39
+ * HEAD, so two at once would each take their own HEAD lock and the later carry-back would undo the earlier. The lock
40
+ * is a file the plane makes exclusively in the fenced views folder, named by the checkout's real path, holding this
41
+ * process's id; one whose process is gone is taken over. Waits up to `timeoutMs`; returns the release.
42
+ */
43
+ export async function lockCheckout(home, checkout, timeoutMs = 10 * 60_000) {
44
+ const folder = join(ensureGitViewsRoot(home), "locks");
45
+ try {
46
+ mkdirSync(folder, { mode: 0o700 });
47
+ }
48
+ catch (error) {
49
+ if (error.code !== "EEXIST")
50
+ throw error;
51
+ }
52
+ const held = lstatSync(folder);
53
+ if (!held.isDirectory() || held.isSymbolicLink())
54
+ throw new Error(`${folder} isn't a folder of this user's own`);
55
+ const path = join(folder, `${createHash("sha256").update(checkout).digest("hex")}.lock`);
56
+ const deadline = Date.now() + timeoutMs;
57
+ for (;;) {
58
+ try {
59
+ const fd = openSync(path, fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL | fsConstants.O_NOFOLLOW, 0o600);
60
+ try {
61
+ writeSync(fd, `${process.pid}\n`);
62
+ }
63
+ finally {
64
+ closeSync(fd);
65
+ }
66
+ let released = false;
67
+ return () => { if (!released) {
68
+ released = true;
69
+ rmSync(path, { force: true });
70
+ } };
71
+ }
72
+ catch (error) {
73
+ if (error.code !== "EEXIST")
74
+ throw error;
75
+ }
76
+ // Held: by a live process, wait; by one that is gone, take it over.
77
+ let owner = NaN;
78
+ try {
79
+ owner = Number(readFileSync(path, "utf8").trim());
80
+ }
81
+ catch {
82
+ owner = NaN;
83
+ }
84
+ if (Number.isSafeInteger(owner) && owner > 0 && owner !== process.pid && !alive(owner)) {
85
+ rmSync(path, { force: true });
86
+ continue;
87
+ }
88
+ if (Date.now() > deadline)
89
+ throw new Error("another of Toolroll's git commands on this checkout is still running");
90
+ await new Promise(done => setTimeout(done, 25));
91
+ }
92
+ }
93
+ function alive(pid) {
94
+ try {
95
+ process.kill(pid, 0);
96
+ return true;
97
+ }
98
+ catch (error) {
99
+ return error.code === "EPERM";
100
+ }
101
+ }
102
+ /** The checkouts the current async call chain already holds (lockCheckout through withCheckoutLock). */
103
+ const heldHere = new AsyncLocalStorage();
104
+ /**
105
+ * Run `work` holding the checkout's lock (lockCheckout). Re-entrant along one call chain: a hardened git nested in
106
+ * another on the same checkout (teardown's git run through the builder's hardened runner, a view within a view) runs
107
+ * under the lock its caller already holds instead of waiting on itself.
108
+ */
109
+ export async function withCheckoutLock(home, checkout, work) {
110
+ const held = heldHere.getStore() ?? new Set();
111
+ if (held.has(checkout))
112
+ return work();
113
+ const release = await lockCheckout(home, checkout);
114
+ try {
115
+ return await heldHere.run(new Set([...held, checkout]), work);
116
+ }
117
+ finally {
118
+ release();
119
+ }
120
+ }
package/dist/grant.js CHANGED
@@ -178,7 +178,9 @@ async function observedByGit(repo, paths, runner) {
178
178
  for (const path of local) {
179
179
  const asked = [path, `${normalise(path)}/`];
180
180
  const ignored = await Promise.all(asked.map(async (candidate) => {
181
- const result = await runner("git", ["--no-optional-locks", "check-ignore", "-q", candidate], {
181
+ // check-ignore reads the index, so a planted fsmonitor would run: off (and hooks, lazy fetch) on the command line,
182
+ // which wins over any config the repository's builds can write.
183
+ const result = await runner("git", ["--no-optional-locks", "--no-lazy-fetch", "-c", "core.fsmonitor=false", "-c", "core.hooksPath=/dev/null", "check-ignore", "-q", candidate], {
182
184
  cwd: repo,
183
185
  });
184
186
  return result.code === 0;