@intentic/sandbox-contract 1.212.0 → 1.214.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.
@@ -60,11 +60,23 @@ export interface WorkspaceStateFile extends StateFile {
60
60
  * the same reason. An ignore-pattern list would invert it: a credential store added next month would be
61
61
  * committed on its first write, and nothing would have had to change for that to happen.
62
62
  *
63
- * Only CONFIGURATION belongs here the small, slow-moving files that decide how this sandbox behaves. Two
64
- * kinds of entry are excluded on purpose even though they are `carry` and hold no secret:
65
- * - LEDGERS (workflow runs, loop iterations, thread bookkeeping, permission-usage batches), which are
66
- * rewritten on a timer or several times per step. Tracking them buries the owner's code review under
67
- * machine noise one of them is written every few seconds while a browser has the app open.
63
+ * WHAT EARNS IT is one question, asked of the entry rather than of its shape: does a change here change what
64
+ * this sandbox DOES? Two families answer yes.
65
+ * - CONFIGURATION the small, slow-moving files that decide how the sandbox behaves: settings, personas,
66
+ * skills, automations, workflow designs, the environment overlay, which extensions are on.
67
+ * - AUTHORED CONTENT WHOSE CONSEQUENCES LEAVE THE SANDBOX a workspace extension is code that runs in the
68
+ * app with a declared permission surface and a backend of its own; a post draft is words that go out
69
+ * under the owner's name. Neither is configuration, and reading `versioned` as config-only is what kept
70
+ * both of them out: an agent could write, and then run, a whole extension with a diff nowhere, and
71
+ * propose a public post that left no trace once declined. The rule was never "is this a setting", it was
72
+ * "can this be read, reverted and attributed" — and for the two things the AGENT authors on its own,
73
+ * that matters more than it does for a file a person edited on purpose.
74
+ *
75
+ * Two kinds of entry stay out on purpose even though they are `carry` and hold no secret:
76
+ * - LEDGERS (workflow runs, loop iterations, thread bookkeeping, permission-usage batches, held-wake
77
+ * queues), which are rewritten on a timer or several times per step. Tracking them buries the owner's
78
+ * code review under machine noise — one of them is written every few seconds while a browser has the app
79
+ * open. A queue is a ledger too: it records that something was ASKED, and is emptied when it is answered.
68
80
  * - BULK (session transcripts, artifacts), which are hundreds of megabytes of constantly-rewritten
69
81
  * content. They travel in a bundle; they do not belong in a diff.
70
82
  * `versioned` is therefore NARROWER than `carry`, and the two answer different questions: carry is "does it
@@ -74,7 +86,10 @@ export interface WorkspaceStateFile extends StateFile {
74
86
  * human- or agent-written TEXT that a workspace search should surface? Every `versioned` entry already is
75
87
  * (a setting, a persona, a skill — things the agent is asked to find and edit), so this flag exists only
76
88
  * for the entries that are authored content without being config: a draft awaiting approval, a staged
77
- * README, an extension the agent wrote in place. Everything else under `.intentic` is machine state, and
89
+ * README, an extension the agent wrote in place. Two of those three are now `versioned` as well, which makes
90
+ * the flag redundant to `SEARCHABLE_STATE_PATHS` on them and is why it stays anyway: searchability is a
91
+ * property of the content, and hanging it on `versioned` would mean a future decision to stop TRACKING a
92
+ * draft silently also stopped anyone FINDING one. Everything else under `.intentic` is machine state, and
78
93
  * `SEARCHABLE_STATE_PATHS` below is what lets the search engine deny the rest BY DEFAULT instead of
79
94
  * hand-keeping a deny list that goes stale the day a store is added (which is how a 98 kB loop ledger and
80
95
  * whole third-party extension checkouts ended up ranking in code search). */
@@ -94,12 +109,31 @@ export interface WorkspaceStateFile extends StateFile {
94
109
  const STATE_FILES = [
95
110
  /* A capability add/remove recomposes the environment overlay and can add or drop a repo's panel.
96
111
  *
97
- * Each entry's `config` carries that capability's credential (an mcp server's token, a Komodo key, an ssh
98
- * key), so the manifest is a secret in full. It is also what composeEnvironment reads its Dockerfile
99
- * fragments from, which makes this the entry where the owner's export choice has the most visible
100
- * consequence: a bundle exported WITHOUT secrets rebuilds a stock overlay, and the import report has to
101
- * name every capability the target needs re-added before its environment matches again. */
102
- { path: ".intentic/capabilities.json", invalidates: ["capabilities", "environment", "panels", "manifests"], portability: "secret" },
112
+ * SPLIT ALREADY and this entry's classification had not caught up, which is the whole of what changed here.
113
+ * It read `secret` on a claim that had stopped being true: that each entry's `config` carries that
114
+ * capability's credential, so the manifest is a secret in full. It does not. capabilities-store.ts's
115
+ * withSecretVault keeps credential VALUES off /work entirely the manifest holds `__intentic_vaulted__`
116
+ * where one used to be, reads rehydrate so no caller noticed, and main.ts sweeps a hand-written value out at
117
+ * boot. What is left is the SHAPE of a connection: a kind, a URL, a username, a purpose, which permissions a
118
+ * connected computer was granted.
119
+ *
120
+ * WHICH KEYS THOSE ARE IS DERIVED, not listed a second time: `echo` already answers "what of this config may
121
+ * a browser see", and the credential keys are exactly its complement (capabilities/secret-fields.ts). A kind
122
+ * that starts withholding a new field starts vaulting it on the same commit. That is what makes this
123
+ * classification a property of the code rather than a promise to re-audit it — the reason the entry can be
124
+ * reclassified at all, and the reason a hand-kept "these fields are safe" list could not have earned it.
125
+ *
126
+ * `carry`, and this is the entry where that earns the most. composeEnvironment reads its Dockerfile fragments
127
+ * from here, so a bundle that dropped it arrived on a stock overlay with an import report listing every
128
+ * connection to re-add by hand. It now arrives listing them itself, each visibly unconnected and waiting for
129
+ * one credential apiece — the shape personas.json has had all along, for the same reason.
130
+ *
131
+ * `versioned`, which is the point. Connecting this sandbox to a deployment orchestrator, or granting a
132
+ * connected computer shell and screen control, is the largest change anyone makes to what it can DO, and it
133
+ * left a diff nowhere. One consequence worth stating rather than discovering: an identifier that pairs with a
134
+ * credential — Komodo's api key beside its api secret, which its own connector card calls "like a database
135
+ * user" — is echoed, and therefore lands in the diff exactly as a database username would. */
136
+ { path: ".intentic/capabilities.json", invalidates: ["capabilities", "environment", "panels", "manifests"], portability: "carry", versioned: true },
103
137
 
104
138
  /* Which workspace-derived recommendations the owner has said "not needed" to, and the evidence each was
105
139
  * declined against. It rides the `capabilities` key because the catalog is what changes when one lands, and
@@ -159,10 +193,22 @@ const STATE_FILES = [
159
193
  portability: "derived",
160
194
  note: "Stamps of when each rule last did something; the new sandbox starts its own record.",
161
195
  },
162
- // Written by the AGENT's file tools (the drafts skill), read by the owner's approval inbox — the one entry
163
- // here whose whole point is that a change arrives from outside the browser that renders it. `authored`:
164
- // a draft is text somebody wrote, and "find the reddit draft about X" is an ordinary search.
165
- { path: ".intentic/drafts/", invalidates: ["drafts"], portability: "carry", authored: true },
196
+ /* Written by the AGENT's file tools (the drafts skill), read by the owner's approval inbox — the one entry
197
+ * here whose whole point is that a change arrives from outside the browser that renders it. `authored`:
198
+ * a draft is text somebody wrote, and "find the reddit draft about X" is an ordinary search.
199
+ *
200
+ * `versioned` because a draft is the furthest-reaching thing the agent writes: these words go out under the
201
+ * owner's name, to an audience, and cannot be recalled. The approval inbox already gates that — but a gate is
202
+ * not a record. Declining one used to erase it, so the question "what has this agent tried to post" had no
203
+ * answer at all, and an approved post's own history (what was proposed, what the owner changed, when it
204
+ * actually went) lived only in a file nobody could diff.
205
+ *
206
+ * It costs almost nothing to track, which is why the ledger objection does not reach it: a draft is one small
207
+ * file per post, it is written a handful of times across its whole life (proposed → approved → posted, with
208
+ * `postedAt`/`postedUrl` stamped at the end), and it is KEPT afterwards rather than consumed — so tracking
209
+ * yields a durable record instead of the add/delete churn a queue would produce. Nothing in a draft is a
210
+ * credential: it is a platform, a target URL and the body itself, all of it bound for publication anyway. */
211
+ { path: ".intentic/drafts/", invalidates: ["drafts"], portability: "carry", versioned: true, authored: true },
166
212
  // ---- declared by the extension that renders them (contributes.files), not here ----
167
213
  // The path is the DAEMON's (automations-store writes both), the query keys are the intentic.automations
168
214
  // extension's. It declares them in its own manifest and the browser unions the two lists, so uninstalling
@@ -263,16 +309,33 @@ const STATE_FILES = [
263
309
  why: "Thread bookkeeping (an inbound thread — a Doorbell visitor, a Discord or Slack channel — → sandbox conversation + provider session), written on EVERY inbound message. Nothing in the browser reads it: what a thread produces is a conversation, and the fleet board already learns about that from the agent registry's own push. Naming a key here would bill every connected browser a refetch per inbound message — the request storm this table's own note warns about — to refresh nothing it can see.",
264
310
  portability: "carry",
265
311
  },
266
- /* Values are a primitive union an extension chooses the meaning of, and "an API key for the service I talk
267
- * to" is squarely within it — so this is classed by what it CAN hold, not by what any particular extension
268
- * happens to put there. The alternative reads the wrong way round: a bundle that leaked one extension's
269
- * token would have been correct about all the others. */
312
+ /* SPLIT, so that "what an extension is configured to do" and "the token it does it with" stop being one file.
313
+ *
314
+ * This entry used to be `secret` and untracked, classed by what a value COULD hold: values are a primitive
315
+ * union an extension chooses the meaning of, and "an API key for the service I talk to" is squarely within
316
+ * it. That classification was honest about the risk and wrong about the file — it meant an extension's whole
317
+ * configuration was unreviewable because one of its keys might be a credential, AND the credential was in
318
+ * there anyway, in a file the workspace API does not lock. A turn could simply read it.
319
+ *
320
+ * A descriptor already says which keys those are (`contributes.settings[].secret`), so the values it names
321
+ * now live in the vault off /work and this file keeps the rest — the capability manifest's split, applied to
322
+ * the same problem one table over (extensions/extension-settings.ts holds it, and the reasoning). Reads
323
+ * rehydrate, so no caller changed.
324
+ *
325
+ * What the split earns: `carry`, because what is left is an extension's configuration and a bundle should
326
+ * arrive with it; and `versioned`, because turning an extension's behaviour on is a decision, and the file
327
+ * that records it can now be read without reading anybody's token. The boot sweep is what keeps that true of
328
+ * a file the agent can also edit — see vaultExtensionSettingSecrets.
329
+ *
330
+ * NO `note`, and the split is why: a note is printed by the import report beside a SKIPPED entry, so an entry
331
+ * that carries can never show one. "Re-enter the credentials" is now the vault's instruction to give, and the
332
+ * vault is under `.intentic/auth/` — which is skipped, and says so there. */
270
333
  {
271
334
  path: ".intentic/extension-settings.json",
272
335
  invalidates: [],
273
336
  why: "Held in a module-level shallowRef store per extension (web's extensionSettingsStore) with no query observer, and deliberately so: api.settings.get must answer SYNCHRONOUSLY from an extension's first activate() line, and the store outlives every component scope. A module-level QueryObserver is the one shape that would make invalidation refetch, and this app already ruled it out — it detaches on the queryClient.clear() at logout (see useSandbox's sandbox-list mirror). So a remote member's setting edit reaches this browser on its next load, not live.",
274
- portability: "secret",
275
- note: "Re-enter each extension's settings on the Extensions tab.",
337
+ portability: "carry",
338
+ versioned: true,
276
339
  },
277
340
  /* Unlike the settings file above it, the on/off switch IS observed by a query — the Extensions tab's list,
278
341
  * which carries each row's switch position — so a flip made elsewhere (another member, the agent writing the
@@ -289,8 +352,21 @@ const STATE_FILES = [
289
352
  * an extension and it is live for the daemon and every session at once, since .intentic is shared), so this
290
353
  * push is what makes one appearing or changing show up on the Extensions tab while the owner watches.
291
354
  * `authored` for the same reason as drafts: this is source the agent wrote and will be asked to find —
292
- * unlike `.intentic/extensions/` below, which is CLONES of source that lives elsewhere. */
293
- { path: ".intentic/workspace-extensions/", invalidates: ["extensions"], portability: "carry", authored: true },
355
+ * unlike `.intentic/extensions/` below, which is CLONES of source that lives elsewhere.
356
+ *
357
+ * `versioned` FOR THAT SAME REASON, which is the whole argument. Every other load path an extension can take
358
+ * is already reviewable by construction: a git-installed one is a sha in `capabilities.json` that an owner
359
+ * approved, a baked one shipped in the image. This one is neither — it is code that appears because an agent
360
+ * wrote a file, runs in the app on the owner's session, may register a rail tile, and may serve HTTP from a
361
+ * node process with the workspace under `node:fs` and whatever `permissions.daemon` names. Untracked, the
362
+ * switch that turns it on was in `git log` (extension-enablement.json, below) while the thing being switched
363
+ * on was not: a commit could record enabling something nobody else could read. It is also the one extension
364
+ * kind with no install moment to review at, so the diff is the only review there is.
365
+ *
366
+ * Not a ledger and not bulk: a handful of small authored files per extension, written when someone edits them.
367
+ * The daemon restarts the backend host on a change here, so an edit is already a consequential event — this
368
+ * makes it a legible one. */
369
+ { path: ".intentic/workspace-extensions/", invalidates: ["extensions"], portability: "carry", versioned: true, authored: true },
294
370
  /* What the registry comparison found per installed extension (update available / advisory / post-update
295
371
  * health), written by the periodic check and by the update/revert transactions — pushed to the tab because
296
372
  * an advisory that auto-disabled something must not wait for a reload to be seen. */
@@ -306,6 +382,17 @@ const STATE_FILES = [
306
382
  why: "Which of the routes each extension DECLARED it has actually called — the evidence behind the permissions list on its row. The one entry here whose empty set is a RATE decision rather than an architectural one: every browser with the app open reports its batch on a timer, so wiring this to the `extensions` query would refetch the whole list every few seconds for a figure nobody is watching change. The tab reads it when it loads, which is when anyone is reading it.",
307
383
  portability: "carry",
308
384
  },
385
+ /* THE ONE ENTRY WHERE "HOLDS NO CREDENTIAL" IS TRUE AND `versioned` IS STILL WRONG, which is worth stating
386
+ * because it looks like the two above it: an email and a role per row, nothing to vault, and "who may drive
387
+ * this sandbox" is as consequential a fact as any this table tracks.
388
+ *
389
+ * It stays out for two reasons that are not about secrecy. It is a MIRROR — the platform's invite records are
390
+ * the grant, this is the copy the enforcer keeps so a grant it never received is never honoured, and a change
391
+ * here is the two disagreeing rather than anyone deciding something. Review of the decision already exists,
392
+ * on the Access tab, against the record that is authoritative. And tracking it would mean reclassifying it
393
+ * `carry` to satisfy the guard, which is the one thing it must never be: an access list that travelled would
394
+ * let a source sandbox hand itself the target's ownership. Widening the guard for this single entry is the
395
+ * worse trade — it protects every `identity` entry, and most of those ARE credentials. */
309
396
  {
310
397
  path: ".intentic/members.json",
311
398
  invalidates: [],
@@ -318,13 +405,20 @@ const STATE_FILES = [
318
405
  /* Keep credentials and conversation state in disjoint top-level trees. Provider homes are intentionally
319
406
  * classified as a single secret unit: several CLIs mix OAuth, config, and provider-native thread metadata,
320
407
  * and no generic export can safely distinguish those files. The broad root also makes a newly-added provider
321
- * secret by construction instead of relying on another hand-maintained provider-name list. */
408
+ * secret by construction instead of relying on another hand-maintained provider-name list.
409
+ *
410
+ * IT IS NO LONGER ONLY THE AI LOGINS. Both credential splits put their vault here — `capability-secrets.json`
411
+ * and `extension-secrets.json`, sited beside the provider homes precisely because this tree is already
412
+ * outside the file routes, the workspace walk and the search index (composition.ts sites them, and the two
413
+ * stores argue why). So this is now the ONE entry a secret-less bundle leaves behind, and its note is
414
+ * therefore the only place the owner is told what to re-enter: the manifests that name those connections
415
+ * travel, and would otherwise arrive looking complete. */
322
416
  {
323
417
  path: ".intentic/auth/",
324
418
  invalidates: [],
325
- why: "AI-provider credentials and runtime homes; each account is rendered through owner-gated provider routes.",
419
+ why: "AI-provider credentials and runtime homes, plus the capability and extension-settings secret vaults; each account is rendered through owner-gated provider routes.",
326
420
  portability: "secret",
327
- note: "Sign the agent's AI accounts in again on the Agent tab.",
421
+ note: "Sign the agent's AI accounts in again on the Agent tab, then re-enter each connection's credential on Capabilities and each extension's secret settings on Extensions — both arrived listed but unauthenticated.",
328
422
  },
329
423
  /* Agent session transcripts, rewritten on every streamed token.
330
424
  *
@@ -498,8 +592,13 @@ export const VERSIONED_STATE_PATHS: readonly string[] = WORKSPACE_STATE_FILES.fi
498
592
  * ledger is forgotten from, and the forgetting is silent — it ranked loop iteration history and cloned
499
593
  * third-party extension source against the user's own code for months before this derivation existed.
500
594
  *
501
- * Note what falls out without a special case: `capabilities.json` is `secret` and unversioned, so the index
502
- * stops copying capability tokens into search text the exact boundary the floor already drew for `auth/`. */
595
+ * WHAT THE CREDENTIAL SPLITS MOVED ACROSS THIS LINE, since the note that used to sit here said the opposite and
596
+ * was worth replacing rather than deleting. `capabilities.json` was `secret` and unversioned, and the sentence
597
+ * celebrated that the index therefore stopped copying capability tokens into search text. It is `versioned` now
598
+ * and searchable — and the guarantee is unchanged, because the tokens are not in the file any more. The floor
599
+ * moved from "keep the index away from the file that holds credentials" to "the file holds none", which is the
600
+ * stronger of the two: it also holds for the shell, which never consulted this list at all. `auth/` — where those
601
+ * values went, both vaults included — is still denied, and is the entry that was doing the real work all along. */
503
602
  export const SEARCHABLE_STATE_PATHS: readonly string[] = WORKSPACE_STATE_FILES.filter((file) => file.versioned || file.authored).map(
504
603
  (file) => file.path,
505
604
  );