# Native-CC admin PIN identity gate

Every native-CC admin session — the operator chatting through claude.ai/code (web,
desktop, mobile) over the per-account `claude rc` daemon — must bind a
PIN-authenticated `userId` before any tool runs. This closes the gap where the
daemon stamped one boot-time `USER_ID` (from `users.json`) onto every session,
which is correct for at most one human on a multi-admin install. (Task 619.)

The legacy cookie/PIN browser login (`POST /api/admin/session`) is a separate
surface documented in [`admin-session.md`](admin-session.md); this gate covers the
native path, which that login never touched.

## How it works

Three pieces, plus a standing audit. No cross-process `session_id` plumbing: the
hook keys state by the `session_id` it reads from stdin; validation and profile
resolution live behind one loopback endpoint.

1. **Shared validator** — `ui/app/lib/admin-identity/pin-validator.ts`. The single
   PIN primitive (`validatePin` → `match` / `miss` / `unavailable` / `corrupt`),
   shared by the legacy browser login and the gate so the two cannot drift.

2. **Loopback validate endpoint** — `POST /api/admin/identity/validate`
   (`ui/server/routes/admin/identity.ts`). Loopback-only (rejects any request
   carrying `x-forwarded-for`, which only edge-proxied browser requests have).
   Body `{ pin, sessionId? }`. On a match it resolves the operator's `userName`
   and `aboutOwner` via the same helpers the browser path uses
   (`resolveUserIdentity`, `resolveOwnerProfileBlock`) — `loadUserProfile` is never
   duplicated. On a miss with a `sessionId` it fires a best-effort manager
   `POST /<sessionId>/stop` (only the UI process reliably holds the manager port).

3. **PreToolUse gate hook** — `plugins/admin/hooks/pin-identity-gate.sh`, wired in
   `setup-account.sh` as the first universal PreToolUse entry. Runs only for native
   operator sessions (`MAXY_SESSION_ROLE=admin` and empty `MAXY_SPECIALIST` — the
   latter excludes pty-spawned specialist/recorder sessions). Per tool call: if an
   auth-state file exists for the `session_id`, allow; if the tool is
   `admin-identity-authenticate`, post the PIN to the validate endpoint and on a
   match write the state file and allow (on a miss/unavailable, deny + terminate);
   any other tool while unauthenticated is denied with an instruction to
   authenticate first. Enforcement is in the hook, not the agent's choice — a model
   that skips the prompt reaches no other tool.

The agent-callable surface is the eager-loaded `admin-identity-authenticate` MCP
tool (admin plugin); the gate validates and binds before allowing it, and the tool
returns the authenticated operator's profile to the agent.

Auth state: `<accountDir>/state/admin-identity/<session_id>.json` (`{userId, ts}`).
One file per authenticated conversation; bound for the session lifetime, not
re-prompted per message. Task-tool specialist subagents share the operator's
`session_id`, so they pass once the operator is authenticated.

## Cases

- **Zero admins / absent / empty `users.json`** → `pin-unavailable`, the gate
  refuses; never a bound empty `userId`. A real install always has `users.json`
  (the installer/seed writes it), so this is a misconfiguration guard.
- **One admin** → still prompts; the single matching PIN binds that `userId`.
- **Many admins** → each PIN binds its own `userId`. The account is fixed (the
  install's own scope); there is no multi-account picker on this path.
- **Mismatch** → the session is terminated on the first miss (no retry budget).

## Observability

One structured `[admin-identity]` line per step, correlated by `sessionId`:

```
op=loopback-spawn-received → op=pin-prompted → (op=pin-bound source=pin |
op=pin-miss → op=session-terminated) ; the MCP tool emits op=about-owner-loaded.
```

**Standing audit** (`services/claude-session-manager/src/admin-identity-audit.ts`,
every 60s) reconciles, independently of the hook, live native-operator sessions
(watcher rows with `role === null` — daemon-spawned, no sidecar) that reached real
tool use against the auth-state files. Any such session with no state file emits
`[admin-identity] op=audit-orphan sessionId=…
reason=loopback-session-without-authenticated-userId`. This is the no-event
detector: a silent gate bypass leaves no per-session log line, but the audit still
surfaces it.

Diagnostic path: `journalctl --user -u <brand>-admin | grep -E '\[admin-identity\]'`,
then filter by `sessionId` for one session's full lifeline.
