Portunus

السجل الرسمي الأمن

Secret broker for agents: reference secrets by name; values injected only at the execution boundary.

التثبيت

أضف هذا إلى إعدادات MCP في عميلك (Claude Desktop: claude_desktop_config.json، وCursor: ‏.cursor/mcp.json، ويستخدم VS Code وغيره الصيغة نفسها «mcpServers»).

OCI package ghcr.io/mdostal/portunus:0.34.0
Claude Code
claude mcp add portunus -- docker run -i --rm ghcr.io/mdostal/portunus:0.34.0 mcp
إعداد JSON (Claude Desktop وCursor وVS Code)
{
    "mcpServers": {
        "portunus": {
            "command": "docker",
            "args": [
                "run",
                "-i",
                "--rm",
                "ghcr.io/mdostal/portunus:0.34.0",
                "mcp"
            ]
        }
    }
}

التوثيق

مأخوذ من ملف README للمشروع على GitHub ويُحدَّث أسبوعيًا. تفتح الروابط على GitHub.

المحتويات
  1. Portunus
  2. Get your agent using it in one command
  3. The problem
  4. What works today
  5. Roadmap (help wanted)
  6. Honest scope
  7. Built in the open
  8. Quick start
  9. Architecture, vision & design decisions
  10. Component model
  11. Why it's safe
  12. Install
  13. Usage
  14. MCP server — for other agents/harnesses
  15. Standalone UI
  16. Library API
  17. Development
  18. Layout
  19. License
  20. Support this project

Portunus

A secret broker that finds secrets by metadata and injects them — without them ever touching your LLM. Free & open source.

Built by Mathew Dostal — fractional CTO, Dostal Technology.

Choose your vault. Give your AI agents safe tooling to use it — without secrets ever touching the LLM.

portunus is a thin broker between your AI agents and the secret vault you already run. It doesn't store your secrets or reinvent vault security — it resolves them by metadata and injects them at the boundary, so an agent can use a secret (call an API, deploy, sign) without the raw value ever landing in the model's context, logs, or tool output.

(Portunus — the Roman god of keys and doors. Fitting.)

Get your agent using it in one command

curl -fsSL https://mdostal.github.io/portunus/install.sh | bash

Installs the CLI, wires the MCP server into whatever AI coding agent CLIs are already on the machine (Claude Code, Codex CLI today), and drops in the usage skills — so an agent gets the boundary-only usage conventions immediately, not just a raw tool list. Already have portunus installed? Just run portunus agent init — same thing, idempotent, safe to re-run any time (e.g. after installing a new harness). portunus agent status shows what's currently wired without changing anything.

Since this runs standalone often — a local key-value store on its own, not just a plugin — it keeps itself current too: portunus update check (read-only) / portunus update run (installs, with either an interactive confirm or --yes) walk the same "your own already-authenticated gh CLI, pinned to an exact release tag, never a silent unattended swap" posture as the desktop app's updater. A one-line stderr heads-up ("portunus vX.Y.Z is available…") shows up on its own, throttled to once a day, never installs anything by itself — opt out any time with PORTUNUS_NO_UPDATE_CHECK=1.

portunus site → — landing page, install snippets, and the component-model pitch in one place.

Documentation → — quickstart, concepts, architecture, CLI/MCP reference, and Process & Design, the design record behind every feature, published from .pHive/ and rebuilt on every push to main.

The problem

AI agents need real secrets to do real work — deploy tokens, API keys, DB creds. But the moment a secret touches an LLM's context, a log line, or a tool result, it's leaked. Most teams already have a vault. What's missing is the layer that lets an agent use that vault safely.

That's portunus.

What works today

  • The secret value never enters the LLM's context, logs, or tool output. Enforced by code — every boundary path (the resolver's three sinks, every MCP tool, every adapter's failure path) is covered by a test that asserts the value never appears in a return value, stdout/stderr, or the audit log, including on the failure path, not just the happy one.
  • Resolve by metadata. Ask for anthropic / demo / prod by what it is, not by memorizing a key name. Fail-closed — an ambiguous match refuses and lists candidates, never returns the wrong secret.
  • Inject at the boundary — env var, file (env/json/yaml), subprocess argv, or a 0600 tempfile. The agent gets a reference or a path, never the value.
  • Bring your own vault — GCP Secret Manager (keyless, via Workload Identity Federation), a local-encrypted vault (Fernet: AES-128-CBC + HMAC-SHA256, zero cloud setup), and Chrome's own saved logins (macOS Keychain / Linux Secret Service-KWallet, read-only — see docs/backends.md) work today.
  • One core, three doors — an MCP server (21 tools, metadata-only by default), a 35-command CLI, and a Next.js UI (plus a native macOS desktop shell around the UI).
  • Tamper-evident, hash-chained audit log — keyed by secret name, never value. portunus verify proves the chain.
  • Per-agent access control, opt-in. portunus roles set --principal <agent> scopes a policy to a specific identity (org/project/env/repo); portunus roles enforce on activates it. A scope with no configured policy always stays fully open — enforcement only ever narrows what a specific principal can reach, never a surprise lockout. See "Access control (Petitio)" below.

Roadmap (help wanted)

  • [ ] More vault backends — HashiCorp Vault, AWS, Azure, 1Password, Doppler, Infisical (fail-closed stubs today — request the one you need and it jumps the queue)
  • [ ] Native HTTP-client injection adapter (today it's portunus resolve --exec curl ..., or a caller-supplied boundary callable via the library's resolve_call — works, but there's no built-in HttpHeaderAdapter/HttpBodyAdapter class yet, only EnvVarAdapter/FileAdapter)
  • [ ] More secret-rotation adapters (eight real adapters ship today, all run via portunus rotation run: OAuth refresh tokens, GCP service-account keys, Google Cloud API keys, Resend API keys, Grafana Cloud access policy tokens, YouTrack/Hub permanent tokens, Vercel access tokens, and GitHub OAuth App tokens from an App you own. GitHub PATs are manual because GitHub has no reissue API. Stripe is still a stub that raises. portunus liveness check flags dead GitHub tokens. See docs/rotation.md for the per-subtype capability matrix)
  • [ ] Scope-aware list/tree — these two MCP tools still return full-vault metadata regardless of the caller's own access scope; only the actual resolve/inject path is gated today (a real, named gap — not started; portunus-petitio-rbac design-discussion.md's self-grill)
  • [ ] Approval tokens (portunus approve) are scoped to a reference name only, not to the identity that requested it — a different concurrently-running agent can walk through the same approval window (also a real, named gap, same source)

Honest scope

Portunus guarantees that its own paths never leak the value. It can't stop a command you wrap from echoing its own argument — echo {{secret}} will print the secret, because that's the command's doing, not portunus's. That's the correct, defensible boundary, and it's documented in the code.

Built in the open

Solo-built and moving fast. The core is real and tested; the backends and rotation are where the growth is. Break it, tell me where it leaks, open issues, send PRs — poking a hole in the security model is a gift, open it as an issue.

Quick start

curl -fsSL https://mdostal.github.io/portunus/install.sh | bash   # or: pip install -e ".[test]" from a clone

# store a secret -- stdin/file only, never an inline flag (would land in shell history)
echo -n "sk-ant-..." | portunus drop shared-anthropic dostal-shared-anthropic --stdin
portunus state shared-anthropic enabled     # lands dropped/fail-closed by default; this makes it injectable

# resolve it only at the boundary -- the value never touches your shell history, a log, or an LLM turn
portunus resolve --exec curl -H "Authorization: Bearer {{secret:shared-anthropic}}" \
  https://api.anthropic.com/v1/messages

See Install and Usage below for the full picture — registering an existing secret instead of dropping a new one, tag-based lookup, the MCP server, and the UI.

Architecture, vision & design decisions

  • docs/architecture.md — the adopter-facing reference: a component diagram, ARCA's backend-selection precedence as a decision tree, Petitio today-vs-the-designed- future, the full request/resolve sequence, and the rotation-provenance design — five diagrams, kept honest about what's real versus what's a stub.
  • Long-term direction (from this project's own planning doc, .pHive/project-profile.yaml):

    Portunus is a standalone, releasable, containable secret finder/manager — not just a Dostal-harness plugin. [...] GCP Secret Manager is the store; Portunus is the resolver+injector layer on top — that split is decided, not open. Long-term it should also self-declare as an L2 Pantheon plugin [...] while still standing up its own harness integration (skill/CLI) and UI when run standalone.

  • Every design decision, with the reasoning, not just the outcome — .pHive/epics/ holds a research brief + design discussion (often with an adversarial "grill" pass) for every feature this project has shipped: why local+GCP are the only real ARCA backends today, why rotation first shipped as provenance-only, why the desktop app shells out to gh instead of embedding a token, and more. This is the actual paper trail, not a curated highlight reel.
  • CHANGELOG.md — what shipped, release by release.
  • Adapter pattern — a new ARCA backend implements one method (access(sm_name, project="") -> str, see SecretBackend in backend.py) and fails closed with a clear error until it's real; request one or see an existing stub (e.g. InfisicalBackend) as a template.

Component model

Portunus is the whole secret-broker system, not any single piece of it. Its components carry their own (Latin, theme-consistent) names:

ComponentRoleWhere it lives today
OSTIARIUSThe gatekeeper API — the only way to request things from the vault or deposit things into it (the request/deposit boundary), including metadata-only queries like "what secrets exist for this project". Three entry points, one implementation: the portunus CLI, the standalone UI's API routes, and an MCP stdio server for other agents/harnessesresolver.py + the portunus CLI (cli.py) + mcp_server.py (portunus mcp)
ARCAThe vault store — pluggable backends behind one interface, actually selected per-Reference/per-project (a reference's own backend override, else its project's VaultBinding, else the global fallback), not one global choice. Real today: local-encrypted (default), GCP Secret Manager (keyless via WIF, optionally with a recency-aware pull-only sync-down cache that survives a real network outage by serving the last-known-good cached value), OAuth refresh-token broker, and Chrome's own saved logins (macOS Keychain / Linux Secret Service-KWallet, read-only, Windows deferred — docs/backends.md). Honest stubs, not yet real: AWS Secrets Manager, HashiCorp Vault, Infisical, Doppler, 1Password, Azure Key Vault — each fails closed with a clear error and a link to request it, never silently mis-routes. See docs/architecture.md for the full picture.localvault.py (LocalEncryptedBackend, default); backend.py (SecretBackend, GcloudBackend, SyncingBackend, VaultBinding, and the six stub classes); auth.py (keyless WIF/OIDC credential minting); discover.py (read-only enumeration of what already exists in a live provider project)
PetitioThe approval-gate wrapper — wraps every OSTIARIUS request so access is always gated (grant / gate / approve + lifecycle guard). Per-agent access control is real and opt-in: every call threads an Identity (requester) through check_injectable(), which evaluates it against roles.py's PolicyRecords (org/project/env/repo scope + principal) via roles.evaluate() — every resolve gets a would-allow/would-deny audit line regardless, but only actually raises NotAuthorized when portunus roles enforce on has been explicitly run for that vault (default: off; a scope with zero configured policies always stays open). Not yet scope-aware: the list/tree MCP tools, and approve()'s token (scoped to a reference name, not the requesting identity) — see Roadmap. Adjacent, not the same thing: rotation provenance (rotation.py) records which provider could rotate a reference and whether Portunus has a real adapter for it. Eight adapters are real (auto), all run via portunus rotation run: OAuth refresh tokens (OAuthRefreshRotationAdapter), GCP service-account keys (GCPServiceAccountKeyRotationAdapter), Google Cloud API keys (GoogleCloudAPIKeyRotationAdapter), Resend API keys (ResendRotationAdapter), Grafana Cloud org-level access policy tokens (GrafanaCloudRotationAdapter), YouTrack/Hub permanent tokens (YouTrackRotationAdapter), Vercel access tokens (VercelRotationAdapter), and GitHub OAuth App tokens from an App you own (GitHubRotationAdapter; GitHub PATs are manual, since GitHub has no reissue API). Stripe is still a stub that raises. See docs/rotation.md. See docs/architecture.md §3/§17.broker.py, roles.py, rotation.py
(audit)Tamper-evident hash-chain access log underneath all of the aboveaudit.py

So: an agent talks to OSTIARIUS; Petitio decides whether the request may proceed; only then does ARCA give up (or accept) a value — and every decision lands in the audit chain.

Portunus keeps a reference registry (name -> Secret Manager location, never the value) and resolves a {{secret:NAME}} placeholder to a live value only at the execution boundary — the actual outbound API / tool / build call, which runs after the model has produced its output.

The non-negotiable principle: an LLM/agent context must never contain a plaintext secret. Secrets are referenced by name; the harness fetches and injects the real value at the boundary. The model only ever sees {{secret:slack-bot-token}}.

This is a Dostal harness plugin (type: core, engine tool) — one of the first runtime plugins alongside the Anonymizer (PII) and Approval plugins. Multica/Hive registers its manifest.json and links its Vault tab; agents call the portunus tool.

                 model sees only  {{secret:shared-anthropic}}
  ┌──────────┐   ┌───────────┐   ┌────────────────────┐   ┌──────────────┐
  │  ticket/ │──▶│  the LLM  │──▶│  PORTUNUS RESOLVE  │──▶│ outbound API │
  │  agent   │   │ (context) │   │  gate + fetch + sub│   │  (real value)│
  └──────────┘   └───────────┘   └─────────┬──────────┘   └──────────────┘
                                           │ fetch by name (boundary only)
                                           ▼
                                  GCP Secret Manager

Why it's safe

  • The registry has no value field. A Reference records the SM name/path, scope, kind, lifecycle state, and approval gate — nothing secret. It's safe to read, copy, and inspect.
  • The resolver never returns a value up the stack. The plaintext flows only into one of three boundary sinks: a caller-supplied callable, an exec'd subprocess's argv, or a 0600 temp file the caller must delete. It is never returned to arbitrary caller/model code and never written to a log.
  • Every fetch is policy-gated. dropped/revoked references fail closed; gated references need a time-boxed human approval before they resolve.
  • Every resolution is hash-chain audited — the log records the reference/SM name and result, never the value; portunus verify proves the chain is untampered.

Install

curl -fsSL https://mdostal.github.io/portunus/install.sh | bash   # CLI + MCP + agent skills, one shot
# or, from a clone:
pip install -e ".[test]"

Not on PyPI under this name yet — portunus there is an unrelated, unmaintained package (IQTLabs/portunus); this project's own PyPI name is pantheon-portunus (the installed command is still just portunus) once a real release ships. The installer above pulls straight from GitHub in the meantime.

Requires Python ≥ 3.10. The default backend is the local-encrypted ARCA tier (LocalEncryptedBackend, cryptography's Fernet recipe — AES-128-CBC + HMAC-SHA256; we never hand-roll a cipher). The master key lives in its own 0600 file, separate from the encrypted vault file, both under PORTUNUS_HOME (default ~/.portunus, 0700). Set PORTUNUS_BACKEND=gcloud to use GCP Secret Manager instead — keyless by default via Workload Identity Federation, multi-project aware (see "GCP: multi-project + discovery" below); PORTUNUS_BACKEND=aws selects the AWS Secrets Manager stub (fails clearly — not yet implemented); PORTUNUS_BACKEND=mock is for tests/dry-runs.

Running in a container

For most real deployments this is the actual install path — not "download the desktop app," but "pull an image and run it alongside whatever you're already deploying." The Dockerfile at the repo root builds the CLI + MCP server (not the UI — see docs/architecture.md for why that's a deliberate v1 scope line); it works unchanged under Docker or Podman.

docker build -t portunus .
docker run --rm -v portunus-home:/home/portunus/.portunus portunus resolve --exec curl \
  -H "Authorization: Bearer {{secret:shared-anthropic}}" https://api.anthropic.com/v1/messages

Prebuilt image + MCP Registry. Release tags also publish a multi-arch image to ghcr.io/mdostal/portunus:<version> (and :latest) and list it in the official MCP Registry as io.github.mdostal/portunus, so an MCP client can run the server as docker run -i --rm -v portunus-home:/home/portunus/.portunus ghcr.io/mdostal/portunus mcp. Inside a container only the backends that can reach their store from there work: local-encrypted (through the mounted PORTUNUS_HOME volume) and GCP Secret Manager (with credentials you mount or inject). The Chrome saved-login backend reads your host's Chrome profile, so it is not available from the container image.

⚠️ PORTUNUS_HOME must be a real, persistent volume for the local-encrypted backend. The image declares it as a VOLUME, so an unmounted run still gets a persistent anonymous volume — but if that volume is ever removed (docker rm -v, docker volume rm, an ephemeral CI runner that never mounts one at all), master.key regenerates from scratch on next start and every previously-stored secret becomes permanently unrecoverable. Always bind-mount or name a real volume in anything beyond a throwaway test. This risk doesn't apply if you're using the GCP backend only (PORTUNUS_BACKEND=gcloud) — there's no local ciphertext to lose.

Two real deployment shapes, not one abstract "containerize it":

  • CI/build-step: exactly the command above — a one-shot container that resolves a secret into a single command's argv, same boundary-only guarantee as running the CLI locally.
  • Kubernetes sidecar: Portunus's container and your app's container share one pod, so they already share a network namespace and can share a volume for free. The app reaches Portunus via kubectl exec (or Portunus starts the app itself: resolve --exec <app-start-command>, the same pattern the CI example uses) — never a network call, never a second trust boundary. This is a deliberate v1 scoping decision, not an oversight: the MCP server is stdio-only today, which makes same-pod/same-host reachability the natural fit; a network-reachable shared Portunus service many pods call would need real network-level authentication between caller and broker (today's roles.py access control assumes a trusted local-process caller, same as every other same-host boundary in this design) — real, larger, explicitly future work.

Auth per backend, per environment:

EnvironmentLocal-encrypted backendGCP backend
Local Docker/Podman devzero-config — self-bootstraps on first writemount your host's ~/.config/gcloud read-only (-v ~/.config/gcloud:/home/portunus/.config/gcloud:ro) so the container reuses your own already-authenticated gcloud identity
Real Kubernetesmount a real PersistentVolumeGKE Workload Identity (a k8s ServiceAccount bound to a GCP service account) — keyless, no credential ever touches the container image or a volume. This is the recommended production path, not one option among several.

docker-compose.yml at the repo root is a runnable worked example of the sidecar pattern — docker compose build && docker compose run --rm portunus drop ... — see the file's own header comment for the full walkthrough.

Podman note. Podman's rootless-by-default model remaps container UIDs to an unprivileged host UID range, which can affect bind-mounted (not named-volume) host directory permissions differently than Docker. If a bind mount isn't writable as expected, try podman run --userns=keep-id .... Named volumes (as in the examples above) sidestep this entirely — prefer them over host bind-mounts unless you specifically need host-path access.

Usage

Drop a secret into Arca (harness-side only)

drop is how a value gets into the local-encrypted vault — from stdin, a local file, or an interactive masked prompt, never an inline flag (it would land in shell history / ps) and never through an LLM turn. It lands in state=dropped (fail-closed) so a separate, explicit enable is required before it's injectable. Surrounding whitespace (a real, common artifact of copy-pasting a token from a web page or a Windows-authored file) is always trimmed:

portunus drop shared-anthropic dostal-shared-anthropic --value-file /path/to/value   # or --stdin
portunus state shared-anthropic enabled     # now injectable
portunus state shared-anthropic locked      # optional: freeze further changes, still injectable

Omit both --stdin and --value-file for an interactive, masked prompt (no terminal echo, entered twice, refusing unless both entries match) — for a human at their own terminal, not an agent's tool call: an agent's own tool-execution channel is fundamentally non-interactive (no live TTY), so this mode only ever helps a human running the command themselves:

portunus drop shared-anthropic dostal-shared-anthropic
value:
value (confirm):
Register a reference to an out-of-band secret (name → Secret Manager location)

Use this instead of drop when the value already lives in the backend (e.g. an existing GCP secret):

portunus reg add shared-anthropic dostal-shared-anthropic --scope shared --kind anthropic
portunus reg show
Store browser/login session state in Arca

Arca can persist Playwright-style storageState or another JSON-serializable session object under a site/account namespace. The raw vault stores one encrypted blob at session:<site>:<account>; inspection returns only TTL and rotation metadata, never cookies, tokens, or local storage values:

from portunus import LocalEncryptedBackend

vault = LocalEncryptedBackend()
vault.store_session(
    "example.test",
    "dostal@example.test",
    storage_state,
    ttl_seconds=3600,
    rotation_interval_seconds=900,
)
metadata = vault.inspect_session("example.test", "dostal@example.test")
record = vault.load_session("example.test", "dostal@example.test")  # raises SessionExpired past its TTL

Also available via the CLI — session load follows resolve's exact boundary discipline: it writes a 0600 temp file and prints only the path, never the record:

portunus session store example.test dostal@example.test --value-file storage_state.json --ttl-seconds 3600
portunus session inspect example.test dostal@example.test    # metadata only
portunus session list                                        # every stored session, metadata only
path=$(portunus session load example.test dostal@example.test)   # fails closed once expired (--allow-expired to override)
# ... use "$path" ...
rm -f "$path"
portunus session remove example.test dostal@example.test

Access control: --org/--project/--env/--repo on session store are optional scope metadata that session load gates through the exact same roles.evaluate() policy engine every other reference already uses — a session is exactly as sensitive as a secret value. Unconfigured by default (no scope set, or no matching policy = always allowed, same permissive-if-unconfigured posture as everything else); only session load is gated, store/remove/inspect/list never are, matching resolve's own gated-fetch-only precedent.

Bridging to a real Playwright browser context: session load's printed path holds the full record (namespace/TTL/rotation/scope alongside the payload), not the bare storageState shape — one line unwraps it:

import json
from playwright.sync_api import sync_playwright

record = json.load(open(path))          # $path from `session load`, above
storage_state = record["session"]        # the bare Playwright storageState dict

with sync_playwright() as p:
    browser = p.chromium.launch()
    context = browser.new_context(storage_state=storage_state)   # or write it back out and pass a path

No separate injection module exists or is needed — browser.new_context(storage_state=...) already accepts a dict directly (or a path to one written to a file), so this one-line unwrap is the entire bridge. Live-verified against a real Chromium context, not a synthetic JSON-shape assertion — see docs/architecture.md §21.

Find, inject, and ask — metadata-tag lookup and boundary injection

References carry structured tags (provider/project/env, plus an open tags{} dict) alongside legacy scope/kind. Lookup by tags is fail-closed: zero or more-than-one match is always an explicit error, never a guess.

# Find a reference by tags -- metadata only, never a value:
portunus find --tags provider=vercel,project=mdostal.com,env=prod

# Resolve by tags and inject directly at a boundary target:
portunus inject --tags provider=vercel,project=mdostal.com,env=prod --target env --var VERCEL_TOKEN
portunus inject --tags provider=vercel,project=mdostal.com,env=prod --target file --format env --key VERCEL_TOKEN --path ./.env.local

# Semantic front door -- plain-language request instead of exact tags:
portunus ask "the vercel secret for mdostal.com in prod" --target env --var VERCEL_TOKEN
portunus ask "the vercel secret for mdostal.com in prod"   # resolve-only preview, no injection

ask never guesses either: an unrecognized or ambiguous request gets a clarifying question on stderr instead of a pick. Three thin Claude skills under .claude/skills/ (also installed at Claude Code's user scope so any session on the machine sees them, not just this repo) wrap the CLI/MCP surface for agent tool-call use: portunus-ask (fetch/inject by description), portunus-drop (store a new secret you were just handed, single or bulk), and portunus-vault-setup (configure/check a project's backend + sync mode, force a sync).

ask also recognizes add/rotate language ("add"/"create"/"new secret", "rotate"/"roll"/ "regenerate") and routes to a request, not a fulfillment — an agent never supplies or sees a value at any point:

# Requires explicit --name/--tags -- free text can't safely name/tag something brand new:
portunus ask "add a new secret" --name gh-ci-token --tags provider=github,project=portunus,env=prod

# Flags an existing reference for rotation (metadata only, value untouched):
portunus ask "rotate the vercel secret for mdostal.com in prod"
Move a reference (re-tag in place)
portunus retag vercel-mdostal --env staging
portunus retag vercel-mdostal --tags team=platform

Rejects any change that would collide with a different existing reference's tag combination — fails closed, same contract as resolve_by_tags.

Richer metadata — what a secret is, what it's for, how it's injected, and how it relates to others

References carry description (what it is), purpose (what it's for), injected_as ({env_name: "env:VAR" | "file:path"}, documenting how it gets injected per environment), group (a hierarchical path placing it in a tree, e.g. project-y/supabase/auth), and related (explicit cross-references to other reference names) — all optional, all additive to the existing tag schema:

portunus reg add stripe-prod dostal-stripe-live \
  --scope shared --kind stripe --org firefly-events --project mdostal.com --env prod \
  --group mdostal.com/stripe --related mdostal-com-mongodb-prod
# description/purpose/injected_as/group/related are also settable via retag or the UI's edit form

org sits one level above project — an organizational umbrella spanning several projects (e.g. firefly-events spanning demo-cicd, shindig, and more), the structural field the Standalone UI's Vault Map drill-down and Console's facets key off of. Like every other structured tag, an absent org is never an error — it lands in a (no org set) bucket, same non-dropping treatment group's own (ungrouped) bucket already gets.

portunus tree — navigate secrets by hierarchy and relationship

The LLM-facing structure query: renders every reference's group as a real tree, with related links shown per leaf. A reference with no group never disappears — it renders under an (ungrouped) bucket rather than being silently dropped:

$ portunus tree --project demo-cicd
demo-cicd/
  clerk-webhook/
    demo-cicd-clerk-webhook-secret-dev
    demo-cicd-clerk-webhook-secret-prod
  event-api/
    dev/
      demo-cicd-event-api-dev-mongo-uri
      demo-cicd-event-api-dev-jwt-secret
      ... (48 more)
    prod/
      demo-cicd-event-api-prod-mongo-uri
      ... (39 more)
  ... (18 more apps)

--json gives the same structure as nested JSON for programmatic/UI consumption. A related entry naming a reference outside the current result set is marked (unresolved), never silently dropped. The Project Explorer UI tab renders the identical tree client-side from the same data.

portunus impact — what a rotation actually touches

tree shows structure. impact answers "if I rotate this, what breaks?" for one reference. It follows related transitively in both directions and lists every reference it reaches, along with the projects, repos, source_files, envs and injected_as targets they're wired into:

$ portunus impact supabase-service-key
rotating supabase-service-key touches 3 other references in 3 projects (edge-fns, project-y, worker)

supabase-service-key
    project=project-y  env=prod  repo=project-y-api  group=project-y/supabase/auth  state=enabled
    injected_as prod=env:SUPABASE_SERVICE_KEY
    file project-y-api:docker-compose.prod.yml
  supabase-jwt-secret  (related)
    edge-fn-key  (related)
        file edge-fns:.env.production
  worker-supabase-key  (lists supabase-service-key as related)
      injected_as prod=file:/run/secrets/supabase
...
suggested related -- NOT linked, not walked above; ...
  supabase-anon-key  [shared source file: docker-compose.prod.yml; shared group parent: project-y/supabase; same repo: project-y-api]

The traversal only follows the human-curated related list. The suggested related block is derived on read from a shared source file, group, group parent or repo. It is never written, never walked and never counted, so related stays a list a human chose. rotation run prints the same summary before it runs. When a real rotation reaches other references, it asks for confirmation (or needs --yes when there's no terminal). The UI detail drawer shows the same tree above the Rotate buttons.

repo/source_files — which git repo (and file) actually consumes a secret

A real gap, found by looking at the real data: group already captures a rough service/env hierarchy (demo-cicd/event-api/prod), but nothing distinguished which git repo consumes a secret from which cloud project it lives in — one shared GCP project (demo-cicd) can span many repos/services. repo is a new structured field (find --tags repo=event-api works immediately, same as provider/project/env); source_files is a list of file paths in that repo that reference the secret (a docker-compose.yml, a CI workflow) — same optional, human-filled posture as related, not auto-discovered:

portunus reg add stripe-prod dostal-stripe-live --repo billing-service
portunus retag stripe-prod --source-files docker-compose.prod.yml,.github/workflows/deploy.yml
portunus find --tags repo=billing-service

Backfilling many references at once — the real unlock for hundreds of already-grouped secrets, not a one-at-a-time retag per reference:

# Preview first -- makes zero writes, reports exactly what would change:
portunus retag-bulk --group-prefix demo-cicd/event-api --repo event-api --dry-run

# Then actually apply it:
portunus retag-bulk --group-prefix demo-cicd/event-api --repo event-api

Selects every reference whose group starts with --group-prefix (a plain string prefix, no query language) and retags each — one reference's collision failure is reported under failed and never aborts the rest of the batch, same precedent drop-bulk already set.

portunus tree --by repo re-renders the identical tree, keyed on repo instead of group — same command, same shape, a second facet on the same underlying data:

portunus tree --project demo-cicd --by repo   # --by group is the default, unchanged

A reference with no repo set renders under a (no repo set) bucket, same non-dropping guarantee (ungrouped) already has for group. The Project Explorer UI tab has a matching Group/Repo toggle above its tree view.

"What secrets exist for this project?" — an LLM-facing metadata query

An agent can ask what's available without ever seeing a value — metadata only, zero-to-many, never a fail-closed single-match requirement (it's a browse, not a resolve):

portunus list --project mdostal.com
portunus ask "what secrets are available for mdostal.com"
portunus search — find a key without knowing its exact name

portunus list dumps every reference for a project. portunus search finds the ones you actually care about — a free-text substring match across name, sm_name, description, purpose, tags, and group, across one project or all of them at once:

# Find anything mentioning "linear" across all projects
portunus search "linear"

# Scope to one project
portunus search "discord" --project ffe-cicd

# Machine-readable output (what the MCP tool and UI route use)
portunus search "stripe" --json

# Narrow by state or env
portunus search "api" --state enabled --env prod

Results sorted: enabled references first, then alphabetical. An empty query always returns nothing — the command never dumps the whole registry. Same three-entry-point shape as portunus list: one core implementation (search.py), wired into the CLI, the MCP tool (portunus_search), and the UI API route (/api/search?query=<text>).

The MCP tool is the recommended path for agents — call it before portunus_resolve_* when you don't know the exact reference name:

# Agent: find the Linear token, then inject it
refs = portunus_search("linear", project="ffe-cicd")
# refs[0]["name"] → "linear-token"
GCP: multi-project + keyless auth (WIF) + discovery

GcloudBackend authenticates keyless by default — no static service-account JSON, no long-lived AWS-style key pairs (assert_no_long_lived_cloud_keys() enforces this). Per-project bindings live in PORTUNUS_HOME/vault-bindings.json (0600, project → backend/sync_mode/WIF audience/account — see "Per-project/per-reference vault routing" below); with no bindings file, PORTUNUS_GCP_PROJECT/PORTUNUS_GCP_WIF_AUDIENCE give today's zero-config single-project behavior unchanged. Two references can point at two different GCP projects and each resolves against its own binding in the same process.

portunus auth gcp --project my-project-12345   # mint + report identity/scope/expiry only

Multiple GCP accounts at once. gcloud already stores multiple credentialed accounts simultaneously (gcloud auth login <email> adds one without removing others) — but any command with no explicit identity follows whichever account gcloud considers "active," a single mutable pointer. vault-bindings.json's account field fixes this: set it per project and every Portunus GCP call for that project passes --account=<email> explicitly, regardless of gcloud's ambient active account. (Mutually exclusive with a WIF binding on the same project — a minted access token already carries identity.) Add --impersonate-service-account to act as a least-privilege service account. With WIF, that impersonation happens in-process through the IAM Credentials API, so no gcloud login is needed at all. See docs/gcp-keyless.md.

portunus bindings set demo-cicd --account work@example.com
portunus bindings set my-project-12345 --account personal@example.com
portunus bindings show                          # real values -- a local CLI reading your own 0600 config
portunus discover --provider gcp --project demo-cicd            # uses work@example.com
portunus discover --provider gcp --project my-project-12345  # uses personal@example.com
# both work in the same session, regardless of which account gcloud currently considers active

Discovery is read-only and opt-in — it enumerates what already exists in a live GCP Secret Manager project (names + labels + create-time, never a value — discover.py holds no reference to any backend's access() method at all) so you register real secrets instead of re-creating them blind. Worked example, against a real project:

$ portunus discover --provider gcp --project my-project-12345
not-registered  AUTH_SECRET
not-registered  SANITY_API_ADMIN_TOKEN labels={'project': 'dafshiq1', 'scope': 'admin', 'service': 'sanity', ...}
not-registered  dostal-shared-gemini labels={'app': 'dostal-swarm', 'kind': 'gemini', 'scope': 'shared'}
... (19 secrets total)

$ portunus discover --provider gcp --project my-project-12345 --register
registered  my-project-12345-auth_secret (state=requested)
registered  my-project-12345-sanity_api_admin_token (state=requested)
...

Every --registered reference lands in state=requested — the same fail-closed placeholder state agent-initiated ask "add ..." requests use — so nothing becomes injectable until a human reviews and promotes it. The local reference name is derived as <project>-<sm-name> so two different projects that happen to share a secret name never collide.

For a project bound --sync-mode cached (see below), --register also warms the local encrypted cache immediately — no separate first-resolve round-trip needed before the value is available offline. The reference's state still stays requested: it's not injectable any sooner, only cached sooner.

Per-project/per-reference vault routing + sync-down caching

A reference resolves through whichever backend its own project (or the reference itself) is actually bound to — not one global PORTUNUS_BACKEND choice for the whole process. Three levels of precedence: a reference's own backend override (set via portunus_drop/reg add/ retag --backend {local,gcp,aws}) wins outright; else the project's VaultBinding.backend (portunus bindings set <project> --backend ...); else today's global PORTUNUS_BACKEND env var, unchanged, as the final fallback. This means my-project-12345 and demo-cicd can resolve correctly in the same process without ever setting PORTUNUS_BACKEND=gcloud by hand.

portunus bindings set gig-tracker --backend local              # everything in this project stays local
portunus bindings set my-project-12345 --backend gcp --sync-mode cached
portunus bindings show                                          # backend + sync_mode per project

--sync-mode cached opts a project into a recency-aware, pull-only sync-down cache — GCP → local only, never the reverse. On each access, Portunus checks the remote's current value version against what's cached locally (a cheap, metadata-only gcloud secrets versions describe call); a match serves straight from the local encrypted cache with zero remote fetch, a mismatch (a real rotation, or the first sync) re-fetches and re-caches. portunus sync <project> forces this check explicitly — useful for a deploy that wants to materialize a fresh set of secrets once rather than a live Secret Manager round-trip per secret per instance:

$ portunus sync my-project-12345
  synced        my-project-12345-resend_audience_id
  already-fresh my-project-12345-google_generative_ai_api_key

Config lives in PORTUNUS_HOME/vault-bindings.json (0600) — the successor to the earlier gcp-bindings.json, read with a migration-safe fallback: if only the legacy file exists, every entry loads with backend="gcp", sync_mode="direct", byte-for-byte today's real behavior, no manual migration step.

account (a local gcloud identity, e.g. for multi-account setups) and wif_audience (the WIF provider resource name) are also editable straight from Project Explorer's binding panel — the Standalone UI isn't CLI-shelled-out-only for these anymore. Neither field is a credential (account is an identity selector; the identity itself must already be authenticated locally via gcloud auth login), so neither is masked in the UI.

A rotation binding's account (the free-text context hint portunus rotation-bindings set <provider> --account ... already accepted, e.g. a Vercel team slug) is likewise editable inline in a reference's detail view, next to the Auto-rotate button. status (stub/real) stays code-driven and is never reachable from the UI — only the OAuth, GCP service-account, Resend, Grafana Cloud, YouTrack, Vercel and GitHub (OAuth App token) adapters are real today (Stripe is a stub), and a UI control that could flip a stub to real would misrepresent what actually happens on click.

Bulk-import many secrets at once — e.g. importing a batch of candidate passwords/keys before trying each one against something via portunus_resolve_exec, without exposing which one worked until you check the result:

portunus drop-bulk entries.json   # [{"name": ..., "sm_name": ..., "value": ...}, ...]

A malformed entry is reported under a separate failed list and never aborts the rest of the batch.

Vault backup: passphrase-locked export/import

portunus vault export/portunus vault import move the vault's whole critical-state surface (reference registry, master key, encrypted values, vault bindings, audit log) as one portable archive — useful for a machine move, a reinstall, or just a real backup that doesn't depend on a fully-signed desktop app's own local sandboxing.

export PORTUNUS_EXPORT_PASSPHRASE="something only you know"   # or answer the interactive prompt
portunus vault export --out ~/backups/portunus-$(date +%F).pvault
portunus vault import ~/backups/portunus-2026-08-14.pvault --force   # on a new machine

The archive is re-encrypted under your passphrase (PBKDF2-SHA256, 600k iterations) — it never carries the vault's own live decryption key. A wrong passphrase on import fails closed with a clear error; the passphrase itself is never accepted via an inline flag, only the PORTUNUS_EXPORT_PASSPHRASE env var or an interactive prompt. import refuses to touch a PORTUNUS_HOME that already has vault state present unless you pass --force (a full replace, not a merge). CLI-only by design — no MCP tool, no UI surface: an archive containing every secret in your vault should never be triggerable by an LLM-facing tool without you directly running the command yourself.

Vault access transfer: scoped, plain-JSON, no values (vault access)

portunus vault access export/import/verify shares working access to some or all of a vault between two Portunus instances — distinct from vault export/import above, which moves the whole vault including every value. A vault access bundle carries registry pointers and bindings only: it structurally cannot contain a secret value, so it's plain JSON, never passphrase-locked.

portunus vault access export --project my-gcp-project --out access.json
portunus --home /path/to/second/vault vault access import access.json
portunus --home /path/to/second/vault vault access verify

export accepts --project/--org/--tags k=v,... filters (AND semantics; no filter means everything) and stamps each reference with a resolved_backend field — computed once, on the exporting instance, the same way every other command resolves which backend a reference actually uses (explicit override → project binding → the exporting instance's own global default). import reads that field rather than re-deriving it, since only the exporting instance ever knew its own global default. A reference whose resolved_backend is "local" always lands as state=requested on import, regardless of its state on the source — the value lives only on the source machine, so anything else would be a silent lie about readiness; a human fulfills it with portunus drop. Every other backend's pointer (GCP/AWS/ etc.) transfers as-is — no value ever needs to move, since the value lives with the cloud provider, not locally. A conflicting name (same reference, different sm_name/backend) refuses that one entry and reports it clearly, without aborting the rest of the batch; pass --force to overwrite it.

verify makes a real per-reference reachability check through the same boundary-safe resolve_call() a real resolve/ask/mcp call uses — the resolved value is handed to a boundary that immediately discards it, never printed or returned. Failures become actionable hints: a state=requested reference reports the exact portunus drop command; a real auth/IAM failure reports the portunus auth login/gcloud … commands naming the binding's own account and project. verify is CLI-only (no MCP tool) — it can trigger real backend API calls across every reference in the registry in one invocation, so it's a human-initiated batch action, the same posture vault export/import already have.

Note: running any command against a fresh PORTUNUS_HOME — including vault access import/verify on a throwaway target — causes an unused master.key to be generated as a pre-existing side effect of how the local backend is constructed (a long-standing behavior of every command, not something vault access introduces). It's an inert Fernet key, never applied to any data; vault.enc.json, the file that would actually hold an encrypted value, is never created by vault access alone.

Fulfilling a pending request (portunus ui open)

portunus ask "add ..." (below) creates a value-less, state=requested placeholder — an agent already captured every metadata field it knew (org/provider/project/env/tags), but a human still has to supply the actual value. portunus ui open [--fulfill NAME] is the one command an agent's own tool call CAN run itself to make that easy: it only ever opens a browser tab and returns immediately (no TTY, no waiting on human input), landing the human straight on the existing Standalone UI's "Fulfill" form, pre-filled from that reference's own metadata — they only have to type the value:

portunus ui open                              # just opens the dashboard
portunus ui open --fulfill vercel-mdostal     # deep-links straight into that pending request's form

Reads PORTUNUS_UI_URL (default http://localhost:3000, matching npm run dev's own default below). Refuses clearly — before ever opening a browser — if --fulfill NAME doesn't exist or isn't state=requested anymore, and if no UI is reachable at that URL at all (never opens a browser to a broken page). The same "Fulfill…" action is also reachable by hand: click any requested-state reference in the Console/Project Explorer, then "Fulfill…" in its detail drawer.

Target a different vault (--home)
portunus --home /path/to/other-repo/.portunus reg show

Explicit, per-invocation vault override — not automatic multi-vault search across repos (that's still out of scope). Omit it and PORTUNUS_HOME/DOSTAL_SECRETS_HOME/~/.portunus resolve as before.

Resolve at the boundary

Exec mode — plaintext exists only in the child process argv, nothing is written to disk:

portunus resolve --exec curl -H "Authorization: Bearer {{secret:shared-anthropic}}" https://api.anthropic.com/v1/messages

Temp-file mode — writes a 0600 file and prints its path (never the value); caller reads + deletes:

path=$(portunus resolve "key={{secret:shared-openai}}")
# ... use "$path" ...
rm -f "$path"
Policy: gate / approve / grant
portunus gate shared-anthropic          # now requires human approval to resolve
portunus approve shared-anthropic --ttl 3
portunus grant shared-anthropic serviceAccount:agent-att@proj.iam   # audit record only, no IAM change
portunus state shared-anthropic revoked  # emergency: blocks all injection
portunus status shared-anthropic
Audit
portunus audit 25     # last 25 access decisions (names + results, never values)
portunus verify       # prove the hash chain is intact

MCP server — for other agents/harnesses

portunus mcp starts a stdio MCP server — a third OSTIARIUS entry point (alongside this CLI and the standalone UI) so any MCP-capable agent or harness, not just this one, can query and inject Portunus secrets directly, without ever asking a human for a key. Same package, same process, no subprocess boundary — the server calls Registry/Resolver/ Broker in-process, the same way cli.py does.

portunus agent init          # registers it (+ Codex CLI's mcp add, + the usage skills below)
# or by hand, same effect for Claude Code specifically:
claude mcp add --scope user portunus -- portunus mcp

portunus agent init detects whichever of Claude Code / Codex CLI are actually on the machine and wires each one — idempotent, so re-running after installing a new harness is always safe. portunus agent status reports what's currently registered without changing anything; --harness claude/--harness codex (repeatable) narrows init to just one.

Usage skills, not just tools. portunus agent init also installs four Claude Code skills (portunus-ask, portunus-drop, portunus-vault-setup, portunus-vault-audit) to ~/.claude/skills/ — the same ones this repo's own .claude/skills/ ships for working in this codebase, now available to any Claude Code session on the machine, in any project. They carry the actual usage conventions (when to use ask vs. drop, the fail-closed-means-don't-guess discipline, never echoing a value back after a successful store) that the raw MCP tool table below doesn't — a fresh agent gets the judgment, not just the tool names.

Tools:

ToolReturns
portunus_health(shallow=False)Read-only deep self-check, same as portunus health --json; shallow=True is liveness only
portunus_list(project)Every reference's metadata for a project — never a value
portunus_tree(project="")Group hierarchy + related links, same shape as portunus tree --json
portunus_impact(name, max_depth=None, suggest=True)Blast radius of a reference (what a rotation touches), same shape as portunus impact --json — read-only, never a value
portunus_ask_preview(request)What a plain-language fetch request would resolve to — metadata only, previews, never injects
portunus_bindings_show(project="")Configured per-project vault bindings (backend/sync_mode/account/WIF audience)
portunus_discover(project, register=False)Read-only diff against a live GCP Secret Manager project; register=True writes not-yet-registered secrets as state=requested
portunus_suggest_metadata(name, description="", purpose="", group="", tags=None)Propose description/purpose/group/tags — lands in a pending sidecar, never the live field; a human confirms via portunus metadata confirm or the UI
portunus_crawl_candidates(org="", project="")Discovery bundle (sm_name/group/project/org/repo/vault-binding/rotation-binding) for every reference missing metadata — never a value, never a write; read it, then call portunus_suggest_metadata for whichever fields you have a real proposal for
portunus_leak_status(name="")Already-computed leak severity/finding-count/timestamps for one or every reference with active findings — never a value, never file content, never triggers a scan
portunus_run_leak_scan()Runs a real scan over the human-configured leak-scan paths and persists new findings — {ref_name, path, line_number} per finding, never a value; can only read paths already added via portunus_leak_scan_config_add_path
portunus_leak_scan_config_show() / _add_path(glob) / _remove_path(glob)Manage the leak-scan path config — globs only, never a value, never scans anything itself
portunus_leak_mark_rotated(name)Clears a reference's active leak findings — a human/agent assertion Portunus can't independently verify; also invalidates the affected files' scan watermarks so a premature call still gets re-flagged by the next scan
portunus_resolve_to_tempfile(name="", tags=None)A 0600 temp file path holding the resolved value — never the value itself
portunus_resolve_exec(argv, name="", tags=None){stdout, stderr, returncode} from running argv with a {{secret}} marker substituted — never the resolved command line
portunus_drop(name, sm_name, value, ..., backend="")Create a new local-vault-only secret — {name, sm_name, state}, never the value back
portunus_drop_bulk(entries)Create many local-vault secrets in one call — {"created": [names], "failed": [{"name","error"}]}, never a value
portunus_state(name, state)Change a reference's lifecycle state — {name, state}
portunus_sync(project)Force a recency check for every cached-mode reference in a project — {"synced", "already_fresh", "failed"}, names only
portunus_rotation_status(provider="")Configured per-provider rotation bindings (status/account/capability) — one provider or all. capability comes from the adapter registry: auto for oauth, gcp, resend, grafana-cloud, youtrack, vercel and github (GitHub's per-token-type split is in docs/rotation.md), manual for sentry, unknown for the Stripe stub

The injection tools use the same dual addressing as the CLI's own inject/ask: give an exact name (from a prior portunus_list/portunus_tree call) or tags — never raw {{secret:NAME}} placeholder syntax. portunus_resolve_exec is the "make the call for me" tool: write a literal {{secret}} marker wherever the value belongs, Portunus builds the real placeholder and runs the command through a capturing subprocess.run (30s timeout) instead of the CLI's default execvp. A non-zero exit is returned normally, not treated as a tool-level error — that's the wrapped command's own semantics, not Portunus's.

portunus_drop is the create-side counterpart, letting a handed-off agent instance set up a new project's secrets end-to-end without shelling out to the CLI — but local-vault only: it fails closed with the same message the CLI's own drop uses if the active backend is gcloud/aws (Portunus has no write path into GCP Secret Manager or AWS yet — creating a real cloud-side secret is a separate, not-yet-built capability). It lands new references at state=dropped (fail-closed, same as the CLI); portunus_state(name, "enabled") is the separate explicit step that makes one injectable. value is the one argument, across every tool in this server, that flows in from the calling agent's own context rather than out of Portunus — that's inherent to being handed a brand-new secret to store, not a boundary violation. Portunus's guarantee there is narrower but still absolute: the value never appears in portunus_drop's own return, is never logged, and never lands in the audit chain — but the calling agent is responsible for not re-echoing it to the human afterward, the same way it's responsible for not reading a resolve_to_tempfile path back into its own output.

Worked example — "give them the personal Gemini key," without ever handing over the key:

portunus_resolve_exec(
    argv=["curl", "-s", "https://generativelanguage.googleapis.com/v1beta/models?key={{secret}}"],
    name="my-project-12345-google_generative_ai_api_key",
)
# -> {"stdout": "{...real model list JSON...}", "stderr": "", "returncode": 0}
# the key itself never appears in the tool's return value, this call's own output, or any log.
Auth lifecycle through Portunus
portunus auth login user@example.com   # thin wrapper around `gcloud auth login` -- the one command to remember
portunus auth status [--json] [--strict]  # per binding: wif ready / account ok|missing|EXPIRED; --strict exits 1

Bounded on purpose — not automatic reauth. login still opens a real browser (Portunus doesn't remove that step); status reports, per project, whether each configured binding is authenticated, missing, or expired. It mints a real token to check, so a listed account that is stuck behind a reauth wall shows as EXPIRED. That way a stale credential shows up before an injection or write fails on it. Neither command ever touches a secret value — only account emails and gcloud's own credential metadata.

OAuth token broker: store a refresh token, mint access tokens on demand

portunus oauth store/list/remove + --backend oauth let Portunus hold a provider-issued OAuth refresh token and mint short-lived access tokens from it on demand, injected only at the boundary — the same safety shape every other secret already gets.

What this is NOT: Portunus does not run any OAuth consent/browser-redirect flow itself, and this is not browser-session-cookie capture. The one-time consent step — obtaining the refresh token in the first place — happens entirely outside Portunus, through whatever mechanism already legitimately covers the scope you need (e.g. gcloud auth application-default login --scopes=... for GCP-adjacent access — the same delegation precedent portunus auth login already established for gcloud auth login, just pointed at the portable credential file instead of gcloud's own internal one). Portunus's job starts once you hand it the resulting credential bundle.

# Bootstrap (outside Portunus) -- writes ~/.config/gcloud/application_default_credentials.json
gcloud auth application-default login --scopes=https://www.googleapis.com/auth/cloud-platform

# Hand Portunus the resulting bundle (never inline -- stdin/--value-file only)
python3 -c "import json; d=json.load(open('$HOME/.config/gcloud/application_default_credentials.json')); \
  print(json.dumps({'client_id': d['client_id'], 'client_secret': d['client_secret'], \
  'refresh_token': d['refresh_token'], 'token_endpoint': 'https://oauth2.googleapis.com/token'}))" \
  | portunus oauth store google personal --stdin

# Point a reference at it (sm_name encodes "<provider>:<account>")
portunus drop my-gmail-token "google:personal" --backend oauth --stdin <<< "unused-placeholder"
portunus state my-gmail-token enabled
portunus resolve "Authorization: Bearer {{secret:my-gmail-token}}"   # a real, live, short-lived access token

Or skip the one-liner entirely: the Standalone UI's "+ Add OAuth credential" button opens a metadata-rich entry form (name/provider/account/org/project/env/tags/description/purpose/ injected_as/group/related/repo/source_files — the same field set AddSecretForm already gives regular secrets) with an explicit choice between two credential sources: auto-fill from a detected local gcloud ADC file (read directly by the Next.js server; the client_id/ client_secret/refresh_token values are never sent to or shown in the browser — the form shows only "detected: yes/no," never the values themselves) or enter manually (masked client_secret/refresh_token fields, same type="password" discipline as every other secret value in this UI). One submit both stores the credential (portunus oauth store) and registers the pointing reference (portunus drop --backend oauth) — no behavioral difference from doing both by hand.

Multiple accounts under one vault is a first-class case, not an afterthought: oauth store namespaces by (provider, account), so google:personal and google:firefly-events (or any other label) coexist as fully independent credentials — separate mint, separate resolve, separate oauth remove — the same "many sub-vaults/accounts under one Portunus" shape org/project scoping already gives every other reference. Live-verified with two real, distinct Google accounts: both minted genuinely different access tokens, confirmed via independent SHA-256 digests (never by comparing raw values) — see docs/architecture.md §20 and .pHive/epics/portunus-oauth-token-broker/docs/lessons-learned.md.

gcloud auth login is not gcloud auth application-default login — easy to conflate, two different credential stores. auth login authenticates the gcloud CLI itself (an internal SQLite store portunus auth login already wraps); auth application-default login writes the portable JSON file (~/.config/gcloud/application_default_credentials.json) the bootstrap above actually reads. Running the wrong one produces no error — it just silently authenticates the wrong thing (confirmed by the ADC file's own mtime staying unchanged).

Verifying which account a bootstrapped credential actually belongs to: gcloud's own success output doesn't print the account you picked in the browser — only an unrelated quota project, if any. To know for certain (not guess from context), mint a token and ask Google directly:

portunus resolve --exec curl -sG "https://oauth2.googleapis.com/tokeninfo" \
  --data-urlencode "access_token={{secret:my-gmail-token}}" | python3 -c "import json,sys; print(json.load(sys.stdin)['email'])"

(uses resolve --exec, not $(...) substitution — the token never lands in argv/ps output, same boundary-safety discipline as every other example in this README.)

An access token is cached in-memory (this process only, never written to disk) until close to its own expiry, then re-minted automatically — no re-bootstrap needed until the refresh token itself expires or is revoked (which is entirely up to the provider, not Portunus). A failed mint (expired/revoked refresh token) reports a clean BackendError, the same as any other backend's auth failure — portunus vault access verify (portunus-vault-transfer) already translates that into an actionable hint pointing back at the bootstrap command.

Troubleshooting: if minting fails with an SSL certificate error on macOS, it's almost always a stock python.org installer issue (its bundled Python doesn't automatically pick up the system CA trust store) — run that Python version's own Install Certificates.command, or set SSL_CERT_FILE to python3 -c "import certifi; print(certifi.where())"'s output. Not a Portunus bug; the same fix applies to any Python HTTPS client on an affected install.

Standalone UI

A localhost-only Next.js app under ui/ — Console (default tab, faceted table + detail drawer), Vault Map (second tab, an org → project drill-down — pick an org card, then a project card, and the view scopes to just that slice, each level showing its own reference count and completeness summary; a real fix for what becomes an unmanageable flat card wall once a vault spans 30+ repos), Project Explorer (third tab — a GCP-project-scoped view: what's already registered, what's discoverable via portunus discover with a one-click "register all", and whether that project has a WIF binding configured), Settings (org/project hierarchy overview + role/policy management — see "Roles & permissions" below), About (in-app documentation of all of this), and an Ask Bar (persistent side panel across all four main tabs, backed by portunus ask). A first-run setup wizard appears automatically the very first time you open the UI against a fresh PORTUNUS_HOME (never again once anything's configured) — walks through what Portunus's three parts do, picking your first vault's backend (including a real in-UI trigger for gcloud auth login if you choose GCP), and a first discover pass. No gating logic is duplicated in TypeScript: every API route under ui/app/api/ shells out to the same portunus console script the CLI uses, so Broker.check_injectable stays the single, only implementation of the gate. The add-secret form is the one deliberate human-plaintext-entry point (mirroring portunus drop --stdin) — a value is piped to the CLI via stdin only, never an argv element, never logged. A secret's org/provider/project/env/repo/source_files/description/purpose/injected_as metadata is viewable and editable from the detail drawer's Move form — never a value, always through the same portunus retag path. A state=requested reference (an agent's own portunus ask "add ...") gets a "Fulfill…" action in its detail drawer that opens this same add-secret form already pre-filled from everything the agent captured — see "Fulfilling a pending request" above for the ?fulfill=<name> deep link and portunus ui open --fulfill that point straight at it.

Missing-metadata signal. A reference with no description/purpose/org/project/tags gets a "⚠ missing metadata" badge everywhere it renders, plus a real clickable Metadata facet in Console to filter down to exactly what still needs filling in — a real vault checked live this project has this problem for nearly every reference, so this is a genuine, not theoretical, gap to close.

Custom views. Named, human-curated collections of references for task-shaped clustering that doesn't map onto org/project/env — "everything for the Shindig deploy," assembled from wherever those references actually live. Create one from Console's "My views" panel; add/remove a reference to any view straight from its own detail drawer. CLI: portunus views create/add/ remove/delete/show.

LLM suggests, human confirms. An agent can propose description/purpose/tags/group for a reference via the portunus_suggest_metadata MCP tool — it lands in a pending sidecar, never the live field. DetailDrawer shows "suggested by \<agent\>: '...' [Confirm] [Reject]" for each pending field; Confirm applies it through the exact same portunus retag path a manual edit would use, Reject discards it, neither touches the live field except via your own explicit click. Routing fields (org/provider/project/env/repo/backend) are never agent-suggestible — those affect which backend a resolve actually uses. CLI: portunus metadata confirm/reject/pending.

Roles & permissions — real, opt-in access control. Settings lets you record {scope: org/project/env/repo, role, actions, principal} policy records (portunus roles set/delete/ show, PORTUNUS_HOME/roles.json) scoping a policy to a specific agent identity (principal — blank/* means everyone, backward-compatible with every pre-existing record). Every resolve gets a would-allow/would-deny audit line reflecting what the policy store says, whether or not enforcement is active. portunus roles enforce on is the explicit opt-in that makes a would-deny decision actually raise (NotAuthorized) instead of just being logged — default off, and even with it on, a scope with zero configured policies always stays fully open (configuring your first policy for one project/repo never silently locks out every other reference). Not yet scope-aware: list/tree still return full-vault metadata for any caller, and approve()'s approval token is scoped to a reference name only, not the requesting identity — both real, named, deliberately deferred gaps (Roadmap above).

Metadata crawl & deploy-docs report. Settings' "Crawl & report" section lists references still missing description/purpose/org, and offers a "Fetch crawl bundle" button — the same discovery bundle portunus crawl --json returns (sm_name, group, project, org, repo, its project's vault binding, its provider's rotation binding), framed honestly as context for an LLM session to read, not an automatic filler; nothing in this feature calls an LLM or writes metadata itself. "View report" renders portunus report's Markdown output in-app (a small custom renderer, no markdown-parsing dependency — the output is fully controlled and narrow); "Download report" still saves it as a file — an org → project structure plus an explicit gap section, useful immediately as a real "deploy docs" starting point, whether or not any crawl-sourced metadata has ever been confirmed. CLI: portunus crawl [--org] [--project] [--json], portunus report [--org] [--project] [--out path].

Leak detection — detective, not preventive. Settings' "Leak scan" section (and portunus leak-scan) checks whether a managed secret's actual value shows up somewhere it shouldn't — logs, .claude conversation transcripts, shell history, or any other path you explicitly configure (portunus leak-scan config add-path <glob> — nothing is scanned until you add a path; there is no default). A match escalates a visible severity (warn → urgent → critical) over time until you run portunus leak mark-rotated <name> — a human assertion Portunus can't independently verify, not a real rotation trigger. This finds secrets that already leaked; it does not stop the next paste into a chat window, and it never blocks resolve/ inject (advisory only — a separate, orthogonal control from the access-control enforcement above; a reference can be both leak-flagged and fully accessible at the same time). Findings are always {reference, file, line} — never the leaked value itself, at any layer (CLI, MCP, UI). CLI: portunus leak-scan [--json], portunus leak-scan config add-path/remove-path/show, portunus leak status [name], portunus leak mark-rotated <name>. MCP: portunus_run_leak_scan, portunus_leak_status, portunus_leak_scan_config_ show/add_path/remove_path, portunus_leak_mark_rotated — an MCP-connected agent can trigger a scan (explicit user decision), but only over paths a human already configured; it can never scan anything outside that set.

A leak flag follows the reference everywhere, not just in Settings. A small ⚠ leak: <severity> badge (hover: "leaked in N conversations" — distinct files, not raw finding count, since one transcript can match the same secret on many lines) renders next to a leaked reference in Console (with its own "Leaked" facet), Vault Map, Project Explorer, and DetailDrawer — the last of which also shows the full file:line history and a "Mark rotated" button. Independent from the rotation-requested flag (RotationBadge) — an agent-requested rotation and a leak-detected one are different facts, not the same signal.

Git-repository history is a first-class scan target — not just flat log files. portunus leak-scan config add-repo <path> scans a repo's FULL history (every branch, every commit, via git log --all -p), not just its current working tree — the same technique that proved live no leaked credential had ever touched this codebase's own history, now built in rather than a manual dump-and-scan workaround. Always a full re-scan per run (git history can be rewritten, unlike an append-only log file) — repo histories are far smaller than the log corpus that motivated incremental scanning in the first place. Every finding is classified by WHERE it came from: log / local file, or git-history — and for the latter, whether the repo's GitHub remote is public, private, or unknown (never a guess; resolved via gh repo view, the same gh-CLI/your-own-credential posture the desktop app's self-updater already uses). A public-repo finding gets the loudest treatment anywhere it's shown — it's the single most severity-relevant fact this feature can surface. CLI: portunus leak-scan config add-repo/remove-repo/show-repos, and portunus leak status <name> --detail for the full per-finding classification.

Scheduling leak-scan (cron / CI)

portunus leak-scan is cron/CI-ready as-is: it never prompts, and exits 1 when new findings exist, 0 otherwise.

# crontab -e
0 9 * * * PORTUNUS_HOME=/path/to/home PORTUNUS_PASSPHRASE_FILE=/path/to/passphrase \
  portunus leak-scan --json >> /path/to/leak-scan.log 2>&1

<key>ProgramArguments</key>
<array>
  <string>/usr/local/bin/portunus</string>
  <string>leak-scan</string>
  <string>--json</string>
</array>
<key>StartCalendarInterval</key>
<dict><key>Hour</key><integer>9</integer><key>Minute</key><integer>0</integer></dict>

In a CI pipeline, treat a non-zero exit as a failed step (new leak found) rather than a script error — check the exit code, not just stderr. Never wire the scheduled run to auto-rotate or auto-block anything; v1 is deliberately advisory-only (docs/architecture.md §11).

cd ui
npm install
npm run dev   # http://localhost:3000

Bind address. Every way of starting the UI (npm run dev, npm start, and the standalone command below) listens on 127.0.0.1 only by default, so nothing else on your network can reach it. To bind somewhere else, set PORTUNUS_UI_HOST explicitly, e.g. PORTUNUS_UI_HOST=0.0.0.0 npm start. Nothing else changes the bind address. The npm scripts use POSIX ${VAR:-default} expansion, so run them from a POSIX shell. npm run test:bind (after npm run build) checks each command's actual listening address.

Request guard. ui/proxy.ts runs in front of every /api/* route. A non-GET request is rejected with 403 if it carries an Origin other than the UI's own, or if its Host isn't localhost / 127.0.0.1 / [::1] (this stops DNS rebinding; the check is skipped once you set PORTUNUS_UI_HOST). It is rejected with 415 if its Content-Type isn't application/json. That means another web page open in your browser can't drive the vault through the UI's API. A client with no Origin header, such as curl, still works as long as it sends JSON.

Deploying behind a non-loopback name (containers, reverse proxies). If anything reaches the UI by a name other than localhost / 127.0.0.1 / [::1], for example another container calling http://portunus-ui:7802 by its compose service name, set PORTUNUS_UI_HOST up front. Without it every write from that caller is rejected with 403 {"error":"non-loopback Host rejected"} while reads keep working. The deep GET /api/health runs the same Host check on its own request, so that caller sees status: "down" (HTTP 503) with a failed ui-write-host check instead of a green result. ?shallow=1 skips that check. Note that a container health probe which curls localhost from inside the container sends a loopback Host and will still pass; probe health over the same name your real callers use.

Running as a supervised service (L2 plugin lifecycle)

The UI also builds as a self-contained, host-supervisable service — GET /api/health returns the result of portunus health --json ({status: ok|degraded|down, checks}; HTTP 503 when down) plus the ui-write-host check described above, GET /api/health?shallow=1 returns {"status":"ok"} without touching the CLI (the desktop app's readiness poll uses this), and next.config.mjs sets output: "standalone" so a single fixed entrypoint can be supervised directly:

cd ui
npm run build
cp -r .next/static .next/standalone/.next/static   # standalone mode doesn't do this automatically
PORT=7802 HOSTNAME="${PORTUNUS_UI_HOST:-127.0.0.1}" node .next/standalone/server.js
# or, equivalently: PORT=7802 npm run start:standalone

Set HOSTNAME on this command, as shown. Next's standalone server.js binds whatever HOSTNAME says and falls back to 0.0.0.0 (every interface), and Docker and some shells export HOSTNAME as the machine's name. A supervisor that launches server.js directly has to pass HOSTNAME=127.0.0.1 itself, as the desktop app's sidecar already does.

Runs on port 7802 (declared in manifest.json's ui.url), matching the port Portunus is registered under in the Pantheon host's shared manifests.

Desktop app (macOS)

A native Tauri v2 shell wraps this same UI as a menu-bar app — no more cd ui && npm run dev every time. It's additive, not a replacement: npm run dev stays fully valid. See docs/architecture.md for what it is (and isn't).

Install (builds locally — see .pHive/epics/portunus-desktop-app/ for design details, or download the .zip from the latest GitHub release once one has shipped):

cd ui
npm install && npm run build
cargo install tauri-cli --version "^2" --locked   # once
cargo tauri build
cp -r src-tauri/target/release/bundle/macos/Portunus.app /Applications/
open /Applications/Portunus.app

One-time Gatekeeper bypass. This is ad-hoc signed, not notarized — a deliberate v1 scope decision (one user, one machine, not a public-distribution problem; see the design doc for the full reasoning), not an oversight. macOS will refuse to open it the first time. Go to System Settings → Privacy & Security, scroll to the bottom, and click Open Anyway next to the Portunus warning, then launch it again. This only happens once — self-updates afterward don't re-trigger it.

Using it. A tray icon appears in the menu bar: Open Vault, Check for Updates…, Launch at Login, Quit. Closing the window hides it (the app keeps running in the tray) — use Quit to actually stop it. Updates are checked automatically every 6 hours (and on demand via the tray item) against this repo's own GitHub releases, using your own already-authenticated gh CLI — never a token embedded in the app. You'll always get a confirmation dialog before anything installs; nothing swaps itself in silently.

If something goes sideways (a crashed/force-quit app can leave its background server running): pkill -f "server.js.*portunus" clears it out; the next launch picks a fresh port regardless.

Library API

from portunus import Registry, AuditChain, Broker, Resolver, GcloudBackend

registry = Registry()
broker   = Broker(registry, AuditChain())
resolver = Resolver(registry, GcloudBackend(project="my-proj"), broker)

# Inject ONLY into the boundary call; the value is never returned to us.
resolver.resolve_call(
    "Authorization: Bearer {{secret:shared-anthropic}}",
    boundary=lambda header: http_post(url, headers={"Authorization": header}),
)

Development

pip install -e ".[test]"
pytest -q

Tests use an in-memory MockBackend and an isolated PORTUNUS_HOME, so they never touch GCP or real state. The load-bearing test asserts that a resolved value never appears in a return value, the audit log, or a non-0600 file.

Layout

src/portunus/
  registry.py    reference registry (name -> SM path); tags/provider/project/env; description/
                 purpose/injected_as metadata; list_by_project() browse query; no value field
  backend.py     ARCA — SecretBackend protocol; MockBackend (tests); GcloudBackend (keyless WIF,
                 multi-project via VaultBinding); SyncingBackend (recency-aware sync-down cache,
                 falls back to the last cached value on a real connectivity failure);
                 AWSSecretsManagerBackend/VaultServerBackend/InfisicalBackend/DopplerBackend/
                 OnePasswordConnectBackend/AzureKeyVaultBackend (honest stubs, see docs/architecture.md)
  localvault.py  ARCA — LocalEncryptedBackend, the Stage 1 default (encrypted at rest)
  auth.py        keyless WIF/OIDC credential minting (GCP + AWS token exchange); never logs/
                 returns/prints minted credentials
  discover.py    ARCA discovery — read-only enumeration of a live provider project's secrets
                 (names/labels only); structurally cannot reach a value (no backend import)
  broker.py      Petitio — grant / gate / approve + lifecycle guard, wired to audit; Identity +
                 requester feeds real, opt-in access-control enforcement (`roles enforce on`)
  roles.py       PolicyRecord store (org/project/env/repo scope + principal) + roles.evaluate()
                 -- the single seam all scope/precedence logic lives in, per check_injectable()
  audit.py       tamper-evident hash-chain access log
  resolver.py    OSTIARIUS — boundary-only {{secret:NAME}} resolution  ← the core
  adapters.py    boundary injection adapters (env var, file) -- see Roadmap for HTTP-client
  intent.py      semantic front door — natural language -> tag set + intent_kind
                 (fetch/add/rotate/list) (portunus ask)
  cli.py         OSTIARIUS — the `portunus` tool (incl. the harness-side `drop`)
docs/architecture.md  adopter-facing reference: component diagram, ARCA backend-selection
                 precedence, Petitio today-vs-tomorrow, the request/resolve sequence
ui/              standalone localhost-only UI (Console / Vault Map / Ask Bar)
.claude/skills/       thin Claude skills wrapping the CLI/MCP surface: portunus-ask (fetch),
                 portunus-drop (create, single/bulk), portunus-vault-setup (bindings/sync)
.github/ISSUE_TEMPLATE/adapter-request.yaml  request a new ARCA backend
manifest.json    Dostal plugin manifest (type: core, engine: tool)

License

MIT © 2026 Mathew Dostal

Support this project

Free and open source, always. A few ways to help — or just say hi:

  • Use it, star it, file an issue. Honestly the best support an open-source project can get. → this project
  • Hire me. I do fractional-CTO and consulting work — fixing and scaling tech stacks. → mdostal.com/contact
  • Buy me a coffee if it saved you time.
  • More tools like this → tools.mdostal.com
  • Life outside the terminal → life.mdostal.com
  • What we're building at Firefly Events — event discovery, 8,000+ events/day from 7+ sources → ff.events

Always up for a conversation if any of it's useful to you.

الأسئلة الشائعة

ما هو Portunus؟

Secret broker for agents: reference secrets by name; values injected only at the execution boundary.

كيف أثبّت Portunus؟

يعمل على جهازك. في Claude Code: claude mcp add portunus -- docker run -i --rm ghcr.io/mdostal/portunus:0.34.0 mcp. وفي العملاء الآخرين، الصق إعداد JSON من هذه الصفحة في إعدادات MCP الخاصة بهم.

هل يحتاج Portunus إلى مفتاح API؟

لا يذكر إدراجه في السجل أي مفتاح أو سر إلزامي. قد تتطلب بعض الميزات حسابًا لدى الخدمة التي يتصل بها.

هل Portunus مفتوح المصدر؟

نعم. شيفرته متاحة على GitHub بترخيص MIT.

من يصون Portunus؟

ينشره mdostal. يدرجه AI.tn لكنه لا يصونه ولا يوصي به.

من سجل MCP الرسمي. الإدراج ليس توصية: راجع شيفرة الخادم وصلاحياته قبل منحه الوصول إلى بياناتك.