@ngockhoale/ukit 2.2.14 → 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.
Files changed (33) hide show
  1. package/CHANGELOG.md +69 -0
  2. package/manifests/platform.full.yaml +11 -13
  3. package/package.json +1 -1
  4. package/src/core/executionContracts.js +130 -0
  5. package/src/core/runtimeConfig.js +13 -50
  6. package/src/index/taskRouting.js +10 -103
  7. package/templates/.claude/agents/ukit-vision-analyst.md +32 -21
  8. package/templates/.claude/hooks/context-hardcap-gate.sh +2 -2
  9. package/templates/.claude/hooks/protect-files.sh +1 -0
  10. package/templates/.claude/hooks/sensitive-data-guard.sh +269 -0
  11. package/templates/.claude/hooks/skill-router.sh +6 -0
  12. package/templates/.claude/hooks/vision-router.sh +67 -47
  13. package/templates/.claude/settings.json +17 -6
  14. package/templates/.claude/ukit/index/extract-image.mjs +18 -8
  15. package/templates/.claude/ukit/index/provision-worktree.mjs +1 -1
  16. package/templates/.claude/ukit/index/route-task.mjs +53 -2
  17. package/templates/.claude/ukit/index/unic-gateway.mjs +1 -1
  18. package/templates/.claude/ukit/runtime/execution-ledger.mjs +120 -21
  19. package/templates/.claude/ukit/runtime/hook-chain-runner.mjs +8 -5
  20. package/templates/.omp/README.md +3 -3
  21. package/templates/.omp/hooks/pre/ukit-bridge.js +15 -7
  22. package/templates/AGENTS.md +2 -2
  23. package/templates/CLAUDE.md +2 -2
  24. package/templates/ukit/storage/config.json +8 -7
  25. package/src/core/router/advisor.js +0 -42
  26. package/src/core/router/router.js +0 -180
  27. package/src/core/validation/confidence.js +0 -89
  28. package/src/core/validation/validator.js +0 -165
  29. package/templates/.claude/hooks/vision-gate.sh +0 -230
  30. package/templates/docs/INSTALL.md +0 -115
  31. package/templates/docs/STATUS.md +0 -81
  32. package/templates/docs/TASKS.md +0 -79
  33. 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
  }
@@ -1,11 +1,10 @@
1
1
  #!/bin/bash
2
- # UserPromptSubmit hook: detect image input (pasted / local path / URL) and arm the
3
- # TASK-008 write gate by writing pending-<sha>.json markers via extract-image.mjs.
2
+ # UserPromptSubmit hook: detect image input (pasted / local path / URL) and mark it for
3
+ # the vision analyst by writing pending-<sha>.json markers via extract-image.mjs.
4
4
  #
5
5
  # FAILS OPEN, unconditionally: this hook must never block the user's prompt. Any
6
6
  # failure (malformed stdin, missing extractor, node crash) degrades to "no hint,
7
- # no markers" and exit 0. This is the deliberate opposite of the TASK-008 gate,
8
- # which fails closed on Edit/Write.
7
+ # no markers" and exit 0. Advisory only — nothing is ever blocked.
9
8
  #
10
9
  # Detection only — never decodes an image, never writes an image file. Marker
11
10
  # writing is delegated entirely to `extract-image.mjs --mark-pending`; this hook
@@ -13,6 +12,12 @@
13
12
  #
14
13
  # Runs on EVERY prompt, 8s budget: one shell-out to extract-image.mjs
15
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.
16
21
 
17
22
  INPUT=$(cat)
18
23
  PROJECT_ROOT="${CLAUDE_PROJECT_DIR:-$PWD}"
@@ -44,7 +49,7 @@ const { pathToFileURL } = require('url');
44
49
 
45
50
  const promptText = extractPromptText(payload);
46
51
 
47
- // Tri-state, same contract as vision-gate.sh: true | false | null(unknown).
52
+ // Tri-state: true | false | null(unknown). Only a positive false short-circuits.
48
53
  async function detectUnicModeSafe() {
49
54
  try {
50
55
  const gatewayPath = path.join(projectRoot, '.claude', 'ukit', 'index', 'unic-gateway.mjs');
@@ -82,12 +87,9 @@ const { pathToFileURL } = require('url');
82
87
 
83
88
  // Shell out to the single owner of image detection + marker writing. Runs on every
84
89
  // prompt (cheap: --detect --mark-pending, never decodes). All three input forms are
85
- // armed here, not just pasted images: a path or URL that only produced hint text would
86
- // leave Edit/Write ungated, which is exactly the "analyse blind" case this cycle exists
87
- // to prevent. --ref hashes a local file's bytes / a URL string inside the extractor.
88
- let markedPasted = 0;
89
- let markedPath = 0;
90
- 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;
91
93
  const extractorPath = path.join(projectRoot, '.claude', 'ukit', 'index', 'extract-image.mjs');
92
94
  if (fs.existsSync(extractorPath)) {
93
95
  const args = [extractorPath, '--detect', '--mark-pending', '--json'];
@@ -106,37 +108,50 @@ const { pathToFileURL } = require('url');
106
108
  encoding: 'utf8',
107
109
  timeout: 6000,
108
110
  });
109
- const parsed = JSON.parse(out);
110
- const images = Array.isArray(parsed?.images) ? parsed.images : [];
111
- // Ref markers carry a `source`; pasted ones do not. Counting this way stays correct
112
- // when a named path does not exist on disk and the extractor drops it.
113
- markedPasted = images.filter((img) => !img?.source).length;
114
- markedPath = images.filter((img) => img?.source === 'path').length;
115
- markedUrl = images.filter((img) => img?.source === 'url').length;
111
+ extractorJson = JSON.parse(out);
116
112
  } catch (err) {
117
113
  process.stderr.write(`vision-router: extractor call skipped (${err?.message ?? err})\n`);
118
114
  }
119
115
  }
120
116
 
121
- // `cases` reflects what the PROMPT mentioned; `armed` reflects what actually got a
122
- // pending marker. They differ when a named path does not resolve from the project root
123
- // — still worth a hint, but the gate is not armed and must not be claimed to be.
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
+ );
124
131
  const cases = [];
125
- if (markedPasted > 0) cases.push('pasted image');
126
- if (localMatches.length > 0) cases.push('local file path');
127
- if (urlMatches.length > 0) cases.push('image URL');
128
- const armed = markedPasted + markedPath + markedUrl;
129
-
130
- 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
+ }
131
147
  process.exit(0);
132
148
  return;
133
149
  }
134
150
 
135
- // Only unicMode true or null reach here; off-gateway already exited. Both remaining
136
- // cases get the same strict `unic-vision` remedy, so there is no fallback-model branch
137
- // to advertise. There must never be one: the analyst self-reports the model it really
138
- // ran on, so telling it to claim some other ID is asking it to falsify the receipt —
139
- // which the gate is entitled to reject, and which defeats the point of having a gate.
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.
140
155
  let unicNote = '';
141
156
  if (unicMode === true) {
142
157
  try {
@@ -151,28 +166,33 @@ const { pathToFileURL } = require('url');
151
166
  }
152
167
  }
153
168
 
154
- const reasonLine = armed > 0
155
- ? 'unic-code / unic-smart cannot read images on this gateway, and Edit/Write is now GATED\nuntil a vision analysis exists. Do this before anything else:'
156
- : 'unic-code / unic-smart cannot read images on this gateway. Do this before anything else:';
157
- 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
+ ];
158
177
 
159
178
  const lines = [
160
- `UKIT VISION ROUTE — image input detected (${cases.join(', ')}).`,
161
- reasonLine,
179
+ `UKIT VISION ROUTE — new image input detected (${cases.join(', ')}).`,
180
+ ...reasonLines,
162
181
  ' 1. node .claude/ukit/index/extract-image.mjs --json',
163
- agentLine,
164
- ' Pass the ABSOLUTE paths from images[].path as text — a subagent does NOT',
165
- ' 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.',
166
188
  ];
167
- lines.push(' 3. Continue the real task using the returned description.');
168
- 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')) {
169
193
  lines.push(' Image URL detected: ukit-vision-analyst downloads it with Bash into');
170
194
  lines.push(' .ukit/storage/cache/vision/, then Reads the downloaded file.');
171
195
  }
172
- if (localMatches.length > markedPath) {
173
- lines.push(' A named image path did not resolve from the project root — resolve it before');
174
- lines.push(' analysing, and do not guess at its contents.');
175
- }
176
196
  if (unicNote) lines.push(unicNote);
177
197
 
178
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",
@@ -84,11 +90,6 @@
84
90
  "command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/handoff-model-guard.sh\"",
85
91
  "timeout": 8
86
92
  },
87
- {
88
- "type": "command",
89
- "command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/vision-gate.sh\"",
90
- "timeout": 8
91
- },
92
93
  {
93
94
  "type": "command",
94
95
  "command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/context-hardcap-gate.sh\"",
@@ -109,6 +110,11 @@
109
110
  "command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/block-dangerous.sh\"",
110
111
  "timeout": 8
111
112
  },
113
+ {
114
+ "type": "command",
115
+ "command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/sensitive-data-guard.sh\"",
116
+ "timeout": 8
117
+ },
112
118
  {
113
119
  "type": "command",
114
120
  "command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/handoff-model-guard.sh\"",
@@ -167,6 +173,11 @@
167
173
  "UserPromptSubmit": [
168
174
  {
169
175
  "hooks": [
176
+ {
177
+ "type": "command",
178
+ "command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/sensitive-data-guard.sh\"",
179
+ "timeout": 8
180
+ },
170
181
  {
171
182
  "type": "command",
172
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 the TASK-008 gate deadlocks forever. Both
10
- * paths call the same hashPayload() function on the exact base64 string as it
11
- * appears in the transcript — no trimming, no normalising, no decoding first.
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 selected = selectImages(blocks, opts.limit).filter((img) => !isAlreadyAnalyzed(img.sha));
327
- const refs = resolveRefs(opts.refs).filter((ref) => !isAlreadyAnalyzed(ref.sha));
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 the TASK-008 write gate permanently open.
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
 
@@ -7,7 +7,7 @@
7
7
  * - .claude/ -> REAL COPY preserving file modes (never a symlink: a worktree
8
8
  * edit must not write through into the live main-tree mirror).
9
9
  * Mode preservation matters: every .claude/hooks/*.sh is 755 and
10
- * tests/handoff/cycle4/vision-gate.test.mjs asserts the exec bit.
10
+ * tests/handoff/cycle7/provision-worktree.test.mjs asserts the exec bit.
11
11
  * - .ukit/storage/config.json -> copy
12
12
  * - .cache/index/ -> copy
13
13
  *