Skip to content

Commit db52fb4

Browse files
committed
making editor more resistant for suggestions trailing effect and permissions issues
1 parent 27c6c9f commit db52fb4

17 files changed

Lines changed: 1516 additions & 53 deletions

src/core/SuggestionEngine.ts

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,14 @@ Examples of good startup suggestions:
2929
const MAX_SUGGESTION_LENGTH = 80;
3030
/** Max conversation messages included in the suggestion prompt (system prompt added on top). */
3131
const MAX_HISTORY_MESSAGES = 6; // 3 user+assistant pairs → 7 messages total sent to LLM
32-
const SUGGESTION_TIMEOUT_MS = 3000;
32+
/** Max characters per message to keep the suggestion prompt small and fast. */
33+
const MAX_MESSAGE_CONTENT_LENGTH = 500;
34+
/**
35+
* Internal timeout for the background LLM call. Set higher than the user-facing
36+
* deadline in promptForInstruction (3s) so the request can finish in the background
37+
* and be available for the next prompt cycle.
38+
*/
39+
const SUGGESTION_TIMEOUT_MS = 10_000;
3340

3441
export interface SuggestionEngineOptions {
3542
/** When provided, constrains suggestions to only actions achievable with these tools. */
@@ -80,7 +87,24 @@ export class SuggestionEngine {
8087
}
8188

8289
async generate(history: LLMMessage[]): Promise<void> {
83-
const recentHistory = history.slice(-MAX_HISTORY_MESSAGES);
90+
// Clear stale suggestion from previous turn immediately so that a lazy
91+
// provider (e.g., `() => engine.getSuggestion()`) won't return outdated text
92+
// while the new LLM call is in flight.
93+
this.suggestion = null;
94+
95+
// Strip tool messages, empty assistant messages (tool-call-only turns),
96+
// and internal metadata (tool_calls, priority, etc.) to avoid breaking
97+
// the LLM API with orphaned tool responses or invalid sequences.
98+
const cleanHistory = history
99+
.filter(m => (m.role === 'user' || m.role === 'assistant') &&
100+
typeof m.content === 'string' && m.content.trim().length > 0)
101+
.map(m => ({
102+
role: m.role,
103+
content: m.content.length > MAX_MESSAGE_CONTENT_LENGTH
104+
? m.content.slice(0, MAX_MESSAGE_CONTENT_LENGTH) + '…'
105+
: m.content,
106+
}));
107+
const recentHistory = cleanHistory.slice(-MAX_HISTORY_MESSAGES);
84108
await this.executeWithTimeout([
85109
{ role: 'system', content: SUGGESTION_SYSTEM_PROMPT + this.toolConstraint },
86110
...recentHistory,
@@ -107,8 +131,10 @@ export class SuggestionEngine {
107131

108132
const controller = new AbortController();
109133
this.abortController = controller;
134+
const debug = process.env.AUTOHAND_DEBUG === '1';
110135

111136
const timeout = setTimeout(() => controller.abort(), SUGGESTION_TIMEOUT_MS);
137+
const startTime = Date.now();
112138

113139
try {
114140
const response = await this.llm.complete({
@@ -119,20 +145,27 @@ export class SuggestionEngine {
119145
});
120146

121147
if (controller.signal.aborted) {
148+
if (debug) process.stderr.write(`[SUGGESTION] Aborted after ${Date.now() - startTime}ms\n`);
122149
return;
123150
}
124151

125152
const raw = (response.content ?? '').trim();
126153
if (!raw) {
127154
this.suggestion = null;
155+
if (debug) process.stderr.write(`[SUGGESTION] Empty response after ${Date.now() - startTime}ms\n`);
128156
return;
129157
}
130158

131159
this.suggestion = sanitizeSuggestion(raw);
132-
} catch {
160+
if (debug) process.stderr.write(`[SUGGESTION] Generated "${this.suggestion}" in ${Date.now() - startTime}ms\n`);
161+
} catch (err) {
133162
if (!controller.signal.aborted) {
134163
this.suggestion = null;
135164
}
165+
if (debug) {
166+
const msg = err instanceof Error ? err.message : String(err);
167+
process.stderr.write(`[SUGGESTION] Error after ${Date.now() - startTime}ms: ${msg}\n`);
168+
}
136169
} finally {
137170
clearTimeout(timeout);
138171
if (this.abortController === controller) {

src/core/agent.ts

Lines changed: 30 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -749,7 +749,8 @@ export class AutohandAgent {
749749
maxQueueSize: 10,
750750
silentMode: disableTerminalRegions,
751751
workspaceRoot: this.runtime.workspaceRoot,
752-
resolveShellSuggestion: (input) => this.resolveLlmShellSuggestion(input)
752+
resolveShellSuggestion: (input) => this.resolveLlmShellSuggestion(input),
753+
suggestionProvider: () => this.suggestionEngine?.getSuggestion() ?? undefined,
753754
});
754755

755756
this.persistentInput.on('queued', (text: string, count: number) => {
@@ -1012,6 +1013,7 @@ export class AutohandAgent {
10121013
recentFiles,
10131014
});
10141015
})();
1016+
this.persistentInput.setPendingSuggestion(this.pendingSuggestion);
10151017
}
10161018

10171019
// Show prompt immediately - don't wait for init
@@ -1444,6 +1446,7 @@ If lint or tests fail, report the issues but do NOT commit.`;
14441446
// so the LLM call runs concurrently with hooks/notifications below.
14451447
if (this.suggestionEngine) {
14461448
this.pendingSuggestion = this.suggestionEngine.generate(this.conversation.history());
1449+
this.persistentInput.setPendingSuggestion(this.pendingSuggestion);
14471450
}
14481451

14491452
// Fire stop hook after turn completes (non-blocking)
@@ -1596,16 +1599,23 @@ If lint or tests fail, report the issues but do NOT commit.`;
15961599
// otherwise the default placeholder is shown.
15971600
// Turns: wait up to 3s. The user is still reading output so a brief
15981601
// wait for contextual ghost text is acceptable.
1599-
if (this.pendingSuggestion) {
1600-
if (!this.isStartupSuggestion) {
1601-
const deadline = new Promise<void>((r) => setTimeout(r, 3000));
1602-
await Promise.race([this.pendingSuggestion, deadline]).catch(() => {});
1603-
}
1604-
this.isStartupSuggestion = false;
1605-
this.pendingSuggestion = null;
1606-
}
1607-
const suggestionText = this.suggestionEngine?.getSuggestion() ?? undefined;
1608-
this.suggestionEngine?.clear();
1602+
// Suggestion uses a lazy provider: each render cycle in the prompt reads
1603+
// the latest value via getSuggestion(). This eliminates the race condition
1604+
// where the LLM takes >3s and the static snapshot was always undefined.
1605+
// The pendingSuggestion promise triggers a re-render when it resolves,
1606+
// so the ghost text appears as soon as the LLM responds — even if the
1607+
// prompt is already displayed.
1608+
const pendingSuggestion = this.pendingSuggestion;
1609+
this.isStartupSuggestion = false;
1610+
this.pendingSuggestion = null;
1611+
1612+
const debugSuggestion = process.env.AUTOHAND_DEBUG === '1';
1613+
if (debugSuggestion) {
1614+
const state = pendingSuggestion ? 'pending' : 'none';
1615+
process.stderr.write(`[SUGGESTION] Provider mode — pending=${state}, engine=${this.suggestionEngine ? 'exists' : 'null'}\n`);
1616+
}
1617+
1618+
const engine = this.suggestionEngine;
16091619
const input = await readInstruction(
16101620
() => this.workspaceFileCollector.getCachedFiles(),
16111621
SLASH_COMMANDS,
@@ -1614,8 +1624,9 @@ If lint or tests fail, report the issues but do NOT commit.`;
16141624
(data, mimeType, filename) => this.imageManager.add(data, mimeType, filename),
16151625
this.runtime.workspaceRoot,
16161626
initialValue,
1617-
suggestionText,
1618-
(line) => this.resolveLlmShellSuggestion(line)
1627+
() => engine?.getSuggestion() ?? undefined,
1628+
(line) => this.resolveLlmShellSuggestion(line),
1629+
pendingSuggestion ?? undefined
16191630
);
16201631
// Only exit on explicit ABORT (double Ctrl+C). Palette cancel or dismiss should continue.
16211632
if (input === 'ABORT') { // double Ctrl+C from prompt
@@ -4327,7 +4338,11 @@ If lint or tests fail, report the issues but do NOT commit.`;
43274338
if (this.inkRenderer) {
43284339
this.inkRenderer.setStatus(status);
43294340
} else if (this.runtime.spinner) {
4341+
// setSpinnerStatus already handles terminal regions internally
43304342
this.setSpinnerStatus(status);
4343+
} else if (this.isUsingTerminalRegionsForActiveTurn()) {
4344+
// No spinner (suppressed when persistent input is used) — route directly
4345+
this.setPersistentInputActivityLine(status);
43314346
}
43324347
}
43334348

@@ -4821,6 +4836,8 @@ If lint or tests fail, report the issues but do NOT commit.`;
48214836
this.inkRenderer.setElapsed(elapsed);
48224837
} else if (this.runtime.spinner) {
48234838
this.setSpinnerStatus(status);
4839+
} else if (this.isUsingTerminalRegionsForActiveTurn()) {
4840+
this.setPersistentInputActivityLine(status);
48244841
}
48254842
};
48264843
update();

src/permissions/PermissionManager.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ import {
1515
addToLocalWhitelist,
1616
mergePermissions
1717
} from './localProjectPermissions.js';
18+
import { matchesToolPattern } from './toolPatterns.js';
19+
import type { ToolPattern } from './toolPatterns.js';
1820

1921
/**
2022
* Default security blacklist - always blocked patterns for sensitive files and dangerous commands.
@@ -213,6 +215,12 @@ export class PermissionManager {
213215
return { allowed: false, reason: 'blacklisted' };
214216
}
215217

218+
// Pattern-based checks (AFTER security blacklist, BEFORE session cache)
219+
const patternDecision = this.checkPatterns(context);
220+
if (patternDecision) {
221+
return patternDecision;
222+
}
223+
216224
const cacheKey = this.getCacheKey(context);
217225

218226
// Check session cache
@@ -349,6 +357,71 @@ export class PermissionManager {
349357
return `${context.tool}:${dir}/*`;
350358
}
351359

360+
/**
361+
* Convert a PermissionContext to the { kind, target } shape used by matchesToolPattern.
362+
*/
363+
private contextToCall(context: PermissionContext): { kind: string; target: string } {
364+
return { kind: context.tool, target: this.getFullCommand(context) };
365+
}
366+
367+
/**
368+
* Check pattern-based allow/deny rules (denyPatterns, availableTools, excludedTools,
369+
* allowPatterns, allPathsAllowed, allUrlsAllowed).
370+
* Returns a decision when a pattern fires, or null to continue with normal flow.
371+
*/
372+
private checkPatterns(context: PermissionContext): PermissionDecision | null {
373+
const settings = this.getMergedSettings();
374+
const call = this.contextToCall(context);
375+
376+
// 1. denyPatterns – always denied
377+
if (settings.denyPatterns?.length) {
378+
for (const p of settings.denyPatterns) {
379+
if (matchesToolPattern(p, call)) {
380+
return { allowed: false, reason: 'pattern_denied' };
381+
}
382+
}
383+
}
384+
385+
// 2. availableTools – if non-empty, tool must appear in the list
386+
if (settings.availableTools?.length) {
387+
const inAvailable = settings.availableTools.some(p => matchesToolPattern(p, call));
388+
if (!inAvailable) {
389+
return { allowed: false, reason: 'not_in_available' };
390+
}
391+
}
392+
393+
// 3. excludedTools – always denied
394+
if (settings.excludedTools?.length) {
395+
for (const p of settings.excludedTools) {
396+
if (matchesToolPattern(p, call)) {
397+
return { allowed: false, reason: 'excluded' };
398+
}
399+
}
400+
}
401+
402+
// 4. allowPatterns – explicitly allowed
403+
if (settings.allowPatterns?.length) {
404+
for (const p of settings.allowPatterns) {
405+
if (matchesToolPattern(p, call)) {
406+
return { allowed: true, reason: 'pattern_allowed' };
407+
}
408+
}
409+
}
410+
411+
// 5. allPathsAllowed – allow any file-path tool
412+
const fileTools = new Set(['read_file', 'write_file', 'list_dir', 'delete_path', 'move_path', 'copy_path']);
413+
if (settings.allPathsAllowed && fileTools.has(context.tool)) {
414+
return { allowed: true, reason: 'all_paths_allowed' };
415+
}
416+
417+
// 6. allUrlsAllowed – allow url tool
418+
if (settings.allUrlsAllowed && context.tool === 'url') {
419+
return { allowed: true, reason: 'all_urls_allowed' };
420+
}
421+
422+
return null;
423+
}
424+
352425
/**
353426
* Check if context matches the immutable security blacklist
354427
* This check CANNOT be bypassed by any mode, whitelist, or user setting

src/permissions/toolPatterns.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { minimatch } from 'minimatch';
2+
3+
export interface ToolPattern {
4+
kind: string;
5+
argument?: string;
6+
}
7+
8+
export function parseToolPattern(pattern: string): ToolPattern {
9+
const match = pattern.match(/^([^(]+?)\s*\(\s*(.+?)\s*\)\s*$/);
10+
if (match) {
11+
return { kind: match[1]!.trim(), argument: match[2]!.trim() };
12+
}
13+
return { kind: pattern.trim() };
14+
}
15+
16+
export function parseToolPatternList(input: string): ToolPattern[] {
17+
return input.split(',').map(s => parseToolPattern(s.trim())).filter(p => p.kind);
18+
}
19+
20+
export function matchesToolPattern(
21+
pattern: ToolPattern,
22+
call: { kind: string; target: string },
23+
): boolean {
24+
if (pattern.kind !== call.kind) return false;
25+
if (!pattern.argument) return true;
26+
27+
const arg = pattern.argument;
28+
29+
// Stem wildcard: "git:*" matches "git push" (starts with "git ") but not "gitea"
30+
if (arg.endsWith(':*')) {
31+
const stem = arg.slice(0, -2);
32+
return call.target === stem || call.target.startsWith(stem + ' ');
33+
}
34+
35+
// URL domain matching
36+
if (pattern.kind === 'url') {
37+
try {
38+
const url = new URL(call.target);
39+
const domain = url.hostname;
40+
if (arg.startsWith('*.')) {
41+
return domain.endsWith(arg.slice(1));
42+
}
43+
return domain === arg || domain.endsWith('.' + arg);
44+
} catch {
45+
return call.target.includes(arg);
46+
}
47+
}
48+
49+
// Exact match first (fast path)
50+
if (arg === call.target) return true;
51+
52+
// Glob matching for file patterns
53+
if (arg.includes('*') || arg.includes('?')) {
54+
return minimatch(call.target, arg);
55+
}
56+
57+
return false;
58+
}

src/permissions/types.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
* Permission System Types
33
* @license Apache-2.0
44
*/
5+
import type { ToolPattern } from './toolPatterns.js';
56

67
export type PermissionMode = 'interactive' | 'unrestricted' | 'restricted' | 'external';
78

@@ -25,14 +26,28 @@ export interface PermissionSettings {
2526
rules?: PermissionRule[];
2627
/** Remember user decisions for this session */
2728
rememberSession?: boolean;
29+
/** Patterns that are always denied (checked before allowPatterns) */
30+
denyPatterns?: ToolPattern[];
31+
/** Patterns that are always allowed (checked after denyPatterns) */
32+
allowPatterns?: ToolPattern[];
33+
/** If non-empty, only tools matching these patterns are allowed */
34+
availableTools?: ToolPattern[];
35+
/** Tools matching these patterns are always excluded/denied */
36+
excludedTools?: ToolPattern[];
37+
/** If true, all file-path tools are allowed without prompting */
38+
allPathsAllowed?: boolean;
39+
/** If true, all URL-fetching tools are allowed without prompting */
40+
allUrlsAllowed?: boolean;
2841
}
2942

3043
export interface PermissionDecision {
3144
allowed: boolean;
3245
reason:
3346
| 'whitelisted' | 'blacklisted' | 'rule_match' | 'user_approved' | 'user_denied'
3447
| 'mode_unrestricted' | 'mode_restricted' | 'default'
35-
| 'external_approved' | 'external_denied' | 'external_error';
48+
| 'external_approved' | 'external_denied' | 'external_error'
49+
| 'pattern_denied' | 'pattern_allowed' | 'not_in_available' | 'excluded'
50+
| 'all_paths_allowed' | 'all_urls_allowed';
3651
cached?: boolean;
3752
}
3853

0 commit comments

Comments
 (0)