@danmoisan/drm-copilot-mcp 1.0.19 → 1.0.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/out/mcp-server.js +71 -8
- package/package.json +1 -1
- package/resources/claude-customizations/.claude/agents/atomic-executor.md +2 -2
- package/resources/claude-customizations/.claude/hooks/validate-orchestrator-output.ps1 +14 -6
- package/resources/claude-customizations/.claude/lib/model-routing/ModelRouting.psm1 +34 -14
- package/resources/claude-customizations/.claude/lib/orchestrator-state/OrchestratorState.psm1 +30 -18
- package/resources/claude-customizations/.claude/rules/general-code-change.md +1 -1
- package/resources/claude-customizations/.claude/rules/general-unit-test.md +2 -2
- package/resources/claude-customizations/.claude/rules/typescript.md +5 -5
- package/resources/codex-and-agents-customizations/.agents/skills/general-code-change/SKILL.md +1 -1
- package/resources/codex-and-agents-customizations/.agents/skills/general-unit-test/SKILL.md +2 -2
- package/resources/codex-and-agents-customizations/.codex/agents/orchestrator-c1.toml +4 -0
- package/resources/codex-and-agents-customizations/.codex/agents/orchestrator-c2.toml +4 -0
- package/resources/codex-and-agents-customizations/.codex/agents/orchestrator-c3-elevated.toml +4 -0
- package/resources/codex-and-agents-customizations/.codex/agents/orchestrator-c3.toml +4 -0
- package/resources/codex-and-agents-customizations/.codex/agents/orchestrator-c4.toml +4 -0
- package/resources/codex-and-agents-customizations/.codex/agents/orchestrator.toml +4 -0
- package/resources/codex-and-agents-customizations/.codex/config.toml +1 -1
- package/resources/codex-and-agents-customizations/.codex/hooks/check-powershell-test-purity.ps1 +9 -65
- package/resources/codex-and-agents-customizations/.codex/hooks/check-python-test-purity.ps1 +9 -65
- package/resources/codex-and-agents-customizations/.codex/hooks/codex-pretooluse-file-mapping.ps1 +474 -0
- package/resources/codex-and-agents-customizations/.codex/hooks/enforce-checkpoint-monotonic.ps1 +20 -101
- package/resources/codex-and-agents-customizations/.codex/hooks/enforce-completion-consistency.ps1 +16 -3
- package/resources/codex-and-agents-customizations/.codex/hooks/enforce-epic-child-worktree-binding.ps1 +14 -5
- package/resources/codex-and-agents-customizations/.codex/hooks/enforce-epic-planning-only.ps1 +23 -5
- package/resources/codex-and-agents-customizations/.codex/hooks/enforce-evidence-locations.ps1 +30 -52
- package/resources/codex-and-agents-customizations/.codex/hooks/enforce-orchestration-preimplementation-gate.ps1 +28 -27
- package/resources/codex-and-agents-customizations/.codex/hooks/enforce-powershell-batch-budget.ps1 +19 -49
- package/resources/codex-and-agents-customizations/.codex/hooks/enforce-python-batch-budget.ps1 +19 -49
- package/resources/codex-and-agents-customizations/pack-manifests/core.json +1 -0
- package/resources/powershell/PoshQC/settings/pester.runsettings.psd1 +19 -0
- package/resources/codex-and-agents-customizations/.codex/hooks/enforce-pr-author-skill.ps1 +0 -500
package/resources/codex-and-agents-customizations/.codex/hooks/check-powershell-test-purity.ps1
CHANGED
|
@@ -33,6 +33,10 @@
|
|
|
33
33
|
[CmdletBinding()]
|
|
34
34
|
param()
|
|
35
35
|
|
|
36
|
+
# Shared Codex PreToolUse transport: stdin payload parsing and tool_input-to-file
|
|
37
|
+
# mapping for every tool name the ^(apply_patch|Edit|Write)$ matcher admits.
|
|
38
|
+
. (Join-Path $PSScriptRoot 'codex-pretooluse-file-mapping.ps1')
|
|
39
|
+
|
|
36
40
|
function Get-PowerShellTestPurityBlockDecision {
|
|
37
41
|
[CmdletBinding()]
|
|
38
42
|
[OutputType([System.Collections.Specialized.OrderedDictionary])]
|
|
@@ -138,76 +142,16 @@ function Invoke-PowerShellTestPurityDecision {
|
|
|
138
142
|
return Get-PowerShellTestPurityBlockDecision -Reason $reason
|
|
139
143
|
}
|
|
140
144
|
|
|
141
|
-
function ConvertFrom-CodexPowerShellPurityPayload {
|
|
142
|
-
[CmdletBinding()]
|
|
143
|
-
param([Parameter(Mandatory)][string] $PayloadRaw)
|
|
144
|
-
|
|
145
|
-
if ([string]::IsNullOrWhiteSpace($PayloadRaw)) {
|
|
146
|
-
throw 'check-powershell-test-purity hook input is empty.'
|
|
147
|
-
}
|
|
148
|
-
try {
|
|
149
|
-
$payload = $PayloadRaw | ConvertFrom-Json -ErrorAction Stop
|
|
150
|
-
} catch {
|
|
151
|
-
throw "check-powershell-test-purity hook input is malformed JSON: $_"
|
|
152
|
-
}
|
|
153
|
-
if ($payload.PSObject.Properties.Name -notcontains 'tool_input' -or $null -eq $payload.tool_input) {
|
|
154
|
-
throw 'check-powershell-test-purity hook input is missing tool_input.'
|
|
155
|
-
}
|
|
156
|
-
if ([string]$payload.hook_event_name -ne 'PreToolUse' -or [string]$payload.tool_name -ne 'apply_patch') {
|
|
157
|
-
throw 'check-powershell-test-purity requires a PreToolUse apply_patch payload.'
|
|
158
|
-
}
|
|
159
|
-
return $payload
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
function ConvertTo-CodexPowerShellPurityInput {
|
|
163
|
-
[CmdletBinding()]
|
|
164
|
-
[OutputType([object[]])]
|
|
165
|
-
param([Parameter(Mandatory)] $Payload)
|
|
166
|
-
|
|
167
|
-
if ($Payload.tool_input.PSObject.Properties.Name -contains 'file_path') {
|
|
168
|
-
return , $Payload.tool_input
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
$command = [string]$Payload.tool_input.command
|
|
172
|
-
if ([string]::IsNullOrWhiteSpace($command)) {
|
|
173
|
-
throw 'check-powershell-test-purity cannot map tool_input to a file edit.'
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
$fileMatches = [regex]::Matches(
|
|
177
|
-
$command,
|
|
178
|
-
'(?ms)^\*\*\* (?:Add|Update|Delete) File:\s*(?<path>.+?)\r?\n(?<body>.*?)(?=^\*\*\* (?:(?:Add|Update|Delete) File:|End Patch)\s*|\z)'
|
|
179
|
-
)
|
|
180
|
-
if ($fileMatches.Count -eq 0) {
|
|
181
|
-
throw 'check-powershell-test-purity received an unrecognized apply_patch command.'
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
$inputs = [System.Collections.Generic.List[object]]::new()
|
|
185
|
-
foreach ($match in $fileMatches) {
|
|
186
|
-
$filePath = ([string]$match.Groups['path'].Value).Trim()
|
|
187
|
-
$moveMatch = [regex]::Match([string]$match.Groups['body'].Value, '(?m)^\*\*\* Move to:\s*(?<path>.+?)\s*$')
|
|
188
|
-
if ($moveMatch.Success) {
|
|
189
|
-
$filePath = ([string]$moveMatch.Groups['path'].Value).Trim()
|
|
190
|
-
}
|
|
191
|
-
$addedLines = foreach ($line in ([string]$match.Groups['body'].Value -split '\r?\n')) {
|
|
192
|
-
if ($line.StartsWith('+') -and -not $line.StartsWith('+++')) {
|
|
193
|
-
$line.Substring(1)
|
|
194
|
-
}
|
|
195
|
-
}
|
|
196
|
-
$inputs.Add([pscustomobject]@{
|
|
197
|
-
file_path = $filePath
|
|
198
|
-
content = $addedLines -join [Environment]::NewLine
|
|
199
|
-
})
|
|
200
|
-
}
|
|
201
|
-
return $inputs.ToArray()
|
|
202
|
-
}
|
|
203
|
-
|
|
204
145
|
if ($MyInvocation.InvocationName -eq '.') {
|
|
205
146
|
return
|
|
206
147
|
}
|
|
207
148
|
|
|
208
149
|
try {
|
|
209
|
-
|
|
210
|
-
|
|
150
|
+
# Transport and mapping come from the shared module. A well-formed payload
|
|
151
|
+
# that maps to no file edit produces an empty record set, so the loop body
|
|
152
|
+
# never runs and the hook allows silently.
|
|
153
|
+
$payload = ConvertFrom-CodexPreToolUsePayload -PayloadRaw ([Console]::In.ReadToEnd()) -HookName 'check-powershell-test-purity'
|
|
154
|
+
foreach ($toolInput in @(ConvertTo-CodexFileEditInput -Payload $payload)) {
|
|
211
155
|
$toolInputRaw = $toolInput | ConvertTo-Json -Compress -Depth 20
|
|
212
156
|
$decision = Invoke-PowerShellTestPurityDecision -ToolInputRaw $toolInputRaw
|
|
213
157
|
if ($null -ne $decision -and $decision.hookSpecificOutput.permissionDecision -eq 'deny') {
|
|
@@ -30,6 +30,10 @@
|
|
|
30
30
|
[CmdletBinding()]
|
|
31
31
|
param()
|
|
32
32
|
|
|
33
|
+
# Shared Codex PreToolUse transport: stdin payload parsing and tool_input-to-file
|
|
34
|
+
# mapping for every tool name the ^(apply_patch|Edit|Write)$ matcher admits.
|
|
35
|
+
. (Join-Path $PSScriptRoot 'codex-pretooluse-file-mapping.ps1')
|
|
36
|
+
|
|
33
37
|
function Get-PythonTestPurityBlockDecision {
|
|
34
38
|
[CmdletBinding()]
|
|
35
39
|
[OutputType([System.Collections.Specialized.OrderedDictionary])]
|
|
@@ -138,76 +142,16 @@ function Invoke-PythonTestPurityDecision {
|
|
|
138
142
|
return Get-PythonTestPurityBlockDecision -Reason $reason
|
|
139
143
|
}
|
|
140
144
|
|
|
141
|
-
function ConvertFrom-CodexPythonPurityPayload {
|
|
142
|
-
[CmdletBinding()]
|
|
143
|
-
param([Parameter(Mandatory)][string] $PayloadRaw)
|
|
144
|
-
|
|
145
|
-
if ([string]::IsNullOrWhiteSpace($PayloadRaw)) {
|
|
146
|
-
throw 'check-python-test-purity hook input is empty.'
|
|
147
|
-
}
|
|
148
|
-
try {
|
|
149
|
-
$payload = $PayloadRaw | ConvertFrom-Json -ErrorAction Stop
|
|
150
|
-
} catch {
|
|
151
|
-
throw "check-python-test-purity hook input is malformed JSON: $_"
|
|
152
|
-
}
|
|
153
|
-
if ($payload.PSObject.Properties.Name -notcontains 'tool_input' -or $null -eq $payload.tool_input) {
|
|
154
|
-
throw 'check-python-test-purity hook input is missing tool_input.'
|
|
155
|
-
}
|
|
156
|
-
if ([string]$payload.hook_event_name -ne 'PreToolUse' -or [string]$payload.tool_name -ne 'apply_patch') {
|
|
157
|
-
throw 'check-python-test-purity requires a PreToolUse apply_patch payload.'
|
|
158
|
-
}
|
|
159
|
-
return $payload
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
function ConvertTo-CodexPythonPurityInput {
|
|
163
|
-
[CmdletBinding()]
|
|
164
|
-
[OutputType([object[]])]
|
|
165
|
-
param([Parameter(Mandatory)] $Payload)
|
|
166
|
-
|
|
167
|
-
if ($Payload.tool_input.PSObject.Properties.Name -contains 'file_path') {
|
|
168
|
-
return , $Payload.tool_input
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
$command = [string]$Payload.tool_input.command
|
|
172
|
-
if ([string]::IsNullOrWhiteSpace($command)) {
|
|
173
|
-
throw 'check-python-test-purity cannot map tool_input to a file edit.'
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
$fileMatches = [regex]::Matches(
|
|
177
|
-
$command,
|
|
178
|
-
'(?ms)^\*\*\* (?:Add|Update|Delete) File:\s*(?<path>.+?)\r?\n(?<body>.*?)(?=^\*\*\* (?:(?:Add|Update|Delete) File:|End Patch)\s*|\z)'
|
|
179
|
-
)
|
|
180
|
-
if ($fileMatches.Count -eq 0) {
|
|
181
|
-
throw 'check-python-test-purity received an unrecognized apply_patch command.'
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
$inputs = [System.Collections.Generic.List[object]]::new()
|
|
185
|
-
foreach ($match in $fileMatches) {
|
|
186
|
-
$filePath = ([string]$match.Groups['path'].Value).Trim()
|
|
187
|
-
$moveMatch = [regex]::Match([string]$match.Groups['body'].Value, '(?m)^\*\*\* Move to:\s*(?<path>.+?)\s*$')
|
|
188
|
-
if ($moveMatch.Success) {
|
|
189
|
-
$filePath = ([string]$moveMatch.Groups['path'].Value).Trim()
|
|
190
|
-
}
|
|
191
|
-
$addedLines = foreach ($line in ([string]$match.Groups['body'].Value -split '\r?\n')) {
|
|
192
|
-
if ($line.StartsWith('+') -and -not $line.StartsWith('+++')) {
|
|
193
|
-
$line.Substring(1)
|
|
194
|
-
}
|
|
195
|
-
}
|
|
196
|
-
$inputs.Add([pscustomobject]@{
|
|
197
|
-
file_path = $filePath
|
|
198
|
-
content = $addedLines -join [Environment]::NewLine
|
|
199
|
-
})
|
|
200
|
-
}
|
|
201
|
-
return $inputs.ToArray()
|
|
202
|
-
}
|
|
203
|
-
|
|
204
145
|
if ($MyInvocation.InvocationName -eq '.') {
|
|
205
146
|
return
|
|
206
147
|
}
|
|
207
148
|
|
|
208
149
|
try {
|
|
209
|
-
|
|
210
|
-
|
|
150
|
+
# Transport and mapping come from the shared module. A well-formed payload
|
|
151
|
+
# that maps to no file edit produces an empty record set, so the loop body
|
|
152
|
+
# never runs and the hook allows silently.
|
|
153
|
+
$payload = ConvertFrom-CodexPreToolUsePayload -PayloadRaw ([Console]::In.ReadToEnd()) -HookName 'check-python-test-purity'
|
|
154
|
+
foreach ($toolInput in @(ConvertTo-CodexFileEditInput -Payload $payload)) {
|
|
211
155
|
$toolInputRaw = $toolInput | ConvertTo-Json -Compress -Depth 20
|
|
212
156
|
$decision = Invoke-PythonTestPurityDecision -ToolInputRaw $toolInputRaw
|
|
213
157
|
if ($null -ne $decision -and $decision.hookSpecificOutput.permissionDecision -eq 'deny') {
|
package/resources/codex-and-agents-customizations/.codex/hooks/codex-pretooluse-file-mapping.ps1
ADDED
|
@@ -0,0 +1,474 @@
|
|
|
1
|
+
<#
|
|
2
|
+
.SYNOPSIS
|
|
3
|
+
Shared Codex PreToolUse transport parsing and tool_input-to-file-edit mapping.
|
|
4
|
+
|
|
5
|
+
.DESCRIPTION
|
|
6
|
+
Dot-sourced by every hook registered under the .codex/config.toml matcher
|
|
7
|
+
^(apply_patch|Edit|Write)$. It provides the two transport concerns those
|
|
8
|
+
hooks previously duplicated with drift:
|
|
9
|
+
|
|
10
|
+
- ConvertFrom-CodexPreToolUsePayload: validates and parses the single JSON
|
|
11
|
+
object a Codex PreToolUse hook receives on stdin. It throws only for
|
|
12
|
+
genuinely un-processable input, and every message it throws begins with
|
|
13
|
+
the caller-supplied hook name so each handler's stderr names itself.
|
|
14
|
+
|
|
15
|
+
- ConvertTo-CodexFileEditInput: maps a parsed payload to zero or more
|
|
16
|
+
file-edit records for the admitted tool names apply_patch, Edit, and
|
|
17
|
+
Write. Well-formed input that names nothing the caller governs maps to an
|
|
18
|
+
EMPTY array, which callers translate to allow (exit 0, no stdout) rather
|
|
19
|
+
than to a transport failure.
|
|
20
|
+
|
|
21
|
+
Separating these concerns is required because enforce-checkpoint-monotonic.ps1
|
|
22
|
+
and enforce-completion-consistency.ps1 are already near the 500-line cap and
|
|
23
|
+
cannot absorb inline mapping logic.
|
|
24
|
+
|
|
25
|
+
PUBLIC SURFACE. Consuming hooks call exactly the two functions named above
|
|
26
|
+
and nothing else. ConvertTo-CodexAddedLineText, Test-CodexGovernedPath, and
|
|
27
|
+
Resolve-CodexUpdatedFileContent are INTERNAL helpers of
|
|
28
|
+
ConvertTo-CodexFileEditInput; they exist only because the repository's code
|
|
29
|
+
standards require long branching logic to be factored into small, focused
|
|
30
|
+
functions rather than inlined, and no hook calls them directly.
|
|
31
|
+
|
|
32
|
+
This script performs no policy evaluation. Each consuming hook keeps its own
|
|
33
|
+
allow/deny policy functions unchanged and applies them to the records
|
|
34
|
+
returned here.
|
|
35
|
+
|
|
36
|
+
.NOTES
|
|
37
|
+
Compatible with PowerShell 7+.
|
|
38
|
+
|
|
39
|
+
Entrypoint-free by design: this file contains only script-scoped constants
|
|
40
|
+
and function definitions, so dot-sourcing it has no side effects and no
|
|
41
|
+
exit-code semantics. The precedent is enforce-completion-helpers.ps1, which
|
|
42
|
+
is likewise dot-sourced and carries no entrypoint. Consequently this file
|
|
43
|
+
never reads standard input and never reads any legacy Claude environment
|
|
44
|
+
variable; reading the payload is the calling hook's responsibility, and the
|
|
45
|
+
Pester parity suite asserts that no legacy environment read appears here.
|
|
46
|
+
#>
|
|
47
|
+
[CmdletBinding()]
|
|
48
|
+
param()
|
|
49
|
+
|
|
50
|
+
# Tool names the ^(apply_patch|Edit|Write)$ PreToolUse matcher admits. Any other
|
|
51
|
+
# well-formed tool name maps to no records, which the caller treats as allow.
|
|
52
|
+
$script:CodexAdmittedToolNames = @('apply_patch', 'Edit', 'Write')
|
|
53
|
+
|
|
54
|
+
# Splits an apply_patch command into one match per touched file. The lookahead
|
|
55
|
+
# terminates each file body at the next file marker or at '*** End Patch', so a
|
|
56
|
+
# multi-file patch yields one match per file. Lifted verbatim from the
|
|
57
|
+
# per-handler implementations this module replaces.
|
|
58
|
+
$script:CodexApplyPatchFileRegex = '(?ms)^\*\*\* (?<operation>Add|Update|Delete) File:\s*(?<path>.+?)\r?\n(?<body>.*?)(?=^\*\*\* (?:(?:Add|Update|Delete) File:|End Patch)\s*|\z)'
|
|
59
|
+
|
|
60
|
+
# Locates a rename destination inside a single file body.
|
|
61
|
+
$script:CodexApplyPatchMoveRegex = '(?m)^\*\*\* Move to:\s*(?<path>.+?)\s*$'
|
|
62
|
+
|
|
63
|
+
# Splits an apply_patch Update body into hunks on its @@ headers.
|
|
64
|
+
$script:CodexApplyPatchHunkRegex = '(?m)^@@[^\r\n]*\r?\n'
|
|
65
|
+
|
|
66
|
+
function ConvertFrom-CodexPreToolUsePayload {
|
|
67
|
+
<#
|
|
68
|
+
.SYNOPSIS
|
|
69
|
+
Parses and validates one Codex PreToolUse stdin payload.
|
|
70
|
+
|
|
71
|
+
.DESCRIPTION
|
|
72
|
+
Converts the raw JSON string a PreToolUse hook read from stdin into an
|
|
73
|
+
object, throwing only when the input cannot be processed at all. The
|
|
74
|
+
caller converts a throw into exit 2 with the message on stderr.
|
|
75
|
+
|
|
76
|
+
This function deliberately performs NO tool-name assertion. Admission is
|
|
77
|
+
the matcher's job in .codex/config.toml, and narrowing it here is the
|
|
78
|
+
defect that made every Edit and Write payload exit 2. Tool-name routing
|
|
79
|
+
belongs to ConvertTo-CodexFileEditInput, which maps unadmitted names to
|
|
80
|
+
no records so the caller allows.
|
|
81
|
+
|
|
82
|
+
.PARAMETER PayloadRaw
|
|
83
|
+
The raw stdin text. Empty, whitespace-only, and null are all rejected.
|
|
84
|
+
AllowEmptyString is required so the explicit, hook-named error below runs
|
|
85
|
+
instead of a generic parameter-binding failure whose message would not
|
|
86
|
+
name the hook.
|
|
87
|
+
|
|
88
|
+
.PARAMETER HookName
|
|
89
|
+
The calling hook's name. Every thrown message begins with this value so
|
|
90
|
+
a handler that shares this module still reports itself in its stderr.
|
|
91
|
+
|
|
92
|
+
.PARAMETER RequireSessionId
|
|
93
|
+
When present, a missing, empty, or whitespace-only session_id is also
|
|
94
|
+
rejected. Only the batch-budget hooks need this, because they key
|
|
95
|
+
per-session state by session_id.
|
|
96
|
+
|
|
97
|
+
.OUTPUTS
|
|
98
|
+
The parsed payload object.
|
|
99
|
+
|
|
100
|
+
.NOTES
|
|
101
|
+
Throws for exactly four conditions and nothing else: empty or
|
|
102
|
+
whitespace-only input, invalid JSON, missing or null tool_input, and
|
|
103
|
+
(only under -RequireSessionId) a missing or empty session_id.
|
|
104
|
+
#>
|
|
105
|
+
[CmdletBinding()]
|
|
106
|
+
[OutputType([object])]
|
|
107
|
+
param(
|
|
108
|
+
[Parameter(Mandatory)]
|
|
109
|
+
[AllowNull()]
|
|
110
|
+
[AllowEmptyString()]
|
|
111
|
+
[string] $PayloadRaw,
|
|
112
|
+
|
|
113
|
+
[Parameter(Mandatory)]
|
|
114
|
+
[ValidateNotNullOrEmpty()]
|
|
115
|
+
[string] $HookName,
|
|
116
|
+
|
|
117
|
+
[Parameter()]
|
|
118
|
+
[switch] $RequireSessionId
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
if ([string]::IsNullOrWhiteSpace($PayloadRaw)) {
|
|
122
|
+
throw "$HookName hook input is empty."
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
try {
|
|
126
|
+
$payload = $PayloadRaw | ConvertFrom-Json -ErrorAction Stop
|
|
127
|
+
} catch {
|
|
128
|
+
throw "$HookName hook input is malformed JSON: $_"
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
# A JSON literal such as 'null', a bare string, or a number parses without
|
|
132
|
+
# error but carries no tool_input, so both cases fail through one check.
|
|
133
|
+
if ($null -eq $payload -or
|
|
134
|
+
$payload.PSObject.Properties.Name -notcontains 'tool_input' -or
|
|
135
|
+
$null -eq $payload.tool_input) {
|
|
136
|
+
throw "$HookName hook input is missing tool_input."
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if ($RequireSessionId -and [string]::IsNullOrWhiteSpace([string]$payload.session_id)) {
|
|
140
|
+
throw "$HookName hook input is missing session_id."
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return $payload
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function ConvertTo-CodexFileEditInput {
|
|
147
|
+
<#
|
|
148
|
+
.SYNOPSIS
|
|
149
|
+
Maps a parsed Codex PreToolUse payload to zero or more file-edit records.
|
|
150
|
+
|
|
151
|
+
.DESCRIPTION
|
|
152
|
+
Admits the tool names apply_patch, Edit, and Write. Every other
|
|
153
|
+
well-formed tool name, and every admitted tool name whose tool_input
|
|
154
|
+
names no file, maps to an EMPTY array so the caller allows.
|
|
155
|
+
|
|
156
|
+
Each emitted record carries:
|
|
157
|
+
|
|
158
|
+
file_path - the path the operation ultimately affects; for an
|
|
159
|
+
apply_patch rename this is the '*** Move to:'
|
|
160
|
+
destination, matching the pre-fix adapters.
|
|
161
|
+
source_path - the path the operation started from; equal to file_path
|
|
162
|
+
except for a rename.
|
|
163
|
+
operation - Add, Update, Delete, Edit, or Write.
|
|
164
|
+
content, old_string, new_string - whichever fields the operation has.
|
|
165
|
+
|
|
166
|
+
Both path properties are emitted because the consuming policies split on
|
|
167
|
+
this point: enforce-evidence-locations and the two batch-budget hooks
|
|
168
|
+
evaluated BOTH sides of a rename before this refactor, while the purity
|
|
169
|
+
and checkpoint hooks evaluated only the resulting file. Carrying both
|
|
170
|
+
keeps every consuming policy's inputs identical to the pre-fix
|
|
171
|
+
behaviour.
|
|
172
|
+
|
|
173
|
+
Direct-mapped tool_input (anything carrying an explicit file_path) is
|
|
174
|
+
copied property-for-property, because the pre-fix adapters returned the
|
|
175
|
+
raw tool_input object and downstream policies read fields such as
|
|
176
|
+
content, old_string, and new_string from it.
|
|
177
|
+
|
|
178
|
+
.PARAMETER Payload
|
|
179
|
+
The object returned by ConvertFrom-CodexPreToolUsePayload.
|
|
180
|
+
|
|
181
|
+
.PARAMETER ResolveUpdateContent
|
|
182
|
+
When present, an apply_patch Update is reconstructed against the on-disk
|
|
183
|
+
source file so the caller sees complete post-patch content. Only the two
|
|
184
|
+
checkpoint hooks need this, and it applies ONLY to paths matching
|
|
185
|
+
-GovernedPath.
|
|
186
|
+
|
|
187
|
+
.PARAMETER GovernedPath
|
|
188
|
+
The repository-relative path the caller governs, matched with the same
|
|
189
|
+
'(^|/)<path>$' anchoring the checkpoint hooks use. When
|
|
190
|
+
-ResolveUpdateContent is supplied, an Update touching a path outside this
|
|
191
|
+
value yields NO record at all, so a patch that merely happens to touch
|
|
192
|
+
other files allows instead of failing. That closes the latent defect in
|
|
193
|
+
which any unreadable update source made the hook exit 2 even when it
|
|
194
|
+
governed none of the patched files.
|
|
195
|
+
|
|
196
|
+
.OUTPUTS
|
|
197
|
+
An array of file-edit records; empty when nothing maps.
|
|
198
|
+
|
|
199
|
+
.NOTES
|
|
200
|
+
Reconstruction failure for a GOVERNED path yields a record with empty
|
|
201
|
+
content rather than a throw. Empty content is the signal each checkpoint
|
|
202
|
+
policy already treats as an un-validatable checkpoint and fails closed
|
|
203
|
+
on, so a governed reconstruction failure stays a deny and never becomes
|
|
204
|
+
exit 2.
|
|
205
|
+
#>
|
|
206
|
+
[CmdletBinding()]
|
|
207
|
+
[OutputType([object[]])]
|
|
208
|
+
param(
|
|
209
|
+
[Parameter(Mandatory)]
|
|
210
|
+
[AllowNull()]
|
|
211
|
+
$Payload,
|
|
212
|
+
|
|
213
|
+
[Parameter()]
|
|
214
|
+
[switch] $ResolveUpdateContent,
|
|
215
|
+
|
|
216
|
+
[Parameter()]
|
|
217
|
+
[AllowEmptyString()]
|
|
218
|
+
[string] $GovernedPath = ''
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
$records = [System.Collections.Generic.List[object]]::new()
|
|
222
|
+
|
|
223
|
+
if ($null -eq $Payload -or $null -eq $Payload.tool_input) {
|
|
224
|
+
return $records.ToArray()
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
# Admission is by tool name only. An unadmitted name is not an error: the
|
|
228
|
+
# caller allows, because the matcher decides what reaches the hook.
|
|
229
|
+
$toolName = [string]$Payload.tool_name
|
|
230
|
+
if ($script:CodexAdmittedToolNames -notcontains $toolName) {
|
|
231
|
+
return $records.ToArray()
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
$toolInput = $Payload.tool_input
|
|
235
|
+
$toolInputProperties = @($toolInput.PSObject.Properties.Name)
|
|
236
|
+
|
|
237
|
+
# Direct mapping first: any tool_input carrying a usable file_path maps
|
|
238
|
+
# without patch parsing, which is how every pre-fix adapter behaved for
|
|
239
|
+
# Edit-shaped and Write-shaped input regardless of tool name.
|
|
240
|
+
if ($toolInputProperties -contains 'file_path') {
|
|
241
|
+
$filePath = [string]$toolInput.file_path
|
|
242
|
+
if ([string]::IsNullOrWhiteSpace($filePath)) {
|
|
243
|
+
return $records.ToArray()
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
# Copy every supplied field so downstream policies keep reading the same
|
|
247
|
+
# properties they read from the raw tool_input before this refactor.
|
|
248
|
+
$record = [ordered]@{}
|
|
249
|
+
foreach ($property in $toolInput.PSObject.Properties) {
|
|
250
|
+
$record[$property.Name] = $property.Value
|
|
251
|
+
}
|
|
252
|
+
$record['file_path'] = $filePath
|
|
253
|
+
$record['source_path'] = $filePath
|
|
254
|
+
$record['operation'] = $toolName
|
|
255
|
+
|
|
256
|
+
$records.Add([pscustomobject]$record)
|
|
257
|
+
return $records.ToArray()
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
if ($toolInputProperties -notcontains 'command') {
|
|
261
|
+
return $records.ToArray()
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
$command = [string]$toolInput.command
|
|
265
|
+
if ([string]::IsNullOrWhiteSpace($command)) {
|
|
266
|
+
return $records.ToArray()
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
$fileMatches = [regex]::Matches($command, $script:CodexApplyPatchFileRegex)
|
|
270
|
+
if ($fileMatches.Count -eq 0) {
|
|
271
|
+
return $records.ToArray()
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
# Build one record per file the patch touches, resolving rename destinations
|
|
275
|
+
# and per-operation content so each consuming policy receives the same shape
|
|
276
|
+
# its own adapter produced before extraction.
|
|
277
|
+
foreach ($fileMatch in $fileMatches) {
|
|
278
|
+
$sourcePath = ([string]$fileMatch.Groups['path'].Value).Trim()
|
|
279
|
+
$operation = [string]$fileMatch.Groups['operation'].Value
|
|
280
|
+
$body = ([string]$fileMatch.Groups['body'].Value) -replace '\r\n', "`n"
|
|
281
|
+
|
|
282
|
+
$targetPath = $sourcePath
|
|
283
|
+
$moveMatch = [regex]::Match($body, $script:CodexApplyPatchMoveRegex)
|
|
284
|
+
if ($moveMatch.Success) {
|
|
285
|
+
$targetPath = ([string]$moveMatch.Groups['path'].Value).Trim()
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
# Content derivation is per operation. Delete yields empty content, which
|
|
289
|
+
# is the deletion signal the checkpoint policies fail closed on. Add
|
|
290
|
+
# yields the added lines. Update yields the added lines unless the caller
|
|
291
|
+
# asked for on-disk reconstruction.
|
|
292
|
+
$content = ''
|
|
293
|
+
if ($operation -eq 'Add') {
|
|
294
|
+
$content = ConvertTo-CodexAddedLineText -Body $body
|
|
295
|
+
} elseif ($operation -eq 'Update') {
|
|
296
|
+
if (-not $ResolveUpdateContent) {
|
|
297
|
+
$content = ConvertTo-CodexAddedLineText -Body $body
|
|
298
|
+
} else {
|
|
299
|
+
$isGoverned = (Test-CodexGovernedPath -Path $sourcePath -GovernedPath $GovernedPath) -or
|
|
300
|
+
(Test-CodexGovernedPath -Path $targetPath -GovernedPath $GovernedPath)
|
|
301
|
+
if (-not $isGoverned) {
|
|
302
|
+
# Ungoverned update: emit nothing so the caller allows instead
|
|
303
|
+
# of reading an unrelated file it does not govern.
|
|
304
|
+
continue
|
|
305
|
+
}
|
|
306
|
+
$content = Resolve-CodexUpdatedFileContent -SourcePath $sourcePath -Body $body
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
$records.Add([pscustomobject]@{
|
|
311
|
+
file_path = $targetPath
|
|
312
|
+
source_path = $sourcePath
|
|
313
|
+
operation = $operation
|
|
314
|
+
content = $content
|
|
315
|
+
})
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
return $records.ToArray()
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function ConvertTo-CodexAddedLineText {
|
|
322
|
+
<#
|
|
323
|
+
.SYNOPSIS
|
|
324
|
+
INTERNAL. Returns the added ('+') lines of an apply_patch file body.
|
|
325
|
+
|
|
326
|
+
.PARAMETER Body
|
|
327
|
+
The LF-normalized body of one apply_patch file section.
|
|
328
|
+
|
|
329
|
+
.OUTPUTS
|
|
330
|
+
The added lines joined with LF, or an empty string when there are none.
|
|
331
|
+
|
|
332
|
+
.NOTES
|
|
333
|
+
'+++' lines are unified-diff headers, not content, and are excluded. LF
|
|
334
|
+
is the join character because the checkpoint policies compare against
|
|
335
|
+
LF-normalized file text and the purity patterns are newline-agnostic.
|
|
336
|
+
#>
|
|
337
|
+
[CmdletBinding()]
|
|
338
|
+
[OutputType([string])]
|
|
339
|
+
param(
|
|
340
|
+
[Parameter(Mandatory)]
|
|
341
|
+
[AllowEmptyString()]
|
|
342
|
+
[string] $Body
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
# Collect the inserted lines in order, dropping the '+' marker itself.
|
|
346
|
+
$addedLines = foreach ($line in ($Body -split "`n")) {
|
|
347
|
+
if ($line.StartsWith('+') -and -not $line.StartsWith('+++')) {
|
|
348
|
+
$line.Substring(1)
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
return ($addedLines -join "`n")
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function Test-CodexGovernedPath {
|
|
356
|
+
<#
|
|
357
|
+
.SYNOPSIS
|
|
358
|
+
INTERNAL. Returns $true when a patched path is the caller's governed path.
|
|
359
|
+
|
|
360
|
+
.PARAMETER Path
|
|
361
|
+
The path taken from an apply_patch file marker.
|
|
362
|
+
|
|
363
|
+
.PARAMETER GovernedPath
|
|
364
|
+
The repository-relative path the caller governs; empty governs nothing.
|
|
365
|
+
|
|
366
|
+
.OUTPUTS
|
|
367
|
+
$true when the normalized path equals the governed path or ends with it
|
|
368
|
+
on a segment boundary. Uses the same '(^|/)<path>$' anchoring as the
|
|
369
|
+
checkpoint hooks' Test-IsCheckpointPath, so absolute and relative forms
|
|
370
|
+
both match.
|
|
371
|
+
#>
|
|
372
|
+
[CmdletBinding()]
|
|
373
|
+
[OutputType([bool])]
|
|
374
|
+
param(
|
|
375
|
+
[Parameter(Mandatory)]
|
|
376
|
+
[AllowEmptyString()]
|
|
377
|
+
[string] $Path,
|
|
378
|
+
|
|
379
|
+
[Parameter(Mandatory)]
|
|
380
|
+
[AllowEmptyString()]
|
|
381
|
+
[string] $GovernedPath
|
|
382
|
+
)
|
|
383
|
+
|
|
384
|
+
if ([string]::IsNullOrWhiteSpace($GovernedPath) -or [string]::IsNullOrWhiteSpace($Path)) {
|
|
385
|
+
return $false
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
$normalizedPath = $Path -replace '\\', '/'
|
|
389
|
+
$normalizedGoverned = ($GovernedPath -replace '\\', '/').Trim('/')
|
|
390
|
+
return ($normalizedPath -match "(^|/)$([regex]::Escape($normalizedGoverned))$")
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function Resolve-CodexUpdatedFileContent {
|
|
394
|
+
<#
|
|
395
|
+
.SYNOPSIS
|
|
396
|
+
INTERNAL. Reconstructs post-patch content for a governed Update.
|
|
397
|
+
|
|
398
|
+
.DESCRIPTION
|
|
399
|
+
Reads the source file, then applies each hunk in memory. Nothing is
|
|
400
|
+
written to disk. Returns an empty string when the source cannot be read
|
|
401
|
+
or a hunk does not apply, because empty content is the signal the
|
|
402
|
+
governed policies already fail closed on. This function never throws, so
|
|
403
|
+
a governed reconstruction failure becomes a deny rather than exit 2.
|
|
404
|
+
|
|
405
|
+
.PARAMETER SourcePath
|
|
406
|
+
The path to read the pre-patch content from.
|
|
407
|
+
|
|
408
|
+
.PARAMETER Body
|
|
409
|
+
The LF-normalized body of the Update file section.
|
|
410
|
+
|
|
411
|
+
.OUTPUTS
|
|
412
|
+
The reconstructed content, or an empty string on any failure.
|
|
413
|
+
#>
|
|
414
|
+
[CmdletBinding()]
|
|
415
|
+
[OutputType([string])]
|
|
416
|
+
param(
|
|
417
|
+
[Parameter(Mandatory)]
|
|
418
|
+
[AllowEmptyString()]
|
|
419
|
+
[string] $SourcePath,
|
|
420
|
+
|
|
421
|
+
[Parameter(Mandatory)]
|
|
422
|
+
[AllowEmptyString()]
|
|
423
|
+
[string] $Body
|
|
424
|
+
)
|
|
425
|
+
|
|
426
|
+
if (-not (Test-Path -LiteralPath $SourcePath -PathType Leaf)) {
|
|
427
|
+
return ''
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
try {
|
|
431
|
+
$content = (Get-Content -Raw -LiteralPath $SourcePath) -replace '\r\n', "`n"
|
|
432
|
+
} catch {
|
|
433
|
+
Write-Verbose "Unable to read update source '$SourcePath': $($_.Exception.Message)"
|
|
434
|
+
return ''
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
$hunks = @([regex]::Split($Body, $script:CodexApplyPatchHunkRegex) | Where-Object { $_ -match '\S' })
|
|
438
|
+
|
|
439
|
+
# Apply each hunk by locating its pre-image and substituting its post-image.
|
|
440
|
+
# A hunk whose pre-image is absent means the patch does not apply to this
|
|
441
|
+
# source, so reconstruction fails closed with empty content.
|
|
442
|
+
foreach ($hunk in $hunks) {
|
|
443
|
+
$oldLines = [System.Collections.Generic.List[string]]::new()
|
|
444
|
+
$newLines = [System.Collections.Generic.List[string]]::new()
|
|
445
|
+
|
|
446
|
+
# Classify each hunk line into the pre-image, the post-image, or both.
|
|
447
|
+
foreach ($line in ($hunk -split "`n")) {
|
|
448
|
+
if ($line -match '^\*\*\* (?:Move to|End of File)') {
|
|
449
|
+
continue
|
|
450
|
+
}
|
|
451
|
+
if ($line.StartsWith('+') -and -not $line.StartsWith('+++')) {
|
|
452
|
+
$newLines.Add($line.Substring(1))
|
|
453
|
+
} elseif ($line.StartsWith('-') -and -not $line.StartsWith('---')) {
|
|
454
|
+
$oldLines.Add($line.Substring(1))
|
|
455
|
+
} elseif ($line.StartsWith(' ')) {
|
|
456
|
+
$oldLines.Add($line.Substring(1))
|
|
457
|
+
$newLines.Add($line.Substring(1))
|
|
458
|
+
} else {
|
|
459
|
+
$oldLines.Add($line)
|
|
460
|
+
$newLines.Add($line)
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
$oldText = $oldLines -join "`n"
|
|
465
|
+
$newText = $newLines -join "`n"
|
|
466
|
+
$index = $content.IndexOf($oldText, [System.StringComparison]::Ordinal)
|
|
467
|
+
if ($index -lt 0) {
|
|
468
|
+
return ''
|
|
469
|
+
}
|
|
470
|
+
$content = $content.Substring(0, $index) + $newText + $content.Substring($index + $oldText.Length)
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
return $content
|
|
474
|
+
}
|