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»).
ghcr.io/mdostal/portunus:0.34.0claude mcp add portunus -- docker run -i --rm ghcr.io/mdostal/portunus:0.34.0 mcp
{
"mcpServers": {
"portunus": {
"command": "docker",
"args": [
"run",
"-i",
"--rm",
"ghcr.io/mdostal/portunus:0.34.0",
"mcp"
]
}
}
}
التوثيق
مأخوذ من ملف README للمشروع على GitHub ويُحدَّث أسبوعيًا. تفتح الروابط على GitHub.
المحتويات
- Portunus
- Get your agent using it in one command
- The problem
- What works today
- Roadmap (help wanted)
- Honest scope
- Built in the open
- Quick start
- Architecture, vision & design decisions
- Component model
- Why it's safe
- Install
- Usage
- MCP server — for other agents/harnesses
- Standalone UI
- Library API
- Development
- Layout
- License
- 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 / prodby 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
0600tempfile. 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 verifyproves 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 onactivates 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'sresolve_call— works, but there's no built-inHttpHeaderAdapter/HttpBodyAdapterclass yet, onlyEnvVarAdapter/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 aremanualbecause GitHub has no reissue API. Stripe is still a stub that raises.portunus liveness checkflags 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 actualresolve/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 toghinstead 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, seeSecretBackendinbackend.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:
| Component | Role | Where it lives today |
|---|---|---|
| OSTIARIUS | The 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/harnesses | resolver.py + the portunus CLI (cli.py) + mcp_server.py (portunus mcp) |
| ARCA | The 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) |
| Petitio | The 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 above | audit.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
Referencerecords 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
0600temp 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/revokedreferences 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 verifyproves 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'sroles.pyaccess 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:
| Environment | Local-encrypted backend | GCP backend |
|---|---|---|
| Local Docker/Podman dev | zero-config — self-bootstraps on first write | mount 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 Kubernetes | mount a real PersistentVolume | GKE 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:
| Tool | Returns |
|---|---|
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 الرسمي. الإدراج ليس توصية: راجع شيفرة الخادم وصلاحياته قبل منحه الوصول إلى بياناتك.