Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -0,0 +1,231 @@
# OpenSpec Change: enterprise-macos-keychain
- **Change ID**: enterprise-macos-keychain
- **Package**: `@suaveplan/macos-keychain`
- **Category**: universal
- **Target path**: `universal/auth/macos-keychain`
- **Status**: PENDING
- **Date**: 2026-04-24
- **Reference spec**: `/mnt/s/web/suaveplan/enterprise/macos-keychain.md`
---
## Summary
Deliver `@suaveplan/macos-keychain` on the genesis branch from scratch at enterprise quality. The package provides a macOS Security framework bridge via the `security` CLI: `MacOSKeychainBridge` for credential CRUD (add, find, update, delete, list), a pluggable `CommandExecutor` interface for test-double injection, a rich typed error hierarchy keyed on macOS exit codes, and a structured error code registry. The genesis rewrite adds Zod option schemas, injectable `Clock` for timeout calculations, OTel instrumentation, `AbortSignal` cancellation on CLI invocations, property-based tests, and mitata benchmarks covering the executor path. Since the package wraps a macOS-only CLI tool, tests that call the real `/usr/bin/security` binary are tagged `@integration` and skipped in CI; all unit tests use a real injected `CommandExecutor` stub (no `vi.mock`).
---
## Current-state audit (genesis)
The package does not exist on genesis yet — this is a fresh build.
---
## Available workspace deps on genesis
| Package | Role |
|---|---|
| `@suaveplan/error` (`workspace:^`) | Base class for `KeychainError`; `defineCodes` for `KEYCHAIN.*` codes |
| `@suaveplan/types` (`workspace:^`) | `Clock` (injectable for timeout calculation), `LoggerLike` |
| `@suaveplan/crypto` (`workspace:^`) | `zeroize` — used to clear password strings from memory after use |
| `zod` (`catalog:forms`) | Zod schemas for all public option types |
| `@opentelemetry/api` (`catalog:observability`) | Spans, counters, histograms |
| `tslib` (`catalog:build`) | TypeScript runtime helpers |
Dev deps: `vitest`, `@vitest/coverage-istanbul`, `fast-check`, `mitata` (all `catalog:testing`), `@suaveplan/testing` (`workspace:^`).
---
## Preserved public API surface (from main branch)
### Types
```ts
interface KeychainConfig {
serviceName?: string // default service label for operations
accessGroup?: string // keychain access group for shared access
timeoutMs?: number // CLI command timeout; default 10 000
clock?: Clock
logger?: LoggerLike
}
interface KeychainItem {
service: string
account: string
password: string // plain text — caller responsible for zeroize after use
label?: string
comment?: string
createdAt?: Date
modifiedAt?: Date
}
interface KeychainResult {
success: boolean
error?: string
}
type CommandExecutor = (
command: string,
args: string[]
) => Promise<{ stdout: string; stderr: string; exitCode: number }>
```
### MacOSKeychainBridge class
```ts
class MacOSKeychainBridge {
constructor(config?: KeychainConfig, executor?: CommandExecutor)
// Add a new password item; throws KeychainError(KEYCHAIN_ITEM_EXISTS) if duplicate
addItem(item: Omit<KeychainItem, 'createdAt' | 'modifiedAt'>): Promise<KeychainResult>
// Find and return the password for a service/account pair; throws KeychainError(KEYCHAIN_NOT_FOUND) if absent
findItem(service: string, account: string): Promise<KeychainItem>
// Update an existing item's password
updateItem(service: string, account: string, newPassword: string): Promise<KeychainResult>
// Delete an item; throws KeychainError(KEYCHAIN_NOT_FOUND) if absent
deleteItem(service: string, account: string): Promise<KeychainResult>
// List all items for a service (or all items if service is omitted)
listItems(service?: string): Promise<KeychainItem[]>
// Check whether an item exists without fetching the password
itemExists(service: string, account: string): Promise<boolean>
}
```
### Error factories and error codes
```ts
type KeychainErrorCode =
| "KEYCHAIN_NOT_FOUND"
| "KEYCHAIN_ITEM_EXISTS"
| "KEYCHAIN_ACCESS_FAILED"
| "KEYCHAIN_LOCKED"
| "KEYCHAIN_UNAVAILABLE"
| "KEYCHAIN_PERMISSION_DENIED"
| "KEYCHAIN_ITEM_INVALID"
| "KEYCHAIN_TIMEOUT"
const KEYCHAIN_ERROR_METADATA: Readonly<Record<KeychainErrorCode, CodeMetadata>>
function registerKeychainErrorCodes(): void
// Named factory helpers (each returns a typed KeychainError instance)
function keychainNotFound(service: string, account: string): KeychainError
function keychainItemExists(service: string, account: string): KeychainError
function keychainAccessFailed(service: string, account: string): KeychainError
function keychainLocked(service: string, account: string): KeychainError
function keychainUnavailable(): KeychainError
function keychainPermissionDenied(service: string, account: string): KeychainError
function keychainItemInvalid(service: string, account: string, reason: string): KeychainError
function keychainTimeout(service: string, account: string, timeoutMs: number): KeychainError
```
---
## New additions for genesis
### 1. `KEYCHAIN_TIMEOUT` error code
The main branch exposes seven codes; genesis adds `KEYCHAIN_TIMEOUT` for when the CLI invocation exceeds `config.timeoutMs`. The error factory `keychainTimeout(service, account, timeoutMs)` is a new export.
### 2. `AbortSignal` on CLI invocations
`MacOSKeychainBridge` methods accept an optional `signal?: AbortSignal`. When present, if the signal fires before the CLI returns, the promise rejects with `KeychainError(KEYCHAIN_TIMEOUT)`.
### 3. Clock injection
`timeoutMs` deadline is computed via `clock.now() + timeoutMs` — no bare `Date.now()` calls. `KeychainItem.createdAt` / `modifiedAt` are parsed from the `security show-keychain-info` output but `clock` is also used as the fallback timestamp when the CLI omits dates.
### 4. OTel instrumentation
- `keychain.add` span + `keychain.ops_total` counter (label `op: "add"`, `success: bool`)
- `keychain.find` span + counter (label `op: "find"`, `success: bool`, `cached: bool`)
- `keychain.update` span + counter (label `op: "update"`)
- `keychain.delete` span + counter (label `op: "delete"`)
- `keychain.list` span + histogram `keychain.items_returned`
- `keychain.cli_duration_ms` histogram (label `op`)
### 5. Zod schemas
`KeychainConfigSchema`, `KeychainItemSchema`, `KeychainResultSchema`.
### 6. `zeroize` on sensitive values
After `findItem` returns to the caller, the bridge does not hold a reference to the plain-text password. Internal temporary buffers holding the `stdout` string from the CLI are zeroed via `@suaveplan/crypto`'s `zeroize` after the password is extracted.
### 7. Property-based tests (fast-check)
- Error classification: for every macOS exit code in the documented set, `classifyError` returns a `KeychainError` with the expected code — never `null` for known codes, always non-null for unknown codes.
- Error factory idempotency: calling `keychainNotFound(s, a)` twice with the same arguments returns errors with identical `code` and equivalent `message` strings.
- Round-trip (stub executor): `addItem` → `findItem` via a deterministic `CommandExecutor` stub that echoes the stored data; result password matches input.
### 8. Benchmark suite
- `MacOSKeychainBridge.findItem` via a stub `CommandExecutor` (no real keychain) — 100 000 iterations measuring parse and error-classification overhead.
- `classifyError` for 10 different exit-code/stderr combinations — 1 000 000 iterations (pure-function benchmark).
---
## File layout
```text
universal/auth/macos-keychain/
package.json
tsconfig.json
vitest.config.ts
src/
index.ts
error-codes.ts # KEYCHAIN.* codes + registerKeychainErrorCodes
constants/
index.ts # EXIT_CODES, SECURITY_BINARY, DEFAULT_TIMEOUT_MS
types/
macos-keychain.types.ts
macos-keychain.types.md
core/
keychain-bridge/
keychain-bridge.ts # MacOSKeychainBridge class
keychain-bridge.md
keychain-bridge.types.ts
keychain-bridge.test.ts
index.ts
error-classifier/
error-classifier.ts # classifyError(exitCode, stderr, service, account)
error-classifier.md
error-classifier.test.ts
index.ts
output-parser/
output-parser.ts # parseKeychainItem from security CLI stdout
output-parser.md
output-parser.test.ts
index.ts
errors/
keychain-errors.ts # named factory helpers
keychain-errors.md
keychain-errors.test.ts
schemas/
macos-keychain.schemas.ts
macos-keychain.schemas.md
macos-keychain.schemas.test.ts
__benchmarks__/
macos-keychain.bench.ts
tests/
integration.test.ts # @integration — requires real macOS + security binary
property.test.ts
```
---
## Impact
**Downstream packages**: CLI tools and desktop apps on macOS that need credential storage (e.g., `@suaveplan/crypto-vault` could delegate to this for native key storage on macOS).
**Breaking changes from main-branch API**: `KEYCHAIN_TIMEOUT` code and `keychainTimeout` factory are net-new additions. All existing exports are preserved. The `CommandExecutor` interface is unchanged.
**Workspace dep prerequisites**: `@suaveplan/error`, `@suaveplan/types`, `@suaveplan/crypto` must be published to Verdaccio before this package can be built or tested.
**Platform note**: The integration tests (`tests/integration.test.ts`) require a real macOS environment with `/usr/bin/security`. They are tagged `@integration` and excluded from CI by default. All unit tests run on Linux/macOS via the stub `CommandExecutor`.

View File

@@ -0,0 +1,185 @@
<!-- markdownlint-disable MD024 -->
# 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. (See `openspec/AGENTS.md` Section 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 returns `exitCode === 0`
- **THEN** the promise resolves to `{ success: true }` and `keychain.ops_total` records `op="add", success=true`
#### Scenario: addItem rejects with KEYCHAIN.ITEM_EXISTS on duplicate
- **WHEN** `addItem` is called and the executor returns the `errSecDuplicateItem` exit code (45)
- **THEN** the promise rejects with a `KeychainError` whose `code === "KEYCHAIN.ITEM_EXISTS"`
#### Scenario: findItem returns the stored password
- **WHEN** `findItem(service, account)` is called and the executor returns valid `find-generic-password -g` output
- **THEN** the promise resolves to a `KeychainItem` whose `password` field 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 `KeychainError` has `code === "KEYCHAIN.NOT_FOUND"` and `context.service === "svc"` and `context.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 `-s` position and no shell is spawned
#### Scenario: A custom executor receives a parameterised argv
- **WHEN** a stub `CommandExecutor` is injected and any CRUD method is called
- **THEN** the executor receives `(command, args)` where `command === "/usr/bin/security"` and `args` is a `string[]` 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** `findItem` is called with an `AbortSignal` that aborts before the executor resolves
- **THEN** the promise rejects with `KEYCHAIN.TIMEOUT` and `keychain.ops_total` records `success=false`
#### Scenario: timeoutMs deadline triggers timeout
- **WHEN** the executor never resolves and `timeoutMs` elapses 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 `KeychainError` with `code === "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 `KeychainError` with `code === "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** `parseKeychainItem` receives stderr containing `password: "hunter2"`
- **THEN** the returned `KeychainItem.password === "hunter2"`
#### Scenario: Hex-encoded password is decoded
- **WHEN** `parseKeychainItem` receives stderr containing `password: 0x68756e74657232`
- **THEN** the returned `KeychainItem.password === "hunter2"`
#### Scenario: Missing password line throws KEYCHAIN.ITEM_INVALID
- **WHEN** `parseKeychainItem` receives stderr without a `password:` line
- **THEN** it throws a `KeychainError` whose `code === "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** `parseKeychainItem` is invoked with a buffer-bearing input
- **THEN** the implementation calls `zeroize` on the working `Uint8Array` before 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** `addItem` is called against a stub executor
- **THEN** the OTel meter has been used to increment `keychain.ops_total` and record `keychain.cli_duration_ms` without throwing
#### Scenario: listItems records on items_returned
- **WHEN** `listItems()` returns N items
- **THEN** `keychain.items_returned` is 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.
<!-- markdownlint-enable MD024 -->

View File

@@ -0,0 +1,203 @@
# Tasks: enterprise-macos-keychain
**Change ID**: enterprise-macos-keychain
**Package**: `@suaveplan/macos-keychain`
**Target path**: `universal/auth/macos-keychain`
**Prerequisite packages on Verdaccio**: `@suaveplan/error`, `@suaveplan/types`, `@suaveplan/crypto`
---
## 1. Scaffold
- [x] Create directory `universal/auth/macos-keychain/` with `src/`, `tests/`, `src/__benchmarks__/`, and all feature subdirectories per the layout in `proposal.md`.
- [x] Write `package.json` at version `0.1.0` with:
- `"type": "module"`, `"sideEffects": false`
- `exports` map with top-level `"."` plus subpaths: `"./core/keychain-bridge/keychain-bridge"`, `"./core/error-classifier/error-classifier"`, `"./core/output-parser/output-parser"`, `"./errors/keychain-errors"`, `"./schemas/macos-keychain.schemas"`, `"./types/macos-keychain.types"`
- `dependencies`: `@suaveplan/error workspace:^`, `@suaveplan/types workspace:^`, `@suaveplan/crypto workspace:^`, `zod catalog:forms`, `@opentelemetry/api catalog:observability`, `tslib catalog:build`
- `devDependencies`: `vitest catalog:testing`, `@vitest/coverage-istanbul catalog:testing`, `fast-check catalog:testing`, `mitata catalog:testing`, `@suaveplan/testing workspace:^`
- [x] Write `tsconfig.json` extending `@suaveplan/typescript-config/library.json`.
- [x] Write `vitest.config.ts` using `createTestConfig()` from `@suaveplan/testing/config` (pool: `forks`).
- [x] Add `lint`, `typecheck`, `test`, `test:coverage`, `build`, `docs:check`, `bench` scripts to `package.json`.
---
## 2. Types and errors
### 2a. Types (`src/types/macos-keychain.types.ts` + `.md`)
- [x] Define all public interfaces: `KeychainConfig`, `KeychainItem`, `KeychainResult`, `CommandExecutor`.
- `CommandExecutor` signature: `(command: string, args: string[]) => Promise<{ stdout: string; stderr: string; exitCode: number }>`.
- The default `CommandExecutor` implementation uses `Bun.spawn` (Bun runtime) or `node:child_process` `execFile` (not `exec` — shell injection prevention); all arguments are passed as an array, never interpolated into a shell string.
- [x] Add genesis-only optional fields: `clock?: Clock` and `logger?: LoggerLike` on `KeychainConfig`.
- [x] Write `macos-keychain.types.md` (≥200 words, API reference for every type, security note on `CommandExecutor` using `execFile`/`Bun.spawn` not `exec`).
### 2b. Error codes (`src/error-codes.ts`)
- [x] Define `KeychainErrorCode` union — eight codes: `KEYCHAIN_NOT_FOUND`, `KEYCHAIN_ITEM_EXISTS`, `KEYCHAIN_ACCESS_FAILED`, `KEYCHAIN_LOCKED`, `KEYCHAIN_UNAVAILABLE`, `KEYCHAIN_PERMISSION_DENIED`, `KEYCHAIN_ITEM_INVALID`, `KEYCHAIN_TIMEOUT` (genesis addition).
- [x] Define `KEYCHAIN_ERROR_METADATA` with `httpStatus`, `severity`, `retryable`, `userMessageKey`, `category`, `description` per code.
- [x] Export `registerKeychainErrorCodes()` as idempotent eager side-effect; call it at file bottom.
- [x] Export `KeychainError extends SuaveplanError<KeychainErrorCode>`.
### 2c. Zod schemas (`src/schemas/macos-keychain.schemas.ts` + `.md`)
- [x] Write Zod schemas: `KeychainConfigSchema`, `KeychainItemSchema`, `KeychainResultSchema`.
- [x] Write `macos-keychain.schemas.md`.
- [x] Write `macos-keychain.schemas.test.ts` — parse valid/invalid for each schema.
---
## 3. Constants (`src/constants/index.ts`)
- [x] Export:
- `SECURITY_BINARY = "/usr/bin/security"` — path to the macOS security CLI.
- `DEFAULT_TIMEOUT_MS = 10_000`.
- `EXIT_ITEM_NOT_FOUND = 44` — `errSecItemNotFound`.
- `EXIT_DUPLICATE_ITEM = 45` — `errSecDuplicateItem`.
- `EXIT_AUTH_FAILED = 36` — `errAuthFailed`.
- `EXIT_LOCKED_PATTERNS: string[]` — stderr substrings that indicate a locked keychain.
---
## 4. Error classifier (`src/core/error-classifier/`)
- [x] Implement `classifyError(exitCode, stderr, service, account): KeychainError | null`:
- Map `EXIT_ITEM_NOT_FOUND` + "could not be found" / "no such keychain" stderr patterns → `keychainNotFound`.
- Map `EXIT_DUPLICATE_ITEM` + "duplicate" / "already exists" → `keychainItemExists`.
- Map `EXIT_AUTH_FAILED` + "auth" patterns → `keychainAccessFailed`.
- Map locked-keychain stderr patterns → `keychainLocked`.
- Map "permission" / "denied" / "not authorized" → `keychainPermissionDenied`.
- Map "security: SecKeychainItemRef" invalid type → `keychainItemInvalid`.
- Return `null` for unrecognized combinations (caller wraps in a generic `KEYCHAIN_ACCESS_FAILED`).
- [x] Write `error-classifier.test.ts` — each known exit code + stderr combo, unknown combo returns null, stderr precedence over exit code when ambiguous. Each `it` has `{ timeout: 5000 }`.
- [x] Write `error-classifier.md`.
---
## 5. Error factories (`src/errors/keychain-errors.ts`)
- [x] Implement named factory helpers: `keychainNotFound`, `keychainItemExists`, `keychainAccessFailed`, `keychainLocked`, `keychainUnavailable`, `keychainPermissionDenied`, `keychainItemInvalid`, `keychainTimeout`.
- [x] Each factory returns a `KeychainError` with the corresponding code, a human-readable message, and a structured context object `{ service, account }` (where applicable).
- [x] Write `keychain-errors.test.ts` — construction of each factory, correct code, message non-empty, context fields present. Each `it` has `{ timeout: 5000 }`.
- [x] Write `keychain-errors.md`.
---
## 6. Output parser (`src/core/output-parser/`)
- [x] Implement `parseKeychainItem(stdout: string, service: string, account: string): KeychainItem`:
- Extract `password` from `security find-generic-password -g` output (appears after `password:` line, possibly base-64 encoded for binary passwords).
- Extract optional `label`, `comment`, `createdAt`, `modifiedAt` from structured output lines.
- Return `KeychainItem` with all available fields.
- Throw `KeychainError(KEYCHAIN_ITEM_INVALID)` if the output cannot be parsed.
- [x] Implement `parseKeychainList(stdout: string): Array<{ service: string; account: string }>`:
- Parse `security dump-keychain` output for service + account pairs.
- [x] Write `output-parser.test.ts` — valid stdout for each supported format, missing password line, list parsing, malformed output triggers `KEYCHAIN_ITEM_INVALID`. Each `it` has `{ timeout: 5000 }`.
- [x] Write `output-parser.md`.
---
## 7. MacOSKeychainBridge (`src/core/keychain-bridge/`)
- [x] Implement `MacOSKeychainBridge`:
- Constructor: `constructor(config?: KeychainConfig, executor?: CommandExecutor)`.
- If `executor` is not provided, use a default `CommandExecutor` that invokes `SECURITY_BINARY` via `Bun.spawn` (on Bun runtime) or `node:child_process` `execFile` (not `exec`) — arguments are always passed as an array to prevent shell injection.
- Validate `config` with `KeychainConfigSchema`; register error codes eagerly.
- `addItem(item)`:
- Build args array: `["add-generic-password", "-s", service, "-a", account, "-w", password, ...]` — never interpolate into a shell string.
- Execute via `executor`; wrap with `AbortSignal.timeout(timeoutMs)` and translate `TimeoutError` to `keychainTimeout`.
- On `exitCode !== 0`, classify via `classifyError`; throw the result (or generic `keychainAccessFailed` for unrecognized codes).
- On duplicate, throw `keychainItemExists`.
- Instrument with `keychain.add` span and `keychain.ops_total` counter.
- `findItem(service, account)`:
- Build args array: `["find-generic-password", "-s", service, "-a", account, "-g"]`.
- Parse stdout via `parseKeychainItem`.
- `zeroize` the internal temporary stdout buffer after extraction.
- Instrument with `keychain.find` span and counter.
- `updateItem(service, account, newPassword)`:
- Delete existing item, then re-add with new password (macOS has no native in-place update for generic passwords).
- Instrument with `keychain.update` span and counter.
- `deleteItem(service, account)`:
- Build args array: `["delete-generic-password", "-s", service, "-a", account]`.
- Instrument with `keychain.delete` span and counter.
- `listItems(service?)`:
- Build args for `find-generic-password` or `dump-keychain`.
- Parse via `parseKeychainList`.
- Instrument with `keychain.list` span and histogram `keychain.items_returned`.
- `itemExists(service, account)`:
- Calls `findItem`; returns `true` on success, `false` on `KEYCHAIN_NOT_FOUND`, re-throws other errors.
- All methods accept optional `signal?: AbortSignal`; pass to `executor` and reject with `keychainTimeout` on abort.
- Log all operations at `debug` level via `config.logger` (if provided).
- [x] Write `keychain-bridge.test.ts` (≤500 lines):
- Use a stub `CommandExecutor` (plain object literal — no `vi.mock`) that returns preset `{ stdout, stderr, exitCode }` values.
- Test each CRUD operation: success path, each error code classification, AbortSignal cancellation, logger calls, OTel counter increments (in-memory exporter).
- `{ timeout: 5000 }` on each `it`.
- [x] Write `keychain-bridge.md` (≥200 words — architecture overview, security model noting `execFile`/`Bun.spawn` + args-array usage, platform limitations, stub executor guide for tests).
---
## 8. OTel instrumentation (cross-cutting review)
- [x] Confirm all spans, counters, and histograms from `proposal.md §4` are wired.
- [x] Add `tests/otel.test.ts` — in-memory exporter; assert `keychain.ops_total` increments for add/find/update/delete, `keychain.cli_duration_ms` records, `keychain.items_returned` histogram records on list.
---
## 9. Property-based tests (`tests/property.test.ts`)
Use `fast-check`. All test timeouts `{ timeout: 30000 }`.
- [x] **Error classification coverage**: for each documented macOS exit code constant, `classifyError` returns a non-null `KeychainError` with the expected `code`.
- [x] **Error factory idempotency**: calling `keychainNotFound(s, a)` twice returns errors with identical `.code` and `.message`.
- [x] **Round-trip via stub executor**: `addItem` then `findItem` using a deterministic stub that echoes stored data — result password equals input password.
- [x] **Timeout detection**: stub executor that delays beyond `timeoutMs` (AbortSignal + FakeClock) triggers `KEYCHAIN_TIMEOUT` error.
---
## 10. Benchmarks (`src/__benchmarks__/macos-keychain.bench.ts`)
Use `mitata`. All benchmarks use stub `CommandExecutor` (no real keychain access).
- [x] `findItem_stub`: 100 000 `findItem` calls via a stub executor returning a static valid stdout string — measures parse + error-classification overhead.
- [x] `classifyError_10_patterns`: 1 000 000 calls to `classifyError` cycling through 10 exit-code/stderr pairs — pure function overhead.
- [x] Document placeholder numbers in `README.md` under `## Benchmarks`.
---
## 11. Integration tests (`tests/integration.test.ts`)
- [x] Tag all tests with `// @integration` header comment.
- [x] Guard every test with a runtime skip if not on macOS: `if (process.platform !== 'darwin') { it.skip(...) }`.
- [x] Tests: real `addItem`, `findItem`, `updateItem`, `deleteItem`, `listItems`, `itemExists`. Clean up added items in `afterEach`.
- [x] Exclude from coverage threshold in `vitest.config.ts`.
---
## 12. Docs
Per-file `.md` (all ≥200 words):
- [x] `src/types/macos-keychain.types.md`
- [x] `src/core/keychain-bridge/keychain-bridge.md`
- [x] `src/core/error-classifier/error-classifier.md`
- [x] `src/core/output-parser/output-parser.md`
- [x] `src/errors/keychain-errors.md`
- [x] `src/schemas/macos-keychain.schemas.md`
Root README:
- [x] Write `universal/auth/macos-keychain/README.md`: purpose, install, quick-start (add + find + delete), security model (`execFile` array args, password zeroize, access group), stub executor guide for tests, platform requirements (macOS only for real operations), modules table, benchmarks placeholder.
---
## 13. Verify and publish
- [x] Run `bunx turbo lint --filter=@suaveplan/macos-keychain` — zero errors.
- [x] Run `bunx turbo typecheck --filter=@suaveplan/macos-keychain` — zero errors.
- [x] Run `bunx turbo test --filter=@suaveplan/macos-keychain` — all unit + property tests pass (integration skipped on non-macOS).
- [x] Run `bunx turbo test:coverage --filter=@suaveplan/macos-keychain` — 100/100/100/100 on non-integration source files.
- [x] Run `bun run docs:check --filter=@suaveplan/macos-keychain` — zero errors.
- [x] Confirm no `TODO`, `FIXME`, `@deprecated`, untyped `any`, `ts-ignore` in `src/`. Platform-guarded `.skip` in `tests/integration.test.ts` is permissible.
- [x] Confirm `"sideEffects": false` in `package.json`.
- [x] Run `bun scripts/publish.ts --package @suaveplan/macos-keychain`.
- [x] Update `CLAUDE.md` package listing to include `@suaveplan/macos-keychain`.
- [x] Archive: move `openspec/changes/enterprise-macos-keychain/` to `openspec/archive/2026-04-24-enterprise-macos-keychain/` and fold spec delta into `openspec/specs/macos-keychain/spec.md`.