@pugi/cli 0.1.0-beta.17 → 0.1.0-beta.19
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/dist/core/compact/auto-trigger.js +96 -0
- package/dist/core/compact/buffer-rewriter.js +115 -0
- package/dist/core/compact/summarizer.js +196 -0
- package/dist/core/compact/token-counter.js +108 -0
- package/dist/core/denial-tracking/index.js +8 -0
- package/dist/core/denial-tracking/state.js +264 -0
- package/dist/core/diagnostics/probe-runner.js +93 -0
- package/dist/core/diagnostics/probes/api.js +46 -0
- package/dist/core/diagnostics/probes/auth.js +86 -0
- package/dist/core/diagnostics/probes/cli-version.js +127 -0
- package/dist/core/diagnostics/probes/config.js +72 -0
- package/dist/core/diagnostics/probes/denial-tracking.js +57 -0
- package/dist/core/diagnostics/probes/disk.js +81 -0
- package/dist/core/diagnostics/probes/git.js +65 -0
- package/dist/core/diagnostics/probes/mcp.js +75 -0
- package/dist/core/diagnostics/probes/node.js +59 -0
- package/dist/core/diagnostics/probes/pnpm.js +36 -0
- package/dist/core/diagnostics/probes/session.js +74 -0
- package/dist/core/diagnostics/probes/status-snapshot.js +442 -0
- package/dist/core/diagnostics/probes/workspace.js +63 -0
- package/dist/core/diagnostics/types.js +70 -0
- package/dist/core/engine/native-pugi.js +20 -0
- package/dist/core/engine/strip-internal-fields.js +124 -0
- package/dist/core/engine/tool-bridge.js +251 -49
- package/dist/core/file-cache.js +113 -1
- package/dist/core/mcp/client.js +66 -6
- package/dist/core/mcp/registry.js +24 -2
- package/dist/core/permissions/gate.js +187 -0
- package/dist/core/permissions/index.js +18 -0
- package/dist/core/permissions/mode.js +102 -0
- package/dist/core/permissions/state.js +160 -0
- package/dist/core/permissions/tool-class.js +93 -0
- package/dist/core/repl/session.js +261 -9
- package/dist/core/repl/slash-commands.js +67 -4
- package/dist/runtime/cli.js +153 -58
- package/dist/runtime/commands/compact.js +296 -0
- package/dist/runtime/commands/doctor.js +369 -0
- package/dist/runtime/commands/mcp.js +290 -3
- package/dist/runtime/commands/permissions.js +87 -0
- package/dist/runtime/commands/status.js +178 -0
- package/dist/runtime/version.js +1 -1
- package/dist/tools/agent-tool.js +18 -4
- package/dist/tools/ask-user-question.js +213 -0
- package/dist/tools/file-tools.js +57 -14
- package/dist/tools/registry.js +7 -0
- package/dist/tui/ask-user-question-prompt.js +192 -0
- package/dist/tui/compact-banner.js +54 -0
- package/dist/tui/conversation-pane.js +68 -7
- package/dist/tui/doctor-table.js +31 -0
- package/dist/tui/status-table.js +7 -0
- package/package.json +2 -2
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* α7 L3 (2026-05-27) — leak-parity: underscore-prefix internal-fields filter.
|
|
3
|
+
*
|
|
4
|
+
* The convention (observed in the leaked Claude Code BashTool surface and
|
|
5
|
+
* codified in `docs/research/2026-05-27-pugi-gap-analysis-3-repos.md` §1)
|
|
6
|
+
* is that tool-argument fields whose names start with a leading underscore
|
|
7
|
+
* are INTERNAL — populated by the dispatcher at call time (sessionId,
|
|
8
|
+
* tenantId, correlation handles, hook context, ask-modal bridge handles)
|
|
9
|
+
* but never advertised to the model. The model schema MUST omit them so:
|
|
10
|
+
*
|
|
11
|
+
* 1. No token cost — internal context never burns model budget.
|
|
12
|
+
* 2. No fabrication risk — the model cannot hallucinate values for
|
|
13
|
+
* sessionId / tenantId / etc. because the field is invisible.
|
|
14
|
+
* 3. No leak surface — implementation detail stays implementation detail.
|
|
15
|
+
*
|
|
16
|
+
* The dispatcher (see `tool-bridge.ts::buildExecutor`) does NOT strip these
|
|
17
|
+
* fields at call time. It passes the full args record (including any
|
|
18
|
+
* `_internal*` keys an upstream layer injected) straight to the tool
|
|
19
|
+
* handler. Only the schema surface that the engine adapter ships to the
|
|
20
|
+
* model is filtered.
|
|
21
|
+
*
|
|
22
|
+
* This module is intentionally narrow: it accepts a JSON Schema fragment
|
|
23
|
+
* and returns a deep clone with `_`-prefixed keys removed from every
|
|
24
|
+
* `properties` map encountered while walking, and with `required` filtered
|
|
25
|
+
* to drop any references to those keys. It descends into nested object
|
|
26
|
+
* schemas and into the `items` schema of arrays. It is JSON-Schema-version
|
|
27
|
+
* agnostic (works on draft-07, 2019-09, 2020-12 alike) because it only
|
|
28
|
+
* inspects `properties`/`required`/`items` and leaves the rest of the
|
|
29
|
+
* fragment alone.
|
|
30
|
+
*
|
|
31
|
+
* Edge cases handled:
|
|
32
|
+
* - `_` alone (single underscore) is treated as internal and stripped.
|
|
33
|
+
* - Nested object schemas inside `properties` get the same treatment
|
|
34
|
+
* (a sub-property whose name starts with `_` is removed too).
|
|
35
|
+
* - Array `items` are walked. Tuple schemas (`items` as array) are
|
|
36
|
+
* walked element-by-element.
|
|
37
|
+
* - `oneOf`/`anyOf`/`allOf` branches are walked.
|
|
38
|
+
* - Non-object inputs (null, primitives, arrays passed as the root)
|
|
39
|
+
* are returned as-is — defensive no-op, never throws.
|
|
40
|
+
*
|
|
41
|
+
* Contract notes:
|
|
42
|
+
* - Pure function. Input is never mutated.
|
|
43
|
+
* - Output is a deep clone — every nested object/array is freshly
|
|
44
|
+
* allocated so callers can mutate safely.
|
|
45
|
+
* - JSON-only — does not preserve symbols, getters, or class instances
|
|
46
|
+
* because JSON Schema is plain-data by spec.
|
|
47
|
+
*/
|
|
48
|
+
const INTERNAL_PREFIX = '_';
|
|
49
|
+
/**
|
|
50
|
+
* Returns true when the field name should be stripped from the model-
|
|
51
|
+
* facing schema. Leading underscore is the contract — single `_` is also
|
|
52
|
+
* stripped (no escape hatch). Empty-string keys (which are technically
|
|
53
|
+
* valid JSON) are left alone so we do not silently drop them.
|
|
54
|
+
*/
|
|
55
|
+
export function isInternalFieldName(name) {
|
|
56
|
+
return name.length > 0 && name.startsWith(INTERNAL_PREFIX);
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Strip `_`-prefixed properties from a JSON Schema fragment. Recursively
|
|
60
|
+
* walks nested object schemas and array `items`. Returns a deep clone;
|
|
61
|
+
* the input is never mutated.
|
|
62
|
+
*
|
|
63
|
+
* Pass-through behaviour:
|
|
64
|
+
* - Non-object / null / array inputs round-trip unchanged (as deep
|
|
65
|
+
* clones where applicable).
|
|
66
|
+
* - Fragments with no `properties` key are returned as deep clones
|
|
67
|
+
* after walking `items`/`oneOf`/`anyOf`/`allOf`.
|
|
68
|
+
*/
|
|
69
|
+
export function stripInternalFields(schema) {
|
|
70
|
+
if (schema === null || typeof schema !== 'object')
|
|
71
|
+
return schema;
|
|
72
|
+
if (Array.isArray(schema)) {
|
|
73
|
+
return schema.map((item) => stripInternalFields(item));
|
|
74
|
+
}
|
|
75
|
+
return walkObject(schema);
|
|
76
|
+
}
|
|
77
|
+
function walkObject(node) {
|
|
78
|
+
const out = {};
|
|
79
|
+
for (const [key, value] of Object.entries(node)) {
|
|
80
|
+
if (key === 'properties' && value !== null && typeof value === 'object' && !Array.isArray(value)) {
|
|
81
|
+
out[key] = walkProperties(value);
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
if (key === 'required' && Array.isArray(value)) {
|
|
85
|
+
out[key] = value.filter((entry) => typeof entry === 'string' && !isInternalFieldName(entry));
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
if (key === 'items') {
|
|
89
|
+
out[key] = stripInternalFields(value);
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
if (key === 'oneOf' || key === 'anyOf' || key === 'allOf') {
|
|
93
|
+
if (Array.isArray(value)) {
|
|
94
|
+
out[key] = value.map((branch) => stripInternalFields(branch));
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
// Default: deep-clone any nested objects/arrays so the caller can
|
|
99
|
+
// mutate freely without touching the input. Primitives pass through.
|
|
100
|
+
out[key] = cloneJson(value);
|
|
101
|
+
}
|
|
102
|
+
return out;
|
|
103
|
+
}
|
|
104
|
+
function walkProperties(props) {
|
|
105
|
+
const out = {};
|
|
106
|
+
for (const [propName, propSchema] of Object.entries(props)) {
|
|
107
|
+
if (isInternalFieldName(propName))
|
|
108
|
+
continue;
|
|
109
|
+
out[propName] = stripInternalFields(propSchema);
|
|
110
|
+
}
|
|
111
|
+
return out;
|
|
112
|
+
}
|
|
113
|
+
function cloneJson(value) {
|
|
114
|
+
if (value === null || typeof value !== 'object')
|
|
115
|
+
return value;
|
|
116
|
+
if (Array.isArray(value))
|
|
117
|
+
return value.map((item) => cloneJson(item));
|
|
118
|
+
const out = {};
|
|
119
|
+
for (const [key, val] of Object.entries(value)) {
|
|
120
|
+
out[key] = cloneJson(val);
|
|
121
|
+
}
|
|
122
|
+
return out;
|
|
123
|
+
}
|
|
124
|
+
//# sourceMappingURL=strip-internal-fields.js.map
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { editTool, globTool, grepTool, OperatorAbortedError, readTool, writeTool, } from '../../tools/file-tools.js';
|
|
1
|
+
import { editTool, globTool, grepTool, OperatorAbortedError, readTool, StaleReadError, writeTool, } from '../../tools/file-tools.js';
|
|
2
2
|
import { bashToolSync } from '../../tools/bash.js';
|
|
3
3
|
import { askUser } from '../../tools/ask-user.js';
|
|
4
|
+
import { askUserQuestionJsonSchema, dispatchAskUserQuestion, } from '../../tools/ask-user-question.js';
|
|
4
5
|
import { skillInvoke, skillList } from '../../tools/skill-tool.js';
|
|
5
6
|
import { taskCreate, taskGet, taskList, taskUpdate, } from '../../tools/tasks.js';
|
|
6
7
|
import { webFetchTool } from '../../tools/web-fetch.js';
|
|
@@ -8,6 +9,9 @@ import { webSearchTool } from '../../tools/web-search.js';
|
|
|
8
9
|
import { agentTool } from '../../tools/agent-tool.js';
|
|
9
10
|
import { multiEdit } from '../../tools/multi-edit.js';
|
|
10
11
|
import { buildMcpToolDefs, defaultNonInteractiveMcpPrompt, dispatchMcpTool, MCP_TOOL_PREFIX, } from '../../tools/mcp-tool.js';
|
|
12
|
+
import { buildDenialContext, DENIAL_REMINDER_THRESHOLD, } from '../denial-tracking/state.js';
|
|
13
|
+
import { stripInternalFields } from './strip-internal-fields.js';
|
|
14
|
+
import { applyAskAnswer, gate as permissionGate, getToolClass, PermissionDenied, } from '../permissions/index.js';
|
|
11
15
|
/**
|
|
12
16
|
* Tool-bridge: turns the abstract tool registry into:
|
|
13
17
|
* 1. An OpenAI-shaped tools schema for `EngineLoopClient.send`.
|
|
@@ -185,26 +189,18 @@ export function buildToolsSchema(kind, options = { allowFetch: false, allowSearc
|
|
|
185
189
|
},
|
|
186
190
|
},
|
|
187
191
|
});
|
|
188
|
-
// β1 T2
|
|
189
|
-
//
|
|
192
|
+
// β1 T2 → leak L5 (2026-05-27): structured AskUserQuestion bridge.
|
|
193
|
+
// Schema upgraded to openclaude's multi-choice form: header chip +
|
|
194
|
+
// {label, description} per option. Dispatcher accepts the structured
|
|
195
|
+
// form (preferred) AND the legacy string-array form so existing
|
|
196
|
+
// callers / tests keep working until the next major bump.
|
|
197
|
+
//
|
|
198
|
+
// Interactive TTY → returns the picked label(s).
|
|
199
|
+
// Non-TTY / no bridge → `[user_input_required]` envelope.
|
|
190
200
|
toolDefs.push({
|
|
191
201
|
name: 'ask_user_question',
|
|
192
|
-
description: '
|
|
193
|
-
parameters:
|
|
194
|
-
type: 'object',
|
|
195
|
-
additionalProperties: false,
|
|
196
|
-
required: ['question', 'options'],
|
|
197
|
-
properties: {
|
|
198
|
-
question: { type: 'string', description: 'Short, scannable question. Max 1000 chars.' },
|
|
199
|
-
options: {
|
|
200
|
-
type: 'array',
|
|
201
|
-
items: { type: 'string', maxLength: 200 },
|
|
202
|
-
minItems: 2,
|
|
203
|
-
maxItems: 4,
|
|
204
|
-
},
|
|
205
|
-
multiSelect: { type: 'boolean' },
|
|
206
|
-
},
|
|
207
|
-
},
|
|
202
|
+
description: 'Clarifying multi-choice question to the operator. Use INSTEAD of asking in prose when one parameter is missing. Required: question (?-ended), header (≤12 chars), 2-4 options each with {label, description}. NEVER include "Other" — UI auto-adds. Budget: max 1 per turn.',
|
|
203
|
+
parameters: askUserQuestionJsonSchema,
|
|
208
204
|
});
|
|
209
205
|
// β1 T3: Skill tool — discover + invoke locally-installed skills.
|
|
210
206
|
toolDefs.push({
|
|
@@ -332,7 +328,10 @@ export function buildToolsSchema(kind, options = { allowFetch: false, allowSearc
|
|
|
332
328
|
if (!planMode) {
|
|
333
329
|
toolDefs.push({
|
|
334
330
|
name: 'write',
|
|
335
|
-
description: 'Create or overwrite a workspace file.
|
|
331
|
+
description: 'Create or overwrite a workspace file. Prefer edit for existing files. ' +
|
|
332
|
+
'For OVERWRITE of an existing file, you MUST read the file first in this session — ' +
|
|
333
|
+
'write refuses with STALE_READ if the file changed since your last read, or if you ' +
|
|
334
|
+
'never read it. New-file creation (path does not exist) skips that gate. Workspace-scoped.',
|
|
336
335
|
parameters: {
|
|
337
336
|
type: 'object',
|
|
338
337
|
additionalProperties: false,
|
|
@@ -344,7 +343,10 @@ export function buildToolsSchema(kind, options = { allowFetch: false, allowSearc
|
|
|
344
343
|
},
|
|
345
344
|
}, {
|
|
346
345
|
name: 'edit',
|
|
347
|
-
description: 'Replace exactly one occurrence of oldString with newString inside an already-read file.
|
|
346
|
+
description: 'Replace exactly one occurrence of oldString with newString inside an already-read file. ' +
|
|
347
|
+
'Refuses with STALE_READ if the file was never read this session or the on-disk contents ' +
|
|
348
|
+
'drifted since the read (mtime+sha gate). Recovery: re-read with the `read` tool, then ' +
|
|
349
|
+
'retry the edit. Also fails if oldString is missing or duplicate.',
|
|
348
350
|
parameters: {
|
|
349
351
|
type: 'object',
|
|
350
352
|
additionalProperties: false,
|
|
@@ -417,7 +419,41 @@ export function buildToolsSchema(kind, options = { allowFetch: false, allowSearc
|
|
|
417
419
|
});
|
|
418
420
|
}
|
|
419
421
|
}
|
|
420
|
-
|
|
422
|
+
// α7 L3 (2026-05-27): leak-parity underscore-prefix filter. Every
|
|
423
|
+
// tool's parameter schema is scrubbed of `_`-prefixed fields before
|
|
424
|
+
// the model ever sees it. Native tool schemas above currently declare
|
|
425
|
+
// no `_*` fields, but MCP tools surfaced through buildMcpToolDefs
|
|
426
|
+
// come from third-party servers whose authors may follow the same
|
|
427
|
+
// convention (an MCP tool can declare `_sessionId` knowing the CLI
|
|
428
|
+
// dispatcher will inject it before forwarding). The dispatcher
|
|
429
|
+
// (buildExecutor below) does NOT strip these from the args record at
|
|
430
|
+
// call time — `_internal*` keys still flow through to tool handlers
|
|
431
|
+
// when an upstream layer populates them.
|
|
432
|
+
return toolDefs.map((tool) => ({
|
|
433
|
+
name: tool.name,
|
|
434
|
+
description: tool.description,
|
|
435
|
+
parameters: stripInternalFields(tool.parameters),
|
|
436
|
+
}));
|
|
437
|
+
}
|
|
438
|
+
/**
|
|
439
|
+
* α7 L11: tolerant args-parse for the denial fingerprint. Unlike
|
|
440
|
+
* `parseArgs` (which throws on malformed JSON so the model sees a
|
|
441
|
+
* parse error), this swallows failures and returns `{}` — the denial
|
|
442
|
+
* tracker needs SOME key even when the raw payload is unparseable,
|
|
443
|
+
* because malformed-args spam is itself a pattern operators want to
|
|
444
|
+
* see in `/permissions denials`.
|
|
445
|
+
*/
|
|
446
|
+
function safeParseForTracking(raw) {
|
|
447
|
+
if (!raw || raw.trim() === '')
|
|
448
|
+
return {};
|
|
449
|
+
try {
|
|
450
|
+
return JSON.parse(raw);
|
|
451
|
+
}
|
|
452
|
+
catch {
|
|
453
|
+
// Use the raw string as the fingerprint payload so repeated
|
|
454
|
+
// identical malformed dispatches still cluster.
|
|
455
|
+
return { _rawArgs: raw.slice(0, 512) };
|
|
456
|
+
}
|
|
421
457
|
}
|
|
422
458
|
function parseArgs(raw) {
|
|
423
459
|
if (!raw || raw.trim() === '')
|
|
@@ -433,24 +469,70 @@ function parseArgs(raw) {
|
|
|
433
469
|
throw new Error(`invalid JSON in tool arguments: ${error.message}`);
|
|
434
470
|
}
|
|
435
471
|
}
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
472
|
+
/**
|
|
473
|
+
* Strict canonical-only argument coercion (leak P0 L2, 2026-05-27).
|
|
474
|
+
*
|
|
475
|
+
* Reverts the beta.17 alias acceptance (`file` / `filename` / `filepath`
|
|
476
|
+
* / `file_path` → `path`). The alias shim was the wrong direction: it
|
|
477
|
+
* paved over a model-side prompt-drift bug at the runtime layer, weakened
|
|
478
|
+
* the strict JSON-Schema contract one layer up (`additionalProperties:
|
|
479
|
+
* false`), and drifted away from the openclaude reference (research memo
|
|
480
|
+
* §1.1 — `z.strictObject` rejects aliased fields).
|
|
481
|
+
*
|
|
482
|
+
* The compensating change ships in the persona prompts: Mira's system
|
|
483
|
+
* prompt and Hiroshi's persona body now declare canonical parameter
|
|
484
|
+
* names with few-shot wrong/right contrasts so the model learns the
|
|
485
|
+
* grammar upstream of the bridge.
|
|
486
|
+
*/
|
|
487
|
+
function requireString(obj, key) {
|
|
488
|
+
const v = obj[key];
|
|
489
|
+
if (typeof v === 'string')
|
|
490
|
+
return v;
|
|
446
491
|
throw new Error(`tool argument "${key}" must be a string`);
|
|
447
492
|
}
|
|
448
|
-
const PATH_ALIASES = ['file', 'filename', 'filepath', 'file_path'];
|
|
449
493
|
export function buildExecutor(input) {
|
|
450
|
-
const { kind, ctx, hooks, sessionId, askUserBridge, interactive, allowFetch, allowSearch, agentDispatch, mcpRegistry } = input;
|
|
494
|
+
const { kind, ctx, hooks, sessionId, askUserBridge, interactive, allowFetch, allowSearch, agentDispatch, mcpRegistry, permissionMode, permissionAlwaysCache, permissionAsk, } = input;
|
|
451
495
|
const mcpPrompt = input.mcpPrompt ?? defaultNonInteractiveMcpPrompt;
|
|
452
496
|
const workspaceRoot = input.workspaceRoot ?? ctx.root;
|
|
453
497
|
const planMode = kind === 'plan';
|
|
498
|
+
const denialTracking = input.denialTracking;
|
|
499
|
+
// α7 L11: helper that records a denial (when tracking is wired) and
|
|
500
|
+
// ALWAYS returns an Error whose message includes a compact
|
|
501
|
+
// `<denial-context>` reminder when the same (tool, args) pair has
|
|
502
|
+
// already been refused at least once before in this session.
|
|
503
|
+
//
|
|
504
|
+
// The reminder is appended to the THROWN message — the engine loop
|
|
505
|
+
// appends thrown messages to the transcript as tool-result strings,
|
|
506
|
+
// so the model sees the aggregate the next time it considers a
|
|
507
|
+
// dispatch. Without this every retry would only see the latest
|
|
508
|
+
// single-turn reason and could loop indefinitely.
|
|
509
|
+
//
|
|
510
|
+
// Best-effort: a hash/clone failure inside the tracker MUST NOT
|
|
511
|
+
// mask the original refusal. The catch path falls back to a bare
|
|
512
|
+
// Error with the reason text.
|
|
513
|
+
const recordDenial = (toolName, args, reason) => {
|
|
514
|
+
if (!denialTracking)
|
|
515
|
+
return new Error(reason);
|
|
516
|
+
try {
|
|
517
|
+
const record = denialTracking.recordDenial(toolName, args, reason);
|
|
518
|
+
// Only inject the reminder once the threshold is hit — the very
|
|
519
|
+
// first denial is the model's first chance to learn, no need to
|
|
520
|
+
// shout. From the 2nd repeat onwards the model has demonstrated
|
|
521
|
+
// it is not learning from the single-turn sentinel, so we splice
|
|
522
|
+
// the aggregate context.
|
|
523
|
+
if (record.count >= DENIAL_REMINDER_THRESHOLD) {
|
|
524
|
+
const reminder = buildDenialContext(denialTracking);
|
|
525
|
+
if (reminder.length > 0) {
|
|
526
|
+
return new Error(`${reason}\n\n${reminder}`);
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
catch {
|
|
531
|
+
// Tracking is best-effort. Fall through to the bare Error so
|
|
532
|
+
// the refusal still propagates.
|
|
533
|
+
}
|
|
534
|
+
return new Error(reason);
|
|
535
|
+
};
|
|
454
536
|
return async ({ name, arguments: argsRaw }) => {
|
|
455
537
|
// β4 M1/M3: MCP tool names live outside WIRED_TOOLS. They are
|
|
456
538
|
// validated lazily by the dispatcher (the registry knows which
|
|
@@ -458,15 +540,64 @@ export function buildExecutor(input) {
|
|
|
458
540
|
// so a bad `mcp__bogus__foo` does not collide with the native
|
|
459
541
|
// unknown-tool branch.
|
|
460
542
|
const isMcpName = name.startsWith(MCP_TOOL_PREFIX);
|
|
543
|
+
// α7 L11: parse-or-empty args once up-front so every deny path
|
|
544
|
+
// below can fingerprint the call against the denial tracker. We
|
|
545
|
+
// tolerate parse failure — `{}` keys still produce a stable hash
|
|
546
|
+
// (the model may have sent malformed JSON, but the refusal is
|
|
547
|
+
// semantic, not parse-driven).
|
|
548
|
+
const argsForTracking = safeParseForTracking(argsRaw);
|
|
461
549
|
if (!isMcpName && !WIRED_TOOLS.has(name)) {
|
|
462
|
-
throw
|
|
550
|
+
throw recordDenial(name, argsForTracking, `unknown tool: ${name}`);
|
|
463
551
|
}
|
|
464
|
-
|
|
552
|
+
// Leak L6 — canonical 4-mode permission gate. Routes the dispatch
|
|
553
|
+
// decision BEFORE the legacy plan-mode-only enforcement so the new
|
|
554
|
+
// surface is the source of truth when the caller opted in. Absent
|
|
555
|
+
// `permissionMode` falls through to the legacy plan-mode branch
|
|
556
|
+
// (existing semantics preserved for callsites that have not
|
|
557
|
+
// migrated yet).
|
|
558
|
+
let hooksBypassed = false;
|
|
559
|
+
if (permissionMode) {
|
|
560
|
+
const decision = permissionGate(name, argsRaw, {
|
|
561
|
+
permissionMode,
|
|
562
|
+
...(permissionAlwaysCache ? { alwaysCache: permissionAlwaysCache } : {}),
|
|
563
|
+
});
|
|
564
|
+
if (decision.decision === 'deny') {
|
|
565
|
+
throw new PermissionDenied(name, getToolClass(name), permissionMode, decision.reason);
|
|
566
|
+
}
|
|
567
|
+
if (decision.decision === 'ask') {
|
|
568
|
+
if (!permissionAsk) {
|
|
569
|
+
// Non-interactive caller (CI / pipes / agent-as-tool) cannot
|
|
570
|
+
// surface a prompt. Collapse to deny so the loop receives a
|
|
571
|
+
// deterministic refusal instead of hanging.
|
|
572
|
+
throw new PermissionDenied(name, decision.toolClass, permissionMode, `Ask mode: no operator prompt available for ${name} (non-interactive caller)`);
|
|
573
|
+
}
|
|
574
|
+
const answer = await permissionAsk({
|
|
575
|
+
toolName: name,
|
|
576
|
+
toolClass: decision.toolClass,
|
|
577
|
+
question: decision.question,
|
|
578
|
+
options: decision.options,
|
|
579
|
+
});
|
|
580
|
+
const verdict = permissionAlwaysCache
|
|
581
|
+
? applyAskAnswer(permissionAlwaysCache, name, answer)
|
|
582
|
+
: applyAskAnswer({ alwaysAllowed: new Set(), alwaysDenied: new Set() }, name, answer);
|
|
583
|
+
if (verdict.decision === 'deny') {
|
|
584
|
+
throw new PermissionDenied(name, decision.toolClass, permissionMode, verdict.reason);
|
|
585
|
+
}
|
|
586
|
+
// verdict.decision === 'allow' falls through to dispatch.
|
|
587
|
+
}
|
|
588
|
+
else {
|
|
589
|
+
// allow — honour the bypass flag for the hook layer below.
|
|
590
|
+
hooksBypassed = decision.hooksBypassed === true;
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
else if (planMode) {
|
|
594
|
+
// Legacy plan-mode enforcement (kind === 'plan') stays in place
|
|
595
|
+
// for callers that have not opted into the canonical gate.
|
|
465
596
|
// MCP tools are uniformly refused in plan mode (see schema-side
|
|
466
597
|
// rationale in buildToolsSchema). Native tools split via
|
|
467
598
|
// READ_ONLY_TOOLS as before.
|
|
468
599
|
if (isMcpName || !READ_ONLY_TOOLS.has(name)) {
|
|
469
|
-
throw
|
|
600
|
+
throw recordDenial(name, argsForTracking, `PLAN_MODE_REFUSED: ${name} is not allowed in plan mode`);
|
|
470
601
|
}
|
|
471
602
|
}
|
|
472
603
|
// α6.9: refuse cancelled-token tool dispatch BEFORE PreToolUse
|
|
@@ -475,13 +606,18 @@ export function buildExecutor(input) {
|
|
|
475
606
|
// by `runEngineLoop` as a terminal-cancel signal so the loop
|
|
476
607
|
// returns control to the caller rather than retrying the model.
|
|
477
608
|
if (ctx.cancellation && ctx.cancellation.isAborted) {
|
|
478
|
-
throw
|
|
609
|
+
throw recordDenial(name, argsForTracking, `OPERATOR_ABORTED: ${name} refused — operator cancelled the dispatch.`);
|
|
479
610
|
}
|
|
480
611
|
// Fire PreToolUse hooks. The match grammar takes the tool name and
|
|
481
612
|
// (when extractable) the target path. Each new tool dispatch starts a
|
|
482
613
|
// fresh dedup batch so a hook fires once per dispatch, not once per
|
|
483
614
|
// session.
|
|
484
|
-
|
|
615
|
+
//
|
|
616
|
+
// Leak L6 — bypass mode skips the entire hook layer (PreToolUse +
|
|
617
|
+
// PostToolUse + PostToolUseFailure). The gate's allow decision
|
|
618
|
+
// carries the `hooksBypassed` flag; we honour it here so the
|
|
619
|
+
// executor stays single-pass.
|
|
620
|
+
if (hooks && sessionId && !hooksBypassed) {
|
|
485
621
|
hooks.resetBatch();
|
|
486
622
|
const path = extractToolPath(name, argsRaw);
|
|
487
623
|
const preCtx = {
|
|
@@ -501,7 +637,11 @@ export function buildExecutor(input) {
|
|
|
501
637
|
const hook = matchingPreHooks[i];
|
|
502
638
|
const result = preResults[i];
|
|
503
639
|
if (hook && result && hook.onFailure === 'block' && !result.ok) {
|
|
504
|
-
|
|
640
|
+
// α7 L11: record the PreToolUse hook denial so the model
|
|
641
|
+
// sees the pattern reminder on subsequent turns. Without
|
|
642
|
+
// this the model would re-issue the same refused call and
|
|
643
|
+
// burn a turn each time before noticing the loop.
|
|
644
|
+
throw recordDenial(name, argsForTracking, `HOOK_BLOCKED: PreToolUse hook (${hook.run.slice(0, 80)}) refused ${name} (exit=${result.exitCode})`);
|
|
505
645
|
}
|
|
506
646
|
}
|
|
507
647
|
}
|
|
@@ -558,7 +698,7 @@ export function buildExecutor(input) {
|
|
|
558
698
|
// model spawn a write-capable child and break the read-only
|
|
559
699
|
// contract.
|
|
560
700
|
if (planMode) {
|
|
561
|
-
throw
|
|
701
|
+
throw recordDenial(name, argsForTracking, 'PLAN_MODE_REFUSED: agent is not allowed in plan mode');
|
|
562
702
|
}
|
|
563
703
|
return dispatchAgent(args, agentDispatch);
|
|
564
704
|
}
|
|
@@ -566,7 +706,7 @@ export function buildExecutor(input) {
|
|
|
566
706
|
};
|
|
567
707
|
try {
|
|
568
708
|
const result = await dispatch();
|
|
569
|
-
if (hooks && sessionId) {
|
|
709
|
+
if (hooks && sessionId && !hooksBypassed) {
|
|
570
710
|
const path = extractToolPath(name, argsRaw);
|
|
571
711
|
await hooks.fire({
|
|
572
712
|
sessionId,
|
|
@@ -579,6 +719,27 @@ export function buildExecutor(input) {
|
|
|
579
719
|
return result;
|
|
580
720
|
}
|
|
581
721
|
catch (error) {
|
|
722
|
+
// Leak L6 — surface the PermissionDenied sentinel as a model-
|
|
723
|
+
// readable message instead of leaking the raw Error type. The
|
|
724
|
+
// string format is stable so the engine adapter / spec layer
|
|
725
|
+
// can pattern-match against it.
|
|
726
|
+
if (error instanceof PermissionDenied) {
|
|
727
|
+
// PostToolUseFailure fires for visibility unless bypass is on.
|
|
728
|
+
if (hooks && sessionId && !hooksBypassed) {
|
|
729
|
+
await hooks.fire({
|
|
730
|
+
sessionId,
|
|
731
|
+
event: 'PostToolUseFailure',
|
|
732
|
+
tool: name,
|
|
733
|
+
payload: {
|
|
734
|
+
tool: name,
|
|
735
|
+
arguments: argsRaw,
|
|
736
|
+
ok: false,
|
|
737
|
+
error: error.toModelMessage(),
|
|
738
|
+
},
|
|
739
|
+
});
|
|
740
|
+
}
|
|
741
|
+
throw new Error(error.toModelMessage());
|
|
742
|
+
}
|
|
582
743
|
// α6.9: re-shape OperatorAbortedError throws from the
|
|
583
744
|
// file-tools layer into the same `OPERATOR_ABORTED:` sentinel
|
|
584
745
|
// the upstream cancellation gate uses so `runEngineLoop` sees
|
|
@@ -586,7 +747,7 @@ export function buildExecutor(input) {
|
|
|
586
747
|
// the abort landed pre-dispatch or mid-tool (e.g. inside the
|
|
587
748
|
// grep file-loop).
|
|
588
749
|
if (error instanceof OperatorAbortedError) {
|
|
589
|
-
if (hooks && sessionId) {
|
|
750
|
+
if (hooks && sessionId && !hooksBypassed) {
|
|
590
751
|
const path = extractToolPath(name, argsRaw);
|
|
591
752
|
await hooks.fire({
|
|
592
753
|
sessionId,
|
|
@@ -601,9 +762,35 @@ export function buildExecutor(input) {
|
|
|
601
762
|
},
|
|
602
763
|
});
|
|
603
764
|
}
|
|
604
|
-
throw
|
|
765
|
+
throw recordDenial(name, argsForTracking, `OPERATOR_ABORTED: ${name} aborted mid-execution.`);
|
|
766
|
+
}
|
|
767
|
+
// Leak L1 (2026-05-27): re-shape StaleReadError into a
|
|
768
|
+
// deterministic STALE_READ:<reason> sentinel so the model's
|
|
769
|
+
// retry policy can pattern-match on a stable prefix instead of
|
|
770
|
+
// free-form prose. The model is expected to re-read the file and
|
|
771
|
+
// retry the edit — the message points it at exactly that recovery
|
|
772
|
+
// path. PostToolUseFailure hooks observe the typed error so an
|
|
773
|
+
// operator can build a "warn me when stale edits keep happening"
|
|
774
|
+
// hook (likely a concurrency / multi-agent indicator).
|
|
775
|
+
if (error instanceof StaleReadError) {
|
|
776
|
+
if (hooks && sessionId && !hooksBypassed) {
|
|
777
|
+
const path = extractToolPath(name, argsRaw);
|
|
778
|
+
await hooks.fire({
|
|
779
|
+
sessionId,
|
|
780
|
+
event: 'PostToolUseFailure',
|
|
781
|
+
tool: name,
|
|
782
|
+
path,
|
|
783
|
+
payload: {
|
|
784
|
+
tool: name,
|
|
785
|
+
arguments: argsRaw,
|
|
786
|
+
ok: false,
|
|
787
|
+
error: `STALE_READ: ${error.reason} on ${error.path}`,
|
|
788
|
+
},
|
|
789
|
+
});
|
|
790
|
+
}
|
|
791
|
+
throw recordDenial(name, argsForTracking, `STALE_READ: ${name} on ${error.path} refused (${error.reason}). Re-read the file with the \`read\` tool, then retry the ${name}.`);
|
|
605
792
|
}
|
|
606
|
-
if (hooks && sessionId) {
|
|
793
|
+
if (hooks && sessionId && !hooksBypassed) {
|
|
607
794
|
const path = extractToolPath(name, argsRaw);
|
|
608
795
|
await hooks.fire({
|
|
609
796
|
sessionId,
|
|
@@ -643,7 +830,7 @@ function extractToolPath(name, argsRaw) {
|
|
|
643
830
|
function dispatchTool(name, args, ctx) {
|
|
644
831
|
switch (name) {
|
|
645
832
|
case 'read': {
|
|
646
|
-
const { path } = { path: requireString(args, 'path'
|
|
833
|
+
const { path } = { path: requireString(args, 'path') };
|
|
647
834
|
const content = readTool(ctx, path);
|
|
648
835
|
// Cap the content surfaced back to the model so a 10MB file
|
|
649
836
|
// does not blow the context window. The model sees the head
|
|
@@ -656,7 +843,7 @@ function dispatchTool(name, args, ctx) {
|
|
|
656
843
|
}
|
|
657
844
|
case 'write': {
|
|
658
845
|
const wargs = {
|
|
659
|
-
path: requireString(args, 'path'
|
|
846
|
+
path: requireString(args, 'path'),
|
|
660
847
|
content: requireString(args, 'content'),
|
|
661
848
|
};
|
|
662
849
|
writeTool(ctx, wargs.path, wargs.content);
|
|
@@ -664,7 +851,7 @@ function dispatchTool(name, args, ctx) {
|
|
|
664
851
|
}
|
|
665
852
|
case 'edit': {
|
|
666
853
|
const eargs = {
|
|
667
|
-
path: requireString(args, 'path'
|
|
854
|
+
path: requireString(args, 'path'),
|
|
668
855
|
oldString: requireString(args, 'oldString'),
|
|
669
856
|
newString: requireString(args, 'newString'),
|
|
670
857
|
};
|
|
@@ -762,11 +949,26 @@ function dispatchTaskTool(name, args, opts) {
|
|
|
762
949
|
}
|
|
763
950
|
}
|
|
764
951
|
async function dispatchAskUser(args, opts) {
|
|
765
|
-
const question = requireString(args, 'question');
|
|
766
952
|
const rawOptions = args['options'];
|
|
767
953
|
if (!Array.isArray(rawOptions)) {
|
|
768
954
|
throw new Error('ask_user_question: options must be an array');
|
|
769
955
|
}
|
|
956
|
+
// Leak L5 (2026-05-27): detect structured vs legacy form. Structured
|
|
957
|
+
// entries are objects with {label, description}; legacy entries are
|
|
958
|
+
// plain strings. The structured path validates via Zod and emits the
|
|
959
|
+
// [ask_user_question:answered|cancelled|timeout] envelope. The legacy
|
|
960
|
+
// path stays for back-compat with the existing β1 T2 tests + the
|
|
961
|
+
// <pugi-ask> prompt envelope (which still feeds string options).
|
|
962
|
+
const looksStructured = rawOptions.length > 0
|
|
963
|
+
&& typeof rawOptions[0] === 'object'
|
|
964
|
+
&& rawOptions[0] !== null
|
|
965
|
+
&& !Array.isArray(rawOptions[0]);
|
|
966
|
+
if (looksStructured) {
|
|
967
|
+
const result = await dispatchAskUserQuestion({ interactive: opts.interactive, ...(opts.bridge ? { bridge: opts.bridge } : {}) }, args);
|
|
968
|
+
return result.envelope;
|
|
969
|
+
}
|
|
970
|
+
// Legacy string-array form.
|
|
971
|
+
const question = requireString(args, 'question');
|
|
770
972
|
const options = rawOptions.map((o, i) => {
|
|
771
973
|
if (typeof o !== 'string') {
|
|
772
974
|
throw new Error(`ask_user_question: options[${i}] must be a string`);
|