fix(agent): track rewritten xdev approval tiers
This commit is contained in:
@@ -239,6 +239,7 @@ export class ExtensionToolWrapper<TParameters extends TSchema = TSchema, TDetail
|
||||
// short-circuit above.
|
||||
const resolvedArgs = approvalArgs(effectiveParams, context);
|
||||
const resolved = resolveApproval(this.tool, resolvedArgs, approvalMode, userPolicies);
|
||||
context?.xdevTierResolved?.(resolved.tier);
|
||||
if (resolved.policy === "deny") {
|
||||
throw new Error(
|
||||
`Tool "${this.tool.name}" is blocked by user policy.\n` +
|
||||
|
||||
@@ -13,6 +13,9 @@ declare module "@oh-my-pi/pi-agent-core" {
|
||||
* wrapper must not re-prompt for the same action (explicit per-tool
|
||||
* policies and overrides still apply). */
|
||||
xdevApproved?: boolean;
|
||||
/** Reports the approval tier resolved after an extension rewrites an
|
||||
* xd:// device call, so dispatch metadata describes the input that ran. */
|
||||
xdevTierResolved?(tier: "read" | "write" | "exec"): void;
|
||||
/** Set only after an interactive prompt approves provider computer safety checks. */
|
||||
providerSafetyApproved?: boolean;
|
||||
}
|
||||
|
||||
@@ -444,7 +444,15 @@ export async function dispatchXdevTool(
|
||||
})
|
||||
: undefined;
|
||||
const executable = state.decorateExecution?.(canonical) ?? canonical;
|
||||
const result = await executable.execute(toolCallId, validated as never, signal, innerOnUpdate, context);
|
||||
const executionContext = context
|
||||
? {
|
||||
...context,
|
||||
xdevTierResolved: (effectiveTier: ToolTier) => {
|
||||
xdev = { ...xdev, tier: effectiveTier };
|
||||
},
|
||||
}
|
||||
: undefined;
|
||||
const result = await executable.execute(toolCallId, validated as never, signal, innerOnUpdate, executionContext);
|
||||
return { result, xdev: { ...xdev, inner: result.details } };
|
||||
} catch (error) {
|
||||
if (
|
||||
|
||||
@@ -2542,6 +2542,50 @@ describe("ExtensionRunner", () => {
|
||||
expect(fs.existsSync(recordPath)).toBe(false); // tool never executed
|
||||
});
|
||||
|
||||
it("reports the effective tier after a tool_call handler revises xd:// input", async () => {
|
||||
const recordPath = path.join(tempDir.path(), "xdev-effective-tier.jsonl");
|
||||
const extCode = `
|
||||
export default function(pi) {
|
||||
pi.on("tool_call", async (event) => {
|
||||
if (event.toolName !== "bash") return;
|
||||
return { input: { command: "echo revised" } };
|
||||
});
|
||||
}
|
||||
`;
|
||||
fs.writeFileSync(path.join(extensionsDir, "tool-call-xdev-tier.ts"), extCode);
|
||||
|
||||
const result = await loadTestExtensions();
|
||||
const runner = new ExtensionRunner(
|
||||
result.extensions,
|
||||
result.runtime,
|
||||
tempDir.path(),
|
||||
sessionManager,
|
||||
modelRegistry,
|
||||
);
|
||||
const tool = createRecordingTool(recordPath);
|
||||
tool.approval = args =>
|
||||
args &&
|
||||
typeof args === "object" &&
|
||||
"command" in args &&
|
||||
typeof args.command === "string" &&
|
||||
args.command.includes("revised")
|
||||
? "write"
|
||||
: "read";
|
||||
const wrapped = new ExtensionToolWrapper(tool, runner);
|
||||
let effectiveTier: string | undefined;
|
||||
const xdevContext = {
|
||||
settings: { get: (key: string) => (key === "tools.approvalMode" ? "yolo" : {}) },
|
||||
xdevApproved: true,
|
||||
xdevTierResolved: (tier: string) => {
|
||||
effectiveTier = tier;
|
||||
},
|
||||
} as never;
|
||||
|
||||
await wrapped.execute("xdev-tier-id", { command: "echo original" }, undefined, undefined, xdevContext);
|
||||
expect(JSON.parse(fs.readFileSync(recordPath, "utf8"))).toEqual({ command: "echo revised" });
|
||||
expect(effectiveTier).toBe("write");
|
||||
});
|
||||
|
||||
it("emits tool_call before the approval prompt so approval sees the final input", async () => {
|
||||
const order: string[] = [];
|
||||
const extCode = `
|
||||
|
||||
@@ -131,6 +131,45 @@ describe("read and write route xd:// device URLs", () => {
|
||||
expect(result.details?.xdev).toMatchObject({ tool: "peek", mode: "execute", tier: "read" });
|
||||
});
|
||||
|
||||
it("records the effective tier reported after an execution decorator rewrites device args", async () => {
|
||||
let executedQuery: string | undefined;
|
||||
const device: AgentTool = {
|
||||
name: "peek",
|
||||
label: "Peek",
|
||||
description: "Argument-dependent device",
|
||||
parameters: type({ q: "string" }),
|
||||
approval: args =>
|
||||
args && typeof args === "object" && "q" in args && args.q === "mutate" ? "write" : "read",
|
||||
async execute(_id, args) {
|
||||
if (!args || typeof args !== "object" || !("q" in args) || typeof args.q !== "string") {
|
||||
throw new Error("Expected a string query");
|
||||
}
|
||||
executedQuery = args.q;
|
||||
return { content: [{ type: "text", text: "done" }] };
|
||||
},
|
||||
};
|
||||
const xdev = createTestXdevState([device]);
|
||||
xdev.decorateExecution = canonical => ({
|
||||
...canonical,
|
||||
async execute(id, _args, signal, onUpdate, context) {
|
||||
const revised = { q: "mutate" };
|
||||
context?.xdevTierResolved?.("write");
|
||||
return canonical.execute(id, revised as never, signal, onUpdate, context);
|
||||
},
|
||||
});
|
||||
const write = new WriteTool(xdevSession(process.cwd(), { xdev }));
|
||||
|
||||
const result = await write.execute(
|
||||
"write-xdev-revised",
|
||||
{ path: "xd://peek", content: JSON.stringify({ q: "inspect" }) },
|
||||
undefined,
|
||||
undefined,
|
||||
{} as never,
|
||||
);
|
||||
expect(executedQuery).toBe("mutate");
|
||||
expect(result.details?.xdev?.tier).toBe("write");
|
||||
});
|
||||
|
||||
it("rejects near-miss xd addresses before filesystem fallback", async () => {
|
||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "write-xdev-near-miss-"));
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user