--- url: /arca/tutorials/first-secret.md --- # Your first secret A full arca lifecycle — bootstrap, seal, declare, inject, audit — in a throwaway `HOME` so nothing touches your real config. All values below are fake. ## Setup ```sh export HOME="$(mktemp -d)" # scratch HOME for this tutorial export XDG_CONFIG_HOME="$HOME/.config" export XDG_DATA_HOME="$HOME/.local/share" arca init ``` ```text identity: /tmp/tmp.…/.config/arca/identity.txt (generated) recipient: age1… vault: /tmp/tmp.…/.local/share/arca/vault.age ``` On stderr you'll also see the first audit event — every arca op emits one ndjson line there: ```json {"ts":"2026-01-01T00:00:00Z","actor":"you","op":"init","env":"dev","outcome":"ok"} ``` ## Seal a value ```sh printf '%s' 'sk_test_FAKE000000000000000000' | arca put arca://billing/stripe ``` ```text stored arca://billing/stripe (v1) ``` The plaintext went in on stdin — never an argument. What's on disk is age ciphertext: ```sh file "$XDG_DATA_HOME/arca/vault.age" # data — not readable ``` ## Declare and inject ```sh cat > arca.toml <<'EOF' [secrets] STRIPE_KEY = "arca://billing/stripe" EOF arca run -- 'echo "key starts with: ${STRIPE_KEY:0:7}…"' ``` ```text key starts with: sk_test… ``` The child saw `$STRIPE_KEY` as a normal env var; nothing else — not your shell, not a file — ever held it. And the audit wire recorded the materialization: ```json {"ts":"…","actor":"you","op":"run","rref":"billing/stripe","env":"dev","outcome":"ok"} ``` ## List without spilling ```sh arca list # arca://billing/stripe arca list --json # [{"ref":"arca://billing/stripe","env":"dev","version":1,"created_at":"…","rotated_at":null}] ``` `list` is names plus metadata — never values. The only surfaces that print a value are `get` (the escape hatch) and `env` (exports for `eval`). ## Rotate and verify ```sh printf '%s' 'sk_test_FAKE111111111111111111' | arca rotate arca://billing/stripe # rotated arca://billing/stripe (v2) arca check # config: /tmp/tmp.…/arca.toml # env: dev → vault "local" # vault "local" (age): readable # secret STRIPE_KEY → arca://billing/stripe (env dev): resolves # ok ``` `rotate` requires the ref to exist (it's deliberate rotation, not upsert), bumps the version, and stamps `rotated_at`. ## Clean up ```sh rm -rf "$HOME" # the scratch vault and identity go with it ``` ## Where next * [Add a secret](../how-to/add-a-secret.md) — multi-env and pinning. * [Route envs to vaults](../how-to/route-envs-to-vaults.md) — `prod` to a remote vault. * [The audit wire](../reference/audit-events.md) — the ndjson contract. --- --- url: /arca/how-to/add-a-secret.md --- # Add a secret Store a value under an `arca://` ref in the vault backing an env. ## The pipe path (scripts and agents) ```sh printf '%s' "$VALUE" | arca put arca://ns/name ``` * stdin is read to EOF; **one** trailing newline is stripped, the rest is verbatim (multi-line values work). * Empty input fails — you can't store a blank secret. * The value is never an argument: args land in shell history and `/proc`. Other sources that already hold the value pipe the same way: ```sh pass show stripe/test | arca put arca://billing/stripe op read "op://vault/stripe/test" | arca put arca://billing/stripe --env dev ``` ## The interactive path (humans) ```sh arca put arca://ns/name # stdin is a tty → hidden /dev/tty prompt ``` The prompt doesn't echo — paste the value, hit enter. ## Pick the env Env is resolution *context*, not part of the name — the same `arca://ns/name` ref can hold different values per env: ```sh printf dev-token | arca put arca://billing/stripe --env dev printf prod-token | arca put arca://prod/billing/stripe # env pin ``` `arca://prod/…` pins the env regardless of `--env`/`ARCA_ENV`; the bare form resolves under the selected env. ## Upsert semantics `put` is an upsert: a new ref starts at `version = 1`; re-putting an existing ref replaces the value and bumps the version but is *not* a rotation (`rotated_at` stays untouched — use [`arca rotate`](rotate-a-secret.md) for deliberate rotations). ## Remove ```sh arca rm arca://ns/name ``` ## Verify ```sh arca list [--env prod] # refs only — never values arca check # declared [secrets] entries resolve? ``` --- --- url: /arca/how-to/inject-into-a-process.md --- # Inject secrets into a process `arca run` is the agent path — resolved values become env vars in the child and nowhere else. `arca env` is the human path — export lines for `eval`. ## Declare the surface `[secrets]` in `arca.toml` bounds what `run`/`env` may inject: ```toml [secrets] STRIPE_KEY = "arca://billing/stripe" DATABASE_URL = "arca://prod/db/url" # env pin: prod, always ``` Nothing outside this table (plus `-s` ad-hoc pairs) is injectable — least privilege, and `arca check` verifies every entry resolves. ## `arca run` — two exec modes ```sh # one arg → bash -c — pipes, redirects, $VAR expansion arca run -- 'psql "$DATABASE_URL" -c "select 1" | tee out.log' # several args → argv[0] exec'd directly, NO shell — # the program reads its own environment arca run -- stripe charges list --limit 3 ``` Prefer argv mode when the command has arguments — no quoting puzzles. One caveat: with no shell in the child there is no `$VAR` expansion — write `$STRIPE_KEY` in argv and *your* outer shell eats it (to an empty string) before arca runs. Use `bash -c` mode when the command line itself needs the value. Exit code is the child's; a child's non-zero exit is not an arca failure (no `error` audit event). ## Ad-hoc pairs: `-s VAR=arca://ref` Repeatable, merged over `[secrets]` (same `VAR` — the `-s` pair wins): ```sh arca run -s TEMP_TOKEN=arca://ci/token -- ./deploy.sh ``` ## Missing secrets fail closed If any declared or `-s` ref isn't stored in its env, `run` fails before spawning — partial injection is worse than none. ## `arca env` — the human path ```sh eval "$(arca env)" # export every declared VAR eval "$(arca env -s EXTRA=arca://misc/extra)" # plus ad-hoc pairs arca env --json # {"VAR": "value"} map ``` Output is `export VAR='…'` with shell-safe quoting for any value. This prints plaintext into your shell environment — that's why it's the *human* path: agents should `run` instead. A `[guardrails]` deny on `env` is the owner's hint. ## The audit trail Each resolved ref emits one materialization event on stderr — `op` is `run` or `env`, `rref` the ref path, `outcome: "ok"`. See [the audit wire](../reference/audit-events.md). --- --- url: /arca/how-to/rotate-a-secret.md --- # Rotate a secret Replace a stored value deliberately — version bump plus a `rotated_at` timestamp, and an audit event. ```sh printf '%s' "$NEW_VALUE" | arca rotate arca://billing/stripe # rotated arca://billing/stripe (v3) ``` Like `put`, the value arrives on stdin (or a hidden `/dev/tty` prompt) — never an argument. ## `rotate` vs re-`put` | | `put` (upsert) | `rotate` | | --- | --- | --- | | Missing ref | creates it (v1) | **fails** — `NotFound` | | `version` | +1 | +1 | | `rotated_at` | untouched | stamped | Rotation is intentional: a missing ref failing loudly catches a typo'd ref or wrong env that `put` would silently create. ## Rotate in a specific env ```sh printf '%s' "$PROD_VALUE" | arca rotate arca://prod/billing/stripe # or: --env prod ``` ## Verify ```sh arca list --json --env prod # … "version": 3, "rotated_at": "2026-…" ``` Downstream consumers pick the new value up on their next `arca run` — resolution happens at exec time, nothing caches it. If the value was pushed to a deploy sink, re-push it: [`arca deploy arca://billing/stripe`](deploy-to-a-sink.md) — the sink is write-only and doesn't follow the vault on its own. --- --- url: /arca/how-to/route-envs-to-vaults.md --- # Route envs to vaults Envs are the routing axis: `[envs]` maps each env name to a `[vaults]` backend, so `dev` can stay on the local age file while `prod` reads from a remote ciphertext store. ## The tables ```toml [vaults.local] # the built-in default — entry optional kind = "age" # path / identity / recipients # optional overrides [vaults.cloud] kind = "cf" # self-hosted Worker + D1, age ciphertext url = "https://arca-vault.you.workers.dev" token_env = "ARCA_CF_TOKEN" # env var NAME — token value stays in env [envs] dev = "local" # bare-name form prod = { vault = "cloud" } # table form ``` Rules: * `local` always exists implicitly (age, default paths) — a `[vaults.local]` entry only overrides it. * Envs not listed route to `local`. `dev`/`staging`/`prod` always exist; `[envs]` keys add custom envs (`ci`, `qa`, …). * `token_env` names the env var carrying the credential — the *name* is in config, the *value* only in the process env. * `ARCA_IDENTITY_FILE` overrides the age identity path globally. ## What changes at run time ```sh arca list --env prod # opens the "cloud" vault over HTTPS arca put arca://prod/x/y # pin → writes to "cloud" arca run --env prod -- ./app # [secrets] resolve under prod → cloud ``` The ref spelling doesn't change — `arca://ns/name` is env-agnostic; routing decides which backend serves it. ## The `cf` backend `kind = "cf"` talks to your own Cloudflare Worker fronting D1. All encryption is client-side: the worker stores opaque age ciphertext and has no key material. Setup is a how-to of its own — see [Cloudflare Worker setup](cloudflare-worker-setup.md). ## Verify routing ```sh arca check # env: dev → vault "local" # vault "local" (age): readable # vault "cloud" (cf): readable ← actually probed over the wire ``` `check` reports every configured and routed vault — including ones no env currently routes to — and fails on routes pointing at undeclared vault names. --- --- url: /arca/how-to/cached-vaults.md --- # Cache a remote vault locally A remote vault (`kind = "cf"`) costs a network round-trip per read and goes hard-down offline. `[vaults.*] cache` fronts it with a read-through, age-encrypted local mirror — `get`/`list` serve the cache while it's fresh, misses fetch remote and write-through, and a remote failure falls back to the stale copy. ## Turn it on ```toml [vaults.cloud] kind = "cf" url = "https://arca-vault.you.workers.dev" token_env = "ARCA_CF_TOKEN" cache = ["dev"] # cache only the dev env # cache = true # or: cache every env # cache_ttl_secs = 600 # freshness window, default 300 ``` Three scope forms — `true` for every env, `"dev"` for one, `["dev", "staging"]` for a set. `cache = false` and `cache = []` are the same as leaving it out. ## What lands on disk Rows mirror into `$XDG_CACHE_HOME/arca/.age` (else `~/.cache/arca/`) — an ordinary age vault encrypted to the same identity and recipients as the vault itself, so the cache needs no new key material and no new threat surface. Each cached row carries the remote `version`/`rotated_at` verbatim plus a `fetched_at` stamp; a tiny `cloud.listed.json` sidecar holds per-env list markers (env names and timestamps, never refs or values). It's a cache: delete the directory and it rebuilds. ## Freshness model * **Within the TTL** a cached `get`/`list` answers with no network at all — a burst of `arca run`s costs one round-trip. * **Past the TTL** the read goes remote and rewrites the row. * **Remote unreachable** — the stale copy answers. That's the offline mode the cache exists for, not an error. * **Writes never serve stale**: `put`/`rotate`/`rm` always hit the remote first, then update or invalidate the mirror. A remote `rm` propagates into the cache on the next `list`. The tradeoff to know: within the TTL another writer's change isn't visible — the remote's own change is. Shorten `cache_ttl_secs` if your env rotates often. ## Scope it — prod stays live ```toml [envs] dev = { vault = "cloud" } # cached — fast, offline-capable prod = { vault = "cloud" } # out of scope → every read live-fetches ``` `cache = ["dev"]` means prod rows *never touch the disk mirror* — every prod verb is a straight remote call. A stale prod secret is a footgun; the scope is the safety rail. ## Verify ```sh arca get arca://a/b # first call: remote fetch + write-through arca get arca://a/b # second call: cache hit, no round-trip ls ~/.cache/arca/ # cloud.age + cloud.listed.json arca check # probes the remote like before ``` A cache file that won't decrypt is moved aside to `.corrupt` and rebuilt — corruption degrades to remote-only reads, never to a wrong answer. --- --- url: /arca/how-to/deploy-to-a-sink.md --- # Deploy a secret to a sink `arca deploy` pushes a vault-stored value to a write-only target — a `[deploy.NAME]` table in `arca.toml`. The vault is the source of truth; the sink serves your runtimes directly (e.g. a Worker reading a Secrets Store binding). ## Configure a target ```toml [deploy.prod] kind = "cf-secrets-store" # Cloudflare account Secrets Store account_id = "00000000000000000000000000000000" store_id = "00000000000000000000000000000000" # token_env = "CLOUDFLARE_API_TOKEN" # default; env var NAME only ``` The API token comes from the env var named by `token_env` (default `CLOUDFLARE_API_TOKEN`) — never from the file. ## Push ```sh arca deploy arca://billing/stripe --target prod # deployed arca://billing/stripe → "prod" as dev_billing_stripe (created) ``` * `--target` may be omitted when exactly one `[deploy.*]` table is configured; several targets make it required. * The sink-side name is derived and stable — `env + ref-path` underscored (`dev_billing_stripe`). * Re-deploying updates in place (`updated`, not `created`). ## The direction is one-way Deploy targets are **sinks**, not sources: the plaintext crosses to the sink exactly once, at push time, and nothing ever reads it back through arca. A deploy emits a `deploy` audit event for the ref. ## Rotate → re-deploy `arca rotate` changes the vault; the sink doesn't follow on its own. Re-push after rotation: ```sh printf '%s' "$NEW_VALUE" | arca rotate arca://prod/billing/stripe arca deploy arca://prod/billing/stripe --target prod ``` ## Verify `arca check` validates deploy tables (kind known, `account_id` + `store_id` present) as findings — it doesn't push anything. --- --- url: /arca/how-to/guardrails-for-agents.md --- # Guardrails for agents `[guardrails]` is an advisory op table — the owner declaring "agents get these verbs, not those". It runs before the op touches the vault, on every verb. ```toml [guardrails] deny = ["get", "env", "put", "rm", "rotate", "deploy"] ``` ```toml [guardrails] default = "deny" # unlisted ops refused allow = ["run", "list", "check", "manifest", "schema"] ``` * `deny` wins over `allow`; `default` covers the rest (default `"allow"`). * Op names match `arca manifest`'s `ops` list: `init, put, get, list, rm, rotate, run, env, check, deploy, ui, serve, manifest, schema`. * `manifest`/`schema`/`gen-cli-docs` run before config load — introspection is never gated. ## What a denial looks like ```text arca: op 'get' denied by guardrails in ./arca.toml (advisory — remove/relax the rule to allow) ``` …plus an `outcome: "denied"` audit event carrying the requested ref. Exit is non-zero; `--json` emits `{"error": "…"}`. ## Honest scope: advisory, not policy Guardrails are accident prevention, not enforcement: * `ARCA_ACTOR` (and `--actor`) is self-claimed — attribution, not identity. * Nothing stops a determined process from editing `arca.toml` or running the binary differently. Real policy enforcement is peregrin's job (arca's orchestrator neighbor); arca's contract is to *honor* the declared rules and *record* every refusal. If a guardrail denies your op as an agent: don't work around it. Use an allowed verb (`run` instead of `get`), or surface the denial to the human — it's the owner telling you the boundary. ## Typical agent posture Deny every value-egress and mutation verb; allow the inject-and-run path: ```toml [guardrails] default = "deny" allow = ["run", "list", "check", "ui"] ``` The agent can `arca run` (values materialize inside the child, never in its context) and can list refs — but `get`/`env`, the plaintext printers, are off. --- --- url: /arca/how-to/cloudflare-worker-setup.md --- # Cloudflare Worker setup The `cf` vault kind is two halves: `arca-vault-cf` in the CLI (client-side age encryption + blocking HTTP) and a Worker you self-host — a thin store fronting D1 that holds **opaque ciphertext only**. The worker has no key material and cannot read what it serves. ## What you're deploying The worker source, schema, and the authoritative deploy walkthrough live in the repo: * [`crates/arca-vault-cf/worker/README.md`](https://github.com/9aia/arca/blob/main/crates/arca-vault-cf/worker/README.md) — wire protocol (`arca-vault-cf/2`), `wrangler` steps * `worker/index.js` — the Worker (constant-time auth, scoped tokens, ArcaHmac verification + REST verbs) * `worker/schema.sql` — the D1 table (`name` column added in v2 — `ALTER TABLE secrets ADD COLUMN name TEXT` on an existing db) * `worker/wrangler.toml` — binding config The short version: ```sh cd crates/arca-vault-cf/worker wrangler d1 create arca-vault # → paste database_id into wrangler.toml wrangler d1 execute arca-vault --file=schema.sql wrangler secret put ARCA_VAULT_TOKEN # legacy single bearer, or: wrangler secret put ARCA_VAULT_TOKENS # scoped token map (JSON) — below wrangler deploy ``` Then point a vault at it in `arca.toml`: ```toml [vaults.cloud] kind = "cf" url = "https://arca-vault.you.workers.dev" token_env = "ARCA_CF_TOKEN" [envs] prod = { vault = "cloud" } ``` and export the bearer token under the name `token_env` chose (`export ARCA_CF_TOKEN=…`). ## Scoped tokens (wire v2) One static bearer for every env is a big blast radius. Wire v2 scopes tokens per env: the worker reads `ARCA_VAULT_TOKENS`, a JSON map of `token_id → {token, scopes}`, and enforces `{env|"*" → {read, write}}` after auth — a leaked `dev` token can't read `prod`. ```json { "ops/cf-token-dev": { "token": "", "scopes": { "dev": { "read": true, "write": true } } }, "ops/cf-token-viewer": { "token": "", "scopes": { "*": { "read": true, "write": false } } } } ``` On the client side, each scoped credential is an `arca://` ref — the token itself lives as a secret in the **`local` vault** under the env it scopes; the ref path is the wire `token_id`: ```toml [vaults.cloud.tokens] dev = "arca://ops/cf-token-dev" prod = "arca://ops/cf-token-prod" ``` ```sh printf '%s' '' | arca put arca://ops/cf-token-dev --env dev printf '%s' '' | arca put arca://ops/cf-token-prod --env prod ``` With a scoped token configured the client signs every request — `Authorization: ArcaHmac <token_id>, <ts>, <sig>` where `sig = hex HMAC-SHA256(token, method+path+ts+sha256(body))` and `ts` must be within the worker's 60-second replay window. **The token never travels.** `Bearer` stays accepted during migration, so a mixed fleet upgrades gracefully. **Rotation is `wrangler secret put`** — replace the value inside `ARCA_VAULT_TOKENS`, then `arca put` the new secret at the token ref. There is deliberately no admin route. ## Private refs (opt-in) By default `ref` and `env` are visible to the store — values are ciphertext, but *which secrets exist* is metadata. With `private_refs = true` the client derives `ref_key = HMAC(identity-derived-key, env + "/" + ref)` and uses it as the storage key; each record also carries a `name` blob — age ciphertext of the real ref — so `list` maps keys back while the store sees only hex: ```toml [vaults.cloud] kind = "cf" url = "…" private_refs = true ``` Existing rows aren't rewritten by the flag alone — run: ```sh arca migrate --private-refs ``` which re-keys every plaintext-named row (ciphertext moves verbatim, old keys deleted after new ones land). A vault mixing plain and private rows is **mixed-mode**: `list`/`check` fail closed until you migrate — deliberately, since reading the wrong interpretation silently is worse than failing. Turning `private_refs` off again on a private vault fails closed the other way; migrating back is a manual re-put. ## Client-side crypto boundary `put` seals the value with age **before** it leaves the machine; `get` unseals after it arrives. The wire — and the D1 row — carries `{"ciphertext", "name", "version", created_at, rotated_at}`. A compromised worker yields ciphertext and, at most, opaque keys — not values, not ref names (with `private_refs`). Your local `identity.txt` is the only key, same file the local age vault uses; the private-refs derivation is keyed by it, so rotating the identity means a full re-key + migrate. ## Verify ```sh arca check --env prod # vault "cloud" (cf): readable ← probes the worker over HTTPS ``` An unreachable worker or a bad token shows up as a finding — `check` fails open in the report, never panics. ## Deploy sinks vs the vault Don't confuse the two CF surfaces: * `[vaults.cloud] kind = "cf"` — a *readable* ciphertext store (your Worker + D1). Source of truth. * `[deploy.prod] kind = "cf-secrets-store"` — a *write-only* Cloudflare Secrets Store sink `arca deploy` pushes plaintext into for Workers to consume via bindings. See [deploy to a sink](deploy-to-a-sink.md). --- --- url: /arca/reference/cli.md --- # Command-Line Help for `arca` This document contains the help content for the `arca` command-line program. **Command Overview:** * [`arca`↴](#arca) * [`arca init`↴](#arca-init) * [`arca put`↴](#arca-put) * [`arca get`↴](#arca-get) * [`arca list`↴](#arca-list) * [`arca rm`↴](#arca-rm) * [`arca rotate`↴](#arca-rotate) * [`arca run`↴](#arca-run) * [`arca env`↴](#arca-env) * [`arca check`↴](#arca-check) * [`arca migrate`↴](#arca-migrate) * [`arca deploy`↴](#arca-deploy) * [`arca serve`↴](#arca-serve) * [`arca ui`↴](#arca-ui) * [`arca manifest`↴](#arca-manifest) * [`arca schema`↴](#arca-schema) ## `arca` arca — secrets broker for agents: `arca://` refs, redaction, env-injection instead of plaintext. Refs name logical secrets (`arca://billing/stripe`); `--env` picks the resolution context. `[secrets]` in arca.toml bounds what `run`/`env` may inject. `manifest`/`schema` describe the surface for machines; every verb honors `--json`. **Usage:** `arca [OPTIONS] ` ###### **Subcommands:** * `init` — Bootstrap: store the age identity in the system keyring when a session secret store is reachable — importing an existing identity.txt rather than orphaning it — else the `~/.config/arca/identity.txt` keyfile (0600). Verifies the vault opens * `put` — Store a secret. The value comes from stdin (`printf … | arca put arca://ns/name`) or a hidden terminal prompt — never args, which land in shell history and `/proc` * `get` — Print a secret's value to stdout — the escape hatch. Prefer `arca run` so values never enter agent context; guardrails can deny this op * `list` — List the refs stored in the selected env — names only, never values * `rm` — Remove a secret from the selected env * `rotate` — Rotate a secret to a new value (stdin or hidden prompt, like `put`); bumps version + rotated_at * `run` — Run a command with declared secrets injected as env vars * `env` — Print `export VAR='…'` lines for `eval "$(arca env)"` — the human path (`run` is the agent path). Values print to stdout: `--json` emits a `VAR → value` map * `check` — Validate config + the declared surface: `[envs]` routes point at declared vaults, vaults decrypt, every `[secrets]` ref resolves in its routed env. Exit 1 on findings * `migrate` — Rewrite stored records for a wire/config upgrade. Currently one transform: `--private-refs` re-keys every plaintext-named row in each env routed to a cf vault with `[vaults.*] private_refs = true` — ciphertext moves verbatim, old keys are deleted after the new rows land * `deploy` — Push a stored secret to a write-only deploy target (`[deploy.NAME]` in arca.toml, e.g. a CF Secrets Store). The vault is the source of truth; the sink never gives the value back. With one configured target, `--target` may be omitted * `serve` — Serve the Vault port over ndjson — one JSON request per stdin line, one response per stdout line, audit events on stderr unchanged. For orchestrators embedding arca (peregrin); with `[serve.pdp]` configured, a policy decision point is consulted before every vault-touching op. `{"op":"manifest"}` on the wire describes the protocol * `ui` — Interactive vault browser (requires the `tui` feature) * `manifest` — Capabilities, JSON shapes, env vars, audit contract — agent self-discovery (always JSON) * `schema` — JSON Schema for `arca.toml` ###### **Options:** * `--json` — Emit JSON — stable machine shapes on every verb; errors are `{"error": "…"}` on stderr with a non-zero exit * `--env ` — Env context — `dev`/`staging`/`prod`/custom. Overrides `ARCA_ENV`; default `dev` * `--config ` — Config file, overriding the search ($ARCA_CONFIG → ./arca.toml → ~/.config/arca/arca.toml) * `--actor ` — Actor attribution for audit events (default $ARCA_ACTOR, else $USER) ## `arca init` Bootstrap: store the age identity in the system keyring when a session secret store is reachable — importing an existing identity.txt rather than orphaning it — else the `~/.config/arca/identity.txt` keyfile (0600). Verifies the vault opens **Usage:** `arca init [OPTIONS]` ###### **Options:** * `--with-file` — Force the identity keyfile even when a keyring is available — the headless/CI mode ## `arca put` Store a secret. The value comes from stdin (`printf … | arca put arca://ns/name`) or a hidden terminal prompt — never args, which land in shell history and `/proc` **Usage:** `arca put ` ###### **Arguments:** * `` — `arca://` ref, e.g. arca://billing/stripe ## `arca get` Print a secret's value to stdout — the escape hatch. Prefer `arca run` so values never enter agent context; guardrails can deny this op **Usage:** `arca get ` ###### **Arguments:** * `` — `arca://` ref ## `arca list` List the refs stored in the selected env — names only, never values **Usage:** `arca list` ## `arca rm` Remove a secret from the selected env **Usage:** `arca rm ` ###### **Arguments:** * `` — `arca://` ref ## `arca rotate` Rotate a secret to a new value (stdin or hidden prompt, like `put`); bumps version + rotated_at **Usage:** `arca rotate ` ###### **Arguments:** * `` — `arca://` ref ## `arca run` Run a command with declared secrets injected as env vars. Injection surface = `[secrets]` in arca.toml plus `-s VAR=arca://ref` ad-hoc pairs; nothing else is injectable. Two exec modes after `--`: `arca run -- 'pnpm test | tee log'` — one string → `bash -c` `arca run -- pnpm test --filter x` — argv → direct exec argv mode needs no shell quoting — prefer it when the command has arguments. Exit code is the child's. **Usage:** `arca run [OPTIONS] -- ...` ###### **Arguments:** * `` — Command to run (after `--`). One arg → `bash -c`; several → `argv[0]` exec'd directly with the rest ###### **Options:** * `-s`, `--secret ` — Extra `VAR=arca://ref` pairs to inject (refused when `[guardrails] deny_adhoc` is set) ## `arca env` Print `export VAR='…'` lines for `eval "$(arca env)"` — the human path (`run` is the agent path). Values print to stdout: `--json` emits a `VAR → value` map **Usage:** `arca env [OPTIONS]` ###### **Options:** * `-s`, `--secret ` — Extra `VAR=arca://ref` pairs to resolve ## `arca check` Validate config + the declared surface: `[envs]` routes point at declared vaults, vaults decrypt, every `[secrets]` ref resolves in its routed env. Exit 1 on findings **Usage:** `arca check` ## `arca migrate` Rewrite stored records for a wire/config upgrade. Currently one transform: `--private-refs` re-keys every plaintext-named row in each env routed to a cf vault with `[vaults.*] private_refs = true` — ciphertext moves verbatim, old keys are deleted after the new rows land **Usage:** `arca migrate [OPTIONS]` ###### **Options:** * `--private-refs` — Re-key cf-vault rows under HMAC-derived storage keys, attaching the encrypted `name` blob (P-004) ## `arca deploy` Push a stored secret to a write-only deploy target (`[deploy.NAME]` in arca.toml, e.g. a CF Secrets Store). The vault is the source of truth; the sink never gives the value back. With one configured target, `--target` may be omitted **Usage:** `arca deploy [OPTIONS] ` ###### **Arguments:** * `` — `arca://` ref to push ###### **Options:** * `--target ` — Deploy target name (`[deploy.NAME]`); required when several are configured ## `arca serve` Serve the Vault port over ndjson — one JSON request per stdin line, one response per stdout line, audit events on stderr unchanged. For orchestrators embedding arca (peregrin); with `[serve.pdp]` configured, a policy decision point is consulted before every vault-touching op. `{"op":"manifest"}` on the wire describes the protocol **Usage:** `arca serve` ## `arca ui` Interactive vault browser (requires the `tui` feature) **Usage:** `arca ui` ## `arca manifest` Capabilities, JSON shapes, env vars, audit contract — agent self-discovery (always JSON) **Usage:** `arca manifest` ## `arca schema` JSON Schema for `arca.toml` **Usage:** `arca schema` This document was generated automatically by clap-markdown. --- --- url: /arca/reference/config.md --- # Config — `arca.toml` ## Top level | field | type | required | notes | |---|---|---|---| | `vaults` | `{name: vault}` | optional | Named vault backends. `local` always exists implicitly (age, default paths); an entry only overrides it or adds more. | | `envs` | `{env: vault|{vault}}` | optional | Env → vault routing. Envs not listed route to `local`; `dev`/`staging`/`prod` always exist. | | `secrets` | `{VAR: arca://ref}` | optional | The declared secret surface: env-var name → `arca://` ref. Bounds what `run`/`env` may inject — least privilege, no ambient vault dump. | | `deploy` | `{name: target}` | optional | Named deploy targets — write-only sinks (`arca deploy` pushes a stored value in; nothing ever reads one back). | | `guardrails` | `table` | optional | Advisory op guardrails — accident prevention for standalone use, honestly *not* a policy boundary (a locally claimed actor is spoofable by construction). | | `serve` | `table` | optional | The `arca serve` surface — ndjson request/response on stdio plus the optional policy decision point. Absent, serve runs with no PDP — same advisory posture as the single-shot CLI. | ## `[vaults.NAME]` — a named vault backend | field | type | required | notes | |---|---|---|---| | `kind` | `string` | optional | Backend kind — `"age"` (built-in, local file) or `"cf"` (self-hosted Cloudflare Worker + D1, `arca-vault-cf`). | | `path` | `string` | optional | Vault file path override (age impl; `~` is *not* expanded — write the path in full). | | `identity` | `string` | optional | Age identity file override (`ARCA_IDENTITY_FILE` wins over this; see `arca-ops`). `cf` vaults decrypt with the same keyfile — all vault impls store age ciphertext (ADR-0001). | | `recipients` | `list` | optional | Extra age recipients to encrypt to, beyond the identity's public key (`age1…` strings). | | `url` | `string` | optional | Worker base URL (`cf` impl), e.g. `https://arca-vault.you.workers.dev`. | | `token_env` | `string` | optional | Name of the env var holding the bearer token (`cf` impl) — the token *name* lives in config, the token *value* only in the process env. | | `tokens` | `BTreeMap` | optional | Per-env scoped credentials (`cf` impl, P-003): env → the `arca://` ref of a token stored in the `local` vault. Each resolved token authenticates as `ArcaHmac <ref-path>, ts, sig` — the ref path is the wire `token_id`. Token *refs* live in config, never token values. | | `private_refs` | `bool` | optional | Opt-in private ref metadata (`cf` impl, P-004): storage keys become HMAC-derived hex and every record carries an age-encrypted `name` blob, so the remote store can't see which secrets exist. Default false; `arca migrate --private-refs` rewrites existing rows. | | `cache` | `CacheScope` | optional | Read-through encrypted local cache in front of this vault (P-007): `true` caches every env; a name or list scopes it — `cache = "dev"` or `cache = ["dev", "staging"]`. Rows land at `$XDG_CACHE_HOME/arca/<name>.age` as ordinary age ciphertext carrying the remote `version`/`rotated_at` verbatim; freshness is TTL-bounded (`cache_ttl_secs`) and writes are always remote-first. Meant for remote backends — `dev` may cache, `prod` should live-fetch. | | `cache_ttl_secs` | `int` | optional | Cache freshness window in seconds — a cached row or env listing younger than this answers without a remote round-trip; past it the remote is re-fetched and the cache updated. A remote *failure* falls back to the stale copy — the offline mode. Absent → 300. | ## `[envs]` — env → vault routing (table form) | field | type | required | notes | |---|---|---|---| | `vault` | `string` | required | Vault name in `[vaults]` (or the implicit `local`). | ## `[deploy.NAME]` — a write-only push target | field | type | required | notes | |---|---|---|---| | `kind` | `string` | optional | Target kind — `"cf-secrets-store"` (Cloudflare account Secrets Store, `arca-deploy-cf`). | | `account_id` | `string` | optional | Cloudflare account id (`cf-secrets-store`). | | `store_id` | `string` | optional | Secrets Store id (`cf-secrets-store`). | | `token_env` | `string` | optional | Name of the env var holding the API token — never the token itself (`cf-secrets-store` defaults to `CLOUDFLARE_API_TOKEN`). | | `base_url` | `string` | optional | API base URL override — for tests, gateways, and self-hosted proxies. Defaults to `https://api.cloudflare.com/client/v4`. | ## `[guardrails]` — advisory op table | field | type | required | notes | |---|---|---|---| | `default` | `"allow"|"deny"` | optional | Fallback for ops in neither list. | | `allow` | `op list` | optional | Ops always allowed. | | `deny` | `op list` | optional | Ops always refused — deny wins over allow. | | `deny_adhoc` | `bool` | optional | Refuse `-s VAR=arca://ref` ad-hoc injection on `run`/`env` (MED-5). Without it, a posture like `deny = ["get"]` is sidestepped: `arca run -s V=arca://x -- printenv V` *is* a get. Declared `[secrets]` injection is unaffected — the declared surface is auditable config, not an ad-hoc grab. | ## `[serve]` — the `arca serve` surface | field | type | required | notes | |---|---|---|---| | `pdp` | `table` | optional | The external policy decision point serve consults before every op that can materialize (`get`/`put`/`rm`/`rotate`/ `list`/`check`). Absent, no consult happens — the advisory posture only. | ## `[serve.pdp]` — the external policy decision point | field | type | required | notes | |---|---|---|---| | `exec` | `string` | required | The exec driver command — run via `sh -c` once per decision, request JSON on stdin, verdict JSON on stdout. **Trust root**: the line authorizes shell exec; the child inherits only the `arca-shell` env allowlist. `arca serve` warns when this arrives via an implicit `./arca.toml` — pin config with `--config`/`$ARCA_CONFIG`. | | `allow_on_error` | `bool` | optional | What an unreachable PDP means. `false` (default) fails **closed** — the op is denied; a policy engine that can't answer must not silently mean "allowed". `true` fails open — the op runs anyway and the audit event records the degraded verdict. | | `timeout_ms` | `int` | optional | Per-decision deadline in milliseconds — the child is killed and the consult counts as *unreachable* past it. A hung or flooding PDP must never freeze the serve loop; absent → 5000. | ## `[envs]` route forms Either a bare vault name or the table above: ```toml [envs] dev = "local" prod = { vault = "cloud" } ``` ## Constraints * `deny_unknown_fields`: typos in field names fail the load. * Search order: `--config` → `$ARCA_CONFIG` → `./arca.toml` → `~/.config/arca/arca.toml`. The file is optional — no `arca.toml` anywhere just means defaults (local age vault, `dev` env). * Token values never live in the file — `token_env` fields name the env var that carries them. * `arca schema` emits the JSON Schema for this shape; `arca check` validates a real file end-to-end. --- --- url: /arca/reference/audit-events.md --- # Audit events The audit wire: one ndjson line on **stderr** per op outcome. arca keeps no local ledger (ADR-0001 — aggregation is peregrin's); the stderr stream *is* the contract, so a wrapper — peregrin, sepia, your own harness — can capture it, and a human sees a breadcrumb. ## Shape ```json {"ts":"2026-01-01T00:00:00Z","actor":"devin","op":"run","rref":"billing/stripe","env":"dev","outcome":"ok"} ``` | field | notes | | --- | --- | | `ts` | RFC 3339, UTC | | `actor` | `--actor` → `ARCA_ACTOR` → `$USER`/`$LOGNAME` → `anon`. Self-claimed — attribution, not identity | | `op` | the verb: `init put get list rm rotate run env check deploy ui serve manifest schema` | | `rref` | the resolved ref path, env pin stripped — same on `denied`/`error` when the arg parses, else the raw arg verbatim. **Absent** on op-scoped events | | `env` | the env the op ran under | | `outcome` | `ok` | `denied` | `error` | | `policy` | **serve-path only, optional** — the PDP verdict `{allow, reason?, obligations?}` behind the outcome; absent on every other path | ## When events fire | event | meaning | | --- | --- | | `ok`, ref-scoped | one **per ref** materialized or mutated — `put`, `get`, `rotate`, `rm`, `deploy`, and per resolved ref inside `run`/`env` | | `ok`, op-scoped | ref-less verb completed — `init`, `list`, `check`, `ui`, `serve` | | `denied` | `[guardrails]` — or a serve-path PDP verdict of `allow: false` (including fail-closed on an unreachable PDP) — refused the op before it touched the vault; `rref` is the requested ref (normalized when parseable) | | `error` | the op ran and failed — missing secret, vault i/o, bad ref; a `check` with findings also audits `error` (it exits 1); also emitted when config load itself fails (best-effort actor/env from flags + process env) | Two deliberate silences: * `manifest`, `schema`, `gen-cli-docs` run before config load — pure introspection, no event. * A `run` child's non-zero exit is the *child's* outcome — no `error` event for it. arca's own exit code is the child's. ## `serve` and the `policy` field `arca serve` emits the same events — `get`/`put`/`rm`/`rotate` requests audit exactly like the CLI verbs, under the request's claimed `actor` when it carries one. The PDP consult covers the ops that can materialize — `get`/`put`/`rm`/`rotate` *and* `list`/`check` (record decrypts, vault probes, scoped-token gets) — so a `check`'s incidental token `get`s ride the same `policy`-bearing events. On the serve path only, every event a consulted `[serve.pdp]` gated gains a `policy` object — `{allow, reason?, obligations?}` — so the stream shows the verdict behind the outcome: a PDP allow, a PDP deny (`outcome: "denied"`, `policy.allow: false`), or a degraded allow recorded when `allow_on_error` overrode an unreachable PDP. The `serve` verb itself closes with one op-scoped `ok`. See [the serve protocol](serve-protocol.md). ## `ui` and the TUI `arca ui` emits one op-scoped `ok` when the browser exits cleanly. Inside it, browsing is silent — listing refs and metadata is not a materialization. The `r`→`r` reveal goes through the audited `get` path: a real `{"op":"get","rref":…,"outcome":"ok"}` event per value shown. ## Failure posture Audit emission is best-effort: a closed stderr (broken pipe) never takes the broker down — the write error is swallowed by design. --- --- url: /arca/reference/serve-protocol.md --- # The `arca serve` protocol `arca serve` is the Vault port over newline-delimited JSON — the surface orchestrators (peregrin) embed. One JSON request per **stdin** line, one JSON response per **stdout** line, and the [audit wire](audit-events.md) stays on **stderr**, unchanged — three separate fds, so a reply never rides the event stream and an event never answers a request. Protocol version: `arca-serve/1` — send `{"op":"manifest"}` on the wire for the machine-readable self-description. Serve is a thin front over the same `arca-ops` plumbing the CLI uses: `[envs]` routing, `arca:///…` pins, and audit events are identical. `run`/`env`/`deploy`/`init`/`ui`/`migrate`/`schema` stay single-shot CLI verbs — serve covers what an embedder needs in-process. ## Requests ```json {"id": 1, "op": "get", "ref": "arca://billing/stripe", "env": "prod", "actor": "agent:peregrin", "attestation": "…"} ``` | field | notes | | --- | --- | | `id` | any JSON value — echoed verbatim (absent → `null`), ≤ 4 KiB serialized | | `op` | `get` | `put` | `rm` | `rotate` | `list` | `check` | `manifest` | `shutdown` | | `ref` | `arca://ns/name` ≤ 1 KiB — required by the four vault ops; an `arca:///…` pin overrides `env` | | `env` | per-request env override — else the loaded context (`--env`/`ARCA_ENV`) | | `value` | plaintext ≤ 64 KiB — required by `put`/`rotate`. Values cross this wire deliberately: the parent owns the channel exactly like it owns `arca get`'s stdout | | `actor` | claimed actor id ≤ 256 B — advisory attribution recorded on audit events; validated, never verified | | `attestation` | opaque identity claim ≤ 64 KiB — passed to the PDP verbatim, never interpreted by arca | Hard bounds on the wire: a request line may be at most **4 MiB** — an oversized line answers `bad_request` and the reader resyncs to the next line, so a broken embedder can't exhaust memory. Blank or whitespace-only lines get **no reply** — legal keep-alive, unanswered. A non-UTF-8 line is a `bad_request`, not a crash. ## Responses ```json {"id": 1, "ok": true, "result": {"ref": "arca://billing/stripe", "env": "prod", "value": "…", "version": 3, "created_at": "…", "rotated_at": "…"}, "policy": {"allow": true, "reason": "…", "obligations": ["…"]}} ``` ```json {"id": 2, "ok": false, "error": {"code": "denied", "message": "denied by policy: no prod reads"}} ``` `policy` appears on a response whenever a PDP was consulted for that op — the same verdict the audit event records. | error code | meaning | | --- | --- | | `bad_request` | malformed/oversized/non-UTF-8 line, missing `ref`/`value`, invalid ref/env/actor, field over cap — the loop keeps serving | | `unknown_op` | op not in the table (a real CLI verb reports "not served — use the single-shot CLI") | | `denied` | `[guardrails]` or PDP refusal | | `pdp_unreachable` | the PDP couldn't answer (spawn, non-zero exit, timeout, flooded/oversized/unparseable verdict) and `allow_on_error` is false — fail-closed | | `not_found` | the ref isn't stored in that env | | `op_error` | vault/config/io failure | `shutdown` replies then exits cleanly; EOF on stdin exits too. `manifest` and `shutdown` are protocol meta-ops: they skip guardrails and the PDP consult by design — gating session lifecycle is the embedder's spawn decision, not a materialization. ## The policy decision point — `[serve.pdp]` Configured, serve consults the PDP **before every op that can materialize** — `get`/`put`/`rm`/`rotate`, and `list`/`check` too: `list` decrypts every record's metadata and `check` probes vaults, including scoped-token `get`s and outbound signed HTTP against cf vaults. Nothing crosses the vault boundary without a verdict. `manifest`/`shutdown` are meta-ops and don't consult. `[guardrails]` still apply on the serve path (the advisory layer, checked before the consult), and request validation runs before the consult too — a doomed request never spawns a PDP. ```toml [serve.pdp] exec = "peregrin policy decide" # sh -c per decision allow_on_error = false # default: fail closed timeout_ms = 5000 # default: 5s per consult ``` A `[serve.pdp] exec` line is **trust-root config**: it authorizes shell execution. When `arca serve` loads it from an *implicit* `./arca.toml` (the search path, not a pinned `--config`/`$ARCA_CONFIG` or `~/.config/arca/`), it warns on stderr — the working directory decides what code runs. The `exec` command runs once per decision — request JSON on stdin, verdict JSON on stdout, stderr discarded. The child inherits only the `arca-shell` env allowlist (same scrub `arca run` applies): `ARCA_CF_TOKEN`, `CLOUDFLARE_API_TOKEN`, and every other parent credential stay out of the decider. ```json {"actor": "agent:peregrin", "attestation": "…", "op": "get", "env": "prod", "ref": "billing/stripe"} ``` `ref` is the resolved path with the env pin stripped — and is **absent** on `list`/`check` consults, which carry no ref. ```json {"allow": true, "reason": "…", "obligations": ["redact", "session-bound"]} ``` `allow` is required; `reason` and `obligations` are free-form context the PDP wants on record (allow-with-conditions — arca records them; enforcing them is the requester's job). A deny is a `denied` response plus a `denied` audit event — no vault access. A consult is hard-bounded so a misbehaving PDP can't take the broker with it: the child is killed at `timeout_ms`, the verdict is capped at 64 KiB (a flooding child blocks on its own pipe and dies on the deadline — it cannot sink memory), and the request write can't deadlock against a child that never reads stdin. Spawn failure, non-zero exit, timeout, an oversized or unparseable verdict is **unreachable** → the op is denied (`pdp_unreachable`) unless `allow_on_error = true`, which runs the op anyway and records the degraded verdict (`policy.reason` says why). Fail-closed is the default because an unanswered policy engine must not silently mean "allowed". Why `exec` and not a socket or HTTP: zero new dependencies, sync only, no daemon lifecycle. A PDP that wants a persistent endpoint can put `socat`/`nc`/`curl` in its own exec line — the wire stays the same. Note the per-op cost: every consult spawns a fresh `sh -c`, `list` decrypts each record, and `check` probes every vault — batch reads rather than looping `list`. ## Actor claims are advisory `actor` and `attestation` are *claims*, not proof — arca validates the actor string's shape and passes both through to audit and the PDP. Signed, short-lived attestation is the PDP's side (peregrin's design, a later decision); ADR-0003 only fixes the pass-through seam. --- --- url: /arca/reference/env-vars.md --- # Environment variables Process env vars arca itself reads. (The vars in `[secrets]` are yours — this is arca's own surface; `arca manifest` reports the same table.) | var | default | used for | | --- | --- | --- | | `ARCA_CONFIG` | — | config file override, between `--config` and `./arca.toml` in the search order | | `ARCA_ENV` | `dev` | env context — resolution + routing (`--env` wins) | | `ARCA_ACTOR` | `$USER` → `anon` | audit attribution (`--actor` wins). Self-claimed | | `ARCA_IDENTITY_FILE` | `~/.config/arca/identity.txt` | age identity keyfile for every vault impl | | `ARCA_CF_TOKEN` | — | bearer token for `kind = "cf"` vaults — *default* name; `[vaults.*].token_env` picks another | | `CLOUDFLARE_API_TOKEN` | — | API token for `cf-secrets-store` deploy sinks — *default* name; `[deploy.*].token_env` picks another | Honored XDG paths (when the matching override is unset): | var | falls back to | | --- | --- | | `XDG_CONFIG_HOME` | `~/.config` — identity + user-level `arca.toml` | | `XDG_DATA_HOME` | `~/.local/share` — the default `vault.age` | Two rules of thumb: * `token_env` config fields name env vars — the token *value* only ever lives in the process env. * `ARCA_ACTOR` is a self-claimed string for the audit trail, not authentication; see the [threat model](../explanation/threat-model.md). --- --- url: /arca/reference/crate-api.md --- # Crate API Rustdoc for every workspace crate — public APIs are documented at source (doc comments). * [arca-core](/arca/api/arca_core/index.html) — domain * [arca-config](/arca/api/arca_config/index.html) — port * [arca-deploy](/arca/api/arca_deploy/index.html) — port * [arca-vault](/arca/api/arca_vault/index.html) — port * [arca-ops](/arca/api/arca_ops/index.html) — ops * [arca-config-toml](/arca/api/arca_config_toml/index.html) — adapter * [arca-deploy-cf](/arca/api/arca_deploy_cf/index.html) — adapter * [arca-identity](/arca/api/arca_identity/index.html) — adapter * [arca-identity-file](/arca/api/arca_identity_file/index.html) — adapter * [arca-identity-keyring](/arca/api/arca_identity_keyring/index.html) — adapter * [arca-serve](/arca/api/arca_serve/index.html) — adapter * [arca-shell](/arca/api/arca_shell/index.html) — adapter * [arca-testkit](/arca/api/arca_testkit/index.html) — adapter * [arca-tui](/arca/api/arca_tui/index.html) — adapter * [arca-vault-age](/arca/api/arca_vault_age/index.html) — adapter * [arca-vault-cached](/arca/api/arca_vault_cached/index.html) — adapter * [arca-vault-cf](/arca/api/arca_vault_cf/index.html) — adapter * [arca](/arca/api/arca/index.html) — binary --- --- url: /arca/explanation/architecture.md --- # Architecture Ports-and-adapters over a flat `crates/` workspace. `arca-core` is pure domain — `arca://` refs, the redacting `Secret` newtype, the env axis, actor attribution — with no I/O. Ports are traits (`arca-config`, `arca-vault`, `arca-deploy`); impls live in separate crates; `crates/arca-cli` (package `arca`) is the composition root that wires impls to verbs. Design rules: * **No `unsafe`, no async** — the binary stays dependency-light and synchronous; vault impls that speak HTTP use a blocking client (`ureq`), so no runtime embeds anywhere. * **`Secret` never exposes plaintext** — no `Display`/`Debug` of the value; `.expose()` is the only way out, called at edges only (child env injection, `arca env` exports, `get`). * **Stores hold ciphertext only** — every vault impl encrypts client-side with age (ADR-0001); a remote vault is ciphertext over the wire *and* at rest, never a plaintext service. * **Ports are conformance-tested** — `arca-testkit` runs the same contract suite against every vault impl (`arca-vault-age`, `arca-vault-cf`): upsert/rotate/list/env-isolation semantics can't drift per backend. * **One ops shell** — `arca-ops` owns config load, env→vault routing, the env-pin rule, and audit emission; the CLI and the TUI are both thin front-ends over it and cannot diverge. | crate | layer | purpose | |---|---|---| | `arca-core` | domain | arca domain core: arca:// refs, redacting Secret, env axis, actor, config model — pure, no I/O | | `arca-config` | port | Config port: where arca.toml-shaped config comes from | | `arca-deploy` | port | Deploy port: write-only push targets (sinks) for secret values — never a source | | `arca-vault` | port | Vault port: readable secret stores — get/put/rm/list/rotate, per-env; every impl stores opaque ciphertext | | `arca-ops` | ops | arca ops I/O: config load, env/ref resolution, vault open, verb plumbing, audit emission | | `arca-config-toml` | adapter | Config impl: arca.toml file (explicit path, env, ./, ~/.config) | | `arca-deploy-cf` | adapter | Deploy impl: push secret values to a Cloudflare account Secrets Store (write-only sink) via the CF API v4 | | `arca-identity` | adapter | Identity port: where the age identity (master key) comes from — file, keyring, later sealed variants | | `arca-identity-file` | adapter | Identity impl: the ~/.config/arca/identity.txt age keyfile (0600) — the headless/CI default | | `arca-identity-keyring` | adapter | Identity impl: the platform session secret store (secret-service / Keychain / Credential Manager) via keyring-core | | `arca-serve` | adapter | arca serve — the ndjson request/response IPC surface orchestrators embed, with the external policy-decision consult (P-005) | | `arca-shell` | adapter | Run a command in a working directory via bash -c — the generic shell-out helper | | `arca-testkit` | adapter | Vault conformance suite — every arca-vault impl passes the same contract tests (fake values only) | | `arca-tui` | adapter | arca — interactive vault TUI; a thin front-end over arca-ops (phase 5 polish) | | `arca-vault-age` | adapter | Local vault impl: one age-x25519-encrypted JSON doc — the v1 default backend | | `arca-vault-cached` | adapter | Composite vault impl: read-through age-encrypted local cache in front of any backend (P-007) | | `arca-vault-cf` | adapter | Remote vault impl: self-hosted Cloudflare Worker + D1 holding per-secret age ciphertext — the Worker never sees plaintext | | `arca` | binary | arca — secrets broker for agents: reference URIs, redaction, env-injection CLI | ## Front-ends * **`arca` (CLI)** — clap verbs + `--json`/`manifest`/`schema` for machines; the agent interface. * **`arca ui` (TUI)** — a read-only ratatui browser over the same ops; feature-gated (`--no-default-features` for a lean binary). * **`arca serve`** — ndjson IPC over the Vault port with an exec-driven `[serve.pdp]` policy-decision hook (ADR-0003); peregrin's embedding point. ## Boundary — secret materialization only arca owns refs, vaults, env injection, redaction, and the audit wire. Neighbors own theirs — see [boundaries](boundaries.md) for the full map. --- --- url: /arca/explanation/threat-model.md --- # Threat model What arca protects against, honestly — and what it explicitly does not. ## Protects against * **Plaintext in the repo** — code and config carry `arca://` refs, never values. There is nothing to leak in a commit, a diff, a paste, or a prompt context. * **Plaintext in shell history / process listings** — values never enter via CLI arguments (args land in `~/.bash_history` and `/proc/*/cmdline`). `put`/`rotate` read stdin or a hidden `/dev/tty` prompt. * **Plaintext in agent context** — `arca run` injects values into the child's environment; the agent that spawned it sees the child's *output*, never the variable. `[secrets]` bounds the injectable surface so a `run` can't dump the whole vault. * **Accidental printing** — `Secret` has no `Display`/`Debug` of the value (`.expose()` only), `list` is names + metadata only, and the TUI's posture is browse-never-spill (reveal is a deliberate two-step, audited). * **Ciphertext at rest, everywhere** — every vault impl stores age ciphertext. The local file is unreadable without the identity; the `cf` remote store holds ciphertext over the wire *and* in D1 — a compromised worker yields blobs, not values. With `[vaults.*] private_refs = true` it can't even see *which* secrets exist: storage keys are HMAC-derived hex and the real ref travels in an age-encrypted `name` blob. * **Token leakage on the cf wire** — scoped tokens (`ARCA_VAULT_TOKENS`) bound a leaked credential to its `{env, read, write}` scope instead of every env forever, and ArcaHmac request signing means the token never travels at all (a ±60s window bounds replay). All auth compares are constant-time. * **Silent misuse** — every materialization, denial, and error emits an ndjson audit event on stderr, so capture is a wrapper away. `check` fails closed on a vault that won't decrypt, and on a mixed-mode cf vault (private config + plaintext rows). ## Does NOT protect against * **A determined local actor** — `ARCA_ACTOR` is self-claimed (attribution, not identity) and `[guardrails]` are advisory: anyone who can edit `arca.toml` or run the binary can bypass them. They prevent *accidents*, not adversaries. Real multi-actor policy and enforcement belong to **peregrin**. * **The identity's host protection** — the master key lives in the session keyring when one exists (ADR-0002), which protects it about as well as your login session. On headless/CI it is still `identity.txt` (0600), raw key material — protect it like a private SSH key. Either way, UID-level compromise reaches it: the keyring raises the bar, it is not a TPM. * **The child process** — once `run` injects env vars, the child owns them (`/proc//environ`, core dumps, the child logging its own env). arca's guarantee ends at `exec`. * **Deploy sinks** — `arca deploy` pushes plaintext into the sink by design (CF Secrets Store is write-only); a sink compromise is outside arca's boundary — it crosses exactly once, at push. * **Access patterns** — `private_refs` hides *which* secrets exist, not *when* they're touched: request timing and row counts are still observable to the store. Oblivious access is out of scope. * **Host compromise in general** — an attacker who can read your memory, ptrace the broker, or impersonate your UID already won; arca raises the bar for *accidental* and *casual* leaks, not root. ## In one line arca makes the *default* posture "values never appear in repos, prompts, args, logs, or agent context" — and records every time one still crosses. --- --- url: /arca/explanation/boundaries.md --- # Boundaries arca is one organism in a stack — it owns **secret materialization** and nothing else. If a feature needs another kind of memory or authority, it belongs to a neighbor. | neighbor | owns | how it meets arca | | --- | --- | --- | | **myco** | work memory — tasks, issues, proposals, plans, ADRs, handoffs | this repo's `project/` ledger is myco's; arca has no PM surface | | **sturn** | ephemeral coordination — live claims/ops/topics | not secrets: sturn state is transient, arca state is sealed | | **kuda** | code memory — symbol/index knowledge | arca refs name *secrets*, not code entities | | **peregrin** | orchestration — campaigns, multi-actor arbitration, policy | campaigns consume secrets via `arca run`; audit aggregation + real policy are peregrin's (`ARCA_ACTOR` is self-claimed inside arca); `arca serve` — the ndjson IPC wire + `[serve.pdp]` exec consult (ADR-0003) — is its embedding point | | **bupha** | CI gate | asserts no plaintext secrets land in pipelines; `arca check` in pipeline steps | | **mimus** | critic | secrets resolve env → arca → legacy (keyring, `~/.config/mimus/env`) via `mimus-arca` shelling `arca`; vaults are canonical, writes land in them | | **sepia** | session lifecycle | session wrappers can capture arca's stderr audit stream | ## What that means in practice * **A hosted vault service is in scope** — `arca-vault-cf` is a store impl behind the same port, ciphertext-only, self-hosted. * **A daemon is out of scope** — persistent coordination, multi-machine views, arbitration between actors → peregrin. arca stays a sync, embeddable binary: env vars are the interface, no SDK. * **Policy is out of scope** — `[guardrails]` are advisory accident prevention. Enforcement, identity, and unforgeable attribution are orchestration concerns. * **The audit wire stops at stderr** — emitting ndjson is arca's; collecting, storing, and alerting on it is peregrin's (ADR-0001 deliberately removed the local `audit.jsonl`). --- --- url: /arca/explanation/crypto-model.md --- # Crypto model One crypto choice everywhere, made once in ADR-0001: **age**. Every vault impl stores age ciphertext; the same identity unlocks the local file vault and the remote `cf` vault alike. ## The pieces * **Identity** — one X25519 keypair, held by an `arca-identity` impl (ADR-0002): the **session keyring** when one is reachable (secret-service, macOS Keychain, Credential Manager), else the `~/.config/arca/identity.txt` keyfile (mode `0600`). An explicit file — `ARCA_IDENTITY_FILE` or `[vaults.*].identity` — pins the keyfile outright; that is the headless/CI path. `arca init` bootstraps whichever backend applies and prints the public `age1…` recipient. * **Recipients** — `[vaults.*].recipients` encrypts to extra public keys beyond the identity's own — a team recipient list without sharing the private key. * **Ciphertext at rest** — the local vault is a single age-encrypted file (`vault.age`); the `cf` vault stores per-secret age ciphertext in D1. Neither store — local disk, remote worker — holds plaintext or key material. * **Seal/open at the edge** — values are plaintext only inside the arca process, and only for as long as the op needs them. `Secret` is a `Zeroizing` newtype: memory is scrubbed on drop, and nothing but `.expose()` reads it. * **HMAC alongside age** — the `cf` wire uses HMAC-SHA256 twice (P-003, P-004), keyed but unkeyed-payload, complementary to the age envelope: request *signing* so tokens never travel, and `ref_key` *derivation* so storage keys are unlinkable. Neither is a second encryption primitive — it's authentication and deterministic naming. ## Why age (and not KMS / libsodium / SSH keys) * **One identity, every backend** — a remote vault that stores client-side ciphertext needs an offline asymmetric primitive; age gives recipient-style encryption with no service to trust. * **Dependency posture** — the `age` crate (rage's Rust impl) is pulled with `default-features = false`: binary ciphertext, sync API, no async/armor/plugin machinery — the binary stays light per the workspace rules. * **Interop for free** — the vault file decrypts with stock `rage`/`age` tooling, so a human is never locked into the CLI for disaster recovery. ## The cf wire (`arca-vault-cf/2`) * **Request signing** — `Authorization: ArcaHmac <token_id>, <ts>, <sig>` with `sig = hex HMAC-SHA256(token, method+path+ts+ sha256hex(body))`. The token is the HMAC key and never crosses the wire; a ±60s timestamp window bounds replay. Worker-side, `ARCA_VAULT_TOKENS` maps `token_id → {env → {read, write}}` scopes — per-env credentials instead of one static bearer (which stays accepted during migration). * **Private refs** — with `[vaults.*] private_refs = true`, the storage key is `ref_key = hex HMAC-SHA256(k, env + "/" + ref)` where `k = HMAC-SHA256(identity, "arca-cf/private-refs/v1")`. Derivation is deterministic (lookup is a plain keyed read) and identity-bound (rotating the identity re-keys everything, via `arca migrate --private-refs`). The real ref travels inside a `name` blob — age ciphertext — so `list` maps keys back while the store sees only hex. * **Constant-time compares** — every token/signature comparison on the worker is a byte-wise XOR-fold over the longer input with length folded in; no early exit, no prefix oracle. ## What is deliberately not here * **No KMS port yet** — managed-key backends (AWS KMS/Secrets Manager) were explored in the `arca-vault-aws` spike to prove the `Vault` port generalizes to non-age stores; a KMS-backed *crypto* port is a later ADR, not v1. * **No passphrase-wrapped identity file** — when the keyring is unavailable the keyfile is still raw age key material at `0600`, like an unencrypted SSH key. A passphrase-wrapped arm of the identity port is a later impl, not a new port. * **No signing / integrity metadata** beyond what age's AEAD gives — tampered ciphertext fails decryption loudly, which is the correct failure. --- --- url: /arca/explanation/worktree-model.md --- # The worktree model Why `project/` and `docs/` live as git worktrees of orphan branches inside this repo — and what that buys. ## The shape ```text arca/ ├── crates/ … ← code branch (main) ├── project/ ← worktree of the orphan `project` branch │ ├── TODO.md BACKLOG.md index.md meta.toml │ └── issues/ proposals/ plans/ adrs/ handoffs/ ├── docs/ ← worktree of the orphan `docs` branch │ └── this site — VitePress sources └── .gitignore ← ignores project/ and docs/ ``` Both worktrees share the repo's `.git` object store but have histories disjoint from `main` — orphan roots, no code ancestors. To tools they're ordinary directories; to `git worktree list` they're checkouts of their own branches. ## Why orphan branches Non-code state moves at its own cadence. On orphan branches, ledger and docs commits never touch the code history: no `chore: handoff T-009` noise in `main`'s log, no PM or docs-site diffs in code PRs. Both dirs are gitignored on `main` — the worktrees physically can't leak into a code commit. ## Why worktrees (not just branches) The content has to be *visible* — agents read `project/`, humans edit `docs/` — without `git checkout` swapping the code tree. Worktrees keep all three branches checked out at once. ## `project/` — the PM ledger Owned by **myco**, not arca: this repo dogfoods the stack's PM tool. The `project` branch history is the audit trail — every mutating `myco` verb is one atomic commit. `myco init` created the branch and worktree; agents go through `myco` verbs rather than hand-editing when a verb exists. ## `docs/` — this site The `docs` branch holds the VitePress + Diátaxis sources; generated pages (`reference/cli.md`, `reference/config.md`, the crate map in `architecture.md`, `reference/crate-api.md`) are rebuilt by `docs/gen-*.py` and `arca gen-cli-docs` — never hand-edited. `cargo xtask site` builds the site; pushes deploy to GitHub Pages at `9aia.com/arca` via `scripts/deploy-docs.sh`. See [architecture](architecture.md) for the crates the code side is made of. --- --- url: /arca/getting-started.md --- # Getting started Install the `arca` CLI, seal your first secret in the local age-encrypted vault, and run a command that uses it — without the value ever touching your terminal, a file, or an agent's context. ## Install The crates.io name `arca` belongs to an unrelated project — install from the repo: ```sh cargo install --git https://github.com/9aia/arca arca ``` From a checkout of this repo: `cargo xtask install` builds and installs the release binary to `~/.cargo/bin/arca`. For a lean binary without the TUI: `cargo install --git https://github.com/9aia/arca arca --no-default-features`. ## Bootstrap ```sh arca init ``` `init` stores your age identity where it's safest available and verifies the vault at `~/.local/share/arca/vault.age` opens. It's idempotent — run it any time. On a desktop session (GNOME Keyring, KeePassXC, KDE Wallet, macOS Keychain, Windows Credential Manager) the identity lands in the session keyring — it unlocks with login, no prompt. On headless boxes with no session store, `init` falls back to the `~/.config/arca/identity.txt` keyfile (mode `0600`) with a note saying so. `arca init --with-file` forces the keyfile; pinning `ARCA_IDENTITY_FILE` or `[vaults.*].identity` in config does the same — that's the headless/CI path. Already have an `identity.txt`? `arca init` imports it into the keyring rather than orphaning it, and leaves the file as a backup you can delete when ready. ## Seal a secret Values never enter via CLI arguments — they would land in shell history and `/proc`. Pipe them instead: ```sh printf '%s' "$STRIPE_KEY" | arca put arca://billing/stripe ``` Interactive humans get a hidden `/dev/tty` prompt instead of stdin. The value is encrypted to your identity and stored under the `dev` env (the default context). ## Declare the surface `[secrets]` in `arca.toml` bounds what `run`/`env` may inject — least privilege, no ambient vault dump: ```toml [secrets] STRIPE_KEY = "arca://billing/stripe" ``` Config search: `./arca.toml` → `~/.config/arca/arca.toml` (`--config`/`$ARCA_CONFIG` override). No file at all is fine — defaults are the local vault and the `dev` env. ## Run with secrets ```sh arca run -- 'curl -H "Authorization: Bearer $STRIPE_KEY" \ https://api.stripe.com/v1/charges' ``` `run` resolves the declared refs, injects them as env vars into the child only, and exits with the child's code. One arg after `--` runs via `bash -c` (shell syntax, as above); several args exec `argv[0]` directly with no shell. The value lives only in that process — agents never see it. ## Check the setup ```sh arca check ``` Validates the config, that the routed vaults decrypt, and that every `[secrets]` ref resolves — exit `1` on findings. ## Agent self-discovery ```sh arca manifest # verbs, JSON shapes, env vars, audit contract (JSON) arca schema # JSON Schema for arca.toml arca --help # every verb, self-documenting ``` `--json` is honored on every verb; errors are `{"error": "…"}` on stderr with a non-zero exit. The [arca skill](https://github.com/9aia/arca/tree/main/.agents/skills/arca) teaches agents the verbs. ## Where next * [Your first secret](tutorials/first-secret.md) — a guided end-to-end in a throwaway HOME. * [Inject into a process](how-to/inject-into-a-process.md) — run modes, ad-hoc `-s` pairs, the `eval "$(arca env)"` human path. * [The threat model](explanation/threat-model.md) — what arca does and does not protect against. * [CLI reference](reference/cli.md) — the full verb surface. --- --- url: /arca/public/api/static.files/SourceSerif4-LICENSE-a2cfd9d5.md --- Copyright 2014-2021 Adobe (http://www.adobe.com/), with Reserved Font Name 'Source'. All Rights Reserved. Source is a trademark of Adobe in the United States and/or other countries. Copyright 2014 - 2023 Adobe (http://www.adobe.com/), with Reserved Font Name ‘Source’. All Rights Reserved. Source is a trademark of Adobe in the United States and/or other countries. This Font Software is licensed under the SIL Open Font License, Version 1.1. This license is copied below, and is also available with a FAQ at: http://scripts.sil.org/OFL *** ## SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 PREAMBLE The goals of the Open Font License (OFL) are to stimulate worldwide development of collaborative font projects, to support the font creation efforts of academic and linguistic communities, and to provide a free and open framework in which fonts may be shared and improved in partnership with others. The OFL allows the licensed fonts to be used, studied, modified and redistributed freely as long as they are not sold by themselves. The fonts, including any derivative works, can be bundled, embedded, redistributed and/or sold with any software provided that any reserved names are not used by derivative works. The fonts and derivatives, however, cannot be released under any other type of license. The requirement for fonts to remain under this license does not apply to any document created using the fonts or their derivatives. DEFINITIONS "Font Software" refers to the set of files released by the Copyright Holder(s) under this license and clearly marked as such. This may include source files, build scripts and documentation. "Reserved Font Name" refers to any names specified as such after the copyright statement(s). "Original Version" refers to the collection of Font Software components as distributed by the Copyright Holder(s). "Modified Version" refers to any derivative made by adding to, deleting, or substituting -- in part or in whole -- any of the components of the Original Version, by changing formats or by porting the Font Software to a new environment. "Author" refers to any designer, engineer, programmer, technical writer or other person who contributed to the Font Software. PERMISSION & CONDITIONS Permission is hereby granted, free of charge, to any person obtaining a copy of the Font Software, to use, study, copy, merge, embed, modify, redistribute, and sell modified and unmodified copies of the Font Software, subject to the following conditions: 1. Neither the Font Software nor any of its individual components, in Original or Modified Versions, may be sold by itself. 2. Original or Modified Versions of the Font Software may be bundled, redistributed and/or sold with any software, provided that each copy contains the above copyright notice and this license. These can be included either as stand-alone text files, human-readable headers or in the appropriate machine-readable metadata fields within text or binary files as long as those fields can be easily viewed by the user. 3. No Modified Version of the Font Software may use the Reserved Font Name(s) unless explicit written permission is granted by the corresponding Copyright Holder. This restriction only applies to the primary font name as presented to the users. 4. The name(s) of the Copyright Holder(s) or the Author(s) of the Font Software shall not be used to promote, endorse or advertise any Modified Version, except to acknowledge the contribution(s) of the Copyright Holder(s) and the Author(s) or with their explicit written permission. 5. The Font Software, modified or unmodified, in part or in whole, must be distributed entirely under this license, and must not be distributed under any other license. The requirement for fonts to remain under this license does not apply to any document created using the Font Software. TERMINATION This license becomes null and void if any of the above conditions are not met. DISCLAIMER THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE FONT SOFTWARE.