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
@@ -23,7 +23,14 @@ from test_loop_milestones import world as world # noqa: PLC0414 -- the fixture
|
||||
from test_loop_projects import Router
|
||||
|
||||
from nisse_dev.cancel import Cancel
|
||||
from nisse_dev.canonical import Fact, PlanSubmitted, ToolCall, UserMessage
|
||||
from nisse_dev.canonical import (
|
||||
ActionVerdict,
|
||||
Fact,
|
||||
PlanSubmitted,
|
||||
ToolCall,
|
||||
ToolResult,
|
||||
UserMessage,
|
||||
)
|
||||
from nisse_dev.delegation import DELEGATION_INTENT, Caps
|
||||
from nisse_dev.execution import SSHHost
|
||||
from nisse_dev.gitea import Comment, Issue
|
||||
@@ -58,6 +65,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"
|
||||
|
||||
|
||||
class PolicyModel:
|
||||
"""Plays a plausible driver: restate first, then a milestone, fixes
|
||||
after comments, close after a merge; sometimes asks a question or
|
||||
@@ -118,6 +129,15 @@ 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:]):
|
||||
# Told to, as the driver is: run it again exactly as before.
|
||||
return [call("run_command", command=DANGEROUS_COMMAND)]
|
||||
if self.rng.random() < 0.12:
|
||||
# Dangerous, always the same, so an allow can match it (P3, L3).
|
||||
return [
|
||||
call("start_milestone", name=f"m{self.n}"),
|
||||
call("run_command", command=DANGEROUS_COMMAND),
|
||||
]
|
||||
if "wait_for_review" in offered and self.rng.random() < 0.2:
|
||||
return [call("wait_for_review", note=f"waiting {self.n}")]
|
||||
if self.rng.random() < 0.15:
|
||||
@@ -152,6 +172,8 @@ 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)]
|
||||
return [
|
||||
call("write_file", path=f"h{self.n}.py", content=f"h = {self.n}\n"),
|
||||
ToolCall(
|
||||
@@ -265,7 +287,7 @@ class Walk:
|
||||
if item and loop:
|
||||
weights.update({"go": 3, "answer": 2, "abandon": 1, "go-stale": 1})
|
||||
weights.update({"plain-yes": 2, "wait": 2, "question": 2})
|
||||
weights.update({"status-here": 1})
|
||||
weights.update({"status-here": 1, "allow": 3 if loop.pending_action else 1})
|
||||
if loop.new_project:
|
||||
weights.update({"done": 2})
|
||||
if item.scope != "general": # conversations aren't named by number
|
||||
@@ -358,6 +380,8 @@ class Walk:
|
||||
self.agent, self.transport = self._agent()
|
||||
self.agent.boot()
|
||||
self.agent._schedule()
|
||||
elif action == "allow" and loop:
|
||||
self.transport.receive(DON, "allow", thread=loop.thread)
|
||||
elif action in ("go", "answer", "abandon") and loop:
|
||||
text = {"go": "go", "answer": "an answer", "abandon": "abandon"}[action]
|
||||
if action == "go" and item.position == WAITING and loop.wait == GO_AHEAD:
|
||||
@@ -396,8 +420,36 @@ class Walk:
|
||||
|
||||
# -- the properties (specs/loop.qnt) -------------------------------------
|
||||
|
||||
def _dangerous_only_when_allowed(self, where: str) -> None:
|
||||
"""P3, L3: a dangerous action runs only on an operator's allow, one
|
||||
allow per run of it, and is otherwise never run."""
|
||||
store = self.world["store"]
|
||||
for item in store.work_items():
|
||||
events = store.transcript(item.id, "orchestrator").read()
|
||||
allows = 0
|
||||
held: set[str] = set() # dangerous calls that weren't run
|
||||
for e in events:
|
||||
event = e.event
|
||||
if isinstance(event, UserMessage) and event.text == "allow":
|
||||
allows += 1
|
||||
elif isinstance(event, ActionVerdict) and event.effect == "dangerous":
|
||||
if event.decision == "allowed":
|
||||
allows -= 1
|
||||
assert allows >= 0, (
|
||||
f"dangerous_only_when_allowed: {item.id}: {where}"
|
||||
)
|
||||
else:
|
||||
assert event.decision in ("asked", "refused"), where
|
||||
held.add(event.call_id)
|
||||
elif isinstance(event, ToolResult) and event.call_id in held:
|
||||
assert event.result.startswith("not run"), (
|
||||
f"dangerous_only_when_allowed: {item.id} ran "
|
||||
f"{event.call_id} unallowed: {where}"
|
||||
)
|
||||
|
||||
def check(self) -> None:
|
||||
where = f"seed {self.seed}, steps {self.steps}"
|
||||
self._dangerous_only_when_allowed(where)
|
||||
store = self.world["store"]
|
||||
items = self.items()
|
||||
# Open items: an issue assigned again after its item closed gets a new
|
||||
|
||||
Reference in new issue
Block a user