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

440 lines
12 KiB
Go

package agentworkspace
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"reflect"
"strings"
"sync"
"iop/packages/go/agenttask"
)
// ErrConfinementUnavailable means this host cannot install the filesystem
// policy required for an unattended overlay child. Callers must fail closed.
var ErrConfinementUnavailable = errors.New(
"agentworkspace: executable filesystem confinement is unavailable",
)
const confinementPolicySchemaVersion uint32 = 1
type confinementPolicy struct {
SchemaVersion uint32 `json:"schema_version"`
Revision string `json:"revision"`
Backend string `json:"backend"`
WritableRoots []string `json:"writable_roots"`
}
// ConfinementProof is an opaque launcher issued only after the platform
// confinement implementation and every bound filesystem identity validate.
type ConfinementProof struct {
binding agenttask.ConfinementBinding
policy confinementPolicy
}
var _ agenttask.InvocationConfinement = (*ConfinementProof)(nil)
type startedConfinement struct {
child *exec.Cmd
stdin io.WriteCloser
stdout io.ReadCloser
stderr io.ReadCloser
abortOnce sync.Once
abortErr error
}
var _ agenttask.StartedConfinement = (*startedConfinement)(nil)
func (started *startedConfinement) Child() *exec.Cmd {
if started == nil {
return nil
}
return started.child
}
func (started *startedConfinement) Stdin() io.WriteCloser {
if started == nil {
return nil
}
return started.stdin
}
func (started *startedConfinement) Stdout() io.ReadCloser {
if started == nil {
return nil
}
return started.stdout
}
func (started *startedConfinement) Stderr() io.ReadCloser {
if started == nil {
return nil
}
return started.stderr
}
func (started *startedConfinement) Abort() error {
if started == nil {
return nil
}
started.abortOnce.Do(func() {
var cleanupErrors []error
for _, endpoint := range []io.Closer{
started.stdin,
started.stdout,
started.stderr,
} {
if endpoint == nil {
continue
}
if err := endpoint.Close(); err != nil && !errors.Is(err, os.ErrClosed) {
cleanupErrors = append(cleanupErrors, err)
}
}
if started.child != nil &&
started.child.Process != nil &&
started.child.ProcessState == nil {
if err := started.child.Process.Kill(); err != nil &&
!errors.Is(err, os.ErrProcessDone) {
cleanupErrors = append(cleanupErrors, err)
}
if err := started.child.Wait(); err != nil {
var exitError *exec.ExitError
if !errors.As(err, &exitError) {
cleanupErrors = append(cleanupErrors, err)
}
}
}
started.abortErr = errors.Join(cleanupErrors...)
})
return started.abortErr
}
func confinementBinding(
runtimeRoot string,
record OverlayRecord,
) agenttask.ConfinementBinding {
return agenttask.ConfinementBinding{
Revision: record.ConfinementRevision,
IsolationID: record.IsolationID,
IsolationRevision: record.Revision,
PinnedBaseRevision: record.SnapshotRevision,
ConfigRevision: record.ConfigRevision,
GrantRevision: record.GrantRevision,
ProfileRevision: record.ProfileRevision,
BaseRoot: record.CanonicalRoot,
RuntimeRoot: runtimeRoot,
SnapshotRoot: record.Locator.SnapshotRoot,
TaskRoot: filepath.Dir(record.Locator.OverlayRecord),
WorkingDir: record.Locator.ViewRoot,
WritableRoots: []string{
record.Locator.ViewRoot,
record.Locator.TempRoot,
record.Locator.CacheRoot,
},
}
}
func newConfinementProof(
binding agenttask.ConfinementBinding,
) (*ConfinementProof, error) {
if err := validateConfinementBinding(binding); err != nil {
return nil, err
}
revision, err := resolveConfinementRevision(binding)
if err != nil {
return nil, err
}
if binding.Revision != revision {
return nil, errors.New(
"agentworkspace: overlay record has a mismatched confinement revision",
)
}
return &ConfinementProof{
binding: cloneConfinementBinding(binding),
policy: confinementPolicy{
SchemaVersion: confinementPolicySchemaVersion,
Revision: revision,
WritableRoots: append([]string(nil), binding.WritableRoots...),
},
}, nil
}
// Revision returns the exact overlay/profile/platform policy revision.
func (proof *ConfinementProof) Revision() string {
if proof == nil {
return ""
}
return proof.binding.Revision
}
// Binding returns a defensive copy of the immutable proof inputs.
func (proof *ConfinementProof) Binding() agenttask.ConfinementBinding {
if proof == nil {
return agenttask.ConfinementBinding{}
}
return cloneConfinementBinding(proof.binding)
}
// Validate rejects omission, rebinding, or in-memory corruption before a
// provider invoker receives the proof.
func (proof *ConfinementProof) Validate(
expected agenttask.ConfinementBinding,
) error {
if proof == nil {
return errors.New("confinement proof is missing")
}
if err := validateConfinementBinding(proof.binding); err != nil {
return err
}
revision, err := resolveConfinementRevision(proof.binding)
if err != nil {
return err
}
if proof.binding.Revision != revision ||
proof.policy.SchemaVersion != confinementPolicySchemaVersion ||
proof.policy.Revision != revision ||
!reflect.DeepEqual(proof.policy.WritableRoots, proof.binding.WritableRoots) {
return errors.New("confinement proof integrity check failed")
}
if !reflect.DeepEqual(proof.binding, expected) {
return errors.New("confinement proof does not match the dispatch identity")
}
return nil
}
// Start installs the proof's OS policy and starts the child before returning,
// so the caller cannot replace the sandbox helper path or arguments.
func (proof *ConfinementProof) Start(
ctx context.Context,
spec agenttask.ConfinementCommand,
) (agenttask.StartedConfinement, error) {
if proof == nil {
return nil, errors.New("agentworkspace: confinement proof is missing")
}
if err := proof.Validate(proof.Binding()); err != nil {
return nil, fmt.Errorf("agentworkspace: validate confinement proof: %w", err)
}
if strings.TrimSpace(spec.Name) == "" {
return nil, errors.New("agentworkspace: confined command is required")
}
command, err := platformConfinementCommand(
ctx,
proof.binding,
proof.policy,
spec.Name,
spec.Args,
)
if err != nil {
return nil, err
}
command.Dir = proof.binding.WorkingDir
if spec.Env != nil {
command.Env = append([]string(nil), spec.Env...)
}
started, err := startConfinementCommand(command)
if err != nil {
return nil, err
}
return started, nil
}
type confinementPipeFactory func() (*os.File, *os.File, error)
func startConfinementCommand(
command *exec.Cmd,
) (agenttask.StartedConfinement, error) {
return startConfinementCommandWithPipes(command, os.Pipe)
}
func startConfinementCommandWithPipes(
command *exec.Cmd,
openPipe confinementPipeFactory,
) (agenttask.StartedConfinement, error) {
if command == nil {
return nil, errors.New("agentworkspace: confined child command is missing")
}
if openPipe == nil {
return nil, errors.New("agentworkspace: confinement pipe factory is missing")
}
if command.Stdin != nil || command.Stdout != nil || command.Stderr != nil {
return nil, errors.New("agentworkspace: confined child I/O must be proof-owned")
}
var endpoints []*os.File
closeEndpoints := func() {
for _, endpoint := range endpoints {
if endpoint != nil {
_ = endpoint.Close()
}
}
}
stdinChild, stdinParent, err := openPipe()
if err != nil {
return nil, fmt.Errorf("agentworkspace: create confined stdin pipe: %w", err)
}
endpoints = append(endpoints, stdinChild, stdinParent)
stdoutParent, stdoutChild, err := openPipe()
if err != nil {
closeEndpoints()
return nil, fmt.Errorf("agentworkspace: create confined stdout pipe: %w", err)
}
endpoints = append(endpoints, stdoutParent, stdoutChild)
stderrParent, stderrChild, err := openPipe()
if err != nil {
closeEndpoints()
return nil, fmt.Errorf("agentworkspace: create confined stderr pipe: %w", err)
}
endpoints = append(endpoints, stderrParent, stderrChild)
command.Stdin = stdinChild
command.Stdout = stdoutChild
command.Stderr = stderrChild
if err := command.Start(); err != nil {
closeEndpoints()
return nil, fmt.Errorf("agentworkspace: start confined child: %w", err)
}
started := &startedConfinement{
child: command,
stdin: stdinParent,
stdout: stdoutParent,
stderr: stderrParent,
}
var closeErrors []error
for _, childEndpoint := range []*os.File{stdinChild, stdoutChild, stderrChild} {
if err := childEndpoint.Close(); err != nil && !errors.Is(err, os.ErrClosed) {
closeErrors = append(closeErrors, err)
}
}
if err := errors.Join(closeErrors...); err != nil {
_ = started.Abort()
return nil, fmt.Errorf("agentworkspace: release confined child pipe endpoints: %w", err)
}
return started, nil
}
func resolveConfinementRevision(
binding agenttask.ConfinementBinding,
) (string, error) {
platformRevision, err := platformConfinementRevision()
if err != nil {
return "", err
}
hashParts := []string{
fmt.Sprintf("%d", confinementPolicySchemaVersion),
platformRevision,
binding.IsolationID,
binding.IsolationRevision,
binding.PinnedBaseRevision,
binding.ConfigRevision,
binding.GrantRevision,
binding.ProfileRevision,
binding.BaseRoot,
binding.RuntimeRoot,
binding.SnapshotRoot,
binding.TaskRoot,
binding.WorkingDir,
}
for _, root := range binding.WritableRoots {
hashParts = append(hashParts, root)
}
hash := sha256.New()
for _, part := range hashParts {
writeDigestPart(hash, part)
}
return "sha256:" + hex.EncodeToString(hash.Sum(nil)), nil
}
func validateConfinementBinding(binding agenttask.ConfinementBinding) error {
for name, value := range map[string]string{
"isolation ID": binding.IsolationID,
"isolation revision": binding.IsolationRevision,
"base revision": binding.PinnedBaseRevision,
"config revision": binding.ConfigRevision,
"grant revision": binding.GrantRevision,
"profile revision": binding.ProfileRevision,
"canonical root": binding.BaseRoot,
"runtime root": binding.RuntimeRoot,
"snapshot root": binding.SnapshotRoot,
"task root": binding.TaskRoot,
"working directory": binding.WorkingDir,
} {
if strings.TrimSpace(value) == "" {
return fmt.Errorf("agentworkspace: confinement %s is required", name)
}
}
if len(binding.WritableRoots) != 3 ||
binding.WritableRoots[0] != binding.WorkingDir {
return errors.New(
"agentworkspace: confinement requires exact view, temp, and cache writable roots",
)
}
paths := []string{
binding.BaseRoot,
binding.RuntimeRoot,
binding.SnapshotRoot,
binding.TaskRoot,
binding.WorkingDir,
}
paths = append(paths, binding.WritableRoots...)
for _, path := range paths {
if !filepath.IsAbs(path) || filepath.Clean(path) != path {
return errors.New(
"agentworkspace: confinement paths must be absolute and clean",
)
}
resolved, err := filepath.EvalSymlinks(path)
if err != nil || filepath.Clean(resolved) != path {
return errors.New(
"agentworkspace: confinement paths must be existing canonical paths",
)
}
}
if binding.BaseRoot == binding.TaskRoot ||
pathContains(binding.BaseRoot, binding.RuntimeRoot) ||
pathContains(binding.RuntimeRoot, binding.BaseRoot) {
return errors.New(
"agentworkspace: canonical and runtime roots must be separate",
)
}
if !pathContains(binding.RuntimeRoot, binding.SnapshotRoot) ||
!pathContains(binding.RuntimeRoot, binding.TaskRoot) ||
pathContains(binding.TaskRoot, binding.SnapshotRoot) {
return errors.New(
"agentworkspace: snapshot and task roots must have the strict runtime layout",
)
}
seen := make(map[string]struct{}, len(binding.WritableRoots))
for _, root := range binding.WritableRoots {
if !pathContains(binding.TaskRoot, root) || root == binding.TaskRoot {
return errors.New(
"agentworkspace: writable roots must remain below the task root",
)
}
if _, duplicate := seen[root]; duplicate {
return errors.New("agentworkspace: writable roots must be unique")
}
seen[root] = struct{}{}
}
if !pathContains(binding.TaskRoot, binding.WorkingDir) {
return errors.New(
"agentworkspace: working directory must remain below the task root",
)
}
return nil
}
func cloneConfinementBinding(
binding agenttask.ConfinementBinding,
) agenttask.ConfinementBinding {
binding.WritableRoots = append([]string(nil), binding.WritableRoots...)
return binding
}