Reference

Security-key touch: reference

The details behind the switch that requires a security-key touch for person decisions from terminals, including what each summary names, what the gateway writes, the limits, keys, the terminal client and rollback.

SectionReference
UpdatedOctober 24, 2026
EditionSelf-hosted Enterprise, public beta

This page is the reference for the switch described in Work with an agent. Read that section first: it says what the switch is for, how to turn it on and how to register a key.

The refusal and the proof

With the switch on, a service token's person decision without a valid proof gets HTTP 403 with error person_decision_required, header x-shakerscan-step-up: required, the decision's name and a plain-words summary, and the refusal is audited as route.denied person-only. The client then asks the gateway for a challenge bound to exactly that request (method, path and query, SHA-256 of the body bytes, the token, the person it acts for), the person touches a security key registered to that person (WebAuthn, user presence, no PIN; the gateway's public URL as origin, its host name as relying party), and the gateway answers a grant: one use, at most 120 seconds, for that request through that token. The client sends the same request once more, with the same bytes and Idempotency-Key, carrying the grant in x-shakerscan-person-proof. A grant is spent by its first use, is refused for another body, path, query, method, token or person, after it expires and on replay, and is honoured only while the key that made it stays registered. Proofs are audited as person_decision.granted and person_decision.proved.

With the switch off nothing of this applies: the request is forwarded as the client sent it (a proof header is not read and not forwarded), and audited as person_decision.token.

What a summary names

The summary you touch for names every value the request sets:

  • every budget limit a raise sets, the safety ceilings (state-changing requests, out-of-band interactions, ports, hosts, device fragility) first;
  • the whole host, before any name the target was given (internationalized names as their A-labels), and for every target authorization the environment it is evaluated in ("in the production environment", or "in the lab environment (private and local addresses are allowed in it)"): the one the request names, else the target's stored environment or cohort. The gateway writes that environment into the authorization it forwards. The engine keeps no environment with it for later, which is why, with the switch on, changing a target's or device's environment (its cohort, or its stored environment) is a person's decision of its own, named with the environment before and after;
  • the credentials, collections and switches a widening of a target's Hunt permissions adds (a field it leaves out counts as the engine's default);
  • for an approval receipt, the one scope it is for (previewed through the same token), the environment it was previewed in, that scope's verdict and every confirmation the receipt carries, as the engine answered that preview: naming it sends the engine nothing, and the grant fixes that scope, which alone is previewed again when the approval is sent (each such call is an engine.request row of the gateway's own, as is the target the gateway registers to name a scan of a URL);
  • for a Hunt start past its session's safety budget (below), the target, the profile, what the start turns on, its start bounds and, for each safety ceiling it takes past the budget, what it starts with and what is left;
  • names an agent can choose (a credential, a collection) in quotes, with control and bidirectional characters made inert.

A decision too long to show in a terminal (over 1,000 characters) gets no proof; make it in the browser. A decision the gateway cannot name in full (a target, device, Hunt, credential, policy or secret the engine did not return) gets no touch either, nor does a request the gateway would refuse after it (for example an operator's receipt only an administrator may write): the command says why, and nothing is asked of your key. Two decisions are made in the browser only with the switch on, because the terminal cannot show what they approve: confirming a Hunt's SSH plan (its commands and their effects are on the Hunt page) and approving an authorization investigation (its proposal in full). Their refusal says proof: browser, no touch is begun for them, and the client says to make them on the Hunt page.

What the gateway writes

On a request a grant was made for, the gateway writes who decided ("Alice (local:alice) via terminal security-key touch") into approved_by, authorized_by or granted_by, and a budget raise's reason, never a name or reason the client sent. With the switch on, a request that names a field twice is refused (HTTP 422), and so is an approval receipt that both approves and denies (a receipt whose denial reason is blank is an approval, as the engine reads it). A target registered and authorized in one request (POST /targets with authorized_by) gets no touch, because its environment is known only once the engine answers: shakerscan targets authorize <url> registers it first and then authorizes it. A touch from a terminal authorizes active testing only; an intrusive authorization is made on the Targets page.

With the switch off nothing is rewritten: the engine records what the client sent. Client 0.11.2's targets authorize and hunt budget raise then record the token and that no touch proved it ("alice's token laptop, no security-key touch (shakerscan targets authorize)").

Scans, Hunt starts and retests

With the switch on, a scan, Hunt start, retest or network scan that a token sends goes to the engine without the gateway recording the target's authorization. On a target already authorized it simply runs. If the engine refuses it for want of an authorization, that refusal becomes the person's decision ("Authorize staging.example.com for active testing in the production environment (to run this scan)"), naming the target; nothing was started. In an agent's shell the client names shakerscan targets authorize <target-id> at the end of its line, and the agent is told to send its request again, once, only after you say the target is authorized. Your own shakerscan scan ... --active-testing shows the same decision, takes the touch and sends the scan again, without its own "Type yes" (the touch is the confirmation); a target you authorized already is scanned with no touch and asks "Type yes" as before. When the engine registers a URL's www/apex twin instead (only the twin has an address record), the summary names the host it registered, and the touch authorizes exactly that target.

A Hunt start counts as active work when its policy asks for any authority the engine treats as active testing (active testing, state-changing requests, network discovery, out-of-band interactions, identity headers or a direct origin). With the switch off, and from the browser, a start that asks only for identity headers or a direct origin is forwarded as before and the engine answers it.

One safety budget per agent session

With the switch on, every Hunt a token starts counts against one session, and the Hunts of a session share one safety budget over the six safety ceilings (state-changing requests, out-of-band interactions, TCP ports, UDP ports, hosts, device fragility points). With the switch off nothing of this applies.

  • The session. An agent session is the touch-made pre-authorization of shakerscan agent --allow. Its one touch approves the bounds and the budget: one balanced Hunt's safety ceilings (state-changing requests 20, out-of-band interactions 50, TCP ports 1,200, UDP ports 1,000, hosts 500, device fragility 100), raised as the session's budget.raise bounds raise one such Hunt (budget.raise:2x doubles each; budget.raise:max_hosts=900 takes hosts to 900). Client 0.11.2 shows the budget before the touch. A start that names the session's pre-authorization counts in it, and so does a start through the same token that names none while the session is live (the newest, when there are several). A token with no live agent session has a connection session: one balanced Hunt's ceilings, no raises, for 12 hours from its first counted start.
  • What counts. A running Hunt holds what it may still spend: the most its start limits and its own bounds let the engine raise it to without a person, or its current limits when a person raised it further. A Hunt that ended (completed, cancelled or failed) counts what it spent. So cancelling a Hunt and starting another resets nothing, and two Hunts at once share what is left.
  • Only what a Hunt can spend. A start counts each ceiling as the engine starts the Hunt with it: a ceiling whose authority the start's policy leaves off starts at 0, and the engine refuses to raise it, so it counts 0. State-changing requests need allow_state_changing_http; UDP ports need network_discovery, and so do TCP ports and hosts unless an active start selects an SSH credential; out-of-band interactions need allow_oob_interactions; device fragility points need a device target. A read-only Hunt therefore counts nothing, and runs beside an active one with no touch. A capability: start bound that turns such an authority on (the engine grants it with no person) counts that ceiling at the profile's value. When a ceiling starts at 0, the gateway sends the start's budget.raise bounds as one total per limit, so a raise after a person turns that authority on never multiplies past the budget. When the gateway cannot read what a session's Hunt spent, no Hunt starts (HTTP 503; retry).
  • A start past what is left (its start limits, profile or overrides, take a ceiling past it) is the person's decision, hunt.start: refused before anything is sent, with each ceiling it takes past the budget named ("state-changing requests 20 (safety ceiling; 15 of 40 left)"). In an agent's shell the client names that same start as your command (shakerscan api POST /hunts ...) and tells the agent not to send it again; your touch starts it, its bounds recorded as yours, and it counts against no session. A start you pre-authorize yourself with shakerscan hunt start --allow (a touch for that one start) counts against none either.
  • A start that fits is reserved atomically (two starts that each fit cannot together go past) and sent with its raise bounds narrowed to what is left: each budget.raise bound is written as one total per limit, a safety ceiling's at most what is left, every other limit's exactly as your bound raises it on the engine (from the engine's own Hunt contract). A raise the Hunt then needs past the session's budget is its budget.raise permission request, which you decide (shakerscan approve, a touch). A raise sent with a token is a person's decision under the switch already.
  • Limits. At most 50 Hunts count in one session; another start is your decision. The ledger lives in the gateway's state with the session and ends with it; a restore withdraws it with every pre-authorization. Hunts started before the switch was turned on are not counted. A bound like budget.raise:max_hosts=900 lets each Hunt reach 900 hosts, so a session of 900 holds one such Hunt at a time. The budget covers the safety ceilings only, not a Hunt's requests, calls or duration, and not scans.

Start bounds

shakerscan agent --allow ... pre-authorizes an agent's work with one proof before it starts. With the switch on that proof is a touch, and only bounds proved by a touch count: bounds pre-authorized with a code before the switch was on are refused at the next Hunt start (a Hunt already running keeps the bounds it started with until it ends; cancel it to end them sooner). Bounds proved by a touch end when the key that made them is removed from the account, as a grant does. --allow target.authorize:<host> with a Hunt's exact host records the target's authorization in the person's name when one of the agent's Hunts starts with active testing on it, unless the target is stored in a lab environment (then the start asks for a touch that names it). A wildcard (*.example.com) lets a running Hunt reach other hosts but never authorizes a Hunt's own target, and neither does an IP address.

Touches, attempts and limits

  • Touches are never counted against step-up attempts, failed ones included (a key's signature cannot be guessed), neither the five-minute count nor the daily count for knowledge decisions, so deciding a busy Hunt one touch at a time never locks you out, and an agent holding your token cannot lock out your touches. Every failed touch is in the audit log (approval.fail). Authenticator codes are counted (ten in five minutes) where they are still accepted.
  • Beginning a person's decision takes from the token's own request allowance and is limited to 20 times for the same request in five minutes; a token keeps its 50 newest unanswered challenges. Other terminal approvals: 60 begun per token in five minutes. An agent looping on its own requests through your token does not hold up your touch; one that floods the gateway with your token on purpose slows every request of that token (HTTP 429), and one that begins more than 50 other decisions before you touch pushes your challenge out (your touch then answers approval_unknown; run the command again): stop it, or decide from a connection of your own.
  • The decisions feed approve --watch reads takes an operator token, is read-only, reads only the last 15 minutes and is limited per token (about one read a second).
  • A begun approval is the token's act (audited with the token's account, naming the person); only the touch or the code is the person's.

Keys

  • A key answers a terminal, and counts as your key, when the browser that registered it reported it as USB or NFC, or reported no transport at all (an older browser; it may well be a USB key). One it reported only as built in or on a phone (Touch ID, Windows Hello, a synced passkey) never answers a terminal and does not count as your key. A key registered with no transport whose passkey lives on this computer is offered to the client but cannot answer it.
  • Try a key with shakerscan approve --test (client 0.11.2): one touch, checked as every touch is, nothing decided, with the switch on or off.
  • A key added "for terminal approvals" never signs anyone in, is never the second factor after a password and does not count toward the sign-in policy. A passkey you sign in with on a USB key answers a touch too, unless the browser made it need its PIN for every use (then add the key again, for terminal approvals). A key added "after my password" also answers a touch, but it is the second factor at every password sign-in of an account without an authenticator app.
  • When the browser offers to save the passkey to iCloud Keychain or Google Password Manager, choose "security key" or "use another device" and touch the YubiKey. For a key for terminal approvals, and a single sign-on person's key, the gateway asks the browser for a roaming key.
  • The gateway does not check which make of key answers (no attestation): a software authenticator or a password manager that reports a USB transport counts as a key too. We recommend a YubiKey.
  • A touch whose client data says it was made inside a frame of another site (crossOrigin true, or a topOrigin) is refused; a terminal client never sends one.
  • A single sign-on person's key answers terminal approvals with a touch in either switch state (with the switch off the browser sign-in stays the fallback). It works until an administrator disables the person here; a disable at the identity provider reaches the gateway at the person's next sign-in, as for service tokens.

The terminal client

  • Where it asks. Client 0.11.2 asks at a terminal: its input is one, and it is not inside an agent that shakerscan agent started. Otherwise it exits with status 3 having asked nothing of you or your key, and names the command to run in your own terminal.
  • Output piped or captured (| tee log, > file, --json | jq, HUNT=$(shakerscan hunt start ... --allow ...)): approve, --watch, agent --allow, hunt start --allow and the knowledge and secrets decisions ask as client 0.11.1 did, on standard error when that is the terminal, with the code where you have one. With the switch on they never ask for a touch there: they exit with status 3 having begun nothing, and say to run the command again with its output on your terminal (a key never blinks for a program that captures a command's output).
  • The key first only when input and output are both the terminal. approve --test, targets authorize, hunt budget raise and the touch for a person's decision need both, with the switch on or off.
  • No key registered: approve says so whole, before showing any request, and exits with status 1, as targets authorize and approve --test do.
  • Inside an agent started with shakerscan agent, client 0.11.2 refuses shakerscan approve, approve --watch and agent --allow (switch on or off), even typed into the agent's own window: run them in a terminal of your own.
  • With no key plugged in, the client says "Insert your security key" and waits up to a minute for one (within the challenge's two minutes). Over SSH, in WSL (unless the key is passed through with usbipd attach) and in a container, where a key plugged into your computer cannot arrive, it says so at once.
  • Ctrl-C at the touch cancels it: nothing is changed, and the command exits with status 130; a touch that fails exits with status 1, with the reason on one line.
  • A request body over 1,024 characters (a long Hunt start, typically) is named as shakerscan api … @FILE, saved in the decisions directory of the client that refused it. An agent you run as another computer user, or in a container, saves it in its own directory, which your client does not read: ask the agent for the body, save it in your own decisions directory (~/.config/shakerscan/decisions/), run the same command with that file, and read the decision the touch prompt names (the gateway says it from the bytes you send); or decide in the browser. approve --watch cannot name such a start's command (the gateway keeps no request body): ask the agent for it.
  • Client 0.11.1: upgrade to 0.11.2 first. 0.11.1 works with the switch only for approve <id>, --all-pending and agent --allow, and only with a key that has a PIN: it asks the PIN with every touch, and a key registered for terminal approvals, or a new YubiKey, has none until you set one (ykman fido access change-pin; ykman is not installed by default). A single sign-on person also types --account sso:<subject>. Its approve --watch stops at start with "each decision is one touch"; use approve --all-pending with it.

Where the key is

  • The key must be on the computer that runs the command. On a remote box over SSH, run the same command on your laptop with its own connection (these decisions are about a target or a Hunt, not about a connection), then run the box's command again; the box's refusal prints the exact command.

  • An approval receipt names a scope previewed through the same connection. If the agent previewed it through the box's connection, preview it again through the laptop's (shakerscan api POST /arsenal/scope/preview '<the same scope>', a touch with the switch on) before you approve it there; otherwise the gateway refuses it as not previewed through this connection.

  • Linux: a desktop session on systemd 244 or later (Debian 11, Ubuntu 20.04, Fedora 32 and later) already lets the signed-in user open a FIDO key. Older desktops: install libu2f-udev (Debian, Ubuntu) or u2f-hidraw-policy (Fedora, RHEL). With no desktop session (signed in over SSH to the machine the key is plugged into), add a rule for a group you are in, for example for a YubiKey on Debian or Ubuntu, in /etc/udev/rules.d/70-fido-key.rules:

    KERNEL=="hidraw*", SUBSYSTEM=="hidraw", ATTRS{idVendor}=="1050", MODE="0660", GROUP="plugdev"
    

    then sudo udevadm control --reload-rules && sudo udevadm trigger, and replug the key.

  • Windows is not supported by the terminal touch in this release; use the console there.

Rollback

Gateways before 0.2.0-beta.47 do not know the setting: they read the switch as off (person decisions from terminals are then unprotected) and refuse every console save while it is saved ("not a console setting: person_decision_proof"). They also offer only sign-in passkeys, with their PIN, for a terminal approval: a key registered "for terminal approvals (touch)" and a touch without a PIN no longer answer one there, so after a rollback people approve with the authenticator code, a sign-in passkey and its PIN, or the browser sign-in for single sign-on. Turn the switch off before rolling back. The host-side recovery is in Work with an agent; an administrator can also remove it from the browser's developer console on the console page:

const me = await (await fetch("/_enterprise/me")).json();
await fetch("/_enterprise/settings", {method: "POST", headers: {"content-type": "application/json",
  "x-csrf-token": me.user.csrf}, body: JSON.stringify({changes: {person_decision_proof: null}})});

Agents started with --allow under the switch keep their start bounds after a rollback; grants are approvals that expire on their own.