Skip to main content

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

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-fileSKEYD_PASSPHRASE_COMMANDSKEYD_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 via sandbox.
  • An attacker executing code as your user. They can read your key file, your environment, and /proc/<pid>/environ of a running child. skeyd raises the cost and creates a record; it does not stop this.
  • Principals as authentication. --principal is 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 verify prints 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)

Source distribution for skeyd 0.1.0
File Size Uploaded
skeyd-0.1.0.tar.gz 151.8 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for skeyd 0.1.0
File Interpreter ABI Platform
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 log

Release 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

Release history Release notifications | RSS feed

This release

0.1.0 This release

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page