From da39bee633653d035746fc106d3ea6951ff0ee16 Mon Sep 17 00:00:00 2001 From: NNIIOVO Date: Mon, 9 Nov 2026 17:02:15 -0600 Subject: [PATCH] fix(agent route): let the sandboxed app open its socket; record the ADR-0013 spike The ADR-0013 spike, run with this app's exact entitlements and hardened runtime, found that the folder exception for /Users/Shared/encrypted- note-keys covers files but not Unix sockets: bind() there is refused under the App Sandbox, with or without network.server. So the merged ADR-0012 route could not have opened its socket in a sandboxed build; the tests pass because they run unsandboxed. Add one temporary-exception.sbpl rule allowing only network-bind and network-inbound on Unix sockets in that folder (no outbound, no IP sockets), approved by the owner on 2026-11-09, and update its pinned hash. The spike showed the rule works with the exact file committed here; a Developer ID-signed, notarized build still has to confirm it. ADR-0013 records the spike: the Security framework's signature checks are refused under the sandbox for other programs, but the kernel's own record (csops_audittoken, sysctl) gives identity, team and validation category for Claude Code, Codex and other accounts' processes, and a fake ad-hoc "com.anthropic.claude-code" shows no team and no Developer ID category. The owner accepted Claude Code although its signature disables library validation. ADR-0012, the guide and the security model describe the two entitlements. Co-Authored-By: Claude Opus 5.5 --- docs/adr/0012-agents-store-and-manage-keys.md | 6 +- ...ude-code-and-codex-use-the-agent-routes.md | 61 ++++++++++++++----- docs/agent-keys.md | 6 +- docs/security-model.md | 4 +- scripts/pinned-hashes.txt | 2 +- src-tauri/Entitlements.plist | 8 +++ src-tauri/src/agent_route.rs | 2 +- 7 files changed, 69 insertions(+), 20 deletions(-) diff --git a/docs/adr/0012-agents-store-and-manage-keys.md b/docs/adr/0012-agents-store-and-manage-keys.md index 1bb2fe6..98e07e5 100644 --- a/docs/adr/0012-agents-store-and-manage-keys.md +++ b/docs/adr/0012-agents-store-and-manage-keys.md @@ -63,7 +63,11 @@ The owner settled these details on 2026-11-05 and 2026-11-06, before the build: - **Where values are typed:** in the app's ordinary editor, the same Add and Edit screen as when you add a key yourself, filled in from the agent's suggestion. Seed Phrases and Private Keys then need the Master Password in today's native panel. "The app's own window" above means the app's window, not the agent's: there is no new native panel for values, and the value takes the same path as any value typed in the editor ([ADR-0003](0003-tauri-with-secrets-kept-in-rust.md)). - **Edits, field by field.** At once: the name of a Login, API Key or Text, a Login's or API Key's purpose, and an API Key's expiry date. The owner allows first: a Login's website and username, and an API Key's service, key ID, where it is managed and variable name. The owner allows, then types the Master Password: every edit of a Seed Phrase or Private Key, its name included. Never: a Hidden Field (only Replace changes a value) and a Seed Phrase's word count. - **Delete** needs the owner's confirmation and the Master Password for every Kind. -- **The approved entitlement:** one `temporary-exception.files.absolute-path.read-write` entry for `/Users/Shared/encrypted-note-keys/`, where the app makes its socket. No network entitlement. +- **The approved entitlements:** + - one `temporary-exception.files.absolute-path.read-write` entry for `/Users/Shared/encrypted-note-keys/`, where the app makes its socket; + - approved 2026-11-09, after the [ADR-0013](0013-only-claude-code-and-codex-use-the-agent-routes.md) spike showed that entry alone can't make a socket under the sandbox: one `temporary-exception.sbpl` rule, `(allow network-bind network-inbound (local unix-socket (subpath "/Users/Shared/encrypted-note-keys")))`. + + No new network entitlement, no outbound connections and no IP sockets. Where it lives: the mark and the edit rules in `crates/notes/src/agent.rs`, the grant and the five requests in `src-tauri/src/core/agent.rs`, the socket in `src-tauri/src/agent_route.rs`, the agent's tool in `crates/agent-keys-mcp`, the owner's setup in `scripts/agent-keys-setup.sh`, and the screens in `src/AgentRequest.tsx`, `src/Note.tsx`, `src/Home.tsx` and `src/Settings.tsx`. Steps 2 to 4 of the build order are built and tested with fictional data; steps 5 and 6 are not done. 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 3849e82..9020e88 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: Proposed 2026-11-09 from the owner's choices below; it stands once the owner merges it. **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. **The check itself is not built.** 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)). @@ -10,43 +10,72 @@ Amends [ADR-0012](0012-agents-store-and-manage-keys.md) ("It accepts connections ## Decision -- **Three checks on every connection.** All three come from the kernel and macOS, never from anything the caller says about itself: +- **Three checks on every connection.** All three come from the kernel, never from anything the caller says about itself: 1. **The account** is the separate agent account (as built for [ADR-0012](0012-agents-store-and-manage-keys.md)). - 2. **The connecting program** is the installed `enote-agent-keys` tool. Its code hash must match the one the owner approved in Settings, which the setup script prints. Once the tool is bundled inside the app and signed with the app's identity, the app checks that signature instead. - 3. **The program that started the tool** is Claude Code or Codex, checked by its Apple code signature against the list below. + 2. **The connecting program** is the installed `enote-agent-keys` tool. Its code hash must match the one the owner approved in Settings, which the setup script prints. Once the tool is bundled inside the app and signed with the app's identity, the app checks that identity instead. + 3. **The program that started the tool** is Claude Code or Codex, from the list below. A connection that fails any check is closed without a reply, as one from the wrong account is today. -- **The allowed programs**, as found on the owner's Mac on 2026-11-09: +- **The allowed programs**, as seen on the owner's Mac on 2026-11-09: - | Program | Signed by | Team ID | Identifier | + | Program | Identity | Team | Signed by | |---|---|---|---| - | Claude Code (command line) | Developer ID Application: Anthropic PBC | `Q6L2SF6YDW` | `com.anthropic.claude-code` | - | Codex (command line, its native program) | Developer ID Application: OpenAI OpCo, LLC | `2DC432GLL2` | `codex` | + | Claude Code (command line, and the copy the Claude app's Code tab runs) | `com.anthropic.claude-code` | `Q6L2SF6YDW` | Developer ID Application: Anthropic PBC | + | Codex (command line, its native program) | `codex` | `2DC432GLL2` | Developer ID Application: OpenAI OpCo, LLC | - The list is fixed in code: nothing the agent account can write changes it. The Claude app and the Codex app are added only after a check shows which of their processes starts the tool. ChatGPT and every other program are not on the list. + The list is fixed in code: nothing the agent account can write changes it. + - **Covered:** the Claude app's Code tab runs its own copy of Claude Code, with the same identity and team, and starts tools from it directly. So it is covered. + - **Not yet covered:** the Claude app's chat side and the Codex app are added only after a check shows which of their processes starts the tool. + - **Never on the list:** ChatGPT and every other program. - **Codex installed with npm.** There the `codex` command is a Node.js script, which can't be signed. It starts Codex's signed native program, which is the program that starts the tool. The build step confirms this on a real run. -- **No fooling it with reused process numbers or injected code.** The connecting program is identified by the connection's audit token. The program that started it is identified by its process number, re-checked after the signature check so a reused number can't pass; the build PR shows how. A program whose signature lets outside code be loaded into it is refused: disabled library validation, allowed DYLD environment variables, or a debuggable signature. +- **How a program is identified.** By the kernel's record of the running program, never by reading program files. The App Sandbox refuses the Security framework's checks for other programs, because those read the program's file (see the spike). + - **The caller:** identified through its connection's audit token. + - **The program that started it:** found by process number through `sysctl`, re-checked afterwards so a reused number can't pass. +- **What must match.** All of these, together: + - **identity and team, exactly:** an identity alone can be faked by anyone (the spike faked one); + - **validation category "Developer ID":** macOS records this only when the signature chains to Apple's Developer ID, so a matching team can't be faked without that company's certificate; + - **a valid signature with hardened runtime;** + - **not debuggable and not being debugged.** +- **Outside code.** A program whose signature allows DYLD environment variables is refused. Neither Claude Code nor Codex has that entitlement today. If the kernel won't give a program's entitlements inside the sandbox, the build PR comes back to the owner. +- **Claude Code is accepted although its signature disables library validation** (owner's decision, 2026-11-09). Both the Homebrew and the Claude app copies carry `disable-library-validation` and `allow-unsigned-executable-memory`; Codex carries neither. See Consequences for what that allows. - **Where the code lives.** In `src-tauri/src/agent_route.rs`, next to the existing peer checks, under the same placement rule in `scripts/check-network-source.mjs`. -- **A spike comes first.** It tests whether the sandboxed app may read another account's process information and code signatures. If it can't, the check isn't built that way, and this decision goes back to the owner. ## Consequences - **A speed bump, not a wall.** It stops other programs in the agent account from using the route, whether directly or by running the tool. It doesn't stop a program in that account from starting Claude Code or Codex itself, with its own instructions, settings or MCP configuration. So the agent account must still hold only Claude Code and Codex. The separate account stays the real boundary. +- **Claude Code can load libraries Anthropic didn't sign.** A program in the agent account could plant one where Claude Code loads it, and act as Claude Code. That is the same rule again: the agent account holds only Claude Code and Codex. - **It doesn't make Claude Code or Codex trustworthy.** A tricked Claude Code or Codex passes the check. Everything [ADR-0011](0011-chat-wallets.md), [ADR-0012](0012-agents-store-and-manage-keys.md) and [ADR-0014](0014-agent-key-operations-through-the-companion.md) say a tricked agent can still do stays true. - **Updates can lock agents out.** If Anthropic or OpenAI change their signing identity, or the way the tool is started, the route refuses until a new build updates the list. It fails closed. -- **The app reads a little about other programs:** the connecting program's signature and code hash, and its parent's signature. Nothing else, and none of it leaves the app. +- **The app reads a little about other programs:** the caller's and its parent's identity, team, validation category, code hash and signature status, from the kernel. Nothing else, and none of it leaves the app. ## Build order Each step is its own pull request. 1. These documents. -2. The spike, in a signed, sandboxed test build (a test identifier only, never the owner's Vault): can the app read what the check needs? +2. **Done 2026-11-09:** the spike. A stand-in, signed ad-hoc with hardened runtime and the app's exact entitlements, answered what the check needs. It also found that the socket itself needed one more sandbox rule (see below). 3. The check on the [ADR-0012](0012-agents-store-and-manage-keys.md) route, with tests: - accepted: the right tool, started by Claude Code or by Codex; - - refused: the tool started by a shell or any other program, a program connecting directly, a wrong code hash, and a program whose signature allows outside code. + - refused: the tool started by a shell or any other program, a program connecting directly, a wrong code hash, a program signed only ad-hoc that claims an allowed identity, and a program that allows DYLD environment variables. 4. The same check on the Agent Companion's agent entry ([ADR-0014](0014-agent-key-operations-through-the-companion.md)). -5. Hand checks from Claude Code and Codex in the agent account, and from a third program that must be refused. +5. Hand checks from Claude Code and Codex in the agent account, and from a third program that must be refused, in a Developer ID-signed build. + +## Spike result (2026-11-09) + +A throwaway Swift program, signed ad-hoc with this app's exact `Entitlements.plist`, with hardened runtime, and given a bundle identity. Every test also ran unsandboxed as a control. Fake data only, on the owner's Mac (macOS 26). + +| What the check needs | Security framework (reads the program file) | Kernel record (`csops_audittoken`, `sysctl`) | +|---|---|---| +| The caller's identity, by its audit token | Refused, except for Apple's own programs | Works | +| Claude Code's and Codex's identity, team and validation category | Refused | Works: Developer ID for both | +| A process owned by another account | Refused | Works | +| The parent's process number | `proc_pidinfo` is refused for another account, even unsandboxed | `sysctl` works, other accounts too | +| A fake signed ad-hoc as `com.anthropic.claude-code` | n/a | Identity matches, but no team and category "none": refused | + +**The socket.** The folder entry in `Entitlements.plist` covers files but not Unix sockets. +- **Refused:** `bind()` there, even with `network.server` added; and connecting out to a listener there. +- **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. ## Considered Options @@ -54,3 +83,5 @@ Each step is its own pull request. - **Asking the caller who it is,** such as a name or token the agent sends: rejected. Any program can claim to be Claude Code. - **Checking only the tool, not who started it:** rejected. Any program in the agent account can run the tool. - **Checking the whole chain of parent processes:** not needed. The tool's direct parent must be on the list, so a shell or other program in between is refused. +- **The Security framework's signature checks:** ruled out by the spike. The sandbox refuses them for other programs. +- **Refusing every program that disables library validation:** rejected by the owner, because it would refuse Claude Code. diff --git a/docs/agent-keys.md b/docs/agent-keys.md index 02e2b17..6bdcb0f 100644 --- a/docs/agent-keys.md +++ b/docs/agent-keys.md @@ -170,7 +170,11 @@ The code: `src-tauri/src/core/agent.rs` (the grant, the five requests and their **Still to do by hand, on a real Mac, with fake keys:** - [ ] `scripts/agent-keys-setup.sh` without `--dry-run`, for a real Standard account: the folder, its mode and its one ACL entry, and that the agent account can reach the socket but not list the folder. -- [ ] The built, sandboxed app can make its socket in `/Users/Shared/encrypted-note-keys` with its one new entitlement, and Start's account check works inside the sandbox (if macOS can't answer the group check there, Start refuses). +- [ ] The built, sandboxed app can make its socket in `/Users/Shared/encrypted-note-keys` with its two entitlements, and Start's account check works inside the sandbox (if macOS can't answer the group check there, Start refuses). + - **Partly answered by a spike on 2026-11-09.** It used a stand-in signed ad-hoc, with hardened runtime and this app's exact entitlements. + - **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. - [ ] A connection from your own account, and from a third account, gets no reply. - [ ] 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. diff --git a/docs/security-model.md b/docs/security-model.md index 4f358a5..f1c751d 100644 --- a/docs/security-model.md +++ b/docs/security-model.md @@ -30,7 +30,9 @@ - **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. The entitlement it needs is the one `temporary-exception.files.absolute-path.read-write` entry for that folder, 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. 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: + - 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. ## Tested so far diff --git a/scripts/pinned-hashes.txt b/scripts/pinned-hashes.txt index c95dd21..823463d 100644 --- a/scripts/pinned-hashes.txt +++ b/scripts/pinned-hashes.txt @@ -2,6 +2,6 @@ # check.sh fails if one differs. Change a line only in the pull request that changes the file, # and say why there: the owner reviews it. companion/policy.json is pinned in # src-tauri/companion/companion.lock.json instead. -101968d3c18b423f5aa588d738148b86ef14121a872cdcb054dd2d981a993878 src-tauri/Entitlements.plist +cd6e849a3dce73da0a3eaf37ed4ab5553c7aaac7ea0266d983d9baccdee2da50 src-tauri/Entitlements.plist f5be827d7f59a3c0174e81e2fcaf9a6dde063d6d2b0ab096cd16c112d69403c8 src-tauri/companion/helper.entitlements 8b0aa42216f7b2be93f8f3d3a0e11f0e93d4b7faa837fe4408af92011de98f24 src-tauri/companion/node.entitlements diff --git a/src-tauri/Entitlements.plist b/src-tauri/Entitlements.plist index 21fb313..a9c6ee2 100644 --- a/src-tauri/Entitlements.plist +++ b/src-tauri/Entitlements.plist @@ -17,5 +17,13 @@ /Users/Shared/encrypted-note-keys/ + + com.apple.security.temporary-exception.sbpl + + (allow network-bind network-inbound (local unix-socket (subpath "/Users/Shared/encrypted-note-keys"))) + diff --git a/src-tauri/src/agent_route.rs b/src-tauri/src/agent_route.rs index c9ae756..18f436a 100644 --- a/src-tauri/src/agent_route.rs +++ b/src-tauri/src/agent_route.rs @@ -38,7 +38,7 @@ use crate::core::agent::{ use crate::core::{ApiError, AppCore}; /// The folder the owner's setup script makes, and the only place the app may write a socket -/// (the one path exception in `Entitlements.plist`). +/// (the folder exception and the one Unix-socket rule in `Entitlements.plist`). pub const SOCKET_DIR: &str = "/Users/Shared/encrypted-note-keys"; pub const SOCKET_NAME: &str = "agent.sock"; pub const PROTOCOL_VERSION: u64 = 1;