SLOPSHOPPER

cua

Computer use for Claude Code on macOS (Apple silicon) and Linux (X11) through OpenAI's computer-use runtime: a stdio MCP server running the pinned runtime that…

newpaneguardcommandtoastprompt
v0.6.0no licenseupdated 2026-10-09SSFSKIM/cua
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · cua
│ ┃ secret ✕ › fix the failing auth test and add an audit log call │ ┃ : value ⏎ store │ ┃ Escape cancels. Stored in ⏺ Read(src/auth.ts) │ ┃ ~/.config/claude-secrets, mode 600. ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /secret │ ⎿ cua: No secrets stored. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · secret
: value ⏎ store Escape cancels. Stored in ~/.config/claude-secrets, mode 600.
README

cua — native macOS computer use for Claude Code

Lets Claude Code read and operate macOS apps (accessibility tree, screenshots, clicks, typing, menus) by hosting OpenAI's Codex computer-use stack. cua serve (the plugin runs it through cua-shim.mjs) is a stdio MCP server: it launches a pinned copy of OpenAI's cua_repl runtime that cua install verified and placed under CUA_HOME, not the copy inside an installed ChatGPT.app, and nothing in ChatGPT.app or ~/.codex is read or modified. With the browser surface turned on it also drives your existing Chrome profiles, signed-in sessions included, through cua's own Chrome extension and native host, with no ChatGPT account or Codex login (see Chrome), and with cua agent it serves a client on another machine (see Remote control). The design and its status are in docs/doperpowers/specs/2026-10-02-standalone-cua-design.md, for remote control in docs/doperpowers/specs/2026-10-06-remote-and-linux-design.md, and for the Chrome extension in docs/doperpowers/specs/2026-10-07-own-chrome-extension-design.md.

Requirements

  • macOS on Apple silicon, or Linux on x64 or arm64 with an X11 desktop (see Linux; other platforms get unsupported_platform), and node 22 or newer on PATH. The one npm dependency, ws, is needed only for remote control through a relay (npm ci); everything else runs without node_modules.
  • The pinned runtime, installed into CUA_HOME (default ~/Library/Application Support/cua) by cua install: it downloads OpenAI's pinned ChatGPT archive from its official URL (about 690 MB), or takes a local copy with --archive <zip>, and refuses anything whose length, SHA-256, layout or OpenAI code signatures (team 2DC432GLL2) differ from runtime/releases/*.json. Vendor files are never modified or re-signed.
  • Accessibility and Screen Recording for the native computer-use helper (Codex Computer Use.app, started by the runtime through LaunchServices). macOS asks on first use; cua doctor cannot see these grants and reports them as blocked until a live run shows them. Where ChatGPT's Computer Use already runs, its compatible helper serves this runtime too and is reused as it is, never stopped or replaced.
  • Native control needs no account: the runtime gets its own empty CODEX_HOME under CUA_HOME, and nothing is read from ChatGPT.app or ~/.codex.
  • For Chrome only: Google Chrome with the cua extension in each profile you want to use (from the Chrome Web Store once it is listed, or loaded unpacked from the checkout's extension/) and cua chrome register run once (see Chrome). No account or login is needed. cua never installs the extension for you. (The ChatGPT extension route, until its removal, needs OpenAI's extension and a Codex login of the server's own instead.)

A clean Mac without ChatGPT installed has been shown, in a macOS 27 VM (docs/evidence/clean-machine-acceptance.md). There, the pinned helper started from cua's release tree, macOS asked for Accessibility and Screen Recording on the helper's behalf once, the native slice passed (accept-native items 1 and 3–8, with item 2 shown by the download installs and item 9 BLOCKED by design), and the Chrome acceptance passed C1–C7. The helper first shows its own "Enable ChatGPT Computer Use" window, which lists the permissions.

Install

From a checkout (or after npm link, the same commands as cua):

npm test                                   # Node only; no runtime, GUI, network or credentials
node bin/cua.mjs install                   # or: install --archive <ChatGPT-darwin-arm64-26.928.40906.zip>
node bin/cua.mjs doctor                    # --json for the structured checks; exit 1 when one fails

cua install is idempotent for a verified release and never repairs one in place; cua runtime use <release> switches between verified installed releases, including the release's Chrome host and its configuration where one is placed. A release that no longer verifies is reported with its offline recovery: stop the servers using it, remove its directory, install again.

cua install also places the pinned archive's Chrome plugin (OpenAI's signed native host and its scripts) in the release, with the host's configuration beside it; it is used only on the ChatGPT extension route, after cua chrome register --vendor (plain cua chrome register registers cua's own host instead; see Chrome). An installed release without it gains it on the next cua install, and nothing already installed changes.

As a Claude Code plugin

claude plugin marketplace add SSFSKIM/cua
claude plugin install cua@cua

This repository is its own marketplace, so the installed plugin follows main (claude plugin marketplace update cua picks up a new version). The plugin runs node cua-shim.mjs, which is cua serve, as the MCP server plugin:cua:cua_repl with CUA_SHIM_SURFACES=computer,browser; its tools are mcp__plugin_cua_cua_repl__*. The installed copy needs no npm ci: ws, the one dependency, is loaded only on the relay path of cua agent, never by serve (the suite checks that; a copy without node_modules reaches the runtime check). The plugin cannot install the runtime: run cua install from a checkout once (Install, above), into the same CUA_HOME. If you registered cua serve yourself before (Plain MCP server, below), remove that registration under the name you gave it (claude mcp remove cua -s user for the example there, or cua_repl), or two servers drive the same Mac.

The same server drives other machines too: devices_list and devices_use switch its tools to a device you registered with cua devices (Remote control, 4), with no registration in Claude Code per device. The plugin also carries the cua-remote skill, the procedure for setting up or driving another computer through cua (Remote control), and, where function hooks are enabled (CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1), the /secret KEY command: a value typed into a masked field and stored for the session's project (/secret -g KEY: for every project; mode 600, see Secrets), which the model sees only by its key (hooks/mods/README.md). Allow the tools in your settings so each call does not prompt: "mcp__plugin_cua_cua_repl__*" under permissions.allow. App approvals are a separate dialog; see the next section. This repository is the plugin's source of truth.

Secrets need nothing extra: every copy of cua, the plugin's included, reads the same store in your home directory (see Secrets).

As a plain MCP server (any host)

cua serve speaks MCP over stdin/stdout. Register it yourself under a name you do not already use; nothing in this repository registers or replaces a server for you. For Claude Code, for example:

claude mcp list                                                       # check what is already registered
claude mcp add --scope user cua -- node /absolute/path/to/cua/bin/cua.mjs serve

Settings (below) go in the server's environment, e.g. claude mcp add ... -e CUA_SHIM_SECRETS=off -- .... For a client on another machine, see Remote control.

App approvals

Apart from Claude Code's tool permission, OpenAI's stack asks before an app is first used, Allow Computer Use to use "X"?, as an MCP elicitation that Claude Code shows as a dialog. Where an accepted answer is remembered depends on CUA_SHIM_PERSIST:

  • session (the default) writes $CUA_HOME/state/codex/computer-use/sessions/<session id>.toml; each server connection has its own random session id, so every new connection asks once more per app, and the file is removed when its connection closes.
  • always adds the app to the machine-wide list Codex Desktop's own "Always allow" uses, ~/Library/Group Containers/2DC432GLL2.com.openai.sky.CUAService/Library/Application Support/Software/ComputerUseAppApprovals.json; an app on that list is never asked about again from any session or host. One more accept per app, then silence.

The plugin answers these dialogs for you, by design. Its Elicitation hook (hooks/hooks.json, matcher cua_repl|plugin:cua:cua_repl) runs hooks/cua-approve.sh, which accepts every elicitation cua_repl sends, app and site approvals alike; Claude Code runs the hook before it would show the dialog and takes its answer as the user's. This follows the owner's trust model (the agent is trusted and cua adds no policy of its own; issues #20 and #36), and it is what lets an unattended or headless session (claude -p, a remote client) use an app at all. Other servers' elicitations are untouched. The script uses jq when present and prints the same answer without it, so a missing jq never leaves a dialog nobody answers.

Be clear about what it removes: the model can bind any app or site OpenAI's policy allows, and binding hands it that app's whole front window (see Use). To narrow it, edit hooks/cua-approve.sh in your copy to test .message and print nothing for the rest (empty output leaves the dialog to you), for example jq -c 'if (.message // "" | startswith("Allow Computer Use to use ")) then {hookSpecificOutput:{hookEventName:"Elicitation",action:"accept",content:{}}} else empty end'; to drop it, remove the Elicitation entry from the plugin's hooks/hooks.json (an edit to the installed copy lasts until the next plugin update). Without the plugin, register the same script yourself under hooks.Elicitation in ~/.claude/settings.json with that matcher and "command": "bash /path/to/cua/hooks/cua-approve.sh".

Use

Ask for the task in plain words: "open Notes and read my latest note", "in Preview, rotate this image and save". The first call returns OpenAI's API document to the model, which then writes small JavaScript cells against the cua API. The server adds rules covering what that document leaves out: how to run a task, in the host notes it appends to the server instructions (see Operating guidance for agents), and the native quirks (index-first addressing, dropping an app handle after quitting it, typeText and emoji, and so on), ahead of OpenAI's text in the js description.

The model sees four tools: js and js_reset (OpenAI's own), end_task, and secrets_list (the keys of your stored secrets, never values; see Secrets). Calls on one connection form a task until the model calls end_task, which waits for running JavaScript and then has the runtime complete the task. The plugin no longer installs Stop/SubagentStop hooks for this; if completion cannot be confirmed, the connection fails closed and stops its runtime, and native cleanup of what was already submitted is unconfirmed. In this pinned runtime a forwarded MCP cancellation does not stop a running cell and js_reset waits behind it, so a cell's timeout_ms is what bounds runaway work; cancelling never means control has been handed back.

cua serve (the plugin's server) adds two more: devices_list and devices_use, which switch every other tool to a remote machine and back (Remote control, 4). The agent's HTTP sessions never list them.

Be aware that binding an app hands the model that app's whole front window as text, chat lists and inboxes included. For a messaging app, open the room you mean before asking.

What cua changes in results

cua relays js and js_reset results with two rewrites and no other filtering: what a cell returns, text, screenshots and page content included, reaches the client's transcript as the runtime produced it.

  • Image MIME types are corrected to what the bytes are (the runtime labels JPEG screenshots image/png).
  • Token-bearing URLs are redacted in text content and structured content, at any depth. The value of a query or fragment parameter whose name ends in the word token, key, secret or apikey (token, access_token, refresh_token, api_key, apiKey, key, client_secret, X-Refresh-Token; not monkey, keyword or tokens_left; a percent-encoded name is decoded first) becomes <redacted>, also inside a redirect parameter, raw or URL-encoded, and in a bare ?… or #… reference. A value runs to the next delimiter of its URL or to a closing bracket it did not open (the ) of a Markdown link), less a trailing }, , or .. The Playwright MCP extension's connection URL (chrome-extension://<id>/connect.html?mcpRelayUrl=…&token=…) has every parameter value redacted, and a loopback relay URL (ws://127.0.0.1:<port>/extension/…) its path. The rest of the result is unchanged.

The redaction exists because a first real run printed an extension connection URL with its token in a tab inventory (docs/evidence/2026-10-05-homework-1b-dogfooding.md). It is a pattern list, not a data-loss filter: a credential in any other shape (a cookie value, a header, a token in page text, a password typed into a field and read back), a URL in an image, and JSON-RPC error replies pass unchanged, and nothing the agent sends is rewritten. In the other direction, URL-like text that is not a URL (x?key=1 in code or prose; a & or ? after a space starts no parameter) is rewritten too. Keep secrets out of results with {{secret:<KEY>}} (see Secrets) and by asking for focused reads.

Secrets

Secrets are plain files, one value per file, in the directory the plugin's /secret mod writes (hooks/mods/secrets.tsx): ~/.config/claude-secrets/<KEY> (file mode 0600, directory 0700; $HOME decides where ~ is). A KEY is letters, digits and _, not starting with a digit. One store serves cua on macOS and Linux, locally and through the remote agent, and any other tool that reads that directory.

The store has two tiers. That directory is the global one, for every project. Each project has its own beside it, ~/.config/claude-secrets/projects/<slug>/<KEY>, the slug being the project's path in the form Claude Code names its ~/.claude/projects/ folders (every character other than a letter or digit becomes -: /Users/me/repo is -Users-me-repo). The project is the main checkout of the git repository around the working directory, so every worktree of a repository shares its secrets, and outside a repository the directory itself. A key stored in both is read from the project's tier: the project's value shadows the global one. Device credentials (CUA_DEVICE_…, Remote control) live in the global tier only, and so does everything a device's remote agent serves: it has no project. Existing files stay where they are; nothing moves between tiers.

The preferred way to store one is /secret KEY in Claude Code (the plugin's mod, where function hooks are enabled): it opens a masked field, writes the file in the session's project tier (/secret -g KEY or --global: the global tier), and keeps the value out of the model's view of tool output. The model's system prompt lists the keys of both tiers, each marked (project) or (global), a global key the project shadows included. Without the mod, cua does the same at a terminal, on the global tier unless told --project:

node bin/cua.mjs secrets set WORK_PASSWORD             # typed twice at a masked prompt, nothing echoed
node bin/cua.mjs secrets set WORK_PASSWORD --project   # the same, for the project of this directory
node bin/cua.mjs secrets list                          # keys only, both tiers, each marked; --project or --global for one
node bin/cua.mjs secrets remove WORK_PASSWORD          # asks for confirmation; --yes skips it; --project for that tier

A value is only ever typed at a terminal: set refuses arguments, flags and piped input, and nothing prints or exports a value. secrets_list lists the keys of both tiers, once each: the names of the stores' regular files that follow the KEY rule, whatever their mode (a key whose file is not 0600 is listed, then refused with secret_insecure_mode when used). cua serve learns its project from its own working directory, which Claude Code sets to the session's. cua doctor reports the global store (secrets.store) and the working directory's project tier (secrets.project).

To have the agent enter a stored secret, authorize it to use the key; it then passes the exact reference {{secret:<KEY>}} as an input argument:

cua API callruntime commandexpanded field
app.paste(text) (text format)pastethe whole text
app.typeText(text)type_textthe whole text (on Linux also app.paste, which the vendor sends as type_text {window, text})
app.setValue(index, value)set_valuethe whole value
Chrome tab tab.playwright.<locator>.fill(value)playwright_locator_fillthe whole value
Chrome tab accessibility paste/type/set-value actiontab_ax_actionthe whole text (paste, type_text) or value (set_value)

The substitution happens inside the runtime's trusted service process (src/services/sky.mjs, and src/services/browser.mjs for Chrome), after the agent's code and the MCP call have passed: the trusted service reads the file (from the store directories cua serve resolved from its own $HOME and working directory, the project's first) and hands the value only to the native input command, never to the agent's code, the tool result or an error. Only an argument that is entirely one reference expands; text that merely contains {{secret:…}}, any other method or field, and JavaScript strings in general are left alone. A reference fails before anything is entered, with a value-free error code, when its key is invalid (invalid_secret_label) or unknown (secret_not_found), its file is not a regular file owned by you with mode 0600 (secret_not_regular_file, secret_wrong_owner, secret_insecure_mode: cua never reads such a file; chmod 600 it), is over 256 KiB or not UTF-8 text (secret_too_large, secret_unsupported_value), is empty (secret_empty: neither writer stores an empty value, so it is an interrupted write) or cannot be read (secret_unreadable), secrets are off (CUA_SHIM_SECRETS=off: secrets_disabled), or the command is not in its pinned shape (unsupported_secret_shape). Exactly one trailing newline is dropped from a file's contents, so a file written with echo works. If the native command fails after substitution, the error is a fixed diagnostic (secret_input_failed, with the runtime's error name when it is one of its fixed codes); the runtime's own message is withheld because it can contain the value, and the input may have been partly entered.

This is input substitution, not a vault around the value: once entered, a secret can be seen in screenshots, the app's accessibility text or the app itself, and paste uses the system clipboard as the runtime always does (it restores the previous contents; a clipboard manager may record it). typeText enters the value as keystrokes, so an ordinary text view's own substitutions (autocorrect, automatic capitalization, smart dashes) can change it, and even text typed before it, as they would for a person typing; password fields do not do this. Only authorize secrets for apps you would type them into yourself. In Chrome the same rules apply to the two browser rows above, only on OpenAI's pinned browser service version (unsupported_browser_runtime otherwise); script evaluation, CDP commands and every other browser command are never scanned. A failed locator.fill makes the vendor's client read back diagnostics of the matched elements (tag, role, type, label, text; not the input's value).

What the store does not protect against. The store is as private as your account: any process running as you can read it, and that includes the agent's JavaScript cells. Under every sandbox mode cells read everywhere your account can (the vendor's sandbox can deny a directory, but the same profile binds the trusted service that must read the value, so cua cannot deny the store to cells alone; measured, Decision Log 2026-10-07). This is the same exposure as Bash in Claude Code, where the mod's guard (not a sandbox) keeps the model from reading the directory; cua's host notes tell the agent never to read it. The guarantee is narrower and holds: the agent works with keys, and trusted code fills the value in.

cua doctor's secrets.store row reports the store from metadata only: blocked while nothing is stored, fail for a directory open to others or a key file cua would refuse (named by key), pass with the number of keys. Until issue #66 the store was the macOS login Keychain behind a Swift helper and a per-connection broker; both are gone, and Keychain items stored by older versions are not read (store them again with /secret or cua secrets set).

Chrome (browser surface)

With CUA_SHIM_SURFACES=computer,browser (or browser alone) in the server's environment, cua serve also hosts OpenAI's browser service, the agent-facing browser API pinned in the runtime. It reaches your running Chrome through cua's own Chrome extension, named "cua", and a native host cua runs itself, so the agent works in your real profiles: their cookies, signed-in sessions, settings and password manager stay where they are, and nothing is copied or migrated. This route needs no ChatGPT or OpenAI account and no Codex login. The default (computer) is unchanged: no browser API, no browser environment.

With the browser surface the model gets a fifth tool, profiles_list, whose description carries the Chrome rules: select only a profile profiles_list returned and never pick one for you, use Playwright locators for input, loop short waits past the 3 s browser action cap, treat page evaluation as read-only, give tab creation a long limit (see Operating guidance for agents). OpenAI's browser API is in the js description.

Setting it up, once per machine and once per Chrome profile:

# the cua extension in each Chrome profile you want to use (below), then:
node bin/cua.mjs chrome register                     # cua's native host, for this CUA_HOME
node bin/cua.mjs profiles add personal --chrome-profile Default
node bin/cua.mjs profiles bind personal              # Chrome open on that profile with the extension
node bin/cua.mjs doctor                              # chrome.* rows pass; codex.login reads skip (not needed)

Until the cua extension is listed on the Chrome Web Store, the route cua used before stays available: OpenAI's ChatGPT extension and its native host, which need a Codex login (see "ChatGPT extension route (until removal)"); a later version removes it. A CUA_HOME uses one route at a time, whichever cua chrome register ran last; a home where neither ran is on the ChatGPT extension route, so run cua chrome register once.

The cua extension

  • From the Chrome Web Store: not listed yet; the link goes here once it is.
  • Until then, load it unpacked: in each Chrome profile you want to use, open chrome://extensions, turn on Developer mode, click "Load unpacked" and choose the extension directory of your cua checkout. Chrome keeps loading it from there, so use a checkout at a stable path (not the plugin's cache, which every plugin update replaces), and after an update that changes extension/, click the extension's reload button on that page. Branded Google Chrome 137 and later ignore --load-extension, so this is a step in Chrome's own UI, once per profile.
  • On a Linux VM: the template in deploy/cloud-vm/ force-installs it by policy, from a self-hosted CRX until the Store listing exists, then from the Store.

Every form has the same id, jkejaaijdfpohkdhankllbekkhmnippb (the manifest carries the public key), so registration, binding and doctor do not care which one is installed. It asks for debugger, nativeMessaging, tabs, tabGroups, storage and alarms and has no host permissions. It adds no scripts to web pages; the agent acts in a tab only through Chrome's debugger, while Chrome

Source 2 files
hooks/mods/register.tsx 13 lines
1import type { On } from 'claude-code'
2
3import { registerSecrets } from './secrets'
4
5/**
6 * The plugin's one hooks module. The engine loads it only where function
7 * hooks are enabled (CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1); the shell hook
8 * beside this folder (cua-approve.sh) runs everywhere.
9 */
10export function register(on: On) {
11  registerSecrets(on)
12}
13
hooks/mods/secrets.tsx 441 lines
1import type { EngineInterface, Hook, On } from 'claude-code'
2
3/**
4 * `/secret KEY` opens a field in a pane; the value typed there is drawn as
5 * bullets, stored as `~/.config/claude-secrets/projects/<slug>/KEY` for the
6 * session's project (`/secret -g KEY`: `~/.config/claude-secrets/KEY`, for
7 * every project; mode 600), and the model sees only the key. The value never passes through the prompt box, so neither
8 * the prompt history nor the transcript can hold it; a stored value that turns
9 * up in a tool's output reaches neither the model nor the transcript file.
10 *
11 * Not the prompt box: when the last keys and Enter arrive in one read (mosh,
12 * a slow link), the editor inserts and submits them before a `prompt.edit`
13 * answer lands, so they reach the history raw. The box still masks a value
14 * typed there out of habit, and the command refuses it.
15 */
16
17// A file per key, not the Keychain: a session under ssh, mosh or a daemon
18// finds the login keychain locked and cannot raise its unlock dialog.
19export const DIR = '.config/claude-secrets'
20export const MASK = '•'
21// `/secret KEY ` or `/secret KEY=`, `-g` or `--global` before the key or not:
22// the value starts after the one character that ends the key.
23const PREFIX = /^\/secret[ \t]+(?:(?:-g|--global)[ \t]+)?([A-Za-z_][A-Za-z0-9_]*)[ \t=]/
24const FLAG = /^(?:(-g|--global)(?:[ \t]+|$))?([\s\S]*)$/
25const ARGS = /^([A-Za-z_][A-Za-z0-9_]*)[ \t=]([\s\S]*)$/
26// The echo of a `/secret` run as the conversation keeps it.
27const ECHO = /(<command-name>\/secret<\/command-name>[\s\S]*?<command-args>[ \t]*(?:(?:-g|--global)[ \t]+)?[A-Za-z_][A-Za-z0-9_]*[ \t=])([\s\S]*?)(<\/command-args>)/g
28// Device credentials (cua's reserved keys, src/secrets/label.mjs) are read
29// from the global tier only, so they are stored there whatever the project.
30const RESERVED = /^CUA_DEVICE_/i
31// The global tier's folder of the project tiers, so never a key (in any case:
32// macOS's file system would open PROJECTS for projects).
33const PROJECTS = /^projects$/i
34// Claude Code cuts a slug at this length and adds a hash of the whole path.
35const SLUG_MAX = 200
36// Shorter values would scrub ordinary words out of everything the model reads.
37const SCRUB_MIN = 6
38
39const PANE = 'secret'
40const FIELD = 'secret-value'
41
42type Scope = 'project' | 'global'
43type Pending = { key: string; scope: Scope }
44type Listed = { key: string; scope: Scope; shadowed: boolean }
45
46// What is typed lives only in this module's memory, never in `$.state` (which
47// every plugin can read): `drafted` behind the prompt box's bullets, `typed`
48// behind the field's, for the key and tier in `pending`.
49let drafted = ''
50let typed = ''
51let pending: Pending | undefined
52// The session's project root, once resolved.
53let project: string | undefined
54// Every stored secret of both tiers, by `<scope>/<key>`, so that no row the
55// model reads carries one.
56const known = new Map<string, { key: string; scope: Scope; value: string }>()
57
58type Edit = { text: string; start: number; end: number; inputText: string }
59type Masked = { held: string; box?: { text: string; cursor: number } }
60
61/**
62 * One edit of the prompt box, given the value held so far. While the draft
63 * reads `/secret KEY ` (or `KEY=`), what follows is kept in `held` and the box
64 * shows one bullet per character; `box` absent means the edit is not ours.
65 */
66export function maskEdit(held: string, e: Edit): Masked {
67  const before = PREFIX.exec(e.text)?.[0].length
68  const isMasked = before !== undefined && e.text.slice(before) === MASK.repeat(held.length)
69  const cursor = e.start + e.inputText.length
70
71  if (isMasked && e.start < before) {
72    // An edit to `/secret KEY ` itself drops the value, so a moved boundary
73    // between key and value can never show part of it.
74    const prefix = e.text.slice(0, before)
75    return { held: '', box: { text: prefix.slice(0, e.start) + e.inputText + prefix.slice(Math.min(e.end, before)), cursor } }
76  }
77
78  const real = isMasked ? e.text.slice(0, before) + held : e.text
79  const spliced = real.slice(0, e.start) + e.inputText + real.slice(e.end)
80  const after = PREFIX.exec(spliced)?.[0].length
81  if (after === undefined) return { held: '' }
82  const value = spliced.slice(after)
83  return { held: value, box: { text: spliced.slice(0, after) + MASK.repeat(value.length), cursor } }
84}
85
86/**
87 * The field's text read back against what it held: bullets for the characters
88 * kept, then whatever was typed since the last redraw (several characters when
89 * they arrived together, Enter among them). Undefined for an edit inside the
90 * bullets, which cannot be placed.
91 */
92export function readField(held: string, value: string): string | undefined {
93  const kept = value.length - value.replace(/^•+/, '').length
94  const added = value.slice(kept)
95  if (kept > held.length || added.includes(MASK)) return undefined
96  return held.slice(0, kept) + added
97}
98
99/** A `/secret` echo with its value masked, for one typed where nothing masked it. */
100export function maskEcho(text: string): string {
101  return text.replace(ECHO, (_, head: string, value: string, tail: string) => head + MASK.repeat(value.length) + tail)
102}
103
104// The value and the encodings a command prints it in by accident: base64
105// (padded or not), hex either case, URL encoding. A slice or a transformation
106// of its own making is beyond this; the guard below keeps the store unread.
107function spellings(value: string): string[] {
108  const bytes = Array.from(new TextEncoder().encode(value), (b) => b.toString(16).padStart(2, '0')).join('')
109  const base64 = btoa(String.fromCharCode(...new TextEncoder().encode(value)))
110  return [...new Set([value, base64, base64.replace(/=+$/, ''), bytes, bytes.toUpperCase(), encodeURIComponent(value)])]
111}
112
113function scrub(text: string): string {
114  let out = maskEcho(text)
115  for (const { key, value } of known.values()) {
116    if (value.length < SCRUB_MIN) continue
117    for (const spelling of spellings(value)) out = out.split(spelling).join(`[secret:${key}]`)
118  }
119  return out
120}
121
122// The one form a command may name a stored value in: substituted where it is
123// used, so the command's own output is all that could carry it. It admits the
124// global tier and, given the session's project slug, that project's tier
125// only: another project's values are not loaded for scrubbing, so a command
126// naming its folder is refused like any other read of the store.
127export function substitutionFor(slug?: string): RegExp {
128  const tier = slug === undefined ? '' : `(?:projects\\/${slug.replace(/[^A-Za-z0-9-]/g, '')}\\/)?`
129  return new RegExp(
130    `\\$\\(\\s*cat\\s+(?:~|"?\\$HOME"?|"?\\$\\{HOME\\}"?|\\/(?:Users|home)\\/[^/\\s"')]+)\\/\\.config\\/claude-secrets\\/${tier}[A-Za-z_][A-Za-z0-9_]*\\s*\\)`,
131    'g',
132  )
133}
134// The session's form, rebuilt once its project is resolved.
135let substitution = substitutionFor()
136
137/**
138 * Why a tool call would read the store itself, or undefined: a command naming
139 * the folder (the projects' folders beneath it included) other than in the
140 * substitution form (`form`, the session's: its own project's folder and the
141 * global one), or a file tool whose path
142 * (a Glob's pattern) points into it. What a Write or an Edit puts in a file,
143 * or what a Grep searches for, is not a read of the store.
144 */
145export function guardReason(input: Readonly<Record<string, unknown>>, form: RegExp = substitution): string | undefined {
146  const reads =
147    typeof input.command === 'string'
148      ? input.command.replace(form, '').includes('.config/claude-secrets')
149      : Object.entries(input).some(
150          ([field, value]) =>
151            (/path$/i.test(field) || (input.tool === 'Glob' && field === 'pattern')) &&
152            typeof value === 'string' &&
153            value.includes('claude-secrets'),
154        )
155  if (!reads) return undefined
156  return (
157    'secrets: the stored values are not for reading. Use one only inside the command that needs it, ' +
158    'as "$(cat ~/.config/claude-secrets/KEY)"; the stored keys are listed in the system prompt.'
159  )
160}
161
162// A tool's record, every string in it scrubbed; the same object when none changed.
163function scrubRecord(value: unknown): unknown {
164  if (typeof value === 'string') return scrub(value)
165  if (Array.isArray(value)) {
166    const items = value.map(scrubRecord)
167    return items.some((item, i) => item !== value[i]) ? items : value
168  }
169  if (value !== null && typeof value === 'object') {
170    const entries = Object.entries(value).map(([k, v]) => [k, scrubRecord(v)] as const)
171    return entries.some(([k, v]) => v !== (value as Record<string, unknown>)[k]) ? Object.fromEntries(entries) : value
172  }
173  return value
174}
175
176/**
177 * A project's path as Claude Code names its folder under ~/.claude/projects/:
178 * every character other than an ASCII letter or digit becomes '-', and a slug
179 * longer than 200 characters keeps its first 200 and gains '-' and a base-36
180 * hash of the path. cua's store (src/secrets/store.mjs) carries the same.
181 */
182export function projectSlug(path: string): string {
183  const slug = path.replace(/[^a-zA-Z0-9]/g, '-')
184  if (slug.length <= SLUG_MAX) return slug
185  let hash = 0
186  for (let i = 0; i < path.length; i++) hash = ((hash << 5) - hash + path.charCodeAt(i)) | 0
187  return `${slug.slice(0, SLUG_MAX)}-${Math.abs(hash).toString(36)}`
188}
189
190/**
191 * The project root from `git rev-parse --path-format=absolute
192 * --git-common-dir --show-toplevel`'s two lines: the main checkout's root
193 * (the parent of its `.git`, which every linked worktree shares), or the
194 * repository's top level where the common directory is no `.git` folder (a
195 * submodule's). cua's src/secrets/project.mjs resolves it the same way.
196 */
197export function rootFromGit(commonDir: string, topLevel: string): string {
198  return /\/\.git$/.test(commonDir) ? commonDir.slice(0, -'/.git'.length) || '/' : topLevel
199}
200
201// The session's project: the main worktree root of the git repository around
202// its directory, else that directory. Resolved once, at session start.
203async function projectOf($: EngineInterface, cwd?: string): Promise<string> {
204  if (project !== undefined) return project
205  const dir = cwd ?? (await $.session.cwd())
206  let root = dir
207  try {
208    const git = await $.process.run(['git', 'rev-parse', '--path-format=absolute', '--git-common-dir', '--show-toplevel'], { cwd: dir, timeoutMs: 5000 })
209    const [commonDir, topLevel] = git.stdout.trim().split('\n')
210    // A git older than 2.31 echoes `--path-format=absolute` back as a line of
211    // its own; only two absolute paths are an answer.
212    if (git.exitCode === 0 && commonDir?.startsWith('/') && topLevel?.startsWith('/')) root = rootFromGit(commonDir, topLevel)
213  } catch {}
214  project = root
215  substitution = substitutionFor(projectSlug(root))
216  return root
217}
218
219// The tier's folder, relative to $HOME.
220async function tierDir($: EngineInterface, scope: Scope): Promise<string> {
221  return scope === 'global' ? DIR : `${DIR}/projects/${projectSlug(await projectOf($))}`
222}
223
224async function usage($: EngineInterface, key: string, scope: Scope): Promise<string> {
225  return `"$(cat ~/${await tierDir($, scope)}/${key})"`
226}
227
228const TIER = { project: 'for this project', global: 'for every project (global)' }
229
230async function storeField($: EngineInterface, { key, scope }: Pending, value: string) {
231  if (value === '') return
232  // The bullets hide a mistyped character, an input method left on.
233  if (/[^\x20-\x7e]/.test(value)) {
234    typed = ''
235    $.ui.toast('secret: a character outside printable ASCII (an input method on?); type it again')
236    return
237  }
238  // The value goes in on stdin, so no process's arguments carry it, and is
239  // created under umask 077 (`$.fs.write` sets no mode).
240  const dir = `${await $.env.get('HOME')}/${await tierDir($, scope)}`
241  const added = await $.process.run(
242    ['/bin/sh', '-c', 'umask 077 && mkdir -p "$1" && chmod 700 "$1" && cat > "$1/$2" && chmod 600 "$1/$2"', 'sh', dir, key],
243    { stdin: value },
244  )
245  if (added.exitCode !== 0) {
246    $.ui.toast(`secret: not stored, ${added.stderr.trim().split('\n')[0] || 'the write failed'}`)
247    return
248  }
249  known.set(`${scope}/${key}`, { key, scope, value })
250  typed = ''
251  pending = undefined
252  await $.ui.close({ id: PANE })
253  $.ui.toast(`secret: stored ${key} (${scope})`)
254  await $.session.append({
255    message: {
256      type: 'user',
257      content: [
258        {
259          type: 'text',
260          text: `The person stored a secret under ${key}, ${TIER[scope]}. You never see its value. In a shell command, read it as ${await usage($, key, scope)}, never printing it.`,
261        },
262      ],
263    },
264  })
265}
266
267// The stored keys, sorted by key with the project's first; a global key the
268// project's of the same name shadows is marked so.
269function listed(): Listed[] {
270  return [...known.values()]
271    .map(({ key, scope }) => ({ key, scope, shadowed: scope === 'global' && known.has(`project/${key}`) }))
272    .sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : a.scope === 'project' ? -1 : 1))
273}
274
275function describe({ key, scope, shadowed }: Listed): string {
276  return `${key} (${scope}${shadowed ? ", shadowed by the project's" : ''})`
277}
278
279// The tier's folder as the pane shows it, the project resolved at session start.
280function shownDir(scope: Scope): string {
281  return scope === 'global' || project === undefined ? DIR : `${DIR}/projects/${projectSlug(project)}`
282}
283
284// Taken with both matchers below: the other mods register session.start too,
285// and an event is registered without one once per module.
286const secretsStart: Hook<'session.start'> = async ($, e, next) => {
287  await $.command.register({
288    name: 'secret',
289    description: 'Store a secret for this project (-g: for every project) that the model sees only by its key; the value goes in the field it opens',
290    argumentHint: '[-g] KEY',
291  })
292  project = undefined
293  known.clear()
294  await projectOf($, e.cwd)
295  const home = await $.env.get('HOME')
296  for (const scope of ['project', 'global'] as const) {
297    const dir = `${home}/${await tierDir($, scope)}`
298    if (!(await $.fs.exists(dir))) continue
299    for (const entry of await $.fs.list(dir)) {
300      if (entry.kind === 'file' && /^[A-Za-z_][A-Za-z0-9_]*$/.test(entry.name)) {
301        known.set(`${scope}/${entry.name}`, { key: entry.name, scope, value: await $.fs.read(`${dir}/${entry.name}`) })
302      }
303    }
304  }
305  return next(e)
306}
307
308export function registerSecrets(on: On) {
309  on('session.start', { isInteractive: true }, secretsStart)
310  on('session.start', { isInteractive: false }, secretsStart)
311
312  // Masks a value typed into the box out of habit; the command refuses it.
313  on('prompt.edit', async (_, e, next) => {
314    const masked = maskEdit(drafted, e)
315    drafted = masked.held
316    return masked.box ?? next(e)
317  })
318
319  on('command.run', { command: 'secret' }, async ($, e) => {
320    const [, flag, rest = ''] = FLAG.exec(e.args.trim()) ?? []
321    const args = rest.trim()
322    if (args === '' && !flag) {
323      return { text: known.size ? `Stored: ${listed().map(describe).join(', ')}` : 'No secrets stored.' }
324    }
325    const parsed = ARGS.exec(args)
326    drafted = ''
327    if (parsed) {
328      return {
329        text:
330          'Not stored: type `/secret KEY` alone and the value in the field it opens. ' +
331          'A value typed in the prompt box may be in the prompt history (~/.claude/history.jsonl), whole or in part.',
332      }
333    }
334    if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(args)) return { text: 'Usage: /secret [-g|--global] KEY (letters, digits and _)' }
335    if (PROJECTS.test(args)) return { text: 'Not stored: "projects" is the folder that holds the projects\' secrets, so it is not a key.' }
336    const isDevice = !flag && RESERVED.test(args)
337    pending = { key: args, scope: flag || isDevice ? 'global' : 'project' }
338    typed = ''
339    await projectOf($)
340    await $.ui.open({ id: PANE, title: `secret ${args} (${pending.scope})`, focus: true, closeOnEscape: true, rows: 2 })
341    const where = isDevice
342      ? 'for every project, as device credentials are'
343      : pending.scope === 'project'
344        ? `${TIER.project} (/secret -g ${args} stores it for every project)`
345        : TIER.global
346    return { text: `Type the value of ${args} in the field of the pane it opened; it is stored ${where}. Enter stores it, Escape cancels.` }
347  })
348
349  on('ui.render', { component: 'Pane', requestId: PANE }, ($, e) => {
350    if (e.surface === 'mobile') {
351      const { Text } = $.ui.resolve(e)
352      return <Text>The mobile app draws no field: type the value from a terminal, the desktop app or VS Code.</Text>
353    }
354    const { Box, Input, Text } = $.ui.resolve(e)
355    return (
356      <Box flexDirection="column">
357        <Input
358          key={FIELD}
359          label={`${pending?.key ?? ''} `}
360          value={MASK.repeat(typed.length)}
361          placeholder="value"
362          submitLabel="store"
363          autoFocus
364          onSubmit={() => {}}
365        />
366        <Text dimColor>Escape cancels. Stored in ~/{pending ? `${shownDir(pending.scope)}/${pending.key}` : DIR}, mode 600.</Text>
367      </Box>
368    )
369  })
370
371  // Taken here rather than in the element's closures, which have no `$`.
372  on('ui.input', { element: FIELD }, async ($, e) => {
373    const value = readField(typed, e.value)
374    typed = value ?? ''
375    if (value === undefined) $.ui.toast('secret: edit at the end of the field; it was cleared')
376    $.ui.invalidate('ui.render')
377    if (e.kind === 'submit' && value !== undefined && pending !== undefined) await storeField($, pending, value)
378    return { element: e.element, value: MASK.repeat(typed.length) }
379  })
380
381  on('ui.close', { id: PANE }, (_, e, next) => {
382    typed = ''
383    pending = undefined
384    return next(e)
385  })
386
387  // The tool's own record is what the transcript file keeps beside the row the
388  // model reads (`toolUseResult`), and `session.append` cannot reach it.
389  on('tool.call', async (_, e, next) => {
390    const reason = guardReason(e)
391    if (reason !== undefined) return { deny: reason }
392    const ran = await next(e)
393    if (ran.deny !== undefined || known.size === 0) return ran
394    const result = scrubRecord(ran.result)
395    if (result === ran.result) return ran
396    return ran.isError ? { deny: scrub(ran.text ?? '') } : { result, context: ran.context }
397  }).catch((_, e, next) => (next.called ? next(e) : { deny: 'secrets: its guard failed, so the call did not run.' }))
398
399  // The last line of defence: a stored value that turns up in any row the
400  // conversation keeps (a tool's output, an echo) is replaced by its key.
401  on('session.append', async (_, e, next) => {
402    let isChanged = false
403    const content = e.message.content.map((block) => {
404      if (block.type === 'text' && typeof block.text === 'string') {
405        const text = scrub(block.text)
406        if (text === block.text) return block
407        isChanged = true
408        return { ...block, text }
409      }
410      if (block.type === 'tool_result') {
411        const inner = scrubRecord(block.content)
412        if (inner === block.content) return block
413        isChanged = true
414        return { ...block, content: inner }
415      }
416      return block
417    })
418    return isChanged ? next({ ...e, message: { ...e.message, content } }) : next(e)
419  })
420
421  on('prompt.compose', async ($, e, next) => {
422    const composed = await next(e)
423    if (known.size === 0) return composed
424    const lines = await Promise.all(listed().map(async (s) => `- ${describe(s)}: ${await usage($, s.key, s.scope)}`))
425    return {
426      sections: [
427        ...composed.sections,
428        {
429          id: 'cua:secrets',
430          scope: 'session',
431          text:
432            'Secrets the person stored, one file each, for this project or for every project (global). ' +
433            'You see only their keys; read a value inside the shell command that uses it, never printing it. ' +
434            "Where a key is stored in both, use the project's:\n" +
435            lines.join('\n'),
436        },
437      ],
438    }
439  })
440}
441