Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
49 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
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: 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 @@ -238,7 +238,11 @@ func toolCallResult(result agent.ToolResult) ToolCallUpdate {
}

func toolResultContent(result agent.ToolResult) []ToolCallContent {
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
160 changes: 160 additions & 0 deletions internal/agent/after_tool_notice_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
package agent

import (
"context"
"os/exec"
"path/filepath"
"runtime"
"strings"
"testing"

"github.com/Gitlawb/zero/internal/execution"
"github.com/Gitlawb/zero/internal/hooks"
"github.com/Gitlawb/zero/internal/tools"
)

// The tool's own disclosure and the afterTool hook's are the SAME STRING, which
// is the real case: both come from the fixed Windows deny_read warning, so a hook
// running under the same token shape as the tool it follows reports exactly what
// the tool reported.
const sharedEnforcementNotice = "least-privilege notice"

const afterToolChatter = "vet-found-nothing"

// sharedNoticeHookPreparer runs a hook that prints ordinary output and carries
// the same enforcement notice the tool carries.
type sharedNoticeHookPreparer struct{}

func (sharedNoticeHookPreparer) PrepareExecution(_ context.Context, _ execution.Request) (execution.PreparedCommand, error) {
var command *exec.Cmd
if runtime.GOOS == "windows" {
command = exec.Command("cmd.exe", "/c", "echo "+afterToolChatter)
} else {
command = exec.Command("/bin/sh", "-c", "echo "+afterToolChatter)
}
return execution.PreparedCommand{
Command: command,
Enforcement: execution.Enforcement{Notices: []string{sharedEnforcementNotice}},
}, nil
}

func afterToolNoticeDispatcher(t *testing.T) *hooks.Dispatcher {
t.Helper()
audit, err := hooks.NewAuditStore(hooks.AuditStoreOptions{AuditPath: filepath.Join(t.TempDir(), "audit.jsonl")})
if err != nil {
t.Fatalf("NewAuditStore: %v", err)
}
return hooks.NewDispatcher(hooks.DispatcherOptions{
Config: hooks.Config{
Enabled: true,
Hooks: []hooks.Definition{
{ID: "zero.after-tool", Event: hooks.EventAfterTool, Matcher: "notice_projection", Command: "vet", Enabled: true},
},
},
Audit: audit,
Cwd: t.TempDir(),
Execution: execution.NewRunner(sharedNoticeHookPreparer{}),
})
}

// ONE DISCLOSURE, ONE DELIVERY, FROM EITHER HOOK PHASE.
//
// beforeTool was moved onto the typed EnforcementNotices slice and afterTool was
// left folding its notices into the prose feedback. Both halves then wrote the
// same fact: the typed slice, which every surface composes through ModelOutput
// and HumanDisplay, and the "Hook output:" block appended to the body. Since the
// two carry the identical fixed string, the model saw the disclosure twice and a
// bash or exec card showed it in the amber furniture and again in the body.
//
// Half a symmetry is its own defect, and this is the composition that catches it:
// a tool that reports a notice, followed by an afterTool hook that reports the
// same one.
func TestAnAfterToolNoticeIsDeliveredOnce(t *testing.T) {
registry := tools.NewRegistry()
registry.Register(noticeProjectionTool{})

result, err := executeToolCall(context.Background(), registry, ToolCall{
ID: "call-1", Name: "notice_projection", Arguments: `{}`,
}, PermissionModeAuto, Options{Cwd: t.TempDir(), Hooks: afterToolNoticeDispatcher(t)})
if err != nil {
t.Fatalf("executeToolCall: %v", err)
}

// SETUP: the hook really ran, or the silence below is the silence of a hook
// that never executed.
if !strings.Contains(result.ModelOutput(), afterToolChatter) {
t.Fatalf("SETUP INVALID: the afterTool hook's own output never arrived, so nothing here is under test:\n%s", result.ModelOutput())
}

if got := strings.Count(result.ModelOutput(), sharedEnforcementNotice); got != 1 {
t.Errorf("the disclosure reached the model %d times, want once:\n%s", got, result.ModelOutput())
}
if got := strings.Count(strings.Join(result.EnforcementNotices, "\n"), sharedEnforcementNotice); got != 1 {
t.Errorf("the typed slice carries the disclosure %d times, want once: %v", got, result.EnforcementNotices)
}
// The body must not carry it at all: the surfaces draw it from the slice, so a
// copy in the body is what renders it twice.
if strings.Contains(result.BaseModelOutput(), sharedEnforcementNotice) {
t.Errorf("the disclosure is in the result body as well as the typed slice:\n%s", result.BaseModelOutput())
}
if strings.Count(result.HumanDisplay().Summary, sharedEnforcementNotice) != 1 {
t.Errorf("the card summary shows the disclosure %d times, want once:\n%s",
strings.Count(result.HumanDisplay().Summary, sharedEnforcementNotice), result.HumanDisplay().Summary)
}
}

// And an afterTool hook that discloses something the tool did not still gets
// through, on the typed channel rather than as prose.
func TestAnAfterToolNoticeReachesTheTypedSlice(t *testing.T) {
registry := tools.NewRegistry()
registry.Register(quietTool{})

result, err := executeToolCall(context.Background(), registry, ToolCall{
ID: "call-1", Name: "quiet_tool", Arguments: `{}`,
}, PermissionModeAuto, Options{Cwd: t.TempDir(), Hooks: quietToolAfterHookDispatcher(t)})
if err != nil {
t.Fatalf("executeToolCall: %v", err)
}
if !strings.Contains(result.ModelOutput(), afterToolChatter) {
t.Fatalf("SETUP INVALID: the afterTool hook never ran:\n%s", result.ModelOutput())
}
if len(result.EnforcementNotices) != 1 || result.EnforcementNotices[0] != sharedEnforcementNotice {
t.Errorf("an afterTool hook's disclosure did not reach the typed slice, so no card renders it: %v", result.EnforcementNotices)
}
if strings.Contains(result.BaseModelOutput(), sharedEnforcementNotice) {
t.Errorf("the disclosure travelled as prose in the body instead:\n%s", result.BaseModelOutput())
}
}

// quietTool carries no enforcement notice of its own.
type quietTool struct{}

func (quietTool) Name() string { return "quiet_tool" }
func (quietTool) Description() string { return "test tool with no enforcement notice" }
func (quietTool) Parameters() tools.Schema { return tools.Schema{Type: "object"} }
func (quietTool) Safety() tools.Safety {
return tools.Safety{SideEffect: tools.SideEffectRead, Permission: tools.PermissionAllow}
}

func (quietTool) Run(ctx context.Context, args map[string]any) tools.Result {
return tools.Result{Status: tools.StatusOK, Output: "the command output"}
}

func quietToolAfterHookDispatcher(t *testing.T) *hooks.Dispatcher {
t.Helper()
audit, err := hooks.NewAuditStore(hooks.AuditStoreOptions{AuditPath: filepath.Join(t.TempDir(), "audit.jsonl")})
if err != nil {
t.Fatalf("NewAuditStore: %v", err)
}
return hooks.NewDispatcher(hooks.DispatcherOptions{
Config: hooks.Config{
Enabled: true,
Hooks: []hooks.Definition{
{ID: "zero.after-tool", Event: hooks.EventAfterTool, Matcher: "quiet_tool", Command: "vet", Enabled: true},
},
},
Audit: audit,
Cwd: t.TempDir(),
Execution: execution.NewRunner(sharedNoticeHookPreparer{}),
})
}
Loading
Loading