@shieldfive/mcp 0.2.0 → 0.3.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.
@@ -0,0 +1,170 @@
1
+ // One view of the vault as this grant can see it, rebuilt on every tool call.
2
+ //
3
+ // Every call re-fetches the grant, its wraps and the in-scope listing from the
4
+ // server. That is what makes revocation and expiry take effect on the next
5
+ // call: nothing here outlives a request except (a) the grant wrap key derived
6
+ // from the connection string and (b) decrypted names, cached by envelope. Both
7
+ // live only in this process's memory.
8
+ //
9
+ // Keys come from exactly two places: the grant's own wraps (opened with the
10
+ // grant secret) and the folder-key chain below them. Nothing here can derive a
11
+ // key the grant was not given; a folder or file whose key does not open is
12
+ // reported as unreadable, never guessed.
13
+
14
+ import {
15
+ deriveGrantWrapKey,
16
+ unwrapChainKey,
17
+ unwrapKeyForGrant,
18
+ } from '@shieldfive/crypto/vault'
19
+
20
+ import { ToolError } from '../roots.mjs'
21
+
22
+ // C0/C1 controls, line/paragraph separators and bidi overrides/isolates: a
23
+ // decrypted name is attacker-influenced text headed for a model's context.
24
+ const UNSAFE = new RegExp('[\\u0000-\\u001f\\u007f-\\u009f\\u2028-\\u2029\\u202a-\\u202e\\u2066-\\u2069]', 'g')
25
+
26
+ /** A name as it may be shown to a model: no control or bidi characters, bounded. */
27
+ export function displayName(name) {
28
+ if (typeof name !== 'string') return null
29
+ const clean = name.replace(UNSAFE, String.fromCharCode(0xfffd))
30
+ return clean.length > 255 ? `${clean.slice(0, 255)}…` : clean
31
+ }
32
+
33
+ export const TRASH_LABEL = 'ShieldFive Bin (this connection)'
34
+
35
+ export function createVaultSession({ credential, api, names }) {
36
+ let wrapKey = null
37
+
38
+ async function grantWrapKey() {
39
+ wrapKey ??= await deriveGrantWrapKey(credential.secret, credential.grantId)
40
+ return wrapKey
41
+ }
42
+
43
+ /** Fetch and open everything this grant can see right now. */
44
+ async function load(signal, onProgress) {
45
+ const { grant, keys } = await api.grant(signal)
46
+ if (grant.id !== credential.grantId) {
47
+ throw new ToolError(
48
+ 'grant_mismatch',
49
+ 'The server answered for a different connection. Nothing was read.',
50
+ )
51
+ }
52
+ const gk = await grantWrapKey()
53
+ const wraps = { folder: new Map(), file: new Map(), file_pq: new Map(), name: new Map() }
54
+ for (const k of keys) {
55
+ if (!wraps[k.kind]) continue
56
+ try {
57
+ wraps[k.kind].set(
58
+ k.objectId,
59
+ await unwrapKeyForGrant({
60
+ grantWrapKey: gk,
61
+ grantId: grant.id,
62
+ kind: k.kind,
63
+ objectId: k.objectId,
64
+ wrapped: k,
65
+ }),
66
+ )
67
+ } catch {
68
+ // A wrap that does not open under this grant's key was not made for
69
+ // this grant, or was tampered with. Ignored, never trusted.
70
+ }
71
+ }
72
+
73
+ const [folderRows, fileRows] = await Promise.all([api.folders(signal), api.files(signal)])
74
+
75
+ // Folder keys: the grant's wraps for its roots, then the chain downwards.
76
+ const folderKeys = new Map(wraps.folder)
77
+ let grew = true
78
+ while (grew) {
79
+ grew = false
80
+ for (const f of folderRows) {
81
+ if (folderKeys.has(f.id) || f.isScopeRoot || !f.parentId || !f.fkWrapped) continue
82
+ const parentKey = folderKeys.get(f.parentId)
83
+ if (!parentKey) continue
84
+ try {
85
+ folderKeys.set(f.id, await unwrapChainKey(parentKey, { wrapped: f.fkWrapped, iv: f.fkIv }))
86
+ grew = true
87
+ } catch {
88
+ // Unopenable branch; its contents are reported as unreadable.
89
+ }
90
+ }
91
+ }
92
+
93
+ // Names, in parallel on the worker pool.
94
+ const total = folderRows.length + fileRows.length
95
+ let done = 0
96
+ // Raw names are what writes re-seal; display names are what the model sees.
97
+ const nameOf = async (row, parentId, isRoot) => {
98
+ let name = null
99
+ if (row.id === grant.trashFolderId) name = TRASH_LABEL
100
+ else if (isRoot || !parentId) {
101
+ const k = wraps.name.get(row.id)
102
+ if (k) name = await names.decrypt({ raw: row.name, key: k, rowId: row.id, direct: true })
103
+ } else {
104
+ const k = folderKeys.get(parentId)
105
+ if (k) name = await names.decrypt({ raw: row.name, key: k, rowId: row.id })
106
+ }
107
+ onProgress?.(++done, total)
108
+ return name
109
+ }
110
+ const folderNames = await Promise.all(folderRows.map((f) => nameOf(f, f.parentId, f.isScopeRoot)))
111
+ const fileNames = await Promise.all(fileRows.map((f) => nameOf(f, f.folderId, false)))
112
+
113
+ const folders = new Map()
114
+ folderRows.forEach((f, i) => {
115
+ folders.set(f.id, {
116
+ id: f.id,
117
+ name: displayName(folderNames[i]),
118
+ rawName: folderNames[i],
119
+ parentId: f.isScopeRoot ? null : f.parentId,
120
+ isScopeRoot: f.isScopeRoot,
121
+ inTrash: f.inTrash,
122
+ updatedAt: f.updatedAt,
123
+ raw: f,
124
+ })
125
+ })
126
+ const pathOf = (folderId) => {
127
+ const parts = []
128
+ let cur = folderId ? folders.get(folderId) : null
129
+ for (let depth = 0; cur && depth < 256; depth++) {
130
+ parts.unshift(cur.name ?? '[name unavailable]')
131
+ cur = cur.parentId ? folders.get(cur.parentId) : null
132
+ }
133
+ return `/${parts.join('/')}`
134
+ }
135
+ for (const f of folders.values()) f.path = pathOf(f.id)
136
+
137
+ const files = new Map()
138
+ fileRows.forEach((f, i) => {
139
+ const name = displayName(fileNames[i])
140
+ files.set(f.id, {
141
+ id: f.id,
142
+ name,
143
+ rawName: fileNames[i],
144
+ path: `${f.folderId ? pathOf(f.folderId) : ''}/${name ?? '[name unavailable]'}`,
145
+ folderId: f.folderId,
146
+ size: f.size ?? null,
147
+ ciphertextSize: f.ciphertextSize ?? null,
148
+ createdAt: f.createdAt,
149
+ updatedAt: f.updatedAt,
150
+ contentType: typeof f.contentType === 'string' ? displayName(f.contentType.slice(0, 100)) : null,
151
+ cipherVersion: f.cipherVersion,
152
+ inTrash: f.inTrash,
153
+ readable: contentKeyAvailable(f, folderKeys, wraps),
154
+ raw: f,
155
+ })
156
+ })
157
+
158
+ return { grant, folders, files, folderKeys, wraps }
159
+ }
160
+
161
+ return { load, grantWrapKey }
162
+ }
163
+
164
+ function contentKeyAvailable(f, folderKeys, wraps) {
165
+ if (f.folderId) {
166
+ if (!folderKeys.has(f.folderId)) return false
167
+ return f.cipherVersion === 3 ? Boolean(f.pqkFkWrapped) : Boolean(f.cskWrapped)
168
+ }
169
+ return f.cipherVersion === 3 ? wraps.file_pq.has(f.id) : wraps.file.has(f.id)
170
+ }