← Files NightshiftARCHIVED FILE
hooks/windows/hardhat.ps1
51.8 KB · Oct 2, 2026 · 00:30 UTC
param(
[Parameter(Mandatory = $true)]
[ValidateSet('claude', 'codex')]
[string]$HostName,
[Parameter(ValueFromPipeline = $true)]
[AllowEmptyString()]
[string]$HookJson = ''
)
Set-StrictMode -Version 2.0
$ErrorActionPreference = 'Stop'
if (Test-Path Variable:PSNativeCommandUseErrorActionPreference) {
$PSNativeCommandUseErrorActionPreference = $false
}
$pluginRoot = Resolve-Path (Join-Path $PSScriptRoot '../..')
Import-Module (Join-Path $pluginRoot 'lib/Nightshift.psm1') -Force -DisableNameChecking
$script:cwd = ''
$script:ns = ''
function Write-Deny {
param([Parameter(Mandatory = $true)][string]$Reason)
if ((Test-Path Variable:workspace) -and -not [string]::IsNullOrEmpty($workspace)) {
$Reason = Expand-NSInjectedPaths $workspace $Reason
}
$output = @{
hookSpecificOutput = @{
hookEventName = 'PreToolUse'
permissionDecision = 'deny'
permissionDecisionReason = $Reason
}
}
[Console]::Out.WriteLine(($output | ConvertTo-Json -Compress -Depth 5))
exit 0
}
function Get-PropertyValue {
param(
[AllowNull()][object]$Object,
[Parameter(Mandatory = $true)][string]$Name,
[AllowNull()][object]$Default = ''
)
if ($null -eq $Object) {
return $Default
}
$property = $Object.PSObject.Properties[$Name]
if ($null -eq $property -or $null -eq $property.Value) {
return $Default
}
return $property.Value
}
function Remove-NSCommitMessage {
param([AllowEmptyString()][string]$Command)
$quotedPattern = @'
(?is)(?<!\S)(?<option>-m|--message)(?<separator>=|\s*)(?<value>'[^']*'|"(?:\\.|[^"])*")
'@
$quoted = [Text.RegularExpressions.Regex]::new($quotedPattern)
$Command = $quoted.Replace($Command, [Text.RegularExpressions.MatchEvaluator]{
param($match)
$value = $match.Groups['value'].Value
$separator = $match.Groups['separator'].Value
if ([string]::IsNullOrEmpty($separator) -and $value[0] -notin @("'", '"')) {
return $match.Value
}
if ($value[0] -eq '"' -and ($value.Contains('$(') -or $value.Contains('`'))) {
return $match.Value
}
return $match.Groups['option'].Value + $separator + 'MSG'
})
$plainPattern = @'
(?is)(?<!\S)(?<option>-m|--message)(?<separator>=|\s+)(?<value>[^\s'"]+)
'@
$plain = [Text.RegularExpressions.Regex]::new($plainPattern)
return $plain.Replace($Command, '${option}${separator}MSG')
}
function Get-NSNestedStrings {
param([AllowNull()][object]$Value)
if ($null -eq $Value) {
return
}
if ($Value -is [string]) {
$Value
return
}
if ($Value -is [Collections.IEnumerable] -and $Value -isnot [Management.Automation.PSCustomObject]) {
foreach ($entry in $Value) {
Get-NSNestedStrings $entry
}
return
}
foreach ($property in $Value.PSObject.Properties) {
Get-NSNestedStrings $property.Value
}
}
function Get-NSPayloadTargets {
param(
[AllowNull()][object]$ToolInput,
[AllowEmptyString()][string]$ToolName,
[AllowEmptyString()][string]$Command
)
if ($ToolName -in @('Bash', 'PowerShell')) {
Remove-NSCommitMessage $Command
return
}
if ($ToolName -eq 'apply_patch') {
foreach ($line in ($Command -split "`r?`n")) {
if ($line -match '^\*\*\* (?:Add|Update|Delete) File:\s*(.+)$' -or $line -match '^\*\*\* Move to:\s*(.+)$') {
$Matches[1]
}
}
return
}
if ($null -eq $ToolInput) {
return
}
$pathKey = '(?i)((^|_)(path|filepath|file|filename|directory|dir|uri|name)$|^(target|destination|dest|source|src)$)'
$commandKey = '(?i)(^|_)(command|cmd|script)$'
function Walk-Input {
param([AllowNull()][object]$Value)
if ($null -eq $Value -or $Value -is [string] -or $Value -is [ValueType]) {
return
}
if ($Value -is [Collections.IEnumerable] -and $Value -isnot [Management.Automation.PSCustomObject]) {
foreach ($entry in $Value) {
Walk-Input $entry
}
return
}
$directories = @()
$names = @()
foreach ($property in $Value.PSObject.Properties) {
$strings = @(Get-NSNestedStrings $property.Value)
if ($property.Name -match $commandKey) {
foreach ($string in $strings) {
Remove-NSCommitMessage $string
}
}
elseif ($property.Name -match $pathKey) {
$strings
}
if ($property.Name -match '^(?i:directory|dir)$') {
$directories += $strings
}
if ($property.Name -match '^(?i:name|filename|file)$') {
$names += $strings
}
Walk-Input $property.Value
}
foreach ($directory in $directories) {
foreach ($name in $names) {
Join-Path $directory $name
}
}
}
Walk-Input $ToolInput
}
function Test-NSRulesTarget {
param([AllowEmptyString()][string]$Target)
$normalized = $Target.Replace('\', '/')
return $normalized -match '(?i)\.nightshift/rules\.json|nightshift-rules\.json' `
-or ($normalized -match '(?i)\.nightshift' -and $normalized -match '(?i)rules\.json')
}
function Resolve-NSFollowSymlink {
param([AllowEmptyString()][string]$Path)
if ([string]::IsNullOrWhiteSpace($Path) -or -not (Test-Path -LiteralPath $Path)) {
return $null
}
$item = Get-Item -LiteralPath $Path -Force
$raw = $null
if ($item.PSObject.Properties['LinkType'] -and $item.LinkType) {
$raw = $item.Target
}
elseif ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) {
$raw = $item.Target
}
if ($null -eq $raw) {
return $null
}
$dest = if ($raw -is [System.Array]) { [string]$raw[0] } else { [string]$raw }
if ([string]::IsNullOrEmpty($dest)) {
return $null
}
if (-not [IO.Path]::IsPathRooted($dest)) {
$parent = Split-Path -Parent $Path
if ([string]::IsNullOrEmpty($parent)) {
return Resolve-NSWriteTarget $dest
}
$dest = Join-Path $parent $dest
}
return Resolve-NSWriteTarget $dest
}
function Test-NSWriteTargetReachesRules {
param([AllowEmptyString()][string]$Target)
if ([string]::IsNullOrWhiteSpace($Target) -or [string]::IsNullOrEmpty($script:ns)) {
return $false
}
$rules = Resolve-NSWriteTarget (Get-NSLayoutPath $script:ns 'rules')
if ($null -eq $rules) {
return $false
}
$canon = Resolve-NSWriteTarget $Target
if ($null -eq $canon) {
return $false
}
if ($canon -ceq $rules) {
return $true
}
$hop = Resolve-NSFollowSymlink $canon
if ($null -ne $hop -and $hop -ceq $rules) {
return $true
}
$lex = $Target.Replace('"', '').Replace("'", '').Replace('\', '/')
if (-not [IO.Path]::IsPathRooted($lex)) {
$base = if (-not [string]::IsNullOrEmpty($script:cwd)) { $script:cwd } else { Split-Path -Parent $script:ns }
if ([string]::IsNullOrEmpty($base)) {
return $false
}
$lex = (Join-Path $base $lex).Replace('\', '/')
}
$acc = ''
foreach ($part in ($lex.TrimStart('/') -split '/')) {
if ([string]::IsNullOrEmpty($part) -or $part -eq '.') {
continue
}
if ($part -eq '..') {
$acc = if ([string]::IsNullOrEmpty($acc) -or $acc -eq '/') { '/' } else { $acc.Substring(0, $acc.LastIndexOf('/')) }
continue
}
$acc = if ([string]::IsNullOrEmpty($acc) -or $acc -eq '/') { "/$part" } else { "$acc/$part" }
$hop = Resolve-NSFollowSymlink ($acc.Replace('/', [IO.Path]::DirectorySeparatorChar))
if ($null -ne $hop -and $hop -ceq $rules) {
return $true
}
}
return $false
}
function Test-NSRealParkingLot {
param([AllowEmptyString()][string]$Target)
if ([string]::IsNullOrWhiteSpace($Target)) {
return $false
}
if (Test-NSWriteTargetReachesRules $Target) {
return $false
}
$parking = Resolve-NSWriteTarget (Get-NSLayoutPath $script:ns 'parking-lot')
$canon = Resolve-NSWriteTarget $Target
return ($null -ne $parking -and $null -ne $canon -and $canon -ceq $parking)
}
function Get-NSLiteralAppendTarget {
param([AllowEmptyString()][string]$Command)
if ([string]::IsNullOrEmpty($Command)) {
return $null
}
$s = $Command
$n = $s.Length
$i = 0
$q = $null
$appends = 0
$extraRedir = 0
$extras = 0
$target = ''
$collecting = $false
$word = ''
while ($i -lt $n) {
$c = $s[$i]
$next = if ($i + 1 -lt $n) { $s[$i + 1] } else { [char]0 }
if ($null -ne $q) {
if ($c -eq '\' -and $q -eq '"') {
$i += 2
continue
}
if ($c -eq $q) {
$q = $null
$i += 1
continue
}
if ($q -eq '"' -and ($c -eq '$' -or $c -eq '`')) {
return $null
}
$i += 1
continue
}
if ($c -eq "'" -or $c -eq '"') {
if ($collecting) {
$q = $c
$i += 1
while ($i -lt $n -and $s[$i] -ne $q) {
if ($q -eq '"' -and ($s[$i] -eq '$' -or $s[$i] -eq '`')) {
return $null
}
$word += $s[$i]
$i += 1
}
if ($i -ge $n) {
return $null
}
$target = $word
$collecting = $false
$word = ''
$q = $null
$i += 1
continue
}
$q = $c
$i += 1
continue
}
if ($c -eq '$' -or $c -eq '`' -or $c -eq '(') {
return $null
}
if ($c -eq '>' -and $next -eq '>') {
$appends += 1
$collecting = $true
$word = ''
$i += 2
while ($i -lt $n -and ($s[$i] -eq ' ' -or $s[$i] -eq "`t")) {
$i += 1
}
continue
}
if ($c -eq '>') {
$extraRedir += 1
$i += 1
continue
}
if ($c -eq '<' -and $next -eq '<') {
$collecting = $false
$i += 2
continue
}
if ($c -eq '<') {
$extraRedir += 1
$i += 1
continue
}
if ($c -eq '|' -or $c -eq ';') {
$extras += 1
$collecting = $false
$i += 1
continue
}
if ($c -eq '&') {
$extras += 1
$collecting = $false
$i += $(if ($next -eq '&') { 2 } else { 1 })
continue
}
if ($collecting) {
if ($c -eq ' ' -or $c -eq "`t" -or $c -eq "`n") {
if (-not [string]::IsNullOrEmpty($word)) {
$target = $word
$collecting = $false
$word = ''
}
$i += 1
continue
}
$word += $c
$i += 1
continue
}
$i += 1
}
if ($null -ne $q) {
return $null
}
if ($appends -ne 1 -or $extraRedir -ne 0 -or $extras -ne 0) {
return $null
}
if ([string]::IsNullOrEmpty($target)) {
$target = $word
}
if ([string]::IsNullOrEmpty($target) -or $target -match '[*?[]') {
return $null
}
$trimmed = $s.TrimStart()
$first = ($trimmed -split '\s+', 2)[0]
if ([string]::IsNullOrEmpty($first)) {
return $null
}
if ($first -notin @('echo', 'printf', 'cat') -and $first[0] -notin @([char]"'", [char]'"')) {
return $null
}
return $target
}
function Test-NSInertParkingLotWrite {
param(
[AllowEmptyString()][string]$ToolName,
[AllowNull()][object]$ToolInput,
[AllowEmptyString()][string]$Command
)
if ($ToolName -in @('Bash', 'PowerShell', 'Shell')) {
$appendTarget = Get-NSLiteralAppendTarget $Command
return (Test-NSRealParkingLot $appendTarget)
}
$paths = @(Get-NSPayloadTargets $ToolInput $ToolName $Command)
if ($paths.Count -ne 1) {
return $false
}
return Test-NSRealParkingLot ([string]$paths[0])
}
function Test-NSLeaseTarget {
param([AllowEmptyString()][string]$Target)
$normalized = $Target.Replace('\', '/').Replace('"', '').Replace("'", '')
if ($normalized -match '(?i)(^|/)(\.shift-lease|\.mutex-scope)($|[^A-Za-z0-9_-])|(^|/)\.lease-lock\.d($|/)') {
return $true
}
$nightshiftContext = Test-NSNightshiftDirContext $normalized
if ($nightshiftContext -and
$normalized -match '(?i)\.nightshift/(?:\.\*|\*|\?|\[|\{|\$|`)') {
return $true
}
if ($nightshiftContext -and $normalized -match '(?i)\.(shift|lease|mutex)-(?:\*|\?|\[|\{|\$|`)') {
return $true
}
# A glob across the runtime directory reaches the lease as surely as one across the root.
$runRel = Get-NSControlDirRelative
if ($nightshiftContext -and $runRel.Length -gt 0 -and
$normalized -match ('(?i)' + [regex]::Escape($runRel) + '/(?:\.\*|\*|\.\?)')) {
return $true
}
if ($normalized -match '(?i)(^|[;&|()\s])(rm|rmdir|unlink|mv|Remove-Item|Move-Item|Rename-Item)\s+([^;&|\r\n]*\s+)?(\./)?\.nightshift/?([;&|()\s]|$)') {
return $true
}
if ($normalized -match '(?i)(^|[;&|\s])find(\s|$)' -and $normalized -match '(?i)\.nightshift' `
-and $normalized -match '(?i)(-delete|-exec)') {
return $true
}
return $false
}
function Test-NSNightshiftDirContext {
param([AllowEmptyString()][string]$Normalized)
# `cd .nightshift && unlink .shift-armed` has no slash after the directory name.
if ($Normalized -match '(?i)\.nightshift([/\s;&]|$)') {
return $true
}
if ($Normalized -match '(?i)(^|[;&|()\s])(cd|pushd|Set-Location)(?:\s+-LiteralPath)?\s+[''"]?\.nightshift\b') {
return $true
}
$cwdValue = [string]$script:cwd
$nsValue = [string]$script:ns
if (-not [string]::IsNullOrEmpty($cwdValue) -and -not [string]::IsNullOrEmpty($nsValue)) {
$cwdNorm = $cwdValue.Replace('\', '/').TrimEnd('/')
$nsNorm = $nsValue.Replace('\', '/').TrimEnd('/')
if ($cwdNorm -eq $nsNorm -or $cwdNorm.StartsWith("$nsNorm/")) {
return $true
}
}
return $false
}
# The shift policy, the remembered defaults and the derived deadline are control files too:
# tonight's authority is written before arming, so an armed agent that could rewrite it could
# widen its own permissions. A delete or move of the directory the layout keeps the runtime's
# control files in takes them all, so it is refused the same way. Regex is a pre-filter; a write
# is a hit only when the target's canonical absolute path is the control file's path in this layout.
function Test-NSControlPrefilter {
param([AllowEmptyString()][string]$Target)
if ($Target -match '(?i)(STOP|\.shift-armed|\.ended|\.shift-session|\.shift-worker|work-target|work-mode|shift-policy\.json|shift-defaults\.json|deadline|punch-list\.md)') {
return $true
}
$runRel = Get-NSControlDirRelative
return ($runRel.Length -gt 0 -and (Test-NSControlDeleteVerb $Target) -and
$Target -match ('(?i)(^|[/\s;&|()])' + [regex]::Escape($runRel) + '/?([\s;&|()]|$)'))
}
# The layout key of a control file, from its name.
function Get-NSControlKey {
param([AllowEmptyString()][string]$Name)
switch -CaseSensitive ($Name) {
'STOP' { return 'stop' }
'.shift-armed' { return 'armed' }
'.ended' { return 'ended' }
'.shift-session' { return 'session' }
'.shift-worker' { return 'worker' }
'work-target' { return 'work-target' }
'work-mode' { return 'work-mode' }
'shift-policy.json' { return 'shift-policy' }
'shift-defaults.json' { return 'shift-defaults' }
'deadline' { return 'deadline' }
'punch-list.md' { return 'punch-list' }
}
return ''
}
# The runtime directory of this layout (run in layout 2), relative to the state directory; empty in
# a layout that keeps its control files at the top.
function Get-NSControlDirRelative {
if ([string]::IsNullOrEmpty($script:ns)) { return '' }
return (Get-NSLayoutRelativePath $script:ns 'run')
}
function Test-NSControlDeleteVerb {
param([AllowEmptyString()][string]$Target)
return $Target -match '(?i)(^|[;&|()\s])(rm|rmdir|unlink|mv|Remove-Item|Move-Item|Rename-Item)([\s]|$)'
}
function Test-NSControlBareName {
param([AllowEmptyString()][string]$Token)
return $Token -match '(?i)^(\./)?(STOP|\.shift-armed|\.ended|\.shift-session|\.shift-worker|work-target|work-mode|shift-policy\.json|shift-defaults\.json|deadline|punch-list\.md)$'
}
# Physical directory path, including symlink and junction ancestors. Matches POSIX cd -P.
function Resolve-NSPhysicalDirectory {
param([Parameter(Mandatory = $true)][string]$Path)
$full = [IO.Path]::GetFullPath($Path)
if (-not (Test-Path -LiteralPath $full -PathType Container)) {
throw 'not a directory'
}
if (Test-Path -LiteralPath '/bin/pwd' -PathType Leaf) {
$here = Get-Location
try {
Set-Location -LiteralPath $full
$physical = & /bin/pwd -P
if (-not [string]::IsNullOrWhiteSpace($physical)) {
return [string]$physical
}
}
finally {
Set-Location $here
}
}
$root = [IO.Path]::GetPathRoot($full)
$relative = $full.Substring($root.Length).Trim([char]'\', [char]'/')
if ($root -eq '/' -or $root -eq '\') {
$acc = '/'
}
else {
$acc = $root
}
if (-not [string]::IsNullOrEmpty($relative)) {
foreach ($part in ($relative -split '[\\/]+')) {
if ([string]::IsNullOrEmpty($part) -or $part -eq '.') {
continue
}
$next = if ($acc -eq '/') { "/$part" } else { Join-Path $acc $part }
$item = Get-Item -LiteralPath $next -Force
$target = $null
if ($item.PSObject.Properties['LinkType'] -and $item.LinkType) {
$raw = $item.Target
$target = if ($raw -is [System.Array]) { [string]$raw[0] } else { [string]$raw }
}
elseif ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) {
$raw = $item.Target
$target = if ($raw -is [System.Array]) { [string]$raw[0] } else { [string]$raw }
}
if (-not [string]::IsNullOrEmpty($target)) {
if (-not [IO.Path]::IsPathRooted($target)) {
$target = Join-Path (Split-Path -Parent $next) $target
}
$acc = [IO.Path]::GetFullPath($target)
}
else {
$acc = $item.FullName
}
}
}
return [IO.Path]::GetFullPath($acc)
}
function Resolve-NSWriteTarget {
param([AllowEmptyString()][string]$Target)
$normalized = $Target.Replace('"', '').Replace("'", '')
if ([string]::IsNullOrWhiteSpace($normalized)) {
return $null
}
if (-not [IO.Path]::IsPathRooted($normalized)) {
$base = $null
if (-not [string]::IsNullOrEmpty($script:cwd)) {
$base = $script:cwd
}
elseif (-not [string]::IsNullOrEmpty($script:ns)) {
$base = Split-Path -Parent $script:ns
}
if ([string]::IsNullOrEmpty($base)) {
return $null
}
$normalized = Join-Path $base $normalized
}
$full = [IO.Path]::GetFullPath($normalized)
$parent = Split-Path -Parent $full
$leaf = Split-Path -Leaf $full
if ([string]::IsNullOrEmpty($parent) -or -not (Test-Path -LiteralPath $parent -PathType Container)) {
return $null
}
try {
$parentCanon = Resolve-NSPhysicalDirectory $parent
}
catch {
try {
$parentCanon = Resolve-NSCanonicalPath $parent
}
catch {
return $null
}
}
return (Join-Path $parentCanon $leaf)
}
function Test-NSControlRewriteHit {
param([AllowEmptyString()][string]$Canon)
if ([string]::IsNullOrEmpty($Canon) -or [string]::IsNullOrEmpty($script:ns)) {
return $false
}
foreach ($key in @(
'stop', 'armed', 'ended', 'session', 'worker',
'work-target', 'work-mode', 'shift-policy', 'shift-defaults', 'deadline'
)) {
$expected = Resolve-NSWriteTarget (Get-NSLayoutPath $script:ns $key)
if ($null -ne $expected -and $Canon -ceq $expected) {
return $true
}
}
return $false
}
function Test-NSControlListHit {
param([AllowEmptyString()][string]$Canon)
if ([string]::IsNullOrEmpty($Canon) -or [string]::IsNullOrEmpty($script:ns)) {
return $false
}
$expected = Resolve-NSWriteTarget (Get-NSLayoutPath $script:ns 'punch-list')
return ($null -ne $expected -and $Canon -ceq $expected)
}
function Test-NSControlDirHit {
param([AllowEmptyString()][string]$Canon)
$runRel = Get-NSControlDirRelative
if ([string]::IsNullOrEmpty($Canon) -or $runRel.Length -eq 0) {
return $false
}
$expected = Resolve-NSWriteTarget (Get-NSLayoutPath $script:ns 'run')
return ($null -ne $expected -and $Canon -ceq $expected)
}
function Test-NSControlCandidateHits {
param(
[AllowEmptyString()][string]$Candidate,
[AllowEmptyString()][string]$Full
)
$leaf = Split-Path -Leaf ($Candidate.Replace('/', [IO.Path]::DirectorySeparatorChar))
$runRel = Get-NSControlDirRelative
$bare = $Candidate
if ($bare.StartsWith('./')) { $bare = $bare.Substring(2) }
$bare = $bare.TrimEnd('/')
$canon = $null
if ((Test-NSControlBareName $Candidate) -and (Test-NSNightshiftDirContext $Full)) {
$canon = Resolve-NSWriteTarget (Get-NSLayoutPath $script:ns (Get-NSControlKey $leaf))
}
elseif ($runRel.Length -gt 0 -and $bare -ceq $runRel -and (Test-NSNightshiftDirContext $Full)) {
$canon = Resolve-NSWriteTarget (Get-NSLayoutPath $script:ns 'run')
}
else {
$canon = Resolve-NSWriteTarget $Candidate
}
if (Test-NSControlRewriteHit $canon) {
return $true
}
return ((Test-NSControlListHit $canon) -or (Test-NSControlDirHit $canon)) -and (Test-NSControlDeleteVerb $Full)
}
function Test-NSControlTarget {
param([AllowEmptyString()][string]$Target)
$normalized = $Target.Replace('\', '/').Replace('"', '').Replace("'", '')
if (-not (Test-NSControlPrefilter $normalized)) {
return $false
}
if ([string]::IsNullOrEmpty($script:ns)) {
return $false
}
if ($normalized -notmatch '[\s;&|<>()]') {
return Test-NSControlCandidateHits $normalized $normalized
}
foreach ($candidate in ($normalized -split '[\s;&|<>()]+')) {
if ([string]::IsNullOrWhiteSpace($candidate)) {
continue
}
if (Test-NSControlCandidateHits $candidate $normalized) {
return $true
}
}
return $false
}
function Convert-NSErePattern {
param([Parameter(Mandatory = $true)][string]$Pattern)
$result = $Pattern.Replace('[[:space:]]', '\s')
$result = $result.Replace('[[:blank:]]', '[ \t]')
$result = $result.Replace('[[:digit:]]', '\d')
$result = $result.Replace('[[:alnum:]]', '[A-Za-z0-9]')
$result = $result.Replace('[[:alpha:]]', '[A-Za-z]')
$result = $result.Replace('[[:lower:]]', '[a-z]')
$result = $result.Replace('[[:upper:]]', '[A-Z]')
$result = $result.Replace('[[:xdigit:]]', '[A-Fa-f0-9]')
if ($result -match '\[:[a-z]+:\]') {
throw 'unmapped POSIX character class'
}
return $result
}
function New-NSRegex {
param(
[Parameter(Mandatory = $true)][string]$Pattern,
[switch]$IgnoreCase
)
$options = [Text.RegularExpressions.RegexOptions]::Multiline
if ($IgnoreCase) {
$options = $options -bor [Text.RegularExpressions.RegexOptions]::IgnoreCase
}
return [Text.RegularExpressions.Regex]::new((Convert-NSErePattern $Pattern), $options)
}
function Test-NSGitVerb {
param(
[AllowEmptyString()][string]$Command,
[Parameter(Mandatory = $true)][string]$Verb
)
return $Command -match '(?i)(^|[^A-Za-z0-9_-])git(?:\.exe)?([^A-Za-z0-9]|$)' `
-and $Command -match ('(?i)(^|[^A-Za-z0-9_-]){0}([^A-Za-z0-9]|$)' -f [regex]::Escape($Verb))
}
function Resolve-NSCommandRepository {
param(
[Parameter(Mandatory = $true)][string]$Command,
[Parameter(Mandatory = $true)][string]$BaseDirectory,
[Parameter(Mandatory = $true)][string]$Workspace
)
$directory = $null
if ($Command -match '(?i)\bgit(?:\.exe)?\s+-C\s+["'']?([^"''\s;&|]+)') {
$directory = $Matches[1]
}
elseif ($Command -match '(?i)^\s*(?:cd|Set-Location)(?:\s+-LiteralPath)?\s+["'']?([^"''\s;&|]+)["'']?\s*(?:&&|;)') {
$directory = $Matches[1]
}
if ($null -ne $directory) {
if (-not [IO.Path]::IsPathRooted($directory)) {
$directory = Join-Path $BaseDirectory $directory
}
$top = Invoke-NSGit $directory @('rev-parse', '--show-toplevel')
if ([string]::IsNullOrWhiteSpace($top)) {
return $null
}
return (Resolve-NSCanonicalPath $top)
}
$top = Invoke-NSGit $BaseDirectory @('rev-parse', '--show-toplevel')
if (-not [string]::IsNullOrWhiteSpace($top)) {
return (Resolve-NSCanonicalPath $top)
}
try {
return Resolve-NSWorkTarget $Workspace
}
catch {
return $null
}
}
function Get-NSProspectiveGitPaths {
param(
[Parameter(Mandatory = $true)][string]$Repository,
[Parameter(Mandatory = $true)][string]$Command,
[Parameter(Mandatory = $true)][string]$Verb,
[switch]$AsDiff
)
$gitDir = Invoke-NSGit $Repository @('rev-parse', '--absolute-git-dir')
if ([string]::IsNullOrWhiteSpace($gitDir)) {
return $null
}
$temp = Join-Path ([IO.Path]::GetTempPath()) ("ns-git-" + [guid]::NewGuid().ToString('N'))
$null = New-Item -ItemType Directory -Path $temp -Force
$index = Join-Path $gitDir 'index'
$copy = Join-Path $temp 'index'
if (Test-Path -LiteralPath $index -PathType Leaf) {
Copy-Item -LiteralPath $index -Destination $copy -Force
}
$env:GIT_INDEX_FILE = $copy
try {
$before = Invoke-NSGitCommand $Repository @('ls-files', '--stage')
$beforePaths = New-Object 'System.Collections.Generic.HashSet[string]' ([StringComparer]::Ordinal)
foreach ($line in $before.Lines) {
$tab = $line.IndexOf("`t")
if ($tab -ge 0) {
[void]$beforePaths.Add($line.Substring($tab + 1).Replace('\', '/'))
}
}
$words = @($Command -split '\s+' | Where-Object { $_ -ne '' })
$start = [array]::IndexOf($words, $Verb)
if ($start -lt 0) { return $null }
$form = 'index'
$paths = New-Object System.Collections.Generic.List[string]
$rest = $false
$skip = $false
foreach ($word in $words[($start + 1)..($words.Length - 1)]) {
if ($skip) { $skip = $false; continue }
if ($rest) {
if ($word -ne 'MSG') { $paths.Add($word) }
continue
}
switch -Regex ($word) {
'^--$' { $rest = $true; break }
'^(--all|-A)$' { $form = $(if ($Verb -eq 'add') { 'all' } else { 'tracked' }); break }
'^(--update|-u)$' { $form = 'tracked'; break }
'^(--include|-i)$' { if ($Verb -eq 'commit') { $form = 'include' }; break }
'^(--only|-o)$' { if ($Verb -eq 'commit') { $form = 'only' }; break }
'^(-m|--message|--file|--author|--date|--cleanup)$' { $skip = $true; break }
'^(--message=|--file=|--author=|--date=|MSG)' { break }
'^(--allow-empty|--allow-empty-message|--no-verify|--no-edit|--quiet|--signoff|-q|-s|-n|--no-gpg-sign|--verbose|-v|--force|-f|--ignore-missing|--refresh|--porcelain)$' { break }
# .NET has no POSIX [[:alpha:]]; clustered shorts like -am must use [A-Za-z].
'^-[A-Za-z]*a[A-Za-z]*$' { if ($Verb -eq 'commit') { $form = 'tracked' }; break }
'^-[A-Za-z]*i[A-Za-z]*$' { if ($Verb -eq 'commit') { $form = 'include' }; break }
'^-[A-Za-z]*o[A-Za-z]*$' { if ($Verb -eq 'commit') { $form = 'only' }; break }
'^-[A-Za-z]*A[A-Za-z]*$' { if ($Verb -eq 'add') { $form = 'all' }; break }
'^-[A-Za-z]*u[A-Za-z]*$' { if ($Verb -eq 'add') { $form = 'tracked' }; break }
'^-' { return $null }
default { if ($word -ne 'MSG') { $paths.Add($word) }; break }
}
}
if ($Verb -eq 'commit' -and $paths.Count -gt 0 -and $form -eq 'index') { $form = 'only' }
$replay = $null
switch ("$Verb-$form") {
'add-all' { $replay = Invoke-NSGitCommand $Repository @('add', '-A') }
'add-tracked' { $replay = Invoke-NSGitCommand $Repository @('add', '-u') }
'add-index' {
if ($paths.Count -gt 0) { $replay = Invoke-NSGitCommand $Repository (@('add', '--') + @($paths)) }
else { $replay = [pscustomobject]@{ ExitCode = 0 } }
}
'commit-index' { $replay = [pscustomobject]@{ ExitCode = 0 } }
'commit-tracked' { $replay = Invoke-NSGitCommand $Repository @('add', '-u') }
'commit-include' {
if ($paths.Count -gt 0) { $replay = Invoke-NSGitCommand $Repository (@('add', '--') + @($paths)) }
else { $replay = [pscustomobject]@{ ExitCode = 0 } }
}
'commit-only' {
$replay = Invoke-NSGitCommand $Repository @('read-tree', 'HEAD')
if ($null -eq $replay -or $replay.ExitCode -ne 0) {
$replay = Invoke-NSGitCommand $Repository @('read-tree', '--empty')
}
if ($null -ne $replay -and $replay.ExitCode -eq 0 -and $paths.Count -gt 0) {
$replay = Invoke-NSGitCommand $Repository (@('add', '--') + @($paths))
}
}
default { return $null }
}
if ($null -eq $replay -or $replay.ExitCode -ne 0) {
return $null
}
if ($AsDiff) {
$diff = Get-NSGitDiffText $Repository @('diff', '--cached', '--no-ext-diff', 'HEAD')
if ($null -eq $diff) {
$diff = Get-NSGitDiffText $Repository @('diff', '--cached', '--no-ext-diff')
}
return $diff
}
$changed = New-Object System.Collections.Generic.List[string]
if ($Verb -eq 'add') {
$after = Invoke-NSGitCommand $Repository @('ls-files', '--stage')
$afterPaths = New-Object 'System.Collections.Generic.HashSet[string]' ([StringComparer]::Ordinal)
$beforeLines = New-Object 'System.Collections.Generic.HashSet[string]' ([StringComparer]::Ordinal)
foreach ($line in $before.Lines) {
[void]$beforeLines.Add($line)
}
foreach ($line in $after.Lines) {
$tab = $line.IndexOf("`t")
if ($tab -ge 0) {
$path = $line.Substring($tab + 1).Replace('\', '/')
[void]$afterPaths.Add($path)
if (-not $beforeLines.Contains($line)) {
$changed.Add($path)
}
}
}
foreach ($path in $beforePaths) {
if (-not $afterPaths.Contains($path)) {
$changed.Add($path)
}
}
return @($changed | Select-Object -Unique)
}
$listed = Get-NSGitDiffText $Repository @('diff', '--cached', '--name-only', '--no-ext-diff', 'HEAD')
if ($null -eq $listed) {
$listed = Get-NSGitDiffText $Repository @('diff', '--cached', '--name-only', '--no-ext-diff')
}
if ([string]::IsNullOrEmpty($listed)) { return @() }
return @($listed -split "(`r`n|`n|`0)" | Where-Object { -not [string]::IsNullOrEmpty($_) })
}
finally {
Remove-Item -LiteralPath $env:GIT_INDEX_FILE -ErrorAction SilentlyContinue
Remove-Item Env:GIT_INDEX_FILE -ErrorAction SilentlyContinue
Remove-Item -LiteralPath $temp -Recurse -Force -ErrorAction SilentlyContinue
}
}
# A simple quoted payload after eval or a shell -c. Hardening only: one pair of
# quotes, no nested parser, not a sandbox.
function Get-NSElevationInner {
param([Parameter(Mandatory = $true)][AllowEmptyString()][string]$Command)
if ($Command -notmatch '(^|[;&|()\s])(eval|[A-Za-z0-9./_-]*sh\s+-[a-zA-Z]*c)\s') {
return ''
}
if ($Command -match "eval\s+'([^']*)'") { return $Matches[1] }
if ($Command -match 'eval\s+"([^"]*)"') { return $Matches[1] }
if ($Command -match "[A-Za-z0-9./_-]*sh\s+-[a-zA-Z]*c\s+'([^']*)'") { return $Matches[1] }
if ($Command -match '[A-Za-z0-9./_-]*sh\s+-[a-zA-Z]*c\s+"([^"]*)"') { return $Matches[1] }
return ''
}
# Elevation gates creating system state, never using what already runs. The category patterns come
# from rules.elevation (or the shipped defaults) through Get-NSElevationPattern, which the
# permission preflight reads too, so the guard and the preflight cannot disagree about what a
# command needs. Whether tonight lifts a deny is Test-NSPolicyAllowed's answer alone - it carries
# the whole precedence table, including the exact-plan binding. A pattern the owner broke denies.
# A simple eval / sh -c quoted payload is matched as well as the outer text; that is hardening,
# not isolation.
function Get-NSElevationDenyReason {
param(
[Parameter(Mandatory = $true)][AllowEmptyString()][string]$Scrubbed,
[Parameter(Mandatory = $true)][string]$Workspace
)
$subject = $Scrubbed
$inner = Get-NSElevationInner $Scrubbed
if (-not [string]::IsNullOrEmpty($inner)) { $subject = "$Scrubbed $inner" }
foreach ($category in @('sudo', 'containers', 'global-packages', 'daemons', 'external-services')) {
$pattern = [string](Get-NSElevationPattern -Workspace $Workspace -Category $category)
if ([string]::IsNullOrEmpty($pattern)) { continue }
try {
$regex = New-NSRegex $pattern
}
catch {
return "BLOCKED: elevation.$category.pattern is not a valid extended regular expression, so the guard it configures cannot run. Fix the pattern in $(Get-NSLayoutName $script:ns 'rules')."
}
if (-not $regex.IsMatch($subject)) { continue }
$status = Test-NSPolicyAllowed -Workspace $Workspace -Category $category -Command $Scrubbed
if ($status -eq 0) { continue }
$reason = "BLOCKED: this command needs the '$category' elevation category, which is denied for this shift."
if ($status -eq 2) {
return "$reason An exact-plan allowance exists but this command is not one of its approved commands."
}
return "$reason The owner allows it in $(Get-NSLayoutName $script:ns 'rules') (elevation.$category.policy) or for one shift in shift-policy.json before arming. Park the item in $(Get-NSLayoutName $script:ns 'parking-lot') as `"needs allowance: $category`" and keep working."
}
return ''
}
function Get-NSCommandDenyReason {
param(
[Parameter(Mandatory = $true)][string]$Command,
[Parameter(Mandatory = $true)][string]$Scrubbed,
[Parameter(Mandatory = $true)][string]$CurrentDirectory,
[Parameter(Mandatory = $true)][string]$Workspace,
[AllowEmptyString()][string]$ProtectedDirectories,
[AllowEmptyString()][string]$ExpectedEmail,
[AllowEmptyString()][string]$NeverCommitPatterns,
[AllowEmptyString()][string]$ForbiddenCommands
)
$forbiddenRegex = $null
$neverRegex = $null
if (-not [string]::IsNullOrEmpty($ForbiddenCommands)) {
try {
$forbiddenRegex = New-NSRegex $ForbiddenCommands
}
catch {
return 'BLOCKED: NIGHTSHIFT_FORBIDDEN_COMMANDS is not a valid extended regular expression, so the guard it configures cannot run. Fix the pattern in your session settings.'
}
}
if (-not [string]::IsNullOrEmpty($NeverCommitPatterns)) {
try {
$neverRegex = New-NSRegex $NeverCommitPatterns -IgnoreCase
}
catch {
return 'BLOCKED: NIGHTSHIFT_NEVER_COMMIT_PATTERNS is not a valid extended regular expression, so the guard it configures cannot run. Fix the pattern in your session settings.'
}
}
$isGitWrite = (Test-NSGitVerb $Scrubbed 'add') -or (Test-NSGitVerb $Scrubbed 'commit') `
-or (Test-NSGitVerb $Scrubbed 'tag') -or (Test-NSGitVerb $Scrubbed 'remote')
$pointsElsewhere = [regex]::IsMatch($Scrubbed, '--git-dir|--work-tree',
[Text.RegularExpressions.RegexOptions]::IgnoreCase)
if ($pointsElsewhere -and [regex]::IsMatch($Scrubbed, '(?i)(^|[^A-Za-z0-9_-])git(?:\.exe)?([^A-Za-z0-9]|$)')) {
if ([regex]::IsMatch($Scrubbed, '(?i)(^|[^A-Za-z0-9_-])add([^A-Za-z0-9]|$)') `
-and -not [string]::IsNullOrEmpty($ProtectedDirectories)) {
return 'BLOCKED: --git-dir/--work-tree point this add somewhere the protected-directory guard cannot verify. Run it from inside the repository instead.'
}
if ([regex]::IsMatch($Scrubbed, '(?i)(^|[^A-Za-z0-9_-])commit([^A-Za-z0-9]|$)') `
-and (-not [string]::IsNullOrEmpty($ExpectedEmail) -or $null -ne $neverRegex)) {
return 'BLOCKED: --git-dir/--work-tree point this commit somewhere the configured commit guards cannot verify. Run the commit from inside the repository instead.'
}
}
if ($isGitWrite -and -not [string]::IsNullOrEmpty($ProtectedDirectories)) {
$verb = $null
if (Test-NSGitVerb $Scrubbed 'add') { $verb = 'add' }
if (Test-NSGitVerb $Scrubbed 'commit') { $verb = 'commit' }
if ($null -ne $verb) {
if ($pointsElsewhere) {
return "BLOCKED: --git-dir/--work-tree point this $verb somewhere the protected-directory guard cannot verify. Run it from inside the repository instead."
}
$repository = Resolve-NSCommandRepository $Command $CurrentDirectory $Workspace
if ([string]::IsNullOrEmpty($repository)) {
return "BLOCKED: cannot tell which Git repository this $verb targets, so the protected-directory guard cannot run. Run it from inside the repository."
}
$paths = Get-NSProspectiveGitPaths $repository $Scrubbed $verb
if ($null -eq $paths) {
return "BLOCKED: this $verb uses a form the protected-directory guard cannot verify. Do not retry a rephrased form."
}
foreach ($path in $paths) {
foreach ($directory in ($ProtectedDirectories -split '[\s|]+')) {
if ([string]::IsNullOrEmpty($directory)) { continue }
$normalized = $path.Replace('\', '/').TrimStart('./')
if ($normalized -eq $directory -or $normalized.StartsWith("$directory/") `
-or $normalized.EndsWith("/$directory") -or $normalized -match "/$([regex]::Escape($directory))/") {
return "BLOCKED: never git add/commit/tag/remote inside '$directory' (a protected directory). Do not retry a rephrased form."
}
}
}
}
else {
$tokens = $Scrubbed -split '\s+'
foreach ($directory in ($ProtectedDirectories -split '[\s|]+')) {
if ([string]::IsNullOrEmpty($directory)) { continue }
foreach ($token in $tokens) {
$clean = $token.Trim("'`"")
$parts = $clean.Replace('\', '/').Split('/')
if ($parts -contains $directory -or $clean -like "*=$directory" -or $clean -like "*=$directory/*") {
return "BLOCKED: never git add/commit/tag/remote inside '$directory' (a protected directory). Do not retry a rephrased form."
}
}
}
}
}
$isCommit = Test-NSGitVerb $Scrubbed 'commit'
if ($isCommit -and (-not [string]::IsNullOrEmpty($ExpectedEmail) -or $null -ne $neverRegex)) {
if ($pointsElsewhere) {
return 'BLOCKED: --git-dir/--work-tree point this commit somewhere the configured commit guards cannot verify. Run the commit from inside the repository instead.'
}
if (-not [string]::IsNullOrEmpty($ExpectedEmail) `
-and $Scrubbed -match '(?i)-c\s*user\.email=|--author|GIT_(AUTHOR|COMMITTER)_EMAIL=|\$env:GIT_(AUTHOR|COMMITTER)_EMAIL') {
return "BLOCKED: this commit overrides the author identity on the command line, which the expected-identity guard cannot verify. Commit with the repository's configured identity."
}
$repository = Resolve-NSCommandRepository $Command $CurrentDirectory $Workspace
if ([string]::IsNullOrEmpty($repository)) {
return 'BLOCKED: cannot tell which Git repository this commit targets, so the configured commit guards cannot run. Run the commit from inside the repository.'
}
if (-not [string]::IsNullOrEmpty($ExpectedEmail)) {
$email = Invoke-NSGit $repository @('config', 'user.email')
if ($email -ne $ExpectedEmail) {
return "BLOCKED: committer identity ('$email') is not the expected '$ExpectedEmail'. Fix git config user.email, then retry."
}
}
if ($null -ne $neverRegex) {
$diff = Get-NSProspectiveGitPaths $repository $Scrubbed 'commit' -AsDiff
if ($null -eq $diff) {
return 'BLOCKED: this commit uses a form the never-commit guard cannot verify. Do not retry a rephrased form.'
}
if ($neverRegex.IsMatch([string]$diff)) {
return 'BLOCKED: the diff this commit would write matches a never-commit pattern. Remove it, restage, retry. Do not weaken the pattern list.'
}
}
}
if ($null -ne $forbiddenRegex -and $forbiddenRegex.IsMatch($Scrubbed)) {
return "BLOCKED: the command matches the owner's forbidden list for this shift. Find another way, or park the task with a note in $(Get-NSLayoutName $script:ns 'parking-lot') and keep working. Do not retry a rephrased form."
}
# forbiddenCommands is the owner's own list and stays independent of the categories: a command
# can clear it and still need an allowance the shift does not hold.
return (Get-NSElevationDenyReason -Scrubbed $Scrubbed -Workspace $Workspace)
}
if ($env:NIGHTSHIFT_HARDHAT_LIB -eq '1') {
return
}
if (Test-NSHookIdle) { exit 0 }
$raw = Get-NSStdinText -Piped $HookJson
if ([string]::IsNullOrWhiteSpace($raw)) {
$raw = Get-NSStdinText -Piped (($input | ForEach-Object { $_ }) -join "`n")
}
$payload = $null
if (-not [string]::IsNullOrWhiteSpace($raw)) {
try {
$payload = $raw | ConvertFrom-Json -ErrorAction Stop
}
catch {
$payload = $null
}
}
$tool = [string](Get-PropertyValue $payload 'tool_name')
$toolInput = Get-PropertyValue $payload 'tool_input' $null
$command = [string](Get-PropertyValue $toolInput 'command')
if ([string]::IsNullOrEmpty($command)) {
$command = [string](Get-PropertyValue $toolInput 'script')
}
$cwd = [string](Get-PropertyValue $payload 'cwd' ([Environment]::CurrentDirectory))
$sessionId = [string](Get-PropertyValue $payload 'session_id')
$transcript = [string](Get-PropertyValue $payload 'transcript_path')
if ($HostName -eq 'claude') {
$hostRoot = if (-not [string]::IsNullOrEmpty($env:CLAUDE_PROJECT_DIR)) { $env:CLAUDE_PROJECT_DIR } else { $cwd }
}
else {
$hostRoot = if (-not [string]::IsNullOrEmpty($env:CODEX_PROJECT_DIR)) { $env:CODEX_PROJECT_DIR } else { $cwd }
}
if ([string]::IsNullOrEmpty($hostRoot)) {
$hostRoot = [Environment]::CurrentDirectory
}
try {
$workspace = Resolve-NSWorkspaceRoot $hostRoot
}
catch {
Write-Deny 'BLOCKED: .nightshift-link is invalid. Open the correct project task or repair the explicit link to an absolute workspace containing .nightshift/.'
}
$stateKind = Get-NSStateKind $workspace
if ($stateKind -in @('malformed', 'future')) {
Write-Deny ('BLOCKED: ' + (Get-NSStateRefuseMessage $stateKind))
}
$ns = Join-Path $workspace '.nightshift'
$script:cwd = $cwd
try {
$script:ns = Resolve-NSPhysicalDirectory $ns
}
catch {
try {
$script:ns = Resolve-NSCanonicalPath $ns
}
catch {
$script:ns = $ns
}
}
$punch = Get-NSLayoutPath $ns 'punch-list'
$armed = Get-NSLayoutPath $ns 'armed'
$ended = Get-NSLayoutPath $ns 'ended'
$endedReal = (Test-Path -LiteralPath $ended -PathType Leaf) -and -not (Test-NSReparsePoint $ended)
$active = Test-NSHardhatActive $ns
$nonce = [string]$env:NIGHTSHIFT_LEASE_NONCE
$generation = [string]$env:NIGHTSHIFT_LEASE_GENERATION
$revival = $env:NIGHTSHIFT_REVIVAL -eq '1'
if (-not $active) {
if ($revival -and (-not (Test-NSLeaseNonce $ns $HostName $nonce $generation) `
-or -not (Test-Path -LiteralPath $armed -PathType Leaf) `
-or -not (Test-Path -LiteralPath $punch -PathType Leaf) `
-or $endedReal)) {
Write-Deny 'BLOCKED: this recovered worker no longer owns an active shift. Do not continue after clock-out.'
}
exit 0
}
if ($null -eq $payload) {
Write-Deny 'BLOCKED: the hook payload is unreadable while a shift is active. Retry after the host can provide valid hook JSON.'
}
$targets = @(Get-NSPayloadTargets $toolInput $tool $command)
foreach ($target in $targets) {
if (Test-NSLeaseTarget ([string]$target)) {
Write-Deny 'BLOCKED: the process lease is runtime-owned, as is its mutex identity. Do not read, delete, or rewrite either file; issue STOP from another session if ownership must be reset.'
}
}
if ($tool -in @('Bash', 'PowerShell')) {
if (Test-NSTrustedShiftControl -Command $command -PluginRoot "$pluginRoot" -Workspace $workspace) {
exit 0
}
}
# Cursor IDE loads the Claude marketplace plugin beside the Cursor host plugin.
if ($HostName -eq 'claude' -and (Test-NSClaudeForeignCursorSurface -NightshiftDir $ns -Transcript $transcript)) {
exit 0
}
$bindingProbe = ($tool -in @('Bash', 'PowerShell')) -and (
$command.Trim() -in @(': nightshift-binding-probe', "`$null = 'nightshift-binding-probe'")
)
$unbound = Resolve-NSShiftUnbound -NightshiftDir $ns -HostName $HostName `
-Nonce $nonce -Generation $generation -Revival $revival -Mode hardhat `
-SessionId $sessionId -BindingProbe $bindingProbe
if ($unbound.Status -eq 'Pass') { exit 0 }
if ($unbound.Status -eq 'Fail') { Write-Deny $unbound.Message }
$hostProcess = Get-NSHostProcess $HostName
$processId = if ($null -eq $hostProcess) { '' } else { [string]$hostProcess.Id }
$processStart = if ($null -eq $hostProcess) { '' } else { [string]$hostProcess.Start }
$bindingTools = @('Bash', 'PowerShell', 'AskQuestion', 'AskUserQuestion', 'request_user_input', 'apply_patch', 'Edit', 'Write', 'MultiEdit', 'NotebookEdit')
$session = Read-NSSession $ns
if ($null -eq $session -and -not [string]::IsNullOrEmpty($sessionId) -and $tool -in $bindingTools) {
$null = Claim-NSSession $ns $sessionId $transcript $processId $processStart $HostName
}
$rebind = Resolve-NSShiftRebind -NightshiftDir $ns -HostName $HostName `
-SessionId $sessionId -Transcript $transcript -ProcessId $processId `
-ProcessStart $processStart -Nonce $nonce -Generation $generation `
-Revival $revival -Mode hardhat
if ($rebind.Status -eq 'Pass') { exit 0 }
if ($rebind.Status -eq 'Fail') { Write-Deny $rebind.Message }
$session = $rebind.Session
if ($bindingProbe) {
if ([string]::IsNullOrEmpty($sessionId) -or $null -eq $session) {
Write-Deny 'BLOCKED: Start could not bind this session atomically. Issue STOP, inspect with Doctor, and retry Start.'
}
if ($session.SessionId -ne $sessionId) {
Write-Deny 'BLOCKED: another session already owns this shift. Reopen that conversation or issue STOP before running Start again.'
}
}
$owned = Resolve-NSShiftAuthorize -NightshiftDir $ns -HostName $HostName `
-SessionId $sessionId -ProcessId $processId -ProcessStart $processStart `
-Nonce $nonce -Generation $generation -Revival $revival -Mode hardhat `
-Session $session
if ($owned.Status -eq 'Pass') { exit 0 }
if ($owned.Status -eq 'Fail') { Write-Deny $owned.Message }
$session = $owned.Session
try {
$toolRules = Get-NSToolRules $workspace ([string]$env:NIGHTSHIFT_TOOL_RULES)
}
catch {
Write-Deny "BLOCKED: toolDeny is not a JSON object of string values, so the tool rules cannot run. Fix $(Get-NSLayoutName $script:ns 'rules') or run Setup again (/nightshift:setup on Claude Code; ask Nightshift to set up on Codex)."
}
if (-not (Test-NSInertParkingLotWrite $tool $toolInput $command)) {
$rulesHit = $false
if ($tool -in @('Bash', 'PowerShell', 'Shell')) {
if (Test-NSRulesTarget $command) { $rulesHit = $true }
$appendTarget = Get-NSLiteralAppendTarget $command
if (-not [string]::IsNullOrEmpty($appendTarget) -and (Test-NSWriteTargetReachesRules $appendTarget)) {
$rulesHit = $true
}
}
foreach ($target in $targets) {
if ((Test-NSRulesTarget ([string]$target)) -or (Test-NSWriteTargetReachesRules ([string]$target))) {
$rulesHit = $true
}
}
if ($rulesHit) {
Write-Deny "BLOCKED: the rules file is the owner's - the night neither reads nor rewrites its own rules. Park the need in $(Get-NSLayoutName $script:ns 'parking-lot') and keep working."
}
}
$controlPassive = $tool -in @(
'Read', 'Grep', 'Glob', 'LS', 'WebFetch', 'WebSearch', 'Task', 'TodoWrite',
'AskQuestion', 'AskUserQuestion', 'request_user_input', 'NotebookRead'
) -or $tool -match '(?i)read'
if (-not $controlPassive) {
foreach ($target in $targets) {
if (Test-NSControlTarget ([string]$target)) {
Write-Deny "BLOCKED: shift control files are owner-owned while the night is armed. Do not delete or forge .shift-armed, .ended, STOP, .shift-session, work-target, work-mode, shift-policy.json, shift-defaults.json, or deadline, and do not delete the punch list. Park the need in $(Get-NSLayoutName $script:ns 'parking-lot') and keep working."
}
}
}
if ($tool -in @('AskQuestion', 'AskUserQuestion', 'request_user_input')) {
$property = if ($null -eq $toolRules) { $null } else { $toolRules.PSObject.Properties[$tool] }
if ($null -eq $property) {
Write-Deny "BLOCKED: toolDeny is missing the required '$tool' entry. Add that exact host tool name to $(Get-NSLayoutName $script:ns 'rules') with a denial message, or use an empty string to allow it; run Setup again (/nightshift:setup on Claude Code; ask Nightshift to set up on Codex) to review the current template."
}
if (-not [string]::IsNullOrEmpty([string]$property.Value)) {
Write-Deny ([string]$property.Value)
}
exit 0
}
if ($null -ne $toolRules -and -not [string]::IsNullOrEmpty($tool)) {
$property = $toolRules.PSObject.Properties[$tool]
if ($null -ne $property -and -not [string]::IsNullOrEmpty([string]$property.Value)) {
Write-Deny ([string]$property.Value)
}
}
if ($tool -in @('Bash', 'PowerShell')) {
$scrubbed = Remove-NSCommitMessage $command
$reason = Get-NSCommandDenyReason -Command $command -Scrubbed $scrubbed `
-CurrentDirectory $cwd -Workspace $workspace `
-ProtectedDirectories (Get-NSRule $workspace 'protectedDirs' ([string]$env:NIGHTSHIFT_PROTECTED_DIRS)) `
-ExpectedEmail (Get-NSRule $workspace 'expectedEmail' ([string]$env:NIGHTSHIFT_EXPECTED_EMAIL)) `
-NeverCommitPatterns (Get-NSRule $workspace 'neverCommitPatterns' ([string]$env:NIGHTSHIFT_NEVER_COMMIT_PATTERNS)) `
-ForbiddenCommands (Get-NSRule $workspace 'forbiddenCommands' ([string]$env:NIGHTSHIFT_FORBIDDEN_COMMANDS))
if (-not [string]::IsNullOrEmpty($reason)) {
Write-Deny $reason
}
}
exit 0
SHA-256: 64ec0a22695a51b09cda14e066c80694f0773305cf5d0278323174cb9f000225