karajan-code 4.52.0 → 4.53.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/package.json +2 -1
- package/scripts/contract-claude.mjs +24 -0
- package/scripts/esbuild-sea.config.mjs +7 -0
- package/scripts/record-agent-fixtures.mjs +98 -0
- package/src/agents/claude-agent.js +8 -0
- package/src/agents/model-registry.js +5 -0
- package/src/agents/opencode-agent.js +16 -1
- package/src/audit/issue-filter.js +19 -1
- package/src/checks/audit-cycle.js +41 -0
- package/src/checks/claude-contract-check.js +27 -0
- package/src/checks/method.js +3 -2
- package/src/checks/release-check.js +7 -1
- package/src/checks/sonar.js +30 -12
- package/src/checks/supply-chain-check.js +47 -0
- package/src/cli/register-meta.js +6 -1
- package/src/cli/register-pipeline.js +9 -0
- package/src/cli/register-plan.js +10 -0
- package/src/commands/check.js +11 -1
- package/src/commands/config.js +2 -0
- package/src/commands/harden.js +3 -0
- package/src/commands/hu.js +24 -3
- package/src/commands/identity.js +4 -1
- package/src/commands/init.js +34 -1
- package/src/commands/plan/generate.js +5 -0
- package/src/commands/plan/license.js +35 -0
- package/src/commands/plan/ready.js +1 -1
- package/src/commands/plan/validate.js +1 -1
- package/src/commands/report.js +14 -0
- package/src/commands/review-gate.js +22 -0
- package/src/commands/worktree.js +15 -6
- package/src/config/defaults.js +10 -1
- package/src/config/overrides.js +8 -0
- package/src/config/schema.js +7 -0
- package/src/harden/act-sign.js +2 -2
- package/src/harden/branch-protection.js +97 -0
- package/src/harden/phone-invite.js +15 -8
- package/src/harden/phone-texts.js +17 -0
- package/src/harden/security-level.js +18 -2
- package/src/harden/sentinel-hooks.js +2 -1
- package/src/hu/complexity-tier.js +32 -0
- package/src/hu/lane-prune.js +24 -0
- package/src/hu/model-router.js +7 -16
- package/src/hu/routing-metrics.js +16 -0
- package/src/hu/store.js +2 -0
- package/src/orchestrator/drivers/pre-loop-phases/inject-loaded-plan.js +2 -0
- package/src/orchestrator/hu-sub-pipeline.js +19 -0
- package/src/orchestrator/stages/hu-reviewer-stage.js +10 -2
- package/src/orchestrator/stages/sonar-stage.js +19 -1
- package/src/plan/plan-schema.js +4 -1
- package/src/plan/rework-license.js +38 -0
- package/src/prompts/hu-reviewer.js +5 -2
- package/src/prompts/planner.js +9 -1
- package/src/report/model-tally.js +39 -0
- package/src/review/branch-overlap.js +40 -0
- package/src/review/dep-cooldown.js +87 -0
- package/src/review/one-shot-review.js +2 -2
- package/src/review/parser.js +5 -0
- package/src/review/publish-pr.js +54 -0
- package/src/review/verdict-store.js +19 -7
- package/src/roles/sonar-role.js +4 -2
- package/src/sonar/api.js +7 -0
- package/src/utils/injection-guard.js +59 -43
- package/src/utils/injection-patterns.json +36 -0
- package/src/utils/model-selector.js +47 -3
|
@@ -14,6 +14,7 @@ import fs from "node:fs/promises";
|
|
|
14
14
|
import path from "node:path";
|
|
15
15
|
import { runCommand } from "../utils/process.js";
|
|
16
16
|
import { kjStamp, stampDiffers, stampLabel } from "./kj-provenance.js";
|
|
17
|
+
import { sourceFilesOf } from "./tests-with-code.js";
|
|
17
18
|
|
|
18
19
|
const STORE_DIR = path.join(".karajan", "reviews");
|
|
19
20
|
|
|
@@ -107,13 +108,15 @@ export async function stampStagedVerdict({ projectDir, reviewer, summary = "", s
|
|
|
107
108
|
}
|
|
108
109
|
// KJC-BUG-0294: the pipeline commits by path (`--only`); the verdict must bind
|
|
109
110
|
// to that diff, not to other files the person had staged.
|
|
110
|
-
const
|
|
111
|
+
const pathArgs = paths?.length ? ["--", ...paths] : [];
|
|
112
|
+
const res = await runCommand("git", ["diff", "--cached", ...pathArgs], { cwd: dir });
|
|
111
113
|
if (res.exitCode !== 0 || !res.stdout?.trim()) return { stamped: false };
|
|
114
|
+
// A deleted file cannot be indexed, so it never counts as a source to prove.
|
|
115
|
+
const names = sonar?.indexed ? (await runCommand("git", ["diff", "--cached", "--name-only", "--diff-filter=ACMR", ...pathArgs], { cwd: dir })).stdout.split("\n").filter(Boolean) : [];
|
|
112
116
|
await saveVerdict(dir, res.stdout, {
|
|
113
117
|
verdict: "approved", reviewer, host: "kj-pipeline", issues: [], summary,
|
|
114
|
-
// KJC-TSK-0838: what the pipeline's sonar stage saw,
|
|
115
|
-
|
|
116
|
-
...(sonar ? { sonar } : {}),
|
|
118
|
+
// KJC-TSK-0838/0839: what the pipeline's sonar stage saw, proved per staged source.
|
|
119
|
+
...(sonar ? { sonar: coverageFor(sonar, names) } : {}),
|
|
117
120
|
});
|
|
118
121
|
return { stamped: true };
|
|
119
122
|
}
|
|
@@ -122,8 +125,8 @@ export async function stampStagedVerdict({ projectDir, reviewer, summary = "", s
|
|
|
122
125
|
* KJC-TSK-0838: the pipeline sonar stage result as a verdict sonar block.
|
|
123
126
|
* SKIPPED (no remote, no key) or absent is `ran:false` WITH the reason — the
|
|
124
127
|
* pre-commit check then refuses code, as it would for any other diff.
|
|
125
|
-
*
|
|
126
|
-
*
|
|
128
|
+
* KJC-TSK-0839: a stage that ran carries the files its verbose scan indexed;
|
|
129
|
+
* stampStagedVerdict turns them into covered/uncovered for the staged sources.
|
|
127
130
|
*/
|
|
128
131
|
export function pipelineSonarBlock(stageResult) {
|
|
129
132
|
if (!stageResult) return { ran: false, source: "pipeline", reason: "the pipeline recorded no sonar stage" };
|
|
@@ -131,10 +134,19 @@ export function pipelineSonarBlock(stageResult) {
|
|
|
131
134
|
return { ran: false, source: "pipeline", reason: stageResult.reason || stageResult.error || "the sonar stage did not run" };
|
|
132
135
|
}
|
|
133
136
|
return {
|
|
134
|
-
ran: true, source: "pipeline", projectKey: stageResult.projectKey || null, gateStatus: stageResult.gateStatus,
|
|
137
|
+
ran: true, source: "pipeline", projectKey: stageResult.projectKey || null, gateStatus: stageResult.gateStatus, indexed: stageResult.indexedFiles ?? [],
|
|
135
138
|
};
|
|
136
139
|
}
|
|
137
140
|
|
|
141
|
+
/** KJC-TSK-0839: the per-source proof of a pipeline block, against the files this commit stages. */
|
|
142
|
+
function coverageFor(sonar, stagedNames) {
|
|
143
|
+
if (!sonar?.ran || !Array.isArray(sonar.indexed)) return sonar;
|
|
144
|
+
const { indexed, ...block } = sonar;
|
|
145
|
+
const seen = new Set(indexed);
|
|
146
|
+
const { sources } = sourceFilesOf({}, stagedNames);
|
|
147
|
+
return { ...block, covered: sources.filter((f) => seen.has(f)), uncovered: sources.filter((f) => !seen.has(f)) };
|
|
148
|
+
}
|
|
149
|
+
|
|
138
150
|
/**
|
|
139
151
|
* Is there an APPROVED verdict for exactly this diff?
|
|
140
152
|
* @returns {Promise<{ok: boolean, verdict?: object, reason?: string}>}
|
package/src/roles/sonar-role.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { BaseRole } from "./base-role.js";
|
|
2
|
-
import { runSonarScan } from "../sonar/scanner.js";
|
|
2
|
+
import { indexedFilesFrom, runSonarScan } from "../sonar/scanner.js";
|
|
3
3
|
import { getQualityGateStatus, getOpenIssues } from "../sonar/api.js";
|
|
4
4
|
import { shouldBlockByProfile, summarizeIssues } from "../sonar/enforcer.js";
|
|
5
5
|
|
|
@@ -20,7 +20,8 @@ export class SonarRole extends BaseRole {
|
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
async execute(_input) {
|
|
23
|
-
|
|
23
|
+
// KJC-TSK-0839: verbose, so the scanner names every file it indexed.
|
|
24
|
+
const scan = await runSonarScan(this.config, null, { verbose: true });
|
|
24
25
|
|
|
25
26
|
if (!scan.ok) {
|
|
26
27
|
return {
|
|
@@ -53,6 +54,7 @@ export class SonarRole extends BaseRole {
|
|
|
53
54
|
ok: !blocking,
|
|
54
55
|
result: {
|
|
55
56
|
projectKey: scan.projectKey,
|
|
57
|
+
indexedFiles: [...indexedFilesFrom(scan.stdout)],
|
|
56
58
|
gateStatus: gate.status,
|
|
57
59
|
conditions: gate.conditions || [],
|
|
58
60
|
issues,
|
package/src/sonar/api.js
CHANGED
|
@@ -124,6 +124,13 @@ export async function getQualityGateStatus(config, projectKey = null) {
|
|
|
124
124
|
}
|
|
125
125
|
}
|
|
126
126
|
|
|
127
|
+
/** KJC-TSK-1051 (TEAM-E): the names of the quality profiles the project runs on. */
|
|
128
|
+
export async function getProjectQualityProfiles(config, projectKey = null) {
|
|
129
|
+
const effectiveProjectKey = await resolveSonarProjectKey(config, { projectKey, cwd: queryCwd(config) });
|
|
130
|
+
const body = await sonarFetch(config, `/api/qualityprofiles/search?project=${encodeURIComponent(effectiveProjectKey)}`);
|
|
131
|
+
return (JSON.parse(body).profiles ?? []).map((p) => p.name);
|
|
132
|
+
}
|
|
133
|
+
|
|
127
134
|
export async function getOpenIssues(config, projectKey = null) {
|
|
128
135
|
const effectiveProjectKey = await resolveSonarProjectKey(config, { projectKey, cwd: queryCwd(config) });
|
|
129
136
|
// KJC-BUG-0339 (#2099): current SonarQube ignores projectKeys and answered with
|
|
@@ -8,52 +8,52 @@
|
|
|
8
8
|
* @module utils/injection-guard
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
const DIRECTIVE_PATTERNS = [
|
|
13
|
-
/ignore\s+(all\s+)?(previous|prior|above|earlier)\s+(instructions?|rules?|prompts?|guidelines?)/i,
|
|
14
|
-
/disregard\s+(all\s+)?(previous|prior|above|earlier)\s+(instructions?|rules?|prompts?)/i,
|
|
15
|
-
/forget\s+(all\s+)?(previous|prior|your)\s+(instructions?|rules?|context)/i,
|
|
16
|
-
/override\s+(the\s+)?(system|reviewer|review)\s+(prompt|instructions?|rules?)/i,
|
|
17
|
-
/do\s+not\s+follow\s+(the\s+)?(above|previous|system)\s+(instructions?|rules?)/i,
|
|
18
|
-
/new\s+instructions?\s*:/i,
|
|
19
|
-
/system\s*:\s*you\s+are/i,
|
|
20
|
-
/\bact\s+as\s+(a\s+)?(different|new|my|an?\s+)/i,
|
|
21
|
-
/you\s+are\s+now\s+(a\s+)?/i,
|
|
22
|
-
/from\s+now\s+on\s*(,|\s)?\s*you\s+(will|should|must|are)/i,
|
|
23
|
-
/pretend\s+(you\s+are|to\s+be)/i,
|
|
24
|
-
/respond\s+(only\s+)?with\s+["']?approved["']?/i,
|
|
25
|
-
/always\s+(return|respond|output)\s+.*approved/i,
|
|
26
|
-
/set\s+approved\s*[=:]\s*true/i,
|
|
27
|
-
/\bapproved["']?\s*:\s*true\b.*ignore/i,
|
|
28
|
-
/output\s+the\s+following\s+json/i,
|
|
29
|
-
/return\s+this\s+exact\s+(json|response|output)/i,
|
|
30
|
-
];
|
|
31
|
-
|
|
32
|
-
/** Unicode categories that can hide or disguise content. */
|
|
33
|
-
const UNICODE_PATTERNS = [
|
|
34
|
-
/[\u200B-\u200F]/, // zero-width spaces, LTR/RTL marks
|
|
35
|
-
/[\u202A-\u202E]/, // bidi embedding/override
|
|
36
|
-
/[\u2066-\u2069]/, // bidi isolate
|
|
37
|
-
/[\uFEFF]/, // BOM mid-text
|
|
38
|
-
/[\u00AD]{3,}/, // repeated soft hyphens
|
|
39
|
-
/[\u2060-\u2064]/, // invisible operators
|
|
40
|
-
/[\u{E0000}-\u{E007F}]/u, // tag characters
|
|
41
|
-
];
|
|
42
|
-
|
|
43
|
-
/** Maximum comment block size (chars) before flagging. */
|
|
44
|
-
const MAX_COMMENT_BLOCK = 2000;
|
|
11
|
+
import { readFileSync } from "node:fs";
|
|
45
12
|
|
|
46
13
|
/**
|
|
47
|
-
*
|
|
48
|
-
*
|
|
14
|
+
* KJC-TSK-0703: the patterns live in injection-patterns.json, the family's
|
|
15
|
+
* versioned catalog (karajan-radar reads it from Python). Only constructs both
|
|
16
|
+
* JavaScript RegExp and Python re understand are accepted.
|
|
49
17
|
*/
|
|
50
|
-
const
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
/=begin[\s\S]{0,20000}?=end/g, // Ruby block comments
|
|
18
|
+
const NON_PORTABLE = [
|
|
19
|
+
[/\\u\{/, "\\u{...} escapes (put the literal character in the JSON instead)"],
|
|
20
|
+
[/\\[pP]\{/, "\\p{...} property escapes"],
|
|
21
|
+
[/\(\?<(?![=!])/, "a named group (Python spells it (?P<name>)"],
|
|
22
|
+
[/\\k</, "a named backreference"],
|
|
56
23
|
];
|
|
24
|
+
const PORTABLE_FLAGS = new Set(["i", "m", "s", "u"]);
|
|
25
|
+
|
|
26
|
+
/** Throws, naming the pattern, when an entry would not work outside JavaScript. */
|
|
27
|
+
export function assertPortable({ id, expression, flags = "" }) {
|
|
28
|
+
for (const flag of flags) {
|
|
29
|
+
if (!PORTABLE_FLAGS.has(flag)) throw new Error(`${id}: flag "${flag}" is not portable (only i, m, s, u)`);
|
|
30
|
+
}
|
|
31
|
+
for (const [construct, what] of NON_PORTABLE) {
|
|
32
|
+
if (construct.test(expression)) throw new Error(`${id}: ${what} is not portable: ${expression}`);
|
|
33
|
+
}
|
|
34
|
+
return new RegExp(expression, flags);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The catalog compiled per type; a missing or broken catalog fails loudly, never scans empty. */
|
|
38
|
+
// The SEA bundle has no sibling files: scripts/esbuild-sea.config.mjs inlines this exact read.
|
|
39
|
+
const readCatalog = () => readFileSync(new URL("./injection-patterns.json", import.meta.url), "utf8");
|
|
40
|
+
|
|
41
|
+
export function loadInjectionCatalog() {
|
|
42
|
+
const catalog = JSON.parse(readCatalog());
|
|
43
|
+
const compiled = { directive: [], unicode: [], comment_block: [], maxCommentBlock: catalog.max_comment_block, version: catalog.version };
|
|
44
|
+
for (const entry of catalog.patterns) {
|
|
45
|
+
const re = assertPortable(entry);
|
|
46
|
+
// Comment blocks are walked match by match: the global flag is the engine's, not the catalog's.
|
|
47
|
+
compiled[entry.type].push(entry.type === "comment_block" ? new RegExp(re.source, `${re.flags}g`) : re);
|
|
48
|
+
}
|
|
49
|
+
return compiled;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const CATALOG = loadInjectionCatalog();
|
|
53
|
+
const DIRECTIVE_PATTERNS = CATALOG.directive;
|
|
54
|
+
const UNICODE_PATTERNS = CATALOG.unicode;
|
|
55
|
+
const MAX_COMMENT_BLOCK = CATALOG.maxCommentBlock;
|
|
56
|
+
const COMMENT_BLOCK_RE = CATALOG.comment_block;
|
|
57
57
|
|
|
58
58
|
/**
|
|
59
59
|
* @typedef {Object} InjectionFinding
|
|
@@ -118,7 +118,7 @@ export function scanForInjection(text, opts = {}) {
|
|
|
118
118
|
for (const re of COMMENT_BLOCK_RE) {
|
|
119
119
|
let m;
|
|
120
120
|
while ((m = re.exec(text)) !== null) {
|
|
121
|
-
if (m[0]
|
|
121
|
+
if (proseLength(m[0]) > maxBlock) {
|
|
122
122
|
findings.push({
|
|
123
123
|
type: "comment_block",
|
|
124
124
|
pattern: `block ${m[0].length} chars (max ${maxBlock})`,
|
|
@@ -175,6 +175,22 @@ export function scanDiff(diff, opts = {}) {
|
|
|
175
175
|
|
|
176
176
|
// --- Helpers ---
|
|
177
177
|
|
|
178
|
+
/**
|
|
179
|
+
* KJC-TSK-0743: the size of a comment block that counts against the limit. In a
|
|
180
|
+
* JSDoc block (`/**`), tag lines (`* @typedef`, `* @property`...) are structure,
|
|
181
|
+
* not prose: they are left out unless a single one runs past 300 chars. Any
|
|
182
|
+
* instruction inside a tag line is still caught by the directive patterns.
|
|
183
|
+
*/
|
|
184
|
+
const JSDOC_TAG_LINE = /^\s*\*\s*@[A-Za-z]+/;
|
|
185
|
+
const MAX_TAG_LINE = 300;
|
|
186
|
+
function proseLength(block) {
|
|
187
|
+
if (!block.startsWith("/**")) return block.length;
|
|
188
|
+
return block
|
|
189
|
+
.split("\n")
|
|
190
|
+
.filter((line) => !(JSDOC_TAG_LINE.test(line) && line.length <= MAX_TAG_LINE))
|
|
191
|
+
.join("\n").length;
|
|
192
|
+
}
|
|
193
|
+
|
|
178
194
|
function excerpt(text, index) {
|
|
179
195
|
const start = Math.max(0, index - 20);
|
|
180
196
|
const end = Math.min(text.length, index + 100);
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$comment": "KJC-TSK-0703: the canonical prompt-injection catalog of the karajan family. Data, not code: each language keeps only its small scanning engine. Expressions must stay portable (JavaScript RegExp and Python re): invisible characters go as literal characters (JSON escapes), never as regex escapes like \\u{...}; flags are only i, m, s, u. Bump version on any change.",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"max_comment_block": 2000,
|
|
5
|
+
"patterns": [
|
|
6
|
+
{ "id": "directive-ignore-previous", "type": "directive", "expression": "ignore\\s+(all\\s+)?(previous|prior|above|earlier)\\s+(instructions?|rules?|prompts?|guidelines?)", "flags": "i", "severity": "high", "why": "Tells the model to drop the instructions it was given, the classic override." },
|
|
7
|
+
{ "id": "directive-disregard-previous", "type": "directive", "expression": "disregard\\s+(all\\s+)?(previous|prior|above|earlier)\\s+(instructions?|rules?|prompts?)", "flags": "i", "severity": "high", "why": "Same override as ignore-previous, worded to slip past a single keyword." },
|
|
8
|
+
{ "id": "directive-forget-instructions", "type": "directive", "expression": "forget\\s+(all\\s+)?(previous|prior|your)\\s+(instructions?|rules?|context)", "flags": "i", "severity": "high", "why": "Asks the model to wipe its context or rules before obeying new ones." },
|
|
9
|
+
{ "id": "directive-override-system", "type": "directive", "expression": "override\\s+(the\\s+)?(system|reviewer|review)\\s+(prompt|instructions?|rules?)", "flags": "i", "severity": "high", "why": "Names the system or reviewer prompt as something to replace." },
|
|
10
|
+
{ "id": "directive-do-not-follow", "type": "directive", "expression": "do\\s+not\\s+follow\\s+(the\\s+)?(above|previous|system)\\s+(instructions?|rules?)", "flags": "i", "severity": "high", "why": "Negates the instructions above it, steering the model away from its task." },
|
|
11
|
+
{ "id": "directive-new-instructions", "type": "directive", "expression": "new\\s+instructions?\\s*:", "flags": "i", "severity": "medium", "why": "Announces a fresh instruction block inside content that should carry none." },
|
|
12
|
+
{ "id": "directive-system-you-are", "type": "directive", "expression": "system\\s*:\\s*you\\s+are", "flags": "i", "severity": "high", "why": "Forges a system message that redefines who the model is." },
|
|
13
|
+
{ "id": "directive-act-as", "type": "directive", "expression": "\\bact\\s+as\\s+(a\\s+)?(different|new|my|an?\\s+)", "flags": "i", "severity": "medium", "why": "Role reassignment: asks the model to become another persona." },
|
|
14
|
+
{ "id": "directive-you-are-now", "type": "directive", "expression": "you\\s+are\\s+now\\s+(a\\s+)?", "flags": "i", "severity": "medium", "why": "Role reassignment phrased as a fact the model should accept." },
|
|
15
|
+
{ "id": "directive-from-now-on", "type": "directive", "expression": "from\\s+now\\s+on\\s*(,|\\s)?\\s*you\\s+(will|should|must|are)", "flags": "i", "severity": "medium", "why": "Installs a standing behaviour change for the rest of the conversation." },
|
|
16
|
+
{ "id": "directive-pretend", "type": "directive", "expression": "pretend\\s+(you\\s+are|to\\s+be)", "flags": "i", "severity": "medium", "why": "Role-play framing used to step around the model's instructions." },
|
|
17
|
+
{ "id": "directive-respond-approved", "type": "directive", "expression": "respond\\s+(only\\s+)?with\\s+[\"']?approved[\"']?", "flags": "i", "severity": "high", "why": "Dictates the reviewer's verdict instead of letting it judge the change." },
|
|
18
|
+
{ "id": "directive-always-approved", "type": "directive", "expression": "always\\s+(return|respond|output)\\s+.*approved", "flags": "i", "severity": "high", "why": "Fixes the output to approved whatever the content says." },
|
|
19
|
+
{ "id": "directive-set-approved", "type": "directive", "expression": "set\\s+approved\\s*[=:]\\s*true", "flags": "i", "severity": "high", "why": "Tries to write the verdict field directly." },
|
|
20
|
+
{ "id": "directive-approved-true-ignore", "type": "directive", "expression": "\\bapproved[\"']?\\s*:\\s*true\\b.*ignore", "flags": "i", "severity": "high", "why": "A forged approved verdict next to an instruction to ignore something." },
|
|
21
|
+
{ "id": "directive-output-json", "type": "directive", "expression": "output\\s+the\\s+following\\s+json", "flags": "i", "severity": "medium", "why": "Hands the model a ready answer to emit instead of its own." },
|
|
22
|
+
{ "id": "directive-return-exact", "type": "directive", "expression": "return\\s+this\\s+exact\\s+(json|response|output)", "flags": "i", "severity": "medium", "why": "Same as output-json: a scripted response smuggled into the content." },
|
|
23
|
+
{ "id": "unicode-zero-width", "type": "unicode", "expression": "[\u200B-\u200F]", "flags": "", "severity": "high", "why": "Zero-width spaces and LTR/RTL marks hide text a human reviewer cannot see." },
|
|
24
|
+
{ "id": "unicode-bidi-embedding", "type": "unicode", "expression": "[\u202A-\u202E]", "flags": "", "severity": "high", "why": "Bidi embedding and override characters reorder what is displayed (Trojan Source)." },
|
|
25
|
+
{ "id": "unicode-bidi-isolate", "type": "unicode", "expression": "[\u2066-\u2069]", "flags": "", "severity": "high", "why": "Bidi isolates, the other half of the Trojan Source trick." },
|
|
26
|
+
{ "id": "unicode-bom-midtext", "type": "unicode", "expression": "[\uFEFF]", "flags": "", "severity": "medium", "why": "A byte order mark in the middle of text is invisible and has no honest use there." },
|
|
27
|
+
{ "id": "unicode-soft-hyphens", "type": "unicode", "expression": "[\u00AD]{3,}", "flags": "", "severity": "low", "why": "Runs of soft hyphens split words so keyword filters miss them." },
|
|
28
|
+
{ "id": "unicode-invisible-operators", "type": "unicode", "expression": "[\u2060-\u2064]", "flags": "", "severity": "medium", "why": "Word joiner and invisible operators carry no visible glyph." },
|
|
29
|
+
{ "id": "unicode-tag-characters", "type": "unicode", "expression": "[\uDB40\uDC00-\uDB40\uDC7F]", "flags": "u", "severity": "high", "why": "Unicode tag characters (U+E0000 to U+E007F) can encode a whole hidden instruction." },
|
|
30
|
+
{ "id": "comment-block-c", "type": "comment_block", "expression": "/\\*[\\s\\S]{0,20000}?\\*/", "flags": "", "severity": "low", "why": "An oversized C-style comment can hide a payload addressed to the model." },
|
|
31
|
+
{ "id": "comment-block-html", "type": "comment_block", "expression": "<!--[\\s\\S]{0,20000}?-->", "flags": "", "severity": "low", "why": "An oversized HTML comment is invisible when rendered but read by the model." },
|
|
32
|
+
{ "id": "comment-block-docstring-double", "type": "comment_block", "expression": "\"\"\"\\s*[\\s\\S]{0,20000}?\\s*\"\"\"", "flags": "", "severity": "low", "why": "An oversized Python docstring can carry instructions as prose." },
|
|
33
|
+
{ "id": "comment-block-docstring-single", "type": "comment_block", "expression": "'''\\s*[\\s\\S]{0,20000}?\\s*'''", "flags": "", "severity": "low", "why": "Same as the double-quoted docstring, with single quotes." },
|
|
34
|
+
{ "id": "comment-block-ruby", "type": "comment_block", "expression": "=begin[\\s\\S]{0,20000}?=end", "flags": "", "severity": "low", "why": "An oversized Ruby block comment, the same hiding place." }
|
|
35
|
+
]
|
|
36
|
+
}
|
|
@@ -1,8 +1,19 @@
|
|
|
1
|
+
// KJC-TSK-0699: the ONE table of default models per provider and level (the HU
|
|
2
|
+
// router reads it too). Ids verified 2026-10-16: claude takes its CLI aliases,
|
|
3
|
+
// which follow the current version; codex takes the models its CLI lists
|
|
4
|
+
// (models_cache.json: luna fast, terra balanced, sol the most capable). null
|
|
5
|
+
// leaves the CLI's own default where no id could be verified: aider and
|
|
6
|
+
// opencode run on each user's providers, gemini and qwen no longer serve
|
|
7
|
+
// individuals, copilot publishes no list.
|
|
8
|
+
const NO_MODEL = { trivial: null, simple: null, medium: null, complex: null };
|
|
1
9
|
const DEFAULT_MODEL_TIERS = {
|
|
2
10
|
claude: { trivial: "haiku", simple: "haiku", medium: "sonnet", complex: "opus" },
|
|
3
|
-
codex: { trivial: "
|
|
4
|
-
gemini:
|
|
5
|
-
|
|
11
|
+
codex: { trivial: "gpt-5.6-luna", simple: "gpt-5.6-luna", medium: "gpt-5.6-terra", complex: "gpt-5.6-sol" },
|
|
12
|
+
gemini: NO_MODEL,
|
|
13
|
+
qwen: NO_MODEL,
|
|
14
|
+
copilot: NO_MODEL,
|
|
15
|
+
opencode: NO_MODEL,
|
|
16
|
+
aider: NO_MODEL
|
|
6
17
|
};
|
|
7
18
|
|
|
8
19
|
const DEFAULT_ROLE_OVERRIDES = {
|
|
@@ -12,6 +23,39 @@ const DEFAULT_ROLE_OVERRIDES = {
|
|
|
12
23
|
|
|
13
24
|
const VALID_LEVELS = new Set(["trivial", "simple", "medium", "complex"]);
|
|
14
25
|
|
|
26
|
+
/**
|
|
27
|
+
* KJC-TSK-0699 (MR-B): named profiles over the levels, so the user talks of
|
|
28
|
+
* profiles, not model ids. Resolution: flag > roles.<role>.model > profile >
|
|
29
|
+
* the CLI's own default. The reviewer keeps its floor (never below medium).
|
|
30
|
+
*/
|
|
31
|
+
export const MODEL_PROFILES = { frugal: "trivial", standard: "medium", deep: "complex" };
|
|
32
|
+
const CODER_PROVIDER_ROLES = new Set(["planner", "refactorer", "solomon", "researcher", "tester", "security", "impeccable", "triage", "discover", "architect", "audit"]);
|
|
33
|
+
|
|
34
|
+
/** Fills the model of every role that has none yet; returns what it set. */
|
|
35
|
+
export function applyModelProfile(config, profile) {
|
|
36
|
+
const level = MODEL_PROFILES[profile];
|
|
37
|
+
if (!level) throw new Error(`model profile "${profile}" does not exist: use one of ${Object.keys(MODEL_PROFILES).join(", ")}`);
|
|
38
|
+
const coderProvider = config.roles?.coder?.provider || config.coder || null;
|
|
39
|
+
const set = {};
|
|
40
|
+
for (const [role, entry] of Object.entries(config.roles ?? {})) {
|
|
41
|
+
if (entry?.model) continue;
|
|
42
|
+
const provider = entry?.provider || (role === "reviewer" ? config.reviewer : null) || (CODER_PROVIDER_ROLES.has(role) || role === "coder" ? coderProvider : null);
|
|
43
|
+
const model = resolveModelForRole({ role, provider, level });
|
|
44
|
+
// A new object: the entry may be shared with the shipped defaults.
|
|
45
|
+
if (!model) continue;
|
|
46
|
+
set[role] = model;
|
|
47
|
+
config.roles[role] = { ...entry, model };
|
|
48
|
+
}
|
|
49
|
+
return set;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The line kj config prints about the effective profile. */
|
|
53
|
+
export function formatModelProfile(config) {
|
|
54
|
+
const profile = config?.model_profile;
|
|
55
|
+
if (!profile) return "Model profile: none (each CLI uses its own default model)";
|
|
56
|
+
return `Model profile: ${profile} (${MODEL_PROFILES[profile]} models; reviewer never below standard)`;
|
|
57
|
+
}
|
|
58
|
+
|
|
15
59
|
export function getDefaultModelTiers() {
|
|
16
60
|
return structuredClone(DEFAULT_MODEL_TIERS);
|
|
17
61
|
}
|