독립 호스트에서 안전한 작업 실행과 복구를 제공하기 위해 런타임 설정, 정책, 상태 저장소, 워크스페이스 격리 및 AgentTask 오케스트레이션을 확장한다.
265 lines
8.1 KiB
Go
265 lines
8.1 KiB
Go
package agentguard
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
)
|
|
|
|
type canonicalPath struct {
|
|
path string
|
|
info os.FileInfo
|
|
}
|
|
|
|
type evaluatedAdmission struct {
|
|
workspace CanonicalWorkspace
|
|
payload permitPayload
|
|
pins []canonicalPath
|
|
}
|
|
|
|
func evaluateAdmission(req AdmissionRequest) (evaluatedAdmission, AdmissionResult) {
|
|
if result := validateAdmissionInputs(req); result.Blocker != nil {
|
|
return evaluatedAdmission{}, result
|
|
}
|
|
|
|
grantRoot, err := canonicalDirectory(req.Grant.Root, true)
|
|
if err != nil {
|
|
return evaluatedAdmission{}, blockedResult(
|
|
req, BlockerCodeWorkspaceNotCanonical,
|
|
"registered workspace root is not an existing canonical directory",
|
|
)
|
|
}
|
|
baseRoot, err := canonicalDirectory(req.Isolation.BaseRoot, true)
|
|
if err != nil || baseRoot.path != grantRoot.path {
|
|
return evaluatedAdmission{}, blockedResult(
|
|
req, BlockerCodeRevisionMismatch,
|
|
"task isolation base does not match the registered workspace revision",
|
|
)
|
|
}
|
|
taskRoot, err := canonicalDirectory(req.Isolation.TaskRoot, true)
|
|
if err != nil {
|
|
return evaluatedAdmission{}, blockedResult(
|
|
req, BlockerCodeWorkspaceNotCanonical,
|
|
"task isolation root is not an existing canonical directory",
|
|
)
|
|
}
|
|
if taskRoot.path == grantRoot.path {
|
|
return evaluatedAdmission{}, blockedResult(
|
|
req, BlockerCodeIsolationRequired,
|
|
"task execution cannot write directly to the canonical workspace",
|
|
)
|
|
}
|
|
|
|
workingInput := req.Isolation.WorkingDir
|
|
if strings.TrimSpace(workingInput) == "" {
|
|
workingInput = taskRoot.path
|
|
}
|
|
workingDir, err := canonicalDirectory(workingInput, false)
|
|
if err != nil || !containsPath(taskRoot.path, workingDir.path) {
|
|
return evaluatedAdmission{}, blockedResult(
|
|
req, BlockerCodeWorkspaceRootEscape,
|
|
"task working directory resolves outside the isolated workspace",
|
|
)
|
|
}
|
|
|
|
writableRoots := make([]canonicalPath, 0, len(req.Isolation.WritableRoots))
|
|
for _, root := range req.Isolation.WritableRoots {
|
|
canonical, canonicalErr := canonicalDirectory(root, false)
|
|
if canonicalErr != nil || !containsPath(taskRoot.path, canonical.path) {
|
|
return evaluatedAdmission{}, blockedResult(
|
|
req, BlockerCodeWritableRootEscape,
|
|
"one or more writable roots resolve outside the isolated workspace",
|
|
)
|
|
}
|
|
writableRoots = append(writableRoots, canonical)
|
|
}
|
|
|
|
allowedVCS, allowedPins, err := canonicalVCSAllowances(req.Grant.VCSMetadataRoots)
|
|
if err != nil {
|
|
return evaluatedAdmission{}, blockedResult(
|
|
req, BlockerCodeWorkspaceNotCanonical,
|
|
"workspace grant contains an invalid VCS metadata allowance",
|
|
)
|
|
}
|
|
rootVCS, gitErr := discoverGitMetadata(taskRoot.path, req.Isolation.Mode)
|
|
if gitErr != nil {
|
|
return evaluatedAdmission{}, blockedResult(req, gitErr.code, gitErr.message)
|
|
}
|
|
effectiveVCS, gitErr := discoverEffectiveGitMetadata(taskRoot.path, workingDir.path)
|
|
if gitErr != nil {
|
|
return evaluatedAdmission{}, blockedResult(req, gitErr.code, gitErr.message)
|
|
}
|
|
actualVCS := deduplicatePins(append(rootVCS, effectiveVCS...))
|
|
for _, metadata := range actualVCS {
|
|
if containsPath(taskRoot.path, metadata.path) {
|
|
continue
|
|
}
|
|
if _, ok := allowedVCS[metadata.path]; !ok {
|
|
return evaluatedAdmission{}, blockedResult(
|
|
req, BlockerCodeVCSMetadataNotAllowed,
|
|
"task Git metadata resolves outside the isolation without an exact grant allowance",
|
|
)
|
|
}
|
|
}
|
|
|
|
writablePaths := canonicalPathStrings(writableRoots)
|
|
vcsPaths := canonicalPathStrings(actualVCS)
|
|
sort.Strings(writablePaths)
|
|
sort.Strings(vcsPaths)
|
|
workspace := CanonicalWorkspace{
|
|
ProjectID: req.Grant.ProjectID,
|
|
WorkspaceID: req.Grant.WorkspaceID,
|
|
GrantRevision: req.Grant.Revision,
|
|
IsolationID: req.Isolation.ID,
|
|
IsolationRevision: req.Isolation.Revision,
|
|
PinnedBaseRevision: req.Isolation.PinnedBaseRevision,
|
|
ConfinementRevision: req.Isolation.ConfinementRevision,
|
|
Mode: req.Isolation.Mode,
|
|
BaseRoot: grantRoot.path,
|
|
TaskRoot: taskRoot.path,
|
|
WorkingDir: workingDir.path,
|
|
WritableRoots: writablePaths,
|
|
VCSMetadataRoots: vcsPaths,
|
|
}
|
|
payload := permitPayload{
|
|
Workspace: workspace,
|
|
Profile: req.Profile,
|
|
}
|
|
pins := deduplicatePins(append(
|
|
[]canonicalPath{grantRoot, baseRoot, taskRoot, workingDir},
|
|
append(writableRoots, append(allowedPins, actualVCS...)...)...,
|
|
))
|
|
return evaluatedAdmission{
|
|
workspace: workspace,
|
|
payload: payload,
|
|
pins: pins,
|
|
}, AdmissionResult{}
|
|
}
|
|
|
|
func validateAdmissionInputs(req AdmissionRequest) AdmissionResult {
|
|
if req.Grant == nil {
|
|
return blockedResult(
|
|
req, BlockerCodeMissingWorkspaceGrant,
|
|
"workspace is not backed by a registered canonical grant",
|
|
)
|
|
}
|
|
if req.Isolation == nil {
|
|
return blockedResult(
|
|
req, BlockerCodeIsolationRequired,
|
|
"task execution requires an isolated writable workspace",
|
|
)
|
|
}
|
|
if req.Profile.ProviderID == "" || req.Profile.ModelID == "" ||
|
|
req.Profile.ProfileID == "" || req.Profile.Revision == "" ||
|
|
req.Grant.ProjectID == "" || req.Grant.WorkspaceID == "" ||
|
|
req.Grant.Revision == "" || req.Isolation.ID == "" ||
|
|
req.Isolation.Revision == "" || req.Isolation.PinnedBaseRevision == "" {
|
|
return blockedResult(
|
|
req, BlockerCodeInvalidAdmissionRequest,
|
|
"admission identities and immutable revisions must be complete",
|
|
)
|
|
}
|
|
switch req.Isolation.Mode {
|
|
case IsolationModeOverlay, IsolationModeWorktree, IsolationModeClone:
|
|
default:
|
|
return blockedResult(
|
|
req, BlockerCodeInvalidAdmissionRequest,
|
|
"task isolation mode is unsupported",
|
|
)
|
|
}
|
|
if !req.Profile.Unattended {
|
|
return blockedResult(
|
|
req, BlockerCodeProviderUnattendedUnavailable,
|
|
"provider profile does not support unattended execution",
|
|
)
|
|
}
|
|
if !req.Profile.ApprovalBypass {
|
|
return blockedResult(
|
|
req, BlockerCodeProviderApprovalBypassUnavailable,
|
|
"provider profile does not support approval bypass",
|
|
)
|
|
}
|
|
if !req.Profile.WritableRootConfinement ||
|
|
req.Isolation.ConfinementRevision == "" {
|
|
return blockedResult(
|
|
req, BlockerCodeWritableConfinementUnavailable,
|
|
"provider profile cannot consume executable writable-root confinement",
|
|
)
|
|
}
|
|
if len(req.Isolation.WritableRoots) == 0 {
|
|
return blockedResult(
|
|
req, BlockerCodeWritableConfinementUnavailable,
|
|
"task isolation declares no writable root",
|
|
)
|
|
}
|
|
return AdmissionResult{}
|
|
}
|
|
|
|
func canonicalDirectory(raw string, requireCanonical bool) (canonicalPath, error) {
|
|
if strings.TrimSpace(raw) == "" || strings.TrimSpace(raw) != raw ||
|
|
!filepath.IsAbs(raw) || containsParentReference(raw) {
|
|
return canonicalPath{}, fmt.Errorf("path must be absolute and clean")
|
|
}
|
|
cleaned := filepath.Clean(raw)
|
|
resolved, err := filepath.EvalSymlinks(cleaned)
|
|
if err != nil {
|
|
return canonicalPath{}, err
|
|
}
|
|
resolved = filepath.Clean(resolved)
|
|
if requireCanonical && resolved != cleaned {
|
|
return canonicalPath{}, fmt.Errorf("path is not canonical")
|
|
}
|
|
info, err := os.Stat(resolved)
|
|
if err != nil {
|
|
return canonicalPath{}, err
|
|
}
|
|
if !info.IsDir() {
|
|
return canonicalPath{}, fmt.Errorf("path is not a directory")
|
|
}
|
|
handle, err := os.Open(resolved)
|
|
if err != nil {
|
|
return canonicalPath{}, err
|
|
}
|
|
_ = handle.Close()
|
|
return canonicalPath{path: resolved, info: info}, nil
|
|
}
|
|
|
|
func canonicalVCSAllowances(paths []string) (map[string]struct{}, []canonicalPath, error) {
|
|
allowed := make(map[string]struct{}, len(paths))
|
|
pins := make([]canonicalPath, 0, len(paths))
|
|
for _, path := range paths {
|
|
canonical, err := canonicalDirectory(path, true)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
allowed[canonical.path] = struct{}{}
|
|
pins = append(pins, canonical)
|
|
}
|
|
return allowed, pins, nil
|
|
}
|
|
|
|
func canonicalPathStrings(paths []canonicalPath) []string {
|
|
result := make([]string, 0, len(paths))
|
|
for _, path := range paths {
|
|
result = append(result, path.path)
|
|
}
|
|
return result
|
|
}
|
|
|
|
func deduplicatePins(paths []canonicalPath) []canonicalPath {
|
|
seen := make(map[string]struct{}, len(paths))
|
|
result := make([]canonicalPath, 0, len(paths))
|
|
for _, path := range paths {
|
|
if _, ok := seen[path.path]; ok {
|
|
continue
|
|
}
|
|
seen[path.path] = struct{}{}
|
|
result = append(result, path)
|
|
}
|
|
sort.Slice(result, func(i, j int) bool {
|
|
return result[i].path < result[j].path
|
|
})
|
|
return result
|
|
}
|