arca

command module
v0.9.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 27, 2026 License: MIT Imports: 36 Imported by: 0

README

arca logo

arca

arca (Latin): a strongbox or chest for keeping valuables under lock.

ci codeql OpenSSF Scorecard OpenSSF Baseline Go Report Card MIT

A small, file-based secrets manager built on age — designed to sit safely in front of AI agents. Secrets are encrypted per value with cleartext metadata in a single JSON store; every access is recorded in a local, fail-closed audit log attributed to the calling agent. No daemon, no account, no proprietary backend.


Contents


Why arca

  • Use secrets without revealing them. A command can use a secret (exec) or a config can reference one (inject) while the value never reaches stdout or an agent's context.
  • Every access is accountable. The audit log records who/what/when — including the auto-detected AI agent, version, and session — and is fail-closed by default.
  • Per-secret policy. Mark a secret exec-only (--no-print) or require human approval (--require-approval).
  • Git-friendly. The store is plain JSON: it diffs, merges, and lives happily in a dotfiles repo as the source of truth, with a free created/modified history from git log.
  • Inspectable & dependency-light. Stdlib + age + cobra; pure-Go SQLite for the audit log. No cgo, reproducible builds, signed releases.

Features

Area What you get
Encryption Per-value age (X25519) ciphertext, ASCII-armored; reuses your existing $SOPS_AGE_KEY_FILE
Store Single JSON doc; cleartext metadata (tags, description, timestamps, policy), encrypted values
Metadata & query ls/show list and filter without decrypting; --reads joins usage from the audit log
Rotation rotate (keeps created_at), --rotate-after dates, stale to find overdue/missing policies
Expiry (TTL) --ttl 30m|12h|7d|2w or --expires-at; expired secrets are refused on every access path and surfaced by stale
Audit Local SQLite log of every access; agent name/version/session attribution; fail-closed by default; hash-chained + per-session signed so tampering is detectable (log --verify)
AI-safety policies --no-print (exec-only), --require-approval (human approval), least-privilege exec --only
Deny-by-default agents arca mcp --strict exposes only the secrets you arca agent allow — an agent sees and uses nothing else (list/show/read/run all refuse it)
Health check arca doctor audits your setup — identity-key perms/location, decryption blast radius, high-privilege secret names, rotation/expiry, agent exposure, audit-chain integrity — ranked by severity with a fix per finding (--json, --fix)
Exposure visibility arca who-can-read / arca exposure show which recipients can decrypt each secret; label keys with recipients add --label name@machine
Canary secrets Plant realistic decoys (canary --template stripe); any use trips a loud, signed audit alert — leak detection, not just prevention
JIT grants --require-grant secrets are usable only via a grant scoped to a command, a use count, and a time window — bind a secret to what an agent does, not just whether it can see it
Rate limiting set --rate 10/1h caps how often a secret may be used in a rolling window; the throttle is recorded, so a runaway agent hammering a secret is stopped and surfaced
Capability handles handle create mints an opaque hdl_… token; over MCP an agent uses it (run_with_handle) without ever learning the secret's name or value, or being able to enumerate the store
References arca://NAME resolved at render time by inject — agents manipulate references, not secrets
Teams Encrypt each value to multiple age recipients; recipients add/rm + reencrypt re-wrap the whole store
Multi-machine sync Optional sync replicates the store through any S3-compatible backend (R2, MinIO, Garage, S3). The backend is untrusted: the store is age-encrypted end-to-end (it sees only ciphertext), lost updates are impossible (conditional-write CAS), and replay/rollback/recipient-broadening are refused on pull. sync auto on for opportunistic push/pull; the audit trail is escrowed off-machine too (log --verify --remote)
JSON output --json on ls/show/log/stale for agents and scripts
Completion completion bash|zsh|fish with dynamic secret-name + tag suggestions
Migration import a dotenv or JSON stream (audited); set NAME < file for blobs; env for shell eval
Supply chain Reproducible builds, SBOM, cosign-signed + SLSA-provenanced releases, govulncheck, CodeQL

Install

Homebrew (macOS / Linux):

brew install arenzana/tap/arca

Scoop (Windows):

scoop bucket add arenzana https://github.com/arenzana/scoop-bucket
scoop install arca

RPM / DEB (RHEL/Rocky/Fedora, Debian/Ubuntu) — download from the latest release and:

sudo dnf install ./arca_<version>_linux_<arch>.rpm    # or: sudo dpkg -i arca_….deb

Packages are listed in checksums.txt, so the release's cosign bundle covers them (see SECURITY.md).

go install:

go install github.com/arenzana/arca@latest

Pre-built binaries for linux/macOS/windows (amd64 + arm64) are attached to each release, cosign-signed with SLSA provenance — see SECURITY.md to verify. Or build from source: git clone … && cd arca && make build.


Quickstart

arca init                                  # reuses $SOPS_AGE_KEY_FILE, or generates an identity
printf '%s' "$TOKEN" | arca set GITHUB_TOKEN --tag github,ci --desc "classic PAT"

arca ls --reads                            # metadata + last-read/count (no decryption)
arca get GITHUB_TOKEN                       # decrypt just this one; records a read

arca exec -- terraform apply                # inject secrets as env (value never hits stdout)
echo 'token = "arca://GITHUB_TOKEN"' | arca inject > config.toml   # resolve references

arca set DB_PASSWORD --no-print             # exec-only: get/env/inject refuse to reveal it
arca set ROOT_KEY --require-approval         # human must approve each release
arca set TMP_TOKEN --ttl 1h                  # ephemeral: refused everywhere after it expires

arca rotate GITHUB_TOKEN --rotate-after 2026-12-01
arca stale                                  # secrets past their rotate-after date, or expired
arca log GITHUB_TOKEN                        # who/what accessed it, and when

arca ls --json | jq '.[].name'              # structured output for agents/scripts

arca recipients add age1teammate...         # share with a teammate's key
arca reencrypt                              # re-wrap every secret to the new recipient set

Documentation

The README is the overview; the detail lives in docs/:

  • Commands — the full command reference.
  • Policies — the per-secret controls: --no-print, --require-approval, output redaction, canaries, JIT grants (--require-grant), rate limiting, and capability handles.
  • AI agents & MCP — the MCP server, its tools, and the agent-safety model.
  • Importing & migrating — pipe from sops, .env, JSON, AWS, Vault, 1Password, …
  • Configuration & storageARCA_* env vars, paths, and the store schema.
  • Syncing between machines — optional S3-compatible replication, the untrusted-backend model, automatic sync, audit escrow, and the multi-machine setup walkthrough.
  • Architecture · Threat model — how it's built and what it defends against.

Recipes

Use a secret in a command — never on stdout

arca exec -- terraform apply
arca exec --only AWS_ACCESS_KEY_ID,AWS_SECRET_ACCESS_KEY -- terraform apply   # least privilege

If the command itself prints an injected secret, arca redacts it from the output before it reaches whoever is reading — replacing the value with «arca:NAME» and recording the catch in the audit log. Redaction is automatic when output is captured (piped to an agent or a log) and steps aside for an interactive terminal; --redact on|off forces it, and --reveal shows a partial mask of long values instead of the name.

$ arca exec --only PASSWORD -- sh -c 'echo connecting with $PASSWORD'
connecting with «arca:PASSWORD»          # the value never reaches the agent's context

Plant a canary to catch an agent that grabs everything

arca canary AWS_PROD_KEY --template aws    # a realistic decoy; using it should never happen
# ... later, if anything reads it ...
#   ⚠  CANARY TRIPPED: "AWS_PROD_KEY" was accessed by malicious-agent (session …)
arca canary --list                         # which canaries exist, and which have been tripped

Grant a secret just-in-time, scoped to a command

arca set DEPLOY_KEY --require-grant                       # now unusable until granted
arca grant DEPLOY_KEY --command 'terraform *' --uses 3 --ttl 15m
arca exec --only DEPLOY_KEY -- terraform apply           # allowed (use 1 of 3)
arca exec --only DEPLOY_KEY -- sh -c 'curl …'            # denied: command doesn't match
arca grants                                              # secret, command, uses, expiry

Rate-limit a secret an agent might hammer

arca set SIGNING_KEY --rate 5/1h        # at most 5 uses per rolling hour
arca get SIGNING_KEY                     # ... the 6th within the hour is refused and recorded

Hand an agent a secret it can't name, read, or enumerate

# operator mints an opaque, command-scoped, expiring handle
arca handle create DB_PASSWORD --as PGPASSWORD --command 'psql *' --ttl 1h
#   hdl_3fd698a47ed0c05e21c41d30
# the agent, over MCP, runs a command via the handle — never seeing the name or value:
#   run_with_handle(handle="hdl_…", command="psql", args=["-c","select 1"])

Render a config from a template (the value only lands in the rendered file):

echo 'database_url = "arca://DATABASE_URL"' > app.toml.tmpl
echo 'api_key      = "arca://STRIPE_KEY"'  >> app.toml.tmpl
arca inject < app.toml.tmpl > app.toml

Generate, then rotate on a schedule:

arca generate DB_PASSWORD --length 40 --no-print      # random value, exec-only
arca rotate   DB_PASSWORD --rotate-after 2026-12-01    # new value + next rotation date
arca stale --within 30                                 # what's due (or expired) in 30 days

Short-lived tokens:

arca generate CI_DEPLOY_TOKEN --ttl 1h                 # refused on every path after an hour

Exec-only and human-approved secrets:

arca set PROD_DB_PASSWORD --no-print                   # get/env/inject refuse to print it
arca set ROOT_SIGNING_KEY --require-approval           # a human confirms each release on the TTY

Share with a teammate (add their age public key, re-wrap the store):

arca recipients add age1teammate...
arca reencrypt

Load into a shell or write a dotenv:

eval "$(arca env)"                                     # export every non-no-print secret
arca env --no-export > .env

Use from an AI agent (MCP):

claude mcp add arca -- arca mcp
# the agent calls run_with_secrets to USE a secret without seeing it, and read_secret
# (policy-gated, audited) only when the value must enter its context.

Audit — who touched what:

arca log                       # recent access across all secrets
arca log PROD_DB_PASSWORD      # one secret, with agent / session / actor
arca ls --reads                # last-read time + count per secret

Keep the store in a dotfiles repo (the JSON diffs cleanly; the audit DB stays local):

export ARCA_STORE=~/.dotfiles/arca/store.json

Script against JSON output:

arca ls --json | jq -r '.[] | select(.expired) | .name'    # expired secrets
arca log --json | jq '.[] | {op, name, agent, time}'

The model

arca is three pieces with deliberately different jobs:

 ┌──────────────────────────┐        ┌───────────────────────────┐
 │ store.json (git-synced)  │        │ audit.db  (local, SQLite) │
 │ cleartext metadata +     │        │ append-only access log    │
 │ per-value age ciphertext │        │ op, name, time, AGENT,    │
 │ (changes only on set/    │        │ version, session, actor   │
 │  rotate/rm)              │        │ (read tracking lives here)│
 └──────────────────────────┘        └───────────────────────────┘
             ▲  encrypt/decrypt with the age identity (≈ your sops key)
  • The store is the source of truth and is meant to be git-synced. Reads never touch it, so it only changes on real mutations — clean history, no churn.
  • The audit log is local and never synced. Keeping read-tracking here is what lets the store stay quiet and lets log/show --reads answer "who accessed this?".
  • Per-value encryption means get/inject/exec decrypt only the one secret asked for, and unchanged secrets keep byte-identical ciphertext (clean diffs).
Access shapes (and what each exposes)
Command Exposes the value to… Blocked for --no-print? Audited
get / env stdout (the caller/agent sees it) yes yes
inject stdout, but only inside a rendered template yes yes (per ref)
exec a subprocess's environment only — never stdout no (this is the sanctioned path) yes (per secret)

Rule of thumb for agents: let a command use a secret with exec, or a config reference one with inject; reach for get only when a human needs the raw value.

Fail-closed auditing

By default, if an access cannot be recorded, the operation is aborted — and for reads it aborts before the value is revealed, so a secret an agent accesses is never disclosed without a trail. A human at a controlling terminal may set ARCA_STRICT_AUDIT=0 to fall back to best-effort (swallow audit errors); a detected agent or a headless caller cannot.

Agent attribution

Each event is tagged with the calling AI agent, auto-detected from the environment:

  • Claude Code — name, session (CLAUDE_CODE_SESSION_ID), version (from the exec path)
  • Cursor — via CURSOR_TRACE_ID
  • GenericAI_AGENT=name_version_agent
  • plus an explicit ARCA_ACTOR label you can set yourself.

Security & supply chain

Built as security software: reproducible builds (CGO_ENABLED=0, -trimpath, pinned timestamps), cosign-signed checksums, a CycloneDX SBOM, and SLSA build-provenance attestations on every release. CI runs go vet, go test -race (~92% coverage, enforced), go mod verify, govulncheck, CodeQL, OpenSSF Scorecard, dependency review, and SHA-pinned actions under a hardened runner. See SECURITY.md for the disclosure policy and release-verification steps.


Stability

From v1.0, arca follows Semantic Versioning. STABILITY.md defines exactly which surfaces are covered — commands/flags, exit codes, the store schema, arca:// references, the ARCA_* configuration, --json output, and the MCP tools — and what may change (human-readable text, internal packages). Parse --json, not the table output.


License

MIT

Documentation

Overview

Safer agent defaults. The MCP server (mcp.go) historically exposed EVERY secret to a connected AI agent, with the per-secret guards (--no-print, --require-approval, --require-grant) all opt-in. This flips the model to deny-by-default under --strict: only secrets explicitly marked agent-exposed (`arca agent allow NAME`) are visible or usable to an agent.

Rollout is warn-then-flip: today --strict is opt-in and the default still exposes everything but prints a loud notice (and `doctor` flags it). A future major makes --strict the default.

Shell completion helpers. Cobra already provides the `completion` command that emits bash/zsh/fish/powershell scripts; these functions add *dynamic* completion so that, with that script sourced, `arca get <TAB>` offers the actual secret names and `arca ls --tag <TAB>` the tags in the store. Completion runs the binary, so it reads the live store on every TAB.

`arca doctor`: a read-only, secure-by-default health check. It turns the security tribal knowledge ("don't commit your identity key", "who can decrypt this?", "is a master key on too many hosts?", "is the MCP server wide open to agents?") into one command every user can run. Findings are ranked by severity, each with a one-line why and the exact remediation; `--json` for CI (non-zero exit on any HIGH), `--fix` applies only the unambiguously safe repairs.

Audit escrow (SEC-14, Option B): every sync replicates the local audit log off-machine as append-only, age-encrypted segments — audit/<machine-id>/<seq>.age.

The local SQLite log remains the operational, fail-closed witness; escrow adds an off-machine copy a local tamperer can't retract. Each segment carries full rows (chain hashes, signatures, store generations) plus the chain coordinates of its tail — which are exactly an anchor token, so `log --verify --remote` is CheckAnchor against a witness this machine cannot quietly rewrite. Escrow is best-effort by design: a failure warns and never breaks the sync (let alone an access).

Exposure / blast-radius visibility: `who-can-read` and `exposure` make "who can decrypt this, and how sensitive is it" something an operator can SEE, rather than discover during an incident.

Under today's single-store model every secret is encrypted to the same recipient set, so per-secret readership equals the store's recipients. The commands still frame it per-secret so the mental model generalizes if per-tier stores ever land.

JSON output for the read/inspect commands (ls, show, log, stale). Agents and scripts get a stable, parseable shape via --json; the human-facing tabwriter output stays the default.

Command arca is an age-encrypted secret store with cleartext metadata and a local audit log, designed to sit safely in front of AI agents.

The CLI is intentionally split into three "access shapes" with different trust levels:

  • get / env — reveal a value to stdout (blocked for --no-print secrets);
  • inject — resolve arca://NAME references in a template to stdout (also blocked for --no-print secrets);
  • exec — inject values into a subprocess's environment, so a command can *use* a secret while the value never appears on arca's stdout or in an agent's context. This is the sanctioned path for --no-print secrets.

Every access is written to the audit log with the calling AI agent's name/version/session (auto-detected) plus an explicit $ARCA_ACTOR, so `arca log` can answer who touched what.

MCP server: exposes arca to AI agents as audited, policy-respecting tools over stdio.

The design goal is "use without revealing": an agent runs commands with secrets injected (run_with_secrets) or inspects metadata (list/show) without the raw value ever entering the model's context. read_secret is the explicit, policy-gated, audited escape hatch for when a value genuinely must be returned. Every tool honours --no-print, --require-approval, and the fail-closed audit, just like the CLI.

NOTE: handlers must never write to stdout (that's the JSON-RPC channel) — they only return results. All output goes through the returned CallToolResult.

Control-plane anchoring (T11/T12). arca's strongest per-secret controls — --require-approval, --require-grant, --no-print, the kill switch, deny-by-default MCP exposure — are all enforced on the paths that *release* a value, and `approve()` anchors the strongest of them to an interactive confirmation on the controlling terminal (SEC-06).

The commands that *change those rules* had no such anchor. An agent refused by --require-grant could run `arca grant NAME --ttl 52w --uses 0` and retry; an agent hidden by `mcp --strict` could run `arca agent allow NAME`; and widest of all, `arca recipients add <own key>` + `arca reencrypt` re-wraps every value to a key the operator does not hold — permanently, on every machine the store reaches, without ever entering an access path, so no per-secret policy applies and no canary trips. `handle create` is the widest of the self-serve paths, because run_with_handle skips the whole gate() ladder rather than satisfying one rung of it; it already refused a *detected* agent, which is the advisory half, and now carries the terminal anchor as well.

So the disclosure path was anchored and the control plane was not, which meant every control on the disclosure path could be edited away by the party it governs. This file closes that.

Deliberately NOT anchored: the commands that only ever *restrict* — `agent deny`, `disable`, `recipients rm`, `handle revoke`. The rule is **anchor the loosening direction only; tightening must always work headless.** arca's existing convention is that the environment may refuse but never grant (ARCA_APPROVAL=deny is honored, =allow is not), a caller that can only tighten its own constraints is not an escalation path, and `recipients rm` is emergency revocation — putting a prompt on the incident-response path costs speed exactly when speed is what matters.

Multi-recipient / team support. A store encrypts every value to one or more age recipients (see Store.Recipients); `set`/`rotate`/`import` already wrap to all of them. These commands manage that recipient set and re-wrap existing secrets when it changes — e.g. to add or remove a teammate's key. Changing the set does NOT touch existing ciphertext until `reencrypt` runs, so the workflow is: recipients add/rm → reencrypt.

Per-store state directories (finding R5, design D4).

`stateDir()` is `$XDG_STATE_HOME/arca` for every store on the machine, so before this change `store.gen`, `sync.json`, `sync-state.json`, `grants.json`, `handles.json`, `canaries.json`, `escrow-state.json`, the audit DB and the session signing keys were shared by every store a machine had. Two stores — the documented personal/work split, which is one `ARCA_STORE` away — clobbered each other two ways: a `sync` run against store B reconciled B against store A's backend and replaced B's contents, and B's legitimately lower generation tripped the SEC-14 rollback warning against A's high-water mark. Both are reproduced by tests in this package.

Per-store files now live in `stateDir()/stores/<key>`, keyed to the store they belong to. `machine-id` deliberately stays flat in `stateDir()`: it identifies the MACHINE to escrow, not the store, and keying it per-store would silently fork this machine's escrow identity into one lineage per store.

arca sync — replicate the store through an untrusted network backend (S3-compatible), replacing "keep the store file in a git repo" as the only multi-machine story.

The pushed envelope is the whole store file wrapped in ONE MORE age layer to the same recipients, so the backend sees no secret names, tags, or policy — only bytes. The local file remains the source of truth for every read/exec; sync never sits in an access path. Concurrency is compare-and-swap: lost updates are impossible, conflicts are reported with both sides' generations and never auto-merged.

Directories

Path Synopsis
internal
atomicfile
Package atomicfile writes a file so that a concurrent reader never sees a partial document, the destination always lands at exactly the mode asked for, and a power loss immediately after a successful write cannot resurrect the old contents.
Package atomicfile writes a file so that a concurrent reader never sees a partial document, the destination always lands at exactly the mode asked for, and a power loss immediately after a successful write cannot resurrect the old contents.
audit
Package audit is arca's access log: an append-only history of every secret access, backed by a local SQLite database.
Package audit is arca's access log: an append-only history of every secret access, backed by a local SQLite database.
crypto
Package crypto is arca's thin encryption layer over filippo.io/age.
Package crypto is arca's thin encryption layer over filippo.io/age.
remote
Package remote replicates opaque store envelopes to an untrusted network backend.
Package remote replicates opaque store envelopes to an untrusted network backend.
store
Package store is arca's on-disk secret store: a single JSON document holding cleartext metadata plus per-value age ciphertext.
Package store is arca's on-disk secret store: a single JSON document holding cleartext metadata plus per-value age ciphertext.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL