From 641c6c2c433ad9c14373e57cefc1862f1a780b47 Mon Sep 17 00:00:00 2001 From: Steffen Heil | secforge Date: Sun, 13 Sep 2026 16:58:51 +0200 Subject: [PATCH 1/2] feat: resolve {{secret:NAME}} and {{script:NAME}} placeholders in the MCP server Lets a password be filled into a page without ever passing the plaintext to a tool, so it never enters the conversation transcript. fill, fill_form, type_text and evaluate_script substitute {{secret:NAME}} with the contents of /secrets/NAME in this MCP server, immediately before the input is dispatched to the browser. A secret is consumed (deleted) once the call succeeds; ":keep" retains it and ":raw" preserves a trailing newline. {{script:NAME}} does the same for /scripts/NAME, so the same JavaScript can be run repeatedly without the caller writing it out in every call. The code is still sent to the browser each time; only the caller is spared repeating it. A script takes no modifiers: it is never consumed and always used exactly as stored. Resolution runs in exactly two passes, scripts then secrets, so a script may carry a secret but substituted content is never rescanned: scripts do not nest and a secret value is never interpreted as a placeholder. Both directories live under this server's own per-OS data directory, keyed off "chrome-devtools-mcp" rather than any MCP client, and can be relocated with CHROME_DEVTOOLS_MCP_SECRETS_DIR and CHROME_DEVTOOLS_MCP_SCRIPTS_DIR. The per-OS resolution telemetry already used moves to utils/paths.ts so both share it. References are basename-only so they cannot escape their directory, and resolved values are kept out of every response: type_text echoes the unresolved text, and fill errors report the placeholder rather than the substituted value. Adds a secret-handling skill covering staging by reference, login and 2FA flows, script reuse, and the request-body caveat. --- docs/tool-reference.md | 10 +- skills/secret-handling/SKILL.md | 134 ++++++++++++++++++ src/telemetry/persistence.ts | 23 +-- src/tools/input.ts | 40 ++++-- src/tools/script.ts | 17 ++- src/utils/paths.ts | 49 +++++++ src/utils/secrets.ts | 242 ++++++++++++++++++++++++++++++++ tests/secrets.test.ts | 194 +++++++++++++++++++++++++ 8 files changed, 673 insertions(+), 36 deletions(-) create mode 100644 skills/secret-handling/SKILL.md create mode 100644 src/utils/paths.ts create mode 100644 src/utils/secrets.ts create mode 100644 tests/secrets.test.ts 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]); + }); + }); +}); From 81ae2ab511350b987fb68c68d0a1f69bc83f9fb7 Mon Sep 17 00:00:00 2001 From: Steffen Heil | secforge Date: Mon, 17 Aug 2026 22:13:41 +0200 Subject: [PATCH 2/2] feat: add cookie management tools Adds get_cookies, set_cookie and clear_cookies, covering the inspect, edit and delete cases raised in #408. Cookies in the browser profile are not fully reachable with the existing tools: cookieStore and document.cookie cannot see or modify HttpOnly cookies, and the network tools only show cookies for requests that happened to be captured. These tools go through CDP, so they cover HttpOnly cookies and domains the page never requested. Cookie values are kept out of the conversation in both directions. get_cookies writes them to a JSON file and reports only each cookie's name, domain, path, expiry and security flags, so enumerating a profile does not dump live session tokens into the transcript. set_cookie does not echo the value back, and resolves {{secret:NAME}} placeholders, so a session token never has to be passed to the tool as literal text. clear_cookies deletes cookies matching a name and/or domain, passing each cookie's partition key so partitioned (CHIPS) cookies are not silently left behind. Clearing every cookie signs the user out of every site, so it refuses to do that implicitly: an explicit "all: true" is required when no filter is given. The domain filter matches on domain-label boundaries, so "example.com" covers "www.example.com" but never "notexample.com" or "example.com.evil.test". A substring match would have let clear_cookies delete cookies the caller never asked for. All three act on the default browser context, which the cookie-debugging skill is updated to say, along with the HttpOnly cases that are now reachable. --- docs/tool-reference.md | 57 +++++- skills/cookie-debugging/SKILL.md | 22 +-- src/tools/cookies.ts | 290 +++++++++++++++++++++++++++++++ src/tools/tools.ts | 2 + tests/tools/cookies.test.ts | 251 ++++++++++++++++++++++++++ 5 files changed, 606 insertions(+), 16 deletions(-) create mode 100644 src/tools/cookies.ts create mode 100644 tests/tools/cookies.test.ts diff --git a/docs/tool-reference.md b/docs/tool-reference.md index 4a055207a..d0dda5b7e 100644 --- a/docs/tool-reference.md +++ b/docs/tool-reference.md @@ -27,9 +27,12 @@ - [`performance_analyze_insight`](#performance_analyze_insight) - [`performance_start_trace`](#performance_start_trace) - [`performance_stop_trace`](#performance_stop_trace) -- **[Network](#network)** (2 tools) +- **[Network](#network)** (5 tools) + - [`clear_cookies`](#clear_cookies) + - [`get_cookies`](#get_cookies) - [`get_network_request`](#get_network_request) - [`list_network_requests`](#list_network_requests) + - [`set_cookie`](#set_cookie) - **[Debugging](#debugging)** (9 tools) - [`evaluate_script`](#evaluate_script) - [`get_console_message`](#get_console_message) @@ -343,6 +346,31 @@ ## Network +### `clear_cookies` + +**Description:** Deletes cookies from the browser. Pass 'name' and/or 'domain' to delete matching cookies, or 'all' to clear every cookie in the browser. Use this to return to a signed-out or first-visit state. + +**Parameters:** + +- **pageId** (number) **(required)**: Targets a specific page by ID. +- **all** (boolean) _(optional)_: Set to true to delete EVERY cookie in the browser. This signs the user out of every site, so it is required when no other filter is given, to make a full wipe explicit. +- **domain** (string) _(optional)_: Only delete cookies for this domain and its subdomains, e.g. "example.com" also matches "www.example.com" but not "notexample.com" (case-insensitive). +- **name** (string) _(optional)_: Only delete cookies with this exact name. + +--- + +### `get_cookies` + +**Description:** Gets all cookies stored in the browser's default context and writes them, including their values, to a JSON file. The tool reports only which cookies were found (name, domain, and security metadata); cookie values are never returned inline and only exist in the file. + +**Parameters:** + +- **filePath** (string) **(required)**: The absolute or relative path to a .json file to write the cookies (including their values) to. +- **pageId** (number) **(required)**: Targets a specific page by ID. +- **domain** (string) _(optional)_: Only return cookies for this domain and its subdomains, e.g. "example.com" also matches "www.example.com" (case-insensitive). When omitted, returns all cookies. + +--- + ### `get_network_request` **Description:** Gets a network request by an optional reqid, if omitted returns the currently selected request in the DevTools Network panel. Useful for inspecting request headers (including 'Cookie') and response headers (including 'Set-Cookie' and directives). @@ -370,6 +398,25 @@ --- +### `set_cookie` + +**Description:** Sets a cookie in the browser. Either 'url' or 'domain' must be given. Use this to restore a session, toggle a feature flag, or reproduce a state that depends on a specific cookie. + +**Parameters:** + +- **name** (string) **(required)**: The name of the cookie. +- **pageId** (number) **(required)**: Targets a specific page by ID. +- **value** (string) **(required)**: The value of the cookie. A cookie value is often a session token, and this parameter is recorded in the conversation transcript, so prefer a placeholder. 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. +- **domain** (string) _(optional)_: The cookie domain, e.g. "example.com" or ".example.com" to include subdomains. Either this or "url" is required. +- **expires** (number) _(optional)_: Expiry as seconds since the UNIX epoch. Omit to create a session cookie that is dropped when the browser closes. +- **httpOnly** (boolean) _(optional)_: Whether the cookie is inaccessible to JavaScript. +- **path** (string) _(optional)_: The cookie path. Defaults to "/" when a domain is given. +- **sameSite** (enum: "Strict", "Lax", "None") _(optional)_: The SameSite policy. "None" requires secure to be true. +- **secure** (boolean) _(optional)_: Whether the cookie is only sent over HTTPS. +- **url** (string) _(optional)_: The request URI to associate the cookie with, which sets its domain and path. Either this or "domain" is required. + +--- + ## Debugging ### `evaluate_script` @@ -379,10 +426,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` -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. + 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/cookie-debugging/SKILL.md b/skills/cookie-debugging/SKILL.md index 85929b967..420744506 100644 --- a/skills/cookie-debugging/SKILL.md +++ b/skills/cookie-debugging/SKILL.md @@ -9,7 +9,7 @@ description: Uses Chrome DevTools MCP for inspecting, debugging, and testing coo Cookies marked `HttpOnly` cannot be accessed or modified by client-side JavaScript (`cookieStore` or `document.cookie`). However, the browser **automatically attaches active HttpOnly cookies to outgoing HTTP request headers (`Cookie`)**. -- To inspect current `HttpOnly` values: Look at the `Cookie` request header of any outgoing HTTP request via `get_network_request`. +- To inspect current `HttpOnly` values: Use `get_cookies`, which reads them via CDP and is not subject to the JavaScript restriction. Alternatively, look at the `Cookie` request header of any outgoing HTTP request via `get_network_request`. - To inspect how cookies were created or configured: Look at the `Set-Cookie` response header of login/auth responses. - To inspect non-`HttpOnly` cookies: Use `evaluate_script` with the modern `cookieStore` API (`async () => await cookieStore.getAll()`). @@ -24,16 +24,16 @@ Choose the right session environment to avoid state contamination (e.g., residua ### Client-Side Capabilities & Limitations -| Action | Client JavaScript (`cookieStore` / `document.cookie`) | DevTools Network & Context Tools | -| :--------------------------------------------------------------- | :---------------------------------------------------- | :------------------------------------------------------ | -| **Read Non-HttpOnly** | ✅ `async () => await cookieStore.getAll()` | ✅ `get_network_request` (Request `Cookie`) | -| **Read HttpOnly** | ❌ Blocked by browser security | ✅ `get_network_request` (Request `Cookie`) | -| **Inspect Attributes** (`Domain`, `Path`, `SameSite`, `Expires`) | ✅ `async () => await cookieStore.getAll()` | ✅ `get_network_request` (Response `Set-Cookie`) | -| **Modify / Delete Non-HttpOnly** | ✅ `async () => await cookieStore.set(...)` | N/A | -| **Modify / Delete HttpOnly** | ❌ **Silent failure** in JavaScript | ✅ Use `new_page(isolatedContext: ...)` for clean state | +| Action | Client JavaScript (`cookieStore` / `document.cookie`) | DevTools Network & Context Tools | +| :--------------------------------------------------------------- | :---------------------------------------------------- | :------------------------------------------------------------------------------------- | +| **Read Non-HttpOnly** | ✅ `async () => await cookieStore.getAll()` | ✅ `get_network_request` (Request `Cookie`) | +| **Read HttpOnly** | ❌ Blocked by browser security | ✅ `get_cookies`, or `get_network_request` (Request `Cookie`) | +| **Inspect Attributes** (`Domain`, `Path`, `SameSite`, `Expires`) | ✅ `async () => await cookieStore.getAll()` | ✅ `get_network_request` (Response `Set-Cookie`) | +| **Modify / Delete Non-HttpOnly** | ✅ `async () => await cookieStore.set(...)` | ✅ `set_cookie` / `clear_cookies` | +| **Modify / Delete HttpOnly** | ❌ **Silent failure** in JavaScript | ✅ `set_cookie` / `clear_cookies`, or `new_page(isolatedContext: ...)` for clean state | > [!WARNING] -> Attempting to clear an `HttpOnly` cookie via JavaScript (`cookieStore.delete` or `document.cookie = "...; max-age=0"`) will silently fail. To test in an unauthenticated or fresh state, always spawn a new isolated context using `new_page` with `isolatedContext`. +> Attempting to clear an `HttpOnly` cookie via JavaScript (`cookieStore.delete` or `document.cookie = "...; max-age=0"`) will silently fail. Use `clear_cookies`, which deletes via CDP, or spawn a new isolated context with `new_page` and `isolatedContext` for a guaranteed clean slate. Note that `get_cookies`, `set_cookie` and `clear_cookies` act on the default browser context, not on an isolated one. --- @@ -140,8 +140,8 @@ For client-accessible, non-`HttpOnly` cookies (e.g., UI preferences, non-sensiti - **`cookieStore` is undefined**: `cookieStore` requires a Secure Context (`https://`, `localhost`, or `127.0.0.1`). On non-secure HTTP origins, use `() => document.cookie` or test over HTTPS. - **`evaluate_script` returns empty / unresolved Promise**: `cookieStore` methods are asynchronous. Always wrap calls with `async () => await cookieStore.getAll()`. -- **Cookie not visible in JavaScript**: The cookie is marked `HttpOnly`. Trigger a network request and call `get_network_request` to view it in the `Cookie` request header. -- **JavaScript deletion did not remove cookie**: The cookie is `HttpOnly` or requires matching `Path` and `Domain` parameters. Use a fresh `isolatedContext` with `new_page` for a clean slate. +- **Cookie not visible in JavaScript**: The cookie is marked `HttpOnly`. Call `get_cookies`, or trigger a network request and call `get_network_request` to view it in the `Cookie` request header. +- **JavaScript deletion did not remove cookie**: The cookie is `HttpOnly` or requires matching `Path` and `Domain` parameters. Use `clear_cookies`, or a fresh `isolatedContext` with `new_page` for a clean slate. - **Cookie set in response but not sent in requests**: - Verify if page is `http://` while cookie specifies `Secure`. - Check if `Domain` restricts subdomains. diff --git a/src/tools/cookies.ts b/src/tools/cookies.ts new file mode 100644 index 000000000..36cefe57b --- /dev/null +++ b/src/tools/cookies.ts @@ -0,0 +1,290 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {CDPSession, Protocol} from '../third_party/index.js'; + +import {zod} from '../third_party/index.js'; +import { + deleteSecrets, + placeholderHint, + resolvePlaceholders, +} from '../utils/secrets.js'; + +import {ToolCategory} from './categories.js'; +import type {ContextPage} from './ToolDefinition.js'; +import {definePageTool} from './ToolDefinition.js'; + +/** + * Runs `action` on a CDP session that is always detached again afterwards. + */ +async function withCdpSession( + page: ContextPage, + action: (session: CDPSession) => Promise, +): Promise { + const session = await page.pptrPage.createCDPSession(); + try { + return await action(session); + } finally { + await session.detach().catch(() => undefined); + } +} + +/** + * Matches a cookie against a domain filter on domain-label boundaries, so + * "example.com" matches "example.com" and "www.example.com" but never + * "notexample.com" or "example.com.evil.test". A substring match here would + * make clear_cookies delete cookies the caller did not ask for. + */ +function matchesDomain( + cookie: Protocol.Network.Cookie, + domain: string | undefined, +): boolean { + if (!domain) { + return true; + } + // Cookie domains may carry a leading dot meaning "and subdomains". + const cookieDomain = cookie.domain.toLowerCase().replace(/^\./, ''); + const filter = domain.toLowerCase().replace(/^\./, ''); + return cookieDomain === filter || cookieDomain.endsWith(`.${filter}`); +} + +/** + * Describes a cookie WITHOUT exposing its value, so cookie values never end up + * in the model context. The full cookie, including values, is written to the + * file instead. + */ +function describeCookie(cookie: Protocol.Network.Cookie): string { + const parts = [`${cookie.name} (domain=${cookie.domain}`]; + if (cookie.path) { + parts.push(`path=${cookie.path}`); + } + if (cookie.expires && cookie.expires > 0) { + parts.push(`expires=${new Date(cookie.expires * 1000).toISOString()}`); + } else { + parts.push('session'); + } + if (cookie.httpOnly) { + parts.push('httpOnly'); + } + if (cookie.secure) { + parts.push('secure'); + } + if (cookie.sameSite) { + parts.push(`sameSite=${cookie.sameSite}`); + } + return parts.join(', ') + ')'; +} + +export const getCookies = definePageTool({ + name: 'get_cookies', + description: `Gets all cookies stored in the browser's default context and writes them, including their values, to a JSON file. The tool reports only which cookies were found (name, domain, and security metadata); cookie values are never returned inline and only exist in the file.`, + annotations: { + category: ToolCategory.NETWORK, + readOnlyHint: true, + }, + schema: { + filePath: zod + .string() + .describe( + 'The absolute or relative path to a .json file to write the cookies (including their values) to.', + ), + domain: zod + .string() + .optional() + .describe( + 'Only return cookies for this domain and its subdomains, e.g. "example.com" also matches "www.example.com" (case-insensitive). When omitted, returns all cookies.', + ), + }, + blockedByDialog: false, + verifyFilesSchema: { + filePath: true, + }, + handler: async (request, response, context) => { + let {cookies} = await withCdpSession(request.page, session => + session.send('Storage.getCookies'), + ); + + cookies = cookies.filter(cookie => + matchesDomain(cookie, request.params.domain), + ); + + const data = new TextEncoder().encode(JSON.stringify(cookies, null, 2)); + const file = await context.saveFile(data, request.params.filePath, '.json'); + + if (cookies.length === 0) { + response.appendResponseLine(`No cookies found. Wrote ${file.filename}.`); + return; + } + + response.appendResponseLine( + `Found ${cookies.length} cookie(s); values written to ${file.filename}.`, + ); + for (const cookie of cookies) { + response.appendResponseLine(describeCookie(cookie)); + } + }, +}); + +export const setCookie = definePageTool({ + name: 'set_cookie', + description: `Sets a cookie in the browser. Either 'url' or 'domain' must be given. Use this to restore a session, toggle a feature flag, or reproduce a state that depends on a specific cookie.`, + annotations: { + category: ToolCategory.NETWORK, + readOnlyHint: false, + }, + schema: { + name: zod.string().describe('The name of the cookie.'), + value: zod + .string() + .describe( + `The value of the cookie. A cookie value is often a session token, and this parameter is recorded in the conversation transcript, so prefer a placeholder. ${placeholderHint}`, + ), + url: zod + .string() + .optional() + .describe( + 'The request URI to associate the cookie with, which sets its domain and path. Either this or "domain" is required.', + ), + domain: zod + .string() + .optional() + .describe( + 'The cookie domain, e.g. "example.com" or ".example.com" to include subdomains. Either this or "url" is required.', + ), + path: zod + .string() + .optional() + .describe('The cookie path. Defaults to "/" when a domain is given.'), + expires: zod + .number() + .optional() + .describe( + 'Expiry as seconds since the UNIX epoch. Omit to create a session cookie that is dropped when the browser closes.', + ), + httpOnly: zod + .boolean() + .optional() + .describe('Whether the cookie is inaccessible to JavaScript.'), + secure: zod + .boolean() + .optional() + .describe('Whether the cookie is only sent over HTTPS.'), + sameSite: zod + .enum(['Strict', 'Lax', 'None']) + .optional() + .describe('The SameSite policy. "None" requires secure to be true.'), + }, + blockedByDialog: false, + verifyFilesSchema: {}, + handler: async (request, response) => { + const {name, url, domain, path, expires, httpOnly, secure, sameSite} = + request.params; + + // Resolved here so a session token never has to be passed to this tool. + const secret = await resolvePlaceholders(request.params.value); + const value = secret.value; + + if (!url && !domain) { + throw new Error( + `Either 'url' or 'domain' is required to set a cookie, so the browser knows where it applies.`, + ); + } + + const {success} = await withCdpSession(request.page, session => + session.send('Network.setCookie', { + name, + value, + url, + domain, + path: path ?? (domain ? '/' : undefined), + expires, + httpOnly, + secure, + sameSite, + }), + ); + + if (!success) { + throw new Error( + `The browser rejected the cookie "${name}". Check that the domain matches the page, and that secure is set when sameSite is "None".`, + ); + } + + await deleteSecrets(secret.consume); + + // The value is deliberately not echoed back. + response.appendResponseLine( + `Set cookie ${name} for ${url ?? domain}${path ? ` (path=${path})` : ''}.`, + ); + }, +}); + +export const clearCookies = definePageTool({ + name: 'clear_cookies', + description: `Deletes cookies from the browser. Pass 'name' and/or 'domain' to delete matching cookies, or 'all' to clear every cookie in the browser. Use this to return to a signed-out or first-visit state.`, + annotations: { + category: ToolCategory.NETWORK, + readOnlyHint: false, + }, + schema: { + domain: zod + .string() + .optional() + .describe( + 'Only delete cookies for this domain and its subdomains, e.g. "example.com" also matches "www.example.com" but not "notexample.com" (case-insensitive).', + ), + name: zod + .string() + .optional() + .describe('Only delete cookies with this exact name.'), + all: zod + .boolean() + .optional() + .describe( + 'Set to true to delete EVERY cookie in the browser. This signs the user out of every site, so it is required when no other filter is given, to make a full wipe explicit.', + ), + }, + blockedByDialog: false, + verifyFilesSchema: {}, + handler: async (request, response) => { + const {domain, name, all} = request.params; + + if (!domain && !name && !all) { + throw new Error( + `Refusing to clear every cookie implicitly. Pass 'domain' and/or 'name' to delete specific cookies, or 'all: true' to deliberately sign the user out of every site.`, + ); + } + + await withCdpSession(request.page, async session => { + const {cookies} = await session.send('Storage.getCookies'); + const matching = cookies.filter( + cookie => + matchesDomain(cookie, domain) && (!name || cookie.name === name), + ); + + if (matching.length === 0) { + response.appendResponseLine('No matching cookies found.'); + return; + } + + for (const cookie of matching) { + await session.send('Network.deleteCookies', { + name: cookie.name, + domain: cookie.domain, + path: cookie.path, + // Without the partition key a partitioned (CHIPS) cookie is not + // matched and would silently survive the delete. + partitionKey: cookie.partitionKey, + }); + } + + response.appendResponseLine(`Deleted ${matching.length} cookie(s):`); + for (const cookie of matching) { + response.appendResponseLine(`${cookie.name} (domain=${cookie.domain})`); + } + }); + }, +}); diff --git a/src/tools/tools.ts b/src/tools/tools.ts index 63bd3e6f0..984d3f57f 100644 --- a/src/tools/tools.ts +++ b/src/tools/tools.ts @@ -8,6 +8,7 @@ import type {ParsedArguments} from '../config/mcp-options.js'; import * as commentsTools from './comments.js'; import * as consoleTools from './console.js'; +import * as cookieTools from './cookies.js'; import * as cssTools from './css.js'; import * as emulationTools from './emulation.js'; import * as extensionTools from './extensions.js'; @@ -33,6 +34,7 @@ export const createTools = (args: ParsedArguments) => { : [ ...(args.devtoolsComments ? Object.values(commentsTools) : []), ...Object.values(consoleTools), + ...Object.values(cookieTools), ...Object.values(cssTools), ...Object.values(emulationTools), ...Object.values(extensionTools), diff --git a/tests/tools/cookies.test.ts b/tests/tools/cookies.test.ts new file mode 100644 index 000000000..da2dbc104 --- /dev/null +++ b/tests/tools/cookies.test.ts @@ -0,0 +1,251 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'node:assert'; +import {afterEach, describe, it} from 'node:test'; + +import sinon from 'sinon'; + +import {clearCookies, getCookies, setCookie} from '../../src/tools/cookies.js'; +import {serverHooks} from '../server.js'; +import {getTextContent, html, withMcpContext} from '../utils.js'; + +describe('cookies', () => { + const server = serverHooks(); + + afterEach(() => { + sinon.restore(); + }); + + describe('get_cookies', () => { + it('writes cookie values to the file but not the response', async () => { + server.addHtmlRoute('/', html`
Cookie page
`); + const filePath = 'cookies.json'; + + await withMcpContext(async (response, context) => { + const saveFileStub = sinon + .stub(context, 'saveFile') + .resolves({filename: filePath}); + + const page = context.getSelectedMcpPage().pptrPage; + await page.goto(server.getRoute('/')); + await page.evaluate(() => { + document.cookie = 'testcookie=testvalue'; + }); + + await getCookies.handler( + {params: {filePath}, page: context.getSelectedMcpPage()}, + response, + context, + ); + + // The value must be written to the file... + sinon.assert.calledOnce(saveFileStub); + const [savedData, savedPath] = saveFileStub.firstCall.args; + assert.strictEqual(savedPath, filePath); + const savedText = new TextDecoder().decode(savedData); + assert.match(savedText, /testvalue/); + + // ...but the value must never appear in the response context. + const responseData = await response.handle(context); + const text = getTextContent(responseData.content[0]); + assert.match(text, /testcookie/); // name is reported + assert.doesNotMatch(text, /testvalue/); // value is not + assert.match(text, new RegExp(filePath)); + }); + }); + + it('filters cookies by domain', async () => { + server.addHtmlRoute('/', html`
Cookie page
`); + const filePath = 'cookies.json'; + + await withMcpContext(async (response, context) => { + sinon.stub(context, 'saveFile').resolves({filename: filePath}); + + const page = context.getSelectedMcpPage().pptrPage; + await page.goto(server.getRoute('/')); + await page.evaluate(() => { + document.cookie = 'testcookie=testvalue'; + }); + + await getCookies.handler( + { + params: {filePath, domain: 'no-such-domain.example'}, + page: context.getSelectedMcpPage(), + }, + response, + context, + ); + + const responseData = await response.handle(context); + const text = getTextContent(responseData.content[0]); + assert.match(text, /No cookies found/); + }); + }); + }); + + describe('set_cookie', () => { + it('requires either url or domain', async () => { + await withMcpContext(async (response, context) => { + await assert.rejects( + () => + setCookie.handler( + { + params: {name: 'a', value: 'b'}, + page: context.getSelectedMcpPage(), + }, + response, + context, + ), + /Either 'url' or 'domain' is required/, + ); + }); + }); + + it('sets a cookie and does not echo its value', async () => { + server.addHtmlRoute('/', html`
Cookie page
`); + + await withMcpContext(async (response, context) => { + const page = context.getSelectedMcpPage().pptrPage; + await page.goto(server.getRoute('/')); + + await setCookie.handler( + { + params: { + name: 'flag', + value: 'super-secret-value', + url: server.getRoute('/'), + }, + page: context.getSelectedMcpPage(), + }, + response, + context, + ); + + const text = getTextContent( + (await response.handle(context)).content[0], + ); + assert.match(text, /Set cookie flag/); + assert.doesNotMatch(text, /super-secret-value/); + + // The cookie is really there. + const value = await page.evaluate(() => document.cookie); + assert.match(value, /flag=super-secret-value/); + }); + }); + it('resolves a {{secret:NAME}} value without echoing it', async () => { + server.addHtmlRoute('/', html`
Cookie page
`); + const {SECRETS_DIR} = await import('../../src/utils/secrets.js'); + const fs = await import('node:fs/promises'); + const path = await import('node:path'); + const name = `cdp-test-${process.pid}-cookieval`; + await fs.mkdir(SECRETS_DIR, {recursive: true}); + await fs.writeFile(path.join(SECRETS_DIR, name), 'tok-from-file\n'); + + await withMcpContext(async (response, context) => { + const page = context.getSelectedMcpPage().pptrPage; + await page.goto(server.getRoute('/')); + + await setCookie.handler( + { + params: { + name: 'sess', + value: `{{secret:${name}}}`, + url: server.getRoute('/'), + }, + page: context.getSelectedMcpPage(), + }, + response, + context, + ); + + const text = getTextContent( + (await response.handle(context)).content[0], + ); + assert.doesNotMatch(text, /tok-from-file/); + + // The resolved value really reached the browser... + const value = await page.evaluate(() => document.cookie); + assert.match(value, /sess=tok-from-file/); + // ...and the secret was consumed. + await assert.rejects(() => fs.access(path.join(SECRETS_DIR, name))); + }); + }); + }); + + describe('clear_cookies', () => { + it('refuses to wipe everything without an explicit flag', async () => { + await withMcpContext(async (response, context) => { + await assert.rejects( + () => + clearCookies.handler( + {params: {}, page: context.getSelectedMcpPage()}, + response, + context, + ), + /Refusing to clear every cookie implicitly/, + ); + }); + }); + + it('deletes only cookies matching the filter', async () => { + server.addHtmlRoute('/', html`
Cookie page
`); + + await withMcpContext(async (response, context) => { + const page = context.getSelectedMcpPage().pptrPage; + await page.goto(server.getRoute('/')); + await page.evaluate(() => { + document.cookie = 'keep=1'; + document.cookie = 'drop=1'; + }); + + await clearCookies.handler( + {params: {name: 'drop'}, page: context.getSelectedMcpPage()}, + response, + context, + ); + + const remaining = await page.evaluate(() => document.cookie); + assert.match(remaining, /keep=1/); + assert.doesNotMatch(remaining, /drop=1/); + }); + }); + }); + + describe('domain filter boundaries', () => { + it('matches subdomains but not lookalike domains', async () => { + server.addHtmlRoute('/', html`
Cookie page
`); + + await withMcpContext(async (response, context) => { + const saveFileStub = sinon + .stub(context, 'saveFile') + .resolves({filename: 'c.json'}); + const page = context.getSelectedMcpPage().pptrPage; + await page.goto(server.getRoute('/')); + + // "localhost" must not match a lookalike such as "notlocalhost". + await getCookies.handler( + { + params: {filePath: 'c.json', domain: 'notlocalhost'}, + page: context.getSelectedMcpPage(), + }, + response, + context, + ); + + const [savedData] = saveFileStub.firstCall.args; + const saved = JSON.parse(new TextDecoder().decode(savedData)); + for (const cookie of saved) { + assert.ok( + !cookie.domain.includes('notlocalhost') || + cookie.domain.endsWith('notlocalhost'), + `unexpected domain ${cookie.domain}`, + ); + } + }); + }); + }); +});