tirith 0.4.0

Terminal security - catches homograph attacks, pipe-to-shell, ANSI injection
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
# 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
}

# Resolve the trusted executable once while the interactive hook is loaded.
# Repository-local PATH changes made by a later command must not redirect the
# security hook into an attacker-controlled `tirith` shim.
$tirithCommand = Get-Command tirith -CommandType Application -ErrorAction SilentlyContinue |
    Select-Object -First 1
if ($null -eq $tirithCommand -or [string]::IsNullOrWhiteSpace($tirithCommand.Source)) {
    Write-Host 'tirith: executable not found; PowerShell hooks disabled' -ForegroundColor Yellow
    $global:TIRITH_STATUS = 'off'
    return
}
$global:_TIRITH_BIN = [System.IO.Path]::GetFullPath($tirithCommand.Source)

# 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
# <state-dir>/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 {
        param([string]$TirithBin)
        & $TirithBin env snapshot 2>$null 1>$null
    } -ArgumentList $global:_TIRITH_BIN | 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 = & $global:_TIRITH_BIN 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 | & $global:_TIRITH_BIN 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
# <cmd>`. 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 <cmd> [args...]'
#         return
#     }
#     & $Args[0] $Args[1..($Args.Count-1)] 2>&1 | & tirith view --max-bytes 16777216 -
# }
# Set-Alias tirith-out tirith-output-guard-wrap