Skip to content

Resolve API keys from macOS Keychain - #82

Merged
glenjbarber merged 1 commit into
mainfrom
claude/relaxed-knuth-1frpkt
Oct 13, 2026
Merged

glenjbarber merged 1 commit into
mainfrom
claude/relaxed-knuth-1frpkt

Conversation

@glenjbarber

Copy link
Copy Markdown
Owner

Fixes #67.

What

Adds internal/keychain, a small wrapper around the security CLI for looking up, setting and deleting generic-password items by account/service — the macOS Keychain mechanism issue #67 asks for, so orcli.json can name a credential instead of containing it.

Design decisions the issue left open

  • Source: the security CLI (find-generic-password/add-generic-password/delete-generic-password), not the Security framework's native API — the native API has no stable cgo-free binding, and adding cgo to a client that otherwise has none is a bigger cost than the one exposure the CLI carries (see below). The issue's own acceptance criteria already describe -a/-s matching, which is this CLI's own flags.
  • Precedence when both an inline api_key and api_key_keychain are configured: the inline key always wins. Migrating means removing api_key from the file, not just adding api_key_keychain beside it — a stale api_key silently continuing to be used with no error is exactly the failure mode this feature exists to avoid elsewhere.
  • Listing never touches the Keychain at all — it reads the account/service references already sitting in the config file (never a secret), so "list names/provider association without displaying secret values" has nothing to redact in the first place.

Security properties (the issue's own acceptance criteria)

  • Every call goes through exec.CommandContext with an argument array, never a shell — a configured account/service name reaches argv as a single element, never interpolated into a command line.
  • A secret value can never reach a returned error, by construction: Lookup separates stdout (the secret) from stderr (the diagnostic) at the type level — the internal commandErr helper's signature only accepts stderr, so stdout isn't available to pass in by mistake. Tested explicitly (TestLookup_FailureNeverLeaksStdout, TestSet_FailureNeverLeaksTheSecret).
  • orcli keychain set reads the secret with echo suppressed (golang.org/x/term.ReadPassword) when stdin is a real terminal; the interactive chat session is deliberately not used for this, since it renders input into the transcript.
  • The secret reaches security add-generic-password as one argv element — the one exposure this approach cannot avoid while driving this CLI rather than the Security framework's own API, visible only to another local process inspecting the process list at that moment, and documented as such in the README.
  • orcli keychain delete NAME removes only the named item, from both the config file and the Keychain.

Testing

Fully testable on Linux CI (which has no security binary and will never run macOS) via an injectable subprocess seam (run in internal/keychain) and a goos variable standing in for runtime.GOOS. New tests across internal/keychain, internal/config, and cmd/orcli cover: lookup success/not-found/failure, the stdout/secret-leak guard, Set/Delete argument shape, config precedence (inline vs. keychain, missing item, no keychain block, lookup-failure propagation), the writer (WriteKeychainItem/DeleteKeychainItem, including that a refused write leaves existing items untouched), the orcli keychain set/list/delete commands end to end, and the startup path (run's own ErrNoAPIKey case resolving via api_key_keychain, and reporting — not silently swallowing — a configured-but-failing reference).

make test passes clean.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Nxr4mW9rMNzn9kThjdJpZt


Generated by Claude Code

Adds internal/keychain, a small wrapper around the `security` CLI for
looking up, setting and deleting generic-password items by account/
service - the macOS Keychain mechanism issue #67 asks to support, so
orcli.json can name a credential instead of containing it.

Every call goes through exec.CommandContext with an argument array,
never a shell. The one invariant the package is built around: a secret
value can never reach a returned error. Lookup separates stdout (the
secret) from stderr (the diagnostic) at the type level - commandErr's
own signature only accepts stderr - and Set/Delete never see the
secret's bytes again once they reach argv. Fully testable on Linux CI
(which has no `security` binary and will never run macOS itself) via
an injectable subprocess seam and a `goos` variable standing in for
runtime.GOOS.

Config changes (internal/config/keychain.go):
- New `api_key_keychain` field names an entry in a new `keychain.items`
  block, each item being an {account, service} reference.
- Precedence is deliberate and documented: an inline `api_key`, when
  present, always wins over `api_key_keychain`, so migrating means
  removing the stale field rather than just adding the new one beside
  it.
- WriteKeychainItem/DeleteKeychainItem edit only the "keychain" member,
  the same byte-level approach every other writer in this package
  uses, despite this being the first writer for a block that isn't a
  pure raw-bytes passthrough like cloudflare/notion/github/apiary.

cmd/orcli:
- `orcli keychain set NAME ACCOUNT SERVICE` prompts for the secret
  with echo suppressed (golang.org/x/term.ReadPassword) when stdin is
  a real terminal, falling back to a plain line read for piped input
  and tests.
- `orcli keychain list` shows names/accounts/services - never secret
  values - and marks which one api_key_keychain currently points at.
- `orcli keychain delete NAME` removes both the config reference and
  the Keychain item, refusing an unconfigured name outright.
- Startup (run's own ErrNoAPIKey case) now tries api_key_keychain
  before falling back to writing the empty-api_key stub, and reports a
  configured-but-failing reference as a startup error naming it,
  rather than silently treating it as no credential at all.

orcli.json.example and README.md document the shape, the precedence
rule, and the one exposure this approach cannot avoid while driving
the `security` CLI rather than the Security framework's own API: the
secret is briefly visible to another local process's own view of the
process list while `security add-generic-password` runs.

`make test` passes clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nxr4mW9rMNzn9kThjdJpZt
@glenjbarber
glenjbarber marked this pull request as ready for review October 13, 2026 19:51
@glenjbarber
glenjbarber merged commit 72e4ef8 into main Oct 13, 2026
1 check passed
@glenjbarber
glenjbarber deleted the claude/relaxed-knuth-1frpkt branch October 13, 2026 19:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Feature: Resolve API keys from macOS Keychain

2 participants