Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
60 commits
Select commit Hold shift + click to select a range
6a4eff2
fix(sandbox): guard the Windows write-jail invariant and disclose the…
Vasanthdev2004 Aug 9, 2026
93f9505
fix(sandbox): only warn about the DenyRead token trade on a Windows host
Vasanthdev2004 Aug 9, 2026
c4905d2
fix(sandbox): stop advertising a sandbox override that does not exist
Vasanthdev2004 Aug 12, 2026
3bfd898
test(sandbox): pin the remedies the unelevated ACL failure offers
Vasanthdev2004 Aug 12, 2026
915e1df
test(sandbox): fail rather than skip when the DenyRead token loses th…
Vasanthdev2004 Aug 19, 2026
55621d2
fix(sandbox): disclose the DenyRead write-jail trade on the execution…
Vasanthdev2004 Aug 20, 2026
5c64cef
fix(sandbox,tools): make the DenyRead disclosure reach a human, and p…
Vasanthdev2004 Aug 21, 2026
f781481
fix(sandbox,plugins,hooks): disclose the write-jail trade where it ac…
Vasanthdev2004 Aug 22, 2026
4e2231e
fix(plugins,hooks): carry the disclosure through every post-launch ou…
Vasanthdev2004 Aug 22, 2026
0b646ad
test(sandbox): pin the notice projection itself, not just its consumers
Vasanthdev2004 Aug 22, 2026
081aabd
fix(agent): carry one canonical representation of a tool result acros…
Vasanthdev2004 Aug 24, 2026
cd76c67
fix(acp,cli): carry the enforcement notice to the consumers the proje…
Vasanthdev2004 Aug 26, 2026
76e5e35
fix(execution): one launch-state decision, and disclose it on the MCP…
Vasanthdev2004 Aug 27, 2026
4f8aa57
fix(mcp,hooks): carry the enforcement fact to the durable consumers
Vasanthdev2004 Aug 27, 2026
1ee7aa7
fix(mcp): collect startup disclosures in the serial phase, and keep t…
Vasanthdev2004 Aug 27, 2026
85526b4
fix(cli): report MCP startup disclosures from headless exec
Vasanthdev2004 Aug 27, 2026
22c9194
fix(execution): record launch state instead of inferring it from the …
Vasanthdev2004 Aug 27, 2026
427df5f
fix(mcp): carry the launch disclosure through an initialize failure
Vasanthdev2004 Aug 27, 2026
f62ae77
fix(tools): derive command notices from applied execution state, not …
Vasanthdev2004 Aug 27, 2026
e0e8db3
fix(sandbox): gate the DenyRead diagnostic on the resolved plan
Vasanthdev2004 Aug 27, 2026
f889b7e
fix(tui): show the enforcement disclosure in cards and after resume
Vasanthdev2004 Aug 27, 2026
b60359e
fix(tools): measure the model output the enforcement notices are part of
Vasanthdev2004 Aug 28, 2026
e2ac98b
test(sandbox): fail when the current-user SID prerequisite cannot be …
Vasanthdev2004 Aug 28, 2026
6bf5a86
fix(mcp): keep the launch disclosure when registration times out
Vasanthdev2004 Aug 28, 2026
b44d56f
fix(tools): carry bash's real launch state into the shared outcome
Vasanthdev2004 Aug 29, 2026
fc06cc6
fix(mcp): synchronize the timeout with a start that is still completing
Vasanthdev2004 Aug 29, 2026
98fdb3e
fix(tui): let the typed notice own the card's disclosure
Vasanthdev2004 Aug 31, 2026
60a1452
fix(mcp): keep the launch fact alive past the registration bound
Vasanthdev2004 Aug 31, 2026
b619016
fix(mcp,tui): deliver the launch fact once, and persist one payload s…
Vasanthdev2004 Sep 2, 2026
591874f
fix(mcp,cli): hand late launch disclosures to the output owner, not a…
Vasanthdev2004 Sep 2, 2026
47d6ab7
fix(execution,sandbox): confirm the restricted child launched, not th…
Vasanthdev2004 Sep 2, 2026
0ae48e4
fix(execution,sandbox,mcp,tools): one launch fact, applied by every l…
Vasanthdev2004 Sep 3, 2026
2176c98
fix(cli): give the late MCP disclosure and startup output one owner o…
Vasanthdev2004 Sep 3, 2026
b8d344d
fix(agent): deliver a successful beforeTool hook's output to the model
Vasanthdev2004 Sep 3, 2026
600bbc4
fix(agent): drop the deprecated runtime.GOROOT fallback from the hook…
Vasanthdev2004 Sep 3, 2026
4ccdab8
fix(agent,hooks): deliver only the beforeTool enforcement notice, on …
Vasanthdev2004 Sep 4, 2026
b69ff67
docs(hooks): state the property the notice accumulation actually holds
Vasanthdev2004 Sep 4, 2026
03ee13e
fix(execution): observe the adapter's launch while the process is sti…
Vasanthdev2004 Sep 4, 2026
b16971f
fix(mcp): gate the initialization-error disclosure on the confirmed c…
Vasanthdev2004 Sep 4, 2026
886567f
docs(agent): record why the notice delivery path needs no rebudget
Vasanthdev2004 Sep 4, 2026
2f8dc76
fix(execution,mcp,sandbox): settle the launch decision before caching…
Vasanthdev2004 Sep 5, 2026
67de11f
fix(mcp): gate the success-path disclosure on the launch decision too
Vasanthdev2004 Sep 5, 2026
105e955
test(mcp): pin the StartupNotices launch gate at the carrier
Vasanthdev2004 Sep 5, 2026
b11a8a6
test(sandbox): fail the deny_read disclosure tests when the producer …
Vasanthdev2004 Sep 7, 2026
3b7c741
fix(agent): keep a beforeTool enforcement notice typed through the re…
Vasanthdev2004 Sep 7, 2026
6bce7d3
fix(agent,hooks): give afterTool the same typed-notice contract as be…
Vasanthdev2004 Sep 7, 2026
8535e4a
fix(sandbox): unwind the launch report when the suspended child canno…
Vasanthdev2004 Sep 8, 2026
33f62e7
fix(sandbox,execution): let a failed resume revoke the launch it publ…
Vasanthdev2004 Sep 9, 2026
f984082
test(sandbox): pin the retraction, and the fallback when it cannot be…
Vasanthdev2004 Sep 9, 2026
9e09d0a
Merge remote-tracking branch 'origin/main' into fix/windows-restricte…
Vasanthdev2004 Sep 12, 2026
eb3bbb2
fix(sandbox): drop the Windows denyRead trade notice now that denyRea…
Vasanthdev2004 Sep 12, 2026
f1ff15e
fix(sandbox): give the suspended child an owner that outlives a kille…
Vasanthdev2004 Sep 13, 2026
7dfdbcb
Merge remote-tracking branch 'origin/main' into HEAD
Vasanthdev2004 Sep 13, 2026
9d8059c
Merge origin/main into fix/windows-restricted-sid-invariant
Vasanthdev2004 Sep 15, 2026
937631a
Merge remote-tracking branch 'origin/main' into fix/windows-restricte…
Vasanthdev2004 Sep 19, 2026
c1b4e27
fix(sandbox): hold the child kill job for the process lifetime, and t…
Vasanthdev2004 Sep 19, 2026
c022e6a
test(sandbox): pin the ownership order at the runner's earliest refusal
Vasanthdev2004 Sep 19, 2026
daf9b8c
fix(acp): persist tool results through the shared session contract
Vasanthdev2004 Sep 21, 2026
b868a92
fix(hooks,mcp): one owner for a vetoing hook's notice, and close the …
Vasanthdev2004 Sep 24, 2026
066a3a2
test(mcp): bound the stream Wait in the Close tests
Vasanthdev2004 Sep 24, 2026
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
65 changes: 45 additions & 20 deletions internal/acp/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -862,16 +862,25 @@ func toolCallEvent(call agent.ToolCall) sessions.AppendEventInput {
}
}

// toolResultEvent persists a tool result through the ONE session contract every
// writer shares, agent.ToolResultSessionPayload.
//
// This used to spell its own payload and wrote result.Output. After the
// output/notice split that field is the UNDECORATED text, and nothing else in
// the payload carried the enforcement notices, so the disclosure was dropped at
// the moment of persisting. The live tool_call_update for the same result had
// shown it (toolResultContent reads ModelOutput), and then session/load
// replayed the call with the sandbox's narrowing gone. Fixing only the live
// translation could not reach this: the text was already lost on disk.
//
// The session store is shared with the TUI and headless exec, so the same
// payload is also what lets a TUI resume of an ACP-written session draw the
// notice as card furniture, from the typed field and the undecorated body.
// Reported by @jatmn.
func toolResultEvent(result agent.ToolResult) sessions.AppendEventInput {
return sessions.AppendEventInput{
Type: sessions.EventToolResult,
Payload: map[string]any{
"toolCallId": result.ToolCallID,
"name": result.Name,
"status": result.Status,
"output": result.Output,
"changedFiles": append([]string(nil), result.ChangedFiles...),
},
Type: sessions.EventToolResult,
Payload: agent.ToolResultSessionPayload(result),
}
}

Expand Down Expand Up @@ -1031,13 +1040,14 @@ func replayToolUpdate(event sessions.Event) *ToolCallUpdate {
return nil
}
var payload struct {
Name string `json:"name"`
ToolCallID string `json:"toolCallId"`
ID string `json:"id"`
Arguments string `json:"arguments"`
Status string `json:"status"`
Output string `json:"output"`
ChangedFiles []string `json:"changedFiles"`
Name string `json:"name"`
ToolCallID string `json:"toolCallId"`
ID string `json:"id"`
Arguments string `json:"arguments"`
Status string `json:"status"`
Output string `json:"output"`
ChangedFiles []string `json:"changedFiles"`
EnforcementNotices []string `json:"enforcementNotices"`
}
if json.Unmarshal(raw, &payload) != nil {
return nil
Expand All @@ -1057,12 +1067,27 @@ func replayToolUpdate(event sessions.Event) *ToolCallUpdate {
if status == "" {
status = tools.StatusOK
}
// THE DISCLOSURE IS RESTORED AS WHAT IT WAS: typed notices beside undecorated
// text, so toolCallResult composes it the way it did live and the client
// sees it exactly once. The stored output already has the notices composed
// in (that is the shared contract, because output is the provider-facing
// text), so handing it to ModelOutput together with the typed field would
// draw the disclosure twice, and dropping the typed field would make this
// reader depend on every writer having decorated the text. Taking the
// notices back off the front covers both: a record whose output was never
// decorated comes through unchanged and still gets its notice.
//
// displayPreview is deliberately not read. It is the card body for a
// terminal, and the ACP wire has no card: the live update sends the model
// text, so a replay that sent the preview instead would not match it.
notices := append([]string(nil), payload.EnforcementNotices...)
upd := toolCallResult(agent.ToolResult{
ToolCallID: id,
Name: payload.Name,
Status: status,
Output: payload.Output,
ChangedFiles: append([]string(nil), payload.ChangedFiles...),
ToolCallID: id,
Name: payload.Name,
Status: status,
Output: tools.WithoutEnforcementNotices(payload.Output, notices),
EnforcementNotices: notices,
ChangedFiles: append([]string(nil), payload.ChangedFiles...),
})
return &upd
}
Expand Down
195 changes: 195 additions & 0 deletions internal/acp/enforcement_notice_persist_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
package acp

import (
"context"
"encoding/json"
"reflect"
"strings"
"testing"
"time"

"github.com/Gitlawb/zero/internal/agent"
"github.com/Gitlawb/zero/internal/sessions"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/zeroruntime"
)

const persistedNotice = "least-privilege notice: read access was narrowed"

func mustRawPayload(t *testing.T, payload map[string]any) json.RawMessage {
t.Helper()
encoded, err := json.Marshal(payload)
if err != nil {
t.Fatal(err)
}
return encoded
}

func toolUpdateText(update ToolCallUpdate) string {
var text strings.Builder
for _, part := range update.Content {
if part.Content != nil {
text.WriteString(part.Content.Text)
}
}
return text.String()
}

// nextToolResultUpdate returns the next tool_call_update, skipping the
// tool_call that announces it.
func nextToolResultUpdate(t *testing.T, ctx context.Context, updates <-chan ToolCallUpdate, stage string) ToolCallUpdate {
t.Helper()
for {
select {
case update := <-updates:
if update.SessionUpdate == UpdateToolCallUpdate {
return update
}
case <-ctx.Done():
t.Fatalf("%s: the tool result update never arrived", stage)
}
}
}

// A DISCLOSURE SHOWN LIVE HAS TO SURVIVE BEING WRITTEN DOWN.
//
// enforcement_notice_test.go pins the live translation: toolResultContent reads
// ModelOutput, so a connected ACP client sees the sandbox's narrowing. That was
// the only half fixed. The turn also persists each result, and the persisted
// payload was spelled by hand in this package with result.Output, which after
// the output/notice split is the undecorated text, and with no notices field at
// all. The disclosure was gone from disk, so session/load replayed a sandboxed
// command as if nothing had constrained it.
//
// This runs a real turn, so the result reaches the store through the same
// OnToolResult callback production uses, then loads the session in a fresh
// agent and reads the replayed update. It fails if only translate.go is fixed,
// which is the state it was written against. Reported by @jatmn.
func TestACPReloadedToolResultCarriesTheEnforcementNoticeOnce(t *testing.T) {
for _, tc := range []struct {
name string
output string
}{
{name: "a command with output", output: "the command output"},
// An enforced command that printed nothing still has a disclosure, and
// an empty body is where a reader that falls back to the decorated text
// draws it twice.
{name: "a command that printed nothing", output: ""},
} {
t.Run(tc.name, func(t *testing.T) {
deps := testDeps(t)
deps.RunAgent = func(_ context.Context, _ string, _ zeroruntime.Provider, opts agent.Options) (agent.Result, error) {
call := agent.ToolCall{ID: "call-sandboxed", Name: "bash", Arguments: `{"command":"ls"}`}
opts.OnToolCall(call)
opts.OnToolResult(agent.ToolResult{
ToolCallID: call.ID,
Name: call.Name,
Status: tools.StatusOK,
Output: tc.output,
EnforcementNotices: []string{persistedNotice},
})
return agent.Result{FinalAnswer: "done"}, nil
}
workspace := t.TempDir()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()

live := newHarness(t, deps)
var created NewSessionResult
if err := live.client.Call(ctx, MethodSessionNew, NewSessionParams{Cwd: workspace, McpServers: []McpServer{}}, &created); err != nil {
t.Fatalf("session/new: %v", err)
}
if err := live.client.Call(ctx, MethodSessionPrompt, PromptParams{SessionID: created.SessionID, Prompt: []ContentBlock{TextBlock("list the files")}}, &PromptResult{}); err != nil {
t.Fatalf("session/prompt: %v", err)
}
liveText := toolUpdateText(nextToolResultUpdate(t, ctx, live.tools, "live"))
if count := strings.Count(liveText, persistedNotice); count != 1 {
t.Fatalf("premise: the live update carries the notice %d times, want 1:\n%s", count, liveText)
}
live.stop()

loader := newHarness(t, deps)
defer loader.stop()
if err := loader.client.Call(ctx, MethodSessionLoad, LoadSessionParams{SessionID: created.SessionID, Cwd: workspace, McpServers: []McpServer{}}, &LoadSessionResult{}); err != nil {
t.Fatalf("session/load: %v", err)
}
replayed := toolUpdateText(nextToolResultUpdate(t, ctx, loader.tools, "replay"))
if count := strings.Count(replayed, persistedNotice); count != 1 {
t.Errorf("the replayed result carries the notice %d times, want exactly 1:\n%s", count, replayed)
}
if replayed != liveText {
t.Errorf("the replayed result does not match what the client saw live\nlive: %q\nreplayed: %q", liveText, replayed)
}
})
}
}

// THE ACP WRITER IS THE SHARED CONTRACT, NOT A COPY OF IT. The session store is
// the one the TUI and headless exec also write to and resume from, so a payload
// spelled here by hand is a third representation waiting to drift. It already
// had: it was the only writer with no notices and no undecorated body.
func TestToolResultEventIsTheSharedSessionPayload(t *testing.T) {
result := agent.ToolResult{
ToolCallID: "call-1",
Name: "bash",
Status: tools.StatusOK,
Output: "the command output",
EnforcementNotices: []string{persistedNotice},
ChangedFiles: []string{"a.go"},
}
event := toolResultEvent(result)
if event.Type != sessions.EventToolResult {
t.Fatalf("event type = %q", event.Type)
}
if want := agent.ToolResultSessionPayload(result); !reflect.DeepEqual(event.Payload, want) {
t.Fatalf("the ACP payload is not the shared one\n got: %#v\nwant: %#v", event.Payload, want)
}
payload, ok := event.Payload.(map[string]any)
if !ok {
t.Fatalf("payload is %T", event.Payload)
}
if notices, _ := payload["enforcementNotices"].([]string); len(notices) != 1 || notices[0] != persistedNotice {
t.Errorf("typed notices were not persisted: %#v", payload["enforcementNotices"])
}
if body, present := payload["displayPreview"]; !present || body != "the command output" {
t.Errorf("the undecorated body was not persisted: %#v (present=%v)", body, present)
}
}

// A record can reach the reader in either shape, and both have to come out as
// one disclosure: output with the notices already composed in, which is what
// every current writer stores, and output left undecorated beside the typed
// field, which a reader must not answer by dropping the notice.
func TestReplayedToolResultComposesTheNoticeOnceForEitherStoredShape(t *testing.T) {
for _, tc := range []struct {
name string
output string
want string
}{
{name: "output stored decorated", output: persistedNotice + "\n\nthe command output", want: persistedNotice + "\n\nthe command output"},
{name: "output stored undecorated", output: "the command output", want: persistedNotice + "\n\nthe command output"},
{name: "decorated and otherwise empty", output: persistedNotice, want: persistedNotice},
{name: "undecorated and empty", output: "", want: persistedNotice},
} {
t.Run(tc.name, func(t *testing.T) {
update := replayToolUpdate(sessions.Event{Type: sessions.EventToolResult, Payload: mustRawPayload(t, map[string]any{
"toolCallId": "call-1", "name": "bash", "status": "ok",
"output": tc.output, "enforcementNotices": []string{persistedNotice},
})})
if update == nil {
t.Fatal("the stored result was not replayed")
}
if got := toolUpdateText(*update); got != tc.want {
t.Errorf("replayed text = %q, want %q", got, tc.want)
}
})
}

// And a stored result with no notices is replayed as it was written.
plain := replayToolUpdate(sessions.Event{Type: sessions.EventToolResult, Payload: mustRawPayload(t, map[string]any{
"toolCallId": "call-2", "name": "bash", "status": "ok", "output": "plain output",
})})
if plain == nil || toolUpdateText(*plain) != "plain output" {
t.Errorf("an ordinary stored result changed on replay: %+v", plain)
}
}
65 changes: 65 additions & 0 deletions internal/acp/enforcement_notice_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
package acp

import (
"strings"
"testing"

"github.com/Gitlawb/zero/internal/agent"
"github.com/Gitlawb/zero/internal/tools"
)

// AN ACP CLIENT MUST SEE THE DISCLOSURE THE TUI SEES.
//
// agent.ToolResult stores the UNDECORATED model text alongside the typed
// enforcement notices; ModelOutput is what composes them. Reading .Output
// directly compiles and looks right, and silently drops the notice for every
// ACP client, which is the one surface with no other way to learn the sandbox
// narrowed what the command could do.
func TestToolResultContentCarriesTheEnforcementNotice(t *testing.T) {
const notice = "least-privilege notice: read access was narrowed"
result := agent.ToolResult{
Name: "bash",
Status: tools.StatusOK,
Output: "the command output",
EnforcementNotices: []string{notice},
}

content := toolResultContent(result)
if len(content) == 0 {
t.Fatal("no content produced for a successful tool result")
}
var text strings.Builder
for _, part := range content {
if part.Content != nil {
text.WriteString(part.Content.Text)
}
}
got := text.String()

if count := strings.Count(got, notice); count != 1 {
t.Errorf("the notice appears %d times, want exactly 1:\n%s", count, got)
}
if !strings.Contains(got, "the command output") {
t.Errorf("the underlying output was lost:\n%s", got)
}
}

// And a result with no notice is unchanged, so the accessor is not adding
// anything to ordinary output.
func TestToolResultContentLeavesAnOrdinaryResultAlone(t *testing.T) {
result := agent.ToolResult{
Name: "bash",
Status: tools.StatusOK,
Output: "plain output",
}
content := toolResultContent(result)
if len(content) == 0 {
t.Fatal("no content produced")
}
if content[0].Content == nil {
t.Fatal("content block missing")
}
if got := content[0].Content.Text; got != "plain output" {
t.Errorf("ordinary output = %q, want it untouched", got)
}
}
6 changes: 5 additions & 1 deletion internal/acp/translate.go
Original file line number Diff line number Diff line change
Expand Up @@ -248,7 +248,11 @@ func toolCallResult(result agent.ToolResult) ToolCallUpdate {

func toolResultContent(result agent.ToolResult) []ToolCallContent {
content := make([]ToolCallContent, 0, 1+len(result.FileDiffs))
text := strings.TrimRight(result.Output, "\n")
// ModelOutput, not the raw field. agent.ToolResult stores the undecorated
// model text alongside the typed enforcement notices, and the accessor is
// what composes the two; reading Output directly sends an ACP client the
// output with the disclosure missing.
text := strings.TrimRight(result.ModelOutput(), "\n")
if text == "" {
text = result.Display.Summary
}
Expand Down
Loading
Loading