Skip to main content
Glama
README.md
# Portunus

<!-- shared:tagline -->
> A secret broker that finds secrets by metadata and injects them — without them ever touching your LLM. Free & open source.
<!-- /shared:tagline -->
<!-- shared:byline -->
Built by [Mathew Dostal](https://mdostal.com) — fractional CTO, Dostal Technology.
<!-- /shared:byline -->

> 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

```bash
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 →](https://mdostal.github.io/portunus/)** — landing page, install snippets, and
the component-model pitch in one place.

**[Documentation →](https://mdostal.github.io/portunus/docs/)** — quickstart, concepts,
architecture, CLI/MCP reference, and [Process & Design](https://mdostal.github.io/portunus/docs/process/),
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](.github/ISSUE_TEMPLATE/adapter-request.yaml) 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](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

```bash
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](#install) and [Usage](#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](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`](.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/`](.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](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](.github/ISSUE_TEMPLATE/adapter-request.yaml)
  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](.github/ISSUE_TEMPLATE/adapter-request.yaml), 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 `PolicyRecord`s (`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 `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

```bash
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`](https://github.com/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.

```bash
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](https://registry.modelcontextprotocol.io) 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:**

| 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:

```bash
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:

```bash
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):

```bash
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:

```python
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:

```bash
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:

```python
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.

```bash
# 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:

```bash
# 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)

```bash
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:

```bash
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:

```bash
$ 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:

```bash
$ 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:

```bash
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:

```bash
# 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:

```bash
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):

```bash
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:

```bash
# 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:

```python
# 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.

```bash
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`.

```bash
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:

```bash
$ 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 `--register`ed 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.

```bash
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:

```bash
$ 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:

```bash
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.

```bash
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.

```bash
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:

```bash
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`)

```bash
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:

```bash
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:

```bash
path=$(portunus resolve "key={{secret:shared-openai}}")
# ... use "$path" ...
rm -f "$path"
```

### Policy: gate / approve / grant

```bash
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

```bash
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](https://modelcontextprotocol.io) 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.

```bash
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:**

```python
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

```bash
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.

```bash
# 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:

```bash
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.

```bash
# 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
```

```xml
<!-- ~/Library/LaunchAgents/com.portunus.leakscan.plist (macOS launchd) -->
<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).

```bash
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:

```bash
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](../../releases) once one has shipped):

```bash
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

```python
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

```bash
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

<!-- shared:support -->
## 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](https://github.com/mdostal/portunus)
- **Hire me.** I do fractional-CTO and consulting work — fixing and scaling tech stacks. → [mdostal.com/contact](https://mdostal.com/contact)
- **[Buy me a coffee](https://www.buymeacoffee.com/mdostal)** if it saved you time.
- **More tools like this** → [tools.mdostal.com](https://tools.mdostal.com)
- **Life outside the terminal** → [life.mdostal.com](https://life.mdostal.com)
- **What we're building at Firefly Events** — event discovery, 8,000+ events/day from 7+ sources → [ff.events](https://ff.events)

Always up for a conversation if any of it's useful to you.
<!-- /shared:support -->