Scoped operator tokens, one-time enrollment, and scoped profile contracts (milestone 0) - #64697
Scoped operator tokens, one-time enrollment, and scoped profile contracts (milestone 0)#64697XelHaku wants to merge 9 commits into
Conversation
Define VALID_SCOPE_DOMAINS, normalize_scopes(), AuthPrincipal, IssuedCredential, and CredentialSummary as the shared vocabulary for scoped Hermes operator tokens (milestone-0 Task 1).
Add OperatorCredentialStore with issue/authenticate/list_credentials/ revoke, backed by versioned JSON at a caller-supplied path. Tokens are hop_-prefixed secrets.token_urlsafe(32) values; only their SHA-256 hash is ever persisted, the raw token is returned exactly once from issue(), and authenticate() uses hmac.compare_digest against the full hash (not a prefix). Storage writes go through a temp-file + atomic_replace sequence with 0600 permissions and a single threading.RLock guarding read-modify-write cycles, mirroring gateway/pairing.py. Malformed store files fail closed (treated as empty), revoked/unknown credentials fail authentication, and labels are trimmed and bounded to 80 characters. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Parse each stored credential record defensively via a shared _parse_record helper so one malformed or forward-incompatible entry is dropped from the active set rather than raising out of (and DoSing) authenticate()/list_credentials(). A record is skipped when it is not a dict, its token_hash is not an ASCII str, its scopes is not a list of known scope strings, or its created_at is not numeric; the created_at sort key is therefore always numeric and never throws. authenticate() now also guards isinstance(token, str) and returns None for non-str input. Document the single-instance-per-path concurrency invariant on OperatorCredentialStore: the RLock only serializes threads sharing one instance, so concurrent instances/processes on the same path can lose updates (no cross-process locking by design). Also create the store's parent directory with mode 0700 to match the 0600 file. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
_authorize resolves superuser (API_SERVER_KEY, constant-time) or scoped operator principal; mutating routes require :write scopes; insufficient_scope 403 never echoes granted scopes; capabilities advertises schema_version, auth block, and per-endpoint required_scopes/profile_scoped. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds OperatorEnrollmentStore (gateway/api_operator_enrollment.py), a salted-hash, single-use pairing-code store mirroring gateway/pairing.py's lock/atomic-write/TTL conventions but decoupled from messaging-platform allowlists. A pairing code binds a label/origin/scopes grant; exchange atomically consumes the code and delegates token minting to the existing OperatorCredentialStore.issue(). Wires five HTTP endpoints onto the canonical API server: creating an enrollment (settings:write), unauthenticated inspect/exchange keyed by code+origin, and listing/revoking issued operator credentials (settings:read/settings:write). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Expose GET/POST/PATCH/DELETE /api/profiles and GET/PUT /api/profiles/{name}/soul
on the canonical API server, gated by the existing scope-authorization layer
(profiles:read / profiles:write). Handlers call hermes_cli.profiles domain
functions directly (list_profiles, create_profile, seed_profile_skills,
rename_profile, delete_profile, validate_profile_name/profile_exists/
get_profile_dir/normalize_profile_name) — no Dashboard HTTP proxy and no
active-profile mutation endpoint. Mutations require If-Match against an
opaque per-profile revision, computed and validated inside a locked
read-modify-write so a stale caller never silently clobbers a concurrent
write. Responses never carry filesystem paths, env values, or wrapper/alias
commands.
_handle_create_profile echoed str(exc) for duplicate/clone-missing domain errors, leaking absolute filesystem paths into the API response body. Replace with path-free messages built from request inputs. _handle_patch_profile called rename_profile() unconditionally, which unconditionally calls create_wrapper_script() — writing an executable into the operator's ~/.local/bin for a remote scoped client. Add a no_alias seam to rename_profile (default False, CLI behavior unchanged) and pass no_alias=True from the API handler, matching what the create handler already does.
Drives the real shipped enrollment/authorization/profile handlers through the full paired-client lifecycle: mint -> inspect -> single-use exchange -> scoped profile CRUD+soul with If-Match -> revoke -> revoked token 401. Auditable integration receipt for the milestone-0 server contract. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
tonydwb
left a comment
There was a problem hiding this comment.
Code Review Summary
Verdict: Comment (High Surface Area)
Overview
Milestone 0 for scoped operator tokens: one-time enrollment and scoped profile contracts. 2815 additions, 151KB diff, multiple files.
Assessment
- Scope: Large feature PR touching auth/token enrollment — high security sensitivity.
- Security: Token enrollment logic requires careful review for token scope boundaries, expiration, and enrollment uniqueness.
- Debug artifacts: 8 debug hits; verify no leftover logging in token handling paths.
- Secret scan: 42 hits — verify all are test fixtures/variable names, not real credentials.
Recommendation
Human deep-dive review recommended for token scope enforcement and one-time enrollment uniqueness before merge.
Reviewed by Hermes Agent
teknium1
left a comment
There was a problem hiding this comment.
Thanks for the detailed scope model and lifecycle tests. The feature is not present on current main, but the current API-server path has moved and this needs conflict-aware salvage.
Problems
- Current main's admission wrapper still calls legacy
_check_authbefore decorated agent handlers (gateway/platforms/api_server.py:687). The PR moves scope checks into handlers such asgateway/platforms/api_server.py:2551; preserve admission/drain behavior while allowing a valid scoped token to reach those checks, and test actual scoped chat/session/runs requests. - Browser profile mutations cannot preflight:
_CORS_HEADERSomitsPATCH,PUT, andIf-Match(gateway/platforms/api_server.py:562), while the PR adds thePATCHandPUTprofile routes (gateway/platforms/api_server.py:5303-5306). rename_profile(..., no_alias=True)skips removing an existing old wrapper as well as creating a new one (gateway/platforms/api_server.py:2406;hermes_cli/profiles.py:1971). The new test covers only a wrapper-free setup.
Suggested changes
- Preserve the current admission wrapper and make it scope-aware; add endpoint-level scoped-token coverage.
- Extend CORS methods/headers and add a preflight regression test.
- Remove stale old aliases without creating a new remote alias.
Automated hermes-sweeper review.
| async def _handle_chat_completions(self, request: "web.Request") -> "web.Response": | ||
| """POST /v1/chat/completions — OpenAI Chat Completions format.""" | ||
| auth_err = self._check_auth(request) | ||
| _principal, auth_err = self._authorize(request, "chat:write") |
There was a problem hiding this comment.
When this is salvaged onto current main, the current @_admit_api_agent_request wrapper still calls legacy _check_auth before this handler and will reject a valid scoped bearer token before _authorize runs. Preserve the admission/drain accounting but make its authentication accept scoped principals; add an endpoint test that reaches this path with a chat:write token.
| self._app.router.add_patch("/api/profiles/{name}", self._handle_patch_profile) | ||
| self._app.router.add_delete("/api/profiles/{name}", self._handle_delete_profile) | ||
| self._app.router.add_get("/api/profiles/{name}/soul", self._handle_get_profile_soul) | ||
| self._app.router.add_put("/api/profiles/{name}/soul", self._handle_put_profile_soul) |
There was a problem hiding this comment.
These profile mutations require If-Match, but _CORS_HEADERS still allows only GET/POST/DELETE and omits If-Match. A browser at an allowed CORS origin cannot preflight this PUT (or the PATCH route above). Add PATCH, PUT, and If-Match plus an OPTIONS regression test.
| status=412, | ||
| ) | ||
| try: | ||
| new_dir = profiles_mod.rename_profile(old_name, new_name, no_alias=True) |
There was a problem hiding this comment.
no_alias=True also skips remove_wrapper_script(old_canon), leaving a stale old-name wrapper for profiles originally created through the CLI. Split suppressing new-wrapper creation from cleanup of an existing old wrapper, and test a rename that starts with one.
What this adds
A scoped-operator-token trust foundation for the API server, so a mobile client can be granted least-privilege, revocable access without sharing the superuser
API_SERVER_KEY. This is milestone 0 ("Remote trust foundation") of a Flutter client's parity effort; it is purely additive — the existingAPI_SERVER_KEYpath is unchanged and continues to authenticate as superuser (*).New surface (all under
gateway/)api_operator_auth.py— scope vocabulary (normalize_scopes,AuthPrincipal), and a hashed, revocableOperatorCredentialStore(SHA-256 of a 256-bit token,hmac.compare_digest, one-time raw return, 0600 atomic writes, per-record fail-closed loading).api_operator_enrollment.py— one-time, origin-bound, TTL-limited pairing-code enrollment that mints scoped tokens (salted-hash code storage, single-use atomic consume, lockout).api_server.py—_authorize(request, required_scope): constant-timeAPI_SERVER_KEY→ superuser, else operator-token → scoped principal, else 401;insufficient_scope403 never echoes granted scopes. Every mutating route is:write-gated./v1/capabilitiesnow advertisesschema_version, anauthblock, and per-endpointrequired_scopes/profile_scoped. Adds scoped profile CRUD + soul contracts (profiles:read/write) withIf-Matchoptimistic concurrency (428 missing, 412 stale-no-write) that reuse the existinghermes_cli.profilesdomain functions — no Dashboard proxy, noactive_profilemutation, no shell-wrapper creation for remote clients.Evidence
tests/gateway/test_milestone0_receipt.pydrives the real handlers end-to-end: mint → inspect → single-use exchange → scoped profile create/list/soul/rename/delete withIf-Match→ revoke → revoked token 401 (fail closed).Caveats / not-yet-done
implementing, notvalidated./api/cron/firekeeps its purpose-built NAS-JWT auth (an audited exception to the operator-scope gating).Happy to split this into smaller PRs (vocabulary/store → authorization → enrollment → profiles) if that's easier to review.
🤖 Generated with Claude Code