toolroll 0.9.72 → 0.9.73

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 (63) hide show
  1. package/CHANGELOG.md +76 -0
  2. package/dist/agent-fence.d.ts +5 -0
  3. package/dist/agent-fence.js +42 -7
  4. package/dist/browser/workspace.js +28 -28
  5. package/dist/chat-actions.js +13 -8
  6. package/dist/chat-decide.d.ts +47 -1
  7. package/dist/chat-decide.js +170 -4
  8. package/dist/check-report.js +2 -1
  9. package/dist/coding-provider.js +4 -2
  10. package/dist/commit-extract.d.ts +17 -0
  11. package/dist/commit-extract.js +82 -4
  12. package/dist/contracts/console-api.d.ts +3 -3
  13. package/dist/contracts/console-api.js +4 -4
  14. package/dist/contracts/release-terms.d.ts +7 -1
  15. package/dist/contracts/release-terms.js +6 -2
  16. package/dist/exec.d.ts +1 -1
  17. package/dist/exec.js +7 -3
  18. package/dist/flow-gallery-ui.js +4 -2
  19. package/dist/flow-gallery.d.ts +3 -0
  20. package/dist/flow-gallery.js +6 -2
  21. package/dist/flow-triggers.d.ts +8 -0
  22. package/dist/flow-triggers.js +15 -0
  23. package/dist/flows-cli.js +13 -3
  24. package/dist/guides.js +10 -1
  25. package/dist/kits-ui.js +4 -2
  26. package/dist/kits.d.ts +6 -4
  27. package/dist/kits.js +9 -11
  28. package/dist/mcp-connect.d.ts +10 -0
  29. package/dist/mcp-connect.js +2 -0
  30. package/dist/project-tools.js +3 -1
  31. package/dist/protected-storage.d.ts +67 -0
  32. package/dist/protected-storage.js +398 -0
  33. package/dist/release-adapters.d.ts +8 -1
  34. package/dist/release-adapters.js +80 -14
  35. package/dist/release-authority.d.ts +6 -0
  36. package/dist/release-authority.js +9 -0
  37. package/dist/release-card.d.ts +76 -0
  38. package/dist/release-card.js +252 -0
  39. package/dist/release-carry.d.ts +3 -0
  40. package/dist/release-carry.js +23 -4
  41. package/dist/release-cli.js +36 -3
  42. package/dist/release-git.d.ts +43 -0
  43. package/dist/release-git.js +106 -0
  44. package/dist/release.d.ts +8 -2
  45. package/dist/release.js +79 -6
  46. package/dist/scope.d.ts +7 -0
  47. package/dist/scope.js +22 -5
  48. package/dist/server/flows.js +40 -10
  49. package/dist/server/render-settings.js +1 -1
  50. package/dist/server/settings.js +26 -5
  51. package/dist/setup-guide.d.ts +2 -2
  52. package/dist/store.d.ts +3 -0
  53. package/dist/store.js +6 -0
  54. package/dist/subagent-memory.d.ts +1 -0
  55. package/dist/subagent-memory.js +1 -1
  56. package/dist/subagent-tools.d.ts +22 -6
  57. package/dist/subagent-tools.js +44 -8
  58. package/dist/subagent-work.d.ts +2 -0
  59. package/dist/subagent-work.js +5 -2
  60. package/dist/subagents-ui.js +6 -4
  61. package/dist/telegram.d.ts +6 -1
  62. package/dist/telegram.js +58 -6
  63. package/package.json +1 -1
@@ -13,6 +13,7 @@ export declare const releasePolicySchema: z.ZodObject<{
13
13
  files: z.ZodInt;
14
14
  churn: z.ZodInt;
15
15
  }, z.core.$strict>;
16
+ unsandboxedCarry: z.ZodOptional<z.ZodBoolean>;
16
17
  }, z.core.$strict>;
17
18
  export type ReleasePolicy = z.infer<typeof releasePolicySchema>;
18
19
  export declare const releaseTermsSchema: z.ZodObject<{
@@ -35,12 +36,17 @@ export declare const releaseTermsSchema: z.ZodObject<{
35
36
  files: z.ZodInt;
36
37
  churn: z.ZodInt;
37
38
  }, z.core.$strict>;
39
+ unsandboxedCarry: z.ZodOptional<z.ZodBoolean>;
38
40
  }, z.core.$strict>;
39
41
  grant: z.ZodObject<{
40
42
  id: z.ZodInt;
41
43
  digest: z.ZodString;
42
44
  }, z.core.$strict>;
43
- access: z.ZodLiteral<"sandboxed">;
45
+ access: z.ZodEnum<{
46
+ sandboxed: "sandboxed";
47
+ "sign-ins": "sign-ins";
48
+ unsandboxed: "unsandboxed";
49
+ }>;
44
50
  expiresAt: z.ZodISODateTime;
45
51
  }, z.core.$strict>;
46
52
  export type ReleaseTerms = z.infer<typeof releaseTermsSchema>;
@@ -17,6 +17,9 @@ export const releasePolicySchema = versioned(1, {
17
17
  protectProject: z.boolean(),
18
18
  protectedPaths: z.array(z.string().min(1).max(400)).max(200),
19
19
  limits: z.strictObject({ lines: z.int().min(0).max(1_000_000), files: z.int().min(0).max(100_000), churn: z.int().min(0).max(1_000_000) }),
20
+ /** "Carry release approvals when checks aren't sandboxed" (a person's project setting, off by default). Absent (a
21
+ * policy sealed before it existed): off. */
22
+ unsandboxedCarry: z.boolean().optional(),
20
23
  });
21
24
  const sha = z.string().regex(/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/);
22
25
  export const releaseTermsSchema = versioned(1, {
@@ -29,8 +32,9 @@ export const releaseTermsSchema = versioned(1, {
29
32
  approvedTree: sha,
30
33
  policy: releasePolicySchema,
31
34
  grant: z.strictObject({ id: z.int(), digest: z.string().min(1).max(128) }),
32
- /** The check access the release check runs with: only the fence ever carries. */
33
- access: z.literal("sandboxed"),
35
+ /** The check access the release check runs with at approval: the fence, or (only with unsandboxedCarry sealed in the
36
+ * policy) a wider one. Never wider later: a check that reaches further than this stops the release. */
37
+ access: z.enum(["sandboxed", "sign-ins", "unsandboxed"]),
34
38
  expiresAt: z.iso.datetime(),
35
39
  });
36
40
  export function readReleaseTerms(input) {
package/dist/exec.d.ts CHANGED
@@ -200,7 +200,7 @@ export declare function confined(file: string, args: readonly string[], bag: {
200
200
  env: Record<string, string>;
201
201
  };
202
202
  /** A spawn's environment with what its sandbox needs added (nothing added: the caller's, as it was). */
203
- export declare function withSandboxEnv<T extends Record<string, string | undefined>>(env: T | undefined, added: Record<string, string>): T | Record<string, string | undefined> | undefined;
203
+ export declare function withSandboxEnv<T extends Record<string, string | undefined>>(env: T | undefined, added: Record<string, string>, fenced?: boolean): T | Record<string, string | undefined> | undefined;
204
204
  /** Run IDs are local to a database. Connections to the same file share
205
205
  * custody; separate files and separate in-memory databases never do. */
206
206
  export declare function runOwnerTag(store: Store, runId: number): string;
package/dist/exec.js CHANGED
@@ -8,6 +8,7 @@
8
8
  */
9
9
  import { ACP_LINE_CAP, createAcpConversation } from "./acp-client.js";
10
10
  import { linuxFenceAvailable, macosFenceAvailable, sandboxedSpawn } from "./agent-fence.js";
11
+ import { withoutProtectedEnv } from "./protected-storage.js";
11
12
  import { execFile, execFileSync, spawn } from "node:child_process";
12
13
  import { readlinkSync, realpathSync } from "node:fs";
13
14
  import { observeProcessTree, stopProcessTree } from "./process-tree.js";
@@ -75,7 +76,10 @@ export function confined(file, args, bag) {
75
76
  return { file, args: [...args], env: {} };
76
77
  }
77
78
  /** A spawn's environment with what its sandbox needs added (nothing added: the caller's, as it was). */
78
- export function withSandboxEnv(env, added) {
79
+ export function withSandboxEnv(env, added, fenced = false) {
80
+ // A fenced launch never gets a chat or vault variable (protected-storage.ts), whoever made its environment.
81
+ if (fenced)
82
+ return withoutProtectedEnv({ ...(env ?? process.env), ...added });
79
83
  return Object.keys(added).length === 0 ? env : { ...(env ?? process.env), ...added };
80
84
  }
81
85
  /** A spawn callback refused custody AFTER the child existed: the caller
@@ -107,7 +111,7 @@ function spawnContained(file, args, spawnOptions, label, bag, contain = true) {
107
111
  // The agent fence wraps the target itself (sandbox-exec then execs it, same pid).
108
112
  const sandboxed = confined(file, args, { ...bag, cwd: spawnOptions.cwd, temp: spawnOptions.env?.["TMPDIR"] });
109
113
  ({ file, args } = sandboxed);
110
- spawnOptions = { ...spawnOptions, env: withSandboxEnv(spawnOptions.env, sandboxed.env) };
114
+ spawnOptions = { ...spawnOptions, env: withSandboxEnv(spawnOptions.env, sandboxed.env, (bag.fence?.length ?? 0) > 0) };
111
115
  // The policy refusal comes first: nothing is reserved, recorded or
112
116
  // spawned for a spawn that cannot be contained as required.
113
117
  const effective = contain ? currentContainment() : null;
@@ -583,7 +587,7 @@ export function run(file, args, options = {}) {
583
587
  encoding: "utf8",
584
588
  shell: false,
585
589
  windowsHide: true,
586
- ...((env => env === undefined ? {} : { env }))(withSandboxEnv(childEnv, wrapped.env)),
590
+ ...((env => env === undefined ? {} : { env }))(withSandboxEnv(childEnv, wrapped.env, (options.fence?.length ?? 0) > 0)),
587
591
  }, (error, stdout, stderr) => {
588
592
  if (error === null) {
589
593
  resolve({ result: { code: 0, stdout, stderr, timedOut: false, notFound: false }, transient: false });
@@ -5,7 +5,7 @@
5
5
  * A template's tools show their marks and whether the project has them; one it lacks offers Connect first.
6
6
  */
7
7
  import { brandMarkHtml } from "./brand-mark.js";
8
- import { BLANK, GALLERY, GALLERY_GROUPS, galleryDiagram, galleryToolsOf, OUTDATED_COMMANDS, SEND_RESULT, sendsAlready } from "./flow-gallery.js";
8
+ import { BLANK, GALLERY, GALLERY_GROUPS, galleryAdmitsAnyone, galleryDiagram, galleryToolsOf, OUTDATED_COMMANDS, SEND_RESULT, sendsAlready } from "./flow-gallery.js";
9
9
  import { choiceTargets } from "./flows.js";
10
10
  import { html, joinHtml, postForm, reauth } from "./html.js";
11
11
  export const GALLERY_CSS = `.gallery{max-width:1120px;min-width:0}.gallery h2{margin:28px 0 10px;font-size:.9375rem}.gallery h2:first-of-type{margin-top:8px}` +
@@ -107,7 +107,9 @@ export function galleryUseHtml(input) {
107
107
  : html `<input type="hidden" name="repo" value="${input.repo}">`;
108
108
  const preview = input.preview === null ? "" : html `<section class="gallery-preview" aria-labelledby="gallery-will"><h2 id="gallery-will">What it will do</h2><ul>${input.preview.built.does.map(one => html `<li>${one}</li>`)}</ul><p class="gallery-never">${input.preview.built.never}</p>${needing}${input.preview.startsFrom.length === 0 ? html `<p class="meta">Cards start when you add them.</p>` : input.preview.startsFrom.map(one => html `<p class="meta">Starts from: ${one}</p>`)}<details><summary>Every step</summary><ol>${input.preview.steps.map(one => html `<li>${one.replace(/^\d+\.\s/, "")}</li>`)}</ol></details></section>`;
109
109
  const sendResult = !sendsAlready(template) && html `<label class="gallery-check"><input type="checkbox" name="${SEND_RESULT.key}" value="yes"${input.answers[SEND_RESULT.key] === "yes" && html ` checked`} data-send-result><span>${SEND_RESULT.title}<small>When a card finishes, what was done comes to you in your chat apps.</small></span></label>`;
110
- const actions = html `<p class="gallery-actions">${input.preview !== null && html `<button type="submit" name="intent" value="create"${missing.length > 0 && html ` class="secondary" data-without-tools`}>Create flow</button>`}<button type="submit" name="intent" value="preview" class="secondary">${input.preview === null ? "Preview" : "Update preview"}</button></p>`;
110
+ // A flow that brings in items from anyone takes the password to make (chat-management.md §0a.3).
111
+ const step = input.preview !== null && galleryAdmitsAnyone(template) ? reauth(html `<label>Your password <span class="meta">(it brings in items from anyone)</span><input type="password" name="password" autocomplete="current-password"></label>`) : "";
112
+ const actions = html `${step}<p class="gallery-actions">${input.preview !== null && html `<button type="submit" name="intent" value="create"${missing.length > 0 && html ` class="secondary" data-without-tools`}>Create flow</button>`}<button type="submit" name="intent" value="preview" class="secondary">${input.preview === null ? "Preview" : "Update preview"}</button></p>`;
111
113
  const form = postForm(`/flows/new/${template.id}`, html `${project}${template.asks.map(field)}<label>Name<input name="name" value="${input.name}" maxlength="80"></label>${sendResult}${preview}${actions}`, { attrs: { "data-gallery-use": true }, hidden: { previewed: input.preview?.digest ?? "" } });
112
114
  return html `<section class="gallery-use" data-gallery-template="${template.id}">${input.problem !== null && html `<p class="problem" role="alert">${input.problem}</p>`}${input.said ? html `<p role="status">${input.said}</p>` : ""}<p>${template.promise}</p>${input.diagram !== null && stepStrip(input.diagram)}${needsList(template)}${toolsBlock}${form}<p class="meta"><a href="/flows/new">All templates</a></p></section>`;
113
115
  }
@@ -136,9 +136,12 @@ export type GalleryUsed = {
136
136
  said: string;
137
137
  };
138
138
  /** Use a template in a project: its scripts, its flow and its triggers, together or not at all. */
139
+ /** Whether a template's triggers bring in items from anyone: making it takes the person's password. */
140
+ export declare function galleryAdmitsAnyone(template: GalleryTemplate): boolean;
139
141
  export declare function useGalleryTemplate(store: Store, template: GalleryTemplate, repo: string, given: GalleryAnswers, input: {
140
142
  name?: string | null;
141
143
  by: string;
142
144
  now: Date;
143
145
  dir: string | null;
146
+ widening?: "proved";
144
147
  }): GalleryUsed;
@@ -20,7 +20,7 @@ import { scanForSecrets } from "./evidence.js";
20
20
  import { flowDefinitionOf } from "./flow-engine.js";
21
21
  import { saveScript, validateScript } from "./flow-scripts.js";
22
22
  import { isToolrollRepo, planeReviewSteps } from "./flow-starters.js";
23
- import { addFlowTriggerTo, describeTrigger, githubRepoOf, scheduleFromWords, validateTriggerConfig } from "./flow-triggers.js";
23
+ import { addFlowTriggerTo, describeTrigger, githubRepoOf, rawAdmitsAnyone, scheduleFromWords, validateTriggerConfig } from "./flow-triggers.js";
24
24
  import { choiceTargets, clockTime, flowFromSteps, flowTerms, FLOW_TEMPLATES, ISSUE_LABEL } from "./flows.js";
25
25
  import { connectionsOf, localConnectOf, oneClickOf } from "./mcp-connect.js";
26
26
  import { publishingOf } from "./pull-request-flow.js";
@@ -640,6 +640,10 @@ export function galleryDigest(id, repo, answers) {
640
640
  return createHash("sha256").update(JSON.stringify([id, repo, Object.entries(answers).sort(([a], [b]) => a.localeCompare(b))])).digest("hex").slice(0, 24);
641
641
  }
642
642
  /** Use a template in a project: its scripts, its flow and its triggers, together or not at all. */
643
+ /** Whether a template's triggers bring in items from anyone: making it takes the person's password. */
644
+ export function galleryAdmitsAnyone(template) {
645
+ return template.triggers.some(one => rawAdmitsAnyone(one));
646
+ }
643
647
  export function useGalleryTemplate(store, template, repo, given, input) {
644
648
  try {
645
649
  const built = buildFromGallery(store, template, repo, given);
@@ -656,7 +660,7 @@ export function useGalleryTemplate(store, template, repo, given, input) {
656
660
  const made = store.getFlow(id);
657
661
  for (const trigger of built.triggers) {
658
662
  // No one is shown the gallery's addresses: a webhook is made with none until its panel makes one (v126).
659
- const added = addFlowTriggerTo(store, made, { ...trigger, zone: trigger["zone"] ?? flowDefinitionOf(made).start }, input.by, input.now, input.dir, { shown: false });
663
+ const added = addFlowTriggerTo(store, made, { ...trigger, zone: trigger["zone"] ?? flowDefinitionOf(made).start }, input.by, input.now, input.dir, { shown: false, ...(input.widening === undefined ? {} : { widening: input.widening }) });
660
664
  if (!added.ok)
661
665
  throw new Error(added.message);
662
666
  if (added.reveal !== null)
@@ -57,6 +57,11 @@ export declare const senderOf: (config: Extract<TriggerConfig, {
57
57
  /** Whether this trigger takes deliveries at a secret address rather than being checked. */
58
58
  /** Whether a raw trigger the caller is about to add would take deliveries (an address): read leniently, before validation. */
59
59
  export declare function rawTakesDeliveries(raw: unknown): boolean;
60
+ /** Whether a trigger brings in items from anyone (chat-management.md §0a.3): GitHub issues or pull requests from anyone,
61
+ * or mail from any sender. A widening: it takes a person's password, on every road that adds one. */
62
+ export declare function admitsAnyone(config: TriggerConfig): boolean;
63
+ /** The same, read leniently from a raw trigger before validation (so the form can ask for the password first). */
64
+ export declare function rawAdmitsAnyone(raw: unknown): boolean;
60
65
  export declare function takesDeliveries(config: TriggerConfig): boolean;
61
66
  /** A webhook's signing secret (made here, or pasted from its sender). In the keychain (or an owner-only file where there's none), never a column. */
62
67
  export declare function saveHookSecret(dir: string, trigger: number, secret: string, credential?: string): void;
@@ -104,6 +109,9 @@ export type TriggerMade = {
104
109
  export declare function addFlowTriggerTo(store: Store, flow: FlowRow, raw: unknown, actor: string, now: Date, dir: string | null, options?: {
105
110
  shown?: boolean;
106
111
  ingress?: "proved";
112
+ /** The person proved themselves for this act (a password, or a session under Passwordless): a trigger that admits
113
+ * anyone is added only then. */
114
+ widening?: "proved";
107
115
  }): TriggerMade;
108
116
  /** A new secret address for a webhook trigger (the old one stops working at once). */
109
117
  export declare function renewFlowHook(store: Store, trigger: FlowTriggerRow, now: Date, dir: string): TriggerMade;
@@ -368,6 +368,18 @@ export function rawTakesDeliveries(raw) {
368
368
  const one = raw;
369
369
  return one["kind"] === "webhook" || ((one["kind"] === "github" || one["kind"] === "linear") && one["delivery"] === "webhook");
370
370
  }
371
+ /** Whether a trigger brings in items from anyone (chat-management.md §0a.3): GitHub issues or pull requests from anyone,
372
+ * or mail from any sender. A widening: it takes a person's password, on every road that adds one. */
373
+ export function admitsAnyone(config) {
374
+ return (config.kind === "github" && config.from === "anyone") || (config.kind === "email" && config.sender === null);
375
+ }
376
+ /** The same, read leniently from a raw trigger before validation (so the form can ask for the password first). */
377
+ export function rawAdmitsAnyone(raw) {
378
+ if (raw === null || typeof raw !== "object")
379
+ return false;
380
+ const one = raw;
381
+ return (one["kind"] === "github" && one["from"] === "anyone") || (one["kind"] === "email" && (typeof one["sender"] !== "string" || one["sender"].trim() === ""));
382
+ }
371
383
  export function takesDeliveries(config) {
372
384
  return config.kind === "webhook" || ((config.kind === "github" || config.kind === "linear") && config.delivery === "webhook");
373
385
  }
@@ -495,6 +507,9 @@ export function addFlowTriggerTo(store, flow, raw, actor, now, dir, options = {}
495
507
  return { ok: false, message: "Webhook addresses are set up on the console." };
496
508
  if (takesDeliveries(config) && config.kind !== "webhook" && options.ingress !== "proved")
497
509
  return { ok: false, message: "Webhook delivery is set up on the flow's trigger panel, with your password." };
510
+ // Items from anyone come in only on a person's proof (chat-management.md §0a.3): never from a card, an import or a template alone.
511
+ if (admitsAnyone(config) && options.ingress !== "proved" && options.widening !== "proved")
512
+ return { ok: false, message: "A trigger that brings in items from anyone takes your password. Add it on the flow's Triggers panel." };
498
513
  // v126: a webhook made where no one is shown its address (the gallery, an import) is made with none: its first
499
514
  // set-up or New address makes one and shows it. A webhook made on its panel is shown its address now.
500
515
  const token = takesDeliveries(config) && options.ingress === "proved" && (options.shown !== false || config.kind !== "webhook") ? randomBytes(24).toString("base64url") : null;
package/dist/flows-cli.js CHANGED
@@ -21,7 +21,8 @@ import { deciderOf, FLOW_KIND_WORDS, FLOW_TEMPLATES, flowDigest, flowFromSteps,
21
21
  import { prepareUnattendedCandidates, activeUnattendedGrant, parseUnattendedAsk, revokeUnattended, signUnattended, unattendedViewOf, unattendedWords, untilWords, UNATTENDED_MAX_RUNS } from './flow-unattended.js';
22
22
  import { MODE_MAX_DAYS } from './modes.js';
23
23
  import { verifyApproverByPassword } from './principal.js';
24
- import { proveCredential } from './scope.js';
24
+ import { authenticateApprover, proveCredential } from './scope.js';
25
+ import { personProof } from './person-proof.js';
25
26
  import { projectName } from './project.js';
26
27
  const EXIT = { ok: 0, failed: 1, usage: 2, refused: 3 };
27
28
  const json = { name: 'json', takesValue: false, meaning: 'answer with one machine envelope on stdout' };
@@ -157,6 +158,12 @@ export async function runFlowsCommand(positional, flags, context) {
157
158
  store.recordAction({ at: context.clock().toISOString(), actor: who, repo: project, taskId: null, runId: null, action: command, outcome, source: 'request', detail: `command line${detail === null ? '' : ` · ${detail}`}` });
158
159
  };
159
160
  const refuse = (project, reason, message, code = EXIT.refused) => { record(project, 'refused', reason); return fail(reason, message, code); };
161
+ // A trigger from anyone widens what comes in (chat-management.md §0a.3): only the person's own proof counts, never a
162
+ // machine credential standing in for them (a remote run's API token, a lead's token), though both pass the check above.
163
+ const widening = (project) => {
164
+ const proved = authenticateApprover(store, acting.name, acting.token, project);
165
+ return proved.ok && personProof(proved.method) ? { widening: 'proved' } : {};
166
+ };
160
167
  const settle = (project) => { try {
161
168
  advanceFlows(store, project, context.clock(), { evidenceRoot: context.evidenceRoot });
162
169
  }
@@ -243,11 +250,13 @@ export async function runFlowsCommand(positional, flags, context) {
243
250
  if (!flags.has('yes'))
244
251
  return preview(project, `Create the ${flowName} flow in ${projectName(project)}`, terms, { definition });
245
252
  let id;
253
+ const proof = template?.trigger === undefined ? {} : widening(project);
246
254
  try {
247
255
  id = store.transact(() => {
248
256
  const made = store.createFlow({ repo: project, name: flowName, definitionJson: JSON.stringify(definition), by: who }, now);
249
257
  if (template?.trigger !== undefined) {
250
- const trigger = addFlowTriggerTo(store, store.getFlow(made), template.trigger, who, now, context.configDir);
258
+ // A trigger from anyone only on the person's own proof (widening above); a machine credential's is refused.
259
+ const trigger = addFlowTriggerTo(store, store.getFlow(made), template.trigger, who, now, context.configDir, proof);
251
260
  if (!trigger.ok)
252
261
  throw new Error(trigger.message);
253
262
  }
@@ -508,7 +517,8 @@ export async function runFlowsCommand(positional, flags, context) {
508
517
  const ingress = rawTakesDeliveries(input.value);
509
518
  if (ingress && !proveCredential(store, acting.name, acting.token).ok)
510
519
  return refuse(flow.repo, 'unauthenticated', 'Making a webhook address takes your password, typed: pass --token.');
511
- const made = addFlowTriggerTo(store, flow, input.value, who, now, context.configDir, ingress ? { ingress: 'proved' } : {});
520
+ // A trigger from anyone only on the person's own proof (widening above): a machine credential's is refused.
521
+ const made = addFlowTriggerTo(store, flow, input.value, who, now, context.configDir, ingress ? { ingress: 'proved' } : widening(flow.repo));
512
522
  if (!made.ok)
513
523
  return refuse(flow.repo, 'invalid-trigger', made.message);
514
524
  await prepareUnattendedCandidates(store, flow.id);
package/dist/guides.js CHANGED
@@ -445,7 +445,13 @@ with a time limit.
445
445
 
446
446
  ## What it does, in order
447
447
 
448
- 1. **gate**: files task \`release-<version>-<sha7>\` pinned to the
448
+ 1. **gate**: before filing, looks at the pull request's checks on the
449
+ branch's exact commit: a required check that failed stops here, before
450
+ anything is filed or sent for approval. Checks not started or still
451
+ running are named and waited for (GitHub not answering is waited out
452
+ too); if they don't finish within the gate's limit (60 min, \`--limit
453
+ gate=<min>\`), it stops here as well. A task already filed for the commit is kept and
454
+ not looked at again. Then it files task \`release-<version>-<sha7>\` pinned to the
449
455
  branch's exact commit with \`--checks full\`, regardless of the project's default.
450
456
  2. **approval**: waits for the owner to approve that exact scope
451
457
  (\`toolroll task approve <id> --digest <d>\`, or the console). A
@@ -499,6 +505,9 @@ instead of making them twice.
499
505
  checks and obtain a passing result; Quick and Off cannot authorize a release.
500
506
  - \`check-failed\`: fix the branch and release the new commit with \`--new\`.
501
507
  - \`ci-failed\`: fix the failed GitHub check, then rerun. Nothing was merged.
508
+ A stop at the gate comes before the task is filed, so nothing was filed
509
+ either: a fix pushed as a new commit is released with \`--new\`; after
510
+ rerunning the failed job on GitHub, run the same command again.
502
511
  - \`publish-failed\` / \`homebrew-failed\`: fix the failed workflow before
503
512
  resuming. A failed tag check cannot finish publication or update Homebrew.
504
513
  - \`base-moved\`: main gained a commit the branch doesn't have, so the
package/dist/kits-ui.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { kitChecklist, kitInstalled, KITS } from "./kits.js";
2
2
  import { SUBAGENT_TEMPLATES } from "./subagents.js";
3
- import { html, postForm } from "./html.js";
3
+ import { html, postForm, reauth } from "./html.js";
4
4
  const CHECK = html `<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M20 6 9 17l-5-5"/></svg>`;
5
5
  const RING = html `<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="8"/></svg>`;
6
6
  export const KITS_CSS = `.kits{max-width:960px;min-width:0}.kits-lede{color:var(--so-muted);margin:.25rem 0 1.5rem;max-width:40rem}.kit-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:16px}` +
@@ -35,7 +35,9 @@ export function kitPageHtml(store, kit, repo, dir, canSetUp, notice) {
35
35
  const rows = steps.map(step => {
36
36
  const go = step.done ? (step.href === null ? "" : html `<a class="kit-step-go" href="${step.href}">Open</a>`)
37
37
  : step.action === "sample" || step.action === "github"
38
- ? (canSetUp ? postForm(`/kits/${kit.id}/${step.action}`, html `<button${step.action === "sample" ? "" : html ` class="secondary"`}>${step.action === "sample" ? "Try it" : "Bring them in"}</button>`, { hidden: { repo } }) : "")
38
+ ? (canSetUp ? postForm(`/kits/${kit.id}/${step.action}`, step.action === "sample" ? html `<button>Try it</button>`
39
+ // Issues from anyone come in: it takes your password (an ordinary step-up; a session does under Passwordless).
40
+ : html `${reauth(html `<input type="password" name="password" autocomplete="current-password" aria-label="Your password" placeholder="Your password">`)}<button class="secondary">Bring them in</button>`, { hidden: { repo } }) : "")
39
41
  : step.href === null ? "" : html `<a class="kit-step-go" href="${step.href}">${step.action === "connect" ? "Connect" : "Set up"}</a>`;
40
42
  return html `<li data-step="${step.id}" data-done="${String(step.done)}">${step.done ? CHECK : RING}<span class="kit-step-said">${step.said}</span>${go}</li>`;
41
43
  });
package/dist/kits.d.ts CHANGED
@@ -53,9 +53,11 @@ export declare function kitInstalled(store: Store, kit: Kit, repo: string): {
53
53
  * flow, and its button triggers; then its subagent may use whichever of the
54
54
  * kit's tools the project already has (reading free, writing asking first).
55
55
  */
56
- export declare function setUpKit(store: Store, kit: Kit, repo: string, by: string, now: Date, dir: string | null, io?: ToolIo): Promise<Done>;
57
- /** The same, all at once (a chat confirmation can't wait on a tool): its subagent is given the kit's connected tools by the names their last test found, and each is listed properly on its first turn. */
58
- export declare function setUpKitNow(store: Store, kit: Kit, repo: string, by: string, now: Date, dir: string | null, grantByName?: boolean): Done;
56
+ export declare function setUpKit(store: Store, kit: Kit, repo: string, by: string, now: Date, dir: string | null, io?: ToolIo, options?: {
57
+ widening?: "proved";
58
+ }): Promise<Done>;
59
+ /** The same, all at once and without its tools (a chat confirmation is never a grant's proof): its subagent, its flow and its buttons. */
60
+ export declare function setUpKitNow(store: Store, kit: Kit, repo: string, by: string, now: Date, dir: string | null): Done;
59
61
  /** One line of a kit's checklist: done, or the next step with where to take it. */
60
62
  export type KitStep = {
61
63
  id: string;
@@ -76,7 +78,7 @@ export declare function addKitSample(store: Store, kit: Kit, repo: string, by: s
76
78
  said: string;
77
79
  };
78
80
  /** Bring new issues from the project's GitHub repository into the kit's flow (issues from anyone, polled). */
79
- export declare function addKitGithubTrigger(store: Store, kit: Kit, repo: string, by: string, now: Date, dir: string | null): {
81
+ export declare function addKitGithubTrigger(store: Store, kit: Kit, repo: string, by: string, now: Date, dir: string | null, proved?: "proved" | null): {
80
82
  ok: true;
81
83
  said: string;
82
84
  } | {
package/dist/kits.js CHANGED
@@ -17,7 +17,7 @@ import { readEmailSettings } from "./email-settings.js";
17
17
  import { mailboxReady } from "./mailbox.js";
18
18
  import { projectToolsOf } from "./project-tools.js";
19
19
  import { createSubagentFrom, nameOf } from "./subagent-admin.js";
20
- import { grantListed, grantTool } from "./subagent-tools.js";
20
+ import { grantTool } from "./subagent-tools.js";
21
21
  import { SUBAGENT_TEMPLATES } from "./subagents.js";
22
22
  export const KITS = [
23
23
  {
@@ -115,22 +115,23 @@ export function kitInstalled(store, kit, repo) {
115
115
  * flow, and its button triggers; then its subagent may use whichever of the
116
116
  * kit's tools the project already has (reading free, writing asking first).
117
117
  */
118
- export async function setUpKit(store, kit, repo, by, now, dir, io = {}) {
118
+ export async function setUpKit(store, kit, repo, by, now, dir, io = {}, options = {}) {
119
119
  const made = setUpKitNow(store, kit, repo, by, now, dir);
120
- if (!made.ok || made.said.endsWith("already set up here."))
120
+ // Its tools only with the person's proof (chat-management.md §0a.3); without it, the checklist says to let it use each.
121
+ if (!made.ok || made.said.endsWith("already set up here.") || options.widening !== "proved")
121
122
  return made;
122
123
  const mate = store.getSubagent(made.subagent);
123
124
  const present = new Set(projectToolsOf(store, repo).map(one => one.name));
124
125
  for (const tool of kit.tools.filter(one => present.has(one.tool))) {
125
126
  try {
126
- await grantTool(store, mate, tool.tool, by, now, io);
127
+ await grantTool(store, mate, tool.tool, by, now, io, options);
127
128
  }
128
129
  catch { /* it can be given the tool on its page */ }
129
130
  }
130
131
  return made;
131
132
  }
132
- /** The same, all at once (a chat confirmation can't wait on a tool): its subagent is given the kit's connected tools by the names their last test found, and each is listed properly on its first turn. */
133
- export function setUpKitNow(store, kit, repo, by, now, dir, grantByName = false) {
133
+ /** The same, all at once and without its tools (a chat confirmation is never a grant's proof): its subagent, its flow and its buttons. */
134
+ export function setUpKitNow(store, kit, repo, by, now, dir) {
134
135
  const had = kitInstalled(store, kit, repo);
135
136
  if (had !== null)
136
137
  return { ok: true, said: `${kit.name} is already set up here.`, flow: had.flow.id, subagent: had.mate.id };
@@ -155,9 +156,6 @@ export function setUpKitNow(store, kit, repo, by, now, dir, grantByName = false)
155
156
  const flow = store.getFlow(flowId);
156
157
  for (const button of kit.buttons)
157
158
  addFlowTriggerTo(store, flow, { kind: "button", label: button.label, questions: button.questions, zone: definition.start }, by, now, dir);
158
- if (grantByName)
159
- for (const tool of projectToolsOf(store, repo).filter(one => kit.tools.some(each => each.tool === one.name)))
160
- grantListed(store, mate, tool, null, by, now);
161
159
  return { ok: true, said: `${kit.name} is ready: ${nameOf(mate)} works its ${kit.flowName} flow.`, flow: flowId, subagent: mate.id };
162
160
  }
163
161
  /** What's set up and what's left, read fresh each time. */
@@ -205,11 +203,11 @@ export function addKitSample(store, kit, repo, by, now) {
205
203
  return added.ok ? { ok: true, href: flowCardHref(set.flow.id, added.card) } : { ok: false, said: added.message };
206
204
  }
207
205
  /** Bring new issues from the project's GitHub repository into the kit's flow (issues from anyone, polled). */
208
- export function addKitGithubTrigger(store, kit, repo, by, now, dir) {
206
+ export function addKitGithubTrigger(store, kit, repo, by, now, dir, proved = null) {
209
207
  const set = kitInstalled(store, kit, repo);
210
208
  const github = githubRepoOf(repo);
211
209
  if (set === null || github === null)
212
210
  return { ok: false, said: "This project isn't on GitHub." };
213
- const made = addFlowTriggerTo(store, set.flow, { kind: "github", repo: github, watch: "issues", from: "anyone", delivery: "poll", zone: flowDefinitionOf(set.flow)?.start }, by, now, dir);
211
+ const made = addFlowTriggerTo(store, set.flow, { kind: "github", repo: github, watch: "issues", from: "anyone", delivery: "poll", zone: flowDefinitionOf(set.flow)?.start }, by, now, dir, proved === null ? {} : { widening: proved });
214
212
  return made.ok ? { ok: true, said: `New issues on ${github} now come into ${kit.flowName}.` } : { ok: false, said: made.message };
215
213
  }
@@ -117,6 +117,14 @@ export type ConnectVisit = {
117
117
  scope?: string | null;
118
118
  /** Where the service revokes a token, when its metadata said: a run's own token is revoked there when the run ends. */
119
119
  revoke?: string | null;
120
+ /** The signer's account generation when the password was typed at Connect: a rotation or revocation since ends the visit. */
121
+ generation?: number;
122
+ /** From a kit: the one grant the password at Connect proved (its subagent, this tool, this project); nothing else. */
123
+ grant?: {
124
+ subagent: number;
125
+ tool: string;
126
+ repo: string;
127
+ } | null;
120
128
  };
121
129
  /** A server's sign-in: the resource metadata its 401 names (or the well-known one), then its authorization server's metadata. */
122
130
  export declare function discoverSignIn(mcpUrl: string, fetcher?: Fetch): Promise<Server | null>;
@@ -154,6 +162,8 @@ export declare function finishConnect(store: Store, visit: ConnectVisit, code: s
154
162
  home?: string | undefined;
155
163
  test?: boolean;
156
164
  omitEnv?: readonly string[];
165
+ /** The signer's standing, checked again after the token answer and before anything changes. */
166
+ stillStanding?: () => boolean;
157
167
  }): Promise<{
158
168
  ok: true;
159
169
  said: string;
@@ -351,6 +351,8 @@ export async function finishConnect(store, visit, code, now, options = {}) {
351
351
  const tokens = tokensFrom(body, now.getTime());
352
352
  if (tokens.access === null)
353
353
  return { ok: false, said: `${service.label} didn't send a sign-in token.` };
354
+ if (options.stillStanding !== undefined && !options.stillStanding())
355
+ return { ok: false, said: "Your access changed during the sign-in, so nothing was connected. Connect again." };
354
356
  if (had === undefined) {
355
357
  const added = addToolTo(store, visit.repo, spec, `${service.label}, connected by signing in`, visit.by, now, { home });
356
358
  if (!added.ok)
@@ -22,6 +22,7 @@
22
22
  import { toolRefusal } from "./policy.js";
23
23
  import { createHash } from "node:crypto";
24
24
  import { spawn } from "node:child_process";
25
+ import { isProtectedEnvName } from "./protected-storage.js";
25
26
  import { chmodSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
26
27
  import { homedir, tmpdir } from "node:os";
27
28
  import { realpathSync } from "node:fs";
@@ -572,7 +573,8 @@ function exchangeStdio(spec, values, timeoutMs, base, omit, request, fence) {
572
573
  let child;
573
574
  try {
574
575
  const launch = confined(spec.command, spec.args, { ...(fence ?? {}), temp: env["TMPDIR"] });
575
- child = spawn(launch.file, launch.args, { env: { ...env, ...launch.env }, stdio: ["pipe", "pipe", "pipe"], detached: process.platform !== "win32" });
576
+ // A fenced server gets no chat or vault variable (protected-storage.ts) but the tool's own secrets, set by name.
577
+ child = spawn(launch.file, launch.args, { env: fence === null ? { ...env, ...launch.env } : { ...Object.fromEntries(Object.entries(env).filter(([name]) => !isProtectedEnvName(name) || Object.hasOwn(values, name))), ...launch.env }, stdio: ["pipe", "pipe", "pipe"], detached: process.platform !== "win32" });
576
578
  }
577
579
  catch (error) {
578
580
  if (error instanceof ContainmentRefusal) {
@@ -0,0 +1,67 @@
1
+ /** One app's storage, relative to the home folder, per platform. */
2
+ export type ProtectedApp = {
3
+ app: string;
4
+ darwin: readonly string[];
5
+ linux: readonly string[];
6
+ };
7
+ /**
8
+ * Every listed root. Checked against a real Mac (2026-10-25): existence only, never a file's contents. The 1Password
9
+ * containers and the extra Chrome, Edge and other browsers' folders below were found there and added.
10
+ */
11
+ export declare const PROTECTED_STORAGE: readonly ProtectedApp[];
12
+ /** The chat and vault variables no launch gets. */
13
+ export declare const PROTECTED_ENV_PREFIXES: readonly string[];
14
+ export declare function isProtectedEnvName(name: string): boolean;
15
+ /** `env` without a chat or vault variable. */
16
+ export declare function withoutProtectedEnv<T extends Record<string, string | undefined>>(env: T): T;
17
+ /** How far the link walk goes in each present root. */
18
+ export declare const PROTECTED_WALK: {
19
+ readonly depth: 4;
20
+ readonly entries: 5000;
21
+ };
22
+ /** The roots by name, under the (real) home folder and the overrides the plane's environment names. */
23
+ export declare function protectedStorageNames(home?: string, env?: Readonly<Record<string, string | undefined>>, platform?: NodeJS.Platform): string[];
24
+ /**
25
+ * Every protected path one launch denies: each root by name and by real path, every link inside a present root that
26
+ * leads outward (where it leads, by real path and as spelled), and the overrides. Resolved at each launch, never kept.
27
+ * A link macOS makes in a sandboxed app's container to the same place under the home folder (its Desktop, Downloads,
28
+ * Fonts…) is the system's own mirror of the person's folders, not the app's data, and isn't followed (systemMirror).
29
+ *
30
+ * On a Mac, listing inside another app's container waits for the person to answer macOS's "access data from other apps"
31
+ * prompt when the process asking has no answer yet (a plane run by launchd: every launch, and the console with it, hung
32
+ * there in release check 3249). So the containers are walked only after a time-limited look in a child process
33
+ * (`containers`, containersListable) shows they can be listed without waiting. A look that fails any other way refuses
34
+ * the launch (it throws). When macOS is waiting on the person, the containers stay denied by name and real path, and
35
+ * the outward links the last complete walk found (kept in the plane's own state folder, linkCacheFile) stay denied too;
36
+ * with none kept, only the roots are denied, and that is said once.
37
+ */
38
+ export declare function protectedStorageDenies(home?: string, env?: Readonly<Record<string, string | undefined>>, platform?: NodeJS.Platform, say?: (line: string) => void, containers?: (roots: readonly string[]) => boolean, list?: (path: string) => import("node:fs").Dirent[]): string[];
39
+ /** How long the look at other apps' containers may take before Toolroll takes it that macOS is waiting on the person. */
40
+ export declare const CONTAINER_LOOK_MS = 3000;
41
+ /** What a look at the containers found: listable without waiting, macOS waiting on the person (the look timed out), or
42
+ * the look itself failing (it couldn't start, crashed or was killed by anything but its own limit). */
43
+ export type ContainerLook = "listable" | "waiting" | "failed";
44
+ /**
45
+ * Whether this process can list inside these app containers without macOS stopping to ask the person: the walk run
46
+ * first in a child process that is killed at CONTAINER_LOOK_MS. Finishing in time (listed, or refused outright) means
47
+ * the walk can run here too; only running into that limit means macOS is waiting on the person (only the child waited).
48
+ * A look that fails any other way is tried once more, then throws: the launch is refused rather than fenced with less.
49
+ * A yes is kept (macOS keeps the person's answer); a no is looked at again after CONTAINER_RELOOK_MS.
50
+ */
51
+ export declare function containersListable(roots: readonly string[], look?: (roots: readonly string[]) => ContainerLook, now?: () => number): boolean;
52
+ /** A look's outcome from its child's result: only its own limit (ETIMEDOUT) is macOS waiting; any other error, signal
53
+ * or exit code is a failed look. */
54
+ export declare function lookOutcome(result: {
55
+ error?: Error;
56
+ signal: NodeJS.Signals | null;
57
+ status: number | null;
58
+ }): ContainerLook;
59
+ /** Where the outward links the last complete container walk found are kept: the plane's own state folder (fenced from
60
+ * every build), one file per user. */
61
+ export declare function linkCacheFile(home: string, env?: Readonly<Record<string, string | undefined>>): string;
62
+ /** The kept links for this home: only a plain file of this user's (no link), at most LINK_CACHE_MAX, of exactly the
63
+ * kept shape; anything else is no cache at all. */
64
+ export declare function readLinkCache(file: string, home: string): string[] | null;
65
+ /** Keep a complete walk's links: written aside (owner-only) and renamed into place, only into a state folder that is
66
+ * already there. A failed write keeps whatever was kept before. */
67
+ export declare function writeLinkCache(file: string, home: string, targets: readonly string[]): void;