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