@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.
- package/CHANGELOG.md +74 -0
- package/README.md +213 -59
- package/SECURITY.md +27 -8
- package/package.json +22 -12
- package/server.json +36 -0
- package/src/server.mjs +253 -31
- package/src/tools/vault.mjs +548 -0
- package/src/tools/vaultConnect.mjs +119 -0
- package/src/vault/api.mjs +190 -0
- package/src/vault/cli.mjs +150 -0
- package/src/vault/connect.mjs +227 -0
- package/src/vault/content.mjs +89 -0
- package/src/vault/credential.mjs +75 -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,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
|
+
}
|