Metadata-Version: 2.5
Name: skeyd-cli
Version: 0.1.0
Summary: A Zero-Trust secrets broker for AI agents and automation.
Project-URL: Homepage, https://github.com/gebzerly/skeyd
Project-URL: Documentation, https://github.com/gebzerly/skeyd/tree/main/docs
Project-URL: Repository, https://github.com/gebzerly/skeyd
Project-URL: Issues, https://github.com/gebzerly/skeyd/issues
Project-URL: Changelog, https://github.com/gebzerly/skeyd/blob/main/CHANGELOG.md
Project-URL: Security, https://github.com/gebzerly/skeyd/blob/main/SECURITY.md
Author: gebzerly
License-Expression: Apache-2.0
License-File: LICENSE
Keywords: ai-agents,credentials,llm,secrets,secrets-management,security,zero-trust
Classifier: Development Status :: 4 - Beta
Classifier: Environment :: Console
Classifier: Intended Audience :: Developers
Classifier: Intended Audience :: System Administrators
Classifier: Operating System :: MacOS :: MacOS X
Classifier: Operating System :: Microsoft :: Windows
Classifier: Operating System :: POSIX :: Linux
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Security
Classifier: Topic :: Security :: Cryptography
Classifier: Topic :: System :: Systems Administration
Classifier: Typing :: Typed
Requires-Python: >=3.10
Requires-Dist: cryptography>=42.0
Requires-Dist: tomli>=2.0; python_version < '3.11'
Provides-Extra: dev
Requires-Dist: build>=1.2; extra == 'dev'
Requires-Dist: mypy>=1.11; extra == 'dev'
Requires-Dist: pytest-cov>=5.0; extra == 'dev'
Requires-Dist: pytest>=8.0; extra == 'dev'
Requires-Dist: ruff>=0.6; extra == 'dev'
Requires-Dist: twine>=5.1; extra == 'dev'
Provides-Extra: keyring
Requires-Dist: keyring>=24.0; extra == 'keyring'
Description-Content-Type: text/markdown

# 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.

```console
$ 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](#why)
- [Install](#install)
- [Quick start](#quick-start)
- [How it works](#how-it-works)
- [Writing policy](#writing-policy)
- [Using it from an AI agent](#using-it-from-an-ai-agent)
- [Command reference](#command-reference)
- [Configuration](#configuration)
- [What skeyd does not protect against](#what-skeyd-does-not-protect-against)
- [Development](#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

```console
$ pip install skeyd-cli
```

Requires Python 3.10+. The only hard dependency is [`cryptography`](https://cryptography.io). Optional OS keyring support:

```console
$ pip install 'skeyd-cli[keyring]'
```

## Quick start

**1. Initialise.** Creates an encrypted store, a key file, and a starter policy.

```console
$ 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.

```console
$ 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:

```toml
[[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.**

```console
$ 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:

```console
$ 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.**

```console
$ 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.

```console
$ 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.

```console
$ 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`](docs/policy.md).

```toml
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:

```console
$ 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:

```console
$ 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.

```console
$ 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`](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`](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

```console
$ git clone https://github.com/gebzerly/skeyd && cd skeyd
$ pip install -e '.[dev]'
$ 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`](CONTRIBUTING.md).

Architecture notes and the backend extension contract (Vault, AWS Secrets Manager, an HSM): [`docs/architecture.md`](docs/architecture.md).

## Licence

Apache 2.0 — see [`LICENSE`](LICENSE).

Security issues: please follow [`SECURITY.md`](SECURITY.md) rather than opening a public issue.
