gentle-pi 4.1.0 → 4.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.
- package/README.md +39 -14
- package/bin/engram-guard +1 -1
- package/bin/gentle-shell.mjs +16 -11
- package/docs/auto-permission-answers.md +25 -0
- package/docs/auto-permission-evidence.md +39 -0
- package/docs/bundled-install.md +8 -6
- package/docs/gentle-shell.md +17 -10
- package/docs/install-wizard.md +9 -9
- package/docs/permission-modes.md +50 -5
- package/docs/readme-reference.md +48 -12
- package/docs/session-profile-format.md +39 -0
- package/docs/yolo-mode.md +2 -1
- package/extensions/ask-user-choice.ts +4 -0
- package/extensions/ask-user-question.ts +6 -1
- package/extensions/gentle-agents.ts +54 -23
- package/extensions/gentle-ai.ts +105 -25
- package/extensions/gentle-shell.ts +26 -31
- package/extensions/gentle-todo.ts +26 -16
- package/lib/auto-permission-answers.ts +133 -0
- package/lib/auto-permission-classifier.ts +215 -36
- package/lib/auto-permission-evidence.ts +88 -0
- package/lib/auto-permission-memory.ts +69 -0
- package/lib/auto-permission-runtime.ts +251 -35
- package/lib/auto-permission-tool-context.ts +61 -0
- package/lib/destructive-command-guard.ts +104 -28
- package/lib/gentle-shell-bundled.ts +23 -5
- package/lib/gentle-shell-launcher.ts +1 -1
- package/lib/model-routing-authority.ts +6 -1
- package/lib/selection-engine.ts +4 -1
- package/lib/session-profile-freeze.ts +10 -15
- package/lib/session-profile-persistence.ts +187 -0
- package/lib/shell-changes.ts +2 -0
- package/lib/shell-prompt.ts +6 -4
- package/lib/shell-todo.ts +20 -8
- package/lib/vim-editor-adapter.ts +22 -4
- package/package.json +5 -5
- package/runtime/gentle-shell-bundled.mjs +23 -5
- package/runtime/gentle-shell-launcher.mjs +1 -1
- package/scripts/install-wizard-preview.mjs +2 -2
- package/scripts/installer-downloads.mjs +7 -7
- package/scripts/installer-preflight.mjs +1 -1
- package/scripts/pi-update.mjs +223 -0
- package/tests/append-system-prompt-route.test.ts +18 -1
- package/tests/auto-mode-human-answers.test.ts +68 -0
- package/tests/auto-mode-invariants.test.ts +23 -5
- package/tests/auto-mode-sdk.test.ts +29 -2
- package/tests/auto-permission-answers.test.ts +170 -0
- package/tests/auto-permission-classifier.test.ts +353 -2
- package/tests/auto-permission-compound.test.ts +24 -0
- package/tests/auto-permission-evidence.test.ts +88 -0
- package/tests/auto-permission-memory.test.ts +46 -0
- package/tests/auto-permission-public-regressions.test.ts +111 -0
- package/tests/auto-permission-runtime.test.ts +270 -12
- package/tests/autonomous-guard.test.ts +86 -16
- package/tests/bundled-install.test.ts +3 -3
- package/tests/engram-safety.test.ts +34 -6
- package/tests/gentle-agents.test.ts +162 -6
- package/tests/gentle-shell-bin.test.ts +17 -17
- package/tests/gentle-shell-bundled.test.ts +67 -33
- package/tests/gentle-shell-launcher.test.ts +18 -13
- package/tests/gentle-shell.test.ts +71 -5
- package/tests/gentle-todo.test.ts +79 -27
- package/tests/install-wizard.test.ts +2 -2
- package/tests/installer-posix-bootstrap.test.ts +8 -8
- package/tests/installer-preflight.test.ts +3 -3
- package/tests/installer-probes.test.ts +8 -8
- package/tests/installer-runner.test.ts +14 -13
- package/tests/installer-server.test.ts +5 -5
- package/tests/main-channel-upgrade.test.ts +7 -1
- package/tests/odd-runtime-delegation-gate.test.ts +4 -1
- package/tests/package-manifest.test.ts +6 -6
- package/tests/pi-update-launcher.test.ts +87 -0
- package/tests/pi-update.test.ts +186 -0
- package/tests/rdd-status-line.test.ts +52 -0
- package/tests/selection-engine.test.ts +35 -5
- package/tests/session-profile-freeze.test.ts +14 -1
- package/tests/session-profile-persistence.test.ts +334 -0
- package/tests/shell-changes.test.ts +18 -0
- package/tests/shell-todo.test.ts +15 -12
- package/tests/vim-editor-adapter.test.ts +41 -15
- package/tests/yolo-mode.test.ts +6 -1
|
@@ -1,7 +1,12 @@
|
|
|
1
|
-
import { existsSync, realpathSync } from "node:fs";
|
|
1
|
+
import { existsSync, lstatSync, realpathSync } from "node:fs";
|
|
2
2
|
import { dirname, isAbsolute, relative, resolve, sep } from "node:path";
|
|
3
|
-
import type { ExtensionAPI, ExtensionContext, InputEvent, ToolCallEvent, ToolCallEventResult } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import type { ExtensionAPI, ExtensionContext, InputEvent, ToolCallEvent, ToolCallEventResult, ToolResultEvent } from "@earendil-works/pi-coding-agent";
|
|
4
4
|
import { gentlePiConfigHome } from "./agent-home.ts";
|
|
5
|
+
import { AutoPermissionMemory } from "./auto-permission-memory.ts";
|
|
6
|
+
import { projectToolContext } from "./auto-permission-tool-context.ts";
|
|
7
|
+
import { subscribeHumanAnswers, type HumanAnswerReceipt } from "./auto-permission-answers.ts";
|
|
8
|
+
import { AUTO_PERMISSION_EVIDENCE_BYTES, AUTO_PERMISSION_EVIDENCE_LIMITS, resolveAutoPermissionEvidencePath } from "./auto-permission-evidence.ts";
|
|
9
|
+
import { AUTO_PERMISSION_DIAGNOSTIC_PATHS, type AutoPermissionDiagnostic } from "./auto-permission-classifier.ts";
|
|
5
10
|
import { appendSystemPromptOnce, type AppendableSystemPromptOptions } from "./append-system-prompt.ts";
|
|
6
11
|
|
|
7
12
|
export const AUTO_DIRECTIVE = `<gentle-auto-permissions>
|
|
@@ -14,11 +19,23 @@ export function updateAutoPrompt(options: AppendableSystemPromptOptions | undefi
|
|
|
14
19
|
}
|
|
15
20
|
|
|
16
21
|
type ModeController = {mode(ctx: ExtensionContext): Promise<string>; epoch(): number};
|
|
17
|
-
type Verdict = {status: string; decision?: string; reason?: string; code?: string; usage?: unknown; usageKnown?: boolean; actualModel?: unknown; requestedModel?: unknown};
|
|
22
|
+
type Verdict = {status: string; decision?: string; reason?: string; code?: string; usage?: unknown; usageKnown?: boolean; actualModel?: unknown; requestedModel?: unknown; diagnostic?: unknown};
|
|
23
|
+
/** Project a closed metadata schema; injected classifiers cannot add payloads to diagnostics. */
|
|
24
|
+
function safeDiagnostic(value: unknown): AutoPermissionDiagnostic | undefined {
|
|
25
|
+
try {
|
|
26
|
+
if(!value || typeof value!=="object")return undefined;
|
|
27
|
+
const {implementation,elapsedMs,deadlineMs,timerFired,returnPath,evidenceLimit}=value as AutoPermissionDiagnostic;
|
|
28
|
+
if(implementation!=="auto-classifier-diagnostics-v1" || !Number.isSafeInteger(elapsedMs) || elapsedMs<0 ||
|
|
29
|
+
!Number.isFinite(deadlineMs) || deadlineMs<1 || deadlineMs>20000 || typeof timerFired!=="boolean" ||
|
|
30
|
+
!AUTO_PERMISSION_DIAGNOSTIC_PATHS.includes(returnPath) ||
|
|
31
|
+
(evidenceLimit!==undefined && !AUTO_PERMISSION_EVIDENCE_LIMITS.includes(evidenceLimit)))return undefined;
|
|
32
|
+
return {implementation,elapsedMs,deadlineMs,timerFired,returnPath,...(evidenceLimit ? {evidenceLimit} : {})};
|
|
33
|
+
} catch { return undefined; }
|
|
34
|
+
}
|
|
18
35
|
export type AutoRuntimeClassifier = {run(request: any): Promise<Verdict>; cancel(): void};
|
|
19
36
|
type Classifier = AutoRuntimeClassifier;
|
|
20
37
|
type BranchItem = {id: string; type: string; message?: {role: string; content: unknown}};
|
|
21
|
-
type AuthoredInput = {text: string; before: Set<string>; entryId?: string; prompt?: string};
|
|
38
|
+
type AuthoredInput = {text: string; order:number; before: Set<string>; entryId?: string; prompt?: string};
|
|
22
39
|
// These are independent authority/dialog/delegation surfaces, not Auto approvals.
|
|
23
40
|
const OWNED_CONTROL_TOOLS = new Set([
|
|
24
41
|
"ask_user_choice", "ask_user_question", "gentle_review", "gentle_review_capture", "gentle_review_capture_group",
|
|
@@ -30,7 +47,46 @@ const DENIAL_LIMIT = 3, TOTAL_DENIAL_LIMIT = 20;
|
|
|
30
47
|
const MAX_AUTHORED_BYTES = 24_000;
|
|
31
48
|
// Answered prompts are context, not authority: keep each tail short and the total (prompt plus repeated reply) well inside the classifier budget.
|
|
32
49
|
const PROMPT_TAIL_BYTES = 1_200, PROMPT_TOTAL_BYTES = 4_000;
|
|
50
|
+
// Codex-style window for long sessions: never block every action on size. Oversized messages keep
|
|
51
|
+
// their start and end; the first (task) and latest messages are always kept, recent ones fill the rest.
|
|
52
|
+
// Sizes here are JSON-serialized bytes, as the classifier measures them; the reserve leaves room
|
|
53
|
+
// for the omission policy that is only sent once history is omitted.
|
|
54
|
+
const MESSAGE_WINDOW_BYTES = 6_000, OMISSION_RESERVE_BYTES = 1_024;
|
|
55
|
+
const serialized = (text: string): number => Buffer.byteLength(JSON.stringify(text))-2;
|
|
56
|
+
function codePointBytes(text: string, max: number, fromEnd: boolean): string {
|
|
57
|
+
let bytes=0;const points=Array.from(text);const kept:string[]=[];
|
|
58
|
+
for(const point of fromEnd ? points.reverse() : points) {
|
|
59
|
+
const size=serialized(point);
|
|
60
|
+
if(bytes+size>max)break;
|
|
61
|
+
bytes+=size;kept.push(point);
|
|
62
|
+
}
|
|
63
|
+
return (fromEnd ? kept.reverse() : kept).join("");
|
|
64
|
+
}
|
|
65
|
+
function middleTruncate(text: string): string {
|
|
66
|
+
if(serialized(text)<=MESSAGE_WINDOW_BYTES)return text;
|
|
67
|
+
const size=Buffer.byteLength(text);
|
|
68
|
+
const keep=Math.floor((MESSAGE_WINDOW_BYTES-64)/2);
|
|
69
|
+
const head=codePointBytes(text,keep,false), tailText=codePointBytes(text,keep,true);
|
|
70
|
+
return `${head} [${size-Buffer.byteLength(head)-Buffer.byteLength(tailText)} bytes omitted by host] ${tailText}`;
|
|
71
|
+
}
|
|
33
72
|
const PROVIDER_RETRY_DELAYS_MS = [500, 1_500];
|
|
73
|
+
const HISTORY_BYTES = 4_000, HISTORY_ENTRIES = 12;
|
|
74
|
+
const UNAVAILABLE_REASONS: Record<string,string> = {
|
|
75
|
+
busy: "The Auto classifier is still settling a previous request. Wait for it to finish before retrying; this is not a safety denial.",
|
|
76
|
+
timeout: "The Auto classifier timed out. A timeout is not evidence that the action is unsafe. Retry after the pending request settles, or use manual permissions.",
|
|
77
|
+
cancelled: "The Auto classifier was cancelled. Retry only in the current live context.",
|
|
78
|
+
revoked: "The Auto decision expired. Retry only in the current live context.",
|
|
79
|
+
"missing-trusted-request": "Auto has no live human request for this branch. Ask the user for a specific instruction.",
|
|
80
|
+
"input-too-large": "The Auto context exceeds its input budget. Narrow the current action, or use manual permissions; repeated retries cannot fix oversized input.",
|
|
81
|
+
"redacted-context": "The Auto context contains potentially sensitive evidence. Use non-sensitive evidence or manual review; do not expose it to obtain approval.",
|
|
82
|
+
"invalid-input": "The Auto action context is invalid. Correct the tool input before retrying.",
|
|
83
|
+
"invalid-output": "The Auto classifier returned an invalid verdict. Retry later or use manual permissions.",
|
|
84
|
+
"truncated-output": "The Auto classifier verdict was truncated. Retry later or use manual permissions.",
|
|
85
|
+
"output-too-large": "The Auto classifier verdict exceeded its output budget. Retry later or use manual permissions.",
|
|
86
|
+
"tool-call": "The Auto classifier requested an unsupported tool. No usable verdict was produced; use manual permissions.",
|
|
87
|
+
"evidence-unavailable": "The Auto classifier could not obtain the required evidence. Provide a relevant local file or use manual review.",
|
|
88
|
+
"evidence-limit": "The Auto classifier exhausted its evidence budget. Narrow the action or use manual review.",
|
|
89
|
+
};
|
|
34
90
|
function block(reason: string): ToolCallEventResult { return {block:true,reason}; }
|
|
35
91
|
function within(path: string, root: string): boolean {
|
|
36
92
|
const rel = relative(root, path);
|
|
@@ -43,6 +99,73 @@ function canonical(path: string): string {
|
|
|
43
99
|
while (!existsSync(ancestor)) { const parent = dirname(ancestor); if (parent === ancestor) break; ancestor = parent; }
|
|
44
100
|
return resolve(realpathSync(ancestor), relative(ancestor, absolute));
|
|
45
101
|
}
|
|
102
|
+
/** True only for an existing symlink-free in-project regular file larger than the reader accepts.
|
|
103
|
+
* lstat walks each component from cwd and stops at any symlink, so no outside path is ever examined.
|
|
104
|
+
* Anything else keeps its existing candidate path; the reader still re-checks everything. */
|
|
105
|
+
function oversizedLocalFile(cwd: string, path: string): boolean {
|
|
106
|
+
try {
|
|
107
|
+
const parts=path.split("/");
|
|
108
|
+
let current=realpathSync(resolve(cwd));
|
|
109
|
+
for(let i=0;i<parts.length;i++) {
|
|
110
|
+
current=resolve(current,parts[i]);
|
|
111
|
+
const stat=lstatSync(current,{throwIfNoEntry:false});
|
|
112
|
+
if(!stat || stat.isSymbolicLink())return false;
|
|
113
|
+
if(i===parts.length-1)return stat.isFile() && stat.size>AUTO_PERMISSION_EVIDENCE_BYTES;
|
|
114
|
+
if(!stat.isDirectory())return false;
|
|
115
|
+
}
|
|
116
|
+
return false;
|
|
117
|
+
} catch { return false; }
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Select explicit file operands, never evaluate a shell or crawl prior read paths.
|
|
121
|
+
* Unsupported shell syntax stays tool-free rather than inspecting a file in the wrong cwd. */
|
|
122
|
+
function evidencePaths(toolName: string, input: unknown, cwd: string): string[] {
|
|
123
|
+
if(!["bash","bash_background","monitor"].includes(toolName))return [];
|
|
124
|
+
const command=(input as {command?:unknown})?.command;
|
|
125
|
+
if(typeof command!=="string")return [];
|
|
126
|
+
if(command.includes("&&")) {
|
|
127
|
+
// Evidence only: validate the whole narrow expression before selecting operands.
|
|
128
|
+
// No shell evaluation, cwd changes, quoting, redirection or inherited approval.
|
|
129
|
+
const segments=command.split("&&").map(part=>part.trim());
|
|
130
|
+
if(segments.length>4 || segments.some(part=>!part || /[$`;&|<>(){}*?\\\r\n\"']/.test(part)))return [];
|
|
131
|
+
const valid=segments.every(part=>part==="git diff --check" ||
|
|
132
|
+
/^node(?:\s+(?:--experimental-strip-types|--test))*\s+[\w./-]+\.(?:[cm]?[jt]s)(?:\s+[\w./-]+\.(?:[cm]?[jt]s))*$/.test(part));
|
|
133
|
+
if(!valid)return [];
|
|
134
|
+
return [...new Set(segments.flatMap(part=>evidencePaths(toolName,{command:part},cwd)))].slice(0,12);
|
|
135
|
+
}
|
|
136
|
+
if(/[$`;&|<>(){}*?\\\r\n]/.test(command))return [];
|
|
137
|
+
const tokens:string[]=[];
|
|
138
|
+
let token="",quote="";
|
|
139
|
+
for(const char of command) {
|
|
140
|
+
if(quote) {if(char===quote)quote="";else token+=char;}
|
|
141
|
+
else if(char==='"' || char==="'")quote=char;
|
|
142
|
+
else if(/\s/.test(char)) {if(token){tokens.push(token);token="";}}
|
|
143
|
+
else token+=char;
|
|
144
|
+
}
|
|
145
|
+
if(quote)return [];
|
|
146
|
+
if(token)tokens.push(token);
|
|
147
|
+
if(!tokens.length || tokens.some(t=>["-e","--eval","-c","--command","-p","--print"].includes(t)))return [];
|
|
148
|
+
// Interpreter/package-manager cwd switches change which file a relative operand means.
|
|
149
|
+
// Do not offer root decoys as evidence when we cannot bind the effective directory.
|
|
150
|
+
if(tokens.some(t=>/^(?:--(?:cwd|dir|directory|prefix|workspace|workspaces)(?:=|$)|-C|-w)/.test(t)))return [];
|
|
151
|
+
let candidates:string[]=[];
|
|
152
|
+
if(["npm","pnpm","yarn","bun"].includes(tokens[0]) && ["run","run-script","test","test:unit","lint"].includes(tokens[1])) candidates=["package.json"];
|
|
153
|
+
else if(/^(?:node|nodejs|python(?:3(?:\.\d+)?)?|bash|sh|zsh|ruby|perl|deno|bun|tsx|ts-node)$/.test(tokens[0]))
|
|
154
|
+
candidates=tokens.slice(1).filter(t=>!t.startsWith("-") && /\.(?:[cm]?[jt]s|tsx|jsx|py|sh|bash|zsh|rb|pl)$/.test(t));
|
|
155
|
+
else if(tokens[0].startsWith("./") && /\.(?:sh|bash|zsh|py|[cm]?[jt]s)$/.test(tokens[0]))candidates=[tokens[0]];
|
|
156
|
+
const result:string[]=[];
|
|
157
|
+
for(const candidate of candidates) {
|
|
158
|
+
const path=isAbsolute(candidate) ? relative(resolve(cwd),candidate).split(sep).join("/") : candidate.replace(/^\.\//,"");
|
|
159
|
+
try {
|
|
160
|
+
resolveAutoPermissionEvidencePath(resolve(cwd),[path],path);
|
|
161
|
+
// Never offer a file the reader must reject: the classifier then judges without it instead of
|
|
162
|
+
// failing technically. Missing files keep their existing path; the reader still re-checks size.
|
|
163
|
+
if(oversizedLocalFile(cwd,path))continue;
|
|
164
|
+
if(!result.includes(path))result.push(path);
|
|
165
|
+
} catch { /* Unsupported/credential paths are not inspection candidates. */ }
|
|
166
|
+
}
|
|
167
|
+
return result.slice(0,12);
|
|
168
|
+
}
|
|
46
169
|
function inputText(content: unknown): string | undefined {
|
|
47
170
|
if (typeof content === "string") return content;
|
|
48
171
|
if (!Array.isArray(content) || content.some(p => p?.type !== "text")) return undefined;
|
|
@@ -90,6 +213,10 @@ export class AutoPermissionRuntime {
|
|
|
90
213
|
private owner?: {manager: ExtensionContext["sessionManager"]; id: string; cwd: string};
|
|
91
214
|
private generation = 0;
|
|
92
215
|
private requests: AuthoredInput[] = [];
|
|
216
|
+
private hostAnswers: {text:string;prompt:string;anchor:string;epoch:number;order:number}[] = [];
|
|
217
|
+
private inputOrder=0;
|
|
218
|
+
private unsubscribeAnswers?: () => void;
|
|
219
|
+
private answerEpoch?: number;
|
|
93
220
|
private priorActions: {toolName:string;input:unknown}[] = [];
|
|
94
221
|
private consecutive = 0;
|
|
95
222
|
private total = 0;
|
|
@@ -98,33 +225,77 @@ export class AutoPermissionRuntime {
|
|
|
98
225
|
private tokens = 0;
|
|
99
226
|
private cost = 0;
|
|
100
227
|
private active = new Set<AbortController>();
|
|
101
|
-
private overflow = false;
|
|
102
228
|
private configHome: string;
|
|
103
|
-
private pi: Pick<ExtensionAPI,"appendEntry"
|
|
229
|
+
private pi: Pick<ExtensionAPI,"appendEntry"> & Partial<Pick<ExtensionAPI,"getAllTools">>;
|
|
230
|
+
private memory: AutoPermissionMemory;
|
|
104
231
|
private modes: ModeController;
|
|
105
232
|
private classifier: Classifier;
|
|
106
|
-
constructor(pi: Pick<ExtensionAPI,"appendEntry"
|
|
233
|
+
constructor(pi: Pick<ExtensionAPI,"appendEntry"> & Partial<Pick<ExtensionAPI,"getAllTools">>, modes: ModeController, classifier: Classifier,
|
|
107
234
|
options: {configHome?: string; retryDelaysMs?: number[]} = {}) {
|
|
108
235
|
this.pi=pi;this.modes=modes;this.classifier=classifier;
|
|
236
|
+
this.memory=new AutoPermissionMemory(()=>pi.getAllTools?.() ?? []);
|
|
109
237
|
this.configHome = resolve(options.configHome ?? gentlePiConfigHome());
|
|
110
238
|
this.retryDelaysMs = options.retryDelaysMs ?? PROVIDER_RETRY_DELAYS_MS;
|
|
111
239
|
}
|
|
112
240
|
private retryDelaysMs: number[];
|
|
241
|
+
private toolContext(name:string) {
|
|
242
|
+
try { return projectToolContext(this.pi.getAllTools?.().find(tool=>tool.name===name),name); }
|
|
243
|
+
catch { return undefined; }
|
|
244
|
+
}
|
|
245
|
+
toolResult(event: ToolResultEvent, ctx: ExtensionContext): void {
|
|
246
|
+
this.own(ctx);
|
|
247
|
+
this.memory.result(event,ctx,this.modes.epoch());
|
|
248
|
+
}
|
|
113
249
|
cancelPending(): void {
|
|
250
|
+
this.memory.clear();
|
|
251
|
+
this.unsubscribeAnswers?.();this.unsubscribeAnswers=undefined;
|
|
252
|
+
this.hostAnswers=[];this.answerEpoch=undefined;
|
|
114
253
|
this.generation++;
|
|
115
254
|
for (const controller of this.active) controller.abort();
|
|
116
255
|
this.classifier.cancel();
|
|
117
256
|
}
|
|
118
257
|
reset(): void {
|
|
119
258
|
this.cancelPending();
|
|
120
|
-
this.active.clear();this.owner=undefined;this.requests=[];this.priorActions=[];
|
|
121
|
-
this.consecutive=0;this.total=0;this.unavailable=0;this.calls=0;this.tokens=0;this.cost=0;
|
|
259
|
+
this.active.clear();this.owner=undefined;this.requests=[];this.priorActions=[];this.inputOrder=0;
|
|
260
|
+
this.consecutive=0;this.total=0;this.unavailable=0;this.calls=0;this.tokens=0;this.cost=0;
|
|
122
261
|
}
|
|
123
262
|
private own(ctx: ExtensionContext): void {
|
|
124
263
|
const id=ctx.sessionManager.getSessionId();
|
|
125
264
|
if (this.owner?.manager === ctx.sessionManager && this.owner.id === id && this.owner.cwd === ctx.cwd) return;
|
|
126
265
|
this.reset();this.owner={manager:ctx.sessionManager,id,cwd:ctx.cwd};
|
|
127
266
|
}
|
|
267
|
+
private observeAnswers(ctx: ExtensionContext): void {
|
|
268
|
+
const epoch=this.modes.epoch();
|
|
269
|
+
if(this.unsubscribeAnswers && this.answerEpoch===epoch)return;
|
|
270
|
+
this.unsubscribeAnswers?.();this.answerEpoch=epoch;
|
|
271
|
+
this.hostAnswers=this.hostAnswers.filter(answer=>answer.epoch===epoch);
|
|
272
|
+
this.unsubscribeAnswers=subscribeHumanAnswers(ctx,receipt=>{
|
|
273
|
+
if(epoch!==this.modes.epoch())return;
|
|
274
|
+
this.captureAnswer(receipt,ctx,epoch);
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
private captureAnswer(receipt: HumanAnswerReceipt, ctx: ExtensionContext, epoch: number): void {
|
|
278
|
+
if(receipt.sessionManager!==this.owner?.manager || receipt.sessionId!==this.owner.id ||
|
|
279
|
+
receipt.cwd!==this.owner.cwd || receipt.branchAnchor===null || ctx.signal?.aborted ||
|
|
280
|
+
!this.branch(ctx).some(entry=>entry.id===receipt.branchAnchor))return;
|
|
281
|
+
const answer=receipt.answer;
|
|
282
|
+
const text=answer.kind==="multi" ? answer.selected.join("; ") :
|
|
283
|
+
[answer.text,...answer.selected ?? []].join("; ");
|
|
284
|
+
if(!text.trim())return;
|
|
285
|
+
// Preserve the entire referent: cutting option descriptions can change what "yes" means.
|
|
286
|
+
const prompt=receipt.prompt;
|
|
287
|
+
// Size never blocks: the reply joins the bounded window and its prompt is kept only while it fits.
|
|
288
|
+
if(typeof prompt!=="string")return;
|
|
289
|
+
this.generation++;
|
|
290
|
+
for(const controller of this.active)controller.abort();
|
|
291
|
+
this.classifier.cancel();
|
|
292
|
+
this.consecutive=0;this.total=0;this.unavailable=0;this.priorActions=[];
|
|
293
|
+
this.hostAnswers.push({text,prompt,anchor:receipt.branchAnchor,epoch,order:++this.inputOrder});
|
|
294
|
+
}
|
|
295
|
+
private liveHostAnswers(ctx: ExtensionContext) {
|
|
296
|
+
const ids=new Set(this.branch(ctx).map(entry=>entry.id));
|
|
297
|
+
return this.hostAnswers.filter(answer=>answer.epoch===this.modes.epoch() && ids.has(answer.anchor));
|
|
298
|
+
}
|
|
128
299
|
private branch(ctx: ExtensionContext): BranchItem[] {
|
|
129
300
|
try { return ctx.sessionManager.getBranch() as BranchItem[]; } catch { return []; }
|
|
130
301
|
}
|
|
@@ -132,20 +303,21 @@ export class AutoPermissionRuntime {
|
|
|
132
303
|
this.own(ctx);
|
|
133
304
|
// RPC clients and extension messages are not evidence of an interactive human decision.
|
|
134
305
|
if (event.source !== "interactive") return;
|
|
306
|
+
this.unsubscribeAnswers?.();this.unsubscribeAnswers=undefined;
|
|
135
307
|
this.generation++;
|
|
136
308
|
for (const controller of this.active) controller.abort();
|
|
137
309
|
this.classifier.cancel();
|
|
138
310
|
this.consecutive=0;this.total=0;this.unavailable=0;
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
311
|
+
// Optional action history is not authority. A new request starts a fresh evidence window;
|
|
312
|
+
// all live human restrictions below remain intact.
|
|
313
|
+
this.priorActions=[];
|
|
142
314
|
const branch=this.branch(ctx);
|
|
143
315
|
// Captured now, before any later assistant text exists, so a reply never inherits a question asked after it.
|
|
144
316
|
const last=branch.findLast(e=>e.type==="message" && (e.message?.role==="assistant" || e.message?.role==="user"));
|
|
145
317
|
const prompt=last?.message?.role==="assistant" ? tail(assistantText(last.message.content),PROMPT_TAIL_BYTES) : "";
|
|
146
|
-
this.requests.push({text:event.text,before:new Set(branch.map(e=>e.id)),...(prompt ? {prompt} : {})});
|
|
318
|
+
this.requests.push({text:event.text,order:++this.inputOrder,before:new Set(branch.map(e=>e.id)),...(prompt ? {prompt} : {})});
|
|
147
319
|
}
|
|
148
|
-
private trusted(ctx: ExtensionContext): string[] {
|
|
320
|
+
private trusted(ctx: ExtensionContext): {messages: string[]; kept: Set<number>; omitted: number} {
|
|
149
321
|
const branch=this.branch(ctx), ids=new Set(branch.map(e=>e.id));
|
|
150
322
|
for(const request of this.requests) {
|
|
151
323
|
if(request.entryId) continue;
|
|
@@ -153,15 +325,38 @@ export class AutoPermissionRuntime {
|
|
|
153
325
|
if(match) request.entryId=match.id;
|
|
154
326
|
}
|
|
155
327
|
// Switching branches cannot reuse an approval whose actual input entry is absent.
|
|
156
|
-
|
|
328
|
+
const all=[...this.live(ids),...this.liveHostAnswers(ctx)].sort((a,b)=>a.order-b.order);
|
|
329
|
+
const full=all.map(r=>r.text);
|
|
330
|
+
if(Buffer.byteLength(full.join(""))<=MAX_AUTHORED_BYTES)return {messages:full,kept:new Set(all.map(r=>r.order)),omitted:0};
|
|
331
|
+
// Over budget: keep the task (first) and the latest message, then the most recent contiguous ones.
|
|
332
|
+
const texts=full.map(middleTruncate);
|
|
333
|
+
const keep=new Set([0,all.length-1]);
|
|
334
|
+
// +3 per entry covers the array's quotes and separator.
|
|
335
|
+
const cost=(i:number)=>serialized(texts[i])+3;
|
|
336
|
+
let budget=MAX_AUTHORED_BYTES-OMISSION_RESERVE_BYTES-2-[...keep].reduce((sum,i)=>sum+cost(i),0);
|
|
337
|
+
for(let i=all.length-2;i>0;i--) {
|
|
338
|
+
const size=cost(i);
|
|
339
|
+
if(size>budget)break;
|
|
340
|
+
budget-=size;keep.add(i);
|
|
341
|
+
}
|
|
342
|
+
const order=[...keep].sort((a,b)=>a-b);
|
|
343
|
+
return {messages:order.map(i=>texts[i]),kept:new Set(order.map(i=>all[i].order)),omitted:all.length-keep.size};
|
|
157
344
|
}
|
|
158
345
|
private live(ids: Set<string>): AuthoredInput[] { return this.requests.filter(r=>r.entryId && ids.has(r.entryId)); }
|
|
159
346
|
/** Newest first within a fixed budget that also counts the repeated reply; dropped pairs leave their replies trusted. */
|
|
160
|
-
private answered(ctx: ExtensionContext): {prompt:string;reply:string}[] {
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
let budget=Math.min(PROMPT_TOTAL_BYTES,MAX_AUTHORED_BYTES-live.reduce((sum,r)=>sum+Buffer.byteLength(r.text),0));
|
|
347
|
+
private answered(ctx: ExtensionContext, window: {messages: string[]; kept: Set<number>}): {prompt:string;reply:string}[] {
|
|
348
|
+
// Only pairs whose reply is in the window; omitted replies must not return through their prompt.
|
|
349
|
+
const live=this.live(new Set(this.branch(ctx).map(e=>e.id))).filter(r=>window.kept.has(r.order));
|
|
164
350
|
const kept: {prompt:string;reply:string}[]=[];
|
|
351
|
+
// Host answers keep their full question, newest first within their own budget, as before the window existed.
|
|
352
|
+
let hostBudget=PROMPT_TOTAL_BYTES;
|
|
353
|
+
for(const answer of this.liveHostAnswers(ctx).filter(r=>window.kept.has(r.order)).reverse()) {
|
|
354
|
+
const size=Buffer.byteLength(answer.prompt)+Buffer.byteLength(answer.text);
|
|
355
|
+
if(size>hostBudget)continue;
|
|
356
|
+
hostBudget-=size;kept.unshift({prompt:answer.prompt,reply:answer.text});
|
|
357
|
+
}
|
|
358
|
+
// Typed-reply pairs use what live requests and host pairs leave, so the classifier input never grows past its previous ceiling.
|
|
359
|
+
let budget=Math.min(PROMPT_TOTAL_BYTES,MAX_AUTHORED_BYTES-live.reduce((sum,r)=>sum+Buffer.byteLength(r.text),0))-(PROMPT_TOTAL_BYTES-hostBudget);
|
|
165
360
|
for(const request of live.reverse()) {
|
|
166
361
|
if(!request.prompt) continue;
|
|
167
362
|
const size=Buffer.byteLength(request.prompt)+Buffer.byteLength(request.text);
|
|
@@ -182,11 +377,11 @@ export class AutoPermissionRuntime {
|
|
|
182
377
|
}
|
|
183
378
|
return false;
|
|
184
379
|
}
|
|
185
|
-
private record(ctx: ExtensionContext, event: Pick<ToolCallEvent,"toolName"|"toolCallId">, result: Verdict, late=false): void {
|
|
186
|
-
const usage=numericUsage(result.usage);
|
|
380
|
+
private record(ctx: ExtensionContext, event: Pick<ToolCallEvent,"toolName"|"toolCallId">, result: Verdict, late=false, memoryContextAttached?: boolean): void {
|
|
381
|
+
const usage=numericUsage(result.usage), diagnostic=safeDiagnostic(result.diagnostic);
|
|
187
382
|
// Receipts carry no raw command, input, transcript, or provider-generated rationale.
|
|
188
383
|
try {this.pi.appendEntry("gentle-auto-permission",{schema:1,toolCallId:event.toolCallId,toolName:event.toolName,
|
|
189
|
-
status:result.status,decision:result.decision,code:result.code,usage,usageKnown:result.usageKnown,late,actualModel:result.actualModel});} catch { /* Missing persistence never creates permission. */ }
|
|
384
|
+
status:result.status,decision:result.decision,code:result.code,usage,usageKnown:result.usageKnown,late,actualModel:result.actualModel,...(memoryContextAttached === undefined ? {} : {memoryContextAttached}),...(diagnostic ? {diagnostic} : {})});} catch { /* Missing persistence never creates permission. */ }
|
|
190
385
|
if(!late)this.calls++;
|
|
191
386
|
this.tokens+=usage.totalTokens ?? 0;this.cost+=usage.costTotal ?? 0;
|
|
192
387
|
try {ctx.ui.setStatus("gentle:auto-usage",`Auto checks ${this.calls} · ${this.tokens} tokens · $${this.cost.toFixed(4)} (separate)`);} catch { /* Headless. */ }
|
|
@@ -203,29 +398,40 @@ export class AutoPermissionRuntime {
|
|
|
203
398
|
const mode=await this.modes.mode(ctx);
|
|
204
399
|
if(generation!==this.generation || epoch!==this.modes.epoch() || owner!==this.owner || ctx.signal?.aborted)
|
|
205
400
|
return block("Auto decision expired during mode lookup. The action was not run.");
|
|
206
|
-
if(mode!=="auto")return undefined;
|
|
401
|
+
if(mode!=="auto") { this.memory.admitted(event,ctx,epoch);return undefined; }
|
|
402
|
+
this.observeAnswers(ctx);
|
|
207
403
|
// Independent executors retain their own consent and delegation authorities.
|
|
208
404
|
if(OWNED_CONTROL_TOOLS.has(event.toolName))return undefined;
|
|
209
405
|
if(this.consecutive>=DENIAL_LIMIT || this.total>=TOTAL_DENIAL_LIMIT || this.unavailable>=DENIAL_LIMIT)
|
|
210
406
|
return block("Auto mode is paused after repeated denials or unavailable checks. Stop this attempt and ask the user for a safer, specific instruction; do not route around the boundary.");
|
|
211
407
|
// Local reads can disclose explicitly forbidden data; they need the same check.
|
|
212
|
-
if(this.overflow)return block("Auto cannot retain all live user restrictions within its context limit. Continue with manual permissions or start a new session with an explicit scoped request.");
|
|
213
408
|
if(!ctx.model)return block("Auto could not evaluate this action: no session model is available. The action was not run.");
|
|
214
409
|
// Copy identity values: a host can update its model object in place.
|
|
215
410
|
const model={id:ctx.model.id,provider:ctx.model.provider};
|
|
216
|
-
const
|
|
411
|
+
const window=this.trusted(ctx), trustedMessages=window.messages, answeredPrompts=this.answered(ctx,window);
|
|
412
|
+
const omittedTrustedMessages=window.omitted || undefined;
|
|
217
413
|
const branchIds=new Set(this.branch(ctx).map(entry=>entry.id));
|
|
218
|
-
const trustedEntryIds=this.requests.flatMap(request=>request.entryId && branchIds.has(request.entryId) ? [request.entryId] : [])
|
|
414
|
+
const trustedEntryIds=[...this.requests.flatMap(request=>request.entryId && branchIds.has(request.entryId) ? [request.entryId] : []),
|
|
415
|
+
...this.liveHostAnswers(ctx).map(answer=>answer.anchor)];
|
|
219
416
|
let serialized: string;
|
|
220
417
|
try {serialized=JSON.stringify(event.input);}catch{return block("Auto could not evaluate invalid tool input. The action was not run.");}
|
|
418
|
+
const memoryContext=this.memory.context(event,ctx,epoch);
|
|
419
|
+
const memoryContextAttached=["mem_save","mem_session_summary"].includes(event.toolName) ? memoryContext !== undefined : undefined;
|
|
420
|
+
const memoryBinding=JSON.stringify(memoryContext);
|
|
421
|
+
const toolContext=this.toolContext(event.toolName),toolBinding=JSON.stringify(toolContext);
|
|
221
422
|
const current=()=>{ try {
|
|
222
423
|
// SDK branch changes must revoke removed input even before a host tree event.
|
|
223
424
|
const currentBranchIds=new Set(this.branch(ctx).map(entry=>entry.id));
|
|
224
425
|
return generation===this.generation && epoch===this.modes.epoch() && owner===this.owner &&
|
|
225
426
|
owner?.manager===ctx.sessionManager && owner.id===ctx.sessionManager.getSessionId() && owner.cwd===ctx.cwd &&
|
|
226
427
|
model.id===ctx.model?.id && model.provider===ctx.model?.provider && JSON.stringify(event.input)===serialized &&
|
|
227
|
-
trustedEntryIds.every(id=>currentBranchIds.has(id))
|
|
428
|
+
trustedEntryIds.every(id=>currentBranchIds.has(id)) &&
|
|
429
|
+
JSON.stringify(this.memory.context(event,ctx,epoch))===memoryBinding &&
|
|
430
|
+
JSON.stringify(this.toolContext(event.toolName))===toolBinding;
|
|
228
431
|
} catch {return false;} };
|
|
432
|
+
const paths=evidencePaths(event.toolName,JSON.parse(serialized),ctx.cwd);
|
|
433
|
+
// Canonical cwd allows safe inspection in workspaces reached through /tmp-like aliases.
|
|
434
|
+
const classifierCwd=paths.length ? canonical(ctx.cwd) : ctx.cwd;
|
|
229
435
|
const controller=new AbortController();this.active.add(controller);
|
|
230
436
|
const onAbort=()=>controller.abort();ctx.signal?.addEventListener("abort",onAbort,{once:true});
|
|
231
437
|
if(ctx.signal?.aborted)controller.abort();
|
|
@@ -235,23 +441,26 @@ export class AutoPermissionRuntime {
|
|
|
235
441
|
for(let attempt=0;attempt<=this.retryDelaysMs.length;attempt++){
|
|
236
442
|
if(attempt>0 && (!(await pause(this.retryDelaysMs[attempt-1],controller.signal)) || !current()))break;
|
|
237
443
|
try {
|
|
238
|
-
result=await this.classifier.run({trustedMessages,answeredPrompts,action:{toolName:event.toolName,input:JSON.parse(serialized)},
|
|
239
|
-
priorActions:this.priorActions,cwd:
|
|
444
|
+
result=await this.classifier.run({trustedMessages,...(omittedTrustedMessages ? {omittedTrustedMessages} : {}),answeredPrompts,memoryContext,toolContext,action:{toolName:event.toolName,input:JSON.parse(serialized)},
|
|
445
|
+
priorActions:this.priorActions,evidencePaths:paths,cwd:classifierCwd,model:ctx.model,modelRegistry:ctx.modelRegistry,isCurrent:current,signal:controller.signal,
|
|
240
446
|
onUsage:(late: Omit<Verdict,"status">)=>{
|
|
241
|
-
if(owner?.manager===ctx.sessionManager && owner.id===ctx.sessionManager.getSessionId()) this.record(ctx,event,{...late,status:"settled"},true);
|
|
447
|
+
if(owner?.manager===ctx.sessionManager && owner.id===ctx.sessionManager.getSessionId()) this.record(ctx,event,{...late,status:"settled"},true,memoryContextAttached);
|
|
242
448
|
}});
|
|
243
449
|
}catch{result={status:"unavailable",code:"provider-error"};}
|
|
244
450
|
// Record attempts even when revoked; never transplant old receipts into a replacement session.
|
|
245
|
-
if(owner?.manager===ctx.sessionManager && owner.id===ctx.sessionManager.getSessionId())this.record(ctx,event,result);
|
|
451
|
+
if(owner?.manager===ctx.sessionManager && owner.id===ctx.sessionManager.getSessionId())this.record(ctx,event,result,false,memoryContextAttached);
|
|
246
452
|
if(result.status==="available" || result.code!=="provider-error" || controller.signal.aborted)break;
|
|
247
453
|
}
|
|
248
454
|
}finally{ctx.signal?.removeEventListener("abort",onAbort);this.active.delete(controller);}
|
|
249
455
|
const stillAuto=await this.modes.mode(ctx)==="auto";
|
|
250
456
|
if(!current() || controller.signal.aborted || !stillAuto)return block("Auto decision expired because the action, mode, request or session changed. The action was not run.");
|
|
251
457
|
if(result.status!=="available" || !["allow","deny"].includes(result.decision ?? "")){
|
|
252
|
-
|
|
458
|
+
// Contention and revocation are not failed safety judgements or provider outages.
|
|
459
|
+
if(!["busy","cancelled","revoked"].includes(result.code ?? ""))this.unavailable++;
|
|
253
460
|
if(result.code==="provider-error")return block(`Auto could not reach its classifier model (provider error after ${this.retryDelaysMs.length+1} attempts). The action was not run. Retry later, switch the session model, or continue with manual permissions; do not treat a missing verdict as permission or evade this check.`);
|
|
254
|
-
|
|
461
|
+
const diagnostic=["timeout","evidence-limit"].includes(result.code ?? "") ? safeDiagnostic(result.diagnostic) : undefined;
|
|
462
|
+
const detail=diagnostic ? ` Diagnostic: ${JSON.stringify(diagnostic)}.` : "";
|
|
463
|
+
return block(`${UNAVAILABLE_REASONS[result.code ?? ""] ?? "Auto received no usable classifier verdict. Retry later or use manual permissions."} The action was not run. A missing verdict is not permission; do not evade this check.${detail}`);
|
|
255
464
|
}
|
|
256
465
|
this.unavailable=0;
|
|
257
466
|
if(result.decision==="deny"){
|
|
@@ -261,8 +470,15 @@ export class AutoPermissionRuntime {
|
|
|
261
470
|
return block(`Auto denied this action: ${reason}. Find a materially safer alternative within the request, or ask the user; do not pursue the same effect through another tool.`);
|
|
262
471
|
}
|
|
263
472
|
this.consecutive=0;
|
|
264
|
-
|
|
265
|
-
|
|
473
|
+
this.memory.admitted(event,ctx,epoch);
|
|
474
|
+
// Keep only complete recent records: truncated source could hide side effects. Missing
|
|
475
|
+
// script evidence can be inspected by the reviewer, never inferred from an omitted record.
|
|
476
|
+
const action={toolName:event.toolName,input:JSON.parse(serialized)};
|
|
477
|
+
if(Buffer.byteLength(JSON.stringify([action]))<=HISTORY_BYTES) {
|
|
478
|
+
const history=[...this.priorActions,action];
|
|
479
|
+
while(history.length>HISTORY_ENTRIES || Buffer.byteLength(JSON.stringify(history))>HISTORY_BYTES)history.shift();
|
|
480
|
+
this.priorActions=history;
|
|
481
|
+
}
|
|
266
482
|
return undefined;
|
|
267
483
|
}
|
|
268
484
|
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/** Descriptive registry metadata, never authorization or a callable reviewer tool. */
|
|
2
|
+
export interface AutoPermissionToolContext {
|
|
3
|
+
name: string;
|
|
4
|
+
description: string;
|
|
5
|
+
parameters: Record<string, unknown> | boolean;
|
|
6
|
+
}
|
|
7
|
+
const MAX_BYTES = 8192;
|
|
8
|
+
/** Snapshot plain schema data without invoking getters/serializers. Drop unusable metadata whole. */
|
|
9
|
+
export function projectToolContext(value: unknown, name: string, unsafeText: (text:string)=>boolean = ()=>false): AutoPermissionToolContext | undefined {
|
|
10
|
+
try {
|
|
11
|
+
let nodes=0, bytes=0;
|
|
12
|
+
const text=(s:string)=>{
|
|
13
|
+
bytes+=Buffer.byteLength(s);
|
|
14
|
+
if(bytes>MAX_BYTES || /[\p{Cf}\p{Cs}]/u.test(s) || unsafeText(s))throw Error("unusable-metadata");
|
|
15
|
+
return s;
|
|
16
|
+
};
|
|
17
|
+
const json=(input:unknown,depth=0,declarations=false,propertyName?:string):unknown=>{
|
|
18
|
+
if(++nodes>512 || depth>12)throw Error("unusable-metadata");
|
|
19
|
+
if(typeof input==="string")return text(input);
|
|
20
|
+
if(input===null || typeof input==="boolean" || (typeof input==="number" && Number.isFinite(input)))return input;
|
|
21
|
+
if(!input || typeof input!=="object")throw Error("unusable-metadata");
|
|
22
|
+
const array=Array.isArray(input), proto=Object.getPrototypeOf(input);
|
|
23
|
+
if(array ? proto!==Array.prototype : proto!==Object.prototype && proto!==null)throw Error("unusable-metadata");
|
|
24
|
+
const descriptors=Object.getOwnPropertyDescriptors(input);
|
|
25
|
+
const entries:[string,unknown][]=[];
|
|
26
|
+
for(const key of Object.keys(descriptors)) {
|
|
27
|
+
if(array && key==="length")continue;
|
|
28
|
+
const descriptor=descriptors[key];
|
|
29
|
+
if(!descriptor.enumerable)continue; // TypeBox also carries non-enumerable string annotations.
|
|
30
|
+
if(!Object.hasOwn(descriptor,"value") || key==="toJSON")throw Error("unusable-metadata");
|
|
31
|
+
if(array && !/^(0|[1-9][0-9]*)$/.test(key))throw Error("unusable-metadata");
|
|
32
|
+
const child=json(descriptor.value,depth+1,key==="properties" || key==="patternProperties",declarations ? key : propertyName);
|
|
33
|
+
// Property declarations describe slots; examples/defaults/const/enum contain values.
|
|
34
|
+
const literal=["examples","example","default","const","enum"].includes(key);
|
|
35
|
+
const assignment=(field:string)=>child!==null && child!=="" && unsafeText(`${field}=${typeof child==="number" ? child : "value"}`);
|
|
36
|
+
if((!declarations && assignment(key)) || (literal &&
|
|
37
|
+
(unsafeText(JSON.stringify(child)) || (propertyName!==undefined && assignment(propertyName)))))throw Error("unusable-metadata");
|
|
38
|
+
entries.push([text(key),child]);
|
|
39
|
+
}
|
|
40
|
+
// TypeBox's symbol annotations are not JSON Schema and are not sent to the reviewer.
|
|
41
|
+
if(array) {
|
|
42
|
+
if(descriptors.length.value!==entries.length)throw Error("unusable-metadata");
|
|
43
|
+
return entries.map(([,item])=>item);
|
|
44
|
+
}
|
|
45
|
+
return Object.fromEntries(entries);
|
|
46
|
+
};
|
|
47
|
+
if(!value || typeof value!=="object")return undefined;
|
|
48
|
+
const descriptors=Object.getOwnPropertyDescriptors(value);
|
|
49
|
+
const field=(key:string)=>{
|
|
50
|
+
const descriptor=descriptors[key];
|
|
51
|
+
if(!descriptor || !Object.hasOwn(descriptor,"value"))throw Error("unusable-metadata");
|
|
52
|
+
return descriptor.value;
|
|
53
|
+
};
|
|
54
|
+
const toolName=field("name"), description=field("description"), parameters=json(field("parameters"));
|
|
55
|
+
if(toolName!==name || typeof toolName!=="string" || !name || name.length>256 || typeof description!=="string" ||
|
|
56
|
+
!(typeof parameters==="boolean" || (parameters!==null && typeof parameters==="object" && !Array.isArray(parameters))))return undefined;
|
|
57
|
+
const result={name:text(toolName),description:text(description),parameters} as AutoPermissionToolContext;
|
|
58
|
+
if(Buffer.byteLength(JSON.stringify(result))>MAX_BYTES)return undefined;
|
|
59
|
+
return result;
|
|
60
|
+
} catch { return undefined; }
|
|
61
|
+
}
|