M3.1: action classes and the fast path
CI / checks (push) Skipped
CI / checks (pull_request) Successful in 2m10s
CI / e2e (pull_request) Successful in 10m30s

Every execution tool call, the driver's and a helper's, gets a verdict
from code before it runs (P3, P4, D6).

- nisse_core.actions, nisse-core's first code: Action and Verdict, and
  reading shell commands statically, segment by segment (cd, wrappers,
  sh -c). What can't be read that way is unknown; a line is as bad as
  its worst command.
- nisse_dev.loop.actions, the rule table: reading, checks and tests are
  harmless; workspace edits, local git and installs on the build VM are
  contained; pushing, other hosts, uploads, publishing, the forge's own
  tools, deleting outside the workspace and wrecking the VM are
  dangerous; changing branches by hand is refused (the harness owns
  branches) with a pointer to the milestone tools.
- A dangerous call isn't run. The item waits on the operator with a
  question rendered from the action, and `allow` lets exactly that
  action run once. A helper can't ask, so it is refused and told to
  hand back. Unknown actions run, logged, until the classifier (M3.2).
- Every verdict is an action_verdict event, which no model sees, and an
  oplog line; `details` shows the counts and every action not simply
  run.
- The property walk gains dangerous_only_when_allowed (checked against
  a mutant that doesn't use the allow up); the slice e2e checks every
  call was judged and none needed an allow.

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 04:32:13 +00:00
1 parent d0fde2f136
commit ea4376eaa1
16 files changed
+1400 -17

No files matched your search

+7 -4
View File
@@ -2,7 +2,10 @@
The shared library both Nisse agents sit on (Part L of the architecture).
It does not exist as code yet: it is extracted from `nisse-dev` once that agent
works, at step 10 of the build order — a package split within this repository.
Until then this directory is a placeholder holding the seam the extraction will
land on.
Most of it still lives in `nisse-dev`, to be extracted as `nisse-ops` arrives.
What is here so far:
- `nisse_core.actions`: actions by their effects (P3, P4, L4). The `Action`
and `Verdict` types, and static reading of shell commands, which each
agent's fast-path rules build on (`nisse-dev`'s are in
`nisse_dev.loop.actions`).
+214
View File
@@ -0,0 +1,214 @@
"""Actions by their effects (architecture P3, P4, L4), shared by every agent.
An action is a tool call as code sees it: the tool and its arguments, never a
model's account of them. The deterministic fast path (P4) sorts it into one of
these effects, by static rules each agent supplies:
- **harmless:** changes nothing outside a disposable sandbox;
- **contained:** stays within the agent's own area, or has a known undo;
- **unknown:** no rule recognises it, so the classifier decides (L4; until it
exists, it runs and is logged);
- **dangerous:** irreversible, or visible beyond the agent's area: discussed
with the operator first;
- **refused:** never run, with a reason the model can act on.
Shell commands are read statically, segment by segment. Anything that can't be
read that way (command substitution, subshells, here-documents, unbalanced
quotes) is unknown rather than guessed at, and a command is as bad as its
worst segment.
"""
import json
import posixpath
import shlex
from collections.abc import Callable, Iterable, Mapping
from dataclasses import dataclass
from typing import Any, Literal
Effect = Literal["harmless", "contained", "unknown", "dangerous", "refused"]
HARMLESS: Effect = "harmless"
CONTAINED: Effect = "contained"
UNKNOWN: Effect = "unknown"
DANGEROUS: Effect = "dangerous"
REFUSED: Effect = "refused"
_SEVERITY: dict[str, int] = {
HARMLESS: 0,
CONTAINED: 1,
UNKNOWN: 2,
DANGEROUS: 3,
REFUSED: 4,
}
@dataclass(frozen=True)
class Action:
tool: str
arguments: Mapping[str, Any]
def key(self) -> str:
"""What an approval binds to: exactly this tool with exactly these
arguments (L3)."""
return json.dumps(
{"tool": self.tool, "arguments": dict(self.arguments)}, sort_keys=True
)
@dataclass(frozen=True)
class Verdict:
effect: Effect
rule: str # the rule that decided, for the log and the evaluation set
reason: str # in words, for the operator or the model
def worst(verdicts: Iterable[Verdict]) -> Verdict:
"""The most severe verdict; harmless when there are none."""
found = list(verdicts)
if not found:
return Verdict(HARMLESS, "empty", "nothing to run")
return max(found, key=lambda v: _SEVERITY[v.effect])
# --- reading shell commands --------------------------------------------------
@dataclass(frozen=True)
class Command:
"""One simple command of a shell line, as far as it can be known without
running it. ``cwd`` is where it runs: None once a ``cd`` went somewhere
that can't be worked out."""
argv: tuple[str, ...]
cwd: str | None
redirects: tuple[str, ...] = () # files written by > and >>
# Separators between commands, and redirection operators.
_SEPARATORS = frozenset({";", "&&", "||", "|", "&", "\n", "|&"})
# Prefixes that run the rest of the line as the command.
_WRAPPERS = frozenset(
{"sudo", "env", "nice", "nohup", "time", "command", "exec", "stdbuf", "xargs"}
)
_UNREADABLE = ("$(", "`", "<<", "<(", ">(")
def split_shell(text: str) -> list[tuple[list[str], list[str]]] | None:
"""The line's simple commands, each as (argv, files written by
redirection); None when it can't be read statically."""
if any(mark in text for mark in _UNREADABLE):
return None
lexer = shlex.shlex(text, posix=True, punctuation_chars="();<>|&\n")
lexer.whitespace = " \t\r"
lexer.whitespace_split = True
lexer.commenters = "#"
try:
tokens = list(lexer)
except ValueError: # unbalanced quotes
return None
commands: list[tuple[list[str], list[str]]] = []
argv: list[str] = []
written: list[str] = []
i = 0
while i < len(tokens):
token = tokens[i]
if token in ("(", ")"):
return None # a subshell or a function
if token in _SEPARATORS:
if argv:
commands.append((argv, written))
argv, written = [], []
elif set(token) <= set("<>&|") and ("<" in token or ">" in token):
# A redirection: the next token is its target. A file descriptor
# just before it (2>) belongs to the operator, not the command.
if argv and argv[-1].isdigit():
argv.pop()
target = tokens[i + 1] if i + 1 < len(tokens) else ""
i += 1
if ">" in token and not (token.endswith("&") and target.isdigit()):
written.append(target)
else:
argv.append(token)
i += 1
if argv:
commands.append((argv, written))
return commands
def unwrap(argv: list[str]) -> list[str]:
"""The command a wrapper runs: ``sudo make test`` is ``make test``.
Leading variable assignments go too."""
while argv:
head = argv[0]
if (
"=" in head
and not head.startswith("-")
and head.split("=", 1)[0].isidentifier()
):
argv = argv[1:]
elif head in _WRAPPERS:
argv = argv[1:]
while argv and (argv[0].startswith("-") or "=" in argv[0]):
argv = argv[1:] # the wrapper's own options
elif head == "timeout":
argv = argv[1:]
while argv and argv[0].startswith("-"):
# -k and -s take a value, unless it's attached (--signal=KILL)
takes = argv[0] in ("-k", "-s", "--kill-after", "--signal")
argv = argv[2:] if takes else argv[1:]
argv = argv[1:] # the duration
else:
break
return argv
def resolve(path: str, cwd: str | None) -> str | None:
"""Where ``path`` points, without touching the filesystem; None when that
can't be known (a variable, a home directory, an unknown cwd)."""
if not path or "$" in path or path.startswith("~"):
return None
if posixpath.isabs(path):
return posixpath.normpath(path)
if cwd is None:
return None
return posixpath.normpath(posixpath.join(cwd, path))
def inside(path: str | None, root: str) -> bool:
if path is None:
return False
root = posixpath.normpath(root)
return path == root or path.startswith(root.rstrip("/") + "/")
Judge = Callable[[Command], Verdict]
def classify_shell(text: str, cwd: str, judge: Judge, *, depth: int = 0) -> Verdict:
"""The worst verdict over the line's commands, each judged by the
agent's rules. ``cd`` moves where the rest of the line runs, and
``sh -c '...'`` is read as the line it runs."""
commands = split_shell(text)
if commands is None:
return Verdict(UNKNOWN, "unreadable", "can't be read without running it")
verdicts: list[Verdict] = []
here: str | None = cwd
for raw, written in commands:
argv = unwrap(raw)
if not argv:
continue
if argv[0] == "cd":
here = resolve(argv[1], here) if len(argv) > 1 else None
continue
if argv[0] in ("sh", "bash", "zsh", "dash") and "-c" in argv:
inner = argv[argv.index("-c") + 1 :]
if not inner or depth > 3:
verdicts.append(Verdict(UNKNOWN, "unreadable", "a nested shell"))
continue
verdicts.append(
classify_shell(inner[0], here or cwd, judge, depth=depth + 1)
if here is not None
else Verdict(UNKNOWN, "unreadable", "a shell in an unknown directory")
)
continue
verdicts.append(judge(Command(tuple(argv), here, tuple(written))))
return worst(verdicts)
+121
View File
@@ -0,0 +1,121 @@
import pytest
from nisse_core.actions import (
CONTAINED,
DANGEROUS,
HARMLESS,
REFUSED,
UNKNOWN,
Action,
Command,
Verdict,
classify_shell,
inside,
resolve,
split_shell,
unwrap,
worst,
)
def test_an_approval_binds_to_the_exact_tool_and_arguments() -> None:
a = Action("run_command", {"command": "git push"})
assert a.key() == Action("run_command", {"command": "git push"}).key()
assert a.key() != Action("run_command", {"command": "git push -f"}).key()
assert a.key() != Action("write_file", {"command": "git push"}).key()
def test_the_worst_verdict_wins() -> None:
verdicts = [
Verdict(HARMLESS, "a", ""),
Verdict(DANGEROUS, "b", ""),
Verdict(UNKNOWN, "c", ""),
]
assert worst(verdicts).rule == "b"
assert worst([Verdict(DANGEROUS, "d", ""), Verdict(REFUSED, "r", "")]).rule == "r"
assert worst([]).effect == HARMLESS
@pytest.mark.parametrize(
("line", "commands"),
[
("pytest -q", [(["pytest", "-q"], [])]),
(
"pytest 2>/dev/null && git status",
[(["pytest"], ["/dev/null"]), (["git", "status"], [])],
),
(
"ls | grep x; echo hi >> log",
[(["ls"], []), (["grep", "x"], []), (["echo", "hi"], ["log"])],
),
("make test 2>&1 | tail -5", [(["make", "test"], []), (["tail", "-5"], [])]),
("echo 'a > b'", [(["echo", "a > b"], [])]),
("cd sub\nls", [(["cd", "sub"], []), (["ls"], [])]),
("ls # a comment", [(["ls"], [])]),
],
)
def test_a_line_splits_into_its_commands(line: str, commands: list) -> None:
assert split_shell(line) == commands
@pytest.mark.parametrize(
"line",
["echo $(whoami)", "echo `id`", "cat <<EOF\nx\nEOF", "(cd /; rm x)", "echo 'open"],
)
def test_what_cant_be_read_statically_is_not_split(line: str) -> None:
assert split_shell(line) is None
@pytest.mark.parametrize(
("argv", "command"),
[
(["sudo", "apt-get", "install", "x"], ["apt-get", "install", "x"]),
(["FOO=1", "BAR=2", "pytest"], ["pytest"]),
(["env", "-i", "A=1", "make"], ["make"]),
(["timeout", "60", "pytest", "-x"], ["pytest", "-x"]),
(["timeout", "-k", "5", "60", "pytest"], ["pytest"]),
(["timeout", "--signal=KILL", "60", "make"], ["make"]),
(["nohup", "python", "serve.py"], ["python", "serve.py"]),
],
)
def test_wrappers_are_seen_through(argv: list[str], command: list[str]) -> None:
assert unwrap(argv) == command
def test_paths_resolve_without_touching_the_filesystem() -> None:
assert resolve("a/../b", "/w") == "/w/b"
assert resolve("/etc/passwd", "/w") == "/etc/passwd"
assert resolve("$HOME/x", "/w") is None
assert resolve("~/x", "/w") is None
assert resolve("x", None) is None
assert inside("/w/b", "/w") and inside("/w", "/w/")
assert not inside("/wx", "/w") and not inside(None, "/w")
def judge(command: Command) -> Verdict:
"""A toy rule set: rm is dangerous outside /w, ls harmless."""
if command.argv[0] == "ls":
return Verdict(HARMLESS, "ls", "")
if command.argv[0] == "rm":
target = resolve(command.argv[-1], command.cwd)
if inside(target, "/w"):
return Verdict(CONTAINED, "rm-inside", "")
return Verdict(DANGEROUS, "rm-outside", "")
return Verdict(UNKNOWN, "no-rule", "")
@pytest.mark.parametrize(
("line", "rule"),
[
("ls && rm x", "rm-inside"),
("cd /tmp && rm x", "rm-outside"),
("cd sub && rm x", "rm-inside"),
("cd && rm x", "rm-outside"), # cd with no argument: home, unknown
("bash -c 'cd /etc; rm passwd'", "rm-outside"),
("sh -c 'ls'", "ls"),
("ls; frobnicate", "no-rule"),
("ls $(rm -rf /)", "unreadable"),
],
)
def test_a_line_is_as_bad_as_its_worst_command(line: str, rule: str) -> None:
assert classify_shell(line, "/w", judge).rule == rule