{"id":51830540,"url":"https://github.com/askalf/strongroom","last_synced_at":"2026-07-22T14:34:17.745Z","repository":{"id":365113483,"uuid":"1270605364","full_name":"askalf/strongroom","owner":"askalf","description":"Own your agent secrets — your AI agent holds a scoped, single-use lease, never the raw API key. strongroom injects the real secret only at egress, so a leaked prompt or poisoned tool can't leak a credential. Encrypted vault · tamper-evident audit · zero deps · MCP server included. Part of Own Your Agent Security (redstamp · truecopy · strongroom).","archived":false,"fork":false,"pushed_at":"2026-07-20T16:07:32.000Z","size":284,"stargazers_count":1,"open_issues_count":0,"forks_count":0,"subscribers_count":0,"default_branch":"master","last_synced_at":"2026-07-22T14:34:17.425Z","etag":null,"topics":["agent-security","ai-agents","ai-security","api-keys","credentials","least-privilege","llm","mcp","own-your-stack","secrets","secrets-management","security","vault"],"latest_commit_sha":null,"homepage":"https://sprayberrylabs.com/own-your-stack","language":"JavaScript","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/askalf.png","metadata":{"files":{"readme":"README.md","changelog":"CHANGELOG.md","contributing":"CONTRIBUTING.md","funding":null,"license":"LICENSE","code_of_conduct":"CODE_OF_CONDUCT.md","threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":"SECURITY.md","support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-06-15T22:01:39.000Z","updated_at":"2026-07-20T16:09:10.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/askalf/strongroom","commit_stats":null,"previous_names":["askalf/keeper"],"tags_count":5,"template":false,"template_full_name":null,"purl":"pkg:github/askalf/strongroom","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/askalf%2Fstrongroom","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/askalf%2Fstrongroom/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/askalf%2Fstrongroom/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/askalf%2Fstrongroom/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/askalf","download_url":"https://codeload.github.com/askalf/strongroom/tar.gz/refs/heads/master","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/askalf%2Fstrongroom/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":35766428,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-07-20T02:08:10.276Z","status":"online","status_checked_at":"2026-07-22T02:00:06.236Z","response_time":124,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["agent-security","ai-agents","ai-security","api-keys","credentials","least-privilege","llm","mcp","own-your-stack","secrets","secrets-management","security","vault"],"created_at":"2026-07-22T14:34:14.274Z","updated_at":"2026-07-22T14:34:17.735Z","avatar_url":"https://github.com/askalf.png","language":"JavaScript","funding_links":[],"categories":[],"sub_categories":[],"readme":"# strongroom\n\n[![ci](https://github.com/askalf/strongroom/actions/workflows/ci.yml/badge.svg)](https://github.com/askalf/strongroom/actions/workflows/ci.yml)\n[![CodeQL](https://github.com/askalf/strongroom/actions/workflows/codeql.yml/badge.svg)](https://github.com/askalf/strongroom/actions/workflows/codeql.yml)\n[![OpenSSF Scorecard](https://api.scorecard.dev/projects/github.com/askalf/strongroom/badge)](https://scorecard.dev/viewer/?uri=github.com/askalf/strongroom)\n[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)\n\n\u003e _strongroom — **own your agent secrets**. An encrypted vault that hands agents scoped, short-lived, single-use leases instead of raw keys. Part of **[Own Your Stack](https://github.com/askalf)** — own your AI infrastructure instead of renting it by the token._\n\n\u003e _**Formerly `keeper`.** Renamed to `strongroom` for the npm release; the GitHub repo redirects and the legacy `keeper` CLI alias keeps working. `KEEPER_*` env vars and the `~/.keeper` home directory are unchanged for compatibility._\n\nAgents need credentials — API keys, tokens, passwords — to do anything useful. Today they get them the worst possible way: a long-lived key stuffed into an environment variable or, worse, into the prompt. OpenClaw leaked the keys of ~135k exposed instances exactly this way. A key in the model's context is a key in every log, every trace, and every place a poisoned tool can read.\n\n**strongroom holds the keys so the agent doesn't.** The raw secret stays encrypted in the vault; the agent only ever holds a **lease** — a scoped, short-lived, use-limited handle — and the real key is revealed **only at the egress point**, only while the lease is valid:\n\n- **vault** — secrets encrypted at rest (AES-256-GCM, key in `~/.keeper`, `0600`). Never a plaintext env var, never in a prompt.\n- **lease** — `grant` mints an opaque handle bound to a **TTL**, a **use count**, and (optionally) a **destination host**. The agent's context holds the lease, not the secret.\n- **redeem** — exchange a lease for the secret at the point of use, *iff* it's still valid (not expired, uses remaining, host in scope). A denial is audited and never burns a use.\n- **audit** — every grant / redeem / deny / revoke is **hash-chained** (shared with [redstamp](https://github.com/askalf/redstamp)) — editing or deleting a past access breaks `strongroom audit --verify`.\n\nCompletes the agent-security stack: **redstamp** contains the call · **truecopy** vets the tool · **strongroom** holds the keys.\n\n## Quick start\n\n\u003e Install: `npm i -g @askalf/strongroom` (or run any command below with `npx -y @askalf/strongroom`). Also installable straight from GitHub: `npm i -g github:askalf/strongroom`.\n\n```bash\necho \"sk-live-…\" | strongroom add OPENAI_API_KEY          # stored encrypted\n\nLEASE=$(strongroom grant OPENAI_API_KEY --ttl 300 --uses 1 --host api.openai.com)\n# → the agent gets $LEASE — not the key\n\n# at the egress point, run the call with the key in the child's env only:\nstrongroom exec \"$LEASE\" --as OPENAI_API_KEY -- \\\n  curl https://api.openai.com/v1/models -H \"Authorization: Bearer $OPENAI_API_KEY\"\n\nstrongroom audit --verify                                 # tamper-evident access log\n```\n\nThe agent dispatched `strongroom exec \u003clease\u003e …`; the key was decrypted inside strongroom and handed to the subprocess's environment — it never entered the agent's context, stdout, or logs. Run the whole story: `npm run demo`.\n\n## Egress broker — the agent just swaps a base URL\n\nRun the broker and the agent needs no key, no `exec`, no redeem — only a base-URL swap:\n\n```bash\n# bind a lease to ONE upstream, how to inject, which endpoints, and a rate cap\nLEASE=$(strongroom grant OPENAI_API_KEY \\\n  --upstream https://api.openai.com --inject bearer \\\n  --paths \"/v1/chat/*,/v1/models\" --rate 60 --concurrency 4 --ttl 600 --uses 100)\nstrongroom broker --port 8771 \u0026\n```\n\nPoint the agent's client at the broker:\n\n```js\nconst openai = new OpenAI({ baseURL: `http://127.0.0.1:8771/${LEASE}`, apiKey: 'unused' });\nawait openai.chat.completions.create({ model: 'gpt-4o-mini', messages: [/* … */] });\n```\n\nFor each call the broker redeems the lease (atomic + audited), makes the **real** upstream request itself with the secret injected (`Authorization: Bearer …`), and streams the response back. The key is injected at the network boundary — it never enters the agent's context, env, or logs. And because the lease is **bound to one upstream**, the secret can only ever go to that host; the agent can't redirect it. `--inject`: `bearer` (default) · `x-api-key` (Anthropic) · `Header-Name` (custom).\n\n**Scope it down further:**\n- `--paths \"/v1/chat/*,/v1/models\"` — restrict the lease to specific endpoints (glob; a chat lease can't reach billing or admin).\n- `--rate 60` — cap it at 60 requests/min.\n- `--concurrency 4` — cap simultaneous in-flight requests (a runaway or hijacked agent can't hold N parallel streams open through one lease).\n\nAll three are enforced **before** the secret is redeemed — an out-of-scope, over-rate, or over-concurrency request gets `403` / `429`, consumes no use, and is audited.\n\n**And the upstream call itself is bounded.** The broker's upstream request times out after **30 s** to first response headers (`strongroom broker --timeout \u003cms\u003e`, or `KEEPER_BROKER_TIMEOUT_MS`). A black-hole upstream gets a `504` (audited as `deny`/`timeout`) instead of hanging the request forever — which also means a hung upstream can't pin a `--concurrency` slot and wedge the lease. The bound is to *headers*: a healthy streaming response is never cut off mid-stream.\n\n**And the response is sanitized on the way back.** If the upstream ever *reflects* the injected secret — an echo/debug endpoint, a verbose error, a misconfigured proxy — the broker redacts it from the relayed headers and body (`[strongroom:redacted]`) and records a `sanitize` audit event. The scan is streaming-safe: SSE passes through event-by-event, and a secret split across chunk boundaries is still caught. Without this, a reflecting upstream would hand the raw key straight back into the agent's context, defeating the injection boundary.\n\n\u003e **Windows / Git Bash:** MSYS auto-rewrites an argument that looks like a Unix absolute path, so a bare `--paths \"/v1/models\"` reaches strongroom as `C:/Program Files/Git/v1/models` and silently never matches (every call then `403`s on `path`). A comma-list like `\"/v1/chat/*,/v1/models\"` is left alone, which is why it works. Prefix the run with `MSYS_NO_PATHCONV=1` (use drive-letter paths for any file args), or call strongroom from PowerShell/cmd. Not a strongroom bug — it mangles the arg before strongroom sees it.\n\n## Redeem-daemon — no master key on the redeeming side\n\nThe broker covers HTTP APIs. For credentials a tool consumes *directly* — git over `GIT_ASKPASS`, a CLI that reads a token — the redeem happens in the agent's own process tree, and a local `strongroom redeem` would need the master key there. The **redeem-daemon** removes that requirement:\n\n```bash\nstrongroom serve \u0026                          # long-lived local process — HOLDS the master key\nKEEPER_DAEMON=1 strongroom redeem \"$LEASE\"  # this side holds NO key, NO passphrase\n```\n\nWith `KEEPER_DAEMON=1`, `strongroom redeem` / `strongroom exec` route lease→secret over a **local socket** (unix domain socket / Windows named pipe — token-gated, owner-only `0600`, never TCP) instead of opening the vault. Same-user callers need zero config — both sides share the default socket path, and the client reads the capability token from the daemon's `0600` info file; a **sandboxed worker** is instead handed only `KEEPER_SOCKET` + `KEEPER_DAEMON_TOKEN` (pin one via env before `serve`) and never reads strongroom's home at all. Either way the redeeming process never holds the master key: compromise it and you get its leases — scoped, expiring, revocable — not the vault. This is how a control plane hands git credentials to sandboxed workers: a `GIT_ASKPASS` helper that runs `strongroom redeem`, with zero token bytes on disk and zero key material in the worker.\n\n## Examples — real SDKs, zero keys in the agent\n\nThree end-to-end examples, each running a genuine client with a credential that never enters the agent's context:\n\n| Example | Shows |\n|---|---|\n| [`examples/anthropic-sdk-strongroom`](examples/anthropic-sdk-strongroom) | the **Anthropic SDK** (`@anthropic-ai/sdk`) making a real `messages.create` call through the broker — `x-api-key` injected at egress |\n| [`examples/openai-agents-strongroom`](examples/openai-agents-strongroom) | a real **OpenAI Agents SDK** agent run loop with its model calls brokered through a lease |\n| [`examples/mcp-strongroom`](examples/mcp-strongroom) | an **MCP server** whose tools return *leases, not keys* — the \"where does the key live?\" answer for every credentialed MCP server |\n\n## Why a lease, not the key\n\n| | a raw key in env / prompt | a strongroom lease |\n|---|---|---|\n| in the model's context | **yes** — leaks to logs, traces, poisoned tools | no — only an opaque handle |\n| lifetime | until you rotate it | seconds (TTL) |\n| blast radius | every call, every host | one use, one host |\n| revocable | rotate everywhere | `strongroom revoke \u003clease\u003e` |\n| audited | no | every access, tamper-evident |\n\n## Dispatching to a fleet\n\nA platform that runs agents on remote devices shouldn't ship a long-lived key to each one — that's how OpenClaw leaked ~135k of them. Ship a **lease** instead:\n\n- the **control plane** stores the secret in strongroom and grants a scoped, short-lived lease per task (`--upstream`, `--paths`, `--rate`, `--concurrency`, `--ttl`, `--uses`);\n- the **device** receives only the lease id and runs through `strongroom broker` — the key is injected at egress, never written to the device;\n- a compromised device yields a *lease* (scoped, expiring, revocable), not a key. `strongroom revoke \u003clease\u003e` kills it instantly — no production-key rotation.\n\n**The control plane never scrapes human output.** `grant`, `leases`, `ls`, and `audit` take `--json` and put exactly **one JSON value on stdout** — no ANSI, no prose, no stderr summary:\n\n```bash\nstrongroom grant TASK_API_KEY --ttl 300 --uses 50 --upstream https://api.example.com --json\n# → {\"id\":\"lease_…\",\"secret\":\"TASK_API_KEY\",\"usesLeft\":50,\"expiresAt\":1720000000000,\"ttlS\":300,\n#    \"host\":null,\"upstream\":\"https://api.example.com\",\"inject\":null,\"rate\":null,\"paths\":null,\"concurrency\":null}\nstrongroom leases --json          # → array of secret-safe lease records (fingerprints, never raw ids)\nstrongroom ls --json              # → [\"TASK_API_KEY\", …]\nstrongroom audit --json           # → the parsed event array\nstrongroom audit --verify --json  # → {\"ok\":true,\"entries\":n} | {\"ok\":false,\"reason\":\"audit-tip-forged\"} — exit code 0/1 preserved\n```\n\n`grant --json` returns the same one-time id + metadata the human path already returns — just machine-readable. Without `--json`, output is unchanged.\n\nSee it end to end: `npm run demo:platform`.\n\n## Delegating a lease — least privilege between agents\n\nIn a multi-agent tree, a parent agent can hand a **sub-agent** a *narrower* slice of its own access without ever touching the vault. `grant --from-lease` **attenuates** a lease the parent holds into a sub-lease whose every scope is `≤` the parent's — shorter TTL, fewer uses, tighter host/upstream/paths/rate/concurrency. **Never wider.**\n\n```bash\n# Parent holds a broad lease: 1h, 100 uses, all of /v1/*\nPARENT=$(strongroom grant OPENAI_API_KEY --upstream https://api.openai.com \\\n  --paths \"/v1/*\" --ttl 3600 --uses 100)\n\n# Delegate a tight sub-lease to a summarizer sub-agent: 5 min, 3 uses, chat only\nCHILD=$(strongroom grant --from-lease \"$PARENT\" \\\n  --paths \"/v1/chat/completions\" --ttl 300 --uses 3)\n```\n\n- **Attenuate-only.** A sub-lease may **narrow** any axis or **inherit** it (unset = inherit the parent's), but never widen: a longer TTL, more uses, a broader `--paths` glob, a different `--host`/`--upstream`, or a higher `--rate`/`--concurrency` is **rejected** with an error naming the axis. `--paths` must be a **subset** of the parent's (checked with the same segment-glob semantics the broker enforces). A parent axis left *unlimited* may be *capped* by the child.\n- **Recorded provenance.** The child lease carries the **parent lease fingerprint**, and the child's `grant` audit event carries it as `from` — so a delegation shows up in the hash-chained, authenticated-tip audit as a parent→child link that still verifies. `strongroom leases` and `strongroom audit` render it as `⤷ from \u003cfp\u003e`.\n- **Per-capability guarantee.** The child is an independent lease bounded by `child scope ⊆ parent scope`, so a sub-agent can never redeem toward anything its parent couldn't — and delegating does **not** spend a parent use.\n\n## Security model\n\nstrongroom is a vault, so its own security is the point:\n\n- **Encrypted at rest** — AES-256-GCM, with the secret *name* bound in as AAD, so a ciphertext can't be swapped between names.\n- **Master key** — three options, in priority order:\n  - `KEEPER_PASSPHRASE` — derived with **scrypt**; never on disk (only a salt is).\n  - `KEEPER_KEYCHAIN=1` — held by the **OS keychain**: macOS Keychain · Linux Secret Service · Windows DPAPI (user scope). Never plaintext on disk, and it **fails closed** if no keychain is available (no silent downgrade). `strongroom keychain` shows the active backend.\n  - else — a random key file in `~/.keeper` (`0600` + a restrictive ACL on Windows).\n\n  Use the passphrase or the keychain for anything that matters.\n- **Rotation is built in** — `strongroom rekey` re-encrypts every secret under a fresh master key, optionally switching key stores (`--to passphrase|keychain|file`; a passphrase target reads `KEEPER_NEW_PASSPHRASE`). It's atomic and fail-closed: a wrong current passphrase aborts with nothing changed, an interrupted swap is completed or discarded safely on the next run, retired key material (old salt / key file / keychain entry) is removed, and the audit's authenticated tip is re-MACed under the new key. Restart a running daemon/broker afterwards — they hold the old key and fail closed.\n- **Operator ceiling on grants** — set `KEEPER_MAX_TTL` (seconds) and/or `KEEPER_MAX_USES` and no lease minted from this vault — CLI **or** library — may exceed them. An over-cap grant is **rejected** with an error naming the cap (never silently clamped) and audited as a `deny`/`policy` event, so \"leases stay small\" is vault policy, not caller discipline. Unset = no ceiling (unchanged behavior). Zero, negative, or non-numeric `--ttl`/`--uses` are always rejected — a NaN would otherwise mint a lease that never expires.\n- **Leases are bearer tokens** — only `sha256(id)` is stored; the raw id is returned once, to you. Reading `leases.json` therefore can't redeem anything.\n- **Single-use is atomic** — redeem is a check-and-consume under a cross-process lock, so concurrent redeems can't double-spend a one-use lease.\n- **Fail-closed** — a tampered, swapped, or wrong-key entry returns null and denies; it never throws or leaks garbage.\n- **Tamper-evident audit** — every access is hash-chained (shared with redstamp) and logged by lease *fingerprint*, never the raw id. An **authenticated tip** (HMAC under a subkey of the master key) commits to the chain's length and last hash, so *truncating* or *splicing* the log is caught — not just editing an entry.\n- **Reflected secrets can't ride back in** — the broker redacts any occurrence of the injected secret from relayed response headers and bodies and audits it (`sanitize`), so an echoing or misconfigured upstream can't hand the raw key back into the agent's context.\n\nWhat it is **not**: a defense against an attacker who already has your passphrase / master key or full process memory — at that point they have the vault. strongroom shrinks the *agent's* exposure (a lease, not the key; short-lived; scoped; audited); it doesn't replace OS-level isolation.\n\n## Commands\n\n```\nstrongroom add \u003cname\u003e                  store a secret (stdin, or --value=)\nstrongroom ls [--json]                 list secret names (never values)\nstrongroom grant \u003cname\u003e [--ttl --uses --host]                        mint a lease\n              [--upstream --inject --paths --rate --concurrency]  (broker scoping)\n              (KEEPER_MAX_TTL / KEEPER_MAX_USES, if set, cap every grant — over-cap is rejected + audited)\n              [--json]                 one machine-readable JSON object on stdout\nstrongroom grant --from-lease \u003clease\u003e [tighter opts]  DELEGATE: attenuate a lease you hold into a\n              narrower sub-lease for a sub-agent (shorter --ttl, fewer --uses, tighter\n              --host/--upstream/--paths/--rate/--concurrency; NEVER wider; unset scopes inherit)\nstrongroom redeem \u003clease\u003e [--host]     exchange a valid lease for the secret (egress side)\nstrongroom exec \u003clease\u003e --as \u003cENV\u003e -- \u003ccmd...\u003e  redeem + run \u003ccmd\u003e with the secret in its env only\nstrongroom broker [--port 8771]        egress-injection proxy (base-URL swap, zero key in the agent)\nstrongroom serve [--socket \u003cpath\u003e]     redeem-daemon: holds the master key, answers lease→secret\n                                   over a local socket (KEEPER_DAEMON=1 on the keyless side)\nstrongroom leases [--json] · strongroom revoke \u003clease\u003e · strongroom rm \u003cname\u003e\nstrongroom audit [--verify] [--json]   the access log, optionally chain-verified\nstrongroom rekey [--to passphrase|keychain|file]   rotate the master key (re-encrypts the vault)\nstrongroom keychain                    master-key backend status (KEEPER_KEYCHAIN=1 to use the OS keychain)\n```\n\n## Library\n\n```js\nimport { addSecret, grant, redeem } from '@askalf/strongroom';\n\naddSecret('STRIPE_KEY', process.env.STRIPE_KEY);\nconst lease = grant('STRIPE_KEY', { ttlS: 60, uses: 1, host: 'api.stripe.com' });\n// hand `lease.id` to the agent; at egress:\nconst { ok, value } = redeem(lease.id, { host: 'api.stripe.com' });\n\n// Delegate a narrower sub-lease to a sub-agent (attenuate-only; child ⊆ parent):\nimport { grantFromLease } from '@askalf/strongroom';\nconst sub = grantFromLease(lease.id, { ttlS: 30, uses: 1 }); // sub.parent = parent fingerprint\n```\n\n## The agent-security stack\n\nThree composable layers, one defense: **[redstamp](https://github.com/askalf/redstamp)** contains the call · **[truecopy](https://github.com/askalf/truecopy)** vets the tool · **[strongroom](https://github.com/askalf/strongroom)** holds the keys *(you are here)*. Run all three together → **[agent-security-stack](https://github.com/askalf/agent-security-stack)**.\n\n---\nPart of **[Own Your Stack](https://github.com/askalf)** — own your AI infrastructure instead of renting it. Built by Thomas Sprayberry.\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Faskalf%2Fstrongroom","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Faskalf%2Fstrongroom","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Faskalf%2Fstrongroom/lists"}