# tirith PowerShell hook # Overrides Enter key via PSReadLine to check commands before execution. # Overrides Ctrl+V to check pasted content. # Guard against double-loading (session-local only). # If inherited from environment (exported by attacker/parent), ignore it. if ($global:_TIRITH_PS_LOADED) { if ([Environment]::GetEnvironmentVariable('_TIRITH_PS_LOADED')) { [Environment]::SetEnvironmentVariable('_TIRITH_PS_LOADED', $null) $global:_TIRITH_PS_LOADED = $false # Fall through to load fresh } else { return # Set in this session - genuine double-source guard } } $global:_TIRITH_PS_LOADED = $true # Session tracking: generate ID per session if not inherited if (-not $env:TIRITH_SESSION_ID) { $env:TIRITH_SESSION_ID = '{0:x}-{1:x}' -f $PID, [int][DateTimeOffset]::UtcNow.ToUnixTimeSeconds() } # M8 ch2 — surface "this shell is on the remote side of an SSH session" to # `tirith prompt-status` (planned for M8 ch6) and any other downstream # consumer. Set NOW so chunk 6 can read it without a follow-up hook patch. # Standard SSH env vars: SSH_CONNECTION, SSH_CLIENT, SSH_TTY. PowerShell on # Windows rarely sees these but PowerShell 7+ via OpenSSH does. if ((-not $env:TIRITH_SSH_REMOTE) -and ($env:SSH_CONNECTION -or $env:SSH_CLIENT -or $env:SSH_TTY)) { $env:TIRITH_SSH_REMOTE = '1' } # Interactivity gate: the hook only intercepts commands typed at a prompt and # pasted text, so it must be a complete no-op in a non-interactive PowerShell # (`pwsh -c …`, `pwsh -File …`, a CI step). `[Environment]::UserInteractive` # is false there. A non-interactive child must inherit nothing from tirith. if (-not [Environment]::UserInteractive) { return } # M9 ch4 — record a shell-start environment snapshot for `tirith env diff`. # Start a background job that execs a hidden tirith subcommand; the child reads # ITS OWN inherited environment and writes ONLY variable names + an 8-char # value-hash prefix (never raw values, never a recoverable hash) to # /env_snapshot.json. No value crosses an argv boundary or a temp # file. Backgrounded via Start-Job so it never blocks the prompt; errors are # swallowed so a missing binary never disrupts the shell. Runs once per session # (this hook is sourced once per shell start). try { Start-Job -ScriptBlock { & tirith env snapshot 2>$null 1>$null } | Out-Null } catch { # Ignore — the snapshot is best-effort and must never break the shell. } # Check for PSReadLine $psrlModule = Get-Module PSReadLine -ErrorAction SilentlyContinue if (-not $psrlModule) { Write-Host "tirith: PSReadLine not found, hooks disabled. Install PSReadLine for shell protection." -ForegroundColor Yellow # TIRITH_STATUS: opt-in prompt indicator (see docs/prompt-status.md). With # no PSReadLine, no key handler is installed and tirith intercepts nothing, # so the live protection level is `off`. Set as a session-scoped # `$global:` variable — deliberately NOT `$env:`, which would export it to # child processes that have no tirith protection of their own. $global:TIRITH_STATUS = 'off' return } function global:_tirith_escape_preview { param([string]$Text) if ($null -eq $Text) { return '""' } return (ConvertTo-Json -Compress -InputObject ([string]$Text)) } function global:_tirith_parse_approval { param($FilePath) $script:_tirith_ap_required = "no" $script:_tirith_ap_timeout = 0 $script:_tirith_ap_fallback = "block" $script:_tirith_ap_rule = "" $script:_tirith_ap_desc = "" if (-not (Test-Path $FilePath -ErrorAction SilentlyContinue)) { [Console]::Error.WriteLine("tirith: warning: approval file missing or unreadable, failing closed") Remove-Item $FilePath -Force -ErrorAction SilentlyContinue # delete on all paths $script:_tirith_ap_required = "yes" $script:_tirith_ap_fallback = "block" return $false } $validKeys = 0 try { foreach ($rawLine in [System.IO.File]::ReadAllLines($FilePath)) { $parts = $rawLine -split '=', 2 if ($parts.Count -ge 2) { switch ($parts[0]) { "TIRITH_REQUIRES_APPROVAL" { $script:_tirith_ap_required = $parts[1]; $validKeys++ } "TIRITH_APPROVAL_TIMEOUT" { $parsed = 0 if ([int]::TryParse($parts[1], [ref]$parsed)) { $script:_tirith_ap_timeout = $parsed } else { [Console]::Error.WriteLine("tirith: warning: invalid approval timeout '$($parts[1])', using 0") } } "TIRITH_APPROVAL_FALLBACK" { $script:_tirith_ap_fallback = $parts[1] } "TIRITH_APPROVAL_RULE" { $script:_tirith_ap_rule = $parts[1] } "TIRITH_APPROVAL_DESCRIPTION" { $script:_tirith_ap_desc = $parts[1] } } } } } catch { [Console]::Error.WriteLine("tirith: warning: approval file read failed: $_") $script:_tirith_ap_required = "yes" $script:_tirith_ap_fallback = "block" $validKeys = 0 } Remove-Item $FilePath -Force -ErrorAction SilentlyContinue if ($validKeys -eq 0) { [Console]::Error.WriteLine("tirith: warning: approval file corrupt, failing closed") $script:_tirith_ap_required = "yes" $script:_tirith_ap_fallback = "block" return $false } return $true } # Read a single line with timeout using Console.KeyAvailable polling. # Returns the user's input, or empty string on timeout. function global:_tirith_read_with_timeout { param([int]$TimeoutSecs, [string]$Prompt) Write-Host -NoNewline $Prompt $deadline = [DateTime]::UtcNow.AddSeconds($TimeoutSecs) $buffer = "" while ([DateTime]::UtcNow -lt $deadline) { if ([Console]::KeyAvailable) { $key = [Console]::ReadKey($true) if ($key.Key -eq 'Enter') { break } if ($key.Key -eq 'Backspace') { if ($buffer.Length -gt 0) { $buffer = $buffer.Substring(0, $buffer.Length - 1) Write-Host -NoNewline "`b `b" } continue } $buffer += $key.KeyChar Write-Host -NoNewline $key.KeyChar } Start-Sleep -Milliseconds 50 } Write-Host "" # newline after input return $buffer } function global:_tirith_parse_warn_ack { param($FilePath) $script:_tirith_wa_findings = 0 $script:_tirith_wa_max_severity = "" if (-not (Test-Path $FilePath -ErrorAction SilentlyContinue)) { Remove-Item $FilePath -Force -ErrorAction SilentlyContinue return $false } try { foreach ($rawLine in [System.IO.File]::ReadAllLines($FilePath)) { $parts = $rawLine -split '=', 2 if ($parts.Count -ge 2) { switch ($parts[0]) { "TIRITH_WARN_ACK_FINDINGS" { $parsed = 0 if ([int]::TryParse($parts[1], [ref]$parsed)) { $script:_tirith_wa_findings = $parsed } } "TIRITH_WARN_ACK_MAX_SEVERITY" { $script:_tirith_wa_max_severity = $parts[1] } } } } } catch { $script:_tirith_wa_findings = 0 $script:_tirith_wa_max_severity = "" } Remove-Item $FilePath -Force -ErrorAction SilentlyContinue return $true } # Override Enter key Set-PSReadLineKeyHandler -Key Enter -ScriptBlock { $line = $null $cursor = $null [Microsoft.PowerShell.PSConsoleReadLine]::GetBufferState([ref]$line, [ref]$cursor) # Empty input: pass through if ([string]::IsNullOrWhiteSpace($line)) { [Microsoft.PowerShell.PSConsoleReadLine]::AcceptLine() return } # Run tirith check with approval workflow (stdout=approval file path, stderr=human output) $errfile = [System.IO.Path]::GetTempFileName() $prevHook = $env:_TIRITH_HOOK $env:_TIRITH_HOOK = '1' try { $approvalPath = & tirith check --approval-check --non-interactive --interactive --shell powershell -- $line 2>$errfile $rc = $LASTEXITCODE } finally { if ($null -eq $prevHook) { Remove-Item Env:\_TIRITH_HOOK -ErrorAction SilentlyContinue } else { $env:_TIRITH_HOOK = $prevHook } } $output = Get-Content $errfile -Raw -ErrorAction SilentlyContinue Remove-Item $errfile -Force -ErrorAction SilentlyContinue # Exit code 3 (WarnAck): stdout has two lines — approval path + warn-ack path. $warnAckPath = "" if ($rc -eq 3 -and $approvalPath -is [array]) { $warnAckPath = $approvalPath[1] $approvalPath = $approvalPath[0] } elseif ($rc -eq 3 -and -not [string]::IsNullOrWhiteSpace($approvalPath)) { $lines = $approvalPath -split "`n" if ($lines.Count -ge 2) { $approvalPath = $lines[0].Trim() $warnAckPath = $lines[1].Trim() } } if ($rc -eq 0) { # Allow: no output } elseif ($rc -eq 2 -or $rc -eq 3) { Write-Host "command> $(_tirith_escape_preview $line)" if (-not [string]::IsNullOrWhiteSpace($output)) { Write-Host $output } } elseif ($rc -eq 1) { Write-Host "command> $(_tirith_escape_preview $line)" if (-not [string]::IsNullOrWhiteSpace($output)) { Write-Host $output } } else { # Unexpected rc: warn + execute (fail-open to avoid terminal breakage) if (-not [string]::IsNullOrWhiteSpace($output)) { Write-Host $output } Write-Host "tirith: unexpected exit code $rc - running unprotected" if (-not [string]::IsNullOrWhiteSpace($approvalPath)) { Remove-Item $approvalPath.Trim() -Force -ErrorAction SilentlyContinue } if (-not [string]::IsNullOrWhiteSpace($warnAckPath)) { Remove-Item $warnAckPath.Trim() -Force -ErrorAction SilentlyContinue } [Microsoft.PowerShell.PSConsoleReadLine]::AcceptLine() return } # Approval workflow: runs for ALL exit codes (0, 1, 2, 3). # For rc=1 (block), approval gives user a chance to override. if (-not [string]::IsNullOrWhiteSpace($approvalPath)) { _tirith_parse_approval $approvalPath.Trim() if ($script:_tirith_ap_required -eq "yes") { Write-Host "tirith: approval required for $($script:_tirith_ap_rule)" if (-not [string]::IsNullOrWhiteSpace($script:_tirith_ap_desc)) { Write-Host " $($script:_tirith_ap_desc)" } if ($script:_tirith_ap_timeout -gt 0) { $response = _tirith_read_with_timeout -TimeoutSecs $script:_tirith_ap_timeout -Prompt "Approve? ($($script:_tirith_ap_timeout) sec timeout) [y/N] " } else { Write-Host -NoNewline "Approve? [y/N] " $response = Read-Host } if ($response -match '^[yY]') { # Approved: fall through to execute } else { switch ($script:_tirith_ap_fallback) { "allow" { Write-Host "tirith: approval not granted - fallback: allow" } "warn" { Write-Host "tirith: approval not granted - fallback: warn" } default { Write-Host "tirith: approval not granted - fallback: block" if (-not [string]::IsNullOrWhiteSpace($warnAckPath)) { Remove-Item $warnAckPath.Trim() -Force -ErrorAction SilentlyContinue } [Microsoft.PowerShell.PSConsoleReadLine]::RevertLine() return } } } } elseif ($rc -eq 1) { # Approval not required but command was blocked: honor block if (-not [string]::IsNullOrWhiteSpace($warnAckPath)) { Remove-Item $warnAckPath.Trim() -Force -ErrorAction SilentlyContinue } [Microsoft.PowerShell.PSConsoleReadLine]::RevertLine() return } } elseif ($rc -eq 1) { # No approval file: honor block [Microsoft.PowerShell.PSConsoleReadLine]::RevertLine() return } # Warn-ack workflow (exit code 3): strict_warn requires explicit acknowledgement if ($rc -eq 3 -and -not [string]::IsNullOrWhiteSpace($warnAckPath)) { _tirith_parse_warn_ack $warnAckPath.Trim() Write-Host -NoNewline "tirith: proceed with $($script:_tirith_wa_findings) warning(s)? [y/N] " $response = Read-Host if ($response -match '^[yY]') { # Acknowledged: fall through to execute } else { Write-Host "tirith: warnings not acknowledged - command blocked" [Microsoft.PowerShell.PSConsoleReadLine]::RevertLine() return } } elseif (-not [string]::IsNullOrWhiteSpace($warnAckPath)) { Remove-Item $warnAckPath.Trim() -Force -ErrorAction SilentlyContinue } [Microsoft.PowerShell.PSConsoleReadLine]::AcceptLine() } # Override Ctrl+V for paste interception Set-PSReadLineKeyHandler -Key Ctrl+v -ScriptBlock { # Get clipboard content $pasted = Get-Clipboard -ErrorAction SilentlyContinue if ([string]::IsNullOrEmpty($pasted)) { return } # Check with tirith paste, use temp file to prevent output leakage $tmpfile = [System.IO.Path]::GetTempFileName() $prevHook = $env:_TIRITH_HOOK $env:_TIRITH_HOOK = '1' try { $pasted | & tirith paste --shell powershell --interactive > $tmpfile 2>&1 $rc = $LASTEXITCODE } finally { if ($null -eq $prevHook) { Remove-Item Env:\_TIRITH_HOOK -ErrorAction SilentlyContinue } else { $env:_TIRITH_HOOK = $prevHook } } $output = Get-Content $tmpfile -Raw -ErrorAction SilentlyContinue Remove-Item $tmpfile -Force -ErrorAction SilentlyContinue if ($rc -eq 0) { # Allow: fall through to insert } elseif ($rc -eq 2) { if (-not [string]::IsNullOrWhiteSpace($output)) { Write-Host $output } # Warn: fall through to insert } else { # Block or unexpected: discard paste Write-Host "paste> $(_tirith_escape_preview $pasted)" if (-not [string]::IsNullOrWhiteSpace($output)) { Write-Host $output } if ($rc -ne 1) { Write-Host "tirith: unexpected exit code $rc - paste blocked for safety" } return } [Microsoft.PowerShell.PSConsoleReadLine]::Insert($pasted) } # TIRITH_STATUS: a small public contract a user can reference in their prompt # function to surface tirith's live protection level (see # docs/prompt-status.md). tirith prints NOTHING per-prompt — it only sets the # variable; wiring it into a prompt is opt-in. The PowerShell hook overrides # the Enter key handler, which can revert a blocked command, so its protection # level is `blocks`; there is no runtime-degrade path. # # Set as a session-scoped `$global:` variable, deliberately NOT `$env:`: a # `prompt` function runs in THIS interactive session and reads a `$global:` # variable fine, whereas an `$env:` variable is inherited by every child # process — and a non-interactive child has no tirith protection, so an # inherited status would misrepresent it. The hook above already returned # early for a non-interactive session, so this only runs interactively. $global:TIRITH_STATUS = 'blocks' # ── tirith output wrap (M7 ch1) ───────────────────────────────────────────── # Opt-in output-direction wrapper. Commented out by default in this embedded # hook copy; `tirith output wrap on` writes an active copy of the function # into the user's shell-profile separately. This block is kept here as the # canonical source so a user reading the hook understands the surface area. # # Scope honesty: this wraps INDIVIDUAL commands invoked via `tirith-out # `. It does NOT intercept output from anything run outside the wrapper. # # function tirith-output-guard-wrap { # param([Parameter(ValueFromRemainingArguments=$true)]$Args) # if ($Args.Count -eq 0) { # Write-Error 'tirith-output-guard-wrap: usage: tirith-out [args...]' # return # } # & $Args[0] $Args[1..($Args.Count-1)] 2>&1 | & tirith view --max-bytes 16777216 - # } # Set-Alias tirith-out tirith-output-guard-wrap