@ngockhoale/ukit 2.2.16 → 2.3.1

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.
@@ -131,24 +131,14 @@ const estimatedTokens = Math.round(contentChars / CHARS_PER_TOKEN);
131
131
  const hardCap = loadHardCap();
132
132
  const ratio = estimatedTokens / hardCap;
133
133
 
134
- if (ratio < 0.8) process.exit(0);
135
-
136
- const pct = Math.round(ratio * 100);
137
- const phase = ratio >= 1 ? 'hard' : 'soft';
138
-
139
- // Debounce the directive block so it does not re-print in full every single prompt
140
- // while the phase is unchanged — that would just be more tokens added to the same
141
- // oversized context it is warning about. Re-arms on phase change (soft -> hard) or
142
- // after the cooldown, and resets naturally once a real compaction/new session drops
143
- // the live transcript back under 0.8, since this whole branch exits early above.
134
+ // Debounce/state file shared by the context warning and the gateway model-swap note.
144
135
  const GUARD_STATE_PATH = path.join(projectRoot, '.ukit', 'storage', 'cache', 'context-guard-state.json');
145
- const COOLDOWN_MS = phase === 'hard' ? 3 * 60 * 1000 : 8 * 60 * 1000;
146
136
 
147
137
  function readGuardState() {
148
138
  try {
149
139
  return JSON.parse(fs.readFileSync(GUARD_STATE_PATH, 'utf8'));
150
140
  } catch {
151
- return { lastPhase: null, lastActionAt: 0 };
141
+ return { lastPhase: null, lastActionAt: 0, lastSwapModel: null, lastSwapNoteAt: 0 };
152
142
  }
153
143
  }
154
144
 
@@ -160,6 +150,68 @@ function writeGuardState(state) {
160
150
  }
161
151
 
162
152
  const guardState = readGuardState();
153
+ let persistedState = { ...guardState };
154
+
155
+ // ── Gateway model-swap note ──
156
+ // Assistant entries carry the REAL model that served them, so a swapping gateway
157
+ // (the backend alias resolves to a different vendor model, e.g. on limit) is directly
158
+ // visible here. Swapping is ROUTINE on such gateways, not an incident: a swap can
159
+ // look like a short stall (cold prompt cache re-reads the context once) and providers
160
+ // expose slightly different tools. So this is ONE line, cooldown-bounded — never a
161
+ // per-swap directive block — telling the session the only two things that matter:
162
+ // keep working through swaps, and on a tool error after a swap, re-check the tool and
163
+ // call it again instead of stopping. Runs before the ratio early-exit because a swap
164
+ // can happen at any context level.
165
+ const RECENT_MODEL_WINDOW = 40;
166
+ const SWAP_NOTE_COOLDOWN_MS = 60 * 60 * 1000;
167
+ const modelSequence = [];
168
+ for (let i = start; i < lines.length; i += 1) {
169
+ let entry;
170
+ try {
171
+ entry = JSON.parse(lines[i]);
172
+ } catch {
173
+ continue;
174
+ }
175
+ if (entry?.isSidechain || entry?.type !== 'assistant') continue;
176
+ const model = entry?.message?.model;
177
+ if (typeof model === 'string' && model.trim()) modelSequence.push(model.trim());
178
+ }
179
+ const recentModels = modelSequence.slice(-RECENT_MODEL_WINDOW);
180
+ const currentModel = recentModels[recentModels.length - 1] || null;
181
+ let previousModel = null;
182
+ for (let i = recentModels.length - 2; i >= 0; i -= 1) {
183
+ if (recentModels[i] !== currentModel) {
184
+ previousModel = recentModels[i];
185
+ break;
186
+ }
187
+ }
188
+ const swapDetected = Boolean(currentModel && previousModel);
189
+ if (swapDetected && persistedState.lastSwapModel !== currentModel) {
190
+ persistedState.lastSwapModel = currentModel;
191
+ const withinSwapCooldown = (Date.now() - Number(persistedState.lastSwapNoteAt || 0)) < SWAP_NOTE_COOLDOWN_MS;
192
+ if (!withinSwapCooldown) {
193
+ persistedState.lastSwapNoteAt = Date.now();
194
+ writeGuardState(persistedState);
195
+ process.stdout.write(
196
+ `UKIT GATEWAY — routine backend model swap ("${currentModel}" after "${previousModel}"): keep working, do not restart or re-plan; if a tool call errors after a swap, re-check the tool (providers differ) and simply call it again — never stop over it.\n`,
197
+ );
198
+ } else {
199
+ writeGuardState(persistedState);
200
+ }
201
+ }
202
+
203
+ if (ratio < 0.8) process.exit(0);
204
+
205
+ const pct = Math.round(ratio * 100);
206
+ const phase = ratio >= 1 ? 'hard' : 'soft';
207
+
208
+ // Debounce the directive block so it does not re-print in full every single prompt
209
+ // while the phase is unchanged — that would just be more tokens added to the same
210
+ // oversized context it is warning about. Re-arms on phase change (soft -> hard) or
211
+ // after the cooldown, and resets naturally once a real compaction/new session drops
212
+ // the live transcript back under 0.8, since this whole branch exits early above.
213
+ const COOLDOWN_MS = phase === 'hard' ? 3 * 60 * 1000 : 8 * 60 * 1000;
214
+
163
215
  const now = Date.now();
164
216
  const withinCooldown = guardState.lastPhase === phase
165
217
  && (now - Number(guardState.lastActionAt || 0)) < COOLDOWN_MS;
@@ -206,7 +258,7 @@ if (withinCooldown) {
206
258
  if (sidechainEntries > 0) {
207
259
  lines_out.push(`Note: ${sidechainEntries} subagent entries in this stretch. Keep concurrency at or below handoff.maxParallelAgents and keep returns short; do not widen the batch while this warning stands.`);
208
260
  }
209
- writeGuardState({ lastPhase: phase, lastActionAt: now });
261
+ writeGuardState({ ...persistedState, lastPhase: phase, lastActionAt: now });
210
262
  } else {
211
263
  lines_out.push('ACTION THIS TURN, before starting new investigation/subagents/pipeline phases:');
212
264
  lines_out.push('1) Persist current progress now (update docs/STATUS.md, e.g. via the update-status skill) so nothing is lost.');
@@ -215,7 +267,7 @@ if (withinCooldown) {
215
267
  if (sidechainEntries > 0) {
216
268
  lines_out.push(`Note: ${sidechainEntries} subagent entries in this stretch — each teammate carries its own context window, and every finished report is injected back here, so running many at once is the fastest way to overflow this session. Avoid spawning more until context drops back under the cap.`);
217
269
  }
218
- writeGuardState({ lastPhase: phase, lastActionAt: now });
270
+ writeGuardState({ ...persistedState, lastPhase: phase, lastActionAt: now });
219
271
  }
220
272
 
221
273
  process.stdout.write(`${lines_out.join('\n')}\n`);
@@ -25,6 +25,7 @@ PROTECTED_PATTERNS=(
25
25
  ".git/"
26
26
  "node_modules/"
27
27
  ".claude/settings.local.json"
28
+ ".ukit/storage/security/"
28
29
  )
29
30
 
30
31
  for pattern in "${PROTECTED_PATTERNS[@]}"; do
@@ -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 $?
@@ -41,6 +41,33 @@ STATE_FILE="$PROJECT_ROOT/.claude/ukit/skill-router-state.json"
41
41
  HOOK_DIR="$(cd "$(dirname "$0")" && pwd)"
42
42
  THRESHOLD_SCRIPT="$HOOK_DIR/../ukit/runtime/compact-threshold.mjs"
43
43
 
44
+ # Sidechain early-exit: PreToolUse/PostToolUse payloads carry agent_id/agent_type only
45
+ # when the tool call runs INSIDE a subagent (documented marker). A subagent's Edit/Bash
46
+ # must not re-key or re-classify the MAIN request's route — that churn swapped
47
+ # completionEvidence mid-request and made the main Stop gate demand the wrong evidence.
48
+ # Fails OPEN: any parse problem, missing field, or node error falls through to normal
49
+ # routing. Pure-bash pre-check first: the node guard can only match when the literal
50
+ # "agent_id" key appears, so ordinary calls never pay the node spawn.
51
+ case "$INPUT" in
52
+ *'"agent_id"'*|*'"agent_type"'*)
53
+ if printf '%s' "$INPUT" | node -e '
54
+ const chunks = [];
55
+ process.stdin.on("data", (c) => chunks.push(c));
56
+ process.stdin.on("end", () => {
57
+ try {
58
+ const payload = JSON.parse(Buffer.concat(chunks).toString("utf8") || "{}");
59
+ process.exit(payload && typeof payload === "object"
60
+ && (payload.agent_id || payload.agent_type) ? 0 : 1);
61
+ } catch {
62
+ process.exit(1);
63
+ }
64
+ });
65
+ ' >/dev/null 2>&1; then
66
+ exit 0
67
+ fi
68
+ ;;
69
+ esac
70
+
44
71
  INPUT="$INPUT" PROJECT_ROOT="$PROJECT_ROOT" STATE_FILE="$STATE_FILE" HOOK_DIR="$HOOK_DIR" node <<'NODE'
45
72
  const fs = require('fs');
46
73
  const path = require('path');
@@ -2415,6 +2442,10 @@ const { pathToFileURL } = require('url');
2415
2442
  }
2416
2443
 
2417
2444
  ensureDir(statePath);
2445
+ // A no-route prompt (short nudge, informational question) must not wipe the last full
2446
+ // route: completion-gate.sh reads routeSummary/requestKey from this state to decide
2447
+ // whether a Stop is premature. Downgrading to a route-less state here silently disarms
2448
+ // the gate mid-task, so carry the previous route forward until a real route replaces it.
2418
2449
  fs.writeFileSync(statePath, JSON.stringify({
2419
2450
  fingerprint,
2420
2451
  ts: now,
@@ -2422,6 +2453,8 @@ const { pathToFileURL } = require('url');
2422
2453
  activeSkills: [],
2423
2454
  routingContext: compactRoutingContext(routingContext),
2424
2455
  ...(previousContext ? { previousContext: compactPreviousContext(previousContext) } : {}),
2456
+ ...(previous?.routeSummary ? { routeSummary: previous.routeSummary } : {}),
2457
+ ...(previous?.requestKey ? { requestKey: previous.requestKey } : {}),
2425
2458
  }));
2426
2459
  process.exit(0);
2427
2460
  }
@@ -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: a path or URL that only produced hint text would
85
- // leave Edit/Write ungated, which is exactly the "analyse blind" case this cycle exists
86
- // to prevent. --ref hashes a local file's bytes / a URL string inside the extractor.
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
- const parsed = JSON.parse(out);
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
- // `cases` reflects what the PROMPT mentioned. Named paths that do not resolve from
121
- // the project root still get the unresolved-path note below.
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 (markedPasted > 0) cases.push('pasted image');
124
- if (localMatches.length > 0) cases.push('local file path');
125
- if (urlMatches.length > 0) cases.push('image URL');
126
-
127
- if (cases.length === 0) {
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. Both remaining
133
- // cases get the same strict `unic-vision` remedy, so there is no fallback-model branch
134
- // to advertise. There must never be one: the analyst self-reports the model it really
135
- // ran on, so telling it to claim some other ID is asking it to falsify the receipt.
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
- const reasonLine = 'unic-code / unic-smart cannot read images on this gateway — never guess at\nimage contents. Do this before relying on them:';
151
- const agentLine = ' 2. Agent(subagent_type: "ukit-vision-analyst") [model: unic-vision]';
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
- reasonLine,
179
+ `UKIT VISION ROUTE — new image input detected (${cases.join(', ')}).`,
180
+ ...reasonLines,
156
181
  ' 1. node .claude/ukit/index/extract-image.mjs --json',
157
- agentLine,
158
- ' Pass the ABSOLUTE paths from images[].path as text — a subagent does NOT',
159
- ' inherit image blocks; it can only Read files.',
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
- lines.push(' 3. Continue the real task using the returned description.');
162
- if (markedUrl > 0) {
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\"",