PermDock
Adapters

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 isA subpath of the permdock package, imported by the CLI you ship to your usersThe bin of the same permdock package, run by you and your CI
What it doesResolves a verified subject for the process, guards command actions, filters help output, formats denials, sets exit codesScans 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 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

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
  });
ExportRole
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

SourceHow the token is obtainedTypical use
deviceOAuth 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
keychainReads 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
envPERMDOCK_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-oidcThe 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
anonymoustoken() 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

  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 see it.

Exit codes

Codes follow the BSD sysexits.h conventions so shell scripts and agents can branch without parsing output.

CodeNameWhen
0EX_OKgranted and the action completed, or granted under --dry-run
64EX_USAGEA destructive permission in a process with no terminal and no --yes
75EX_TEMPFAILapproval-required with the request recorded in store, or a self-approvable request in a non-interactive process: the command can succeed once someone approves
77EX_NOPERMdenied, 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
78EX_CONFIGThe 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'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

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

{
  "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 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:
{
  "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 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:

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 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 (ASI02, ASI03); the identity model is described under delegation.

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

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

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.

  • Authentication: how tokens become subjects.
  • JWT adapter: subjectFromJwt, issuer and audience checks.
  • Node http: the same protect shape for a server process.
  • MCP: list_tools filtering that filterCommands mirrors.
  • Approvals: the replay-safe token and resume flow.
  • Errors: error classes and the Problem Details shape.
  • CLI: the permdock binary, the developer tool this adapter is not.

Last updated on

On this page