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 <cursoragent@cursor.com>
105 lines
4.6 KiB
Go
105 lines
4.6 KiB
Go
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}
|
|
}
|