Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions COMMANDS.md
Original file line number Diff line number Diff line change
Expand Up @@ -574,6 +574,15 @@ aether config set autoApply true
| `autoApply` | bool | Apply streamed edits without a per-edit prompt. |
| `telemetry` | bool | Anonymous usage telemetry opt-in. |

When an interactive coding run asks to execute a tool, answering no opens a
separate optional instruction field for that one declined call. Type a note
such as `Use the offline unit suite.`, then press Enter; an empty Enter skips
it, and Escape or Ctrl+C cancels the note. The note is limited to 512 UTF-8
bytes and reaches the model once inside that call's failed tool result. It
never approves or runs the tool. The main draft, prompt history, queued tasks,
and reviewed shell attachment are untouched. Non-TTY decisions and `--yes`
retain their existing behavior and do not produce an instruction.

---

## Slash commands (inside the REPL)
Expand Down
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,11 @@ Ctrl+R searches this workspace's submitted prompts newest first. Type to narrow
matches, repeat Ctrl+R for older ones, Enter to insert a match for editing, or
Escape to restore your draft. `AETHER_NO_HISTORY=1` disables prompt history.

If you decline a model-requested tool call, you can add one short instruction
for that denial, such as `Use the offline unit suite.` The tool stays blocked;
the instruction is returned once with its failed result. Enter skips the note,
and Escape cancels it. [Approval details](COMMANDS.md#aether-config-showgetset--local-settings).

Type `/` at an idle raw terminal prompt to browse described commands. Use
arrows or Tab to choose, Enter to insert editable command text, then Enter
again to run it; Escape restores your earlier draft.
Expand Down
29 changes: 20 additions & 9 deletions src/commands/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ import { ConsoleQueue, describeEntry, entryKind, parseQueueCommand, renderDispos
import { HostRenderer } from "../ui/host_render.js";
import type { TaskCommand } from "../core/brain.js";
import { getRegistry } from "../core/context_registry.js";
import { prepareToolApproval, requestToolApproval, terminalSafeReview } from "../core/tool_approval.js";
import { bindToolApprovalVerdict, deniedToolResult, prepareToolApproval, requestToolApproval, terminalSafeReview, type ToolApprovalVerdict } from "../core/tool_approval.js";
import { openRunSession, refusalToolResult } from "../core/skills/run_session.js";
import {
TRANSIENT_READ_AUTO_RETRIES,
Expand Down Expand Up @@ -859,25 +859,29 @@ export async function runLocalTurn(
});
const brain = deps.brain ?? new OllamaBrain({ model, ...(deps.advertisedTools ? { tools: deps.advertisedTools } : {}) });
const exec = deps.exec ?? new ToolExecutor(cwd);
const seenCallIds = new Set<string>();
const controller = new AbortController();
const renderer = new HostRenderer({ poolGb: 5, json: ctx.flags.json });
const pulse = new ThinkingPulse({
enabled: Boolean(process.stderr.isTTY) && !ctx.flags.json && process.env["AETHER_NO_ANIM"] !== "1",
write: (s) => process.stderr.write(errTheme.dim(s)),
onPaint: deps.onPulsePaint,
});
const approveTool = async (name: string, args: Record<string, unknown>): Promise<boolean> => {
const approveTool = async (callId: string, name: string, args: Record<string, unknown>): Promise<ToolApprovalVerdict> => {
let patchPreview: string | undefined;
if (name === "patch_file" && exec.previewPatch) {
const preview = exec.previewPatch(args);
process.stderr.write(terminalSafeReview(preview.output) + "\n");
if (preview.exitCode !== 0) return true;
if (preview.exitCode !== 0) return { callId, approved: true };
patchPreview = preview.output;
}
return requestToolApproval({
callId,
name, args: args as Record<string, string | number>,
permissionMode: ctx.cfg.permissionMode, autoApply: ctx.cfg.autoApply,
yes: ctx.flags.yes, isTty: Boolean(process.stdin.isTTY),
shellCwd: exec.shellCwd ?? cwd, fileRoot: cwd, confirm: ctx.confirm,
...(ctx.approvalFeedback ? { feedback: () => ctx.approvalFeedback!(controller.signal) } : {}),
patchPreview,
onDeny: () => process.stderr.write(`blocked ${name}: confirmation required; use --yes or permissionMode skip\n`),
});
Expand All @@ -893,7 +897,6 @@ export async function runLocalTurn(
let partialOutput = false;
let terminalError: ChatTurnError | null = null;
const timeoutMs = deps.meaningfulProgressTimeoutMs ?? defaultStreamTimeoutMs();
const controller = new AbortController();
const forwardAbort = (): void => controller.abort(signal?.reason ?? new DOMException("turn cancelled", "AbortError"));
const closeBrain = (): void => {
try { brain.close(); } catch { /* cleanup cannot replace the primary outcome */ }
Expand Down Expand Up @@ -953,6 +956,8 @@ export async function runLocalTurn(
break;
}
if (ev.type === "tool_call") {
if (seenCallIds.has(ev.id)) continue; // one result and no second execution for one call ID
seenCallIds.add(ev.id);
noteWaitingForTool(lifecycle);
renderer.event(ev);
// Same ordering as hostLoop (commands/code.ts): the skill narrowing is
Expand Down Expand Up @@ -1028,13 +1033,13 @@ export async function runLocalTurn(
continue;
}
const approvalContext = exec.shellContext;
const approved = await boundedLocalOperation(
() => approveTool(ev.name, prepared.args),
const approval = bindToolApprovalVerdict(ev.id, await boundedLocalOperation(
() => approveTool(ev.id, ev.name, prepared.args),
controller.signal,
timeoutMs,
lastMeaningfulAt,
timeout,
);
));
const execute = (): Promise<ToolResult> => {
const remaining = timeoutMs > 0
? Math.max(1, timeoutMs - (Date.now() - lastMeaningfulAt))
Expand All @@ -1053,8 +1058,8 @@ export async function runLocalTurn(
timeout,
);
};
if (!approved) {
deliver({ output: `[tool ${ev.name} blocked: permission denied]`, exitCode: 1 }, "approval");
if (!approval.approved) {
deliver(deniedToolResult(ev.name, approval), "approval");
} else if (!supersededBySteer()) {
// Approval can take a while; a steer accepted during the prompt is
// checked above, so an approved-but-stale write still never runs.
Expand Down Expand Up @@ -1330,6 +1335,12 @@ export async function repl(ctx: AppContext, skillOpts: TurnSkillOptions = {}): P
try { return await ctx.confirm(question); }
finally { approvalOwnsInput = false; }
},
approvalFeedback: async (signal) => {
buf.endRecoveryScope();
approvalOwnsInput = true;
try { return await ctx.approvalFeedback?.(signal) ?? null; }
finally { approvalOwnsInput = false; }
},
};
const renderHudLine = (): void => {
if (!process.stdout.isTTY) return;
Expand Down
35 changes: 25 additions & 10 deletions src/commands/code.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ import { parseRepoSpec, ensureLocalClone, type RepoSpec } from "../core/repo.js"
import { chooseBackend, chooseLocalBrain } from "../core/backend.js";
import { ModelTextProgress } from "../core/model_text_progress.js";
import { ModelOutputBudget } from "../core/model_output_budget.js";
import { prepareToolApproval, requestToolApproval, terminalSafeReview } from "../core/tool_approval.js";
import { bindToolApprovalVerdict, deniedToolResult, prepareToolApproval, requestToolApproval, terminalSafeReview, type ToolApprovalVerdict } from "../core/tool_approval.js";
import { openRunSession, refusalToolResult } from "../core/skills/run_session.js";
import type { SessionContext } from "../core/session_resume.js";
import type { SkillSessionProvenance } from "../core/skills/skill_session.js";
Expand Down Expand Up @@ -159,7 +159,7 @@ export function codeMeaningfulProgressTimeoutMs(
}

/** Approve (or refuse) one brain-emitted tool call before the host executes it. */
export type ToolGate = (call: { name: string; args: Record<string, unknown> }) => Promise<boolean>;
export type ToolGate = (call: { id: string; name: string; args: Record<string, unknown> }) => Promise<ToolApprovalVerdict | boolean>;

/** Read-only correlation hooks for an explicit saved-goal phase run. */
export interface CodeRunStarted {
Expand Down Expand Up @@ -1116,33 +1116,36 @@ export async function cmdCode(
if (result.exitCode !== 0 && hostRefusals.length < 16
&& (/^\[(?:denied:|refused by host policy:|tool .* rejected:|unknown tool:)/i.test(result.output)
|| result.exitCode === 127)) {
hostRefusals.push(redactInline(sanitizeServerText(result.output)).slice(0, 240));
hostRefusals.push(redactInline(sanitizeServerText(result.output.split("\n", 1)[0]!)).slice(0, 240));
}
};
let confirmToolReview = ctx.confirm;
let confirmToolFeedback = ctx.approvalFeedback;

// Permission gate: every brain-emitted mutating/shell tool call is approved
// here before the host runs it. Honors the configured permission mode + auto-
// apply; in `ask` (the default) on a TTY the user gets a y/N prompt, and on a
// non-TTY (CI/pipe) an un-pre-approved call FAILS CLOSED rather than running
// unattended. `--yes` or `permissionMode: skip` opt out.
const gate: ToolGate = async ({ name, args }) => {
const gate: ToolGate = async ({ id, name, args }) => {
if (opts.forbidPublication && isPublicationToolCall(name, args)) {
process.stderr.write("✗ publishing is a separate ship action; this goal phase tool call was refused.\n");
return false;
return { callId: id, approved: false };
}
let patchPreview: string | undefined;
if (name === "patch_file") {
const preview = exec.previewPatch(args);
process.stderr.write(terminalSafeReview(preview.output) + "\n");
if (preview.exitCode !== 0) return true; // executor returns the same conflict to the brain
if (preview.exitCode !== 0) return { callId: id, approved: true }; // executor returns the same conflict to the brain
patchPreview = preview.output;
}
return requestToolApproval({
callId: id,
name, args: args as Record<string, string | number>,
permissionMode: ctx.cfg.permissionMode, autoApply: ctx.cfg.autoApply,
yes: ctx.flags.yes, isTty: Boolean(process.stdin.isTTY),
shellCwd: exec.shellCwd, fileRoot: cwd, confirm: confirmToolReview,
...(confirmToolFeedback ? { feedback: () => confirmToolFeedback!(commandAbort.signal) } : {}),
patchPreview,
onDeny: () => process.stderr.write(
`✗ blocked ${name} — permission mode "${ctx.cfg.permissionMode}" needs confirmation but there is no TTY.\n` +
Expand Down Expand Up @@ -1191,6 +1194,14 @@ export async function cmdCode(
try { return await ctx.confirm(review); }
finally { resume(); }
};
if (confirmToolFeedback) {
const requestFeedback = confirmToolFeedback;
confirmToolFeedback = async (signal) => {
const resume = sr.pauseForPrompt();
try { return await requestFeedback(signal); }
finally { resume(); }
};
}
replay((line) => sr.log(line));
const anim = new AnimationController({
onFrame: (_stage, art) => sr.setAnim(art),
Expand Down Expand Up @@ -1523,6 +1534,7 @@ export async function hostLoop(
const timeoutMs = options.meaningfulProgressTimeoutMs ?? DEFAULT_CODE_MEANINGFUL_PROGRESS_TIMEOUT_MS;
const segmentTimeoutMs = options.modelSegmentTimeoutMs ?? codeModelSegmentTimeoutMs();
const signal = options.signal;
const seenCallIds = new Set<string>();
const failures =
options.failureBudget === false ? null : (options.failureBudget ?? defaultToolFailureBudget(task.cwd, exec));
let lastMeaningfulAt = Date.now();
Expand Down Expand Up @@ -1555,6 +1567,8 @@ export async function hostLoop(
let terminal = false;
switch (ev.type) {
case "tool_call": {
if (seenCallIds.has(ev.id)) break; // never re-execute or re-deliver one call ID
seenCallIds.add(ev.id);
// The host owns execution + the path-guard. A tool call is gated FIRST
// (permission mode); a denied call is never executed — the brain gets a
// synthetic refusal result so the loop continues without running it.
Expand Down Expand Up @@ -1628,18 +1642,19 @@ export async function hostLoop(
break;
}
const approvalContext = exec.shellContext;
const approved = gate
const gateDecision = gate
? await boundedCodeOperation(
() => gate({ name: ev.name, args: prepared.args }),
() => gate({ id: ev.id, name: ev.name, args: prepared.args }),
timeoutMs,
lastMeaningfulAt,
signal,
segmentTimeoutMs,
modelSegmentStartedAt,
)
: true;
if (!approved) {
deliver({ output: `[denied: ${ev.name} not approved by user]`, exitCode: 1 }, "approval");
const approval = bindToolApprovalVerdict(ev.id, gateDecision);
if (!approval.approved) {
deliver(deniedToolResult(ev.name, approval), "approval");
break;
}
const execute = (): Promise<ToolResult> => {
Expand Down
2 changes: 2 additions & 0 deletions src/core/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,4 +51,6 @@ export interface AppContext {
/** Ask the user a yes/no question (readline y/N; `--yes` short-circuits).
* Injected so slash-command confirmations are testable. */
confirm: (q: string) => Promise<boolean>;
/** One declined tool call's optional instruction; a separate input lease. */
approvalFeedback?: (signal?: AbortSignal) => Promise<string | null>;
}
51 changes: 46 additions & 5 deletions src/core/tool_approval.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,10 +57,47 @@ export function formatToolApprovalReview(
const diff = name === "patch_file" && patchPreview
? `\nAffected diff (escaped for terminal safety):\n${terminalSafeReview(patchPreview)}\n`
: "";
return `\nModel-requested tool approval\nTool: ${name}\nShell cwd: ${terminalSafeReview(shellCwd, false)}\nFile root: ${terminalSafeReview(fileRoot, false)}\n${field} (numbered rows preserve line breaks; controls and backslashes are escaped):\n${lines}${diff}\nRun this exact tool call? [y/N] `;
return `\nModel-requested tool approval\nTool: ${name}\nShell cwd: ${terminalSafeReview(shellCwd, false)}\nFile root: ${terminalSafeReview(fileRoot, false)}\n${field} (numbered rows preserve line breaks; controls and backslashes are escaped):\n${lines}${diff}\nDeclining offers an optional one-call instruction.\nRun this exact tool call? [y/N] `;
}

export const MAX_DENIAL_FEEDBACK_BYTES = 512;

/** Feedback is inert tool-result text, never a command or an approval. */
export function boundedDenialFeedback(raw: string | null | undefined): string | null {
if (!raw) return null;
const cleaned = raw.replace(/[\r\n\t]+/g, " ")
.replace(/[\x00-\x1f\x7f-\x9f\u2028\u2029\p{Cf}]/gu, "").trim();
let bounded = "";
for (const character of cleaned) {
if (Buffer.byteLength(bounded + character, "utf8") > MAX_DENIAL_FEEDBACK_BYTES) break;
bounded += character;
}
return bounded || null;
}

export interface ToolApprovalVerdict {
readonly callId: string;
readonly approved: boolean;
/** Present only for a declined, interactive call. */
readonly feedback?: string;
}

/** Existing embedders may still return a boolean; bind it to this exact call. */
export function bindToolApprovalVerdict(callId: string, decision: ToolApprovalVerdict | boolean): ToolApprovalVerdict {
if (typeof decision === "boolean") return { callId, approved: decision };
if (decision.callId !== callId) return { callId, approved: false };
if (decision.approved) return { callId, approved: true };
const feedback = boundedDenialFeedback(decision.feedback);
return { callId, approved: false, ...(feedback ? { feedback } : {}) };
}

export function deniedToolResult(name: string, verdict: ToolApprovalVerdict): { output: string; exitCode: number } {
const base = `[denied: ${name} not approved by user]`;
return { output: verdict.feedback ? `${base}\nOperator instruction for this denied call: ${verdict.feedback}` : base, exitCode: 1 };
}

export interface ToolApprovalRequest {
callId: string;
name: string;
args: ValidatedToolArgs;
permissionMode: PermissionMode;
Expand All @@ -71,20 +108,24 @@ export interface ToolApprovalRequest {
fileRoot: string;
patchPreview?: string;
confirm: (review: string) => Promise<boolean>;
feedback?: () => Promise<string | null>;
onDeny: () => void;
}

export async function requestToolApproval(request: ToolApprovalRequest): Promise<boolean> {
export async function requestToolApproval(request: ToolApprovalRequest): Promise<ToolApprovalVerdict> {
const outcome = decideGate(request.name, request.permissionMode, request.autoApply, {
yes: request.yes,
isTty: request.isTty,
});
if (outcome === "allow") return true;
if (outcome === "allow") return { callId: request.callId, approved: true };
if (outcome === "deny") {
request.onDeny();
return false;
return { callId: request.callId, approved: false };
}
return request.confirm(formatToolApprovalReview(
const approved = await request.confirm(formatToolApprovalReview(
request.name, request.args, request.shellCwd, request.fileRoot, request.patchPreview,
));
if (approved) return { callId: request.callId, approved: true };
const feedback = boundedDenialFeedback(await request.feedback?.());
return { callId: request.callId, approved: false, ...(feedback ? { feedback } : {}) };
}
3 changes: 2 additions & 1 deletion src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { cmdRun } from "./commands/run.js";
import { cmdCode } from "./commands/code.js";
import { promptFileConflict, readPromptFile } from "./commands/prompt_file.js";
import { errTheme } from "./ui/theme.js";
import { promptDenialFeedback } from "./ui/approval_feedback.js";
// VERSION is imported ONCE from the generated version.js — main.ts must never
// hardcode a duplicate version string that can drift from package.json.
import { VERSION } from "./version.js";
Expand Down Expand Up @@ -179,7 +180,7 @@ export async function main(argv: string[]): Promise<number> {
});
const ctx: AppContext = {
cfg, api, tokens, driveStaffTokens: new FileTokenStore(".drive-staff-session"),
flags, confirm,
flags, confirm, approvalFeedback: (signal) => promptDenialFeedback({ signal }),
};

const loginOpts: LoginOpts = {
Expand Down
Loading
Loading