독립 호스트에서 안전한 작업 실행과 복구를 제공하기 위해 런타임 설정, 정책, 상태 저장소, 워크스페이스 격리 및 AgentTask 오케스트레이션을 확장한다.
440 lines
12 KiB
Go
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
|
|
}
|