A malicious repo makes an AI coding assistant appear to edit project_settings.json while the actual write lands on ~/.ssh/authorized_keys. The approval UI shows the harmless path. The filesystem follows the symlink.
The bug isn't symlinks. It's this:
An approval prompt is only strong if it shows the resolved operation, not the requested one.
The approval showed a lie
Common agent mistake: approve the visible request instead of what the runtime actually does.
Visible request: Edit project_settings.json
Resolved operation: Write to ~/.ssh/authorized_keys
If the human sees only the first, approval is theater.
Fix: resolve before you approve
Before showing an approval prompt, the runtime should resolve the actual target:
typescriptimport path from "node:path";
import fs from "node:fs/promises";
async function resolveWriteTarget(workspaceRoot: string, requestedPath: string) {
const workspace = await fs.realpath(workspaceRoot);
const absolute = path.resolve(workspace, requestedPath);
const parent = await fs.realpath(path.dirname(absolute));
const resolved = path.join(parent, path.basename(absolute));
return {
requested: absolute,
resolved,
safe: resolved.startsWith(workspace + path.sep),
};
}
Don't approve the string. Approve the canonical target.
Then add a gate:
typescriptasync function beforeFileWrite(workspace, path, content) {
const target = await resolveWriteTarget(workspace, path);
if (!target.safe) {
return { allowed: false, reason: "write_outside_workspace", target };
}
return { allowed: true, target };
}
The check happens before execution. Not after. Not in an audit log.
Same pattern for provider calls
GhostApproval is about files. The design applies to every agent operation.
Before a provider call executes, resolve:
Which model?
Known price?
Which run?
Retry count?
Step count?
Prompt loop?
Budget remaining?
Making progress?
Naive:
typescriptconst result = await provider.call({ model, messages });
Safer:
typescriptconst decision = guard.beforeCall({
runId, model, messages, stepCount, retryCount, budgetRemaining, progressState,
});
if (!decision.allowed) return { status: "stopped", reason: decision.reason };
const result = await provider.call({ model, messages });
The key is placement. Decide before.
Human approval alone isn't enough
GhostApproval is a reminder that formal approval can be practically weak.
If the human sees a simplified story, approval is not informed. If the operation already executed, approval is not a gate. If the runtime hides resolved details, the human approved the wrong thing.
A real approval prompt shows what matters:
typescripttype ApprovalPrompt = {
action: "file_write" | "shell_command" | "provider_call";
requested?: string;
resolved?: string; // The actual target
model?: string;
estimatedCost?: number;
budgetRemaining?: number;
};
Users shouldn't infer the real operation. The runtime should expose it.
The pattern
Resolve the real operation.
Check the policy.
Then decide.
Then execute.
This is what AI CostGuard does for provider calls—pre-call guards that catch retry storms, prompt loops, budget overruns, and step explosions before they happen. Not after. Not in a dashboard. Before.
Takeaway
GhostApproval isn't just a symlink story. It's a runtime design story.
Don't ask humans to approve abstractions. Show them what's actually happening. Make the decision before execution. Then run.
https://github.com/salimassili62-afk/ai-costguard
Top comments (0)