Skip to content

Scoped operator tokens, one-time enrollment, and scoped profile contracts (milestone 0) - #64697

Draft
XelHaku wants to merge 9 commits into
NousResearch:mainfrom
XelHaku:feat/scoped-operator-auth
Draft

Scoped operator tokens, one-time enrollment, and scoped profile contracts (milestone 0)#64697
XelHaku wants to merge 9 commits into
NousResearch:mainfrom
XelHaku:feat/scoped-operator-auth

Conversation

@XelHaku

@XelHaku XelHaku commented Jul 15, 2026

Copy link
Copy Markdown

Draft — not requesting review/merge yet. Opening for visibility and discussion of the contract. One validation step (on-device enrollment + accessibility receipt) is still outstanding; see caveats below.

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 existing API_SERVER_KEY path 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, revocable OperatorCredentialStore (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-time API_SERVER_KEY → superuser, else operator-token → scoped principal, else 401; insufficient_scope 403 never echoes granted scopes. Every mutating route is :write-gated. /v1/capabilities now advertises schema_version, an auth block, and per-endpoint required_scopes / profile_scoped. Adds scoped profile CRUD + soul contracts (profiles:read/write) with If-Match optimistic concurrency (428 missing, 412 stale-no-write) that reuse the existing hermes_cli.profiles domain functions — no Dashboard proxy, no active_profile mutation, no shell-wrapper creation for remote clients.

Evidence

  • 257 gateway tests pass on the merged tree (the pre-existing Telegram/Wecom failures are unrelated and unchanged).
  • tests/gateway/test_milestone0_receipt.py drives the real handlers end-to-end: mint → inspect → single-use exchange → scoped profile create/list/soul/rename/delete with If-Match → revoke → revoked token 401 (fail closed).
  • Each piece was security-reviewed; fixes in-branch cover wildcard scope validation, per-record fail-closed credential loading, path-free error bodies, and suppressing remote-rename wrapper creation.

Caveats / not-yet-done

  • On-device receipt pending: a real phone enrolling against a running instance (and the client-side accessibility pass) has not yet been captured, so the client parity rows remain implementing, not validated.
  • /api/cron/fire keeps its purpose-built NAS-JWT auth (an audited exception to the operator-scope gating).
  • Operator enrollment uses a store-global lockout (single-owner-install assumption); expired/consumed rows are not pruned.

Happy to split this into smaller PRs (vocabulary/store → authorization → enrollment → profiles) if that's easier to review.

🤖 Generated with Claude Code

XelHaku and others added 9 commits July 14, 2026 14:33
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>
@alt-glitch alt-glitch added type/feature New feature or request comp/gateway Gateway runner, session dispatch, delivery comp/cli CLI entry point, hermes_cli/, setup wizard area/auth Authentication, OAuth, credential pools P3 Low — cosmetic, nice to have sweeper:risk-security-boundary Sweeper risk: may affect sandboxing, auth, credentials, or sensitive data labels Jul 15, 2026

@tonydwb tonydwb left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 teknium1 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_auth before decorated agent handlers (gateway/platforms/api_server.py:687). The PR moves scope checks into handlers such as gateway/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_HEADERS omits PATCH, PUT, and If-Match (gateway/platforms/api_server.py:562), while the PR adds the PATCH and PUT profile 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")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@teknium1 teknium1 added sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform area/profiles Multi-profile isolation, HERMES_HOME scoping labels Jul 16, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/auth Authentication, OAuth, credential pools area/profiles Multi-profile isolation, HERMES_HOME scoping comp/cli CLI entry point, hermes_cli/, setup wizard comp/gateway Gateway runner, session dispatch, delivery P3 Low — cosmetic, nice to have sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades sweeper:risk-security-boundary Sweeper risk: may affect sandboxing, auth, credentials, or sensitive data type/feature New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants