@permitio/agent-security-cli
v0.5.0
Published
Command-line tool to manage the Agent Security Gateway
Readme
@permitio/agent-security-cli
asg is the command-line tool for managing the Agent Security Gateway.
It wraps the gateway Admin API in human-friendly commands and is designed for
both interactive operators and AI agents using it as a tool.
Installation
# Use without installing (recommended)
bunx @permitio/agent-security-cli --help
# Or with npm
npx -y @permitio/agent-security-cli --help
# Install globally
bun install -g @permitio/agent-security-cli
asg --helpThe npx/npm forms are intentionally documented: external consumers of the
published npm package are often on Node-only environments (the monorepo's own
development stays Bun-only — the same exception scripts/pack-smoke.sh makes
to exercise the Node consumer path).
Quick start
Two audiences, two flows. See
docs/guides/asg-cli-quickstart.md for the
full walkthrough of either.
A human launching an agent session (self-serve, no admin token) — against a tenant an operator has already provisioned:
asg init --host acme-corp.example.com # once per machine
asg run --workflow ship_pr --client claude # or --client cursor, or -- <any command>Give init the tenant's full host, not just its label: that is what lets it
reach the tenant's own public endpoints, so no cluster access or port-forward is
needed. A bare label (acme-corp) still works wherever the gateway URL itself
resolves — in-cluster, or against local Tilt.
An operator provisioning a tenant (needs the admin token):
export AGENT_SECURITY_GATEWAY_URL=https://admin.agent.security:8002
asg login --token <admin-token>
asg host create --subdomain acme-corp --permit-key permit_key_abc
asg config set subdomain acme-corp # default for future commands
asg whoami # validate the token
asg host list # tabular output
asg host list --json | jq # machine-readable outputWhere credentials come from
Three different credentials show up above, and they have different owners:
- Admin token -- issued by the gateway operator (it is the gateway
deployment's
ADMIN_TOKENsecret). If you are a tenant developer, ask your platform administrator for it; the CLI never mints one. Stored viaasg login(orAGENT_SECURITY_API_KEY).asg init/asg runneed none of this -- see below. - Permit API key -- comes from the Permit.io dashboard (an environment-level API key for the project backing your tenant). Needed only when creating or updating a host via the CLI (see the operator note below).
- Session-launcher consent --
asg run's workflow-bound proxy token comes from a per-workflow browser PKCE consent, not from any of the above.asg initfetches only public, non-secret material (config + the tenant's intercept CA) over the gateway's unauthenticated/public/proxy/*endpoints -- a brand-new user goes from nothing to a governed session with no admin token and no Permit key.
Host creation is an operator action; customers do not need a Permit key.
asg host createrequires a--permit-keybecause the gateway Admin API stores a per-host Permit key. End customers normally onboard through the Platform UI, which resolves that key server-side from their Permit SSO session -- they never type or see it. Reach for the CLI host-creation command only as the gateway operator / platform admin; for the customer-facing onboarding path, use the Platform UI. Everything else in the CLI (proxy *,policy *, sessions, etc.) operates on an already-created host and needs no Permit key.asg init/asg run(the session launcher) need neither this nor the admin token.
Most commands talk to the gateway Admin API and therefore need the admin
token: host *, policy *, session *, preprovisioned-client *,
notification *, and every proxy * management command (config, credentials, token create, connect, preset, ca, container-config,
env, doctor, and the e14 identity status/config + guardian
enable/disable/status). Commands that need neither the admin token nor a
browser session: asg proxy authorize (browser consent via PKCE -- you
authenticate in the browser, not the CLI), plus the local/utility commands
(config, completion, docs). The session launcher (asg init, asg run) needs
neither the admin token nor a Permit key: asg init fetches only public gateway
config/CA material, and asg run authenticates via a per-workflow browser consent, same
as asg proxy authorize. One command needs a proxy token (not the
admin token): asg proxy register-session is agent-facing and reads
$AGENT_SECURITY_PROXY_TOKEN_FILE. asg pdp
validate needs only the Permit API key it probes with.
Configuration
asg resolves configuration from three sources, highest precedence first:
- Environment variables
AGENT_SECURITY_API_KEY(admin token)AGENT_SECURITY_GATEWAY_URLAGENT_SECURITY_SUBDOMAIN
~/.agent-security/credentials.json(mode 0600) and~/.agent-security/config.json- Built-in defaults (
http://localhost:8002for the gateway URL)
Two further environment variables configure the proxy data plane rather than the Admin API connection:
AGENT_SECURITY_CONSENT_URL-- the tenant consent origin (e.g.https://acme.agent.security) used byasg proxy authorize. It is one link in the resolution chain:--consent-urlflag → this env var → the gateway container-configconsent_url(derived automatically when you have an admin token) →asg config set consent-url <url>. Most end-users never set it.AGENT_SECURITY_PROXY_TOKEN_FILE-- the path agents read the proxy token from.asg proxy env <subdomain>exports it ($HOME/.agent-security/tokens/<subdomain>.token), the--outflag ofasg proxy token createwrites it, andasg proxy doctorchecks its permissions and expiry.
Inspect the resolved view with:
asg config listThe session launcher (asg init / asg run) uses a separate store, ~/.asg/ --
config.json (gateway/consent URL, default subdomain) and ca/{gateway-ca,bundle}.pem
(the intercept CA material asg init fetches). It is deliberately not the same directory
as the operator config above: the two commands hold different credentials for different
audiences. See the CLI quickstart for the full
layout.
Commands
| Command | What it does |
| ------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| asg init --host <tenant> | Session-launcher machine bootstrap (self-serve — no admin token). <tenant> is the bare subdomain, the tenant's full host (acme.example.com), or a pasted origin; supplying the base domain lets init ask the tenant's own origin and so needs no cluster access, while a bare label falls back to the gateway URL (right in-cluster and on Tilt). The bare label is always what persists as subdomain. Resolves the gateway/consent URLs and the tenant's public intercept CA over the unauthenticated /public/proxy/* endpoints, persists them to ~/.asg/config.json, and writes ~/.asg/ca/gateway-ca.pem + ~/.asg/ca/bundle.pem. After saving, runs an advisory preflight checklist — gateway / consent origin / forward-proxy / intercept-CA reachability — reporting each with an actionable next step; a consent-origin warning switches the exit code to 3 (config still saved), while an unreachable ClusterIP-only forward proxy is a skip, since asg run tunnels instead of dialling it. --json includes a preflight array of { name, status, detail, fix? }. Run once per machine, not per project. Distinct from asg host create below, which is an operator action and needs the admin token. |
| asg run --workflow <wf> [--client generic\|claude\|cursor] [--no-browser] -- <cmd> | Session launcher: get a workflow-bound proxy token via browser consent (omit --workflow for the consent screen's picker; there is no token cache — every launch is a distinct agent identity, so consent is per launch), build the proxy env, run a pre-handoff canary, then spawn <cmd> (or, for --client claude/--client cursor, the adapter's own launch of claude/cursor agent) as a child process with the env injected. --no-browser prints the consent URL instead of auto-opening it (SSH/headless). The injected env is scoped to that one child process tree — it never mutates the parent shell. See the CLI quickstart for the full self-serve walkthrough. |
| asg run --no-tunnel --workflow <wf> -- <cmd> | asg run reaches the proxy through the gateway's WebSocket tunnel by default; --no-tunnel opts out and connects to the proxy listener directly, which only works in-cluster or against local Tilt. The tunnel is on by default because outside the cluster: the proxy listener is ClusterIP-only and an ALB cannot carry CONNECT, so a direct proxy URL is unreachable. Runs a loopback broker for the lifetime of the launched command and tears it down on exit. Fails closed — if the tunnel drops, the agent's connection dies rather than silently egressing outside the gateway. |
| asg tunnel --gateway-url <origin> | Run that same broker as a long-lived shared service for containerised agents: ONE Deployment plus a Service, with agents setting HTTP_PROXY=http://asg-broker:8080. Not a sidecar per agent — authorization is the agent's own workflow-bound token, verified per request by the gateway, so identity is in the payload rather than the transport. Takes no --host: the tenant is read from the token's own tenant_subdomain claim. Reads its token from AGENT_SECURITY_PROXY_TOKEN_FILE (a mounted Secret) so it never lands in a process list or manifest. --bind defaults to 0.0.0.0; asg run's in-process broker stays on loopback. |
| asg login --token <token> | Store the admin token in ~/.agent-security/credentials.json (mode 0600) |
| asg whoami | Validate the configured admin token against the gateway |
| asg config set <key> <value> | Persist a configuration value (gateway-url, subdomain, consent-url) |
| asg config list | Show the resolved config and where each value came from |
| asg host create / list / get / update / delete | Manage gateway tenants |
| asg policy set <server-key> --tool <name> --require-approval | Toggle per-tool approvals |
| asg policy get <server-key> | List approval requirements for a server |
| asg policy bypass get/set <server-key> | Manage the agent bypass list |
| asg session get/delete/exists | MCP session operations |
| asg preprovisioned-client create/lookup/delete | Pre-provisioned OAuth client CRUD |
| asg pdp validate | Probe a Permit PDP for reachability |
| asg notification get/set | HITL notification configuration. set takes --email-recipients, --slack-webhook-url/--slack-webhook-url-file, --clear-slack; get redacts the stored webhook to scheme+host (https://hooks.slack.com/****) |
| asg proxy config [--enable\|--disable] | Show or toggle HTTP proxy mode for a host |
| asg proxy credentials list/create/update/delete | Manage proxy credentials (secrets are write-only: reads show has_secret/has_issuer booleans, never values) |
| asg proxy container-config --format <fmt> | Emit bootstrap config (docker-env / docker-compose / k8s-configmap / shell) |
| asg proxy env [--format docker\|k8s] | Print proxy env vars (eval $(asg proxy env <sub>)) for the shell / container |
| asg proxy doctor | Diagnose proxy setup (reachability, config, stored credentials, CA status/expiry, CA-trust env, proxy env vars, token-file perms + token expiry/audience) |
| asg proxy connect <provider> | Connect a provider via OAuth and store the resulting credential |
| asg proxy preset list/apply <name> | Browse the built-in preset catalog. (apply is temporarily unavailable and returns 501 — apply presets from the dashboard instead.) |
| asg proxy token create --client-id <id> --workflow <slug> | Mint a short-lived, workflow-bound proxy token (--workflow <slug> binds it to one API workflow and is required; --subject defaults to human-<client-id>; --out <file> writes it at mode 0600). --intent "<text>" records an operator-declared task description that the Guardian judges the agent's traffic against; it never touches the agent's identity/drift baseline and does not satisfy agent_identity_required — the agent must register-session itself. |
| asg proxy register-session --word … / --intent "<text>" | (e14, agent-facing) Declare this agent session's intent to the proxy (12-word fingerprint or free-text; exactly 12 words, all distinct, are required, e.g. --intent "reviewing the gateway proxy authorization and identity interrogation rust code for regressions"). Each --word is a single token, repeated exactly 12 times. POSTs through the proxy to the session.agent-security.internal sentinel; needs a proxy token ($AGENT_SECURITY_PROXY_TOKEN_FILE), not the admin token. A drift 409 (PER-15560) prints the gateway's re-approval URL for the agent to relay to its human (show it, never open it; re-register after approval), with a caution when the URL's origin differs from the configured consent origin. |
| asg proxy identity status/config [subdomain] | (e14) status shows an agent's proxy interrogation state (identification/drift counts, last drift score); config sets the per-host interrogation toggles + thresholds (--required / --token-ttl / --drift-warn / --drift-block). A drift-blocked status names the user recovery URL ({consent origin}/reconsent-agent?client_id=...) with the operator command as the override. |
| asg proxy identity reconsent [subdomain] --client-id <id> | (PER-15560) Operator override that clears a drift block. The primary recovery is user-driven: the blocked agent's 409 carries the tenant's /reconsent-agent URL, where the agent's user reviews the drifted intent and re-approves in the browser. |
| asg proxy guardian enable/disable/status [subdomain] | (e14) Toggle / inspect the per-host LLM intent Guardian (enable first requires that identity is required for the host). |
| asg proxy authorize <client_id> --workflow <slug> | Authorize an agent on your behalf via browser consent (PKCE), bound to one API workflow. --workflow is optional in the browser flow (pick it on the consent screen) and required with --device; the access level is chosen on the consent screen (least-privilege default, capped at your ceiling); consent URL auto-derived. --out <file> writes the token at mode 0600 |
| asg proxy ca status/download/rotate/trust-shell | Manage the tenant TLS-interception CA (status/download/rotate; trust-shell emits a shell snippet that points the common TLS trust env vars at the downloaded CA) |
| asg completion --shell zsh\|bash | Emit a shell completion script |
| asg docs | Open the documentation in your browser |
Egress policy is authored as API workflows (a set of allowed domains and rules) in the Platform UI under Proxy -> Workflows. There is no
asg proxy workflowcommand yet; the CLI mints workflow-bound tokens (token create --workflow/authorize --workflow) against workflows you create there.
Run asg <command> --help for command-specific options, and asg --help for
the top-level overview.
How the host subdomain is passed
The subdomain a command operates on is taken, in order, from: an explicit
value on the command line, then AGENT_SECURITY_SUBDOMAIN, then the
subdomain saved via asg config set subdomain <name>. How you pass it
explicitly depends on whether the command also takes another positional
argument:
- Positional
[subdomain]-- commands where the host fits as a (trailing) positional:host get/update/delete,notification get/set,proxy config,proxy container-config,proxy credentials *,proxy token create. Example:asg host get acme-corp,asg proxy credentials list acme-corp. --subdomainflag -- commands whose positionals are already taken (e.g.<server-key>, a variadic id list):policy set/get,policy bypass get/set,session *,preprovisioned-client *. Example:asg policy get github --subdomain acme-corp.
Because the configured default usually supplies the subdomain, you only hit
this distinction when overriding it per-call. When in doubt,
asg <command> --help shows whether the command expects a positional or the
--subdomain flag.
Global flags
Every command accepts these:
--json-- emit JSON to stdout (no colors, machine-readable)--quiet,-q-- suppress decorative output (see the--quietcontract)--verbose,-v-- debug logging on stderr--no-color-- disable ANSI colors (also honored viaNO_COLOR=1)--yes,-y-- skip confirmation prompts (CI / automation)--non-interactive(alias--no-input) -- never prompt; every value must come from a flag, env var, or--<secret>-file. Distinct from--yes:--yesauto-approves a confirmation,--non-interactiveforbids prompting at all. Combine them (--non-interactive --yes) to run a mutating command unattended. (--non-interactiveis also implied whenever stdin is not a TTY.)
Color output is also automatically disabled when stdout is not a TTY.
The --quiet contract
--quiet trims output to the essential payload. What "essential" means depends
on the output type, and is identical across every command:
| Output type | Default | With --quiet |
| --------------------------------- | ----------------------------------------- | ----------------------------------------------------- |
| --json | Pretty-printed (2-space indent) | Compact, single line — same object, just minified |
| Human messages / hints / spinners | Printed to stdout / stderr | Suppressed |
| Primary result (e.g. a token) | Printed (plus a human reminder on stderr) | Only the result on stdout, nothing else |
| Tables | Header row + aligned columns | Tab-separated rows, no header |
--json --quiet is the agent path: it emits the same JSON object as --json,
minified to one line, with no decoration on stdout. Parsing --json and
--json --quiet always yields an identical object — only the whitespace differs.
Reading secrets without exposing them on ps
Any value passed as a flag (--token, --permit-key, --client-secret,
--secret, --slack-webhook-url) is visible to other users on the host via
ps / /proc and lands in shell history. Every credential-bearing flag
therefore has a companion --<flag>-file <path> that reads the secret from a
file (or /dev/stdin for piped input):
asg login --token-file ./admin-token
asg host create --subdomain acme --permit-key-file ./permit.key
printf '%s' "$DD_API_KEY" | asg proxy credentials create datadog acme --host api.datadoghq.com --secret-file /dev/stdin --yes(asg login also reads a bare piped stdin directly: cat token | asg login.)
The direct flags still work for back-compat, but the file form is recommended
for CI and is the one shown in --help.
JSON output contract
Read commands emit a stable JSON shape under --json. The shapes are snapshot-pinned
in CI (src/json-shape.test.ts), so a renamed / dropped / retyped field fails the
build. Gateway-backed commands (host *, proxy config/credentials/container-config/ca,
session, notification, pdp, preprovisioned-client) pass the typed gateway
response through unchanged — their fields are additionally pinned by the generated
@workspace/gateway-api types via tsc. The CLI-constructed shapes are:
whoami --json→{ authenticated, gatewayUrl, token, sources: { adminToken, gatewayUrl, subdomain }, subdomain }—sourcesandsubdomainare present only whenauthenticated === true; the failure path (exit 1) emits just{ authenticated: false, gatewayUrl, token }proxy doctor --json→{ subdomain, checks: Check[] }, where everyCheckhas the uniform shape:status:"pass" | "warn" | "fail"kind:"user" | "server" | null— always present,nullunlessstatus === "fail"("user"→ exit 1,"server"→ exit 2)label: human-readable stringfix: a remediation command/hint, ornullwhen there's nothing to do — always present
proxy connect --json→{ state, credential_key, connected }config list --json→ the resolved config object (values + their sources)
Agents should branch on the documented fields and the exit code, never on human-formatted stdout.
Exit codes
0-- success (forasg init: all preflight checks clear)1-- user error (bad input, missing config, cancelled prompt, a denied or timed-out browser authorization inasg proxy authorize)2-- server error (gateway unreachable, 5xx, network failure)3--asg initpreflight degraded (init-specific): config was saved successfully, but one or more forward-looking reachability checks warned. This is NOT a failure -- the config and CA files are on disk andasg initcompleted. It's an advisory signal thatasg runmay hit one of the warnings until it's resolved (0 = all clear, 1 = fatal setup error such as the gateway/CA fetch itself failing). Onlyasg initever returns3; every other command uses{0, 1, 2}.An unreachable forward proxy does not trigger this. The address the gateway advertises is ClusterIP-only, so it is unreachable from every machine outside the cluster, and
asg runreaches the proxy through the tenant origin's tunnel by default rather than dialling it. Warning there would have made every correct bootstrap exit non-zero. It is reported as askipthat still names the address and the--no-tunnelfix.
This matches the agent-first CLI convention: agents can branch on exit code
to decide whether to retry, fix input, or surface the error to the user.
Transport failures (connection refused, DNS, timeout) surface as
{"code":"NETWORK_ERROR"} under --json and exit 2.
Proxy requests gated by a require_approval rule decode to distinct error
codes under --json instead of a lumped FORBIDDEN/SERVER_ERROR:
APPROVAL_DENIED (a reviewer rejected the request; exit 1),
APPROVAL_TIMEOUT (no reviewer responded before the gateway's timeout;
exit 2 -- re-running opens a fresh approval), and APPROVAL_UNAVAILABLE
(the approval could not be opened; nothing was sent; exit 1). The human
output renders the reviewer's note and the approvals-dashboard link when the
gateway provides them.
Warnings on stderr
The CLI emits one-line advisory warnings on stderr (never stdout, so --json
and eval $(...) pipelines stay clean) when it detects a risky or broken
configuration:
- the admin token is about to be sent over cleartext
http://to a non-loopback host (loopback targets like the defaulthttp://localhost:8002stay silent); ~/.agent-security/config.jsonexists but is not valid JSON — its overrides (gatewayUrl,subdomain,credentialsPath) are ignored until the file is fixed or deleted;asg proxy envreceived a gateway value that does not look like a URL / hostname list / env var name — the offending export line is skipped.
Tab completion
# zsh
asg completion --shell zsh >> ~/.zshrc
# bash
asg completion --shell bash | sudo tee /etc/bash_completion.d/asgReload the shell (or source ~/.zshrc) and asg <TAB> will suggest commands.
Completion covers the full command tree, including third-level subcommands
(asg proxy credentials <TAB>, asg proxy ca <TAB>, ...); a drift test derives the
tree from the live CLI definition so the scripts cannot silently fall behind.
Working with AI agents
The CLI doubles as an agent tool: --json --quiet produces deterministic,
low-token output; exit codes distinguish recoverable errors from server
problems; every operation that mutates state is gated by either --yes or
an explicit confirmation prompt. See
docs/plans/2026-05-14-agent-cli-support.md
for the design rationale.
Development
This package lives in the agent-security monorepo.
# From repo root
bun install
bun run --filter @permitio/agent-security-cli tsc
bun run --filter @permitio/agent-security-cli test
# Run the CLI directly during development
bun packages/agent-security-cli/src/bin/asg.ts host listReleasing
Publishing to npm is manual and deliberate — see the maintainer runbook: Releasing the asg CLI to npm. In short: run the Release CLI GitHub Actions workflow (dry run first, then dry_run: false) — either bump the version in a reviewed PR first and use version_bump: none, or pick patch|minor|major and let the workflow bump, publish, and sync the bump back to the branch through an auto-merged PR.
