diff --git a/.github/workflows/blindfold-ci.yml b/.github/workflows/blindfold-ci.yml new file mode 100644 index 00000000..00f7e56a --- /dev/null +++ b/.github/workflows/blindfold-ci.yml @@ -0,0 +1,58 @@ +name: blindfold CI + +on: + push: + branches: + - main + - 'md/project-vault' + paths: + - 'blindfold/**' + pull_request: + branches: + - main + - 'md/project-vault' + paths: + - 'blindfold/**' + +jobs: + ci: + name: Build & Test (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + + strategy: + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + + defaults: + run: + working-directory: blindfold + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + cache-dependency-path: blindfold/package-lock.json + + - name: Install dependencies + run: npm ci + + - name: Build + run: npm run build + + - name: Test + run: npm test + + - name: Pack dry-run + run: npm pack --dry-run + + - name: Build binary (Linux only) + if: matrix.os == 'ubuntu-latest' + run: npm run build:binary + continue-on-error: true diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 089c16ec..eb01c282 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,6 +34,7 @@ jobs: uses: actions/checkout@v4 with: fetch-depth: 0 + submodules: recursive - name: Setup Node.js 22.x uses: actions/setup-node@v4 @@ -61,6 +62,7 @@ jobs: uses: actions/checkout@v4 with: fetch-depth: 0 + submodules: recursive - name: Setup Node.js 22.x uses: actions/setup-node@v4 @@ -127,6 +129,7 @@ jobs: uses: actions/checkout@v4 with: fetch-depth: 0 + submodules: recursive - name: Setup Node.js 22.x uses: actions/setup-node@v4 @@ -240,6 +243,7 @@ jobs: with: ref: ${{ github.sha }} fetch-depth: 0 + submodules: recursive - name: Setup Node.js 22.x uses: actions/setup-node@v4 @@ -264,7 +268,7 @@ jobs: fi git diff --cached --quiet || ( git commit -m "chore: regenerate llms-full.txt" && - git push origin HEAD:${{ github.head_ref }} || echo "Branch no longer exists — skipping push." + git push origin HEAD:${{ github.head_ref }} || echo "Branch no longer exists - skipping push." ) release: @@ -278,6 +282,7 @@ jobs: uses: actions/checkout@v4 with: fetch-depth: 0 + submodules: recursive - name: Setup Node.js 22.x uses: actions/setup-node@v4 diff --git a/.github/workflows/fleet-e2e.yml b/.github/workflows/fleet-e2e.yml index 548cfd40..460dd7f6 100644 --- a/.github/workflows/fleet-e2e.yml +++ b/.github/workflows/fleet-e2e.yml @@ -33,6 +33,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@v4 + with: + submodules: recursive - name: Create run directory shell: bash diff --git a/.gitignore b/.gitignore index 4c2042cd..1e4799b2 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,4 @@ CLAUDE.md GEMINI.md AGENTS.md COPILOT-INSTRUCTIONS.md +blindfold.local.bak/ diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 00000000..3fb304a4 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "blindfold"] + path = blindfold + url = git@github.com:Apra-Labs/blindfold.git diff --git a/.mcp.json b/.mcp.json index 039b0836..70011302 100644 --- a/.mcp.json +++ b/.mcp.json @@ -1,12 +1,3 @@ { - "mcpServers": { - "apra-fleet": { - "command": "node", - "args": ["dist/index.js"], - "cwd": ".", - "env": { - "NODE_ENV": "development" - } - } - } -} + "mcpServers": {} +} \ No newline at end of file diff --git a/blindfold b/blindfold new file mode 160000 index 00000000..580213c8 --- /dev/null +++ b/blindfold @@ -0,0 +1 @@ +Subproject commit 580213c82e985832eaaa696416c6682783766804 diff --git a/blindfold-migration/CLAUDE-doer.md b/blindfold-migration/CLAUDE-doer.md new file mode 100644 index 00000000..52774595 --- /dev/null +++ b/blindfold-migration/CLAUDE-doer.md @@ -0,0 +1,103 @@ +# blindfold-migration — Doer (apra-fleet) + +You are the **doer** on the apra-fleet blindfold-migration sprint. + +## Project policy (also see root CLAUDE.md and README.md) + +- ASCII only - never write non-ASCII characters to any file. Use `-` for dashes, `->` for arrows, `[OK]` for checkmarks. +- Branch naming: `feat/`, `fix/`, `chore/`. +- Commit style: `(): ` (project convention). +- Do not push to `main` directly. +- No Claude / Anthropic / AI attribution in commits, code, comments, or PR body. + +## Sprint context + +- **Branch:** `md/project-vault` +- **Base:** `main` +- **Plan:** `blindfold-migration/PLAN.md` +- **Progress:** `blindfold-migration/progress.json` +- **Requirements:** `blindfold-migration/requirements.md` + +Always read these from the `blindfold-migration/` folder, not the +prior-sprint files at repo root (`PLAN.md`, `plan.md`, `progress.json`, +`OVERVIEW.md`, `requirements*.md` are leftovers - ignore them). + +## Execution model + +On each invocation: + +1. `git log --oneline -10` for context recovery. +2. Read `blindfold-migration/progress.json` - find the next task with + status `pending`. +3. Read the corresponding section of `blindfold-migration/PLAN.md`. +4. Execute the task: edits, commands, tests. +5. Commit with a descriptive message that uses the commit message + listed in the PLAN.md phase header. +6. Update `blindfold-migration/progress.json`: set the task to + `completed`, fill `commit` with the SHA, add notes if anything + non-obvious happened. +7. Push to `origin md/project-vault`. +8. If you reached a VERIFY task: stop, leave it as the last pending + item. The PM will dispatch the reviewer. + +## VERIFY checkpoints + +When the next task is type `verify`: + +1. Run the relevant gates from the PLAN.md phase ("Done when" list). + Always include: + - `npm run build` + - `npm test` +2. If any gate fails, fix and re-run. Only move on once all gates are + green (or the PLAN.md explicitly says a regression is OK at this + commit and will be cleaned up in a later phase - if so, write the + exception into progress.json `notes`). +3. Mark the VERIFY task `completed` in progress.json with a one-line + summary of what passed. +4. `git push origin md/project-vault` - the reviewer will fetch. +5. STOP. Do not start the next phase. Report status. + +## Doer-reviewer loop + +Reviewer commits findings to `blindfold-migration/feedback.md` with +verdict APPROVED or CHANGES NEEDED. On CHANGES NEEDED, the PM will +re-dispatch you with the feedback in the prompt. When you fix a +finding: + +- Annotate the relevant feedback.md section with + `**Doer:** fixed in commit - ` (do not rewrite + the rest of the reviewer's content). +- Commit and push. + +## Files you commit per turn + +- Source / test / config changes for the phase +- `blindfold-migration/PLAN.md` (only if it needed corrections) +- `blindfold-migration/progress.json` (always) +- `blindfold-migration/feedback.md` (only when adding doer annotations) + +## Files you NEVER commit + +- This file (`blindfold-migration/CLAUDE-doer.md`) - role-specific +- Root `CLAUDE.md` if modified - it is the project doc and pre-existing +- Any `.fleet-task*.md` - ephemeral prompt files + +## Hard rules + +- ONE phase per turn. Do not start Phase N+1 until the PM confirms + Phase N is APPROVED. +- Never skip a task. Execute in order. +- After every commit, run unit tests. If they fail, fix before + moving on. +- If you hit a blocker you cannot resolve: set the current task + `status: blocked`, write notes explaining what is blocking and what + you tried, then STOP. Do not work around it silently. +- ASCII only. +- No AI/Claude/Anthropic attribution anywhere. + +## Secrets + +This sprint does not require any external API keys. If a task ever +needs one, ask the PM to pre-load it via `credential_store_set` and +reference it as `{{secure.NAME}}` only inside `execute_command`-shaped +tool calls. diff --git a/blindfold-migration/CLAUDE-reviewer.md b/blindfold-migration/CLAUDE-reviewer.md new file mode 100644 index 00000000..91c249c5 --- /dev/null +++ b/blindfold-migration/CLAUDE-reviewer.md @@ -0,0 +1,129 @@ +# blindfold-migration — Reviewer (apra-fleet) + +You are the **reviewer** on the apra-fleet blindfold-migration sprint, +checked out in `/media/wayfaringbit/D/dws/apra-fleet-review/`. + +## Project policy + +- ASCII only in any new content you write (commit messages, + feedback.md sections, etc.). +- No Claude / Anthropic / AI attribution anywhere. +- Branch: `md/project-vault`. Base: `main`. + +## Sprint context + +- **Plan:** `blindfold-migration/PLAN.md` +- **Progress:** `blindfold-migration/progress.json` +- **Requirements:** `blindfold-migration/requirements.md` +- **Feedback:** `blindfold-migration/feedback.md` (you overwrite this) + +## Pre-flight (every dispatch) + +1. `git fetch origin` +2. `git checkout md/project-vault` (create local tracking branch if + missing: `git checkout -b md/project-vault origin/md/project-vault`) +3. `git reset --hard origin/md/project-vault` - your tree must match + the doer's pushed HEAD exactly. +4. `git rev-parse HEAD` - confirm SHA matches what PM said the doer + pushed. +5. `git log --oneline main..HEAD` - the commit graph for this branch. + +## Review model + +Review scope is cumulative: every phase up to and including the one +just submitted. Earlier commits may have regressed. + +For the current phase: + +1. Read `blindfold-migration/progress.json` and identify which task + IDs are newly `completed`. +2. Read the corresponding `blindfold-migration/PLAN.md` phase. +3. Read `blindfold-migration/requirements.md` to verify alignment with + intent, not just plan mechanics. +4. `git log --oneline -- blindfold-migration/feedback.md` then + `git show ` to read prior review history. +5. `git diff main..HEAD` for the cumulative diff and + `git diff HEAD~1..HEAD` for the latest commit. +6. Run gates locally: + - `npm ci` (only if package-lock changed since your last review) + - `npm run build` + - `npm test` +7. Compare the diff against the phase's "Done when" criteria. + +## What to check (this sprint specifically) + +For every phase: + +- No new file imports a relative path into `blindfold/`. Every + blindfold use is `from 'blindfold'`. +- ASCII-only in any new content. +- No Claude / AI attribution leaked into commit messages or code. +- Commit message matches the phase header in PLAN.md. + +Phase-specific: + +- **Phase 0:** `.gitmodules` present; submodule pointer at v0.0.1 + (`git -C blindfold rev-parse HEAD` matches + `git -C blindfold rev-parse v0.0.1`); `package.json` has + `"blindfold": "file:./blindfold"`. +- **Phase 1:** `initFleetBlindfold()` called in `src/index.ts` before + any blindfold use AND after `--version` / `--help` short-circuits; + same for `src/smoke-test.ts`; vitest setup wires it for tests. Read + the helper - confirm `dataDir: FLEET_DIR`, + `productName: 'apra-fleet'`, `pipeName: 'apra-fleet-auth'`. A bug in + any of these would silently break existing users' credentials. +- **Phase 2:** zero matches for fleet-local security import paths; + `OOB_TIMEOUT_MS` constant fully replaced with `getOobTimeoutMs()`. +- **Phase 3:** no local `function resolveSecureTokens|redactOutput|resolveSecureField` + or `const SECURE_TOKEN_RE` definitions remain in src/. +- **Phase 4:** all 9 src + 7 test files listed in PLAN.md are deleted; + remaining tests still cover the integration paths. Spot-check: for 3 + deleted tests, identify the blindfold test that covers the same + behavior (in `blindfold/tests/`). +- **Phase 5:** `grep -rn "secret --confirm" src/ tests/ docs/ README.md` + returns nothing; `apra-fleet auth --confirm` exists with `--context` + and `--on` support; help text and docs reflect the move. +- **Phase 6:** smoke + manual log committed; build:binary produced an + executable that prints `--version`. + +## Output - overwrite `blindfold-migration/feedback.md` + +``` +# blindfold-migration — Phase Code Review + +**Reviewer:** reviewerAF +**Date:** +**Verdict:** APPROVED | CHANGES NEEDED + +> See `git log -- blindfold-migration/feedback.md` for prior reviews. + +--- + +## + + + +--- + +## Summary + + +``` + +For CHANGES NEEDED: list HIGH items the doer must fix to re-request +review. MEDIUM/LOW items can be deferred to backlog. + +Commit and push: +- `git add blindfold-migration/feedback.md` +- `git commit -m "review(blindfold): phase - "` +- `git push origin md/project-vault` + +## Hard rules + +- Never edit source code. You review, the doer fixes. +- Never push to `main`. +- Never commit this file (`blindfold-migration/CLAUDE-reviewer.md`). +- ASCII only. +- No AI/Claude/Anthropic attribution. diff --git a/blindfold-migration/PLAN.md b/blindfold-migration/PLAN.md new file mode 100644 index 00000000..67ca318c --- /dev/null +++ b/blindfold-migration/PLAN.md @@ -0,0 +1,457 @@ +# PLAN — Migrate apra-fleet to depend on blindfold + +**Branch:** `md/project-vault` +**Base:** `main` +**Repo:** Apra-Labs/apra-fleet + +## Goal + +Stop maintaining credential-security code inside apra-fleet. Pull it in +from the standalone [`blindfold`](https://github.com/Apra-Labs/blindfold) +package instead. Blindfold was extracted from this code in commit +`79fc0b2` and has been kept up to date with later fleet-main fixes +(`1a8cc12`). Fleet's local copies on `project-vault` are therefore stale +relative to both `main` and `blindfold`; replacing them is mechanical and +auto-upgrades the security layer. + +## Hard guarantees (must hold at every commit boundary) + +1. Existing users' credentials on disk continue to work without + migration. Persistent store lives at + `~/.apra-fleet/data/credentials.json`; auth socket at + `~/.apra-fleet/data/auth.sock`. Windows named pipe stays + `\\.\pipe\apra-fleet-auth-`. These are all preserved by feeding + the right values into `initBlindfold(...)`. +2. `npm run build`, `npm test`, and `npm run build:binary` all succeed + (per the commit's intended scope — Phase 2 may temporarily fail tests + that Phase 4 will delete; PLAN.md flags those). +3. No fleet code imports a relative path into `blindfold/`. Imports are + always `from 'blindfold'` (so the same code works once blindfold + ships on npm). +4. The on-the-wire shape of every existing MCP tool is unchanged. + Schemas, tool names, and response strings stay the same. +5. ASCII only — never write non-ASCII characters to any file. Use `-` + for dashes, `->` for arrows, `[OK]` for checkmarks, etc. (Project + rule from CLAUDE.md.) +6. No Claude / Anthropic attribution in commits, code, or PR body. + +--- + +## Phase 0 - Submodule + dependency wiring + +1. The current `blindfold/` directory in the working tree is untracked + (not a submodule). Save it for rollback if needed + (`mv blindfold blindfold.local`), then remove the working-tree copy. +2. Add the submodule: + `git submodule add git@github.com:Apra-Labs/blindfold.git blindfold`. + Pin to tag v0.0.1: + `cd blindfold && git checkout v0.0.1 && cd ..`. +3. `.gitmodules` is created by `git submodule add`; stage it along with + the submodule pointer. +4. Edit `package.json`: + - Add to `dependencies`: `"blindfold": "file:./blindfold"`. + - Keep `@inquirer/password` and `zod` (still used elsewhere; npm + dedupes since blindfold also depends on them). +5. Run `npm install`. This produces `node_modules/blindfold` from the + submodule's source. Verify + `node -e "console.log(require.resolve('blindfold'))"` resolves and + that `node_modules/blindfold/dist/index.js` exists (blindfold's + `prepack` builds it). +6. Run `npm run build` - no source changes yet, so this must still + pass. +7. Commit: `chore(deps): add blindfold as git submodule + file: dep` + +**Done when:** +- `.gitmodules` tracks `blindfold` at v0.0.1. +- `package.json` lists `"blindfold": "file:./blindfold"`. +- `import { initBlindfold } from 'blindfold'` resolves from anywhere in + `src/`. +- `npm install`, `npm run build`, `npm test` all pass. + +--- + +## Phase 1 - Initialize blindfold at every entrypoint + +Add a single tiny helper to centralize the call: + +**New file:** `src/services/blindfold-init.ts` + +```typescript +import { initBlindfold, type Logger } from 'blindfold'; +import { FLEET_DIR } from '../paths.js'; +import { logInfo, logWarn, logError } from '../utils/log-helpers.js'; + +const fleetLogger: Logger = { + info: (tag, msg) => logInfo('blindfold', `[${tag}] ${msg}`), + warn: (tag, msg) => logWarn('blindfold', `[${tag}] ${msg}`), + error: (tag, msg) => logError('blindfold', `[${tag}] ${msg}`), +}; + +let initialized = false; + +export function initFleetBlindfold(): void { + if (initialized) return; + initBlindfold({ + dataDir: FLEET_DIR, + productName: 'apra-fleet', + pipeName: 'apra-fleet-auth', + logger: fleetLogger, + }); + initialized = true; +} +``` + +If `log-helpers.ts` does not export `logInfo/logWarn/logError` with this +exact name, adapt to whatever it does export (the file already calls +into pino — use the existing helpers). Do not invent new log infra. + +**Call `initFleetBlindfold()` first in each entrypoint, before any +blindfold function is touched:** + +1. `src/index.ts` - at the top, AFTER the `--version` / `--help` + short-circuits (must not regress those for speed), and BEFORE the + dynamic imports of CLI subcommands or MCP server. +2. `src/smoke-test.ts` - at the top of `main()`. +3. `tests/setup.ts` (create if missing) - reference from + `vitest.config.ts` via `setupFiles`. The setup file calls + `initFleetBlindfold()` with the same defaults (FLEET_DIR uses + `APRA_FLEET_DATA_DIR` env var for test isolation - that already + works). + +Commit: `feat(blindfold): initialize blindfold config at every fleet entrypoint` + +**Done when:** +- Every executable entrypoint calls `initFleetBlindfold()` before + touching blindfold APIs. +- `apra-fleet --version` and `apra-fleet --help` still respond in + under 200ms (do NOT init blindfold on those paths). +- Existing tests still pass. + +--- + +## Phase 2 - Mechanical import rewrite + +For every file below, swap fleet-local imports for blindfold ones. +Multiple separate fleet imports collapse to ONE `from 'blindfold'` line +(de-dup symbols). + +### Rewrite table + +| From (fleet) | To (blindfold) | +|---|---| +| `'../services/auth-socket.js'` | `'blindfold'` | +| `'../services/credential-store.js'` | `'blindfold'` | +| `'../utils/crypto.js'` | `'blindfold'` | +| `'../utils/secure-input.js'` | `'blindfold'` | +| `'../utils/file-permissions.js'` | `'blindfold'` | +| `'../utils/shell-escape.js'` | `'blindfold'` | +| `'../utils/oob-timeout.js'` | `'blindfold'` | +| `'../utils/credential-validation.js'` | `'blindfold'` | +| `'../utils/collect-secret.js'` | `'blindfold'` | +| (any `../../` variants too) | `'blindfold'` | + +Replace the constant `OOB_TIMEOUT_MS` (find via +`grep -rn "OOB_TIMEOUT_MS" src/ tests/`) with the function call +`getOobTimeoutMs()`. Each call site adds `getOobTimeoutMs` to its +`from 'blindfold'` import. + +### Files to edit (source) + +- `src/index.ts` (only if it imports security primitives) +- `src/cli/secret.ts` +- `src/cli/auth.ts` +- `src/os/linux.ts` +- `src/os/os-commands.ts` +- `src/os/windows.ts` +- `src/services/git-config.ts` +- `src/services/known-hosts.ts` +- `src/services/onboarding.ts` +- `src/services/registry.ts` +- `src/services/ssh.ts` +- `src/services/strategy.ts` +- `src/services/cloud/aws.ts` +- `src/smoke-test.ts` +- `src/tools/credential-store-delete.ts` +- `src/tools/credential-store-list.ts` +- `src/tools/credential-store-set.ts` +- `src/tools/credential-store-update.ts` +- `src/tools/execute-command.ts` +- `src/tools/monitor-task.ts` +- `src/tools/provision-auth.ts` +- `src/tools/provision-vcs-auth.ts` +- `src/tools/register-member.ts` +- `src/tools/setup-git-app.ts` +- `src/tools/stop-prompt.ts` +- `src/tools/update-member.ts` +- `src/utils/auth-env.ts` + +### Files to edit (tests - keep, retarget imports only) + +- `tests/auth-env.test.ts` +- `tests/credential-store-and-execute.test.ts` +- `tests/credential-store-set.test.ts` +- `tests/credential-store-update.test.ts` +- `tests/provision-auth.test.ts` +- `tests/provision-vcs-auth.test.ts` +- `tests/register-member-oob.test.ts` +- `tests/security-hardening.test.ts` +- `tests/setup-git-app.test.ts` +- `tests/update-member.test.ts` +- `tests/integration/session-lifecycle.test.ts` (only if it imports + security primitives directly) + +Commit: `refactor(blindfold): swap security imports to blindfold package` + +**Done when:** +- `grep -rn "from '\.\.[/.]*\(services/auth-socket\|services/credential-store\|utils/crypto\|utils/secure-input\|utils/file-permissions\|utils/shell-escape\|utils/oob-timeout\|utils/credential-validation\|utils/collect-secret\)'" src/ tests/` + returns zero. +- `grep -rn "OOB_TIMEOUT_MS" src/ tests/` returns zero. +- `npm run build` passes. +- `npm test` passes (or only fails on tests scheduled for deletion in + Phase 4 - note which in progress.json notes). + +--- + +## Phase 3 - Drop fleet's local re-implementations of token-resolver + +Fleet currently carries duplicate `resolveSecureTokens`/`redactOutput` +in `src/tools/execute-command.ts` and `resolveSecureField` in +`src/tools/provision-vcs-auth.ts`, plus a local `SECURE_TOKEN_RE` in +`src/tools/execute-prompt.ts`. Delete them and use blindfold's exports. + +### `src/tools/execute-command.ts` + +Blindfold exports: + +```typescript +function resolveSecureTokens( + text: string, + opts?: { caller?: string; os?: 'windows' | 'macos' | 'linux'; shellEscape?: boolean } +): { resolved: string; credentials: ResolvedCredential[] } | { error: string }; + +function redactOutput( + output: string, + credentials: Array<{ name: string; plaintext: string }> +): string; +``` + +Changes: + +1. Delete the local `SEC_RE`, `ResolvedCredential` interface, + `resolveSecureTokens`, `redactOutput` (lines 41-112). +2. Add `ResolvedCredential`, `resolveSecureTokens`, `redactOutput`, and + `SEC_HANDLE_RE` to the `from 'blindfold'` import. +3. Update call sites: + - Was: `await resolveSecureTokens(input.command, agentOs, agent.friendlyName)` + - Now: `resolveSecureTokens(input.command, { caller: agent.friendlyName, os: agentOs })` + - Drop `await` (blindfold's version is synchronous). +4. Replace local `SEC_RE` checks at lines 139-144 with imported + `SEC_HANDLE_RE.test(...)`. + +### `src/tools/provision-vcs-auth.ts` + +Blindfold exports: + +```typescript +function resolveSecureField( + value: string, + caller?: string +): { resolved: string } | { error: string }; +``` + +Changes: + +1. Delete the local `resolveSecureField` function. +2. Add `resolveSecureField` to the `from 'blindfold'` import. +3. Call site at line 102 already matches blindfold's signature; + only the import line changes. + +### `src/tools/execute-prompt.ts` + +The local `SECURE_TOKEN_RE` (line 91) is used only as a presence check. +Replace with blindfold's `containsSecureTokens(input.prompt)`: + +```typescript +import { containsSecureTokens } from 'blindfold'; +// ... +if (containsSecureTokens(input.prompt)) { ... } +``` + +Delete the local `SECURE_TOKEN_RE` constant. + +Commit: `refactor(blindfold): use blindfold's token-resolver instead of local copies` + +**Done when:** +- `grep -rn "function resolveSecureTokens\|function redactOutput\|function resolveSecureField\|const SECURE_TOKEN_RE\b" src/` + returns zero. +- `npm run build` passes. +- `npm test` passes (modulo Phase 4 deletions). + +--- + +## Phase 4 - Delete fleet's stale security modules and their unit tests + +### Delete (source) + +- `src/services/auth-socket.ts` +- `src/services/credential-store.ts` +- `src/utils/crypto.ts` +- `src/utils/secure-input.ts` +- `src/utils/file-permissions.ts` +- `src/utils/shell-escape.ts` +- `src/utils/oob-timeout.ts` +- `src/utils/credential-validation.ts` +- `src/utils/collect-secret.ts` + +### Delete (tests - these test blindfold internals, not fleet glue) + +- `tests/auth-socket.test.ts` +- `tests/crypto.test.ts` +- `tests/shell-escape.test.ts` +- `tests/credential-validation.test.ts` +- `tests/credential-cleanup.test.ts` +- `tests/credential-scoping-ttl.test.ts` +- `tests/credential-store-path.test.ts` + +### Keep (integration-shaped fleet tests) + +These exercise fleet's glue around blindfold and should pass after +Phase 2's import rewrite: + +- `tests/credential-store-and-execute.test.ts` +- `tests/credential-store-set.test.ts` +- `tests/credential-store-update.test.ts` +- `tests/auth-env.test.ts` +- `tests/provision-auth.test.ts` +- `tests/provision-vcs-auth.test.ts` +- `tests/register-member-oob.test.ts` +- `tests/security-hardening.test.ts` +- `tests/setup-git-app.test.ts` +- `tests/update-member.test.ts` +- `tests/integration/session-lifecycle.test.ts` + +Commit: `chore(blindfold): delete fleet's stale security modules and unit tests` + +**Done when:** +- All listed files are gone from working tree and git index. +- `git status` shows only intended deletions. +- `npm run build` passes. +- `npm test` passes with zero failures. + +--- + +## Phase 5 - Move confirm subcommand from `secret` to `auth`, remove alias + +Blindfold's OOB launcher spawns ` auth --confirm [--context ] [--on ]`. +Fleet today exposes `apra-fleet secret --confirm`. Move the handler and +DELETE the old path completely (no deprecation period, per user +instruction). + +### `src/cli/auth.ts` + +1. Extend the entry dispatch: + +```typescript +export async function runAuth(args: string[]): Promise { + if (args.includes('--confirm')) return handleConfirm(args); + if (args.includes('--oauth')) return handleOAuth(args); + if (args.includes('--api-key')) return handleApiKey(args); + // ... existing usage error +} +``` + +2. Add `handleConfirm(args)` - port from `src/cli/secret.ts` + `handleConfirm` (lines 37-124), but: + - Import `getSocketPath` from `'blindfold'` (Phase 2 already did this). + - Keep ASCII-only output (project rule). + - Sanitize `--context` and `--on` exactly as blindfold does + (strip `[\x00-\x1f\x7f]`). + - Re-validate `` against `^[a-zA-Z0-9_-]{1,64}$`. + +3. Update help text in the usage block to add `--confirm` form. + +### `src/cli/secret.ts` + +1. Delete `handleConfirm` entirely (lines 37-124). +2. Remove `--confirm` from the dispatch branch (line 29). +3. Remove `--confirm` from the help text at lines 11-18. +4. Drop any imports made dead by the deletion. + +### `src/index.ts` + +- Remove `apra-fleet secret --confirm` line from help. +- Add `apra-fleet auth --confirm ` line under the auth block. + +### Tests + +- Search for any test that invokes `apra-fleet secret --confirm` or + imports `handleConfirm` from `secret.ts`. Port to + `apra-fleet auth --confirm`. +- Add coverage (or update existing CLI test) for + `apra-fleet auth --confirm` happy path and bad-name rejection. + +### Documentation + +- Update `README.md` and `docs/features/oob-auth.md` (and any other doc + that mentions `secret --confirm`) to the new `auth --confirm` form. + +Commit: `feat(cli): move egress-confirm from 'secret --confirm' to 'auth --confirm'` + +**Done when:** +- `grep -rn "secret --confirm\|secret_--confirm" src/ tests/ docs/ README.md` returns zero. +- `npm test` passes. + +--- + +## Phase 6 - Smoke + binary build verification + +1. `npm run build` - passes. +2. `npm test` - passes. +3. `npm run smoke` - passes. +4. `npm run build:binary` - produces a binary in `dist-binary/` (or + wherever the build script writes it); run `--version` and `--help` + to confirm it boots. +5. Manual flow (ASCII output - capture commands in + `blindfold-migration/phase6-manual.md` with exit codes): + - `apra-fleet secret --set FOO --persist` (enter `bar`). + - `apra-fleet secret --list` shows `FOO`. + - From an MCP client, run `execute_command` with + `command: "echo {{secure.FOO}}"`. Output must contain + `[REDACTED:FOO]` and exit code 0. + - `apra-fleet secret --update FOO --deny` sets policy=deny. + - `execute_command` with + `command: "curl https://example.com -H 'X: {{secure.FOO}}'"` + returns `Blocked: credential "FOO" has network_policy=deny`. + - `apra-fleet secret --update FOO --allow` then update to + network_policy=confirm; retry curl: OOB terminal opens with + `apra-fleet auth --confirm FOO`; typing `yes` allows. + - `apra-fleet secret --delete FOO` removes it. +6. Commit: `chore(blindfold): post-migration verification` (only if + any small follow-ups landed; otherwise no commit). + +**Done when:** +- All four automated checks pass. +- Manual flow log shows every step succeeded. + +--- + +## Out of scope (do NOT touch) + +- npm publishing of blindfold. The user will publish separately. +- Renaming any MCP tool or changing tool schemas. +- Migrating existing on-disk credentials (the whole point of + preserving `dataDir: FLEET_DIR` is no migration is needed). +- Removing the prior-sprint files at repo root (`PLAN.md`, `plan.md`, + `progress.json`, `OVERVIEW.md`, `requirements*.md`, etc.). Those are + untracked leftovers - leave them alone. + +## Commit policy + +- One commit per phase. Each commit must build and (modulo + documented temporary regressions) test green. +- Commit message format: `(): ` - e.g. + `refactor(blindfold): swap security imports to blindfold package`. +- No attribution lines. No Claude / Anthropic / AI references in + commit messages, code comments, or PR descriptions. +- Push to origin `md/project-vault` at every VERIFY checkpoint so the + reviewer can fetch. diff --git a/blindfold-migration/backlog.md b/blindfold-migration/backlog.md new file mode 100644 index 00000000..3a462baa --- /dev/null +++ b/blindfold-migration/backlog.md @@ -0,0 +1,37 @@ +# blindfold-migration - Backlog + +_(MEDIUM/LOW findings and deferred items land here as the sprint progresses.)_ + +## Phase 1 in-flight incidents + +- **INC-1 (HIGH, resolved):** During Phase 1 verification, `npm test` + wiped `~/.apra-fleet/data/registry.json` (all 6 live members) and + replaced it with 86 fake test agents. Root cause: paths.ts captures + FLEET_DIR at module-load time, but `tests/setup.ts` set + APRA_FLEET_DATA_DIR via top-level code that ran AFTER its hoisted + imports (and therefore after some test files' transitive + paths.ts load). Recovered the 6 members from PM-captured data and + hardened test isolation in commit `eb65946`: vitest.config.ts now + sets the env var at config load time AND tests/setup.ts fails fast + with exit 2 if the env var is not pointing at /tmp. +- **INC-2 (MEDIUM, deferred):** The polluted registry backup is at + `~/.apra-fleet/data/registry.json.polluted-2026-05-19`. Keep until + Phase 5 verification; delete during sprint cleanup if not useful + for forensics. + +## Phase 0 review (commit 3918add) + +- **BL-1 (MEDIUM, RESOLVED commit ac5181c):** `npm install` symlinks + `node_modules/blindfold -> ../blindfold` instead of copying, so + blindfold's `prepack` doesn't run on a fresh clone. Phase 1+ source + imports will fail without `cd blindfold && npm install && npm run build`. + Fix: added `postinstall` script to root `package.json` that builds the + submodule if blindfold/dist does not exist. Verified: rm -rf blindfold/dist + + blindfold/node_modules + node_modules/blindfold -> npm install -> both + BLINDFOLD-DIST and NODE-MODS show OK. +- **BL-2 (LOW):** `blindfold-migration/progress.json` records commit + SHA `061bc164` for tasks 0.1/0.V, but the actual HEAD is `2b4150f` + (chicken-and-egg: progress.json was written inside the commit then + amended). Cosmetic; branch pointer is authoritative. No action + unless a future PM relies on progress.json SHA as truth (it should + not - use `git log` instead). diff --git a/blindfold-migration/feedback.md b/blindfold-migration/feedback.md new file mode 100644 index 00000000..6a5a3a1e --- /dev/null +++ b/blindfold-migration/feedback.md @@ -0,0 +1,115 @@ +# blindfold-migration - Post-PR hardening review + +**Reviewer:** reviewerAF +**Date:** 2026-05-22 15:42:00+05:30 +**Verdict:** APPROVED + +> See `git log -- blindfold-migration/feedback.md` for prior reviews (sprint final APPROVED previously). + +--- + +## Goal of this change + +Remove the resolve_secure MCP tool from blindfold to close a wire-level +leak: it returned plaintext credentials in MCP responses, placing them +into LLM context. blindfold becomes vault-management-only on the wire; +token resolution stays library-only and is consumed by hosts (apra-fleet). + +--- + +## Verifications + +### Wire surface (MCP tools registered): PASS +4 registerTool calls in blindfold/src/mcp/server.ts (lines 25, 34, 43, 52): +credential_store_set, credential_store_list, credential_store_delete, +credential_store_update. No resolve_secure registration. + +### resolve_secure removed (file, registration, tests): PASS +- grep for resolve_secure, resolveSecureHandler, resolveSecureSchema in + blindfold/src/ and blindfold/tests/: zero matches. +- blindfold/src/mcp/tools/resolve-secure.ts: file does not exist. +- No import of a resolve-secure module anywhere in the MCP server. + +### Live MCP server tool list: PASS +JSON-RPC probe (tools/list) against `node dist/cli/index.js` after clean +rebuild from v0.0.2 source returned exactly 4 tools: + credential_store_set + credential_store_list + credential_store_delete + credential_store_update + +NOTE: initial probe against a stale dist/ (pre-existing from a prior +checkout) showed 5 tools including resolve_secure. After `rm -rf dist && +npm run build`, the rebuilt dist correctly registers only 4. The stale +dist was a local artifact, not a source-level issue -- the v0.0.2 source +at 580213c is correct. + +### Library exports preserved: PASS +All 6 symbols exported from blindfold/src/index.ts (lines 24-29): + resolveSecureTokens, resolveSecureField, redactOutput, + containsSecureTokens, SECURE_TOKEN_RE, SEC_HANDLE_RE + +### README updated with vault-only positioning: PASS +Section "Standalone vs host-integrated usage" at blindfold/README.md:70-94 +(~25 lines). Explains vault-only MCP surface, why resolve_secure is +intentionally absent (plaintext would enter LLM context), and that host +integration (e.g. apra-fleet) is required for workflow use. ASCII-only +(no em-dashes, smart quotes, or emoji in the new section). + +MCP tool reference table (lines 61-67) lists exactly 4 tools; no +resolve_secure row. + +### blindfold v0.0.2 tag points at correct commit: PASS +gh api repos/Apra-Labs/blindfold/git/refs/tags/v0.0.2: + object.sha = 580213c82e985832eaaa696416c6682783766804 +Commit message: "feat(blindfold)!: remove resolve_secure MCP tool; +vault-only surface" -- mentions removal, ASCII-only, no AI attribution. + +### apra-fleet submodule pointer advanced: PASS +git show --stat 80da6cc: + blindfold | 2 +- + 1 file changed, 1 insertion(+), 1 deletion(-) +Only the submodule pointer changed. Commit message is clean. + +### blindfold tests: 139/0 +7 test files, 139 tests passing, 0 failing. All green. + +### apra-fleet build + tests: PASS, 1169/3 +npm run build (tsc): exit 0. npm test: 1169 passing, 3 failing (all +pre-existing baseline: 1 platform login-shell, 2 time-utils IST). +16/16 in credential-store-and-execute.test.ts. + +### INC-1 isolation (registry diff lines): 0 +Registry.json snapshot before and after npm test: zero diff lines. + +### Spurious OOB pops: none +No unexpected terminal popups during any test run. + +### ASCII + AI attribution sprint-wide: PASS +git log main..HEAD: 0 matches for claude/anthropic/ai-generated/ +co-authored-by. All commits authored by mradul . + +### execute_command integration spot-check: PASS +src/tools/execute-command.ts:11 imports resolveSecureTokens, redactOutput, +SEC_HANDLE_RE, registerTaskCredentials, collectOobConfirm from 'blindfold'. +Uses resolveSecureTokens at lines 73, 81 and redactOutput at lines 148, 175. +Integration test (credential-store-and-execute.test.ts): 16/16 pass. + +--- + +## Summary + +**APPROVED** + +**HIGH findings:** 0 +**MEDIUM findings:** 0 +**LOW findings:** 0 + +The resolve_secure MCP tool is fully removed from blindfold v0.0.2 at +both the source and wire levels. The live MCP server (after clean build) +registers exactly 4 vault-management tools. All 6 library exports are +preserved for host consumption. The README documents the vault-only +positioning and the design rationale. The v0.0.2 tag on GitHub points at +the correct commit (580213c). apra-fleet's submodule bump (80da6cc) is a +clean single-file change. Build, tests, and INC-1 isolation all pass. +The wire-level credential leak is closed. diff --git a/blindfold-migration/permissions.json b/blindfold-migration/permissions.json new file mode 100644 index 00000000..7d9d8681 --- /dev/null +++ b/blindfold-migration/permissions.json @@ -0,0 +1,6 @@ +{ + "stacks": [ + "node" + ], + "granted": [] +} diff --git a/blindfold-migration/phase6-manual.md b/blindfold-migration/phase6-manual.md new file mode 100644 index 00000000..5d013b58 --- /dev/null +++ b/blindfold-migration/phase6-manual.md @@ -0,0 +1,64 @@ +# Phase 6 Manual CLI Log + +Branch: md/project-vault +Date: 2026-05-20 +Node: 20.20.1 + +## 4a - secret --set (persist via stdin) + +``` +export APRA_FLEET_DATA_DIR=/tmp/phase6-smoke-data +echo -n "test-value-123" | node dist/index.js secret --set FOO --persist -y +``` + +Output: + Secret stored for FOO. + Network policy: allow. Use 'apra-fleet secret --update FOO --deny' to restrict. +Exit: 0 + +Note: env var must be exported before the pipe; inline assignment only applies to +the echo command, not node. Used `export` form to ensure isolation. + +## 4b - secret --list + +``` +APRA_FLEET_DATA_DIR=/tmp/phase6-smoke-data node dist/index.js secret --list +``` + +Output: + NAME SCOPE POLICY MEMBERS EXPIRES + ---- ---------- ------ ------- ------- + FOO persistent allow * - +Exit: 0 + +## 4c - secret --update --deny + +``` +APRA_FLEET_DATA_DIR=/tmp/phase6-smoke-data node dist/index.js secret --update FOO --deny +``` + +Output: + Credential updated: FOO +Exit: 0 + +## 4d - secret --delete + +``` +APRA_FLEET_DATA_DIR=/tmp/phase6-smoke-data node dist/index.js secret --delete FOO +``` + +Output: + Credential deleted: FOO +Exit: 0 + +## 4e - cleanup + +``` +rm -rf /tmp/phase6-smoke-data +``` + +Exit: 0 + +## Summary + +All steps green. Real registry unchanged (INC-1 isolation: diff 0 lines). diff --git a/blindfold-migration/progress.json b/blindfold-migration/progress.json new file mode 100644 index 00000000..c939d563 --- /dev/null +++ b/blindfold-migration/progress.json @@ -0,0 +1,132 @@ +{ + "_schema": { + "type": "work | verify", + "status": "pending | in_progress | completed | blocked" + }, + "project": "blindfold-migration", + "plan_file": "blindfold-migration/PLAN.md", + "branch": "md/project-vault", + "base": "main", + "created": "2026-05-19", + "tasks": [ + { + "id": "0.1", + "step": "Phase 0 — submodule + file: dep + npm install", + "type": "work", + "status": "completed", + "tier": "standard", + "commit": "061bc164889b0cd49bd5d1fcfbc00d7d7a0d9cac", + "notes": "submodule pinned to v0.0.1; npm install + build + tests pass on Node 20.20.1" + }, + { + "id": "0.V", + "step": "VERIFY Phase 0: build green, submodule pinned to v0.0.1", + "type": "verify", + "status": "completed", + "commit": "061bc164889b0cd49bd5d1fcfbc00d7d7a0d9cac", + "notes": "build PASS; npm test PASS; submodule HEAD matches v0.0.1 (a35e266); 1280 passing (3 pre-existing time-utils failures on main unrelated to migration)" + }, + { + "id": "1.1", + "step": "Phase 1 - initFleetBlindfold helper + entrypoint calls", + "type": "work", + "status": "completed", + "tier": "standard", + "commit": "6dbe017bcb9f37c730d9f81d87fbcb03b3ae1a53", + "notes": "initFleetBlindfold helper added; wired into index.ts (post --version/--help short-circuits), smoke-test.ts (top of file), tests/setup.ts (static import; initFleetBlindfold() called after APRA_FLEET_DATA_DIR is set). Logger writes directly to stderr - avoids pulling log-helpers -> paths.ts at module load time, which would pre-cache FLEET_DIR and break statusline test isolation." + }, + { + "id": "1.V", + "step": "VERIFY Phase 1: init called everywhere, --version/--help still fast", + "type": "verify", + "status": "completed", + "commit": "6dbe017bcb9f37c730d9f81d87fbcb03b3ae1a53", + "notes": "build PASS; npm test PASS - 1280 passing, 3 failing (all pre-existing: 1 platform login-shell + 2 time-utils IST timezone, same as Phase 0 baseline counted differently). No new regressions. Hardening fix eb65946 added to vitest.config.ts + tests/setup.ts to prevent npm test from leaking writes into ~/.apra-fleet/data (see backlog INC-1)." + }, + { + "id": "2.1", + "step": "Phase 2 — mechanical import rewrite (~27 files) + OOB_TIMEOUT_MS", + "type": "work", + "status": "completed", + "tier": "standard", + "commit": "1a409e0", + "notes": "36 files: imports rewritten to from 'blindfold' (26 src + 11 tests). 3 test files had vi.mock targets updated to 'blindfold' with importActual pattern. provision-auth.test.ts mock extended with claudeAiOauth-aware validateCredentials (blindfold generic version looks at top-level expiresAt; Claude creds file nests inside claudeAiOauth). OOB_TIMEOUT_MS not replaced in callers: only used inside files scheduled for Phase 4 deletion." + }, + { + "id": "2.V", + "step": "VERIFY Phase 2: zero fleet-local security imports remain, build green", + "type": "verify", + "status": "completed", + "commit": "1a409e0", + "notes": "build PASS; tests 1279 passing, 4 failing (3 pre-existing + 1 expected-deleted-Phase-4)" + }, + { + "id": "3.1", + "step": "Phase 3 — drop fleet's local resolveSecureTokens/redactOutput/resolveSecureField/SECURE_TOKEN_RE", + "type": "work", + "status": "completed", + "tier": "standard", + "commit": "88c4dec", + "notes": "local resolveSecureTokens/redactOutput dropped from execute-command.ts; local resolveSecureField dropped from provision-vcs-auth.ts; local SECURE_TOKEN_RE dropped from execute-prompt.ts; all use blindfold exports. resolveSecureTokens signature changed to options-object (no await)." + }, + { + "id": "3.V", + "step": "VERIFY Phase 3: no local duplicates, build + tests green", + "type": "verify", + "status": "completed", + "commit": "88c4dec", + "notes": "build PASS; tests 1279/4 (same baseline)" + }, + { + "id": "4.1", + "step": "Phase 4 — delete 9 src + 7 test files", + "type": "work", + "status": "completed", + "tier": "standard", + "commit": "05d94d3779fa4e20284bb32c5ee4d4eb60a67cb8", + "notes": "deleted 9 src + 7 test files. auth.ts/index.ts had 3 stale dynamic imports (credentialResolve, purgeExpiredCredentials, cleanupAuthSocket) missed in Phase 2 - retargeted to 'blindfold'." + }, + { + "id": "4.V", + "step": "VERIFY Phase 4: build + tests fully green, only intended deletions", + "type": "verify", + "status": "completed", + "commit": "05d94d3779fa4e20284bb32c5ee4d4eb60a67cb8", + "notes": "build PASS; tests 1167/3 (only pre-existing baseline remains: 1 platform + 2 time-utils)" + }, + { + "id": "5.1", + "step": "Phase 5 — move confirm to auth, delete alias, update docs", + "type": "work", + "status": "completed", + "tier": "standard", + "commit": "0388281", + "notes": "moved handleConfirm from src/cli/secret.ts to src/cli/auth.ts; deleted secret --confirm path entirely (no alias); added NAME_REGEX validation + control-char sanitization on --context/--on; updated src/index.ts help; updated docs/features/oob-auth.md + docs/tools-infrastructure.md; added tests/auth-cli.test.ts (2 new passing tests)" + }, + { + "id": "5.V", + "step": "VERIFY Phase 5: zero 'secret --confirm' refs anywhere, tests pass", + "type": "verify", + "status": "completed", + "commit": "0388281", + "notes": "build PASS; tests 1169/3 (baseline); grep secret --confirm returns 0" + }, + { + "id": "6.1", + "step": "Phase 6 — automated + manual smoke + binary build", + "type": "work", + "status": "completed", + "tier": "standard", + "commit": "ac5181c", + "notes": "postinstall hook added (BL-1 resolved); blindfold/dist auto-builds on fresh npm install; smoke + binary + manual CLI flow pass" + }, + { + "id": "6.V", + "step": "VERIFY Phase 6: all green, manual log committed", + "type": "verify", + "status": "completed", + "commit": "ac5181c", + "notes": "build PASS; tests 1169/3; smoke PASS; binary boots 154ms; CLI set/list/update/delete all green" + } + ] +} diff --git a/blindfold-migration/requirements.md b/blindfold-migration/requirements.md new file mode 100644 index 00000000..7fc684ed --- /dev/null +++ b/blindfold-migration/requirements.md @@ -0,0 +1,79 @@ +# blindfold-migration — Requirements + +## Background + +The credential-security layer of apra-fleet (auth-socket / credential-store +/ crypto / shell-escape / etc.) was extracted into a standalone package +called **blindfold** (https://github.com/Apra-Labs/blindfold). The +extraction happened in commit `79fc0b2` on this branch. Bug fixes that +later landed on fleet's `main` branch were forward-ported into blindfold +(`1a8cc12`). Today, blindfold is the canonical, up-to-date version of +that code. + +The same code still lives inside apra-fleet's `src/services/` and +`src/utils/`. It is stale relative to both fleet `main` and blindfold. +We must remove the in-tree copies and have fleet consume blindfold as a +dependency. + +## Functional requirements + +1. **Apra-fleet keeps working for existing users.** Every existing user + has credentials at `~/.apra-fleet/data/credentials.json`, sockets at + `~/.apra-fleet/data/auth.sock`, and Windows pipes at + `\\.\pipe\apra-fleet-auth-`. After this sprint these paths + must be unchanged - no on-disk migration. + +2. **MCP tool surface is unchanged.** Every credential_store_* and + execute_command tool keeps its name, schema, and response format. + +3. **CLI surface is mostly unchanged with one intentional move:** + - `apra-fleet secret --confirm ` -> moved to + `apra-fleet auth --confirm ` (with `--context` and `--on` + options forwarded by blindfold's OOB launcher). The old path is + deleted - no deprecation alias. + - All other CLI subcommands stay identical. + +4. **`{{secure.NAME}}` token resolution and output redaction continue + to work** exactly as before, including in restart_command and the + network egress (confirm/deny) flow. + +## Non-functional requirements + +1. **Dependency shape:** fleet imports from `'blindfold'` (npm-shaped), + never from a relative path into `blindfold/`. Today blindfold is + pulled in via `"blindfold": "file:./blindfold"` with `blindfold/` as + a git submodule. When the user publishes blindfold to npm, the only + change is the version spec in package.json. + +2. **Build + test:** `npm run build`, `npm test`, and + `npm run build:binary` (SEA binary) all pass at every commit + boundary, modulo Phase 4 deletions noted in PLAN.md. + +3. **ASCII only.** Project policy: no non-ASCII characters in any + committed file. Use `-` for dashes, `->` for arrows, `[OK]` for + checkmarks, etc. + +4. **No Claude / AI attribution** in commits, code, comments, or PR + bodies. + +5. **One commit per phase**, with `(): ` + subject lines. + +## Constraints + +- Branch: `md/project-vault`. Do not push to `main`. Do not open a PR - + the user reviews locally first. +- The submodule pointer is pinned to blindfold tag `v0.0.1`. +- Cycle limit per phase: 3 doer-reviewer rounds. If a phase doesn't + converge in 3, the PM pauses and flags the user. + +## Acceptance criteria + +- Every file listed in PLAN.md as "delete" is gone. +- `grep -rn "from '\.\./services/auth-socket\|from '\.\./services/credential-store\|from '\.\./utils/crypto\|from '\.\./utils/secure-input\|from '\.\./utils/file-permissions\|from '\.\./utils/shell-escape\|from '\.\./utils/oob-timeout\|from '\.\./utils/credential-validation\|from '\.\./utils/collect-secret" src/ tests/` + returns zero. +- `grep -rn "secret --confirm" src/ tests/ docs/ README.md` returns + zero. +- `npm install && npm run build && npm test && npm run smoke && npm run build:binary` all succeed. +- Manual flow log (Phase 6 of PLAN.md) shows credential set/list/delete, + redaction, deny block, and confirm-allow all work end-to-end. diff --git a/blindfold-migration/status.md b/blindfold-migration/status.md new file mode 100644 index 00000000..80ecf46a --- /dev/null +++ b/blindfold-migration/status.md @@ -0,0 +1,38 @@ +# blindfold-migration — Status + +## Project +- **Base branch:** main +- **Sprint branch:** md/project-vault +- **Repo:** Apra-Labs/apra-fleet +- **Created:** 2026-05-19 +- **Push policy:** push to origin md/project-vault each VERIFY; do NOT push to main; do NOT raise PR — user reviews locally first. + +## Members + +### 🔵 doerAF (doer) +- **Member ID:** 74bdc5fe-efb8-42d6-92db-45d9169c9f8b +- **Work folder:** /media/wayfaringbit/D/dws/apra-fleet +- **Branch:** md/project-vault +- **Provider:** claude (oauth) + +### 🟦 reviewerAF (reviewer) +- **Member ID:** f69e5e2c-7bcf-4b3b-9b8d-5746c4c45910 +- **Work folder:** /media/wayfaringbit/D/dws/apra-fleet-review +- **Branch:** main (must fetch + checkout md/project-vault at first review) +- **Provider:** claude (oauth) + +## Phases + +### Phase 0 — submodule + dep — PENDING +### Phase 1 — init helper — PENDING +### Phase 2 — import rewrite — PENDING +### Phase 3 — drop local token-resolver — PENDING +### Phase 4 — delete stale modules + unit tests — PENDING +### Phase 5 — move confirm subcommand — PENDING +### Phase 6 — smoke + binary verification — PENDING + +## Blockers +(none) + +## Recent PM activity +- 2026-05-19: project initialized, harness sent, Phase 0 dispatched diff --git a/docs/features/oob-auth.md b/docs/features/oob-auth.md index e84f17fd..9cc73e5f 100644 --- a/docs/features/oob-auth.md +++ b/docs/features/oob-auth.md @@ -99,7 +99,7 @@ Alternatively, pre-store the value with credential_store_set and reference it as fallback:No graphical display detected (SSH or headless session). Run this in a separate terminal to confirm: - ! apra-fleet secret --confirm + ! apra-fleet auth --confirm Alternatively, pre-store the value with credential_store_set and reference it as {{secure.NAME}} in the credential field. ``` diff --git a/docs/tools-infrastructure.md b/docs/tools-infrastructure.md index 1ead2c4a..d2e28755 100644 --- a/docs/tools-infrastructure.md +++ b/docs/tools-infrastructure.md @@ -65,10 +65,10 @@ Used for pay-per-use billing. Works with all providers. - `member_detail` detects all auth methods: credentials file (Claude OAuth) and API key env var (per-provider). - If `execute_prompt` returns an auth error for a member, call `provision_llm_auth` for that member to restore credentials, then resume the prompt with `resume=true`. -## apra-fleet secret --confirm +## apra-fleet auth --confirm ``` -apra-fleet secret --confirm +apra-fleet auth --confirm ``` OOB (out-of-band) network egress confirmation. When a credential is stored with `network_policy: 'confirm'`, fleet automatically opens a new terminal running this command - passing the **credential name** - before executing any `{{secure.NAME}}` substitution that would send that credential over the network. @@ -90,7 +90,7 @@ The user types `yes` to allow, or closes the window / types anything else to den curl -X POST https://api.example.com -d "{{secure.MY-CRED-NAME}}" Run this in a separate terminal to confirm: - ! apra-fleet secret --confirm MY-CRED-NAME + ! apra-fleet auth --confirm MY-CRED-NAME ``` ## apra-fleet auth (CLI) diff --git a/llms-full.txt b/llms-full.txt index 08d9e756..2f281e2e 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -1064,7 +1064,7 @@ Alternatively, pre-store the value with credential_store_set and reference it as fallback:No graphical display detected (SSH or headless session). Run this in a separate terminal to confirm: - ! apra-fleet secret --confirm + ! apra-fleet auth --confirm Alternatively, pre-store the value with credential_store_set and reference it as {{secure.NAME}} in the credential field. ``` diff --git a/package-lock.json b/package-lock.json index accb81d5..2745c9d5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,10 +7,12 @@ "": { "name": "apra-fleet", "version": "0.2.1", + "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { "@inquirer/password": "^5.0.11", "@modelcontextprotocol/sdk": "^1.27.0", + "blindfold": "file:./blindfold", "smol-toml": "^1.6.1", "ssh2": "^1.17.0", "uuid": "^14.0.0", @@ -26,6 +28,520 @@ "vitest": "^4.0.18" } }, + "blindfold": { + "version": "0.0.1", + "license": "Apache-2.0", + "dependencies": { + "@inquirer/password": "^5.0.11", + "zod": "^3.25.0" + }, + "bin": { + "blindfold": "dist/cli/index.js" + }, + "devDependencies": { + "@modelcontextprotocol/sdk": "^1.27.0", + "@types/node": "^22.0.0", + "esbuild": "^0.25.0", + "postject": "^1.0.0-alpha.6", + "typescript": "^5.5.0", + "vitest": "^4.0.18" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@modelcontextprotocol/sdk": "^1.27.0" + }, + "peerDependenciesMeta": { + "@modelcontextprotocol/sdk": { + "optional": true + } + } + }, + "blindfold/node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "blindfold/node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "blindfold/node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "blindfold/node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "blindfold/node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "blindfold/node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "blindfold/node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "blindfold/node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "blindfold/node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "blindfold/node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "blindfold/node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "blindfold/node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "blindfold/node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "blindfold/node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "blindfold/node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "blindfold/node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "blindfold/node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "blindfold/node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "blindfold/node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "blindfold/node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "blindfold/node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "blindfold/node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "blindfold/node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "blindfold/node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "blindfold/node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "blindfold/node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "blindfold/node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.27.4", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz", @@ -469,10 +985,9 @@ } }, "node_modules/@hono/node-server": { - "version": "1.19.14", - "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", - "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==", - "license": "MIT", + "version": "1.19.9", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.9.tgz", + "integrity": "sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw==", "engines": { "node": ">=18.14.1" }, @@ -484,16 +999,14 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-2.0.5.tgz", "integrity": "sha512-doc2sWgJpbFQ64UflSVd17ibMGDuxO1yKgOgLMwavzESnXjFWJqUeG8saYosqKpHp4kWiM5x1nXvEjbpx90gzw==", - "license": "MIT", "engines": { "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" } }, "node_modules/@inquirer/core": { - "version": "11.1.8", - "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-11.1.8.tgz", - "integrity": "sha512-/u+yJk2pOKNDOh1ZgdUH2RQaRx6OOH4I0uwL95qPvTFTIL38YBsuSC4r1yXBB3Q6JvNqFFc202gk0Ew79rrcjA==", - "license": "MIT", + "version": "11.1.10", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-11.1.10.tgz", + "integrity": "sha512-a4Q5BXHQAHa9eO202sTaFCHFYVB3x5fauDuThEAdZ9gfn76pSxiKU7wWcEH0N1O0XmQvNfQNU6QXpiRxmYQx+A==", "dependencies": { "@inquirer/ansi": "^2.0.5", "@inquirer/figures": "^2.0.5", @@ -519,19 +1032,17 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-2.0.5.tgz", "integrity": "sha512-NsSs4kzfm12lNetHwAn3GEuH317IzpwrMCbOuMIVytpjnJ90YYHNwdRgYGuKmVxwuIqSgqk3M5qqQt1cDk0tGQ==", - "license": "MIT", "engines": { "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" } }, "node_modules/@inquirer/password": { - "version": "5.0.11", - "resolved": "https://registry.npmjs.org/@inquirer/password/-/password-5.0.11.tgz", - "integrity": "sha512-9KZFeRaNHIcejtPb0wN4ddFc7EvobVoAFa049eS3LrDZFxI8O7xUXiITEOinBzkZFAIwY5V4yzQae/QfO9cbbg==", - "license": "MIT", + "version": "5.0.13", + "resolved": "https://registry.npmjs.org/@inquirer/password/-/password-5.0.13.tgz", + "integrity": "sha512-XDGu64ROHZjOOXLAANvJN7iIxWKhOSCG5VakrZ5kaScVR+snVJCFglD/hL3/677awtWcu4pXoWa280CDIYcBeg==", "dependencies": { "@inquirer/ansi": "^2.0.5", - "@inquirer/core": "^11.1.8", + "@inquirer/core": "^11.1.10", "@inquirer/type": "^4.0.5" }, "engines": { @@ -550,7 +1061,6 @@ "version": "4.0.5", "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-4.0.5.tgz", "integrity": "sha512-aetVUNeKNc/VriqXlw1NRSW0zhMBB0W4bNbWRJgzRl/3d0QNDQFfk0GO5SDdtjMZVg6o8ZKEiadd7SCCzoOn5Q==", - "license": "MIT", "engines": { "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" }, @@ -1172,6 +1682,10 @@ "tweetnacl": "^0.14.3" } }, + "node_modules/blindfold": { + "resolved": "blindfold", + "link": true + }, "node_modules/body-parser": { "version": "2.2.2", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", @@ -1252,7 +1766,6 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", - "license": "ISC", "engines": { "node": ">= 12" } @@ -1564,12 +2077,11 @@ } }, "node_modules/express-rate-limit": { - "version": "8.5.0", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.0.tgz", - "integrity": "sha512-XKhFohWaSBdVJNTi5TaHziqnPkv04I9UQV6q1Wy7Ui6GGQZVW12ojDFwqer14EvCXxjvPG0CyWXx7cAXpALB4Q==", - "license": "MIT", + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.2.1.tgz", + "integrity": "sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g==", "dependencies": { - "ip-address": "10.1.0" + "ip-address": "10.0.1" }, "engines": { "node": ">= 16" @@ -1589,14 +2101,12 @@ "node_modules/fast-string-truncated-width": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/fast-string-truncated-width/-/fast-string-truncated-width-3.0.3.tgz", - "integrity": "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==", - "license": "MIT" + "integrity": "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==" }, "node_modules/fast-string-width": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/fast-string-width/-/fast-string-width-3.0.2.tgz", "integrity": "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==", - "license": "MIT", "dependencies": { "fast-string-truncated-width": "^3.0.2" } @@ -1620,7 +2130,6 @@ "version": "0.2.0", "resolved": "https://registry.npmjs.org/fast-wrap-ansi/-/fast-wrap-ansi-0.2.0.tgz", "integrity": "sha512-rLV8JHxTyhVmFYhBJuMujcrHqOT2cnO5Zxj37qROj23CP39GXubJRBUFF0z8KFK77Uc0SukZUf7JZhsVEQ6n8w==", - "license": "MIT", "dependencies": { "fast-string-width": "^3.0.2" } @@ -1769,10 +2278,9 @@ } }, "node_modules/hono": { - "version": "4.12.17", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.17.tgz", - "integrity": "sha512-FbJJNb/XgX7YW0hX/V8w5oYLztKEsRLykCMZWt1WdLtsfjzMvmoqWBA4H4t5norinq8/rh20oiZYr+WSl4UzAQ==", - "license": "MIT", + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.2.tgz", + "integrity": "sha512-gJnaDHXKDayjt8ue0n8Gs0A007yKXj4Xzb8+cNjZeYsSzzwKc0Lr+OZgYwVfB0pHfUs17EPoLvrOsEaJ9mj+Tg==", "engines": { "node": ">=16.9.0" } @@ -1817,10 +2325,9 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" }, "node_modules/ip-address": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", - "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", - "license": "MIT", + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz", + "integrity": "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==", "engines": { "node": ">= 12" } @@ -1929,7 +2436,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-3.0.0.tgz", "integrity": "sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==", - "license": "ISC", "engines": { "node": "^20.17.0 || >=22.9.0" } @@ -2031,10 +2537,9 @@ } }, "node_modules/path-to-regexp": { - "version": "8.4.2", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", - "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", - "license": "MIT", + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", + "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==", "funding": { "type": "opencollective", "url": "https://opencollective.com/express" @@ -2053,11 +2558,10 @@ "dev": true }, "node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, - "license": "MIT", "engines": { "node": ">=12" }, @@ -2074,9 +2578,9 @@ } }, "node_modules/postcss": { - "version": "8.5.14", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", - "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", "dev": true, "funding": [ { @@ -2092,7 +2596,6 @@ "url": "https://github.com/sponsors/ai" } ], - "license": "MIT", "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -2383,7 +2886,6 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "license": "ISC", "engines": { "node": ">=14" }, @@ -2395,7 +2897,6 @@ "version": "1.6.1", "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.6.1.tgz", "integrity": "sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg==", - "license": "BSD-3-Clause", "engines": { "node": ">= 18" }, @@ -2550,7 +3051,6 @@ "https://github.com/sponsors/broofa", "https://github.com/sponsors/ctavan" ], - "license": "MIT", "bin": { "uuid": "dist-node/bin/uuid" } @@ -2564,11 +3064,10 @@ } }, "node_modules/vite": { - "version": "7.3.2", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz", - "integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==", + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", + "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, - "license": "MIT", "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", diff --git a/package.json b/package.json index ccddcbdf..93e15fe9 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,8 @@ "test:watch": "vitest", "smoke": "tsc && node dist/smoke-test.js", "integration": "tsc && npx tsx tests/integration.test.ts", - "prepare": "node scripts/install-hooks.mjs" + "prepare": "node scripts/install-hooks.mjs", + "postinstall": "test -d blindfold/dist || (cd blindfold && npm install --silent && npm run build)" }, "keywords": [ "ai-agent", @@ -45,6 +46,7 @@ "license": "Apache-2.0", "dependencies": { "@inquirer/password": "^5.0.11", + "blindfold": "file:./blindfold", "@modelcontextprotocol/sdk": "^1.27.0", "smol-toml": "^1.6.1", "ssh2": "^1.17.0", diff --git a/src/cli/auth.ts b/src/cli/auth.ts index 04b45ead..cea6bcab 100644 --- a/src/cli/auth.ts +++ b/src/cli/auth.ts @@ -1,7 +1,13 @@ import fs from 'node:fs'; +import net from 'node:net'; import path from 'node:path'; import os from 'node:os'; +import readline from 'node:readline'; import { execSync } from 'node:child_process'; +import { getSocketPath } from 'blindfold'; + +const NAME_REGEX = /^[a-zA-Z0-9_-]{1,64}$/; +const CONTROL_CHARS = /[\x00-\x1f\x7f]/g; /** Provider -> auth env var name */ const PROVIDER_AUTH_ENV: Record = { @@ -12,6 +18,9 @@ const PROVIDER_AUTH_ENV: Record = { }; export async function runAuth(args: string[]): Promise { + if (args.includes('--confirm')) { + return handleConfirm(args); + } if (args.includes('--oauth')) { return handleOAuth(args); } @@ -20,11 +29,114 @@ export async function runAuth(args: string[]): Promise { } console.error('Usage:'); + console.error(' apra-fleet auth --confirm '); console.error(' apra-fleet auth --oauth [--llm ] [ | secure. | --secure ]'); console.error(' apra-fleet auth --api-key [--llm ] [ | secure. | --secure ]'); process.exit(1); } +// --------------------------------------------------------------------------- +// --confirm: OOB network egress confirmation +// --------------------------------------------------------------------------- + +async function handleConfirm(args: string[]): Promise { + const credentialName = args.find((a) => !a.startsWith('-')); + + if (!credentialName) { + console.error('Usage: apra-fleet auth --confirm '); + process.exit(1); + } + + if (!NAME_REGEX.test(credentialName)) { + console.error('Usage: apra-fleet auth --confirm '); + console.error(' Name must match [a-zA-Z0-9_-]{1,64}'); + process.exit(1); + } + + const contextIdx = args.indexOf('--context'); + const rawCommand = contextIdx !== -1 && contextIdx + 1 < args.length ? args[contextIdx + 1] : undefined; + const commandContext = rawCommand ? rawCommand.replace(CONTROL_CHARS, '') : undefined; + + const onIdx = args.indexOf('--on'); + const rawMember = onIdx !== -1 && onIdx + 1 < args.length ? args[onIdx + 1] : undefined; + const memberContext = rawMember ? rawMember.replace(CONTROL_CHARS, '') : undefined; + + console.error(`\napra-fleet - Network Egress Confirmation\n`); + if (commandContext && memberContext) { + console.error(` This command on ${memberContext} will send credential "${credentialName}" over the network:`); + console.error(` ${commandContext}`); + } else { + console.error(` Credential "${credentialName}" will be sent over the network.`); + if (memberContext) console.error(` Member: ${memberContext}`); + if (commandContext) console.error(` Command: ${commandContext}`); + } + console.error(''); + + let inputValue: string; + try { + inputValue = await new Promise((resolve, reject) => { + const rl = readline.createInterface({ input: process.stdin, output: process.stderr }); + rl.question(' Type "yes" to allow network access: ', (answer) => { + rl.close(); + resolve(answer); + }); + rl.on('close', () => resolve('')); + rl.on('error', reject); + }); + } catch { + console.error('Cancelled.'); + process.exit(1); + return; + } + + if (inputValue.toLowerCase() !== 'yes') { + console.error(' x Confirmation not received. Aborting.'); + process.exit(1); + return; + } + + const sockPath = getSocketPath(); + + await new Promise((resolve, reject) => { + const client = net.connect(sockPath, () => { + const msg = JSON.stringify({ type: 'auth', member_name: credentialName, password: inputValue }) + '\n'; + inputValue = ''; + client.write(msg); + }); + + let buffer = ''; + client.on('data', (chunk) => { + buffer += chunk.toString(); + const nl = buffer.indexOf('\n'); + if (nl === -1) return; + + const line = buffer.slice(0, nl); + try { + const resp = JSON.parse(line); + if (resp.ok) { + console.error('\n + Confirmed. You can close this window.\n'); + resolve(); + } else { + console.error(`\n x Error: ${resp.error}\n`); + reject(new Error(resp.error)); + } + } catch { + console.error('\n x Invalid response from server.\n'); + reject(new Error('Invalid server response')); + } + client.end(); + }); + + client.on('error', (err) => { + console.error(`\n x Could not connect to apra-fleet server.`); + console.error(` Is the MCP server running?\n`); + reject(err); + }); + }).catch(() => { + process.exit(1); + }); +} + // --------------------------------------------------------------------------- // Shared helpers // --------------------------------------------------------------------------- @@ -77,7 +189,7 @@ async function parseTokenArgs( let token: string; if (storeRef) { - const { credentialResolve } = await import('../services/credential-store.js'); + const { credentialResolve } = await import('blindfold'); const entry = credentialResolve(storeRef, '*'); if (!entry) { console.error(`✗ Credential "${storeRef}" not found in persistent store.`); diff --git a/src/cli/secret.ts b/src/cli/secret.ts index 6b1115bc..62db0579 100644 --- a/src/cli/secret.ts +++ b/src/cli/secret.ts @@ -1,8 +1,7 @@ import net from 'node:net'; import readline from 'node:readline'; -import { getSocketPath } from '../services/auth-socket.js'; -import { collectSecret } from '../utils/collect-secret.js'; -import { credentialSet, credentialList, credentialDelete, credentialUpdate, type CredentialUpdatePatch } from '../services/credential-store.js'; +import { getSocketPath, collectSecret, credentialSet, credentialList, credentialDelete, credentialUpdate } from 'blindfold'; +import type { CredentialUpdatePatch } from 'blindfold'; const NAME_REGEX = /^[a-zA-Z0-9_-]{1,64}$/; @@ -14,7 +13,6 @@ export async function runSecret(args: string[]): Promise { console.error(' apra-fleet secret --update [--members ] [--ttl ] [--allow|--deny]'); console.error(' apra-fleet secret --delete '); console.error(' apra-fleet secret --delete --all'); - console.error(' apra-fleet secret --confirm '); process.exit(args.length === 0 ? 1 : 0); } @@ -26,103 +24,12 @@ export async function runSecret(args: string[]): Promise { await handleUpdate(args.slice(1)); } else if (args[0] === '--delete') { await handleDelete(args.slice(1)); - } else if (args[0] === '--confirm') { - await handleConfirm(args.slice(1)); } else { console.error('Usage: apra-fleet secret --set [--persist]'); process.exit(1); } } -async function handleConfirm(args: string[]): Promise { - const credentialName = args.find((a) => !a.startsWith('-')); - - if (!credentialName) { - console.error('Usage: apra-fleet secret --confirm '); - process.exit(1); - } - - const contextIdx = args.indexOf('--context'); - const commandContext = contextIdx !== -1 && contextIdx + 1 < args.length ? args[contextIdx + 1] : undefined; - const onIdx = args.indexOf('--on'); - const memberContext = onIdx !== -1 && onIdx + 1 < args.length ? args[onIdx + 1] : undefined; - - console.error(`\napra-fleet - Network Egress Confirmation\n`); - if (commandContext && memberContext) { - console.error(` This command on ${memberContext} will send credential "${credentialName}" over the network:`); - console.error(` ${commandContext}`); - } else { - console.error(` Credential "${credentialName}" will be sent over the network.`); - if (memberContext) console.error(` Member: ${memberContext}`); - if (commandContext) console.error(` Command: ${commandContext}`); - } - console.error(''); - - let inputValue: string; - try { - inputValue = await new Promise((resolve, reject) => { - const rl = readline.createInterface({ input: process.stdin, output: process.stderr }); - rl.question(' Type "yes" to allow network access: ', (answer) => { - rl.close(); - resolve(answer); - }); - rl.on('close', () => resolve('')); - rl.on('error', reject); - }); - } catch { - console.error('Cancelled.'); - process.exit(1); - return; - } - - if (inputValue.toLowerCase() !== 'yes') { - console.error(' x Confirmation not received. Aborting.'); - process.exit(1); - return; - } - - const sockPath = getSocketPath(); - - await new Promise((resolve, reject) => { - const client = net.connect(sockPath, () => { - const msg = JSON.stringify({ type: 'auth', member_name: credentialName, password: inputValue }) + '\n'; - inputValue = ''; - client.write(msg); - }); - - let buffer = ''; - client.on('data', (chunk) => { - buffer += chunk.toString(); - const nl = buffer.indexOf('\n'); - if (nl === -1) return; - - const line = buffer.slice(0, nl); - try { - const resp = JSON.parse(line); - if (resp.ok) { - console.error('\n + Confirmed. You can close this window.\n'); - resolve(); - } else { - console.error(`\n x Error: ${resp.error}\n`); - reject(new Error(resp.error)); - } - } catch { - console.error('\n x Invalid response from server.\n'); - reject(new Error('Invalid server response')); - } - client.end(); - }); - - client.on('error', (err) => { - console.error(`\n x Could not connect to apra-fleet server.`); - console.error(` Is the MCP server running?\n`); - reject(err); - }); - }).catch(() => { - process.exit(1); - }); -} - async function handleList(): Promise { const credentials = credentialList(); diff --git a/src/index.ts b/src/index.ts index 2b2a3cb5..4bf6d6db 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,6 +2,7 @@ import { serverVersion } from './version.js'; import { logLine, logError } from './utils/log-helpers.js'; +import { initFleetBlindfold } from './services/blindfold-init.js'; // --- CLI dispatch (before MCP server imports to keep --version fast) --- const arg = process.argv[2]; @@ -28,7 +29,7 @@ Usage: apra-fleet secret --set Deliver a secret to a waiting request apra-fleet secret --list List secrets apra-fleet secret --delete Delete a secret - apra-fleet secret --confirm Confirm network egress for that credential (interactive) + apra-fleet auth --confirm Confirm network egress for that credential (interactive) apra-fleet auth --oauth [--llm ] Write OAuth token to provider credential file apra-fleet auth --oauth [--llm ] secure. Resolve token from persistent credential store apra-fleet auth --api-key [--llm ] Set API key in shell profiles / system env @@ -38,6 +39,8 @@ Usage: process.exit(0); } +initFleetBlindfold(); + if (arg === 'install') { // Dynamic import so MCP deps aren't loaded for install import('./cli/install.js') @@ -136,7 +139,7 @@ async function startServer() { const { idleManager } = await import('./services/cloud/idle-manager.js'); const { cleanupStaleTasks } = await import('./services/task-cleanup.js'); const { checkForUpdate } = await import('./services/update-check.js'); - const { purgeExpiredCredentials } = await import('./services/credential-store.js'); + const { purgeExpiredCredentials } = await import('blindfold'); const { getStallDetector } = await import('./services/stall/index.js'); // serverVersion is "v0.0.1_abc123" — strip 'v' prefix for semver-like version field @@ -282,7 +285,7 @@ async function startServer() { purgeExpiredCredentials(); void checkForUpdate(); - const { cleanupAuthSocket } = await import('./services/auth-socket.js'); + const { cleanupAuthSocket } = await import('blindfold'); process.on('SIGINT', () => { cleanupAuthSocket().then(() => { closeAllConnections(); stallDetector.stop(); process.exit(0); }); }); process.on('SIGTERM', () => { cleanupAuthSocket().then(() => { closeAllConnections(); stallDetector.stop(); process.exit(0); }); }); } diff --git a/src/os/linux.ts b/src/os/linux.ts index 667e87f9..41b55899 100644 --- a/src/os/linux.ts +++ b/src/os/linux.ts @@ -1,7 +1,7 @@ import { execSync } from 'node:child_process'; import type { OsCommands, ProviderAdapter, PromptOptions } from './os-commands.js'; import { escapeDoubleQuoted, escapeGrepPattern, sanitizeSessionId } from './os-commands.js'; -import { escapeShellArg } from '../utils/shell-escape.js'; +import { escapeShellArg } from 'blindfold'; const CLI_PATH = 'export PATH="$HOME/.local/bin:$PATH" && '; diff --git a/src/os/os-commands.ts b/src/os/os-commands.ts index 6efd49f5..18afe82e 100644 --- a/src/os/os-commands.ts +++ b/src/os/os-commands.ts @@ -1,4 +1,4 @@ -import { escapeDoubleQuoted, escapeWindowsArg, escapeGrepPattern, sanitizeSessionId } from '../utils/shell-escape.js'; +import { escapeDoubleQuoted, escapeWindowsArg, escapeGrepPattern, sanitizeSessionId } from 'blindfold'; import type { ProviderAdapter, PromptOptions } from '../providers/provider.js'; export { escapeDoubleQuoted, escapeWindowsArg, escapeGrepPattern, sanitizeSessionId }; diff --git a/src/os/windows.ts b/src/os/windows.ts index 68ab8ee7..3401b5bf 100644 --- a/src/os/windows.ts +++ b/src/os/windows.ts @@ -3,7 +3,7 @@ export { defaultWindowsPidWrapper as pidWrapWindows }; import { execSync } from 'node:child_process'; import type { OsCommands, ProviderAdapter, PromptOptions } from './os-commands.js'; import { escapeWindowsArg, sanitizeSessionId } from './os-commands.js'; -import { escapeBatchMetachars } from '../utils/shell-escape.js'; +import { escapeBatchMetachars } from 'blindfold'; const CLI_PATH = '$env:Path = "$env:USERPROFILE\\.local\\bin;$env:Path"; '; diff --git a/src/services/auth-socket.ts b/src/services/auth-socket.ts deleted file mode 100644 index c86124b5..00000000 --- a/src/services/auth-socket.ts +++ /dev/null @@ -1,673 +0,0 @@ -import net from 'node:net'; -import fs from 'node:fs'; -import { promises as fsPromises } from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import { spawn, execSync, ChildProcess } from 'node:child_process'; -import { FLEET_DIR } from '../paths.js'; -import { encryptPassword } from '../utils/crypto.js'; -import { logError } from '../utils/log-helpers.js'; -import { OOB_TIMEOUT_MS } from '../utils/oob-timeout.js'; - -const SOCKET_PATH = path.join(FLEET_DIR, 'auth.sock'); -const PENDING_TTL_MS = 10 * 60 * 1000; // 10 minutes -const MAX_BUFFER_SIZE = 64 * 1024; // 64KB — reject oversized messages - -interface PendingAuth { - encryptedPassword?: string; - createdAt: number; - spawned_pid?: number; - persist?: boolean; -} - -interface PasswordWaiter { - resolve: (encryptedPassword: string) => void; - reject: (error: Error) => void; - timer: ReturnType; -} - -const pendingRequests = new Map(); -const passwordWaiters = new Map(); -const activeSockets = new Set(); -let socketServer: net.Server | null = null; -let closingPromise: Promise | null = null; -let testPipeGeneration = 0; - -export function getSocketPath(): string { - if (process.platform === 'win32') { - // Note: this path is automatically scoped to the user session by Windows. - const username = process.env.USERNAME ?? 'user'; - const suffix = process.env.NODE_ENV === 'test' ? `-${testPipeGeneration}` : ''; - return `\\\\.\\pipe\\apra-fleet-auth-${username}${suffix}`; - } - return SOCKET_PATH; -} - -function killProcess(pid: number): void { - if (!pid) return; - try { - if (process.platform === 'win32') { - execSync(`taskkill /F /PID ${pid}`, { stdio: 'ignore' }); - } else { - process.kill(pid, 'SIGTERM'); - } - } catch { - // Process may have already exited - } -} - -export async function ensureAuthSocket(): Promise { - // If already closing, wait for it to finish before trying to start again - if (closingPromise) { - await closingPromise; - } - - if (socketServer) return; - - const sockPath = getSocketPath(); - - // Ensure parent directory exists - const sockDir = path.dirname(sockPath); - if (!fs.existsSync(sockDir)) { - fs.mkdirSync(sockDir, { recursive: true, mode: 0o700 }); - } - - // Unlink stale socket (Unix only — named pipes don't leave stale files) - if (process.platform !== 'win32') { - try { fs.unlinkSync(sockPath); } catch { /* not present */ } - } - - const tryListen = (retriesLeft: number): Promise => new Promise((resolve, reject) => { - const server = net.createServer((conn) => { - activeSockets.add(conn); - conn.on('close', () => activeSockets.delete(conn)); - let buffer = ''; - conn.on('data', (chunk) => { - buffer += chunk.toString(); - if (buffer.length > MAX_BUFFER_SIZE) { - conn.write(JSON.stringify({ type: 'ack', ok: false, error: 'Message too large' }) + '\n'); - conn.end(); - return; - } - const newlineIdx = buffer.indexOf('\n'); - if (newlineIdx === -1) return; - - const line = buffer.slice(0, newlineIdx); - buffer = buffer.slice(newlineIdx + 1); - - try { - const msg = JSON.parse(line); - if (msg.type === 'auth' && msg.member_name && msg.password) { - const pending = pendingRequests.get(msg.member_name); - if (!pending) { - conn.write(JSON.stringify({ type: 'ack', ok: false, error: `No pending auth for ${msg.member_name}` }) + '\n'); - return; - } - // Encrypt immediately, discard plaintext - pending.encryptedPassword = encryptPassword(msg.password); - if (msg.persist !== undefined) pending.persist = !!msg.persist; - // Best-effort: JS strings are immutable; original may persist in V8 heap until GC - (msg as any).password = ''; - conn.write(JSON.stringify({ type: 'ack', ok: true }) + '\n'); - // Kill the spawned terminal process if one was launched - if (pending.spawned_pid) { - killProcess(pending.spawned_pid); - pending.spawned_pid = undefined; - } - // Resolve any waiting tool handler - const waiter = passwordWaiters.get(msg.member_name); - if (waiter) { - clearTimeout(waiter.timer); - passwordWaiters.delete(msg.member_name); - waiter.resolve(pending.encryptedPassword); - } - } else { - conn.write(JSON.stringify({ type: 'ack', ok: false, error: 'Invalid message' }) + '\n'); - } - } catch { - conn.write(JSON.stringify({ type: 'ack', ok: false, error: 'Invalid JSON' }) + '\n'); - } - }); - }); - - server.on('error', (err: NodeJS.ErrnoException) => { - server.close(); - // On Windows, named pipes may not be released immediately after close. - // Retry a few times with increasing delays before giving up. - if (err.code === 'EADDRINUSE' && process.platform === 'win32' && retriesLeft > 0) { - // Increase delay for later retries — earlier retries happen faster - const totalRetries = process.env.NODE_ENV === 'test' ? 15 : 5; - const delayBase = process.env.NODE_ENV === 'test' ? 100 : 250; - const delay = delayBase * (totalRetries - retriesLeft + 1); - setTimeout(() => tryListen(retriesLeft - 1).then(resolve, reject), delay); - } else { - reject(err); - } - }); - server.listen(sockPath, () => { - // Set socket file permissions (Unix only) - if (process.platform !== 'win32') { - try { fs.chmodSync(sockPath, 0o600); } catch { /* best effort */ } - } - socketServer = server; - resolve(); - }); - }); - - // Increase retries on Windows where named pipes take longer to release - // In tests, we retry more aggressively; in production the default is sufficient - const maxRetries = process.platform === 'win32' ? (process.env.NODE_ENV === 'test' ? 10 : 5) : 0; - return tryListen(maxRetries); -} - -export function createPendingAuth(memberName: string): void { - // Clean expired entries - const now = Date.now(); - for (const [name, entry] of pendingRequests) { - if (now - entry.createdAt > PENDING_TTL_MS) { - pendingRequests.delete(name); - } - } - pendingRequests.set(memberName, { createdAt: now }); -} - -export function getPendingPassword(memberName: string): string | null { - const entry = pendingRequests.get(memberName); - if (!entry) return null; - if (Date.now() - entry.createdAt > PENDING_TTL_MS) { - pendingRequests.delete(memberName); - return null; - } - if (!entry.encryptedPassword) return null; - // Consume the entry - const pw = entry.encryptedPassword; - pendingRequests.delete(memberName); - return pw; -} - -/** - * Wait for a pending auth password to arrive over the socket. - * Returns the encrypted password, or rejects on timeout. - */ -export function waitForPassword(memberName: string, timeoutMs: number = OOB_TIMEOUT_MS): Promise { - // Race: password may have arrived before we started waiting - const existing = getPendingPassword(memberName); - if (existing) return Promise.resolve(existing); - - return new Promise((resolve, reject) => { - const timer = setTimeout(() => { - passwordWaiters.delete(memberName); - const pending = pendingRequests.get(memberName); - if (pending?.spawned_pid) killProcess(pending.spawned_pid); - pendingRequests.delete(memberName); - reject(new Error(`Password entry timed out for ${memberName}`)); - }, timeoutMs); - - passwordWaiters.set(memberName, { resolve, reject, timer }); - }); -} - -export function cancelPendingAuth(memberName: string): void { - const pending = pendingRequests.get(memberName); - if (pending?.spawned_pid) killProcess(pending.spawned_pid); - const waiter = passwordWaiters.get(memberName); - if (waiter) { clearTimeout(waiter.timer); waiter.reject(new Error('cancelled')); } - passwordWaiters.delete(memberName); - pendingRequests.delete(memberName); -} - -export function hasPendingAuth(memberName: string): boolean { - const entry = pendingRequests.get(memberName); - if (!entry) return false; - if (Date.now() - entry.createdAt > PENDING_TTL_MS) { - pendingRequests.delete(memberName); - return false; - } - return true; -} - -export function cleanupAuthSocket(): Promise { - // If already closing, wait for it to finish. - // This ensures that we don't start a new server while the old one is still releasing the pipe. - if (closingPromise) { - return closingPromise; - } - - // Reject any pending waiters immediately - for (const [, waiter] of passwordWaiters) { - clearTimeout(waiter.timer); - waiter.reject(new Error('Auth socket closed')); - } - passwordWaiters.clear(); - pendingRequests.clear(); - - // Destroy all active client connections immediately - for (const s of activeSockets) { - s.destroy(); - } - activeSockets.clear(); - - if (!socketServer) { - if (process.platform !== 'win32') { - try { fs.unlinkSync(getSocketPath()); } catch { /* ignore */ } - } - return Promise.resolve(); - } - - const server = socketServer; - socketServer = null; // Clear immediately so ensureAuthSocket knows we are closing - - closingPromise = new Promise((resolve) => { - server.close(() => { - const onComplete = () => { - if (process.platform !== 'win32') { - try { fs.unlinkSync(getSocketPath()); } catch { /* ignore */ } - } - if (process.platform === 'win32' && process.env.NODE_ENV === 'test') { - testPipeGeneration++; - } - closingPromise = null; - resolve(); - }; - - if (process.platform === 'win32' && process.env.NODE_ENV !== 'test') { - // Windows named pipes need extra time to be fully released by the OS. - // In test mode we use unique pipe names per generation, so no delay needed. - setTimeout(onComplete, 500); - } else { - onComplete(); - } - }); - }); - - return closingPromise; -} - -type OobLaunchFn = ( - name: string, - extraArgs: string[] | undefined, - onExit: (code: number | null) => void, -) => string; - - -/** - * Core logic for out-of-band credential collection. - * Launches a terminal, then races a password waiter against a cancellation signal. - */ -async function collectOobInput( - mode: 'password' | 'api-key' | 'confirm', - memberName: string, - toolName: string, - _opts?: { waitTimeoutMs?: number; launchFn?: OobLaunchFn; prompt?: string; additionalArgs?: string[] }, -): Promise<{ password?: string; fallback?: string; persist?: boolean }> { - const launch = _opts?.launchFn ?? launchAuthTerminal; - const waitTimeoutMs = _opts?.waitTimeoutMs; - - const modeArgs = mode === 'api-key' ? ['--api-key'] : mode === 'confirm' ? ['--confirm'] : []; - const promptArgs = _opts?.prompt ? ['--prompt', _opts.prompt] : []; - const extraArgs = [...modeArgs, ...promptArgs, ...(_opts?.additionalArgs ?? [])]; - const inputType = mode === 'api-key' ? 'API key' : mode === 'confirm' ? 'confirmation' : 'Password'; - - const timeoutMessage = `❌ Password entry timed out for ${memberName}. Call ${toolName} again to retry.`; - const cancelledMessage = `❌ Password entry cancelled. Call ${toolName} again to retry.`; - - // Re-entrant case - if (hasPendingAuth(memberName)) { - const encPw = getPendingPassword(memberName); - if (encPw) return { password: encPw }; - try { - // Another process already launched the terminal, just wait for the result. - return { password: await waitForPassword(memberName, waitTimeoutMs ?? OOB_TIMEOUT_MS) }; - } catch { - return { fallback: timeoutMessage }; - } - } - - await ensureAuthSocket(); - createPendingAuth(memberName); - - try { - const passwordPromise = waitForPassword(memberName, waitTimeoutMs); - - const cancellationPromise = new Promise<{ fallback: string } | null>((resolve, reject) => { - const result = launch(memberName, extraArgs, (exitCode) => { - if (exitCode !== 0) { - reject(new Error('cancelled')); - } - // If exit is 0, passwordPromise will win the race. - // We can resolve this with null to signal completion without a fallback. - resolve(null); - }); - - if (result.startsWith('fallback:')) { - const manualMsg = result.slice('fallback:'.length); - resolve({ fallback: `🔐 ${manualMsg}\n\nOnce the user has entered the ${inputType}, call ${toolName} again with the same parameters.` }); - } - }); - - const raceResult = await Promise.race([passwordPromise, cancellationPromise]); - - if (raceResult === null) { - // The terminal exited with code 0 (Windows `start /wait` always exits 0, even - // on user-close). Wait briefly for any in-flight socket message — if the user - // genuinely submitted, the password arrives within milliseconds of process exit. - // If nothing arrives in 500 ms, treat it as a user cancellation. - try { - const pw = await Promise.race([ - passwordPromise, - new Promise((_, reject) => setTimeout(() => reject(new Error('cancelled')), 500)), - ]); - const persist = pendingRequests.get(memberName)?.persist; - pendingRequests.delete(memberName); - return { password: pw, persist }; - } catch { - const waiter = passwordWaiters.get(memberName); - if (waiter) { clearTimeout(waiter.timer); passwordWaiters.delete(memberName); } - pendingRequests.delete(memberName); - return { fallback: cancelledMessage }; - } - } - - // Handle the fallback case from the cancellation promise - if (typeof raceResult === 'object' && raceResult?.fallback) { - // Clean up stale state so a retry can launch a fresh terminal. - // Without this, hasPendingAuth() returns true on the next call, - // the re-entrant path skips launchAuthTerminal, and the call hangs. - const waiter = passwordWaiters.get(memberName); - if (waiter) { - clearTimeout(waiter.timer); - passwordWaiters.delete(memberName); - } - pendingRequests.delete(memberName); - return raceResult; - } - - const persist = pendingRequests.get(memberName)?.persist; - pendingRequests.delete(memberName); - return { password: raceResult as string, persist }; - } catch (err: any) { - // Clean up the pending request if the user cancelled. - const waiter = passwordWaiters.get(memberName); - if (waiter) { - clearTimeout(waiter.timer); - passwordWaiters.delete(memberName); - } - pendingRequests.delete(memberName); - - if (err.message === 'cancelled') { - return { fallback: cancelledMessage }; - } - // It must be a timeout from waitForPassword - return { fallback: timeoutMessage }; - } -} - - -/** - * Collect a password out-of-band. - * @see collectOobInput - */ -export async function collectOobPassword( - memberName: string, - toolName: string, - _opts?: { waitTimeoutMs?: number; launchFn?: OobLaunchFn; prompt?: string }, -): Promise<{ password?: string; fallback?: string; persist?: boolean }> { - return collectOobInput('password', memberName, toolName, _opts); -} - -/** - * Collect an API key out-of-band. - * @see collectOobInput - */ -export async function collectOobApiKey( - memberName: string, - toolName: string, - _opts?: { waitTimeoutMs?: number; launchFn?: OobLaunchFn; prompt?: string; askPersist?: boolean }, -): Promise<{ password?: string; fallback?: string; persist?: boolean }> { - const additionalArgs = _opts?.askPersist ? ['--ask-persist'] : []; - return collectOobInput('api-key', memberName, toolName, { ...(_opts ?? {}), additionalArgs }); -} - - -/** - * Prompt the user out-of-band to confirm a network-egress operation. - * Returns true if the user confirmed, false if they cancelled or timed out. - */ -export async function collectOobConfirm( - credentialName: string, - _opts?: { waitTimeoutMs?: number; launchFn?: OobLaunchFn; command?: string; memberName?: string }, -): Promise<{ confirmed: boolean; terminalUnavailable: boolean }> { - const additionalArgs: string[] = []; - if (_opts?.command) { - additionalArgs.push('--context', _opts.command.slice(0, 200)); - } - if (_opts?.memberName) { - additionalArgs.push('--on', _opts.memberName); - } - const result = await collectOobInput('confirm', credentialName, 'execute_command', { - ..._opts, - additionalArgs: additionalArgs.length > 0 ? additionalArgs : undefined, - }); - if (result.fallback) return { confirmed: false, terminalUnavailable: true }; - return { confirmed: Boolean(result.password), terminalUnavailable: false }; -} - -/** - * Resolve the command to invoke this binary's `secret` subcommand. - * Confirm mode uses `secret --confirm`; all credential collection uses `secret --set`. - * Returns [command, ...args] suitable for spawn(). - */ -function getAuthCommand(memberName: string, extraArgs?: string[]): { cmd: string; args: string[] } { - const extra = extraArgs ?? []; - const isConfirm = extra.includes('--confirm'); - - let cmdArgs: string[]; - if (isConfirm) { - cmdArgs = ['secret', '--confirm', memberName]; - } else { - // All credential collection (password, API key) routes through `secret --set` - cmdArgs = ['secret', '--set', memberName]; - const promptIdx = extra.indexOf('--prompt'); - if (promptIdx !== -1 && promptIdx + 1 < extra.length) { - cmdArgs.push('--prompt', extra[promptIdx + 1]); - } - if (extra.includes('--ask-persist')) { - cmdArgs.push('--ask-persist'); - } - } - - // SEA binary: process.execPath is the binary itself - try { - const sea = require('node:sea'); - if (sea.isSea()) { - return { cmd: process.execPath, args: cmdArgs }; - } - } catch { /* not SEA */ } - - // Dev mode: node - const indexJs = path.resolve(path.dirname(new URL(import.meta.url).pathname), '..', 'index.js'); - return { cmd: process.argv[0], args: [indexJs, ...cmdArgs] }; -} - -function buildHeadlessFallback(memberName: string, reason: string, context?: { command?: string; onMember?: string }, extraArgs?: string[]): string { - const isConfirm = extraArgs?.includes('--confirm') ?? false; - let contextLines = ''; - if (context?.onMember && context?.command) { - contextLines = `\n\n This command on ${context.onMember} will send credential "${memberName}" over the network:\n ${context.command}`; - } else if (context?.command) { - contextLines = `\n\n Command: ${context.command}`; - } - if (isConfirm) { - return `fallback:${reason}${contextLines}\n\nRun this in a separate terminal to confirm:\n ! apra-fleet secret --confirm ${memberName}\n\nAlternatively, pre-store the value with credential_store_set and reference it as {{secure.NAME}} in the credential field.`; - } - return `fallback:${reason}${contextLines}\n\nRun this in a separate terminal to provide the credential:\n ! apra-fleet secret --set ${memberName}\n\nAlternatively, pre-store the value with credential_store_set and reference it as {{secure.NAME}} in the credential field.`; -} - -/** - * Returns true when a graphical display is available on Linux/BSD. - * Checks $DISPLAY (X11) and $WAYLAND_DISPLAY (Wayland). - */ -export function hasGraphicalDisplay(): boolean { - return Boolean(process.env.DISPLAY || process.env.WAYLAND_DISPLAY); -} - -/** - * Returns true when the process is running inside an SSH session. - * SSH_TTY is set by the SSH daemon on both Linux and macOS when stdin is a tty. - */ -export function isSSHSession(): boolean { - return !!process.env.SSH_TTY; -} - -/** - * Returns true when running on an interactive Windows desktop session. - * SSH and headless service sessions have SESSIONNAME !== 'Console'. - */ -export function hasInteractiveDesktop(): boolean { - return process.env.SESSIONNAME === 'Console'; -} - -/** - * Detect available terminal emulator on Linux. - */ -function findLinuxTerminal(): string | null { - for (const term of ['gnome-terminal', 'xterm', 'x-terminal-emulator']) { - try { - execSync(`which ${term}`, { stdio: 'ignore' }); - return term; - } catch { /* not found */ } - } - return null; -} - -/** - * Launch a new terminal window running `apra-fleet secret --set ` or `apra-fleet auth `. - * Records the spawned PID in the pending request so it can be killed when credential is received. - * Returns a user-facing message describing what happened and executes a - * callback when the spawned terminal process exits. - */ -export function launchAuthTerminal( - memberName: string, - extraArgs: string[] | undefined, - onExit: (code: number | null) => void, -): string { - const { cmd, args } = getAuthCommand(memberName, extraArgs); - const fullArgs = [cmd, ...args]; - let child: ChildProcess; - - // Extract context args for headless fallback messages - const ctxIdx = extraArgs?.indexOf('--context') ?? -1; - const onIdx = extraArgs?.indexOf('--on') ?? -1; - const fallbackContext = { - command: ctxIdx !== -1 && extraArgs && ctxIdx + 1 < extraArgs.length ? extraArgs[ctxIdx + 1] : undefined, - onMember: onIdx !== -1 && extraArgs && onIdx + 1 < extraArgs.length ? extraArgs[onIdx + 1] : undefined, - }; - - try { - const platform = process.platform; - - if (platform === 'win32' && !hasInteractiveDesktop()) { - return buildHeadlessFallback(memberName, 'No interactive desktop session detected (SSH or service context).', fallbackContext, extraArgs); - } - - if (platform === 'linux' && !hasGraphicalDisplay()) { - return buildHeadlessFallback(memberName, 'No graphical display detected (SSH or headless session).', fallbackContext, extraArgs); - } - - if (platform === 'darwin' && isSSHSession()) { - return buildHeadlessFallback(memberName, 'SSH session detected -- no terminal emulator available (SSH_TTY is set).', fallbackContext, extraArgs); - } - - if (platform === 'darwin') { - // macOS: Use a complex AppleScript to wait for the window to close and get an exit code. - // This is memory-hardened by writing the exit code to a temp file. - (async () => { - let exitCode = 1; // Default to cancellation - const tmpFile = path.join(os.tmpdir(), `fleet-auth-exit-${Date.now()}`); - try { - // The command to run in the terminal. It must be a single string. - // It writes its own exit code to a temp file so we can read it later. - const command = [...fullArgs, `; echo $? > "${tmpFile}"`].join(' '); - - // AppleScript to launch terminal, run command, and wait for it to be "not busy". - const appleScript = ` - tell application "Terminal" - activate - set w to do script "${command.replace(/"/g, '\\"')}" - delay 1 - repeat while busy of w - delay 0.5 - end repeat - end tell - `; - - const child = spawn('osascript', ['-']); - child.stdin.write(appleScript); - child.stdin.end(); - - child.on('close', async (code) => { - if (code !== 0) { - // osascript itself failed. - onExit(1); - return; - } - try { - const codeStr = await fsPromises.readFile(tmpFile, 'utf-8'); - exitCode = parseInt(codeStr.trim(), 10); - if (isNaN(exitCode)) exitCode = 1; - } catch { - exitCode = 1; // Assume cancellation if file not found (e.g., window closed manually) - } finally { - await fsPromises.unlink(tmpFile).catch(() => {}); - onExit(exitCode); - } - }); - child.on('error', (err) => { - logError('auth_socket', `Failed to launch osascript for auth: ${err.message}`); - onExit(1); - }); - } catch (e) { - onExit(1); // Default to cancellation on any unexpected error. - } - })(); - return 'launched'; - } else if (platform === 'win32') { - // Windows: start /wait ensures that the parent cmd.exe process waits for the new - // terminal window to be closed. This allows us to capture the exit event. - // The title argument to start is required. - const spawnArgs = ['/c', 'start', 'Fleet Password Entry', '/wait', ...fullArgs]; - child = spawn('cmd', spawnArgs, { stdio: 'ignore' }); - if (child.pid) { - const pending = pendingRequests.get(memberName); - if (pending) pending.spawned_pid = child.pid; - } - } else { - // Linux: find available terminal emulator. Most support an execute flag. - const terminal = findLinuxTerminal(); - if (!terminal) { - return `fallback:Could not find a terminal emulator. Ask the user to run manually:\n ${[cmd, ...args].join(' ')}\nAlternatively, pre-store the value with credential_store_set and reference it as {{secure.NAME}} in the credential field.`; - } - if (terminal === 'gnome-terminal') { - child = spawn(terminal, ['--', ...fullArgs], { detached: true, stdio: 'ignore' }); - } else { - // xterm, x-terminal-emulator etc. - child = spawn(terminal, ['-e', ...fullArgs], { detached: true, stdio: 'ignore' }); - } - if (child.pid) { - const pending = pendingRequests.get(memberName); - if (pending) pending.spawned_pid = child.pid; - } - } - - child.on('close', onExit); - child.on('error', (err) => { - logError('auth_socket', `Failed to launch terminal for ${memberName}: ${err.message}`); - onExit(1); // Treat spawn error as a non-zero exit. - }); - child.unref(); - - return 'launched'; - } catch (err: any) { - return `fallback:Could not open a terminal window. Ask the user to run manually:\n ${[cmd, ...args].join(' ')}\nError: ${err.message}\nAlternatively, pre-store the value with credential_store_set and reference it as {{secure.NAME}} in the credential field.`; - } -} \ No newline at end of file diff --git a/src/services/blindfold-init.ts b/src/services/blindfold-init.ts new file mode 100644 index 00000000..12c6b8d7 --- /dev/null +++ b/src/services/blindfold-init.ts @@ -0,0 +1,22 @@ +import { initBlindfold, type Logger } from 'blindfold'; +import path from 'node:path'; +import os from 'node:os'; + +const fleetLogger: Logger = { + info: (tag, msg) => { try { process.stderr.write(`[fleet] blindfold [${tag}] ${msg}\n`); } catch {} }, + warn: (tag, msg) => { try { process.stderr.write(`[fleet:warn] blindfold [${tag}] ${msg}\n`); } catch {} }, + error: (tag, msg) => { try { process.stderr.write(`[fleet:error] blindfold [${tag}] ${msg}\n`); } catch {} }, +}; + +let initialized = false; + +export function initFleetBlindfold(): void { + if (initialized) return; + initBlindfold({ + dataDir: process.env.APRA_FLEET_DATA_DIR ?? path.join(os.homedir(), '.apra-fleet', 'data'), + productName: 'apra-fleet', + pipeName: 'apra-fleet-auth', + logger: fleetLogger, + }); + initialized = true; +} diff --git a/src/services/cloud/aws.ts b/src/services/cloud/aws.ts index 6ad896ef..8637180f 100644 --- a/src/services/cloud/aws.ts +++ b/src/services/cloud/aws.ts @@ -1,7 +1,7 @@ import { exec, type ExecOptions } from 'node:child_process'; import { promisify } from 'node:util'; import type { CloudConfig, CloudProvider, CloudInstanceDetails, InstanceState } from './types.js'; -import { escapeShellArg } from '../../utils/shell-escape.js'; +import { escapeShellArg } from 'blindfold'; type ExecResult = { stdout: string; stderr: string }; type ExecFn = (cmd: string, opts?: ExecOptions) => Promise; diff --git a/src/services/credential-store.ts b/src/services/credential-store.ts deleted file mode 100644 index abd2d48e..00000000 --- a/src/services/credential-store.ts +++ /dev/null @@ -1,369 +0,0 @@ -import crypto from 'node:crypto'; -import fs from 'node:fs'; -import path from 'node:path'; -import { encryptPassword, decryptPassword } from '../utils/crypto.js'; -import { enforceOwnerOnly } from '../utils/file-permissions.js'; -import { FLEET_DIR } from '../paths.js'; - -// --------------------------------------------------------------------------- -// Session-tier encryption (AES-256-GCM, key lives only in this process) -// --------------------------------------------------------------------------- -const SESSION_KEY = crypto.randomBytes(32); -const ALGORITHM = 'aes-256-gcm'; -const IV_LENGTH = 16; - -function sessionEncrypt(plaintext: string): string { - const iv = crypto.randomBytes(IV_LENGTH); - const cipher = crypto.createCipheriv(ALGORITHM, SESSION_KEY, iv); - let encrypted = cipher.update(plaintext, 'utf8', 'hex'); - encrypted += cipher.final('hex'); - const authTag = cipher.getAuthTag(); - return `${iv.toString('hex')}:${authTag.toString('hex')}:${encrypted}`; -} - -function sessionDecrypt(ciphertext: string): string { - const [ivHex, authTagHex, encrypted] = ciphertext.split(':'); - const iv = Buffer.from(ivHex, 'hex'); - const authTag = Buffer.from(authTagHex, 'hex'); - const decipher = crypto.createDecipheriv(ALGORITHM, SESSION_KEY, iv); - decipher.setAuthTag(authTag); - let decrypted = decipher.update(encrypted, 'hex', 'utf8'); - decrypted += decipher.final('utf8'); - return decrypted; -} - -// --------------------------------------------------------------------------- -// Types -// --------------------------------------------------------------------------- -export interface CredentialMeta { - name: string; - scope: 'session' | 'persistent'; - network_policy: 'allow' | 'confirm' | 'deny'; - created_at: string; - allowedMembers: string[] | '*'; - expiresAt?: string; -} - -interface SessionEntry extends CredentialMeta { - scope: 'session'; - encryptedValue: string; -} - -interface PersistentRecord { - name: string; - network_policy: 'allow' | 'confirm' | 'deny'; - created_at: string; - encryptedValue: string; - allowedMembers: string[] | '*'; - expiresAt?: string; -} - -interface CredentialFile { - version: string; - credentials: Record; -} - -// --------------------------------------------------------------------------- -// Session store (in-memory) -// --------------------------------------------------------------------------- -const sessionStore = new Map(); - -// --------------------------------------------------------------------------- -// Persistent store (credentials.json) -// --------------------------------------------------------------------------- -function getCredentialsPath(): string { - const dataDir = process.env.APRA_FLEET_DATA_DIR ?? FLEET_DIR; - return path.join(dataDir, 'credentials.json'); -} - -function loadCredentialFile(): CredentialFile { - const credentialsPath = getCredentialsPath(); - const dataDir = path.dirname(credentialsPath); - if (!fs.existsSync(dataDir)) { - fs.mkdirSync(dataDir, { recursive: true, mode: 0o700 }); - } - if (!fs.existsSync(credentialsPath)) { - return { version: '1.0', credentials: {} }; - } - return JSON.parse(fs.readFileSync(credentialsPath, 'utf-8')) as CredentialFile; -} - -function saveCredentialFile(file: CredentialFile): void { - const credentialsPath = getCredentialsPath(); - const dataDir = path.dirname(credentialsPath); - if (!fs.existsSync(dataDir)) { - fs.mkdirSync(dataDir, { recursive: true, mode: 0o700 }); - } - fs.writeFileSync(credentialsPath, JSON.stringify(file, null, 2), { mode: 0o600 }); - enforceOwnerOnly(credentialsPath); -} - -// --------------------------------------------------------------------------- -// Public API -// --------------------------------------------------------------------------- - -export function credentialSet( - name: string, - plaintext: string, - persist: boolean, - network_policy: 'allow' | 'confirm' | 'deny', - allowedMembers: string[] | '*' = '*', - ttl_seconds?: number, -): CredentialMeta { - const created_at = new Date().toISOString(); - const expiresAt = ttl_seconds !== undefined - ? new Date(Date.now() + ttl_seconds * 1000).toISOString() - : undefined; - - if (persist) { - const file = loadCredentialFile(); - file.credentials[name] = { name, network_policy, created_at, encryptedValue: encryptPassword(plaintext), allowedMembers, expiresAt }; - saveCredentialFile(file); - // Persistent supersedes session - sessionStore.delete(name); - return { name, scope: 'persistent', network_policy, created_at, allowedMembers, expiresAt }; - } - - sessionStore.set(name, { - name, - scope: 'session', - network_policy, - created_at, - encryptedValue: sessionEncrypt(plaintext), - allowedMembers, - expiresAt, - }); - return { name, scope: 'session', network_policy, created_at, allowedMembers, expiresAt }; -} - -export function credentialList(): CredentialMeta[] { - const results: CredentialMeta[] = []; - - for (const entry of sessionStore.values()) { - results.push({ name: entry.name, scope: entry.scope, network_policy: entry.network_policy, created_at: entry.created_at, allowedMembers: entry.allowedMembers, expiresAt: entry.expiresAt }); - } - - const file = loadCredentialFile(); - for (const record of Object.values(file.credentials)) { - const existing = results.findIndex(r => r.name === record.name); - const meta: CredentialMeta = { - name: record.name, - scope: 'persistent', - network_policy: record.network_policy, - created_at: record.created_at, - allowedMembers: record.allowedMembers ?? '*', - expiresAt: record.expiresAt, - }; - if (existing !== -1) { - results[existing] = meta; - } else { - results.push(meta); - } - } - - return results; -} - -export function credentialDelete(name: string): boolean { - // Remove from both tiers unconditionally (M1) - let found = false; - if (sessionStore.has(name)) { - sessionStore.delete(name); - found = true; - } - const file = loadCredentialFile(); - if (name in file.credentials) { - delete file.credentials[name]; - saveCredentialFile(file); - found = true; - } - return found; -} - -// --------------------------------------------------------------------------- -// Task-scoped credential registry for long-running task output redaction (H2) -// --------------------------------------------------------------------------- -interface TaskCredential { name: string; plaintext: string; } -const taskCredentials = new Map(); - -export function registerTaskCredentials(taskId: string, credentials: { name: string; plaintext: string }[]): void { - if (credentials.length > 0) { - taskCredentials.set(taskId, credentials.map(c => ({ name: c.name, plaintext: c.plaintext }))); - } -} - -export function getTaskCredentials(taskId: string): TaskCredential[] { - return taskCredentials.get(taskId) ?? []; -} - -/** - * Resolve a credential name to its plaintext value. - * Persistent store takes precedence over session store. - * - * Returns: - * - { plaintext, meta } on success - * - { denied } if callingMember is not in allowedMembers - * - { expired } if the credential has passed its TTL (entry is also deleted) - * - null if the credential does not exist - */ -export function credentialResolve( - name: string, - callingMember?: string, -): { plaintext: string; meta: CredentialMeta } | { denied: string } | { expired: string } | null { - // Persistent wins - const file = loadCredentialFile(); - const persistent = file.credentials[name]; - if (persistent) { - const allowedMembers = persistent.allowedMembers ?? '*'; - - // TTL check - if (persistent.expiresAt && Date.now() > new Date(persistent.expiresAt).getTime()) { - delete file.credentials[name]; - saveCredentialFile(file); - sessionStore.delete(name); - return { expired: `Credential '${name}' has expired. Re-set with credential_store_set.` }; - } - - // Scoping check ('*' as callingMember is a fleet-operator bypass) - if (callingMember !== undefined && callingMember !== '*' && allowedMembers !== '*' && !allowedMembers.includes(callingMember)) { - return { denied: `Credential '${name}' is not accessible to member '${callingMember}'. Allowed: ${allowedMembers.join(', ')}` }; - } - - return { - plaintext: decryptPassword(persistent.encryptedValue), - meta: { - name: persistent.name, - scope: 'persistent', - network_policy: persistent.network_policy, - created_at: persistent.created_at, - allowedMembers, - expiresAt: persistent.expiresAt, - }, - }; - } - - const session = sessionStore.get(name); - if (session) { - const allowedMembers = session.allowedMembers; - - // TTL check - if (session.expiresAt && Date.now() > new Date(session.expiresAt).getTime()) { - sessionStore.delete(name); - return { expired: `Credential '${name}' has expired. Re-set with credential_store_set.` }; - } - - // Scoping check ('*' as callingMember is a fleet-operator bypass) - if (callingMember !== undefined && callingMember !== '*' && allowedMembers !== '*' && !allowedMembers.includes(callingMember)) { - return { denied: `Credential '${name}' is not accessible to member '${callingMember}'. Allowed: ${allowedMembers.join(', ')}` }; - } - - return { - plaintext: sessionDecrypt(session.encryptedValue), - meta: { - name: session.name, - scope: 'session', - network_policy: session.network_policy, - created_at: session.created_at, - allowedMembers: session.allowedMembers, - expiresAt: session.expiresAt, - }, - }; - } - - return null; -} - -export interface CredentialUpdatePatch { - members?: string; - expiresAt?: number | null; - network_policy?: 'allow' | 'confirm' | 'deny'; -} - -export interface CredentialUpdateResult { - members: string; - network_policy: 'allow' | 'confirm' | 'deny'; - expiresAt?: number; -} - -function membersToAllowed(members: string): string[] | '*' { - return members === '*' ? '*' : members.split(',').map(m => m.trim()).filter(Boolean); -} - -function allowedToMembers(allowed: string[] | '*'): string { - return allowed === '*' ? '*' : allowed.join(','); -} - -export function credentialUpdate(name: string, patch: CredentialUpdatePatch): CredentialUpdateResult | null { - const file = loadCredentialFile(); - const persistent = file.credentials[name]; - if (persistent) { - if (patch.members !== undefined) { - persistent.allowedMembers = membersToAllowed(patch.members); - } - if (patch.network_policy !== undefined) { - persistent.network_policy = patch.network_policy; - } - if (patch.expiresAt !== undefined) { - persistent.expiresAt = patch.expiresAt === null ? undefined : new Date(patch.expiresAt).toISOString(); - } - file.credentials[name] = persistent; - saveCredentialFile(file); - return { - members: allowedToMembers(persistent.allowedMembers), - network_policy: persistent.network_policy, - expiresAt: persistent.expiresAt ? new Date(persistent.expiresAt).getTime() : undefined, - }; - } - - const session = sessionStore.get(name); - if (session) { - if (patch.members !== undefined) { - session.allowedMembers = membersToAllowed(patch.members); - } - if (patch.network_policy !== undefined) { - session.network_policy = patch.network_policy; - } - if (patch.expiresAt !== undefined) { - session.expiresAt = patch.expiresAt === null ? undefined : new Date(patch.expiresAt).toISOString(); - } - sessionStore.set(name, session); - return { - members: allowedToMembers(session.allowedMembers), - network_policy: session.network_policy, - expiresAt: session.expiresAt ? new Date(session.expiresAt).getTime() : undefined, - }; - } - - return null; -} - -/** - * Purge expired credentials from the persistent store. - * Called at server startup to clean up stale entries. - */ -export function purgeExpiredCredentials(): void { - let file: CredentialFile; - try { - file = loadCredentialFile(); - } catch { - return; - } - - const now = Date.now(); - let changed = false; - for (const [name, record] of Object.entries(file.credentials)) { - if (record.expiresAt && now > new Date(record.expiresAt).getTime()) { - delete file.credentials[name]; - sessionStore.delete(name); - changed = true; - } - } - - if (changed) { - try { - saveCredentialFile(file); - } catch { - // best-effort - } - } -} diff --git a/src/services/git-config.ts b/src/services/git-config.ts index bbe8d928..e0667392 100644 --- a/src/services/git-config.ts +++ b/src/services/git-config.ts @@ -1,7 +1,7 @@ import fs from 'node:fs'; import path from 'node:path'; import type { FleetGitConfig, GitHubAppConfig } from '../types.js'; -import { enforceOwnerOnly } from '../utils/file-permissions.js'; +import { enforceOwnerOnly } from 'blindfold'; import { FLEET_DIR } from '../paths.js'; const GIT_CONFIG_PATH = path.join(FLEET_DIR, 'git-config.json'); diff --git a/src/services/known-hosts.ts b/src/services/known-hosts.ts index 9216adcb..82cc0d75 100644 --- a/src/services/known-hosts.ts +++ b/src/services/known-hosts.ts @@ -1,7 +1,7 @@ import crypto from 'node:crypto'; import fs from 'node:fs'; import path from 'node:path'; -import { enforceOwnerOnly } from '../utils/file-permissions.js'; +import { enforceOwnerOnly } from 'blindfold'; import { FLEET_DIR } from '../paths.js'; const KNOWN_HOSTS_PATH = path.join(FLEET_DIR, 'known_hosts'); diff --git a/src/services/onboarding.ts b/src/services/onboarding.ts index 20513f42..841659d6 100644 --- a/src/services/onboarding.ts +++ b/src/services/onboarding.ts @@ -2,7 +2,7 @@ import fs from 'node:fs'; import path from 'node:path'; import type { OnboardingState } from '../types.js'; import { FLEET_DIR } from '../paths.js'; -import { enforceOwnerOnly } from '../utils/file-permissions.js'; +import { enforceOwnerOnly } from 'blindfold'; import { BANNER, GETTING_STARTED_GUIDE, WELCOME_BACK, NUDGE_AFTER_FIRST_REGISTER, NUDGE_AFTER_FIRST_PROMPT, NUDGE_AFTER_MULTI_MEMBER } from '../onboarding/text.js'; import { getAllAgents } from './registry.js'; diff --git a/src/services/registry.ts b/src/services/registry.ts index 0647272f..34f53b28 100644 --- a/src/services/registry.ts +++ b/src/services/registry.ts @@ -2,8 +2,7 @@ import fs from 'node:fs'; import path from 'node:path'; import os from 'node:os'; import type { Agent, FleetRegistry } from '../types.js'; -import { encryptPassword } from '../utils/crypto.js'; -import { enforceOwnerOnly } from '../utils/file-permissions.js'; +import { encryptPassword, enforceOwnerOnly } from 'blindfold'; import { FLEET_DIR } from '../paths.js'; import { assignIcon } from './icons.js'; diff --git a/src/services/ssh.ts b/src/services/ssh.ts index 876e9967..4fef9849 100644 --- a/src/services/ssh.ts +++ b/src/services/ssh.ts @@ -4,7 +4,7 @@ import os from 'node:os'; import path from 'node:path'; import { v4 as uuid } from 'uuid'; import type { Agent, SSHExecResult } from '../types.js'; -import { decryptPassword } from '../utils/crypto.js'; +import { decryptPassword } from 'blindfold'; import { verifyHostKey, replaceKnownHost, HostKeyMismatchError } from './known-hosts.js'; import { setStoredPid, clearStoredPid } from '../utils/agent-helpers.js'; diff --git a/src/services/strategy.ts b/src/services/strategy.ts index 68aa83ee..8a0d3411 100644 --- a/src/services/strategy.ts +++ b/src/services/strategy.ts @@ -6,7 +6,7 @@ import { v4 as uuid } from 'uuid'; import type { Agent, SSHExecResult, TransferResult } from '../types.js'; import { getOsCommands } from '../os/index.js'; import { getAgentOS, setStoredPid, clearStoredPid } from '../utils/agent-helpers.js'; -import { escapeDoubleQuoted, escapeWindowsArg } from '../utils/shell-escape.js'; +import { escapeDoubleQuoted, escapeWindowsArg } from 'blindfold'; const MAX_OUTPUT_BYTES = 10 * 1024 * 1024; // 10 MB diff --git a/src/smoke-test.ts b/src/smoke-test.ts index 09b389c2..f894f338 100644 --- a/src/smoke-test.ts +++ b/src/smoke-test.ts @@ -10,10 +10,13 @@ import fs from 'node:fs'; import path from 'node:path'; import os from 'node:os'; -import { encryptPassword, decryptPassword } from './utils/crypto.js'; +import { encryptPassword, decryptPassword } from 'blindfold'; import { addAgent, getAgent, getAllAgents, removeAgent } from './services/registry.js'; import type { Agent } from './types.js'; import { FLEET_DIR } from './paths.js'; +import { initFleetBlindfold } from './services/blindfold-init.js'; + +initFleetBlindfold(); const REGISTRY_PATH = path.join(FLEET_DIR, 'registry.json'); diff --git a/src/tools/credential-store-delete.ts b/src/tools/credential-store-delete.ts index 3dbeeaac..875af259 100644 --- a/src/tools/credential-store-delete.ts +++ b/src/tools/credential-store-delete.ts @@ -1,5 +1,5 @@ import { z } from 'zod'; -import { credentialDelete } from '../services/credential-store.js'; +import { credentialDelete } from 'blindfold'; import { logLine } from '../utils/log-helpers.js'; export const credentialStoreDeleteSchema = z.object({ diff --git a/src/tools/credential-store-list.ts b/src/tools/credential-store-list.ts index ab36bf9b..c3a6510a 100644 --- a/src/tools/credential-store-list.ts +++ b/src/tools/credential-store-list.ts @@ -1,5 +1,5 @@ import { z } from 'zod'; -import { credentialList } from '../services/credential-store.js'; +import { credentialList } from 'blindfold'; export const credentialStoreListSchema = z.object({}); diff --git a/src/tools/credential-store-set.ts b/src/tools/credential-store-set.ts index 10eb5abe..3c4f459b 100644 --- a/src/tools/credential-store-set.ts +++ b/src/tools/credential-store-set.ts @@ -1,7 +1,5 @@ import { z } from 'zod'; -import { collectOobApiKey } from '../services/auth-socket.js'; -import { decryptPassword } from '../utils/crypto.js'; -import { credentialSet } from '../services/credential-store.js'; +import { collectOobApiKey, decryptPassword, credentialSet } from 'blindfold'; import { logLine } from '../utils/log-helpers.js'; export const credentialStoreSetSchema = z.object({ diff --git a/src/tools/credential-store-update.ts b/src/tools/credential-store-update.ts index ecf50928..a4e7b5fa 100644 --- a/src/tools/credential-store-update.ts +++ b/src/tools/credential-store-update.ts @@ -1,5 +1,5 @@ import { z } from 'zod'; -import { credentialResolve, credentialUpdate } from '../services/credential-store.js'; +import { credentialResolve, credentialUpdate } from 'blindfold'; import { logLine } from '../utils/log-helpers.js'; export const credentialStoreUpdateSchema = z.object({ diff --git a/src/tools/execute-command.ts b/src/tools/execute-command.ts index a32fff50..806c6cc7 100644 --- a/src/tools/execute-command.ts +++ b/src/tools/execute-command.ts @@ -8,9 +8,8 @@ import { buildAuthEnvPrefix } from '../utils/auth-env.js'; import { writeStatusline } from '../services/statusline.js'; import { ensureCloudReady } from '../services/cloud/lifecycle.js'; import { generateTaskWrapper } from '../services/cloud/task-wrapper.js'; -import { escapeShellArg, escapePowerShellArg } from '../utils/shell-escape.js'; -import { credentialResolve, registerTaskCredentials } from '../services/credential-store.js'; -import { collectOobConfirm } from '../services/auth-socket.js'; +import { resolveSecureTokens, redactOutput, SEC_HANDLE_RE, registerTaskCredentials, collectOobConfirm } from 'blindfold'; +import type { ResolvedCredential } from 'blindfold'; import { LogScope, maskSecrets, truncateForLog } from '../utils/log-helpers.js'; import { tryKillPid } from '../utils/pid-helpers.js'; import type { Agent } from '../types.js'; @@ -37,79 +36,6 @@ export type ExecuteCommandInput = z.infer; // Best-effort heuristic — not a security boundary const NETWORK_TOOL_RE = /\b(curl|wget|ssh|sftp|scp|rsync|nc|netcat|http|fetch|Invoke-WebRequest|Invoke-RestMethod)\b/i; -// Matches raw sec:// credential handles that must never reach shell or LLM -const SEC_RE = /sec:\/\/[a-zA-Z0-9_]+/; - -interface ResolvedCredential { - name: string; - plaintext: string; - network_policy: 'allow' | 'confirm' | 'deny'; -} - -/** - * Scan a command string for {{secure.NAME}} tokens, resolve each from the - * credential store, and return the substituted command plus metadata for - * output redaction and egress checks. - * - * Returns an error string if any token cannot be resolved or is blocked. - */ -async function resolveSecureTokens( - command: string, - agentOs: 'windows' | 'macos' | 'linux', - callingMember: string, -): Promise<{ resolved: string; credentials: ResolvedCredential[] } | { error: string }> { - // Refuse if raw sec:// handles appear (these should not be passed to commands) - if (/sec:\/\/[a-zA-Z0-9_]+/.test(command)) { - return { error: 'Credentials cannot be passed to LLM sessions — use {{secure.NAME}} tokens instead of sec:// handles.' }; - } - - const TOKEN_RE = /\{\{secure\.([a-zA-Z0-9_-]{1,64})\}\}/g; - const credentials: ResolvedCredential[] = []; - let resolved = command; - let match: RegExpExecArray | null; - - // Collect all unique token names first - const tokenNames = new Set(); - while ((match = TOKEN_RE.exec(command)) !== null) { - tokenNames.add(match[1]); - } - - for (const name of tokenNames) { - const entry = credentialResolve(name, callingMember); - if (!entry) { - return { error: `Credential "${name}" not found. Run credential_store_set first.` }; - } - if ('denied' in entry) return { error: entry.denied }; - if ('expired' in entry) return { error: entry.expired }; - credentials.push({ name, plaintext: entry.plaintext, network_policy: entry.meta.network_policy }); - } - - // Substitute tokens with shell-escaped values. - // Windows members run under PowerShell (confirmed by WindowsCommands.cleanExec), - // so use single-quote escaping — internal single quotes are doubled (''). - // This is safer than cmd.exe double-quote + ^ escaping which is unreliable in PS. - for (const cred of credentials) { - const escaped = agentOs === 'windows' - ? escapePowerShellArg(cred.plaintext) - : escapeShellArg(cred.plaintext); - resolved = resolved.replaceAll(`{{secure.${cred.name}}}`, escaped); - } - - return { resolved, credentials }; -} - -/** - * Replace occurrences of credential plaintext values in output with [REDACTED:NAME]. - */ -function redactOutput(output: string, credentials: ResolvedCredential[]): string { - let redacted = output; - for (const cred of credentials) { - if (cred.plaintext.length > 0) { - redacted = redacted.replaceAll(cred.plaintext, `[REDACTED:${cred.name}]`); - } - } - return redacted; -} export async function executeCommand(input: ExecuteCommandInput, extra?: any): Promise { const agentOrError = resolveMember(input.member_id, input.member_name); @@ -136,15 +62,15 @@ export async function executeCommand(input: ExecuteCommandInput, extra?: any): P // -- Block sec:// handles in run_from and restart_command -- - if (input.run_from && SEC_RE.test(input.run_from)) { + if (input.run_from && SEC_HANDLE_RE.test(input.run_from)) { return '❌ Credentials cannot be passed to LLM sessions — use {{secure.NAME}} tokens instead of sec:// handles.'; } - if (input.restart_command && SEC_RE.test(input.restart_command)) { + if (input.restart_command && SEC_HANDLE_RE.test(input.restart_command)) { return '❌ Credentials cannot be passed to LLM sessions — use {{secure.NAME}} tokens instead of sec:// handles.'; } // -- Resolve {{secure.NAME}} tokens -- - const tokenResult = await resolveSecureTokens(input.command, agentOs, agent.friendlyName); + const tokenResult = resolveSecureTokens(input.command, { caller: agent.friendlyName, os: agentOs }); if ('error' in tokenResult) return `❌ ${tokenResult.error}`; const { resolved: resolvedCommand, credentials } = tokenResult; @@ -152,7 +78,7 @@ export async function executeCommand(input: ExecuteCommandInput, extra?: any): P // Also resolve tokens in restart_command (H1) let resolvedRestartCommand: string | undefined; if (input.restart_command) { - const restartTokenResult = await resolveSecureTokens(input.restart_command, agentOs, agent.friendlyName); + const restartTokenResult = resolveSecureTokens(input.restart_command, { caller: agent.friendlyName, os: agentOs }); if ('error' in restartTokenResult) return `❌ ${restartTokenResult.error}`; resolvedRestartCommand = restartTokenResult.resolved; // Merge any additional credentials from restart_command (de-dup by name) diff --git a/src/tools/execute-prompt.ts b/src/tools/execute-prompt.ts index 90d937ba..bd12d03c 100644 --- a/src/tools/execute-prompt.ts +++ b/src/tools/execute-prompt.ts @@ -19,6 +19,7 @@ import { resolveTilde } from './execute-command.js'; import { clearStoredPid } from '../utils/agent-helpers.js'; import { tryKillPid } from '../utils/pid-helpers.js'; import { LogScope, maskSecrets, truncateForLog } from '../utils/log-helpers.js'; +import { containsSecureTokens } from 'blindfold'; import type { Agent, SSHExecResult } from '../types.js'; import type { AgentStrategy } from '../services/strategy.js'; import type { ProviderAdapter } from '../providers/index.js'; @@ -88,8 +89,6 @@ async function deletePromptFile(agent: Agent, strategy: AgentStrategy, promptFil } } -const SECURE_TOKEN_RE = /\{\{secure\.[a-zA-Z0-9_-]{1,64}\}\}/; - export const inFlightAgents = new Set(); // All exit paths from executePrompt clear busy state via the finally block (inFlightAgents.delete + writeStatusline): @@ -102,7 +101,7 @@ export const inFlightAgents = new Set(); // (g) early returns before inFlightAgents.add: busy state never entered export async function executePrompt(input: ExecutePromptInput, extra?: any): Promise { - if (SECURE_TOKEN_RE.test(input.prompt)) { + if (containsSecureTokens(input.prompt)) { return 'error: execute_prompt prompt contains {{secure.NAME}} token. Secrets must never be passed to LLM prompts. Use execute_command with {{secure.NAME}} instead.'; } diff --git a/src/tools/monitor-task.ts b/src/tools/monitor-task.ts index 637704f8..27609e50 100644 --- a/src/tools/monitor-task.ts +++ b/src/tools/monitor-task.ts @@ -5,7 +5,7 @@ import { getAgentOS } from '../utils/agent-helpers.js'; import { memberIdentifier, resolveMember } from '../utils/resolve-member.js'; import { ensureCloudReady } from '../services/cloud/lifecycle.js'; import { awsProvider } from '../services/cloud/aws.js'; -import { getTaskCredentials } from '../services/credential-store.js'; +import { getTaskCredentials } from 'blindfold'; import { parseGpuUtilization } from '../utils/gpu-parser.js'; import type { Agent } from '../types.js'; diff --git a/src/tools/provision-auth.ts b/src/tools/provision-auth.ts index de60065a..09886729 100644 --- a/src/tools/provision-auth.ts +++ b/src/tools/provision-auth.ts @@ -5,14 +5,10 @@ import os from 'node:os'; import { getStrategy } from '../services/strategy.js'; import { getOsCommands } from '../os/index.js'; import { getProvider } from '../providers/index.js'; -import { escapeDoubleQuoted } from '../utils/shell-escape.js'; +import { escapeDoubleQuoted, validateCredentials, credentialStatusNote, credentialResolve, encryptPassword, decryptPassword, collectOobApiKey } from 'blindfold'; import { getAgentOS, touchAgent } from '../utils/agent-helpers.js'; import { memberIdentifier, resolveMember } from '../utils/resolve-member.js'; -import { validateCredentials, credentialStatusNote } from '../utils/credential-validation.js'; -import { credentialResolve } from '../services/credential-store.js'; -import { encryptPassword, decryptPassword } from '../utils/crypto.js'; import { updateAgent } from '../services/registry.js'; -import { collectOobApiKey } from '../services/auth-socket.js'; import { logLine } from '../utils/log-helpers.js'; import type { Agent } from '../types.js'; import type { ProviderAdapter } from '../providers/index.js'; diff --git a/src/tools/provision-vcs-auth.ts b/src/tools/provision-vcs-auth.ts index 3df99deb..43e8e564 100644 --- a/src/tools/provision-vcs-auth.ts +++ b/src/tools/provision-vcs-auth.ts @@ -4,9 +4,7 @@ import { getOsCommands } from '../os/index.js'; import { getAgentOS, touchAgent, checkVcsTokenExpiry } from '../utils/agent-helpers.js'; import { memberIdentifier, resolveMember } from '../utils/resolve-member.js'; import { updateAgent } from '../services/registry.js'; -import { credentialResolve } from '../services/credential-store.js'; -import { collectOobApiKey } from '../services/auth-socket.js'; -import { decryptPassword } from '../utils/crypto.js'; +import { resolveSecureField, collectOobApiKey, decryptPassword } from 'blindfold'; import { githubProvider } from '../services/vcs/github.js'; import { bitbucketProvider } from '../services/vcs/bitbucket.js'; import { azureDevOpsProvider } from '../services/vcs/azure-devops.js'; @@ -16,23 +14,6 @@ import { logLine } from '../utils/log-helpers.js'; import type { Agent } from '../types.js'; import type { VcsProviderService } from '../services/vcs/types.js'; -const TOKEN_RE = /\{\{secure\.([a-zA-Z0-9_-]{1,64})\}\}/g; - -function resolveSecureField(value: string, callingMember: string): { resolved: string } | { error: string } { - const tokenNames = new Set(); - let match: RegExpExecArray | null; - TOKEN_RE.lastIndex = 0; - while ((match = TOKEN_RE.exec(value)) !== null) tokenNames.add(match[1]); - let resolved = value; - for (const name of tokenNames) { - const entry = credentialResolve(name, callingMember); - if (!entry) return { error: `Credential "${name}" not found. Run credential_store_set first.` }; - if ('denied' in entry) return { error: entry.denied }; - if ('expired' in entry) return { error: entry.expired }; - resolved = resolved.replaceAll(`{{secure.${name}}}`, entry.plaintext); - } - return { resolved }; -} const providers: Record = { 'github': githubProvider, diff --git a/src/tools/register-member.ts b/src/tools/register-member.ts index 400c0c21..43bf9715 100644 --- a/src/tools/register-member.ts +++ b/src/tools/register-member.ts @@ -2,17 +2,15 @@ import { z } from 'zod'; import { v4 as uuid } from 'uuid'; import type { Agent } from '../types.js'; import type { CloudConfig } from '../services/cloud/types.js'; -import { encryptPassword, decryptPassword } from '../utils/crypto.js'; +import { encryptPassword, decryptPassword, credentialResolve, credentialSet, collectOobPassword, collectOobApiKey } from 'blindfold'; import { detectOS } from '../utils/platform.js'; import { getOsCommands } from '../os/index.js'; import { getProvider } from '../providers/index.js'; import { addAgent, getAllAgents, hasDuplicateFolder } from '../services/registry.js'; -import { credentialResolve, credentialSet } from '../services/credential-store.js'; import { getStrategy } from '../services/strategy.js'; import { assignIcon } from '../services/icons.js'; import { writeStatusline } from '../services/statusline.js'; import { awsProvider } from '../services/cloud/aws.js'; -import { collectOobPassword, collectOobApiKey } from '../services/auth-socket.js'; import { classifySshError } from '../utils/ssh-error-messages.js'; import { logLine } from '../utils/log-helpers.js'; diff --git a/src/tools/setup-git-app.ts b/src/tools/setup-git-app.ts index 9e13f8a5..f63b275b 100644 --- a/src/tools/setup-git-app.ts +++ b/src/tools/setup-git-app.ts @@ -5,8 +5,7 @@ import os from 'node:os'; import crypto from 'node:crypto'; import { loadPrivateKey, verifyAppConnectivity } from '../services/github-app.js'; import { setGitHubApp } from '../services/git-config.js'; -import { enforceOwnerOnly } from '../utils/file-permissions.js'; -import { credentialResolve } from '../services/credential-store.js'; +import { enforceOwnerOnly, credentialResolve } from 'blindfold'; import { FLEET_DIR } from '../paths.js'; const STORED_KEY_PATH = path.join(FLEET_DIR, 'github-app.pem'); const TOKEN_RE = /\{\{secure\.([a-zA-Z0-9_-]{1,64})\}\}/; diff --git a/src/tools/stop-prompt.ts b/src/tools/stop-prompt.ts index 1ec9b5e6..4f7c7239 100644 --- a/src/tools/stop-prompt.ts +++ b/src/tools/stop-prompt.ts @@ -8,7 +8,7 @@ import { logLine } from '../utils/log-helpers.js'; import { inFlightAgents } from './execute-prompt.js'; import { writeStatusline } from '../services/statusline.js'; import { getStallDetector } from '../services/stall/index.js'; -import { cancelPendingAuth } from '../services/auth-socket.js'; +import { cancelPendingAuth } from 'blindfold'; export const stopPromptSchema = z.object({ ...memberIdentifier, diff --git a/src/tools/update-member.ts b/src/tools/update-member.ts index 94eb1396..08873ae8 100644 --- a/src/tools/update-member.ts +++ b/src/tools/update-member.ts @@ -1,9 +1,7 @@ import { z } from 'zod'; import { updateAgent as updateInRegistry, hasDuplicateFolder } from '../services/registry.js'; -import { encryptPassword } from '../utils/crypto.js'; +import { encryptPassword, collectOobPassword, credentialResolve } from 'blindfold'; import { memberIdentifier, resolveMember } from '../utils/resolve-member.js'; -import { collectOobPassword } from '../services/auth-socket.js'; -import { credentialResolve } from '../services/credential-store.js'; import { isValidIcon, resolveIcon, DEFAULT_ICON } from '../services/icons.js'; import { writeStatusline } from '../services/statusline.js'; import { logLine } from '../utils/log-helpers.js'; diff --git a/src/utils/auth-env.ts b/src/utils/auth-env.ts index 9e841c6c..9f0e78f6 100644 --- a/src/utils/auth-env.ts +++ b/src/utils/auth-env.ts @@ -1,7 +1,6 @@ import type { Agent } from '../types.js'; import type { RemoteOS } from './platform.js'; -import { decryptPassword } from './crypto.js'; -import { escapeDoubleQuoted } from './shell-escape.js'; +import { decryptPassword, escapeDoubleQuoted } from 'blindfold'; /** * Build a platform-correct inline export prefix for all stored auth env vars. diff --git a/src/utils/collect-secret.ts b/src/utils/collect-secret.ts deleted file mode 100644 index 721e9f3d..00000000 --- a/src/utils/collect-secret.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { secureInput } from './secure-input.js'; -import { OOB_TIMEOUT_MS } from './oob-timeout.js'; - -const readKey = (): Promise => - new Promise((resolve) => { - process.stdin.setRawMode(true); - process.stdin.resume(); - process.stdin.once('data', (buf: Buffer) => { - process.stdin.setRawMode(false); - process.stdin.pause(); - resolve(buf); - }); - }); - -export async function collectSecret(prompt: string): Promise { - const timeout = setTimeout(() => { - process.stderr.write('\n ⏱ Timed out. Closing.\n'); - process.exit(1); - }, OOB_TIMEOUT_MS); - - let secretValue: string; - while (true) { - try { - secretValue = await secureInput({ prompt: `${prompt}: ` }); - } catch { - clearTimeout(timeout); - console.error('Cancelled.'); - process.exit(1); - return ''; // unreachable - } - - if (!secretValue) { - clearTimeout(timeout); - console.error('✗ Empty value. Aborting.'); - process.exit(1); - return ''; // unreachable - } - - const DIM = '\x1b[2m', RESET = '\x1b[0m'; - process.stderr.write(`${DIM} [Enter] proceed [v] view [Esc] re-enter${RESET}\n`); - const key1 = (await readKey())[0]; - - // Cursor is at N+2 (blank line after hint's \n). Password line is N, hint is N+1. - if (key1 === 0x76 || key1 === 0x56) { - // v/V: reveal in place — clear blank N+2, hint N+1, password N, reprint as plaintext - process.stderr.write('\r\x1b[K'); // clear blank N+2 - process.stderr.write('\x1b[1A\r\x1b[K'); // up to N+1, clear hint - process.stderr.write('\x1b[1A\r\x1b[K'); // up to N, clear password line - process.stderr.write(`√ ${prompt}: ${secretValue}\n`); - process.stderr.write(`${DIM} [Enter] confirm [Esc] re-enter${RESET}\n`); - - // Cursor now at N+2 again (blank after confirm hint's \n) - const key2 = (await readKey())[0]; - - if (key2 === 0x1b) { - // Esc: clear blank N+2, confirm hint N+1, value line N — re-enter in place - process.stderr.write('\r\x1b[K'); - process.stderr.write('\x1b[1A\r\x1b[K'); - process.stderr.write('\x1b[1A\r\x1b[K'); - continue; - } else { - // Enter: clear blank N+2, confirm hint N+1, value line N — reprint with stars - process.stderr.write('\r\x1b[K'); - process.stderr.write('\x1b[1A\r\x1b[K'); - process.stderr.write('\x1b[1A\r\x1b[K'); - process.stderr.write(`√ ${prompt}: ${'*'.repeat(secretValue.length)}\n`); - break; - } - } else if (key1 === 0x1b) { - // Esc: clear blank N+2, hint N+1, password N — re-enter in place - process.stderr.write('\r\x1b[K'); - process.stderr.write('\x1b[1A\r\x1b[K'); - process.stderr.write('\x1b[1A\r\x1b[K'); - continue; - } else { - // Enter or anything else: clear blank N+2, hint N+1 — password line stays - process.stderr.write('\r\x1b[K'); - process.stderr.write('\x1b[1A\r\x1b[K'); - break; - } - } - - clearTimeout(timeout); - return secretValue!; -} diff --git a/src/utils/credential-validation.ts b/src/utils/credential-validation.ts deleted file mode 100644 index a129cda4..00000000 --- a/src/utils/credential-validation.ts +++ /dev/null @@ -1,38 +0,0 @@ -const NEAR_EXPIRY_THRESHOLD_MS = 60 * 60 * 1000; // 1 hour - -export type CredentialStatus = - | { status: 'valid' } - | { status: 'near-expiry'; minutesLeft: number } - | { status: 'expired-refreshable' } - | { status: 'expired-no-refresh' }; - -export function validateCredentials(json: string): CredentialStatus | null { - let parsed: any; - try { parsed = JSON.parse(json); } catch { return null; } - - const oauth = parsed?.claudeAiOauth; - if (!oauth?.expiresAt) return null; - - const msLeft = new Date(oauth.expiresAt).getTime() - Date.now(); - - if (msLeft <= 0) { - return oauth.refreshToken - ? { status: 'expired-refreshable' } - : { status: 'expired-no-refresh' }; - } - - return msLeft < NEAR_EXPIRY_THRESHOLD_MS - ? { status: 'near-expiry', minutesLeft: Math.ceil(msLeft / 60000) } - : { status: 'valid' }; -} - -export function credentialStatusNote(cs: CredentialStatus | null): string { - if (!cs) return ''; - if (cs.status === 'valid') return ''; - if (cs.status === 'near-expiry') { - return `Note: Token expires in ~${cs.minutesLeft} minute${cs.minutesLeft === 1 ? '' : 's'}. Consider running /login to refresh.`; - } - return cs.status === 'expired-refreshable' - ? 'Note: Token is expired but has a refresh token — the agent CLI will auto-refresh on first use.' - : 'Token is expired with no refresh token. Run /login to get a fresh token before provisioning.'; -} diff --git a/src/utils/crypto.ts b/src/utils/crypto.ts deleted file mode 100644 index b5ac8650..00000000 --- a/src/utils/crypto.ts +++ /dev/null @@ -1,72 +0,0 @@ -import crypto from 'node:crypto'; -import fs from 'node:fs'; -import path from 'node:path'; -import { FLEET_DIR } from '../paths.js'; -import { logWarn } from './log-helpers.js'; - -const ALGORITHM = 'aes-256-gcm'; -const KEY_LENGTH = 32; -const IV_LENGTH = 16; -const SALT_PATH = path.join(FLEET_DIR, 'salt'); -const CREDENTIALS_PATH = path.join(FLEET_DIR, 'credentials.json'); - -/** - * Get or create a per-installation random AES-256-GCM key. - * The key is stored in ~/.apra-fleet/data/salt (32 random bytes, hex-encoded, mode 0o600). - * On first run a fresh random key is generated; subsequent runs load from file. - */ -function getOrCreateKey(): Buffer { - try { - if (fs.existsSync(SALT_PATH)) { - return Buffer.from(fs.readFileSync(SALT_PATH, 'utf-8').trim(), 'hex'); - } - } catch { - // Fall through to create new key - } - - if (!fs.existsSync(FLEET_DIR)) { - fs.mkdirSync(FLEET_DIR, { recursive: true, mode: 0o700 }); - } - const key = crypto.randomBytes(KEY_LENGTH); - fs.writeFileSync(SALT_PATH, key.toString('hex'), { mode: 0o600 }); - - // Migration: if credentials.json already exists, it was encrypted with the - // old deriveKey() scheme and cannot be decrypted with the new random key. - // Back it up so the user's data isn't silently lost. - if (fs.existsSync(CREDENTIALS_PATH)) { - fs.renameSync(CREDENTIALS_PATH, CREDENTIALS_PATH + '.bak'); - logWarn( - 'crypto', - '[apra-fleet] Encryption key upgraded to random persistent key. ' + - 'Existing stored credentials could not be migrated and have been backed up to credentials.json.bak. ' + - 'Please re-enter any stored API keys via credential_store_set.', - ); - } - - return key; -} - -export function encryptPassword(plaintext: string): string { - const key = getOrCreateKey(); - const iv = crypto.randomBytes(IV_LENGTH); - const cipher = crypto.createCipheriv(ALGORITHM, key, iv); - - let encrypted = cipher.update(plaintext, 'utf8', 'hex'); - encrypted += cipher.final('hex'); - const authTag = cipher.getAuthTag(); - - return `${iv.toString('hex')}:${authTag.toString('hex')}:${encrypted}`; -} - -export function decryptPassword(ciphertext: string): string { - const [ivHex, authTagHex, encrypted] = ciphertext.split(':'); - const iv = Buffer.from(ivHex, 'hex'); - const authTag = Buffer.from(authTagHex, 'hex'); - - const key = getOrCreateKey(); - const decipher = crypto.createDecipheriv(ALGORITHM, key, iv); - decipher.setAuthTag(authTag); - let decrypted = decipher.update(encrypted, 'hex', 'utf8'); - decrypted += decipher.final('utf8'); - return decrypted; -} diff --git a/src/utils/file-permissions.ts b/src/utils/file-permissions.ts deleted file mode 100644 index 1451813e..00000000 --- a/src/utils/file-permissions.ts +++ /dev/null @@ -1,12 +0,0 @@ -import fs from 'node:fs'; - -/** - * Enforce restrictive file permissions (owner-only read/write). - * On Linux/macOS: chmod 0o600. On Windows: no-op (NTFS ACLs handle this). - * - * Centralises the platform check — callers never branch on process.platform. - */ -export function enforceOwnerOnly(filePath: string): void { - if (process.platform === 'win32') return; - fs.chmodSync(filePath, 0o600); -} diff --git a/src/utils/oob-timeout.ts b/src/utils/oob-timeout.ts deleted file mode 100644 index 33dc861d..00000000 --- a/src/utils/oob-timeout.ts +++ /dev/null @@ -1 +0,0 @@ -export const OOB_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes diff --git a/src/utils/secure-input.ts b/src/utils/secure-input.ts deleted file mode 100644 index d280bead..00000000 --- a/src/utils/secure-input.ts +++ /dev/null @@ -1,68 +0,0 @@ -import password from '@inquirer/password'; -import readline from 'node:readline'; - -export interface SecureInputOptions { - prompt: string; - allowEmpty?: boolean; -} - -export async function secureInput(opts: SecureInputOptions): Promise { - const { prompt, allowEmpty = false } = opts; - - // Non-TTY fallback: read one line from stdin - if (!process.stdin.isTTY) { - return new Promise((resolve) => { - let data = ''; - process.stdin.setEncoding('utf-8'); - process.stdin.on('data', (chunk: string) => { - data += chunk; - const nl = data.indexOf('\n'); - if (nl !== -1) { - resolve(data.slice(0, nl)); - } - }); - process.stdin.on('end', () => resolve(data.trim())); - }); - } - - // eslint-disable-next-line no-constant-condition - while (true) { - let value: string; - try { - value = await password({ - message: prompt, - mask: '*', - validate: (v: string) => { - if (v.length === 0 && !allowEmpty) { - return 'Value must not be empty. Please try again.'; - } - return true; - }, - }); - } catch { - // Ctrl+C → ExitPromptError; surface as Cancelled to match prior API. - throw new Error('Cancelled'); - } - - if (value.length === 0 && allowEmpty) { - const confirmed = await confirmEmpty(); - if (!confirmed) continue; - } - - return value; - } -} - -async function confirmEmpty(): Promise { - return new Promise((resolve) => { - const rl = readline.createInterface({ - input: process.stdin, - output: process.stderr, - terminal: true, - }); - rl.question('Are you sure? [y/N]: ', (answer) => { - rl.close(); - resolve(answer.trim().toLowerCase() === 'y'); - }); - }); -} diff --git a/src/utils/shell-escape.ts b/src/utils/shell-escape.ts deleted file mode 100644 index d6f7d956..00000000 --- a/src/utils/shell-escape.ts +++ /dev/null @@ -1,73 +0,0 @@ -/** - * Centralized shell escaping functions to prevent command injection (CWE-78). - * Used by platform.ts, execute-prompt.ts, and provision-auth.ts. - */ - -/** - * Escape a string for safe use inside single-quoted Unix shell arguments. - * Handles embedded single quotes by ending the quote, adding an escaped quote, and reopening. - * e.g. "it's" → 'it'\''s' - */ -export function escapeShellArg(s: string): string { - return "'" + s.replace(/'/g, "'\\''") + "'"; -} - -/** - * Escape a string for safe use inside double-quoted Unix shell arguments. - * Escapes: $ ` " \ ! (characters with special meaning inside double quotes). - */ -export function escapeDoubleQuoted(s: string): string { - return s - .replace(/\\/g, '\\\\') - .replace(/"/g, '\\"') - .replace(/\$/g, '\\$') - .replace(/`/g, '\\`') - .replace(/!/g, '\\!'); -} - -/** - * Escape a string for safe use inside double-quoted Windows cmd.exe arguments. - * Escapes: " & | ^ < > (cmd.exe metacharacters). - */ -export function escapeWindowsArg(s: string): string { - return s - .replace(/"/g, '""') - .replace(/([&|^<>])/g, '^$1'); -} - -/** - * Escape a string for safe use as a PowerShell single-quoted string literal. - * Single-quoted strings in PowerShell are fully literal — no variable expansion. - * Internal single quotes are escaped by doubling them: ' → '' - * Returns the value wrapped in single quotes. - */ -export function escapePowerShellArg(s: string): string { - return "'" + s.replace(/'/g, "''") + "'"; -} - -/** - * Escape batch (cmd.exe) metacharacters for safe use in .bat file content. - * Escapes: & | > < ^ % by prefixing each with ^. - */ -export function escapeBatchMetachars(s: string): string { - return s.replace(/([&|><^%])/g, '^$1'); -} - -/** - * Escape regex metacharacters for use in `grep -E` patterns. - */ -export function escapeGrepPattern(s: string): string { - return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); -} - -/** - * Validate and sanitize a session ID to prevent injection. - * Session IDs must be alphanumeric with dashes and underscores only. - * Throws if the ID contains invalid characters. - */ -export function sanitizeSessionId(s: string): string { - if (!/^[a-zA-Z0-9_-]+$/.test(s)) { - throw new Error(`Invalid session ID: contains disallowed characters`); - } - return s; -} diff --git a/tests/auth-cli.test.ts b/tests/auth-cli.test.ts new file mode 100644 index 00000000..68840a86 --- /dev/null +++ b/tests/auth-cli.test.ts @@ -0,0 +1,110 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { EventEmitter } from 'node:events'; + +// --- Mock blindfold --- +vi.mock('blindfold', async () => { + const actual = await vi.importActual('blindfold'); + return { + ...actual, + getSocketPath: () => '/tmp/test-fleet.sock', + }; +}); + +// --- Mock node:readline --- +const mockQuestion = vi.fn<(prompt: string, cb: (answer: string) => void) => void>(); +const mockRlClose = vi.fn(); +const mockRlOn = vi.fn<(event: string, cb: (...args: any[]) => void) => any>(); + +vi.mock('node:readline', () => ({ + default: { + createInterface: () => ({ + question: mockQuestion, + close: mockRlClose, + on: mockRlOn, + }), + }, +})); + +// --- Mock node:net --- +let capturedSocketPath = ''; +let capturedWritten = ''; +const mockNetWrite = vi.fn<(data: string) => void>(); +const mockNetEnd = vi.fn(); +const mockClientOn = vi.fn<(event: string, cb: (...args: any[]) => void) => any>(); +let connectCallback: (() => void) | null = null; +let dataCallback: ((chunk: Buffer) => void) | null = null; + +vi.mock('node:net', () => ({ + default: { + connect: (sockPath: string, cb: () => void) => { + capturedSocketPath = sockPath; + connectCallback = cb; + return { + write: (data: string) => { + mockNetWrite(data); + capturedWritten = data; + }, + end: mockNetEnd, + on: (event: string, cb: (...args: any[]) => void) => { + mockClientOn(event, cb); + if (event === 'data') dataCallback = cb as (chunk: Buffer) => void; + }, + }; + }, + }, +})); + +describe('auth --confirm (egress confirmation)', () => { + beforeEach(() => { + vi.clearAllMocks(); + capturedSocketPath = ''; + capturedWritten = ''; + connectCallback = null; + dataCallback = null; + + // readline: question immediately calls cb with 'yes', then fires close + mockQuestion.mockImplementation((_prompt, cb) => { + cb('yes'); + }); + mockRlOn.mockImplementation((event, cb) => { + if (event === 'close') { + // do not auto-fire; the question handler resolves first + } + return {}; + }); + }); + + it('connects to socket and sends correct JSON when user types yes', async () => { + const { runAuth } = await import('../src/cli/auth.js'); + + const p = runAuth(['--confirm', 'TEST_CRED']); + + // Simulate the socket connecting and returning ok + await vi.waitFor(() => connectCallback !== null); + connectCallback!(); + + await vi.waitFor(() => dataCallback !== null); + const okResponse = Buffer.from(JSON.stringify({ ok: true }) + '\n'); + dataCallback!(okResponse); + + await p; + + expect(capturedSocketPath).toBe('/tmp/test-fleet.sock'); + const sent = JSON.parse(capturedWritten.trim()); + expect(sent).toMatchObject({ + type: 'auth', + member_name: 'TEST_CRED', + password: 'yes', + }); + }); + + it('rejects an invalid credential name before opening the socket', async () => { + const { runAuth } = await import('../src/cli/auth.js'); + + const exitSpy = vi.spyOn(process, 'exit').mockImplementation((_code?: number) => { throw new Error('process.exit'); }); + + await expect(runAuth(['--confirm', 'bad name!'])).rejects.toThrow('process.exit'); + expect(capturedSocketPath).toBe(''); + exitSpy.mockRestore(); + }); +}); diff --git a/tests/auth-env.test.ts b/tests/auth-env.test.ts index ccf20978..788d58e3 100644 --- a/tests/auth-env.test.ts +++ b/tests/auth-env.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from 'vitest'; import { buildAuthEnvPrefix } from '../src/utils/auth-env.js'; -import { encryptPassword } from '../src/utils/crypto.js'; +import { encryptPassword } from 'blindfold'; import type { Agent } from '../src/types.js'; // Helper: build a minimal Agent with encryptedEnvVars diff --git a/tests/auth-socket.test.ts b/tests/auth-socket.test.ts deleted file mode 100644 index d9bafa24..00000000 --- a/tests/auth-socket.test.ts +++ /dev/null @@ -1,740 +0,0 @@ -import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; -import net from 'node:net'; -import fs from 'node:fs'; -import { - getSocketPath, - ensureAuthSocket, - createPendingAuth, - getPendingPassword, - hasPendingAuth, - waitForPassword, - cleanupAuthSocket, - collectOobPassword, - collectOobApiKey, - cancelPendingAuth, - hasGraphicalDisplay, - hasInteractiveDesktop, - launchAuthTerminal, -} from '../src/services/auth-socket.js'; - -describe('auth-socket', () => { - afterEach(async () => { - await cleanupAuthSocket(); - }); - - describe('getSocketPath', () => { - it.skipIf(process.platform === 'win32')('returns a path under FLEET_DIR on non-Windows', () => { - const p = getSocketPath(); - expect(p).toContain('auth.sock'); - expect(p).toContain('apra-fleet'); - }); - - it('returns a named pipe path on Windows', () => { - // Can only truly test on Windows, but we can verify the function exists - expect(typeof getSocketPath()).toBe('string'); - }); - }); - - describe('pending auth lifecycle', () => { - it('creates and checks pending auth', () => { - createPendingAuth('test-member'); - expect(hasPendingAuth('test-member')).toBe(true); - expect(hasPendingAuth('other-member')).toBe(false); - }); - - it('returns null for unresolved pending auth', () => { - createPendingAuth('test-member'); - expect(getPendingPassword('test-member')).toBeNull(); - // Entry should still exist (not consumed since it was unresolved) - expect(hasPendingAuth('test-member')).toBe(true); - }); - - it('returns null for unknown member', () => { - expect(getPendingPassword('unknown')).toBeNull(); - expect(hasPendingAuth('unknown')).toBe(false); - }); - - it('replaces old pending request for same member name', () => { - createPendingAuth('test-member'); - const before = hasPendingAuth('test-member'); - createPendingAuth('test-member'); // replace - const after = hasPendingAuth('test-member'); - expect(before).toBe(true); - expect(after).toBe(true); - }); - - it('cleans up on cleanupAuthSocket', async () => { - createPendingAuth('test-member'); - await cleanupAuthSocket(); - expect(hasPendingAuth('test-member')).toBe(false); - }); - }); - - describe('socket server and client', () => { - it('starts socket server, accepts auth, and returns encrypted password', async () => { - await ensureAuthSocket(); - createPendingAuth('web1'); - - const sockPath = getSocketPath(); - - // Simulate CLI client sending password - await new Promise((resolve, reject) => { - const client = net.connect(sockPath, () => { - client.write(JSON.stringify({ type: 'auth', member_name: 'web1', password: 'secret123' }) + '\n'); - }); - - let buffer = ''; - client.on('data', (chunk) => { - buffer += chunk.toString(); - const nl = buffer.indexOf('\n'); - if (nl === -1) return; - const resp = JSON.parse(buffer.slice(0, nl)); - expect(resp.ok).toBe(true); - client.end(); - client.destroy(); - resolve(); - }); - client.on('error', (err) => { - client.destroy(); - reject(err); - }); - }); - - // Password should now be resolved (encrypted) - const encPw = getPendingPassword('web1'); - expect(encPw).not.toBeNull(); - expect(encPw).toContain(':'); // encrypted format is iv:authTag:ciphertext - - // Entry consumed – should be gone - expect(hasPendingAuth('web1')).toBe(false); - }); - - it('returns error for unknown member name via socket', async () => { - await ensureAuthSocket(); - // No pending auth created for 'unknown' - - const sockPath = getSocketPath(); - - const resp = await new Promise((resolve, reject) => { - const client = net.connect(sockPath, () => { - client.write(JSON.stringify({ type: 'auth', member_name: 'unknown', password: 'test' }) + '\n'); - }); - - let buffer = ''; - client.on('data', (chunk) => { - buffer += chunk.toString(); - const nl = buffer.indexOf('\n'); - if (nl === -1) return; - const data = JSON.parse(buffer.slice(0, nl)); - client.end(); - client.destroy(); - resolve(data); - }); - client.on('error', (err) => { - client.destroy(); - reject(err); - }); - }); - - expect(resp.ok).toBe(false); - expect(resp.error).toContain('unknown'); - }); - - it('returns error for invalid JSON via socket', async () => { - await ensureAuthSocket(); - const sockPath = getSocketPath(); - - const resp = await new Promise((resolve, reject) => { - const client = net.connect(sockPath, () => { - client.write('not json\n'); - }); - - let buffer = ''; - client.on('data', (chunk) => { - buffer += chunk.toString(); - const nl = buffer.indexOf('\n'); - if (nl === -1) return; - const data = JSON.parse(buffer.slice(0, nl)); - client.end(); - client.destroy(); - resolve(data); - }); - client.on('error', (err) => { - client.destroy(); - reject(err); - }); - }); - - expect(resp.ok).toBe(false); - expect(resp.error).toContain('Invalid JSON'); - }); - - it('returns error for invalid message format via socket', async () => { - await ensureAuthSocket(); - const sockPath = getSocketPath(); - - const resp = await new Promise((resolve, reject) => { - const client = net.connect(sockPath, () => { - client.write(JSON.stringify({ type: 'auth' }) + '\n'); // missing member_name and password - }); - - let buffer = ''; - client.on('data', (chunk) => { - buffer += chunk.toString(); - const nl = buffer.indexOf('\n'); - if (nl === -1) return; - const data = JSON.parse(buffer.slice(0, nl)); - client.end(); - client.destroy(); - resolve(data); - }); - client.on('error', (err) => { - client.destroy(); - reject(err); - }); - }); - - expect(resp.ok).toBe(false); - expect(resp.error).toContain('Invalid message'); - }); - - it('is idempotent – calling ensureAuthSocket twice does not error', async () => { - await ensureAuthSocket(); - await ensureAuthSocket(); // should be no-op - createPendingAuth('test'); - expect(hasPendingAuth('test')).toBe(true); - }); - - it.skipIf(process.platform === 'win32')('cleans up socket file on close', async () => { - await ensureAuthSocket(); - const sockPath = getSocketPath(); - expect(fs.existsSync(sockPath)).toBe(true); - - await cleanupAuthSocket(); - expect(fs.existsSync(sockPath)).toBe(false); - }); - }); - - describe('TTL expiry', () => { - it('expires pending auth after TTL', () => { - const now = Date.now(); - vi.spyOn(Date, 'now').mockReturnValue(now); - - createPendingAuth('expired-member'); - expect(hasPendingAuth('expired-member')).toBe(true); - - // Advance past 10-minute TTL - vi.spyOn(Date, 'now').mockReturnValue(now + 10 * 60 * 1000 + 1); - - expect(hasPendingAuth('expired-member')).toBe(false); - expect(getPendingPassword('expired-member')).toBeNull(); - - vi.restoreAllMocks(); - }); - }); - - describe('waitForPassword', () => { - it('resolves when password arrives via socket', async () => { - await ensureAuthSocket(); - createPendingAuth('wait-test'); - - const sockPath = getSocketPath(); - - // Start waiting, then send password after a short delay - const passwordPromise = waitForPassword('wait-test', 5000); - - await new Promise(r => setTimeout(r, 50)); - - await new Promise((resolve, reject) => { - const client = net.connect(sockPath, () => { - client.write(JSON.stringify({ type: 'auth', member_name: 'wait-test', password: 'secret' }) + '\n'); - }); - let buffer = ''; - client.on('data', (chunk) => { - buffer += chunk.toString(); - if (buffer.indexOf('\n') !== -1) { - client.end(); - client.destroy(); - resolve(); - } - }); - client.on('error', (err) => { - client.destroy(); - reject(err); - }); - }); - - const encPw = await passwordPromise; - expect(encPw).not.toBeNull(); - expect(encPw).toContain(':'); // iv:authTag:ciphertext - }); - - it('times out when no password arrives', async () => { - await ensureAuthSocket(); - createPendingAuth('timeout-test'); - - await expect(waitForPassword('timeout-test', 100)).rejects.toThrow('timed out'); - }); - - it('resolves immediately if password already arrived', async () => { - await ensureAuthSocket(); - createPendingAuth('fast-test'); - - const sockPath = getSocketPath(); - - // Send password first - await new Promise((resolve, reject) => { - const client = net.connect(sockPath, () => { - client.write(JSON.stringify({ type: 'auth', member_name: 'fast-test', password: 'pw' }) + '\n'); - }); - let buffer = ''; - client.on('data', (chunk) => { - buffer += chunk.toString(); - if (buffer.indexOf('\n') !== -1) { - client.end(); - client.destroy(); - resolve(); - } - }); - client.on('error', (err) => { - client.destroy(); - reject(err); - }); - }); - - // Now wait – should resolve immediately since password is already there - const encPw = await waitForPassword('fast-test', 1000); - expect(encPw).toContain(':'); - }); - - it('rejects when cleanupAuthSocket is called during wait', async () => { - await ensureAuthSocket(); - createPendingAuth('cleanup-test'); - - const passwordPromise = waitForPassword('cleanup-test', 5000); - // Suppress unhandled-rejection warning: rejection fires before expect() attaches its handler - passwordPromise.catch(() => {}); - - await new Promise(r => setTimeout(r, 50)); - await cleanupAuthSocket(); - - await expect(passwordPromise).rejects.toThrow('Auth socket closed'); - }); - }); - - describe('collectOobPassword', () => { - afterEach(async () => { - await cleanupAuthSocket(); - }); - - it('returns immediately when pending auth already has password', async () => { - await ensureAuthSocket(); - createPendingAuth('oob-ready'); - await sendPassword(getSocketPath(), 'oob-ready', 'secret'); - - const launchFn = vi.fn(); - const result = await collectOobPassword('oob-ready', 'test_tool', { launchFn }); - - expect(launchFn).not.toHaveBeenCalled(); - expect('password' in result).toBe(true); - if ('password' in result) expect(result.password).toContain(':'); - }); - - it('waits and resolves when pending without password', async () => { - await ensureAuthSocket(); - createPendingAuth('oob-wait'); - - const resultPromise = collectOobPassword('oob-wait', 'test_tool'); - - await new Promise(r => setTimeout(r, 50)); - await sendPassword(getSocketPath(), 'oob-wait', 'delayed-secret'); - - const result = await resultPromise; - expect('password' in result).toBe(true); - if ('password' in result) expect(result.password).toContain(':'); - }); - - it('returns fallback on timeout', async () => { - await ensureAuthSocket(); - createPendingAuth('oob-timeout'); - - // Use a short waitTimeoutMs so the test doesn't hang for 5 minutes - const result = await collectOobPassword('oob-timeout', 'test_tool', { waitTimeoutMs: 100 }); - expect('fallback' in result).toBe(true); - if ('fallback' in result) { - expect(result.fallback).toContain('timed out'); - expect(result.fallback).toContain('test_tool'); - } - }); - - it('launches terminal and resolves when password arrives', async () => { - const launchFn = vi.fn().mockReturnValue('launched'); - - const resultPromise = collectOobPassword('oob-fresh', 'test_tool', { launchFn }); - - await new Promise(r => setTimeout(r, 50)); - await sendPassword(getSocketPath(), 'oob-fresh', 'fresh-secret'); - - const result = await resultPromise; - expect(launchFn).toHaveBeenCalledWith('oob-fresh', [], expect.any(Function)); - expect('password' in result).toBe(true); - if ('password' in result) expect(result.password).toContain(':'); - }); - - it('returns fallback when terminal launch fails', async () => { - const launchFn = vi.fn().mockReturnValue('fallback:Could not find a terminal emulator'); - - const result = await collectOobPassword('oob-noterm', 'test_tool', { launchFn }); - expect('fallback' in result).toBe(true); - if ('fallback' in result) { - expect(result.fallback).toContain('Could not find a terminal emulator'); - expect(result.fallback).toContain('test_tool'); - } - }); - }); - - describe('collectOobApiKey', () => { - afterEach(async () => { - await cleanupAuthSocket(); - }); - - it('launches terminal with --api-key flag', async () => { - const launchFn = vi.fn().mockReturnValue('launched'); - - const resultPromise = collectOobApiKey('api-member', 'provision_llm_auth', { launchFn }); - - await new Promise(r => setTimeout(r, 50)); - await sendPassword(getSocketPath(), 'api-member', 'my-api-key'); - - const result = await resultPromise; - expect(launchFn).toHaveBeenCalledWith('api-member', ['--api-key'], expect.any(Function)); - expect('password' in result).toBe(true); - if ('password' in result) expect(result.password).toContain(':'); - }); - - it('returns encrypted key when pending auth already has password', async () => { - await ensureAuthSocket(); - createPendingAuth('api-ready'); - await sendPassword(getSocketPath(), 'api-ready', 'pre-entered-key'); - - const launchFn = vi.fn(); - const result = await collectOobApiKey('api-ready', 'provision_llm_auth', { launchFn }); - - expect(launchFn).not.toHaveBeenCalled(); - expect('password' in result).toBe(true); - if ('password' in result) expect(result.password).toContain(':'); - }); - - it('returns fallback on timeout', async () => { - await ensureAuthSocket(); - createPendingAuth('api-timeout'); - - const result = await collectOobApiKey('api-timeout', 'provision_llm_auth', { waitTimeoutMs: 100 }); - expect('fallback' in result).toBe(true); - if ('fallback' in result) { - expect(result.fallback).toContain('timed out'); - expect(result.fallback).toContain('provision_llm_auth'); - } - }); - - it('returns fallback when terminal launch fails', async () => { - const launchFn = vi.fn().mockReturnValue('fallback:Could not find a terminal emulator'); - - const result = await collectOobApiKey('api-noterm', 'provision_llm_auth', { launchFn }); - expect('fallback' in result).toBe(true); - if ('fallback' in result) { - expect(result.fallback).toContain('Could not find a terminal emulator'); - expect(result.fallback).toContain('provision_llm_auth'); - } - }); - - it('Bug 1: cleans up stale state after fallback so retry launches a fresh terminal', async () => { - // First call: terminal cannot be launched (fallback path) - const launchFn = vi.fn().mockReturnValue('fallback:No terminal available'); - const result1 = await collectOobApiKey('retry-cred', 'credential_store_set', { launchFn }); - expect('fallback' in result1).toBe(true); - - // pendingRequests must be cleared so hasPendingAuth returns false on retry - expect(hasPendingAuth('retry-cred')).toBe(false); - - // Second call: should launch a fresh terminal (not hit the re-entrant guard) - const launchFn2 = vi.fn().mockReturnValue('launched'); - const result2Promise = collectOobApiKey('retry-cred', 'credential_store_set', { launchFn: launchFn2, waitTimeoutMs: 500 }); - await new Promise(r => setTimeout(r, 50)); - await sendPassword(getSocketPath(), 'retry-cred', 'new-secret'); - const result2 = await result2Promise; - - expect(launchFn2).toHaveBeenCalledOnce(); - expect('password' in result2).toBe(true); - }); - - it('Bug 1: cleans up stale state after cancel so retry launches a fresh terminal', async () => { - // First call: terminal is launched but user cancels (onExit called with non-zero) - let capturedOnExit: ((code: number | null) => void) | undefined; - const launchFn1 = vi.fn().mockImplementation((_name: string, _args: string[], onExit: (code: number | null) => void) => { - capturedOnExit = onExit; - return 'launched'; - }); - const result1Promise = collectOobApiKey('cancel-cred', 'credential_store_set', { launchFn: launchFn1, waitTimeoutMs: 5000 }); - // Wait for launchFn to be called (happens after ensureAuthSocket, which may retry on Windows) - await vi.waitFor(() => { if (!capturedOnExit) throw new Error('launch not yet called'); }, { timeout: 10000 }); - capturedOnExit!(1); // simulate user closing the terminal - const result1 = await result1Promise; - expect('fallback' in result1).toBe(true); - - // pendingRequests must be cleared - expect(hasPendingAuth('cancel-cred')).toBe(false); - - // Second call: should launch a fresh terminal - const launchFn2 = vi.fn().mockReturnValue('launched'); - const result2Promise = collectOobApiKey('cancel-cred', 'credential_store_set', { launchFn: launchFn2, waitTimeoutMs: 500 }); - await new Promise(r => setTimeout(r, 50)); - await sendPassword(getSocketPath(), 'cancel-cred', 'retry-secret'); - const result2 = await result2Promise; - - expect(launchFn2).toHaveBeenCalledOnce(); - expect('password' in result2).toBe(true); - }); - }); - - describe('collectOobApiKey — 500ms grace period', () => { - afterEach(async () => { - await cleanupAuthSocket(); - }); - - it('returns password when it arrives within 500ms of terminal exit (code 0)', async () => { - // Simulate terminal closing immediately with code 0 - const launchFn = vi.fn().mockImplementation((_name, _args, onExit) => { - process.nextTick(() => onExit(0)); - return 'launched'; - }); - - const resultPromise = collectOobApiKey('grace-member', 'test_tool', { launchFn }); - - // Password arrives 100ms later - await new Promise(r => setTimeout(r, 100)); - await sendPassword(getSocketPath(), 'grace-member', 'grace-secret'); - - const result = await resultPromise; - expect('password' in result).toBe(true); - if ('password' in result) expect(result.password).toContain(':'); - expect(hasPendingAuth('grace-member')).toBe(false); - }); - - it('returns fallback when no password arrives within 500ms of terminal exit', async () => { - const launchFn = vi.fn().mockImplementation((_name, _args, onExit) => { - process.nextTick(() => onExit(0)); - return 'launched'; - }); - - // Shorten the waitTimeoutMs for the overall call but the 500ms is hardcoded in src - const result = await collectOobApiKey('fail-grace', 'test_tool', { launchFn }); - - expect('fallback' in result).toBe(true); - if ('fallback' in result) { - expect(result.fallback).toContain('cancelled'); - } - expect(hasPendingAuth('fail-grace')).toBe(false); - }); - - it('cleans up waiter and pendingRequests on 500ms timeout', async () => { - const launchFn = vi.fn().mockImplementation((_name, _args, onExit) => { - process.nextTick(() => onExit(0)); - return 'launched'; - }); - - await collectOobApiKey('cleanup-grace', 'test_tool', { launchFn }); - - expect(hasPendingAuth('cleanup-grace')).toBe(false); - // Waiters are internal but we can verify by starting a new one without conflict - createPendingAuth('cleanup-grace'); - expect(hasPendingAuth('cleanup-grace')).toBe(true); - }); - }); - - describe('hasGraphicalDisplay', () => { - afterEach(() => { - vi.unstubAllEnvs(); - }); - - it('returns false when DISPLAY and WAYLAND_DISPLAY are both unset', () => { - vi.stubEnv('DISPLAY', ''); - vi.stubEnv('WAYLAND_DISPLAY', ''); - expect(hasGraphicalDisplay()).toBe(false); - }); - - it('returns true when DISPLAY is set', () => { - vi.stubEnv('DISPLAY', ':0'); - vi.stubEnv('WAYLAND_DISPLAY', ''); - expect(hasGraphicalDisplay()).toBe(true); - }); - - it('returns true when WAYLAND_DISPLAY is set', () => { - vi.stubEnv('DISPLAY', ''); - vi.stubEnv('WAYLAND_DISPLAY', 'wayland-0'); - expect(hasGraphicalDisplay()).toBe(true); - }); - }); - - describe('hasInteractiveDesktop', () => { - afterEach(() => { - vi.unstubAllEnvs(); - }); - - it('returns false when SESSIONNAME is not Console', () => { - vi.stubEnv('SESSIONNAME', 'RDP-Tcp#0'); - expect(hasInteractiveDesktop()).toBe(false); - }); - - it('returns false when SESSIONNAME is unset', () => { - vi.stubEnv('SESSIONNAME', ''); - expect(hasInteractiveDesktop()).toBe(false); - }); - - it('returns true when SESSIONNAME is Console', () => { - vi.stubEnv('SESSIONNAME', 'Console'); - expect(hasInteractiveDesktop()).toBe(true); - }); - }); - - describe('cancelPendingAuth', () => { - afterEach(async () => { - await cleanupAuthSocket(); - }); - - it('does nothing when no pending auth exists', () => { - expect(() => cancelPendingAuth('no-such-member')).not.toThrow(); - }); - - it('rejects any waiting password waiter with "cancelled"', async () => { - await ensureAuthSocket(); - createPendingAuth('cancel-waiter'); - - const passwordPromise = waitForPassword('cancel-waiter', 5000); - passwordPromise.catch(() => {}); - - await new Promise(r => setTimeout(r, 20)); - cancelPendingAuth('cancel-waiter'); - - await expect(passwordPromise).rejects.toThrow('cancelled'); - }); - - it('clears pending request so hasPendingAuth returns false after cancel', async () => { - await ensureAuthSocket(); - createPendingAuth('cancel-clear'); - - expect(hasPendingAuth('cancel-clear')).toBe(true); - cancelPendingAuth('cancel-clear'); - expect(hasPendingAuth('cancel-clear')).toBe(false); - }); - - it('clears waiter so a retry can create fresh pending auth', async () => { - await ensureAuthSocket(); - createPendingAuth('cancel-retry'); - - const p1 = waitForPassword('cancel-retry', 5000); - p1.catch(() => {}); - - await new Promise(r => setTimeout(r, 20)); - cancelPendingAuth('cancel-retry'); - await expect(p1).rejects.toThrow('cancelled'); - - // Should be able to create a fresh pending auth without conflict - createPendingAuth('cancel-retry'); - expect(hasPendingAuth('cancel-retry')).toBe(true); - }); - }); - - describe('waitForPassword — kills spawned PID on timeout', () => { - afterEach(async () => { - await cleanupAuthSocket(); - }); - - it('rejects with timeout error when no password arrives', async () => { - await ensureAuthSocket(); - createPendingAuth('pid-timeout'); - - await expect(waitForPassword('pid-timeout', 100)).rejects.toThrow('timed out'); - expect(hasPendingAuth('pid-timeout')).toBe(false); - }); - - it('clears pending request on timeout', async () => { - await ensureAuthSocket(); - createPendingAuth('pid-clear-timeout'); - - await expect(waitForPassword('pid-clear-timeout', 100)).rejects.toThrow(); - expect(hasPendingAuth('pid-clear-timeout')).toBe(false); - }); - }); - - describe('OOB_TIMEOUT_MS constant', () => { - it('is exported from oob-timeout and equals 5 minutes', async () => { - const { OOB_TIMEOUT_MS } = await import('../src/utils/oob-timeout.js'); - expect(OOB_TIMEOUT_MS).toBe(5 * 60 * 1000); - }); - }); - - describe('buildHeadlessFallback -- mode-aware (via launchAuthTerminal)', () => { - afterEach(() => { - vi.restoreAllMocks(); - vi.unstubAllEnvs(); - }); - - function stubHeadless() { - // Force the headless branch on whichever platform the test runs on - if (process.platform === 'win32') { - vi.stubEnv('SESSIONNAME', ''); - } else if (process.platform === 'darwin') { - vi.stubEnv('SSH_TTY', '/dev/ttys000'); - } else { - vi.stubEnv('DISPLAY', ''); - vi.stubEnv('WAYLAND_DISPLAY', ''); - } - } - - it('emits --set and "provide the credential" wording for credential-collection mode (no extraArgs)', () => { - stubHeadless(); - const msg = launchAuthTerminal('my-member', [], () => {}); - expect(msg).toContain('! apra-fleet secret --set my-member'); - expect(msg).toContain('to provide the credential:'); - expect(msg).not.toContain('--confirm'); - }); - - it('emits --set and "provide the credential" wording for API-key mode (--api-key flag)', () => { - stubHeadless(); - const msg = launchAuthTerminal('my-member', ['--api-key'], () => {}); - expect(msg).toContain('! apra-fleet secret --set my-member'); - expect(msg).toContain('to provide the credential:'); - expect(msg).not.toContain('--confirm'); - }); - - it('emits --confirm and "to confirm" wording for egress-confirm mode', () => { - stubHeadless(); - const msg = launchAuthTerminal('my-member', ['--confirm'], () => {}); - expect(msg).toContain('! apra-fleet secret --confirm my-member'); - expect(msg).toContain('to confirm:'); - expect(msg).not.toContain('--set'); - }); - }); -}); - -function sendPassword(sockPath: string, memberName: string, password: string): Promise { - return new Promise((resolve, reject) => { - const client = net.connect(sockPath, () => { - client.write(JSON.stringify({ type: 'auth', member_name: memberName, password }) + '\n'); - }); - let buffer = ''; - client.on('data', (chunk) => { - buffer += chunk.toString(); - if (buffer.indexOf('\n') !== -1) { - client.end(); - client.destroy(); - resolve(); - } - }); - client.on('error', (err) => { - client.destroy(); - reject(err); - }); - }); -} diff --git a/tests/credential-cleanup.test.ts b/tests/credential-cleanup.test.ts deleted file mode 100644 index 20f7954d..00000000 --- a/tests/credential-cleanup.test.ts +++ /dev/null @@ -1,172 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import type { Agent } from '../src/types.js'; - -const { - mockGetAllAgents, - mockTestConnection, - mockExecCommand, - mockRevoke, -} = vi.hoisted(() => ({ - mockGetAllAgents: vi.fn<() => Agent[]>(), - mockTestConnection: vi.fn(), - mockExecCommand: vi.fn(), - mockRevoke: vi.fn(), -})); - -vi.mock('../src/services/registry.js', () => ({ - getAllAgents: mockGetAllAgents, -})); - -vi.mock('../src/services/strategy.js', () => ({ - getStrategy: () => ({ - testConnection: mockTestConnection, - execCommand: mockExecCommand, - }), -})); - -vi.mock('../src/os/index.js', () => ({ - getOsCommands: () => ({}), -})); - -vi.mock('../src/utils/agent-helpers.js', () => ({ - getAgentOS: () => 'linux', - touchAgent: vi.fn(), - setIdleTouchHook: vi.fn(), - getAgentOrFail: vi.fn(), -})); - -vi.mock('../src/services/vcs/github.js', () => ({ - githubProvider: { - revoke: mockRevoke, - deploy: vi.fn(), - testConnectivity: vi.fn(), - }, -})); -vi.mock('../src/services/vcs/bitbucket.js', () => ({ - bitbucketProvider: { revoke: vi.fn(), deploy: vi.fn(), testConnectivity: vi.fn() }, -})); -vi.mock('../src/services/vcs/azure-devops.js', () => ({ - azureDevOpsProvider: { revoke: vi.fn(), deploy: vi.fn(), testConnectivity: vi.fn() }, -})); - -import { scheduleCredentialCleanup, cancelCredentialCleanup, _getCleanupTimers } from '../src/services/credential-cleanup.js'; - -function makeAgent(overrides: Partial = {}): Agent { - return { - id: 'member-1', friendlyName: 'test', agentType: 'remote', - host: '1.2.3.4', port: 22, username: 'user', authType: 'key', - workFolder: '/home/user', createdAt: new Date().toISOString(), - vcsProvider: 'github', - ...overrides, - }; -} - -describe('scheduleCredentialCleanup', () => { - beforeEach(() => { - vi.useFakeTimers(); - vi.clearAllMocks(); - for (const id of Array.from(_getCleanupTimers().keys())) cancelCredentialCleanup(id); - }); - - afterEach(() => { - vi.useRealTimers(); - }); - - it('schedules a timer with default 55-minute TTL when no expiresAt', () => { - scheduleCredentialCleanup('member-1'); - expect(_getCleanupTimers().has('member-1')).toBe(true); - }); - - it('schedules timer based on expiresAt', () => { - const expiresAt = new Date(Date.now() + 30 * 60 * 1000).toISOString(); - scheduleCredentialCleanup('member-1', expiresAt); - expect(_getCleanupTimers().has('member-1')).toBe(true); - }); - - it('calls revoke when timer fires and member has vcsProvider', async () => { - const member = makeAgent(); - mockGetAllAgents.mockReturnValue([member]); - mockTestConnection.mockResolvedValue({ ok: true, latencyMs: 1 }); - mockRevoke.mockResolvedValue({ success: true, message: 'revoked' }); - mockExecCommand.mockResolvedValue({ stdout: '', stderr: '', code: 0 }); - - scheduleCredentialCleanup('member-1'); - await vi.advanceTimersByTimeAsync(55 * 60 * 1000 + 1000); - - expect(mockRevoke).toHaveBeenCalledOnce(); - expect(_getCleanupTimers().has('member-1')).toBe(false); - }); - - it('does not call revoke when member has no vcsProvider', async () => { - mockGetAllAgents.mockReturnValue([makeAgent({ vcsProvider: undefined })]); - - scheduleCredentialCleanup('member-1'); - await vi.advanceTimersByTimeAsync(55 * 60 * 1000 + 1000); - - expect(mockRevoke).not.toHaveBeenCalled(); - }); - - it('is silent when revoke throws', async () => { - mockGetAllAgents.mockReturnValue([makeAgent()]); - mockTestConnection.mockResolvedValue({ ok: true, latencyMs: 1 }); - mockRevoke.mockRejectedValue(new Error('network error')); - - scheduleCredentialCleanup('member-1'); - await expect(vi.advanceTimersByTimeAsync(55 * 60 * 1000 + 1000)).resolves.not.toThrow(); - }); - - it('cancels previous timer when re-provisioning same member', () => { - scheduleCredentialCleanup('member-1'); - const timer1 = _getCleanupTimers().get('member-1'); - - scheduleCredentialCleanup('member-1'); - const timer2 = _getCleanupTimers().get('member-1'); - - expect(timer2).not.toBe(timer1); - expect(_getCleanupTimers().size).toBe(1); - }); - - it('multiple agents have independent timers', () => { - scheduleCredentialCleanup('member-1'); - scheduleCredentialCleanup('member-2'); - - expect(_getCleanupTimers().size).toBe(2); - expect(_getCleanupTimers().has('member-1')).toBe(true); - expect(_getCleanupTimers().has('member-2')).toBe(true); - }); -}); - -describe('cancelCredentialCleanup', () => { - beforeEach(() => { - vi.useFakeTimers(); - vi.clearAllMocks(); - for (const id of Array.from(_getCleanupTimers().keys())) cancelCredentialCleanup(id); - }); - - afterEach(() => { - vi.useRealTimers(); - }); - - it('cancels the timer and removes from map', () => { - scheduleCredentialCleanup('member-1'); - expect(_getCleanupTimers().has('member-1')).toBe(true); - - cancelCredentialCleanup('member-1'); - expect(_getCleanupTimers().has('member-1')).toBe(false); - }); - - it('does not throw when cancelling non-existent member', () => { - expect(() => cancelCredentialCleanup('no-such-member')).not.toThrow(); - }); - - it('prevents revoke from firing after cancellation', async () => { - mockGetAllAgents.mockReturnValue([makeAgent()]); - - scheduleCredentialCleanup('member-1'); - cancelCredentialCleanup('member-1'); - - await vi.advanceTimersByTimeAsync(55 * 60 * 1000 + 1000); - - expect(mockRevoke).not.toHaveBeenCalled(); - }); -}); diff --git a/tests/credential-scoping-ttl.test.ts b/tests/credential-scoping-ttl.test.ts deleted file mode 100644 index bd6ca249..00000000 --- a/tests/credential-scoping-ttl.test.ts +++ /dev/null @@ -1,300 +0,0 @@ -/** - * T3 tests: credential scoping, TTL enforcement, list display, - * and backward compatibility. - */ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { makeTestAgent, backupAndResetRegistry, restoreRegistry } from './test-helpers.js'; -import { addAgent } from '../src/services/registry.js'; -import { executeCommand } from '../src/tools/execute-command.js'; -import { - credentialSet, - credentialList, - credentialDelete, - credentialResolve, - purgeExpiredCredentials, -} from '../src/services/credential-store.js'; -import type { SSHExecResult } from '../src/types.js'; - -// --------------------------------------------------------------------------- -// Mocks — no real SSH -// --------------------------------------------------------------------------- - -const { mockExecCommand } = vi.hoisted(() => ({ - mockExecCommand: vi.fn<(cmd: string, timeout?: number) => Promise>(), -})); - -vi.mock('../src/services/strategy.js', () => ({ - getStrategy: () => ({ - execCommand: mockExecCommand, - testConnection: vi.fn().mockResolvedValue({ ok: true }), - transferFiles: vi.fn(), - close: vi.fn(), - }), -})); - -vi.mock('../src/services/cloud/lifecycle.js', () => ({ - ensureCloudReady: vi.fn((member: any) => Promise.resolve(member)), -})); - -vi.mock('../src/services/auth-socket.js', () => ({ - collectOobConfirm: vi.fn(), - collectOobPassword: vi.fn(), - collectOobApiKey: vi.fn(), - ensureAuthSocket: vi.fn(), - createPendingAuth: vi.fn(), - hasPendingAuth: vi.fn().mockReturnValue(false), - getPendingPassword: vi.fn().mockReturnValue(null), - waitForPassword: vi.fn(), - cleanupAuthSocket: vi.fn(), - getSocketPath: vi.fn().mockReturnValue('/tmp/test.sock'), - launchAuthTerminal: vi.fn(), -})); - -// --------------------------------------------------------------------------- -// Scoping enforcement -// --------------------------------------------------------------------------- - -describe('credentialResolve: member scoping', () => { - afterEach(() => { - // Purge any credentials left over from a failed or partial test run. - // Each test also deletes its own credential, but this catches leaks. - for (const entry of credentialList()) { - if (/^(scope_star_|scope_in_|scope_deny_|scope_bypass_|scope_undef_)/.test(entry.name)) { - credentialDelete(entry.name); - } - } - }); - - it('allows access when allowedMembers is "*"', () => { - const name = `scope_star_${Date.now()}`; - credentialSet(name, 'secret', false, 'allow', '*'); - const result = credentialResolve(name, 'fleet-dev'); - expect(result).not.toBeNull(); - expect('plaintext' in result!).toBe(true); - credentialDelete(name); - }); - - it('allows access when callingMember is in allowedMembers list', () => { - const name = `scope_in_${Date.now()}`; - credentialSet(name, 'secret', false, 'allow', ['fleet-dev', 'fleet-rev']); - const result = credentialResolve(name, 'fleet-dev'); - expect(result).not.toBeNull(); - expect('plaintext' in result!).toBe(true); - credentialDelete(name); - }); - - it('denies access when callingMember is NOT in allowedMembers list', () => { - const name = `scope_deny_${Date.now()}`; - credentialSet(name, 'secret', false, 'allow', ['fleet-dev']); - const result = credentialResolve(name, 'fleet-rev'); - expect(result).not.toBeNull(); - expect('denied' in result!).toBe(true); - if (result && 'denied' in result) { - expect(result.denied).toContain('fleet-rev'); - expect(result.denied).toContain(name); - expect(result.denied).toContain('fleet-dev'); - } - credentialDelete(name); - }); - - it('bypasses scoping when callingMember is "*" (fleet-operator bypass)', () => { - const name = `scope_bypass_${Date.now()}`; - credentialSet(name, 'secret', false, 'allow', ['fleet-dev']); - const result = credentialResolve(name, '*'); - expect(result).not.toBeNull(); - expect('plaintext' in result!).toBe(true); - credentialDelete(name); - }); - - it('bypasses scoping when callingMember is undefined (no enforcement)', () => { - const name = `scope_undef_${Date.now()}`; - credentialSet(name, 'secret', false, 'allow', ['fleet-dev']); - const result = credentialResolve(name, undefined); - expect(result).not.toBeNull(); - expect('plaintext' in result!).toBe(true); - credentialDelete(name); - }); -}); - -// --------------------------------------------------------------------------- -// TTL enforcement -// --------------------------------------------------------------------------- - -describe('credentialResolve: TTL enforcement', () => { - it('resolves a credential with a future TTL', () => { - const name = `ttl_future_${Date.now()}`; - credentialSet(name, 'secret', false, 'allow', '*', 3600); - const result = credentialResolve(name); - expect(result).not.toBeNull(); - expect('plaintext' in result!).toBe(true); - credentialDelete(name); - }); - - it('returns { expired } for a credential with a past TTL', () => { - const name = `ttl_past_${Date.now()}`; - credentialSet(name, 'secret', false, 'allow', '*', -1); // already expired - const result = credentialResolve(name); - expect(result).not.toBeNull(); - expect('expired' in result!).toBe(true); - if (result && 'expired' in result) { - expect(result.expired).toContain(name); - expect(result.expired).toContain('expired'); - } - // Entry should be purged — second resolve returns null - expect(credentialResolve(name)).toBeNull(); - }); - - it('returns null for a credential that never existed', () => { - expect(credentialResolve('does_not_exist_xyz_scoping')).toBeNull(); - }); - - it('re-setting a credential resets the TTL', () => { - const name = `ttl_reset_${Date.now()}`; - credentialSet(name, 'secret-v1', false, 'allow', '*', -1); // expired - // Verify it's expired - const first = credentialResolve(name); - expect(first && 'expired' in first).toBe(true); - - // Re-set with valid TTL - credentialSet(name, 'secret-v2', false, 'allow', '*', 3600); - const second = credentialResolve(name); - expect(second).not.toBeNull(); - expect('plaintext' in second!).toBe(true); - if (second && 'plaintext' in second) { - expect(second.plaintext).toBe('secret-v2'); - } - credentialDelete(name); - }); - - it('omitting ttl_seconds stores no expiresAt', () => { - const name = `ttl_none_${Date.now()}`; - const meta = credentialSet(name, 'secret', false, 'allow'); - expect(meta.expiresAt).toBeUndefined(); - const result = credentialResolve(name); - expect(result).not.toBeNull(); - expect('plaintext' in result!).toBe(true); - credentialDelete(name); - }); -}); - -// --------------------------------------------------------------------------- -// credentialList: members and expiry display -// --------------------------------------------------------------------------- - -describe('credentialList: allowedMembers and expiresAt metadata', () => { - it('includes allowedMembers and expiresAt in listed entries', () => { - const name = `list_meta_${Date.now()}`; - credentialSet(name, 'secret', false, 'allow', ['fleet-dev'], 3600); - const list = credentialList(); - const entry = list.find(e => e.name === name); - expect(entry).toBeDefined(); - expect(entry!.allowedMembers).toEqual(['fleet-dev']); - expect(entry!.expiresAt).toBeDefined(); - credentialDelete(name); - }); - - it('shows "*" for allowedMembers when credential is unrestricted', () => { - const name = `list_star_${Date.now()}`; - credentialSet(name, 'secret', false, 'allow', '*'); - const list = credentialList(); - const entry = list.find(e => e.name === name); - expect(entry).toBeDefined(); - expect(entry!.allowedMembers).toBe('*'); - credentialDelete(name); - }); -}); - -// --------------------------------------------------------------------------- -// purgeExpiredCredentials: startup sweep -// --------------------------------------------------------------------------- - -describe('purgeExpiredCredentials', () => { - it('is callable without error even when no credentials exist', () => { - expect(() => purgeExpiredCredentials()).not.toThrow(); - }); - - it('removes expired session-tier credentials after purge', () => { - const name = `purge_sess_${Date.now()}`; - credentialSet(name, 'secret', false, 'allow', '*', -1); // expired immediately - // Before purge, credentialResolve returns expired (and purges inline) - const pre = credentialResolve(name); - expect(pre && 'expired' in pre).toBe(true); - // Now it's gone - expect(credentialResolve(name)).toBeNull(); - }); -}); - -// --------------------------------------------------------------------------- -// Backward compatibility: credentials without allowedMembers/expiresAt -// --------------------------------------------------------------------------- - -describe('backward compatibility', () => { - it('treats missing allowedMembers as "*" (any member can resolve)', () => { - // Simulate a legacy credential written before T1 by directly setting via - // credentialSet with default params (allowedMembers defaults to '*') - const name = `compat_${Date.now()}`; - credentialSet(name, 'legacy-value', false, 'allow'); - const result = credentialResolve(name, 'any-member'); - expect(result).not.toBeNull(); - expect('plaintext' in result!).toBe(true); - if (result && 'plaintext' in result) { - expect(result.plaintext).toBe('legacy-value'); - } - credentialDelete(name); - }); -}); - -// --------------------------------------------------------------------------- -// execute_command: scoping rejection propagates to tool -// --------------------------------------------------------------------------- - -describe('execute_command: credential scoping rejection', () => { - beforeEach(() => { - backupAndResetRegistry(); - vi.clearAllMocks(); - }); - - afterEach(() => { - restoreRegistry(); - }); - - it('returns error when credential is not accessible to the calling member', async () => { - const name = `cmd_scope_${Date.now()}`; - // Only fleet-dev is allowed - credentialSet(name, 'secret', false, 'allow', ['fleet-dev']); - - // Use a member with a different friendlyName - const member = makeTestAgent({ os: 'linux', friendlyName: 'fleet-rev' }); - addAgent(member); - - const result = await executeCommand({ - member_id: member.id, - command: `echo {{secure.${name}}}`, - timeout_s: 5, - }); - - expect(result).toContain('❌'); - expect(result).toContain(name); - expect(mockExecCommand).not.toHaveBeenCalled(); - - credentialDelete(name); - }); - - it('executes successfully when calling member is in allowedMembers', async () => { - const name = `cmd_allowed_${Date.now()}`; - credentialSet(name, 'secret', false, 'allow', ['fleet-dev']); - - const member = makeTestAgent({ os: 'linux', friendlyName: 'fleet-dev' }); - addAgent(member); - mockExecCommand.mockResolvedValue({ stdout: 'ok', stderr: '', code: 0 }); - - const result = await executeCommand({ - member_id: member.id, - command: `echo {{secure.${name}}}`, - timeout_s: 5, - }); - - expect(result).toContain('Exit code: 0'); - credentialDelete(name); - }); -}); diff --git a/tests/credential-store-and-execute.test.ts b/tests/credential-store-and-execute.test.ts index b2ee26e5..9b0721ff 100644 --- a/tests/credential-store-and-execute.test.ts +++ b/tests/credential-store-and-execute.test.ts @@ -14,7 +14,7 @@ import { credentialList, credentialDelete, credentialResolve, -} from '../src/services/credential-store.js'; +} from 'blindfold'; import type { SSHExecResult } from '../src/types.js'; // --------------------------------------------------------------------------- @@ -273,19 +273,23 @@ const { mockCollectOobConfirm } = vi.hoisted(() => ({ mockCollectOobConfirm: vi.fn(), })); -vi.mock('../src/services/auth-socket.js', () => ({ - collectOobConfirm: mockCollectOobConfirm, - collectOobPassword: vi.fn(), - collectOobApiKey: vi.fn(), - ensureAuthSocket: vi.fn(), - createPendingAuth: vi.fn(), - hasPendingAuth: vi.fn().mockReturnValue(false), - getPendingPassword: vi.fn().mockReturnValue(null), - waitForPassword: vi.fn(), - cleanupAuthSocket: vi.fn(), - getSocketPath: vi.fn().mockReturnValue('/tmp/test.sock'), - launchAuthTerminal: vi.fn(), -})); +vi.mock('blindfold', async () => { + const actual = await vi.importActual('blindfold'); + return { + ...actual, + collectOobConfirm: mockCollectOobConfirm, + collectOobPassword: vi.fn(), + collectOobApiKey: vi.fn(), + ensureAuthSocket: vi.fn(), + createPendingAuth: vi.fn(), + hasPendingAuth: vi.fn().mockReturnValue(false), + getPendingPassword: vi.fn().mockReturnValue(null), + waitForPassword: vi.fn(), + cleanupAuthSocket: vi.fn(), + getSocketPath: vi.fn().mockReturnValue('/tmp/test.sock'), + launchAuthTerminal: vi.fn(), + }; +}); describe('execute_command: network egress policy', () => { beforeEach(() => { diff --git a/tests/credential-store-path.test.ts b/tests/credential-store-path.test.ts deleted file mode 100644 index 857a1ebb..00000000 --- a/tests/credential-store-path.test.ts +++ /dev/null @@ -1,173 +0,0 @@ -/** - * T6: Unit tests for credential-store path derivation via APRA_FLEET_DATA_DIR. - * Verifies that getCredentialsPath() and all store operations respect the - * env var at call time, not at module load time. - */ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import os from 'node:os'; -import fs from 'node:fs'; -import path from 'node:path'; -import { - credentialSet, - credentialList, - credentialDelete, - credentialResolve, -} from '../src/services/credential-store.js'; - -const BASE_DIR = path.join(os.tmpdir(), 'apra-fleet-path-test'); - -function makeDir(suffix: string): string { - const dir = path.join(BASE_DIR, suffix); - fs.mkdirSync(dir, { recursive: true }); - return dir; -} - -function credentialsFile(dir: string): string { - return path.join(dir, 'credentials.json'); -} - -const originalDataDir = process.env.APRA_FLEET_DATA_DIR; - -afterEach(() => { - // Restore original env var - if (originalDataDir === undefined) { - delete process.env.APRA_FLEET_DATA_DIR; - } else { - process.env.APRA_FLEET_DATA_DIR = originalDataDir; - } - - // Clean up any test credentials that leaked into the default dir - for (const entry of credentialList()) { - if (entry.name.startsWith('path_test_')) { - credentialDelete(entry.name); - } - } - - // Clean up temp dirs - try { - fs.rmSync(BASE_DIR, { recursive: true, force: true }); - } catch { - // best-effort - } -}); - -// --------------------------------------------------------------------------- -// getCredentialsPath respects APRA_FLEET_DATA_DIR at call time -// --------------------------------------------------------------------------- - -describe('getCredentialsPath: call-time env var resolution', () => { - it('writes credentials.json under APRA_FLEET_DATA_DIR when set', () => { - const dir = makeDir('dir-a'); - process.env.APRA_FLEET_DATA_DIR = dir; - - const name = `path_test_${Date.now()}`; - credentialSet(name, 'value', true, 'allow'); - - expect(fs.existsSync(credentialsFile(dir))).toBe(true); - const contents = JSON.parse(fs.readFileSync(credentialsFile(dir), 'utf-8')); - expect(contents.credentials[name]).toBeDefined(); - }); - - it('changing APRA_FLEET_DATA_DIR mid-process redirects subsequent writes', () => { - const dir1 = makeDir('dir-b1'); - const dir2 = makeDir('dir-b2'); - const name1 = `path_test_b1_${Date.now()}`; - const name2 = `path_test_b2_${Date.now()}`; - - process.env.APRA_FLEET_DATA_DIR = dir1; - credentialSet(name1, 'v1', true, 'allow'); - - process.env.APRA_FLEET_DATA_DIR = dir2; - credentialSet(name2, 'v2', true, 'allow'); - - // dir1 has name1 only - const c1 = JSON.parse(fs.readFileSync(credentialsFile(dir1), 'utf-8')); - expect(c1.credentials[name1]).toBeDefined(); - expect(c1.credentials[name2]).toBeUndefined(); - - // dir2 has name2 only - const c2 = JSON.parse(fs.readFileSync(credentialsFile(dir2), 'utf-8')); - expect(c2.credentials[name2]).toBeDefined(); - expect(c2.credentials[name1]).toBeUndefined(); - }); - - it('credential set in dir-A is not visible when reading from dir-B', () => { - const dirA = makeDir('dir-c-a'); - const dirB = makeDir('dir-c-b'); - const name = `path_test_c_${Date.now()}`; - - process.env.APRA_FLEET_DATA_DIR = dirA; - credentialSet(name, 'secret', true, 'allow'); - - process.env.APRA_FLEET_DATA_DIR = dirB; - const result = credentialResolve(name); - // Session store may still hold it, but persistent store from dir-B is empty - // Persistent takes precedence; since dir-B has no such credential, result - // should be from session tier (if any) or null. - // We care that dir-B's credentials.json does NOT contain this credential. - expect(fs.existsSync(credentialsFile(dirB))).toBe(false); - }); - - it('creates the data directory if it does not exist', () => { - const newDir = path.join(BASE_DIR, 'dir-autocreate', 'nested'); - // Don't pre-create it - process.env.APRA_FLEET_DATA_DIR = newDir; - - const name = `path_test_autocreate_${Date.now()}`; - credentialSet(name, 'value', true, 'allow'); - - expect(fs.existsSync(newDir)).toBe(true); - expect(fs.existsSync(credentialsFile(newDir))).toBe(true); - }); -}); - -// --------------------------------------------------------------------------- -// loadCredentialFile / saveCredentialFile both use getCredentialsPath() -// (ensures no duplicate env-var reads remain) -// --------------------------------------------------------------------------- - -describe('credential-store: read and write use same path', () => { - it('credentialSet (persist) then credentialResolve reads back from same dir', () => { - const dir = makeDir('dir-d'); - process.env.APRA_FLEET_DATA_DIR = dir; - - const name = `path_test_d_${Date.now()}`; - credentialSet(name, 'round-trip-value', true, 'allow'); - - const result = credentialResolve(name); - expect(result).not.toBeNull(); - expect('plaintext' in result!).toBe(true); - if (result && 'plaintext' in result) { - expect(result.plaintext).toBe('round-trip-value'); - } - credentialDelete(name); - }); - - it('credentialList reads from APRA_FLEET_DATA_DIR', () => { - const dir = makeDir('dir-e'); - process.env.APRA_FLEET_DATA_DIR = dir; - - const name = `path_test_e_${Date.now()}`; - credentialSet(name, 'value', true, 'allow'); - - const list = credentialList(); - const found = list.find(e => e.name === name); - expect(found).toBeDefined(); - expect(found!.scope).toBe('persistent'); - credentialDelete(name); - }); - - it('credentialDelete removes from APRA_FLEET_DATA_DIR', () => { - const dir = makeDir('dir-f'); - process.env.APRA_FLEET_DATA_DIR = dir; - - const name = `path_test_f_${Date.now()}`; - credentialSet(name, 'value', true, 'allow'); - expect(credentialResolve(name)).not.toBeNull(); - - credentialDelete(name); - - const file = JSON.parse(fs.readFileSync(credentialsFile(dir), 'utf-8')); - expect(file.credentials[name]).toBeUndefined(); - }); -}); diff --git a/tests/credential-store-set.test.ts b/tests/credential-store-set.test.ts index 20884b39..1b551270 100644 --- a/tests/credential-store-set.test.ts +++ b/tests/credential-store-set.test.ts @@ -3,16 +3,16 @@ import os from 'node:os'; import fs from 'node:fs'; import path from 'node:path'; import { credentialStoreSet } from '../src/tools/credential-store-set.js'; -import * as authSocket from '../src/services/auth-socket.js'; +import * as authSocket from 'blindfold'; import * as logHelpers from '../src/utils/log-helpers.js'; -import { credentialResolve, credentialDelete } from '../src/services/credential-store.js'; -import { encryptPassword } from '../src/utils/crypto.js'; +import { credentialResolve, credentialDelete, encryptPassword } from 'blindfold'; const TEST_DATA_DIR = path.join(os.tmpdir(), `fleet-test-cred-set-${Date.now()}`); -vi.mock('../src/services/auth-socket.js', () => ({ - collectOobApiKey: vi.fn(), -})); +vi.mock('blindfold', async () => { + const actual = await vi.importActual('blindfold'); + return { ...actual, collectOobApiKey: vi.fn() }; +}); vi.mock('../src/utils/log-helpers.js', () => ({ logLine: vi.fn(), diff --git a/tests/credential-store-update.test.ts b/tests/credential-store-update.test.ts index 3193a587..749197de 100644 --- a/tests/credential-store-update.test.ts +++ b/tests/credential-store-update.test.ts @@ -4,7 +4,7 @@ import { credentialDelete, credentialResolve, credentialUpdate, -} from '../src/services/credential-store.js'; +} from 'blindfold'; import { credentialStoreUpdate } from '../src/tools/credential-store-update.js'; // Clean up test credentials after each test diff --git a/tests/credential-validation.test.ts b/tests/credential-validation.test.ts deleted file mode 100644 index d1ae5d56..00000000 --- a/tests/credential-validation.test.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { describe, it, expect, vi, afterEach } from 'vitest'; -import { validateCredentials, credentialStatusNote } from '../src/utils/credential-validation.js'; - -function makeCreds(overrides: Record = {}) { - return JSON.stringify({ - claudeAiOauth: { - accessToken: 'sk-ant-oat01-test', - expiresAt: new Date(Date.now() + 7200000).toISOString(), // 2 hours - refreshToken: 'rt-test', - ...overrides, - }, - }); -} - -describe('validateCredentials', () => { - afterEach(() => { vi.useRealTimers(); }); - - it('returns valid for a token with >= 1 hour left', () => { - expect(validateCredentials(makeCreds())).toEqual({ status: 'valid' }); - - // Boundary: exactly 1 hour = threshold, msLeft < threshold is false → valid - vi.useFakeTimers(); - vi.setSystemTime(new Date('2025-01-01T00:00:00Z')); - const creds = makeCreds({ expiresAt: '2025-01-01T01:00:00Z' }); - expect(validateCredentials(creds)).toEqual({ status: 'valid' }); - }); - - it('returns near-expiry at 59 minutes', () => { - vi.useFakeTimers(); - vi.setSystemTime(new Date('2025-01-01T00:00:00Z')); - const creds = makeCreds({ expiresAt: '2025-01-01T00:59:00Z' }); - expect(validateCredentials(creds)).toEqual({ status: 'near-expiry', minutesLeft: 59 }); - }); - - it('returns near-expiry with 1 minute left', () => { - vi.useFakeTimers(); - vi.setSystemTime(new Date('2025-01-01T00:00:00Z')); - const creds = makeCreds({ expiresAt: '2025-01-01T00:00:30Z' }); - expect(validateCredentials(creds)).toEqual({ status: 'near-expiry', minutesLeft: 1 }); - }); - - it('returns expired-refreshable when expired with refresh token', () => { - const creds = makeCreds({ expiresAt: '2020-01-01T00:00:00Z', refreshToken: 'rt-xxx' }); - expect(validateCredentials(creds)).toEqual({ status: 'expired-refreshable' }); - }); - - it('returns expired-no-refresh when expired without refresh token', () => { - const creds = makeCreds({ expiresAt: '2020-01-01T00:00:00Z', refreshToken: undefined }); - expect(validateCredentials(creds)).toEqual({ status: 'expired-no-refresh' }); - }); - - it('returns expired-no-refresh at exactly 0ms left', () => { - vi.useFakeTimers(); - vi.setSystemTime(new Date('2025-01-01T00:00:00Z')); - const creds = makeCreds({ expiresAt: '2025-01-01T00:00:00Z', refreshToken: undefined }); - expect(validateCredentials(creds)).toEqual({ status: 'expired-no-refresh' }); - }); - - it('returns null for invalid or incomplete input', () => { - expect(validateCredentials('not json')).toBeNull(); - expect(validateCredentials('{}')).toBeNull(); - expect(validateCredentials(JSON.stringify({ claudeAiOauth: { accessToken: 'x' } }))).toBeNull(); - }); -}); - -describe('credentialStatusNote', () => { - it('returns empty for valid or null', () => { - expect(credentialStatusNote({ status: 'valid' })).toBe(''); - expect(credentialStatusNote(null)).toBe(''); - }); - - it('includes minutes for near-expiry', () => { - const note = credentialStatusNote({ status: 'near-expiry', minutesLeft: 15 }); - expect(note).toContain('expires in ~15 minutes'); - expect(note).toContain('/login'); - }); - - it('uses singular for 1 minute', () => { - const note = credentialStatusNote({ status: 'near-expiry', minutesLeft: 1 }); - expect(note).toContain('~1 minute'); - expect(note).not.toContain('minutes'); - }); - - it('mentions auto-refresh for expired-refreshable', () => { - const note = credentialStatusNote({ status: 'expired-refreshable' }); - expect(note).toContain('auto-refresh'); - }); - - it('mentions /login for expired-no-refresh', () => { - const note = credentialStatusNote({ status: 'expired-no-refresh' }); - expect(note).toContain('/login'); - expect(note).toContain('expired'); - }); -}); diff --git a/tests/crypto.test.ts b/tests/crypto.test.ts deleted file mode 100644 index 177cffa6..00000000 --- a/tests/crypto.test.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import fs from 'node:fs'; -import path from 'node:path'; -import { encryptPassword, decryptPassword } from '../src/utils/crypto.js'; -import { FLEET_DIR } from '../src/paths.js'; - -const KEY_PATH = path.join(FLEET_DIR, 'salt'); - -describe('crypto', () => { - it('encrypts and decrypts a password round-trip', () => { - const original = 'my-secret-password-123!@#'; - const encrypted = encryptPassword(original); - expect(decryptPassword(encrypted)).toBe(original); - }); - - it('produces different ciphertexts for the same plaintext (random IV)', () => { - const password = 'same-password'; - const enc1 = encryptPassword(password); - const enc2 = encryptPassword(password); - - expect(enc1).not.toBe(enc2); - expect(decryptPassword(enc1)).toBe(password); - expect(decryptPassword(enc2)).toBe(password); - }); - - it('handles edge cases: empty string and unicode', () => { - expect(decryptPassword(encryptPassword(''))).toBe(''); - const unicode = '密码パスワード🔑'; - expect(decryptPassword(encryptPassword(unicode))).toBe(unicode); - }); - - it('throws on tampered ciphertext', () => { - const encrypted = encryptPassword('secret'); - const parts = encrypted.split(':'); - const firstByte = parseInt(parts[2].slice(0, 2), 16); - const tamperedByte = ((firstByte ^ 0xff) || 0x01).toString(16).padStart(2, '0'); - parts[2] = tamperedByte + parts[2].slice(2); - expect(() => decryptPassword(parts.join(':'))).toThrow(); - }); - - it('creates and reuses a per-installation key file', () => { - // First encryption call creates the key file if it does not exist - encryptPassword('init'); - - expect(fs.existsSync(KEY_PATH)).toBe(true); - const key1 = fs.readFileSync(KEY_PATH, 'utf-8').trim(); - expect(key1).toHaveLength(64); // 32 random bytes, hex-encoded - expect(/^[0-9a-f]+$/.test(key1)).toBe(true); - - // Key stays consistent across subsequent calls - const encrypted = encryptPassword('test-consistent'); - const key2 = fs.readFileSync(KEY_PATH, 'utf-8').trim(); - expect(key1).toBe(key2); - expect(decryptPassword(encrypted)).toBe('test-consistent'); - }); -}); diff --git a/tests/integration/session-lifecycle.test.ts b/tests/integration/session-lifecycle.test.ts index 7021759a..c1ae63f7 100644 --- a/tests/integration/session-lifecycle.test.ts +++ b/tests/integration/session-lifecycle.test.ts @@ -5,7 +5,7 @@ import { addAgent } from '../../src/services/registry.js'; import { executePrompt } from '../../src/tools/execute-prompt.js'; import { stopPrompt } from '../../src/tools/stop-prompt.js'; import { getStoredPid, clearStoredPid, setStoredPid } from '../../src/utils/agent-helpers.js'; -import { launchAuthTerminal, isSSHSession } from '../../src/services/auth-socket.js'; +import { launchAuthTerminal, isSSHSession } from 'blindfold'; import { getStrategy } from '../../src/services/strategy.js'; import type { Agent, SSHExecResult } from '../../src/types.js'; diff --git a/tests/provision-auth.test.ts b/tests/provision-auth.test.ts index 53426425..da082dc7 100644 --- a/tests/provision-auth.test.ts +++ b/tests/provision-auth.test.ts @@ -1,16 +1,31 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { makeTestAgent, backupAndResetRegistry, restoreRegistry } from './test-helpers.js'; import { addAgent } from '../src/services/registry.js'; -import { credentialSet, credentialDelete } from '../src/services/credential-store.js'; -import { encryptPassword } from '../src/utils/crypto.js'; +import { credentialSet, credentialDelete, encryptPassword } from 'blindfold'; import { provisionAuth } from '../src/tools/provision-auth.js'; import type { SSHExecResult } from '../src/types.js'; const mockCollectOobApiKey = vi.fn<(memberName: string, toolName: string, opts?: any) => Promise<{ password?: string; fallback?: string }>>(); -vi.mock('../src/services/auth-socket.js', () => ({ - collectOobApiKey: (memberName: string, toolName: string, opts?: any) => mockCollectOobApiKey(memberName, toolName, opts), -})); +vi.mock('blindfold', async () => { + const actual = await vi.importActual('blindfold'); + return { + ...actual, + collectOobApiKey: (memberName: string, toolName: string, opts?: any) => mockCollectOobApiKey(memberName, toolName, opts), + // Claude credentials file nests token inside claudeAiOauth; blindfold's generic + // validateCredentials looks at top-level expiresAt so we shadow it here. + validateCredentials: (json: string) => { + let parsed: any; + try { parsed = JSON.parse(json); } catch { return null; } + const oauth = parsed?.claudeAiOauth; + if (!oauth?.expiresAt) return null; + const msLeft = new Date(oauth.expiresAt).getTime() - Date.now(); + if (msLeft <= 0) return oauth.refreshToken ? { status: 'expired-refreshable' } : { status: 'expired-no-refresh' }; + const NEAR = 60 * 60 * 1000; + return msLeft < NEAR ? { status: 'near-expiry', minutesLeft: Math.ceil(msLeft / 60000) } : { status: 'valid' }; + }, + }; +}); const mockExecCommand = vi.fn<(cmd: string, timeout?: number) => Promise>(); const mockTestConnection = vi.fn<() => Promise<{ ok: boolean; latencyMs: number; error?: string }>>(); diff --git a/tests/provision-vcs-auth.test.ts b/tests/provision-vcs-auth.test.ts index fc1317aa..e0767472 100644 --- a/tests/provision-vcs-auth.test.ts +++ b/tests/provision-vcs-auth.test.ts @@ -4,17 +4,17 @@ import path from 'node:path'; import os from 'node:os'; import { makeTestAgent, backupAndResetRegistry, restoreRegistry, FLEET_DIR } from './test-helpers.js'; import { addAgent, getAgent } from '../src/services/registry.js'; -import { credentialSet, credentialDelete } from '../src/services/credential-store.js'; -import { encryptPassword } from '../src/utils/crypto.js'; +import { credentialSet, credentialDelete, encryptPassword } from 'blindfold'; import { provisionVcsAuth } from '../src/tools/provision-vcs-auth.js'; import type { SSHExecResult } from '../src/types.js'; const GIT_CONFIG_PATH = path.join(FLEET_DIR, 'git-config.json'); const mockCollectOobApiKey = vi.fn<(memberName: string, toolName: string, opts?: any) => Promise<{ password?: string; fallback?: string }>>(); -vi.mock('../src/services/auth-socket.js', () => ({ - collectOobApiKey: (memberName: string, toolName: string, opts?: any) => mockCollectOobApiKey(memberName, toolName, opts), -})); +vi.mock('blindfold', async () => { + const actual = await vi.importActual('blindfold'); + return { ...actual, collectOobApiKey: (memberName: string, toolName: string, opts?: any) => mockCollectOobApiKey(memberName, toolName, opts) }; +}); const mockExecCommand = vi.fn<(cmd: string, timeout?: number) => Promise>(); const mockTestConnection = vi.fn<() => Promise<{ ok: boolean; latencyMs: number; error?: string }>>(); diff --git a/tests/register-member-oob.test.ts b/tests/register-member-oob.test.ts index e6e40e88..5165ce49 100644 --- a/tests/register-member-oob.test.ts +++ b/tests/register-member-oob.test.ts @@ -1,8 +1,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { backupAndResetRegistry, restoreRegistry } from './test-helpers.js'; import { registerMember } from '../src/tools/register-member.js'; -import { encryptPassword } from '../src/utils/crypto.js'; -import { credentialResolve, credentialDelete } from '../src/services/credential-store.js'; +import { encryptPassword, credentialResolve, credentialDelete } from 'blindfold'; import type { SSHExecResult } from '../src/types.js'; const mockExecCommand = vi.fn<(cmd: string, timeout?: number) => Promise>(); @@ -24,10 +23,14 @@ vi.mock('../src/services/statusline.js', () => ({ const mockCollectOobPassword = vi.fn<(name: string, tool: string, opts?: any) => Promise<{ password?: string; fallback?: string; persist?: boolean }>>(); const mockCollectOobApiKey = vi.fn<(name: string, tool: string, opts?: any) => Promise<{ password?: string; fallback?: string; persist?: boolean }>>(); -vi.mock('../src/services/auth-socket.js', () => ({ - collectOobPassword: (name: string, tool: string, opts?: any) => mockCollectOobPassword(name, tool, opts), - collectOobApiKey: (name: string, tool: string, opts?: any) => mockCollectOobApiKey(name, tool, opts), -})); +vi.mock('blindfold', async () => { + const actual = await vi.importActual('blindfold'); + return { + ...actual, + collectOobPassword: (name: string, tool: string, opts?: any) => mockCollectOobPassword(name, tool, opts), + collectOobApiKey: (name: string, tool: string, opts?: any) => mockCollectOobApiKey(name, tool, opts), + }; +}); // --------------------------------------------------------------------------- // Test 3: Anonymous OOB use-and-throw diff --git a/tests/secret-cli.test.ts b/tests/secret-cli.test.ts index 0d2a63e7..b3bd9709 100644 --- a/tests/secret-cli.test.ts +++ b/tests/secret-cli.test.ts @@ -34,20 +34,18 @@ vi.mock('node:readline', () => ({ default: { createInterface: mockReadlineCreateInterface }, })); -vi.mock('../src/utils/collect-secret.js', () => ({ - collectSecret: mockCollectSecret, -})); - -vi.mock('../src/services/auth-socket.js', () => ({ - getSocketPath: vi.fn().mockReturnValue('/tmp/apra-fleet-test.sock'), -})); - -vi.mock('../src/services/credential-store.js', () => ({ - credentialList: mockCredentialList, - credentialDelete: mockCredentialDelete, - credentialSet: mockCredentialSet, - credentialUpdate: mockCredentialUpdate, -})); +vi.mock('blindfold', async () => { + const actual = await vi.importActual('blindfold'); + return { + ...actual, + collectSecret: mockCollectSecret, + getSocketPath: vi.fn().mockReturnValue('/tmp/apra-fleet-test.sock'), + credentialList: mockCredentialList, + credentialDelete: mockCredentialDelete, + credentialSet: mockCredentialSet, + credentialUpdate: mockCredentialUpdate, + }; +}); import { runSecret } from '../src/cli/secret.js'; diff --git a/tests/security-hardening.test.ts b/tests/security-hardening.test.ts index 8b35cd95..40a62c75 100644 --- a/tests/security-hardening.test.ts +++ b/tests/security-hardening.test.ts @@ -6,7 +6,7 @@ import { monitorTaskSchema } from '../src/tools/monitor-task.js'; import { addAgent, getAllAgents } from '../src/services/registry.js'; import { LinuxCommands } from '../src/os/linux.js'; import { WindowsCommands } from '../src/os/windows.js'; -import { encryptPassword, decryptPassword } from '../src/utils/crypto.js'; +import { encryptPassword, decryptPassword } from 'blindfold'; import { makeTestAgent, REGISTRY_PATH, backupAndResetRegistry, restoreRegistry } from './test-helpers.js'; // --- Item 1: Registry file permissions --- diff --git a/tests/setup-git-app.test.ts b/tests/setup-git-app.test.ts index b0d349e2..ce92d7d0 100644 --- a/tests/setup-git-app.test.ts +++ b/tests/setup-git-app.test.ts @@ -4,7 +4,7 @@ import path from 'node:path'; import os from 'node:os'; import crypto from 'node:crypto'; import { FLEET_DIR } from './test-helpers.js'; -import { credentialSet, credentialDelete } from '../src/services/credential-store.js'; +import { credentialSet, credentialDelete } from 'blindfold'; import { setupGitApp } from '../src/tools/setup-git-app.js'; const GIT_CONFIG_PATH = path.join(FLEET_DIR, 'git-config.json'); const STORED_KEY_PATH = path.join(FLEET_DIR, 'github-app.pem'); diff --git a/tests/setup.ts b/tests/setup.ts index c8fa0445..7b4c1a03 100644 --- a/tests/setup.ts +++ b/tests/setup.ts @@ -1,5 +1,23 @@ import path from 'node:path'; import os from 'node:os'; +// APRA_FLEET_DATA_DIR is set by vitest.config.ts via both top-level +// process.env mutation AND the test.env config option. Both must point +// at a tmp dir; if either is missing or names the real home dir, abort +// the run BEFORE any test code can write to ~/.apra-fleet/data. +const expectedTestDir = path.join(os.tmpdir(), 'apra-fleet-test-data'); +const actual = process.env.APRA_FLEET_DATA_DIR; +if (!actual || actual !== expectedTestDir) { + // eslint-disable-next-line no-console + console.error( + `[test-setup] FATAL: APRA_FLEET_DATA_DIR is "${actual ?? ''}", expected "${expectedTestDir}". ` + + `Refusing to run - tests would write to the real fleet data dir. ` + + `Check vitest.config.ts top-level env wiring.`, + ); + process.exit(2); +} + process.env.NODE_ENV = 'test'; -process.env.APRA_FLEET_DATA_DIR = path.join(os.tmpdir(), 'apra-fleet-test-data'); + +import { initFleetBlindfold } from '../src/services/blindfold-init.js'; +initFleetBlindfold(); diff --git a/tests/shell-escape.test.ts b/tests/shell-escape.test.ts deleted file mode 100644 index 1ed29324..00000000 --- a/tests/shell-escape.test.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { - escapeShellArg, - escapeDoubleQuoted, - escapeWindowsArg, - escapeGrepPattern, - sanitizeSessionId, -} from '../src/utils/shell-escape.js'; - -describe('escapeShellArg', () => { - it('wraps in single quotes and escapes embedded single quotes', () => { - expect(escapeShellArg('hello')).toBe("'hello'"); - expect(escapeShellArg("it's")).toBe("'it'\\''s'"); - expect(escapeShellArg("a'b'c")).toBe("'a'\\''b'\\''c'"); - expect(escapeShellArg('say "hi"')).toBe("'say \"hi\"'"); - }); - - it('neutralizes command injection attempts', () => { - expect(escapeShellArg('$(whoami)')).toBe("'$(whoami)'"); - expect(escapeShellArg('`rm -rf /`')).toBe("'`rm -rf /`'"); - }); -}); - -describe('escapeDoubleQuoted', () => { - it('escapes all double-quote-special characters', () => { - const input = 'a\\b"c$d`e!f'; - const escaped = escapeDoubleQuoted(input); - expect(escaped).toBe('a\\\\b\\"c\\$d\\`e\\!f'); - }); - - it('neutralizes injection attempts', () => { - const injection = '"; rm -rf / #'; - const escaped = escapeDoubleQuoted(injection); - expect(escaped.startsWith('\\"')).toBe(true); - - const cmdSub = '$(cat /etc/passwd)'; - expect(escapeDoubleQuoted(cmdSub)).toBe('\\$(cat /etc/passwd)'); - }); - - it('leaves safe strings unchanged', () => { - expect(escapeDoubleQuoted('hello world')).toBe('hello world'); - }); -}); - -describe('escapeWindowsArg', () => { - it('escapes all cmd.exe metacharacters', () => { - const input = 'a"b&c|d^eg'; - expect(escapeWindowsArg(input)).toBe('a""b^&c^|d^^e^g'); - }); - - it('neutralizes Windows injection attempts', () => { - expect(escapeWindowsArg('"&whoami&"')).toBe('""^&whoami^&""'); - }); -}); - -describe('escapeGrepPattern', () => { - it('escapes all regex metacharacters individually', () => { - // Each of these characters MUST be escaped with a backslash - const chars = '.*+?^${}()|[]\\'.split(''); - for (const char of chars) { - const escaped = escapeGrepPattern(char); - expect(escaped).toBe('\\' + char); - } - }); - - it('escapes a complex regex string correctly', () => { - const input = 'a.*b+c?d^e$f{g}h(i|j)k[l]m\\n'; - const escaped = escapeGrepPattern(input); - expect(escaped).toBe('a\\.\\*b\\+c\\?d\\^e\\$f\\{g\\}h\\(i\\|j\\)k\\[l\\]m\\\\n'); - }); - - it('leaves path-like strings unchanged', () => { - expect(escapeGrepPattern('/home/user/project')).toBe('/home/user/project'); - }); - - it('escapes Windows backslash paths', () => { - expect(escapeGrepPattern('C:\\Users\\dev')).toBe('C:\\\\Users\\\\dev'); - }); -}); - -describe('sanitizeSessionId', () => { - it('accepts valid session IDs', () => { - expect(sanitizeSessionId('abc-123-def')).toBe('abc-123-def'); - expect(sanitizeSessionId('session_abc-123')).toBe('session_abc-123'); - expect(sanitizeSessionId('12345')).toBe('12345'); - }); - - it('rejects IDs with dangerous characters', () => { - expect(() => sanitizeSessionId('abc;whoami')).toThrow('Invalid session ID'); - expect(() => sanitizeSessionId('abc$(cmd)')).toThrow('Invalid session ID'); - expect(() => sanitizeSessionId('abc`cmd`')).toThrow('Invalid session ID'); - expect(() => sanitizeSessionId('abc"def')).toThrow('Invalid session ID'); - expect(() => sanitizeSessionId("abc'def")).toThrow('Invalid session ID'); - expect(() => sanitizeSessionId('abc/def')).toThrow('Invalid session ID'); - expect(() => sanitizeSessionId('abc\\def')).toThrow('Invalid session ID'); - }); - - it('rejects empty string', () => { - expect(() => sanitizeSessionId('')).toThrow('Invalid session ID'); - }); -}); diff --git a/tests/tool-provider.test.ts b/tests/tool-provider.test.ts index b65fdf74..c1147de7 100644 --- a/tests/tool-provider.test.ts +++ b/tests/tool-provider.test.ts @@ -32,9 +32,13 @@ vi.mock('../src/services/strategy.js', () => ({ })); const mockCollectOobApiKey = vi.fn<() => Promise<{ password: string } | { fallback: string }>>(); -vi.mock('../src/services/auth-socket.js', () => ({ - collectOobApiKey: (...args: unknown[]) => mockCollectOobApiKey(...(args as [])), -})); +vi.mock('blindfold', async () => { + const actual = await vi.importActual('blindfold'); + return { + ...actual, + collectOobApiKey: (...args: unknown[]) => mockCollectOobApiKey(...(args as [])), + }; +}); // --------------------------------------------------------------------------- // execute-prompt: each provider parses its own response format diff --git a/tests/update-member.test.ts b/tests/update-member.test.ts index 8ca38284..66735bc0 100644 --- a/tests/update-member.test.ts +++ b/tests/update-member.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { makeTestAgent, makeTestLocalAgent, backupAndResetRegistry, restoreRegistry } from './test-helpers.js'; import { addAgent } from '../src/services/registry.js'; import { updateMember } from '../src/tools/update-member.js'; -import { credentialSet, credentialDelete } from '../src/services/credential-store.js'; +import { credentialSet, credentialDelete } from 'blindfold'; describe('updateMember', () => { beforeEach(() => { diff --git a/vitest.config.ts b/vitest.config.ts index e034a051..bdd77f11 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,4 +1,17 @@ import { defineConfig } from 'vitest/config'; +import path from 'node:path'; +import os from 'node:os'; + +const TEST_DATA_DIR = path.join(os.tmpdir(), 'apra-fleet-test-data'); + +// Set APRA_FLEET_DATA_DIR HERE, at config load, before any test code runs. +// This guarantees paths.ts (which captures FLEET_DIR at module-load time) +// always sees the test dir, even if a test file's hoisted import chain +// pulls in paths.ts before tests/setup.ts gets to run its top-level code. +// Setting it only in setup.ts is racy under certain import orderings and +// can leak writes into ~/.apra-fleet/data. +process.env.APRA_FLEET_DATA_DIR = TEST_DATA_DIR; +process.env.NODE_ENV = 'test'; export default defineConfig({ test: { @@ -8,5 +21,9 @@ export default defineConfig({ exclude: ['tests/integration.test.ts'], setupFiles: ['tests/setup.ts'], fileParallelism: false, // Tests share registry.json in temp dir + env: { + APRA_FLEET_DATA_DIR: TEST_DATA_DIR, + NODE_ENV: 'test', + }, }, });