@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.
- package/README.md +4 -1
- package/dist/agent-catalog.d.ts +2 -1
- package/dist/agent-catalog.d.ts.map +1 -1
- package/dist/agent-catalog.js +3 -2
- package/dist/agent-catalog.js.map +1 -1
- package/dist/batch-runs.d.ts +32 -0
- package/dist/batch-runs.d.ts.map +1 -0
- package/dist/batch-runs.js +43 -0
- package/dist/batch-runs.js.map +1 -0
- package/dist/card-status.d.ts.map +1 -1
- package/dist/card-status.js +8 -1
- package/dist/card-status.js.map +1 -1
- package/dist/chores/stack.d.ts.map +1 -1
- package/dist/chores/stack.js +2 -2
- package/dist/chores/stack.js.map +1 -1
- package/dist/command-classes.d.ts +1 -0
- package/dist/command-classes.d.ts.map +1 -1
- package/dist/command-classes.js +2 -2
- package/dist/command-classes.js.map +1 -1
- package/dist/contracts/agent.contract.d.ts +106 -0
- package/dist/contracts/agent.contract.d.ts.map +1 -1
- package/dist/contracts/agents.contract.d.ts +50 -0
- package/dist/contracts/agents.contract.d.ts.map +1 -1
- package/dist/contracts/runner.contract.d.ts +138 -92
- package/dist/contracts/runner.contract.d.ts.map +1 -1
- package/dist/contracts/secrets.contract.d.ts +50 -0
- package/dist/contracts/secrets.contract.d.ts.map +1 -1
- package/dist/contracts/secrets.contract.js +36 -1
- package/dist/contracts/secrets.contract.js.map +1 -1
- package/dist/contracts/sessions.contract.d.ts +37 -0
- package/dist/contracts/sessions.contract.d.ts.map +1 -1
- package/dist/contracts/settings.contract.d.ts +12 -0
- package/dist/contracts/settings.contract.d.ts.map +1 -1
- package/dist/contracts/system.contract.d.ts +67 -28
- package/dist/contracts/system.contract.d.ts.map +1 -1
- package/dist/definition.d.ts +8 -0
- package/dist/definition.d.ts.map +1 -1
- package/dist/events.d.ts +440 -1
- package/dist/events.d.ts.map +1 -1
- package/dist/events.js +40 -4
- package/dist/events.js.map +1 -1
- package/dist/index.d.ts +294 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/safety-policy.d.ts +6 -0
- package/dist/safety-policy.d.ts.map +1 -1
- package/dist/safety-policy.js +1 -0
- package/dist/safety-policy.js.map +1 -1
- package/dist/schemas/agents.d.ts +4 -0
- package/dist/schemas/agents.d.ts.map +1 -1
- package/dist/schemas/agents.js +3 -0
- package/dist/schemas/agents.js.map +1 -1
- package/dist/schemas/automations.d.ts +1 -0
- package/dist/schemas/automations.d.ts.map +1 -1
- package/dist/schemas/computers.d.ts +1 -1
- package/dist/schemas/computers.d.ts.map +1 -1
- package/dist/schemas/computers.js +2 -1
- package/dist/schemas/computers.js.map +1 -1
- package/dist/schemas/engines.d.ts +2 -0
- package/dist/schemas/engines.d.ts.map +1 -1
- package/dist/schemas/engines.js +1 -0
- package/dist/schemas/engines.js.map +1 -1
- package/dist/schemas/plan-limits.d.ts +4 -0
- package/dist/schemas/plan-limits.d.ts.map +1 -1
- package/dist/schemas/plan-limits.js +7 -0
- package/dist/schemas/plan-limits.js.map +1 -1
- package/dist/schemas/secrets.d.ts +75 -0
- package/dist/schemas/secrets.d.ts.map +1 -1
- package/dist/schemas/secrets.js +44 -0
- package/dist/schemas/secrets.js.map +1 -1
- package/dist/schemas/settings.d.ts +6 -0
- package/dist/schemas/settings.d.ts.map +1 -1
- package/dist/schemas/settings.js +7 -0
- package/dist/schemas/settings.js.map +1 -1
- package/dist/transcript-fold.d.ts.map +1 -1
- package/dist/transcript-fold.js +11 -0
- package/dist/transcript-fold.js.map +1 -1
- package/dist/workspace-state.d.ts +7 -0
- package/dist/workspace-state.d.ts.map +1 -1
- package/dist/workspace-state.js +8 -0
- package/dist/workspace-state.js.map +1 -1
- package/package.json +15 -4
- package/src/agent-catalog.test.ts +21 -4
- package/src/agent-catalog.ts +37 -13
- package/src/batch-runs.test.ts +167 -0
- package/src/batch-runs.ts +181 -0
- package/src/card-status.ts +13 -1
- package/src/chores/stack.test.ts +36 -0
- package/src/chores/stack.ts +13 -2
- package/src/command-classes.ts +8 -3
- package/src/contracts/secrets.contract.ts +67 -1
- package/src/events.ts +100 -11
- package/src/safety-policy.ts +23 -0
- package/src/schemas/agents.ts +6 -0
- package/src/schemas/computers.ts +15 -6
- package/src/schemas/engines.ts +1 -0
- package/src/schemas/plan-limits.ts +19 -0
- package/src/schemas/secrets.ts +110 -0
- package/src/schemas/settings.ts +45 -11
- package/src/transcript-fold.test.ts +47 -0
- package/src/transcript-fold.ts +11 -0
- package/src/workspace-state.test.ts +7 -0
- package/src/workspace-state.ts +28 -0
package/src/schemas/computers.ts
CHANGED
|
@@ -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
|
-
*
|
|
310
|
-
*
|
|
311
|
-
*
|
|
312
|
-
*
|
|
313
|
-
|
|
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 ||
|
|
325
|
+
if (!report.watcher.running || installed === undefined || installed === DEV_VERSION || running === installed) {
|
|
317
326
|
return undefined;
|
|
318
327
|
}
|
|
319
328
|
return { running, installed };
|
package/src/schemas/engines.ts
CHANGED
|
@@ -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),
|
package/src/schemas/secrets.ts
CHANGED
|
@@ -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({
|
package/src/schemas/settings.ts
CHANGED
|
@@ -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
|
|
719
|
-
*
|
|
720
|
-
*
|
|
721
|
-
*
|
|
722
|
-
*
|
|
723
|
-
*
|
|
724
|
-
*
|
|
725
|
-
*
|
|
726
|
-
*
|
|
727
|
-
*
|
|
728
|
-
*
|
|
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[] = [
|
package/src/transcript-fold.ts
CHANGED
|
@@ -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
|
package/src/workspace-state.ts
CHANGED
|
@@ -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.
|