From f246b3e941e2b4f0493ae6197ab6888d9c39030a Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Sat, 18 Jul 2026 22:05:53 +0530 Subject: [PATCH 1/6] trace(perf): add posture escalation counter --- internal/trace/trace.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/internal/trace/trace.go b/internal/trace/trace.go index c128e406f..e3bcf4c84 100644 --- a/internal/trace/trace.go +++ b/internal/trace/trace.go @@ -57,6 +57,11 @@ const ( CounterPrewarmAttempts = "prewarm_attempts" CounterPrefixStable = "prefix_stable" CounterPrefixDrift = "prefix_drift" + // CounterPostureEscalations counts one-shot in-run posture escalations by + // the execution-profile controller. Deliberately distinct from + // model_switches: a posture escalation changes loop policy knobs, never the + // model or session. + CounterPostureEscalations = "posture_escalations" ) // Span is one named wall interval attributed to part of a run. Each stamp is @@ -289,6 +294,7 @@ func OptionalEventKeys() []string { "counter:" + CounterPrewarmAttempts, "counter:" + CounterPrefixStable, "counter:" + CounterPrefixDrift, + "counter:" + CounterPostureEscalations, "event:prefix_hash", } } From 624e073334e33a44d90c0ea8c0c2ef405d0f4a71 Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Sat, 18 Jul 2026 22:06:28 +0530 Subject: [PATCH 2/6] feat(agent): typed posture-escalation policy and tool-result risk field --- internal/agent/types.go | 54 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/internal/agent/types.go b/internal/agent/types.go index 94dcfb676..28f1fdb5f 100644 --- a/internal/agent/types.go +++ b/internal/agent/types.go @@ -75,6 +75,12 @@ type ToolResult struct { // DenialReason categorizes why a tool call was blocked (empty when it ran). // It lets a surface distinguish the cause precisely instead of parsing Output. DenialReason DenialCategory + // Risk is the sandbox risk classification of this call, stamped for EXECUTED + // results so run-policy observers (the execution-profile controller) can see + // the risk level of an allowed mutation. It mirrors the classification the + // permission path already computes; denied or canceled results keep the zero + // value. Pure observation: nothing about permissions or sandboxing changes. + Risk sandbox.Risk // LoadedTools carries the deferred-tool names a tool_search call asked the // loop to expose next turn (lifted from Meta["load_tools"]). nil for every // ordinary tool result; only tool_search populates it. @@ -98,6 +104,48 @@ const ( DenialHookBlocked DenialCategory = "hook_blocked" // vetoed by a beforeTool hook ) +// ProfilePolicy is the loop-facing slice of a selected execution profile. +// The surface (exec flag, TUI command) resolves a named profile into this +// policy; the loop only ever sees the policy, never the catalog. +type ProfilePolicy struct { + // Name labels trace counters and diagnostics (e.g. "fast"). The trace + // recorder's profile label is set by the caller at recorder construction. + Name string + // Escalate, when non-nil, arms one-shot in-run posture escalation. + Escalate *PostureEscalation +} + +// PostureEscalation describes a one-shot escalation to stricter knob values, +// applied mid-run when an armed trigger fires. Targets are the values the +// selected profile DISPLACED at run start (i.e. "restore the balanced +// posture"), so escalation can never introduce a value that was not already +// valid for this run and model. Zero-valued targets leave that knob untouched. +type PostureEscalation struct { + // MaxTurns raises the turn ceiling to this value when greater than the + // ceiling in effect. 0 leaves the ceiling untouched. + MaxTurns int + // ReasoningEffort replaces the run's effort when non-empty. + ReasoningEffort string + // RestoreCompletionGate re-enables RequireCompletionSignal (headless + // completion semantics) when true. + RestoreCompletionGate bool + + // Triggers. A zero value disables that signal entirely. + // OnToolFailureStreak fires when the repeated-failure guard observes a + // same-tool retriable-failure streak of at least this length. + OnToolFailureStreak int + // OnCompletionUncertain fires on the Nth uncertain completion evaluation + // (continue nudge or semantic check). Headless only: the completion gate + // never runs interactively. + OnCompletionUncertain int + // OnSelfCorrectFailure fires when a post-edit verification cycle reports a + // failing outcome (correcting, reported, or aborted). + OnSelfCorrectFailure bool + // OnRiskyMutation fires when an EXECUTED tool result carries a sandbox risk + // level at or above this threshold. Empty disables the signal. + OnRiskyMutation sandbox.RiskLevel +} + type PermissionRequest struct { ToolCallID string `json:"toolCallId"` ToolName string `json:"name"` @@ -298,6 +346,12 @@ type Options struct { // contract as ModelSwitcher: a returned error records a note and the run // continues on the current model and session. ModelSessionSwitcher func(ctx context.Context, modelID string) (zeroruntime.TurnSessionProvider, error) + // Profile, when set, arms the execution-profile posture controller for this + // run (auto-escalation to stricter knob values on failure/uncertainty/risky + // mutation signals). nil — the default everywhere today — leaves the loop + // byte-identical: no observation, no escalation, no counters. Same opt-in + // convention as Trace and SelfCorrect. + Profile *ProfilePolicy // Trace, when set, records per-turn timing for the run: the loop stamps // spans (prompt build, generation, tool execution, permission wait, // compaction, provider connect) and counters (model requests, tool calls, From 9406059a8f821c6547c83a902aa447a2d50aa031 Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Sat, 18 Jul 2026 22:08:00 +0530 Subject: [PATCH 3/6] feat(agent): posture controller wiring and executed-risk stamping --- internal/agent/loop.go | 40 +++++++++- internal/agent/profile_controller.go | 114 +++++++++++++++++++++++++++ 2 files changed, 153 insertions(+), 1 deletion(-) create mode 100644 internal/agent/profile_controller.go diff --git a/internal/agent/loop.go b/internal/agent/loop.go index 131be3cdd..adec737ee 100644 --- a/internal/agent/loop.go +++ b/internal/agent/loop.go @@ -185,6 +185,10 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options) guards := newGuardState() compactor := newCompactionState(options) + // The execution-profile posture controller. nil Options.Profile (every + // existing caller) makes every observe/decide call a no-op, keeping the + // loop byte-identical for unprofiled runs. + posture := newProfileController(options.Profile) // Background post-edit diagnostics: files changed by mutating tools are // checked off the tool-call critical path and any errors are appended as a @@ -692,6 +696,7 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options) // would misdirect the model toward JSON shape or blocked behavior. retriableFailure := isRetriableToolError(toolResult) outcome := guards.observeToolResult(call.Name, retriableFailure, toolResult.Output) + posture.observeToolOutcome(outcome, toolResult) if outcome.Stop { // The assistant message advertised EVERY collected tool call, but // the guard halts mid-turn so the calls after this one never run. @@ -724,7 +729,9 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options) // the assistant's tool_results stay contiguous (a user message between // tool_results breaks strict provider replay). nil SelfCorrect is a no-op. if options.SelfCorrect != nil && len(changedFilesThisBatch) > 0 { - if feedback, _ := options.SelfCorrect.AfterEdit(ctx, dedupeStrings(changedFilesThisBatch)); feedback != "" { + feedback, selfCorrectOutcome := options.SelfCorrect.AfterEdit(ctx, dedupeStrings(changedFilesThisBatch)) + posture.observeSelfCorrect(selfCorrectOutcome) + if feedback != "" { messages = append(messages, zeroruntime.Message{ Role: zeroruntime.MessageRoleUser, Content: feedback, @@ -817,6 +824,24 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options) Content: reminder, }) } + + // One-shot posture escalation: when an armed profile trigger fired this + // turn, restore the stricter knob values the profile displaced. Mutates + // only per-turn-read policy (the hoisted turn ceiling, reasoning effort, + // completion gate); never messages, model, or session. No-op forever + // after the first escalation and for every unprofiled run. + if target, fired := posture.maybeEscalate(); fired { + if target.MaxTurns > maxTurns { + maxTurns = target.MaxTurns + } + if target.ReasoningEffort != "" { + options.ReasoningEffort = target.ReasoningEffort + } + if target.RestoreCompletionGate { + options.RequireCompletionSignal = true + } + options.Trace.Counter(trace.CounterPostureEscalations, 1) + } } if ctx.Err() != nil { @@ -1338,10 +1363,23 @@ func executeToolCall(ctx context.Context, registry *tools.Registry, call ToolCal result = registry.RebudgetAfterHook(call.Name, args, result) } } + // Stamp the sandbox risk classification for this EXECUTED call so run-policy + // observers can see the risk of an allowed mutation. Prefer the preflight + // decision's classification when the sandbox evaluated the call; otherwise + // run the same pure classifier the permission path uses. Denied/canceled + // results returned earlier keep the zero value. + executedRisk := sandbox.Risk{} + if preflightDecision != nil { + executedRisk = preflightDecision.Risk + } else { + executedRisk = sandbox.Classify(sandboxRequest(call.Name, tool, args, permissionGranted, permissionMode, options)) + } + // Secret scrubbing happens at the registry boundary (the single point both // the agent loop and the MCP server pass through), so result.Output is // already redacted here and result.Redacted reflects whether it changed. return ToolResult{ + Risk: executedRisk, ToolCallID: call.ID, Name: call.Name, Status: result.Status, diff --git a/internal/agent/profile_controller.go b/internal/agent/profile_controller.go new file mode 100644 index 000000000..d7bd56dff --- /dev/null +++ b/internal/agent/profile_controller.go @@ -0,0 +1,114 @@ +package agent + +import ( + "github.com/Gitlawb/zero/internal/sandbox" + "github.com/Gitlawb/zero/internal/tools" +) + +// profileController observes per-turn run signals and decides at most one +// posture escalation per run for the armed execution profile. A nil policy (or +// a policy without Escalate) makes every method a no-op, so runs without a +// profile are byte-identical to before — the same opt-in convention as Trace +// and SelfCorrect. +// +// The controller only ever CHANGES loop policy knobs (turn ceiling, reasoning +// effort, completion gate) through the values the loop applies at its act +// point; it never injects messages, never touches the model or session, and +// never interacts with the model-initiated escalate_model path. +type profileController struct { + policy *PostureEscalation + + escalated bool + uncertainSeen int + + failureTripped bool + riskTripped bool + scTripped bool + uncertainTrip bool +} + +// newProfileController is nil-safe: a nil ProfilePolicy (the default for every +// existing caller) or a nil Escalate yields a controller that observes and +// decides nothing. +func newProfileController(policy *ProfilePolicy) *profileController { + if policy == nil { + return &profileController{} + } + return &profileController{policy: policy.Escalate} +} + +// observeToolOutcome watches two signals from an executed tool call: the +// repeated-failure guard's streak (reusing the guard's own counting, not a +// second failure model) and the sandbox risk classification of an executed +// result. +func (c *profileController) observeToolOutcome(outcome toolFailureOutcome, result ToolResult) { + if c.policy == nil || c.escalated { + return + } + if c.policy.OnToolFailureStreak > 0 && outcome.Count >= c.policy.OnToolFailureStreak { + c.failureTripped = true + } + if c.policy.OnRiskyMutation != "" && result.Status == tools.StatusOK && + riskRank(result.Risk.Level) >= riskRank(c.policy.OnRiskyMutation) { + c.riskTripped = true + } +} + +// observeUncertain counts uncertain completion evaluations (continue nudges and +// semantic checks). Headless-only by construction: the completion gate never +// runs interactively. +func (c *profileController) observeUncertain() { + if c.policy == nil || c.escalated || c.policy.OnCompletionUncertain <= 0 { + return + } + c.uncertainSeen++ + if c.uncertainSeen >= c.policy.OnCompletionUncertain { + c.uncertainTrip = true + } +} + +// observeSelfCorrect watches the post-edit verification outcome. Any failing +// outcome (correcting, reported, aborted) is a signal; passed and disabled are +// not. +func (c *profileController) observeSelfCorrect(outcome Outcome) { + if c.policy == nil || c.escalated || !c.policy.OnSelfCorrectFailure { + return + } + switch outcome { + case OutcomeCorrecting, OutcomeReported, OutcomeAborted: + c.scTripped = true + } +} + +// maybeEscalate reports the escalation target exactly once, the first time any +// armed trigger has fired. Subsequent calls return false for the rest of the +// run: escalation is one-shot and never de-escalates, so no cooldown state is +// needed. +func (c *profileController) maybeEscalate() (PostureEscalation, bool) { + if c.policy == nil || c.escalated { + return PostureEscalation{}, false + } + if !(c.failureTripped || c.riskTripped || c.scTripped || c.uncertainTrip) { + return PostureEscalation{}, false + } + c.escalated = true + return *c.policy, true +} + +// riskRank orders sandbox risk levels for threshold comparison. Unknown levels +// rank lowest so a malformed threshold or classification can never trip the +// signal spuriously. +func riskRank(level sandbox.RiskLevel) int { + switch level { + case sandbox.RiskLow: + return 1 + case sandbox.RiskMedium: + return 2 + case sandbox.RiskHigh: + return 3 + case sandbox.RiskCritical: + return 4 + default: + return 0 + } +} From 12bb426e48cff92c74669ea7b67c5166d38703cf Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Sat, 18 Jul 2026 22:10:13 +0530 Subject: [PATCH 4/6] test(agent): cover posture controller, escalation act path, and risk stamping --- internal/agent/loop.go | 4 +- internal/agent/profile_controller_test.go | 248 ++++++++++++++++++++++ 2 files changed, 251 insertions(+), 1 deletion(-) create mode 100644 internal/agent/profile_controller_test.go diff --git a/internal/agent/loop.go b/internal/agent/loop.go index adec737ee..e5e58400d 100644 --- a/internal/agent/loop.go +++ b/internal/agent/loop.go @@ -1371,7 +1371,9 @@ func executeToolCall(ctx context.Context, registry *tools.Registry, call ToolCal executedRisk := sandbox.Risk{} if preflightDecision != nil { executedRisk = preflightDecision.Risk - } else { + } else if toolFound { + // Unknown-tool results fall through here with a nil tool; they carry a + // denial and keep the zero risk value. executedRisk = sandbox.Classify(sandboxRequest(call.Name, tool, args, permissionGranted, permissionMode, options)) } diff --git a/internal/agent/profile_controller_test.go b/internal/agent/profile_controller_test.go new file mode 100644 index 000000000..1066b1931 --- /dev/null +++ b/internal/agent/profile_controller_test.go @@ -0,0 +1,248 @@ +package agent + +import ( + "context" + "strings" + "testing" + + "github.com/Gitlawb/zero/internal/sandbox" + "github.com/Gitlawb/zero/internal/tools" + "github.com/Gitlawb/zero/internal/trace" + "github.com/Gitlawb/zero/internal/zeroruntime" +) + +func TestProfileControllerNilPolicyIsNoOp(t *testing.T) { + for _, controller := range []*profileController{ + newProfileController(nil), + newProfileController(&ProfilePolicy{Name: "balanced"}), // Escalate nil + } { + controller.observeToolOutcome(toolFailureOutcome{Count: 99}, ToolResult{Status: tools.StatusOK, Risk: sandbox.Risk{Level: sandbox.RiskCritical}}) + controller.observeUncertain() + controller.observeSelfCorrect(OutcomeAborted) + if _, fired := controller.maybeEscalate(); fired { + t.Fatal("nil policy must never escalate") + } + } +} + +func TestProfileControllerEscalatesOnFailureStreak(t *testing.T) { + controller := newProfileController(&ProfilePolicy{Escalate: &PostureEscalation{OnToolFailureStreak: 2, MaxTurns: 80}}) + controller.observeToolOutcome(toolFailureOutcome{Count: 1}, ToolResult{Status: tools.StatusError}) + if _, fired := controller.maybeEscalate(); fired { + t.Fatal("streak below threshold must not escalate") + } + controller.observeToolOutcome(toolFailureOutcome{Count: 2}, ToolResult{Status: tools.StatusError}) + target, fired := controller.maybeEscalate() + if !fired || target.MaxTurns != 80 { + t.Fatalf("expected escalation with the policy target, got fired=%t target=%+v", fired, target) + } +} + +func TestProfileControllerEscalatesOnRiskyMutation(t *testing.T) { + controller := newProfileController(&ProfilePolicy{Escalate: &PostureEscalation{OnRiskyMutation: sandbox.RiskHigh}}) + // Medium risk on an executed result: below threshold. + controller.observeToolOutcome(toolFailureOutcome{}, ToolResult{Status: tools.StatusOK, Risk: sandbox.Risk{Level: sandbox.RiskMedium}}) + if _, fired := controller.maybeEscalate(); fired { + t.Fatal("medium risk must not trip a high threshold") + } + // Critical risk on a DENIED result: never counts. + controller.observeToolOutcome(toolFailureOutcome{}, ToolResult{Status: tools.StatusError, DenialReason: DenialSandboxBlock, Risk: sandbox.Risk{Level: sandbox.RiskCritical}}) + if _, fired := controller.maybeEscalate(); fired { + t.Fatal("a denied result must not trip the risky-mutation signal") + } + controller.observeToolOutcome(toolFailureOutcome{}, ToolResult{Status: tools.StatusOK, Risk: sandbox.Risk{Level: sandbox.RiskCritical}}) + if _, fired := controller.maybeEscalate(); !fired { + t.Fatal("critical executed mutation must trip a high threshold") + } +} + +func TestProfileControllerEscalatesOnSelfCorrectFailure(t *testing.T) { + controller := newProfileController(&ProfilePolicy{Escalate: &PostureEscalation{OnSelfCorrectFailure: true}}) + controller.observeSelfCorrect(OutcomePassed) + controller.observeSelfCorrect(OutcomeDisabled) + if _, fired := controller.maybeEscalate(); fired { + t.Fatal("passing/disabled outcomes must not escalate") + } + controller.observeSelfCorrect(OutcomeReported) + if _, fired := controller.maybeEscalate(); !fired { + t.Fatal("a failing verification outcome must escalate") + } +} + +func TestProfileControllerEscalatesOnUncertainCompletion(t *testing.T) { + controller := newProfileController(&ProfilePolicy{Escalate: &PostureEscalation{OnCompletionUncertain: 2}}) + controller.observeUncertain() + if _, fired := controller.maybeEscalate(); fired { + t.Fatal("first uncertain evaluation must not trip a threshold of 2") + } + controller.observeUncertain() + if _, fired := controller.maybeEscalate(); !fired { + t.Fatal("second uncertain evaluation must escalate") + } +} + +func TestProfileControllerEscalatesAtMostOnce(t *testing.T) { + controller := newProfileController(&ProfilePolicy{Escalate: &PostureEscalation{OnToolFailureStreak: 1}}) + controller.observeToolOutcome(toolFailureOutcome{Count: 1}, ToolResult{Status: tools.StatusError}) + if _, fired := controller.maybeEscalate(); !fired { + t.Fatal("expected first escalation") + } + controller.observeToolOutcome(toolFailureOutcome{Count: 5}, ToolResult{Status: tools.StatusOK, Risk: sandbox.Risk{Level: sandbox.RiskCritical}}) + controller.observeUncertain() + if _, fired := controller.maybeEscalate(); fired { + t.Fatal("escalation must be one-shot per run") + } +} + +func TestOptionalEventKeysIncludePostureEscalations(t *testing.T) { + for _, key := range trace.OptionalEventKeys() { + if key == "counter:"+trace.CounterPostureEscalations { + return + } + } + t.Fatalf("posture_escalations missing from OptionalEventKeys: %v", trace.OptionalEventKeys()) +} + +// failingProfileTool always errors with the same signature so the repeated- +// failure guard builds a streak. +type failingProfileTool struct{} + +func (failingProfileTool) Name() string { return "flaky_probe" } +func (failingProfileTool) Description() string { return "always fails identically (test)" } +func (failingProfileTool) Parameters() tools.Schema { + return tools.Schema{Type: "object", AdditionalProperties: false} +} +func (failingProfileTool) Safety() tools.Safety { + return tools.Safety{SideEffect: tools.SideEffectRead, Permission: tools.PermissionAllow} +} +func (failingProfileTool) Run(context.Context, map[string]any) tools.Result { + return tools.Result{Status: tools.StatusError, Output: "probe exploded: same signature"} +} + +// TestPostureEscalationRaisesTurnCeilingMidRun proves the end-to-end act path: +// a Fast-style run with a 2-turn ceiling hits a failure streak, escalates, and +// finishes on a turn the original ceiling would have cut off, with the effort +// target applied to the post-escalation request and the counter emitted. +func TestPostureEscalationRaisesTurnCeilingMidRun(t *testing.T) { + registry := tools.NewRegistry() + registry.Register(failingProfileTool{}) + + toolTurn := []zeroruntime.StreamEvent{ + {Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "c1", ToolName: "flaky_probe"}, + {Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "c1"}, + {Type: zeroruntime.StreamEventDone}, + } + provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{ + toolTurn, + toolTurn, + { + {Type: zeroruntime.StreamEventText, Content: "recovered after escalation"}, + {Type: zeroruntime.StreamEventDone}, + }, + }} + + recorder := trace.NewRecorder("posture-session", "run-1", "fast") + result, err := Run(context.Background(), "go", provider, Options{ + Registry: registry, + MaxTurns: 2, + Trace: recorder, + Profile: &ProfilePolicy{ + Name: "fast", + Escalate: &PostureEscalation{ + MaxTurns: 4, + ReasoningEffort: "high", + OnToolFailureStreak: 2, + }, + }, + }) + if err != nil { + t.Fatal(err) + } + if result.FinalAnswer != "recovered after escalation" { + t.Fatalf("expected the run to continue past the original 2-turn ceiling, got %q (turns=%d)", result.FinalAnswer, result.Turns) + } + if len(provider.requests) != 3 { + t.Fatalf("expected 3 requests (ceiling raised from 2 to 4), got %d", len(provider.requests)) + } + if provider.requests[2].ReasoningEffort != "high" { + t.Fatalf("expected the post-escalation request to carry the target effort, got %q", provider.requests[2].ReasoningEffort) + } + tr := recorder.Finish() + if got := tr.Counter(trace.CounterPostureEscalations); got != 1 { + t.Fatalf("posture_escalations = %d, want 1", got) + } +} + +// TestPostureEscalationAbsentWithoutProfile pins the no-regression invariant at +// the loop level: the identical failing script without a profile ends at the +// original ceiling with the max-turns answer. +func TestPostureEscalationAbsentWithoutProfile(t *testing.T) { + registry := tools.NewRegistry() + registry.Register(failingProfileTool{}) + + toolTurn := []zeroruntime.StreamEvent{ + {Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "c1", ToolName: "flaky_probe"}, + {Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "c1"}, + {Type: zeroruntime.StreamEventDone}, + } + provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{toolTurn, toolTurn}} + + result, err := Run(context.Background(), "go", provider, Options{ + Registry: registry, + MaxTurns: 2, + }) + if err != nil { + t.Fatal(err) + } + if len(provider.requests) > 3 { + t.Fatalf("unprofiled run must not extend the ceiling, got %d requests", len(provider.requests)) + } + if !strings.Contains(result.FinalAnswer, "maximum number of turns") && result.FinalAnswer == "recovered after escalation" { + t.Fatalf("unprofiled run unexpectedly continued: %q", result.FinalAnswer) + } +} + +// TestExecuteToolCallClassifiesRiskWithoutSandbox verifies the executed-risk +// stamp on the unsandboxed path: a read-only tool classifies low. +func TestExecuteToolCallClassifiesRiskWithoutSandbox(t *testing.T) { + registry := tools.NewRegistry() + registry.Register(failingProfileTool{}) + + result, abortErr := executeToolCall( + context.Background(), + registry, + ToolCall{ID: "c1", Name: "flaky_probe"}, + PermissionModeAuto, + Options{}, + ) + if abortErr != nil { + t.Fatal(abortErr) + } + if result.Risk.Level == "" { + t.Fatal("executed result must carry a risk classification") + } + if riskRank(result.Risk.Level) > riskRank(sandbox.RiskMedium) { + t.Fatalf("read-only tool classified %q, want at most medium", result.Risk.Level) + } +} + +// TestDeniedToolResultCarriesZeroRisk verifies denial paths keep the zero value. +func TestDeniedToolResultCarriesZeroRisk(t *testing.T) { + registry := tools.NewRegistry() + result, abortErr := executeToolCall( + context.Background(), + registry, + ToolCall{ID: "c1", Name: "no_such_tool"}, + PermissionModeAuto, + Options{}, + ) + if abortErr != nil { + t.Fatal(abortErr) + } + if result.DenialReason == DenialNone { + t.Fatal("expected a denial for an unknown tool") + } + if result.Risk.Level != "" { + t.Fatalf("denied result must keep the zero risk value, got %q", result.Risk.Level) + } +} From fb0b0e2a80e4ce33518b964c1d7c0a3952d36183 Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Sat, 18 Jul 2026 22:11:21 +0530 Subject: [PATCH 5/6] test(agent): pin zero risk on not-executed results --- internal/agent/profile_controller_test.go | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/internal/agent/profile_controller_test.go b/internal/agent/profile_controller_test.go index 1066b1931..ffbf4591b 100644 --- a/internal/agent/profile_controller_test.go +++ b/internal/agent/profile_controller_test.go @@ -226,8 +226,10 @@ func TestExecuteToolCallClassifiesRiskWithoutSandbox(t *testing.T) { } } -// TestDeniedToolResultCarriesZeroRisk verifies denial paths keep the zero value. -func TestDeniedToolResultCarriesZeroRisk(t *testing.T) { +// TestUnknownToolResultCarriesZeroRisk verifies a call that never executed a +// tool (unknown name, nil tool) keeps the zero risk value instead of panicking +// or inventing a classification. +func TestUnknownToolResultCarriesZeroRisk(t *testing.T) { registry := tools.NewRegistry() result, abortErr := executeToolCall( context.Background(), @@ -239,10 +241,10 @@ func TestDeniedToolResultCarriesZeroRisk(t *testing.T) { if abortErr != nil { t.Fatal(abortErr) } - if result.DenialReason == DenialNone { - t.Fatal("expected a denial for an unknown tool") + if result.Status == tools.StatusOK { + t.Fatal("expected an error result for an unknown tool") } if result.Risk.Level != "" { - t.Fatalf("denied result must keep the zero risk value, got %q", result.Risk.Level) + t.Fatalf("not-executed result must keep the zero risk value, got %q", result.Risk.Level) } } From e8afb7f3be700e2f5296c77677c2c3897385e249 Mon Sep 17 00:00:00 2001 From: Vasanthdev2004 Date: Sat, 18 Jul 2026 22:21:31 +0530 Subject: [PATCH 6/6] fix(agent): wire uncertain-path escalation and validate risk thresholds --- internal/agent/loop.go | 38 ++++++++---- internal/agent/profile_controller.go | 14 +++-- internal/agent/profile_controller_test.go | 72 +++++++++++++++++++---- internal/trace/trace_test.go | 9 +++ 4 files changed, 105 insertions(+), 28 deletions(-) diff --git a/internal/agent/loop.go b/internal/agent/loop.go index e5e58400d..8e05b84cd 100644 --- a/internal/agent/loop.go +++ b/internal/agent/loop.go @@ -189,6 +189,26 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options) // existing caller) makes every observe/decide call a no-op, keeping the // loop byte-identical for unprofiled runs. posture := newProfileController(options.Profile) + // applyPosture applies at most one posture escalation when an armed trigger + // has fired: raise the hoisted turn ceiling, restore effort and the + // completion gate, stamp the counter. Shared by the end-of-turn tail and + // the completion-gate uncertain path (which continues the loop before the + // tail runs). No-op forever after the first escalation and for unprofiled + // runs. + applyPosture := func() { + if target, fired := posture.maybeEscalate(); fired { + if target.MaxTurns > maxTurns { + maxTurns = target.MaxTurns + } + if target.ReasoningEffort != "" { + options.ReasoningEffort = target.ReasoningEffort + } + if target.RestoreCompletionGate { + options.RequireCompletionSignal = true + } + options.Trace.Counter(trace.CounterPostureEscalations, 1) + } + } // Background post-edit diagnostics: files changed by mutating tools are // checked off the tool-call critical path and any errors are appended as a @@ -558,6 +578,11 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options) result.Messages = copyMessages(messages) return result, nil case CompletionUncertain: + // Observe the uncertainty signal and apply any escalation NOW: + // this branch continues the turn loop before the end-of-turn + // act point, so deferring would skip escalation entirely. + posture.observeUncertain() + applyPosture() switch evaluation.Action { case completionActionContinue: options.Trace.Counter(trace.CounterCompletionNudges, 1) @@ -830,18 +855,7 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options) // only per-turn-read policy (the hoisted turn ceiling, reasoning effort, // completion gate); never messages, model, or session. No-op forever // after the first escalation and for every unprofiled run. - if target, fired := posture.maybeEscalate(); fired { - if target.MaxTurns > maxTurns { - maxTurns = target.MaxTurns - } - if target.ReasoningEffort != "" { - options.ReasoningEffort = target.ReasoningEffort - } - if target.RestoreCompletionGate { - options.RequireCompletionSignal = true - } - options.Trace.Counter(trace.CounterPostureEscalations, 1) - } + applyPosture() } if ctx.Err() != nil { diff --git a/internal/agent/profile_controller.go b/internal/agent/profile_controller.go index d7bd56dff..416c60f0d 100644 --- a/internal/agent/profile_controller.go +++ b/internal/agent/profile_controller.go @@ -2,7 +2,6 @@ package agent import ( "github.com/Gitlawb/zero/internal/sandbox" - "github.com/Gitlawb/zero/internal/tools" ) // profileController observes per-turn run signals and decides at most one @@ -48,8 +47,12 @@ func (c *profileController) observeToolOutcome(outcome toolFailureOutcome, resul if c.policy.OnToolFailureStreak > 0 && outcome.Count >= c.policy.OnToolFailureStreak { c.failureTripped = true } - if c.policy.OnRiskyMutation != "" && result.Status == tools.StatusOK && - riskRank(result.Risk.Level) >= riskRank(c.policy.OnRiskyMutation) { + if threshold := riskRank(c.policy.OnRiskyMutation); threshold > 0 && + result.DenialReason == DenialNone && + riskRank(result.Risk.Level) >= threshold { + // The call EXECUTED (was not denied): partial failures count too, since + // the mutation ran. An unrecognized threshold ranks 0 and disables the + // signal instead of matching everything. c.riskTripped = true } } @@ -96,8 +99,9 @@ func (c *profileController) maybeEscalate() (PostureEscalation, bool) { } // riskRank orders sandbox risk levels for threshold comparison. Unknown levels -// rank lowest so a malformed threshold or classification can never trip the -// signal spuriously. +// rank 0: an unrecognized RESULT level can never meet a valid threshold, and an +// unrecognized THRESHOLD is rejected before comparison (rank 0 disables the +// signal) so a typo in a profile can never make every result match. func riskRank(level sandbox.RiskLevel) int { switch level { case sandbox.RiskLow: diff --git a/internal/agent/profile_controller_test.go b/internal/agent/profile_controller_test.go index ffbf4591b..3783ece05 100644 --- a/internal/agent/profile_controller_test.go +++ b/internal/agent/profile_controller_test.go @@ -45,14 +45,24 @@ func TestProfileControllerEscalatesOnRiskyMutation(t *testing.T) { if _, fired := controller.maybeEscalate(); fired { t.Fatal("medium risk must not trip a high threshold") } - // Critical risk on a DENIED result: never counts. + // Critical risk on a DENIED result: never counts (the mutation never ran). controller.observeToolOutcome(toolFailureOutcome{}, ToolResult{Status: tools.StatusError, DenialReason: DenialSandboxBlock, Risk: sandbox.Risk{Level: sandbox.RiskCritical}}) if _, fired := controller.maybeEscalate(); fired { t.Fatal("a denied result must not trip the risky-mutation signal") } - controller.observeToolOutcome(toolFailureOutcome{}, ToolResult{Status: tools.StatusOK, Risk: sandbox.Risk{Level: sandbox.RiskCritical}}) + // Critical risk on an executed PARTIAL FAILURE (error status, no denial): + // the mutation ran, so it counts. + controller.observeToolOutcome(toolFailureOutcome{}, ToolResult{Status: tools.StatusError, Risk: sandbox.Risk{Level: sandbox.RiskCritical}}) if _, fired := controller.maybeEscalate(); !fired { - t.Fatal("critical executed mutation must trip a high threshold") + t.Fatal("an executed partial failure at critical risk must trip a high threshold") + } +} + +func TestProfileControllerIgnoresInvalidRiskThreshold(t *testing.T) { + controller := newProfileController(&ProfilePolicy{Escalate: &PostureEscalation{OnRiskyMutation: sandbox.RiskLevel("hgih")}}) + controller.observeToolOutcome(toolFailureOutcome{}, ToolResult{Status: tools.StatusOK, Risk: sandbox.Risk{Level: sandbox.RiskCritical}}) + if _, fired := controller.maybeEscalate(); fired { + t.Fatal("an unrecognized threshold must disable the signal, not match every result") } } @@ -94,13 +104,53 @@ func TestProfileControllerEscalatesAtMostOnce(t *testing.T) { } } -func TestOptionalEventKeysIncludePostureEscalations(t *testing.T) { - for _, key := range trace.OptionalEventKeys() { - if key == "counter:"+trace.CounterPostureEscalations { - return - } +// TestPostureEscalationOnUncertainCompletion proves the uncertain-path act +// point: the completion gate's continue branch escapes the end-of-turn tail, +// so escalation must apply before the continue. A cue turn under the gate +// counts as uncertain, escalates, and the very next request carries the target +// effort. +func TestPostureEscalationOnUncertainCompletion(t *testing.T) { + provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{ + { + {Type: zeroruntime.StreamEventText, Content: "Let me read the file:"}, + {Type: zeroruntime.StreamEventDone}, + }, + { + {Type: zeroruntime.StreamEventText, Content: "Done. All set."}, + {Type: zeroruntime.StreamEventDone}, + }, + }} + + recorder := trace.NewRecorder("posture-session", "run-2", "fast") + result, err := Run(context.Background(), "go", provider, Options{ + Registry: tools.NewRegistry(), + MaxTurns: 10, + RequireCompletionSignal: true, + Trace: recorder, + Profile: &ProfilePolicy{ + Name: "fast", + Escalate: &PostureEscalation{ + ReasoningEffort: "high", + OnCompletionUncertain: 1, + }, + }, + }) + if err != nil { + t.Fatal(err) + } + if result.FinalAnswer != "Done. All set." { + t.Fatalf("expected the completed answer, got %q", result.FinalAnswer) + } + if len(provider.requests) != 2 { + t.Fatalf("expected 2 requests, got %d", len(provider.requests)) + } + if provider.requests[1].ReasoningEffort != "high" { + t.Fatalf("expected the post-escalation request to carry the target effort, got %q", provider.requests[1].ReasoningEffort) + } + tr := recorder.Finish() + if got := tr.Counter(trace.CounterPostureEscalations); got != 1 { + t.Fatalf("posture_escalations = %d, want 1", got) } - t.Fatalf("posture_escalations missing from OptionalEventKeys: %v", trace.OptionalEventKeys()) } // failingProfileTool always errors with the same signature so the repeated- @@ -197,8 +247,8 @@ func TestPostureEscalationAbsentWithoutProfile(t *testing.T) { if len(provider.requests) > 3 { t.Fatalf("unprofiled run must not extend the ceiling, got %d requests", len(provider.requests)) } - if !strings.Contains(result.FinalAnswer, "maximum number of turns") && result.FinalAnswer == "recovered after escalation" { - t.Fatalf("unprofiled run unexpectedly continued: %q", result.FinalAnswer) + if !strings.Contains(result.FinalAnswer, "maximum number of turns") { + t.Fatalf("unprofiled run must end with the max-turns answer, got %q", result.FinalAnswer) } } diff --git a/internal/trace/trace_test.go b/internal/trace/trace_test.go index 19db69e82..b5920d672 100644 --- a/internal/trace/trace_test.go +++ b/internal/trace/trace_test.go @@ -577,3 +577,12 @@ func TestCounterPrecisionRoundTrip(t *testing.T) { t.Fatalf("counter round-trip = %d, want %d (float64 precision loss)", got, big) } } + +func TestOptionalEventKeysIncludePostureEscalations(t *testing.T) { + for _, key := range OptionalEventKeys() { + if key == "counter:"+CounterPostureEscalations { + return + } + } + t.Fatalf("posture_escalations missing from OptionalEventKeys: %v", OptionalEventKeys()) +}