@event4u/agent-config 14.19.0 → 14.20.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 (101) hide show
  1. package/.augment-plugin/marketplace.json +3 -3
  2. package/.augment-plugin/plugin.json +1 -1
  3. package/.claude-plugin/marketplace.json +1 -1
  4. package/CHANGELOG.md +108 -156
  5. package/dist/agent-src/commands/agent-handoff.md +42 -41
  6. package/dist/agent-src/contexts/execution/host-capability-manifest.md +7 -1
  7. package/dist/agent-src/rules/untrusted-input-defense.md +0 -2
  8. package/dist/agent-src/skills/evaluate-llm-feature/SKILL.md +1 -1
  9. package/dist/agent-src/skills/markitdown/SKILL.md +1 -1
  10. package/dist/agent-src/skills/skill-improvement-pipeline/SKILL.md +28 -6
  11. package/dist/agent-src/skills/test-driven-development/SKILL.md +1 -3
  12. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  13. package/dist/agent-src/templates/roadmaps.md +14 -1
  14. package/dist/agent-src/templates/scripts/telemetry/mcp_lite.ts +213 -0
  15. package/dist/agent-src/templates/scripts/telemetry/report_renderer.ts +16 -0
  16. package/dist/agent-src/templates/scripts/telemetry_report.ts +22 -2
  17. package/dist/cli/mcp/dispatch.js +10 -0
  18. package/dist/cli/mcp/stdio.js +33 -2
  19. package/dist/cli/mcp/telemetry.js +227 -0
  20. package/dist/cli-delegate/{chunk-GZTMRYCN.js → chunk-C6IPZRDA.js} +79 -12
  21. package/dist/cli-delegate/{chunk-CYH5SP6M.js → chunk-I6RHFQEG.js} +151 -16
  22. package/dist/cli-delegate/{chunk-IDHUQIXP.js → chunk-NUMQHLS5.js} +21 -4
  23. package/dist/cli-delegate/{chunk-PMEKGDED.js → chunk-PI2GU3DP.js} +163 -1
  24. package/dist/cli-delegate/{chunk-7PRE6ZV2.js → chunk-YS6XTLEK.js} +199 -4
  25. package/dist/cli-delegate/chunk-ZVBOHSVF.js +1109 -0
  26. package/dist/cli-delegate/cmd_conformance.js +73 -109
  27. package/dist/cli-delegate/cmd_doctor.js +2 -2
  28. package/dist/cli-delegate/cmd_export.js +2 -2
  29. package/dist/cli-delegate/cmd_handoff.js +41 -11
  30. package/dist/cli-delegate/cmd_memory_get.js +1 -1
  31. package/dist/cli-delegate/cmd_prune.js +2 -2
  32. package/dist/cli-delegate/cmd_route_audit.js +1 -1
  33. package/dist/cli-delegate/cmd_session_recycle.js +59 -902
  34. package/dist/cli-delegate/cmd_settings_migrate.js +2 -2
  35. package/dist/cli-delegate/cmd_sync.js +2 -2
  36. package/dist/cli-delegate/cmd_uninstall.js +2 -2
  37. package/dist/cli-delegate/cmd_validate.js +2 -2
  38. package/dist/discovery/deprecation-report.md +1 -1
  39. package/dist/discovery/discovery-manifest.json +9 -9
  40. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  41. package/dist/discovery/discovery-manifest.summary.md +1 -1
  42. package/dist/discovery/dormancy-report.md +1 -1
  43. package/dist/discovery/orphan-report.md +1 -1
  44. package/dist/discovery/packs.json +2 -2
  45. package/dist/discovery/trust-report.md +1 -1
  46. package/dist/discovery/workspaces.json +2 -2
  47. package/dist/hooks/dispatch.js +7257 -6497
  48. package/dist/install/install.mjs +393 -168
  49. package/dist/mcp/registry-manifest.json +2 -2
  50. package/dist/mcp/server.json +2 -2
  51. package/dist/mcp/server.mjs +497 -118
  52. package/dist/scripts/mcp_server/telemetry.js +148 -0
  53. package/docs/guidelines/agent-infra/naming.md +1 -1
  54. package/docs/guidelines/agent-infra/security-lint-containment.md +46 -6
  55. package/docs/guidelines/agent-infra/self-improvement-pipeline.md +4 -4
  56. package/docs/guidelines/agent-infra/untrusted-input-spotlighting.md +1 -1
  57. package/docs/guidelines/redundancy-taxonomy.md +28 -0
  58. package/package.json +1 -1
  59. package/src/agent-src/templates/scripts/telemetry/mcp_lite.ts +213 -0
  60. package/src/agent-src/templates/scripts/telemetry/report_renderer.ts +16 -0
  61. package/src/agent-src/templates/scripts/telemetry_report.ts +22 -2
  62. package/src/config/assurance-capability-registry.json +9 -0
  63. package/src/config/gate-coverage.yml +55 -0
  64. package/src/config/gate-violation-baselines.json +15 -3
  65. package/src/scripts/_cli/cmd_session_recycle.ts +51 -3
  66. package/src/scripts/_lib/audit_field_provenance.ts +44 -3
  67. package/src/scripts/_lib/host_capability.ts +38 -0
  68. package/src/scripts/_lib/install_reach_checks.ts +33 -1
  69. package/src/scripts/_lib/mcp_bridge.ts +222 -10
  70. package/src/scripts/_lib/mcp_consent_residual.ts +173 -0
  71. package/src/scripts/_lib/orchestration_record.ts +23 -1
  72. package/src/scripts/_lib/recycle_envelope_paths.ts +190 -0
  73. package/src/scripts/_lib/release_position.ts +96 -0
  74. package/src/scripts/_lib/review_skipped_record.ts +17 -1
  75. package/src/scripts/_lib/security_lint.ts +179 -7
  76. package/src/scripts/_lib/self_repair.ts +141 -6
  77. package/src/scripts/_lib/self_repair_store.ts +273 -7
  78. package/src/scripts/_lib/subagent_capsule.ts +155 -3
  79. package/src/scripts/build_rule_trigger_matrix.ts +5 -0
  80. package/src/scripts/check_mcp_doc_drift.ts +217 -0
  81. package/src/scripts/check_pack_size.ts +321 -3
  82. package/src/scripts/handoff_context_hook.ts +54 -7
  83. package/src/scripts/hooks/session_eol_hook.ts +18 -7
  84. package/src/scripts/install.ts +4 -4
  85. package/src/scripts/lint_agent_security.ts +316 -25
  86. package/src/scripts/lint_confusables.ts +42 -6
  87. package/src/scripts/lint_handoffs.ts +0 -132
  88. package/src/scripts/lint_hidden_unicode.ts +49 -7
  89. package/src/scripts/lint_instruction_smuggling.ts +42 -6
  90. package/src/scripts/lint_mcp_config_security.ts +42 -6
  91. package/src/scripts/lint_roadmap_later_disposition.ts +217 -27
  92. package/src/scripts/lint_skill_frontmatter_safety.ts +43 -7
  93. package/src/scripts/mcp_server/resources.ts +41 -0
  94. package/src/scripts/mcp_server/telemetry.ts +19 -4
  95. package/src/scripts/orchestration_record.ts +12 -1
  96. package/src/scripts/release.ts +24 -24
  97. package/src/scripts/release_publication.ts +6 -2
  98. package/src/scripts/self_repair_cli.ts +31 -1
  99. package/src/scripts/skill_scout.ts +69 -0
  100. package/dist/cli-delegate/chunk-2GNWGMAL.js +0 -172
  101. package/dist/cli-delegate/chunk-LPPWO5CE.js +0 -161
@@ -37,10 +37,12 @@ import { fileURLToPath } from 'node:url';
37
37
  import { collectRepoAnchor, describeDrift } from './_lib/envelope_grounding.js';
38
38
  import { readHookStdin } from './hooks/hook_stdin.js';
39
39
  import {
40
- RECYCLE_CONSUMED_REL,
41
- RECYCLE_ENVELOPE_REL,
42
40
  RECYCLE_MAX_AGE_HOURS,
41
+ predecessorTracePresent,
42
+ recycle_consumed_rel,
43
+ resolveContinuityRecord,
43
44
  } from './_lib/recycle_envelope_paths.js';
45
+ import { env_session_id } from './sessions_cli.js';
44
46
  import {
45
47
  hasBoundaryMarker,
46
48
  scanEnvelopeDirectives,
@@ -153,8 +155,33 @@ export function consume_handoff_context(root: string, now: Date = new Date()): C
153
155
  * - focus (Phase 3.4): `AGENT_RESUME_FOCUS` narrows what the successor
154
156
  * attacks first — the consumer-side mirror of `next_task`.
155
157
  */
156
- export function consume_recycle_envelope(root: string, now: Date = new Date()): ConsumeDecision {
157
- const target = process.env.AGENT_RECYCLE_ENVELOPE_FILE || path.join(root, RECYCLE_ENVELOPE_REL);
158
+ export function consume_recycle_envelope(
159
+ root: string,
160
+ now: Date = new Date(),
161
+ session_id: string | null | undefined = undefined,
162
+ ): ConsumeDecision {
163
+ // Phase 2.1: which record belongs to THIS session is a resolution, not a
164
+ // path constant. `resolveContinuityRecord` returns `null` with a reason
165
+ // when it cannot tell — the reader then starts clean and says the reason
166
+ // out loud rather than resuming from whichever file is newest.
167
+ //
168
+ // The id is a PARAMETER with an env fallback, not an env read. The
169
+ // dispatcher already carries `session_id` in the envelope, and reading the
170
+ // ambient variable instead means any process that inherits a host's
171
+ // `CLAUDE_CODE_SESSION_ID` — a test runner in an agent session, most
172
+ // obviously — resolves a foreign identity and reports the record absent.
173
+ const sessionId = session_id === undefined ? env_session_id() : session_id;
174
+ const override = process.env.AGENT_RECYCLE_ENVELOPE_FILE;
175
+ let target: string;
176
+ if (override !== undefined && override !== '') {
177
+ target = override;
178
+ } else {
179
+ const resolved = resolveContinuityRecord(root, sessionId);
180
+ if (resolved.file === null) {
181
+ return { action: 'absent', reason: resolved.reason };
182
+ }
183
+ target = resolved.file;
184
+ }
158
185
  let text: string;
159
186
  try {
160
187
  text = fs.readFileSync(target, 'utf-8');
@@ -164,7 +191,10 @@ export function consume_recycle_envelope(root: string, now: Date = new Date()):
164
191
 
165
192
  const consume = (): void => {
166
193
  try {
167
- fs.renameSync(target, path.join(path.dirname(target), path.basename(RECYCLE_CONSUMED_REL)));
194
+ fs.renameSync(
195
+ target,
196
+ path.join(path.dirname(target), path.basename(recycle_consumed_rel(sessionId))),
197
+ );
168
198
  } catch {
169
199
  try {
170
200
  fs.unlinkSync(target); // fallback — never let it survive
@@ -212,6 +242,19 @@ export function consume_recycle_envelope(root: string, now: Date = new Date()):
212
242
  return { action: 'discard', reason: 'recycle envelope workspace does not resolve' };
213
243
  }
214
244
 
245
+ // Lineage (Phase 2.3): a record that NAMES a predecessor is claiming a
246
+ // chain. If nothing in this workspace corroborates that session, the claim
247
+ // is unverifiable and the record is refused — never resolved to whatever
248
+ // else is lying about.
249
+ const predecessor = String(envelope['predecessor'] ?? '').trim();
250
+ if (predecessor !== '' && !predecessorTracePresent(root, predecessor)) {
251
+ consume();
252
+ return {
253
+ action: 'discard',
254
+ reason: `recycle envelope names predecessor "${predecessor}", which has no trace in this workspace`,
255
+ };
256
+ }
257
+
215
258
  // Drift first: a stale resume against the wrong tree is the failure the
216
259
  // reader must see before anything else in the block.
217
260
  const drift = describeDrift(envelope, collectRepoAnchor(root));
@@ -221,7 +264,7 @@ export function consume_recycle_envelope(root: string, now: Date = new Date()):
221
264
  const focus = (process.env.AGENT_RESUME_FOCUS ?? '').trim();
222
265
  const block = wrapAsPriorSessionData(JSON.stringify(envelope, null, 2), {
223
266
  kind: 'recycle-envelope',
224
- source: RECYCLE_ENVELOPE_REL,
267
+ source: path.relative(root, target) || path.basename(target),
225
268
  warnings: [
226
269
  ...drift,
227
270
  ...(focus ? [`FOCUS: attack "${focus}" first — the rest of this envelope is context.`] : []),
@@ -265,7 +308,11 @@ export function main(): number {
265
308
  }
266
309
  if (event === 'session_start') {
267
310
  const handoff = consume_handoff_context(root);
268
- const recycle = consume_recycle_envelope(root);
311
+ const recycle = consume_recycle_envelope(
312
+ root,
313
+ new Date(),
314
+ String(envelope['session_id'] ?? '').trim() || env_session_id(),
315
+ );
269
316
  const blocks: string[] = [];
270
317
  const reasons: string[] = [];
271
318
  // Recycle envelope first — it is the task-state restore the
@@ -45,7 +45,7 @@ import { fileURLToPath, pathToFileURL } from 'node:url';
45
45
 
46
46
  import recycleThresholdConfig from '../../config/recycle-threshold-budget.json';
47
47
 
48
- import { RECYCLE_ENVELOPE_REL } from '../_lib/recycle_envelope_paths.js';
48
+ import { recycle_envelope_rel } from '../_lib/recycle_envelope_paths.js';
49
49
  import {
50
50
  emptyCounters,
51
51
  eolSessionKey,
@@ -180,8 +180,16 @@ export function readState(file: string): SessionEolState {
180
180
  * session's state — a wrong resume instead of an empty one. An envelope whose
181
181
  * `written_at` predates the advisory is not this session's.
182
182
  */
183
- export function envelopeExists(workspaceRoot: string, since: string | null = null): boolean {
184
- const target = path.join(workspaceRoot, RECYCLE_ENVELOPE_REL);
183
+ export function envelopeExists(
184
+ workspaceRoot: string,
185
+ since: string | null = null,
186
+ sessionId: string | null = null,
187
+ ): boolean {
188
+ // Phase 2.1: the record is keyed by session, so the counter-check must look
189
+ // at THIS session's path. Checking the shared name would read a peer's
190
+ // record as proof that this session wrote one — the same cross-session
191
+ // confusion the key exists to remove, re-created in the checker.
192
+ const target = path.join(workspaceRoot, recycle_envelope_rel(sessionId));
185
193
  let raw: string;
186
194
  try {
187
195
  raw = fs.readFileSync(target, 'utf-8');
@@ -212,10 +220,10 @@ export function envelopeExists(workspaceRoot: string, since: string | null = nul
212
220
  * the next action the advisory recommended is the one that destroys the
213
221
  * session.
214
222
  */
215
- export function buildMissingEnvelopeLine(workspaceRoot: string): string {
223
+ export function buildMissingEnvelopeLine(workspaceRoot: string, sessionId: string | null = null): string {
216
224
  return (
217
225
  `recycle advised earlier, but no envelope exists at ` +
218
- `${path.join(workspaceRoot, RECYCLE_ENVELOPE_REL)} — /clear now starts the successor ` +
226
+ `${path.join(workspaceRoot, recycle_envelope_rel(sessionId))} — /clear now starts the successor ` +
219
227
  `from nothing. Re-run \`agent-config session:recycle\` and check that it prints the ` +
220
228
  `absolute path it wrote before clearing.`
221
229
  );
@@ -334,11 +342,14 @@ export function main(): number {
334
342
  // `AGENT_RECYCLE_THRESHOLD_TOKENS=0` is documented as the emergency switch
335
343
  // for the whole advisory lane, and a lane that keeps one more warn after
336
344
  // being switched off is not off.
345
+ // Raw id, not `sessionKey`: the record path is built from the id the host
346
+ // exported (`safe_stem`), never from the hashed state-file key.
347
+ const rawSessionId = payloadSessionId(payload, envelope) || null;
337
348
  const shouldWarnMissing =
338
349
  threshold !== null &&
339
350
  state.advisory_fired_at !== null &&
340
351
  (state.missing_envelope_warned_at ?? null) === null &&
341
- !envelopeExists(workspaceRoot, state.advisory_fired_at);
352
+ !envelopeExists(workspaceRoot, state.advisory_fired_at, rawSessionId);
342
353
 
343
354
  const nextState: SessionEolState = {
344
355
  schema_version: 1,
@@ -415,7 +426,7 @@ export function main(): number {
415
426
  `${JSON.stringify({
416
427
  decision: 'warn',
417
428
  reason: 'session-eol: recycle advised, no envelope written',
418
- additional_context: buildMissingEnvelopeLine(workspaceRoot),
429
+ additional_context: buildMissingEnvelopeLine(workspaceRoot, rawSessionId),
419
430
  })}\n`,
420
431
  );
421
432
  return EXIT_WARN;
@@ -75,6 +75,7 @@ import { build_merge_entries } from './_lib/json_pointers.js';
75
75
  import { is_claude_builtin_name } from './_lib/claude_builtin_names.js';
76
76
  import * as installed_lock from './_lib/installed_lock.js';
77
77
  import * as mcp_bridge from './_lib/mcp_bridge.js';
78
+ import * as mcp_consent from './_lib/mcp_consent_residual.js';
78
79
  import * as scoped_projection from './_lib/scoped_projection.js';
79
80
  import * as surface_tiers from './_lib/surface_tiers.js';
80
81
  import * as global_deploy_inventory from './_lib/global_deploy_inventory.js';
@@ -5121,11 +5122,9 @@ function _main_project_install(
5121
5122
  ...ensure_augment_bridge(project_root, opts.force),
5122
5123
  ];
5123
5124
  if (_is_tool_enabled(tools, 'claude-code')) {
5124
- merged_keys_by_tool['claude-code'] = [
5125
- ...ensure_claude_bridge(project_root, opts.force),
5126
- ...ensure_mcp_bridge(project_root, opts.force),
5127
- ];
5125
+ merged_keys_by_tool['claude-code'] = ensure_claude_bridge(project_root, opts.force);
5128
5126
  }
5127
+ mcp_bridge.registerMcpHosts(merge_json_file, project_root, opts.force, package_root, tools, merged_keys_by_tool);
5129
5128
  if (_is_tool_enabled(tools, 'cursor')) {
5130
5129
  merged_keys_by_tool['cursor'] = ensure_cursor_bridge(project_root, opts.force);
5131
5130
  }
@@ -5226,6 +5225,7 @@ function _main_project_install(
5226
5225
  );
5227
5226
  process.stdout.write('\n');
5228
5227
  }
5228
+ mcp_consent.printResiduals(tools, state.QUIET);
5229
5229
  if (_is_tool_enabled(tools, 'claude-code')) {
5230
5230
  const team_hint = _team_setup_hint_line(project_root);
5231
5231
  if (team_hint !== null) {
@@ -26,8 +26,10 @@
26
26
  import fs from "node:fs";
27
27
  import path from "node:path";
28
28
  import { fileURLToPath, pathToFileURL } from "node:url";
29
- import { runCountedProbe } from "./_lib/counted_probe.js";
30
- import { assertWatchlistResolves, DeadScopeError } from "./_lib/scan_scope.js";
29
+ import { runCountedProbe, type ProbeResult } from "./_lib/counted_probe.js";
30
+ import { assertWatchlistResolves, DeadScopeError, reportScanned } from "./_lib/scan_scope.js";
31
+ import { runGateCli, runSelfTest } from "./_lib/gate_self_test.js";
32
+ import os from "node:os";
31
33
 
32
34
  const _HERE = path.dirname(fileURLToPath(import.meta.url));
33
35
 
@@ -50,26 +52,162 @@ const LINTERS: ReadonlyArray<[string, string]> = [
50
52
  ["dangerous-frontmatter", "lint_skill_frontmatter_safety.ts"],
51
53
  ];
52
54
 
53
- function _run(script: string): [number, Finding[]] {
54
- // Spawn the child linter's `.ts` twin via the repo-local tsx binary
55
- // (capture_output=True, text=True equivalent). Mirrors the retired Python implementation's
56
- // `subprocess.run([sys.executable, HERE/<child>.py, "--json"])`, now that the
57
- // children are TypeScript and the `.py` originals are deleted.
58
- // Bounded read: a truncated `--json` payload would fail to parse and be
59
- // swallowed as zero findings — a security aggregate reporting clean because
60
- // it lost the answer. `runCountedProbe` throws on overflow instead.
61
- const proc = runCountedProbe(tsxBin, [path.join(_HERE, script), "--json"]);
62
- let findings: Finding[];
55
+ /**
56
+ * How one child linter is executed.
57
+ *
58
+ * A seam, not a policy: the production runner is `_spawnChild` below and is the
59
+ * default everywhere. It exists so the failure corpus can drive each way a
60
+ * child can fail to ANSWER — non-zero with empty stdout, zero with unparsable
61
+ * stdout, and a spawn that never happened — without planting a broken linter in
62
+ * the tree. The four ways are not reproducible any other way: a real child that
63
+ * crashes on demand is a child that has to be able to crash in production.
64
+ */
65
+ export type ChildRunResult = ProbeResult | { readonly skipped: SkipReason };
66
+ export type ChildRunner = (script: string) => ChildRunResult;
67
+
68
+ // Spawn the child linter's `.ts` twin via the repo-local tsx binary
69
+ // (capture_output=True, text=True equivalent). Mirrors the retired Python implementation's
70
+ // `subprocess.run([sys.executable, HERE/<child>.py, "--json"])`, now that the
71
+ // children are TypeScript and the `.py` originals are deleted.
72
+ // Bounded read: a truncated `--json` payload would fail to parse and be
73
+ // swallowed as zero findings — a security aggregate reporting clean because
74
+ // it lost the answer. `runCountedProbe` throws on overflow instead.
75
+ function _spawnChild(root: string | null): ChildRunner {
76
+ return (script) =>
77
+ runCountedProbe(tsxBin, [
78
+ path.join(_HERE, script),
79
+ "--json",
80
+ ...(root === null ? [] : ["--root", root]),
81
+ ]);
82
+ }
83
+
84
+ /**
85
+ * The closed set of terminal outcomes a child run can have.
86
+ *
87
+ * Closed is the whole point. Before this, a child had one outcome — "here are
88
+ * its findings" — and every way of not answering collapsed into an empty
89
+ * findings array indistinguishable from a clean run. Three states make the
90
+ * distinction expressible, and `failed` is the one that blocks.
91
+ */
92
+ export type ChildOutcome =
93
+ | {
94
+ readonly kind: "completed";
95
+ readonly findings: Finding[];
96
+ readonly exitCode: number;
97
+ /** Artifacts this child reported inspecting. See `CHILD_SCANNED` below. */
98
+ readonly scanned: number;
99
+ }
100
+ | { readonly kind: "failed"; readonly detail: string; readonly exitCode: number | null }
101
+ | { readonly kind: "skipped"; readonly reason: SkipReason };
102
+
103
+ /**
104
+ * Skip reasons, as a RUNNER CONSTANT rather than configuration.
105
+ *
106
+ * A configurable skip list is a fail-open switch with a settings file in front
107
+ * of it: whoever can add a reason can silence a child. Keeping the set here
108
+ * means adding one is a reviewed code change in the gate itself.
109
+ *
110
+ * NO CHILD PRODUCES A SKIP TODAY, and that is stated rather than implied — all
111
+ * five apply on every platform this suite runs on. The state exists because the
112
+ * alternative to having it is the pressure this repair creates: once a
113
+ * non-completing child blocks, a genuinely non-applicable child would have to
114
+ * be expressed as `failed`, and the fix for that would be to weaken `failed`.
115
+ * A legal non-failing terminal state is what stops that. It is exercised
116
+ * through the runner seam in the failure corpus, so the branch is not dead.
117
+ */
118
+ export const SKIP_REASONS = {
119
+ PLATFORM_NOT_APPLICABLE: "not applicable on this platform",
120
+ } as const;
121
+ export type SkipReason = (typeof SKIP_REASONS)[keyof typeof SKIP_REASONS];
122
+
123
+ /**
124
+ * The child `--json` contract, stated so a violation of it is nameable:
125
+ *
126
+ * exit 0 — ran, here is the findings array (possibly empty)
127
+ * exit 1 — ran, here is the findings array (non-empty)
128
+ * anything else, or a non-array payload, or unparsable stdout, or no run at
129
+ * all — the child did not answer.
130
+ *
131
+ * Exit 1 with an EMPTY array is a violation and not a nit: every child returns
132
+ * its dead-scope code (1 or 2) with nothing on stdout, which is precisely the
133
+ * "the corpus moved and I read nothing" case that used to score as clean.
134
+ */
135
+ function _classify(res: ChildRunResult): ChildOutcome {
136
+ // The execution layer decides WHETHER a child applies here; the reason it may
137
+ // give comes from `SKIP_REASONS` above, so a new reason is a reviewed code
138
+ // change in this file rather than a value someone can supply.
139
+ if ("skipped" in res) {
140
+ return { kind: "skipped", reason: res.skipped };
141
+ }
142
+ const proc = res;
143
+ // spawnSync reports null when the process was signalled or never spawned.
144
+ if (proc.status === null) {
145
+ return {
146
+ kind: "failed",
147
+ exitCode: null,
148
+ detail: `the child never ran (${proc.failure ?? "no exit status reported"})`,
149
+ };
150
+ }
151
+ let parsed: unknown;
63
152
  try {
64
- const parsed: unknown = JSON.parse((proc.stdout ?? "") || "[]");
65
- findings = Array.isArray(parsed) ? (parsed as Finding[]) : [];
153
+ parsed = JSON.parse((proc.stdout ?? "") || "[]");
66
154
  } catch {
67
- findings = [];
155
+ return {
156
+ kind: "failed",
157
+ exitCode: proc.status,
158
+ detail:
159
+ `exited ${String(proc.status)} and its stdout was not JSON — a crashed child that ` +
160
+ "printed a diagnostic used to be read as zero findings",
161
+ };
162
+ }
163
+ if (!Array.isArray(parsed)) {
164
+ return {
165
+ kind: "failed",
166
+ exitCode: proc.status,
167
+ detail:
168
+ `exited ${String(proc.status)} and its stdout parsed but was not a findings array — ` +
169
+ "a JSON error object used to be read as zero findings",
170
+ };
171
+ }
172
+ const findings = parsed as Finding[];
173
+ if (proc.status !== 0 && !(proc.status === 1 && findings.length > 0)) {
174
+ return {
175
+ kind: "failed",
176
+ exitCode: proc.status,
177
+ detail:
178
+ `exited ${String(proc.status)} with ${String(findings.length)} finding(s), which is outside ` +
179
+ "the --json contract (0 = clean, 1 = findings; every other code means it could not run)",
180
+ };
181
+ }
182
+ // A child that ran but did not say what it read leaves the aggregate unable to
183
+ // publish a real corpus size — the same class of unknown as a crashed child,
184
+ // so it gets the same verdict rather than a silent zero.
185
+ const scanned = _childScanned(proc.stderr ?? "");
186
+ if (scanned === null) {
187
+ return {
188
+ kind: "failed",
189
+ exitCode: proc.status,
190
+ detail:
191
+ `exited ${String(proc.status)} without reporting what it inspected — an aggregate ` +
192
+ "corpus size cannot be published from a child that did not state one",
193
+ };
194
+ }
195
+ return { kind: "completed", findings, exitCode: proc.status, scanned };
196
+ }
197
+
198
+ /** Read a child's `scanned: <N>` line off its stderr. `null` = it printed none. */
199
+ function _childScanned(stderr: string): number | null {
200
+ for (const line of stderr.split("\n")) {
201
+ const m = /^scanned: (\d+)$/.exec(line.trim());
202
+ if (m) {
203
+ return Number.parseInt(m[1] as string, 10);
204
+ }
68
205
  }
69
- // Python returncode of a normally-exited process is its exit code; spawnSync
70
- // reports null when the process was signalled / failed to spawn.
71
- const returncode = proc.status ?? 1;
72
- return [returncode, findings];
206
+ return null;
207
+ }
208
+
209
+ function _run(script: string, runChild: ChildRunner): ChildOutcome {
210
+ return _classify(runChild(script));
73
211
  }
74
212
 
75
213
  function _is_fail(f: Finding): boolean {
@@ -84,7 +222,7 @@ interface SarifReport {
84
222
  runs: unknown[];
85
223
  }
86
224
 
87
- function _sarif(all_findings: Finding[]): SarifReport {
225
+ function _sarif(all_findings: Finding[], outcomes: ReadonlyArray<[string, string, ChildOutcome]>): SarifReport {
88
226
  const results: unknown[] = [];
89
227
  for (const f of all_findings) {
90
228
  const lineRaw = f["line"];
@@ -109,6 +247,21 @@ function _sarif(all_findings: Finding[]): SarifReport {
109
247
  ],
110
248
  });
111
249
  }
250
+ // Execution state rides in SARIF's OWN `invocations` array — the schema
251
+ // already models "did this tool run and with what exit code", so a parallel
252
+ // metadata block beside it would be a second, unread copy of the same fact.
253
+ // One invocation per child, in the order the children ran.
254
+ //
255
+ // `executionSuccessful` is the field a SARIF consumer reads to decide whether
256
+ // an empty `results` array means "clean" or "never looked". That is exactly
257
+ // the question this whole repair is about, so it is the field that carries it.
258
+ const invocations = outcomes.map(([, script, outcome]) => ({
259
+ commandLine: `${script} --json`,
260
+ executionSuccessful: outcome.kind === "completed",
261
+ ...(outcome.kind === "skipped" || outcome.exitCode === null
262
+ ? {}
263
+ : { exitCode: outcome.exitCode }),
264
+ }));
112
265
  return {
113
266
  $schema: "https://json.schemastore.org/sarif-2.1.0.json",
114
267
  version: "2.1.0",
@@ -121,6 +274,7 @@ function _sarif(all_findings: Finding[]): SarifReport {
121
274
  rules: LINTERS.map(([cid]) => ({ id: cid })),
122
275
  },
123
276
  },
277
+ invocations,
124
278
  results,
125
279
  },
126
280
  ],
@@ -149,10 +303,13 @@ function _pyJsonDumpsIndent2(obj: unknown): string {
149
303
  interface ParsedArgs {
150
304
  sarif: string | null;
151
305
  quiet: boolean;
306
+ /** Bounded scan base, forwarded verbatim to every child. Null = the package root. */
307
+ root: string | null;
308
+ selfTest: boolean;
152
309
  }
153
310
 
154
311
  function parse_args(argv: string[]): ParsedArgs {
155
- const out: ParsedArgs = { sarif: null, quiet: false };
312
+ const out: ParsedArgs = { sarif: null, quiet: false, root: null, selfTest: false };
156
313
  for (let i = 0; i < argv.length; i += 1) {
157
314
  const a = argv[i] as string;
158
315
  if (a === "--sarif" || a.startsWith("--sarif=")) {
@@ -170,11 +327,28 @@ function parse_args(argv: string[]): ParsedArgs {
170
327
  i += 1;
171
328
  out.sarif = next;
172
329
  }
330
+ } else if (a === "--root" || a.startsWith("--root=")) {
331
+ const eq = a.indexOf("=");
332
+ if (eq !== -1) {
333
+ out.root = a.slice(eq + 1);
334
+ } else {
335
+ const next = argv[i + 1];
336
+ if (next === undefined) {
337
+ process.stderr.write(
338
+ "lint_agent_security: error: argument --root: expected one argument\n",
339
+ );
340
+ process.exit(2);
341
+ }
342
+ i += 1;
343
+ out.root = next;
344
+ }
345
+ } else if (a === "--self-test") {
346
+ out.selfTest = true;
173
347
  } else if (a === "--quiet") {
174
348
  out.quiet = true;
175
349
  } else if (a === "-h" || a === "--help") {
176
350
  process.stdout.write(
177
- "usage: lint_agent_security [-h] [--sarif PATH] [--quiet]\n",
351
+ "usage: lint_agent_security [-h] [--sarif PATH] [--quiet] [--root DIR] [--self-test]\n",
178
352
  );
179
353
  process.exit(0);
180
354
  }
@@ -182,8 +356,17 @@ function parse_args(argv: string[]): ParsedArgs {
182
356
  return out;
183
357
  }
184
358
 
185
- export function main(argv: string[] | null = null): number {
359
+ export interface MainOptions {
360
+ /** Override how child linters are executed. Test seam; production uses `_spawnChild`. */
361
+ readonly runChild?: ChildRunner;
362
+ }
363
+
364
+ export function main(argv: string[] | null = null, opts: MainOptions = {}): number {
186
365
  const args = parse_args(argv ?? process.argv.slice(2));
366
+ if (args.selfTest) {
367
+ return _selfTest();
368
+ }
369
+ const runChild = opts.runChild ?? _spawnChild(args.root);
187
370
 
188
371
  // This runner owns no corpus of its own — it guards five named child linters,
189
372
  // so its scope is that watch list. `_run` swallows an unparseable child
@@ -204,9 +387,26 @@ export function main(argv: string[] | null = null): number {
204
387
  }
205
388
 
206
389
  const all_findings: Finding[] = [];
390
+ const outcomes: Array<[string, string, ChildOutcome]> = [];
391
+ const notCompleted: string[] = [];
207
392
  let blocking = 0;
393
+ let totalScanned = 0;
208
394
  for (const [check, script] of LINTERS) {
209
- const [, findings] = _run(script);
395
+ const outcome = _run(script, runChild);
396
+ outcomes.push([check, script, outcome]);
397
+ if (outcome.kind === "failed") {
398
+ // Named on the line, because "one child failed" sends a reader to five
399
+ // linters. The exit code stays on the line for the same reason.
400
+ notCompleted.push(check);
401
+ process.stdout.write(` ❌ ${check}: did not complete — ${outcome.detail}\n`);
402
+ continue;
403
+ }
404
+ if (outcome.kind === "skipped") {
405
+ process.stdout.write(` ⏭️ ${check}: skipped — ${outcome.reason}\n`);
406
+ continue;
407
+ }
408
+ const findings = outcome.findings;
409
+ totalScanned += outcome.scanned;
210
410
  all_findings.push(...findings);
211
411
  const fails = findings.filter((f) => _is_fail(f)).length;
212
412
  const warns = findings.length - fails;
@@ -218,11 +418,50 @@ export function main(argv: string[] | null = null): number {
218
418
  if (args.sarif) {
219
419
  const out = args.sarif;
220
420
  fs.mkdirSync(path.dirname(out), { recursive: true });
221
- fs.writeFileSync(out, _pyJsonDumpsIndent2(_sarif(all_findings)), "utf-8");
421
+ fs.writeFileSync(out, _pyJsonDumpsIndent2(_sarif(all_findings, outcomes)), "utf-8");
222
422
  process.stdout.write(` SARIF → ${args.sarif}\n`);
223
423
  }
224
424
 
425
+ // Publish what the scan actually read, on EVERY path including the red one.
426
+ // The umbrella owns no corpus of its own — this number is the SUM of the five
427
+ // children's own reported counts, so it is artifact INSPECTIONS rather than
428
+ // distinct artifacts (a file two children read counts twice). That is the
429
+ // stronger collapse detector: a distinct-union figure would stay high while
430
+ // one child's corpus went missing, which is the failure this line exists to
431
+ // make visible. `src/config/gate-coverage.yml` carries the floor.
432
+ reportScanned({
433
+ gate: "lint_agent_security",
434
+ scanned: totalScanned,
435
+ units: "artifact inspection(s) across the five child linters",
436
+ roots: LINTERS.map(([cid]) => cid),
437
+ ...(notCompleted.length > 0
438
+ ? {
439
+ allowEmpty:
440
+ "WATCHLIST_DRIVEN: a child that did not complete reports no count. Zero here means " +
441
+ "every child failed, which this run is already exiting 1 for and naming — the scope " +
442
+ "is not dead, the children are.",
443
+ }
444
+ : {}),
445
+ });
446
+
225
447
  process.stdout.write("\n");
448
+ // A child that did not answer is reported BEFORE findings and fails on its
449
+ // own. The two are independent verdicts: findings say the corpus is dirty,
450
+ // a non-completing child says the verdict is unknown, and an unknown verdict
451
+ // is not a pass. Both can hold at once, and both print.
452
+ if (notCompleted.length > 0) {
453
+ process.stdout.write(
454
+ `❌ agent-security: ${notCompleted.length} of ${LINTERS.length} child linter(s) did not complete ` +
455
+ `(${notCompleted.join(", ")}). A child that did not answer is not a clean scan — ` +
456
+ "run it directly for its own diagnostic.\n",
457
+ );
458
+ if (blocking) {
459
+ process.stdout.write(
460
+ `❌ agent-security: ${blocking} blocking finding(s) from the children that did complete.\n`,
461
+ );
462
+ }
463
+ return 1;
464
+ }
226
465
  if (blocking) {
227
466
  process.stdout.write(
228
467
  `❌ agent-security: ${blocking} blocking finding(s). ` +
@@ -237,6 +476,58 @@ export function main(argv: string[] | null = null): number {
237
476
  return 0;
238
477
  }
239
478
 
479
+ /**
480
+ * `--self-test` — prove, on demand, that the shipped binary still rejects.
481
+ *
482
+ * The two rejecting cases are the two ways this gate is supposed to go red, and
483
+ * they are different failures rather than one failure twice:
484
+ *
485
+ * - EMPTY SCOPE. Every child asserts its own corpus is non-empty, so an empty
486
+ * root makes all five exit non-zero with nothing on stdout — which, since the
487
+ * fail-closed repair, is a run failure the umbrella names. This is also the
488
+ * negative control the coverage floor owes: the run publishes `scanned: 0`,
489
+ * and 0 is below any floor above zero, so `check_gate_coverage` reds on it.
490
+ * - A PLANTED PAYLOAD. A zero-width joiner inside a `.md` file under an
491
+ * otherwise clean root, which a child must flag as a blocking finding.
492
+ *
493
+ * The accepting case is the same root without the payload. Without it, a gate
494
+ * that returned non-zero unconditionally would score two rejections and look
495
+ * healthy.
496
+ */
497
+ function _selfTest(): number {
498
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "lint-agent-security-selftest-"));
499
+ const mk = (name: string, body: string): string => {
500
+ const dir = path.join(tmp, name, "src", "skills", "candidate");
501
+ fs.mkdirSync(dir, { recursive: true });
502
+ fs.writeFileSync(path.join(dir, "SKILL.md"), body, "utf-8");
503
+ return path.join(tmp, name);
504
+ };
505
+ const cleanRoot = mk("clean", "---\ntitle: candidate\n---\n\nOrdinary prose with nothing hidden in it.\n");
506
+ // U+200D ZERO WIDTH JOINER between two words — invisible, and exactly the
507
+ // hidden-instruction carrier class this umbrella exists to catch.
508
+ const payloadRoot = mk("payload", "---\ntitle: candidate\n---\n\nOrdinary\u200dprose with something hidden in it.\n");
509
+ const emptyRoot = path.join(tmp, "empty");
510
+ fs.mkdirSync(emptyRoot, { recursive: true });
511
+
512
+ const run = (root: string): number =>
513
+ runGateCli(REPO_ROOT, "src/scripts/lint_agent_security.ts", ["--root", root], REPO_ROOT);
514
+
515
+ try {
516
+ return runSelfTest({
517
+ gate: "lint_agent_security",
518
+ minCases: 3,
519
+ minRejectCases: 2,
520
+ cases: [
521
+ { name: "empty scope — every child reads nothing and the aggregate refuses", expect: "reject", run: () => run(emptyRoot) },
522
+ { name: "planted zero-width joiner — a child flags it and the aggregate blocks", expect: "reject", run: () => run(payloadRoot) },
523
+ { name: "clean root — the same scope without the payload still passes", expect: "accept", run: () => run(cleanRoot) },
524
+ ],
525
+ });
526
+ } finally {
527
+ fs.rmSync(tmp, { recursive: true, force: true });
528
+ }
529
+ }
530
+
240
531
  function _isCliEntry(): boolean {
241
532
  if (process.argv[1] === undefined) {
242
533
  return false;