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

+46 -11
View File
@@ -6,9 +6,11 @@ do, judged by code before anything runs.
- **Contained:** editing the workspace, local commits, installing on the build
VM and otherwise changing it (D3).
- **Dangerous:** anything reaching beyond the item's repository or build VM
(pushing, other hosts, uploads, publishing, the forge's own tools), deleting
outside the workspace, or wrecking the build VM itself. Discussed with the
operator first.
(pushing, other hosts, uploads, publishing, the forge's own tools), or
wrecking the host itself (deleting system or home directories, `dd`,
`mkfs`, `reboot`). Discussed with the operator first. Deleting elsewhere on
the build VM is contained: it is disposable, and in tests the "build VM" is
this machine, which the host rules protect.
- **Refused:** changing branches by hand. The harness owns branches (D4), so
the model is told to use its milestone tools instead.
- **Unknown:** everything else, for the classifier (M3.2); until then it runs,
@@ -137,23 +139,56 @@ def _program(command: Command, root: str) -> Verdict:
)
if name in _WRECKS_THE_VM:
return Verdict(
DANGEROUS, "wrecks-vm", f"{name} can break the build VM or its network"
DANGEROUS, "wrecks-host", f"{name} can break the host or its network"
)
if name in _PACKAGE_MANAGERS:
return Verdict(CONTAINED, "install", "installs on the build VM")
return Verdict(UNKNOWN, "no-rule", f"no rule for {name}")
# Deleting under these breaks the host itself. On a real build VM that only
# costs a rebuild, but in tests the "build VM" is this machine's own sshd.
_HOST = (
"/bin", "/boot", "/dev", "/etc", "/home", "/lib", "/lib32", "/lib64",
"/libx32", "/proc", "/root", "/sbin", "/snap", "/sys", "/usr", "/var",
) # fmt: skip
_SCRATCH = ("/tmp", "/var/tmp")
def _wrecks_host(target: str, cwd: str | None, root: str) -> bool | None:
"""Whether deleting ``target`` breaks the host; None when it can't be
told (a variable). The workspace is never the host's, wherever it is."""
if target in ("~", "$HOME") or target.startswith(("~/", "$HOME/", "${HOME}")):
return True
path = resolve(target, cwd)
if path is None:
return None
if inside(path, root):
return False
if (
path == "/"
or path.startswith("/*")
or posixpath.dirname(path) == "/"
and "*" in path
):
return True
if any(inside(path, s) for s in _SCRATCH):
return False
return any(inside(path, h) for h in _HOST)
def _deletes(name: str, args: list[str], command: Command, root: str) -> Verdict:
"""The build VM is disposable (D3), so deleting on it is contained, except
what breaks the host itself."""
targets = [a for a in args if not a.startswith("-")]
outside = [t for t in targets if not inside(resolve(t, command.cwd), root)]
if outside:
wrecks = [t for t in targets if _wrecks_host(t, command.cwd, root)]
if wrecks:
return Verdict(
DANGEROUS,
"deletes-outside",
f"{name} deletes outside the workspace: {', '.join(outside)}",
DANGEROUS, "wrecks-host", f"{name} deletes {', '.join(wrecks)} on the host"
)
return Verdict(CONTAINED, "deletes-inside", f"{name} deletes in the workspace")
if any(_wrecks_host(t, command.cwd, root) is None for t in targets):
return Verdict(UNKNOWN, "no-rule", f"{name} deletes what a variable names")
return Verdict(CONTAINED, "deletes", f"{name} deletes files on the build VM")
def _git(args: list[str], command: Command, root: str) -> Verdict:
@@ -326,7 +361,7 @@ def _interpreter(args: list[str], command: Command, root: str) -> Verdict:
def _dangerous_vm(args: list[str], command: Command, root: str) -> Verdict:
return Verdict(DANGEROUS, "wrecks-vm", "can break the build VM")
return Verdict(DANGEROUS, "wrecks-host", "can break the host")
Rule = Callable[[list[str], Command, str], Verdict]