@ngockhoale/ukit 2.7.4 → 2.7.6
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 +64 -0
- package/manifests/platform.full.yaml +43 -0
- package/package.json +1 -1
- package/src/core/ompConfigMerge.js +7 -2
- package/src/core/permissionDoctor.js +59 -4
- package/src/core/permissionPolicy.js +1 -1
- package/templates/.claude/hooks/block-dangerous.mjs +377 -0
- package/templates/.claude/hooks/block-dangerous.sh +24 -223
- package/templates/.claude/hooks/record-execution.mjs +141 -0
- package/templates/.claude/hooks/record-execution.sh +14 -9
- package/templates/.claude/hooks/sensitive-data-guard.mjs +513 -0
- package/templates/.claude/hooks/sensitive-data-guard.sh +1 -353
- package/templates/.claude/settings.json +6 -7
- package/templates/.claude/ukit/runtime/execution-ledger.mjs +17 -1
- package/templates/.claude/ukit/runtime/hook-chain-runner.mjs +134 -26
- package/templates/.claude/ukit/runtime/hook-field-salvage.mjs +119 -0
- package/templates/.omp/config.yml +9 -8
- package/templates/.omp/hooks/pre/ukit-bridge.js +20 -12
|
@@ -0,0 +1,513 @@
|
|
|
1
|
+
// sensitive-data-guard.mjs — C40 TASK-002 port of sensitive-data-guard.sh's
|
|
2
|
+
// inline-node scan + salvage glue into a dual-mode module (SPEC §FR-005, §8):
|
|
3
|
+
//
|
|
4
|
+
// * `runHook(ctx)` — in-proc chain step. ctx = { payload, payloadText,
|
|
5
|
+
// projectRoot, env, deadlineMs, signal } -> { code, stdout, stderr }.
|
|
6
|
+
// Byte-identical verdicts to the .sh: detection -> code 2 + same stderr
|
|
7
|
+
// BLOCKED lines + same systemMessage JSON; clean -> code 0 empty; crash or
|
|
8
|
+
// deadline -> the same fail-closed refusals (BUG-C21-03 parity).
|
|
9
|
+
// * `isDirectRun()` CLI mode — the .sh thin wrapper execs
|
|
10
|
+
// `node sensitive-data-guard.mjs` with the exact env contract the heredoc
|
|
11
|
+
// consumed (INPUT_FILE PROJECT_ROOT SCRIPT_PATH UKIT_HOOK_DEADLINE_MS
|
|
12
|
+
// UKIT_SALVAGE_ACTIVE UKIT_SALVAGED_EVENT UKIT_SALVAGED_TOOL_NAME
|
|
13
|
+
// UKIT_SALVAGED_FIELD_NAME UKIT_SALVAGED_FIELD_VALUE).
|
|
14
|
+
//
|
|
15
|
+
// FAIL-CLOSED on detection (code 2). Never echoes the detected value — labels
|
|
16
|
+
// only, values withheld (leak-safe scanner contract). Fails open only when
|
|
17
|
+
// there is nothing to scan (malformed stdin with no degraded flag, no channel).
|
|
18
|
+
// Truncated input is NEVER scanned partial: the degraded path salvages only
|
|
19
|
+
// provably-complete fields via ukit/runtime/hook-field-salvage.mjs and refuses
|
|
20
|
+
// fail-closed otherwise.
|
|
21
|
+
//
|
|
22
|
+
// User escape hatches (explicit human action only):
|
|
23
|
+
// - security.sensitiveDataGate=false in .ukit/storage/config.json
|
|
24
|
+
// - .ukit/storage/security/allowlist.json { values:[sha256...], paths:[...] }
|
|
25
|
+
|
|
26
|
+
import fs from 'node:fs';
|
|
27
|
+
import fsp from 'node:fs/promises';
|
|
28
|
+
import path from 'node:path';
|
|
29
|
+
import { createHash } from 'node:crypto';
|
|
30
|
+
import { pathToFileURL, fileURLToPath } from 'node:url';
|
|
31
|
+
import { salvageField } from '../ukit/runtime/hook-field-salvage.mjs';
|
|
32
|
+
|
|
33
|
+
export const hookFailClosed = true;
|
|
34
|
+
|
|
35
|
+
const MODULE_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
36
|
+
const DEFAULT_SCANNER_PATH = path.join(MODULE_DIR, '..', 'ukit', 'runtime', 'sensitive-value-scanner.mjs');
|
|
37
|
+
|
|
38
|
+
// --- byte-pinned verdicts (parity with the .sh) -----------------------------
|
|
39
|
+
const SALVAGE_REFUSAL = {
|
|
40
|
+
code: 2,
|
|
41
|
+
stdout:
|
|
42
|
+
'{"systemMessage":"UKit sensitive-data gate blocked this call: the tool-call payload was truncated or stalled during stdin staging and its scanned field could not be recovered intact, so it was never scanned. Fail-closed — reduce the payload or check for a stalled producer."}\n',
|
|
43
|
+
stderr:
|
|
44
|
+
'BLOCKED (sensitive data gate): truncated/stalled payload; scanned field unrecoverable — refused fail-closed.\n',
|
|
45
|
+
};
|
|
46
|
+
const TIMEOUT_REFUSAL = {
|
|
47
|
+
code: 2,
|
|
48
|
+
stdout:
|
|
49
|
+
'{"systemMessage":"UKit sensitive-data gate timed out before finishing its scan; blocked fail-closed rather than pass an uninspected payload."}\n',
|
|
50
|
+
stderr:
|
|
51
|
+
'BLOCKED (sensitive data gate): scan timed out; blocked fail-closed rather than pass an uninspected payload.\n',
|
|
52
|
+
};
|
|
53
|
+
const crashRefusal = (detail) => ({
|
|
54
|
+
code: 2,
|
|
55
|
+
stdout:
|
|
56
|
+
'{"systemMessage":"UKit sensitive-data gate crashed before finishing its scan; blocked fail-closed rather than pass an uninspected payload. Run `ukit install` if this repeats."}\n',
|
|
57
|
+
stderr: `BLOCKED (sensitive data gate): scanner process ${detail}; payload never inspected (fail-closed).\n`,
|
|
58
|
+
});
|
|
59
|
+
const SCANNER_UNAVAILABLE = {
|
|
60
|
+
code: 2,
|
|
61
|
+
stdout:
|
|
62
|
+
'{"systemMessage":"UKit blocked this call: the sensitive-data scanner module is unavailable (fail-closed). Run `ukit install`."}\n',
|
|
63
|
+
stderr:
|
|
64
|
+
'BLOCKED (sensitive data gate unavailable): the shared scanner module ' +
|
|
65
|
+
'ukit/runtime/sensitive-value-scanner.mjs is missing or failed to load, ' +
|
|
66
|
+
'so this payload cannot be proven clean. Run `ukit install` to restore it.\n',
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
// Inner early-verdict propagation (the heredoc called process.exit; the module
|
|
70
|
+
// returns a verdict instead).
|
|
71
|
+
class VerdictSignal extends Error {
|
|
72
|
+
constructor(verdict) {
|
|
73
|
+
super('verdict');
|
|
74
|
+
this.verdict = verdict;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// TASK-003 degraded-path salvage: reconstruct just enough payload from the
|
|
79
|
+
// provably-COMPLETE salvaged fields. In-proc mode salvages straight from the
|
|
80
|
+
// truncated payloadText via salvageField; CLI mode consumes the bash-salvaged
|
|
81
|
+
// UKIT_SALVAGED_* env (identical semantics). Any salvage gap refuses fail-closed
|
|
82
|
+
// — a partial field is never scanned.
|
|
83
|
+
function salvageValue(env, text, dotPath) {
|
|
84
|
+
if (env.UKIT_SALVAGE_ACTIVE === '1') {
|
|
85
|
+
const v = env[dotPath.env];
|
|
86
|
+
return v === undefined ? null : v;
|
|
87
|
+
}
|
|
88
|
+
const r = salvageField(text, dotPath.field);
|
|
89
|
+
return r.complete ? r.value : null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function reconstructSalvagedPayload(payload, { env, rawText }) {
|
|
93
|
+
const event = salvageValue(env, rawText, { env: 'UKIT_SALVAGED_EVENT', field: 'hook_event_name' });
|
|
94
|
+
if (!event) return false;
|
|
95
|
+
payload.hook_event_name = event;
|
|
96
|
+
if (event === 'PreToolUse') {
|
|
97
|
+
const toolName = salvageValue(env, rawText, { env: 'UKIT_SALVAGED_TOOL_NAME', field: 'tool_name' });
|
|
98
|
+
if (!toolName) return false;
|
|
99
|
+
const fieldName = { Read: 'file_path', Grep: 'path', Bash: 'command' }[toolName];
|
|
100
|
+
if (!fieldName) return false;
|
|
101
|
+
const value = salvageValue(env, rawText, {
|
|
102
|
+
env: 'UKIT_SALVAGED_FIELD_VALUE',
|
|
103
|
+
field: `tool_input.${fieldName}`,
|
|
104
|
+
});
|
|
105
|
+
if (value === null) return false;
|
|
106
|
+
payload.tool_name = toolName;
|
|
107
|
+
payload.tool_input = { [fieldName]: value };
|
|
108
|
+
return true;
|
|
109
|
+
}
|
|
110
|
+
if (event === 'UserPromptSubmit') {
|
|
111
|
+
const value = salvageValue(env, rawText, { env: 'UKIT_SALVAGED_FIELD_VALUE', field: 'prompt' });
|
|
112
|
+
if (value === null) return false;
|
|
113
|
+
payload.prompt = value;
|
|
114
|
+
return true;
|
|
115
|
+
}
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// --- the scan itself (verbatim port of the .sh heredoc) ----------------------
|
|
120
|
+
async function evaluate({ rawInput, projectRoot, env }) {
|
|
121
|
+
const payload = (() => {
|
|
122
|
+
try {
|
|
123
|
+
const parsed = JSON.parse(rawInput);
|
|
124
|
+
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
|
|
125
|
+
} catch {
|
|
126
|
+
return {};
|
|
127
|
+
}
|
|
128
|
+
})();
|
|
129
|
+
|
|
130
|
+
// Degraded input: env salvage (bash wrapper recovered fields) applies on top
|
|
131
|
+
// of a parsed payload exactly like the heredoc. In-proc truncated-payloadText
|
|
132
|
+
// salvage happens in runHook before evaluate is called.
|
|
133
|
+
if (env.UKIT_SALVAGE_ACTIVE === '1' && env.UKIT_SALVAGED_EVENT) {
|
|
134
|
+
payload.hook_event_name = env.UKIT_SALVAGED_EVENT;
|
|
135
|
+
if (env.UKIT_SALVAGED_EVENT === 'PreToolUse' && env.UKIT_SALVAGED_TOOL_NAME) {
|
|
136
|
+
payload.tool_name = env.UKIT_SALVAGED_TOOL_NAME;
|
|
137
|
+
payload.tool_input = {};
|
|
138
|
+
if (env.UKIT_SALVAGED_FIELD_NAME) {
|
|
139
|
+
payload.tool_input[env.UKIT_SALVAGED_FIELD_NAME] = env.UKIT_SALVAGED_FIELD_VALUE;
|
|
140
|
+
}
|
|
141
|
+
} else if (env.UKIT_SALVAGED_EVENT === 'UserPromptSubmit') {
|
|
142
|
+
payload.prompt = env.UKIT_SALVAGED_FIELD_VALUE;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const readJsonSafe = async (filePath, fallback) => {
|
|
147
|
+
try {
|
|
148
|
+
return JSON.parse(await fsp.readFile(filePath, 'utf8'));
|
|
149
|
+
} catch {
|
|
150
|
+
return fallback;
|
|
151
|
+
}
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
const config = await readJsonSafe(path.join(projectRoot, '.ukit', 'storage', 'config.json'), null);
|
|
155
|
+
if (config && config.security && config.security.sensitiveDataGate === false) {
|
|
156
|
+
return { code: 0, stdout: '', stderr: '' };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const allowlist = (await readJsonSafe(path.join(projectRoot, '.ukit', 'storage', 'security', 'allowlist.json'), null)) || {};
|
|
160
|
+
const allowedValueHashes = new Set(
|
|
161
|
+
Array.isArray(allowlist.values) ? allowlist.values.filter((v) => typeof v === 'string') : [],
|
|
162
|
+
);
|
|
163
|
+
const allowedPaths = Array.isArray(allowlist.paths) ? allowlist.paths.filter((p) => typeof p === 'string') : [];
|
|
164
|
+
|
|
165
|
+
const sha256 = (value) => createHash('sha256').update(value).digest('hex');
|
|
166
|
+
|
|
167
|
+
const pathAllowed = (filePath) => {
|
|
168
|
+
const normalized = String(filePath).replace(/\\/g, '/').trim();
|
|
169
|
+
let relative = normalized;
|
|
170
|
+
try {
|
|
171
|
+
relative = path.relative(projectRoot, normalized);
|
|
172
|
+
} catch {
|
|
173
|
+
// keep normalized
|
|
174
|
+
}
|
|
175
|
+
return allowedPaths.some((allowed) => allowed === normalized || allowed === relative);
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
// --- high-confidence secret value scan (shared runtime module) ---
|
|
179
|
+
const SCANNER_PATH = env.SCRIPT_PATH || DEFAULT_SCANNER_PATH;
|
|
180
|
+
let scannerModule = null;
|
|
181
|
+
let scannerTried = false;
|
|
182
|
+
const loadScanner = async () => {
|
|
183
|
+
if (scannerTried) return scannerModule;
|
|
184
|
+
scannerTried = true;
|
|
185
|
+
try {
|
|
186
|
+
scannerModule = await import(pathToFileURL(SCANNER_PATH).href);
|
|
187
|
+
} catch {
|
|
188
|
+
scannerModule = null;
|
|
189
|
+
}
|
|
190
|
+
return scannerModule;
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
const blockScannerUnavailable = () => {
|
|
194
|
+
throw new VerdictSignal(SCANNER_UNAVAILABLE);
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
// --- secret file classification ---
|
|
198
|
+
const classifySecretFile = (filePath) => {
|
|
199
|
+
if (typeof filePath !== 'string' || !filePath.trim()) return null;
|
|
200
|
+
const normalized = filePath.replace(/\\/g, '/').trim();
|
|
201
|
+
const rawBase = normalized.split('/').pop();
|
|
202
|
+
if (!rawBase) return null;
|
|
203
|
+
const base = rawBase.toLowerCase();
|
|
204
|
+
if (base.endsWith('.env.example')) return null;
|
|
205
|
+
if (base === '.env' || base.startsWith('.env.')) return 'dotenv/secret env file';
|
|
206
|
+
if (base.endsWith('.pub') || base.endsWith('.public')) return null;
|
|
207
|
+
if (/^id_(rsa|dsa|ecdsa|ed25519)(\.|$)/.test(base)) return 'SSH private key';
|
|
208
|
+
if (/\.(pem|key|p12|pfx|keystore|jks)$/.test(base)) return 'key/certificate file';
|
|
209
|
+
if (/^credentials\.(json|ya?ml|txt|ini)$/.test(base)) return 'credentials file';
|
|
210
|
+
if (/^service[_-]?account.*\.json$/.test(base)) return 'service-account key file';
|
|
211
|
+
if (base === '.npmrc' || base === '.netrc' || base === '.htpasswd') return 'credentials file';
|
|
212
|
+
if (/^secrets?\.(json|ya?ml|txt|ini|env)$/.test(base)) return 'secrets file';
|
|
213
|
+
if (/(^|\/)\.aws\/credentials$/i.test(normalized)) return 'AWS credentials file';
|
|
214
|
+
return null;
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
// --- bash command shapes that dump secrets ---
|
|
218
|
+
const DUMP_VERBS = new Set([
|
|
219
|
+
'cat', 'head', 'tail', 'less', 'more', 'zless', 'zcat', 'xxd', 'strings',
|
|
220
|
+
'base64', 'od', 'hexdump', 'bat', 'grep', 'egrep', 'fgrep', 'rg', 'awk', 'sed',
|
|
221
|
+
]);
|
|
222
|
+
|
|
223
|
+
const scanBashShapes = (command) => {
|
|
224
|
+
const found = [];
|
|
225
|
+
const segments = command.split(/\|\||&&|;|\||\n/);
|
|
226
|
+
for (const rawSegment of segments) {
|
|
227
|
+
const segment = rawSegment.trim();
|
|
228
|
+
if (!segment) continue;
|
|
229
|
+
const tokens = segment.split(/\s+/);
|
|
230
|
+
const head = tokens[0].replace(/^["']+|["']+$/g, '');
|
|
231
|
+
|
|
232
|
+
if ((head === 'env' || head === 'printenv' || head === 'history') && tokens.length === 1) {
|
|
233
|
+
found.push({ label: `bare \`${head}\` dumps environment/history (may contain tokens)`, value: segment });
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
if (head === 'export' && (tokens[1] === '-p' || tokens[1] === '--print')) {
|
|
237
|
+
found.push({ label: 'bare `export -p` dumps all environment variables', value: segment });
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
if (DUMP_VERBS.has(head)) {
|
|
242
|
+
for (const token of tokens.slice(1)) {
|
|
243
|
+
const cleaned = token.replace(/^["']+|["',:]+$/g, '');
|
|
244
|
+
const kind = classifySecretFile(cleaned);
|
|
245
|
+
if (kind && !pathAllowed(cleaned)) {
|
|
246
|
+
found.push({ label: `${head} reads ${kind}`, value: cleaned, isPath: true });
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const headBase = head.replace(/^.*\//, '');
|
|
252
|
+
const CRED_USER_TOOLS = new Set(['curl', 'wget', 'ftp', 'lftp', 'aria2c', 'http', 'https']);
|
|
253
|
+
const inlineUser = /(^|\s)(-u|--user)\s+["']?([^\s"':]+):([^\s"']+)/.exec(segment);
|
|
254
|
+
if (inlineUser && CRED_USER_TOOLS.has(headBase)) {
|
|
255
|
+
const numericIds = /^\d+$/.test(inlineUser[3]) && /^\d+$/.test(inlineUser[4]);
|
|
256
|
+
if (!numericIds) {
|
|
257
|
+
found.push({ label: 'curl/wget inline credentials (-u user:password)', value: segment });
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
if (/https?:\/\/[^\s/@'"]+:[^\s/@'"]+@/.test(segment)) {
|
|
261
|
+
found.push({ label: 'URL with embedded credentials (user:pass@host)', value: segment });
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
return found;
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
// --- channel wiring ---
|
|
268
|
+
const event = String(payload.hook_event_name || '');
|
|
269
|
+
const toolName = String(payload.tool_name || '');
|
|
270
|
+
const toolInput = payload.tool_input && typeof payload.tool_input === 'object' ? payload.tool_input : {};
|
|
271
|
+
|
|
272
|
+
const textChannels = [];
|
|
273
|
+
const filePathChannels = [];
|
|
274
|
+
let bashCommand = null;
|
|
275
|
+
|
|
276
|
+
if (event === 'UserPromptSubmit') {
|
|
277
|
+
const prompt = [payload.prompt, payload.user_prompt, payload.text].find(
|
|
278
|
+
(candidate) => typeof candidate === 'string' && candidate.trim(),
|
|
279
|
+
);
|
|
280
|
+
if (prompt) textChannels.push({ channel: 'prompt', text: prompt });
|
|
281
|
+
} else if (event === 'PreToolUse') {
|
|
282
|
+
if (toolName === 'Read' && typeof toolInput.file_path === 'string') {
|
|
283
|
+
filePathChannels.push(toolInput.file_path);
|
|
284
|
+
} else if (toolName === 'Grep') {
|
|
285
|
+
if (typeof toolInput.path === 'string' && toolInput.path.trim()) filePathChannels.push(toolInput.path);
|
|
286
|
+
if (typeof toolInput.pattern === 'string') textChannels.push({ channel: 'grep pattern', text: toolInput.pattern });
|
|
287
|
+
} else if (toolName === 'Bash' && typeof toolInput.command === 'string') {
|
|
288
|
+
bashCommand = toolInput.command;
|
|
289
|
+
}
|
|
290
|
+
} else {
|
|
291
|
+
return { code: 0, stdout: '', stderr: '' };
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// --- collect findings ---
|
|
295
|
+
const findings = [];
|
|
296
|
+
|
|
297
|
+
for (const filePath of filePathChannels) {
|
|
298
|
+
const kind = classifySecretFile(filePath);
|
|
299
|
+
if (kind && !pathAllowed(filePath)) {
|
|
300
|
+
findings.push({ label: `Read of ${kind}`, preview: filePath, hashSource: filePath });
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// The value scan needs the shared scanner module only when a text/bash
|
|
305
|
+
// channel exists to scan; a pure secret-file Read verdict never depends on it.
|
|
306
|
+
const needsScanner = textChannels.length > 0 || bashCommand;
|
|
307
|
+
let scanner = null;
|
|
308
|
+
if (needsScanner) {
|
|
309
|
+
scanner = await loadScanner();
|
|
310
|
+
if (!scanner || typeof scanner.scanText !== 'function') {
|
|
311
|
+
blockScannerUnavailable();
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
const gateEnabled = scanner ? scanner.isSensitiveDataGateEnabled(config) !== false : true;
|
|
315
|
+
const allowlistHashes = scanner ? scanner.loadSensitiveAllowlist({ security: { allowlist } }) : [];
|
|
316
|
+
|
|
317
|
+
const scanTextLabels = (text) => {
|
|
318
|
+
let verdict;
|
|
319
|
+
try {
|
|
320
|
+
verdict = scanner.scanText(text, { allowlistHashes, gateEnabled });
|
|
321
|
+
} catch {
|
|
322
|
+
blockScannerUnavailable();
|
|
323
|
+
}
|
|
324
|
+
return verdict && Array.isArray(verdict.labels) ? verdict.labels : [];
|
|
325
|
+
};
|
|
326
|
+
|
|
327
|
+
const seenLabels = new Set();
|
|
328
|
+
for (const { channel, text } of textChannels) {
|
|
329
|
+
for (const label of scanTextLabels(text)) {
|
|
330
|
+
const key = `${channel}:${label}`;
|
|
331
|
+
if (seenLabels.has(key)) continue;
|
|
332
|
+
seenLabels.add(key);
|
|
333
|
+
findings.push({ label: `${label} in ${channel}` });
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
if (bashCommand) {
|
|
338
|
+
for (const finding of scanBashShapes(bashCommand)) {
|
|
339
|
+
if (finding.isPath) {
|
|
340
|
+
findings.push({ label: `bash command touches ${finding.label}`, preview: finding.value });
|
|
341
|
+
continue;
|
|
342
|
+
}
|
|
343
|
+
if (allowedValueHashes.has(sha256(finding.value))) continue;
|
|
344
|
+
findings.push({ label: `${finding.label} in bash command`, value: finding.value });
|
|
345
|
+
}
|
|
346
|
+
for (const label of scanTextLabels(bashCommand)) {
|
|
347
|
+
const key = `bash:${label}`;
|
|
348
|
+
if (seenLabels.has(key)) continue;
|
|
349
|
+
seenLabels.add(key);
|
|
350
|
+
findings.push({ label: `${label} in bash command` });
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
if (findings.length === 0) {
|
|
355
|
+
return { code: 0, stdout: '', stderr: '' };
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// --- machine-generated envelope lane (UserPromptSubmit only) ---
|
|
359
|
+
const MACHINE_ENVELOPE_RE = /^(?:task-notification|system-reminder|cross-session-message)$/i;
|
|
360
|
+
const isCompleteMachineEnvelope = (text) => {
|
|
361
|
+
const trimmed = String(text || '').trim();
|
|
362
|
+
const opening = /^<([a-z-]+)(?:\s[^>]*)?>/i.exec(trimmed);
|
|
363
|
+
if (!opening || !MACHINE_ENVELOPE_RE.test(opening[1])) return false;
|
|
364
|
+
const tag = opening[1];
|
|
365
|
+
const closing = new RegExp(`</${tag}>$`, 'i');
|
|
366
|
+
return closing.test(trimmed);
|
|
367
|
+
};
|
|
368
|
+
const promptText = textChannels.length > 0 ? textChannels[0].text : null;
|
|
369
|
+
if (event === 'UserPromptSubmit' && promptText && isCompleteMachineEnvelope(promptText)) {
|
|
370
|
+
const labels = [...new Set(findings.map((f) => f.label))];
|
|
371
|
+
return {
|
|
372
|
+
code: 0,
|
|
373
|
+
stdout: `${JSON.stringify({
|
|
374
|
+
systemMessage: `UKit sensitive-data gate: ${findings.length} secret-shaped value(s) inside a machine-generated notification (${labels.slice(0, 3).join('; ')}${labels.length > 3 ? '; …' : ''}). Delivered without blocking — treat as fixture/log plumbing, not a typed secret. Never repeat the values in your reply.`,
|
|
375
|
+
})}\n`,
|
|
376
|
+
stderr: '',
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// --- block message: redacted previews only, never the secret itself ---
|
|
381
|
+
const describeFinding = (finding) => {
|
|
382
|
+
const where = finding.preview ? `${finding.preview}` : '(value withheld)';
|
|
383
|
+
return ` - ${finding.label}: ${where}`;
|
|
384
|
+
};
|
|
385
|
+
|
|
386
|
+
const lines = [
|
|
387
|
+
`BLOCKED (sensitive data): ${findings.length} potential secret(s) detected. Nothing was sent to the AI.`,
|
|
388
|
+
...findings.map(describeFinding),
|
|
389
|
+
'Detection is format-true only: a match must itself be a vendor-formatted key,',
|
|
390
|
+
'a private key block, a JWT, or a credentials file. Code, prose, hashes and',
|
|
391
|
+
'encoded blobs are not blocked. To proceed, the USER can choose one of:',
|
|
392
|
+
' 1. Redact the secret (placeholder like <API_KEY>) and retry.',
|
|
393
|
+
' 2. Approve it explicitly: add the full sha256 (shasum -a 256 of the value) or the file path',
|
|
394
|
+
' to .ukit/storage/security/allowlist.json — that file is protected from AI edits.',
|
|
395
|
+
' 3. Disable the gate: security.sensitiveDataGate=false in .ukit/storage/config.json (not recommended).',
|
|
396
|
+
'Never repeat or guess the detected value in your reply. Ask the user instead.',
|
|
397
|
+
];
|
|
398
|
+
return {
|
|
399
|
+
code: 2,
|
|
400
|
+
stdout: `${JSON.stringify({
|
|
401
|
+
systemMessage: `UKit blocked this call: ${findings.length} potential secret(s) detected (${findings.map((f) => f.label).slice(0, 3).join('; ')}${findings.length > 3 ? '; …' : ''}). Redact the value, allowlist its sha256, or ask the user to decide.`,
|
|
402
|
+
})}\n`,
|
|
403
|
+
stderr: `${lines.join('\n')}\n`,
|
|
404
|
+
};
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// --- SPEC §8 chain-step entry ------------------------------------------------
|
|
408
|
+
export async function runHook(ctx) {
|
|
409
|
+
const env = (ctx && ctx.env) || {};
|
|
410
|
+
const projectRoot = (ctx && ctx.projectRoot) || env.PROJECT_ROOT || process.cwd();
|
|
411
|
+
const deadlineMs =
|
|
412
|
+
Number.parseInt((ctx && ctx.deadlineMs) ?? env.UKIT_HOOK_DEADLINE_MS ?? '', 10) || 3000;
|
|
413
|
+
|
|
414
|
+
let rawInput;
|
|
415
|
+
try {
|
|
416
|
+
rawInput =
|
|
417
|
+
typeof (ctx && ctx.payloadText) === 'string' && ctx.payloadText.length > 0
|
|
418
|
+
? ctx.payloadText
|
|
419
|
+
: JSON.stringify((ctx && ctx.payload) ?? {});
|
|
420
|
+
} catch {
|
|
421
|
+
return crashRefusal('crashed');
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// In-proc degraded path: a payloadText that does not parse is the chain's
|
|
425
|
+
// equivalent of the .sh's truncated/stalled staging flag. Salvage the
|
|
426
|
+
// decision-relevant field only when it is provably complete (salvageField);
|
|
427
|
+
// anything else refuses fail-closed — a partial field is never scanned.
|
|
428
|
+
const parsedOk = (() => {
|
|
429
|
+
try {
|
|
430
|
+
// A literal `null` payload parses fine — the .sh treated it like `{}`
|
|
431
|
+
// (no fields to scan → pass), so normalize it to `{}` rather than routing
|
|
432
|
+
// to the salvage/refusal path.
|
|
433
|
+
if (JSON.parse(rawInput) === null) rawInput = '{}';
|
|
434
|
+
return true;
|
|
435
|
+
} catch {
|
|
436
|
+
return false;
|
|
437
|
+
}
|
|
438
|
+
})();
|
|
439
|
+
if (!parsedOk && rawInput && env.UKIT_SALVAGE_ACTIVE !== '1') {
|
|
440
|
+
const salvaged = {};
|
|
441
|
+
if (!reconstructSalvagedPayload(salvaged, { env, rawText: rawInput })) {
|
|
442
|
+
return SALVAGE_REFUSAL;
|
|
443
|
+
}
|
|
444
|
+
rawInput = JSON.stringify(salvaged);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
let deadline;
|
|
448
|
+
const timeout = new Promise((resolve) => {
|
|
449
|
+
deadline = setTimeout(() => resolve(TIMEOUT_REFUSAL), deadlineMs);
|
|
450
|
+
if (deadline && typeof deadline.unref === 'function') deadline.unref();
|
|
451
|
+
});
|
|
452
|
+
try {
|
|
453
|
+
const verdict = await Promise.race([evaluate({ rawInput, projectRoot, env }), timeout]);
|
|
454
|
+
return verdict;
|
|
455
|
+
} catch (error) {
|
|
456
|
+
if (error instanceof VerdictSignal) return error.verdict;
|
|
457
|
+
return crashRefusal('crashed');
|
|
458
|
+
} finally {
|
|
459
|
+
clearTimeout(deadline);
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// --- direct-run CLI mode (the .sh thin wrapper's target) ---------------------
|
|
464
|
+
export function isDirectRun() {
|
|
465
|
+
try {
|
|
466
|
+
const invoked = process.argv[1] ? fs.realpathSync(process.argv[1]) : '';
|
|
467
|
+
const self = fs.realpathSync(fileURLToPath(import.meta.url));
|
|
468
|
+
return invoked === self;
|
|
469
|
+
} catch {
|
|
470
|
+
return false;
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
async function main() {
|
|
475
|
+
const env = process.env;
|
|
476
|
+
const HOOK_DEADLINE_MS = Number.parseInt(env.UKIT_HOOK_DEADLINE_MS || '', 10) || 3000;
|
|
477
|
+
// BUG-C21-03 (FAIL-CLOSED, parity with the staging refusal): a wedged scan
|
|
478
|
+
// must never pass an uninspected payload. The deadline emits the redacted §8
|
|
479
|
+
// degrade reason and exits 2 — never a silent exit-0 pass.
|
|
480
|
+
setTimeout(() => {
|
|
481
|
+
try {
|
|
482
|
+
process.stdout.write(TIMEOUT_REFUSAL.stdout);
|
|
483
|
+
} catch {}
|
|
484
|
+
try {
|
|
485
|
+
// stderr mirror: piped stdout is async and process.exit() can truncate
|
|
486
|
+
// the pending write — without this the block arrives with no reason.
|
|
487
|
+
process.stderr.write(TIMEOUT_REFUSAL.stderr);
|
|
488
|
+
} catch {}
|
|
489
|
+
process.exit(2);
|
|
490
|
+
}, HOOK_DEADLINE_MS).unref();
|
|
491
|
+
|
|
492
|
+
let rawInput = '';
|
|
493
|
+
try {
|
|
494
|
+
rawInput = await fsp.readFile(env.INPUT_FILE || '', 'utf8');
|
|
495
|
+
} catch {}
|
|
496
|
+
const projectRoot = env.PROJECT_ROOT || process.cwd();
|
|
497
|
+
|
|
498
|
+
try {
|
|
499
|
+
const verdict = await evaluate({ rawInput, projectRoot, env });
|
|
500
|
+
if (verdict.stdout) process.stdout.write(verdict.stdout);
|
|
501
|
+
if (verdict.stderr) process.stderr.write(verdict.stderr);
|
|
502
|
+
process.exit(verdict.code);
|
|
503
|
+
} catch (error) {
|
|
504
|
+
const refusal = error instanceof VerdictSignal ? error.verdict : crashRefusal('crashed');
|
|
505
|
+
if (refusal.stdout) process.stdout.write(refusal.stdout);
|
|
506
|
+
if (refusal.stderr) process.stderr.write(refusal.stderr);
|
|
507
|
+
process.exit(refusal.code);
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
if (isDirectRun()) {
|
|
512
|
+
main();
|
|
513
|
+
}
|