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. Administering
Windows hosts over OpenSSH with PowerShell 7 (pwsh) as the
login/DefaultShell surfaced where the POSIX assumption is a safety gap,
not just a functional one. A code audit (2026-07-15) produced two findings:
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.
The classifier is blind to them, and that is what 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 class of operation they exist to guard on a Windows target.
This is the crux of the ADR; everything else is secondary.
PowerShell 7 also removes one concern the initial (cmd.exe-oriented) analysis
raised: pwsh defaults to UTF-8 output, so the hardcoded
decode("utf-8", "replace") in sshpool.py / sessions.py is correct for
cmdlet output; the OEM-codepage mangling of cmd.exe / Windows PowerShell 5.1
does not apply (a native .exe invoked from pwsh can still emit OEM bytes; 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.
The increments land as their own reviewed PRs. Increment A (classification) closes a safety gap and is why this needs an ADR (it extends what the tier classifier means across a new shell surface, more than "another verb"; see the 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).
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. - 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; C is a small follow-up; D/E are
documentation caveats that ride B. A touches
patterns.py, 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, with a 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: 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 on ADR 0003 classification; it does not move the ADR 0002 boundary, and 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. It already works, so "unsupported" would be untrue and leave the classifier blind on live Windows admin: 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 interprets its own syntax. Rejected in favor of classify-and-audit-what-is-sent.
- A global remote-output-encoding setting now. The pwsh-7 path has no such
problem (UTF-8), and a global setting is 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 shipped
guardrail that feeds
guarded/readonly/the broker. Windows destructive operations belong in the same classifier as their POSIX peers, not in 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.