11 KiB
Capability Spec Delta: macos-keychain (enterprise-macos-keychain)
Package: @suaveplan/macos-keychain
Status: PROPOSED
Every requirement below carries a
**Verified by:**line at authoring time. No exceptions. (Seeopenspec/AGENTS.mdSection 13.)
ADDED Requirements
Requirement: Generic-password CRUD via the macOS security CLI
The package SHALL expose a MacOSKeychainBridge class that performs add, find, update, delete, list, and exists operations against the macOS Keychain by shelling out to /usr/bin/security via an injectable CommandExecutor. Callers can rely on a strict success/throw contract: success methods resolve with a KeychainResult or KeychainItem; failure methods reject with a typed KeychainError whose code field is one of the eight registered codes in KEYCHAIN.*.
Scenario: addItem succeeds for a new credential
- WHEN
addItem({ service, account, password })is called and the executor returnsexitCode === 0 - THEN the promise resolves to
{ success: true }andkeychain.ops_totalrecordsop="add", success=true
Scenario: addItem rejects with KEYCHAIN.ITEM_EXISTS on duplicate
- WHEN
addItemis called and the executor returns theerrSecDuplicateItemexit code (45) - THEN the promise rejects with a
KeychainErrorwhosecode === "KEYCHAIN.ITEM_EXISTS"
Scenario: findItem returns the stored password
- WHEN
findItem(service, account)is called and the executor returns validfind-generic-password -goutput - THEN the promise resolves to a
KeychainItemwhosepasswordfield equals the stored secret
Verified by: src/core/keychain-bridge/keychain-bridge.test.ts::"adds a new keychain item successfully" AND src/core/keychain-bridge/keychain-bridge.test.ts::"throws KEYCHAIN.ITEM_EXISTS on duplicate add" AND src/core/keychain-bridge/keychain-bridge.test.ts::"finds an existing keychain item and returns parsed password"
Requirement: Stable, registered error-code namespace
The package SHALL define exactly eight error codes under the KEYCHAIN.* namespace, register them eagerly at module load time via defineCodes and registerKeychainErrorCodes, and surface them through named factory helpers that return KeychainError instances carrying both the literal code and a structured context object. Codes are: KEYCHAIN.NOT_FOUND, KEYCHAIN.ITEM_EXISTS, KEYCHAIN.ACCESS_FAILED, KEYCHAIN.LOCKED, KEYCHAIN.UNAVAILABLE, KEYCHAIN.PERMISSION_DENIED, KEYCHAIN.ITEM_INVALID, KEYCHAIN.TIMEOUT.
Scenario: registerKeychainErrorCodes is idempotent
- WHEN
registerKeychainErrorCodes()is called twice - THEN the metadata table is unchanged on the second call and no error is thrown
Scenario: Each factory yields the matching code
- WHEN
keychainNotFound("svc", "user")is invoked - THEN the returned
KeychainErrorhascode === "KEYCHAIN.NOT_FOUND"andcontext.service === "svc"andcontext.account === "user"
Verified by: src/errors/keychain-errors.test.ts::"keychainNotFound produces KEYCHAIN.NOT_FOUND with service and account context" AND src/errors/keychain-errors.test.ts::"registerKeychainErrorCodes is idempotent across repeat calls" AND tests/property.test.ts::"every factory produces the documented code (idempotency)"
Requirement: Shell-injection-safe CLI invocation
The package SHALL never interpolate caller-supplied strings into a shell command. The default CommandExecutor MUST use Bun.spawn (Bun runtime) or node:child_process.execFile (Node runtime), passing all arguments as an array to SECURITY_BINARY = "/usr/bin/security". The implementation MUST NOT invoke any shell wrapper.
Scenario: Arguments containing shell metacharacters are passed safely
- WHEN
addItem({ service: "a; rm -rf /", account, password })is invoked with the default executor - THEN the argv passed to the underlying spawn API contains the literal
"a; rm -rf /"string at the-sposition and no shell is spawned
Scenario: A custom executor receives a parameterised argv
- WHEN a stub
CommandExecutoris injected and any CRUD method is called - THEN the executor receives
(command, args)wherecommand === "/usr/bin/security"andargsis astring[]whose values are not concatenated into a single string
Verified by: src/core/keychain-bridge/keychain-bridge.test.ts::"forwards a parameterised argv to the executor (no shell interpolation)" AND src/core/keychain-bridge/keychain-bridge.test.ts::"argv preserves shell metacharacters as literal payload"
Requirement: AbortSignal cancellation and deadline enforcement
The package SHALL accept an optional signal?: AbortSignal on every public CRUD method and SHALL also derive a deadline from config.timeoutMs (default 10_000) using the injected Clock. When either signal fires before the executor returns, the promise MUST reject with a KeychainError whose code === "KEYCHAIN.TIMEOUT".
Scenario: Caller-supplied AbortSignal cancels an in-flight call
- WHEN
findItemis called with anAbortSignalthat aborts before the executor resolves - THEN the promise rejects with
KEYCHAIN.TIMEOUTandkeychain.ops_totalrecordssuccess=false
Scenario: timeoutMs deadline triggers timeout
- WHEN the executor never resolves and
timeoutMselapses on the injected clock - THEN the promise rejects with
KEYCHAIN.TIMEOUT
Verified by: src/core/keychain-bridge/keychain-bridge.test.ts::"rejects with KEYCHAIN.TIMEOUT when caller signal aborts" AND src/core/keychain-bridge/keychain-bridge.test.ts::"rejects with KEYCHAIN.TIMEOUT when timeoutMs deadline elapses" AND tests/property.test.ts::"timeout via AbortSignal yields KEYCHAIN.TIMEOUT"
Requirement: Error classification from CLI exit code and stderr
The package SHALL provide classifyError(exitCode, stderr, service, account) which deterministically maps the documented set of macOS security exit codes and stderr substrings to the matching KeychainError factory. Stderr substring matching MUST take precedence when the exit code alone is ambiguous. Unrecognised pairs MUST return null so the caller can fall back to keychainAccessFailed.
Scenario: errSecItemNotFound is classified as KEYCHAIN.NOT_FOUND
- WHEN
classifyError(44, "could not be found", "svc", "user")is called - THEN the result is a
KeychainErrorwithcode === "KEYCHAIN.NOT_FOUND"
Scenario: Locked-keychain stderr beats ambiguous exit code
- WHEN
classifyError(1, "User interaction is not allowed; locked", "svc", "user")is called - THEN the result is a
KeychainErrorwithcode === "KEYCHAIN.LOCKED"
Scenario: Unknown exit code returns null
- WHEN
classifyError(99999, "unknown failure", "svc", "user")is called - THEN the result is
null
Verified by: src/core/error-classifier/error-classifier.test.ts::"classifies errSecItemNotFound as KEYCHAIN.NOT_FOUND" AND src/core/error-classifier/error-classifier.test.ts::"prefers locked-stderr over ambiguous exit code" AND src/core/error-classifier/error-classifier.test.ts::"returns null for unknown exit code and stderr combinations"
Requirement: Output parsing for find-generic-password and dump-keychain
The package SHALL parse the output of security find-generic-password -g into a KeychainItem (extracting password, optional label, comment, createdAt, modifiedAt) and the output of security dump-keychain into an array of { service, account } records. Malformed output MUST throw a KeychainError with code === "KEYCHAIN.ITEM_INVALID". The parser MUST handle both quoted-string and 0x... hexadecimal password formats emitted by different security CLI versions.
Scenario: Quoted-string password is parsed verbatim
- WHEN
parseKeychainItemreceives stderr containingpassword: "hunter2" - THEN the returned
KeychainItem.password === "hunter2"
Scenario: Hex-encoded password is decoded
- WHEN
parseKeychainItemreceives stderr containingpassword: 0x68756e74657232 - THEN the returned
KeychainItem.password === "hunter2"
Scenario: Missing password line throws KEYCHAIN.ITEM_INVALID
- WHEN
parseKeychainItemreceives stderr without apassword:line - THEN it throws a
KeychainErrorwhosecode === "KEYCHAIN.ITEM_INVALID"
Verified by: src/core/output-parser/output-parser.test.ts::"parses a quoted-string password" AND src/core/output-parser/output-parser.test.ts::"decodes a hex-encoded password" AND src/core/output-parser/output-parser.test.ts::"throws KEYCHAIN.ITEM_INVALID when the password line is missing"
Requirement: Zeroize sensitive intermediate buffers
The package SHALL allocate sensitive intermediate buffers (the byte representation of stdout/stderr containing the raw password) inside a narrow scope and zero them via zeroize from @suaveplan/crypto-utils after the parsed KeychainItem.password has been produced. The KeychainItem.password returned to the caller is a JavaScript string and remains the caller's responsibility to dispose of.
Scenario: parseKeychainItem zeroizes its working buffer
- WHEN
parseKeychainItemis invoked with a buffer-bearing input - THEN the implementation calls
zeroizeon the workingUint8Arraybefore returning
Verified by: src/core/output-parser/output-parser.test.ts::"zeroizes the working buffer after extracting the password"
Requirement: Zod schema validation at every public boundary
The package SHALL expose Zod schemas — KeychainConfigSchema, KeychainItemSchema, KeychainResultSchema — and the MacOSKeychainBridge constructor MUST parse the supplied config object through KeychainConfigSchema so invalid shapes fail fast with a Zod error rather than a downstream undefined-behaviour failure.
Scenario: Negative timeoutMs is rejected
- WHEN
new MacOSKeychainBridge({ timeoutMs: -1 })is constructed - THEN the constructor throws a
ZodError
Verified by: src/schemas/macos-keychain.schemas.test.ts::"rejects negative timeoutMs" AND src/core/keychain-bridge/keychain-bridge.test.ts::"validates config via KeychainConfigSchema in the constructor"
Requirement: OpenTelemetry instrumentation
The package SHALL emit OpenTelemetry spans, counters, and histograms via the global @opentelemetry/api provider for every public CRUD operation. Names: spans keychain.add / keychain.find / keychain.update / keychain.delete / keychain.list; counter keychain.ops_total (labels op, success); histograms keychain.cli_duration_ms (label op) and keychain.items_returned (recorded only on listItems).
Scenario: addItem records on the ops counter and the duration histogram
- WHEN
addItemis called against a stub executor - THEN the OTel meter has been used to increment
keychain.ops_totaland recordkeychain.cli_duration_mswithout throwing
Scenario: listItems records on items_returned
- WHEN
listItems()returns N items - THEN
keychain.items_returnedis recorded with value N
Verified by: tests/otel.test.ts::"addItem records on the ops counter without throwing" AND tests/otel.test.ts::"listItems records the items_returned histogram"
MODIFIED Requirements
None — this is a greenfield package on genesis.
REMOVED Requirements
None.