@sanqianx/claude-ai-workbench 0.13.2 → 0.13.3

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 (120) hide show
  1. package/LICENSE +201 -201
  2. package/README.md +156 -156
  3. package/apps/agent-terminal/app.css +636 -613
  4. package/apps/agent-terminal/app.js +125 -65
  5. package/apps/agent-terminal/conversation-composer.js +44 -0
  6. package/apps/agent-terminal/index.html +171 -170
  7. package/apps/agent-terminal/panel-host-proof.html +152 -152
  8. package/apps/agent-terminal/panel-proof-content.html +41 -41
  9. package/apps/agent-terminal/right-workspace-dock.css +187 -159
  10. package/apps/agent-terminal/right-workspace-dock.js +623 -519
  11. package/apps/ai-profile-settings-host/index.html +25 -25
  12. package/apps/ai-profile-settings-host/page.css +1 -1
  13. package/apps/ai-profile-settings-host/page.js +113 -113
  14. package/apps/minimal-test-host/README.md +15 -15
  15. package/apps/minimal-test-host/host.css +2 -2
  16. package/apps/minimal-test-host/host.js +35 -35
  17. package/apps/minimal-test-host/index.html +58 -58
  18. package/apps/minimal-test-host/optional-mock/fault-injection.js +15 -15
  19. package/docs/sdk/FILE_LAYOUT.md +31 -31
  20. package/docs/sdk/INTEGRATION.md +126 -126
  21. package/docs/sdk/README.md +21 -21
  22. package/docs/sdk/SESSION_EVENTS.md +13 -13
  23. package/docs/sdk/openapi.yaml +8 -1
  24. package/package.json +1 -1
  25. package/packages/client/README.md +29 -29
  26. package/packages/client/index.d.ts +122 -122
  27. package/packages/client/index.js +260 -260
  28. package/packages/client/package.json +2 -2
  29. package/packages/contracts/README.md +9 -9
  30. package/packages/contracts/index.d.ts +519 -517
  31. package/packages/contracts/index.js +312 -312
  32. package/packages/contracts/package.json +1 -1
  33. package/packages/core/README.md +8 -8
  34. package/packages/core/index.d.ts +76 -76
  35. package/packages/core/index.js +222 -222
  36. package/packages/core/package.json +2 -2
  37. package/packages/server/README.md +17 -17
  38. package/packages/server/bin/agent-terminal-server.js +42 -42
  39. package/packages/server/bin/claude-workbench.js +225 -225
  40. package/packages/server/index.d.ts +171 -171
  41. package/packages/server/index.js +230 -230
  42. package/packages/server/lib/agent-discovery.js +133 -133
  43. package/packages/server/lib/agent-registry.js +143 -143
  44. package/packages/server/lib/agent-spawn-env.js +17 -17
  45. package/packages/server/lib/attachment-store.js +78 -78
  46. package/packages/server/lib/bridges/app-server-bridge.js +373 -373
  47. package/packages/server/lib/bridges/opencode-server-bridge.js +506 -506
  48. package/packages/server/lib/capture-settings-store.js +35 -35
  49. package/packages/server/lib/claude-history-recovery.js +87 -87
  50. package/packages/server/lib/cli-session-recovery.js +28 -28
  51. package/packages/server/lib/context-resolver.js +55 -55
  52. package/packages/server/lib/conversation-capture.js +51 -51
  53. package/packages/server/lib/delegation-phase-store.js +357 -357
  54. package/packages/server/lib/delegation-store.js +474 -468
  55. package/packages/server/lib/diagnostics.js +32 -32
  56. package/packages/server/lib/drivers/cli-json-runner.js +974 -974
  57. package/packages/server/lib/drivers/codex-driver.js +1442 -1442
  58. package/packages/server/lib/drivers/opencode-driver.js +1554 -1554
  59. package/packages/server/lib/drivers/zcode-driver.js +1250 -1250
  60. package/packages/server/lib/drivers/zcode-profile-runtime.js +71 -71
  61. package/packages/server/lib/event-stream.js +138 -138
  62. package/packages/server/lib/folder-picker.js +623 -623
  63. package/packages/server/lib/http-handler.js +339 -339
  64. package/packages/server/lib/json-file.js +26 -26
  65. package/packages/server/lib/knowledge-context.js +33 -33
  66. package/packages/server/lib/model-detection.js +295 -295
  67. package/packages/server/lib/parent-context.js +44 -0
  68. package/packages/server/lib/profile-store.js +283 -283
  69. package/packages/server/lib/project-store.js +102 -102
  70. package/packages/server/lib/runtime-loader.js +120 -120
  71. package/packages/server/lib/session-archive-store.js +53 -53
  72. package/packages/server/lib/session-history.js +612 -612
  73. package/packages/server/lib/system-doctor.js +125 -125
  74. package/packages/server/lib/ui-preferences-store.js +48 -48
  75. package/packages/server/lib/workbench-service.js +1696 -1675
  76. package/packages/server/package.json +2 -2
  77. package/packages/ui/README.md +30 -30
  78. package/packages/ui/ai-profile-settings.d.ts +45 -45
  79. package/packages/ui/ai-profile-settings.js +283 -283
  80. package/packages/ui/claude-terminal.d.ts +31 -31
  81. package/packages/ui/claude-terminal.js +343 -343
  82. package/packages/ui/index.d.ts +18 -18
  83. package/packages/ui/index.js +51 -51
  84. package/packages/ui/package.json +2 -2
  85. package/packages/ui/theme.css +13 -13
  86. package/src/assets/claude-prompts.json +21 -21
  87. package/src/backend/http/ai-profile-routes.js +61 -61
  88. package/src/backend/http/claude-routes.js +170 -170
  89. package/src/backend/http/node-helpers.js +45 -45
  90. package/src/backend/index.js +21 -21
  91. package/src/backend/lib/ai-adapter.js +144 -144
  92. package/src/backend/lib/ai-profile-store.js +259 -259
  93. package/src/backend/lib/ai-vendor-presets.js +83 -83
  94. package/src/backend/lib/ai-workspace.js +140 -140
  95. package/src/backend/lib/automation-config.js +253 -253
  96. package/src/backend/lib/claude-cli-runner.js +2392 -2392
  97. package/src/backend/lib/data-dir.js +169 -169
  98. package/src/backend/lib/llm-client.js +176 -176
  99. package/src/backend/lib/model-context-windows.js +82 -82
  100. package/src/backend/lib/native-task-control.js +217 -217
  101. package/src/backend/lib/native-task-history-index.js +475 -475
  102. package/src/backend/lib/native-task-history-reader.js +526 -526
  103. package/src/backend/lib/native-task-messages.js +457 -457
  104. package/src/backend/lib/native-task-registry.js +325 -325
  105. package/src/backend/lib/native-task-runtime.js +242 -242
  106. package/src/backend/lib/native-task-trace.js +440 -440
  107. package/src/backend/lib/native-task-transcript.js +393 -393
  108. package/src/backend/lib/prompt-registry.js +166 -166
  109. package/src/backend/lib/runtime-config.js +121 -121
  110. package/src/backend/lib/sdk-session-stream.js +143 -143
  111. package/src/backend/lib/sdk-turn-stream.js +16 -16
  112. package/src/backend/lib/store-scope.js +131 -131
  113. package/src/backend/lib/task-identity.js +104 -104
  114. package/src/backend/lib/task-state-projection.js +58 -58
  115. package/src/index.js +7 -7
  116. package/src/sdk-wave-a.ts +1 -1
  117. package/src/sdk-wave-b.ts +1 -1
  118. package/src/sdk-wave-c.ts +1 -1
  119. package/src/sdk-wave-d.ts +1 -1
  120. 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 };