@event4u/agent-config 7.3.0 → 7.5.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 (100) hide show
  1. package/.claude-plugin/marketplace.json +4 -1
  2. package/CHANGELOG.md +81 -0
  3. package/README.md +2 -2
  4. package/dist/agent-src/commands/fix/pr-comments.md +42 -13
  5. package/dist/agent-src/commands/review-changes.md +14 -7
  6. package/dist/agent-src/rules/artifact-engagement-recording.md +9 -0
  7. package/dist/agent-src/rules/cli-output-handling.md +18 -0
  8. package/dist/agent-src/rules/domain-safety-pii.md +4 -3
  9. package/dist/agent-src/rules/icon-consistency.md +1 -0
  10. package/dist/agent-src/rules/legal-safety-floor.md +2 -2
  11. package/dist/agent-src/rules/untrusted-input-defense.md +8 -5
  12. package/dist/agent-src/skills/adr-create/SKILL.md +5 -1
  13. package/dist/agent-src/skills/adversarial-review/SKILL.md +1 -1
  14. package/dist/agent-src/skills/blade-ui/SKILL.md +4 -0
  15. package/dist/agent-src/skills/brand-to-tokens/SKILL.md +2 -1
  16. package/dist/agent-src/skills/code-review/SKILL.md +4 -0
  17. package/dist/agent-src/skills/context-authoring/SKILL.md +1 -1
  18. package/dist/agent-src/skills/design-intelligence/SKILL.md +40 -0
  19. package/dist/agent-src/skills/design-review/SKILL.md +23 -11
  20. package/dist/agent-src/skills/design-system-capture/SKILL.md +41 -0
  21. package/dist/agent-src/skills/design-system-capture/reference/design-system-json.md +62 -0
  22. package/dist/agent-src/skills/design-tokens/SKILL.md +1 -0
  23. package/dist/agent-src/skills/existing-ui-audit/SKILL.md +6 -0
  24. package/dist/agent-src/skills/flux/SKILL.md +4 -0
  25. package/dist/agent-src/skills/iconography/SKILL.md +1 -0
  26. package/dist/agent-src/skills/image-editing/evals/triggers.json +13 -13
  27. package/dist/agent-src/skills/judge-artifact-completeness/SKILL.md +136 -0
  28. package/dist/agent-src/skills/judge-artifact-completeness/calibration/fixtures.json +149 -0
  29. package/dist/agent-src/skills/judge-artifact-completeness/evals/triggers.json +17 -0
  30. package/dist/agent-src/skills/judge-artifact-completeness/rubrics/architecture-score.json +73 -0
  31. package/dist/agent-src/skills/judge-artifact-completeness/rubrics/pr-review-score.json +65 -0
  32. package/dist/agent-src/skills/judge-artifact-completeness/rubrics/roadmap-score.json +73 -0
  33. package/dist/agent-src/skills/judge-artifact-completeness/rubrics/ticket-quality-score.json +73 -0
  34. package/dist/agent-src/skills/judge-injection-defense/SKILL.md +99 -0
  35. package/dist/agent-src/skills/judge-injection-defense/calibration/fixtures.json +24 -0
  36. package/dist/agent-src/skills/judge-injection-defense/evals/triggers.json +17 -0
  37. package/dist/agent-src/skills/judge-injection-defense/fixtures/behavioral-fixtures.json +63 -0
  38. package/dist/agent-src/skills/judge-injection-defense/fixtures/coverage-report.md +53 -0
  39. package/dist/agent-src/skills/judge-injection-defense/fixtures/perturbation-taxonomy.json +110 -0
  40. package/dist/agent-src/skills/judge-synthesis/SKILL.md +134 -0
  41. package/dist/agent-src/skills/judge-synthesis/evals/triggers.json +17 -0
  42. package/dist/agent-src/skills/logo-generation/SKILL.md +1 -1
  43. package/dist/agent-src/skills/markitdown/SKILL.md +1 -1
  44. package/dist/agent-src/skills/react-shadcn-ui/SKILL.md +18 -0
  45. package/dist/agent-src/skills/react-shadcn-ui/reference/registry.md +59 -0
  46. package/dist/agent-src/skills/refine-ticket/SKILL.md +5 -1
  47. package/dist/agent-src/skills/release-comms/SKILL.md +1 -1
  48. package/dist/agent-src/skills/roadmap-management/SKILL.md +4 -0
  49. package/dist/agent-src/skills/roadmap-writing/SKILL.md +1 -1
  50. package/dist/agent-src/skills/script-writing/SKILL.md +1 -1
  51. package/dist/agent-src/skills/sql-writing/SKILL.md +1 -1
  52. package/dist/agent-src/skills/systematic-debugging/SKILL.md +1 -1
  53. package/dist/agent-src/skills/tailwind-engineer/SKILL.md +4 -0
  54. package/dist/agent-src/skills/testing-anti-patterns/SKILL.md +1 -1
  55. package/dist/agent-src/skills/token-optimizer/SKILL.md +1 -1
  56. package/dist/agent-src/skills/typography-system/SKILL.md +2 -1
  57. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  58. package/dist/cli/registry.js +1 -0
  59. package/dist/cli/registry.js.map +1 -1
  60. package/dist/discovery/deprecation-report.md +1 -1
  61. package/dist/discovery/discovery-manifest.json +109 -43
  62. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  63. package/dist/discovery/discovery-manifest.summary.md +3 -3
  64. package/dist/discovery/orphan-report.md +1 -1
  65. package/dist/discovery/packs.json +8 -5
  66. package/dist/discovery/trust-report.md +2 -2
  67. package/dist/discovery/workspaces.json +7 -4
  68. package/dist/mcp/registry-manifest.json +3 -3
  69. package/dist/router.json +1 -1
  70. package/dist/server/schemas/settings.js +6 -0
  71. package/dist/server/schemas/settings.js.map +1 -1
  72. package/docs/architecture.md +2 -2
  73. package/docs/catalog.md +28 -20
  74. package/docs/command-flows.md +1 -1
  75. package/docs/enforcement-by-host.md +9 -0
  76. package/docs/governance-advantage.md +72 -0
  77. package/docs/guidelines/design-antipatterns.md +26 -0
  78. package/docs/guidelines/design-canon.md +62 -0
  79. package/package.json +2 -1
  80. package/src/config/agent-settings.template.yml +21 -0
  81. package/src/scripts/_cli/cmd_analyze_session.ts +347 -0
  82. package/src/scripts/_dispatch.bash +8 -0
  83. package/src/scripts/_lib/token_count.ts +110 -35
  84. package/src/scripts/audit_initial_context.ts +37 -12
  85. package/src/scripts/bench_quality_run.ts +253 -0
  86. package/src/scripts/bench_rule_load_latency.ts +178 -0
  87. package/src/scripts/check_kernel_prefix_stability.ts +0 -0
  88. package/src/scripts/check_quality_regression.ts +244 -0
  89. package/src/scripts/check_token_quality_golden.ts +253 -0
  90. package/src/scripts/check_token_regression.ts +266 -0
  91. package/src/scripts/design_slop_rules.test.ts +193 -0
  92. package/src/scripts/design_slop_rules.ts +502 -0
  93. package/src/scripts/external_sources_denylist.json +7 -1
  94. package/src/scripts/hook_manifest.yaml +26 -3
  95. package/src/scripts/hooks/design_slop_hook.ts +181 -0
  96. package/src/scripts/hooks/rtk_wrap_hook.ts +228 -0
  97. package/src/scripts/lint_agent_security.ts +1 -0
  98. package/src/scripts/lint_confusables.ts +197 -0
  99. package/src/scripts/lint_design_slop.ts +245 -0
  100. package/src/scripts/probe_host_compliance.ts +145 -0
@@ -0,0 +1,181 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * PreToolUse anti-slop nudge — surfaces high-severity aesthetic tells in
4
+ * proposed UI content BEFORE the write (road-to-anti-slop-detector Phase 3).
5
+ *
6
+ * OPTIONAL, host-limited (pre_tool_use fires on ~2–3 hosts). The universal
7
+ * source of truth is the `lint_design_slop` linter / CI gate; this hook is a
8
+ * convenience layer that runs the same registry against the about-to-be-written
9
+ * content. Default-OFF: no-ops unless `hooks.design_slop.enabled: true` in
10
+ * `.agent-settings.yml`. fail_closed: false (never break a write for a flag).
11
+ *
12
+ * FLAGS, NEVER A BLOCK. On a P0/P1 finding it WARNS (exit 2 + reason); it never
13
+ * returns block (exit 1). Mirrors the injection-scan "warn, don't block" shape.
14
+ *
15
+ * Anti-loop degradation (council-required safety valve): a `file::rule`
16
+ * signature surfaced ≥ DEGRADE_AFTER times is downgraded to silent, so a
17
+ * deliberate-but-undeclared pattern the agent keeps re-writing never traps it.
18
+ * State: <root>/agents/runtime/state/design-slop-hook.json.
19
+ *
20
+ * Exit codes (dispatcher contract): 0 allow · 2 warn (+ JSON reason on stdout).
21
+ */
22
+ import fs from "node:fs";
23
+ import path from "node:path";
24
+ import { pathToFileURL } from "node:url";
25
+ import { type DesignContext } from "../design_slop_rules.js";
26
+ import { loadDesignContext, scanFile } from "../lint_design_slop.js";
27
+
28
+ const SETTINGS_FILE = ".agent-settings.yml";
29
+ const EXIT_ALLOW = 0;
30
+ const EXIT_WARN = 2;
31
+ const DEGRADE_AFTER = 3; // surfaces of the same file::rule signature before going silent
32
+ const UI_EXT = /\.(html|htm|css|scss|sass|less|vue|svelte|astro|jsx|tsx)$/i;
33
+
34
+ type JsonValue = string | number | boolean | null | JsonValue[] | { [k: string]: JsonValue };
35
+ type JsonObject = { [k: string]: JsonValue };
36
+
37
+ function isObject(v: unknown): v is JsonObject {
38
+ return typeof v === "object" && v !== null && !Array.isArray(v);
39
+ }
40
+
41
+ /** Minimal `.agent-settings.yml` reader for hooks.design_slop.enabled (mirrors injection-scan). */
42
+ function enabled(root: string): boolean {
43
+ const f = path.join(root, SETTINGS_FILE);
44
+ let text: string;
45
+ try {
46
+ if (!fs.statSync(f).isFile()) return false;
47
+ text = fs.readFileSync(f, "utf-8");
48
+ } catch {
49
+ return false;
50
+ }
51
+ let inHooks = false;
52
+ let inDs = false;
53
+ for (const raw of text.split(/\r\n|\r|\n/)) {
54
+ const line = raw.replace(/\s+$/, "");
55
+ if (!line || line.replace(/^\s+/, "").startsWith("#")) continue;
56
+ if (!(line.startsWith(" ") || line.startsWith("\t"))) {
57
+ inHooks = /^hooks\s*:\s*$/.test(line);
58
+ inDs = false;
59
+ continue;
60
+ }
61
+ if (inHooks) {
62
+ if (/^\s+design_slop\s*:\s*$/.test(line)) {
63
+ inDs = true;
64
+ continue;
65
+ }
66
+ if (inDs && /^\s{0,3}\S/.test(line)) inDs = false;
67
+ }
68
+ if (inDs && /^\s+enabled\s*:\s*true\b/.test(line)) return true;
69
+ }
70
+ return false;
71
+ }
72
+
73
+ /** Best-effort: pull (toolName, filePath, proposedContent) from the PreToolUse envelope. */
74
+ function extract(envelope: JsonObject): { file: string; content: string } | null {
75
+ const ti = envelope["tool_input"] ?? envelope["toolInput"] ?? envelope["input"];
76
+ if (!isObject(ti)) return null;
77
+ const fileVal = ti["file_path"] ?? ti["path"] ?? ti["filePath"];
78
+ const file = typeof fileVal === "string" ? fileVal : "";
79
+ // Edit → new_string/new_str; Write → content; some hosts → text
80
+ for (const key of ["content", "new_string", "new_str", "text", "newText"]) {
81
+ const v = ti[key];
82
+ if (typeof v === "string" && v.length > 0) return { file, content: v };
83
+ }
84
+ return null;
85
+ }
86
+
87
+ function stateFile(root: string): string {
88
+ return path.join(root, "agents", "runtime", "state", "design-slop-hook.json");
89
+ }
90
+
91
+ function readState(root: string): Record<string, number> {
92
+ try {
93
+ const t = fs.readFileSync(stateFile(root), "utf-8");
94
+ const parsed = JSON.parse(t);
95
+ return isObject(parsed) ? (parsed as Record<string, number>) : {};
96
+ } catch {
97
+ return {};
98
+ }
99
+ }
100
+
101
+ function writeState(root: string, state: Record<string, number>): void {
102
+ try {
103
+ const sf = stateFile(root);
104
+ fs.mkdirSync(path.dirname(sf), { recursive: true });
105
+ fs.writeFileSync(sf, JSON.stringify(state, null, 2) + "\n");
106
+ } catch {
107
+ /* fail-open: never break a write because state could not persist */
108
+ }
109
+ }
110
+
111
+ function readStdin(): string {
112
+ try {
113
+ return fs.readFileSync(0, "utf-8");
114
+ } catch {
115
+ return "";
116
+ }
117
+ }
118
+
119
+ export function main(): number {
120
+ let envelope: JsonValue;
121
+ try {
122
+ const raw = readStdin();
123
+ envelope = raw.trim() ? (JSON.parse(raw) as JsonValue) : {};
124
+ } catch {
125
+ return EXIT_ALLOW;
126
+ }
127
+ if (!isObject(envelope)) return EXIT_ALLOW;
128
+
129
+ const rootVal = envelope["cwd"] ?? envelope["project_root"] ?? ".";
130
+ const root = typeof rootVal === "string" && rootVal ? rootVal : ".";
131
+ if (!enabled(root)) return EXIT_ALLOW;
132
+
133
+ const ex = extract(envelope);
134
+ if (!ex || !ex.file || !UI_EXT.test(ex.file)) return EXIT_ALLOW;
135
+
136
+ let ctx: DesignContext;
137
+ try {
138
+ ctx = loadDesignContext(path.dirname(path.resolve(root, ex.file)));
139
+ } catch {
140
+ ctx = { raw: "", has: () => false };
141
+ }
142
+
143
+ let findings;
144
+ try {
145
+ findings = scanFile(ex.content, ex.file, ctx).filter(
146
+ (f) => f.severity === "P0" || f.severity === "P1",
147
+ );
148
+ } catch {
149
+ return EXIT_ALLOW;
150
+ }
151
+ if (findings.length === 0) return EXIT_ALLOW;
152
+
153
+ // Anti-loop degradation: drop any signature already surfaced DEGRADE_AFTER times.
154
+ const state = readState(root);
155
+ const surfaced = findings.filter((f) => {
156
+ const sig = `${ex.file}::${f.rule}`;
157
+ const seen = state[sig] ?? 0;
158
+ if (seen >= DEGRADE_AFTER) return false; // gone silent — never trap the agent
159
+ state[sig] = seen + 1;
160
+ return true;
161
+ });
162
+ writeState(root, state);
163
+
164
+ if (surfaced.length === 0) return EXIT_ALLOW;
165
+
166
+ const lines = surfaced
167
+ .map((f) => `${f.catalogId} (${f.rule}) ${f.file}:${f.line} — ${f.message}`)
168
+ .join("; ");
169
+ const reason =
170
+ "⚠️ Anti-slop: high-severity aesthetic tell(s) in the proposed UI — " +
171
+ lines +
172
+ ". Rebuttable: declare intent in DESIGN.md, add design-slop-disable-next-line, " +
173
+ "or proceed if deliberate (this is a flag, not a block).";
174
+ process.stdout.write(JSON.stringify({ decision: "warn", reason }) + "\n");
175
+ return EXIT_WARN;
176
+ }
177
+
178
+ const isCliEntry =
179
+ process.argv[1] !== undefined &&
180
+ import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href;
181
+ if (isCliEntry) process.exit(main());
@@ -0,0 +1,228 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * PreToolUse RTK-wrap nudge — deterministic, warn-in-context
4
+ * (token-saving Phase 3).
5
+ *
6
+ * Converts RTK from advisory to DETERMINISTIC: instead of trusting a
7
+ * self-reported flag, this hook keys off a live `which rtk` probe. When rtk is
8
+ * actually installed AND the agent is about to run a single verbose CLI command
9
+ * that is NOT completeness-critical, it surfaces (warn) "re-run wrapped with
10
+ * rtk" so the 60–90% output-token saving is captured. It NEVER blocks (the v1
11
+ * dispatcher contract is allow/block/warn — there is no transparent
12
+ * `updatedInput` rewrite — and, like `injection_scan_hook`, warn preserves
13
+ * agency). It NEVER fires when rtk is absent (silent plain-command fallback,
14
+ * no nag).
15
+ *
16
+ * Default-OFF. Fires only when `hooks.rtk_wrap.enabled: true` in
17
+ * `.agent-settings.yml`. Disabled / missing / rtk-absent → no-op exit 0.
18
+ * fail_closed: false — any parse/probe error returns allow (never break a
19
+ * command for a token optimization).
20
+ *
21
+ * Denylist (never nudged — completeness-critical or already-handled):
22
+ * - `git diff` / `rtk read` (silent truncation risk — see the rtk skill)
23
+ * - already `rtk …` wrapped, or piped / compound (`|`, `&&`, `||`, `;`)
24
+ * - any program not in the verbose-CLI allowlist
25
+ *
26
+ * Exit codes (dispatcher contract): 0 allow · 2 warn (+ JSON reason on stdout).
27
+ */
28
+ import fs from "node:fs";
29
+ import path from "node:path";
30
+ import { pathToFileURL } from "node:url";
31
+
32
+ import {
33
+ shlexSplit,
34
+ ShlexError,
35
+ _split_subcommands,
36
+ _is_env_assignment,
37
+ } from "./block_no_verify.js";
38
+
39
+ const SETTINGS_FILE = ".agent-settings.yml";
40
+ const EXIT_ALLOW = 0;
41
+ const EXIT_WARN = 2;
42
+
43
+ type JsonValue = string | number | boolean | null | JsonValue[] | { [k: string]: JsonValue };
44
+ type JsonObject = { [k: string]: JsonValue };
45
+
46
+ // Verbose-CLI programs worth wrapping — mirrors the cli-output-handling rule
47
+ // triggers + the rtk skill's "✅ Always" table. Kept in sync with
48
+ // src/rules/cli-output-handling.md.
49
+ const _RTK_PROGRAMS: ReadonlySet<string> = new Set([
50
+ "git", "phpstan", "rector", "phpunit", "composer",
51
+ "npm", "pnpm", "yarn", "eslint", "tsc", "vitest", "jest",
52
+ "pytest", "ruff", "mypy", "pyright", "cargo", "go",
53
+ "golangci-lint", "docker", "kubectl", "terraform",
54
+ ]);
55
+
56
+ function _isObject(v: unknown): v is JsonObject {
57
+ return typeof v === "object" && v !== null && !Array.isArray(v);
58
+ }
59
+
60
+ /** `hooks.rtk_wrap.enabled: true` mini-parser (mirrors injection_scan_hook). */
61
+ function _enabled(root: string): boolean {
62
+ const f = path.join(root, SETTINGS_FILE);
63
+ try {
64
+ if (!fs.statSync(f).isFile()) return false;
65
+ } catch {
66
+ return false;
67
+ }
68
+ let text: string;
69
+ try {
70
+ text = fs.readFileSync(f, "utf-8");
71
+ } catch {
72
+ return false;
73
+ }
74
+ let in_hooks = false;
75
+ let in_rw = false;
76
+ const lines = text.split(/\r\n|\r|\n/);
77
+ if (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
78
+ for (const raw of lines) {
79
+ const line = raw.replace(/\s+$/, "");
80
+ if (!line || line.replace(/^\s+/, "").startsWith("#")) continue;
81
+ if (!(line.startsWith(" ") || line.startsWith("\t"))) {
82
+ in_hooks = /^hooks\s*:\s*$/.test(line);
83
+ in_rw = false;
84
+ continue;
85
+ }
86
+ if (in_hooks) {
87
+ if (/^\s+rtk_wrap\s*:\s*$/.test(line)) {
88
+ in_rw = true;
89
+ continue;
90
+ }
91
+ if (in_rw && /^\s{0,3}\S/.test(line)) in_rw = false;
92
+ }
93
+ if (in_rw && /^\s+enabled\s*:\s*true\b/.test(line)) return true;
94
+ }
95
+ return false;
96
+ }
97
+
98
+ /** Live `which rtk` — scan PATH for an executable `rtk` (cross-platform). */
99
+ export function rtk_available(env: NodeJS.ProcessEnv = process.env): boolean {
100
+ const pathVar = env.PATH ?? env.Path ?? "";
101
+ if (!pathVar) return false;
102
+ const dirs = pathVar.split(path.delimiter).filter(Boolean);
103
+ // On Windows the launcher is rtk.exe / rtk.cmd / rtk.bat.
104
+ const exts = process.platform === "win32" ? ["", ".exe", ".cmd", ".bat"] : [""];
105
+ for (const dir of dirs) {
106
+ for (const ext of exts) {
107
+ const candidate = path.join(dir, `rtk${ext}`);
108
+ try {
109
+ const st = fs.statSync(candidate);
110
+ if (st.isFile()) {
111
+ // POSIX: require an executable bit; Windows: extension is enough.
112
+ if (process.platform === "win32" || (st.mode & 0o111) !== 0) return true;
113
+ }
114
+ } catch {
115
+ /* not here — keep scanning */
116
+ }
117
+ }
118
+ }
119
+ return false;
120
+ }
121
+
122
+ function _extract_command(envelope: JsonObject): string | null {
123
+ const payload = (_isObject(envelope["payload"]) ? envelope["payload"] : {}) as JsonObject;
124
+ const tool_input = (_isObject(payload["tool_input"]) ? payload["tool_input"] : {}) as JsonObject;
125
+ for (const src of [tool_input["command"], payload["command"], envelope["command"]]) {
126
+ if (typeof src === "string") return src;
127
+ }
128
+ return null;
129
+ }
130
+
131
+ export interface Eligibility {
132
+ eligible: boolean;
133
+ program?: string;
134
+ reason_skip?: string;
135
+ }
136
+
137
+ /**
138
+ * A command is eligible for an rtk-wrap nudge iff it is a SINGLE simple command
139
+ * (no pipe / compound separators), whose program is a known verbose CLI, and is
140
+ * not a completeness-critical / already-wrapped case.
141
+ */
142
+ export function classify(command: string): Eligibility {
143
+ const cmd = command.trim();
144
+ if (!cmd) return { eligible: false, reason_skip: "empty" };
145
+
146
+ let tokens: string[];
147
+ try {
148
+ tokens = shlexSplit(cmd);
149
+ } catch (e) {
150
+ if (e instanceof ShlexError) return { eligible: false, reason_skip: "parse-error" };
151
+ throw e;
152
+ }
153
+
154
+ // Compound / piped → don't wrap (rtk wraps a single command). `_split_subcommands`
155
+ // splits on && || ; | — more than one group means a pipeline/chain.
156
+ const groups = _split_subcommands(tokens);
157
+ if (groups.length !== 1) return { eligible: false, reason_skip: "compound-or-piped" };
158
+
159
+ const sub = groups[0] as string[];
160
+ let i = 0;
161
+ while (i < sub.length && _is_env_assignment(sub[i] as string)) i += 1;
162
+ const program = sub[i];
163
+ if (!program) return { eligible: false, reason_skip: "no-program" };
164
+ if (program === "rtk") return { eligible: false, reason_skip: "already-rtk" };
165
+ if (!_RTK_PROGRAMS.has(program)) return { eligible: false, program, reason_skip: "not-verbose-cli" };
166
+ // `git diff` is completeness-critical (silent truncation) — never wrap.
167
+ if (program === "git" && sub[i + 1] === "diff") {
168
+ return { eligible: false, program, reason_skip: "git-diff-denylisted" };
169
+ }
170
+ return { eligible: true, program };
171
+ }
172
+
173
+ function _readStdin(): string {
174
+ try {
175
+ if (process.stdin.isTTY) return "";
176
+ return fs.readFileSync(0, "utf-8");
177
+ } catch {
178
+ return "";
179
+ }
180
+ }
181
+
182
+ function _jsonReason(reason: string): string {
183
+ return JSON.stringify({ decision: "warn", reason });
184
+ }
185
+
186
+ export function main(): number {
187
+ let envelope: JsonValue;
188
+ try {
189
+ const raw = _readStdin();
190
+ envelope = raw.trim() ? (JSON.parse(raw) as JsonValue) : {};
191
+ } catch {
192
+ return EXIT_ALLOW; // never break a command on a malformed envelope
193
+ }
194
+ if (!_isObject(envelope)) return EXIT_ALLOW;
195
+
196
+ const cwd = envelope["cwd"];
197
+ const projectRoot = envelope["workspace_root"] ?? envelope["project_root"];
198
+ const root =
199
+ typeof cwd === "string" && cwd
200
+ ? cwd
201
+ : typeof projectRoot === "string" && projectRoot
202
+ ? projectRoot
203
+ : ".";
204
+ if (!_enabled(root)) return EXIT_ALLOW;
205
+
206
+ // Deterministic gate: the live probe, NOT a self-reported flag.
207
+ if (!rtk_available()) return EXIT_ALLOW;
208
+
209
+ const command = _extract_command(envelope);
210
+ if (!command) return EXIT_ALLOW;
211
+
212
+ const verdict = classify(command);
213
+ if (!verdict.eligible) return EXIT_ALLOW;
214
+
215
+ const reason =
216
+ `rtk is installed and wraps verbose CLI output (60–90% fewer output tokens). ` +
217
+ `Re-run wrapped: \`rtk ${command.trim()}\`. ` +
218
+ `(Skip for completeness-critical output like \`git diff\`.)`;
219
+ process.stdout.write(`${_jsonReason(reason)}\n`);
220
+ return EXIT_WARN;
221
+ }
222
+
223
+ const _isCliEntry =
224
+ process.argv[1] !== undefined &&
225
+ import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href;
226
+ if (_isCliEntry) {
227
+ process.exit(main());
228
+ }
@@ -43,6 +43,7 @@ type Finding = Record<string, unknown>;
43
43
 
44
44
  const LINTERS: ReadonlyArray<[string, string]> = [
45
45
  ["hidden-unicode", "lint_hidden_unicode.ts"],
46
+ ["mixed-script-confusable", "lint_confusables.ts"],
46
47
  ["instruction-smuggling", "lint_instruction_smuggling.ts"],
47
48
  ["mcp-config-security", "lint_mcp_config_security.ts"],
48
49
  ["dangerous-frontmatter", "lint_skill_frontmatter_safety.ts"],
@@ -0,0 +1,197 @@
1
+ // Visible mixed-script confusable / homoglyph linter
2
+ // (road-to-injection-defense-pressure-corpus.md, Phase 1.2).
3
+ //
4
+ // Sibling of `lint_hidden_unicode.ts`. That linter catches the INVISIBLE
5
+ // smuggling class (zero-width, bidi, tag-block). This one catches the VISIBLE
6
+ // class it misses: a Latin word with one or more letters swapped for
7
+ // visually-identical Cyrillic or Greek lookalikes (mixed-script token) — the
8
+ // homoglyph version of the rules-file-backdoor attack. A reviewer sees
9
+ // "ignore"; the model reads a token whose 'o' is U+043E CYRILLIC SMALL LETTER O.
10
+ //
11
+ // Detection signature (high-precision, low-false-positive):
12
+ // A whitespace-delimited token is flagged when ALL hold —
13
+ // 1. it contains >= 1 Latin letter AND >= 1 Cyrillic-or-Greek letter that
14
+ // is in the TR39 confusable set (has a Latin lookalike). Math operators
15
+ // with NO Latin lookalike (Δ Σ Π Ω Φ Θ Λ Ξ Ψ Γ …) are NOT confusables,
16
+ // so legit notation like "ΔNWC" / "Σwᵢ" is never flagged,
17
+ // 2. it has >= MIN_LETTERS total letters, and
18
+ // 3. Latin is the MAJORITY script (the attack is a Latin word with foreign
19
+ // lookalike substitutions, not a foreign word with one Latin letter).
20
+ // Pure-Cyrillic / pure-Greek / CJK prose is single-script per token and is
21
+ // never flagged — legit non-Latin text passes.
22
+ //
23
+ // Scope + containment mirror lint_hidden_unicode: scans every .md under
24
+ // src/{skills,rules,agent-src,domains} via sl.iter_corpus(); a
25
+ // ```security-example fence or a `security-lint: allow mixed-script-confusable`
26
+ // pragma exempts a region/file (a doc teaching confusables needs to show one).
27
+ //
28
+ // Exit 0 clean, 1 on any blocking finding.
29
+ //
30
+ // Usage: ./scripts-run src/scripts/lint_confusables [--json]
31
+
32
+ import * as path from 'node:path';
33
+ import { pathToFileURL } from 'node:url';
34
+
35
+ import * as sl from './_lib/security_lint.js';
36
+
37
+ export const CHECK = 'mixed-script-confusable';
38
+
39
+ const MIN_LETTERS = 3;
40
+
41
+ // Unicode-script classifiers via property escapes (requires the `u` flag).
42
+ const _LATIN = /\p{Script=Latin}/u;
43
+ const _CYRILLIC = /\p{Script=Cyrillic}/u;
44
+ const _GREEK = /\p{Script=Greek}/u;
45
+ const _LETTER = /\p{L}/u;
46
+ // A "token" is a maximal run of letters / combining marks / decimal digits.
47
+ const _TOKEN = /[\p{L}\p{M}\p{Nd}]+/gu;
48
+
49
+ // Cyrillic + Greek codepoints that have a basic-Latin lookalike (Unicode TR39
50
+ // confusables, common subset). A foreign letter only counts toward the
51
+ // confusable signal when it is in this set — Greek math operators with no Latin
52
+ // twin (Δ Σ Π Ω Φ Θ Λ Ξ Ψ Γ δ π ω …) are deliberately excluded so legit
53
+ // notation does not false-positive.
54
+ const _CONFUSABLE_FOREIGN: ReadonlySet<number> = new Set([
55
+ // Cyrillic lowercase ↔ Latin
56
+ 0x0430 /*а→a*/, 0x0435 /*е→e*/, 0x043e /*о→o*/, 0x0440 /*р→p*/, 0x0441 /*с→c*/,
57
+ 0x0443 /*у→y*/, 0x0445 /*х→x*/, 0x0455 /*ѕ→s*/, 0x0456 /*і→i*/, 0x0458 /*ј→j*/,
58
+ 0x04bb /*һ→h*/, 0x0501 /*ԁ→d*/, 0x051b /*ԛ→q*/, 0x0577 /*no*/,
59
+ // Cyrillic uppercase ↔ Latin
60
+ 0x0410 /*А→A*/, 0x0412 /*В→B*/, 0x0415 /*Е→E*/, 0x041a /*К→K*/, 0x041c /*М→M*/,
61
+ 0x041d /*Н→H*/, 0x041e /*О→O*/, 0x0420 /*Р→P*/, 0x0421 /*С→C*/, 0x0422 /*Т→T*/,
62
+ 0x0423 /*У→Y*/, 0x0425 /*Х→X*/, 0x0405 /*Ѕ→S*/, 0x0406 /*І→I*/, 0x0408 /*Ј→J*/,
63
+ // Greek lowercase ↔ Latin
64
+ 0x03b1 /*α→a*/, 0x03b5 /*ε→e*/, 0x03bf /*ο→o*/, 0x03bd /*ν→v*/, 0x03c1 /*ρ→p*/,
65
+ 0x03c5 /*υ→u*/, 0x03c7 /*χ→x*/, 0x03ba /*κ→k*/,
66
+ // Greek uppercase ↔ Latin (only those with a real Latin twin)
67
+ 0x0391 /*Α→A*/, 0x0392 /*Β→B*/, 0x0395 /*Ε→E*/, 0x0396 /*Ζ→Z*/, 0x0397 /*Η→H*/,
68
+ 0x0399 /*Ι→I*/, 0x039a /*Κ→K*/, 0x039c /*Μ→M*/, 0x039d /*Ν→N*/, 0x039f /*Ο→O*/,
69
+ 0x03a1 /*Ρ→P*/, 0x03a4 /*Τ→T*/, 0x03a5 /*Υ→Y*/, 0x03a7 /*Χ→X*/, 0x0392 /*Β→B*/,
70
+ ]);
71
+
72
+ type ScriptName = 'latin' | 'cyrillic' | 'greek' | 'other';
73
+
74
+ function _script_of(ch: string): ScriptName {
75
+ if (_LATIN.test(ch)) return 'latin';
76
+ if (_CYRILLIC.test(ch)) return 'cyrillic';
77
+ if (_GREEK.test(ch)) return 'greek';
78
+ return 'other';
79
+ }
80
+
81
+ /**
82
+ * Classify a single token. Returns the offending mixed-script descriptor when
83
+ * the token matches the homoglyph signature, else null.
84
+ */
85
+ export function _classify_token(token: string): string | null {
86
+ let latin = 0;
87
+ let cyrillic = 0;
88
+ let greek = 0;
89
+ let letters = 0;
90
+ for (const ch of token) {
91
+ if (!_LETTER.test(ch)) continue; // skip combining marks / digits for script vote
92
+ letters += 1;
93
+ const script = _script_of(ch);
94
+ if (script === 'latin') {
95
+ latin += 1;
96
+ } else if (script === 'cyrillic' || script === 'greek') {
97
+ // Only confusable foreign letters (TR39) count — math operators with
98
+ // no Latin twin are ignored, so legit notation never trips.
99
+ if (_CONFUSABLE_FOREIGN.has(ch.codePointAt(0) as number)) {
100
+ if (script === 'cyrillic') cyrillic += 1;
101
+ else greek += 1;
102
+ }
103
+ }
104
+ }
105
+ if (letters < MIN_LETTERS) return null;
106
+ const foreign = cyrillic + greek;
107
+ if (latin === 0 || foreign === 0) return null; // single-script (or no Latin) → legit
108
+ if (latin <= foreign) return null; // majority must be Latin (foreign word ≠ attack)
109
+ const which = cyrillic > 0 && greek > 0
110
+ ? 'cyrillic+greek'
111
+ : cyrillic > 0
112
+ ? 'cyrillic'
113
+ : 'greek';
114
+ return `latin+${which} (${latin} latin / ${foreign} foreign-confusable)`;
115
+ }
116
+
117
+ export function _scan(sf: sl.ScannedFile): sl.Finding[] {
118
+ if (sf.pragma_allows(CHECK)) {
119
+ return [];
120
+ }
121
+ const out: sl.Finding[] = [];
122
+ for (const [lineno, text] of sf.iter_lines({ skip_example_fence: true })) {
123
+ const tokens = text.match(_TOKEN);
124
+ if (tokens === null) continue;
125
+ for (const tok of tokens) {
126
+ const desc = _classify_token(tok);
127
+ if (desc !== null) {
128
+ out.push(
129
+ new sl.Finding(
130
+ sf.rel,
131
+ lineno,
132
+ CHECK,
133
+ 'HIGH',
134
+ `mixed-script confusable token "${tok}" — ${desc}`,
135
+ sf.weight,
136
+ ),
137
+ );
138
+ }
139
+ }
140
+ }
141
+ return out;
142
+ }
143
+
144
+ interface Args {
145
+ json: boolean;
146
+ }
147
+
148
+ function _argError(msg: string): never {
149
+ process.stderr.write('usage: lint_confusables [-h] [--json]\n');
150
+ process.stderr.write(`lint_confusables: error: ${msg}\n`);
151
+ process.exit(2);
152
+ }
153
+
154
+ function parse_args(argv: readonly string[]): Args {
155
+ const out: Args = { json: false };
156
+ const extra: string[] = [];
157
+ for (const a of argv) {
158
+ if (a === '-h' || a === '--help') {
159
+ process.stdout.write('usage: lint_confusables [-h] [--json]\n');
160
+ process.exit(0);
161
+ } else if (a === '--json') {
162
+ out.json = true;
163
+ } else {
164
+ extra.push(a);
165
+ }
166
+ }
167
+ if (extra.length > 0) {
168
+ _argError(`unrecognized arguments: ${extra.join(' ')}`);
169
+ }
170
+ return out;
171
+ }
172
+
173
+ export function main(argv: readonly string[] | null = null): number {
174
+ const args = parse_args(argv ?? process.argv.slice(2));
175
+
176
+ const findings: sl.Finding[] = [];
177
+ for (const sf of sl.iter_corpus()) {
178
+ for (const h of _scan(sf)) {
179
+ findings.push(h);
180
+ }
181
+ }
182
+
183
+ if (args.json) {
184
+ const payload = sl.py_json_dumps_indent2(findings.map((f) => f.toDict()));
185
+ process.stdout.write(payload + '\n');
186
+ return findings.some((f) => f.is_fail) ? 1 : 0;
187
+ }
188
+
189
+ return sl.report(findings, { check_label: 'mixed-script-confusable' });
190
+ }
191
+
192
+ const _isCliEntry =
193
+ process.argv[1] !== undefined &&
194
+ import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href;
195
+ if (_isCliEntry) {
196
+ process.exitCode = main();
197
+ }