SSH policy and approval
What the gateway lets an operator do on a target host once it knows who they are: the shape of a command rule and what it is matched against, what the policy cannot read, what is inside each certificate the gateway signs, and the gates a connect passes. Who the operator is and which groups gate what is on identities, groups and locks.
Command policy
The shape of a rule
policy.yaml holds a list of rules. Each one is:
| Key | Meaning |
|---|---|
name |
The rule's identifier. It appears in the denial message and in the audit entry |
pattern |
A regular expression. It is matched against the whole line and against every command inside it — see What the rules are matched against |
category |
A maintained pattern under a name (script_execution, interactive_pty) instead of a raw regex. Mutually exclusive with pattern |
action |
deny or require_approval. Absent means deny. Anything else is a load error |
reason |
The explanation shown to the operator and written to the audit log |
timeout_seconds |
How long an approval request waits. Defaults to 300 for a require_approval rule |
min_level |
The rule is inactive on hosts below this lockdown level |
roles |
The rule only applies to these roles. Empty means every role |
There are two actions, not three. There is no allow, and none is needed:
a command that matches no rule runs. So every command falls into one of three
classes — denied by a matching rule, sent to a second person by a matching
rule, or allowed by matching nothing.
Against one piece of text, evaluation is first-match over three blocks in a
fixed order: the built-in deny rules, then the rules from policy.yaml, then
the built-in approval rules. That order is what makes the union behave. The
denials come first, so no file can weaken them; the approval defaults come
last, so a narrower rule of your own can match ahead of one and override it.
The built-in deny rules are part of every policy, including the policy of a
gateway with no policy.yaml at all:
| Rule | What it blocks |
|---|---|
block_interactive_root_shell |
sudo -i, sudo -s, and sudo <shell> with no -c, compared by the target program's base name |
block_sudo_shell_wrapper |
sudo <shell> -c …, same base-name comparison — a shell wrapper hides the real command from the policy engine |
block_destructive_root_rm |
rm -rf / |
block_passwd_changes |
passwd, with or without sudo — password changes go through the directory |
block_sudo_not_allowlisted |
Present only when policy.yaml sets sudo_allowed_commands: any sudo target whose base name is not on that list |
The two sudo rules match by parsing sudo's own flags and comparing the target
program's base name, not by matching text after the word sudo — so
sudo /bin/bash and sudo bash are the same fact to them. The allow-list is
opt-in, but once opted in it cannot be overridden from the same file: it sits
inside the built-in deny block.
The built-in approval rules both apply from lockdown level 4 upward:
approve_script_execution_on_prod (running a script bypasses per-command
policy) and approve_interactive_pty_on_prod (an interactive terminal
program).
At a glance: what runs, what waits, what is refused
Every line an operator types ends in one of three outcomes: it runs, it waits for a second person, or it is refused. This table is the whole decision in one place — the sections after it are the detail.
| What the line contains | Outcome | Why | Where you change it |
|---|---|---|---|
| A command no rule matches | Runs | There is no allow action: a command nothing objects to is allowed |
Add a rule to policy.yaml (deny or require_approval, optionally narrowed by roles and min_level) |
| A command one of your rules matches | That rule's action | Your rule, your decision | policy.yaml |
A built-in deny: root shell through sudo, a sudo shell wrapper, rm -rf /, passwd |
Refused | The floor every policy keeps — no file can weaken it | Not configurable. sudo_allowed_commands can only add to it |
| A built-in approval: script execution, an interactive terminal program, on hardened hosts | Waits for approval | Running a script or a full-screen program hides the commands inside it from the policy | Your own narrower rule ahead of it (another timeout, other roles) |
Several commands in one line (;, &&, &, an or-list, a pipe, ( ), { }, a loop or conditional body) |
Every command is judged; the strictest outcome wins | A second command must not ride on the first one's verdict | Not configurable — this is how matching works |
A command behind a prefix (sudo, env, timeout, …) or written with quotes and escapes (up\time) |
Judged as the command that actually runs | A wrapper or an escape must not hide the program from the rules | Not configurable |
Something decided only at run time: $(...), backticks, eval, bash -c, a here-document, a function definition, a command name from a variable |
Refused by default | No parse can say beforehand what will run | dynamic_construct_action in policy.yaml: deny (default), require_approval, or allow (a lab setting) |
| A line bash cannot parse | Refused | Nobody can say what it means | Not configurable (block_unparseable_line) |
exit together with other commands in one line |
Refused | Inside a line it would not close your session, which is not what it looks like | Not configurable (block_exit_in_compound_line) — put exit on its own line |
What is typed inside an allowed vi, less or python |
Not judged, recorded | The gateway sees the line that opened the program, not what happens inside it | On the target host: the account's rights and its sudo configuration |
To see the outcome for a line before anyone runs it: isg policy check.

Screenshot: reference environment (env1), 2026-10-17.
What the rules are matched against
An operator types a LINE, and a line can run more than one command. The gateway parses it the way bash parses it and evaluates every command that line could run, not just its beginning:
- the raw line exactly as typed;
- every simple command inside it — either side of
;,&&,||,|and&, inside( )and{ }, inside a loop or conditional body, and inside a command substitution; - each of those again with a leading
sudoand the known command prefixes (env,nohup,timeout,stdbuf,setsid,nice,ionice,command,builtin,time) stepped over.
Quoting and escaping are resolved first, so re\boot is the command reboot,
which is what bash would run.
The strictest outcome wins: deny beats require_approval beats no match.
A rule of your own cannot soften a built-in denial by matching something else
in the same line — there is no allow action to write it with. When the
decision came from one part rather than the whole line, the message says which:
DENIED by policy: block_passwd_changes (password changes must go through the IdP) — part 2 of 2: "passwd -S peter"
Lines the policy cannot read
Some constructs decide what runs only once the line is already running, and no
parse can tell beforehand: a command substitution ($(...) or backticks), a
process substitution (<(...)), a here-document, a shell function definition,
eval, source, ., exec, alias, a shell or interpreter handed its
program as a string (bash -c, python3 -c, perl -e), and a command name
that only exists after expansion ($X, or a glob in the program's own name).
policy.yaml's dynamic_construct_action decides what happens to such a line.
It takes deny (the default, and what an absent key means),
require_approval, or allow; any other value is a load error. Under the
first two the decision shows up as the rule block_dynamic_shell_construct or
approve_dynamic_shell_construct. allow runs the line unevaluated and is a
lab setting — it logs a warning when the policy loads.
Two line-level rules are not configurable at all:
| Rule | When it fires |
|---|---|
block_unparseable_line |
The line is not a shell line bash can parse — an unterminated quote, an unclosed here-document. There is no reading of "run it anyway" for a line nobody can say the meaning of |
block_exit_in_compound_line |
exit, quit or logout appears in a line that has other commands in it. Those words close the session only as a line of their own; anywhere else they are a command sent to the target that does nothing to your session, and the difference is worth refusing rather than silently doing the other thing |
To see all of this for a line of your own without running it,
isg policy check prints the decomposition, every candidate text, and the rule
that decided:
isg policy check -- 'id; passwd -S peter'
Its exit status is the decision: 0 allowed, 1 denied, 2 approval
required.
Where the policy stops
The gateway's policy is not a hard boundary, and it is not sold as one. It narrows what reaches the target and records what was attempted. Three things are outside its reach by construction:
- An allowed interpreter, editor or pager can run commands from inside
itself.
vi's:!,less's!, apythonprompt. The gateway sees the line that opened the program; what is typed inside it is a byte stream to the target, recorded in the session's transcript but not matched against any rule. - Runtime facts are invisible. An alias or shell function defined on the
TARGET host,
xargs,find -exec,watch, and the file a redirection writes to — all of them name a program somewhere the gateway never reads. - A parse is not an execution. That is exactly why the constructs above are refused by default rather than guessed at.
The hard boundary lives on the target host: a least-privilege account and a
sudo configuration that names what that account may escalate to. The gateway
is the gate in front of it and the record of who walked through — the two
together, not either alone. See Target host for the account's shape.
What a certificate contains
The gateway signs four kinds of certificate, and the differences are in what each one names and how long it lives.
| Human session | Service account | Renewable identity | Host | |
|---|---|---|---|---|
| Principals | The one resolved --as OS user |
The one principal from ca.yaml's service_accounts[].principal |
— (an X.509 client certificate) | Every hostname the gateway dials that host by |
| Key ID | isg-human:<principal> |
The account name | — | isg-host:<first principal> |
| Extensions | permit-pty, and nothing else |
permit-pty |
— | none |
| Critical options | none | none | — | none |
| Lifetime | 10 minutes, centred on issuance | ttl_seconds, 1 hour by default |
renewable_ttl_days, 30 days by default |
host_cert_ttl, 1 year by default |
| Key origin | Generated in the daemon's memory, never written to disk | The requester's key | Generated on the service machine | The target's existing host key, which the CA only signs |
The principal is the answer to --as, after resolution — the OS user the
session becomes on the target. Nothing else is in it: one principal, one
certificate, one session.
permit-pty and nothing else is a decision, not an omission. No
permit-port-forwarding, no permit-agent-forwarding, no
permit-X11-forwarding, no permit-user-rc. A forwarded port is exactly the
channel the policy engine cannot see, so the credential itself is unable to
open one.
Group membership is never written into a certificate. Groups gate whether a certificate is issued; they are not carried by it. Neither is the role, neither is the identity of whoever requested the session when it was requested on someone else's behalf — those live in the audit log, which is where a question about them belongs.
The ten-minute human window is sized for clock drift between the gateway and the target during the handshake, and for nothing else. SSH checks a certificate only while the connection is being established, so a session that connected inside its window keeps working afterwards; the lever for cutting someone off is refusing the next issuance, which is what the access gates below do.
The gates a connect passes
Before any credential exists, a request is settled in a fixed order, and everything non-interactive happens first so that a request which was never going to be allowed does not cost the operator a multi-factor challenge.
human_access in ca.yaml is an allow-list with first-match-wins
semantics. Each rule names principals (the --as values it covers; * is
allowed here), and then either hosts (literal names — there is deliberately
no *) or host_labels (a set of CMDB key/value pairs that must all match).
A rule may additionally require a group (require_group) and may name who is
allowed to request a session as that principal (requested_by; with the key
absent, only the principal themselves may). There is no deny rule: a request
that matches nothing is refused with no_matching_rule.
Two properties of this list are worth stating plainly. A host_labels rule
cannot match on CMDB labels that are stale or unavailable — it reports
labels_stale or labels_unavailable rather than guessing. And whether the
section is enforced is its own switch, human_access.enforce: with it off,
every decision is still evaluated and audited, and none of them block. Turning
on requested_by_enforce without enforce is refused as a contradiction.
A third property is about where the labels come from, and it is why the daemon
is strict about it. A host_labels rule is only as real as the CMDB behind it:
a resolver with no data answers every host with an empty label set, so every
such rule stops matching at once — quietly, with nothing in the output to say
the labels went away. So a cmdb.yaml that names a mock_file which is not
there refuses the start rather than coming up labelless, and the cmdb.yaml
row of isg system check reads red with the same text. Leaving the key out
entirely is still allowed — that is the explicit way to run with no labels —
and the row then reads amber, naming the consequence.
Where these rules come from on a provisioned gateway. When no ca.yaml is
shipped from the controller, the Ansible role generates a minimal one, and it
writes two allow rules rather than one wide list. The first is the operators'
(isg_gateway_operators): the same people who also get a local account and
isg-operators membership on the gateway host itself. The second appears only
when isg_gateway_human_access_users names someone, and it covers those named
humans over the same hosts. That second group gets no local account on the
gateway and no group membership there — their route to a target is the
certificate, and the gateway is only its issuer. The names are account names,
never *: the role refuses to render a list that contains one, because in
principals a * is not a name but everybody.
isg_gateway_requested_by_enforce is what turns on requested_by_enforce in
that same generated file, and since neither rule carries requested_by, each
principal may only ask for a session as themselves.
hostgroup.yaml is the second, independent gate: per-host required
groups, selected by the host's labels. It has its own enforce switch and
behaves the same way when it is off — measured and audited, not enforced.
Then the multi-factor challenge, whose mode is mfa.yaml's mfa_mode:
email (a random code delivered by SMTP, or written to totp_outbox.txt when
no relay is configured), totp (RFC 6238, enrolled once per operator with
isg mfa enroll), or totp-with-email-fallback. Repeated failures lock the
user and the source address out for the periods mfa.yaml sets.
In totp-with-email-fallback — and only there — an operator who signed in with
an e-mailed code can register an authenticator app themselves, from the webUI's
/profile page. The confirmation asks for two codes, not one: the app's, and a
fresh one sent to the factor the account already has. That is because in this
mode a TOTP record replaces the e-mailed factor, so a stolen browser session
must not be able to enrol an app of its own. There is no re-enrollment on this
path — a lost authenticator is an administrator's MFA_RESET, not a
self-service step.
Who controls the second factor in email mode. The gateway sends the code
to the mail attribute it reads off the directory entry at login, and the
first factor is the directory password. Whoever can reset a user's password in
the directory and rewrite that user's mail field therefore holds both factors
under mfa_mode: email — the second factor is then not independent of the
directory. With totp the user enrols the secret once and the gateway keeps it
(mfa.yaml store: a file in the gateway's own state, $ISG_HOME/totp, or
OpenBao with store: vault), outside the directory's reach.
totp-with-email-fallback sits between the two: an operator who has enrolled
an authenticator app is in the totp position, one who has not is in the
email position — and can enrol an app on the strength of an e-mailed code.
Recommendation: where the directory is not administered by the same people as
the gateway, choose totp; email fits where you accept the directory as the
trust anchor for both factors, and totp-with-email-fallback is the migration
path between them.
The gateway login (ssh isg-login@gateway) has one more way in: with
sso.yaml setting cli: optional an empty password at the prompt starts the
RFC 8628 device flow with the identity provider, and with cli: required there
is no password prompt at all. The username typed at the prompt has to match the
name the provider returns, and after that it is the same directory gate and the
same multi-factor branch as above. isg connect is not on this path — its
caller is established from the socket, not from a login.
Only then is a certificate signed — and every command typed afterwards is its own policy decision and its own audit entry.
What the gateway does not provide on SSH
These are measured absences: things the code refuses to do, listed here so a deployment is not designed around one of them.
- No password authentication to a target host.
connect.yaml'sallow_password_auth: trueis a hard configuration error, and a rejected certificate never falls back to a password prompt — a target that has not trusted the CA yet is a finding, not something a prompt should paper over. There is exactly one mechanism,human_auth: cert, and it is the only legal value of that key. - No credential vault for target-host accounts. The gateway holds no
password for any account on any target. Where a command needs
sudo, the escalation happens on the target and the target's ownsudoprompts for it over a pass-through terminal: the broker neither reads nor stores what is typed, and the session recorder — which records output only — cannot capture it either. - No standing credential left on a target. The human session key is generated in the daemon's memory and is never written to disk, never handed to the operator, and never installed on the target. Removing someone's access is a directory change, not a file to clean up on every host.
- No mid-session cut-off for a human session. There is no renewal and no
re-issuance; a live SSH session is not interrupted when its certificate's
window passes. Session limits are the tool for bounding one
(
policy.yaml'sidle_timeout_secondsandmax_duration_seconds), and refusing the next issuance is the tool for revoking access. For the long-lived credentials it is the other way round:isg ca revokeandisg ca host-cert revokeadd a serial to the revocation list, which is consulted on every renewal and every dial. - No wildcard over hosts in an access rule.
principals: ["*"]exists;hosts: ["*"]does not. A broad selection is expressed withhost_labels, because a label set is auditable — a reader can see which hosts a rule covers — whereas a wildcard silently absorbs every host added afterwards. - No deny rules in
human_access. It is an allow-list only. Mixing allow and deny in one ordered list is precisely the precedence confusion the per-command policy engine already carries a scar from.