@ngockhoale/ukit 2.2.16 → 2.3.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/CHANGELOG.md +52 -0
- package/manifests/platform.full.yaml +11 -0
- package/package.json +1 -1
- package/src/core/executionContracts.js +130 -0
- package/src/core/runtimeConfig.js +13 -50
- package/src/index/taskRouting.js +10 -103
- package/templates/.claude/agents/ukit-vision-analyst.md +32 -21
- package/templates/.claude/hooks/protect-files.sh +1 -0
- package/templates/.claude/hooks/sensitive-data-guard.sh +269 -0
- package/templates/.claude/hooks/skill-router.sh +6 -0
- package/templates/.claude/hooks/vision-router.sh +63 -37
- package/templates/.claude/settings.json +17 -1
- package/templates/.claude/ukit/index/extract-image.mjs +18 -8
- package/templates/.claude/ukit/index/route-task.mjs +53 -2
- package/templates/.claude/ukit/runtime/execution-ledger.mjs +120 -21
- package/templates/.claude/ukit/runtime/hook-chain-runner.mjs +8 -4
- package/templates/.omp/hooks/pre/ukit-bridge.js +15 -5
- package/templates/AGENTS.md +2 -2
- package/templates/CLAUDE.md +1 -1
- package/templates/ukit/storage/config.json +8 -7
- package/src/core/router/advisor.js +0 -42
- package/src/core/router/router.js +0 -180
- package/src/core/validation/confidence.js +0 -89
- package/src/core/validation/validator.js +0 -165
- package/templates/docs/INSTALL.md +0 -115
- package/templates/docs/STATUS.md +0 -81
- package/templates/docs/TASKS.md +0 -79
- package/templates/docs/UKIT_USAGE_GUIDE.md +0 -163
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# PreToolUse (Read|Grep|Bash) + UserPromptSubmit hook: sensitive-data gate.
|
|
3
|
+
#
|
|
4
|
+
# Blocks sensitive data — API keys, private keys, credentials, secret files —
|
|
5
|
+
# from being sent to the AI. Strict by design ("cảnh cực gắt"): mere suspicion
|
|
6
|
+
# of private data blocks the call so the USER decides. Detection covers three
|
|
7
|
+
# egress channels into model context:
|
|
8
|
+
# 1. Read/Grep of secret files (.env*, *.pem, id_rsa, credentials.json, ...)
|
|
9
|
+
# 2. Bash commands that dump secrets (cat .env, bare `env`, curl -u user:pass,
|
|
10
|
+
# URL-embedded credentials, any high-confidence token in the command text)
|
|
11
|
+
# 3. UserPromptSubmit prompt text containing high-confidence secret patterns
|
|
12
|
+
#
|
|
13
|
+
# FAIL-CLOSED on detection (exit 2). Never echoes the detected value — only a
|
|
14
|
+
# redacted preview plus a truncated sha256 (12 hex chars: enough for the human
|
|
15
|
+
# to verify, not enough for the model to forge an allowlist entry).
|
|
16
|
+
#
|
|
17
|
+
# User escape hatches (explicit human action only):
|
|
18
|
+
# - security.sensitiveDataGate=false in .ukit/storage/config.json disables the gate
|
|
19
|
+
# - .ukit/storage/security/allowlist.json { values: [sha256...], paths: [...] }
|
|
20
|
+
# approves exact secrets / file paths (protect-files.sh blocks model edits there)
|
|
21
|
+
#
|
|
22
|
+
# Fails open only when there is nothing to scan (malformed stdin, no channel):
|
|
23
|
+
# a broken gate must not brick every session, but every real detection blocks.
|
|
24
|
+
|
|
25
|
+
INPUT=$(cat)
|
|
26
|
+
PROJECT_ROOT="${CLAUDE_PROJECT_DIR:-$PWD}"
|
|
27
|
+
|
|
28
|
+
INPUT="$INPUT" PROJECT_ROOT="$PROJECT_ROOT" node <<'NODE'
|
|
29
|
+
const fs = require('fs');
|
|
30
|
+
const path = require('path');
|
|
31
|
+
const { createHash } = require('crypto');
|
|
32
|
+
|
|
33
|
+
const payload = (() => {
|
|
34
|
+
try {
|
|
35
|
+
const parsed = JSON.parse(process.env.INPUT || '');
|
|
36
|
+
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
|
|
37
|
+
} catch {
|
|
38
|
+
return {};
|
|
39
|
+
}
|
|
40
|
+
})();
|
|
41
|
+
|
|
42
|
+
const projectRoot = process.env.PROJECT_ROOT || process.cwd();
|
|
43
|
+
|
|
44
|
+
function readJsonSafe(filePath, fallback) {
|
|
45
|
+
try {
|
|
46
|
+
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
47
|
+
} catch {
|
|
48
|
+
return fallback;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const config = readJsonSafe(path.join(projectRoot, '.ukit', 'storage', 'config.json'), null);
|
|
53
|
+
if (config && config.security && config.security.sensitiveDataGate === false) {
|
|
54
|
+
process.exit(0);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const allowlist = readJsonSafe(path.join(projectRoot, '.ukit', 'storage', 'security', 'allowlist.json'), null) || {};
|
|
58
|
+
const allowedValueHashes = new Set(
|
|
59
|
+
Array.isArray(allowlist.values) ? allowlist.values.filter((v) => typeof v === 'string') : [],
|
|
60
|
+
);
|
|
61
|
+
const allowedPaths = Array.isArray(allowlist.paths) ? allowlist.paths.filter((p) => typeof p === 'string') : [];
|
|
62
|
+
|
|
63
|
+
function sha256(value) {
|
|
64
|
+
return createHash('sha256').update(value).digest('hex');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function pathAllowed(filePath) {
|
|
68
|
+
const normalized = String(filePath).replace(/\\/g, '/').trim();
|
|
69
|
+
let relative = normalized;
|
|
70
|
+
try {
|
|
71
|
+
relative = path.relative(projectRoot, normalized);
|
|
72
|
+
} catch {
|
|
73
|
+
// keep normalized
|
|
74
|
+
}
|
|
75
|
+
return allowedPaths.some((allowed) => allowed === normalized || allowed === relative);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// --- high-confidence secret value patterns (shared by prompt + bash scanning) ---
|
|
79
|
+
const TOKEN_PATTERNS = [
|
|
80
|
+
{ label: 'OpenAI/Anthropic-style API key', re: /\bsk-(?:proj-|ant-|svc-|acct-|admin-)?[A-Za-z0-9_-]{20,}/g },
|
|
81
|
+
{ label: 'AWS access key id', re: /\bAKIA[0-9A-Z]{16}\b/g },
|
|
82
|
+
{ label: 'GitHub token', re: /\bgh[pousr]_[A-Za-z0-9]{30,}\b/g },
|
|
83
|
+
{ label: 'GitHub fine-grained token', re: /\bgithub_pat_[A-Za-z0-9_]{20,}/g },
|
|
84
|
+
{ label: 'GitLab token', re: /\bglpat-[A-Za-z0-9_-]{20,}/g },
|
|
85
|
+
{ label: 'Slack token', re: /\bxox[baprs]-[A-Za-z0-9-]{10,}/g },
|
|
86
|
+
{ label: 'Google API key', re: /\bAIza[0-9A-Za-z_-]{20,}/g },
|
|
87
|
+
{ label: 'Stripe live key', re: /\b[srp]k_live_[A-Za-z0-9]{20,}/g },
|
|
88
|
+
{ label: 'JWT', re: /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/g },
|
|
89
|
+
{ label: 'private key block', re: /-----BEGIN (?:RSA |EC |DSA |OPENSSH |PGP |ENCRYPTED )?PRIVATE KEY-----/g },
|
|
90
|
+
{ label: 'bearer token', re: /\bBearer\s+[A-Za-z0-9._+/=-]{20,}/gi },
|
|
91
|
+
];
|
|
92
|
+
|
|
93
|
+
const ASSIGNMENT_RE = /(^|[^A-Za-z0-9_])(api[_-]?key|apikey|secret[_-]?key|secret|client[_-]?secret|password|passwd|auth[_-]?token|access[_-]?token|token)\s*[:=]\s*["']?([A-Za-z0-9+/_.~-]{16,})/gi;
|
|
94
|
+
|
|
95
|
+
function scanSecretText(text) {
|
|
96
|
+
const found = [];
|
|
97
|
+
for (const { label, re } of TOKEN_PATTERNS) {
|
|
98
|
+
re.lastIndex = 0;
|
|
99
|
+
let match;
|
|
100
|
+
while ((match = re.exec(text)) !== null) {
|
|
101
|
+
found.push({ label, value: match[0] });
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
ASSIGNMENT_RE.lastIndex = 0;
|
|
105
|
+
let match;
|
|
106
|
+
while ((match = ASSIGNMENT_RE.exec(text)) !== null) {
|
|
107
|
+
found.push({ label: `${match[2].toLowerCase()} assignment`, value: match[3] });
|
|
108
|
+
}
|
|
109
|
+
return found;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// --- secret file classification ---
|
|
113
|
+
function classifySecretFile(filePath) {
|
|
114
|
+
if (typeof filePath !== 'string' || !filePath.trim()) return null;
|
|
115
|
+
const normalized = filePath.replace(/\\/g, '/').trim();
|
|
116
|
+
const rawBase = normalized.split('/').pop();
|
|
117
|
+
if (!rawBase) return null;
|
|
118
|
+
const base = rawBase.toLowerCase();
|
|
119
|
+
if (base.endsWith('.env.example')) return null;
|
|
120
|
+
if (base === '.env' || base.startsWith('.env.')) return 'dotenv/secret env file';
|
|
121
|
+
if (base.endsWith('.pub') || base.endsWith('.public')) return null;
|
|
122
|
+
if (/^id_(rsa|dsa|ecdsa|ed25519)(\.|$)/.test(base)) return 'SSH private key';
|
|
123
|
+
if (/\.(pem|key|p12|pfx|keystore|jks)$/.test(base)) return 'key/certificate file';
|
|
124
|
+
if (/^credentials\.(json|ya?ml|txt|ini)$/.test(base)) return 'credentials file';
|
|
125
|
+
if (/^service[_-]?account.*\.json$/.test(base)) return 'service-account key file';
|
|
126
|
+
if (base === '.npmrc' || base === '.netrc' || base === '.htpasswd') return 'credentials file';
|
|
127
|
+
if (/^secrets?\.(json|ya?ml|txt|ini|env)$/.test(base)) return 'secrets file';
|
|
128
|
+
if (/(^|\/)\.aws\/credentials$/i.test(normalized)) return 'AWS credentials file';
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// --- bash command shapes that dump secrets ---
|
|
133
|
+
const DUMP_VERBS = new Set([
|
|
134
|
+
'cat', 'head', 'tail', 'less', 'more', 'zless', 'zcat', 'xxd', 'strings',
|
|
135
|
+
'base64', 'od', 'hexdump', 'bat', 'grep', 'egrep', 'fgrep', 'rg', 'awk', 'sed',
|
|
136
|
+
]);
|
|
137
|
+
|
|
138
|
+
function scanBashShapes(command) {
|
|
139
|
+
const found = [];
|
|
140
|
+
const segments = command.split(/\|\||&&|;|\||\n/);
|
|
141
|
+
for (const rawSegment of segments) {
|
|
142
|
+
const segment = rawSegment.trim();
|
|
143
|
+
if (!segment) continue;
|
|
144
|
+
const tokens = segment.split(/\s+/);
|
|
145
|
+
const head = tokens[0].replace(/^["']+|["']+$/g, '');
|
|
146
|
+
|
|
147
|
+
if ((head === 'env' || head === 'printenv' || head === 'history') && tokens.length === 1) {
|
|
148
|
+
found.push({ label: `bare \`${head}\` dumps environment/history (may contain tokens)`, value: segment });
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
if (head === 'export' && (tokens[1] === '-p' || tokens[1] === '--print')) {
|
|
152
|
+
found.push({ label: 'bare `export -p` dumps all environment variables', value: segment });
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (DUMP_VERBS.has(head)) {
|
|
157
|
+
for (const token of tokens.slice(1)) {
|
|
158
|
+
const cleaned = token.replace(/^["']+|["',:]+$/g, '');
|
|
159
|
+
const kind = classifySecretFile(cleaned);
|
|
160
|
+
if (kind && !pathAllowed(cleaned)) {
|
|
161
|
+
found.push({ label: `${head} reads ${kind}`, value: cleaned, isPath: true });
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (/(^|\s)(-u|--user)\s+["']?[^\s"':]+:[^\s"']+/.test(segment)) {
|
|
167
|
+
found.push({ label: 'curl/wget inline credentials (-u user:password)', value: segment });
|
|
168
|
+
}
|
|
169
|
+
if (/https?:\/\/[^\s/@'"]+:[^\s/@'"]+@/.test(segment)) {
|
|
170
|
+
found.push({ label: 'URL with embedded credentials (user:pass@host)', value: segment });
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return found;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// --- channel wiring ---
|
|
177
|
+
const event = String(payload.hook_event_name || '');
|
|
178
|
+
const toolName = String(payload.tool_name || '');
|
|
179
|
+
const toolInput = payload.tool_input && typeof payload.tool_input === 'object' ? payload.tool_input : {};
|
|
180
|
+
|
|
181
|
+
const textChannels = [];
|
|
182
|
+
const filePathChannels = [];
|
|
183
|
+
let bashCommand = null;
|
|
184
|
+
|
|
185
|
+
if (event === 'UserPromptSubmit') {
|
|
186
|
+
const prompt = [payload.prompt, payload.user_prompt, payload.text].find(
|
|
187
|
+
(candidate) => typeof candidate === 'string' && candidate.trim(),
|
|
188
|
+
);
|
|
189
|
+
if (prompt) textChannels.push({ channel: 'prompt', text: prompt });
|
|
190
|
+
} else if (event === 'PreToolUse') {
|
|
191
|
+
if (toolName === 'Read' && typeof toolInput.file_path === 'string') {
|
|
192
|
+
filePathChannels.push(toolInput.file_path);
|
|
193
|
+
} else if (toolName === 'Grep') {
|
|
194
|
+
if (typeof toolInput.path === 'string' && toolInput.path.trim()) filePathChannels.push(toolInput.path);
|
|
195
|
+
if (typeof toolInput.pattern === 'string') textChannels.push({ channel: 'grep pattern', text: toolInput.pattern });
|
|
196
|
+
} else if (toolName === 'Bash' && typeof toolInput.command === 'string') {
|
|
197
|
+
bashCommand = toolInput.command;
|
|
198
|
+
}
|
|
199
|
+
} else {
|
|
200
|
+
process.exit(0);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// --- collect findings ---
|
|
204
|
+
const findings = [];
|
|
205
|
+
|
|
206
|
+
for (const filePath of filePathChannels) {
|
|
207
|
+
const kind = classifySecretFile(filePath);
|
|
208
|
+
if (kind && !pathAllowed(filePath)) {
|
|
209
|
+
findings.push({ label: `Read of ${kind}`, preview: filePath, hashSource: filePath });
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const seenValues = new Set();
|
|
214
|
+
for (const { channel, text } of textChannels) {
|
|
215
|
+
for (const finding of scanSecretText(text)) {
|
|
216
|
+
if (allowedValueHashes.has(sha256(finding.value))) continue;
|
|
217
|
+
const key = `${channel}:${finding.label}:${finding.value}`;
|
|
218
|
+
if (seenValues.has(key)) continue;
|
|
219
|
+
seenValues.add(key);
|
|
220
|
+
findings.push({ label: `${finding.label} in ${channel}`, value: finding.value });
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if (bashCommand) {
|
|
225
|
+
for (const finding of scanBashShapes(bashCommand)) {
|
|
226
|
+
if (finding.isPath) {
|
|
227
|
+
findings.push({ label: `bash command touches ${finding.label}`, preview: finding.value });
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
if (allowedValueHashes.has(sha256(finding.value))) continue;
|
|
231
|
+
findings.push({ label: `${finding.label} in bash command`, value: finding.value });
|
|
232
|
+
}
|
|
233
|
+
for (const finding of scanSecretText(bashCommand)) {
|
|
234
|
+
if (allowedValueHashes.has(sha256(finding.value))) continue;
|
|
235
|
+
const key = `bash:${finding.label}:${finding.value}`;
|
|
236
|
+
if (seenValues.has(key)) continue;
|
|
237
|
+
seenValues.add(key);
|
|
238
|
+
findings.push({ label: `${finding.label} in bash command`, value: finding.value });
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
if (findings.length === 0) {
|
|
243
|
+
process.exit(0);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// --- block message: redacted previews only, never the secret itself ---
|
|
247
|
+
function describeFinding(finding) {
|
|
248
|
+
const where = finding.preview
|
|
249
|
+
? `${finding.preview}`
|
|
250
|
+
: `${String(finding.value).slice(0, 3)}…(${String(finding.value).length} chars, sha256 ${sha256(String(finding.value)).slice(0, 12)}…)`;
|
|
251
|
+
return ` - ${finding.label}: ${where}`;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const lines = [
|
|
255
|
+
`BLOCKED (sensitive data): ${findings.length} potential secret(s) detected. Nothing was sent to the AI.`,
|
|
256
|
+
...findings.map(describeFinding),
|
|
257
|
+
'This gate is intentionally strict — it blocks on suspicion so the USER decides.',
|
|
258
|
+
'To proceed, the USER must choose one of:',
|
|
259
|
+
' 1. Redact the secret (placeholder like <API_KEY>) and retry.',
|
|
260
|
+
' 2. Approve it explicitly: add the full sha256 (shasum -a 256 of the value) or the file path',
|
|
261
|
+
' to .ukit/storage/security/allowlist.json — that file is protected from AI edits.',
|
|
262
|
+
' 3. Disable the gate: security.sensitiveDataGate=false in .ukit/storage/config.json (not recommended).',
|
|
263
|
+
'Never repeat or guess the detected value in your reply. Ask the user instead.',
|
|
264
|
+
];
|
|
265
|
+
process.stderr.write(`${lines.join('\n')}\n`);
|
|
266
|
+
process.exit(2);
|
|
267
|
+
NODE
|
|
268
|
+
|
|
269
|
+
exit $?
|
|
@@ -2415,6 +2415,10 @@ const { pathToFileURL } = require('url');
|
|
|
2415
2415
|
}
|
|
2416
2416
|
|
|
2417
2417
|
ensureDir(statePath);
|
|
2418
|
+
// A no-route prompt (short nudge, informational question) must not wipe the last full
|
|
2419
|
+
// route: completion-gate.sh reads routeSummary/requestKey from this state to decide
|
|
2420
|
+
// whether a Stop is premature. Downgrading to a route-less state here silently disarms
|
|
2421
|
+
// the gate mid-task, so carry the previous route forward until a real route replaces it.
|
|
2418
2422
|
fs.writeFileSync(statePath, JSON.stringify({
|
|
2419
2423
|
fingerprint,
|
|
2420
2424
|
ts: now,
|
|
@@ -2422,6 +2426,8 @@ const { pathToFileURL } = require('url');
|
|
|
2422
2426
|
activeSkills: [],
|
|
2423
2427
|
routingContext: compactRoutingContext(routingContext),
|
|
2424
2428
|
...(previousContext ? { previousContext: compactPreviousContext(previousContext) } : {}),
|
|
2429
|
+
...(previous?.routeSummary ? { routeSummary: previous.routeSummary } : {}),
|
|
2430
|
+
...(previous?.requestKey ? { requestKey: previous.requestKey } : {}),
|
|
2425
2431
|
}));
|
|
2426
2432
|
process.exit(0);
|
|
2427
2433
|
}
|
|
@@ -12,6 +12,12 @@
|
|
|
12
12
|
#
|
|
13
13
|
# Runs on EVERY prompt, 8s budget: one shell-out to extract-image.mjs
|
|
14
14
|
# --detect --mark-pending --json, no decoding.
|
|
15
|
+
#
|
|
16
|
+
# Hint policy (dedup): the full dispatch hint fires ONLY when the extractor armed
|
|
17
|
+
# NEW pending markers this prompt. Mentions whose markers already carry
|
|
18
|
+
# analyzed-<sha>.json receipts are silent — re-hinting a closed, analyzed image on
|
|
19
|
+
# every unrelated prompt is the false-positive loop this version exists to kill.
|
|
20
|
+
# A named path that does not resolve from the project root gets a short note only.
|
|
15
21
|
|
|
16
22
|
INPUT=$(cat)
|
|
17
23
|
PROJECT_ROOT="${CLAUDE_PROJECT_DIR:-$PWD}"
|
|
@@ -81,12 +87,9 @@ const { pathToFileURL } = require('url');
|
|
|
81
87
|
|
|
82
88
|
// Shell out to the single owner of image detection + marker writing. Runs on every
|
|
83
89
|
// prompt (cheap: --detect --mark-pending, never decodes). All three input forms are
|
|
84
|
-
// armed here, not just pasted images
|
|
85
|
-
//
|
|
86
|
-
|
|
87
|
-
let markedPasted = 0;
|
|
88
|
-
let markedPath = 0;
|
|
89
|
-
let markedUrl = 0;
|
|
90
|
+
// armed here, not just pasted images. --ref hashes a local file's bytes / a URL
|
|
91
|
+
// string inside the extractor.
|
|
92
|
+
let extractorJson = null;
|
|
90
93
|
const extractorPath = path.join(projectRoot, '.claude', 'ukit', 'index', 'extract-image.mjs');
|
|
91
94
|
if (fs.existsSync(extractorPath)) {
|
|
92
95
|
const args = [extractorPath, '--detect', '--mark-pending', '--json'];
|
|
@@ -105,34 +108,50 @@ const { pathToFileURL } = require('url');
|
|
|
105
108
|
encoding: 'utf8',
|
|
106
109
|
timeout: 6000,
|
|
107
110
|
});
|
|
108
|
-
|
|
109
|
-
const images = Array.isArray(parsed?.images) ? parsed.images : [];
|
|
110
|
-
// Ref markers carry a `source`; pasted ones do not. Counting this way stays correct
|
|
111
|
-
// when a named path does not exist on disk and the extractor drops it.
|
|
112
|
-
markedPasted = images.filter((img) => !img?.source).length;
|
|
113
|
-
markedPath = images.filter((img) => img?.source === 'path').length;
|
|
114
|
-
markedUrl = images.filter((img) => img?.source === 'url').length;
|
|
111
|
+
extractorJson = JSON.parse(out);
|
|
115
112
|
} catch (err) {
|
|
116
113
|
process.stderr.write(`vision-router: extractor call skipped (${err?.message ?? err})\n`);
|
|
117
114
|
}
|
|
118
115
|
}
|
|
119
116
|
|
|
120
|
-
//
|
|
121
|
-
//
|
|
117
|
+
// Decide from what the EXTRACTOR did, not from prompt-text regex hits: `written`
|
|
118
|
+
// markers are genuinely new work; `alreadyAnalyzed` entries are closed cases that
|
|
119
|
+
// must stay silent. Regex mentions alone no longer trigger the full hint.
|
|
120
|
+
const images = Array.isArray(extractorJson?.images) ? extractorJson.images : [];
|
|
121
|
+
const analyzed = Array.isArray(extractorJson?.alreadyAnalyzed) ? extractorJson.alreadyAnalyzed : [];
|
|
122
|
+
const sessionId = typeof extractorJson?.sessionId === 'string' && extractorJson.sessionId
|
|
123
|
+
? extractorJson.sessionId
|
|
124
|
+
: '';
|
|
125
|
+
const armedCount = images.length;
|
|
126
|
+
const armedRefs = new Set(images.filter((img) => typeof img?.ref === 'string').map((img) => img.ref));
|
|
127
|
+
const analyzedRefs = new Set(analyzed.filter((a) => typeof a?.ref === 'string').map((a) => a.ref));
|
|
128
|
+
const unresolvedLocal = localMatches.filter(
|
|
129
|
+
(m) => !armedRefs.has(m.trim()) && !analyzedRefs.has(m.trim())
|
|
130
|
+
);
|
|
122
131
|
const cases = [];
|
|
123
|
-
if (
|
|
124
|
-
if (
|
|
125
|
-
if (
|
|
126
|
-
|
|
127
|
-
if (
|
|
132
|
+
if (images.some((img) => !img?.source)) cases.push('pasted image');
|
|
133
|
+
if (images.some((img) => img?.source === 'path')) cases.push('local file path');
|
|
134
|
+
if (images.some((img) => img?.source === 'url')) cases.push('image URL');
|
|
135
|
+
|
|
136
|
+
if (armedCount === 0) {
|
|
137
|
+
// No NEW markers. Only speak up when a named path never resolved at all —
|
|
138
|
+
// an already-analyzed mention is a closed case and stays silent.
|
|
139
|
+
if (unresolvedLocal.length > 0) {
|
|
140
|
+
const lines = [
|
|
141
|
+
`UKIT VISION ROUTE — named image path did not resolve from the project root (${unresolvedLocal[0]}).`,
|
|
142
|
+
'Nothing was armed for analysis. Resolve the real path or drop the reference;',
|
|
143
|
+
'never guess at the contents of an image you cannot read.',
|
|
144
|
+
];
|
|
145
|
+
process.stdout.write(`${lines.join('\n')}\n`);
|
|
146
|
+
}
|
|
128
147
|
process.exit(0);
|
|
129
148
|
return;
|
|
130
149
|
}
|
|
131
150
|
|
|
132
|
-
// Only unicMode true or null reach here; off-gateway already exited.
|
|
133
|
-
//
|
|
134
|
-
//
|
|
135
|
-
//
|
|
151
|
+
// Only unicMode true or null reach here; off-gateway already exited. There is no
|
|
152
|
+
// fallback-model branch to advertise. There must never be one: the analyst
|
|
153
|
+
// self-reports the model it really ran on, so telling it to claim some other ID is
|
|
154
|
+
// asking it to falsify the receipt.
|
|
136
155
|
let unicNote = '';
|
|
137
156
|
if (unicMode === true) {
|
|
138
157
|
try {
|
|
@@ -147,26 +166,33 @@ const { pathToFileURL } = require('url');
|
|
|
147
166
|
}
|
|
148
167
|
}
|
|
149
168
|
|
|
150
|
-
|
|
151
|
-
|
|
169
|
+
// Capability-framed, never provider identity: the remedy is "don't guess — use a
|
|
170
|
+
// verified reader", which holds whether the active model reads images natively or
|
|
171
|
+
// must hand off to the specialist.
|
|
172
|
+
const reasonLines = [
|
|
173
|
+
'Advisory: never guess at image contents. If the active model has VERIFIED native',
|
|
174
|
+
'vision for these images it may read them directly; otherwise dispatch the specialist',
|
|
175
|
+
'before relying on them:',
|
|
176
|
+
];
|
|
152
177
|
|
|
153
178
|
const lines = [
|
|
154
|
-
`UKIT VISION ROUTE — image input detected (${cases.join(', ')}).`,
|
|
155
|
-
|
|
179
|
+
`UKIT VISION ROUTE — new image input detected (${cases.join(', ')}).`,
|
|
180
|
+
...reasonLines,
|
|
156
181
|
' 1. node .claude/ukit/index/extract-image.mjs --json',
|
|
157
|
-
|
|
158
|
-
'
|
|
159
|
-
'
|
|
182
|
+
' 2. Agent(subagent_type: "ukit-vision-analyst") [model: unic-vision]',
|
|
183
|
+
' Send the ABSOLUTE paths from images[].path as TEXT (subagents do NOT inherit',
|
|
184
|
+
' image blocks; they can only Read files). Include the task envelope: the ORIGINAL',
|
|
185
|
+
' user prompt verbatim, the visual question, and the task goal — the analyst must',
|
|
186
|
+
' know what the images are FOR.',
|
|
187
|
+
' 3. Continue the real task using the analyst\'s OBSERVATIONS + INFERENCES.',
|
|
160
188
|
];
|
|
161
|
-
|
|
162
|
-
|
|
189
|
+
if (sessionId) {
|
|
190
|
+
lines.push(` Markers armed under sessionId: ${sessionId} (receipts: analyzed-<sha>.json).`);
|
|
191
|
+
}
|
|
192
|
+
if (images.some((img) => img?.source === 'url')) {
|
|
163
193
|
lines.push(' Image URL detected: ukit-vision-analyst downloads it with Bash into');
|
|
164
194
|
lines.push(' .ukit/storage/cache/vision/, then Reads the downloaded file.');
|
|
165
195
|
}
|
|
166
|
-
if (localMatches.length > markedPath) {
|
|
167
|
-
lines.push(' A named image path did not resolve from the project root — resolve it before');
|
|
168
|
-
lines.push(' analysing, and do not guess at its contents.');
|
|
169
|
-
}
|
|
170
196
|
if (unicNote) lines.push(unicNote);
|
|
171
197
|
|
|
172
198
|
process.stdout.write(`${lines.join('\n')}\n`);
|
|
@@ -54,7 +54,13 @@
|
|
|
54
54
|
"PreToolUse": [
|
|
55
55
|
{
|
|
56
56
|
"matcher": "Read|Grep|Glob",
|
|
57
|
-
"hooks": [
|
|
57
|
+
"hooks": [
|
|
58
|
+
{
|
|
59
|
+
"type": "command",
|
|
60
|
+
"command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/sensitive-data-guard.sh\"",
|
|
61
|
+
"timeout": 8
|
|
62
|
+
}
|
|
63
|
+
]
|
|
58
64
|
},
|
|
59
65
|
{
|
|
60
66
|
"matcher": "Edit|Write",
|
|
@@ -104,6 +110,11 @@
|
|
|
104
110
|
"command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/block-dangerous.sh\"",
|
|
105
111
|
"timeout": 8
|
|
106
112
|
},
|
|
113
|
+
{
|
|
114
|
+
"type": "command",
|
|
115
|
+
"command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/sensitive-data-guard.sh\"",
|
|
116
|
+
"timeout": 8
|
|
117
|
+
},
|
|
107
118
|
{
|
|
108
119
|
"type": "command",
|
|
109
120
|
"command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/handoff-model-guard.sh\"",
|
|
@@ -162,6 +173,11 @@
|
|
|
162
173
|
"UserPromptSubmit": [
|
|
163
174
|
{
|
|
164
175
|
"hooks": [
|
|
176
|
+
{
|
|
177
|
+
"type": "command",
|
|
178
|
+
"command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/sensitive-data-guard.sh\"",
|
|
179
|
+
"timeout": 8
|
|
180
|
+
},
|
|
165
181
|
{
|
|
166
182
|
"type": "command",
|
|
167
183
|
"command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/skill-router.sh\"",
|
|
@@ -6,9 +6,10 @@
|
|
|
6
6
|
* blocks, so pasted images are invisible unless this script first decodes them
|
|
7
7
|
* to disk. This is the single home of the sha256 key: `pending-<sha>` markers
|
|
8
8
|
* (written by --mark-pending) and `<sha>.<ext>` image files (written by a normal
|
|
9
|
-
* extract) MUST agree exactly, or
|
|
10
|
-
* paths call the same hashPayload() function on the exact
|
|
11
|
-
* appears in the transcript — no trimming, no normalising,
|
|
9
|
+
* extract) MUST agree exactly, or analysis receipts can never be matched back to
|
|
10
|
+
* their markers. Both paths call the same hashPayload() function on the exact
|
|
11
|
+
* base64 string as it appears in the transcript — no trimming, no normalising,
|
|
12
|
+
* no decoding first.
|
|
12
13
|
*
|
|
13
14
|
* Usage:
|
|
14
15
|
* node extract-image.mjs [options]
|
|
@@ -323,14 +324,23 @@ function main() {
|
|
|
323
324
|
// A sha with an existing analyzed-<sha>.json receipt is a closed, content-addressed case:
|
|
324
325
|
// identical bytes already produced that analysis, so skipping it loses no information.
|
|
325
326
|
const isAlreadyAnalyzed = (sha) => fs.existsSync(path.join(markerDir, `analyzed-${sha}.json`));
|
|
326
|
-
const
|
|
327
|
-
const
|
|
327
|
+
const allSelected = selectImages(blocks, opts.limit);
|
|
328
|
+
const allRefs = resolveRefs(opts.refs);
|
|
329
|
+
const selected = allSelected.filter((img) => !isAlreadyAnalyzed(img.sha));
|
|
330
|
+
const refs = allRefs.filter((ref) => !isAlreadyAnalyzed(ref.sha));
|
|
331
|
+
// Closed cases reported back so callers (vision-router.sh) can stay silent on them:
|
|
332
|
+
// a sha with an analyzed-<sha>.json receipt needs no new dispatch, and without this
|
|
333
|
+
// list the caller cannot tell "already analyzed" from "never resolved".
|
|
334
|
+
const alreadyAnalyzed = [
|
|
335
|
+
...allSelected.filter((img) => isAlreadyAnalyzed(img.sha)).map((img) => ({ sha: img.sha, source: 'pasted' })),
|
|
336
|
+
...allRefs.filter((ref) => isAlreadyAnalyzed(ref.sha)).map((ref) => ({ sha: ref.sha, source: ref.source, ref: ref.value })),
|
|
337
|
+
];
|
|
328
338
|
|
|
329
339
|
// --detect is report-only, so it must not short-circuit --mark-pending when both are
|
|
330
340
|
// passed together. vision-router.sh combines them; short-circuiting here would arm
|
|
331
|
-
// nothing and leave
|
|
341
|
+
// nothing and leave every image unmarked.
|
|
332
342
|
if (opts.detect && !opts.markPending) {
|
|
333
|
-
printResult(opts, { imageCount: selected.length + refs.length, sessionId, images: [] });
|
|
343
|
+
printResult(opts, { imageCount: selected.length + refs.length, sessionId, images: [], alreadyAnalyzed });
|
|
334
344
|
return;
|
|
335
345
|
}
|
|
336
346
|
|
|
@@ -374,7 +384,7 @@ function main() {
|
|
|
374
384
|
}
|
|
375
385
|
}
|
|
376
386
|
pruneMarkerDirs(outDir);
|
|
377
|
-
printResult(opts, { imageCount: written.length, sessionId, images: written });
|
|
387
|
+
printResult(opts, { imageCount: written.length, sessionId, images: written, alreadyAnalyzed });
|
|
378
388
|
return;
|
|
379
389
|
}
|
|
380
390
|
|
|
@@ -579,6 +579,9 @@ function printRouteState(state) {
|
|
|
579
579
|
if (state.routeSummary?.executionContract?.modelTier) {
|
|
580
580
|
console.log(`modelTier: ${state.routeSummary.executionContract.modelTier}`);
|
|
581
581
|
}
|
|
582
|
+
if (state.routeSummary?.tierLane?.instruction) {
|
|
583
|
+
console.log(`tierLane: ${state.routeSummary.tierLane.instruction}`);
|
|
584
|
+
}
|
|
582
585
|
if (state.routeSummary?.escalatedTier) {
|
|
583
586
|
console.log(`escalatedTier: ${state.routeSummary.escalatedTier}`);
|
|
584
587
|
if (state.routeSummary.escalationReason) {
|
|
@@ -1475,6 +1478,15 @@ function buildRouteSummary({
|
|
|
1475
1478
|
const primaryTargets = summarizeCompactList(preview.primaryTargets ?? [], 2);
|
|
1476
1479
|
const relatedTests = summarizeCompactList(preview.relatedTests ?? [], 2);
|
|
1477
1480
|
const styleFiles = summarizeCompactList(preview.styleFiles ?? [], 1);
|
|
1481
|
+
// WS-C target-aware evidence: the completion ledger checks impact-evidence against these
|
|
1482
|
+
// files, so carry the full resolver list (target + primaryTargets + relatedTests), bounded
|
|
1483
|
+
// for compact route state. Absent when the route has no indexed context — the ledger then
|
|
1484
|
+
// falls back to its legacy any-read behaviour.
|
|
1485
|
+
const expectedSourceFiles = unique([
|
|
1486
|
+
...(routingContext.targetFile ? [routingContext.targetFile] : []),
|
|
1487
|
+
...(preview.primaryTargets ?? []),
|
|
1488
|
+
...(preview.relatedTests ?? []),
|
|
1489
|
+
].filter((filePath) => typeof filePath === 'string' && filePath.trim())).slice(0, 8);
|
|
1478
1490
|
const primaryCommands = unique(verificationRecommendation?.commands ?? []);
|
|
1479
1491
|
const fallbackCommands = unique(verificationRecommendation?.fallbackCommands ?? []);
|
|
1480
1492
|
const preferredOrder = unique(
|
|
@@ -1497,6 +1509,7 @@ function buildRouteSummary({
|
|
|
1497
1509
|
executionCandidates,
|
|
1498
1510
|
});
|
|
1499
1511
|
const executionContract = buildExecutionContract(executionMode);
|
|
1512
|
+
const tierLane = buildTierLane({ modelTier: executionContract?.modelTier ?? null });
|
|
1500
1513
|
const completionState = buildCompletionState({
|
|
1501
1514
|
executionMode,
|
|
1502
1515
|
verificationRecommendation,
|
|
@@ -1539,8 +1552,10 @@ function buildRouteSummary({
|
|
|
1539
1552
|
executionCandidates,
|
|
1540
1553
|
approachSelector,
|
|
1541
1554
|
executionContract,
|
|
1555
|
+
tierLane,
|
|
1542
1556
|
completionState,
|
|
1543
1557
|
continuationState,
|
|
1558
|
+
...(expectedSourceFiles.length > 0 ? { expectedSourceFiles } : {}),
|
|
1544
1559
|
intentMode: routingContext.intentMode ?? null,
|
|
1545
1560
|
handoffFile,
|
|
1546
1561
|
delegateHint: delegationRecommendation?.hint ?? null,
|
|
@@ -1894,9 +1909,10 @@ function buildExecutionContract(executionMode = null) {
|
|
|
1894
1909
|
maxReadPasses: 2,
|
|
1895
1910
|
maxContextPulls: 1,
|
|
1896
1911
|
verificationPolicy: 'targeted-if-covered',
|
|
1897
|
-
completionRule: 'require-write',
|
|
1912
|
+
completionRule: 'require-write-and-verification',
|
|
1898
1913
|
delegationPolicy: 'disallow-by-default',
|
|
1899
|
-
completionEvidence: ['write-evidence'],
|
|
1914
|
+
completionEvidence: ['write-evidence', 'verification-evidence'],
|
|
1915
|
+
postEditReviewPolicy: 'sidecar-non-blocking',
|
|
1900
1916
|
},
|
|
1901
1917
|
'find-cause': {
|
|
1902
1918
|
modelTier: 'code',
|
|
@@ -1915,6 +1931,7 @@ function buildExecutionContract(executionMode = null) {
|
|
|
1915
1931
|
delegationPolicy: 'allow-qualified-sidecar',
|
|
1916
1932
|
completionEvidence: ['write-evidence', 'verification-evidence'],
|
|
1917
1933
|
mirrorConsistencyRequired: true,
|
|
1934
|
+
postEditReviewPolicy: 'sidecar-non-blocking',
|
|
1918
1935
|
},
|
|
1919
1936
|
'map-impact': {
|
|
1920
1937
|
modelTier: 'code',
|
|
@@ -2182,10 +2199,36 @@ function applyEscalationToRouteSummary(routeSummary = null, { config = null, tar
|
|
|
2182
2199
|
delete routeSummary.escalatedTier;
|
|
2183
2200
|
delete routeSummary.escalationReason;
|
|
2184
2201
|
}
|
|
2202
|
+
// Recomputed here (not only in buildRouteSummary) so the lane always reflects the final
|
|
2203
|
+
// tier after escalation, on every path that emits a route summary.
|
|
2204
|
+
routeSummary.tierLane = buildTierLane({
|
|
2205
|
+
modelTier: routeSummary.executionContract?.modelTier ?? null,
|
|
2206
|
+
escalatedTier: escalation?.escalatedTier ?? null,
|
|
2207
|
+
});
|
|
2185
2208
|
|
|
2186
2209
|
return routeSummary;
|
|
2187
2210
|
}
|
|
2188
2211
|
|
|
2212
|
+
// WS-E tier-role wiring: a contract tier only takes effect when the executing session hands
|
|
2213
|
+
// the work to an agent whose definition binds that tier — otherwise every session behaves as
|
|
2214
|
+
// if it only had one model. `code` is the DEFAULT implementation lane, so a code-tier contract
|
|
2215
|
+
// produces no instruction (inline execution on a code session is already correct, and silence
|
|
2216
|
+
// keeps the hook flow smooth). Labels are stable tier roles (lite/code/smart), never provider
|
|
2217
|
+
// or model identity. Vision never appears here — it is a capability lane, not a cost tier.
|
|
2218
|
+
function buildTierLane({ modelTier = null, escalatedTier = null } = {}) {
|
|
2219
|
+
const tier = escalatedTier ?? modelTier;
|
|
2220
|
+
if (!tier || tier === 'vision') {
|
|
2221
|
+
return null;
|
|
2222
|
+
}
|
|
2223
|
+
return {
|
|
2224
|
+
tier,
|
|
2225
|
+
defaultLane: tier === 'code',
|
|
2226
|
+
instruction: tier === 'code'
|
|
2227
|
+
? null
|
|
2228
|
+
: `Hand this task to an agent bound to the ${tier} tier instead of doing it inline, unless this session already runs on the ${tier} tier.`,
|
|
2229
|
+
};
|
|
2230
|
+
}
|
|
2231
|
+
|
|
2189
2232
|
function advanceContinuationState(continuationState = null, previousContinuationState = null, thresholds = null) {
|
|
2190
2233
|
if (!continuationState || typeof continuationState !== 'object') {
|
|
2191
2234
|
return continuationState;
|
|
@@ -2875,6 +2918,14 @@ function compactRouteSummary(routeSummary = null) {
|
|
|
2875
2918
|
completionState: routeSummary.completionState ?? null,
|
|
2876
2919
|
continuationState: routeSummary.continuationState ?? null,
|
|
2877
2920
|
delegateHint: routeSummary.delegateHint ?? null,
|
|
2921
|
+
// WS-E tier-role binding: consumers need the hand-off instruction, not just the raw tier.
|
|
2922
|
+
...(routeSummary.tierLane ? { tierLane: routeSummary.tierLane } : {}),
|
|
2923
|
+
// WS-C target-aware evidence: the completion ledger reads these from the PERSISTED route
|
|
2924
|
+
// state — without this spread the CLI path never delivers them and the gate falls back
|
|
2925
|
+
// to legacy any-read behaviour.
|
|
2926
|
+
...(Array.isArray(routeSummary.expectedSourceFiles) && routeSummary.expectedSourceFiles.length > 0 ? {
|
|
2927
|
+
expectedSourceFiles: routeSummary.expectedSourceFiles,
|
|
2928
|
+
} : {}),
|
|
2878
2929
|
nextActionType: routeSummary.nextActionType ?? null,
|
|
2879
2930
|
nextActionCommand: routeSummary.nextActionCommand ?? null,
|
|
2880
2931
|
helperHint: routeSummary.helperHint ?? null,
|