@shieldfive/mcp 0.2.0 → 0.4.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,75 @@
1
+ // Where the grant's connection string comes from, and nothing else.
2
+ //
3
+ // A connection string holds the bearer token AND the grant secret that opens
4
+ // the grant's keys. It is looked up in this order:
5
+ //
6
+ // 1. SHIELDFIVE_GRANT in the environment — for CI and headless use. Anything
7
+ // that can read this process's environment can read it; say so in docs.
8
+ // 2. The OS keychain (macOS Keychain, Windows Credential Manager, the Secret
9
+ // Service on Linux), written by `npx @shieldfive/mcp login`.
10
+ //
11
+ // It is never written to a file by this package, never logged, and never
12
+ // included in a tool result or an error message. parseConnectionString's
13
+ // errors describe the shape, not the value.
14
+
15
+ import { parseConnectionString, VaultCryptoError } from '@shieldfive/crypto/vault'
16
+
17
+ export const KEYCHAIN_SERVICE = 'shieldfive-mcp'
18
+ export const KEYCHAIN_ACCOUNT = 'grant'
19
+
20
+ async function keychainEntry() {
21
+ const { Entry } = await import('@napi-rs/keyring')
22
+ return new Entry(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT)
23
+ }
24
+
25
+ export async function readKeychain() {
26
+ try {
27
+ const entry = await keychainEntry()
28
+ return entry.getPassword() ?? null
29
+ } catch {
30
+ // No keychain on this machine (headless Linux without a Secret Service),
31
+ // or no entry: both mean "not configured here".
32
+ return null
33
+ }
34
+ }
35
+
36
+ export async function writeKeychain(connectionString) {
37
+ const entry = await keychainEntry()
38
+ entry.setPassword(connectionString)
39
+ }
40
+
41
+ export async function deleteKeychain() {
42
+ try {
43
+ const entry = await keychainEntry()
44
+ return entry.deletePassword()
45
+ } catch {
46
+ return false
47
+ }
48
+ }
49
+
50
+ /**
51
+ * The configured grant, parsed, or null when none is configured. A malformed
52
+ * value is an error with a fixed message; the value itself never appears.
53
+ *
54
+ * SHIELDFIVE_GRANT=none means "no vault here": the keychain is not read at all.
55
+ * That is how one client stays local-only on a machine where another client is
56
+ * connected, and how the tests avoid depending on the developer's keychain.
57
+ */
58
+ export async function loadGrantCredential(env = process.env, readStore = readKeychain) {
59
+ const configured = env.SHIELDFIVE_GRANT?.trim()
60
+ if (configured === 'none') return null
61
+ const raw = configured || (await readStore())?.trim() || null
62
+ if (!raw) return null
63
+ try {
64
+ return { ...parseConnectionString(raw), source: env.SHIELDFIVE_GRANT ? 'env' : 'keychain' }
65
+ } catch (err) {
66
+ if (err instanceof VaultCryptoError) {
67
+ throw new Error(
68
+ 'The configured ShieldFive connection string is not valid. Create a new ' +
69
+ 'connection in ShieldFive → Settings → AI assistants, or run ' +
70
+ '`npx @shieldfive/mcp login` again.',
71
+ )
72
+ }
73
+ throw err
74
+ }
75
+ }
@@ -0,0 +1,90 @@
1
+ // A fixed pool of name-decryption workers with an in-memory cache keyed by the
2
+ // envelope itself: a renamed item has a new envelope and is decrypted again;
3
+ // an unchanged one never is. The cache dies with the process.
4
+
5
+ import { availableParallelism } from 'node:os'
6
+ import { Worker } from 'node:worker_threads'
7
+
8
+ export function createNamePool({ size = Math.max(1, Math.min(8, availableParallelism() - 1)) } = {}) {
9
+ const workers = []
10
+ const idle = []
11
+ const queue = []
12
+ const pending = new Map()
13
+ const cache = new Map()
14
+ let seq = 0
15
+
16
+ function startWorker() {
17
+ const w = new Worker(new URL('./nameWorker.mjs', import.meta.url))
18
+ w.unref()
19
+ w.on('message', ({ id, ok, name }) => {
20
+ const p = pending.get(id)
21
+ pending.delete(id)
22
+ p?.resolve(ok ? name : null)
23
+ idle.push(w)
24
+ pump()
25
+ })
26
+ // A crashed worker must not strand its job: fail that name (it shows as
27
+ // unavailable), drop the worker and start a replacement.
28
+ w.on('error', () => {})
29
+ w.on('exit', () => {
30
+ for (const [id, job] of pending) {
31
+ if (job.worker === w) {
32
+ pending.delete(id)
33
+ job.resolve(null)
34
+ }
35
+ }
36
+ const i = workers.indexOf(w)
37
+ if (i >= 0) workers.splice(i, 1)
38
+ const j = idle.indexOf(w)
39
+ if (j >= 0) idle.splice(j, 1)
40
+ if (!closing) {
41
+ startWorker()
42
+ pump()
43
+ }
44
+ })
45
+ workers.push(w)
46
+ idle.push(w)
47
+ }
48
+
49
+ function pump() {
50
+ while (idle.length && queue.length) {
51
+ const w = idle.pop()
52
+ const job = queue.shift()
53
+ job.worker = w
54
+ pending.set(job.msg.id, job)
55
+ w.postMessage(job.msg)
56
+ }
57
+ }
58
+
59
+ /**
60
+ * Decrypt one name. `key` is the parent folder key, or with direct: true the
61
+ * already-derived name key from a grant `name` wrap. Resolves to null when
62
+ * the envelope does not open — callers show it as unavailable, never guess.
63
+ */
64
+ function decrypt({ raw, key, rowId, direct = false }) {
65
+ const cacheKey = `${rowId}|${raw}`
66
+ if (cache.has(cacheKey)) return Promise.resolve(cache.get(cacheKey))
67
+ if (!workers.length) for (let i = 0; i < size; i++) startWorker()
68
+ return new Promise((resolve) => {
69
+ const id = ++seq
70
+ queue.push({
71
+ msg: { id, raw, key, rowId, direct },
72
+ resolve: (name) => {
73
+ if (name !== null) cache.set(cacheKey, name)
74
+ resolve(name)
75
+ },
76
+ })
77
+ pump()
78
+ })
79
+ }
80
+
81
+ let closing = false
82
+ async function close() {
83
+ closing = true
84
+ await Promise.all(workers.map((w) => w.terminate()))
85
+ workers.length = 0
86
+ idle.length = 0
87
+ }
88
+
89
+ return { decrypt, close, get cached() { return cache.size } }
90
+ }
@@ -0,0 +1,23 @@
1
+ // Worker for name decryption. Argon2id costs ~70 ms per name at the web app's
2
+ // "interactive" level, so names are opened on a small pool of threads. Keys
3
+ // arrive over the in-process message channel; nothing touches disk.
4
+
5
+ import { parentPort } from 'node:worker_threads'
6
+
7
+ import { decryptName, decryptNameWithKey, parseNameEnvelope } from '@shieldfive/crypto/vault'
8
+
9
+ parentPort.on('message', async ({ id, raw, key, rowId, direct }) => {
10
+ try {
11
+ const envelope = parseNameEnvelope(raw)
12
+ if (!envelope) {
13
+ parentPort.postMessage({ id, ok: false })
14
+ return
15
+ }
16
+ const name = direct
17
+ ? await decryptNameWithKey({ envelope, nameKey: key, rowId })
18
+ : await decryptName({ envelope, folderKey: key, rowId })
19
+ parentPort.postMessage({ id, ok: true, name })
20
+ } catch {
21
+ parentPort.postMessage({ id, ok: false })
22
+ }
23
+ })
@@ -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
+ }