-
Notifications
You must be signed in to change notification settings - Fork 369
Bound tool output, detect anti-bot challenges, add a per-bot activity ledger #422
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
aivsomkar
wants to merge
10
commits into
main
Choose a base branch
from
feat/parity-round-1
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
10 commits
Select commit
Hold shift + click to select a range
1e11092
docs: plan the Grok-parity upgrades (3 rounds, 8 items)
aivsomkar be545f0
feat(server): bound oversized harness-owned MCP tool output
aivsomkar 206df10
feat(server): spill oversized tool output into the bot workspace
aivsomkar bacc6a9
feat(server): bound tool output at the harness-owned MCP servers
aivsomkar 90312ab
feat(server): recognise anti-bot challenge pages
aivsomkar 88f4d90
feat(server): stop and ask for takeover on anti-bot challenge pages
aivsomkar f4ca4a9
feat(server): ask the user to take over when a bot hits a challenge page
aivsomkar 2f195a7
feat(server): per-bot action ledger
aivsomkar c54dcbd
feat: show a bot's recorded activity in the inspector
aivsomkar a323168
fix: keep lint parity with main after the rebase
aivsomkar File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,99 @@ | ||
| // The per-bot activity ledger. | ||
| // | ||
| // decision-log.ts answers "was this allowed, and by which rule". This | ||
| // answers "what did this bot actually do", per bot, and it is a projection | ||
| // of events the bus already carries — so the tests that matter are: the | ||
| // right events become rows, the wrong ones do not, and nothing secret | ||
| // survives the trip to disk. | ||
| import { mkdtempSync } from "node:fs"; | ||
| import { tmpdir } from "node:os"; | ||
| import { join } from "node:path"; | ||
| import { describe, expect, it } from "vitest"; | ||
|
|
||
| import { actionFromEvent, appendAction, flushActionAudit, readActions } from "./action-audit.ts"; | ||
|
|
||
| const base = { | ||
| eventId: "e1", | ||
| provider: "claude", | ||
| threadId: "t1", | ||
| createdAt: "2026-08-24T00:00:00Z", | ||
| turnId: "turn-1", | ||
| }; | ||
|
|
||
| describe("actionFromEvent", () => { | ||
| it("records a tool call by the title the driver reported", () => { | ||
| const row = actionFromEvent({ ...base, type: "item.started", itemType: "tool", title: "Bash(git status)" }, "bot-1"); | ||
| expect(row).toEqual({ | ||
| botId: "bot-1", | ||
| threadId: "t1", | ||
| turnId: "turn-1", | ||
| type: "tool_call", | ||
| name: "Bash(git status)", | ||
| }); | ||
| }); | ||
|
|
||
| it("ignores reasoning items — thinking is not an action", () => { | ||
| expect(actionFromEvent({ ...base, type: "item.started", itemType: "reasoning" }, "bot-1")).toBeNull(); | ||
| }); | ||
|
|
||
| it("ignores stream deltas", () => { | ||
| expect( | ||
| actionFromEvent({ ...base, type: "content.delta", streamKind: "assistant_text", delta: "hi" }, "bot-1"), | ||
| ).toBeNull(); | ||
| }); | ||
|
|
||
| it("ignores a tool call with no title — a nameless row helps nobody", () => { | ||
| expect(actionFromEvent({ ...base, type: "item.started", itemType: "tool", title: " " }, "bot-1")).toBeNull(); | ||
| }); | ||
|
|
||
| it("omits turnId when the event carries none", () => { | ||
| const { turnId: _turnId, ...noTurn } = base; | ||
| const row = actionFromEvent({ ...noTurn, type: "item.started", itemType: "tool", title: "Read" }, "bot-1"); | ||
| expect(row).not.toHaveProperty("turnId"); | ||
| }); | ||
| }); | ||
|
|
||
| describe("appendAction / readActions", () => { | ||
| it("round-trips rows newest first", async () => { | ||
| const dir = mkdtempSync(join(tmpdir(), "maus-audit-")); | ||
| appendAction(dir, { botId: "bot-1", threadId: "t1", type: "tool_call", name: "Bash(git status)" }); | ||
| appendAction(dir, { botId: "bot-1", threadId: "t1", type: "tool_call", name: "Read(README.md)" }); | ||
| await flushActionAudit(dir, "bot-1"); | ||
| const rows = readActions(dir, "bot-1", 10); | ||
| expect(rows.map((row) => row.name)).toEqual(["Read(README.md)", "Bash(git status)"]); | ||
| expect(rows[0]!.ts).toMatch(/^\d{4}-\d{2}-\d{2}T/); | ||
| }); | ||
|
|
||
| it("keeps one bot's actions out of another's file", async () => { | ||
| const dir = mkdtempSync(join(tmpdir(), "maus-audit-")); | ||
| appendAction(dir, { botId: "bot-1", threadId: "t1", type: "tool_call", name: "mine" }); | ||
| appendAction(dir, { botId: "bot-2", threadId: "t2", type: "tool_call", name: "theirs" }); | ||
| await flushActionAudit(dir, "bot-1"); | ||
| await flushActionAudit(dir, "bot-2"); | ||
| expect(readActions(dir, "bot-1", 10).map((row) => row.name)).toEqual(["mine"]); | ||
| expect(readActions(dir, "bot-2", 10).map((row) => row.name)).toEqual(["theirs"]); | ||
| }); | ||
|
|
||
| it("returns nothing for a bot that has done nothing", () => { | ||
| const dir = mkdtempSync(join(tmpdir(), "maus-audit-")); | ||
| expect(readActions(dir, "never-ran", 10)).toEqual([]); | ||
| }); | ||
|
|
||
| it("honours the limit, keeping the newest", async () => { | ||
| const dir = mkdtempSync(join(tmpdir(), "maus-audit-")); | ||
| for (let i = 0; i < 5; i += 1) { | ||
| appendAction(dir, { botId: "bot-1", threadId: "t1", type: "tool_call", name: `step-${i}` }); | ||
| } | ||
| await flushActionAudit(dir, "bot-1"); | ||
| expect(readActions(dir, "bot-1", 2).map((row) => row.name)).toEqual(["step-4", "step-3"]); | ||
| }); | ||
|
|
||
| it("survives a torn final line rather than losing the whole file", async () => { | ||
| const dir = mkdtempSync(join(tmpdir(), "maus-audit-")); | ||
| appendAction(dir, { botId: "bot-1", threadId: "t1", type: "tool_call", name: "intact" }); | ||
| await flushActionAudit(dir, "bot-1"); | ||
| const { appendFileSync } = await import("node:fs"); | ||
| appendFileSync(join(dir, "audit", "bot-1.jsonl"), '{"ts":"2026-01-01T00:00:00Z","bo'); | ||
| expect(readActions(dir, "bot-1", 10).map((row) => row.name)).toEqual(["intact"]); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,112 @@ | ||
| // What a bot actually DID, per bot. | ||
| // | ||
| // decision-log.ts answers "was this allowed, by which rule" fleet-wide. | ||
| // That is the authorization record and it stays exactly as it is. This is | ||
| // the activity record: one append-only NDJSON file per bot, folded out of | ||
| // the event stream the bus already tees, so nothing new is captured — it is | ||
| // only projected somewhere a person can read it per BOT instead of per | ||
| // thread. A bot works across many threads; "what has this one been doing" | ||
| // is not a question the per-thread event logs can answer. | ||
| // | ||
| // Same discipline as the decision log: 0600 (rows name tools and command | ||
| // lines), through redactSecrets (a tool title carries whatever the agent | ||
| // typed, credentials included), and fire-and-forget — an activity log must | ||
| // never take down the turn it is recording. | ||
| import { readFileSync } from "node:fs"; | ||
| import { appendFile, mkdir, rename, stat } from "node:fs/promises"; | ||
| import { join } from "node:path"; | ||
|
|
||
| import type { RuntimeEvent } from "./contracts.ts"; | ||
| import { redactSecrets } from "./redact.ts"; | ||
|
|
||
| /** One rotation, then the old file is overwritten by the next. A bot that | ||
| * has done four megabytes of things has a long enough memory. */ | ||
| export const MAX_AUDIT_BYTES = 4_000_000; | ||
|
|
||
| export interface ActionRow { | ||
| ts: string; | ||
| botId: string; | ||
| threadId: string; | ||
| turnId?: string; | ||
| type: "tool_call"; | ||
| name: string; | ||
| } | ||
|
|
||
| export function auditPath(dataDir: string, botId: string): string { | ||
| return join(dataDir, "audit", `${botId}.jsonl`); | ||
| } | ||
|
|
||
| /** The projection. Only what the bot DID: a started tool call is an action, | ||
| * thinking is not, and a stream delta is not. `item.completed` is | ||
| * deliberately not folded in — it carries no title, so a row built from it | ||
| * would be nameless, and correlating the two by itemId would mean holding | ||
| * per-turn state for a ledger that does not need it. */ | ||
| export function actionFromEvent(event: RuntimeEvent, botId: string): Omit<ActionRow, "ts"> | null { | ||
| if (event.type !== "item.started" || event.itemType !== "tool") return null; | ||
| const name = event.title?.trim(); | ||
| if (!name) return null; | ||
| const row: Omit<ActionRow, "ts"> = { | ||
| botId, | ||
| threadId: event.threadId, | ||
| type: "tool_call", | ||
| name, | ||
| }; | ||
| if (event.turnId) row.turnId = event.turnId; | ||
| return row; | ||
| } | ||
|
|
||
| async function writeAction(dataDir: string, row: Omit<ActionRow, "ts">): Promise<void> { | ||
| const line = `${JSON.stringify(redactSecrets({ ts: new Date().toISOString(), ...row }))}\n`; | ||
| const path = auditPath(dataDir, row.botId); | ||
| await mkdir(join(dataDir, "audit"), { recursive: true, mode: 0o700 }); | ||
| const size = await stat(path).then((stats) => stats.size).catch(() => 0); | ||
| if (size > MAX_AUDIT_BYTES) await rename(path, `${path}.1`).catch(() => {}); | ||
| await appendFile(path, line, { mode: 0o600 }); | ||
| } | ||
|
|
||
| /** Per-bot write queues, the same discipline decision-log.ts uses: without | ||
| * one, two tool calls landing in the same tick can both rotate, overwrite | ||
| * .1, or append out of the order they happened in — and a ledger whose rows | ||
| * are out of order is worse than no ledger. Keyed per bot, so a busy bot | ||
| * never serializes behind a different one. */ | ||
| const writeQueues = new Map<string, Promise<void>>(); | ||
|
|
||
| export function appendAction(dataDir: string, row: Omit<ActionRow, "ts">): void { | ||
| const key = `${dataDir}\u0000${row.botId}`; | ||
| const previous = writeQueues.get(key) ?? Promise.resolve(); | ||
| const queued = previous.then(() => writeAction(dataDir, row)).catch(() => { | ||
| /* an activity log must never take down a turn */ | ||
| }); | ||
| writeQueues.set(key, queued); | ||
| void queued.finally(() => { | ||
| if (writeQueues.get(key) === queued) writeQueues.delete(key); | ||
| }); | ||
| } | ||
|
|
||
| /** Test/shutdown seam: wait until every action already queued for this bot | ||
| * has reached disk. Normal turn paths deliberately do not wait. */ | ||
| export async function flushActionAudit(dataDir: string, botId: string): Promise<void> { | ||
| await writeQueues.get(`${dataDir}\u0000${botId}`); | ||
| } | ||
|
|
||
| export function readActions(dataDir: string, botId: string, limit: number): ActionRow[] { | ||
| let raw: string; | ||
| try { | ||
| raw = readFileSync(auditPath(dataDir, botId), "utf8"); | ||
| } catch { | ||
| return []; | ||
| } | ||
| const rows: ActionRow[] = []; | ||
| for (const line of raw.split("\n").filter(Boolean).slice(-limit)) { | ||
| try { | ||
| // SAFETY: every line in this file was written by writeAction from an | ||
| // ActionRow. A line that is not one (a torn write) throws in JSON.parse | ||
| // or lands as a partial row the UI renders as blanks — neither is worth | ||
| // re-validating a log we ourselves wrote. | ||
| rows.push(JSON.parse(line) as ActionRow); | ||
| } catch { | ||
| // a torn final line is not a reason to lose the rest of the file | ||
| } | ||
| } | ||
| return rows.reverse(); | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,87 @@ | ||
| // The pages a browsing agent gets stuck on. | ||
| // | ||
| // Getting this wrong in the permissive direction wastes a turn; getting it | ||
| // wrong in the strict direction interrupts the user over an ordinary page — | ||
| // so anything that could plausibly be a real page stays "low" and only | ||
| // records. The negative cases below are the ones that matter most. | ||
| import { describe, expect, it } from "vitest"; | ||
|
|
||
| import { BLOCK_HELP_WINDOW_MS, classifyBlockPage, createBlockHelpGate } from "./bot-block.ts"; | ||
|
|
||
| describe("classifyBlockPage", () => { | ||
| const blocked: Array<[string, string, string]> = [ | ||
| ["https://www.google.com/sorry/index?continue=x", "", "google_sorry"], | ||
| ["https://accounts.google.com/signin/rejected?x=1", "", "google_signin_rejected"], | ||
| ["https://challenges.cloudflare.com/turnstile", "", "cloudflare_challenge"], | ||
| ["https://shop.example.com/", "Just a moment...", "cloudflare_challenge"], | ||
| ["https://shop.example.com/cdn-cgi/challenge-platform/h/b/orchestrate", "", "cloudflare_challenge"], | ||
| ["https://geo.captcha-delivery.com/captcha/", "", "datadome"], | ||
| ["https://example.com/px/captcha", "", "perimeterx"], | ||
| ["https://example.com/_Incapsula_Resource?SWUDNSAI=9", "", "imperva"], | ||
| ["https://abc.token.awswaf.com/abc", "", "aws_waf"], | ||
| ["https://www.linkedin.com/checkpoint/challenge/verify", "", "linkedin_checkpoint"], | ||
| ["https://app.example.com/", "Vercel Security Checkpoint", "vercel_checkpoint"], | ||
| ["https://client-api.arkoselabs.com/fc/gt2/", "", "arkose"], | ||
| ]; | ||
| for (const [url, title, family] of blocked) { | ||
| it(`flags ${family} for ${url || title}`, () => { | ||
| const hit = classifyBlockPage({ url, title }); | ||
| expect(hit?.family).toBe(family); | ||
| expect(hit?.confidence).toBe("high"); | ||
| }); | ||
| } | ||
|
|
||
| it("only records a bare captcha frame — it is often embedded in a real page", () => { | ||
| const hit = classifyBlockPage({ url: "https://www.google.com/recaptcha/api2/anchor?k=x", title: "" }); | ||
| expect(hit?.family).toBe("recaptcha"); | ||
| expect(hit?.confidence).toBe("low"); | ||
| }); | ||
|
|
||
| it("does not flag an ordinary page", () => { | ||
| expect(classifyBlockPage({ url: "https://example.com/docs", title: "Docs" })).toBeUndefined(); | ||
| }); | ||
|
|
||
| it("does not flag a page that merely mentions captcha in its path", () => { | ||
| expect( | ||
| classifyBlockPage({ url: "https://example.com/blog/how-captcha-works", title: "How CAPTCHA works" }), | ||
| ).toBeUndefined(); | ||
| }); | ||
|
|
||
| it("does not flag a lookalike host that only ends in a brand name", () => { | ||
| expect(classifyBlockPage({ url: "https://notlinkedin.com/checkpoint/challenge", title: "" })).toBeUndefined(); | ||
| }); | ||
|
|
||
| it("strips www so a signature written bare still matches", () => { | ||
| expect(classifyBlockPage({ url: "https://www.linkedin.com/checkpoint/challenge", title: "" })?.host).toBe( | ||
| "linkedin.com", | ||
| ); | ||
| }); | ||
|
|
||
| it("returns undefined for an unparseable url", () => { | ||
| expect(classifyBlockPage({ url: "not a url", title: "" })).toBeUndefined(); | ||
| }); | ||
|
|
||
| it("tolerates a missing title", () => { | ||
| expect(classifyBlockPage({ url: "https://example.com/" })).toBeUndefined(); | ||
| }); | ||
| }); | ||
|
|
||
| describe("createBlockHelpGate", () => { | ||
| it("asks once per host, then holds off for the window", () => { | ||
| let now = 1_000_000; | ||
| const gate = createBlockHelpGate(() => now); | ||
| expect(gate.shouldAsk("shop.example.com")).toBe(true); | ||
| expect(gate.shouldAsk("shop.example.com")).toBe(false); | ||
| now += BLOCK_HELP_WINDOW_MS - 1; | ||
| expect(gate.shouldAsk("shop.example.com")).toBe(false); | ||
| now += 2; | ||
| expect(gate.shouldAsk("shop.example.com")).toBe(true); | ||
| }); | ||
|
|
||
| it("tracks hosts independently — being stuck on one is not being stuck on another", () => { | ||
| const gate = createBlockHelpGate(() => 0); | ||
| expect(gate.shouldAsk("a.example.com")).toBe(true); | ||
| expect(gate.shouldAsk("b.example.com")).toBe(true); | ||
| expect(gate.shouldAsk("a.example.com")).toBe(false); | ||
| }); | ||
| }); |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win
Keep each audit file within
MAX_AUDIT_BYTES.Line 62 checks only the existing file size. A large
namecan make the append on Line 64 exceed the limit. A single unboundedevent.titlecan also exceed the limit after rotation.Calculate
Buffer.byteLength(line)before rotation. Bound the serialized row when one row exceeds the limit.🤖 Prompt for AI Agents