@@ -949,15 +1069,85 @@ export function ApprovalRecords({ taskId }: { taskId?: string }) {
{filtered ? "Filtered results" : "All records"} ({total})
diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts
index 59e5fd4..178fad5 100644
--- a/web/src/lib/api.ts
+++ b/web/src/lib/api.ts
@@ -1232,6 +1232,9 @@ export const api = {
interceptGetOne: (id: number) => get(`/intercept/pending/${id}`),
interceptDecide: (id: number, decision: "allowed" | "denied") =>
post<{ ok: boolean }>(`/intercept/pending/${id}/decide`, { decision }),
+ // 200 even on partial failure: each id carries its own ok/error.
+ interceptBatchDecide: (ids: number[], decision: "allowed" | "denied") =>
+ post("/intercept/pending/batch-decide", { ids, decision }),
// No body: the server derives the rule from the stored request, which stays pending.
interceptAlwaysAllow: (id: number) =>
post<{ rule: InterceptRule; created: boolean }>(`/intercept/pending/${id}/always-allow`),
diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts
index 854792a..ec3bf3d 100644
--- a/web/src/lib/types.ts
+++ b/web/src/lib/types.ts
@@ -1368,6 +1368,20 @@ export interface InterceptPending {
reason: string; // the rule's message or the model's verdict reason (model verdicts carry a [model] prefix)
decided_at?: string;
created_at: string;
+ // Deterministic risk score, computed by the server when a pending row is read.
+ risk?: InterceptRisk;
+}
+
+export interface InterceptRisk {
+ score: number; // 0..100
+ level: "low" | "medium" | "high";
+ reasons: string[];
+}
+
+export interface InterceptBatchDecideResult {
+ decided: number;
+ failed: number;
+ results: { id: number; ok: boolean; error?: string }[];
}
// JudgeConfig: global config for model fallback approval (the model decides only when no interception rule matches).
From 8f5a67d10c363e5b593633b6dfa7894ff3357de8 Mon Sep 17 00:00:00 2001
From: BaaRai <130491490+BaaRai@users.noreply.github.com>
Date: Sat, 14 Nov 2026 22:46:18 +0100
Subject: [PATCH 2/2] fix(intercept): destructive commands score high; chunk
batch decisions to the server limit
The destructive shell signal is worth 60 instead of 50, so a destructive command alone scores 10 + 60 = 70, high. The level thresholds are unchanged. The approvals page sends the selection in sequential calls of at most 100 ids, the server limit (maxBatchDecide), instead of one call that the server would refuse with 400 when more than 100 rows are selected. If a call fails, sending stops: its ids and the later ones stay selected with the error, and ids already decided stay decided. The final toast gives the totals.
---
intercept/risk.go | 3 ++-
intercept/risk_test.go | 19 +++++++-------
server/intercept_batch_decide_test.go | 2 +-
web/src/components/approval-records.tsx | 34 +++++++++++++++++++------
4 files changed, 39 insertions(+), 19 deletions(-)
diff --git a/intercept/risk.go b/intercept/risk.go
index 059e8e0..c61e596 100644
--- a/intercept/risk.go
+++ b/intercept/risk.go
@@ -47,7 +47,8 @@ const shellBase = 10
// shellSignals are additive; each counts once, and reasons follow this order.
var shellSignals = []riskSignal{
- reSignal("destructive command (rm -r, dd, mkfs, shred, truncate, DROP TABLE, DELETE FROM, shutdown)", 50,
+ // 60 so that a destructive command alone reaches high: 10 + 60 = 70.
+ reSignal("destructive command (rm -r, dd, mkfs, shred, truncate, DROP TABLE, DELETE FROM, shutdown)", 60,
`(?i)\brm\s+([^;&|]*\s)?(-[a-z]*r[a-z]*|--recursive)(\s|$)|\bdd\s+[^;&|]*\b(if|of)=|\bmkfs\b|\bshred\b|\btruncate\b|\bdrop\s+table\b|\bdelete\s+from\b|\bshutdown\b`),
reSignal("privilege change (sudo, su, chmod 777, setcap)", 40,
`(^|[\s;&|(])(sudo|su)(\s|$)|\bchmod\s+(-\w+\s+)*0?777\b|\bsetcap\b`),
diff --git a/intercept/risk_test.go b/intercept/risk_test.go
index b9060f1..0c0c9c4 100644
--- a/intercept/risk_test.go
+++ b/intercept/risk_test.go
@@ -30,16 +30,17 @@ func TestScoreRisk(t *testing.T) {
reasons []string
}{
{"plain command", "Bash", bash("ls -la"), 10, "low", nil},
- {"rm -rf", "Bash", bash("rm -rf /tmp/x"), 60, "medium", []string{sig(destructive)}},
- {"rm -R", "Bash", bash("rm -R build"), 60, "medium", []string{sig(destructive)}},
+ {"destructive alone is high", "Bash", bash("rm -rf ./cache"), 70, "high", []string{sig(destructive)}},
+ {"rm -rf", "Bash", bash("rm -rf /tmp/x"), 70, "high", []string{sig(destructive)}},
+ {"rm -R", "Bash", bash("rm -R build"), 70, "high", []string{sig(destructive)}},
{"rm of a file named -r suffix", "Bash", bash("rm file-r"), 10, "low", nil},
- {"dd", "Bash", bash("dd if=/dev/zero of=/dev/sda"), 60, "medium", []string{sig(destructive)}},
- {"mkfs", "Bash", bash("mkfs.ext4 /dev/sdb1"), 60, "medium", []string{sig(destructive)}},
- {"shred", "Bash", bash("shred -u key.pem"), 60, "medium", []string{sig(destructive)}},
- {"truncate", "Bash", bash("truncate -s 0 app.log"), 60, "medium", []string{sig(destructive)}},
- {"drop table", "Bash", bash(`psql -c "DROP TABLE users"`), 60, "medium", []string{sig(destructive)}},
- {"delete from", "Bash", bash(`psql -c "delete from users"`), 60, "medium", []string{sig(destructive)}},
- {"shutdown", "Bash", bash("shutdown -h now"), 60, "medium", []string{sig(destructive)}},
+ {"dd", "Bash", bash("dd if=/dev/zero of=/dev/sda"), 70, "high", []string{sig(destructive)}},
+ {"mkfs", "Bash", bash("mkfs.ext4 /dev/sdb1"), 70, "high", []string{sig(destructive)}},
+ {"shred", "Bash", bash("shred -u key.pem"), 70, "high", []string{sig(destructive)}},
+ {"truncate", "Bash", bash("truncate -s 0 app.log"), 70, "high", []string{sig(destructive)}},
+ {"drop table", "Bash", bash(`psql -c "DROP TABLE users"`), 70, "high", []string{sig(destructive)}},
+ {"delete from", "Bash", bash(`psql -c "delete from users"`), 70, "high", []string{sig(destructive)}},
+ {"shutdown", "Bash", bash("shutdown -h now"), 70, "high", []string{sig(destructive)}},
{"sudo", "Bash", bash("sudo id"), 50, "medium", []string{sig(privilege)}},
{"su", "Bash", bash("su - root"), 50, "medium", []string{sig(privilege)}},
{"chmod 777", "Bash", bash("chmod -R 777 /srv"), 50, "medium", []string{sig(privilege)}},
diff --git a/server/intercept_batch_decide_test.go b/server/intercept_batch_decide_test.go
index 95a75b2..31d73e0 100644
--- a/server/intercept_batch_decide_test.go
+++ b/server/intercept_batch_decide_test.go
@@ -53,7 +53,7 @@ func TestInterceptBatchDecideHTTP(t *testing.T) {
}
return id
}
- id1, id2, id3 := pending("ls"), pending("rm -rf /tmp/x"), pending("sudo rm -rf / ; cat /etc/shadow")
+ id1, id2, id3 := pending("ls"), pending("nmap -sV target.example"), pending("sudo rm -rf / ; cat /etc/shadow")
// The pending JSON keeps every historical field and adds risk.
var historical map[string]any
diff --git a/web/src/components/approval-records.tsx b/web/src/components/approval-records.tsx
index 82f0af5..9430bbc 100644
--- a/web/src/components/approval-records.tsx
+++ b/web/src/components/approval-records.tsx
@@ -642,6 +642,9 @@ function serverColumnCount() {
type Selection = { selected: Set; toggle: (id: number) => void; toggleAll: () => void };
+// Server limit on ids per batch-decide call (maxBatchDecide in server/intercept.go).
+const batchDecideMax = 100;
+
function ApprovalTable({
rows,
busy,
@@ -938,24 +941,39 @@ export function ApprovalRecords({ taskId }: { taskId?: string }) {
setSelected(selectedRows.length === pending.length ? new Set() : new Set(pending.map((row) => row.id))),
};
- // Each id is decided by the server exactly like a single decision; a failed
- // id stays pending and selected, with its error listed.
+ // Each id is decided by the server exactly like a single decision. The server
+ // takes at most 100 ids per call (maxBatchDecide in server/intercept.go) and
+ // the pending queue is not paginated, so the selection goes out in sequential
+ // chunks of at most batchDecideMax ids. If a chunk cannot be sent (network
+ // error, 400), sending stops: its ids and the later ones stay selected with
+ // the error, and chunks already decided stay decided. A failed id stays
+ // pending and selected, with its error listed.
const batchDecide = async (decision: "allowed" | "denied") => {
const ids = selectedRows.map((row) => row.id);
if (!ids.length || decisionLock.current) return;
decisionLock.current = true;
setDeciding(true);
+ let decided = 0;
+ const failed: { id: number; error: string }[] = [];
try {
- const res = await api.interceptBatchDecide(ids, decision);
+ for (let start = 0; start < ids.length; start += batchDecideMax) {
+ try {
+ const res = await api.interceptBatchDecide(ids.slice(start, start + batchDecideMax), decision);
+ decided += res.decided;
+ for (const r of res.results) if (!r.ok) failed.push({ id: r.id, error: r.error || "Not decided" });
+ } catch (e) {
+ const error = `Not sent: ${(e as Error).message || "request failed"}`;
+ for (const id of ids.slice(start)) failed.push({ id, error });
+ break;
+ }
+ }
request.current++;
- const failed = res.results.filter((r) => !r.ok).map((r) => ({ id: r.id, error: r.error || "Not decided" }));
setBatchErrors(failed);
setSelected(new Set(failed.map((f) => f.id)));
setRevision((v) => v + 1);
- if (res.decided) toast.success(`${res.decided} ${decision === "allowed" ? "allowed" : "denied"}`);
- if (res.failed) toast.error(`${res.failed} not decided, see the errors above the pending list`);
- } catch (e) {
- toast.error((e as Error).message);
+ const verb = decision === "allowed" ? "allowed" : "denied";
+ if (failed.length) toast.error(`${decided} ${verb}, ${failed.length} not decided (see the errors listed above)`);
+ else toast.success(`${decided} ${verb}`);
} finally {
decisionLock.current = false;
setDeciding(false);