iop/packages/go/agentguard/canonical.go
toki 3c48879c44 feat(agent-runtime): 독립 Agent CLI 런타임 기반을 구현한다
독립 호스트에서 안전한 작업 실행과 복구를 제공하기 위해 런타임 설정, 정책, 상태 저장소, 워크스페이스 격리 및 AgentTask 오케스트레이션을 확장한다.
2026-07-29 13:12:21 +09:00

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
}