M3.2: the action classifier
CI / checks (push) Skipped
CI / checks (pull_request) Successful in 2m20s
CI / e2e (pull_request) Successful in 12m31s

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:
dongarandClaude Opus 5.5 committed 2026-10-08 12:09:38 +00:00
1 parent 5044af371b
commit 089af0c391
15 files changed
+1023 -71

No files matched your search

+56 -8
View File
@@ -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}"