diff --git a/docs/tool-reference.md b/docs/tool-reference.md index 5db918bee..4a055207a 100644 --- a/docs/tool-reference.md +++ b/docs/tool-reference.md @@ -108,7 +108,7 @@ - **pageId** (number) **(required)**: Targets a specific page by ID. - **uid** (string) **(required)**: The uid of an element on the page from the page content snapshot -- **value** (string) **(required)**: The value to [`fill`](#fill) in. "true" or "false" for checkboxes and toggles, "true" for radio buttons. +- **value** (string) **(required)**: The value to [`fill`](#fill) in. "true" or "false" for checkboxes and toggles, "true" for radio buttons. May contain `{{secret:NAME}}`, which is replaced with the contents of ~/.local/share/chrome-devtools-mcp/secrets/NAME by this MCP server just before the input is sent to the browser, so the secret never has to be passed to this tool. Stage such files by reference (e.g. `pass show x > ~/.local/share/chrome-devtools-mcp/secrets/x`), never by writing the literal value. The secret file is DELETED once the call succeeds; append `:keep` (`{{secret:NAME:keep}}`) to keep it for later calls. Append `:raw` (`{{secret:NAME:raw}}`, `{{secret:NAME:raw:keep}}`) to keep a trailing newline. The directory can be relocated with the CHROME_DEVTOOLS_MCP_SECRETS_DIR environment variable. ALWAYS pick a unique, specific NAME (e.g. "github-login-7f3a" rather than "pw"): ~/.local/share/chrome-devtools-mcp/secrets is shared by all MCP servers running in parallel, so a generic name can be overwritten by another session and make you [`fill`](#fill) the wrong value, or be deleted while you still need it. - **includeSnapshot** (boolean) _(optional)_: Whether to include a snapshot in the response. Default is false. --- @@ -168,7 +168,7 @@ **Parameters:** - **pageId** (number) **(required)**: Targets a specific page by ID. -- **text** (string) **(required)**: The text to type +- **text** (string) **(required)**: The text to type. May contain `{{secret:NAME}}`, which is replaced with the contents of ~/.local/share/chrome-devtools-mcp/secrets/NAME by this MCP server just before the input is sent to the browser, so the secret never has to be passed to this tool. Stage such files by reference (e.g. `pass show x > ~/.local/share/chrome-devtools-mcp/secrets/x`), never by writing the literal value. The secret file is DELETED once the call succeeds; append `:keep` (`{{secret:NAME:keep}}`) to keep it for later calls. Append `:raw` (`{{secret:NAME:raw}}`, `{{secret:NAME:raw:keep}}`) to keep a trailing newline. The directory can be relocated with the CHROME_DEVTOOLS_MCP_SECRETS_DIR environment variable. ALWAYS pick a unique, specific NAME (e.g. "github-login-7f3a" rather than "pw"): ~/.local/share/chrome-devtools-mcp/secrets is shared by all MCP servers running in parallel, so a generic name can be overwritten by another session and make you [`fill`](#fill) the wrong value, or be deleted while you still need it. - **submitKey** (string) _(optional)_: Optional key to press after typing. E.g., "Enter", "Tab", "Escape" --- @@ -379,8 +379,10 @@ **Parameters:** - **function** (string) **(required)**: A JavaScript function declaration to be executed by the tool in the target page. - Example without arguments: `() => document.title` or `async () => await fetch("example.com")`. - Example with arguments: `(el) => el.innerText` +Example without arguments: `() => document.title` or `async () => await fetch("example.com")`. +Example with arguments: `(el) => el.innerText` +May contain `{{secret:NAME}}`, which is replaced with the contents of ~/.local/share/chrome-devtools-mcp/secrets/NAME by this MCP server just before the input is sent to the browser, so the secret never has to be passed to this tool. Stage such files by reference (e.g. `pass show x > ~/.local/share/chrome-devtools-mcp/secrets/x`), never by writing the literal value. The secret file is DELETED once the call succeeds; append `:keep` (`{{secret:NAME:keep}}`) to keep it for later calls. Append `:raw` (`{{secret:NAME:raw}}`, `{{secret:NAME:raw:keep}}`) to keep a trailing newline. The directory can be relocated with the CHROME_DEVTOOLS_MCP_SECRETS_DIR environment variable. ALWAYS pick a unique, specific NAME (e.g. "github-login-7f3a" rather than "pw"): ~/.local/share/chrome-devtools-mcp/secrets is shared by all MCP servers running in parallel, so a generic name can be overwritten by another session and make you [`fill`](#fill) the wrong value, or be deleted while you still need it. +To run the same code repeatedly without writing it out in every call, store it in ~/.local/share/chrome-devtools-mcp/scripts/NAME and pass `{{script:NAME}}`, which this MCP server replaces with that file's contents exactly as stored. A script takes no modifiers: it is never deleted, and it is never trimmed. - **pageId** (number) **(required)**: Targets a specific page by ID. - **args** (array) _(optional)_: An optional list of arguments to pass to the function. diff --git a/skills/secret-handling/SKILL.md b/skills/secret-handling/SKILL.md new file mode 100644 index 000000000..55241fc20 --- /dev/null +++ b/skills/secret-handling/SKILL.md @@ -0,0 +1,134 @@ +--- +name: secret-handling +description: Uses Chrome DevTools MCP to fill passwords, API keys, tokens and other credentials into a page without the plaintext ever appearing in the conversation, and to reuse the same script across calls. Use when logging into a site, filling a password or 2FA field, automating an authenticated flow, or running the same JavaScript repeatedly. +--- + +## Core Concepts + +### Why placeholders exist + +Everything you pass to a tool is recorded in the conversation transcript. Typing a password directly into `fill`, `fill_form`, `type_text` or `evaluate_script` therefore writes that password into a durable log that anyone reading the transcript later can see. + +Instead, name the credential and let the MCP server substitute it: + +```json +{"uid": "5_3", "value": "{{secret:acme-login-7f3a}}"} +``` + +The server reads the file, splices the value in, and dispatches it to the browser. You never see the value, so it cannot leak through you. + +> [!IMPORTANT] +> The rule is about **where the plaintext exists**, not about who types it. Staging a secret with `echo "hunter2" > .../secrets/x` puts the password in a tool call and defeats the entire mechanism. Always stage **by reference** — see below. + +### Placeholder reference + +The exact directories are named in the `value` / `text` / `function` parameter descriptions of the tools; read them rather than guessing a path. + +| Placeholder | Trailing newline | File after the call | +| :---------------------- | :--------------- | :------------------ | +| `{{secret:X}}` | stripped | **deleted** | +| `{{secret:X:raw}}` | kept | **deleted** | +| `{{secret:X:keep}}` | stripped | kept | +| `{{secret:X:raw:keep}}` | kept | kept | +| `{{script:X}}` | kept (always) | kept (always) | + +- Secrets are **consumed by default** so they do not linger on disk. Use `:keep` for a credential you need in several calls (e.g. a password plus a confirmation field in separate steps). +- Deletion happens only **after the call succeeds**, so a failed fill leaves the secret staged and you can retry without re-staging. +- `{{script:X}}` takes **no modifiers** — passing `:raw` or `:keep` is an error. A script is never deleted and never trimmed. + +### Staging a secret by reference + +Write the file from an existing source so the plaintext never passes through a tool argument: + +```bash +pass show acme/login > /acme-login-7f3a # password manager +cp ~/.config/acme/token /acme-token-9c21 # existing file +printf '%s' "$ACME_PASSWORD" > /acme-login-7f3a # environment variable +security find-generic-password -s acme -w > /acme-login-7f3a # macOS keychain +``` + +Never `echo ""`. If the credential only exists in something the user typed into the chat, it has already been recorded and this mechanism cannot retroactively protect it — say so rather than pretending otherwise. + +### Choose unique names + +The secrets directory is shared by every MCP server running in parallel. Use a specific name (`acme-login-7f3a`), never a generic one (`pw`): another session can overwrite a generic name and make you fill the wrong credential into the wrong site, or delete it while you still need it. + +--- + +## Workflow Patterns + +### 1. Logging into a site + +1. **Stage the credential** by reference (see above), choosing a unique name. +2. **Locate the fields**: `take_snapshot` to get the `uid`s of the username and password inputs. +3. **Fill both in one call** with `fill_form`, using the placeholder for the password: + ```json + { + "elements": [ + {"uid": "5_3", "value": "user@example.com"}, + {"uid": "5_4", "value": "{{secret:acme-login-7f3a}}"} + ] + } + ``` +4. **Submit**: `click` the submit button, or pass `submitKey: "Enter"` to `type_text`. +5. **Verify**: `take_snapshot` or `list_network_requests` to confirm the login succeeded. + +The secret file is gone after step 3 succeeded. If the login failed and you need to retry, the file is still there only if the _fill_ failed — a fill that succeeded against a wrong-password page still consumes it, so stage with `:keep` when you expect to retry. + +### 2. Two-factor codes + +A TOTP code is short-lived, so generate it straight into the secrets directory and let it be consumed: + +```bash +oathtool --totp -b "$(pass show acme/totp-seed)" > /acme-otp-4b8e +``` + +Then `fill` with `{{secret:acme-otp-4b8e}}`. Consume-by-default is exactly right here: the code is useless after one use and should not remain on disk. + +### 3. Reusing a script across calls + +To run the same JavaScript repeatedly without writing it out in every call, store the function declaration once and reference it: + +```bash +cat > /collect-metrics.js <<'EOF' +() => ({ + title: document.title, + forms: document.forms.length, + errors: [...document.querySelectorAll('.error')].map(e => e.textContent), +}) +EOF +``` + +Then call `evaluate_script` with `{"function": "{{script:collect-metrics.js}}"}` as often as you like. The code is still sent to the browser each time; you are simply spared repeating it. + +### 4. A script that needs a credential + +Resolution runs in **two passes — scripts first, then secrets** — so a stored script may contain a secret placeholder and both are resolved in one call: + +```js +// /login.js +async () => { + document.querySelector('#user').value = 'user@example.com'; + document.querySelector('#pass').value = '{{secret:acme-login-7f3a}}'; + document.querySelector('form').submit(); +}; +``` + +Called as `{"function": "{{script:login.js}}"}`, the script is inserted and then its secret is resolved. + +Substituted content is never rescanned, which means a script **cannot** reference another script, and a secret whose value happens to look like a placeholder is used literally. + +--- + +## Troubleshooting + +- **`No secret named "x" found at ...`**: The file was never staged, or a previous call consumed it. Re-stage it, and use `:keep` if several calls need it. +- **`Invalid secret name "..."`**: Names are plain file names. Paths, `..` and `/` are rejected so a reference cannot escape the directory. +- **`{{script:x}} takes no modifiers`**: Scripts are never consumed and never trimmed, so `:raw` and `:keep` are meaningless there. Drop the modifier. +- **`Unknown modifier ":..."`**: Only `:raw` and `:keep` exist, in any order. +- **The placeholder was typed into the page literally**: The parameter you used does not resolve placeholders. Only `fill`, `fill_form`, `type_text` and `evaluate_script`'s `function` do. +- **The wrong value was filled**: Another session probably reused the same generic name. Re-stage under a unique name. +- **You need to confirm what was filled**: Error messages and `type_text`'s confirmation deliberately echo the _placeholder_, never the substituted value. Verify the effect (a successful login, a snapshot) instead of trying to read the value back. + +> [!WARNING] +> The substituted value is still sent to the site in the login request. After submitting credentials, avoid calling `get_network_request` on that request, or saving it with `requestFilePath`, unless you actually need it — the request body contains the plaintext and would put it back into the transcript. diff --git a/src/telemetry/persistence.ts b/src/telemetry/persistence.ts index 1ff787e79..56cca9fb6 100644 --- a/src/telemetry/persistence.ts +++ b/src/telemetry/persistence.ts @@ -5,11 +5,10 @@ */ import fs from 'node:fs/promises'; -import os from 'node:os'; import path from 'node:path'; -import process from 'node:process'; import {logger} from '../utils/logger.js'; +import {getDataFolder} from '../utils/paths.js'; import {ClearcutLogger} from './ClearcutLogger.js'; import {ErrorCode} from './errors.js'; @@ -34,26 +33,6 @@ function isContextValid(state: LocalState): boolean { } const STATE_FILE_NAME = 'telemetry_state.json'; -function getDataFolder(): string { - const homedir = os.homedir(); - const {env} = process; - const name = 'chrome-devtools-mcp'; - - if (process.platform === 'darwin') { - return path.join(homedir, 'Library', 'Application Support', name); - } - - if (process.platform === 'win32') { - const localAppData = - env.LOCALAPPDATA || path.join(homedir, 'AppData', 'Local'); - return path.join(localAppData, name, 'Data'); - } - - return path.join( - env.XDG_DATA_HOME || path.join(homedir, '.local', 'share'), - name, - ); -} export interface Persistence { loadState(): Promise; diff --git a/src/tools/input.ts b/src/tools/input.ts index ac3119be4..ba9e6777c 100644 --- a/src/tools/input.ts +++ b/src/tools/input.ts @@ -10,6 +10,11 @@ import type {ElementHandle, KeyInput} from '../third_party/index.js'; import type {TextSnapshotNode} from '../types.js'; import {parseKey} from '../utils/keyboard.js'; import {logger} from '../utils/logger.js'; +import { + deleteSecrets, + placeholderHint, + resolvePlaceholders, +} from '../utils/secrets.js'; import type {WaitForEventsResult} from '../utils/WaitForHelper.js'; import {ToolCategory} from './categories.js'; @@ -211,6 +216,7 @@ async function selectOption( handle: ElementHandle, aXNode: TextSnapshotNode, value: string, + displayValue: string, ) { let optionFound = false; for (const child of aXNode.children) { @@ -230,7 +236,7 @@ async function selectOption( } } if (!optionFound) { - throw new Error(`Could not find option with text "${value}"`); + throw new Error(`Could not find option with text "${displayValue}"`); } } @@ -243,6 +249,9 @@ async function fillFormElement( value: string, context: McpContext, page: ContextPage, + // Used in error messages in place of `value`, so that a value resolved from + // a secret is never echoed back to the caller. + displayValue: string = value, ) { using handle = await page.getElementByUid(uid); try { @@ -250,7 +259,7 @@ async function fillFormElement( // We assume that combobox needs to be handled as select if it has // role='combobox' and option children. if (aXNode && aXNode.role === 'combobox' && hasOptionChildren(aXNode)) { - await selectOption(handle, aXNode, value); + await selectOption(handle, aXNode, value, displayValue); } else { const isToggle = await handle.evaluate(el => { if (el instanceof HTMLInputElement) { @@ -265,7 +274,7 @@ async function fillFormElement( await handle.asLocator().fill(value === 'true'); } else { throw new Error( - `Checkboxes, radio boxes and toggles require "true" or "false" value, but ${value} was used`, + `Checkboxes, radio boxes and toggles require "true" or "false" value, but ${displayValue} was used`, ); } } else { @@ -297,7 +306,7 @@ export const fill = definePageTool({ value: zod .string() .describe( - 'The value to fill in. "true" or "false" for checkboxes and toggles, "true" for radio buttons.', + `The value to fill in. "true" or "false" for checkboxes and toggles, "true" for radio buttons. ${placeholderHint}`, ), includeSnapshot: includeSnapshotSchema, }, @@ -305,14 +314,17 @@ export const fill = definePageTool({ verifyFilesSchema: {}, handler: async (request, response, context) => { const page = request.page; + const secret = await resolvePlaceholders(request.params.value); const result = await page.waitForEventsAfterAction(async () => { await fillFormElement( request.params.uid, - request.params.value, + secret.value, context as McpContext, page, + request.params.value, ); }); + await deleteSecrets(secret.consume); response.appendResponseLine(`Successfully filled out the element`); response.attachWaitForResult(result); if (request.params.includeSnapshot) { @@ -329,21 +341,24 @@ export const typeText = definePageTool({ readOnlyHint: false, }, schema: { - text: zod.string().describe('The text to type'), + text: zod.string().describe(`The text to type. ${placeholderHint}`), submitKey: submitKeySchema, }, blockedByDialog: true, verifyFilesSchema: {}, handler: async (request, response) => { const page = request.page; + const secret = await resolvePlaceholders(request.params.text); const result = await page.waitForEventsAfterAction(async () => { - await page.pptrPage.keyboard.type(request.params.text); + await page.pptrPage.keyboard.type(secret.value); if (request.params.submitKey) { await page.pptrPage.keyboard.press( request.params.submitKey as KeyInput, ); } }); + await deleteSecrets(secret.consume); + // Echoes the unresolved text so a resolved secret is never reported back. response.appendResponseLine( `Typed text "${request.params.text}${request.params.submitKey ? ` + ${request.params.submitKey}` : ''}"`, ); @@ -400,7 +415,7 @@ export const fillForm = definePageTool({ value: zod .string() .describe( - 'Value for the element. "true" or "false" for checkboxes and toggles, "true" for radio buttons.', + `Value for the element. "true" or "false" for checkboxes and toggles, "true" for radio buttons. ${placeholderHint}`, ), }), ) @@ -412,16 +427,23 @@ export const fillForm = definePageTool({ handler: async (request, response, context) => { const page = request.page; let lastResult: WaitForEventsResult = {}; + const usedSecrets = new Set(); for (const element of request.params.elements) { + const secret = await resolvePlaceholders(element.value); + for (const name of secret.consume) { + usedSecrets.add(name); + } lastResult = await page.waitForEventsAfterAction(async () => { await fillFormElement( element.uid, - element.value, + secret.value, context as McpContext, page, + element.value, ); }); } + await deleteSecrets([...usedSecrets]); response.appendResponseLine(`Successfully filled out the form`); response.attachWaitForResult(lastResult); if (request.params.includeSnapshot) { diff --git a/src/tools/script.ts b/src/tools/script.ts index d06311267..25e7fb9fb 100644 --- a/src/tools/script.ts +++ b/src/tools/script.ts @@ -7,6 +7,12 @@ import {zod} from '../third_party/index.js'; import type {Frame, JSHandle, Page, WebWorker} from '../third_party/index.js'; import type {ExtensionServiceWorker} from '../types.js'; +import { + deleteSecrets, + placeholderHint, + resolvePlaceholders, + scriptPlaceholderHint, +} from '../utils/secrets.js'; import {ToolCategory} from './categories.js'; import type {Context, Response} from './ToolDefinition.js'; @@ -40,6 +46,8 @@ export const evaluateScript = defineTool(cliArgs => { `A JavaScript function declaration to be executed by the tool in the target page. Example without arguments: \`() => document.title\` or \`async () => await fetch("example.com")\`. Example with arguments: \`(el) => el.innerText\` +${placeholderHint} +${scriptPlaceholderHint} `, ), args: zod @@ -89,13 +97,18 @@ Example with arguments: \`(el) => el.innerText\` const { serviceWorkerId, args: uidArgs, - function: fnString, + function: inlineFunction, pageId, dialogAction, filePath, waitForStableDom, } = request.params; + // Secrets are resolved here, on the server, so that the plaintext never + // has to be passed to this tool. + const secret = await resolvePlaceholders(inlineFunction); + const fnString = secret.value; + if (cliArgs?.categoryExtensions && serviceWorkerId) { if (uidArgs && uidArgs.length > 0) { throw new Error( @@ -119,6 +132,7 @@ Example with arguments: \`(el) => el.innerText\` // Service workers cannot interact with the DOM, so never wait for it. {handleDialog: dialogAction ?? 'accept', waitForStableDom: false}, ); + await deleteSecrets(secret.consume); if (result.dialogHandled) { context.getSelectedMcpPage().clearDialog(); } @@ -158,6 +172,7 @@ Example with arguments: \`(el) => el.innerText\` }, {handleDialog: dialogAction ?? 'accept', waitForStableDom}, ); + await deleteSecrets(secret.consume); response.attachWaitForResult(result); }, }; diff --git a/src/utils/paths.ts b/src/utils/paths.ts new file mode 100644 index 000000000..02b8e957b --- /dev/null +++ b/src/utils/paths.ts @@ -0,0 +1,49 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import os from 'node:os'; +import path from 'node:path'; +import process from 'node:process'; + +const APP_NAME = 'chrome-devtools-mcp'; + +/** + * The per-user data directory for this server, following the conventions of + * the host operating system. It is keyed off this server's own name, never off + * the MCP client's, so it is the same whichever client launched us. + */ +export function getDataFolder(): string { + const homedir = os.homedir(); + const {env} = process; + + if (process.platform === 'darwin') { + return path.join(homedir, 'Library', 'Application Support', APP_NAME); + } + + if (process.platform === 'win32') { + const localAppData = + env.LOCALAPPDATA || path.join(homedir, 'AppData', 'Local'); + return path.join(localAppData, APP_NAME, 'Data'); + } + + return path.join( + env.XDG_DATA_HOME || path.join(homedir, '.local', 'share'), + APP_NAME, + ); +} + +/** + * A spelling of `filePath` for user-facing text, with the home directory + * written as `~` so that the text does not bake in the account the server + * happens to run as. + */ +export function displayPath(filePath: string): string { + const homedir = os.homedir(); + if (homedir && filePath.startsWith(homedir + path.sep)) { + return `~${filePath.slice(homedir.length)}`; + } + return filePath; +} diff --git a/src/utils/secrets.ts b/src/utils/secrets.ts new file mode 100644 index 000000000..75ca73e65 --- /dev/null +++ b/src/utils/secrets.ts @@ -0,0 +1,242 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import fs from 'node:fs/promises'; +import path from 'node:path'; +import process from 'node:process'; + +import {displayPath, getDataFolder} from './paths.js'; + +/** + * Secret values are read from here and spliced into input just before it is + * dispatched to the browser, so that the plaintext never has to be passed to + * the tool (and therefore never enters the transcript). + */ +export const SECRETS_DIR = + process.env.CHROME_DEVTOOLS_MCP_SECRETS_DIR || + path.join(getDataFolder(), 'secrets'); + +/** + * Spellings used in all user-facing text, with the home directory written as + * `~` so the text does not bake in the account the server runs as. + */ +export const SECRETS_DIR_DISPLAY = displayPath(SECRETS_DIR); + +/** + * Reusable scripts are read from here so that a caller does not have to + * include the same code in every call. The code itself is still sent to the + * browser each time; only the caller is spared repeating it. Unlike a secret, + * a script is never consumed. + */ +export const SCRIPTS_DIR = + process.env.CHROME_DEVTOOLS_MCP_SCRIPTS_DIR || + path.join(getDataFolder(), 'scripts'); + +export const SCRIPTS_DIR_DISPLAY = displayPath(SCRIPTS_DIR); + +interface Store { + dir: string; + display: string; + kind: string; +} + +const SECRETS: Store = { + dir: SECRETS_DIR, + display: SECRETS_DIR_DISPLAY, + kind: 'secret', +}; + +const SCRIPTS: Store = { + dir: SCRIPTS_DIR, + display: SCRIPTS_DIR_DISPLAY, + kind: 'script', +}; + +/** `{{script:NAME}}`. Takes no modifiers. */ +const SCRIPT_PLACEHOLDER = /\{\{script:([^{}]+)\}\}/g; + +/** `{{secret:NAME}}`, with optional `:raw` and `:keep` in any order. */ +const SECRET_PLACEHOLDER = /\{\{secret:([^{}]+)\}\}/g; + +const VALID_NAME = /^[A-Za-z0-9._-]+$/; + +interface ParsedSecret { + name: string; + /** Keep the trailing newline. */ + raw: boolean; + /** Do not delete the secret file after it has been used. */ + keep: boolean; +} + +/** + * A script takes no modifiers: it is meant to be reused, so it is never + * consumed, and it is code, so it is always used exactly as stored. + */ +function parseScript(inner: string): string { + const [name, ...modifiers] = inner.split(':'); + if (modifiers.length > 0) { + throw new Error( + `{{script:${inner}}} takes no modifiers: a script is never consumed and is always used exactly as stored.`, + ); + } + return name; +} + +function parseSecret(inner: string): ParsedSecret { + const [name, ...modifiers] = inner.split(':'); + let raw = false; + let keep = false; + for (const modifier of modifiers) { + if (modifier === 'raw') { + raw = true; + } else if (modifier === 'keep') { + keep = true; + } else { + throw new Error( + `Unknown modifier ":${modifier}" in {{secret:${inner}}}. Supported modifiers are ":raw" and ":keep".`, + ); + } + } + return {name, raw, keep}; +} + +/** + * Resolves a bare name to a file inside `dir`. Only basenames are accepted: + * path separators, `.`/`..` and absolute paths are rejected so that a + * reference can never escape the directory. + */ +function resolveName(store: Store, name: string): string { + if (!VALID_NAME.test(name) || name === '.' || name === '..') { + throw new Error( + `Invalid ${store.kind} name "${name}". Use a plain file name (letters, digits, ".", "_", "-") of a file in ${store.display}.`, + ); + } + return path.join(store.dir, name); +} + +async function readFileIn(store: Store, name: string): Promise { + const filePath = resolveName(store, name); + try { + return await fs.readFile(filePath, 'utf8'); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + throw new Error( + `No ${store.kind} named "${name}" found at ${store.display}/${name}.`, + ); + } + throw error; + } +} + +/** + * Files usually end with a trailing newline that is not part of the secret. + */ +function stripTrailingNewline(value: string): string { + return value.replace(/\r?\n$/, ''); +} + +export interface ResolvedPlaceholders { + /** The text with every placeholder replaced by the file's contents. */ + value: string; + /** Names of the secrets that were substituted, in order of first use. */ + names: string[]; + /** + * Names of the secrets to delete once they have been used successfully, + * i.e. every referenced secret that was not marked `:keep`. + */ + consume: string[]; +} + +/** + * Resolves placeholders in exactly two passes: + * + * 1. `{{script:NAME}}` is replaced with the contents of + * {@link SCRIPTS_DIR}/NAME, exactly as stored. + * 2. `{{secret:NAME}}` is replaced with the contents of + * {@link SECRETS_DIR}/NAME, including any that came from a script in + * pass 1. A trailing newline is stripped unless `:raw` is given. + * + * Substituted content is never rescanned, so a script cannot pull in another + * script and a secret's value is never interpreted as a placeholder. + * + * Text without placeholders is returned unchanged. + */ +export async function resolvePlaceholders( + text: string, +): Promise { + // Pass 1: scripts. + const scriptMatches = [...text.matchAll(SCRIPT_PLACEHOLDER)]; + let value = text; + if (scriptMatches.length > 0) { + const scripts = new Map(); + for (const [placeholder, inner] of scriptMatches) { + if (scripts.has(placeholder)) { + continue; + } + scripts.set(placeholder, await readFileIn(SCRIPTS, parseScript(inner))); + } + value = value.replace(SCRIPT_PLACEHOLDER, match => { + return scripts.get(match) ?? match; + }); + } + + // Pass 2: secrets, including any a script brought in. + const secretMatches = [...value.matchAll(SECRET_PLACEHOLDER)]; + if (secretMatches.length === 0) { + return {value, names: [], consume: []}; + } + + const names: string[] = []; + const consume: string[] = []; + const values = new Map(); + for (const [placeholder, inner] of secretMatches) { + const {name, raw, keep} = parseSecret(inner); + if (!values.has(placeholder)) { + const contents = await readFileIn(SECRETS, name); + values.set(placeholder, raw ? contents : stripTrailingNewline(contents)); + } + if (!names.includes(name)) { + names.push(name); + } + // Any use without `:keep` consumes the secret, even if another + // placeholder for the same name asked to keep it. + if (!keep && !consume.includes(name)) { + consume.push(name); + } + } + + value = value.replace(SECRET_PLACEHOLDER, match => { + return values.get(match) ?? match; + }); + + return {value, names, consume}; +} + +/** + * Deletes the named secret files. Missing files are ignored so that the same + * secret can be consumed by several calls. + */ +export async function deleteSecrets(names: string[]): Promise { + for (const name of names) { + await fs.rm(resolveName(SECRETS, name), {force: true}); + } +} + +export const scriptPlaceholderHint = + `To run the same code repeatedly without writing it out in every call, store it in ${SCRIPTS_DIR_DISPLAY}/NAME and pass ` + + `\`{{script:NAME}}\`, which this MCP server replaces with that file's contents exactly as stored. ` + + `A script takes no modifiers: it is never deleted, and it is never trimmed.`; + +export const placeholderHint = + `May contain \`{{secret:NAME}}\`, which is replaced with the contents of ${SECRETS_DIR_DISPLAY}/NAME ` + + `by this MCP server just before the input is sent to the browser, so the secret never has to be passed to this tool. ` + + `Stage such files by reference (e.g. \`pass show x > ${SECRETS_DIR_DISPLAY}/x\`), never by writing the literal value. ` + + `The secret file is DELETED once the call succeeds; append \`:keep\` (\`{{secret:NAME:keep}}\`) to keep it for later calls. ` + + `Append \`:raw\` (\`{{secret:NAME:raw}}\`, \`{{secret:NAME:raw:keep}}\`) to keep a trailing newline. ` + + `The directory can be relocated with the CHROME_DEVTOOLS_MCP_SECRETS_DIR environment variable. ` + + `ALWAYS pick a unique, specific NAME (e.g. "github-login-7f3a" rather than "pw"): ` + + `${SECRETS_DIR_DISPLAY} is shared by all MCP servers running in parallel, so a generic name can be overwritten by ` + + `another session and make you fill the wrong value, or be deleted while you still need it.`; diff --git a/tests/secrets.test.ts b/tests/secrets.test.ts new file mode 100644 index 000000000..727f2b3c4 --- /dev/null +++ b/tests/secrets.test.ts @@ -0,0 +1,194 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'node:assert'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import {after, before, describe, it} from 'node:test'; + +import { + deleteSecrets, + resolvePlaceholders, + SCRIPTS_DIR, + SECRETS_DIR, +} from '../src/utils/secrets.js'; + +// Unique per run so the tests never clobber a real secret. +const PREFIX = `cdp-test-${process.pid}-`; +const written: string[] = []; + +async function writeSecret(name: string, contents: string): Promise { + const full = `${PREFIX}${name}`; + const file = path.join(SECRETS_DIR, full); + await fs.writeFile(file, contents); + written.push(file); + return full; +} + +async function writeScript(name: string, contents: string): Promise { + const full = `${PREFIX}${name}`; + const file = path.join(SCRIPTS_DIR, full); + await fs.writeFile(file, contents); + written.push(file); + return full; +} + +describe('secrets', () => { + before(async () => { + await fs.mkdir(SECRETS_DIR, {recursive: true}); + await fs.mkdir(SCRIPTS_DIR, {recursive: true}); + }); + + after(async () => { + for (const file of written) { + await fs.rm(file, {force: true}); + } + }); + + describe('resolvePlaceholders', () => { + it('returns text without placeholders unchanged', async () => { + const result = await resolvePlaceholders('just a value'); + assert.strictEqual(result.value, 'just a value'); + assert.deepStrictEqual(result.names, []); + }); + + it('substitutes a secret and strips the trailing newline', async () => { + const name = await writeSecret('pw', 'hunter2\n'); + const result = await resolvePlaceholders(`{{secret:${name}}}`); + assert.strictEqual(result.value, 'hunter2'); + assert.deepStrictEqual(result.names, [name]); + assert.deepStrictEqual(result.consume, [name]); + }); + + it('keeps the trailing newline with :raw', async () => { + const name = await writeSecret('raw', 'hunter2\n'); + const result = await resolvePlaceholders(`{{secret:${name}:raw}}`); + assert.strictEqual(result.value, 'hunter2\n'); + }); + + it('substitutes inside surrounding text and repeats', async () => { + const name = await writeSecret('tok', 'abc'); + const result = await resolvePlaceholders( + `Bearer {{secret:${name}}} and {{secret:${name}}}`, + ); + assert.strictEqual(result.value, 'Bearer abc and abc'); + assert.deepStrictEqual(result.names, [name]); + }); + + it('marks a :keep secret as not to be consumed', async () => { + const name = await writeSecret('kept', 'v\n'); + const result = await resolvePlaceholders(`{{secret:${name}:keep}}`); + assert.strictEqual(result.value, 'v'); + assert.deepStrictEqual(result.names, [name]); + assert.deepStrictEqual(result.consume, []); + }); + + it('supports :raw:keep together', async () => { + const name = await writeSecret('rawkept', 'v\n'); + const result = await resolvePlaceholders(`{{secret:${name}:raw:keep}}`); + assert.strictEqual(result.value, 'v\n'); + assert.deepStrictEqual(result.consume, []); + }); + + it('consumes when the same secret is also used without :keep', async () => { + const name = await writeSecret('mixed', 'v'); + const result = await resolvePlaceholders( + `{{secret:${name}:keep}} {{secret:${name}}}`, + ); + assert.strictEqual(result.value, 'v v'); + assert.deepStrictEqual(result.consume, [name]); + }); + + it('rejects an unknown modifier', async () => { + const name = await writeSecret('badmod', 'v'); + await assert.rejects( + () => resolvePlaceholders(`{{secret:${name}:nope}}`), + /Unknown modifier ":nope"/, + ); + }); + + it('rejects a name that escapes the secrets dir', async () => { + await assert.rejects( + () => resolvePlaceholders('{{secret:../../etc/passwd}}'), + /Invalid secret name/, + ); + }); + + it('reports a missing secret clearly', async () => { + await assert.rejects( + () => resolvePlaceholders(`{{secret:${PREFIX}nope}}`), + /No secret named/, + ); + }); + }); + + describe('deleteSecrets', () => { + it('removes the file and tolerates a missing one', async () => { + const name = await writeSecret('temp', 'x'); + await deleteSecrets([name]); + await assert.rejects(() => fs.access(path.join(SECRETS_DIR, name))); + // Second delete must not throw. + await deleteSecrets([name]); + }); + }); + + describe('{{script:NAME}}', () => { + it('substitutes a script exactly as stored and never consumes it', async () => { + const name = await writeScript('reuse.js', '() => document.title\n'); + const result = await resolvePlaceholders(`{{script:${name}}}`); + // Always raw: a script is code, so it is never trimmed. + assert.strictEqual(result.value, '() => document.title\n'); + assert.deepStrictEqual(result.consume, []); + await fs.access(path.join(SCRIPTS_DIR, name)); + }); + + it('rejects any modifier', async () => { + const name = await writeScript('mod.js', '() => 1'); + for (const modifier of ['raw', 'keep', 'nope']) { + await assert.rejects( + () => resolvePlaceholders(`{{script:${name}:${modifier}}}`), + /takes no modifiers/, + ); + } + }); + + it('reports a missing script clearly', async () => { + await assert.rejects( + () => resolvePlaceholders(`{{script:${PREFIX}missing.js}}`), + /No script named/, + ); + }); + }); + + describe('two-pass resolution', () => { + it('resolves a secret that came from a script', async () => { + const secret = await writeSecret('inscript', 's3cret\n'); + const script = await writeScript( + 'login.js', + `() => login("{{secret:${secret}}}")`, + ); + const result = await resolvePlaceholders(`{{script:${script}}}`); + assert.strictEqual(result.value, `() => login("s3cret")`); + assert.deepStrictEqual(result.consume, [secret]); + }); + + it('does not resolve a script nested inside a script', async () => { + const inner = await writeScript('inner.js', '() => 1'); + const outer = await writeScript('outer.js', `{{script:${inner}}}`); + const result = await resolvePlaceholders(`{{script:${outer}}}`); + // Substituted content is never rescanned for further scripts. + assert.strictEqual(result.value, `{{script:${inner}}}`); + }); + + it('does not reinterpret a secret value as a placeholder', async () => { + const name = await writeSecret('tricky', '{{secret:other}}'); + const result = await resolvePlaceholders(`{{secret:${name}}}`); + // The value is used literally, not resolved again. + assert.strictEqual(result.value, '{{secret:other}}'); + assert.deepStrictEqual(result.consume, [name]); + }); + }); +});