Terminal (your own CLI)
permdock/terminal puts permission checks inside command-line tools you build with commander, citty, oclif, yargs, clack or Ink, with verified subjects from device flow, keychain, env or CI tokens, sysexits exit codes and Problem Details on --json.
Not to be confused with the permdock binary. That is PermDock's own developer tool (permdock collect, permdock doctor); this page is about the library entry you import when the command-line tool is yours and its commands need to be authorised.
permdock/terminal (this page) | The permdock binary | |
|---|---|---|
| What it is | A subpath of the permdock package, imported by the CLI you ship to your users | The bin of the same permdock package, run by you and your CI |
| What it does | Resolves a verified subject for the process, guards command actions, filters help output, formats denials, sets exit codes | Scans source, writes catalogs, checks OpenAPI and RLS drift; never evaluates a policy against a real subject (CLI) |
Purpose
A CLI is a client like any other: the user behind it holds grants, the binary may be driven by a person or by an AI agent's shell tool, and a refused command must explain itself to both. permdock/terminal applies the same subject model, three-outcome decisions and errors as the HTTP and agent adapters to a process instead of a request. The differences are in how the subject arrives (a token stored on disk or minted by CI, not a cookie), how a denial is shown (stderr and an exit code, not a 403) and how an approval is asked for (a prompt on the TTY, or a refusal when there is none). The adapter never trusts an unverified --user or --actor flag; every identity comes from a token verified with permdock/jwt or a provider subjectFrom* helper.
Install
pnpm add permdockOptional peers: @napi-rs/keyring for the OS keychain used by the keychain source (pass its Entry class as storage.keyring), and your prompt library (@clack/prompts, Ink) if you replace the default node:readline confirm.
API
import { Entry } from "@napi-rs/keyring";
import { createPermDock } from "permdock/terminal";
import { subjectFromJwt } from "permdock/jwt";
import { policy } from "./policy";
import { permissions } from "./permissions";
const issuer = "https://auth.acme.dev";
export const { permdock, protect, filterCommands, format, exitCode } =
createPermDock(policy, {
subject: async ({ token }) => {
const jwt = await token(["env", "keychain", "ci-oidc", "device"]); // first source that yields a token wins
return jwt ? subjectFromJwt(jwt, { issuer, audience: "acme-cli" }) : null; // null = anonymous
},
actor: async ({ token }) => {
const jwt = await token([{ env: "PERMDOCK_ACTOR_TOKEN" }]); // agent-run mode; see below
return jwt
? subjectFromJwt(jwt, { issuer, audience: "acme-cli-agent" })
: undefined;
},
storage: { service: "acme-cli", keyring: Entry }, // Entry from @napi-rs/keyring; file fallback in the config dir
interactive: process.stdout.isTTY && !process.env.CI, // default shown; controls approval prompts
approval: {
at: "https://console.acme.dev/approvals",
hint: "Ask a release manager to approve, then re-run.",
},
output: { json: process.argv.includes("--json") }, // default shown; selects Problem Details output
yes: process.argv.includes("--yes") || process.argv.includes("-y"), // default shown; skips the typed confirmation of a destructive action
dryRun: process.argv.includes("--dry-run"), // default shown; decides and prints, never runs the action
});| Export | Role |
|---|---|
permdock() | Resolves the subject once and returns a process-scoped PermDock. Memoised for the process lifetime; permdock({ refresh: true }) re-resolves after login, logout or --as <profile> switches the stored profile. Anonymous callers get an anonymous instance, not an error. |
protect(permission, load?) | Returns a wrapper for a command action. load(...args) receives the framework's action arguments and returns the resource instance; the wrapped action runs only on granted (or after an approval) and receives { permdock, data, decision } as its first argument, ahead of whatever the framework passes (commander's positionals, options and command, or citty's single context). |
filterCommands(entries, options) | Removes or annotates entries the subject cannot run before they are registered with the framework, so help output matches authority. |
format(decision, options) | Renders a denied or approval-required decision as text for stderr or, with json: true, as the RFC 9457 Problem Details object the HTTP adapters emit. |
exitCode(decision) | Maps an outcome to a sysexits.h code (table below). |
subject and actor receive a token(sources) helper that walks the listed sources in order and returns the first raw token found, or null. Verification is your call to subjectFromJwt or a provider helper; the adapter refuses to build a principal from anything that has not been verified. delegation is lifted from the verified actor token (scope, RFC 9396 authorization_details) so a decision is the principal's grants intersected with what the agent was delegated (delegation).
Subject sources
| Source | How the token is obtained | Typical use |
|---|---|---|
device | OAuth 2.0 device authorization grant (RFC 8628). The CLI prints user_code and verification_uri (and opens verification_uri_complete when a browser is available), polls the token endpoint honouring interval and slow_down, then stores the result through storage. | Interactive login on a developer machine |
keychain | Reads the token stored by a previous device login from the OS keychain through storage.keyring; falls back to a mode-0600 file when no keychain is available (headless Linux, containers). Refreshes with the refresh token when expired. | Every later invocation |
env | PERMDOCK_TOKEN (or a configured name) holding a JWT or an API key that your subject resolver exchanges for a principal. | Scripts, non-interactive shells |
ci-oidc | The job's OIDC token: GitHub Actions through ACTIONS_ID_TOKEN_REQUEST_URL ({ source: 'ci-oidc', audience } requests that aud), a GitLab id_tokens variable named with { source: 'ci-oidc', env }, or CI_JOB_JWT_V2. Verify it with subjectFromCiOidc from permdock/jwt, which returns a workload principal (kind: 'workload', sub as the id, with repository, ref and environment), or exchange it with RFC 8693 token exchange at your authorization server. The CLI never sees a long-lived secret. | Pipelines |
| anonymous | token() returned null and subject returned null. | Public read-only commands |
Each request to the device and token endpoints, and the GitHub Actions OIDC request, is aborted after 10 seconds and then yields no token.
All sources end in the same verification step. A --user flag, a USER environment variable or a git config email are never accepted as identity; they may at most select which stored profile to load (--as), and the profile's token is still verified.
Process lifecycle
- The CLI parses arguments; commands are registered through
filterCommands, so--helpalready reflects the subject. - The first call to
permdock()resolves the subject (and actor) once. Adevicesource may block here for the login round trip. protectrunsloadfor instance actions, validates the result at the boundary whenloadis marked untrusted (JSON from stdin or a file), then decides. With--dry-runit prints the decision and exits with its code, running nothing.grantedruns your action, after a typed confirmation when the permission isdestructive;deniedwritesformat(decision)to stderr and exits77;approval-requiredprompts when the grant lets the requester approve (approval: { distinct: false }, no actor) andinteractiveis true; otherwise it records the request instore, writes the Problem Detailsapprovalextension and exits75, or exits77when nostoreis configured.- Every decision, including the human's answer to a prompt, is emitted through
on('decision')with the command path so audit and otel see it.
Exit codes
Codes follow the BSD sysexits.h conventions so shell scripts and agents can branch without parsing output.
| Code | Name | When |
|---|---|---|
0 | EX_OK | granted and the action completed, or granted under --dry-run |
64 | EX_USAGE | A destructive permission in a process with no terminal and no --yes |
75 | EX_TEMPFAIL | approval-required with the request recorded in store, or a self-approvable request in a non-interactive process: the command can succeed once someone approves |
77 | EX_NOPERM | denied, including anonymous subjects, a declined approval prompt, an approval someone else must give with no store configured, a typed confirmation that did not match, and a load that throws or returns nothing for an instance permission |
78 | EX_CONFIG | The adapter is misconfigured: no verifier, unreachable authorization server metadata, async schema at a boundary |
Failures inside your action keep whatever code your framework assigns; the adapter only sets codes for outcomes it produced. This is a different contract from the 0 / 1 / 2 codes of the permdock binary, which reports findings, not permissions.
Filtering help output
filterCommands is the terminal counterpart of the MCP adapter's list_tools filter (MCP): what the subject cannot run is hidden or marked before the framework ever sees it.
const instance = await permdock();
const visible = filterCommands(
[
{
name: "status",
permission: permissions.deploy.read,
description: "Show the current deployment",
},
{
name: "deploy",
permission: permissions.deploy.run,
description: "Deploy a service",
},
{
name: "rollback",
permission: permissions.deploy.rollback,
description: "Roll back to the previous release",
},
],
{ mode: "annotate" },
); // or 'hide'
for (const entry of visible)
program.command(entry.name).description(entry.description);mode: 'hide'returns only entries whose collection-level check passes (can(permission)for collection actions; for instance actions, whether any grant exists for the permission). Unknown commands then fail with the framework's usual "unknown command" error, revealing nothing.mode: 'annotate'keeps every entry and appends(requires deploy:run)to the description of entries the subject lacks, using the permission'sscope. A reader seesrollback Roll back to the previous release (requires deploy:rollback)and knows what to ask for. Hiding suits tools driven by agents; annotating suits people who can request access.
Formatting denials
process.stderr.write(format(decision, { json: false }));deploy.run denied for subject u_1: developer (condition). Alternatives: deploy.read, deploy.status.
reason the service is in the "production" environment and you hold deploy.run for "staging" only
you may acme status api, acme deploy api --env staging
to request acme request-access deploy:run --service apiWith --json (or output.json), format returns the same object toProblemDetails() produces for the HTTP adapters, so an agent that already parses application/problem+json from your API parses the CLI without a second code path:
{
"type": "https://permdock.com/problems/denied",
"title": "Permission denied",
"status": 403,
"detail": "deploy.run denied for subject u_1: developer (condition). Alternatives: deploy.read, deploy.status.",
"instance": "acme deploy api",
"permission": "deploy.run",
"scope": "deploy:run",
"resource": { "type": "service", "id": "api" },
"denials": [{ "role": "developer", "reason": "condition" }],
"alternatives": ["deploy.read", "deploy.status"]
}status is kept so the object validates against the same schema; instance carries the command line with arguments, never with secrets or environment values. The text form follows the one-line template from errors so a model driving the CLI sees the same first line it would see in an MCP refusal.
approval-required
A grant with approval: 'human' produces approval-required (approvals). The adapter binds the prompt to Decision.token, a hash of permission key, resource id, subject and actor, and re-runs decide after the answer so a revocation between question and answer still denies.
The y/N prompt is the requester answering for themselves, so it is an approval only where the grant says the requester may approve:
- Self-approvable (
approval: { distinct: false }and noactor), interactive (interactive: true, which defaults to a TTY on stdout and noCIvariable): the default confirm prints the permission key, the resource identity and the reason, waits fory, and continues only if the recomputed token matches. Declining exits77. - Everything else, including
approval: 'human'and any command an agent runs for the user: the adapter never prompts. With astore, it records the request (or resumes an approved one for the same token, consuming it) and exits75while it is pending. Without astore, it exits77and says to configure one, since nothing could record an approval for the rerun to find. - Non-interactive (
CI=true, no TTY, output piped): the decision is not prompted for.--yes,--forceor any other flag is not accepted as an approval, because a flag can be typed by the same agent that asked for the action. The CLI exits75and prints Problem Details with anapprovalextension:
{
"type": "https://permdock.com/problems/approval-required",
"title": "Approval required",
"status": 403,
"permission": "deploy.run",
"resource": { "type": "service", "id": "api" },
"reason": "human",
"token": "pd1.…",
"approval": {
"at": "https://console.acme.dev/approvals?token=pd1.…",
"hint": "Ask a release manager to approve, then re-run."
}
}approval is the approval option (ApprovalHint, { at?, hint? }), the same optional member the server kernel adds to its approval-required Problem Details (vocabulary). The request is recorded in the store you pass (approvals); a rerun with the same permission, resource or arguments, subject and actor recomputes the same token and resumes the approved record once. Replacing the prompt with clack:
import { confirm, isCancel } from "@clack/prompts";
createPermDock(policy, {
// ...
interactive: {
confirm: async ({ permission, resource, reason }) => {
const answer = await confirm({
message: `${permission} on ${resource.type} ${resource.id} (${reason}). Continue?`,
});
return answer === true && !isCancel(answer);
},
},
});An Ink useInput component can implement the same confirm contract; the adapter only needs a Promise<boolean>.
Destructive commands and dry runs
A permission whose action meta sets destructive: true (the delete action of crud(), or { rollback: { destructive: true } }) asks for a typed confirmation before the action runs, after the decision granted it:
- In a terminal, the default prompt asks the user to type the resource id (the permission key for a collection action) and runs the action only on an exact match; anything else exits
77. Replace it withinteractive: { typed }, a function that receives{ permission, resource, expected }and resolves with what the user typed. - Without a terminal (no TTY,
CIset, output piped), the command exits64(EX_USAGE) and says to pass--yes, unless--yesor-yis inargvor theyesoption is set. --yesonly waives this confirmation. It never approves anapproval-requireddecision and never turns a denial into a grant.
--dry-run (or dryRun: true) decides without side effects: nothing runs, no quota is consumed, no confirmation is asked. A granted decision prints dry run: <permission> on <resource> is granted; nothing ran (with --json, { "outcome": "granted", "permission": …, "dryRun": true }); a denial or an approval-required prints the usual Problem Details. The process exits with the code exitCode maps the outcome to, so a pipeline can check a deploy before it runs it:
acme deploy api --env production --dry-run --json || echo "would be refused: $?"Agent-driven CLIs
When an AI agent runs your CLI through a shell tool, two identities are involved and the adapter records both:
- The
principalis the logged-in human whose stored token the process finds throughkeychainorenv. Their grants are the ceiling. - The
actoris the agent. It is filled from a verified environment token, for example a short-lived JWT minted for the agent session and exposed asPERMDOCK_ACTOR_TOKENby the harness. A bare--actor claudeflag is ignored, and a subject that arrives only via an actor token is denied: an agent cannot act without a human principal. delegationcomes from the actor token (scope,authorization_details) and narrows what the agent-run CLI may do. A human who maydeploy.runin production does not make the agent able to, unless the delegation says so.on('decision')events carry bothprincipal.idandactor.id, so adeploy.runexecuted by an agent is distinguishable from the same command typed by the person.
Combined with filterCommands({ mode: 'hide' }), the agent's --help shows only what the delegation allows, and --json refusals give it the alternatives it needs to re-plan. This is the CLI form of the least-agency and tool-misuse controls in OWASP Agentic Top 10 (ASI02, ASI03); the identity model is described under delegation.
Storage and secrets
- Tokens obtained by the
devicesource are written to the OS keychain whenstorage.keyringis set (theEntryclass from@napi-rs/keyring), one entry per profile under thestorage.servicename. Without it, or when the keychain throws (no secret service on headless Linux, a locked keychain), they go tocredentials.jsonin the platform config directory ($XDG_CONFIG_HOME/<service>on Linux and macOS,%APPDATA%\<service>on Windows) with the directory at mode0700and the file at0600; the adapter refuses to read a credentials file that is group- or world-readable. logoutdeletes the entry and, when the authorization server advertises arevocation_endpointin its RFC 8414 metadata, revokes the refresh token so the copy on disk is useless afterwards.- Tokens never appear in
argv. There is no--tokenflag, and the adapter warns on startup when a value that parses as a JWT is found among the arguments, becauseargvis visible to other users viapsand to shell history. - Tokens are never written to
on('decision')events, Problem Details, or--verboseoutput; events carry the subject id and the token'sjtiat most. An expired refresh token falls through to the next source, typicallydevice, which prompts for a new login.
Examples
commander:
import { Command } from "commander";
import { permdock, protect, format, exitCode } from "./permdock";
const program = new Command("acme");
program
.command("deploy <service>")
.option("--env <env>", "target environment", "staging")
.action(
protect(permissions.deploy.run, (service, opts) =>
loadService(service, opts.env),
)(async ({ data }, service, opts) => {
await deploy(data, opts.env);
}),
);
program.command("login").action(async () => {
await permdock({ refresh: true, source: "device" });
});
await program.parseAsync();citty:
import { defineCommand, runMain } from "citty";
const deploy = defineCommand({
meta: { name: "deploy", description: "Deploy a service" },
args: {
service: { type: "positional", required: true },
env: { type: "string", default: "staging" },
},
run: protect(permissions.deploy.run, ({ args }) =>
loadService(args.service, args.env),
)(async ({ data }, { args }) => {
await deploy(data, args.env);
}),
});
runMain(defineCommand({ meta: { name: "acme" }, subCommands: { deploy } }));Both wrappers catch PermDockDeniedError and PermDockApprovalRequiredError, print format(error.decision, output) to stderr and call process.exit(exitCode(error.decision)); a --json run prints nothing else on stdout, so acme deploy api --json || echo $? yields 77 and a parseable object.
Why
- The keychain peer is injected, not imported.
permdock's runtime entries import nothing but@standard-schema/spec(invariant 12), and a dynamic import of an optional native module breaks bundlers and single-file CLI builds. PassingEntrykeeps the choice and the native build in the consumer'spackage.json. @napi-rs/keyringis the recommendation because it ships prebuilt binaries for macOS Keychain, Windows Credential Manager and the Linux Secret Service, needs nonode-gypstep, and replaced the archivedkeytar. Any class with the samegetPassword/setPassword/deletePasswordshape works.- The mode-0600 file stays as the fallback. Containers, CI runners and headless Linux have no secret service, and a CLI that cannot log in there is worse than one that stores a refreshable token in a file only its user can read. A successful keychain write removes the file copy for that profile.
- The destructive confirmation is typed, not
y. Ayis muscle memory and an agent answers it without reading; typing the id makes the user name what is about to go. Without a terminal there is nobody to ask, so the adapter refuses withEX_USAGErather than guessing, and--yesis the explicit, greppable way a script says it meant it.--yesstays separate from approvals because the agent that asked for the action can type the flag. --dry-rundecides through the same path as a real run but as a simulation, so it reports what the run would decide without consuming alimitand without the prompt; a pipeline gets the real exit code before it commits.subjectFromCiOidcreturns aworkload, never a user: a CI job acts for a repository and a ref, not for the person who pushed. Grant it through aprincipal.kindorprincipal.repositorycondition, or map it to memberships in yourprincipalfunction.- The y/N prompt is not an approval for
approval: 'human'. The person at the terminal is the requester, or an agent the requester runs, and approvals exist so someone else looks. Only a grant that says the requester may approve (distinct: false) takes the local answer, and never from an agent; every other approval goes through the same store and token resume the HTTP and agent adapters use. approval.atandhintare shared with the HTTP adapters rather than terminal-only fields, so one client that readsapproval-requiredProblem Details handles both a CLI's--jsonoutput and a 403 body.
Example app
apps/examples/terminal: a deploy CLI with status / deploy / rollback guarded by protect, --help built through filterCommands, --json Problem Details, an approval-gated production deploy, --dry-run, a destructive rollback that needs a typed confirmation or --yes, and an agent-run mode that reads PERMDOCK_ACTOR_TOKEN.
Related
- Authentication: how tokens become subjects.
- JWT adapter:
subjectFromJwt, issuer and audience checks. - Node http: the same
protectshape for a server process. - MCP:
list_toolsfiltering thatfilterCommandsmirrors. - Approvals: the replay-safe
tokenand resume flow. - Errors: error classes and the Problem Details shape.
- CLI: the
permdockbinary, the developer tool this adapter is not.
Last updated on
Node http
permdock/node wraps the Fetch kernel for raw node:http handlers (IncomingMessage and ServerResponse) and for any framework not covered by a dedicated adapter.
tRPC
permdock/trpc adds a request-scoped PermDock to tRPC context and a protect middleware that loads the resource from procedure input, with a trpc-to-openapi hook for OpenAPI security.