@event4u/agent-config 9.6.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 (204) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +207 -942
  3. package/README.md +21 -21
  4. package/dist/agent-src/README.md +2 -2
  5. package/dist/agent-src/commands/commit/in-chunks.md +8 -0
  6. package/dist/agent-src/commands/commit.md +17 -0
  7. package/dist/agent-src/commands/pr/create.md +4 -0
  8. package/dist/agent-src/contexts/override-system.md +75 -5
  9. package/dist/agent-src/rules/context-hygiene.md +2 -0
  10. package/dist/agent-src/rules/doc-screenshot-hygiene.md +97 -0
  11. package/dist/agent-src/rules/domain-safety-pii.md +1 -0
  12. package/dist/agent-src/rules/downstream-changes.md +31 -1
  13. package/dist/agent-src/rules/external-code-graph-interop.md +13 -3
  14. package/dist/agent-src/rules/framework-neutrality-in-generic-skills.md +2 -0
  15. package/dist/agent-src/rules/git-history-discipline.md +2 -0
  16. package/dist/agent-src/rules/language-and-tone.md +2 -0
  17. package/dist/agent-src/rules/lethal-trifecta-guard.md +3 -0
  18. package/dist/agent-src/rules/media-governance-routing.md +2 -0
  19. package/dist/agent-src/rules/minimal-safe-diff.md +2 -0
  20. package/dist/agent-src/rules/no-roadmap-references.md +3 -0
  21. package/dist/agent-src/rules/non-destructive-by-default.md +20 -0
  22. package/dist/agent-src/rules/onboarding-gate.md +2 -0
  23. package/dist/agent-src/rules/output-discipline.md +2 -0
  24. package/dist/agent-src/rules/persona-governance.md +2 -0
  25. package/dist/agent-src/rules/preservation-guard.md +3 -0
  26. package/dist/agent-src/rules/roadmap-progress-sync.md +2 -0
  27. package/dist/agent-src/rules/secret-vcs-guard.md +106 -0
  28. package/dist/agent-src/rules/security-sensitive-stop.md +1 -1
  29. package/dist/agent-src/rules/skill-quality.md +2 -0
  30. package/dist/agent-src/rules/source-confidentiality.md +2 -0
  31. package/dist/agent-src/rules/source-of-truth.md +2 -0
  32. package/dist/agent-src/rules/telegraph-speak.md +2 -0
  33. package/dist/agent-src/rules/token-optimizer-maintenance.md +2 -0
  34. package/dist/agent-src/rules/tool-safety.md +1 -0
  35. package/dist/agent-src/rules/verify-before-complete.md +2 -0
  36. package/dist/agent-src/skills/adr-create/SKILL.md +17 -1
  37. package/dist/agent-src/skills/agent-docs-writing/SKILL.md +34 -16
  38. package/dist/agent-src/skills/agents-md-thin-root/SKILL.md +9 -9
  39. package/dist/agent-src/skills/code-intelligence/SKILL.md +94 -0
  40. package/dist/agent-src/skills/gated-reach/SKILL.md +185 -0
  41. package/dist/agent-src/skills/gated-reach/evals/triggers.json +72 -0
  42. package/dist/agent-src/skills/image-analyser/SKILL.md +14 -12
  43. package/dist/agent-src/skills/image-editing/SKILL.md +1 -0
  44. package/dist/agent-src/skills/readme-writing/SKILL.md +6 -1
  45. package/dist/agent-src/skills/screenshot-hygiene/SKILL.md +171 -0
  46. package/dist/agent-src/skills/secrets-management/SKILL.md +44 -1
  47. package/dist/agent-src/skills/subagent-orchestration/prompts/adversarial-verification-council.md +29 -0
  48. package/dist/agent-src/skills/subagent-orchestration/prompts/do-and-judge-two-stage.md +29 -0
  49. package/dist/agent-src/skills/subagent-orchestration/prompts/do-and-judge.md +29 -0
  50. package/dist/agent-src/skills/subagent-orchestration/prompts/do-competitively.md +29 -0
  51. package/dist/agent-src/skills/subagent-orchestration/prompts/do-in-parallel.md +29 -0
  52. package/dist/agent-src/skills/subagent-orchestration/prompts/do-in-steps.md +29 -0
  53. package/dist/agent-src/skills/subagent-orchestration/prompts/do-in-worktrees.md +29 -0
  54. package/dist/agent-src/skills/subagent-orchestration/prompts/judge-with-debate.md +29 -0
  55. package/dist/agent-src/skills/verify-repair-loop/SKILL.md +4 -0
  56. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  57. package/dist/agent-src/templates/github-workflows/doc-impact.yml +101 -0
  58. package/dist/agent-src/templates/github-workflows/secret-scan.yml +65 -0
  59. package/dist/cli/configRoot.js +112 -0
  60. package/dist/cli/configRoot.js.map +1 -0
  61. package/dist/cli/configRoot.test.js +97 -0
  62. package/dist/cli/configRoot.test.js.map +1 -0
  63. package/dist/cli/help.js +4 -1
  64. package/dist/cli/help.js.map +1 -1
  65. package/dist/cli/main.js +19 -2
  66. package/dist/cli/main.js.map +1 -1
  67. package/dist/cli/registry.js +1 -0
  68. package/dist/cli/registry.js.map +1 -1
  69. package/dist/discovery/deprecation-report.md +1 -1
  70. package/dist/discovery/discovery-manifest.json +164 -47
  71. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  72. package/dist/discovery/discovery-manifest.summary.md +4 -4
  73. package/dist/discovery/orphan-report.md +1 -1
  74. package/dist/discovery/packs.json +13 -8
  75. package/dist/discovery/trust-report.md +11 -11
  76. package/dist/discovery/workspaces.json +11 -6
  77. package/dist/install/install.mjs +16 -0
  78. package/dist/mcp/awesome-mcp-servers.row.md +1 -1
  79. package/dist/mcp/mcp-cloudflare-catalogue.json +1 -1
  80. package/dist/mcp/registry-manifest.json +4 -4
  81. package/dist/router.json +1 -1
  82. package/dist/server/app.js +10 -0
  83. package/dist/server/app.js.map +1 -1
  84. package/dist/server/routes/ping.js +18 -0
  85. package/dist/server/routes/ping.js.map +1 -1
  86. package/dist/server/schemas/settings.js +8 -0
  87. package/dist/server/schemas/settings.js.map +1 -1
  88. package/dist/server/serverInfo.js +6 -2
  89. package/dist/server/serverInfo.js.map +1 -1
  90. package/dist/server/token.js +11 -4
  91. package/dist/server/token.js.map +1 -1
  92. package/dist/server/writeRoot.js +15 -1
  93. package/dist/server/writeRoot.js.map +1 -1
  94. package/dist/shared/capabilities.js +33 -0
  95. package/dist/shared/capabilities.js.map +1 -0
  96. package/dist/shared/capabilities.test.js +39 -0
  97. package/dist/shared/capabilities.test.js.map +1 -0
  98. package/dist/ui/assets/{index-BiEZiZ31.js → index-CrmZ34Rg.js} +16 -16
  99. package/dist/ui/assets/index-CrmZ34Rg.js.map +1 -0
  100. package/dist/ui/index.html +14 -5
  101. package/docs/CLAIMS.md +64 -38
  102. package/docs/architecture.md +2 -2
  103. package/docs/archive/CHANGELOG-6.0.0-overview.md +953 -0
  104. package/docs/benchmark.md +170 -9
  105. package/docs/catalog.md +8 -3
  106. package/docs/comparison.yaml +44 -2
  107. package/docs/contracts/local-server-ports.md +53 -0
  108. package/docs/contracts/no-runtime-boundary.md +1 -1
  109. package/docs/contracts/subagent-boundary.md +15 -0
  110. package/docs/customization.md +14 -3
  111. package/docs/decisions/ADR-016-installer-architecture.md +2 -0
  112. package/docs/decisions/ADR-088-no-external-runtime-federation.md +1 -1
  113. package/docs/decisions/ADR-094-agent-memory-layer-removal.md +1 -1
  114. package/docs/decisions/ADR-109-subagent-v1-contract.md +2 -0
  115. package/docs/decisions/ADR-110-discipline-profile-resolution-locus.md +2 -0
  116. package/docs/decisions/ADR-113-ci-native-release-label-trigger.md +11 -3
  117. package/docs/decisions/ADR-122-adversarial-verification-council.md +30 -0
  118. package/docs/decisions/ADR-123-runtime-security-scope-and-spawn-hardening.md +31 -0
  119. package/docs/decisions/ADR-124-embedded-engine-doctrine.md +255 -0
  120. package/docs/decisions/ADR-125-doc-screenshot-anonymization.md +131 -0
  121. package/docs/decisions/ADR-126-internet-reach-operator-tooling.md +254 -0
  122. package/docs/decisions/ADR-127-enforcement-claims-must-resolve.md +150 -0
  123. package/docs/decisions/ADR-128-executable-evidence-and-measured-escalation.md +201 -0
  124. package/docs/decisions/INDEX.md +21 -0
  125. package/docs/decisions/engine-reclassification-2026-07.md +104 -0
  126. package/docs/distribution/awesome-list-submission.md +1 -1
  127. package/docs/distribution/registries.md +36 -27
  128. package/docs/enforcement-by-host.md +7 -0
  129. package/docs/featured-skills.md +2 -2
  130. package/docs/getting-started-by-role.md +1 -1
  131. package/docs/getting-started.md +1 -1
  132. package/docs/governance-advantage.md +1 -1
  133. package/docs/guides/gated-platform-reads.md +221 -0
  134. package/docs/positioning-evidence.md +1 -1
  135. package/docs/proof.md +92 -16
  136. package/docs/spawn-site-policy.md +72 -0
  137. package/docs/threat-model.md +2 -1
  138. package/package.json +6 -3
  139. package/src/agent-src/README.md +2 -2
  140. package/src/config/agent-settings.template.yml +43 -0
  141. package/src/config/reach-channels.yml +217 -0
  142. package/src/config/reach-prescriptions-intake.yml +200 -0
  143. package/src/scripts/_dispatch.bash +15 -0
  144. package/src/scripts/_lib/adversarial_bench_score.ts +106 -0
  145. package/src/scripts/_lib/exec_evidence.ts +251 -0
  146. package/src/scripts/_lib/fixtures/secret-detector/negatives.txt +10 -0
  147. package/src/scripts/_lib/fixtures/secret-detector/positives.txt +11 -0
  148. package/src/scripts/_lib/kernel_rules.ts +46 -0
  149. package/src/scripts/_lib/reddit_thread_parse.ts +598 -0
  150. package/src/scripts/_lib/risk_paths.ts +160 -0
  151. package/src/scripts/_lib/secret_detector.test.ts +103 -0
  152. package/src/scripts/_lib/secret_detector.ts +319 -0
  153. package/src/scripts/_lib/spawn_env.ts +24 -4
  154. package/src/scripts/_lib/tool_probe.ts +359 -0
  155. package/src/scripts/adr/regenerate_index.ts +81 -6
  156. package/src/scripts/audit_adr_coverage.ts +33 -0
  157. package/src/scripts/bench_adversarial_council.ts +292 -0
  158. package/src/scripts/build_proof.ts +162 -0
  159. package/src/scripts/check_adr_frontmatter.ts +195 -0
  160. package/src/scripts/check_artefact_count_messaging.ts +4 -0
  161. package/src/scripts/check_backstop_debt.ts +200 -0
  162. package/src/scripts/check_claims.ts +188 -8
  163. package/src/scripts/check_comparison.ts +12 -0
  164. package/src/scripts/check_enforcement_coverage.ts +592 -0
  165. package/src/scripts/check_kernel_rule_bundle.ts +16 -12
  166. package/src/scripts/check_no_new_legacy_path.ts +2 -0
  167. package/src/scripts/check_reach_channels.ts +407 -0
  168. package/src/scripts/check_reach_staleness.ts +364 -0
  169. package/src/scripts/check_secret_leak.test.ts +47 -0
  170. package/src/scripts/check_secret_leak.ts +290 -0
  171. package/src/scripts/check_source_pointer_freshness.ts +165 -0
  172. package/src/scripts/code_graph/build.ts +0 -0
  173. package/src/scripts/code_graph/cli.ts +207 -0
  174. package/src/scripts/code_graph/detect.ts +134 -0
  175. package/src/scripts/code_graph/extract.ts +386 -0
  176. package/src/scripts/code_graph/loader.ts +123 -0
  177. package/src/scripts/code_graph/query.ts +176 -0
  178. package/src/scripts/code_graph/sanitize.ts +30 -0
  179. package/src/scripts/code_graph/types.ts +83 -0
  180. package/src/scripts/code_graph/validate.ts +63 -0
  181. package/src/scripts/external_sources_denylist.json +3 -1
  182. package/src/scripts/generate_subagent_floor.ts +194 -0
  183. package/src/scripts/hook_manifest.yaml +13 -3
  184. package/src/scripts/hooks/code_graph_nudge_hook.ts +184 -0
  185. package/src/scripts/hooks/dispatch_hook.ts +5 -1
  186. package/src/scripts/iron_law_sha.ts +3 -12
  187. package/src/scripts/lint_override_kernel_guard.ts +219 -0
  188. package/src/scripts/lint_readme_serial_comma.ts +169 -0
  189. package/src/scripts/measure_rule_budget.ts +3 -12
  190. package/src/scripts/postinstall_gui.ts +129 -0
  191. package/src/scripts/reach_doctor.ts +1651 -0
  192. package/src/scripts/release.ts +24 -3
  193. package/src/scripts/risk_escalation_shadow.ts +160 -0
  194. package/src/scripts/schemas/agent-settings.schema.json +23 -0
  195. package/src/scripts/schemas/reach-channels.schema.json +134 -0
  196. package/src/scripts/schemas/reach-doctor-payload.schema.json +220 -0
  197. package/src/scripts/schemas/rule.schema.json +10 -0
  198. package/src/scripts/skill_linter.ts +55 -0
  199. package/src/scripts/validate_reach_prescriptions.ts +751 -0
  200. package/src/server/schemas/settings.ts +16 -0
  201. package/src/shared/capabilities.test.ts +43 -0
  202. package/src/shared/capabilities.ts +75 -0
  203. package/src/templates/minimal/agents-overrides-readme.md +20 -0
  204. package/dist/ui/assets/index-BiEZiZ31.js.map +0 -1
@@ -0,0 +1,160 @@
1
+ /**
2
+ * Trust-boundary risk classification — the second axis of rigor.
3
+ *
4
+ * `discipline_profile: auto` answers one question: *how much governance can
5
+ * this host afford?* It is a property of the environment. The question it
6
+ * cannot ask is *how dangerous is this diff?* — a property of the change.
7
+ *
8
+ * The two are orthogonal, and today only one of them is wired. That leaves a
9
+ * live combination: a weak host plus a diff in the installer's provenance path
10
+ * resolves to minimum discipline, which is exactly the change class hardened in
11
+ * 9.6.0 (CWE-426/88/427). The fix went in; the process escalation for the next
12
+ * change of the same class did not.
13
+ *
14
+ * **This module classifies. It does not gate.** It exists to produce the number
15
+ * that decides whether gating is justified — see
16
+ * `internal/reports/risk-escalation-shadow.json`. The teeth threshold was
17
+ * written into the roadmap before the report was first read, so the measurement
18
+ * cannot be rationalised after the fact.
19
+ *
20
+ * Classification is path-based on purpose. A model judgement about whether a
21
+ * diff "feels risky" is not reproducible from the diff alone; a path match is.
22
+ */
23
+
24
+ import { KERNEL_RULE_FILENAMES } from './kernel_rules.js';
25
+
26
+ /** Risk classes, in ascending severity. `none` is the common case. */
27
+ export type RiskClass = 'none' | 'governance' | 'auth' | 'trust-boundary';
28
+
29
+ /** Severity order — used to take the max across a multi-file diff. */
30
+ const SEVERITY: Record<RiskClass, number> = {
31
+ none: 0,
32
+ governance: 1,
33
+ auth: 2,
34
+ 'trust-boundary': 3,
35
+ };
36
+
37
+ export interface RiskVerdict {
38
+ /** Highest class across every path in the diff. */
39
+ risk: RiskClass;
40
+ /** Why — one reason per matched path, for the report. */
41
+ reasons: { path: string; risk: RiskClass; because: string }[];
42
+ }
43
+
44
+ /**
45
+ * Docs-only changes are exempt from escalation.
46
+ *
47
+ * Taken from the reference, which carves this out explicitly, and it is the
48
+ * right carve-out: a paragraph describing the installer does not cross a trust
49
+ * boundary.
50
+ *
51
+ * The exemption is about what a file *is*, not what extension it carries. In
52
+ * this repo governance is written in markdown — a rule file, `AGENTS.md`, and
53
+ * the kernel set are all `.md`, and all of them are executable policy rather
54
+ * than prose about policy. An extension-only test classified
55
+ * `src/rules/icon-consistency.md` as `none`, which is how the most governed
56
+ * surface in the tree would have escaped the classifier entirely. Governed
57
+ * markdown is excluded from the exemption up front.
58
+ */
59
+ function is_docs_only(p: string): boolean {
60
+ if (!/\.(md|mdx|txt)$/i.test(p)) return false;
61
+ if (p.startsWith('src/rules/')) return false;
62
+ if (/(^|\/)AGENTS\.md$/.test(p)) return false;
63
+ return true;
64
+ }
65
+
66
+ /**
67
+ * Does this path carry authentication, authorization, session, or token logic?
68
+ * Kept to the names this repo actually uses, so it does not fire on every file
69
+ * with "user" in it.
70
+ */
71
+ function is_auth_path(p: string): boolean {
72
+ return /(^|\/)(auth|authz|session|token|credential|secret)s?[/.]/i.test(p)
73
+ || /(^|\/)(keys|keychain)\b/i.test(p);
74
+ }
75
+
76
+ /**
77
+ * Does this path decide *where framework code comes from* or *whether it is
78
+ * genuine*? This is the class the reference justifies in its own text as
79
+ * crossing a downstream trust boundary — the change affects people other than
80
+ * the one making it, because a consumer installs the result.
81
+ */
82
+ function is_trust_boundary_path(p: string): boolean {
83
+ if (p.startsWith('src/install/')) return true;
84
+ if (/(^|\/)(install|bootstrap|update)r?\.(ts|sh|mjs|cjs)$/i.test(p)) return true;
85
+ if (/scripts\/install/i.test(p)) return true;
86
+ if (/(^|\/)(provenance|manifest|integrity|bundle)[.-]/i.test(p)) return true;
87
+ // Publishing and release automation resolve the source a consumer receives.
88
+ if (/^\.github\/workflows\/(publish|release)/i.test(p)) return true;
89
+ // The spawn path composes the prompt a subagent runs under, floor included.
90
+ if (/subagent_(spawn|bundle|floor)/i.test(p)) return true;
91
+ if (/generate_subagent_floor/i.test(p)) return true;
92
+ return false;
93
+ }
94
+
95
+ /** Kernel rules and the safety floor — the invariants everything else rests on. */
96
+ function is_kernel_path(p: string): boolean {
97
+ const base = p.slice(p.lastIndexOf('/') + 1);
98
+ if (p.startsWith('src/rules/') && KERNEL_RULE_FILENAMES.has(base)) return true;
99
+ return /_lib\/kernel_rules\.ts$/.test(p) || /iron_law_sha/.test(p);
100
+ }
101
+
102
+ /** Governance surfaces — rules, settings schema, and the agent contract files. */
103
+ function is_governance_path(p: string): boolean {
104
+ if (p.startsWith('src/rules/')) return true;
105
+ if (/(^|\/)AGENTS\.md$/.test(p)) return true;
106
+ if (/agent-settings.*\.(yml|yaml|json)$/i.test(p)) return true;
107
+ if (/schemas\/.*\.schema\.json$/.test(p)) return true;
108
+ return false;
109
+ }
110
+
111
+ /** Classify one path. */
112
+ export function classify_path(p: string): { risk: RiskClass; because: string } {
113
+ // Kernel and spawn paths outrank the docs exemption: a kernel rule IS a
114
+ // markdown file, so exempting by extension first would classify the most
115
+ // dangerous surface in the repo as `none`.
116
+ if (is_kernel_path(p)) {
117
+ return { risk: 'trust-boundary', because: 'kernel rule or Iron-Law integrity surface' };
118
+ }
119
+ if (is_docs_only(p)) return { risk: 'none', because: 'docs-only' };
120
+ if (is_trust_boundary_path(p)) {
121
+ return {
122
+ risk: 'trust-boundary',
123
+ because: 'installer, provenance, publish, or subagent-spawn path — crosses a downstream trust boundary',
124
+ };
125
+ }
126
+ if (is_auth_path(p)) {
127
+ return { risk: 'auth', because: 'authentication, session, token, or secret handling' };
128
+ }
129
+ if (is_governance_path(p)) {
130
+ return { risk: 'governance', because: 'rule, settings, or schema surface' };
131
+ }
132
+ return { risk: 'none', because: '' };
133
+ }
134
+
135
+ /** Classify a whole diff — the verdict is the highest class present. */
136
+ export function classify_diff(paths: readonly string[]): RiskVerdict {
137
+ const reasons: RiskVerdict['reasons'] = [];
138
+ let risk: RiskClass = 'none';
139
+ for (const p of paths) {
140
+ const v = classify_path(p);
141
+ if (v.risk === 'none') continue;
142
+ reasons.push({ path: p, risk: v.risk, because: v.because });
143
+ if (SEVERITY[v.risk] > SEVERITY[risk]) risk = v.risk;
144
+ }
145
+ return { risk, reasons };
146
+ }
147
+
148
+ /**
149
+ * Combine the host axis with the risk axis.
150
+ *
151
+ * The direction is the whole point and is encoded rather than left to a caller:
152
+ * the host profile sets a floor, a risk trigger may only **raise** it. A weak
153
+ * host must never be able to lower an escalation, which is the failure mode
154
+ * that makes the two-axis model worth having at all.
155
+ */
156
+ export function escalated_floor(hostFloor: number, risk: RiskClass): number {
157
+ return Math.max(hostFloor, SEVERITY[risk]);
158
+ }
159
+
160
+ export { SEVERITY as RISK_SEVERITY };
@@ -0,0 +1,103 @@
1
+ /**
2
+ * secret_detector — coverage (secret-hygiene-guardrail Phase 0).
3
+ *
4
+ * Positive fixtures: each labelled line carries one fake secret; expectations
5
+ * come from an in-test label→kind map, NOT from detector output.
6
+ * Negative fixtures: every line must be free of high/medium findings (low is
7
+ * only for placeholder-shaped lines; the `secret-allow` line yields nothing).
8
+ */
9
+ import * as fs from 'node:fs';
10
+ import * as path from 'node:path';
11
+ import { fileURLToPath } from 'node:url';
12
+
13
+ import { describe, expect, it } from 'vitest';
14
+
15
+ import { scanText, type SecretFinding } from './secret_detector.js';
16
+
17
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
18
+ const FIXTURES = path.join(HERE, 'fixtures', 'secret-detector');
19
+
20
+ function readFixture(name: string): string {
21
+ return fs.readFileSync(path.join(FIXTURES, name), 'utf-8');
22
+ }
23
+
24
+ // Label → expected kind. Keyed by the leading `<label>|` token, so a reordered
25
+ // fixture cannot silently pass.
26
+ const EXPECTED_KIND: Readonly<Record<string, string>> = {
27
+ aws: 'aws-access-key',
28
+ github: 'github-pat',
29
+ stripe: 'stripe-secret-key',
30
+ google: 'google-api-key',
31
+ slack: 'slack-token',
32
+ pem: 'pem-private-key',
33
+ jwt: 'jwt',
34
+ dburl: 'db-connection-uri',
35
+ assignment: 'generic-assignment',
36
+ };
37
+
38
+ function isReal(f: SecretFinding): boolean {
39
+ return f.confidence === 'high' || f.confidence === 'medium';
40
+ }
41
+
42
+ describe('scanText — positives', () => {
43
+ const text = readFixture('positives.txt');
44
+ const lines = text.split(/\r\n|\r|\n/);
45
+ const findings = scanText(text);
46
+
47
+ for (let i = 0; i < lines.length; i++) {
48
+ const line = lines[i] as string;
49
+ if (!line.includes('|') || line.startsWith('#')) {
50
+ continue;
51
+ }
52
+ const label = line.slice(0, line.indexOf('|'));
53
+ const expectedKind = EXPECTED_KIND[label];
54
+ const lineNo = i + 1;
55
+
56
+ it(`flags ${label} as ${expectedKind}`, () => {
57
+ expect(expectedKind, `no expected kind mapped for label "${label}"`).toBeDefined();
58
+ const onLine = findings.filter((f) => f.line === lineNo && isReal(f));
59
+ const kinds = onLine.map((f) => f.kind);
60
+ expect(
61
+ kinds,
62
+ `line ${lineNo} (${label}) expected kind ${expectedKind}, got ${JSON.stringify(onLine)}`,
63
+ ).toContain(expectedKind);
64
+ });
65
+ }
66
+ });
67
+
68
+ describe('scanText — negatives', () => {
69
+ const text = readFixture('negatives.txt');
70
+ const lines = text.split(/\r\n|\r|\n/);
71
+ const findings = scanText(text);
72
+
73
+ for (let i = 0; i < lines.length; i++) {
74
+ const line = lines[i] as string;
75
+ if (line.trim() === '' || line.startsWith('#')) {
76
+ continue;
77
+ }
78
+ const lineNo = i + 1;
79
+ it(`no high/medium finding on line ${lineNo}`, () => {
80
+ const real = findings.filter((f) => f.line === lineNo && isReal(f));
81
+ expect(real, `unexpected real findings: ${JSON.stringify(real)}`).toHaveLength(0);
82
+ });
83
+ }
84
+
85
+ it('the secret-allow line yields zero findings of any confidence', () => {
86
+ const allowLineNo = lines.findIndex((l) => l.includes('secret-allow')) + 1;
87
+ expect(allowLineNo, 'no secret-allow line in fixture').toBeGreaterThan(0);
88
+ const onLine = findings.filter((f) => f.line === allowLineNo);
89
+ expect(onLine).toHaveLength(0);
90
+ });
91
+ });
92
+
93
+ describe('scanText — masking', () => {
94
+ it('never reproduces the raw secret in `masked`', () => {
95
+ const raw = 'AKIA1234567890ABCDEF';
96
+ const findings = scanText(`aws_key = "${raw}"`);
97
+ const hit = findings.find((f) => f.kind === 'aws-access-key');
98
+ expect(hit, 'expected an aws-access-key finding').toBeDefined();
99
+ const masked = (hit as SecretFinding).masked;
100
+ expect(masked).not.toBe(raw);
101
+ expect(masked.includes(raw)).toBe(false);
102
+ });
103
+ });
@@ -0,0 +1,319 @@
1
+ // VCS-agnostic secret detector (secret-hygiene-guardrail Phase 0).
2
+ //
3
+ // A pure, I/O-free, network-free module: given a blob of text it returns the
4
+ // secrets it finds. Callers (pre-commit hooks, CI scanners, editor lints)
5
+ // decide policy — this module only detects.
6
+ //
7
+ // Three detection layers, in confidence order:
8
+ // 1. Regex rule pack (HIGH) — reuses the exact `_SECRET` patterns from
9
+ // `lint_mcp_config_security.ts` (sk-ant-, sk-proj-, AKIA, AIza, ghp_, JWT)
10
+ // and extends them with Stripe, Slack, PEM private-key headers, DB
11
+ // connection URLs with embedded credentials, and generic secret-ish
12
+ // assignments. The reused patterns are NOT weakened.
13
+ // 2. Entropy layer (MEDIUM) — Shannon entropy over base64/hex-ish tokens
14
+ // ≥ ENTROPY_MIN_TOKEN_LENGTH chars, flagging those above a tunable
15
+ // per-charset threshold (base64 ≥ 4.5 bits/char, hex ≥ 3.0).
16
+ // 3. Keyword/context — a high-entropy token on a line that also mentions a
17
+ // secret keyword is raised to HIGH; standalone entropy stays MEDIUM.
18
+ //
19
+ // False-positive suppression lives INSIDE the detector: a `secret-allow`
20
+ // marker mutes a line, placeholder-shaped values are demoted to LOW (still
21
+ // returned so callers can choose), and the masked preview never reproduces the
22
+ // full secret.
23
+
24
+ /** One detected secret. Never carries the raw value — see `masked`. */
25
+ export interface SecretFinding {
26
+ rule: string;
27
+ kind: string;
28
+ line: number;
29
+ column: number;
30
+ masked: string;
31
+ confidence: 'high' | 'medium' | 'low';
32
+ }
33
+
34
+ /** Options for a scan. `path` lets `.example` / `.sample` files demote hits. */
35
+ export interface ScanOptions {
36
+ path?: string;
37
+ }
38
+
39
+ // ---------------------------------------------------------------------
40
+ // Entropy thresholds (exported so callers / tests can tune + assert them).
41
+ // ---------------------------------------------------------------------
42
+
43
+ /** Minimum token length before the entropy layer even considers a candidate. */
44
+ export const ENTROPY_MIN_TOKEN_LENGTH = 20;
45
+ /** base64/mixed-alphabet tokens above this bits/char are entropy hits. */
46
+ export const BASE64_ENTROPY_THRESHOLD = 4.5;
47
+ /** pure-hex tokens above this bits/char are entropy hits (with keyword ctx). */
48
+ export const HEX_ENTROPY_THRESHOLD = 3.0;
49
+
50
+ // ---------------------------------------------------------------------
51
+ // Regex rule pack (HIGH-confidence).
52
+ // ---------------------------------------------------------------------
53
+
54
+ interface Rule {
55
+ rule: string;
56
+ kind: string;
57
+ regex: RegExp; // MUST carry the global flag
58
+ valueGroup?: number; // capture group holding the sensitive value (default 0)
59
+ }
60
+
61
+ // Reused verbatim from lint_mcp_config_security.ts `_SECRET` (do NOT weaken),
62
+ // split into individually-kinded rules, plus the extension set from the spec.
63
+ const RULES: readonly Rule[] = [
64
+ { rule: 'anthropic-api-key', kind: 'anthropic-api-key', regex: /sk-ant-[A-Za-z0-9_-]{20,}/g },
65
+ { rule: 'anthropic-project-key', kind: 'anthropic-api-key', regex: /sk-proj-[A-Za-z0-9_-]{20,}/g },
66
+ { rule: 'aws-access-key', kind: 'aws-access-key', regex: /AKIA[0-9A-Z]{16}/g },
67
+ { rule: 'google-api-key', kind: 'google-api-key', regex: /AIza[0-9A-Za-z_-]{35}/g },
68
+ { rule: 'github-pat', kind: 'github-pat', regex: /ghp_[0-9A-Za-z]{36}/g },
69
+ {
70
+ rule: 'jwt',
71
+ kind: 'jwt',
72
+ regex: /eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/g,
73
+ },
74
+ // Extensions.
75
+ { rule: 'stripe-secret-key', kind: 'stripe-secret-key', regex: /sk_(?:live|test)_[0-9A-Za-z]{16,}/g },
76
+ { rule: 'slack-token', kind: 'slack-token', regex: /xox[baprs]-[0-9A-Za-z-]{10,}/g },
77
+ { rule: 'pem-private-key', kind: 'pem-private-key', regex: /-----BEGIN [A-Z ]*PRIVATE KEY-----/g },
78
+ {
79
+ rule: 'db-connection-uri',
80
+ kind: 'db-connection-uri',
81
+ regex: /\b[a-z][a-z0-9+.-]*:\/\/[^\s:/@]+:[^\s:/@]+@/g,
82
+ },
83
+ {
84
+ rule: 'generic-assignment',
85
+ kind: 'generic-assignment',
86
+ regex: /(?:password|passwd|pwd|secret|api[_-]?key|access[_-]?token|auth[_-]?token)\s*[:=]\s*['"]?([^'"\s]{8,})['"]?/gi,
87
+ valueGroup: 1,
88
+ },
89
+ ];
90
+
91
+ // ---------------------------------------------------------------------
92
+ // Suppression + placeholder detection.
93
+ // ---------------------------------------------------------------------
94
+
95
+ // A line carrying any comment-style `secret-allow` marker is fully muted.
96
+ const SECRET_ALLOW = /secret-allow/;
97
+
98
+ // A high-entropy token that looks like a placeholder is demoted to LOW.
99
+ const PLACEHOLDER_PATTERNS: readonly RegExp[] = [
100
+ /x{4,}/i,
101
+ /example/i,
102
+ /changeme/i,
103
+ /your[_-].*here/i,
104
+ /<[^>]+>/,
105
+ /\.\.\./,
106
+ /^your[_-]/i,
107
+ /placeholder/i,
108
+ ];
109
+
110
+ // A base64 data URI (embedded image/font) — its payload is not a secret.
111
+ const DATA_URI = /data:[\w.+-]+\/[\w.+-]+;base64,/i;
112
+
113
+ // A UUID is a benign identifier, not a credential.
114
+ const UUID = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
115
+
116
+ // Secret-context keywords that raise an otherwise-MEDIUM entropy hit to HIGH.
117
+ const KEYWORD_CONTEXT = /password|secret|token|api[_-]?key|credential/i;
118
+
119
+ // Candidate high-entropy tokens (base64 / hex alphabets). `/` is deliberately
120
+ // excluded — it appears in URL paths far more than in inline credentials, and
121
+ // including it turns every long path slug into a false positive.
122
+ const ENTROPY_TOKEN = /[A-Za-z0-9+=_-]{20,}/g;
123
+ const IS_HEX = /^[0-9a-fA-F]+$/;
124
+ // Common digest lengths (MD5 / SHA-1 / SHA-256 hex). A token of exactly one of
125
+ // these is a hash — a commit SHA, lockfile digest, condensation hash — not a
126
+ // credential. Never flag it, even with a keyword nearby.
127
+ const HASH_HEX_LENGTHS: ReadonlySet<number> = new Set([32, 40, 64]);
128
+
129
+ function isAllSameChar(value: string): boolean {
130
+ return value.length > 0 && /^(.)\1*$/.test(value);
131
+ }
132
+
133
+ // Minimum length + entropy for a `generic-assignment` value to count as a real
134
+ // credential. A bare `secret: moderate` / `token: modelName` / `key: request()`
135
+ // is a keyword-shaped assignment of a low-entropy WORD — not a secret. Real
136
+ // credentials assigned this way are long and high-entropy. Without this gate the
137
+ // generic rule floods on prose, config, and test data (the FP class the council
138
+ // warned about — precision over allowlist growth).
139
+ const GENERIC_MIN_LENGTH = 16;
140
+ const GENERIC_MIN_ENTROPY = 3.3;
141
+ // Code-expression shapes: an assignment RHS that is a reference / call /
142
+ // interpolation, not a literal credential (`opts.apiKey`, `${API_KEY}`,
143
+ // `load()`, `process.env.X`, `$this->get()`). Never a real inline secret.
144
+ const CODE_EXPR = /[()$`{}<>[\];,]|->|=>|\.\w/;
145
+
146
+ function looksLikeSecretValue(value: string): boolean {
147
+ if (value.length < GENERIC_MIN_LENGTH) {
148
+ return false;
149
+ }
150
+ if (CODE_EXPR.test(value)) {
151
+ return false;
152
+ }
153
+ // A real credential carries entropy AND is not a plain lowercase word —
154
+ // it has a digit or mixed case.
155
+ const hasDigit = /[0-9]/.test(value);
156
+ const mixedCase = /[a-z]/.test(value) && /[A-Z]/.test(value);
157
+ if (!hasDigit && !mixedCase) {
158
+ return false;
159
+ }
160
+ return shannonEntropy(value) >= GENERIC_MIN_ENTROPY;
161
+ }
162
+
163
+ function isPlaceholderValue(value: string, pathHint?: string): boolean {
164
+ if (pathHint !== undefined) {
165
+ if (
166
+ pathHint.endsWith('.example') ||
167
+ pathHint.endsWith('.sample') ||
168
+ pathHint.includes('.env.example')
169
+ ) {
170
+ return true;
171
+ }
172
+ }
173
+ if (isAllSameChar(value)) {
174
+ return true;
175
+ }
176
+ return PLACEHOLDER_PATTERNS.some((p) => p.test(value));
177
+ }
178
+
179
+ /** first ≤4 + `…` + last ≤2; guaranteed never to contain the full value. */
180
+ function maskSecret(value: string): string {
181
+ const front = value.slice(0, 4);
182
+ const back = value.length > 6 ? value.slice(-2) : '';
183
+ if (front.length + back.length >= value.length) {
184
+ // Too short for a 4+2 window without revealing everything — hide more.
185
+ return `${value.slice(0, 1)}…`;
186
+ }
187
+ return `${front}…${back}`;
188
+ }
189
+
190
+ function shannonEntropy(value: string): number {
191
+ const freq = new Map<string, number>();
192
+ for (const ch of value) {
193
+ freq.set(ch, (freq.get(ch) ?? 0) + 1);
194
+ }
195
+ const n = value.length;
196
+ let entropy = 0;
197
+ for (const count of freq.values()) {
198
+ const p = count / n;
199
+ entropy -= p * Math.log2(p);
200
+ }
201
+ return entropy;
202
+ }
203
+
204
+ function overlaps(spans: ReadonlyArray<[number, number]>, start: number, end: number): boolean {
205
+ return spans.some(([s, e]) => start < e && s < end);
206
+ }
207
+
208
+ /**
209
+ * Scan `text` for secrets. Pure — no I/O, no network. Line and column are
210
+ * 1-based; column points at the sensitive value, not the surrounding syntax.
211
+ */
212
+ export function scanText(text: string, opts: ScanOptions = {}): SecretFinding[] {
213
+ const pathHint = opts.path;
214
+ const findings: SecretFinding[] = [];
215
+ const lines = text.split(/\r\n|\r|\n/);
216
+
217
+ for (let i = 0; i < lines.length; i++) {
218
+ const line = lines[i] as string;
219
+ const lineNo = i + 1;
220
+
221
+ // A `secret-allow` marker mutes the whole line — no findings at all.
222
+ if (SECRET_ALLOW.test(line)) {
223
+ continue;
224
+ }
225
+
226
+ const covered: Array<[number, number]> = [];
227
+
228
+ // Layer 1 — regex rule pack (HIGH, demoted to LOW for placeholders).
229
+ for (const rule of RULES) {
230
+ rule.regex.lastIndex = 0;
231
+ let m: RegExpExecArray | null;
232
+ while ((m = rule.regex.exec(line)) !== null) {
233
+ const value = rule.valueGroup !== undefined ? m[rule.valueGroup] : m[0];
234
+ if (value === undefined) {
235
+ if (m.index === rule.regex.lastIndex) {
236
+ rule.regex.lastIndex += 1;
237
+ }
238
+ continue;
239
+ }
240
+ // The keyword-anchored generic rule needs a value-likeness gate:
241
+ // a low-entropy word after `secret:`/`token:` is not a credential.
242
+ if (rule.rule === 'generic-assignment' && !looksLikeSecretValue(value)) {
243
+ if (m.index === rule.regex.lastIndex) {
244
+ rule.regex.lastIndex += 1;
245
+ }
246
+ continue;
247
+ }
248
+ const valueStart = m.index + m[0].indexOf(value);
249
+ const confidence: SecretFinding['confidence'] = isPlaceholderValue(value, pathHint)
250
+ ? 'low'
251
+ : 'high';
252
+ findings.push({
253
+ rule: rule.rule,
254
+ kind: rule.kind,
255
+ line: lineNo,
256
+ column: valueStart + 1,
257
+ masked: maskSecret(value),
258
+ confidence,
259
+ });
260
+ covered.push([valueStart, valueStart + value.length]);
261
+ if (m.index === rule.regex.lastIndex) {
262
+ rule.regex.lastIndex += 1;
263
+ }
264
+ }
265
+ }
266
+
267
+ // Layer 2 + 3 — entropy, raised by keyword context. Skip data URIs.
268
+ if (!DATA_URI.test(line)) {
269
+ const hasKeyword = KEYWORD_CONTEXT.test(line);
270
+ ENTROPY_TOKEN.lastIndex = 0;
271
+ let tm: RegExpExecArray | null;
272
+ while ((tm = ENTROPY_TOKEN.exec(line)) !== null) {
273
+ const token = tm[0];
274
+ const start = tm.index;
275
+ const end = start + token.length;
276
+ if (token.length < ENTROPY_MIN_TOKEN_LENGTH) {
277
+ continue;
278
+ }
279
+ if (overlaps(covered, start, end)) {
280
+ continue; // already reported by the regex pack
281
+ }
282
+ if (UUID.test(token)) {
283
+ continue; // benign identifier
284
+ }
285
+ const entropy = shannonEntropy(token);
286
+ const isHex = IS_HEX.test(token);
287
+ if (isHex && HASH_HEX_LENGTHS.has(token.length)) {
288
+ continue; // MD5 / SHA-1 / SHA-256 digest — a hash, not a secret
289
+ }
290
+ let confidence: SecretFinding['confidence'] | null = null;
291
+ if (isHex) {
292
+ // Pure hex is usually a SHA / UUID / lockfile hash — only a
293
+ // keyword nearby lifts it out of benign territory.
294
+ if (entropy >= HEX_ENTROPY_THRESHOLD && hasKeyword) {
295
+ confidence = 'high';
296
+ }
297
+ } else if (entropy >= BASE64_ENTROPY_THRESHOLD) {
298
+ confidence = hasKeyword ? 'high' : 'medium';
299
+ }
300
+ if (confidence === null) {
301
+ continue;
302
+ }
303
+ if (isPlaceholderValue(token, pathHint)) {
304
+ confidence = 'low';
305
+ }
306
+ findings.push({
307
+ rule: 'entropy',
308
+ kind: 'high-entropy-string',
309
+ line: lineNo,
310
+ column: start + 1,
311
+ masked: maskSecret(token),
312
+ confidence,
313
+ });
314
+ }
315
+ }
316
+ }
317
+
318
+ return findings;
319
+ }
@@ -14,6 +14,11 @@
14
14
  * - git command-injection (CWE-88): GIT_EXTERNAL_DIFF, GIT_SSH,
15
15
  * GIT_SSH_COMMAND, GIT_PROXY_COMMAND, any GIT_*_COMMAND, GIT_PAGER, PAGER —
16
16
  * git runs these as shell commands (`core.pager` / external-diff RCE).
17
+ * - git config-injection (CWE-88): the GIT_CONFIG* family (GIT_CONFIG,
18
+ * GIT_CONFIG_GLOBAL/_SYSTEM, and inline GIT_CONFIG_COUNT / GIT_CONFIG_KEY_<n>
19
+ * / GIT_CONFIG_VALUE_<n>) sets ANY git config — `core.fsmonitor` runs shell
20
+ * on every `git status`; GIT_ALTERNATE_OBJECT_DIRECTORIES / HOSTALIASES
21
+ * redirect object stores / hostname resolution.
17
22
  * - runtime auto-exec hooks: NODE_OPTIONS (`--require attacker.js`),
18
23
  * BASH_ENV / ENV (sourced by non-interactive shells), PYTHONPATH /
19
24
  * PYTHONSTARTUP / PYTHONINSPECT, PERL5OPT / PERL5LIB, RUBYOPT.
@@ -45,6 +50,13 @@ const DENY_EXACT: ReadonlySet<string> = new Set([
45
50
  'GIT_PROXY_COMMAND',
46
51
  'GIT_PAGER',
47
52
  'PAGER',
53
+ // git config-injection: GIT_ALTERNATE_OBJECT_DIRECTORIES can point git at
54
+ // attacker-controlled object stores; the GIT_CONFIG* family is handled by
55
+ // prefix below (it is a general arbitrary-config-injection primitive).
56
+ 'GIT_ALTERNATE_OBJECT_DIRECTORIES',
57
+ // resolver hijack: HOSTALIASES remaps hostnames via an attacker file,
58
+ // redirecting any network fetch the child performs (CWE-427-adjacent).
59
+ 'HOSTALIASES',
48
60
  // runtime auto-exec hooks
49
61
  'NODE_OPTIONS',
50
62
  'BASH_ENV',
@@ -61,14 +73,22 @@ const DENY_EXACT: ReadonlySet<string> = new Set([
61
73
 
62
74
  /**
63
75
  * Prefix families removed by pattern. `LD_` / `DYLD_` cover loader variants we
64
- * did not enumerate; `GIT_*_COMMAND` covers every git config-command hook
65
- * (e.g. GIT_ASKPASS is intentionally NOT here — it is a path, not a shell
66
- * command, and legitimate credential helpers need it; the *_COMMAND family is
67
- * the shell-executed one).
76
+ * did not enumerate; `GIT_*_COMMAND` covers every git config-command hook; the
77
+ * `GIT_CONFIG` family (GIT_CONFIG, GIT_CONFIG_GLOBAL, GIT_CONFIG_SYSTEM, and the
78
+ * inline GIT_CONFIG_COUNT / GIT_CONFIG_KEY_<n> / GIT_CONFIG_VALUE_<n> trio) is a
79
+ * general arbitrary-config-injection primitive — an attacker can set ANY git
80
+ * config key, e.g. `core.fsmonitor` / `core.pager` / `core.sshCommand` /
81
+ * `core.hooksPath` / `alias.*`, all of which git executes as shell on ordinary
82
+ * operations (`git status` alone runs `core.fsmonitor`). The plain `GIT_*_COMMAND`
83
+ * check missed it (no `_COMMAND` suffix), so it is denied by its own prefix.
84
+ * (GIT_ASKPASS is intentionally NOT denied — it is a path, not a shell command,
85
+ * and legitimate credential helpers need it; the *_COMMAND family is the
86
+ * shell-executed one.)
68
87
  */
69
88
  function isDeniedByFamily(name: string): boolean {
70
89
  if (name.startsWith('LD_') || name.startsWith('DYLD_')) return true;
71
90
  if (name.startsWith('GIT_') && name.endsWith('_COMMAND')) return true;
91
+ if (name === 'GIT_CONFIG' || name.startsWith('GIT_CONFIG_')) return true;
72
92
  return false;
73
93
  }
74
94