@event4u/agent-config 14.7.0 → 14.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 +245 -388
- package/README.md +1 -1
- package/dist/agent-src/commands/evals/git-pr-merge.json +13 -0
- package/dist/agent-src/commands/evals/roadmap.json +17 -1
- package/dist/agent-src/commands/pr/create.md +14 -2
- package/dist/agent-src/commands/pr/merge.md +379 -0
- package/dist/agent-src/commands/roadmap/process-full.md +157 -3
- package/dist/agent-src/commands/roadmap.md +1 -1
- package/dist/agent-src/contexts/execution/auto-dispatch-classification.md +46 -36
- package/dist/agent-src/contexts/execution/autonomy-mechanics.md +37 -2
- package/dist/agent-src/contexts/execution/roadmap-execution-contract.md +64 -2
- package/dist/agent-src/contexts/execution/roadmap-process-loop.md +154 -2
- package/dist/agent-src/rules/artifact-drafting-protocol.md +5 -1
- package/dist/agent-src/rules/decision-revisit-gate.md +65 -33
- package/dist/agent-src/rules/recurring-criticism.md +1 -1
- package/dist/agent-src/rules/roadmap-progress-sync.md +19 -0
- package/dist/agent-src/rules/source-confidentiality.md +1 -1
- package/dist/agent-src/scripts/archive_completed_roadmaps.ts +7 -4
- package/dist/agent-src/scripts/dashboard_mode.ts +212 -0
- package/dist/agent-src/scripts/update_roadmap_progress.ts +47 -48
- package/dist/agent-src/skills/adr-create/SKILL.md +124 -5
- package/dist/agent-src/skills/decision-record/SKILL.md +61 -3
- package/dist/agent-src/skills/decision-review/SKILL.md +65 -0
- package/dist/agent-src/skills/merge-conflicts/SKILL.md +26 -0
- package/dist/agent-src/skills/security/SKILL.md +8 -0
- package/dist/agent-src/skills/security-audit/SKILL.md +12 -1
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/agent-src/templates/roadmaps.md +20 -2
- package/dist/cli/registry.js +1 -0
- package/dist/cli/registry.js.map +1 -1
- package/dist/cli-delegate/{chunk-JZ4NR2TW.js → chunk-7PRE6ZV2.js} +1 -1
- package/dist/cli-delegate/{chunk-7F3VYSM2.js → chunk-EEJUMCDZ.js} +14 -14
- package/dist/cli-delegate/{chunk-74E3TKWV.js → chunk-H4VIVD4C.js} +5 -5
- package/dist/cli-delegate/chunk-I3EIN2YG.js +36 -0
- package/dist/cli-delegate/{chunk-4VR4ORQ6.js → chunk-LPPWO5CE.js} +4 -3
- package/dist/cli-delegate/{chunk-UEZ7D35A.js → chunk-N7CDDGI3.js} +4 -23
- package/dist/cli-delegate/cmd_adr_effective.js +1162 -0
- package/dist/cli-delegate/cmd_conformance.js +14 -14
- package/dist/cli-delegate/cmd_converge.js +3 -3
- package/dist/cli-delegate/cmd_doctor.js +7 -7
- package/dist/cli-delegate/cmd_export.js +6 -6
- package/dist/cli-delegate/cmd_handoff.js +7 -6
- package/dist/cli-delegate/cmd_memory_get.js +2 -1
- package/dist/cli-delegate/cmd_migrate.js +2 -2
- package/dist/cli-delegate/cmd_preflight.js +5 -5
- package/dist/cli-delegate/cmd_prune.js +6 -6
- package/dist/cli-delegate/cmd_route_audit.js +6 -5
- package/dist/cli-delegate/cmd_route_explain.js +3 -3
- package/dist/cli-delegate/cmd_session_recycle.js +3 -3
- package/dist/cli-delegate/cmd_settings_get.js +3 -3
- package/dist/cli-delegate/cmd_settings_migrate.js +6 -6
- package/dist/cli-delegate/cmd_sync.js +6 -6
- package/dist/cli-delegate/cmd_uninstall.js +6 -6
- package/dist/cli-delegate/cmd_update.js +3 -3
- package/dist/cli-delegate/cmd_upgrade.js +3 -3
- package/dist/cli-delegate/cmd_validate.js +6 -6
- package/dist/cli-delegate/cmd_versions.js +3 -3
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +53 -22
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +3 -3
- package/dist/discovery/dormancy-report.md +1 -1
- package/dist/discovery/orphan-report.md +1 -1
- package/dist/discovery/packs.json +7 -6
- package/dist/discovery/trust-report.md +2 -2
- package/dist/discovery/workspaces.json +6 -5
- package/dist/hooks/dispatch.js +806 -681
- package/dist/install/globalRuleLayers.js +169 -0
- package/dist/install/globalRuleLayers.js.map +1 -0
- package/dist/install/partitionEligibility.js +156 -0
- package/dist/install/partitionEligibility.js.map +1 -1
- package/dist/install/ruleLayerPartition.js +157 -0
- package/dist/install/ruleLayerPartition.js.map +1 -0
- package/dist/mcp/registry-manifest.json +3 -3
- package/dist/mcp/server.json +2 -2
- package/dist/mcp/server.mjs +654 -578
- package/dist/scripts/_lib/prune_empty_dirs.js +85 -0
- package/dist/scripts/_lib/prune_empty_dirs.js.map +1 -0
- package/dist/scripts/condense.js +52 -89
- package/dist/scripts/condense.js.map +1 -1
- package/docs/guidelines/agent-infra/artifact-drafting-protocol-mechanics.md +45 -1
- package/docs/guidelines/agent-infra/false-green.md +47 -1
- package/package.json +1 -1
- package/src/agent-src/scripts/archive_completed_roadmaps.ts +7 -4
- package/src/agent-src/scripts/dashboard_mode.ts +212 -0
- package/src/agent-src/scripts/update_roadmap_progress.ts +47 -48
- package/src/config/ci-local-parity.yml +21 -0
- package/src/config/estate-count-budget.json +275 -2
- package/src/config/evaluator-budgets.json +3 -3
- package/src/config/gate-coverage.yml +220 -12
- package/src/config/gate-violation-baselines.json +73 -63
- package/src/config/gitignore-block.txt +15 -2
- package/src/config/hook-latency-budget.json +5 -0
- package/src/config/rule-stub-ceilings.json +17 -3
- package/src/install/globalRuleLayers.ts +197 -0
- package/src/install/partitionEligibility.ts +177 -0
- package/src/install/ruleLayerPartition.ts +212 -0
- package/src/scripts/_cli/cmd_adr_effective.ts +484 -0
- package/src/scripts/_dispatch.bash +11 -0
- package/src/scripts/_lib/adr_frontmatter.test.ts +365 -0
- package/src/scripts/_lib/adr_frontmatter.ts +459 -0
- package/src/scripts/_lib/envelope_grounding.ts +11 -1
- package/src/scripts/_lib/layer_overlap_notice.ts +11 -2
- package/src/scripts/_lib/prune_empty_dirs.ts +80 -0
- package/src/scripts/adr/evidence_census.ts +899 -0
- package/src/scripts/adr/regenerate_index.ts +12 -32
- package/src/scripts/adr_cite_check.ts +540 -39
- package/src/scripts/audit_adr_coverage.ts +103 -17
- package/src/scripts/bench_hook_latency.ts +204 -0
- package/src/scripts/build_archive_index.ts +34 -0
- package/src/scripts/check_adr_frontmatter.ts +574 -39
- package/src/scripts/check_hook_bundle_content.ts +219 -0
- package/src/scripts/check_new_adr_evidence.ts +529 -0
- package/src/scripts/check_references.ts +18 -0
- package/src/scripts/check_rule_layer_partition.ts +531 -0
- package/src/scripts/check_single_delivery.ts +137 -4
- package/src/scripts/check_standing_rule_delivery.ts +8 -2
- package/src/scripts/condense.ts +61 -91
- package/src/scripts/external_sources_denylist.json +4 -1
- package/src/scripts/hooks/block_unauthorized_git.ts +22 -2
- package/src/scripts/hooks/dispatch_hook.ts +154 -187
- package/src/scripts/hooks/dispatch_issues.ts +38 -19
- package/src/scripts/hooks/fallback_yaml.ts +66 -0
- package/src/scripts/hooks/host_semantics.ts +21 -0
- package/src/scripts/hooks/injection_budget.ts +13 -3
- package/src/scripts/hooks/py_json_dumps.ts +103 -0
- package/src/scripts/hooks/state_io.ts +88 -1
- package/src/scripts/hooks/stdin_failure_policy.ts +96 -0
- package/src/scripts/injection_scan_hook.ts +137 -5
- package/src/scripts/lint_hook_manifest.ts +49 -0
- package/src/scripts/lint_provenance_vocabulary.ts +472 -4
- package/src/scripts/routing_doctor.ts +5 -1
- package/src/scripts/sync_pr_branch.ts +163 -20
- package/dist/cli-delegate/{chunk-PPQO4NGR.js → chunk-6RJA2F5A.js} +3 -3
- package/dist/cli-delegate/{chunk-6EOCNQAM.js → chunk-E4XIZDIB.js} +0 -0
- package/dist/cli-delegate/{chunk-FBKL6JWT.js → chunk-MMSGVZKV.js} +3 -3
- package/dist/cli-delegate/{chunk-GJ5BVHXM.js → chunk-PMEKGDED.js} +3 -3
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `json.dumps(obj, indent=n)` byte-for-byte, extracted from `dispatch_hook.ts`.
|
|
3
|
+
*
|
|
4
|
+
* Pure and self-contained: it recurses into itself and calls nothing else, which
|
|
5
|
+
* is what made it safe to move out of a dispatcher that runs on every tool call.
|
|
6
|
+
* Extracted because dispatch_hook.ts sits at the 1500-line source ceiling.
|
|
7
|
+
*
|
|
8
|
+
* For anyone tempted to share it: mcp_telemetry_store, mcp_telemetry_query and
|
|
9
|
+
* mcp_telemetry_health each define their OWN `_py_json_dumps`. They were left
|
|
10
|
+
* alone — unifying four copies is a change with its own risk, not a merge-time
|
|
11
|
+
* side effect.
|
|
12
|
+
*/
|
|
13
|
+
// Python json.dumps(record, indent=2) byte-for-byte (ensure_ascii=True).
|
|
14
|
+
export function _py_json_dumps(value: unknown, indent: number): string {
|
|
15
|
+
const pad = " ".repeat(indent);
|
|
16
|
+
const escapeString = (s: string): string => {
|
|
17
|
+
let out = '"';
|
|
18
|
+
for (const ch of s) {
|
|
19
|
+
const code = ch.codePointAt(0) as number;
|
|
20
|
+
switch (ch) {
|
|
21
|
+
case '"':
|
|
22
|
+
out += '\\"';
|
|
23
|
+
break;
|
|
24
|
+
case "\\":
|
|
25
|
+
out += "\\\\";
|
|
26
|
+
break;
|
|
27
|
+
case "\n":
|
|
28
|
+
out += "\\n";
|
|
29
|
+
break;
|
|
30
|
+
case "\r":
|
|
31
|
+
out += "\\r";
|
|
32
|
+
break;
|
|
33
|
+
case "\t":
|
|
34
|
+
out += "\\t";
|
|
35
|
+
break;
|
|
36
|
+
case "\b":
|
|
37
|
+
out += "\\b";
|
|
38
|
+
break;
|
|
39
|
+
case "\f":
|
|
40
|
+
out += "\\f";
|
|
41
|
+
break;
|
|
42
|
+
default:
|
|
43
|
+
if (code < 0x20 || code > 0x7e) {
|
|
44
|
+
if (code > 0xffff) {
|
|
45
|
+
const c = code - 0x10000;
|
|
46
|
+
const hi = 0xd800 + (c >> 10);
|
|
47
|
+
const lo = 0xdc00 + (c & 0x3ff);
|
|
48
|
+
out +=
|
|
49
|
+
"\\u" +
|
|
50
|
+
hi.toString(16).padStart(4, "0") +
|
|
51
|
+
"\\u" +
|
|
52
|
+
lo.toString(16).padStart(4, "0");
|
|
53
|
+
} else {
|
|
54
|
+
out += "\\u" + code.toString(16).padStart(4, "0");
|
|
55
|
+
}
|
|
56
|
+
} else {
|
|
57
|
+
out += ch;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return out + '"';
|
|
62
|
+
};
|
|
63
|
+
const render = (v: unknown, depth: number): string => {
|
|
64
|
+
if (v === null || v === undefined) return "null";
|
|
65
|
+
if (typeof v === "boolean") return v ? "true" : "false";
|
|
66
|
+
if (typeof v === "number") {
|
|
67
|
+
if (!Number.isFinite(v)) {
|
|
68
|
+
if (Number.isNaN(v)) return "NaN";
|
|
69
|
+
return v > 0 ? "Infinity" : "-Infinity";
|
|
70
|
+
}
|
|
71
|
+
return String(v);
|
|
72
|
+
}
|
|
73
|
+
if (typeof v === "string") return escapeString(v);
|
|
74
|
+
const curPad = pad.repeat(depth + 1);
|
|
75
|
+
const closePad = pad.repeat(depth);
|
|
76
|
+
if (Array.isArray(v)) {
|
|
77
|
+
if (v.length === 0) return "[]";
|
|
78
|
+
return (
|
|
79
|
+
"[\n" +
|
|
80
|
+
v.map((item) => curPad + render(item, depth + 1)).join(",\n") +
|
|
81
|
+
"\n" +
|
|
82
|
+
closePad +
|
|
83
|
+
"]"
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
if (typeof v === "object") {
|
|
87
|
+
const obj = v as Record<string, unknown>;
|
|
88
|
+
const keys = Object.keys(obj);
|
|
89
|
+
if (keys.length === 0) return "{}";
|
|
90
|
+
return (
|
|
91
|
+
"{\n" +
|
|
92
|
+
keys
|
|
93
|
+
.map((k) => curPad + escapeString(k) + ": " + render(obj[k], depth + 1))
|
|
94
|
+
.join(",\n") +
|
|
95
|
+
"\n" +
|
|
96
|
+
closePad +
|
|
97
|
+
"}"
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
throw new TypeError(`Object of type ${typeof v} is not JSON serializable`);
|
|
101
|
+
};
|
|
102
|
+
return render(value, 0);
|
|
103
|
+
}
|
|
@@ -540,7 +540,9 @@ function _atomic_write_text(target: string, text: string): void {
|
|
|
540
540
|
* Layout:
|
|
541
541
|
* <state_root>/.dispatcher/<session_id>/
|
|
542
542
|
* <concern>.json — one per concern that ran
|
|
543
|
-
* summary.json —
|
|
543
|
+
* summary.json — capped list of per-invocation rollups
|
|
544
|
+
* (schema 2; see hook-architecture-v1 for why
|
|
545
|
+
* a single rollup object lost one per overlap)
|
|
544
546
|
*
|
|
545
547
|
* Per Council Round 2 (2026-05-04): exit-code reduction collapses
|
|
546
548
|
* multiple concern signals into a single platform-native code; the
|
|
@@ -1003,6 +1005,91 @@ export function owns_session_state(state: unknown, session_id: string): boolean
|
|
|
1003
1005
|
* sites at the time of the change and both were converted; a new caller must
|
|
1004
1006
|
* compare against a member of the union, never coerce to a boolean.
|
|
1005
1007
|
*/
|
|
1008
|
+
/**
|
|
1009
|
+
* Read a TEXT file, transform it, and write it back — the WHOLE sequence under
|
|
1010
|
+
* one lock.
|
|
1011
|
+
*
|
|
1012
|
+
* The text sibling of {@link update_json_under_lock}, added for P3 of
|
|
1013
|
+
* `b-stop-async-split-prerequisites` (council 2026-08-20, option (a),
|
|
1014
|
+
* "P3 before anything else"). The concrete case is
|
|
1015
|
+
* `agents/runtime/state/dispatch-issues.jsonl`, which had NO lock and NO
|
|
1016
|
+
* tmp+rename: `log_dispatch_issue` read the whole capped log, appended a line,
|
|
1017
|
+
* and `writeFileSync`'d the target directly.
|
|
1018
|
+
*
|
|
1019
|
+
* That is corruption-capable rather than merely lossy, and the distinction is
|
|
1020
|
+
* why this needed its own primitive instead of a comment. `writeFileSync`
|
|
1021
|
+
* truncates and then writes, so a concurrent reader can observe a half-written
|
|
1022
|
+
* log and a concurrent writer can interleave into one — and the file is written
|
|
1023
|
+
* precisely when something has ALREADY gone wrong, so the evidence destroyed is
|
|
1024
|
+
* the evidence of the failure being reported. Two concurrent dispatchers in one
|
|
1025
|
+
* workspace is a supported configuration (two platforms installed side by side),
|
|
1026
|
+
* and a host that runs tool calls in parallel produces it without any second
|
|
1027
|
+
* platform at all.
|
|
1028
|
+
*
|
|
1029
|
+
* `transform` receives the file's content as read INSIDE the lock — `null` when
|
|
1030
|
+
* the file does not exist, so a caller can tell "absent" from "empty" — and
|
|
1031
|
+
* returns what to write. Returning `null` writes nothing and still counts as
|
|
1032
|
+
* success.
|
|
1033
|
+
*
|
|
1034
|
+
* Returns `written` / `skipped` / `failed`, the same three-state contract
|
|
1035
|
+
* `update_json_under_lock` carries since `bcbb0380b` — a boolean could not tell
|
|
1036
|
+
* a deliberate decline from a failure, and every member of the union is a truthy
|
|
1037
|
+
* string, so `if (!result)` is a silent no-op rather than a compile error. Under
|
|
1038
|
+
* `AGENT_CONFIG_REPLAY=1` this reports `failed` without touching the tree.
|
|
1039
|
+
*/
|
|
1040
|
+
export function update_text_under_lock(
|
|
1041
|
+
target: string,
|
|
1042
|
+
transform: (loaded: string | null) => string | null,
|
|
1043
|
+
options: { blocking?: boolean } = {},
|
|
1044
|
+
): LockedUpdateResult {
|
|
1045
|
+
if (is_replay_mode()) return "failed";
|
|
1046
|
+
const targetPath = path.resolve(target);
|
|
1047
|
+
const state_dir = path.dirname(targetPath);
|
|
1048
|
+
try {
|
|
1049
|
+
fs.mkdirSync(state_dir, { recursive: true });
|
|
1050
|
+
} catch {
|
|
1051
|
+
return "failed";
|
|
1052
|
+
}
|
|
1053
|
+
// FILE-keyed, matching `update_json_under_lock` rather than
|
|
1054
|
+
// `_atomic_write_text`. Converged onto the sibling deliberately after
|
|
1055
|
+
// `bcbb0380b` landed the measured argument: a read-modify-write needs mutual
|
|
1056
|
+
// exclusion against writers of THIS file and nothing else, and the shared
|
|
1057
|
+
// directory lock was measured paying the full cost of exclusion for writes
|
|
1058
|
+
// that require none (8 workers: slowest 138-267 ms shared vs 83-95 ms
|
|
1059
|
+
// unshared). This function was written against the directory-keyed primitive
|
|
1060
|
+
// one commit earlier; leaving it there would have made
|
|
1061
|
+
// `dispatch-issues.jsonl` serialise against every unrelated
|
|
1062
|
+
// `atomic_write_text` in the state dir.
|
|
1063
|
+
const lock_path = _target_lock_path(targetPath);
|
|
1064
|
+
let fd: number | null;
|
|
1065
|
+
try {
|
|
1066
|
+
fd = _acquire_lock(lock_path, { blocking: options.blocking ?? true });
|
|
1067
|
+
} catch {
|
|
1068
|
+
return "failed";
|
|
1069
|
+
}
|
|
1070
|
+
// FAIL CLOSED, for the same reason the JSON sibling does: an append without
|
|
1071
|
+
// exclusion is the truncating write this function exists to prevent.
|
|
1072
|
+
if (fd === null) return "failed";
|
|
1073
|
+
try {
|
|
1074
|
+
let loaded: string | null = null;
|
|
1075
|
+
try {
|
|
1076
|
+
loaded = fs.readFileSync(targetPath, "utf-8");
|
|
1077
|
+
} catch {
|
|
1078
|
+
// Absent or unreadable. `null` lets the transform distinguish the two
|
|
1079
|
+
// from an empty file, which matters for an append-only log: an
|
|
1080
|
+
// unreadable existing log must not be silently replaced by one line.
|
|
1081
|
+
}
|
|
1082
|
+
const next = transform(loaded);
|
|
1083
|
+
if (next === null) return "skipped"; // deliberate no-write, not a failure
|
|
1084
|
+
_publish_text_locked(targetPath, next);
|
|
1085
|
+
return "written";
|
|
1086
|
+
} catch {
|
|
1087
|
+
return "failed";
|
|
1088
|
+
} finally {
|
|
1089
|
+
_release_lock(fd, lock_path);
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1006
1093
|
export function update_json_under_lock<T>(
|
|
1007
1094
|
target: string,
|
|
1008
1095
|
mutate: (loaded: Partial<T>) => T | null,
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The stdin-read-failure policy, extracted from `dispatch_hook.ts`.
|
|
3
|
+
*
|
|
4
|
+
* These functions are pure with respect to dispatch_hook's own state: they take
|
|
5
|
+
* what they need as parameters and return a verdict. They do call two imported
|
|
6
|
+
* helpers (`readFd0ToEnd`, `isBlockCapable`), which move with them as imports. The module-level flag they feed, `_stdin_read_failed`, stays in
|
|
7
|
+
* dispatch_hook.ts, written at exactly one call site and read at three. That
|
|
8
|
+
* asymmetry is what makes moving the functions safe and the state untouched.
|
|
9
|
+
*
|
|
10
|
+
* Extracted because dispatch_hook.ts sits at the 1500-line source ceiling.
|
|
11
|
+
*/
|
|
12
|
+
import type { JsonObject } from './dispatch_hook.js';
|
|
13
|
+
import { readFd0ToEnd } from './hook_stdin.js';
|
|
14
|
+
import { isBlockCapable } from './host_semantics.js';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The seam where a FAILED read becomes an empty string — isolated so the three
|
|
18
|
+
* failure classes the policy names can be driven with real errno values.
|
|
19
|
+
*
|
|
20
|
+
* Returns the failure message, or `null` when the read succeeded. The read
|
|
21
|
+
* itself is injectable for exactly one reason: `EAGAIN` exhaustion, `EIO` and
|
|
22
|
+
* `EBADF` cannot be staged against a live fd 0 portably, and a policy whose
|
|
23
|
+
* trigger is untestable is a policy nobody can show works. What the injection
|
|
24
|
+
* does NOT buy is proof that the production path reaches here — that is the
|
|
25
|
+
* wiring, pinned separately by driving `main()`.
|
|
26
|
+
*/
|
|
27
|
+
export function stdinReadFailure(read: () => string = readFd0ToEnd): {
|
|
28
|
+
text: string;
|
|
29
|
+
failure: string | null;
|
|
30
|
+
} {
|
|
31
|
+
try {
|
|
32
|
+
// Reads to EOF and retries on EAGAIN rather than treating it as empty
|
|
33
|
+
// input; shared with the concern-side reader so both paths cannot drift.
|
|
34
|
+
return { text: read(), failure: null };
|
|
35
|
+
} catch (exc) {
|
|
36
|
+
return { text: "", failure: exc instanceof Error ? exc.message : String(exc) };
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Is this concern one whose verdict a failed read silently converted into an
|
|
42
|
+
* allow?
|
|
43
|
+
*
|
|
44
|
+
* Both halves are required and neither is redundant. `fail_closed: true` is the
|
|
45
|
+
* manifest's declaration that this concern's own crash must not become a pass;
|
|
46
|
+
* `severity: blocking` is the declaration that it can refuse at all. An
|
|
47
|
+
* advisory concern that happens to be `fail_closed` has no verdict to lose, and
|
|
48
|
+
* a blocking concern that is not `fail_closed` has already declared the opposite
|
|
49
|
+
* intent.
|
|
50
|
+
*/
|
|
51
|
+
export function _is_fail_closed_blocking(concern: JsonObject): boolean {
|
|
52
|
+
const severity = String(concern["severity"] ?? "").trim().toLowerCase();
|
|
53
|
+
return concern["fail_closed"] === true && severity === "blocking";
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* `b-stdin-read-failure-policy`, option (c) — deny a failed read ONLY where a
|
|
58
|
+
* deny is honoured AND something was actually silenced.
|
|
59
|
+
*
|
|
60
|
+
* Decided by council 2026-08-20, 2/2 quorum, both seats on option (c):
|
|
61
|
+
* "on a read failure, deny only when the slot is block-capable and at least one
|
|
62
|
+
* selected concern is both blocking and `fail_closed: true`".
|
|
63
|
+
*
|
|
64
|
+
* The two rejected options are worth keeping visible, because each is the
|
|
65
|
+
* obvious move. Denying on EVERY slot (option (a)) refuses nothing on
|
|
66
|
+
* `post_tool_use` — the tool already ran — and on `stop` it breaks a turn end
|
|
67
|
+
* to protect a guard that was never there; it buys availability risk for no
|
|
68
|
+
* enforcement. Keeping the allow (option (b), the status quo) leaves a
|
|
69
|
+
* documented allow-on-failure on a security path: F-1 measured a
|
|
70
|
+
* `git commit --no-verify` DENIED at small payload size and ALLOWED at 300 KB,
|
|
71
|
+
* and the loud stderr line that now ships makes that visible without making it
|
|
72
|
+
* stop.
|
|
73
|
+
*
|
|
74
|
+
* The cost is stated rather than hidden: a transient I/O error on
|
|
75
|
+
* `pre_tool_use` now refuses a tool call the user must retry. The retry budget
|
|
76
|
+
* already survived ~10 s of `EAGAIN` before this point, so the class of error
|
|
77
|
+
* that reaches here is not "the writer was slow".
|
|
78
|
+
*
|
|
79
|
+
* Returns `null` when the dispatch proceeds normally.
|
|
80
|
+
*/
|
|
81
|
+
export function denyOnStdinFailure(
|
|
82
|
+
platform: string,
|
|
83
|
+
event: string,
|
|
84
|
+
concerns: readonly JsonObject[],
|
|
85
|
+
failure: string,
|
|
86
|
+
): { reason: string } | null {
|
|
87
|
+
if (!isBlockCapable(platform, event)) return null;
|
|
88
|
+
const silenced = concerns.filter(_is_fail_closed_blocking).map((c) => String(c["name"]));
|
|
89
|
+
if (silenced.length === 0) return null;
|
|
90
|
+
return {
|
|
91
|
+
reason:
|
|
92
|
+
`agent-config: refusing this action — the hook payload could not be read ` +
|
|
93
|
+
`(${failure}), so ${silenced.join(", ")} evaluated nothing. ` +
|
|
94
|
+
`A fail-closed guard on a block-capable slot must not pass by default. Retry.`,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
@@ -141,10 +141,114 @@ function _enabled(root: string): boolean {
|
|
|
141
141
|
return false;
|
|
142
142
|
}
|
|
143
143
|
|
|
144
|
-
/**
|
|
144
|
+
/**
|
|
145
|
+
* ── The output-envelope contract ─────────────────────────────────────────────
|
|
146
|
+
*
|
|
147
|
+
* `b-injection-scan-unwrap-security`, option (a), council 2026-08-20 (2/2
|
|
148
|
+
* quorum): "specify the envelope contract and its fixtures BEFORE narrowing the
|
|
149
|
+
* scanner input". This block is that contract; the fixtures are in
|
|
150
|
+
* `tests/hooks/injection_scan_output_contract.test.ts`, and they are the
|
|
151
|
+
* deliverable — the code change below is one descent and one deletion.
|
|
152
|
+
*
|
|
153
|
+
* WHAT THIS CONCERN READS, exhaustively:
|
|
154
|
+
*
|
|
155
|
+
* 1. **The tool output, and only the tool output.** `injection-scan` is bound
|
|
156
|
+
* to `post_tool_use` on every platform that carries it, so a payload
|
|
157
|
+
* reaching here is expected to hold a tool result.
|
|
158
|
+
* 2. **Where it lives.** Under a dispatcher envelope the host-shaped fields are
|
|
159
|
+
* nested under `payload` (`dispatch_hook._build_envelope`); under a bare-host
|
|
160
|
+
* or direct invocation they sit at the root. Both are supported.
|
|
161
|
+
* 3. **Which keys.** {@link OUTPUT_KEYS}, in order. The first four mirror
|
|
162
|
+
* `payload_stub.BODY_KEYS.result` — the set the dispatcher stubs, and
|
|
163
|
+
* therefore the set it knows how to serve whole. `output` and `result` are
|
|
164
|
+
* the two generic spellings this concern has accepted since it shipped and
|
|
165
|
+
* are kept: dropping them would narrow coverage on the same change that
|
|
166
|
+
* narrows the fallback, and only one of those is decided here.
|
|
167
|
+
* 4. **How a value is read.** A string is scanned verbatim. An object or array
|
|
168
|
+
* is scanned as its compact JSON serialisation — unchanged, deliberately,
|
|
169
|
+
* including the consequence that a hidden codepoint inside a nested string
|
|
170
|
+
* becomes the literal text `\u200b` and does NOT trip `_HIDDEN`. That is a
|
|
171
|
+
* detection property of the serialiser, not of this contract, and changing
|
|
172
|
+
* it is a separate question.
|
|
173
|
+
* 5. **Missing output → nothing to scan → allow.** THIS IS THE NARROWING. The
|
|
174
|
+
* previous code fell through to serialising the WHOLE envelope, so what it
|
|
175
|
+
* actually scanned in production was everything: the tool input echo, the
|
|
176
|
+
* cwd, the session id, the settings block. It worked by accident and nothing
|
|
177
|
+
* tested the accident.
|
|
178
|
+
* 6. **Malformed envelope → allow.** Non-JSON, a list, a scalar: unchanged.
|
|
179
|
+
* 7. **A stubbed result body → allow, LOUDLY.** The manifest declares
|
|
180
|
+
* `needs_payload_bodies: [input, result]`, which is what keeps the result
|
|
181
|
+
* served whole. If that declaration is ever dropped the body arrives as a
|
|
182
|
+
* `PayloadStub` and a plain read finds no string — indistinguishable from
|
|
183
|
+
* "clean output", which is a scanner that silently stops scanning. Say so on
|
|
184
|
+
* stderr instead. Same second-silent-death route `ship-diff-volume` closes.
|
|
185
|
+
*
|
|
186
|
+
* BOTH DIRECTIONS OF THE COST, stated because the narrowing is on a security
|
|
187
|
+
* surface and only one direction is an improvement:
|
|
188
|
+
*
|
|
189
|
+
* - **Removed false positives.** The old fallback could raise a hit on text
|
|
190
|
+
* that is not tool output at all — a `cwd` under a path containing an exfil
|
|
191
|
+
* signature, an injection phrase echoed in the tool INPUT the user typed.
|
|
192
|
+
* - **Accepted false-negative risk.** A host that puts its tool output under a
|
|
193
|
+
* key not in {@link OUTPUT_KEYS} is now unscanned where the fallback would
|
|
194
|
+
* have caught it. This is mitigated, not dismissed: an unrecognised payload
|
|
195
|
+
* shape emits one stderr line naming the payload's top-level KEY NAMES (never
|
|
196
|
+
* values), so a new host spelling surfaces on first contact instead of going
|
|
197
|
+
* dark. The list is one exported constant; adding a spelling is one line.
|
|
198
|
+
*
|
|
199
|
+
* The descent below is LOCAL and is deliberately NOT `envelope.ts`'s shared
|
|
200
|
+
* `unwrap`, which descends only when all four `ENVELOPE_KEYS` are present: a
|
|
201
|
+
* producer emitting a partial envelope would leave this concern reading the root
|
|
202
|
+
* again with every test still green. `ship-diff-volume`, `design-slop` and
|
|
203
|
+
* `ui-route-nudge` each carry their own descent for the same reason.
|
|
204
|
+
*/
|
|
205
|
+
export const OUTPUT_KEYS: readonly string[] = [
|
|
206
|
+
// payload_stub.BODY_KEYS.result — the spellings the dispatcher serves.
|
|
207
|
+
"tool_response",
|
|
208
|
+
"toolResponse",
|
|
209
|
+
"tool_result",
|
|
210
|
+
"toolUseResult",
|
|
211
|
+
// Generic spellings accepted since this concern shipped.
|
|
212
|
+
"output",
|
|
213
|
+
"result",
|
|
214
|
+
];
|
|
215
|
+
|
|
216
|
+
/** The dispatcher's stub marker (payload_stub.STUB_MARKER), inlined so this
|
|
217
|
+
* concern keeps its no-hard-import-dependency property in a consumer repo. */
|
|
218
|
+
const STUB_MARKER = "_agent_config_body_omitted";
|
|
219
|
+
|
|
220
|
+
function _isStub(v: unknown): boolean {
|
|
221
|
+
return _isObject(v as JsonValue) && (v as JsonObject)[STUB_MARKER] === true;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Descend to the platform payload. See the contract block above, point 2.
|
|
226
|
+
*/
|
|
227
|
+
function _payload_of(root: JsonObject): JsonObject {
|
|
228
|
+
const inner = root["payload"];
|
|
229
|
+
return _isObject(inner) ? inner : root;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* The tool-output text, per the contract above. `''` means "nothing to scan",
|
|
234
|
+
* which is a real answer and not a failure.
|
|
235
|
+
*/
|
|
145
236
|
function _tool_output(envelope: JsonObject): string {
|
|
146
|
-
|
|
147
|
-
|
|
237
|
+
const payload = _payload_of(envelope);
|
|
238
|
+
for (const key of OUTPUT_KEYS) {
|
|
239
|
+
const v = payload[key];
|
|
240
|
+
if (v === undefined || v === null) {
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
if (_isStub(v)) {
|
|
244
|
+
// Contract point 7: the declaration that serves this body was dropped.
|
|
245
|
+
process.stderr.write(
|
|
246
|
+
`injection-scan: ${key} arrived stubbed; the concern declares ` +
|
|
247
|
+
"needs_payload_bodies: [input, result] and cannot scan tool output " +
|
|
248
|
+
"without it. Nothing was scanned on this event.\n",
|
|
249
|
+
);
|
|
250
|
+
return "";
|
|
251
|
+
}
|
|
148
252
|
if (typeof v === "string") {
|
|
149
253
|
return v;
|
|
150
254
|
}
|
|
@@ -152,8 +256,36 @@ function _tool_output(envelope: JsonObject): string {
|
|
|
152
256
|
return _pyJsonDumpsCompact(v);
|
|
153
257
|
}
|
|
154
258
|
}
|
|
155
|
-
//
|
|
156
|
-
|
|
259
|
+
// Contract point 5 + the accepted false-negative risk. Key NAMES only — a
|
|
260
|
+
// value here could be tool output, which is exactly what must not be logged.
|
|
261
|
+
const names = Object.keys(payload);
|
|
262
|
+
if (names.length > 0) {
|
|
263
|
+
process.stderr.write(
|
|
264
|
+
"injection-scan: no recognised tool-output key in this payload; nothing " +
|
|
265
|
+
`scanned. Present keys: ${names.sort().join(", ")}. Expected one of: ` +
|
|
266
|
+
`${OUTPUT_KEYS.join(", ")}.\n`,
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
return "";
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* The stdin boundary: raw concern stdin → the text this concern scans.
|
|
274
|
+
*
|
|
275
|
+
* Exported so the regression drives the BOUNDARY rather than the extractor —
|
|
276
|
+
* both council seats required that, and it is what stops a hand-written fixture
|
|
277
|
+
* from drifting into agreeing with the bug. Never throws.
|
|
278
|
+
*/
|
|
279
|
+
export function toolOutputFromStdin(raw: string): string {
|
|
280
|
+
if (!raw.trim()) return "";
|
|
281
|
+
let root: unknown;
|
|
282
|
+
try {
|
|
283
|
+
root = JSON.parse(raw);
|
|
284
|
+
} catch {
|
|
285
|
+
return "";
|
|
286
|
+
}
|
|
287
|
+
if (!_isObject(root as JsonValue)) return "";
|
|
288
|
+
return _tool_output(root as JsonObject);
|
|
157
289
|
}
|
|
158
290
|
|
|
159
291
|
function _scan(text: string): string[] {
|
|
@@ -135,6 +135,7 @@ function _check_concerns(manifest: YamlObject, errors: string[]): Set<string> {
|
|
|
135
135
|
return new Set();
|
|
136
136
|
}
|
|
137
137
|
const names = new Set<string>();
|
|
138
|
+
const nudgeRanks = new Map<number, string[]>();
|
|
138
139
|
for (const [name, spec] of Object.entries(concernsRaw)) {
|
|
139
140
|
if (!isObject(spec)) {
|
|
140
141
|
errors.push(
|
|
@@ -213,8 +214,56 @@ function _check_concerns(manifest: YamlObject, errors: string[]): Set<string> {
|
|
|
213
214
|
}
|
|
214
215
|
}
|
|
215
216
|
}
|
|
217
|
+
// `nudge_rank:` — the nudge-exclusivity ordering `injection_budget`
|
|
218
|
+
// arbitrates on. Validated here because `injection_budget._selectNudge`
|
|
219
|
+
// says so in as many words: "a tie is a manifest defect rather than a
|
|
220
|
+
// runtime one, and `lint_hook_manifest` is where it should eventually be
|
|
221
|
+
// caught — that check does not exist yet and this comment is the honest
|
|
222
|
+
// statement of the gap". This is that check.
|
|
223
|
+
//
|
|
224
|
+
// A tie is not a crash. `_selectNudge` keeps the lowest rank and breaks
|
|
225
|
+
// ties on concern name, so a collision resolves stably and arbitrarily:
|
|
226
|
+
// one nudge silently wins forever, and the author who added the second one
|
|
227
|
+
// gets no signal that their concern can never emit. That is the failure
|
|
228
|
+
// mode — silent, not loud.
|
|
229
|
+
if ("nudge_rank" in spec) {
|
|
230
|
+
const rank = spec["nudge_rank"];
|
|
231
|
+
if (typeof rank !== "number" || !Number.isInteger(rank) || rank < 1) {
|
|
232
|
+
errors.push(
|
|
233
|
+
`concerns.${name}: 'nudge_rank' must be a positive integer ` +
|
|
234
|
+
`(got ${pyTypeName(rank)}) — the dispatcher keeps the LOWEST rank, ` +
|
|
235
|
+
`so a malformed value reads as "never wins" rather than as an error`,
|
|
236
|
+
);
|
|
237
|
+
} else {
|
|
238
|
+
const seen = nudgeRanks.get(rank);
|
|
239
|
+
if (seen) {
|
|
240
|
+
seen.push(name);
|
|
241
|
+
} else {
|
|
242
|
+
nudgeRanks.set(rank, [name]);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
216
246
|
names.add(name);
|
|
217
247
|
}
|
|
248
|
+
// Uniqueness is enforced GLOBALLY, not per event, and that is a deliberate
|
|
249
|
+
// choice rather than an accident of what is easy to compute. `_selectNudge`
|
|
250
|
+
// compares candidates within one dispatch, so two concerns bound to disjoint
|
|
251
|
+
// events could share a rank harmlessly and the accurate invariant is
|
|
252
|
+
// per-event. Global uniqueness is the strictly-safe superset: it costs
|
|
253
|
+
// nothing at today's two declared ranks, and an author reusing a rank across
|
|
254
|
+
// events is exactly the confusion worth refusing. If a legitimate design
|
|
255
|
+
// ever needs the same rank on two events, NARROW this to concerns sharing at
|
|
256
|
+
// least one bound event — do not delete it.
|
|
257
|
+
for (const [rank, holders] of nudgeRanks) {
|
|
258
|
+
if (holders.length > 1) {
|
|
259
|
+
errors.push(
|
|
260
|
+
`concerns: nudge_rank ${rank} is declared by ${holders.length} concerns ` +
|
|
261
|
+
`(${holders.slice().sort().join(", ")}) — ranks must be unique, because ` +
|
|
262
|
+
`the dispatcher breaks a tie on concern name and the loser can then ` +
|
|
263
|
+
`never emit`,
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
218
267
|
return names;
|
|
219
268
|
}
|
|
220
269
|
|