@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,364 @@
|
|
|
1
|
+
#!/usr/bin/env tsx
|
|
2
|
+
/**
|
|
3
|
+
* check_reach_staleness.ts — offline staleness gate for the reach channel
|
|
4
|
+
* registry (road-to-internet-reach Phase 7, Step 3).
|
|
5
|
+
*
|
|
6
|
+
* WHY THIS EXISTS. The council asked for a weekly cron that probes every
|
|
7
|
+
* upstream backend. A scheduled job against third-party endpoints buys flaky
|
|
8
|
+
* CI plus an implicit availability promise, so it was replaced by this: the
|
|
9
|
+
* registry records WHEN a human last verified each channel (`last_verified:`)
|
|
10
|
+
* and its deprecation path (`lifecycle:` / `removal_after:` / `replacement:`),
|
|
11
|
+
* and this lint reads those fields. Capability rot becomes a red CI line
|
|
12
|
+
* instead of a silent decay — without a single outbound request.
|
|
13
|
+
*
|
|
14
|
+
* OFFLINE AND DETERMINISTIC BY CONSTRUCTION. One file is read and compared
|
|
15
|
+
* against a date. No subprocess, no socket, no PATH lookup. Time is the only
|
|
16
|
+
* external input, so it is INJECTABLE: `--today YYYY-MM-DD` overrides the
|
|
17
|
+
* clock, which is what lets the fixtures below pin a reference date instead of
|
|
18
|
+
* expiring on a calendar boundary. A test whose verdict flips 91 days from now
|
|
19
|
+
* is a broken test, not a passing one.
|
|
20
|
+
*
|
|
21
|
+
* WHAT IT CHECKS (the three roadmap rules, one finding kind each)
|
|
22
|
+
* (a) stale-verification — `last_verified` older than 90 days.
|
|
23
|
+
* (b) deprecated-without-replacement — `lifecycle: deprecated` and no
|
|
24
|
+
* `replacement:` — a dead end with no
|
|
25
|
+
* migration target.
|
|
26
|
+
* (c) removal-overdue — today is past `removal_after` and the
|
|
27
|
+
* channel is still in the registry.
|
|
28
|
+
* plus (defensive) unparseable-date — a missing or non-ISO date field. Shape
|
|
29
|
+
* validation belongs to check_reach_channels.ts; this kind exists only so
|
|
30
|
+
* an unreadable date can never be scored as "fresh".
|
|
31
|
+
*
|
|
32
|
+
* REUSE, NOT DUPLICATION. The YAML loader and the exit-3 error class come from
|
|
33
|
+
* check_reach_channels.ts, exactly as validate_reach_prescriptions.ts does.
|
|
34
|
+
*
|
|
35
|
+
* Exit codes:
|
|
36
|
+
* 0 — clean.
|
|
37
|
+
* 1 — at least one staleness violation.
|
|
38
|
+
* 3 — internal error: target missing, unreadable, unparseable, or a bad
|
|
39
|
+
* `--today` value.
|
|
40
|
+
*
|
|
41
|
+
* Invocation (from project root):
|
|
42
|
+
* tsx src/scripts/check_reach_staleness.ts [<path-to-yml>] [--today YYYY-MM-DD]
|
|
43
|
+
* [--quiet] [--help]
|
|
44
|
+
*
|
|
45
|
+
* The optional path argument is what lets the negative fixtures under
|
|
46
|
+
* `tests/fixtures/reach-staleness/` run through the same code path as the real
|
|
47
|
+
* 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 { RegistryLoadError, load_registry } from './check_reach_channels.js';
|
|
55
|
+
|
|
56
|
+
const _HERE = fileURLToPath(import.meta.url);
|
|
57
|
+
/** Repo root — two dirs up from src/scripts. */
|
|
58
|
+
const ROOT = path.resolve(path.dirname(_HERE), '..', '..');
|
|
59
|
+
|
|
60
|
+
export const REGISTRY_PATH = path.join(ROOT, 'src', 'config', 'reach-channels.yml');
|
|
61
|
+
|
|
62
|
+
/** A channel unverified for longer than this is stale (roadmap Phase 7.3). */
|
|
63
|
+
export const STALE_AFTER_DAYS = 90;
|
|
64
|
+
|
|
65
|
+
const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
|
66
|
+
const MS_PER_DAY = 86_400_000;
|
|
67
|
+
|
|
68
|
+
/** Every finding carries the channel id that makes it actionable without a grep. */
|
|
69
|
+
export interface StalenessFinding {
|
|
70
|
+
/** `channels[github]` or `channels[github].removal_after`. */
|
|
71
|
+
readonly locator: string;
|
|
72
|
+
/** Machine-stable violation class — tests assert on this, not on prose. */
|
|
73
|
+
readonly kind:
|
|
74
|
+
| 'stale-verification'
|
|
75
|
+
| 'deprecated-without-replacement'
|
|
76
|
+
| 'removal-overdue'
|
|
77
|
+
| 'unparseable-date';
|
|
78
|
+
readonly message: string;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function as_object(value: unknown): Record<string, unknown> | null {
|
|
82
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
83
|
+
? (value as Record<string, unknown>)
|
|
84
|
+
: null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Parse an ISO calendar date to UTC midnight. Returns null for anything that is
|
|
89
|
+
* not a `YYYY-MM-DD` string, so a YAML timestamp (an unquoted date) is rejected
|
|
90
|
+
* rather than silently coerced.
|
|
91
|
+
*/
|
|
92
|
+
export function parse_iso_date(value: unknown): number | null {
|
|
93
|
+
if (typeof value !== 'string' || !ISO_DATE_RE.test(value)) {
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
const stamp = Date.parse(`${value}T00:00:00Z`);
|
|
97
|
+
if (Number.isNaN(stamp)) {
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
// Round-trip guard: `2026-02-31` parses in some engines but is not a date.
|
|
101
|
+
return new Date(stamp).toISOString().slice(0, 10) === value ? stamp : null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Whole days between two UTC-midnight stamps. */
|
|
105
|
+
export function days_between(fromStamp: number, toStamp: number): number {
|
|
106
|
+
return Math.floor((toStamp - fromStamp) / MS_PER_DAY);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Today as `YYYY-MM-DD` in UTC — the only place the clock is read. */
|
|
110
|
+
export function today_iso(now: Date = new Date()): string {
|
|
111
|
+
return now.toISOString().slice(0, 10);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export interface Channel {
|
|
115
|
+
readonly id: string;
|
|
116
|
+
readonly lifecycle?: unknown;
|
|
117
|
+
readonly last_verified?: unknown;
|
|
118
|
+
readonly removal_after?: unknown;
|
|
119
|
+
readonly replacement?: unknown;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Flatten the registry's `channels:` list. Defensive by design: a malformed
|
|
124
|
+
* registry is reported by the schema gate, so an unexpected entry is skipped
|
|
125
|
+
* here rather than thrown.
|
|
126
|
+
*/
|
|
127
|
+
export function extract_channels(registry: unknown): Channel[] {
|
|
128
|
+
const root = as_object(registry);
|
|
129
|
+
const channels = Array.isArray(root?.channels) ? root.channels : [];
|
|
130
|
+
const out: Channel[] = [];
|
|
131
|
+
channels.forEach((rawChannel, index) => {
|
|
132
|
+
const channel = as_object(rawChannel);
|
|
133
|
+
if (!channel) {
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
out.push({
|
|
137
|
+
id: typeof channel.id === 'string' ? channel.id : `#${index}`,
|
|
138
|
+
lifecycle: channel.lifecycle,
|
|
139
|
+
last_verified: channel.last_verified,
|
|
140
|
+
removal_after: channel.removal_after,
|
|
141
|
+
replacement: channel.replacement,
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
return out;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* The three roadmap rules, evaluated against an injected reference date.
|
|
149
|
+
*
|
|
150
|
+
* All three are reported independently: a channel can be simultaneously stale,
|
|
151
|
+
* replacement-less and overdue, and hiding two of those behind the first would
|
|
152
|
+
* make the fix a guessing game.
|
|
153
|
+
*/
|
|
154
|
+
export function check_channels(registry: unknown, todayStamp: number): StalenessFinding[] {
|
|
155
|
+
const findings: StalenessFinding[] = [];
|
|
156
|
+
|
|
157
|
+
for (const channel of extract_channels(registry)) {
|
|
158
|
+
const at = `channels[${channel.id}]`;
|
|
159
|
+
|
|
160
|
+
// (a) staleness of the human verification.
|
|
161
|
+
const verified = parse_iso_date(channel.last_verified);
|
|
162
|
+
if (verified === null) {
|
|
163
|
+
findings.push({
|
|
164
|
+
locator: `${at}.last_verified`,
|
|
165
|
+
kind: 'unparseable-date',
|
|
166
|
+
message: `\`last_verified\` is missing or not a quoted ISO date (YYYY-MM-DD), got ${JSON.stringify(channel.last_verified ?? null)} — staleness cannot be judged (fix via \`task check-reach-channels\`)`,
|
|
167
|
+
});
|
|
168
|
+
} else {
|
|
169
|
+
const age = days_between(verified, todayStamp);
|
|
170
|
+
if (age > STALE_AFTER_DAYS) {
|
|
171
|
+
findings.push({
|
|
172
|
+
locator: at,
|
|
173
|
+
kind: 'stale-verification',
|
|
174
|
+
message: `\`last_verified\` is ${age} days old (${String(channel.last_verified)}), over the ${STALE_AFTER_DAYS}-day floor — re-verify the channel (\`./agent-config reach:doctor --deep\`) and commit a fresh date, or retire the channel`,
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const isDeprecated = channel.lifecycle === 'deprecated';
|
|
180
|
+
|
|
181
|
+
// (b) a deprecated channel with no migration target.
|
|
182
|
+
if (isDeprecated) {
|
|
183
|
+
const replacement =
|
|
184
|
+
typeof channel.replacement === 'string' ? channel.replacement.trim() : '';
|
|
185
|
+
if (replacement === '') {
|
|
186
|
+
findings.push({
|
|
187
|
+
locator: at,
|
|
188
|
+
kind: 'deprecated-without-replacement',
|
|
189
|
+
message:
|
|
190
|
+
'channel is `lifecycle: deprecated` but declares no `replacement:` — a deprecation with no successor is a dead end; name the channel id that supersedes it, or drop the channel',
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// (c) a channel that outlived its own removal date.
|
|
196
|
+
//
|
|
197
|
+
// NOT A LEXICOGRAPHIC DATE COMPARISON — flagged as one by review, so the
|
|
198
|
+
// two facts that refute it are recorded here rather than re-derived:
|
|
199
|
+
// 1. Both operands are NUMBERS. `parse_iso_date` (line 92 of this
|
|
200
|
+
// file) returns a UTC-midnight epoch stamp or `null`, and `null`
|
|
201
|
+
// takes the `unparseable-date` branch below instead of any
|
|
202
|
+
// comparison; `todayStamp` comes from the same function. The `>`
|
|
203
|
+
// below is therefore numeric — as is the `age` test in (a) above,
|
|
204
|
+
// which goes through `days_between`.
|
|
205
|
+
// 2. The input shape is pinned upstream regardless:
|
|
206
|
+
// `src/scripts/schemas/reach-channels.schema.json` constrains
|
|
207
|
+
// `last_verified` (line 52) and `removal_after` (line 57) to
|
|
208
|
+
// `^[0-9]{4}-[0-9]{2}-[0-9]{2}$`, and `ISO_DATE_RE` (line 65) is
|
|
209
|
+
// re-checked here, so an unpadded or non-ISO date cannot reach the
|
|
210
|
+
// parse at all.
|
|
211
|
+
if (channel.removal_after !== undefined && channel.removal_after !== null) {
|
|
212
|
+
const removal = parse_iso_date(channel.removal_after);
|
|
213
|
+
if (removal === null) {
|
|
214
|
+
findings.push({
|
|
215
|
+
locator: `${at}.removal_after`,
|
|
216
|
+
kind: 'unparseable-date',
|
|
217
|
+
message: `\`removal_after\` is not a quoted ISO date (YYYY-MM-DD), got ${JSON.stringify(channel.removal_after)} — the removal deadline cannot be judged (fix via \`task check-reach-channels\`)`,
|
|
218
|
+
});
|
|
219
|
+
} else if (todayStamp > removal) {
|
|
220
|
+
const overdue = days_between(removal, todayStamp);
|
|
221
|
+
findings.push({
|
|
222
|
+
locator: at,
|
|
223
|
+
kind: 'removal-overdue',
|
|
224
|
+
message: `channel is ${overdue} day(s) past its \`removal_after\` date (${String(channel.removal_after)}) and is still present in the registry — remove it, or move the date with the reason recorded in internal/upstream-changes.md`,
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
return findings;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export interface RunOptions {
|
|
234
|
+
readonly registryPath?: string | undefined;
|
|
235
|
+
/** Injected clock: `YYYY-MM-DD`. Defaults to the real UTC date. */
|
|
236
|
+
readonly today?: string | undefined;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Everything the CLI reports, as data. Throws RegistryLoadError for the exit-3
|
|
241
|
+
* class so an unusable file is never reported as "0 violations".
|
|
242
|
+
*/
|
|
243
|
+
export function run_checks(options: RunOptions = {}): StalenessFinding[] {
|
|
244
|
+
const registryPath = options.registryPath ?? REGISTRY_PATH;
|
|
245
|
+
const today = options.today ?? today_iso();
|
|
246
|
+
const todayStamp = parse_iso_date(today);
|
|
247
|
+
if (todayStamp === null) {
|
|
248
|
+
throw new RegistryLoadError(`--today must be an ISO date (YYYY-MM-DD), got ${today}`);
|
|
249
|
+
}
|
|
250
|
+
return check_channels(load_registry(registryPath), todayStamp);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const HELP = `check_reach_staleness — offline staleness gate for the reach channel registry
|
|
254
|
+
|
|
255
|
+
Usage:
|
|
256
|
+
tsx src/scripts/check_reach_staleness.ts [<registry.yml>] [options]
|
|
257
|
+
|
|
258
|
+
Options:
|
|
259
|
+
--today <YYYY-MM-DD> Reference date (default: the real UTC date). Time is the
|
|
260
|
+
only external input, so it is injectable — the fixtures
|
|
261
|
+
pin it instead of expiring on a calendar boundary.
|
|
262
|
+
--quiet Suppress the success line
|
|
263
|
+
--help This text
|
|
264
|
+
|
|
265
|
+
Checks (all offline — no network, no subprocess):
|
|
266
|
+
stale-verification last_verified older than ${STALE_AFTER_DAYS} days
|
|
267
|
+
deprecated-without-replacement lifecycle: deprecated with no replacement:
|
|
268
|
+
removal-overdue today is past removal_after and the channel is still present
|
|
269
|
+
unparseable-date a missing or non-ISO date field (defensive; shape is
|
|
270
|
+
owned by task check-reach-channels)
|
|
271
|
+
|
|
272
|
+
Replaces a scheduled network probe on purpose: a cron against third-party
|
|
273
|
+
endpoints buys flaky CI plus an implicit availability promise. Real requests are
|
|
274
|
+
operator-invoked only (\`./agent-config reach:doctor --deep\`).
|
|
275
|
+
|
|
276
|
+
Exit codes: 0 clean · 1 violations · 3 unusable input (missing / unparseable file or --today).
|
|
277
|
+
`;
|
|
278
|
+
|
|
279
|
+
export function main(argv: string[] = process.argv.slice(2)): number {
|
|
280
|
+
if (argv.includes('--help') || argv.includes('-h')) {
|
|
281
|
+
process.stdout.write(HELP);
|
|
282
|
+
return 0;
|
|
283
|
+
}
|
|
284
|
+
const quiet = argv.includes('--quiet');
|
|
285
|
+
|
|
286
|
+
let today: string | undefined;
|
|
287
|
+
const positional: string[] = [];
|
|
288
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
289
|
+
const arg = argv[index];
|
|
290
|
+
if (arg === undefined) {
|
|
291
|
+
continue;
|
|
292
|
+
}
|
|
293
|
+
if (arg === '--today') {
|
|
294
|
+
const value = argv[index + 1];
|
|
295
|
+
if (!value) {
|
|
296
|
+
process.stderr.write('check_reach_staleness: --today needs a date\n');
|
|
297
|
+
return 3;
|
|
298
|
+
}
|
|
299
|
+
today = value;
|
|
300
|
+
index += 1;
|
|
301
|
+
} else if (arg.startsWith('--today=')) {
|
|
302
|
+
today = arg.slice('--today='.length);
|
|
303
|
+
} else if (!arg.startsWith('-')) {
|
|
304
|
+
positional.push(arg);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
if (positional.length > 1) {
|
|
308
|
+
process.stderr.write('check_reach_staleness: at most one path argument is accepted\n');
|
|
309
|
+
return 3;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const registryPath = positional[0] ? path.resolve(positional[0]) : REGISTRY_PATH;
|
|
313
|
+
const relTarget = path.relative(ROOT, registryPath) || registryPath;
|
|
314
|
+
|
|
315
|
+
let findings: StalenessFinding[];
|
|
316
|
+
try {
|
|
317
|
+
findings = run_checks({ registryPath, today });
|
|
318
|
+
} catch (err) {
|
|
319
|
+
if (err instanceof RegistryLoadError) {
|
|
320
|
+
process.stderr.write(`❌ check_reach_staleness: ${err.message}\n`);
|
|
321
|
+
return 3;
|
|
322
|
+
}
|
|
323
|
+
throw err;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
if (findings.length > 0) {
|
|
327
|
+
process.stdout.write(`❌ ${relTarget}: ${findings.length} staleness violation(s):\n`);
|
|
328
|
+
for (const finding of findings) {
|
|
329
|
+
process.stdout.write(` - [${finding.kind}] ${finding.locator}: ${finding.message}\n`);
|
|
330
|
+
}
|
|
331
|
+
return 1;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
if (!quiet) {
|
|
335
|
+
process.stdout.write(
|
|
336
|
+
`✅ ${relTarget}: every channel is verified within ${STALE_AFTER_DAYS} days, deprecations name a replacement, and no removal date is overdue (offline check, reference date ${today ?? today_iso()}).\n`,
|
|
337
|
+
);
|
|
338
|
+
}
|
|
339
|
+
return 0;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function _isCliEntry(): boolean {
|
|
343
|
+
if (!process.argv[1]) {
|
|
344
|
+
return false;
|
|
345
|
+
}
|
|
346
|
+
const argvUrl = pathToFileURL(path.resolve(process.argv[1])).href;
|
|
347
|
+
if (import.meta.url === argvUrl) {
|
|
348
|
+
return true;
|
|
349
|
+
}
|
|
350
|
+
// A symlinked invocation makes the raw URLs differ (import.meta.url is the
|
|
351
|
+
// resolved real path while argv[1] keeps the symlink path) — compare
|
|
352
|
+
// realpaths so the CLI still fires when run through a projection symlink.
|
|
353
|
+
try {
|
|
354
|
+
const here = fs.realpathSync(fileURLToPath(import.meta.url));
|
|
355
|
+
const argv = fs.realpathSync(path.resolve(process.argv[1]));
|
|
356
|
+
return here === argv;
|
|
357
|
+
} catch {
|
|
358
|
+
return false;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
if (_isCliEntry()) {
|
|
363
|
+
process.exit(main());
|
|
364
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import * as fs from 'node:fs';
|
|
2
|
+
import * as os from 'node:os';
|
|
3
|
+
import * as path from 'node:path';
|
|
4
|
+
|
|
5
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
6
|
+
|
|
7
|
+
import { readAllowFile, scanRepo } from './check_secret_leak.js';
|
|
8
|
+
|
|
9
|
+
describe('check_secret_leak — .secret-allow', () => {
|
|
10
|
+
let tmp: string;
|
|
11
|
+
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'secret-allow-'));
|
|
14
|
+
});
|
|
15
|
+
afterEach(() => {
|
|
16
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('parses path and path:line entries, dropping comments', () => {
|
|
20
|
+
fs.writeFileSync(
|
|
21
|
+
path.join(tmp, '.secret-allow'),
|
|
22
|
+
'# audited: intentional fixture\nleak.txt:1\nwhole-file.txt\n\n',
|
|
23
|
+
);
|
|
24
|
+
expect(readAllowFile(tmp)).toEqual([
|
|
25
|
+
{ file: 'leak.txt', line: 1 },
|
|
26
|
+
{ file: 'whole-file.txt', line: null },
|
|
27
|
+
]);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('is narrow — an allowed line is muted but a DIFFERENT secret in the same file is still caught', () => {
|
|
31
|
+
// Two real-shaped (fake) AWS keys, one per line.
|
|
32
|
+
fs.writeFileSync(
|
|
33
|
+
path.join(tmp, 'leak.txt'),
|
|
34
|
+
'aws_a = AKIA1234567890ABCDEF\naws_b = AKIAZZ99887766554433\n',
|
|
35
|
+
);
|
|
36
|
+
fs.writeFileSync(path.join(tmp, '.secret-allow'), 'leak.txt:1\n');
|
|
37
|
+
|
|
38
|
+
const hits = scanRepo(tmp, 'explicit', { explicit: ['leak.txt'] });
|
|
39
|
+
expect(hits).toHaveLength(1);
|
|
40
|
+
expect(hits[0]?.line).toBe(2); // line 1 suppressed, line 2 still flagged
|
|
41
|
+
expect(hits[0]?.kind).toBe('aws-access-key');
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('returns no entries when .secret-allow is absent', () => {
|
|
45
|
+
expect(readAllowFile(tmp)).toEqual([]);
|
|
46
|
+
});
|
|
47
|
+
});
|