mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-08 09:49:34 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1dfe275e50 | ||
|
|
5d080ef46b | ||
|
|
1d233c0a9b | ||
|
|
de1443b2ed | ||
|
|
84c1b15540 | ||
|
|
6393a90ed6 | ||
|
|
cd3e0b0a9f | ||
|
|
aa9ba8412e | ||
|
|
a97db8832f | ||
|
|
3ad039e937 | ||
|
|
3123b8d36e | ||
|
|
a1d4ac3b9a | ||
|
|
0899b1132e | ||
|
|
433653b873 | ||
|
|
e09c43e20a | ||
|
|
066a3a2ff0 | ||
|
|
b868a92290 | ||
|
|
daf9b8c65d | ||
|
|
c022e6afe7 | ||
|
|
c1b4e27749 | ||
|
|
937631a4ea | ||
|
|
9d8059cd49 | ||
|
|
7dfdbcb686 | ||
|
|
f1ff15eb94 | ||
|
|
eb3bbb2f12 | ||
|
|
9e09d0a7e6 | ||
|
|
f984082c82 | ||
|
|
33f62e7f94 | ||
|
|
8535e4a6b1 | ||
|
|
6bce7d3883 | ||
|
|
3b7c741480 | ||
|
|
b11a8a6231 | ||
|
|
105e955fa6 | ||
|
|
67de11f0ad | ||
|
|
2f8dc76666 | ||
|
|
886567fe19 | ||
|
|
b16971f5c3 | ||
|
|
03ee13e480 | ||
|
|
b69ff6733d | ||
|
|
4ccdab8f08 | ||
|
|
600bbc4c44 | ||
|
|
b8d344d48c | ||
|
|
2176c98271 | ||
|
|
0ae48e400d | ||
|
|
47d6ab7015 | ||
|
|
591874f1f8 | ||
|
|
b619016f08 | ||
|
|
60a1452eeb | ||
|
|
98fdb3e8e5 | ||
|
|
fc06cc6aaa | ||
|
|
b44d56f2b2 | ||
|
|
6bf5a86b7f | ||
|
|
e2ac98b906 | ||
|
|
b60359e2d6 | ||
|
|
f889b7e3b3 | ||
|
|
e0e8db306e | ||
|
|
f62ae776c9 | ||
|
|
427df5fdb9 | ||
|
|
22c9194bce | ||
|
|
85526b4978 | ||
|
|
1ee7aa762a | ||
|
|
4f8aa57314 | ||
|
|
76e5e35a1c | ||
|
|
cd76c67050 | ||
|
|
081aabdbc4 | ||
|
|
0b646ad7f9 | ||
|
|
4e2231e5de | ||
|
|
f7814819cb | ||
|
|
5c64cef7f6 | ||
|
|
55621d2176 | ||
|
|
915e1df103 | ||
|
|
3bfd898bfc | ||
|
|
c4905d296b | ||
|
|
93f9505b72 | ||
|
|
6a4eff21a2 |
No files matched your search
@@ -12,7 +12,7 @@
|
||||
|
||||
<p align="center">
|
||||
<a href="LICENSE"><img alt="license" src="https://img.shields.io/badge/license-MIT-blue"></a>
|
||||
<img alt="Go 1.26.9+" src="https://img.shields.io/badge/Go-1.26.9+-00ADD8?logo=go&logoColor=white">
|
||||
<img alt="Go 1.26.10+" src="https://img.shields.io/badge/Go-1.26.10+-00ADD8?logo=go&logoColor=white">
|
||||
<img alt="25+ providers" src="https://img.shields.io/badge/providers-25+-34E2EA">
|
||||
<a href="https://discord.gg/CaQDS6wdFn"><img alt="Discord" src="https://img.shields.io/badge/Discord-join-5865F2?logo=discord&logoColor=white"></a>
|
||||
<br>
|
||||
@@ -158,7 +158,7 @@ irm https://raw.githubusercontent.com/Twigpine/zero/main/scripts/install.ps1 | i
|
||||
|
||||
### From source
|
||||
|
||||
Source builds require Go 1.26.9+.
|
||||
Source builds require Go 1.26.10+.
|
||||
|
||||
```bash
|
||||
git clone https://github.com/Twigpine/zero.git
|
||||
|
||||
+2
-2
@@ -12,7 +12,7 @@
|
||||
|
||||
<p align="center">
|
||||
<a href="LICENSE"><img alt="license" src="https://img.shields.io/badge/license-MIT-blue"></a>
|
||||
<img alt="Go 1.26.9+" src="https://img.shields.io/badge/Go-1.26.9+-00ADD8?logo=go&logoColor=white">
|
||||
<img alt="Go 1.26.10+" src="https://img.shields.io/badge/Go-1.26.10+-00ADD8?logo=go&logoColor=white">
|
||||
<img alt="25+ providers" src="https://img.shields.io/badge/providers-25+-34E2EA">
|
||||
<a href="https://discord.gg/CaQDS6wdFn"><img alt="Discord" src="https://img.shields.io/badge/Discord-join-5865F2?logo=discord&logoColor=white"></a>
|
||||
<br>
|
||||
@@ -136,7 +136,7 @@ irm https://raw.githubusercontent.com/Twigpine/zero/main/scripts/install.ps1 | i
|
||||
|
||||
### 从源码构建
|
||||
|
||||
源码构建需要 Go 1.26.9+。
|
||||
源码构建需要 Go 1.26.10+。
|
||||
|
||||
```bash
|
||||
git clone https://github.com/Twigpine/zero.git
|
||||
|
||||
+2
-2
@@ -190,7 +190,7 @@ Every release so far declares the module path `github.com/Gitlawb/zero`, so
|
||||
fails with "module declares its path as" until a release is tagged with the new
|
||||
module path.
|
||||
|
||||
This builds from source, so it needs Go 1.26.9+ and it does not go through the
|
||||
This builds from source, so it needs Go 1.26.10+ and it does not go through the
|
||||
release archives. Two consequences worth knowing before you pick it:
|
||||
|
||||
- On Linux, Bubblewrap is still required for native sandboxing. Zero resolves
|
||||
@@ -218,7 +218,7 @@ Build a local binary:
|
||||
go build -o zero ./cmd/zero
|
||||
```
|
||||
|
||||
Source builds require Go 1.26.9+.
|
||||
Source builds require Go 1.26.10+.
|
||||
|
||||
### Sandbox Helpers For Source Builds
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
module github.com/Twigpine/zero
|
||||
|
||||
go 1.26.9
|
||||
go 1.26.10
|
||||
|
||||
require (
|
||||
charm.land/bubbles/v2 v2.1.1
|
||||
|
||||
+45
-20
@@ -962,16 +962,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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1144,13 +1153,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
|
||||
@@ -1170,12 +1180,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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
package acp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Twigpine/zero/internal/agent"
|
||||
"github.com/Twigpine/zero/internal/sessions"
|
||||
"github.com/Twigpine/zero/internal/tools"
|
||||
"github.com/Twigpine/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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package acp
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Twigpine/zero/internal/agent"
|
||||
"github.com/Twigpine/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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Twigpine/zero/internal/execution"
|
||||
"github.com/Twigpine/zero/internal/hooks"
|
||||
"github.com/Twigpine/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{}),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Twigpine/zero/internal/execution"
|
||||
"github.com/Twigpine/zero/internal/hooks"
|
||||
"github.com/Twigpine/zero/internal/tools"
|
||||
zeroruntime "github.com/Twigpine/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
const beforeToolNotice = "denyRead is configured, so the write jail is not confining writes"
|
||||
|
||||
// beforeToolChatter is what a hook prints for its own reasons. It must never
|
||||
// reach the model: main is silent for a successful hook, and a hook that logs is
|
||||
// not asking to be heard by anything but the operator's terminal.
|
||||
const beforeToolChatter = "hook-ran-and-logged-this"
|
||||
|
||||
// noticeHookPreparer plans the hook command with an enforcement notice attached,
|
||||
// the way the sandbox does for a command it weakened. The prepared child prints
|
||||
// ordinary output as well, so one run carries both kinds of text and the
|
||||
// delivery decision has to tell them apart.
|
||||
type noticeHookPreparer struct{}
|
||||
|
||||
func (noticeHookPreparer) PrepareExecution(_ context.Context, _ execution.Request) (execution.PreparedCommand, error) {
|
||||
var command *exec.Cmd
|
||||
if runtime.GOOS == "windows" {
|
||||
command = exec.Command("cmd.exe", "/c", "echo "+beforeToolChatter)
|
||||
} else {
|
||||
command = exec.Command("/bin/sh", "-c", "echo "+beforeToolChatter)
|
||||
}
|
||||
return execution.PreparedCommand{
|
||||
Command: command,
|
||||
Enforcement: execution.Enforcement{Notices: []string{beforeToolNotice}},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func beforeToolDispatcher(t *testing.T, event hooks.Event, exitCode int) *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.before-tool", Event: event, Matcher: "read_file", Command: "hook", Enabled: true},
|
||||
},
|
||||
},
|
||||
Audit: audit,
|
||||
Cwd: t.TempDir(),
|
||||
Execution: execution.NewRunner(noticeHookPreparer{}),
|
||||
})
|
||||
}
|
||||
|
||||
func readFileRunOptions(t *testing.T, dispatcher *hooks.Dispatcher) (Options, *mockProvider, string) {
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(root, "notes.txt"), []byte("hello"), 0o644); err != nil {
|
||||
t.Fatalf("write notes.txt: %v", err)
|
||||
}
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewReadFileTool(root))
|
||||
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
|
||||
{
|
||||
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-1", ToolName: "read_file"},
|
||||
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{"path":"notes.txt"}`},
|
||||
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-1"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
{
|
||||
{Type: zeroruntime.StreamEventText, Content: "read it"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
}}
|
||||
return Options{
|
||||
SessionID: "session-hook",
|
||||
Cwd: root,
|
||||
Registry: registry,
|
||||
ProviderName: "test-provider",
|
||||
Model: "test-model",
|
||||
Hooks: dispatcher,
|
||||
MaxTurns: 2,
|
||||
}, provider, root
|
||||
}
|
||||
|
||||
// countRequestsContaining reports how many provider requests carry needle, so a
|
||||
// notice delivered twice is distinguishable from one delivered once.
|
||||
func countRequestsContaining(requests []zeroruntime.CompletionRequest, needle string) int {
|
||||
total := 0
|
||||
for _, request := range requests {
|
||||
for _, message := range request.Messages {
|
||||
if strings.Contains(message.Content, needle) {
|
||||
total++
|
||||
}
|
||||
}
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
// THE NOTICE CROSSES TO THE MODEL. THE HOOK'S OWN OUTPUT DOES NOT.
|
||||
//
|
||||
// executeToolCall used to read the beforeTool outcome only when Blocked was
|
||||
// true, so a hook that ran under the weakened DenyRead token said so to nobody.
|
||||
// Delivering DispatchOutcome.Messages fixed that and overshot: hookMessage folds
|
||||
// the notice together with the hook's ordinary stdout, so every successful
|
||||
// hook's routine logging became a standing input channel into the next model
|
||||
// request, which is not what main does.
|
||||
//
|
||||
// One hook run produces both kinds of text here, because the bug is exactly a
|
||||
// failure to tell them apart. Asserted on what the PROVIDER received, since that
|
||||
// is the boundary that matters; a unit test on the joining helper cannot see
|
||||
// which slice the loop passes it.
|
||||
func TestSuccessfulBeforeToolHookDeliversItsNoticeAndNotItsOutput(t *testing.T) {
|
||||
options, provider, _ := readFileRunOptions(t, beforeToolDispatcher(t, hooks.EventBeforeTool, 0))
|
||||
if _, err := Run(context.Background(), "read the notes", provider, options); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
|
||||
// The tool ran, so this is the successful-hook path rather than a blocked
|
||||
// call that never reached the tool.
|
||||
if !someRequestContains(provider.requests, "hello") {
|
||||
t.Fatal("SETUP INVALID: the tool result never reached the model, so nothing was delivered to check")
|
||||
}
|
||||
// And the hook really did run and really did print, or the silence asserted
|
||||
// below would be the silence of a hook that never executed.
|
||||
if !someRequestContains(provider.requests, beforeToolNotice) {
|
||||
t.Fatal("the enforcement notice never reached the model, so a hook could run under the weakened token and say so to nobody")
|
||||
}
|
||||
if got := countRequestsContaining(provider.requests, beforeToolNotice); got != 1 {
|
||||
t.Errorf("the notice reached the model %d times, want exactly once", got)
|
||||
}
|
||||
if someRequestContains(provider.requests, beforeToolChatter) {
|
||||
t.Error("the hook's ordinary output reached the model; main is silent for a successful hook and routine logging must not become model input")
|
||||
}
|
||||
}
|
||||
|
||||
// A VETO MUST NOT SWALLOW A NOTICE FROM A HOOK THAT ALREADY RAN.
|
||||
//
|
||||
// Dispatch runs hooks in order and returns at the first veto. The successful
|
||||
// hook ahead of it may already have run under the weakened token, and that is a
|
||||
// fact about something that happened. The veto result used to be built from the
|
||||
// blocking hook's Reason alone, so the earlier disclosure existed only in the
|
||||
// audit record.
|
||||
func TestABlockedCallStillCarriesTheEarlierHooksNotice(t *testing.T) {
|
||||
audit, err := hooks.NewAuditStore(hooks.AuditStoreOptions{AuditPath: filepath.Join(t.TempDir(), "audit.jsonl")})
|
||||
if err != nil {
|
||||
t.Fatalf("NewAuditStore: %v", err)
|
||||
}
|
||||
dispatcher := hooks.NewDispatcher(hooks.DispatcherOptions{
|
||||
Config: hooks.Config{
|
||||
Enabled: true,
|
||||
Hooks: []hooks.Definition{
|
||||
{ID: "zero.first", Event: hooks.EventBeforeTool, Matcher: "read_file", Command: "hook", Enabled: true},
|
||||
{ID: "zero.veto", Event: hooks.EventBeforeTool, Matcher: "read_file", Command: "veto", Enabled: true},
|
||||
},
|
||||
},
|
||||
Audit: audit,
|
||||
Cwd: t.TempDir(),
|
||||
Execution: execution.NewRunner(vetoSecondPreparer{}),
|
||||
})
|
||||
options, provider, _ := readFileRunOptions(t, dispatcher)
|
||||
if _, err := Run(context.Background(), "read the notes", provider, options); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
|
||||
// SETUP: the second hook really did veto, or this is the ordinary path.
|
||||
if !someRequestContains(provider.requests, "was blocked by hook") {
|
||||
t.Fatal("SETUP INVALID: the call was not blocked, so the veto path is not under test")
|
||||
}
|
||||
if !someRequestContains(provider.requests, beforeToolNotice) {
|
||||
t.Error("the veto result dropped the notice from the hook that had already run under the weakened token")
|
||||
}
|
||||
if got := countRequestsContaining(provider.requests, beforeToolNotice); got != 1 {
|
||||
t.Errorf("the notice reached the model %d times, want exactly once", got)
|
||||
}
|
||||
if someRequestContains(provider.requests, beforeToolChatter) {
|
||||
t.Error("the vetoed result carried the earlier hook's ordinary output")
|
||||
}
|
||||
}
|
||||
|
||||
// vetoSecondPreparer runs the first hook successfully with a notice and makes
|
||||
// the second one exit non-zero, which is a veto for a blocking event.
|
||||
type vetoSecondPreparer struct{}
|
||||
|
||||
func (vetoSecondPreparer) PrepareExecution(_ context.Context, request execution.Request) (execution.PreparedCommand, error) {
|
||||
script := "echo " + beforeToolChatter
|
||||
notices := []string{beforeToolNotice}
|
||||
if request.Command.Name == "veto" {
|
||||
script = "exit 2"
|
||||
notices = nil
|
||||
}
|
||||
var command *exec.Cmd
|
||||
if runtime.GOOS == "windows" {
|
||||
command = exec.Command("cmd.exe", "/c", script)
|
||||
} else {
|
||||
command = exec.Command("/bin/sh", "-c", script)
|
||||
}
|
||||
return execution.PreparedCommand{
|
||||
Command: command,
|
||||
Enforcement: execution.Enforcement{Notices: notices},
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Twigpine/zero/internal/execution"
|
||||
"github.com/Twigpine/zero/internal/hooks"
|
||||
"github.com/Twigpine/zero/internal/tools"
|
||||
zeroruntime "github.com/Twigpine/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
// editMatchingDispatcher is beforeToolDispatcher for the edit tool, so the same
|
||||
// notice-and-chatter hook runs ahead of a call whose result carries a diff.
|
||||
func editMatchingDispatcher(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.before-tool", Event: hooks.EventBeforeTool, Matcher: "edit_file", Command: "hook", Enabled: true},
|
||||
},
|
||||
},
|
||||
Audit: audit,
|
||||
Cwd: t.TempDir(),
|
||||
Execution: execution.NewRunner(noticeHookPreparer{}),
|
||||
})
|
||||
}
|
||||
|
||||
// editFileRunOptions is readFileRunOptions for a tool whose result carries a
|
||||
// rich preview, which is the case the disclosure went missing on.
|
||||
func editFileRunOptions(t *testing.T, dispatcher *hooks.Dispatcher, onResult func(ToolResult)) (Options, *mockProvider) {
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(root, "x.go"), []byte("package main\n\nconst answer = 41\n"), 0o644); err != nil {
|
||||
t.Fatalf("write x.go: %v", err)
|
||||
}
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewScopedEditFileTool(root, nil))
|
||||
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
|
||||
{
|
||||
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-1", ToolName: "edit_file"},
|
||||
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{"path":"x.go","old_string":"41","new_string":"42"}`},
|
||||
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-1"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
{
|
||||
{Type: zeroruntime.StreamEventText, Content: "edited it"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
}}
|
||||
return Options{
|
||||
SessionID: "session-hook-edit",
|
||||
Cwd: root,
|
||||
Registry: registry,
|
||||
ProviderName: "test-provider",
|
||||
Model: "test-model",
|
||||
Hooks: dispatcher,
|
||||
OnToolResult: onResult,
|
||||
PermissionMode: PermissionModeUnsafe,
|
||||
MaxTurns: 2,
|
||||
}, provider
|
||||
}
|
||||
|
||||
// THE NOTICE HAS TO SURVIVE A RESULT WHOSE BODY IS A DIFF.
|
||||
//
|
||||
// This is the boundary the component suites left between them. The delivery test
|
||||
// above proves a beforeTool notice reaches the provider with read_file, whose
|
||||
// body is its output. The card tests prove a card renders a notice when the
|
||||
// result already carries one typed. Neither composed the two, and in between them
|
||||
// the notice was being folded into result.Output as hook prose.
|
||||
//
|
||||
// For an edit or a write that is exactly where it disappears: the card builds its
|
||||
// body from Display.Preview rather than Output, and draws enforcement furniture
|
||||
// only from the typed slice, so the operator saw the diff and no disclosure at
|
||||
// all, live and restored, while the model saw the disclosure. A notice about a
|
||||
// weakened token was hidden on precisely the results where something was written.
|
||||
func TestABeforeToolNoticeSurvivesARichPreviewResult(t *testing.T) {
|
||||
dispatcher := editMatchingDispatcher(t)
|
||||
|
||||
var results []ToolResult
|
||||
options, provider := editFileRunOptions(t, dispatcher, func(result ToolResult) {
|
||||
results = append(results, result)
|
||||
})
|
||||
if _, err := Run(context.Background(), "bump the answer", provider, options); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
|
||||
if len(results) != 1 {
|
||||
t.Fatalf("SETUP INVALID: %d tool results, want the one edit", len(results))
|
||||
}
|
||||
result := results[0]
|
||||
if result.Status != tools.StatusOK {
|
||||
t.Fatalf("SETUP INVALID: the edit failed, so this is not the successful rich-preview path: %s", result.Output)
|
||||
}
|
||||
preview := result.BaseDisplay().Preview
|
||||
if !strings.Contains(preview, "42") {
|
||||
t.Fatalf("SETUP INVALID: the result carries no diff preview, which is the whole case under test: %q", preview)
|
||||
}
|
||||
|
||||
// TYPED, which is what every interactive surface reads.
|
||||
if len(result.EnforcementNotices) != 1 || result.EnforcementNotices[0] != beforeToolNotice {
|
||||
t.Errorf("the hook's disclosure did not reach the typed field, so the card and the restored card show the diff and no disclosure: %v", result.EnforcementNotices)
|
||||
}
|
||||
// AND NOT IN THE BODY AS WELL. Decoration has one owner per surface; carrying
|
||||
// it in both places renders it twice wherever the surface draws the slice.
|
||||
if strings.Contains(result.BaseModelOutput(), beforeToolNotice) {
|
||||
t.Errorf("the disclosure is in the result body as well as the typed field:\n%s", result.BaseModelOutput())
|
||||
}
|
||||
// The diff itself stays a parseable diff.
|
||||
if strings.Contains(preview, beforeToolNotice) {
|
||||
t.Errorf("the disclosure was glued onto the diff:\n%s", preview)
|
||||
}
|
||||
|
||||
// And the model still sees it, exactly once, with none of the hook's chatter.
|
||||
if got := countRequestsContaining(provider.requests, beforeToolNotice); got != 1 {
|
||||
t.Errorf("the notice reached the model %d times, want exactly once", got)
|
||||
}
|
||||
if someRequestContains(provider.requests, beforeToolChatter) {
|
||||
t.Error("the hook's ordinary output reached the model")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Twigpine/zero/internal/tools"
|
||||
)
|
||||
|
||||
// noticeProjectionTool stands in for any sandboxed command tool: it reports the
|
||||
// enforcement disclosure the way the real ones do, through the sandbox metadata
|
||||
// key that finalizeToolOutcome promotes into the typed notice slice.
|
||||
type noticeProjectionTool struct{}
|
||||
|
||||
func (noticeProjectionTool) Name() string { return "notice_projection" }
|
||||
func (noticeProjectionTool) Description() string { return "test tool carrying an enforcement notice" }
|
||||
func (noticeProjectionTool) Parameters() tools.Schema { return tools.Schema{Type: "object"} }
|
||||
func (noticeProjectionTool) Safety() tools.Safety {
|
||||
return tools.Safety{SideEffect: tools.SideEffectRead, Permission: tools.PermissionAllow}
|
||||
}
|
||||
|
||||
func (noticeProjectionTool) Run(ctx context.Context, args map[string]any) tools.Result {
|
||||
return tools.Result{
|
||||
Status: tools.StatusOK,
|
||||
Output: "the command output",
|
||||
Display: tools.Display{Summary: "the human summary"},
|
||||
Meta: map[string]string{"sandbox_notices": "least-privilege notice"},
|
||||
}
|
||||
}
|
||||
|
||||
// THE PROJECTION MUST CARRY ONE REPRESENTATION, NOT TWO.
|
||||
//
|
||||
// executeToolCall copies the typed notice slice into agent.ToolResult, so the
|
||||
// text fields it copies alongside must be the UNDECORATED base. Storing the
|
||||
// already-rendered text there instead leaves the same fact in two places with no
|
||||
// contract between them: it happens to render once today only because the
|
||||
// outcome arrives finalized and the agent accessor then reads Outcome.ModelView
|
||||
// rather than the stored field. Any result that reaches the accessor without a
|
||||
// finalized outcome renders the disclosure twice, and every raw reader of
|
||||
// .Output sees text that disagrees with Outcome.ModelView.
|
||||
func TestEnforcementNoticeIsStoredOnceAndRenderedOnce(t *testing.T) {
|
||||
const notice = "least-privilege notice"
|
||||
|
||||
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()})
|
||||
if err != nil {
|
||||
t.Fatalf("executeToolCall: %v", err)
|
||||
}
|
||||
if len(result.EnforcementNotices) == 0 {
|
||||
t.Fatalf("the notice never reached the agent result: %#v", result)
|
||||
}
|
||||
|
||||
// The stored fields are the canonical undecorated base, and they agree with
|
||||
// the finalized outcome they were projected from.
|
||||
if strings.Contains(result.Output, notice) {
|
||||
t.Errorf("ToolResult.Output stores the rendered notice as well as the slice: %q", result.Output)
|
||||
}
|
||||
if strings.Contains(result.Display.Summary, notice) {
|
||||
t.Errorf("ToolResult.Display.Summary stores the rendered notice as well as the slice: %q", result.Display.Summary)
|
||||
}
|
||||
if result.Output != result.Outcome.ModelView {
|
||||
t.Errorf("stored output %q disagrees with the finalized model view %q", result.Output, result.Outcome.ModelView)
|
||||
}
|
||||
if result.Display.Summary != result.Outcome.HumanView.Summary {
|
||||
t.Errorf("stored summary %q disagrees with the finalized human view %q", result.Display.Summary, result.Outcome.HumanView.Summary)
|
||||
}
|
||||
|
||||
// And every consumer that renders goes through the accessors, which show the
|
||||
// disclosure exactly once without hiding the output it is attached to.
|
||||
// loop.go builds the provider transcript from ModelOutput; the CLI writer and
|
||||
// the TUI cards use both accessors.
|
||||
transcript := result.ModelOutput()
|
||||
if got := strings.Count(transcript, notice); got != 1 {
|
||||
t.Errorf("the transcript shows the notice %d times, want 1: %q", got, transcript)
|
||||
}
|
||||
if !strings.Contains(transcript, "the command output") {
|
||||
t.Errorf("the transcript lost the command output: %q", transcript)
|
||||
}
|
||||
summary := result.HumanDisplay().Summary
|
||||
if got := strings.Count(summary, notice); got != 1 {
|
||||
t.Errorf("the human summary shows the notice %d times, want 1: %q", got, summary)
|
||||
}
|
||||
if !strings.Contains(summary, "the human summary") {
|
||||
t.Errorf("the human summary lost the tool summary: %q", summary)
|
||||
}
|
||||
}
|
||||
|
||||
// A result that never crossed the registry has no finalized outcome, so the
|
||||
// accessor falls back to the stored field. That is the path on which a stored
|
||||
// rendering would double, and it is the reason the contract above is stated on
|
||||
// the stored fields rather than only on the accessors.
|
||||
func TestUnfinalizedResultStillRendersTheNoticeOnce(t *testing.T) {
|
||||
const notice = "least-privilege notice"
|
||||
result := ToolResult{
|
||||
Status: tools.StatusOK,
|
||||
Output: "the command output",
|
||||
Display: tools.Display{Summary: "the human summary"},
|
||||
EnforcementNotices: []string{notice},
|
||||
}
|
||||
if result.Outcome.Finalized() {
|
||||
t.Fatal("fixture is finalized; it no longer covers the fallback path")
|
||||
}
|
||||
if got := strings.Count(result.ModelOutput(), notice); got != 1 {
|
||||
t.Errorf("ModelOutput shows the notice %d times, want 1: %q", got, result.ModelOutput())
|
||||
}
|
||||
if got := strings.Count(result.HumanDisplay().Summary, notice); got != 1 {
|
||||
t.Errorf("HumanDisplay shows the notice %d times, want 1: %q", got, result.HumanDisplay().Summary)
|
||||
}
|
||||
}
|
||||
@@ -84,12 +84,102 @@ func TestBlockedByHookResultFallsBackWhenReasonEmpty(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// THE BLOCKING HOOK'S OWN NOTICE IS A NOTICE, NOT PART OF THE VETO TEXT.
|
||||
//
|
||||
// It used to arrive folded into Reason, so it reached the model inside the error
|
||||
// message and never reached EnforcementNotices, which is what every surface
|
||||
// renders as the disclosure and what the session record keeps apart from the
|
||||
// body.
|
||||
func TestBlockedByHookResultCarriesTheBlockersNoticeOnTheTypedSlice(t *testing.T) {
|
||||
const notice = "denyRead is configured, so the write jail is not confining writes"
|
||||
out := blockedByHookResult(
|
||||
ToolCall{ID: "c4", Name: "bash"},
|
||||
hooks.DispatchOutcome{Blocked: true, BlockedBy: "policy", Reason: "policy violation", Notices: []string{notice}},
|
||||
)
|
||||
if len(out.EnforcementNotices) != 1 || out.EnforcementNotices[0] != notice {
|
||||
t.Fatalf("EnforcementNotices = %#v, want the blocking hook's notice", out.EnforcementNotices)
|
||||
}
|
||||
if strings.Contains(out.Output, notice) {
|
||||
t.Errorf("the notice is also inside the veto message, so it is said twice:\n%s", out.Output)
|
||||
}
|
||||
if got := strings.Count(out.ModelOutput(), notice); got != 1 {
|
||||
t.Errorf("the model sees the notice %d times, want once:\n%s", got, out.ModelOutput())
|
||||
}
|
||||
}
|
||||
|
||||
// AN EARLIER HOOK'S NOTICE SURVIVES A REASON THAT QUOTES IT.
|
||||
//
|
||||
// The veto path used to drop every notice whose text appeared anywhere in the
|
||||
// blocking hook's reason, on the theory that the reason already carried it. A
|
||||
// blocking hook that printed the same sentence, for example by relaying another
|
||||
// tool's stderr, therefore removed an earlier hook's disclosure from the typed
|
||||
// slice.
|
||||
func TestBlockedByHookResultKeepsAnEarlierNoticeTheReasonQuotes(t *testing.T) {
|
||||
const notice = "denyRead is configured, so the write jail is not confining writes"
|
||||
out := blockedByHookResult(
|
||||
ToolCall{ID: "c5", Name: "bash"},
|
||||
hooks.DispatchOutcome{
|
||||
Blocked: true,
|
||||
BlockedBy: "veto",
|
||||
Reason: "refusing: the last run reported \"" + notice + "\"",
|
||||
Notices: []string{notice},
|
||||
},
|
||||
)
|
||||
if len(out.EnforcementNotices) != 1 || out.EnforcementNotices[0] != notice {
|
||||
t.Fatalf("the earlier hook's notice was dropped because the veto quoted it: %#v", out.EnforcementNotices)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDispatchHelpersAreNoopWithoutDispatcher(t *testing.T) {
|
||||
options := Options{} // Hooks is nil
|
||||
if _, blocked := dispatchBeforeTool(context.Background(), options, ToolCall{Name: "bash"}, nil); blocked {
|
||||
t.Fatal("a nil dispatcher must never block a tool")
|
||||
}
|
||||
if feedback := dispatchAfterTool(context.Background(), options, ToolCall{Name: "bash"}, nil, tools.Result{}); feedback != "" {
|
||||
t.Fatalf("a nil dispatcher must yield no feedback, got %q", feedback)
|
||||
if feedback, notices := dispatchAfterTool(context.Background(), options, ToolCall{Name: "bash"}, nil, tools.Result{}); feedback != "" || notices != nil {
|
||||
t.Fatalf("a nil dispatcher must yield no feedback and no notices, got %q and %v", feedback, notices)
|
||||
}
|
||||
}
|
||||
|
||||
// A SUCCESSFUL beforeTool HOOK'S NOTICE MUST STAY TYPED.
|
||||
//
|
||||
// executeToolCall used to read the beforeTool outcome only when Blocked was true,
|
||||
// so a hook that ran fine and produced an enforcement notice — for instance that
|
||||
// it ran under the weakened DenyRead token — put that notice in the audit record
|
||||
// and nowhere anybody could see it. The first fix delivered it as prose appended
|
||||
// to the result output, which reached the model and no interactive surface,
|
||||
// because those build their enforcement furniture from the typed slice.
|
||||
//
|
||||
// So the delivery is the typed field, and this pins the merge: the hook's notice
|
||||
// first, the tool's own after it, each exactly once, blanks contributing nothing.
|
||||
func TestBeforeToolNoticesMergeIntoTheTypedField(t *testing.T) {
|
||||
const notice = "hook ran without WRITE_RESTRICTED because denyRead is configured"
|
||||
const toolOwned = "the sandbox dropped the network capability for this call"
|
||||
|
||||
result := withAppliedHookNotices(ToolResult{Output: "ok"}, []string{notice}, nil)
|
||||
if len(result.EnforcementNotices) != 1 || result.EnforcementNotices[0] != notice {
|
||||
t.Fatalf("a successful beforeTool notice did not reach the typed field: %v", result.EnforcementNotices)
|
||||
}
|
||||
// AND NOT THE OUTPUT AS WELL, or every surface that renders the slice shows
|
||||
// the disclosure twice.
|
||||
if strings.Contains(result.Output, notice) {
|
||||
t.Errorf("the notice was written into the output as well as the typed field: %q", result.Output)
|
||||
}
|
||||
|
||||
// Both arrive, hook first, when the tool carries its own.
|
||||
result = withAppliedHookNotices(ToolResult{EnforcementNotices: []string{toolOwned}}, []string{notice}, nil)
|
||||
if len(result.EnforcementNotices) != 2 || result.EnforcementNotices[0] != notice || result.EnforcementNotices[1] != toolOwned {
|
||||
t.Fatalf("the hook and tool notices did not merge in order: %v", result.EnforcementNotices)
|
||||
}
|
||||
|
||||
// The same disclosure from both sides is carried once.
|
||||
result = withAppliedHookNotices(ToolResult{EnforcementNotices: []string{notice}}, []string{notice}, nil)
|
||||
if len(result.EnforcementNotices) != 1 {
|
||||
t.Errorf("one disclosure reported by both the hook and the tool was carried %d times: %v", len(result.EnforcementNotices), result.EnforcementNotices)
|
||||
}
|
||||
|
||||
// Blank notices contribute nothing, so a run with no hook output stays silent.
|
||||
result = withAppliedHookNotices(ToolResult{}, []string{"", " "}, nil)
|
||||
if len(result.EnforcementNotices) != 0 {
|
||||
t.Errorf("blank hook notices produced %v, want nothing", result.EnforcementNotices)
|
||||
}
|
||||
}
|
||||
+191
-36
@@ -1467,10 +1467,31 @@ func executeToolCall(ctx context.Context, registry *tools.Registry, call ToolCal
|
||||
}
|
||||
|
||||
// beforeTool hooks may veto the call before it runs (a non-zero exit blocks).
|
||||
//
|
||||
// A SUCCESSFUL beforeTool HOOK STILL HAS SOMETHING TO SAY, BUT ONLY ITS NOTICE.
|
||||
//
|
||||
// Reading the outcome only when Blocked left the enforcement disclosure in the
|
||||
// audit record and nowhere the model or the operator could see it: a hook could
|
||||
// run under the weakened DenyRead token and say so to nobody.
|
||||
//
|
||||
// Notices, NOT Messages. Messages is presentation text that hookMessage builds
|
||||
// by folding the notice together with the hook's ordinary stdout, so delivering
|
||||
// it would put every successful hook's routine logging, large diagnostics, and
|
||||
// whatever text a hook happened to process into the next model request. That is
|
||||
// a behaviour change nobody asked for and a standing input channel. main is
|
||||
// silent for successful hooks and stays silent here for everything except the
|
||||
// disclosure. Carried to the tool result below, the same surface afterTool
|
||||
// feedback already uses.
|
||||
var beforeToolNotices []string
|
||||
// Filled after the tool runs, and merged with beforeTool's at the one
|
||||
// finalization below so both phases land on the same contract.
|
||||
var afterToolNotices []string
|
||||
if toolFound {
|
||||
if outcome, blocked := dispatchBeforeTool(ctx, options, call, args); blocked {
|
||||
outcome, blocked := dispatchBeforeTool(ctx, options, call, args)
|
||||
if blocked {
|
||||
return blockedByHookResult(call, outcome), nil
|
||||
}
|
||||
beforeToolNotices = outcome.Notices
|
||||
}
|
||||
args = shellExecutionArgsForApproval(call.Name, args, decisionAction, options)
|
||||
|
||||
@@ -1516,7 +1537,10 @@ func executeToolCall(ctx context.Context, registry *tools.Registry, call ToolCal
|
||||
})
|
||||
if retryResult, directResult, retried, action, reason, prefix, abortErr := maybeRetryUnsandboxedAfterSandboxRestriction(ctx, registry, call, tool, args, result, permissionMode, options, progressCallback); retried || directResult != nil || abortErr != nil {
|
||||
if directResult != nil {
|
||||
return *directResult, abortErr
|
||||
// A denied, cancelled, or ungrantable retry still returns a result for a
|
||||
// call whose beforeTool hook already ran. Without this the disclosure is
|
||||
// produced and then dropped on the floor.
|
||||
return withAppliedHookNotices(*directResult, beforeToolNotices, nil), abortErr
|
||||
}
|
||||
result = retryResult
|
||||
permissionGranted = true
|
||||
@@ -1542,7 +1566,15 @@ func executeToolCall(ctx context.Context, registry *tools.Registry, call ToolCal
|
||||
// afterTool hooks run once the tool has executed; their output (e.g. a
|
||||
// formatter or vet result) is surfaced back to the model on the result.
|
||||
if toolFound {
|
||||
if feedback := dispatchAfterTool(ctx, options, call, args, result); feedback != "" {
|
||||
// NEITHER PHASE'S NOTICES ARE FOLDED IN HERE. They are typed enforcement
|
||||
// data on both sides now, and appending them as hook prose was what kept
|
||||
// beforeTool's out of every surface that renders the typed slice, and what
|
||||
// made afterTool's arrive twice once that slice was composed. Only the
|
||||
// hook's own output, which is what an afterTool validator asked to say,
|
||||
// still travels this way.
|
||||
feedback, notices := dispatchAfterTool(ctx, options, call, args, result)
|
||||
afterToolNotices = notices
|
||||
if strings.TrimSpace(feedback) != "" {
|
||||
var didRedact bool
|
||||
result.Output, didRedact = appendHookFeedback(result.Output, feedback)
|
||||
if didRedact {
|
||||
@@ -1569,28 +1601,32 @@ func executeToolCall(ctx context.Context, registry *tools.Registry, call ToolCal
|
||||
// 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,
|
||||
Output: result.ModelOutput(),
|
||||
Truncated: result.Truncated,
|
||||
Meta: result.Meta,
|
||||
Images: result.Images,
|
||||
Redacted: result.Redacted,
|
||||
ChangedFiles: result.ChangedFiles,
|
||||
FileDiffs: result.FileDiffs,
|
||||
ChangeSummaries: result.ChangeSummaries,
|
||||
Display: result.HumanDisplay(),
|
||||
Outcome: result.Outcome,
|
||||
LoadedTools: loadedToolsFromResult(result.Meta),
|
||||
//
|
||||
// Wrapped in the same finalization every other return from a hooked call
|
||||
// uses, so the normal path cannot drift away from the veto and retry paths.
|
||||
return withAppliedHookNotices(ToolResult{
|
||||
Risk: executedRisk,
|
||||
ToolCallID: call.ID,
|
||||
Name: call.Name,
|
||||
Status: result.Status,
|
||||
Output: result.BaseModelOutput(),
|
||||
Truncated: result.Truncated,
|
||||
Meta: result.Meta,
|
||||
EnforcementNotices: append([]string(nil), result.EnforcementNotices...),
|
||||
Images: result.Images,
|
||||
Redacted: result.Redacted,
|
||||
ChangedFiles: result.ChangedFiles,
|
||||
FileDiffs: result.FileDiffs,
|
||||
ChangeSummaries: result.ChangeSummaries,
|
||||
Display: result.BaseDisplay(),
|
||||
Outcome: result.Outcome,
|
||||
LoadedTools: loadedToolsFromResult(result.Meta),
|
||||
// A tool may signal a mid-run model escalation by carrying the target id
|
||||
// in Meta["escalate_to_model"]. Lift it into the typed loop-level field;
|
||||
// the Run turn loop performs the actual provider switch. Empty for every
|
||||
// ordinary tool result.
|
||||
RequestedModel: result.Meta["escalate_to_model"],
|
||||
}, nil
|
||||
}, beforeToolNotices, afterToolNotices), nil
|
||||
}
|
||||
|
||||
const sandboxNamespaceLimitedReason = "sandbox output is limited to the sandbox PID namespace; host/global state requires approval"
|
||||
@@ -1974,9 +2010,9 @@ func dispatchBeforeTool(ctx context.Context, options Options, call ToolCall, arg
|
||||
// dispatchAfterTool runs configured afterTool hooks once a tool has executed and
|
||||
// returns any advisory output (e.g. a formatter or vet result) to surface back
|
||||
// to the model. afterTool hooks never block. A nil dispatcher is a no-op.
|
||||
func dispatchAfterTool(ctx context.Context, options Options, call ToolCall, args map[string]any, result tools.Result) string {
|
||||
func dispatchAfterTool(ctx context.Context, options Options, call ToolCall, args map[string]any, result tools.Result) (string, []string) {
|
||||
if options.Hooks == nil || hooksSuppressed(options) {
|
||||
return ""
|
||||
return "", nil
|
||||
}
|
||||
outcome := options.Hooks.Dispatch(ctx, hooks.DispatchInput{
|
||||
Event: hooks.EventAfterTool,
|
||||
@@ -1992,7 +2028,13 @@ func dispatchAfterTool(ctx context.Context, options Options, call ToolCall, args
|
||||
"changedFiles": result.ChangedFiles,
|
||||
},
|
||||
})
|
||||
return strings.TrimSpace(strings.Join(outcome.Messages, "\n"))
|
||||
// Both halves, because they have different destinations. The hook's own output
|
||||
// is prose that belongs in the result body where an afterTool validator's
|
||||
// findings have always gone; its enforcement notices are typed data that the
|
||||
// caller merges into the result the same way beforeTool's are. Returning only
|
||||
// the joined messages was what left afterTool on the old channel while
|
||||
// beforeTool moved, and a notice reported by both then appeared twice.
|
||||
return strings.TrimSpace(strings.Join(outcome.Messages, "\n")), outcome.Notices
|
||||
}
|
||||
|
||||
// dispatchSessionStart runs configured sessionStart hooks once before the first
|
||||
@@ -2056,7 +2098,7 @@ func blockedByHookResult(call ToolCall, outcome hooks.DispatchOutcome) ToolResul
|
||||
reason = "blocked by a beforeTool hook"
|
||||
}
|
||||
message := fmt.Sprintf("Error: %q was blocked by hook %q: %s", call.Name, outcome.BlockedBy, reason)
|
||||
return ToolResult{
|
||||
result := ToolResult{
|
||||
ToolCallID: call.ID,
|
||||
Name: call.Name,
|
||||
Status: tools.StatusError,
|
||||
@@ -2064,12 +2106,124 @@ func blockedByHookResult(call ToolCall, outcome hooks.DispatchOutcome) ToolResul
|
||||
Redacted: redacted,
|
||||
DenialReason: DenialHookBlocked,
|
||||
}
|
||||
// Dispatch runs hooks in order and stops at the first veto, so an earlier hook
|
||||
// may already have run under a weakened token before this one said no. Its
|
||||
// notice describes something that happened and has to survive the veto, and so
|
||||
// does the blocking hook's own.
|
||||
//
|
||||
// ALL OF THEM, FROM THE TYPED SLICE. Reason carries the hook's words only, so
|
||||
// nothing here is said twice and nothing has to be filtered out. The filter
|
||||
// this replaced matched notices against the reason text, and dropped an
|
||||
// earlier hook's disclosure whenever the blocking hook's output quoted it.
|
||||
return withAppliedHookNotices(result, outcome.Notices, nil)
|
||||
}
|
||||
|
||||
// withAppliedHookNotices is the single place a hook enforcement notice
|
||||
// reaches a tool result on a path that does NOT run afterTool.
|
||||
//
|
||||
// The normal tail joins the notices with the afterTool feedback and delivers
|
||||
// both at once. Two other exits return a result for a call whose hook already
|
||||
// ran: a later hook's veto, and a denied, cancelled, or ungrantable unsandboxed
|
||||
// retry. Routing all three through one function is what keeps "the hook ran
|
||||
// under this token" from depending on which exit the call happened to take.
|
||||
//
|
||||
// NO REBUDGET HERE, AND THAT IS LOAD-BEARING ON WHAT MAY PASS THROUGH. The
|
||||
// normal tail appends and then calls Registry.RebudgetAfterHook, because what it
|
||||
// appends is afterTool feedback: hook stdout, which a hook can make arbitrarily
|
||||
// large. These notices cannot be: they are CommandPlan.Notes, which only this
|
||||
// package's own fixed sentences may fill (none does today; #1006 refuses the
|
||||
// denyRead trade the last one described), and nothing hook-authored reaches
|
||||
// this slice: DispatchOutcome.Messages is where
|
||||
// hook output lives, and the capture site deliberately does not read it.
|
||||
//
|
||||
// So if anything ever widens what is delivered here to include text a hook or a
|
||||
// tool can size, this needs the rebudget step as well, which means converting
|
||||
// through tools.Result the way the tail does rather than editing Output in
|
||||
// place. Do not widen it without that.
|
||||
// withAppliedHookNotices carries a hook's disclosures onto the result as TYPED
|
||||
// enforcement notices, and is the single finalization point every return path
|
||||
// from a hooked call goes through.
|
||||
//
|
||||
// BOTH PHASES, because half a symmetry is its own defect. beforeTool was moved
|
||||
// onto the typed slice while afterTool was left folding its notices into the
|
||||
// prose feedback, and since the two carry the identical fixed string, a
|
||||
// disclosure reported by both then reached the model twice: once from the typed
|
||||
// prepend every surface composes, once inside the hook feedback in the body. A
|
||||
// bash or exec card showed it in the furniture and again in the body.
|
||||
//
|
||||
// It used to fold them into result.Output as prose. That reached the provider,
|
||||
// which reads the output, and reached nothing else. Every interactive surface
|
||||
// builds its enforcement furniture from the typed slice, and for an edit or a
|
||||
// write the card shows Display.Preview instead of Output, so the disclosure was
|
||||
// absent from the live card in both its collapsed and expanded states and from
|
||||
// the session payload that restores them. A notice about a weakened token was
|
||||
// therefore shown to the model and hidden from the operator, on exactly the
|
||||
// results where something was written.
|
||||
//
|
||||
// Typed here, the canonical accessors compose it per surface, the same way a
|
||||
// tool's own notices already work. Nothing is written into Output as well:
|
||||
// decoration has one owner per surface or the disclosure appears twice.
|
||||
// Hook notices lead, in run order, with the tool's own after them: dedupe makes
|
||||
// the ordering moot for the duplicate case that motivated this, and for distinct
|
||||
// disclosures it keeps the shape the tool-owned notices already had.
|
||||
func withAppliedHookNotices(result ToolResult, before []string, after []string) ToolResult {
|
||||
hookNotices := before
|
||||
if len(after) > 0 {
|
||||
hookNotices = append(append([]string(nil), before...), after...)
|
||||
}
|
||||
merged, didRedact := mergeEnforcementNotices(hookNotices, result.EnforcementNotices)
|
||||
result.EnforcementNotices = merged
|
||||
if didRedact {
|
||||
result.Redacted = true
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// mergeEnforcementNotices puts the hook disclosures ahead of the
|
||||
// tool's own and drops exact repeats, so a surface rendering the slice shows
|
||||
// each disclosure exactly once.
|
||||
//
|
||||
// Hook notices are third-party text arriving on an intercepted path that bypasses
|
||||
// the registry's redaction boundary, so they are scrubbed here the way
|
||||
// appendHookFeedback scrubbed them while they travelled as prose. The bool
|
||||
// reports whether scrubbing changed anything, so Redacted keeps matching the
|
||||
// registry's contract.
|
||||
func mergeEnforcementNotices(before []string, own []string) ([]string, bool) {
|
||||
if len(before) == 0 && len(own) == 0 {
|
||||
return nil, false
|
||||
}
|
||||
merged := make([]string, 0, len(before)+len(own))
|
||||
seen := make(map[string]struct{}, len(before)+len(own))
|
||||
redacted := false
|
||||
for index, notice := range append(append([]string(nil), before...), own...) {
|
||||
if index < len(before) {
|
||||
scrubbed := redaction.RedactString(notice, redaction.Options{})
|
||||
redacted = redacted || scrubbed != notice
|
||||
notice = scrubbed
|
||||
}
|
||||
if strings.TrimSpace(notice) == "" {
|
||||
continue
|
||||
}
|
||||
if _, already := seen[notice]; already {
|
||||
continue
|
||||
}
|
||||
seen[notice] = struct{}{}
|
||||
merged = append(merged, notice)
|
||||
}
|
||||
if len(merged) == 0 {
|
||||
return nil, redacted
|
||||
}
|
||||
return merged, redacted
|
||||
}
|
||||
|
||||
// appendHookFeedback appends afterTool hook output to a tool result's output,
|
||||
// scrubbed for secrets like every other string crossing the tool boundary. The
|
||||
// returned bool reports whether scrubbing changed the feedback, so the caller can
|
||||
// set ToolResult.Redacted to match the registry's redaction contract.
|
||||
// The joiner that folded beforeTool notices in with the afterTool feedback is
|
||||
// gone. Sending a typed enforcement notice out as hook prose was the defect, not
|
||||
// the delivery: see withAppliedHookNotices. afterTool feedback still arrives here
|
||||
// on its own, which is all this path was ever meant to carry.
|
||||
func appendHookFeedback(output string, feedback string) (string, bool) {
|
||||
scrubbed := redaction.RedactString(feedback, redaction.Options{})
|
||||
redacted := scrubbed != feedback
|
||||
@@ -2204,18 +2358,19 @@ func askUserFallbackResult(ctx context.Context, registry *tools.Registry, call T
|
||||
Cwd: options.Cwd,
|
||||
})
|
||||
return ToolResult{
|
||||
ToolCallID: call.ID,
|
||||
Name: call.Name,
|
||||
Status: result.Status,
|
||||
Output: result.ModelOutput(),
|
||||
Truncated: result.Truncated,
|
||||
Meta: result.Meta,
|
||||
Redacted: result.Redacted,
|
||||
ChangedFiles: result.ChangedFiles,
|
||||
FileDiffs: result.FileDiffs,
|
||||
ChangeSummaries: result.ChangeSummaries,
|
||||
Display: result.HumanDisplay(),
|
||||
Outcome: result.Outcome,
|
||||
ToolCallID: call.ID,
|
||||
Name: call.Name,
|
||||
Status: result.Status,
|
||||
Output: result.BaseModelOutput(),
|
||||
Truncated: result.Truncated,
|
||||
Meta: result.Meta,
|
||||
EnforcementNotices: append([]string(nil), result.EnforcementNotices...),
|
||||
Redacted: result.Redacted,
|
||||
ChangedFiles: result.ChangedFiles,
|
||||
FileDiffs: result.FileDiffs,
|
||||
ChangeSummaries: result.ChangeSummaries,
|
||||
Display: result.BaseDisplay(),
|
||||
Outcome: result.Outcome,
|
||||
}
|
||||
}
|
||||
return ToolResult{
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/Twigpine/zero/internal/tools"
|
||||
)
|
||||
|
||||
// CardBody is the card body source: the rich card-only Display.Preview (a
|
||||
// code/diff preview) when present on a successful result, else the text the
|
||||
// model also saw. Error results keep their output so the failure shows.
|
||||
//
|
||||
// UNDECORATED, ALWAYS. The enforcement disclosure is carried separately as typed
|
||||
// notices and rendered by the card as its own furniture, so a body that already
|
||||
// had the notice composed into it drew the warning twice: once in the notice
|
||||
// lines and once at the top of the output. A rich preview never carried it, so
|
||||
// only the no-preview results (every bash and exec card, and every error) were
|
||||
// wrong, which is exactly the shape a preview-only test cannot see.
|
||||
//
|
||||
// One owner for composition: this returns the base text, and whoever presents it
|
||||
// decorates once. Provider-facing text still goes through ModelOutput.
|
||||
func (result ToolResult) CardBody() string {
|
||||
display := result.BaseDisplay()
|
||||
if strings.TrimSpace(display.Preview) != "" && (result.Status != tools.StatusError || result.Outcome.Finalized()) {
|
||||
return display.Preview
|
||||
}
|
||||
return result.BaseModelOutput()
|
||||
}
|
||||
|
||||
// ToolResultSessionPayload is THE serialization of a tool result into a session
|
||||
// event, shared by every writer: the TUI, headless exec, and the ACP agent.
|
||||
//
|
||||
// It lives here, beside ToolResult, because it has three callers and only one of
|
||||
// them is a terminal. The writers used to spell the payload separately, and each
|
||||
// time one of them was left behind the same thing went missing on reload. The
|
||||
// headless writer persisted only the decorated ModelOutput, so a CLI-written
|
||||
// result restored into the TUI arrived with no typed notices and no undecorated
|
||||
// body. The ACP writer persisted the raw Output field, which after the
|
||||
// output/notice split is the UNDECORATED text, so an ACP client that loaded a
|
||||
// session saw every sandboxed result with its disclosure gone, although the
|
||||
// live update for the same call had shown it. All of them write to the same
|
||||
// session store, so a session written by one surface is restored by another.
|
||||
// One owner for the contract means one place where a field can go missing, and a
|
||||
// test against this function covers every writer.
|
||||
//
|
||||
// output is the provider-facing text with the disclosure composed in.
|
||||
// displayPreview is the undecorated card body, written whenever it differs from
|
||||
// output. A result carrying enforcement notices ALWAYS differs, because output
|
||||
// is decorated and the card body is not, so the undecorated body is written
|
||||
// even when it is empty. Readers key on the field being PRESENT rather than
|
||||
// non-empty for exactly that case: a command that printed nothing under an
|
||||
// enforced profile has an empty body and a real notice, and falling back to
|
||||
// output there would restore the decorated text and draw the notice twice.
|
||||
func ToolResultSessionPayload(result ToolResult) map[string]any {
|
||||
output := result.ModelOutput()
|
||||
payload := map[string]any{
|
||||
"toolCallId": result.ToolCallID,
|
||||
"name": result.Name,
|
||||
"status": string(result.Status),
|
||||
"output": output,
|
||||
}
|
||||
if preview := result.CardBody(); preview != output {
|
||||
payload["displayPreview"] = preview
|
||||
}
|
||||
if result.Truncated {
|
||||
payload["truncated"] = true
|
||||
}
|
||||
if result.Redacted {
|
||||
payload["redacted"] = true
|
||||
}
|
||||
if len(result.Meta) > 0 {
|
||||
payload["meta"] = result.Meta
|
||||
}
|
||||
if len(result.EnforcementNotices) > 0 {
|
||||
payload["enforcementNotices"] = result.EnforcementNotices
|
||||
}
|
||||
if len(result.ChangedFiles) > 0 {
|
||||
payload["changedFiles"] = result.ChangedFiles
|
||||
}
|
||||
if len(result.ChangeSummaries) > 0 {
|
||||
payload["changeSummaries"] = result.ChangeSummaries
|
||||
}
|
||||
return payload
|
||||
}
|
||||
+28
-6
@@ -79,6 +79,10 @@ type ToolResult struct {
|
||||
// The full result may be recoverable through Meta["spill_path"].
|
||||
Truncated bool
|
||||
Meta map[string]string
|
||||
// EnforcementNotices mirrors tools.Result.EnforcementNotices so the
|
||||
// disclosure survives the conversion into the agent-facing result and
|
||||
// reaches the model, the transcript and the interactive display.
|
||||
EnforcementNotices []string
|
||||
// Images the tool produced, delivered to the model as a following user
|
||||
// message rather than on this result. See tools.Result.Images.
|
||||
Images []zeroruntime.ImageBlock
|
||||
@@ -110,24 +114,42 @@ type ToolResult struct {
|
||||
RequestedModel string
|
||||
}
|
||||
|
||||
// ModelOutput returns the bounded provider-facing result while preserving
|
||||
// compatibility with synthetic and restored results created before outcomes
|
||||
// were finalized.
|
||||
func (result ToolResult) ModelOutput() string {
|
||||
// BaseModelOutput is the bounded provider-facing result WITHOUT the enforcement
|
||||
// disclosure composed into it, mirroring tools.Result.BaseModelOutput.
|
||||
//
|
||||
// A surface that renders the typed EnforcementNotices itself must build its body
|
||||
// from here, or the disclosure appears twice. Decoration has exactly one owner
|
||||
// per surface: either the text carries it or the surface draws it, never both.
|
||||
func (result ToolResult) BaseModelOutput() string {
|
||||
if result.Outcome.Finalized() {
|
||||
return result.Outcome.ModelView
|
||||
}
|
||||
return result.Output
|
||||
}
|
||||
|
||||
// HumanDisplay returns the presentation intended for interactive surfaces.
|
||||
func (result ToolResult) HumanDisplay() tools.Display {
|
||||
// BaseDisplay is BaseModelOutput's presentation half, and carries no enforcement
|
||||
// notices for the same reason.
|
||||
func (result ToolResult) BaseDisplay() tools.Display {
|
||||
if result.Outcome.Finalized() {
|
||||
return result.Outcome.HumanView
|
||||
}
|
||||
return result.Display
|
||||
}
|
||||
|
||||
// ModelOutput returns the bounded provider-facing result while preserving
|
||||
// compatibility with synthetic and restored results created before outcomes
|
||||
// were finalized.
|
||||
func (result ToolResult) ModelOutput() string {
|
||||
return tools.WithEnforcementNotices(result.BaseModelOutput(), result.EnforcementNotices)
|
||||
}
|
||||
|
||||
// HumanDisplay returns the presentation intended for interactive surfaces.
|
||||
func (result ToolResult) HumanDisplay() tools.Display {
|
||||
display := result.BaseDisplay()
|
||||
display.Summary = tools.WithEnforcementNotices(display.Summary, result.EnforcementNotices)
|
||||
return display
|
||||
}
|
||||
|
||||
// DenialCategory classifies why a tool call was blocked before it executed.
|
||||
type DenialCategory string
|
||||
|
||||
|
||||
@@ -874,6 +874,19 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
|
||||
}
|
||||
fmt.Fprintf(stderr, "warning: MCP server %s unavailable, skipped: %s\n", skipped.Name, redaction.ErrorMessage(skipped.Err, redaction.Options{}))
|
||||
}
|
||||
// AND WHAT THE SERVERS THAT DID START RAN UNDER. A stdio MCP server prepared
|
||||
// with a weakened write jail serves the whole session from that process, so
|
||||
// the disclosure is about startup and no later tool result can carry it. Said
|
||||
// once, here, next to the skip warnings, rather than pasted onto every
|
||||
// response the server produces. Network servers launch no local process and
|
||||
// report nothing, which is why the optional background registration is not
|
||||
// asked: its only member is the built-in HTTP default, which starts no local
|
||||
// process. A stdio default would need this statement from that path too.
|
||||
// NOT deferred: stderr here is the bare terminal, and the TUI takes it over at
|
||||
// deps.runTUI below. Delivery stops before that hand-off, so a late launch can
|
||||
// never write raw text into the alt screen; see stopMCPDisclosures's call site.
|
||||
guardedStderr, stopMCPDisclosures := reportMCPStartupDisclosures(stderr, mcpRuntime)
|
||||
stderr = guardedStderr
|
||||
// Make local plugins live: register their declared tools into the registry and
|
||||
// collect their hooks + skill roots for the dispatcher and skill tool below.
|
||||
// Done after specialist + MCP registration so plugin tools are part of the
|
||||
@@ -969,6 +982,10 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
|
||||
// describes the sandbox they actually get.
|
||||
startupNotices := []string{sandboxEngine.DegradedNotice()}
|
||||
modelRPM := zeroruntime.NewModelRPMLimiter(resolved.ModelRPM)
|
||||
// The terminal stops being ours on the next line. Stop and join the disclosure
|
||||
// pump first: anything already queued is printed here, on this goroutine, and a
|
||||
// launch that resolves later is dropped rather than written raw over the TUI.
|
||||
stopMCPDisclosures()
|
||||
return deps.runTUI(context.Background(), tui.Options{
|
||||
ModelRPM: modelRPM,
|
||||
Cwd: workspaceRoot,
|
||||
|
||||
+38
-19
@@ -365,6 +365,18 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
|
||||
return writeExecProviderError(stdout, stderr, options.outputFormat, "mcp_error", err.Error())
|
||||
}
|
||||
defer closeMCPRuntime(stderr, mcpRuntime)
|
||||
// Said HERE, before --list-tools and before the first result, because both
|
||||
// return early and the process this describes is already running by now. On
|
||||
// stderr, so text, JSON and stream-JSON framing on stdout are untouched:
|
||||
// this is the same channel the skipped-server and trust notices use.
|
||||
// Deferred AFTER closeMCPRuntime was deferred, so it runs BEFORE it: the
|
||||
// pump stops and joins while stderr is still ours, and only then are the
|
||||
// clients closed.
|
||||
// Adopt the guarded writer for the rest of startup: the pump is live from
|
||||
// here until stop, and everything below writes to this same stderr.
|
||||
guardedStderr, stopDisclosures := reportMCPStartupDisclosures(stderr, mcpRuntime)
|
||||
stderr = guardedStderr
|
||||
defer stopDisclosures()
|
||||
}
|
||||
pluginActivation = activatePlugins(workspaceRoot, registry, deps, stderr, trustRoot, executionRunner)
|
||||
registerLocalControlTools(registry, workspaceRoot, resolved.LocalControl)
|
||||
@@ -722,25 +734,7 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
|
||||
},
|
||||
OnToolResult: func(result agent.ToolResult) {
|
||||
writer.toolResult(result)
|
||||
payload := map[string]any{
|
||||
"toolCallId": result.ToolCallID,
|
||||
"name": result.Name,
|
||||
"status": string(result.Status),
|
||||
"output": result.Output,
|
||||
}
|
||||
if len(result.Meta) > 0 {
|
||||
payload["meta"] = result.Meta
|
||||
}
|
||||
if result.Truncated {
|
||||
payload["truncated"] = true
|
||||
}
|
||||
if result.Redacted {
|
||||
payload["redacted"] = true
|
||||
}
|
||||
if len(result.ChangedFiles) > 0 {
|
||||
payload["changedFiles"] = result.ChangedFiles
|
||||
}
|
||||
sessionRecorder.append(sessions.EventToolResult, payload)
|
||||
sessionRecorder.append(sessions.EventToolResult, persistedToolResultPayload(result))
|
||||
},
|
||||
OnUsage: func(u agent.Usage) {
|
||||
writer.usage(u)
|
||||
@@ -1504,3 +1498,28 @@ func writeTraceSnapshot(snapshot *trace.TurnTrace, dest string, stderr io.Writer
|
||||
defer file.Close()
|
||||
return trace.WriteNDJSON(file, snapshot)
|
||||
}
|
||||
|
||||
// persistedToolResultPayload renders one tool result for the durable session
|
||||
// log.
|
||||
//
|
||||
// IT USES THE ACCESSOR, NOT THE RAW FIELD. agent.ToolResult stores the
|
||||
// undecorated model text alongside the typed enforcement notices, and
|
||||
// ModelOutput is what composes the two. Replay reads this payload straight back
|
||||
// into the transcript without reconstructing a ToolResult, so a disclosure that
|
||||
// is not rendered here is simply absent from resumed and compacted context even
|
||||
// though it was visible during the original run.
|
||||
//
|
||||
// Both headless writers go through this, because they previously spelled the
|
||||
// same payload separately and had already drifted: one persisted the raw field
|
||||
// while the stream writer used the accessor.
|
||||
func persistedToolResultPayload(result agent.ToolResult) map[string]any {
|
||||
// ONE CONTRACT WITH THE TUI. This used to build its own payload with the
|
||||
// decorated output only, and both writers append to the same default
|
||||
// session store the TUI resumes from. A CLI-written result restored into
|
||||
// the TUI therefore arrived without typed enforcement notices and without
|
||||
// the undecorated card body, so a long collapsed result rendered no body
|
||||
// and, with it, no disclosure. internal/agent owns the shape now, beside
|
||||
// ToolResult, and every writer calls the same function rather than keeping
|
||||
// a matching copy of it.
|
||||
return agent.ToolResultSessionPayload(result)
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Twigpine/zero/internal/agent"
|
||||
"github.com/Twigpine/zero/internal/tools"
|
||||
)
|
||||
|
||||
// THE HEADLESS WRITER PERSISTS THE SAME SHAPE THE TUI DOES.
|
||||
//
|
||||
// The tui-side restore test proves the shared payload restores a disclosure
|
||||
// exactly once; this proves the CLI actually WRITES that shared payload rather
|
||||
// than its own. The two used to be spelled separately and the headless one had
|
||||
// already drifted to decorated output only. Reverting the delegation leaves
|
||||
// the tui test green and fails this one, which is the point of having both.
|
||||
func TestHeadlessPayloadCarriesTypedNoticesAndUndecoratedBody(t *testing.T) {
|
||||
const notice = "denyRead is configured, so the Windows sandbox uses the token shape without WRITE_RESTRICTED (#869)"
|
||||
result := agent.ToolResult{
|
||||
ToolCallID: "call-cli",
|
||||
Name: "bash",
|
||||
Status: tools.StatusOK,
|
||||
Output: "PROBE-BODY",
|
||||
Truncated: true,
|
||||
EnforcementNotices: []string{notice},
|
||||
}
|
||||
payload := persistedToolResultPayload(result)
|
||||
|
||||
notices, _ := payload["enforcementNotices"].([]string)
|
||||
if len(notices) != 1 || notices[0] != notice {
|
||||
t.Errorf("headless payload does not carry the typed notice: %#v", payload["enforcementNotices"])
|
||||
}
|
||||
preview, _ := payload["displayPreview"].(string)
|
||||
if preview != "PROBE-BODY" {
|
||||
t.Errorf("headless payload does not carry the undecorated body as displayPreview: %q", preview)
|
||||
}
|
||||
output, _ := payload["output"].(string)
|
||||
if !strings.Contains(output, notice) || !strings.Contains(output, "PROBE-BODY") {
|
||||
t.Errorf("provider-facing output is no longer the decorated text: %q", output)
|
||||
}
|
||||
// The one field the headless writer added on its own must survive the
|
||||
// delegation, or a truncation marker silently stops being persisted.
|
||||
if truncated, _ := payload["truncated"].(bool); !truncated {
|
||||
t.Errorf("truncated flag was lost in the shared payload: %#v", payload["truncated"])
|
||||
}
|
||||
}
|
||||
@@ -161,22 +161,7 @@ func runExecSpecDraft(run execSpecDraftRun) int {
|
||||
if info, ok := execSpecDraftInfoFromToolResult(result); ok {
|
||||
draftInfo = info
|
||||
}
|
||||
payload := map[string]any{
|
||||
"toolCallId": result.ToolCallID,
|
||||
"name": result.Name,
|
||||
"status": string(result.Status),
|
||||
"output": result.Output,
|
||||
}
|
||||
if len(result.Meta) > 0 {
|
||||
payload["meta"] = result.Meta
|
||||
}
|
||||
if result.Redacted {
|
||||
payload["redacted"] = true
|
||||
}
|
||||
if len(result.ChangedFiles) > 0 {
|
||||
payload["changedFiles"] = result.ChangedFiles
|
||||
}
|
||||
sessionRecorder.append(sessions.EventToolResult, payload)
|
||||
sessionRecorder.append(sessions.EventToolResult, persistedToolResultPayload(result))
|
||||
},
|
||||
OnUsage: func(u agent.Usage) {
|
||||
writer.usage(u)
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Twigpine/zero/internal/config"
|
||||
"github.com/Twigpine/zero/internal/mcp"
|
||||
"github.com/Twigpine/zero/internal/tools"
|
||||
)
|
||||
|
||||
// disclosingExecRuntime is an MCP runtime that launched a process under reduced
|
||||
// enforcement.
|
||||
type disclosingExecRuntime struct {
|
||||
noopMCPRuntime
|
||||
disclosures []mcp.StartupDisclosure
|
||||
}
|
||||
|
||||
func (r disclosingExecRuntime) StartupDisclosures() []mcp.StartupDisclosure { return r.disclosures }
|
||||
|
||||
const execDisclosureNotice = "denyRead is configured, so the write jail is not confining writes"
|
||||
|
||||
// isolateConfigDirs points every config/cache root at test-owned storage.
|
||||
//
|
||||
// Without it these tests build a sandbox engine against the developer's REAL
|
||||
// config dir, trigger the one-time grant migration there, and the migration
|
||||
// notice then turns up on a LATER test's stderr, failing whichever test happens
|
||||
// to assert an empty one. The failure moves between runs, which is what makes it
|
||||
// look like flakiness rather than contamination.
|
||||
func isolateConfigDirs(t *testing.T) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
t.Setenv("HOME", dir)
|
||||
t.Setenv("USERPROFILE", dir)
|
||||
t.Setenv("APPDATA", dir)
|
||||
t.Setenv("LOCALAPPDATA", dir)
|
||||
t.Setenv("XDG_CONFIG_HOME", dir)
|
||||
t.Setenv("XDG_CACHE_HOME", dir)
|
||||
}
|
||||
|
||||
func execDisclosureDeps(cwd string) appDeps {
|
||||
return appDeps{
|
||||
getwd: func() (string, error) { return cwd, nil },
|
||||
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) { return execResolvedConfig(), nil },
|
||||
resolveMCPConfig: func(string, bool) (config.MCPConfig, error) {
|
||||
return config.MCPConfig{Servers: map[string]config.MCPServerConfig{
|
||||
"docs": {Type: "stdio", Command: "docs-mcp"},
|
||||
}}, nil
|
||||
},
|
||||
registerMCPTools: func(context.Context, *tools.Registry, config.MCPConfig, mcp.RegisterOptions) (mcpToolRuntime, error) {
|
||||
return disclosingExecRuntime{disclosures: []mcp.StartupDisclosure{
|
||||
{Name: "docs", Notices: []string{execDisclosureNotice}},
|
||||
}}, nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// A HEADLESS RUN IS A DISCLOSURE SURFACE TOO.
|
||||
//
|
||||
// `zero exec` registers workspace MCP servers through the same sandbox-backed
|
||||
// runner interactive startup uses, so a stdio server here can launch under the
|
||||
// weakened token and serve the whole run. Reporting the disclosure only from the
|
||||
// TUI meant every text, JSON, stream-JSON and --list-tools caller was told
|
||||
// nothing about the enforcement trade, for a process that was already running.
|
||||
func TestExecReportsMCPStartupDisclosures(t *testing.T) {
|
||||
isolateConfigDirs(t)
|
||||
for _, format := range []string{"", "--output-format=json", "--output-format=stream-json"} {
|
||||
name := format
|
||||
if name == "" {
|
||||
name = "text"
|
||||
}
|
||||
t.Run(name, func(t *testing.T) {
|
||||
args := []string{"exec", "--list-tools"}
|
||||
if format != "" {
|
||||
args = append(args, format)
|
||||
}
|
||||
var stdout, stderr bytes.Buffer
|
||||
if code := runWithDeps(args, &stdout, &stderr, execDisclosureDeps(t.TempDir())); code != exitSuccess {
|
||||
t.Fatalf("exit = %d, stderr = %s", code, stderr.String())
|
||||
}
|
||||
if !strings.Contains(stderr.String(), execDisclosureNotice) {
|
||||
t.Errorf("the headless run said nothing about the enforcement trade: %q", stderr.String())
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "docs") {
|
||||
t.Errorf("the report does not name the server: %q", stderr.String())
|
||||
}
|
||||
// The machine-readable surfaces must stay parseable: the disclosure
|
||||
// belongs on stderr precisely so stdout framing is untouched.
|
||||
if format == "--output-format=json" {
|
||||
var any map[string]any
|
||||
if err := json.Unmarshal(bytes.TrimSpace(stdout.Bytes()), &any); err != nil {
|
||||
t.Errorf("stdout is no longer valid JSON: %v (%q)", err, stdout.String())
|
||||
}
|
||||
}
|
||||
if format == "--output-format=stream-json" {
|
||||
for _, line := range strings.Split(strings.TrimSpace(stdout.String()), "\n") {
|
||||
if strings.TrimSpace(line) == "" {
|
||||
continue
|
||||
}
|
||||
var any map[string]any
|
||||
if err := json.Unmarshal([]byte(line), &any); err != nil {
|
||||
t.Errorf("a stream-json line is not valid JSON: %v (%q)", err, line)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A run whose servers launched nothing says nothing.
|
||||
func TestExecWithoutDisclosuresStaysQuiet(t *testing.T) {
|
||||
isolateConfigDirs(t)
|
||||
deps := execDisclosureDeps(t.TempDir())
|
||||
deps.registerMCPTools = func(context.Context, *tools.Registry, config.MCPConfig, mcp.RegisterOptions) (mcpToolRuntime, error) {
|
||||
return noopMCPRuntime{}, nil
|
||||
}
|
||||
var stdout, stderr bytes.Buffer
|
||||
if code := runWithDeps([]string{"exec", "--list-tools"}, &stdout, &stderr, deps); code != exitSuccess {
|
||||
t.Fatalf("exit = %d, stderr = %s", code, stderr.String())
|
||||
}
|
||||
if strings.Contains(stderr.String(), "reduced enforcement") {
|
||||
t.Errorf("a run with nothing to disclose printed one: %q", stderr.String())
|
||||
}
|
||||
}
|
||||
@@ -54,10 +54,10 @@ func TestRunExecImageAttachmentHistory(t *testing.T) {
|
||||
{name: "stream and file images", model: "gpt-4.1", fileImage: true},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
for _, name := range []string{"HOME", "USERPROFILE", "APPDATA", "LOCALAPPDATA", "XDG_CONFIG_HOME", "XDG_CACHE_HOME", "XDG_DATA_HOME", "XDG_STATE_HOME"} {
|
||||
t.Setenv(name, root)
|
||||
}
|
||||
// The shared isolation also turns off the models.dev refresh that
|
||||
// runExec starts in the background. Without it, the refresh downloads
|
||||
// the catalog into this HOME and races the TempDir cleanup.
|
||||
isolateCLIUserState(t)
|
||||
cwd := t.TempDir()
|
||||
png := []byte{0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 0x0d, 0x49, 0x48, 0x44, 0x52}
|
||||
encoded := base64.StdEncoding.EncodeToString(png)
|
||||
@@ -148,6 +148,7 @@ func TestRunExecImageAttachmentHistory(t *testing.T) {
|
||||
// user turn). Before the fix, stream-json images were parsed and validated but
|
||||
// silently dropped — never threaded into the agent run.
|
||||
func TestRunExecStreamJSONImageReachesAgent(t *testing.T) {
|
||||
isolateCLIUserState(t)
|
||||
cwd := t.TempDir()
|
||||
|
||||
// A minimal PNG, base64-encoded for the stream-json image payload.
|
||||
@@ -217,6 +218,7 @@ func TestRunExecStreamJSONImageReachesAgent(t *testing.T) {
|
||||
// the fix, ResolvePrompt's "must include at least one prompt or user message
|
||||
// event" error was returned before images were considered, rejecting the turn.
|
||||
func TestRunExecStreamJSONImageOnlyMessageProceeds(t *testing.T) {
|
||||
isolateCLIUserState(t)
|
||||
cwd := t.TempDir()
|
||||
|
||||
png := []byte{
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Twigpine/zero/internal/config"
|
||||
"github.com/Twigpine/zero/internal/mcp"
|
||||
"github.com/Twigpine/zero/internal/tools"
|
||||
)
|
||||
|
||||
// A LAUNCH THAT COMPLETES AFTER THE REPORTER HAS RUN MUST STILL BE SAID, ONCE,
|
||||
// AND ONLY WHILE SOMEONE OWNS THE WRITER.
|
||||
//
|
||||
// Both production paths call reportMCPStartupDisclosures exactly once, right
|
||||
// after RegisterTools returns. A stdio attempt abandoned at the connect timeout
|
||||
// can still be inside cmd.Start at that moment; the process then starts under
|
||||
// the reduced write confinement, the reaper closes its client, and a reporter
|
||||
// that merely SAMPLED the runtime has already come and gone. The retained sink
|
||||
// held the fact and nobody read it again, so the operator saw the skipped
|
||||
// server and never the disclosure.
|
||||
//
|
||||
// This drives the REAL reporter against a REAL runtime, rather than polling
|
||||
// StartupDisclosures by hand, which is what an earlier regression did and which
|
||||
// is precisely how it masked the original bug: a test that re-reads on the
|
||||
// tester's behalf proves nothing about a production path that does not.
|
||||
//
|
||||
// It also never reads stderr while the pump could write. The buffer is examined
|
||||
// only after stop has joined the pump, which is the same discipline both
|
||||
// production callers follow, and is why this passes under -race.
|
||||
func TestLateMCPLaunchReachesTheStartupReporterExactlyOnce(t *testing.T) {
|
||||
const notice = "MCP server started without WRITE_RESTRICTED because denyRead is configured (#869)"
|
||||
released := make(chan struct{})
|
||||
published := make(chan struct{})
|
||||
|
||||
runtime, err := mcp.RegisterTools(context.Background(), tools.NewRegistry(),
|
||||
config.MCPConfig{Servers: map[string]config.MCPServerConfig{
|
||||
"slow": {Type: "stdio", Command: "slow-mcp"},
|
||||
}},
|
||||
mcp.RegisterOptions{
|
||||
ConnectTimeout: 50 * time.Millisecond,
|
||||
ClientFactory: func(ctx context.Context, server mcp.Server) (mcp.ToolClient, error) {
|
||||
// Held past the registration timeout AND past the settle grace, so
|
||||
// registration has already reaped this attempt and returned.
|
||||
<-released
|
||||
mcp.PublishLaunchForTest(ctx, []string{notice})
|
||||
// Publishing is synchronous into the stream, so by the time this
|
||||
// closes the disclosure is queued and stop cannot race past it.
|
||||
close(published)
|
||||
return nil, errors.New("initialize failed long after start")
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = runtime.Close() })
|
||||
|
||||
// The reporter runs ONCE, here, exactly as runExec and the interactive
|
||||
// startup path run it: before the launch has resolved.
|
||||
var stderr bytes.Buffer
|
||||
_, stop := reportMCPStartupDisclosures(&stderr, runtime)
|
||||
|
||||
close(released)
|
||||
<-published
|
||||
// The owner ends delivery and joins the pump. Every write to the buffer has
|
||||
// happened by the time this returns, so the reads below are unsynchronised
|
||||
// only because there is no longer anything to synchronise with.
|
||||
stop()
|
||||
|
||||
got := stderr.String()
|
||||
if n := strings.Count(got, notice); n != 1 {
|
||||
t.Fatalf("a launch that completed after the reporter ran was disclosed %d time(s), want exactly 1:\n%s", n, got)
|
||||
}
|
||||
if !strings.Contains(got, "MCP server slow started with reduced enforcement") {
|
||||
t.Errorf("the late disclosure does not name the server:\n%s", got)
|
||||
}
|
||||
if skipped := runtime.Skipped(); len(skipped) != 1 {
|
||||
t.Errorf("the server should still be recorded as skipped: %#v", skipped)
|
||||
}
|
||||
}
|
||||
|
||||
// And a server whose launch was already known when the reporter ran is said
|
||||
// once by it, and not again by the late path.
|
||||
func TestKnownMCPLaunchIsNotReportedTwice(t *testing.T) {
|
||||
const notice = "MCP server started under reduced enforcement"
|
||||
runtime, err := mcp.RegisterTools(context.Background(), tools.NewRegistry(),
|
||||
config.MCPConfig{Servers: map[string]config.MCPServerConfig{
|
||||
"fast": {Type: "stdio", Command: "fast-mcp"},
|
||||
}},
|
||||
mcp.RegisterOptions{
|
||||
ConnectTimeout: time.Second,
|
||||
ClientFactory: func(ctx context.Context, server mcp.Server) (mcp.ToolClient, error) {
|
||||
mcp.PublishLaunchForTest(ctx, []string{notice})
|
||||
return nil, errors.New("initialize failed after start")
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = runtime.Close() })
|
||||
|
||||
var stderr bytes.Buffer
|
||||
_, stop := reportMCPStartupDisclosures(&stderr, runtime)
|
||||
stop()
|
||||
if n := strings.Count(stderr.String(), notice); n != 1 {
|
||||
t.Fatalf("a launch known at registration was disclosed %d time(s), want exactly 1:\n%s", n, stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
// THE OWNERSHIP BOUNDARY ITSELF: once the caller has stopped delivery, nothing
|
||||
// may write to its writer again.
|
||||
//
|
||||
// This is the property that the retained presentation callback could not hold.
|
||||
// It invoked the CLI's print function from the abandoned connect goroutine
|
||||
// whenever the launch happened to resolve, so a write could land after runExec
|
||||
// had returned or after Bubble Tea had taken the alt screen. The interactive
|
||||
// path stops delivery on the line before it hands over the terminal, and this
|
||||
// pins what that buys: a launch resolving afterwards is dropped, not printed.
|
||||
func TestMCPDisclosureAfterStopIsDroppedNotWritten(t *testing.T) {
|
||||
const notice = "MCP server started under reduced enforcement"
|
||||
released := make(chan struct{})
|
||||
published := make(chan struct{})
|
||||
|
||||
runtime, err := mcp.RegisterTools(context.Background(), tools.NewRegistry(),
|
||||
config.MCPConfig{Servers: map[string]config.MCPServerConfig{
|
||||
"slow": {Type: "stdio", Command: "slow-mcp"},
|
||||
}},
|
||||
mcp.RegisterOptions{
|
||||
ConnectTimeout: 50 * time.Millisecond,
|
||||
ClientFactory: func(ctx context.Context, server mcp.Server) (mcp.ToolClient, error) {
|
||||
<-released
|
||||
mcp.PublishLaunchForTest(ctx, []string{notice})
|
||||
close(published)
|
||||
return nil, errors.New("initialize failed long after start")
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = runtime.Close() })
|
||||
|
||||
var stderr bytes.Buffer
|
||||
_, stop := reportMCPStartupDisclosures(&stderr, runtime)
|
||||
// The owner gives up the writer BEFORE the launch resolves, which is the
|
||||
// interactive hand-off to the TUI.
|
||||
stop()
|
||||
|
||||
close(released)
|
||||
<-published
|
||||
// Asserting an absence, so the wrong behaviour is given time to appear: once
|
||||
// publishing has returned the disclosure is queued, and a delivery path that
|
||||
// outlived stop would have this long to print it. With delivery ended and the
|
||||
// pump joined there is no writer left, so this window changes nothing.
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
if got := stderr.String(); got != "" {
|
||||
t.Fatalf("a launch that resolved after the owner stopped still wrote to its writer: %q", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Twigpine/zero/internal/mcp"
|
||||
)
|
||||
|
||||
type disclosingRuntime struct {
|
||||
noopMCPRuntime
|
||||
disclosures []mcp.StartupDisclosure
|
||||
}
|
||||
|
||||
func (runtime disclosingRuntime) StartupDisclosures() []mcp.StartupDisclosure {
|
||||
return runtime.disclosures
|
||||
}
|
||||
|
||||
// SAID ONCE, WHERE THE USER IS ALREADY BEING TOLD WHAT STARTED.
|
||||
//
|
||||
// The disclosure describes a server PROCESS, which serves the whole session, so
|
||||
// it cannot ride on a tool result and must not be repeated on every one.
|
||||
func TestStartupDisclosuresAreReportedOnce(t *testing.T) {
|
||||
const notice = "denyRead is configured, so the write jail is not confining writes"
|
||||
var stderr bytes.Buffer
|
||||
reportMCPStartupDisclosures(&stderr, disclosingRuntime{
|
||||
disclosures: []mcp.StartupDisclosure{{Name: "docs", Notices: []string{notice}}},
|
||||
})
|
||||
output := stderr.String()
|
||||
if count := strings.Count(output, notice); count != 1 {
|
||||
t.Errorf("the disclosure appears %d times, want exactly 1: %q", count, output)
|
||||
}
|
||||
if !strings.Contains(output, "docs") {
|
||||
t.Errorf("the report does not name the server it is about: %q", output)
|
||||
}
|
||||
}
|
||||
|
||||
// A run with nothing to disclose prints nothing at all.
|
||||
func TestNoStartupDisclosuresPrintNothing(t *testing.T) {
|
||||
var stderr bytes.Buffer
|
||||
reportMCPStartupDisclosures(&stderr, disclosingRuntime{})
|
||||
if stderr.Len() != 0 {
|
||||
t.Errorf("a run with no disclosure wrote %q", stderr.String())
|
||||
}
|
||||
stderr.Reset()
|
||||
// And a runtime that launches nothing is not required to answer.
|
||||
reportMCPStartupDisclosures(&stderr, noopMCPRuntime{})
|
||||
if stderr.Len() != 0 {
|
||||
t.Errorf("a runtime that launches nothing wrote %q", stderr.String())
|
||||
}
|
||||
}
|
||||
@@ -3,9 +3,11 @@ package cli
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/Twigpine/zero/internal/config"
|
||||
"github.com/Twigpine/zero/internal/execution"
|
||||
@@ -190,3 +192,107 @@ func isSensitiveMCPDisplayKey(key string) bool {
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// mcpStartupDisclosing is the optional interface a runtime implements when it
|
||||
// can report what its launched server processes ran under. Optional rather than
|
||||
// part of mcpToolRuntime so a runtime that launches nothing, and every test
|
||||
// double, stays unchanged.
|
||||
type mcpStartupDisclosing interface {
|
||||
StartupDisclosures() []mcp.StartupDisclosure
|
||||
}
|
||||
|
||||
// mcpStartupStreaming is the push form: the runtime queues each disclosure as a
|
||||
// typed event, including a launch that completes after registration returned,
|
||||
// and this package drains it on the goroutine that owns stderr.
|
||||
type mcpStartupStreaming interface {
|
||||
StartupDisclosureStream() *mcp.StartupDisclosureStream
|
||||
}
|
||||
|
||||
// reportMCPStartupDisclosures states once what enforcement applied to the MCP
|
||||
// server processes this run launched.
|
||||
//
|
||||
// A PUSH, NOT A SAMPLE. This used to read StartupDisclosures once, here, and a
|
||||
// stdio attempt abandoned at the connect timeout could still be inside cmd.Start
|
||||
// at that moment. The process then started under the reduced write confinement,
|
||||
// the reaper closed its client, and nothing read the runtime again: the operator
|
||||
// saw the skipped-server warning and never the disclosure.
|
||||
//
|
||||
// THIS GOROUTINE OWNS THE WRITER. The runtime queues typed disclosures; every
|
||||
// write to stderr happens either on the caller's goroutine (the set already known
|
||||
// when this returns, in server order, so startup output keeps its order) or on
|
||||
// the single pump started here, never both at once and never after stop.
|
||||
//
|
||||
// The returned stop ends delivery and joins the pump, so no write to stderr can
|
||||
// outlive the caller's ownership of it. The caller must run it before handing the
|
||||
// terminal to anything else. A disclosure that arrives after stop is dropped: it
|
||||
// is worth printing while someone owns the writer, and worth losing rather than
|
||||
// writing into a screen that now belongs to Bubble Tea. Anything already queued
|
||||
// when stop runs is still printed, on the caller's goroutine, with the pump
|
||||
// already finished.
|
||||
//
|
||||
// ONE WRITER, ONE CALLER AT A TIME. Joining the pump stops writes after its
|
||||
// lifetime but does nothing about the overlap: startup keeps emitting plugin,
|
||||
// trust, peer, provider and validation output to the same writer while the pump
|
||||
// is live. That is unsafe for an ordinary bytes.Buffer and interleaves lines even
|
||||
// on a writer that tolerates concurrent calls. A mutex private to the pump would
|
||||
// not help, because the other writes do not go through it. So the returned writer
|
||||
// is a guarded view of the caller's, and the caller adopts it for the rest of
|
||||
// startup; both sides then take the same lock.
|
||||
//
|
||||
// The pull form is kept for a runtime that implements no stream, which today is
|
||||
// only test doubles; it has no late launches to deliver, so its stop is a no-op
|
||||
// and its writer is handed back unchanged.
|
||||
func reportMCPStartupDisclosures(stderr io.Writer, runtime mcpToolRuntime) (guarded io.Writer, stop func()) {
|
||||
serialized := &serializedWriter{writer: stderr}
|
||||
print := func(disclosure mcp.StartupDisclosure) {
|
||||
for _, notice := range disclosure.Notices {
|
||||
fmt.Fprintf(serialized, "notice: MCP server %s started with reduced enforcement: %s\n", disclosure.Name, notice)
|
||||
}
|
||||
}
|
||||
printAll := func(disclosures []mcp.StartupDisclosure) {
|
||||
for _, disclosure := range disclosures {
|
||||
print(disclosure)
|
||||
}
|
||||
}
|
||||
streaming, ok := runtime.(mcpStartupStreaming)
|
||||
if !ok {
|
||||
if disclosing, ok := runtime.(mcpStartupDisclosing); ok {
|
||||
printAll(disclosing.StartupDisclosures())
|
||||
}
|
||||
return stderr, func() {}
|
||||
}
|
||||
stream := streaming.StartupDisclosureStream()
|
||||
if stream == nil {
|
||||
return stderr, func() {}
|
||||
}
|
||||
printAll(stream.Drain())
|
||||
pumped := make(chan struct{})
|
||||
go func() {
|
||||
defer close(pumped)
|
||||
for stream.Wait() {
|
||||
printAll(stream.Drain())
|
||||
}
|
||||
}()
|
||||
var once sync.Once
|
||||
return serialized, func() {
|
||||
once.Do(func() {
|
||||
stream.Close()
|
||||
<-pumped
|
||||
printAll(stream.Drain())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// serializedWriter gives one underlying writer a single owner at a time, so the
|
||||
// late-disclosure pump and the foreground startup path cannot be inside it
|
||||
// together.
|
||||
type serializedWriter struct {
|
||||
mu sync.Mutex
|
||||
writer io.Writer
|
||||
}
|
||||
|
||||
func (w *serializedWriter) Write(data []byte) (int, error) {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
return w.writer.Write(data)
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Twigpine/zero/internal/config"
|
||||
"github.com/Twigpine/zero/internal/mcp"
|
||||
"github.com/Twigpine/zero/internal/tools"
|
||||
)
|
||||
|
||||
// blockingWriter makes the overlap deterministic instead of hoping to hit it.
|
||||
//
|
||||
// The first write parks inside Write until the test releases it, which is the
|
||||
// window the pump and the foreground startup path really share: startup keeps
|
||||
// emitting plugin, trust, peer and provider output to the same stderr while a
|
||||
// late MCP disclosure can arrive at any moment. It also records whether it was
|
||||
// ever entered twice at once, which is the property under test.
|
||||
type blockingWriter struct {
|
||||
mu sync.Mutex
|
||||
inside int
|
||||
overlaps int
|
||||
buf bytes.Buffer
|
||||
|
||||
block chan struct{}
|
||||
blockOne sync.Once
|
||||
entered chan struct{}
|
||||
}
|
||||
|
||||
func newBlockingWriter() *blockingWriter {
|
||||
return &blockingWriter{block: make(chan struct{}), entered: make(chan struct{})}
|
||||
}
|
||||
|
||||
func (w *blockingWriter) Write(data []byte) (int, error) {
|
||||
w.mu.Lock()
|
||||
w.inside++
|
||||
if w.inside > 1 {
|
||||
w.overlaps++
|
||||
}
|
||||
w.mu.Unlock()
|
||||
|
||||
// Only the first writer parks, and it announces that it is inside.
|
||||
first := false
|
||||
w.blockOne.Do(func() {
|
||||
first = true
|
||||
close(w.entered)
|
||||
})
|
||||
if first {
|
||||
<-w.block
|
||||
}
|
||||
|
||||
w.mu.Lock()
|
||||
n, err := w.buf.Write(data)
|
||||
w.inside--
|
||||
w.mu.Unlock()
|
||||
return n, err
|
||||
}
|
||||
|
||||
func (w *blockingWriter) overlapCount() int {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
return w.overlaps
|
||||
}
|
||||
|
||||
func (w *blockingWriter) String() string {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
return w.buf.String()
|
||||
}
|
||||
|
||||
// ONE CALLER AT A TIME, FOR THE WHOLE OVERLAP.
|
||||
//
|
||||
// Joining the pump at stop bounds writes to the pump's lifetime but says nothing
|
||||
// about what happens DURING it. The startup paths keep writing to the same
|
||||
// io.Writer the whole time, and the caller may legitimately hand in a plain
|
||||
// bytes.Buffer, which corrupts under concurrent use. A mutex private to the pump
|
||||
// would not have helped, because the foreground writes do not go through it; the
|
||||
// reporter therefore hands back a guarded view of the caller's writer and the
|
||||
// caller adopts it, so both sides take the same lock.
|
||||
func TestLateDisclosureAndForegroundStartupNeverShareTheWriter(t *testing.T) {
|
||||
const notice = "MCP server started under reduced enforcement"
|
||||
released := make(chan struct{})
|
||||
published := make(chan struct{})
|
||||
|
||||
runtime, err := mcp.RegisterTools(context.Background(), tools.NewRegistry(),
|
||||
config.MCPConfig{Servers: map[string]config.MCPServerConfig{
|
||||
"slow": {Type: "stdio", Command: "slow-mcp"},
|
||||
}},
|
||||
mcp.RegisterOptions{
|
||||
ConnectTimeout: 50 * time.Millisecond,
|
||||
ClientFactory: func(ctx context.Context, server mcp.Server) (mcp.ToolClient, error) {
|
||||
<-released
|
||||
mcp.PublishLaunchForTest(ctx, []string{notice})
|
||||
close(published)
|
||||
return nil, errors.New("initialize failed long after start")
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = runtime.Close() })
|
||||
|
||||
writer := newBlockingWriter()
|
||||
guarded, stop := reportMCPStartupDisclosures(writer, runtime)
|
||||
if guarded == io.Writer(writer) {
|
||||
t.Fatal("SETUP INVALID: the reporter handed back the raw writer, so the caller cannot share its lock")
|
||||
}
|
||||
|
||||
// Foreground startup writes through the guarded writer and parks inside it,
|
||||
// exactly as a slow terminal would.
|
||||
foregroundDone := make(chan struct{})
|
||||
go func() {
|
||||
defer close(foregroundDone)
|
||||
fmt.Fprintln(guarded, "warning: MCP server other unavailable, skipped: dial tcp: refused")
|
||||
}()
|
||||
<-writer.entered
|
||||
|
||||
// While the foreground write is parked, the late launch resolves and the pump
|
||||
// tries to print. If the two did not share a lock, this would enter Write
|
||||
// concurrently.
|
||||
close(released)
|
||||
<-published
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
close(writer.block)
|
||||
<-foregroundDone
|
||||
stop()
|
||||
|
||||
if n := writer.overlapCount(); n != 0 {
|
||||
t.Fatalf("the pump and foreground startup were inside the writer together %d time(s)", n)
|
||||
}
|
||||
got := writer.String()
|
||||
if count := strings.Count(got, notice); count != 1 {
|
||||
t.Fatalf("the late disclosure was written %d time(s), want exactly 1 after stop drained it:\n%s", count, got)
|
||||
}
|
||||
if !strings.Contains(got, "unavailable, skipped") {
|
||||
t.Errorf("the foreground startup message was lost:\n%s", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Twigpine/zero/internal/agent"
|
||||
"github.com/Twigpine/zero/internal/tools"
|
||||
)
|
||||
|
||||
// A DISCLOSURE THAT IS NOT PERSISTED DID NOT SURVIVE THE RUN.
|
||||
//
|
||||
// The session log is what a resumed or compacted conversation is rebuilt from,
|
||||
// and replay reads this payload's "output" straight into the transcript without
|
||||
// reconstructing an agent.ToolResult. So persisting the raw undecorated field
|
||||
// makes a warning that was visible during the original run disappear the moment
|
||||
// the session is resumed, with nothing failing anywhere to say so.
|
||||
func TestPersistedToolResultKeepsTheEnforcementNotice(t *testing.T) {
|
||||
const notice = "least-privilege notice: read access was narrowed"
|
||||
payload := persistedToolResultPayload(agent.ToolResult{
|
||||
ToolCallID: "call-1",
|
||||
Name: "bash",
|
||||
Status: tools.StatusOK,
|
||||
Output: "the command output",
|
||||
EnforcementNotices: []string{notice},
|
||||
})
|
||||
|
||||
output, _ := payload["output"].(string)
|
||||
if count := strings.Count(output, notice); count != 1 {
|
||||
t.Errorf("persisted output carries the notice %d times, want exactly 1: %q", count, output)
|
||||
}
|
||||
if !strings.Contains(output, "the command output") {
|
||||
t.Errorf("persisted output lost the command output: %q", output)
|
||||
}
|
||||
}
|
||||
|
||||
// The other fields still round-trip, so the shared helper did not quietly drop
|
||||
// what the two writers used to record separately.
|
||||
func TestPersistedToolResultKeepsItsOtherFields(t *testing.T) {
|
||||
payload := persistedToolResultPayload(agent.ToolResult{
|
||||
ToolCallID: "call-2",
|
||||
Name: "write_file",
|
||||
Status: tools.StatusError,
|
||||
Output: "boom",
|
||||
Meta: map[string]string{"k": "v"},
|
||||
Truncated: true,
|
||||
Redacted: true,
|
||||
ChangedFiles: []string{"a.go"},
|
||||
})
|
||||
for _, field := range []string{"toolCallId", "name", "status", "output", "meta", "truncated", "redacted", "changedFiles"} {
|
||||
if _, ok := payload[field]; !ok {
|
||||
t.Errorf("payload is missing %q: %#v", field, payload)
|
||||
}
|
||||
}
|
||||
if payload["status"] != string(tools.StatusError) {
|
||||
t.Errorf("status = %v, want %q", payload["status"], tools.StatusError)
|
||||
}
|
||||
}
|
||||
|
||||
// An ordinary result records exactly what it did before, so the accessor is not
|
||||
// adding anything where there is nothing to add.
|
||||
func TestPersistedToolResultLeavesAnOrdinaryResultAlone(t *testing.T) {
|
||||
payload := persistedToolResultPayload(agent.ToolResult{
|
||||
ToolCallID: "call-3",
|
||||
Name: "bash",
|
||||
Status: tools.StatusOK,
|
||||
Output: "plain output",
|
||||
})
|
||||
if got := payload["output"]; got != "plain output" {
|
||||
t.Errorf("persisted output = %v, want it untouched", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
package execution
|
||||
|
||||
import "sync"
|
||||
|
||||
// ChildLaunchTracker is the one monotonic answer to "did the requested child
|
||||
// run", shared by every consumer of a prepared command.
|
||||
//
|
||||
// THE REPORT HAS THREE STATES, NOT TWO. For a plan whose child is created inside
|
||||
// an adapter, the report file passes through: not settled yet, settled with no
|
||||
// child, and child created. An absent file, an empty file the adapter opened but
|
||||
// has not written, a half-written one, and a decode error are all the FIRST
|
||||
// state, and collapsing them to "no child" is a claim about a process that may be
|
||||
// running at that instant. Consumers used to make that collapse independently and
|
||||
// then compensate for the timing on their own, which is why fixing one
|
||||
// presentation site kept exposing the next.
|
||||
//
|
||||
// Two things make an answer terminal here:
|
||||
//
|
||||
// - Confirm, when the consumer has observed the child directly. A stdio MCP
|
||||
// server answering initialize is that observation: the adapter speaks no MCP,
|
||||
// so a well-formed response can only have come from the requested child.
|
||||
// - Settle, when the adapter process itself has exited. After that the report
|
||||
// will never change, so whatever it says, including nothing, is the truth.
|
||||
//
|
||||
// Settle runs from Cleanup, BEFORE the report file is deleted. Reading the
|
||||
// evidence and then destroying it in one step is what lets a consumer ask after
|
||||
// close and still get an answer, rather than racing a deletion it does not
|
||||
// control.
|
||||
//
|
||||
// The decision only ever moves from unknown to known and never back, so two
|
||||
// consumers of the same prepared command cannot disagree, and a retry cannot turn
|
||||
// a launch that happened into one that did not.
|
||||
type ChildLaunchTracker struct {
|
||||
mu sync.Mutex
|
||||
settled bool
|
||||
launched bool
|
||||
|
||||
ownedByAdapter bool
|
||||
report func() (AdapterReport, error)
|
||||
}
|
||||
|
||||
// NewChildLaunchTracker builds the tracker for a prepared command and returns it
|
||||
// with a Cleanup that settles before releasing the plan's resources.
|
||||
//
|
||||
// The returned cleanup is the one the caller must use. Calling the prepared
|
||||
// command's own Cleanup instead deletes the report while the decision is still
|
||||
// unknown, which is the ordering hazard this type exists to remove.
|
||||
func NewChildLaunchTracker(prepared PreparedCommand) (*ChildLaunchTracker, func()) {
|
||||
tracker := &ChildLaunchTracker{
|
||||
ownedByAdapter: prepared.ChildLaunchOwnedByAdapter,
|
||||
report: prepared.Report,
|
||||
}
|
||||
// A plan whose child is the started process needs no evidence: Start
|
||||
// succeeding IS the launch, so the answer is terminal from the beginning.
|
||||
if !tracker.ownedByAdapter {
|
||||
tracker.settled = true
|
||||
tracker.launched = true
|
||||
}
|
||||
inner := prepared.Cleanup
|
||||
return tracker, func() {
|
||||
tracker.Settle()
|
||||
if inner != nil {
|
||||
inner()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Confirm latches a launch the consumer observed for itself.
|
||||
//
|
||||
// Positive evidence is always terminal. Nothing that happens later can make a
|
||||
// child that answered not have run, so this needs no settlement and cannot be
|
||||
// undone by a subsequent Settle finding no report.
|
||||
func (tracker *ChildLaunchTracker) Confirm() {
|
||||
if tracker == nil {
|
||||
return
|
||||
}
|
||||
tracker.mu.Lock()
|
||||
defer tracker.mu.Unlock()
|
||||
tracker.settled = true
|
||||
tracker.launched = true
|
||||
}
|
||||
|
||||
// Settle freezes the answer because the adapter has finished.
|
||||
//
|
||||
// One last read first: the adapter may have published between the consumer's
|
||||
// last look and its exit, and this is the only remaining chance to see it.
|
||||
func (tracker *ChildLaunchTracker) Settle() {
|
||||
if tracker == nil {
|
||||
return
|
||||
}
|
||||
tracker.mu.Lock()
|
||||
defer tracker.mu.Unlock()
|
||||
if tracker.settled {
|
||||
return
|
||||
}
|
||||
tracker.launched = tracker.readReportLocked()
|
||||
tracker.settled = true
|
||||
}
|
||||
|
||||
// Launched reports the decision so far.
|
||||
//
|
||||
// Before settlement this reads the report and answers true only for a published
|
||||
// launch. A negative answer here is NOT remembered, because the adapter may still
|
||||
// be between creating the child and recording it; the next caller asks again.
|
||||
// Consumers that need a final answer settle first, which Cleanup does for them.
|
||||
func (tracker *ChildLaunchTracker) Launched() bool {
|
||||
if tracker == nil {
|
||||
return false
|
||||
}
|
||||
tracker.mu.Lock()
|
||||
defer tracker.mu.Unlock()
|
||||
if tracker.settled {
|
||||
return tracker.launched
|
||||
}
|
||||
if tracker.readReportLocked() {
|
||||
tracker.launched = true
|
||||
tracker.settled = true
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Settled reports whether the answer is final, for callers that would otherwise
|
||||
// present an unknown as a fact.
|
||||
func (tracker *ChildLaunchTracker) Settled() bool {
|
||||
if tracker == nil {
|
||||
return false
|
||||
}
|
||||
tracker.mu.Lock()
|
||||
defer tracker.mu.Unlock()
|
||||
return tracker.settled
|
||||
}
|
||||
|
||||
// readReportLocked answers only the positive case. A missing file, an empty one,
|
||||
// a partial write and a decode error are all "nothing published yet", which is
|
||||
// the state this type refuses to record as an outcome.
|
||||
func (tracker *ChildLaunchTracker) readReportLocked() bool {
|
||||
if tracker.report == nil {
|
||||
return false
|
||||
}
|
||||
report, err := tracker.report()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return ResolveChildLaunched(true, tracker.ownedByAdapter, report)
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
package execution
|
||||
|
||||
import "testing"
|
||||
|
||||
// scriptedReport is an adapter report whose answer changes over time, the way a
|
||||
// real one does: the file is opened empty, decodes to an error while nothing has
|
||||
// been written, and only later carries the fact.
|
||||
type scriptedReport struct {
|
||||
launched bool
|
||||
reads int
|
||||
err error
|
||||
}
|
||||
|
||||
func (script *scriptedReport) read() (AdapterReport, error) {
|
||||
script.reads++
|
||||
if script.err != nil {
|
||||
return AdapterReport{}, script.err
|
||||
}
|
||||
if !script.launched {
|
||||
// What an empty or half-written file decodes to. NOT childLaunched=false,
|
||||
// which would be the adapter stating an outcome.
|
||||
return AdapterReport{}, nil
|
||||
}
|
||||
launched := true
|
||||
return AdapterReport{ChildLaunched: &launched}, nil
|
||||
}
|
||||
|
||||
func trackerOver(script *scriptedReport) (*ChildLaunchTracker, func()) {
|
||||
return NewChildLaunchTracker(PreparedCommand{
|
||||
ChildLaunchOwnedByAdapter: true,
|
||||
Report: script.read,
|
||||
})
|
||||
}
|
||||
|
||||
// A NEGATIVE READ IS NOT AN OUTCOME UNTIL THE ADAPTER IS DONE.
|
||||
//
|
||||
// This is the whole point of the type. Reading the report between the adapter
|
||||
// creating the child and recording it answers "nothing published", and caching
|
||||
// that answer freezes "not yet" into "never" for the rest of the session.
|
||||
func TestAnUnsettledNegativeIsNotRemembered(t *testing.T) {
|
||||
script := &scriptedReport{}
|
||||
tracker, _ := trackerOver(script)
|
||||
|
||||
if tracker.Launched() {
|
||||
t.Fatal("SETUP INVALID: an unpublished report answered launched, so there is no negative here to cache")
|
||||
}
|
||||
if tracker.Settled() {
|
||||
t.Fatal("a read that found nothing settled the decision; the adapter may still be about to publish")
|
||||
}
|
||||
|
||||
// The adapter publishes, as it does a moment after CreateProcessAsUser.
|
||||
script.launched = true
|
||||
if !tracker.Launched() {
|
||||
t.Fatal("the launch published after the first read was never seen; the earlier negative was cached")
|
||||
}
|
||||
if !tracker.Settled() {
|
||||
t.Fatal("a confirmed launch left the decision open")
|
||||
}
|
||||
}
|
||||
|
||||
// And once it IS an outcome it stays one, so the answer is monotonic and two
|
||||
// consumers of the same prepared command cannot disagree.
|
||||
func TestASettledLaunchIsNeverWithdrawn(t *testing.T) {
|
||||
script := &scriptedReport{launched: true}
|
||||
tracker, cleanup := trackerOver(script)
|
||||
|
||||
if !tracker.Launched() {
|
||||
t.Fatal("SETUP INVALID: a published report did not answer launched")
|
||||
}
|
||||
// Cleanup deletes the report in production, which is what a later read would
|
||||
// find. The decision must not follow it.
|
||||
script.launched = false
|
||||
cleanup()
|
||||
if !tracker.Launched() {
|
||||
t.Fatal("the decision followed the report file into deletion; a server that ran became one that did not")
|
||||
}
|
||||
}
|
||||
|
||||
// CLEANUP SETTLES BEFORE IT DESTROYS THE EVIDENCE.
|
||||
//
|
||||
// The report is a file the plan's cleanup removes. A consumer that asks after
|
||||
// cleanup used to read an absent report and answer "no child" about a server that
|
||||
// really did run, which is why the decision was being taken early and hitting the
|
||||
// unsettled window instead.
|
||||
func TestCleanupSettlesBeforeReleasingThePlan(t *testing.T) {
|
||||
script := &scriptedReport{launched: true}
|
||||
var order []string
|
||||
tracker, cleanup := NewChildLaunchTracker(PreparedCommand{
|
||||
ChildLaunchOwnedByAdapter: true,
|
||||
Report: func() (AdapterReport, error) {
|
||||
order = append(order, "read")
|
||||
return script.read()
|
||||
},
|
||||
Cleanup: func() { order = append(order, "release") },
|
||||
})
|
||||
|
||||
cleanup()
|
||||
if len(order) != 2 || order[0] != "read" || order[1] != "release" {
|
||||
t.Fatalf("cleanup order = %v, want the report read before the plan is released", order)
|
||||
}
|
||||
if !tracker.Launched() {
|
||||
t.Fatal("the launch published just before cleanup was lost; nothing read the report on the way out")
|
||||
}
|
||||
}
|
||||
|
||||
// An adapter that exits having published nothing settles negative, and that
|
||||
// answer is final: this is the case the unsettled rule must not swallow.
|
||||
func TestAnAdapterThatPublishedNothingSettlesNegative(t *testing.T) {
|
||||
script := &scriptedReport{}
|
||||
tracker, cleanup := trackerOver(script)
|
||||
cleanup()
|
||||
|
||||
if !tracker.Settled() {
|
||||
t.Fatal("the adapter finished without the decision becoming final")
|
||||
}
|
||||
if tracker.Launched() {
|
||||
t.Fatal("an adapter that published nothing was credited with a launch")
|
||||
}
|
||||
// And a report that changes after the adapter is gone changes nothing.
|
||||
script.launched = true
|
||||
if tracker.Launched() {
|
||||
t.Fatal("a settled decision was reopened by a later read")
|
||||
}
|
||||
}
|
||||
|
||||
// Direct evidence outranks the report and needs no settlement, because nothing
|
||||
// that happens later can make a child that answered not have run.
|
||||
func TestConfirmIsTerminalWithoutAReport(t *testing.T) {
|
||||
script := &scriptedReport{}
|
||||
tracker, cleanup := trackerOver(script)
|
||||
|
||||
tracker.Confirm()
|
||||
if !tracker.Launched() || !tracker.Settled() {
|
||||
t.Fatal("an observed child did not settle the decision")
|
||||
}
|
||||
cleanup()
|
||||
if !tracker.Launched() {
|
||||
t.Fatal("settling from cleanup overwrote an observed launch with an absent report")
|
||||
}
|
||||
}
|
||||
|
||||
// A plan whose started process IS the requested command has nothing to decide,
|
||||
// and must not be made to wait on a report it will never have.
|
||||
func TestAPlanTheAdapterDoesNotOwnIsLaunchedFromTheStart(t *testing.T) {
|
||||
tracker, _ := NewChildLaunchTracker(PreparedCommand{ChildLaunchOwnedByAdapter: false})
|
||||
if !tracker.Launched() || !tracker.Settled() {
|
||||
t.Fatal("a directly started command was not treated as launched")
|
||||
}
|
||||
}
|
||||
|
||||
// A report that fails to read is the unsettled state, not a negative outcome.
|
||||
func TestADecodeErrorIsNotAnAnswer(t *testing.T) {
|
||||
script := &scriptedReport{err: errScriptedReportBroken}
|
||||
tracker, _ := trackerOver(script)
|
||||
|
||||
if tracker.Launched() {
|
||||
t.Fatal("a broken report was read as a launch")
|
||||
}
|
||||
if tracker.Settled() {
|
||||
t.Fatal("a broken report settled the decision; a half-written file is a moment, not an outcome")
|
||||
}
|
||||
}
|
||||
|
||||
var errScriptedReportBroken = errScriptedReport("unexpected end of JSON input")
|
||||
|
||||
type errScriptedReport string
|
||||
|
||||
func (e errScriptedReport) Error() string { return string(e) }
|
||||
@@ -173,6 +173,11 @@ type Enforcement struct {
|
||||
Level string `json:"level,omitempty"`
|
||||
Degraded bool `json:"degraded,omitempty"`
|
||||
DowngradeReason string `json:"downgradeReason,omitempty"`
|
||||
// Notices are least-privilege disclosures about the enforcement actually
|
||||
// applied to THIS command, as opposed to the diagnostic views produced by
|
||||
// `zero sandbox policy` and `zero sandbox check`. A trade an operator only
|
||||
// discovers by running a separate diagnostic command is not disclosed.
|
||||
Notices []string `json:"notices,omitempty"`
|
||||
}
|
||||
|
||||
type Outcome struct {
|
||||
@@ -182,7 +187,18 @@ type Outcome struct {
|
||||
Exit *Exit `json:"exit,omitempty"`
|
||||
Denial *Denial `json:"denial,omitempty"`
|
||||
Enforcement Enforcement `json:"enforcement"`
|
||||
Changes []Change `json:"changes,omitempty"`
|
||||
// Launched records whether an OS process was actually created, observed at
|
||||
// the boundary that calls Run rather than inferred afterwards.
|
||||
//
|
||||
// OutcomeKind is not a launch-state field, and reading it as one is wrong in
|
||||
// both directions. A child that ran and then produced an unreadable adapter
|
||||
// report is rewritten to a setup failure, so inference drops a disclosure that
|
||||
// did apply; a context already cancelled before os.StartProcess yields a
|
||||
// cancellation, so inference claims reduced enforcement for a child that never
|
||||
// existed. Report decoding can fail after launch without rewriting history,
|
||||
// and cancellation happens on either side of Start.
|
||||
Launched bool `json:"launched,omitempty"`
|
||||
Changes []Change `json:"changes,omitempty"`
|
||||
}
|
||||
|
||||
// AdapterReport is the structured, machine-readable result emitted by a
|
||||
@@ -190,6 +206,51 @@ type Outcome struct {
|
||||
// command text cannot impersonate a policy decision.
|
||||
type AdapterReport struct {
|
||||
Denial *Denial `json:"denial,omitempty"`
|
||||
// ChildLaunched is the adapter's authoritative statement that the REQUESTED
|
||||
// process started, for a plan where the command the runner starts is not that
|
||||
// process.
|
||||
//
|
||||
// A wrapped plan starts a helper, and the helper creates the sandboxed child
|
||||
// only after validating the setup marker, applying ACLs, checking the network
|
||||
// policy, building capability SIDs and minting the restricted token. Any of
|
||||
// those can fail with the helper already running, so the runner's own
|
||||
// exec.Cmd.Process tells it the WRAPPER started and nothing about the child.
|
||||
// Only the adapter sees that transition, so only the adapter may report it.
|
||||
//
|
||||
// nil means the adapter does not speak to this, and the runner keeps its own
|
||||
// observation. That is correct for every direct, unwrapped command, where the
|
||||
// process the runner starts IS the requested one.
|
||||
ChildLaunched *bool `json:"childLaunched,omitempty"`
|
||||
}
|
||||
|
||||
// ChildLaunched reports whether this outcome describes a process that actually
|
||||
// started, from the recorded fact rather than from the terminal outcome kind.
|
||||
func (outcome Outcome) ChildLaunched() bool {
|
||||
return outcome.Launched
|
||||
}
|
||||
|
||||
// AppliedEnforcementNotices returns the least-privilege disclosures that are
|
||||
// true of what actually happened.
|
||||
//
|
||||
// ONE DECISION, AT THE BOUNDARY WHERE THE OUTCOME IS KNOWN. Enforcement.Notices
|
||||
// is planned: it describes the shape the command was PREPARED to run under, and
|
||||
// planning is not proof that anything ran. Every consumer that copied the field
|
||||
// straight out therefore made the completed-enforcement claim for commands that
|
||||
// never launched, telling an operator the write jail had been traded away for a
|
||||
// child that failed before it existed.
|
||||
//
|
||||
// Keeping this on Outcome rather than repeating an outcome-kind switch in hooks,
|
||||
// plugins and tools is the point: a new pre-launch outcome kind has to be
|
||||
// classified once, here, instead of being silently disclosed by whichever
|
||||
// consumer was not updated.
|
||||
func (outcome Outcome) AppliedEnforcementNotices() []string {
|
||||
if !outcome.ChildLaunched() {
|
||||
return nil
|
||||
}
|
||||
if len(outcome.Enforcement.Notices) == 0 {
|
||||
return nil
|
||||
}
|
||||
return append([]string(nil), outcome.Enforcement.Notices...)
|
||||
}
|
||||
|
||||
func (outcome Outcome) Validate() error {
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
package execution
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const launchStateNotice = "denyRead is configured, so the write jail is not confining writes"
|
||||
|
||||
func launchStateShell(ctx context.Context, script string) *exec.Cmd {
|
||||
if runtime.GOOS == "windows" {
|
||||
return exec.CommandContext(ctx, "cmd.exe", "/c", script)
|
||||
}
|
||||
return exec.CommandContext(ctx, "/bin/sh", "-c", script)
|
||||
}
|
||||
|
||||
// launchStatePreparer plans a command carrying an enforcement notice, and can
|
||||
// make the adapter report fail after the child has already run.
|
||||
type launchStatePreparer struct {
|
||||
script string
|
||||
reportErr error
|
||||
missing bool
|
||||
}
|
||||
|
||||
func (p *launchStatePreparer) PrepareExecution(ctx context.Context, _ Request) (PreparedCommand, error) {
|
||||
command := launchStateShell(ctx, p.script)
|
||||
if p.missing {
|
||||
command = exec.CommandContext(ctx, "definitely-not-a-real-binary-zzz")
|
||||
}
|
||||
prepared := PreparedCommand{
|
||||
Command: command,
|
||||
Enforcement: Enforcement{Notices: []string{launchStateNotice}},
|
||||
}
|
||||
if p.reportErr != nil {
|
||||
prepared.Report = func() (AdapterReport, error) { return AdapterReport{}, p.reportErr }
|
||||
}
|
||||
return prepared, nil
|
||||
}
|
||||
|
||||
func captured(t *testing.T, ctx context.Context, p *launchStatePreparer) CapturedResult {
|
||||
t.Helper()
|
||||
return NewRunner(p).ExecuteCaptured(ctx, CapturedRequest{Request: Request{
|
||||
Origin: OriginHook,
|
||||
Mode: ModeCaptured,
|
||||
Command: Command{Name: "irrelevant"},
|
||||
WorkingDirectory: t.TempDir(),
|
||||
WorkspaceRoots: []string{t.TempDir()},
|
||||
Approval: ApprovalContext{PolicyVersion: PolicyVersion},
|
||||
}})
|
||||
}
|
||||
|
||||
// THE OUTCOME KIND IS NOT A LAUNCH-STATE FIELD, IN EITHER DIRECTION.
|
||||
//
|
||||
// Deriving launch from the terminal kind is wrong twice over. The adapter report
|
||||
// is read AFTER Run, so a child that really ran and then produced an unreadable
|
||||
// report is rewritten to a setup failure: inference drops a disclosure that did
|
||||
// apply. And a context already cancelled before os.StartProcess still selects a
|
||||
// cancellation, so inference claims reduced enforcement for a process that never
|
||||
// existed.
|
||||
func TestLaunchStateIsRecordedNotInferred(t *testing.T) {
|
||||
t.Run("ran, then the adapter report failed", func(t *testing.T) {
|
||||
result := captured(t, context.Background(), &launchStatePreparer{
|
||||
script: "exit 0",
|
||||
reportErr: errors.New("adapter report is unreadable"),
|
||||
})
|
||||
if result.Outcome.Kind != OutcomeSandboxSetupFailure {
|
||||
t.Fatalf("SETUP INVALID: kind = %q, want the report failure to rewrite it", result.Outcome.Kind)
|
||||
}
|
||||
if !result.Outcome.Launched {
|
||||
t.Error("a child that ran was recorded as never launched")
|
||||
}
|
||||
if got := result.Outcome.AppliedEnforcementNotices(); len(got) != 1 {
|
||||
t.Errorf("the disclosure was dropped for a child that did run: %#v", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("cancelled before the process started", func(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
result := captured(t, ctx, &launchStatePreparer{script: "exit 0"})
|
||||
if result.Outcome.Launched {
|
||||
t.Error("a process that never started was recorded as launched")
|
||||
}
|
||||
if got := result.Outcome.AppliedEnforcementNotices(); len(got) != 0 {
|
||||
t.Errorf("an enforcement trade was claimed for a process that never existed: %#v", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("never found", func(t *testing.T) {
|
||||
result := captured(t, context.Background(), &launchStatePreparer{missing: true})
|
||||
if result.Outcome.Launched {
|
||||
t.Error("a missing executable was recorded as launched")
|
||||
}
|
||||
if got := result.Outcome.AppliedEnforcementNotices(); len(got) != 0 {
|
||||
t.Errorf("a missing executable claimed an enforcement trade: %#v", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ordinary success still discloses", func(t *testing.T) {
|
||||
result := captured(t, context.Background(), &launchStatePreparer{script: "exit 0"})
|
||||
if !result.Outcome.Launched {
|
||||
t.Fatal("an ordinary run was recorded as never launched")
|
||||
}
|
||||
if got := result.Outcome.AppliedEnforcementNotices(); len(got) != 1 {
|
||||
t.Errorf("an ordinary run lost its disclosure: %#v", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
package execution
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
const liveLaunchNotice = "denyRead is configured, so the write jail is not confining writes"
|
||||
|
||||
// liveReportReader mirrors sandbox.CommandPlan.ExecutionReport: read the file the
|
||||
// helper publishes, treat "not there yet" as nothing recorded.
|
||||
func liveReportReader(path string) func() (AdapterReport, error) {
|
||||
return func() (AdapterReport, error) {
|
||||
raw, err := os.ReadFile(path)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return AdapterReport{}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return AdapterReport{}, err
|
||||
}
|
||||
var report AdapterReport
|
||||
if err := json.Unmarshal(raw, &report); err != nil {
|
||||
return AdapterReport{}, err
|
||||
}
|
||||
return report, nil
|
||||
}
|
||||
}
|
||||
|
||||
// liveHelperCommand stands in for the Windows helper: publish the launch fact the
|
||||
// way it does right after CreateProcessAsUser, then stay alive the way it does
|
||||
// while waiting on the child.
|
||||
func liveHelperCommand(t *testing.T, reportPath string, publish bool) *exec.Cmd {
|
||||
t.Helper()
|
||||
if publish {
|
||||
if err := os.WriteFile(reportPath, []byte(`{"childLaunched":true}`), 0o600); err != nil {
|
||||
t.Fatalf("publish the launch report: %v", err)
|
||||
}
|
||||
}
|
||||
if runtime.GOOS == "windows" {
|
||||
// A child that holds itself open without exiting.
|
||||
return exec.Command("cmd.exe", "/c", "pause")
|
||||
}
|
||||
return exec.Command("/bin/sh", "-c", "sleep 30")
|
||||
}
|
||||
|
||||
// liveRequest is a valid interactive request; ProcessManager.Start validates it
|
||||
// before anything under test runs.
|
||||
func liveRequest(t *testing.T) Request {
|
||||
t.Helper()
|
||||
return Request{
|
||||
Origin: OriginInteractiveCommand,
|
||||
Mode: ModeInteractive,
|
||||
Command: Command{Name: "helper"},
|
||||
WorkingDirectory: t.TempDir(),
|
||||
WorkspaceRoots: []string{t.TempDir()},
|
||||
Approval: ApprovalContext{PolicyVersion: PolicyVersion},
|
||||
}
|
||||
}
|
||||
|
||||
// A RETAINED SESSION HAS TO DISCLOSE WHILE IT IS STILL RUNNING.
|
||||
//
|
||||
// The helper publishes childLaunched immediately after it creates the restricted
|
||||
// child, and only then waits for it. The manager read that report exclusively in
|
||||
// the post-Wait goroutine, so for the whole live lifetime of a wrapped session the
|
||||
// report was the zero value: the first exec_command reply and every write_stdin
|
||||
// poll resolved Launched=false and disclosed nothing, while the fact sat readable
|
||||
// on disk. A watcher, or a retained session nobody polls to completion, would
|
||||
// never be told the write jail had been traded away.
|
||||
//
|
||||
// Driven through the real ProcessManager with a real child process, and asserted
|
||||
// on the ProcessResult the tool layer consumes.
|
||||
func TestALiveWrappedSessionCarriesTheLaunchFact(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
reportPath := filepath.Join(directory, "report.json")
|
||||
command := liveHelperCommand(t, reportPath, true)
|
||||
|
||||
manager := NewProcessManager(ProcessManagerOptions{})
|
||||
result, err := manager.Start(context.Background(), ProcessStart{
|
||||
Prepared: PreparedCommand{
|
||||
Command: command,
|
||||
ChildLaunchOwnedByAdapter: true,
|
||||
Enforcement: Enforcement{Notices: []string{liveLaunchNotice}},
|
||||
Report: liveReportReader(reportPath),
|
||||
},
|
||||
Request: liveRequest(t),
|
||||
}, 300*time.Millisecond)
|
||||
if err != nil {
|
||||
t.Fatalf("Start: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { manager.StopAll() })
|
||||
|
||||
// SETUP: this has to be the LIVE state, and the fact has to be on disk, or the
|
||||
// assertion below would be about a terminal read.
|
||||
if result.Exited {
|
||||
t.Fatal("SETUP INVALID: the stand-in helper exited, so the live lifecycle is not under test")
|
||||
}
|
||||
if _, statErr := os.Stat(reportPath); statErr != nil {
|
||||
t.Fatalf("SETUP INVALID: the launch report is not on disk, so there is nothing to observe: %v", statErr)
|
||||
}
|
||||
|
||||
if !ResolveChildLaunched(true, result.ChildLaunchOwnedByAdapter, result.Report) {
|
||||
t.Fatal("a live wrapped session resolved as not launched, so its enforcement disclosure is withheld while the command runs")
|
||||
}
|
||||
|
||||
// And again on a poll, which is the write_stdin leg.
|
||||
polled, err := manager.Continue(context.Background(), ProcessContinue{ProcessID: result.ProcessID, Wait: 200 * time.Millisecond})
|
||||
if err != nil {
|
||||
t.Fatalf("Continue: %v", err)
|
||||
}
|
||||
if polled.Exited {
|
||||
t.Fatal("SETUP INVALID: the helper exited before the poll, so the live poll is not under test")
|
||||
}
|
||||
if !ResolveChildLaunched(true, polled.ChildLaunchOwnedByAdapter, polled.Report) {
|
||||
t.Fatal("a live poll of a wrapped session resolved as not launched")
|
||||
}
|
||||
if got := polled.Enforcement.Notices; len(got) != 1 || got[0] != liveLaunchNotice {
|
||||
t.Fatalf("the live poll carries notices %v, want exactly the one planned notice", got)
|
||||
}
|
||||
}
|
||||
|
||||
// AND A HELPER THAT NEVER CREATED THE CHILD STAYS SILENT.
|
||||
//
|
||||
// This is the negative the live read must not destroy. A helper that starts and
|
||||
// then fails setup, ACL application, or CreateProcessAsUser has an outer process
|
||||
// running and no child, so nothing may be promoted from the fact that the
|
||||
// wrapper itself is alive.
|
||||
func TestALiveWrappedSessionWithNoReportedChildStaysSilent(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
reportPath := filepath.Join(directory, "report.json")
|
||||
command := liveHelperCommand(t, reportPath, false)
|
||||
|
||||
manager := NewProcessManager(ProcessManagerOptions{})
|
||||
result, err := manager.Start(context.Background(), ProcessStart{
|
||||
Prepared: PreparedCommand{
|
||||
Command: command,
|
||||
ChildLaunchOwnedByAdapter: true,
|
||||
Enforcement: Enforcement{Notices: []string{liveLaunchNotice}},
|
||||
Report: liveReportReader(reportPath),
|
||||
},
|
||||
Request: liveRequest(t),
|
||||
}, 300*time.Millisecond)
|
||||
if err != nil {
|
||||
t.Fatalf("Start: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { manager.StopAll() })
|
||||
|
||||
if result.Exited {
|
||||
t.Fatal("SETUP INVALID: the stand-in helper exited, so the live lifecycle is not under test")
|
||||
}
|
||||
if _, statErr := os.Stat(reportPath); statErr == nil {
|
||||
t.Fatal("SETUP INVALID: a report exists, so this is not the no-child case")
|
||||
}
|
||||
if ResolveChildLaunched(true, result.ChildLaunchOwnedByAdapter, result.Report) {
|
||||
t.Fatal("a helper that reported no child was promoted to a launch, so the operator is told a write jail was traded away for a child that never existed")
|
||||
}
|
||||
}
|
||||
|
||||
// A report that appears mid-flight is observed on the next poll, which is what
|
||||
// makes this a lifecycle transition rather than a start-time snapshot.
|
||||
func TestTheLaunchFactIsObservedWhenItAppearsMidFlight(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
reportPath := filepath.Join(directory, "report.json")
|
||||
command := liveHelperCommand(t, reportPath, false)
|
||||
|
||||
manager := NewProcessManager(ProcessManagerOptions{})
|
||||
result, err := manager.Start(context.Background(), ProcessStart{
|
||||
Prepared: PreparedCommand{
|
||||
Command: command,
|
||||
ChildLaunchOwnedByAdapter: true,
|
||||
Report: liveReportReader(reportPath),
|
||||
},
|
||||
Request: liveRequest(t),
|
||||
}, 200*time.Millisecond)
|
||||
if err != nil {
|
||||
t.Fatalf("Start: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { manager.StopAll() })
|
||||
if ResolveChildLaunched(true, result.ChildLaunchOwnedByAdapter, result.Report) {
|
||||
t.Fatal("SETUP INVALID: nothing was published yet, so the first result must not be a launch")
|
||||
}
|
||||
|
||||
if err := os.WriteFile(reportPath, []byte(`{"childLaunched":true}`), 0o600); err != nil {
|
||||
t.Fatalf("publish the launch report: %v", err)
|
||||
}
|
||||
polled, err := manager.Continue(context.Background(), ProcessContinue{ProcessID: result.ProcessID, Wait: 200 * time.Millisecond})
|
||||
if err != nil {
|
||||
t.Fatalf("Continue: %v", err)
|
||||
}
|
||||
if !ResolveChildLaunched(true, polled.ChildLaunchOwnedByAdapter, polled.Report) {
|
||||
t.Fatal("the launch published while the session was live was never observed")
|
||||
}
|
||||
}
|
||||
@@ -79,8 +79,12 @@ type ProcessResult struct {
|
||||
Enforcement Enforcement
|
||||
Report AdapterReport
|
||||
ReportErr error
|
||||
Changes []Change
|
||||
Metadata map[string]string
|
||||
// ChildLaunchOwnedByAdapter carries the prepared plan's ownership of the
|
||||
// requested-child launch fact through to the caller, which for a retained
|
||||
// session no longer has the plan.
|
||||
ChildLaunchOwnedByAdapter bool
|
||||
Changes []Change
|
||||
Metadata map[string]string
|
||||
}
|
||||
|
||||
type ProcessSnapshot struct {
|
||||
@@ -162,6 +166,7 @@ func (manager *ProcessManager) Start(ctx context.Context, input ProcessStart, wa
|
||||
command: command,
|
||||
request: request,
|
||||
enforcement: input.Prepared.Enforcement,
|
||||
ownedLaunch: input.Prepared.ChildLaunchOwnedByAdapter,
|
||||
report: input.Prepared.Report,
|
||||
cleanup: input.Prepared.Cleanup,
|
||||
stdin: stdin,
|
||||
@@ -423,31 +428,46 @@ func (manager *ProcessManager) removeCompletedLater(process *managedProcess) {
|
||||
}
|
||||
|
||||
type managedProcess struct {
|
||||
id int
|
||||
commandText string
|
||||
cwd string
|
||||
relativeCwd string
|
||||
startedAt time.Time
|
||||
lastUsedAt time.Time
|
||||
tty bool
|
||||
command *exec.Cmd
|
||||
request Request
|
||||
enforcement Enforcement
|
||||
report func() (AdapterReport, error)
|
||||
cleanup func()
|
||||
stdin io.WriteCloser
|
||||
output *processOutputBuffer
|
||||
reaped chan struct{}
|
||||
doneOnce sync.Once
|
||||
done chan struct{}
|
||||
kill func(int) error
|
||||
mu sync.Mutex
|
||||
exitCode *int
|
||||
waitErr error
|
||||
resultReport AdapterReport
|
||||
reportErr error
|
||||
changes []Change
|
||||
metadata map[string]string
|
||||
id int
|
||||
commandText string
|
||||
cwd string
|
||||
relativeCwd string
|
||||
startedAt time.Time
|
||||
lastUsedAt time.Time
|
||||
tty bool
|
||||
command *exec.Cmd
|
||||
request Request
|
||||
enforcement Enforcement
|
||||
ownedLaunch bool
|
||||
// launchObserved latches the adapter's launch transition the first time it is
|
||||
// seen, so a live result can report it. Guarded by mu.
|
||||
launchObserved bool
|
||||
report func() (AdapterReport, error)
|
||||
cleanup func()
|
||||
stdin io.WriteCloser
|
||||
output *processOutputBuffer
|
||||
reaped chan struct{}
|
||||
doneOnce sync.Once
|
||||
done chan struct{}
|
||||
kill func(int) error
|
||||
mu sync.Mutex
|
||||
exitCode *int
|
||||
waitErr error
|
||||
resultReport AdapterReport
|
||||
reportErr error
|
||||
changes []Change
|
||||
metadata map[string]string
|
||||
}
|
||||
|
||||
// launchedReportLocked returns the report to hand out, with a latched live
|
||||
// launch folded in. Caller holds mu.
|
||||
func (process *managedProcess) launchedReportLocked() AdapterReport {
|
||||
report := process.resultReport
|
||||
if report.ChildLaunched == nil && process.launchObserved {
|
||||
launched := true
|
||||
report.ChildLaunched = &launched
|
||||
}
|
||||
return report
|
||||
}
|
||||
|
||||
func (process *managedProcess) markDone(err error, exitCode int, report AdapterReport, reportErr error, changes []Change) {
|
||||
@@ -455,14 +475,76 @@ func (process *managedProcess) markDone(err error, exitCode int, report AdapterR
|
||||
process.waitErr = err
|
||||
process.exitCode = &exitCode
|
||||
process.resultReport = report
|
||||
// The plan's cleanup has already removed the report file by the time this
|
||||
// runs on some orderings, so a terminal read can answer "nothing recorded"
|
||||
// about a child that demonstrably started. A launch we already saw is not
|
||||
// un-seen by that.
|
||||
if report.ChildLaunched == nil && process.launchObserved {
|
||||
launched := true
|
||||
process.resultReport.ChildLaunched = &launched
|
||||
}
|
||||
process.reportErr = reportErr
|
||||
process.changes = append([]Change(nil), changes...)
|
||||
process.mu.Unlock()
|
||||
process.doneOnce.Do(func() { close(process.done) })
|
||||
}
|
||||
|
||||
// observeLaunch reads the adapter's launch report while the process is still
|
||||
// running, and latches a confirmed launch.
|
||||
//
|
||||
// THE LAUNCH FACT IS A LIFECYCLE TRANSITION, NOT TERMINAL DATA. The Windows
|
||||
// helper publishes childLaunched immediately after CreateProcessAsUser creates
|
||||
// the restricted child, and then waits for it. The manager used to read the
|
||||
// report only in the post-Wait goroutine, so for the entire live lifetime of a
|
||||
// retained session the report was the zero value: the first exec_command reply
|
||||
// and every write_stdin poll resolved Launched=false and disclosed nothing,
|
||||
// even though the fact was sitting readable on disk. A watcher or an abandoned
|
||||
// retained session could therefore never be told the write jail had been traded
|
||||
// away. The MCP launcher already reads the report while its server is live;
|
||||
// this is the same read, in the launcher that was left behind.
|
||||
//
|
||||
// SILENCE IS NOT A NEGATIVE, BUT AN EXPLICIT NEGATIVE IS. An absent, partial or
|
||||
// undecodable report, and a helper that failed before it ever created the child,
|
||||
// must all leave the live result exactly as it was: not confirmed, nothing
|
||||
// disclosed. Reading absence as false, or surfacing a read error or a denial
|
||||
// from here, would let a mid-flight poll rewrite a running command into a setup
|
||||
// failure, and absence is the normal state once the plan's cleanup has removed
|
||||
// the file.
|
||||
//
|
||||
// A report that SAYS false is different, and it revokes. The Windows helper
|
||||
// publishes the launch before it resumes the suspended child, so there is a
|
||||
// window where the fact is readable and the child has still executed nothing; if
|
||||
// the resume then fails, the helper retracts the record with an explicit false.
|
||||
// Without this, a poll that landed inside that window would hold a launch that
|
||||
// never happened, and hold it through completion, since the final read finds the
|
||||
// file cleaned away and restores what was latched.
|
||||
//
|
||||
// Which is why the observation is repeated while the command runs rather than
|
||||
// latched once. It costs one small read per poll on a wrapped plan, and every
|
||||
// unwrapped plan still does no extra work at all; a fact that can be withdrawn
|
||||
// is not one to cache.
|
||||
func (process *managedProcess) observeLaunch() {
|
||||
if process.report == nil {
|
||||
return
|
||||
}
|
||||
process.mu.Lock()
|
||||
skip := !process.ownedLaunch
|
||||
process.mu.Unlock()
|
||||
if skip || process.doneClosed() {
|
||||
return
|
||||
}
|
||||
report, err := process.report()
|
||||
if err != nil || report.ChildLaunched == nil {
|
||||
return
|
||||
}
|
||||
process.mu.Lock()
|
||||
process.launchObserved = *report.ChildLaunched
|
||||
process.mu.Unlock()
|
||||
}
|
||||
|
||||
func (process *managedProcess) collectResult(ctx context.Context, wait time.Duration, interrupted bool) ProcessResult {
|
||||
output, truncated := process.collect(ctx, wait)
|
||||
process.observeLaunch()
|
||||
process.mu.Lock()
|
||||
exitCode := 0
|
||||
exited := process.exitCode != nil
|
||||
@@ -473,8 +555,9 @@ func (process *managedProcess) collectResult(ctx context.Context, wait time.Dura
|
||||
ProcessID: process.id, CommandText: process.commandText, RelativeCwd: process.relativeCwd,
|
||||
TTY: process.tty, Output: output, OutputTruncated: truncated, Exited: exited,
|
||||
ExitCode: exitCode, Interrupted: interrupted, Request: process.request,
|
||||
Enforcement: process.enforcement, Report: process.resultReport, ReportErr: process.reportErr,
|
||||
Changes: append([]Change(nil), process.changes...), Metadata: cloneStringMap(process.metadata),
|
||||
Enforcement: process.enforcement, Report: process.launchedReportLocked(), ReportErr: process.reportErr,
|
||||
ChildLaunchOwnedByAdapter: process.ownedLaunch,
|
||||
Changes: append([]Change(nil), process.changes...), Metadata: cloneStringMap(process.metadata),
|
||||
}
|
||||
process.mu.Unlock()
|
||||
return result
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
package execution
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// A LAUNCH THE HELPER TAKES BACK MUST NOT SURVIVE IN EITHER RESULT.
|
||||
//
|
||||
// The Windows helper publishes childLaunched before it resumes the suspended
|
||||
// child, so the fact is readable while the child has still executed nothing. A
|
||||
// poll landing in that window latched it, and the latch outlived the file: the
|
||||
// terminal read finds the report cleaned away and restores what was observed, so
|
||||
// a child that never became runnable was reported as launched, and the write-jail
|
||||
// trade it never made was disclosed as if it had.
|
||||
//
|
||||
// Deleting the report on that path cannot fix it, because deletion is also what a
|
||||
// normal cleanup does. The helper retracts with an explicit false instead, and
|
||||
// this is the interleaving that pins it: publish, live read, retract, poll, then
|
||||
// finish and read terminally.
|
||||
func TestARetractedLaunchIsNotCommittedLiveOrTerminally(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
reportPath := filepath.Join(directory, "report.json")
|
||||
command := liveHelperCommand(t, reportPath, true)
|
||||
|
||||
manager := NewProcessManager(ProcessManagerOptions{})
|
||||
result, err := manager.Start(context.Background(), ProcessStart{
|
||||
Prepared: PreparedCommand{
|
||||
Command: command,
|
||||
ChildLaunchOwnedByAdapter: true,
|
||||
Enforcement: Enforcement{Notices: []string{liveLaunchNotice}},
|
||||
Report: liveReportReader(reportPath),
|
||||
},
|
||||
Request: liveRequest(t),
|
||||
}, 300*time.Millisecond)
|
||||
if err != nil {
|
||||
t.Fatalf("Start: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { manager.StopAll() })
|
||||
|
||||
// SETUP: the publication window. The live read has to have seen the positive,
|
||||
// or the revocation below would have nothing to revoke.
|
||||
if result.Exited {
|
||||
t.Fatal("SETUP INVALID: the stand-in helper exited, so the live lifecycle is not under test")
|
||||
}
|
||||
if !ResolveChildLaunched(true, result.ChildLaunchOwnedByAdapter, result.Report) {
|
||||
t.Fatal("SETUP INVALID: the published launch was not observed live, so this test cannot show it being taken back")
|
||||
}
|
||||
|
||||
// The resume fails: the helper retracts the record it published.
|
||||
if err := os.WriteFile(reportPath, []byte(`{"childLaunched":false}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
polled, err := manager.Continue(context.Background(), ProcessContinue{ProcessID: result.ProcessID, Wait: 200 * time.Millisecond})
|
||||
if err != nil {
|
||||
t.Fatalf("Continue: %v", err)
|
||||
}
|
||||
if polled.Exited {
|
||||
t.Fatal("SETUP INVALID: the helper exited before the poll, so the live poll is not under test")
|
||||
}
|
||||
if ResolveChildLaunched(true, polled.ChildLaunchOwnedByAdapter, polled.Report) {
|
||||
t.Error("a live poll still reports a launch the helper retracted, so the disclosure stands for a child that never ran")
|
||||
}
|
||||
|
||||
// Finish, with the plan's cleanup removing the report the way it always does.
|
||||
// The terminal read then finds absence, which is exactly the case the restore
|
||||
// exists for, and it must not resurrect the retracted launch.
|
||||
if !manager.Stop(result.ProcessID) {
|
||||
t.Fatal("Stop: process not found")
|
||||
}
|
||||
_ = os.Remove(reportPath)
|
||||
final, err := manager.Continue(context.Background(), ProcessContinue{ProcessID: result.ProcessID, Wait: time.Second})
|
||||
if err != nil {
|
||||
t.Fatalf("Continue after stop: %v", err)
|
||||
}
|
||||
if !final.Exited {
|
||||
t.Fatal("SETUP INVALID: the helper never exited, so there is no terminal result under test")
|
||||
}
|
||||
if ResolveChildLaunched(true, final.ChildLaunchOwnedByAdapter, final.Report) {
|
||||
t.Error("the final result reports a launch the helper retracted")
|
||||
}
|
||||
}
|
||||
|
||||
// AND A GENUINE LAUNCH STILL SURVIVES ITS OWN CLEANUP. The revocation must key
|
||||
// on an explicit denial and nothing else: a report removed by the plan's normal
|
||||
// cleanup is silence, and silence does not take back what was seen.
|
||||
func TestAConfirmedLaunchSurvivesTheReportCleanup(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
reportPath := filepath.Join(directory, "report.json")
|
||||
command := liveHelperCommand(t, reportPath, true)
|
||||
|
||||
manager := NewProcessManager(ProcessManagerOptions{})
|
||||
result, err := manager.Start(context.Background(), ProcessStart{
|
||||
Prepared: PreparedCommand{
|
||||
Command: command,
|
||||
ChildLaunchOwnedByAdapter: true,
|
||||
Enforcement: Enforcement{Notices: []string{liveLaunchNotice}},
|
||||
Report: liveReportReader(reportPath),
|
||||
},
|
||||
Request: liveRequest(t),
|
||||
}, 300*time.Millisecond)
|
||||
if err != nil {
|
||||
t.Fatalf("Start: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { manager.StopAll() })
|
||||
if !ResolveChildLaunched(true, result.ChildLaunchOwnedByAdapter, result.Report) {
|
||||
t.Fatal("SETUP INVALID: the published launch was not observed live")
|
||||
}
|
||||
|
||||
if !manager.Stop(result.ProcessID) {
|
||||
t.Fatal("Stop: process not found")
|
||||
}
|
||||
_ = os.Remove(reportPath)
|
||||
final, err := manager.Continue(context.Background(), ProcessContinue{ProcessID: result.ProcessID, Wait: time.Second})
|
||||
if err != nil {
|
||||
t.Fatalf("Continue after stop: %v", err)
|
||||
}
|
||||
if !final.Exited {
|
||||
t.Fatal("SETUP INVALID: the helper never exited")
|
||||
}
|
||||
if !ResolveChildLaunched(true, final.ChildLaunchOwnedByAdapter, final.Report) {
|
||||
t.Error("a confirmed launch was lost when the plan's cleanup removed its report")
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,12 @@ type PreparedCommand struct {
|
||||
Enforcement Enforcement
|
||||
Report func() (AdapterReport, error)
|
||||
Cleanup func()
|
||||
// ChildLaunchOwnedByAdapter marks a plan where Command is a WRAPPER and the
|
||||
// requested process is created inside it, so exec.Cmd.Process says nothing
|
||||
// about whether the sandboxed child ever existed. The adapter must state the
|
||||
// fact in its report; if it does not, the runner treats the child as not
|
||||
// launched rather than crediting the wrapper's start.
|
||||
ChildLaunchOwnedByAdapter bool
|
||||
}
|
||||
|
||||
type CapturedRequest struct {
|
||||
@@ -90,10 +96,25 @@ func (runner *Runner) ExecuteCaptured(ctx context.Context, input CapturedRequest
|
||||
prepared.Command.Stdout = stdout
|
||||
prepared.Command.Stderr = stderr
|
||||
runErr := prepared.Command.Run()
|
||||
// Observed HERE, from the only thing that knows: exec.Cmd sets Process only
|
||||
// once os.StartProcess has succeeded, so this is false for a missing
|
||||
// executable and for a context cancelled before Start, and true for anything
|
||||
// that ran, including a later timeout or cancellation.
|
||||
launched := prepared.Command.Process != nil
|
||||
report, reportErr := AdapterReport{}, error(nil)
|
||||
if prepared.Report != nil {
|
||||
report, reportErr = prepared.Report()
|
||||
}
|
||||
// A WRAPPED PLAN'S LAUNCH BIT BELONGS TO THE ADAPTER. The line above observes
|
||||
// the process THIS command started, which for a Windows restricted-token plan
|
||||
// is the helper, not the requested executable: the helper validates the setup
|
||||
// marker, applies ACLs, checks the network policy, builds capability SIDs and
|
||||
// mints the restricted token after it is already running, and any of those can
|
||||
// fail with no sandboxed child ever created. Believing the outer bit there
|
||||
// reports that reads were denied as requested when only the unsandboxed
|
||||
// adapter ran. An adapter that owns the inner transition overrides it; one
|
||||
// that stays silent leaves the direct-command observation alone.
|
||||
launched = ResolveChildLaunched(launched, prepared.ChildLaunchOwnedByAdapter, report)
|
||||
result := CapturedResult{
|
||||
Stdout: stdout.String(),
|
||||
Stderr: stderr.String(),
|
||||
@@ -101,6 +122,7 @@ func (runner *Runner) ExecuteCaptured(ctx context.Context, input CapturedRequest
|
||||
Err: runErr,
|
||||
Outcome: Outcome{
|
||||
Enforcement: prepared.Enforcement,
|
||||
Launched: launched,
|
||||
},
|
||||
}
|
||||
exitCode := commandExitCode(runErr)
|
||||
@@ -157,6 +179,28 @@ func (runner *Runner) Prepare(ctx context.Context, request Request) (PreparedCom
|
||||
return preparer.PrepareExecution(ctx, request)
|
||||
}
|
||||
|
||||
// ResolveChildLaunched decides whether the REQUESTED process launched.
|
||||
//
|
||||
// ONE IMPLEMENTATION, because every launcher needs the same answer and each one
|
||||
// that re-derived it got a different one. observed is what the caller saw of the
|
||||
// process IT started, which for a wrapped plan is the helper and not the
|
||||
// requested child.
|
||||
//
|
||||
// - the adapter stated the fact: believe the adapter, in both directions.
|
||||
// - the adapter owns the fact and stayed silent: not launched. An absent report
|
||||
// must not be read as proof that enforcement applied.
|
||||
// - nobody owns it but the caller: keep the direct observation, which is
|
||||
// correct for a direct command and for bwrap.
|
||||
func ResolveChildLaunched(observed bool, ownedByAdapter bool, report AdapterReport) bool {
|
||||
if report.ChildLaunched != nil {
|
||||
return *report.ChildLaunched
|
||||
}
|
||||
if ownedByAdapter {
|
||||
return false
|
||||
}
|
||||
return observed
|
||||
}
|
||||
|
||||
func capturedSetupFailure(message string, err error, enforcement Enforcement) CapturedResult {
|
||||
return CapturedResult{
|
||||
Stderr: message,
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
package execution
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A WRAPPER'S START IS NOT THE REQUESTED CHILD'S START.
|
||||
//
|
||||
// For a Windows restricted-token plan the command the runner starts is the
|
||||
// sandbox helper, not the executable the caller asked for. Inside that helper,
|
||||
// setup-marker validation, unelevated ACL application, network-policy
|
||||
// validation, capability and offline SID construction, restricted-token creation
|
||||
// and the CreateProcessAsUser call all happen afterwards, and each of them can
|
||||
// return with no sandboxed child ever created. exec.Cmd.Process is already
|
||||
// non-nil by then, so reading the launch state off it reports that reads were
|
||||
// denied as requested when the only thing that ran was the unsandboxed adapter.
|
||||
//
|
||||
// The fact belongs to whoever sees the transition. These pin both directions of
|
||||
// that boundary.
|
||||
type wrappedPreparer struct {
|
||||
script string
|
||||
owned bool
|
||||
childLaunched *bool
|
||||
reportNothing bool
|
||||
}
|
||||
|
||||
func (p *wrappedPreparer) PrepareExecution(ctx context.Context, _ Request) (PreparedCommand, error) {
|
||||
prepared := PreparedCommand{
|
||||
Command: launchStateShell(ctx, p.script),
|
||||
Enforcement: Enforcement{Notices: []string{launchStateNotice}},
|
||||
ChildLaunchOwnedByAdapter: p.owned,
|
||||
}
|
||||
if !p.reportNothing {
|
||||
launched := p.childLaunched
|
||||
prepared.Report = func() (AdapterReport, error) {
|
||||
return AdapterReport{ChildLaunched: launched}, nil
|
||||
}
|
||||
}
|
||||
return prepared, nil
|
||||
}
|
||||
|
||||
func capturedWrapped(t *testing.T, p *wrappedPreparer) CapturedResult {
|
||||
t.Helper()
|
||||
return NewRunner(p).ExecuteCaptured(context.Background(), CapturedRequest{Request: Request{
|
||||
Origin: OriginHook,
|
||||
Mode: ModeCaptured,
|
||||
Command: Command{Name: "irrelevant"},
|
||||
WorkingDirectory: t.TempDir(),
|
||||
WorkspaceRoots: []string{t.TempDir()},
|
||||
Approval: ApprovalContext{PolicyVersion: PolicyVersion},
|
||||
}})
|
||||
}
|
||||
|
||||
func TestWrappedPlanDisclosesOnlyWhatTheAdapterConfirms(t *testing.T) {
|
||||
// The helper starts and then fails before it can create the restricted child:
|
||||
// a bad setup marker, an ACL it could not apply, a network policy it rejected,
|
||||
// a token it could not mint. The wrapper process exists; the sandboxed one
|
||||
// never did, so nothing may be claimed about enforcement.
|
||||
t.Run("helper ran but never created the child", func(t *testing.T) {
|
||||
no := false
|
||||
result := capturedWrapped(t, &wrappedPreparer{script: "exit 1", owned: true, childLaunched: &no})
|
||||
// The wrapper really did run, which is the whole point: its exit code is
|
||||
// the script's. Without this the test could pass because nothing executed.
|
||||
if result.Outcome.Exit == nil || result.Outcome.Exit.Code != 1 {
|
||||
t.Fatalf("SETUP INVALID: the wrapper itself must have run; outcome = %+v", result.Outcome)
|
||||
}
|
||||
if notices := result.Outcome.AppliedEnforcementNotices(); len(notices) != 0 {
|
||||
t.Fatalf("a helper that never created the restricted child disclosed %q", notices)
|
||||
}
|
||||
})
|
||||
|
||||
// Same shape, but the adapter says nothing at all. Silence from the owner of
|
||||
// the fact is not permission to fall back to the wrapper's own start.
|
||||
t.Run("adapter that owns the fact stayed silent", func(t *testing.T) {
|
||||
result := capturedWrapped(t, &wrappedPreparer{script: "exit 1", owned: true, reportNothing: true})
|
||||
if notices := result.Outcome.AppliedEnforcementNotices(); len(notices) != 0 {
|
||||
t.Fatalf("an unreported child launch was disclosed as applied enforcement: %q", notices)
|
||||
}
|
||||
})
|
||||
|
||||
// And the other side of the boundary: a restricted child that really started
|
||||
// and then exited non-zero DID run under the disclosed enforcement, so the
|
||||
// notice must still be made, exactly once.
|
||||
t.Run("restricted child started, then failed", func(t *testing.T) {
|
||||
yes := true
|
||||
result := capturedWrapped(t, &wrappedPreparer{script: "exit 3", owned: true, childLaunched: &yes})
|
||||
notices := result.Outcome.AppliedEnforcementNotices()
|
||||
if len(notices) != 1 || notices[0] != launchStateNotice {
|
||||
t.Fatalf("a child that ran and then failed disclosed %q, want exactly one %q", notices, launchStateNotice)
|
||||
}
|
||||
})
|
||||
|
||||
// A direct, unwrapped command is unchanged: the process the runner starts IS
|
||||
// the requested one, so its own observation still decides.
|
||||
t.Run("direct command keeps its own observation", func(t *testing.T) {
|
||||
result := capturedWrapped(t, &wrappedPreparer{script: "exit 0", owned: false, reportNothing: true})
|
||||
notices := result.Outcome.AppliedEnforcementNotices()
|
||||
if len(notices) != 1 || notices[0] != launchStateNotice {
|
||||
t.Fatalf("a direct command that ran disclosed %q, want exactly one %q", notices, launchStateNotice)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -36,7 +36,25 @@ type DispatchOutcome struct {
|
||||
// Messages collects the output (stdout, else stderr) of each hook that
|
||||
// produced any, in run order. afterTool validators use this to feed results
|
||||
// (e.g. a formatter diff or vet warning) back to the model on the tool result.
|
||||
//
|
||||
// PRESENTATION TEXT, NOT A NOTICE CHANNEL, and it no longer carries notices at
|
||||
// all. hookMessage used to fold them in so afterTool got both on one line;
|
||||
// that made the disclosure arrive twice once the typed slice was composed by
|
||||
// every surface. Enforcement disclosures are on Notices, for both hook phases.
|
||||
// A caller that only wants to know what the sandbox did must read that:
|
||||
// delivering this slice would put every successful hook's routine logging into
|
||||
// the model's context.
|
||||
Messages []string
|
||||
// Notices carries only the enforcement disclosures, one entry per notice, in
|
||||
// run order across every hook that ran.
|
||||
//
|
||||
// Separate from Messages because they answer different questions and have
|
||||
// different audiences. A notice says the hook ran under a weakened token,
|
||||
// which the model and the operator both need; the hook's own output is for
|
||||
// afterTool validators that asked to be heard. Appended as each hook runs,
|
||||
// rather than read off the final result, so a disclosure from a hook that
|
||||
// already ran survives a later hook's veto ending the chain.
|
||||
Notices []string
|
||||
}
|
||||
|
||||
type commandResult struct {
|
||||
@@ -45,6 +63,13 @@ type commandResult struct {
|
||||
Stderr string
|
||||
Err error // set when the command could not be executed (not a non-zero exit)
|
||||
TimedOut bool // the hook started but its deadline/cancellation fired before it returned
|
||||
// Notices carries the enforcement disclosures the execution runner attached.
|
||||
//
|
||||
// Same reason as the plugin path: the generic execution contract is not
|
||||
// transport-only. Enforcement.Notices says what the sandbox actually did, and
|
||||
// a projection that keeps only stdout, stderr and an exit code drops it, so a
|
||||
// hook ran under a weakened token with nothing said about it.
|
||||
Notices []string
|
||||
}
|
||||
|
||||
// commandRunner executes one hook command. It is injectable so the dispatch
|
||||
@@ -139,6 +164,10 @@ func executionCommandRunner(runner *execution.Runner) commandRunner {
|
||||
Stderr: stderr,
|
||||
Err: commandErr,
|
||||
TimedOut: result.Outcome.Kind == execution.OutcomeTimedOut,
|
||||
// One shared decision: see Outcome.AppliedEnforcementNotices. A setup
|
||||
// failure or a missing executable launched no hook child, so the notice
|
||||
// would describe a token trade nobody made.
|
||||
Notices: result.Outcome.AppliedEnforcementNotices(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -182,11 +211,19 @@ func (dispatcher *Dispatcher) Dispatch(ctx context.Context, input DispatchInput)
|
||||
if message := hookMessage(result); message != "" {
|
||||
outcome.Messages = append(outcome.Messages, message)
|
||||
}
|
||||
// Appended per hook rather than read off the final result, so hook A's
|
||||
// disclosure is not lost when hook B stops the chain. A notice describes
|
||||
// something that ALREADY happened.
|
||||
for _, notice := range result.Notices {
|
||||
if strings.TrimSpace(notice) != "" {
|
||||
outcome.Notices = append(outcome.Notices, notice)
|
||||
}
|
||||
}
|
||||
|
||||
if blocked {
|
||||
outcome.Blocked = true
|
||||
outcome.BlockedBy = hook.ID
|
||||
outcome.Reason = blockReason(result)
|
||||
outcome.Reason = blockCause(result)
|
||||
// Stop on the first veto: the action is already denied.
|
||||
return outcome
|
||||
}
|
||||
@@ -243,14 +280,38 @@ func classifyResult(event Event, result commandResult) (AuditStatus, bool) {
|
||||
|
||||
// hookMessage returns the output worth surfacing from a hook run: stdout when
|
||||
// present, else stderr. Empty when the hook produced no output.
|
||||
// STDOUT OR STDERR, AND NOTHING ELSE. The enforcement notices used to be
|
||||
// prepended here as well, so that afterTool got the disclosure and its own output
|
||||
// on one line. That was the only delivery available when this was written, and it
|
||||
// is not any more: the notices travel typed on DispatchOutcome.Notices, and the
|
||||
// agent loop merges them into ToolResult.EnforcementNotices for beforeTool and
|
||||
// afterTool alike.
|
||||
//
|
||||
// Keeping the fold as well made the same disclosure arrive twice, once from the
|
||||
// typed slice that every surface composes and once inside the hook feedback block
|
||||
// in the body. The two writers carry the identical fixed string, so the model saw
|
||||
// it doubled and a bash or exec card showed it in both the furniture and the body.
|
||||
// One owner per fact, and for a notice that owner is the typed slice.
|
||||
func hookMessage(result commandResult) string {
|
||||
if trimmed := strings.TrimSpace(result.Stdout); trimmed != "" {
|
||||
return trimmed
|
||||
message := strings.TrimSpace(result.Stdout)
|
||||
if message == "" {
|
||||
message = strings.TrimSpace(result.Stderr)
|
||||
}
|
||||
return strings.TrimSpace(result.Stderr)
|
||||
return message
|
||||
}
|
||||
|
||||
func blockReason(result commandResult) string {
|
||||
// blockCause explains a veto in the hook's own words, and says nothing about
|
||||
// enforcement.
|
||||
//
|
||||
// THE SAME RULE AS hookMessage. The blocking hook's notices used to be folded in
|
||||
// here as well, from before the typed slice existed, and Dispatch had already
|
||||
// appended them to DispatchOutcome.Notices, so a hook that vetoed while running
|
||||
// without write confinement put one disclosure on two channels. The one consumer
|
||||
// made up for it by dropping every notice whose text appeared anywhere in the
|
||||
// reason, which also dropped an EARLIER hook's disclosure whenever the blocking
|
||||
// hook's output happened to quote it. The notice now travels on the typed slice
|
||||
// only, whichever hook in the chain produced it. Reported by CodeRabbit.
|
||||
func blockCause(result commandResult) string {
|
||||
if result.TimedOut {
|
||||
if trimmed := strings.TrimSpace(result.Stderr); trimmed != "" {
|
||||
return "hook timed out: " + trimmed
|
||||
@@ -288,7 +349,14 @@ func (dispatcher *Dispatcher) recordCompleted(hook Definition, input DispatchInp
|
||||
Matcher: hook.Matcher,
|
||||
ToolCallID: input.ToolCallID,
|
||||
Status: status,
|
||||
Results: []AuditResult{{ExitCode: result.ExitCode, Stdout: result.Stdout, Stderr: result.Stderr}},
|
||||
Results: []AuditResult{{
|
||||
ExitCode: result.ExitCode,
|
||||
Stdout: result.Stdout,
|
||||
Stderr: result.Stderr,
|
||||
// The notice is not in stdout or stderr by design, so the durable record
|
||||
// has to carry it or the fact ends with the dispatch result.
|
||||
EnforcementNotices: append([]string(nil), result.Notices...),
|
||||
}},
|
||||
DurationMs: durationMs,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -244,8 +244,8 @@ func TestClassifyResultTimedOutFailsClosedForBeforeTool(t *testing.T) {
|
||||
if status, blocked := classifyResult(EventAfterTool, timedOut); blocked || status != AuditError {
|
||||
t.Fatalf("afterTool timeout classify = (%q, %v), want (error, false)", status, blocked)
|
||||
}
|
||||
if reason := blockReason(timedOut); !strings.Contains(reason, "timed out") {
|
||||
t.Fatalf("blockReason = %q, want a timeout message", reason)
|
||||
if reason := blockCause(timedOut); !strings.Contains(reason, "timed out") {
|
||||
t.Fatalf("blockCause = %q, want a timeout message", reason)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
package hooks
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// auditedDispatcher wires a real audit store to a dispatcher whose hook result
|
||||
// is whatever the caller wants, and returns the events that survived the write.
|
||||
func auditedDispatcher(t *testing.T, hook Definition, result commandResult) []AuditEvent {
|
||||
t.Helper()
|
||||
store, err := NewAuditStore(AuditStoreOptions{AuditPath: filepath.Join(t.TempDir(), "audit.jsonl")})
|
||||
if err != nil {
|
||||
t.Fatalf("NewAuditStore: %v", err)
|
||||
}
|
||||
dispatcher := NewDispatcher(DispatcherOptions{
|
||||
Config: beforeToolConfig(hook),
|
||||
Audit: store,
|
||||
run: func(context.Context, string, []string, []byte, string, []string) commandResult {
|
||||
return result
|
||||
},
|
||||
})
|
||||
dispatcher.Dispatch(context.Background(), DispatchInput{Event: EventBeforeTool, ToolName: "bash", ToolCallID: "call_1"})
|
||||
|
||||
// READ BACK FROM DISK, not from the in-memory event the append returned. The
|
||||
// durable reader is the consumer this field exists for.
|
||||
events, err := store.ReadEvents()
|
||||
if err != nil {
|
||||
t.Fatalf("ReadEvents: %v", err)
|
||||
}
|
||||
return events
|
||||
}
|
||||
|
||||
func completedResults(t *testing.T, events []AuditEvent) []AuditResult {
|
||||
t.Helper()
|
||||
for _, event := range events {
|
||||
if len(event.Results) > 0 {
|
||||
return event.Results
|
||||
}
|
||||
}
|
||||
t.Fatalf("no completed record was written: %#v", events)
|
||||
return nil
|
||||
}
|
||||
|
||||
// THE TRANSIENT DISPATCH RESULT IS NOT WHERE THIS FACT CAN LIVE.
|
||||
//
|
||||
// recordCompleted kept an exit code, stdout and stderr, and the notice is
|
||||
// deliberately in none of those. So once the dispatch result was gone, an audit
|
||||
// or recovery reader could not tell that a hook had run under the weakened
|
||||
// DenyRead token, whatever the hook did afterwards.
|
||||
func TestTheAuditRecordKeepsTheEnforcementNotice(t *testing.T) {
|
||||
const notice = "denyRead is configured, so the write jail is not confining writes"
|
||||
|
||||
for _, testCase := range []struct {
|
||||
name string
|
||||
result commandResult
|
||||
}{
|
||||
{"launched and succeeded", commandResult{ExitCode: 0, Stdout: "looks fine", Notices: []string{notice}}},
|
||||
{"vetoed the tool", commandResult{ExitCode: 2, Stderr: "policy violation", Notices: []string{notice}}},
|
||||
{"silent hook", commandResult{ExitCode: 0, Notices: []string{notice}}},
|
||||
} {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
results := completedResults(t, auditedDispatcher(t,
|
||||
Definition{ID: "policy", Event: EventBeforeTool, Command: "policy-check", Enabled: true},
|
||||
testCase.result))
|
||||
if len(results) != 1 {
|
||||
t.Fatalf("results = %#v, want one", results)
|
||||
}
|
||||
if len(results[0].EnforcementNotices) != 1 || results[0].EnforcementNotices[0] != notice {
|
||||
t.Errorf("the durable record lost the disclosure: %#v", results[0])
|
||||
}
|
||||
// The existing semantics are untouched.
|
||||
if results[0].ExitCode != testCase.result.ExitCode {
|
||||
t.Errorf("ExitCode = %d, want %d", results[0].ExitCode, testCase.result.ExitCode)
|
||||
}
|
||||
if results[0].Stdout != testCase.result.Stdout || results[0].Stderr != testCase.result.Stderr {
|
||||
t.Errorf("stdout/stderr changed: %#v", results[0])
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A hook with nothing to disclose writes exactly what it wrote before, so a
|
||||
// reader of historical records sees no difference.
|
||||
func TestAnOrdinaryHookWritesNoEnforcementField(t *testing.T) {
|
||||
results := completedResults(t, auditedDispatcher(t,
|
||||
Definition{ID: "policy", Event: EventBeforeTool, Command: "policy-check", Enabled: true},
|
||||
commandResult{ExitCode: 0, Stdout: "looks fine"}))
|
||||
if len(results[0].EnforcementNotices) != 0 {
|
||||
t.Errorf("a hook with no disclosure recorded one: %#v", results[0])
|
||||
}
|
||||
}
|
||||
|
||||
// And the durable record inherits the launch-state rule rather than restating
|
||||
// it: a hook that never started records no enforcement claim.
|
||||
func TestTheAuditRecordMakesNoClaimForAHookThatNeverLaunched(t *testing.T) {
|
||||
store, err := NewAuditStore(AuditStoreOptions{AuditPath: filepath.Join(t.TempDir(), "audit.jsonl")})
|
||||
if err != nil {
|
||||
t.Fatalf("NewAuditStore: %v", err)
|
||||
}
|
||||
dispatcher := NewDispatcher(DispatcherOptions{
|
||||
Config: beforeToolConfig(Definition{ID: "policy", Event: EventBeforeTool, Command: "policy-check", Enabled: true}),
|
||||
Audit: store,
|
||||
Cwd: t.TempDir(),
|
||||
Execution: newRunnerFor(¬icePreparer{build: func() *exec.Cmd { return exec.Command("definitely-not-a-real-binary-zzz") }}),
|
||||
})
|
||||
dispatcher.Dispatch(context.Background(), DispatchInput{Event: EventBeforeTool, ToolName: "bash", ToolCallID: "call_1"})
|
||||
events, err := store.ReadEvents()
|
||||
if err != nil {
|
||||
t.Fatalf("ReadEvents: %v", err)
|
||||
}
|
||||
for _, result := range completedResults(t, events) {
|
||||
if len(result.EnforcementNotices) != 0 {
|
||||
t.Errorf("the durable record claims an enforcement trade for a hook that never started: %#v", result)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
//go:build !windows
|
||||
|
||||
package hooks
|
||||
|
||||
// sleepScript keeps a launched child alive long enough for a timeout to fire.
|
||||
const sleepScript = "sleep 2"
|
||||
@@ -0,0 +1,6 @@
|
||||
package hooks
|
||||
|
||||
// sleepScript keeps a launched child alive long enough for a timeout to fire.
|
||||
// Paired with the !windows file of the same name; both must exist or the
|
||||
// platform without one silently loses the timeout case.
|
||||
const sleepScript = "ping -n 3 127.0.0.1 > NUL"
|
||||
@@ -0,0 +1,184 @@
|
||||
package hooks
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Twigpine/zero/internal/execution"
|
||||
)
|
||||
|
||||
const launchStateNotice = "denyRead is configured, so the write jail is not confining writes"
|
||||
|
||||
// shellCommand builds a portable child that the platform can actually launch.
|
||||
func shellCommand(script string) *exec.Cmd {
|
||||
if runtime.GOOS == "windows" {
|
||||
return exec.Command("cmd.exe", "/c", script)
|
||||
}
|
||||
return exec.Command("/bin/sh", "-c", script)
|
||||
}
|
||||
|
||||
// noticePreparer plans a command carrying an enforcement notice, and can fail
|
||||
// the way the sandbox does before the child exists.
|
||||
type noticePreparer struct {
|
||||
prepareErr error
|
||||
build func() *exec.Cmd
|
||||
}
|
||||
|
||||
func (preparer *noticePreparer) PrepareExecution(_ context.Context, request execution.Request) (execution.PreparedCommand, error) {
|
||||
if preparer.prepareErr != nil {
|
||||
return execution.PreparedCommand{}, preparer.prepareErr
|
||||
}
|
||||
command := preparer.build
|
||||
if command == nil {
|
||||
command = func() *exec.Cmd { return exec.Command(request.Command.Name, request.Command.Args...) }
|
||||
}
|
||||
return execution.PreparedCommand{
|
||||
Command: command(),
|
||||
Enforcement: execution.Enforcement{Notices: []string{launchStateNotice}},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// PLANNING A WRAPPED COMMAND IS NOT PROOF THAT ANYTHING RAN.
|
||||
//
|
||||
// Enforcement.Notices describes the shape the command was PREPARED to run
|
||||
// under. Copying it straight out made the completed-enforcement claim for
|
||||
// commands that never existed: a sandbox setup failure and a missing executable
|
||||
// are both decided before the child launches, so the hook message told the
|
||||
// operator the write jail had been traded away for a process that never
|
||||
// started.
|
||||
//
|
||||
// Everything after launch keeps the disclosure, including a nonzero exit, a
|
||||
// timeout and a cancellation: those happened to a child that really did run
|
||||
// under that token.
|
||||
//
|
||||
// Driven through the execution runner rather than a hand-built commandResult,
|
||||
// because the projection is the thing under test.
|
||||
func TestTheHookRunnerOnlyDisclosesEnforcementForAChildThatLaunched(t *testing.T) {
|
||||
for _, testCase := range []struct {
|
||||
name string
|
||||
preparer *noticePreparer
|
||||
timeout time.Duration
|
||||
wantNotice bool
|
||||
wantTimedOut bool
|
||||
}{
|
||||
{
|
||||
name: "sandbox setup failed before the child existed",
|
||||
preparer: ¬icePreparer{prepareErr: errors.New("could not build the restricted token")},
|
||||
wantNotice: false,
|
||||
},
|
||||
{
|
||||
name: "the executable was never found",
|
||||
preparer: ¬icePreparer{build: func() *exec.Cmd {
|
||||
return exec.Command("definitely-not-a-real-binary-zzz")
|
||||
}},
|
||||
wantNotice: false,
|
||||
},
|
||||
{
|
||||
name: "the child launched and succeeded",
|
||||
preparer: ¬icePreparer{build: func() *exec.Cmd { return shellCommand("exit 0") }},
|
||||
wantNotice: true,
|
||||
},
|
||||
{
|
||||
name: "the child launched and exited nonzero",
|
||||
preparer: ¬icePreparer{build: func() *exec.Cmd { return shellCommand("exit 3") }},
|
||||
wantNotice: true,
|
||||
},
|
||||
{
|
||||
name: "the child launched and timed out",
|
||||
preparer: ¬icePreparer{build: func() *exec.Cmd { return shellCommand(sleepScript) }},
|
||||
timeout: 150 * time.Millisecond,
|
||||
wantNotice: true,
|
||||
wantTimedOut: true,
|
||||
},
|
||||
} {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
if testCase.timeout > 0 {
|
||||
var cancel context.CancelFunc
|
||||
ctx, cancel = context.WithTimeout(ctx, testCase.timeout)
|
||||
defer cancel()
|
||||
}
|
||||
run := executionCommandRunner(execution.NewRunner(testCase.preparer))
|
||||
result := run(ctx, "hook-command", nil, nil, t.TempDir(), nil)
|
||||
|
||||
if result.TimedOut != testCase.wantTimedOut {
|
||||
t.Fatalf("TimedOut = %v, want %v: the case did not reach the outcome kind it is named for", result.TimedOut, testCase.wantTimedOut)
|
||||
}
|
||||
if got := len(result.Notices) > 0; got != testCase.wantNotice {
|
||||
t.Fatalf("notices present = %v, want %v: %#v", got, testCase.wantNotice, result.Notices)
|
||||
}
|
||||
// Asserted on Notices, which is where the disclosure travels. It used
|
||||
// to be folded into the hook message as well, and the agent loop now
|
||||
// merges Notices into the typed enforcement slice for both hook
|
||||
// phases, so carrying it in both places delivered it twice.
|
||||
notices := strings.Join(result.Notices, "\n")
|
||||
if testCase.wantNotice && !strings.Contains(notices, launchStateNotice) {
|
||||
t.Errorf("a launched child lost its disclosure:\n%s", notices)
|
||||
}
|
||||
if !testCase.wantNotice && strings.Contains(notices, launchStateNotice) {
|
||||
t.Errorf("a child that never launched claimed the token was traded away:\n%s", notices)
|
||||
}
|
||||
if message := hookMessage(result); strings.Contains(message, launchStateNotice) {
|
||||
t.Errorf("the disclosure also rode along in the hook message, so it reaches the model twice:\n%s", message)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// And the same rule has to hold on the veto path, which returns early and is what
|
||||
// the model actually sees. Both channels are checked: the notice travels on
|
||||
// Notices, and Reason must not grow a copy of it either.
|
||||
func TestAVetoingHookThatNeverLaunchedClaimsNoEnforcement(t *testing.T) {
|
||||
dispatcher := NewDispatcher(DispatcherOptions{
|
||||
Config: beforeToolConfig(Definition{ID: "policy", Event: EventBeforeTool, Command: "policy-check", Enabled: true}),
|
||||
Cwd: t.TempDir(),
|
||||
// A missing executable rather than a prepare error: a prepare error never
|
||||
// builds the PreparedCommand, so its outcome carries no planned notice and
|
||||
// the assertion below would hold with the launch gate deleted. This shape
|
||||
// plans the notice and then fails to launch.
|
||||
Execution: execution.NewRunner(¬icePreparer{build: func() *exec.Cmd {
|
||||
return exec.Command("definitely-not-a-real-binary-zzz")
|
||||
}}),
|
||||
})
|
||||
outcome := dispatcher.Dispatch(context.Background(), DispatchInput{Event: EventBeforeTool, ToolName: "bash"})
|
||||
if !outcome.Blocked {
|
||||
t.Fatal("SETUP INVALID: a beforeTool hook that could not run must fail closed, or the veto path is not exercised")
|
||||
}
|
||||
if len(outcome.Notices) != 0 {
|
||||
t.Errorf("the dispatch outcome claims an enforcement trade for a hook that never started: %#v", outcome.Notices)
|
||||
}
|
||||
if strings.Contains(outcome.Reason, launchStateNotice) {
|
||||
t.Errorf("the veto reason claims an enforcement trade for a hook that never started:\n%s", outcome.Reason)
|
||||
}
|
||||
}
|
||||
|
||||
// A launched hook still carries it all the way into the dispatch outcome, on the
|
||||
// typed slice and only there.
|
||||
func TestALaunchedHookCarriesTheNoticeIntoTheDispatchOutcome(t *testing.T) {
|
||||
dispatcher := NewDispatcher(DispatcherOptions{
|
||||
Config: beforeToolConfig(Definition{ID: "policy", Event: EventBeforeTool, Command: "policy-check", Enabled: true}),
|
||||
Cwd: t.TempDir(),
|
||||
Execution: execution.NewRunner(¬icePreparer{build: func() *exec.Cmd { return shellCommand("exit 2") }}),
|
||||
})
|
||||
outcome := dispatcher.Dispatch(context.Background(), DispatchInput{Event: EventBeforeTool, ToolName: "bash"})
|
||||
if !outcome.Blocked {
|
||||
t.Fatal("SETUP INVALID: the hook did not veto, so the reason path is not exercised")
|
||||
}
|
||||
if got := strings.Count(strings.Join(outcome.Notices, "\n"), launchStateNotice); got != 1 {
|
||||
t.Errorf("a hook that really ran under the weakened token disclosed it %d times on Notices, want once: %#v", got, outcome.Notices)
|
||||
}
|
||||
if strings.Contains(outcome.Reason, launchStateNotice) {
|
||||
t.Errorf("the veto reason carries a second copy of the notice:\n%s", outcome.Reason)
|
||||
}
|
||||
}
|
||||
|
||||
// newRunnerFor keeps the audit tests readable without importing the execution
|
||||
// package into every file that needs one.
|
||||
func newRunnerFor(preparer *noticePreparer) *execution.Runner {
|
||||
return execution.NewRunner(preparer)
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package hooks
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A HOOK THAT RAN UNDER THE WEAKENED TOKEN STILL SAYS SO, ON THE TYPED CHANNEL.
|
||||
//
|
||||
// The projection once kept stdout, stderr and an exit code and dropped the
|
||||
// notices, so such a hook ran silently. The first fix put them into the message
|
||||
// alongside the hook's own output, which delivered them but made Messages two
|
||||
// things at once. Now that the agent loop merges Notices into the typed
|
||||
// EnforcementNotices for beforeTool and afterTool alike, folding them into the
|
||||
// message as well delivered the same disclosure twice.
|
||||
//
|
||||
// So the property is unchanged and its carrier moved: whatever the hook printed,
|
||||
// the notice reaches Notices exactly once and never rides along in Messages.
|
||||
func TestAHookSurfacesTheEnforcementNoticeOnTheTypedChannel(t *testing.T) {
|
||||
const notice = "denyRead is configured, so the write jail is not confining writes"
|
||||
|
||||
for _, testCase := range []struct {
|
||||
name string
|
||||
result commandResult
|
||||
}{
|
||||
{"hook printed nothing", commandResult{ExitCode: 0, Notices: []string{notice}}},
|
||||
{"hook printed to stdout", commandResult{ExitCode: 0, Stdout: "looks fine", Notices: []string{notice}}},
|
||||
{"hook printed to stderr only", commandResult{ExitCode: 0, Stderr: "a warning", Notices: []string{notice}}},
|
||||
} {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
result := testCase.result
|
||||
dispatcher := NewDispatcher(DispatcherOptions{
|
||||
Config: beforeToolConfig(Definition{ID: "vet", Event: EventAfterTool, Command: "vet", Enabled: true}),
|
||||
run: func(context.Context, string, []string, []byte, string, []string) commandResult {
|
||||
return result
|
||||
},
|
||||
})
|
||||
outcome := dispatcher.Dispatch(context.Background(), DispatchInput{Event: EventAfterTool, ToolName: "bash"})
|
||||
|
||||
if got := strings.Count(strings.Join(outcome.Notices, "\n"), notice); got != 1 {
|
||||
t.Fatalf("the notice reached Notices %d times, want once: %v", got, outcome.Notices)
|
||||
}
|
||||
// AND NOT IN THE PROSE AS WELL, which is what made it arrive twice.
|
||||
if joined := strings.Join(outcome.Messages, "\n"); strings.Contains(joined, notice) {
|
||||
t.Errorf("the notice also rode along in Messages, so every surface that composes the typed slice shows it twice:\n%s", joined)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A hook's own output is untouched, with a notice or without one.
|
||||
func TestAHookMessageCarriesOnlyTheHooksOwnOutput(t *testing.T) {
|
||||
const notice = "denyRead is configured, so the write jail is not confining writes"
|
||||
if message := hookMessage(commandResult{ExitCode: 0, Stdout: "looks fine"}); message != "looks fine" {
|
||||
t.Errorf("hookMessage = %q, want the hook's own output untouched", message)
|
||||
}
|
||||
if message := hookMessage(commandResult{ExitCode: 0, Stdout: "looks fine", Notices: []string{notice}}); message != "looks fine" {
|
||||
t.Errorf("hookMessage = %q, want the notice left to the typed channel", message)
|
||||
}
|
||||
if message := hookMessage(commandResult{ExitCode: 0}); message != "" {
|
||||
t.Errorf("a silent hook with no notice produced %q", message)
|
||||
}
|
||||
if message := hookMessage(commandResult{ExitCode: 0, Notices: []string{notice}}); message != "" {
|
||||
t.Errorf("a silent hook produced %q, want nothing: its disclosure travels typed", message)
|
||||
}
|
||||
}
|
||||
|
||||
// THROUGH Dispatch, NOT A HAND-BUILT commandResult.
|
||||
//
|
||||
// The blocking branch returns immediately, so it never touches hookMessage, and a
|
||||
// vetoing beforeTool hook that ran without write confinement once reported only
|
||||
// the veto. The disclosure still has to come out of that branch, and it has to
|
||||
// come out ONCE: the blocking hook's notices used to be folded into Reason as well
|
||||
// as appended to Notices, so the same fact travelled on two channels and the
|
||||
// consumer had to guess which copy to drop. Reason is the hook's words, and the
|
||||
// notice travels on the typed slice like every other hook's.
|
||||
func TestABlockedBeforeToolHookCarriesItsNoticeOnTheTypedSliceOnly(t *testing.T) {
|
||||
const notice = "denyRead is configured, so the write jail is not confining writes"
|
||||
|
||||
dispatcher := NewDispatcher(DispatcherOptions{
|
||||
Config: beforeToolConfig(Definition{ID: "policy", Event: EventBeforeTool, Command: "policy-check", Enabled: true}),
|
||||
run: func(context.Context, string, []string, []byte, string, []string) commandResult {
|
||||
return commandResult{ExitCode: 2, Stderr: "policy violation", Notices: []string{notice}}
|
||||
},
|
||||
})
|
||||
|
||||
outcome := dispatcher.Dispatch(context.Background(), DispatchInput{Event: EventBeforeTool, ToolName: "bash"})
|
||||
if !outcome.Blocked {
|
||||
t.Fatal("SETUP INVALID: the hook did not block, so the blocking branch was never taken")
|
||||
}
|
||||
if len(outcome.Notices) != 1 || outcome.Notices[0] != notice {
|
||||
t.Errorf("the veto lost the enforcement notice from the typed slice: %#v", outcome.Notices)
|
||||
}
|
||||
if outcome.Reason != "policy violation" {
|
||||
t.Errorf("Reason = %q, want the hook's own explanation and nothing else", outcome.Reason)
|
||||
}
|
||||
// Across BOTH channels, because a copy in each is the defect this pins.
|
||||
if got := strings.Count(outcome.Reason, notice) + strings.Count(strings.Join(outcome.Notices, "\n"), notice); got != 1 {
|
||||
t.Errorf("the notice appears %d times across Reason and Notices, want once", got)
|
||||
}
|
||||
}
|
||||
|
||||
// And a veto with no notice reads exactly as it did before.
|
||||
func TestABlockedHookWithoutANoticeIsUnchanged(t *testing.T) {
|
||||
dispatcher := NewDispatcher(DispatcherOptions{
|
||||
Config: beforeToolConfig(Definition{ID: "policy", Event: EventBeforeTool, Command: "policy-check", Enabled: true}),
|
||||
run: func(context.Context, string, []string, []byte, string, []string) commandResult {
|
||||
return commandResult{ExitCode: 2, Stderr: "policy violation"}
|
||||
},
|
||||
})
|
||||
outcome := dispatcher.Dispatch(context.Background(), DispatchInput{Event: EventBeforeTool, ToolName: "bash"})
|
||||
if outcome.Reason != "policy violation" {
|
||||
t.Errorf("Reason = %q, want the hook's own explanation untouched", outcome.Reason)
|
||||
}
|
||||
}
|
||||
@@ -118,6 +118,19 @@ type AuditResult struct {
|
||||
ExitCode int `json:"exitCode"`
|
||||
Stdout string `json:"stdout,omitempty"`
|
||||
Stderr string `json:"stderr,omitempty"`
|
||||
// EnforcementNotices are the least-privilege disclosures that were true of
|
||||
// this hook's execution.
|
||||
//
|
||||
// A DURABLE READER CANNOT RECOVER A FACT THAT WAS DROPPED IN CONVERSION. The
|
||||
// notice is deliberately not written into stdout or stderr, so recording only
|
||||
// those three fields meant that once the transient dispatch result was gone,
|
||||
// nothing could tell an audit or recovery reader that a successful, failing or
|
||||
// vetoing hook had run under the weakened DenyRead token.
|
||||
//
|
||||
// Typed rather than a rendered line, and omitempty, so historical records that
|
||||
// predate the field read back unchanged and an ordinary hook writes exactly
|
||||
// what it wrote before.
|
||||
EnforcementNotices []string `json:"enforcementNotices,omitempty"`
|
||||
}
|
||||
|
||||
type AuditEvent struct {
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
|
||||
"github.com/Twigpine/zero/internal/config"
|
||||
"github.com/Twigpine/zero/internal/execution"
|
||||
"github.com/Twigpine/zero/internal/tools"
|
||||
)
|
||||
|
||||
// helperCommandName is a command that resolves on this platform, so registration
|
||||
// reaches connectStdio. What actually runs is whatever the preparer returns.
|
||||
func helperCommandName() string {
|
||||
if runtime.GOOS == "windows" {
|
||||
return "cmd.exe"
|
||||
}
|
||||
return "sh"
|
||||
}
|
||||
|
||||
const adapterLaunchNotice = "denyRead is configured, so the write jail is not confining writes"
|
||||
|
||||
// adapterHelperPreparer models the Windows wrapped plan: the command is the
|
||||
// HELPER, the launch fact is a report file, and the plan's cleanup removes that
|
||||
// file. The cleanup is the part that matters, because it is what runs inside
|
||||
// client.Close and destroys the evidence a later decision needs.
|
||||
type adapterHelperPreparer struct {
|
||||
reportPath string
|
||||
reportBody string
|
||||
writeReport bool
|
||||
called bool
|
||||
}
|
||||
|
||||
func (preparer *adapterHelperPreparer) PrepareExecution(_ context.Context, _ execution.Request) (execution.PreparedCommand, error) {
|
||||
preparer.called = true
|
||||
if preparer.writeReport {
|
||||
if err := os.WriteFile(preparer.reportPath, []byte(preparer.reportBody), 0o600); err != nil {
|
||||
return execution.PreparedCommand{}, err
|
||||
}
|
||||
}
|
||||
// A helper that starts, says nothing an MCP client understands, and exits, so
|
||||
// the handshake fails the way it does when the requested server never existed.
|
||||
var command *exec.Cmd
|
||||
if runtime.GOOS == "windows" {
|
||||
command = exec.Command("cmd.exe", "/c", "exit 0")
|
||||
} else {
|
||||
command = exec.Command("/bin/sh", "-c", "exit 0")
|
||||
}
|
||||
path := preparer.reportPath
|
||||
return execution.PreparedCommand{
|
||||
Command: command,
|
||||
ChildLaunchOwnedByAdapter: true,
|
||||
Enforcement: execution.Enforcement{Notices: []string{adapterLaunchNotice}},
|
||||
Report: func() (execution.AdapterReport, error) {
|
||||
raw, err := os.ReadFile(path)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return execution.AdapterReport{}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return execution.AdapterReport{}, err
|
||||
}
|
||||
var report execution.AdapterReport
|
||||
if err := json.Unmarshal(raw, &report); err != nil {
|
||||
return execution.AdapterReport{}, err
|
||||
}
|
||||
return report, nil
|
||||
},
|
||||
Cleanup: func() { _ = os.Remove(path) },
|
||||
}, nil
|
||||
}
|
||||
|
||||
func registerWithAdapterHelper(t *testing.T, preparer *adapterHelperPreparer) *Runtime {
|
||||
t.Helper()
|
||||
runtime, err := RegisterTools(context.Background(), tools.NewRegistry(), config.MCPConfig{Servers: map[string]config.MCPServerConfig{
|
||||
"docs": {Type: "stdio", Command: helperCommandName()},
|
||||
}}, RegisterOptions{
|
||||
// No ClientFactory on purpose: an injected factory skips connectStdio
|
||||
// entirely, which is where the decision under test is made, and the test
|
||||
// would pass with the fix removed.
|
||||
Execution: execution.NewRunner(preparer),
|
||||
WorkspaceRoot: t.TempDir(),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("RegisterTools: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = runtime.Close() })
|
||||
return runtime
|
||||
}
|
||||
|
||||
// A HELPER THAT NEVER CREATED THE SERVER HAS NOTHING TO DISCLOSE.
|
||||
//
|
||||
// For an adapter-owned launch, cmd.Start proves only that the sandbox helper
|
||||
// started. It can then fail setup-marker validation, ACL application, network
|
||||
// validation, token construction, or CreateProcessAsUser without ever creating
|
||||
// the requested MCP server. The launch sink already made that distinction; the
|
||||
// initialize-error path did not, and carried the planned notices out
|
||||
// unconditionally. The operator was told the server had run without write
|
||||
// confinement when no server had run at all.
|
||||
func TestAnMCPHelperThatReportedNoChildDisclosesNothing(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
preparer := &adapterHelperPreparer{
|
||||
reportPath: filepath.Join(directory, "report.json"),
|
||||
reportBody: `{"childLaunched":false}`,
|
||||
writeReport: true,
|
||||
}
|
||||
runtime := registerWithAdapterHelper(t, preparer)
|
||||
|
||||
// SETUP: the attempt really did fail, or there is no disclosure decision here.
|
||||
if len(runtime.Skipped()) == 0 {
|
||||
t.Fatal("SETUP INVALID: the server connected, so the initialize-failure path is not under test")
|
||||
}
|
||||
if got := runtime.StartupDisclosures(); len(got) != 0 {
|
||||
t.Fatalf("a helper that reported no child announced %v; no server ran, confined or otherwise", got)
|
||||
}
|
||||
}
|
||||
|
||||
// AND ONE THAT DID CREATE IT DISCLOSES ONCE.
|
||||
//
|
||||
// The companion case, and the one that keeps the assertion above from being
|
||||
// satisfied by a path that discloses nothing ever. The child ran under the
|
||||
// planned token and may have done filesystem work before the handshake failed,
|
||||
// so the disclosure has to survive the failure.
|
||||
func TestAnMCPHelperThatLaunchedTheChildDisclosesOnce(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
preparer := &adapterHelperPreparer{
|
||||
reportPath: filepath.Join(directory, "report.json"),
|
||||
reportBody: `{"childLaunched":true}`,
|
||||
writeReport: true,
|
||||
}
|
||||
runtime := registerWithAdapterHelper(t, preparer)
|
||||
|
||||
if !preparer.called {
|
||||
t.Fatal("SETUP INVALID: the preparer never ran, so connectStdio was never reached")
|
||||
}
|
||||
disclosures := runtime.StartupDisclosures()
|
||||
if len(disclosures) != 1 {
|
||||
t.Fatalf("a server that really ran under the weakened token produced %d disclosures, want exactly one: %v", len(disclosures), disclosures)
|
||||
}
|
||||
if len(disclosures[0].Notices) != 1 || disclosures[0].Notices[0] != adapterLaunchNotice {
|
||||
t.Fatalf("the disclosure carries %v, want the one planned notice", disclosures[0].Notices)
|
||||
}
|
||||
}
|
||||
|
||||
// A helper that wrote no report at all is the same answer as one that reported
|
||||
// no child: absence is not confirmation.
|
||||
func TestAnMCPHelperThatWroteNoReportDisclosesNothing(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
preparer := &adapterHelperPreparer{reportPath: filepath.Join(directory, "report.json")}
|
||||
runtime := registerWithAdapterHelper(t, preparer)
|
||||
|
||||
if len(runtime.Skipped()) == 0 {
|
||||
t.Fatal("SETUP INVALID: the server connected, so the initialize-failure path is not under test")
|
||||
}
|
||||
if got := runtime.StartupDisclosures(); len(got) != 0 {
|
||||
t.Fatalf("a helper that published nothing announced %v", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Twigpine/zero/internal/config"
|
||||
"github.com/Twigpine/zero/internal/execution"
|
||||
"github.com/Twigpine/zero/internal/tools"
|
||||
)
|
||||
|
||||
const (
|
||||
adapterHelperModeEnv = "ZERO_TEST_MCP_ADAPTER_MODE"
|
||||
adapterHelperReportEnv = "ZERO_TEST_MCP_ADAPTER_REPORT"
|
||||
|
||||
// answerThenPublish models the ordering the fix is about: the child is
|
||||
// serving before the adapter has recorded that it exists.
|
||||
answerThenPublish = "answer-then-publish"
|
||||
// failThenPublish is the same ordering on the other exit: the handshake dies
|
||||
// first and the adapter publishes on its way out.
|
||||
failThenPublish = "fail-then-publish"
|
||||
// failAndNeverPublish is the companion that keeps the two above from being
|
||||
// satisfied by disclosing unconditionally.
|
||||
failAndNeverPublish = "fail-and-never-publish"
|
||||
)
|
||||
|
||||
// TestAdapterHelperProcess is the helper process body, not a test.
|
||||
//
|
||||
// It exists so the report can be published at a controlled point RELATIVE TO THE
|
||||
// HANDSHAKE. A fixture that writes the finished report during PrepareExecution,
|
||||
// as the older test does, publishes before the helper command has even started,
|
||||
// so every ordering this file is about is already over before the parent looks.
|
||||
func TestAdapterHelperProcess(t *testing.T) {
|
||||
mode := os.Getenv(adapterHelperModeEnv)
|
||||
if mode == "" {
|
||||
t.Skip("not the helper process")
|
||||
}
|
||||
reportPath := os.Getenv(adapterHelperReportEnv)
|
||||
publish := func() {
|
||||
// Same shape the Windows helper writes, into the file the preparer already
|
||||
// created empty.
|
||||
_ = os.WriteFile(reportPath, []byte(`{"childLaunched":true}`), 0o600)
|
||||
}
|
||||
|
||||
switch mode {
|
||||
case answerThenPublish:
|
||||
serveOneMCPSessionThenPublish(publish)
|
||||
case failThenPublish:
|
||||
// The handshake dies here, before anything is recorded. Closing stdout is
|
||||
// the child going away; the adapter is still running.
|
||||
_ = os.Stdout.Close()
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
publish()
|
||||
case failAndNeverPublish:
|
||||
_ = os.Stdout.Close()
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
}
|
||||
// Before the framework can write anything to the pipe the parent is reading.
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
// serveOneMCPSessionThenPublish answers the handshake and only then records that
|
||||
// the child exists, which is the interleaving that used to lose the disclosure.
|
||||
func serveOneMCPSessionThenPublish(publish func()) {
|
||||
reader := bufio.NewReader(os.Stdin)
|
||||
out := bufio.NewWriter(os.Stdout)
|
||||
published := false
|
||||
for {
|
||||
line, err := reader.ReadString('\n')
|
||||
if strings.TrimSpace(line) != "" {
|
||||
var message struct {
|
||||
ID *int `json:"id"`
|
||||
Method string `json:"method"`
|
||||
}
|
||||
if json.Unmarshal([]byte(strings.TrimSpace(line)), &message) == nil && message.ID != nil {
|
||||
var result string
|
||||
switch message.Method {
|
||||
case "initialize":
|
||||
result = `{"protocolVersion":"2024-11-05"}`
|
||||
case "tools/list":
|
||||
result = `{"tools":[]}`
|
||||
default:
|
||||
result = `{}`
|
||||
}
|
||||
fmt.Fprintf(out, `{"jsonrpc":"2.0","id":%d,"result":%s}`+"\n", *message.ID, result)
|
||||
_ = out.Flush()
|
||||
if message.Method == "initialize" && !published {
|
||||
// AFTER the response is on the wire. The parent can act on a
|
||||
// handshake that succeeded before this line runs.
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
publish()
|
||||
published = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// livePublishPreparer models the Windows wrapped plan with the real publication
|
||||
// ordering: the report file is created EMPTY before the launch, exactly as
|
||||
// openWindowsExecutionReport does, and the helper fills it in later.
|
||||
type livePublishPreparer struct {
|
||||
mode string
|
||||
reportPath string
|
||||
called bool
|
||||
}
|
||||
|
||||
func (preparer *livePublishPreparer) PrepareExecution(_ context.Context, _ execution.Request) (execution.PreparedCommand, error) {
|
||||
preparer.called = true
|
||||
// The empty file the parent can see before anything is published. This is the
|
||||
// state that is neither "no child" nor "child created".
|
||||
if err := os.WriteFile(preparer.reportPath, nil, 0o600); err != nil {
|
||||
return execution.PreparedCommand{}, err
|
||||
}
|
||||
command := exec.Command(os.Args[0], "-test.run=^TestAdapterHelperProcess$")
|
||||
command.Env = append(os.Environ(),
|
||||
adapterHelperModeEnv+"="+preparer.mode,
|
||||
adapterHelperReportEnv+"="+preparer.reportPath,
|
||||
)
|
||||
path := preparer.reportPath
|
||||
return execution.PreparedCommand{
|
||||
Command: command,
|
||||
ChildLaunchOwnedByAdapter: true,
|
||||
Enforcement: execution.Enforcement{Notices: []string{adapterLaunchNotice}},
|
||||
Report: func() (execution.AdapterReport, error) {
|
||||
raw, err := os.ReadFile(path)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return execution.AdapterReport{}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return execution.AdapterReport{}, err
|
||||
}
|
||||
var report execution.AdapterReport
|
||||
// An empty or half-written file decodes to an error, which is the
|
||||
// unsettled state and not an answer.
|
||||
if err := json.Unmarshal(raw, &report); err != nil {
|
||||
return execution.AdapterReport{}, err
|
||||
}
|
||||
return report, nil
|
||||
},
|
||||
Cleanup: func() { _ = os.Remove(path) },
|
||||
}, nil
|
||||
}
|
||||
|
||||
func registerWithLivePublisher(t *testing.T, mode string) (*Runtime, *livePublishPreparer) {
|
||||
t.Helper()
|
||||
preparer := &livePublishPreparer{mode: mode, reportPath: filepath.Join(t.TempDir(), "report.json")}
|
||||
runtime, err := RegisterTools(context.Background(), tools.NewRegistry(), config.MCPConfig{Servers: map[string]config.MCPServerConfig{
|
||||
"docs": {Type: "stdio", Command: helperCommandName()},
|
||||
}}, RegisterOptions{
|
||||
Execution: execution.NewRunner(preparer),
|
||||
WorkspaceRoot: t.TempDir(),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("RegisterTools: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = runtime.Close() })
|
||||
if !preparer.called {
|
||||
t.Fatal("SETUP INVALID: the preparer never ran, so connectStdio was never reached")
|
||||
}
|
||||
return runtime, preparer
|
||||
}
|
||||
|
||||
// A HANDSHAKE THAT SUCCEEDED IS PROOF THE CHILD RAN, WHATEVER THE REPORT SAYS YET.
|
||||
//
|
||||
// The child is created before the adapter records it, and it inherits the MCP
|
||||
// pipes, so it can answer initialize while the report file is still the empty one
|
||||
// the adapter opened. Reading it at that instant and remembering the answer
|
||||
// turned "not yet" into "never" for the rest of the session, and the operator was
|
||||
// never told the server serving these tools ran without the write jail.
|
||||
//
|
||||
// The adapter speaks no MCP, so a well-formed response can only have come from
|
||||
// the requested child. That is terminal evidence and needs no report.
|
||||
func TestADisclosureSurvivesAReportPublishedAfterTheHandshake(t *testing.T) {
|
||||
runtime, _ := registerWithLivePublisher(t, answerThenPublish)
|
||||
|
||||
// SETUP: the server really connected, or this is the failure path instead.
|
||||
if skipped := runtime.Skipped(); len(skipped) != 0 {
|
||||
t.Fatalf("SETUP INVALID: the server did not connect (%v), so the successful-handshake path is not under test", skipped)
|
||||
}
|
||||
disclosures := runtime.StartupDisclosures()
|
||||
if len(disclosures) != 1 {
|
||||
t.Fatalf("a server that answered the handshake produced %d disclosures, want exactly one: %v", len(disclosures), disclosures)
|
||||
}
|
||||
if len(disclosures[0].Notices) != 1 || disclosures[0].Notices[0] != adapterLaunchNotice {
|
||||
t.Fatalf("the disclosure carries %v, want the one planned notice", disclosures[0].Notices)
|
||||
}
|
||||
}
|
||||
|
||||
// AND ON THE OTHER EXIT, THE DECISION WAITS FOR THE ADAPTER.
|
||||
//
|
||||
// Here the handshake dies first and the adapter publishes on its way out. The
|
||||
// decision used to be taken before Close, precisely because Close deletes the
|
||||
// report, so it read the empty file and answered "no child" about a server that
|
||||
// really had run. Settling from inside cleanup, with the file still there, is what
|
||||
// lets the answer be taken after the adapter is terminal instead of before it.
|
||||
func TestADisclosureSurvivesAReportPublishedAfterAFailedHandshake(t *testing.T) {
|
||||
runtime, _ := registerWithLivePublisher(t, failThenPublish)
|
||||
|
||||
// SETUP: the attempt really failed, or the successful path is being measured.
|
||||
if len(runtime.Skipped()) == 0 {
|
||||
t.Fatal("SETUP INVALID: the server connected, so the initialize-failure path is not under test")
|
||||
}
|
||||
disclosures := runtime.StartupDisclosures()
|
||||
if len(disclosures) != 1 {
|
||||
t.Fatalf("a helper that published its launch on the way out produced %d disclosures, want exactly one: %v", len(disclosures), disclosures)
|
||||
}
|
||||
|
||||
// THROUGH THE ERROR, not only through the sink. Registration merges the two,
|
||||
// so asserting the sink alone passes while the failure the operator actually
|
||||
// reads carries nothing. This is the carrier that exists because the client
|
||||
// holding the notices has already been closed and discarded by then.
|
||||
skipped := runtime.Skipped()
|
||||
if len(skipped) != 1 {
|
||||
t.Fatalf("skipped = %v, want exactly the one server", skipped)
|
||||
}
|
||||
carried := startupNoticesFromError(skipped[0].Err)
|
||||
if len(carried) != 1 || carried[0] != adapterLaunchNotice {
|
||||
t.Fatalf("the initialize failure carries %v, want the planned notice; the decision was taken before the adapter settled", carried)
|
||||
}
|
||||
}
|
||||
|
||||
// AND AN ADAPTER THAT NEVER PUBLISHED STILL DISCLOSES NOTHING.
|
||||
//
|
||||
// The companion that keeps both cases above from being satisfied by announcing
|
||||
// unconditionally. Settling reads the report one last time and finds nothing,
|
||||
// which after the adapter has exited is the answer rather than a race.
|
||||
func TestNoDisclosureWhenTheAdapterExitsWithoutPublishing(t *testing.T) {
|
||||
runtime, _ := registerWithLivePublisher(t, failAndNeverPublish)
|
||||
|
||||
if len(runtime.Skipped()) == 0 {
|
||||
t.Fatal("SETUP INVALID: the server connected, so the initialize-failure path is not under test")
|
||||
}
|
||||
if got := runtime.StartupDisclosures(); len(got) != 0 {
|
||||
t.Fatalf("a helper that published nothing announced %v; no server is known to have run", got)
|
||||
}
|
||||
}
|
||||
|
||||
// THE CARRIER ENFORCES THE RULE ITSELF, NOT ONLY ITS CALLERS.
|
||||
//
|
||||
// connectAndList reads StartupNotices on the success path and passes the result
|
||||
// straight through to the disclosure sources, so this method is a carrier of the
|
||||
// launch fact in its own right. Today the handshake confirmation makes the gate
|
||||
// redundant: every route that reaches here has already settled the decision
|
||||
// positive. It is kept because the redundancy is on the safe side. An edit that
|
||||
// moves or loses the confirmation makes this return nothing rather than announce
|
||||
// a confinement on the strength of the helper having started, which is the claim
|
||||
// this whole mechanism exists to stop making.
|
||||
//
|
||||
// Driven directly, because no path through connectStdio can reach it with a
|
||||
// negative decision, and a test that cannot construct the state it is about would
|
||||
// be asserting nothing.
|
||||
func TestStartupNoticesAreEmptyWhileTheLaunchIsUnknown(t *testing.T) {
|
||||
client := &Client{startupNotices: []string{adapterLaunchNotice}}
|
||||
|
||||
// SETUP: ungated, this client discloses, or the assertion below is vacuous.
|
||||
if len(client.StartupNotices()) != 1 {
|
||||
t.Fatal("SETUP INVALID: the client discloses nothing even before the gate, so the gate cannot be what is under test")
|
||||
}
|
||||
|
||||
client.launched = func() bool { return false }
|
||||
if got := client.StartupNotices(); len(got) != 0 {
|
||||
t.Fatalf("a client whose launch is not established carries %v", got)
|
||||
}
|
||||
client.launched = func() bool { return true }
|
||||
if got := client.StartupNotices(); len(got) != 1 || got[0] != adapterLaunchNotice {
|
||||
t.Fatalf("an established launch carries %v, want the planned notice", got)
|
||||
}
|
||||
}
|
||||
+160
-3
@@ -54,6 +54,56 @@ type ToolClient interface {
|
||||
Close() error
|
||||
}
|
||||
|
||||
// startupDisclosureError carries a launch disclosure out through a failure.
|
||||
//
|
||||
// A launched process is a fact about the past: once Start has succeeded the
|
||||
// disclosure is true whatever the handshake does next. The client is the only
|
||||
// thing that holds it, and the failure paths close and discard the client, so
|
||||
// without this the fact dies with the connection it was attached to.
|
||||
type startupDisclosureError struct {
|
||||
err error
|
||||
notices []string
|
||||
}
|
||||
|
||||
func (e *startupDisclosureError) Error() string { return e.err.Error() }
|
||||
func (e *startupDisclosureError) Unwrap() error { return e.err }
|
||||
|
||||
// startupNoticesFromError recovers a launch disclosure from a failed connect.
|
||||
func startupNoticesFromError(err error) []string {
|
||||
var disclosure *startupDisclosureError
|
||||
if errors.As(err, &disclosure) {
|
||||
return disclosure.notices
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// startupDisclosing is the optional interface a client implements when its
|
||||
// LAUNCH carried a least-privilege disclosure. A network server launches no
|
||||
// local process, so it does not implement this and reports nothing, which is the
|
||||
// correct answer rather than an empty one.
|
||||
type startupDisclosing interface {
|
||||
StartupNotices() []string
|
||||
}
|
||||
|
||||
// StartupNotices reports the disclosures that applied to this server's launch.
|
||||
//
|
||||
// GATED ON THE SAME DECISION AS EVERY OTHER CARRIER. connectAndList reads this on
|
||||
// the success path and hands the result straight to the disclosure sources, so
|
||||
// for a while a successfully connected wrapped server disclosed on the strength
|
||||
// of cmd.Start returning: the HELPER starting, which is the one thing the report
|
||||
// exists because it does not prove. It happened to be right, since a completed
|
||||
// handshake does imply the child ran, but by coincidence rather than by rule, and
|
||||
// the failure path next to it was already asking the adapter.
|
||||
func (client *Client) StartupNotices() []string {
|
||||
if client == nil || len(client.startupNotices) == 0 {
|
||||
return nil
|
||||
}
|
||||
if client.launched != nil && !client.launched() {
|
||||
return nil
|
||||
}
|
||||
return append([]string(nil), client.startupNotices...)
|
||||
}
|
||||
|
||||
type Client struct {
|
||||
server Server
|
||||
cmd *exec.Cmd
|
||||
@@ -64,6 +114,20 @@ type Client struct {
|
||||
idMu sync.Mutex
|
||||
nextID int
|
||||
cleanup func()
|
||||
// startupNotices are the least-privilege disclosures that applied to THIS
|
||||
// server's launch.
|
||||
//
|
||||
// THE FACT DESCRIBES STARTUP, SO IT CANNOT BE RECOVERED FROM A TOOL RESULT.
|
||||
// A stdio server prepared under the weakened token runs for the whole session,
|
||||
// and connectStdio used to keep only the command and its cleanup, so nothing
|
||||
// downstream could tell the operator that the process serving these tools had
|
||||
// reduced write confinement. Kept typed here and rendered exactly once at
|
||||
// registration rather than pasted onto every later tool result.
|
||||
startupNotices []string
|
||||
// launched is the launch decision this server's disclosures are gated on, nil
|
||||
// for a plan whose started process IS the server and where there is nothing to
|
||||
// decide. See internal/execution/child_launch.go.
|
||||
launched func() bool
|
||||
|
||||
writeMu sync.Mutex
|
||||
writeQueue chan writeOp
|
||||
@@ -169,6 +233,11 @@ func (b *boundedBuffer) String() string {
|
||||
func connectStdio(ctx context.Context, server Server, options ConnectOptions) (*Client, error) {
|
||||
var cmd *exec.Cmd
|
||||
var cleanup func()
|
||||
var plannedEnforcement execution.Enforcement
|
||||
// Retained from the prepared plan rather than dropped: for a wrapped plan the
|
||||
// adapter, not cmd.Start, owns whether the requested server process exists.
|
||||
var launchTracker *execution.ChildLaunchTracker
|
||||
var ownedLaunch bool
|
||||
cleanupTransferred := false
|
||||
defer func() {
|
||||
if cleanup != nil && !cleanupTransferred {
|
||||
@@ -192,7 +261,11 @@ func connectStdio(ctx context.Context, server Server, options ConnectOptions) (*
|
||||
return nil, fmt.Errorf("start MCP server %s: %w", server.Name, err)
|
||||
}
|
||||
cmd = prepared.Command
|
||||
cleanup = prepared.Cleanup
|
||||
plannedEnforcement = prepared.Enforcement
|
||||
ownedLaunch = prepared.ChildLaunchOwnedByAdapter
|
||||
// The tracker's cleanup, not the plan's: it settles the launch decision
|
||||
// before the report file it was read from is deleted.
|
||||
launchTracker, cleanup = execution.NewChildLaunchTracker(prepared)
|
||||
} else {
|
||||
cmd = exec.CommandContext(ctx, server.Command, server.Args...)
|
||||
cmd.Env = mergeProcessEnv(server.Env)
|
||||
@@ -211,6 +284,55 @@ func connectStdio(ctx context.Context, server Server, options ConnectOptions) (*
|
||||
return nil, fmt.Errorf("start MCP server %s: %w", server.Name, err)
|
||||
}
|
||||
|
||||
// PUBLISHED AT START, not at return. Registration abandons a server that
|
||||
// exceeds the connect timeout, and everything below this line (initialize, and
|
||||
// tools/list above it in the caller) can hang past that. Announcing the launch
|
||||
// here is what lets an abandoned attempt still disclose the confinement its
|
||||
// process ran under.
|
||||
//
|
||||
// EXCEPT WHEN START IS NOT THE LAUNCH. For a wrapped plan the process started
|
||||
// above is the sandbox helper, which creates the MCP server only after marker,
|
||||
// ACL, network, SID and token setup, any of which can fail leaving no server at
|
||||
// all. Publishing here would make the durable delivery machinery reliably
|
||||
// announce a confinement nothing ever ran under. Those plans publish from
|
||||
// publishAdapterLaunch below, once the adapter has stated the fact.
|
||||
if !ownedLaunch {
|
||||
publishLaunch(ctx, plannedEnforcement.Notices)
|
||||
}
|
||||
// ONE ANSWER, AND IT IS ONLY CACHED ONCE IT CANNOT CHANGE.
|
||||
//
|
||||
// The adapter's report is a file the plan's cleanup deletes, so a decision made
|
||||
// after cleanup used to read an absent report and answer "no child" about a
|
||||
// server that really did run. Memoizing the first read fixed that ordering and
|
||||
// introduced another: the read can land while the adapter has created the child
|
||||
// and not yet recorded it, and "not yet" got frozen as "never".
|
||||
//
|
||||
// ChildLaunchTracker holds the three states apart. It caches a launch, never an
|
||||
// absence, until the adapter is terminal, and it settles from cleanup with the
|
||||
// report still on disk. See internal/execution/child_launch.go.
|
||||
//
|
||||
// It also gives the success, late and failed paths the same input. The failure
|
||||
// path used to carry client.StartupNotices() unconditionally while the sink was
|
||||
// gated on the adapter, which is two competing definitions of applied
|
||||
// enforcement: an operator was told a server ran without write confinement when
|
||||
// only the sandbox helper ran and the requested server never existed.
|
||||
launchedOnce := func() bool {
|
||||
if !ownedLaunch {
|
||||
return true
|
||||
}
|
||||
return launchTracker.Launched()
|
||||
}
|
||||
// publishAdapterLaunch announces a wrapped plan's launch, but only if the
|
||||
// adapter confirms the requested child was created. Called on both ways this
|
||||
// attempt can end, which is also where an attempt abandoned at the connect
|
||||
// timeout eventually arrives, so a late disclosure is still delivered once.
|
||||
publishAdapterLaunch := func() {
|
||||
if !ownedLaunch || !launchedOnce() {
|
||||
return
|
||||
}
|
||||
publishLaunch(ctx, plannedEnforcement.Notices)
|
||||
}
|
||||
|
||||
client := &Client{
|
||||
server: server,
|
||||
cmd: cmd,
|
||||
@@ -219,16 +341,51 @@ func connectStdio(ctx context.Context, server Server, options ConnectOptions) (*
|
||||
writer: newMessageWriter(stdin),
|
||||
nextID: 1,
|
||||
cleanup: cleanup,
|
||||
// Recorded only now, AFTER Start returned. Everything above returns early,
|
||||
// so a prepare failure or an executable that could not be launched records
|
||||
// nothing: same launch-state rule hooks and plugins use, expressed by where
|
||||
// this assignment sits rather than by another outcome-kind switch.
|
||||
startupNotices: append([]string(nil), plannedEnforcement.Notices...),
|
||||
launched: launchedOnce,
|
||||
}
|
||||
cleanupTransferred = true
|
||||
if err := client.initialize(ctx); err != nil {
|
||||
// THE LAUNCH ALREADY HAPPENED, so the fact has to leave through the error.
|
||||
// Start succeeded above, which means the process ran under the planned token
|
||||
// and may have done filesystem work before the handshake failed. Returning a
|
||||
// bare error discards the client, and with it the only carrier the notices
|
||||
// had, so the operator was told the server was unavailable and not that it
|
||||
// had already run without the write jail.
|
||||
// AFTER Close, which waits out the adapter and then settles the decision
|
||||
// with the report still on disk. Asking first would ask an adapter that may
|
||||
// be between creating the child and recording it, and read "not yet" as
|
||||
// "never": the connect timeout ends the attempt here while the helper is
|
||||
// still working.
|
||||
_ = client.Close()
|
||||
launched := launchedOnce()
|
||||
publishAdapterLaunch()
|
||||
message := strings.TrimSpace(stderr.String())
|
||||
failure := fmt.Errorf("initialize MCP server %s: %w", server.Name, err)
|
||||
if message != "" {
|
||||
return nil, fmt.Errorf("initialize MCP server %s: %w: %s", server.Name, err, message)
|
||||
failure = fmt.Errorf("initialize MCP server %s: %w: %s", server.Name, err, message)
|
||||
}
|
||||
return nil, fmt.Errorf("initialize MCP server %s: %w", server.Name, err)
|
||||
// Same decision as the sink above. For a wrapped plan whose helper started
|
||||
// and then failed before creating the requested server, there is nothing to
|
||||
// disclose: no server ran, confined or otherwise.
|
||||
var carried []string
|
||||
if launched {
|
||||
carried = client.StartupNotices()
|
||||
}
|
||||
return nil, &startupDisclosureError{err: failure, notices: carried}
|
||||
}
|
||||
// THE HANDSHAKE IS THE OBSERVATION. A wrapped plan's adapter speaks no MCP and
|
||||
// creates the child suspended, so a well-formed initialize response can only
|
||||
// have come from the requested server, already running. That is terminal
|
||||
// evidence and does not depend on the report having been read yet, which is
|
||||
// what keeps a long-lived session from having to wait for an adapter that will
|
||||
// not exit until the session ends.
|
||||
launchTracker.Confirm()
|
||||
publishAdapterLaunch()
|
||||
return client, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Twigpine/zero/internal/tools"
|
||||
)
|
||||
|
||||
// A MODEL-FACING PROTOCOL BOUNDARY IS A PRESENTATION CONSUMER.
|
||||
//
|
||||
// This branch changed the result contract: Result.Output holds the UNDECORATED
|
||||
// base text and ModelOutput is the sole model-facing projection that composes it
|
||||
// with the typed enforcement notices. tools/call serialized Output directly,
|
||||
// which was a complete value before and is not one now, so an affected Windows
|
||||
// command reached an MCP client with its ordinary output and no statement that
|
||||
// its DenyRead token shape left writes unconfined.
|
||||
//
|
||||
// Driven through Serve rather than the accessor, because the question is what
|
||||
// goes on the wire.
|
||||
func TestMCPToolsCallCarriesTheEnforcementNotice(t *testing.T) {
|
||||
const notice = "denyRead is configured, so the write jail is not confining writes"
|
||||
const output = "ran the command"
|
||||
|
||||
for _, testCase := range []struct {
|
||||
name string
|
||||
result tools.Result
|
||||
wantText string
|
||||
wantIsErr bool
|
||||
wantNotice bool
|
||||
}{
|
||||
{
|
||||
name: "successful command with a notice",
|
||||
result: tools.Result{Status: tools.StatusOK, Output: output, EnforcementNotices: []string{notice}},
|
||||
wantIsErr: false,
|
||||
wantNotice: true,
|
||||
},
|
||||
{
|
||||
name: "failed command with a notice",
|
||||
result: tools.Result{Status: tools.StatusError, Output: output, EnforcementNotices: []string{notice}},
|
||||
wantIsErr: true,
|
||||
wantNotice: true,
|
||||
},
|
||||
{
|
||||
name: "ordinary command with no notice",
|
||||
result: tools.Result{Status: tools.StatusOK, Output: output},
|
||||
wantIsErr: false,
|
||||
wantNotice: false,
|
||||
},
|
||||
} {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(serverFakeTool{
|
||||
name: "run_thing",
|
||||
description: "runs a thing",
|
||||
parameters: tools.Schema{Type: "object", AdditionalProperties: false},
|
||||
safety: tools.Safety{SideEffect: tools.SideEffectRead, Permission: tools.PermissionAllow, Reason: "test"},
|
||||
run: func(map[string]any) tools.Result { return testCase.result },
|
||||
})
|
||||
|
||||
var input bytes.Buffer
|
||||
writeServerTestMessage(t, &input, rpcMessage{ID: 1, Method: "initialize"})
|
||||
writeServerTestMessage(t, &input, rpcMessage{Method: "notifications/initialized"})
|
||||
writeServerTestMessage(t, &input, rpcMessage{
|
||||
ID: 2,
|
||||
Method: "tools/call",
|
||||
Params: mustRaw(map[string]any{"name": "run_thing", "arguments": map[string]any{}}),
|
||||
})
|
||||
|
||||
var out bytes.Buffer
|
||||
if err := Serve(context.Background(), &input, &out, registry, ServeOptions{Name: "zero-test", Version: "1.2.3"}); err != nil {
|
||||
t.Fatalf("Serve() error = %v", err)
|
||||
}
|
||||
reader := newMessageReader(&out)
|
||||
readServerTestMessage(t, reader) // initialize
|
||||
var call CallToolResult
|
||||
decodeServerTestResult(t, readServerTestMessage(t, reader), &call)
|
||||
|
||||
if len(call.Content) != 1 || call.Content[0].Type != "text" {
|
||||
t.Fatalf("content shape changed: %#v", call.Content)
|
||||
}
|
||||
text := call.Content[0].Text
|
||||
if call.IsError != testCase.wantIsErr {
|
||||
t.Errorf("IsError = %v, want %v", call.IsError, testCase.wantIsErr)
|
||||
}
|
||||
if count := strings.Count(text, output); count != 1 {
|
||||
t.Errorf("the command's own output appears %d times, want exactly 1: %q", count, text)
|
||||
}
|
||||
gotNotice := strings.Count(text, notice)
|
||||
if testCase.wantNotice && gotNotice != 1 {
|
||||
t.Errorf("the disclosure appears %d times, want exactly 1: %q", gotNotice, text)
|
||||
}
|
||||
if !testCase.wantNotice {
|
||||
if gotNotice != 0 {
|
||||
t.Errorf("a disclosure appeared for a command that had none: %q", text)
|
||||
}
|
||||
if text != output {
|
||||
t.Errorf("ordinary output was altered: got %q, want %q", text, output)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// launchSink carries the fact that a server's PROCESS STARTED out of the connect
|
||||
// attempt, without waiting for that attempt to finish.
|
||||
//
|
||||
// The startup notices used to leave connectStdio only on the returned client, or
|
||||
// on the returned error. Both require the attempt to return. Registration
|
||||
// abandons a server that exceeds the connect timeout, so a server that started
|
||||
// under the reduced write confinement and then hung in initialize or tools/list
|
||||
// was recorded as skipped with nothing said about the confinement it ran under.
|
||||
// The reaper that later collects the abandoned attempt runs after the serial
|
||||
// commit phase has finished, so it cannot contribute without breaking the
|
||||
// deterministic ordering that phase exists to provide.
|
||||
//
|
||||
// Publishing at Start splits the two facts apart, which is the point: launch and
|
||||
// connection usability have different lifetimes. A sink that was never published
|
||||
// to means Start never happened, so prepare, pipe, and Start failures stay silent
|
||||
// exactly as before.
|
||||
//
|
||||
// IT IS ALSO AN EVENT, NOT ONLY A VALUE. A retained sink that nobody re-reads is
|
||||
// still a lost disclosure: both production reporters sample once, immediately
|
||||
// after registration returns, and a Start that completes after that sample had
|
||||
// no way to reach them. onPublish lets a reporter subscribe; if the launch has
|
||||
// already happened by the time it subscribes, it is told at once, so the fact
|
||||
// reaches exactly one presentation regardless of which side won the race.
|
||||
type launchSink struct {
|
||||
mu sync.Mutex
|
||||
launched bool
|
||||
notices []string
|
||||
onPublish func(notices []string)
|
||||
delivered bool
|
||||
}
|
||||
|
||||
type launchSinkKey struct{}
|
||||
|
||||
// withLaunchSink attaches a sink to the context handed to the client factory.
|
||||
// Carried on the context rather than added to the factory signature so an
|
||||
// injected or third-party factory that knows nothing about it still works, and
|
||||
// simply discloses nothing.
|
||||
func withLaunchSink(ctx context.Context, sink *launchSink) context.Context {
|
||||
return context.WithValue(ctx, launchSinkKey{}, sink)
|
||||
}
|
||||
|
||||
// publishLaunch records that the process for this connect attempt has started,
|
||||
// along with the enforcement notices that applied to it. Safe on a context with
|
||||
// no sink, which is every caller outside registration.
|
||||
func publishLaunch(ctx context.Context, notices []string) {
|
||||
sink, _ := ctx.Value(launchSinkKey{}).(*launchSink)
|
||||
if sink == nil {
|
||||
return
|
||||
}
|
||||
sink.mu.Lock()
|
||||
sink.launched = true
|
||||
sink.notices = append([]string(nil), notices...)
|
||||
deliver := sink.pendingDeliveryLocked()
|
||||
sink.mu.Unlock()
|
||||
if deliver != nil {
|
||||
deliver()
|
||||
}
|
||||
}
|
||||
|
||||
// PublishLaunchForTest is publishLaunch for a test in another package that
|
||||
// injects a client factory and needs to mark its fake process as started. It
|
||||
// is the same function with the same context lookup, so a test exercises the
|
||||
// real sink rather than a stand-in, and it is inert on any context that did
|
||||
// not come through registration.
|
||||
func PublishLaunchForTest(ctx context.Context, notices []string) {
|
||||
publishLaunch(ctx, notices)
|
||||
}
|
||||
|
||||
// observe reports whether Start was reached and what applied to it. Read from
|
||||
// the registration goroutine while the connect goroutine may still be running,
|
||||
// hence the lock.
|
||||
func (sink *launchSink) observe() (bool, []string) {
|
||||
if sink == nil {
|
||||
return false, nil
|
||||
}
|
||||
sink.mu.Lock()
|
||||
defer sink.mu.Unlock()
|
||||
return sink.launched, append([]string(nil), sink.notices...)
|
||||
}
|
||||
|
||||
// subscribe registers the one presentation this launch should reach. If the
|
||||
// launch already happened, fn runs before subscribe returns; otherwise it runs
|
||||
// from publishLaunch. Either way it runs at most once, and a second subscriber
|
||||
// replaces nothing: the first delivery is the only delivery.
|
||||
func (sink *launchSink) subscribe(fn func(notices []string)) {
|
||||
if sink == nil || fn == nil {
|
||||
return
|
||||
}
|
||||
sink.mu.Lock()
|
||||
if sink.onPublish == nil {
|
||||
sink.onPublish = fn
|
||||
}
|
||||
deliver := sink.pendingDeliveryLocked()
|
||||
sink.mu.Unlock()
|
||||
if deliver != nil {
|
||||
deliver()
|
||||
}
|
||||
}
|
||||
|
||||
// pendingDeliveryLocked returns the delivery to perform, or nil, and marks it
|
||||
// done. Called with mu held; the returned closure must be invoked with mu
|
||||
// released, since a subscriber may itself take other locks.
|
||||
func (sink *launchSink) pendingDeliveryLocked() func() {
|
||||
if !sink.launched || sink.onPublish == nil || sink.delivered {
|
||||
return nil
|
||||
}
|
||||
sink.delivered = true
|
||||
fn := sink.onPublish
|
||||
notices := append([]string(nil), sink.notices...)
|
||||
return func() { fn(notices) }
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Twigpine/zero/internal/config"
|
||||
"github.com/Twigpine/zero/internal/tools"
|
||||
)
|
||||
|
||||
const launchNotice = "denyRead is configured, so the Windows sandbox uses the token shape without WRITE_RESTRICTED (#869)"
|
||||
|
||||
func registerWithFactory(t *testing.T, factory func(context.Context, Server) (ToolClient, error)) *Runtime {
|
||||
t.Helper()
|
||||
runtime, err := RegisterTools(context.Background(), tools.NewRegistry(), config.MCPConfig{Servers: map[string]config.MCPServerConfig{
|
||||
"slow": {Type: "stdio", Command: "slow-mcp"},
|
||||
}}, RegisterOptions{
|
||||
ConnectTimeout: 50 * time.Millisecond,
|
||||
ClientFactory: factory,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("RegisterTools error: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = runtime.Close() })
|
||||
return runtime
|
||||
}
|
||||
|
||||
// A SERVER THAT STARTED AND THEN HUNG STILL RAN UNDER THE REDUCED TOKEN.
|
||||
//
|
||||
// Registration abandons a server that exceeds the connect timeout and records it
|
||||
// as skipped. The startup notices used to leave connectStdio only on the returned
|
||||
// client or the returned error, and the abandoned attempt returns neither before
|
||||
// the serial commit phase is over, so the process ran with reduced write
|
||||
// confinement and startup said only that the server was skipped.
|
||||
func TestTimeoutAfterLaunchKeepsTheStartupDisclosure(t *testing.T) {
|
||||
runtime := registerWithFactory(t, func(ctx context.Context, server Server) (ToolClient, error) {
|
||||
// The process started under the reduced token, then initialize hangs.
|
||||
publishLaunch(ctx, []string{launchNotice})
|
||||
<-ctx.Done()
|
||||
return nil, ctx.Err()
|
||||
})
|
||||
|
||||
disclosures := runtime.StartupDisclosures()
|
||||
if len(disclosures) == 0 {
|
||||
t.Fatal("a server that started and then timed out disclosed nothing, so it ran under reduced write confinement unannounced")
|
||||
}
|
||||
if disclosures[0].Name != "slow" || len(disclosures[0].Notices) != 1 || disclosures[0].Notices[0] != launchNotice {
|
||||
t.Errorf("StartupDisclosures() = %#v, want one entry for slow carrying the launch notice", disclosures)
|
||||
}
|
||||
if skipped := runtime.Skipped(); len(skipped) != 1 || skipped[0].Name != "slow" {
|
||||
t.Errorf("the server should still be recorded as skipped: %#v", skipped)
|
||||
}
|
||||
}
|
||||
|
||||
// AND A TIMEOUT BEFORE LAUNCH STAYS SILENT.
|
||||
//
|
||||
// Without this, retaining the disclosure on timeout could be satisfied by
|
||||
// disclosing on every timeout, which would claim a token trade for a process
|
||||
// that was never created.
|
||||
func TestTimeoutBeforeLaunchDisclosesNothing(t *testing.T) {
|
||||
runtime := registerWithFactory(t, func(ctx context.Context, server Server) (ToolClient, error) {
|
||||
// Never reached Start: no publish.
|
||||
<-ctx.Done()
|
||||
return nil, ctx.Err()
|
||||
})
|
||||
|
||||
if disclosures := runtime.StartupDisclosures(); len(disclosures) != 0 {
|
||||
t.Errorf("a server that never started claimed a token trade: %#v", disclosures)
|
||||
}
|
||||
if skipped := runtime.Skipped(); len(skipped) != 1 {
|
||||
t.Errorf("the server should still be recorded as skipped: %#v", skipped)
|
||||
}
|
||||
}
|
||||
|
||||
// THE PUBLISH HAS TO SIT AFTER Start, AND ONLY A REAL LAUNCH PROVES IT.
|
||||
//
|
||||
// The two tests above inject a factory, so they exercise the registry's handling
|
||||
// of the sink and say nothing about where connectStdio publishes to it. Moving
|
||||
// the call one line up, above cmd.Start, leaves both of them green while every
|
||||
// failed launch starts claiming the token trade. This one drives the real
|
||||
// connectStdio with a command that cannot start.
|
||||
func TestAFailedStartPublishesNoLaunch(t *testing.T) {
|
||||
sink := &launchSink{}
|
||||
ctx := withLaunchSink(context.Background(), sink)
|
||||
|
||||
client, err := connectStdio(ctx, Server{
|
||||
Name: "missing",
|
||||
Type: "stdio",
|
||||
Command: "zero-nonexistent-mcp-binary-for-test",
|
||||
}, ConnectOptions{})
|
||||
if err == nil {
|
||||
if client != nil {
|
||||
_ = client.Close()
|
||||
}
|
||||
t.Fatal("expected a nonexistent executable to fail to start")
|
||||
}
|
||||
|
||||
if launched, notices := sink.observe(); launched {
|
||||
t.Errorf("a server whose process never started was published as launched (notices %#v)", notices)
|
||||
}
|
||||
}
|
||||
|
||||
// A START THAT COMPLETES JUST AFTER THE TIMEOUT MUST STILL BE DISCLOSED.
|
||||
//
|
||||
// connectStdio publishes only once cmd.Start has returned, and the timeout
|
||||
// branch used to sample the sink the instant it fired. Those interleave: the
|
||||
// sample reads empty, the result commits with no notice, and the reaper closes
|
||||
// the late client without being able to amend a commit that already happened.
|
||||
//
|
||||
// The real window is microseconds wide, so this drives the CONTRACT instead:
|
||||
// the attempt starts after the registration timeout has elapsed but inside the
|
||||
// settle grace, which is the case the synchronization exists to catch.
|
||||
func TestStartJustAfterTheTimeoutIsStillDisclosed(t *testing.T) {
|
||||
runtime := registerWithFactory(t, func(ctx context.Context, server Server) (ToolClient, error) {
|
||||
// 50ms registration timeout has fired; this lands inside launchSettleGrace.
|
||||
time.Sleep(120 * time.Millisecond)
|
||||
publishLaunch(ctx, []string{launchNotice})
|
||||
return nil, errors.New("initialize failed after start")
|
||||
})
|
||||
|
||||
disclosures := runtime.StartupDisclosures()
|
||||
if len(disclosures) != 1 || len(disclosures[0].Notices) != 1 || disclosures[0].Notices[0] != launchNotice {
|
||||
t.Fatalf("a process that started just after the timeout was not disclosed: %#v", disclosures)
|
||||
}
|
||||
if skipped := runtime.Skipped(); len(skipped) != 1 {
|
||||
t.Errorf("the server should still be recorded as skipped: %#v", skipped)
|
||||
}
|
||||
}
|
||||
|
||||
// And an attempt that never starts is not held for the grace, nor disclosed.
|
||||
func TestTimeoutBeforeStartIsNotDelayedOrDisclosed(t *testing.T) {
|
||||
start := time.Now()
|
||||
runtime := registerWithFactory(t, func(ctx context.Context, server Server) (ToolClient, error) {
|
||||
<-ctx.Done() // cancel arrives with the timeout; returns immediately
|
||||
return nil, ctx.Err()
|
||||
})
|
||||
elapsed := time.Since(start)
|
||||
|
||||
if disclosures := runtime.StartupDisclosures(); len(disclosures) != 0 {
|
||||
t.Errorf("a server that never started claimed a token trade: %#v", disclosures)
|
||||
}
|
||||
// The grace is 250ms; an attempt that returns on cancel must not pay it.
|
||||
if elapsed > 200*time.Millisecond {
|
||||
t.Errorf("registration waited %v for an attempt that never started", elapsed)
|
||||
}
|
||||
}
|
||||
|
||||
// AND A START THAT COMPLETES AFTER THE SETTLE GRACE MUST STILL BE DISCLOSED.
|
||||
//
|
||||
// The grace is only another timeout. Once it expires, registration reaps the
|
||||
// attempt in the background and returns; the process can still be inside
|
||||
// cmd.Start at that moment and start successfully afterwards. If Runtime were a
|
||||
// snapshot taken at commit time, that launch would have no owner: the reaper can
|
||||
// close the late client but cannot amend a value already returned, so the server
|
||||
// would have run under the reduced write confinement with startup reporting it
|
||||
// only as skipped.
|
||||
//
|
||||
// A larger grace changes the probability, not the contract, which is why this
|
||||
// releases the launch strictly AFTER the bound rather than inside it. The sink
|
||||
// outlives registration and StartupDisclosures reads through it.
|
||||
func TestStartAfterTheSettleGraceIsStillDisclosed(t *testing.T) {
|
||||
released := make(chan struct{})
|
||||
runtime := registerWithFactory(t, func(ctx context.Context, server Server) (ToolClient, error) {
|
||||
// Held past the 50ms registration timeout AND past launchSettleGrace, so
|
||||
// registration has already reaped this attempt and returned.
|
||||
<-released
|
||||
publishLaunch(ctx, []string{launchNotice})
|
||||
return nil, errors.New("initialize failed long after start")
|
||||
})
|
||||
|
||||
// Registration is done and the disclosure legitimately is not known yet.
|
||||
if disclosures := runtime.StartupDisclosures(); len(disclosures) != 0 {
|
||||
t.Fatalf("nothing had started yet, so nothing should be disclosed: %#v", disclosures)
|
||||
}
|
||||
|
||||
close(released)
|
||||
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
var disclosures []StartupDisclosure
|
||||
for time.Now().Before(deadline) {
|
||||
if disclosures = runtime.StartupDisclosures(); len(disclosures) > 0 {
|
||||
break
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
|
||||
if len(disclosures) != 1 || len(disclosures[0].Notices) != 1 || disclosures[0].Notices[0] != launchNotice {
|
||||
t.Fatalf("a process that started after the settle grace was never disclosed: %#v", disclosures)
|
||||
}
|
||||
// Reading again must not duplicate it.
|
||||
if again := runtime.StartupDisclosures(); len(again) != 1 {
|
||||
t.Errorf("a second read changed the disclosures: %#v", again)
|
||||
}
|
||||
if skipped := runtime.Skipped(); len(skipped) != 1 {
|
||||
t.Errorf("the server should still be recorded as skipped: %#v", skipped)
|
||||
}
|
||||
}
|
||||
+260
-15
@@ -18,6 +18,11 @@ import (
|
||||
// so a slow or unreachable server (e.g. a hosted endpoint blocked by the local
|
||||
// network) cannot delay the first model response. Servers connect concurrently,
|
||||
// so total startup cost is the slowest reachable server, not the sum.
|
||||
// launchSettleGrace bounds how long an abandoned connect attempt is given to
|
||||
// say whether it had already started. It is paid only after cancel, so an
|
||||
// attempt that never reached Start returns well inside it.
|
||||
const launchSettleGrace = 250 * time.Millisecond
|
||||
|
||||
const defaultConnectTimeout = 8 * time.Second
|
||||
|
||||
type RegisterOptions struct {
|
||||
@@ -47,6 +52,18 @@ type SkippedServer struct {
|
||||
UnconfiguredDefault bool
|
||||
}
|
||||
|
||||
// StartupDisclosure is a least-privilege statement about one MCP server's
|
||||
// LAUNCH, as opposed to anything a later tool call does.
|
||||
//
|
||||
// A stdio server prepared under a weakened token serves the whole session from
|
||||
// that process, so the fact describes startup and cannot be recovered from an
|
||||
// individual tool result afterwards. It is reported once, here, rather than
|
||||
// appended to every response the server produces.
|
||||
type StartupDisclosure struct {
|
||||
Name string
|
||||
Notices []string
|
||||
}
|
||||
|
||||
type Runtime struct {
|
||||
clients []ToolClient
|
||||
// cancels releases the per-server connect contexts of the clients we KEPT.
|
||||
@@ -55,8 +72,83 @@ type Runtime struct {
|
||||
// is closed). Same length/order as clients is not required.
|
||||
cancels []context.CancelFunc
|
||||
skipped []SkippedServer
|
||||
once sync.Once
|
||||
err error
|
||||
// disclosureSources are the least-privilege statements that applied to each
|
||||
// server process this registration LAUNCHED, in server order, each still
|
||||
// holding the sink that carries the authoritative launch fact.
|
||||
//
|
||||
// NOT a frozen snapshot. Registration is bounded and a launch is not: an
|
||||
// attempt abandoned at the connect timeout can still be inside cmd.Start when
|
||||
// wg.Wait returns, so the serial commit samples an empty sink and the process
|
||||
// then starts under the reduced confinement with nobody left to say so. The
|
||||
// sink outlives registration and StartupDisclosures reads through it, so a
|
||||
// late Start is reported instead of lost. See StartupDisclosures.
|
||||
disclosureSources []disclosureSource
|
||||
// disclosureStream is the typed hand-off to whoever owns the output. Created
|
||||
// on the first StartupDisclosureStream call and closed by Close, so a launch
|
||||
// that resolves after the runtime is gone has somewhere defined to land:
|
||||
// nowhere.
|
||||
disclosureStreamOnce sync.Once
|
||||
disclosureStream *StartupDisclosureStream
|
||||
once sync.Once
|
||||
err error
|
||||
}
|
||||
|
||||
// disclosureSource pairs a server with both the notices known at commit time and
|
||||
// the sink that may still learn them. notices wins when it is already populated,
|
||||
// so a settled server never re-reads the sink.
|
||||
type disclosureSource struct {
|
||||
name string
|
||||
notices []string
|
||||
sink *launchSink
|
||||
}
|
||||
|
||||
// ReportStartupDisclosures delivers each server's launch disclosure to report
|
||||
// EXACTLY ONCE, whether the launch had already completed when this was called
|
||||
// or completes later.
|
||||
//
|
||||
// StartupDisclosures reads through the sink, which stopped a late Start from
|
||||
// being lost, but a value nobody re-reads is still a lost disclosure: both
|
||||
// production reporters sample once, right after RegisterTools returns, and an
|
||||
// attempt abandoned at the connect timeout can finish Start after that sample.
|
||||
// The reaper only closes the late client. So the operator saw the skipped-server
|
||||
// warning and never learned that a local process had run under the reduced
|
||||
// enforcement.
|
||||
//
|
||||
// Servers whose notices were known at commit are reported now, in server order.
|
||||
// Every other server subscribes its sink: if the launch already happened the
|
||||
// subscriber runs before this returns, otherwise it runs from publishLaunch on
|
||||
// the connect goroutine. Either way each server reaches report once. A server
|
||||
// that never starts never publishes, so prepare, pipe and Start failures stay
|
||||
// silent, and network servers, which launch no process, contribute nothing.
|
||||
//
|
||||
// Late deliveries arrive in completion order, which is the only order they
|
||||
// have; the immediate set keeps server order.
|
||||
//
|
||||
// A STREAM, NOT A CALLBACK. An earlier version took the presentation function
|
||||
// and invoked it from whichever goroutine resolved the launch, which for an
|
||||
// abandoned attempt is the connect goroutine. That put a write to the caller's
|
||||
// writer on a goroutine and at a time the caller did not control. The runtime
|
||||
// owns the fact; it appends the fact here and the owner drains it. See
|
||||
// StartupDisclosureStream.
|
||||
func (runtime *Runtime) StartupDisclosureStream() *StartupDisclosureStream {
|
||||
if runtime == nil {
|
||||
return nil
|
||||
}
|
||||
runtime.disclosureStreamOnce.Do(func() {
|
||||
stream := newStartupDisclosureStream()
|
||||
runtime.disclosureStream = stream
|
||||
for _, source := range runtime.disclosureSources {
|
||||
if len(source.notices) > 0 {
|
||||
stream.offer(StartupDisclosure{Name: source.name, Notices: append([]string(nil), source.notices...)})
|
||||
continue
|
||||
}
|
||||
name := source.name
|
||||
source.sink.subscribe(func(notices []string) {
|
||||
stream.offer(StartupDisclosure{Name: name, Notices: notices})
|
||||
})
|
||||
}
|
||||
})
|
||||
return runtime.disclosureStream
|
||||
}
|
||||
|
||||
// Skipped returns the servers that were skipped during registration (unreachable
|
||||
@@ -110,19 +202,38 @@ func RegisterTools(ctx context.Context, registry *tools.Registry, cfg config.MCP
|
||||
remote []RemoteTool
|
||||
cancel context.CancelFunc
|
||||
err error
|
||||
// notices travels with the indexed result rather than being appended to
|
||||
// shared state from inside the goroutine. The concurrent phase touches no
|
||||
// shared state, which is the property the comment above promises and the
|
||||
// reason the serial phase can be deterministic; appending here broke both,
|
||||
// racing the slice header and ordering disclosures by completion time.
|
||||
notices []string
|
||||
}
|
||||
results := make([]connectResult, len(servers))
|
||||
// RETAINED PAST THE CONCURRENT PHASE. The timeout branch samples the sink the
|
||||
// moment it fires, but connectStdio does not publish until cmd.Start has
|
||||
// returned, so a Start that succeeds just after the timeout selected was
|
||||
// sampled as "never launched" and its disclosure was lost: the reaper closes
|
||||
// the late client and cannot amend a commit that has already happened.
|
||||
// Reading the sink again in the serial phase is strictly later than the
|
||||
// timeout branch and still deterministic, because it runs after wg.Wait.
|
||||
sinks := make([]*launchSink, len(servers))
|
||||
var wg sync.WaitGroup
|
||||
for index := range servers {
|
||||
wg.Add(1)
|
||||
go func(index int) {
|
||||
defer wg.Done()
|
||||
server := servers[index]
|
||||
serverCtx, cancel := context.WithCancel(ctx)
|
||||
// The sink hears about Start as it happens, so an attempt abandoned below
|
||||
// can still report the confinement its process ran under. The connect
|
||||
// result cannot supply that: it does not arrive until after this phase.
|
||||
sink := &launchSink{}
|
||||
sinks[index] = sink
|
||||
serverCtx, cancel := context.WithCancel(withLaunchSink(ctx, sink))
|
||||
done := make(chan connectResult, 1)
|
||||
go func() {
|
||||
client, remote, err := connectAndList(serverCtx, factory, server)
|
||||
done <- connectResult{client: client, remote: remote, err: err}
|
||||
client, remote, notices, err := connectAndList(serverCtx, factory, server)
|
||||
done <- connectResult{client: client, remote: remote, notices: notices, err: err}
|
||||
}()
|
||||
select {
|
||||
case res := <-done:
|
||||
@@ -134,14 +245,47 @@ func RegisterTools(ctx context.Context, registry *tools.Registry, cfg config.MCP
|
||||
results[index] = res
|
||||
case <-time.After(timeout):
|
||||
cancel() // abandon the slow connect: tears down the conn/subprocess
|
||||
// Reap the goroutine + any partial client in the background so a
|
||||
// slow server never blocks startup.
|
||||
go func() {
|
||||
if res := <-done; res.client != nil {
|
||||
timedOut := connectResult{err: fmt.Errorf("connect timed out after %s", timeout)}
|
||||
// SYNCHRONIZE WITH THE START, briefly, before deciding there was none.
|
||||
//
|
||||
// connectStdio publishes only after cmd.Start returns, so sampling the
|
||||
// sink the instant the timeout fires races a Start that is about to
|
||||
// succeed: the sample reads empty, the result commits with no notice,
|
||||
// and the reaper cannot amend a commit that has already happened. The
|
||||
// window is microseconds and unreachable from a test seam, which is
|
||||
// exactly why it must be closed by construction rather than measured.
|
||||
//
|
||||
// cancel() has already fired, so an attempt that has NOT started fails
|
||||
// fast and this returns immediately; only one that did start can still
|
||||
// be in Start, and it publishes on the way out. The grace is therefore
|
||||
// paid only when there is something to learn.
|
||||
select {
|
||||
case res := <-done:
|
||||
if res.client != nil {
|
||||
_ = res.client.Close()
|
||||
}
|
||||
}()
|
||||
results[index] = connectResult{err: fmt.Errorf("connect timed out after %s", timeout)}
|
||||
if len(res.notices) > 0 {
|
||||
timedOut.notices = res.notices
|
||||
}
|
||||
case <-time.After(launchSettleGrace):
|
||||
// Still stuck past the grace. Reap in the background so a slow
|
||||
// server never blocks startup.
|
||||
go func() {
|
||||
if res := <-done; res.client != nil {
|
||||
_ = res.client.Close()
|
||||
}
|
||||
}()
|
||||
}
|
||||
// A server that reached Start ran under the planned enforcement even
|
||||
// though its connection never became usable. One that timed out
|
||||
// before Start discloses nothing, so the sink stays empty and this
|
||||
// adds nothing.
|
||||
if len(timedOut.notices) == 0 {
|
||||
if launched, notices := sink.observe(); launched {
|
||||
timedOut.notices = notices
|
||||
}
|
||||
}
|
||||
results[index] = timedOut
|
||||
}
|
||||
}(index)
|
||||
}
|
||||
@@ -157,6 +301,28 @@ func RegisterTools(ctx context.Context, registry *tools.Registry, cfg config.MCP
|
||||
stagedNames := make(map[string]struct{})
|
||||
for index, server := range servers {
|
||||
res := results[index]
|
||||
// Recorded here, in server order, for any server whose PROCESS STARTED,
|
||||
// including one whose tools are rejected below: the launch happened under
|
||||
// that token either way, and a skip warning does not say what confinement
|
||||
// the process ran with while it was alive.
|
||||
notices := res.notices
|
||||
if len(notices) == 0 {
|
||||
// A launch that published after the timeout branch sampled. Checked here
|
||||
// rather than only there so the window between Start succeeding and the
|
||||
// timeout committing cannot swallow the disclosure.
|
||||
if launched, late := sinks[index].observe(); launched {
|
||||
notices = late
|
||||
}
|
||||
}
|
||||
// Recorded whether or not notices are known YET. An abandoned attempt can
|
||||
// still be inside cmd.Start, and keeping its sink here is what lets
|
||||
// StartupDisclosures report that launch after this phase has finished.
|
||||
// Order is server order, so a late arrival does not reorder the rest.
|
||||
runtime.disclosureSources = append(runtime.disclosureSources, disclosureSource{
|
||||
name: server.Name,
|
||||
notices: notices,
|
||||
sink: sinks[index],
|
||||
})
|
||||
if res.err != nil {
|
||||
runtime.skipped = append(runtime.skipped, SkippedServer{Name: server.Name, Err: res.err, UnconfiguredDefault: server.UnconfiguredDefault})
|
||||
continue
|
||||
@@ -190,17 +356,42 @@ func RegisterTools(ctx context.Context, registry *tools.Registry, cfg config.MCP
|
||||
// connectAndList connects to one server and lists its tools. It does ONLY I/O
|
||||
// (no registry, permission-store, or other shared state), so it is safe to run
|
||||
// concurrently for every server. On a list error it closes the client.
|
||||
func connectAndList(ctx context.Context, factory func(context.Context, Server) (ToolClient, error), server Server) (ToolClient, []RemoteTool, error) {
|
||||
// connectAndList returns the client, its tools, and the least-privilege
|
||||
// disclosures that applied to its LAUNCH.
|
||||
//
|
||||
// THE LAUNCH FACT OUTLIVES THE CONNECTION. A stdio server can start, and do
|
||||
// filesystem work, and then fail initialize or tools/list. Returning the notices
|
||||
// separately rather than leaving them on the client means the fact survives that
|
||||
// failure: the client is closed and discarded here, so anything reachable only
|
||||
// through it is gone by the time the caller sees the error, and the skip warning
|
||||
// on its own does not say the process already ran with reduced write
|
||||
// confinement.
|
||||
func connectAndList(ctx context.Context, factory func(context.Context, Server) (ToolClient, error), server Server) (ToolClient, []RemoteTool, []string, error) {
|
||||
client, err := factory(ctx, server)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
// A failure BEFORE the process started discloses nothing. A failure after it
|
||||
// started carries the fact out through the error, because the client that
|
||||
// held it has already been closed and discarded by then.
|
||||
return nil, nil, startupNoticesFromError(err), err
|
||||
}
|
||||
notices := startupNoticesOf(client)
|
||||
remoteTools, err := client.ListTools(ctx)
|
||||
if err != nil {
|
||||
_ = client.Close()
|
||||
return nil, nil, fmt.Errorf("list MCP tools for %s: %w", server.Name, err)
|
||||
return nil, nil, notices, fmt.Errorf("list MCP tools for %s: %w", server.Name, err)
|
||||
}
|
||||
return client, remoteTools, nil
|
||||
return client, remoteTools, notices, nil
|
||||
}
|
||||
|
||||
// startupNoticesOf reads a client's launch disclosures, if it reports any.
|
||||
func startupNoticesOf(client ToolClient) []string {
|
||||
if client == nil {
|
||||
return nil
|
||||
}
|
||||
if disclosing, ok := client.(startupDisclosing); ok {
|
||||
return disclosing.StartupNotices()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildServerTools validates a server's remote tools against the registry and the
|
||||
@@ -236,6 +427,25 @@ func (runtime *Runtime) Close() error {
|
||||
return nil
|
||||
}
|
||||
runtime.once.Do(func() {
|
||||
// End disclosure delivery FIRST. A launch that resolves while the clients
|
||||
// are being closed has no owner left to print it, and the runtime must not
|
||||
// leave a subscriber holding a writer whose lifetime it does not know.
|
||||
//
|
||||
// THROUGH THE ONCE, NOT THE FIELD. Reading disclosureStream directly raced
|
||||
// a first StartupDisclosureStream call on another goroutine, and a Close
|
||||
// that came before any call found nil and closed nothing, so a later call
|
||||
// built a fresh, open stream and subscribed it to launches from a runtime
|
||||
// that was already gone. Reported by CodeRabbit.
|
||||
//
|
||||
// If nobody asked for the stream before this, nobody is draining it, so it
|
||||
// is published EMPTY rather than built the usual way: no sink is
|
||||
// subscribed and nothing is queued for an owner that does not exist. Every
|
||||
// later caller then gets this stream, already closed. The read after Do is
|
||||
// ordered by the Once whichever call got there first.
|
||||
runtime.disclosureStreamOnce.Do(func() {
|
||||
runtime.disclosureStream = newStartupDisclosureStream()
|
||||
})
|
||||
runtime.disclosureStream.Close()
|
||||
for _, client := range runtime.clients {
|
||||
if err := client.Close(); err != nil && runtime.err == nil {
|
||||
runtime.err = err
|
||||
@@ -434,6 +644,41 @@ func isPersistentlyApproved(store *PermissionStore, server Server, toolName stri
|
||||
return err == nil && approved
|
||||
}
|
||||
|
||||
// StartupDisclosures returns the least-privilege statements that applied to the
|
||||
// MCP server processes this registration launched, so a caller can report them
|
||||
// once. Empty when no server was launched under reduced enforcement, and always
|
||||
// empty for network servers, which launch no local process.
|
||||
//
|
||||
// READ THROUGH THE SINK, so this is not fixed at the moment registration
|
||||
// returned. A server abandoned at the connect timeout may still have been inside
|
||||
// cmd.Start then, and its process starts under the reduced write confinement
|
||||
// regardless of whether the connection ever became usable. Registration stays
|
||||
// bounded; the disclosure does not expire with it.
|
||||
//
|
||||
// Server order, and a settled entry never re-reads its sink, so calling this
|
||||
// twice cannot reorder or duplicate anything.
|
||||
func (runtime *Runtime) StartupDisclosures() []StartupDisclosure {
|
||||
if runtime == nil {
|
||||
return nil
|
||||
}
|
||||
disclosures := make([]StartupDisclosure, 0, len(runtime.disclosureSources))
|
||||
for _, source := range runtime.disclosureSources {
|
||||
notices := source.notices
|
||||
if len(notices) == 0 {
|
||||
if launched, late := source.sink.observe(); launched {
|
||||
notices = late
|
||||
}
|
||||
}
|
||||
if len(notices) > 0 {
|
||||
disclosures = append(disclosures, StartupDisclosure{Name: source.name, Notices: notices})
|
||||
}
|
||||
}
|
||||
if len(disclosures) == 0 {
|
||||
return nil
|
||||
}
|
||||
return disclosures
|
||||
}
|
||||
|
||||
// appendServerNote joins a [zero] note to output, choosing "returned" or
|
||||
// "also returned" based on whether anything precedes it.
|
||||
func appendServerNote(output, subject, tail string) string {
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Twigpine/zero/internal/config"
|
||||
"github.com/Twigpine/zero/internal/tools"
|
||||
)
|
||||
|
||||
// streamStillOpen asks Wait whether the stream is worth draining, without
|
||||
// letting it block the suite. An open, empty stream blocks Wait forever, which
|
||||
// is the very defect these tests pin, so an unbounded call hangs until the test
|
||||
// binary times out instead of failing. Past the bound the stream is reported
|
||||
// open and closed here, so the waiting goroutine returns.
|
||||
func streamStillOpen(t *testing.T, stream *StartupDisclosureStream) bool {
|
||||
t.Helper()
|
||||
result := make(chan bool, 1)
|
||||
go func() { result <- stream.Wait() }()
|
||||
select {
|
||||
case open := <-result:
|
||||
return open
|
||||
case <-time.After(5 * time.Second):
|
||||
stream.Close()
|
||||
<-result
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// disclosingRuntime registers one server whose launch carried a startup notice,
|
||||
// so its runtime has something the stream could deliver.
|
||||
func disclosingRuntime(t *testing.T) *Runtime {
|
||||
t.Helper()
|
||||
client := &disclosingFakeClient{
|
||||
fakeToolClient: fakeToolClient{listed: []RemoteTool{{Name: "lookup", Description: "Lookup"}}},
|
||||
notices: []string{startupNotice},
|
||||
}
|
||||
runtime, err := RegisterTools(context.Background(), tools.NewRegistry(), config.MCPConfig{Servers: map[string]config.MCPServerConfig{
|
||||
"docs": {Type: "stdio", Command: "docs-mcp"},
|
||||
}}, RegisterOptions{ClientFactory: func(context.Context, Server) (ToolClient, error) { return client, nil }})
|
||||
if err != nil {
|
||||
t.Fatalf("RegisterTools() error = %v", err)
|
||||
}
|
||||
if disclosures := runtime.StartupDisclosures(); len(disclosures) != 1 {
|
||||
t.Fatalf("SETUP INVALID: the runtime has %d disclosures, so an empty stream would prove nothing", len(disclosures))
|
||||
}
|
||||
return runtime
|
||||
}
|
||||
|
||||
// A CLOSED RUNTIME HANDS OUT A CLOSED STREAM.
|
||||
//
|
||||
// Close read the stream field directly. Before anyone had asked for the stream
|
||||
// that field was nil, so Close closed nothing, and the next caller built a fresh,
|
||||
// open stream, queued this runtime's disclosures into it and subscribed it to
|
||||
// launches from a runtime that was already gone. A pump started on it then
|
||||
// waited forever.
|
||||
func TestAStreamRequestedAfterCloseIsAlreadyClosed(t *testing.T) {
|
||||
runtime := disclosingRuntime(t)
|
||||
if err := runtime.Close(); err != nil {
|
||||
t.Fatalf("Close() error = %v", err)
|
||||
}
|
||||
|
||||
stream := runtime.StartupDisclosureStream()
|
||||
if stream == nil {
|
||||
t.Fatal("StartupDisclosureStream() returned nil for a runtime")
|
||||
}
|
||||
if queued := stream.Drain(); len(queued) != 0 {
|
||||
t.Errorf("a stream requested after Close delivered %#v from a runtime that is gone", queued)
|
||||
}
|
||||
if streamStillOpen(t, stream) {
|
||||
t.Error("the stream is still open after Close, so a pump started on it never returns")
|
||||
}
|
||||
}
|
||||
|
||||
// And the stream an owner already holds is the one Close ends, with what was
|
||||
// queued before Close still drainable.
|
||||
func TestCloseEndsTheStreamAnOwnerAlreadyHolds(t *testing.T) {
|
||||
runtime := disclosingRuntime(t)
|
||||
stream := runtime.StartupDisclosureStream()
|
||||
if err := runtime.Close(); err != nil {
|
||||
t.Fatalf("Close() error = %v", err)
|
||||
}
|
||||
if again := runtime.StartupDisclosureStream(); again != stream {
|
||||
t.Error("Close replaced the stream the owner holds, so the owner's pump is never told to stop")
|
||||
}
|
||||
if queued := stream.Drain(); len(queued) != 1 {
|
||||
t.Errorf("the disclosure queued before Close was lost: %#v", queued)
|
||||
}
|
||||
if streamStillOpen(t, stream) {
|
||||
t.Error("Close did not end the stream the owner holds")
|
||||
}
|
||||
}
|
||||
|
||||
// NO RACE BETWEEN CLOSE AND THE FIRST REQUEST FOR THE STREAM.
|
||||
//
|
||||
// Close read the field while a first StartupDisclosureStream call could be
|
||||
// writing it inside the Once on another goroutine. Only meaningful under -race,
|
||||
// which the race job runs.
|
||||
func TestCloseAndTheFirstStreamRequestDoNotRace(t *testing.T) {
|
||||
for attempt := 0; attempt < 20; attempt++ {
|
||||
runtime := disclosingRuntime(t)
|
||||
start := make(chan struct{})
|
||||
var group sync.WaitGroup
|
||||
group.Add(2)
|
||||
go func() {
|
||||
defer group.Done()
|
||||
<-start
|
||||
_ = runtime.StartupDisclosureStream()
|
||||
}()
|
||||
go func() {
|
||||
defer group.Done()
|
||||
<-start
|
||||
_ = runtime.Close()
|
||||
}()
|
||||
close(start)
|
||||
group.Wait()
|
||||
// Whichever call won the Once, the stream is closed now. A disclosure
|
||||
// queued before Close stays drainable, so drain before asking.
|
||||
stream := runtime.StartupDisclosureStream()
|
||||
stream.Drain()
|
||||
if streamStillOpen(t, stream) {
|
||||
t.Fatal("the stream survived Close")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -218,8 +218,14 @@ func (server toolServer) callTool(ctx context.Context, rawParams json.RawMessage
|
||||
result := server.registry.RunWithOptions(ctx, params.Name, params.Arguments, tools.RunOptions{
|
||||
PermissionGranted: server.options.PermissionGranted,
|
||||
})
|
||||
// ModelOutput, not the raw field. This is a model-facing protocol boundary,
|
||||
// and Result.Output now holds the UNDECORATED base text: the enforcement
|
||||
// disclosure lives in typed state and the accessor is what composes the two.
|
||||
// Serializing Output directly hands an MCP client a Windows command's ordinary
|
||||
// output with no statement that its DenyRead token shape left writes
|
||||
// unconfined, which is the one thing the disclosure exists to say.
|
||||
return CallToolResult{
|
||||
Content: []Content{{Type: "text", Text: result.Output}},
|
||||
Content: []Content{{Type: "text", Text: result.ModelOutput()}},
|
||||
IsError: result.Status != tools.StatusOK,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"testing"
|
||||
|
||||
"github.com/Twigpine/zero/internal/config"
|
||||
"github.com/Twigpine/zero/internal/tools"
|
||||
)
|
||||
|
||||
// disclosingRaceClient is a launched server that reports a disclosure.
|
||||
type disclosingRaceClient struct {
|
||||
fakeToolClient
|
||||
notices []string
|
||||
}
|
||||
|
||||
func (c *disclosingRaceClient) StartupNotices() []string { return c.notices }
|
||||
|
||||
// THE CONCURRENT PHASE TOUCHES NO SHARED STATE, AND THAT IS LOAD-BEARING.
|
||||
//
|
||||
// RegisterTools runs one goroutine per server and commits everything in a
|
||||
// deterministic serial phase afterwards, which is what lets the result be
|
||||
// identical regardless of completion order. Collecting the startup disclosures
|
||||
// inside the goroutine broke both halves of that: the append raced the slice
|
||||
// header, so entries could be lost or overwritten, and whichever survived were
|
||||
// ordered by completion time rather than by server.
|
||||
//
|
||||
// Many servers rather than one, because a single disclosing server cannot
|
||||
// exercise a shared write at all. Run this package with -race.
|
||||
func TestStartupDisclosuresAreCollectedWithoutRacing(t *testing.T) {
|
||||
const servers = 32
|
||||
configured := map[string]config.MCPServerConfig{}
|
||||
for index := range servers {
|
||||
configured[fmt.Sprintf("srv%02d", index)] = config.MCPServerConfig{Type: "stdio", Command: "server"}
|
||||
}
|
||||
|
||||
registry := tools.NewRegistry()
|
||||
runtime, err := RegisterTools(context.Background(), registry, config.MCPConfig{Servers: configured}, RegisterOptions{
|
||||
ClientFactory: func(_ context.Context, server Server) (ToolClient, error) {
|
||||
return &disclosingRaceClient{
|
||||
fakeToolClient: fakeToolClient{listed: []RemoteTool{{Name: "tool_" + server.Name, Description: "d"}}},
|
||||
notices: []string{"denyRead is configured for " + server.Name},
|
||||
}, nil
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("RegisterTools() error = %v", err)
|
||||
}
|
||||
defer runtime.Close()
|
||||
|
||||
got := runtime.StartupDisclosures()
|
||||
if len(got) != servers {
|
||||
t.Fatalf("collected %d disclosures, want %d: a shared append loses entries", len(got), servers)
|
||||
}
|
||||
|
||||
// Deterministic server order, not completion order. Sorting the result and
|
||||
// then comparing would hide exactly the defect this pins.
|
||||
names := make([]string, 0, len(got))
|
||||
for _, disclosure := range got {
|
||||
names = append(names, disclosure.Name)
|
||||
}
|
||||
sorted := append([]string(nil), names...)
|
||||
sort.Strings(sorted)
|
||||
for index := range names {
|
||||
if names[index] != sorted[index] {
|
||||
t.Fatalf("disclosure %d is %q, want %q: order follows completion rather than server order", index, names[index], sorted[index])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// failingDisclosingClient launches (so it has a disclosure) and then fails
|
||||
// tools/list, which is the shape that used to drop the fact.
|
||||
type failingDisclosingClient struct {
|
||||
fakeToolClient
|
||||
notices []string
|
||||
}
|
||||
|
||||
func (c *failingDisclosingClient) StartupNotices() []string { return c.notices }
|
||||
func (c *failingDisclosingClient) ListTools(context.Context) ([]RemoteTool, error) {
|
||||
return nil, fmt.Errorf("initialize failed after the process started")
|
||||
}
|
||||
|
||||
// THE LAUNCH FACT OUTLIVES THE CONNECTION.
|
||||
//
|
||||
// connectStdio records the disclosure once cmd.Start returns, which is the right
|
||||
// moment. But a stdio server can start, do filesystem work, and then fail
|
||||
// initialize or tools/list, and that path closes the client and returns nil. The
|
||||
// disclosure was reachable only through that client, so it died with it, and the
|
||||
// operator was told the server was unavailable without being told the process
|
||||
// had already run without the write jail.
|
||||
func TestADisclosureSurvivesAFailureAfterTheProcessLaunched(t *testing.T) {
|
||||
const notice = "denyRead is configured, so the write jail is not confining writes"
|
||||
registry := tools.NewRegistry()
|
||||
runtime, err := RegisterTools(context.Background(), registry, config.MCPConfig{Servers: map[string]config.MCPServerConfig{
|
||||
"docs": {Type: "stdio", Command: "docs-mcp"},
|
||||
}}, RegisterOptions{
|
||||
ClientFactory: func(context.Context, Server) (ToolClient, error) {
|
||||
return &failingDisclosingClient{notices: []string{notice}}, nil
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("RegisterTools() error = %v", err)
|
||||
}
|
||||
defer runtime.Close()
|
||||
|
||||
skipped := runtime.Skipped()
|
||||
if len(skipped) != 1 {
|
||||
t.Fatalf("Skipped() = %#v, want the failure recorded", skipped)
|
||||
}
|
||||
disclosures := runtime.StartupDisclosures()
|
||||
if len(disclosures) != 1 || len(disclosures[0].Notices) != 1 || disclosures[0].Notices[0] != notice {
|
||||
t.Fatalf("StartupDisclosures() = %#v, want the launch disclosure kept despite the failure", disclosures)
|
||||
}
|
||||
if disclosures[0].Name != "docs" {
|
||||
t.Errorf("Name = %q, want the server it describes", disclosures[0].Name)
|
||||
}
|
||||
}
|
||||
|
||||
// And a server that never launched still discloses nothing.
|
||||
func TestAFactoryFailureDisclosesNothing(t *testing.T) {
|
||||
registry := tools.NewRegistry()
|
||||
runtime, err := RegisterTools(context.Background(), registry, config.MCPConfig{Servers: map[string]config.MCPServerConfig{
|
||||
"docs": {Type: "stdio", Command: "docs-mcp"},
|
||||
}}, RegisterOptions{
|
||||
ClientFactory: func(context.Context, Server) (ToolClient, error) {
|
||||
return nil, fmt.Errorf("could not start the process")
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("RegisterTools() error = %v", err)
|
||||
}
|
||||
defer runtime.Close()
|
||||
if disclosures := runtime.StartupDisclosures(); len(disclosures) != 0 {
|
||||
t.Errorf("StartupDisclosures() = %#v, want none for a process that never started", disclosures)
|
||||
}
|
||||
}
|
||||
|
||||
// A launched process that fails its HANDSHAKE keeps its disclosure too.
|
||||
//
|
||||
// connectStdio records the notices once cmd.Start returns, which is the right
|
||||
// moment, but the initialize failure path closes and discards the client. The
|
||||
// client was the only carrier, so the fact died with the connection unless the
|
||||
// failure carries it out itself.
|
||||
func TestADisclosureSurvivesAnInitializeFailure(t *testing.T) {
|
||||
const notice = "denyRead is configured, so the write jail is not confining writes"
|
||||
registry := tools.NewRegistry()
|
||||
runtime, err := RegisterTools(context.Background(), registry, config.MCPConfig{Servers: map[string]config.MCPServerConfig{
|
||||
"docs": {Type: "stdio", Command: "docs-mcp"},
|
||||
}}, RegisterOptions{
|
||||
ClientFactory: func(context.Context, Server) (ToolClient, error) {
|
||||
// What connectStdio does once Start has succeeded and the handshake
|
||||
// then fails: the client is gone, the fact rides the error.
|
||||
return nil, &startupDisclosureError{
|
||||
err: fmt.Errorf("initialize MCP server docs: handshake timed out"),
|
||||
notices: []string{notice},
|
||||
}
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("RegisterTools() error = %v", err)
|
||||
}
|
||||
defer runtime.Close()
|
||||
|
||||
if skipped := runtime.Skipped(); len(skipped) != 1 {
|
||||
t.Fatalf("Skipped() = %#v, want the failure recorded", skipped)
|
||||
}
|
||||
disclosures := runtime.StartupDisclosures()
|
||||
if len(disclosures) != 1 || len(disclosures[0].Notices) != 1 || disclosures[0].Notices[0] != notice {
|
||||
t.Fatalf("StartupDisclosures() = %#v, want the launch disclosure kept through the handshake failure", disclosures)
|
||||
}
|
||||
}
|
||||
|
||||
// And a plain failure with no launch behind it still discloses nothing, so the
|
||||
// error path is not just attaching notices to everything.
|
||||
func TestAPlainConnectFailureDisclosesNothing(t *testing.T) {
|
||||
registry := tools.NewRegistry()
|
||||
runtime, err := RegisterTools(context.Background(), registry, config.MCPConfig{Servers: map[string]config.MCPServerConfig{
|
||||
"docs": {Type: "stdio", Command: "docs-mcp"},
|
||||
}}, RegisterOptions{
|
||||
ClientFactory: func(context.Context, Server) (ToolClient, error) {
|
||||
return nil, fmt.Errorf("could not start the process")
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("RegisterTools() error = %v", err)
|
||||
}
|
||||
defer runtime.Close()
|
||||
if disclosures := runtime.StartupDisclosures(); len(disclosures) != 0 {
|
||||
t.Errorf("StartupDisclosures() = %#v, want none", disclosures)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package mcp
|
||||
|
||||
import "sync"
|
||||
|
||||
// StartupDisclosureStream carries launch disclosures out of the runtime as
|
||||
// TYPED EVENTS, for whichever component owns the output to drain on its own
|
||||
// goroutine.
|
||||
//
|
||||
// It replaces handing the runtime a presentation callback. That callback closed
|
||||
// over the CLI's writer, and the runtime invoked it synchronously from whichever
|
||||
// goroutine happened to resolve the launch. For a server still inside cmd.Start
|
||||
// when registration gave up, that goroutine is the abandoned connect attempt,
|
||||
// which runs on no schedule the caller controls: the write landed off the output
|
||||
// owner's goroutine, raced any other writer, and could arrive after the owner had
|
||||
// returned or after Bubble Tea had taken the alt screen. The runtime owns the
|
||||
// FACT that a process started; it does not own anyone's writer.
|
||||
//
|
||||
// So the runtime only ever appends a value here. Delivery, ordering against other
|
||||
// startup output, and the decision to stop listening all belong to the owner.
|
||||
//
|
||||
// LIFETIME IS EXPLICIT. Close is the owner saying "I will not write again".
|
||||
// Offers after Close are dropped rather than queued for a consumer that no longer
|
||||
// exists, and Wait returns false so a pump exits. Both are deliberate: a
|
||||
// disclosure is worth printing while someone can print it, and worth dropping
|
||||
// rather than corrupting a screen that now belongs to something else. Close is
|
||||
// idempotent and safe from any goroutine, so the runtime and the owner may both
|
||||
// call it.
|
||||
type StartupDisclosureStream struct {
|
||||
mu sync.Mutex
|
||||
queue []StartupDisclosure
|
||||
closed bool
|
||||
wake chan struct{}
|
||||
}
|
||||
|
||||
func newStartupDisclosureStream() *StartupDisclosureStream {
|
||||
return &StartupDisclosureStream{wake: make(chan struct{}, 1)}
|
||||
}
|
||||
|
||||
// offer queues one disclosure. Called from the registration goroutine for a
|
||||
// launch already known at commit, and from an abandoned connect goroutine for one
|
||||
// that resolves later. It takes a lock and appends; it never touches a writer,
|
||||
// which is the whole point of the type.
|
||||
func (stream *StartupDisclosureStream) offer(disclosure StartupDisclosure) {
|
||||
if stream == nil || len(disclosure.Notices) == 0 {
|
||||
return
|
||||
}
|
||||
stream.mu.Lock()
|
||||
if stream.closed {
|
||||
stream.mu.Unlock()
|
||||
return
|
||||
}
|
||||
stream.queue = append(stream.queue, disclosure)
|
||||
stream.mu.Unlock()
|
||||
select {
|
||||
case stream.wake <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
// Drain removes and returns everything queued right now, without blocking. The
|
||||
// owner calls this on the goroutine that owns the writer, so every disclosure is
|
||||
// printed by exactly one goroutine at a time.
|
||||
func (stream *StartupDisclosureStream) Drain() []StartupDisclosure {
|
||||
if stream == nil {
|
||||
return nil
|
||||
}
|
||||
stream.mu.Lock()
|
||||
defer stream.mu.Unlock()
|
||||
if len(stream.queue) == 0 {
|
||||
return nil
|
||||
}
|
||||
queued := stream.queue
|
||||
stream.queue = nil
|
||||
return queued
|
||||
}
|
||||
|
||||
// Wait blocks until at least one disclosure is queued or the stream is closed. It
|
||||
// reports whether draining is still worthwhile: false means closed and empty, so
|
||||
// a pump loop should return.
|
||||
func (stream *StartupDisclosureStream) Wait() bool {
|
||||
if stream == nil {
|
||||
return false
|
||||
}
|
||||
for {
|
||||
stream.mu.Lock()
|
||||
queued := len(stream.queue) > 0
|
||||
closed := stream.closed
|
||||
stream.mu.Unlock()
|
||||
if queued {
|
||||
return true
|
||||
}
|
||||
if closed {
|
||||
return false
|
||||
}
|
||||
<-stream.wake
|
||||
}
|
||||
}
|
||||
|
||||
// Close ends delivery. Idempotent, safe from any goroutine, and safe to call
|
||||
// from both the runtime and the output owner.
|
||||
func (stream *StartupDisclosureStream) Close() {
|
||||
if stream == nil {
|
||||
return
|
||||
}
|
||||
stream.mu.Lock()
|
||||
if stream.closed {
|
||||
stream.mu.Unlock()
|
||||
return
|
||||
}
|
||||
stream.closed = true
|
||||
stream.mu.Unlock()
|
||||
select {
|
||||
case stream.wake <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Twigpine/zero/internal/config"
|
||||
"github.com/Twigpine/zero/internal/execution"
|
||||
"github.com/Twigpine/zero/internal/tools"
|
||||
)
|
||||
|
||||
const startupNotice = "denyRead is configured, so the write jail is not confining writes"
|
||||
|
||||
// disclosingPreparer plans an MCP server launch that carries an enforcement
|
||||
// notice, and can fail the way the sandbox does before the child exists.
|
||||
type disclosingPreparer struct {
|
||||
prepareErr error
|
||||
missing bool
|
||||
}
|
||||
|
||||
func (preparer *disclosingPreparer) PrepareExecution(ctx context.Context, request execution.Request) (execution.PreparedCommand, error) {
|
||||
if preparer.prepareErr != nil {
|
||||
return execution.PreparedCommand{}, preparer.prepareErr
|
||||
}
|
||||
name, args := request.Command.Name, request.Command.Args
|
||||
if preparer.missing {
|
||||
name, args = "definitely-not-a-real-binary-zzz", nil
|
||||
}
|
||||
command := exec.CommandContext(ctx, name, args...)
|
||||
command.Dir = request.WorkingDirectory
|
||||
command.Env = request.Command.Env
|
||||
return execution.PreparedCommand{
|
||||
Command: command,
|
||||
Enforcement: execution.Enforcement{Notices: []string{startupNotice}},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func helperServer(t *testing.T) Server {
|
||||
t.Helper()
|
||||
executable, err := os.Executable()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return Server{
|
||||
Name: "docs",
|
||||
Type: ServerTypeStdio,
|
||||
Command: executable,
|
||||
Args: []string{"-test.run=TestMCPStdioHelperProcess", "--"},
|
||||
Env: map[string]string{"ZERO_MCP_STDIO_HELPER": "1"},
|
||||
}
|
||||
}
|
||||
|
||||
// THE FACT DESCRIBES STARTUP, SO NOTHING LATER CAN CARRY IT.
|
||||
//
|
||||
// The generic adapter puts plan notes on PreparedCommand.Enforcement for
|
||||
// OriginMCPServer, and connectStdio kept only the command and its cleanup. A
|
||||
// stdio server launched under the weakened token then served the whole session
|
||||
// with no path able to tell the operator that its write confinement was
|
||||
// reduced, and no individual tool result could recover it, because the fact is
|
||||
// about the process rather than about any response.
|
||||
func TestAnMCPServerLaunchKeepsItsEnforcementDisclosure(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
client, err := ConnectWithOptions(ctx, helperServer(t), ConnectOptions{
|
||||
Execution: execution.NewRunner(&disclosingPreparer{}),
|
||||
WorkspaceRoot: t.TempDir(),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ConnectWithOptions() error = %v", err)
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
disclosing, ok := client.(startupDisclosing)
|
||||
if !ok {
|
||||
t.Fatal("a launched stdio client does not report its startup enforcement at all")
|
||||
}
|
||||
notices := disclosing.StartupNotices()
|
||||
if len(notices) != 1 || notices[0] != startupNotice {
|
||||
t.Fatalf("StartupNotices() = %#v, want the launch disclosure", notices)
|
||||
}
|
||||
}
|
||||
|
||||
// A launch with nothing to disclose reports nothing, or every server would carry
|
||||
// a notice and the statement would mean nothing.
|
||||
func TestAnUnrestrictedMCPServerLaunchDisclosesNothing(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
client, err := ConnectWithOptions(ctx, helperServer(t), ConnectOptions{
|
||||
Execution: execution.NewRunner(&mcpExecutionPreparer{}),
|
||||
WorkspaceRoot: t.TempDir(),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ConnectWithOptions() error = %v", err)
|
||||
}
|
||||
defer client.Close()
|
||||
disclosing, ok := client.(startupDisclosing)
|
||||
if !ok {
|
||||
t.Fatal("a launched stdio client does not report its startup enforcement at all")
|
||||
}
|
||||
if notices := disclosing.StartupNotices(); len(notices) != 0 {
|
||||
t.Errorf("StartupNotices() = %#v, want none", notices)
|
||||
}
|
||||
}
|
||||
|
||||
// And a launch that never happened claims nothing, which is the same launch-state
|
||||
// rule hooks and plugins apply. Here it is expressed by WHERE the notice is
|
||||
// recorded: every failure above returns before the client exists.
|
||||
func TestAnMCPServerThatNeverLaunchedClaimsNoEnforcement(t *testing.T) {
|
||||
for _, testCase := range []struct {
|
||||
name string
|
||||
preparer *disclosingPreparer
|
||||
}{
|
||||
{"sandbox setup failed", &disclosingPreparer{prepareErr: errors.New("could not build the restricted token")}},
|
||||
{"executable not found", &disclosingPreparer{missing: true}},
|
||||
} {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
client, err := ConnectWithOptions(ctx, helperServer(t), ConnectOptions{
|
||||
Execution: execution.NewRunner(testCase.preparer),
|
||||
WorkspaceRoot: t.TempDir(),
|
||||
})
|
||||
if err == nil {
|
||||
client.Close()
|
||||
t.Fatal("the server started even though its launch was supposed to fail")
|
||||
}
|
||||
// THE CARRIER, NOT THE TEXT. A connect failure hands its notices on
|
||||
// startupDisclosureError, whose Error() prints only the cause, so a
|
||||
// check of the message could never see a notice that was carried.
|
||||
if carried := startupNoticesFromError(err); len(carried) != 0 {
|
||||
t.Errorf("a launch that never happened claimed an enforcement trade: %#v", carried)
|
||||
}
|
||||
if strings.Contains(err.Error(), startupNotice) {
|
||||
t.Errorf("a launch that never happened claimed an enforcement trade in its error: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// disclosingFakeClient is a launched server that carries a disclosure.
|
||||
type disclosingFakeClient struct {
|
||||
fakeToolClient
|
||||
notices []string
|
||||
}
|
||||
|
||||
func (client *disclosingFakeClient) StartupNotices() []string { return client.notices }
|
||||
|
||||
// Registration is the boundary that reports it, once, for the process it
|
||||
// launched.
|
||||
func TestRegistrationCollectsStartupDisclosures(t *testing.T) {
|
||||
registry := tools.NewRegistry()
|
||||
client := &disclosingFakeClient{
|
||||
fakeToolClient: fakeToolClient{listed: []RemoteTool{{Name: "lookup", Description: "Lookup"}}},
|
||||
notices: []string{startupNotice},
|
||||
}
|
||||
runtime, err := RegisterTools(context.Background(), registry, config.MCPConfig{Servers: map[string]config.MCPServerConfig{
|
||||
"docs": {Type: "stdio", Command: "docs-mcp"},
|
||||
}}, RegisterOptions{ClientFactory: func(context.Context, Server) (ToolClient, error) { return client, nil }})
|
||||
if err != nil {
|
||||
t.Fatalf("RegisterTools() error = %v", err)
|
||||
}
|
||||
defer runtime.Close()
|
||||
|
||||
disclosures := runtime.StartupDisclosures()
|
||||
if len(disclosures) != 1 {
|
||||
t.Fatalf("StartupDisclosures() = %#v, want one", disclosures)
|
||||
}
|
||||
if disclosures[0].Name != "docs" {
|
||||
t.Errorf("Name = %q, want the server it describes", disclosures[0].Name)
|
||||
}
|
||||
if len(disclosures[0].Notices) != 1 || disclosures[0].Notices[0] != startupNotice {
|
||||
t.Errorf("Notices = %#v, want the launch disclosure", disclosures[0].Notices)
|
||||
}
|
||||
}
|
||||
|
||||
// A network server launches no local process, so it implements nothing and
|
||||
// reports nothing. That is a different answer from an empty one and the negative
|
||||
// case that keeps the statement meaningful.
|
||||
func TestANetworkServerReportsNoStartupDisclosure(t *testing.T) {
|
||||
registry := tools.NewRegistry()
|
||||
runtime, err := RegisterTools(context.Background(), registry, config.MCPConfig{Servers: map[string]config.MCPServerConfig{
|
||||
"docs": {Type: "http", URL: "https://host.invalid/mcp"},
|
||||
}}, RegisterOptions{ClientFactory: func(context.Context, Server) (ToolClient, error) {
|
||||
return &fakeToolClient{listed: []RemoteTool{{Name: "lookup", Description: "Lookup"}}}, nil
|
||||
}})
|
||||
if err != nil {
|
||||
t.Fatalf("RegisterTools() error = %v", err)
|
||||
}
|
||||
defer runtime.Close()
|
||||
if disclosures := runtime.StartupDisclosures(); len(disclosures) != 0 {
|
||||
t.Errorf("StartupDisclosures() = %#v, want none for a server that launches no process", disclosures)
|
||||
}
|
||||
}
|
||||
@@ -149,6 +149,7 @@ func TestRefreshModelsDevCacheRejectsBadBodyWithoutClobbering(t *testing.T) {
|
||||
}
|
||||
t.Setenv("ZERO_MODELS_CACHE_PATH", cachePath)
|
||||
t.Setenv("ZERO_MODELS_URL", server.URL)
|
||||
t.Setenv("ZERO_DISABLE_MODELS_FETCH", "")
|
||||
|
||||
if err := RefreshModelsDevCache(t.Context()); err == nil {
|
||||
t.Fatal("bad body must return an error")
|
||||
@@ -211,6 +212,9 @@ func TestCachedModelsDevProvidersIgnoresStaleCache(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("ZERO_MODELS_CACHE_PATH", cachePath)
|
||||
// The variable turns the overlay off too, which would pass this test
|
||||
// without the staleness check ever running.
|
||||
t.Setenv("ZERO_DISABLE_MODELS_FETCH", "")
|
||||
resetModelsDevCacheForTest()
|
||||
t.Cleanup(resetModelsDevCacheForTest)
|
||||
EnableModelsDevOverlay()
|
||||
@@ -226,6 +230,9 @@ func TestCachedModelsDevProvidersRequiresOptIn(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("ZERO_MODELS_CACHE_PATH", cachePath)
|
||||
// As above: with the variable set, a nil result says nothing about the
|
||||
// opt-in.
|
||||
t.Setenv("ZERO_DISABLE_MODELS_FETCH", "")
|
||||
resetModelsDevCacheForTest()
|
||||
t.Cleanup(resetModelsDevCacheForTest)
|
||||
|
||||
@@ -242,6 +249,7 @@ func TestDefaultModelEntriesAppliesFreshCache(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("ZERO_MODELS_CACHE_PATH", cachePath)
|
||||
t.Setenv("ZERO_DISABLE_MODELS_FETCH", "")
|
||||
resetModelsDevCacheForTest()
|
||||
t.Cleanup(resetModelsDevCacheForTest)
|
||||
EnableModelsDevOverlay()
|
||||
|
||||
@@ -1,191 +0,0 @@
|
||||
package oauth
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
// secretBytes is the AES-256 key length kept in the per-user secret file.
|
||||
secretBytes = 32
|
||||
|
||||
secretRetryAttempts = 500
|
||||
secretRetryDelay = 2 * time.Millisecond
|
||||
)
|
||||
|
||||
// aesGCMCrypter encrypts the token file at rest with AES-256-GCM under a
|
||||
// per-user random secret persisted (0600) beside the token file. The on-disk
|
||||
// blob is nonce || ciphertext; GCM provides confidentiality AND tamper
|
||||
// detection, so a corrupted/forged file fails closed on open. This is the
|
||||
// opt-in "encrypted-file" storage backend; the default backend writes the
|
||||
// 0600 plaintext JSON unchanged.
|
||||
type aesGCMCrypter struct {
|
||||
secretPath string
|
||||
}
|
||||
|
||||
func newAESGCMCrypter(secretPath string) *aesGCMCrypter {
|
||||
return &aesGCMCrypter{secretPath: secretPath}
|
||||
}
|
||||
|
||||
// aead loads (or, when create is set, generates) the secret and returns the GCM
|
||||
// AEAD. open passes create=false so a missing secret is a hard error rather than
|
||||
// silently minting a new key that could never decrypt the existing file.
|
||||
func (c *aesGCMCrypter) aead(create bool) (cipher.AEAD, error) {
|
||||
secret, err := loadOrCreateSecret(c.secretPath, create)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
block, err := aes.NewCipher(secret)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("oauth: build cipher: %w", err)
|
||||
}
|
||||
return cipher.NewGCM(block)
|
||||
}
|
||||
|
||||
// seal encrypts plaintext, prefixing a fresh random nonce. It creates the secret
|
||||
// on first use.
|
||||
func (c *aesGCMCrypter) seal(plaintext []byte) ([]byte, error) {
|
||||
gcm, err := c.aead(true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
nonce := make([]byte, gcm.NonceSize())
|
||||
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
|
||||
return nil, fmt.Errorf("oauth: generate nonce: %w", err)
|
||||
}
|
||||
return gcm.Seal(nonce, nonce, plaintext, nil), nil
|
||||
}
|
||||
|
||||
// open decrypts a nonce||ciphertext blob, failing closed on a missing secret,
|
||||
// a short blob, or a failed authentication tag (tampering / wrong key).
|
||||
func (c *aesGCMCrypter) open(blob []byte) ([]byte, error) {
|
||||
gcm, err := c.aead(false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(blob) < gcm.NonceSize() {
|
||||
return nil, errors.New("oauth: encrypted token file is too short")
|
||||
}
|
||||
nonce, ciphertext := blob[:gcm.NonceSize()], blob[gcm.NonceSize():]
|
||||
plaintext, err := gcm.Open(nil, nonce, ciphertext, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("oauth: decrypt token file (wrong secret or tampered): %w", err)
|
||||
}
|
||||
return plaintext, nil
|
||||
}
|
||||
|
||||
// loadOrCreateSecret reads the 32-byte secret at path. When create is set and
|
||||
// the file is absent, it generates a random secret and creates the file
|
||||
// atomically (0600). A wrong-sized existing secret fails closed (corruption).
|
||||
func loadOrCreateSecret(path string, create bool) ([]byte, error) {
|
||||
if data, err := readSecretFileRetry(path); err == nil {
|
||||
return data, nil
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
return nil, err
|
||||
}
|
||||
if !create {
|
||||
return nil, fmt.Errorf("oauth: token secret %s is missing; cannot decrypt the token file", path)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return createSecretFile(path)
|
||||
}
|
||||
|
||||
func createSecretFile(path string) ([]byte, error) {
|
||||
lockPath := path + ".lock"
|
||||
var lastErr error
|
||||
for attempt := 0; attempt < secretRetryAttempts; attempt++ {
|
||||
lock, err := os.OpenFile(lockPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
|
||||
if err == nil {
|
||||
_ = lock.Close()
|
||||
defer os.Remove(lockPath)
|
||||
if data, rerr := readSecretFileRetry(path); rerr == nil {
|
||||
return data, nil
|
||||
} else if !errors.Is(rerr, os.ErrNotExist) {
|
||||
return nil, rerr
|
||||
}
|
||||
return writeNewSecretFile(path)
|
||||
}
|
||||
// On Windows a concurrent holder's os.Remove leaves the lock file in a
|
||||
// "delete pending" state, so an O_EXCL create races it with
|
||||
// ERROR_ACCESS_DENIED (os.ErrPermission) rather than ErrExist. Treat that
|
||||
// as contention and retry too — mirroring acquireFileLock in lock.go —
|
||||
// otherwise concurrent secret creation spuriously fails on Windows.
|
||||
if !errors.Is(err, os.ErrExist) && !errors.Is(err, os.ErrPermission) {
|
||||
return nil, fmt.Errorf("oauth: create token secret lock: %w", err)
|
||||
}
|
||||
// Remember the lock-creation error itself rather than a subsequent
|
||||
// "secret file doesn't exist yet" read error -- otherwise a real
|
||||
// contention/ACL problem is masked by the expected-while-waiting
|
||||
// ErrNotExist once the retries are exhausted.
|
||||
lastErr = err
|
||||
if data, rerr := readSecretFileRetry(path); rerr == nil {
|
||||
return data, nil
|
||||
}
|
||||
time.Sleep(secretRetryDelay)
|
||||
}
|
||||
return nil, fmt.Errorf("oauth: timed out waiting for token secret %s: %w", path, lastErr)
|
||||
}
|
||||
|
||||
func writeNewSecretFile(path string) ([]byte, error) {
|
||||
secret := make([]byte, secretBytes)
|
||||
if _, err := io.ReadFull(rand.Reader, secret); err != nil {
|
||||
return nil, fmt.Errorf("oauth: generate token secret: %w", err)
|
||||
}
|
||||
// The encryption key is published exactly like the token blob: through the
|
||||
// protected per-store directory, under a random name a sandboxed process
|
||||
// cannot guess, wait for, or rename away.
|
||||
tmp, tmpPath, err := createPublicationFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("oauth: create token secret temp file: %w", err)
|
||||
}
|
||||
defer os.Remove(tmpPath)
|
||||
if _, werr := tmp.Write(secret); werr != nil {
|
||||
_ = tmp.Close()
|
||||
return nil, fmt.Errorf("oauth: write token secret: %w", werr)
|
||||
}
|
||||
if cerr := tmp.Close(); cerr != nil {
|
||||
return nil, fmt.Errorf("oauth: write token secret: %w", cerr)
|
||||
}
|
||||
if err := os.Rename(tmpPath, path); err != nil {
|
||||
return nil, fmt.Errorf("oauth: publish token secret: %w", err)
|
||||
}
|
||||
return secret, nil
|
||||
}
|
||||
|
||||
func readSecretFileRetry(path string) ([]byte, error) {
|
||||
var lastErr error
|
||||
for attempt := 0; attempt < secretRetryAttempts; attempt++ {
|
||||
data, err := readSecretFile(path)
|
||||
if !isTransientSecretAccessError(err) {
|
||||
return data, err
|
||||
}
|
||||
lastErr = err
|
||||
time.Sleep(secretRetryDelay)
|
||||
}
|
||||
return nil, fmt.Errorf("oauth: timed out waiting for token secret %s: %w", path, lastErr)
|
||||
}
|
||||
|
||||
// readSecretFile reads and validates the secret at path, returning a wrapped
|
||||
// os.ErrNotExist when it is absent so callers can branch on creation.
|
||||
func readSecretFile(path string) ([]byte, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil, err
|
||||
}
|
||||
return nil, fmt.Errorf("oauth: read token secret: %w", err)
|
||||
}
|
||||
if len(data) != secretBytes {
|
||||
return nil, fmt.Errorf("oauth: token secret at %s has unexpected size %d", path, len(data))
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
@@ -1,103 +1,13 @@
|
||||
package oauth
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestLoadOrCreateSecretConcurrentConverges(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "tok.json.secret")
|
||||
const n = 16
|
||||
secrets := make([][]byte, n)
|
||||
errs := make([]error, n)
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < n; i++ {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
secrets[i], errs[i] = loadOrCreateSecret(path, true)
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
// Exactly one creator wins; every racer must converge on the same on-disk
|
||||
// secret rather than reading a half-published file or orphaning its own.
|
||||
for i := 0; i < n; i++ {
|
||||
if errs[i] != nil {
|
||||
t.Fatalf("goroutine %d: %v", i, errs[i])
|
||||
}
|
||||
if !bytes.Equal(secrets[i], secrets[0]) {
|
||||
t.Fatalf("goroutine %d got a divergent secret; concurrent create did not converge", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAESGCMCrypterRoundTripAndTamper(t *testing.T) {
|
||||
secretPath := filepath.Join(t.TempDir(), "tok.json.secret")
|
||||
c := newAESGCMCrypter(secretPath)
|
||||
plaintext := []byte(`{"schemaVersion":1,"tokens":{}}`)
|
||||
blob, err := c.seal(plaintext)
|
||||
if err != nil {
|
||||
t.Fatalf("seal: %v", err)
|
||||
}
|
||||
if bytes.Contains(blob, plaintext) {
|
||||
t.Fatal("sealed blob must not contain the plaintext")
|
||||
}
|
||||
got, err := c.open(blob)
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
if !bytes.Equal(got, plaintext) {
|
||||
t.Fatalf("round-trip mismatch: %q", got)
|
||||
}
|
||||
// Tamper: flipping any byte breaks the GCM tag.
|
||||
tampered := append([]byte(nil), blob...)
|
||||
tampered[len(tampered)-1] ^= 0xff
|
||||
if _, err := c.open(tampered); err == nil {
|
||||
t.Fatal("open must reject a tampered blob (GCM auth)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadOrCreateSecret(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "tok.json.secret")
|
||||
// Missing + create=false => fail closed (can't decrypt without the secret).
|
||||
if _, err := loadOrCreateSecret(path, false); err == nil {
|
||||
t.Fatal("missing secret with create=false must error")
|
||||
}
|
||||
secret, err := loadOrCreateSecret(path, true)
|
||||
if err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
if len(secret) != secretBytes {
|
||||
t.Fatalf("secret length = %d, want %d", len(secret), secretBytes)
|
||||
}
|
||||
// Stable across reads.
|
||||
again, err := loadOrCreateSecret(path, false)
|
||||
if err != nil || !bytes.Equal(again, secret) {
|
||||
t.Fatalf("secret not stable: %v", err)
|
||||
}
|
||||
if runtime.GOOS != "windows" {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
t.Fatalf("stat secret: %v", err)
|
||||
}
|
||||
if perm := info.Mode().Perm(); perm != 0o600 {
|
||||
t.Fatalf("secret file mode = %o, want 600", perm)
|
||||
}
|
||||
}
|
||||
// Wrong-sized secret fails closed.
|
||||
if err := os.WriteFile(path, []byte("short"), 0o600); err != nil {
|
||||
t.Fatalf("corrupt secret: %v", err)
|
||||
}
|
||||
if _, err := loadOrCreateSecret(path, true); err == nil {
|
||||
t.Fatal("wrong-sized secret must error")
|
||||
}
|
||||
}
|
||||
|
||||
func newEncryptedStore(t *testing.T) (*Store, string) {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "oauth-tokens.json")
|
||||
@@ -202,3 +112,36 @@ func TestEncryptedStoreMissingSecretFailsClosed(t *testing.T) {
|
||||
t.Fatal("missing secret must fail closed, not return empty")
|
||||
}
|
||||
}
|
||||
|
||||
// TestEncryptedStoreReclaimsStaleSecretLock reproduces a crash while the
|
||||
// encrypted-file backend was creating its AES key. The key-creation lock is an
|
||||
// O_EXCL ownership marker, not a kernel lock, so a process killed between
|
||||
// creating and removing it leaves a file that every later Save waits on and
|
||||
// then rejects forever. A stale marker must be reclaimed instead.
|
||||
func TestEncryptedStoreReclaimsStaleSecretLock(t *testing.T) {
|
||||
s, path := newEncryptedStore(t)
|
||||
lockPath := path + ".secret.lock"
|
||||
if err := os.WriteFile(lockPath, []byte("crashed-holder"), 0o600); err != nil {
|
||||
t.Fatalf("write stale secret lock: %v", err)
|
||||
}
|
||||
stale := time.Now().Add(-2 * time.Minute)
|
||||
if err := os.Chtimes(lockPath, stale, stale); err != nil {
|
||||
t.Fatalf("backdate stale secret lock: %v", err)
|
||||
}
|
||||
|
||||
if err := s.Save(ProviderKey("demo"), Token{AccessToken: "after-crash"}); err != nil {
|
||||
t.Fatalf("Save must reclaim a stale secret lock, got: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(lockPath); err == nil {
|
||||
t.Fatal("stale secret lock was not cleaned up")
|
||||
}
|
||||
|
||||
s2, err := NewStore(StoreOptions{FilePath: path, Encrypted: true})
|
||||
if err != nil {
|
||||
t.Fatalf("NewStore: %v", err)
|
||||
}
|
||||
token, ok, err := s2.Load(ProviderKey("demo"))
|
||||
if err != nil || !ok || token.AccessToken != "after-crash" {
|
||||
t.Fatalf("Load after stale-lock recovery = %+v ok=%v err=%v", token, ok, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package oauth
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestFileBlobWriteSyncsFileAndDirectory(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "oauth-tokens.json")
|
||||
blob := fileBlob{path: path}
|
||||
origFile, origDir := syncOAuthFileFn, syncOAuthDirFn
|
||||
t.Cleanup(func() {
|
||||
syncOAuthFileFn, syncOAuthDirFn = origFile, origDir
|
||||
})
|
||||
fileSynced, dirSynced := false, false
|
||||
syncOAuthFileFn = func(file *os.File) error {
|
||||
fileSynced = true
|
||||
return origFile(file)
|
||||
}
|
||||
syncOAuthDirFn = func(dir string) error {
|
||||
dirSynced = true
|
||||
return origDir(dir)
|
||||
}
|
||||
|
||||
if err := blob.write([]byte("payload")); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
if !fileSynced {
|
||||
t.Fatal("token blob was not synced before rename")
|
||||
}
|
||||
if !dirSynced {
|
||||
t.Fatal("token store directory was not synced after rename")
|
||||
}
|
||||
data, ok, err := blob.read()
|
||||
if err != nil || !ok || !bytes.Equal(data, []byte("payload")) {
|
||||
t.Fatalf("read = %q ok=%v err=%v", data, ok, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFileBlobWriteDirectorySyncFailureLeavesPublishedBlob pins the post-rename
|
||||
// path: the directory barrier runs after the atomic replace, so a barrier
|
||||
// failure reports an error but must not hide the already-published token.
|
||||
func TestFileBlobWriteDirectorySyncFailureLeavesPublishedBlob(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "oauth-tokens.json")
|
||||
blob := fileBlob{path: path}
|
||||
if err := blob.write([]byte("old")); err != nil {
|
||||
t.Fatalf("seed write: %v", err)
|
||||
}
|
||||
|
||||
origDir := syncOAuthDirFn
|
||||
t.Cleanup(func() { syncOAuthDirFn = origDir })
|
||||
syncErr := errors.New("directory sync failed")
|
||||
syncOAuthDirFn = func(string) error { return syncErr }
|
||||
|
||||
err := blob.write([]byte("new"))
|
||||
if !errors.Is(err, syncErr) {
|
||||
t.Fatalf("write error = %v, want it to wrap %v", err, syncErr)
|
||||
}
|
||||
data, ok, err := blob.read()
|
||||
if err != nil || !ok || !bytes.Equal(data, []byte("new")) {
|
||||
t.Fatalf("published blob was not retained: data=%q ok=%v err=%v", data, ok, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileBlobWriteSyncFailurePreservesPrevious(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "oauth-tokens.json")
|
||||
blob := fileBlob{path: path}
|
||||
if err := blob.write([]byte("old")); err != nil {
|
||||
t.Fatalf("seed write: %v", err)
|
||||
}
|
||||
|
||||
origFile := syncOAuthFileFn
|
||||
t.Cleanup(func() { syncOAuthFileFn = origFile })
|
||||
syncErr := errors.New("sync failed")
|
||||
syncOAuthFileFn = func(*os.File) error { return syncErr }
|
||||
|
||||
err := blob.write([]byte("new"))
|
||||
if !errors.Is(err, syncErr) {
|
||||
t.Fatalf("write error = %v, want it to wrap %v", err, syncErr)
|
||||
}
|
||||
data, ok, err := blob.read()
|
||||
if err != nil || !ok || !bytes.Equal(data, []byte("old")) {
|
||||
t.Fatalf("previous blob was not preserved: data=%q ok=%v err=%v", data, ok, err)
|
||||
}
|
||||
entries, err := os.ReadDir(PublicationDir(path))
|
||||
if err != nil {
|
||||
t.Fatalf("ReadDir publication dir: %v", err)
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if strings.HasPrefix(entry.Name(), "publish-") {
|
||||
t.Fatalf("failed write left publication temp %s", entry.Name())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
//go:build !windows
|
||||
|
||||
package oauth
|
||||
|
||||
func isTransientSecretAccessError(error) bool {
|
||||
return false
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
//go:build windows
|
||||
|
||||
package oauth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
func isTransientSecretAccessError(err error) bool {
|
||||
return errors.Is(err, windows.ERROR_SHARING_VIOLATION) ||
|
||||
errors.Is(err, windows.ERROR_LOCK_VIOLATION)
|
||||
}
|
||||
+53
-9
@@ -18,7 +18,9 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/Twigpine/zero/internal/fsutil"
|
||||
"github.com/Twigpine/zero/internal/keyring"
|
||||
"github.com/Twigpine/zero/internal/securefile"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -155,7 +157,7 @@ const (
|
||||
// is AES-256-GCM ciphertext at rest.
|
||||
type Store struct {
|
||||
blob blobStore
|
||||
crypter *aesGCMCrypter // nil => plaintext blob
|
||||
crypter *securefile.Crypter // nil => plaintext blob
|
||||
now func() time.Time
|
||||
mu sync.Mutex
|
||||
}
|
||||
@@ -244,8 +246,15 @@ func NewStore(options StoreOptions) (*Store, error) {
|
||||
return nil, err
|
||||
}
|
||||
// The file blob holds AES-256-GCM ciphertext; the per-user secret lives in
|
||||
// a sibling ".secret" file (see encrypt.go).
|
||||
return &Store{blob: fileBlob{path: path}, crypter: newAESGCMCrypter(path + ".secret"), now: now}, nil
|
||||
// a sibling ".secret" file. The secret publishes through the sandbox-denied
|
||||
// ".secret.publish" directory so a same-user sandboxed process cannot race
|
||||
// the plaintext key name.
|
||||
secretPath := path + ".secret"
|
||||
return &Store{
|
||||
blob: fileBlob{path: path},
|
||||
crypter: securefile.NewCrypterWithPublicationDir(secretPath, PublicationDir(secretPath)),
|
||||
now: now,
|
||||
}, nil
|
||||
case "keyring":
|
||||
kr := options.Keyring
|
||||
if kr == nil {
|
||||
@@ -405,7 +414,7 @@ func (s *Store) readState() (storeFile, error) {
|
||||
}
|
||||
if s.crypter != nil {
|
||||
// Encrypted backend: the blob is AES-256-GCM ciphertext, not JSON.
|
||||
data, err = s.crypter.open(data)
|
||||
data, err = s.crypter.Open(data)
|
||||
if err != nil {
|
||||
return storeFile{}, fmt.Errorf("oauth: decrypt token store at %s: %w", s.blob.location(), err)
|
||||
}
|
||||
@@ -437,9 +446,9 @@ func (s *Store) writeState(state storeFile) error {
|
||||
// writes opaque ciphertext instead.
|
||||
payload := append(data, '\n')
|
||||
if s.crypter != nil {
|
||||
payload, err = s.crypter.seal(data)
|
||||
payload, err = s.crypter.Seal(data)
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("oauth: encrypt token store: %w", err)
|
||||
}
|
||||
}
|
||||
return s.blob.write(payload)
|
||||
@@ -511,6 +520,31 @@ func (b fileBlob) read() ([]byte, bool, error) {
|
||||
return data, true, nil
|
||||
}
|
||||
|
||||
// Sync seams allow tests to inject persistence failures at each barrier.
|
||||
var (
|
||||
syncOAuthFileFn = (*os.File).Sync
|
||||
syncOAuthDirFn = syncOAuthDir
|
||||
)
|
||||
|
||||
// syncOAuthDir fsyncs the directory that now holds the published token store.
|
||||
// Windows cannot sync directory handles, so the rename is best-effort durable
|
||||
// there. A directory the user can write but not read cannot be opened for
|
||||
// syncing either; that is also best effort, because the write it follows
|
||||
// already succeeded. Every other failure is reported.
|
||||
func syncOAuthDir(dir string) error {
|
||||
if runtime.GOOS == "windows" {
|
||||
return nil
|
||||
}
|
||||
d, err := os.Open(dir)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrPermission) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
return errors.Join(d.Sync(), d.Close())
|
||||
}
|
||||
|
||||
func (b fileBlob) write(data []byte) error {
|
||||
if err := os.MkdirAll(filepath.Dir(b.path), 0o700); err != nil {
|
||||
return err
|
||||
@@ -524,10 +558,20 @@ func (b fileBlob) write(data []byte) error {
|
||||
_ = temp.Close()
|
||||
return err
|
||||
}
|
||||
if err := temp.Close(); err != nil {
|
||||
return err
|
||||
if err := syncOAuthFileFn(temp); err != nil {
|
||||
_ = temp.Close()
|
||||
return fmt.Errorf("oauth: sync token store: %w", err)
|
||||
}
|
||||
return os.Rename(tempPath, b.path)
|
||||
if err := temp.Close(); err != nil {
|
||||
return fmt.Errorf("oauth: write token store: %w", err)
|
||||
}
|
||||
if err := fsutil.RenameWithRetry(tempPath, b.path, nil); err != nil {
|
||||
return fmt.Errorf("oauth: publish token store: %w", err)
|
||||
}
|
||||
if err := syncOAuthDirFn(filepath.Dir(b.path)); err != nil {
|
||||
return fmt.Errorf("oauth: sync token store directory: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// PublicationDirSuffix names the per-store directory a token store publishes new
|
||||
|
||||
@@ -56,6 +56,15 @@ type commandOutput struct {
|
||||
Stderr string
|
||||
ExitCode int
|
||||
Err error
|
||||
// Notices carries the enforcement disclosures the execution runner attached
|
||||
// to this command.
|
||||
//
|
||||
// The generic contract is not transport-only. Enforcement.Notices says what
|
||||
// the sandbox actually did, and on Windows that includes trading the write
|
||||
// jail away for a deny-read profile. A projection that copies stdout, stderr
|
||||
// and an exit code drops it, so a plugin tool ran under the weakened token
|
||||
// and said nothing about it.
|
||||
Notices []string
|
||||
}
|
||||
|
||||
// toolRunner executes a resolved plugin tool command. It is injectable so
|
||||
@@ -549,26 +558,37 @@ func (tool pluginTool) invoke(ctx context.Context, args map[string]any, cwd stri
|
||||
meta["exit_code"] = strconv.Itoa(output.ExitCode)
|
||||
|
||||
if output.Err != nil {
|
||||
// EVERY POST-LAUNCH TERMINAL OUTCOME CARRIES THE DISCLOSURE. This branch
|
||||
// used to rebuild a result from status, output and metadata alone, so a
|
||||
// plugin that timed out or was cancelled reported only that and said
|
||||
// nothing about having run without write confinement. Whether the notice
|
||||
// survives must not depend on how the process ended. The launched-or-not
|
||||
// question is answered in execPluginCommandWithExecution; by here
|
||||
// output.Notices is empty for anything that never started.
|
||||
return tools.Result{
|
||||
Status: tools.StatusError,
|
||||
Output: "Error executing plugin tool " + tool.name + ": " + output.Err.Error(),
|
||||
Meta: meta,
|
||||
Status: tools.StatusError,
|
||||
Output: "Error executing plugin tool " + tool.name + ": " + output.Err.Error(),
|
||||
Meta: meta,
|
||||
EnforcementNotices: output.Notices,
|
||||
Display: tools.Display{Summary: tool.name + " failed", Kind: "plugin"},
|
||||
}
|
||||
}
|
||||
formatted := formatPluginToolOutput(output)
|
||||
if output.ExitCode != 0 {
|
||||
return tools.Result{
|
||||
Status: tools.StatusError,
|
||||
Output: formatted,
|
||||
Meta: meta,
|
||||
Display: tools.Display{Summary: tool.name + " failed", Kind: "plugin"},
|
||||
Status: tools.StatusError,
|
||||
Output: formatted,
|
||||
Meta: meta,
|
||||
EnforcementNotices: output.Notices,
|
||||
Display: tools.Display{Summary: tool.name + " failed", Kind: "plugin"},
|
||||
}
|
||||
}
|
||||
return tools.Result{
|
||||
Status: tools.StatusOK,
|
||||
Output: formatted,
|
||||
Meta: meta,
|
||||
Display: tools.Display{Summary: tool.name, Kind: "plugin"},
|
||||
Status: tools.StatusOK,
|
||||
Output: formatted,
|
||||
Meta: meta,
|
||||
EnforcementNotices: output.Notices,
|
||||
Display: tools.Display{Summary: tool.name, Kind: "plugin"},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -719,7 +739,18 @@ func execPluginCommandWithExecution(ctx context.Context, runner *execution.Runne
|
||||
if result.Outcome.Exit != nil {
|
||||
exitCode = result.Outcome.Exit.Code
|
||||
}
|
||||
output := commandOutput{Stdout: result.Stdout, Stderr: result.Stderr, ExitCode: exitCode}
|
||||
output := commandOutput{
|
||||
Stdout: result.Stdout,
|
||||
Stderr: result.Stderr,
|
||||
ExitCode: exitCode,
|
||||
}
|
||||
// THE NOTICE DESCRIBES A CHILD THAT RAN. Deciding that here, where the outcome
|
||||
// kind is known, rather than at each result constructor: a timeout or a
|
||||
// cancellation happened to a process that had already launched under the
|
||||
// weakened token, so the disclosure is still true of it. A setup failure or a
|
||||
// missing executable launched nothing, and claiming the write jail was traded
|
||||
// away there would describe a trade nobody made.
|
||||
output.Notices = result.Outcome.AppliedEnforcementNotices()
|
||||
switch result.Outcome.Kind {
|
||||
case execution.OutcomeSandboxSetupFailure, execution.OutcomeExecutableNotFound, execution.OutcomeTimedOut, execution.OutcomeCancelled:
|
||||
output.Err = result.Err
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Twigpine/zero/internal/execution"
|
||||
"github.com/Twigpine/zero/internal/tools"
|
||||
)
|
||||
|
||||
// THE DISCLOSURE HAS TO SURVIVE THE PROJECTION.
|
||||
//
|
||||
// The execution runner puts enforcement notices on the structured outcome, and
|
||||
// this path used to copy only stdout, stderr and an exit code out of it. A
|
||||
// plugin tool therefore ran under the non-WRITE_RESTRICTED token and returned a
|
||||
// result that said nothing about the write jail it had just traded away.
|
||||
//
|
||||
// Asserted through pluginTool.invoke, which is what the registry calls, and
|
||||
// through Result.ModelOutput, which is what the model actually reads.
|
||||
func TestAPluginToolCarriesTheEnforcementNotice(t *testing.T) {
|
||||
const notice = "denyRead is configured, so the write jail is not confining writes"
|
||||
|
||||
for _, testCase := range []struct {
|
||||
name string
|
||||
exitCode int
|
||||
}{
|
||||
{"successful command", 0},
|
||||
{"failed command", 3},
|
||||
} {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
tool := pluginTool{
|
||||
name: "demo",
|
||||
run: func(context.Context, pluginCommand) commandOutput {
|
||||
return commandOutput{Stdout: "hello", ExitCode: testCase.exitCode, Notices: []string{notice}}
|
||||
},
|
||||
}
|
||||
|
||||
result := tool.invoke(context.Background(), map[string]any{}, t.TempDir())
|
||||
if len(result.EnforcementNotices) == 0 {
|
||||
t.Fatal("the plugin result carried no enforcement notice; the command ran under the weakened token and said nothing")
|
||||
}
|
||||
model := result.ModelOutput()
|
||||
if !strings.Contains(model, notice) {
|
||||
t.Errorf("the model-facing output does not contain the notice:\n%s", model)
|
||||
}
|
||||
if strings.Count(model, notice) != 1 {
|
||||
t.Errorf("the notice appears %d times, want exactly once:\n%s", strings.Count(model, notice), model)
|
||||
}
|
||||
if summary := result.HumanDisplay().Summary; !strings.Contains(summary, notice) {
|
||||
t.Errorf("the human summary does not contain the notice: %q", summary)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// And a command with no notice is unchanged, or the assertion above would be
|
||||
// satisfied by text pasted onto everything.
|
||||
func TestAPluginToolWithoutANoticeIsUnchanged(t *testing.T) {
|
||||
tool := pluginTool{
|
||||
name: "demo",
|
||||
run: func(context.Context, pluginCommand) commandOutput {
|
||||
return commandOutput{Stdout: "hello", ExitCode: 0}
|
||||
},
|
||||
}
|
||||
result := tool.invoke(context.Background(), map[string]any{}, t.TempDir())
|
||||
if len(result.EnforcementNotices) != 0 {
|
||||
t.Errorf("a command with no enforcement notice grew one: %v", result.EnforcementNotices)
|
||||
}
|
||||
if result.Status != tools.StatusOK {
|
||||
t.Errorf("status = %v, want ok", result.Status)
|
||||
}
|
||||
}
|
||||
|
||||
// A TIMEOUT OR CANCELLATION STILL RAN THE CHILD.
|
||||
//
|
||||
// invoke's error branch rebuilt a result from status, output and metadata alone,
|
||||
// so a plugin that timed out under the non-WRITE_RESTRICTED token reported only
|
||||
// the timeout. The process had already launched without write confinement;
|
||||
// whether the disclosure survives must not depend on how it ended.
|
||||
func TestAPluginToolCarriesTheNoticeWhenItTimesOutOrIsCancelled(t *testing.T) {
|
||||
const notice = "denyRead is configured, so the write jail is not confining writes"
|
||||
|
||||
for _, testCase := range []struct {
|
||||
name string
|
||||
err error
|
||||
}{
|
||||
{"timed out", context.DeadlineExceeded},
|
||||
{"cancelled", context.Canceled},
|
||||
} {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
tool := pluginTool{
|
||||
name: "demo",
|
||||
run: func(context.Context, pluginCommand) commandOutput {
|
||||
return commandOutput{ExitCode: -1, Err: testCase.err, Notices: []string{notice}}
|
||||
},
|
||||
}
|
||||
result := tool.invoke(context.Background(), map[string]any{}, t.TempDir())
|
||||
|
||||
model := result.ModelOutput()
|
||||
if !strings.Contains(model, notice) {
|
||||
t.Errorf("the model-facing output lost the notice:\n%s", model)
|
||||
}
|
||||
if strings.Count(model, notice) != 1 {
|
||||
t.Errorf("the notice appears %d times, want once:\n%s", strings.Count(model, notice), model)
|
||||
}
|
||||
if summary := result.HumanDisplay().Summary; !strings.Contains(summary, notice) {
|
||||
t.Errorf("the human summary lost the notice: %q", summary)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// But a child that never launched must stay silent, or the notice describes a
|
||||
// trade nobody made.
|
||||
//
|
||||
// KEYED ON THE RECORDED FACT, NOT ON THE OUTCOME KIND. Reading the kind as a
|
||||
// launch-state field is wrong in both directions: a child that ran and then
|
||||
// produced an unreadable adapter report is rewritten to a setup failure, so the
|
||||
// disclosure is dropped although it applied, and a context cancelled before
|
||||
// os.StartProcess yields a cancellation, so the disclosure is claimed for a
|
||||
// process that never existed. This is why the earlier version of this test,
|
||||
// which asserted that the kind decides, was encoding the defect.
|
||||
func TestAPluginNoticeFollowsRecordedLaunchState(t *testing.T) {
|
||||
const notice = "denyRead is configured, so the write jail is not confining writes"
|
||||
enforcement := execution.Enforcement{Notices: []string{notice}}
|
||||
|
||||
// The two directions the kind gets wrong, spelled out.
|
||||
ranThenReportFailed := execution.Outcome{
|
||||
Kind: execution.OutcomeSandboxSetupFailure,
|
||||
Launched: true,
|
||||
Enforcement: enforcement,
|
||||
}
|
||||
if got := ranThenReportFailed.AppliedEnforcementNotices(); len(got) != 1 {
|
||||
t.Errorf("a child that ran lost its disclosure because the report failed afterwards: %#v", got)
|
||||
}
|
||||
|
||||
cancelledBeforeStart := execution.Outcome{
|
||||
Kind: execution.OutcomeCancelled,
|
||||
Launched: false,
|
||||
Enforcement: enforcement,
|
||||
}
|
||||
if got := cancelledBeforeStart.AppliedEnforcementNotices(); len(got) != 0 {
|
||||
t.Errorf("a process that never started claimed an enforcement trade: %#v", got)
|
||||
}
|
||||
|
||||
// And the ordinary pairs still behave.
|
||||
for _, testCase := range []struct {
|
||||
name string
|
||||
outcome execution.Outcome
|
||||
discloses bool
|
||||
}{
|
||||
{"launched and succeeded", execution.Outcome{Kind: execution.OutcomeSuccess, Launched: true, Enforcement: enforcement}, true},
|
||||
{"launched then timed out", execution.Outcome{Kind: execution.OutcomeTimedOut, Launched: true, Enforcement: enforcement}, true},
|
||||
{"never launched, missing executable", execution.Outcome{Kind: execution.OutcomeExecutableNotFound, Launched: false, Enforcement: enforcement}, false},
|
||||
{"never launched, setup failed", execution.Outcome{Kind: execution.OutcomeSandboxSetupFailure, Launched: false, Enforcement: enforcement}, false},
|
||||
} {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
if got := len(testCase.outcome.AppliedEnforcementNotices()) > 0; got != testCase.discloses {
|
||||
t.Errorf("discloses = %v, want %v", got, testCase.discloses)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+241
-31
@@ -112,6 +112,42 @@ var textSecretPrefixes = [][]string{
|
||||
{"eyJ"},
|
||||
}
|
||||
|
||||
type secretTrigger struct {
|
||||
literal string
|
||||
// shape indexes the anchoredSecretPatterns entry the trigger belongs to,
|
||||
// so a cut can chain the bytes after it against the shape the trigger
|
||||
// actually starts.
|
||||
shape int
|
||||
}
|
||||
|
||||
// secretTriggers flattens textSecretPrefixes into one sorted list for the
|
||||
// swallowed-trigger search below. Sorted so cuts are discovered earliest-first
|
||||
// regardless of which pattern list they came from.
|
||||
var secretTriggers = func() []secretTrigger {
|
||||
pairs := []secretTrigger{}
|
||||
for shape, prefixes := range textSecretPrefixes {
|
||||
for _, prefix := range prefixes {
|
||||
pairs = append(pairs, secretTrigger{literal: prefix, shape: shape})
|
||||
}
|
||||
}
|
||||
sort.Slice(pairs, func(i, j int) bool { return pairs[i].literal < pairs[j].literal })
|
||||
return pairs
|
||||
}()
|
||||
|
||||
// longestTrigger bounds how far past a swallowed-scored span the trigger
|
||||
// inspection may need to look: an occurrence whose last bytes run past the
|
||||
// span end (a greedy body stops at a byte outside its class, and several
|
||||
// triggers end in one).
|
||||
var longestTrigger = func() int {
|
||||
longest := 0
|
||||
for _, trigger := range secretTriggers {
|
||||
if len(trigger.literal) > longest {
|
||||
longest = len(trigger.literal)
|
||||
}
|
||||
}
|
||||
return longest
|
||||
}()
|
||||
|
||||
// anchoredSecretPatterns mirrors textSecretPatterns with the leading \b
|
||||
// replaced by \A, so each shape can be tested at an exact byte offset. A
|
||||
// credential that starts exactly where a redacted span ends (two AWS keys
|
||||
@@ -129,52 +165,226 @@ var anchoredSecretPatterns = func() []*regexp.Regexp {
|
||||
return out
|
||||
}()
|
||||
|
||||
// redactAdjacent redacts every match of pattern, plus any match of anchored
|
||||
// that begins exactly where a redacted span ends. This catches credentials
|
||||
// glued end to end (AKIA...AKIA...) without weakening the leading-boundary
|
||||
// rule elsewhere: a mid-word occurrence that does not abut a redacted span
|
||||
// still does not match. Chained runs (three or more glued credentials) are
|
||||
// followed to the end.
|
||||
// A credential's body can run into the trigger prefix of the NEXT one when
|
||||
// both are glued end to end. For fixed-length bodies this cannot happen — the
|
||||
// `{16}` stops the body exactly where the next AKIA...AKIA... key begins,
|
||||
// which is the case the anchored end-chaining always covered. An unbounded
|
||||
// body ({36,} on a github key, {20,} on an API key) swallows whichever trigger
|
||||
// bytes sit inside its class — "ghp" after a github key is all lowercase
|
||||
// letters — so the first match ends PAST the next credential's trigger, and a
|
||||
// scan that re-anchors at the span end finds nothing: the second credential
|
||||
// is never redacted.
|
||||
//
|
||||
// redactedSpan is one region of a redaction: the slice s[start:end] and the
|
||||
// bytes to write in its place. Chained runs can link shapes together, so a
|
||||
// span says which shape produced it rather than assuming the caller's pattern
|
||||
// covered it.
|
||||
type redactedSpan struct {
|
||||
start int
|
||||
end int
|
||||
// text replaces s[start:end]. Every entry in textSecretPatterns captures
|
||||
// nothing, so this is the replacement itself unless a custom replacement
|
||||
// template references a group; anchoring the expansion to the shape that
|
||||
// matched keeps that working for cross-shape chains.
|
||||
text string
|
||||
}
|
||||
|
||||
// redactAdjacent redacts every match of pattern, plus every credential glued
|
||||
// into the runs those matches start. Runs follow three loss-of-boundary
|
||||
// mechanisms: end-chaining (a credential glued exactly at the previous span's
|
||||
// end, the AKIA...AKIA... case), all-shape chaining (a glued credential of a
|
||||
// different shape), and the swallowed-trigger cut (one credential's greedy
|
||||
// body runs into the next one's trigger prefix, hiding it from any scan that
|
||||
// re-anchors at a span end). A run ends at the first byte boundary where no
|
||||
// shape matches; a mid-word lookalike never matches and never starts one.
|
||||
func redactAdjacent(s string, pattern, anchored *regexp.Regexp, replacement string) string {
|
||||
spans := pattern.FindAllStringIndex(s, -1)
|
||||
// Bound the outer loop to the original match count. The inner loop
|
||||
// appends chained spans to the same slice; re-reading len(spans) would
|
||||
// re-chain every appended span, growing the span count (and the
|
||||
// FindStringIndex calls) exponentially on long glued runs.
|
||||
n := len(spans)
|
||||
for i := 0; i < n; i++ {
|
||||
end := spans[i][1]
|
||||
for end < len(s) {
|
||||
loc := anchored.FindStringIndex(s[end:])
|
||||
if loc == nil || loc[0] != 0 || loc[1] <= 0 {
|
||||
break
|
||||
}
|
||||
newEnd := end + loc[1]
|
||||
spans = append(spans, []int{end, newEnd})
|
||||
end = newEnd
|
||||
}
|
||||
plain := pattern.FindAllStringIndex(s, -1)
|
||||
if len(plain) == 0 {
|
||||
return s
|
||||
}
|
||||
// Bound the outer loop to the original match count. Every span collects
|
||||
// its own run; re-reading len(plain) after appends would re-run every
|
||||
// appended credential and grow the span count (with the anchored scans
|
||||
// each drags) exponentially on long glued runs.
|
||||
n := len(plain)
|
||||
spans := make([]redactedSpan, 0, n+4)
|
||||
for i := 0; i < n; i++ {
|
||||
spans = appendCredentialRun(s, plain[i][0], plain[i][1], replacement, spans)
|
||||
}
|
||||
return writeRedactedSpans(s, spans)
|
||||
}
|
||||
|
||||
func writeRedactedSpans(s string, spans []redactedSpan) string {
|
||||
if len(spans) == 0 {
|
||||
return s
|
||||
}
|
||||
sort.Slice(spans, func(i, j int) bool { return spans[i][0] < spans[j][0] })
|
||||
sort.Slice(spans, func(i, j int) bool { return spans[i].start < spans[j].start })
|
||||
var out strings.Builder
|
||||
pos := 0
|
||||
for _, sp := range spans {
|
||||
if sp[0] < pos {
|
||||
if sp.start < pos {
|
||||
continue // overlapping span, already covered
|
||||
}
|
||||
out.WriteString(s[pos:sp[0]])
|
||||
// s[sp[0]:sp[1]] is a match (anchored spans match at \b when taken
|
||||
// alone), so ReplaceAllString expands $ references exactly as the
|
||||
// plain loop below would.
|
||||
out.WriteString(pattern.ReplaceAllString(s[sp[0]:sp[1]], replacement))
|
||||
pos = sp[1]
|
||||
out.WriteString(s[pos:sp.start])
|
||||
out.WriteString(sp.text)
|
||||
pos = sp.end
|
||||
}
|
||||
out.WriteString(s[pos:])
|
||||
return out.String()
|
||||
}
|
||||
|
||||
// appendCredentialRun follows the path that begins exactly at start, whose
|
||||
// first plain match ended at end. Every step finds the shape that matches at
|
||||
// the current boundary, tries to re-bind that match at a swallowed trigger,
|
||||
// and otherwise consumes the whole match; the run ends where no shape does.
|
||||
func appendCredentialRun(s string, start, end int, replacement string, spans []redactedSpan) []redactedSpan {
|
||||
cur := start
|
||||
for cur < len(s) {
|
||||
shape, matchEnd := firstShapeAt(s, cur)
|
||||
if shape < 0 || matchEnd == cur {
|
||||
break
|
||||
}
|
||||
if cutStart, cut := earliestSwallowedCut(s, cur, matchEnd, shape); cut {
|
||||
// The bytes before the trigger are the credential that matched;
|
||||
// from the trigger on the run continues under the next shape.
|
||||
spans = append(spans, redactedSpan{start: cur, end: cutStart, text: shapeReplacement(shape, s[cur:cutStart], replacement)})
|
||||
cur = cutStart
|
||||
continue
|
||||
}
|
||||
spans = append(spans, redactedSpan{start: cur, end: matchEnd, text: shapeReplacement(shape, s[cur:matchEnd], replacement)})
|
||||
cur = matchEnd
|
||||
}
|
||||
return spans
|
||||
}
|
||||
|
||||
func shapeReplacement(shape int, slice, replacement string) string {
|
||||
return anchoredSecretPatterns[shape].ReplaceAllString(slice, replacement)
|
||||
}
|
||||
|
||||
// firstShapeAt returns the first anchored shape that matches exactly at off,
|
||||
// with the end of its match. Shapes are tried in declaration order, so the
|
||||
// strict JWT form wins over the loose one; both redact the same bytes, so the
|
||||
// tie-break only decides which regex expanded the replacement.
|
||||
func firstShapeAt(s string, off int) (shape int, end int) {
|
||||
for shape, anchored := range anchoredSecretPatterns {
|
||||
loc := anchored.FindStringIndex(s[off:])
|
||||
if loc != nil && loc[0] == 0 && loc[1] > 0 {
|
||||
return shape, off + loc[1]
|
||||
}
|
||||
}
|
||||
return -1, off
|
||||
}
|
||||
|
||||
// earliestSwallowedCut searches the matched span [start, end) of shape for a
|
||||
// trigger literal that begins a valid credential right there: the head (the
|
||||
// bytes before it) still matches the shape as a whole, and the run chained
|
||||
// from the trigger reaches past the span. The earliest such trigger is the
|
||||
// cut a boundary at that byte would have produced.
|
||||
//
|
||||
// The head requirement and the past-the-end requirement keep the cut honest:
|
||||
// a trigger spelled mid-body that does not begin a credential leaves the span
|
||||
// alone, and a cut that stayed inside the original span recovers no bytes —
|
||||
// it would only split one redaction in two.
|
||||
//
|
||||
// The candidate set is the trigger occurrences inside the span, bounded by
|
||||
// the span plus one trigger length; every occurrence of every trigger in that
|
||||
// window is a candidate, earliest-first.
|
||||
func earliestSwallowedCut(s string, start, end, shape int) (int, bool) {
|
||||
if end-start <= 1 {
|
||||
return 0, false
|
||||
}
|
||||
lookahead := end + longestTrigger
|
||||
if lookahead > len(s) {
|
||||
lookahead = len(s)
|
||||
}
|
||||
window := s[start+1 : lookahead]
|
||||
|
||||
// A trigger occurrence whose bytes begin inside the span may extend past
|
||||
// its end: the greedy body stops at one byte outside its class, and
|
||||
// several triggers end in exactly such a byte ("ghp_"'s underscore,
|
||||
// "sk-ant-" plus a hyphen).
|
||||
anchored := anchoredSecretPatterns[shape]
|
||||
examined := 0
|
||||
for _, q := range triggerOccurrences(window, start+1, end) {
|
||||
if examined >= maxTriggerOccurrences {
|
||||
// Occurrences are earliest-first, so the ones past the cap are the
|
||||
// farthest into the span; a cut among them is missed and the
|
||||
// greedy span stands, as it did before the cut existed.
|
||||
break
|
||||
}
|
||||
examined++
|
||||
// The bytes before the trigger must still be a complete match of this
|
||||
// shape: a trigger spelling inside a body whose head does not
|
||||
// validate is prose-shaped, not a swallowed credential.
|
||||
loc := anchored.FindStringIndex(s[start:q])
|
||||
if loc == nil || loc[0] != 0 || loc[1] != q-start {
|
||||
continue
|
||||
}
|
||||
if runReach(s, q) > end {
|
||||
return q, true
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// maxTriggerOccurrences bounds how many trigger occurrences one span is
|
||||
// examined for, after sorting. Real glued credentials announce their trigger
|
||||
// within reach of the span's own start, so a distant lookalike run does not
|
||||
// get walked to its end; the split path makes the same trade ("at most two
|
||||
// passes and no more"). A cut found past the cap is missed and the greedy
|
||||
// span stands, as it did before the cut existed.
|
||||
const maxTriggerOccurrences = 64
|
||||
|
||||
// triggerOccurrences returns every occurrence of every trigger literal that
|
||||
// begins inside [windowStart, windowEnd), where window is the slice rooted at
|
||||
// windowStart that the caller bounded so occurrences could reach one trigger
|
||||
// length past its end. Earliest first, duplicates collapsed.
|
||||
func triggerOccurrences(window string, windowStart, windowEnd int) []int {
|
||||
occurrences := make([]int, 0, 4)
|
||||
for _, trigger := range secretTriggers {
|
||||
at := 0
|
||||
for {
|
||||
found := strings.Index(window[at:], trigger.literal)
|
||||
if found < 0 {
|
||||
break
|
||||
}
|
||||
q := windowStart + at + found
|
||||
if q >= windowEnd {
|
||||
break // later occurrences for this trigger start even later
|
||||
}
|
||||
occurrences = append(occurrences, q)
|
||||
at += found + 1
|
||||
}
|
||||
}
|
||||
sort.Ints(occurrences)
|
||||
// In-place deduplicate: duplicates come only from triggers shared between
|
||||
// shapes ("eyJ"), and zero is never an occurrence — the scan starts past it.
|
||||
unique := occurrences[:0]
|
||||
for i, q := range occurrences {
|
||||
if i > 0 && q == occurrences[i-1] {
|
||||
continue
|
||||
}
|
||||
unique = append(unique, q)
|
||||
}
|
||||
return unique
|
||||
}
|
||||
|
||||
// runReach reports how far the credential run beginning exactly at off
|
||||
// extends: each boundary picks the first shape that matches there, until one
|
||||
// does not. The run is followed, not redacted; this is the shared-size check
|
||||
// that keeps a cut honest.
|
||||
func runReach(s string, off int) int {
|
||||
cur := off
|
||||
for cur < len(s) {
|
||||
_, matchEnd := firstShapeAt(s, cur)
|
||||
if matchEnd == cur {
|
||||
break
|
||||
}
|
||||
cur = matchEnd
|
||||
}
|
||||
return cur
|
||||
}
|
||||
|
||||
var (
|
||||
privateKeyPattern = regexp.MustCompile(`(?s)-----BEGIN [A-Z ]*PRIVATE KEY-----.*?-----END [A-Z ]*PRIVATE KEY-----`)
|
||||
jsonStringPattern = regexp.MustCompile(`("([^"\\]*(?:\\.[^"\\]*)*)"\s*:\s*)"([^"\\]*(?:\\.[^"\\]*)*)"`)
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
package redaction
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A GREEDY BODY SWALLOWS THE NEXT CREDENTIAL'S TRIGGER, SO THE END-CHAIN
|
||||
// NEVER SEES IT.
|
||||
//
|
||||
// The anchored chain re-binds a redacted span at its END; it can only catch a
|
||||
// credential that begins exactly where the previous one stops. That is true
|
||||
// for fixed-length bodies (AKIA{16} stops at the next key) and false for
|
||||
// unbounded ones: github's body class [A-Za-z0-9] happily eats the literal
|
||||
// "ghp" of the token glued behind it, so the first match ends past the second
|
||||
// trigger and the plain chain has nothing left to match. Every unbounded shape
|
||||
// below leaked its second credential before the swallowed-trigger cut.
|
||||
func TestRedactStringRedactsCredentialsSwallowedByAGluedRun(t *testing.T) {
|
||||
// Assembled so no secret-shaped literal appears in source.
|
||||
// gitleaks:allow -- synthetic redaction fixtures below
|
||||
firstGitHub := "ghp_" + strings.Repeat("a", 36)
|
||||
secondGitHub := "ghp_" + strings.Repeat("b", 36)
|
||||
|
||||
t.Run("two github tokens glued end to end", func(t *testing.T) {
|
||||
got := RedactString(firstGitHub+secondGitHub, Options{})
|
||||
if got != RedactedSecret+RedactedSecret {
|
||||
t.Fatalf("second token survived a glued github pair: got %q", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("two JWTs glued end to end", func(t *testing.T) {
|
||||
got := RedactString(jwtToken+otherJWT, Options{})
|
||||
if got != RedactedSecret+RedactedSecret {
|
||||
t.Fatalf("second token survived a glued JWT pair: got %q", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("third github token in the same run", func(t *testing.T) {
|
||||
thirdGitHub := "ghp_" + strings.Repeat("c", 36)
|
||||
got := RedactString(firstGitHub+secondGitHub+thirdGitHub, Options{})
|
||||
if got != strings.Repeat(RedactedSecret, 3) {
|
||||
t.Fatalf("glued github run left residue between markers: got %q", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// THE SWALLOWED TRIGGER CAN BELONG TO A DIFFERENT SHAPE. The chained run from
|
||||
// a cut must try every shape at each boundary, not only the one that matched
|
||||
// the swallowed span.
|
||||
//
|
||||
// Two outcomes are both fully redacted and the tests accept either. A github
|
||||
// body stops at the hyphen an anthropic trigger carries, so the cut recovers
|
||||
// the boundary and each key becomes its own marker. An anthropic body class
|
||||
// spans [A-Za-z0-9_-], wide enough to cover the next trigger and the
|
||||
// credential behind it whole, so the single match already consumed them and
|
||||
// the requirement is only that nothing of either key survives.
|
||||
func TestRedactStringRedactsACrossShapedCredentialGluedOntoARun(t *testing.T) {
|
||||
// gitleaks:allow -- synthetic redaction fixtures below
|
||||
github := "ghp_" + strings.Repeat("a", 36)
|
||||
anthropic := "sk-ant-" + strings.Repeat("b", 20)
|
||||
secondAnthropic := "sk-ant-" + strings.Repeat("d", 20)
|
||||
|
||||
// keptOut fails when any trigger or credential-shaped residue survives the
|
||||
// redaction, the one property a glued run can never relax.
|
||||
keptOut := func(t *testing.T, got string, pieces ...string) {
|
||||
t.Helper()
|
||||
for _, mark := range []string{"sk-ant-", "ghp_"} {
|
||||
if strings.Contains(got, mark) {
|
||||
t.Fatalf("a trigger survived a glued run: %q in %q", mark, got)
|
||||
}
|
||||
}
|
||||
for _, piece := range pieces {
|
||||
if strings.Contains(got, piece) {
|
||||
t.Fatalf("a credential survived a glued run: parts of %q… in %q", piece[:8], got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("anthropic glued behind github", func(t *testing.T) {
|
||||
value := github + anthropic
|
||||
got := RedactString(value, Options{})
|
||||
if got != RedactedSecret+RedactedSecret {
|
||||
t.Fatalf("anthropic key survived behind a github key: got %q", got)
|
||||
}
|
||||
keptOut(t, got, github, anthropic)
|
||||
})
|
||||
|
||||
t.Run("anthropic glued behind anthropic, then github", func(t *testing.T) {
|
||||
value := anthropic + secondAnthropic + github
|
||||
got := RedactString(value, Options{})
|
||||
if got == value {
|
||||
t.Fatal("SETUP INVALID: the glued run is not redacted at all")
|
||||
}
|
||||
keptOut(t, got, anthropic, secondAnthropic, github)
|
||||
})
|
||||
|
||||
t.Run("github glued behind anthropic", func(t *testing.T) {
|
||||
value := anthropic + github
|
||||
got := RedactString(value, Options{})
|
||||
if got == value {
|
||||
t.Fatal("SETUP INVALID: the glued run is not redacted at all")
|
||||
}
|
||||
keptOut(t, got, anthropic, github)
|
||||
})
|
||||
}
|
||||
|
||||
// THE CUT MUST NOT WEAKEN WHAT ALREADY WORKED.
|
||||
//
|
||||
// A trigger spelled inside a matched span that starts no credential there
|
||||
// leaves the span whole: the bytes before it do not re-validate as their
|
||||
// shape, and the run chained from it stays inside the span. The end-chaining
|
||||
// for fixed-length bodies and every single-credential behavior the suite
|
||||
// already pins must be untouched.
|
||||
func TestRedactStringSwallowedTriggerCutKeepsExistingBehavior(t *testing.T) {
|
||||
// gitleaks:allow -- synthetic redaction fixtures below
|
||||
github := "ghp_" + strings.Repeat("a", 36)
|
||||
|
||||
t.Run("two fixed-length AWS keys still chain at the end", func(t *testing.T) {
|
||||
got := RedactString("AKIAIOSFODNN7EXAMPLE"+"ASIAIOSFODNN7EXAMPLE", Options{})
|
||||
if got != RedactedSecret+RedactedSecret {
|
||||
t.Fatalf("glued AWS keys regressed: got %q", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("an interior trigger spelling starts no credential of its own", func(t *testing.T) {
|
||||
// A JWT payload can contain "eyJ" away from a segment start. The strict
|
||||
// form needs the segment to open with it and the loose form needs the
|
||||
// three-segment skeleton, so a payload fragment is prose to both and the
|
||||
// token stays one marker.
|
||||
token := "eyJhbGciOiJIUzI1NiJ9." + "aaaaeyJaaaaaa" + ".QWxpY2VCb2JDYXJvbA"
|
||||
if got := RedactString(token, Options{}); got != RedactedSecret {
|
||||
t.Fatalf("a JWT with an interior trigger spelling changed shape: got %q", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a single token is one marker, not two", func(t *testing.T) {
|
||||
got := RedactString(github, Options{})
|
||||
if got != RedactedSecret {
|
||||
t.Fatalf("a lone token changed shape: got %q", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// A SPAM OF TRIGGER SPELLINGS INSIDE ONE BODY MUST COST WHAT THE SPAN COSTS.
|
||||
//
|
||||
// Every trigger occurrence inside a matched span is a cut candidate, but only
|
||||
// the first maxTriggerOccurrences are examined, so the scan stays linear in
|
||||
// the span: a body stuffed with lookalikes must not walk the span once per
|
||||
// candidate. Measured as allocation against the same bar the split path's
|
||||
// linearity test uses, the one an index-per-byte or a walk-per-credential
|
||||
// explosion offends.
|
||||
func TestRedactStringTriggerLookalikeSpamStaysBounded(t *testing.T) {
|
||||
// No digit appears anywhere in the fixture, so the openai shape's filter
|
||||
// rejects it as kebab-case prose and the anthropic shape still matches it
|
||||
// whole: every "sk-ant-" inside the span is a cut candidate, and none
|
||||
// validates because the run chained from any of them reaches only to the
|
||||
// span's own end.
|
||||
// gitleaks:allow -- synthetic redaction fixture below
|
||||
value := "sk-ant-" + strings.Repeat("sk-ant-", (64<<10)/len("sk-ant-"))
|
||||
if got := RedactString(value, Options{}); got != RedactedSecret {
|
||||
t.Fatalf("SETUP INVALID: the lookalike span was not redacted whole: got %q", got)
|
||||
}
|
||||
// Sixty-four candidates, each re-checked against the span, cost a small
|
||||
// constant multiple of the span. A walk over every occurrence quadruples
|
||||
// the cost when the span doubles, and the split path measured that walk
|
||||
// at seventeen gigabytes on a one-megabyte chain.
|
||||
limit := int64(200 * len(value))
|
||||
if used := redactAllocBytes(value); used > limit {
|
||||
t.Fatalf("redacting a %d byte lookalike span allocated %d bytes, over the %d byte limit", len(value), used, limit)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package sandbox
|
||||
|
||||
import "testing"
|
||||
|
||||
// THE CHANNEL IS PINNED WITHOUT A PRODUCER.
|
||||
//
|
||||
// Nothing in the tree fills CommandPlan.Notes today: the Windows denyRead trade
|
||||
// notice was the last producer, and since #1006 refuses denyRead outright there
|
||||
// is no trade to describe. The line in EnforcementFor that carries plan notes
|
||||
// into Enforcement.Notices is still what hooks, plugins and MCP read, and the
|
||||
// end-to-end test that used to reach it through the real producer went with the
|
||||
// producer. So it is driven from a fixture plan instead: deleting that one line
|
||||
// fails here, and nowhere else.
|
||||
func TestEnforcementForCarriesThePlanNoticesToTheGenericContract(t *testing.T) {
|
||||
plan := CommandPlan{Notes: []string{
|
||||
"first fixed sentence from the sandbox",
|
||||
"second fixed sentence from the sandbox",
|
||||
}}
|
||||
notices := EnforcementFor(plan).Notices
|
||||
if len(notices) != len(plan.Notes) {
|
||||
t.Fatalf("EnforcementFor produced %d notices from %d plan notes; hooks, plugins and MCP read this field and would see nothing",
|
||||
len(notices), len(plan.Notes))
|
||||
}
|
||||
for index, note := range plan.Notes {
|
||||
if notices[index] != note {
|
||||
t.Errorf("notice %d = %q, want the plan note %q in the same position", index, notices[index], note)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// And a plan with nothing to say produces no notices, so a consumer cannot be
|
||||
// handed an empty string it would then render as a card.
|
||||
func TestEnforcementForCarriesNoNoticesFromASilentPlan(t *testing.T) {
|
||||
if notices := EnforcementFor(CommandPlan{}).Notices; len(notices) != 0 {
|
||||
t.Fatalf("EnforcementFor produced notices from a plan with none: %q", notices)
|
||||
}
|
||||
}
|
||||
@@ -70,6 +70,20 @@ type CommandPlan struct {
|
||||
// workspace. It carries structured policy facts; command output is never
|
||||
// parsed as the control protocol.
|
||||
executionReportPath string
|
||||
// childLaunchReported marks a plan whose helper publishes the authoritative
|
||||
// child-launch fact through executionReportPath. Set ONLY by adapters that
|
||||
// actually write it: Wrapped alone is not enough, since a bwrap plan is also
|
||||
// wrapped and reports only denials, and treating its silence as "no child"
|
||||
// would deny every successful Linux sandbox run its disclosure.
|
||||
childLaunchReported bool
|
||||
}
|
||||
|
||||
// ChildLaunchOwnedByAdapter reports whether this plan starts a WRAPPER whose
|
||||
// helper creates the requested process itself, so the requested child launched
|
||||
// only if the adapter says so. False for a direct command, where the process the
|
||||
// caller starts IS the requested one.
|
||||
func (plan CommandPlan) ChildLaunchOwnedByAdapter() bool {
|
||||
return plan.childLaunchReported
|
||||
}
|
||||
|
||||
// Cleanup releases any resources the plan holds. It is safe to call on a zero
|
||||
@@ -131,15 +145,12 @@ func (engine *Engine) PrepareExecution(ctx context.Context, request execution.Re
|
||||
return execution.PreparedCommand{}, err
|
||||
}
|
||||
return execution.PreparedCommand{
|
||||
Command: command,
|
||||
Enforcement: execution.Enforcement{
|
||||
Backend: string(plan.TargetBackend),
|
||||
Level: string(plan.EnforcementLevel),
|
||||
Degraded: plan.EnforcementLevel == EnforcementDegraded,
|
||||
DowngradeReason: plan.DowngradeReason,
|
||||
},
|
||||
Report: plan.ExecutionReport,
|
||||
Cleanup: plan.Cleanup,
|
||||
Command: command,
|
||||
Enforcement: EnforcementFor(plan),
|
||||
Report: plan.ExecutionReport,
|
||||
Cleanup: plan.Cleanup,
|
||||
// Only for adapters that publish the fact; see CommandPlan.childLaunchReported.
|
||||
ChildLaunchOwnedByAdapter: plan.childLaunchReported,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -1196,3 +1207,26 @@ func isDynamicSensitiveEnvKey(key string) bool {
|
||||
strings.HasSuffix(key, suffix) &&
|
||||
len(key) > len(prefix)+len(suffix)
|
||||
}
|
||||
|
||||
// EnforcementFor projects a CommandPlan onto the platform-neutral enforcement
|
||||
// contract.
|
||||
//
|
||||
// ONE PROJECTION, because there were two and they drifted. PrepareExecution
|
||||
// built execution.Enforcement by hand for the generic adapter that hooks,
|
||||
// plugins and MCP processes go through, and exec_command built the same struct
|
||||
// by hand for the tool path. When Notices was added it reached only the tool
|
||||
// path, so the contract was true for one wrapper and false for the wrapper other
|
||||
// execution consumers depend on. A hand-maintained projection duplicated across
|
||||
// two adapters cannot be kept honest by review; a shared one cannot be missed.
|
||||
//
|
||||
// The notice slice is copied rather than aliased so a consumer cannot mutate the
|
||||
// plan through it.
|
||||
func EnforcementFor(plan CommandPlan) execution.Enforcement {
|
||||
return execution.Enforcement{
|
||||
Backend: string(plan.TargetBackend),
|
||||
Level: string(plan.EnforcementLevel),
|
||||
Degraded: plan.EnforcementLevel == EnforcementDegraded,
|
||||
DowngradeReason: plan.DowngradeReason,
|
||||
Notices: append([]string(nil), plan.Notes...),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
//go:build windows
|
||||
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"unsafe"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// THE SUSPENDED CHILD NEEDS AN OWNER THAT OUTLIVES A KILLED HELPER.
|
||||
//
|
||||
// The helper publishes the launch fact and only then resumes, which is what
|
||||
// makes "no report" mean "nothing ran" rather than a race (see
|
||||
// publishThenResume). Creating the child suspended is what buys that, and it
|
||||
// also creates a state nothing else in the helper covers: between
|
||||
// CreateProcessAsUser and ResumeThread the child exists, holds the inherited
|
||||
// stdin/stdout/stderr handles, and cannot execute a single instruction.
|
||||
//
|
||||
// Hook and plugin commands reach this through Engine.CommandContext, which uses
|
||||
// the default exec.CommandContext cancellation: on timeout or cancel the helper
|
||||
// is killed outright. It cannot run its deferred cleanup, cannot reach
|
||||
// terminateSuspendedWindowsChild, and cannot resume. Windows does not terminate
|
||||
// a child because its parent died, so the suspended process survives holding the
|
||||
// pipe write ends, and a parent still reading them waits on a process that will
|
||||
// never write and never exit.
|
||||
//
|
||||
// A job object with kill-on-close is the only mechanism that survives the
|
||||
// helper being terminated rather than unwound: the kernel enforces it. The job
|
||||
// is created and the helper joins it BEFORE any child exists, so every child
|
||||
// created afterwards inherits membership at creation and there is no window in
|
||||
// which a child is alive and unowned. TerminateProcess on the helper closes the
|
||||
// last handle, and the kernel kills the job.
|
||||
//
|
||||
// Deliberately not solved with exec.Cmd.WaitDelay in the parent: that releases
|
||||
// the caller while the orphan keeps running and keeps the pipes, which trades a
|
||||
// visible hang for an invisible leak. It is a reasonable backstop, not the fix.
|
||||
//
|
||||
// Reported by @jatmn.
|
||||
// windowsJoinChildKillJob is the seam the command runner calls. Production value
|
||||
// only; a test substitutes it to prove the runner takes ownership on EVERY path,
|
||||
// including the ones that refuse the command a few lines later.
|
||||
var windowsJoinChildKillJob = joinWindowsChildKillJob
|
||||
|
||||
func joinWindowsChildKillJob() (windows.Handle, error) {
|
||||
job, err := windows.CreateJobObject(nil, nil)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
limits := windows.JOBOBJECT_EXTENDED_LIMIT_INFORMATION{
|
||||
BasicLimitInformation: windows.JOBOBJECT_BASIC_LIMIT_INFORMATION{
|
||||
LimitFlags: windows.JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
|
||||
},
|
||||
}
|
||||
if _, err := windows.SetInformationJobObject(
|
||||
job,
|
||||
windows.JobObjectExtendedLimitInformation,
|
||||
uintptr(unsafe.Pointer(&limits)),
|
||||
uint32(unsafe.Sizeof(limits)),
|
||||
); err != nil {
|
||||
_ = windows.CloseHandle(job)
|
||||
return 0, err
|
||||
}
|
||||
// CurrentProcess() is a pseudo-handle, which AssignProcessToJobObject
|
||||
// accepts; the membership it creates is what descendants inherit.
|
||||
if err := windows.AssignProcessToJobObject(job, windows.CurrentProcess()); err != nil {
|
||||
_ = windows.CloseHandle(job)
|
||||
return 0, err
|
||||
}
|
||||
return job, nil
|
||||
}
|
||||
@@ -0,0 +1,427 @@
|
||||
//go:build windows
|
||||
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
|
||||
"github.com/Twigpine/zero/internal/execution"
|
||||
)
|
||||
|
||||
// THE OWNERSHIP HAS TO BE TESTED FROM OUTSIDE THE PROCESS THAT OWNS IT.
|
||||
//
|
||||
// joinWindowsChildKillJob puts the CALLING process into a job with
|
||||
// kill-on-close, which is the whole point: when the caller is terminated its
|
||||
// handle closes and the kernel takes the children down with it. A test that
|
||||
// calls it in-process and then closes the handle therefore terminates the test
|
||||
// binary. That is exactly what the first version of this file did: `go test`
|
||||
// printed "ok", exited 0, and the assertions after the close were never
|
||||
// reached. Worse, the package's remaining tests never ran either, because the
|
||||
// binary was gone, and nothing anywhere said so.
|
||||
//
|
||||
// So everything below drives a SEPARATE process: the test binary re-executed as
|
||||
// the helper below. It calls the production joinWindowsChildKillJob, creates a
|
||||
// suspended child the way the sandbox helper does, and parks. The test is then
|
||||
// free to kill it and ask what happened, which is the question.
|
||||
|
||||
const (
|
||||
suspendedHelperModeEnv = "ZERO_TEST_SUSPENDED_CHILD_MODE"
|
||||
suspendedHelperPIDFileEnv = "ZERO_TEST_SUSPENDED_CHILD_PIDFILE"
|
||||
|
||||
// suspendedHelperModeJob is the production shape: own the children before
|
||||
// creating any. suspendedHelperModeNoJob is the same helper WITHOUT that
|
||||
// ownership, which is what this code did before the fix.
|
||||
suspendedHelperModeJob = "job"
|
||||
suspendedHelperModeNoJob = "nojob"
|
||||
// suspendedHelperModeRunner runs the production command runner itself and
|
||||
// exits with what it returned.
|
||||
suspendedHelperModeRunner = "runner"
|
||||
)
|
||||
|
||||
// TestWindowsSuspendedChildHelperProcess is not a test. It is the stand-in for
|
||||
// the sandbox command helper, re-executed from the test binary, parked in the
|
||||
// one window this contract is about: after CreateProcessAsUser has returned a
|
||||
// suspended child and before ResumeThread.
|
||||
//
|
||||
// It stands in for the helper rather than being it, because the real one mints a
|
||||
// restricted token against an elevated setup marker. What it does NOT stand in
|
||||
// for is the ownership: it calls the production joinWindowsChildKillJob, in the
|
||||
// production order, before any child can exist.
|
||||
//
|
||||
// The child inherits this process's stdout, which is the pipe the captured
|
||||
// runner is reading. That is what makes a surviving child a hang and not just a
|
||||
// leak: the reader waits for an EOF that a process which can never execute will
|
||||
// never produce.
|
||||
func TestWindowsSuspendedChildHelperProcess(t *testing.T) {
|
||||
mode := os.Getenv(suspendedHelperModeEnv)
|
||||
if mode == "" {
|
||||
return
|
||||
}
|
||||
fail := func(code int, format string, args ...any) {
|
||||
fmt.Fprintf(os.Stderr, "suspended-child helper: "+format+"\n", args...)
|
||||
os.Exit(code)
|
||||
}
|
||||
if mode == suspendedHelperModeJob {
|
||||
// BEFORE ANY CHILD EXISTS, exactly where runWindowsSandboxCommand does
|
||||
// it, so there is no interval in which one is alive and unowned. The
|
||||
// handle is deliberately never closed: it is released when this process
|
||||
// is terminated, which is the event the guarantee is about.
|
||||
if _, err := joinWindowsChildKillJob(); err != nil {
|
||||
fail(3, "join the kill job: %v", err)
|
||||
}
|
||||
}
|
||||
if mode == suspendedHelperModeRunner {
|
||||
// The production entry point, refused at its own earliest return so the
|
||||
// exit code below is the one runWindowsSandboxCommand chose and nothing
|
||||
// else has run. An unsupported level is the cheapest such refusal.
|
||||
os.Exit(runWindowsSandboxCommand(WindowsSandboxCommandConfig{SandboxLevel: "not-a-level"}, os.Stderr))
|
||||
}
|
||||
child := exec.Command(comspec(), "/c", "ver")
|
||||
child.Stdout = os.Stdout
|
||||
child.Stderr = os.Stderr
|
||||
child.SysProcAttr = &windows.SysProcAttr{CreationFlags: windows.CREATE_SUSPENDED}
|
||||
if err := child.Start(); err != nil {
|
||||
fail(4, "start the suspended child: %v", err)
|
||||
}
|
||||
if err := publishHelperChildPID(os.Getenv(suspendedHelperPIDFileEnv), child.Process.Pid); err != nil {
|
||||
_ = child.Process.Kill()
|
||||
fail(5, "publish the child pid: %v", err)
|
||||
}
|
||||
// Parked. Production reaches ResumeThread a few instructions later; this
|
||||
// process never does, so it holds the window open for as long as the test
|
||||
// needs. Nothing below ever runs: the test kills this process.
|
||||
time.Sleep(10 * time.Minute)
|
||||
fail(6, "the test never terminated this helper")
|
||||
}
|
||||
|
||||
func comspec() string {
|
||||
if shell := strings.TrimSpace(os.Getenv("COMSPEC")); shell != "" {
|
||||
return shell
|
||||
}
|
||||
return "cmd.exe"
|
||||
}
|
||||
|
||||
// publishHelperChildPID writes the pid through a rename, so the test never
|
||||
// reads a half-written file and never mistakes "not created yet" for "created".
|
||||
func publishHelperChildPID(path string, pid int) error {
|
||||
if strings.TrimSpace(path) == "" {
|
||||
return fmt.Errorf("no pid file was requested")
|
||||
}
|
||||
temp := path + ".tmp"
|
||||
if err := os.WriteFile(temp, []byte(strconv.Itoa(pid)), 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(temp, path)
|
||||
}
|
||||
|
||||
// suspendedChildRun is one captured run of the helper, parked with its child
|
||||
// created.
|
||||
type suspendedChildRun struct {
|
||||
childPID uint32
|
||||
done chan struct{}
|
||||
result execution.CapturedResult
|
||||
cancel context.CancelFunc
|
||||
}
|
||||
|
||||
// startParkedSuspendedChildRun runs the helper through the REAL captured
|
||||
// execution path, the one hook and plugin commands use: execution.Runner over
|
||||
// the sandbox Engine, whose CommandContext builds an exec.CommandContext. It
|
||||
// returns once the helper has created its child, which is the barrier.
|
||||
//
|
||||
// The plan is deliberately a direct command. The wrapper is not what this is
|
||||
// about: the hang lives in the captured runner, which sets Stdout and Stderr to
|
||||
// buffers, so Wait cannot finish until both pipes reach EOF, and sets no
|
||||
// WaitDelay.
|
||||
func startParkedSuspendedChildRun(t *testing.T, ctx context.Context, cancel context.CancelFunc, mode string) *suspendedChildRun {
|
||||
t.Helper()
|
||||
workspace := t.TempDir()
|
||||
pidFile := filepath.Join(t.TempDir(), "child.pid")
|
||||
|
||||
engine := NewEngine(EngineOptions{
|
||||
WorkspaceRoot: workspace,
|
||||
Policy: DefaultPolicy(),
|
||||
Backend: Backend{Name: BackendUnavailable, Message: "test backend unavailable"},
|
||||
})
|
||||
runner := execution.NewRunner(engine)
|
||||
|
||||
run := &suspendedChildRun{done: make(chan struct{}), cancel: cancel}
|
||||
go func() {
|
||||
defer close(run.done)
|
||||
run.result = runner.ExecuteCaptured(ctx, execution.CapturedRequest{Request: execution.Request{
|
||||
Origin: execution.OriginHook,
|
||||
Mode: execution.ModeCaptured,
|
||||
Command: execution.Command{
|
||||
Name: os.Args[0],
|
||||
Args: []string{"-test.run=TestWindowsSuspendedChildHelperProcess"},
|
||||
Env: append(os.Environ(),
|
||||
suspendedHelperModeEnv+"="+mode,
|
||||
suspendedHelperPIDFileEnv+"="+pidFile,
|
||||
),
|
||||
},
|
||||
WorkingDirectory: workspace,
|
||||
WorkspaceRoots: []string{workspace},
|
||||
Approval: execution.ApprovalContext{PolicyVersion: execution.PolicyVersion},
|
||||
}})
|
||||
}()
|
||||
|
||||
run.childPID = awaitHelperChildPID(t, run, pidFile)
|
||||
// INDEPENDENT OF EVERY ASSERTION BELOW. A failure after this point must not
|
||||
// leave a suspended process on the machine, and a suspended process cannot
|
||||
// exit by itself.
|
||||
t.Cleanup(func() {
|
||||
cancel()
|
||||
terminateTestProcess(run.childPID)
|
||||
select {
|
||||
case <-run.done:
|
||||
case <-time.After(30 * time.Second):
|
||||
t.Errorf("the captured call never returned, even after the child was killed")
|
||||
}
|
||||
})
|
||||
if alive, err := windowsProcessAlive(run.childPID); err != nil || !alive {
|
||||
t.Fatalf("SETUP INVALID: the helper's child is not alive at the barrier: alive=%v err=%v", alive, err)
|
||||
}
|
||||
return run
|
||||
}
|
||||
|
||||
// awaitHelperChildPID waits for the helper to say it has created its child. It
|
||||
// fails rather than timing out silently, and it gives up early if the helper
|
||||
// died, so a broken helper reads as a broken helper.
|
||||
func awaitHelperChildPID(t *testing.T, run *suspendedChildRun, pidFile string) uint32 {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(90 * time.Second)
|
||||
for {
|
||||
if data, err := os.ReadFile(pidFile); err == nil {
|
||||
pid, convErr := strconv.Atoi(strings.TrimSpace(string(data)))
|
||||
if convErr == nil && pid > 0 {
|
||||
return uint32(pid)
|
||||
}
|
||||
}
|
||||
select {
|
||||
case <-run.done:
|
||||
t.Fatalf("SETUP INVALID: the helper exited before creating its child: %#v\nstderr: %s", run.result.Outcome, run.result.Stderr)
|
||||
default:
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatal("SETUP INVALID: the helper never reported a child pid")
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
func terminateTestProcess(pid uint32) {
|
||||
handle, err := windows.OpenProcess(windows.PROCESS_TERMINATE, false, pid)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer func() { _ = windows.CloseHandle(handle) }()
|
||||
_ = windows.TerminateProcess(handle, 1)
|
||||
}
|
||||
|
||||
func awaitProcessGone(t *testing.T, pid uint32, within time.Duration) bool {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(within)
|
||||
for {
|
||||
alive, err := windowsProcessAlive(pid)
|
||||
if err != nil {
|
||||
t.Fatalf("query the child: %v", err)
|
||||
}
|
||||
if !alive {
|
||||
return true
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
return false
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
// CANCELLATION IN THE CREATE-TO-RESUME WINDOW MUST NOT ORPHAN THE CHILD, AND
|
||||
// MUST NOT HANG THE CALLER.
|
||||
//
|
||||
// Hook and plugin commands reach the sandbox through Engine.CommandContext,
|
||||
// which uses the default exec.CommandContext cancellation: on a timeout or a
|
||||
// cancel the helper is killed outright, so it never reaches its own cleanup,
|
||||
// never reaches terminateSuspendedWindowsChild, and never resumes. Windows does
|
||||
// not terminate a child because its parent died. The suspended child then
|
||||
// survives holding the inherited pipe write ends, and ExecuteCaptured, which is
|
||||
// copying from those pipes with no WaitDelay, waits for an EOF that a process
|
||||
// which cannot execute will never send.
|
||||
//
|
||||
// Both halves are asserted, because either one alone is the wrong fix: a pipe
|
||||
// deadline would release the caller and keep the orphan, and killing the child
|
||||
// without the caller returning would still hang the hook.
|
||||
func TestCancellingACapturedRunTakesDownTheSuspendedChild(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
deadline bool
|
||||
}{
|
||||
{"parent cancellation", false},
|
||||
{"deadline expiry", true},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
if tc.deadline {
|
||||
ctx, cancel = context.WithTimeout(context.Background(), 3*time.Second)
|
||||
}
|
||||
run := startParkedSuspendedChildRun(t, ctx, cancel, suspendedHelperModeJob)
|
||||
if !tc.deadline {
|
||||
cancel()
|
||||
}
|
||||
|
||||
select {
|
||||
case <-run.done:
|
||||
case <-time.After(60 * time.Second):
|
||||
t.Fatal("the captured call did not return after cancellation; the hook or plugin that made it is blocked on a pipe the orphaned child still holds")
|
||||
}
|
||||
if !awaitProcessGone(t, run.childPID, 30*time.Second) {
|
||||
t.Fatal("the suspended child outlived the cancelled helper, so it is an orphan holding the inherited pipes")
|
||||
}
|
||||
if state := run.result.Outcome.State; state != execution.StateCancelled && state != execution.StateFailed {
|
||||
t.Errorf("outcome state = %q, want the run reported as cancelled or failed", state)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// THE CONTROL, and the reason the job is the fix rather than a nicety.
|
||||
//
|
||||
// The same helper WITHOUT the ownership step is what this code did before: the
|
||||
// child survives its terminated parent, and the captured call stays blocked on
|
||||
// the pipes it holds. Asserting that shape keeps the test above from passing for
|
||||
// some unrelated reason, such as the child exiting on its own.
|
||||
//
|
||||
// It cleans up after itself by killing the orphan, which is also what releases
|
||||
// the blocked call.
|
||||
func TestWithoutTheKillJobACancelledHelperLeavesAnOrphan(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
run := startParkedSuspendedChildRun(t, ctx, cancel, suspendedHelperModeNoJob)
|
||||
cancel()
|
||||
|
||||
if awaitProcessGone(t, run.childPID, 5*time.Second) {
|
||||
t.Fatal("SETUP INVALID: the child died without the kill job, so this control proves nothing about the job")
|
||||
}
|
||||
select {
|
||||
case <-run.done:
|
||||
t.Error("SETUP INVALID: the captured call returned while the orphan still held the pipes, so the hang this pins is not reproduced here")
|
||||
case <-time.After(3 * time.Second):
|
||||
}
|
||||
// The cleanup registered by startParkedSuspendedChildRun kills the orphan,
|
||||
// which closes the pipes and lets the captured call return.
|
||||
}
|
||||
|
||||
// A SUCCESSFUL RUN IS STILL A SUCCESSFUL RUN. The job is joined on every command,
|
||||
// so an ordinary one has to come back with its output and its exit code intact.
|
||||
func TestJoiningTheKillJobLeavesAnOrdinaryRunAlone(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
engine := NewEngine(EngineOptions{
|
||||
WorkspaceRoot: workspace,
|
||||
Policy: DefaultPolicy(),
|
||||
Backend: Backend{Name: BackendUnavailable, Message: "test backend unavailable"},
|
||||
})
|
||||
runner := execution.NewRunner(engine)
|
||||
result := runner.ExecuteCaptured(context.Background(), execution.CapturedRequest{Request: execution.Request{
|
||||
Origin: execution.OriginHook,
|
||||
Mode: execution.ModeCaptured,
|
||||
Command: execution.Command{Name: comspec(), Args: []string{"/c", "echo", "still-here"}},
|
||||
WorkingDirectory: workspace,
|
||||
WorkspaceRoots: []string{workspace},
|
||||
Approval: execution.ApprovalContext{PolicyVersion: execution.PolicyVersion},
|
||||
}})
|
||||
if result.Outcome.Kind != execution.OutcomeSuccess {
|
||||
t.Fatalf("outcome = %#v, stderr=%q", result.Outcome, result.Stderr)
|
||||
}
|
||||
if !strings.Contains(result.Stdout, "still-here") {
|
||||
t.Errorf("stdout = %q, want the command's output", result.Stdout)
|
||||
}
|
||||
}
|
||||
|
||||
// windowsProcessAlive reports whether pid still exists. A suspended process is
|
||||
// alive, so WaitForSingleObject returning WAIT_TIMEOUT is the positive answer.
|
||||
func windowsProcessAlive(pid uint32) (bool, error) {
|
||||
handle, err := windows.OpenProcess(windows.SYNCHRONIZE|windows.PROCESS_QUERY_LIMITED_INFORMATION, false, pid)
|
||||
if err != nil {
|
||||
// The process is gone, which is what the caller is asking about.
|
||||
return false, nil
|
||||
}
|
||||
defer func() { _ = windows.CloseHandle(handle) }()
|
||||
state, err := windows.WaitForSingleObject(handle, 0)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return state == uint32(windows.WAIT_TIMEOUT), nil
|
||||
}
|
||||
|
||||
// TAKING OWNERSHIP MUST NOT COST THE EXIT CODE.
|
||||
//
|
||||
// The helper is a MEMBER of the job it creates, so closing the last handle to
|
||||
// that job terminates the helper itself. The first version of this fix closed
|
||||
// it in a defer, which fired during runWindowsSandboxCommand's own return: the
|
||||
// process was killed before its exit code could be handed back, and every
|
||||
// sandboxed command reported 0 no matter how it really ended, including the
|
||||
// refusals a few lines below the join.
|
||||
//
|
||||
// This runs the production entry point in a separate process and asks for the
|
||||
// code it returned. The refusal it drives returns 1, so a 0 here is the bug
|
||||
// coming back rather than a command that happened to succeed.
|
||||
func TestOwningTheChildrenKeepsTheCommandRunnersExitCode(t *testing.T) {
|
||||
command := exec.Command(os.Args[0], "-test.run=TestWindowsSuspendedChildHelperProcess")
|
||||
command.Env = append(os.Environ(), suspendedHelperModeEnv+"="+suspendedHelperModeRunner)
|
||||
output, err := command.CombinedOutput()
|
||||
|
||||
code := 0
|
||||
if exitErr, ok := err.(*exec.ExitError); ok {
|
||||
code = exitErr.ExitCode()
|
||||
} else if err != nil {
|
||||
t.Fatalf("run the command runner: %v\n%s", err, output)
|
||||
}
|
||||
if code != 1 {
|
||||
t.Fatalf("the command runner exited %d, want the 1 it returned; the job handle is being closed on the way out and killing this process before its exit code survives\n%s", code, output)
|
||||
}
|
||||
if !strings.Contains(string(output), "unsupported Windows sandbox level") {
|
||||
t.Errorf("the runner did not reach its refusal, so the exit code above is not the one under test:\n%s", output)
|
||||
}
|
||||
}
|
||||
|
||||
// AND OWNERSHIP IS TAKEN ON EVERY PATH, including the ones that refuse the
|
||||
// command before any child could exist. The point of joining first is that
|
||||
// there is no interval in which a child is alive and unowned, and a join that
|
||||
// moved below one of these returns would leave exactly that interval on the
|
||||
// paths that do go on to launch.
|
||||
func TestTheCommandRunnerTakesOwnershipBeforeItRefuses(t *testing.T) {
|
||||
joined := 0
|
||||
previous := windowsJoinChildKillJob
|
||||
t.Cleanup(func() { windowsJoinChildKillJob = previous })
|
||||
windowsJoinChildKillJob = func() (windows.Handle, error) {
|
||||
joined++
|
||||
return 0, fmt.Errorf("declined for the test")
|
||||
}
|
||||
|
||||
// The EARLIEST refusal in the function, so a join that slipped below any of
|
||||
// the checks would be measured as not having happened. A DenyRead profile on
|
||||
// a restricted-token tier is refused before the level switch, before setup
|
||||
// validation, and long before a token or a child exists.
|
||||
var stderr strings.Builder
|
||||
config := WindowsSandboxCommandConfig{
|
||||
SandboxLevel: WindowsSandboxLevelRestrictedToken,
|
||||
PermissionProfile: PermissionProfile{FileSystem: FileSystemPolicy{DenyRead: []string{`C:\secrets`}}},
|
||||
}
|
||||
if code := runWindowsSandboxCommand(config, &stderr); code != 1 {
|
||||
t.Fatalf("exit code = %d, want 1 (%s)", code, stderr.String())
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "DenyRead is not supported") {
|
||||
t.Fatalf("SETUP INVALID: the runner did not stop at its earliest refusal, so a later join would still count: %s", stderr.String())
|
||||
}
|
||||
if joined != 1 {
|
||||
t.Fatalf("the runner took ownership %d times before its earliest refusal, want exactly once", joined)
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,20 @@ import (
|
||||
)
|
||||
|
||||
func runWindowsSandboxCommand(config WindowsSandboxCommandConfig, stderr io.Writer) int {
|
||||
// OWN THE CHILDREN BEFORE ANY CHILD CAN EXIST, so one created later inherits
|
||||
// job membership at creation and a forcibly terminated helper cannot leave a
|
||||
// suspended one behind. A failure here is not fatal: it costs the
|
||||
// kill-on-close guarantee, not the sandbox, and the ordinary unwind paths
|
||||
// still run.
|
||||
//
|
||||
// THE HANDLE IS NEVER CLOSED, and that is the contract rather than a leak.
|
||||
// Closing it IS the kill: this process is a member of the job, so releasing
|
||||
// the last handle terminates it where it stands. A deferred close therefore
|
||||
// killed this helper during its own return, before the exit code below could
|
||||
// be handed back, and every sandboxed command reported 0 however it really
|
||||
// ended. Process exit releases the handle, which is exactly the event the
|
||||
// guarantee is built on.
|
||||
_, _ = windowsJoinChildKillJob()
|
||||
// Fully restricted DenyRead tokens cannot load ordinary Users-granted
|
||||
// system binaries without SID broadening; broadening is permanently off
|
||||
// because it admits write grants outside WriteRoots. Reject on both the
|
||||
@@ -98,6 +112,11 @@ func runWindowsSandboxCommand(config WindowsSandboxCommandConfig, stderr io.Writ
|
||||
return exitCode
|
||||
}
|
||||
|
||||
// applyWindowsUnelevatedACLPlanFn is a seam. The failure branch below builds
|
||||
// the guidance an operator acts on, and that text is only correct by
|
||||
// inspection until something drives the branch and reads it back.
|
||||
var applyWindowsUnelevatedACLPlanFn = applyWindowsACLPlan
|
||||
|
||||
// ensureWindowsUnelevatedSetup applies the workspace ACL plan from the current
|
||||
// (non-elevated) process so the write-restricted token has somewhere its
|
||||
// capability SIDs are granted. DACL edits on user-owned workspace and temp
|
||||
@@ -120,9 +139,16 @@ func ensureWindowsUnelevatedSetup(config WindowsSandboxCommandConfig) error {
|
||||
if marker.contains(applied) {
|
||||
return nil
|
||||
}
|
||||
if _, err := applyWindowsACLPlan(plan); err != nil {
|
||||
if _, err := applyWindowsUnelevatedACLPlanFn(plan); err != nil {
|
||||
// Both remedies below are real. An earlier version offered `--sandbox
|
||||
// forbid`, which is not: SandboxPreferenceForbid is an internal engine
|
||||
// state with no flag behind it, so following that advice produced an
|
||||
// unknown option and left the reader stuck on a failure they had just been
|
||||
// told how to clear. A recovery instruction that does not work is worse
|
||||
// than none, because it costs the reader the time to discover that.
|
||||
return fmt.Errorf("apply unelevated workspace ACLs: %w — the workspace may be on a filesystem the current user does not own; "+
|
||||
"run `zero sandbox setup` from an elevated (Administrator) terminal, or re-run with `--sandbox forbid` to skip OS sandboxing", err)
|
||||
"run `zero sandbox setup` from an elevated (Administrator) terminal, "+
|
||||
`or turn the sandbox off in your user config with "sandbox": {"enabled": false}`, err)
|
||||
}
|
||||
return recordWindowsUnelevatedAppliedPlan(config.SandboxHome, applied)
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
//go:build windows
|
||||
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/Twigpine/zero/internal/execution"
|
||||
)
|
||||
|
||||
// A REPORT THAT SAYS A CHILD LAUNCHED HAS TO MEAN THE CHILD COULD RUN.
|
||||
//
|
||||
// The report is published before ResumeThread so the inherited-pipe race is
|
||||
// closed, and that ordering is right. But a failure between the publish and the
|
||||
// resume reaps a process that executed nothing, and the report was left on disk
|
||||
// saying true. AppliedEnforcementNotices gates on that report, so the operator
|
||||
// would have been told a write-jail trade applied to a child that never became
|
||||
// runnable. The record has to be unwound on that path, not only on the ones
|
||||
// before it was written.
|
||||
// AND IT HAS TO SAY SO, RATHER THAN SAY NOTHING. Removing the file leaves the
|
||||
// parent reading absence, which is also what a normal cleanup leaves, so a
|
||||
// reader that saw the publication during the window keeps its positive through
|
||||
// completion. The retraction states the negative instead.
|
||||
func TestResumeFailureRetractsTheLaunchReport(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "report.json")
|
||||
report, err := openWindowsExecutionReport(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
keep, err := publishThenResume(report, func() error { return errors.New("STATUS_ACCESS_DENIED") })
|
||||
if err == nil {
|
||||
t.Fatal("SETUP INVALID: the injected resume failure was not reported")
|
||||
}
|
||||
if !keep {
|
||||
t.Fatal("publishThenResume discarded the report after its resume failed, so the parent reads absence and a live reader keeps the launch it already saw")
|
||||
}
|
||||
report.close(keep)
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("the retracted report is not readable: %v", err)
|
||||
}
|
||||
var decoded execution.AdapterReport
|
||||
if err := json.Unmarshal(data, &decoded); err != nil {
|
||||
t.Fatalf("decode report %q: %v", data, err)
|
||||
}
|
||||
if decoded.ChildLaunched == nil {
|
||||
t.Fatalf("report = %s, want an explicit childLaunched false; silence does not revoke a launch a live reader already latched", data)
|
||||
}
|
||||
if *decoded.ChildLaunched {
|
||||
t.Fatalf("report = %s, want childLaunched false for a child that executed no instruction", data)
|
||||
}
|
||||
}
|
||||
|
||||
// A retraction that cannot be written falls back to discarding the file, which
|
||||
// is where this path was before: absence is weaker than an explicit false, and
|
||||
// still better than a report left saying true.
|
||||
func TestAnUnwritableRetractionDiscardsTheReport(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "report.json")
|
||||
report, err := openWindowsExecutionReport(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
keep, err := publishThenResume(report, func() error {
|
||||
// Close the handle underneath the retraction, so its write fails the
|
||||
// way a broken report file would.
|
||||
_ = report.file.Close()
|
||||
return errors.New("STATUS_ACCESS_DENIED")
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("SETUP INVALID: the injected resume failure was not reported")
|
||||
}
|
||||
if keep {
|
||||
t.Fatal("publishThenResume kept a report it could not retract, so the file still says a child launched")
|
||||
}
|
||||
report.close(keep)
|
||||
|
||||
if _, statErr := os.Stat(path); !os.IsNotExist(statErr) {
|
||||
t.Fatalf("a report that could not be retracted survived (stat: %v); the parent would read a child as launched that executed no instruction", statErr)
|
||||
}
|
||||
}
|
||||
|
||||
// And the ordinary path still publishes exactly what the parent relies on.
|
||||
func TestSuccessfulResumeKeepsTheLaunchReport(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "report.json")
|
||||
report, err := openWindowsExecutionReport(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
keep, err := publishThenResume(report, func() error { return nil })
|
||||
if err != nil || !keep {
|
||||
t.Fatalf("publishThenResume = (%v, %v), want (true, nil)", keep, err)
|
||||
}
|
||||
report.close(keep)
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("the launch report is gone after a successful resume: %v", err)
|
||||
}
|
||||
var decoded execution.AdapterReport
|
||||
if err := json.Unmarshal(data, &decoded); err != nil {
|
||||
t.Fatalf("decode report: %v", err)
|
||||
}
|
||||
if decoded.ChildLaunched == nil || !*decoded.ChildLaunched {
|
||||
t.Fatalf("report = %s, want childLaunched true", data)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
//go:build windows
|
||||
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/Twigpine/zero/internal/execution"
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// windowsExecutionReport is the helper's side channel back to the parent.
|
||||
//
|
||||
// The only fact it carries is whether the REQUESTED process was created. The
|
||||
// parent starts this helper, so the parent's own exec.Cmd.Process proves the
|
||||
// helper ran and nothing more: setup-marker validation, ACL application,
|
||||
// network-policy validation, capability and offline SID construction and
|
||||
// restricted-token creation all happen afterwards and can each return with no
|
||||
// sandboxed child. Only this process observes the transition, so only this
|
||||
// process may report it.
|
||||
//
|
||||
// OPENED BEFORE THE LAUNCH, ON PURPOSE. Publishing is not free of failure, and
|
||||
// once CreateProcessAsUser has succeeded a running child exists whether or not
|
||||
// the report can be written. Acquiring the file first moves every failure that
|
||||
// can be moved to a point where there is still nothing to own; what remains is
|
||||
// handled by reaping the child rather than returning while it runs.
|
||||
type windowsExecutionReport struct {
|
||||
file *os.File
|
||||
path string
|
||||
}
|
||||
|
||||
// openWindowsExecutionReport claims the report path before anything is launched.
|
||||
//
|
||||
// O_EXCL, so a file another local user pre-created at this name makes the helper
|
||||
// fail here, before any child exists, instead of letting them supply the fact
|
||||
// the parent reads back. An empty path means the caller wants no report, which
|
||||
// keeps the standalone helper and every existing test working unchanged.
|
||||
func openWindowsExecutionReport(path string) (*windowsExecutionReport, error) {
|
||||
trimmed := strings.TrimSpace(path)
|
||||
if trimmed == "" {
|
||||
return &windowsExecutionReport{}, nil
|
||||
}
|
||||
file, err := os.OpenFile(trimmed, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open sandbox execution report: %w", err)
|
||||
}
|
||||
return &windowsExecutionReport{file: file, path: trimmed}, nil
|
||||
}
|
||||
|
||||
// publish records the launch fact. Safe on a report the caller never opened.
|
||||
func (report *windowsExecutionReport) publish(childLaunched bool) error {
|
||||
if report == nil || report.file == nil {
|
||||
return nil
|
||||
}
|
||||
launched := childLaunched
|
||||
if err := json.NewEncoder(report.file).Encode(execution.AdapterReport{ChildLaunched: &launched}); err != nil {
|
||||
return fmt.Errorf("write sandbox execution report: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// close releases the handle. keep is false when nothing worth reading was
|
||||
// written, and the file is discarded then, so a truncated or empty report can
|
||||
// never be read back as a launch that happened. A retracted report is worth
|
||||
// reading: an explicit false is the only answer that outranks a launch a live
|
||||
// reader has already seen.
|
||||
func (report *windowsExecutionReport) close(keep bool) {
|
||||
if report == nil || report.file == nil {
|
||||
return
|
||||
}
|
||||
closeErr := report.file.Close()
|
||||
if !keep || closeErr != nil {
|
||||
_ = os.Remove(report.path)
|
||||
}
|
||||
report.file = nil
|
||||
}
|
||||
|
||||
// retract replaces a published launch with an explicit denial of it.
|
||||
//
|
||||
// REMOVING THE FILE IS NOT ENOUGH, BECAUSE ABSENCE ALREADY MEANS SOMETHING
|
||||
// ELSE. The parent reads this report while the command is still running and
|
||||
// latches a launch it sees, precisely because the file is expected to be gone
|
||||
// by the time the command finishes: a normal cleanup removes it, and the
|
||||
// manager restores the launch it observed rather than reading that absence as a
|
||||
// child that never started. So a reader that looked during the window between
|
||||
// publish and resume keeps its positive no matter what is deleted afterwards.
|
||||
// An explicit false is the one answer that outranks it.
|
||||
func (report *windowsExecutionReport) retract() error {
|
||||
if report == nil || report.file == nil {
|
||||
return nil
|
||||
}
|
||||
if err := report.file.Truncate(0); err != nil {
|
||||
return fmt.Errorf("retract sandbox execution report: %w", err)
|
||||
}
|
||||
if _, err := report.file.Seek(0, io.SeekStart); err != nil {
|
||||
return fmt.Errorf("retract sandbox execution report: %w", err)
|
||||
}
|
||||
launched := false
|
||||
if err := json.NewEncoder(report.file).Encode(execution.AdapterReport{ChildLaunched: &launched}); err != nil {
|
||||
return fmt.Errorf("retract sandbox execution report: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// publishThenResume records the launch and only then lets the child run, and
|
||||
// unwinds the record if the child cannot run after all.
|
||||
//
|
||||
// The report says one thing to the parent: a sandboxed child could execute, and
|
||||
// enforcement was in force for it. That is what AppliedEnforcementNotices gates
|
||||
// on, and what ResolveChildLaunched treats as authoritative. It is deliberately
|
||||
// NOT "CreateProcessAsUser returned a handle": a suspended process that is reaped
|
||||
// before ResumeThread has executed no instruction and applied nothing, and a
|
||||
// report saying otherwise would disclose a write-jail trade nobody made.
|
||||
//
|
||||
// Publish before resume stays, because it closes the inherited-pipe race the
|
||||
// caller documents. What this adds is the other half: a resume failure retracts
|
||||
// the record, so the report stops saying true about a child that never ran.
|
||||
//
|
||||
// RETRACTED, NOT DELETED. The publication is readable for as long as the window
|
||||
// between it and the resume lasts, and the parent reads it live: a poll landing
|
||||
// in that window latches a launch, and that latch outlives the file, because a
|
||||
// normal cleanup deletes the report too and the parent must not read that
|
||||
// deletion as a child that never started. Deleting on this path would therefore
|
||||
// leave the latched positive standing in both the live and the final result. An
|
||||
// explicit false is the one answer that revokes it, so the returned flag keeps
|
||||
// the file when the retraction was written. If it could not be written the file
|
||||
// is discarded after all, which is no worse than before.
|
||||
func publishThenResume(report *windowsExecutionReport, resume func() error) (keep bool, err error) {
|
||||
if err := report.publish(true); err != nil {
|
||||
return false, fmt.Errorf("record sandboxed child launch: %w", err)
|
||||
}
|
||||
if resumeErr := resume(); resumeErr != nil {
|
||||
if retractErr := report.retract(); retractErr != nil {
|
||||
return false, fmt.Errorf("resume sandboxed process: %w", resumeErr)
|
||||
}
|
||||
return true, fmt.Errorf("resume sandboxed process: %w", resumeErr)
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// terminateSuspendedWindowsChild takes down a child that was created suspended
|
||||
// and never resumed, and waits for it to actually leave.
|
||||
//
|
||||
// Used on the paths between CreateProcessAsUser and ResumeThread. The process
|
||||
// exists and holds the inherited pipes, so it has to be closed out rather than
|
||||
// abandoned, but it has executed no instructions: there is no work to undo and
|
||||
// nothing for the parent to be told about.
|
||||
//
|
||||
// Two of those paths differ in what the report holds. Before publish, nothing was
|
||||
// written and "no child launched" is simply what happened. After publish but
|
||||
// before resume, a report saying true is on disk about a child that never ran;
|
||||
// publishThenResume returns false there so the caller's deferred close removes
|
||||
// it. Either way the parent reads absence, and returning an error here is honest.
|
||||
func terminateSuspendedWindowsChild(process windows.Handle) {
|
||||
if process == 0 {
|
||||
return
|
||||
}
|
||||
// The exit code is irrelevant: this path is already returning an error, and
|
||||
// the point is that the child is gone before the helper is.
|
||||
_ = windows.TerminateProcess(process, 1)
|
||||
_, _ = windows.WaitForSingleObject(process, windows.INFINITE)
|
||||
}
|
||||
@@ -56,6 +56,16 @@ func runWindowsCommandAsUser(token windows.Token, config WindowsSandboxCommandCo
|
||||
startup.StdErr = stderr
|
||||
var process windows.ProcessInformation
|
||||
envPtr := &envBlock[0]
|
||||
// Claim the report side channel BEFORE the launch. Publishing can fail, and
|
||||
// after CreateProcessAsUser succeeds a running child exists whether or not the
|
||||
// fact can be recorded; taking the file first moves that failure to a point
|
||||
// where there is still nothing to own.
|
||||
report, err := openWindowsExecutionReport(config.ExecutionReportPath)
|
||||
if err != nil {
|
||||
return 1, err
|
||||
}
|
||||
published := false
|
||||
defer func() { report.close(published) }()
|
||||
if err := windows.CreateProcessAsUser(
|
||||
token,
|
||||
nil,
|
||||
@@ -63,7 +73,7 @@ func runWindowsCommandAsUser(token windows.Token, config WindowsSandboxCommandCo
|
||||
nil,
|
||||
nil,
|
||||
true,
|
||||
windows.CREATE_UNICODE_ENVIRONMENT,
|
||||
windows.CREATE_UNICODE_ENVIRONMENT|windows.CREATE_SUSPENDED,
|
||||
envPtr,
|
||||
cwdPtr,
|
||||
&startup,
|
||||
@@ -73,6 +83,39 @@ func runWindowsCommandAsUser(token windows.Token, config WindowsSandboxCommandCo
|
||||
}
|
||||
defer windows.CloseHandle(process.Process)
|
||||
defer windows.CloseHandle(process.Thread)
|
||||
// THE TRANSITION ONLY THIS PROCESS CAN SEE. Everything above can fail with the
|
||||
// helper already running, and the parent's exec.Cmd.Process cannot tell those
|
||||
// failures apart from a real sandboxed launch. The restricted child exists as
|
||||
// of this line, so this is where the fact is published.
|
||||
//
|
||||
// CREATED SUSPENDED, SO REPORTING CANNOT LOSE A RACE IT IS IN. The child
|
||||
// inherits the MCP pipes. Created runnable, it could answer initialize, emit a
|
||||
// malformed response, or close stdout before this helper was next scheduled,
|
||||
// and a parent reading the report at that moment would see the empty file the
|
||||
// open above created and cache "no child" about a server that was already
|
||||
// running unconfined. It could also fail to publish AFTER a runnable child had
|
||||
// begun making external changes, and reaping the child then does not undo the
|
||||
// work it did.
|
||||
//
|
||||
// A suspended child has executed nothing. Publish first and resume second, and
|
||||
// the absence of a report becomes a fact rather than a race: every failure
|
||||
// between creation and resume terminates a process that never ran, so "no child
|
||||
// launched" is true when the parent reads it.
|
||||
//
|
||||
// AND THE RECORD HAS TO MEAN THE SAME THING ON THE WAY BACK OUT. The report
|
||||
// is published before the resume, so a failure between the two leaves a report
|
||||
// saying a child launched about a process that is being reaped without ever
|
||||
// having run. publishThenResume unwinds the record on that path, which is
|
||||
// what keeps "no child launched" true for the parent on every failure before
|
||||
// the child could execute, not just the ones before the write.
|
||||
published, err = publishThenResume(report, func() error {
|
||||
_, err := windows.ResumeThread(process.Thread)
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
terminateSuspendedWindowsChild(process.Process)
|
||||
return 1, err
|
||||
}
|
||||
if _, err := windows.WaitForSingleObject(process.Process, windows.INFINITE); err != nil {
|
||||
return 1, fmt.Errorf("wait for sandboxed process: %w", err)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package sandbox
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/binary"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -156,23 +157,30 @@ func windowsShellCommandLineFromArgs(args []string) (string, bool) {
|
||||
}
|
||||
|
||||
type WindowsSandboxCommandArgsOptions struct {
|
||||
SandboxHome string
|
||||
CommandCWD string
|
||||
WorkspaceRoots []string
|
||||
PermissionProfile PermissionProfile
|
||||
Env []string
|
||||
SandboxLevel WindowsSandboxLevel
|
||||
Command []string
|
||||
// ExecutionReportPath is where the helper writes its structured report,
|
||||
// including the authoritative fact that the sandboxed child was created.
|
||||
ExecutionReportPath string
|
||||
SandboxHome string
|
||||
CommandCWD string
|
||||
WorkspaceRoots []string
|
||||
PermissionProfile PermissionProfile
|
||||
Env []string
|
||||
SandboxLevel WindowsSandboxLevel
|
||||
Command []string
|
||||
}
|
||||
|
||||
type WindowsSandboxCommandConfig struct {
|
||||
SandboxHome string
|
||||
CommandCWD string
|
||||
WorkspaceRoots []string
|
||||
PermissionProfile PermissionProfile
|
||||
Env map[string]string
|
||||
SandboxLevel WindowsSandboxLevel
|
||||
Command []string
|
||||
// ExecutionReportPath is the adapter-owned side channel back to the runner.
|
||||
// Empty when the caller wants no report, which keeps every existing test and
|
||||
// the standalone helper working unchanged.
|
||||
ExecutionReportPath string
|
||||
SandboxHome string
|
||||
CommandCWD string
|
||||
WorkspaceRoots []string
|
||||
PermissionProfile PermissionProfile
|
||||
Env map[string]string
|
||||
SandboxLevel WindowsSandboxLevel
|
||||
Command []string
|
||||
}
|
||||
|
||||
func BuildWindowsSandboxCommandArgs(options WindowsSandboxCommandArgsOptions) ([]string, error) {
|
||||
@@ -217,6 +225,9 @@ func BuildWindowsSandboxCommandArgs(options WindowsSandboxCommandArgsOptions) ([
|
||||
"--env-json", string(envJSON),
|
||||
"--windows-sandbox-level", string(level),
|
||||
}
|
||||
if reportPath := strings.TrimSpace(options.ExecutionReportPath); reportPath != "" {
|
||||
args = append(args, "--execution-report", reportPath)
|
||||
}
|
||||
for _, root := range workspaceRoots {
|
||||
args = append(args, "--workspace-root", root)
|
||||
}
|
||||
@@ -249,6 +260,13 @@ func ParseWindowsSandboxCommandArgs(args []string) (WindowsSandboxCommandConfig,
|
||||
}
|
||||
config.SandboxHome = strings.TrimSpace(value)
|
||||
index = next
|
||||
case "--execution-report":
|
||||
value, next, err := nextWindowsSandboxFlagValue(args, index)
|
||||
if err != nil {
|
||||
return WindowsSandboxCommandConfig{}, err
|
||||
}
|
||||
config.ExecutionReportPath = strings.TrimSpace(value)
|
||||
index = next
|
||||
case "--workspace-root":
|
||||
value, next, err := nextWindowsSandboxFlagValue(args, index)
|
||||
if err != nil {
|
||||
@@ -341,14 +359,23 @@ func windowsRestrictedTokenCommandPlan(execRequest SandboxExecutionRequest, poli
|
||||
if execRequest.EnforcementLevel == EnforcementUnelevated {
|
||||
level = WindowsSandboxLevelUnelevated
|
||||
}
|
||||
// The helper's side channel back to us. The runner starts the helper, so its
|
||||
// own exec.Cmd.Process only proves the HELPER ran; everything that makes this
|
||||
// a sandbox happens inside, after that. The helper writes the authoritative
|
||||
// child-launch fact here and the runner believes it over its own observation.
|
||||
reportPath, err := newWindowsExecutionReportPath()
|
||||
if err != nil {
|
||||
return CommandPlan{}, err
|
||||
}
|
||||
args, err := BuildWindowsSandboxCommandArgs(WindowsSandboxCommandArgsOptions{
|
||||
SandboxHome: sandboxHome,
|
||||
CommandCWD: spec.Dir,
|
||||
WorkspaceRoots: []string{execRequest.WorkspaceRoot},
|
||||
PermissionProfile: execRequest.PermissionProfile,
|
||||
Env: childEnv,
|
||||
SandboxLevel: level,
|
||||
Command: append([]string{spec.Name}, spec.Args...),
|
||||
ExecutionReportPath: reportPath,
|
||||
SandboxHome: sandboxHome,
|
||||
CommandCWD: spec.Dir,
|
||||
WorkspaceRoots: []string{execRequest.WorkspaceRoot},
|
||||
PermissionProfile: execRequest.PermissionProfile,
|
||||
Env: childEnv,
|
||||
SandboxLevel: level,
|
||||
Command: append([]string{spec.Name}, spec.Args...),
|
||||
})
|
||||
if err != nil {
|
||||
return CommandPlan{}, err
|
||||
@@ -359,21 +386,38 @@ func windowsRestrictedTokenCommandPlan(execRequest SandboxExecutionRequest, poli
|
||||
// helper .exe, where args are passed unchanged.
|
||||
fullArgs := append(append([]string{}, execRequest.Backend.ExecutableArgsPrefix...), args...)
|
||||
return withSandboxExecutionMetadata(CommandPlan{
|
||||
Backend: execRequest.Backend,
|
||||
TargetBackend: execRequest.TargetBackend,
|
||||
WorkspaceRoot: execRequest.WorkspaceRoot,
|
||||
Policy: policy,
|
||||
Wrapped: true,
|
||||
SandboxEnvMarkers: execRequest.SandboxEnvMarkers,
|
||||
EnforcementLevel: execRequest.EnforcementLevel,
|
||||
Name: execRequest.Backend.Executable,
|
||||
Args: fullArgs,
|
||||
Dir: spec.Dir,
|
||||
Env: childEnv,
|
||||
SandboxDir: spec.Dir,
|
||||
Backend: execRequest.Backend,
|
||||
TargetBackend: execRequest.TargetBackend,
|
||||
WorkspaceRoot: execRequest.WorkspaceRoot,
|
||||
Policy: policy,
|
||||
Wrapped: true,
|
||||
SandboxEnvMarkers: execRequest.SandboxEnvMarkers,
|
||||
EnforcementLevel: execRequest.EnforcementLevel,
|
||||
Name: execRequest.Backend.Executable,
|
||||
Args: fullArgs,
|
||||
Dir: spec.Dir,
|
||||
Env: childEnv,
|
||||
SandboxDir: spec.Dir,
|
||||
executionReportPath: reportPath,
|
||||
childLaunchReported: true,
|
||||
cleanup: func() {
|
||||
_ = os.Remove(reportPath)
|
||||
},
|
||||
}, execRequest), nil
|
||||
}
|
||||
|
||||
// newWindowsExecutionReportPath names the helper's report file under the
|
||||
// per-user temp directory. Random, and the helper creates it with O_EXCL, so a
|
||||
// name another local user pre-created makes the write fail rather than letting
|
||||
// them dictate the fact the runner reads back.
|
||||
func newWindowsExecutionReportPath() (string, error) {
|
||||
var token [16]byte
|
||||
if _, err := rand.Read(token[:]); err != nil {
|
||||
return "", fmt.Errorf("generate sandbox execution report path: %w", err)
|
||||
}
|
||||
return filepath.Join(os.TempDir(), "zero-sandbox-report-"+hex.EncodeToString(token[:])+".json"), nil
|
||||
}
|
||||
|
||||
func upsertEnvList(env []string, values ...string) []string {
|
||||
out := cloneStrings(env)
|
||||
for _, value := range values {
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
//go:build windows
|
||||
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"unsafe"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// A SID that parses but names nothing on the machine. CreateRestrictedToken does
|
||||
// not require a restricting SID to resolve, and using a real group would make the
|
||||
// test depend on local account layout.
|
||||
const testCapabilitySID = "S-1-5-21-1111111111-1111111111-1111111111-4001"
|
||||
|
||||
// restrictedSIDStrings returns the token's restricted-SID list.
|
||||
//
|
||||
// This list IS the write jail. Under WRITE_RESTRICTED a write must pass both the
|
||||
// ordinary access check and a second check against these SIDs, so the jail holds
|
||||
// exactly as long as the list contains nothing every principal already carries.
|
||||
func restrictedSIDStrings(t *testing.T, token windows.Token) []string {
|
||||
t.Helper()
|
||||
var size uint32
|
||||
err := windows.GetTokenInformation(token, windows.TokenRestrictedSids, nil, 0, &size)
|
||||
if err != nil && err != windows.ERROR_INSUFFICIENT_BUFFER {
|
||||
t.Fatalf("size restricted SID list: %v", err)
|
||||
}
|
||||
if size == 0 {
|
||||
return nil
|
||||
}
|
||||
buffer := make([]byte, size)
|
||||
if err := windows.GetTokenInformation(token, windows.TokenRestrictedSids, &buffer[0], size, &size); err != nil {
|
||||
t.Fatalf("read restricted SID list: %v", err)
|
||||
}
|
||||
groups := (*windows.Tokengroups)(unsafe.Pointer(&buffer[0]))
|
||||
values := make([]string, 0, groups.GroupCount)
|
||||
for _, group := range groups.AllGroups() {
|
||||
values = append(values, group.Sid.String())
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
func restrictedTokenForTest(t *testing.T, writeRestricted bool) windows.Token {
|
||||
t.Helper()
|
||||
token, err := createWindowsRestrictedTokenForCapabilitySIDs([]string{testCapabilitySID}, writeRestricted)
|
||||
if err != nil {
|
||||
t.Fatalf("create restricted token (writeRestricted=%v): %v", writeRestricted, err)
|
||||
}
|
||||
t.Cleanup(func() { _ = token.Close() })
|
||||
return token
|
||||
}
|
||||
|
||||
func containsSID(values []string, want string) bool {
|
||||
for _, value := range values {
|
||||
if strings.EqualFold(value, want) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// THE REGRESSION GUARD FOR #865. The World SID (Everyone) must not be a
|
||||
// restricting SID on the WRITE_RESTRICTED token.
|
||||
//
|
||||
// Every principal carries Everyone, so if it is on this list the second check
|
||||
// passes for free on any path whose DACL grants Everyone write, and confinement
|
||||
// silently falls back to the user's own permissions. No privilege, no symlink and
|
||||
// no race is needed; an Everyone-writable directory is enough.
|
||||
//
|
||||
// This is a unit test on purpose. The existing coverage
|
||||
// (TestWindowsRestrictedTokenDeniesWritesToEveryoneWritablePaths) sits behind
|
||||
// ZERO_SANDBOX_REAL_SMOKE=1, which no workflow sets, so until now a refactor that
|
||||
// restored the unconditional World SID went green in CI. CreateRestrictedToken
|
||||
// works unelevated against the caller's own token, so there is no reason this
|
||||
// invariant cannot be checked on every run.
|
||||
func TestWriteRestrictedTokenExcludesTheWorldSID(t *testing.T) {
|
||||
values := restrictedSIDStrings(t, restrictedTokenForTest(t, true))
|
||||
if len(values) == 0 {
|
||||
t.Fatal("write-restricted token has no restricting SIDs at all, so there is no write jail to speak of")
|
||||
}
|
||||
if containsSID(values, "S-1-1-0") {
|
||||
t.Fatalf("the World SID is a restricting SID on the write-restricted token, which collapses the write jail: %v", values)
|
||||
}
|
||||
}
|
||||
|
||||
// No universal group belongs on this list, for the same reason Everyone does not.
|
||||
// #869 calls these out by name as the ones that would reopen the gap, and the
|
||||
// runner's own comment already states the rule, so this pins it rather than
|
||||
// trusting the next reader to remember.
|
||||
//
|
||||
// Checked on BOTH token shapes: the non-WRITE_RESTRICTED one still must not gain
|
||||
// any of these beyond the World SID it is documented to carry.
|
||||
func TestRestrictedSIDListNeverCarriesABroadGroup(t *testing.T) {
|
||||
forbidden := map[string]string{
|
||||
"S-1-5-32-545": `BUILTIN\Users`,
|
||||
"S-1-5-11": "Authenticated Users",
|
||||
"S-1-5-4": "INTERACTIVE",
|
||||
"S-1-5-3": "BATCH",
|
||||
"S-1-5-32-544": `BUILTIN\Administrators`,
|
||||
"S-1-5-18": "SYSTEM",
|
||||
"S-1-5-6": "SERVICE",
|
||||
"S-1-5-2": "NETWORK",
|
||||
}
|
||||
for _, writeRestricted := range []bool{true, false} {
|
||||
values := restrictedSIDStrings(t, restrictedTokenForTest(t, writeRestricted))
|
||||
for sid, name := range forbidden {
|
||||
if containsSID(values, sid) {
|
||||
t.Errorf("writeRestricted=%v: %s (%s) is a restricting SID; it has write access nearly everywhere, so the jail would not hold",
|
||||
writeRestricted, name, sid)
|
||||
}
|
||||
}
|
||||
// The user's own SID is the boundary this token exists to be stricter
|
||||
// than, so it must never be its own key.
|
||||
if user := currentUserSIDForTest(t); containsSID(values, user) {
|
||||
t.Errorf("writeRestricted=%v: the current user SID is a restricting SID, which defeats the token entirely", writeRestricted)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The capability SID must actually be present, or the jail denies everything and
|
||||
// the sandbox cannot write even where Zero granted access. A test that only
|
||||
// checked for absences would pass against a token with an empty list.
|
||||
func TestRestrictedSIDListCarriesTheCapabilitySID(t *testing.T) {
|
||||
for _, writeRestricted := range []bool{true, false} {
|
||||
values := restrictedSIDStrings(t, restrictedTokenForTest(t, writeRestricted))
|
||||
if !containsSID(values, testCapabilitySID) {
|
||||
t.Errorf("writeRestricted=%v: the capability SID is missing from %v, so no ACL-granted path would be writable",
|
||||
writeRestricted, values)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Documents the gap #869 tracks rather than asserting the desired end state.
|
||||
//
|
||||
// Without WRITE_RESTRICTED the restricted-SID check covers reads too, and default
|
||||
// Windows DACLs grant BUILTIN\Users, so a token with no universal group cannot
|
||||
// open cmd.exe and dies at launch with STATUS_ACCESS_DENIED. Everyone is
|
||||
// load-bearing here, which is why #865 could not remove it from this shape.
|
||||
//
|
||||
// The consequence is that this shape, selected whenever a profile sets DenyRead,
|
||||
// has no effective write jail. If someone closes #869 by giving reads a grant
|
||||
// that is not a universal group, this test FAILS and must be replaced by the
|
||||
// exclusion assertion in the same change, rather than deleted or skipped past.
|
||||
func TestNonWriteRestrictedTokenStillCarriesTheWorldSID(t *testing.T) {
|
||||
values := restrictedSIDStrings(t, restrictedTokenForTest(t, false))
|
||||
if !containsSID(values, "S-1-1-0") {
|
||||
// FAILS rather than skips, and the difference matters more than it looks.
|
||||
//
|
||||
// This SID is availability-critical as well as security-relevant: without
|
||||
// WRITE_RESTRICTED the restricted-SID check covers reads, default Windows
|
||||
// DACLs grant BUILTINUsers, and a token carrying no universal group cannot
|
||||
// open cmd.exe. Removing it therefore breaks every command with DenyRead at
|
||||
// launch. A skip here would let exactly that land on green CI, which is the
|
||||
// one outcome this test exists to prevent.
|
||||
//
|
||||
// If you are reading this because you deliberately changed the token shape
|
||||
// for #869: good, and this assertion is now yours to replace, in the same
|
||||
// change, with tests proving the new token still launches an ordinary
|
||||
// executable, still denies the intended read path, and has not restored the
|
||||
// broad write bypass. Deleting it without those is not the same thing.
|
||||
t.Fatal("the World SID is gone from the DenyRead token shape: every DenyRead command now fails at launch unless reads were given a non-universal grant; replace this assertion with the #869 exclusion and launch tests")
|
||||
}
|
||||
t.Log("known gap (#869): the DenyRead token shape carries the World SID, so its write jail does not hold")
|
||||
}
|
||||
|
||||
// currentUserSIDForTest FAILS rather than returning empty.
|
||||
//
|
||||
// It used to swallow the error, and its one caller guarded on the result being
|
||||
// non-empty, so a machine where GetTokenUser fails ran the assertion on nothing
|
||||
// and reported a pass. The check exists to catch the token keying itself to the
|
||||
// very SID it must be stricter than, which is the whole point of the shape, so
|
||||
// not being able to read the prerequisite is a failure and not a skip.
|
||||
func currentUserSIDForTest(t *testing.T) string {
|
||||
t.Helper()
|
||||
token := windows.GetCurrentProcessToken()
|
||||
user, err := token.GetTokenUser()
|
||||
if err != nil {
|
||||
t.Fatalf("read the current user SID, which this assertion depends on: %v", err)
|
||||
}
|
||||
sid := user.User.Sid.String()
|
||||
if strings.TrimSpace(sid) == "" {
|
||||
t.Fatal("the current user SID came back empty, so the assertion below would check nothing")
|
||||
}
|
||||
return sid
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
//go:build windows
|
||||
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// EVERY REMEDY THIS ERROR NAMES MUST BE ONE THE READER CAN CARRY OUT.
|
||||
//
|
||||
// The message told operators to re-run with `--sandbox forbid`. No such option
|
||||
// exists: SandboxPreferenceForbid is an internal engine state with no flag
|
||||
// behind it, so acting on the advice produced an unknown option and left them
|
||||
// stuck on the failure they had just been told how to clear.
|
||||
//
|
||||
// It survived because nothing drove this branch. The text was only ever correct
|
||||
// by inspection, and inspection is what missed it, so the fix is not complete
|
||||
// until something fails the apply and reads the guidance back.
|
||||
func TestUnelevatedACLFailureNamesOnlyRealRemedies(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
config := WindowsSandboxCommandConfig{
|
||||
SandboxHome: t.TempDir(),
|
||||
CommandCWD: workspace,
|
||||
WorkspaceRoots: []string{workspace},
|
||||
PermissionProfile: PermissionProfile{
|
||||
FileSystem: FileSystemPolicy{
|
||||
Kind: FileSystemRestricted,
|
||||
WriteRoots: []WritableRoot{{Root: workspace}},
|
||||
},
|
||||
Network: NetworkPolicy{Mode: NetworkDeny},
|
||||
},
|
||||
SandboxLevel: WindowsSandboxLevelUnelevated,
|
||||
}
|
||||
|
||||
denied := errors.New("Access is denied.")
|
||||
original := applyWindowsUnelevatedACLPlanFn
|
||||
t.Cleanup(func() { applyWindowsUnelevatedACLPlanFn = original })
|
||||
applyWindowsUnelevatedACLPlanFn = func(WindowsACLPlan) (func() error, error) {
|
||||
return nil, denied
|
||||
}
|
||||
|
||||
err := ensureWindowsUnelevatedSetup(config)
|
||||
if err == nil {
|
||||
t.Fatal("ensureWindowsUnelevatedSetup returned nil when the ACL apply failed, so the command would run believing it was sandboxed")
|
||||
}
|
||||
|
||||
// The refusal has to keep naming its cause, or the operator cannot tell an
|
||||
// ACL failure apart from the sandboxed command being rejected.
|
||||
if !errors.Is(err, denied) {
|
||||
t.Errorf("error does not wrap the apply failure, so the cause is lost: %v", err)
|
||||
}
|
||||
|
||||
message := err.Error()
|
||||
|
||||
// The option that does not exist must never come back.
|
||||
if strings.Contains(message, "--sandbox forbid") {
|
||||
t.Errorf("error still advertises `--sandbox forbid`, which is not a real option: %s", message)
|
||||
}
|
||||
|
||||
// Both surviving remedies are real: elevated setup, and the user-config key,
|
||||
// which is honored from global config only so a cloned repo cannot set it.
|
||||
for _, want := range []string{
|
||||
"zero sandbox setup",
|
||||
`"sandbox": {"enabled": false}`,
|
||||
} {
|
||||
if !strings.Contains(message, want) {
|
||||
t.Errorf("error does not offer %q, leaving the reader without a way out: %s", want, message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The failure must not be recorded as a success. The applied-plan marker is
|
||||
// what makes later commands skip the re-apply, so writing it here would turn
|
||||
// one refusal into a sandbox that silently never applies its ACLs again.
|
||||
func TestUnelevatedACLFailureDoesNotRecordTheMarker(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
home := t.TempDir()
|
||||
config := WindowsSandboxCommandConfig{
|
||||
SandboxHome: home,
|
||||
CommandCWD: workspace,
|
||||
WorkspaceRoots: []string{workspace},
|
||||
PermissionProfile: PermissionProfile{
|
||||
FileSystem: FileSystemPolicy{
|
||||
Kind: FileSystemRestricted,
|
||||
WriteRoots: []WritableRoot{{Root: workspace}},
|
||||
},
|
||||
Network: NetworkPolicy{Mode: NetworkDeny},
|
||||
},
|
||||
SandboxLevel: WindowsSandboxLevelUnelevated,
|
||||
}
|
||||
|
||||
original := applyWindowsUnelevatedACLPlanFn
|
||||
t.Cleanup(func() { applyWindowsUnelevatedACLPlanFn = original })
|
||||
applyWindowsUnelevatedACLPlanFn = func(WindowsACLPlan) (func() error, error) {
|
||||
return nil, errors.New("Access is denied.")
|
||||
}
|
||||
|
||||
if err := ensureWindowsUnelevatedSetup(config); err == nil {
|
||||
t.Fatal("expected the apply failure to surface")
|
||||
}
|
||||
|
||||
applied, _, err := buildWindowsUnelevatedAppliedPlan(config)
|
||||
if err != nil {
|
||||
t.Fatalf("buildWindowsUnelevatedAppliedPlan: %v", err)
|
||||
}
|
||||
marker, err := loadWindowsUnelevatedSetupMarker(home)
|
||||
if err != nil {
|
||||
t.Fatalf("loadWindowsUnelevatedSetupMarker: %v", err)
|
||||
}
|
||||
if marker.contains(applied) {
|
||||
t.Error("the failed plan was recorded as applied, so every later command would skip the apply and run unjailed")
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
@@ -30,13 +31,35 @@ const (
|
||||
|
||||
var (
|
||||
openSecretLockFile = os.OpenFile
|
||||
syncSecretFileFn = (*os.File).Sync
|
||||
syncSecretDirFn = syncSecretDir
|
||||
secureLockSeq atomic.Uint64
|
||||
)
|
||||
|
||||
// syncSecretDir fsyncs the directory that now holds the published secret.
|
||||
// Windows cannot sync directory handles, so the rename is best-effort durable
|
||||
// there. A directory the user can write but not read cannot be opened for
|
||||
// syncing either; that is also best effort, because the write it follows
|
||||
// already succeeded. Every other failure is reported.
|
||||
func syncSecretDir(dir string) error {
|
||||
if runtime.GOOS == "windows" {
|
||||
return nil
|
||||
}
|
||||
d, err := os.Open(dir)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrPermission) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
return errors.Join(d.Sync(), d.Close())
|
||||
}
|
||||
|
||||
// Crypter encrypts a blob at rest with AES-256-GCM under a per-user random secret
|
||||
// persisted (0600) at secretPath.
|
||||
type Crypter struct {
|
||||
secretPath string
|
||||
secretPath string
|
||||
publicationDir string
|
||||
}
|
||||
|
||||
// NewCrypter returns a Crypter whose key lives at secretPath (created on first
|
||||
@@ -45,11 +68,20 @@ func NewCrypter(secretPath string) *Crypter {
|
||||
return &Crypter{secretPath: secretPath}
|
||||
}
|
||||
|
||||
// NewCrypterWithPublicationDir is NewCrypter with secret temp files published
|
||||
// through publicationDir instead of beside the secret. Callers that protect a
|
||||
// sandboxed process from racing the plaintext key use this to put the temp file
|
||||
// in a directory their sandbox profile denies. The directory must be on the
|
||||
// same filesystem as secretPath; the final rename remains atomic.
|
||||
func NewCrypterWithPublicationDir(secretPath, publicationDir string) *Crypter {
|
||||
return &Crypter{secretPath: secretPath, publicationDir: publicationDir}
|
||||
}
|
||||
|
||||
// aead loads (or, when create is set, generates) the secret and returns the GCM
|
||||
// AEAD. Open passes create=false so a missing secret is a hard error rather than
|
||||
// silently minting a new key that could never decrypt the existing file.
|
||||
func (c *Crypter) aead(create bool) (cipher.AEAD, error) {
|
||||
secret, err := loadOrCreateSecret(c.secretPath, create)
|
||||
secret, err := loadOrCreateSecret(c.secretPath, c.publicationDir, create)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -95,7 +127,7 @@ func (c *Crypter) Open(blob []byte) ([]byte, error) {
|
||||
// loadOrCreateSecret reads the 32-byte secret at path. When create is set and the
|
||||
// file is absent, it generates a random secret and creates the file atomically
|
||||
// (0600). A wrong-sized existing secret fails closed (corruption).
|
||||
func loadOrCreateSecret(path string, create bool) ([]byte, error) {
|
||||
func loadOrCreateSecret(path, publicationDir string, create bool) ([]byte, error) {
|
||||
if data, err := readSecretFileRetry(path); err == nil {
|
||||
return data, nil
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
@@ -107,10 +139,10 @@ func loadOrCreateSecret(path string, create bool) ([]byte, error) {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return createSecretFile(path)
|
||||
return createSecretFile(path, publicationDir)
|
||||
}
|
||||
|
||||
func createSecretFile(path string) ([]byte, error) {
|
||||
func createSecretFile(path, publicationDir string) ([]byte, error) {
|
||||
lockPath := path + ".lock"
|
||||
token := fmt.Sprintf("%d-%d-%d", os.Getpid(), time.Now().UnixNano(), secureLockSeq.Add(1))
|
||||
var lastErr error
|
||||
@@ -136,13 +168,13 @@ func createSecretFile(path string) ([]byte, error) {
|
||||
} else if !errors.Is(rerr, os.ErrNotExist) {
|
||||
return nil, rerr
|
||||
}
|
||||
return writeNewSecretFile(path)
|
||||
return writeNewSecretFile(path, publicationDir)
|
||||
}
|
||||
// On Windows a concurrent holder's os.Remove leaves the lock file in a
|
||||
// "delete pending" state, so an O_EXCL create races it with
|
||||
// ERROR_ACCESS_DENIED (os.ErrPermission) rather than ErrExist. Treat that
|
||||
// as contention and retry too -- mirroring oauth's createSecretFile --
|
||||
// otherwise concurrent secret creation spuriously fails on Windows.
|
||||
// as contention and retry, otherwise concurrent secret creation
|
||||
// spuriously fails on Windows.
|
||||
if !errors.Is(err, os.ErrExist) && !errors.Is(err, os.ErrPermission) {
|
||||
return nil, fmt.Errorf("securefile: create secret lock: %w", err)
|
||||
}
|
||||
@@ -173,20 +205,63 @@ func reclaimStaleLock(lockPath, token string, staleAfter time.Duration) bool {
|
||||
return false
|
||||
}
|
||||
if info, err := os.Stat(reclaimed); err == nil && time.Since(info.ModTime()) <= staleAfter {
|
||||
_ = os.Rename(reclaimed, lockPath)
|
||||
// The lock was replaced between our stat and rename, so put the fresh
|
||||
// lock back without clobbering a third holder that acquired lockPath in
|
||||
// the meantime.
|
||||
_ = restoreReclaimedLock(reclaimed, lockPath)
|
||||
return false
|
||||
}
|
||||
_ = os.Remove(reclaimed)
|
||||
return true
|
||||
}
|
||||
|
||||
func writeNewSecretFile(path string) ([]byte, error) {
|
||||
// restoreReclaimedLock restores a quarantined lock only if lockPath is still
|
||||
// free. The destination is created exclusively; a newer holder is never
|
||||
// replaced. The quarantined copy is discarded either way.
|
||||
func restoreReclaimedLock(reclaimed, lockPath string) error {
|
||||
data, err := os.ReadFile(reclaimed)
|
||||
if err != nil {
|
||||
_ = os.Remove(reclaimed)
|
||||
return err
|
||||
}
|
||||
lock, err := openSecretLockFile(lockPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
|
||||
if err != nil {
|
||||
_ = os.Remove(reclaimed)
|
||||
return err
|
||||
}
|
||||
if _, err := lock.Write(data); err != nil {
|
||||
_ = lock.Close()
|
||||
_ = os.Remove(lockPath)
|
||||
_ = os.Remove(reclaimed)
|
||||
return err
|
||||
}
|
||||
if err := lock.Close(); err != nil {
|
||||
_ = os.Remove(lockPath)
|
||||
_ = os.Remove(reclaimed)
|
||||
return err
|
||||
}
|
||||
_ = os.Remove(reclaimed)
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeNewSecretFile(path, publicationDir string) ([]byte, error) {
|
||||
secret := make([]byte, secretBytes)
|
||||
if _, err := io.ReadFull(rand.Reader, secret); err != nil {
|
||||
return nil, fmt.Errorf("securefile: generate secret: %w", err)
|
||||
}
|
||||
dir := filepath.Dir(path)
|
||||
tmp, err := os.CreateTemp(dir, filepath.Base(path)+".*.tmp")
|
||||
dir := publicationDir
|
||||
prefix := filepath.Base(path) + ".*.tmp"
|
||||
if dir == "" {
|
||||
dir = filepath.Dir(path)
|
||||
} else {
|
||||
// Keep the prefix oauth's store reset uses to clean crash residue from a
|
||||
// publication directory.
|
||||
prefix = "publish-*"
|
||||
}
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
return nil, fmt.Errorf("securefile: create secret publication dir: %w", err)
|
||||
}
|
||||
tmp, err := os.CreateTemp(dir, prefix)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("securefile: create secret temp file: %w", err)
|
||||
}
|
||||
@@ -200,7 +275,7 @@ func writeNewSecretFile(path string) ([]byte, error) {
|
||||
_ = tmp.Close()
|
||||
return nil, fmt.Errorf("securefile: write secret: %w", werr)
|
||||
}
|
||||
if serr := tmp.Sync(); serr != nil {
|
||||
if serr := syncSecretFileFn(tmp); serr != nil {
|
||||
_ = tmp.Close()
|
||||
return nil, fmt.Errorf("securefile: sync secret: %w", serr)
|
||||
}
|
||||
@@ -210,6 +285,12 @@ func writeNewSecretFile(path string) ([]byte, error) {
|
||||
if err := os.Rename(tmpPath, path); err != nil {
|
||||
return nil, fmt.Errorf("securefile: publish secret: %w", err)
|
||||
}
|
||||
// The key is now visible at its final name; sync the destination directory
|
||||
// so the rename itself survives a crash. The source publication directory
|
||||
// only held an unlinked temp entry and does not need a barrier.
|
||||
if err := syncSecretDirFn(filepath.Dir(path)); err != nil {
|
||||
return nil, fmt.Errorf("securefile: sync secret directory: %w", err)
|
||||
}
|
||||
return secret, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
package securefile
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
@@ -23,7 +26,7 @@ func TestCreateSecretFileRetriesOnPermissionContention(t *testing.T) {
|
||||
openSecretLockFile = origOpen
|
||||
})
|
||||
|
||||
secret, err := createSecretFile(secretPath)
|
||||
secret, err := createSecretFile(secretPath, "")
|
||||
if err != nil {
|
||||
t.Fatalf("createSecretFile should retry on permission contention: %v", err)
|
||||
}
|
||||
@@ -125,7 +128,7 @@ func TestCreateSecretFileReclaimsStaleLock(t *testing.T) {
|
||||
}
|
||||
|
||||
// Attempt to create the secret file; it should detect the stale lock, reclaim it, and succeed
|
||||
secret, err := createSecretFile(secretPath)
|
||||
secret, err := createSecretFile(secretPath, "")
|
||||
if err != nil {
|
||||
t.Fatalf("createSecretFile failed to reclaim stale lock: %v", err)
|
||||
}
|
||||
@@ -138,3 +141,198 @@ func TestCreateSecretFileReclaimsStaleLock(t *testing.T) {
|
||||
t.Fatal("expected lock file to be cleaned up after successful acquisition")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecretCreationConcurrentConverges(t *testing.T) {
|
||||
secretPath := filepath.Join(t.TempDir(), "k.secret")
|
||||
const n = 16
|
||||
blobs := make([][]byte, n)
|
||||
errs := make([]error, n)
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < n; i++ {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
blobs[i], errs[i] = NewCrypter(secretPath).Seal([]byte("payload"))
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
c := NewCrypter(secretPath)
|
||||
for i := 0; i < n; i++ {
|
||||
if errs[i] != nil {
|
||||
t.Fatalf("goroutine %d: %v", i, errs[i])
|
||||
}
|
||||
got, err := c.Open(blobs[i])
|
||||
if err != nil {
|
||||
t.Fatalf("goroutine %d blob does not decrypt under the converged secret: %v", i, err)
|
||||
}
|
||||
if !bytes.Equal(got, []byte("payload")) {
|
||||
t.Fatalf("goroutine %d plaintext = %q", i, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrongSizedSecretFailsClosed(t *testing.T) {
|
||||
secretPath := filepath.Join(t.TempDir(), "k.secret")
|
||||
if err := os.WriteFile(secretPath, []byte("short"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := NewCrypter(secretPath).Seal([]byte("payload")); err == nil {
|
||||
t.Fatal("wrong-sized secret must error, not mint a new key")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewCrypterWithPublicationDirPublishesThroughIt(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
secretPath := filepath.Join(dir, "tokens.json.secret")
|
||||
pubDir := secretPath + ".publish"
|
||||
c := NewCrypterWithPublicationDir(secretPath, pubDir)
|
||||
if _, err := c.Seal([]byte("payload")); err != nil {
|
||||
t.Fatalf("Seal: %v", err)
|
||||
}
|
||||
if info, err := os.Stat(secretPath); err != nil || info.Size() != secretBytes {
|
||||
t.Fatalf("secret = %v err=%v", info, err)
|
||||
}
|
||||
entries, err := os.ReadDir(pubDir)
|
||||
if err != nil {
|
||||
t.Fatalf("publication dir: %v", err)
|
||||
}
|
||||
if len(entries) != 0 {
|
||||
t.Fatalf("publication dir left temp files: %v", entries)
|
||||
}
|
||||
if entries, err := os.ReadDir(dir); err != nil {
|
||||
t.Fatalf("parent dir: %v", err)
|
||||
} else {
|
||||
for _, entry := range entries {
|
||||
if entry.Name() != "tokens.json.secret" && entry.Name() != filepath.Base(pubDir) {
|
||||
t.Fatalf("unexpected parent-dir entry %q; secret must publish through the protected dir", entry.Name())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecretCreationSyncsFileAndDirectory(t *testing.T) {
|
||||
secretPath := filepath.Join(t.TempDir(), "k.secret")
|
||||
origFile, origDir := syncSecretFileFn, syncSecretDirFn
|
||||
t.Cleanup(func() {
|
||||
syncSecretFileFn, syncSecretDirFn = origFile, origDir
|
||||
})
|
||||
fileSynced, dirSynced := false, false
|
||||
syncSecretFileFn = func(file *os.File) error {
|
||||
fileSynced = true
|
||||
return origFile(file)
|
||||
}
|
||||
syncSecretDirFn = func(dir string) error {
|
||||
dirSynced = true
|
||||
return origDir(dir)
|
||||
}
|
||||
if _, err := createSecretFile(secretPath, ""); err != nil {
|
||||
t.Fatalf("createSecretFile: %v", err)
|
||||
}
|
||||
if !fileSynced {
|
||||
t.Fatal("secret temp file was not synced before rename")
|
||||
}
|
||||
if !dirSynced {
|
||||
t.Fatal("secret destination directory was not synced after rename")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSecretDirectorySyncFailureLeavesPublishedSecret pins the post-rename
|
||||
// path: a destination-directory barrier failure reports an error but the
|
||||
// renamed secret is already there and a later caller can load it.
|
||||
func TestSecretDirectorySyncFailureLeavesPublishedSecret(t *testing.T) {
|
||||
secretPath := filepath.Join(t.TempDir(), "k.secret")
|
||||
origDir := syncSecretDirFn
|
||||
t.Cleanup(func() { syncSecretDirFn = origDir })
|
||||
syncErr := errors.New("directory sync failed")
|
||||
syncSecretDirFn = func(string) error { return syncErr }
|
||||
|
||||
if _, err := createSecretFile(secretPath, ""); !errors.Is(err, syncErr) {
|
||||
t.Fatalf("createSecretFile error = %v, want %v", err, syncErr)
|
||||
}
|
||||
published, err := os.ReadFile(secretPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read published secret: %v", err)
|
||||
}
|
||||
if len(published) != secretBytes {
|
||||
t.Fatalf("published secret size = %d, want %d", len(published), secretBytes)
|
||||
}
|
||||
loaded, err := createSecretFile(secretPath, "")
|
||||
if err != nil {
|
||||
t.Fatalf("later caller could not load the published secret: %v", err)
|
||||
}
|
||||
if !bytes.Equal(loaded, published) {
|
||||
t.Fatal("later caller did not reuse the published secret")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRestoreReclaimedLockDoesNotReplaceNewerLock covers the reclaim race: a
|
||||
// lock that looked stale can be replaced before the reclaimer renames it. If a
|
||||
// third holder then creates lockPath, restoring the quarantined lock must not
|
||||
// replace that newer lock.
|
||||
func TestRestoreReclaimedLockDoesNotReplaceNewerLock(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
lockPath := filepath.Join(dir, "k.secret.lock")
|
||||
reclaimed := lockPath + ".stale.crashed"
|
||||
if err := os.WriteFile(reclaimed, []byte("quarantined-token"), 0o600); err != nil {
|
||||
t.Fatalf("write quarantined lock: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(lockPath, []byte("new-holder-token"), 0o600); err != nil {
|
||||
t.Fatalf("write newer lock: %v", err)
|
||||
}
|
||||
|
||||
if err := restoreReclaimedLock(reclaimed, lockPath); !errors.Is(err, os.ErrExist) {
|
||||
t.Fatalf("restoreReclaimedLock error = %v, want %v", err, os.ErrExist)
|
||||
}
|
||||
data, err := os.ReadFile(lockPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read newer lock: %v", err)
|
||||
}
|
||||
if string(data) != "new-holder-token" {
|
||||
t.Fatalf("newer lock was replaced: %q", data)
|
||||
}
|
||||
if _, err := os.Stat(reclaimed); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("quarantined lock was not discarded: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestoreReclaimedLockRestoresWhenDestinationIsFree(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
lockPath := filepath.Join(dir, "k.secret.lock")
|
||||
reclaimed := lockPath + ".stale.crashed"
|
||||
if err := os.WriteFile(reclaimed, []byte("fresh-token"), 0o600); err != nil {
|
||||
t.Fatalf("write quarantined lock: %v", err)
|
||||
}
|
||||
|
||||
if err := restoreReclaimedLock(reclaimed, lockPath); err != nil {
|
||||
t.Fatalf("restoreReclaimedLock: %v", err)
|
||||
}
|
||||
data, err := os.ReadFile(lockPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read restored lock: %v", err)
|
||||
}
|
||||
if string(data) != "fresh-token" {
|
||||
t.Fatalf("restored lock = %q", data)
|
||||
}
|
||||
if _, err := os.Stat(reclaimed); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("quarantined lock was not discarded: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecretSyncFailureLeavesNoSecretAndRetrySucceeds(t *testing.T) {
|
||||
secretPath := filepath.Join(t.TempDir(), "k.secret")
|
||||
origFile := syncSecretFileFn
|
||||
t.Cleanup(func() { syncSecretFileFn = origFile })
|
||||
syncErr := errors.New("sync failed")
|
||||
syncSecretFileFn = func(*os.File) error { return syncErr }
|
||||
if _, err := createSecretFile(secretPath, ""); !errors.Is(err, syncErr) {
|
||||
t.Fatalf("createSecretFile error = %v, want %v", err, syncErr)
|
||||
}
|
||||
if _, err := os.Stat(secretPath); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("failed publish left a secret: %v", err)
|
||||
}
|
||||
syncSecretFileFn = origFile
|
||||
if _, err := createSecretFile(secretPath, ""); err != nil {
|
||||
t.Fatalf("retry after sync failure: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Twigpine/zero/internal/execution"
|
||||
)
|
||||
|
||||
const appliedNotice = "denyRead is configured, so the write jail is not confining writes"
|
||||
|
||||
// THE PLAN IS NOT THE APPLICATION.
|
||||
//
|
||||
// addSandboxMeta writes the plan's notices at plan time, before anything runs,
|
||||
// so promoting them into the user-visible disclosure unconditionally claims a
|
||||
// token trade for a command that may never have started. The execution outcome
|
||||
// is the thing that knows whether a process existed, and it applies the same
|
||||
// launched-and-planned rule hooks and plugins use.
|
||||
func TestCommandNoticesFollowAppliedExecutionState(t *testing.T) {
|
||||
planned := map[string]string{sandboxNoticesMeta: appliedNotice}
|
||||
|
||||
t.Run("launched", func(t *testing.T) {
|
||||
outcome := execution.Outcome{
|
||||
Kind: execution.OutcomeSuccess,
|
||||
Launched: true,
|
||||
Enforcement: execution.Enforcement{Notices: []string{appliedNotice}},
|
||||
}
|
||||
got := finalizeToolOutcome(Result{
|
||||
Status: StatusOK, Output: "ran", Meta: planned, ExecutionOutcome: &outcome,
|
||||
}, "ran")
|
||||
if len(got.EnforcementNotices) != 1 {
|
||||
t.Fatalf("a launched command lost its disclosure: %#v", got.EnforcementNotices)
|
||||
}
|
||||
if !strings.Contains(got.ModelOutput(), appliedNotice) {
|
||||
t.Errorf("the model view does not carry it: %q", got.ModelOutput())
|
||||
}
|
||||
})
|
||||
|
||||
// A command that ran and then FAILED still ran under the token it was given,
|
||||
// and a failure is when the reader most needs to know that. Only success was
|
||||
// pinned, so a rule keyed on the result status rather than on the launch would
|
||||
// have passed everything above.
|
||||
t.Run("launched and failed", func(t *testing.T) {
|
||||
outcome := execution.Outcome{
|
||||
Kind: execution.OutcomeApplicationFailure,
|
||||
Launched: true,
|
||||
Enforcement: execution.Enforcement{Notices: []string{appliedNotice}},
|
||||
}
|
||||
got := finalizeToolOutcome(Result{
|
||||
Status: StatusError, Output: "exit status 1: permission denied", Meta: planned, ExecutionOutcome: &outcome,
|
||||
Display: Display{Summary: "command failed", Kind: "shell"},
|
||||
}, "exit status 1: permission denied")
|
||||
if len(got.EnforcementNotices) != 1 {
|
||||
t.Fatalf("a command that ran and failed lost its disclosure: %#v", got.EnforcementNotices)
|
||||
}
|
||||
model := got.ModelOutput()
|
||||
for _, want := range []string{appliedNotice, "permission denied"} {
|
||||
if !strings.Contains(model, want) {
|
||||
t.Errorf("the model view of a failed command is missing %q:\n%s", want, model)
|
||||
}
|
||||
}
|
||||
summary := got.HumanDisplay().Summary
|
||||
for _, want := range []string{appliedNotice, "command failed"} {
|
||||
if !strings.Contains(summary, want) {
|
||||
t.Errorf("the display of a failed command is missing %q: %q", want, summary)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("never launched", func(t *testing.T) {
|
||||
outcome := execution.Outcome{
|
||||
Kind: execution.OutcomeSandboxSetupFailure,
|
||||
Launched: false,
|
||||
Enforcement: execution.Enforcement{Notices: []string{appliedNotice}},
|
||||
}
|
||||
got := finalizeToolOutcome(Result{
|
||||
Status: StatusError, Output: "could not start", Meta: planned, ExecutionOutcome: &outcome,
|
||||
}, "could not start")
|
||||
if len(got.EnforcementNotices) != 0 {
|
||||
t.Fatalf("a command that never started claimed a token trade: %#v", got.EnforcementNotices)
|
||||
}
|
||||
if strings.Contains(got.ModelOutput(), appliedNotice) {
|
||||
t.Errorf("the model view claims it anyway: %q", got.ModelOutput())
|
||||
}
|
||||
})
|
||||
|
||||
// The plan metadata is diagnostics and stays put either way, so the record of
|
||||
// what was intended is not lost with the claim about what happened.
|
||||
t.Run("metadata survives", func(t *testing.T) {
|
||||
outcome := execution.Outcome{Kind: execution.OutcomeSandboxSetupFailure, Launched: false}
|
||||
got := finalizeToolOutcome(Result{
|
||||
Status: StatusError, Output: "x", Meta: planned, ExecutionOutcome: &outcome,
|
||||
}, "x")
|
||||
if got.Meta[sandboxNoticesMeta] != appliedNotice {
|
||||
t.Errorf("the planned notice was erased from diagnostics: %q", got.Meta[sandboxNoticesMeta])
|
||||
}
|
||||
})
|
||||
|
||||
// A tool with no execution outcome at all still promotes from metadata, so
|
||||
// this did not silently drop disclosure for a path that has no outcome.
|
||||
t.Run("no execution outcome", func(t *testing.T) {
|
||||
got := finalizeToolOutcome(Result{Status: StatusOK, Output: "x", Meta: planned}, "x")
|
||||
if len(got.EnforcementNotices) != 1 {
|
||||
t.Errorf("a tool without an execution outcome lost its disclosure: %#v", got.EnforcementNotices)
|
||||
}
|
||||
})
|
||||
}
|
||||
+27
-5
@@ -164,8 +164,22 @@ func (tool bashTool) run(ctx context.Context, args map[string]any, engine *zeroS
|
||||
// A no-op when MonitorTag is empty, so the default path is unchanged.
|
||||
monitor := zeroSandbox.StartDenialMonitor(context.Background(), plan.MonitorTag)
|
||||
err = command.Run()
|
||||
// OBSERVED HERE, at the only boundary that knows. exec.Cmd sets Process
|
||||
// only once os.StartProcess has succeeded, so this separates a child that
|
||||
// ran from a pre-start failure (missing executable, context already
|
||||
// cancelled) that Run reports the same way. Every branch below hands this
|
||||
// to withBashExecution rather than letting the conversion assume it.
|
||||
launched := command.Process != nil
|
||||
exitCode := commandExitCode(err)
|
||||
adapterReport, reportErr := plan.ExecutionReport()
|
||||
// AND FOR A WRAPPED PLAN THAT OBSERVATION IS OF THE WRAPPER. On Windows the
|
||||
// command started here is the sandbox helper; it creates the requested child
|
||||
// only after marker, ACL, network, SID and token setup, any of which can fail
|
||||
// with the helper already running. Reading the report was not enough on its
|
||||
// own: the launch decision has to consume it, or bash promotes the planned
|
||||
// DenyRead notice for a command that never ran under that enforcement. Same
|
||||
// resolution the captured runner uses, so the two cannot drift.
|
||||
launched = execution.ResolveChildLaunched(launched, plan.ChildLaunchOwnedByAdapter(), adapterReport)
|
||||
meta["exit_code"] = strconv.Itoa(exitCode)
|
||||
stdoutText := stdout.retained()
|
||||
stderrRetained := stderr.retained()
|
||||
@@ -180,7 +194,7 @@ func (tool bashTool) run(ctx context.Context, args map[string]any, engine *zeroS
|
||||
Output: fmt.Sprintf("Error: Command timed out after %dms.", timeoutMS),
|
||||
Meta: meta,
|
||||
}
|
||||
return withBashExecution(result, executionRequest, plan, exitCode, adapterReport, reportErr, changeObserver.Changes(), true)
|
||||
return withBashExecution(result, executionRequest, plan, exitCode, adapterReport, reportErr, launched, changeObserver.Changes(), true)
|
||||
}
|
||||
if err != nil {
|
||||
if exitCode < 0 {
|
||||
@@ -189,7 +203,7 @@ func (tool bashTool) run(ctx context.Context, args map[string]any, engine *zeroS
|
||||
Output: "Error executing command: " + err.Error(),
|
||||
Meta: meta,
|
||||
}
|
||||
return withBashExecution(result, executionRequest, plan, exitCode, adapterReport, reportErr, changeObserver.Changes(), false)
|
||||
return withBashExecution(result, executionRequest, plan, exitCode, adapterReport, reportErr, launched, changeObserver.Changes(), false)
|
||||
}
|
||||
if adapterReport.Denial != nil {
|
||||
markStructuredSandboxDenial(meta, *adapterReport.Denial)
|
||||
@@ -201,7 +215,7 @@ func (tool bashTool) run(ctx context.Context, args map[string]any, engine *zeroS
|
||||
Truncated: truncated,
|
||||
Meta: meta,
|
||||
}
|
||||
return withBashExecution(result, executionRequest, plan, exitCode, adapterReport, reportErr, changeObserver.Changes(), false)
|
||||
return withBashExecution(result, executionRequest, plan, exitCode, adapterReport, reportErr, launched, changeObserver.Changes(), false)
|
||||
}
|
||||
|
||||
if adapterReport.Denial != nil {
|
||||
@@ -214,12 +228,13 @@ func (tool bashTool) run(ctx context.Context, args map[string]any, engine *zeroS
|
||||
Truncated: truncated,
|
||||
Meta: meta,
|
||||
}
|
||||
return withBashExecution(result, executionRequest, plan, exitCode, adapterReport, reportErr, changeObserver.Changes(), false)
|
||||
return withBashExecution(result, executionRequest, plan, exitCode, adapterReport, reportErr, launched, changeObserver.Changes(), false)
|
||||
}
|
||||
|
||||
func withBashExecution(result Result, request execution.Request, plan zeroSandbox.CommandPlan, exitCode int, report execution.AdapterReport, reportErr error, changes []execution.Change, timedOut bool) Result {
|
||||
func withBashExecution(result Result, request execution.Request, plan zeroSandbox.CommandPlan, exitCode int, report execution.AdapterReport, reportErr error, launched bool, changes []execution.Change, timedOut bool) Result {
|
||||
input := execToolResultInput{
|
||||
exited: true,
|
||||
launched: launched,
|
||||
exitCode: exitCode,
|
||||
enforcement: executionEnforcement(plan),
|
||||
request: request,
|
||||
@@ -349,6 +364,13 @@ func addSandboxMeta(meta map[string]string, plan zeroSandbox.CommandPlan) {
|
||||
if plan.DowngradeReason != "" {
|
||||
meta["sandbox_downgrade_reason"] = plan.DowngradeReason
|
||||
}
|
||||
// Least-privilege notices for the command actually being run, on the same
|
||||
// channel as the downgrade reason. Without this the DenyRead write-jail
|
||||
// trade was visible only to `zero sandbox policy` and `zero sandbox check`,
|
||||
// so an operator could approve it per command and never be told.
|
||||
if len(plan.Notes) > 0 {
|
||||
meta["sandbox_notices"] = strings.Join(plan.Notes, "\n")
|
||||
}
|
||||
meta["sandbox_requires_platform"] = strconv.FormatBool(plan.RequiresPlatformSandbox)
|
||||
if plan.Backend.Message != "" {
|
||||
meta["sandbox_message"] = plan.Backend.Message
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// THE DISCLOSURE FOLLOWS THE PROCESS, AND BASH IS THE PATH THAT PROVES IT.
|
||||
//
|
||||
// execExecutionOutcome is shared between exec_command and bash. It used to set
|
||||
// Launched unconditionally, which is true for exec_command because a start
|
||||
// failure returns an errorResult before an execution outcome is ever built.
|
||||
// bash is different: it hands EVERY Run error to the same conversion, including
|
||||
// a missing executable and a context cancelled before os.StartProcess. Those
|
||||
// have a prepared plan, and therefore planned notices, but no child, so the
|
||||
// hard-coded launch state turned a plan into a claim that reduced enforcement
|
||||
// had actually been applied.
|
||||
//
|
||||
// These drive the real tool rather than constructing an outcome, because the
|
||||
// bug was precisely that the constructed shape and the real one disagreed.
|
||||
func TestBashOutcomeCarriesTheRealLaunchState(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
tool := NewScopedBashTool(root, nil)
|
||||
|
||||
t.Run("a command whose executable does not exist never launched", func(t *testing.T) {
|
||||
res := tool.Run(context.Background(), map[string]any{
|
||||
"command": "zero-nonexistent-binary-for-launch-state-test --please-fail",
|
||||
})
|
||||
if res.ExecutionOutcome == nil {
|
||||
t.Fatal("no execution outcome recorded")
|
||||
}
|
||||
// The shell itself starts and reports "command not found", so this asserts
|
||||
// the contract rather than a specific errno: whatever the platform did,
|
||||
// the notice must agree with whether a process was created.
|
||||
if got := len(res.ExecutionOutcome.AppliedEnforcementNotices()); got > 0 && !res.ExecutionOutcome.Launched {
|
||||
t.Errorf("a command that never launched disclosed %d enforcement notices", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a context cancelled before start never launched", func(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
res := tool.Run(ctx, map[string]any{"command": "echo hi"})
|
||||
if res.ExecutionOutcome == nil {
|
||||
t.Skip("this platform produced no execution outcome for a pre-cancelled run")
|
||||
}
|
||||
if res.ExecutionOutcome.Launched {
|
||||
t.Error("a run cancelled before start reported a launched child")
|
||||
}
|
||||
if got := res.ExecutionOutcome.AppliedEnforcementNotices(); len(got) != 0 {
|
||||
t.Errorf("a run cancelled before start claimed an enforcement trade: %v", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("an ordinary command that runs does launch", func(t *testing.T) {
|
||||
res := tool.Run(context.Background(), map[string]any{"command": "echo hello"})
|
||||
if res.ExecutionOutcome == nil {
|
||||
t.Fatal("no execution outcome recorded")
|
||||
}
|
||||
if !res.ExecutionOutcome.Launched {
|
||||
t.Error("a command that ran was not recorded as launched")
|
||||
}
|
||||
if !strings.Contains(res.Output, "hello") {
|
||||
t.Errorf("unexpected output: %q", res.Output)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package tools
|
||||
|
||||
import "testing"
|
||||
|
||||
// WithoutEnforcementNotices exists for readers of stored results, which hold
|
||||
// the text with the disclosure composed in AND the same notices as a typed
|
||||
// field. The property that matters is that composing after it always yields
|
||||
// exactly what composing once would have: whether the stored text had been
|
||||
// decorated or not, the reader ends up with one disclosure.
|
||||
func TestWithoutEnforcementNoticesUndoesExactlyOneComposition(t *testing.T) {
|
||||
notices := []string{"least-privilege notice: read access was narrowed", "network access was denied"}
|
||||
for _, base := range []string{"the command output", "", " ", "two\n\nparagraphs of output", "least-privilege notice: quoted in the output itself"} {
|
||||
once := WithEnforcementNotices(base, notices)
|
||||
|
||||
// Stored decorated, the shape every writer produces.
|
||||
if got := WithEnforcementNotices(WithoutEnforcementNotices(once, notices), notices); got != once {
|
||||
t.Errorf("decorated %q: recomposed to %q, want %q", base, got, once)
|
||||
}
|
||||
// Stored undecorated: nothing to take off, and still one disclosure.
|
||||
if got := WithEnforcementNotices(WithoutEnforcementNotices(base, notices), notices); got != once {
|
||||
t.Errorf("undecorated %q: recomposed to %q, want %q", base, got, once)
|
||||
}
|
||||
}
|
||||
|
||||
// No notices, or only blank ones, means there was never a composition.
|
||||
for _, none := range [][]string{nil, {}, {"", " "}} {
|
||||
if got := WithoutEnforcementNotices("plain output", none); got != "plain output" {
|
||||
t.Errorf("notices %q changed undecorated text to %q", none, got)
|
||||
}
|
||||
}
|
||||
|
||||
// Only a leading copy is a composition. The same words further down are the
|
||||
// command's own output and stay where they are.
|
||||
text := "first line\n\n" + notices[0] + "\n" + notices[1] + "\n\ntail"
|
||||
if got := WithoutEnforcementNotices(text, notices); got != text {
|
||||
t.Errorf("a notice quoted inside the output was removed: %q", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package tools
|
||||
|
||||
import "testing"
|
||||
|
||||
// THE BUDGET HAS TO COUNT WHAT THE MODEL ACTUALLY RECEIVES.
|
||||
//
|
||||
// The enforcement notices are prepended to the model view on the way out, so a
|
||||
// disclosed call costs more context than result.Output alone. Measuring the bare
|
||||
// output undercounts every one of them, and the undercount grows with the notice
|
||||
// rather than being a fixed slack.
|
||||
func TestOutcomeMeasuresTheNoticesTheModelReceives(t *testing.T) {
|
||||
const notice = "denyRead is configured, so the Windows sandbox uses the token shape without WRITE_RESTRICTED (#869)"
|
||||
const output = "exit status 0"
|
||||
|
||||
bare := finalizeToolOutcome(Result{Status: StatusOK, Output: output}, output)
|
||||
disclosed := finalizeToolOutcome(Result{Status: StatusOK, Output: output, EnforcementNotices: []string{notice}}, output)
|
||||
|
||||
if disclosed.Outcome.Diagnostics.ModelBytes <= bare.Outcome.Diagnostics.ModelBytes {
|
||||
t.Errorf("a disclosed result measured %d model bytes, no more than the undisclosed %d, so the notice is uncounted",
|
||||
disclosed.Outcome.Diagnostics.ModelBytes, bare.Outcome.Diagnostics.ModelBytes)
|
||||
}
|
||||
if want := len(WithEnforcementNotices(output, []string{notice})); disclosed.Outcome.Diagnostics.ModelBytes != want {
|
||||
t.Errorf("model bytes = %d, want %d (the canonical output the model is handed)",
|
||||
disclosed.Outcome.Diagnostics.ModelBytes, want)
|
||||
}
|
||||
if disclosed.Outcome.Diagnostics.EstimatedModelTokens <= bare.Outcome.Diagnostics.EstimatedModelTokens {
|
||||
t.Errorf("estimated model tokens did not grow with the notice: %d vs %d",
|
||||
disclosed.Outcome.Diagnostics.EstimatedModelTokens, bare.Outcome.Diagnostics.EstimatedModelTokens)
|
||||
}
|
||||
}
|
||||
|
||||
// And the stored view stays bare, or the notices ship twice: ModelOutput
|
||||
// prepends them to whatever ModelView holds.
|
||||
func TestOutcomeModelViewDoesNotCarryTheNoticesItself(t *testing.T) {
|
||||
const notice = "sandbox notice"
|
||||
const output = "exit status 0"
|
||||
|
||||
result := finalizeToolOutcome(Result{Status: StatusOK, Output: output, EnforcementNotices: []string{notice}}, output)
|
||||
if result.Outcome.ModelView != output {
|
||||
t.Errorf("ModelView = %q, want the bare output %q; ModelOutput prepends the notice, so storing it here sends it twice",
|
||||
result.Outcome.ModelView, output)
|
||||
}
|
||||
}
|
||||
@@ -221,6 +221,9 @@ func (tool execCommandTool) run(ctx context.Context, args map[string]any, engine
|
||||
Command: command,
|
||||
Enforcement: executionEnforcement(plan),
|
||||
Report: plan.ExecutionReport,
|
||||
// A wrapped plan starts a helper; the requested child is created inside
|
||||
// it and only the adapter sees that transition.
|
||||
ChildLaunchOwnedByAdapter: plan.ChildLaunchOwnedByAdapter(),
|
||||
Cleanup: func() {
|
||||
plan.Cleanup()
|
||||
cancel()
|
||||
@@ -246,18 +249,14 @@ func (tool execCommandTool) run(ctx context.Context, args map[string]any, engine
|
||||
exitCode: processResult.ExitCode, exited: processResult.Exited, relativeCwd: processResult.RelativeCwd,
|
||||
tty: processResult.TTY, request: processResult.Request, enforcement: processResult.Enforcement,
|
||||
report: processResult.Report, reportErr: processResult.ReportErr, changes: processResult.Changes,
|
||||
sandboxMeta: processResult.Metadata,
|
||||
maxOutputTokens: maxOutputTokens,
|
||||
childLaunchOwnedByAdapter: processResult.ChildLaunchOwnedByAdapter,
|
||||
sandboxMeta: processResult.Metadata,
|
||||
maxOutputTokens: maxOutputTokens,
|
||||
}, directBudget)
|
||||
}
|
||||
|
||||
func executionEnforcement(plan zeroSandbox.CommandPlan) execution.Enforcement {
|
||||
return execution.Enforcement{
|
||||
Backend: string(plan.TargetBackend),
|
||||
Level: string(plan.EnforcementLevel),
|
||||
Degraded: plan.EnforcementLevel == zeroSandbox.EnforcementDegraded,
|
||||
DowngradeReason: plan.DowngradeReason,
|
||||
}
|
||||
return zeroSandbox.EnforcementFor(plan)
|
||||
}
|
||||
|
||||
type writeStdinTool struct {
|
||||
@@ -388,7 +387,8 @@ func (tool writeStdinTool) RunWithOptions(ctx context.Context, args map[string]a
|
||||
exitCode: processResult.ExitCode, exited: processResult.Exited, relativeCwd: processResult.RelativeCwd,
|
||||
tty: processResult.TTY, interrupted: processResult.Interrupted, request: processResult.Request,
|
||||
enforcement: processResult.Enforcement, report: processResult.Report, reportErr: processResult.ReportErr,
|
||||
changes: processResult.Changes, sandboxMeta: processResult.Metadata,
|
||||
childLaunchOwnedByAdapter: processResult.ChildLaunchOwnedByAdapter,
|
||||
changes: processResult.Changes, sandboxMeta: processResult.Metadata,
|
||||
maxOutputTokens: maxOutputTokens,
|
||||
})
|
||||
}
|
||||
@@ -425,16 +425,25 @@ type execToolResultInput struct {
|
||||
sessionID int
|
||||
exitCode int
|
||||
exited bool
|
||||
relativeCwd string
|
||||
tty bool
|
||||
interrupted bool
|
||||
request execution.Request
|
||||
enforcement execution.Enforcement
|
||||
sandboxMeta map[string]string
|
||||
report execution.AdapterReport
|
||||
reportErr error
|
||||
changes []execution.Change
|
||||
maxOutputTokens int
|
||||
// launched records whether an OS process was actually created, observed at
|
||||
// the boundary that ran it rather than assumed from the outcome shape. The
|
||||
// exec_command paths set it true because a start failure returns an
|
||||
// errorResult before reaching here; bash cannot, because it routes a
|
||||
// pre-start Run error through the same conversion.
|
||||
launched bool
|
||||
// childLaunchOwnedByAdapter marks a wrapped plan, where launched above
|
||||
// describes the helper rather than the requested process.
|
||||
childLaunchOwnedByAdapter bool
|
||||
relativeCwd string
|
||||
tty bool
|
||||
interrupted bool
|
||||
request execution.Request
|
||||
enforcement execution.Enforcement
|
||||
sandboxMeta map[string]string
|
||||
report execution.AdapterReport
|
||||
reportErr error
|
||||
changes []execution.Change
|
||||
maxOutputTokens int
|
||||
}
|
||||
|
||||
func execToolResult(input execToolResultInput) Result {
|
||||
@@ -454,6 +463,15 @@ func execToolResultWithBudget(input execToolResultInput, directBudget bool) Resu
|
||||
for key, value := range input.sandboxMeta {
|
||||
meta[key] = value
|
||||
}
|
||||
// A process started here by construction, because a start failure returns an
|
||||
// errorResult above without building an execution outcome. But for a wrapped
|
||||
// plan that process is the SANDBOX HELPER, which creates the requested child
|
||||
// only after marker, ACL, network, SID and token setup. So hand the observation
|
||||
// to the same resolution every other launcher uses instead of asserting it.
|
||||
// The retained path matters most here: the helper can be returned before it
|
||||
// has attempted the inner launch, and an absent report then means not yet
|
||||
// launched rather than launched.
|
||||
input.launched = execution.ResolveChildLaunched(true, input.childLaunchOwnedByAdapter, input.report)
|
||||
outcome := execExecutionOutcome(input)
|
||||
if input.exited {
|
||||
meta["exit_code"] = strconv.Itoa(input.exitCode)
|
||||
@@ -548,29 +566,40 @@ func execExecutionRequest(command *exec.Cmd, plan zeroSandbox.CommandPlan, cwd s
|
||||
|
||||
func execExecutionOutcome(input execToolResultInput) execution.Outcome {
|
||||
enforcement := input.enforcement
|
||||
// EVERY OUTCOME BUILT HERE DESCRIBES A PROCESS THAT STARTED. A command that
|
||||
// could not be started returns an error result before this point, so there is
|
||||
// no path in without a process behind it. Stated rather than inferred, so the
|
||||
// disclosure derived from it does not rest on the terminal outcome kind.
|
||||
// READ, not assumed. This used to be a const true, documented as safe
|
||||
// because exec_command returns early on a start failure. That holds for
|
||||
// exec_command and not for bash, which hands every Run error to this same
|
||||
// conversion, so a command whose executable did not exist claimed the
|
||||
// DenyRead token trade had been applied.
|
||||
launched := input.launched
|
||||
if !input.exited {
|
||||
return execution.Outcome{
|
||||
State: execution.StateRetained,
|
||||
Kind: execution.OutcomeRunning,
|
||||
Launched: launched,
|
||||
ProcessID: strconv.Itoa(input.sessionID),
|
||||
Enforcement: enforcement,
|
||||
}
|
||||
}
|
||||
exit := &execution.Exit{Code: input.exitCode}
|
||||
if input.reportErr != nil {
|
||||
return execution.Outcome{State: execution.StateFailed, Kind: execution.OutcomeSandboxSetupFailure, Exit: exit, Enforcement: enforcement, Changes: input.changes}
|
||||
return execution.Outcome{State: execution.StateFailed, Kind: execution.OutcomeSandboxSetupFailure, Launched: launched, Exit: exit, Enforcement: enforcement, Changes: input.changes}
|
||||
}
|
||||
if input.report.Denial != nil {
|
||||
denial := *input.report.Denial
|
||||
return execution.Outcome{State: execution.StateDenied, Kind: execution.OutcomeEnforcementDenied, Exit: exit, Denial: &denial, Enforcement: enforcement, Changes: input.changes}
|
||||
return execution.Outcome{State: execution.StateDenied, Kind: execution.OutcomeEnforcementDenied, Launched: launched, Exit: exit, Denial: &denial, Enforcement: enforcement, Changes: input.changes}
|
||||
}
|
||||
if input.interrupted {
|
||||
return execution.Outcome{State: execution.StateCancelled, Kind: execution.OutcomeCancelled, Exit: exit, Enforcement: enforcement, Changes: input.changes}
|
||||
return execution.Outcome{State: execution.StateCancelled, Kind: execution.OutcomeCancelled, Launched: launched, Exit: exit, Enforcement: enforcement, Changes: input.changes}
|
||||
}
|
||||
if input.exitCode == 0 {
|
||||
return execution.Outcome{State: execution.StateCompleted, Kind: execution.OutcomeSuccess, Exit: exit, Enforcement: enforcement, Changes: input.changes}
|
||||
return execution.Outcome{State: execution.StateCompleted, Kind: execution.OutcomeSuccess, Launched: launched, Exit: exit, Enforcement: enforcement, Changes: input.changes}
|
||||
}
|
||||
return execution.Outcome{State: execution.StateFailed, Kind: execution.OutcomeApplicationFailure, Exit: exit, Enforcement: enforcement, Changes: input.changes}
|
||||
return execution.Outcome{State: execution.StateFailed, Kind: execution.OutcomeApplicationFailure, Launched: launched, Exit: exit, Enforcement: enforcement, Changes: input.changes}
|
||||
}
|
||||
|
||||
func executionChangedFiles(changes []execution.Change) []string {
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/Twigpine/zero/internal/execution"
|
||||
)
|
||||
|
||||
// THE LAUNCH FACT HAS TO SURVIVE EVERY RESULT SHAPE, NOT JUST THE CAPTURED ONE.
|
||||
//
|
||||
// exec_command builds its outcome through its own conversion rather than through
|
||||
// Runner.ExecuteCaptured, and that conversion used to assert `launched = true` on
|
||||
// the grounds that a start failure returns earlier. That reasoning holds for the
|
||||
// process the tool starts, and on Windows a wrapped plan starts the sandbox
|
||||
// helper: the requested child is created inside it, after marker, ACL, network,
|
||||
// SID and token setup, any of which can fail with the helper already running.
|
||||
// Asserting the launch there tells the operator that reads were denied in
|
||||
// exchange for the write jail when nothing ran under that enforcement.
|
||||
//
|
||||
// The retained shape is the one worth pinning hardest: exec_command can return a
|
||||
// running session before the helper has even attempted the inner launch, so an
|
||||
// absent report there means "not yet", not "yes".
|
||||
func TestExecOutcomeTakesTheLaunchFactFromTheAdapter(t *testing.T) {
|
||||
yes, no := true, false
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
owned bool
|
||||
report execution.AdapterReport
|
||||
exited bool
|
||||
want bool
|
||||
because string
|
||||
}{
|
||||
{
|
||||
name: "wrapped helper failed before creating the child",
|
||||
owned: true, report: execution.AdapterReport{ChildLaunched: &no}, exited: true,
|
||||
want: false, because: "only the unsandboxed helper ran",
|
||||
},
|
||||
{
|
||||
name: "wrapped plan, adapter said nothing",
|
||||
owned: true, report: execution.AdapterReport{}, exited: true,
|
||||
want: false, because: "an absent report is not proof that enforcement applied",
|
||||
},
|
||||
{
|
||||
name: "wrapped plan still running, nothing reported yet",
|
||||
owned: true, report: execution.AdapterReport{}, exited: false,
|
||||
want: false, because: "the helper can be returned before it attempts the inner launch",
|
||||
},
|
||||
{
|
||||
name: "wrapped plan, restricted child confirmed",
|
||||
owned: true, report: execution.AdapterReport{ChildLaunched: &yes}, exited: true,
|
||||
want: true, because: "the adapter saw the transition",
|
||||
},
|
||||
{
|
||||
name: "direct command keeps its own observation",
|
||||
owned: false, report: execution.AdapterReport{}, exited: true,
|
||||
want: true, because: "the process the tool started is the requested one",
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range cases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
input := execToolResultInput{
|
||||
commandText: "echo hi",
|
||||
sessionID: 7,
|
||||
exited: testCase.exited,
|
||||
report: testCase.report,
|
||||
childLaunchOwnedByAdapter: testCase.owned,
|
||||
enforcement: execution.Enforcement{Notices: []string{"denyRead is configured, so the write jail is not confining writes"}},
|
||||
}
|
||||
// Through the PRODUCTION conversion, which is what decides the launch
|
||||
// state. Computing it here instead would pin the shared helper and prove
|
||||
// nothing about whether exec_command consults it.
|
||||
result := execToolResult(input)
|
||||
outcome := result.ExecutionOutcome
|
||||
if outcome == nil {
|
||||
t.Fatalf("SETUP INVALID: the conversion produced no execution outcome")
|
||||
}
|
||||
if outcome.Launched != testCase.want {
|
||||
t.Fatalf("Launched = %v, want %v: %s", outcome.Launched, testCase.want, testCase.because)
|
||||
}
|
||||
notices := outcome.AppliedEnforcementNotices()
|
||||
if testCase.want && len(notices) != 1 {
|
||||
t.Fatalf("a confirmed launch disclosed %q, want the planned notice exactly once", notices)
|
||||
}
|
||||
if !testCase.want && len(notices) != 0 {
|
||||
t.Fatalf("no requested child ran, but the outcome disclosed %q", notices)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// And the shared resolution itself, since three launchers now depend on it
|
||||
// answering the same way.
|
||||
func TestResolveChildLaunchedIsOneAnswerForEveryLauncher(t *testing.T) {
|
||||
yes, no := true, false
|
||||
cases := []struct {
|
||||
name string
|
||||
observed bool
|
||||
owned bool
|
||||
report execution.AdapterReport
|
||||
want bool
|
||||
}{
|
||||
{"adapter confirms over a false observation", false, true, execution.AdapterReport{ChildLaunched: &yes}, true},
|
||||
{"adapter denies over a true observation", true, true, execution.AdapterReport{ChildLaunched: &no}, false},
|
||||
{"owned and silent fails closed", true, true, execution.AdapterReport{}, false},
|
||||
{"unowned keeps the observation, true", true, false, execution.AdapterReport{}, true},
|
||||
{"unowned keeps the observation, false", false, false, execution.AdapterReport{}, false},
|
||||
{"an adapter may speak even when unowned", false, false, execution.AdapterReport{ChildLaunched: &yes}, true},
|
||||
}
|
||||
for _, testCase := range cases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
if got := execution.ResolveChildLaunched(testCase.observed, testCase.owned, testCase.report); got != testCase.want {
|
||||
t.Fatalf("ResolveChildLaunched(%v, %v, %+v) = %v, want %v",
|
||||
testCase.observed, testCase.owned, testCase.report, got, testCase.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -387,24 +387,24 @@ func scanGrepFile(ctx context.Context, resolvedRoot string, absolutePaths bool,
|
||||
|
||||
reader := bufio.NewReader(handle)
|
||||
lineNumber := 1
|
||||
sawLine := false
|
||||
lastEnded := false
|
||||
for {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
raw, ended, err := readRawLine(reader)
|
||||
// At EOF the scan stops where the line breaks stopped. A trailing
|
||||
// line break is the boundary that CLOSED the line it ends — the rule
|
||||
// the file tracker's countLines states for everything a reader
|
||||
// reports, and an empty file has no lines at all. Emitting at EOF
|
||||
// anyway handed every pattern that matches an empty string (anchors
|
||||
// among them: ^$, \b, x*) a phantom match on a line that does not
|
||||
// exist, numbered one past the real content.
|
||||
if err == io.EOF {
|
||||
if !sawLine || lastEnded {
|
||||
return emitGrepLine(matcher, fileLabel, lineNumber, nil, emit)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
sawLine = true
|
||||
lastEnded = ended
|
||||
line := trimTrailingCarriageReturns(trimLineBreak(raw, ended))
|
||||
if err := emitGrepLine(matcher, fileLabel, lineNumber, line, emit); err != nil {
|
||||
return err
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A LINE BREAK ENDS THE LINE IT TRAILS; IT DOES NOT OPEN THE ONE AFTER IT.
|
||||
//
|
||||
// The scan used to hand the matcher an empty line past the file's real content
|
||||
// at EOF, so every pattern that matches an empty string — the anchored ones ^$
|
||||
// and \b among them — reported a match on a line no reader sees, numbered one
|
||||
// past the real content, and count mode charged that phantom to its total.
|
||||
// read_file and the file tracker's countLines both report "x\n" as one line
|
||||
// and an empty file as none; the grep scan now stops with them.
|
||||
func TestGrepStopsAtTheLineBreaksTheFileHas(t *testing.T) {
|
||||
for name, testCase := range map[string]struct {
|
||||
content string
|
||||
pattern string
|
||||
want string
|
||||
}{
|
||||
"a trailing newline closes the line": {"x\n", `^$`, "content:No matches found."},
|
||||
"an unterminated last line stands alone": {"x", `^$`, "content:No matches found."},
|
||||
"an empty file has no lines": {"", `^$`, "content:No matches found."},
|
||||
"the middle empty line is the only empty one": {"x\n\nz\n", `^$`, "content:a.txt:2: "},
|
||||
"dot star does not run past the break": {"x\n", ".*", "content:a.txt:1: x"},
|
||||
"count stops at the trailing break": {"x\n", `^$`, "count:0 matches found"},
|
||||
"count charges only real lines": {"x\n\nz\n", `^$`, "count:1 matches found"},
|
||||
"count takes the trailing empty lines": {"z\n\n\n", `^$`, "count:2 matches found"},
|
||||
"an empty file counts nothing": {"", `^$`, "count:0 matches found"},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeTestFile(t, filepath.Join(root, "a.txt"), testCase.content)
|
||||
|
||||
mode, want, found := strings.Cut(testCase.want, ":")
|
||||
if !found {
|
||||
t.Fatalf("SETUP INVALID: %s carries no mode", testCase.want)
|
||||
}
|
||||
|
||||
result := NewScopedGrepTool(root, nil).Run(context.Background(), map[string]any{
|
||||
"pattern": testCase.pattern,
|
||||
"output_mode": mode,
|
||||
})
|
||||
if result.Status != StatusOK {
|
||||
t.Fatalf("grep status = %s: %s", result.Status, result.Output)
|
||||
}
|
||||
if result.Output != want {
|
||||
t.Fatalf("grep = %q, want %q", result.Output, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// THE LINE NUMBERS THE SCAN REPORTS ARE THE LINE NUMBERS read_file USES, and
|
||||
// neither of them runs past the breaks the content carries. The pattern also
|
||||
// matches an empty line, so the line the scan used to invent at EOF would
|
||||
// appear right beside the real one: numbering a real line wrong in the same
|
||||
// file would fail the read side first, and the phantom would fail as a match
|
||||
// the content does not carry.
|
||||
func TestGrepLineNumbersAgreeWithReadFile(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeTestFile(t, filepath.Join(root, "a.txt"), "one\nneedle two\nthree\n")
|
||||
|
||||
read := NewScopedReadFileTool(root, nil).Run(context.Background(), map[string]any{
|
||||
"path": "a.txt",
|
||||
})
|
||||
if read.Status != StatusOK || !strings.Contains(read.Output, "2→needle two") {
|
||||
t.Fatalf("SETUP INVALID: read_file does not number the needle line 2: %s", read.Output)
|
||||
}
|
||||
result := NewScopedGrepTool(root, nil).Run(context.Background(), map[string]any{
|
||||
"pattern": "needle|^$",
|
||||
})
|
||||
if result.Status != StatusOK {
|
||||
t.Fatalf("grep status = %s: %s", result.Status, result.Output)
|
||||
}
|
||||
if result.Output != "a.txt:2: needle two" {
|
||||
t.Fatalf("grep = %q, want exactly the line read_file numbers 2; an extra empty match is a line the content does not have, so the scan opened one past the trailing break", result.Output)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
zeroSandbox "github.com/Twigpine/zero/internal/sandbox"
|
||||
)
|
||||
|
||||
// THE DISCLOSURE HAS TO REACH THE OPERATOR, not just exist on the plan.
|
||||
//
|
||||
// The DenyRead write-jail trade was reachable only from BackendPlan, which
|
||||
// `zero sandbox policy` and `zero sandbox check` render. Someone approving
|
||||
// file_system.deny_read for a single command never runs those, so they lost the
|
||||
// write jail silently. addSandboxMeta is the boundary where a tool call's
|
||||
// sandbox facts become visible, alongside the downgrade reason that already
|
||||
// travels this way.
|
||||
func TestSandboxMetaCarriesLeastPrivilegeNotices(t *testing.T) {
|
||||
meta := map[string]string{}
|
||||
addSandboxMeta(meta, zeroSandbox.CommandPlan{
|
||||
Backend: zeroSandbox.Backend{Name: zeroSandbox.BackendWindowsRestrictedToken},
|
||||
Notes: []string{
|
||||
"denyRead is set, so the restricted token drops WRITE_RESTRICTED and the workspace write jail no longer confines writes outside it (#869).",
|
||||
},
|
||||
})
|
||||
|
||||
notices, ok := meta["sandbox_notices"]
|
||||
if !ok {
|
||||
t.Fatalf("no sandbox_notices in the tool result metadata, so the trade stays invisible to whoever approved it: %#v", meta)
|
||||
}
|
||||
for _, want := range []string{"denyRead", "#869"} {
|
||||
if !strings.Contains(notices, want) {
|
||||
t.Errorf("notice does not mention %q: %q", want, notices)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A plan with nothing to disclose must not add the key, or every command grows
|
||||
// an empty field and the presence of one stops meaning anything.
|
||||
func TestSandboxMetaOmitsNoticesWhenThereAreNone(t *testing.T) {
|
||||
meta := map[string]string{}
|
||||
addSandboxMeta(meta, zeroSandbox.CommandPlan{
|
||||
Backend: zeroSandbox.Backend{Name: zeroSandbox.BackendWindowsRestrictedToken},
|
||||
})
|
||||
if value, ok := meta["sandbox_notices"]; ok {
|
||||
t.Errorf("sandbox_notices present with nothing to say: %q", value)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
zeroSandbox "github.com/Twigpine/zero/internal/sandbox"
|
||||
)
|
||||
|
||||
const testDenyReadNotice = "denyRead is set, so the restricted token drops WRITE_RESTRICTED and the workspace write jail no longer confines writes outside it (#869)."
|
||||
|
||||
// noticeCarryingTool stands in for a command tool whose plan carried an
|
||||
// enforcement notice. It writes the notice the way addSandboxMeta does, which is
|
||||
// the only thing the production paths do with it.
|
||||
type noticeCarryingTool struct{}
|
||||
|
||||
func (noticeCarryingTool) Name() string { return "bash" }
|
||||
func (noticeCarryingTool) Description() string { return "test shell tool" }
|
||||
func (noticeCarryingTool) Parameters() Schema {
|
||||
return Schema{
|
||||
Type: "object",
|
||||
Properties: map[string]PropertySchema{"command": {Type: "string"}},
|
||||
Required: []string{"command"},
|
||||
AdditionalProperties: false,
|
||||
}
|
||||
}
|
||||
func (noticeCarryingTool) Safety() Safety {
|
||||
return Safety{SideEffect: SideEffectRead, Permission: PermissionAllow, Reason: "reads files"}
|
||||
}
|
||||
func (noticeCarryingTool) Run(context.Context, map[string]any) Result {
|
||||
meta := map[string]string{}
|
||||
addSandboxMeta(meta, zeroSandbox.CommandPlan{
|
||||
Backend: zeroSandbox.Backend{Name: zeroSandbox.BackendWindowsRestrictedToken},
|
||||
Notes: []string{testDenyReadNotice},
|
||||
})
|
||||
return Result{
|
||||
Status: StatusOK,
|
||||
Output: "hello from the command",
|
||||
Meta: meta,
|
||||
Display: Display{Summary: "ran the command", Kind: "shell"},
|
||||
}
|
||||
}
|
||||
|
||||
// THE DISCLOSURE HAS TO REACH A HUMAN AND A MODEL, NOT A METADATA MAP.
|
||||
//
|
||||
// The first version of this wrote sandbox_notices into Result.Meta and stopped
|
||||
// there. Nothing in production reads those keys, ModelOutput and HumanDisplay
|
||||
// never consult Meta, and the durable history drops it, so a Windows user who
|
||||
// configured deny_read could take the non-WRITE_RESTRICTED token, lose write
|
||||
// confinement, and see nothing but ordinary command output. Metadata is
|
||||
// side-band data, not a disclosure channel.
|
||||
func TestEnforcementNoticeReachesTheModelAndTheDisplay(t *testing.T) {
|
||||
registry := NewRegistry()
|
||||
registry.Register(noticeCarryingTool{})
|
||||
|
||||
result := registry.RunWithOptions(context.Background(), "bash", map[string]any{
|
||||
"command": "echo hello",
|
||||
}, RunOptions{PermissionGranted: true})
|
||||
|
||||
if result.Status != StatusOK {
|
||||
t.Fatalf("tool failed: %s", result.Output)
|
||||
}
|
||||
|
||||
model := result.ModelOutput()
|
||||
if !strings.Contains(model, "#869") {
|
||||
t.Errorf("the model-facing result does not carry the disclosure, so the agent proceeds unaware:\n%s", model)
|
||||
}
|
||||
if !strings.Contains(model, "hello from the command") {
|
||||
t.Errorf("the notice displaced the actual output:\n%s", model)
|
||||
}
|
||||
// PREPENDED, because the output budget trims from the end and a disclosure
|
||||
// that survives only on short results is not a disclosure.
|
||||
if !strings.HasPrefix(strings.TrimSpace(model), testDenyReadNotice) {
|
||||
t.Errorf("the notice is not in front of the output, so a trimmed result can lose it:\n%s", model)
|
||||
}
|
||||
|
||||
display := result.HumanDisplay()
|
||||
if !strings.Contains(display.Summary, "#869") {
|
||||
t.Errorf("the interactive display does not carry the disclosure, so the operator sees nothing: %q", display.Summary)
|
||||
}
|
||||
|
||||
// Kept in metadata too, for integrations reading the result JSON.
|
||||
if result.Meta[sandboxNoticesMeta] == "" {
|
||||
t.Errorf("the metadata copy was dropped: %#v", result.Meta)
|
||||
}
|
||||
}
|
||||
|
||||
// A result with nothing to disclose must be untouched, or every command grows a
|
||||
// blank line and the presence of a notice stops meaning anything.
|
||||
func TestResultsWithoutNoticesAreUnchanged(t *testing.T) {
|
||||
result := Result{Status: StatusOK, Output: "plain output", Display: Display{Summary: "did a thing"}}
|
||||
|
||||
if got := result.ModelOutput(); got != "plain output" {
|
||||
t.Errorf("model output = %q, want it untouched", got)
|
||||
}
|
||||
if got := result.HumanDisplay().Summary; got != "did a thing" {
|
||||
t.Errorf("display summary = %q, want it untouched", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Whitespace-only notices are not notices. Guards against a plan that carries an
|
||||
// empty entry putting a blank line in front of every result.
|
||||
func TestBlankNoticesDoNotAlterTheResult(t *testing.T) {
|
||||
result := Result{
|
||||
Status: StatusOK,
|
||||
Output: "plain output",
|
||||
EnforcementNotices: []string{"", " "},
|
||||
}
|
||||
if got := result.ModelOutput(); got != "plain output" {
|
||||
t.Errorf("model output = %q, want it untouched", got)
|
||||
}
|
||||
}
|
||||
@@ -46,6 +46,28 @@ func (outcome *ToolOutcome) UnmarshalJSON(data []byte) error {
|
||||
// boundaryOutput must already be redacted. It is the text seen immediately
|
||||
// before command reduction and semantic budgeting.
|
||||
func finalizeToolOutcome(result Result, boundaryOutput string) Result {
|
||||
// PROMOTED HERE, at the one seam every tool result crosses, rather than at
|
||||
// each construction site. addSandboxMeta already carries the plan's notices
|
||||
// into metadata for both the bash and the exec_command paths, and any future
|
||||
// command tool that calls it gets the same treatment for free. Setting the
|
||||
// field at the call sites instead would be a third hand-maintained projection
|
||||
// of the same fact, which is exactly how the disclosure went missing from the
|
||||
// generic execution adapter in the first place.
|
||||
if len(result.EnforcementNotices) == 0 {
|
||||
// DERIVED FROM APPLIED STATE, NOT FROM THE PLAN. addSandboxMeta writes the
|
||||
// plan's notices at plan time, before anything runs, so promoting them
|
||||
// unconditionally claims a token trade for a command that may never have
|
||||
// started. The execution outcome is the thing that knows, and it applies
|
||||
// the same launched-and-planned rule hooks and plugins use.
|
||||
//
|
||||
// The metadata stays as diagnostics either way: it records what was
|
||||
// planned, which is still worth having.
|
||||
if result.ExecutionOutcome != nil {
|
||||
result.EnforcementNotices = result.ExecutionOutcome.AppliedEnforcementNotices()
|
||||
} else if notices := strings.TrimSpace(result.Meta[sandboxNoticesMeta]); notices != "" {
|
||||
result.EnforcementNotices = strings.Split(notices, "\n")
|
||||
}
|
||||
}
|
||||
previous := result.Outcome
|
||||
human := result.Display
|
||||
if human.Preview == "" && result.Meta["command_output_reduced"] == "true" {
|
||||
@@ -65,8 +87,18 @@ func finalizeToolOutcome(result Result, boundaryOutput string) Result {
|
||||
originalBytes = previous.Diagnostics.OriginalBytes
|
||||
originalTokens = previous.Diagnostics.EstimatedOriginalTokens
|
||||
}
|
||||
modelBytes := len(result.Output)
|
||||
modelTokens := estimateOutputTokens(result.Output)
|
||||
// MEASURE WHAT THE MODEL ACTUALLY RECEIVES.
|
||||
//
|
||||
// The enforcement notices are prepended to the model view on the way out
|
||||
// (agent.ToolResult.ModelOutput), so a result carrying them costs more context
|
||||
// than result.Output alone. Measuring the bare output undercounts every
|
||||
// disclosed call and hands the budget a figure the model never saw.
|
||||
//
|
||||
// ModelView below stays the bare output on purpose: ModelOutput prepends the
|
||||
// notices itself, so storing them here would send them twice.
|
||||
canonicalModelOutput := WithEnforcementNotices(result.Output, result.EnforcementNotices)
|
||||
modelBytes := len(canonicalModelOutput)
|
||||
modelTokens := estimateOutputTokens(canonicalModelOutput)
|
||||
|
||||
var artifact *ToolArtifact
|
||||
if previous.Finalized() {
|
||||
|
||||
+95
-6
@@ -2,6 +2,7 @@ package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/Twigpine/zero/internal/execution"
|
||||
"github.com/Twigpine/zero/internal/sandbox"
|
||||
@@ -52,6 +53,11 @@ const (
|
||||
SandboxDenialKindMeta = "sandbox_denial_kind"
|
||||
SandboxDenialReasonMeta = "sandbox_denial_reason"
|
||||
SandboxDenialKeywordMeta = "sandbox_denial_keyword"
|
||||
// sandboxNoticesMeta transports the plan notices from addSandboxMeta to
|
||||
// finalizeToolOutcome, which promotes them onto Result.EnforcementNotices.
|
||||
// Kept as metadata as well, because integrations reading the result JSON have
|
||||
// no other way to see them.
|
||||
sandboxNoticesMeta = "sandbox_notices"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -113,6 +119,16 @@ type Result struct {
|
||||
// emits them as a following user message, which is also the only shape that
|
||||
// keeps one tool result per tool call.
|
||||
Images []zeroruntime.ImageBlock `json:"-"`
|
||||
// EnforcementNotices are least-privilege disclosures about the enforcement
|
||||
// actually applied to this command, and they are USER AND MODEL VISIBLE.
|
||||
//
|
||||
// A separate field rather than text baked into Output, so one canonical
|
||||
// result carries it and every surface reads it through the accessors below.
|
||||
// The first attempt put this in Meta alongside the sandbox metadata, which
|
||||
// looked like the established channel and is not one: nothing in production
|
||||
// reads those keys, ModelOutput and HumanDisplay never consult Meta, and the
|
||||
// durable history drops it. The disclosure reached nobody.
|
||||
EnforcementNotices []string `json:"enforcementNotices,omitempty"`
|
||||
// Redacted is set when secret scrubbing altered Output before it left the
|
||||
// tool-execution boundary.
|
||||
Redacted bool
|
||||
@@ -179,24 +195,97 @@ type OutcomeDiagnostics struct {
|
||||
Reason string
|
||||
}
|
||||
|
||||
// ModelOutput returns the finalized provider-facing text, falling back to the
|
||||
// legacy field for direct Tool.Run callers that have not crossed the registry.
|
||||
func (result Result) ModelOutput() string {
|
||||
// BaseModelOutput returns the UNDECORATED provider-facing text: the finalized
|
||||
// model view, falling back to the legacy field for direct Tool.Run callers that
|
||||
// have not crossed the registry. It carries no enforcement notices.
|
||||
//
|
||||
// Callers that PROJECT a result into another carrier (the agent loop building an
|
||||
// agent.ToolResult) must copy this, not ModelOutput, and copy the typed notice
|
||||
// slice alongside it. Storing already-rendered text next to the same notices is
|
||||
// two representations of one fact with no contract between them, and whichever
|
||||
// side of the projection loses its finalized outcome renders the disclosure
|
||||
// twice.
|
||||
func (result Result) BaseModelOutput() string {
|
||||
if result.Outcome.finalized {
|
||||
return result.Outcome.ModelView
|
||||
}
|
||||
return result.Output
|
||||
}
|
||||
|
||||
// HumanDisplay returns the finalized presentation, falling back to the legacy
|
||||
// display for direct Tool.Run callers.
|
||||
func (result Result) HumanDisplay() Display {
|
||||
// BaseDisplay is BaseModelOutput for the presentation half, and carries no
|
||||
// enforcement notices for the same reason.
|
||||
func (result Result) BaseDisplay() Display {
|
||||
if result.Outcome.finalized {
|
||||
return result.Outcome.HumanView
|
||||
}
|
||||
return result.Display
|
||||
}
|
||||
|
||||
// ModelOutput returns the finalized provider-facing text with the enforcement
|
||||
// disclosure rendered in front of it. This is the only place the model view is
|
||||
// decorated.
|
||||
func (result Result) ModelOutput() string {
|
||||
return WithEnforcementNotices(result.BaseModelOutput(), result.EnforcementNotices)
|
||||
}
|
||||
|
||||
// HumanDisplay returns the finalized presentation with the enforcement
|
||||
// disclosure rendered in front of the summary.
|
||||
func (result Result) HumanDisplay() Display {
|
||||
display := result.BaseDisplay()
|
||||
display.Summary = WithEnforcementNotices(display.Summary, result.EnforcementNotices)
|
||||
return display
|
||||
}
|
||||
|
||||
// WithEnforcementNotices puts the enforcement disclosure IN FRONT of the text.
|
||||
//
|
||||
// PREPENDED, not appended, because the output budget trims from the end: a
|
||||
// notice at the tail is the first thing a long result loses, and a disclosure
|
||||
// that survives only on short outputs is not a disclosure. It is also why this
|
||||
// lives on the accessors rather than at the call sites that build results.
|
||||
// The previous version wrote it into Result.Meta, and neither ModelOutput nor
|
||||
// HumanDisplay nor the durable history reads Meta, so it reached nobody at all.
|
||||
func WithEnforcementNotices(text string, notices []string) string {
|
||||
if len(notices) == 0 {
|
||||
return text
|
||||
}
|
||||
joined := strings.TrimSpace(strings.Join(notices, "\n"))
|
||||
if joined == "" {
|
||||
return text
|
||||
}
|
||||
if strings.TrimSpace(text) == "" {
|
||||
return joined
|
||||
}
|
||||
return joined + "\n\n" + text
|
||||
}
|
||||
|
||||
// WithoutEnforcementNotices is the inverse, for a reader of STORED text.
|
||||
//
|
||||
// A persisted tool result carries its output with the disclosure already
|
||||
// composed in, and the same notices again as a typed field. A reader that
|
||||
// rebuilds a result from both has to take the composed copy back off, or the
|
||||
// accessors above compose it a second time and the disclosure is drawn twice.
|
||||
// It lives beside WithEnforcementNotices so the two agree on the format by
|
||||
// construction rather than by a second spelling of it somewhere else.
|
||||
//
|
||||
// Text that was never decorated is returned unchanged, so a record whose writer
|
||||
// stored the undecorated output still round-trips to exactly one disclosure.
|
||||
func WithoutEnforcementNotices(text string, notices []string) string {
|
||||
if len(notices) == 0 {
|
||||
return text
|
||||
}
|
||||
joined := strings.TrimSpace(strings.Join(notices, "\n"))
|
||||
if joined == "" {
|
||||
return text
|
||||
}
|
||||
if rest, ok := strings.CutPrefix(text, joined+"\n\n"); ok {
|
||||
return rest
|
||||
}
|
||||
if strings.TrimSpace(text) == joined {
|
||||
return ""
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
// Display carries a short, structured summary of a tool result for the TUI/stream.
|
||||
type Display struct {
|
||||
Summary string
|
||||
|
||||
@@ -23,7 +23,7 @@ func testAskUserRequest() agent.AskUserRequest {
|
||||
|
||||
func newAskUserModel(t *testing.T, request agent.AskUserRequest, answers *[][]string) model {
|
||||
t.Helper()
|
||||
m := newModel(context.Background(), Options{})
|
||||
m := newViewTestModel(t, Options{})
|
||||
m.pending = true
|
||||
m.activeRunID = 7
|
||||
m.width = 96
|
||||
|
||||
@@ -306,7 +306,7 @@ func TestSuggestionsSuppressedDuringSpecReview(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestSuggestionOverlayRenders(t *testing.T) {
|
||||
m := newModel(context.Background(), Options{})
|
||||
m := newViewTestModel(t, Options{})
|
||||
m.width, m.height = 96, 30
|
||||
m = typeRunes(t, m, "/mo")
|
||||
|
||||
@@ -387,7 +387,7 @@ func TestOverlayViewportLinesPreservesTextOutsidePanel(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestSuggestionOverlayCapsRowsWithoutMoreText(t *testing.T) {
|
||||
m := newModel(context.Background(), Options{})
|
||||
m := newViewTestModel(t, Options{})
|
||||
m.width, m.height = 96, 30
|
||||
m = typeRunes(t, m, "/")
|
||||
|
||||
|
||||
@@ -263,7 +263,7 @@ func TestMCPManagerNavigationOpensAddWizard(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestMCPManagerSearchFiltersMarketplace(t *testing.T) {
|
||||
m := newModel(context.Background(), Options{})
|
||||
m := newViewTestModel(t, Options{})
|
||||
m.width = 120
|
||||
m.height = 36
|
||||
m.input.SetValue("/mcp")
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Twigpine/zero/internal/agent"
|
||||
"github.com/Twigpine/zero/internal/sessions"
|
||||
"github.com/Twigpine/zero/internal/tools"
|
||||
)
|
||||
|
||||
const cardNotice = "denyRead is configured, so the Windows sandbox uses the token shape without WRITE_RESTRICTED (#869)"
|
||||
|
||||
func resultWithPreviewAndNotice() agent.ToolResult {
|
||||
return agent.ToolResult{
|
||||
ToolCallID: "call-1",
|
||||
Name: "edit_file",
|
||||
Status: tools.StatusOK,
|
||||
Output: "Successfully edited x.go (replaced 1 occurrence).",
|
||||
Display: tools.Display{Summary: "Successfully edited x.go.", Kind: "file", Preview: "--- a/x.go\n+++ b/x.go\n@@ -1 +1 @@\n-old\n+new"},
|
||||
EnforcementNotices: []string{cardNotice},
|
||||
}
|
||||
}
|
||||
|
||||
func renderedCard(row transcriptRow, expanded bool) string {
|
||||
row.expanded = expanded
|
||||
return renderToolResultCard(row, 100, rowContext{}, cardRenderOptions{bodyCap: 20})
|
||||
}
|
||||
|
||||
func rowForResult(result agent.ToolResult) transcriptRow {
|
||||
return transcriptRow{
|
||||
kind: rowToolResult, id: "r1", tool: result.Name, status: result.Status,
|
||||
text: toolResultRowText(result), detail: toolResultDetail(result),
|
||||
enforcementNotices: result.EnforcementNotices,
|
||||
}
|
||||
}
|
||||
|
||||
// THE DISCLOSURE HAS TO REACH THE CARD, NOT JUST THE ROW TEXT.
|
||||
//
|
||||
// The notice is prepended to ModelOutput, which toolResultRowText carries into
|
||||
// row.text, and toolCardHead is handed row.text. But the head renders the action
|
||||
// and target, so the notice went nowhere. Every result with a rich preview (each
|
||||
// edit and write card) rendered with no disclosure at all.
|
||||
//
|
||||
// Collapsed as well as expanded: a trade the operator has to expand a card to
|
||||
// discover has not been disclosed.
|
||||
func TestToolCardShowsTheEnforcementDisclosure(t *testing.T) {
|
||||
row := rowForResult(resultWithPreviewAndNotice())
|
||||
|
||||
for _, expanded := range []bool{false, true} {
|
||||
card := renderedCard(row, expanded)
|
||||
if !strings.Contains(card, "WRITE_RESTRICTED") {
|
||||
t.Errorf("expanded=%v: the card rendered no enforcement disclosure:\n%s", expanded, card)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// THE DISCLOSURE IS DATA, NOT PROSE GLUED ONTO THE DIFF.
|
||||
//
|
||||
// row.detail is parsed as a diff by the files panel (planDiffStat,
|
||||
// perFileDiffStats) and rendered line by line by the file view. Prefixing it
|
||||
// with the notice would have been the shorter fix and would have corrupted both,
|
||||
// so the notice travels in its own field and the diff stays a diff.
|
||||
func TestTheDisclosureDoesNotContaminateTheDiffDetail(t *testing.T) {
|
||||
result := resultWithPreviewAndNotice()
|
||||
row := rowForResult(result)
|
||||
|
||||
if strings.Contains(row.detail, "WRITE_RESTRICTED") {
|
||||
t.Fatalf("the notice leaked into the diff detail, which is parsed as a diff: %q", row.detail)
|
||||
}
|
||||
adds, dels := planDiffStat(row.detail)
|
||||
if adds != 1 || dels != 1 {
|
||||
t.Errorf("diff stats changed with the disclosure attached: +%d -%d, want +1 -1", adds, dels)
|
||||
}
|
||||
}
|
||||
|
||||
// AND IT HAS TO SURVIVE A RESUME.
|
||||
//
|
||||
// The session payload carried the notice only inside the "output" string. The
|
||||
// rich card is rebuilt from displayPreview, which never had it, so a restored
|
||||
// transcript lost the disclosure even though the row it replaced had shown it.
|
||||
func TestRestoredSessionKeepsTheEnforcementDisclosure(t *testing.T) {
|
||||
encoded, err := json.Marshal(toolResultSessionPayload(resultWithPreviewAndNotice()))
|
||||
if err != nil {
|
||||
t.Fatalf("marshal session payload: %v", err)
|
||||
}
|
||||
|
||||
rows := transcriptRowsFromSessionEvents([]sessions.Event{{Type: sessions.EventToolResult, Payload: json.RawMessage(encoded)}})
|
||||
if len(rows) != 1 {
|
||||
t.Fatalf("expected one restored row, got %d", len(rows))
|
||||
}
|
||||
if len(rows[0].enforcementNotices) == 0 {
|
||||
t.Fatal("the restored row carries no enforcement notices, so the resumed transcript lost the disclosure")
|
||||
}
|
||||
for _, expanded := range []bool{false, true} {
|
||||
if card := renderedCard(rows[0], expanded); !strings.Contains(card, "WRITE_RESTRICTED") {
|
||||
t.Errorf("expanded=%v: the restored card rendered no disclosure:\n%s", expanded, card)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A result with no notice must not grow card furniture, or every card gains a
|
||||
// blank line and the disclosure stops standing out.
|
||||
func TestOrdinaryResultsGainNoNoticeLines(t *testing.T) {
|
||||
result := resultWithPreviewAndNotice()
|
||||
result.EnforcementNotices = nil
|
||||
plain := renderedCard(rowForResult(result), true)
|
||||
|
||||
result.EnforcementNotices = []string{"", " "}
|
||||
blank := renderedCard(rowForResult(result), true)
|
||||
|
||||
if plain != blank {
|
||||
t.Errorf("blank notices changed the card:\n--- none ---\n%s\n--- blank ---\n%s", plain, blank)
|
||||
}
|
||||
}
|
||||
|
||||
// THE NO-PREVIEW CARD IS THE ONE THE PREVIEW TEST CANNOT SEE.
|
||||
//
|
||||
// The disclosure travels in two forms: typed EnforcementNotices, which the card
|
||||
// renders as its own furniture, and ModelOutput, which has the notice composed
|
||||
// in. A rich preview is undecorated, so an edit card was right. Every bash and
|
||||
// exec result, and every error, has no preview and fell back to ModelOutput, so
|
||||
// row.detail already began with the notice and the card drew it twice: once in
|
||||
// the notice lines and once at the top of the body.
|
||||
//
|
||||
// Both halves are asserted, because a body that lost the notice by losing the
|
||||
// output would also count once.
|
||||
func resultWithoutPreviewAndNotice(status tools.Status, output string) agent.ToolResult {
|
||||
return agent.ToolResult{
|
||||
ToolCallID: "call-2",
|
||||
Name: "bash",
|
||||
Status: status,
|
||||
Output: output,
|
||||
EnforcementNotices: []string{cardNotice},
|
||||
}
|
||||
}
|
||||
|
||||
func countNoticeAndBody(t *testing.T, card string, body string) (int, int) {
|
||||
t.Helper()
|
||||
return strings.Count(card, "WRITE_RESTRICTED"), strings.Count(card, body)
|
||||
}
|
||||
|
||||
func TestNoPreviewCardShowsTheDisclosureExactlyOnce(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
status tools.Status
|
||||
output string
|
||||
}{
|
||||
{"success", tools.StatusOK, "PROBE-BODY-OK"},
|
||||
{"error", tools.StatusError, "PROBE-BODY-ERR"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
row := rowForResult(resultWithoutPreviewAndNotice(tc.status, tc.output))
|
||||
for _, expanded := range []bool{false, true} {
|
||||
notices, bodies := countNoticeAndBody(t, renderedCard(row, expanded), tc.output)
|
||||
if notices != 1 {
|
||||
t.Errorf("expanded=%v: disclosure rendered %d times, want exactly 1", expanded, notices)
|
||||
}
|
||||
if bodies != 1 {
|
||||
t.Errorf("expanded=%v: command output rendered %d times, want exactly 1", expanded, bodies)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The durable path had the same mismatch: the payload stores the decorated
|
||||
// output beside the typed notices, and restoration used that output as the card
|
||||
// body whenever no distinct preview was stored.
|
||||
func TestRestoredNoPreviewCardShowsTheDisclosureExactlyOnce(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
status tools.Status
|
||||
output string
|
||||
}{
|
||||
{"success", tools.StatusOK, "PROBE-BODY-OK"},
|
||||
{"error", tools.StatusError, "PROBE-BODY-ERR"},
|
||||
// A command that printed nothing under an enforced profile still has a
|
||||
// real notice. The stored body is empty, which restoration must treat as
|
||||
// present-and-empty rather than absent.
|
||||
{"empty output", tools.StatusOK, ""},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
encoded, err := json.Marshal(toolResultSessionPayload(resultWithoutPreviewAndNotice(tc.status, tc.output)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rows := transcriptRowsFromSessionEvents([]sessions.Event{{Type: sessions.EventToolResult, Payload: json.RawMessage(encoded)}})
|
||||
if len(rows) != 1 {
|
||||
t.Fatalf("expected one restored row, got %d", len(rows))
|
||||
}
|
||||
for _, expanded := range []bool{false, true} {
|
||||
card := renderedCard(rows[0], expanded)
|
||||
if notices := strings.Count(card, "WRITE_RESTRICTED"); notices != 1 {
|
||||
t.Errorf("expanded=%v: restored card rendered the disclosure %d times, want exactly 1:\n%s", expanded, notices, card)
|
||||
}
|
||||
if tc.output != "" && strings.Count(card, tc.output) != 1 {
|
||||
t.Errorf("expanded=%v: restored card rendered the output %d times, want exactly 1:\n%s", expanded, strings.Count(card, tc.output), card)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A CLI-WRITTEN RESULT RESUMED IN THE TUI MUST STILL DISCLOSE, ONCE.
|
||||
//
|
||||
// The headless writers and the interactive writer append to the same default
|
||||
// session store, and the TUI resumes from it. The headless payload used to
|
||||
// carry only the decorated ModelOutput: no typed notices, no undecorated body.
|
||||
// On restore the transcript found neither, and for a long result the card is
|
||||
// collapsed by default, so there was no body to carry the decorated text and no
|
||||
// notice furniture to draw it. The disclosure the run had shown was simply gone
|
||||
// from the resumed transcript.
|
||||
//
|
||||
// Every writer now goes through agent.ToolResultSessionPayload, so this exercises the
|
||||
// exact bytes the CLI persists, restores them the way the TUI does, and renders
|
||||
// the collapsed card, which is the shape the old CLI test could not reach.
|
||||
func TestHeadlessWrittenCollapsedResultRestoresTheDisclosureExactlyOnce(t *testing.T) {
|
||||
var lines []string
|
||||
for i := 0; i < cardBodyMaxLines*3; i++ {
|
||||
lines = append(lines, fmt.Sprintf("PROBE-LINE-%03d", i))
|
||||
}
|
||||
result := agent.ToolResult{
|
||||
ToolCallID: "call-cli",
|
||||
Name: "bash",
|
||||
Status: tools.StatusOK,
|
||||
Output: strings.Join(lines, "\n"),
|
||||
EnforcementNotices: []string{cardNotice},
|
||||
}
|
||||
|
||||
// The CLI's persisted payload IS this function now; encode it as the
|
||||
// session store would.
|
||||
encoded, err := json.Marshal(agent.ToolResultSessionPayload(result))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rows := transcriptRowsFromSessionEvents([]sessions.Event{{Type: sessions.EventToolResult, Payload: json.RawMessage(encoded)}})
|
||||
if len(rows) != 1 {
|
||||
t.Fatalf("expected one restored row, got %d", len(rows))
|
||||
}
|
||||
if len(rows[0].enforcementNotices) == 0 {
|
||||
t.Fatal("the headless payload carried no typed notices, so the resumed card cannot render the disclosure")
|
||||
}
|
||||
|
||||
for _, expanded := range []bool{false, true} {
|
||||
card := renderedCard(rows[0], expanded)
|
||||
if n := strings.Count(card, "WRITE_RESTRICTED"); n != 1 {
|
||||
t.Errorf("expanded=%v: restored headless card rendered the disclosure %d time(s), want exactly 1:\n%s", expanded, n, card)
|
||||
}
|
||||
}
|
||||
// Collapsed is the case that used to lose it: with no body shown there was
|
||||
// nothing to carry a decorated notice.
|
||||
if card := renderedCard(rows[0], false); !strings.Contains(card, "WRITE_RESTRICTED") {
|
||||
t.Errorf("the collapsed restored card has no disclosure at all:\n%s", card)
|
||||
}
|
||||
}
|
||||
@@ -4,12 +4,100 @@ import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Twigpine/zero/internal/config"
|
||||
"github.com/Twigpine/zero/internal/testutil"
|
||||
)
|
||||
|
||||
// newViewTestModel builds the model for a test that asserts on the whole view.
|
||||
// With an empty Options.Cwd, newModel uses this package's directory, so the
|
||||
// title bar shows the real branch and checkout path, and a check that some text
|
||||
// is absent fails wherever either one contains it: a branch named
|
||||
// feat/model-picker breaks a "/model" check, a checkout under ~/more breaks a
|
||||
// "more" check.
|
||||
//
|
||||
// The cwd is isolatedCwd, so the path in the title bar is the same on every
|
||||
// machine. The branch is cleared after construction, because no path can
|
||||
// promise there is none: gitBranch walks up from the cwd even though it does
|
||||
// not exist, so a temporary directory inside a checkout would still put that
|
||||
// checkout's branch in the title bar.
|
||||
func newViewTestModel(t *testing.T, options Options) model {
|
||||
t.Helper()
|
||||
options.Cwd = isolatedCwd(t)
|
||||
m := newModel(context.Background(), options)
|
||||
m.gitBranch = ""
|
||||
return m
|
||||
}
|
||||
|
||||
// isolatedCwd is a child of the home TestMain isolates that nothing creates:
|
||||
// fully qualified on every platform and holding nothing newModel could read.
|
||||
// The title bar shows it as ~/not-a-checkout. A t.TempDir path would carry the
|
||||
// user name and the test name into the view instead.
|
||||
func isolatedCwd(t *testing.T) string {
|
||||
t.Helper()
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return filepath.Join(home, "not-a-checkout")
|
||||
}
|
||||
|
||||
// Guards isolatedCwd: the view tests rely on a fully qualified path that does
|
||||
// not exist. Whether a checkout encloses it depends on where the temporary
|
||||
// directory is, which is why newViewTestModel clears the branch instead.
|
||||
func TestIsolatedCwdIsAbsentAndFullyQualified(t *testing.T) {
|
||||
cwd := isolatedCwd(t)
|
||||
if !filepath.IsAbs(cwd) {
|
||||
t.Fatalf("isolatedCwd = %q, want a fully qualified path", cwd)
|
||||
}
|
||||
if _, err := os.Lstat(cwd); !os.IsNotExist(err) {
|
||||
t.Fatalf("isolatedCwd %q must not exist, got %v", cwd, err)
|
||||
}
|
||||
}
|
||||
|
||||
// A temporary directory inside a checkout puts the home, and so isolatedCwd,
|
||||
// under that checkout. Its branch must still stay out of the view, or a branch
|
||||
// named feat/model-picker fails the "/model" check again.
|
||||
func TestViewTestModelKeepsAnEnclosingCheckoutsBranchOutOfTheView(t *testing.T) {
|
||||
checkout := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(checkout, ".git"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(checkout, ".git", "HEAD"), []byte("ref: refs/heads/feat/model-picker\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
home := filepath.Join(checkout, "home")
|
||||
t.Setenv("HOME", home)
|
||||
t.Setenv("USERPROFILE", home)
|
||||
if got := gitBranch(isolatedCwd(t)); got != "feat/model-picker" {
|
||||
t.Fatalf("fixture: gitBranch(isolatedCwd) = %q, want the enclosing checkout's feat/model-picker", got)
|
||||
}
|
||||
|
||||
m := newViewTestModel(t, Options{})
|
||||
m.width, m.height = 96, 30
|
||||
m = typeRunes(t, m, "/mo")
|
||||
plain := plainRender(t, m.View())
|
||||
if strings.Contains(plain, "feat/model-picker") || strings.Contains(plain, "/model") {
|
||||
t.Fatalf("the enclosing checkout's branch reached the view:\n%s", plain)
|
||||
}
|
||||
// The path is the other half: the title bar shows the isolated cwd, not the
|
||||
// checkout the tests happen to run from or the one enclosing the home.
|
||||
if !strings.Contains(plain, "not-a-checkout") {
|
||||
t.Fatalf("the title bar does not show the isolated cwd:\n%s", plain)
|
||||
}
|
||||
wd, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, path := range []string{checkout, wd} {
|
||||
if strings.Contains(plain, path) {
|
||||
t.Fatalf("the view shows the checkout path %s:\n%s", path, plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Guards TestMain: the per-user directories every newModel reads (and the ones
|
||||
// tests could write to) must resolve inside the isolated root on every OS.
|
||||
func TestUserDirsAreIsolatedFromTheDeveloperHome(t *testing.T) {
|
||||
|
||||
+18
-43
@@ -6002,16 +6002,17 @@ func (m model) runAgentWithOptions(runID int, runCtx context.Context, prompt str
|
||||
}
|
||||
}
|
||||
row := transcriptRow{
|
||||
kind: rowToolResult,
|
||||
id: effectiveToolRowID(result.ToolCallID, callSeq[result.ToolCallID]),
|
||||
text: toolResultRowText(result),
|
||||
tool: result.Name,
|
||||
status: result.Status,
|
||||
detail: toolResultDetail(result),
|
||||
meta: result.Meta,
|
||||
runID: runID,
|
||||
changedFiles: result.ChangedFiles,
|
||||
changeSummaries: result.ChangeSummaries,
|
||||
kind: rowToolResult,
|
||||
id: effectiveToolRowID(result.ToolCallID, callSeq[result.ToolCallID]),
|
||||
text: toolResultRowText(result),
|
||||
tool: result.Name,
|
||||
status: result.Status,
|
||||
detail: toolResultDetail(result),
|
||||
meta: result.Meta,
|
||||
runID: runID,
|
||||
changedFiles: result.ChangedFiles,
|
||||
changeSummaries: result.ChangeSummaries,
|
||||
enforcementNotices: result.EnforcementNotices,
|
||||
}
|
||||
// A successful Task/TaskOutput result is represented by a specialist card.
|
||||
// update_plan stays in the transcript as a rendered checklist; failures
|
||||
@@ -6253,45 +6254,19 @@ func (m model) sendAgentUsage(runID int, modelID string, event zeroruntime.Usage
|
||||
m.runtimeMessageSink(agentUsageMsg{runID: runID, modelID: modelID, usage: event})
|
||||
}
|
||||
|
||||
// toolResultDetail is the card body source: the rich card-only Display.Preview
|
||||
// (a code/diff preview) when present on a successful result, else the Output that
|
||||
// the model also saw. Error results keep their Output so the failure shows.
|
||||
// toolResultDetail is the card body source. The rule lives on the result itself
|
||||
// (agent.ToolResult.CardBody) because the session payload is built from it too,
|
||||
// and that payload now has a writer outside this package.
|
||||
func toolResultDetail(result agent.ToolResult) string {
|
||||
display := result.HumanDisplay()
|
||||
if strings.TrimSpace(display.Preview) != "" && (result.Status != tools.StatusError || result.Outcome.Finalized()) {
|
||||
return display.Preview
|
||||
}
|
||||
return result.ModelOutput()
|
||||
return result.CardBody()
|
||||
}
|
||||
|
||||
// toolResultSessionPayload preserves both views of a tool result: output remains
|
||||
// the provider-facing text used for session context, while displayPreview keeps
|
||||
// the richer card body that was visible during the live run. The preview is only
|
||||
// stored when it differs, so ordinary tool results retain their compact event.
|
||||
// the richer card body that was visible during the live run. The contract and
|
||||
// its presence rules are documented on agent.ToolResultSessionPayload.
|
||||
func toolResultSessionPayload(result agent.ToolResult) map[string]any {
|
||||
output := result.ModelOutput()
|
||||
payload := map[string]any{
|
||||
"toolCallId": result.ToolCallID,
|
||||
"name": result.Name,
|
||||
"status": string(result.Status),
|
||||
"output": output,
|
||||
}
|
||||
if preview := toolResultDetail(result); strings.TrimSpace(preview) != "" && preview != output {
|
||||
payload["displayPreview"] = preview
|
||||
}
|
||||
if result.Redacted {
|
||||
payload["redacted"] = true
|
||||
}
|
||||
if len(result.Meta) > 0 {
|
||||
payload["meta"] = result.Meta
|
||||
}
|
||||
if len(result.ChangedFiles) > 0 {
|
||||
payload["changedFiles"] = result.ChangedFiles
|
||||
}
|
||||
if len(result.ChangeSummaries) > 0 {
|
||||
payload["changeSummaries"] = result.ChangeSummaries
|
||||
}
|
||||
return payload
|
||||
return agent.ToolResultSessionPayload(result)
|
||||
}
|
||||
|
||||
func toolResultRowText(result agent.ToolResult) string {
|
||||
|
||||
@@ -357,7 +357,7 @@ func TestInitialRenderShowsLimeChatSurface(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestEmptyStateCollapsesAfterFirstPrompt(t *testing.T) {
|
||||
m := newModel(context.Background(), Options{})
|
||||
m := newViewTestModel(t, Options{})
|
||||
m.width = 100
|
||||
m.height = 30
|
||||
m.input.SetValue("inspect the repo")
|
||||
|
||||
Loaded 100 of 107 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user