Token-based authorization system for gating AI agent access to host applications.
agent-auth provides a local authorization layer between AI agents (e.g. Claude Code) and host applications (Things3, Outlook, etc.). It issues scoped, HMAC-signed tokens that control which operations an agent can perform, with support for:
- Fine-grained permission scopes modeled after GitHub PAT scopes (e.g.
things:read,outlook:mail:send) - Three-tier access control:
allow(immediate),prompt(requires JIT human approval),deny(blocked) - Just-in-time approval via a pluggable notification backend for sensitive operations
- Token families with paired access/refresh tokens and automatic reuse detection
- Field-level encryption (AES-256-GCM) for sensitive data in the SQLite token store
- CLI interface for token lifecycle management (create, list, modify, revoke, rotate)
- HTTP validation server for runtime token and scope checks
Each service in the monorepo ships as its own installable package
under packages/. Click through for the package's own
README — public surface, configuration, and the ADRs that motivate
its design.
| Package | Purpose |
|---|---|
agent-auth |
Token authorization service — HMAC-signed access/refresh pairs, three-tier scope model, SQLite store. |
things-bridge |
HTTP bridge from agent-auth-protected clients to the Things 3 to-do app. |
things-cli |
Read-only command-line client for things-bridge. |
things-client-cli-applescript |
macOS-only AppleScript-backed implementation of the Things-client contract; invoked by things-bridge. |
gpg-bridge |
Host-side HTTP bridge that brokers GPG sign/verify on behalf of devcontainer-resident callers. |
gpg-cli |
Devcontainer gpg.program replacement that forwards git's sign/verify argv to gpg-bridge. |
agent-auth-common |
Library-only workspace package: shared types, HTTP clients, Prometheus metrics helper. |
Each service in this repository ships as its own installable Python
package under packages/. There is no top-level meta
installer — install only the pieces you need via the per-service
install.sh scripts below. Each installer downloads the matching
release wheel(s), verifies sha256 against the published checksum,
installs into ~/.local/share/<service>/venv via stdlib venv, and
symlinks the entrypoint(s) into ~/.local/bin/.
Requires Python 3.11+ with the stdlib venv module on PATH (e.g.
brew install python@3.12 on macOS, apt install python3 python3-venv
on Ubuntu/Debian). uv is not required at install time. See
ADR 0044
for the per-package distribution model.
agent-auth— token server, token CLI, and out-of-process approval notifier:curl -fsSL https://raw.githubusercontent.com/aidanns/agent-auth/main/packages/agent-auth/install.sh | bashthings-bridge— HTTP bridge from agent-auth-protected clients to Things 3:curl -fsSL https://raw.githubusercontent.com/aidanns/agent-auth/main/packages/things-bridge/install.sh | bashthings-cli— read-only Things 3 command-line client (talks tothings-bridge):curl -fsSL https://raw.githubusercontent.com/aidanns/agent-auth/main/packages/things-cli/install.sh | bashthings-client-cli-applescript— macOS-only AppleScript-backed Things 3 CLI (invoked bythings-bridgeas a subprocess):curl -fsSL https://raw.githubusercontent.com/aidanns/agent-auth/main/packages/things-client-cli-applescript/install.sh | bashgpg-bridge— HTTP bridge delegating GPG signing to the host gpg binary for agent-auth-protected callers:curl -fsSL https://raw.githubusercontent.com/aidanns/agent-auth/main/packages/gpg-bridge/install.sh | bashgpg-cli— devcontainer-sidegpg.programreplacement that forwards git's sign / verify requests togpg-bridge:curl -fsSL https://raw.githubusercontent.com/aidanns/agent-auth/main/packages/gpg-cli/install.sh | bash
The agent-auth-common workspace
package ships shared types (Things models, HTTP clients, Prometheus
metrics helper); it has no CLI of its own and is pulled in
transitively by every service installer.
Requires:
- uv — Python package and environment manager. Install via
brew install uv(macOS) orcurl -LsSf https://astral.sh/uv/install.sh | sh(Linux). uv readsrequires-pythonfrompyproject.tomland installs a matching CPython automatically, so Python 3.11+ does not need to be pre-installed. - go-task — task runner (
brew install go-taskon macOS).
cd ~/Projects/agent-auth
task test # bootstraps .venv-$(uname -s)-$(uname -m) via `uv sync` and runs the suiteEvery repeatable operation is exposed through the task runner — run task --list to see the catalogue. Common commands:
task test # run the pytest suite
task build # build sdist and wheel into dist/
task verify-design # verify functional decomposition allocation
task verify-function-tests # verify functional decomposition test coverage
task verify-standards # verify the Taskfile matches the tooling standard
task agent-auth -- serve # run the agent-auth CLI (any subcommand)
task things-bridge -- serve # run the things-bridge CLI
task things-cli -- todos list # run the things-cli client
task things-client-applescript -- todos list # run the macOS-only things client CLIEvery tool invocation routes through scripts/_bootstrap_venv.sh, which creates the per-OS/arch virtualenv on first use and reinstalls in editable mode whenever pyproject.toml changes (hash-compared against a marker file inside the venv), so rerunning a task after a dependency or entry-point edit picks the change up automatically.
If you don't have go-task installed, every task dispatches to a script under scripts/*.sh that you can invoke directly (e.g. scripts/test.sh, scripts/agent-auth.sh serve).
For a bare install without the task runner:
export UV_PROJECT_ENVIRONMENT=".venv-$(uname -s)-$(uname -m)"
uv sync --extra dev
uv run agent-auth --help# Create a token pair with specific scopes
agent-auth token create --scope things:read=allow --scope things:write=prompt
# Create a token and output as JSON
agent-auth --json token create --scope things:read=allow
# List all token families
agent-auth token list
# Modify scopes on an existing family
agent-auth token modify <family-id> --add-scope outlook:mail:read=allow --set-tier things:write=deny
# Revoke a token family
agent-auth token revoke <family-id>
# Rotate a token family (revoke old, create new with same scopes)
agent-auth token rotate <family-id># Start the HTTP server (default: 127.0.0.1:9100)
agent-auth serve
# Start on a custom address
agent-auth serve --host 127.0.0.1 --port 8080# Validate a token against a required scope
curl -X POST http://127.0.0.1:9100/agent-auth/v1/validate \
-H "Content-Type: application/json" \
-d '{"token": "aa_<id>_<sig>", "required_scope": "things:read"}'
# Refresh a token pair
curl -X POST http://127.0.0.1:9100/agent-auth/v1/token/refresh \
-H "Content-Type: application/json" \
-d '{"refresh_token": "rt_<id>_<sig>"}'
# Check token status
curl -H "Authorization: Bearer aa_<id>_<sig>" \
http://127.0.0.1:9100/agent-auth/v1/token/statusThe complete wire contract for both servers is published as OpenAPI 3.1 alongside
the service that owns each surface:
packages/agent-auth/openapi/agent-auth.v1.yml
and
packages/things-bridge/openapi/things-bridge.v1.yml.
Versioning guarantees are documented under "API Versioning Policy" in
design/DESIGN.md.
things-bridge is an HTTP server that delegates token validation to agent-auth and exposes read-only Things 3 endpoints under /things-bridge/. It contains no Things 3 logic itself — every read-path request is translated into a subprocess invocation of a configured Things-client CLI (default things-client-cli-applescript, which shells to osascript on macOS). Run it alongside agent-auth serve:
# Start the bridge (default: 127.0.0.1:9200)
things-bridge serveHost, port, agent-auth URL, and things_client_command are configured in ~/.config/things-bridge/config.yaml.
Standalone read-only CLI that talks to Things 3 via osascript. Useful for local debugging of the Things side independent of the bridge and agent-auth. Emits JSON on stdout.
things-client-cli-applescript todos list --status open
things-client-cli-applescript projects show <project-id>The bridge is indifferent to which Things-client CLI is installed — it simply runs things_client_command. For Linux devcontainer development and CI, a test-only fake client (tests/things_client_fake/, invoked as python -m things_client_fake --fixtures PATH) reads an in-memory store from a YAML fixture so the full agent-auth + things-bridge + things-cli stack runs end-to-end without osascript or Things 3. Point the bridge at it via config.yaml:
things_client_command:
- python
- -m
- things_client_fake
- --fixtures
- tests/things_client_fake/fake-things.ymlThe fake CLI is not shipped in the sdist/wheel — it lives under tests/ and is only reachable when running from a development checkout. It exists for integration and end-to-end testing only; never point production traffic at it.
things-cli is a thin client for things-bridge that auto-refreshes/reissues tokens via agent-auth. Credentials are kept in the system keyring by default; when no keyring backend is available (e.g. inside a devcontainer), the CLI automatically falls back to a 0600 YAML file at ~/.config/things-cli/credentials.yaml.
# Save credentials — the CLI prompts interactively for tokens so they
# don't appear in shell history. Alternatively, pre-populate the
# credentials file at ~/.config/things-cli/credentials.yaml.
things-cli login \
--bridge-url http://127.0.0.1:9200 \
--auth-url http://127.0.0.1:9100 \
--family-id <family-id>
# Show redacted credential status
things-cli status
# Commands (add --json for structured output)
things-cli todos list
things-cli todos list --list TMTodayListSource --status open # Things built-in list id
things-cli todos show <todo-id>
things-cli projects list
things-cli projects show <project-id>
things-cli areas list
things-cli areas show <area-id>
# Discard stored credentials
things-cli logoutTests are split into two layers:
- Unit (fast, in-process) —
scripts/test.sh --unit(default). - Integration (Docker-backed) —
scripts/test.sh --integration. Each test spins up its ownagent-auth servecontainer via Docker Compose (one ephemeral Compose project per test), drives it over HTTP, and usesdocker compose execto invoke theagent-authCLI inside the container. Requires Docker + Docker Compose on the host. Tests skip automatically if Docker is not available. Pass a service name to run only that slice:scripts/test.sh --integration agent-auth(alsothings-bridge,things-cli,things-client-applescript). - Both —
scripts/test.sh --all.
See design/decisions/0004-docker-integration-tests.md for the design
rationale.
When running the integration tests inside a devcontainer, prefer rootless Docker-in-Docker over bind-mounting the host's Docker socket:
- A mounted host socket lets any process inside the devcontainer mount
arbitrary host paths (
~/.ssh,~/.aws, keyring-backed credentials), which short-circuits agent-auth's token/scope contract. Rootless DinD keeps the blast radius inside the devcontainer. - Socket-mount containers are siblings of the devcontainer on the host (volumes resolve against host paths, they outlive the devcontainer, they share networks with other host processes). DinD nests them so teardown is clean and volume paths behave as expected.
- The nested daemon's image cache and networks live inside the devcontainer, so integration state does not diverge based on what each developer has cached on their laptop.
- Rootless (not privileged root) keeps the daemon capped at the
devcontainer user's privileges;
--privilegedis not an acceptable default.
Trade-offs: overlay-on-overlay storage is slower than sharing the host
cache, rootless-in-rootless needs /dev/fuse and user-namespace
config, and images built inside DinD are not visible to the host
docker CLI.
The integration-test fixture chmods the bind-mounted config directory
to 0755 and config.yaml to 0644 so the container user (UID 1001,
see docker/Dockerfile.agent-auth.test) can read it regardless of the
host tmpdir's default mode or the host runner's UID.
CI runners, where there is no host developer state to protect, can use whatever Docker the runner provides.
- The server binds to
127.0.0.1by default (localhost only, not network-accessible) - Signing and encryption keys are stored in the system keyring (macOS Keychain or libsecret/gnome-keyring)
- Tokens are HMAC-SHA256 signed with the prefix included in the signature to prevent cross-type substitution
- Sensitive fields (scopes, HMAC signatures) are encrypted at rest with AES-256-GCM
- Refresh token reuse triggers automatic family-wide revocation
- Request body size is capped at 1 MiB
- Optional in-process TLS listener for devcontainer-to-host traffic — see "TLS for devcontainer-to-host traffic" below
Both servers bind plaintext HTTP on 127.0.0.1 by default — fine for a single-host deployment where loopback satisfies NIST SP 800-53 SC-8. When things-cli runs inside a devcontainer and reaches agent-auth / things-bridge on the host, the socket crosses a virtual network interface and plaintext traffic is visible to other bridge-networked containers. Configure TLS on both services to close that gap (see ADR 0025):
-
Generate a self-signed cert good for
localhostand127.0.0.1:openssl req -x509 -newkey rsa:2048 -keyout key.pem -out cert.pem \ -days 365 -nodes -subj "/CN=localhost" \ -addext "subjectAltName=DNS:localhost,IP:127.0.0.1" -
Point each service at its cert/key via
config.yaml:tls_cert_path: /path/to/cert.pem tls_key_path: /path/to/key.pem
-
For
things-bridge, also setauth_ca_cert_pathso the bridge trusts agent-auth's self-signed cert when validating tokens. -
Inside the devcontainer, point
things-cliat the HTTPS URLs and pass--ca-certso it trusts the self-signed CA:things-cli --ca-cert /path/to/cert.pem \ login --bridge-url https://host.docker.internal:9200 \ --auth-url https://host.docker.internal:9100 \ --access-token ... --refresh-token ... --family-id ...
Setting only one of tls_cert_path / tls_key_path is rejected at startup so the services cannot silently fall back to plaintext when TLS was intended.
Every GitHub release ships an SPDX SBOM and keyless Sigstore cosign signatures for each artifact and SBOM. See SECURITY.md § Supply-chain artifacts for the verification recipe.
See SECURITY.md for the full threat model, trust boundaries, key handling, revocation flow, audit surface, vulnerability reporting, and the chosen cybersecurity standard (NIST SP 800-53 Rev 5).
Contributions welcome — see CONTRIBUTING.md for dev setup, task runner usage, commit conventions, and the release process. Participation is governed by the project Code of Conduct. SUPPORT.md covers where to ask questions, file bugs, and report vulnerabilities.
Aidan Nagorcka-Smith aidanns@gmail.com