@ai-ecoverse/slicc-vault 0.0.0 → 1.1.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/LICENSE ADDED
@@ -0,0 +1,191 @@
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6
+
7
+ 1. Definitions.
8
+
9
+ "License" shall mean the terms and conditions for use, reproduction,
10
+ and distribution as defined by Sections 1 through 9 of this document.
11
+
12
+ "Licensor" shall mean the copyright owner or entity authorized by
13
+ the copyright owner that is granting the License.
14
+
15
+ "Legal Entity" shall mean the union of the acting entity and all
16
+ other entities that control, are controlled by, or are under common
17
+ control with that entity. For the purposes of this definition,
18
+ "control" means (i) the power, direct or indirect, to cause the
19
+ direction or management of such entity, whether by contract or
20
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
21
+ outstanding shares, or (iii) beneficial ownership of such entity.
22
+
23
+ "You" (or "Your") shall mean an individual or Legal Entity
24
+ exercising permissions granted by this License.
25
+
26
+ "Source" form shall mean the preferred form for making modifications,
27
+ including but not limited to software source code, documentation
28
+ source, and configuration files.
29
+
30
+ "Object" form shall mean any form resulting from mechanical
31
+ transformation or translation of a Source form, including but
32
+ not limited to compiled object code, generated documentation,
33
+ and conversions to other media types.
34
+
35
+ "Work" shall mean the work of authorship, whether in Source or
36
+ Object form, made available under the License, as indicated by a
37
+ copyright notice that is included in or attached to the work
38
+ (an example is provided in the Appendix below).
39
+
40
+ "Derivative Works" shall mean any work, whether in Source or Object
41
+ form, that is based on (or derived from) the Work and for which the
42
+ editorial revisions, annotations, elaborations, or other modifications
43
+ represent, as a whole, an original work of authorship. For the purposes
44
+ of this License, Derivative Works shall not include works that remain
45
+ separable from, or merely link (or bind by name) to the interfaces of,
46
+ the Work and Derivative Works thereof.
47
+
48
+ "Contribution" shall mean any work of authorship, including
49
+ the original version of the Work and any modifications or additions
50
+ to that Work or Derivative Works thereof, that is intentionally
51
+ submitted to the Licensor for inclusion in the Work by the copyright
52
+ owner or by an individual or Legal Entity authorized to submit on
53
+ behalf of the copyright owner. For the purposes of this definition,
54
+ "submitted" means any form of electronic, verbal, or written
55
+ communication sent to the Licensor or its representatives, including
56
+ but not limited to communication on electronic mailing lists, source
57
+ code control systems, and issue tracking systems that are managed by,
58
+ or on behalf of, the Licensor for the purpose of discussing and
59
+ improving the Work, but excluding communication that is conspicuously
60
+ marked or otherwise designated in writing by the copyright owner as
61
+ "Not a Contribution."
62
+
63
+ "Contributor" shall mean Licensor and any individual or Legal Entity
64
+ on behalf of whom a Contribution has been received by Licensor and
65
+ subsequently incorporated within the Work.
66
+
67
+ 2. Grant of Copyright License. Subject to the terms and conditions of
68
+ this License, each Contributor hereby grants to You a perpetual,
69
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
70
+ copyright license to reproduce, prepare Derivative Works of,
71
+ publicly display, publicly perform, sublicense, and distribute the
72
+ Work and such Derivative Works in Source or Object form.
73
+
74
+ 3. Grant of Patent License. Subject to the terms and conditions of
75
+ this License, each Contributor hereby grants to You a perpetual,
76
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
77
+ (except as stated in this section) patent license to make, have made,
78
+ use, offer to sell, sell, import, and otherwise transfer the Work,
79
+ where such license applies only to those patent claims licensable
80
+ by such Contributor that are necessarily infringed by their
81
+ Contribution(s) alone or by combination of their Contribution(s)
82
+ with the Work to which such Contribution(s) was submitted. If You
83
+ institute patent litigation against any entity (including a
84
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
85
+ or a Contribution incorporated within the Work constitutes direct
86
+ or contributory patent infringement, then any patent licenses
87
+ granted to You under this License for that Work shall terminate
88
+ as of the date such litigation is filed.
89
+
90
+ 4. Redistribution. You may reproduce and distribute copies of the
91
+ Work or Derivative Works thereof in any medium, with or without
92
+ modifications, and in Source or Object form, provided that You
93
+ meet the following conditions:
94
+
95
+ (a) You must give any other recipients of the Work or
96
+ Derivative Works a copy of this License; and
97
+
98
+ (b) You must cause any modified files to carry prominent notices
99
+ stating that You changed the files; and
100
+
101
+ (c) You must retain, in the Source form of any Derivative Works
102
+ that You distribute, all copyright, patent, trademark, and
103
+ attribution notices from the Source form of the Work,
104
+ excluding those notices that do not pertain to any part of
105
+ the Derivative Works; and
106
+
107
+ (d) If the Work includes a "NOTICE" text file as part of its
108
+ distribution, then any Derivative Works that You distribute must
109
+ include a readable copy of the attribution notices contained
110
+ within such NOTICE file, excluding those notices that do not
111
+ pertain to any part of the Derivative Works, in at least one
112
+ of the following places: within a NOTICE text file distributed
113
+ as part of the Derivative Works; within the Source form or
114
+ documentation, if provided along with the Derivative Works; or,
115
+ within a display generated by the Derivative Works, if and
116
+ wherever such third-party notices normally appear. The contents
117
+ of the NOTICE file are for informational purposes only and
118
+ do not modify the License. You may add Your own attribution
119
+ notices within Derivative Works that You distribute, alongside
120
+ or as an addendum to the NOTICE text from the Work, provided
121
+ that such additional attribution notices cannot be construed
122
+ as modifying the License.
123
+
124
+ You may add Your own copyright statement to Your modifications and
125
+ may provide additional or different license terms and conditions
126
+ for use, reproduction, or distribution of Your modifications, or
127
+ for any such Derivative Works as a whole, provided Your use,
128
+ reproduction, and distribution of the Work otherwise complies with
129
+ the conditions stated in this License.
130
+
131
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
132
+ any Contribution intentionally submitted for inclusion in the Work
133
+ by You to the Licensor shall be under the terms and conditions of
134
+ this License, without any additional terms or conditions.
135
+ Notwithstanding the above, nothing herein shall supersede or modify
136
+ the terms of any separate license agreement you may have executed
137
+ with Licensor regarding such Contributions.
138
+
139
+ 6. Trademarks. This License does not grant permission to use the trade
140
+ names, trademarks, service marks, or product names of the Licensor,
141
+ except as required for reasonable and customary use in describing the
142
+ origin of the Work and reproducing the content of the NOTICE file.
143
+
144
+ 7. Disclaimer of Warranty. Unless required by applicable law or
145
+ agreed to in writing, Licensor provides the Work (and each
146
+ Contributor provides its Contributions) on an "AS IS" BASIS,
147
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
148
+ implied, including, without limitation, any warranties or conditions
149
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
150
+ PARTICULAR PURPOSE. You are solely responsible for determining the
151
+ appropriateness of using or redistributing the Work and assume any
152
+ risks associated with Your exercise of permissions under this License.
153
+
154
+ 8. Limitation of Liability. In no event and under no legal theory,
155
+ whether in tort (including negligence), contract, or otherwise,
156
+ unless required by applicable law (such as deliberate and grossly
157
+ negligent acts) or agreed to in writing, shall any Contributor be
158
+ liable to You for damages, including any direct, indirect, special,
159
+ incidental, or consequential damages of any character arising as a
160
+ result of this License or out of the use or inability to use the
161
+ Work (including but not limited to damages for loss of goodwill,
162
+ work stoppage, computer failure or malfunction, or any and all
163
+ other commercial damages or losses), even if such Contributor
164
+ has been advised of the possibility of such damages.
165
+
166
+ 9. Accepting Warranty or Additional Liability. While redistributing
167
+ the Work or Derivative Works thereof, You may choose to offer,
168
+ and charge a fee for, acceptance of support, warranty, indemnity,
169
+ or other liability obligations and/or rights consistent with this
170
+ License. However, in accepting such obligations, You may act only
171
+ on Your own behalf and on Your sole responsibility, not on behalf
172
+ of any other Contributor, and only if You agree to indemnify,
173
+ defend, and hold each Contributor harmless for any liability
174
+ incurred by, or claims asserted against, such Contributor by reason
175
+ of your accepting any such warranty or additional liability.
176
+
177
+ END OF TERMS AND CONDITIONS
178
+
179
+ Copyright 2025 ai-ecoverse
180
+
181
+ Licensed under the Apache License, Version 2.0 (the "License");
182
+ you may not use this file except in compliance with the License.
183
+ You may obtain a copy of the License at
184
+
185
+ http://www.apache.org/licenses/LICENSE-2.0
186
+
187
+ Unless required by applicable law or agreed to in writing, software
188
+ distributed under the License is distributed on an "AS IS" BASIS,
189
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
190
+ See the License for the specific language governing permissions and
191
+ limitations under the License.
package/README.md CHANGED
@@ -1,9 +1,224 @@
1
- # @ai-ecoverse/slicc-vault
1
+ # slicc-vault
2
2
 
3
- 🐣 This package name has been claimed with [fledgling](https://github.com/dmno-dev/fledgling), and a real release will land here soon.
3
+ The separate-origin vault at `vault.sliccy.ai`. It holds the sealed secrets, OAuth tokens, and model and MCP credentials of SLICC seven, so that no value ever reaches the model provider. The design is `secrets-tokens-design.md` (revision 4) in slicc-agent-plan. This README is the spec of what is built.
4
4
 
5
- This is a placeholder — there is nothing to install yet.
5
+ - **Built:** the key store, records, origin namespacing, auto-unlock with an optional passkey or passphrase lock, the embedding API with its `page` and `agent` roles, the page's `put`, `seal`, `remove`, `restore`, `cancel` and `lock`, the scrub state, the lock button and its popup, and the vault side of the `/dev/sealed` ops `seal`, `placeholder`, `list`, `test`, `request`, `status`, `cancel` and `materialize`.
6
+ - **Not built yet:** policy (every record is `inert`, and `test` always refuses), `forward` and the scrubber, exec, `import` and `export`, and the agent's ops. They answer as described below.
6
7
 
7
- ---
8
+ ## Documents and hosting
8
9
 
9
- Want to claim a name of your own? Run `npx fledgling add <name> --new --yes` — it reserves the name on npm and can set up token-less (OIDC trusted) publishing for when you are ready.
10
+ The build (`npm run build`) writes a flat static tree to `dist/site/`:
11
+
12
+ | file | what it is |
13
+ |---|---|
14
+ | `index.html`, `frame.js` | the core frame. bios embeds it as a hidden iframe and talks to it over a `MessagePort` |
15
+ | `button.html`, `button.js` | the lock button. A popup can only open from a click inside a vault document, so bios and spectrum embed this wherever the lock is offered |
16
+ | `popup.html`, `popup.js` | the vault window, a top-level `vault.sliccy.ai` document, used only for the passkey or passphrase lock |
17
+ | `vault.css`, `fonts/` | Spectrum 2 from the CSS-only `@spectrum-css` packages, and Adobe Clean |
18
+ | `config.json` | `{ version, embedders }`. `embedders` is the fallback list, `["https://seven.sliccy.ai"]` |
19
+
20
+ There is no third-party JavaScript: the bundles hold only this repo's code. The sliccy-ai Worker serves the tree from the network on every load, never from OPFS, with:
21
+ - `Content-Security-Policy` with `script-src 'self'`, no inline scripts or styles, and `frame-ancestors` listing the seven origins;
22
+ - `Cross-Origin-Embedder-Policy: require-corp`, `Cross-Origin-Resource-Policy: same-site`, `Cross-Origin-Opener-Policy: same-origin`;
23
+ - `Origin-Agent-Cluster: ?1`, `nosniff`, `Referrer-Policy: no-referrer`, `no-cache` with an ETag.
24
+
25
+ `GET /embedders.json` is answered by the Worker, not by this tree. It returns `{ "embedders": [...] }`, the same sources as `frame-ancestors`. The vault reads it at start, falls back to `config.json` when it's missing or malformed, and accepts only those origins. An embedder source is `http(s)://host`, optionally with `:port` or `:*`, matched exactly.
26
+
27
+ `main` is served at `vault.sliccy.ai`, and every other branch at `vault-<label>.sliccy.ai`. seven embeds only `vault.sliccy.ai`.
28
+
29
+ ## Storage and keys
30
+
31
+ Everything lives in the vault origin's IndexedDB, database `slicc-vault`, version 1:
32
+
33
+ | store | key | holds |
34
+ |---|---|---|
35
+ | `meta` | `"keys"` | the key state below |
36
+ | `records` | `[ns, kind, name]`, unique index on `placeholder` | records |
37
+ | `requests` | `id` | requests that wait for a human |
38
+ | `audit` | `seq` (auto-increment) | the audit log, per namespace |
39
+ | `intents` | `nonce` | hand-offs from a button to the popup, valid for 10 minutes |
40
+
41
+ **The data key** is one AES-GCM 256 key. Each record's value is encrypted under it with a fresh 12-byte IV and the additional data `slicc-vault/1 NUL ns NUL kind NUL name`, so a ciphertext can't be moved to another record.
42
+
43
+ **The key state** is one of:
44
+ - `auto` (the default): the data key is stored as a **non-extractable** `CryptoKey`. Every vault document loads it at start, so there's never a prompt.
45
+ - `passkey`: the data key is stored only wrapped (AES-GCM) under a key-encryption key from **WebAuthn PRF**. The passkey's RP id is the vault's host, the PRF input is a random 32-byte salt, and the KEK is HKDF-SHA-256 over the PRF output with that salt and the info `slicc-vault/kek/1`.
46
+ - `passphrase`: the same, with the KEK from PBKDF2-SHA-256, 600,000 iterations, over a random 16-byte salt.
47
+
48
+ **Locking:**
49
+ - While protected, a document starts locked and asks the other open vault documents (over the origin's `BroadcastChannel` `slicc-vault`) for the unwrapped key. Unlocking in the popup unwraps the key as non-extractable and hands it to every open vault document the same way.
50
+ - `Lock now` drops the key from every document. Closing every vault document has the same effect.
51
+ - **Protecting** makes a new data key, re-encrypts every record under it, and stores it wrapped, in one transaction. If a record changed meanwhile, protecting fails with `EAGAIN` and changes nothing. **Removing protection** stores the current key as non-extractable again.
52
+ - Every data key has an id, stored with it. A seal checks that id in the same transaction that writes the record, so a value encrypted under a key that was replaced meanwhile is refused with `EAGAIN` rather than stored unreadable.
53
+ - While locked, `seal` answers `EACCES` with a message starting `vault is locked`. Ops that need no value (`placeholder`, `list`, `status`, ...) still work.
54
+
55
+ ## Records
56
+
57
+ ```
58
+ { ns, kind: "secret" | "grant" | "model" | "mcp", name, placeholder, sealed: { iv, data },
59
+ source, createdAt, updatedAt, usedAt?, expiresAt?, refresh?, removedAt? }
60
+ ```
61
+
62
+ - **`ns`** is the embedder's origin, below.
63
+ - **`name`** matches `^[A-Za-z_][A-Za-z0-9_]*$`, at most 128 characters.
64
+ - **A value** is at least 8 bytes of UTF-8, without CR, LF or NUL, and contains no placeholder. Anything shorter is refused with `EINVAL` "values shorter than 8 bytes can't be protected from the model", because the scrubber couldn't tell it apart from ordinary output.
65
+ - **`placeholder`** is `fnox_proxy_` plus 24 lower-case hex characters from `crypto.getRandomValues`, unique across the whole vault and stable for the record's life. All placeholders have the same length, so none contains another.
66
+ - **`source`** is `settings` (entered in spectrum), `process`, `jerk:<provider>@<device>`, `oauth:<provider>` or `import:<kind>`.
67
+ - **`state`** is computed, never stored: `removed` when `removedAt` is set, `active` when the accepted policy has a rule naming the record, otherwise `inert`. With no policy yet, every record is `inert`.
68
+ - **Removing** sets `removedAt`. A removed record keeps its placeholder, can be restored with `restore`, and is deleted for good 30 days later. Sealing a removed name brings it back with a new value and the same placeholder.
69
+ - **Times** are epoch milliseconds everywhere.
70
+
71
+ ## Backup reminder
72
+
73
+ `status.exportedAt` is the time of the namespace's last successful `export`, or `null` if it never exported. `status.backupReminder` is `due` once the namespace holds a `secret` record and has never exported, `dismissed` after `dismissBackupReminder`, and `null` otherwise (no secrets yet, or exported). `export` arrives with VAULT-1e; until then `exportedAt` stays `null`.
74
+
75
+ ## Scrub state
76
+
77
+ `scrub { needed, version, ready }` tells bios and the agent whether output must pass the vault's scrubber before it reaches the model. It's per namespace:
78
+ - `needed` is false only when the namespace holds no plaintext at all: no record of any kind or state, removed ones included until they're purged.
79
+ - `version` counts every change to the namespace's set of values: each seal, rotation, removal, restore and purge.
80
+ - `ready` is false only when values exist but this vault frame never held the data key since it loaded, which is a reload with the lock on, before unlock. Locking again later doesn't clear it, because the compiled scrubber stays in memory for the frame's life.
81
+
82
+ It's in `status`, in `placeholders` replies and in every `placeholders` push. After `put` and `seal`, the push with the new pair goes out **before** the reply.
83
+
84
+ ## Namespaces
85
+
86
+ Every record, request, audit entry and scrub version belongs to the origin that embeds the vault. The core frame takes it from the `event.origin` of the hello, and the button from `location.ancestorOrigins[0]` (or the first message's origin when the browser hides ancestors). A branch preview of bios therefore gets its own empty vault and never sees seven's records. The popup shows the namespace of the button that opened it. The lock itself is one per vault.
87
+
88
+ ## The embedding API (core frame)
89
+
90
+ The embedder (bios) loads `https://vault.sliccy.ai/` in an iframe and, after `load`, posts a hello carrying a port:
91
+
92
+ ```js
93
+ iframe.contentWindow.postMessage({ type: 'slicc-vault', v: 1, op: 'hello', role: 'page' }, 'https://vault.sliccy.ai', [port2]);
94
+ ```
95
+
96
+ - The frame answers only when the message comes from its own parent window, from an allowed origin, matching `location.ancestorOrigins[0]` when the browser exposes it. Anything else gets no answer at all.
97
+ - On `port2` it answers `{ v: 1, op: 'welcome', role, status }`, or `{ v: 1, op: 'refused', error }` for another protocol version or an unknown role.
98
+ - Everything after that goes over the port. The namespace and the role are fixed by the hello. Requests are `{ v: 1, id, op, ...fields }`; replies are `{ v: 1, id, op, ...fields }` or `{ v: 1, id, error: { code, message } }`. Each port is answered in order (FIFO).
99
+ - **Roles:** `page` (the default) is bios and spectrum. bios may hand spectrum a page port of its own, or relay; the messages are the same. `agent` is the port bios hands the agent worker: it may use only `redact`, `redactStream`, `push`, `end`, `drop`, `model`, `mcp`, `endpoints`, `records`, `grant` and `refresh` (all `ENOTSUP` in this version), and anything else is `EACCES`. The agent port gets no events.
100
+
101
+ | op (page role) | fields | reply |
102
+ |---|---|---|
103
+ | `sealed` | `req` (the `/dev/sealed` request object, or its JSON text), `stamp` (`{ pid, ppid, uid, gid, tag }` from the kernel) | `{ reply }`, the §3.7 reply, below |
104
+ | `status` | — | `{ status: { version, protocol, origin, locked, lock, scrub, waiting, exportedAt, backupReminder } }` |
105
+ | `placeholders` | — | `{ placeholders, scrub }`: every placeholder of a record that isn't removed, active and inert, sorted |
106
+ | `list` | — | `{ list: { secrets, refused, audit, auditMore, exits } }` for Settings › Secrets |
107
+ | `audit` | `before?` (a `seq`), `limit?` (1–1000, default 100) | `{ audit, more, next? }`, newest first; pass `next` as `before` for the next page |
108
+ | `put` | `name`, `value`, `request?` | `{ name, placeholder, state }`. A value typed in Settings, with source `settings`; on an existing name it's a rotation that keeps the placeholder. With `request`, it answers that value request, which must still be pending and ask for `name` (else `EINVAL`, and nothing is stored), so its `/dev/sealed` `status` becomes `done` |
109
+ | `seal` | `name`, `value`, `source`, `kind?` (default `secret`), `expiresAt?`, `refresh?` | `{ name, placeholder, state }`. bios's, for J1 leases and sign-in tokens |
110
+ | `remove`, `restore` | `name`, `kind?` (default `secret`) | `{ name, placeholder, state }`. A record removed more than 30 days ago can't be restored (`ENOENT`) |
111
+ | `cancel` | `request` | `{ state }`; the request's `/dev/sealed` `status` becomes `cancelled` |
112
+ | `lock` | — | `{}`; drops the key everywhere when the lock is on |
113
+ | `dismissBackupReminder` | — | `{ backupReminder }`; the reminder becomes `dismissed` for this namespace |
114
+ | `import`, `export`, anything else | | `ENOTSUP` |
115
+
116
+ `put` and `seal` need the vault unlocked. They answer `EACCES` with a message starting `vault is locked` otherwise.
117
+
118
+ **Events** on page ports:
119
+ - `{ v: 1, event: 'status', status }` whenever requests, records, the scrub state or the lock change;
120
+ - `{ v: 1, event: 'placeholders', placeholders, scrub }` whenever either changes;
121
+ - `{ v: 1, event: 'changed', parts }`, with `parts` among `secrets`, `audit` and `exits`, so a view knows what to reload.
122
+
123
+ **Errors** use errno names: `EINVAL`, `ENOENT`, `EACCES`, `EEXIST`, `EAGAIN`, `ETIMEDOUT`, `ENOTCONN`, and `ENOTSUP` for ops this version doesn't have.
124
+
125
+ ## `/dev/sealed`, the vault side
126
+
127
+ bios relays each kernel request unchanged as the `sealed` op, with the kernel's stamp. The vault validates and answers `{ v: 1, ...fields }` or `{ v: 1, error: { code, message } }`. A request over 64 KiB, one that isn't a single JSON object, one with `v` other than 1, and an unknown `op` are `EINVAL`, as is a missing or malformed stamp.
128
+
129
+ | op | fields | reply |
130
+ |---|---|---|
131
+ | `seal` | `name`, `value` | `{ placeholder, state }`. A new name creates a `process` record. An existing one is rotated and keeps its placeholder, but only when its value came from a process; a value from Settings, jerk, OAuth or an import is replaced only by uid 0, and anyone else gets `EEXIST` |
132
+ | `placeholder` | `name` | `{ placeholder, state }`, including `removed`; `ENOENT` when there's no record |
133
+ | `list` | — | `{ secrets: [{ name, placeholder, state, exec, rules, expiresAt? }] }`, records of kind `secret` that aren't removed |
134
+ | `test` | `name`, `url`, `method?`, `header?` | `{ verdict, reason }`. Without policy it's always `refuse`; `ENOENT` for an unknown name |
135
+ | `request` | `kind: "value"`, `name`, `prompt?` (≤ 200 characters) | `{ state: "pending", id }` |
136
+ | `request` | `kind: "oauth"`, `provider`, `oauth` (an object) | the same |
137
+ | `request` | `kind: "jerk-seal"`, `provider` | the same |
138
+ | `status` | `id` | `{ state }`, with `placeholder` and `record` once a value request is `done` |
139
+ | `cancel` | `id` | `{ state }`: `cancelled`, or the state it had already reached |
140
+ | `materialize` | `names`, `path`, `argv`, `cwd` | checked, then always `EACCES` in this version |
141
+
142
+ - **Requests** expire 300 s after they're made. Each uid has at most one pending request per namespace; a second one is `EAGAIN`. `status` and `cancel` see only the caller's own requests (uid 0 sees all), and others get `ENOENT`.
143
+ - A value request shows up in `status.waiting` and is answered by the page's `put` with its `request` id, or ended by `cancel`. `oauth` and `jerk-seal` requests can only be cancelled in this version.
144
+
145
+ ## The button (`button.html`)
146
+
147
+ A popup needs a click inside a vault document, so the way into the lock popup is this button. The embedder loads `https://vault.sliccy.ai/button.html` in an iframe and, after `load`, posts:
148
+
149
+ ```js
150
+ frame.contentWindow.postMessage({ type: 'slicc-vault', v: 1, op: 'button', button: config }, 'https://vault.sliccy.ai');
151
+ ```
152
+
153
+ **`config`** (`ButtonConfig` in the types):
154
+
155
+ | field | values | default |
156
+ |---|---|---|
157
+ | `intent` | `{ kind: 'unlock' }`, `{ kind: 'protect' }`, or `"auto"` | `"auto"` |
158
+ | `label` | text instead of the default label | per intent |
159
+ | `size` | `s`, `m`, `l` (24, 32 and 40 px high) | `m` |
160
+ | `variant` | `accent`, `primary`, `secondary` | `secondary` |
161
+ | `treatment` | `fill`, `outline` | `fill` |
162
+ | `quiet`, `disabled` | booleans | `false` |
163
+ | `scale` | `medium`, `large` | `medium` |
164
+ | `theme` | `light`, `dark` | the system's colour scheme |
165
+ | `dir`, `lang` | `ltr`/`rtl`, a language tag | `ltr`, `en` |
166
+
167
+ - **Sending `op: 'button'` again updates the same button in place.** The frame doesn't reload, and only the fields given apply on top of the defaults.
168
+ - `{ op: 'focus' }` moves keyboard focus into the button.
169
+ - `unlock` is labelled `Unlock vault`, and `protect` (set up or remove the lock) `Vault lock`. `"auto"` shows `Unlock vault` while the vault is locked, and otherwise no button at all (size 0×0).
170
+ - The background is transparent, and the button sits inside a 2 px inset so its focus ring isn't clipped. Pass `theme` to match the page: Chrome paints an iframe opaque when its colour scheme differs from the embedder's, so the frame takes the scheme of `theme`, and the browser default without it.
171
+
172
+ **Events** to the embedder, all `{ type: 'slicc-vault', v: 1, event, ... }`:
173
+ - `ready`, once;
174
+ - `size { width, height, inset }`, on every change, so the embedder sizes the iframe to it;
175
+ - `label { label }`, on every change, for the iframe's `title`;
176
+ - `focus { direction: 'next' | 'previous' }`, when Tab or Shift+Tab leaves the button;
177
+ - `popup { intent, state }`, with `state` `opened`, `done`, `cancelled`, `closed` (the window closed without an answer) or `blocked` (the browser blocked the window).
178
+
179
+ ## The popup (`popup.html`)
180
+
181
+ The button writes its intent to `intents` under a random 128-bit nonce and opens `/popup.html#<nonce>` in a 460×680 window. The popup claims that intent and shows the namespace it was opened for. Without a valid nonce it shows only "Nothing to do here". It asks the browser to make the vault's storage persistent.
182
+
183
+ | intent | what the popup does |
184
+ |---|---|
185
+ | `unlock` | unlocks with the passkey or passphrase, then closes |
186
+ | `protect` | while unlocked: **Protect with a passkey** (WebAuthn PRF), **Protect with a passphrase**, or, once protected, **Lock now** and **Remove protection** |
187
+
188
+ - A locked vault asks to be unlocked first, whatever the intent.
189
+ - A wrong passphrase or passkey says so ("That passphrase doesn't unlock this vault."), and a wrong passphrase is cleared from the field.
190
+ - A new passphrase needs at least 12 characters, typed twice. The popup says what's missing as you type, and **Protect** stays disabled until both fields pass.
191
+ - **Remove protection** asks first: "Remove protection? The vault will unlock by itself on this device."
192
+
193
+ ## Types for bios and spectrum
194
+
195
+ The npm package `@ai-ecoverse/slicc-vault` holds only TypeScript types, built from `src/contract.ts`: the port API (`Hello`, `Greeting`, `Request`, `Reply`, `VaultEvent`, `Role`), the button (`ButtonConfig`, `ButtonMessage`, `ButtonEvent`), the popup (`PopupIntent`, `PopupState`), the page's results (`SealResult`, `ImportResult`), and the data Settings shows (`SecretView`, `VaultStatus`, `ScrubState`, `PendingRequest`, `AuditEntry`, `ExitPolicyView`, `VaultList`). `Availability` (`connecting`, `waiting`, `locked`, `ready`) is for bios to tell spectrum "waiting for the vault" and "unlock the vault to continue". They mean:
196
+
197
+ - **`EpochMs`**: milliseconds since the epoch. **`DeviceId`**: the same id bios uses for linked devices and trays, in `sourceDevice`, `device:<id>` exits and `ExitPolicyView.devices[].device`.
198
+ - **`Rule`**: `domain` is an exact lower-case host; `header` a lower-case header name; **empty `methods` or `paths` mean any**.
199
+ - **`exec`**: `false` means the value is never revealed to a program; `any`, `argv` (with `execArgv`, the allowed argv prefixes) and `shell` are the `[exec]` modes under which `fnox exec` may hand it to a program.
200
+ - **`exits`** is present only when `/etc/slicc/secrets-policy.toml` restricts that secret; absent means every exit (`page`, `proxy`, `tailscale`, `device:<id>`).
201
+ - **`kind`**: `secret` (a credential programs use through rules), **`grant`** (a sign-in's refresh token, never injected; show it as "Sign-in"), `model` (a model provider key), `mcp` (an MCP server credential). `provider` and `account` name the service and the signed-in account where one is known.
202
+ - **`AuditEntry`**: `forward` is a use of a value in a request, with the exit that carried it; **`materialize` is shown as "Revealed to a program"**; `seal`, `remove`, `restore` and `policy` (with its `changes`) record changes. Every entry has a `seq` for paging.
203
+ - **`VaultList.refused`** lists rules the vault dropped because they name a model or MCP endpoint.
204
+ - **`PendingRequest.prompt`** is untrusted, agent-written text of at most 200 characters.
205
+ - **`ExitPolicyView`** is read-only: the secrets `/etc/slicc/secrets-policy.toml` restricts to certain exits.
206
+
207
+ ## Develop
208
+
209
+ ```sh
210
+ npm install
211
+ npm run build # dist/site and dist/types
212
+ npm run lint
213
+ npm test # Chromium integration tests
214
+ npm start # vault on http://vault.sliccy.localhost:8790, a test embedder on seven.sliccy.localhost
215
+ ```
216
+
217
+ - **Integration tests** (`test/integration/`) run the built vault on a local server that sends the production headers, at `http://vault.sliccy.localhost:<port>`, with test embedders on `seven.`, `preview.` and `evil.sliccy.localhost`. Chromium treats `*.sliccy.localhost` as one site and as a secure context, so the embedded frame gets the same first-party storage it gets under `sliccy.ai`. They use slicc-shared-web's CDP harness, plus a second CDP session for popups and the virtual WebAuthn authenticator.
218
+ - **Unit tests** stay in the gitignored `test/unit/` (`npm run test:unit`), and the pre-commit hook requires them to cover every changed line of `src/`.
219
+ - The Biome, TypeScript, lefthook and Renovate configuration comes from [slicc-shared-web](https://github.com/ai-ecoverse/slicc-shared-web). There are no comments in any file.
220
+
221
+ ## Release and deploy
222
+
223
+ - **Releases:** semantic-release on `main` tags a version from the conventional commits. It publishes the types package when the repository variable `NPM_PUBLISH` is `true`.
224
+ - **Deploy:** after semantic-release, the release job builds with the new version and publishes `dist/site/` to `vault.sliccy.ai` from the `production` environment. Every push to another branch publishes it to `vault-<label>.sliccy.ai`. Both use GitHub's OIDC token against sliccy-ai's `/api/publish` route (`edge/publish.mjs`), with no stored secret.
@@ -0,0 +1,377 @@
1
+ export type Protocol = 1;
2
+ export type MessageType = 'slicc-vault';
3
+ export type EpochMs = number;
4
+ export type DeviceId = string;
5
+ export type RecordKind = 'secret' | 'grant' | 'model' | 'mcp';
6
+ export type RecordState = 'active' | 'inert' | 'removed';
7
+ export type RecordSource = 'settings' | 'process' | `jerk:${string}@${DeviceId}` | `oauth:${string}` | `import:${string}`;
8
+ export type LockMode = 'auto' | 'passkey' | 'passphrase';
9
+ export type RequestKind = 'value' | 'oauth' | 'jerk-seal';
10
+ export type RequestState = 'pending' | 'done' | 'cancelled' | 'expired';
11
+ export type ErrorCode = 'EINVAL' | 'ENOENT' | 'EACCES' | 'EEXIST' | 'EAGAIN' | 'ETIMEDOUT' | 'ENOTCONN' | 'ENOTSUP';
12
+ export type ExitName = 'page' | 'proxy' | 'tailscale' | `device:${DeviceId}`;
13
+ export type ExecMode = false | 'any' | 'argv' | 'shell';
14
+ export type Role = 'page' | 'agent';
15
+ export interface Stamp {
16
+ pid: number;
17
+ ppid: number;
18
+ uid: number;
19
+ gid: number;
20
+ tag: string | null;
21
+ }
22
+ export interface VaultError {
23
+ code: ErrorCode;
24
+ message: string;
25
+ }
26
+ export interface Rule {
27
+ domain: string;
28
+ header: string;
29
+ methods: string[];
30
+ paths: string[];
31
+ }
32
+ export interface RefusedRule {
33
+ secret: string;
34
+ rule: Rule;
35
+ reason: string;
36
+ }
37
+ export interface SecretView {
38
+ name: string;
39
+ kind: RecordKind;
40
+ placeholder: string;
41
+ state: RecordState;
42
+ source: RecordSource;
43
+ sourceDevice: DeviceId | null;
44
+ provider?: string;
45
+ account?: string;
46
+ domains: string[];
47
+ rules: Rule[];
48
+ exits?: ExitName[];
49
+ exec: ExecMode;
50
+ execArgv?: string[][];
51
+ createdAt: EpochMs;
52
+ usedAt?: EpochMs;
53
+ expiresAt?: EpochMs;
54
+ removedAt?: EpochMs;
55
+ }
56
+ export interface PendingRequest {
57
+ id: string;
58
+ kind: RequestKind;
59
+ name?: string;
60
+ provider?: string;
61
+ prompt?: string;
62
+ uid: number;
63
+ tag: string | null;
64
+ createdAt: EpochMs;
65
+ expiresAt: EpochMs;
66
+ }
67
+ export interface ScrubState {
68
+ needed: boolean;
69
+ version: number;
70
+ ready: boolean;
71
+ }
72
+ export interface VaultStatus {
73
+ version: string;
74
+ protocol: Protocol;
75
+ origin: string;
76
+ locked: boolean;
77
+ lock: LockMode;
78
+ scrub: ScrubState;
79
+ waiting: PendingRequest[];
80
+ exportedAt: EpochMs | null;
81
+ backupReminder: BackupReminder;
82
+ }
83
+ export type BackupReminder = 'due' | 'dismissed' | null;
84
+ export type Availability = 'connecting' | 'waiting' | 'locked' | 'ready';
85
+ export type PolicyFile = '/etc/fnox/config.toml' | '/etc/slicc/secrets-policy.toml';
86
+ export type PolicyChange = {
87
+ kind: 'rule';
88
+ secret: string;
89
+ rule: Rule;
90
+ change: 'added' | 'removed';
91
+ } | {
92
+ kind: 'exec';
93
+ secret: string;
94
+ from: ExecMode;
95
+ to: ExecMode;
96
+ } | {
97
+ kind: 'exits';
98
+ secret: string;
99
+ from: ExitName[] | null;
100
+ to: ExitName[] | null;
101
+ } | {
102
+ kind: 'limits';
103
+ field: string;
104
+ from: number;
105
+ to: number;
106
+ };
107
+ interface AuditBase {
108
+ seq: number;
109
+ id: string;
110
+ at: EpochMs;
111
+ name: string;
112
+ uid: number | null;
113
+ tag: string | null;
114
+ }
115
+ export interface ForwardEntry extends AuditBase {
116
+ type: 'forward';
117
+ domain: string;
118
+ method: string;
119
+ path: string;
120
+ rule: Rule;
121
+ exit: ExitName;
122
+ }
123
+ export interface RevealEntry extends AuditBase {
124
+ type: 'materialize';
125
+ exec: ExecMode;
126
+ path: string;
127
+ argv: string;
128
+ argvSha256: string;
129
+ cwd: string;
130
+ pid: number;
131
+ ppid: number;
132
+ ticket: string;
133
+ consumed: boolean;
134
+ }
135
+ export interface SealEntry extends AuditBase {
136
+ type: 'seal';
137
+ source: RecordSource;
138
+ rotated: boolean;
139
+ }
140
+ export interface RemoveEntry extends AuditBase {
141
+ type: 'remove' | 'restore';
142
+ }
143
+ export interface PolicyEntry {
144
+ type: 'policy';
145
+ seq: number;
146
+ id: string;
147
+ at: EpochMs;
148
+ file: PolicyFile;
149
+ sha256: string;
150
+ accepted: boolean;
151
+ changes: PolicyChange[];
152
+ warning?: string;
153
+ }
154
+ export type AuditEntry = ForwardEntry | RevealEntry | SealEntry | RemoveEntry | PolicyEntry;
155
+ export interface AuditPage {
156
+ audit: AuditEntry[];
157
+ more: boolean;
158
+ next?: number;
159
+ }
160
+ export interface ExitPolicyView {
161
+ file: '/etc/slicc/secrets-policy.toml';
162
+ sha256: string | null;
163
+ restricted: {
164
+ name: string;
165
+ exits: ExitName[];
166
+ }[];
167
+ }
168
+ export interface VaultList {
169
+ secrets: SecretView[];
170
+ refused: RefusedRule[];
171
+ audit: AuditEntry[];
172
+ auditMore: boolean;
173
+ exits: ExitPolicyView;
174
+ }
175
+ export interface SealResult {
176
+ name: string;
177
+ placeholder: string;
178
+ state: RecordState;
179
+ }
180
+ export type ImportKind = 'bundle' | 'keepass' | 'six';
181
+ export interface ImportCandidate {
182
+ name: string;
183
+ exists: boolean;
184
+ suggestedRules?: string;
185
+ }
186
+ export type ImportResult = {
187
+ stage: 'pick';
188
+ candidates: ImportCandidate[];
189
+ } | {
190
+ stage: 'done';
191
+ imported: SealResult[];
192
+ skipped: string[];
193
+ };
194
+ export type PopupIntent = {
195
+ kind: 'unlock';
196
+ } | {
197
+ kind: 'protect';
198
+ };
199
+ export type PopupState = 'opened' | 'done' | 'cancelled' | 'closed' | 'blocked';
200
+ export interface ButtonConfig {
201
+ intent: PopupIntent | 'auto';
202
+ label?: string;
203
+ size?: 's' | 'm' | 'l';
204
+ variant?: 'accent' | 'primary' | 'secondary';
205
+ treatment?: 'fill' | 'outline';
206
+ quiet?: boolean;
207
+ scale?: 'medium' | 'large';
208
+ theme?: 'light' | 'dark';
209
+ dir?: 'ltr' | 'rtl';
210
+ lang?: string;
211
+ disabled?: boolean;
212
+ }
213
+ export type SealedRequest = {
214
+ v: Protocol;
215
+ op: string;
216
+ } & Record<string, unknown>;
217
+ export type SealedReply = {
218
+ v: Protocol;
219
+ } & Record<string, unknown>;
220
+ export type Op = {
221
+ op: 'sealed';
222
+ req: SealedRequest;
223
+ stamp: Stamp;
224
+ } | {
225
+ op: 'status';
226
+ } | {
227
+ op: 'placeholders';
228
+ } | {
229
+ op: 'list';
230
+ } | {
231
+ op: 'audit';
232
+ before?: number;
233
+ limit?: number;
234
+ } | {
235
+ op: 'put';
236
+ name: string;
237
+ value: string;
238
+ request?: string;
239
+ } | {
240
+ op: 'cancel';
241
+ request: string;
242
+ } | {
243
+ op: 'remove';
244
+ name: string;
245
+ kind?: RecordKind;
246
+ } | {
247
+ op: 'restore';
248
+ name: string;
249
+ kind?: RecordKind;
250
+ } | {
251
+ op: 'seal';
252
+ name: string;
253
+ value: string;
254
+ source: RecordSource;
255
+ kind?: RecordKind;
256
+ expiresAt?: EpochMs;
257
+ refresh?: string;
258
+ } | {
259
+ op: 'import';
260
+ kind: ImportKind;
261
+ bytes: Uint8Array;
262
+ password?: string;
263
+ pick?: string[];
264
+ } | {
265
+ op: 'export';
266
+ passphrase: string;
267
+ } | {
268
+ op: 'lock';
269
+ } | {
270
+ op: 'dismissBackupReminder';
271
+ };
272
+ export type OpReply = {
273
+ op: 'sealed';
274
+ reply: SealedReply;
275
+ } | {
276
+ op: 'status';
277
+ status: VaultStatus;
278
+ } | {
279
+ op: 'placeholders';
280
+ placeholders: string[];
281
+ scrub: ScrubState;
282
+ } | {
283
+ op: 'list';
284
+ list: VaultList;
285
+ } | ({
286
+ op: 'audit';
287
+ } & AuditPage) | ({
288
+ op: 'put' | 'seal' | 'remove' | 'restore';
289
+ } & SealResult) | {
290
+ op: 'cancel';
291
+ state: RequestState;
292
+ } | ({
293
+ op: 'import';
294
+ } & ImportResult) | {
295
+ op: 'export';
296
+ bundle: Uint8Array;
297
+ } | {
298
+ op: 'lock';
299
+ } | {
300
+ op: 'dismissBackupReminder';
301
+ backupReminder: BackupReminder;
302
+ };
303
+ export type Request = Op & {
304
+ v: Protocol;
305
+ id: number;
306
+ };
307
+ export type Reply = (OpReply & {
308
+ v: Protocol;
309
+ id: number;
310
+ }) | {
311
+ v: Protocol;
312
+ id: number;
313
+ error: VaultError;
314
+ };
315
+ export type ChangedPart = 'secrets' | 'audit' | 'exits';
316
+ export type VaultEvent = {
317
+ v: Protocol;
318
+ event: 'status';
319
+ status: VaultStatus;
320
+ } | {
321
+ v: Protocol;
322
+ event: 'placeholders';
323
+ placeholders: string[];
324
+ scrub: ScrubState;
325
+ } | {
326
+ v: Protocol;
327
+ event: 'changed';
328
+ parts: ChangedPart[];
329
+ };
330
+ export interface Hello {
331
+ type: MessageType;
332
+ v: Protocol;
333
+ op: 'hello';
334
+ role?: Role;
335
+ }
336
+ export type Greeting = {
337
+ v: Protocol;
338
+ op: 'welcome';
339
+ role: Role;
340
+ status: VaultStatus;
341
+ } | {
342
+ v: Protocol;
343
+ op: 'refused';
344
+ error: VaultError;
345
+ };
346
+ export type ButtonMessage = {
347
+ type: MessageType;
348
+ v: Protocol;
349
+ op: 'button';
350
+ button: ButtonConfig;
351
+ } | {
352
+ type: MessageType;
353
+ v: Protocol;
354
+ op: 'focus';
355
+ };
356
+ export type ButtonEvent = {
357
+ type: MessageType;
358
+ v: Protocol;
359
+ } & ({
360
+ event: 'ready';
361
+ } | {
362
+ event: 'size';
363
+ width: number;
364
+ height: number;
365
+ inset: number;
366
+ } | {
367
+ event: 'label';
368
+ label: string;
369
+ } | {
370
+ event: 'focus';
371
+ direction: 'next' | 'previous';
372
+ } | {
373
+ event: 'popup';
374
+ intent: PopupIntent;
375
+ state: PopupState;
376
+ });
377
+ export {};
package/package.json CHANGED
@@ -1,4 +1,64 @@
1
1
  {
2
2
  "name": "@ai-ecoverse/slicc-vault",
3
- "version": "0.0.0"
3
+ "version": "1.1.0",
4
+ "description": "The separate-origin vault at vault.sliccy.ai: sealed secrets, OAuth tokens, and model and MCP credentials for SLICC seven, used only by its injector",
5
+ "type": "module",
6
+ "license": "Apache-2.0",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/ai-ecoverse/slicc-vault.git"
10
+ },
11
+ "engines": {
12
+ "node": ">=24"
13
+ },
14
+ "fledgling": {
15
+ "provider": "github",
16
+ "workflow": "release.yml",
17
+ "repo": "ai-ecoverse/slicc-vault",
18
+ "publish": true
19
+ },
20
+ "publishConfig": {
21
+ "access": "public",
22
+ "provenance": true
23
+ },
24
+ "exports": {
25
+ ".": {
26
+ "types": "./dist/types/contract.d.ts"
27
+ }
28
+ },
29
+ "types": "./dist/types/contract.d.ts",
30
+ "files": [
31
+ "dist/types"
32
+ ],
33
+ "scripts": {
34
+ "prepare": "[ ! -e .git ] || lefthook install",
35
+ "build": "node build.mjs && tsc -p tsconfig.build.json",
36
+ "prepack": "npm run build",
37
+ "start": "npm run build && node test/integration/serve.mjs",
38
+ "pretest": "npm run build && playwright-core install --no-shell chromium",
39
+ "test": "node --test --test-timeout=300000 --test-global-setup=node_modules/@ai-ecoverse/slicc-shared-web/harness/global.mjs 'test/integration/*.test.mjs'",
40
+ "test:unit": "NODE_NO_WARNINGS=1 mcr -c node_modules/@ai-ecoverse/slicc-shared-web/mcr.config.mjs node --import fake-indexeddb/auto --test --test-force-exit 'test/unit/**/*.test.mjs'",
41
+ "typecheck": "tsc",
42
+ "lint": "slicc-lint",
43
+ "format": "biome check --write .",
44
+ "trust": "npx fledgling sync -y --skip-publish",
45
+ "trust:dry": "npx fledgling sync --dry-run -y --skip-publish",
46
+ "release": "semantic-release"
47
+ },
48
+ "devDependencies": {
49
+ "@ai-ecoverse/slicc-shared-web": "1.13.1",
50
+ "@biomejs/biome": "2.5.15",
51
+ "@happy-dom/global-registrator": "20.14.5",
52
+ "@spectrum-css/button": "14.2.0",
53
+ "@spectrum-css/textfield": "8.2.0",
54
+ "@spectrum-css/tokens": "16.0.2",
55
+ "esbuild": "0.28.2",
56
+ "fake-indexeddb": "6.2.5",
57
+ "happy-dom": "20.14.5",
58
+ "lefthook": "2.1.17",
59
+ "monocart-coverage-reports": "2.13.0",
60
+ "playwright-core": "1.63.0",
61
+ "semantic-release": "25.0.9",
62
+ "typescript": "7.0.2"
63
+ }
4
64
  }