@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.
- package/.claude-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +144 -0
- package/README.md +5 -5
- package/dist/agent-src/commands/commit/in-chunks.md +8 -0
- package/dist/agent-src/commands/commit.md +17 -0
- package/dist/agent-src/commands/pr/create.md +4 -0
- package/dist/agent-src/contexts/override-system.md +75 -5
- package/dist/agent-src/rules/context-hygiene.md +2 -0
- package/dist/agent-src/rules/doc-screenshot-hygiene.md +97 -0
- package/dist/agent-src/rules/domain-safety-pii.md +1 -0
- package/dist/agent-src/rules/external-code-graph-interop.md +13 -3
- package/dist/agent-src/rules/framework-neutrality-in-generic-skills.md +2 -0
- package/dist/agent-src/rules/git-history-discipline.md +2 -0
- package/dist/agent-src/rules/language-and-tone.md +2 -0
- package/dist/agent-src/rules/lethal-trifecta-guard.md +3 -0
- package/dist/agent-src/rules/media-governance-routing.md +2 -0
- package/dist/agent-src/rules/minimal-safe-diff.md +2 -0
- package/dist/agent-src/rules/no-roadmap-references.md +3 -0
- package/dist/agent-src/rules/non-destructive-by-default.md +20 -0
- package/dist/agent-src/rules/onboarding-gate.md +2 -0
- package/dist/agent-src/rules/output-discipline.md +2 -0
- package/dist/agent-src/rules/persona-governance.md +2 -0
- package/dist/agent-src/rules/preservation-guard.md +3 -0
- package/dist/agent-src/rules/roadmap-progress-sync.md +2 -0
- package/dist/agent-src/rules/secret-vcs-guard.md +106 -0
- package/dist/agent-src/rules/security-sensitive-stop.md +1 -1
- package/dist/agent-src/rules/skill-quality.md +2 -0
- package/dist/agent-src/rules/source-confidentiality.md +2 -0
- package/dist/agent-src/rules/source-of-truth.md +2 -0
- package/dist/agent-src/rules/telegraph-speak.md +2 -0
- package/dist/agent-src/rules/token-optimizer-maintenance.md +2 -0
- package/dist/agent-src/rules/tool-safety.md +1 -0
- package/dist/agent-src/rules/verify-before-complete.md +2 -0
- package/dist/agent-src/skills/adr-create/SKILL.md +17 -1
- package/dist/agent-src/skills/code-intelligence/SKILL.md +94 -0
- package/dist/agent-src/skills/gated-reach/SKILL.md +185 -0
- package/dist/agent-src/skills/gated-reach/evals/triggers.json +72 -0
- package/dist/agent-src/skills/image-analyser/SKILL.md +14 -12
- package/dist/agent-src/skills/image-editing/SKILL.md +1 -0
- package/dist/agent-src/skills/readme-writing/SKILL.md +6 -1
- package/dist/agent-src/skills/screenshot-hygiene/SKILL.md +171 -0
- package/dist/agent-src/skills/secrets-management/SKILL.md +44 -1
- package/dist/agent-src/skills/subagent-orchestration/prompts/adversarial-verification-council.md +29 -0
- package/dist/agent-src/skills/subagent-orchestration/prompts/do-and-judge-two-stage.md +29 -0
- package/dist/agent-src/skills/subagent-orchestration/prompts/do-and-judge.md +29 -0
- package/dist/agent-src/skills/subagent-orchestration/prompts/do-competitively.md +29 -0
- package/dist/agent-src/skills/subagent-orchestration/prompts/do-in-parallel.md +29 -0
- package/dist/agent-src/skills/subagent-orchestration/prompts/do-in-steps.md +29 -0
- package/dist/agent-src/skills/subagent-orchestration/prompts/do-in-worktrees.md +29 -0
- package/dist/agent-src/skills/subagent-orchestration/prompts/judge-with-debate.md +29 -0
- package/dist/agent-src/skills/verify-repair-loop/SKILL.md +4 -0
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/agent-src/templates/github-workflows/secret-scan.yml +65 -0
- package/dist/cli/configRoot.js +112 -0
- package/dist/cli/configRoot.js.map +1 -0
- package/dist/cli/configRoot.test.js +97 -0
- package/dist/cli/configRoot.test.js.map +1 -0
- package/dist/cli/help.js +4 -1
- package/dist/cli/help.js.map +1 -1
- package/dist/cli/main.js +19 -2
- package/dist/cli/main.js.map +1 -1
- package/dist/cli/registry.js +1 -0
- package/dist/cli/registry.js.map +1 -1
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +161 -44
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +4 -4
- package/dist/discovery/orphan-report.md +1 -1
- package/dist/discovery/packs.json +13 -8
- package/dist/discovery/trust-report.md +11 -11
- package/dist/discovery/workspaces.json +11 -6
- package/dist/install/install.mjs +16 -0
- package/dist/mcp/registry-manifest.json +3 -3
- package/dist/router.json +1 -1
- package/dist/server/app.js +10 -0
- package/dist/server/app.js.map +1 -1
- package/dist/server/routes/ping.js +18 -0
- package/dist/server/routes/ping.js.map +1 -1
- package/dist/server/schemas/settings.js +8 -0
- package/dist/server/schemas/settings.js.map +1 -1
- package/dist/server/serverInfo.js +6 -2
- package/dist/server/serverInfo.js.map +1 -1
- package/dist/server/token.js +11 -4
- package/dist/server/token.js.map +1 -1
- package/dist/server/writeRoot.js +15 -1
- package/dist/server/writeRoot.js.map +1 -1
- package/dist/shared/capabilities.js +33 -0
- package/dist/shared/capabilities.js.map +1 -0
- package/dist/shared/capabilities.test.js +39 -0
- package/dist/shared/capabilities.test.js.map +1 -0
- package/dist/ui/assets/{index-BiEZiZ31.js → index-CrmZ34Rg.js} +16 -16
- package/dist/ui/assets/index-CrmZ34Rg.js.map +1 -0
- package/dist/ui/index.html +14 -5
- package/docs/CLAIMS.md +62 -37
- package/docs/architecture.md +2 -2
- package/docs/benchmark.md +137 -0
- package/docs/catalog.md +8 -3
- package/docs/comparison.yaml +44 -2
- package/docs/contracts/local-server-ports.md +53 -0
- package/docs/contracts/no-runtime-boundary.md +1 -1
- package/docs/contracts/subagent-boundary.md +15 -0
- package/docs/customization.md +14 -3
- package/docs/decisions/ADR-016-installer-architecture.md +2 -0
- package/docs/decisions/ADR-088-no-external-runtime-federation.md +1 -1
- package/docs/decisions/ADR-094-agent-memory-layer-removal.md +1 -1
- package/docs/decisions/ADR-109-subagent-v1-contract.md +2 -0
- package/docs/decisions/ADR-110-discipline-profile-resolution-locus.md +2 -0
- package/docs/decisions/ADR-113-ci-native-release-label-trigger.md +11 -3
- package/docs/decisions/ADR-124-embedded-engine-doctrine.md +255 -0
- package/docs/decisions/ADR-125-doc-screenshot-anonymization.md +131 -0
- package/docs/decisions/ADR-126-internet-reach-operator-tooling.md +254 -0
- package/docs/decisions/ADR-127-enforcement-claims-must-resolve.md +150 -0
- package/docs/decisions/ADR-128-executable-evidence-and-measured-escalation.md +201 -0
- package/docs/decisions/INDEX.md +21 -0
- package/docs/decisions/engine-reclassification-2026-07.md +104 -0
- package/docs/distribution/awesome-list-submission.md +1 -1
- package/docs/distribution/registries.md +36 -27
- package/docs/enforcement-by-host.md +7 -0
- package/docs/featured-skills.md +2 -2
- package/docs/getting-started-by-role.md +1 -1
- package/docs/getting-started.md +1 -1
- package/docs/governance-advantage.md +1 -1
- package/docs/guides/gated-platform-reads.md +221 -0
- package/docs/positioning-evidence.md +1 -1
- package/docs/proof.md +92 -16
- package/docs/threat-model.md +1 -0
- package/package.json +5 -2
- package/src/config/agent-settings.template.yml +43 -0
- package/src/config/reach-channels.yml +217 -0
- package/src/config/reach-prescriptions-intake.yml +200 -0
- package/src/scripts/_dispatch.bash +15 -0
- package/src/scripts/_lib/exec_evidence.ts +251 -0
- package/src/scripts/_lib/fixtures/secret-detector/negatives.txt +10 -0
- package/src/scripts/_lib/fixtures/secret-detector/positives.txt +11 -0
- package/src/scripts/_lib/kernel_rules.ts +46 -0
- package/src/scripts/_lib/reddit_thread_parse.ts +598 -0
- package/src/scripts/_lib/risk_paths.ts +160 -0
- package/src/scripts/_lib/secret_detector.test.ts +103 -0
- package/src/scripts/_lib/secret_detector.ts +319 -0
- package/src/scripts/_lib/tool_probe.ts +359 -0
- package/src/scripts/adr/regenerate_index.ts +81 -6
- package/src/scripts/audit_adr_coverage.ts +33 -0
- package/src/scripts/build_proof.ts +162 -0
- package/src/scripts/check_adr_frontmatter.ts +195 -0
- package/src/scripts/check_artefact_count_messaging.ts +4 -0
- package/src/scripts/check_backstop_debt.ts +200 -0
- package/src/scripts/check_claims.ts +188 -8
- package/src/scripts/check_comparison.ts +12 -0
- package/src/scripts/check_enforcement_coverage.ts +592 -0
- package/src/scripts/check_kernel_rule_bundle.ts +16 -12
- package/src/scripts/check_reach_channels.ts +407 -0
- package/src/scripts/check_reach_staleness.ts +364 -0
- package/src/scripts/check_secret_leak.test.ts +47 -0
- package/src/scripts/check_secret_leak.ts +290 -0
- package/src/scripts/code_graph/build.ts +0 -0
- package/src/scripts/code_graph/cli.ts +207 -0
- package/src/scripts/code_graph/detect.ts +134 -0
- package/src/scripts/code_graph/extract.ts +386 -0
- package/src/scripts/code_graph/loader.ts +123 -0
- package/src/scripts/code_graph/query.ts +176 -0
- package/src/scripts/code_graph/sanitize.ts +30 -0
- package/src/scripts/code_graph/types.ts +83 -0
- package/src/scripts/code_graph/validate.ts +63 -0
- package/src/scripts/external_sources_denylist.json +3 -1
- package/src/scripts/generate_subagent_floor.ts +194 -0
- package/src/scripts/hook_manifest.yaml +13 -3
- package/src/scripts/hooks/code_graph_nudge_hook.ts +184 -0
- package/src/scripts/iron_law_sha.ts +3 -12
- package/src/scripts/lint_override_kernel_guard.ts +219 -0
- package/src/scripts/measure_rule_budget.ts +3 -12
- package/src/scripts/postinstall_gui.ts +129 -0
- package/src/scripts/reach_doctor.ts +1651 -0
- package/src/scripts/release.ts +24 -3
- package/src/scripts/risk_escalation_shadow.ts +160 -0
- package/src/scripts/schemas/agent-settings.schema.json +23 -0
- package/src/scripts/schemas/reach-channels.schema.json +134 -0
- package/src/scripts/schemas/reach-doctor-payload.schema.json +220 -0
- package/src/scripts/schemas/rule.schema.json +10 -0
- package/src/scripts/skill_linter.ts +55 -0
- package/src/scripts/validate_reach_prescriptions.ts +751 -0
- package/src/server/schemas/settings.ts +16 -0
- package/src/shared/capabilities.test.ts +43 -0
- package/src/shared/capabilities.ts +75 -0
- package/src/templates/minimal/agents-overrides-readme.md +20 -0
- 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
|
+
}
|