@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.
- package/CHANGELOG.md +35 -0
- package/README.md +187 -59
- package/SECURITY.md +27 -8
- package/package.json +22 -12
- package/server.json +34 -0
- package/src/server.mjs +206 -30
- package/src/tools/vault.mjs +548 -0
- package/src/vault/api.mjs +190 -0
- package/src/vault/cli.mjs +115 -0
- package/src/vault/content.mjs +89 -0
- package/src/vault/credential.mjs +69 -0
- package/src/vault/namePool.mjs +90 -0
- package/src/vault/nameWorker.mjs +23 -0
- package/src/vault/session.mjs +170 -0
|
@@ -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
|
+
}
|