독립 호스트에서 안전한 작업 실행과 복구를 제공하기 위해 런타임 설정, 정책, 상태 저장소, 워크스페이스 격리 및 AgentTask 오케스트레이션을 확장한다.
398 lines
13 KiB
Go
398 lines
13 KiB
Go
// Package agentpolicy implements the deterministic target policy evaluator for
|
|
// the IOP agent CLI runtime. It consumes an immutable agentconfig runtime
|
|
// snapshot and a runtime selection context to produce a single, durable
|
|
// RouteDecision.
|
|
package agentpolicy
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/sha256"
|
|
"crypto/subtle"
|
|
"encoding/binary"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"time"
|
|
)
|
|
|
|
const (
|
|
decisionVersion = "1"
|
|
|
|
CandidateSourceRule = "rule"
|
|
CandidateSourceDefault = "default"
|
|
|
|
CandidateReasonMatched = "matched"
|
|
CandidateReasonDefault = "default"
|
|
CandidateReasonNotEvaluatedAfterFirstHit = "not evaluated after first match"
|
|
)
|
|
|
|
// RouteDecision is the durable, single-target result of evaluating an ordered
|
|
// selection policy. Candidate evidence preserves the complete ordered policy
|
|
// traversal, and History records every route that was actually used.
|
|
type RouteDecision struct {
|
|
ProviderID string `json:"provider_id"`
|
|
ModelID string `json:"model_id"`
|
|
ProfileID string `json:"profile_id"`
|
|
ProfileRevision string `json:"profile_revision"`
|
|
|
|
ConfigRevision string `json:"config_revision"`
|
|
SelectionRevision string `json:"selection_revision"`
|
|
SelectedRuleID string `json:"selected_rule_id"`
|
|
SelectedReason string `json:"selected_reason"`
|
|
|
|
Candidates []CandidateEvaluation `json:"candidates"`
|
|
History RouteHistory `json:"history"`
|
|
}
|
|
|
|
// CandidateEvaluation records one ordered rule or default candidate. Rules
|
|
// after the first match remain explicit but unevaluated.
|
|
type CandidateEvaluation struct {
|
|
Source string `json:"source"`
|
|
RuleID string `json:"rule_id"`
|
|
ProviderID string `json:"provider_id"`
|
|
ModelID string `json:"model_id"`
|
|
ProfileID string `json:"profile_id"`
|
|
ProfileRevision string `json:"profile_revision"`
|
|
Evaluated bool `json:"evaluated"`
|
|
Eligible bool `json:"eligible"`
|
|
Used bool `json:"used"`
|
|
Reason string `json:"reason"`
|
|
}
|
|
|
|
// RouteHistory is the immutable, revision-pinned record of a persisted route.
|
|
type RouteHistory struct {
|
|
DecisionID string `json:"decision_id"`
|
|
SelectedAt time.Time `json:"selected_at"`
|
|
UsedRoutes []UsedRoute `json:"used_routes"`
|
|
}
|
|
|
|
// UsedRoute records one route that was actually selected for execution.
|
|
type UsedRoute struct {
|
|
ProviderID string `json:"provider_id"`
|
|
ModelID string `json:"model_id"`
|
|
ProfileID string `json:"profile_id"`
|
|
ProfileRevision string `json:"profile_revision"`
|
|
RuleID string `json:"rule_id"`
|
|
Reason string `json:"reason"`
|
|
UsedAt time.Time `json:"used_at"`
|
|
}
|
|
|
|
// DecisionExpectation pins both configuration and effective selection policy
|
|
// revisions when a durable decision is decoded or resumed.
|
|
type DecisionExpectation struct {
|
|
ConfigRevision string
|
|
SelectionRevision string
|
|
}
|
|
|
|
// decisionEnvelope is a strict versioned wrapper. Integrity covers
|
|
// length-prefixed version, revisions, and canonical decision bytes.
|
|
type decisionEnvelope struct {
|
|
Version string `json:"version"`
|
|
ConfigRevision string `json:"config_revision"`
|
|
SelectionRevision string `json:"selection_revision"`
|
|
Decision json.RawMessage `json:"decision"`
|
|
Integrity string `json:"integrity"`
|
|
}
|
|
|
|
// EncodeDecision serialises and integrity-seals one structurally valid
|
|
// RouteDecision.
|
|
func EncodeDecision(decision RouteDecision) ([]byte, error) {
|
|
if err := validateDecision(decision); err != nil {
|
|
return nil, err
|
|
}
|
|
decisionData, err := json.Marshal(decision)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("agentpolicy: encode decision payload: %w", err)
|
|
}
|
|
envelope := decisionEnvelope{
|
|
Version: decisionVersion,
|
|
ConfigRevision: decision.ConfigRevision,
|
|
SelectionRevision: decision.SelectionRevision,
|
|
Decision: decisionData,
|
|
Integrity: decisionIntegrity(
|
|
decisionVersion,
|
|
decision.ConfigRevision,
|
|
decision.SelectionRevision,
|
|
decisionData,
|
|
),
|
|
}
|
|
data, err := json.Marshal(envelope)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("agentpolicy: encode decision envelope: %w", err)
|
|
}
|
|
return data, nil
|
|
}
|
|
|
|
// DecodeDecision strictly decodes, integrity-checks, and revision-pins one
|
|
// durable decision. It never returns an unchecked payload.
|
|
func DecodeDecision(data []byte, expected DecisionExpectation) (RouteDecision, error) {
|
|
if expected.ConfigRevision == "" || expected.SelectionRevision == "" {
|
|
return RouteDecision{}, fmt.Errorf(
|
|
"%w: both expected config and selection revisions are required",
|
|
ErrRevisionMismatch,
|
|
)
|
|
}
|
|
|
|
var envelope decisionEnvelope
|
|
if err := decodeStrictJSON(data, &envelope); err != nil {
|
|
return RouteDecision{}, fmt.Errorf("%w: envelope: %v", ErrCorruptDecision, err)
|
|
}
|
|
if envelope.Version != decisionVersion {
|
|
return RouteDecision{}, fmt.Errorf(
|
|
"%w: unsupported version %q",
|
|
ErrCorruptDecision,
|
|
envelope.Version,
|
|
)
|
|
}
|
|
if envelope.ConfigRevision == "" || envelope.SelectionRevision == "" ||
|
|
len(envelope.Decision) == 0 || envelope.Integrity == "" {
|
|
return RouteDecision{}, fmt.Errorf("%w: incomplete envelope", ErrCorruptDecision)
|
|
}
|
|
wantIntegrity := decisionIntegrity(
|
|
envelope.Version,
|
|
envelope.ConfigRevision,
|
|
envelope.SelectionRevision,
|
|
envelope.Decision,
|
|
)
|
|
if !constantBytesEqual(envelope.Integrity, wantIntegrity) {
|
|
return RouteDecision{}, fmt.Errorf("%w: integrity mismatch", ErrCorruptDecision)
|
|
}
|
|
|
|
var decision RouteDecision
|
|
if err := decodeStrictJSON(envelope.Decision, &decision); err != nil {
|
|
return RouteDecision{}, fmt.Errorf("%w: decision: %v", ErrCorruptDecision, err)
|
|
}
|
|
canonicalDecision, err := json.Marshal(decision)
|
|
if err != nil {
|
|
return RouteDecision{}, fmt.Errorf("%w: canonical decision: %v", ErrCorruptDecision, err)
|
|
}
|
|
if !bytes.Equal(envelope.Decision, canonicalDecision) {
|
|
return RouteDecision{}, fmt.Errorf("%w: decision payload is not canonical", ErrCorruptDecision)
|
|
}
|
|
if envelope.ConfigRevision != decision.ConfigRevision ||
|
|
envelope.SelectionRevision != decision.SelectionRevision {
|
|
return RouteDecision{}, fmt.Errorf(
|
|
"%w: envelope and decision revisions disagree",
|
|
ErrCorruptDecision,
|
|
)
|
|
}
|
|
if err := validateDecision(decision); err != nil {
|
|
return RouteDecision{}, err
|
|
}
|
|
if envelope.ConfigRevision != expected.ConfigRevision ||
|
|
envelope.SelectionRevision != expected.SelectionRevision {
|
|
return RouteDecision{}, fmt.Errorf(
|
|
"%w: got config=%q selection=%q, want config=%q selection=%q",
|
|
ErrRevisionMismatch,
|
|
envelope.ConfigRevision,
|
|
envelope.SelectionRevision,
|
|
expected.ConfigRevision,
|
|
expected.SelectionRevision,
|
|
)
|
|
}
|
|
return decision, nil
|
|
}
|
|
|
|
func validateDecision(decision RouteDecision) error {
|
|
if decision.ProviderID == "" || decision.ModelID == "" ||
|
|
decision.ProfileID == "" || decision.ProfileRevision == "" {
|
|
return fmt.Errorf("%w: selected target identity is incomplete", ErrCorruptDecision)
|
|
}
|
|
if decision.ConfigRevision == "" || decision.SelectionRevision == "" {
|
|
return fmt.Errorf("%w: decision revisions are incomplete", ErrCorruptDecision)
|
|
}
|
|
if decision.SelectedReason == "" || len(decision.Candidates) == 0 {
|
|
return fmt.Errorf("%w: selection evidence is incomplete", ErrCorruptDecision)
|
|
}
|
|
|
|
usedIndex := -1
|
|
defaultIndex := -1
|
|
for index, candidate := range decision.Candidates {
|
|
if candidate.Source != CandidateSourceRule &&
|
|
candidate.Source != CandidateSourceDefault {
|
|
return fmt.Errorf(
|
|
"%w: candidate %d has unknown source %q",
|
|
ErrCorruptDecision,
|
|
index,
|
|
candidate.Source,
|
|
)
|
|
}
|
|
if candidate.Source == CandidateSourceRule && candidate.RuleID == "" {
|
|
return fmt.Errorf("%w: candidate %d rule id is empty", ErrCorruptDecision, index)
|
|
}
|
|
if candidate.Source == CandidateSourceDefault {
|
|
if candidate.RuleID != "" || defaultIndex >= 0 ||
|
|
index != len(decision.Candidates)-1 {
|
|
return fmt.Errorf("%w: default candidate ordering is invalid", ErrCorruptDecision)
|
|
}
|
|
defaultIndex = index
|
|
}
|
|
if candidate.ProviderID == "" || candidate.ModelID == "" ||
|
|
candidate.ProfileID == "" || candidate.ProfileRevision == "" ||
|
|
candidate.Reason == "" {
|
|
return fmt.Errorf(
|
|
"%w: candidate %d identity or reason is incomplete",
|
|
ErrCorruptDecision,
|
|
index,
|
|
)
|
|
}
|
|
if !candidate.Evaluated && (candidate.Eligible || candidate.Used) {
|
|
return fmt.Errorf(
|
|
"%w: unevaluated candidate %d is eligible or used",
|
|
ErrCorruptDecision,
|
|
index,
|
|
)
|
|
}
|
|
if candidate.Eligible != candidate.Used {
|
|
return fmt.Errorf(
|
|
"%w: candidate %d eligibility and usage disagree",
|
|
ErrCorruptDecision,
|
|
index,
|
|
)
|
|
}
|
|
if usedIndex < 0 && !candidate.Evaluated {
|
|
return fmt.Errorf(
|
|
"%w: candidate %d was skipped before the first match",
|
|
ErrCorruptDecision,
|
|
index,
|
|
)
|
|
}
|
|
if usedIndex >= 0 && candidate.Evaluated {
|
|
return fmt.Errorf(
|
|
"%w: candidate %d was evaluated after the first match",
|
|
ErrCorruptDecision,
|
|
index,
|
|
)
|
|
}
|
|
if !candidate.Evaluated &&
|
|
candidate.Reason != CandidateReasonNotEvaluatedAfterFirstHit {
|
|
return fmt.Errorf(
|
|
"%w: candidate %d has invalid unevaluated reason",
|
|
ErrCorruptDecision,
|
|
index,
|
|
)
|
|
}
|
|
if candidate.Used {
|
|
if usedIndex >= 0 ||
|
|
(candidate.Reason != CandidateReasonMatched &&
|
|
candidate.Reason != CandidateReasonDefault) {
|
|
return fmt.Errorf("%w: used candidate evidence is invalid", ErrCorruptDecision)
|
|
}
|
|
if (candidate.Source == CandidateSourceRule &&
|
|
candidate.Reason != CandidateReasonMatched) ||
|
|
(candidate.Source == CandidateSourceDefault &&
|
|
candidate.Reason != CandidateReasonDefault) {
|
|
return fmt.Errorf(
|
|
"%w: used candidate source and reason disagree",
|
|
ErrCorruptDecision,
|
|
)
|
|
}
|
|
usedIndex = index
|
|
}
|
|
}
|
|
if usedIndex < 0 {
|
|
return fmt.Errorf("%w: no used candidate", ErrCorruptDecision)
|
|
}
|
|
|
|
selected := decision.Candidates[usedIndex]
|
|
if selected.ProviderID != decision.ProviderID ||
|
|
selected.ModelID != decision.ModelID ||
|
|
selected.ProfileID != decision.ProfileID ||
|
|
selected.ProfileRevision != decision.ProfileRevision ||
|
|
selected.RuleID != decision.SelectedRuleID ||
|
|
selected.Reason != decision.SelectedReason {
|
|
return fmt.Errorf("%w: selected candidate and decision disagree", ErrCorruptDecision)
|
|
}
|
|
if decision.History.DecisionID == "" || decision.History.SelectedAt.IsZero() ||
|
|
len(decision.History.UsedRoutes) == 0 {
|
|
return fmt.Errorf("%w: route history is incomplete", ErrCorruptDecision)
|
|
}
|
|
for index, route := range decision.History.UsedRoutes {
|
|
if route.ProviderID == "" || route.ModelID == "" ||
|
|
route.ProfileID == "" || route.ProfileRevision == "" ||
|
|
route.Reason == "" || route.UsedAt.IsZero() {
|
|
return fmt.Errorf(
|
|
"%w: used route %d identity or reason is incomplete",
|
|
ErrCorruptDecision,
|
|
index,
|
|
)
|
|
}
|
|
}
|
|
used := decision.History.UsedRoutes[len(decision.History.UsedRoutes)-1]
|
|
if used.ProviderID != decision.ProviderID ||
|
|
used.ModelID != decision.ModelID ||
|
|
used.ProfileID != decision.ProfileID ||
|
|
used.ProfileRevision != decision.ProfileRevision ||
|
|
used.RuleID != decision.SelectedRuleID ||
|
|
used.Reason != decision.SelectedReason ||
|
|
!used.UsedAt.Equal(decision.History.SelectedAt) {
|
|
return fmt.Errorf("%w: latest used route and decision disagree", ErrCorruptDecision)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func decodeStrictJSON(data []byte, destination any) error {
|
|
decoder := json.NewDecoder(bytes.NewReader(data))
|
|
decoder.DisallowUnknownFields()
|
|
if err := decoder.Decode(destination); err != nil {
|
|
return err
|
|
}
|
|
var trailing any
|
|
if err := decoder.Decode(&trailing); err != io.EOF {
|
|
if err == nil {
|
|
return errors.New("multiple JSON documents")
|
|
}
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func decisionIntegrity(
|
|
version, configRevision, selectionRevision string,
|
|
decisionData []byte,
|
|
) string {
|
|
hash := sha256.New()
|
|
writeLengthPrefixed(hash, []byte(version))
|
|
writeLengthPrefixed(hash, []byte(configRevision))
|
|
writeLengthPrefixed(hash, []byte(selectionRevision))
|
|
writeLengthPrefixed(hash, decisionData)
|
|
return "sha256:" + hex.EncodeToString(hash.Sum(nil))
|
|
}
|
|
|
|
func digestParts(parts ...string) string {
|
|
hash := sha256.New()
|
|
for _, part := range parts {
|
|
writeLengthPrefixed(hash, []byte(part))
|
|
}
|
|
return "sha256:" + hex.EncodeToString(hash.Sum(nil))
|
|
}
|
|
|
|
func writeLengthPrefixed(destination io.Writer, value []byte) {
|
|
var length [8]byte
|
|
binary.BigEndian.PutUint64(length[:], uint64(len(value)))
|
|
_, _ = destination.Write(length[:])
|
|
_, _ = destination.Write(value)
|
|
}
|
|
|
|
func constantBytesEqual(left, right string) bool {
|
|
return subtle.ConstantTimeCompare([]byte(left), []byte(right)) == 1
|
|
}
|
|
|
|
// Typed errors for decision encode/decode and evaluation.
|
|
var (
|
|
ErrCorruptDecision = errors.New("agentpolicy: corrupt route decision")
|
|
|
|
ErrRevisionMismatch = errors.New("agentpolicy: route decision revision mismatch")
|
|
|
|
ErrNoMatch = errors.New("agentpolicy: no matching selection rule and no default target")
|
|
|
|
ErrUnknownProject = errors.New("agentpolicy: unknown project")
|
|
|
|
ErrUnknownProfile = errors.New("agentpolicy: unknown profile in selection target")
|
|
|
|
ErrTargetIdentityMismatch = errors.New("agentpolicy: selection target identity mismatch")
|
|
|
|
ErrInvalidSelectionContext = errors.New("agentpolicy: invalid selection context")
|
|
)
|