@event4u/agent-config 9.7.0 → 9.8.0

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 (185) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +144 -0
  3. package/README.md +5 -5
  4. package/dist/agent-src/commands/commit/in-chunks.md +8 -0
  5. package/dist/agent-src/commands/commit.md +17 -0
  6. package/dist/agent-src/commands/pr/create.md +4 -0
  7. package/dist/agent-src/contexts/override-system.md +75 -5
  8. package/dist/agent-src/rules/context-hygiene.md +2 -0
  9. package/dist/agent-src/rules/doc-screenshot-hygiene.md +97 -0
  10. package/dist/agent-src/rules/domain-safety-pii.md +1 -0
  11. package/dist/agent-src/rules/external-code-graph-interop.md +13 -3
  12. package/dist/agent-src/rules/framework-neutrality-in-generic-skills.md +2 -0
  13. package/dist/agent-src/rules/git-history-discipline.md +2 -0
  14. package/dist/agent-src/rules/language-and-tone.md +2 -0
  15. package/dist/agent-src/rules/lethal-trifecta-guard.md +3 -0
  16. package/dist/agent-src/rules/media-governance-routing.md +2 -0
  17. package/dist/agent-src/rules/minimal-safe-diff.md +2 -0
  18. package/dist/agent-src/rules/no-roadmap-references.md +3 -0
  19. package/dist/agent-src/rules/non-destructive-by-default.md +20 -0
  20. package/dist/agent-src/rules/onboarding-gate.md +2 -0
  21. package/dist/agent-src/rules/output-discipline.md +2 -0
  22. package/dist/agent-src/rules/persona-governance.md +2 -0
  23. package/dist/agent-src/rules/preservation-guard.md +3 -0
  24. package/dist/agent-src/rules/roadmap-progress-sync.md +2 -0
  25. package/dist/agent-src/rules/secret-vcs-guard.md +106 -0
  26. package/dist/agent-src/rules/security-sensitive-stop.md +1 -1
  27. package/dist/agent-src/rules/skill-quality.md +2 -0
  28. package/dist/agent-src/rules/source-confidentiality.md +2 -0
  29. package/dist/agent-src/rules/source-of-truth.md +2 -0
  30. package/dist/agent-src/rules/telegraph-speak.md +2 -0
  31. package/dist/agent-src/rules/token-optimizer-maintenance.md +2 -0
  32. package/dist/agent-src/rules/tool-safety.md +1 -0
  33. package/dist/agent-src/rules/verify-before-complete.md +2 -0
  34. package/dist/agent-src/skills/adr-create/SKILL.md +17 -1
  35. package/dist/agent-src/skills/code-intelligence/SKILL.md +94 -0
  36. package/dist/agent-src/skills/gated-reach/SKILL.md +185 -0
  37. package/dist/agent-src/skills/gated-reach/evals/triggers.json +72 -0
  38. package/dist/agent-src/skills/image-analyser/SKILL.md +14 -12
  39. package/dist/agent-src/skills/image-editing/SKILL.md +1 -0
  40. package/dist/agent-src/skills/readme-writing/SKILL.md +6 -1
  41. package/dist/agent-src/skills/screenshot-hygiene/SKILL.md +171 -0
  42. package/dist/agent-src/skills/secrets-management/SKILL.md +44 -1
  43. package/dist/agent-src/skills/subagent-orchestration/prompts/adversarial-verification-council.md +29 -0
  44. package/dist/agent-src/skills/subagent-orchestration/prompts/do-and-judge-two-stage.md +29 -0
  45. package/dist/agent-src/skills/subagent-orchestration/prompts/do-and-judge.md +29 -0
  46. package/dist/agent-src/skills/subagent-orchestration/prompts/do-competitively.md +29 -0
  47. package/dist/agent-src/skills/subagent-orchestration/prompts/do-in-parallel.md +29 -0
  48. package/dist/agent-src/skills/subagent-orchestration/prompts/do-in-steps.md +29 -0
  49. package/dist/agent-src/skills/subagent-orchestration/prompts/do-in-worktrees.md +29 -0
  50. package/dist/agent-src/skills/subagent-orchestration/prompts/judge-with-debate.md +29 -0
  51. package/dist/agent-src/skills/verify-repair-loop/SKILL.md +4 -0
  52. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  53. package/dist/agent-src/templates/github-workflows/secret-scan.yml +65 -0
  54. package/dist/cli/configRoot.js +112 -0
  55. package/dist/cli/configRoot.js.map +1 -0
  56. package/dist/cli/configRoot.test.js +97 -0
  57. package/dist/cli/configRoot.test.js.map +1 -0
  58. package/dist/cli/help.js +4 -1
  59. package/dist/cli/help.js.map +1 -1
  60. package/dist/cli/main.js +19 -2
  61. package/dist/cli/main.js.map +1 -1
  62. package/dist/cli/registry.js +1 -0
  63. package/dist/cli/registry.js.map +1 -1
  64. package/dist/discovery/deprecation-report.md +1 -1
  65. package/dist/discovery/discovery-manifest.json +161 -44
  66. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  67. package/dist/discovery/discovery-manifest.summary.md +4 -4
  68. package/dist/discovery/orphan-report.md +1 -1
  69. package/dist/discovery/packs.json +13 -8
  70. package/dist/discovery/trust-report.md +11 -11
  71. package/dist/discovery/workspaces.json +11 -6
  72. package/dist/install/install.mjs +16 -0
  73. package/dist/mcp/registry-manifest.json +3 -3
  74. package/dist/router.json +1 -1
  75. package/dist/server/app.js +10 -0
  76. package/dist/server/app.js.map +1 -1
  77. package/dist/server/routes/ping.js +18 -0
  78. package/dist/server/routes/ping.js.map +1 -1
  79. package/dist/server/schemas/settings.js +8 -0
  80. package/dist/server/schemas/settings.js.map +1 -1
  81. package/dist/server/serverInfo.js +6 -2
  82. package/dist/server/serverInfo.js.map +1 -1
  83. package/dist/server/token.js +11 -4
  84. package/dist/server/token.js.map +1 -1
  85. package/dist/server/writeRoot.js +15 -1
  86. package/dist/server/writeRoot.js.map +1 -1
  87. package/dist/shared/capabilities.js +33 -0
  88. package/dist/shared/capabilities.js.map +1 -0
  89. package/dist/shared/capabilities.test.js +39 -0
  90. package/dist/shared/capabilities.test.js.map +1 -0
  91. package/dist/ui/assets/{index-BiEZiZ31.js → index-CrmZ34Rg.js} +16 -16
  92. package/dist/ui/assets/index-CrmZ34Rg.js.map +1 -0
  93. package/dist/ui/index.html +14 -5
  94. package/docs/CLAIMS.md +62 -37
  95. package/docs/architecture.md +2 -2
  96. package/docs/benchmark.md +137 -0
  97. package/docs/catalog.md +8 -3
  98. package/docs/comparison.yaml +44 -2
  99. package/docs/contracts/local-server-ports.md +53 -0
  100. package/docs/contracts/no-runtime-boundary.md +1 -1
  101. package/docs/contracts/subagent-boundary.md +15 -0
  102. package/docs/customization.md +14 -3
  103. package/docs/decisions/ADR-016-installer-architecture.md +2 -0
  104. package/docs/decisions/ADR-088-no-external-runtime-federation.md +1 -1
  105. package/docs/decisions/ADR-094-agent-memory-layer-removal.md +1 -1
  106. package/docs/decisions/ADR-109-subagent-v1-contract.md +2 -0
  107. package/docs/decisions/ADR-110-discipline-profile-resolution-locus.md +2 -0
  108. package/docs/decisions/ADR-113-ci-native-release-label-trigger.md +11 -3
  109. package/docs/decisions/ADR-124-embedded-engine-doctrine.md +255 -0
  110. package/docs/decisions/ADR-125-doc-screenshot-anonymization.md +131 -0
  111. package/docs/decisions/ADR-126-internet-reach-operator-tooling.md +254 -0
  112. package/docs/decisions/ADR-127-enforcement-claims-must-resolve.md +150 -0
  113. package/docs/decisions/ADR-128-executable-evidence-and-measured-escalation.md +201 -0
  114. package/docs/decisions/INDEX.md +21 -0
  115. package/docs/decisions/engine-reclassification-2026-07.md +104 -0
  116. package/docs/distribution/awesome-list-submission.md +1 -1
  117. package/docs/distribution/registries.md +36 -27
  118. package/docs/enforcement-by-host.md +7 -0
  119. package/docs/featured-skills.md +2 -2
  120. package/docs/getting-started-by-role.md +1 -1
  121. package/docs/getting-started.md +1 -1
  122. package/docs/governance-advantage.md +1 -1
  123. package/docs/guides/gated-platform-reads.md +221 -0
  124. package/docs/positioning-evidence.md +1 -1
  125. package/docs/proof.md +92 -16
  126. package/docs/threat-model.md +1 -0
  127. package/package.json +5 -2
  128. package/src/config/agent-settings.template.yml +43 -0
  129. package/src/config/reach-channels.yml +217 -0
  130. package/src/config/reach-prescriptions-intake.yml +200 -0
  131. package/src/scripts/_dispatch.bash +15 -0
  132. package/src/scripts/_lib/exec_evidence.ts +251 -0
  133. package/src/scripts/_lib/fixtures/secret-detector/negatives.txt +10 -0
  134. package/src/scripts/_lib/fixtures/secret-detector/positives.txt +11 -0
  135. package/src/scripts/_lib/kernel_rules.ts +46 -0
  136. package/src/scripts/_lib/reddit_thread_parse.ts +598 -0
  137. package/src/scripts/_lib/risk_paths.ts +160 -0
  138. package/src/scripts/_lib/secret_detector.test.ts +103 -0
  139. package/src/scripts/_lib/secret_detector.ts +319 -0
  140. package/src/scripts/_lib/tool_probe.ts +359 -0
  141. package/src/scripts/adr/regenerate_index.ts +81 -6
  142. package/src/scripts/audit_adr_coverage.ts +33 -0
  143. package/src/scripts/build_proof.ts +162 -0
  144. package/src/scripts/check_adr_frontmatter.ts +195 -0
  145. package/src/scripts/check_artefact_count_messaging.ts +4 -0
  146. package/src/scripts/check_backstop_debt.ts +200 -0
  147. package/src/scripts/check_claims.ts +188 -8
  148. package/src/scripts/check_comparison.ts +12 -0
  149. package/src/scripts/check_enforcement_coverage.ts +592 -0
  150. package/src/scripts/check_kernel_rule_bundle.ts +16 -12
  151. package/src/scripts/check_reach_channels.ts +407 -0
  152. package/src/scripts/check_reach_staleness.ts +364 -0
  153. package/src/scripts/check_secret_leak.test.ts +47 -0
  154. package/src/scripts/check_secret_leak.ts +290 -0
  155. package/src/scripts/code_graph/build.ts +0 -0
  156. package/src/scripts/code_graph/cli.ts +207 -0
  157. package/src/scripts/code_graph/detect.ts +134 -0
  158. package/src/scripts/code_graph/extract.ts +386 -0
  159. package/src/scripts/code_graph/loader.ts +123 -0
  160. package/src/scripts/code_graph/query.ts +176 -0
  161. package/src/scripts/code_graph/sanitize.ts +30 -0
  162. package/src/scripts/code_graph/types.ts +83 -0
  163. package/src/scripts/code_graph/validate.ts +63 -0
  164. package/src/scripts/external_sources_denylist.json +3 -1
  165. package/src/scripts/generate_subagent_floor.ts +194 -0
  166. package/src/scripts/hook_manifest.yaml +13 -3
  167. package/src/scripts/hooks/code_graph_nudge_hook.ts +184 -0
  168. package/src/scripts/iron_law_sha.ts +3 -12
  169. package/src/scripts/lint_override_kernel_guard.ts +219 -0
  170. package/src/scripts/measure_rule_budget.ts +3 -12
  171. package/src/scripts/postinstall_gui.ts +129 -0
  172. package/src/scripts/reach_doctor.ts +1651 -0
  173. package/src/scripts/release.ts +24 -3
  174. package/src/scripts/risk_escalation_shadow.ts +160 -0
  175. package/src/scripts/schemas/agent-settings.schema.json +23 -0
  176. package/src/scripts/schemas/reach-channels.schema.json +134 -0
  177. package/src/scripts/schemas/reach-doctor-payload.schema.json +220 -0
  178. package/src/scripts/schemas/rule.schema.json +10 -0
  179. package/src/scripts/skill_linter.ts +55 -0
  180. package/src/scripts/validate_reach_prescriptions.ts +751 -0
  181. package/src/server/schemas/settings.ts +16 -0
  182. package/src/shared/capabilities.test.ts +43 -0
  183. package/src/shared/capabilities.ts +75 -0
  184. package/src/templates/minimal/agents-overrides-readme.md +20 -0
  185. package/dist/ui/assets/index-BiEZiZ31.js.map +0 -1
@@ -0,0 +1,407 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * check_reach_channels.ts — schema gate for the reach channel registry
4
+ * (road-to-internet-reach Phase 1, Step 3).
5
+ *
6
+ * Validates `src/config/reach-channels.yml` against
7
+ * `src/scripts/schemas/reach-channels.schema.json` and reports one line per
8
+ * violation with its JSON path, the failing schema rule, and the message.
9
+ *
10
+ * OFFLINE AND DETERMINISTIC BY CONSTRUCTION. The gate reads two files and
11
+ * matches strings — it never spawns a process, never resolves a binary on
12
+ * PATH, and never touches the network. An absent backend (`yt-dlp` is not
13
+ * installed on the authoring machine) therefore cannot fail this check:
14
+ * whether a tool is *healthy* is the probe layer's question, whether its
15
+ * install prescription is *pinned* is this one's.
16
+ *
17
+ * Scope note: the registry is standalone operator tooling. The Phase 0
18
+ * benchmark returned `band: stop`, so nothing validated here is a routing
19
+ * table or an agent-facing recommendation.
20
+ *
21
+ * No new dependency: the Draft-07 subset validator is the one already
22
+ * exported by `validate_frontmatter.ts` (type, required, properties,
23
+ * additionalProperties:false, items, minItems, pattern, minLength, enum) and
24
+ * YAML parsing uses the repo's existing `yaml` package. Consequence for
25
+ * schema authors: `$ref`, `patternProperties` and `if/then` are unavailable —
26
+ * the schema inlines instead.
27
+ *
28
+ * Plus ONE cross-field rule no Draft-07 subset can express, asserted in code as
29
+ * rule `probe_cmd-binding`: a backend's `probe_cmd` must equal its own `id`
30
+ * (see `check_probe_cmd_binding`). Free choice of `probe_cmd` is what made the
31
+ * registry an execution primitive — `id: curl` + `probe_cmd: sh` spawned a
32
+ * shell and the report still printed a healthy `curl` row.
33
+ *
34
+ * Severity: `error`-severity findings fail the run; `warning`-severity
35
+ * findings (minLength) are printed and do not change the exit code.
36
+ *
37
+ * Exit codes:
38
+ * 0 — clean (or warnings only).
39
+ * 1 — at least one error-severity violation.
40
+ * 3 — internal error: target missing, unreadable, or not parseable YAML.
41
+ *
42
+ * Invocation (from project root):
43
+ * tsx src/scripts/check_reach_channels.ts [<path-to-yml>] [--quiet]
44
+ *
45
+ * The optional path argument is what lets the negative fixtures under
46
+ * `tests/fixtures/reach-channels/` be validated by the same code path as the
47
+ * real registry.
48
+ */
49
+ import * as fs from 'node:fs';
50
+ import * as path from 'node:path';
51
+ import process from 'node:process';
52
+ import { fileURLToPath, pathToFileURL } from 'node:url';
53
+
54
+ import { parse as parseYaml } from 'yaml';
55
+
56
+ import { SchemaError, validate, type YamlValue } from './validate_frontmatter.js';
57
+
58
+ const _HERE = fileURLToPath(import.meta.url);
59
+ /** Repo root — two dirs up from src/scripts. */
60
+ const ROOT = path.resolve(path.dirname(_HERE), '..', '..');
61
+
62
+ export const REGISTRY_PATH = path.join(ROOT, 'src', 'config', 'reach-channels.yml');
63
+ export const SCHEMA_PATH = path.join(
64
+ ROOT,
65
+ 'src',
66
+ 'scripts',
67
+ 'schemas',
68
+ 'reach-channels.schema.json',
69
+ );
70
+
71
+ /** Thrown for the exit-3 class: unusable input, as opposed to invalid content. */
72
+ export class RegistryLoadError extends Error {}
73
+
74
+ /**
75
+ * A parse / read failure rendered WITHOUT any of the file's bytes.
76
+ *
77
+ * `String(err)` on a `YAMLParseError` appends the offending source line and a
78
+ * caret under it — so interpolating it into a message turns any reader of the
79
+ * error stream into an arbitrary-file-read oracle: point `--registry` (or
80
+ * `--intake`) at a secrets file and the first line comes back in the
81
+ * diagnostic. Only the error's name and its own first message line (which
82
+ * carries the position, never the content) survive this function.
83
+ *
84
+ * Cheap-oracle note: a YAML error message may still quote a single offending
85
+ * TOKEN (`Unexpected "]"`). That is a structural character, not file content —
86
+ * the leak this closes is the verbatim source-line echo.
87
+ */
88
+ export function sanitizeParseError(err: unknown): string {
89
+ const name = err instanceof Error && err.name !== '' ? err.name : 'Error';
90
+ const raw = err instanceof Error ? err.message : String(err);
91
+ const firstLine = (raw.split('\n')[0] ?? '').trim();
92
+ return firstLine === '' ? name : `${name}: ${firstLine}`;
93
+ }
94
+
95
+ // --- echoed excerpts -------------------------------------------------------
96
+ // A finding's whole value is that it names the offending TEXT, not just a line
97
+ // number — an operator fixing their own registry needs to read the command
98
+ // back. That echo is also the one place these gates copy bytes out of a file
99
+ // they were pointed at, so the excerpt is bounded and credential-redacted
100
+ // before it reaches stdout.
101
+ //
102
+ // This lives HERE, one layer below `validate_reach_prescriptions.ts`, because
103
+ // both gates echo file-derived text and that script already imports this
104
+ // module (`sanitizeParseError`, `load_registry`, `validate_file`). The reverse
105
+ // direction would be an import cycle.
106
+
107
+ /**
108
+ * Cap on the echoed excerpt. Long enough for a real prescription
109
+ * (`pipx install yt-dlp==2026.7.4`, a pinned release URL), short enough that a
110
+ * single finding can never dump a paragraph of the scanned file.
111
+ */
112
+ export const EXCERPT_MAX_CHARS = 120;
113
+
114
+ /**
115
+ * Cap for a SCHEMA finding's message, which is not a line of the file but
116
+ * schema-authored prose wrapping at most one quoted value
117
+ * (`Value 'x' does not match /<regex>/`). The reach schema's longest `pattern`
118
+ * is 229 chars, and the framing adds ~26 — under the 120-char line cap the
119
+ * regex the operator has to satisfy would be truncated away, which turns an
120
+ * actionable finding into a riddle. 400 clears that text with a ~145-char
121
+ * window for the quoted value, and still bounds the output.
122
+ *
123
+ * Residual: the value is quoted BEFORE the regex, so a pathologically long
124
+ * value pushes the regex past the cap. That costs readability, not
125
+ * confidentiality — the credential classes below are removed by redaction,
126
+ * which runs before any truncation.
127
+ */
128
+ export const SCHEMA_MESSAGE_MAX_CHARS = 400;
129
+
130
+ const REDACTED = '<redacted>';
131
+
132
+ /**
133
+ * Credential-shaped substrings replaced before a line is echoed. Every pattern
134
+ * keeps the part that makes the finding actionable — the variable name, the
135
+ * header name, the key prefix — and drops only the value.
136
+ *
137
+ * Each one is deliberately narrower than its obvious form, because a
138
+ * false-positive redaction is not free: it destroys the command the operator
139
+ * came here to read. The version-pin cases that MUST survive are
140
+ * `brew install gh`, `pipx install yt-dlp==2026.7.4`, `node@22`,
141
+ * `--version 2.96.0`, and a pinned release URL. So must a schema `pattern`
142
+ * regex, whose body contains `--version[ =]v?[0-9]+` and `(?:==|@|=)v?`.
143
+ */
144
+ export const REDACTIONS: readonly (readonly [RegExp, string])[] = [
145
+ // `Authorization: Bearer …` — the header name stays, the value does not.
146
+ // Runs to end of line: everything after the colon is the credential.
147
+ [/(\bauthorization\s*:\s*)(\S.*)$/gi, `$1${REDACTED}`],
148
+ // `KEY=VALUE`. Two narrowings carry their weight. The lookbehind requires
149
+ // the key to START a token — so `yt-dlp==2026.7.4` is never chopped at its
150
+ // `dlp=` tail and `--target=/opt` is left alone — while still admitting a
151
+ // quote or paren before it, because `sh -c "SECRET=…"` is how a credential
152
+ // actually appears in a command. `(?!=)` excludes the `==` pin operator.
153
+ [/(?<![A-Za-z0-9_./-])([A-Za-z_][A-Za-z0-9_]{2,})=(?!=)\S+/g, `$1=${REDACTED}`],
154
+ // Provider key prefixes — the shapes that are a secret by construction.
155
+ [/\b(sk|pk|rk)-[A-Za-z0-9_-]{8,}/gi, `$1-${REDACTED}`],
156
+ [/\b(gh[pousr]|github_pat|xox[baprs])[-_][A-Za-z0-9_-]{8,}/gi, `$1_${REDACTED}`],
157
+ // A hex digest ≥ 32: an API key or a signature, never part of an install verb.
158
+ [/(?<![0-9a-fA-F])[0-9a-fA-F]{32,}(?![0-9a-fA-F])/g, REDACTED],
159
+ // A ≥ 24-char run carrying lower + upper + digit — the entropy shape of an
160
+ // opaque token. Narrower than "any 24-char base64/hex run" ON PURPOSE: that
161
+ // form also eats `some-long-homebrew-formula-name` and the path segment of
162
+ // a pinned release URL, both of which the operator needs to read. Requiring
163
+ // mixed case AND a digit keeps hyphenated names and lowercase paths intact.
164
+ [
165
+ /(?<![A-Za-z0-9+_-])(?=[A-Za-z0-9+_-]{24,})(?=[A-Za-z0-9+_-]*[a-z])(?=[A-Za-z0-9+_-]*[A-Z])(?=[A-Za-z0-9+_-]*\d)[A-Za-z0-9+_-]{24,}(?![A-Za-z0-9+_-])/g,
166
+ REDACTED,
167
+ ],
168
+ ];
169
+
170
+ /**
171
+ * Bounded, credential-redacted excerpt of one scanned line.
172
+ *
173
+ * Redaction runs BEFORE truncation on purpose: truncating first would let a
174
+ * secret that straddles the 120-char boundary leave a usable prefix behind.
175
+ *
176
+ * RESIDUAL, STATED PLAINLY — this is NOT a confidentiality boundary and cannot
177
+ * be made into one. A bounded excerpt still reveals some content of a file the
178
+ * caller named, and the positional path argument only ever reads what the
179
+ * invoking shell could already `cat`; whoever runs this CLI has that shell. Two
180
+ * things are nonetheless bought here, and they are the real ones: the
181
+ * credential-shaped classes above never land in stdout or a CI log, and no
182
+ * single finding can emit an unbounded slab of the target file. CI never passes
183
+ * a caller-supplied path — `task check-reach-channels` and
184
+ * `task check-reach-prescriptions` run with no positional argument, so the
185
+ * surfaces swept there are the committed reach config and docs, whose contents
186
+ * are public in the repo anyway.
187
+ */
188
+ // Used by EVERY echo site across both gates — the line-level surface sweep,
189
+ // the install-command findings in `check_prescriptions()`, and the schema
190
+ // findings (`format_finding` here, the finding conversion in
191
+ // `run_checks()` there). The last one was missed on the earlier passes: the
192
+ // Draft-07 validator quotes the offending VALUE (`Value 'brew install curl
193
+ // SECRET=…' does not match /…/`), so leaving it raw kept the same
194
+ // content-echo class open through a third message. `maxChars` exists for that
195
+ // site alone — see `SCHEMA_MESSAGE_MAX_CHARS`.
196
+ //
197
+ // Known cosmetic effect on a quoted message: `KEY=VALUE`'s `\S+` also consumes
198
+ // the validator's closing `'`, so a redacted schema message reads
199
+ // `Value 'cmd KEY=<redacted> does not match /…/`. Left as-is deliberately —
200
+ // narrowing `\S+` to stop at a quote would leave the tail of a value that
201
+ // CONTAINS one reachable, and over-removing beats under-removing. The rule
202
+ // name, the regex and the JSON path stay readable either way.
203
+ export function excerpt_for_finding(line: string, maxChars: number = EXCERPT_MAX_CHARS): string {
204
+ let text = line.trim();
205
+ for (const [pattern, replacement] of REDACTIONS) {
206
+ text = text.replace(pattern, replacement);
207
+ }
208
+ return text.length > maxChars ? `${text.slice(0, maxChars)}…` : text;
209
+ }
210
+
211
+ /** Load the registry schema. Throws RegistryLoadError when it is unusable. */
212
+ export function load_schema(schemaPath: string = SCHEMA_PATH): Record<string, YamlValue> {
213
+ if (!fs.existsSync(schemaPath)) {
214
+ throw new RegistryLoadError(`schema not found: ${schemaPath}`);
215
+ }
216
+ try {
217
+ return JSON.parse(fs.readFileSync(schemaPath, 'utf-8')) as Record<string, YamlValue>;
218
+ } catch (err) {
219
+ throw new RegistryLoadError(
220
+ `schema is not valid JSON: ${schemaPath}: ${sanitizeParseError(err)}`,
221
+ );
222
+ }
223
+ }
224
+
225
+ /** Parse one registry YAML file. Throws RegistryLoadError when it is unusable. */
226
+ export function load_registry(targetPath: string): YamlValue {
227
+ if (!fs.existsSync(targetPath)) {
228
+ throw new RegistryLoadError(`registry not found: ${targetPath}`);
229
+ }
230
+ let text: string;
231
+ try {
232
+ text = fs.readFileSync(targetPath, 'utf-8');
233
+ } catch (err) {
234
+ throw new RegistryLoadError(
235
+ `registry unreadable: ${targetPath}: ${sanitizeParseError(err)}`,
236
+ );
237
+ }
238
+ let data: unknown;
239
+ try {
240
+ data = parseYaml(text);
241
+ } catch (err) {
242
+ throw new RegistryLoadError(
243
+ `registry is not parseable YAML: ${targetPath}: ${sanitizeParseError(err)}`,
244
+ );
245
+ }
246
+ if (data === null || typeof data !== 'object' || Array.isArray(data)) {
247
+ throw new RegistryLoadError(`registry is not a YAML mapping: ${targetPath}`);
248
+ }
249
+ return data as YamlValue;
250
+ }
251
+
252
+ /**
253
+ * Cross-field rule the Draft-07 subset cannot express: a backend's `probe_cmd`
254
+ * MUST be its own `id`.
255
+ *
256
+ * Two holes close at once. (1) Free choice of `probe_cmd` made the registry an
257
+ * execution primitive by name — `id: curl` + `probe_cmd: sh` spawned a shell.
258
+ * (2) Even with harmless args, a row labelled `curl` reported the health of
259
+ * whatever binary `probe_cmd` named, so the report mislabelled the executed
260
+ * file. Binding the two fields makes the label and the executed binary the same
261
+ * string by construction, and it keeps holding as backends are added — an enum
262
+ * of today's four would need editing for every new one.
263
+ *
264
+ * Deliberately tolerant of shape: entries this cannot read are the schema's
265
+ * business, so anything not-a-mapping is skipped rather than double-reported.
266
+ */
267
+ export function check_probe_cmd_binding(registry: unknown): SchemaError[] {
268
+ const findings: SchemaError[] = [];
269
+ const root =
270
+ registry !== null && typeof registry === 'object' && !Array.isArray(registry)
271
+ ? (registry as Record<string, unknown>)
272
+ : null;
273
+ const channels = Array.isArray(root?.['channels']) ? (root['channels'] as unknown[]) : [];
274
+ channels.forEach((rawChannel, channelIndex) => {
275
+ if (rawChannel === null || typeof rawChannel !== 'object' || Array.isArray(rawChannel)) {
276
+ return;
277
+ }
278
+ const channel = rawChannel as Record<string, unknown>;
279
+ const backends = Array.isArray(channel['backends'])
280
+ ? (channel['backends'] as unknown[])
281
+ : [];
282
+ backends.forEach((rawBackend, backendIndex) => {
283
+ if (rawBackend === null || typeof rawBackend !== 'object' || Array.isArray(rawBackend)) {
284
+ return;
285
+ }
286
+ const backend = rawBackend as Record<string, unknown>;
287
+ const id = backend['id'];
288
+ const probeCmd = backend['probe_cmd'];
289
+ if (typeof id !== 'string' || typeof probeCmd !== 'string') {
290
+ return; // `required` already reports the missing field.
291
+ }
292
+ if (id !== probeCmd) {
293
+ findings.push(
294
+ new SchemaError(
295
+ `$.channels[${channelIndex}].backends[${backendIndex}].probe_cmd`,
296
+ 'probe_cmd-binding',
297
+ `probe_cmd '${probeCmd}' must equal the backend id '${id}' — a backend ` +
298
+ `probes its own binary, and a row labelled '${id}' may never report ` +
299
+ `the health of a different executable`,
300
+ ),
301
+ );
302
+ }
303
+ });
304
+ });
305
+ return findings;
306
+ }
307
+
308
+ /**
309
+ * Validate one registry file against the schema.
310
+ *
311
+ * Returns every finding in schema order (errors and warnings alike); the
312
+ * caller decides which severities are fatal. Throws RegistryLoadError for the
313
+ * exit-3 class so an unusable file is never reported as "0 violations".
314
+ */
315
+ export function validate_file(
316
+ targetPath: string,
317
+ schemaPath: string = SCHEMA_PATH,
318
+ ): SchemaError[] {
319
+ const registry = load_registry(targetPath);
320
+ return [
321
+ ...validate(registry, load_schema(schemaPath)),
322
+ ...check_probe_cmd_binding(registry),
323
+ ];
324
+ }
325
+
326
+ /**
327
+ * `<path>: <rule>: <message>` — one line per finding, stable across runs.
328
+ *
329
+ * The message is the one file-derived part: the Draft-07 `pattern` / `enum`
330
+ * rules quote the offending value, and `probe_cmd-binding` quotes two registry
331
+ * fields. It therefore goes through `excerpt_for_finding` — printing it raw is
332
+ * what let `--registry /path/to/secrets.yml` echo a credential verbatim into
333
+ * stdout and any CI log capturing it.
334
+ *
335
+ * `finding.path` stays raw on purpose. Its file-derived segments are property
336
+ * NAMES (`additionalProperties` → `$.channels[0].AWS_SECRET_ACCESS_KEY`), never
337
+ * values, and the JSON path is the whole locator an operator navigates by.
338
+ */
339
+ export function format_finding(finding: SchemaError): string {
340
+ return `${finding.path}: ${finding.rule}: ${excerpt_for_finding(finding.message, SCHEMA_MESSAGE_MAX_CHARS)}`;
341
+ }
342
+
343
+ export function main(argv: string[] = process.argv.slice(2)): number {
344
+ const quiet = argv.includes('--quiet');
345
+ const positional = argv.filter((arg) => !arg.startsWith('-'));
346
+ if (positional.length > 1) {
347
+ process.stderr.write('check_reach_channels: at most one path argument is accepted\n');
348
+ return 3;
349
+ }
350
+ const targetPath = positional[0] ? path.resolve(positional[0]) : REGISTRY_PATH;
351
+ const relTarget = path.relative(ROOT, targetPath) || targetPath;
352
+
353
+ let findings: SchemaError[];
354
+ try {
355
+ findings = validate_file(targetPath);
356
+ } catch (err) {
357
+ if (err instanceof RegistryLoadError) {
358
+ process.stderr.write(`❌ check_reach_channels: ${err.message}\n`);
359
+ return 3;
360
+ }
361
+ throw err;
362
+ }
363
+
364
+ const errors = findings.filter((finding) => finding.severity === 'error');
365
+ const warnings = findings.filter((finding) => finding.severity !== 'error');
366
+
367
+ for (const warning of warnings) {
368
+ process.stdout.write(`⚠️ ${relTarget}: ${format_finding(warning)}\n`);
369
+ }
370
+
371
+ if (errors.length > 0) {
372
+ process.stdout.write(`❌ ${relTarget}: ${errors.length} schema violation(s):\n`);
373
+ for (const error of errors) {
374
+ process.stdout.write(` - ${format_finding(error)}\n`);
375
+ }
376
+ return 1;
377
+ }
378
+
379
+ if (!quiet) {
380
+ process.stdout.write(`✅ ${relTarget}: schema-valid (reach channel registry).\n`);
381
+ }
382
+ return 0;
383
+ }
384
+
385
+ function _isCliEntry(): boolean {
386
+ if (!process.argv[1]) {
387
+ return false;
388
+ }
389
+ const argvUrl = pathToFileURL(path.resolve(process.argv[1])).href;
390
+ if (import.meta.url === argvUrl) {
391
+ return true;
392
+ }
393
+ // A symlinked invocation makes the raw URLs differ (import.meta.url is the
394
+ // resolved real path while argv[1] keeps the symlink path) — compare
395
+ // realpaths so the CLI still fires when run through a projection symlink.
396
+ try {
397
+ const here = fs.realpathSync(fileURLToPath(import.meta.url));
398
+ const argv = fs.realpathSync(path.resolve(process.argv[1]));
399
+ return here === argv;
400
+ } catch {
401
+ return false;
402
+ }
403
+ }
404
+
405
+ if (_isCliEntry()) {
406
+ process.exit(main());
407
+ }