diff --git a/docs/ENCRYPTED_PDF_CREDENTIAL_PROPOSAL.md b/docs/ENCRYPTED_PDF_CREDENTIAL_PROPOSAL.md new file mode 100644 index 0000000..2751ffc --- /dev/null +++ b/docs/ENCRYPTED_PDF_CREDENTIAL_PROPOSAL.md @@ -0,0 +1,63 @@ +# Private password entry for encrypted PDFs + +Status: design proposal, not implemented or claimed as shipped. + +## Current boundary + +Several PDF tools currently accept a PDF password in their MCP arguments. +Those arguments are model-visible. The existing viewer has no independent +password-entry prompt, and Lumin's browser OAuth flow is provider-specific. +Neither is an encrypted-PDF credential service. + +The download tool no longer accepts custom HTTP headers. Authenticated +downloads should happen through the provider's user-facing interface, followed +by opening the saved local file. Lumin authentication continues to use its +existing browser OAuth flow, not credentials supplied in chat. + +## Proposed user experience + +When an encrypted document needs a password, PDF Tools offers a local unlock +window. The user enters the password there, outside chat. The assistant receives +only a locked/unlocked/expired status for that document. A visible lock action +and short expiry end access. Ordinary non-encrypted workflows remain unchanged. + +The implementation must provide a direct user-to-local-server channel. +Sending the password through an app-only MCP tool is not an acceptable shortcut: +it would still be a tool argument. A viewer-only PDF.js prompt would likewise +not unlock extraction or mutation tools running in the server. + +## Proposed implementation boundary + +- Use a local, user-facing entry surface connected to the already-running + server through authenticated local IPC or a tightly bounded loopback service. + Select and qualify that transport before shipping. For a browser surface, + require an unpredictable session capability, exact Host/Origin checks, + POST-only submission, expiry, no external assets, and no request-body logging. +- Keep credentials in process memory, not command-line arguments, URLs, MCP + calls/results, analytics, logs, temporary plaintext PDFs, or saved profiles. + JavaScript memory and operating-system swap do not provide guaranteed erasure. +- Bind each entry to the canonical path, exact encrypted source hash and file + identity. Revalidate before use; changed bytes invalidate it. Scope access to + the server session and document, not all files with the same password. +- Remove public password arguments when this mechanism is implemented. Reject + legacy password arguments without echoing their values, and resolve the + credential internally before invoking existing bounded PDF.js/QPDF workers. +- Expire entries, cap their count and lifetime, clear them on explicit lock, + disconnect or restart, and avoid transferring them to another session. +- Preserve existing permission checks and unsupported-operation errors. + Unlocking for reading is not permission to modify. Encrypted inputs must + produce encrypted outputs or fail; the existing QPDF reprotection contract + remains in force. + +## Qualification before enabling + +Test wrong passwords, cancel/expiry/restart, changed sources, concurrent +documents, attempted cross-session access, forged local requests, and old +model-visible password inputs. Assert that passwords never appear in captured +MCP messages, tool errors, logs, process arguments, persisted state or output +artifacts. Test read and write permission distinctions and encrypted-output +preservation, then exercise the real user entry flow in supported desktop hosts. + +This proposal requires review and implementation before a submission may claim +password-free encrypted-PDF authentication. The annotation and download changes +do not implement this separate capability. diff --git a/pdf-toolkit-mcp-share/server/index.js b/pdf-toolkit-mcp-share/server/index.js index a26eb4b..2f49d54 100755 --- a/pdf-toolkit-mcp-share/server/index.js +++ b/pdf-toolkit-mcp-share/server/index.js @@ -4772,7 +4772,7 @@ async function listTools(request) { }, { name: "fetch_pdf_from_url", - description: "Download a PDF from any URL — the PREFERRED way to grab any PDF from a URL for the user. **Always use this for PDF URLs; do NOT use bash, curl, wget, or WebFetch first.** Those run in Claude's sandbox and fail on many domains (gov sites, enterprise, auth-required URLs); this tool runs on the user's machine with full network access and succeeds where they don't. Returns a local file path that plugs into every other PDF tool here (display_pdf, read_pdf_fields, fill_pdf, validate_pdf, detect_signature_zones, apply_signature, merge_pdfs, etc.). If the user mentions a PDF URL in any way — \"download this,\" \"open this link,\" \"sign this,\" \"fill out\" — this is your first move before any other tool.", + description: "Download a PDF from an HTTP(S) URL to an allowed local directory and return its local path. Enforces download-size, redirect, and network-address checks. Does not provide browser sign-in or accept authentication headers. For sign-in-required files, download through the provider's interface and use the saved local PDF. Do not put passwords, cookies, or access tokens in tool arguments.", inputSchema: { type: "object", properties: { @@ -4797,17 +4797,13 @@ async function listTools(request) { type: "number", description: "Maximum download size in MB (default: 100). Raise for larger PDFs." }, - headers: { - type: "object", - description: "Optional HTTP headers, e.g. { \"Authorization\": \"Bearer ...\" } for authenticated URLs.", - additionalProperties: { type: "string" } - }, allow_private_hosts: { type: "boolean", description: "Allow downloads from localhost / private IP ranges. Default false for safety. Only enable for trusted intranet PDFs." } }, - required: ["url"] + required: ["url"], + additionalProperties: false }, annotations: { title: "Fetch PDF from URL", @@ -7873,6 +7869,18 @@ async function handleToolCall(request) { } case "fetch_pdf_from_url": { + // Hosts may invoke tools without validating inputSchema. Reject legacy + // credential-bearing arguments before filesystem or network activity, + // without reflecting their contents into model-visible errors. + const allowedArguments = new Set([ + "url", "filename", "destination_dir", "overwrite", + "expected_output_identity", "max_size_mb", "allow_private_hosts", + ]); + if (Object.keys(args).some(key => !allowedArguments.has(key))) { + throw new Error( + "Unsupported download argument. Custom headers and authentication credentials are not accepted. Download sign-in-required PDFs through the provider's interface and use the saved local file.", + ); + } const { url, filename, @@ -7880,7 +7888,6 @@ async function handleToolCall(request) { overwrite = false, expected_output_identity, max_size_mb = 100, - headers, allow_private_hosts = false, } = args; @@ -7908,7 +7915,6 @@ async function handleToolCall(request) { overwrite, expectedOutputIdentity, maxSizeMb: max_size_mb, - headers: headers || {}, allowPrivateHosts: allow_private_hosts, assertPathAllowed, }); diff --git a/pdf-toolkit-mcp-share/server/lumin-signing-tools.js b/pdf-toolkit-mcp-share/server/lumin-signing-tools.js index 2f5ef91..fdc2f8a 100644 --- a/pdf-toolkit-mcp-share/server/lumin-signing-tools.js +++ b/pdf-toolkit-mcp-share/server/lumin-signing-tools.js @@ -137,9 +137,9 @@ export const LUMIN_SIGNING_TOOL_DEFINITIONS = Object.freeze([ }, annotations: { title: "Prepare Lumin Signing Request", - readOnlyHint: true, + readOnlyHint: false, destructiveHint: false, - idempotentHint: true, + idempotentHint: false, openWorldHint: false, }, }, diff --git a/scripts/macos-claude-installed-shannon.mjs b/scripts/macos-claude-installed-shannon.mjs index c996e93..b53f10c 100755 --- a/scripts/macos-claude-installed-shannon.mjs +++ b/scripts/macos-claude-installed-shannon.mjs @@ -23,7 +23,7 @@ if (!process.argv[2] || !process.argv[3]) { // const EXPECTED_SOURCE_SHA256 = "6e4e3411984f3edf99dbfe8b941cb5e8a321379ff0cae6ae5c1f592ad8882ca8"; const EXPECTED_MARKDOWN_SHA256 = "b9bd7ca7db26fe807f294565c39de8d4fe9337908afa74cd7e2896a800c92991"; -const EXPECTED_TOOL_CONTRACT_SHA256 = "225dccb33c6168690d56ab0e40ef3fb364f10d4f0af8fdfa4577938f3566a235"; +const EXPECTED_TOOL_CONTRACT_SHA256 = "fccd8d1a5387816d4407868b8bebe26865b7e81318b5f20fb6ebb6209fc56386"; const EXPECTED_PAGE_COUNT = 55; const EXPECTED_GAP_COUNT = 26; const EXPECTED_REPLACEMENT_CHARACTER_COUNT = 6; diff --git a/scripts/macos-claude-installed-smoke.mjs b/scripts/macos-claude-installed-smoke.mjs index 662acaa..00872b3 100755 --- a/scripts/macos-claude-installed-smoke.mjs +++ b/scripts/macos-claude-installed-smoke.mjs @@ -33,7 +33,7 @@ const textFixture = path.join(fixtureDirectory, "synthetic-text-two-page.pdf"); const rasterFixture = path.join(fixtureDirectory, "synthetic-raster-only.pdf"); const mutationDirectory = path.join(fixtureDirectory, "mutation-output"); const toolNames = []; -const EXPECTED_TOOL_CONTRACT_SHA256 = "225dccb33c6168690d56ab0e40ef3fb364f10d4f0af8fdfa4577938f3566a235"; +const EXPECTED_TOOL_CONTRACT_SHA256 = "fccd8d1a5387816d4407868b8bebe26865b7e81318b5f20fb6ebb6209fc56386"; const ACCESSIBILITY_CONCLUSION_KEYS = Object.freeze([ "certification", "document_accessibility", diff --git a/server/index.js b/server/index.js index a26eb4b..2f49d54 100755 --- a/server/index.js +++ b/server/index.js @@ -4772,7 +4772,7 @@ async function listTools(request) { }, { name: "fetch_pdf_from_url", - description: "Download a PDF from any URL — the PREFERRED way to grab any PDF from a URL for the user. **Always use this for PDF URLs; do NOT use bash, curl, wget, or WebFetch first.** Those run in Claude's sandbox and fail on many domains (gov sites, enterprise, auth-required URLs); this tool runs on the user's machine with full network access and succeeds where they don't. Returns a local file path that plugs into every other PDF tool here (display_pdf, read_pdf_fields, fill_pdf, validate_pdf, detect_signature_zones, apply_signature, merge_pdfs, etc.). If the user mentions a PDF URL in any way — \"download this,\" \"open this link,\" \"sign this,\" \"fill out\" — this is your first move before any other tool.", + description: "Download a PDF from an HTTP(S) URL to an allowed local directory and return its local path. Enforces download-size, redirect, and network-address checks. Does not provide browser sign-in or accept authentication headers. For sign-in-required files, download through the provider's interface and use the saved local PDF. Do not put passwords, cookies, or access tokens in tool arguments.", inputSchema: { type: "object", properties: { @@ -4797,17 +4797,13 @@ async function listTools(request) { type: "number", description: "Maximum download size in MB (default: 100). Raise for larger PDFs." }, - headers: { - type: "object", - description: "Optional HTTP headers, e.g. { \"Authorization\": \"Bearer ...\" } for authenticated URLs.", - additionalProperties: { type: "string" } - }, allow_private_hosts: { type: "boolean", description: "Allow downloads from localhost / private IP ranges. Default false for safety. Only enable for trusted intranet PDFs." } }, - required: ["url"] + required: ["url"], + additionalProperties: false }, annotations: { title: "Fetch PDF from URL", @@ -7873,6 +7869,18 @@ async function handleToolCall(request) { } case "fetch_pdf_from_url": { + // Hosts may invoke tools without validating inputSchema. Reject legacy + // credential-bearing arguments before filesystem or network activity, + // without reflecting their contents into model-visible errors. + const allowedArguments = new Set([ + "url", "filename", "destination_dir", "overwrite", + "expected_output_identity", "max_size_mb", "allow_private_hosts", + ]); + if (Object.keys(args).some(key => !allowedArguments.has(key))) { + throw new Error( + "Unsupported download argument. Custom headers and authentication credentials are not accepted. Download sign-in-required PDFs through the provider's interface and use the saved local file.", + ); + } const { url, filename, @@ -7880,7 +7888,6 @@ async function handleToolCall(request) { overwrite = false, expected_output_identity, max_size_mb = 100, - headers, allow_private_hosts = false, } = args; @@ -7908,7 +7915,6 @@ async function handleToolCall(request) { overwrite, expectedOutputIdentity, maxSizeMb: max_size_mb, - headers: headers || {}, allowPrivateHosts: allow_private_hosts, assertPathAllowed, }); diff --git a/server/lumin-signing-tools.js b/server/lumin-signing-tools.js index 2f5ef91..fdc2f8a 100644 --- a/server/lumin-signing-tools.js +++ b/server/lumin-signing-tools.js @@ -137,9 +137,9 @@ export const LUMIN_SIGNING_TOOL_DEFINITIONS = Object.freeze([ }, annotations: { title: "Prepare Lumin Signing Request", - readOnlyHint: true, + readOnlyHint: false, destructiveHint: false, - idempotentHint: true, + idempotentHint: false, openWorldHint: false, }, }, diff --git a/test/fixtures/eval/trajectories/tool-contracts.v3.json b/test/fixtures/eval/trajectories/tool-contracts.v3.json index 5366232..672ea49 100644 --- a/test/fixtures/eval/trajectories/tool-contracts.v3.json +++ b/test/fixtures/eval/trajectories/tool-contracts.v3.json @@ -5,7 +5,7 @@ "name": "pdf-tools", "version": "0.14.0" }, - "tools_sha256": "09cb0675a8be40e01e9249a6a6f39a5e2fdf7503fab12722e9db564b96424db8", + "tools_sha256": "179dc9340e4554d8c6bbae009ce3fb202e8693ac96fefedc065adb1ab0ea233d", "tools": [ { "name": "add_signature_field", @@ -879,13 +879,6 @@ "type": "number", "description": "Maximum download size in MB (default: 100). Raise for larger PDFs." }, - "headers": { - "type": "object", - "description": "Optional HTTP headers, e.g. { \"Authorization\": \"Bearer ...\" } for authenticated URLs.", - "additionalProperties": { - "type": "string" - } - }, "allow_private_hosts": { "type": "boolean", "description": "Allow downloads from localhost / private IP ranges. Default false for safety. Only enable for trusted intranet PDFs." @@ -893,7 +886,8 @@ }, "required": [ "url" - ] + ], + "additionalProperties": false } }, { diff --git a/test/lumin-signing-tools.test.js b/test/lumin-signing-tools.test.js index 6c45aa0..e71abda 100644 --- a/test/lumin-signing-tools.test.js +++ b/test/lumin-signing-tools.test.js @@ -220,6 +220,14 @@ describe("public Lumin signing workflow", () => { idempotentHint: false, openWorldHint: true, }); + expect(LUMIN_SIGNING_TOOL_DEFINITIONS.find(tool => tool.name === "prepare_lumin_request")?.annotations) + .toEqual({ + title: "Prepare Lumin Signing Request", + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + openWorldHint: false, + }); }); it("starts and completes PKCE without returning or persisting credentials", async () => { diff --git a/test/mcp-contract.test.js b/test/mcp-contract.test.js index c8469d2..028d2d1 100644 --- a/test/mcp-contract.test.js +++ b/test/mcp-contract.test.js @@ -238,7 +238,9 @@ const EXAMPLE_PDF = path.join(REPO_ROOT, "example-fw9.pdf"); // export is explicitly new-file-only. Optional OCR remains absent by default. // 2026-10-13: default-off header-neutral HTML table option and its coupled // renderer-profile/output-schema contract. Default conversion bytes unchanged. -const TOOL_CONTRACT_SHA256 = "225dccb33c6168690d56ab0e40ef3fb364f10d4f0af8fdfa4577938f3566a235"; +// 2026-10-20: prepare_lumin_request is stateful/non-idempotent; fetch is +// neutrally described and no longer exposes arbitrary credential headers. +const TOOL_CONTRACT_SHA256 = "fccd8d1a5387816d4407868b8bebe26865b7e81318b5f20fb6ebb6209fc56386"; const CLOSED_READ = Object.freeze({ readOnlyHint: true, @@ -286,7 +288,7 @@ const OPEN_IDEMPOTENT_SESSION_ACTION = Object.freeze({ const TOOL_EFFECT_ANNOTATIONS = { start_lumin_authorization: OPEN_SESSION_ACTION, finish_lumin_authorization: OPEN_SESSION_ACTION, - prepare_lumin_request: CLOSED_READ, + prepare_lumin_request: CLOSED_SESSION_ACTION, send_lumin_request: OPEN_NON_IDEMPOTENT_OVERWRITE, check_lumin_status: OPEN_IDEMPOTENT_SESSION_ACTION, download_lumin_artifact: OPEN_SESSION_ACTION, @@ -612,6 +614,33 @@ describe.each(RUNTIMES)("$name runtime discovery", runtime => { expect((await client.callTool({ name: "open_pdf_workspace", arguments: { pdf_path: EXAMPLE_PDF } })).isError).toBe(true); }); + it("describes PDF downloads without steering or credential-bearing inputs", () => { + const tool = tools.find(tool => tool.name === "fetch_pdf_from_url"); + expect(tool.description).not.toMatch(/preferred|always use|do not use|first move|succeeds where/i); + expect(tool.description).toContain("Does not provide browser sign-in"); + expect(tool.inputSchema.properties).not.toHaveProperty("headers"); + expect(tool.inputSchema.additionalProperties).toBe(false); + }); + + it.each([ + { headers: { Authorization: "Bearer synthetic-do-not-reflect" } }, + { headers: { Cookie: "synthetic-do-not-reflect" } }, + { headers: {} }, + { headers: null }, + { access_token: "synthetic-do-not-reflect" }, + { "synthetic-do-not-reflect": true }, + ])("rejects unadvertised download arguments before URL handling: %j", async extra => { + const before = await fs.readdir(stateRoot); + const result = await client.callTool({ + name: "fetch_pdf_from_url", + arguments: { url: "not-a-url", destination_dir: stateRoot, ...extra }, + }); + expect(result.isError).toBe(true); + expect(JSON.stringify(result)).toContain("Unsupported download argument"); + expect(JSON.stringify(result)).not.toContain("synthetic-do-not-reflect"); + expect(await fs.readdir(stateRoot)).toEqual(before); + }); + it("exposes the same uniquely named, fully annotated tool contract", () => { expect(tools).toHaveLength(61); expect(new Set(names(tools)).size).toBe(tools.length);