@ai-ecoverse/slicc-spectrum 2.1.0 → 2.2.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 CHANGED
@@ -335,6 +335,11 @@ Sudo and guest requests show the verdict under the command, in a live region (`.
335
335
  Focus starts on Deny (on Close once `decide` rejects) and goes back, after Deny, Always allow, Allow once, <kbd>Esc</kbd> or Close, to what had it when the dialog opened; inside a terminal, to that `<slicc-terminal>` through its own `focus()`, even when it remounted meanwhile.
336
336
  Guest requests (`guest-message`, `guest-tool`) get their own copy, the guest's text as plain text (`[data-guest-text]`), attachments (`[data-attachments]`), the automatic check's verdict (`[data-verdict]`), and Deny and Allow once, plus **Always allow** (`data-action="always"`) with a hint saying what it covers when the approval's `always` is `true`; the dialog body carries `[data-approval-kind]` and fills the dialog's width at every size.
337
337
  Tray requests (`tray`) get their own title and one sentence (`#asking`), and only **Deny** (`deny`, focused) and **Allow once** (`once`), never Always allow.
338
+ Secret requests (`secret`, `dialog[data-kind=secret]`) read "Enter a value for {name}?" (`mode: 'enter'`) or "Reveal {name} to a program?" (`reveal`). Under the title comes the trusted block, which only bios and the kernel fill: "{name} ran this[ in {where}]:" for an agent or "You ran this[ in {where}] (as {name}):" for a user, then the command (`[data-command]`), which is `process.exe` followed by `argv[1…]`, each word shell-quoted (`argv[0]` is the program's own choice), then "In {cwd}". The command follows sudo's rules: the same scrolling box, the same control-character chips, and the same end-in-view rule. Then the untrusted `prompt`, if any, in its own block (`[data-prompt]`) under "From the agent, not checked by SLICC:", with control characters as chips and cut at 200 characters. With `reveal`, a negative block (`[data-reveal]`) says "{exe} gets the plaintext. It can send it anywhere.", and adds "The agent asked for this." when `requester.kind` is `agent`.
339
+ The body is the vault's own frame (`[data-value-frame]`, a `<slicc-vault-value>` on `vault.valueFrame(id)`). The value is typed and Save or Allow clicked there, so spectrum never sees the value and can't fake the consent. The frame is `inert` until the end of the command has been in view, and "Scroll to the end of the command to answer it." (`[data-allow-hint]`) shows while it isn't. Without `vault.valueFrame`, "The vault isn’t available here, so this request can only be denied." (`[data-id="no-frame"]`) stands in its place.
340
+ The title shows control characters in the name as chips, and so does the notice. The only button is **Deny** (`deny`, focused). The element's `decide()` takes only `deny` for a secret, and only while that request is the one showing: not once it was denied, answered in its frame, closed, or ended (timed out, answered elsewhere, withdrawn). A late `escape` from the frame does nothing either.
341
+ When a listed request is replaced (the same `id` with another `createdAt`, command, mode, name, `reveal`, prompt or requester), or an `id` comes back after it left `list()`, the dialog counts it as a new request: a new frame, the end of its command unseen again, and the half-second guard again. Escape on the dialog denies, and so does the frame's `escape` event, posted when Escape is pressed inside the frame. The frame's `done` for this request closes the dialog and moves on to the next request, with no "answered" dialog. The frame's `gone` shows "Secret request timed out" (`expired`) or "Answered elsewhere", or closes silently (`withdrawn`), except for a request this tab denied or answered. The frame's `error` shows its message under the frame (`[data-frame-error]`). A secret request that leaves `list()` without `ended` before `expiresAt` closes silently, because the frame's `done` and the list change can arrive in either order. Focus then goes back as for sudo, to the `<slicc-terminal>` that had it.
342
+ `approvalKinds` (from `/ui`) is a frozen array of the kinds this build shows (`sudo`, `guest-message`, `guest-tool`, `tray`, `secret`), so an embedder can hold back the kinds an older UI would leave waiting unseen.
338
343
 
339
344
  #### `<slicc-follow>`
340
345
 
@@ -480,7 +485,7 @@ The secrets, sorted by name (`li[data-secret][data-state]`, with `[data-rules]`,
480
485
  Locked, every form (Add secret, Replace value, Import, Export) says "Unlock the vault first" (`p[data-locked]`) next to the vault's own small **Unlock** button (`slicc-vault-button[data-action=unlock]`) and disables its submit; once unlocked, the form stays open with what was typed and its submit enables.
481
486
  Vault errors show their message, EACCES as "Unlock the vault first", an EACCES with `cause: 'policy-paused'` as "Secrets are paused until the missing policy file is back", and ENOTSUP as "This arrives with a later vault version".
482
487
  Each action shows only when the port has its method.
483
- Then **Rules SLICC refused** (`[data-refused]`), where the rules live (`[data-policy]`) and **Activity** (`ol.audit li[data-seq][data-type]`; an entry with `uid` and `tag` both `null` reads "by you", a `refresh` entry "Refreshed automatically" (with the sign-in it used), an `endpoints` entry (vault-originated, no "by …") one part per field it carries: (1.91.0's `EndpointsEntry` had a required `purged` with `url`; no vault wrote it before 1.13.0, so the shape was corrected rather than kept, with `purged` optional and `host` replacing `url`) `purged` "Removed custom model URLs an agent set, automatically: {provider name} at {host}, …. Only you can set a model URL now, in Settings › Accounts" (the provider's name from `settings.accounts()`, else its id), `adopted` "Kept the MCP servers your tokens use, automatically: {server} at {host}, …", `pinned` "{name} pinned to {host} automatically, on first use" with **Unpin** (`data-action=unpin`, calling the vault port's optional `unpin([name])`; disabled while the vault is locked; a failure shows in the row as `[data-error=unpin]`, in the same words as other vault failures; it's hidden once a newer `unpinned` entry for that name is in the log), and `unpinned` "{name} unpinned from {host}; the next use pins it again", an `mcp` entry "Sent to the MCP server SERVER at DOMAIN" with its request and exit, a `session` entry (a value the vault handed the kernel for a verified TLS session, vault 1.15.0) "Sent over {HTTP/1.1, SMTP or Postgres} to {domain}:{port}, {trusted by public roots, or trusted by its pinned anchor}, through {exit}" (no trust part when an older entry has no `verifiedBy` or a newer vault sends one spectrum doesn't know; a protocol it doesn't know reads as its raw name), and a policy entry lists its changes, its warning and, when accepted, how to change the file back, except that an entry with `absent` reads "{file} was removed. Secrets are paused until it’s back." and the accepted entry that ends its pause reads "{file} is back. Secrets are running again." ("Secrets stay paused until the other file is back." while the other file is still missing), neither with the way back; **Only reveals to programs**, `data-action="reveals"`, and **Show older**, `data-action="older"`).
488
+ Then **Rules SLICC refused** (`[data-refused]`), where the rules live (`[data-policy]`) and **Activity** (`ol.audit li[data-seq][data-type]`; an entry with `uid` and `tag` both `null` reads "by you", a `refresh` entry "Refreshed automatically" (with the sign-in it used), an `endpoints` entry (vault-originated, no "by …") one part per field it carries: (1.91.0's `EndpointsEntry` had a required `purged` with `url`; no vault wrote it before 1.13.0, so the shape was corrected rather than kept, with `purged` optional and `host` replacing `url`) `purged` "Removed custom model URLs an agent set, automatically: {provider name} at {host}, …. Only you can set a model URL now, in Settings › Accounts" (the provider's name from `settings.accounts()`, else its id), `adopted` "Kept the MCP servers your tokens use, automatically: {server} at {host}, …", `pinned` "{name} pinned to {host} automatically, on first use" with **Unpin** (`data-action=unpin`, calling the vault port's optional `unpin([name])`; disabled while the vault is locked; a failure shows in the row as `[data-error=unpin]`, in the same words as other vault failures; it's hidden once a newer `unpinned` entry for that name is in the log), and `unpinned` "{name} unpinned from {host}; the next use pins it again", an `mcp` entry "Sent to the MCP server SERVER at DOMAIN" with its request and exit, a `reveal` entry (vault 1.23.0, a secret ask whose plaintext the user allowed) "Revealed to {argv} in {cwd}, allowed in a dialog", (the argv, cwd and "by …" of `materialize` and `reveal` entries, and of a secret's "Revealed to programs…, last by … with {argv}" line, show control characters as chips, as the approval dialog does), which counts as a reveal for **Only reveals to programs** and for the secret's "Revealed to programs…" line, a policy change of `[reveal] agent` "Programs an agent runs may no longer ask for a secret’s plaintext" (or "…may ask for a secret’s plaintext again, in a dialog each time"), a `session` entry (a value the vault handed the kernel for a verified TLS session, vault 1.15.0) "Sent over {HTTP/1.1, SMTP or Postgres} to {domain}:{port}, {trusted by public roots, or trusted by its pinned anchor}, through {exit}" (no trust part when an older entry has no `verifiedBy` or a newer vault sends one spectrum doesn't know; a protocol it doesn't know reads as its raw name), and a policy entry lists its changes, its warning and, when accepted, how to change the file back, except that an entry with `absent` reads "{file} was removed. Secrets are paused until it’s back." and the accepted entry that ends its pause reads "{file} is back. Secrets are running again." ("Secrets stay paused until the other file is back." while the other file is still missing), neither with the way back; **Only reveals to programs**, `data-action="reveals"`, and **Show older**, `data-action="older"`).
484
489
  The section's `data-vault` is `locked`, `unlocked` or `loading`.
485
490
  A popup the browser blocked shows how to allow it (`[data-popup=blocked]`).
486
491
  Widened-rule notices aren't shown.
@@ -490,8 +495,14 @@ An open **Removed** list (`details[data-removed]`) stays open across a layout sw
490
495
 
491
496
  Places `vault.button()`'s element (the vault's own button frame), posts its `config` (`ButtonConfig` plus `theme`) once the button is `ready`, sets the frame's `color-scheme` to `theme` (or leaves it to the page) so the frame stays transparent, takes the reported `size` (with the focus-ring `inset` as negative margin) and `label` (as its title), moves focus to the control before or after it when the frame reports `focus` (`previous` or `next`), and fires `vault-popup` (bubbles, composed) with each `popup` event's `{ intent, state }`. spectrum sends only the `unlock` and `protect` intents.
492
497
 
498
+ #### `<slicc-vault-value>`
499
+
500
+ Places `vault.valueFrame(request)`'s element (the vault's `value.html` frame) and, once the frame is `ready`, posts `{ type: 'slicc-vault', v: 1, op: 'value', request, config }`, where `config` is a `ValueConfig` (`theme`, `dir`, `lang`, `scale`). It posts again when `config` changes, and mounts a new frame when `vault` or `request` changes. It sets the frame's `color-scheme` to `theme`. From `size` it takes the height, at most 480 px (and at most `45dvh`, beyond which the frame scrolls), and makes the frame its own width, with the `inset` (at most 4 px) as a negative margin above and below only, so the dialog never scrolls sideways. A value that isn't a finite number counts as 0. The element clips the frame to its own box (with room above and below for the largest inset it allows, 4 px, for the focus ring), so a frame can't paint over the command above it or the dialog's buttons. On the frame's `focus` event it moves focus to the control after or before the frame (`next` or `previous`), within its `<dialog>` and wrapping around. `focus()` posts `{ op: 'focus' }`, which focuses the frame's first control. It fires `vault-value` (bubbles, composed) with each `done`, `gone`, `error` and `escape` event. The detail holds only `event`, `request`, `state`, `code` and `message` (each when it's a string), so anything else the frame sends goes no further.
501
+
493
502
  #### `<slicc-value-request>`
494
503
 
504
+ For the older `value` requests only, until bios stops sending them: `ask` requests show in `<slicc-approvals>` instead.
505
+
495
506
  The vault's waiting `value` requests (`fnox set` on a terminal) as a modal dialog, one at a time, oldest first, with "N more waiting"; `<slicc-app>` mounts it.
496
507
  `dialog[data-request]`: "Enter a value for NAME" (a Name field when the request has no name), who asks, as on the Settings card ("docs-pass asks for NAME", or "A program running as uid N asks for NAME"), the prompt under "From the agent, not checked by SLICC:", a masked value field with the same rules as Settings › Secrets, "Expires in N min", and **Later** (`data-action="later"`, or Escape: closes, the request stays in Settings › Secrets, whose **Enter value** reopens it), **Cancel request** (`data-action="cancel-request"`, with `cancel`) and **Save** (`data-action="save"`, `put(name, value, request.id)`, with `put`).
497
508
  Locked, it shows the vault's Unlock button and disables Save.
@@ -967,6 +978,9 @@ It has no `always`: the dialog never offers Always allow for it, even when the i
967
978
  Its dialog reads "Share this seven’s join URL?" with "{name} wants this seven’s join URL. Anyone with it can ask to link to this seven.", "Make a new join URL?" with "{name} wants a new join URL. Linked devices move to it, and the old one stops working.", or "Unlink {device.name}?" with "{name} wants to unlink {device.name} from this seven." ("Unlink a device?" and "…unlink a device from this seven." without `device`), then the countdown, **Deny** and **Allow once**.
968
979
  Ended tray requests read "Answered in another tab" or "Tray request timed out", and a withdrawn one closes silently.
969
980
  Without `decide`, its notice reads "Waiting for tray approval" with the same sentence, then "It waits for the owner's approval. Denied automatically at {time}."
981
+ A secret request (`kind` `secret`, a `SecretApproval`) is a program asking for a secret through `/dev/sealed`. Its fields are `mode` (`enter` when there is no live secret of that name, `reveal` when there is), `name`, an optional `prompt` (the program's own words, ≤ 200 characters, untrusted), `reveal` (the process gets the plaintext back too), `requester` (`kind` `agent` or `user`, `name`, optional `where`), `process` (`exe`, the resolved program file; `argv`; `cwd`, all from the kernel), `createdAt` and `expiresAt`.
982
+ `decide(id, 'deny')` cancels it in the vault, and the UI never sends `once` or `always` for it: the answer is given in the vault's frame. Send `ended` (`answered`, `expired` or `withdrawn`) only for a request that ended outside this tab: not for one this tab denied, and not for one whose frame in this tab posted `done`.
983
+ Without `decide`, its notice (`[data-approval-kind=secret]`) reads "Waiting for a secret" with "{name} ran {exe}, which asks for a value for {secret}." ("You ran…" for a user, "…the plaintext of {secret}." for `reveal`), then "It waits for the owner's answer. Denied automatically at {time}."
970
984
  An approval whose `kind` this build doesn't know shows neither a dialog nor a notice, so it waits until it ends and is never allowed from here.
971
985
 
972
986
  #### `vault` (optional)
@@ -977,10 +991,12 @@ The vault's view for SLICC, typed after `@ai-ecoverse/slicc-vault` (spectrum re-
977
991
  Emit `status` and `list` when they change (bios re-lists after the vault's `changed` events), and `availability` when the optional `availability()` (`connecting`, `waiting`, `locked` or `ready`) changes.
978
992
  `audit(before?, limit?)` resolves with an older `AuditPage` (`before` is a `seq`).
979
993
  The optional page ops, each hiding its control when missing: `put(name, value, request?)` (a value request's `put` must use its `name`), `cancel(request)`, `remove(name, kind?)`, `restore(name, kind?)`, `import(kind, bytes, { password?, pick? })` (a `pick` stage, then `done`), `export(passphrase)` (the bundle's bytes), `lock()`, `dismissBackupReminder()` and `endpoints({ add?, remove? })`, which registers or takes back the URLs a page-sealed credential may be filled on (each an `EndpointSet`, `{ model?: [{ name, url }], mcp?: [{ server, url }] }`, a model URL bound to one record by name), checks the whole change first (`EINVAL` changes nothing) and rejects with a sentence. Settings registers them itself, never through the agent. `settings(name, kind, settings)` replaces a record's tenant settings as a whole without touching its key (the vault's `put` without a value); `put`'s options take the same `settings` on a first save.
980
- They reject with a `VaultFailure`, an `Error` with an `ErrorCode` `code` (`isVaultFailure` from `/ui`) and an optional `ErrorCause` `cause`, matching slicc-vault 1.22.0’s stable refusal causes; `isPolicyPaused` still identifies only `policy-paused`.
994
+ They reject with a `VaultFailure`, an `Error` with an `ErrorCode` `code` (`isVaultFailure` from `/ui`) and an optional `ErrorCause` `cause`, matching slicc-vault 1.22.0’s stable refusal causes (checked against the pinned 1.23.0, whose ask types `compat/vault.ts` also checks: `Requester`, `ValueConfig`, `ValueEvent` and, the other way, `ValueMessage`); `isPolicyPaused` still identifies only `policy-paused`.
981
995
  Values go to the vault only through `put`, and never through another port.
982
996
  `button()` returns a new `VaultButton`, `{ element, post(message), listen(fn) }` returning an unsubscribe: bios wraps the vault's `button.html` frame and its postMessage protocol, and spectrum only sends `ButtonMessage`s and receives `ButtonEvent`s.
983
997
  The passkey lock (unlock and protect) happens in the vault's own popup, which only a click in that frame can open.
998
+ The optional `valueFrame(request)` returns a new `VaultValueFrame` for an `ask` request, `{ element, post(message), listen(fn) }` like `button()`. bios wraps the vault's `value.html` frame. spectrum sends only `ValueMessage`s (`op: 'value'` with the request and a `ValueConfig`, and `op: 'focus'`) and receives only `ValueEvent`s (`ready`, `size`, `focus`, `done { request, state: 'sealed' | 'allowed' }`, `gone { request, state }`, `error { code, message }`, `escape`). The value is typed in that frame, and a reveal is allowed only by a click there, so the page never holds the value or the consent.
999
+ An `ask` request in `status.waiting` also carries `mode`, `reveal` and `requester` (`{ kind, name?, where?, exe, argv, cwd, tty, stdin }`). Settings › Secrets lists it with Cancel request only, and `RequestState` includes `withdrawn`.
984
1000
  `notices`, `acknowledge` and the `notice` event are still typed, and ignored.
985
1001
 
986
1002
  #### `guests` (optional)
@@ -148,6 +148,86 @@ function trayFixtures(scenario, now2) {
148
148
  const id = scenario === "tray-expired" ? "tray-url" : scenario;
149
149
  return all.filter((approval) => approval.id === id);
150
150
  }
151
+ function secretFixtures(scenario, now2) {
152
+ const base = { createdAt: now2, expiresAt: now2 + 5 * minutes };
153
+ const enter = {
154
+ ...base,
155
+ id: "ask-harbor",
156
+ kind: "secret",
157
+ mode: "enter",
158
+ name: "HARBOR_DEPLOY_TOKEN",
159
+ prompt: "Paste the harbor deploy token from the release checklist.",
160
+ reveal: false,
161
+ requester: { kind: "user", name: "cone", where: "Terminal 1" },
162
+ process: {
163
+ exe: "/usr/bin/fnox",
164
+ argv: ["fnox", "set", "HARBOR_DEPLOY_TOKEN"],
165
+ cwd: "/home/cone/harbor"
166
+ }
167
+ };
168
+ const reveal = {
169
+ ...base,
170
+ id: "ask-lagoon",
171
+ kind: "secret",
172
+ mode: "reveal",
173
+ name: "LAGOON_API_KEY",
174
+ prompt: "The tide report script signs its uploads with this key.",
175
+ reveal: true,
176
+ requester: { kind: "agent", name: "docs-pass", where: "scoop:docs" },
177
+ process: {
178
+ exe: "/usr/bin/sealed",
179
+ argv: ["sealed", "ask", "LAGOON_API_KEY", "--reveal"],
180
+ cwd: "/home/cone/harbor"
181
+ },
182
+ createdAt: now2 + 1
183
+ };
184
+ if (scenario === "secret-enter") return [enter];
185
+ if (scenario === "secret-reveal") return [reveal];
186
+ if (scenario === "secret-long")
187
+ return [
188
+ {
189
+ ...enter,
190
+ id: "ask-long",
191
+ reveal: true,
192
+ requester: { kind: "agent", name: "quiet-otter" },
193
+ prompt: `Paste it\u202Eevil\r${"and wait. ".repeat(30)}`,
194
+ process: {
195
+ exe: "/usr/bin/sh",
196
+ argv: [
197
+ "sh",
198
+ "-c",
199
+ [longCommand, longCommand, longCommand].join(" && \\\n"),
200
+ "tide\rreport"
201
+ ],
202
+ cwd: "/home/cone/harbor\rferry"
203
+ }
204
+ }
205
+ ];
206
+ return [enter, reveal];
207
+ }
208
+ function pendingAsk(approval) {
209
+ const { kind, name: name2, where } = approval.requester;
210
+ return {
211
+ id: approval.id,
212
+ kind: "ask",
213
+ name: approval.name,
214
+ ...approval.prompt ? { prompt: approval.prompt } : {},
215
+ mode: approval.mode,
216
+ reveal: approval.reveal,
217
+ requester: {
218
+ kind,
219
+ name: name2,
220
+ ...where ? { where } : {},
221
+ ...approval.process,
222
+ tty: kind === "user" ? "/dev/pts/1" : null,
223
+ stdin: kind === "user" ? "tty" : "pipe"
224
+ },
225
+ uid: 1e3,
226
+ tag: kind,
227
+ createdAt: approval.createdAt,
228
+ expiresAt: approval.expiresAt
229
+ };
230
+ }
151
231
  function levelFixture(scenario, now2) {
152
232
  const base = {
153
233
  id: `sudo-${scenario}`,
@@ -296,6 +376,7 @@ function approvalFixtures(scenario, now2 = Date.now()) {
296
376
  if (scenario === "guest" || scenario === "guest-follower") return guestFixtures(now2);
297
377
  if (scenario === "guest-always") return guestAlways(now2);
298
378
  if (scenario.startsWith("tray")) return trayFixtures(scenario, now2);
379
+ if (scenario.startsWith("secret-") || scenario === "secret") return secretFixtures(scenario, now2);
299
380
  if (scenario.startsWith("guest-")) return [guestCall(scenario, now2)];
300
381
  if (scenario === "none") return [];
301
382
  if (scenario === "long")
@@ -355,12 +436,20 @@ var DummyApprovals = class extends Emitter {
355
436
  #approvals;
356
437
  #time;
357
438
  #fail;
439
+ #vault;
440
+ #quiet = /* @__PURE__ */ new Set();
358
441
  decisions = [];
359
442
  decide;
360
- constructor(scenario, time) {
443
+ constructor(scenario, time, vault) {
361
444
  super();
362
445
  this.#approvals = approvalFixtures(scenario);
363
446
  this.#time = time;
447
+ this.#vault = vault;
448
+ if (vault) {
449
+ for (const approval of this.#approvals)
450
+ if (approval.kind === "secret") vault.hold(pendingAsk(approval));
451
+ vault.on("status", (status2) => this.#left(status2.waiting));
452
+ }
364
453
  this.#fail = scenario === "fail";
365
454
  if (scenario !== "follower" && scenario !== "guest-follower")
366
455
  this.decide = (id, decision) => this.#decide(id, decision);
@@ -370,10 +459,24 @@ var DummyApprovals = class extends Emitter {
370
459
  void time.sleep(100).then(() => this.end("tray-url", "expired"));
371
460
  }
372
461
  raise(approval) {
462
+ if (approval.kind === "secret") this.#vault?.hold(pendingAsk(approval));
373
463
  this.#approvals = [...this.#approvals.filter(({ id }) => id !== approval.id), approval];
374
464
  this.emit("approvals", this.list());
375
465
  }
466
+ #left(waiting2) {
467
+ const vault = this.#vault;
468
+ for (const approval of this.#approvals) {
469
+ if (approval.kind !== "secret" || waiting2.some(({ id }) => id === approval.id)) continue;
470
+ if (vault.answered.has(approval.id) || this.#quiet.has(approval.id)) this.#drop(approval.id);
471
+ else this.end(approval.id, "answered");
472
+ }
473
+ }
474
+ #drop(id) {
475
+ this.#approvals = this.#approvals.filter((approval) => approval.id !== id);
476
+ this.emit("approvals", this.list());
477
+ }
376
478
  end(id, reason) {
479
+ if (!this.#approvals.some((approval) => approval.id === id)) return;
377
480
  this.emit("ended", { id, reason });
378
481
  this.#approvals = this.#approvals.filter((approval) => approval.id !== id);
379
482
  this.emit("approvals", this.list());
@@ -385,7 +488,14 @@ var DummyApprovals = class extends Emitter {
385
488
  await this.#time.sleep(10);
386
489
  if (this.#fail) throw new Error("The kernel stopped waiting. Run the command again.");
387
490
  if (id === "guest-late") throw new Error("Someone else already answered this.");
491
+ const approval = this.#approvals.find((item) => item.id === id);
492
+ if (approval?.kind === "secret" && decision !== "deny")
493
+ throw new Error("Save or Allow in the vault\u2019s frame.");
388
494
  this.decisions.push([id, decision]);
495
+ if (approval?.kind === "secret" && this.#vault?.ask(id)) {
496
+ this.#quiet.add(id);
497
+ await this.#vault.cancel(id);
498
+ }
389
499
  this.end(id, "answered");
390
500
  }
391
501
  };
@@ -6351,7 +6461,8 @@ function vaultAudit(now2 = Date.now()) {
6351
6461
  secret: "HARBOR_API_KEY",
6352
6462
  from: ["proxy", "device:link-pier"],
6353
6463
  to: ["proxy", "tailscale", "device:link-pier"]
6354
- }
6464
+ },
6465
+ { kind: "reveal", field: "agent", from: true, to: false }
6355
6466
  ]
6356
6467
  },
6357
6468
  {
@@ -6750,6 +6861,151 @@ var DummyVaultButton = class {
6750
6861
  this.#emit({ event: "popup", intent: resolved, state: "done" });
6751
6862
  }
6752
6863
  };
6864
+ var DummyValueFrame = class {
6865
+ element;
6866
+ request;
6867
+ config = null;
6868
+ messages = [];
6869
+ arming = 500;
6870
+ #listeners = /* @__PURE__ */ new Set();
6871
+ #vault;
6872
+ #box;
6873
+ #view = "";
6874
+ #armed = false;
6875
+ #finished = false;
6876
+ #sizes = new ResizeObserver(() => this.#size());
6877
+ constructor(vault, time, request) {
6878
+ this.#vault = vault;
6879
+ this.request = request;
6880
+ this.element = document.createElement("div");
6881
+ this.element.dataset.vaultValue = "";
6882
+ this.#box = document.createElement("div");
6883
+ this.#box.style.padding = "2px";
6884
+ this.element.append(this.#box);
6885
+ this.element.addEventListener("keydown", (event) => {
6886
+ if (event.key !== "Escape" || event.isComposing) return;
6887
+ event.preventDefault();
6888
+ event.stopPropagation();
6889
+ this.#emit({ event: "escape" });
6890
+ });
6891
+ vault.on("status", () => this.#render());
6892
+ this.#sizes.observe(this.#box);
6893
+ void time.sleep(0).then(() => this.#emit({ event: "ready" }));
6894
+ }
6895
+ post(message) {
6896
+ this.messages.push(message);
6897
+ if (message.op === "focus") {
6898
+ this.#box.querySelector("input, swc-button")?.focus();
6899
+ return;
6900
+ }
6901
+ this.request = message.request;
6902
+ this.config = message.config;
6903
+ this.element.style.colorScheme = message.config.theme ?? "";
6904
+ this.#render();
6905
+ }
6906
+ listen(listener) {
6907
+ this.#listeners.add(listener);
6908
+ return () => this.#listeners.delete(listener);
6909
+ }
6910
+ #emit(event) {
6911
+ const message = { type: "slicc-vault", v: 1, ...event };
6912
+ for (const listener of [...this.#listeners]) listener(message);
6913
+ }
6914
+ #size() {
6915
+ const { width, height } = this.#box.getBoundingClientRect();
6916
+ this.#emit({ event: "size", width: Math.ceil(width), height: Math.ceil(height), inset: 2 });
6917
+ }
6918
+ #render() {
6919
+ if (!this.config || this.#finished) return;
6920
+ const ask2 = this.#vault.ask(this.request);
6921
+ if (!ask2) {
6922
+ this.#finished = true;
6923
+ this.#box.replaceChildren();
6924
+ this.#emit({ event: "gone", request: this.request, state: "cancelled" });
6925
+ return;
6926
+ }
6927
+ const view = this.#vault.status()?.locked ? "locked" : ask2.mode ?? "enter";
6928
+ if (view === this.#view) return;
6929
+ if (view !== "locked" && (this.#view === "" || this.#view === "locked"))
6930
+ setTimeout(() => {
6931
+ this.#armed = true;
6932
+ this.#arm();
6933
+ }, this.arming);
6934
+ this.#view = view;
6935
+ this.#box.replaceChildren(...this.#controls(view, ask2));
6936
+ this.#arm();
6937
+ this.#size();
6938
+ }
6939
+ #arm() {
6940
+ for (const button of this.#box.querySelectorAll("[data-action=save], [data-action=allow]"))
6941
+ button.toggleAttribute("disabled", !this.#armed);
6942
+ }
6943
+ #button(action, label2, variant, click) {
6944
+ const button = document.createElement("swc-button");
6945
+ button.dataset.action = action;
6946
+ button.setAttribute("variant", variant);
6947
+ button.textContent = label2;
6948
+ button.addEventListener("click", click);
6949
+ return button;
6950
+ }
6951
+ #controls(view, ask2) {
6952
+ const text = document.createElement("p");
6953
+ text.style.margin = "0 0 8px";
6954
+ const problem = document.createElement("p");
6955
+ problem.dataset.problem = "";
6956
+ problem.style.margin = "8px 0 0";
6957
+ if (view === "locked") {
6958
+ text.textContent = "Unlock the vault to answer.";
6959
+ return [
6960
+ text,
6961
+ this.#button("unlock", "Unlock", "accent", () => {
6962
+ void this.#vault.answer({ kind: "unlock" });
6963
+ })
6964
+ ];
6965
+ }
6966
+ if (view === "reveal") {
6967
+ text.textContent = `${ask2.requester?.exe} gets the plaintext of ${ask2.name}`;
6968
+ return [
6969
+ text,
6970
+ this.#button("allow", "Allow", "negative", () => void this.#answer(problem, "")),
6971
+ problem
6972
+ ];
6973
+ }
6974
+ const label2 = document.createElement("label");
6975
+ label2.textContent = `Value for ${ask2.name}`;
6976
+ label2.style.display = "grid";
6977
+ const input = document.createElement("input");
6978
+ input.type = "password";
6979
+ input.dataset.field = "value";
6980
+ input.autocomplete = "off";
6981
+ label2.append(input);
6982
+ text.append(label2);
6983
+ return [
6984
+ text,
6985
+ this.#button("save", "Save", "accent", () => {
6986
+ const value = input.value;
6987
+ input.value = "";
6988
+ void this.#answer(problem, value);
6989
+ }),
6990
+ problem
6991
+ ];
6992
+ }
6993
+ async #answer(problem, value) {
6994
+ const reveal = this.#view === "reveal";
6995
+ problem.textContent = "";
6996
+ this.#finished = true;
6997
+ try {
6998
+ if (reveal) await this.#vault.allow(this.request);
6999
+ else await this.#vault.seal(this.request, value);
7000
+ this.#emit({ event: "done", request: this.request, state: reveal ? "allowed" : "sealed" });
7001
+ } catch (error) {
7002
+ this.#finished = false;
7003
+ const { code, message } = error;
7004
+ if (code === "EINVAL") problem.textContent = message;
7005
+ else this.#emit({ event: "error", code, message });
7006
+ }
7007
+ }
7008
+ };
6753
7009
  var DummyVault = class extends Emitter {
6754
7010
  #status;
6755
7011
  #list;
@@ -6759,6 +7015,9 @@ var DummyVault = class extends Emitter {
6759
7015
  #older = vaultOlder();
6760
7016
  #serial = 0;
6761
7017
  buttons = [];
7018
+ frames = [];
7019
+ answered = /* @__PURE__ */ new Set();
7020
+ revealed = [];
6762
7021
  puts = [];
6763
7022
  endpointChanges = [];
6764
7023
  settingsCalls = [];
@@ -7033,6 +7292,55 @@ var DummyVault = class extends Emitter {
7033
7292
  this.buttons.push(button);
7034
7293
  return button;
7035
7294
  }
7295
+ valueFrame(request) {
7296
+ const frame = new DummyValueFrame(this, this.#time, request);
7297
+ this.frames.push(frame);
7298
+ return frame;
7299
+ }
7300
+ hold(request) {
7301
+ this.#status = {
7302
+ ...this.#status,
7303
+ waiting: [...this.#status.waiting.filter(({ id }) => id !== request.id), request]
7304
+ };
7305
+ this.emit("status", this.#status);
7306
+ }
7307
+ ask(request) {
7308
+ return this.#status.waiting.find(({ id, kind }) => id === request && kind === "ask");
7309
+ }
7310
+ #asked(request) {
7311
+ this.#unlocked();
7312
+ const found = this.ask(request);
7313
+ if (!found) throw vaultFailure("ENOENT", "That request is no longer waiting.");
7314
+ return found;
7315
+ }
7316
+ async seal(request, value) {
7317
+ await this.#time.sleep(5);
7318
+ const found = this.#asked(request);
7319
+ const problem = valueProblem(value);
7320
+ if (problem) throw vaultFailure("EINVAL", problem);
7321
+ this.answered.add(request);
7322
+ this.#take(request);
7323
+ return this.#seal(found.name, "settings");
7324
+ }
7325
+ async allow(request) {
7326
+ await this.#time.sleep(5);
7327
+ const found = this.#asked(request);
7328
+ this.answered.add(request);
7329
+ this.revealed.push(request);
7330
+ this.#take(request);
7331
+ const { exe, argv, cwd, kind } = found.requester;
7332
+ this.#log(found.name, {
7333
+ type: "reveal",
7334
+ uid: found.uid,
7335
+ tag: found.tag,
7336
+ exe,
7337
+ argv: argv.join(" "),
7338
+ cwd,
7339
+ requester: kind,
7340
+ request
7341
+ });
7342
+ this.emit("list", this.#list);
7343
+ }
7036
7344
  autoLabel() {
7037
7345
  return this.#status.locked ? "Vault locked" : "Vault unlocked";
7038
7346
  }
@@ -7175,6 +7483,7 @@ function createDummyModel({
7175
7483
  if (older === "paged") agent.paginate(6, 4);
7176
7484
  if (hold !== "off") agent.setHold({ reason: hold, held: 2 });
7177
7485
  const noticePort = noticesPort(agent, time, notices, readonly, arrival);
7486
+ const vaultPort = vault === "off" ? void 0 : new DummyVault(vault, time, vaultRecords(accounts2, mcp));
7178
7487
  if (scenario === "driving") void browser.run(drive);
7179
7488
  const base = {
7180
7489
  agent,
@@ -7217,11 +7526,9 @@ function createDummyModel({
7217
7526
  ...changes === "files" ? {} : { changes: new DummyChanges(files3, changes === "git") },
7218
7527
  ...noticePort ? { notices: noticePort } : {},
7219
7528
  ...tabs2 === "off" ? {} : { tabs: new DummyTabs(tabs2, time) },
7220
- ...approvals === "off" ? {} : { approvals: new DummyApprovals(approvals, time) },
7529
+ ...approvals === "off" ? {} : { approvals: new DummyApprovals(approvals, time, vaultPort) },
7221
7530
  ...follow === "off" ? {} : { follow: new DummyFollow(follow, time) },
7222
- ...vault === "off" ? {} : {
7223
- vault: new DummyVault(vault, time, vaultRecords(accounts2, mcp))
7224
- },
7531
+ ...vaultPort ? { vault: vaultPort } : {},
7225
7532
  ...guestsPort(guests, time)
7226
7533
  };
7227
7534
  base.vault?.on("status", (status2) => {
@@ -7244,6 +7551,7 @@ export {
7244
7551
  DummyNotices,
7245
7552
  DummyTabs,
7246
7553
  DummyUpdates,
7554
+ DummyValueFrame,
7247
7555
  DummyVault,
7248
7556
  DummyVaultButton,
7249
7557
  actionLimits,