# 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`. ### 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`.