ADR 0011: Windows targets via OpenSSH + PowerShell 7¶
- Status: Accepted
- Date: 2026-07-15
Context¶
relay-shell was built and hardened against POSIX SSH targets. A concrete
deployment question — administering Windows hosts over OpenSSH with
PowerShell 7 (pwsh) as the login/DefaultShell — surfaced how much of the
stack silently assumes POSIX, and, more importantly, where that assumption is a
safety gap rather than a functional one.
A code audit (2026-07-15) mapped the current state. Two findings frame this ADR:
Execution already works. SshPool.run and SshPool.open_process pass the
raw command string straight to asyncssh's conn.create_process(command) — no
/bin/sh -c wrapping, no POSIX quoting, no shlex. asyncssh hands the string
to whatever the SSH server runs as its command interpreter, so with
DefaultShell=pwsh an ssh_exec/ssh_spawn/ssh_fanout command is
interpreted by PowerShell 7 with no adapter layer. SFTP paths pass through
opaquely (a caller can use C:\... or /c/... as the server expects). So
Windows/pwsh targets are already functional today.
The classifier is blind to them — and that is the part that matters. The
tier heuristics in patterns.py (TIER3_PATTERN, TIER2_PATTERN,
PRIV_ESC_PATTERN, consumed by policy.classify) are POSIX command-name
matches: rm -rf, mkfs, shutdown, systemctl, sudo. A PowerShell 7
target's destructive operations — Remove-Item -Recurse -Force, Clear-Disk,
Format-Volume, Stop-Computer, Remove-Service, Remove-LocalUser,
vssadmin delete shadows — classify as Tier 1. Consequences: guarded
mode does not refuse them, readonly mode's ceiling does not see them, and the
opt-in Tier-3 confirmation broker (ADR 0009) never fires. The compensating
controls that make the unsandboxed posture (ADR 0002) safe to operate are
absent for the exact class of operation they exist to guard on a Windows
target. This is the crux of the ADR; everything else is secondary polish.
PowerShell 7 also simplifies one concern the initial (cmd.exe-oriented)
analysis raised: pwsh defaults to UTF-8 for its output stream, so the
existing hardcoded decode("utf-8", "replace") in sshpool.py / sessions.py
is correct for cmdlet output — the OEM-codepage mangling that afflicts
cmd.exe / Windows PowerShell 5.1 does not apply to the pwsh-7 path (a native
.exe invoked from pwsh can still emit OEM bytes; that is a documented edge,
not a feature to build).
Decision¶
Adopt Windows-over-OpenSSH-with-PowerShell-7 as a first-class target class,
delivered as compensating-control and documentation work — not a new transport,
not a command/path translation layer. Scope is deliberately bounded to the
pwsh deployment; cmd.exe / Windows PowerShell 5.1 are out of scope except
where a native .exe reachable from pwsh is already covered.
This ADR records the decision and the plan; the increments land as their own reviewed PRs. Increment A (classification) is the one that closes a safety gap and is the reason this needs an ADR (it extends what the tier classifier means, across a new shell surface — more than "another verb", see ADR-README "when to write an ADR"); the rest are ordinary review-loop changes tracked here for coherence.
A. PowerShell-7-aware tier classification (the safety fix)¶
Extend TIER3_PATTERN / TIER2_PATTERN / PRIV_ESC_PATTERN with Windows/pwsh
alternatives, under the same discipline as the POSIX rules: (?<![\w])
anchoring, bounded quantifiers (no catastrophic backtracking — the RED-7/POL-2
ReDoS ceiling tests apply), paired positive and negative (false-positive)
tests, and a PATTERNS_VERSION bump. POSIX matching stays byte-identical
(pure additions).
- Tier 3 (irreversible):
Remove-Item … -Recurse/-Force(and the aliasesdel/ri/rd/rmdir/erase; noterm -Recursealready trips the existingrm\s+-[rf]rule via case-insensitivity),Clear-Disk,Format-Volume,Initialize-Disk,Stop-Computer/Restart-Computer,Remove-Service,Remove-LocalUser/Remove-LocalGroup,Clear-EventLog, plus native destructive.exes still reachable frompwsh(diskpart,format,vssadmin delete shadows,bcdedit,cipher /w,reg delete). - Tier 2 (stateful):
Stop-Service/Set-Service,Install-Module/Install-Package/Uninstall-*,Remove-/Disable-/Set-NetFirewallRule,Set-ItemProperty … HKLM:,Register-/Unregister-ScheduledTask,New-LocalUser. - Privilege escalation / high-risk: add
runasandStart-Process … -Verb RunAstoPRIV_ESC_PATTERN(Windows 11's nativesudois already thesudotoken the rule matches). ConsiderInvoke-Expression/iexandSet-ExecutionPolicyas security-relevant.
The cmdlet names are distinctive CapCase-hyphenated tokens, so false-positive
risk is low (unlike the short cmd.exe verbs del/rd/sc, which the pwsh
scope lets us avoid centering on).
B. Documentation¶
A docs/deployment.md (+ the _OPERATING_GUIDE prompt) section on
Windows/pwsh-7 targets: what already works (native-shell execution, SFTP), that
the classifier now covers common pwsh destructive cmdlets, the honest limits
below, and the native-.exe UTF-8 caveat.
C. Redaction (small follow-up)¶
Add credential and asplaintext to the CLI-flag redaction keyword list so
-Credential and ConvertTo-SecureString -AsPlainText '…' do not reach the
audit log ( -Password/-Token/-ApiKey are already caught by the single-dash
--? rule). Document the residue the audit found (positional secrets like
net user <name> <pass> remain unredactable — a POSIX-shared limitation).
D / E. Encoding and PTY (documented caveats, not features)¶
- Encoding: none needed for the pwsh-7 path (UTF-8 by default). Document the
native-
.exe-OEM edge; only revisit with a config/inventory-field if a mixed fleet demands it. - PTY:
term_typeis hardcodedxterm-256color; make it overridable if a need appears, and document that the SSHsignalrequest (used bysession_kill) may be a no-op on older Windows OpenSSH builds, degrading to best-effortterminate(). Modern OpenSSH + pwsh-7 largely handle ConPTY and window-change.
What this decision explicitly does NOT do¶
- No command or path translation between POSIX and Windows. The remote
pwshinterprets the command; the caller supplies pwsh-appropriate syntax. An adapter layer would be a large, brittle surface for no capability gain (ADR 0002 preserves operator power; this adds visibility, not translation). - No PowerShell interpreter for the local
shell_script. That tool spawns a local subprocess and is a separate, local-OS axis; it is untouched. - No new transport, tool, or auth provider. This is classification + redaction + docs on the existing SSH path; the tool contract count is unchanged.
Consequences¶
- Sequencing. A + B ship first as one PR (the safety fix plus honest docs);
C is a small follow-up; D/E are documentation caveats that ride B. Each
through the normal review loop; A is
patterns.py-touching, so it is security-sensitive (runbook §3.3) and gets a redaction/classification review and the ReDoS-ceiling tests. PATTERNS_VERSIONbumps once for increment A (audit consumers can detect the widened classification surface, per the module's version contract).- Classification remains heuristic — sharper caveat for PowerShell. pwsh
parameters are case-insensitive, abbreviatable (
-Recurse→-rec→-r), and:-bindable (-Recurse:$true), and cmdlets have aliases. Matching a cmdlet by name is reliable; a fully-abbreviated-alias form (ri -rec -fo) or an obfuscated pipeline can still evade it — exactly the ADR 0003 "heuristic, advisory, defence-in-depth" property, now stated for Windows too. The deny list andguarded/readonlyremain the hard controls; this widens the guardrail, it does not make it a sandbox. - Trust boundary unchanged. Like ADR 0006/0009 this adds compensating controls layered on ADR 0003 classification; it does not move the ADR 0002 boundary, and (as always) the tier gate never overrides the deny list.
- Tracked as backlog item WIN-1 (runbook §7.1), which points here.
Rejected alternatives¶
- Do nothing / treat Windows as unsupported. Functionally it already works, so "unsupported" would be untrue and leave the classifier blind on live Windows admin — the worst combination (operators get the capability with none of the guardrail). Rejected.
- A command/path translation / normalization layer (rewrite POSIX ⇄ pwsh, normalize path separators). Large, brittle, and unnecessary: the remote shell already interprets its own syntax. It would add surface and bugs for zero capability gain. Rejected in favor of classify-and-audit-what-is-sent.
- A global remote-output-encoding setting now. Solved a problem the pwsh-7
path does not have (pwsh is UTF-8). A global setting is also wrong for a mixed
Linux+Windows fleet. Deferred to a per-host inventory field if a concrete
mixed-fleet, native-
.exe-heavy need appears. Rejected for now. - Fold Windows verbs into the deny list instead of the tier patterns. The
deny list is deployment-specific and absolute; classification is the general,
shipped-default guardrail that feeds
guarded/readonly/the broker. Windows destructive operations belong in the same shipped classifier as their POSIX peers, not offloaded to every operator's deny regex. Rejected.
Validation outcome (2026-07-15)¶
Increments A (classification) and B (docs) implemented in the PR that moves this ADR to Accepted:
patterns.py—TIER3_PATTERN/TIER2_PATTERN/PRIV_ESC_PATTERNgained the Windows/pwsh alternatives above as pure additions;PATTERNS_VERSION9 → 10. The bounded-gap rules (Remove-Item … -Recurse,del … /s,format … <drive>) use the RED-7{0,N}?ReDoS ceiling.- Tests (
tests/test_patterns.py): paired positive / negative cases for the Tier-3, Tier-2, and priv-esc additions (the negatives pin no over-classification ofFormat-Table,Get-ChildItem -Recurse, a single-fileRemove-Item, proseformat, and read-onlyGet-*counterparts), plus a ReDoS-ceiling test on a large verb-repeating argument. POSIX classification verified byte-identical (the existingtest_policy.py/test_patterns.pyPOSIX cases pass unchanged). - Docs (
docs/deployment.md§8b): what already works, the newly-classified operations, the heuristic caveat (pwsh abbreviation / aliases / pipeline forms), the UTF-8 / native-.exeencoding edge, and the ConPTY /session_killsignal note.
Increment C (PowerShell credential redaction) also landed: credential
joined the generic keyword and CLI-flag redaction rules, a dedicated
ConvertTo-SecureString rule collapses an inline plaintext operand (positional,
-String, or after switches) while leaving a $var handle untouched, and a
(?<![A-Za-z]) guard stops the flag rule from over-scrubbing the token after a
Verb-Noun cmdlet (Get-Credential). PATTERNS_VERSION 10 → 11; paired
over/under-scrub + ReDoS-ceiling tests in tests/test_redaction.py. Increments
D (encoding) and E (PTY) stay documented caveats, not features, per the
decision above. No change to the tool contract, transport surface, audit-record
shape, or the ADR 0002/0003 trust boundary.