Merge pull request #13743 from esengine/fix/dispatch-proof-replay

fix(peers): a dispatch proof is valid once per sequence number, and the adopt/spool boundaries are pinned
This commit is contained in:
YHH authored and GitHub committed 2026-10-18 00:46:31 -07:00
commit d31d8daf07
10 files changed
+183 -18

No files matched your search

+1 -1
View File
@@ -145,7 +145,7 @@ allow = ["Bash(go test:*)"] # never prompted
[peers]
# enabled = false # your sessions on this machine can message each other; every message waits for that session's user to approve it; also a switch in Settings, applied at the next turn (user config only)
# dispatch = false # the model may hand a task to a session in another of your projects, via dispatch_to_project; needs enabled; turning it on is your consent for those sessions' tasks and replies, no card (user config only)
# dispatch = false # the model may hand a task to a session in another of your projects, via dispatch_to_project; needs enabled; turning it on is your consent for those sessions' tasks and replies, no card; the opened session keeps the sender's model, preset, approval mode and planning, so a YOLO sender opens a YOLO session that answers the target project's ask rules too (deny rules still hold) (user config only)
# max_dispatch_depth = 1 # how many sessions deep a dispatch may chain; 0 removes the ceiling (user config only)
# max_dispatched_sessions = 8 # sessions one session may keep open from its dispatches; 0 removes the ceiling (user config only)
# release_projects = [] # projects that take messages from your other sessions without the card; their own approvals and sandbox still apply (user config only)
+1 -1
View File
@@ -154,7 +154,7 @@ allow = ["Bash(go test:*)"] # 从不询问
[peers]
# enabled = false # 本机你的各个会话可以互发消息;每条消息都要对方会话的用户批准;设置里也有开关,下个回合起生效(只认用户级配置)
# dispatch = false # 模型可用 dispatch_to_project 把任务交给你另一个项目里的会话;需要 enabled;打开就是你对这些会话的任务和回信的总同意,不再出审批卡(只认用户级配置)
# dispatch = false # 模型可用 dispatch_to_project 把任务交给你另一个项目里的会话;需要 enabled;打开就是你对这些会话的任务和回信的总同意,不再出审批卡;新开的会话沿用发起方的模型、预设、审批档位和计划模式,所以 YOLO 的发起方开出的也是 YOLO 会话,会连目标项目的“要问我”规则一起答掉(拒绝规则仍然生效)(只认用户级配置)
# max_dispatch_depth = 1 # 派发最多能连着开几层会话;0 表示不限(只认用户级配置)
# max_dispatched_sessions = 8 # 一个会话通过派发最多同时开着几个会话;0 表示不限(只认用户级配置)
# release_projects = [] # 这些项目收到你其它会话的消息时不弹批准卡;它们自己的审批与沙盒照旧(只认用户级配置)
@@ -40,9 +40,11 @@ func TestEffectADispatchedTaskReachesTheModelWithoutACardAndAStrangersDoesNot(t
if err != nil {
t.Fatal(err)
}
var seq uint64
send := func(id, body string, signWith []byte) {
t.Helper()
m := peermail.Message{ID: id, SentAt: time.Now().UTC(), Body: body,
seq++
m := peermail.Message{ID: id, SentAt: time.Now().UTC(), Body: body, Seq: seq,
From: peermail.Party{SessionID: parent, Title: "planner", Project: "alpha"}, To: peermail.Party{SessionID: receiver}}
m.Proof = peermail.Proof(signWith, m)
if err := d.Send(m); err != nil {
@@ -21,6 +21,14 @@ func TestAConfinedCommandCannotTouchThePeerSpool(t *testing.T) {
if runtime.GOOS == "windows" || !sandbox.Available() {
t.Skip("no OS-level bash confinement on this machine")
}
t.Run("workspace beside the state root", func(t *testing.T) { confinedSpoolCase(t, false) })
// The workspace being the state root's parent (a project opened at the home
// directory) puts the spool inside a writable root; the forbidden-root rule
// has to win there too.
t.Run("workspace above the state root", func(t *testing.T) { confinedSpoolCase(t, true) })
}
func confinedSpoolCase(t *testing.T, workspaceAbove bool) {
base, err := os.MkdirTemp(os.Getenv("HOME"), ".reasonix-peers-test-")
if err != nil {
t.Skipf("cannot create a work dir under home: %v", err)
@@ -32,6 +40,9 @@ func TestAConfinedCommandCannotTouchThePeerSpool(t *testing.T) {
t.Fatal(err)
}
}
if workspaceAbove {
workspace = base
}
t.Setenv("REASONIX_HOME", home)
cfg, err := config.Load()
if err != nil {
+58 -13
View File
@@ -44,7 +44,46 @@ type dispatchState struct {
mu sync.Mutex
opened bool
depth int
pairs map[string][]byte
pairs map[string]*dispatchPair
}
// dispatchWindow is how many accepted sequence numbers a pairing remembers; one
// older than the newest by more than this is refused unseen.
const dispatchWindow = 4096
// dispatchPair is one pairing: the secret, the next number to sign with, and the
// numbers taken from the partner. It lives as long as the secret does, in memory
// only, so a message copied out of the spool cannot be replayed into this pairing
// however many others have been read since.
type dispatchPair struct {
secret []byte
sent uint64
seen map[uint64]bool
floor uint64
}
// accept records seq as taken, or refuses it when it was taken or has aged out.
func (p *dispatchPair) accept(seq uint64) bool {
if seq == 0 || seq <= p.floor || p.seen[seq] {
return false
}
if p.seen == nil {
p.seen = map[uint64]bool{}
}
p.seen[seq] = true
if len(p.seen) > dispatchWindow {
var newest uint64
for n := range p.seen {
newest = max(newest, n)
}
p.floor = newest - dispatchWindow
for n := range p.seen {
if n <= p.floor {
delete(p.seen, n)
}
}
}
return true
}
// SetProjectDispatcher gives the dispatch tool somewhere to go; a controller
@@ -94,27 +133,33 @@ func (c *Controller) rememberPairLocked(key string, secret []byte) {
return
}
if c.dispatch.pairs == nil {
c.dispatch.pairs = map[string][]byte{}
c.dispatch.pairs = map[string]*dispatchPair{}
}
c.dispatch.pairs[key] = append([]byte(nil), secret...)
c.dispatch.pairs[key] = &dispatchPair{secret: append([]byte(nil), secret...)}
}
// dispatchProof signs a message to a dispatch partner, or returns "" when the
// target is not one.
func (c *Controller) dispatchProof(m peermail.Message) string {
// signDispatch numbers and signs a message to a dispatch partner, and leaves it
// alone when the target is not one.
func (c *Controller) signDispatch(m *peermail.Message) {
c.dispatch.mu.Lock()
secret := c.dispatch.pairs[m.To.SessionID]
c.dispatch.mu.Unlock()
return peermail.Proof(secret, m)
defer c.dispatch.mu.Unlock()
pair := c.dispatch.pairs[m.To.SessionID]
if pair == nil {
return
}
pair.sent++
m.Seq = pair.sent
m.Proof = peermail.Proof(pair.secret, *m)
}
// dispatchPartner reports whether the message proves it came from a session this
// one dispatched to or was dispatched by.
// one dispatched to or was dispatched by, and that it is the first time it is
// taken: a proof is valid once per sequence number.
func (c *Controller) dispatchPartner(m peermail.Message) bool {
c.dispatch.mu.Lock()
secret := c.dispatch.pairs[m.From.SessionID]
c.dispatch.mu.Unlock()
return peermail.ProofValid(secret, m)
defer c.dispatch.mu.Unlock()
pair := c.dispatch.pairs[m.From.SessionID]
return pair != nil && peermail.ProofValid(pair.secret, m) && pair.accept(m.Seq)
}
// PeerKey is the address other sessions write to this one at, empty until the
@@ -0,0 +1,103 @@
package control
import (
"context"
"testing"
"time"
"reasonix/internal/safety/permission"
"reasonix/internal/state/peermail"
)
func pairedController(t *testing.T) (*Controller, []byte) {
t.Helper()
f := newPeerFixture(t)
secret := []byte("0123456789abcdef0123456789abcdef")
f.c.NoteDispatchedSession("s-peer", secret)
return f.c, secret
}
func signedFrom(c *Controller, secret []byte, seq uint64) peermail.Message {
m := peermail.Message{ID: "m-x", From: peermail.Party{SessionID: "s-peer"}, To: peermail.Party{SessionID: "s-me"}, SentAt: time.Now().UTC(), Body: "task", Seq: seq}
m.Proof = peermail.Proof(secret, m)
return m
}
// A message the receiver already took in proves nothing a second time: the seen
// log forgets ids after a thousand acknowledgements, so the pairing itself
// remembers which sequence numbers it has accepted for as long as it lives.
func TestAProofedMessageIsAcceptedOnceForThePairingsLifetime(t *testing.T) {
c, secret := pairedController(t)
first := signedFrom(c, secret, 1)
if !c.dispatchPartner(first) {
t.Fatal("a fresh proofed message was refused")
}
if c.dispatchPartner(first) {
t.Fatal("the same proofed message was accepted twice")
}
if !c.dispatchPartner(signedFrom(c, secret, 2)) {
t.Fatal("the next sequence number was refused")
}
if c.dispatchPartner(signedFrom(c, secret, 0)) {
t.Fatal("an unnumbered message was accepted")
}
}
// Messages are numbered in the order they were signed, not the order they are
// read, so a later one read first must not shut out an earlier one.
func TestAnOlderSequenceNumberStillGetsInWhileTheWindowHoldsIt(t *testing.T) {
c, secret := pairedController(t)
if !c.dispatchPartner(signedFrom(c, secret, 5)) || !c.dispatchPartner(signedFrom(c, secret, 3)) {
t.Fatal("an out-of-order but unseen number was refused")
}
for i := range uint64(dispatchWindow + 10) {
c.dispatchPartner(signedFrom(c, secret, 100+i))
}
if c.dispatchPartner(signedFrom(c, secret, 3)) {
t.Fatal("a number older than the window was accepted again")
}
}
func TestEachMessageSentToAPartnerCarriesTheNextSequenceNumber(t *testing.T) {
c, _ := pairedController(t)
var got []uint64
for range 3 {
m := peermail.Message{ID: "m-y", From: peermail.Party{SessionID: "s-me"}, To: peermail.Party{SessionID: "s-peer"}, SentAt: time.Now().UTC(), Body: "b"}
c.signDispatch(&m)
got = append(got, m.Seq)
if m.Proof == "" {
t.Fatal("a message to a partner went out unsigned")
}
}
if got[0] != 1 || got[1] != 2 || got[2] != 3 {
t.Fatalf("sequence numbers = %v, want 1 2 3", got)
}
stranger := peermail.Message{To: peermail.Party{SessionID: "s-nobody"}, Body: "b"}
c.signDispatch(&stranger)
if stranger.Proof != "" || stranger.Seq != 0 {
t.Fatalf("a message to a non-partner was signed: %+v", stranger)
}
}
// The grant a dispatching session passes on sets the approval posture; it must
// not reach past the target project's own rules. A deny rule the project holds
// keeps refusing under an adopted yolo, and a call outside it is the only thing
// the posture lets through.
func TestAdoptingAnApprovalGrantDoesNotLiftTheTargetProjectsDenyRules(t *testing.T) {
deny, ok := permission.ParseRule("bash(rm:*)")
if !ok {
t.Fatal("ParseRule failed")
}
c := New(Options{Policy: permission.Policy{Deny: []permission.Rule{deny}}})
c.AdoptSessionGrant(SessionGrant{Approval: ToolApprovalYolo})
if c.ToolApprovalMode() != ToolApprovalYolo {
t.Fatalf("posture = %q, want the grant's", c.ToolApprovalMode())
}
gate := c.newInteractiveGate()
if v, err := permission.VerdictOf(context.Background(), gate, "bash", bashCall(t, "rm -rf build"), false); err != nil || v.Allow {
t.Fatalf("a call the project denies under an adopted yolo: allow=%v err=%v", v.Allow, err)
}
if v, err := permission.VerdictOf(context.Background(), gate, "bash", bashCall(t, "ls"), false); err != nil || !v.Allow {
t.Fatalf("a call no rule names under yolo: allow=%v err=%v", v.Allow, err)
}
}
+1 -1
View File
@@ -122,7 +122,7 @@ func (c *Controller) SendPeerMessage(to, text string) (PeerReceipt, error) {
From: peermail.Party{SessionID: self, Title: peerAttribute(sessionstore.BranchID(path)), Project: peerAttribute(baseName(c.WorkspaceRoot()))},
To: peermail.Party{SessionID: target.SessionID, WriterID: target.WriterID, Title: target.Title, Project: target.Project},
}
msg.Proof = c.dispatchProof(msg)
c.signDispatch(&msg)
if err := dir.Send(msg); err != nil {
return PeerReceipt{}, err
}
+1 -1
View File
@@ -14,7 +14,7 @@ func Proof(secret []byte, m Message) string {
return ""
}
mac := hmac.New(sha256.New, secret)
for _, part := range []string{m.ID, m.From.SessionID, m.To.SessionID, strconv.FormatInt(m.SentAt.UnixNano(), 10), strconv.Itoa(m.Hops), cleanBody(m.Body)} {
for _, part := range []string{m.ID, m.From.SessionID, m.To.SessionID, strconv.FormatInt(m.SentAt.UnixNano(), 10), strconv.Itoa(m.Hops), strconv.FormatUint(m.Seq, 10), cleanBody(m.Body)} {
mac.Write([]byte(strconv.Itoa(len(part))))
mac.Write([]byte{':'})
mac.Write([]byte(part))
+1
View File
@@ -23,6 +23,7 @@ func TestAProofHoldsOnlyForTheMessageAndTheSecretItWasMadeFor(t *testing.T) {
"id": func(m *Message) { m.ID = "m-2" },
"time": func(m *Message) { m.SentAt = m.SentAt.Add(time.Nanosecond) },
"hops": func(m *Message) { m.Hops++ },
"seq": func(m *Message) { m.Seq++ },
} {
forged := m
mutate(&forged)
+3
View File
@@ -53,6 +53,9 @@ type Message struct {
Hops int `json:"hops"`
Body string `json:"body"`
Proof string `json:"proof,omitempty"`
// Seq numbers the messages one session signs for one partner, from 1. The
// proof covers it, so the receiver can refuse a number it has already taken.
Seq uint64 `json:"seq,omitempty"`
}
// Live describes one running session. It is a description: whether the session