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.
Files changed (81) hide show
  1. package/README.md +39 -14
  2. package/bin/engram-guard +1 -1
  3. package/bin/gentle-shell.mjs +16 -11
  4. package/docs/auto-permission-answers.md +25 -0
  5. package/docs/auto-permission-evidence.md +39 -0
  6. package/docs/bundled-install.md +8 -6
  7. package/docs/gentle-shell.md +17 -10
  8. package/docs/install-wizard.md +9 -9
  9. package/docs/permission-modes.md +50 -5
  10. package/docs/readme-reference.md +48 -12
  11. package/docs/session-profile-format.md +39 -0
  12. package/docs/yolo-mode.md +2 -1
  13. package/extensions/ask-user-choice.ts +4 -0
  14. package/extensions/ask-user-question.ts +6 -1
  15. package/extensions/gentle-agents.ts +54 -23
  16. package/extensions/gentle-ai.ts +105 -25
  17. package/extensions/gentle-shell.ts +26 -31
  18. package/extensions/gentle-todo.ts +26 -16
  19. package/lib/auto-permission-answers.ts +133 -0
  20. package/lib/auto-permission-classifier.ts +215 -36
  21. package/lib/auto-permission-evidence.ts +88 -0
  22. package/lib/auto-permission-memory.ts +69 -0
  23. package/lib/auto-permission-runtime.ts +251 -35
  24. package/lib/auto-permission-tool-context.ts +61 -0
  25. package/lib/destructive-command-guard.ts +104 -28
  26. package/lib/gentle-shell-bundled.ts +23 -5
  27. package/lib/gentle-shell-launcher.ts +1 -1
  28. package/lib/model-routing-authority.ts +6 -1
  29. package/lib/selection-engine.ts +4 -1
  30. package/lib/session-profile-freeze.ts +10 -15
  31. package/lib/session-profile-persistence.ts +187 -0
  32. package/lib/shell-changes.ts +2 -0
  33. package/lib/shell-prompt.ts +6 -4
  34. package/lib/shell-todo.ts +20 -8
  35. package/lib/vim-editor-adapter.ts +22 -4
  36. package/package.json +5 -5
  37. package/runtime/gentle-shell-bundled.mjs +23 -5
  38. package/runtime/gentle-shell-launcher.mjs +1 -1
  39. package/scripts/install-wizard-preview.mjs +2 -2
  40. package/scripts/installer-downloads.mjs +7 -7
  41. package/scripts/installer-preflight.mjs +1 -1
  42. package/scripts/pi-update.mjs +223 -0
  43. package/tests/append-system-prompt-route.test.ts +18 -1
  44. package/tests/auto-mode-human-answers.test.ts +68 -0
  45. package/tests/auto-mode-invariants.test.ts +23 -5
  46. package/tests/auto-mode-sdk.test.ts +29 -2
  47. package/tests/auto-permission-answers.test.ts +170 -0
  48. package/tests/auto-permission-classifier.test.ts +353 -2
  49. package/tests/auto-permission-compound.test.ts +24 -0
  50. package/tests/auto-permission-evidence.test.ts +88 -0
  51. package/tests/auto-permission-memory.test.ts +46 -0
  52. package/tests/auto-permission-public-regressions.test.ts +111 -0
  53. package/tests/auto-permission-runtime.test.ts +270 -12
  54. package/tests/autonomous-guard.test.ts +86 -16
  55. package/tests/bundled-install.test.ts +3 -3
  56. package/tests/engram-safety.test.ts +34 -6
  57. package/tests/gentle-agents.test.ts +162 -6
  58. package/tests/gentle-shell-bin.test.ts +17 -17
  59. package/tests/gentle-shell-bundled.test.ts +67 -33
  60. package/tests/gentle-shell-launcher.test.ts +18 -13
  61. package/tests/gentle-shell.test.ts +71 -5
  62. package/tests/gentle-todo.test.ts +79 -27
  63. package/tests/install-wizard.test.ts +2 -2
  64. package/tests/installer-posix-bootstrap.test.ts +8 -8
  65. package/tests/installer-preflight.test.ts +3 -3
  66. package/tests/installer-probes.test.ts +8 -8
  67. package/tests/installer-runner.test.ts +14 -13
  68. package/tests/installer-server.test.ts +5 -5
  69. package/tests/main-channel-upgrade.test.ts +7 -1
  70. package/tests/odd-runtime-delegation-gate.test.ts +4 -1
  71. package/tests/package-manifest.test.ts +6 -6
  72. package/tests/pi-update-launcher.test.ts +87 -0
  73. package/tests/pi-update.test.ts +186 -0
  74. package/tests/rdd-status-line.test.ts +52 -0
  75. package/tests/selection-engine.test.ts +35 -5
  76. package/tests/session-profile-freeze.test.ts +14 -1
  77. package/tests/session-profile-persistence.test.ts +334 -0
  78. package/tests/shell-changes.test.ts +18 -0
  79. package/tests/shell-todo.test.ts +15 -12
  80. package/tests/vim-editor-adapter.test.ts +41 -15
  81. 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">, modes: ModeController, classifier: Classifier,
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;this.overflow=false;
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
- if (this.requests.reduce((sum,r)=>sum+Buffer.byteLength(r.text),0)+Buffer.byteLength(event.text)>MAX_AUTHORED_BYTES) {
140
- this.overflow=true;return; // Never truncate away a restriction and keep approving.
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
- return this.live(ids).map(r=>r.text);
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
- const live=this.live(new Set(this.branch(ctx).map(e=>e.id)));
162
- // Pairs only use what live input leaves of the authored budget, so the classifier input never grows past its previous ceiling.
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 trustedMessages=this.trusted(ctx), answeredPrompts=this.answered(ctx);
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:ctx.cwd,model:ctx.model,modelRegistry:ctx.modelRegistry,isCurrent:current,signal:controller.signal,
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
- this.unavailable++;
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
- return block("Auto could not determine whether this action is safe. It was not run. Do not treat a missing verdict as permission or evade this check.");
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
- // Retain action payloads for script indirection; bounded classifier projection fails closed on overflow.
265
- this.priorActions.push({toolName:event.toolName,input:JSON.parse(serialized)});
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
+ }