@ngockhoale/ukit 2.2.12 → 2.2.16

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.
@@ -1,230 +0,0 @@
1
- #!/bin/bash
2
- # PreToolUse hook: hard-enforce the TASK-008 vision-analysis gate.
3
- #
4
- # Blocks Edit/Write whenever a pending image marker (written by vision-router.sh via
5
- # extract-image.mjs --mark-pending) has no matching analyzed-<sha>.json receipt from a
6
- # vision-capable model. Opposite fail direction from vision-router.sh: that hook fails
7
- # OPEN (never wedge a prompt); this hook fails CLOSED (a broken gate must not silently
8
- # permit blind edits). "Fails closed" applies only to this hook's own logic errors — if
9
- # no pending marker exists at all, it exits 0 immediately, before any other logic. That
10
- # is >99% of invocations and must stay free.
11
- #
12
- # Always hard-blocks (exit 2). No advisory/soft mode — matches handoff-model-guard.sh's
13
- # posture: the user explicitly asked for strict, mechanical, non-bypassable enforcement.
14
- # A wrong image analysis yields confidently wrong results, which is worse than no
15
- # analysis at all.
16
- #
17
- # Matcher is Edit|Write ONLY. Never Read/Grep/Glob/Bash — ukit-vision-analyst itself
18
- # needs Read to see the extracted image and Bash to write its receipt; gating those
19
- # tools would deadlock the only lane that can clear the gate.
20
- #
21
- # Never recomputes a sha. extract-image.mjs (TASK-002) is the single owner of the hash;
22
- # this hook only matches pending-<sha>.json to analyzed-<sha>.json by filename.
23
- #
24
- # Session-scoped and 24h-bounded: a stale marker from another session, or an abandoned
25
- # session's marker older than 24h, must never permanently block writes.
26
-
27
- INPUT=$(cat)
28
- PROJECT_ROOT="${CLAUDE_PROJECT_DIR:-$PWD}"
29
-
30
- INPUT="$INPUT" PROJECT_ROOT="$PROJECT_ROOT" node <<'NODE'
31
- const fs = require('fs');
32
- const path = require('path');
33
- const { pathToFileURL } = require('url');
34
-
35
- const MARKER_MAX_AGE_MS = 24 * 60 * 60 * 1000; // 24h
36
-
37
- const payload = (() => {
38
- try {
39
- const parsed = JSON.parse(process.env.INPUT || '');
40
- return parsed && typeof parsed === 'object' ? parsed : {};
41
- } catch {
42
- return {};
43
- }
44
- })();
45
-
46
- const projectRoot = process.env.PROJECT_ROOT;
47
- const toolName = payload?.tool_name || '';
48
-
49
- // Gate only Edit|Write. Every other tool (Read, Grep, Glob, Bash, ...) is free — the
50
- // vision analyst needs Read/Bash to clear this very gate. This check is independent of
51
- // tool_input shape: the gate never inspects file_path/content, only tool_name.
52
- if (toolName !== 'Edit' && toolName !== 'Write') {
53
- process.exit(0);
54
- }
55
-
56
- const sessionId = (typeof payload.session_id === 'string' && payload.session_id.trim())
57
- ? payload.session_id.trim()
58
- : 'default';
59
-
60
- const sessionDir = path.join(projectRoot, '.ukit', 'storage', 'cache', 'vision', sessionId);
61
-
62
- let markerNames = [];
63
- try {
64
- markerNames = fs.readdirSync(sessionDir).filter((name) => /^pending-.+\.json$/.test(name));
65
- } catch {
66
- markerNames = [];
67
- }
68
-
69
- // No pending marker at all: the overwhelmingly common path. Exit 0 immediately, before
70
- // any config reads, gateway detection, or receipt checks.
71
- if (markerNames.length === 0) {
72
- process.exit(0);
73
- }
74
-
75
- function readJsonSafe(filePath) {
76
- try {
77
- const raw = fs.readFileSync(filePath, 'utf8');
78
- const parsed = JSON.parse(raw);
79
- return parsed && typeof parsed === 'object' ? parsed : null;
80
- } catch {
81
- return null;
82
- }
83
- }
84
-
85
- // Tri-state on purpose: true | false | null(unknown). Returning false on a detection
86
- // FAILURE would be read as "unicMode is off", which GRANTS the fallback-model exception
87
- // below — i.e. deleting or breaking one gitignored file would make the gate accept a
88
- // plain code-tier receipt. Unknown must deny the exception, not grant it.
89
- async function detectUnicModeSafe() {
90
- try {
91
- const gatewayPath = path.join(projectRoot, '.claude', 'ukit', 'index', 'unic-gateway.mjs');
92
- if (!fs.existsSync(gatewayPath)) return null;
93
- const mod = await import(pathToFileURL(gatewayPath).href);
94
- if (typeof mod.detectUnicGateway !== 'function') return null;
95
- const result = mod.detectUnicGateway({ rootDir: projectRoot });
96
- return !!result?.unicMode;
97
- } catch {
98
- return null;
99
- }
100
- }
101
-
102
- // Anchored, not a substring match: "unic-vision-mini" and "not-unic-vision" are different
103
- // models and must not inherit the real one's capability by sharing a substring.
104
- // Only reached when unicMode is true or unknown — off-gateway sessions already exited
105
- // above — so `unic-vision` is the only model that can legitimately clear the gate here.
106
- function isVisionCapable(model) {
107
- if (typeof model !== 'string' || !model.trim()) return false;
108
- return /^unic-vision$/i.test(model.trim());
109
- }
110
-
111
- const VALID_STATUSES = new Set(['OK', 'NO_IMAGE', 'UNREADABLE']);
112
-
113
- // A receipt whose self-reported model is not vision-capable (unic-code, unic-smart,
114
- // sonnet, opus, ...) is treated as ABSENT and still blocks — accepting it would make the
115
- // whole enforcement design theatre. A JSON.parse throw is caught per-receipt and turned
116
- // into BLOCK, never an accidental skip-through.
117
- // The gate keys its directory on the hook payload's session_id, but the analyst resolves
118
- // its own sessionId by discovering the newest transcript. Those agree in the common case
119
- // and diverge with concurrent sessions on one repo, which would leave the receipt in a
120
- // sibling directory and hold the gate shut. The sha is content-addressed, so accepting a
121
- // receipt from any session dir is safe: it still must exist and be vision-capable.
122
- function findReceipt(sha) {
123
- const own = path.join(sessionDir, `analyzed-${sha}.json`);
124
- if (fs.existsSync(own)) return own;
125
- const visionRoot = path.join(projectRoot, '.ukit', 'storage', 'cache', 'vision');
126
- let entries = [];
127
- try {
128
- entries = fs.readdirSync(visionRoot, { withFileTypes: true });
129
- } catch {
130
- return null;
131
- }
132
- for (const entry of entries) {
133
- if (!entry.isDirectory() || entry.name === sessionId) continue;
134
- const candidate = path.join(visionRoot, entry.name, `analyzed-${sha}.json`);
135
- if (fs.existsSync(candidate)) return candidate;
136
- }
137
- return null;
138
- }
139
-
140
- function checkReceipt(receiptPath) {
141
- if (!receiptPath || !fs.existsSync(receiptPath)) {
142
- return { ok: false, reason: 'no analysis receipt found' };
143
- }
144
- const receipt = readJsonSafe(receiptPath);
145
- if (!receipt) {
146
- return { ok: false, reason: 'receipt is not valid JSON' };
147
- }
148
- if (!isVisionCapable(receipt.model)) {
149
- const modelLabel = typeof receipt.model === 'string' && receipt.model.trim() ? receipt.model.trim() : '(missing)';
150
- return { ok: false, reason: `receipt model "${modelLabel}" is not vision-capable — treated as absent` };
151
- }
152
- const status = receipt.status;
153
- if (!VALID_STATUSES.has(status)) {
154
- return { ok: false, reason: `receipt status "${status}" is not a valid terminal outcome` };
155
- }
156
- return { ok: true };
157
- }
158
-
159
- (async () => {
160
- const now = Date.now();
161
- const unicMode = await detectUnicModeSafe();
162
-
163
- // The gate exists because the UNIC gateway BLINDS the model: unic-code/unic-smart
164
- // cannot see images there, so an unanalysed image means guessing. Off the gateway
165
- // Claude reads images natively and there is nothing left to enforce.
166
- //
167
- // This early exit is what makes the gate satisfiable at all off-gateway. The previous
168
- // design tried to cover this case by accepting a receipt whose model string equalled
169
- // modelTiers.vision.fallbackModel — but the analyst's frontmatter pins `unic-vision`,
170
- // so off-gateway it runs on whatever model is actually available and self-reports THAT.
171
- // A hardcoded fallback ID could never match it, so no valid receipt was reachable and
172
- // one pasted screenshot hard-blocked Edit/Write for the rest of the session.
173
- //
174
- // Only a POSITIVE false stands the gate down. null (detection failed) still enforces:
175
- // breaking one gitignored file must not be a bypass.
176
- if (unicMode === false) {
177
- process.exit(0);
178
- }
179
-
180
- const unsatisfied = [];
181
- for (const markerName of markerNames) {
182
- const sha = markerName.replace(/^pending-/, '').replace(/\.json$/, '');
183
- const markerPath = path.join(sessionDir, markerName);
184
-
185
- let markerTs = null;
186
- const marker = readJsonSafe(markerPath);
187
- if (marker && typeof marker.ts === 'number' && Number.isFinite(marker.ts)) {
188
- markerTs = marker.ts;
189
- }
190
- if (markerTs == null) {
191
- try {
192
- markerTs = fs.statSync(markerPath).mtimeMs;
193
- } catch {
194
- markerTs = now;
195
- }
196
- }
197
- // Abandoned-session bound: a marker older than 24h can never brick the repo.
198
- if (now - markerTs > MARKER_MAX_AGE_MS) continue;
199
-
200
- const receiptPath = findReceipt(sha);
201
- const result = checkReceipt(receiptPath);
202
- if (!result.ok) {
203
- unsatisfied.push({ sha, reason: result.reason });
204
- }
205
- }
206
-
207
- if (unsatisfied.length === 0) {
208
- process.exit(0);
209
- return;
210
- }
211
-
212
- const lines = [
213
- 'BLOCKED (vision gate): image(s) pending analysis — Edit/Write refused.',
214
- ...unsatisfied.map((u) => ` - sha ${u.sha}: ${u.reason}`),
215
- 'unic-code / unic-smart cannot read images on this gateway and must not guess at their',
216
- 'contents — a wrong image analysis yields confidently wrong results.',
217
- 'Remedy: delegate to Agent(subagent_type: "ukit-vision-analyst") to analyse the pending',
218
- 'image(s) first, then retry this edit.',
219
- ];
220
- process.stderr.write(`${lines.join('\n')}\n`);
221
- process.exit(2);
222
- })().catch((err) => {
223
- // A logic error inside this hook's own JS must still fail CLOSED — the opposite fail
224
- // direction from vision-router.sh, which fails open unconditionally.
225
- process.stderr.write(`BLOCKED (vision gate): internal error, failing closed: ${err?.message ?? err}\n`);
226
- process.exit(2);
227
- });
228
- NODE
229
-
230
- exit $?