@danmoisan/drm-copilot-mcp 1.1.23 → 1.1.24

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.
Files changed (199) hide show
  1. package/out/mcp-server.js +13347 -7926
  2. package/package.json +1 -1
  3. package/resources/claude-customizations/.claude/agents/discovery-capability-sentinel.md +34 -0
  4. package/resources/claude-customizations/.claude/agents/legacy-parity-analyst.md +101 -43
  5. package/resources/claude-customizations/.claude/agents/migration-coverage-reviewer.md +107 -43
  6. package/resources/claude-customizations/.claude/agents/parallel-orchestrator.md +14 -0
  7. package/resources/claude-customizations/.claude/agents/parallel-planner.md +21 -1
  8. package/resources/claude-customizations/.claude/agents/pr-author.md +17 -1
  9. package/resources/claude-customizations/.claude/agents/requirements-reconciler.md +117 -42
  10. package/resources/claude-customizations/.claude/agents/runtime-characterization-analyst.md +103 -43
  11. package/resources/claude-customizations/.claude/hooks/discovery-capability-probe.ps1 +102 -0
  12. package/resources/claude-customizations/.claude/hooks/enforce-discovery-artifact-gate.ps1 +69 -194
  13. package/resources/claude-customizations/.claude/hooks/enforce-discovery-planning-gate.ps1 +87 -0
  14. package/resources/claude-customizations/.claude/hooks/enforce-epic-merge-gate.ps1 +7 -1
  15. package/resources/claude-customizations/.claude/hooks/enforce-model-routing-receipt.ps1 +6 -3
  16. package/resources/claude-customizations/.claude/hooks/enforce-orchestration-preimplementation-gate-epic-scope.ps1 +168 -48
  17. package/resources/claude-customizations/.claude/hooks/enforce-orchestration-preimplementation-gate.ps1 +1 -1
  18. package/resources/claude-customizations/.claude/hooks/enforce-powershell-batch-budget.ps1 +59 -20
  19. package/resources/claude-customizations/.claude/hooks/enforce-pr-author-command-allowlist.ps1 +44 -3
  20. package/resources/claude-customizations/.claude/hooks/enforce-pr-author-skill-helpers.ps1 +60 -4
  21. package/resources/claude-customizations/.claude/hooks/enforce-pr-author-skill.epic-base-branch.ps1 +4 -2
  22. package/resources/claude-customizations/.claude/hooks/enforce-python-batch-budget.ps1 +59 -20
  23. package/resources/claude-customizations/.claude/hooks/record-discovery-agent-start.ps1 +132 -0
  24. package/resources/claude-customizations/.claude/hooks/validate-discovery-artifact-gate.ps1 +56 -212
  25. package/resources/claude-customizations/.claude/hooks/validate-orchestrator-output-rejection.ps1 +172 -0
  26. package/resources/claude-customizations/.claude/hooks/validate-orchestrator-output.ps1 +9 -1
  27. package/resources/claude-customizations/.claude/lib/blast-radius/BlastRadius.psm1 +9 -3
  28. package/resources/claude-customizations/.claude/lib/blast-radius/BlastRadiusAggregate.psm1 +154 -0
  29. package/resources/claude-customizations/.claude/lib/blast-radius/BlastRadiusCombination.psm1 +228 -0
  30. package/resources/claude-customizations/.claude/lib/blast-radius/BlastRadiusExtraction.psm1 +1 -0
  31. package/resources/claude-customizations/.claude/lib/blast-radius/BlastRadiusNormalization.psm1 +4 -2
  32. package/resources/claude-customizations/.claude/lib/blast-radius/BlastRadiusScheduling.psm1 +63 -16
  33. package/resources/claude-customizations/.claude/lib/blast-radius/BlastRadiusTokenShape.psm1 +56 -1
  34. package/resources/claude-customizations/.claude/lib/blast-radius/BlastRadiusWriteIntent.psm1 +4 -1
  35. package/resources/claude-customizations/.claude/lib/codex-routing/CodexDeployment.psm1 +5 -1
  36. package/resources/claude-customizations/.claude/lib/codex-routing/CodexDiscoveryRouting.psm1 +161 -0
  37. package/resources/claude-customizations/.claude/lib/codex-routing/CodexTopology.psm1 +25 -2
  38. package/resources/claude-customizations/.claude/lib/discovery-validation/DiscoveryAuthority.psd1 +90 -0
  39. package/resources/claude-customizations/.claude/lib/discovery-validation/DiscoveryAuthority.psm1 +493 -0
  40. package/resources/claude-customizations/.claude/lib/discovery-validation/DiscoveryAuthorization.psm1 +479 -0
  41. package/resources/claude-customizations/.claude/lib/discovery-validation/DiscoveryCapability.psm1 +468 -0
  42. package/resources/claude-customizations/.claude/lib/discovery-validation/DiscoveryEncoding.psm1 +156 -0
  43. package/resources/claude-customizations/.claude/lib/discovery-validation/DiscoveryManifest.psm1 +384 -0
  44. package/resources/claude-customizations/.claude/lib/discovery-validation/DiscoveryObservation.psm1 +217 -0
  45. package/resources/claude-customizations/.claude/lib/discovery-validation/DiscoveryPayload.psm1 +276 -0
  46. package/resources/claude-customizations/.claude/lib/discovery-validation/DiscoveryPlanning.psm1 +336 -0
  47. package/resources/claude-customizations/.claude/lib/discovery-validation/DiscoveryRootGrant.psm1 +164 -0
  48. package/resources/claude-customizations/.claude/lib/discovery-validation/DiscoveryStart.psm1 +440 -0
  49. package/resources/claude-customizations/.claude/lib/discovery-validation/DiscoveryStartRecord.psm1 +296 -0
  50. package/resources/claude-customizations/.claude/lib/discovery-validation/DiscoveryStop.psm1 +336 -0
  51. package/resources/claude-customizations/.claude/lib/discovery-validation/DiscoveryToolRouting.psm1 +228 -0
  52. package/resources/claude-customizations/.claude/lib/discovery-validation/DiscoveryValidation.psm1 +188 -341
  53. package/resources/claude-customizations/.claude/lib/worktree-resolution/HookTargetWorktree.psm1 +391 -0
  54. package/resources/claude-customizations/.claude/rules/orchestrator-state.md +1 -0
  55. package/resources/claude-customizations/.claude/rules/parallel-orchestration.md +150 -18
  56. package/resources/claude-customizations/.claude/settings.json +32 -3
  57. package/resources/claude-customizations/.claude/skills/adjudicate-unspecified-behavior/SKILL.md +135 -0
  58. package/resources/claude-customizations/.claude/skills/characterize-runtime-behavior/SKILL.md +134 -0
  59. package/resources/claude-customizations/.claude/skills/discovery-behavior-reconciliation/SKILL.md +28 -47
  60. package/resources/claude-customizations/.claude/skills/discovery-coverage-ledger/SKILL.md +32 -48
  61. package/resources/claude-customizations/.claude/skills/discovery-parity-matrix/SKILL.md +28 -45
  62. package/resources/claude-customizations/.claude/skills/discovery-repo-inventory/SKILL.md +28 -62
  63. package/resources/claude-customizations/.claude/skills/discovery-runtime-characterization/SKILL.md +28 -45
  64. package/resources/claude-customizations/.claude/skills/discovery-validate-artifacts/SKILL.md +28 -61
  65. package/resources/claude-customizations/.claude/skills/discovery-workflow/SKILL.md +55 -139
  66. package/resources/claude-customizations/.claude/skills/execution-environment/SKILL.md +24 -9
  67. package/resources/claude-customizations/.claude/skills/extract-feature-contract/SKILL.md +133 -0
  68. package/resources/claude-customizations/.claude/skills/generate-acceptance-scenarios/SKILL.md +132 -0
  69. package/resources/claude-customizations/.claude/skills/initialize-discovery-workspace/SKILL.md +131 -0
  70. package/resources/claude-customizations/.claude/skills/inventory-legacy-system/SKILL.md +131 -0
  71. package/resources/claude-customizations/.claude/skills/orchestrate/SKILL.md +5 -4
  72. package/resources/claude-customizations/.claude/skills/parallel-add/SKILL.md +21 -1
  73. package/resources/claude-customizations/.claude/skills/parallel-close/SKILL.md +11 -1
  74. package/resources/claude-customizations/.claude/skills/parallel-orchestrate/SKILL.md +34 -0
  75. package/resources/claude-customizations/.claude/skills/parallel-plan/SKILL.md +69 -6
  76. package/resources/claude-customizations/.claude/skills/parallel-remove/SKILL.md +15 -0
  77. package/resources/claude-customizations/.claude/skills/parallel-run/SKILL.md +6 -0
  78. package/resources/claude-customizations/.claude/skills/pr-author/SKILL.md +21 -4
  79. package/resources/claude-customizations/.claude/skills/reconcile-parity-matrix/SKILL.md +131 -0
  80. package/resources/claude-customizations/.claude/skills/review-discovery-coverage/SKILL.md +129 -0
  81. package/resources/claude-customizations/.claude/skills/validate-discovery-artifacts/SKILL.md +127 -0
  82. package/resources/claude-customizations/config/blast-radius.json +6 -5
  83. package/resources/claude-customizations/config/orchestration-routing.json +61 -1
  84. package/resources/claude-customizations/pack-manifests/core.json +33 -1
  85. package/resources/codex-and-agents-customizations/.agents/skills/adjudicate-unspecified-behavior/SKILL.md +135 -0
  86. package/resources/codex-and-agents-customizations/.agents/skills/atomic-planner/SKILL.md +7 -0
  87. package/resources/codex-and-agents-customizations/.agents/skills/characterize-runtime-behavior/SKILL.md +134 -0
  88. package/resources/codex-and-agents-customizations/.agents/skills/discovery-behavior-reconciliation/SKILL.md +46 -0
  89. package/resources/codex-and-agents-customizations/.agents/skills/discovery-coverage-ledger/SKILL.md +50 -0
  90. package/resources/codex-and-agents-customizations/.agents/skills/discovery-parity-matrix/SKILL.md +46 -0
  91. package/resources/codex-and-agents-customizations/.agents/skills/discovery-repo-inventory/SKILL.md +46 -0
  92. package/resources/codex-and-agents-customizations/.agents/skills/discovery-runtime-characterization/SKILL.md +46 -0
  93. package/resources/codex-and-agents-customizations/.agents/skills/discovery-validate-artifacts/SKILL.md +46 -0
  94. package/resources/codex-and-agents-customizations/.agents/skills/discovery-workflow/SKILL.md +62 -0
  95. package/resources/codex-and-agents-customizations/.agents/skills/execution-environment/SKILL.md +24 -9
  96. package/resources/codex-and-agents-customizations/.agents/skills/extract-feature-contract/SKILL.md +133 -0
  97. package/resources/codex-and-agents-customizations/.agents/skills/generate-acceptance-scenarios/SKILL.md +132 -0
  98. package/resources/codex-and-agents-customizations/.agents/skills/initialize-discovery-workspace/SKILL.md +131 -0
  99. package/resources/codex-and-agents-customizations/.agents/skills/inventory-legacy-system/SKILL.md +131 -0
  100. package/resources/codex-and-agents-customizations/.agents/skills/orchestrator-workflow/SKILL.md +8 -0
  101. package/resources/codex-and-agents-customizations/.agents/skills/reconcile-parity-matrix/SKILL.md +131 -0
  102. package/resources/codex-and-agents-customizations/.agents/skills/review-discovery-coverage/SKILL.md +129 -0
  103. package/resources/codex-and-agents-customizations/.agents/skills/validate-discovery-artifacts/SKILL.md +127 -0
  104. package/resources/codex-and-agents-customizations/.codex/agents/discovery-capability-sentinel.toml +51 -0
  105. package/resources/codex-and-agents-customizations/.codex/agents/legacy-parity-analyst-c1.toml +148 -0
  106. package/resources/codex-and-agents-customizations/.codex/agents/legacy-parity-analyst-c2.toml +148 -0
  107. package/resources/codex-and-agents-customizations/.codex/agents/legacy-parity-analyst-c3-elevated.toml +148 -0
  108. package/resources/codex-and-agents-customizations/.codex/agents/legacy-parity-analyst-c3.toml +148 -0
  109. package/resources/codex-and-agents-customizations/.codex/agents/legacy-parity-analyst-c4.toml +148 -0
  110. package/resources/codex-and-agents-customizations/.codex/agents/legacy-parity-analyst.toml +148 -0
  111. package/resources/codex-and-agents-customizations/.codex/agents/migration-coverage-reviewer-c1.toml +154 -0
  112. package/resources/codex-and-agents-customizations/.codex/agents/migration-coverage-reviewer-c2.toml +154 -0
  113. package/resources/codex-and-agents-customizations/.codex/agents/migration-coverage-reviewer-c3-elevated.toml +154 -0
  114. package/resources/codex-and-agents-customizations/.codex/agents/migration-coverage-reviewer-c3.toml +154 -0
  115. package/resources/codex-and-agents-customizations/.codex/agents/migration-coverage-reviewer-c4.toml +154 -0
  116. package/resources/codex-and-agents-customizations/.codex/agents/migration-coverage-reviewer.toml +154 -0
  117. package/resources/codex-and-agents-customizations/.codex/agents/requirements-reconciler-c1.toml +164 -0
  118. package/resources/codex-and-agents-customizations/.codex/agents/requirements-reconciler-c2.toml +164 -0
  119. package/resources/codex-and-agents-customizations/.codex/agents/requirements-reconciler-c3-elevated.toml +164 -0
  120. package/resources/codex-and-agents-customizations/.codex/agents/requirements-reconciler-c3.toml +164 -0
  121. package/resources/codex-and-agents-customizations/.codex/agents/requirements-reconciler-c4.toml +164 -0
  122. package/resources/codex-and-agents-customizations/.codex/agents/requirements-reconciler.toml +164 -0
  123. package/resources/codex-and-agents-customizations/.codex/agents/runtime-characterization-analyst-c1.toml +150 -0
  124. package/resources/codex-and-agents-customizations/.codex/agents/runtime-characterization-analyst-c2.toml +150 -0
  125. package/resources/codex-and-agents-customizations/.codex/agents/runtime-characterization-analyst-c3-elevated.toml +150 -0
  126. package/resources/codex-and-agents-customizations/.codex/agents/runtime-characterization-analyst-c3.toml +150 -0
  127. package/resources/codex-and-agents-customizations/.codex/agents/runtime-characterization-analyst-c4.toml +150 -0
  128. package/resources/codex-and-agents-customizations/.codex/agents/runtime-characterization-analyst.toml +150 -0
  129. package/resources/codex-and-agents-customizations/.codex/config.toml +33 -3
  130. package/resources/codex-and-agents-customizations/.codex/hooks/codex-agent-profile-attestation.ps1 +22 -0
  131. package/resources/codex-and-agents-customizations/.codex/hooks/discovery-capability-probe.ps1 +97 -0
  132. package/resources/codex-and-agents-customizations/.codex/hooks/discovery-start-attestation.ps1 +171 -0
  133. package/resources/codex-and-agents-customizations/.codex/hooks/enforce-codex-model-routing.ps1 +4 -1
  134. package/resources/codex-and-agents-customizations/.codex/hooks/enforce-discovery-artifact-gate.ps1 +187 -0
  135. package/resources/codex-and-agents-customizations/.codex/hooks/enforce-discovery-planning-gate.ps1 +149 -0
  136. package/resources/codex-and-agents-customizations/.codex/hooks/enforce-orchestration-preimplementation-gate-epic-scope.ps1 +239 -2
  137. package/resources/codex-and-agents-customizations/.codex/hooks/enforce-orchestration-preimplementation-gate.ps1 +6 -15
  138. package/resources/codex-and-agents-customizations/.codex/hooks/enforce-powershell-batch-budget.ps1 +54 -15
  139. package/resources/codex-and-agents-customizations/.codex/hooks/enforce-python-batch-budget.ps1 +54 -15
  140. package/resources/codex-and-agents-customizations/.codex/hooks/hook-target-worktree.ps1 +369 -0
  141. package/resources/codex-and-agents-customizations/.codex/hooks/record-subagent-routing-attestation.ps1 +8 -0
  142. package/resources/codex-and-agents-customizations/.codex/hooks/validate-codex-subagent-routing.ps1 +22 -1
  143. package/resources/codex-and-agents-customizations/.codex/hooks/validate-discovery-agent-output.ps1 +178 -0
  144. package/resources/codex-and-agents-customizations/.codex/scripts/Resolve-CodexTopology.ps1 +11 -1
  145. package/resources/codex-and-agents-customizations/pack-manifests/core.json +63 -1
  146. package/resources/config/orchestration-routing.json +61 -1
  147. package/resources/customizations/.github/agents/discovery-capability-sentinel.agent.md +34 -0
  148. package/resources/customizations/.github/agents/legacy-parity-analyst.agent.md +122 -0
  149. package/resources/customizations/.github/agents/migration-coverage-reviewer.agent.md +128 -0
  150. package/resources/customizations/.github/agents/requirements-reconciler.agent.md +138 -0
  151. package/resources/customizations/.github/agents/runtime-characterization-analyst.agent.md +124 -0
  152. package/resources/customizations/.github/hooks/discovery-agent-start.ps1 +139 -0
  153. package/resources/customizations/.github/hooks/discovery-agent-stop.ps1 +123 -0
  154. package/resources/customizations/.github/hooks/discovery-capability-probe.ps1 +98 -0
  155. package/resources/customizations/.github/hooks/discovery-planning.ps1 +91 -0
  156. package/resources/customizations/.github/hooks/discovery-pre-tool.ps1 +123 -0
  157. package/resources/customizations/.github/hooks/discovery.json +35 -0
  158. package/resources/customizations/.github/instructions/mermaid.instructions.md +85 -0
  159. package/resources/customizations/.github/prompts/execute-hard-lock.prompt.md +105 -0
  160. package/resources/customizations/.github/skills/adjudicate-unspecified-behavior/SKILL.md +135 -0
  161. package/resources/customizations/.github/skills/characterize-runtime-behavior/SKILL.md +134 -0
  162. package/resources/customizations/.github/skills/discovery-behavior-reconciliation/SKILL.md +46 -0
  163. package/resources/customizations/.github/skills/discovery-coverage-ledger/SKILL.md +50 -0
  164. package/resources/customizations/.github/skills/discovery-parity-matrix/SKILL.md +46 -0
  165. package/resources/customizations/.github/skills/discovery-repo-inventory/SKILL.md +46 -0
  166. package/resources/customizations/.github/skills/discovery-runtime-characterization/SKILL.md +46 -0
  167. package/resources/customizations/.github/skills/discovery-validate-artifacts/SKILL.md +46 -0
  168. package/resources/customizations/.github/skills/discovery-workflow/SKILL.md +62 -0
  169. package/resources/customizations/.github/skills/execution-environment/SKILL.md +24 -9
  170. package/resources/customizations/.github/skills/extract-feature-contract/SKILL.md +133 -0
  171. package/resources/customizations/.github/skills/generate-acceptance-scenarios/SKILL.md +132 -0
  172. package/resources/customizations/.github/skills/initialize-discovery-workspace/SKILL.md +131 -0
  173. package/resources/customizations/.github/skills/inventory-legacy-system/SKILL.md +131 -0
  174. package/resources/customizations/.github/skills/reconcile-parity-matrix/SKILL.md +131 -0
  175. package/resources/customizations/.github/skills/review-discovery-coverage/SKILL.md +129 -0
  176. package/resources/customizations/.github/skills/validate-discovery-artifacts/SKILL.md +127 -0
  177. package/resources/lib/codex-routing/CodexDeployment.psm1 +5 -1
  178. package/resources/lib/codex-routing/CodexDiscoveryRouting.psm1 +161 -0
  179. package/resources/lib/codex-routing/CodexTopology.psm1 +25 -2
  180. package/resources/lib/discovery-validation/DiscoveryAuthority.psd1 +90 -0
  181. package/resources/lib/discovery-validation/DiscoveryAuthority.psm1 +493 -0
  182. package/resources/lib/discovery-validation/DiscoveryAuthorization.psm1 +479 -0
  183. package/resources/lib/discovery-validation/DiscoveryCapability.psm1 +468 -0
  184. package/resources/lib/discovery-validation/DiscoveryEncoding.psm1 +156 -0
  185. package/resources/lib/discovery-validation/DiscoveryManifest.psm1 +384 -0
  186. package/resources/lib/discovery-validation/DiscoveryObservation.psm1 +217 -0
  187. package/resources/lib/discovery-validation/DiscoveryPayload.psm1 +276 -0
  188. package/resources/lib/discovery-validation/DiscoveryPlanning.psm1 +336 -0
  189. package/resources/lib/discovery-validation/DiscoveryRootGrant.psm1 +164 -0
  190. package/resources/lib/discovery-validation/DiscoveryStart.psm1 +440 -0
  191. package/resources/lib/discovery-validation/DiscoveryStartRecord.psm1 +296 -0
  192. package/resources/lib/discovery-validation/DiscoveryStop.psm1 +336 -0
  193. package/resources/lib/discovery-validation/DiscoveryToolRouting.psm1 +228 -0
  194. package/resources/powershell/PoshQC/PoshQC.Parallel.psm1 +452 -0
  195. package/resources/powershell/PoshQC/PoshQC.Shard.psm1 +106 -8
  196. package/resources/powershell/PoshQC/PoshQC.Testing.psm1 +12 -0
  197. package/resources/powershell/PoshQC/PoshQC.psd1 +6 -1
  198. package/resources/powershell/PoshQC/PoshQC.psm1 +7 -1
  199. package/resources/powershell/PoshQC/README.md +20 -1
@@ -0,0 +1,468 @@
1
+ <#
2
+ .SYNOPSIS
3
+ Native host capability observation for discovery agents: registration, handler hashes and the harmless sentinel.
4
+
5
+ .DESCRIPTION
6
+ Every native capability probe (Claude Code, Codex, GitHub Copilot) takes its
7
+ observation from this module. The probe reads the host's hook registration
8
+ (`.claude/settings.json`, `.codex/config.toml` or `.github/hooks/discovery.json`),
9
+ confirms that the start, tool and stop handlers are registered for the four
10
+ discovery agent names and the controlled tools, hashes the configuration and
11
+ every handler, and reads the host observations the native PreToolUse gate
12
+ recorded while the discovery-capability-sentinel agent ran: a sentinel tool call
13
+ that carried the agent identity and was consumed by the sentinel MCP tool, and
14
+ the denied sentinel shell command from the same agent. Consumption is the
15
+ exclusive `<record>.json.consumed` latch the MCP server writes beside the
16
+ observation and never a rewrite of the record: the gate writes the record once
17
+ with state `issued`, and the probe accepts it only when the latch beside it names
18
+ the same observation_ref. The result is a `capability-attestation`-shaped document
19
+ whose status is derived, never supplied: `supported` requires every control,
20
+ every handler and observation evidence for attribution and the denied sentinel.
21
+ No in-process decision, caller Boolean or repository text can substitute for
22
+ these observations; the gateway alone writes the protected attestation.
23
+
24
+ .NOTES
25
+ Issue #980. Compatible with PowerShell 7+. Depends only on the sibling
26
+ DiscoveryEncoding, DiscoveryAuthority, DiscoveryPayload and DiscoveryObservation modules.
27
+ CONVENTION: this module fails fast at module scope and imports its siblings with -ErrorAction Stop.
28
+ #>
29
+
30
+ Set-StrictMode -Version Latest
31
+ $ErrorActionPreference = 'Stop'
32
+
33
+ Import-Module (Join-Path $PSScriptRoot 'DiscoveryEncoding.psm1') -ErrorAction Stop
34
+ Import-Module (Join-Path $PSScriptRoot 'DiscoveryAuthority.psm1') -ErrorAction Stop
35
+ Import-Module (Join-Path $PSScriptRoot 'DiscoveryPayload.psm1') -ErrorAction Stop
36
+ Import-Module (Join-Path $PSScriptRoot 'DiscoveryObservation.psm1') -ErrorAction Stop
37
+
38
+ $script:CapabilitySchema = 'urn:drm-copilot:discovery:2.0.0:capability-attestation'
39
+ $script:CapabilityAttestationFile = 'capability-attestation.json'
40
+ $script:DiscoveryAgentNames = @('legacy-parity-analyst', 'runtime-characterization-analyst', 'requirements-reconciler', 'migration-coverage-reviewer')
41
+ $script:ControlledToolNames = @('Bash', 'Write', 'mcp__drm-copilot__run_discovery_repo_inventory', 'Agent')
42
+ $script:ControlNames = @('workspace_trusted', 'hooks_enabled', 'trusted_attribution', 'start_event', 'tool_event', 'stop_event', 'forbidden_tools_denied')
43
+ $script:HandlerEvents = @('start', 'tool', 'stop')
44
+ $script:EventNames = @{ start = 'SubagentStart'; tool = 'PreToolUse'; stop = 'SubagentStop' }
45
+ # Per-host registration file, handler directory and the handler each lifecycle event names.
46
+ $script:HostLayout = @{
47
+ 'claude-code' = @{ Configuration = '.claude/settings.json'; Directory = '.claude/hooks'; start = 'record-discovery-agent-start.ps1'; tool = 'enforce-discovery-artifact-gate.ps1'; stop = 'validate-discovery-artifact-gate.ps1' }
48
+ 'codex' = @{ Configuration = '.codex/config.toml'; Directory = '.codex/hooks'; start = 'record-subagent-routing-attestation.ps1'; tool = 'enforce-discovery-artifact-gate.ps1'; stop = 'validate-discovery-agent-output.ps1' }
49
+ 'github-copilot' = @{ Configuration = '.github/hooks/discovery.json'; Directory = '.github/hooks'; start = 'discovery-agent-start.ps1'; tool = 'discovery-pre-tool.ps1'; stop = 'discovery-agent-stop.ps1' }
50
+ }
51
+
52
+ function Get-DiscoverySentinelRequest {
53
+ <#
54
+ .SYNOPSIS
55
+ Returns the harmless sentinel command text the native hook must deny and never execute.
56
+ #>
57
+ [CmdletBinding()]
58
+ [OutputType([string])]
59
+ param([Parameter(Mandatory = $true)][string]$SentinelId)
60
+
61
+ return "drm-copilot discovery capability probe $SentinelId" + ': this command must be denied by the native hook and never executed'
62
+ }
63
+
64
+ function Get-DiscoverySentinelEnvelope {
65
+ <#
66
+ .SYNOPSIS
67
+ Builds the host-shaped PreToolUse envelope of the sentinel: an attested specialist running a shell command.
68
+ #>
69
+ [CmdletBinding()]
70
+ [OutputType([string])]
71
+ param(
72
+ [Parameter(Mandatory = $true)][ValidateSet('claude-code', 'codex', 'github-copilot')][string]$HostName,
73
+ [Parameter(Mandatory = $true)][string]$SessionId,
74
+ [Parameter(Mandatory = $true)][string]$SentinelId,
75
+ [Parameter(Mandatory = $true)][string]$WorkspaceRoot
76
+ )
77
+
78
+ $command = Get-DiscoverySentinelRequest -SentinelId $SentinelId
79
+ if ($HostName -eq 'github-copilot') {
80
+ $envelope = [ordered]@{ sessionId = $SessionId; hookEventName = 'PreToolUse'; toolName = 'Bash'; toolInput = [ordered]@{ command = $command }; cwd = $WorkspaceRoot; toolUseId = $SentinelId }
81
+ } else {
82
+ $envelope = [ordered]@{ session_id = $SessionId; hook_event_name = 'PreToolUse'; tool_name = 'Bash'; tool_input = [ordered]@{ command = $command }; cwd = $WorkspaceRoot; tool_use_id = $SentinelId }
83
+ }
84
+ return ($envelope | ConvertTo-Json -Compress -Depth 4)
85
+ }
86
+
87
+ function ConvertTo-DiscoveryObservationInstant {
88
+ <#
89
+ .SYNOPSIS
90
+ Returns a UTC instant from a parsed observation timestamp (string or DateTime), or $null when unreadable.
91
+ #>
92
+ [CmdletBinding()]
93
+ [OutputType([datetime])]
94
+ param([Parameter(Mandatory = $false)][AllowNull()][object]$Value)
95
+
96
+ if ($Value -is [datetime]) { return $Value.ToUniversalTime() }
97
+ $parsed = [datetime]::MinValue
98
+ $styles = [System.Globalization.DateTimeStyles]::AssumeUniversal -bor [System.Globalization.DateTimeStyles]::AdjustToUniversal
99
+ if ($Value -is [string] -and [datetime]::TryParseExact($Value, "yyyy-MM-dd'T'HH:mm:ss'Z'", [System.Globalization.CultureInfo]::InvariantCulture, $styles, [ref]$parsed)) { return $parsed }
100
+ return $null
101
+ }
102
+
103
+ function Get-DiscoveryConsumedLatch {
104
+ <#
105
+ .SYNOPSIS
106
+ Reads the exclusive `.consumed` latch beside a one-use record through the ReadText seam.
107
+ .DESCRIPTION
108
+ The MCP server consumes a sentinel grant by creating `<record>.json.consumed`
109
+ with a JSON object naming the record's observation_ref; the record itself is
110
+ never rewritten. Returns the parsed latch as a hashtable, or $null when the
111
+ latch is absent, unreadable or not a JSON object.
112
+ #>
113
+ [CmdletBinding()]
114
+ [OutputType([hashtable])]
115
+ param(
116
+ [Parameter(Mandatory = $true)][hashtable]$Store,
117
+ [Parameter(Mandatory = $true)][string]$Path
118
+ )
119
+
120
+ try {
121
+ $text = Invoke-DiscoveryStoreSeam -Seam $Store.ReadText -Arguments @($Path)
122
+ $latch = $(if ($null -ne $text) { ConvertFrom-Json -InputObject $text -AsHashtable -Depth 4 } else { $null })
123
+ } catch { $latch = $null }
124
+ if ($latch -is [hashtable]) { return $latch }
125
+ return $null
126
+ }
127
+
128
+ function Get-DiscoverySessionObservation {
129
+ <#
130
+ .SYNOPSIS
131
+ Reads every parseable host-observation record of the session from the protected store, with its consumer latch.
132
+ .DESCRIPTION
133
+ Entries that cannot be read or parsed are skipped; an absent directory yields
134
+ an empty list. Records are returned as hashtables keyed by the schema names
135
+ plus the reader-side entry `consumed_latch`: the sibling `<name>.consumed`
136
+ the sentinel MCP tool writes when it consumes the grant, read by path through
137
+ the same ReadText seam, as a hashtable when it parses as a JSON object and
138
+ otherwise $null.
139
+ #>
140
+ [CmdletBinding()]
141
+ [OutputType([object[]])]
142
+ param(
143
+ [Parameter(Mandatory = $true)][pscustomobject]$Location,
144
+ [Parameter(Mandatory = $true)][string]$SessionId,
145
+ [Parameter(Mandatory = $true)][hashtable]$Store
146
+ )
147
+
148
+ $directory = Get-DiscoveryObservationDirectory -Location $Location -SessionId $SessionId
149
+ $names = @()
150
+ try { $names = @(Invoke-DiscoveryStoreSeam -Seam $Store.ListEntries -Arguments @($directory)) } catch { $names = @() }
151
+ $records = [System.Collections.Generic.List[object]]::new()
152
+ # Each entry is one observation file; unreadable or non-object entries carry no evidence.
153
+ foreach ($name in @($names | Where-Object { $_ -is [string] -and $_.EndsWith('.json') })) {
154
+ try {
155
+ $text = Invoke-DiscoveryStoreSeam -Seam $Store.ReadText -Arguments @("$directory/$name")
156
+ $record = $(if ($null -ne $text) { ConvertFrom-Json -InputObject $text -AsHashtable -Depth 8 } else { $null })
157
+ } catch { $record = $null }
158
+ if ($record -is [hashtable] -and [string]$record['artifact_kind'] -eq 'host-observation') {
159
+ # The latch is the consumption marker; it is attached here so the evidence derivation never touches the store.
160
+ $record['consumed_latch'] = Get-DiscoveryConsumedLatch -Store $Store -Path "$directory/$name.consumed"
161
+ $records.Add($record)
162
+ }
163
+ }
164
+ return $records.ToArray()
165
+ }
166
+
167
+ function Get-DiscoveryObservationEvidence {
168
+ <#
169
+ .SYNOPSIS
170
+ Derives trusted_attribution and forbidden_tools_denied from the session's host observations.
171
+ .DESCRIPTION
172
+ trusted_attribution holds only for a sentinel-tool observation that carries a
173
+ non-empty agent_id, the sentinel agent type, state `issued` beside a consumer
174
+ latch (`consumed_latch`, the parsed `<record>.json.consumed` the sentinel MCP
175
+ tool writes) whose observation_ref equals the record's, the hash of the
176
+ registered tool handler and an observed_at within TtlSeconds of Now. A record
177
+ rewritten as `consumed`, a missing latch or a latch naming another observation
178
+ is reported as not consumed, because the record is never rewritten and only
179
+ the latch beside it proves consumption. forbidden_tools_denied holds only for
180
+ a SHELL deny observation from the same agent_id. Every rejected or missing
181
+ observation yields one deficiency message.
182
+ .OUTPUTS
183
+ [pscustomobject] TrustedAttribution, ForbiddenDenied, Deficiencies, SentinelRef, ShellRef.
184
+ #>
185
+ [CmdletBinding()]
186
+ [OutputType([pscustomobject])]
187
+ param(
188
+ [Parameter(Mandatory = $true)][AllowEmptyCollection()][object[]]$Observations,
189
+ [Parameter(Mandatory = $true)][AllowNull()][AllowEmptyString()][string]$ToolHandlerSha256,
190
+ [Parameter(Mandatory = $true)][datetime]$Now,
191
+ [Parameter(Mandatory = $true)][int]$TtlSeconds
192
+ )
193
+
194
+ $deficiencies = [System.Collections.Generic.List[object]]::new()
195
+ $accepted = $null
196
+ $candidates = @($Observations | Where-Object { [string]$_['code'] -ceq 'SENTINEL' -and [string]$_['tool'] -ceq 'discovery_capability_sentinel' })
197
+ if ($candidates.Count -eq 0) { $deficiencies.Add([pscustomobject]@{ Control = 'trusted_attribution'; Message = 'no sentinel tool observation was recorded for this session' }) }
198
+ # Each sentinel-tool observation must pass every check; the first that does supplies the attributed agent.
199
+ foreach ($candidate in $candidates) {
200
+ $reference = [string]$candidate['observation_ref']
201
+ $observedAt = ConvertTo-DiscoveryObservationInstant -Value $candidate['observed_at']
202
+ $problem = $null
203
+ # Consumption is proved by the latch alone: present and naming this record, or absent, or naming another record.
204
+ $latch = $candidate['consumed_latch']
205
+ $latchState = $(if ($latch -isnot [hashtable]) { 'absent' } elseif ([string]$latch['observation_ref'] -ceq $reference) { 'present' } else { 'names another observation' })
206
+ if ([string]::IsNullOrWhiteSpace([string]$candidate['agent_id']) -or [string]$candidate['agent_type'] -cne 'discovery-capability-sentinel') { $problem = 'carries no sentinel agent identity' }
207
+ elseif ([string]$candidate['state'] -cne 'issued' -or $latchState -ne 'present') { $problem = "was not consumed by the sentinel tool (state $([string]$candidate['state']), latch $latchState)" }
208
+ elseif ([string]::IsNullOrEmpty($ToolHandlerSha256) -or [string]$candidate['handler_sha256'] -cne $ToolHandlerSha256) { $problem = 'was recorded by a tool handler whose hash differs from the registered handler' }
209
+ elseif ($null -eq $observedAt -or ($Now.ToUniversalTime() - $observedAt).TotalSeconds -gt $TtlSeconds -or $observedAt -gt $Now.ToUniversalTime()) { $problem = 'is stale' }
210
+ if ($null -eq $problem) { if ($null -eq $accepted) { $accepted = $candidate } }
211
+ else { $deficiencies.Add([pscustomobject]@{ Control = 'trusted_attribution'; Message = "sentinel observation $reference $problem" }) }
212
+ }
213
+ $shell = $null
214
+ if ($null -ne $accepted) {
215
+ $shell = @($Observations | Where-Object { [string]$_['code'] -ceq 'SHELL' -and [string]$_['decision'] -ceq 'deny' -and [string]$_['agent_id'] -ceq [string]$accepted['agent_id'] }) | Select-Object -First 1
216
+ }
217
+ if ($null -eq $shell) { $deficiencies.Add([pscustomobject]@{ Control = 'forbidden_tools_denied'; Message = 'no SHELL deny observation from the attributed sentinel agent was recorded' }) }
218
+ return [pscustomobject]@{
219
+ TrustedAttribution = ($null -ne $accepted)
220
+ ForbiddenDenied = ($null -ne $shell)
221
+ Deficiencies = @($deficiencies)
222
+ SentinelRef = $(if ($null -ne $accepted) { [string]$accepted['observation_ref'] } else { $null })
223
+ ShellRef = $(if ($null -ne $shell) { [string]$shell['observation_ref'] } else { $null })
224
+ }
225
+ }
226
+
227
+ function Get-DiscoveryHostRegistration {
228
+ <#
229
+ .SYNOPSIS
230
+ Parses a host registration text into event entries (Event, Matcher, Command).
231
+ .DESCRIPTION
232
+ Claude settings and Copilot hook files are JSON: an event maps to groups
233
+ (`matcher` plus `hooks[]`) or directly to hook objects. A hook object names
234
+ its handler through `powershell`, `bash` or `command`, read in that order, so
235
+ the documented Copilot shape (`version` 1 with `powershell` and `bash`) and
236
+ the Claude `command` shape both parse. The Codex file is TOML scanned line by
237
+ line: a `[[hooks.<Event>]]` table starts a group, `matcher` sets its regex and
238
+ each `command` names a handler. Parsed is $false when the text cannot be read
239
+ as the host's format.
240
+ #>
241
+ [CmdletBinding()]
242
+ [OutputType([pscustomobject])]
243
+ param(
244
+ [Parameter(Mandatory = $true)][ValidateSet('claude-code', 'codex', 'github-copilot')][string]$HostName,
245
+ [Parameter(Mandatory = $false)][AllowNull()][AllowEmptyString()][string]$ConfigurationText
246
+ )
247
+
248
+ $entries = [System.Collections.Generic.List[object]]::new()
249
+ if ([string]::IsNullOrWhiteSpace($ConfigurationText)) { return [pscustomobject]@{ Parsed = $false; Entries = @() } }
250
+ if ($HostName -eq 'codex') {
251
+ $currentEvent = $null
252
+ $currentMatcher = $null
253
+ foreach ($line in ($ConfigurationText -split "`r?`n")) {
254
+ if ($line -match '^\[\[hooks\.(?<event>[A-Za-z]+)\]\]\s*$') { $currentEvent = $Matches['event']; $currentMatcher = $null; continue }
255
+ if ($null -ne $currentEvent -and $line -match '^matcher\s*=\s*"(?<matcher>.+)"\s*$') { $currentMatcher = $Matches['matcher']; continue }
256
+ if ($null -ne $currentEvent -and $line -match '^command\s*=\s*''(?<command>.+)''\s*$') {
257
+ $entries.Add([pscustomobject]@{ Event = $currentEvent; Matcher = $currentMatcher; Command = $Matches['command'] })
258
+ }
259
+ }
260
+ return [pscustomobject]@{ Parsed = ($entries.Count -gt 0); Entries = @($entries) }
261
+ }
262
+ try { $document = ConvertFrom-Json -InputObject $ConfigurationText -Depth 16 } catch { return [pscustomobject]@{ Parsed = $false; Entries = @() } }
263
+ if ($document -isnot [System.Management.Automation.PSCustomObject] -or $null -eq $document.PSObject.Properties['hooks'] -or $document.hooks -isnot [System.Management.Automation.PSCustomObject]) {
264
+ return [pscustomobject]@{ Parsed = $false; Entries = @() }
265
+ }
266
+ foreach ($eventProperty in $document.hooks.PSObject.Properties) {
267
+ foreach ($item in @($eventProperty.Value)) {
268
+ if ($item -isnot [System.Management.Automation.PSCustomObject]) { continue }
269
+ $matcher = $(if ($null -ne $item.PSObject.Properties['matcher']) { [string]$item.matcher } else { $null })
270
+ $hooks = $(if ($null -ne $item.PSObject.Properties['hooks']) { @($item.hooks) } else { @($item) })
271
+ foreach ($hook in $hooks) {
272
+ if ($hook -isnot [System.Management.Automation.PSCustomObject]) { continue }
273
+ # The first present handler key wins: the Copilot shape names powershell and bash, the Claude shape names command.
274
+ $command = $null
275
+ foreach ($key in @('powershell', 'bash', 'command')) {
276
+ if ($null -ne $hook.PSObject.Properties[$key] -and -not [string]::IsNullOrWhiteSpace([string]$hook.$key)) { $command = [string]$hook.$key; break }
277
+ }
278
+ if ($null -ne $command) { $entries.Add([pscustomobject]@{ Event = $eventProperty.Name; Matcher = $matcher; Command = $command }) }
279
+ }
280
+ }
281
+ }
282
+ return [pscustomobject]@{ Parsed = $true; Entries = @($entries) }
283
+ }
284
+
285
+ function Test-DiscoveryHandlerRegistered {
286
+ <#
287
+ .SYNOPSIS
288
+ True when Event registers HandlerName with a matcher that admits every name in Admitted (or no matcher).
289
+ #>
290
+ [CmdletBinding()]
291
+ [OutputType([bool])]
292
+ param(
293
+ [Parameter(Mandatory = $true)][pscustomobject]$Registration,
294
+ [Parameter(Mandatory = $true)][string]$Event,
295
+ [Parameter(Mandatory = $true)][string]$HandlerName,
296
+ [Parameter(Mandatory = $true)][AllowEmptyCollection()][string[]]$Admitted
297
+ )
298
+
299
+ $entries = @($Registration.Entries | Where-Object { $_.Event -eq $Event -and ([string]$_.Command).Contains($HandlerName) })
300
+ if ($entries.Count -eq 0) { return $false }
301
+ # The handler may be registered under several matcher groups; their union must admit every name.
302
+ foreach ($name in $Admitted) {
303
+ $covered = @($entries | Where-Object { [string]::IsNullOrEmpty($_.Matcher) -or $name -match $_.Matcher })
304
+ if ($covered.Count -eq 0) { return $false }
305
+ }
306
+ return $true
307
+ }
308
+
309
+ function Get-DiscoveryCapabilityObservation {
310
+ <#
311
+ .SYNOPSIS
312
+ Observes one host's discovery controls and returns the capability-attestation-shaped document.
313
+ .DESCRIPTION
314
+ Controls: hooks_enabled (the registration file is present and parseable and
315
+ names a discovery handler), start_event, tool_event and stop_event (the
316
+ handler is registered for the discovery agents or controlled tools and its
317
+ file exists), trusted_attribution and forbidden_tools_denied (proved only
318
+ from the session's host observations recorded by the native PreToolUse gate
319
+ while the capability sentinel agent ran; see Get-DiscoveryObservationEvidence),
320
+ workspace_trusted (supplied by the host probe, which the host only runs inside
321
+ a trusted workspace). The protected activation supplies the workspace binding;
322
+ without one the status is unsupported. A session without observations is
323
+ unsupported, so a supported result requires the session the sentinel ran in.
324
+ #>
325
+ [CmdletBinding()]
326
+ [OutputType([System.Collections.Specialized.OrderedDictionary])]
327
+ param(
328
+ [Parameter(Mandatory = $true)][ValidateSet('claude-code', 'codex', 'github-copilot')][string]$HostName,
329
+ [Parameter(Mandatory = $true)][string]$WorkspaceRoot,
330
+ [Parameter(Mandatory = $true)][string]$SessionId,
331
+ [Parameter(Mandatory = $true)][string]$Nonce,
332
+ [Parameter(Mandatory = $true)][System.Collections.IDictionary]$Runtime,
333
+ [Parameter(Mandatory = $true)][hashtable]$HostEnvironment,
334
+ [Parameter(Mandatory = $false)][hashtable]$Store = (Get-DiscoveryDefaultStore),
335
+ [Parameter(Mandatory = $false)][bool]$WorkspaceTrusted = $true,
336
+ [Parameter(Mandatory = $false)][datetime]$Now = [datetime]::UtcNow,
337
+ [Parameter(Mandatory = $false)][int]$TtlSeconds = 3600,
338
+ [Parameter(Mandatory = $false)][scriptblock]$ReadBytes = {
339
+ param([string]$Path)
340
+ if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { return $null }
341
+ return [System.IO.File]::ReadAllBytes($Path)
342
+ }
343
+ )
344
+
345
+ $layout = $script:HostLayout[$HostName]
346
+ $root = $WorkspaceRoot.TrimEnd('/', '\')
347
+ $findings = [System.Collections.Generic.List[object]]::new()
348
+ $controls = [ordered]@{}
349
+ foreach ($name in $script:ControlNames) { $controls[$name] = $false }
350
+ $controls['workspace_trusted'] = $WorkspaceTrusted
351
+ $configurationBytes = & $ReadBytes "$root/$($layout.Configuration)"
352
+ $configurationText = $null
353
+ if ($null -ne $configurationBytes) {
354
+ [byte[]]$configurationBytes = @($configurationBytes)
355
+ try { $configurationText = [System.Text.UTF8Encoding]::new($false, $true).GetString($configurationBytes) } catch { $configurationText = $null }
356
+ } else { [byte[]]$configurationBytes = @() }
357
+ $registration = Get-DiscoveryHostRegistration -HostName $HostName -ConfigurationText $configurationText
358
+ $handlers = [System.Collections.Generic.List[object]]::new()
359
+ foreach ($lifecycle in $script:HandlerEvents) {
360
+ $handlerName = $layout[$lifecycle]
361
+ $admitted = $(if ($lifecycle -eq 'tool') { $script:ControlledToolNames } else { $script:DiscoveryAgentNames })
362
+ $registered = $registration.Parsed -and (Test-DiscoveryHandlerRegistered -Registration $registration -Event $script:EventNames[$lifecycle] -HandlerName $handlerName -Admitted $admitted)
363
+ $relativePath = "$($layout.Directory)/$handlerName"
364
+ $bytes = & $ReadBytes "$root/$relativePath"
365
+ if ($registered -and $null -ne $bytes) {
366
+ $handlers.Add([ordered]@{ event = $lifecycle; path = $relativePath; sha256 = (Get-DiscoveryBytesSha256Hex -Bytes ([byte[]]@($bytes))) })
367
+ $controls["$($lifecycle)_event"] = $true
368
+ }
369
+ }
370
+ $controls['hooks_enabled'] = $registration.Parsed -and ($handlers.Count -gt 0)
371
+ # The workspace binding comes from the protected activation, never from the caller.
372
+ $location = Resolve-DiscoveryAuthorityLocation -WorkspaceRoot $WorkspaceRoot -Platform $HostEnvironment.Platform -UserHome $HostEnvironment.UserHome -LocalAppData $HostEnvironment.LocalAppData -XdgStateHome $HostEnvironment.XdgStateHome
373
+ # Attribution and the forbidden-tool deny are proved only from what the gate observed for the sentinel agent.
374
+ $toolHandler = @($handlers | Where-Object { $_['event'] -eq 'tool' }) | Select-Object -First 1
375
+ $observations = @(Get-DiscoverySessionObservation -Location $location -SessionId $SessionId -Store $Store)
376
+ $evidence = Get-DiscoveryObservationEvidence -Observations $observations -ToolHandlerSha256 $(if ($null -ne $toolHandler) { [string]$toolHandler['sha256'] } else { $null }) -Now $Now -TtlSeconds $TtlSeconds
377
+ $controls['trusted_attribution'] = $evidence.TrustedAttribution
378
+ $controls['forbidden_tools_denied'] = $evidence.ForbiddenDenied
379
+ $denied = $evidence.TrustedAttribution -and $evidence.ForbiddenDenied
380
+ $probe = [ordered]@{
381
+ sentinel_id = $(if ($null -ne $evidence.SentinelRef) { $evidence.SentinelRef } else { "sentinel-$Nonce" })
382
+ result = $(if ($denied) { 'denied' } else { 'unavailable' })
383
+ evidence_ref = $(if ($denied) { $evidence.ShellRef } else { $null })
384
+ }
385
+ foreach ($deficiency in $evidence.Deficiencies) {
386
+ $findings.Add([ordered]@{ code = 'DISCOVERY_ENFORCEMENT_UNAVAILABLE'; severity = 'error'; artifact_path = $null; pointer = '/controls'; related_ids = @(); related_paths = @(); message = "Native control $($deficiency.Control) is not proved: $($deficiency.Message)."; next_action = 'Run the discovery-capability-sentinel agent in this session, then re-run the capability probe with the same session.' })
387
+ }
388
+ $activation = Read-DiscoveryActivationState -Location $location -ReadText $Store.ReadText
389
+ $workspace = [ordered]@{ workspace_root = $location.Workspace; workspace_sha256 = $location.WorkspaceSha256; profile_path = 'config/discovery.json'; profile_sha256 = ('0' * 64) }
390
+ if ($activation.Status -eq 'active') {
391
+ $workspace['profile_path'] = [string]$activation.Record.profile_path
392
+ $workspace['profile_sha256'] = [string]$activation.Record.profile_sha256
393
+ } else {
394
+ $findings.Add([ordered]@{ code = 'DISCOVERY_ENFORCEMENT_UNAVAILABLE'; severity = 'error'; artifact_path = $null; pointer = '/workspace'; related_ids = @(); related_paths = @(); message = "No active protected activation binds this workspace ($($activation.Status))."; next_action = 'Initialize or configure discovery for this workspace, then re-run the capability probe.' })
395
+ }
396
+ foreach ($name in $script:ControlNames) {
397
+ if (-not $controls[$name]) { $findings.Add([ordered]@{ code = 'DISCOVERY_ENFORCEMENT_UNAVAILABLE'; severity = 'error'; artifact_path = $null; pointer = '/controls'; related_ids = @(); related_paths = @(); message = "Native control $name is not available."; next_action = 'Enable the native hook controls for this host and re-run the capability probe.' }) }
398
+ }
399
+ foreach ($lifecycle in $script:HandlerEvents) {
400
+ if (-not ($handlers | Where-Object { $_['event'] -eq $lifecycle })) { $findings.Add([ordered]@{ code = 'DISCOVERY_ENFORCEMENT_UNAVAILABLE'; severity = 'error'; artifact_path = $null; pointer = '/handlers'; related_ids = @(); related_paths = @(); message = "No $lifecycle hook handler was observed."; next_action = 'Install and register the discovery hook handlers and re-run the capability probe.' }) }
401
+ }
402
+ if (-not $denied) { $findings.Add([ordered]@{ code = 'DISCOVERY_ENFORCEMENT_UNAVAILABLE'; severity = 'error'; artifact_path = $null; pointer = '/probe'; related_ids = @(); related_paths = @(); message = "Sentinel $($probe['sentinel_id']) has no attributed denial in the recorded host observations."; next_action = 'Confirm the native hook denies forbidden operations and records protected evidence.' }) }
403
+ $stamp = $Now.ToUniversalTime()
404
+ return [ordered]@{
405
+ '$schema' = $script:CapabilitySchema
406
+ schema_version = '2.0.0'
407
+ artifact_kind = 'capability-attestation'
408
+ id = "capability-$Nonce"
409
+ attestation_ref = "capability-$Nonce"
410
+ workspace = $workspace
411
+ host = $HostName
412
+ session_id = $SessionId
413
+ runtime = $Runtime
414
+ configuration_sha256 = Get-DiscoveryBytesSha256Hex -Bytes $configurationBytes
415
+ handlers = @($handlers)
416
+ controls = $controls
417
+ probe = $probe
418
+ status = $(if ($findings.Count -eq 0) { 'supported' } else { 'unsupported' })
419
+ findings = @($findings)
420
+ observed_at = $stamp.ToString("yyyy-MM-dd'T'HH:mm:ss'Z'")
421
+ expires_at = $stamp.AddSeconds($TtlSeconds).ToString("yyyy-MM-dd'T'HH:mm:ss'Z'")
422
+ }
423
+ }
424
+
425
+ function Write-DiscoveryCapabilityAttestation {
426
+ <#
427
+ .SYNOPSIS
428
+ Persists an observation as the session's protected capability attestation and returns its path.
429
+ .DESCRIPTION
430
+ The doctor, the agent preparation and the start attestation read only the protected
431
+ session copy at `<sessions>/<session>/capability-attestation.json`, so a probe that
432
+ only prints its observation leaves every control unverifiable. The record is written
433
+ exclusively on first observation and replaced on a later probe of the same session
434
+ when the store offers ReplaceText; a store without it keeps the first record.
435
+ #>
436
+ [CmdletBinding()]
437
+ [OutputType([string])]
438
+ param(
439
+ [Parameter(Mandatory = $true)][System.Collections.IDictionary]$Observation,
440
+ [Parameter(Mandatory = $true)][string]$WorkspaceRoot,
441
+ [Parameter(Mandatory = $true)][hashtable]$HostEnvironment,
442
+ [Parameter(Mandatory = $true)][hashtable]$Store
443
+ )
444
+
445
+ $location = Resolve-DiscoveryAuthorityLocation -WorkspaceRoot $WorkspaceRoot -Platform $HostEnvironment.Platform -UserHome $HostEnvironment.UserHome -LocalAppData $HostEnvironment.LocalAppData -XdgStateHome $HostEnvironment.XdgStateHome
446
+ $directory = Get-DiscoverySessionDirectory -Location $location -SessionId ([string]$Observation['session_id'])
447
+ $path = "$directory/$($script:CapabilityAttestationFile)"
448
+ $text = (($Observation | ConvertTo-Json -Depth 8) + "`n")
449
+ Invoke-DiscoveryStoreSeam -Seam $Store.EnsureDirectory -Arguments @($directory)
450
+ # A re-probe of the same session replaces its earlier record; the exclusive write guards the first.
451
+ $existing = Invoke-DiscoveryStoreSeam -Seam $Store.ReadText -Arguments @($path)
452
+ if ($null -ne $existing -and $Store.ContainsKey('ReplaceText')) {
453
+ Invoke-DiscoveryStoreSeam -Seam $Store.ReplaceText -Arguments @($path, $text)
454
+ } else {
455
+ Invoke-DiscoveryStoreSeam -Seam $Store.WriteText -Arguments @($path, $text)
456
+ }
457
+ return $path
458
+ }
459
+
460
+ Export-ModuleMember -Function `
461
+ Get-DiscoverySentinelRequest, `
462
+ Get-DiscoverySentinelEnvelope, `
463
+ Get-DiscoverySessionObservation, `
464
+ Get-DiscoveryObservationEvidence, `
465
+ Get-DiscoveryHostRegistration, `
466
+ Test-DiscoveryHandlerRegistered, `
467
+ Get-DiscoveryCapabilityObservation, `
468
+ Write-DiscoveryCapabilityAttestation
@@ -0,0 +1,156 @@
1
+ <#
2
+ .SYNOPSIS
3
+ Hashing and canonical JSON encoding shared by the native discovery hooks.
4
+
5
+ .DESCRIPTION
6
+ Renders JSON values with sorted keys, no whitespace, a trailing LF and non-ASCII
7
+ kept literal, exactly as `canonical_json` in the Python runtime and
8
+ `canonicalJson` in the TypeScript gateway do, and returns lowercase hex SHA-256
9
+ digests of text and bytes. Every discovery digest (tool/input authorization,
10
+ input manifest, receipt) is computed over this encoding, so a hook that
11
+ recomputes a digest with these functions agrees with the runtime byte for byte.
12
+
13
+ Numbers must be safe integers because a fractional value cannot be normalized
14
+ identically across runtimes; anything outside JSON is refused rather than
15
+ guessed. The module performs no I/O.
16
+
17
+ .NOTES
18
+ Issue #980. Compatible with PowerShell 7+. No external module dependencies.
19
+ Mirrored byte-identically under extensions/drm-copilot/resources/claude-customizations/
20
+ and published to the Codex bundle under .codex/lib/discovery-validation/.
21
+ CONVENTION: this module fails fast at module scope and imports its siblings with -ErrorAction Stop.
22
+ #>
23
+
24
+ Set-StrictMode -Version Latest
25
+ $ErrorActionPreference = 'Stop'
26
+
27
+ function Get-DiscoverySha256Hex {
28
+ <#
29
+ .SYNOPSIS
30
+ Returns the lowercase hex SHA-256 of the UTF-8 encoding of Text.
31
+ #>
32
+ [CmdletBinding()]
33
+ [OutputType([string])]
34
+ param([Parameter(Mandatory = $true)][AllowEmptyString()][string]$Text)
35
+
36
+ return Get-DiscoveryBytesSha256Hex -Bytes ([System.Text.Encoding]::UTF8.GetBytes($Text))
37
+ }
38
+
39
+ function Get-DiscoveryBytesSha256Hex {
40
+ <#
41
+ .SYNOPSIS
42
+ Returns the lowercase hex SHA-256 of raw bytes (an empty array is allowed).
43
+ #>
44
+ [CmdletBinding()]
45
+ [OutputType([string])]
46
+ param([Parameter(Mandatory = $true)][AllowEmptyCollection()][byte[]]$Bytes)
47
+
48
+ return [Convert]::ToHexString([System.Security.Cryptography.SHA256]::HashData($Bytes)).ToLowerInvariant()
49
+ }
50
+
51
+ function ConvertTo-DiscoveryJsonString {
52
+ <#
53
+ .SYNOPSIS
54
+ Quotes one string the way JSON.stringify and json.dumps(ensure_ascii=False) do.
55
+ .DESCRIPTION
56
+ Only the quote, the backslash and C0 controls are escaped; every other code
57
+ point stays literal so digests agree across the three runtimes.
58
+ #>
59
+ [CmdletBinding()]
60
+ [OutputType([string])]
61
+ param([Parameter(Mandatory = $true)][AllowEmptyString()][string]$Value)
62
+
63
+ $builder = [System.Text.StringBuilder]::new('"')
64
+ # Escape per code point (culture-aware string comparison treats control
65
+ # characters as ignorable, so the switch is on the integer value): the short
66
+ # forms first, then \u00XX for other controls.
67
+ foreach ($character in $Value.ToCharArray()) {
68
+ $code = [int]$character
69
+ switch ($code) {
70
+ 34 { [void]$builder.Append('\"') }
71
+ 92 { [void]$builder.Append('\\') }
72
+ 8 { [void]$builder.Append('\b') }
73
+ 12 { [void]$builder.Append('\f') }
74
+ 10 { [void]$builder.Append('\n') }
75
+ 13 { [void]$builder.Append('\r') }
76
+ 9 { [void]$builder.Append('\t') }
77
+ default {
78
+ if ($code -lt 0x20) {
79
+ [void]$builder.Append('\u' + $code.ToString('x4'))
80
+ } else {
81
+ [void]$builder.Append($character)
82
+ }
83
+ }
84
+ }
85
+ }
86
+ return $builder.Append('"').ToString()
87
+ }
88
+
89
+ function ConvertTo-DiscoveryCanonicalJson {
90
+ <#
91
+ .SYNOPSIS
92
+ Renders a JSON value with sorted keys, no whitespace and a trailing LF.
93
+ .DESCRIPTION
94
+ Mirrors `canonical_json` in the Python runtime and `canonicalJson` in the
95
+ TypeScript gateway. Objects (hashtables, ordered dictionaries and
96
+ PSCustomObjects) sort keys by ordinal code point; arrays keep order; numbers
97
+ must be integers because a fractional value cannot be normalized identically.
98
+ .OUTPUTS
99
+ [string] The canonical text, ending with a single LF.
100
+ #>
101
+ [CmdletBinding()]
102
+ [OutputType([string])]
103
+ param([Parameter(Mandatory = $false)][AllowNull()][object]$Value)
104
+
105
+ return (ConvertTo-DiscoveryCanonicalFragment -Value $Value) + "`n"
106
+ }
107
+
108
+ function ConvertTo-DiscoveryCanonicalFragment {
109
+ <#
110
+ .SYNOPSIS
111
+ Renders one JSON value without the trailing LF; recursive helper.
112
+ #>
113
+ [CmdletBinding()]
114
+ [OutputType([string])]
115
+ param([Parameter(Mandatory = $false)][AllowNull()][object]$Value)
116
+
117
+ # Routing by JSON type; anything outside JSON is refused rather than guessed.
118
+ if ($null -eq $Value) { return 'null' }
119
+ if ($Value -is [bool]) { return $(if ($Value) { 'true' } else { 'false' }) }
120
+ if ($Value -is [string]) { return ConvertTo-DiscoveryJsonString -Value $Value }
121
+ if ($Value -is [byte] -or $Value -is [int16] -or $Value -is [int32] -or $Value -is [int64] -or $Value -is [uint16] -or $Value -is [uint32]) {
122
+ return ([int64]$Value).ToString([System.Globalization.CultureInfo]::InvariantCulture)
123
+ }
124
+ if ($Value -is [double] -or $Value -is [decimal] -or $Value -is [single]) {
125
+ if ([math]::Floor([double]$Value) -ne [double]$Value -or [math]::Abs([double]$Value) -gt 9007199254740991) {
126
+ throw 'AUTHORIZATION_INPUT_INVALID: canonical JSON accepts only safe integers as numbers.'
127
+ }
128
+ return ([int64][double]$Value).ToString([System.Globalization.CultureInfo]::InvariantCulture)
129
+ }
130
+ if ($Value -is [System.Collections.IDictionary]) {
131
+ $members = @()
132
+ # Ordinal key order is what both reference runtimes emit.
133
+ [string[]]$sortedKeys = @($Value.Keys | ForEach-Object { [string]$_ })
134
+ [Array]::Sort($sortedKeys, [System.StringComparer]::Ordinal)
135
+ foreach ($key in $sortedKeys) {
136
+ $members += (ConvertTo-DiscoveryJsonString -Value ([string]$key)) + ':' + (ConvertTo-DiscoveryCanonicalFragment -Value $Value[$key])
137
+ }
138
+ return '{' + ($members -join ',') + '}'
139
+ }
140
+ if ($Value -is [System.Collections.IEnumerable]) {
141
+ $items = @()
142
+ foreach ($item in $Value) { $items += ConvertTo-DiscoveryCanonicalFragment -Value $item }
143
+ return '[' + ($items -join ',') + ']'
144
+ }
145
+ if ($Value -is [System.Management.Automation.PSCustomObject]) {
146
+ $table = [ordered]@{}
147
+ foreach ($property in $Value.PSObject.Properties) { $table[$property.Name] = $property.Value }
148
+ return ConvertTo-DiscoveryCanonicalFragment -Value $table
149
+ }
150
+ throw "AUTHORIZATION_INPUT_INVALID: canonical JSON cannot render a value of type $($Value.GetType().FullName)."
151
+ }
152
+
153
+ Export-ModuleMember -Function `
154
+ Get-DiscoverySha256Hex, `
155
+ Get-DiscoveryBytesSha256Hex, `
156
+ ConvertTo-DiscoveryCanonicalJson