@danmoisan/drm-copilot-mcp 1.1.9 → 1.1.11
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 +3526 -1037
- package/package.json +1 -1
- package/resources/claude-customizations/.claude/agents/parallel-orchestrator.md +15 -2
- package/resources/claude-customizations/.claude/agents/parallel-planner.md +3 -0
- package/resources/claude-customizations/.claude/hooks/enforce-epic-merge-gate.ps1 +49 -14
- package/resources/claude-customizations/.claude/hooks/enforce-epic-worktree-removal-gate.ps1 +52 -3
- package/resources/claude-customizations/.claude/hooks/enforce-orchestration-preimplementation-gate.ps1 +7 -1
- package/resources/claude-customizations/.claude/hooks/enforce-parallel-abandon-gate.ps1 +103 -6
- package/resources/claude-customizations/.claude/hooks/enforce-parallel-worktree-removal-gate.ps1 +58 -3
- package/resources/claude-customizations/.claude/hooks/enforce-pr-author-skill-helpers.ps1 +37 -5
- package/resources/claude-customizations/.claude/hooks/enforce-pr-author-skill.epic-base-branch.ps1 +13 -2
- package/resources/claude-customizations/.claude/hooks/enforce-promotion-mcp-only.ps1 +35 -7
- package/resources/claude-customizations/.claude/hooks/hook-command-invocation.ps1 +483 -0
- package/resources/claude-customizations/.claude/hooks/hook-command-scanner.ps1 +483 -0
- package/resources/claude-customizations/.claude/hooks/validate-bash.ps1 +254 -5
- package/resources/claude-customizations/.claude/lib/blast-radius/BlastRadius.psm1 +18 -75
- package/resources/claude-customizations/.claude/lib/blast-radius/BlastRadiusConflict.psm1 +290 -0
- package/resources/claude-customizations/.claude/lib/cleanup-manifest/CleanupWorktreeManifest.psm1 +415 -0
- package/resources/claude-customizations/.claude/lib/project-file-merge/ProjectFileMerge.psm1 +355 -0
- package/resources/claude-customizations/.claude/lib/project-file-merge/ProjectFileMergeGrammar.psm1 +318 -0
- package/resources/claude-customizations/.claude/lib/project-file-merge/Resolve-MergeableConflict.ps1 +229 -0
- package/resources/claude-customizations/.claude/rules/parallel-orchestration.md +66 -2
- package/resources/claude-customizations/.claude/skills/cleanup-merged-worktrees/SKILL.md +311 -16
- package/resources/claude-customizations/.claude/skills/orchestrate/SKILL.md +42 -0
- package/resources/claude-customizations/.claude/skills/parallel-add/SKILL.md +3 -1
- package/resources/claude-customizations/.claude/skills/parallel-orchestrate/SKILL.md +36 -2
- package/resources/claude-customizations/.claude/skills/parallel-plan/SKILL.md +6 -0
- package/resources/claude-customizations/.claude/skills/powershell-orchestration-state-machine/SKILL.md +29 -1
- package/resources/claude-customizations/config/blast-radius.json +7 -0
- package/resources/claude-customizations/pack-manifests/core.json +7 -0
- package/resources/codex-and-agents-customizations/.agents/skills/orchestrate/SKILL.md +41 -0
- package/resources/codex-and-agents-customizations/.agents/skills/orchestrator-state/SKILL.md +41 -0
- package/resources/codex-and-agents-customizations/.agents/skills/repo-automation-adapter/SKILL.md +26 -0
- package/resources/codex-and-agents-customizations/.codex/config.toml +1 -1
- package/resources/codex-and-agents-customizations/.codex/hooks/enforce-epic-merge-gate.ps1 +49 -3
- package/resources/codex-and-agents-customizations/.codex/hooks/enforce-epic-planning-only.ps1 +57 -12
- package/resources/codex-and-agents-customizations/.codex/hooks/enforce-epic-worktree-removal-gate.ps1 +34 -8
- package/resources/codex-and-agents-customizations/.codex/hooks/enforce-orchestration-preimplementation-gate.ps1 +6 -1
- package/resources/codex-and-agents-customizations/.codex/hooks/enforce-promotion-mcp-only.ps1 +34 -7
- package/resources/codex-and-agents-customizations/.codex/hooks/hook-command-invocation.ps1 +483 -0
- package/resources/codex-and-agents-customizations/.codex/hooks/hook-command-scanner.ps1 +483 -0
- package/resources/codex-and-agents-customizations/.codex/hooks/validate-bash.ps1 +130 -2
- package/resources/codex-and-agents-customizations/pack-manifests/core.json +6 -1
- package/resources/config/orchestration-handoff-registry.json +138 -0
- package/resources/config/orchestration-handoff.schema.json +472 -0
- package/resources/powershell/PoshQC/settings/pester.runsettings.psd1 +38 -0
|
@@ -0,0 +1,483 @@
|
|
|
1
|
+
<#
|
|
2
|
+
.SYNOPSIS
|
|
3
|
+
Bash command-line segment scanner shared by the Claude and Codex enforcement hooks.
|
|
4
|
+
.DESCRIPTION
|
|
5
|
+
Realizes Piece 1 and Piece 2 of the D2 behavior contract in
|
|
6
|
+
docs/features/active/2026-08-25-enforcement-hook-trigger-matches-whole-command-text-545/spec.md.
|
|
7
|
+
|
|
8
|
+
A single left-to-right scan of a raw Bash command line produces an ordered list of
|
|
9
|
+
segment records. Each record carries the segment's raw text, a masked text in which
|
|
10
|
+
every quoted span and every attached heredoc body has been replaced by spaces, a
|
|
11
|
+
quote-stripped token list, the leading command word, and the flags a caller needs in
|
|
12
|
+
order to decide whether the masked text is safe to scan.
|
|
13
|
+
|
|
14
|
+
Pure string logic only: no disk, process, network, clock, or environment access. It is
|
|
15
|
+
dot-sourced as: . (Join-Path $PSScriptRoot 'hook-command-scanner.ps1')
|
|
16
|
+
#>
|
|
17
|
+
|
|
18
|
+
# The wrapper carve-out set (D2 Piece 2). A segment led by one of these names keeps its raw
|
|
19
|
+
# text as scan text, because a wrapper's quoted argument is a nested command line rather
|
|
20
|
+
# than inert data. Pinned by test through Get-CommandLineWrapperName (rule R6).
|
|
21
|
+
$script:CommandLineWrapperNames = @(
|
|
22
|
+
'sh', 'bash', 'zsh', 'dash', 'ksh', 'pwsh', 'powershell',
|
|
23
|
+
'xargs', 'env', 'command', 'eval', 'nohup', 'time', 'timeout'
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
# Characters that end a segment when they occur outside a quoted span and outside a heredoc
|
|
27
|
+
# body (D2 Piece 1). The subshell, group, and substitution openers and closers are
|
|
28
|
+
# delimiters by design: treating them as such is what closes the '(git add .)' and
|
|
29
|
+
# '$(git add .)' non-match bypasses. The two-character '$(' opener is handled alongside
|
|
30
|
+
# this table rather than in it.
|
|
31
|
+
$script:CommandLineSegmentDelimiters = [char[]]@(';', '&', '|', "`n", '(', ')', '{', '}', '`')
|
|
32
|
+
|
|
33
|
+
function Get-CommandLineWrapperName {
|
|
34
|
+
<#
|
|
35
|
+
.SYNOPSIS
|
|
36
|
+
Return the wrapper carve-out set of D2 Piece 2.
|
|
37
|
+
.DESCRIPTION
|
|
38
|
+
A getter rather than a bare variable, so the R6 membership test pins a public surface.
|
|
39
|
+
.OUTPUTS
|
|
40
|
+
System.String[]
|
|
41
|
+
#>
|
|
42
|
+
[CmdletBinding()]
|
|
43
|
+
[OutputType([string[]])]
|
|
44
|
+
param()
|
|
45
|
+
|
|
46
|
+
return [string[]]$script:CommandLineWrapperNames
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function ConvertTo-CommandLineToken {
|
|
50
|
+
<#
|
|
51
|
+
.SYNOPSIS
|
|
52
|
+
Split one segment into whitespace-delimited tokens with balanced quotes stripped.
|
|
53
|
+
.DESCRIPTION
|
|
54
|
+
The issue #539 ConvertTo-OrchestrationCommandToken idiom, reproduced here so the
|
|
55
|
+
parser carries no dependency on the orchestration gate's helper file. A quoted span
|
|
56
|
+
contributes to the token it sits in and its delimiting quotes are removed, and
|
|
57
|
+
whitespace inside it does not split a token, so `-m "rm -rf x"` yields the two
|
|
58
|
+
tokens `-m` and `rm -rf x` and never an adjacent token pair equal to a literal.
|
|
59
|
+
.OUTPUTS
|
|
60
|
+
System.String[]
|
|
61
|
+
#>
|
|
62
|
+
[CmdletBinding()]
|
|
63
|
+
[OutputType([string[]])]
|
|
64
|
+
param([Parameter(Mandatory)][AllowEmptyString()][string] $Segment)
|
|
65
|
+
|
|
66
|
+
$tokens = [System.Collections.Generic.List[string]]::new()
|
|
67
|
+
$current = [System.Text.StringBuilder]::new()
|
|
68
|
+
$hasToken = $false
|
|
69
|
+
$openQuote = [char]0
|
|
70
|
+
|
|
71
|
+
foreach ($character in $Segment.ToCharArray()) {
|
|
72
|
+
if ($openQuote -ne [char]0) {
|
|
73
|
+
if ($character -eq $openQuote) { $openQuote = [char]0 } else { [void]$current.Append($character) }
|
|
74
|
+
continue
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if ($character -eq '"' -or $character -eq "'") {
|
|
78
|
+
$openQuote = $character
|
|
79
|
+
$hasToken = $true
|
|
80
|
+
continue
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if ([char]::IsWhiteSpace($character)) {
|
|
84
|
+
if ($hasToken) {
|
|
85
|
+
$tokens.Add($current.ToString())
|
|
86
|
+
[void]$current.Clear()
|
|
87
|
+
$hasToken = $false
|
|
88
|
+
}
|
|
89
|
+
continue
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
$hasToken = $true
|
|
93
|
+
[void]$current.Append($character)
|
|
94
|
+
}
|
|
95
|
+
if ($hasToken) { $tokens.Add($current.ToString()) }
|
|
96
|
+
return $tokens.ToArray()
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function Get-CommandLineSegmentCommandWord {
|
|
100
|
+
<#
|
|
101
|
+
.SYNOPSIS
|
|
102
|
+
Return the leading command word of a token list, skipping VAR=value prefixes.
|
|
103
|
+
.DESCRIPTION
|
|
104
|
+
Realizes step 1 of D2 Piece 3 for the scanner's own CommandWord property. Returns
|
|
105
|
+
the empty string when the segment carries no command word.
|
|
106
|
+
.OUTPUTS
|
|
107
|
+
System.String
|
|
108
|
+
#>
|
|
109
|
+
[CmdletBinding()]
|
|
110
|
+
[OutputType([string])]
|
|
111
|
+
param([Parameter(Mandatory)][AllowEmptyCollection()][AllowEmptyString()][AllowNull()][string[]] $Token)
|
|
112
|
+
|
|
113
|
+
foreach ($candidate in $Token) {
|
|
114
|
+
if ($candidate -match '^[A-Za-z_][A-Za-z0-9_]*=') { continue }
|
|
115
|
+
return $candidate
|
|
116
|
+
}
|
|
117
|
+
return ''
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function ConvertTo-CommandLineSegmentRecord {
|
|
121
|
+
<#
|
|
122
|
+
.SYNOPSIS
|
|
123
|
+
Assemble one segment record and apply the three ordered ScanText clauses.
|
|
124
|
+
.DESCRIPTION
|
|
125
|
+
ScanText selection follows D2 Piece 2 in order: RawText when the segment is
|
|
126
|
+
Unbalanced or carries a live substitution; RawText when it is wrapper-led;
|
|
127
|
+
MaskedText otherwise. TokenText is the segment text with heredoc bodies replaced by
|
|
128
|
+
spaces and quoted spans intact. Unbalanced also carries the unmaskable-delimiter
|
|
129
|
+
case of rule R3, because such a heredoc cannot be shown to close.
|
|
130
|
+
.OUTPUTS
|
|
131
|
+
System.Management.Automation.PSCustomObject
|
|
132
|
+
#>
|
|
133
|
+
[CmdletBinding()]
|
|
134
|
+
[OutputType([pscustomobject])]
|
|
135
|
+
param(
|
|
136
|
+
[Parameter(Mandatory)][AllowEmptyString()][string] $RawText,
|
|
137
|
+
[Parameter(Mandatory)][AllowEmptyString()][string] $MaskedText,
|
|
138
|
+
[Parameter(Mandatory)][AllowEmptyString()][string] $TokenText,
|
|
139
|
+
[Parameter(Mandatory)][bool] $HasLiveSubstitution,
|
|
140
|
+
[Parameter(Mandatory)][bool] $Unbalanced
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
$tokens = @(ConvertTo-CommandLineToken -Segment $TokenText)
|
|
144
|
+
$commandWord = Get-CommandLineSegmentCommandWord -Token $tokens
|
|
145
|
+
$isWrapperLed = $false
|
|
146
|
+
if (-not [string]::IsNullOrEmpty($commandWord)) {
|
|
147
|
+
$isWrapperLed = $script:CommandLineWrapperNames -contains $commandWord
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
$scanText = $MaskedText
|
|
151
|
+
if ($Unbalanced -or $HasLiveSubstitution -or $isWrapperLed) { $scanText = $RawText }
|
|
152
|
+
|
|
153
|
+
return [pscustomobject]@{
|
|
154
|
+
RawText = $RawText
|
|
155
|
+
MaskedText = $MaskedText
|
|
156
|
+
Tokens = $tokens
|
|
157
|
+
CommandWord = $commandWord
|
|
158
|
+
IsWrapperLed = $isWrapperLed
|
|
159
|
+
HasLiveSubstitution = $HasLiveSubstitution
|
|
160
|
+
Unbalanced = $Unbalanced
|
|
161
|
+
ScanText = $scanText
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function Test-CommandLineSegmentRawScan {
|
|
166
|
+
<#
|
|
167
|
+
.SYNOPSIS
|
|
168
|
+
Report whether the scanner selected RawText as this segment's ScanText.
|
|
169
|
+
.DESCRIPTION
|
|
170
|
+
The three disjuncts below are the same three clauses ConvertTo-CommandLineSegmentRecord
|
|
171
|
+
applies when it selects ScanText, so a caller that guards a raw-text read with this
|
|
172
|
+
predicate reads raw on exactly the segments the scanner already scans raw, and on no
|
|
173
|
+
others.
|
|
174
|
+
|
|
175
|
+
It exists for one fail-open class the token readers cannot close by themselves.
|
|
176
|
+
ConvertTo-CommandLineToken collapses a balanced quoted span into ONE token, so a flag
|
|
177
|
+
carried inside a wrapper's quoted argument never appears as a token and every
|
|
178
|
+
token-based flag read reports it absent. A call site that reads flag absence as OUT OF
|
|
179
|
+
SCOPE therefore allows a command the pre-parser whole-text scan denied. A call site
|
|
180
|
+
that reads flag absence as MISSING AUTHORIZATION is already fail-closed and must not
|
|
181
|
+
use this predicate.
|
|
182
|
+
.PARAMETER Segment
|
|
183
|
+
One segment record produced by Read-CommandLineSegment.
|
|
184
|
+
.OUTPUTS
|
|
185
|
+
System.Boolean
|
|
186
|
+
#>
|
|
187
|
+
[CmdletBinding()]
|
|
188
|
+
[OutputType([bool])]
|
|
189
|
+
param([Parameter(Mandatory)][AllowNull()] $Segment)
|
|
190
|
+
|
|
191
|
+
if ($null -eq $Segment) {
|
|
192
|
+
return $false
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return ([bool]$Segment.IsWrapperLed -or [bool]$Segment.HasLiveSubstitution -or [bool]$Segment.Unbalanced)
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function Read-CommandLineHeredocHeader {
|
|
199
|
+
<#
|
|
200
|
+
.SYNOPSIS
|
|
201
|
+
Read a heredoc header beginning at a '<<' operator and report its delimiter.
|
|
202
|
+
.DESCRIPTION
|
|
203
|
+
Handles the plain '<<' form, the tab-stripping '<<-' form, and an optionally quoted
|
|
204
|
+
delimiter word. A delimiter produced by expansion is reported as non-literal, which
|
|
205
|
+
makes the segment unresolvable under R3. Returns NextIndex, Delimiter, StripTabs,
|
|
206
|
+
and IsLiteral.
|
|
207
|
+
.OUTPUTS
|
|
208
|
+
System.Management.Automation.PSCustomObject
|
|
209
|
+
#>
|
|
210
|
+
[CmdletBinding()]
|
|
211
|
+
[OutputType([pscustomobject])]
|
|
212
|
+
param(
|
|
213
|
+
[Parameter(Mandatory)][string] $Text,
|
|
214
|
+
[Parameter(Mandatory)][int] $StartIndex
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
$cursor = $StartIndex + 2
|
|
218
|
+
$stripTabs = $false
|
|
219
|
+
if ($cursor -lt $Text.Length -and $Text[$cursor] -eq '-') {
|
|
220
|
+
$stripTabs = $true
|
|
221
|
+
$cursor++
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
while ($cursor -lt $Text.Length -and ($Text[$cursor] -eq ' ' -or $Text[$cursor] -eq "`t")) { $cursor++ }
|
|
225
|
+
|
|
226
|
+
$word = [System.Text.StringBuilder]::new()
|
|
227
|
+
$isLiteral = $true
|
|
228
|
+
$openQuote = [char]0
|
|
229
|
+
while ($cursor -lt $Text.Length) {
|
|
230
|
+
$character = $Text[$cursor]
|
|
231
|
+
if ($openQuote -ne [char]0) {
|
|
232
|
+
if ($character -eq $openQuote) {
|
|
233
|
+
$openQuote = [char]0
|
|
234
|
+
} else {
|
|
235
|
+
if ($openQuote -eq '"' -and ($character -eq '$' -or $character -eq '`')) { $isLiteral = $false }
|
|
236
|
+
[void]$word.Append($character)
|
|
237
|
+
}
|
|
238
|
+
$cursor++
|
|
239
|
+
continue
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
if ($character -eq '"' -or $character -eq "'") {
|
|
243
|
+
$openQuote = $character
|
|
244
|
+
$cursor++
|
|
245
|
+
continue
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if ([char]::IsWhiteSpace($character) -or $script:CommandLineSegmentDelimiters -contains $character) { break }
|
|
249
|
+
if ($character -eq '$' -or $character -eq '`') { $isLiteral = $false }
|
|
250
|
+
[void]$word.Append($character)
|
|
251
|
+
$cursor++
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
$delimiter = $word.ToString()
|
|
255
|
+
if ($openQuote -ne [char]0 -or [string]::IsNullOrEmpty($delimiter)) { $isLiteral = $false }
|
|
256
|
+
|
|
257
|
+
return [pscustomobject]@{
|
|
258
|
+
NextIndex = $cursor
|
|
259
|
+
Delimiter = $delimiter
|
|
260
|
+
StripTabs = $stripTabs
|
|
261
|
+
IsLiteral = $isLiteral
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function Read-CommandLineHeredocBody {
|
|
266
|
+
<#
|
|
267
|
+
.SYNOPSIS
|
|
268
|
+
Consume one heredoc body from a start index and report where it ends.
|
|
269
|
+
.DESCRIPTION
|
|
270
|
+
Consumes lines until a line whose content - after optional leading tabs for the
|
|
271
|
+
'<<-' form - equals the delimiter exactly, case-sensitively as in the shell. An
|
|
272
|
+
unterminated body is consumed to end of text and reported through Unbalanced.
|
|
273
|
+
.OUTPUTS
|
|
274
|
+
System.Management.Automation.PSCustomObject
|
|
275
|
+
#>
|
|
276
|
+
[CmdletBinding()]
|
|
277
|
+
[OutputType([pscustomobject])]
|
|
278
|
+
param(
|
|
279
|
+
[Parameter(Mandatory)][string] $Text,
|
|
280
|
+
[Parameter(Mandatory)][int] $StartIndex,
|
|
281
|
+
[Parameter(Mandatory)][AllowEmptyString()][string] $Delimiter,
|
|
282
|
+
[Parameter(Mandatory)][bool] $StripTabs
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
$cursor = $StartIndex
|
|
286
|
+
$length = $Text.Length
|
|
287
|
+
while ($cursor -lt $length) {
|
|
288
|
+
$lineEnd = $Text.IndexOf("`n", $cursor)
|
|
289
|
+
if ($lineEnd -lt 0) { $lineEnd = $length }
|
|
290
|
+
|
|
291
|
+
$line = $Text.Substring($cursor, $lineEnd - $cursor).TrimEnd("`r")
|
|
292
|
+
if ($StripTabs) { $line = $line.TrimStart("`t") }
|
|
293
|
+
|
|
294
|
+
$cursor = if ($lineEnd -lt $length) { $lineEnd + 1 } else { $length }
|
|
295
|
+
if ($line -ceq $Delimiter) {
|
|
296
|
+
return [pscustomobject]@{ NextIndex = $cursor; Terminated = $true }
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
return [pscustomobject]@{ NextIndex = $length; Terminated = $false }
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function Read-CommandLineSegment {
|
|
304
|
+
<#
|
|
305
|
+
.SYNOPSIS
|
|
306
|
+
Scan a raw Bash command line into an ordered list of segment records.
|
|
307
|
+
.DESCRIPTION
|
|
308
|
+
A single left-to-right scan tracking single-quote spans, double-quote spans,
|
|
309
|
+
backslash escapes, and a heredoc delimiter state machine. Segment delimiters,
|
|
310
|
+
recognized only outside quoted spans and heredoc bodies: ';', '&', '|', newline,
|
|
311
|
+
and the subshell/group/substitution openers and closers '(', ')', '{', '}',
|
|
312
|
+
'$(' and the backtick.
|
|
313
|
+
|
|
314
|
+
Pure: reads no file, starts no process, reads no clock, mutates no input.
|
|
315
|
+
.PARAMETER CommandText
|
|
316
|
+
The raw Bash command text, exactly as delivered by
|
|
317
|
+
Get-ClaudeHookToolInputString -Name 'command'.
|
|
318
|
+
.OUTPUTS
|
|
319
|
+
System.Management.Automation.PSCustomObject[] - one record per segment, in
|
|
320
|
+
source order. Each record carries:
|
|
321
|
+
RawText [string] the segment's original text, unmodified
|
|
322
|
+
MaskedText [string] quoted spans and attached heredoc bodies
|
|
323
|
+
replaced by single spaces
|
|
324
|
+
Tokens [string[]] quote-stripped, whitespace-delimited tokens
|
|
325
|
+
CommandWord [string] the first token after any VAR=value prefixes,
|
|
326
|
+
or '' when the segment has no command word
|
|
327
|
+
IsWrapperLed [bool] $true when CommandWord is a member of the
|
|
328
|
+
wrapper carve-out set
|
|
329
|
+
HasLiveSubstitution [bool] $true when '$(' or a backtick occurred inside
|
|
330
|
+
one of the segment's double-quoted spans
|
|
331
|
+
Unbalanced [bool] $true when a quote span or heredoc did not
|
|
332
|
+
close before end of text
|
|
333
|
+
ScanText [string] the clause-ordered selection: RawText when
|
|
334
|
+
Unbalanced or HasLiveSubstitution or
|
|
335
|
+
IsWrapperLed; MaskedText otherwise
|
|
336
|
+
Returns an empty array for null, empty, or whitespace-only input.
|
|
337
|
+
#>
|
|
338
|
+
[CmdletBinding()]
|
|
339
|
+
[OutputType([pscustomobject[]])]
|
|
340
|
+
param(
|
|
341
|
+
[Parameter(Mandatory)]
|
|
342
|
+
[AllowEmptyString()]
|
|
343
|
+
[AllowNull()]
|
|
344
|
+
[string] $CommandText
|
|
345
|
+
)
|
|
346
|
+
|
|
347
|
+
$records = [System.Collections.Generic.List[pscustomobject]]::new()
|
|
348
|
+
if ([string]::IsNullOrWhiteSpace($CommandText)) { return $records.ToArray() }
|
|
349
|
+
|
|
350
|
+
$raw = [System.Text.StringBuilder]::new()
|
|
351
|
+
$masked = [System.Text.StringBuilder]::new()
|
|
352
|
+
$tokenText = [System.Text.StringBuilder]::new()
|
|
353
|
+
$pending = [System.Collections.Generic.List[pscustomobject]]::new()
|
|
354
|
+
$hasLiveSubstitution = $false
|
|
355
|
+
$unbalanced = $false
|
|
356
|
+
$inSingle = $false
|
|
357
|
+
$inDouble = $false
|
|
358
|
+
$index = 0
|
|
359
|
+
$length = $CommandText.Length
|
|
360
|
+
|
|
361
|
+
while ($index -lt $length) {
|
|
362
|
+
$character = $CommandText[$index]
|
|
363
|
+
$next = if ($index + 1 -lt $length) { $CommandText[$index + 1] } else { [char]0 }
|
|
364
|
+
|
|
365
|
+
if ($inSingle) {
|
|
366
|
+
if ($character -eq "'") { $inSingle = $false }
|
|
367
|
+
[void]$raw.Append($character)
|
|
368
|
+
[void]$tokenText.Append($character)
|
|
369
|
+
[void]$masked.Append(' ')
|
|
370
|
+
$index++
|
|
371
|
+
continue
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
if ($inDouble) {
|
|
375
|
+
if ($character -eq '\' -and $next -ne [char]0) {
|
|
376
|
+
[void]$raw.Append($character).Append($next)
|
|
377
|
+
[void]$tokenText.Append($character).Append($next)
|
|
378
|
+
[void]$masked.Append(' ')
|
|
379
|
+
$index += 2
|
|
380
|
+
continue
|
|
381
|
+
}
|
|
382
|
+
if ($character -eq '`' -or ($character -eq '$' -and $next -eq '(')) { $hasLiveSubstitution = $true }
|
|
383
|
+
if ($character -eq '"') { $inDouble = $false }
|
|
384
|
+
[void]$raw.Append($character)
|
|
385
|
+
[void]$tokenText.Append($character)
|
|
386
|
+
[void]$masked.Append(' ')
|
|
387
|
+
$index++
|
|
388
|
+
continue
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
if ($character -eq '\' -and $next -ne [char]0) {
|
|
392
|
+
[void]$raw.Append($character).Append($next)
|
|
393
|
+
[void]$tokenText.Append($character).Append($next)
|
|
394
|
+
[void]$masked.Append($character).Append($next)
|
|
395
|
+
$index += 2
|
|
396
|
+
continue
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
if ($character -eq "'" -or $character -eq '"') {
|
|
400
|
+
if ($character -eq "'") { $inSingle = $true } else { $inDouble = $true }
|
|
401
|
+
[void]$raw.Append($character)
|
|
402
|
+
[void]$tokenText.Append($character)
|
|
403
|
+
[void]$masked.Append(' ')
|
|
404
|
+
$index++
|
|
405
|
+
continue
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
if ($character -eq '<' -and $next -eq '<') {
|
|
409
|
+
if ($index + 2 -lt $length -and $CommandText[$index + 2] -eq '<') {
|
|
410
|
+
# '<<<' is a here-string. It has no body and is not a heredoc.
|
|
411
|
+
[void]$raw.Append('<<<')
|
|
412
|
+
[void]$tokenText.Append('<<<')
|
|
413
|
+
[void]$masked.Append('<<<')
|
|
414
|
+
$index += 3
|
|
415
|
+
continue
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
$header = Read-CommandLineHeredocHeader -Text $CommandText -StartIndex $index
|
|
419
|
+
$headerText = $CommandText.Substring($index, $header.NextIndex - $index)
|
|
420
|
+
[void]$raw.Append($headerText)
|
|
421
|
+
[void]$tokenText.Append($headerText)
|
|
422
|
+
[void]$masked.Append($headerText)
|
|
423
|
+
if ($header.IsLiteral) {
|
|
424
|
+
$pending.Add($header)
|
|
425
|
+
} else {
|
|
426
|
+
# A delimiter produced by expansion is unmaskable, so the containing
|
|
427
|
+
# segment is unresolvable and falls back to a raw scan (rule R3).
|
|
428
|
+
$unbalanced = $true
|
|
429
|
+
}
|
|
430
|
+
$index = $header.NextIndex
|
|
431
|
+
continue
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
$isSubstitutionOpener = $character -eq '$' -and $next -eq '('
|
|
435
|
+
if ($isSubstitutionOpener -or $script:CommandLineSegmentDelimiters -contains $character) {
|
|
436
|
+
if ($character -eq "`n" -and $pending.Count -gt 0) {
|
|
437
|
+
[void]$raw.Append($character)
|
|
438
|
+
[void]$tokenText.Append($character)
|
|
439
|
+
[void]$masked.Append($character)
|
|
440
|
+
$index++
|
|
441
|
+
foreach ($heredoc in $pending) {
|
|
442
|
+
$body = Read-CommandLineHeredocBody -Text $CommandText -StartIndex $index -Delimiter $heredoc.Delimiter -StripTabs $heredoc.StripTabs
|
|
443
|
+
$bodyText = $CommandText.Substring($index, $body.NextIndex - $index)
|
|
444
|
+
[void]$raw.Append($bodyText)
|
|
445
|
+
[void]$tokenText.Append(' ' * $bodyText.Length)
|
|
446
|
+
[void]$masked.Append(' ' * $bodyText.Length)
|
|
447
|
+
if (-not $body.Terminated) { $unbalanced = $true }
|
|
448
|
+
$index = $body.NextIndex
|
|
449
|
+
}
|
|
450
|
+
$pending.Clear()
|
|
451
|
+
} elseif ($isSubstitutionOpener) {
|
|
452
|
+
$index += 2
|
|
453
|
+
} else {
|
|
454
|
+
$index++
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
$rawText = $raw.ToString()
|
|
458
|
+
if (-not [string]::IsNullOrWhiteSpace($rawText)) {
|
|
459
|
+
$records.Add((ConvertTo-CommandLineSegmentRecord -RawText $rawText -MaskedText $masked.ToString() -TokenText $tokenText.ToString() -HasLiveSubstitution $hasLiveSubstitution -Unbalanced $unbalanced))
|
|
460
|
+
}
|
|
461
|
+
[void]$raw.Clear()
|
|
462
|
+
[void]$masked.Clear()
|
|
463
|
+
[void]$tokenText.Clear()
|
|
464
|
+
$hasLiveSubstitution = $false
|
|
465
|
+
$unbalanced = $false
|
|
466
|
+
continue
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
[void]$raw.Append($character)
|
|
470
|
+
[void]$tokenText.Append($character)
|
|
471
|
+
[void]$masked.Append($character)
|
|
472
|
+
$index++
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
if ($inSingle -or $inDouble -or $pending.Count -gt 0) { $unbalanced = $true }
|
|
476
|
+
|
|
477
|
+
$rawText = $raw.ToString()
|
|
478
|
+
if (-not [string]::IsNullOrWhiteSpace($rawText)) {
|
|
479
|
+
$records.Add((ConvertTo-CommandLineSegmentRecord -RawText $rawText -MaskedText $masked.ToString() -TokenText $tokenText.ToString() -HasLiveSubstitution $hasLiveSubstitution -Unbalanced $unbalanced))
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
return $records.ToArray()
|
|
483
|
+
}
|
|
@@ -14,6 +14,13 @@
|
|
|
14
14
|
[CmdletBinding()]
|
|
15
15
|
param()
|
|
16
16
|
|
|
17
|
+
# Shared command-line parser (issue #545). The denylist comparison below runs against the
|
|
18
|
+
# segment list rather than against the unsegmented command string, which is what keeps the
|
|
19
|
+
# two runtimes on one implementation of the same concern. This copy has no cd-chained leg
|
|
20
|
+
# and none is added.
|
|
21
|
+
. (Join-Path $PSScriptRoot 'hook-command-scanner.ps1')
|
|
22
|
+
. (Join-Path $PSScriptRoot 'hook-command-invocation.ps1')
|
|
23
|
+
|
|
17
24
|
function Get-BlockedBashPattern {
|
|
18
25
|
[CmdletBinding()]
|
|
19
26
|
[OutputType([string[]])]
|
|
@@ -29,7 +36,112 @@ function Get-BlockedBashPattern {
|
|
|
29
36
|
)
|
|
30
37
|
}
|
|
31
38
|
|
|
39
|
+
function Test-BlockedPatternTokenRun {
|
|
40
|
+
<#
|
|
41
|
+
.SYNOPSIS
|
|
42
|
+
Report whether a literal's token sequence occurs as a contiguous run in a token list.
|
|
43
|
+
.DESCRIPTION
|
|
44
|
+
Whole-token equality, element by element. This is the comparison primitive that
|
|
45
|
+
replaces String.Contains. It is what makes '--force-with-lease' stop matching the
|
|
46
|
+
literal 'git push --force': the two are different tokens, whereas one is a substring
|
|
47
|
+
of the other.
|
|
48
|
+
.OUTPUTS
|
|
49
|
+
System.Boolean
|
|
50
|
+
#>
|
|
51
|
+
[CmdletBinding()]
|
|
52
|
+
[OutputType([bool])]
|
|
53
|
+
param(
|
|
54
|
+
[Parameter(Mandatory)][AllowEmptyCollection()][string[]] $Token,
|
|
55
|
+
[Parameter(Mandatory)][ValidateNotNullOrEmpty()][string[]] $PatternToken
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
if ($PatternToken.Count -eq 0 -or $Token.Count -lt $PatternToken.Count) {
|
|
59
|
+
return $false
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
for ($start = 0; $start -le $Token.Count - $PatternToken.Count; $start++) {
|
|
63
|
+
$matched = $true
|
|
64
|
+
for ($offset = 0; $offset -lt $PatternToken.Count; $offset++) {
|
|
65
|
+
if ($Token[$start + $offset] -ne $PatternToken[$offset]) {
|
|
66
|
+
$matched = $false
|
|
67
|
+
break
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
if ($matched) {
|
|
71
|
+
return $true
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return $false
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function Get-BlockedStructuralGitMatch {
|
|
79
|
+
<#
|
|
80
|
+
.SYNOPSIS
|
|
81
|
+
Report the denylist literal a segment's structural git invocation stands for, or $null.
|
|
82
|
+
.DESCRIPTION
|
|
83
|
+
The flag conjunction is required rather than optional. Classifying on the bare
|
|
84
|
+
subcommand would deny 'git push --force-with-lease origin HEAD', which spec Test
|
|
85
|
+
Strategy row AT-8 requires to allow, and would deny 'git reset --soft HEAD~1'.
|
|
86
|
+
.OUTPUTS
|
|
87
|
+
System.String or $null
|
|
88
|
+
#>
|
|
89
|
+
[CmdletBinding()]
|
|
90
|
+
[OutputType([string])]
|
|
91
|
+
param(
|
|
92
|
+
[Parameter(Mandatory)][AllowEmptyString()][string] $SegmentText,
|
|
93
|
+
[Parameter(Mandatory)][AllowEmptyCollection()][string[]] $Token
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
if (Test-CommandLineInvocation -CommandText $SegmentText -CommandWord 'git' -SubcommandPath @('push')) {
|
|
97
|
+
if ($Token -contains '--force') { return 'git push --force' }
|
|
98
|
+
if ($Token -contains '-f') { return 'git push -f' }
|
|
99
|
+
}
|
|
100
|
+
if (Test-CommandLineInvocation -CommandText $SegmentText -CommandWord 'git' -SubcommandPath @('reset')) {
|
|
101
|
+
if ($Token -contains '--hard') { return 'git reset --hard' }
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return $null
|
|
105
|
+
}
|
|
106
|
+
|
|
32
107
|
function Get-BlockedPatternMatch {
|
|
108
|
+
<#
|
|
109
|
+
.SYNOPSIS
|
|
110
|
+
Return the denylist literal a command matches, or $null.
|
|
111
|
+
.DESCRIPTION
|
|
112
|
+
The Codex sibling of the Claude copy's detector, with the same two legs in the same
|
|
113
|
+
order over the segment list produced by Read-CommandLineSegment.
|
|
114
|
+
|
|
115
|
+
Leg 1 (literal): each of the six byte-unchanged literals is split into its own
|
|
116
|
+
whitespace-delimited token sequence and reported as a match when that sequence occurs
|
|
117
|
+
as a CONTIGUOUS RUN inside a segment's Tokens, every element compared by whole-token
|
|
118
|
+
equality. The value returned is the literal string itself, in declaration order.
|
|
119
|
+
|
|
120
|
+
Leg 1 has a second condition, the wrapper carve-out of D2 Piece 2 together with the two
|
|
121
|
+
other clauses under which the scanner selects raw scan text. A wrapper's quoted argument is
|
|
122
|
+
a nested command line rather than inert data, and ConvertTo-CommandLineToken collapses a
|
|
123
|
+
balanced quoted span into ONE token, so a multi-token literal can never form a contiguous
|
|
124
|
+
token run inside it. The same masking hides a literal carried inside a quoted span or a
|
|
125
|
+
heredoc that never closes. For a segment that is wrapper-led, that carries a live
|
|
126
|
+
substitution, or whose quoting or heredoc did not close, the scanner already selects
|
|
127
|
+
RawText as ScanText, and this leg reads that field with an Ordinal IndexOf, matching the
|
|
128
|
+
culture-insensitive String.Contains it replaced. The three disjuncts here are exactly the
|
|
129
|
+
three clauses of that ScanText selection, so no segment the scanner scans raw is left
|
|
130
|
+
unscanned by this leg. That is what keeps both 'bash -c "rm -rf /tmp/x"' and
|
|
131
|
+
'echo "rm -rf /tmp/x' denying. A segment matching none of the three is never scanned this
|
|
132
|
+
way, so 'git commit -m "docs: explain why rm -rf is banned"' still allows.
|
|
133
|
+
|
|
134
|
+
Leg 2 (structural): a relocating spelling such as 'git -C ../wt push --force origin
|
|
135
|
+
HEAD' contains no literal as a token run, so it is classified structurally instead.
|
|
136
|
+
|
|
137
|
+
Leg 1 is evaluated in full before any leg 2 evaluation, so 'git push origin --force'
|
|
138
|
+
returns the literal 'git push origin --force' rather than the leg 2 value.
|
|
139
|
+
|
|
140
|
+
Spec D11.3 rules that rule R2 governs the literal TEXT, not the comparison operator,
|
|
141
|
+
so all six literals stay byte-unchanged; only the comparison primitive changed.
|
|
142
|
+
.OUTPUTS
|
|
143
|
+
System.String or $null
|
|
144
|
+
#>
|
|
33
145
|
[CmdletBinding()]
|
|
34
146
|
[OutputType([string])]
|
|
35
147
|
param(
|
|
@@ -43,9 +155,25 @@ function Get-BlockedPatternMatch {
|
|
|
43
155
|
return $null
|
|
44
156
|
}
|
|
45
157
|
|
|
158
|
+
$segments = @(Read-CommandLineSegment -CommandText $Command)
|
|
159
|
+
|
|
46
160
|
foreach ($pattern in (Get-BlockedBashPattern)) {
|
|
47
|
-
|
|
48
|
-
|
|
161
|
+
$patternTokens = [string[]]@($pattern -split '\s+' | Where-Object { $_ })
|
|
162
|
+
foreach ($segment in $segments) {
|
|
163
|
+
if (Test-BlockedPatternTokenRun -Token @($segment.Tokens) -PatternToken $patternTokens) {
|
|
164
|
+
return $pattern
|
|
165
|
+
}
|
|
166
|
+
if (($segment.IsWrapperLed -or $segment.HasLiveSubstitution -or $segment.Unbalanced) -and
|
|
167
|
+
$segment.ScanText.IndexOf($pattern, [System.StringComparison]::Ordinal) -ge 0) {
|
|
168
|
+
return $pattern
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
foreach ($segment in $segments) {
|
|
174
|
+
$structural = Get-BlockedStructuralGitMatch -SegmentText $segment.RawText -Token @($segment.Tokens)
|
|
175
|
+
if ($structural) {
|
|
176
|
+
return $structural
|
|
49
177
|
}
|
|
50
178
|
}
|
|
51
179
|
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
".agents/skills/epic-run/SKILL.md",
|
|
16
16
|
".agents/skills/evidence-and-timestamp-conventions/SKILL.md",
|
|
17
17
|
".agents/skills/orchestrate/SKILL.md",
|
|
18
|
+
".agents/skills/orchestrator-state/SKILL.md",
|
|
18
19
|
".agents/skills/orchestrator-workflow/SKILL.md",
|
|
19
20
|
".agents/skills/policy-compliance-order/SKILL.md",
|
|
20
21
|
".agents/skills/repo-automation-adapter/SKILL.md",
|
|
@@ -39,6 +40,8 @@
|
|
|
39
40
|
".codex/hooks/enforce-epic-worktree-removal-gate.ps1",
|
|
40
41
|
".codex/hooks/enforce-orchestration-preimplementation-gate-helpers.ps1",
|
|
41
42
|
".codex/hooks/enforce-orchestration-preimplementation-gate-modes.ps1",
|
|
43
|
+
".codex/hooks/hook-command-invocation.ps1",
|
|
44
|
+
".codex/hooks/hook-command-scanner.ps1",
|
|
42
45
|
".codex/hooks/record-subagent-routing-attestation.ps1",
|
|
43
46
|
".codex/hooks/validate-codex-subagent-routing.ps1",
|
|
44
47
|
".codex/scripts/epic-child-launch-contract.ps1",
|
|
@@ -93,6 +96,8 @@
|
|
|
93
96
|
".codex/agents/commit-steward-c2.toml",
|
|
94
97
|
".codex/agents/commit-steward-c3.toml",
|
|
95
98
|
".codex/agents/commit-steward-c3-elevated.toml",
|
|
96
|
-
".codex/agents/commit-steward-c4.toml"
|
|
99
|
+
".codex/agents/commit-steward-c4.toml",
|
|
100
|
+
"config/orchestration-handoff-registry.json",
|
|
101
|
+
"config/orchestration-handoff.schema.json"
|
|
97
102
|
]
|
|
98
103
|
}
|