M3.1: action classes and the fast path #127

Merged
dongar merged 1 commits from action-classes into main 2026-10-08 11:25:48 +00:00
Collaborator

M3.1: action classes and the fast path (P3, P4, D6). Every execution tool call is judged by code before it runs, the driver's and a helper's alike.

The agreed decisions:

  • Unknown actions run, and are logged, until the classifier exists (M3.2).
  • The shared part goes in nisse-core.
  • Changing branches by hand is refused outright.

Shared part (nisse_core.actions, nisse-core's first real code)

  • Action and Verdict types. Effects are harmless, contained, unknown, dangerous or refused.
  • Shell commands are read statically, segment by segment across &&, ;, | and newlines.
    • A cd moves where the rest of the line runs.
    • Wrappers like sudo, env and timeout are seen through, and sh -c '…' is read as the line it runs.
    • Substitutions, subshells and here-documents are unknown, never guessed at.
    • A line is as bad as its worst command.

nisse-dev's rules (nisse_dev.loop.actions)

  • Harmless: reading, git status/diff/log, tests, linters and builds (pytest, uv run …, make test, npm test, cargo test).
  • Contained: workspace edits, local git (add, commit, restore), installs on the build VM, and other changes there.
  • Dangerous: pushing, other hosts (ssh, scp, rsync host:), uploads (curl -d/-T/-X POST), publishing, the forge's own tools (tea, gh, twine), deleting outside the workspace, and wrecking the VM (dd, mkfs, reboot, iptables).
  • Refused: git checkout -b/<branch>, switch, branch <name>/-D, merge, rebase, pull. The reason says the harness owns branches, and points at start_milestone.

In the loop

  • A dangerous call isn't run. The item waits on the operator with a question rendered in code from the action itself (L3, L11). For example: #14 wants to run rm -rf /srv/x: rm deletes outside the workspace: /srv/x. Say allow to let exactly this run once…
  • allow binds to that exact action, once. A second identical call is asked about again, and a different command isn't covered. An answer instead of allow goes to the driver as usual.
  • A helper can't ask, so a dangerous call from a helper is refused, and the helper is told to hand back and say what it needs.
  • Every verdict is logged as an action_verdict event and an oplog line. No model sees the events; this is M3.2's evaluation set. details shows the counts and every action that wasn't simply run.
  • Two smaller changes:
    • a prompt line says such commands are put to the operator, and to never change branches;
    • the dump tool shows verdicts.

Tests

  • Rule table: 83 cases. Shell reading: 31 cases in nisse-core.
  • Loop behaviour: asked, allowed once, not covered, answered instead, branches refused with the run going on, helper refused, details, and no model seeing a verdict.
  • The property walk gains dangerous_only_when_allowed. The walk's model now tries a dangerous command, and repeats it after an allow, as the prompt tells it to. Across 16 seeds it reaches asked, allowed and helper-refused.
    • It catches a mutant where the allow isn't used up (seed 6).
  • The slice e2e checks that every call was judged and none needed an allow.

Results, run locally before pushing:

  • Unit: 683 passed.
  • Integration: 17 passed, plus 7 opt-in skips.
  • Full e2e: 8 of 8 passed.
  • What the real model's calls looked like across those 8 e2e runs: 113 calls in all — 79 harmless, 32 contained, 2 unknown (inline Python, and a line that can't be read statically), none dangerous or refused.

Deferred:

  • the classifier, and judging what's in scope (M3.2);
  • the trust dial (M3.3);
  • enforcing egress, the real hard wall behind the network rules (X2).

🤖 Generated with Claude Code

https://claude.ai/code/session_01PPQ14L3LikyefCMFBqEbKE

**M3.1: action classes and the fast path** (P3, P4, D6). Every execution tool call is judged by code before it runs, the driver's and a helper's alike. **The agreed decisions:** - **Unknown actions** run, and are logged, until the classifier exists (M3.2). - **The shared part** goes in `nisse-core`. - **Changing branches by hand** is refused outright. **Shared part (`nisse_core.actions`, `nisse-core`'s first real code)** - `Action` and `Verdict` types. Effects are harmless, contained, unknown, dangerous or refused. - **Shell commands are read statically, segment by segment** across `&&`, `;`, `|` and newlines. - A `cd` moves where the rest of the line runs. - Wrappers like `sudo`, `env` and `timeout` are seen through, and `sh -c '…'` is read as the line it runs. - Substitutions, subshells and here-documents are unknown, never guessed at. - A line is as bad as its worst command. **nisse-dev's rules (`nisse_dev.loop.actions`)** - **Harmless:** reading, `git status`/`diff`/`log`, tests, linters and builds (`pytest`, `uv run …`, `make test`, `npm test`, `cargo test`). - **Contained:** workspace edits, local git (`add`, `commit`, `restore`), installs on the build VM, and other changes there. - **Dangerous:** pushing, other hosts (`ssh`, `scp`, `rsync host:`), uploads (`curl -d`/`-T`/`-X POST`), publishing, the forge's own tools (`tea`, `gh`, `twine`), deleting outside the workspace, and wrecking the VM (`dd`, `mkfs`, `reboot`, `iptables`). - **Refused:** `git checkout -b`/`<branch>`, `switch`, `branch <name>`/`-D`, `merge`, `rebase`, `pull`. The reason says the harness owns branches, and points at `start_milestone`. **In the loop** - **A dangerous call isn't run.** The item waits on the operator with a question rendered in code from the action itself (L3, L11). For example: *#14 wants to run `rm -rf /srv/x`: rm deletes outside the workspace: /srv/x. Say `allow` to let exactly this run once…* - **`allow` binds to that exact action, once.** A second identical call is asked about again, and a different command isn't covered. An answer instead of `allow` goes to the driver as usual. - **A helper can't ask**, so a dangerous call from a helper is refused, and the helper is told to hand back and say what it needs. - **Every verdict is logged** as an `action_verdict` event and an oplog line. No model sees the events; this is M3.2's evaluation set. `details` shows the counts and every action that wasn't simply run. - **Two smaller changes:** - a prompt line says such commands are put to the operator, and to never change branches; - the `dump` tool shows verdicts. **Tests** - **Rule table:** 83 cases. **Shell reading:** 31 cases in `nisse-core`. - **Loop behaviour:** asked, allowed once, not covered, answered instead, branches refused with the run going on, helper refused, `details`, and no model seeing a verdict. - **The property walk** gains `dangerous_only_when_allowed`. The walk's model now tries a dangerous command, and repeats it after an allow, as the prompt tells it to. Across 16 seeds it reaches asked, allowed and helper-refused. - It catches a mutant where the allow isn't used up (seed 6). - **The slice e2e** checks that every call was judged and none needed an allow. **Results, run locally before pushing:** - **Unit:** 683 passed. - **Integration:** 17 passed, plus 7 opt-in skips. - **Full e2e:** 8 of 8 passed. - **What the real model's calls looked like** across those 8 e2e runs: 113 calls in all — 79 harmless, 32 contained, 2 unknown (inline Python, and a line that can't be read statically), none dangerous or refused. **Deferred:** - the classifier, and judging what's in scope (M3.2); - the trust dial (M3.3); - enforcing egress, the real hard wall behind the network rules (X2). 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01PPQ14L3LikyefCMFBqEbKE
dev-agent added the e2e:full label 2026-10-08 04:32:32 +00:00
dev-agent added 1 commit 2026-10-08 04:32:32 +00:00
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
ea4376eaa1
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
dongar approved these changes 2026-10-08 11:25:42 +00:00
dongar merged commit 5044af371b into main 2026-10-08 11:25:48 +00:00
dongar deleted branch action-classes 2026-10-08 11:25:48 +00:00
Sign in to join this conversation.
No Reviewers
2 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: dev/nisse#127