From 9b4ee7b62367cbcd60bab9063768922cd8fd27e2 Mon Sep 17 00:00:00 2001 From: NNIIOVO Date: Mon, 9 Nov 2026 19:30:48 -0600 Subject: [PATCH] feat(agent route): answer only Claude Code and Codex (ADR-0013 step 3) The agent key route now answers, inside the agent account, only the agent's tool with the code hash the owner approved in Settings, started by Claude Code or Codex as validated by macOS (Developer ID, teams Q6L2SF6YDW and 2DC432GLL2). Both must have hardened runtime, be neither debuggable nor debugged, and name neither allow-dyld-environment- variables nor get-task-allow in their entitlements. Claude Code's disabled library validation is accepted, as the owner decided. Everything is read from the kernel's record of the running programs, never from their files, since the ADR-0013 spike showed the Security framework's checks are refused under the App Sandbox while these work: csops_audittoken through the connection's audit token for the tool, sysctl(KERN_PROC_PID) for its parent (kinfo_proc size and fields checked; proc_pidinfo is refused for other accounts) and csops for the parent, then a re-check that the tool and its parent are unchanged. Anything else is closed without a reply, as a wrong account is. - agent_route.rs: ProgramIdentity, ProgramPolicy, judge_programs on every platform; check_program and the kernel readers on a Mac; the listener runs the check after the account check. ProgramCheck::Off exists only in debug builds (ENOTE_DEV_AGENT_ANY_PROGRAM, tests). - Settings: "The agent tool's code hash" (the CDHash line), kept in settings.json; Start refuses without it; changing it ends a grant. The Start dialog says Claude Code or Codex. - agent-keys-setup.sh prints the tool's code hash and refuses a tool not signed with hardened runtime; it no longer prints the Claude desktop app's chat config, which isn't on the list. - Tests: every branch of the decision; the kernel readers against a running /bin/sleep; on a real socket, Apple's nc as the tool and bash as its starter, answered when both are listed and closed without a reply for the real list, a wrong tool hash, or a direct connection. - Docs: ADR-0013 As built, the guide, the security model, AGENTS.md, CONTEXT.md. Fictional data and stand-in programs only; not yet run from Claude Code or Codex in a real agent account, nor in a Developer ID-signed build. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 2 +- CONTEXT.md | 2 +- crates/agent-keys-mcp/src/lib.rs | 2 +- ...ude-code-and-codex-use-the-agent-routes.md | 26 +- docs/agent-keys.md | 68 ++- docs/security-model.md | 2 +- scripts/agent-keys-setup.sh | 42 +- src-tauri/build.rs | 1 + src-tauri/capabilities/main.json | 1 + src-tauri/src/agent_route.rs | 405 ++++++++++++- src-tauri/src/commands.rs | 46 +- src-tauri/src/core.rs | 4 + src-tauri/src/core/agent.rs | 59 ++ src-tauri/src/lib.rs | 1 + src-tauri/tests/agent_route.rs | 530 +++++++++++++++++- src/Settings.tsx | 46 +- src/api.ts | 5 + src/dev-fixture.ts | 4 + src/strings.ts | 7 +- 19 files changed, 1187 insertions(+), 66 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index d78f247..d72bf51 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,7 +11,7 @@ A personal app for storing secrets, encrypted and saved only on this computer. - Never add network access to the Vault core or WebView: no HTTP clients, updaters, telemetry, crash reporters or analytics (ADR-0001). The only approved exception is the optional Agent Companion helper (ADR-0009): a separately sandboxed XPC service that makes only fixed operations the owner has approved. Today there is one, the companion's OpenAI request; ADR-0014 (not built) lets the owner add more, one at a time, each with its own record, Agent Key and PR. Never widen it beyond an approved operation, give it Vault access, or add networking anywhere else. Its source is in `companion/`: never import that folder from `src/`, `src-tauri/src/` or `crates/` (ADR-0010). - Keys and decrypted Notes live only in the Rust core; the WebView receives one Hidden Field at a time (ADR-0003). The Agent Companion helper may hold separately created Agent Keys, one per approved operation, never a Vault value (ADR-0009, ADR-0014). - Never pass Vault content, Note metadata, Note keys, Wallet Kinds, the Master Password or the Recovery Key to the Agent Companion, and never forward helper output, provider errors, keys or tokens to the WebView (ADR-0009). -- The agent key route (ADR-0012; built with fictional data, not yet checked by hand or reviewed) lets an agent in the separate agent account store keys and manage the Notes it added, and nothing more. Its code: `src-tauri/src/core/agent.rs` (the grant and the five requests), `src-tauri/src/agent_route.rs` (the Unix socket in `/Users/Shared/encrypted-note-keys`, the app's only listener), `crates/agent-keys-mcp` (the agent's tool), and the mark and edit rules in `crates/notes/src/agent.rs`. Keep it to the five requests (ten once ADR-0011's Chat Wallet requests are built), from the agent account's uid only, behind the owner's grant of at most an hour. No request field or reply may carry a value, whether a value is set, a Note not marked "added by an agent", a Note in Trash, the Master Password or the Recovery Key; replies stay fixed shapes and error codes, never free text. The owner types values into the app's own editor, never through the agent, and nothing marks an existing Note. Don't loosen `agent_edit_rule`: where-a-key-is-used edits need the owner's Allow, and every Wallet Kind edit and every delete also the Master Password. Unix sockets and peer-uid checks belong only in `agent_route.rs` and `crates/agent-keys-mcp/src/`, the tool depends on no Vault crate, and none of this code uses `companion/` (`node scripts/check-network-source.mjs` checks all three). Signing comes only with Chat Wallets (ADR-0011, reinstated 2026-11-09, not built): build it only by its build order, one PR per step, with fake keys and test funds, and never sign with a Seed Phrase, a Private Key or an Agent-added Note. Only Claude Code and Codex may use the route (ADR-0013, not built): their signature list stays fixed in code, never in anything the agent account can write. +- The agent key route (ADR-0012; built with fictional data, not yet checked by hand or reviewed) lets an agent in the separate agent account store keys and manage the Notes it added, and nothing more. Its code: `src-tauri/src/core/agent.rs` (the grant and the five requests), `src-tauri/src/agent_route.rs` (the Unix socket in `/Users/Shared/encrypted-note-keys`, the app's only listener), `crates/agent-keys-mcp` (the agent's tool), and the mark and edit rules in `crates/notes/src/agent.rs`. Keep it to the five requests (ten once ADR-0011's Chat Wallet requests are built), from the agent account's uid only, behind the owner's grant of at most an hour. No request field or reply may carry a value, whether a value is set, a Note not marked "added by an agent", a Note in Trash, the Master Password or the Recovery Key; replies stay fixed shapes and error codes, never free text. The owner types values into the app's own editor, never through the agent, and nothing marks an existing Note. Don't loosen `agent_edit_rule`: where-a-key-is-used edits need the owner's Allow, and every Wallet Kind edit and every delete also the Master Password. Unix sockets and peer-uid checks belong only in `agent_route.rs` and `crates/agent-keys-mcp/src/`, the tool depends on no Vault crate, and none of this code uses `companion/` (`node scripts/check-network-source.mjs` checks all three). Signing comes only with Chat Wallets (ADR-0011, reinstated 2026-11-09, not built): build it only by its build order, one PR per step, with fake keys and test funds, and never sign with a Seed Phrase, a Private Key or an Agent-added Note. Only Claude Code and Codex may use the route (ADR-0013; built with fictional data, not yet checked by hand): `judge_programs` and `check_program` in `agent_route.rs` let in only the tool with the owner's approved code hash, started by an `ALLOWED_STARTERS` entry validated as Developer ID, both with hardened runtime. Keep that list fixed in code, never in anything the agent account can write; read programs only through the kernel (`csops_audittoken`, `csops`, `sysctl`), never their files; and keep `ProgramCheck::Off` debug-only. - Add a dependency only when it's clearly needed, pin its exact version, and say why in the PR/issue. ## Verify diff --git a/CONTEXT.md b/CONTEXT.md index e13dbc2..83e03ae 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -138,7 +138,7 @@ _Avoid_: pinned, bookmarked, starred item - The owner has one **Vault**; it moves between computers only when the owner copies the file by hand. - The app gives AI agents no interface to the **Vault**, its **Notes**, their metadata, **Backups**, **Safety Copies** or the **Emergency Reader**, and agents are told never to use them. (The one exception is **Agent-added Notes**, through a local route only the separate agent account can use while the owner's grant runs: their names and visible details, never their values.) That limits the app, not the computer: what keeps an agent running in the owner's own Mac account away from these files is the owner's setup. The **Master Password** and **Recovery Key** never take part in agent work, and a **Wallet Kind**'s value never does: an agent may only ask to store one, which the owner types in with the **Master Password**, and, for one it added, ask to edit its visible details (the owner's Allow, then the **Master Password**), replace its value (typed by the owner, with the **Master Password**) or delete it (the **Master Password**) ([ADR-0012](docs/adr/0012-agents-store-and-manage-keys.md)). - The **Agent Companion** is the only **Agent Key** integration inside the app; it runs apart from the **Vault** in its own sandbox and never receives Vault content. Any other **Agent Key** integration is separate from this app. Hiding the key still lets an agent perform every operation its provider scope permits, within its enforced limits; only the owner can independently approve scope or limit changes ([ADR-0007](docs/adr/0007-agents-use-separate-restricted-keys.md)). -- Only Claude Code and Codex, started in the separate agent account, may use the agent routes; the app checks their Apple code signatures ([ADR-0013](docs/adr/0013-only-claude-code-and-codex-use-the-agent-routes.md), not built). That check is a speed bump: the separate account is the boundary, so it holds only those two. +- Only Claude Code and Codex, started in the separate agent account, may use the agent routes; the app checks, through the kernel, that the approved tool was started by one of them, signed by Anthropic or OpenAI ([ADR-0013](docs/adr/0013-only-claude-code-and-codex-use-the-agent-routes.md); built on the agent key route with fictional data, not yet checked by hand). That check is a speed bump: the separate account is the boundary, so it holds only those two. - Results from an external agent's allowed service actions may leave this computer, including for a cloud model provider; no Vault content or metadata may enter that route. - Moving a **Vault** to the other computer means opening a **Backup** there. There is no separate "export" or "sync" concept. - The app nudges the owner when changes have gone 7+ days without a **Backup**, and warns if a **Backup** is saved into a cloud-synced folder. diff --git a/crates/agent-keys-mcp/src/lib.rs b/crates/agent-keys-mcp/src/lib.rs index aa77714..a87efb8 100644 --- a/crates/agent-keys-mcp/src/lib.rs +++ b/crates/agent-keys-mcp/src/lib.rs @@ -1,4 +1,4 @@ -//! `enote-agent-keys`: the MCP tool an agent (Claude Code, the Claude app, Codex) runs in its own +//! `enote-agent-keys`: the MCP tool an agent (Claude Code or Codex, ADR-0013) runs in its own //! Mac account to ask the encrypted-note app to store and look after keys (ADR-0012). //! //! What it guarantees: diff --git a/docs/adr/0013-only-claude-code-and-codex-use-the-agent-routes.md b/docs/adr/0013-only-claude-code-and-codex-use-the-agent-routes.md index 9020e88..d6e4c56 100644 --- a/docs/adr/0013-only-claude-code-and-codex-use-the-agent-routes.md +++ b/docs/adr/0013-only-claude-code-and-codex-use-the-agent-routes.md @@ -1,6 +1,6 @@ # Only Claude Code and Codex may use the agent routes -Status: Accepted by the owner 2026-11-09 (merged in PR #111). **Spike done 2026-11-09** (see [Spike result](#spike-result-2026-11-09)); the owner then decided the two questions it raised. **The check itself is not built.** +Status: Accepted by the owner 2026-11-09 (merged in PR #111). **Spike done 2026-11-09** (see [Spike result](#spike-result-2026-11-09)); the owner then decided the two questions it raised. **Step 3 built 2026-11-09** on the ADR-0012 route, checked with fictional data and stand-in programs only (see [As built](#as-built-2026-11-09)). Steps 4 and 5 are still to do. Amends [ADR-0012](0012-agents-store-and-manage-keys.md) ("It accepts connections only from the agent account"). Applies equally to the Chat Wallet requests ([ADR-0011](0011-chat-wallets.md)) and to the Agent Companion's agent entry ([ADR-0014](0014-agent-key-operations-through-the-companion.md)). @@ -77,6 +77,30 @@ A throwaway Swift program, signed ad-hoc with this app's exact `Entitlements.pli - **Works:** adding one `temporary-exception.sbpl` rule allowing `network-bind` and `network-inbound` on Unix sockets in that folder only. The owner approved that rule on 2026-11-09 ([ADR-0012](0012-agents-store-and-manage-keys.md), As built). - **Still to confirm:** the rule in a Developer ID-signed, notarized build. +## As built (2026-11-09) + +- **Where it lives.** In `src-tauri/src/agent_route.rs`: + - `judge_programs` decides from what the kernel said, on every platform; + - on a Mac, `check_program` gathers it for each connection, after the account check. +- **What it reads.** + - **The tool:** read through the connection's audit token (`csops_audittoken`): code hash, signature status, identity, team, validation category and entitlements. + - **Its starter:** found by process number from `sysctl` (`kinfo_proc`, whose size and fields are checked), and read by `csops`. + - **Afterwards:** the tool must still answer through its token, with the same starter, or it is refused. +- **What must match.** + - **The tool's code hash:** the one approved in Settings. + - **The starter:** Claude Code or Codex, by identity, team and validation category "Developer ID". + - **Both programs:** hardened runtime, not debuggable or debugged, and neither `allow-dyld-environment-variables` nor `get-task-allow` named in their entitlements. +- **The tool's code hash.** The owner pastes it into Settings (**The agent tool's code hash**), and it is kept in `settings.json`. + - **Where it comes from:** `scripts/agent-keys-setup.sh` prints it, and refuses a tool not signed with hardened runtime. + - **Without it, Start refuses** ("not_set_up"). + - **Changing it** ends a running grant. +- **The Start dialog** now says that Claude Code or Codex in that Mac user can ask. +- **Developers only:** `ENOTE_DEV_AGENT_ANY_PROGRAM` turns the check off in debug builds, beside the existing `ENOTE_DEV_AGENT_UID`. Release builds have no way to turn it off. +- **Tests** (`src-tauri/tests/agent_route.rs`): + - every branch of the decision; + - the kernel readers against a running `/bin/sleep`; + - on a real socket, Apple's `nc` as the tool and `bash` as its starter: answered when both are on the list; closed without a reply for the real list, a wrong tool hash, and a program connecting by itself. + ## Considered Options - **The agent account alone (as built):** the owner asked for more. diff --git a/docs/agent-keys.md b/docs/agent-keys.md index 6bdcb0f..82c6a1b 100644 --- a/docs/agent-keys.md +++ b/docs/agent-keys.md @@ -2,7 +2,7 @@ > **Don't put real keys in through an agent yet.** > -> **Status:** built 2026-11-06 and checked with fictional data only, in automated tests and in the screens' fixture. Nobody has yet run it on a real Mac with a real separate agent account, used it from Claude Code, Codex or the Claude app, or seen its native panels and its window coming to the front. The fake-key drill and the security review that [ADR-0012](adr/0012-agents-store-and-manage-keys.md#build-order) asks for before real keys (steps 5 and 6) are still to do. Mac only. Each step marked **Owner** is yours to do; nothing in the app does it for you. +> **Status:** built 2026-11-06 and checked with fictional data only, in automated tests and in the screens' fixture. The check that only Claude Code and Codex are answered ([ADR-0013](adr/0013-only-claude-code-and-codex-use-the-agent-routes.md)) was added 2026-11-09, also checked with fictional data and stand-in programs only. Nobody has yet run it on a real Mac with a real separate agent account, used it from Claude Code, Codex or the Claude app, or seen its native panels and its window coming to the front. The fake-key drill and the security review that [ADR-0012](adr/0012-agents-store-and-manage-keys.md#build-order) asks for before real keys (steps 5 and 6) are still to do. Mac only. Each step marked **Owner** is yours to do; nothing in the app does it for you. While you let it, an AI agent running in **one separate standard Mac account** can ask encrypted-note to store a key. The app comes to the front with its editor filled in from what the agent suggested (Kind, name, visible details). You type or paste the value there and choose Save. The agent gets back only "saved" and the key's id, or "cancelled". It never sees the value, and it can't use the key. @@ -22,7 +22,7 @@ Five requests. The app refuses everything else. | Replace a value (`ask_to_replace_value`) | The app opens that key with only its value editable, and the line "An agent asked to replace this value." A Seed Phrase or Private Key also needs your Master Password | "Saved" or "Cancelled" | | Delete (`ask_to_delete`) | "An agent asked to delete X. It will go to Trash for 30 days." If you choose Move to Trash, your Master Password follows, for every Kind | "Deleted" or "Cancelled" | -The app says "An agent", never "Claude Code" or "Codex", because it can't tell which program in the agent's Mac account asked. +The app answers only Claude Code and Codex ([below](#how-it-fits-together)), but it says "An agent", not which of the two asked. **What the agent never gets, by any request:** a value (a password, key, secret, Seed Phrase words, Passphrase, private key or Text body), whether a value is set, whether a Seed Phrase has a Passphrase or how many words it has, whether a key is a Favorite, any Note you added, any Note in Trash, your Master Password, your Recovery Key, a Backup or a Safety Copy. Nothing is ever signed. A key you added, a missing id and a key in Trash all get the same answer, "No key with that id among the keys you added", so the agent can't even tell your keys exist. @@ -47,11 +47,13 @@ The names and visible details of the keys an agent adds (service, website, usern ``` Your account (the owner) Agent account (Standard, not admin) ┌───────────────────────────────────┐ ┌───────────────────────────────────┐ -│ encrypted-note (Vault: offline) │ │ Claude Code, Codex, Claude app… │ +│ encrypted-note (Vault: offline) │ │ Claude Code or Codex │ │ editor, confirms, password panel │ │ └ enote-agent-keys (stdio MCP): │ │ Settings → Agents storing keys │ │ checks the app runs as you │ │ agent route: answers only the │ │ one JSON line, never a value │ -│ agent account's uid │ │ │ +│ agent account's uid, and there │ │ │ +│ only the approved tool, started │ │ │ +│ by Claude Code or Codex │ │ │ └─────────────────▲─────────────────┘ └─────────────────┬─────────────────┘ │ │ └───────────── agent.sock ◄─────────────┘ @@ -60,16 +62,25 @@ Your account (the owner) Agent account (Standard, not admin) - **The socket is local.** It is a Unix domain socket in a folder only you own, which the agent account can pass through but not list. It isn't a network port, so nothing else on your network can reach it. - **Both ends check each other.** The app asks macOS who is connecting, and answers only the account you named in Settings: a program in your own account, or in any other account, gets no reply at all. The tool checks the same way that the app is running as your account before it sends anything. +- **Only Claude Code and Codex** ([ADR-0013](adr/0013-only-claude-code-and-codex-use-the-agent-routes.md)). In that account, the app also asks the kernel which program is connecting, and which program started it. It answers only if both of these hold: + - the connecting program is `enote-agent-keys` with the code hash you approved in Settings; + - it was started by Claude Code (signed by Anthropic, team `Q6L2SF6YDW`) or Codex (signed by OpenAI, team `2DC432GLL2`), both validated by macOS as Developer ID. + + Both programs must have hardened runtime and allow no outside code in. Anything else gets no reply: another program, a shell, or the tool started by anything else. This is a speed bump, not a wall: any program in the agent account could start Claude Code or Codex itself, so keep only those two there. - **Nothing on the route can hold a value.** Each request is one line with fixed fields (Kind, name, details, id). A detail named after a value, such as `password`, `key`, `secret`, `words` or `passphrase`, is refused by the tool before anything is sent, and by the app again. - **Only while you let it.** The app listens only while a grant you started is running: at most one hour, and it ends at once when you choose Stop, the Vault locks, the Mac sleeps or the screen locks, you switch users, or you quit. After a lock, Settings shows **Off: ended because the Vault locked**; unlocking doesn't start it again. ## Before you start 1. **Owner: a standard agent account.** If you set up the [Agent Companion](agent-companion.md#before-you-start), use the same account. Otherwise, in System Settings → Users & Groups, add a new **Standard** user with the Account name (short name) `enote-agent`. Leave administrator off. Run the agent's tools only in that account, and never put your own passwords, Apple Account or keys there. The app refuses your own account, system accounts, and any account in the admin, wheel or operator group. -2. **Owner: the agent's tool, `enote-agent-keys`.** It isn't inside the app yet. In your own account, in the clone you built the app from, with no AI tool running, build it and install it where the agent account can run it but not change it. Installing into `/usr/local/bin` needs your administrator password: +2. **Owner: the agent's tool, `enote-agent-keys`.** It isn't inside the app yet. In your own account, in the clone you built the app from, with no AI tool running: + - build it; + - sign it with hardened runtime, so no other code can be loaded into it (the app refuses it otherwise); + - install it where the agent account can run it but not change it. Installing into `/usr/local/bin` needs your administrator password. ```bash cargo build --release --locked -p agent-keys-mcp + codesign --force --sign - --options runtime target/release/enote-agent-keys sudo install -m 755 target/release/enote-agent-keys /usr/local/bin/enote-agent-keys ``` @@ -81,11 +92,15 @@ Your account (the owner) Agent account (Standard, not admin) scripts/agent-keys-setup.sh --binary /usr/local/bin/enote-agent-keys enote-agent ``` - It stops with a message if that account doesn't exist, is your own, is a system account, or is in the admin, wheel or operator group, or if `/Users/Shared` isn't the system's shared folder. Otherwise it makes `/Users/Shared/encrypted-note-keys`, only yours (`chmod 700`), with one entry for the agent account (`user:enote-agent allow search`) that lets it reach the app's socket inside but not list, add or remove anything. It removes any earlier entries first, so you can run it again. It checks that the agent account can run the tool but not change it, and prints the lines for the next section, with your account number (uid). It handles no key, writes nothing in the agent account and needs no administrator password. + It stops with a message if that account doesn't exist, is your own, is a system account, or is in the admin, wheel or operator group, or if `/Users/Shared` isn't the system's shared folder. Otherwise it makes `/Users/Shared/encrypted-note-keys`, only yours (`chmod 700`), with one entry for the agent account (`user:enote-agent allow search`) that lets it reach the app's socket inside but not list, add or remove anything. It removes any earlier entries first, so you can run it again. It checks that the agent account can run the tool but not change it, and that the tool has hardened runtime. It prints the tool's code hash (`CDHash=…`) for Settings, and the lines for the next section with your account number (uid). It handles no key, writes nothing in the agent account and needs no administrator password. ## In the app -1. Unlock, open **Settings → Agents storing keys**, type the agent's Mac user (its Account name, such as `enote-agent`) and choose **Save**. The name is kept in `settings.json`, which isn't secret. Changing it stops a running grant. +1. Unlock and open **Settings → Agents storing keys**. + - Type the agent's Mac user (its Account name, such as `enote-agent`) and choose **Save**. + - Paste the `CDHash=…` line the setup script printed as **The agent tool's code hash**, and choose **Save**. + + Both are kept in `settings.json`, which isn't secret. Changing either stops a running grant. After you install the tool again (after each update), run the setup script again and paste its new code hash. 2. Choose **Start for up to one hour**. A dialog of the app's own, outside its page (so nothing in the page can answer it), names the account and asks you to confirm. Start refuses, and says why, if the folder isn't set up or the account isn't a separate Standard one. Settings then shows **On** and the minutes left. 3. When an agent asks for something that needs you, the app comes to the front: @@ -114,18 +129,15 @@ args = ["--owner-uid", "501"] tool_timeout_sec = 330 ``` -The Claude app, in `~/Library/Application Support/Claude/claude_desktop_config.json`: -```json -{ "mcpServers": { "enote-keys": { "command": "/usr/local/bin/enote-agent-keys", "args": ["--owner-uid", "501"] } } } -``` +The Claude app's Code tab runs Claude Code, so it is answered. The Claude app's chat side, the Codex app and ChatGPT are not ([ADR-0013](adr/0013-only-claude-code-and-codex-use-the-agent-routes.md)): a tool they start gets no reply. -**How long the agent's program waits.** Store, replace, delete and an edit you must allow each wait up to 5 minutes for you. The Claude app may give up on a tool after about a minute, and its config has no setting to change that. If you have set `MCP_TOOL_TIMEOUT` for Claude Code, keep it at 330000 (milliseconds) or more. When a program gives up first, the request stays open in the app: answer it there as usual. The agent is told the call failed; the tool's descriptions tell it not to ask again but to check with `list_keys` a little later, and the tool keeps answering `list_keys` while a request waits. While it waits, the tool also sends the program progress notices, which some programs take as a reason to keep waiting. +**How long the agent's program waits.** Store, replace, delete and an edit you must allow each wait up to 5 minutes for you. If you have set `MCP_TOOL_TIMEOUT` for Claude Code, keep it at 330000 (milliseconds) or more. When a program gives up first, the request stays open in the app: answer it there as usual. The agent is told the call failed; the tool's descriptions tell it not to ask again but to check with `list_keys` a little later, and the tool keeps answering `list_keys` while a request waits. While it waits, the tool also sends the program progress notices, which some programs take as a reason to keep waiting. The agent then has five tools: `store_key`, `list_keys`, `edit_key_details`, `ask_to_replace_value` and `ask_to_delete`. Their descriptions tell it that you type every value in the app, that the tool never sees or returns one, and that `name` and `details` reach its AI company and must hold no secret. Every answer is a short fixed text, such as: | The agent hears | What it means | |---|---| -| "The encrypted-note app isn't letting agents in…" | The app isn't open, or no grant is running. Choose **Start** in Settings. | +| "The encrypted-note app isn't letting agents in…" | The app isn't open, or no grant is running: choose **Start** in Settings. Or the app turned the program away: the tool wasn't started by Claude Code or Codex, or its code hash isn't the one in Settings. | | "Agents aren't let in right now…" | The grant ended (Stop, a lock, sleep or the hour). Choose **Start** again. | | "The encrypted-note app is locked…" | Unlock it. | | "No key with that id among the keys you added…" | The id is one of your Notes, in Trash, removed from the agent's reach, or doesn't exist. The agent can't tell which. | @@ -139,7 +151,7 @@ The agent then has five tools: `store_key`, `list_keys`, `edit_key_details`, `as - **Mac only.** On Windows, Start says it works only on a Mac for now. - **Your Vault must stay Unlocked while the agent works.** Agent requests don't count as using the app, so Auto-lock still locks after 5 minutes without activity in the app's own window (unless you chose **Count my activity anywhere on this computer**), and the lock ends the grant. Fast user switching to the agent account locks the Vault too. How to reach the agent account while your own session stays active is still open, as for the [Agent Companion](agent-companion.md#order-and-account-use). - **An agent in your own Mac account can't use this route,** because the app answers only the separate account. Such an agent works as you anyway ([AI tools on this computer](guide.md#ai-tools-on-this-computer)), and this route doesn't change that. -- **Any program in the agent account can ask,** and they all share the one grant. Install only the tools you mean to there. +- **Only Claude Code and Codex are answered, but they share the one grant.** Any program in the agent account could start Claude Code or Codex itself with its own instructions, so install only those two there. Claude Code's signature lets libraries Anthropic didn't sign be loaded into it (you accepted this on 2026-11-09), so a program in that account could plant one: the same reason to keep it clean. - **One request waits at a time,** for at most 5 minutes. Names and details are limited to 1,000 characters each. - **The value is typed into the app's ordinary editor,** exactly as when you add a key yourself, so it has the same protection as that: it goes from the editor to the app's Rust core, never to the agent ([ADR-0003](adr/0003-tauri-with-secrets-kept-in-rust.md)). - **A tricked agent can still** add junk keys, open requests you have to cancel, rename one of its keys or change a purpose or expiry date to confuse you, and ask you to change where a key is used, replace a value or delete a key. Read each confirm: none of those happens unless you allow it. It can't read, copy, move or spend anything. @@ -152,11 +164,16 @@ The agent then has five tools: `store_key`, `list_keys`, `edit_key_details`, `as ## For developers: trying it with a fictional Vault -Debug builds only, and never with a real Vault: `ENOTE_DEV_AGENT_UID` names the account number to accept, so one account can try the whole route, and `ENOTE_DEV_AGENT_SOCKET_DIR` moves the folder. Release builds ignore both. The folder must be yours, mode 700, and every folder above it root's or yours and not writable by others (unless it is root's and sticky); one in your home folder works. +Debug builds only, and never with a real Vault: +- `ENOTE_DEV_AGENT_UID` names the account number to accept, so one account can try the whole route; +- `ENOTE_DEV_AGENT_SOCKET_DIR` moves the folder; +- `ENOTE_DEV_AGENT_ANY_PROGRAM` answers any program in that account, so a tool started from your shell gets in. + +Release builds ignore all three. The folder must be yours, mode 700, and every folder above it root's or yours and not writable by others (unless it is root's and sticky); one in your home folder works. ```bash sock_dir=$(mktemp -d "$HOME/enote-dev-agent.XXXXXX") # mode 700 -ENOTE_DEV_DATA_DIR="$(mktemp -d)" ENOTE_DEV_AGENT_UID="$(id -u)" ENOTE_DEV_AGENT_SOCKET_DIR="$sock_dir" npm run tauri dev +ENOTE_DEV_DATA_DIR="$(mktemp -d)" ENOTE_DEV_AGENT_UID="$(id -u)" ENOTE_DEV_AGENT_SOCKET_DIR="$sock_dir" ENOTE_DEV_AGENT_ANY_PROGRAM=1 npm run tauri dev ``` In the app, create a fictional Vault and choose **Start** (its confirm says "developer override"). Then point a tool at the debug build with `cargo build --locked -p agent-keys-mcp` and `target/debug/enote-agent-keys --owner-uid "$(id -u)" --socket "$sock_dir/agent.sock"`. Use obviously fake values only ([AGENTS.md](../AGENTS.md)). @@ -165,7 +182,21 @@ The code: `src-tauri/src/core/agent.rs` (the grant, the five requests and their ## What has been checked, and what hasn't -**Checked with fictional data, automatically:** that the agent sees only its own keys, never in Trash, and never a value, a Passphrase sign or a word count; that your Notes, missing ids and Notes in Trash get the same answer; every edit rule for every field of every Kind; that store, replace and delete need you, and the Master Password where the tables above say; one request at a time, the 5-minute limit, and the grant ending on lock, Auto-lock, sleep, screen lock, user switch, quit and the hour; that agent requests don't keep Auto-lock away; the socket end to end in one account, including that a connection from any other uid gets no reply, that long or slow lines are refused and that the connection limit holds; the real `enote-agent-keys` against the real listener; and the Vault mark, schema 3 and the Emergency Reader. `scripts/check-network-source.mjs` keeps Unix sockets and peer checks to `agent_route.rs` and the tool's crate, and keeps the Agent Companion's code out of them. The screens were clicked through in the fixture only (`npm run dev`), which proves nothing about native panels, locks or the route. +**Checked with fictional data, automatically:** that the agent sees only its own keys, never in Trash, and never a value, a Passphrase sign or a word count; that your Notes, missing ids and Notes in Trash get the same answer; every edit rule for every field of every Kind; that store, replace and delete need you, and the Master Password where the tables above say; one request at a time, the 5-minute limit, and the grant ending on lock, Auto-lock, sleep, screen lock, user switch, quit and the hour; that agent requests don't keep Auto-lock away; the socket end to end in one account, including that a connection from any other uid gets no reply, that long or slow lines are refused and that the connection limit holds; the real `enote-agent-keys` against the real listener; and the Vault mark, schema 3 and the Emergency Reader. + +**ADR-0013's check, also automatic, with stand-ins:** +- **The decision itself:** the approved tool started by Claude Code or Codex is let in. Refused: + - another tool hash; + - a fake signed ad-hoc as Claude Code; + - the right identity with another team, or not validated as Developer ID; + - Apple's own programs; + - programs without hardened runtime, debuggable, or allowing DYLD injection. +- **The kernel readers:** a real running program's identity and parent. +- **On a real socket,** with Apple's `nc` standing in for the tool and `bash` for its starter: + - answered when both are on the list; + - closed without a reply for the real list (bash isn't Claude Code or Codex), for a wrong tool hash, and for a program connecting by itself. + +A spike on 2026-11-09 showed that the kernel calls the check uses work inside the App Sandbox, in a stand-in signed with this app's exact entitlements. `scripts/check-network-source.mjs` keeps Unix sockets and peer checks to `agent_route.rs` and the tool's crate, and keeps the Agent Companion's code out of them. The screens were clicked through in the fixture only (`npm run dev`), which proves nothing about native panels, locks or the route. **Still to do by hand, on a real Mac, with fake keys:** @@ -175,8 +206,9 @@ The code: `src-tauri/src/core/agent.rs` (the grant, the five requests and their - **The folder entry alone isn't enough:** the sandbox refuses to make the socket, and adding `network.server` doesn't help. - **The added `temporary-exception.sbpl` rule makes it work**, and the stand-in identified each caller. - **Still to confirm** in a Developer ID-signed, notarized build. -- [ ] From Claude Code, Codex and the Claude app in the agent account: every request, including a Seed Phrase and a Private Key stored, edited, replaced and deleted through the Master Password panel, and the app coming to the front. +- [ ] From Claude Code (also in the Claude app's Code tab) and Codex in the agent account: every request, including a Seed Phrase and a Private Key stored, edited, replaced and deleted through the Master Password panel, and the app coming to the front. - [ ] A connection from your own account, and from a third account, gets no reply. +- [ ] In the agent account, the tool started by Claude Code and by Codex is answered. Refused: the tool started from a shell or by another program, a program connecting by itself, and the tool after it is rebuilt but before its new code hash is pasted. - [ ] Stop, a lock, sleep, screen lock, user switching, quitting and the end of the hour each end the grant and remove the socket, and a waiting request is cancelled. - [ ] Each screen in the real app: the editor for store and replace, the edit and delete confirms, the banner, the mark and **Stop agent managing this**, with VoiceOver too. - [ ] The tool bundled inside the app. Until then, the setup script's default `--binary` (inside `/Applications/encrypted-note.app`) doesn't exist, and its check would refuse `/Applications` anyway, because administrators can write there. diff --git a/docs/security-model.md b/docs/security-model.md index f1c751d..fd8ccf5 100644 --- a/docs/security-model.md +++ b/docs/security-model.md @@ -30,7 +30,7 @@ - **Missing and older Vaults.** `settings.json` keeps the Vault's id and a count of its changes. If the Vault file is gone, Welcome says so and offers a Backup first. If an older copy of the same Vault opens, the app warns after unlock and refuses nothing, because a restore can be wanted. Touch ID turns off when the file was replaced by one with other credentials. The settings file isn't signed, so these catch mistakes, not someone who changes both files ([FORMAT.md section 14](FORMAT.md#14-the-settings-file-not-part-of-the-vault)). - **Emergency Reader.** `crates/reader` is a small command-line tool that reads a Vault or Backup even if the app stops working ([how to use it](guide.md#if-the-app-wont-open)). It doesn't have the app's wiping allocator. It prints Notes only to a terminal, and warns when it sees signs of an AI tool's terminal, which it can't stop. `scripts/build-reader.sh` builds a copy to keep from merged `main`, and prints its SHA-256. - **Optional Agent Companion.** A separate helper (an XPC service with its own App Sandbox container) can let the agent tools in one separate Mac account use one fixed OpenAI text tool with a separately created Agent Key. It can't open the Vault's container, receives nothing from the Vault, and stops on Stop, lock, sleep, grant expiry and quit. Its one destination, POST https://api.openai.com/v1/responses, is in `companion/policy.json`, whose pinned SHA-256 the helper checks before every Start; the broker refuses to load a policy that names any other. Its Agent Key is kept in a plain-text file that other non-admin Mac accounts can't open, and in Node.js memory, not encrypted Vault storage ([ADR-0009](adr/0009-optional-agent-companion-helper.md), [guide](agent-companion.md)). -- **Optional: agents storing keys (Mac).** The app's one way in from other programs ([ADR-0012](adr/0012-agents-store-and-manage-keys.md), [guide](agent-keys.md)). It is a Unix domain socket, not a network port, in `/Users/Shared/encrypted-note-keys`, a folder the owner's setup script makes (mode 700, plus one ACL entry that lets the agent account reach the socket but not list the folder). It exists only while the Vault is Unlocked and the owner's grant runs: at most one hour, and it ends on Stop, lock, Auto-lock, sleep, screen lock, user switch and quit. The app takes the connecting program's account from the kernel and answers only the one Standard account named in Settings (never the owner's own, a system account, or one in the admin, wheel or operator group); everything else is closed with no reply. The agent's tool checks the same way that the socket is run by the owner's account. An agent can make five requests, about only the Notes marked "added by an agent" and not in Trash: store (the owner types the value into the app's ordinary editor), list, edit details, and ask to replace a value or delete. No request field can hold a value and every reply is a fixed shape, so names, visible details, ids and dates are all that reach the agent and its AI company. Where-a-key-is-used edits wait for the owner's Allow; every Wallet Kind edit and every delete also need the Master Password; values change only by the owner typing them. One request waits at a time, for at most 5 minutes. **Limits:** any program in the agent account can use it while the grant runs; a tricked agent can still add junk keys, raise requests the owner has to refuse, and rename or re-describe the keys it added; the route can't tell a real request from a planted one, so each confirm is the protection; and it does nothing about AI tools running in the owner's own account, which work as the owner anyway. It needs two entitlements, pinned like the others: +- **Optional: agents storing keys (Mac).** The app's one way in from other programs ([ADR-0012](adr/0012-agents-store-and-manage-keys.md), [guide](agent-keys.md)). It is a Unix domain socket, not a network port, in `/Users/Shared/encrypted-note-keys`, a folder the owner's setup script makes (mode 700, plus one ACL entry that lets the agent account reach the socket but not list the folder). It exists only while the Vault is Unlocked and the owner's grant runs: at most one hour, and it ends on Stop, lock, Auto-lock, sleep, screen lock, user switch and quit. The app takes the connecting program's account from the kernel and answers only the one Standard account named in Settings (never the owner's own, a system account, or one in the admin, wheel or operator group); everything else is closed with no reply. In that account it answers only the agent's tool with the code hash the owner approved in Settings, started by Claude Code or Codex (validated by macOS as Anthropic's or OpenAI's Developer ID), both with hardened runtime: all read from the kernel's record of the running programs, never their files ([ADR-0013](adr/0013-only-claude-code-and-codex-use-the-agent-routes.md)). The agent's tool checks the same way that the socket is run by the owner's account. An agent can make five requests, about only the Notes marked "added by an agent" and not in Trash: store (the owner types the value into the app's ordinary editor), list, edit details, and ask to replace a value or delete. No request field can hold a value and every reply is a fixed shape, so names, visible details, ids and dates are all that reach the agent and its AI company. Where-a-key-is-used edits wait for the owner's Allow; every Wallet Kind edit and every delete also need the Master Password; values change only by the owner typing them. One request waits at a time, for at most 5 minutes. **Limits:** any program in the agent account can start Claude Code or Codex itself, and Claude Code's signature lets libraries Anthropic didn't sign be loaded into it, so the program check is a speed bump and the separate account must hold only those two; a tricked agent can still add junk keys, raise requests the owner has to refuse, and rename or re-describe the keys it added; the route can't tell a real request from a planted one, so each confirm is the protection; and it does nothing about AI tools running in the owner's own account, which work as the owner anyway. It needs two entitlements, pinned like the others: - the one `temporary-exception.files.absolute-path.read-write` entry for that folder; - one `temporary-exception.sbpl` rule letting the app listen on, and accept, a Unix socket only there. The file entry alone doesn't cover sockets under the sandbox (found 2026-11-09). - **Vocabulary.** [CONTEXT.md](../CONTEXT.md) defines Vault, Note, Kind, Hidden Field, Recovery Kit and the rest. diff --git a/scripts/agent-keys-setup.sh b/scripts/agent-keys-setup.sh index 8c86368..25e36b6 100644 --- a/scripts/agent-keys-setup.sh +++ b/scripts/agent-keys-setup.sh @@ -9,9 +9,11 @@ # --binary PATH The agent's tool, enote-agent-keys (default: inside /Applications/encrypted-note.app). # # It creates /Users/Shared/encrypted-note-keys, owned by you, mode 700, with one ACL entry that -# lets the agent account reach the app's socket inside it, and prints the lines that add the tool -# to Claude Code, Codex or the Claude app in the agent account. It handles no key, copies nothing, -# writes nothing in the agent's account and needs no administrator rights. +# lets the agent account reach the app's socket inside it, prints the tool's code hash for you to +# paste into the app's Settings (ADR-0013: only that build of the tool, started by Claude Code or +# Codex, is answered), and prints the lines that add the tool to Claude Code or Codex in the agent +# account. It handles no key, copies nothing, writes nothing in the agent's account and needs no +# administrator rights. set -euo pipefail [[ $(uname -s) == Darwin ]] || { echo "Agents storing keys is set up on macOS only." >&2; exit 1; } @@ -82,7 +84,7 @@ echo "==> the agent's tool ($BINARY)" # folder above it must be root's or yours, and writable by nobody else. binary_ok=1 if [[ ! -f "$BINARY" || -L "$BINARY" ]]; then - echo "Not found (or a link): $BINARY. Build it with 'cargo build --release --locked -p agent-keys-mcp', put it where the agent account can run it but not change it, and pass --binary." + echo "Not found (or a link): $BINARY. Build it with 'cargo build --release --locked -p agent-keys-mcp', sign it with 'codesign --force --sign - --options runtime target/release/enote-agent-keys', put it where the agent account can run it but not change it, and pass --binary." binary_ok=0 else path="$BINARY" @@ -105,6 +107,25 @@ else done fi +# ADR-0013: the app answers only the build of the tool whose code hash you paste into Settings, +# and only if it has hardened runtime, so no other code can be loaded into it. +TOOL_HASH="" +if [[ $binary_ok == 1 ]]; then + signature=$(codesign -dv --verbose=4 "$BINARY" 2>&1) || signature="" + TOOL_HASH=$(sed -n 's/^CDHash=//p' <<<"$signature" | head -n 1) + if [[ ! $TOOL_HASH =~ ^[0-9a-f]{40}$ ]]; then + echo "Couldn't read the tool's code hash. Sign it with 'codesign --force --sign - --options runtime' before installing it, then run this again." + TOOL_HASH="" + binary_ok=0 + elif ! grep -Eq '^CodeDirectory .*flags=0x[0-9a-f]+\([a-z,-]*runtime' <<<"$signature"; then + echo "The tool isn't signed with hardened runtime, so other code could be loaded into it, and the app won't answer it. Sign it with 'codesign --force --sign - --options runtime target/release/enote-agent-keys' before installing it, then run this again." + TOOL_HASH="" + binary_ok=0 + else + echo "Code hash: CDHash=$TOOL_HASH" + fi +fi + QUOTED_BINARY=$(printf '%q' "$BINARY") cat <&2 diff --git a/src-tauri/build.rs b/src-tauri/build.rs index 483feee..2773fe4 100644 --- a/src-tauri/build.rs +++ b/src-tauri/build.rs @@ -36,6 +36,7 @@ const COMMANDS: &[&str] = &[ "agent_route_start", "agent_route_stop", "set_agent_account", + "set_agent_tool_hash", "show_field", "show_wallet_field", "show_and_copy_wallet_field", diff --git a/src-tauri/capabilities/main.json b/src-tauri/capabilities/main.json index ca4cc0e..2ec2fb6 100644 --- a/src-tauri/capabilities/main.json +++ b/src-tauri/capabilities/main.json @@ -38,6 +38,7 @@ "allow-agent-route-start", "allow-agent-route-stop", "allow-set-agent-account", + "allow-set-agent-tool-hash", "allow-show-field", "allow-show-wallet-field", "allow-show-and-copy-wallet-field", diff --git a/src-tauri/src/agent_route.rs b/src-tauri/src/agent_route.rs index 18f436a..5fa6017 100644 --- a/src-tauri/src/agent_route.rs +++ b/src-tauri/src/agent_route.rs @@ -13,6 +13,12 @@ //! (`getpeereid`). Anything but the expected uid is closed without a reply, including the //! owner's own programs. The expected uid is the account the owner named, refused if it is //! the owner's own, below 501, or in the admin, wheel or operator group. +//! * **Only the approved tool, started by Claude Code or Codex (ADR-0013).** The connecting +//! program must be `enote-agent-keys` with the code hash the owner approved in Settings, and +//! the program that started it must be Claude Code or Codex, validated by macOS as their +//! makers' Developer ID. Both come from the kernel's record of the running programs +//! (`csops_audittoken`, `sysctl`), never from their files or from what they say, and both must +//! have hardened runtime and allow no outside code in. Anything else is closed without a reply. //! * **Bounded.** One request per connection: one UTF-8 JSON line of at most 16 KiB, read within //! 10 s; at most 4 connections at once. Requests the owner completes wait at most 5 minutes. //! * **Only while the grant runs.** The listener starts with the grant and stops with it, and @@ -20,8 +26,9 @@ //! the file there is still the one it made. //! //! Developers only, debug builds only: `ENOTE_DEV_AGENT_UID` names the uid to accept (so the -//! route can be tried with a fictional Vault in one account) and `ENOTE_DEV_AGENT_SOCKET_DIR` -//! moves the folder. Release builds ignore both, as they ignore `ENOTE_DEV_DATA_DIR`. +//! route can be tried with a fictional Vault in one account), `ENOTE_DEV_AGENT_SOCKET_DIR` +//! moves the folder, and `ENOTE_DEV_AGENT_ANY_PROGRAM` answers any program in that account. +//! Release builds ignore all three, as they ignore `ENOTE_DEV_DATA_DIR`. use std::collections::BTreeMap; use std::path::PathBuf; @@ -85,6 +92,8 @@ pub enum RouteError { AccountIsOwner, /// The named account is a system account or can administer the Mac. AccountNotStandard, + /// No code hash for the agent's tool is approved in Settings (ADR-0013). + NoToolHash, /// This platform has no route yet. Unavailable, /// Anything else (a bind or a thread that failed). @@ -114,6 +123,10 @@ impl RouteError { "not_allowed", "That account is an administrator or a system account. The agent must run in a Standard account.", ), + RouteError::NoToolHash => ( + "not_set_up", + "Paste the agent tool's code hash first: scripts/agent-keys-setup.sh prints it on its CDHash line. Then choose Start.", + ), RouteError::Unavailable => ( "unavailable", "Agents storing keys works only on a Mac for now.", @@ -146,6 +159,156 @@ pub fn check_account(uid: u32, owner_uid: u32, groups: &[u32]) -> Result<(), Rou Ok(()) } +// ---------- which programs may connect (ADR-0013) ---------- + +/// Code-signing status bits the check reads (xnu `cs_blobs.h`). +pub const CS_VALID: u32 = 0x0000_0001; +pub const CS_GET_TASK_ALLOW: u32 = 0x0000_0004; +pub const CS_RUNTIME: u32 = 0x0001_0000; +pub const CS_DEBUGGED: u32 = 0x1000_0000; + +/// How macOS validated a running program's signature (`CS_OPS_VALIDATION_CATEGORY`). +pub const CATEGORY_PLATFORM: u32 = 1; +pub const CATEGORY_DEVELOPER_ID: u32 = 6; + +/// Entitlements that let outside code into a program. A program with either is refused. +pub const INJECTION_ENTITLEMENTS: [&str; 2] = [ + "com.apple.security.cs.allow-dyld-environment-variables", + "com.apple.security.get-task-allow", +]; + +/// Claude Code and Codex, as their makers sign them: (identity, Developer ID team). Fixed here, +/// never in anything the agent account can write. The Claude app's Code tab runs its own copy of +/// Claude Code, with the same identity and team. +pub const ALLOWED_STARTERS: [(&str, &str); 2] = [ + ("com.anthropic.claude-code", "Q6L2SF6YDW"), + ("codex", "2DC432GLL2"), +]; + +/// What the kernel recorded about a running program when it validated it: read from the kernel, +/// never from the program's file and never from anything the program says. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct ProgramIdentity { + /// The signing identifier, such as `com.anthropic.claude-code`. Anyone can claim one. + pub identity: Option, + /// The Developer ID team; ad-hoc and Apple's own programs have none. + pub team: Option, + /// How macOS validated the signature, such as [`CATEGORY_DEVELOPER_ID`]. + pub category: Option, + /// The code-signing status bits, such as [`CS_VALID`] and [`CS_RUNTIME`]. + pub status: u32, + /// The code directory hash, as `codesign` prints it on its `CDHash=` line. + pub cdhash: Option<[u8; 20]>, + /// The entitlements as the kernel holds them (XML), if the program has any. + pub entitlements: Option>, +} + +/// A program allowed to start the agent's tool. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Starter { + pub identity: String, + pub team: Option, + pub category: u32, +} + +/// Which tool may connect, and which programs may have started it. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ProgramPolicy { + /// The code hash of the installed `enote-agent-keys` the owner approved in Settings. + pub tool_cdhash: [u8; 20], + pub starters: Vec, +} + +impl ProgramPolicy { + /// The owner's approved tool, started by Claude Code or Codex signed by their makers. + pub fn production(tool_cdhash: [u8; 20]) -> Self { + ProgramPolicy { + tool_cdhash, + starters: ALLOWED_STARTERS + .iter() + .map(|(identity, team)| Starter { + identity: (*identity).to_owned(), + team: Some((*team).to_owned()), + category: CATEGORY_DEVELOPER_ID, + }) + .collect(), + } + } +} + +/// Who the route answers, beyond the account. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum ProgramCheck { + Required(ProgramPolicy), + /// Developers and tests only: any program in the account. Absent from release builds. + #[cfg(debug_assertions)] + Off, +} + +/// Why a program was turned away. Never sent to it: the connection just closes. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ProgramRefusal { + /// The kernel couldn't say who it is, or it changed while the route looked. + Unknown, + /// Not the approved tool: another code hash, or no valid signature. + NotTheTool, + /// The tool wasn't started by Claude Code or Codex. + NotAnAllowedStarter, + /// The tool or its starter could have outside code loaded into it. + Injectable, +} + +/// The check, on what the kernel said: the approved tool, started by an allowed program, neither +/// open to outside code. +pub fn judge_programs( + policy: &ProgramPolicy, + tool: &ProgramIdentity, + starter: &ProgramIdentity, +) -> Result<(), ProgramRefusal> { + if tool.status & CS_VALID == 0 || tool.cdhash != Some(policy.tool_cdhash) { + return Err(ProgramRefusal::NotTheTool); + } + let allowed = policy.starters.iter().any(|allowed| { + starter.identity.as_deref() == Some(allowed.identity.as_str()) + && starter.team.as_deref() == allowed.team.as_deref() + && starter.category == Some(allowed.category) + }); + if starter.status & CS_VALID == 0 || !allowed { + return Err(ProgramRefusal::NotAnAllowedStarter); + } + if !closed_to_outside_code(tool) || !closed_to_outside_code(starter) { + return Err(ProgramRefusal::Injectable); + } + Ok(()) +} + +/// Hardened runtime, not debuggable, not being debugged, and no entitlement that lets code in. +/// An entitlement named at all counts, whatever its value. +fn closed_to_outside_code(program: &ProgramIdentity) -> bool { + let named = |name: &str| { + program + .entitlements + .as_deref() + .is_some_and(|xml| xml.windows(name.len()).any(|w| w == name.as_bytes())) + }; + program.status & CS_RUNTIME != 0 + && program.status & (CS_GET_TASK_ALLOW | CS_DEBUGGED) == 0 + && !INJECTION_ENTITLEMENTS.iter().any(|name| named(name)) +} + +/// The check Start sets up from Settings: the approved tool hash, or [`RouteError::NoToolHash`]. +/// Debug builds answer any program when `ENOTE_DEV_AGENT_ANY_PROGRAM` is set. +pub fn program_check(tool_hash: Option<&str>) -> Result { + #[cfg(debug_assertions)] + if std::env::var_os("ENOTE_DEV_AGENT_ANY_PROGRAM").is_some() { + return Ok(ProgramCheck::Off); + } + let hash = tool_hash + .and_then(crate::core::agent::parse_tool_hash) + .ok_or(RouteError::NoToolHash)?; + Ok(ProgramCheck::Required(ProgramPolicy::production(hash))) +} + /// Where the socket goes. Debug builds may move it with `ENOTE_DEV_AGENT_SOCKET_DIR`. pub fn socket_dir() -> PathBuf { #[cfg(debug_assertions)] @@ -345,7 +508,10 @@ fn wait_for_owner( // ---------- the listener (macOS) ---------- #[cfg(target_os = "macos")] -pub use mac::{AgentRoute, check_folder, expected_uid, lookup_account, peer_uid}; +pub use mac::{ + AgentRoute, check_folder, check_program, expected_uid, lookup_account, parent_pid, peer_uid, + program_identity, +}; #[cfg(target_os = "macos")] mod mac { @@ -476,6 +642,222 @@ mod mac { !ok || member != 0 } + // ----- the kernel's record of a running program (ADR-0013) ----- + + /// `audit_token_t`: names one process for as long as it lives, unlike its number. + #[repr(C)] + #[derive(Clone, Copy, Default)] + pub struct AuditToken { + val: [u32; 8], + } + + // The kernel's code-signing calls (``). Declared here because the libc crate + // doesn't have them. Both read the kernel's record; neither opens the program's file, which + // is why they work inside the App Sandbox when the Security framework's checks don't. + unsafe extern "C" { + fn csops( + pid: libc::pid_t, + ops: u32, + useraddr: *mut libc::c_void, + usersize: libc::size_t, + ) -> c_int; + fn csops_audittoken( + pid: libc::pid_t, + ops: u32, + useraddr: *mut libc::c_void, + usersize: libc::size_t, + token: *mut AuditToken, + ) -> c_int; + } + + const CS_OPS_STATUS: u32 = 0; + const CS_OPS_CDHASH: u32 = 5; + const CS_OPS_ENTITLEMENTS_BLOB: u32 = 7; + const CS_OPS_IDENTITY: u32 = 11; + const CS_OPS_TEAMID: u32 = 14; + const CS_OPS_VALIDATION_CATEGORY: u32 = 17; + /// The largest blob read: entitlements are a few kilobytes at most. + const MAX_BLOB: usize = 1 << 20; + + /// One `csops` call into `buffer`, through the audit token when there is one, so that only + /// the very process it names can answer. `Err` carries the errno. + fn cs_call( + pid: i32, + token: Option<&AuditToken>, + op: u32, + buffer: &mut [u8], + ) -> Result<(), i32> { + let rc = match token { + Some(token) => { + let mut token = *token; + // SAFETY: `buffer` is valid for writes of its length, and `token` is a whole + // audit_token_t the call only reads. + unsafe { + csops_audittoken( + pid, + op, + buffer.as_mut_ptr().cast(), + buffer.len(), + &mut token, + ) + } + } + // SAFETY: `buffer` is valid for writes of its length. + None => unsafe { csops(pid, op, buffer.as_mut_ptr().cast(), buffer.len()) }, + }; + if rc == 0 { + Ok(()) + } else { + Err(std::io::Error::last_os_error().raw_os_error().unwrap_or(0)) + } + } + + fn cs_u32(pid: i32, token: Option<&AuditToken>, op: u32) -> Option { + let mut bytes = [0u8; 4]; + cs_call(pid, token, op, &mut bytes).ok()?; + Some(u32::from_ne_bytes(bytes)) + } + + /// A blob the kernel keeps for the program: an 8-byte header (magic, then total length, + /// both big-endian), then the data. `Ok(None)`: the program has none. + fn cs_blob(pid: i32, token: Option<&AuditToken>, op: u32) -> Result>, ()> { + let mut size = 4096; + loop { + let mut buffer = vec![0u8; size]; + match cs_call(pid, token, op, &mut buffer) { + Ok(()) => {} + Err(libc::ENOENT) => return Ok(None), + Err(libc::ERANGE) if size < MAX_BLOB => { + let needed = u32::from_be_bytes([buffer[4], buffer[5], buffer[6], buffer[7]]); + size = + usize::try_from(needed).map_or(MAX_BLOB, |n| n.max(size * 2).min(MAX_BLOB)); + continue; + } + Err(_) => return Err(()), + } + let length = u32::from_be_bytes([buffer[4], buffer[5], buffer[6], buffer[7]]); + let length = usize::try_from(length).map_err(|_| ())?; + if length < 8 || length > buffer.len() { + return if length == 0 { Ok(None) } else { Err(()) }; + } + return Ok(Some(buffer[8..length].to_vec())); + } + } + + fn cs_string(pid: i32, token: Option<&AuditToken>, op: u32) -> Result, ()> { + Ok(cs_blob(pid, token, op)?.and_then(|data| { + let text = data.split(|&b| b == 0).next().unwrap_or_default(); + (!text.is_empty()).then(|| String::from_utf8_lossy(text).into_owned()) + })) + } + + fn read_identity(pid: i32, token: Option<&AuditToken>) -> Option { + let status = cs_u32(pid, token, CS_OPS_STATUS)?; + let mut cdhash = [0u8; 20]; + let cdhash = cs_call(pid, token, CS_OPS_CDHASH, &mut cdhash) + .ok() + .map(|()| cdhash); + Some(ProgramIdentity { + identity: cs_string(pid, token, CS_OPS_IDENTITY).ok()?, + team: cs_string(pid, token, CS_OPS_TEAMID).ok()?, + category: cs_u32(pid, token, CS_OPS_VALIDATION_CATEGORY), + status, + cdhash, + entitlements: cs_blob(pid, token, CS_OPS_ENTITLEMENTS_BLOB).ok()?, + }) + } + + /// What the kernel recorded about the running process `pid`, or `None` if it can't say. + pub fn program_identity(pid: i32) -> Option { + read_identity(pid, None) + } + + /// `struct kinfo_proc` from `sysctl(KERN_PROC_PID)`: its size and where the process and + /// parent numbers sit, the same on Apple silicon and Intel (checked with the system + /// headers, 2026-11-09). Anything else is refused rather than guessed. + const KINFO_PROC_SIZE: usize = 648; + const KINFO_P_PID: usize = 40; + const KINFO_E_PPID: usize = 560; + + /// The number of the process that started `pid`, from the kernel's process table. Works + /// for another account's process, where `proc_pidinfo` is refused. + pub fn parent_pid(pid: i32) -> Option { + let mut mib = [libc::CTL_KERN, libc::KERN_PROC, libc::KERN_PROC_PID, pid]; + let mut record = [0u8; KINFO_PROC_SIZE]; + let mut size: libc::size_t = KINFO_PROC_SIZE; + // SAFETY: `mib` holds the 4 names passed, `record` is valid for writes of `size` + // bytes, and nothing is written back to the kernel. + let rc = unsafe { + libc::sysctl( + mib.as_mut_ptr(), + 4, + record.as_mut_ptr().cast(), + &mut size, + std::ptr::null_mut(), + 0, + ) + }; + let field = |at: usize| { + i32::from_ne_bytes([record[at], record[at + 1], record[at + 2], record[at + 3]]) + }; + (rc == 0 && size == KINFO_PROC_SIZE && field(KINFO_P_PID) == pid) + .then(|| field(KINFO_E_PPID)) + } + + /// The process at the other end of `stream`: its number and audit token, from the kernel. + fn peer_process(stream: &UnixStream) -> Option<(i32, AuditToken)> { + let fd = stream.as_raw_fd(); + let mut pid: libc::pid_t = 0; + let mut pid_len = std::mem::size_of::() as libc::socklen_t; + let mut token = AuditToken::default(); + let mut token_len = std::mem::size_of::() as libc::socklen_t; + // SAFETY: both out pointers are valid for writes of the lengths given, and the + // descriptor is open while `stream` is borrowed. + let ok = unsafe { + libc::getsockopt( + fd, + libc::SOL_LOCAL, + libc::LOCAL_PEERPID, + (&mut pid as *mut libc::pid_t).cast(), + &mut pid_len, + ) == 0 + && libc::getsockopt( + fd, + libc::SOL_LOCAL, + libc::LOCAL_PEERTOKEN, + (&mut token as *mut AuditToken).cast(), + &mut token_len, + ) == 0 + }; + (ok && pid > 1 && token_len as usize == std::mem::size_of::()) + .then_some((pid, token)) + } + + /// ADR-0013's check for one connection: the approved tool, started by Claude Code or Codex. + pub fn check_program( + stream: &UnixStream, + policy: &ProgramPolicy, + ) -> Result<(), ProgramRefusal> { + let (pid, token) = peer_process(stream).ok_or(ProgramRefusal::Unknown)?; + let tool = read_identity(pid, Some(&token)).ok_or(ProgramRefusal::Unknown)?; + let starter_pid = parent_pid(pid).ok_or(ProgramRefusal::Unknown)?; + if starter_pid <= 1 { + return Err(ProgramRefusal::NotAnAllowedStarter); + } + let starter = program_identity(starter_pid).ok_or(ProgramRefusal::Unknown)?; + judge_programs(policy, &tool, &starter)?; + // Still the same tool with the same starter: had the starter exited while the route + // looked, the tool's parent would now be launchd (1), so its number can't have been + // reused by another program in between. The token answers only for the same tool. + let same = cs_u32(pid, Some(&token), CS_OPS_STATUS).is_some() + && parent_pid(pid) == Some(starter_pid); + if same { + Ok(()) + } else { + Err(ProgramRefusal::Unknown) + } + } + /// The uid the route will accept, from the account the owner named. Debug builds take /// `ENOTE_DEV_AGENT_UID` instead, with no checks, so one account can try it end to end. pub fn expected_uid(account: Option<&str>) -> Result { @@ -557,13 +939,14 @@ mod mac { } impl AgentRoute { - /// Listens in `dir` for `grant`, answering only `expected_uid`. Replaces a listener - /// already running. + /// Listens in `dir` for `grant`, answering only `expected_uid`, and among its programs + /// only those `programs` lets in. Replaces a listener already running. pub fn start( &self, host: Arc, grant: u64, expected_uid: u32, + programs: ProgramCheck, dir: &Path, limits: Limits, ) -> Result<(), RouteError> { @@ -578,7 +961,9 @@ mod mac { let stop = stop.clone(); std::thread::Builder::new() .name("agent-route".to_owned()) - .spawn(move || accept_loop(listener, host, grant, expected_uid, limits, stop)) + .spawn(move || { + accept_loop(listener, host, grant, expected_uid, programs, limits, stop) + }) }; let thread = match thread { Ok(thread) => thread, @@ -638,6 +1023,7 @@ mod mac { host: Arc, grant: u64, expected_uid: u32, + programs: ProgramCheck, limits: Limits, stop: Arc, ) { @@ -658,6 +1044,12 @@ mod mac { if peer_uid(&stream) != Some(expected_uid) || stream.set_nonblocking(false).is_err() { continue; } + // And in that account, only the approved tool started by Claude Code or Codex. + if let ProgramCheck::Required(policy) = &programs + && check_program(&stream, policy).is_err() + { + continue; + } let _ = stream.set_write_timeout(Some(limits.read_timeout)); if open.fetch_add(1, Ordering::SeqCst) >= limits.max_connections { open.fetch_sub(1, Ordering::SeqCst); @@ -756,6 +1148,7 @@ mod other { _host: Arc, _grant: u64, _expected_uid: u32, + _programs: ProgramCheck, _dir: &Path, _limits: Limits, ) -> Result<(), RouteError> { diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 3c56db5..a0979fb 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -348,6 +348,8 @@ pub struct AgentRouteView { pub expires_in_ms: Option, /// The Mac account named for the agent, if any. pub account: Option, + /// The code hash of the agent's tool the owner approved (ADR-0013), if any. + pub tool_hash: Option, } fn agent_route_view(app: &AppHandle) -> AgentRouteView { @@ -364,6 +366,7 @@ fn agent_route_view(app: &AppHandle) -> AgentRouteView { }, expires_in_ms: status.expires_in_ms.filter(|_| running), account: core.agent_account(), + tool_hash: core.agent_tool_hash(), } } @@ -383,12 +386,24 @@ pub fn set_agent_account( Ok(agent_route_view(&app)) } +/// Approves the agent tool's code hash (`None` or `""` clears it). Ends a running grant. +#[tauri::command(async)] +pub fn set_agent_tool_hash( + app: AppHandle, + hash: Option, +) -> Result { + with_core(&app, |core| core.set_agent_tool_hash(hash))?; + app.state::().stop(); + Ok(agent_route_view(&app)) +} + const AGENT_ROUTE_START: &str = "Start"; -/// What the owner agrees to in the Start dialog. `who` names the account let in. -fn agent_route_start_text(who: &str) -> String { +/// What the owner agrees to in the Start dialog. `who` names the account let in, and `which` +/// the programs in it that may ask. +fn agent_route_start_text(which: &str, who: &str) -> String { format!( - "For up to one hour, programs in the Mac user {who} can ask this app to store keys, \ + "For up to one hour, {which} in the Mac user {who} can ask this app to store keys, \ list and change the details of the keys they added, and ask to replace or delete one.\n\n\ You type every value here, and the agent never sees one. Changing where a key is used, \ replacing a value and deleting always ask you first.\n\n\ @@ -408,7 +423,10 @@ pub async fn agent_route_start(app: AppHandle) -> Result Result format!("“{name}”"), _ => format!("with uid {uid} (developer override)"), }; - let description = agent_route_start_text(&who); + let which = match &programs { + agent_route::ProgramCheck::Required(_) => "Claude Code or Codex", + #[cfg(debug_assertions)] + agent_route::ProgramCheck::Off => "any program (developer override)", + }; + let description = agent_route_start_text(which, &who); let window = app.get_webview_window("main"); let answer = with_native_dialog(&app, move || { // A sheet on the app's own window; Cancel first because Return presses the first button. @@ -444,16 +467,21 @@ pub async fn agent_route_start(app: AppHandle) -> Result() - .start(host, grant.id, uid, &dir, agent_route::Limits::PRODUCTION); + let started = app.state::().start( + host, + grant.id, + uid, + programs, + &dir, + agent_route::Limits::PRODUCTION, + ); if let Err(error) = started { with_core(&app, AppCore::agent_grant_stop); return Err(error.api_error()); diff --git a/src-tauri/src/core.rs b/src-tauri/src/core.rs index 9caec5d..c9bee21 100644 --- a/src-tauri/src/core.rs +++ b/src-tauri/src/core.rs @@ -342,6 +342,9 @@ struct StoredSettings { /// The Mac account the owner named for agents storing keys (ADR-0012). Only a name: who may /// connect is decided again from it at every Start (`agent_route::expected_uid`). agent_account: Option, + /// The code hash of the agent's tool the owner approved (ADR-0013), 40 lowercase hex + /// digits. Only programs can be told apart by it; it opens nothing. + agent_tool_hash: Option, } /// A Vault's id (also in its file's plaintext header) and its document's `change_counter`. Kept @@ -368,6 +371,7 @@ impl Default for StoredSettings { quick_unlock_binding: None, newest_seen: None, agent_account: None, + agent_tool_hash: None, } } } diff --git a/src-tauri/src/core/agent.rs b/src-tauri/src/core/agent.rs index e507a20..6b8eda9 100644 --- a/src-tauri/src/core/agent.rs +++ b/src-tauri/src/core/agent.rs @@ -50,6 +50,7 @@ const MSG_AGENT_VALUE_ONLY: &str = "Only the value can be changed here. Change the name or details in the Editor yourself."; const MSG_AGENT_NO_VALUE: &str = "Type the new value first, then choose Save."; const MSG_AGENT_ACCOUNT_NAME: &str = "That isn't a Mac account name. Use the Account name from System Settings → Users & Groups, such as enote-agent."; +const MSG_AGENT_TOOL_HASH: &str = "That isn't a code hash. Paste the CDHash line that scripts/agent-keys-setup.sh prints: 40 letters and digits, 0-9 and a-f."; // ---------- shapes ---------- @@ -346,6 +347,41 @@ impl AppCore { Ok(()) } + /// The code hash of the agent's tool the owner approved (ADR-0013), as 40 lowercase hex + /// digits. Not a secret: it identifies a program, it doesn't unlock anything. + pub fn agent_tool_hash(&self) -> Option { + self.settings.agent_tool_hash.clone() + } + + /// The owner approves the agent tool's code hash (or clears it with `None` or `""`). Only + /// while Unlocked. Like a new account name, a new hash ends a running grant, so it takes + /// effect only through a new Start. + pub fn set_agent_tool_hash(&mut self, hash: Option) -> Result<(), ApiError> { + self.tick(); + self.open()?; + let hash = match hash + .as_deref() + .map(str::trim) + .filter(|text| !text.is_empty()) + { + None => None, + Some(text) => Some(tool_hash_text( + &parse_tool_hash(text).ok_or_else(|| err("invalid_input", MSG_AGENT_TOOL_HASH))?, + )), + }; + if hash == self.settings.agent_tool_hash { + return Ok(()); + } + let before = self.settings.agent_tool_hash.clone(); + self.settings.agent_tool_hash = hash; + if let Err(error) = self.write_settings() { + self.settings.agent_tool_hash = before; + return Err(error); + } + self.agent_end(None); + Ok(()) + } + // ----- the five requests (agent) ----- /// Request 1: asks the owner to store a key the agent describes. Returns the request id; @@ -950,6 +986,29 @@ pub fn is_account_name(name: &str) -> bool { && bytes.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_' || b == b'-') } +/// The agent tool's code hash as the owner pastes it (ADR-0013): the 20-byte hash `codesign` +/// prints on its `CDHash=` line, as 40 hex digits, with or without that prefix. +pub fn parse_tool_hash(text: &str) -> Option<[u8; 20]> { + let text = text.trim(); + let hex = text + .strip_prefix("CDHash=") + .or_else(|| text.strip_prefix("cdhash=")) + .unwrap_or(text); + if hex.len() != 40 || !hex.bytes().all(|b| b.is_ascii_hexdigit()) { + return None; + } + let mut hash = [0u8; 20]; + for (i, byte) in hash.iter_mut().enumerate() { + *byte = u8::from_str_radix(&hex[2 * i..2 * i + 2], 16).ok()?; + } + Some(hash) +} + +/// The same hash written the one way it is kept: 40 lowercase hex digits. +pub fn tool_hash_text(hash: &[u8; 20]) -> String { + hash.iter().map(|b| format!("{b:02x}")).collect() +} + /// A suggestion names only visible fields an agent may describe, with a title. fn check_suggestion(suggestion: &AgentStoreSuggestion) -> Result<(), AgentError> { let fits = |text: &str| text.chars().count() <= MAX_AGENT_TEXT_CHARS; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index f8f61d9..f9ba639 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -105,6 +105,7 @@ pub fn run() { commands::agent_route_start, commands::agent_route_stop, commands::set_agent_account, + commands::set_agent_tool_hash, commands::show_field, commands::show_wallet_field, commands::show_and_copy_wallet_field, diff --git a/src-tauri/tests/agent_route.rs b/src-tauri/tests/agent_route.rs index 06cde85..6d24c83 100644 --- a/src-tauri/tests/agent_route.rs +++ b/src-tauri/tests/agent_route.rs @@ -9,9 +9,14 @@ use std::sync::{Arc, Mutex}; use std::time::Duration; use encrypted_note_app::agent_route::{ - Host, Limits, Request, RouteError, check_account, error_reply, handle, parse_request, + ALLOWED_STARTERS, CATEGORY_DEVELOPER_ID, CATEGORY_PLATFORM, CS_DEBUGGED, CS_GET_TASK_ALLOW, + CS_RUNTIME, CS_VALID, Host, Limits, ProgramCheck, ProgramIdentity, ProgramPolicy, + ProgramRefusal, Request, RouteError, Starter, check_account, error_reply, handle, + judge_programs, parse_request, program_check, +}; +use encrypted_note_app::core::agent::{ + AgentEdit, AgentError, AgentStoreSuggestion, parse_tool_hash, tool_hash_text, }; -use encrypted_note_app::core::agent::{AgentEdit, AgentError, AgentStoreSuggestion}; use encrypted_note_app::core::native::{NativeAction, NativeCredentials, NativeResult}; use encrypted_note_app::core::{ApiError, AppCore, Clipboard, Clock, Config, NoteInput}; use serde_json::{Value, json}; @@ -572,6 +577,315 @@ fn the_agent_account_is_saved_checked_and_ends_the_grant() { }); } +// ---------- only Claude Code and Codex (ADR-0013) ---------- + +/// An obviously fake code hash for the agent's tool. +const FAKE_TOOL_HASH: &str = "fa4efa4efa4efa4efa4efa4efa4efa4efa4efa4e"; + +#[test] +fn the_tool_hash_is_read_as_codesign_prints_it() { + let hash = parse_tool_hash(FAKE_TOOL_HASH).unwrap(); + assert_eq!(tool_hash_text(&hash), FAKE_TOOL_HASH); + // The whole CDHash line, upper case and surrounding space are fine. + let line = format!(" CDHash={}\n", FAKE_TOOL_HASH.to_uppercase()); + assert_eq!(parse_tool_hash(&line), Some(hash)); + for bad in [ + "", + "fa4e", + &FAKE_TOOL_HASH[..39], + &format!("{FAKE_TOOL_HASH}0"), + &format!("{}g", &FAKE_TOOL_HASH[..39]), + "sha256=fa4efa4efa4efa4efa4efa4efa4efa4efa4efa4e", + ] { + assert_eq!(parse_tool_hash(bad), None, "{bad:?}"); + } +} + +#[test] +fn the_tool_hash_is_saved_checked_and_ends_the_grant() { + let (host, _grant, _agent_id, _arrived) = unlocked(); + host.with_core(|core| { + assert_eq!(core.agent_tool_hash(), None); + for bad in ["fake", &FAKE_TOOL_HASH[..39], "zz"] { + assert_eq!( + core.set_agent_tool_hash(Some(bad.to_owned())) + .map_err(|e| e.code), + Err("invalid_input"), + "{bad}" + ); + } + assert_eq!(core.agent_grant_status().state, "running"); + core.set_agent_tool_hash(Some(format!("CDHash={}", FAKE_TOOL_HASH.to_uppercase()))) + .unwrap(); + // Kept one way only: 40 lowercase hex digits. + assert_eq!(core.agent_tool_hash().as_deref(), Some(FAKE_TOOL_HASH)); + assert_eq!(core.agent_grant_status().state, "off"); + core.set_agent_tool_hash(Some(" ".to_owned())).unwrap(); + assert_eq!(core.agent_tool_hash(), None); + core.lock(); + assert_eq!( + core.set_agent_tool_hash(Some(FAKE_TOOL_HASH.to_owned())) + .map_err(|e| e.code), + Err("locked") + ); + }); +} + +#[test] +fn start_needs_an_approved_tool_hash() { + if std::env::var_os("ENOTE_DEV_AGENT_ANY_PROGRAM").is_some() { + return; + } + assert_eq!(program_check(None), Err(RouteError::NoToolHash)); + assert_eq!( + program_check(Some("not a hash")), + Err(RouteError::NoToolHash) + ); + assert_eq!(RouteError::NoToolHash.api_error().code, "not_set_up"); + let hash = parse_tool_hash(FAKE_TOOL_HASH).unwrap(); + assert_eq!( + program_check(Some(FAKE_TOOL_HASH)), + Ok(ProgramCheck::Required(ProgramPolicy::production(hash))) + ); +} + +#[test] +fn the_allowed_programs_are_claude_code_and_codex_by_their_makers() { + let policy = ProgramPolicy::production(parse_tool_hash(FAKE_TOOL_HASH).unwrap()); + assert_eq!( + ALLOWED_STARTERS, + [ + ("com.anthropic.claude-code", "Q6L2SF6YDW"), + ("codex", "2DC432GLL2") + ] + ); + assert_eq!(policy.starters.len(), 2); + assert!( + policy + .starters + .iter() + .all(|s| s.category == CATEGORY_DEVELOPER_ID && s.team.is_some()) + ); +} + +/// The installed tool as the kernel describes it: ad-hoc signed with hardened runtime. +fn fake_tool() -> ProgramIdentity { + ProgramIdentity { + identity: Some("enote-agent-keys".to_owned()), + team: None, + category: Some(10), + status: CS_VALID | CS_RUNTIME, + cdhash: parse_tool_hash(FAKE_TOOL_HASH), + entitlements: None, + } +} + +/// Claude Code as seen on the owner's Mac (2026-11-09): Developer ID, hardened runtime, and +/// entitlements that disable library validation (accepted by the owner). +fn claude_code() -> ProgramIdentity { + ProgramIdentity { + identity: Some("com.anthropic.claude-code".to_owned()), + team: Some("Q6L2SF6YDW".to_owned()), + category: Some(CATEGORY_DEVELOPER_ID), + status: 0x2201_0301, + cdhash: Some([0x11; 20]), + entitlements: Some( + b"com.apple.security.cs.allow-jit\ + com.apple.security.cs.disable-library-validation" + .to_vec(), + ), + } +} + +fn codex() -> ProgramIdentity { + ProgramIdentity { + identity: Some("codex".to_owned()), + team: Some("2DC432GLL2".to_owned()), + category: Some(CATEGORY_DEVELOPER_ID), + status: 0x2201_1311, + cdhash: Some([0x22; 20]), + entitlements: None, + } +} + +#[test] +fn the_approved_tool_started_by_claude_code_or_codex_is_let_in() { + let policy = ProgramPolicy::production(parse_tool_hash(FAKE_TOOL_HASH).unwrap()); + assert_eq!( + judge_programs(&policy, &fake_tool(), &claude_code()), + Ok(()) + ); + assert_eq!(judge_programs(&policy, &fake_tool(), &codex()), Ok(())); +} + +#[test] +fn another_tool_or_starter_is_turned_away() { + let policy = ProgramPolicy::production(parse_tool_hash(FAKE_TOOL_HASH).unwrap()); + let tool = fake_tool(); + let starter = claude_code(); + let refused = |tool: &ProgramIdentity, starter: &ProgramIdentity| { + judge_programs(&policy, tool, starter).unwrap_err() + }; + + // Not the approved tool: another hash, none, or no valid signature. + let other = ProgramIdentity { + cdhash: Some([0x33; 20]), + ..tool.clone() + }; + assert_eq!(refused(&other, &starter), ProgramRefusal::NotTheTool); + let none = ProgramIdentity { + cdhash: None, + ..tool.clone() + }; + assert_eq!(refused(&none, &starter), ProgramRefusal::NotTheTool); + let invalid = ProgramIdentity { + status: CS_RUNTIME, + ..tool.clone() + }; + assert_eq!(refused(&invalid, &starter), ProgramRefusal::NotTheTool); + + // The spike's fake: Claude Code's identity, signed ad-hoc (no team, category "none"). + let fake_claude = ProgramIdentity { + team: None, + category: Some(10), + ..claude_code() + }; + assert_eq!( + refused(&tool, &fake_claude), + ProgramRefusal::NotAnAllowedStarter + ); + // The right identity with another company's team, or not validated as Developer ID. + let other_team = ProgramIdentity { + team: Some("2DC432GLL2".to_owned()), + ..claude_code() + }; + assert_eq!( + refused(&tool, &other_team), + ProgramRefusal::NotAnAllowedStarter + ); + let development = ProgramIdentity { + category: Some(3), + ..claude_code() + }; + assert_eq!( + refused(&tool, &development), + ProgramRefusal::NotAnAllowedStarter + ); + let unsigned = ProgramIdentity { + status: CS_RUNTIME, + ..claude_code() + }; + assert_eq!( + refused(&tool, &unsigned), + ProgramRefusal::NotAnAllowedStarter + ); + // A shell or any other program, Apple's own included. + let shell = ProgramIdentity { + identity: Some("com.apple.zsh".to_owned()), + team: None, + category: Some(CATEGORY_PLATFORM), + status: CS_VALID | CS_RUNTIME, + cdhash: Some([0x44; 20]), + entitlements: None, + }; + assert_eq!(refused(&tool, &shell), ProgramRefusal::NotAnAllowedStarter); + assert_eq!( + refused(&tool, &ProgramIdentity::default()), + ProgramRefusal::NotAnAllowedStarter + ); +} + +#[test] +fn a_tool_or_starter_open_to_outside_code_is_turned_away() { + let policy = ProgramPolicy::production(parse_tool_hash(FAKE_TOOL_HASH).unwrap()); + let tool = fake_tool(); + let starter = codex(); + let refused = |tool: &ProgramIdentity, starter: &ProgramIdentity| { + judge_programs(&policy, tool, starter).unwrap_err() + }; + // No hardened runtime (DYLD_INSERT_LIBRARIES would work), debuggable, or being debugged. + for status in [ + CS_VALID, + CS_VALID | CS_RUNTIME | CS_GET_TASK_ALLOW, + CS_VALID | CS_RUNTIME | CS_DEBUGGED, + ] { + let tool_open = ProgramIdentity { + status, + ..tool.clone() + }; + assert_eq!( + refused(&tool_open, &starter), + ProgramRefusal::Injectable, + "{status:#x}" + ); + let starter_open = ProgramIdentity { + status, + ..starter.clone() + }; + assert_eq!( + refused(&tool, &starter_open), + ProgramRefusal::Injectable, + "{status:#x}" + ); + } + // An entitlement that lets code in, whatever its value. + for entitlement in [ + "com.apple.security.cs.allow-dyld-environment-variables", + "com.apple.security.get-task-allow", + ] { + let xml = format!("{entitlement}"); + let starter_open = ProgramIdentity { + entitlements: Some(xml.clone().into_bytes()), + ..starter.clone() + }; + assert_eq!( + refused(&tool, &starter_open), + ProgramRefusal::Injectable, + "{entitlement}" + ); + let tool_open = ProgramIdentity { + entitlements: Some(xml.into_bytes()), + ..tool.clone() + }; + assert_eq!( + refused(&tool_open, &starter), + ProgramRefusal::Injectable, + "{entitlement}" + ); + } + // Disabled library validation alone is accepted for Claude Code (owner, 2026-11-09). + assert_eq!(judge_programs(&policy, &tool, &claude_code()), Ok(())); +} + +#[test] +fn a_starter_list_can_name_apples_own_programs_for_tests() { + let policy = ProgramPolicy { + tool_cdhash: [0x55; 20], + starters: vec![Starter { + identity: "com.apple.bash".to_owned(), + team: None, + category: CATEGORY_PLATFORM, + }], + }; + let tool = ProgramIdentity { + cdhash: Some([0x55; 20]), + ..fake_tool() + }; + let bash = ProgramIdentity { + identity: Some("com.apple.bash".to_owned()), + team: None, + category: Some(CATEGORY_PLATFORM), + status: CS_VALID | CS_RUNTIME, + cdhash: Some([0x66; 20]), + entitlements: None, + }; + assert_eq!(judge_programs(&policy, &tool, &bash), Ok(())); + assert_eq!( + judge_programs(&policy, &tool, &claude_code()), + Err(ProgramRefusal::NotAnAllowedStarter) + ); +} + // ---------- the listener (macOS) ---------- #[cfg(target_os = "macos")] @@ -582,7 +896,7 @@ mod listener { use std::path::{Path, PathBuf}; use encrypted_note_app::agent_route::{ - self, AgentRoute, SOCKET_NAME, check_folder, expected_uid, + self, AgentRoute, SOCKET_NAME, check_folder, expected_uid, parent_pid, program_identity, }; use super::*; @@ -688,7 +1002,14 @@ mod listener { let (host, grant, agent_id, _arrived) = unlocked(); let route = AgentRoute::default(); route - .start(host.clone(), grant, own_uid(), &dir, fast()) + .start( + host.clone(), + grant, + own_uid(), + ProgramCheck::Off, + &dir, + fast(), + ) .unwrap(); assert!(route.is_active()); let socket = dir.join(SOCKET_NAME); @@ -719,7 +1040,7 @@ mod listener { // As if the agent account were someone else: this test's own uid is then a stranger, // exactly as the owner's own programs are to the real route. route - .start(host, grant, own_uid() + 1, &dir, fast()) + .start(host, grant, own_uid() + 1, ProgramCheck::Off, &dir, fast()) .unwrap(); let mut stream = UnixStream::connect(dir.join(SOCKET_NAME)).unwrap(); stream @@ -741,7 +1062,9 @@ mod listener { read_timeout: Duration::from_millis(300), ..fast() }; - route.start(host, grant, own_uid(), &dir, limits).unwrap(); + route + .start(host, grant, own_uid(), ProgramCheck::Off, &dir, limits) + .unwrap(); let long = format!( r#"{{"v":1,"op":"list","pad":"{}"}}"#, "x".repeat(16 * 1024 + 1) @@ -767,7 +1090,9 @@ mod listener { max_connections: 1, ..fast() }; - route.start(host, grant, own_uid(), &dir, limits).unwrap(); + route + .start(host, grant, own_uid(), ProgramCheck::Off, &dir, limits) + .unwrap(); // Holds the one slot by never sending its line. let _first = UnixStream::connect(dir.join(SOCKET_NAME)).unwrap(); std::thread::sleep(Duration::from_millis(300)); @@ -787,13 +1112,27 @@ mod listener { drop(std::os::unix::net::UnixListener::bind(&socket).unwrap()); let route = AgentRoute::default(); route - .start(host.clone(), grant, own_uid(), &dir, fast()) + .start( + host.clone(), + grant, + own_uid(), + ProgramCheck::Off, + &dir, + fast(), + ) .unwrap(); route.stop(); // A file that isn't a socket where the socket goes: refused, and left alone. std::fs::write(&socket, "fake-not-a-socket").unwrap(); assert_eq!( - route.start(host.clone(), grant, own_uid(), &dir, fast()), + route.start( + host.clone(), + grant, + own_uid(), + ProgramCheck::Off, + &dir, + fast() + ), Err(RouteError::NotSetUp) ); assert_eq!( @@ -802,7 +1141,9 @@ mod listener { ); std::fs::remove_file(&socket).unwrap(); // The socket replaced by something else while running: stop leaves that alone. - route.start(host, grant, own_uid(), &dir, fast()).unwrap(); + route + .start(host, grant, own_uid(), ProgramCheck::Off, &dir, fast()) + .unwrap(); std::fs::remove_file(&socket).unwrap(); std::fs::write(&socket, "fake-replacement").unwrap(); route.stop(); @@ -819,7 +1160,7 @@ mod listener { std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o755)).unwrap(); let route = AgentRoute::default(); assert_eq!( - route.start(host, grant, own_uid(), &dir, fast()), + route.start(host, grant, own_uid(), ProgramCheck::Off, &dir, fast()), Err(RouteError::NotSetUp) ); assert!(!route.is_active()); @@ -843,7 +1184,14 @@ mod listener { let (host, grant, agent_id, _arrived) = unlocked(); let route = AgentRoute::default(); route - .start(host.clone(), grant, own_uid(), &dir, fast()) + .start( + host.clone(), + grant, + own_uid(), + ProgramCheck::Off, + &dir, + fast(), + ) .unwrap(); let mut child = Command::new(tool) .args(["--owner-uid", &own_uid().to_string(), "--socket"]) @@ -900,7 +1248,14 @@ mod listener { let (host, grant, _agent_id, arrived) = unlocked(); let route = AgentRoute::default(); route - .start(host.clone(), grant, own_uid(), &dir, fast()) + .start( + host.clone(), + grant, + own_uid(), + ProgramCheck::Off, + &dir, + fast(), + ) .unwrap(); let asking = { let dir = dir.clone(); @@ -922,4 +1277,153 @@ mod listener { ); route.stop(); } + + // ----- ADR-0013 against real programs: the kernel's record, a real socket ----- + + /// Apple's own `/usr/bin/nc`, in place of the agent's tool, and `/bin/bash` in place of + /// Claude Code or Codex: both are signed with hardened runtime, and any Mac has them. + const STAND_IN_TOOL: &str = "/usr/bin/nc"; + + /// The stand-in tool's code hash as `codesign` prints it, the way the owner gets the real + /// tool's from the setup script. + fn stand_in_tool_hash() -> [u8; 20] { + let out = std::process::Command::new("/usr/bin/codesign") + .args(["-dv", "--verbose=4", STAND_IN_TOOL]) + .output() + .unwrap(); + let text = String::from_utf8_lossy(&out.stderr).into_owned(); + let line = text + .lines() + .find(|line| line.starts_with("CDHash=")) + .unwrap(); + parse_tool_hash(line).unwrap() + } + + fn bash_starts_the_tool(tool_hash: [u8; 20]) -> ProgramPolicy { + ProgramPolicy { + tool_cdhash: tool_hash, + starters: vec![Starter { + identity: "com.apple.bash".to_owned(), + team: None, + category: CATEGORY_PLATFORM, + }], + } + } + + /// `/bin/bash` runs the stand-in tool, which sends one request and prints the reply. An + /// empty answer is a connection closed without a word. + fn ask_through_bash(dir: &Path, line: &str) -> String { + let socket = dir.join(SOCKET_NAME); + let out = std::process::Command::new("/bin/bash") + .arg("-c") + .arg(r#"printf '%s\n' "$1" | /usr/bin/nc -U "$2"; true"#) + .arg("bash") + .arg(line) + .arg(&socket) + .output() + .unwrap(); + String::from_utf8_lossy(&out.stdout).into_owned() + } + + #[test] + fn the_kernel_says_who_a_running_program_is_and_who_started_it() { + let mut child = std::process::Command::new("/bin/sleep") + .arg("30") + .spawn() + .unwrap(); + let pid = i32::try_from(child.id()).unwrap(); + let seen = program_identity(pid); + let parent = parent_pid(pid); + child.kill().unwrap(); + child.wait().unwrap(); + let seen = seen.unwrap(); + assert_eq!(seen.identity.as_deref(), Some("com.apple.sleep")); + assert_eq!(seen.team, None); + assert_eq!(seen.category, Some(CATEGORY_PLATFORM)); + assert_eq!(seen.status & (CS_VALID | CS_RUNTIME), CS_VALID | CS_RUNTIME); + assert!(seen.cdhash.is_some()); + assert_eq!(parent, Some(i32::try_from(std::process::id()).unwrap())); + // A process that has gone has nothing to say. + assert_eq!(program_identity(pid), None); + assert_eq!(parent_pid(pid), None); + } + + #[test] + fn the_approved_tool_started_by_an_allowed_program_is_answered() { + let (_temp, dir) = folder(); + let (host, grant, agent_id, _arrived) = unlocked(); + let route = AgentRoute::default(); + let policy = bash_starts_the_tool(stand_in_tool_hash()); + route + .start( + host, + grant, + own_uid(), + ProgramCheck::Required(policy), + &dir, + fast(), + ) + .unwrap(); + let answer = ask_through_bash(&dir, r#"{"v":1,"op":"list"}"#); + route.stop(); + assert_clean(&answer); + assert_eq!(reply(&answer)["keys"][0]["id"], json!(agent_id)); + } + + #[test] + fn the_wrong_starter_or_tool_is_closed_without_a_reply() { + let tool = stand_in_tool_hash(); + let policies = [ + // The real list: bash is neither Claude Code nor Codex. + ProgramPolicy::production(tool), + // Bash may start it, but this isn't the approved tool. + bash_starts_the_tool(parse_tool_hash(FAKE_TOOL_HASH).unwrap()), + ]; + for policy in policies { + let (_temp, dir) = folder(); + let (host, grant, _agent_id, _arrived) = unlocked(); + let route = AgentRoute::default(); + route + .start( + host, + grant, + own_uid(), + ProgramCheck::Required(policy.clone()), + &dir, + fast(), + ) + .unwrap(); + let answer = ask_through_bash(&dir, r#"{"v":1,"op":"list"}"#); + route.stop(); + assert_eq!(answer, "", "{policy:?}"); + } + } + + #[test] + fn a_program_connecting_by_itself_is_closed_without_a_reply() { + let (_temp, dir) = folder(); + let (host, grant, _agent_id, _arrived) = unlocked(); + let route = AgentRoute::default(); + let policy = ProgramPolicy::production(stand_in_tool_hash()); + route + .start( + host, + grant, + own_uid(), + ProgramCheck::Required(policy), + &dir, + fast(), + ) + .unwrap(); + // This test program itself, not the approved tool and not started by an allowed one. + let mut stream = UnixStream::connect(dir.join(SOCKET_NAME)).unwrap(); + stream + .set_read_timeout(Some(Duration::from_secs(10))) + .unwrap(); + let _ = stream.write_all(b"{\"v\":1,\"op\":\"list\"}\n"); + let mut answer = Vec::new(); + let _ = stream.read_to_end(&mut answer); + route.stop(); + assert!(answer.is_empty(), "{}", String::from_utf8_lossy(&answer)); + } } diff --git a/src/Settings.tsx b/src/Settings.tsx index 3cd0ec2..9d6f4bd 100644 --- a/src/Settings.tsx +++ b/src/Settings.tsx @@ -26,6 +26,7 @@ import { restoreSafetyCopy, revealVaultFolder, setAgentAccount, + setAgentToolHash, setSettings, vaultLocation, writeBackup, @@ -567,14 +568,18 @@ function AgentCompanion({ companion }: { companion: ReturnType(null); // What the account box holds, once the owner has typed in it; null: as saved. const [typed, setTyped] = useState(null); const [saved, setSaved] = useState(false); + // The same for the tool's code hash. + const [typedHash, setTypedHash] = useState(null); + const [savedHash, setSavedHash] = useState(false); const [open, setOpen] = useState(false); const acting = useRef(false); const { busy, error, run } = useAction(); @@ -593,6 +598,7 @@ function AgentKeys() { }, []); const act = (call: () => Promise) => { setSaved(false); + setSavedHash(false); acting.current = true; return run(async () => setView(await call())).finally(() => (acting.current = false)); }; @@ -604,6 +610,14 @@ function AgentKeys() { setTyped(null); setSaved(true); }); + const hash = typedHash ?? view?.tool_hash ?? ""; + const changedHash = (hash.trim() || null) !== (view?.tool_hash ?? null); + const saveHash = () => + act(() => setAgentToolHash(hash.trim() || null)).then((ok) => { + if (!ok) return; + setTypedHash(null); + setSavedHash(true); + }); const on = view?.state === "running"; // On, and anything that went wrong, is never folded away. const mustShow = on || !!error; @@ -648,6 +662,30 @@ function AgentKeys() { +
{ + e.preventDefault(); + if (changedHash && !busy) saveHash(); + }} + > + {s.agentAccountSaved}}> + { + setSavedHash(false); + setTypedHash(e.target.value); + }} + {...noAssist} + /> + +
+ +
+

{s.agentKeysSetup}

{!on &&

{s.agentKeysStartLocks}

}
@@ -656,7 +694,7 @@ function AgentKeys() { {s.agentKeysStop} ) : ( - )} diff --git a/src/api.ts b/src/api.ts index f47bcaf..db0eaf8 100644 --- a/src/api.ts +++ b/src/api.ts @@ -535,6 +535,8 @@ export interface AgentRouteView { expires_in_ms: number | null; /** The Mac account named for the agent, if any. Not a secret. */ account: string | null; + /** The approved code hash of the agent's tool (ADR-0013), 40 hex digits, if any. Not a secret. */ + tool_hash: string | null; } export const agentRouteStatus = () => invoke("agent_route_status"); @@ -544,3 +546,6 @@ export const agentRouteStart = () => invoke("agent_route_start") export const agentRouteStop = () => invoke("agent_route_stop"); /** null or "" clears it. A running grant ends: a new name needs a new Start. */ export const setAgentAccount = (account: string | null) => invoke("set_agent_account", { account }); +/** The CDHash line the setup script prints, or just its 40 hex digits; null or "" clears it. + * A running grant ends: a new tool needs a new Start. */ +export const setAgentToolHash = (hash: string | null) => invoke("set_agent_tool_hash", { hash }); diff --git a/src/dev-fixture.ts b/src/dev-fixture.ts index 14f8adf..b614059 100644 --- a/src/dev-fixture.ts +++ b/src/dev-fixture.ts @@ -154,6 +154,7 @@ const agentRoute: AgentRouteView = { state: agentQuery as AgentRouteState, expires_in_ms: agentQuery === "running" ? 42 * 60_000 : null, account: "fake-agent", + tool_hash: "fa4efa4efa4efa4efa4efa4efa4efa4efa4efa4e", }; const noteFor = (id: string) => views.find((note) => note.id === id)!; const requestFor = (kind: string): AgentPendingView | null => { @@ -232,6 +233,9 @@ mockIPC( case "agent_route_status": case "set_agent_account": return agentRoute; + case "set_agent_tool_hash": + agentRoute.tool_hash = ((args as { hash: string | null }).hash ?? "").replace(/^cdhash=/i, "").trim().toLowerCase() || null; + return agentRoute; case "agent_route_start": agentRoute.state = "running"; agentRoute.expires_in_ms = 60 * 60_000; diff --git a/src/strings.ts b/src/strings.ts index e835c88..0f4399c 100644 --- a/src/strings.ts +++ b/src/strings.ts @@ -773,7 +773,7 @@ export const strings = { agentKeysSummary: (state: string) => `Agents storing keys (optional) · ${state}`, agentKeysChecking: "Checking…", agentKeysIntro: - "Optional, and off unless you start it. While it is on, an agent in a separate Mac user can ask this app to store a key, list and change the details of the keys it added, and ask you to replace or delete one. You type every value here, and the agent never sees or uses one. It can't see your own Notes. This is separate from the Agent Companion.", + "Optional, and off unless you start it. While it is on, Claude Code or Codex in a separate Mac user can ask this app to store a key, list and change the details of the keys it added, and ask you to replace or delete one. You type every value here, and the agent never sees or uses one. It can't see your own Notes. This is separate from the Agent Companion.", agentKeysLeaves: "The names and visible details of the keys an agent adds reach that agent's AI company as part of the chat. Name them with that in mind.", agentKeysStates: { off: "Off", @@ -787,8 +787,11 @@ export const strings = { agentAccountHint: "Its Account name from System Settings → Users & Groups, such as enote-agent. A Standard user, never yours or an admin. Changing it stops the agent's access.", agentAccountSave: "Save", agentAccountSaved: "Saved.", + agentToolHash: "The agent tool's code hash", + agentToolHashHint: + "Paste the CDHash line scripts/agent-keys-setup.sh prints. Only that build of the tool, started by Claude Code or Codex, is answered. Paste the new one after you install the tool again; changing it stops the agent's access.", agentKeysSetup: - "Before you start: make that Standard user, run scripts/agent-keys-setup.sh with its name from your own Mac user, and add the tool it prints to the agent there.", + "Before you start: make that Standard user, run scripts/agent-keys-setup.sh with its name from your own Mac user, paste the code hash it prints above, and add the tool it prints to Claude Code or Codex there.", agentKeysStart: "Start for up to one hour", agentKeysStartLocks: "Start asks you again in its own window. The hour ends early if the Vault locks.", agentKeysStop: "Stop",