Files
fusion/docs/secrets.md
gsxdsm a426e28781 FN-9165: Add optional JIRA workspace branch derivation
Add opt-in JIRA-backed workspace branch naming while preserving editable manual branch workflows.

- add scoped JIRA configuration, secret resolution, and documented inheritance
- expose a bounded JIRA issue client and branch-derivation API route
- add settings and task-form controls with recoverable errors and coverage
- derive sanitized branch names from configurable issue-key and summary templates

Files changed:
 .changeset/fn-9165-jira-branch-name.md             |   7 ++
 docs/secrets.md                                    |   4 +
 docs/settings-reference.md                         |  18 ++++
 docs/workspaces.md                                 |   4 +
 packages/core/src/__tests__/jira-config.test.ts    |   6 ++
 .../core/src/__tests__/settings-parity.test.ts     |  12 +++
 packages/core/src/config/settings-schema.ts        |  14 +++
 packages/core/src/index.ts                         |   2 +
 packages/core/src/jira/index.ts                    |   1 +
 packages/core/src/jira/jira-config.ts              |  34 +++++++
 packages/core/src/types/settings/settings-scope.ts |  16 ++++
 .../app/__tests__/settings-save-split.test.ts      |  43 +++++++++
 .../dashboard/app/components/SettingsModal.tsx     |  29 +++++-
 packages/dashboard/app/components/TaskForm.tsx     |  23 ++++-
 .../app/components/__tests__/TaskForm.test.tsx     |  31 +++++++
 .../settings/__tests__/section-keys.test.ts        |   2 +
 .../app/components/settings/save-split.ts          |  64 ++++++++++++-
 .../app/components/settings/section-keys.ts        |   1 +
 .../sections/SourceControlGlobalSection.search.ts  |   9 ++
 .../sections/SourceControlGlobalSection.tsx        |  20 ++++-
 .../sections/SourceControlSection.search.ts        |   9 ++
 .../settings/sections/SourceControlSection.tsx     |  12 +++
 .../settings-default-descriptions.test.tsx         |   7 ++
 .../dashboard/src/__tests__/jira-client.test.ts    |  58 ++++++++++++
 .../src/__tests__/register-jira-routes.test.ts     |  70 +++++++++++++++
 packages/dashboard/src/jira-auth.ts                |  18 ++++
 packages/dashboard/src/jira.ts                     |  75 ++++++++++++++++
 packages/dashboard/src/routes.ts                   |   2 +
 packages/dashboard/src/routes/README.md            | 100 +++++++++++----------
 .../src/routes/create-api-routes-mount-sequence.ts |   2 +-
 packages/dashboard/src/routes/register-jira.ts     |  66 ++++++++++++++
 packages/engine/src/index.ts                       |   2 +
 .../worktree/__tests__/jira-branch-name.test.ts    |   7 ++
 packages/engine/src/worktree/jira-branch-name.ts   |   6 ++
 packages/i18n/locales/en/app.json                  |  23 +++++
 35 files changed, 739 insertions(+), 58 deletions(-)

Fusion-Task-Id: FN-9165

Fusion-Task-Lineage: 88ab5fa3-f66e-421d-8139-5c3e8917348d

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
2026-08-19 22:44:46 -07:00

247 lines
20 KiB
Markdown

# Secrets
[← Docs index](./README.md)
## Overview
Fusion's secrets subsystem provides encrypted-at-rest secret storage in PostgreSQL, with project scope in `project.secrets` and global scope in `central.secrets_global`.
## Implementation Status
| Surface | Status | Follow-up | Source of truth |
|---|---|---|---|
| AES-256-GCM encryption primitives | Shipped | — | `packages/core/src/secrets-crypto.ts` |
| `SecretsStore` CRUD + `revealSecret` | Shipped | — | `packages/core/src/secrets-store.ts` |
| Per-secret access policy + global fallback resolver | Shipped | — | `packages/core/src/secret-access-policy.ts` |
| `MasterKeyManager` (keychain primary, file fallback) | Shipped | — | `packages/core/src/master-key.ts` |
| `fn_secret_get` pi-extension tool (auto/prompt/deny + missing-key) | Shipped | — | `packages/cli/src/extension.ts` |
| `secret:read` / `secret:approval-requested` / `secret:approval-denied` audit events | Shipped | — | `packages/cli/src/extension.ts` (`emitSecretAudit`) |
| Approval API integration for `prompt` policy (`ApprovalRequestStore` + `POST /api/approvals/:id/decision`) | Shipped | — | `packages/cli/src/extension.ts` |
| `secrets-sync.ts` wrap/unwrap core (scrypt → AES-256-GCM, version 1, typed errors) | Shipped | — | `packages/core/src/secrets-sync.ts` |
| Dashboard `SecretsView` CRUD UI | Shipped | — | `packages/dashboard/app/components/SecretsView.tsx` |
| `secretsEnv.*` settings + worktree `.env` materialization + fingerprint cleanup | Shipped | — | `packages/core/src/types.ts`, `packages/engine/src/secrets-env-writer.ts` |
| `secretsSyncPassphraseConfigured` global read-only probe + reserved secret storage (`__sync_passphrase__`) | Shipped | — | `packages/core/src/types.ts`, `packages/core/src/secrets-sync-passphrase.ts` |
| Cross-node sync REST endpoints (`/api/nodes/:id/secrets/{push,pull}`, `/api/secrets/sync-receive`, `/api/secrets/sync-export`) | Shipped | — | `packages/dashboard/src/routes/register-secrets-sync-routes.ts`, `packages/dashboard/src/routes/register-secrets-sync-inbound-routes.ts` |
| Audit-event registration on `FilesystemMutationType` for `secret:env-*` and `secret:sync-*` | Shipped | — | `packages/engine/src/run-audit.ts` |
| Master-key rotation UX | Pending | — | n/a |
| Per-secret TTL / rotation, KMS/Vault backends, per-node asymmetric sync | Out of scope | — | n/a |
Current shipped behavior in this branch includes:
- AES-256-GCM encryption primitives (`packages/core/src/secrets-crypto.ts`)
- CRUD + reveal APIs via `SecretsStore` (`packages/core/src/secrets-store.ts`)
- Per-secret access policy metadata (`auto` / `prompt` / `deny`)
- Schema-backed read metadata (`last_read_at`, `last_read_by`)
Threat-model baseline:
- Secret plaintext is **not** stored in PostgreSQL.
- Ciphertext + nonce are persisted; plaintext exists only in process memory during create/reveal.
- Secret values must never be logged.
- MCP server settings store only secret references for sensitive env/header/token fields; imports surface plaintext as secret-creation descriptors instead of persisting it in settings.
- MCP server secret references are materialized only at session/probe creation time for MCP-capable AI lanes and `POST /api/mcp/validate`; responses and structured logs include status/count metadata only, never resolved env/header values.
See also: [Storage](./storage.md), [Multi-project](./multi-project.md), [Architecture](./architecture.md), [Settings reference](./settings-reference.md), and [MCP](./mcp.md) for MCP-specific secret-reference workflows.
## Architecture
Fusion stores secrets in two PostgreSQL tables:
- Project scope: `project.secrets`, isolated by project identity
- Global scope: `central.secrets_global`
Both tables share the same column contract:
| Column | Type | Notes |
|---|---|---|
| `id` | `TEXT` | Primary key UUID. |
| `key` | `TEXT` | Unique secret key (`idxSecretsKey` / `idxSecretsGlobalKey`). |
| `value_ciphertext` | `BYTEA` | AES-GCM ciphertext payload (includes auth tag). |
| `nonce` | `BYTEA` | Per-row random nonce. |
| `description` | `TEXT` | Optional metadata. |
| `access_policy` | `TEXT` | `CHECK` constrained to `auto`, `prompt`, `deny`. |
| `env_exportable` | `INTEGER` | `0/1` flag for env-materialization intent metadata. |
| `env_export_key` | `TEXT` | Optional env variable key metadata. |
| `created_at` | `TEXT` | ISO timestamp. |
| `updated_at` | `TEXT` | ISO timestamp. |
| `last_read_at` | `TEXT` | Last reveal timestamp. |
| `last_read_by` | `TEXT` | Agent/user identifier recorded on reveal. |
For broader database inventory, see [docs/storage.md](./storage.md).
## Encryption
Secret crypto uses AES-256-GCM with:
- 32-byte master key
- 12-byte random nonce per encrypt operation
- 16-byte auth tag appended to ciphertext
Implementation reference: `packages/core/src/secrets-crypto.ts`.
## Master Key Resolution
The current implementation exposes a `MasterKeyProvider` abstraction consumed by `createSecretCipher` / `SecretsStore`.
- Required contract: async provider that returns a **32-byte** key.
- Validation failures return non-sensitive `SecretCryptoError` codes.
Runtime keychain/filesystem resolution is shipped via `MasterKeyManager` (`packages/core/src/master-key.ts`) with keychain-primary lookup and `~/.fusion/master.key` fallback (mode `0600`); rotation UX remains follow-up work.
## Access Policies
Per-secret policy values are:
- `auto`
- `prompt`
- `deny`
Resolution helper (`resolveSecretAccessPolicy`) uses:
1. Row-level secret policy (if set)
2. Global settings default `secretsAccessPolicy` (if set)
3. Fallback: `prompt`
Implementation references:
- `packages/core/src/secret-access-policy.ts`
- `packages/core/src/types.ts` (`GlobalSettings.secretsAccessPolicy`)
Approval integration is active through `fn_secret_get` policy handling (`packages/cli/src/extension.ts:1581-1611`) and approvals lifecycle APIs.
## Dashboard CRUD
Dashboard secrets CRUD is shipped via `SecretsView` (`packages/dashboard/app/components/SecretsView.tsx`), backed by the existing secrets API/store surfaces.
Dashboard requests carry the currently selected `projectId`. Secrets routes require that explicit request identity **before** resolving project context, so a missing, empty, or whitespace-only id is rejected with HTTP 400 instead of selecting the daemon launch directory's fallback store. The selected id intentionally binds the project store: project-scoped rows use that project's RLS-protected `project.secrets` partition, while global-scoped rows still use shared `central.secrets_global` and remain visible from every selected project.
### Recovering pre-fix fallback rows
Older dashboard writes made without an explicit project id may be stranded in a `local-*` project partition associated with the daemon launch directory. Fusion does not move these rows automatically: their intended registered-project destination cannot be inferred safely. An operator who has independently identified the destination may reassign only the affected `project.secrets` rows using an audited database recovery procedure. Do not apply this procedure to `central.secrets_global`, and do not treat direct SQL reassignment as runtime architecture.
## Agent Access (`fn_secret_get`)
`fn_secret_get` is shipped in `packages/cli/src/extension.ts:1542-1629`.
Tool contract:
- Params: `key` (required), `scope?: "project" | "global"`.
- Resolution: when `scope` is omitted, lookup is project → global; when provided, only that scope is queried. Missing key returns `{ error: "not-found" }`.
- Policy outcomes:
- `auto` → reveals and returns plaintext value (`secret:read` audit at `extension.ts:1615`).
- `prompt` → creates `ApprovalRequestStore` request (`secret-read:{scope}:{key}:{agentId}` dedupe key) and returns `{ outcome: "pending_approval", approvalRequestId }` (`extension.ts:1607-1611`).
- `deny` → immediate refusal and `secret:approval-denied` audit (`extension.ts:1581-1583`).
## `.env` Auto-write into Worktrees
Fusion can materialize env-exportable secrets into each acquired task worktree when project settings enable it (`secretsEnv.enabled=true`).
- Supported settings: `enabled`, `filename` (default `.env`, validated as local filename only), `overwritePolicy` (`skip`/`merge`/`replace`), `keyPrefix`, `requireGitignored` (default `true`).
- Safety guard: when `requireGitignored` is enabled, Fusion runs `git check-ignore -- <filename>` and refuses writes unless the file is ignored.
- Write contract: managed content is canonicalized and written atomically with mode `0o600`; audit metadata includes keys and counts, never values.
- Fingerprint record: successful writes atomically persist `.fusion-secrets-env.fingerprint` containing `<sha256>\n<filename>\n` with mode `0o600` in the worktree's private Git directory (`git rev-parse --git-dir`), never in project content. This keeps Fusion bookkeeping out of porcelain status while teardown can verify file integrity before deletion.
- Legacy reconciliation: before a reused worktree refreshes, Fusion recognizes the exact v0.75.1 UTF-8 root wire format: `<64 lowercase SHA-256 hex>\n<valid filename>\n` (normally `<sha>\n.secrets.env\n`), with no marker or envelope. It writes and syncs a temporary private record, atomically renames it, and syncs the private Git directory before unlinking the root record; it then syncs the worktree root directory before declaring execution safe. Retry re-writes and syncs even a private-only record, so a readable record left after a failed post-rename write cannot bypass the private-directory durability barrier. Thus a crash before either barrier remains fail-closed and retries converge without losing the only validated cleanup authority. Identical private and legacy records reduce to the private record; a valid private record supersedes an absent or invalid legacy record. A malformed sole record, unavailable Git directory, directory-sync failure, tracked root record, or different valid records fails the refresh closed without deleting or overwriting records or the env file.
- Teardown cleanup: Fusion selects only one uniquely validated canonical record. It deletes the managed env file only when its fingerprint still matches and Git proves it is untracked; a missing env removes equivalent validated records, while a tracked or edited env, malformed record, or conflicting records are preserved for safety and diagnosis. Metadata removal or directory-sync failure is reported as a fixed non-success cleanup outcome rather than a false successful cleanup, so retry can reconcile the remaining authority.
Settings shape is split by scope: project-level secrets settings include `ProjectSettings.secretsEnv` and MCP secret references in `ProjectSettings.mcpServers`, while cross-node sync passphrase state is stored only as the reserved `__sync_passphrase__` row in `secrets_global` and exposed read-only through `GlobalSettings.secretsSyncPassphraseConfigured` (`packages/core/src/types.ts`). Settings never carry plaintext passphrases or MCP credentials; MCP env/header/token fields use `{ secretRef, scope }` and materialize through `SecretsStore.revealSecret(...)` only at the runtime use seam.
### Test locations
The settings contract (`SecretsEnvSettings` shape, defaults, project round-trip) is covered in `@fusion/core`:
- `packages/core/src/__tests__/secrets-env.test.ts` — type contract + defaults
- `packages/core/src/__tests__/store-settings.test.ts` — `secretsEnv` project round-trip
- `packages/core/src/__tests__/store-settings-sync-passphrase-probe.test.ts` — read-only `secretsSyncPassphraseConfigured` derivation + write-strip behavior
The materialization implementation lives in `@fusion/engine` and is covered there:
- `packages/engine/src/secrets-env-writer.ts` — `writeSecretsEnvFile` / `cleanupSecretsEnvFile`
- `packages/engine/src/__tests__/secrets-env-writer.test.ts` — writer/cleanup unit coverage
- `packages/engine/src/__tests__/worktree-acquisition-secrets-env.test.ts` — acquisition-time write
- `packages/engine/src/__tests__/worktree-pool-secrets-env-cleanup.test.ts` — pool prune cleanup
- `packages/engine/src/__tests__/reliability-interactions/secrets-env-materialization.test.ts` — cross-layer backstop
New FN tasks that need to verify env materialization should target the engine-side files; the core-side test only guards the settings contract.
## Cross-node Sync
Fusion now exposes four secrets sync endpoints:
- `POST /api/nodes/:id/secrets/push` — wraps local secrets into a passphrase-protected envelope and sends it to a remote node.
- `POST /api/nodes/:id/secrets/pull` — fetches a remote envelope from `GET /api/secrets/sync-export` and applies it locally.
- `POST /api/secrets/sync-receive` — inbound apply endpoint (Bearer `apiKey` required).
- `GET /api/secrets/sync-export` — inbound export endpoint (Bearer `apiKey` required).
Envelope format is `WrappedSecretsBundle` from `packages/core/src/secrets-sync.ts:33-38`: `{ version, ciphertext, salt, nonce, kdf, kdfParams }` plus transport metadata (`sourceNodeId`, `exportedAt`). Wrapping uses scrypt (`N=32768, r=8, p=1, keyLen=32`, `secrets-sync.ts:17-22`) and AES-256-GCM with base64 `ciphertext`/`salt`/`nonce` (`secrets-sync.ts:68-78`).
Sync passphrase storage is local-only: reserved key `__sync_passphrase__` in `secrets_global` with `access_policy="deny"` and `env_exportable=false`, encrypted under the local master key. The passphrase is never transmitted and never returned by HTTP endpoints.
Dashboard UX now exposes this through SecretsView → **Cross-Node Sync Passphrase**. The panel uses `GET/PUT/DELETE /api/secrets/sync-passphrase`; the GET route returns only `{ configured: boolean }` (no plaintext readback), and the reserved `__sync_passphrase__` row is filtered from the regular `GET /api/secrets` list.
Error mapping:
- `SecretsSyncError` codes (`wrong-passphrase`, `version-mismatch`, `malformed`) return HTTP `400` with `{ "error": <code> }`.
- Missing passphrase returns HTTP `400` with `{ "error": "passphrase-not-configured" }`.
- Bearer auth failures return HTTP `401`.
Inbound auth contract is enforced in route code (`packages/dashboard/src/routes/register-secrets-sync-inbound-routes.ts:99-114`, `:181-196`): missing/invalid Bearer `Authorization` or mismatched local `apiKey` returns 401.
Audit payloads exclude plaintext values, passphrases, and envelope crypto material (`ciphertext`, `salt`, `nonce`).
## Audit Events
Filesystem-domain secret audit taxonomy:
- `secret:read`
- `secret:create`
- `secret:update`
- `secret:delete`
- `secret:approval-requested`
- `secret:approval-granted`
- `secret:approval-denied`
- `secret:sync-push`
- `secret:sync-pull`
- `secret:env-write`
- `secret:env-write-skipped`
- `secret:env-cleanup`
- `secret:env-cleanup-skipped`
All listed events are enumerated in `packages/engine/src/run-audit.ts:261-274` (union at `run-audit.ts:325`). Route/tool emitters include: `secret:sync-push` (`packages/dashboard/src/routes/register-secrets-sync-routes.ts:92`), `secret:sync-pull` (`register-secrets-sync-routes.ts:180`, `register-secrets-sync-inbound-routes.ts:158-164`), `secret:read` + approval events (`packages/cli/src/extension.ts:1581-1615`), env materialization/cleanup (`packages/engine/src/secrets-env-writer.ts:99-217`).
Track follow-up: **FN-5031** (missing `packages/core/src/__tests__/secrets-env.test.ts` contract file).
**Plaintext prohibition:** audit payload metadata must never include plaintext, decrypted values, ciphertext, or nonce fields. Use `assertNoSecretPlaintext(...)` as the canonical enforcement helper before emitting secret audit events.
## Provider credential instances (`auth.json`)
Fusion keeps provider credentials in `~/.fusion/agent/auth.json`. A legacy bare key such as `"openrouter"` is the default instance; a named key is `"openrouter[work]"`. Provider and instance ids are non-empty, no-whitespace, no-bracket strings up to 64 characters. Existing bare entries remain readable and are not rewritten merely by reading them.
`__fusionDefaultInstances` is reserved metadata, never a credential. Its per-provider pointer wins only when it names an existing valid credential; resolution otherwise falls back to the bare key, then the lexicographically first named instance, then no instance (`getDefaultInstance` returns `undefined`). The metadata is untrusted: malformed records or stale entries are ignored without a read-time repair. Deleting a pointed-to instance removes that pointer in the same locked write.
Legacy string APIs parse this grammar rather than accepting raw keys. `set("provider", credential)` updates the resolved default, or creates the bare key when none exists; `remove`, `logout`, `removeInstance`, and `modify` are no-ops when no instance exists. `setDefaultInstance` never creates a credential and rejects a missing target. All mutators, including deletes, reject the reserved metadata key. `list()` and `getAll()` remain logical-provider keyed (one resolved credential per provider); use `listInstances()` for individual instances. On-disk records are untrusted and values are type-filtered as credentials, so metadata and malformed values are never returned. External Claude/Codex hydration intentionally consumes bare keys only and ignores named instance keys.
At session creation, an explicitly selected credential instance wins over the provider default only for that provider. A missing named instance falls back to the canonical provider default and is recorded in `session:runtime-resolved`; Fusion never substitutes an arbitrary instance. If there is no default, session resolution fails without exposing credential material. Omitted instance ids retain the legacy default-resolving behavior.
Dashboard auth routes accept an optional instance id; omitted or blank ids retain default-instance behavior. `GET /api/auth/status?provider=<id>&instance=<id>` keeps the full provider envelope but describes the requested instance at that provider entry. A valid missing id is an unauthenticated `200` result, never a fallback to another account. Credential-establishing login and API-key writes may create a supplied id; rename, default, logout, and delete require an existing id. OAuth binds the id and optional opaque label to its server-side flow state, so a callback cannot fall back to the default account. Instance listings expose only ids, labels, auth status, and masked key hints; raw key and token material never leaves storage. `removeInstance` deletes the credential row and its default participation, while credential clear only removes its usable credential state.
## Operational Notes
- Backups: preserve PostgreSQL project/central schemas and the master-key material/provider source used by the deployment. Retain legacy SQLite backups only as controlled migration/recovery inputs; they are not runtime authority.
- If master key material is lost, encrypted secret values become unrecoverable.
- Pending advanced capabilities:
- Master-key rotation UX and key lifecycle tooling
- TTL/rotation automation, env-set profiles, KMS/Vault backends, per-node asymmetric sync
## Organization bundles
`fn org-export <file>` creates a portable bundle for one selected project plus global
settings. Exports scrub credential values by default: provider keys, daemon and remote
access tokens, webhook secrets, and inline `secretsEnv` values are omitted. MCP and
other secret references remain key-only, so importing a bundle requires the destination
operator to provision the referenced secrets. `secretsAccessPolicy` and
`secretsSyncPassphraseConfigured` remain because they are configuration/state rather
than secret values.
### JIRA API token
Store a JIRA API token or PAT as `JIRA_API_TOKEN` (or the configured `jiraAuthTokenSecretKey`). Fusion checks project scope first and falls back to global scope. The token is revealed only in-process when deriving a branch name and is never logged or returned.