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
29 changes: 23 additions & 6 deletions packages/core/src/loop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,24 @@ export const modelContext = (history: readonly HistoryMessage[]): Message[] => h
* Runs one tool call: an unknown or inactive tool, invalid arguments or an
* exception become a failed call the model sees.
*/
/** The operator's answer as a decision and its reason; none when the run was stopped before they answered. */
function operatorDecision(answer: "once" | "session" | "deny" | undefined): { decision: "allow" | "deny"; reason: string } {
if (answer === undefined) return { decision: "deny", reason: "The run was stopped before the operator answered." };
if (answer === "deny") return { decision: "deny", reason: "The operator refused it." };
return { decision: "allow", reason: answer === "session" ? "The operator allowed it for this session." : "The operator allowed it once." };
}

/** What `promise` gives, or nothing once `signal` stops first; the promise itself is left to settle on its own. */
function untilStopped<T>(promise: Promise<T>, signal: AbortSignal): Promise<T | undefined> {
if (signal.aborted) return Promise.resolve(undefined);
return new Promise((resolve, reject) => {
const stop = (): void => { resolve(undefined); };
signal.addEventListener("abort", stop, { once: true });
promise.then((value) => { signal.removeEventListener("abort", stop); resolve(value); },
(error: unknown) => { signal.removeEventListener("abort", stop); reject(error); });
});
}

async function execute(tools: readonly EngineTool[], call: ToolCall, signal: AbortSignal): Promise<ToolOutput> {
const tool = tools.find((candidate) => candidate.name === call.name);
if (tool === undefined) return { text: `Tool "${call.name}" not found.`, isError: true };
Expand Down Expand Up @@ -454,7 +472,7 @@ export function createLoop(options: LoopOptions): LoopSession {
* (`judge`), and the operator answers what they leave to them, once or for
* the session. Every decision is recorded before the call runs, or instead.
*/
async function permitted(run: number, call: ToolCall, offered: readonly EngineTool[]): Promise<boolean> {
async function permitted(run: number, call: ToolCall, offered: readonly EngineTool[], signal: AbortSignal): Promise<boolean> {
const permissions = options.permissions;
const access = offered.find((tool) => tool.name === call.name)?.access ?? "run";
const key = `${call.name}\n${JSON.stringify(call.arguments)}`;
Expand All @@ -466,11 +484,10 @@ export function createLoop(options: LoopOptions): LoopSession {
by = "session";
reason = "The operator allowed it for this session.";
} else if (verdict.decision === "ask") {
const answer = await permissions?.ask?.(call, verdict.reason) ?? "deny";
decision = answer === "deny" ? "deny" : "allow";
// A stop while the operator is asked ends the wait: the run does not wait for an answer that no longer matters.
const answer = await untilStopped(permissions?.ask?.(call, verdict.reason) ?? Promise.resolve("deny" as const), signal);
({ decision, reason } = operatorDecision(answer));
by = "operator";
reason = answer === "deny" ? "The operator refused it." : answer === "session" ? "The operator allowed it for this session."
: "The operator allowed it once.";
if (answer === "session") allowedForSession.add(key);
}
writer.append({ type: "permission", run, callId: call.id, decision, by, reason });
Expand Down Expand Up @@ -527,7 +544,7 @@ export function createLoop(options: LoopOptions): LoopSession {
let output: ToolOutput;
if (message.stopReason === "length") output = truncatedCall(call);
else if (signal.aborted) output = stoppedCall;
else if (!await permitted(run, call, offered)) output = refusedCall(call);
else if (!await permitted(run, call, offered, signal)) output = signal.aborted ? stoppedCall : refusedCall(call);
else output = await hooked(run, call, signal, offered);
const result = toolResult(call, output);
writer.append({ type: "tool_result", run, message: result });
Expand Down
22 changes: 22 additions & 0 deletions packages/core/tests/permissions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,28 @@ describe("the boundary every call passes", () => {
expect(entries(created.file).some(({ type }) => type === "call_intent")).toBe(false);
});

test("stopped while the operator has not answered, the run ends without waiting for the answer, and the call does not run", async () => {
const faux = fauxProvider();
faux.setResponses([write("a.txt"), "done"].map(scriptedReply));
const models = createModels();
models.setProvider(faux.provider);
const cwd = folder("tesota-core-permissions-cwd-");
let asked: () => void = () => undefined;
const asking = new Promise<void>((resolve) => { asked = resolve; });
// The question is never answered, as when no one is there to see it.
const created = createSession({ cwd, folder: folder("tesota-core-sessions-"), models, model: faux.getModel(), verifier: accepts,
permissions: { mode: () => "ask", rules: [], ask: () => { asked(); return new Promise(() => undefined); } } });
open.push(created);
const run = created.prompt("go");
await asking;
await created.abort();
await run;
expect(created.outcome()).toBe("aborted");
expect(existsSync(join(cwd, "a.txt"))).toBe(false);
expect(entries(created.file)).toContainEqual(expect.objectContaining({ type: "permission", decision: "deny",
reason: "The run was stopped before the operator answered." }));
});

test("with no one to ask, a call that would ask is refused; read only refuses writes; the floor holds in full access", async () => {
const alone = session([write("a.txt"), "done"], "ask");
await alone.created.prompt("go");
Expand Down
Loading