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
10 changes: 7 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,10 @@ This is a Next.js 15 fullstack AI agent chat application using LangGraph.js with
- **Tool Approval**: Human-in-the-loop via `humanInTheLoopMiddleware`. Approval is gated **per-tool**
through an `interruptOn` map that lists only **mutating** tools (`log_expense`,
`import_transactions_csv`, `create_category`, `set_config`); read tools (incl. `run_sql`) and MCP tools
auto-approve. `approveAllTools` omits the middleware entirely. Decisions: `allow`→approve,
auto-approve. **The gate cannot be switched off from the product**: no UI control, no query
param, and nothing on `MessageOptions` can disable it — `bypassApprovalForEval` omits the
middleware for the eval harness alone, which calls the agent factory in-process and has no human
to answer an interrupt (`approvalGate.test.ts` pins that boundary). Decisions: `allow`→approve,
`deny`→reject (with an explanatory follow-up). `MUTATING_TOOL_NAMES` lives in
`src/lib/agent/mutatingTools.ts` — a **zero-import leaf**; `index.ts` and `capabilities.ts` both
re-use it from there. It is its own module because **client components need it** (the approval
Expand Down Expand Up @@ -333,7 +336,8 @@ Instructions loaded on demand, in the standard `SKILL.md` format — full detail
### API Route Patterns

- Stream endpoints use `dynamic = "force-dynamic"` and `runtime = "nodejs"`
- Query params for streaming: `content`, `threadId`, `model`, `provider`, `allowTool`, `approveAllTools`
- Query params for streaming: `content`, `threadId`, `model`, `provider`, `allowTool`
(deliberately NO approval-bypass param — see the approval workflow above)
- MCP server CRUD follows REST patterns in `/api/mcp-servers/route.ts`
- File upload endpoint: `/api/agent/upload` accepts multipart/form-data, returns file metadata

Expand Down Expand Up @@ -456,7 +460,7 @@ pnpm typecheck:eval # free — the root tsc misses this
`toolCalled` is a set check — "asked, saved, then logged" and "logged under a guess, then saved"
leave the same rows in the same tables, and only the order says which happened.
- **Approval cases** set `approval: "allow" | "deny"`, which keeps the HITL middleware live (plain
`approveAllTools` omits it entirely). Paused calls land on `RunCapture.interrupts` — the only
`bypassApprovalForEval` omits it entirely). Paused calls land on `RunCapture.interrupts` — the only
evidence the gate fired, since `trajectory` looks identical either way. These cases mutate, so the
fixture is re-seeded before each run.
- **Every run writes `eval/results/latest.json`** (plus a timestamped copy; both gitignored) with
Expand Down
7 changes: 3 additions & 4 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,8 +114,9 @@ async function buildAgent(cfg?: AgentConfigOptions) {
const interruptOn = Object.fromEntries(
MUTATING_TOOL_NAMES.map((name) => [name, { allowedDecisions: ["approve", "reject"] }]),
);
// approveAllTools omits the middleware entirely, so no interrupt is ever created.
const middleware = cfg?.approveAllTools
// bypassApprovalForEval omits the middleware entirely, so no interrupt is ever created. It is
// reachable ONLY from the eval harness — no request can set it.
const middleware = cfg?.bypassApprovalForEval
? []
: [
humanInTheLoopMiddleware({
Expand Down Expand Up @@ -186,7 +187,6 @@ export async function streamResponse(params: {
const agent = await ensureAgent({
model: opts?.model,
tools: opts?.tools,
approveAllTools: opts?.approveAllTools,
});

// Handle tool approval (HITL resume) vs normal input. On resume we read the pending HITL request
Expand Down Expand Up @@ -838,7 +838,6 @@ logger.info("Agent processing started", {
threadId,
model: opts?.model,
toolCount: tools.length,
approveAllTools: opts?.approveAllTools,
});
```

Expand Down
2 changes: 1 addition & 1 deletion eval/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -374,7 +374,7 @@ and tool traffic filtered out — and needs no extra API key, since it uses the
## Approval cases

Setting `approval: "allow" | "deny"` on a case runs it with the human-in-the-loop middleware **live**
(without it, `approveAllTools` omits the middleware and nothing ever pauses). The runner reads the
(without it, `bypassApprovalForEval` omits the middleware and nothing ever pauses). The runner reads the
pending request from the checkpoint and resumes with a `Command`, mirroring `buildResumeCommand` in
[src/services/agentService.ts](../src/services/agentService.ts).

Expand Down
2 changes: 1 addition & 1 deletion eval/cases/approval.cases.mts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import type { EvalCase } from "../types.mts";

/**
* The approval gate — Cameron's first hard rule: "never moves money without explicit human
* approval." Until now this was UNEVALUABLE, because the harness passed `approveAllTools: true`,
* approval." Until now this was UNEVALUABLE, because the harness passed the approval bypass,
* which removes the middleware entirely.
*
* These cases set `approval`, so the middleware runs for real and the runner answers the interrupt
Expand Down
7 changes: 4 additions & 3 deletions eval/run.mts
Original file line number Diff line number Diff line change
Expand Up @@ -140,12 +140,13 @@ async function main() {
// After the reset, or the seeded settings would be wiped by it.
if (testCase.config) await seedConfig(testCase.config);

// A fresh agent per run: no checkpointer state leaks between runs. `approveAllTools`
// omits the HITL middleware entirely, so a case testing the gate must NOT set it.
// A fresh agent per run: no checkpointer state leaks between runs.
// `bypassApprovalForEval` omits the HITL middleware entirely — it is the harness-only
// escape hatch (no HTTP path can set it), so a case testing the gate must NOT set it.
const agent = await getAgent({
provider: MODEL.provider,
model: MODEL.name,
approveAllTools: !testCase.approval,
bypassApprovalForEval: !testCase.approval,
});
const t0 = Date.now();
const capture = await runCase(agent as never, testCase);
Expand Down
2 changes: 0 additions & 2 deletions src/app/api/agent/stream/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@ export async function GET(req: NextRequest) {
const provider = searchParams.get("provider") || undefined;
const allowTool = searchParams.get("allowTool") as "allow" | "deny" | null;
const toolsParam = searchParams.get("tools") || "";
const approveAllTools = searchParams.get("approveAllTools") === "true";
const attachmentsParam = searchParams.get("attachments") || "";

const tools = toolsParam
Expand Down Expand Up @@ -62,7 +61,6 @@ export async function GET(req: NextRequest) {
provider,
tools,
allowTool: allowTool || undefined,
approveAllTools,
attachments,
},
});
Expand Down
4 changes: 0 additions & 4 deletions src/app/api/agent/stream/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,6 @@ const StreamQuery = z.object({
.string()
.optional()
.openapi({ description: "Comma-separated list of enabled tool names" }),
approveAllTools: z
.enum(["true", "false"])
.optional()
.openapi({ description: "Skip per-tool approval prompts" }),
attachments: z
.string()
.optional()
Expand Down
29 changes: 1 addition & 28 deletions src/components/MessageInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,7 @@ export const MessageInput = ({
const [attachments, setAttachments] = useState<FileAttachment[]>([]);
const [isUploading, setIsUploading] = useState(false);

const { provider, setProvider, model, setModel, approveAllTools, setApproveAllTools } =
useUISettings();
const { provider, setProvider, model, setModel } = useUISettings();

const textareaRef = useRef<HTMLTextAreaElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
Expand Down Expand Up @@ -121,7 +120,6 @@ export const MessageInput = ({
model,
provider,
tools: [],
approveAllTools: approveAllTools,
attachments: attachments.length > 0 ? attachments : undefined,
});
setMessage("");
Expand Down Expand Up @@ -253,31 +251,6 @@ export const MessageInput = ({
{remainingChars}
</span>

{/* The approval gate is Cameron's first rule — the switch that disables it is labelled. */}
<label className="flex cursor-pointer items-center gap-2 select-none">
<input
type="checkbox"
className="peer sr-only"
checked={!approveAllTools}
onChange={(e) => setApproveAllTools(!e.target.checked)}
aria-label="Approval gate"
/>
<span
className={`peer-focus-visible:ring-ring relative block h-4 w-7 rounded-full border transition-colors peer-focus-visible:ring-2 ${
approveAllTools ? "bg-muted border-border" : "bg-brand/25 border-brand"
}`}
>
<span
className={`absolute top-0.5 left-0.5 block h-2.5 w-2.5 rounded-full transition-transform ${
approveAllTools ? "bg-muted-foreground" : "bg-brand translate-x-3"
}`}
/>
</span>
<span className="text-muted-foreground font-mono text-[10px]">
{approveAllTools ? "gate off" : "gate on"}
</span>
</label>

<Button
type="submit"
size="sm"
Expand Down
6 changes: 2 additions & 4 deletions src/components/Thread.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ export const Thread = ({ threadId }: ThreadProps) => {
const { messages, isLoadingHistory, isSending, sendMessage, approveToolExecution } =
useChatThread({ threadId: activeId });
const { createThread } = useThreads();
const { provider, model, approveAllTools } = useUISettings();
const { provider, model } = useUISettings();
// Guards against a double-send racing two thread creations before the URL has been adopted.
const creatingRef = useRef<Promise<string> | null>(null);

Expand Down Expand Up @@ -104,9 +104,7 @@ export const Thread = ({ threadId }: ThreadProps) => {
<button
key={example}
type="button"
onClick={() =>
handleSendMessage(example, { provider, model, tools: [], approveAllTools })
}
onClick={() => handleSendMessage(example, { provider, model, tools: [] })}
className="border-border text-muted-foreground hover:border-brand hover:text-foreground cursor-pointer rounded-full border px-3 py-1.5 text-[13px] transition-colors"
>
{example}
Expand Down
10 changes: 0 additions & 10 deletions src/contexts/UISettingsContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,6 @@ interface UISettingsContextType {
setProvider: (provider: string) => void;
model: string;
setModel: (model: string) => void;
approveAllTools: boolean;
setApproveAllTools: (v: boolean) => void;
}

const UISettingsContext = createContext<UISettingsContextType | undefined>(undefined);
Expand All @@ -41,13 +39,11 @@ export const UISettingsProvider = ({ children }: UISettingsProviderProps) => {
// query params on every request, so they override the server's default.
const [provider, setProviderState] = useState<string>("anthropic");
const [model, setModelState] = useState<string>("claude-haiku-4-5");
const [approveAllTools, setApproveAllToolsState] = useState<boolean>(false);

useEffect(() => {
const saved = loadSettings();
if (typeof saved.provider === "string") setProviderState(saved.provider);
if (typeof saved.model === "string") setModelState(saved.model);
if (typeof saved.approveAllTools === "boolean") setApproveAllToolsState(saved.approveAllTools);
}, []);

const setProvider = (v: string) => {
Expand All @@ -58,10 +54,6 @@ export const UISettingsProvider = ({ children }: UISettingsProviderProps) => {
setModelState(v);
saveSetting("model", v);
};
const setApproveAllTools = (v: boolean) => {
setApproveAllToolsState(v);
saveSetting("approveAllTools", v);
};

return (
<UISettingsContext.Provider
Expand All @@ -70,8 +62,6 @@ export const UISettingsProvider = ({ children }: UISettingsProviderProps) => {
setProvider,
model,
setModel,
approveAllTools,
setApproveAllTools,
}}
>
{children}
Expand Down
82 changes: 82 additions & 0 deletions src/lib/agent/approvalGate.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { readFile } from "node:fs/promises";
import { describe, expect, it } from "vitest";

/**
* The approval gate is Cameron's first design rule: nothing mutating runs without an explicit
* human decision. A request must therefore never be able to switch it off.
*
* The bypass exists for ONE caller — the eval harness, which drives the agent factory in-process
* and has no human to answer an interrupt. These tests pin the boundary between that and
* everything reachable over HTTP, because the failure is silent: a bypass wired back into the
* route would still typecheck, still stream, and quietly write to a real ledger.
*
* Source-level assertions, in the same spirit as capabilities.test.ts — what needs pinning is
* that a future edit doesn't reintroduce the path, which no runtime assertion observes.
*/

const read = (path: string) => readFile(new URL(path, import.meta.url), "utf-8");

/** The eval-only flag. Named so no one mistakes it for a product setting. */
const BYPASS = "bypassApprovalForEval";

describe("the approval gate is not reachable from a request", () => {
it("the stream route never reads an approval-bypass param", async () => {
const route = await read("../../app/api/agent/stream/route.ts");
expect(route).not.toContain("approveAllTools");
expect(route).not.toContain(BYPASS);
});

it("the stream's public schema does not advertise one", async () => {
// The OpenAPI doc is a contract; a documented bypass invites a client to send it.
const schema = await read("../../app/api/agent/stream/schema.ts");
expect(schema).not.toContain("approveAllTools");
expect(schema).not.toContain(BYPASS);
});

it("the client never puts one on the wire", async () => {
const chat = await read("../../services/chatService.ts");
expect(chat).not.toContain("approveAllTools");
expect(chat).not.toContain(BYPASS);
});

it("MessageOptions — the request-shaped type — cannot carry one", async () => {
const types = await read("../../types/message.ts");
expect(types).not.toContain("approveAllTools");
expect(types).not.toContain(BYPASS);
});

it("agentService builds the agent without a bypass", async () => {
// This is the seam where a request reaches the agent factory; it must not forward one.
const service = await read("../../services/agentService.ts");
expect(service).not.toMatch(new RegExp(`${BYPASS}\\s*:`));
expect(service).not.toContain("approveAllTools");
});

it("no UI surface offers a toggle", async () => {
for (const file of [
"../../components/MessageInput.tsx",
"../../contexts/UISettingsContext.tsx",
]) {
const src = await read(file);
expect(src, file).not.toContain("approveAllTools");
expect(src, file).not.toContain(BYPASS);
}
});

it("the middleware is installed unless the eval flag is set", async () => {
const factory = await read("./index.ts");
// The gate hangs off exactly one condition, and that condition is the eval-only flag.
expect(factory).toContain(`cfg?.${BYPASS}`);
expect(factory).toContain("humanInTheLoopMiddleware");
expect(factory).not.toContain("approveAllTools");
});

it("every mutating tool is listed in interruptOn", async () => {
const factory = await read("./index.ts");
const { MUTATING_TOOL_NAMES } = await import("./mutatingTools");
// interruptOn is built from MUTATING_TOOL_NAMES, so the gate covers a new mutating tool
// automatically — pin that derivation rather than a hand-written list.
expect(factory).toContain("MUTATING_TOOL_NAMES.map");
expect(MUTATING_TOOL_NAMES.length).toBeGreaterThan(0);
});
});
9 changes: 6 additions & 3 deletions src/lib/agent/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,15 +59,18 @@ async function buildAgent(cfg?: AgentConfigOptions) {
const allTools = [...builtinTools, ...configTools, ...mcpTools] as DynamicTool[];

// Human-in-the-loop approval: mutating tools pause for an approve/reject decision; everything
// else (reads, MCP) auto-approves because it isn't listed in `interruptOn`. When the client asks
// to auto-approve everything, we omit the middleware entirely so no interrupt is ever created.
// else (reads, MCP) auto-approves because it isn't listed in `interruptOn`.
//
// The middleware is ALWAYS installed for anything reachable from a request — there is no
// user-facing switch to turn the gate off. `bypassApprovalForEval` omits it for the eval
// harness alone, which has no human to answer an interrupt (see AgentConfigOptions).
const interruptOn = Object.fromEntries(
MUTATING_TOOL_NAMES.map((name) => [
name,
{ allowedDecisions: ["approve", "reject"] as ("approve" | "reject")[] },
]),
);
const middleware = cfg?.approveAllTools
const middleware = cfg?.bypassApprovalForEval
? []
: [
humanInTheLoopMiddleware({
Expand Down
10 changes: 9 additions & 1 deletion src/lib/agent/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,15 @@ export interface AgentConfigOptions {
provider?: string; // 'google' | 'openai' etc.
systemPrompt?: string; // system prompt override
tools?: unknown[]; // tools from registry or direct tool objects
approveAllTools?: boolean; // if true, skip tool approval prompts
/**
* Omit the human-in-the-loop middleware so mutating tools run unattended.
*
* FOR THE EVAL HARNESS ONLY, which calls the agent factory directly and has no human to answer
* an interrupt. It is deliberately NOT reachable from an HTTP request: nothing in the route,
* the wire protocol, or the UI can set it, because the approval gate is Cameron's first rule.
* An approval-gate eval case must leave this unset — that is the behavior it grades.
*/
bypassApprovalForEval?: boolean;
}

/**
Expand Down
4 changes: 3 additions & 1 deletion src/services/agentService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,13 @@ export async function streamResponse(params: {
const { threadId, userText, opts } = params;
await ensureThread(threadId, userText);

// No approval-bypass is threaded through from the request. The gate is Cameron's first rule,
// so it is not something a client can turn off — `bypassApprovalForEval` exists only for the
// eval harness, which calls the agent factory directly.
const agent = await ensureAgent({
model: opts?.model,
provider: opts?.provider,
tools: opts?.tools,
approveAllTools: opts?.approveAllTools,
});

const config = {
Expand Down
2 changes: 0 additions & 2 deletions src/services/chatService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,6 @@ export function createMessageStream(
if (opts?.provider) params.set("provider", opts.provider);
if (opts?.tools?.length) params.set("tools", opts.tools.join(","));
if (opts?.allowTool) params.set("allowTool", opts.allowTool);
if (opts?.approveAllTools !== undefined)
params.set("approveAllTools", opts.approveAllTools ? "true" : "false");
if (opts?.attachments && opts.attachments.length > 0) {
// Serialize attachments as JSON string for query parameter
params.set("attachments", JSON.stringify(opts.attachments));
Expand Down
1 change: 0 additions & 1 deletion src/types/message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ export interface MessageOptions {
provider?: string;
tools?: string[];
allowTool?: "allow" | "deny";
approveAllTools?: boolean; // if true, skip tool approval prompts
attachments?: FileAttachment[];
/** Client-only: send into this thread instead of the hook's current one. Never serialized. */
targetThreadId?: string;
Expand Down