From b740f8b6f6c78fd971f012e3dfc48e25f201111f Mon Sep 17 00:00:00 2001 From: weichen Date: Thu, 15 Oct 2026 02:33:37 +0000 Subject: [PATCH] Add optional proofs for an exact human structure decision Why: A model-visible flag cannot prove that a person confirmed one proposal revision and payload hash. Evidence: go test ./pkg/extension ./internal/app ./internal/server -run 'HumanDecision|SurfaceSnapshot|ResolveHuman|NaturalLanguage' passed locally. Impact: SurfaceVersion 2.22.0-experimental adds HumanDecisionVerifier. Existing Host methods stay unchanged, and the base does not consume the proof. Refs: W4b human confirmation design r1 Co-authored-by: Cursor --- cmd/storage-migrate/main.go | 2 +- internal/app/compiled.go | 3 +- internal/app/hostcaps.go | 5 + internal/app/human_decision.go | 104 ++++++++++++ internal/app/human_decision_test.go | 170 +++++++++++++++++++ internal/cmdbus/cmdbus.go | 12 ++ internal/server/human_decision.go | 98 +++++++++++ internal/server/human_decision_test.go | 105 ++++++++++++ internal/server/server.go | 14 +- internal/store/human_decision.go | 215 +++++++++++++++++++++++++ internal/store/migrate.go | 47 ++++++ pkg/extension/extension.go | 5 +- pkg/extension/host.go | 52 ++++++ pkg/extension/surface.txt | 8 + 14 files changed, 835 insertions(+), 5 deletions(-) create mode 100644 internal/app/human_decision.go create mode 100644 internal/app/human_decision_test.go create mode 100644 internal/server/human_decision.go create mode 100644 internal/server/human_decision_test.go create mode 100644 internal/store/human_decision.go diff --git a/cmd/storage-migrate/main.go b/cmd/storage-migrate/main.go index eb395be..d1f00e1 100644 --- a/cmd/storage-migrate/main.go +++ b/cmd/storage-migrate/main.go @@ -286,7 +286,7 @@ func cmdBackup(args []string) error { var copyOrder = []string{ "projects", "users", "account_directory_revision", "profiles", "settings", "plugins", "conversations", "runs", "messages", "project_work_outbox", "events", "artifacts", - "hitl_cards", "plans", "todos", "knowledge", "knowledge_vectors", + "hitl_cards", "human_decisions", "plans", "todos", "knowledge", "knowledge_vectors", "activity_cards", "project_event_shares", "command_audit", "project_audit_links", "prompt_modules", "sessions", "api_keys", "plugin_grants", "plugin_schema", "plugin_kv", "background_tasks", "work_task_bindings", diff --git a/internal/app/compiled.go b/internal/app/compiled.go index 3e001c1..6fa498a 100644 --- a/internal/app/compiled.go +++ b/internal/app/compiled.go @@ -65,7 +65,8 @@ func (r pluginRegistry) AddCommandHandler(spec json.RawMessage, h extension.Hand } return r.bus.RegisterPluginCommand(s, func(ctx context.Context, req cmdbus.Request) (any, error) { host := requestBoundHost{inprocHost{caps: r.caps, pluginID: r.pluginID, owner: req.Owner, actor: req.Actor, - conversationID: core.ID(req.ConversationID), hostProjectID: core.ID(req.HostProjectID)}} + conversationID: core.ID(req.ConversationID), hostProjectID: core.ID(req.HostProjectID), + entrance: req.Entrance, humanGrant: req.HumanGrant}} return h(ctx, host, extension.Request{ ID: req.ID, Params: req.Params, Entrance: req.Entrance, Actor: req.Actor, Owner: req.Owner, ConversationID: req.ConversationID, OperationID: req.OperationID, diff --git a/internal/app/hostcaps.go b/internal/app/hostcaps.go index b7ec921..5e37d3f 100644 --- a/internal/app/hostcaps.go +++ b/internal/app/hostcaps.go @@ -7,7 +7,9 @@ import ( "sort" "strings" "sync" + "time" + "agent-system-backend/internal/cmdbus" "agent-system-backend/internal/ingest" "agent-system-backend/internal/rag" "agent-system-backend/internal/runtime" @@ -33,6 +35,7 @@ type hostcaps struct { set *settings.Service authz *projectAuthz // EXT-8 适配器槽(共享指针;首个注册者生效) surfaces *surfacesvc.Service // EXT-17 Surface 能力槽(nil = 未装配,明确拒绝) + now func() time.Time } // projectAuthz EXT-8 资源授权适配器槽:进程内扩展注册,组合根装配进 Server。 @@ -200,6 +203,8 @@ type inprocHost struct { actor string // EXT-18:分发主体(空=非 dispatch 上下文) conversationID core.ID // dispatch-bound, never taken from completion arguments hostProjectID core.ID // dispatch project; rechecked against stored conversation + entrance string // dispatch entrance; model tools are "agent" + humanGrant *cmdbus.HumanGrant } func (h inprocHost) requireEffective() error { diff --git a/internal/app/human_decision.go b/internal/app/human_decision.go new file mode 100644 index 0000000..22e01a4 --- /dev/null +++ b/internal/app/human_decision.go @@ -0,0 +1,104 @@ +package app + +import ( + "fmt" + "time" + + "agent-system-backend/internal/cmdbus" + "agent-system-backend/internal/store" + "agent-system-backend/pkg/extension" +) + +func (h inprocHost) clock() time.Time { + if h.caps.now != nil { + return h.caps.now() + } + return time.Now() +} + +func humanCommand(audience string) (string, error) { + if audience == extension.HumanDecisionAudienceStructure { + return "rdm.structure.decide", nil + } + return "", fmt.Errorf("extension: human decision audience is not issued for this host") +} + +func humanEntranceAllowed(entrance string) bool { + switch entrance { + case "button", "card", "api": + return true + default: + return false + } +} + +// IssueHumanDecision opens a pending card for the authenticated person. +// Model-tool, chat, and slash entrances cannot open one. +func (h inprocHost) IssueHumanDecision(binding extension.HumanDecisionBinding, question string) (extension.HumanDecisionCard, error) { + if h.caps.st == nil || h.actor == "" || h.actor != binding.Actor || !humanEntranceAllowed(h.entrance) { + return extension.HumanDecisionCard{}, fmt.Errorf("extension: human decision cannot be issued on this dispatch") + } + commandID, err := humanCommand(binding.Audience) + if err != nil { + return extension.HumanDecisionCard{}, err + } + if binding.ProjectID == "" || binding.ProposalID == "" || binding.ProposalRevision < 1 || binding.PayloadHash == "" { + return extension.HumanDecisionCard{}, fmt.Errorf("extension: human decision binding is incomplete") + } + rec, err := h.caps.st.IssueHumanDecision(store.HumanDecision{ + PluginID: h.pluginID, Audience: binding.Audience, CommandID: commandID, + HumanActor: binding.Actor, Owner: h.owner, ProjectID: binding.ProjectID, + ProposalID: binding.ProposalID, ProposalRevision: binding.ProposalRevision, + PayloadHash: binding.PayloadHash, Question: question, ConversationID: string(h.conversationID), + }, h.clock()) + if err != nil { + return extension.HumanDecisionCard{}, err + } + return extension.HumanDecisionCard{CardID: rec.ID, Question: rec.Question, ExpiresAt: rec.ExpiresAt}, nil +} + +// VerifyHumanDecision reports the persisted human response for this dispatch. +// A model tool, a forged entrance, a mismatched binding, an expired card, or +// a revoked card fails closed. The record stays answered; it is not consumed. +func (h inprocHost) VerifyHumanDecision(expected extension.HumanDecisionBinding) (extension.HumanDecisionProof, error) { + if h.caps.st == nil || h.humanGrant == nil || h.entrance == "agent" || h.actor == "" || h.actor != h.humanGrant.Actor { + return extension.HumanDecisionProof{}, fmt.Errorf("extension: this dispatch has no authenticated human decision") + } + rec, err := h.caps.st.HumanDecision(h.humanGrant.CardID) + if err != nil { + return extension.HumanDecisionProof{}, fmt.Errorf("extension: human decision card is not pending for this dispatch") + } + now := h.clock() + if rec.Status != "responded" || rec.PluginID != h.pluginID || rec.ProofID == "" || rec.ProofID != h.humanGrant.ProofID || + rec.HumanActor != expected.Actor || rec.HumanActor != h.actor || rec.Decision != expected.Decision || + rec.Audience != expected.Audience || rec.ProjectID != expected.ProjectID || rec.ProposalID != expected.ProposalID || + rec.ProposalRevision != expected.ProposalRevision || rec.PayloadHash != expected.PayloadHash || + !now.Before(rec.ExpiresAt) { + return extension.HumanDecisionProof{}, fmt.Errorf("extension: human decision does not match this dispatch") + } + return extension.HumanDecisionProof{ + ProofID: rec.ProofID, Issuer: "agent-system-backend", HumanActor: rec.HumanActor, + Audience: rec.Audience, ProjectID: rec.ProjectID, ProposalID: rec.ProposalID, + Revision: rec.ProposalRevision, PayloadHash: rec.PayloadHash, Decision: rec.Decision, + IssuedAt: rec.IssuedAt, ExpiresAt: rec.ExpiresAt, ConversationID: rec.ConversationID, + HITLResponseRef: rec.ID, + }, nil +} + +// RevokeHumanDecision invalidates a still-pending card. Answered proofs stay +// available until they expire; consumption is not a host concern. +func (h inprocHost) RevokeHumanDecision(cardID string) error { + if h.caps.st == nil || h.actor == "" || !humanEntranceAllowed(h.entrance) { + return fmt.Errorf("extension: human decision cannot be revoked on this dispatch") + } + return h.caps.st.RevokeHumanDecision(h.pluginID, h.actor, cardID) +} + +// HumanGrantFromRecord copies the persisted response onto a dispatch. +// Callers must set it only after an authenticated response or session retry. +func HumanGrantFromRecord(rec store.HumanDecision) *cmdbus.HumanGrant { + if rec.ProofID == "" || rec.Status != "responded" { + return nil + } + return &cmdbus.HumanGrant{CardID: rec.ID, ProofID: rec.ProofID, Actor: rec.HumanActor, Decision: rec.Decision} +} diff --git a/internal/app/human_decision_test.go b/internal/app/human_decision_test.go new file mode 100644 index 0000000..064fc88 --- /dev/null +++ b/internal/app/human_decision_test.go @@ -0,0 +1,170 @@ +package app + +import ( + "testing" + "time" + + "agent-system-backend/internal/cmdbus" + "agent-system-backend/internal/store" + "agent-system-backend/pkg/extension" +) + +func openDecisionStore(t *testing.T) *store.Store { + t.Helper() + st, err := store.OpenSQLite(t.TempDir() + "/decision.db") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { st.Close() }) + return st +} + +func decisionHost(st *store.Store, plugin, actor, entrance string, now time.Time, grant *cmdbus.HumanGrant) inprocHost { + return inprocHost{ + caps: hostcaps{st: st, now: func() time.Time { return now }}, + pluginID: plugin, owner: actor, actor: actor, entrance: entrance, humanGrant: grant, + } +} + +func binding(decision, hash string, revision int) extension.HumanDecisionBinding { + return extension.HumanDecisionBinding{ + Audience: extension.HumanDecisionAudienceStructure, Actor: "alice", ProjectID: "p1", + ProposalID: "prop-1", ProposalRevision: revision, PayloadHash: hash, Decision: decision, + } +} + +func TestHumanDecisionAuthenticatedResponse(t *testing.T) { + st := openDecisionStore(t) + now := time.Date(2026, 10, 15, 1, 0, 0, 0, time.UTC) + host := decisionHost(st, "rdm", "alice", "button", now, nil) + card, err := host.IssueHumanDecision(binding("", "hash-1", 1), "确认结构变更") + if err != nil { + t.Fatal(err) + } + answered, err := st.RespondHumanDecision("alice", card.CardID, "accept", now) + if err != nil { + t.Fatal(err) + } + host.humanGrant = &cmdbus.HumanGrant{CardID: answered.ID, ProofID: answered.ProofID, Actor: "alice", Decision: "accept"} + host.entrance = "card" + proof, err := host.VerifyHumanDecision(binding("accept", "hash-1", 1)) + if err != nil { + t.Fatal(err) + } + if proof.ProofID == "" || proof.Issuer != "agent-system-backend" || proof.HumanActor != "alice" || + proof.HITLResponseRef != card.CardID || proof.PayloadHash != "hash-1" { + t.Fatalf("proof = %+v", proof) + } + again, err := host.VerifyHumanDecision(binding("accept", "hash-1", 1)) + if err != nil || again.ProofID != proof.ProofID { + t.Fatalf("second verify consumed or changed the proof: %v %+v", err, again) + } + stored, err := st.HumanDecision(card.CardID) + if err != nil || stored.Status != "responded" { + t.Fatalf("host consumed the proof, status=%s err=%v", stored.Status, err) + } +} + +func TestHumanDecisionRejectsUntrustedDispatch(t *testing.T) { + st := openDecisionStore(t) + now := time.Date(2026, 10, 15, 1, 0, 0, 0, time.UTC) + host := decisionHost(st, "rdm", "alice", "button", now, nil) + card, err := host.IssueHumanDecision(binding("", "hash-1", 1), "确认") + if err != nil { + t.Fatal(err) + } + answered, err := st.RespondHumanDecision("alice", card.CardID, "accept", now) + if err != nil { + t.Fatal(err) + } + grant := &cmdbus.HumanGrant{CardID: answered.ID, ProofID: answered.ProofID, Actor: "alice", Decision: "accept"} + expect := binding("accept", "hash-1", 1) + + agent := decisionHost(st, "rdm", "alice", "agent", now, grant) + if _, err := agent.VerifyHumanDecision(expect); err == nil { + t.Fatal("model tool dispatch verified a human decision") + } + forged := decisionHost(st, "rdm", "alice", "card", now, nil) + if _, err := forged.VerifyHumanDecision(expect); err == nil { + t.Fatal("forged entrance verified without a host grant") + } + if _, err := decisionHost(st, "rdm", "alice", "agent", now, nil).IssueHumanDecision(binding("", "hash-2", 1), "x"); err == nil { + t.Fatal("model tool issued a human decision card") + } + if _, err := decisionHost(st, "other", "alice", "card", now, grant).VerifyHumanDecision(expect); err == nil { + t.Fatal("another plugin reused the proof") + } + other := expect + other.ProposalID = "prop-2" + if _, err := decisionHost(st, "rdm", "alice", "card", now, grant).VerifyHumanDecision(other); err == nil { + t.Fatal("proof authorized a second proposal") + } + wrongActor := expect + wrongActor.Actor = "bob" + if _, err := decisionHost(st, "rdm", "bob", "card", now, grant).VerifyHumanDecision(wrongActor); err == nil { + t.Fatal("wrong actor verified") + } + wrongProject := expect + wrongProject.ProjectID = "p2" + if _, err := decisionHost(st, "rdm", "alice", "card", now, grant).VerifyHumanDecision(wrongProject); err == nil { + t.Fatal("wrong project verified") + } + wrongRev := expect + wrongRev.ProposalRevision = 2 + if _, err := decisionHost(st, "rdm", "alice", "card", now, grant).VerifyHumanDecision(wrongRev); err == nil { + t.Fatal("wrong revision verified") + } + wrongHash := expect + wrongHash.PayloadHash = "hash-changed" + if _, err := decisionHost(st, "rdm", "alice", "card", now, grant).VerifyHumanDecision(wrongHash); err == nil { + t.Fatal("wrong payload hash verified") + } + if _, err := decisionHost(st, "rdm", "alice", "button", now, nil).IssueHumanDecision( + extension.HumanDecisionBinding{Audience: "other.plugin", Actor: "alice", ProjectID: "p1", ProposalID: "prop-1", ProposalRevision: 1, PayloadHash: "h"}, + "x"); err == nil { + t.Fatal("foreign audience was issued") + } +} + +func TestHumanDecisionCardChangeExpiryAndRevoke(t *testing.T) { + st := openDecisionStore(t) + now := time.Date(2026, 10, 15, 1, 0, 0, 0, time.UTC) + host := decisionHost(st, "rdm", "alice", "api", now, nil) + first, err := host.IssueHumanDecision(binding("", "hash-1", 1), "旧卡") + if err != nil { + t.Fatal(err) + } + if _, err := host.IssueHumanDecision(binding("", "hash-2", 2), "新卡"); err != nil { + t.Fatal(err) + } + if _, err := st.RespondHumanDecision("alice", first.CardID, "accept", now); err == nil { + t.Fatal("changed card still accepted the old confirmation") + } + current, err := host.IssueHumanDecision(binding("", "hash-3", 3), "待撤销") + if err != nil { + t.Fatal(err) + } + if err := host.RevokeHumanDecision(current.CardID); err != nil { + t.Fatal(err) + } + if _, err := st.RespondHumanDecision("alice", current.CardID, "accept", now); err == nil { + t.Fatal("revoked card was answered") + } + live, err := host.IssueHumanDecision(binding("", "hash-4", 4), "会过期") + if err != nil { + t.Fatal(err) + } + if _, err := st.RespondHumanDecision("alice", live.CardID, "accept", now.Add(16*time.Minute)); err == nil { + t.Fatal("expired card was answered") + } +} + +func TestMissingHumanDecisionStoreFailsClosed(t *testing.T) { + host := decisionHost(nil, "rdm", "alice", "button", time.Now(), nil) + if _, err := host.IssueHumanDecision(binding("", "hash-1", 1), "x"); err == nil { + t.Fatal("missing store issued a card") + } + if _, err := host.VerifyHumanDecision(binding("accept", "hash-1", 1)); err == nil { + t.Fatal("missing store verified a decision") + } +} diff --git a/internal/cmdbus/cmdbus.go b/internal/cmdbus/cmdbus.go index 2f5354a..48bae9d 100644 --- a/internal/cmdbus/cmdbus.go +++ b/internal/cmdbus/cmdbus.go @@ -101,6 +101,18 @@ type Request struct { AnchorMessageID string // 确认卡锚定的发起消息(内联渲染) Confirmed bool // 危险命令经确认卡放行后的执行 HostProjectID string // EXT-18:分发所属基座项目(空=无项目上下文) + // HumanGrant is set only by the authenticated session path or the human + // decision resolver. Client JSON and agent tool dispatch leave it nil. + HumanGrant *HumanGrant +} + +// 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 { + CardID string + ProofID string + Actor string + Decision string } // Response 分发结果:回执 + 处理负载 + 可能的确认卡。 diff --git a/internal/server/human_decision.go b/internal/server/human_decision.go new file mode 100644 index 0000000..a213c18 --- /dev/null +++ b/internal/server/human_decision.go @@ -0,0 +1,98 @@ +package server + +import ( + "errors" + "net/http" + "strconv" + "strings" + "time" + + "agent-system-backend/internal/cmdbus" + "agent-system-backend/internal/store" + "agent-system-core" +) + +// resolveHumanDecision handles a card issued by HumanDecisionVerifier. +// Ordinary HITL cards return handled=false and keep the existing run path. +func (s *Server) resolveHumanDecision(w http.ResponseWriter, r *http.Request, resp core.HITLResponse) (bool, error) { + rec, err := s.st.HumanDecision(string(resp.CardID)) + if errors.Is(err, store.ErrNotFound) { + return false, nil + } + if err != nil { + return true, err + } + decision := resp.ChoiceID + if decision == "" { + switch strings.TrimSpace(resp.FreeText) { + case "批准", "接受", "同意": + pending, perr := s.st.PendingHumanDecisions(s.owner(r), time.Now()) + if perr != nil { + return true, perr + } + if len(pending) != 1 || pending[0].ID != rec.ID { + return true, errors.New("human decision text matches more than one pending card") + } + decision = "accept" + default: + return true, errors.New("human decision requires an explicit choice") + } + } + answered, err := s.st.RespondHumanDecision(s.owner(r), rec.ID, decision, time.Now()) + if err != nil { + return true, err + } + out := map[string]any{ + "resolved": true, "proofId": answered.ProofID, "decision": answered.Decision, + "proposalId": answered.ProposalID, "proposalRevision": answered.ProposalRevision, + } + if s.Bus != nil && answered.CommandID != "" { + follow := s.Bus.Dispatch(r.Context(), cmdbus.Request{ + ID: answered.CommandID, + Params: map[string]any{ + "projectId": answered.ProjectID, "proposalId": answered.ProposalID, + "proposalRevision": answered.ProposalRevision, "payloadHash": answered.PayloadHash, + "decision": answered.Decision, + }, + Entrance: "card", Actor: answered.HumanActor, Owner: s.owner(r), + ConversationID: answered.ConversationID, + HumanGrant: &cmdbus.HumanGrant{ + CardID: answered.ID, ProofID: answered.ProofID, + Actor: answered.HumanActor, Decision: answered.Decision, + }, + }) + out["receipt"] = follow.Receipt + if follow.Payload != nil { + out["payload"] = follow.Payload + } + } + writeJSON(w, http.StatusOK, out) + return true, nil +} + +func (s *Server) sessionHumanGrant(actor, commandID string, params map[string]any) (*cmdbus.HumanGrant, bool) { + if commandID != "rdm.structure.decide" || s.st == nil { + return nil, false + } + project, _ := params["projectId"].(string) + proposal, _ := params["proposalId"].(string) + hash, _ := params["payloadHash"].(string) + decision, _ := params["decision"].(string) + revision := 0 + switch v := params["proposalRevision"].(type) { + case float64: + revision = int(v) + case int: + revision = v + case string: + revision, _ = strconv.Atoi(v) + } + if project == "" || proposal == "" || hash == "" || decision == "" || revision < 1 { + return nil, false + } + rec, err := s.st.SessionHumanGrant(actor, commandID, project, proposal, hash, decision, revision, time.Now()) + if err != nil { + return nil, false + } + return &cmdbus.HumanGrant{CardID: rec.ID, ProofID: rec.ProofID, Actor: rec.HumanActor, Decision: rec.Decision}, true +} diff --git a/internal/server/human_decision_test.go b/internal/server/human_decision_test.go new file mode 100644 index 0000000..0ce6550 --- /dev/null +++ b/internal/server/human_decision_test.go @@ -0,0 +1,105 @@ +package server + +import ( + "context" + "net/http/httptest" + "testing" + "time" + + agentcommand "agent-command" + "agent-system-backend/internal/cmdbus" + "agent-system-backend/internal/store" + "agent-system-backend/pkg/extension" + "agent-system-core" +) + +type nopHumanAudit struct{} + +func (nopHumanAudit) AppendAudit(string, agentcommand.AuditEnvelope) error { return nil } + +func TestResolveHumanDecisionBindsFollowUpAndRetry(t *testing.T) { + st, err := store.OpenSQLite(t.TempDir() + "/decision.db") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { st.Close() }) + issued, err := st.IssueHumanDecision(store.HumanDecision{ + PluginID: "rdm", Audience: extension.HumanDecisionAudienceStructure, + CommandID: "rdm.structure.decide", HumanActor: "alice", Owner: "alice", + ProjectID: "p1", ProposalID: "prop-1", ProposalRevision: 1, + PayloadHash: "hash-1", Question: "确认结构变更", + }, time.Now().UTC()) + if err != nil { + t.Fatal(err) + } + var got *cmdbus.HumanGrant + bus := cmdbus.New(nopHumanAudit{}, nil, nil) + if err := bus.RegisterPluginCommand(agentcommand.CommandSpec{ID: "rdm.structure.decide", Title: "确认结构"}, + func(_ context.Context, req cmdbus.Request) (any, error) { + got = req.HumanGrant + if req.Params["confirmed"] != nil { + t.Fatal("follow-up copied a client confirmation flag") + } + return map[string]any{"applied": true}, nil + }); err != nil { + t.Fatal(err) + } + s := &Server{st: st, Bus: bus} + rec := httptest.NewRecorder() + req := httptest.NewRequest("POST", "/api/hitl/"+issued.ID+"/resolve", nil) + req = req.WithContext(context.WithValue(req.Context(), ownerCtxKey{}, "alice")) + handled, err := s.resolveHumanDecision(rec, req, core.HITLResponse{CardID: core.ID(issued.ID), ChoiceID: "accept"}) + if err != nil || !handled { + t.Fatalf("resolve handled=%v err=%v body=%s", handled, err, rec.Body.String()) + } + if got == nil || got.Actor != "alice" || got.ProofID == "" || got.CardID != issued.ID { + t.Fatalf("follow-up grant = %+v", got) + } + grant, ok := s.sessionHumanGrant("alice", "rdm.structure.decide", map[string]any{ + "projectId": "p1", "proposalId": "prop-1", "proposalRevision": float64(1), + "payloadHash": "hash-1", "decision": "accept", "confirmed": true, + }) + if !ok || grant.ProofID != got.ProofID { + t.Fatalf("retry grant = %+v ok=%v", grant, ok) + } + if _, ok := s.sessionHumanGrant("bob", "rdm.structure.decide", map[string]any{ + "projectId": "p1", "proposalId": "prop-1", "proposalRevision": float64(1), + "payloadHash": "hash-1", "decision": "accept", + }); ok { + t.Fatal("another actor received the grant") + } +} + +func TestNaturalLanguageApprovalRequiresOnePendingCard(t *testing.T) { + st, err := store.OpenSQLite(t.TempDir() + "/decision.db") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { st.Close() }) + now := time.Now().UTC() + first, err := st.IssueHumanDecision(store.HumanDecision{ + PluginID: "rdm", Audience: extension.HumanDecisionAudienceStructure, + CommandID: "rdm.structure.decide", HumanActor: "alice", Owner: "alice", + ProjectID: "p1", ProposalID: "prop-1", ProposalRevision: 1, + PayloadHash: "hash-1", Question: "第一张", + }, now) + if err != nil { + t.Fatal(err) + } + if _, err := st.IssueHumanDecision(store.HumanDecision{ + PluginID: "rdm", Audience: extension.HumanDecisionAudienceStructure, + CommandID: "rdm.structure.decide", HumanActor: "alice", Owner: "alice", + ProjectID: "p1", ProposalID: "prop-2", ProposalRevision: 1, + PayloadHash: "hash-2", Question: "第二张", + }, now); err != nil { + t.Fatal(err) + } + s := &Server{st: st} + rec := httptest.NewRecorder() + req := httptest.NewRequest("POST", "/api/hitl/"+first.ID+"/resolve", nil) + req = req.WithContext(context.WithValue(req.Context(), ownerCtxKey{}, "alice")) + handled, err := s.resolveHumanDecision(rec, req, core.HITLResponse{CardID: core.ID(first.ID), FreeText: "批准"}) + if !handled || err == nil { + t.Fatal("ambiguous natural-language approval was accepted") + } +} diff --git a/internal/server/server.go b/internal/server/server.go index c183f7c..896dc2e 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -1297,11 +1297,15 @@ func (s *Server) execCommand(w http.ResponseWriter, r *http.Request) { } } - resp := s.Bus.Dispatch(r.Context(), cmdbus.Request{ + dispatch := cmdbus.Request{ ID: r.PathValue("id"), Params: body.Params, Entrance: body.Entrance, Actor: s.owner(r), Owner: s.owner(r), ConversationID: body.ConversationID, AnchorMessageID: body.AnchorMessageID, HostProjectID: hostProject, - }) + } + if grant, ok := s.sessionHumanGrant(s.owner(r), dispatch.ID, body.Params); ok { + dispatch.HumanGrant = grant + } + resp := s.Bus.Dispatch(r.Context(), dispatch) code := http.StatusOK if resp.Receipt.State == agentcommand.StateRejected { code = http.StatusBadRequest @@ -1371,6 +1375,12 @@ func (s *Server) resolveHITL(w http.ResponseWriter, r *http.Request) { return } resp.CardID = r.PathValue("id") + if handled, err := s.resolveHumanDecision(w, r, resp); handled { + if err != nil { + writeStoreErr(w, err) + } + return + } if s.projectLifecycle != nil { card, err := s.st.GetHITLCard(s.owner(r), resp.CardID) if err != nil { diff --git a/internal/store/human_decision.go b/internal/store/human_decision.go new file mode 100644 index 0000000..0742ff4 --- /dev/null +++ b/internal/store/human_decision.go @@ -0,0 +1,215 @@ +package store + +import ( + "crypto/sha256" + "database/sql" + "encoding/hex" + "errors" + "fmt" + "time" +) + +const humanDecisionTTL = 15 * time.Minute + +// HumanDecision is the base's record of one pending or answered human card. +// Status stays "responded" after a proof is issued. Consumption belongs to +// the caller that receives the proof, not to this table. +type HumanDecision struct { + ID string + PluginID string + Audience string + CommandID string + HumanActor string + Owner string + ProjectID string + ProposalID string + ProposalRevision int + PayloadHash string + Question string + Status string + Decision string + ProofID string + ConversationID string + IssuedAt time.Time + ExpiresAt time.Time + RespondedAt time.Time +} + +func humanProofID(cardID, decision, payloadHash string) string { + sum := sha256.Sum256([]byte(cardID + "\n" + decision + "\n" + payloadHash)) + return "hdp-" + hex.EncodeToString(sum[:16]) +} + +// IssueHumanDecision persists a pending card and revokes older pending cards +// for the same proposal. A changed card cannot keep an earlier confirmation. +func (s *Store) IssueHumanDecision(rec HumanDecision, now time.Time) (HumanDecision, error) { + if rec.PluginID == "" || rec.Audience == "" || rec.CommandID == "" || rec.HumanActor == "" || + rec.Owner == "" || rec.ProjectID == "" || rec.ProposalID == "" || rec.ProposalRevision < 1 || + rec.PayloadHash == "" || rec.Question == "" { + return HumanDecision{}, fmt.Errorf("store: incomplete human decision") + } + rec.ID = newID("hd") + rec.Status = "pending" + rec.Decision = "" + rec.ProofID = "" + rec.IssuedAt = now.UTC() + rec.ExpiresAt = now.UTC().Add(humanDecisionTTL) + tx, err := s.db.Begin() + if err != nil { + return HumanDecision{}, err + } + defer tx.Rollback() + if _, err := tx.Exec(s.q(`UPDATE human_decisions SET status='revoked' + WHERE plugin_id=? AND audience=? AND project_id=? AND proposal_id=? AND status='pending'`), + rec.PluginID, rec.Audience, rec.ProjectID, rec.ProposalID); err != nil { + return HumanDecision{}, err + } + if _, err := tx.Exec(s.q(`INSERT INTO human_decisions( + id,plugin_id,audience,command_id,human_actor,owner,project_id,proposal_id,proposal_revision, + payload_hash,question,status,decision,proof_id,conversation_id,issued_at,expires_at,responded_at) + VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`), + rec.ID, rec.PluginID, rec.Audience, rec.CommandID, rec.HumanActor, rec.Owner, rec.ProjectID, + rec.ProposalID, rec.ProposalRevision, rec.PayloadHash, rec.Question, rec.Status, "", "", + rec.ConversationID, rec.IssuedAt.Format(time.RFC3339Nano), rec.ExpiresAt.Format(time.RFC3339Nano), ""); err != nil { + return HumanDecision{}, err + } + if err := tx.Commit(); err != nil { + return HumanDecision{}, err + } + return rec, nil +} + +func (s *Store) HumanDecision(id string) (HumanDecision, error) { + row := s.db.QueryRow(s.q(`SELECT id,plugin_id,audience,command_id,human_actor,owner,project_id,proposal_id, + proposal_revision,payload_hash,question,status,decision,proof_id,conversation_id,issued_at,expires_at,responded_at + FROM human_decisions WHERE id=?`), id) + return scanHumanDecision(row) +} + +// RespondHumanDecision records the authenticated person's choice. It does not +// mark the proof consumed. Repeating a resolved card fails closed. +func (s *Store) RespondHumanDecision(actor, cardID, decision string, now time.Time) (HumanDecision, error) { + if decision != "accept" && decision != "reject" && decision != "cancel" { + return HumanDecision{}, fmt.Errorf("store: human decision must be accept, reject, or cancel") + } + rec, err := s.HumanDecision(cardID) + if err != nil { + return HumanDecision{}, err + } + if rec.HumanActor != actor { + return HumanDecision{}, fmt.Errorf("store: human decision actor mismatch") + } + if rec.Status == "revoked" { + return HumanDecision{}, fmt.Errorf("store: human decision revoked") + } + if rec.Status != "pending" { + return HumanDecision{}, fmt.Errorf("store: human decision already answered") + } + if !now.Before(rec.ExpiresAt) { + return HumanDecision{}, fmt.Errorf("store: human decision expired") + } + proof := humanProofID(rec.ID, decision, rec.PayloadHash) + responded := now.UTC() + res, err := s.db.Exec(s.q(`UPDATE human_decisions SET status='responded', decision=?, proof_id=?, responded_at=? + WHERE id=? AND status='pending'`), decision, proof, responded.Format(time.RFC3339Nano), cardID) + if err != nil { + return HumanDecision{}, err + } + if n, _ := res.RowsAffected(); n == 0 { + return HumanDecision{}, fmt.Errorf("store: human decision already answered") + } + rec.Status = "responded" + rec.Decision = decision + rec.ProofID = proof + rec.RespondedAt = responded + return rec, nil +} + +// RevokeHumanDecision invalidates a card before it is answered. The same +// actor who was asked, and only that actor, may revoke it. +func (s *Store) RevokeHumanDecision(pluginID, actor, cardID string) error { + res, err := s.db.Exec(s.q(`UPDATE human_decisions SET status='revoked' + WHERE id=? AND plugin_id=? AND human_actor=? AND status='pending'`), cardID, pluginID, actor) + if err != nil { + return err + } + if n, _ := res.RowsAffected(); n == 0 { + return fmt.Errorf("store: human decision cannot be revoked") + } + return nil +} + +// PendingHumanDecisions returns unanswered cards for one actor. +func (s *Store) PendingHumanDecisions(actor string, now time.Time) ([]HumanDecision, error) { + rows, err := s.db.Query(s.q(`SELECT id,plugin_id,audience,command_id,human_actor,owner,project_id,proposal_id, + proposal_revision,payload_hash,question,status,decision,proof_id,conversation_id,issued_at,expires_at,responded_at + FROM human_decisions WHERE human_actor=? AND status='pending' ORDER BY issued_at`), actor) + if err != nil { + return nil, err + } + defer rows.Close() + var out []HumanDecision + for rows.Next() { + rec, err := scanHumanDecision(rows) + if err != nil { + return nil, err + } + if now.Before(rec.ExpiresAt) { + out = append(out, rec) + } + } + return out, rows.Err() +} + +// SessionHumanGrant returns the grant for an authenticated retry of the exact +// answered card. It does not consume the proof. Missing or mismatched input +// is ErrNotFound so callers can leave the dispatch ungranted. +func (s *Store) SessionHumanGrant(actor, commandID, projectID, proposalID, payloadHash, decision string, revision int, now time.Time) (HumanDecision, error) { + row := s.db.QueryRow(s.q(`SELECT id,plugin_id,audience,command_id,human_actor,owner,project_id,proposal_id, + proposal_revision,payload_hash,question,status,decision,proof_id,conversation_id,issued_at,expires_at,responded_at + FROM human_decisions + WHERE human_actor=? AND command_id=? AND project_id=? AND proposal_id=? AND proposal_revision=? + AND payload_hash=? AND decision=? AND status='responded'`), + actor, commandID, projectID, proposalID, revision, payloadHash, decision) + rec, err := scanHumanDecision(row) + if err != nil { + return HumanDecision{}, err + } + if !now.Before(rec.ExpiresAt) { + return HumanDecision{}, ErrNotFound + } + return rec, nil +} + +type humanScanner interface { + Scan(dest ...any) error +} + +func scanHumanDecision(row humanScanner) (HumanDecision, error) { + var rec HumanDecision + var issued, expires, responded string + err := row.Scan(&rec.ID, &rec.PluginID, &rec.Audience, &rec.CommandID, &rec.HumanActor, &rec.Owner, + &rec.ProjectID, &rec.ProposalID, &rec.ProposalRevision, &rec.PayloadHash, &rec.Question, &rec.Status, + &rec.Decision, &rec.ProofID, &rec.ConversationID, &issued, &expires, &responded) + if errors.Is(err, sql.ErrNoRows) { + return HumanDecision{}, ErrNotFound + } + if err != nil { + return HumanDecision{}, err + } + rec.IssuedAt, err = time.Parse(time.RFC3339Nano, issued) + if err != nil { + return HumanDecision{}, err + } + rec.ExpiresAt, err = time.Parse(time.RFC3339Nano, expires) + if err != nil { + return HumanDecision{}, err + } + if responded != "" { + rec.RespondedAt, err = time.Parse(time.RFC3339Nano, responded) + if err != nil { + return HumanDecision{}, err + } + } + return rec, nil +} diff --git a/internal/store/migrate.go b/internal/store/migrate.go index dac2467..729160d 100644 --- a/internal/store/migrate.go +++ b/internal/store/migrate.go @@ -1094,6 +1094,53 @@ func init() { "sqlite": {`DROP INDEX IF EXISTS idx_project_work_outbox_pending`, `DROP TABLE IF EXISTS project_work_outbox`}, "postgres": {`DROP INDEX IF EXISTS idx_project_work_outbox_pending`, `DROP TABLE IF EXISTS project_work_outbox`}, }}, + migration{version: 45, name: "human_decision_proofs", + up: map[string][]string{ + "sqlite": {`CREATE TABLE human_decisions ( + id TEXT PRIMARY KEY, + plugin_id TEXT NOT NULL, + audience TEXT NOT NULL, + command_id TEXT NOT NULL, + human_actor TEXT NOT NULL, + owner TEXT NOT NULL, + project_id TEXT NOT NULL, + proposal_id TEXT NOT NULL, + proposal_revision INTEGER NOT NULL, + payload_hash TEXT NOT NULL, + question TEXT NOT NULL, + status TEXT NOT NULL, + decision TEXT NOT NULL DEFAULT '', + proof_id TEXT NOT NULL DEFAULT '', + conversation_id TEXT NOT NULL DEFAULT '', + issued_at TEXT NOT NULL, + expires_at TEXT NOT NULL, + responded_at TEXT NOT NULL DEFAULT '' + )`, `CREATE INDEX idx_human_decisions_pending ON human_decisions(human_actor, status, proposal_id)`}, + "postgres": {`CREATE TABLE human_decisions ( + id TEXT PRIMARY KEY, + plugin_id TEXT NOT NULL, + audience TEXT NOT NULL, + command_id TEXT NOT NULL, + human_actor TEXT NOT NULL, + owner TEXT NOT NULL, + project_id TEXT NOT NULL, + proposal_id TEXT NOT NULL, + proposal_revision INTEGER NOT NULL, + payload_hash TEXT NOT NULL, + question TEXT NOT NULL, + status TEXT NOT NULL, + decision TEXT NOT NULL DEFAULT '', + proof_id TEXT NOT NULL DEFAULT '', + conversation_id TEXT NOT NULL DEFAULT '', + issued_at TEXT NOT NULL, + expires_at TEXT NOT NULL, + responded_at TEXT NOT NULL DEFAULT '' + )`, `CREATE INDEX idx_human_decisions_pending ON human_decisions(human_actor, status, proposal_id)`}, + }, + down: map[string][]string{ + "sqlite": {`DROP INDEX IF EXISTS idx_human_decisions_pending`, `DROP TABLE IF EXISTS human_decisions`}, + "postgres": {`DROP INDEX IF EXISTS idx_human_decisions_pending`, `DROP TABLE IF EXISTS human_decisions`}, + }}, ) } diff --git a/pkg/extension/extension.go b/pkg/extension/extension.go index 7468b4b..e20957f 100644 --- a/pkg/extension/extension.go +++ b/pkg/extension/extension.go @@ -52,7 +52,10 @@ import ( // X2 project audit capabilities on one Backend surface. // 2.21.0-experimental: optional ProjectWorkObservationRegistry; host-origin // work-source observations and project action decisions (X3 contract). -const SurfaceVersion = "2.21.0-experimental" +// 2.22.0-experimental: optional HumanDecisionVerifier. The host proves that +// this dispatch is an authenticated human response to one exact pending card. +// It does not consume the proof; the caller does that in its own transaction. +const SurfaceVersion = "2.22.0-experimental" // ErrNotLinked 表示进程没有空白导入 link 包,Main 无法启动服务。 var ErrNotLinked = errors.New("extension: runner not linked; blank-import agent-system-backend/pkg/extension/link") diff --git a/pkg/extension/host.go b/pkg/extension/host.go index df21509..5b85c4c 100644 --- a/pkg/extension/host.go +++ b/pkg/extension/host.go @@ -2,6 +2,7 @@ package extension import ( "context" + "time" core "agent-system-core" ) @@ -46,6 +47,7 @@ type Handler func(ctx context.Context, h Host, req Request) (any, error) // - SurfaceHost — EXT-17:页面保存/读取 // - WorkCompletionHost — X1:受信项目工作生成 // - WorkTaskHost — X1:分发期建立可恢复的受信项目任务绑定 +// - HumanDecisionVerifier — 本次分发是否为认证人对确切待确认卡的响应 type Host interface { // PluginID 返回注册处理器的插件标识(形如 rdm)。 PluginID() string @@ -92,3 +94,53 @@ type Host interface { } // —— AdminProvider 定义移至本文件上方(Request 之后)—— + +// HumanDecisionAudienceStructure is the only audience this proof can authorize. +// Another plugin cannot reuse the same human response as its own confirmation. +const HumanDecisionAudienceStructure = "rdm.structure.decide" + +// HumanDecisionBinding is the exact object a human must have confirmed. +// Callers pass it to the host; they do not carry a copyable secret. +type HumanDecisionBinding struct { + Audience string + Actor string + ProjectID string + ProposalID string + ProposalRevision int + PayloadHash string + Decision string +} + +// HumanDecisionCard is the pending object a person confirms. +type HumanDecisionCard struct { + CardID string + Question string + ExpiresAt time.Time +} + +// HumanDecisionProof is a read-only statement that a person already responded. +// ProofID is a consumption key for the caller. The host does not mark it used. +type HumanDecisionProof struct { + ProofID string + Issuer string + HumanActor string + Audience string + ProjectID string + ProposalID string + Revision int + PayloadHash string + Decision string + IssuedAt time.Time + ExpiresAt time.Time + ConversationID string + RunID string + HITLResponseRef string +} + +// HumanDecisionVerifier is optional. A missing assertion means the capability +// is absent and the caller must refuse the write. +type HumanDecisionVerifier interface { + IssueHumanDecision(binding HumanDecisionBinding, question string) (HumanDecisionCard, error) + VerifyHumanDecision(expected HumanDecisionBinding) (HumanDecisionProof, error) + RevokeHumanDecision(cardID string) error +} diff --git a/pkg/extension/surface.txt b/pkg/extension/surface.txt index 4e52b4a..20a6315 100644 --- a/pkg/extension/surface.txt +++ b/pkg/extension/surface.txt @@ -52,6 +52,14 @@ Host.TaskCreate Host.TaskUpdate HostAuditReceipt HostClient +HumanDecisionAudienceStructure +HumanDecisionBinding +HumanDecisionCard +HumanDecisionProof +HumanDecisionVerifier +HumanDecisionVerifier.IssueHumanDecision +HumanDecisionVerifier.RevokeHumanDecision +HumanDecisionVerifier.VerifyHumanDecision InProc IntentSpec Main