skeyd
A Zero-Trust secrets broker for AI agents and automation.
skeyd holds your credentials encrypted at rest and lends them to processes under an explicit policy — instead of letting programs read them back.
The distinction matters most for AI agents. An agent that can read a credential will, sooner or later, put it in a context window, a transcript, a log line, or a bug report. An agent that can only ask skeyd to run something with a credential never holds one in the first place.
$ skeyd run OPENAI_API_KEY -- python jobs/summarise.py
The child process gets OPENAI_API_KEY in its environment. The caller gets an exit code. Nothing in between ever sees the value — and if the child prints it by accident, skeyd scrubs it on the way out.
Contents
- Why
- Install
- Quick start
- How it works
- Writing policy
- Using it from an AI agent
- Command reference
- Configuration
- What skeyd does not protect against
- Development
Why
The usual way to give a program a credential is an environment variable, and the usual way to manage those is a .env file. That approach has four problems that get sharply worse when the program is an autonomous agent:
| Problem | What happens |
|---|---|
| Plaintext at rest | A backup, a synced folder, a container layer, or a support bundle exposes every key at once. |
| No authorization | Any code that can read the file can use any key for anything. |
| Ambient exposure | {**os.environ} hands a subprocess every other credential you hold. |
| No trail | After a leak, you cannot tell which key was used, by what, or when — so you rotate everything. |
skeyd addresses each directly: an AES-256-GCM encrypted store, a deny-by-default policy engine, an environment built from an allowlist upward, and a hash-chained audit log.
Install
$ pip install skeyd
Requires Python 3.10+. The only hard dependency is cryptography. Optional OS keyring support:
$ pip install 'skeyd[keyring]'
Quick start
1. Initialise. Creates an encrypted store, a key file, and a starter policy.
$ skeyd init
✓ Initialised encrypted store at ~/.local/share/skeyd/store.json
key source key-file (~/.config/skeyd/store.key) [new key file]
policy ~/.config/skeyd/policy.toml (created)
audit log ~/.local/state/skeyd/audit.jsonl
2. Add a credential. The prompt never echoes, and the value never reaches your shell history.
$ skeyd set OPENAI_API_KEY
Secret value (input hidden):
✓ Added value for OPENAI_API_KEY (id 7k2mqp4x, sk-p…4321 (51 chars, fp:9c1a7e2b))
warning: No policy rule matches 'OPENAI_API_KEY', so every attempt to use it
will be denied. Add a rule with: skeyd policy edit
That warning is the point: a fresh credential is unusable until you say what may use it.
3. Grant something. Open skeyd policy edit and add:
[[rules]]
id = "summariser"
description = "The nightly summarisation job may call OpenAI."
labels = ["OPENAI_API_KEY"]
allow_commands = ["python3"]
allow_argv_patterns = ['^jobs/summarise\.py$']
max_duration_seconds = 120
max_uses_per_hour = 30
4. Use it.
$ skeyd run OPENAI_API_KEY -- python3 jobs/summarise.py
5. Check what you are allowed to do, without running anything and without decrypting the store:
$ skeyd check OPENAI_API_KEY -- curl https://api.openai.com
DENY OPENAI_API_KEY → curl https://api.openai.com
· 1 allow rule(s) matched label 'OPENAI_API_KEY' but none permitted this request.
· rule 'summariser': command 'curl' is not in allow_commands (python3)
6. See what happened.
$ skeyd audit tail
2026-05-14 03:00:02 → access.grant OPENAI_API_KEY python3 agent:nightly
2026-05-14 03:00:09 · access.execute OPENAI_API_KEY python3 agent:nightly
7. Run a security self-check. skeyd doctor checks for common misconfigurations: key file sitting next to the store it unlocks, world-writable directories, SKEYD_PASSPHRASE in your environment, an open-by-default policy, and more.
$ skeyd doctor
✓ No issues found.
How it works
skeyd run LABEL -- command args
│
▼
┌──────────────┐ 1. resolve the label and value no plaintext read yet
│ store │ (AES-256-GCM, key from file/
└──────┬───────┘ passphrase/helper/keyring)
▼
┌──────────────┐ 2. decide denials stop here and
│ policy │ who · which label · what command never touch plaintext
└──────┬───────┘ · limits · exposure
▼
┌──────────────┐ 3. arm the redactor before anything can print
│ redaction │
└──────┬───────┘
▼
┌──────────────┐ 4. execute scrubbed env, resolved
│ execution │ secret in env or private file binary, own process group
└──────┬───────┘ never in argv
▼
┌──────────────┐ 5. record redacted, hash-chained
│ audit │
└──────────────┘
A few decisions worth knowing about:
The policy engine resolves the binary once. Authorising python3 and then executing whatever python3 happens to be first on PATH at exec time is a genuine time-of-check/time-of-use gap. skeyd resolves argv[0] to an absolute path during evaluation and executes exactly that.
The child's environment is built from an allowlist upward. It contains what policy permits, the injected credential, and nothing else — no AWS_SECRET_ACCESS_KEY you happened to have exported, and none of skeyd's own SKEYD_* configuration (a child that could read SKEYD_KEY_FILE could open the whole store).
Output is scrubbed in flight. Redaction covers the literal value plus the encodings it actually shows up in: base64 (all three phase alignments, so an embedded key in a larger blob is still caught), hex, percent-encoding, JSON escaping and shell quoting. It works across chunk boundaries, so a value split over two writes is still matched.
$ skeyd run OPENAI_API_KEY -- python3 -c "import os; print(os.environ['OPENAI_API_KEY'])"
«REDACTED»
Unknown credentials are caught too. If the child prints a GitHub token or an AWS key id that skeyd never issued, structural detectors redact that as well.
The audit log is tamper-evident. Each record carries the hash of its predecessor; skeyd audit verify walks the chain and reports the first break.
Writing policy
Policy lives in one TOML file and is deny-by-default. Full reference: docs/policy.md.
version = 1
[defaults]
deny_by_default = true # leave this on
max_duration_seconds = 300
redact_output = true
env_passthrough = ["PATH", "HOME", "LANG", "LC_*", "TZ"]
[[rules]]
id = "deploy-bot"
labels = ["DEPLOY_*"]
principals = ["agent:ci"]
allow_commands = ["/usr/bin/kubectl"] # ! absolute path: no PATH games
deny_argv_patterns = ['(?i)\bdelete\b']
working_directory = "~/infra"
max_uses_per_hour = 10
expires_at = "2027-01-01T00:00:00Z"
sandbox = ["firejail", "--net=none"] # compose with a real sandbox
[[rules]]
id = "never-prod-interactively"
action = "deny" # deny always wins, wherever it sits
labels = ["PROD_*"]
principals = ["local"]
Unknown keys are errors, not warnings. A policy that silently ignored allow_command (no s) would leave you believing a restriction was in force that was not:
$ skeyd policy check
error: Unknown key(s) in [[rules]] 'deploy-bot': 'allow_command' (did you mean 'allow_commands'?)
skeyd policy check --strict treats warnings as failures, which makes it a usable pre-commit hook or CI step.
Using it from an AI agent
skeyd exposes a tool manifest in whichever dialect your framework expects:
$ skeyd agent manifest --format anthropic # or: openai, mcp, native
Five tools: skeyd_list_secrets, skeyd_check_access, skeyd_run, skeyd_describe_policy, skeyd_suggest_label.
There is deliberately no get_secret tool. An agent can list credentials, reason about what policy permits, and run commands with a credential injected — but it cannot obtain one. That absence is the design.
$ skeyd agent call --name skeyd_run --input '{
"label": "OPENAI_API_KEY",
"command": ["python3", "jobs/summarise.py"],
"purpose": "nightly digest"
}'
{
"schema_version": 1,
"ok": true,
"command": "agent.skeyd_run",
"data": {
"agent_schema_version": 1,
"tool": "skeyd_run",
"exit_code": 0,
"result": { "allowed": true, "exit_code": 0, "duration_ms": 1840, "...": "..." }
}
}
In --json mode stdout carries exactly one JSON document; warnings and child output go to stderr. Denials are data, not errors — the agent gets error.hint and can adapt rather than retrying blindly.
See docs/agent-integration.md for worked examples with the Anthropic and OpenAI SDKs.
Command reference
| Command | Purpose |
|---|---|
skeyd init |
Create the store, key material and a starter policy |
skeyd status |
Configuration, key source, store health (no unlock needed) |
skeyd doctor |
Security self-check across the whole install |
skeyd set LABEL |
Store a credential (--stdin, --from-env, --generate) |
skeyd list / show LABEL |
Metadata only, never values |
skeyd rm / rename |
Remove or rename |
skeyd run LABEL -- CMD |
Run a command with the credential injected |
skeyd check LABEL -- CMD |
Would that be allowed? Runs nothing, decrypts nothing |
skeyd policy init|check|show|test|edit |
Manage and validate policy |
skeyd audit tail|verify|summary |
Inspect the tamper-evident log |
skeyd agent manifest|call |
Machine-readable interface |
skeyd rekey |
Re-encrypt under new key material |
skeyd migrate --from PATH |
Import a v0 plaintext store |
skeyd suggest CONTEXT |
Propose a label from a URL or product name |
Exit codes are a stable contract. For every command except run, success is 0 and failures live in the 64–79 range. run propagates the child's own exit code on success — a child that exits 42 makes skeyd run exit 42 — so skeyd's own failures (64–79) never collide with a child's non-zero exit.
0 success |
64 usage |
65 config |
66 not found |
67 policy denied |
68 locked |
69 integrity |
70 internal |
71 leak detected |
75 timeout |
A timeout in run returns 75, not the child's exit code.
Configuration
Locations follow the XDG spec and can be overridden by flag, by environment variable, or wholesale with SKEYD_HOME (handy for projects and containers).
| Variable | Purpose |
|---|---|
SKEYD_HOME |
Put everything under one directory |
SKEYD_STORE, SKEYD_POLICY, SKEYD_AUDIT_LOG, SKEYD_KEY_FILE |
Individual paths |
SKEYD_PASSPHRASE_COMMAND |
Helper command that prints the passphrase (pass, op, vault, …) |
SKEYD_PASSPHRASE |
Passphrase directly (discouraged — visible to other processes) |
SKEYD_PRINCIPAL |
Identity recorded in audit and matched by policy |
Key resolution order: explicit --key-file → SKEYD_PASSPHRASE_COMMAND → SKEYD_PASSPHRASE → keyring → default key file → interactive prompt. skeyd status tells you which one is actually in play.
For unattended operation, a key file is the practical default. Note the honest trade-off: a key file protects against store exfiltration — a stolen backup, a leaked image layer, a synced directory — but not against an attacker who already runs code as your user. A passphrase you keep in your head protects against both and cannot be used unattended. Choose deliberately; skeyd doctor will tell you if the key file is sitting next to the store it unlocks.
What skeyd does not protect against
Being clear about this is more useful than a longer feature list. Full analysis: docs/threat-model.md.
- A child process that chooses to exfiltrate. Once a command legitimately holds a credential, it can send it anywhere. Redaction catches accidents, not intent. Narrow your
allow_commands; compose with a real sandbox viasandbox. - An attacker executing code as your user. They can read your key file, your environment, and
/proc/<pid>/environof a running child. skeyd raises the cost and creates a record; it does not stop this. - Principals as authentication.
--principalis self-asserted. It separates "which of my agents did this" in policy and logs. It is not a credential. - Audit log truncation. The hash chain proves records were not edited. It cannot prove none were removed from the end.
skeyd audit verifyprints the head hash so you can pin it off-host. - Memory forensics. Python offers no guarantee that plaintext is gone from memory. skeyd narrows the window; it does not close it.
Development
$ git clone https://github.com/gebzerly/skeyd && cd skeyd
$ pip install -e '.[dev,keyring]'
$ make check # ruff + mypy --strict + pytest
$ make run-example # init a throwaway store and walk the quick start
The test suite covers the security properties adversarially — redaction across encodings and chunk boundaries, tamper detection, process-group reaping, and end-to-end assertions that no command surfaces plaintext. See CONTRIBUTING.md.
Architecture notes and the backend extension contract (Vault, AWS Secrets Manager, an HSM): docs/architecture.md.
Licence
Apache 2.0 — see LICENSE.
Security issues: please follow SECURITY.md rather than opening a public issue.
Release files for skeyd 0.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| skeyd-0.1.0.tar.gz | 151.8 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| skeyd-0.1.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 262.0 kB
Release files / skeyd-0.1.0.tar.gz
| Download URL | skeyd-0.1.0.tar.gz |
|---|---|
| Size | 151.8 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
4dc91796b73a1ffb30844683a08fe316a6221622559daf1c90ba5263dc190d74
|
|
BLAKE2b-256 checksum How to use checksums |
c6d81cba1a73f0c974c7debf1fed3c934d791c3a3ec92e7875602471b4094d9b
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 20, 2026.
Transparency logRelease files / skeyd-0.1.0-py3-none-any.whl
| Download URL | skeyd-0.1.0-py3-none-any.whl |
|---|---|
| Size | 110.2 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
09716cda666ba653ec2dff57f351fceab4c51cd0e4a7fe17ae3baa8c8d70e233
|
|
BLAKE2b-256 checksum How to use checksums |
7dc05a8f8b9e9bc0eb091c014d299867efed92e1b075e5de9108097519cea7ce
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 20, 2026.
Transparency log