# Terminal (your own CLI)

Source: https://permdock.com/docs/adapters/terminal

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](/docs/cli)) |

## Purpose [#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](/docs/concepts/subject) model, three-outcome [decisions](/docs/concepts/decisions) and [errors](/docs/concepts/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](/docs/adapters/jwt) or a provider `subjectFrom*` helper.

## Install [#install]

```bash
pnpm add permdock
```

Optional 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 [#api]

```ts
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](/docs/security/delegation)).

## Subject sources [#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 [#process-lifecycle]

1. The CLI parses arguments; commands are registered through `filterCommands`, so `--help` already reflects the subject.
2. The first call to `permdock()` resolves the subject (and actor) once. A `device` source may block here for the login round trip.
3. `protect` runs `load` for instance actions, validates the result at the boundary when `load` is marked untrusted (JSON from stdin or a file), then decides. With `--dry-run` it prints the decision and exits with its code, running nothing. `granted` runs your action, after a typed confirmation when the permission is `destructive`; `denied` writes `format(decision)` to stderr and exits `77`; `approval-required` prompts when the grant lets the requester approve (`approval: { distinct: false }`, no actor) and `interactive` is true; otherwise it records the request in `store`, writes the Problem Details `approval` extension and exits `75`, or exits `77` when no `store` is configured.
4. Every decision, including the human's answer to a prompt, is emitted through `on('decision')` with the command path so audit and [otel](/docs/adapters/otel) see it.

## Exit codes [#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](/docs/cli), which reports findings, not permissions.

## Filtering help output [#filtering-help-output]

`filterCommands` is the terminal counterpart of the MCP adapter's `list_tools` filter ([MCP](/docs/adapters/mcp)): what the subject cannot run is hidden or marked before the framework ever sees it.

```ts
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's `scope`. A reader sees `rollback  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 [#formatting-denials]

```ts
process.stderr.write(format(decision, { json: false }));
```

```text
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 api
```

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

```json
{
  "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](/docs/concepts/errors) so a model driving the CLI sees the same first line it would see in an MCP refusal.

## approval-required [#approval-required]

A grant with `approval: 'human'` produces `approval-required` ([approvals](/docs/security/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 no `actor`), interactive (`interactive: true`, which defaults to a TTY on stdout and no `CI` variable): the default confirm prints the permission key, the resource identity and the reason, waits for `y`, and continues only if the recomputed token matches. Declining exits `77`.
* Everything else, including `approval: 'human'` and any command an agent runs for the user: the adapter never prompts. With a `store`, it records the request (or resumes an approved one for the same token, consuming it) and exits `75` while it is pending. Without a `store`, it exits `77` and 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`, `--force` or 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 exits `75` and prints Problem Details with an `approval` extension:

```json
{
  "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](/docs/adapters/server-kernel) adds to its `approval-required` Problem Details ([vocabulary](/docs/standards/problem-details)). The request is recorded in the `store` you pass ([approvals](/docs/adapters/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:

```ts
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 [#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 with `interactive: { typed }`, a function that receives `{ permission, resource, expected }` and resolves with what the user typed.
* Without a terminal (no TTY, `CI` set, output piped), the command exits `64` (`EX_USAGE`) and says to pass `--yes`, unless `--yes` or `-y` is in `argv` or the `yes` option is set.
* `--yes` only waives this confirmation. It never approves an `approval-required` decision 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:

```bash
acme deploy api --env production --dry-run --json || echo "would be refused: $?"
```

## Agent-driven CLIs [#agent-driven-clis]

When an AI agent runs your CLI through a shell tool, two identities are involved and the adapter records both:

* The `principal` is the logged-in human whose stored token the process finds through `keychain` or `env`. Their grants are the ceiling.
* The `actor` is the agent. It is filled from a verified environment token, for example a short-lived JWT minted for the agent session and exposed as `PERMDOCK_ACTOR_TOKEN` by the harness. A bare `--actor claude` flag is ignored, and a subject that arrives only via an actor token is denied: an agent cannot act without a human principal.
* `delegation` comes from the actor token (`scope`, `authorization_details`) and narrows what the agent-run CLI may do. A human who may `deploy.run` in production does not make the agent able to, unless the delegation says so.
* `on('decision')` events carry both `principal.id` and `actor.id`, so a `deploy.run` executed 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](/docs/security/owasp-agentic) (ASI02, ASI03); the identity model is described under [delegation](/docs/security/delegation).

## Storage and secrets [#storage-and-secrets]

* Tokens obtained by the `device` source are written to the OS keychain when `storage.keyring` is set (the `Entry` class from `@napi-rs/keyring`), one entry per profile under the `storage.service` name. Without it, or when the keychain throws (no secret service on headless Linux, a locked keychain), they go to `credentials.json` in the platform config directory (`$XDG_CONFIG_HOME/<service>` on Linux and macOS, `%APPDATA%\<service>` on Windows) with the directory at mode `0700` and the file at `0600`; the adapter refuses to read a credentials file that is group- or world-readable.
* `logout` deletes the entry and, when the authorization server advertises a `revocation_endpoint` in its RFC 8414 metadata, revokes the refresh token so the copy on disk is useless afterwards.
* Tokens never appear in `argv`. There is no `--token` flag, and the adapter warns on startup when a value that parses as a JWT is found among the arguments, because `argv` is visible to other users via `ps` and to shell history.
* Tokens are never written to `on('decision')` events, Problem Details, or `--verbose` output; events carry the subject id and the token's `jti` at most. An expired refresh token falls through to the next source, typically `device`, which prompts for a new login.

## Examples [#examples]

commander:

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

```ts
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 [#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. Passing `Entry` keeps the choice and the native build in the consumer's `package.json`.
* `@napi-rs/keyring` is the recommendation because it ships prebuilt binaries for macOS Keychain, Windows Credential Manager and the Linux Secret Service, needs no `node-gyp` step, and replaced the archived `keytar`. Any class with the same `getPassword` / `setPassword` / `deletePassword` shape 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`. A `y` is 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 with `EX_USAGE` rather than guessing, and `--yes` is the explicit, greppable way a script says it meant it. `--yes` stays separate from approvals because the agent that asked for the action can type the flag.
* `--dry-run` decides through the same path as a real run but as a simulation, so it reports what the run would decide without consuming a `limit` and without the prompt; a pipeline gets the real exit code before it commits.
* `subjectFromCiOidc` returns a `workload`, never a user: a CI job acts for a repository and a ref, not for the person who pushed. Grant it through a `principal.kind` or `principal.repository` condition, or map it to memberships in your `principal` function.
* 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.at` and `hint` are shared with the HTTP adapters rather than terminal-only fields, so one client that reads `approval-required` Problem Details handles both a CLI's `--json` output and a 403 body.

## Example app [#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 [#related]

* [Authentication](/docs/concepts/authentication): how tokens become subjects.
* [JWT adapter](/docs/adapters/jwt): `subjectFromJwt`, issuer and audience checks.
* [Node http](/docs/adapters/node): the same `protect` shape for a server process.
* [MCP](/docs/adapters/mcp): `list_tools` filtering that `filterCommands` mirrors.
* [Approvals](/docs/security/approvals): the replay-safe `token` and resume flow.
* [Errors](/docs/concepts/errors): error classes and the Problem Details shape.
* [CLI](/docs/cli): the `permdock` binary, the developer tool this adapter is not.
