@margay/ccl-core 1.3.6-alpha.1 → 1.3.6-alpha.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.
@@ -0,0 +1,14 @@
1
+ export declare function sanitizeErrorText(text: string): string;
2
+ export type RecoveryOutcome = {
3
+ status: 'needs_user' | 'blocked_external';
4
+ reason: string;
5
+ attempts: number;
6
+ };
7
+ /** Optional wire extension. Omit invalid data; never spread arbitrary payloads. */
8
+ export declare function sanitizeRecoveryOutcome(value: unknown): RecoveryOutcome | undefined;
9
+ export declare function describeError(value: unknown): string;
10
+ export declare function errorDetailsList(errors: unknown): string[];
11
+ /** Preserve successful data and the wire shape; sanitize diagnostic fields only. */
12
+ export declare function sanitizeErrorEnvelope<T extends {
13
+ type: string;
14
+ }>(message: T): T;
@@ -0,0 +1,86 @@
1
+ /** Shared CLI/SDK host diagnostics. Never serialize arbitrary error/request objects. */
2
+ const MAX_DETAIL = 4096;
3
+ const MAX_SCAN = 65_536;
4
+ // Credential syntax redaction, NOT semantic diagnosis or retry classification.
5
+ export function sanitizeErrorText(text) {
6
+ return text.slice(0, MAX_SCAN)
7
+ .replace(/\b(?:Bearer|Basic)\s+[^\s,;"'<>]+/gi, '[REDACTED]')
8
+ .replace(/\bsk-[a-zA-Z0-9_-]+/g, '[REDACTED]')
9
+ .replace(/\beyJ[a-zA-Z0-9_-]+\.[a-zA-Z0-9_-]+\.[a-zA-Z0-9_-]+/g, '[REDACTED]')
10
+ .replace(/((?:["']?)(?:api[_-]?key|access[_-]?token|refresh[_-]?token|id[_-]?token|token|password|secret|authorization|cookie)(?:["']?)\s*[:=]\s*)(?:"[^"\n]*"|'[^'\n]*'|[^\s&,;]+)/gi, '$1[REDACTED]')
11
+ .replace(/(https?:\/\/)[^\s/@]+:[^\s/@]+@/gi, '$1[REDACTED]@')
12
+ .slice(0, MAX_DETAIL);
13
+ }
14
+ function own(value, key) {
15
+ // Accessors/custom toString/proxies must not break the error handler itself.
16
+ try {
17
+ return Object.getOwnPropertyDescriptor(value, key)?.value;
18
+ }
19
+ catch {
20
+ return undefined;
21
+ }
22
+ }
23
+ /** Optional wire extension. Omit invalid data; never spread arbitrary payloads. */
24
+ export function sanitizeRecoveryOutcome(value) {
25
+ if (!value || typeof value !== 'object')
26
+ return undefined;
27
+ const status = own(value, 'status'), reason = own(value, 'reason'), attempts = own(value, 'attempts');
28
+ if ((status !== 'needs_user' && status !== 'blocked_external') || typeof reason !== 'string' ||
29
+ !Number.isSafeInteger(attempts) || attempts < 0)
30
+ return undefined;
31
+ return { status, reason: sanitizeErrorText(reason), attempts: attempts };
32
+ }
33
+ export function describeError(value) {
34
+ const seen = new Set();
35
+ const parts = [];
36
+ function visit(node, depth) {
37
+ if (depth > 4 || parts.length >= 16)
38
+ return;
39
+ if (typeof node === 'string') {
40
+ if (node.trim())
41
+ parts.push(sanitizeErrorText(node));
42
+ return;
43
+ }
44
+ if (typeof node === 'number' || typeof node === 'boolean') {
45
+ parts.push(String(node));
46
+ return;
47
+ }
48
+ if (!node || typeof node !== 'object' || seen.has(node))
49
+ return;
50
+ seen.add(node);
51
+ const message = own(node, 'message');
52
+ if (typeof message === 'string')
53
+ visit(message, depth + 1);
54
+ for (const key of ['code', 'status', 'statusCode', 'requestId', 'request_id']) {
55
+ const field = own(node, key);
56
+ if (typeof field === 'string' || (typeof field === 'number' && Number.isFinite(field))) {
57
+ parts.push(`${key}=${sanitizeErrorText(String(field))}`);
58
+ }
59
+ }
60
+ visit(own(node, 'error'), depth + 1);
61
+ visit(own(node, 'cause'), depth + 1);
62
+ }
63
+ visit(value, 0);
64
+ return [...new Set(parts)].join('; ').slice(0, MAX_DETAIL) ||
65
+ 'Error details were not supplied. Check the service logs for this request; no cause or automatic retry was inferred.';
66
+ }
67
+ export function errorDetailsList(errors) {
68
+ return Array.isArray(errors) ? errors.slice(0, 16).map(describeError) : [describeError(errors)];
69
+ }
70
+ /** Preserve successful data and the wire shape; sanitize diagnostic fields only. */
71
+ export function sanitizeErrorEnvelope(message) {
72
+ const wire = message;
73
+ if (wire.type === 'result' && wire.is_error === true) {
74
+ return {
75
+ ...message,
76
+ ...(wire.errors !== undefined ? { errors: errorDetailsList(wire.errors) } : {}),
77
+ ...(typeof wire.result === 'string' ? { result: sanitizeErrorText(wire.result) } : {}),
78
+ ...(wire.recovery !== undefined ? { recovery: sanitizeRecoveryOutcome(wire.recovery) } : {}),
79
+ };
80
+ }
81
+ const assistant = message;
82
+ if (assistant.type === 'assistant' && assistant.error !== undefined && Array.isArray(assistant.message?.content)) {
83
+ return { ...message, message: { ...assistant.message, content: assistant.message.content.map(block => block?.type === 'text' && typeof block.text === 'string' ? { ...block, text: sanitizeErrorText(block.text) } : block) } };
84
+ }
85
+ return message;
86
+ }
@@ -0,0 +1,16 @@
1
+ import { z } from 'zod/v4';
2
+ export declare const interactionResponseSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
3
+ outcome: z.ZodLiteral<"clarification_requested">;
4
+ intent: z.ZodEnum<{
5
+ clarify: "clarify";
6
+ finish_plan: "finish_plan";
7
+ }>;
8
+ answers: z.ZodRecord<z.ZodString, z.ZodString>;
9
+ }, z.core.$strict>, z.ZodObject<{
10
+ outcome: z.ZodLiteral<"cancelled">;
11
+ }, z.core.$strict>, z.ZodObject<{
12
+ outcome: z.ZodLiteral<"interaction_unavailable">;
13
+ }, z.core.$strict>, z.ZodObject<{
14
+ outcome: z.ZodLiteral<"timed_out">;
15
+ }, z.core.$strict>]>;
16
+ export type InteractionResponse = z.infer<typeof interactionResponseSchema>;
@@ -0,0 +1,13 @@
1
+ import { z } from 'zod/v4';
2
+ // Additive field on deny-shaped wire responses for older host compatibility.
3
+ // It never turns a permission denial into authorization to execute a tool.
4
+ export const interactionResponseSchema = z.discriminatedUnion('outcome', [
5
+ z.object({
6
+ outcome: z.literal('clarification_requested'),
7
+ intent: z.enum(['clarify', 'finish_plan']),
8
+ answers: z.record(z.string(), z.string()),
9
+ }).strict(),
10
+ z.object({ outcome: z.literal('cancelled') }).strict(),
11
+ z.object({ outcome: z.literal('interaction_unavailable') }).strict(),
12
+ z.object({ outcome: z.literal('timed_out') }).strict(),
13
+ ]);
@@ -25,6 +25,7 @@
25
25
  * the model re-generated. Deny works symmetrically. One-shot: the record
26
26
  * is deleted after it is applied.
27
27
  */
28
+ import { type InteractionResponse } from './interactionResponseSchema.js';
28
29
  import type { CanUseToolFn, PendingDecision, QueryOptions, SDKEvent, SessionStore, Transport } from './types.js';
29
30
  /** Builds the CLI argv for a query. Exported for unit tests. */
30
31
  export declare function buildCliArgs(options: QueryOptions): string[];
@@ -43,6 +44,7 @@ type PermissionDecisionWire = {
43
44
  } | {
44
45
  behavior: 'deny';
45
46
  message: string;
47
+ interaction?: InteractionResponse;
46
48
  interrupt?: boolean;
47
49
  toolUseID: string;
48
50
  };
package/dist/sdk/query.js CHANGED
@@ -26,6 +26,8 @@
26
26
  * is deleted after it is applied.
27
27
  */
28
28
  import { existsSync, mkdtempSync, symlinkSync } from 'fs';
29
+ import { describeError, errorDetailsList, sanitizeErrorText, sanitizeRecoveryOutcome } from './errorDiagnostics.js';
30
+ import { interactionResponseSchema } from './interactionResponseSchema.js';
29
31
  import { tmpdir } from 'os';
30
32
  import { dirname, join, resolve } from 'path';
31
33
  import { fileURLToPath } from 'url';
@@ -147,9 +149,12 @@ function resolveCliPath(options) {
147
149
  * tool does not execute. `pending` is surfaced to the caller for suspend.
148
150
  */
149
151
  export async function decideFailClosed(callback, toolName, input, context, timeoutMs) {
152
+ let timedOut = false;
150
153
  const deny = (message, failClosedReason) => ({
151
154
  kind: 'decision',
152
- decision: { behavior: 'deny', message, toolUseID: context.toolUseId },
155
+ decision: { behavior: 'deny', message, toolUseID: context.toolUseId,
156
+ ...(toolName === 'AskUserQuestion' ? { interaction: { outcome: timedOut ? 'timed_out' : 'interaction_unavailable' } } : {}),
157
+ },
153
158
  failClosedReason,
154
159
  });
155
160
  let raw;
@@ -161,13 +166,13 @@ export async function decideFailClosed(callback, toolName, input, context, timeo
161
166
  ? await Promise.race([
162
167
  callbackPromise,
163
168
  new Promise((_, reject) => {
164
- timer = setTimeout(() => reject(new Error(`canUseTool timed out after ${timeoutMs}ms`)), timeoutMs);
169
+ timer = setTimeout(() => { timedOut = true; reject(new Error(`canUseTool timed out after ${timeoutMs}ms`)); }, timeoutMs);
165
170
  }),
166
171
  ])
167
172
  : await callbackPromise;
168
173
  }
169
174
  catch (error) {
170
- return deny(`Denied (fail-closed): canUseTool callback failed: ${error instanceof Error ? error.message : String(error)}`, 'callback_error');
175
+ return deny(`Permission callback failed; no user rejection or permission was inferred: ${describeError(error)}`, 'callback_error');
171
176
  }
172
177
  finally {
173
178
  if (timer)
@@ -190,16 +195,22 @@ export async function decideFailClosed(callback, toolName, input, context, timeo
190
195
  },
191
196
  };
192
197
  }
193
- case 'deny':
198
+ case 'deny': {
199
+ const interaction = interactionResponseSchema.safeParse(raw.interaction);
200
+ if (raw.interaction !== undefined && !interaction.success) {
201
+ return deny('Interaction response was invalid; no user choice was inferred.', 'unrecognized_result');
202
+ }
194
203
  return {
195
204
  kind: 'decision',
196
205
  decision: {
197
206
  behavior: 'deny',
198
- message: raw.message ?? 'Denied by host',
207
+ message: sanitizeErrorText(raw.message ?? 'Denied by host'),
208
+ ...(toolName === 'AskUserQuestion' && interaction.success ? { interaction: interaction.data } : {}),
199
209
  ...(raw.interrupt !== undefined && { interrupt: raw.interrupt }),
200
210
  toolUseID: context.toolUseId,
201
211
  },
202
212
  };
213
+ }
203
214
  case 'pending':
204
215
  return { kind: 'pending' };
205
216
  default:
@@ -355,10 +366,15 @@ export async function* queryWithTransport(prompt, options, transport) {
355
366
  if (block.type === 'tool_result') {
356
367
  const toolUseId = String(block.tool_use_id ?? '');
357
368
  const isError = block.is_error === true;
369
+ const resultContent = isError
370
+ ? typeof block.content === 'string' ? sanitizeErrorText(block.content)
371
+ : Array.isArray(block.content) ? block.content.map(part => part?.type === 'text' && typeof part.text === 'string' ? { ...part, text: sanitizeErrorText(part.text) } : part)
372
+ : describeError(block.content)
373
+ : block.content;
358
374
  yield {
359
375
  type: 'tool_result',
360
376
  toolUseId,
361
- content: block.content,
377
+ content: resultContent,
362
378
  isError,
363
379
  sessionId,
364
380
  };
@@ -366,7 +382,7 @@ export async function* queryWithTransport(prompt, options, transport) {
366
382
  (() => hooks.onToolUse({
367
383
  toolUseId,
368
384
  toolName: toolNamesById.get(toolUseId),
369
- content: block.content,
385
+ content: resultContent,
370
386
  isError,
371
387
  sessionId,
372
388
  })));
@@ -461,6 +477,7 @@ export async function* queryWithTransport(prompt, options, transport) {
461
477
  behavior: 'deny',
462
478
  message: 'Denied (fail-closed): no canUseTool callback configured',
463
479
  toolUseID: toolUseId,
480
+ ...(toolName === 'AskUserQuestion' ? { interaction: { outcome: 'interaction_unavailable' } } : {}),
464
481
  },
465
482
  failClosedReason: 'no_callback',
466
483
  };
@@ -538,6 +555,7 @@ export async function* queryWithTransport(prompt, options, transport) {
538
555
  requestId,
539
556
  toolUseId,
540
557
  behavior: outcome.decision.behavior,
558
+ ...(outcome.decision.behavior === 'deny' && outcome.decision.interaction ? { interaction: outcome.decision.interaction } : {}),
541
559
  source: 'callback',
542
560
  ...(outcome.failClosedReason && {
543
561
  failClosedReason: outcome.failClosedReason,
@@ -587,8 +605,12 @@ export async function* queryWithTransport(prompt, options, transport) {
587
605
  ? message.duration_ms
588
606
  : undefined,
589
607
  isError: message.is_error === true,
608
+ ...(message.errors !== undefined ? { errors: errorDetailsList(message.errors) } : {}),
609
+ ...(message.recovery !== undefined ? { recovery: sanitizeRecoveryOutcome(message.recovery) } : {}),
590
610
  sessionId,
591
- result: typeof message.result === 'string' ? message.result : undefined,
611
+ result: typeof message.result === 'string'
612
+ ? message.is_error === true ? sanitizeErrorText(message.result) : message.result
613
+ : undefined,
592
614
  // slice sdk-json-schema-output (G4): surface the CLI's schema-validated structured
593
615
  // output (present only when the query ran with jsonSchema).
594
616
  structuredOutput: message
@@ -25,6 +25,8 @@ export type CanUseToolResult = {
25
25
  } | {
26
26
  behavior: 'deny';
27
27
  message?: string;
28
+ /** AskUserQuestion only; missing field preserves legacy deny behavior. */
29
+ interaction?: import('./interactionResponseSchema.js').InteractionResponse;
28
30
  /** Also interrupt the current turn instead of letting the agent continue. */
29
31
  interrupt?: boolean;
30
32
  } | {
@@ -304,6 +306,7 @@ export type SDKEvent = {
304
306
  behavior: 'allow' | 'deny';
305
307
  /** Why the SDK decided this way when the host callback did not decide. */
306
308
  failClosedReason?: string;
309
+ interaction?: import('./interactionResponseSchema.js').InteractionResponse;
307
310
  /** 'pending_decision' when resolved by resumeWithDecision, not the live callback. */
308
311
  source?: 'callback' | 'pending_decision';
309
312
  } | {
@@ -324,6 +327,9 @@ export type SDKEvent = {
324
327
  isError: boolean;
325
328
  sessionId?: string;
326
329
  result?: string;
330
+ /** Sanitized execution diagnostics; absent on older CLI result envelopes. */
331
+ errors?: string[];
332
+ recovery?: import('./errorDiagnostics.js').RecoveryOutcome;
327
333
  /**
328
334
  * slice sdk-json-schema-output (G4): the schema-validated structured output, present only
329
335
  * when the query was run with `jsonSchema`. undefined otherwise. subtype
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@margay/ccl-core",
3
- "version": "1.3.6-alpha.1",
3
+ "version": "1.3.6-alpha.3",
4
4
  "description": "Core runtime extracted from Claude Code (ccl) for Margay integration",
5
5
  "license": "SEE LICENSE IN README.md",
6
6
  "type": "module",