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

910 lines
31 KiB
Go

package agentworkspace
import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"io"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"sync"
"syscall"
"testing"
"time"
"golang.org/x/sys/unix"
"iop/packages/go/agentconfig"
"iop/packages/go/agentguard"
"iop/packages/go/agenttask"
)
const confinementHelperEnvironment = "IOP_AGENTWORKSPACE_TEST_CONFINEMENT_HELPER"
type confinementHelperPlan struct {
Allowed []string `json:"allowed"`
Denied []string `json:"denied"`
AllowedMetadata []string `json:"allowed_metadata"`
DeniedMetadata []string `json:"denied_metadata"`
}
func TestConfinementWriteHelper(t *testing.T) {
encoded := os.Getenv(confinementHelperEnvironment)
if encoded == "" {
return
}
raw, err := base64.RawURLEncoding.DecodeString(encoded)
if err != nil {
t.Fatalf("decode helper plan: %v", err)
}
var plan confinementHelperPlan
if err := json.Unmarshal(raw, &plan); err != nil {
t.Fatalf("parse helper plan: %v", err)
}
for _, path := range plan.Allowed {
if err := os.WriteFile(path, []byte("allowed\n"), 0o600); err != nil {
t.Fatalf("allowed write %s: %v", path, err)
}
t.Logf("allowed write: %s", filepath.Base(path))
}
for _, path := range plan.Denied {
err := os.WriteFile(path, []byte("denied\n"), 0o600)
if err == nil {
t.Fatalf("denied write unexpectedly succeeded: %s", path)
}
if !errors.Is(err, syscall.EACCES) &&
!errors.Is(err, syscall.EPERM) &&
!errors.Is(err, syscall.EROFS) {
t.Fatalf("denied write returned an unexpected error for %s: %v", path, err)
}
t.Logf("denied write: %s: %v", filepath.Base(path), err)
}
for _, path := range plan.AllowedMetadata {
for _, mutation := range confinementMetadataMutations(path) {
if err := mutation.apply(); err != nil {
if mutation.name == "setxattr" && xattrUnsupported(err) {
t.Logf("allowed metadata %s unsupported: %v", mutation.name, err)
continue
}
t.Fatalf("allowed metadata %s %s: %v", mutation.name, path, err)
}
t.Logf("allowed metadata %s: %s", mutation.name, filepath.Base(path))
}
}
for _, path := range plan.DeniedMetadata {
for _, mutation := range confinementMetadataMutations(path) {
err := mutation.apply()
if mutation.name == "setxattr" && xattrUnsupported(err) {
t.Logf("denied metadata %s unsupported: %v", mutation.name, err)
continue
}
if err == nil {
t.Fatalf("denied metadata %s unexpectedly succeeded: %s", mutation.name, path)
}
if !confinementMutationDenied(err) {
t.Fatalf(
"denied metadata %s returned an unexpected error for %s: %v",
mutation.name,
path,
err,
)
}
t.Logf("denied metadata %s: %s: %v", mutation.name, filepath.Base(path), err)
}
}
}
type confinementMetadataMutation struct {
name string
apply func() error
}
func confinementMetadataMutations(path string) []confinementMetadataMutation {
timestamp := time.Unix(123456789, 0)
return []confinementMetadataMutation{
{name: "chmod", apply: func() error { return os.Chmod(path, 0o640) }},
{name: "utime", apply: func() error { return os.Chtimes(path, timestamp, timestamp) }},
{name: "chown", apply: func() error { return os.Chown(path, os.Getuid(), os.Getgid()) }},
{name: "setxattr", apply: func() error {
return unix.Setxattr(path, "user.iop_agentworkspace_test", []byte("metadata"), 0)
}},
}
}
func confinementMutationDenied(err error) bool {
return errors.Is(err, syscall.EACCES) ||
errors.Is(err, syscall.EPERM) ||
errors.Is(err, syscall.EROFS)
}
func xattrUnsupported(err error) bool {
return errors.Is(err, unix.ENOTSUP) || errors.Is(err, unix.EOPNOTSUPP)
}
func TestOverlayBackendConfinementDeniesActualAbsoluteWrites(t *testing.T) {
if runtime.GOOS != "linux" && runtime.GOOS != "darwin" {
t.Skip("executable confinement is implemented on Linux and macOS")
}
baseRoot, localRoot := newWorkspaceFixture(t)
initializeDirtyGitFixture(t, baseRoot)
protectedDescriptorPath := filepath.Join(baseRoot, "descriptor-protected.txt")
writeFile(t, protectedDescriptorPath, "descriptor protected\n", 0o600)
beforeSnapshot, err := captureWorkspaceSnapshot(
context.Background(),
baseRoot,
"",
"config-r1",
"grant-r1",
)
if err != nil {
t.Fatalf("capture before snapshot: %v", err)
}
beforeStatus := gitOutput(t, baseRoot, "status", "--porcelain=v1", "--untracked-files=all")
beforeGitConfig, err := os.ReadFile(filepath.Join(baseRoot, ".git", "config"))
if err != nil {
t.Fatalf("read canonical Git config: %v", err)
}
backend := newTestBackend(t, localRoot, baseRoot)
first, err := backend.Prepare(
context.Background(),
testIsolationRequest("task-a", "task-a#1"),
)
if err != nil {
t.Fatalf("Prepare(first): %v", err)
}
second, err := backend.Prepare(
context.Background(),
testIsolationRequest("task-b", "task-b#1"),
)
if err != nil {
t.Fatalf("Prepare(second): %v", err)
}
firstRecord, err := backend.LoadRecord(*first.Descriptor)
if err != nil {
t.Fatalf("LoadRecord(first): %v", err)
}
secondRecord, err := backend.LoadRecord(*second.Descriptor)
if err != nil {
t.Fatalf("LoadRecord(second): %v", err)
}
beforeSnapshotRecord, err := os.ReadFile(firstRecord.Locator.SnapshotRecord)
if err != nil {
t.Fatalf("read snapshot record: %v", err)
}
allowed := []string{
filepath.Join(firstRecord.Locator.ViewRoot, "child-view.txt"),
filepath.Join(firstRecord.Locator.TempRoot, "child-temp.txt"),
filepath.Join(firstRecord.Locator.CacheRoot, "child-cache.txt"),
}
denied := []string{
filepath.Join(baseRoot, "canonical-child.txt"),
protectedDescriptorPath,
filepath.Join(secondRecord.Locator.ViewRoot, "sibling-child.txt"),
filepath.Join(firstRecord.Locator.SnapshotRoot, "snapshot-child.txt"),
filepath.Join(baseRoot, ".git", "confinement-child"),
}
deniedMetadata := []string{
filepath.Join(baseRoot, "shared.txt"),
protectedDescriptorPath,
filepath.Join(secondRecord.Locator.ViewRoot, "shared.txt"),
firstRecord.Locator.SnapshotRecord,
firstRecord.Locator.OverlayRecord,
filepath.Join(baseRoot, ".git", "config"),
}
beforeMetadata := make(map[string]fileMetadata, len(deniedMetadata))
for _, path := range deniedMetadata {
beforeMetadata[path] = captureFileMetadata(t, path)
}
encodedPlan, err := json.Marshal(confinementHelperPlan{
Allowed: allowed,
Denied: denied,
AllowedMetadata: allowed,
DeniedMetadata: deniedMetadata,
})
if err != nil {
t.Fatalf("encode helper plan: %v", err)
}
protectedDescriptor, err := os.OpenFile(
protectedDescriptorPath,
os.O_WRONLY|os.O_APPEND,
0,
)
if err != nil {
t.Fatalf("open protected host descriptor: %v", err)
}
defer protectedDescriptor.Close()
started, err := first.Confinement.Start(
context.Background(),
agenttask.ConfinementCommand{
Name: os.Args[0],
Args: []string{
"-test.run=^TestConfinementWriteHelper$",
"-test.v",
},
Env: append(
os.Environ(),
confinementHelperEnvironment+"="+
base64.RawURLEncoding.EncodeToString(encodedPlan),
),
},
)
if err != nil {
t.Fatalf("start confined child: %v", err)
}
stdoutFile, stdoutIsFile := started.Stdout().(*os.File)
stderrFile, stderrIsFile := started.Stderr().(*os.File)
if !stdoutIsFile || !stderrIsFile ||
stdoutFile.Fd() == protectedDescriptor.Fd() ||
stderrFile.Fd() == protectedDescriptor.Fd() {
t.Fatal("proof-owned child output reused the protected host descriptor")
}
type pipeResult struct {
content string
err error
}
stdoutResult := make(chan pipeResult, 1)
stderrResult := make(chan pipeResult, 1)
go func() {
content, err := io.ReadAll(started.Stdout())
stdoutResult <- pipeResult{content: string(content), err: err}
}()
go func() {
content, err := io.ReadAll(started.Stderr())
stderrResult <- pipeResult{content: string(content), err: err}
}()
if err := started.Stdin().Close(); err != nil {
t.Fatalf("close confined child stdin: %v", err)
}
waitErr := started.Child().Wait()
stdout := <-stdoutResult
stderr := <-stderrResult
if stdout.err != nil || stderr.err != nil {
t.Fatalf("read confined helper output: stdout=%v stderr=%v", stdout.err, stderr.err)
}
output := stdout.content + stderr.content
if waitErr != nil {
t.Fatalf("confined helper: %v\n%s", waitErr, output)
}
if err := started.Abort(); err != nil {
t.Fatalf("cleanup confined helper: %v", err)
}
t.Logf("confined child output:\n%s", output)
if !strings.Contains(output, "allowed write") ||
!strings.Contains(output, "denied write") ||
!strings.Contains(output, "allowed metadata") ||
!strings.Contains(output, "denied metadata") {
t.Fatalf("confined helper did not report both outcomes:\n%s", output)
}
for _, path := range allowed {
assertFileContent(t, path, "allowed\n")
}
for _, path := range denied {
if path == protectedDescriptorPath {
assertFileContent(t, path, "descriptor protected\n")
continue
}
assertNotExist(t, path)
}
for path, before := range beforeMetadata {
if after := captureFileMetadata(t, path); after != before {
t.Fatalf("confined child changed protected metadata at %s: before=%#v after=%#v", path, before, after)
}
}
afterSnapshotRecord, err := os.ReadFile(firstRecord.Locator.SnapshotRecord)
if err != nil {
t.Fatalf("read snapshot record after child: %v", err)
}
if string(afterSnapshotRecord) != string(beforeSnapshotRecord) {
t.Fatal("confined child changed the immutable snapshot record")
}
afterGitConfig, err := os.ReadFile(filepath.Join(baseRoot, ".git", "config"))
if err != nil {
t.Fatalf("read canonical Git config after child: %v", err)
}
if string(afterGitConfig) != string(beforeGitConfig) {
t.Fatal("confined child changed shared Git metadata")
}
afterStatus := gitOutput(t, baseRoot, "status", "--porcelain=v1", "--untracked-files=all")
if afterStatus != beforeStatus {
t.Fatalf("canonical Git status changed:\nbefore=%q\nafter=%q", beforeStatus, afterStatus)
}
afterSnapshot, err := captureWorkspaceSnapshot(
context.Background(),
baseRoot,
"",
"config-r1",
"grant-r1",
)
if err != nil {
t.Fatalf("capture after snapshot: %v", err)
}
if afterSnapshot.Revision != beforeSnapshot.Revision {
t.Fatalf(
"canonical fingerprint changed: %q != %q",
afterSnapshot.Revision,
beforeSnapshot.Revision,
)
}
}
type fileMetadata struct {
Mode os.FileMode
ModTime time.Time
}
func captureFileMetadata(t *testing.T, path string) fileMetadata {
t.Helper()
info, err := os.Lstat(path)
if err != nil {
t.Fatalf("stat %s: %v", path, err)
}
return fileMetadata{Mode: info.Mode(), ModTime: info.ModTime()}
}
func TestOverlayBackendConcurrentTasksSharePinnedDirtyBaseAndIsolateWrites(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("mode and symlink fixtures require Unix filesystem semantics")
}
baseRoot, localRoot := newWorkspaceFixture(t)
initializeDirtyGitFixture(t, baseRoot)
beforeStatus := gitOutput(t, baseRoot, "status", "--porcelain=v1", "--untracked-files=all")
backend := newTestBackend(t, localRoot, baseRoot)
requests := []agenttask.IsolationRequest{
testIsolationRequest("task-a", "task-a#1"),
testIsolationRequest("task-b", "task-b#1"),
}
prepared := make([]agenttask.PreparedIsolation, len(requests))
errorsByIndex := make([]error, len(requests))
var wait sync.WaitGroup
for index := range requests {
index := index
wait.Add(1)
go func() {
defer wait.Done()
prepared[index], errorsByIndex[index] = backend.Prepare(
context.Background(),
requests[index],
)
}()
}
wait.Wait()
for index, err := range errorsByIndex {
if err != nil {
t.Fatalf("Prepare task %d: %v", index, err)
}
}
first := prepared[0].Descriptor
second := prepared[1].Descriptor
if first.PinnedBaseRevision != second.PinnedBaseRevision {
t.Fatalf(
"pinned base revisions differ: %q != %q",
first.PinnedBaseRevision,
second.PinnedBaseRevision,
)
}
if first.TaskRoot == second.TaskRoot || first.WorkingDir == second.WorkingDir {
t.Fatalf("task roots are not isolated: %#v %#v", first, second)
}
for _, result := range prepared {
admission := agentguard.Admit(agentguard.AdmissionRequest{
Grant: result.Grant, Isolation: result.Descriptor, Profile: result.Profile,
})
if !admission.Allowed() {
t.Fatalf("prepared overlay was not admissible: %#v", admission.Blocker)
}
}
firstRecord, err := backend.LoadRecord(*first)
if err != nil {
t.Fatalf("LoadRecord(first): %v", err)
}
secondRecord, err := backend.LoadRecord(*second)
if err != nil {
t.Fatalf("LoadRecord(second): %v", err)
}
if firstRecord.Locator.SnapshotRoot != secondRecord.Locator.SnapshotRoot {
t.Fatalf(
"snapshot roots differ: %q != %q",
firstRecord.Locator.SnapshotRoot,
secondRecord.Locator.SnapshotRoot,
)
}
assertSnapshotEvidence(t, firstRecord.Locator.SnapshotRecord)
assertFileContent(t, filepath.Join(first.WorkingDir, "shared.txt"), "dirty base\n")
assertFileContent(t, filepath.Join(second.WorkingDir, "shared.txt"), "dirty base\n")
assertFileContent(t, filepath.Join(first.WorkingDir, "untracked.txt"), "untracked base\n")
assertFileContent(t, filepath.Join(second.WorkingDir, "untracked.txt"), "untracked base\n")
assertSymlink(t, filepath.Join(first.WorkingDir, "shared-link"), "shared.txt")
assertExecutable(t, filepath.Join(first.WorkingDir, "script.sh"))
writeFile(t, filepath.Join(first.WorkingDir, "shared.txt"), "task a\n", 0o644)
writeFile(t, filepath.Join(second.WorkingDir, "shared.txt"), "task b\n", 0o644)
writeFile(t, filepath.Join(first.WorkingDir, "only-a.txt"), "a\n", 0o644)
writeFile(t, filepath.Join(second.WorkingDir, "only-b.txt"), "b\n", 0o644)
writeFile(t, filepath.Join(firstRecord.Locator.TempRoot, "build.tmp"), "a temp\n", 0o600)
writeFile(t, filepath.Join(secondRecord.Locator.TempRoot, "build.tmp"), "b temp\n", 0o600)
writeFile(t, filepath.Join(firstRecord.Locator.CacheRoot, "cache"), "a cache\n", 0o600)
writeFile(t, filepath.Join(secondRecord.Locator.CacheRoot, "cache"), "b cache\n", 0o600)
writeFile(t, filepath.Join(firstRecord.Locator.GitMetadata, "task-owned"), "a git\n", 0o600)
assertFileContent(t, filepath.Join(first.WorkingDir, "shared.txt"), "task a\n")
assertFileContent(t, filepath.Join(second.WorkingDir, "shared.txt"), "task b\n")
assertFileContent(t, filepath.Join(baseRoot, "shared.txt"), "dirty base\n")
assertNotExist(t, filepath.Join(baseRoot, "only-a.txt"))
assertNotExist(t, filepath.Join(baseRoot, "only-b.txt"))
assertNotExist(t, filepath.Join(second.WorkingDir, "only-a.txt"))
assertNotExist(t, filepath.Join(first.WorkingDir, "only-b.txt"))
assertFileContent(t, filepath.Join(firstRecord.Locator.TempRoot, "build.tmp"), "a temp\n")
assertFileContent(t, filepath.Join(secondRecord.Locator.TempRoot, "build.tmp"), "b temp\n")
assertFileContent(t, filepath.Join(firstRecord.Locator.CacheRoot, "cache"), "a cache\n")
assertFileContent(t, filepath.Join(secondRecord.Locator.CacheRoot, "cache"), "b cache\n")
assertNotExist(t, filepath.Join(baseRoot, ".git", "task-owned"))
assertNotExist(t, filepath.Join(secondRecord.Locator.GitMetadata, "task-owned"))
afterStatus := gitOutput(t, baseRoot, "status", "--porcelain=v1", "--untracked-files=all")
if beforeStatus != afterStatus {
t.Fatalf("canonical Git state changed:\nbefore=%q\nafter=%q", beforeStatus, afterStatus)
}
assertWritableRootDenied(t, prepared[0], baseRoot, agentguard.BlockerCodeWritableRootEscape)
assertWritableRootDenied(
t,
prepared[0],
secondRecord.Locator.ViewRoot,
agentguard.BlockerCodeWritableRootEscape,
)
canonicalLink := filepath.Join(firstRecord.Locator.TempRoot, "canonical-link")
if err := os.Symlink(baseRoot, canonicalLink); err != nil {
t.Fatalf("create canonical link: %v", err)
}
assertWritableRootDenied(
t,
prepared[0],
canonicalLink,
agentguard.BlockerCodeWritableRootEscape,
)
}
func TestOverlayBackendIdempotencyAndFailureRetention(t *testing.T) {
baseRoot, localRoot := newWorkspaceFixture(t)
writeFile(t, filepath.Join(baseRoot, "input.txt"), "base\n", 0o644)
backend := newTestBackend(t, localRoot, baseRoot)
request := testIsolationRequest("task", "task#1")
first, err := backend.Prepare(context.Background(), request)
if err != nil {
t.Fatalf("Prepare(first): %v", err)
}
writeFile(t, filepath.Join(first.Descriptor.WorkingDir, "partial.txt"), "retained\n", 0o600)
writeFile(t, filepath.Join(baseRoot, "input.txt"), "base drift\n", 0o644)
second, err := backend.Prepare(context.Background(), request)
if err != nil {
t.Fatalf("Prepare(second): %v", err)
}
if first.Descriptor.ID != second.Descriptor.ID ||
first.Descriptor.Revision != second.Descriptor.Revision ||
first.Descriptor.TaskRoot != second.Descriptor.TaskRoot {
t.Fatalf("idempotent prepare changed identity: %#v != %#v", first.Descriptor, second.Descriptor)
}
assertFileContent(t, filepath.Join(second.Descriptor.WorkingDir, "input.txt"), "base\n")
conflicting := request
conflicting.Work.Unit.ID = "other-task"
conflicting.Work.AttemptID = "other-task#1"
if _, err := backend.Prepare(context.Background(), conflicting); err == nil ||
!strings.Contains(err.Error(), "does not match the request") {
t.Fatalf("conflicting idempotency key error = %v", err)
}
freshRequest := testIsolationRequest("fresh-task", "fresh-task#1")
fresh, err := backend.Prepare(context.Background(), freshRequest)
if err != nil {
t.Fatalf("Prepare(fresh): %v", err)
}
if fresh.Descriptor.PinnedBaseRevision == first.Descriptor.PinnedBaseRevision {
t.Fatal("fresh task reused a stale base revision")
}
assertFileContent(t, filepath.Join(fresh.Descriptor.WorkingDir, "input.txt"), "base drift\n")
blockedProfile := second.Profile
blockedProfile.ApprovalBypass = false
admission := agentguard.Admit(agentguard.AdmissionRequest{
Grant: second.Grant, Isolation: second.Descriptor, Profile: blockedProfile,
})
if admission.Allowed() ||
admission.Blocker == nil ||
admission.Blocker.Code != agentguard.BlockerCodeProviderApprovalBypassUnavailable {
t.Fatalf("unexpected blocked admission: %#v", admission)
}
record, err := backend.LoadRecord(*second.Descriptor)
if err != nil {
t.Fatalf("LoadRecord after admission failure: %v", err)
}
if record.Retention != RetentionStateActive {
t.Fatalf("retention state = %q, want %q", record.Retention, RetentionStateActive)
}
if record.RetentionPolicy.BlockedDays != 14 {
t.Fatalf("blocked retention days = %d, want 14", record.RetentionPolicy.BlockedDays)
}
assertFileContent(t, filepath.Join(second.Descriptor.WorkingDir, "partial.txt"), "retained\n")
assertFileContent(t, filepath.Join(baseRoot, "input.txt"), "base drift\n")
}
func TestOverlayBackendRejectsRetainedIdentityRebinding(t *testing.T) {
baseRootA, localRoot := newWorkspaceFixture(t)
baseRootB := filepath.Join(filepath.Dir(baseRootA), "workspace-b")
if err := os.MkdirAll(baseRootB, 0o700); err != nil {
t.Fatalf("create root B: %v", err)
}
writeFile(t, filepath.Join(baseRootA, "identity.txt"), "root A\n", 0o600)
writeFile(t, filepath.Join(baseRootB, "identity.txt"), "root B\n", 0o600)
resolvedRoot := baseRootA
resolver := InputResolverFunc(func(
_ context.Context,
request agenttask.IsolationRequest,
) (ResolvedInputs, error) {
return ResolvedInputs{
Grant: agentguard.WorkspaceGrant{
ProjectID: string(request.Project.ProjectID),
WorkspaceID: string(request.Project.WorkspaceID),
Root: resolvedRoot,
Revision: string(request.Project.Intent.GrantRevision),
},
Profile: agentguard.ProviderProfile{
ProviderID: request.Target.ProviderID,
ModelID: request.Target.ModelID,
ProfileID: request.Target.ProfileID,
Revision: request.Target.ProfileRevision,
Unattended: true,
ApprovalBypass: true,
WritableRootConfinement: true,
},
}, nil
})
backend, err := NewBackend(BackendConfig{LocalRoot: localRoot}, resolver)
if err != nil {
t.Fatalf("NewBackend: %v", err)
}
request := testIsolationRequest("task", "task#1")
first, err := backend.Prepare(context.Background(), request)
if err != nil {
t.Fatalf("Prepare(first): %v", err)
}
record, err := backend.LoadRecord(*first.Descriptor)
if err != nil {
t.Fatalf("LoadRecord(first): %v", err)
}
beforeRecord, err := os.ReadFile(record.Locator.OverlayRecord)
if err != nil {
t.Fatalf("read retained record: %v", err)
}
snapshot, err := readWorkspaceSnapshot(record.Locator.SnapshotRecord)
if err != nil {
t.Fatalf("read retained snapshot: %v", err)
}
if snapshot.CanonicalRoot != baseRootA ||
snapshot.ConfigRevision != "config-r1" ||
snapshot.GrantRevision != "grant-r1" {
t.Fatalf("snapshot identity = %#v", snapshot)
}
resolvedRoot = baseRootB
if _, err := backend.Prepare(context.Background(), request); err == nil ||
!strings.Contains(err.Error(), "identity does not match") {
t.Fatalf("root rebinding error = %v", err)
}
resolvedRoot = baseRootA
configRebind := request
configIntent := *request.Project.Intent
configIntent.ConfigRevision = "config-r2"
configRebind.Project.Intent = &configIntent
configRebind.Target.ConfigRevision = "config-r2"
if _, err := backend.Prepare(context.Background(), configRebind); err == nil ||
!strings.Contains(err.Error(), "identity does not match") {
t.Fatalf("config rebinding error = %v", err)
}
grantRebind := request
grantIntent := *request.Project.Intent
grantIntent.GrantRevision = "grant-r2"
grantRebind.Project.Intent = &grantIntent
if _, err := backend.Prepare(context.Background(), grantRebind); err == nil ||
!strings.Contains(err.Error(), "identity does not match") {
t.Fatalf("grant rebinding error = %v", err)
}
afterRecord, err := os.ReadFile(record.Locator.OverlayRecord)
if err != nil {
t.Fatalf("read retained record after rejection: %v", err)
}
if string(afterRecord) != string(beforeRecord) {
t.Fatal("identity rebinding changed the retained overlay record")
}
exact, err := backend.Prepare(context.Background(), request)
if err != nil {
t.Fatalf("Prepare(exact replay): %v", err)
}
if exact.Descriptor.ID != first.Descriptor.ID ||
exact.Descriptor.Revision != first.Descriptor.Revision ||
exact.Descriptor.ConfinementRevision != first.Descriptor.ConfinementRevision {
t.Fatalf("exact replay changed identity: %#v != %#v", exact.Descriptor, first.Descriptor)
}
assertFileContent(t, filepath.Join(exact.Descriptor.WorkingDir, "identity.txt"), "root A\n")
}
func TestOverlayBackendRejectsCanonicalSymlinkEscapeBeforeCreatingTask(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("symlink fixture requires Unix filesystem semantics")
}
baseRoot, localRoot := newWorkspaceFixture(t)
outside := filepath.Join(filepath.Dir(baseRoot), "outside.txt")
writeFile(t, outside, "outside\n", 0o600)
if err := os.Symlink(outside, filepath.Join(baseRoot, "escape")); err != nil {
t.Fatalf("create escaping symlink: %v", err)
}
backend := newTestBackend(t, localRoot, baseRoot)
_, err := backend.Prepare(
context.Background(),
testIsolationRequest("task", "task#1"),
)
if err == nil || !strings.Contains(err.Error(), "absolute symlink") {
t.Fatalf("Prepare error = %v, want absolute symlink confinement failure", err)
}
entries, readErr := os.ReadDir(filepath.Join(localRoot, "tasks"))
if readErr != nil {
t.Fatalf("ReadDir tasks: %v", readErr)
}
if len(entries) != 0 {
t.Fatalf("failed preparation left task artifacts: %v", entries)
}
}
func TestOverlayBackendRejectsNestedSharedGitMetadata(t *testing.T) {
baseRoot, localRoot := newWorkspaceFixture(t)
writeFile(t, filepath.Join(baseRoot, "nested", ".git", "config"), "[core]\n", 0o600)
backend := newTestBackend(t, localRoot, baseRoot)
_, err := backend.Prepare(
context.Background(),
testIsolationRequest("task", "task#1"),
)
if err == nil || !strings.Contains(err.Error(), "nested Git metadata") {
t.Fatalf("Prepare error = %v, want nested Git metadata failure", err)
}
entries, readErr := os.ReadDir(filepath.Join(localRoot, "tasks"))
if readErr != nil {
t.Fatalf("ReadDir tasks: %v", readErr)
}
if len(entries) != 0 {
t.Fatalf("failed preparation left task artifacts: %v", entries)
}
}
func newTestBackend(t *testing.T, localRoot, baseRoot string) *Backend {
t.Helper()
resolver := InputResolverFunc(func(
_ context.Context,
request agenttask.IsolationRequest,
) (ResolvedInputs, error) {
return ResolvedInputs{
Grant: agentguard.WorkspaceGrant{
ProjectID: string(request.Project.ProjectID),
WorkspaceID: string(request.Project.WorkspaceID),
Root: baseRoot,
Revision: string(request.Project.Intent.GrantRevision),
},
Profile: agentguard.ProviderProfile{
ProviderID: request.Target.ProviderID,
ModelID: request.Target.ModelID,
ProfileID: request.Target.ProfileID,
Revision: request.Target.ProfileRevision,
Unattended: true,
ApprovalBypass: true,
WritableRootConfinement: true,
},
}, nil
})
backend, err := NewBackend(BackendConfig{
LocalRoot: localRoot,
Retention: agentconfig.RetentionPolicy{
CompletedDays: 7,
BlockedDays: 14,
},
}, resolver)
if err != nil {
t.Fatalf("NewBackend: %v", err)
}
return backend
}
func testIsolationRequest(workID agenttask.WorkUnitID, attemptID agenttask.AttemptID) agenttask.IsolationRequest {
projectID := agenttask.ProjectID("project")
workspaceID := agenttask.WorkspaceID("workspace")
return agenttask.IsolationRequest{
Project: agenttask.ProjectRecord{
ProjectID: projectID,
WorkspaceID: workspaceID,
Intent: &agenttask.StartIntent{
ProjectID: projectID, WorkspaceID: workspaceID,
GrantRevision: "grant-r1", ConfigRevision: "config-r1",
},
},
Work: agenttask.WorkRecord{
Unit: agenttask.WorkUnit{
ID: workID, IsolationMode: agentguard.IsolationModeOverlay,
},
AttemptID: attemptID,
},
Target: agenttask.ExecutionTarget{
ProviderID: "provider", ModelID: "model", ProfileID: "profile",
ProfileRevision: "profile-r1", ConfigRevision: "config-r1", Capacity: 2,
},
IdempotencyKey: "dispatch/" + string(workID) + "/" + string(attemptID) + "/isolation",
}
}
func newWorkspaceFixture(t *testing.T) (string, string) {
t.Helper()
root, err := filepath.EvalSymlinks(t.TempDir())
if err != nil {
t.Fatalf("EvalSymlinks: %v", err)
}
baseRoot := filepath.Join(root, "workspace")
localRoot := filepath.Join(root, "runtime")
for _, directory := range []string{baseRoot, localRoot} {
if err := os.MkdirAll(directory, 0o700); err != nil {
t.Fatalf("MkdirAll %s: %v", directory, err)
}
}
return baseRoot, localRoot
}
func initializeDirtyGitFixture(t *testing.T, root string) {
t.Helper()
if _, err := exec.LookPath("git"); err != nil {
t.Skip("git is required for the workspace snapshot fixture")
}
gitRun(t, root, "init", "-q")
gitRun(t, root, "config", "user.email", "agentworkspace@example.invalid")
gitRun(t, root, "config", "user.name", "Agent Workspace Test")
writeFile(t, filepath.Join(root, "shared.txt"), "committed\n", 0o644)
writeFile(t, filepath.Join(root, "script.sh"), "#!/bin/sh\nexit 0\n", 0o755)
if err := os.Symlink("shared.txt", filepath.Join(root, "shared-link")); err != nil {
t.Fatalf("create internal symlink: %v", err)
}
gitRun(t, root, "add", "shared.txt", "script.sh", "shared-link")
gitRun(t, root, "commit", "-q", "-m", "fixture")
writeFile(t, filepath.Join(root, "shared.txt"), "dirty base\n", 0o644)
writeFile(t, filepath.Join(root, "untracked.txt"), "untracked base\n", 0o644)
}
func assertSnapshotEvidence(t *testing.T, recordPath string) {
t.Helper()
snapshot, err := readWorkspaceSnapshot(recordPath)
if err != nil {
t.Fatalf("readWorkspaceSnapshot: %v", err)
}
entries := make(map[string]SnapshotEntry, len(snapshot.Entries))
for _, entry := range snapshot.Entries {
entries[entry.Path] = entry
}
shared := entries["shared.txt"]
if shared.GitState != SnapshotGitStateTracked || !shared.Dirty ||
shared.ContentDigest == "" {
t.Fatalf("tracked dirty entry = %#v", shared)
}
untracked := entries["untracked.txt"]
if untracked.GitState != SnapshotGitStateUntracked || untracked.Dirty ||
untracked.ContentDigest == "" {
t.Fatalf("untracked entry = %#v", untracked)
}
script := entries["script.sh"]
if os.FileMode(script.Mode).Perm()&0o100 == 0 {
t.Fatalf("script mode was not fingerprinted: %#o", script.Mode)
}
link := entries["shared-link"]
if link.Kind != SnapshotEntrySymlink || link.SymlinkTarget != "shared.txt" ||
link.ContentDigest == "" {
t.Fatalf("symlink entry = %#v", link)
}
}
func assertWritableRootDenied(
t *testing.T,
prepared agenttask.PreparedIsolation,
root string,
want agentguard.BlockerCode,
) {
t.Helper()
descriptor := *prepared.Descriptor
descriptor.WritableRoots = append(append([]string(nil), descriptor.WritableRoots...), root)
result := agentguard.Admit(agentguard.AdmissionRequest{
Grant: prepared.Grant, Isolation: &descriptor, Profile: prepared.Profile,
})
if result.Allowed() || result.Blocker == nil || result.Blocker.Code != want {
t.Fatalf("tampered writable root result = %#v, want %q", result, want)
}
}
func assertFileContent(t *testing.T, path, want string) {
t.Helper()
content, err := os.ReadFile(path)
if err != nil {
t.Fatalf("ReadFile %s: %v", path, err)
}
if string(content) != want {
t.Fatalf("content %s = %q, want %q", path, content, want)
}
}
func assertNotExist(t *testing.T, path string) {
t.Helper()
if _, err := os.Lstat(path); !os.IsNotExist(err) {
t.Fatalf("path %s exists or returned unexpected error: %v", path, err)
}
}
func assertSymlink(t *testing.T, path, want string) {
t.Helper()
target, err := os.Readlink(path)
if err != nil {
t.Fatalf("Readlink %s: %v", path, err)
}
if target != want {
t.Fatalf("symlink target %s = %q, want %q", path, target, want)
}
}
func assertExecutable(t *testing.T, path string) {
t.Helper()
info, err := os.Stat(path)
if err != nil {
t.Fatalf("Stat %s: %v", path, err)
}
if info.Mode().Perm()&0o100 == 0 {
t.Fatalf("%s is not executable: %s", path, info.Mode())
}
}
func writeFile(t *testing.T, path, content string, mode os.FileMode) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
t.Fatalf("MkdirAll %s: %v", filepath.Dir(path), err)
}
if err := os.WriteFile(path, []byte(content), mode); err != nil {
t.Fatalf("WriteFile %s: %v", path, err)
}
if err := os.Chmod(path, mode); err != nil {
t.Fatalf("Chmod %s: %v", path, err)
}
}
func gitRun(t *testing.T, root string, args ...string) {
t.Helper()
command := exec.Command("git", append([]string{"-C", root}, args...)...)
command.Env = append(os.Environ(), "LC_ALL=C")
if output, err := command.CombinedOutput(); err != nil {
t.Fatalf("git %v: %v\n%s", args, err, output)
}
}
func gitOutput(t *testing.T, root string, args ...string) string {
t.Helper()
command := exec.Command("git", append([]string{"-C", root}, args...)...)
command.Env = append(os.Environ(), "GIT_OPTIONAL_LOCKS=0", "LC_ALL=C")
output, err := command.CombinedOutput()
if err != nil {
t.Fatalf("git %v: %v\n%s", args, err, output)
}
return string(output)
}