Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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.
10 changes: 10 additions & 0 deletions docs/changes/unreleased/2354-agents-cannot-read-credentials.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
kind: changed
title: Agents cannot read credentials in any approval mode
pr: 2354
surface: [chat, engine, app, factory]
invalidates:
- "The conversation could run gh auth token and read credential files under the default allow; only a task was refused. Every agent is now refused, in every approval mode, and only a person's own ! command is exempt."
- "Agent shells inherited GH_TOKEN, GITHUB_TOKEN and other variables named like credentials, though provider keys were stripped. They are now removed too, and CODEAF_ALLOW_PROVIDER_KEYS_IN_SHELL keeps both."
- "The watch tool's commands and principal audit checks inherited the whole environment. They now get the same environment as any other agent shell."
---
18 changes: 18 additions & 0 deletions docs/changes/unreleased/2361-floor-reads.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
kind: fixed
title: Idle factory polls read only job documents that changed
pr: 2361
surface: [engine]
invalidates:
- "Idle lease pulls, settlement and runner capacity checks decoded every job document on each look. They now reuse unchanged documents after checking every file's identity, size and modification time."
---

Each List result owns its mutable data. Repository migration and output retention
still run on each look. Writes from another store and replacements with unchanged
size and modification time are visible on the next look. Every document is read
again at least once a minute, so an in-place edit that keeps its size and time is
seen within that bound.

Lease grants and execution gates still read the job under its lock. A Stop that
lands during selection permits only a thread-only lease for a pending reply,
without restarting job steps.
14 changes: 14 additions & 0 deletions docs/changes/unreleased/2365-usage-writer-drain.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
kind: fixed
title: Closing a conversation waits for queued spending records
pr: 2365
surface: [engine, chat]
invalidates:
- Closing a session previously left queued usage rows behind; it now drains that ledger under a bounded wait.
---

Closing a conversation now waits for its queued spending records, including the
last call, before returning. A stalled ledger can still leave rows pending after
the two-second limit on each close; that leaves one line in the log and counts
those rows as not written down until they are. Other conversations sharing the ledger
can keep recording spending.
Loading