Require source preflight for formal project commands
Why: Direct HTTP and surface command dispatch could mutate RDM project facts without an analyzed committed-message manifest. Evidence: Command-bus, server, and app suites pass with a classification regression for formal writes and read/proof exceptions. Impact: Formal HTTP writes require a conversation anchor and committed proceed decision; surface writes fail closed until they can carry the same proof. Refs: TT-N03 TT-R01
This commit is contained in:
@@ -707,6 +707,9 @@ func Run(opt Options) error {
|
||||
}
|
||||
|
||||
srv := server.New(st, mgr, reg, profSvc, setSvc, pluginHost)
|
||||
srv.SetProjectCommandPreflight(func(ctx context.Context, messageID, action string) (extension.ProjectWorkDecision, error) {
|
||||
return preflightProjectMessage(ctx, st, workObserverSlot, projectSlot.work(), messageID, action)
|
||||
})
|
||||
projectSlot.wire(srv, caps)
|
||||
// EXT-8:装载期注册的资源授权适配器注入读取入口(未注册=默认拒绝,行为不变)。
|
||||
srv.SetProjectAuthorizer(authzSlot.Authorize)
|
||||
|
||||
@@ -2,6 +2,7 @@ package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"agent-system-backend/internal/cmdbus"
|
||||
"agent-system-backend/internal/surfacesvc"
|
||||
@@ -11,6 +12,9 @@ import (
|
||||
// caller. The button is acting for that user, so both Actor and Owner must be
|
||||
// populated before the command bus writes its audit envelope.
|
||||
func dispatchSurfaceAction(ctx context.Context, bus *cmdbus.Bus, d surfacesvc.ActionDispatch) (surfacesvc.ActionOutcome, error) {
|
||||
if cmdbus.RequiresProjectWorkDecision(d.CommandID) {
|
||||
return surfacesvc.ActionOutcome{}, fmt.Errorf("project formal action requires a committed conversation source preflight")
|
||||
}
|
||||
resp := bus.Dispatch(ctx, cmdbus.Request{ID: d.CommandID, Params: d.Params,
|
||||
Entrance: "button", Actor: d.Owner, Owner: d.Owner, ConversationID: d.ConversationID})
|
||||
state := "committed"
|
||||
|
||||
@@ -106,6 +106,31 @@ type Request struct {
|
||||
HumanGrant *HumanGrant
|
||||
}
|
||||
|
||||
// RequiresProjectWorkDecision identifies formal RDM writes that must be bound
|
||||
// to an analyzed committed-message manifest before dispatch. Read views and
|
||||
// the separate exact-card structure issue/decision path are excluded.
|
||||
func RequiresProjectWorkDecision(id string) bool {
|
||||
for _, prefix := range []string{
|
||||
"rdm.reqs.", "rdm.review.", "rdm.draft.", "rdm.cnode.", "rdm.cedge.",
|
||||
"rdm.conclusion.", "rdm.section.", "rdm.stage.", "rdm.change.",
|
||||
} {
|
||||
if strings.HasPrefix(id, prefix) {
|
||||
for _, read := range []string{"rdm.reqs.list", "rdm.reqs.exports", "rdm.reqs.underived", "rdm.review.status", "rdm.review.doc.status", "rdm.draft.proposals", "rdm.conclusion.list", "rdm.change.list"} {
|
||||
if id == read {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
switch id {
|
||||
case "rdm.structure.propose", "rdm.structure.revise", "rdm.docs.instantiate", "rdm.ingest", "rdm.upload.confirm", "rdm.knowledge.publish", "rdm.trace.confirm":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// HumanGrant ties this dispatch to one persisted human response.
|
||||
// It is not a secret token and is not read from command params.
|
||||
type HumanGrant struct {
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
package cmdbus
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestFormalRDMWritesRequireProjectWorkDecision(t *testing.T) {
|
||||
for _, id := range []string{"rdm.reqs.add", "rdm.docs.instantiate", "rdm.review.doc.approve", "rdm.structure.propose", "rdm.trace.confirm"} {
|
||||
if !RequiresProjectWorkDecision(id) {
|
||||
t.Errorf("formal write %s bypassed project source preflight", id)
|
||||
}
|
||||
}
|
||||
for _, id := range []string{"rdm.inspect.tree", "rdm.reqs.list", "rdm.structure.proposals", "rdm.structure.issue", "rdm.structure.decide"} {
|
||||
if RequiresProjectWorkDecision(id) {
|
||||
t.Errorf("read or separately proven command %s was misclassified", id)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -79,7 +79,8 @@ type Server struct {
|
||||
// 全部动作权威;未注册时 read 回落 ProjectAuthz(兼容),其余动作一律
|
||||
// 默认拒绝——旧只读适配器不得自动获得写入能力。公开契约的版本化扩展
|
||||
// 计划见 docs/guides/team-storage.md(KTD13 一节)。
|
||||
ProjectAuthzActions func(owner string, projectID core.ID, action ProjectAction) (bool, error)
|
||||
ProjectAuthzActions func(owner string, projectID core.ID, action ProjectAction) (bool, error)
|
||||
ProjectCommandPreflight func(context.Context, string, string) (extension.ProjectWorkDecision, error)
|
||||
// CompiledPlugins 编译进二进制的进程内插件摘要(组合根在 applyCompiled
|
||||
// 期间记录注册面生成;nil/空 = 无编译插件,清单行为不变)。
|
||||
CompiledPlugins []CompiledPlugin
|
||||
@@ -113,6 +114,10 @@ func (s *Server) SetProjectActionAuthorizer(fn func(owner string, projectID core
|
||||
s.ProjectAuthzActions = fn
|
||||
}
|
||||
|
||||
func (s *Server) SetProjectCommandPreflight(fn func(context.Context, string, string) (extension.ProjectWorkDecision, error)) {
|
||||
s.ProjectCommandPreflight = fn
|
||||
}
|
||||
|
||||
// authorizeProjectAction 动作矩阵唯一判定(KTD13):
|
||||
// - action-aware 适配器已注册 → 权威(出错/拒绝一律 false,fail-closed);
|
||||
// - 未注册 → read 回落旧只读 ProjectAuthz(兼容 EXT-8 现网),其余动作
|
||||
@@ -1280,6 +1285,18 @@ func (s *Server) execCommand(w http.ResponseWriter, r *http.Request) {
|
||||
if body.Params == nil {
|
||||
body.Params = map[string]any{}
|
||||
}
|
||||
commandID := r.PathValue("id")
|
||||
if cmdbus.RequiresProjectWorkDecision(commandID) {
|
||||
if body.ConversationID == "" || body.AnchorMessageID == "" || s.ProjectCommandPreflight == nil {
|
||||
http.Error(w, "project command requires a committed source message", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
decision, err := s.ProjectCommandPreflight(r.Context(), body.AnchorMessageID, "project-command:"+commandID)
|
||||
if err != nil || decision.State != extension.ProjectWorkProceed || decision.DecisionID == "" {
|
||||
http.Error(w, "project command source analysis is not complete", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
}
|
||||
var hostProject string
|
||||
if body.ConversationID != "" {
|
||||
conv, err := s.st.GetConversation(s.owner(r), body.ConversationID)
|
||||
@@ -1298,7 +1315,7 @@ func (s *Server) execCommand(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
dispatch := cmdbus.Request{
|
||||
ID: r.PathValue("id"), Params: body.Params, Entrance: body.Entrance, Actor: s.owner(r),
|
||||
ID: commandID, Params: body.Params, Entrance: body.Entrance, Actor: s.owner(r),
|
||||
Owner: s.owner(r), ConversationID: body.ConversationID, AnchorMessageID: body.AnchorMessageID,
|
||||
HostProjectID: hostProject,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user