@intentic/sandbox-contract 1.243.0 → 1.245.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 (102) 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/batch-runs.d.ts +32 -0
  7. package/dist/batch-runs.d.ts.map +1 -0
  8. package/dist/batch-runs.js +43 -0
  9. package/dist/batch-runs.js.map +1 -0
  10. package/dist/card-status.d.ts.map +1 -1
  11. package/dist/card-status.js +8 -1
  12. package/dist/card-status.js.map +1 -1
  13. package/dist/chores/stack.d.ts.map +1 -1
  14. package/dist/chores/stack.js +2 -2
  15. package/dist/chores/stack.js.map +1 -1
  16. package/dist/command-classes.d.ts +1 -0
  17. package/dist/command-classes.d.ts.map +1 -1
  18. package/dist/command-classes.js +2 -2
  19. package/dist/command-classes.js.map +1 -1
  20. package/dist/contracts/agent.contract.d.ts +106 -0
  21. package/dist/contracts/agent.contract.d.ts.map +1 -1
  22. package/dist/contracts/agents.contract.d.ts +50 -0
  23. package/dist/contracts/agents.contract.d.ts.map +1 -1
  24. package/dist/contracts/runner.contract.d.ts +138 -92
  25. package/dist/contracts/runner.contract.d.ts.map +1 -1
  26. package/dist/contracts/secrets.contract.d.ts +50 -0
  27. package/dist/contracts/secrets.contract.d.ts.map +1 -1
  28. package/dist/contracts/secrets.contract.js +36 -1
  29. package/dist/contracts/secrets.contract.js.map +1 -1
  30. package/dist/contracts/sessions.contract.d.ts +37 -0
  31. package/dist/contracts/sessions.contract.d.ts.map +1 -1
  32. package/dist/contracts/settings.contract.d.ts +12 -0
  33. package/dist/contracts/settings.contract.d.ts.map +1 -1
  34. package/dist/contracts/system.contract.d.ts +67 -28
  35. package/dist/contracts/system.contract.d.ts.map +1 -1
  36. package/dist/definition.d.ts +8 -0
  37. package/dist/definition.d.ts.map +1 -1
  38. package/dist/events.d.ts +440 -1
  39. package/dist/events.d.ts.map +1 -1
  40. package/dist/events.js +40 -4
  41. package/dist/events.js.map +1 -1
  42. package/dist/index.d.ts +294 -0
  43. package/dist/index.d.ts.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 +1 -1
  55. package/dist/schemas/computers.d.ts.map +1 -1
  56. package/dist/schemas/computers.js +2 -1
  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/dist/workspace-state.d.ts +7 -0
  78. package/dist/workspace-state.d.ts.map +1 -1
  79. package/dist/workspace-state.js +8 -0
  80. package/dist/workspace-state.js.map +1 -1
  81. package/package.json +15 -4
  82. package/src/agent-catalog.test.ts +21 -4
  83. package/src/agent-catalog.ts +37 -13
  84. package/src/batch-runs.test.ts +167 -0
  85. package/src/batch-runs.ts +181 -0
  86. package/src/card-status.ts +13 -1
  87. package/src/chores/stack.test.ts +36 -0
  88. package/src/chores/stack.ts +13 -2
  89. package/src/command-classes.ts +8 -3
  90. package/src/contracts/secrets.contract.ts +67 -1
  91. package/src/events.ts +100 -11
  92. package/src/safety-policy.ts +23 -0
  93. package/src/schemas/agents.ts +6 -0
  94. package/src/schemas/computers.ts +15 -6
  95. package/src/schemas/engines.ts +1 -0
  96. package/src/schemas/plan-limits.ts +19 -0
  97. package/src/schemas/secrets.ts +110 -0
  98. package/src/schemas/settings.ts +45 -11
  99. package/src/transcript-fold.test.ts +47 -0
  100. package/src/transcript-fold.ts +11 -0
  101. package/src/workspace-state.test.ts +7 -0
  102. package/src/workspace-state.ts +28 -0
@@ -1,6 +1,8 @@
1
1
  // computers: what ONE of the user's own machines is running
2
2
  import { z } from "zod";
3
3
  import { HostFactsSchema } from "./hosts.js";
4
+ // The one sentinel every non-release build carries, so a locally compiled agent is never told it is behind.
5
+ import { DEV_VERSION } from "../versions.js";
4
6
  /* The other end of desktop sync, stated as a fact instead of a claim.
5
7
  *
6
8
  * Everything here already existed, as the machine agent's printed status on a terminal nobody running the desktop app
@@ -306,14 +308,21 @@ export type MachineReport = z.infer<typeof MachineReportSchema>;
306
308
  * status`) and the browser (the Computers row) both answer this question, and a machine that is behind in one and
307
309
  * fine in the other is worse than either answer alone. The remedy is the same in both: restart the loop.
308
310
  *
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 => {
311
+ * AN UNSTAMPED LOOP IS THE LOUDEST CASE, not a missing one, and reading it as "nothing to say" is what let this
312
+ * whole check miss the machines it was written for. `watcher.build` is stamped into the pidfile by the loop that
313
+ * claimed it, so a loop old enough to predate the stamp reports none and it is running, and something newer is
314
+ * installed beside it, which is a skew by definition and a wider one than any it could have named. Every surface
315
+ * therefore went quiet on precisely the machines furthest behind: upgrade, see the new number everywhere, watch
316
+ * nothing change, and have no screen anywhere say why.
317
+ *
318
+ * So the running build is OPTIONAL in the answer and the question is asked of the installed one. Still silent
319
+ * whenever the honest answer is "no idea": a loop that is stopped (nothing is serving, and every surface already
320
+ * says so in louder words), a machine with no installed agent to compare against, and a working-tree build, which
321
+ * is not a version and must not be told it is behind. */
322
+ export const watcherBuildSkew = (report: MachineReport): { readonly running: string | undefined; readonly installed: string } | undefined => {
314
323
  const running = report.watcher.build;
315
324
  const installed = report.agents.sync;
316
- if (!report.watcher.running || running === undefined || installed === undefined || running === installed) {
325
+ if (!report.watcher.running || installed === undefined || installed === DEV_VERSION || running === installed) {
317
326
  return undefined;
318
327
  }
319
328
  return { running, installed };
@@ -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[] = [
@@ -407,6 +407,8 @@ export class TranscriptFold {
407
407
  return this.park(event.requestId, { capabilityOffer: { requestId: event.requestId, offer: event.offer, status: "pending" } });
408
408
  case "payment_offer":
409
409
  return this.park(event.requestId, { paymentOffer: { requestId: event.requestId, offer: event.offer, status: "pending" } });
410
+ case "credential_offer":
411
+ return this.park(event.requestId, { credentialOffer: { requestId: event.requestId, offer: event.offer, status: "pending" } });
410
412
  case "resolved":
411
413
  // The card above was released, and the frame says how. The window that answered already froze its
412
414
  // own card the instant its reply was accepted (card-status.ts, the same derivation), so this is a
@@ -441,6 +443,15 @@ export class TranscriptFold {
441
443
  };
442
444
  }
443
445
  });
446
+ case "credential_receipt":
447
+ return this.patchParked(event.requestId, (row) => {
448
+ if (row.credentialOffer !== undefined) {
449
+ row.credentialOffer.receipt = {
450
+ outcome: event.outcome,
451
+ ...(event.approvedBy === undefined ? {} : { approvedBy: event.approvedBy }),
452
+ };
453
+ }
454
+ });
444
455
  // Facts about the turn, not rows in it (TURN_FACT_KINDS): the run relays them as themselves.
445
456
  case "session":
446
457
  case "init":
@@ -359,6 +359,13 @@ describe(`VERSIONED_STATE_PATHS`, () => {
359
359
  // raising that limit is a decision about every session sharing the box, and `git log` is the only
360
360
  // thing that answers "since when have we been allowing four of these at a time".
361
361
  `${STATE_DIR}/config/heavy-commands.json`,
362
+ /* The scripts the rules run. Tracked because the exclude list carves entries out BY NAME, so a
363
+ * directory nobody marked is invisible both to `git add -A` and to the land: that is how 559e896
364
+ * came to repoint settings.json at two scripts it never staged, and how the pair written in a
365
+ * worktree never arrived. What the workspace held from then on was settings naming two hooks that
366
+ * did not exist, and every edit for a day firing `node` at a missing file. A rule is reviewable;
367
+ * the code it runs on every write has to be. */
368
+ `${STATE_DIR}/config/hooks/`,
362
369
  `${STATE_DIR}/config/loop-designs.json`,
363
370
  `${STATE_DIR}/config/personas.json`,
364
371
  // A persona's own kit: the prompt it runs on and the skills only its turns reach. Tracked for the
@@ -273,6 +273,34 @@ const STATE_FILES = [
273
273
  * — raising the limit is a decision about everyone's sessions on that box, and `git log` is the only thing
274
274
  * that answers "since when have we allowed four of these at once". */
275
275
  { path: ".intentic/config/heavy-commands.json", invalidates: ["settings"], portability: "carry", versioned: true },
276
+ /* THE SCRIPTS THE RULE TABLE RUNS, one file per reader, at the moments settings.json points them at: the
277
+ * per-edit linter and byte scan today (`file.edited`), whatever the owner adds beside them tomorrow.
278
+ *
279
+ * IT IS HERE BECAUSE IT WAS NOT, and the gap was silent. The root repository excludes `.intentic/config/*`
280
+ * with one carve-out per tracked entry, derived from this table's `versioned` flags (history.ts
281
+ * rootExcludes), so a directory nobody marked is ignored — and an ignored file is invisible to the land.
282
+ * The two scripts were written in an agent's worktree; the rules naming them were written into
283
+ * settings.json, which IS tracked; and only the rules arrived. What the workspace held from then on was two
284
+ * hooks pointing at files that did not exist: a per-edit gate that looked armed on the settings screen,
285
+ * spawned a process on every edit, and read nothing. The linter and the byte scan ran nowhere between an
286
+ * edit and a push for as long as that stood.
287
+ *
288
+ * `versioned` for the strongest form of the reason the config slice generally is. This is not a setting that
289
+ * decides how the sandbox behaves, it is the CODE that runs when it does, against every file an agent
290
+ * writes: a change here deserves review more than a change to the rule that calls it. `carry`, because it is
291
+ * authored text about this workspace's own conventions, holding no credential and nothing about this
292
+ * machine. Invalidating nothing is a real answer here rather than a gap: the settings screen renders the
293
+ * RULES, which live in settings.json and have their own key. */
294
+ {
295
+ path: ".intentic/config/hooks/",
296
+ invalidates: [],
297
+ why: "The settings screen renders the rules that name these scripts, out of settings.json; nothing in the browser reads the scripts themselves.",
298
+ portability: "carry",
299
+ versioned: true,
300
+ // The daemon never BUILDS this path, it only runs what a rule's command names, so there is no statePath
301
+ // call for the coverage pair to find. Whoever authors a reader is the writer here.
302
+ outsideWriter: "the owner or an agent, authoring them; the daemon only ever RUNS one, by the path a rule's command names",
303
+ },
276
304
  // The rule table's last-fired stamps, beside the rules themselves. `derived` rather than `carry`: it is a
277
305
  // record of what happened in THIS sandbox, and carrying it to a fresh one would date every rule to work
278
306
  // that machine never did.