@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
|
+
}
|