Menu

Developer documentation

Signet CLI v0

/docs/cli

The production signet binary owns its setup commands. There is no second package, login, hosted dashboard session, or project-link file.

Scaffold a config

signet init \
  --base-url https://auth.example.com \
  --database postgres \
  --config signet.toml

--config defaults to signet.toml; --base-url defaults to http://localhost:3000; --database accepts memory (the development default) or postgres. The generated file contains env:SIGNET_SECRET and, for PostgreSQL, env:SIGNET_DATABASE_URL references—not values. init uses create-new semantics: if the path already exists, it refuses and leaves the file byte-for-byte unchanged. There is deliberately no --force overwrite path.

Then supply the named values out of band:

export SIGNET_SECRET="$(openssl rand -hex 32)"
export SIGNET_DATABASE_URL="postgres://user:pass@host/db"

Diagnose config and the live instance

signet doctor --offline --config signet.toml
signet doctor --config signet.toml

Offline mode loads the same strict config as boot, runs the licence gate, and reports whether mail delivery is usable. It does not connect to the database, mutate migrations, bind a socket, or make a network request. The existing signet --check remains the terse config-only CI form.

Without --offline, doctor also requests the configured root /health and {base_path}/open-api/generate-schema with a five-second timeout and redirects disabled. It requires success responses, valid schema JSON, at least one published path, and a schema server URL equal to this config's base_url + base_path. A failure names the URL, observed condition, and the next check; it never prints response bodies that may have come from an intermediary.

Pull the public client environment

signet env pull --config signet.toml --file .env.local
# or, for an agent/pipe:
signet env pull --config signet.toml --stdout

The file form inserts or updates exactly one line:

SIGNET_AUTH_URL=https://auth.example.com/api/auth

Unrelated lines and comments remain. Repeating the command is idempotent. Duplicate SIGNET_AUTH_URL assignments fail instead of silently choosing precedence, and symlink destinations are refused. A newly created env file is mode 0600 on Unix. --stdout emits only the assignment on stdout so an agent can compose it safely.

SIGNET_AUTH_URL is public and is suitable for createAuthClient({ baseURL: ... }). Signet is operator-owned, so there is no remote vendor vault from which this command could retrieve SIGNET_SECRET, the database DSN, an admin key, OAuth client secrets, SMTP credentials, webhook secrets, or a licence. Those remain in the operator-owned env:/file: references declared by signet.toml; env pull neither reads nor writes them.

Keep memberships in step with a system that still owns them

Two commands for a migration where another system stays the authority for organisation membership while Signet holds a copy. Both read ONE file, in the importer's generic csv format (docs/migrating-a-multi-tenant-system.md): member_id (the join key, carried byte-identical), organization_id, organization_role, email, name, plus two optional columns, active (true/false, default true) and updated_at (RFC 3339). Signet never opens another system's database. The other system exports the file; how it does that is its business.

signet compare-principals --config <signet.toml> --source <csv> [--org <uuid>]... [--imported-at <RFC3339>] [--json]
signet sync-memberships   --config <signet.toml> --source <csv> [--org <uuid>]... [--apply] [--json]

compare-principals diffs org id, role and lower-cased email per member. It classes every principal as agree, mapped-custom, inactive-absent, role-differs, recorded-role-differs, org-differs, email-differs, missing-on-signet, signet-only or inactive-present.

A row the file marks active=false, or gives a deletion time, is one the sync removes or skips. So Signet no longer holding it is the agreed outcome, counted as inactive-absent on its own, never as agree and never as a gap. Signet still holding such a membership is inactive-present, an unexplained disagreement, and that is reported on its own: a membership the source retired says nothing useful about the role or email it used to carry. Only an active row Signet does not hold is missing-on-signet.

A disagreement counts as EXPLAINED only when you pass --imported-at and the file's updated_at for that row is later. Without the flag, the first output line says nothing is explained. The summary line always names what the tool did not compare. Exit 0 means no unexplained disagreement, 1 means one or more, 2 means it could not run.

sync-memberships plans one action per principal: keep, update-role, map-custom, clear-recorded, create (only when a Signet user already holds that email), create-mapped, remove, skip-inactive or refused. It writes nothing unless you pass --apply. With --apply it touches only the member and memberRoleOrigin tables, never a user or a credential.

A membership whose organisation changed gets a refusal, because that is a remove plus a create and must stay visible as such. Run compare-principals afterwards. That is the sync's verification.

Both commands count unread columns as skipped_columns. Both refuse a file that lacks a required column before they touch Signet. - reads stdin.

A role Signet does not model

Signet has three organisation roles: owner, admin, member. A file may name others. A role outside the three lands as member and the name the file used is stored against that membership in the memberRoleOrigin table. member is the floor: the mapping can only ever give a person less authority than the source system did, never more. A role outside the three is never coerced upward, and never reaches the role column as itself.

Two things are not custom roles and are refused rather than mapped. A Signet role written in another case, such as Owner or ADMIN, is a typo: mapping it to member would quietly demote the person it names, so both commands refuse it and say to write the role exactly. An empty role is refused by sync-memberships, which will not guess; signet import keeps its existing default of member for an empty role. Surrounding whitespace is trimmed and never makes a role custom, and a custom name is stored and compared exactly as written, with case intact.

Every mapped row gets its own receipt. signet import prints a MAPPED line naming the file's role, the organisation and the membership it created, and counts them as mapped= on its summary line. That count is a subset of imported (or of would_import on a dry run), not a separate group of rows. sync-memberships plans map-custom for one it maps and clear-recorded for one whose source role has since become owner, admin or member, which drops the stored name. Both counts appear on the summary line.

compare-principals counts a mapped membership as mapped-custom, on its own, beside agree. It is agreement of one exact shape: the file names a role Signet does not model, Signet holds member, and the stored name is the same string the file used. Anything else about a stored name is recorded-role-differs, an unexplained disagreement, which covers all three ways it can drift: nothing stored, a different name stored, or a name still stored after the file moved that person onto one of Signet's three roles.

Read the two numbers together. unexplained=0 no longer means every role name matched, because mapped memberships hold member under a different name. It means no membership disagreed in a way the tool could not account for. mapped-custom is how many crossed under a mapping, and recorded-role-differs is what catches a mapping that has come apart.

What people mapped this way may DO is not modelled here. They hold the member role and whatever that role grants. The stored name is a record of where the membership came from, not a permission, and nothing in Signet reads it to decide anything.

Agreement and client verbs

signetctl uses your instance origin (SIGNET_URL) and a live admin bearer (SIGNET_ADMIN_KEY). Agreement writes require a platform credential with user:create; reads require token:get and honor the credential's organization scope. Each verb prints human output by default. Add --json for its typed API response. Export prints CSV bytes by default; --json returns {"csv":"..."}.

Replace ORG, AGREEMENT, CLIENT, and HASH below with stored ids and the lowercase SHA-256 of the accepted UTF-8 text:

VerbExample
List agreementssignetctl agreements list --organization-id ORG
Create version 1signetctl agreements create --organization-id ORG --title NDA --text 'Keep project information private.' --renew-after-days 365 --external-id legal-nda-1
Read every versionsignetctl agreements show AGREEMENT --organization-id ORG --json
Set renewal for future acceptancessignetctl agreements renew AGREEMENT --organization-id ORG --renew-after-days 90
Clear renewalsignetctl agreements renew AGREEMENT --organization-id ORG --clear
Delete an agreement with no acceptancessignetctl agreements delete AGREEMENT --organization-id ORG
Publish changed textsignetctl agreements publish AGREEMENT --organization-id ORG --text 'Keep project information private. Delete copies afterwards.'
Record external acceptancesignetctl agreements record AGREEMENT --organization-id ORG --email person@example.com --text-sha256 HASH --accepted-at 2026-10-10T12:00:00Z --source-ref signed-record-42
List acceptance evidencesignetctl agreements acceptances --organization-id ORG --agreement-id AGREEMENT --limit 100 --json
Export all matching evidencesignetctl agreements export --organization-id ORG --agreement-id AGREEMENT > acceptances.csv
List organization clientssignetctl clients list --organization-id ORG
Read a client's requirementssignetctl clients agreements show CLIENT --organization-id ORG
Replace a client's requirementssignetctl clients agreements set CLIENT --organization-id ORG --agreement-id AGREEMENT
Remove all requirementssignetctl clients agreements set CLIENT --organization-id ORG --clear

For recording, pass exactly one of --user-id and --email. Optional evidence flags are --version, --accepted-at, --source-ref, --ip-address, and --user-agent. Omitting the version selects the latest published version with the matching hash. Omitting the timestamp uses Signet's database clock.

Acceptance listing and export accept --agreement-id, --user-id, --client-id, and --since. Listing also accepts --cursor and --limit (1..1000). Use the returned next_cursor for the next page. Export has no cursor or limit; it exports all matching rows. Repeat --agreement-id on clients agreements set for each requirement. The command replaces the whole list.

See Require an agreement before people sign in for the browser flow, token refusals, version rules and CSV columns. These verbs use the admin contract; the command tree also generates the agent/MCP tools.

The vault verbs

Signet's vault holds organization secrets. The operator verbs (vault list, vault put, vault rotate, vault get, vault revoke, vault unseal, vault status, vault audit-export) talk to the Admin API with the admin key. One verb, vault exec, is different: it reads through the data-plane door with a scoped service token and never uses the admin key.

This defends against haste, not malice. The adversary is a well-meaning human or AI coding agent that prints, logs or pastes a secret by accident. A hostile local process running as the same user is out of scope. Nothing here is a security boundary.

Store and rotate

vault put and vault rotate no longer take the value as a command-line argument: a value on the argv line lands in shell history and process listings. The value comes from --value-file <path>, or from stdin when the flag is absent.

signetctl vault put prod/stripe/key --value-file ./stripe.key --organization-id <org>
printf '%s' "$VALUE" | signetctl vault rotate prod/stripe/key --organization-id <org>

Bytes from a file or a pipe are used exactly, with no trimming, so a PEM key survives byte for byte. Only when stdin is a terminal does the command prompt on stderr with echo off and strip the one trailing newline. A value file readable by group or other is refused; run chmod 600 <path> and retry. An empty value is refused. A value that is not valid UTF-8 is refused, because the API body is JSON; re-store such bytes base64-encoded instead.

Read

vault get requires exactly one of two destinations, and refuses before it touches the network when neither is given:

signetctl vault get prod/stripe/key --organization-id <org> --out-file ./stripe.key

--out-file <path> writes the value to a NEW file created with mode 0600; a path that already exists is refused, and stdout carries only the path and version metadata, never the value. --plaintext prints the value as text, and is refused when stdout is a terminal, where the value would sit in scrollback. To hand the secret straight to a program, use vault exec instead.

Unseal

vault unseal no longer takes --master-key. The key comes from --master-key-file <path> (the file must not be group- or other-readable), from stdin, or — only when stdin is a terminal — from a hidden prompt. ASCII whitespace at both ends is trimmed; base64 never contains whitespace.

signetctl vault unseal --master-key-file ./master.key

Explicit machine writes

The HTTP data plane also serves PUT {base_path}/vault/{path}, POST {base_path}/vault/{path}/rotate and POST {base_path}/vault/{path}/revoke. Each requires its own explicit service-token action; get and deploy resolve imply none. Values are JSON body strings. The organisation comes from the key; omission of scope_path selects its own place. A query/body place collision refuses. Owners/admins grant vault keys through the organisation pages; a vault key carries no effective administration powers. The CLI operator verbs above keep their admin API contract. See the machine write schema.

Run a program with a secret: vault exec

vault exec fetches each secret through the data-plane door (GET {base_path}/vault/{path}) with a scoped service token carrying vault:get, then runs a program. A nested secret path travels as ONE url segment with / percent-encoded — --env K=prod/stripe/key requests /vault/prod%2Fstripe%2Fkey — and the admin verbs (vault get, put, rotate, revoke) encode the path the same way. It never uses the admin key: the token comes from --token-file <path> or the SIGNET_VAULT_TOKEN environment variable, and a present SIGNET_ADMIN_KEY is neither an error nor an input. With no token, the command refuses and says how to mint one (signetctl service-tokens mint).

# A program that reads an environment variable:
SIGNET_VAULT_TOKEN=... signetctl vault exec --env API_KEY=prod/stripe/key -- ./deploy.sh

# curl with an Authorization header built from the secret:
SIGNET_VAULT_TOKEN=... signetctl vault exec \
    --file TOKEN=prod/stripe/key \
    --file-format TOKEN='Authorization: Bearer {}' \
    -- curl -sS -H @TOKEN https://api.example.com/me

Rules the command enforces:

  • Everything after -- is the child's argv, used as given. No shell runs in between. The only substitution is an argv element's {NAME} token, which must name a --file binding and becomes the staged file's PATH. {{ and }} are literal braces. A secret VALUE is never placed in argv.
  • --env NAME=<secret path> puts NAME and the value in the child's environment. The names PATH, HOME, IFS, anything starting LD_, DYLD_ or SIGNET_, and anything outside [A-Za-z_][A-Za-z0-9_]* are refused. Binding names must be unique across --env, --file and --file-format. At least one binding is required: vault exec exists to deliver a secret; with no binding use the program directly.
  • --file NAME=<secret path> writes the value to a file (mode 0600) inside a private directory (mode 0700); the child's environment gets NAME=<that path>, and {NAME} in argv becomes that path. --file-format NAME='text with one {}' shapes the file's content around the value. The directory and its files are removed when the child exits, including when you interrupt with SIGINT or SIGTERM, which are forwarded to the child's whole process group. That interrupt cleanup is best effort: it cannot cover SIGKILL, which terminates signetctl with no chance to run any cleanup, so a kill -9 can leave a signet-vault-exec-* directory in the temp folder.
  • The child inherits the parent environment minus every SIGNET_* variable, so SIGNET_ADMIN_KEY and SIGNET_VAULT_TOKEN never reach it.
  • If any secret fetch fails, no child is started, nothing is written to disk, and the server's code passes through unchanged while its message is passed through and then extended with which binding the fetch was for.
  • The child's exit code is returned as vault exec's exit code; a child killed by signal N exits 128+N.

Child output passes through a filter that catches an accidental echo of a value — the value itself and its standard-base64, URL-safe-base64 and percent-encoded forms are replaced with [signet:redacted]. The filter is a courtesy, not a control. It does NOT catch: a value the child reverses, re-encodes or transforms before printing; a value the child writes to a file or a socket instead of stdout/stderr; or a value split across separate lines or processes before it is printed. Values shorter than 6 bytes are not filtered at all, because a needle that short would shred ordinary output; the command says so on stderr when it happens.

Which vault verbs are deliberately not agent tools

Three vault verbs are never offered to agents or MCP clients as tools (TOOL_LEAF_BLOCKED in the CLI source): vault_get, because its result would carry the plaintext secret value into the model-visible tool output; vault_exec, because it delivers secret values to a child process from operator-held credential files and is meant for an operator to type, not an agent to invoke; and vault_unseal, because it supplies the vault master key, which must never travel through an agent transcript.

Local bindings in vault exec

A binding whose source starts with local: is read from the local store on this machine (see the next section) instead of the server: --env NAME=local:<group>/<KEY> and --file NAME=local:<group>/<KEY>. Mixed bindings — some local, some server paths — are allowed in one call. When EVERY binding is local, vault exec needs no URL and no token and makes no network call at all.

Because a source is a local reference exactly when it starts with local:, a SERVER secret path that begins with local: can never be served: vault put, vault rotate, vault get and vault revoke refuse such a path outright, and vault exec would read it from the local store instead. There is no escape syntax — rename the server secret.

The local secret store: signetctl local

signetctl local keeps secrets in one age-encrypted file PER SECRET, at ~/.signet/vault/<group>/<KEY>.age (or the directory named by SIGNET_LOCAL_VAULT_DIR). It is deliberately a separate top-level command from vault: the laptop file is not the org vault, and nothing here talks to the network.

signetctl local init
signetctl local set prod STRIPE_KEY --value-file ./stripe.key
printf '%s' "$VALUE" | signetctl local set prod API_KEY
signetctl local list
signetctl local list prod
signetctl local rm prod API_KEY

The identity never lives in a plain file beside the ciphertext — a backup or sync of the store directory would otherwise copy key and secrets together. On macOS the age identity is stored in the login Keychain (service dev.signet.local-vault, under a random per-store id in <dir>/store-id), written so the key never appears in any process's argv. On other unix it is written passphrase-wrapped with age's scrypt recipient (<dir>/identity.age); the passphrase comes from a hidden terminal prompt or from SIGNET_LOCAL_VAULT_PASSPHRASE_FILE (a 0600 file; anything group- or other-readable is refused). local init --plaintext-identity keeps the old plaintext key.txt behaviour as an explicitly accepted risk — every command then prints a stderr line naming the unprotected path. Tests use that flag.

local init creates the store directory (mode 0700) and the identity, prints the store path and the PUBLIC recipient only, and refuses to run when an identity already exists, so it can never silently replace the key your secrets were encrypted with. There is no recipients file: secrets are encrypted to this store's own identity and nothing else.

local set reads the value the way vault put does — bytes from --value-file used exactly with no trimming, exact bytes from a pipe, or a hidden prompt when stdin is a terminal — and stores the value as EXACT BYTES: a value need not be valid UTF-8 (binary values work; deliver one with a --file binding, since an --env value is an environment variable and must be text). Multi-line values (a PEM, a JSON blob) are stored byte for byte. A group- or other-readable value file is refused. Group and key names may hold only A-Z, a-z, 0-9, _ and -, and a name that differs from an existing name only by CASE is refused — on a case-insensitive filesystem the two would share one file. Every use of the store re-checks permissions on OPENED file descriptors: the store and group directories must be real directories (a symlinked component is refused), owned by you, with no group/other bits, and every file the store reads (identity.age, key.txt, store-id, each <KEY>.age) must be a regular file, owned by you, mode-checked — a file that has drifted open is refused with chmod 600 <path>.

local list prints group names, or the KEY NAMES in one group. It reads directory and file names only — it never decrypts anything, and there is deliberately NO local get and no verb that prints a value: deliver a secret to a program through vault exec, where it arrives as an environment value or a private file and the output filter masks an accidental echo. Mind the filter's floor: a value shorter than 6 bytes is NOT filtered and will appear verbatim in the child's output if the child prints it (the command warns on stderr):

signetctl vault exec --env API_KEY=local:prod/API_KEY -- ./deploy.sh

An all-local vault exec call builds no HTTP client, reads no token and makes no network request unless at least one binding reads from the server. The --url / SIGNET_URL value may still be parsed by the argument parser before dispatch; it is simply unused.

If a command reports a missing store, run signetctl local init. If it reports a missing group or key, run signetctl local list to see what exists. See docs/handling-secrets.md for the secret-handling rules this CLI follows.

Enter to open · Esc to close