Three privilege-tightening features landed in JefeVault tonight, plus the extension UX work to expose them. The motivating threat: scoped service tokens were read/write/delete by default, and CI jobs were stuck either committing bearer tokens or running with humans-only credentials. Both are fixed. Five commits, 198/198 tests green, two-pass secrets scan clean, pushed to titan/master.
Capability Gating on Service Tokens
Scopes already answered which namespaces — they did not answer what verbs. A token minted for the freechat namespace could PUT or DELETE just as easily as GET. That's MEDIUM severity for a read-only puller, HIGH if the token leaks. Added a capabilities JSON column to ServiceToken and WrappedToken (default [read,write,delete] so existing rows keep working), normalized through a single normalizeCapabilities() helper, and enforced via a new requireCapability(cap) middleware that mirrors requireScope. Secrets routes now chain both gates: GET → read, PUT → write, DELETE → delete. Session tokens bypass — operator access is unchanged. Wrap → unwrap propagates capabilities so a read-only wrapping token can never unwrap into a writer. CLI gets --read-only and --capabilities read,write flags. Six new test cases verify enforcement: read-only blocks PUT with INSUFFICIENT_CAPABILITY, write-only blocks GET, read+delete blocks PUT, and the rejected-capability error code is stable.
Forgejo Repository Trust (Auth Method)
The architectural piece. Modeled on Hashicorp Vault's auth-method pattern: a trusted repo can prove its identity using something it already has (the CI runner's short-lived API token) and exchange that proof for a scoped, capability-limited JefeVault token. New TrustedRepository model bound by (forgejoUrl, repoFullName) with allowed scopes, capabilities (default [read]), and a maxTtlSeconds ceiling. POST /vault/auth/forgejo takes {forgejoToken, forgejoUrl, repoFullName, ttlSeconds?}, calls Forgejo's /api/v1/repos/{owner}/{repo} with the presented token, requires the response full_name to match exactly, and requires permissions.push === true — proving the token belongs to a committer rather than an outside reader. Only then does it mint a scoped service token via the existing ServiceTokenService. Five-second network timeout. Every reject path is fail-closed and audit-logged with reason codes (unknown_repo, forgejo_token_invalid, repo_mismatch, no_push_permission, forgejo_unreachable). Requested TTL is capped at min(requested, maxTtlSeconds).
Audit-Log Invariant: Don't Echo the Bearer
The single most important property of an auth-method exchange: never log the presented credential. Wrote the service explicitly so forgejoToken appears in exactly two places — the function parameter and the outbound Authorization: token ${forgejoToken} header to Forgejo. It is never passed to auditService.log(), never stringified, never embedded in an AppError message, never console-logged, never included in a thrown err object surfaced to the client. Every audit call logs only forgejoUrl, repoFullName, reason, forgejoStatus, issuedTokenName, scopes, capabilities, ttlSeconds. The pre-push secrets scan paid this invariant particular attention from both sides; both agents independently confirmed the token never escapes the request boundary.
Pre-Existing Tightening Caught in the Same Push
The working tree already had three pending hardening changes that I cleaned up and committed alongside the new work: (1) a new sessionOnlyAuthMiddleware applied to /vault/audit-log and the service-token management routes, so a compromised jv_ token cannot enumerate or mint other tokens or read the audit trail; (2) resolveVaultUrl() in the CLI now fails closed if JEFEVAULT_URL is unset and no cached session exists — for a secret store, silent fallback to a hardcoded http endpoint is unacceptable; (3) the backup CLI validateBackupPath() now requires the resolved path to live inside the cwd or user home (blocks .. escape and absolute paths), rejects NUL bytes, and exact-matches blocked system directories instead of prefix matching. Thirteen new path-traversal tests cover the cases.
Extension UX
Lower-stakes but it makes the new features actually usable. The popup header is now [+] [☰] [Lock]; the hamburger opens a dropdown over Open Vault / Secrets / New Service Token / New Wrapped Token / Password Options / Settings. First pass tried overlay modals — too cramped against the popup viewport. Second pass converted each quick-action into a full popup view (same pattern as the existing Add Entry view), with capability checkboxes and a Read-only shortcut button on both the service token and wrap forms. The full Vault page Settings tab gains a Trusted Repositories manager with the same fields. Hash-based deep linking (vault.html#secrets, #settings) means popup quick-actions can jump straight to the right tab.
Hygiene Bookkeeping
Two-pass secrets scan (security-engineer + independent verification) found nothing of interest in the changeset: no hardcoded credentials, no .env files, no build artifacts, additive-only schema migration, all test fixtures are obvious placeholders. Tightened .gitignore to cover session-local Claude Code artifacts (.claude/agent-memory/, .claude/settings.local.json) and untracked the previously-committed settings.local.json in the same commit. Outstanding non-blocker: .gitignore should also cover .env.*, *.log, .vscode/, .idea/, .DS_Store.
What's Next
- Replace the free-form scope text input in the popup quick-action forms with a dropdown populated from existing namespaces — flagged by the operator mid-session.
- End-to-end CI integration: a real Forgejo Actions job exchanges its
GITEA_TOKENfor a JefeVault token and pulls a secret. This is the validation that matters. - Consider extending the trust model with branch restriction (only mint tokens when the request originates from a specific branch) and commit-signature verification — both deferred from the initial design conversation as future tightening.