@event4u/agent-config 9.12.0 → 9.13.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 +80 -256
- package/README.md +1 -1
- package/dist/agent-src/rules/active-remediation.md +0 -2
- package/dist/agent-src/rules/architecture.md +0 -1
- package/dist/agent-src/rules/artifact-drafting-protocol.md +0 -4
- package/dist/agent-src/rules/autonomous-execution.md +1 -2
- package/dist/agent-src/rules/broken-access-control.md +0 -2
- package/dist/agent-src/rules/code-comment-discipline.md +0 -3
- package/dist/agent-src/rules/code-provenance.md +0 -3
- package/dist/agent-src/rules/communication-through-line.md +2 -3
- package/dist/agent-src/rules/content-quoting-floor.md +0 -2
- package/dist/agent-src/rules/context-hygiene.md +1 -3
- package/dist/agent-src/rules/decision-revisit-gate.md +0 -2
- package/dist/agent-src/rules/design-fidelity.md +29 -0
- package/dist/agent-src/rules/domain-adoption-policy.md +0 -3
- package/dist/agent-src/rules/downstream-changes.md +10 -1
- package/dist/agent-src/rules/external-reference-deep-dive.md +0 -7
- package/dist/agent-src/rules/fast-path-marker-visibility.md +0 -1
- package/dist/agent-src/rules/git-history-discipline.md +0 -7
- package/dist/agent-src/rules/guidelines.md +0 -2
- package/dist/agent-src/rules/improve-before-implement.md +4 -2
- package/dist/agent-src/rules/invite-challenge.md +0 -4
- package/dist/agent-src/rules/linked-projects-onboarding-gate.md +0 -2
- package/dist/agent-src/rules/markdown-safe-codeblocks.md +0 -1
- package/dist/agent-src/rules/minimal-safe-diff.md +2 -2
- package/dist/agent-src/rules/missing-tool-handling.md +0 -1
- package/dist/agent-src/rules/no-attribution-footers.md +0 -3
- package/dist/agent-src/rules/no-decorative-emojis-in-git-surfaces.md +8 -5
- package/dist/agent-src/rules/no-pr-progress-comments.md +1 -3
- package/dist/agent-src/rules/no-roadmap-references.md +0 -2
- package/dist/agent-src/rules/output-discipline.md +0 -1
- package/dist/agent-src/rules/prefer-enums-over-literals.md +0 -2
- package/dist/agent-src/rules/preservation-guard.md +0 -3
- package/dist/agent-src/rules/role-mode-adherence.md +0 -1
- package/dist/agent-src/rules/secret-vcs-guard.md +0 -2
- package/dist/agent-src/rules/senior-engineering-discipline.md +0 -2
- package/dist/agent-src/rules/session-canary.md +0 -2
- package/dist/agent-src/rules/size-enforcement.md +0 -5
- package/dist/agent-src/rules/source-confidentiality.md +0 -3
- package/dist/agent-src/rules/source-discovery-gate.md +0 -2
- package/dist/agent-src/rules/think-before-action.md +3 -3
- package/dist/agent-src/rules/token-budget-discipline.md +0 -1
- package/dist/agent-src/rules/token-efficiency.md +1 -2
- package/dist/agent-src/rules/tool-safety.md +0 -1
- package/dist/agent-src/rules/user-interaction.md +0 -3
- package/dist/agent-src/rules/user-interrupt-priority.md +0 -1
- package/dist/agent-src/skills/agent-docs-writing/SKILL.md +8 -0
- package/dist/agent-src/skills/analysis-skill-router/SKILL.md +55 -86
- package/dist/agent-src/skills/character-consistency/SKILL.md +7 -4
- package/dist/agent-src/skills/decision-record/SKILL.md +8 -0
- package/dist/agent-src/skills/design-review/SKILL.md +11 -0
- package/dist/agent-src/skills/design-system-capture/reference/design-system-json.md +8 -0
- package/dist/agent-src/skills/existing-ui-audit/SKILL.md +34 -3
- package/dist/agent-src/skills/fe-design/SKILL.md +10 -0
- package/dist/agent-src/skills/motion-choreographer/SKILL.md +7 -4
- package/dist/agent-src/skills/overbuild-review-lens/SKILL.md +201 -0
- package/dist/agent-src/skills/performance/SKILL.md +14 -0
- package/dist/agent-src/skills/pixar-storyteller/SKILL.md +8 -5
- package/dist/agent-src/skills/scene-expander/SKILL.md +9 -6
- package/dist/agent-src/skills/supply-chain-intake/SKILL.md +10 -0
- package/dist/agent-src/skills/universal-project-analysis/SKILL.md +14 -37
- package/dist/agent-src/skills/video-director/SKILL.md +9 -6
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/agent-src/templates/scripts/work_engine/directives/ui/apply.ts +154 -13
- package/dist/agent-src/templates/scripts/work_engine/directives/ui/design.ts +118 -0
- package/dist/agent-src/templates/scripts/work_engine/directives/ui/polish.ts +40 -0
- package/dist/agent-src/templates/scripts/work_engine/state.ts +55 -0
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +89 -67
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +3 -3
- package/dist/discovery/orphan-report.md +1 -1
- package/dist/discovery/packs.json +6 -5
- package/dist/discovery/trust-report.md +2 -2
- package/dist/discovery/workspaces.json +5 -4
- package/dist/hooks/dispatch.js +174 -13
- package/dist/mcp/registry-manifest.json +3 -3
- package/dist/mcp/server.json +2 -2
- package/dist/mcp/server.mjs +750 -507
- package/dist/router.json +1 -1
- package/dist/scripts/condense.js +2 -2
- package/docs/MIGRATION.md +1 -1
- package/docs/guidelines/agent-infra/agent-interaction-and-decision-quality.md +71 -0
- package/docs/guidelines/agent-infra/minimal-safe-diff-mechanics.md +24 -1
- package/docs/guidelines/code-clarity.md +9 -2
- package/docs/guidelines/design-fidelity-mechanics.md +44 -0
- package/llms.txt +63 -27
- package/package.json +4 -4
- package/src/agent-src/templates/scripts/work_engine/directives/ui/apply.ts +154 -13
- package/src/agent-src/templates/scripts/work_engine/directives/ui/design.ts +118 -0
- package/src/agent-src/templates/scripts/work_engine/directives/ui/polish.ts +40 -0
- package/src/agent-src/templates/scripts/work_engine/state.ts +55 -0
- package/src/config/agent-settings.template.yml +8 -0
- package/src/scripts/_lib/counted_probe.ts +142 -0
- package/src/scripts/_lib/overbuild_lens_contract.ts +196 -0
- package/src/scripts/_lib/release_scope.ts +207 -0
- package/src/scripts/ai_council/stance_tally.ts +37 -2
- package/src/scripts/audit_auto_rules.ts +9 -9
- package/src/scripts/audit_likelihood.ts +3 -3
- package/src/scripts/audit_overlap.ts +2 -2
- package/src/scripts/audit_skill_overlap.ts +233 -95
- package/src/scripts/audit_skill_overlap_allowlist.json +4 -0
- package/src/scripts/build_proof.ts +15 -1
- package/src/scripts/check_dependency_floors.ts +16 -6
- package/src/scripts/check_gate_coverage.ts +6 -6
- package/src/scripts/check_kernel_rule_bundle.ts +5 -4
- package/src/scripts/check_token_quality_golden.ts +0 -18
- package/src/scripts/code_graph/loader.ts +35 -6
- package/src/scripts/compile_router.ts +3 -8
- package/src/scripts/condense.ts +2 -2
- package/src/scripts/explain_run.ts +0 -1
- package/src/scripts/generate_pack_manifests.ts +3 -20
- package/src/scripts/hooks/block_kernel_rule_writes.ts +145 -10
- package/src/scripts/hooks/block_no_verify.ts +101 -0
- package/src/scripts/lint_agent_security.ts +5 -4
- package/src/scripts/lint_framework_leakage_allowlist.json +2 -2
- package/src/scripts/lint_regression.ts +92 -1
- package/src/scripts/lint_skill_descriptions.ts +93 -5
- package/src/scripts/project_thin_rules.ts +2 -2
- package/src/scripts/release.ts +107 -3
- package/src/scripts/resolve_lint_scope.ts +53 -0
- package/src/scripts/router_telemetry.ts +0 -5
- package/src/scripts/runtime_dispatcher.ts +40 -0
- package/src/scripts/schemas/pack.schema.json +1 -5
- package/src/scripts/schemas/rule.schema.json +1 -2
- package/src/scripts/self_review_gate.ts +15 -73
- package/src/scripts/skill_linter.ts +93 -11
- package/src/scripts/sweep_dead_scan_roots.ts +550 -0
- package/src/scripts/trigger_coverage.ts +11 -37
|
@@ -2,10 +2,11 @@
|
|
|
2
2
|
* WASM tree-sitter loader for the code-graph engine.
|
|
3
3
|
*
|
|
4
4
|
* Pinned pair (ABI-verified 2026-07-23): `web-tree-sitter@0.24.7` +
|
|
5
|
-
* `tree-sitter-wasms@0.1.13` → grammar ABI 14. The ABI smoke test
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* no node-gyp — grammars load from `node_modules` via the resolved
|
|
5
|
+
* `tree-sitter-wasms@0.1.13` → grammar ABI 14. The ABI smoke test lives in
|
|
6
|
+
* `tests/scripts/code_graph.test.ts`; it runs in this repo because the pair is
|
|
7
|
+
* a devDependency. Consumers install neither (see `INSTALL_HINT` below). No
|
|
8
|
+
* network, no node-gyp — grammars load from `node_modules` via the resolved
|
|
9
|
+
* path.
|
|
9
10
|
*
|
|
10
11
|
* web-tree-sitter 0.24.x is CJS with a single default export (the Parser
|
|
11
12
|
* class) and the classic `Parser.Language.load()` API; we reach it through
|
|
@@ -51,10 +52,32 @@ interface ParserStatic {
|
|
|
51
52
|
Language: { load(wasmPath: string): Promise<TsLanguage> };
|
|
52
53
|
}
|
|
53
54
|
|
|
55
|
+
/**
|
|
56
|
+
* The parser pair is a devDependency, not a runtime one: this repo's tests still
|
|
57
|
+
* exercise the loader, while a consumer install resolves neither. The engine is
|
|
58
|
+
* permanently `enabled: false` (measured recall 0.365 vs grep 0.797), so
|
|
59
|
+
* shipping ~51 MB of WASM to every consumer for a path none of them can reach
|
|
60
|
+
* was pure install cost. A consumer re-enabling the engine installs the
|
|
61
|
+
* ABI-locked pair themselves — this message carries the pin, because
|
|
62
|
+
* `check_dependency_floors` only scans the `dependencies` block.
|
|
63
|
+
*/
|
|
64
|
+
const INSTALL_HINT =
|
|
65
|
+
'the code-graph engine needs its parser pair, which this package no longer ships to consumers ' +
|
|
66
|
+
'(the engine is disabled by a recorded null result). Install the ABI-locked pair:\n' +
|
|
67
|
+
' npm i web-tree-sitter@0.24.7 tree-sitter-wasms@0.1.13\n' +
|
|
68
|
+
'Both versions are exact on purpose — the pair is ABI-coupled (grammar ABI 14) ' +
|
|
69
|
+
'and must move together.';
|
|
70
|
+
|
|
54
71
|
let _wasmDir: string | null = null;
|
|
55
72
|
function wasmDir(): string {
|
|
56
73
|
if (_wasmDir === null) {
|
|
57
|
-
|
|
74
|
+
let pkgJson: string;
|
|
75
|
+
try {
|
|
76
|
+
pkgJson = require.resolve('tree-sitter-wasms/package.json');
|
|
77
|
+
} catch {
|
|
78
|
+
throw new Error(`tree-sitter-wasms is not installed — ${INSTALL_HINT}`);
|
|
79
|
+
}
|
|
80
|
+
_wasmDir = path.join(path.dirname(pkgJson), 'out');
|
|
58
81
|
}
|
|
59
82
|
return _wasmDir;
|
|
60
83
|
}
|
|
@@ -72,7 +95,13 @@ function _Parser(): ParserStatic {
|
|
|
72
95
|
// Require exactly once and cache the class reference — repeated
|
|
73
96
|
// createRequire calls under tsx can hand back a module view whose static
|
|
74
97
|
// `Language` is not yet populated, which breaks the second grammar load.
|
|
75
|
-
if (_mod === null)
|
|
98
|
+
if (_mod === null) {
|
|
99
|
+
try {
|
|
100
|
+
_mod = require('web-tree-sitter') as ParserStatic;
|
|
101
|
+
} catch {
|
|
102
|
+
throw new Error(`web-tree-sitter is not installed — ${INSTALL_HINT}`);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
76
105
|
return _mod;
|
|
77
106
|
}
|
|
78
107
|
|
|
@@ -73,14 +73,9 @@ const LEGACY_TIER_MAP: Record<string, string> = {
|
|
|
73
73
|
};
|
|
74
74
|
|
|
75
75
|
const ALLOWED_TIERS = new Set(['kernel', 'tier-1', 'tier-2']);
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
'intent',
|
|
80
|
-
'file_pattern',
|
|
81
|
-
'path_prefix',
|
|
82
|
-
'command',
|
|
83
|
-
]);
|
|
76
|
+
// `intent` was removed: it never auto-matched at runtime, so declaring one gave
|
|
77
|
+
// the author a false activation path. Compiling one is now a hard error.
|
|
78
|
+
const ALLOWED_TRIGGER_KEYS = new Set(['keyword', 'phrase', 'file_pattern', 'path_prefix', 'command']);
|
|
84
79
|
|
|
85
80
|
function _parse_frontmatter(text: string): JsonObject {
|
|
86
81
|
if (!text.startsWith('---\n')) {
|
package/src/scripts/condense.ts
CHANGED
|
@@ -1176,7 +1176,7 @@ function _yaml_scalar(value: string): string {
|
|
|
1176
1176
|
* Derive host-native activation globs from a rule's `triggers:` frontmatter
|
|
1177
1177
|
* (road-to-request-scoped-rule-load Phase 2). `file_pattern` triggers map
|
|
1178
1178
|
* verbatim; `path_prefix` triggers map to `<prefix>**`. Keyword / phrase /
|
|
1179
|
-
*
|
|
1179
|
+
* command triggers produce no glob — those rules stay
|
|
1180
1180
|
* description-activated (Cursor Agent-Requested / Windsurf model_decision).
|
|
1181
1181
|
*/
|
|
1182
1182
|
export function derive_trigger_globs(meta: Record<string, unknown>): string[] {
|
|
@@ -1224,7 +1224,7 @@ export function _emit_windsurf_rule(source: string, target: string): void {
|
|
|
1224
1224
|
const description = _strip(String(meta['description'] ?? '').replace(/\n/g, ' '));
|
|
1225
1225
|
const always_apply = Boolean(meta['alwaysApply'] || meta['type'] === 'always');
|
|
1226
1226
|
// Path-shaped triggers activate host-natively via Windsurf's `glob`
|
|
1227
|
-
// trigger; keyword/
|
|
1227
|
+
// trigger; keyword/phrase-only rules keep `model_decision`.
|
|
1228
1228
|
const globs = always_apply ? [] : derive_trigger_globs(meta);
|
|
1229
1229
|
const trigger = always_apply ? 'always_on' : globs.length > 0 ? 'glob' : 'model_decision';
|
|
1230
1230
|
const lines = [
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
* regenerated on every `task sync` and must never be hand-edited.
|
|
18
18
|
*
|
|
19
19
|
* For each pack home we emit:
|
|
20
|
-
* - `<home>/pack.yaml` — id, label, owner, requires,
|
|
20
|
+
* - `<home>/pack.yaml` — id, label, owner, requires, artefact_count, …
|
|
21
21
|
* - `<home>/README.md` — pack description + per-artefact description table
|
|
22
22
|
*
|
|
23
23
|
* CLI:
|
|
@@ -40,7 +40,6 @@ const PACKAGES = path.join(ROOT, 'packages');
|
|
|
40
40
|
const SRC_DOMAINS = path.join(ROOT, 'src', 'domains');
|
|
41
41
|
const SRC_PACKS = path.join(ROOT, 'src', 'packs');
|
|
42
42
|
const PACKS_VOCAB = path.join(ROOT, 'src', 'config', 'discovery', 'packs.yml');
|
|
43
|
-
const PACKAGE_JSON = path.join(ROOT, 'package.json');
|
|
44
43
|
const GENERATED_HEADER =
|
|
45
44
|
'# Generated by scripts/generate_pack_manifests.ts — DO NOT EDIT BY HAND\n';
|
|
46
45
|
|
|
@@ -138,14 +137,6 @@ function _vocab_lookup(packsVocab: Dict[]): Record<string, Dict> {
|
|
|
138
137
|
return out;
|
|
139
138
|
}
|
|
140
139
|
|
|
141
|
-
function _package_version(): string {
|
|
142
|
-
if (!_exists(PACKAGE_JSON)) {
|
|
143
|
-
return '0.0.0';
|
|
144
|
-
}
|
|
145
|
-
const pkg = JSON.parse(fs.readFileSync(PACKAGE_JSON, 'utf-8')) as Dict;
|
|
146
|
-
return _pyTruthy(pkg.version) ? String(pkg.version) : '0.0.0';
|
|
147
|
-
}
|
|
148
|
-
|
|
149
140
|
function _artefact_record(p: string, logicalRel: string, fm: Dict): Artefact {
|
|
150
141
|
const name = _pyTruthy(fm.name)
|
|
151
142
|
? String(fm.name)
|
|
@@ -256,12 +247,7 @@ function _category_from_logical(logicalRel: string): string {
|
|
|
256
247
|
* Build the pack.yaml meta dict. Mirrors `_build_pack_yaml`. Insertion
|
|
257
248
|
* order here is irrelevant — `_py_safe_dump` sorts keys (sort_keys=True).
|
|
258
249
|
*/
|
|
259
|
-
function _build_pack_yaml(
|
|
260
|
-
pid: string,
|
|
261
|
-
vocab: Record<string, Dict>,
|
|
262
|
-
artefacts: Artefact[],
|
|
263
|
-
version: string,
|
|
264
|
-
): Dict {
|
|
250
|
+
function _build_pack_yaml(pid: string, vocab: Record<string, Dict>, artefacts: Artefact[]): Dict {
|
|
265
251
|
const meta = vocab[pid] ?? {};
|
|
266
252
|
const label = _pyTruthy(meta.label)
|
|
267
253
|
? String(meta.label)
|
|
@@ -289,7 +275,6 @@ function _build_pack_yaml(
|
|
|
289
275
|
trust_level_default: _pyTruthy(meta.trust_level_default)
|
|
290
276
|
? (meta.trust_level_default as unknown)
|
|
291
277
|
: 'core',
|
|
292
|
-
version,
|
|
293
278
|
artefact_count: artefacts.length,
|
|
294
279
|
};
|
|
295
280
|
if (_isPlainObject(meta.onboarding)) {
|
|
@@ -330,7 +315,6 @@ function _render_readme(packMeta: Dict, artefacts: Artefact[]): string {
|
|
|
330
315
|
const requires = (packMeta.requires as unknown[]) ?? [];
|
|
331
316
|
lines.push(
|
|
332
317
|
`- **id**: \`${String(packMeta.id)}\``,
|
|
333
|
-
`- **version**: \`${String(packMeta.version)}\``,
|
|
334
318
|
`- **owner**: ${owner.map(String).join(', ') || '—'}`,
|
|
335
319
|
`- **requires**: ${requires.map(String).join(', ') || '—'}`,
|
|
336
320
|
`- **artefacts**: ${String(packMeta.artefact_count)}`,
|
|
@@ -369,7 +353,6 @@ function _write_if_changed(p: string, content: string, check: boolean): boolean
|
|
|
369
353
|
export function main(argv: string[] = process.argv.slice(2)): number {
|
|
370
354
|
const check = argv.includes('--check');
|
|
371
355
|
const vocab = _vocab_lookup((_load_yaml(PACKS_VOCAB) as Dict[]) ?? []);
|
|
372
|
-
const version = _package_version();
|
|
373
356
|
const homes = _pack_homes();
|
|
374
357
|
if (homes.length === 0) {
|
|
375
358
|
process.stderr.write(
|
|
@@ -380,7 +363,7 @@ export function main(argv: string[] = process.argv.slice(2)): number {
|
|
|
380
363
|
let drift = 0;
|
|
381
364
|
for (const [pid, homeDir, mode] of homes) {
|
|
382
365
|
const artefacts = _collect_artefacts(pid, homeDir, mode);
|
|
383
|
-
const meta = _build_pack_yaml(pid, vocab, artefacts
|
|
366
|
+
const meta = _build_pack_yaml(pid, vocab, artefacts);
|
|
384
367
|
const yamlBody = GENERATED_HEADER + _py_safe_dump(meta);
|
|
385
368
|
const readmeBody = _render_readme(meta, artefacts);
|
|
386
369
|
const label = _relativeToPosix(homeDir, ROOT);
|
|
@@ -40,6 +40,7 @@ import * as path from 'node:path';
|
|
|
40
40
|
|
|
41
41
|
import { is_kernel_rule } from '../_lib/kernel_rules.js';
|
|
42
42
|
import { EDIT_TOOLS } from '../minimal_safe_diff_hook.js';
|
|
43
|
+
import { COMMAND_TOOLS } from '../verify_before_complete_hook.js';
|
|
43
44
|
import { readHookStdin } from './hook_stdin.js';
|
|
44
45
|
|
|
45
46
|
const _HERE = fileURLToPath(import.meta.url);
|
|
@@ -122,25 +123,159 @@ export function targets_kernel_rule(filePath: string): string | null {
|
|
|
122
123
|
return basename.replace(/\.md$/, '');
|
|
123
124
|
}
|
|
124
125
|
|
|
125
|
-
/**
|
|
126
|
+
/** In-place mutators that name their target as a plain argument. */
|
|
127
|
+
const _MUTATOR_VERBS: ReadonlySet<string> = new Set([
|
|
128
|
+
'sed',
|
|
129
|
+
'tee',
|
|
130
|
+
'truncate',
|
|
131
|
+
'rm',
|
|
132
|
+
'shred',
|
|
133
|
+
]);
|
|
134
|
+
/** Verbs whose LAST positional argument is the destination. */
|
|
135
|
+
const _DEST_LAST_VERBS: ReadonlySet<string> = new Set(['mv', 'cp', 'install', 'rsync']);
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Shell tokens that, in a command naming a kernel rule path, mean the path is
|
|
139
|
+
* being WRITTEN rather than read. Reads (`cat`, `grep`, `head`, `diff`,
|
|
140
|
+
* `git show`) carry none of these and stay allowed — a kernel rule is
|
|
141
|
+
* immutable, not secret.
|
|
142
|
+
*/
|
|
143
|
+
function _bash_targets_kernel_rule(command: string): string | null {
|
|
144
|
+
// Cheap reject first: no kernel-rule path in the string at all.
|
|
145
|
+
const tokens = command.split(/\s+/).filter((t) => t.length > 0);
|
|
146
|
+
const stripped = tokens.map((t) => t.replace(/^['"]|['"]$/g, ''));
|
|
147
|
+
const ruleOf = (t: string): string | null => targets_kernel_rule(t);
|
|
148
|
+
if (!stripped.some((t) => ruleOf(t) !== null)) {
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// 1. Redirection into the path: `> p`, `>> p`, `>p`, `>>p`.
|
|
153
|
+
for (let i = 0; i < stripped.length; i += 1) {
|
|
154
|
+
const tok = stripped[i] as string;
|
|
155
|
+
const inline = /^>{1,2}(.+)$/.exec(tok);
|
|
156
|
+
if (inline) {
|
|
157
|
+
const r = ruleOf(inline[1] as string);
|
|
158
|
+
if (r !== null) {
|
|
159
|
+
return r;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
if (tok === '>' || tok === '>>') {
|
|
163
|
+
const next = stripped[i + 1];
|
|
164
|
+
if (typeof next === 'string') {
|
|
165
|
+
const r = ruleOf(next);
|
|
166
|
+
if (r !== null) {
|
|
167
|
+
return r;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// 2. A mutator verb anywhere in the pipeline, with the path as an argument.
|
|
174
|
+
// `sed` only counts in its in-place form — `sed 's/x/y/' file` prints.
|
|
175
|
+
const verbs = new Set<string>();
|
|
176
|
+
let prevWasSeparator = true;
|
|
177
|
+
for (const tok of stripped) {
|
|
178
|
+
if (['&&', '||', ';', '|'].includes(tok)) {
|
|
179
|
+
prevWasSeparator = true;
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
if (prevWasSeparator && !tok.startsWith('-')) {
|
|
183
|
+
verbs.add(path.basename(tok));
|
|
184
|
+
prevWasSeparator = false;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
const sedInPlace = verbs.has('sed') && stripped.some((t) => /^-i/.test(t));
|
|
188
|
+
for (const verb of verbs) {
|
|
189
|
+
if (verb === 'sed' && !sedInPlace) {
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
if (_MUTATOR_VERBS.has(verb)) {
|
|
193
|
+
const hit = stripped.map(ruleOf).find((r) => r !== null);
|
|
194
|
+
if (hit !== undefined && hit !== null) {
|
|
195
|
+
return hit;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
if (_DEST_LAST_VERBS.has(verb)) {
|
|
199
|
+
const last = stripped[stripped.length - 1] as string;
|
|
200
|
+
const r = ruleOf(last);
|
|
201
|
+
if (r !== null) {
|
|
202
|
+
return r;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
return null;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Return (blocked, reason) for one PreToolUse envelope. Pure — no I/O beyond
|
|
211
|
+
* the arg.
|
|
212
|
+
*
|
|
213
|
+
* Two surfaces, one effect. The Write/Edit branch is the original Layer-1
|
|
214
|
+
* guard. The Bash branch was added by road-to-governance-invariants Phase 1
|
|
215
|
+
* after the S0.2 spike measured the gap: this gate keyed on the TOOL NAME, so
|
|
216
|
+
* `Bash sed -i … src/rules/commit-policy.md` reached the immutable-rule
|
|
217
|
+
* outcome the Write branch refuses, and a two-step
|
|
218
|
+
* `Write docs/staging/<kernel>.md` → `Bash mv … src/rules/<kernel>.md`
|
|
219
|
+
* sequence did the same with every step individually allowed.
|
|
220
|
+
*
|
|
221
|
+
* The Bash branch is deliberately narrow (2026-08-02 council cut, option ii):
|
|
222
|
+
* only redirection into the path, an in-place `sed`, a `tee`/`truncate`/`rm`
|
|
223
|
+
* naming it, or a `mv`/`cp` whose DESTINATION it is. Reads stay allowed — a
|
|
224
|
+
* kernel rule is immutable, not secret — and no attempt is made to understand
|
|
225
|
+
* arbitrary shell. Recognising every conceivable write verb would make this a
|
|
226
|
+
* shell sandbox, which is the failure mode the council named.
|
|
227
|
+
*/
|
|
126
228
|
export function check_envelope(envelope: JsonObject): [boolean, string] {
|
|
127
229
|
const { tool, paths } = _extract(envelope);
|
|
128
|
-
if (!tool
|
|
230
|
+
if (!tool) {
|
|
129
231
|
return [false, ''];
|
|
130
232
|
}
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
233
|
+
const deny = (ruleName: string): [boolean, string] => [
|
|
234
|
+
true,
|
|
235
|
+
`kernel rule ${ruleName} is immutable — tighten-only via the override ` +
|
|
236
|
+
'exception registry; see docs/contracts/kernel-membership.md',
|
|
237
|
+
];
|
|
238
|
+
if (EDIT_TOOLS.has(tool)) {
|
|
239
|
+
for (const p of paths) {
|
|
240
|
+
const ruleName = targets_kernel_rule(p);
|
|
241
|
+
if (ruleName !== null) {
|
|
242
|
+
return deny(ruleName);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
return [false, ''];
|
|
246
|
+
}
|
|
247
|
+
if (COMMAND_TOOLS.has(tool)) {
|
|
248
|
+
const cmd = _extract_command(envelope);
|
|
249
|
+
if (cmd !== null) {
|
|
250
|
+
const ruleName = _bash_targets_kernel_rule(cmd);
|
|
251
|
+
if (ruleName !== null) {
|
|
252
|
+
return deny(ruleName);
|
|
253
|
+
}
|
|
139
254
|
}
|
|
140
255
|
}
|
|
141
256
|
return [false, ''];
|
|
142
257
|
}
|
|
143
258
|
|
|
259
|
+
/** Best-effort read of a shell command off a PreToolUse envelope. */
|
|
260
|
+
function _extract_command(envelope: JsonObject): string | null {
|
|
261
|
+
const payload = _isObject(envelope['payload']) ? envelope['payload'] : envelope;
|
|
262
|
+
const ti = _isObject(payload['tool_input'])
|
|
263
|
+
? payload['tool_input']
|
|
264
|
+
: _isObject(envelope['tool_input'])
|
|
265
|
+
? envelope['tool_input']
|
|
266
|
+
: null;
|
|
267
|
+
for (const src of [ti, payload]) {
|
|
268
|
+
if (src === null) {
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
271
|
+
const v = src['command'];
|
|
272
|
+
if (typeof v === 'string' && v) {
|
|
273
|
+
return v;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
return null;
|
|
277
|
+
}
|
|
278
|
+
|
|
144
279
|
function _asObject(v: JsonValue | undefined): JsonObject | null {
|
|
145
280
|
return _isObject(v) ? v : null;
|
|
146
281
|
}
|
|
@@ -33,6 +33,34 @@ const _SHELL_SEPARATORS: ReadonlySet<string> = new Set(['&&', '||', ';', '|']);
|
|
|
33
33
|
const _NO_VERIFY_FLAGS: ReadonlySet<string> = new Set(['--no-verify']);
|
|
34
34
|
const _NO_VERIFY_SHORT: ReadonlySet<string> = new Set(['-n']);
|
|
35
35
|
const _HOOKS_PATH_RE = /^core\.hooksPath\s*=/i;
|
|
36
|
+
/** Bare key form, as `git config core.hooksPath <value>` spells it. */
|
|
37
|
+
const _HOOKS_PATH_KEY_RE = /^core\.hooksPath$/i;
|
|
38
|
+
/**
|
|
39
|
+
* `git config` flags that make the invocation a READ or a RESTORE rather than
|
|
40
|
+
* a write. `--unset` removes the override — the opposite of disabling hooks —
|
|
41
|
+
* so it stays allowed, or the gate would block its own remediation.
|
|
42
|
+
*/
|
|
43
|
+
/** Global `git` options that consume the following token as their value. */
|
|
44
|
+
const _GIT_GLOBAL_OPTS_WITH_VALUE: ReadonlySet<string> = new Set([
|
|
45
|
+
'-C',
|
|
46
|
+
'-c',
|
|
47
|
+
'--config',
|
|
48
|
+
'--git-dir',
|
|
49
|
+
'--work-tree',
|
|
50
|
+
'--namespace',
|
|
51
|
+
'--exec-path',
|
|
52
|
+
'--super-prefix',
|
|
53
|
+
]);
|
|
54
|
+
const _CONFIG_READ_OR_RESTORE: ReadonlySet<string> = new Set([
|
|
55
|
+
'--get',
|
|
56
|
+
'--get-all',
|
|
57
|
+
'--get-regexp',
|
|
58
|
+
'--get-urlmatch',
|
|
59
|
+
'--list',
|
|
60
|
+
'-l',
|
|
61
|
+
'--unset',
|
|
62
|
+
'--unset-all',
|
|
63
|
+
]);
|
|
36
64
|
|
|
37
65
|
type JsonValue = string | number | boolean | null | JsonValue[] | { [k: string]: JsonValue };
|
|
38
66
|
type JsonObject = { [k: string]: JsonValue };
|
|
@@ -178,7 +206,80 @@ function _git_base(tokens: string[]): string[] | null {
|
|
|
178
206
|
return null;
|
|
179
207
|
}
|
|
180
208
|
|
|
209
|
+
/**
|
|
210
|
+
* `git config … core.hooksPath <value>` — the subcommand form of the hooksPath
|
|
211
|
+
* override, distinct from the `-c` / `--config` global-option form handled in
|
|
212
|
+
* `_is_blocked`.
|
|
213
|
+
*
|
|
214
|
+
* Closed by road-to-governance-invariants Phase 1 after the S0.2 spike measured
|
|
215
|
+
* it: `git-history-discipline` already claimed `core.hooksPath` overrides were
|
|
216
|
+
* "deterministically blocked", and only the inline `-c` form was. That made
|
|
217
|
+
* `git config core.hooksPath /dev/null` → `git commit` a two-step sequence
|
|
218
|
+
* whose every step this gate allowed and whose composition is exactly the
|
|
219
|
+
* outcome the gate exists to prevent.
|
|
220
|
+
*
|
|
221
|
+
* Deliberately narrow, per the 2026-08-02 council cut (option ii — widen only
|
|
222
|
+
* the exact shapes with no plausible legitimate use):
|
|
223
|
+
* - a READ (`--get`, `--list`, or the key with no value) is allowed;
|
|
224
|
+
* - `--unset` is allowed — it restores hooks, it does not disable them;
|
|
225
|
+
* - non-git ways of disabling a hook (`mv`/`chmod`/`rm` on `.git/hooks/*`)
|
|
226
|
+
* are NOT matched here. Recognising them would turn this guard into a
|
|
227
|
+
* shell sandbox and those verbs have ordinary legitimate uses. That gap
|
|
228
|
+
* is published rather than papered over — see the S0.2 spike.
|
|
229
|
+
*/
|
|
230
|
+
function _is_config_hookspath_write(git_tokens: string[]): [boolean, string] {
|
|
231
|
+
const rest = git_tokens.slice(1);
|
|
232
|
+
// The subcommand is the first token that is neither a global option nor
|
|
233
|
+
// the VALUE of one — `git -C /repo config …` must still resolve to
|
|
234
|
+
// `config`, not to `/repo`.
|
|
235
|
+
let subIdx = -1;
|
|
236
|
+
for (let k = 0; k < rest.length; k += 1) {
|
|
237
|
+
const tok = rest[k] as string;
|
|
238
|
+
if (!tok.startsWith('-')) {
|
|
239
|
+
subIdx = k;
|
|
240
|
+
break;
|
|
241
|
+
}
|
|
242
|
+
if (_GIT_GLOBAL_OPTS_WITH_VALUE.has(tok)) {
|
|
243
|
+
k += 1; // consume its value
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
if (subIdx === -1 || rest[subIdx] !== 'config') {
|
|
247
|
+
return [false, ''];
|
|
248
|
+
}
|
|
249
|
+
const args = rest.slice(subIdx + 1);
|
|
250
|
+
if (args.some((t) => _CONFIG_READ_OR_RESTORE.has(t))) {
|
|
251
|
+
return [false, ''];
|
|
252
|
+
}
|
|
253
|
+
for (let k = 0; k < args.length; k += 1) {
|
|
254
|
+
const tok = args[k] as string;
|
|
255
|
+
// `core.hooksPath=<value>` — one token, always a write.
|
|
256
|
+
if (_HOOKS_PATH_RE.test(tok)) {
|
|
257
|
+
return [
|
|
258
|
+
true,
|
|
259
|
+
`'git config ${tok}' disables git hooks via hooksPath (git-history-discipline)`,
|
|
260
|
+
];
|
|
261
|
+
}
|
|
262
|
+
if (!_HOOKS_PATH_KEY_RE.test(tok)) {
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
// Bare key: a write only when a value follows.
|
|
266
|
+
const next = args.slice(k + 1).find((t) => !t.startsWith('-'));
|
|
267
|
+
if (next !== undefined) {
|
|
268
|
+
return [
|
|
269
|
+
true,
|
|
270
|
+
`'git config ${tok} ${next}' disables git hooks via hooksPath (git-history-discipline)`,
|
|
271
|
+
];
|
|
272
|
+
}
|
|
273
|
+
return [false, ''];
|
|
274
|
+
}
|
|
275
|
+
return [false, ''];
|
|
276
|
+
}
|
|
277
|
+
|
|
181
278
|
function _is_blocked(git_tokens: string[]): [boolean, string] {
|
|
279
|
+
const [cfgBlocked, cfgReason] = _is_config_hookspath_write(git_tokens);
|
|
280
|
+
if (cfgBlocked) {
|
|
281
|
+
return [cfgBlocked, cfgReason];
|
|
282
|
+
}
|
|
182
283
|
let i = 1; // skip 'git'
|
|
183
284
|
while (i < git_tokens.length) {
|
|
184
285
|
const tok = git_tokens[i] as string;
|
|
@@ -23,10 +23,10 @@
|
|
|
23
23
|
* Usage:
|
|
24
24
|
* ./scripts-run src/scripts/lint_agent_security [--sarif artifacts/agent-security.sarif]
|
|
25
25
|
*/
|
|
26
|
-
import { spawnSync } from "node:child_process";
|
|
27
26
|
import fs from "node:fs";
|
|
28
27
|
import path from "node:path";
|
|
29
28
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
29
|
+
import { runCountedProbe } from "./_lib/counted_probe.js";
|
|
30
30
|
|
|
31
31
|
const _HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
32
32
|
|
|
@@ -54,9 +54,10 @@ function _run(script: string): [number, Finding[]] {
|
|
|
54
54
|
// (capture_output=True, text=True equivalent). Mirrors the retired Python implementation's
|
|
55
55
|
// `subprocess.run([sys.executable, HERE/<child>.py, "--json"])`, now that the
|
|
56
56
|
// children are TypeScript and the `.py` originals are deleted.
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
57
|
+
// Bounded read: a truncated `--json` payload would fail to parse and be
|
|
58
|
+
// swallowed as zero findings — a security aggregate reporting clean because
|
|
59
|
+
// it lost the answer. `runCountedProbe` throws on overflow instead.
|
|
60
|
+
const proc = runCountedProbe(tsxBin, [path.join(_HERE, script), "--json"]);
|
|
60
61
|
let findings: Finding[];
|
|
61
62
|
try {
|
|
62
63
|
const parsed: unknown = JSON.parse((proc.stdout ?? "") || "[]");
|
|
@@ -93,8 +93,8 @@
|
|
|
93
93
|
{
|
|
94
94
|
"file": "src/skills/supply-chain-intake/SKILL.md",
|
|
95
95
|
"lines": [
|
|
96
|
-
|
|
97
|
-
|
|
96
|
+
80,
|
|
97
|
+
82
|
|
98
98
|
],
|
|
99
99
|
"reason": "Per-ecosystem check commands in fenced blocks; the pip/composer siblings sit farther apart than the ±10-line window — genuine multi-ecosystem documentation."
|
|
100
100
|
},
|
|
@@ -64,6 +64,65 @@ const STATUS_ORDER: Record<string, number> = { pass: 0, pass_with_warnings: 1, f
|
|
|
64
64
|
* baseline worktree-add failure (→ exit 2 in main). */
|
|
65
65
|
class CalledProcessError extends Error {}
|
|
66
66
|
|
|
67
|
+
/** The baseline ran but produced nothing usable (→ exit 2 in main).
|
|
68
|
+
*
|
|
69
|
+
* Distinct from the two DOCUMENTED empty-baseline degradations (no linter file
|
|
70
|
+
* in the ref; a pre-migration `.py` baseline with no python3). Those are
|
|
71
|
+
* deliberate. This one is a broken run, and returning an empty baseline for it
|
|
72
|
+
* silently reclassifies every existing finding as a "new file" — the gate then
|
|
73
|
+
* fires on an unmodified tree and can never report a real regression, because a
|
|
74
|
+
* regression needs the file present in both maps. */
|
|
75
|
+
class BaselineCollectionError extends Error {}
|
|
76
|
+
|
|
77
|
+
/** Give the detached baseline worktree a resolvable module context.
|
|
78
|
+
*
|
|
79
|
+
* `git worktree add` checks out tracked files only, so the temp tree has no
|
|
80
|
+
* `node_modules` and the linter dies on its first bare import. Symlinking the
|
|
81
|
+
* repo's own install is enough — the baseline runs the ref's linter source
|
|
82
|
+
* against the ref's artefacts; only the dependency resolution is borrowed. */
|
|
83
|
+
function _link_node_modules(tmpdir: string, repo_root: string): void {
|
|
84
|
+
const target = path.join(repo_root, 'node_modules');
|
|
85
|
+
const link = path.join(tmpdir, 'node_modules');
|
|
86
|
+
if (fs.existsSync(link) || !fs.existsSync(target)) {
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
try {
|
|
90
|
+
fs.symlinkSync(target, link, 'dir');
|
|
91
|
+
} catch {
|
|
92
|
+
// Non-fatal here: a failed link surfaces as a failed baseline run
|
|
93
|
+
// below, which raises with the real cause rather than degrading.
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Re-root the baseline's file keys onto the repo-relative paths the working-tree
|
|
98
|
+
* run emits.
|
|
99
|
+
*
|
|
100
|
+
* The baseline runs with `--repo-root <tmpdir>` and reports absolute paths under
|
|
101
|
+
* that temp worktree; the working-tree run reports paths relative to the repo.
|
|
102
|
+
* Left as-is the two maps share no key, so every comparison is vacuous — the
|
|
103
|
+
* disjoint guard would reject the run outright once the baseline is non-empty. */
|
|
104
|
+
function _relativise(data: LinterJson, tmpdir: string): LinterJson {
|
|
105
|
+
const roots = new Set<string>([tmpdir]);
|
|
106
|
+
try {
|
|
107
|
+
roots.add(fs.realpathSync(tmpdir));
|
|
108
|
+
} catch {
|
|
109
|
+
/* the temp dir is about to be removed; the literal prefix still applies */
|
|
110
|
+
}
|
|
111
|
+
const strip = (f: string): string => {
|
|
112
|
+
for (const r of roots) {
|
|
113
|
+
const prefix = r.endsWith(path.sep) ? r : r + path.sep;
|
|
114
|
+
if (f.startsWith(prefix)) {
|
|
115
|
+
return f.slice(prefix.length);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return f;
|
|
119
|
+
};
|
|
120
|
+
return {
|
|
121
|
+
...data,
|
|
122
|
+
results: (data.results ?? []).map((e) => ({ ...e, file: strip(e.file) })),
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
67
126
|
/** Run the linter and return parsed JSON. If ref is null, run on working tree.
|
|
68
127
|
*
|
|
69
128
|
* Mirrors `run_linter_json`: for a ref, add a detached temp worktree, run the
|
|
@@ -91,6 +150,7 @@ function run_linter_json(ref: string | null, repo_root: string): LinterJson {
|
|
|
91
150
|
);
|
|
92
151
|
}
|
|
93
152
|
try {
|
|
153
|
+
_link_node_modules(tmpdir, repo_root);
|
|
94
154
|
// The baseline ref may be pre-migration (`.py`, run via python3) or
|
|
95
155
|
// post-migration (`.ts`, run via tsx). Probe both extensions in both
|
|
96
156
|
// locations and spawn by extension. A historical `.py` ref genuinely
|
|
@@ -139,7 +199,17 @@ function run_linter_json(ref: string | null, repo_root: string): LinterJson {
|
|
|
139
199
|
encoding: 'utf8',
|
|
140
200
|
});
|
|
141
201
|
const out = result.stdout ?? '';
|
|
142
|
-
|
|
202
|
+
if (!out.trim()) {
|
|
203
|
+
const why =
|
|
204
|
+
result.status === 0
|
|
205
|
+
? 'it exited 0 but printed nothing'
|
|
206
|
+
: `it exited ${result.status ?? 'null'}`;
|
|
207
|
+
throw new BaselineCollectionError(
|
|
208
|
+
`the baseline linter for '${ref}' produced no output — ${why}.\n` +
|
|
209
|
+
`stderr: ${(result.stderr ?? '').trim().split('\n').slice(-6).join('\n')}`,
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
return _relativise(JSON.parse(out) as LinterJson, tmpdir);
|
|
143
213
|
} finally {
|
|
144
214
|
spawnSync('git', ['-C', repo_root, 'worktree', 'remove', '--force', tmpdir], {
|
|
145
215
|
encoding: 'utf8',
|
|
@@ -433,6 +503,10 @@ function main(): number {
|
|
|
433
503
|
);
|
|
434
504
|
return 2;
|
|
435
505
|
}
|
|
506
|
+
if (e instanceof BaselineCollectionError) {
|
|
507
|
+
process.stderr.write(`Error: ${e.message}\n`);
|
|
508
|
+
return 2;
|
|
509
|
+
}
|
|
436
510
|
throw e;
|
|
437
511
|
}
|
|
438
512
|
|
|
@@ -454,6 +528,22 @@ function main(): number {
|
|
|
454
528
|
return 2;
|
|
455
529
|
}
|
|
456
530
|
|
|
531
|
+
// The same guard's blind spot: an EMPTY baseline against a populated
|
|
532
|
+
// current tree is not comparable either, and it is the worse failure —
|
|
533
|
+
// every existing finding is then reported as a "new file", so the gate
|
|
534
|
+
// fires on an unmodified tree and no real regression can ever surface.
|
|
535
|
+
// The documented empty-baseline degradations (no linter in the ref; a
|
|
536
|
+
// pre-migration `.py` baseline with no python3) print their own warning
|
|
537
|
+
// above and land here on purpose.
|
|
538
|
+
if (!baseKeys.length && currKeys.length) {
|
|
539
|
+
process.stderr.write(
|
|
540
|
+
`Error: the baseline '${args.baseline}' produced no lint results while the ` +
|
|
541
|
+
`current tree produced ${currKeys.length} — runs are not comparable; ` +
|
|
542
|
+
'refusing to report. Every finding would be misreported as new.\n',
|
|
543
|
+
);
|
|
544
|
+
return 2;
|
|
545
|
+
}
|
|
546
|
+
|
|
457
547
|
const delta = compare(baseline_map, current_map);
|
|
458
548
|
|
|
459
549
|
if (args.format === 'json') {
|
|
@@ -528,4 +618,5 @@ export {
|
|
|
528
618
|
format_text,
|
|
529
619
|
format_markdown,
|
|
530
620
|
main,
|
|
621
|
+
_relativise,
|
|
531
622
|
};
|