@mrclrchtr/supi-consulting 11.1.0 → 11.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,7 @@
1
1
  import { isAbsolute, normalize as normalizePath, relative } from "node:path";
2
2
  import { hashEvidence } from "../../../evidence-hash.ts";
3
3
  import type { ConsultationUsage } from "../../../types.ts";
4
+ import type { ConsultingDeniedOperation, ConsultingFailureDiagnostics } from "../../errors.ts";
4
5
  import { isWebToolName, isWorkspaceToolName } from "../activity.ts";
5
6
  import { validateAntigravityAnswer } from "../structured-output.ts";
6
7
  import type { AntigravityExecutionFacts } from "../types.ts";
@@ -13,20 +14,33 @@ import {
13
14
  extractUrl,
14
15
  extractWorkspacePath,
15
16
  isErrorStatus,
16
- isRecord,
17
- isSuccessStatus,
18
17
  isTerminalEvent,
19
18
  isToolResultEvent,
20
19
  isToolStartEvent,
21
- safeString,
22
20
  toolInput,
23
21
  toolName,
24
22
  } from "./event-values.ts";
25
- import { normalizeProtocolEvent } from "./protocol.ts";
23
+ import {
24
+ classifyDeniedOperation,
25
+ classifyToolResult,
26
+ type DiagnosticReadScope,
27
+ FileResources,
28
+ type FileTargetObservation,
29
+ fileTargetUpdate,
30
+ isPermissionDenial,
31
+ terminalDeniedOperations,
32
+ } from "./permission-diagnostics.ts";
33
+ import {
34
+ hasExplicitSuccess,
35
+ isNonToolStepUpdate,
36
+ isSuccessfulActivity,
37
+ nestedToolEvents,
38
+ normalizeProtocolEvent,
39
+ } from "./protocol.ts";
26
40
  import { AntigravityResponseError } from "./response-error.ts";
27
41
  import { mergeUsage, readUsage } from "./usage.ts";
28
42
 
29
- interface PendingTool {
43
+ interface PendingTool extends FileTargetObservation {
30
44
  name: string;
31
45
  urlHash?: string;
32
46
  pathHash?: string;
@@ -34,19 +48,16 @@ interface PendingTool {
34
48
  denied?: boolean;
35
49
  }
36
50
 
37
- interface ToolResultClassification {
38
- kind: "pending" | "failure" | "success";
39
- denied: boolean;
40
- }
41
-
42
51
  const MAX_COMPLETED_TOOL_IDS = 4_096;
43
52
 
44
53
  /**
45
54
  * Reduces parsed events to bounded execution facts. Raw events and tool payloads
46
- * are not retained after each event is processed.
55
+ * are not retained; only bounded structured requested file targets survive pending calls.
47
56
  */
48
57
  export class AntigravityEventAccumulator {
49
58
  readonly #workspaceDirectory: string | undefined;
59
+ readonly #readScope: DiagnosticReadScope;
60
+ readonly #expectedResponseToken: string | undefined;
50
61
  #pendingTools = new Map<string, PendingTool>();
51
62
  #completedToolIds = new Set<string>();
52
63
  #conversationId: string | undefined;
@@ -58,12 +69,17 @@ export class AntigravityEventAccumulator {
58
69
  #toolCounts = new Map<string, number>();
59
70
  #successfulToolNames = new Set<string>();
60
71
  #permissionDenials = 0;
72
+ #permissionDenialsByOperation: Partial<Record<ConsultingDeniedOperation, number>> = {};
73
+ #deniedFileAccessOutsideReadRoots = 0;
74
+ #deniedFileResources = new FileResources();
61
75
  #unattributedPermissionDenialSeen = false;
62
76
  #sourceHashes = new Set<string>();
63
77
  #workspacePathHashes = new Set<string>();
64
78
 
65
- constructor(options: { workspaceDirectory?: string } = {}) {
79
+ constructor(options: DiagnosticReadScope & { expectedResponseToken?: string } = {}) {
66
80
  this.#workspaceDirectory = options.workspaceDirectory;
81
+ this.#readScope = options;
82
+ this.#expectedResponseToken = options.expectedResponseToken;
67
83
  }
68
84
 
69
85
  /** Consume one already-parsed event. */
@@ -74,8 +90,9 @@ export class AntigravityEventAccumulator {
74
90
  conversationId(event) ?? conversationId(normalizedEvent) ?? this.#conversationId;
75
91
  this.#usage = mergeUsage(this.#usage, mergeUsage(readUsage(event), readUsage(normalizedEvent)));
76
92
  const terminal = isTerminalEvent(normalizedEvent);
77
- const nestedTools = consumeNestedToolBlocks(this, normalizedEvent);
78
- if (nestedTools && !terminal) return;
93
+ const nestedTools = nestedToolEvents(normalizedEvent);
94
+ for (const toolEvent of nestedTools) this.consume(toolEvent);
95
+ if (nestedTools.length && !terminal) return;
79
96
 
80
97
  if (isToolStartEvent(normalizedEvent)) {
81
98
  this.#rememberToolStart(normalizedEvent);
@@ -90,27 +107,28 @@ export class AntigravityEventAccumulator {
90
107
  return;
91
108
  }
92
109
 
93
- const directName = toolName(normalizedEvent);
94
- if (directName && isSuccessfulActivity(normalizedEvent)) {
95
- this.#recordSuccessfulTool(directName, normalizedEvent);
96
- }
97
- const denied = isPermissionDenial(normalizedEvent);
98
- if (denied && !isNonToolStepUpdate(normalizedEvent)) {
99
- const counted = directName
100
- ? this.#recordPermissionDenial(directName, normalizedEvent)
101
- : this.#recordUnattributedPermissionDenial();
102
- if (counted) this.#permissionDenials += 1;
103
- }
110
+ this.#consumeActivity(normalizedEvent);
104
111
  }
105
112
 
106
113
  /** Return safe execution facts even when the response is incomplete. */
107
- get diagnostics(): {
108
- permissionDenials: number;
109
- terminalSeen: boolean;
110
- terminalSucceeded: boolean;
111
- } {
114
+ get diagnostics(): ConsultingFailureDiagnostics {
115
+ const denied = this.#deniedFileResources.snapshot;
116
+ const unfinished = new FileResources();
117
+ for (const pending of this.#pendingTools.values()) {
118
+ if (!pending.denied)
119
+ unfinished.record(classifyDeniedOperation(pending.name), pending.requestedResource);
120
+ }
121
+ const pending = unfinished.snapshot;
112
122
  return {
113
123
  permissionDenials: this.#permissionDenials,
124
+ permissionDenialsByOperation: { ...this.#permissionDenialsByOperation },
125
+ deniedFileAccessOutsideReadRoots: this.#deniedFileAccessOutsideReadRoots,
126
+ deniedResources: denied.resources,
127
+ deniedResourcesOmitted: denied.omitted,
128
+ deniedResourcePathsUnavailable: denied.unavailable,
129
+ unfinishedResources: pending.resources,
130
+ unfinishedResourcesOmitted: pending.omitted,
131
+ unfinishedResourcePathsUnavailable: pending.unavailable,
114
132
  terminalSeen: this.#terminalSeen,
115
133
  terminalSucceeded: this.#terminalSucceeded,
116
134
  };
@@ -122,8 +140,16 @@ export class AntigravityEventAccumulator {
122
140
  if (!this.#terminalSucceeded) throw new AntigravityResponseError("terminal-failed");
123
141
  if (!this.#candidate) throw new AntigravityResponseError("missing-answer");
124
142
  if (!this.#conversationId) throw new AntigravityResponseError("missing-conversation");
143
+ const { responseToken, ...candidate } = this.#candidate;
144
+ const answer = validateAntigravityAnswer(candidate);
145
+ if (
146
+ this.#expectedResponseToken !== undefined &&
147
+ responseToken !== this.#expectedResponseToken
148
+ ) {
149
+ throw new AntigravityResponseError("unbound-answer");
150
+ }
125
151
  return {
126
- answer: validateAntigravityAnswer(this.#candidate),
152
+ answer,
127
153
  conversationId: this.#conversationId,
128
154
  ...(this.#usage ? { usage: this.#usage } : {}),
129
155
  observedToolNames: [...this.#toolNames],
@@ -135,6 +161,44 @@ export class AntigravityEventAccumulator {
135
161
  };
136
162
  }
137
163
 
164
+ #consumeActivity(event: Record<string, unknown>): void {
165
+ if (isNonToolStepUpdate(event)) return;
166
+ const id = eventId(event);
167
+ if (id && this.#completedToolIds.has(id)) return;
168
+ let pending = this.#pendingTools.get(id ?? "");
169
+ const name = toolName(event) ?? pending?.name;
170
+ if (name && eventType(event) === "step_update") {
171
+ pending = this.#recordPendingToolResult(id, name, event, pending);
172
+ }
173
+ if (isPermissionDenial(event)) {
174
+ this.#observeDeniedActivity(name, event, pending);
175
+ } else if (name) {
176
+ this.#observeExplicitOutcome(name, event, pending);
177
+ }
178
+ }
179
+
180
+ #observeDeniedActivity(
181
+ name: string | undefined,
182
+ event: Record<string, unknown>,
183
+ pending?: PendingTool,
184
+ ): void {
185
+ const counted = name
186
+ ? this.#recordPermissionDenial(name, event, pending)
187
+ : this.#recordUnattributedPermissionDenial();
188
+ if (counted) this.#countPermissionDenial(name, event, pending);
189
+ if (isErrorStatus(eventStatus(event))) this.#completeToolResult(eventId(event));
190
+ }
191
+
192
+ #observeExplicitOutcome(
193
+ name: string,
194
+ event: Record<string, unknown>,
195
+ pending?: PendingTool,
196
+ ): void {
197
+ const successful = isSuccessfulActivity(event);
198
+ if (successful) this.#recordSuccessfulTool(name, event, pending);
199
+ if (successful || isErrorStatus(eventStatus(event))) this.#completeToolResult(eventId(event));
200
+ }
201
+
138
202
  #rememberToolStart(event: Record<string, unknown>): void {
139
203
  const name = toolName(event);
140
204
  if (!name) return;
@@ -152,14 +216,15 @@ export class AntigravityEventAccumulator {
152
216
  observed: true,
153
217
  ...(urlHash ? { urlHash } : {}),
154
218
  ...(pathHash ? { pathHash } : {}),
219
+ ...fileTargetUpdate(name, event, this.#readScope, previous),
155
220
  ...(denied ? { denied: true } : {}),
156
221
  },
157
222
  previous,
158
223
  );
159
224
  if (!previous) this.#recordObservedTool(name);
160
225
  if (id) this.#pendingTools.set(id, pending);
161
- if (isSuccessfulActivity(event)) this.#recordSuccessfulTool(name, event, pending);
162
- if (denied && !previous?.denied) this.#permissionDenials += 1;
226
+ if (denied && !previous?.denied) this.#countPermissionDenial(name, event, pending);
227
+ if (!denied) this.#observeExplicitOutcome(name, event, pending);
163
228
  }
164
229
 
165
230
  #consumeToolResult(event: Record<string, unknown>): void {
@@ -171,12 +236,14 @@ export class AntigravityEventAccumulator {
171
236
  if (!name) return;
172
237
  const classification = classifyToolResult(event);
173
238
  if (classification.kind === "pending") {
174
- this.#recordPendingToolResult(id, name, pending);
239
+ this.#recordPendingToolResult(id, name, event, pending);
175
240
  return;
176
241
  }
177
242
  if (id) this.#completeToolResult(id);
178
243
  if (classification.kind === "failure") {
179
- this.#recordFailedToolResult(name, pending, classification.denied);
244
+ if (classification.denied && !pending?.denied)
245
+ this.#countPermissionDenial(name, event, pending);
246
+ if (!pending?.observed) this.#recordObservedTool(name);
180
247
  return;
181
248
  }
182
249
  this.#recordSuccessfulTool(name, event, pending);
@@ -185,20 +252,41 @@ export class AntigravityEventAccumulator {
185
252
  #recordPendingToolResult(
186
253
  id: string | undefined,
187
254
  name: string,
255
+ event: Record<string, unknown>,
188
256
  pending: PendingTool | undefined,
189
- ): void {
257
+ ): PendingTool {
190
258
  if (!pending?.observed) this.#recordObservedTool(name);
191
- if (id && !pending) this.#pendingTools.set(id, { name, observed: true });
259
+ const updated = {
260
+ ...pending,
261
+ name,
262
+ observed: true,
263
+ ...fileTargetUpdate(name, event, this.#readScope, pending),
264
+ };
265
+ if (id) this.#pendingTools.set(id, updated);
266
+ return updated;
192
267
  }
193
268
 
194
- #completeToolResult(id: string): void {
269
+ #completeToolResult(id: string | undefined): void {
270
+ if (!id) return;
195
271
  this.#pendingTools.delete(id);
196
272
  this.#rememberCompletedToolId(id);
197
273
  }
198
274
 
199
- #recordFailedToolResult(name: string, pending: PendingTool | undefined, denied: boolean): void {
200
- if (denied && !pending?.denied) this.#permissionDenials += 1;
201
- if (!pending?.observed) this.#recordObservedTool(name);
275
+ #countPermissionDenial(
276
+ name: string | undefined,
277
+ event: Record<string, unknown>,
278
+ pending?: PendingTool,
279
+ ): void {
280
+ this.#permissionDenials += 1;
281
+ const operation = classifyDeniedOperation(name);
282
+ this.#permissionDenialsByOperation[operation] =
283
+ (this.#permissionDenialsByOperation[operation] ?? 0) + 1;
284
+ const started = pending ?? this.#pendingTools.get(eventId(event) ?? "");
285
+ const target = fileTargetUpdate(name, event, this.#readScope, started);
286
+ this.#deniedFileResources.record(operation, target.requestedResource);
287
+ if (target.outsideReadRoots) {
288
+ this.#deniedFileAccessOutsideReadRoots += 1;
289
+ }
202
290
  }
203
291
 
204
292
  #rememberCompletedToolId(id: string): void {
@@ -219,10 +307,14 @@ export class AntigravityEventAccumulator {
219
307
  return true;
220
308
  }
221
309
 
222
- #recordPermissionDenial(name: string, event: Record<string, unknown>): boolean {
310
+ #recordPermissionDenial(
311
+ name: string,
312
+ event: Record<string, unknown>,
313
+ observation?: PendingTool,
314
+ ): boolean {
223
315
  const id = eventId(event);
224
316
  if (id && this.#completedToolIds.has(id)) return false;
225
- const pending = id ? this.#pendingTools.get(id) : undefined;
317
+ const pending = observation ?? (id ? this.#pendingTools.get(id) : undefined);
226
318
  if (pending?.denied) return false;
227
319
  if (!pending?.observed) this.#recordObservedTool(name);
228
320
  if (id) {
@@ -261,8 +353,27 @@ export class AntigravityEventAccumulator {
261
353
  return normalizeWorkspacePath(normalized);
262
354
  }
263
355
 
356
+ /** Terminal summaries prove an operation was denied, but supply no call IDs or targets. */
357
+ #consumeTerminalDenials(event: Record<string, unknown>): void {
358
+ const operations = terminalDeniedOperations(event);
359
+ // The generic notice may describe the same denial as the terminal summary.
360
+ // Keep a conservative lower bound without attributing it to a particular call.
361
+ if (operations.size && this.#unattributedPermissionDenialSeen) {
362
+ this.#permissionDenials -= 1;
363
+ const other = (this.#permissionDenialsByOperation.other ?? 1) - 1;
364
+ if (other) this.#permissionDenialsByOperation.other = other;
365
+ else delete this.#permissionDenialsByOperation.other;
366
+ }
367
+ for (const [operation, name] of operations) {
368
+ if (!this.#permissionDenialsByOperation[operation]) {
369
+ this.#countPermissionDenial(name, {});
370
+ }
371
+ }
372
+ }
373
+
264
374
  #consumeTerminal(event: Record<string, unknown>): void {
265
375
  this.#terminalSeen = true;
376
+ this.#consumeTerminalDenials(event);
266
377
  const status = eventStatus(event);
267
378
  if (isErrorStatus(status) || !hasExplicitSuccess(event, status)) {
268
379
  throw new AntigravityResponseError("terminal-failed");
@@ -273,94 +384,6 @@ export class AntigravityEventAccumulator {
273
384
  }
274
385
  }
275
386
 
276
- function consumeNestedToolBlocks(
277
- accumulator: AntigravityEventAccumulator,
278
- event: Record<string, unknown>,
279
- ): boolean {
280
- const message = isRecord(event.message) ? event.message : undefined;
281
- const content = message?.content;
282
- if (!Array.isArray(content)) return false;
283
- let consumed = false;
284
- for (const block of content) {
285
- if (!isRecord(block)) continue;
286
- const normalizedBlock = normalizeProtocolEvent(block);
287
- if (!isToolActivityBlock(normalizedBlock)) continue;
288
- accumulator.consume(normalizedBlock);
289
- consumed = true;
290
- }
291
- return consumed;
292
- }
293
-
294
- function isToolActivityBlock(event: Record<string, unknown>): boolean {
295
- if (isToolStartEvent(event) || isToolResultEvent(event)) return true;
296
- return eventType(event) === "step_update" && !isNonToolStepUpdate(event);
297
- }
298
-
299
- function hasExplicitSuccess(event: Record<string, unknown>, status: string | undefined): boolean {
300
- return status !== undefined
301
- ? isSuccessStatus(status)
302
- : event.is_error === false || event.isError === false || event.success === true;
303
- }
304
-
305
- function isSuccessfulActivity(event: Record<string, unknown>): boolean {
306
- const status = eventStatus(event);
307
- return (
308
- !isErrorStatus(status) &&
309
- isSuccessStatus(status) &&
310
- (status !== undefined || event.success === true || event.ok === true)
311
- );
312
- }
313
-
314
- function isNonToolStepUpdate(event: Record<string, unknown>): boolean {
315
- const type = eventType(event);
316
- if (!["assistant", "step_update"].includes(type)) return false;
317
- const declaredType = safeString(event.step_type ?? event.stepType, 40)?.toLowerCase();
318
- if (declaredType) return declaredType !== "tool";
319
- return toolName(event) === undefined && !isRecord(event.tool_info) && !isRecord(event.toolInfo);
320
- }
321
-
322
- function classifyToolResult(event: Record<string, unknown>): ToolResultClassification {
323
- const status = eventStatus(event);
324
- const denied = isPermissionDenial(event);
325
- if (denied || isErrorStatus(status)) return { kind: "failure", denied };
326
- return { kind: isSuccessStatus(status) ? "success" : "pending", denied };
327
- }
328
-
329
- function isPermissionDenial(event: Record<string, unknown>): boolean {
330
- const status = eventStatus(event);
331
- if (["denied", "permission_denied", "forbidden", "blocked"].includes(status ?? "")) return true;
332
- const type = eventType(event);
333
- if (type.includes("permission") || type.includes("denied") || type.includes("blocked"))
334
- return true;
335
- const hasExplicitFailure =
336
- event.is_error === true ||
337
- event.isError === true ||
338
- isErrorStatus(status) ||
339
- event.error !== undefined ||
340
- event.reason !== undefined;
341
- if (!hasExplicitFailure) return false;
342
- const message = [event.error, event.reason, event.message, event.content]
343
- .map((value) => safeDenialText(value))
344
- .join(" ")
345
- .toLowerCase();
346
- return message.includes("permission") || message.includes("denied");
347
- }
348
-
349
- function safeDenialText(value: unknown, depth = 0): string {
350
- if (depth > 3) return "";
351
- if (typeof value === "string") return value.slice(0, 4_000);
352
- if (Array.isArray(value)) {
353
- return value
354
- .slice(0, 20)
355
- .map((item) => safeDenialText(item, depth + 1))
356
- .join(" ");
357
- }
358
- if (!isRecord(value)) return "";
359
- return ["text", "message", "content", "error", "reason"]
360
- .map((key) => safeDenialText(value[key], depth + 1))
361
- .join(" ");
362
- }
363
-
364
387
  function mergePendingTool(current: PendingTool, previous: PendingTool | undefined): PendingTool {
365
388
  if (previous?.urlHash && !current.urlHash) current.urlHash = previous.urlHash;
366
389
  if (previous?.pathHash && !current.pathHash) current.pathHash = previous.pathHash;
@@ -14,6 +14,8 @@ export interface ProjectHookProbeResult {
14
14
  export async function probeProjectHooks(options: {
15
15
  paths: IsolatedAntigravityPaths;
16
16
  cwd: string;
17
+ /** Match the paid process's inspection roots when checking project hook state. */
18
+ additionalReadOnlyDirectories?: readonly string[];
17
19
  signal?: AbortSignal;
18
20
  onProcessStart?: () => void;
19
21
  }): Promise<ProjectHookProbeResult> {
@@ -21,7 +23,13 @@ export async function probeProjectHooks(options: {
21
23
  const result = await runAntigravityProbe({
22
24
  paths: options.paths,
23
25
  cwd: options.cwd,
24
- args: [...PROJECT_HOOK_PROBE_ARGUMENTS],
26
+ args: [
27
+ ...PROJECT_HOOK_PROBE_ARGUMENTS,
28
+ ...(options.additionalReadOnlyDirectories ?? []).flatMap((directory) => [
29
+ "--add-dir",
30
+ directory,
31
+ ]),
32
+ ],
25
33
  signal: options.signal,
26
34
  timeoutMs: 15_000,
27
35
  onProcessStart: options.onProcessStart,