M3.1: action classes and the fast path
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:
1 parent
d0fde2f136
commit
ea4376eaa1
16 files changed
+1400
-17
No files matched your search
@@ -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`).
|
||||
@@ -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)
|
||||
@@ -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
|
||||
Reference in new issue
Block a user