@sanqianx/claude-ai-workbench 0.13.2 → 0.13.4
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/LICENSE +201 -201
- package/README.md +156 -156
- package/apps/agent-terminal/app.css +652 -613
- package/apps/agent-terminal/app.js +143 -67
- package/apps/agent-terminal/conversation-composer.js +44 -0
- package/apps/agent-terminal/index.html +172 -170
- package/apps/agent-terminal/panel-host-proof.html +152 -152
- package/apps/agent-terminal/panel-proof-content.html +41 -41
- package/apps/agent-terminal/right-workspace-dock.css +189 -159
- package/apps/agent-terminal/right-workspace-dock.js +652 -521
- package/apps/agent-terminal/user-question-card.js +68 -0
- package/apps/ai-profile-settings-host/index.html +25 -25
- package/apps/ai-profile-settings-host/page.css +1 -1
- package/apps/ai-profile-settings-host/page.js +113 -113
- package/apps/minimal-test-host/README.md +15 -15
- package/apps/minimal-test-host/host.css +2 -2
- package/apps/minimal-test-host/host.js +35 -35
- package/apps/minimal-test-host/index.html +58 -58
- package/apps/minimal-test-host/optional-mock/fault-injection.js +15 -15
- package/docs/sdk/FILE_LAYOUT.md +31 -31
- package/docs/sdk/INTEGRATION.md +126 -126
- package/docs/sdk/README.md +21 -21
- package/docs/sdk/SESSION_EVENTS.md +15 -13
- package/docs/sdk/openapi.yaml +16 -1
- package/package.json +1 -1
- package/packages/client/README.md +29 -29
- package/packages/client/index.d.ts +122 -122
- package/packages/client/index.js +260 -260
- package/packages/client/package.json +2 -2
- package/packages/contracts/README.md +9 -9
- package/packages/contracts/index.d.ts +519 -517
- package/packages/contracts/index.js +312 -312
- package/packages/contracts/package.json +1 -1
- package/packages/core/README.md +8 -8
- package/packages/core/index.d.ts +76 -76
- package/packages/core/index.js +222 -222
- package/packages/core/package.json +2 -2
- package/packages/server/README.md +17 -17
- package/packages/server/bin/agent-terminal-server.js +42 -42
- package/packages/server/bin/claude-workbench.js +225 -225
- package/packages/server/index.d.ts +171 -171
- package/packages/server/index.js +230 -230
- package/packages/server/lib/agent-discovery.js +133 -133
- package/packages/server/lib/agent-registry.js +143 -143
- package/packages/server/lib/agent-spawn-env.js +17 -17
- package/packages/server/lib/attachment-store.js +78 -78
- package/packages/server/lib/bridges/app-server-bridge.js +373 -373
- package/packages/server/lib/bridges/opencode-server-bridge.js +506 -506
- package/packages/server/lib/capture-settings-store.js +35 -35
- package/packages/server/lib/claude-history-recovery.js +87 -87
- package/packages/server/lib/cli-session-recovery.js +28 -28
- package/packages/server/lib/context-resolver.js +55 -55
- package/packages/server/lib/conversation-capture.js +51 -51
- package/packages/server/lib/delegation-phase-store.js +357 -357
- package/packages/server/lib/delegation-store.js +474 -468
- package/packages/server/lib/diagnostics.js +32 -32
- package/packages/server/lib/drivers/cli-json-runner.js +974 -974
- package/packages/server/lib/drivers/codex-driver.js +1461 -1442
- package/packages/server/lib/drivers/opencode-driver.js +1554 -1554
- package/packages/server/lib/drivers/zcode-driver.js +1250 -1250
- package/packages/server/lib/drivers/zcode-profile-runtime.js +71 -71
- package/packages/server/lib/event-stream.js +138 -138
- package/packages/server/lib/folder-picker.js +623 -623
- package/packages/server/lib/http-handler.js +339 -339
- package/packages/server/lib/json-file.js +26 -26
- package/packages/server/lib/knowledge-context.js +33 -33
- package/packages/server/lib/model-detection.js +295 -295
- package/packages/server/lib/parent-context.js +44 -0
- package/packages/server/lib/profile-store.js +283 -283
- package/packages/server/lib/project-store.js +102 -102
- package/packages/server/lib/runtime-loader.js +120 -120
- package/packages/server/lib/session-archive-store.js +53 -53
- package/packages/server/lib/session-history.js +612 -612
- package/packages/server/lib/system-doctor.js +125 -125
- package/packages/server/lib/ui-preferences-store.js +48 -48
- package/packages/server/lib/user-questions.js +67 -0
- package/packages/server/lib/workbench-service.js +1696 -1675
- package/packages/server/package.json +2 -2
- package/packages/ui/README.md +30 -30
- package/packages/ui/ai-profile-settings.d.ts +45 -45
- package/packages/ui/ai-profile-settings.js +283 -283
- package/packages/ui/claude-terminal.d.ts +31 -31
- package/packages/ui/claude-terminal.js +343 -343
- package/packages/ui/index.d.ts +18 -18
- package/packages/ui/index.js +51 -51
- package/packages/ui/package.json +2 -2
- package/packages/ui/theme.css +13 -13
- package/src/assets/claude-prompts.json +21 -21
- package/src/backend/http/ai-profile-routes.js +61 -61
- package/src/backend/http/claude-routes.js +170 -170
- package/src/backend/http/node-helpers.js +45 -45
- package/src/backend/index.js +21 -21
- package/src/backend/lib/ai-adapter.js +144 -144
- package/src/backend/lib/ai-profile-store.js +259 -259
- package/src/backend/lib/ai-vendor-presets.js +83 -83
- package/src/backend/lib/ai-workspace.js +140 -140
- package/src/backend/lib/automation-config.js +253 -253
- package/src/backend/lib/claude-cli-runner.js +2408 -2392
- package/src/backend/lib/data-dir.js +169 -169
- package/src/backend/lib/llm-client.js +176 -176
- package/src/backend/lib/model-context-windows.js +82 -82
- package/src/backend/lib/native-task-control.js +217 -217
- package/src/backend/lib/native-task-history-index.js +475 -475
- package/src/backend/lib/native-task-history-reader.js +526 -526
- package/src/backend/lib/native-task-messages.js +457 -457
- package/src/backend/lib/native-task-registry.js +325 -325
- package/src/backend/lib/native-task-runtime.js +242 -242
- package/src/backend/lib/native-task-trace.js +440 -440
- package/src/backend/lib/native-task-transcript.js +393 -393
- package/src/backend/lib/prompt-registry.js +166 -166
- package/src/backend/lib/runtime-config.js +121 -121
- package/src/backend/lib/sdk-session-stream.js +143 -143
- package/src/backend/lib/sdk-turn-stream.js +16 -16
- package/src/backend/lib/store-scope.js +131 -131
- package/src/backend/lib/task-identity.js +104 -104
- package/src/backend/lib/task-state-projection.js +58 -58
- package/src/index.js +7 -7
- package/src/sdk-wave-a.ts +1 -1
- package/src/sdk-wave-b.ts +1 -1
- package/src/sdk-wave-c.ts +1 -1
- package/src/sdk-wave-d.ts +1 -1
- package/src/sdk-wave-e.ts +1 -1
|
@@ -1,440 +1,440 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
// Native task trace collection from Claude Agent SDK in-process hooks.
|
|
4
|
-
//
|
|
5
|
-
// Real hook payloads (verified on SDK 0.2.141 + global CLI 2.1.143) carry
|
|
6
|
-
// session_id, agent_id, tool_use_id and real tool content, and they keep
|
|
7
|
-
// firing for background subagents after the main reply ends. Every emitted
|
|
8
|
-
// field is an observed raw fact: no owner, no parent relation and no id is
|
|
9
|
-
// ever inferred or fabricated here. Traces that cannot be correlated stay
|
|
10
|
-
// association "unassigned" for a later, separately reviewed resolver.
|
|
11
|
-
|
|
12
|
-
const crypto = require('crypto');
|
|
13
|
-
|
|
14
|
-
const TOOL_HOOKS = new Set(['PreToolUse', 'PostToolUse', 'PostToolUseFailure']);
|
|
15
|
-
const LIFECYCLE_HOOKS = new Set(['SubagentStart', 'SubagentStop']);
|
|
16
|
-
// Root-level dispatch tools: their receipts tie a real returned agentId to
|
|
17
|
-
// a root tool_use_id, so they are traced even though agent_id is empty.
|
|
18
|
-
const ROOT_TASK_TOOLS = new Set(['Agent', 'Task']);
|
|
19
|
-
|
|
20
|
-
// Only the values of these explicit credential env names are read from the
|
|
21
|
-
// session env; the env object itself never reaches an emitted event.
|
|
22
|
-
const CREDENTIAL_ENV_KEYS = [
|
|
23
|
-
'ANTHROPIC_AUTH_TOKEN',
|
|
24
|
-
'ANTHROPIC_API_KEY',
|
|
25
|
-
'ANTHROPIC_DYNAMIC_API_KEY',
|
|
26
|
-
'OPENAI_API_KEY',
|
|
27
|
-
];
|
|
28
|
-
|
|
29
|
-
// Keys whose value is a credential by name, in any spelling. Usage counters
|
|
30
|
-
// such as total_tokens are deliberately not part of this set.
|
|
31
|
-
const SENSITIVE_KEYS = new Set([
|
|
32
|
-
'authorization', 'proxyauthorization', 'apikey', 'authtoken', 'accesstoken',
|
|
33
|
-
'refreshtoken', 'password', 'secret', 'clientsecret', 'credential',
|
|
34
|
-
'credentials', 'privatekey',
|
|
35
|
-
]);
|
|
36
|
-
|
|
37
|
-
const BEARER_RE = /Bearer\s+[A-Za-z0-9._~+/=-]{4,}/g;
|
|
38
|
-
const KEY_PATTERN_RE = /\bsk-(?:ant|cp|proj|test|live|svcacct|admin)-[A-Za-z0-9_-]{8,}\b/g;
|
|
39
|
-
const REDACTED_SECRET = '[REDACTED:secret]';
|
|
40
|
-
const REDACTED_KEY = '[REDACTED:key]';
|
|
41
|
-
const MAX_DEPTH = 8;
|
|
42
|
-
const MAX_NODES = 4000;
|
|
43
|
-
const MAX_STRING = 8000;
|
|
44
|
-
const MAX_CHARS = 65536;
|
|
45
|
-
const SECRET_MAX = 4096;
|
|
46
|
-
const PATTERN_LOOKAHEAD = 512;
|
|
47
|
-
const MAX_MATCHES = 512;
|
|
48
|
-
const REDACTED_BEARER = 'Bearer [REDACTED:bearer]';
|
|
49
|
-
|
|
50
|
-
function idString(value) {
|
|
51
|
-
return typeof value === 'string' ? value : null;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
function isSensitiveKey(key) {
|
|
55
|
-
return SENSITIVE_KEYS.has(String(key).toLowerCase().replace(/[^a-z0-9]/g, ''));
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
// Any non-empty configured credential value is protected, however short;
|
|
59
|
-
// only the empty string is excluded because it cannot be searched.
|
|
60
|
-
function collectSecretValues(env) {
|
|
61
|
-
const values = [];
|
|
62
|
-
if (!env || typeof env !== 'object') return values;
|
|
63
|
-
for (const key of CREDENTIAL_ENV_KEYS) {
|
|
64
|
-
const value = env[key];
|
|
65
|
-
if (typeof value === 'string' && value.length > 0) values.push(value);
|
|
66
|
-
}
|
|
67
|
-
return values;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
function newBudget() {
|
|
71
|
-
return {
|
|
72
|
-
chars: 0, nodes: 0, truncated: false, reason: null,
|
|
73
|
-
mark(reason) { if (!this.truncated) { this.truncated = true; this.reason = reason; } },
|
|
74
|
-
};
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
// Bounded masking. All sensitive intervals - configured credentials plus
|
|
78
|
-
// generic Bearer and sk- key patterns - are collected once on a bounded
|
|
79
|
-
// window (the first MAX_STRING raw characters plus a lookahead only wide
|
|
80
|
-
// enough for the longest configured credential or prefix recognition) and
|
|
81
|
-
// merged before any output is built, so a match straddling a boundary can
|
|
82
|
-
// never survive as a fragment. The lookahead region is never published and
|
|
83
|
-
// no per-chunk re-scanning exists.
|
|
84
|
-
function collectCredentialIntervals(scan, secrets, intervals, state) {
|
|
85
|
-
for (const secret of secrets) {
|
|
86
|
-
let from = 0;
|
|
87
|
-
while (state.matches <= MAX_MATCHES) {
|
|
88
|
-
const at = scan.indexOf(secret, from);
|
|
89
|
-
if (at === -1) break;
|
|
90
|
-
intervals.push({ start: at, end: at + secret.length, kind: 'secret' });
|
|
91
|
-
state.matches += 1;
|
|
92
|
-
from = at + 1;
|
|
93
|
-
}
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
function collectPatternIntervals(scan, regex, kind, intervals, state) {
|
|
98
|
-
regex.lastIndex = 0;
|
|
99
|
-
while (state.matches <= MAX_MATCHES) {
|
|
100
|
-
const hit = regex.exec(scan);
|
|
101
|
-
if (!hit) break;
|
|
102
|
-
intervals.push({ start: hit.index, end: hit.index + hit[0].length, kind });
|
|
103
|
-
state.matches += 1;
|
|
104
|
-
}
|
|
105
|
-
regex.lastIndex = 0;
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
// Overlapping and adjacent intervals collapse into one; secret outranks
|
|
109
|
-
// bearer which outranks key, so a configured secret found inside a Bearer
|
|
110
|
-
// token masks the whole token.
|
|
111
|
-
function mergeIntervals(intervals) {
|
|
112
|
-
const rank = { key: 0, bearer: 1, secret: 2 };
|
|
113
|
-
const ordered = intervals.slice().sort((a, b) => (a.start - b.start) || (rank[b.kind] - rank[a.kind]));
|
|
114
|
-
const merged = [];
|
|
115
|
-
for (const iv of ordered) {
|
|
116
|
-
const last = merged[merged.length - 1];
|
|
117
|
-
if (last && iv.start <= last.end) {
|
|
118
|
-
if (iv.end > last.end) last.end = iv.end;
|
|
119
|
-
if (rank[iv.kind] > rank[last.kind]) last.kind = iv.kind;
|
|
120
|
-
} else merged.push({ start: iv.start, end: iv.end, kind: iv.kind });
|
|
121
|
-
}
|
|
122
|
-
return merged;
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
function maskBounded(text, secrets) {
|
|
126
|
-
const longest = secrets.reduce((acc, secret) => (secret.length > acc ? secret.length : acc), 0);
|
|
127
|
-
const lookahead = Math.min(SECRET_MAX, Math.max(longest, PATTERN_LOOKAHEAD));
|
|
128
|
-
const scan = text.slice(0, MAX_STRING + lookahead);
|
|
129
|
-
const state = { matches: 0 };
|
|
130
|
-
const intervals = [];
|
|
131
|
-
collectCredentialIntervals(scan, secrets, intervals, state);
|
|
132
|
-
if (state.matches <= MAX_MATCHES) collectPatternIntervals(scan, BEARER_RE, 'bearer', intervals, state);
|
|
133
|
-
if (state.matches <= MAX_MATCHES) collectPatternIntervals(scan, KEY_PATTERN_RE, 'key', intervals, state);
|
|
134
|
-
if (state.matches > MAX_MATCHES) {
|
|
135
|
-
// The full sensitive set is unknowable within the match cap, so no
|
|
136
|
-
// output is built from partial interval knowledge.
|
|
137
|
-
return { value: '[TRUNCATED:matches]', truncated: true, reason: 'matches' };
|
|
138
|
-
}
|
|
139
|
-
let out = '';
|
|
140
|
-
let covered = 0;
|
|
141
|
-
let truncated = text.length > MAX_STRING;
|
|
142
|
-
const pushRaw = end => {
|
|
143
|
-
const stop = Math.min(end, MAX_STRING, covered + (MAX_STRING - out.length));
|
|
144
|
-
if (stop > covered) out += scan.slice(covered, stop);
|
|
145
|
-
if (stop < Math.min(end, MAX_STRING)) truncated = true;
|
|
146
|
-
covered = Math.max(covered, stop);
|
|
147
|
-
};
|
|
148
|
-
for (const iv of mergeIntervals(intervals)) {
|
|
149
|
-
if (iv.start >= MAX_STRING) break;
|
|
150
|
-
if (iv.start > covered) pushRaw(iv.start);
|
|
151
|
-
if (covered < iv.start) break;
|
|
152
|
-
const marker = iv.kind === 'secret' ? REDACTED_SECRET : iv.kind === 'bearer' ? REDACTED_BEARER : REDACTED_KEY;
|
|
153
|
-
if (out.length + marker.length > MAX_STRING) {
|
|
154
|
-
// No room for a whole marker: publishing stops here. Later intervals
|
|
155
|
-
// were never traversed, so the final raw push would leak their
|
|
156
|
-
// content; return the withheld output directly instead.
|
|
157
|
-
return { value: out, truncated: true, reason: 'string' };
|
|
158
|
-
}
|
|
159
|
-
out += marker;
|
|
160
|
-
covered = Math.max(covered, iv.end);
|
|
161
|
-
}
|
|
162
|
-
if (covered < MAX_STRING) pushRaw(MAX_STRING);
|
|
163
|
-
return { value: out, truncated, reason: truncated ? 'string' : null };
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
// Full-text masking for the transcript read path. Unlike the bounded hook
|
|
167
|
-
// preview above, the whole input is scanned and the whole input is
|
|
168
|
-
// returned: the scan covers the entire string, so a configured credential
|
|
169
|
-
// of any length and a match straddling any position can never survive as a
|
|
170
|
-
// fragment. The only refusal is the match cap - the full sensitive set is
|
|
171
|
-
// unknowable within MAX_MATCHES, and no output is built from partial
|
|
172
|
-
// interval knowledge.
|
|
173
|
-
function maskFullText(text, secrets) {
|
|
174
|
-
const state = { matches: 0 };
|
|
175
|
-
const intervals = [];
|
|
176
|
-
collectCredentialIntervals(text, secrets, intervals, state);
|
|
177
|
-
if (state.matches <= MAX_MATCHES) collectPatternIntervals(text, BEARER_RE, 'bearer', intervals, state);
|
|
178
|
-
if (state.matches <= MAX_MATCHES) collectPatternIntervals(text, KEY_PATTERN_RE, 'key', intervals, state);
|
|
179
|
-
if (state.matches > MAX_MATCHES) return { value: '', truncated: true, reason: 'matches' };
|
|
180
|
-
let out = '';
|
|
181
|
-
let covered = 0;
|
|
182
|
-
for (const iv of mergeIntervals(intervals)) {
|
|
183
|
-
if (iv.start > covered) out += text.slice(covered, iv.start);
|
|
184
|
-
out += iv.kind === 'secret' ? REDACTED_SECRET : iv.kind === 'bearer' ? REDACTED_BEARER : REDACTED_KEY;
|
|
185
|
-
covered = Math.max(covered, iv.end);
|
|
186
|
-
}
|
|
187
|
-
if (covered < text.length) out += text.slice(covered);
|
|
188
|
-
return { value: out, truncated: false, reason: null };
|
|
189
|
-
}
|
|
190
|
-
function numberCost(value) {
|
|
191
|
-
return Number.isFinite(value) ? String(value).length : 4;
|
|
192
|
-
}
|
|
193
|
-
function redact(value, secrets, budget, depth, seen) {
|
|
194
|
-
const kind = typeof value;
|
|
195
|
-
if (kind === 'string') {
|
|
196
|
-
if (budget.chars >= MAX_CHARS) { budget.mark('size'); return '[TRUNCATED:size]'; }
|
|
197
|
-
const masked = maskBounded(value, secrets);
|
|
198
|
-
budget.chars += 2 + masked.value.length;
|
|
199
|
-
if (masked.truncated) budget.mark(masked.reason === 'matches' ? 'matches' : 'string');
|
|
200
|
-
return masked.value;
|
|
201
|
-
}
|
|
202
|
-
if (value === null || value === undefined) { budget.chars += 4; return null; }
|
|
203
|
-
if (kind === 'boolean') { budget.chars += value ? 4 : 5; return value; }
|
|
204
|
-
if (kind === 'number') { budget.chars += numberCost(value); return value; }
|
|
205
|
-
if (kind === 'bigint') { budget.chars += value.toString().length + 2; return value.toString(); }
|
|
206
|
-
if (kind !== 'object') return null;
|
|
207
|
-
if (depth >= MAX_DEPTH) { budget.mark('depth'); return '[TRUNCATED:depth]'; }
|
|
208
|
-
if (budget.nodes >= MAX_NODES || budget.chars >= MAX_CHARS) { budget.mark('size'); return '[TRUNCATED:size]'; }
|
|
209
|
-
if (seen.has(value)) { budget.mark('cycle'); return '[TRUNCATED:cycle]'; }
|
|
210
|
-
seen.add(value);
|
|
211
|
-
budget.nodes += 1;
|
|
212
|
-
budget.chars += 1;
|
|
213
|
-
if (Array.isArray(value)) {
|
|
214
|
-
const out = [];
|
|
215
|
-
for (const item of value) {
|
|
216
|
-
// Every element consumes budget (separator + its own cost inside the
|
|
217
|
-
// recursion) and the check happens before the element is constructed.
|
|
218
|
-
budget.chars += out.length ? 1 : 0;
|
|
219
|
-
if (budget.chars >= MAX_CHARS) { budget.mark('size'); return out; }
|
|
220
|
-
out.push(redact(item, secrets, budget, depth + 1, seen));
|
|
221
|
-
}
|
|
222
|
-
budget.chars += 1;
|
|
223
|
-
return out;
|
|
224
|
-
}
|
|
225
|
-
// Own-property construction only: a JSON-supplied "__proto__" or
|
|
226
|
-
// "constructor" key must survive as own data and must never retarget the
|
|
227
|
-
// output prototype or create inherited properties.
|
|
228
|
-
const out = {};
|
|
229
|
-
const usedKeys = new Set();
|
|
230
|
-
const keys = Object.keys(value);
|
|
231
|
-
for (let i = 0; i < keys.length; i += 1) {
|
|
232
|
-
const key = keys[i];
|
|
233
|
-
// Classification stays on the ORIGINAL key: renaming must never spare a
|
|
234
|
-
// credential-shaped value from whole-value masking.
|
|
235
|
-
const sensitiveName = isSensitiveKey(key);
|
|
236
|
-
// Keys are payload strings too and run through the same bounded masking
|
|
237
|
-
// engine as values, so a secret or Bearer/sk- pattern inside a field
|
|
238
|
-
// name can no more survive than inside a value.
|
|
239
|
-
const maskedKey = maskBounded(key, secrets);
|
|
240
|
-
if (maskedKey.truncated) budget.mark(maskedKey.reason === 'matches' ? 'matches' : 'string');
|
|
241
|
-
let outKey = maskedKey.value;
|
|
242
|
-
if (usedKeys.has(outKey)) {
|
|
243
|
-
// Deterministic, content-free disambiguation: two originals masking to
|
|
244
|
-
// one marker keep both values; the suffix never encodes key text and
|
|
245
|
-
// nothing is silently overwritten.
|
|
246
|
-
let n = 2;
|
|
247
|
-
while (usedKeys.has(outKey + '@' + n)) n += 1;
|
|
248
|
-
outKey = outKey + '@' + n;
|
|
249
|
-
}
|
|
250
|
-
usedKeys.add(outKey);
|
|
251
|
-
// Emitted keys, quotes, separators and brackets are all charged, so the
|
|
252
|
-
// serialized payload stays near MAX_CHARS (single strings add bounded
|
|
253
|
-
// overshoot).
|
|
254
|
-
budget.chars += outKey.length + 3 + (i ? 1 : 0);
|
|
255
|
-
if (budget.chars >= MAX_CHARS) { budget.mark('size'); return out; }
|
|
256
|
-
if (sensitiveName) {
|
|
257
|
-
budget.chars += REDACTED_KEY.length + 2;
|
|
258
|
-
Object.defineProperty(out, outKey, {
|
|
259
|
-
value: REDACTED_KEY,
|
|
260
|
-
enumerable: true, writable: true, configurable: true,
|
|
261
|
-
});
|
|
262
|
-
continue;
|
|
263
|
-
}
|
|
264
|
-
Object.defineProperty(out, outKey, {
|
|
265
|
-
value: redact(value[key], secrets, budget, depth + 1, seen),
|
|
266
|
-
enumerable: true, writable: true, configurable: true,
|
|
267
|
-
});
|
|
268
|
-
}
|
|
269
|
-
budget.chars += 1;
|
|
270
|
-
return out;
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
function redactPayload(value, secrets) {
|
|
274
|
-
const budget = newBudget();
|
|
275
|
-
const safe = redact(value === undefined ? null : value, secrets, budget, 0, new Set());
|
|
276
|
-
return budget.truncated ? { value: safe, truncated: true, truncationReason: budget.reason } : { value: safe };
|
|
277
|
-
}
|
|
278
|
-
function stableTraceId(tuple) {
|
|
279
|
-
const json = JSON.stringify(['native-trace', 1].concat(tuple));
|
|
280
|
-
return 'nt1-' + crypto.createHash('sha256').update(json).digest('hex').slice(0, 32);
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
// Text-level redaction boundary for the native transcript path: the same
|
|
284
|
-
// credential set and interval machinery the hook traces use, applied to the
|
|
285
|
-
// WHOLE text. The hook preview (maskBounded) may stop at its bounded window
|
|
286
|
-
// and say so; this boundary may not: a message body must survive whole, so
|
|
287
|
-
// any masking that cannot be proven complete throws and the normalizer
|
|
288
|
-
// fails closed instead of publishing partial text as a complete message.
|
|
289
|
-
function createSecretRedactor(getSecrets) {
|
|
290
|
-
return text => {
|
|
291
|
-
const secrets = collectSecretValues(typeof getSecrets === 'function' ? getSecrets() : null);
|
|
292
|
-
if (typeof text !== 'string') throw new Error('redactor requires a string');
|
|
293
|
-
const masked = maskFullText(text, secrets);
|
|
294
|
-
if (masked.truncated) {
|
|
295
|
-
const error = new Error('native task transcript redaction could not complete: ' + masked.reason);
|
|
296
|
-
error.code = 'NATIVE_TASK_TRANSCRIPT_REDACT_INCOMPLETE';
|
|
297
|
-
throw error;
|
|
298
|
-
}
|
|
299
|
-
return masked.value;
|
|
300
|
-
};
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
function createNativeTaskHooks({ parentSessionId, emit, isCurrent, getSecrets } = {}) {
|
|
304
|
-
const safeParent = typeof parentSessionId === 'string' ? parentSessionId : null;
|
|
305
|
-
|
|
306
|
-
// Diagnostics carry a fixed literal only: no raw exception, payload or
|
|
307
|
-
// credential can leak through a collection bug.
|
|
308
|
-
const emitDiagnostic = (hookEvent, diagnostic) => {
|
|
309
|
-
try {
|
|
310
|
-
emit({
|
|
311
|
-
type: 'claude/native-trace', source: 'sdk-hook', hookEvent,
|
|
312
|
-
parentSessionId: safeParent, sdkSessionId: null, nativeAgentId: null,
|
|
313
|
-
rawToolUseId: null, callbackToolUseId: null, rawParentToolUseId: null,
|
|
314
|
-
toolName: null, diagnostic, error: 'native trace hook collection failed',
|
|
315
|
-
traceId: null, correlationIncomplete: true, association: 'unassigned',
|
|
316
|
-
ownerRunId: null,
|
|
317
|
-
});
|
|
318
|
-
} catch { /* one bounded retry only; never break the tool flow */ }
|
|
319
|
-
};
|
|
320
|
-
|
|
321
|
-
// Publish verdict of one trace: true only when the emit path confirms the
|
|
322
|
-
// fact reached its audience. The runner's gate returns false when the
|
|
323
|
-
// durable receipt was refused; a throw also means unpublished.
|
|
324
|
-
const emitTrace = event => {
|
|
325
|
-
try {
|
|
326
|
-
return emit(event) !== false;
|
|
327
|
-
} catch {
|
|
328
|
-
emitDiagnostic(event.hookEvent, 'emit-failed');
|
|
329
|
-
return false;
|
|
330
|
-
}
|
|
331
|
-
};
|
|
332
|
-
|
|
333
|
-
const attachPayload = (event, field, value, secrets) => {
|
|
334
|
-
const result = redactPayload(value, secrets);
|
|
335
|
-
event[field] = result.value;
|
|
336
|
-
if (result.truncated) {
|
|
337
|
-
event[field + 'Truncated'] = true;
|
|
338
|
-
event[field + 'TruncationReason'] = result.truncationReason;
|
|
339
|
-
}
|
|
340
|
-
};
|
|
341
|
-
|
|
342
|
-
const collect = async (hookEvent, input, callbackToolUseId) => {
|
|
343
|
-
try {
|
|
344
|
-
let current = true;
|
|
345
|
-
try { current = typeof isCurrent === 'function' ? isCurrent() : true; }
|
|
346
|
-
catch {
|
|
347
|
-
// Unknown query state: a payload-free fixed diagnostic instead of
|
|
348
|
-
// either silently dropping the fact or emitting untrusted content.
|
|
349
|
-
emitDiagnostic(hookEvent, 'guard-error');
|
|
350
|
-
return {};
|
|
351
|
-
}
|
|
352
|
-
if (!current) return {};
|
|
353
|
-
const h = input && typeof input === 'object' ? input : {};
|
|
354
|
-
const nativeAgentId = idString(h.agent_id);
|
|
355
|
-
const toolName = idString(h.tool_name);
|
|
356
|
-
const childAgent = nativeAgentId !== null && nativeAgentId !== '';
|
|
357
|
-
const rootTaskTool = !childAgent && TOOL_HOOKS.has(hookEvent) && ROOT_TASK_TOOLS.has(toolName || '');
|
|
358
|
-
const lifecycle = LIFECYCLE_HOOKS.has(hookEvent);
|
|
359
|
-
// Ordinary root tools (Read/Bash/...) already stream through the main
|
|
360
|
-
// message path; collecting them again here would duplicate facts.
|
|
361
|
-
if (!((TOOL_HOOKS.has(hookEvent) && (childAgent || rootTaskTool)) || lifecycle)) return {};
|
|
362
|
-
|
|
363
|
-
let secrets = null;
|
|
364
|
-
try { secrets = collectSecretValues(typeof getSecrets === 'function' ? getSecrets() : null); }
|
|
365
|
-
catch {
|
|
366
|
-
// Redaction inputs failed, so the payload can never be published
|
|
367
|
-
// safely; report that fact without any original content.
|
|
368
|
-
emitDiagnostic(hookEvent, 'secrets-unavailable');
|
|
369
|
-
return {};
|
|
370
|
-
}
|
|
371
|
-
if (secrets.some(secret => secret.length > SECRET_MAX)) {
|
|
372
|
-
// A credential longer than the bounded scan window cannot be
|
|
373
|
-
// matched reliably; publish a fixed diagnostic, never a payload.
|
|
374
|
-
emitDiagnostic(hookEvent, 'credential-oversize');
|
|
375
|
-
return {};
|
|
376
|
-
}
|
|
377
|
-
|
|
378
|
-
const sdkSessionId = idString(h.session_id);
|
|
379
|
-
const rawToolUseId = idString(h.tool_use_id);
|
|
380
|
-
const rawParentToolUseId = idString(h.parent_tool_use_id);
|
|
381
|
-
const correlationId = TOOL_HOOKS.has(hookEvent)
|
|
382
|
-
? (rawToolUseId || callbackToolUseId || null)
|
|
383
|
-
: (childAgent ? nativeAgentId : null);
|
|
384
|
-
const traceId = sdkSessionId && correlationId
|
|
385
|
-
? stableTraceId(['sdk-hook', sdkSessionId, nativeAgentId, hookEvent, correlationId])
|
|
386
|
-
: null;
|
|
387
|
-
|
|
388
|
-
const event = {
|
|
389
|
-
type: 'claude/native-trace',
|
|
390
|
-
source: 'sdk-hook',
|
|
391
|
-
hookEvent,
|
|
392
|
-
parentSessionId: safeParent,
|
|
393
|
-
sdkSessionId,
|
|
394
|
-
nativeAgentId,
|
|
395
|
-
agentType: idString(h.agent_type),
|
|
396
|
-
rawToolUseId,
|
|
397
|
-
callbackToolUseId,
|
|
398
|
-
rawParentToolUseId,
|
|
399
|
-
toolName,
|
|
400
|
-
traceId,
|
|
401
|
-
association: 'unassigned',
|
|
402
|
-
ownerRunId: null,
|
|
403
|
-
};
|
|
404
|
-
if (!traceId) event.correlationIncomplete = true;
|
|
405
|
-
if (lifecycle && !childAgent) event.diagnostic = 'missing-agent-id';
|
|
406
|
-
if (hookEvent === 'PreToolUse' && h.tool_input !== undefined) attachPayload(event, 'toolInput', h.tool_input, secrets);
|
|
407
|
-
if (hookEvent === 'PostToolUse' && h.tool_response !== undefined) attachPayload(event, 'toolResponse', h.tool_response, secrets);
|
|
408
|
-
if (hookEvent === 'PostToolUseFailure' && h.error !== undefined) attachPayload(event, 'error', h.error, secrets);
|
|
409
|
-
if (hookEvent === 'SubagentStop' && h.last_assistant_message !== undefined) attachPayload(event, 'lastAssistantMessage', h.last_assistant_message, secrets);
|
|
410
|
-
const published = emitTrace(event);
|
|
411
|
-
// A PreToolUse fact that could not be proven on disk blocks the tool
|
|
412
|
-
// call: the SDK's documented control-response shape (PreToolUseHookSpecificOutput,
|
|
413
|
-
// permissionDecision 'deny') replaces the empty-object default-allow,
|
|
414
|
-
// so an unauditable tool use never silently proceeds.
|
|
415
|
-
if (!published && hookEvent === 'PreToolUse') {
|
|
416
|
-
return {
|
|
417
|
-
hookSpecificOutput: {
|
|
418
|
-
hookEventName: 'PreToolUse',
|
|
419
|
-
permissionDecision: 'deny',
|
|
420
|
-
permissionDecisionReason: 'native task history unavailable: the tool trace could not be persisted, so the call is blocked',
|
|
421
|
-
},
|
|
422
|
-
};
|
|
423
|
-
}
|
|
424
|
-
} catch {
|
|
425
|
-
emitDiagnostic(hookEvent, 'collect-error');
|
|
426
|
-
}
|
|
427
|
-
return {};
|
|
428
|
-
};
|
|
429
|
-
const forEvent = name => async (input, toolUseID) => collect(name, input, idString(toolUseID));
|
|
430
|
-
|
|
431
|
-
return {
|
|
432
|
-
PreToolUse: [{ hooks: [forEvent('PreToolUse')] }],
|
|
433
|
-
PostToolUse: [{ hooks: [forEvent('PostToolUse')] }],
|
|
434
|
-
PostToolUseFailure: [{ hooks: [forEvent('PostToolUseFailure')] }],
|
|
435
|
-
SubagentStart: [{ hooks: [forEvent('SubagentStart')] }],
|
|
436
|
-
SubagentStop: [{ hooks: [forEvent('SubagentStop')] }],
|
|
437
|
-
};
|
|
438
|
-
}
|
|
439
|
-
|
|
440
|
-
module.exports = { createNativeTaskHooks, createSecretRedactor };
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Native task trace collection from Claude Agent SDK in-process hooks.
|
|
4
|
+
//
|
|
5
|
+
// Real hook payloads (verified on SDK 0.2.141 + global CLI 2.1.143) carry
|
|
6
|
+
// session_id, agent_id, tool_use_id and real tool content, and they keep
|
|
7
|
+
// firing for background subagents after the main reply ends. Every emitted
|
|
8
|
+
// field is an observed raw fact: no owner, no parent relation and no id is
|
|
9
|
+
// ever inferred or fabricated here. Traces that cannot be correlated stay
|
|
10
|
+
// association "unassigned" for a later, separately reviewed resolver.
|
|
11
|
+
|
|
12
|
+
const crypto = require('crypto');
|
|
13
|
+
|
|
14
|
+
const TOOL_HOOKS = new Set(['PreToolUse', 'PostToolUse', 'PostToolUseFailure']);
|
|
15
|
+
const LIFECYCLE_HOOKS = new Set(['SubagentStart', 'SubagentStop']);
|
|
16
|
+
// Root-level dispatch tools: their receipts tie a real returned agentId to
|
|
17
|
+
// a root tool_use_id, so they are traced even though agent_id is empty.
|
|
18
|
+
const ROOT_TASK_TOOLS = new Set(['Agent', 'Task']);
|
|
19
|
+
|
|
20
|
+
// Only the values of these explicit credential env names are read from the
|
|
21
|
+
// session env; the env object itself never reaches an emitted event.
|
|
22
|
+
const CREDENTIAL_ENV_KEYS = [
|
|
23
|
+
'ANTHROPIC_AUTH_TOKEN',
|
|
24
|
+
'ANTHROPIC_API_KEY',
|
|
25
|
+
'ANTHROPIC_DYNAMIC_API_KEY',
|
|
26
|
+
'OPENAI_API_KEY',
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
// Keys whose value is a credential by name, in any spelling. Usage counters
|
|
30
|
+
// such as total_tokens are deliberately not part of this set.
|
|
31
|
+
const SENSITIVE_KEYS = new Set([
|
|
32
|
+
'authorization', 'proxyauthorization', 'apikey', 'authtoken', 'accesstoken',
|
|
33
|
+
'refreshtoken', 'password', 'secret', 'clientsecret', 'credential',
|
|
34
|
+
'credentials', 'privatekey',
|
|
35
|
+
]);
|
|
36
|
+
|
|
37
|
+
const BEARER_RE = /Bearer\s+[A-Za-z0-9._~+/=-]{4,}/g;
|
|
38
|
+
const KEY_PATTERN_RE = /\bsk-(?:ant|cp|proj|test|live|svcacct|admin)-[A-Za-z0-9_-]{8,}\b/g;
|
|
39
|
+
const REDACTED_SECRET = '[REDACTED:secret]';
|
|
40
|
+
const REDACTED_KEY = '[REDACTED:key]';
|
|
41
|
+
const MAX_DEPTH = 8;
|
|
42
|
+
const MAX_NODES = 4000;
|
|
43
|
+
const MAX_STRING = 8000;
|
|
44
|
+
const MAX_CHARS = 65536;
|
|
45
|
+
const SECRET_MAX = 4096;
|
|
46
|
+
const PATTERN_LOOKAHEAD = 512;
|
|
47
|
+
const MAX_MATCHES = 512;
|
|
48
|
+
const REDACTED_BEARER = 'Bearer [REDACTED:bearer]';
|
|
49
|
+
|
|
50
|
+
function idString(value) {
|
|
51
|
+
return typeof value === 'string' ? value : null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function isSensitiveKey(key) {
|
|
55
|
+
return SENSITIVE_KEYS.has(String(key).toLowerCase().replace(/[^a-z0-9]/g, ''));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Any non-empty configured credential value is protected, however short;
|
|
59
|
+
// only the empty string is excluded because it cannot be searched.
|
|
60
|
+
function collectSecretValues(env) {
|
|
61
|
+
const values = [];
|
|
62
|
+
if (!env || typeof env !== 'object') return values;
|
|
63
|
+
for (const key of CREDENTIAL_ENV_KEYS) {
|
|
64
|
+
const value = env[key];
|
|
65
|
+
if (typeof value === 'string' && value.length > 0) values.push(value);
|
|
66
|
+
}
|
|
67
|
+
return values;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function newBudget() {
|
|
71
|
+
return {
|
|
72
|
+
chars: 0, nodes: 0, truncated: false, reason: null,
|
|
73
|
+
mark(reason) { if (!this.truncated) { this.truncated = true; this.reason = reason; } },
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Bounded masking. All sensitive intervals - configured credentials plus
|
|
78
|
+
// generic Bearer and sk- key patterns - are collected once on a bounded
|
|
79
|
+
// window (the first MAX_STRING raw characters plus a lookahead only wide
|
|
80
|
+
// enough for the longest configured credential or prefix recognition) and
|
|
81
|
+
// merged before any output is built, so a match straddling a boundary can
|
|
82
|
+
// never survive as a fragment. The lookahead region is never published and
|
|
83
|
+
// no per-chunk re-scanning exists.
|
|
84
|
+
function collectCredentialIntervals(scan, secrets, intervals, state) {
|
|
85
|
+
for (const secret of secrets) {
|
|
86
|
+
let from = 0;
|
|
87
|
+
while (state.matches <= MAX_MATCHES) {
|
|
88
|
+
const at = scan.indexOf(secret, from);
|
|
89
|
+
if (at === -1) break;
|
|
90
|
+
intervals.push({ start: at, end: at + secret.length, kind: 'secret' });
|
|
91
|
+
state.matches += 1;
|
|
92
|
+
from = at + 1;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function collectPatternIntervals(scan, regex, kind, intervals, state) {
|
|
98
|
+
regex.lastIndex = 0;
|
|
99
|
+
while (state.matches <= MAX_MATCHES) {
|
|
100
|
+
const hit = regex.exec(scan);
|
|
101
|
+
if (!hit) break;
|
|
102
|
+
intervals.push({ start: hit.index, end: hit.index + hit[0].length, kind });
|
|
103
|
+
state.matches += 1;
|
|
104
|
+
}
|
|
105
|
+
regex.lastIndex = 0;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Overlapping and adjacent intervals collapse into one; secret outranks
|
|
109
|
+
// bearer which outranks key, so a configured secret found inside a Bearer
|
|
110
|
+
// token masks the whole token.
|
|
111
|
+
function mergeIntervals(intervals) {
|
|
112
|
+
const rank = { key: 0, bearer: 1, secret: 2 };
|
|
113
|
+
const ordered = intervals.slice().sort((a, b) => (a.start - b.start) || (rank[b.kind] - rank[a.kind]));
|
|
114
|
+
const merged = [];
|
|
115
|
+
for (const iv of ordered) {
|
|
116
|
+
const last = merged[merged.length - 1];
|
|
117
|
+
if (last && iv.start <= last.end) {
|
|
118
|
+
if (iv.end > last.end) last.end = iv.end;
|
|
119
|
+
if (rank[iv.kind] > rank[last.kind]) last.kind = iv.kind;
|
|
120
|
+
} else merged.push({ start: iv.start, end: iv.end, kind: iv.kind });
|
|
121
|
+
}
|
|
122
|
+
return merged;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function maskBounded(text, secrets) {
|
|
126
|
+
const longest = secrets.reduce((acc, secret) => (secret.length > acc ? secret.length : acc), 0);
|
|
127
|
+
const lookahead = Math.min(SECRET_MAX, Math.max(longest, PATTERN_LOOKAHEAD));
|
|
128
|
+
const scan = text.slice(0, MAX_STRING + lookahead);
|
|
129
|
+
const state = { matches: 0 };
|
|
130
|
+
const intervals = [];
|
|
131
|
+
collectCredentialIntervals(scan, secrets, intervals, state);
|
|
132
|
+
if (state.matches <= MAX_MATCHES) collectPatternIntervals(scan, BEARER_RE, 'bearer', intervals, state);
|
|
133
|
+
if (state.matches <= MAX_MATCHES) collectPatternIntervals(scan, KEY_PATTERN_RE, 'key', intervals, state);
|
|
134
|
+
if (state.matches > MAX_MATCHES) {
|
|
135
|
+
// The full sensitive set is unknowable within the match cap, so no
|
|
136
|
+
// output is built from partial interval knowledge.
|
|
137
|
+
return { value: '[TRUNCATED:matches]', truncated: true, reason: 'matches' };
|
|
138
|
+
}
|
|
139
|
+
let out = '';
|
|
140
|
+
let covered = 0;
|
|
141
|
+
let truncated = text.length > MAX_STRING;
|
|
142
|
+
const pushRaw = end => {
|
|
143
|
+
const stop = Math.min(end, MAX_STRING, covered + (MAX_STRING - out.length));
|
|
144
|
+
if (stop > covered) out += scan.slice(covered, stop);
|
|
145
|
+
if (stop < Math.min(end, MAX_STRING)) truncated = true;
|
|
146
|
+
covered = Math.max(covered, stop);
|
|
147
|
+
};
|
|
148
|
+
for (const iv of mergeIntervals(intervals)) {
|
|
149
|
+
if (iv.start >= MAX_STRING) break;
|
|
150
|
+
if (iv.start > covered) pushRaw(iv.start);
|
|
151
|
+
if (covered < iv.start) break;
|
|
152
|
+
const marker = iv.kind === 'secret' ? REDACTED_SECRET : iv.kind === 'bearer' ? REDACTED_BEARER : REDACTED_KEY;
|
|
153
|
+
if (out.length + marker.length > MAX_STRING) {
|
|
154
|
+
// No room for a whole marker: publishing stops here. Later intervals
|
|
155
|
+
// were never traversed, so the final raw push would leak their
|
|
156
|
+
// content; return the withheld output directly instead.
|
|
157
|
+
return { value: out, truncated: true, reason: 'string' };
|
|
158
|
+
}
|
|
159
|
+
out += marker;
|
|
160
|
+
covered = Math.max(covered, iv.end);
|
|
161
|
+
}
|
|
162
|
+
if (covered < MAX_STRING) pushRaw(MAX_STRING);
|
|
163
|
+
return { value: out, truncated, reason: truncated ? 'string' : null };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Full-text masking for the transcript read path. Unlike the bounded hook
|
|
167
|
+
// preview above, the whole input is scanned and the whole input is
|
|
168
|
+
// returned: the scan covers the entire string, so a configured credential
|
|
169
|
+
// of any length and a match straddling any position can never survive as a
|
|
170
|
+
// fragment. The only refusal is the match cap - the full sensitive set is
|
|
171
|
+
// unknowable within MAX_MATCHES, and no output is built from partial
|
|
172
|
+
// interval knowledge.
|
|
173
|
+
function maskFullText(text, secrets) {
|
|
174
|
+
const state = { matches: 0 };
|
|
175
|
+
const intervals = [];
|
|
176
|
+
collectCredentialIntervals(text, secrets, intervals, state);
|
|
177
|
+
if (state.matches <= MAX_MATCHES) collectPatternIntervals(text, BEARER_RE, 'bearer', intervals, state);
|
|
178
|
+
if (state.matches <= MAX_MATCHES) collectPatternIntervals(text, KEY_PATTERN_RE, 'key', intervals, state);
|
|
179
|
+
if (state.matches > MAX_MATCHES) return { value: '', truncated: true, reason: 'matches' };
|
|
180
|
+
let out = '';
|
|
181
|
+
let covered = 0;
|
|
182
|
+
for (const iv of mergeIntervals(intervals)) {
|
|
183
|
+
if (iv.start > covered) out += text.slice(covered, iv.start);
|
|
184
|
+
out += iv.kind === 'secret' ? REDACTED_SECRET : iv.kind === 'bearer' ? REDACTED_BEARER : REDACTED_KEY;
|
|
185
|
+
covered = Math.max(covered, iv.end);
|
|
186
|
+
}
|
|
187
|
+
if (covered < text.length) out += text.slice(covered);
|
|
188
|
+
return { value: out, truncated: false, reason: null };
|
|
189
|
+
}
|
|
190
|
+
function numberCost(value) {
|
|
191
|
+
return Number.isFinite(value) ? String(value).length : 4;
|
|
192
|
+
}
|
|
193
|
+
function redact(value, secrets, budget, depth, seen) {
|
|
194
|
+
const kind = typeof value;
|
|
195
|
+
if (kind === 'string') {
|
|
196
|
+
if (budget.chars >= MAX_CHARS) { budget.mark('size'); return '[TRUNCATED:size]'; }
|
|
197
|
+
const masked = maskBounded(value, secrets);
|
|
198
|
+
budget.chars += 2 + masked.value.length;
|
|
199
|
+
if (masked.truncated) budget.mark(masked.reason === 'matches' ? 'matches' : 'string');
|
|
200
|
+
return masked.value;
|
|
201
|
+
}
|
|
202
|
+
if (value === null || value === undefined) { budget.chars += 4; return null; }
|
|
203
|
+
if (kind === 'boolean') { budget.chars += value ? 4 : 5; return value; }
|
|
204
|
+
if (kind === 'number') { budget.chars += numberCost(value); return value; }
|
|
205
|
+
if (kind === 'bigint') { budget.chars += value.toString().length + 2; return value.toString(); }
|
|
206
|
+
if (kind !== 'object') return null;
|
|
207
|
+
if (depth >= MAX_DEPTH) { budget.mark('depth'); return '[TRUNCATED:depth]'; }
|
|
208
|
+
if (budget.nodes >= MAX_NODES || budget.chars >= MAX_CHARS) { budget.mark('size'); return '[TRUNCATED:size]'; }
|
|
209
|
+
if (seen.has(value)) { budget.mark('cycle'); return '[TRUNCATED:cycle]'; }
|
|
210
|
+
seen.add(value);
|
|
211
|
+
budget.nodes += 1;
|
|
212
|
+
budget.chars += 1;
|
|
213
|
+
if (Array.isArray(value)) {
|
|
214
|
+
const out = [];
|
|
215
|
+
for (const item of value) {
|
|
216
|
+
// Every element consumes budget (separator + its own cost inside the
|
|
217
|
+
// recursion) and the check happens before the element is constructed.
|
|
218
|
+
budget.chars += out.length ? 1 : 0;
|
|
219
|
+
if (budget.chars >= MAX_CHARS) { budget.mark('size'); return out; }
|
|
220
|
+
out.push(redact(item, secrets, budget, depth + 1, seen));
|
|
221
|
+
}
|
|
222
|
+
budget.chars += 1;
|
|
223
|
+
return out;
|
|
224
|
+
}
|
|
225
|
+
// Own-property construction only: a JSON-supplied "__proto__" or
|
|
226
|
+
// "constructor" key must survive as own data and must never retarget the
|
|
227
|
+
// output prototype or create inherited properties.
|
|
228
|
+
const out = {};
|
|
229
|
+
const usedKeys = new Set();
|
|
230
|
+
const keys = Object.keys(value);
|
|
231
|
+
for (let i = 0; i < keys.length; i += 1) {
|
|
232
|
+
const key = keys[i];
|
|
233
|
+
// Classification stays on the ORIGINAL key: renaming must never spare a
|
|
234
|
+
// credential-shaped value from whole-value masking.
|
|
235
|
+
const sensitiveName = isSensitiveKey(key);
|
|
236
|
+
// Keys are payload strings too and run through the same bounded masking
|
|
237
|
+
// engine as values, so a secret or Bearer/sk- pattern inside a field
|
|
238
|
+
// name can no more survive than inside a value.
|
|
239
|
+
const maskedKey = maskBounded(key, secrets);
|
|
240
|
+
if (maskedKey.truncated) budget.mark(maskedKey.reason === 'matches' ? 'matches' : 'string');
|
|
241
|
+
let outKey = maskedKey.value;
|
|
242
|
+
if (usedKeys.has(outKey)) {
|
|
243
|
+
// Deterministic, content-free disambiguation: two originals masking to
|
|
244
|
+
// one marker keep both values; the suffix never encodes key text and
|
|
245
|
+
// nothing is silently overwritten.
|
|
246
|
+
let n = 2;
|
|
247
|
+
while (usedKeys.has(outKey + '@' + n)) n += 1;
|
|
248
|
+
outKey = outKey + '@' + n;
|
|
249
|
+
}
|
|
250
|
+
usedKeys.add(outKey);
|
|
251
|
+
// Emitted keys, quotes, separators and brackets are all charged, so the
|
|
252
|
+
// serialized payload stays near MAX_CHARS (single strings add bounded
|
|
253
|
+
// overshoot).
|
|
254
|
+
budget.chars += outKey.length + 3 + (i ? 1 : 0);
|
|
255
|
+
if (budget.chars >= MAX_CHARS) { budget.mark('size'); return out; }
|
|
256
|
+
if (sensitiveName) {
|
|
257
|
+
budget.chars += REDACTED_KEY.length + 2;
|
|
258
|
+
Object.defineProperty(out, outKey, {
|
|
259
|
+
value: REDACTED_KEY,
|
|
260
|
+
enumerable: true, writable: true, configurable: true,
|
|
261
|
+
});
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
Object.defineProperty(out, outKey, {
|
|
265
|
+
value: redact(value[key], secrets, budget, depth + 1, seen),
|
|
266
|
+
enumerable: true, writable: true, configurable: true,
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
budget.chars += 1;
|
|
270
|
+
return out;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function redactPayload(value, secrets) {
|
|
274
|
+
const budget = newBudget();
|
|
275
|
+
const safe = redact(value === undefined ? null : value, secrets, budget, 0, new Set());
|
|
276
|
+
return budget.truncated ? { value: safe, truncated: true, truncationReason: budget.reason } : { value: safe };
|
|
277
|
+
}
|
|
278
|
+
function stableTraceId(tuple) {
|
|
279
|
+
const json = JSON.stringify(['native-trace', 1].concat(tuple));
|
|
280
|
+
return 'nt1-' + crypto.createHash('sha256').update(json).digest('hex').slice(0, 32);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// Text-level redaction boundary for the native transcript path: the same
|
|
284
|
+
// credential set and interval machinery the hook traces use, applied to the
|
|
285
|
+
// WHOLE text. The hook preview (maskBounded) may stop at its bounded window
|
|
286
|
+
// and say so; this boundary may not: a message body must survive whole, so
|
|
287
|
+
// any masking that cannot be proven complete throws and the normalizer
|
|
288
|
+
// fails closed instead of publishing partial text as a complete message.
|
|
289
|
+
function createSecretRedactor(getSecrets) {
|
|
290
|
+
return text => {
|
|
291
|
+
const secrets = collectSecretValues(typeof getSecrets === 'function' ? getSecrets() : null);
|
|
292
|
+
if (typeof text !== 'string') throw new Error('redactor requires a string');
|
|
293
|
+
const masked = maskFullText(text, secrets);
|
|
294
|
+
if (masked.truncated) {
|
|
295
|
+
const error = new Error('native task transcript redaction could not complete: ' + masked.reason);
|
|
296
|
+
error.code = 'NATIVE_TASK_TRANSCRIPT_REDACT_INCOMPLETE';
|
|
297
|
+
throw error;
|
|
298
|
+
}
|
|
299
|
+
return masked.value;
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function createNativeTaskHooks({ parentSessionId, emit, isCurrent, getSecrets } = {}) {
|
|
304
|
+
const safeParent = typeof parentSessionId === 'string' ? parentSessionId : null;
|
|
305
|
+
|
|
306
|
+
// Diagnostics carry a fixed literal only: no raw exception, payload or
|
|
307
|
+
// credential can leak through a collection bug.
|
|
308
|
+
const emitDiagnostic = (hookEvent, diagnostic) => {
|
|
309
|
+
try {
|
|
310
|
+
emit({
|
|
311
|
+
type: 'claude/native-trace', source: 'sdk-hook', hookEvent,
|
|
312
|
+
parentSessionId: safeParent, sdkSessionId: null, nativeAgentId: null,
|
|
313
|
+
rawToolUseId: null, callbackToolUseId: null, rawParentToolUseId: null,
|
|
314
|
+
toolName: null, diagnostic, error: 'native trace hook collection failed',
|
|
315
|
+
traceId: null, correlationIncomplete: true, association: 'unassigned',
|
|
316
|
+
ownerRunId: null,
|
|
317
|
+
});
|
|
318
|
+
} catch { /* one bounded retry only; never break the tool flow */ }
|
|
319
|
+
};
|
|
320
|
+
|
|
321
|
+
// Publish verdict of one trace: true only when the emit path confirms the
|
|
322
|
+
// fact reached its audience. The runner's gate returns false when the
|
|
323
|
+
// durable receipt was refused; a throw also means unpublished.
|
|
324
|
+
const emitTrace = event => {
|
|
325
|
+
try {
|
|
326
|
+
return emit(event) !== false;
|
|
327
|
+
} catch {
|
|
328
|
+
emitDiagnostic(event.hookEvent, 'emit-failed');
|
|
329
|
+
return false;
|
|
330
|
+
}
|
|
331
|
+
};
|
|
332
|
+
|
|
333
|
+
const attachPayload = (event, field, value, secrets) => {
|
|
334
|
+
const result = redactPayload(value, secrets);
|
|
335
|
+
event[field] = result.value;
|
|
336
|
+
if (result.truncated) {
|
|
337
|
+
event[field + 'Truncated'] = true;
|
|
338
|
+
event[field + 'TruncationReason'] = result.truncationReason;
|
|
339
|
+
}
|
|
340
|
+
};
|
|
341
|
+
|
|
342
|
+
const collect = async (hookEvent, input, callbackToolUseId) => {
|
|
343
|
+
try {
|
|
344
|
+
let current = true;
|
|
345
|
+
try { current = typeof isCurrent === 'function' ? isCurrent() : true; }
|
|
346
|
+
catch {
|
|
347
|
+
// Unknown query state: a payload-free fixed diagnostic instead of
|
|
348
|
+
// either silently dropping the fact or emitting untrusted content.
|
|
349
|
+
emitDiagnostic(hookEvent, 'guard-error');
|
|
350
|
+
return {};
|
|
351
|
+
}
|
|
352
|
+
if (!current) return {};
|
|
353
|
+
const h = input && typeof input === 'object' ? input : {};
|
|
354
|
+
const nativeAgentId = idString(h.agent_id);
|
|
355
|
+
const toolName = idString(h.tool_name);
|
|
356
|
+
const childAgent = nativeAgentId !== null && nativeAgentId !== '';
|
|
357
|
+
const rootTaskTool = !childAgent && TOOL_HOOKS.has(hookEvent) && ROOT_TASK_TOOLS.has(toolName || '');
|
|
358
|
+
const lifecycle = LIFECYCLE_HOOKS.has(hookEvent);
|
|
359
|
+
// Ordinary root tools (Read/Bash/...) already stream through the main
|
|
360
|
+
// message path; collecting them again here would duplicate facts.
|
|
361
|
+
if (!((TOOL_HOOKS.has(hookEvent) && (childAgent || rootTaskTool)) || lifecycle)) return {};
|
|
362
|
+
|
|
363
|
+
let secrets = null;
|
|
364
|
+
try { secrets = collectSecretValues(typeof getSecrets === 'function' ? getSecrets() : null); }
|
|
365
|
+
catch {
|
|
366
|
+
// Redaction inputs failed, so the payload can never be published
|
|
367
|
+
// safely; report that fact without any original content.
|
|
368
|
+
emitDiagnostic(hookEvent, 'secrets-unavailable');
|
|
369
|
+
return {};
|
|
370
|
+
}
|
|
371
|
+
if (secrets.some(secret => secret.length > SECRET_MAX)) {
|
|
372
|
+
// A credential longer than the bounded scan window cannot be
|
|
373
|
+
// matched reliably; publish a fixed diagnostic, never a payload.
|
|
374
|
+
emitDiagnostic(hookEvent, 'credential-oversize');
|
|
375
|
+
return {};
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
const sdkSessionId = idString(h.session_id);
|
|
379
|
+
const rawToolUseId = idString(h.tool_use_id);
|
|
380
|
+
const rawParentToolUseId = idString(h.parent_tool_use_id);
|
|
381
|
+
const correlationId = TOOL_HOOKS.has(hookEvent)
|
|
382
|
+
? (rawToolUseId || callbackToolUseId || null)
|
|
383
|
+
: (childAgent ? nativeAgentId : null);
|
|
384
|
+
const traceId = sdkSessionId && correlationId
|
|
385
|
+
? stableTraceId(['sdk-hook', sdkSessionId, nativeAgentId, hookEvent, correlationId])
|
|
386
|
+
: null;
|
|
387
|
+
|
|
388
|
+
const event = {
|
|
389
|
+
type: 'claude/native-trace',
|
|
390
|
+
source: 'sdk-hook',
|
|
391
|
+
hookEvent,
|
|
392
|
+
parentSessionId: safeParent,
|
|
393
|
+
sdkSessionId,
|
|
394
|
+
nativeAgentId,
|
|
395
|
+
agentType: idString(h.agent_type),
|
|
396
|
+
rawToolUseId,
|
|
397
|
+
callbackToolUseId,
|
|
398
|
+
rawParentToolUseId,
|
|
399
|
+
toolName,
|
|
400
|
+
traceId,
|
|
401
|
+
association: 'unassigned',
|
|
402
|
+
ownerRunId: null,
|
|
403
|
+
};
|
|
404
|
+
if (!traceId) event.correlationIncomplete = true;
|
|
405
|
+
if (lifecycle && !childAgent) event.diagnostic = 'missing-agent-id';
|
|
406
|
+
if (hookEvent === 'PreToolUse' && h.tool_input !== undefined) attachPayload(event, 'toolInput', h.tool_input, secrets);
|
|
407
|
+
if (hookEvent === 'PostToolUse' && h.tool_response !== undefined) attachPayload(event, 'toolResponse', h.tool_response, secrets);
|
|
408
|
+
if (hookEvent === 'PostToolUseFailure' && h.error !== undefined) attachPayload(event, 'error', h.error, secrets);
|
|
409
|
+
if (hookEvent === 'SubagentStop' && h.last_assistant_message !== undefined) attachPayload(event, 'lastAssistantMessage', h.last_assistant_message, secrets);
|
|
410
|
+
const published = emitTrace(event);
|
|
411
|
+
// A PreToolUse fact that could not be proven on disk blocks the tool
|
|
412
|
+
// call: the SDK's documented control-response shape (PreToolUseHookSpecificOutput,
|
|
413
|
+
// permissionDecision 'deny') replaces the empty-object default-allow,
|
|
414
|
+
// so an unauditable tool use never silently proceeds.
|
|
415
|
+
if (!published && hookEvent === 'PreToolUse') {
|
|
416
|
+
return {
|
|
417
|
+
hookSpecificOutput: {
|
|
418
|
+
hookEventName: 'PreToolUse',
|
|
419
|
+
permissionDecision: 'deny',
|
|
420
|
+
permissionDecisionReason: 'native task history unavailable: the tool trace could not be persisted, so the call is blocked',
|
|
421
|
+
},
|
|
422
|
+
};
|
|
423
|
+
}
|
|
424
|
+
} catch {
|
|
425
|
+
emitDiagnostic(hookEvent, 'collect-error');
|
|
426
|
+
}
|
|
427
|
+
return {};
|
|
428
|
+
};
|
|
429
|
+
const forEvent = name => async (input, toolUseID) => collect(name, input, idString(toolUseID));
|
|
430
|
+
|
|
431
|
+
return {
|
|
432
|
+
PreToolUse: [{ hooks: [forEvent('PreToolUse')] }],
|
|
433
|
+
PostToolUse: [{ hooks: [forEvent('PostToolUse')] }],
|
|
434
|
+
PostToolUseFailure: [{ hooks: [forEvent('PostToolUseFailure')] }],
|
|
435
|
+
SubagentStart: [{ hooks: [forEvent('SubagentStart')] }],
|
|
436
|
+
SubagentStop: [{ hooks: [forEvent('SubagentStop')] }],
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
module.exports = { createNativeTaskHooks, createSecretRedactor };
|