Skip to content
Merged
22 changes: 21 additions & 1 deletion cmd/codeaf/factory_stage.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"time"

"github.com/Agent-Field/codeaf/internal/approval"
"github.com/Agent-Field/codeaf/internal/config"
"github.com/Agent-Field/codeaf/internal/env"
"github.com/Agent-Field/codeaf/internal/factory"
factoryrun "github.com/Agent-Field/codeaf/internal/factory/run"
Expand Down Expand Up @@ -295,13 +296,21 @@ func factoryStagePosture() string {
// name) still hold, because they sit under every policy and they are not a
// person's preference. A person who wants a stage narrower says so with
// [factoryPostureEnv].
// An unreadable profile still requires a person: its saved denials are unknown.
type stageApprovalGate struct {
rows *v3ApprovalGate
posture string
}

// Build is the gate for one posture.
func (g stageApprovalGate) Build(posture string) (*approval.Policy, bool, error) {
if g.rows != nil {
_, err := config.ReadProfileApprovalConfig(g.rows.profileDir)
var pathErr *os.PathError
if errors.As(err, &pathErr) {
return &approval.Policy{ProfileReadError: err.Error()}, false, nil
}
}
if posture != session.PostureAllow {
return g.rows.Build(posture)
}
Expand Down Expand Up @@ -486,6 +495,10 @@ func (c *stageConversation) drain(events <-chan session.Event) {
}
c.say(factory.ToolCaptionPrefix + factory.ToolLogText(hint, c.dir))
case session.EventToolFailed:
var refused *approval.ProfileReadFailure
if errors.As(event.Err, &refused) {
c.fail(refused)
}
reason := event.Hint
if reason == "" && event.Err != nil {
reason = event.Err.Error()
Expand Down Expand Up @@ -519,7 +532,10 @@ func (c *stageConversation) fail(err error) {
}
c.mu.Lock()
defer c.mu.Unlock()
c.failure = err
var refused *approval.ProfileReadFailure
if !errors.As(c.failure, &refused) {
c.failure = err
}
}

// say puts one caption on the account, dropping it rather than holding the
Expand Down Expand Up @@ -631,6 +647,10 @@ func (c *stageConversation) Wait(ctx context.Context) error {
case <-done:
c.mu.Lock()
defer c.mu.Unlock()
var refused *approval.ProfileReadFailure
if errors.As(c.failure, &refused) {
return refused
}
if c.budget != nil {
if why := c.budget.Refusal(); why != "" {
// A daily refusal already recorded was not caused by memory, so
Expand Down
148 changes: 148 additions & 0 deletions cmd/codeaf/factory_stage_profile_job_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
package main

import (
"bytes"
"context"
"encoding/json"
"github.com/Agent-Field/codeaf/internal/config"
"github.com/Agent-Field/codeaf/internal/factory"
factoryrun "github.com/Agent-Field/codeaf/internal/factory/run"
"github.com/Agent-Field/codeaf/internal/factory/store"
"github.com/Agent-Field/codeaf/internal/home"
"github.com/Agent-Field/codeaf/internal/remote"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
)

func TestUnreadableStageJobKeepsConfigReason(t *testing.T) {
testUnreadableStageJobKeepsConfigReason(t, false)
}

func TestInitiallyUnreadableStageJobKeepsConfigReason(t *testing.T) {
testUnreadableStageJobKeepsConfigReason(t, true)
}

func testUnreadableStageJobKeepsConfigReason(t *testing.T, initiallyUnreadable bool) {
t.Helper()
if os.Geteuid() == 0 {
t.Skip("mode 000 requires nonroot")
}
root := resolvedTempDir(t)
t.Setenv("HOME", filepath.Join(root, "home"))
t.Setenv(home.EnvVar, filepath.Join(root, "state"))
t.Setenv(config.ProfileDirEnv, "")
t.Setenv("OPENROUTER_API_KEY", "test-key")
t.Setenv(factoryPostureEnv, "")
workspace := filepath.Join(root, "work")
if err := os.MkdirAll(workspace, 0o700); err != nil {
t.Fatal(err)
}
target := filepath.Join(workspace, "NOTES.md")
writeArgs, _ := json.Marshal(map[string]string{"path": target, "content": "written by the stage\n"})
model := newScriptedStageModel(t, stageCall{"write", string(writeArgs)}, stageCall{"stage_result", stageModelReport})
t.Setenv("CODEAF_BASE_URL", model.server.URL)
t.Chdir(workspace)
freshEngineProcess(t)
stageSettle = 20 * time.Millisecond
t.Cleanup(func() { stageSettle = unattendedRunSettleTick })

proc, err := openEngineProcess()
if err != nil {
t.Fatal(err)
}
launch, err := openV3Launch(proc, engineLaunchOptions(remote.Hello{Version: remote.Version, Workspace: workspace}, workspace, ""))
if err != nil {
t.Fatal(err)
}
st, err := store.Open(filepath.Join(root, "factory"))
if err != nil {
t.Fatal(err)
}
id, err := st.Add(context.Background(), factory.Item{
Title: "write the notes", Repo: "work", Tier: factory.TierOwner,
Stages: []factory.Stage{{Name: "write", Kind: factory.StageChat, Ask: "write the notes", Until: "done", On: true}},
})
if err != nil {
t.Fatal(err)
}
if err := st.Update(id, func(it *factory.Item) error { it.Stream = &factory.Stream{}; return nil }); err != nil {
t.Fatal(err)
}
it, _ := st.Get(id)

profile := ""
file := config.BudgetConfigPath(profile)
if err := os.MkdirAll(filepath.Dir(file), 0700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(file, []byte(`{"tools.approvals":"write:deny"}`), 0600); err != nil {
t.Fatal(err)
}
reason := "saved approval rules could not be read"
if initiallyUnreadable {
if err := os.Chmod(file, 0); err != nil {
t.Fatal(err)
}
reason = "permission denied"
} else {
oldHandler := model.server.Config.Handler
var once sync.Once
model.server.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
r.Body = io.NopCloser(bytes.NewReader(body))
if bytes.Contains(body, []byte(`"stage_result"`)) {
once.Do(func() {
if err := os.Chmod(file, 0); err != nil {
t.Error(err)
}
})
}
oldHandler.ServeHTTP(w, r)
})
}
t.Cleanup(func() { _ = os.Chmod(file, 0600) })
maker := factoryStageMaker(st, workspace, profile, launch.Config)
exec := factoryChatStage(st, profile, factoryrun.NewChatExecutor(maker))
ctx, cancel := context.WithTimeout(context.Background(), 12*time.Second)
defer cancel()
res, err := exec.Run(ctx, factoryrun.Job{Item: it, Stage: it.Stages[0], Index: 0, Round: 1, Dir: workspace, Log: func(line string) {
t.Logf("JOB LOG: %s", line)
if strings.Contains(line, "reminded") {
t.Error("terminal profile refusal must not retry reporting")
}
}})
if err != nil {
t.Fatalf("ROUND ERROR: %v", err)
}
t.Logf("ROUND RESULT: %+v", res)
if res.Done || res.Exit != 1 || !strings.Contains(res.Output, file) || !strings.Contains(res.Output, reason) {
t.Errorf("job lost the terminal profile-read reason: %+v", res)
}
model.mu.Lock()
defer model.mu.Unlock()
if initiallyUnreadable {
t.Logf("STAGE MODEL REQUESTS: %d", len(model.briefs))
if len(model.briefs) != 0 || res.Spent != 0 || res.Reports != 0 {
t.Fatalf("refused admission did work: requests=%d spent=%v reports=%d", len(model.briefs), res.Spent, res.Reports)
}
}
for _, brief := range model.briefs {
if i := strings.Index(brief, "no resolver is attached"); i >= 0 {
t.Logf("MODEL RECEIVED REFUSAL: %.650s", brief[i:])
}
}
got, readErr := os.ReadFile(target)
t.Logf("TARGET: content=%q err=%v", got, readErr)
if !os.IsNotExist(readErr) {
t.Fatalf("unreadable stage wrote file")
}
if ctx.Err() != nil {
t.Fatalf("stage failed to stop before timeout: %v", ctx.Err())
}
}
95 changes: 95 additions & 0 deletions cmd/codeaf/factory_stage_unreadable_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
package main

import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"

"github.com/Agent-Field/codeaf/internal/approval"
"github.com/Agent-Field/codeaf/internal/config"
"github.com/Agent-Field/codeaf/internal/session"
)

func TestStageUnreadableProfileRequiresPerson(t *testing.T) {
if os.Geteuid() == 0 {
t.Skip("mode 000 requires nonroot")
}
profile := v3Profile(t, map[string]any{config.KeyToolApprovalMode: "prompt", config.KeyToolApprovals: "write:deny"})
file := filepath.Join(profile, "config.json")
if err := os.Chmod(file, 0); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = os.Chmod(file, 0o600) })
rows := &v3ApprovalGate{workspace: t.TempDir(), profileDir: profile, headless: true}
for _, posture := range []string{session.PostureAllow, session.PostureAsk, session.PostureDeny} {
t.Run(posture, func(t *testing.T) {
var gate session.ApprovalGate = stageApprovalGate{rows: rows, posture: posture}
policy, guardian, err := gate.Build(posture)
if err != nil {
t.Fatal(err)
}
decision := policy.Check("write", json.RawMessage(`{"path":"x","content":"x"}`))
t.Logf("stage write: %s", decision)
if decision.Action != approval.ActionPrompt || guardian || !strings.Contains(decision.Rule, file) || !strings.Contains(decision.Rule, "could not be read") {
t.Fatalf("unreadable saved denial must require a person with a clear reason: decision=%+v guardian=%v", decision, guardian)
}
})
}
}

func TestStageProfileReadFailureWinsOverProjectParseError(t *testing.T) {
profile, workspace := t.TempDir(), t.TempDir()
file := filepath.Join(profile, "config.json")
if err := os.Mkdir(file, 0o700); err != nil {
t.Fatal(err)
}
_, readErr := config.ReadProfileApprovalConfig(profile)
if readErr == nil {
t.Fatal("expected a filesystem read failure")
}
if err := os.Mkdir(filepath.Join(workspace, ".codeaf"), 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(workspace, ".codeaf", "config.json"), []byte("{"), 0o600); err != nil {
t.Fatal(err)
}
for _, posture := range []string{session.PostureAllow, session.PostureAsk, session.PostureDeny, session.PostureGuardian, session.PostureAuto} {
t.Run(posture, func(t *testing.T) {
var gate session.ApprovalGate = stageApprovalGate{rows: &v3ApprovalGate{workspace: workspace, profileDir: profile}, posture: posture}
policy, guardian, err := gate.Build(posture)
if err != nil {
t.Fatalf("known profile failure lost to project error: %v", err)
}
decision := policy.Check("write", json.RawMessage(`{"path":"notes.txt","content":"no"}`))
if guardian || decision.Action != approval.ActionPrompt || !strings.Contains(decision.ProfileReadError, file) {
t.Fatalf("lost profile failure: guardian=%v decision=%+v", guardian, decision)
}
})
}
}

func TestStageReadableMalformedProfileKeepsDefaultAllowance(t *testing.T) {
for _, contents := range []string{"{", "null", "[]"} {
t.Run(contents, func(t *testing.T) {
profile := t.TempDir()
if err := os.WriteFile(filepath.Join(profile, "config.json"), []byte(contents), 0o600); err != nil {
t.Fatal(err)
}
rows := &v3ApprovalGate{workspace: t.TempDir(), profileDir: profile}
var gate session.ApprovalGate = stageApprovalGate{rows: rows, posture: session.PostureAllow}
policy, guardian, err := gate.Build(session.PostureAllow)
if err != nil {
t.Fatal(err)
}
decision := policy.Check("write", json.RawMessage(`{"path":"notes.txt","content":"ok"}`))
if guardian || decision.Action != approval.ActionAllow || decision.ProfileReadError != "" {
t.Fatalf("readable default-stage bypass changed: guardian=%v decision=%+v", guardian, decision)
}
if _, _, err := gate.Build(session.PostureAsk); err == nil {
t.Fatal("readable malformed JSON must still fail ordinary row construction")
}
})
}
}
25 changes: 25 additions & 0 deletions docs/changes/unreleased/2325-unreadable-config-limits.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
---
kind: fixed
title: codeaf keeps unreadable profile approval limits in factory stages and task children
pr: 2325
surface: [engine, factory]
invalidates:
- "A factory stage's allow posture bypassed an unreadable profile's saved approval rules. It now requires a person when those rules cannot be read."
- "Task workers, repair rounds and auditors dropped their parent's profile read failure. They now consult that signal at each decision and refuse calls that need a person when nobody can answer."
- "A project parse error could hide a known profile read failure. The stage now returns the failure already in hand before reading project rules."
- "Standalone run workers discarded profile read failures. They now check their run's profile before each call, including after work starts."
- "A refused job could lose the config-file reason during report recovery or first-turn budget admission. Its result now keeps that reason without futile report retries."
---

A readable profile keeps its existing behavior, including a factory stage's own
allow posture. An unreadable profile cannot silently discard a saved denial:
the existing approval gate reports the failed file read and requires a person.

The named readable default-stage bypass includes readable malformed JSON.
Other postures retain their existing parse-error behavior. Filesystem read
failures remain distinct from JSON parse failures. Task, repair, checker and
fleet children keep their own normal allowance while following the parent's
current failed-read signal; recovery clears that inherited signal too.

First-turn daily-limit refusals retain the config filename and read error.
Admission decisions, spending counts and charges are unchanged.
6 changes: 6 additions & 0 deletions internal/approval/approval.go
Original file line number Diff line number Diff line change
Expand Up @@ -459,3 +459,9 @@ func toList(value any) ([]any, bool) {
return nil, false
}
}

// ProfileReadFailure is an unattended call refused because the saved rules
// could not be read. Runners retain this reason ahead of report recovery.
type ProfileReadFailure struct{ Reason string }

func (e *ProfileReadFailure) Error() string { return e.Reason }
6 changes: 6 additions & 0 deletions internal/factory/run/chatstage.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ import (
"sync"
"time"

"github.com/Agent-Field/codeaf/internal/approval"
"github.com/Agent-Field/codeaf/internal/factory"
"github.com/Agent-Field/codeaf/internal/factory/verification"
)
Expand Down Expand Up @@ -402,6 +403,11 @@ loop:
// one that is idle with nothing reported is asked again (stagereport.go)
// until there is nothing left to ask. A spending limit is never asked
// past: the turn it would start is refused before it begins.
var refused *approval.ProfileReadFailure
if errors.As(waitErr, &refused) {
idleFailure = &factory.StageResult{Exit: 1, Output: refused.Error()}
break loop
}
if _, stopped := spendStopOf(waitErr); stopped {
break loop
}
Expand Down
20 changes: 14 additions & 6 deletions internal/manual/chat/permissions.md
Original file line number Diff line number Diff line change
Expand Up @@ -611,12 +611,20 @@ whatever posture or `--yolo` you selected. The prompt names the file and explain
that saved approval rules could not be read. Remembered approvals and the guardian
cannot answer these questions for you. A headless call with nobody to ask returns
an error naming the file. Once the file is readable, its rules apply again.
Malformed JSON still fails policy construction.

Two kinds of work do not get this protection yet. A default Factory stage runs on
its own allow posture and does not read your saved rules, whether the file is
readable or not. A task child started by a conversation does not carry the failed
read over from its parent, so it can run a call your saved rules would deny.
Malformed JSON still fails ordinary policy construction; the readable default-stage
bypass below also applies to malformed JSON.

Factory stages and work started by tasks get the same protection: every call
needs a person, and with nobody to ask it is refused. Workers, repair rounds and
checkers see a parent's current read failure even after they start. Standalone
run workers check the profile before each call. A refused Factory job keeps the
failed-read reason and config filename in its result, without report retries.
This includes a daily-limit check that refuses the first turn because the
profile cannot be read; no model request is made.

**Readable default-stage bypass:** with a readable file, a default Factory stage
runs on its own allow posture and does not apply your saved rules. This named
limit also covers readable malformed JSON.

## Default YOLO in interactive conversations and headless runs

Expand Down
Loading