@intentic/sandbox-contract 1.242.0 → 1.244.0

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 (95) hide show
  1. package/README.md +4 -1
  2. package/dist/agent-catalog.d.ts +2 -1
  3. package/dist/agent-catalog.d.ts.map +1 -1
  4. package/dist/agent-catalog.js +3 -2
  5. package/dist/agent-catalog.js.map +1 -1
  6. package/dist/card-status.d.ts.map +1 -1
  7. package/dist/card-status.js +8 -1
  8. package/dist/card-status.js.map +1 -1
  9. package/dist/chores/stack.d.ts.map +1 -1
  10. package/dist/chores/stack.js +2 -2
  11. package/dist/chores/stack.js.map +1 -1
  12. package/dist/command-classes.d.ts +1 -0
  13. package/dist/command-classes.d.ts.map +1 -1
  14. package/dist/command-classes.js +2 -2
  15. package/dist/command-classes.js.map +1 -1
  16. package/dist/contracts/agent.contract.d.ts +106 -0
  17. package/dist/contracts/agent.contract.d.ts.map +1 -1
  18. package/dist/contracts/agents.contract.d.ts +50 -0
  19. package/dist/contracts/agents.contract.d.ts.map +1 -1
  20. package/dist/contracts/runner.contract.d.ts +138 -92
  21. package/dist/contracts/runner.contract.d.ts.map +1 -1
  22. package/dist/contracts/secrets.contract.d.ts +50 -0
  23. package/dist/contracts/secrets.contract.d.ts.map +1 -1
  24. package/dist/contracts/secrets.contract.js +36 -1
  25. package/dist/contracts/secrets.contract.js.map +1 -1
  26. package/dist/contracts/sessions.contract.d.ts +37 -0
  27. package/dist/contracts/sessions.contract.d.ts.map +1 -1
  28. package/dist/contracts/settings.contract.d.ts +12 -0
  29. package/dist/contracts/settings.contract.d.ts.map +1 -1
  30. package/dist/contracts/system.contract.d.ts +39 -0
  31. package/dist/contracts/system.contract.d.ts.map +1 -1
  32. package/dist/definition.d.ts +8 -0
  33. package/dist/definition.d.ts.map +1 -1
  34. package/dist/events.d.ts +440 -1
  35. package/dist/events.d.ts.map +1 -1
  36. package/dist/events.js +40 -4
  37. package/dist/events.js.map +1 -1
  38. package/dist/index.d.ts +294 -0
  39. package/dist/index.d.ts.map +1 -1
  40. package/dist/plan-pools.d.ts +1 -0
  41. package/dist/plan-pools.d.ts.map +1 -1
  42. package/dist/plan-pools.js +1 -0
  43. package/dist/plan-pools.js.map +1 -1
  44. package/dist/safety-policy.d.ts +6 -0
  45. package/dist/safety-policy.d.ts.map +1 -1
  46. package/dist/safety-policy.js +1 -0
  47. package/dist/safety-policy.js.map +1 -1
  48. package/dist/schemas/agents.d.ts +4 -0
  49. package/dist/schemas/agents.d.ts.map +1 -1
  50. package/dist/schemas/agents.js +3 -0
  51. package/dist/schemas/agents.js.map +1 -1
  52. package/dist/schemas/automations.d.ts +1 -0
  53. package/dist/schemas/automations.d.ts.map +1 -1
  54. package/dist/schemas/computers.d.ts +9 -0
  55. package/dist/schemas/computers.d.ts.map +1 -1
  56. package/dist/schemas/computers.js +9 -0
  57. package/dist/schemas/computers.js.map +1 -1
  58. package/dist/schemas/engines.d.ts +2 -0
  59. package/dist/schemas/engines.d.ts.map +1 -1
  60. package/dist/schemas/engines.js +1 -0
  61. package/dist/schemas/engines.js.map +1 -1
  62. package/dist/schemas/plan-limits.d.ts +4 -0
  63. package/dist/schemas/plan-limits.d.ts.map +1 -1
  64. package/dist/schemas/plan-limits.js +7 -0
  65. package/dist/schemas/plan-limits.js.map +1 -1
  66. package/dist/schemas/secrets.d.ts +75 -0
  67. package/dist/schemas/secrets.d.ts.map +1 -1
  68. package/dist/schemas/secrets.js +44 -0
  69. package/dist/schemas/secrets.js.map +1 -1
  70. package/dist/schemas/settings.d.ts +6 -0
  71. package/dist/schemas/settings.d.ts.map +1 -1
  72. package/dist/schemas/settings.js +7 -0
  73. package/dist/schemas/settings.js.map +1 -1
  74. package/dist/transcript-fold.d.ts.map +1 -1
  75. package/dist/transcript-fold.js +11 -0
  76. package/dist/transcript-fold.js.map +1 -1
  77. package/package.json +4 -4
  78. package/src/agent-catalog.test.ts +21 -4
  79. package/src/agent-catalog.ts +37 -13
  80. package/src/card-status.ts +13 -1
  81. package/src/chores/stack.test.ts +36 -0
  82. package/src/chores/stack.ts +13 -2
  83. package/src/command-classes.ts +8 -3
  84. package/src/contracts/secrets.contract.ts +67 -1
  85. package/src/events.ts +100 -11
  86. package/src/plan-pools.ts +10 -0
  87. package/src/safety-policy.ts +23 -0
  88. package/src/schemas/agents.ts +6 -0
  89. package/src/schemas/computers.ts +39 -4
  90. package/src/schemas/engines.ts +1 -0
  91. package/src/schemas/plan-limits.ts +19 -0
  92. package/src/schemas/secrets.ts +110 -0
  93. package/src/schemas/settings.ts +45 -11
  94. package/src/transcript-fold.test.ts +47 -0
  95. package/src/transcript-fold.ts +11 -0
package/src/events.ts CHANGED
@@ -4,6 +4,7 @@ import { AgentSummarySchema, LandConflictSchema } from "./schemas/agents.js";
4
4
  import { RateLimitInfoSchema } from "./schemas/claude-gate.js";
5
5
  import { FastModeStateSchema } from "./schemas/fast-mode.js";
6
6
  import { AccountUsageSchema, AgentReplySchema, ProviderRefusalSchema, UsageWindowSchema } from "./schemas/plan-limits.js";
7
+ import { CredentialGateKindSchema, CredentialGateScopeSchema, CredentialLaneSchema } from "./schemas/secrets.js";
7
8
  import { ShareDetailSchema } from "./schemas/share.js";
8
9
  import { MemberRoleSchema } from "./schemas/shared.js";
9
10
  import { SubagentKindSchema, SubagentStatusSchema, SubagentVerificationSchema } from "./schemas/terminal.js";
@@ -35,10 +36,16 @@ export type AskQuestion = z.infer<typeof AskQuestionSchema>;
35
36
  * card with no way to know it was holding four hundred characters of shell: it rendered them as a paragraph,
36
37
  * wrapped mid-flag, and the fragment that caused the hold was somewhere in the middle of it.
37
38
  *
38
- * `spans` is the gate's own evidence, computed by the classifier at the moment the rule fired (contract's
39
+ * `spans` is where the pattern match fired, computed by the classifier at the moment it did (contract's
39
40
  * command-classes.ts, matchCommand) and carried rather than re-derived: a browser that re-ran the patterns
40
- * would be a second classifier, and the day the two disagreed the card would be marking a fragment that is not
41
- * the one anybody was held for. Offsets are into `text` AFTER truncation, so they are always paintable.
41
+ * would be a second classifier, and the day the two disagreed the card would be marking a fragment the daemon
42
+ * never saw. Offsets are into `text` AFTER truncation, so they are always paintable.
43
+ *
44
+ * IT IS NOT A CLAIM ABOUT WHY THE CARD EXISTS, and the card no longer presents it as one. The reason is the
45
+ * judge's sentence in the title; these are the fragments TRIAGE noticed, all of the matched classes' rather
46
+ * than whichever sorts first — the card used to show one class's and label them "Stopped for", so a command
47
+ * that cleaned a build directory on its way to publishing offered `rm -rf …` as its reason under a sentence
48
+ * about npm. Under the hard rule the title DOES name a class, so there the marks are that class's alone.
42
49
  *
43
50
  * `language` is a Shiki grammar id, and the two are the two execution backends the gate reads (command-gate's
44
51
  * EXECUTION_SOURCES): a shell line and a script. */
@@ -52,7 +59,9 @@ export const ProgramAskSchema = z.object({
52
59
  ),
53
60
  spans: z
54
61
  .array(z.object({ start: z.number().int().nonnegative(), end: z.number().int().nonnegative() }))
55
- .describe("Which fragments of the text put it in the class that held it. Offsets into text, in order, never overlapping."),
62
+ .describe(
63
+ "Which fragments of the text the pattern match fired on: every matched class's, or, under the hard rule, only the class the title names. Offsets into text, in order, never overlapping.",
64
+ ),
56
65
  });
57
66
  export type ProgramAsk = z.infer<typeof ProgramAskSchema>;
58
67
 
@@ -80,16 +89,20 @@ export const PermissionAskSchema = z.object({
80
89
  program: ProgramAskSchema.optional().describe(
81
90
  "The program this card is holding, when the card is about one. Present on a command gate's card and absent on every other permission ask.",
82
91
  ),
83
- /* THE JUDGE'S OWN SENTENCE, and on a command card it is the reason the card exists rather than a note added
84
- * to it: the judge read the owner's policy and the program and decided this needed asking, and this is what
85
- * it decided. Written by the quick model from the program text and the policy, never by the agent being
86
- * gated a card whose persuasive half was authored by the thing it is stopping argues for its own approval,
87
- * and the turns that raise cards are exactly the ones whose account of themselves may be a stranger's. */
92
+ /* THE JUDGE'S OWN SENTENCE, WHERE THE TITLE IS SOMEBODY ELSE'S. On an ordinary command card the sentence IS
93
+ * the title (the judge read the owner's policy and the program, and its account of why this needs asking is
94
+ * the only account there is), so this is left off rather than printing the same words twice. It carries the
95
+ * sentence on the two cards whose title says something the sentence cannot: the hard rule's, which names the
96
+ * consequence that stopped it, and a machine command's, which names the computer.
97
+ *
98
+ * Written by the quick model from the program text and the policy, never by the agent being gated — a card
99
+ * whose persuasive half was authored by the thing it is stopping argues for its own approval, and the turns
100
+ * that raise cards are exactly the ones whose account of themselves may be a stranger's. */
88
101
  explain: z
89
102
  .string()
90
103
  .optional()
91
104
  .describe(
92
- "One plain sentence saying what the program does and why it is being asked about. Written by the judge that read your safety policy, never by the agent being gated.",
105
+ "One plain sentence saying what the program does and why it is being asked about, where the title says something else. Written by the judge that read your safety policy, never by the agent being gated.",
93
106
  ),
94
107
  });
95
108
  export type PermissionAsk = z.infer<typeof PermissionAskSchema>;
@@ -209,6 +222,39 @@ export const PaymentOfferSchema = z.object({
209
222
  });
210
223
  export type PaymentOffer = z.infer<typeof PaymentOfferSchema>;
211
224
 
225
+ /* ONE GATED CREDENTIAL, ASKED FOR, the card the daemon raises when the agent reaches for a secret or a
226
+ * connected account the owner put behind a named person (secrets/credential-gate.ts).
227
+ *
228
+ * Every field but `why` is the daemon's own: the subject and its approvers come off the gate policy the owner
229
+ * wrote (which lives off the workspace, where the agent cannot edit it), the lane and detail come from the
230
+ * exit that was about to spend the credential, and the scope is the policy's, not the asker's. The model
231
+ * contributes one line of rationale and nothing else, which is what makes the card impossible to
232
+ * misrepresent: a prompt-injected turn can ask for the production password and cannot make the card say it is
233
+ * asking for the staging one.
234
+ *
235
+ * THE APPROVERS ARE ON THE CARD because the card is not addressed to "the owner" the way every other offer
236
+ * here is — it is addressed to a LIST, the server checks the clicker's verified identity against it, and a
237
+ * click from anybody else is refused with the card left standing. So the names have to be visible: a card
238
+ * whose buttons do nothing for the person looking at it must say who it is waiting for. */
239
+ export const CredentialOfferSchema = z.object({
240
+ // The gate's subject: a secret's reference name (`DATABASE_URL`) or a capability id (`reddit`).
241
+ subject: z.string().describe("Which credential is being asked for."),
242
+ kind: CredentialGateKindSchema,
243
+ lane: CredentialLaneSchema,
244
+ // Where it would go, in the reader's terms: the head of the agent's command line, the page's host, or the
245
+ // capability's own name. Reference-form by construction on the secret lanes (resolution is what fires the
246
+ // ask), so this can be shown without leaking anything.
247
+ detail: z
248
+ .string()
249
+ .optional()
250
+ .describe("Where it would go: the start of the command, the site, or what is being mounted. Never a value: the command still reads as a reference at this point."),
251
+ // The agent's one-line case, the only prose on the card that is the model's.
252
+ why: z.string().optional().describe("The agent's case for using it, and the only words on this card that are the agent's."),
253
+ approvers: z.array(z.string()).describe("Who may release it. A click from anyone else is refused and leaves the card standing."),
254
+ scope: CredentialGateScopeSchema,
255
+ });
256
+ export type CredentialOffer = z.infer<typeof CredentialOfferSchema>;
257
+
212
258
  // One provider-advertised slash command, an ACP agent's available_commands entry, or a Claude Code session's
213
259
  // supportedCommands() (its built-ins plus the workspace's own .claude/commands and any plugin/skill commands).
214
260
  // `hint` is the argument placeholder the popover shows after the name.
@@ -360,6 +406,7 @@ const terminalHelpCard = {
360
406
  const serviceOfferCard = { requestId: z.string(), offer: ServiceOfferSchema };
361
407
  const capabilityOfferCard = { requestId: z.string(), offer: CapabilityOfferSchema };
362
408
  const paymentOfferCard = { requestId: z.string(), offer: PaymentOfferSchema };
409
+ const credentialOfferCard = { requestId: z.string(), offer: CredentialOfferSchema };
363
410
 
364
411
  /* HOW AN OFFER'S ACCEPTED HALF ENDED, the follow-up that lands on the card after the click. Each is the body of
365
412
  * the frame that reports it (`service_receipt`, `capability_outcome`, `payment_receipt`) and the field the
@@ -382,6 +429,15 @@ export const PaymentReceiptSchema = z.object({
382
429
  network: z.string().optional(),
383
430
  });
384
431
  export type PaymentReceipt = z.infer<typeof PaymentReceiptSchema>;
432
+ /* WHO RELEASED A GATED CREDENTIAL, or that a person refused it. `released` carries the approver's own address,
433
+ * read off the VERIFIED identity on the reply rather than off anything the click claimed, which is what makes
434
+ * the row an audit line rather than a rendering. There is no receipt for a card nobody answered: `resolved`
435
+ * already says so, and inventing "refused" for a deadline would put words in a person's mouth. */
436
+ export const CredentialReceiptSchema = z.object({
437
+ outcome: z.enum(["released", "refused"]),
438
+ approvedBy: z.string().optional(),
439
+ });
440
+ export type CredentialReceipt = z.infer<typeof CredentialReceiptSchema>;
385
441
 
386
442
  /* THE THREE RESTORABLE CARDS, named so the turn journal can hold them verbatim: a parked turn's raised cards
387
443
  * are written down beside its prompt (sandbox turn-journal.ts), and a daemon death under the park restores the
@@ -468,6 +524,12 @@ export const TranscriptPaymentOfferSchema = z.object({
468
524
  receipt: PaymentReceiptSchema.optional().describe("How the approved payment ended (the payment_receipt frame)."),
469
525
  });
470
526
  export type TranscriptPaymentOffer = z.infer<typeof TranscriptPaymentOfferSchema>;
527
+ export const TranscriptCredentialOfferSchema = z.object({
528
+ ...credentialOfferCard,
529
+ status: OfferStatusSchema.describe("Where the decision stands."),
530
+ receipt: CredentialReceiptSchema.optional().describe("Who released it, or that somebody refused (the credential_receipt frame)."),
531
+ });
532
+ export type TranscriptCredentialOffer = z.infer<typeof TranscriptCredentialOfferSchema>;
471
533
 
472
534
  // ---- transcript rows ----
473
535
  // What a conversation is made of, on every surface: the rows the daemon folds a turn's frames into as they
@@ -664,13 +726,26 @@ export const TranscriptRowSchema = z.object({
664
726
  serviceOffer: TranscriptServiceOfferSchema.optional().describe("The priced service run this row offered, the decision, and the receipt."),
665
727
  capabilityOffer: TranscriptCapabilityOfferSchema.optional().describe("The capability setup this row asked for, the decision, and the outcome."),
666
728
  paymentOffer: TranscriptPaymentOfferSchema.optional().describe("The payment this row asked for, the decision, and the receipt."),
729
+ credentialOffer: TranscriptCredentialOfferSchema.optional().describe(
730
+ "The gated credential this row asked to use, who may release it, and who did.",
731
+ ),
667
732
  });
668
733
  export type TranscriptRow = z.infer<typeof TranscriptRowSchema>;
669
734
 
670
735
  /* THE CARD FIELDS A ROW CAN CARRY, as one list, for every reader that has to ask "does this row hold a card":
671
736
  * the fold that counts a card-only bubble as a row, the chat's row count (a branch is cut by it), and the
672
737
  * surfaces that draw whichever card a bubble is waiting on. */
673
- export const CARD_FIELDS = ["plan", "question", "permission", "browserHelp", "terminalHelp", "serviceOffer", "capabilityOffer", "paymentOffer"] as const;
738
+ export const CARD_FIELDS = [
739
+ "plan",
740
+ "question",
741
+ "permission",
742
+ "browserHelp",
743
+ "terminalHelp",
744
+ "serviceOffer",
745
+ "capabilityOffer",
746
+ "paymentOffer",
747
+ "credentialOffer",
748
+ ] as const;
674
749
  export type CardField = (typeof CARD_FIELDS)[number];
675
750
  export type TranscriptCards = Pick<TranscriptRow, CardField>;
676
751
  // Whether a row holds a card at all, answered or not.
@@ -1164,6 +1239,20 @@ export const AgentEventSchema = z.discriminatedUnion("kind", [
1164
1239
  * case the signed authorization expires unused and NOTHING left the wallet. A skip needs no receipt,
1165
1240
  * nothing moved, and `resolved` already says so. */
1166
1241
  PaymentReceiptSchema.extend({ kind: z.literal("payment_receipt"), requestId: z.string() }),
1242
+ /* A GATED CREDENTIAL awaiting a NAMED person's click, the one card on this stream that is not addressed to
1243
+ * the owner: the daemon holds an exit (a `{{secret:…}}` about to resolve, a browser field about to be
1244
+ * typed into, a connected account about to be mounted) parked until one of the gate's approvers releases
1245
+ * it (secrets/credential-gate.ts). Raised OUTSIDE the turn generator like the offers above — the exits run
1246
+ * inside a PreToolUse hook and inside the daemon's own `secrets request` route — so it is not journalled
1247
+ * for restore: its waiter is a held hook or a held connection, both of which die with the daemon, and the
1248
+ * next use after a restart simply asks again. Settles through the same `POST /agent/reply`, which is where
1249
+ * the clicker's identity is checked against `offer.approvers`. */
1250
+ z.object({ kind: z.literal("credential_offer"), ...credentialOfferCard }),
1251
+ /* WHO RELEASED IT, pushed the moment a person decided, so the settled card names them rather than saying
1252
+ * only that something was approved: `released` with the approver's verified address, or `refused` when a
1253
+ * person said no. Nothing is pushed for a card nobody answered — `resolved` already says that, and a
1254
+ * deadline is not a refusal by anybody. */
1255
+ CredentialReceiptSchema.extend({ kind: z.literal("credential_receipt"), requestId: z.string() }),
1167
1256
  // The card above named by `requestId` is released, the user answered (or dismissed it, or the turn was
1168
1257
  // stopped out from under it), so the turn is executing again. Emitted by whoever parked, the moment its
1169
1258
  // waiter settles, because the park's END is otherwise invisible on this stream: nothing else here says
package/src/plan-pools.ts CHANGED
@@ -13,6 +13,16 @@ import type { AccountUsage, UsageWindow, WindowGates } from "./schemas/plan-limi
13
13
  * chosen a model yet is asking. Pools gated to `none` are left out of that too: a code-review limit is the
14
14
  * account's to see, never the thing that decides whether a chat turn can run. */
15
15
 
16
+ /* WHEN A POOL HAS NOTHING LEFT IN IT, on the wire's own scale, read the same way by both sides.
17
+ *
18
+ * 100, and deliberately not the browser's 90 (usageStatus' SPENT_PERCENT): the two thresholds answer different
19
+ * questions. 90 is a WARNING — the point at which a surface stops recommending an account for a long turn, and
20
+ * what the rings and the bands take their red from. This is EXHAUSTION: the point past which a call is certain
21
+ * to be refused, which is what the daemon needs before it steers a turn elsewhere and the only line a list of
22
+ * offers may hide an account behind. A subscription with a tenth of its week left can still run the next task,
23
+ * and dropping it off a rail titled "Ready to run" answers the reader's question with the wrong one. */
24
+ export const SPENT_UTILIZATION = 100;
25
+
16
26
  export interface ModelRef {
17
27
  // The wire id ("claude-opus-4-6", "gemini-3-pro").
18
28
  readonly id: string;
@@ -46,6 +46,29 @@ import type { CommandClass } from "./schemas/agent.js";
46
46
  * set shrinks to block devices when that lands. */
47
47
  export const HARD_RULE_CLASSES: ReadonlySet<CommandClass> = new Set<CommandClass>(["system.destructive"]);
48
48
 
49
+ /* WHETHER THE JUDGE RUNS AT ALL, and whether its answer is allowed to stop anything. The owner's switch over
50
+ * everything below, and the reason it exists is that a tier which spends a model call and can interrupt you is a
51
+ * tier somebody is entitled to decline — the old rulebook could be set to allow everything, and losing that when
52
+ * the judge arrived made the redesign a thing you could only opt further INTO.
53
+ *
54
+ * THREE STATES, and the middle one is the one worth arguing for. Nobody trusts a judge they have not watched,
55
+ * and the only evidence that it asks about the right things is a log of what it decided while it could not
56
+ * interrupt them. Same shape, and the same reasoning, as the tier judge's own Measure state (settings.autoTier).
57
+ *
58
+ * off nothing is judged. No model call, no cards, nothing written to the log. Triage still runs, because
59
+ * the hard rule below is built on it and costs nothing.
60
+ * watch the judge runs on every triage hit and every verdict is recorded, and NOTHING is ever held: an `ask`
61
+ * is logged as an ask and the command runs anyway. What it costs is one model call per triage hit;
62
+ * what it buys is the Recent decisions list, read against a policy nobody has tested yet.
63
+ * on the verdict decides, which is the behaviour this design describes everywhere else.
64
+ *
65
+ * THE HARD RULE IS NOT UNDER THIS SWITCH, at any setting. HARD_RULE_CLASSES is a typed verdict rather than a
66
+ * judgment, it never needed a model, and the Safety page promises in as many words that it cannot be edited
67
+ * away. So `off` and `watch` still raise a card for wiping a block device or deleting under /history — with a
68
+ * sentence saying the judge did not weigh in, rather than one pretending it did. */
69
+ export const CommandJudgeModeSchema = z.enum(["off", "watch", "on"]);
70
+ export type CommandJudgeMode = z.infer<typeof CommandJudgeModeSchema>;
71
+
49
72
  /* WHAT THE JUDGE ANSWERS. Three verdicts, and each is a different instruction to the gate:
50
73
  *
51
74
  * allow run it, say nothing, nobody is interrupted. The ordinary answer for a triage false positive,
@@ -86,6 +86,12 @@ export const AgentAttentionSchema = z.object({
86
86
  // A missing capability parked on the owner's setup (capabilities/capability-offer.ts), the agent is
87
87
  // waiting for something to be connected, so the lane can say "setup needed" rather than a generic pause.
88
88
  capability: z.boolean().describe("It needs something connected that is not connected yet."),
89
+ // A gated credential parked on a NAMED person's click (secrets/credential-gate.ts). Its own lane because
90
+ // it is the one pause the person reading the board may not be able to clear: every other card here waits
91
+ // for whoever is looking at it, this one waits for the specific people the gate names.
92
+ credential: z
93
+ .boolean()
94
+ .describe("It is waiting for a named person to release a credential. The one pause that may not be yours to clear, whatever your role."),
89
95
  conflict: z.boolean().describe("Its work cannot be merged without somebody resolving a clash."),
90
96
  });
91
97
  export type AgentAttention = z.infer<typeof AgentAttentionSchema>;
@@ -244,6 +244,12 @@ export type MachinePort = z.infer<typeof MachinePortSchema>;
244
244
  export const MachineWatcherSchema = z.object({
245
245
  running: z.boolean(),
246
246
  pid: z.number().int().optional(),
247
+ /* WHICH BUILD IS ACTUALLY SERVING, stamped into the pidfile by the loop that claimed it, which is the only
248
+ * place the fact exists: replacing the binary does not touch the running process, so a machine can hold a
249
+ * current agent and go on serving a months-old one indefinitely. `agents.sync` is the file, this is the
250
+ * process, and the two differing is a restart somebody is owed (see watcherBuildSkew). Absent when no loop
251
+ * is running, and when the one running predates the stamp. */
252
+ build: z.string().optional(),
247
253
  /* When the watcher last FINISHED a pass, the field that makes `running` mean something. The agent holds its
248
254
  * SSH transport listeners on its own event loop, so a failure that escapes the loop leaves a process that is
249
255
  * alive and a loop that is gone: pid present, unit "active", mirroring and the git bridge stopped. Absent
@@ -268,10 +274,18 @@ export const MachineReportSchema = z.object({
268
274
  * the same box, so it is what dedupes them into a single row. */
269
275
  hostname: z.string(),
270
276
  os: z.string(),
271
- // Which of this machine's agents are installed, and at what version, a machine running an old build is
272
- // visible rather than mysteriously lacking a field. Same argument as HostSummary.version. `host` is filled
273
- // by the daemon at merge time (it already knows it from the socket), not by the sync agent, which would have
274
- // to go reading another agent's config to guess at it.
277
+ /* Which agents this machine has, and at what version, so one on an old build is visible rather than
278
+ * mysteriously lacking a field. Same argument as HostSummary.version.
279
+ *
280
+ * `sync` is the agent INSTALLED here the file on disk — and `watcher.build` beside it is the loop running
281
+ * from that file. It used to be neither: whichever process happened to build the report stamped its own
282
+ * version here, so the same machine answered its running build to a sandbox its loop posted to and its
283
+ * installed build to one that ran `status --json` over a host capability. One field, two meanings, and the
284
+ * gap between them — a machine updated but never restarted — invisible in both.
285
+ *
286
+ * `host` is what the live socket announced (so: what is running), filled by the daemon at merge time from
287
+ * the hello frame it already holds, never by the sync agent, which would have to go reading another agent's
288
+ * config to guess at it. */
275
289
  agents: z.object({ sync: z.string().optional(), host: z.string().optional() }),
276
290
  // Filled by the READER, never the agent (see above). Empty is the resting state: no Docker on the machine,
277
291
  // or nothing has looked. Neither is an error, and neither means "no sandboxes exist".
@@ -284,6 +298,27 @@ export const MachineReportSchema = z.object({
284
298
  capturedAt: z.number(),
285
299
  });
286
300
  export type MachineReport = z.infer<typeof MachineReportSchema>;
301
+
302
+ /* THE AGENT THIS MACHINE INSTALLED AND THE ONE IT IS RUNNING, when they are not the same build — the whole of
303
+ * "you updated the agent and nothing changed", as a value.
304
+ *
305
+ * It is one comparison, and it lives HERE for the same reason watcherStalled does: the terminal (`intentic-machine
306
+ * status`) and the browser (the Computers row) both answer this question, and a machine that is behind in one and
307
+ * fine in the other is worse than either answer alone. The remedy is the same in both: restart the loop.
308
+ *
309
+ * Silent whenever either half is unknown, which covers a loop that is stopped (nothing is serving, and the row
310
+ * already says so in louder words), an agent too old to stamp its build, and a machine with no installed agent to
311
+ * compare against — none of which is a skew, and each of which would otherwise nag about a difference nobody can
312
+ * act on. */
313
+ export const watcherBuildSkew = (report: MachineReport): { readonly running: string; readonly installed: string } | undefined => {
314
+ const running = report.watcher.build;
315
+ const installed = report.agents.sync;
316
+ if (!report.watcher.running || running === undefined || installed === undefined || running === installed) {
317
+ return undefined;
318
+ }
319
+ return { running, installed };
320
+ };
321
+
287
322
  // Why a computer that is plainly THERE has no report to show. Each is a different errand for the reader, which is
288
323
  // the whole reason they are not collapsed into one "unavailable".
289
324
  export const ComputerGapSchema = z.enum([
@@ -82,6 +82,7 @@ export const EngineRowSchema = z.object({
82
82
  previous: z.string().optional().describe("The version kept one step back, which is what going back means."),
83
83
  quarantined: z.array(EngineQuarantineSchema).describe("Versions the store installed and then refused, with the reason."),
84
84
  diskBytes: z.number().int().nonnegative().describe("What this engine's kept versions cost on the daemon's volume."),
85
+ installing: z.boolean().optional().describe("Whether this engine is currently being installed in the background."),
85
86
  });
86
87
  export type EngineRow = z.infer<typeof EngineRowSchema>;
87
88
 
@@ -259,6 +259,25 @@ export const AgentReplySchema = z.discriminatedUnion("kind", [
259
259
  .boolean()
260
260
  .describe("Yes releases exactly one payment. Anything else spends nothing. This click is the only way the money can move."),
261
261
  }),
262
+ /* A GATED CREDENTIAL's release, and the one reply on this list whose sender is CHECKED. Every other card
263
+ * here is answered by whoever holds a session, because every other card is the owner's to decide; this one
264
+ * is addressed to a named list, so the daemon reads the verified identity off the request and refuses a
265
+ * click from anybody else with the card left standing for whoever can answer it. The refusal covers `no`
266
+ * as well: a stranger may not skip a release on the approver's behalf either, or the gate would be a
267
+ * denial-of-service anybody with a session could aim at a turn.
268
+ *
269
+ * `approve` carries no qualifiers because the gate's own policy decides how far one yes goes: a per-use
270
+ * gate releases exactly this use and the next one asks again, a conversation-scoped gate covers the rest
271
+ * of the conversation. The card says which, so the click is never wider than it reads. */
272
+ z.object({
273
+ kind: z.literal("credential_offer").describe("Releasing a credential the agent may only use once a named person says so."),
274
+ requestId: z.string().min(1).describe("Which card you are answering."),
275
+ approve: z
276
+ .boolean()
277
+ .describe(
278
+ "Yes releases it, as far as the card says (this one use, or the rest of the conversation). Only the people the card names can answer at all, yes or no.",
279
+ ),
280
+ }),
262
281
  ]);
263
282
  export type AgentReply = z.infer<typeof AgentReplySchema>;
264
283
  // Steering: a user message delivered INTO the running turn (injected between tool calls, Claude Code style),
@@ -16,6 +16,102 @@ export const SecretKeysSchema = z.object({
16
16
  });
17
17
  export const SecretKeyParamSchema = z.object({ key: z.string().describe("Which secret, by name.") });
18
18
  export const SecretRevealSchema = z.object({ value: z.string().describe("The value itself. The only place in this API one is ever returned.") });
19
+
20
+ /* A CREDENTIAL THE AGENT MAY NOT USE UNTIL A NAMED PERSON SAYS SO, and who that person is.
21
+ *
22
+ * Masking already answers "can the model SEE this value" (no: it reads a `{{secret:name}}` reference), and
23
+ * the answer was enough while the only question was disclosure. It is not the whole question. A reference the
24
+ * model can write is a credential the model can SPEND, at whatever moment its own reasoning arrives at, and
25
+ * for the small set of credentials where one wrong spend is the incident — the production database's
26
+ * password, the company X account, the card-shaped connector — the owner wants the moment itself decided by a
27
+ * person, not the storage of the value. That is what a gate is: not a second lock on the vault, a REQUIRED
28
+ * CLICK in front of the exit.
29
+ *
30
+ * WHAT IT IS NOT A WALL AGAINST. A shell in this container runs as the workspace's owner and the policy file
31
+ * sits beside the vault it guards; anything that can read one can read the other, and a compromised container
32
+ * is out of scope here exactly as it is for the vault (SECURITY.md). The gate is a wall against the AGENT's
33
+ * own judgment being the last word — a prompt-injected model can ask for a release and cannot grant itself
34
+ * one — and against the ordinary accident of a turn reaching for the live credential when the staging one was
35
+ * meant.
36
+ *
37
+ * THE APPROVERS ARE AN EXACT LIST, not a role floor, and the owner is not on it implicitly. "Only Bob may
38
+ * release the production password" is the sentence people actually mean, and a floor cannot say it: a floor of
39
+ * `maintainer` says "anyone senior enough", which is the thing the owner was trying to narrow. So the list is
40
+ * the list, the owner adds themselves when they mean themselves, and a click from anybody else is refused
41
+ * server-side with the card left standing for whoever can. */
42
+ export const CredentialGateScopeSchema = z
43
+ .enum(["use", "conversation"])
44
+ .describe(
45
+ "How far one release goes: `use` asks again every single time (one click releases exactly one use), `conversation` covers the rest of this conversation and is forgotten when the daemon restarts.",
46
+ );
47
+ export type CredentialGateScope = z.infer<typeof CredentialGateScopeSchema>;
48
+
49
+ // What a gate is ABOUT: one stored secret by its reference name, or one whole connected capability (a
50
+ // connector's env, a browser account's profile, an MCP server) by its id. Two kinds rather than one because
51
+ // they are withheld differently — a secret is refused at the exit it would resolve at, a capability is simply
52
+ // never mounted for the turn — and the subject namespaces are disjoint anyway (env keys are SCREAMING_SNAKE).
53
+ export const CredentialGateKindSchema = z
54
+ .enum(["secret", "capability"])
55
+ .describe("Whether this gate covers one stored secret, by the name a reference carries, or one whole connected capability, by its id.");
56
+ export type CredentialGateKind = z.infer<typeof CredentialGateKindSchema>;
57
+
58
+ /* WHICH EXIT A RELEASE WAS ASKED FOR, so the card can say what is about to happen in the reader's terms
59
+ * rather than in the daemon's. The three secret exits are the ones the use ledger already names (a shell
60
+ * command, a JS run's script, a browser keystroke); `session` is a whole capability being mounted for the
61
+ * turn (a signed-in browser profile, a connector's env, an MCP server), and `otp` is one derived one-time
62
+ * code off a stored TOTP seed. */
63
+ export const CredentialLaneSchema = z
64
+ .enum(["shell", "code", "browser", "session", "otp"])
65
+ .describe("What the credential was about to be used for: a shell command, a script, typing into a page, mounting a connected account, or one one-time code.");
66
+ export type CredentialLane = z.infer<typeof CredentialLaneSchema>;
67
+
68
+ export const CredentialGateSchema = z.object({
69
+ // An env/generated KEY (`DATABASE_URL`), or a capability id (`reddit`, `komodo`). A vault name's field
70
+ // half (`reddit/password`) is deliberately NOT a subject: gating one field of a connected account and not
71
+ // its neighbours would be a gate with a hole in it, so the whole capability is the unit.
72
+ subject: z.string().min(1).describe("What is gated: a secret's name, or a connected capability's id."),
73
+ kind: CredentialGateKindSchema,
74
+ approvers: z
75
+ .array(z.string().min(3))
76
+ .min(1)
77
+ .describe(
78
+ "Exactly who may release it, by email, from the people on the Access roster. Not a seniority floor: nobody outside this list can release it, the owner included, unless the owner is on it.",
79
+ ),
80
+ scope: CredentialGateScopeSchema,
81
+ });
82
+ export type CredentialGate = z.infer<typeof CredentialGateSchema>;
83
+
84
+ export const CredentialGatesSchema = z.object({
85
+ gates: z
86
+ .array(CredentialGateSchema)
87
+ .describe("Every gate in force. Names, subjects and approver addresses only: this answer never carries a credential."),
88
+ });
89
+ export type CredentialGates = z.infer<typeof CredentialGatesSchema>;
90
+
91
+ export const CredentialGateSubjectParamSchema = z.object({
92
+ subject: z.string().min(1).describe("Which gate, by the secret name or capability id it covers."),
93
+ });
94
+
95
+ /* THE AGENT'S OWN DOOR, `secrets request <subject> --why "…"`. A gated capability is ABSENT from a turn
96
+ * rather than refused inside it (its profile is not mounted, its env is not exported), so the model cannot
97
+ * discover the gate by tripping over it the way a secret reference does — it would conclude the account is
98
+ * disconnected and go looking for another road. This route is the road: it raises the same card the exits
99
+ * raise, and a release is worded as a grant for the REST OF THE CONVERSATION, because a session-shaped
100
+ * credential cannot be handed out for one use. */
101
+ export const CredentialRequestSchema = z.object({
102
+ subject: z.string().min(1).describe("What to ask for: the secret's name, or the connected capability's id."),
103
+ why: z.string().max(280).optional().describe("One line on what it is for. The only words on the card that are the agent's."),
104
+ // Which conversation's chat the card goes up in, filled by the CLI from the turn's own environment, so
105
+ // the model cannot aim a card at somebody else's conversation.
106
+ conversationId: z.string().optional().describe("Which conversation to raise the card in. The CLI fills this from the running turn."),
107
+ });
108
+
109
+ export const CredentialGrantSchema = z.object({
110
+ granted: z.literal(true).describe("Always true: a refusal is an error with a sentence, never a `false` here."),
111
+ approvedBy: z.string().describe("Who released it."),
112
+ message: z.string().describe("What the grant means in practice, and what to do next."),
113
+ });
114
+
19
115
  // One entry per secret the sandbox knows about, across every store: intent env secrets and intentic-generated
20
116
  // passwords (from the desired-state repo), capability credentials, and AI-provider accounts. Values never ride
21
117
  // this shape, `revealable` says whether `reveal` can return one (everything but provider accounts).
@@ -56,9 +152,23 @@ export const SecretInventoryEntrySchema = z.object({
56
152
  .string()
57
153
  .optional()
58
154
  .describe("Where it went: the start of the command or script, or the site. Names and destinations only, never values."),
155
+ // Who released it, when a gate made that use somebody's decision. Absent on an ungated use, which
156
+ // is nearly all of them: the ledger row records a person only where a person was actually asked.
157
+ approvedBy: z.string().optional().describe("Who released it for that use, when it is gated. Absent when nothing had to be approved."),
59
158
  })
60
159
  .optional()
61
160
  .describe("The last time an agent actually spent this secret. Absent while it never has been, which most never are."),
161
+ /* THE APPROVAL THIS ENTRY IS BEHIND, joined on from the gate policy so the row can say "needs approval
162
+ * from Bob" without a second call. Absent on the overwhelming majority of entries: gating is for the few
163
+ * credentials where one wrong use is the incident, and a sandbox where everything asks is a sandbox where
164
+ * nobody reads the cards. */
165
+ gate: z
166
+ .object({
167
+ approvers: z.array(z.string()).describe("Who may release it, by email. Nobody else can, whatever their role."),
168
+ scope: CredentialGateScopeSchema,
169
+ })
170
+ .optional()
171
+ .describe("Who has to release this before the agent can use it, and for how long one release lasts. Absent when it is not gated."),
62
172
  });
63
173
  export type SecretInventoryEntry = z.infer<typeof SecretInventoryEntrySchema>;
64
174
  export const SecretInventorySchema = z.object({
@@ -1,5 +1,6 @@
1
1
  // settings: per-sandbox agent settings (.intentic/config/settings.json)
2
2
  import { z } from "zod";
3
+ import { CommandJudgeModeSchema } from "../safety-policy.js";
3
4
  import { AdmissionPolicySchema, AdmissionRuleSchema, AgentRunPinSchema } from "./agent.js";
4
5
  // Which prompt the agent is, before this turn composes anything on top. Two built-in bases and an escape
5
6
  // hatch: Intentic's own (the default), Claude Code's preset, or the owner's text. Declared out here rather
@@ -715,17 +716,50 @@ export const SandboxSettingsSchema = z.object({
715
716
  .record(z.string(), AdmissionRuleSchema)
716
717
  .default({})
717
718
  .describe("What an agent may do out in the world, per kind of action: go ahead, ask first, or never."),
718
- /* THE COMMAND GATE IS NOT CONFIGURED HERE. It used to be: `commandRules` was a verdict per CommandClass and
719
- * `explainCommands` decided whether a card carried a sentence. Both are gone, and the reason is the whole
720
- * safety redesign (safety-policy.ts argues it): a regex verdict per class asked about `echo "rm -rf /"` and
721
- * an actual delete in the same words, and no setting of six switches fixes that, because telling the two
722
- * apart is an act of understanding rather than a threshold. What replaced them is the owner's written
723
- * policy at .intentic/config/safety.md, read by a judge that also sees what the daemon knows about the turn,
724
- * plus one typed hard rule the judge cannot waive. The Safety page edits that document; nothing about the
725
- * command gate belongs in this object.
726
- *
727
- * The sentence on a card is no longer optional either: it is the judge's own reason for the verdict, so a
728
- * card without one would be a card that could not say why it exists. */
719
+ /* NO VERDICT FOR THE COMMAND GATE LIVES HERE, and the two settings that do are about the JUDGE rather than
720
+ * about what it should decide. `commandRules` used to be a verdict per CommandClass and `explainCommands`
721
+ * decided whether a card carried a sentence; both are gone, and the reason is the whole safety redesign
722
+ * (safety-policy.ts argues it). A regex verdict per class asked about `echo "rm -rf /"` and an actual delete
723
+ * in the same words, and no setting of six switches fixes that, because telling the two apart is an act of
724
+ * understanding rather than a threshold. What replaced them is the owner's written policy at
725
+ * .intentic/config/safety.md, read by a judge that also sees what the daemon knows about the turn, plus one
726
+ * typed hard rule the judge cannot waive. The Safety page edits that document; what a command may DO is
727
+ * settled there and nowhere in this object.
728
+ *
729
+ * WHETHER THE JUDGE RUNS AT ALL is a different question, and one this object has to answer, because it is
730
+ * the only tier of the design that spends money and interrupts people. An owner who does not want either is
731
+ * entitled to say so, and before this they could not: the old rulebook could be set to allow everything, and
732
+ * the redesign quietly made itself the one part of the sandbox you could only opt further into.
733
+ *
734
+ * Judged commands are also the one automatic job whose model choice genuinely differs from the rest of
735
+ * `quickModel`'s work, which is why the list below exists rather than a line in the comment above it: a
736
+ * commit message written by a model that misread the diff is a sentence somebody edits, and a verdict
737
+ * written by a model that misread a command is a card that should not have been raised or, worse, one that
738
+ * should have been. */
739
+ commandJudge: CommandJudgeModeSchema.default("on").describe(
740
+ "Whether a model reads your safety policy before a flagged command runs. Off judges nothing and asks about nothing; Watch judges everything and records it without ever interrupting you, which is how you find out what your policy actually does before you let it stop anything; On lets the verdict decide. Wiping a disk or deleting under /history asks at every setting — that rule is typed rather than judged, and cannot be turned off.",
741
+ ),
742
+ /* WHICH MODEL READS THE POLICY, an ordered list of `${provider}:${modelId}` keys (quickModelKey) walked top
743
+ * to bottom, or EMPTY for whatever the quick model would be.
744
+ *
745
+ * A LIST, for the reason every other model setting here is one: the head runs out and the whole feature goes
746
+ * with it. Falling back to the quick chain rather than to Auto is deliberate — it is what this did before the
747
+ * setting existed, so an owner who never opens the row keeps exactly the behaviour they had, and one who
748
+ * writes an entry is saying that command verdicts are worth a different model than commit messages.
749
+ *
750
+ * THE ARGUMENT FOR SETTING IT AT ALL, since the cheapest connected rung is the default: this prompt is the
751
+ * one quick job that is genuinely adversarial. Its input includes text the agent is about to run, which may
752
+ * have arrived from a stranger's web page, and a small model can be talked round by it (command-judge.ts is
753
+ * candid about that). It is also the job where being WRONG is expensive in both directions — a needless card
754
+ * teaches the owner to click through the next one. Neither is a reason for us to spend somebody's frontier
755
+ * allowance by default; both are reasons for them to be able to. */
756
+ commandJudgeModels: z
757
+ .array(z.string())
758
+ .max(10)
759
+ .default([])
760
+ .describe(
761
+ "Which models decide whether a flagged command should run, tried in order so one spent account does not take the gate down. Empty uses whatever the quick model is, which is what this did before the setting existed.",
762
+ ),
729
763
  /* HOW MUCH AN AGENT MAY DELEGATE, the three ceilings the Claude Code harness enforces on its own Agent
730
764
  * tool, surfaced here because their defaults are tuned for a laptop and this is a container the owner sized.
731
765
  *
@@ -392,6 +392,53 @@ describe("foldTurn", () => {
392
392
  ]);
393
393
  });
394
394
 
395
+ /* A GATED CREDENTIAL's card keeps WHO released it, not merely that something was approved: the approver is
396
+ * the whole point of the gate, and the reply cannot carry them (it is the daemon that verified the
397
+ * identity), so the receipt frame is the only place that name ever appears. */
398
+ it("keeps who released a gated credential on the card that asked for it", () => {
399
+ const offer = {
400
+ subject: "DATABASE_URL",
401
+ kind: "secret" as const,
402
+ lane: "shell" as const,
403
+ detail: "psql {{secret:DATABASE_URL}}",
404
+ why: "run the migration",
405
+ approvers: ["bob@corp.com"],
406
+ scope: "use" as const,
407
+ };
408
+ const events: AgentEvent[] = [
409
+ { kind: "credential_offer", requestId: "c1", offer },
410
+ { kind: "resolved", requestId: "c1", reply: { kind: "credential_offer", requestId: "c1", approve: true } },
411
+ { kind: "credential_receipt", requestId: "c1", outcome: "released", approvedBy: "bob@corp.com" },
412
+ { kind: "delta", text: "Migrated." },
413
+ ];
414
+ expect(foldOf("migrate", events).slice(1)).toEqual([
415
+ {
416
+ role: "assistant",
417
+ text: "",
418
+ credentialOffer: { requestId: "c1", offer, status: "approved", receipt: { outcome: "released", approvedBy: "bob@corp.com" } },
419
+ },
420
+ { role: "assistant", text: "Migrated." },
421
+ ]);
422
+ });
423
+
424
+ /* A release nobody answered is nobody's refusal. The deadline passing freezes the card `cancelled` and
425
+ * writes NO receipt, because "refused" would name a decision a person never made — the same split the
426
+ * gate's two refusal sentences make to the agent. */
427
+ it("freezes an unanswered release as nobody's decision, with no receipt", () => {
428
+ const offer = {
429
+ subject: "reddit",
430
+ kind: "capability" as const,
431
+ lane: "session" as const,
432
+ approvers: ["alice@corp.com"],
433
+ scope: "conversation" as const,
434
+ };
435
+ const events: AgentEvent[] = [{ kind: "credential_offer", requestId: "c1", offer }];
436
+ expect(foldTurn(openingOf("post it"), events, "stopped").slice(1)).toEqual([
437
+ { role: "assistant", text: "", credentialOffer: { requestId: "c1", offer, status: "cancelled" } },
438
+ { role: "notice", text: "Stopped." },
439
+ ]);
440
+ });
441
+
395
442
  // A turn the user stopped says so, after freezing whatever it was parked on.
396
443
  it("writes a stop down after cancelling what the turn was waiting on", () => {
397
444
  const events: AgentEvent[] = [