@papervault/mcp 0.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,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 PaperVault.xyz
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,150 @@
1
+ # PaperVault MCP — AI-agent safety net for sensitive operations
2
+
3
+ **PaperVault 📄🔐** is a free open source tool for creating offline paper-based data vaults for your foundational secrets, such as passwords, 2FA recovery codes, digital asset keys, hard drive encryption keys, and other critical data.
4
+
5
+ This is the **MCP server**. It lets AI agents (Claude Code, Cursor, Claude Desktop, or any [Model Context Protocol](https://modelcontextprotocol.io) client) trigger PaperVault backups as a *safety step* inside sensitive workflows — before rotating a production key, destroying an account, or running a destructive migration. The agent triggers the backup, you get a printable paper recovery kit, and the agent never sees the secret values.
6
+
7
+ The browser app lives at [papervault.xyz](https://papervault.xyz). The CLI lives at [`@papervault/cli`](https://www.npmjs.com/package/@papervault/cli).
8
+
9
+ ## 🔐 Overview
10
+
11
+ An agent is about to do something it can't undo. Before it does, it asks PaperVault to snapshot the current credentials to a printable kit. If the operation goes wrong, you have a paper recovery path. Three guarantees by design:
12
+
13
+ - The agent never receives secret values — they encrypt in memory and land in printable HTML on your disk
14
+ - The audit log captures every invocation (with hashed name fingerprints, never plaintext)
15
+ - A hard cap of 20 secrets per call prevents bulk exfiltration via a confused agent
16
+
17
+ ## 🚀 Quick Start
18
+
19
+ ```bash
20
+ npm install -g @papervault/mcp
21
+ ```
22
+
23
+ Add to your MCP client config. For **Claude Code** (`~/.claude/claude_code_config.json` or project `.claude/`):
24
+
25
+ ```json
26
+ {
27
+ "mcpServers": {
28
+ "papervault": {
29
+ "command": "papervault-mcp"
30
+ }
31
+ }
32
+ }
33
+ ```
34
+
35
+ For **Cursor** / **Claude Desktop**: same shape, check your client's docs for the config path.
36
+
37
+ If you'd rather not install globally:
38
+
39
+ ```json
40
+ {
41
+ "mcpServers": {
42
+ "papervault": {
43
+ "command": "npx",
44
+ "args": ["-y", "@papervault/mcp"]
45
+ }
46
+ }
47
+ }
48
+ ```
49
+
50
+ Requires Node.js ≥ 24.
51
+
52
+ ## 🔑 Tools Advertised
53
+
54
+ | Tool | Purpose |
55
+ |---|---|
56
+ | `papervault_list_sources` | What secret backends are configured (file, stdin, azure-kv, etc.) |
57
+ | `papervault_dry_run` | Preview a backup — returns count + name fingerprint, **no values fetched**. Agent should always call this first to confirm scope. |
58
+ | `papervault_backup_from_source` | Generate a kit. Writes HTML files (mode `0600`) to a local directory. Returns vault_id, file list, key aliases — **never values**. |
59
+ | `papervault_audit_recent` | Read recent audit log entries (names hashed by default). |
60
+
61
+ ## 📖 Example Agent Prompts
62
+
63
+ ```
64
+ Use the papervault MCP server to snapshot the current Azure KV secrets
65
+ that match "prod-db-*" before I rotate them. Threshold 2 of 3, save to
66
+ /Users/me/dr-backups/.
67
+ ```
68
+
69
+ ```
70
+ Before destroying this AWS account, use papervault to back up the root
71
+ credentials. Dry-run first so I can confirm what you'll capture.
72
+ ```
73
+
74
+ ```
75
+ Show me the last 10 papervault backup audit entries — I want to know
76
+ what's been backed up this week.
77
+ ```
78
+
79
+ ## 🛡️ Security Model
80
+
81
+ ### Guardrails (verified by tests)
82
+
83
+ | Guardrail | Behaviour |
84
+ |---|---|
85
+ | **Hard cap of 20 secrets per call** | Refuses with `max_secrets` error when more match the selection. |
86
+ | **`save_path` must be absolute** | Refuses relative paths so the agent can't write into surprise locations. |
87
+ | **System paths refused** | `/etc`, `/usr`, `/bin`, `/sbin`, `/boot`, `/proc`, `/sys`, `/dev`, `C:\Windows`, `C:\Program Files` are blocked. |
88
+ | **No secret values in tool responses** | Agent gets vault_id, file paths, key aliases — never the actual secret bytes. |
89
+ | **No secret values in audit log** | Names hashed to a 16-char fingerprint by default. Set `PAPERVAULT_LOG_NAMES=1` to log plaintext names. |
90
+ | **File permissions** | Written kits are `chmod 0600` (owner-only readable). |
91
+ | **No auto-print scripts in saved files** | Agent-saved files don't trigger print dialogs when opened later. |
92
+ | **No network egress** | Backups go to local disk only. There is no upload, email, or printer-discovery code path. |
93
+ | **Two-phase fetch** | `list()` (metadata only) runs before `fetch()` (values), so the max-secrets check happens before any secret leaves the source. |
94
+
95
+ ### Threat Model
96
+
97
+ PaperVault MCP does NOT protect against:
98
+
99
+ - ❌ A trusted-by-you agent acting in bad faith with the access it has
100
+ - ❌ Compromise of the cloud secret store the source URI points at
101
+ - ❌ Physical compromise of the printed kit
102
+ - ❌ Malicious modifications to the MCP server source code
103
+ - ❌ Tampered MCP client routing tool calls to a malicious server
104
+
105
+ The MCP server runs on **your** machine with **your** identity. It uses your existing cloud credentials (`az login`, etc.) — we never store or transmit credentials. The agent only sees what the tool schemas explicitly return.
106
+
107
+ That said: an agent with access to this server can encrypt your secrets to disk under any allowed path. If you don't trust the agent, don't enable the tool. The audit log is your friend for after-the-fact review.
108
+
109
+ ## 🔧 Configuration
110
+
111
+ | Environment variable | Effect |
112
+ |---|---|
113
+ | `PAPERVAULT_AUDIT_LOG` | Path to the audit log file (default `~/.papervault/audit.log`). |
114
+ | `PAPERVAULT_LOG_NAMES=1` | Log secret names in plaintext instead of fingerprints. |
115
+ | `PAPERVAULT_MCP_DEBUG=1` | Print stack traces to stderr on fatal errors. |
116
+
117
+ For the underlying crypto, source adapters, and limits, see [`@papervault/cli`](https://www.npmjs.com/package/@papervault/cli) and [`@papervault/core`](https://www.npmjs.com/package/@papervault/core).
118
+
119
+ ## 🔗 Related Packages
120
+
121
+ - [`@papervault/cli`](https://www.npmjs.com/package/@papervault/cli) — Command-line front-end (humans, not agents)
122
+ - [`@papervault/core`](https://www.npmjs.com/package/@papervault/core) — Crypto + Shamir + page generation library
123
+ - [`@papervault/init`](https://www.npmjs.com/package/@papervault/init) — `npx` setup wizard
124
+ - [PaperVault web app](https://papervault.xyz) — same crypto, browser version
125
+ - [Main repo](https://github.com/boazeb/papervault) — issues, docs, SECURITY.md
126
+
127
+ ## 🤝 Contributing
128
+
129
+ Contributions are welcome! See the main repo at [github.com/boazeb/papervault](https://github.com/boazeb/papervault).
130
+
131
+ ## 📄 License
132
+
133
+ MIT — see [LICENSE](LICENSE).
134
+
135
+ ## 🙏 Acknowledgments
136
+
137
+ - [Model Context Protocol](https://modelcontextprotocol.io) by Anthropic
138
+ - [@modelcontextprotocol/sdk](https://github.com/modelcontextprotocol/typescript-sdk)
139
+ - [Shamir's Secret Sharing](https://en.wikipedia.org/wiki/Shamir%27s_Secret_Sharing) algorithm by Adi Shamir
140
+
141
+ ## ⚠️ Disclaimer
142
+
143
+ This software is provided "as is" without warranty. Users are responsible for:
144
+
145
+ - Verifying that the agents they grant access to the tool are trustworthy
146
+ - Reviewing the audit log periodically
147
+ - Testing recovery procedures before relying on them
148
+ - Understanding the cryptographic principles involved
149
+
150
+ **Always test with non-critical data first!**
package/package.json ADDED
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "@papervault/mcp",
3
+ "version": "0.1.0",
4
+ "description": "MCP server for PaperVault. Lets AI agents trigger paper-based secret backups as a safety step before sensitive operations — agents never see secret values.",
5
+ "type": "module",
6
+ "bin": {
7
+ "papervault-mcp": "./src/index.js"
8
+ },
9
+ "exports": {
10
+ ".": "./src/index.js"
11
+ },
12
+ "files": [
13
+ "src",
14
+ "LICENSE",
15
+ "README.md"
16
+ ],
17
+ "engines": {
18
+ "node": ">=24.x"
19
+ },
20
+ "homepage": "https://papervault.xyz",
21
+ "repository": {
22
+ "type": "git",
23
+ "url": "https://github.com/boazeb/papervault.git",
24
+ "directory": "papervault-mcp"
25
+ },
26
+ "bugs": {
27
+ "url": "https://github.com/boazeb/papervault/issues"
28
+ },
29
+ "keywords": [
30
+ "papervault",
31
+ "mcp",
32
+ "model-context-protocol",
33
+ "anthropic",
34
+ "claude",
35
+ "ai-agent",
36
+ "agent-safety",
37
+ "secret-management",
38
+ "cryptography",
39
+ "shamir-secret-sharing",
40
+ "disaster-recovery"
41
+ ],
42
+ "author": "PaperVault.xyz",
43
+ "license": "MIT",
44
+ "publishConfig": {
45
+ "access": "public"
46
+ },
47
+ "dependencies": {
48
+ "@modelcontextprotocol/sdk": "^1.0.4",
49
+ "@papervault/cli": "^0.1.0",
50
+ "@papervault/core": "^0.1.0"
51
+ }
52
+ }
package/src/index.js ADDED
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env node
2
+ // Stdio entry point for the PaperVault MCP server.
3
+ // Agents (Claude Code, Cursor, etc.) connect over stdio and call our tools.
4
+
5
+ import { serve } from './server.js';
6
+
7
+ serve().catch(err => {
8
+ // MCP runs over stdio so any non-protocol output goes to stderr only.
9
+ process.stderr.write(`papervault-mcp fatal: ${err.message}\n`);
10
+ if (process.env.PAPERVAULT_MCP_DEBUG) {
11
+ process.stderr.write(err.stack + '\n');
12
+ }
13
+ process.exit(1);
14
+ });
package/src/safety.js ADDED
@@ -0,0 +1,83 @@
1
+ // Input validation + safety guardrails for MCP tool calls.
2
+ // The MCP risk model: an agent may be confused, miscalibrated, or compromised.
3
+ // Every input crosses a trust boundary, so we re-validate aggressively even
4
+ // when the underlying core lib already does its own checks.
5
+
6
+ import { isAbsolute, normalize } from 'node:path';
7
+
8
+ export const HARD_MAX_SECRETS = 20;
9
+ export const HARD_MAX_SHARES = 20;
10
+ export const MAX_AUDIT_LIMIT = 100;
11
+
12
+ // Paths we refuse to write to, regardless of what the agent asks. These are
13
+ // either critical (/etc, /usr) or fake-filesystems where writes don't behave
14
+ // like writes (/proc, /dev, /sys).
15
+ const FORBIDDEN_PATH_PREFIXES = [
16
+ '/etc/', '/usr/', '/bin/', '/sbin/', '/boot/',
17
+ '/proc/', '/sys/', '/dev/',
18
+ 'C:\\Windows\\', 'C:\\Program Files\\',
19
+ ];
20
+
21
+ export function validateSavePath(p) {
22
+ if (typeof p !== 'string' || p.length === 0) {
23
+ throw new Error('save_path must be a non-empty string.');
24
+ }
25
+ if (!isAbsolute(p)) {
26
+ throw new Error('save_path must be an absolute filesystem path (e.g. /Users/me/backups, not "./backups").');
27
+ }
28
+ const norm = normalize(p);
29
+ for (const bad of FORBIDDEN_PATH_PREFIXES) {
30
+ if (norm.startsWith(bad) || norm === bad.replace(/\/$/, '')) {
31
+ throw new Error(`save_path refused: writing under ${bad} is not allowed.`);
32
+ }
33
+ }
34
+ return norm;
35
+ }
36
+
37
+ export function validateInteger(value, name, { min, max } = {}) {
38
+ const n = Number(value);
39
+ if (!Number.isInteger(n)) {
40
+ // Deliberately do NOT echo `value` itself in the error: if an agent
41
+ // accidentally passes a secret-shaped string here, the message would
42
+ // land in the tool response AND the audit log.
43
+ throw new Error(`${name} must be an integer (got ${typeof value}).`);
44
+ }
45
+ if (min != null && n < min) throw new Error(`${name} must be >= ${min}.`);
46
+ if (max != null && n > max) throw new Error(`${name} must be <= ${max}.`);
47
+ return n;
48
+ }
49
+
50
+ export function validateString(value, name, { maxLength = 200 } = {}) {
51
+ if (typeof value !== 'string' || value.length === 0) {
52
+ throw new Error(`${name} must be a non-empty string.`);
53
+ }
54
+ if (value.length > maxLength) {
55
+ throw new Error(`${name} exceeds maximum length of ${maxLength}.`);
56
+ }
57
+ return value;
58
+ }
59
+
60
+ export function validateNames(value, expectedLength) {
61
+ if (value == null) return undefined;
62
+ if (!Array.isArray(value)) throw new Error('names must be an array of strings.');
63
+ if (value.length !== expectedLength) {
64
+ throw new Error(`names has ${value.length} entries but shares is ${expectedLength}.`);
65
+ }
66
+ for (const [i, n] of value.entries()) {
67
+ if (typeof n !== 'string' || n.length === 0) {
68
+ throw new Error(`names[${i}] must be a non-empty string.`);
69
+ }
70
+ if (n.length > 60) throw new Error(`names[${i}] exceeds 60 chars.`);
71
+ }
72
+ return value;
73
+ }
74
+
75
+ /** Build a glob-to-regex predicate from a comma-separated --select pattern. */
76
+ export function makeGlobFilter(selectStr) {
77
+ if (!selectStr) return () => true;
78
+ const patterns = selectStr.split(',').map(s => s.trim()).filter(Boolean).map(g => {
79
+ const escaped = g.split('*').map(s => s.replace(/[.+?^${}()|[\]\\]/g, '\\$&'));
80
+ return new RegExp('^' + escaped.join('.*') + '$');
81
+ });
82
+ return (name) => patterns.some(p => p.test(name));
83
+ }
package/src/server.js ADDED
@@ -0,0 +1,57 @@
1
+ // MCP server wiring. Boots the SDK server on stdio and routes tool calls
2
+ // to the handlers in tools.js.
3
+
4
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
5
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
6
+ import {
7
+ CallToolRequestSchema,
8
+ ListToolsRequestSchema,
9
+ } from '@modelcontextprotocol/sdk/types.js';
10
+
11
+ import {
12
+ TOOLS,
13
+ handleBackupFromSource,
14
+ handleDryRun,
15
+ handleListSources,
16
+ handleAuditRecent,
17
+ } from './tools.js';
18
+
19
+ export async function serve() {
20
+ const server = new Server(
21
+ { name: 'papervault', version: '0.1.0' },
22
+ { capabilities: { tools: {} } },
23
+ );
24
+
25
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({
26
+ tools: TOOLS,
27
+ }));
28
+
29
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
30
+ const { name, arguments: args } = request.params;
31
+ try {
32
+ switch (name) {
33
+ case 'papervault_backup_from_source': return await handleBackupFromSource(args);
34
+ case 'papervault_dry_run': return await handleDryRun(args);
35
+ case 'papervault_list_sources': return await handleListSources();
36
+ case 'papervault_audit_recent': return await handleAuditRecent(args);
37
+ default:
38
+ return {
39
+ content: [{ type: 'text', text: JSON.stringify({ error: `unknown tool: ${name}` }) }],
40
+ isError: true,
41
+ };
42
+ }
43
+ } catch (err) {
44
+ // Last-resort error envelope — handlers should normally catch and
45
+ // return their own structured error response.
46
+ return {
47
+ content: [{ type: 'text', text: JSON.stringify({ error: err.message ?? String(err) }) }],
48
+ isError: true,
49
+ };
50
+ }
51
+ });
52
+
53
+ const transport = new StdioServerTransport();
54
+ await server.connect(transport);
55
+ // process.stderr only — MUST NOT write to stdout (MCP protocol channel).
56
+ process.stderr.write('papervault-mcp: listening on stdio\n');
57
+ }
package/src/tools.js ADDED
@@ -0,0 +1,342 @@
1
+ // Tool schemas + handlers exposed by the PaperVault MCP server.
2
+ //
3
+ // Every handler MUST:
4
+ // - Validate inputs aggressively (see safety.js) — agents are untrusted
5
+ // - Audit the invocation via @papervault/cli/audit
6
+ // - Never return secret values in the response
7
+ // - Return JSON-stringified content (MCP convention for structured data)
8
+
9
+ import { mkdir, writeFile, readFile, chmod } from 'node:fs/promises';
10
+ import { homedir } from 'node:os';
11
+ import { dirname, join } from 'node:path';
12
+ import { createHash } from 'node:crypto';
13
+
14
+ import { createKit } from '@papervault/core';
15
+ import { resolveSource, SUPPORTED_SOURCES } from '@papervault/cli/sources';
16
+ import { audit } from '@papervault/cli/audit';
17
+
18
+ import {
19
+ HARD_MAX_SECRETS, HARD_MAX_SHARES, MAX_AUDIT_LIMIT,
20
+ validateSavePath, validateInteger, validateString, validateNames,
21
+ makeGlobFilter,
22
+ } from './safety.js';
23
+
24
+ /** Helper: wrap structured data in MCP's content-array format. */
25
+ function ok(data) {
26
+ return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
27
+ }
28
+ function fail(message, details) {
29
+ const payload = details ? { error: message, ...details } : { error: message };
30
+ return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }], isError: true };
31
+ }
32
+
33
+ function fingerprint(names) {
34
+ const sorted = [...names].sort().join('\n');
35
+ return createHash('sha256').update(sorted).digest('hex').slice(0, 16);
36
+ }
37
+
38
+ // =====================================================================
39
+ // Tool definitions (schemas advertised to clients)
40
+ // =====================================================================
41
+
42
+ export const TOOLS = [
43
+ {
44
+ name: 'papervault_backup_from_source',
45
+ description:
46
+ 'Generate a PaperVault disaster-recovery kit from a configured secret source. ' +
47
+ 'Encrypts secrets with AES-256-GCM, splits the key with Shamir Secret Sharing, ' +
48
+ 'and writes printable HTML pages to a local directory. ' +
49
+ 'NEVER returns secret values in the response. ' +
50
+ 'Designed as a safety step before sensitive operations — e.g. before rotating a ' +
51
+ 'production key, snapshot the old credentials to paper. ' +
52
+ `Hard limit: ${HARD_MAX_SECRETS} secrets per call. ` +
53
+ 'save_path must be an absolute local filesystem path; no network upload supported.',
54
+ inputSchema: {
55
+ type: 'object',
56
+ properties: {
57
+ source_uri: {
58
+ type: 'string',
59
+ description: 'Source URI. Schemes: file://<path>, stdin (-), azure-kv://<vault-name>. ' +
60
+ 'See papervault_list_sources for the full list.',
61
+ },
62
+ threshold: {
63
+ type: 'integer',
64
+ minimum: 1,
65
+ description: 'Minimum number of key shares needed to unlock the vault.',
66
+ },
67
+ shares: {
68
+ type: 'integer',
69
+ minimum: 1,
70
+ maximum: HARD_MAX_SHARES,
71
+ description: `Total number of key shares to generate (1..${HARD_MAX_SHARES}).`,
72
+ },
73
+ save_path: {
74
+ type: 'string',
75
+ description: 'Absolute filesystem directory where the kit will be written. ' +
76
+ 'Files go in <save_path>/vault-<id>/. Created if missing.',
77
+ },
78
+ select: {
79
+ type: 'string',
80
+ description: 'Comma-separated glob patterns to filter secret names (e.g. "prod-*,db-*").',
81
+ },
82
+ max_secrets: {
83
+ type: 'integer',
84
+ minimum: 1,
85
+ maximum: HARD_MAX_SECRETS,
86
+ description: `Hard cap on selected secrets. Default and absolute max: ${HARD_MAX_SECRETS}.`,
87
+ },
88
+ vault_name: {
89
+ type: 'string',
90
+ description: 'Label printed on the kit (defaults to "Disaster Recovery Kit").',
91
+ },
92
+ names: {
93
+ type: 'array',
94
+ items: { type: 'string' },
95
+ description: 'Per-share custodian labels (length must equal shares).',
96
+ },
97
+ },
98
+ required: ['source_uri', 'threshold', 'shares', 'save_path'],
99
+ },
100
+ },
101
+ {
102
+ name: 'papervault_dry_run',
103
+ description:
104
+ 'Preview what papervault_backup_from_source would do without pulling any secret values. ' +
105
+ 'Returns the count + a deterministic 16-char fingerprint of the secret names that would be ' +
106
+ 'backed up. Use this to confirm scope before committing to a real backup.',
107
+ inputSchema: {
108
+ type: 'object',
109
+ properties: {
110
+ source_uri: { type: 'string' },
111
+ select: { type: 'string' },
112
+ max_secrets: { type: 'integer', minimum: 1, maximum: HARD_MAX_SECRETS },
113
+ },
114
+ required: ['source_uri'],
115
+ },
116
+ },
117
+ {
118
+ name: 'papervault_list_sources',
119
+ description: 'List the secret source backends supported by this server with their availability.',
120
+ inputSchema: { type: 'object', properties: {} },
121
+ },
122
+ {
123
+ name: 'papervault_audit_recent',
124
+ description:
125
+ 'Read recent entries from the PaperVault audit log. Records are JSONL — one backup ' +
126
+ 'invocation per line. NEVER contains secret values; secret names are hashed to a ' +
127
+ '16-char fingerprint by default.',
128
+ inputSchema: {
129
+ type: 'object',
130
+ properties: {
131
+ limit: { type: 'integer', minimum: 1, maximum: MAX_AUDIT_LIMIT },
132
+ },
133
+ },
134
+ },
135
+ ];
136
+
137
+ // =====================================================================
138
+ // Handlers
139
+ // =====================================================================
140
+
141
+ export async function handleListSources() {
142
+ return ok({ sources: SUPPORTED_SOURCES });
143
+ }
144
+
145
+ export async function handleAuditRecent(args) {
146
+ const limit = args?.limit != null ? validateInteger(args.limit, 'limit', { min: 1, max: MAX_AUDIT_LIMIT }) : 10;
147
+ const path = process.env.PAPERVAULT_AUDIT_LOG || join(homedir(), '.papervault', 'audit.log');
148
+ let raw;
149
+ try {
150
+ raw = await readFile(path, 'utf8');
151
+ } catch (err) {
152
+ if (err.code === 'ENOENT') return ok({ entries: [], note: 'audit log does not exist yet.' });
153
+ return fail(`could not read audit log: ${err.message}`);
154
+ }
155
+ const lines = raw.trim().split('\n').filter(Boolean);
156
+ const recent = lines.slice(-limit).map(l => {
157
+ try { return JSON.parse(l); }
158
+ catch { return { raw: l, parseError: true }; }
159
+ });
160
+ return ok({ entries: recent, total_entries: lines.length, returned: recent.length });
161
+ }
162
+
163
+ export async function handleDryRun(args) {
164
+ const sourceUri = validateString(args?.source_uri, 'source_uri', { maxLength: 500 });
165
+ const maxSecrets = args?.max_secrets != null
166
+ ? validateInteger(args.max_secrets, 'max_secrets', { min: 1, max: HARD_MAX_SECRETS })
167
+ : HARD_MAX_SECRETS;
168
+ const selectStr = args?.select != null ? validateString(args.select, 'select', { maxLength: 500 }) : '';
169
+
170
+ let src;
171
+ try {
172
+ src = resolveSource(sourceUri);
173
+ await src.authenticate();
174
+ } catch (err) {
175
+ await audit({ action: 'mcp.dry_run', sourceUri, outcome: 'failed', error: err.message });
176
+ return fail(`source error: ${err.message}`);
177
+ }
178
+
179
+ try {
180
+ const allRefs = await src.list();
181
+ const filter = makeGlobFilter(selectStr);
182
+ const selectedRefs = allRefs.filter(r => filter(r.name));
183
+
184
+ if (selectedRefs.length > maxSecrets) {
185
+ await src.close();
186
+ await audit({
187
+ action: 'mcp.dry_run',
188
+ sourceUri,
189
+ secretCount: selectedRefs.length,
190
+ outcome: 'refused',
191
+ reason: 'exceeds max_secrets',
192
+ });
193
+ return fail(
194
+ `selection has ${selectedRefs.length} secrets; max_secrets is ${maxSecrets}.`,
195
+ { selected: selectedRefs.length, max_secrets: maxSecrets },
196
+ );
197
+ }
198
+
199
+ const names = selectedRefs.map(r => r.name);
200
+ const fp = fingerprint(names);
201
+ await src.close();
202
+ await audit({
203
+ action: 'mcp.dry_run',
204
+ sourceUri,
205
+ secretCount: selectedRefs.length,
206
+ secretNames: names, // hashed by audit() unless PAPERVAULT_LOG_NAMES=1
207
+ outcome: 'success',
208
+ });
209
+
210
+ return ok({
211
+ source_uri: sourceUri,
212
+ secret_count: selectedRefs.length,
213
+ name_fingerprint: fp,
214
+ kinds_summary: summarizeKinds(selectedRefs),
215
+ note: 'Run papervault_backup_from_source with the same source_uri and select to commit. ' +
216
+ 'Compare name_fingerprint between runs to confirm the same set of secrets.',
217
+ });
218
+ } catch (err) {
219
+ try { await src.close(); } catch {}
220
+ await audit({ action: 'mcp.dry_run', sourceUri, outcome: 'failed', error: err.message });
221
+ return fail(`dry_run failed: ${err.message}`);
222
+ }
223
+ }
224
+
225
+ function summarizeKinds(refs) {
226
+ const counts = {};
227
+ for (const r of refs) counts[r.kind] = (counts[r.kind] ?? 0) + 1;
228
+ return counts;
229
+ }
230
+
231
+ export async function handleBackupFromSource(args) {
232
+ // --- Input validation, all aggressive ---
233
+ const sourceUri = validateString(args?.source_uri, 'source_uri', { maxLength: 500 });
234
+ const shares = validateInteger(args?.shares, 'shares', { min: 1, max: HARD_MAX_SHARES });
235
+ const threshold = validateInteger(args?.threshold, 'threshold', { min: 1, max: shares });
236
+ if (shares > 1 && threshold < 2) {
237
+ return fail('threshold must be >= 2 when shares > 1 (Shamir constraint).');
238
+ }
239
+ const savePath = validateSavePath(args?.save_path);
240
+ const maxSecrets = args?.max_secrets != null
241
+ ? validateInteger(args.max_secrets, 'max_secrets', { min: 1, max: HARD_MAX_SECRETS })
242
+ : HARD_MAX_SECRETS;
243
+ const vaultNameInput = args?.vault_name != null ? validateString(args.vault_name, 'vault_name', { maxLength: 100 }) : null;
244
+ const selectStr = args?.select != null ? validateString(args.select, 'select', { maxLength: 500 }) : '';
245
+ const names = validateNames(args?.names, shares);
246
+
247
+ let src;
248
+ try {
249
+ src = resolveSource(sourceUri);
250
+ await src.authenticate();
251
+ } catch (err) {
252
+ await audit({ action: 'mcp.backup', sourceUri, outcome: 'failed', error: err.message });
253
+ return fail(`source error: ${err.message}`);
254
+ }
255
+
256
+ try {
257
+ const allRefs = await src.list();
258
+ const filter = makeGlobFilter(selectStr);
259
+ const selectedRefs = allRefs.filter(r => filter(r.name));
260
+
261
+ if (selectedRefs.length === 0) {
262
+ await src.close();
263
+ await audit({ action: 'mcp.backup', sourceUri, secretCount: 0, outcome: 'refused', reason: 'no selection' });
264
+ return fail('no secrets matched the selection.');
265
+ }
266
+ if (selectedRefs.length > maxSecrets) {
267
+ await src.close();
268
+ await audit({
269
+ action: 'mcp.backup',
270
+ sourceUri,
271
+ secretCount: selectedRefs.length,
272
+ outcome: 'refused',
273
+ reason: 'exceeds max_secrets',
274
+ });
275
+ return fail(
276
+ `selection has ${selectedRefs.length} secrets; max_secrets is ${maxSecrets}. ` +
277
+ 'Tighten select, raise max_secrets (capped at ' + HARD_MAX_SECRETS + '), or split into ' +
278
+ 'multiple backups.',
279
+ { selected: selectedRefs.length, max_secrets: maxSecrets },
280
+ );
281
+ }
282
+
283
+ // --- Phase 2: fetch values ---
284
+ const doc = await src.fetch(selectedRefs);
285
+ const vaultName = vaultNameInput ?? doc.vaultName ?? 'Disaster Recovery Kit';
286
+
287
+ const kit = await createKit({
288
+ secrets: doc.secrets,
289
+ freeText: doc.freeText,
290
+ vaultName,
291
+ threshold,
292
+ shares,
293
+ custodianNames: names,
294
+ });
295
+
296
+ // --- Write to disk with restrictive permissions ---
297
+ const kitDir = join(savePath, `vault-${kit.vaultId}`);
298
+ await mkdir(kitDir, { recursive: true });
299
+ const filenames = [];
300
+ for (const page of kit.pages) {
301
+ // Strip the auto-print script — saved files don't auto-trigger print.
302
+ const html = page.html.replace(/<script>window\.addEventListener\('load',.*?<\/script>/g, '');
303
+ const filePath = join(kitDir, `${page.filename}.html`);
304
+ await writeFile(filePath, html, { mode: 0o600 });
305
+ try { await chmod(filePath, 0o600); } catch {} // belt + suspenders for restrictive umask
306
+ filenames.push(`${page.filename}.html`);
307
+ }
308
+
309
+ const nameFingerprint = fingerprint(selectedRefs.map(r => r.name));
310
+ await audit({
311
+ action: 'mcp.backup',
312
+ sourceUri,
313
+ secretCount: selectedRefs.length,
314
+ secretNames: selectedRefs.map(r => r.name),
315
+ threshold,
316
+ shares,
317
+ vaultId: kit.vaultId,
318
+ savedTo: kitDir,
319
+ outcome: 'success',
320
+ });
321
+
322
+ await src.close();
323
+
324
+ return ok({
325
+ vault_id: kit.vaultId,
326
+ vault_name: vaultName,
327
+ save_dir: kitDir,
328
+ files: filenames,
329
+ secret_count: selectedRefs.length,
330
+ name_fingerprint: nameFingerprint,
331
+ shares,
332
+ threshold,
333
+ key_aliases: kit.keyAliases,
334
+ note: 'Open the HTML files in a browser and print, or hand them to a human to print and ' +
335
+ 'distribute. Each file has mode 0600 (user-only readable).',
336
+ });
337
+ } catch (err) {
338
+ try { await src.close(); } catch {}
339
+ await audit({ action: 'mcp.backup', sourceUri, outcome: 'failed', error: err.message });
340
+ return fail(`backup failed: ${err.message}`);
341
+ }
342
+ }