Skip to content
Merged
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
68 changes: 68 additions & 0 deletions src/engine/engine_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,16 @@ steps:
prompt: x
- id: fetch_data
prompt: y`, "collide under env key"},
{"invalid step enforce", `name: T
steps:
- id: a
prompt: x
enforce: maybe`, `invalid enforce "maybe"`},
{"enforce on command step", `name: T
steps:
- id: a
command: "echo hi"
enforce: soft`, "enforce on a command step"},
}

for _, tt := range tests {
Expand Down Expand Up @@ -1415,6 +1425,64 @@ steps:
}
}

func TestParseStepLevelEnforceOverride(t *testing.T) {
yaml := []byte(`
name: mixed-enforce
steps:
- id: review
prompt: Pure reasoning, stays hard.
- id: fix
prompt: Writes files, needs soft.
enforce: soft
- id: summary
prompt: Pure reasoning again.
enforce: hard
`)
wf, err := Parse(yaml)
if err != nil {
t.Fatalf("parse failed: %v", err)
}
if wf.Enforce != "hard" {
t.Errorf("workflow enforce = %q, want default hard", wf.Enforce)
}
if wf.Steps[0].Enforce != "" {
t.Errorf("step 0 enforce = %q, want empty (inherit)", wf.Steps[0].Enforce)
}
if wf.Steps[1].Enforce != "soft" {
t.Errorf("step 1 enforce = %q, want soft", wf.Steps[1].Enforce)
}
if wf.Steps[2].Enforce != "hard" {
t.Errorf("step 2 enforce = %q, want hard", wf.Steps[2].Enforce)
}
}

func TestEffectiveEnforce(t *testing.T) {
tests := []struct {
name string
wfField string
stepField string
want string
}{
{"step soft overrides wf hard", "hard", "soft", "soft"},
{"step hard overrides wf soft", "soft", "hard", "hard"},
{"empty step inherits wf soft", "soft", "", "soft"},
{"empty step inherits wf hard", "hard", "", "hard"},
{"both zero → default hard", "", "", "hard"},
{"zero wf + soft step → soft", "", "soft", "soft"},
{"zero wf + hard step → hard", "", "hard", "hard"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
wf := Workflow{Enforce: tt.wfField}
step := WfStep{Enforce: tt.stepField}
got := EffectiveEnforce(wf, step)
if got != tt.want {
t.Errorf("EffectiveEnforce = %q, want %q", got, tt.want)
}
})
}
}

func TestInterpolateDeterministic(t *testing.T) {
// Regression: map iteration order is randomized in Go; Interpolate
// must sort keys so a step output containing {{another-id}} renders
Expand Down
40 changes: 40 additions & 0 deletions src/engine/workflow.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,30 @@ type WfStep struct {
Loop *Loop `yaml:"loop"`
Branch []Branch `yaml:"branch"`
Principles []string `yaml:"principles"` // per-step override
// Enforce overrides the workflow-level enforce for this step only.
// Empty inherits from Workflow.Enforce. Lets a workflow keep most
// prompt steps under hard (mid-step tool block) while allowing
// specific steps whose body needs tools the hard mode blocks to
// run under soft. The Stop-hook still blocks session end on soft
// steps, so end-of-turn drift is still caught.
Enforce string `yaml:"enforce,omitempty"`
}

// EffectiveEnforce returns the enforcement mode for a step, falling back
// to the workflow-level setting when the step does not override it, and
// to "hard" when neither is set. Callers must use this instead of
// reading step.Enforce directly so the fall-through is consistent at
// every state transition. Takes values (not pointers) so the compiler
// enforces that both fields exist at the call site — every current
// caller owns concrete structs by the time they reach a transition.
func EffectiveEnforce(wf Workflow, step WfStep) string {
if step.Enforce != "" {
return step.Enforce
}
if wf.Enforce != "" {
return wf.Enforce
}
return "hard"
}

// Loop controls step repetition.
Expand Down Expand Up @@ -151,6 +175,22 @@ func validate(wf *Workflow) error {
if s.Expect != "" && s.Expect != "success" && s.Expect != "failure" {
return fmt.Errorf("step %q has invalid expect %q — must be \"success\" or \"failure\"", s.ID, s.Expect)
}
// Step-level enforce override: empty inherits from workflow,
// otherwise must be hard|soft. Reject on command steps — the
// guard honors SessionState.Enforce uniformly (see guard.go's
// command branch), so marking a command step `soft` would let
// arbitrary agent tool calls slip through while the engine is
// executing that step. Since command steps are engine-owned
// and never need per-step overrides, fail loudly at parse time
// instead of producing a sharp edge at runtime.
if s.Enforce != "" {
if s.Enforce != "hard" && s.Enforce != "soft" {
return fmt.Errorf("step %q has invalid enforce %q — must be \"hard\" or \"soft\"", s.ID, s.Enforce)
}
if s.Command != "" {
return fmt.Errorf("step %q has enforce on a command step — enforce is only meaningful for prompt steps (the engine executes command steps directly)", s.ID)
}
}
if s.Command != "" && s.Loop != nil {
return fmt.Errorf("step %q has both command and loop — these are mutually exclusive", s.ID)
}
Expand Down
19 changes: 14 additions & 5 deletions src/mcp/tools.go
Original file line number Diff line number Diff line change
Expand Up @@ -160,11 +160,14 @@ func (s *Server) startTool() (mcpmcp.Tool, mcpgo.ToolHandlerFunc) {
CurrentIndex: 0,
TotalSteps: len(wf.Steps),
StepType: stepType(firstStep),
Enforce: wf.Enforce,
Branch: wf.BranchMode,
Status: "starting",
StartedAt: time.Now(),
Outputs: map[string]string{},
// SessionState.Enforce is the current step's effective
// enforce, re-derived on every transition so the hook
// always reads the mode that matches state.CurrentStep.
Enforce: engine.EffectiveEnforce(*wf, firstStep),
Branch: wf.BranchMode,
Status: "starting",
StartedAt: time.Now(),
Outputs: map[string]string{},
}, nil
})
if err != nil {
Expand Down Expand Up @@ -478,6 +481,7 @@ func (s *Server) advanceTool() (mcpmcp.Tool, mcpgo.ToolHandlerFunc) {
state.CurrentStep = nextStep.ID
state.CurrentIndex = nextIndex
state.StepType = stepType(nextStep)
state.Enforce = engine.EffectiveEnforce(*wf, nextStep)
state.Busy = false
if err := lib.WriteSessionJSON(s.dataDir, state); err != nil {
return mcpmcp.NewToolResultError(fmt.Sprintf("write state: %v", err)), nil
Expand Down Expand Up @@ -642,6 +646,10 @@ func (s *Server) handleLoopAdvance(ctx context.Context, wf *engine.Workflow, sta

// Continue loop — return same step for another iteration.
// Clear the advance claim as part of this write (see advanceTool).
// state.Enforce is NOT re-derived: loop iterations stay on the same
// step, so the step's effective enforce does not change. If that
// invariant ever breaks (e.g. enforce becomes iteration-dependent),
// recompute here like the other transition sites do.
state.Busy = false
if err := lib.WriteSessionJSON(s.dataDir, state); err != nil {
return mcpmcp.NewToolResultError(fmt.Sprintf("write loop state: %v", err)), nil
Expand All @@ -665,6 +673,7 @@ func (s *Server) advancePastLoop(wf *engine.Workflow, state *lib.SessionState) (
state.CurrentStep = nextStep.ID
state.CurrentIndex = nextIndex
state.StepType = stepType(nextStep)
state.Enforce = engine.EffectiveEnforce(*wf, nextStep)
state.Busy = false
if err := lib.WriteSessionJSON(s.dataDir, state); err != nil {
return mcpmcp.NewToolResultError(fmt.Sprintf("write state: %v", err)), nil
Expand Down
Loading
Loading