Skip to content

Commit b731822

Browse files
MSD21091969claude
andcommitted
review fixup: close §M12 intra-batch bypass + authority_scope fallback
Addresses all 5 line-level findings from Gemini + Copilot on #31. (1) Gemini SECURITY-HIGH — ADD+MUTATE intra-batch bypass (liveness.go:81): Pre-fix: ApplyProgram ran checkLiveness (including §M12) against the batch-initial state. A program that ADDed an ordinary node and then MUTATEd a kernel-authority property on it bypassed §M12 because the classifier saw the target missing from initial state and returned false. Fix: split liveness into two phases with different state-resolution rules: - §M11 (emitter-context) runs in preflight against batch-initial state. Emitter references cannot depend on prior envelopes; initial-state-check is the documented doctrine (impl-plan §2.4). - §M12 (target-operation) runs per-envelope INSIDE the write-locked working-state loop in ApplyProgram. By the time envelope N runs, all nodes ADDed in envelopes 1..N-1 are visible; target classification is correct. New method split in kernel/liveness.go: checkLivenessM11(env, state) — emitter-context only checkLivenessM12(env, state) — admin-scope only checkLiveness(env) — both against rt.state (Apply helper) ApplyProgram preflight loop calls checkLivenessM11 only; the working- state loop adds a checkLivenessM12 call at the top of each iteration. (2) Gemini SECURITY-HIGH — empty AuthorityScope bypass (session_context.go:333): Pre-fix: authorityScopeForField trusted the live property's AuthorityScope unconditionally, even when empty. An ADD that failed to populate the metadata left the field classified as no-authority-scope forever. Fix: fall through to the registry type spec when the stored scope is empty. The registry is authoritative — trust it over potentially- missing node metadata. Same pattern the ValidateMUTATE additive path already uses. (3) Gemini MEDIUM — redundant kernel exclusion (session_context.go:315): Pre-fix: case 2 had `if scope == "kernel" && node.TypeID != "kernel"`. The kernel-type exclusion was redundant because case 1 would have already returned true for kernel-typed nodes. Fix: drop the kernel check. Code reads cleaner; behavior unchanged. (4) Copilot — misleading test name (session_context_test.go:394): TestAdminScopeRewrite_MUTATEKernelAuthorityFieldOnKernelNode_NotAdminScope asserted admin-scope=true (via case 1). Renamed to ...MUTATEOnKernelNode_AdminScopeViaOntologyGovernedType. (5) Copilot — misleading precedence comment (session_context.go:236): The ontologyGovernedTypes docstring said ADD/MUTATE "requires superadmin capability" without noting that kernel-actor and infrastructure-bootstrap envelopes bypass §M12 via the §M11 allowlist upstream. Added an IMPORTANT block clarifying the precedence. Tests (4 new): operad.TestAdminScopeRewrite_MUTATETargetMissing_FailsClosed operad.TestAdminScopeRewrite_EmptyStoredAuthorityScope_FallsBackToTypeSpec kernel.TestApplyProgram_M12_IntraBatchADDThenKernelAuthorityMUTATE_Rejected kernel.TestApplyProgram_M12_IntraBatchADDThenOwnerMUTATE_Passes Plus the existing test rename. 21 total §M12-related tests now (9 operad unit + 12 kernel integration). go build ./... # clean go test ./... # all packages pass Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent a08f0fa commit b731822

5 files changed

Lines changed: 297 additions & 81 deletions

File tree

internal/kernel/liveness.go

Lines changed: 75 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import (
88
"moos/kernel/internal/operad"
99
)
1010

11-
// §M11 liveness gate and §M12 admin-capability hook for kernel rewrites.
11+
// §M11 liveness gate and §M12 admin-capability gate for kernel rewrites.
1212
//
1313
// Doctrine:
1414
//
@@ -18,49 +18,64 @@ import (
1818
// - §M12 says admin-scope rewrites additionally require the actor (after
1919
// has-occupant resolution) to hold the superadmin role via WF02 governs.
2020
//
21-
// This file integrates both checks into Runtime.Apply / Runtime.ApplyProgram
22-
// as small helpers so the main Apply body stays readable. §M12 is plumbed
23-
// here via operad.AdminScopeRewrite + operad.CheckAdminCapability and ships
24-
// as dormant in PR 3 (AdminScopeRewrite returns false until PR 4 fills it in).
25-
26-
// checkLiveness is the §M11 gate. Called from Apply / ApplyProgram BEFORE
27-
// operad validation so we fail fast on missing session context without
28-
// paying structural-validation cost. Read-only over state; no locking
29-
// required because Apply/ApplyProgram are already serialised by rt.mu in
30-
// the caller (or read-lock during validation).
21+
// Evaluation surface:
22+
//
23+
// - §M11 evaluates emitter-context: the session this envelope runs under.
24+
// That session must pre-exist. For ApplyProgram, §M11 runs in preflight
25+
// against the batch-initial state — emitter references cannot depend on
26+
// prior envelopes in the same batch (see the initial-state-check
27+
// doctrine note in kb/research/kernel/20260421-t171-m11-m12-implementation-plan.md §2.4).
3128
//
32-
// Order of checks:
33-
// 1. System-internal allowlist (sweep, kernel-actor, infrastructure ADD).
34-
// These are below the governance line and always pass.
35-
// 2. Session-context resolution via operad.ResolveSessionForEnvelope.
36-
// Explicit env.SessionURN wins; inferred from Actor when unambiguous;
37-
// reject when ambiguous or absent.
38-
// 3. (PR 4 hook) admin-scope classification + capability check. Dormant
39-
// in PR 3.
29+
// - §M12 evaluates target-operation: what the envelope does. That operation
30+
// can depend on nodes created earlier in the same batch. For ApplyProgram,
31+
// §M12 runs per-envelope inside the working-state loop — a MUTATE on a
32+
// node ADDed in envelope N-1 sees that node's type when classifying at
33+
// envelope N. Closes the Gemini PR 31 security-HIGH where §M12 was
34+
// evaluated against initial state and ADD-then-MUTATE could bypass the
35+
// admin gate.
36+
37+
// checkLiveness runs both §M11 and §M12 against rt.state (no working state
38+
// evolves). Used by Apply. The combined form is safe here because Apply
39+
// handles a single envelope — the target either exists at call time or it
40+
// doesn't, and ApplyProgram's mid-batch scenario cannot arise.
4041
//
41-
// Returns nil on pass, a fmt.Errorf wrapping the failure mode on reject.
42-
// Error messages name the doctrine section so log readers can trace back.
42+
// Caller must hold rt.mu at least for read. Returns nil on pass; a
43+
// fmt.Errorf naming the doctrine section on reject.
4344
func (rt *Runtime) checkLiveness(env graph.Envelope) error {
44-
// Registry-less mode (no --ontology): liveness is a no-op. The kernel
45-
// still replays and applies rewrites; it just does not enforce the
46-
// occupancy invariant. Matches the existing pattern where validators
47-
// short-circuit when registry is nil.
45+
if err := rt.checkLivenessM11(env, rt.state); err != nil {
46+
return err
47+
}
48+
return rt.checkLivenessM12(env, rt.state)
49+
}
50+
51+
// checkLivenessM11 is the §M11 emitter-context gate. Single-phase: the
52+
// session URN the envelope runs under must exist in the passed state and
53+
// must have the actor as has-occupant (or the actor must itself be an
54+
// occupied session node).
55+
//
56+
// Parameter `state` is explicit so callers can choose whether to evaluate
57+
// against rt.state (Apply, single-envelope) or some other snapshot. For
58+
// ApplyProgram we always pass the batch-initial state because §M11 is an
59+
// emitter-pre-existence rule — emitter references cannot depend on prior
60+
// envelopes in the batch.
61+
//
62+
// Returns nil on pass; a fmt.Errorf for each resolver failure kind.
63+
func (rt *Runtime) checkLivenessM11(env graph.Envelope, state graph.GraphState) error {
4864
if rt.registry == nil {
4965
return nil
5066
}
5167

52-
// Step 1 — system-internal allowlist. Sweep, kernel actors, and
53-
// bootstrap-infrastructure ADDs are below the liveness line.
68+
// System-internal allowlist precedes all M11/M12 enforcement. Sweep,
69+
// kernel actors, and bootstrap-infrastructure ADDs are below the
70+
// governance line and bypass both gates.
5471
if operad.SystemInternalEnvelope(env) {
5572
return nil
5673
}
5774

58-
// Step 2 — resolve session context. Pass the live state so reverse
59-
// has-occupant walks see the most recent seat assignments.
60-
res := operad.ResolveSessionForEnvelope(rt.state, env)
75+
res := operad.ResolveSessionForEnvelope(state, env)
6176
switch res.Kind {
6277
case operad.ResolveSessionExplicit, operad.ResolveSessionInferred, operad.ResolveSessionActorIsSession:
63-
// Pass: a single, verified session context was resolved.
78+
return nil
6479
case operad.ResolveSessionExplicitMismatch:
6580
return fmt.Errorf("kernel(§M11): envelope names session_urn=%s but that session is missing, not of type session, or does not have has-occupant -> actor=%s",
6681
res.SessionURN, env.Actor)
@@ -71,20 +86,37 @@ func (rt *Runtime) checkLiveness(env graph.Envelope) error {
7186
return fmt.Errorf("kernel(§M11): no session context for actor=%s (no has-occupant relation in state and no session_urn on envelope)",
7287
env.Actor)
7388
}
89+
return nil
90+
}
7491

75-
// Step 3 — admin-scope gate (§M12). Classifies the envelope via the
76-
// registry's AdminScopeRewrite method (sees type specs for property
77-
// authority_scope lookup); if the envelope touches admin surface,
78-
// the actor must hold superadmin capability via WF02 governs. Fails
79-
// closed on any missing hop. The §M11 allowlist above already let
80-
// kernel-actor envelopes through — they never reach this path.
81-
if rt.registry.AdminScopeRewrite(env, rt.state) {
82-
if !operad.CheckAdminCapability(rt.state, env.Actor) {
83-
return fmt.Errorf("kernel(§M12): actor=%s lacks WF02 superadmin capability for admin-scope rewrite",
84-
env.Actor)
85-
}
92+
// checkLivenessM12 is the §M12 admin-capability gate. Classifies the
93+
// envelope via the registry's AdminScopeRewrite method against the passed
94+
// state — if classified admin-scope, the actor must hold WF02 superadmin
95+
// capability. The state parameter matters: for ApplyProgram, the caller
96+
// passes the working state AFTER earlier envelopes in the batch have been
97+
// folded so a MUTATE on a mid-batch-ADDed node sees the node's type
98+
// correctly.
99+
//
100+
// The §M11 allowlist (SystemInternalEnvelope) is re-checked here to keep
101+
// checkLivenessM12 safe to call in isolation — if a future caller uses it
102+
// without first running checkLivenessM11, kernel-actor and infrastructure
103+
// envelopes still bypass correctly.
104+
//
105+
// Returns nil on pass; a fmt.Errorf naming §M12 on reject.
106+
func (rt *Runtime) checkLivenessM12(env graph.Envelope, state graph.GraphState) error {
107+
if rt.registry == nil {
108+
return nil
109+
}
110+
if operad.SystemInternalEnvelope(env) {
111+
return nil
112+
}
113+
if !rt.registry.AdminScopeRewrite(env, state) {
114+
return nil
115+
}
116+
if !operad.CheckAdminCapability(state, env.Actor) {
117+
return fmt.Errorf("kernel(§M12): actor=%s lacks WF02 superadmin capability for admin-scope rewrite",
118+
env.Actor)
86119
}
87-
88120
return nil
89121
}
90122

internal/kernel/liveness_test.go

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -677,6 +677,95 @@ func TestSeedIfAbsent_M12_OntologyGovernedADD_BypassesBoth(t *testing.T) {
677677
}
678678
}
679679

680+
// TestApplyProgram_M12_IntraBatchADDThenKernelAuthorityMUTATE_Rejected
681+
// pins the Gemini security-HIGH fix on PR 31: a batch that ADDs a new
682+
// program node and in the same batch MUTATEs a kernel-authority property
683+
// on it previously bypassed §M12 because the preflight checker saw the
684+
// target missing from the batch-initial state and classified as
685+
// not-admin-scope. Post-fix, §M12 runs against the working state inside
686+
// the write-locked loop; by envelope 2, the program node exists and its
687+
// kernel-authority field is correctly classified admin-scope.
688+
func TestApplyProgram_M12_IntraBatchADDThenKernelAuthorityMUTATE_Rejected(t *testing.T) {
689+
rt := newAdminRuntime(t)
690+
injectOccupancy(rt,
691+
"urn:moos:session:sam.a",
692+
"urn:moos:agent:claude",
693+
"agent",
694+
) // claude occupies a session, passes §M11, no superadmin
695+
696+
preLen := rt.LogLen()
697+
698+
program := []graph.Envelope{
699+
{
700+
RewriteType: graph.ADD,
701+
Actor: "urn:moos:agent:claude",
702+
NodeURN: "urn:moos:program:freshly-baked",
703+
TypeID: "program",
704+
},
705+
{
706+
RewriteType: graph.MUTATE,
707+
Actor: "urn:moos:agent:claude",
708+
TargetURN: "urn:moos:program:freshly-baked", // created in envelope 1
709+
Field: "target_t", // authority_scope=kernel per type spec
710+
NewValue: 200.0,
711+
},
712+
}
713+
_, err := rt.ApplyProgram(program)
714+
if err == nil {
715+
t.Fatalf("ADD+kernel-authority-MUTATE batch without superadmin must be rejected")
716+
}
717+
if !strings.Contains(err.Error(), "§M12") {
718+
t.Errorf("error should cite §M12; got %q", err.Error())
719+
}
720+
if got := rt.LogLen(); got != preLen {
721+
t.Errorf("atomic rejection should leave log unchanged; pre=%d post=%d", preLen, got)
722+
}
723+
}
724+
725+
// TestApplyProgram_M12_IntraBatchADDThenOwnerMUTATE_Passes confirms the
726+
// fix does not over-reject: a batch that ADDs a program and then
727+
// MUTATEs an owner-authority field on it (e.g. status) still passes §M12
728+
// because the mid-batch classification correctly sees the target as a
729+
// program (not ontology-governed) and the field as owner-authority.
730+
func TestApplyProgram_M12_IntraBatchADDThenOwnerMUTATE_Passes(t *testing.T) {
731+
rt := newAdminRuntime(t)
732+
injectOccupancy(rt,
733+
"urn:moos:session:sam.a",
734+
"urn:moos:agent:claude",
735+
"agent",
736+
)
737+
// Register WF18 so ValidateMUTATE is happy.
738+
rt.registry.RewriteCategories[graph.WF18] = operad.RewriteCategorySpec{
739+
ID: graph.WF18,
740+
AllowedRewrites: []graph.RewriteType{graph.ADD, graph.MUTATE, graph.LINK},
741+
MutateScope: []string{"status"},
742+
}
743+
744+
program := []graph.Envelope{
745+
{
746+
RewriteType: graph.ADD,
747+
Actor: "urn:moos:agent:claude",
748+
NodeURN: "urn:moos:program:ordinary",
749+
TypeID: "program",
750+
Properties: map[string]graph.Property{
751+
"status": {Value: "active", Mutability: "mutable", AuthorityScope: "owner"},
752+
"owner_urn": {Value: "urn:moos:agent:claude", Mutability: "immutable"},
753+
},
754+
},
755+
{
756+
RewriteType: graph.MUTATE,
757+
Actor: "urn:moos:agent:claude",
758+
TargetURN: "urn:moos:program:ordinary",
759+
Field: "status", // owner-authority
760+
NewValue: "checkpoint",
761+
RewriteCategory: graph.WF18,
762+
},
763+
}
764+
if _, err := rt.ApplyProgram(program); err != nil {
765+
t.Fatalf("owner-authority MUTATE on same-batch-ADDed program must pass §M12; got %v", err)
766+
}
767+
}
768+
680769
// Test 8 — fold.Replay of pre-PR-4 rewrites that would now be admin-scope
681770
// still replays cleanly. fold doesn't call checkLiveness, so §M12 has no
682771
// retroactive effect. Mirrors the prospective-only invariant for §M11

internal/kernel/runtime.go

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -184,16 +184,15 @@ func (rt *Runtime) applyWithOptions(env graph.Envelope, opts applyOptions) (grap
184184
// §M11 liveness is checked per-envelope before structural validation so a
185185
// single session-less envelope fails the whole batch fast.
186186
func (rt *Runtime) ApplyProgram(envelopes []graph.Envelope) ([]graph.EvalResult, error) {
187-
// Preflight under read-lock: §M11 liveness checks read rt.state maps,
188-
// so we must hold the read-lock for the entire batch preflight. Held
189-
// across all envelopes so liveness observations are consistent with
190-
// each other. Release before acquiring the write-lock below (RWMutex
191-
// does not support upgrade). Same apply-time guarantee as Apply:
192-
// fold.EvaluateProgram under the write-lock is the authoritative
193-
// check; preflight rejects early on clearly-bad batches.
187+
// Preflight under read-lock: §M11 (emitter-context) checks read rt.state
188+
// maps, so we hold the read-lock across the entire batch preflight. §M11
189+
// is evaluated against the batch-initial state — emitter references
190+
// cannot depend on prior envelopes in the batch (see impl-plan §2.4).
191+
// §M12 is NOT checked here; it runs per-envelope inside the write-locked
192+
// working-state loop below so target-classification sees mid-batch ADDs.
194193
rt.mu.RLock()
195194
for _, env := range envelopes {
196-
if err := rt.checkLiveness(env); err != nil {
195+
if err := rt.checkLivenessM11(env, rt.state); err != nil {
197196
rt.mu.RUnlock()
198197
return nil, err
199198
}
@@ -229,6 +228,17 @@ func (rt *Runtime) ApplyProgram(envelopes []graph.Envelope) ([]graph.EvalResult,
229228
}
230229
injected[i] = env
231230

231+
// §M12 admin-scope gate against working state. Per-envelope so a
232+
// MUTATE on a node ADDed in an earlier envelope of this batch sees
233+
// the node's type when classifying. Closes the PR 31 Gemini
234+
// security-HIGH where initial-state preflight let a batch ADD a
235+
// target and then MUTATE its kernel-authority property without
236+
// triggering the admin check. §M11 preflight already ran above;
237+
// if we reach here the emitter-context check has passed.
238+
if err := rt.checkLivenessM12(env, workingState); err != nil {
239+
return nil, err
240+
}
241+
232242
// Strata enforcement (M5) + Gate check (M8) against the working state.
233243
if env.RewriteType == graph.LINK && rt.registry != nil {
234244
if err := rt.registry.ValidateStrataLink(env, workingState); err != nil {

0 commit comments

Comments
 (0)