M3.2: the action classifier
What the fast path calls unknown is judged by an independent model call on the item's classifier role (L4), before it runs. - nisse_dev.loop.classifier: it sees the action and the agreed scope (project, repository, issue number, the restatement the operator said go to), built in code; never the transcript, the issue's text, tool output or the driver's reasoning. Its verdict is a tool call: safe runs; in_scope runs and is reported in the thread; needs_discussion asks, as a dangerous action does. An error, no verdict or an unknown one fails closed. Verdicts are cached per item, agreed revision and action. A helper is refused what needs discussion. - action_verdict gains source, model and the classifier's exact input; details shows the source. An allow after the classifier asked is an override. scripts/classifier-evals.py exports the judgements from a store, read-only; tests/integration/classifier_cases.yaml is the seed set, run per model with --capability-models. - The fast path's delete rule: deleting on the build VM is contained; only what breaks the host (system and home directories) stays dangerous, since in tests the build VM is the test machine. - The classifier role is the local model in the example config. - The property walk gains a classifier answering at random, junk and failures included; dangerous_only_when_allowed now covers its verdicts (checked against a mutant that runs them all). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PPQ14L3LikyefCMFBqEbKE
This commit is contained in:
1 parent
5044af371b
commit
089af0c391
15 files changed
+1023
-71
No files matched your search
@@ -8,6 +8,7 @@ checks every property after every step. A failure prints
|
||||
the seed and the steps, so it replays exactly.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
import re
|
||||
@@ -25,6 +26,7 @@ from test_loop_projects import Router
|
||||
from nisse_dev.cancel import Cancel
|
||||
from nisse_dev.canonical import (
|
||||
ActionVerdict,
|
||||
AssistantText,
|
||||
Fact,
|
||||
PlanSubmitted,
|
||||
ToolCall,
|
||||
@@ -34,8 +36,9 @@ from nisse_dev.canonical import (
|
||||
from nisse_dev.delegation import DELEGATION_INTENT, Caps
|
||||
from nisse_dev.execution import SSHHost
|
||||
from nisse_dev.gitea import Comment, Issue
|
||||
from nisse_dev.llm import RoleMap
|
||||
from nisse_dev.llm import LLMError, RoleMap
|
||||
from nisse_dev.loop.agent import LoopAgent, Project
|
||||
from nisse_dev.loop.classifier import ActionClassifier
|
||||
from nisse_dev.loop.driver import Driver
|
||||
from nisse_dev.loop.help import Helpers
|
||||
from nisse_dev.loop.items import (
|
||||
@@ -65,8 +68,10 @@ TWO_PROJECT_SEEDS = range(100 + _FIRST, 100 + _FIRST + _WALKS - _WALKS * 2 // 3)
|
||||
STEPS = 30
|
||||
|
||||
|
||||
# Deletes outside the workspace (dangerous), but there is nothing there.
|
||||
DANGEROUS_COMMAND = "rm -rf /nonexistent-nisse-walk"
|
||||
# Sends data out (dangerous), but nothing listens on the port.
|
||||
DANGEROUS_COMMAND = "curl -sS -m 1 -X POST -d x http://127.0.0.1:9/"
|
||||
# No rule knows it (inline Python), and it does nothing.
|
||||
UNKNOWN_COMMAND = "python3 -c 'pass'"
|
||||
|
||||
|
||||
class PolicyModel:
|
||||
@@ -129,9 +134,18 @@ class PolicyModel:
|
||||
return [call("restate", text=f"restatement {self.n}", changed=f"c{self.n}")]
|
||||
if "delegate" in offered and self.rng.random() < 0.1:
|
||||
return [call("delegate", task=f"help {self.n}")]
|
||||
if any("allowed this once" in t for t in facts[-3:]):
|
||||
allowed = [t for t in facts[-3:] if "allowed this once" in t]
|
||||
if allowed:
|
||||
# Told to, as the driver is: run it again exactly as before.
|
||||
return [call("run_command", command=DANGEROUS_COMMAND)]
|
||||
said = allowed[-1].split("allowed this once: run_command ", 1)[-1]
|
||||
command = json.loads(said.rsplit(". Run it again", 1)[0])["command"]
|
||||
return [call("run_command", command=command)]
|
||||
if self.rng.random() < 0.2:
|
||||
# No rule knows it: the classifier judges (L4).
|
||||
return [
|
||||
call("start_milestone", name=f"m{self.n}"),
|
||||
call("run_command", command=UNKNOWN_COMMAND),
|
||||
]
|
||||
if self.rng.random() < 0.12:
|
||||
# Dangerous, always the same, so an allow can match it (P3, L3).
|
||||
return [
|
||||
@@ -161,6 +175,29 @@ class PolicyModel:
|
||||
]
|
||||
|
||||
|
||||
class ClassifierModel:
|
||||
"""A classifier answering at random, junk and failures included: the
|
||||
loop must stay safe whatever it says (L4 fails closed)."""
|
||||
|
||||
def __init__(self, rng: random.Random) -> None:
|
||||
self.rng = rng
|
||||
|
||||
def complete(self, events, tools, model, system=None) -> list[Any]:
|
||||
roll = self.rng.random()
|
||||
if roll < 0.1:
|
||||
raise LLMError("classifier down")
|
||||
if roll < 0.2:
|
||||
return [AssistantText(text="hmm", model=model)]
|
||||
label = self.rng.choice(["safe", "in_scope", "needs_discussion", "maybe"])
|
||||
return [
|
||||
ToolCall(
|
||||
call_id=f"v{roll}",
|
||||
tool="verdict",
|
||||
arguments={"verdict": label, "reason": f"judged {label}"},
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
class HelperModel:
|
||||
"""A scripted helper: does some work, then hands back, succeeding or
|
||||
not as the seed says."""
|
||||
@@ -172,8 +209,13 @@ class HelperModel:
|
||||
def complete(self, events, tools, model, system=None) -> list[Any]:
|
||||
assert_renderable(events)
|
||||
self.n += 1
|
||||
if self.rng.random() < 0.3:
|
||||
return [call("run_command", command=DANGEROUS_COMMAND)]
|
||||
if self.rng.random() < 0.4:
|
||||
return [
|
||||
call(
|
||||
"run_command",
|
||||
command=self.rng.choice([DANGEROUS_COMMAND, UNKNOWN_COMMAND]),
|
||||
)
|
||||
]
|
||||
return [
|
||||
call("write_file", path=f"h{self.n}.py", content=f"h = {self.n}\n"),
|
||||
ToolCall(
|
||||
@@ -248,6 +290,10 @@ class Walk:
|
||||
roles=RoleMap(driver="m", helper="h", expert="x"),
|
||||
caps=Caps(turns=4),
|
||||
),
|
||||
classifier=ActionClassifier(
|
||||
ClassifierModel(self.rng), # type: ignore[arg-type]
|
||||
"c",
|
||||
),
|
||||
bug_grace_s=600,
|
||||
clock=lambda: self.now,
|
||||
)
|
||||
@@ -433,7 +479,9 @@ class Walk:
|
||||
if isinstance(event, UserMessage) and event.text == "allow":
|
||||
allows += 1
|
||||
elif isinstance(event, ActionVerdict) and event.effect == "dangerous":
|
||||
if event.decision == "allowed":
|
||||
if event.source == "classifier" and event.rule == "in_scope":
|
||||
assert event.decision == "ran", where
|
||||
elif event.decision == "allowed":
|
||||
allows -= 1
|
||||
assert allows >= 0, (
|
||||
f"dangerous_only_when_allowed: {item.id}: {where}"
|
||||
|
||||
Reference in new issue
Block a user