Credential security model

Status: authoritative reference for the Claude Code ↔ Nightjar credential/identity/OAuth integration (ADR-029). Audience: security reviewers and maintainers. Scope: the two credential planes, the credential-fill trust boundary, grant authority, the per-workspace OAuth Resource Server, the audit model, and the delicate invariants a future contributor must not break.

Read alongside:

  • ADR-029 — Claude Code ↔ Nightjar credential/identity/OAuth integration (the design this document operationalizes).
  • ADR-020 — the two planes (platform identity vs. site-credential vault); ADR-020 Option C = control is both Authorization Server and Resource Server.
  • ADR-019 — agent identity / delegation (the spawn-fact provenance model that grant propagation gates).
  • The companion credentials & identity guide for setup and day-to-day usage.

This document was last verified against the merged tree on 2026-06-27. Cited file:line numbers in the fast-moving control crate (nightjar-control) drift as code lands — treat the symbol name (fn/struct/const) as authoritative and the line number as a hint. Migration numbers, scope strings, and node-side (nightjar-impl) refs are stable.


1. The two credential planes

Nightjar deliberately keeps two entirely separate notions of “credential.” Conflating them is the single most dangerous mistake a contributor can make, so they are named, typed, and stored apart.

Plane 1 — platform identityPlane 2 — site-credential vault
What it isProof of who is calling NightjarA real secret for some third-party website (a password, a session token)
Wire formnjk_… API key, or njo_… OAuth access token (njr_… refresh)Never on the wire to the model — stored sealed, filled server-side
CarriesAn org + agent binding + a clamped scope setA value, an origin_allowlist, and a {{credential:NAME}} placeholder
Stored asapi_keys.hashed_key / oauth_tokens.hashed_token — sha256-at-restidentity_credentials.value_encrypted — per-org KMS envelope, base64
Authority modelScopes (sessions:drive, identities:read, …)Grants (assume / manage) + per-credential origin allowlist
Audit handlenkey_-shaped credential/grant id (presenter)nident_/ncred_/ngrnt_ ids + placeholder NAME

The cross-plane invariant

Possessing a Plane-1 credential never, by itself, grants a Plane-2 credential.

Holding an njk_ key or an njo_ access token only proves possession and resolves to an org/agent/scope set. To fill a Plane-2 secret you additionally need:

  1. a session bound to an identity (sessions.identity_id non-NULL), and
  2. a live grant (assume/manage) from your agent to that identity, and
  3. the target origin inside both the credential’s and the winning grant’s allowlists.

The scope sessions:drive lets you drive a browser; it does not let you read, decrypt, or exfiltrate any stored secret. The secret is never returned by any API (only a masked last-4), so a Plane-1 credential — however broadly scoped — can at most cause a secret to be typed into an allow-listed editable field on the live origin, and nothing else.


2. The credential-fill trust boundary

The headline secure path is the structured credential_fill MCP tool → POST /v1/sessions/:id/fill-credential. Defense is layered so that no single layer’s failure leaks the secret, and the model is structurally incapable of placing it.

(a) The tool has no value field, by construction

The credential_fill MCP tool and the CredentialFillRequest body expose exactly sessionId, credentialName, selector (additionalProperties:false). There is no text/value field anywhere on the surface. The model names a credential; it cannot choose what string lands or smuggle a secret in.

  • Generated input schema: core/spec/mcp-tools.json (credential_fill) (exactly those three keys).
  • Call construction: generated_tools::build_call sets only body[credentialName] + body[selector].
  • /v1 struct: domain::CredentialFillRequest. A timeoutMs field exists on the /v1 struct but the handler ignores it — the node body is hand-built.

(b) Control decrypts just-in-time into a zeroizing buffer and forwards a minimal body

sessions_golden::fill_credential authorizes, resolves the named credential, JIT-decrypts the per-org KMS envelope into a Zeroizing<String>, computes the effective origin allowlist (credential ∩ grant), then forwards to the node a body of exactly:

{ verb: "type", selector, text: <plaintext>, originAllowlist: <effective>, requireEditableField: true }

The plaintext is drop()-ed immediately after the node call returns, before any audit row or response is produced. Decryption (sessions_golden::decrypt_credential) fails closed: a missing KMS object store or any KMS/base64 error maps to a generic Internal — the cause is never surfaced (it could hint at key state).

(c) The node enforces live-origin AND editable-field before any keystroke, then zeroizes

The node (core/crates/nightjar-impl) is the anti-TOCTOU backstop. Both gates run before the element is typed into, default-deny:

  • Live-origin gate (text_input::live_origin_on_allowlist): the current navigation host must be on the forwarded allowlist. Empty allowlist or unparseable URL → deny. Match is exact host or strict dot-boundary subdomain (live_host.ends_with('.'+entry)), so evilgithub.com does not match github.com. On miss: origin_denied_error (ErrorCode::ActionabilityFailed, OutcomeCertainty::NotStarted) — no input dispatched.
  • Field gate (session::is_editable_field_kind): when requireEditableField=true, the resolved element must be an editable text-ish field (closed set: input[password|text|email|tel|url|search|number], textarea, contenteditable/role=textbox). checkbox/radio/button/submit/file/nonefield_denied_error. The probe (EDITABLE_INPUT_PROBE_JS) runs on the same resolved handle (no re-query).

Order at the node: live-origin gate first, then element resolution + actionability, then the field gate. The node also wraps the typed value in Zeroizing (belt-and-suspenders) and the existing password-field journal redaction keeps it out of the journal.

The two enforcement signals are control→node internal wire fields only — SessionActRequest { originAllowlist, requireEditableField } (session_wire::SessionActRequest) — not part of the public /v1 ActRequest.

(d) Free-text {{credential:}} is denied-by-default behind a per-org flag

The legacy path — a {{credential:NAME}} marker inside POST /v1/sessions/:id/act text — is deprecated. sessions_golden::resolve_credentials reads governance_settings.credential_freetext_fill (default FALSE); if disabled it returns Forbidden(FREETEXT_DENIED_SENTINEL) before any session lookup, decrypt, or node dial. Re-enabling is an org:admin-gated PUT /v1/governance decision. The structured path does not call this resolver and is unaffected.

What is enforced WHERE

CheckLayerImplementation (file::fn)
Scope: sessions:drivecontrolsessions_golden::authorize_sessionctx.require_scope("sessions:drive")
Org-ownership (404, no oracle)controlsessions_golden::authorize_sessioncount(*) … WHERE id=$1 AND org_id=ctx.org_id
Bound-identity (else 400/404)controlsessions_golden::resolve_named_credential / sessions_golden::resolve_credentials
JIT decrypt, fail-closed into Zeroizingcontrolsessions_golden::decrypt_credential (st.objects.open(org_id, …))
Free-text deny-by-default (A3)control + dbsessions_golden::resolve_credentials — gate on credential_freetext_fill
Fill-time origin intersection (C3)controlsessions_golden::effective_fill_origins — credential ∩ winning-live-grant; disjoint = hard deny
Live-origin allowlist (anti-TOCTOU)nodetext_input::live_origin_on_allowlist
Editable-field gate (A1)nodesession::editable_input_kind / session::is_editable_field_kind
Forward of originAllowlist + requireEditableFieldcontrol→node wiresession_wire::SessionActRequest, bound and consumed by the generated client
org:admin to toggle the free-text flagcontrolgovernance::putctx.require_scope("org:admin")
Value-free audit (best-effort)control + dbaudit::credential_auditsecurity_log

Error map (structured path), all value-free: 403 missing sessions:drive; 404 cross-tenant/unknown session (no existence oracle); 400 anonymous session (“no bound identity”); 404 unknown credential NAME; 500 “decryption unavailable”/“decryption failed” (cause hidden); 403 GRANT_ORIGIN_DENIED_SENTINEL (disjoint intersection — secret never forwarded); node-relayed field_denied/origin_denied surfaced as the node’s 422-derived error. (Legacy path differs in two places: unknown name is 400 not 404, and free-text-disabled is 403 FREETEXT_DENIED_SENTINEL.)


3. Grant authority & constraints

A grant authorizes one principal to assume or manage an identity. Grants are authored on identities:write endpoints (POST/GET/DELETE /v1/identities/:id/grants, plus …/grants/:grant_id/revoke) — grant authoring is deliberately an owner/admin capability off the autonomous MCP surface.

Schema and the constraint columns

Base grant columns live in migration 0003_identities_environments.sql; the ADR-029 Lane C constraints are added (all additive, expand/contract-safe, no backfill) in 0022_identity_grant_constraints.sql:

  • expires_at (NULL = never), revoked_at (NULL = active)
  • allowed_origins TEXT[] DEFAULT '{}' (empty = no grant-side narrowing)
  • propagation TEXT DEFAULT 'descendants' CHECK IN ('none','same_conversation','descendants')
  • max_session_duration_secs BIGINT (NULL = 1h platform default; records/caps lease TTL, does not force-end a live session)
  • created_by_user_id, reason (audit only)
  • partial index identity_grants_live_idx (identity_id, principal_agent_id) WHERE revoked_at IS NULL

CHECK num_nonnulls(principal_user_id, principal_org_role, principal_agent_id) = 1 — exactly one principal. Only the principal_agent_id grant is enforced at bind today (the /v1 path is key-based); user/org-role grants are stored and listed but not yet checked.

Most-restrictive-live-grant selection

A grant is live iff revoked_at IS NULL AND (expires_at IS NULL OR expires_at > now()), compared DB-side against now() (skew-safe). Both chokepoints compute “most restrictive live grant” independently, with orderings designed so that adding a tighter grant tightens and a stale broad grant never defeats a new narrow one:

  • Bind-time (http/sessions.rs::open_session_inner): orders by (propagation='none') DESC, (expires_at IS NULL) ASC, expires_at ASC, (max_session_duration_secs IS NULL) ASC, max_session_duration_secs ASC, created_at DESC — optimizing propagation/duration.
  • Fill-time (sessions_golden::effective_fill_origins): orders by expires_at ASC NULLS LAST, (cardinality=0) ASC, cardinality ASC, created_at DESC — optimizing origin narrowness, independent of the bind winner.

Bind-time vs. fill-time

Bind-time (when a session assumes an identity) enforces whether you may embody this identity at all:

  • Existence: a coarse EXISTS(… grant_role IN ('assume','manage'))403 "agent not granted to assume identity".
  • Liveness: the winning-live SELECT; winning=None → distinguish grant_expired vs grant_revoked value-free, write grant.bind_denied, 403 "identity grant not live (…)".
  • Propagation vs. assurance (see below).
  • Lease TTL cap: expires_at = now() + LEAST(interval '1 hour', make_interval(secs => COALESCE(cap, 3600))) — NULL cap reproduces today’s 1h byte-for-byte.

Fill-time (when a stored secret is typed) enforces where the secret may land: effective = credential.origin_allowlist ∩ winning-live-grant.allowed_origins.

  • Empty/absent grant list ⇒ no narrowing ⇒ the credential’s own list verbatim (today’s behavior).
  • Disjoint (empty intersection) ⇒ hard deny Forbidden(GRANT_ORIGIN_DENIED_SENTINEL); the plaintext is dropped on the early return, never forwarded.

A grant can only ever tighten where a credential may fill, never widen it — the policy narrows monotonically.

Soft revoke

POST /v1/identities/:id/grants/:grant_id/revoke stamps revoked_at = now() (idempotent via WHERE revoked_at IS NULL — a second revoke leaves the original timestamp). Future binds fail closed at the liveness check and fill-time intersection skips the grant — but an already-live session keeps its lease until it ends (revoke ≠ force-end; force-end is a separate, out-of-lane identity-lock capability). Soft revoke preserves the audit trail; the hard DELETE …/grants/:grant_id erases it.

How propagation='none' severs spawned-child inheritance

A spawn (admit_spawn, ADR-019) mints a child agent-instance principal but keeps actor_entity_id = the parent agent for node authority — so the child’s bind passes the same principal_agent_id grant check as the parent by construction. Admission arrives assurance = 'parent_asserted'.

The only thing that severs this inheritance is the winning grant’s propagation:

if admission.assurance == 'parent_asserted' && propagation == 'none'
    → grant.bind_denied(propagation_blocked) + 403 "grant propagation='none' blocks a spawned-child bind"

The direct create path (assurance = 'direct', admit_session) is never blocked by propagation. In v1, same_conversation and descendants both permit a spawn — the server cannot yet distinguish a same-conversation child from a deeper descendant (see §9).


4. The OAuth Resource Server

ADR-020 Option C: control is both Authorization Server and Resource Server. A per-developer-per-workspace, agent-bound, least-privilege OAuth credential replaces the shared static njk_. Tables: 0021_oauth_resource_server.sql (+ 0023_oauth_org_cascade.sql; Dynamic Client Registration governance in 0025_oauth_dynamic_client_registration.sql).

Token model

TokenWire prefixTTLAt rest
Accessnjo_<base64url(32B)>15 minoauth_tokens.hashed_token = sha256, kind='access', audience bound
Refreshnjr_<…>30 dayssha256, kind='refresh', single-use rotation
  • Token rows are ntok_<uuid>; the grant id is nkey_<uuid>-shaped (deliberately — it slots into sessions.opened_via_credential_id / Admission.presenter_credential_id with no schema change → audit continuity with the api_keys shape).
  • Audience-bound (RFC 8707): verify_access requires t.audience = st.mcp_resource_uri; a token minted for another resource fails (audience_mismatch_does_not_verify).
  • Rotation: rotate_refresh (tokens::rotate_refresh) selects the refresh FOR UPDATE, mints a replacement pair, then stamps the old revoked_at + refresh_rotated_to. Re-presenting a rotated-out refresh finds no live row → 400 invalid_grant (reuse detection).
  • verify_access (tokens::verify_access) is a fail-closed JOIN token → grant → org with all expiry/revocation/audience predicates DB-side vs now(). A token can never outlive its grant (g.revoked_at IS NULL) or its org (o.deleted_at IS NULL).

Deterministic prefix routing — no fallback (downgrade-oracle avoidance)

AuthContext::from_request_parts (auth::from_request_parts) strips Bearer then dispatches the raw token once by prefix:

  • njk_api_keys JOIN path only.
  • njo_oauth::tokens::verify_access only.
  • neither → Unauthorized.

An invalid njo_ never queries api_keys; an invalid njk_ never queries the oauth tables. There is no fallthrough between arms — no downgrade or timing oracle. Both arms fail closed to the same Unauthorized shape, and prefix mutual-exclusivity is unit-asserted.

Scope clamp at provisioning

Two clamps exist, and only the first is a hard floor. The least-privilege floor is auth::oauth_default_clamp, shared by every clamp site so the floor is defined in ONE place; the set it keeps is claude_code_default_scopes() = {sessions:write, sessions:drive, identities:read}, and that it excludes write/admin is proven by claude_code_default_scopes_exclude_write_and_admin_capabilities. Since the ADR-029 owner-consent addendum (2026-07-17), auth::clamp_consented_scopes can elevate beyond that floor: an owner-consented grant can carry a dangerous scope such as keys:write — asserted by clamp_consented_scopes_is_bounded_by_the_role_ceiling. It is called from oauth::provision (both the mint and the grant-reuse path) and from oauth::tokens for the refresh rebound. Elevation is triple-gated — the role must carry the scope, the human must consent, and the result is the INTERSECTION with role_scopes(role) — and the dangerous members are itemized and flagged on the consent screen the human approves, proven by consent_screen_itemizes_elevated_and_flags_dangerous. The elevation is separately audited value-free after approval (:345 records only the COUNT of dangerous scopes, never their names). No new scope was registered — the closed-registry invariant is intact.

Human-session-gated issuance

All issuance is gated on a signed-in human; org/agent are never caller-asserted. /oauth/authorize uses the nj_session cookie (Option<SessionUser>); no cookie → 302 to {public_url}/auth/github/login. The org is resolved only via resolve_active_org(memberships, X-Nightjar-Org). PKCE is S256-only; auth codes are single-use (consumed_at IS NULL conditional UPDATE, single-winner). Redirect URIs are pinned to loopback/oob at both authorize and token (oauth_rs::is_permitted_redirect) — exact 127.0.0.1/localhost host split on :, rejecting localhost.evil.com, https, javascript: ([::1] deliberately excluded for alpha).

Org-purge cascade

oauth_clients/oauth_grants/oauth_tokens are in purge::ORG_SCOPED_TABLES. Migration 0023 makes the intra-oauth client_id FKs (grants, auth_codes) ON DELETE CASCADE — Postgres does not topologically sort cascaded deletes (pg#18064), so a NO-ACTION FK would abort an org purge (half-purge: KEK crypto-erased but rows fail to delete). the fk_cascade_completeness_guard isolation test enforces that the cascade set equals the purge set.

Dynamic Client Registration (RFC 7591)

POST /oauth/register (migration 0025) lets a generic OAuth/MCP client that ships no pre-registered client_id (Codex, Gemini, a custom client) self-register a PUBLIC PKCE client (ncli_<uuid>, org_id NULL, token_endpoint_auth_method='none') so it can run the same login flow as the seeded claude-code client. The endpoint is public and unauthenticated by design — registration is the machine bootstrap before any human is in the loop — which is safe only because registration confers no authority:

  • validate_registration rejects confidential clients (token_endpoint_auth_method must be none) and pins every redirect_uri to loopback/oob by REUSING is_permitted_redirect — the exact same contract as the built-in client. A non-loopback redirect is rejected invalid_redirect_uri; a scope in the request is non-authoritative (clamped at issuance).
  • A token still requires a real human GitHub session + consent at /oauth/authorize, and scopes are still clamped to claude_code_default_scopes(). A registered client_id is only a redirect-pinning handle — it cannot, on its own, obtain a token or any authority.
  • A per-IP DcrRateLimiter bounds oauth_clients row spam (control has no general-purpose limiter, so the endpoint carries its own).
  • Kill-switch NIGHTJAR_OAUTH_DCR_ENABLED (→ st.oauth_dcr_enabled, default on): when off, the route 404s and the AS metadata drops registration_endpoint (prm).
  • Operators govern registered clients out-of-band with nightjar-control list-oauth-clients / revoke-oauth-client --id ncli_…. Revocation stamps oauth_clients.disabled_at (a soft-disable — a hard DELETE would hit the oauth_grants.client_id FK) and revokes the client’s grants; the built-in claude-code client is refused. Every client-resolving path treats a disabled_at IS NOT NULL client as absent (validate_client_redirect).

The gateway stays thin

The MCP gateway’s entire OAuth involvement is emitting one WWW-Authenticate: Bearer resource_metadata="…/.well-known/oauth-protected-resource" header on a 401 (mcp::resource_metadata_url). It runs no auth logic, holds no secret, and adds no request-path state. All authZ lives in control’s /v1 + verify_access.


5. The value-free audit model

Every credential.* and grant.* operation records ids, placeholder NAME, host, field-kind, and reason-code only — never the secret. All rows go through credential_audit(st, org_id, op, actor_key_id, outcome, detail) (audit::credential_audit) → INSERT security_log(org_id, operation, actor_key_id, outcome, detail).

OpWhendetail (value-free)Outcome
credential.fillsecret resolved + node acceptedidentity=<id> placeholder={{credential:NAME}}ok
credential.assumesession bound an identityidentity=<id> session=<id>ok
credential.origin_denied(a) node live-origin refusal … host=<denied-host>; (b) control C3 disjoint … reason=grant_intersectionids + host/reasondenied
credential.field_deniednode refused non-editable target… field_kind=<button\|checkbox\|none\|…> (a DOM label)denied
credential.freetext_deniedfree-text path refused (flag off)placeholder={{credential:NAME}} reason=freetext_disabled (before any decrypt)denied
grant.bind_deniedbind chokepoint denyidentity=<id> grant=<id?> reason=<grant_expired\|grant_revoked\|propagation_blocked>denied

Key properties:

  • Best-effort: a failed security_log INSERT logs a warning and is swallowed — a successful fill must never 500 on an audit failure.
  • org_id is always the authenticated caller’s org; actor_key_id is ctx.key_id (empty string for a human member with no key).
  • The decrypted value is structurally absent as an input to every detail helper — it lives only in Zeroizing/resolved.text and is dropped before this module is reached.
  • security_log is FK-less by contract and survives org hard-purge (a GDPR Art.17(3) record).
  • The OAuth RS path writes no security_log rows at all (verified by grep); it emits only value-free tracing::info! (user/org/client/scopes — the clamped vocabulary, never the secret code).

6. The secret-never-to-model guarantee

Trace the plaintext from rest to keystroke:

identity_credentials.value_encrypted  (sealed, per-org KMS envelope, base64)
        │  decrypt_credential(st, org_id, …)  — fails closed
        ▼
Zeroizing<String>  (control, in-process only)
        │  placed ONLY into the forwarded node JSON body {text:…}
        ▼
control→node TLS  →  node Zeroizing buffer  →  live-origin + field gates  →  keystroke
        │
        ▼  drop(plaintext) on control BEFORE audit/return; node zeroizes after type
        (gone)

The plaintext does not appear in any of these places:

  • The modelcredential_fill has no text/value field by construction; the model names a credential and never sees a value.
  • The journal — node wraps the typed value in Zeroizing; the existing password-field journal redaction keeps it out.
  • Screenshots — the secret is typed into a field; no API ever returns the cleartext for capture, and the fill flow does not screenshot the value.
  • The audit — every detail carries ids/placeholder-NAME/host/field-kind/reason only; the secret is structurally absent input.
  • API responses — reads return only masked_value (last-4). value_encrypted is never SELECTed by any read handler (CredentialRow/AssumableSlotRow/SELECT_CREDENTIAL/SELECT_ASSUMABLE_SLOT have no field for it). Discovery (/v1/identities/assumable) returns slot metadata only.
  • The node after zeroize — the Zeroizing buffer is cleared on drop once the keystroke is dispatched.

7. Threat model

ThreatMitigationResidual risk
Prompt-injection steers a paste (model tries to dump a secret into a chat/comment/search box)The model can never hold the secret (credential_fill has no value field); it can only name a credential + selector. The node refuses a non-editable target (field_denied) and any off-allowlist origin (origin_denied) before the keystroke.A secret can still be typed into a legitimately-editable field on an allow-listed origin if the model picks the wrong selector there — bounded to that origin’s editable inputs; form-action/iframe gating is deferred (§9).
Malicious content on an allow-listed originOrigin allowlist is the primary mitigation; the field gate additionally requires an editable text-ish input.The lane gates origin + input-type only. A compromised allow-listed page that presents a password field still receives the fill. Mitigated operationally by keeping allowlists narrow; form-action-origin gating deferred.
Origin spoofing (dot-boundary)Node match is exact host or strict live_host.ends_with('.'+entry); evilgithub.comgithub.com. Unparseable/about:blank/empty URL denies.No public-suffix list in v0 — an attacker who controls a true subdomain of an allow-listed apex (evil.github.com when github.com is listed) is permitted. List apexes you trust narrowly.
Scope escalationClosed scope registry; OAuth clamp at 3 points to {sessions:write, sessions:drive, identities:read}; the DEFAULT clamp holds no write/admin scope, and an owner-consented grant may carry one only through the triple-gated clamp_consented_scopes path (ADR-029 addendum); every /v1 endpoint has a require_scope gate.A new endpoint that forgets its require_scope gate silently reopens a gap (registry-level, not OAuth-level) — caught only by review/tests.
Token / audience confusionDeterministic prefix routing with no fallback; RFC 8707 audience binding checked DB-side; njk_/njo_ never cross stores.If a deploy’s NIGHTJAR_MCP_RESOURCE_URI diverges from the mint-side audience byte-for-byte, every njo_ token 401s (fail-closed, availability not confidentiality). Mint+verify both read st.mcp_resource_uri, so they agree by construction.
Ambient-parent trap (a spawned child silently inheriting parent authority)Spawn admission is parent_asserted; propagation='none' on the winning grant refuses the inherited bind. Lease single-writer PK prevents double-embodiment.In v1 same_conversation is not distinguished from descendants — both permit a spawn; only none blocks. A deep descendant is treated like a same-conversation child until that enforcement lands (§9).
Shared-key attribution ceiling (parent_asserted)Per-developer-per-workspace OAuth grant gives each developer a distinct agent-bound credential; the grant id (nkey_) is the presenter handle for audit continuity.The v2 delegation broker / child_key_proven (host-isolated keys) is deferred — until then, a child’s authority is asserted by its parent, not cryptographically proven (§9).

8. DELICATE INVARIANTS — DO NOT BREAK

A future contributor must preserve all of the following. Each is load-bearing for confidentiality or tenant isolation.

  1. credential_fill (the MCP tool and CredentialFillRequest) must never gain a text/value field. The model naming a credential — never a value — is the structural guarantee that prompt-injection cannot place a secret. additionalProperties:false + exactly {sessionId, credentialName, selector} is mandatory; the generated schema and build_call are drift-gated.
  2. The node must field-gate AND origin-gate BEFORE the keystroke, default-deny, on the same resolved handle, with the live-origin gate running first. Never dispatch input before both pass.
  3. Origin matching stays exact-host-or-strict-dot-boundary; evilgithub.com must never match github.com; an empty allowlist or unparseable URL must deny.
  4. Effective fill origins narrow monotonically: effective = credential.origin_allowlist ∩ winning-live-grant.allowed_origins. A grant may only tighten, never widen; a disjoint intersection is a hard deny and the plaintext is dropped before the early return — never forwarded.
  5. njk_ and njo_ must never fall through to each other. Routing is deterministic by prefix with no fallback; an invalid token of one kind must never query the other store, and both must fail closed to the same Unauthorized.
  6. OAuth scopes are clamped to claude_code_default_scopes() at every mint point (authorize, device, provision) against one source of truth. A grant carries a write/admin scope ONLY via explicit, server-observed owner consent, bounded by the human’s own role (ADR-029 owner-consent addendum, 2026-07-17). No new scope is registered for this lane.
  7. org_id is always ctx.org_id, never a request value — at the ownership gate, every credential/identity/grant lookup, and the KMS envelope open (st.objects.open(org_id, …)). Org B must never resolve, decrypt, or fill org A’s credential.
  8. Crypto fails closed. No KMS object store → Internal "decryption unavailable", never an unencrypted forward. The decrypt failure cause is never surfaced to the client.
  9. A secret must never appear in an audit detail (nor journal, screenshot, or any API response beyond masked_value). Every detail helper takes ids/name/host/field-kind/reason only; the value is a structurally-absent input.
  10. Audit is best-effort and must never change a request’s outcome. A failed security_log INSERT is swallowed; a successful fill must not 500 on an audit failure.
  11. Legacy free-text {{credential:}} stays denied-by-default (credential_freetext_fill DEFAULT FALSE); a denial must occur before any decrypt or node dial; only org:admin may re-enable it. The structured path must not call resolve_credentials.
  12. OAuth public clients stay pinned to loopback/oob at both authorize and token; an unknown client or non-permitted redirect must render the error directly (never 302 to an unvalidated URI).
  13. No cross-tenant existence oracle: cross-org and totally-unknown ids return the same 404; delete_identity orders ownership (404) before the live-lease 409.
  14. The grant id stays nkey_-shaped so it slots into sessions.opened_via_credential_id / Admission.presenter_credential_id unchanged (audit continuity).
  15. Org purge cascades the oauth tables with no half-purge: the intra-oauth client_id FKs must stay ON DELETE CASCADE, and purge::ORG_SCOPED_TABLES must equal the cascade-guard list.
  16. The two denial sentinels stay pinned across node and control: FIELD_DENIED_SENTINEL and ORIGIN_DENIED_SENTINEL equal in text_input.rs and the audit.rs classifiers; GRANT_ORIGIN_DENIED_SENTINEL and FREETEXT_DENIED_SENTINEL distinct. Tests drift-gate these.
  17. The MCP gateway stays thin and stateless — no secret, no auth logic, no crypto; its only OAuth touch is the WWW-Authenticate challenge string.
  18. Self-registration (RFC 7591 POST /oauth/register) confers no authority. A registered client_id is only a redirect-pinning handle: every registered redirect_uri stays pinned to loopback/oob via the same is_permitted_redirect, only public-PKCE (token_endpoint_auth_method='none') clients are accepted, a token still requires a real human consent at /oauth/authorize with scopes clamped to claude_code_default_scopes(), registration is per-IP rate-limited and NIGHTJAR_OAUTH_DCR_ENABLED-gated (404 when off), and a disabled (disabled_at) client must be treated as absent at every login leg.

9. The agent credential store (ADR-056)

Sections 1–8 describe where a credential lives on the server and how it reaches a page. This one describes where the agent’s own Plane-1 credential lives on the caller’s disk, and what that storage guarantees.

Status, stated precisely because the rest of this section is about guarantees that already hold. The store itself is merged (nightjar-credential-store: the CredentialStore trait and the FileStore file backend), and its root and override are now fixed in the CLI. No user-facing command uses it yet — the auth login surface that populates it is a later slice and is not on main. So read this as a reviewer’s account of a shipped mechanism, not as a workflow you can run today; the current documented way to present a platform credential is unchanged.

Why a store rather than an environment variable

The motivating incident is recorded in the crate’s own module doc: a credential routed through an environment variable resolves through a process tree and freezes at each process’s launch. Two consequences follow, and both are properties of the delivery mechanism rather than of the secret:

  • Rotation propagates only by restart. Every already-running process holds the value it was launched with, so “rotate” means “restart the tree” and a missed process keeps presenting the old credential indefinitely.
  • Neither side can observe which credential the server received. The presenter is opaque to the caller and unattributable to the server, so an incident cannot be reconstructed after the fact.

The store is read at request time rather than at launch time, which is what makes rotation a write rather than a restart.

Where the store lives, and what it does when it cannot tell

Root~/.nightjar/credentials$HOME on POSIX, %USERPROFILE% on Windows
OverrideNIGHTJAR_CREDENTIALS_DIR, pointed at a directory only you can reach
Home unsetAn error. There is no fallback, on purpose.

An empty override is treated as unset, not as “the current directory” — a blank environment variable is overwhelmingly an unset-it-wrong, and resolving it to . would put a credential store wherever the shell happened to be standing.

The third row is the one worth reading twice, because it is a deliberate inconsistency with the rest of the CLI. Nightjar’s other directories resolve home through a helper that falls back when the variable is missing — to C:\Users\default on Windows, /tmp on POSIX. For a profiles directory that is untidy. For a credential store it is the vulnerability, and the refusal says so in the message the operator actually sees:

cannot locate the credential store: $HOME is unset, and unlike other Nightjar directories this one has no fallback on purpose — a credential store created under a world-writable path would be readable during its own setup. Set $HOME, or set NIGHTJAR_CREDENTIALS_DIR to a directory only you can reach.

Note precisely what the fallback would cost, because it is not “secrets end up somewhere surprising”. The backend creates its root and restricts it owner-only — but under a world-writable parent, the root’s own creation window sits inside a directory anyone can write, which defeats the create-owner-only guarantee in the table below before that guarantee gets to apply. The failure is in the setup, not in the steady state, so it would never show up in an inspection of the finished permissions.

A fallback is a convenience for a cache and a defect for a secret. Reaching for the shared helper is the correct instinct everywhere else in the same file; what changes is not the code but the threat model of the new caller, and nothing about reading a path helper tells you which case you are in. The error names the variable because that is the part an operator can fix — a silent relocation is the one outcome they cannot.

What the file backend actually guarantees

Each row is implemented in nightjar-credential-store/src/file.rs; treat the symbol as authoritative per this document’s opening note.

GuaranteeHow it is obtained
Owner-only permissions, with no race windowThe temp file is created owner-only from the moment it exists (create_owner_only), never created-then-chmod-ed. The alternative leaves a window in which the file exists readable.
Permissions are observed, not merely requestedAfter creation the backend re-reads the permissions actually in effect and confirms they are owner-only. In the crate’s words: “Setting them is a request; this is the observation.”
Durable, atomic replacementWrite to the temp file, fsync, then rename into place — so a reader sees either the old record or the new one, never a partial write.
Compare-and-swap on a generationreplace_profile(id, expected: Generation, next) -> Generation. A caller that believed a stale record fails the check rather than blind-overwriting; expected == 0 is the create case.
Locking per stored record, held across the whole refreshacquire_profile_lock returns a guard released on Drop. Its contract is explicit that the lock is held across the entire network refresh, not merely the file write — a lock scoped to the write alone would still allow two concurrent refreshes to race. (The store’s ProfileId names a stored credential record; it is unrelated to the browser profiles managed by the profile command, which are a different thing that happens to share the word.)
Secrets do not leak through DebugSecret’s Debug prints Secret(<redacted>) and withholds the length too — the crate’s reasoning being that a length narrows a guess and buys the reader nothing. Pinned by tests, not convention.

The invariant this section adds

A credential’s storage is part of its security model, not an implementation detail. Permissions that were requested but never verified, a write that can be observed half-finished, and a value recoverable from a debug log are each sufficient to lose a secret that every server-side control in sections 1–8 handled correctly.

This is the same shape as the cross-plane invariant in section 1: the weakest link decides, and it is usually the one nobody classed as security-relevant.


10. Explicitly deferred

Named honestly in ADR-029 — schema or hooks may be present but enforcement is not:

DeferredStatusResidual risk
Form-action / iframe origin gatingNot built — the lane gates origin + input-type only.A fill into an editable field on an allow-listed page whose <form action> posts cross-origin (or inside a hostile iframe) is not blocked. The origin allowlist is the primary mitigation; keep allowlists narrow.
Per-fill / first-use human confirmationDeferred (a host-side concern).No interactive “are you sure” before a secret is typed; the structured tool + gates are the only barrier.
The v2 delegation broker / delegations:exchange / child_key_provenReserved, not built; lane D is the token-forwarding-host ceiling.A spawned child’s authority is parent_asserted, not cryptographically proven; attribution bottoms out at the parent agent until host-isolated child keys land.
same_conversation enforcementTreated identically to descendants at bind (server can’t yet distinguish).A deep descendant is permitted wherever a same-conversation child would be; only propagation='none' blocks a spawn today.
TTL-aware reapermax_session_duration_secs records/caps the lease expires_at but the reaper is liveness-based, not TTL-based.A still-live session is not force-ended at its TTL horizon; the column is schema-present and dormant for force-end.
allowed_credential_names / egress-mode grant columnsNot added.A grant cannot yet restrict which named credentials it covers — assume/manage is identity-wide.
Browser e2e for the field/origin gatesDeferred (node-harness gap).The node gates are proven by unit + Postgres tests (tests/credential_fill.rs), not a live browser e2e — a node-harness regression could slip a gate without a failing browser test.
slow_down device-flow backoffMentioned in a comment, not implemented (authorization_pending/expired_token only).Harmless minor RFC 8628 gap.
[::1] IPv6 loopback redirectDeliberately excluded for alpha.An IPv6-only loopback client cannot complete the auth-code flow; use 127.0.0.1/localhost.