mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-08 17:56:14 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1dfe275e50 | ||
|
|
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
+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())
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+3
-12
@@ -174,7 +174,7 @@ func runSandboxSetup(args []string, stdout io.Writer, stderr io.Writer, deps app
|
||||
if !setupHelper.Available() {
|
||||
return writeAppError(stderr, "Windows sandbox setup helper is not available", exitProvider)
|
||||
}
|
||||
setupPlan, err := zeroSandbox.BuildWindowsSandboxSetupArgs(zeroSandbox.WindowsSandboxSetupArgsOptions{
|
||||
setupArgs, err := zeroSandbox.BuildWindowsSandboxSetupArgs(zeroSandbox.WindowsSandboxSetupArgsOptions{
|
||||
CommandCWD: workspaceRoot,
|
||||
WorkspaceRoots: []string{workspaceRoot},
|
||||
PermissionProfile: profile,
|
||||
@@ -182,18 +182,9 @@ func runSandboxSetup(args []string, stdout io.Writer, stderr io.Writer, deps app
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), exitCrash)
|
||||
}
|
||||
setupArgs := append(append([]string{}, setupHelper.ArgsPrefix...), setupPlan.Args...)
|
||||
// BUILDING THE ARGS ALREADY WROTE TO DISK. Selecting the runtime root takes a
|
||||
// lease, and taking a lease creates the runtime tree and the lease file when
|
||||
// they are not there. A helper that never publishes a marker leaves that tree
|
||||
// behind, attested by nothing, so the undo belongs on this side of the process
|
||||
// boundary where the record of it lives.
|
||||
setupArgs = append(append([]string{}, setupHelper.ArgsPrefix...), setupArgs...)
|
||||
if err := deps.runSandboxSetupHelper(setupHelper.Name, setupArgs, stdout, stderr); err != nil {
|
||||
message := "Windows sandbox setup failed: " + err.Error()
|
||||
if undo := setupPlan.Rollback(); undo != nil {
|
||||
message += "\nleftover sandbox runtime state could not be removed: " + undo.Error()
|
||||
}
|
||||
return writeAppError(stderr, message, exitProvider)
|
||||
return writeAppError(stderr, "Windows sandbox setup failed: "+err.Error(), exitProvider)
|
||||
}
|
||||
return writeSandboxSetupResult(stdout, options.json, sandboxSetupResult{
|
||||
Platform: "windows",
|
||||
|
||||
@@ -99,77 +99,11 @@ func windowsSandboxSetupCheck(goos string, backend sandbox.Backend, workspaceRoo
|
||||
return &result
|
||||
}
|
||||
profile := sandbox.PermissionProfileFromPolicy(workspaceRoot, doctorSandboxPolicy(sandboxConfig), scope)
|
||||
// A RECORDED ROOT IS HISTORY, NOT PROOF. Doctor pins the marker's runtime
|
||||
// root so the stamp check can run without taking a lease, but a command does
|
||||
// not pin blindly: it derives the current cache and fallback candidates and
|
||||
// honours the marker only when its root is one of them. Run setup with the
|
||||
// cache at A, relocate the cache so commands derive B, and the stamped A tree
|
||||
// remains: pinning A here reported a healthy sandbox immediately before every
|
||||
// real command rejected A and failed on the out-of-date marker.
|
||||
//
|
||||
// So ask the command's own question first, through the command's own
|
||||
// function. Only a root a command would still select is used to validate
|
||||
// the marker; anything else is reported as out of date, with the remedy.
|
||||
//
|
||||
// THREE STATES, NOT TWO. The helper answers current, stale, or "I could not
|
||||
// tell", and the last one used to fall through to the pinned branch: a valid
|
||||
// old marker then made doctor report healthy while selectSandboxRuntimeRoot,
|
||||
// reached by BuildCommandPlan, propagated the same resolution error and stopped
|
||||
// every command from launching. Historical state is not a substitute for a
|
||||
// failed current selection, so an error is surfaced as its own warning with the
|
||||
// cause, and the pinned validation profile is built only once currentness has
|
||||
// actually been established.
|
||||
recorded, current, rootErr := sandbox.WindowsSandboxRecordedRuntimeRootIsCurrent(sandboxHome, workspaceRoot)
|
||||
if rootErr != nil {
|
||||
result := check("sandbox.backend", "Sandbox backend", StatusWarn, fmt.Sprintf("Native sandbox backend %s is installed, but the runtime root a command would select cannot be resolved (%s), so the recorded setup cannot be trusted and commands are likely to fail to launch.", backend.Name, rootErr.Error()), map[string]any{
|
||||
"backend": string(backend.Name),
|
||||
"platform": goos,
|
||||
"supportLevel": string(backend.SupportLevel()),
|
||||
"setupStatus": "runtime-root-unresolved",
|
||||
"error": rootErr.Error(),
|
||||
"remedy": "run `zero sandbox setup` to prepare the Windows native sandbox for the current cache location",
|
||||
})
|
||||
return &result
|
||||
}
|
||||
if recorded != "" && !current {
|
||||
result := check("sandbox.backend", "Sandbox backend", StatusWarn, fmt.Sprintf("Native sandbox backend %s is installed, but the runtime root setup recorded (%s) is not one a command would select now, so the setup is out of date.", backend.Name, recorded), map[string]any{
|
||||
"backend": string(backend.Name),
|
||||
"platform": goos,
|
||||
"supportLevel": string(backend.SupportLevel()),
|
||||
"setupStatus": "runtime-root-stale",
|
||||
"runtimeRoot": recorded,
|
||||
"remedy": "run `zero sandbox setup` to prepare the Windows native sandbox for the current cache location",
|
||||
})
|
||||
return &result
|
||||
}
|
||||
setupConfig := sandbox.WindowsSandboxSetupConfig{
|
||||
SandboxHome: sandboxHome,
|
||||
CommandCWD: workspaceRoot,
|
||||
WorkspaceRoots: []string{workspaceRoot},
|
||||
// The same augmentation setup and the command plan apply, so doctor
|
||||
// fingerprints what a real command fingerprints. Checking the bare profile
|
||||
// made doctor call a correctly prepared machine "out of date", which is the
|
||||
// mismatch this pairing exists to close. Safe to resolve in this process:
|
||||
// doctor runs in the operator's shell, not behind the sandbox TEMP
|
||||
// redirection that stops the runner deriving these for itself.
|
||||
//
|
||||
// The RECORDED runtime root goes on the profile as well, because that is
|
||||
// what makes the stamp check run at all. Without it profile.Runtime is nil,
|
||||
// validateWindowsSandboxRuntimeStamp returns nil early, and doctor reported
|
||||
// a healthy sandbox on a machine whose runtime tree had been evicted and
|
||||
// silently recreated without the capability ACE -- precisely the state the
|
||||
// stamp exists to catch, and the state where every sandboxed command then
|
||||
// fails with nothing explaining why.
|
||||
//
|
||||
// Read from the marker rather than selected, so doctor takes no lease and
|
||||
// creates nothing, and read ONCE: this is the root whose currentness was
|
||||
// checked above, not a second read that a rewritten marker could change. A
|
||||
// marker from an older schema records no root, which leaves this exactly as
|
||||
// it was.
|
||||
PermissionProfile: sandbox.WindowsSandboxProfileWithRuntimeRoots(
|
||||
sandbox.PermissionProfileWithRuntimeRoot(profile, recorded),
|
||||
[]string{workspaceRoot},
|
||||
),
|
||||
SandboxHome: sandboxHome,
|
||||
CommandCWD: workspaceRoot,
|
||||
WorkspaceRoots: []string{workspaceRoot},
|
||||
PermissionProfile: profile,
|
||||
}
|
||||
if err := sandbox.ValidateWindowsSandboxSetupMarker(setupConfig); err != nil {
|
||||
result := check("sandbox.backend", "Sandbox backend", StatusWarn, fmt.Sprintf("Native sandbox backend %s is installed, but Windows sandbox setup is missing or out of date: %v.", backend.Name, err), map[string]any{
|
||||
|
||||
@@ -1,247 +0,0 @@
|
||||
package doctor
|
||||
|
||||
import (
|
||||
"os"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Twigpine/zero/internal/config"
|
||||
"github.com/Twigpine/zero/internal/sandbox"
|
||||
)
|
||||
|
||||
// DOCTOR HAS TO ASK THE ONE QUESTION THE MARKER CANNOT ANSWER.
|
||||
//
|
||||
// The marker hashes ACL-plan entries, which are pathnames. Whether the directory
|
||||
// those pathnames resolve to is still the tree setup provisioned is a different
|
||||
// question, and the runtime stamp is what answers it. validateWindowsSandboxRuntimeStamp
|
||||
// returns nil early when profile.Runtime is nil, which is correct for the setup
|
||||
// side and for unrestricted profiles, and was wrong here: doctor built its
|
||||
// profile with PermissionProfileFromPolicy, which never sets Runtime, so the
|
||||
// check was skipped and `zero doctor` reported a healthy sandbox on exactly the
|
||||
// state the stamp was added to detect -- an evicted runtime tree, silently
|
||||
// recreated with ordinary permissions and no capability ACE, where every
|
||||
// sandboxed command then fails with nothing explaining why.
|
||||
// doctorRuntimeCandidate returns a runtime root this workspace would really
|
||||
// select, taken from the candidate set the Windows plan folds in.
|
||||
//
|
||||
// It has to be a REAL candidate. An arbitrary path is already in the ACL plan on
|
||||
// one side and not the other, so the plan hashes diverge and validation fails
|
||||
// before the stamp is ever consulted -- which would make this test fail without
|
||||
// the fix for a reason that has nothing to do with the stamp.
|
||||
// redirectUserCache points os.UserCacheDir at test-owned storage.
|
||||
//
|
||||
// WITHOUT THIS THE TEST WRITES INTO THE DEVELOPER'S REAL CACHE. The runtime
|
||||
// candidate is derived from the process's actual user cache directory, so the
|
||||
// test was creating, recursively removing, recreating and then cleanup-removing
|
||||
// a directory under the real %LocalAppData%zerountime (or ~/.cache/zero on
|
||||
// Unix). The workspace digest made a collision with a live runtime tree
|
||||
// unlikely rather than impossible, and "unlikely" is not the standard for a
|
||||
// test that calls RemoveAll.
|
||||
//
|
||||
// All three variables, because os.UserCacheDir reads a different one per
|
||||
// platform: %LocalAppData% on Windows, $XDG_CACHE_HOME or $HOME on Unix, and
|
||||
// $HOME on macOS.
|
||||
func redirectUserCache(t *testing.T) {
|
||||
t.Helper()
|
||||
cache := t.TempDir()
|
||||
t.Setenv("LOCALAPPDATA", cache)
|
||||
t.Setenv("XDG_CACHE_HOME", cache)
|
||||
t.Setenv("HOME", cache)
|
||||
}
|
||||
|
||||
func doctorRuntimeCandidate(t *testing.T, workspace string) string {
|
||||
t.Helper()
|
||||
bare := doctorProfile(t, workspace)
|
||||
augmented := sandbox.WindowsSandboxProfileWithRuntimeRoots(bare, []string{workspace})
|
||||
existing := map[string]bool{}
|
||||
for _, root := range bare.FileSystem.WriteRoots {
|
||||
existing[root.Root] = true
|
||||
}
|
||||
for _, root := range augmented.FileSystem.WriteRoots {
|
||||
if !existing[root.Root] {
|
||||
return root.Root
|
||||
}
|
||||
}
|
||||
t.Skip("no runtime candidate is derivable in this environment")
|
||||
return ""
|
||||
}
|
||||
|
||||
func doctorProfile(t *testing.T, workspace string) sandbox.PermissionProfile {
|
||||
t.Helper()
|
||||
scope, err := sandbox.NewScope(workspace, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("NewScope: %v", err)
|
||||
}
|
||||
return sandbox.PermissionProfileFromPolicy(workspace, doctorSandboxPolicy(config.SandboxConfig{}), scope)
|
||||
}
|
||||
|
||||
func writeDoctorSetupMarker(t *testing.T, home, workspace, runtimeRoot string) {
|
||||
t.Helper()
|
||||
profile := doctorProfile(t, workspace)
|
||||
setup := sandbox.WindowsSandboxSetupConfig{
|
||||
SandboxHome: home,
|
||||
CommandCWD: workspace,
|
||||
WorkspaceRoots: []string{workspace},
|
||||
PermissionProfile: sandbox.WindowsSandboxProfileWithRuntimeRoots(
|
||||
sandbox.PermissionProfileWithRuntimeRoot(profile, runtimeRoot),
|
||||
[]string{workspace},
|
||||
),
|
||||
}
|
||||
if _, err := sandbox.WriteWindowsSandboxSetupMarker(setup); err != nil {
|
||||
t.Fatalf("WriteWindowsSandboxSetupMarker: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDoctorReportsAnEvictedRuntimeTree(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
workspace := t.TempDir()
|
||||
t.Setenv("ZERO_WINDOWS_SANDBOX_HOME", home)
|
||||
redirectUserCache(t)
|
||||
|
||||
runtimeRoot := doctorRuntimeCandidate(t, workspace)
|
||||
// Belt and braces: if the redirection above ever stops working, fail loudly
|
||||
// rather than quietly operating on the developer's real cache.
|
||||
if !strings.HasPrefix(runtimeRoot, os.TempDir()) && !strings.Contains(runtimeRoot, t.Name()) {
|
||||
t.Fatalf("the runtime candidate %q is outside test-owned storage; this test creates and removes that path", runtimeRoot)
|
||||
}
|
||||
if err := os.MkdirAll(runtimeRoot, 0o700); err != nil {
|
||||
t.Fatalf("create the runtime root: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.RemoveAll(runtimeRoot) })
|
||||
writeDoctorSetupMarker(t, home, workspace, runtimeRoot)
|
||||
|
||||
backend := sandbox.Backend{Name: sandbox.BackendWindowsRestrictedToken}
|
||||
|
||||
// A healthy machine first, or the eviction assertion below would be satisfied
|
||||
// by a check that warns unconditionally.
|
||||
if result := windowsSandboxSetupCheck("windows", backend, workspace, config.SandboxConfig{}); result != nil {
|
||||
t.Fatalf("a freshly set-up machine was reported unhealthy: %s", result.Message)
|
||||
}
|
||||
|
||||
// cleanupSandboxRuntimeRoots evicts inactive roots on an age and count policy,
|
||||
// and the next run recreates the pathname with inherited permissions. The plan
|
||||
// hash never moves, so only the stamp can tell.
|
||||
if err := os.RemoveAll(runtimeRoot); err != nil {
|
||||
t.Fatalf("evict the runtime root: %v", err)
|
||||
}
|
||||
if err := os.MkdirAll(runtimeRoot, 0o700); err != nil {
|
||||
t.Fatalf("recreate the pathname the way an ordinary run would: %v", err)
|
||||
}
|
||||
|
||||
result := windowsSandboxSetupCheck("windows", backend, workspace, config.SandboxConfig{})
|
||||
if result == nil {
|
||||
t.Fatal("doctor reported a healthy sandbox while the provisioned runtime tree was gone; every sandboxed command on this machine would fail with nothing explaining why")
|
||||
}
|
||||
if !strings.Contains(strings.ToLower(result.Message), "setup") {
|
||||
t.Errorf("the warning does not point at setup: %s", result.Message)
|
||||
}
|
||||
}
|
||||
|
||||
// UNKNOWN IS NOT CURRENT.
|
||||
//
|
||||
// WindowsSandboxRecordedRuntimeRootIsCurrent answers three ways: the recorded
|
||||
// root is still one a command would select, it is not, or the inputs a command
|
||||
// selects from cannot be resolved at all. The third used to fall through to the
|
||||
// pinned branch, so a valid old marker and stamp made doctor report healthy
|
||||
// while selectSandboxRuntimeRoot — reached by BuildCommandPlan — propagated the
|
||||
// same resolution failure and stopped every command from launching. Doctor and
|
||||
// the command disagreed about the same machine.
|
||||
//
|
||||
// Stale and unknown are different states, so this is deliberately separate from
|
||||
// the cache-A-to-cache-B test rather than another assertion on the same boolean.
|
||||
func TestDoctorWarnsWhenTheRuntimeRootCannotBeResolved(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
workspace := t.TempDir()
|
||||
t.Setenv("ZERO_WINDOWS_SANDBOX_HOME", home)
|
||||
redirectUserCache(t)
|
||||
|
||||
runtimeRoot := doctorRuntimeCandidate(t, workspace)
|
||||
if !strings.HasPrefix(runtimeRoot, os.TempDir()) && !strings.Contains(runtimeRoot, t.Name()) {
|
||||
t.Fatalf("the runtime candidate %q is outside test-owned storage", runtimeRoot)
|
||||
}
|
||||
if err := os.MkdirAll(runtimeRoot, 0o700); err != nil {
|
||||
t.Fatalf("create the runtime root: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.RemoveAll(runtimeRoot) })
|
||||
writeDoctorSetupMarker(t, home, workspace, runtimeRoot)
|
||||
|
||||
backend := sandbox.Backend{Name: sandbox.BackendWindowsRestrictedToken}
|
||||
|
||||
// Healthy first, so the assertion below cannot be satisfied by a check that
|
||||
// warns unconditionally.
|
||||
if result := windowsSandboxSetupCheck("windows", backend, workspace, config.SandboxConfig{}); result != nil {
|
||||
t.Fatalf("SETUP INVALID: a freshly set-up machine was reported unhealthy: %s", result.Message)
|
||||
}
|
||||
|
||||
// Now take away the inputs the selection is derived from, leaving the marker
|
||||
// and the provisioned tree exactly as they were. os.UserCacheDir fails with no
|
||||
// location to read, which is the same error a command would hit.
|
||||
t.Setenv("LOCALAPPDATA", "")
|
||||
t.Setenv("XDG_CACHE_HOME", "")
|
||||
t.Setenv("HOME", "")
|
||||
|
||||
result := windowsSandboxSetupCheck("windows", backend, workspace, config.SandboxConfig{})
|
||||
if result == nil {
|
||||
t.Fatal("doctor reported a healthy sandbox while the runtime root a command selects could not be resolved; every command would fail to launch with doctor saying nothing")
|
||||
}
|
||||
if status, _ := result.Details["setupStatus"].(string); status != "runtime-root-unresolved" {
|
||||
t.Errorf("setupStatus = %q, want runtime-root-unresolved so the unknown state is not reported as staleness: %s", status, result.Message)
|
||||
}
|
||||
if _, ok := result.Details["error"]; !ok {
|
||||
t.Errorf("the warning does not carry the resolver cause, so an operator cannot act on it: %+v", result.Details)
|
||||
}
|
||||
}
|
||||
|
||||
// A CACHE DIRECTORY THAT RESOLVES TO NOTHING IS UNRESOLVED TOO, not only one
|
||||
// that fails to resolve. os.UserCacheDir succeeds on a value like "." and the
|
||||
// selection a command makes refuses it after canonicalizing, with "user cache
|
||||
// directory is unavailable". The diagnostic behind this check did not, so doctor
|
||||
// went on to compare the recorded root against candidates derived from an empty
|
||||
// cache root and reported staleness, which sends an operator to re-run setup on
|
||||
// a machine where setup cannot select a root either.
|
||||
//
|
||||
// The sandbox package pins the two entry points to one error through the
|
||||
// resolver seam on every platform. This is the consumer's half, and it is
|
||||
// Windows-only because that is where the environment can produce the value:
|
||||
// %LocalAppData% is returned as it is, while a relative XDG_CACHE_HOME is an
|
||||
// error on Linux and a relative HOME still joins to a usable path on macOS.
|
||||
// Reported by @jatmn.
|
||||
func TestDoctorReportsAnEmptyCacheRootAsUnresolvedNotStale(t *testing.T) {
|
||||
if runtime.GOOS != "windows" {
|
||||
t.Skip("only %LocalAppData% hands os.UserCacheDir a value that canonicalizes to nothing")
|
||||
}
|
||||
home := t.TempDir()
|
||||
workspace := t.TempDir()
|
||||
t.Setenv("ZERO_WINDOWS_SANDBOX_HOME", home)
|
||||
redirectUserCache(t)
|
||||
|
||||
runtimeRoot := doctorRuntimeCandidate(t, workspace)
|
||||
if !strings.HasPrefix(runtimeRoot, os.TempDir()) && !strings.Contains(runtimeRoot, t.Name()) {
|
||||
t.Fatalf("the runtime candidate %q is outside test-owned storage", runtimeRoot)
|
||||
}
|
||||
if err := os.MkdirAll(runtimeRoot, 0o700); err != nil {
|
||||
t.Fatalf("create the runtime root: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.RemoveAll(runtimeRoot) })
|
||||
writeDoctorSetupMarker(t, home, workspace, runtimeRoot)
|
||||
|
||||
backend := sandbox.Backend{Name: sandbox.BackendWindowsRestrictedToken}
|
||||
if result := windowsSandboxSetupCheck("windows", backend, workspace, config.SandboxConfig{}); result != nil {
|
||||
t.Fatalf("SETUP INVALID: a freshly set-up machine was reported unhealthy: %s", result.Message)
|
||||
}
|
||||
|
||||
for _, value := range []string{".", " . "} {
|
||||
t.Setenv("LOCALAPPDATA", value)
|
||||
result := windowsSandboxSetupCheck("windows", backend, workspace, config.SandboxConfig{})
|
||||
if result == nil {
|
||||
t.Fatalf("LOCALAPPDATA=%q: doctor reported a healthy sandbox while no command can select a runtime root", value)
|
||||
}
|
||||
if status, _ := result.Details["setupStatus"].(string); status != "runtime-root-unresolved" {
|
||||
t.Errorf("LOCALAPPDATA=%q: setupStatus = %q, want runtime-root-unresolved: %s", value, status, result.Message)
|
||||
}
|
||||
if cause, _ := result.Details["error"].(string); !strings.Contains(cause, "user cache directory is unavailable") {
|
||||
t.Errorf("LOCALAPPDATA=%q: the warning does not carry the resolver cause a command would report: %+v", value, result.Details)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestMain points the sandbox runtime's user-cache root at test-owned storage
|
||||
// for the whole package.
|
||||
//
|
||||
// PLAN CONSTRUCTION CREATES DIRECTORIES, which is easy to miss because it reads
|
||||
// like naming. windowsSandboxProfileWithProvisionedRuntime provisions the root
|
||||
// it selects, and the simulated-Windows tests run on every platform, so a test
|
||||
// that supplies an explicit child environment but leaves the cache alone writes
|
||||
// into the developer's real one. Not hypothetical: the machine this was found on
|
||||
// had accumulated thousands of entries under the real runtime root from exactly
|
||||
// these runs.
|
||||
//
|
||||
// Done once for the package rather than per test, because the leak is in the
|
||||
// DEFAULT: any new test that builds a Windows plan is affected unless its author
|
||||
// remembers, and remembering is what failed here. A test that needs a specific
|
||||
// cache root still overrides sandboxUserCacheDir itself.
|
||||
func TestMain(m *testing.M) {
|
||||
root, err := os.MkdirTemp("", "zero-sandbox-testcache-")
|
||||
if err != nil {
|
||||
// Fail loudly rather than silently falling back to the real cache.
|
||||
panic("sandbox tests: create the test cache root: " + err.Error())
|
||||
}
|
||||
sandboxUserCacheDir = func() (string, error) { return root, nil }
|
||||
|
||||
code := m.Run()
|
||||
|
||||
_ = os.RemoveAll(root)
|
||||
os.Exit(code)
|
||||
}
|
||||
+44
-21
@@ -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
|
||||
}
|
||||
|
||||
@@ -189,18 +200,7 @@ func (engine *Engine) BuildCommandPlan(spec CommandSpec) (CommandPlan, error) {
|
||||
}
|
||||
var runtimeCleanup func()
|
||||
if preference != SandboxPreferenceForbid && policy.Mode != ModeDisabled {
|
||||
// The home THIS command asked for. Windows planning resolves
|
||||
// ZERO_WINDOWS_SANDBOX_HOME out of spec.Env and hands it to the runner for
|
||||
// marker validation, so selection has to read the same environment or the
|
||||
// two disagree about which marker describes the tree. See
|
||||
// pinnedSandboxRuntimeRoot.
|
||||
commandSandboxHome := ""
|
||||
if spec.Env != nil {
|
||||
if resolved, err := ResolveWindowsSandboxHome(envListToMap(spec.Env)); err == nil {
|
||||
commandSandboxHome = resolved
|
||||
}
|
||||
}
|
||||
runtimeState, cleanup, runtimeErr := prepareSandboxRuntime(workspaceRoot, commandSandboxHome)
|
||||
runtimeState, cleanup, runtimeErr := prepareSandboxRuntime(workspaceRoot)
|
||||
if runtimeErr != nil {
|
||||
return CommandPlan{}, runtimeErr
|
||||
}
|
||||
@@ -1207,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...),
|
||||
}
|
||||
}
|
||||
@@ -393,11 +393,10 @@ func realSmokeExecutable(t *testing.T, envKey string, fallbackName string) strin
|
||||
|
||||
func runWindowsRealSmokeSetup(t *testing.T, setupExe string, options WindowsSandboxSetupArgsOptions) {
|
||||
t.Helper()
|
||||
setupPlan, err := BuildWindowsSandboxSetupArgs(options)
|
||||
args, err := BuildWindowsSandboxSetupArgs(options)
|
||||
if err != nil {
|
||||
t.Fatalf("BuildWindowsSandboxSetupArgs: %v", err)
|
||||
}
|
||||
args := setupPlan.Args
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, setupExe, args...)
|
||||
@@ -407,30 +406,8 @@ func runWindowsRealSmokeSetup(t *testing.T, setupExe string, options WindowsSand
|
||||
}
|
||||
}
|
||||
|
||||
// prepareWindowsRealSmokeCommand gives an elevated-tier smoke command the profile
|
||||
// the planner would have given it. The elevated runner validates the command
|
||||
// against the setup marker, and setup fingerprinted a profile with the selected
|
||||
// runtime folded in, so a command built from the bare profile can only be
|
||||
// refused. The unelevated tier applies its own plan and has no marker to agree
|
||||
// with, so it is left exactly as the test wrote it.
|
||||
func prepareWindowsRealSmokeCommand(t *testing.T, base WindowsSandboxCommandArgsOptions) (WindowsSandboxCommandArgsOptions, func()) {
|
||||
t.Helper()
|
||||
if base.SandboxLevel != WindowsSandboxLevelRestrictedToken || base.PermissionProfile.Runtime != nil {
|
||||
return base, func() {}
|
||||
}
|
||||
workspace := firstNonEmpty(base.WorkspaceRoots...)
|
||||
prepared, release, err := plannerPreparedWindowsProfile(workspace, base.SandboxHome, base.PermissionProfile)
|
||||
if err != nil {
|
||||
t.Fatalf("prepare the smoke command the way the planner does: %v", err)
|
||||
}
|
||||
base.PermissionProfile = prepared
|
||||
return base, release
|
||||
}
|
||||
|
||||
func runWindowsRealSmokeCommand(t *testing.T, runnerExe string, base WindowsSandboxCommandArgsOptions, command []string, wantCode int) {
|
||||
t.Helper()
|
||||
base, release := prepareWindowsRealSmokeCommand(t, base)
|
||||
defer release()
|
||||
base.Command = command
|
||||
args, err := BuildWindowsSandboxCommandArgs(base)
|
||||
if err != nil {
|
||||
@@ -456,8 +433,6 @@ func runWindowsRealSmokeCommand(t *testing.T, runnerExe string, base WindowsSand
|
||||
// explicit unsupported-mode rejections rather than sandboxed command failures).
|
||||
func runWindowsRealSmokeCommandExpectError(t *testing.T, runnerExe string, base WindowsSandboxCommandArgsOptions, command []string, wantSubstr ...string) {
|
||||
t.Helper()
|
||||
base, release := prepareWindowsRealSmokeCommand(t, base)
|
||||
defer release()
|
||||
base.Command = command
|
||||
args, err := BuildWindowsSandboxCommandArgs(base)
|
||||
if err != nil {
|
||||
|
||||
@@ -1,88 +0,0 @@
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// THE TWO HALVES OF A ROLLBACK RECORD MUST DESCRIBE ONE OBJECT.
|
||||
//
|
||||
// The snapshot used to read the identity through a handle, close it, and then
|
||||
// re-resolve the pathname to read the stamp. A rename between those pairs one
|
||||
// directory's identity with another's bytes, and a rollback that correctly
|
||||
// proves it holds the first then writes the second's contents into it.
|
||||
//
|
||||
// The window itself needs an elevated installer racing an unelevated renamer and
|
||||
// is not reproducible here, so this pins the contract the binding provides: both
|
||||
// facts come back together, and they agree with the object actually at the path.
|
||||
func TestStampSnapshotPairsIdentityWithItsOwnBytes(t *testing.T) {
|
||||
root := filepath.Join(t.TempDir(), "root")
|
||||
if err := os.MkdirAll(root, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const contents = "the stamp that belongs to this directory"
|
||||
if err := os.WriteFile(windowsSandboxRuntimeStampPath(root, testStampPlanHash), []byte(contents), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
identity, identified, prior, state, err := snapshotRuntimeStampBound(root, windowsSandboxRuntimeStampName(testStampPlanHash))
|
||||
if err != nil {
|
||||
t.Fatalf("snapshot: %v", err)
|
||||
}
|
||||
existed := state == runtimeStampPresent
|
||||
if !identified {
|
||||
t.Fatal("the snapshot established no identity for a directory that exists")
|
||||
}
|
||||
if !existed || string(prior) != contents {
|
||||
t.Fatalf("prior stamp = %q existed=%v, want %q", string(prior), existed, contents)
|
||||
}
|
||||
if direct, ok := runtimeDirIdentity(root); !ok || direct != identity {
|
||||
t.Errorf("snapshot identity %q does not describe the directory at the path (%q)", identity, direct)
|
||||
}
|
||||
}
|
||||
|
||||
// An absent stamp still establishes the identity, because that came from the
|
||||
// directory handle and not from the stamp read.
|
||||
func TestStampSnapshotIdentifiesARootWithNoStamp(t *testing.T) {
|
||||
root := filepath.Join(t.TempDir(), "root")
|
||||
if err := os.MkdirAll(root, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
identity, identified, _, state, err := snapshotRuntimeStampBound(root, windowsSandboxRuntimeStampName(testStampPlanHash))
|
||||
if err != nil {
|
||||
t.Fatalf("snapshot: %v", err)
|
||||
}
|
||||
existed := state == runtimeStampPresent
|
||||
if !identified || identity == "" {
|
||||
t.Error("a root with no stamp established no identity")
|
||||
}
|
||||
if existed {
|
||||
t.Error("reported a prior stamp that does not exist")
|
||||
}
|
||||
}
|
||||
|
||||
// A created directory's identity must come from the creation, so the ledger
|
||||
// cannot record an object this run did not make.
|
||||
func TestCreatedDirectoryIdentityDescribesWhatWasCreated(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "created")
|
||||
|
||||
identity, identified, err := createRuntimeDirIdentified(path)
|
||||
if err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
if !identified || identity == "" {
|
||||
t.Fatal("creation established no identity")
|
||||
}
|
||||
if info, statErr := os.Stat(path); statErr != nil || !info.IsDir() {
|
||||
t.Fatalf("the directory was not created: %v", statErr)
|
||||
}
|
||||
if direct, ok := runtimeDirIdentity(path); !ok || direct != identity {
|
||||
t.Errorf("creation identity %q does not describe the directory now at the path (%q)", identity, direct)
|
||||
}
|
||||
|
||||
// Creating over something that exists is the caller's already-handled signal.
|
||||
if _, _, again := createRuntimeDirIdentified(path); !os.IsExist(again) {
|
||||
t.Errorf("creating over an existing directory returned %v, want an IsExist error", again)
|
||||
}
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
//go:build windows
|
||||
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ABSENCE IS CLASSIFIED IN THE PRODUCER'S DOMAIN.
|
||||
//
|
||||
// The stamp is opened through NtCreateFile, so a stamp that is not there comes
|
||||
// back as STATUS_OBJECT_NAME_NOT_FOUND. Compensation compared it with the Win32
|
||||
// codes only. An NTStatus is a different type and errors.Is does not convert it,
|
||||
// so removing a stamp that never existed was reported as a rollback failure.
|
||||
//
|
||||
// Native, against real directories: the question is what the API returns at this
|
||||
// boundary, and a synthetic error value would only test the comparison.
|
||||
|
||||
func runtimeRootForCompensation(t *testing.T) (string, string) {
|
||||
t.Helper()
|
||||
root := filepath.Join(t.TempDir(), "zero", "runtime", "v1", "abcd")
|
||||
if err := os.MkdirAll(root, 0o700); err != nil {
|
||||
t.Fatalf("create the runtime root: %v", err)
|
||||
}
|
||||
identity, ok := runtimeDirIdentity(root)
|
||||
if !ok {
|
||||
t.Fatal("identify the runtime root")
|
||||
}
|
||||
return root, identity
|
||||
}
|
||||
|
||||
// A runtime directory that exists and holds no stamp: removal has nothing to do
|
||||
// and says so by succeeding.
|
||||
func TestRemovingAStampThatIsNotThereSucceeds(t *testing.T) {
|
||||
root, identity := runtimeRootForCompensation(t)
|
||||
name := windowsSandboxRuntimeStampName(testStampPlanHash)
|
||||
|
||||
if err := compensateRuntimeStampBound(root, identity, name, nil, false); err != nil {
|
||||
t.Fatalf("removing a stamp that was never written reported a failure: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(root); err != nil {
|
||||
t.Errorf("the runtime directory itself was disturbed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A fresh setup that fails BEFORE its stamp is written, composed the way the
|
||||
// setup transaction composes it: a real snapshot of the untouched tree, and the
|
||||
// compensation run with the original failure as its cause. The cause has to come
|
||||
// back alone. A second error about the stamp sends the operator looking for
|
||||
// residue that does not exist.
|
||||
func TestAFreshSetupFailureBeforeTheStampReportsOnlyItsCause(t *testing.T) {
|
||||
root, _ := runtimeRootForCompensation(t)
|
||||
snapshot, err := snapshotWindowsSandboxRuntimeStamp(root, testStampPlanHash)
|
||||
if err != nil {
|
||||
t.Fatalf("snapshot: %v", err)
|
||||
}
|
||||
if snapshot.priorState != runtimeStampAbsent || !snapshot.rootIdentified {
|
||||
t.Fatalf("SETUP INVALID: the snapshot did not record an identified root with no stamp: %+v", snapshot)
|
||||
}
|
||||
|
||||
cause := errors.New("windows ACL target does not exist: an earlier entry in the plan")
|
||||
reported := runWindowsSandboxSetupCompensations(cause, nil, windowsRuntimeRootRollback{stamp: snapshot})
|
||||
|
||||
if reported == nil || !errors.Is(reported, cause) {
|
||||
t.Fatalf("the original failure is no longer visible: %v", reported)
|
||||
}
|
||||
if text := reported.Error(); strings.Contains(text, "rollback failed") || strings.Contains(text, "setup stamp") {
|
||||
t.Errorf("compensation added a failure of its own for a stamp that never existed:\n%s", text)
|
||||
}
|
||||
}
|
||||
|
||||
// AND ONLY ABSENCE. Something at the stamp's name that cannot be opened as the
|
||||
// stamp is not "already removed", and treating every failed open as success
|
||||
// would report a clean undo over residue nobody looked at. A directory at that
|
||||
// name is a failure the real API produces on demand.
|
||||
func TestAnUninspectableStampIsStillReported(t *testing.T) {
|
||||
root, identity := runtimeRootForCompensation(t)
|
||||
name := windowsSandboxRuntimeStampName(testStampPlanHash)
|
||||
if err := os.Mkdir(filepath.Join(root, name), 0o700); err != nil {
|
||||
t.Fatalf("put a directory where the stamp would be: %v", err)
|
||||
}
|
||||
|
||||
err := compensateRuntimeStampBound(root, identity, name, nil, false)
|
||||
if err == nil {
|
||||
t.Fatal("compensation reported success over something it could not open as the stamp")
|
||||
}
|
||||
if isWindowsNotFound(err) {
|
||||
t.Errorf("the failure classifies as not-found, so it would be swallowed: %v", err)
|
||||
}
|
||||
if _, statErr := os.Stat(filepath.Join(root, name)); statErr != nil {
|
||||
t.Errorf("the object it could not inspect was removed anyway: %v", statErr)
|
||||
}
|
||||
}
|
||||
@@ -1,112 +0,0 @@
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// COMPENSATION MUST PROVE IT HOLDS THE OBJECT IT CHANGED.
|
||||
//
|
||||
// The forward apply and its stamp go through one handle, so they are provably
|
||||
// about one object. Compensation runs later, after those handles have closed,
|
||||
// and used to resolve the pathname again. Rename the original aside, put an
|
||||
// ordinary directory at the name, and a pathname-only undo strips a stamp from
|
||||
// the substitute, or writes bytes snapshotted from another object onto it, and
|
||||
// then removes it as though this run had created it, while the moved original
|
||||
// keeps this run's grant and stamp.
|
||||
func TestStampCompensationRefusesAReplacementDirectory(t *testing.T) {
|
||||
parent := t.TempDir()
|
||||
root := filepath.Join(parent, "runtime-root")
|
||||
if err := os.MkdirAll(root, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stampPath := windowsSandboxRuntimeStampPath(root, testStampPlanHash)
|
||||
if err := os.WriteFile(stampPath, []byte("previous-plan-hash"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
snapshot, err := snapshotWindowsSandboxRuntimeStamp(root, testStampPlanHash)
|
||||
if err != nil {
|
||||
t.Fatalf("snapshot: %v", err)
|
||||
}
|
||||
if snapshot.priorState != runtimeStampPresent {
|
||||
t.Fatal("SETUP INVALID: the snapshot did not record the pre-existing stamp")
|
||||
}
|
||||
|
||||
// The original is moved aside and an ordinary directory takes the name.
|
||||
moved := filepath.Join(parent, "moved-aside")
|
||||
if err := os.Rename(root, moved); err != nil {
|
||||
t.Skipf("cannot rename the runtime root on this filesystem: %v", err)
|
||||
}
|
||||
if err := os.MkdirAll(root, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
substituteStamp := filepath.Join(root, filepath.Base(stampPath))
|
||||
if err := os.WriteFile(substituteStamp, []byte("not-ours"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err = snapshot.restore()
|
||||
if err == nil {
|
||||
t.Fatal("compensation mutated a directory it never touched and reported success")
|
||||
}
|
||||
for _, want := range []string{"no longer the directory", "replacement"} {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Errorf("the error does not say what was left behind (%q): %v", want, err)
|
||||
}
|
||||
}
|
||||
if body, readErr := os.ReadFile(substituteStamp); readErr != nil || string(body) != "not-ours" {
|
||||
t.Errorf("the substitute's stamp was overwritten: %q %v", string(body), readErr)
|
||||
}
|
||||
if body, readErr := os.ReadFile(filepath.Join(moved, filepath.Base(stampPath))); readErr != nil || string(body) != "previous-plan-hash" {
|
||||
t.Errorf("the original lost its stamp: %q %v", string(body), readErr)
|
||||
}
|
||||
}
|
||||
|
||||
// And the created-directory ledger applies the same rule, or the stamp check
|
||||
// just moves the damage one line down.
|
||||
func TestCreatedDirectoryCompensationRefusesAReplacement(t *testing.T) {
|
||||
parent := t.TempDir()
|
||||
root := filepath.Join(parent, "created-root")
|
||||
if err := os.MkdirAll(root, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rollback := windowsRuntimeRootRollback{created: createdRuntimeDirsForTest(root)}
|
||||
|
||||
moved := filepath.Join(parent, "moved")
|
||||
if err := os.Rename(root, moved); err != nil {
|
||||
t.Skipf("cannot rename on this filesystem: %v", err)
|
||||
}
|
||||
if err := os.MkdirAll(root, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err := rollback.run()
|
||||
if err == nil {
|
||||
t.Fatal("a substitute directory was removed as though this run had created it")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "no longer the directory this run created") {
|
||||
t.Errorf("the error does not name the replacement: %v", err)
|
||||
}
|
||||
if _, statErr := os.Stat(root); statErr != nil {
|
||||
t.Errorf("the substitute was removed: %v", statErr)
|
||||
}
|
||||
}
|
||||
|
||||
// The ordinary case still cleans up, or the guard would be refusing everything.
|
||||
func TestCompensationStillRemovesWhatThisRunCreated(t *testing.T) {
|
||||
parent := t.TempDir()
|
||||
root := filepath.Join(parent, "mine")
|
||||
if err := os.MkdirAll(root, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rollback := windowsRuntimeRootRollback{created: createdRuntimeDirsForTest(root)}
|
||||
if err := rollback.run(); err != nil {
|
||||
t.Fatalf("compensation refused a directory it really did create: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(root); !os.IsNotExist(err) {
|
||||
t.Errorf("the directory this run created survived compensation: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
//go:build !windows
|
||||
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// runtimeCompensationSwapSeam exists so the shared compensation code compiles
|
||||
// everywhere. Only the Windows build closes a check-then-mutate window, because
|
||||
// only there does setup run elevated against a tree an unelevated process can
|
||||
// rename.
|
||||
var runtimeCompensationSwapSeam func()
|
||||
|
||||
func compensateRuntimeStampBound(root string, identity string, name string, prior []byte, existed bool) error {
|
||||
current, ok := runtimeDirIdentity(root)
|
||||
if !ok {
|
||||
return fmt.Errorf("identify the sandbox runtime root %s for stamp compensation", root)
|
||||
}
|
||||
if current != identity {
|
||||
return fmt.Errorf("sandbox runtime root %s is no longer the directory this setup stamped; "+
|
||||
"leaving the replacement untouched, and the original still carries this run's stamp", root)
|
||||
}
|
||||
if runtimeCompensationSwapSeam != nil {
|
||||
runtimeCompensationSwapSeam()
|
||||
}
|
||||
path := filepath.Join(root, name)
|
||||
if !existed {
|
||||
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
|
||||
return fmt.Errorf("remove sandbox runtime setup stamp written by this run: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if err := os.WriteFile(path, prior, 0o600); err != nil {
|
||||
return fmt.Errorf("restore the previous sandbox runtime setup stamp: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func removeCreatedRuntimeDirBound(path string, identity string) error {
|
||||
current, ok := runtimeDirIdentity(path)
|
||||
if !ok {
|
||||
// ONLY ABSENCE MEANS THERE IS NOTHING TO UNDO. Any lookup failure used to
|
||||
// return here, so a directory that exists but could not be identified was
|
||||
// reported as cleanly removed while it stayed on disk. The Windows build
|
||||
// already draws this line, and the rollback it serves promises to report
|
||||
// what it could not remove. Reported by CodeRabbit.
|
||||
if _, err := os.Lstat(path); errors.Is(err, os.ErrNotExist) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("sandbox runtime root %s was created by this run but could not be identified for removal; leaving it in place", path)
|
||||
}
|
||||
if current != identity {
|
||||
return fmt.Errorf("sandbox runtime root %s is no longer the directory this run created; "+
|
||||
"leaving the replacement in place", path)
|
||||
}
|
||||
if runtimeCompensationSwapSeam != nil {
|
||||
runtimeCompensationSwapSeam()
|
||||
}
|
||||
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
|
||||
return fmt.Errorf("remove sandbox runtime root %s created by this run: %w", path, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
//go:build !windows
|
||||
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ONLY ABSENCE IS NOTHING TO UNDO.
|
||||
//
|
||||
// removeCreatedRuntimeDirBound returned success on any failure to identify the
|
||||
// directory, so one that was still there, but could not be looked at, was
|
||||
// reported as removed. Here the parent loses search permission, which makes the
|
||||
// directory exist and be unidentifiable at the same time.
|
||||
func TestRemovingAnUnidentifiableCreatedDirectoryIsReported(t *testing.T) {
|
||||
if os.Geteuid() == 0 {
|
||||
t.Skip("root bypasses directory search permission, so the lookup cannot be made to fail")
|
||||
}
|
||||
parent := filepath.Join(t.TempDir(), "zero")
|
||||
created := filepath.Join(parent, "runtime")
|
||||
if err := os.MkdirAll(created, 0o700); err != nil {
|
||||
t.Fatalf("create the directory: %v", err)
|
||||
}
|
||||
identity, ok := runtimeDirIdentity(created)
|
||||
if !ok {
|
||||
t.Fatal("identify the created directory")
|
||||
}
|
||||
if err := os.Chmod(parent, 0o600); err != nil {
|
||||
t.Fatalf("withdraw search permission: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.Chmod(parent, 0o700) })
|
||||
if _, err := os.Lstat(created); err == nil || errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("SETUP INVALID: the directory should exist and be unidentifiable, Lstat said %v", err)
|
||||
}
|
||||
|
||||
err := removeCreatedRuntimeDirBound(created, identity)
|
||||
if err == nil {
|
||||
t.Fatal("a created directory that could not be identified was reported as removed")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "could not be identified") {
|
||||
t.Errorf("the error does not say why the directory was left: %v", err)
|
||||
}
|
||||
|
||||
// And it is still there, which is why the error is owed.
|
||||
if err := os.Chmod(parent, 0o700); err != nil {
|
||||
t.Fatalf("restore search permission: %v", err)
|
||||
}
|
||||
if _, err := os.Lstat(created); err != nil {
|
||||
t.Fatalf("the directory was gone after all, so the error was not owed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Absence stays a clean undo, or the test above would be satisfied by a function
|
||||
// that reports every lookup failure.
|
||||
func TestRemovingACreatedDirectoryThatIsAlreadyGoneSucceeds(t *testing.T) {
|
||||
created := filepath.Join(t.TempDir(), "runtime")
|
||||
if err := os.Mkdir(created, 0o700); err != nil {
|
||||
t.Fatalf("create the directory: %v", err)
|
||||
}
|
||||
identity, ok := runtimeDirIdentity(created)
|
||||
if !ok {
|
||||
t.Fatal("identify the created directory")
|
||||
}
|
||||
if err := os.Remove(created); err != nil {
|
||||
t.Fatalf("remove the directory: %v", err)
|
||||
}
|
||||
if err := removeCreatedRuntimeDirBound(created, identity); err != nil {
|
||||
t.Fatalf("a created directory that is already gone was reported as a failed undo: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
//go:build windows
|
||||
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// swapDuringCompensation installs a seam that runs once, between the identity
|
||||
// check and the mutation, and replaces root with a fresh directory carrying a
|
||||
// file of its own.
|
||||
func swapDuringCompensation(t *testing.T, root string, aside string) *string {
|
||||
t.Helper()
|
||||
fired := false
|
||||
previous := runtimeCompensationSwapSeam
|
||||
t.Cleanup(func() { runtimeCompensationSwapSeam = previous })
|
||||
runtimeCompensationSwapSeam = func() {
|
||||
if fired {
|
||||
return
|
||||
}
|
||||
fired = true
|
||||
if err := os.Rename(root, aside); err != nil {
|
||||
t.Logf("could not rename the original aside: %v", err)
|
||||
return
|
||||
}
|
||||
if err := os.MkdirAll(root, 0o700); err != nil {
|
||||
t.Fatalf("create the substitute: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, "substitute.txt"), []byte("belongs to whoever put it here"), 0o600); err != nil {
|
||||
t.Fatalf("seed the substitute: %v", err)
|
||||
}
|
||||
}
|
||||
return &aside
|
||||
}
|
||||
|
||||
// A REPLACEMENT AFTER THE CHECK MUST NOT REACH THE SUBSTITUTE.
|
||||
//
|
||||
// Compensation used to read the identity through a handle, close it, and then
|
||||
// resolve the pathname again for the write or the delete. A rename plus a
|
||||
// replacement in that interval made the comparison true about one object while
|
||||
// the mutation landed on another, under elevation. Holding the handle across
|
||||
// both means the mutation follows the object that was verified, and whatever now
|
||||
// answers to the name is untouched.
|
||||
func TestStampCompensationDoesNotReachASubstitute(t *testing.T) {
|
||||
parent := t.TempDir()
|
||||
root := filepath.Join(parent, "zero", "runtime", "v1", "abcd")
|
||||
if err := os.MkdirAll(root, 0o700); err != nil {
|
||||
t.Fatalf("create the runtime root: %v", err)
|
||||
}
|
||||
if err := writeWindowsSandboxRuntimeStamp(root, "planhash"); err != nil {
|
||||
t.Fatalf("write the stamp: %v", err)
|
||||
}
|
||||
identity, ok := runtimeDirIdentity(root)
|
||||
if !ok {
|
||||
t.Fatal("identify the runtime root")
|
||||
}
|
||||
aside := filepath.Join(parent, "renamed-aside")
|
||||
swapDuringCompensation(t, root, aside)
|
||||
|
||||
// Removal of a stamp this run wrote, i.e. the fresh-setup rollback.
|
||||
_ = compensateRuntimeStampBound(root, identity, windowsSandboxRuntimeStampName(testStampPlanHash), nil, false)
|
||||
|
||||
// Whatever the outcome, the substitute is not this run's business.
|
||||
substitute := filepath.Join(root, "substitute.txt")
|
||||
if _, err := os.Stat(substitute); err != nil {
|
||||
t.Errorf("compensation removed a file from the substitute directory: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(windowsSandboxRuntimeStampPath(root, testStampPlanHash)); err == nil {
|
||||
t.Error("compensation created a stamp inside the substitute directory")
|
||||
}
|
||||
// And the original, which is the object that was verified, is the one that
|
||||
// lost the stamp this run wrote.
|
||||
if _, err := os.Stat(windowsSandboxRuntimeStampPath(aside, testStampPlanHash)); err == nil {
|
||||
t.Error("the stamp this run wrote is still on the original object, so compensation followed the name instead")
|
||||
}
|
||||
}
|
||||
|
||||
// The same for the delete, where following the name would remove a directory
|
||||
// this run never created.
|
||||
func TestDirectoryCompensationDoesNotRemoveASubstitute(t *testing.T) {
|
||||
parent := t.TempDir()
|
||||
created := filepath.Join(parent, "created")
|
||||
if err := os.MkdirAll(created, 0o700); err != nil {
|
||||
t.Fatalf("create the directory: %v", err)
|
||||
}
|
||||
identity, ok := runtimeDirIdentity(created)
|
||||
if !ok {
|
||||
t.Fatal("identify the created directory")
|
||||
}
|
||||
aside := filepath.Join(parent, "renamed-aside")
|
||||
swapDuringCompensation(t, created, aside)
|
||||
|
||||
_ = removeCreatedRuntimeDirBound(created, identity)
|
||||
|
||||
if _, err := os.Stat(filepath.Join(created, "substitute.txt")); err != nil {
|
||||
t.Errorf("compensation removed the substitute directory's contents: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
//go:build windows
|
||||
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ONLY NOT-FOUND IS EVIDENCE OF REMOVAL.
|
||||
//
|
||||
// The verification probe used to read any Lstat error as absence, so a sharing
|
||||
// violation, an access denial, or an entry left delete-pending by another
|
||||
// process that holds a share-delete handle all reported complete compensation
|
||||
// for a directory that is still there. A holder able to clear the disposition
|
||||
// could then make the "removed" object visible again, and setup would already
|
||||
// have said the rollback finished.
|
||||
//
|
||||
// The three outcomes are distinguished with a probe seam, because a real
|
||||
// filesystem will not produce the third on demand.
|
||||
func TestDeletionVerificationDistinguishesAllThreeOutcomes(t *testing.T) {
|
||||
previous := runtimeCompensationStat
|
||||
t.Cleanup(func() { runtimeCompensationStat = previous })
|
||||
|
||||
newDir := func() (string, string) {
|
||||
t.Helper()
|
||||
dir := filepath.Join(t.TempDir(), "created")
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
identity, ok := runtimeDirIdentity(dir)
|
||||
if !ok {
|
||||
t.Fatal("identify the created directory")
|
||||
}
|
||||
return dir, identity
|
||||
}
|
||||
|
||||
t.Run("absence is proven and reported as success", func(t *testing.T) {
|
||||
dir, identity := newDir()
|
||||
runtimeCompensationStat = func(string) (fs.FileInfo, error) {
|
||||
return nil, &os.PathError{Op: "lstat", Path: dir, Err: os.ErrNotExist}
|
||||
}
|
||||
if err := removeCreatedRuntimeDirBound(dir, identity); err != nil {
|
||||
t.Errorf("a proven-absent directory was reported as a failure: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("still present is reported", func(t *testing.T) {
|
||||
dir, identity := newDir()
|
||||
runtimeCompensationStat = func(path string) (fs.FileInfo, error) { return os.Stat(".") }
|
||||
err := removeCreatedRuntimeDirBound(dir, identity)
|
||||
if err == nil {
|
||||
t.Fatal("a directory still present after deletion was reported as removed")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "still present") {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("an unverifiable probe is residue, not success", func(t *testing.T) {
|
||||
dir, identity := newDir()
|
||||
// What a share-delete holder produces: neither absence nor presence.
|
||||
runtimeCompensationStat = func(path string) (fs.FileInfo, error) {
|
||||
return nil, &os.PathError{Op: "lstat", Path: path, Err: errors.New("Access is denied.")}
|
||||
}
|
||||
err := removeCreatedRuntimeDirBound(dir, identity)
|
||||
if err == nil {
|
||||
t.Fatal("an unverifiable removal was reported as complete compensation")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "could not be verified") {
|
||||
t.Errorf("the error does not say the removal is unproven: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,195 +0,0 @@
|
||||
//go:build windows
|
||||
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"unsafe"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// THE HANDLE IS THE OBJECT. A pathname is not.
|
||||
//
|
||||
// Compensation used to read the directory identity through a handle, close it,
|
||||
// and then resolve the pathname again for the mutation. A rename followed by a
|
||||
// replacement between those two steps makes the comparison true about one
|
||||
// object while the write or delete lands on another, and this runs elevated, so
|
||||
// a redirected pathname gives the mutation reach the replacer does not have
|
||||
// directly.
|
||||
//
|
||||
// Opening once and keeping the handle open across BOTH the identity check and
|
||||
// the mutation removes the interval. The child operations are relative to that
|
||||
// handle, so no ancestor is re-resolved either.
|
||||
|
||||
// runtimeCompensationSwapSeam runs between the identity check and the mutation.
|
||||
// Nil in production; a test installs one to replace the directory in exactly the
|
||||
// window this design closes.
|
||||
var runtimeCompensationSwapSeam func()
|
||||
|
||||
// runtimeCompensationStat is the post-deletion existence probe. A var so a test
|
||||
// can produce the third outcome, an inspection that neither proves absence nor
|
||||
// presence, which no real filesystem produces on demand.
|
||||
var runtimeCompensationStat = os.Lstat
|
||||
|
||||
type fileDispositionInfo struct {
|
||||
DeleteFile byte
|
||||
}
|
||||
|
||||
func handleRuntimeIdentity(handle windows.Handle) (string, error) {
|
||||
var info windows.ByHandleFileInformation
|
||||
if err := windows.GetFileInformationByHandle(handle, &info); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("%d:%d:%d", info.VolumeSerialNumber, info.FileIndexHigh, info.FileIndexLow), nil
|
||||
}
|
||||
|
||||
// openVerifiedRuntimeDirectory opens path without following a link at the final
|
||||
// component and returns it only if it is still the object identity names.
|
||||
func openVerifiedRuntimeDirectory(path string, identity string, access uint32, what string) (windows.Handle, error) {
|
||||
utf16Path, err := windows.UTF16PtrFromString(path)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
handle, err := windows.CreateFile(
|
||||
utf16Path,
|
||||
access|windows.FILE_READ_ATTRIBUTES,
|
||||
windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE,
|
||||
nil,
|
||||
windows.OPEN_EXISTING,
|
||||
windows.FILE_FLAG_BACKUP_SEMANTICS|windows.FILE_FLAG_OPEN_REPARSE_POINT,
|
||||
0,
|
||||
)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
current, err := handleRuntimeIdentity(handle)
|
||||
if err != nil {
|
||||
_ = windows.CloseHandle(handle)
|
||||
return 0, fmt.Errorf("identify the sandbox runtime directory %s for compensation: %w", path, err)
|
||||
}
|
||||
if current != identity {
|
||||
_ = windows.CloseHandle(handle)
|
||||
return 0, fmt.Errorf("sandbox runtime root %s is no longer the directory this run %s; "+
|
||||
"leaving the replacement in place, and the original still carries this run's changes", path, what)
|
||||
}
|
||||
return handle, nil
|
||||
}
|
||||
|
||||
// markForDeletion queues the object the handle names for removal. It never
|
||||
// resolves a pathname, so it can only reach what this process already holds.
|
||||
func markForDeletion(handle windows.Handle) error {
|
||||
info := fileDispositionInfo{DeleteFile: 1}
|
||||
return windows.SetFileInformationByHandle(
|
||||
handle,
|
||||
windows.FileDispositionInfo,
|
||||
(*byte)(unsafe.Pointer(&info)),
|
||||
uint32(unsafe.Sizeof(info)),
|
||||
)
|
||||
}
|
||||
|
||||
// compensateRuntimeStampBound restores or removes the stamp through a handle on
|
||||
// the directory whose identity still matches.
|
||||
//
|
||||
// RESTORED WITHOUT A GAP. This used to delete the stamp and then recreate the
|
||||
// previous one, so a command validating in between, or a process stopping
|
||||
// there, found no stamp at all where the previous setup had left a valid one.
|
||||
// The ordinary writer replaces the stamp in one step and puts the protected
|
||||
// DACL back, which a raw write would not, so a restore is just a publish of the
|
||||
// previous bytes. Only the branch that had no stamp before deletes, because
|
||||
// absent is the state it restores. Reported by @jatmn.
|
||||
func compensateRuntimeStampBound(root string, identity string, name string, prior []byte, existed bool) error {
|
||||
directory, err := openVerifiedRuntimeDirectory(root, identity,
|
||||
windows.FILE_TRAVERSE|windowsFileAddFile|windows.READ_CONTROL|windows.SYNCHRONIZE, "stamped")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer windows.CloseHandle(directory)
|
||||
|
||||
if runtimeCompensationSwapSeam != nil {
|
||||
runtimeCompensationSwapSeam()
|
||||
}
|
||||
|
||||
if !existed {
|
||||
return deleteRuntimeStampChild(directory, name)
|
||||
}
|
||||
// Through the ordinary writer so it is protected again, and so the reader ACE
|
||||
// is resolved the same way a fresh setup resolves it.
|
||||
if err := writeWindowsRuntimeStampToDirectoryHandle(directory, name, string(prior)); err != nil {
|
||||
return fmt.Errorf("restore the previous sandbox runtime setup stamp: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// deleteRuntimeStampChild removes the stamp relative to an already verified
|
||||
// directory handle. A stamp that is not there is the desired end state; anything
|
||||
// else is reported rather than swallowed.
|
||||
func deleteRuntimeStampChild(directory windows.Handle, name string) error {
|
||||
stamp, err := openWindowsChildNoFollow(directory, name,
|
||||
windows.DELETE|windows.FILE_READ_ATTRIBUTES, windows.FILE_NON_DIRECTORY_FILE)
|
||||
if err != nil {
|
||||
// ABSENCE IS CLASSIFIED BY THE PRODUCER'S DOMAIN. openWindowsChildNoFollow
|
||||
// goes through NtCreateFile, so a stamp that is not there arrives as
|
||||
// STATUS_OBJECT_NAME_NOT_FOUND. This compared against the Win32 codes only,
|
||||
// and an NTStatus is a different type that errors.Is does not convert, so a
|
||||
// fresh setup that failed before writing any stamp reported a second,
|
||||
// spurious rollback failure for removing a file that never existed. The
|
||||
// snapshot path already answers this question for both domains, and using
|
||||
// the same answer keeps snapshot and compensation agreeing about what
|
||||
// absence means. Reported by @jatmn.
|
||||
if isWindowsNotFound(err) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("remove sandbox runtime setup stamp written by this run: %w", err)
|
||||
}
|
||||
if err := markForDeletion(stamp); err != nil {
|
||||
_ = windows.CloseHandle(stamp)
|
||||
return fmt.Errorf("remove sandbox runtime setup stamp written by this run: %w", err)
|
||||
}
|
||||
// The entry goes when the last handle closes.
|
||||
_ = windows.CloseHandle(stamp)
|
||||
return nil
|
||||
}
|
||||
|
||||
// removeCreatedRuntimeDirBound removes a directory this run created, through a
|
||||
// handle on the object identity names.
|
||||
func removeCreatedRuntimeDirBound(path string, identity string) error {
|
||||
handle, err := openVerifiedRuntimeDirectory(path, identity, windows.DELETE, "created")
|
||||
if err != nil {
|
||||
if isWindowsNotFound(err) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
if runtimeCompensationSwapSeam != nil {
|
||||
runtimeCompensationSwapSeam()
|
||||
}
|
||||
if err := markForDeletion(handle); err != nil {
|
||||
_ = windows.CloseHandle(handle)
|
||||
return fmt.Errorf("remove sandbox runtime root %s created by this run: %w", path, err)
|
||||
}
|
||||
// The entry goes when the last handle closes, so releasing ours is what makes
|
||||
// the removal observable.
|
||||
_ = windows.CloseHandle(handle)
|
||||
// REPORTED SUCCESS IS NOT PROOF. A handle-bound operation accepting the call
|
||||
// has been seen not to take effect (PR #751, the promote rename), so the
|
||||
// outcome is checked rather than assumed.
|
||||
//
|
||||
// THREE OUTCOMES, NOT TWO. This used to read any Lstat error as absence, so a
|
||||
// sharing violation, an access denial, or a delete-pending entry held open by
|
||||
// another process all reported complete compensation for a directory that is
|
||||
// still there. A holder that clears the disposition can then make the
|
||||
// "removed" object visible again. Only not-found is evidence of removal;
|
||||
// everything else is at best unproven and has to be reported as residue.
|
||||
_, statErr := runtimeCompensationStat(path)
|
||||
switch {
|
||||
case errors.Is(statErr, os.ErrNotExist):
|
||||
return nil
|
||||
case statErr == nil:
|
||||
return fmt.Errorf("remove sandbox runtime root %s created by this run: it is still present after the deletion was accepted", path)
|
||||
default:
|
||||
return fmt.Errorf("remove sandbox runtime root %s created by this run: its removal could not be verified, so it may still be present: %w", path, statErr)
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
package sandbox
|
||||
|
||||
// runtimeIdentityAfterCreate resolves a just-created runtime directory's
|
||||
// identity BY PATHNAME, which is a second resolution of the same name and
|
||||
// therefore a window: the directory created can be renamed away and an ordinary
|
||||
// one substituted before this runs, and the ledger then records the substitute
|
||||
// for a directory the run never made.
|
||||
//
|
||||
// It is the honest implementation off Windows, where the elevated-installer
|
||||
// versus unelevated-renamer split this closes does not apply. On Windows the
|
||||
// creation returns the handle and identity is read from that, so nothing there
|
||||
// may call this: a Windows test asserts the count is zero, which is what makes
|
||||
// "the creation establishes the identity" a checked property rather than a
|
||||
// comment.
|
||||
var runtimeIdentityAfterCreate = runtimeDirIdentity
|
||||
@@ -1,15 +0,0 @@
|
||||
//go:build !windows
|
||||
|
||||
package sandbox
|
||||
|
||||
import "os"
|
||||
|
||||
// createRuntimeDirIdentified keeps the pathname form off Windows, where the
|
||||
// elevated-installer-versus-unelevated-renamer split this closes does not apply.
|
||||
func createRuntimeDirIdentified(path string) (string, bool, error) {
|
||||
if err := os.Mkdir(path, 0o700); err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
identity, ok := runtimeIdentityAfterCreate(path)
|
||||
return identity, ok, nil
|
||||
}
|
||||
@@ -1,131 +0,0 @@
|
||||
//go:build windows
|
||||
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"unsafe"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// createRuntimeDirIdentified creates path and returns the identity of the
|
||||
// directory it created, read from the handle the creation itself returned.
|
||||
//
|
||||
// THE CREATION MUST BE THE THING THAT ESTABLISHES IDENTITY. os.Mkdir followed by
|
||||
// runtimeDirIdentity(path) is two resolutions of one name: Mkdir creates A, and
|
||||
// the reopen can land on a B substituted in between, so the rollback ledger
|
||||
// records B's identity for a directory this run never made. Compensation then
|
||||
// correctly proves it holds B and deletes it, while A keeps this run's ACL and
|
||||
// stamp under a name nothing is tracking. The runtime parent belongs to the
|
||||
// ordinary user, so that substitution needs no privilege.
|
||||
//
|
||||
// Win32 CreateFile cannot create a directory at all, whatever disposition it is
|
||||
// given, so an earlier attempt to do this with CREATE_NEW fell through to the
|
||||
// Mkdir-plus-reopen path on EVERY real creation and the documented handle
|
||||
// contract never once held. NtCreateFile with FILE_CREATE and
|
||||
// FILE_DIRECTORY_FILE does create one, and returns the handle to it.
|
||||
//
|
||||
// If the atomic create cannot be completed, this returns an error rather than
|
||||
// manufacturing an ownership record from a reopen: an unidentified create must
|
||||
// stop setup before privileged state is applied, not enter the ledger as though
|
||||
// it were proven.
|
||||
func createRuntimeDirIdentified(path string) (string, bool, error) {
|
||||
clean := filepath.Clean(path)
|
||||
parentPath, leaf := filepath.Split(clean)
|
||||
leaf = filepath.Clean(leaf)
|
||||
parentPath = filepath.Clean(parentPath)
|
||||
if leaf == "" || leaf == "." || parentPath == clean {
|
||||
return "", false, fmt.Errorf("sandbox runtime path %s has no component to create", path)
|
||||
}
|
||||
|
||||
// The parent is either a directory that already existed when the missing
|
||||
// components were computed, or one this same loop created a moment ago.
|
||||
parent, err := openWindowsDirectoryByName(parentPath)
|
||||
if err != nil {
|
||||
return "", false, fmt.Errorf("open sandbox runtime parent %s: %w", parentPath, err)
|
||||
}
|
||||
defer windows.CloseHandle(parent)
|
||||
|
||||
handle, err := createWindowsChildDirectory(parent, leaf)
|
||||
if err != nil {
|
||||
// A collision is the same signal os.Mkdir gives for a component another
|
||||
// process won the race to create, and the caller already treats that as
|
||||
// "not ours" (windows_setup.go). Returned as a *PathError so BOTH
|
||||
// os.IsExist, which the caller uses and which does not unwrap %w, and
|
||||
// errors.Is recognize it; wrapping with %w alone would have turned a
|
||||
// benign race into a hard setup failure.
|
||||
if errors.Is(err, windows.STATUS_OBJECT_NAME_COLLISION) {
|
||||
return "", false, &os.PathError{Op: "mkdir", Path: clean, Err: os.ErrExist}
|
||||
}
|
||||
return "", false, fmt.Errorf("create sandbox runtime directory %s: %w", clean, err)
|
||||
}
|
||||
defer windows.CloseHandle(handle)
|
||||
|
||||
identity, idErr := handleRuntimeIdentity(handle)
|
||||
if idErr != nil {
|
||||
return "", false, fmt.Errorf("identify the sandbox runtime directory created at %s: %w", clean, idErr)
|
||||
}
|
||||
return identity, true, nil
|
||||
}
|
||||
|
||||
// removeWindowsObjectByHandle deletes the object this handle is open on.
|
||||
//
|
||||
// THROUGH THE HANDLE, NOT THE NAME. Every caller is on a failure path partway
|
||||
// through a creation, and the one object it may remove is the one it just made
|
||||
// and is still holding. Re-resolving the name to delete it is the substitution
|
||||
// hole this whole descent exists to close, and here it would be a pathname
|
||||
// authoritative delete of somebody else's directory.
|
||||
//
|
||||
// The handle needs DELETE access, which the creating opens above ask for.
|
||||
func removeWindowsObjectByHandle(handle windows.Handle) error {
|
||||
var iosb windows.IO_STATUS_BLOCK
|
||||
deleteIt := byte(1)
|
||||
return windows.NtSetInformationFile(handle, &iosb, &deleteIt, 1, windows.FileDispositionInformation)
|
||||
}
|
||||
|
||||
// createWindowsChildDirectory creates exactly one directory component beneath
|
||||
// parent and returns the handle to the object it created.
|
||||
//
|
||||
// Relative to a handle, so no ancestor is re-resolved and there is no interval
|
||||
// for a swap to land in. FILE_CREATE fails rather than opening anything that is
|
||||
// already there, so the returned handle cannot describe a pre-existing object,
|
||||
// and FILE_OPEN_REPARSE_POINT keeps the failure honest if one is.
|
||||
func createWindowsChildDirectory(parent windows.Handle, name string) (windows.Handle, error) {
|
||||
objectName, err := windows.NewNTUnicodeString(name)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("encode sandbox runtime component %s: %w", name, err)
|
||||
}
|
||||
attributes := windows.OBJECT_ATTRIBUTES{
|
||||
RootDirectory: parent,
|
||||
ObjectName: objectName,
|
||||
Attributes: windows.OBJ_CASE_INSENSITIVE,
|
||||
}
|
||||
attributes.Length = uint32(unsafe.Sizeof(attributes))
|
||||
|
||||
var handle windows.Handle
|
||||
var iosb windows.IO_STATUS_BLOCK
|
||||
// DELETE because the caller has to be able to undo this creation through the
|
||||
// handle it just got, without going back through the name. See
|
||||
// removeWindowsObjectByHandle.
|
||||
err = windows.NtCreateFile(
|
||||
&handle,
|
||||
windows.DELETE|windows.FILE_READ_ATTRIBUTES|windows.FILE_TRAVERSE|windows.SYNCHRONIZE,
|
||||
&attributes,
|
||||
&iosb,
|
||||
nil,
|
||||
windows.FILE_ATTRIBUTE_DIRECTORY,
|
||||
windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE,
|
||||
windows.FILE_CREATE,
|
||||
windows.FILE_DIRECTORY_FILE|windows.FILE_SYNCHRONOUS_IO_NONALERT|windows.FILE_OPEN_REPARSE_POINT,
|
||||
0,
|
||||
0,
|
||||
)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return handle, nil
|
||||
}
|
||||
@@ -1,158 +0,0 @@
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// CREATION IS THE OWNERSHIP BOUNDARY, NOT THE STEP AFTER IT.
|
||||
//
|
||||
// Acquisition learns it created a directory or a lease file at the create
|
||||
// itself, but used to publish that fact only once every later step had also
|
||||
// succeeded: the identity read for a directory, and the inspection, wrapping and
|
||||
// locking for a lease. A failure in between returned an error while leaving the
|
||||
// object on disk with nothing above holding a record of it. Setup then reported
|
||||
// failure and left invocation-owned state behind, and where an ancestor HAD been
|
||||
// recorded, that ancestor's compensation failed afterwards on a child it could
|
||||
// not account for.
|
||||
//
|
||||
// Both platforms are driven through acquireRuntimeLeaseForPlatform, the call
|
||||
// production makes, rather than through either descent directly.
|
||||
|
||||
var errInjectedRuntimeFailure = errors.New("injected failure after the create")
|
||||
|
||||
// failRuntimeCreationAt makes the step after the creation of the named object
|
||||
// fail, which is the only way to reach the paths under test.
|
||||
func failRuntimeCreationAt(t *testing.T, match string) {
|
||||
t.Helper()
|
||||
runtimeCreationFailure = func(created string) error {
|
||||
if filepath.Base(created) == match {
|
||||
return errInjectedRuntimeFailure
|
||||
}
|
||||
return nil
|
||||
}
|
||||
t.Cleanup(func() { runtimeCreationFailure = nil })
|
||||
}
|
||||
|
||||
// runtimeRootForAcquisition returns a root whose owned tail does not exist yet,
|
||||
// so acquisition has to create it.
|
||||
func runtimeRootForAcquisition(t *testing.T) string {
|
||||
t.Helper()
|
||||
base := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(base, "zero", "runtime"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return filepath.Join(base, "zero", "runtime", "v1", "digest")
|
||||
}
|
||||
|
||||
// A directory whose identity could not be read is removed, not abandoned.
|
||||
func TestAcquisitionUndoesADirectoryItCreatedButCouldNotIdentify(t *testing.T) {
|
||||
root := runtimeRootForAcquisition(t)
|
||||
version := filepath.Dir(root)
|
||||
failRuntimeCreationAt(t, filepath.Base(version))
|
||||
|
||||
lease, created, err := acquireRuntimeLeaseForPlatform(root)
|
||||
if err == nil {
|
||||
lease.release()
|
||||
t.Fatal("SETUP INVALID: acquisition succeeded, so the injected failure never fired")
|
||||
}
|
||||
if !errors.Is(err, errInjectedRuntimeFailure) {
|
||||
t.Fatalf("acquisition failed for some other reason: %v", err)
|
||||
}
|
||||
if len(created) != 0 {
|
||||
t.Errorf("acquisition returned a ledger for a failed create: %v", created)
|
||||
}
|
||||
if _, statErr := os.Stat(version); !os.IsNotExist(statErr) {
|
||||
t.Errorf("%s was created by this run and left behind after the failure, with nothing recording it (stat: %v)", version, statErr)
|
||||
}
|
||||
// And what was already there is untouched.
|
||||
if _, statErr := os.Stat(filepath.Dir(version)); statErr != nil {
|
||||
t.Errorf("a directory that existed before the run was removed: %v", statErr)
|
||||
}
|
||||
}
|
||||
|
||||
// So is a lease file this run created but never got to lock.
|
||||
func TestAcquisitionUndoesALeaseFileItCreatedButCouldNotLock(t *testing.T) {
|
||||
root := runtimeRootForAcquisition(t)
|
||||
leasePath := sandboxRuntimeLeasePath(root)
|
||||
failRuntimeCreationAt(t, filepath.Base(leasePath))
|
||||
|
||||
lease, _, err := acquireRuntimeLeaseForPlatform(root)
|
||||
if err == nil {
|
||||
lease.release()
|
||||
t.Fatal("SETUP INVALID: acquisition succeeded, so the injected failure never fired")
|
||||
}
|
||||
if !errors.Is(err, errInjectedRuntimeFailure) {
|
||||
t.Fatalf("acquisition failed for some other reason: %v", err)
|
||||
}
|
||||
if _, statErr := os.Stat(leasePath); !os.IsNotExist(statErr) {
|
||||
t.Errorf("%s was created by this run and left behind after the failure, so its directory can never be compensated (stat: %v)", leasePath, statErr)
|
||||
}
|
||||
}
|
||||
|
||||
// A LEASE FILE THAT WAS ALREADY THERE IS SOMEBODY ELSE'S.
|
||||
//
|
||||
// The undo is keyed on this call having created the object, so an acquisition
|
||||
// that merely opened an existing lease must not remove it on its way out.
|
||||
func TestAcquisitionLeavesALeaseFileItDidNotCreate(t *testing.T) {
|
||||
root := runtimeRootForAcquisition(t)
|
||||
if err := os.MkdirAll(filepath.Dir(root), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
leasePath := sandboxRuntimeLeasePath(root)
|
||||
if err := os.WriteFile(leasePath, nil, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
failRuntimeCreationAt(t, filepath.Base(leasePath))
|
||||
|
||||
lease, _, err := acquireRuntimeLeaseForPlatform(root)
|
||||
if err == nil {
|
||||
lease.release()
|
||||
t.Fatal("SETUP INVALID: acquisition succeeded, so the injected failure never fired")
|
||||
}
|
||||
if _, statErr := os.Stat(leasePath); statErr != nil {
|
||||
t.Errorf("acquisition removed a lease file it did not create: %v", statErr)
|
||||
}
|
||||
}
|
||||
|
||||
// AND THE LEDGER STILL COVERS WHAT SURVIVES A LATER FAILURE.
|
||||
//
|
||||
// Undoing the component that failed must not undo the ones before it: those are
|
||||
// recorded, and compensation is what removes them.
|
||||
func TestAcquisitionKeepsTheLedgerForComponentsItFinished(t *testing.T) {
|
||||
base := t.TempDir()
|
||||
root := filepath.Join(base, "zero", "runtime", "v1", "digest")
|
||||
failRuntimeCreationAt(t, "v1")
|
||||
|
||||
lease, created, err := acquireRuntimeLeaseForPlatform(root)
|
||||
if err == nil {
|
||||
lease.release()
|
||||
t.Fatal("SETUP INVALID: acquisition succeeded, so the injected failure never fired")
|
||||
}
|
||||
var recorded []string
|
||||
for _, entry := range created {
|
||||
recorded = append(recorded, filepath.Base(entry.path))
|
||||
}
|
||||
// "zero" and "runtime" were created and identified before the injected
|
||||
// failure, so they belong to the caller to compensate.
|
||||
if len(recorded) != 2 || recorded[0] != "zero" || recorded[1] != "runtime" {
|
||||
t.Errorf("the completed components are not on the ledger the caller compensates from: %v", recorded)
|
||||
}
|
||||
if _, statErr := os.Stat(filepath.Join(base, "zero", "runtime")); statErr != nil {
|
||||
t.Errorf("a component that was created and recorded was removed anyway: %v", statErr)
|
||||
}
|
||||
if failed := filepath.Join(base, "zero", "runtime", "v1"); !isMissing(failed) {
|
||||
t.Errorf("%s failed after being created and was left behind", failed)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "v1") {
|
||||
t.Errorf("the error does not name the component that failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func isMissing(path string) bool {
|
||||
_, err := os.Stat(path)
|
||||
return os.IsNotExist(err)
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
//go:build !windows
|
||||
|
||||
package sandbox
|
||||
|
||||
import "golang.org/x/sys/unix"
|
||||
|
||||
// createRuntimeTailHandleRelative creates the owned tail through the same
|
||||
// no-follow boundary the lease uses.
|
||||
//
|
||||
// This was an os.Mkdir loop over full pathnames, on the reasoning that the
|
||||
// elevated-installer-versus-unelevated-renamer split the Windows descent closes
|
||||
// does not apply here. That is true of the elevation asymmetry and false of the
|
||||
// substitution: the fallback root is a predictable name under a temp directory
|
||||
// every local account can write to, so another user can put a link at an owned
|
||||
// component and have this loop create the rest of the tree inside it. Same
|
||||
// descent, same refusal, one implementation per platform rather than one
|
||||
// protected platform.
|
||||
func createRuntimeTailHandleRelative(base string, tail []string) ([]windowsCreatedRuntimeDir, error) {
|
||||
created, parent, err := createRuntimeTailRetainingFD(base, tail)
|
||||
if parent >= 0 {
|
||||
_ = unix.Close(parent)
|
||||
}
|
||||
return created, err
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
package sandbox
|
||||
|
||||
// The two seams the rooted descent exposes, shared by both platform
|
||||
// implementations so a test asserts the same property on either.
|
||||
//
|
||||
// They live apart from the Windows descent they started in because the POSIX
|
||||
// fallback now takes the same shape, and a seam defined next to one of two
|
||||
// implementations is how the other one quietly ends up untested.
|
||||
|
||||
// runtimeDescentBarrier, when set, runs after the base directory has been opened
|
||||
// and before the first owned component is touched. It exists so a test can swap
|
||||
// an owned component for a link at exactly the point the old pathname walk was
|
||||
// vulnerable, and prove the redirected target is never created or granted. Nil in
|
||||
// production.
|
||||
var runtimeDescentBarrier func()
|
||||
|
||||
// runtimeCreationFailure, when set, is consulted immediately after this run
|
||||
// creates a runtime object and before the step that would publish it: a
|
||||
// directory component's identity read, or the lease file's inspection, wrapping
|
||||
// and locking. Returning an error stands in for that step failing.
|
||||
//
|
||||
// It is the only way to reach those paths, and they are the ones where the
|
||||
// transaction knows it created something that nothing above it knows about yet.
|
||||
// The argument is the created component's path, or the lease file's name. Nil in
|
||||
// production.
|
||||
var runtimeCreationFailure func(string) error
|
||||
|
||||
// runtimeCleanupExclusivityBarrier, when set, runs during compensation while the
|
||||
// exclusive cleanup lease is held: after it has been taken and before the first
|
||||
// thing is removed. It exists so a test can put a contender into that window and
|
||||
// prove the exclusion still covers it, which is precisely where the old shape had
|
||||
// already handed the lease back. Nil in production.
|
||||
var runtimeCleanupExclusivityBarrier func()
|
||||
|
||||
// runtimeBaseOpenedByName, when set, receives the ONE path this descent opens by
|
||||
// name. The whole security property is which path that is: the fixed cache or
|
||||
// temp directory above the owned tail, never a predictable component Zero owns. A
|
||||
// test can assert it directly instead of inferring it from whether a swap
|
||||
// happened to be caught, which is not discriminating. Nil in production.
|
||||
var runtimeBaseOpenedByName func(string)
|
||||
@@ -1,171 +0,0 @@
|
||||
//go:build !windows
|
||||
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
// createRuntimeTailRetainingFD creates the owned tail of the runtime root
|
||||
// beneath base, one component at a time, from retained descriptors.
|
||||
//
|
||||
// THE NAME IS NOT AUTHORIZATION, ON THIS SIDE EITHER.
|
||||
//
|
||||
// The fallback root moved from an atomically minted os.MkdirTemp parent to a
|
||||
// predictable /tmp/zero-u<uid>/runtime/v1/<digest>. The stable name is required,
|
||||
// because setup and every later process have to agree on one root without
|
||||
// talking to each other, but it also means another local account can name the
|
||||
// first owned component before this one does. refuseAliasedRuntimeComponents
|
||||
// answers about a component that is ABSENT by saying there is nothing to alias,
|
||||
// and the code then called os.MkdirAll on the parent and opened <root>.lease by
|
||||
// full pathname. Both follow a link planted after that answer, so the guard was
|
||||
// authorizing writes it could not see the destination of.
|
||||
//
|
||||
// So the base is opened by name exactly once, and every component below it is
|
||||
// created or opened relative to a retained descriptor with O_NOFOLLOW. A link at
|
||||
// an owned component is then an ELOOP or ENOTDIR from the kernel rather than a
|
||||
// redirection nobody noticed, and no owned name is ever resolved twice.
|
||||
//
|
||||
// The base itself is opened WITHOUT O_NOFOLLOW. It belongs to the operator and is
|
||||
// legitimately a link on macOS, where /tmp resolves through /private. Only the
|
||||
// tail Zero creates is held to the stricter rule.
|
||||
func createRuntimeTailRetainingFD(base string, tail []string) ([]windowsCreatedRuntimeDir, int, error) {
|
||||
if runtimeBaseOpenedByName != nil {
|
||||
runtimeBaseOpenedByName(base)
|
||||
}
|
||||
parent, err := unix.Open(base, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_CLOEXEC, 0)
|
||||
if err != nil {
|
||||
return nil, -1, fmt.Errorf("open sandbox runtime base %s: %w", base, err)
|
||||
}
|
||||
|
||||
if runtimeDescentBarrier != nil {
|
||||
runtimeDescentBarrier()
|
||||
}
|
||||
|
||||
var created []windowsCreatedRuntimeDir
|
||||
for _, path := range tail {
|
||||
name := filepath.Base(path)
|
||||
child, madeIt, openErr := openOrCreateRuntimeChildNoFollow(parent, name)
|
||||
if openErr != nil {
|
||||
_ = unix.Close(parent)
|
||||
return created, -1, fmt.Errorf("open sandbox runtime component %s: %w", path, openErr)
|
||||
}
|
||||
// CREATED, AND ABOUT TO BE FORGOTTEN. Both checks below run before the
|
||||
// ledger entry is appended, so failing either used to return a directory
|
||||
// this run had made with nothing above holding a record of it: setup
|
||||
// reported failure and left it behind, and a recorded ancestor's
|
||||
// compensation then failed on a child it could not explain.
|
||||
//
|
||||
// Undone relative to the parent descriptor rather than published to the
|
||||
// ledger, because on the identity path the identity the ledger needs is the
|
||||
// thing that just failed, and the descriptor is the only authority here
|
||||
// that is not a name.
|
||||
undoCreation := func(err error) error {
|
||||
if !madeIt {
|
||||
return err
|
||||
}
|
||||
if undoErr := unix.Unlinkat(parent, name, unix.AT_REMOVEDIR); undoErr != nil {
|
||||
return fmt.Errorf("%w; and the directory this run created there could not be removed: %w", err, undoErr)
|
||||
}
|
||||
return err
|
||||
}
|
||||
if err := refuseForeignRuntimeDirectory(child, path); err != nil {
|
||||
err = undoCreation(err)
|
||||
_ = unix.Close(child)
|
||||
_ = unix.Close(parent)
|
||||
return created, -1, err
|
||||
}
|
||||
if madeIt {
|
||||
identity, idErr := runtimeDirectoryIdentity(child)
|
||||
if idErr == nil && runtimeCreationFailure != nil {
|
||||
idErr = runtimeCreationFailure(path)
|
||||
}
|
||||
if idErr != nil {
|
||||
err := undoCreation(fmt.Errorf("identify the sandbox runtime directory created at %s: %w", path, idErr))
|
||||
_ = unix.Close(child)
|
||||
_ = unix.Close(parent)
|
||||
return created, -1, err
|
||||
}
|
||||
created = append(created, windowsCreatedRuntimeDir{path: path, identity: identity, identified: true})
|
||||
}
|
||||
_ = unix.Close(parent)
|
||||
parent = child
|
||||
}
|
||||
return created, parent, nil
|
||||
}
|
||||
|
||||
// openOrCreateRuntimeChildNoFollow opens name under parent, creating it if it is
|
||||
// not there, and never follows a link at that name.
|
||||
//
|
||||
// madeIt distinguishes the directory this process created from one that was
|
||||
// already there, because only the former belongs on the rollback ledger.
|
||||
func openOrCreateRuntimeChildNoFollow(parent int, name string) (fd int, madeIt bool, err error) {
|
||||
const flags = unix.O_RDONLY | unix.O_DIRECTORY | unix.O_NOFOLLOW | unix.O_CLOEXEC
|
||||
child, openErr := unix.Openat(parent, name, flags, 0)
|
||||
if openErr == nil {
|
||||
return child, false, nil
|
||||
}
|
||||
if !errors.Is(openErr, unix.ENOENT) {
|
||||
// ELOOP or ENOTDIR here IS the finding: a link sits where a directory of
|
||||
// ours should be. Returned as-is rather than translated, because the two
|
||||
// platforms spell it differently and the caller only needs it to fail.
|
||||
return -1, false, openErr
|
||||
}
|
||||
if mkErr := unix.Mkdirat(parent, name, 0o700); mkErr != nil {
|
||||
if !errors.Is(mkErr, unix.EEXIST) {
|
||||
return -1, false, mkErr
|
||||
}
|
||||
// Somebody created it between the open and the create. That is an ordinary
|
||||
// race with another Zero process, and the reopen below is still no-follow,
|
||||
// so a link that arrived in the same window is refused rather than used.
|
||||
child, openErr = unix.Openat(parent, name, flags, 0)
|
||||
if openErr != nil {
|
||||
return -1, false, openErr
|
||||
}
|
||||
return child, false, nil
|
||||
}
|
||||
child, openErr = unix.Openat(parent, name, flags, 0)
|
||||
if openErr != nil {
|
||||
return -1, false, openErr
|
||||
}
|
||||
return child, true, nil
|
||||
}
|
||||
|
||||
// refuseForeignRuntimeDirectory proves the directory reached belongs to this
|
||||
// user and is not group- or world-writable.
|
||||
//
|
||||
// Asked of the DESCRIPTOR, so it describes the object the descent is holding
|
||||
// rather than whatever the name resolves to now. Ownership is the part that
|
||||
// matters on a shared temp root: a component another account created first is
|
||||
// theirs, and continuing into it would put the sandbox runtime inside a tree they
|
||||
// control even though nothing about it is a link.
|
||||
func refuseForeignRuntimeDirectory(fd int, path string) error {
|
||||
var stat unix.Stat_t
|
||||
if err := unix.Fstat(fd, &stat); err != nil {
|
||||
return fmt.Errorf("inspect sandbox runtime component %s: %w", path, err)
|
||||
}
|
||||
if uid := unix.Getuid(); uid >= 0 && stat.Uid != uint32(uid) {
|
||||
return fmt.Errorf("refusing to use the sandbox runtime component %s: it is owned by uid %d, not %d, so another account chose what the sandbox writes into",
|
||||
path, stat.Uid, uid)
|
||||
}
|
||||
if stat.Mode&(unix.S_IWGRP|unix.S_IWOTH) != 0 {
|
||||
return fmt.Errorf("refusing to use the sandbox runtime component %s: mode %#o lets another account replace what is inside it",
|
||||
path, stat.Mode&0o7777)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// runtimeDirectoryIdentity names the object behind a descriptor, so rollback can
|
||||
// tell the directory it created from one that replaced it under the same name.
|
||||
func runtimeDirectoryIdentity(fd int) (string, error) {
|
||||
var stat unix.Stat_t
|
||||
if err := unix.Fstat(fd, &stat); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return strconv.FormatUint(uint64(stat.Dev), 10) + ":" + strconv.FormatUint(uint64(stat.Ino), 10), nil
|
||||
}
|
||||
@@ -1,124 +0,0 @@
|
||||
//go:build windows
|
||||
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// createRuntimeTailHandleRelative creates the owned tail of the runtime root
|
||||
// beneath base, one component at a time, from retained handles.
|
||||
//
|
||||
// THE NAME IS NOT AUTHORIZATION. The previous shape found the deepest existing
|
||||
// ancestor with os.Stat and then created each missing component by opening its
|
||||
// parent BY NAME. Both follow a junction, so a local user could replace the
|
||||
// predictable owned "zero" component with a junction after the pre-check,
|
||||
// point it anywhere, let elevated setup create runtime\v1\<hash> beneath that
|
||||
// target, and put the original back before the post-check. The post-check then
|
||||
// saw an ordinary path, the plan applied normally, and rollback reported the
|
||||
// separately created target only as identity-mismatched residue.
|
||||
//
|
||||
// So the base is opened by name exactly once, and everything below it is
|
||||
// addressed relative to a handle with FILE_OPEN_REPARSE_POINT: an existing
|
||||
// component is opened no-follow and refused if it is a link, and a missing one
|
||||
// is created relative to its parent's handle and identified from the handle the
|
||||
// create returned. No component name is resolved twice, so there is no interval
|
||||
// for a swap to land in, and a junction placed at any owned component is seen
|
||||
// as the link it is rather than followed.
|
||||
//
|
||||
// Redirected cache and TEMP locations ABOVE the owned tail are still allowed:
|
||||
// they are part of base, which is the operator's business. The restriction is
|
||||
// on the zero/runtime/v1/<hash> components Zero itself owns.
|
||||
func createRuntimeTailHandleRelative(base string, tail []string) ([]windowsCreatedRuntimeDir, error) {
|
||||
created, parent, err := createRuntimeTailRetainingHandle(base, tail)
|
||||
if parent != 0 {
|
||||
_ = windows.CloseHandle(parent)
|
||||
}
|
||||
return created, err
|
||||
}
|
||||
|
||||
// createRuntimeTailRetainingHandle is the same descent, but it hands the caller
|
||||
// the retained handle to the deepest component instead of closing it.
|
||||
//
|
||||
// Lease acquisition needs that handle: the lease file is a sibling of the
|
||||
// runtime root, so creating it by pathname would re-resolve the owned components
|
||||
// the descent just verified, which is the whole interval this design removes.
|
||||
// The caller owns the returned handle and must close it, including when an error
|
||||
// is returned alongside a non-zero handle.
|
||||
func createRuntimeTailRetainingHandle(base string, tail []string) ([]windowsCreatedRuntimeDir, windows.Handle, error) {
|
||||
if runtimeBaseOpenedByName != nil {
|
||||
runtimeBaseOpenedByName(base)
|
||||
}
|
||||
parent, err := openWindowsDirectoryByName(base)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("open sandbox runtime base %s: %w", base, err)
|
||||
}
|
||||
|
||||
if runtimeDescentBarrier != nil {
|
||||
runtimeDescentBarrier()
|
||||
}
|
||||
|
||||
var created []windowsCreatedRuntimeDir
|
||||
for _, path := range tail {
|
||||
name := filepath.Base(path)
|
||||
// Exists already: another process won the race, or it was there before
|
||||
// setup began. Opened no-follow, so a junction here is refused rather than
|
||||
// descended, and it is not ours to record.
|
||||
existing, openErr := openWindowsChildNoFollow(parent, name,
|
||||
windows.FILE_READ_ATTRIBUTES|windows.FILE_TRAVERSE, windows.FILE_DIRECTORY_FILE)
|
||||
if openErr == nil {
|
||||
_ = windows.CloseHandle(parent)
|
||||
parent = existing
|
||||
continue
|
||||
}
|
||||
if !isWindowsNotFound(openErr) {
|
||||
return created, parent, fmt.Errorf("inspect sandbox runtime component %s: %w", path, openErr)
|
||||
}
|
||||
handle, createErr := createWindowsChildDirectory(parent, name)
|
||||
if createErr != nil {
|
||||
if errors.Is(createErr, windows.STATUS_OBJECT_NAME_COLLISION) {
|
||||
// Created between the open and the create by someone else. Same
|
||||
// answer as the os.IsExist branch the caller already had: not ours,
|
||||
// but not a failure either. Reopen no-follow so the descent
|
||||
// continues through a verified object rather than a name.
|
||||
existing, reopenErr := openWindowsChildNoFollow(parent, name,
|
||||
windows.FILE_READ_ATTRIBUTES|windows.FILE_TRAVERSE, windows.FILE_DIRECTORY_FILE)
|
||||
if reopenErr != nil {
|
||||
return created, parent, fmt.Errorf("inspect sandbox runtime component %s after a concurrent create: %w", path, reopenErr)
|
||||
}
|
||||
_ = windows.CloseHandle(parent)
|
||||
parent = existing
|
||||
continue
|
||||
}
|
||||
return created, parent, fmt.Errorf("create sandbox runtime root %s: %w", path, createErr)
|
||||
}
|
||||
identity, idErr := handleRuntimeIdentity(handle)
|
||||
if idErr == nil && runtimeCreationFailure != nil {
|
||||
idErr = runtimeCreationFailure(path)
|
||||
}
|
||||
if idErr != nil {
|
||||
// CREATED, AND ABOUT TO BE FORGOTTEN. The ledger entry is appended on
|
||||
// the next line, so returning here left a directory this run had made
|
||||
// with nothing above holding a record of it: setup reports failure and
|
||||
// leaves it behind, and if an ancestor WAS recorded, that ancestor's
|
||||
// compensation then fails on a directory it cannot explain.
|
||||
//
|
||||
// Undone through the handle rather than published to the ledger,
|
||||
// because the identity the ledger needs is the thing that just failed.
|
||||
err := fmt.Errorf("identify the sandbox runtime directory created at %s: %w", path, idErr)
|
||||
if undoErr := removeWindowsObjectByHandle(handle); undoErr != nil {
|
||||
err = fmt.Errorf("%w; and the directory this run created there could not be removed: %w", err, undoErr)
|
||||
}
|
||||
_ = windows.CloseHandle(handle)
|
||||
return created, parent, err
|
||||
}
|
||||
created = append(created, windowsCreatedRuntimeDir{path: path, identity: identity, identified: true})
|
||||
_ = windows.CloseHandle(parent)
|
||||
parent = handle
|
||||
}
|
||||
return created, parent, nil
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
//go:build windows
|
||||
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// THE SWAP THE OLD WALK COULD NOT SEE.
|
||||
//
|
||||
// createRuntimeDirRecording used to find the deepest existing ancestor with
|
||||
// os.Stat and then create each missing component by opening its parent BY
|
||||
// NAME. Both follow a junction. So between the pre-check and the create, a
|
||||
// local user could replace the predictable owned "zero" component with a
|
||||
// junction into a directory of their choosing, and elevated setup would create
|
||||
// runtime\v1\<hash> beneath that target, apply its ACL plan there, and then
|
||||
// find an ordinary path at the post-check once the original was put back.
|
||||
//
|
||||
// This plants the junction at the deterministic barrier between opening the
|
||||
// base and touching the first owned component, which is that exact interval,
|
||||
// and proves two things: nothing is created beneath the redirected target, and
|
||||
// the descent refuses rather than continuing through the link. A junction
|
||||
// needs no privilege on Windows, which is why it is the shape used here rather
|
||||
// than a symlink that would skip on an unelevated box.
|
||||
func TestRuntimeDescentRefusesAJunctionSwappedIntoAnOwnedComponent(t *testing.T) {
|
||||
base := t.TempDir()
|
||||
target := t.TempDir()
|
||||
|
||||
zero := filepath.Join(base, "zero")
|
||||
tail := []string{zero, filepath.Join(zero, "runtime")}
|
||||
|
||||
previous := runtimeDescentBarrier
|
||||
runtimeDescentBarrier = func() {
|
||||
out, err := exec.Command("cmd", "/c", "mklink", "/J", zero, target).CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("mklink /J: %v\n%s", err, out)
|
||||
}
|
||||
}
|
||||
t.Cleanup(func() { runtimeDescentBarrier = previous })
|
||||
|
||||
created, err := createRuntimeTailHandleRelative(base, tail)
|
||||
if err == nil {
|
||||
t.Fatalf("the descent continued through a junction and reported success: created=%v", created)
|
||||
}
|
||||
if len(created) != 0 {
|
||||
t.Errorf("a junction-swapped component still produced ownership records: %v", created)
|
||||
}
|
||||
|
||||
// The whole point: the redirected target must be untouched.
|
||||
entries, readErr := os.ReadDir(target)
|
||||
if readErr != nil {
|
||||
t.Fatal(readErr)
|
||||
}
|
||||
if len(entries) != 0 {
|
||||
names := make([]string, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
names = append(names, entry.Name())
|
||||
}
|
||||
t.Errorf("setup created %v beneath the junction target, which is somebody else's directory", names)
|
||||
}
|
||||
}
|
||||
|
||||
// And the honest control: with no swap, the same descent creates the tail,
|
||||
// records an identity for each component from the creation handle, and the
|
||||
// result matches what the name resolves to afterwards.
|
||||
func TestRuntimeDescentCreatesTheOwnedTailFromHandles(t *testing.T) {
|
||||
base := t.TempDir()
|
||||
zero := filepath.Join(base, "zero")
|
||||
tail := []string{zero, filepath.Join(zero, "runtime"), filepath.Join(zero, "runtime", "v1")}
|
||||
|
||||
created, err := createRuntimeTailHandleRelative(base, tail)
|
||||
if err != nil {
|
||||
t.Fatalf("descent: %v", err)
|
||||
}
|
||||
if len(created) != len(tail) {
|
||||
t.Fatalf("created %d components, want %d: %v", len(created), len(tail), created)
|
||||
}
|
||||
for i, record := range created {
|
||||
if record.path != tail[i] {
|
||||
t.Errorf("record %d path = %q, want %q", i, record.path, tail[i])
|
||||
}
|
||||
if !record.identified {
|
||||
t.Errorf("record %d for %s carries no identity", i, record.path)
|
||||
continue
|
||||
}
|
||||
if now, ok := runtimeDirIdentity(record.path); !ok || now != record.identity {
|
||||
t.Errorf("record %d identity %q does not describe the directory at %s (%q)", i, record.identity, record.path, now)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
//go:build !windows
|
||||
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// runtimeDirIdentity identifies the directory currently at path.
|
||||
//
|
||||
// device plus inode, read with Lstat so a link substituted at the final
|
||||
// component is identified as the link rather than followed. The Windows build
|
||||
// cannot use os.SameFile for this and explains why; here the same eager capture
|
||||
// keeps both platforms on one rule.
|
||||
func runtimeDirIdentity(path string) (string, bool) {
|
||||
info, err := os.Lstat(path)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
stat, ok := info.Sys().(*syscall.Stat_t)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
return fmt.Sprintf("%d:%d", stat.Dev, stat.Ino), true
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
//go:build windows
|
||||
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// runtimeDirIdentity identifies the directory currently at path, EAGERLY.
|
||||
//
|
||||
// os.SameFile cannot be used for this on Windows. A Windows fileStat loads its
|
||||
// volume serial and file index lazily, by PATHNAME, at comparison time, so an
|
||||
// identity captured before a replacement and compared afterwards reports the
|
||||
// substitute as the same object and the original as a different one: exactly
|
||||
// backwards, and silently. Measured, not reasoned about.
|
||||
//
|
||||
// Reading the identity through a handle at capture time removes the lazy step.
|
||||
// Opened with FILE_FLAG_OPEN_REPARSE_POINT so a link substituted at the final
|
||||
// component is identified as the link it is rather than followed.
|
||||
func runtimeDirIdentity(path string) (string, bool) {
|
||||
utf16Path, err := windows.UTF16PtrFromString(path)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
handle, err := windows.CreateFile(
|
||||
utf16Path,
|
||||
windows.FILE_READ_ATTRIBUTES,
|
||||
windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE,
|
||||
nil,
|
||||
windows.OPEN_EXISTING,
|
||||
windows.FILE_FLAG_BACKUP_SEMANTICS|windows.FILE_FLAG_OPEN_REPARSE_POINT,
|
||||
0,
|
||||
)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
defer windows.CloseHandle(handle)
|
||||
var info windows.ByHandleFileInformation
|
||||
if err := windows.GetFileInformationByHandle(handle, &info); err != nil {
|
||||
return "", false
|
||||
}
|
||||
return fmt.Sprintf("%d:%d:%d", info.VolumeSerialNumber, info.FileIndexHigh, info.FileIndexLow), true
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
//go:build windows
|
||||
|
||||
package sandbox
|
||||
|
||||
import "testing"
|
||||
|
||||
// THE COMMAND HAS TO RECOGNISE THE ROOT SETUP RECORDED, WHATEVER ITS
|
||||
// ENVIRONMENT SAYS.
|
||||
//
|
||||
// Setup derives the fallback root in an elevated terminal and records it; a
|
||||
// command re-derives it in an ordinary one to decide whether the record belongs
|
||||
// to this workspace. The user scope in that derivation used to come from
|
||||
// USERNAME, so a value that differed between the two terminals made the command
|
||||
// reject the record and select a root with no capability ACE on it.
|
||||
func TestRecordedFallbackRootSurvivesADifferentUsernameVariable(t *testing.T) {
|
||||
// Canonical, as selection hands it over. The record check canonicalizes on
|
||||
// its own, so a raw t.TempDir() spelling digests differently wherever TEMP is
|
||||
// an 8.3 short path, as it is on the CI runners.
|
||||
workspace := canonicalSandboxWorkspaceRoot(t.TempDir())
|
||||
tempHome := t.TempDir()
|
||||
t.Setenv("TMP", tempHome)
|
||||
t.Setenv("TEMP", tempHome)
|
||||
|
||||
t.Setenv("USERNAME", "setup-terminal")
|
||||
recorded, err := fallbackSandboxRuntimeRoot(workspace)
|
||||
if err != nil {
|
||||
t.Fatalf("derive the fallback root as setup: %v", err)
|
||||
}
|
||||
if !ownedFallbackRuntimeRecord(workspace, recorded) {
|
||||
t.Fatalf("SETUP INVALID: the fallback root is not recognised even in the environment that derived it: %s", recorded)
|
||||
}
|
||||
|
||||
t.Setenv("USERNAME", "command-terminal")
|
||||
if !ownedFallbackRuntimeRecord(workspace, recorded) {
|
||||
t.Fatalf("once USERNAME changed, the command no longer recognises the fallback root setup recorded: %s", recorded)
|
||||
}
|
||||
derived, err := fallbackSandboxRuntimeRoot(workspace)
|
||||
if err != nil {
|
||||
t.Fatalf("derive the fallback root as the command: %v", err)
|
||||
}
|
||||
if !sameWindowsRuntimeRootPath(derived, recorded) {
|
||||
t.Fatalf("setup and the command derived different fallback roots for one workspace:\n setup: %s\n command: %s", recorded, derived)
|
||||
}
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// EVERY OWNERSHIP-CHECKED ANCESTOR MUST ALREADY BE INSIDE ONE USER'S NAMESPACE.
|
||||
//
|
||||
// The temp-derived fallback lives under a directory that is shared on Unix
|
||||
// whenever TMPDIR is unset. Runtime preparation creates and ownership-checks
|
||||
// each component of the tail at 0700, so a fixed first component meant the
|
||||
// first account to use the fallback created a private directory every other
|
||||
// account was then refused at: traversal fails on the mode, and relaxing the
|
||||
// mode fails the ownership guard instead. The per-workspace digest is the leaf,
|
||||
// so it never got the chance to separate them.
|
||||
//
|
||||
// The invariant is therefore about the SHALLOWEST checked component, not the
|
||||
// leaf: it has to carry the user scope, or the guards below it are guarding a
|
||||
// namespace two users share.
|
||||
func TestFallbackRuntimeRootScopesItsShallowestOwnedComponent(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("os.TempDir() resolves inside the user profile on Windows, so the shared-temp collision cannot arise")
|
||||
}
|
||||
workspace := t.TempDir()
|
||||
tempHome := t.TempDir()
|
||||
t.Setenv("TMPDIR", tempHome)
|
||||
|
||||
root, err := fallbackSandboxRuntimeRoot(workspace)
|
||||
if err != nil {
|
||||
t.Fatalf("fallbackSandboxRuntimeRoot: %v", err)
|
||||
}
|
||||
components := ownedRuntimeComponents(root)
|
||||
if len(components) == 0 {
|
||||
t.Fatal("no ownership-checked components derived for the fallback root")
|
||||
}
|
||||
// ownedRuntimeComponents walks upward, so the last entry is the shallowest
|
||||
// directory the guards create and validate.
|
||||
shallowest := filepath.Base(components[len(components)-1])
|
||||
scope := sandboxRuntimeUserScope()
|
||||
if !strings.Contains(shallowest, scope) {
|
||||
t.Errorf("the shallowest ownership-checked component is %q and does not carry the user scope %q; "+
|
||||
"two accounts on a shared temp would contend for it", shallowest, scope)
|
||||
}
|
||||
}
|
||||
|
||||
// The workspace still decides the leaf, so setup and the command derive the same
|
||||
// root for the same workspace. Scoping the top must not have moved that.
|
||||
func TestFallbackRuntimeRootStaysStableForOneWorkspace(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
tempHome := t.TempDir()
|
||||
t.Setenv("TMPDIR", tempHome)
|
||||
t.Setenv("TMP", tempHome)
|
||||
t.Setenv("TEMP", tempHome)
|
||||
|
||||
first, err := fallbackSandboxRuntimeRoot(workspace)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second, err := fallbackSandboxRuntimeRoot(workspace)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if first != second {
|
||||
t.Errorf("the fallback root is not stable for one workspace:\n %s\n %s", first, second)
|
||||
}
|
||||
|
||||
other, err := fallbackSandboxRuntimeRoot(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if other == first {
|
||||
t.Error("two different workspaces derived the same fallback root")
|
||||
}
|
||||
if filepath.Dir(other) != filepath.Dir(first) {
|
||||
t.Errorf("two workspaces for one user should differ only in the leaf:\n %s\n %s", first, other)
|
||||
}
|
||||
}
|
||||
|
||||
// And the Windows side is a deliberate choice, not an omission. Its temp root
|
||||
// is already inside the user profile, and the names have to stay fixed so
|
||||
// windowsSandboxRuntimeOwnedTail can still recognise a root that an elevated
|
||||
// setup running as a different account built.
|
||||
func TestFallbackOwnedNamesAreUnscopedOnWindowsOnly(t *testing.T) {
|
||||
names := sandboxRuntimeFallbackOwnedNames()
|
||||
if len(names) != len(windowsSandboxRuntimeOwnedNames) {
|
||||
t.Fatalf("fallback names = %v, want the same depth as %v", names, windowsSandboxRuntimeOwnedNames)
|
||||
}
|
||||
// The components below the first are shared by both platforms, so a change
|
||||
// to them would break the tail matcher on Windows.
|
||||
for index := 1; index < len(names); index++ {
|
||||
if names[index] != windowsSandboxRuntimeOwnedNames[index] {
|
||||
t.Errorf("component %d = %q, want %q", index, names[index], windowsSandboxRuntimeOwnedNames[index])
|
||||
}
|
||||
}
|
||||
scoped := names[0] != windowsSandboxRuntimeOwnedNames[0]
|
||||
if runtime.GOOS == "windows" && scoped {
|
||||
t.Errorf("the Windows fallback scoped its first component to %q; the tail matcher compares fixed names", names[0])
|
||||
}
|
||||
if runtime.GOOS != "windows" && !scoped {
|
||||
t.Errorf("the first component is %q on %s, where the temp root can be shared between accounts",
|
||||
names[0], runtime.GOOS)
|
||||
}
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// writeRecordedRoot puts a setup marker naming root under sandboxHome.
|
||||
func writeRecordedRoot(t *testing.T, sandboxHome, root string) {
|
||||
t.Helper()
|
||||
path := WindowsSandboxSetupMarkerPath(sandboxHome)
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
blob, err := json.Marshal(WindowsSandboxSetupMarker{SchemaVersion: windowsSandboxSetupMarkerSchemaVersion, RuntimeRoot: root})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, blob, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// ONE COMMAND, ONE SANDBOX HOME.
|
||||
//
|
||||
// Runtime preparation happens before Windows platform planning, and it used to
|
||||
// resolve the sandbox home from the AMBIENT environment while the planner
|
||||
// resolves ZERO_WINDOWS_SANDBOX_HOME out of the command's own spec.Env and hands
|
||||
// that one to the runner for marker validation. So a command that explicitly
|
||||
// selects home B, while the parent process still points at home A, pinned A's
|
||||
// recorded root into the profile; the runner then loaded B's marker, saw a
|
||||
// different root, and rejected the command as out of date even though setup for
|
||||
// B was valid. The two homes need no different derivation rules to disagree,
|
||||
// only different valid selections from the same preferred/fallback pair.
|
||||
func TestThePinnedRootComesFromTheCommandsOwnSandboxHome(t *testing.T) {
|
||||
homeA := t.TempDir()
|
||||
homeB := t.TempDir()
|
||||
preferred := filepath.Join(t.TempDir(), "preferred")
|
||||
fallback := filepath.Join(t.TempDir(), "fallback")
|
||||
|
||||
writeRecordedRoot(t, homeA, preferred)
|
||||
writeRecordedRoot(t, homeB, fallback)
|
||||
|
||||
if got := pinnedSandboxRuntimeRoot(t.TempDir(), preferred, fallback, homeB); got != fallback {
|
||||
t.Errorf("pinned %q for a command that selected home B, want %q: the ambient home decided instead of the command's", got, fallback)
|
||||
}
|
||||
if got := pinnedSandboxRuntimeRoot(t.TempDir(), preferred, fallback, homeA); got != preferred {
|
||||
t.Errorf("pinned %q for home A, want %q", got, preferred)
|
||||
}
|
||||
}
|
||||
|
||||
// A recorded root that this workspace could not select is still refused, which
|
||||
// is the protection that stops one workspace's tree being pinned into another.
|
||||
func TestARecordedRootFromAnotherWorkspaceIsStillRefused(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
writeRecordedRoot(t, home, filepath.Join(t.TempDir(), "someone-elses-tree"))
|
||||
if got := pinnedSandboxRuntimeRoot(t.TempDir(), filepath.Join(t.TempDir(), "preferred"), filepath.Join(t.TempDir(), "fallback"), home); got != "" {
|
||||
t.Errorf("pinned %q, want none: it matches neither candidate this workspace derives", got)
|
||||
}
|
||||
}
|
||||
|
||||
// No command context means the ambient environment is the only authority there
|
||||
// is, so an empty home must still resolve rather than refusing outright.
|
||||
func TestAnEmptyCommandHomeFallsBackToTheAmbientEnvironment(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("ZERO_WINDOWS_SANDBOX_HOME", home)
|
||||
preferred := filepath.Join(t.TempDir(), "preferred")
|
||||
writeRecordedRoot(t, home, preferred)
|
||||
if got := pinnedSandboxRuntimeRoot(t.TempDir(), preferred, filepath.Join(t.TempDir(), "fallback"), ""); got != preferred {
|
||||
t.Errorf("pinned %q with no command home, want the ambient one to decide (%q)", got, preferred)
|
||||
}
|
||||
}
|
||||
@@ -1,88 +1,31 @@
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
)
|
||||
|
||||
const sandboxRuntimeLeaseSuffix = ".lease"
|
||||
|
||||
// errRuntimeLeaseReplaced says a lock was taken on a lease object that the lease
|
||||
// name no longer resolves to: cleanup unlinked or replaced it while this call was
|
||||
// waiting. Both acquisitions retry from their open on it, and surface it only
|
||||
// once the retries are spent.
|
||||
var errRuntimeLeaseReplaced = errors.New("the sandbox runtime lease was removed or replaced while it was being taken")
|
||||
|
||||
// runtimeLeaseAcquireAttempts bounds those retries. Each stale lock means one
|
||||
// cleanup ran to completion in the window, and cleanups do not chain without
|
||||
// limit; a third stale object in a row is reported rather than chased.
|
||||
const runtimeLeaseAcquireAttempts = 3
|
||||
|
||||
// sandboxRuntimeLease is one holder's grip on a runtime root.
|
||||
//
|
||||
// createdFile says this acquisition is the one that brought the lease file into
|
||||
// existence. Compensation needs it: rollback refuses a non-empty directory, and
|
||||
// the lease sits beside the leaf inside a directory a failed setup has to be able
|
||||
// to remove, so the file has to go with it. It may only go if it was ours, which
|
||||
// is a fact only the create knows.
|
||||
type sandboxRuntimeLease struct {
|
||||
handle runtimeLeaseHandle
|
||||
once sync.Once
|
||||
// root is the runtime root this lease is for, kept so compensation can name
|
||||
// the lease file without deriving it again.
|
||||
root string
|
||||
// createdFile says THIS acquisition brought the lease file into existence.
|
||||
createdFile bool
|
||||
}
|
||||
|
||||
// createdLeaseFile reports whether compensation may remove the lease file.
|
||||
//
|
||||
// Only the acquisition that created it may, and only while nothing else holds
|
||||
// it. A lease that was already there belongs to whoever made it, and removing it
|
||||
// would take the coordination object out from under a live command.
|
||||
func (lease *sandboxRuntimeLease) createdLeaseFile() (string, bool) {
|
||||
if lease == nil || !lease.createdFile {
|
||||
return "", false
|
||||
}
|
||||
return sandboxRuntimeLeasePath(lease.root), true
|
||||
}
|
||||
|
||||
func sandboxRuntimeLeasePath(root string) string {
|
||||
return root + sandboxRuntimeLeaseSuffix
|
||||
}
|
||||
|
||||
// The pathname-based shared acquisition that used to live here is gone with its
|
||||
// two platform halves. It opened the lease by full pathname, so it followed a
|
||||
// link planted at that name and, on Windows, opened without FILE_SHARE_DELETE,
|
||||
// which alone stops cleanup from ever removing the lease it is holding. Every
|
||||
// caller now goes through acquireRuntimeLeaseForPlatform, which descends from a
|
||||
// retained parent handle no-follow. Leaving the weaker door defined next to the
|
||||
// stronger one is how a later change quietly takes it.
|
||||
|
||||
// appendCreatedRuntimeDirs merges one acquisition attempt's ledger into the
|
||||
// running one. A path seen again was re-created after cleanup removed the
|
||||
// earlier object, so the later identity supersedes; rollback verifies identity
|
||||
// before it removes anything, and a stale one would make it refuse the directory
|
||||
// this run actually made.
|
||||
func appendCreatedRuntimeDirs(existing, made []windowsCreatedRuntimeDir) []windowsCreatedRuntimeDir {
|
||||
for _, record := range made {
|
||||
replaced := false
|
||||
for index := range existing {
|
||||
if existing[index].path == record.path {
|
||||
existing[index] = record
|
||||
replaced = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !replaced {
|
||||
existing = append(existing, record)
|
||||
}
|
||||
func acquireSandboxRuntimeLease(root string) (*sandboxRuntimeLease, error) {
|
||||
handle, err := acquireSharedRuntimeLease(sandboxRuntimeLeasePath(root))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("acquire sandbox runtime lease: %w", err)
|
||||
}
|
||||
return existing
|
||||
return &sandboxRuntimeLease{handle: handle}, nil
|
||||
}
|
||||
|
||||
func tryAcquireSandboxRuntimeCleanupLease(root string) (*sandboxRuntimeLease, bool, error) {
|
||||
handle, inUse, err := tryAcquireExclusiveRuntimeLease(root)
|
||||
handle, inUse, err := tryAcquireExclusiveRuntimeLease(sandboxRuntimeLeasePath(root))
|
||||
if err != nil || inUse {
|
||||
return nil, inUse, err
|
||||
}
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
package sandbox
|
||||
|
||||
// runtimeLeasePreCreateBarrier, when set, runs after lease acquisition has
|
||||
// decided what it is going to do and before it creates anything.
|
||||
//
|
||||
// It exists so a test can reproduce the CHECK-THEN-USE RACE rather than a
|
||||
// junction that was already there when acquisition started. The two are not the
|
||||
// same finding: a junction present from the outset was already refused by the
|
||||
// alias pre-check, while one planted in this window defeated it, because
|
||||
// os.MkdirAll and the lease open both resolved the component again and followed
|
||||
// it. A test that only plants the junction up front passes against the defective
|
||||
// implementation and proves nothing.
|
||||
//
|
||||
// Nil in production.
|
||||
var runtimeLeasePreCreateBarrier func()
|
||||
@@ -1,150 +0,0 @@
|
||||
//go:build windows
|
||||
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// leaseRootUnder builds a production-shaped runtime root beneath a test-owned
|
||||
// cache directory, so the owned tail is real rather than assumed.
|
||||
func leaseRootUnder(t *testing.T, cacheRoot string) string {
|
||||
t.Helper()
|
||||
workspace := t.TempDir()
|
||||
root, ok := deterministicSandboxRuntimeRoot(canonicalSandboxWorkspaceRoot(workspace), canonicalSandboxWorkspaceRoot(cacheRoot))
|
||||
if !ok {
|
||||
t.Fatalf("SETUP INVALID: no deterministic runtime root under %s", cacheRoot)
|
||||
}
|
||||
return root
|
||||
}
|
||||
|
||||
// THE LEASE IS THE FIRST WRITE SETUP MAKES, SO IT IS THE ONE THAT MATTERS MOST.
|
||||
//
|
||||
// prepareSandboxRuntimeLease checked the owned components for aliases and then
|
||||
// created the parent with os.MkdirAll and opened "<root>.lease" by pathname.
|
||||
// Both follow. An ordinary same-account process could replace "zero", "runtime"
|
||||
// or "v1" with a junction after the check and before either call, and elevated
|
||||
// setup would then build the tree and the lease file inside somebody else's
|
||||
// target. Putting the component back afterwards left the later handle-relative
|
||||
// provisioning working on the legitimate tree, so no post-check ever saw it, and
|
||||
// the rollback could not compensate what it had no record of.
|
||||
//
|
||||
// A junction needs no privilege on Windows, so the caller's half of this runs on
|
||||
// an ordinary unelevated box.
|
||||
func TestRuntimeLeaseRefusesAJunctionPlantedAfterTheCheck(t *testing.T) {
|
||||
cacheRoot := t.TempDir()
|
||||
target := t.TempDir()
|
||||
root := leaseRootUnder(t, cacheRoot)
|
||||
|
||||
owned := filepath.Join(canonicalSandboxWorkspaceRoot(cacheRoot), "zero")
|
||||
if !strings.HasPrefix(strings.ToLower(root), strings.ToLower(owned)) {
|
||||
t.Fatalf("SETUP INVALID: %s does not sit under the owned component %s", root, owned)
|
||||
}
|
||||
|
||||
// THE JUNCTION ARRIVES IN THE WINDOW, WHICH IS THE WHOLE POINT.
|
||||
//
|
||||
// One that is already there when acquisition starts was refused by the old
|
||||
// alias pre-check too, so a test that plants it up front passes against the
|
||||
// defective implementation and proves nothing. This one lands after the
|
||||
// decision and before the first create, which is exactly where os.MkdirAll and
|
||||
// the pathname lease open used to follow it.
|
||||
previous := runtimeLeasePreCreateBarrier
|
||||
t.Cleanup(func() { runtimeLeasePreCreateBarrier = previous })
|
||||
attempted, planted := false, false
|
||||
runtimeLeasePreCreateBarrier = func() {
|
||||
if attempted {
|
||||
return
|
||||
}
|
||||
attempted = true
|
||||
if out, err := exec.Command("cmd", "/c", "mklink", "/J", owned, target).CombinedOutput(); err != nil {
|
||||
t.Logf("mklink /J unavailable: %v: %s", err, out)
|
||||
return
|
||||
}
|
||||
planted = true
|
||||
}
|
||||
|
||||
lease, _, err := prepareSandboxRuntimeLeaseRecording(root)
|
||||
if lease != nil {
|
||||
lease.release()
|
||||
}
|
||||
if !attempted {
|
||||
t.Skip("the pre-create barrier never ran, so the race was not reproduced")
|
||||
}
|
||||
// Bound to mklink's result, not to the attempt. When it fails, acquisition
|
||||
// goes on to create an ordinary directory at the same name, the check below
|
||||
// finds it, and a correct acquisition would be failed as a junction bypass.
|
||||
if !planted {
|
||||
t.Skip("mklink /J could not create the junction here, so the race was not reproduced")
|
||||
}
|
||||
if _, statErr := os.Lstat(owned); statErr != nil {
|
||||
t.Skipf("the junction could not be created here: %v", statErr)
|
||||
}
|
||||
|
||||
entries, readErr := os.ReadDir(target)
|
||||
if readErr != nil {
|
||||
t.Fatal(readErr)
|
||||
}
|
||||
if len(entries) != 0 {
|
||||
names := make([]string, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
names = append(names, entry.Name())
|
||||
}
|
||||
t.Fatalf("lease acquisition wrote %v beneath a junction planted after its own check; err=%v", names, err)
|
||||
}
|
||||
if err == nil {
|
||||
t.Fatal("lease acquisition reported success through a junction planted after its check")
|
||||
}
|
||||
}
|
||||
|
||||
// And an ordinary cache tree still gets its lease, or the refusal above would be
|
||||
// satisfied by an acquirer that refuses everything.
|
||||
func TestRuntimeLeaseStillAcquiresOnAnOrdinaryTree(t *testing.T) {
|
||||
cacheRoot := t.TempDir()
|
||||
root := leaseRootUnder(t, cacheRoot)
|
||||
|
||||
lease, created, err := prepareSandboxRuntimeLeaseRecording(root)
|
||||
if err != nil {
|
||||
t.Fatalf("an ordinary cache tree was refused: %v", err)
|
||||
}
|
||||
defer lease.release()
|
||||
|
||||
if _, statErr := os.Stat(sandboxRuntimeLeasePath(root)); statErr != nil {
|
||||
t.Fatalf("the lease file was not created: %v", statErr)
|
||||
}
|
||||
// The components above the leaf are recorded, so a later failure can undo
|
||||
// them. The leaf itself belongs to provisioning and must NOT appear here.
|
||||
for _, entry := range created {
|
||||
if strings.EqualFold(filepath.Clean(entry.path), filepath.Clean(root)) {
|
||||
t.Fatalf("lease acquisition claimed the runtime leaf %s, which provisioning owns", root)
|
||||
}
|
||||
}
|
||||
if len(created) == 0 {
|
||||
t.Fatal("SETUP INVALID: nothing was recorded as created, so the accounting assertion above is vacuous")
|
||||
}
|
||||
}
|
||||
|
||||
// A second acquisition over an existing tree records nothing: it created nothing,
|
||||
// so it owns nothing, and rollback must not remove a tree it found.
|
||||
func TestRuntimeLeaseRecordsOnlyWhatItCreated(t *testing.T) {
|
||||
cacheRoot := t.TempDir()
|
||||
root := leaseRootUnder(t, cacheRoot)
|
||||
|
||||
first, _, err := prepareSandboxRuntimeLeaseRecording(root)
|
||||
if err != nil {
|
||||
t.Fatalf("first acquisition: %v", err)
|
||||
}
|
||||
first.release()
|
||||
|
||||
second, created, err := prepareSandboxRuntimeLeaseRecording(root)
|
||||
if err != nil {
|
||||
t.Fatalf("second acquisition: %v", err)
|
||||
}
|
||||
defer second.release()
|
||||
if len(created) != 0 {
|
||||
t.Fatalf("the second acquisition claimed %d directories it did not create; rollback would delete a tree it found", len(created))
|
||||
}
|
||||
}
|
||||
@@ -1,216 +0,0 @@
|
||||
//go:build !windows
|
||||
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
// unixLeaseRootUnder builds a production-shaped runtime root beneath a
|
||||
// test-owned base, so the owned tail is real rather than assumed.
|
||||
func unixLeaseRootUnder(t *testing.T, base string) string {
|
||||
t.Helper()
|
||||
workspace := t.TempDir()
|
||||
root, ok := deterministicSandboxRuntimeRoot(canonicalSandboxWorkspaceRoot(workspace), canonicalSandboxWorkspaceRoot(base))
|
||||
if !ok {
|
||||
t.Fatalf("SETUP INVALID: no deterministic runtime root under %s", base)
|
||||
}
|
||||
return root
|
||||
}
|
||||
|
||||
// countEntriesUnder reports how many entries exist anywhere beneath dir, so a
|
||||
// test can say "nothing was written here" rather than checking one name.
|
||||
func countEntriesUnder(t *testing.T, dir string) []string {
|
||||
t.Helper()
|
||||
var found []string
|
||||
err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if path == dir {
|
||||
return nil
|
||||
}
|
||||
found = append(found, path)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("walk %s: %v", dir, err)
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
// A PRE-CHECK CANNOT AUTHORIZE A PATHNAME WRITE THAT COMES AFTER IT.
|
||||
//
|
||||
// The fallback root moved from an atomically minted MkdirTemp parent to a
|
||||
// predictable /tmp/zero-u<uid>/runtime/v1/<digest>. The stable name is required,
|
||||
// because setup and every later process have to agree on one root without talking
|
||||
// to each other, but it also means another local account can name the first owned
|
||||
// component first. refuseAliasedRuntimeComponents answers about an ABSENT
|
||||
// component by saying there is nothing to alias, which is the state a fresh
|
||||
// fallback is in, and os.MkdirAll plus the pathname lease open then followed a
|
||||
// link planted after that answer.
|
||||
//
|
||||
// The link arrives after the last validation and before the first create, which
|
||||
// is the only window that matters: one planted earlier was refused by the old
|
||||
// pre-check too, so a test that plants it up front passes against the defect and
|
||||
// proves nothing.
|
||||
func TestRuntimeLeaseRefusesALinkPlantedAfterTheCheck(t *testing.T) {
|
||||
base := t.TempDir()
|
||||
target := t.TempDir()
|
||||
root := unixLeaseRootUnder(t, base)
|
||||
|
||||
owned := filepath.Join(canonicalSandboxWorkspaceRoot(base), "zero")
|
||||
if !pathWithinRoot(owned, root) {
|
||||
t.Fatalf("SETUP INVALID: %s does not sit under the owned component %s", root, owned)
|
||||
}
|
||||
|
||||
previous := runtimeDescentBarrier
|
||||
t.Cleanup(func() { runtimeDescentBarrier = previous })
|
||||
planted := false
|
||||
runtimeDescentBarrier = func() {
|
||||
if planted {
|
||||
return
|
||||
}
|
||||
planted = true
|
||||
if err := os.Symlink(target, owned); err != nil {
|
||||
t.Errorf("plant the link: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
lease, _, err := prepareSandboxRuntimeLeaseRecording(root)
|
||||
if lease != nil {
|
||||
lease.release()
|
||||
}
|
||||
|
||||
// SETUP: the swap really happened in the window, or nothing was under test.
|
||||
if !planted {
|
||||
t.Fatal("SETUP INVALID: the descent never reached the barrier, so no link was planted")
|
||||
}
|
||||
if err == nil {
|
||||
t.Fatal("lease acquisition succeeded through a link planted at the first owned component")
|
||||
}
|
||||
// ELOOP on Linux, and either that or ENOTDIR elsewhere. Asserted as a class
|
||||
// rather than one kernel's spelling.
|
||||
if !errors.Is(err, unix.ELOOP) && !errors.Is(err, unix.ENOTDIR) {
|
||||
t.Errorf("refused for the wrong reason, want a no-follow refusal: %v", err)
|
||||
}
|
||||
|
||||
// THE POINT IS WHERE THE WRITES DID NOT GO. An error alone would also be
|
||||
// produced by a later guard noticing the link after the tree was built inside
|
||||
// somebody else's directory.
|
||||
if leaked := countEntriesUnder(t, target); len(leaked) != 0 {
|
||||
t.Fatalf("the redirected target received %v; the refusal happened after the writes rather than instead of them", leaked)
|
||||
}
|
||||
}
|
||||
|
||||
// CONTROL: an ordinary tree still gets its lease, and the one path opened by name
|
||||
// is the operator's base rather than a component Zero owns.
|
||||
//
|
||||
// Refusing everything would satisfy the test above. Asserting the base directly
|
||||
// is what makes this discriminating: inferring it from whether a swap was caught
|
||||
// says nothing about which name was trusted.
|
||||
func TestRuntimeLeaseOpensOnlyTheBaseByName(t *testing.T) {
|
||||
base := t.TempDir()
|
||||
root := unixLeaseRootUnder(t, base)
|
||||
|
||||
previousBase := runtimeBaseOpenedByName
|
||||
t.Cleanup(func() { runtimeBaseOpenedByName = previousBase })
|
||||
var byName []string
|
||||
runtimeBaseOpenedByName = func(path string) { byName = append(byName, path) }
|
||||
|
||||
lease, created, err := prepareSandboxRuntimeLeaseRecording(root)
|
||||
if err != nil {
|
||||
t.Fatalf("lease acquisition failed on an ordinary tree: %v", err)
|
||||
}
|
||||
lease.release()
|
||||
|
||||
if len(byName) != 1 {
|
||||
t.Fatalf("opened %v by name, want exactly the base", byName)
|
||||
}
|
||||
if got := byName[0]; got != canonicalSandboxWorkspaceRoot(base) && got != base {
|
||||
t.Fatalf("opened %q by name, want the base %q", got, base)
|
||||
}
|
||||
if len(created) == 0 {
|
||||
t.Fatal("the descent created the owned components but recorded none of them, so rollback has nothing to undo")
|
||||
}
|
||||
if _, err := os.Lstat(sandboxRuntimeLeasePath(root)); err != nil {
|
||||
t.Fatalf("no lease file was created on an ordinary tree: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// And a link at the LEASE NAME itself is refused by both holders, so the shared
|
||||
// and exclusive sides cannot end up locking different objects.
|
||||
func TestRuntimeLeaseRefusesALinkAtTheLeaseName(t *testing.T) {
|
||||
base := t.TempDir()
|
||||
root := unixLeaseRootUnder(t, base)
|
||||
|
||||
lease, _, err := prepareSandboxRuntimeLeaseRecording(root)
|
||||
if err != nil {
|
||||
t.Fatalf("SETUP: cannot seed a lease: %v", err)
|
||||
}
|
||||
lease.release()
|
||||
leasePath := sandboxRuntimeLeasePath(root)
|
||||
if err := os.Remove(leasePath); err != nil {
|
||||
t.Fatalf("SETUP INVALID: cannot clear the seeded lease: %v", err)
|
||||
}
|
||||
target := filepath.Join(t.TempDir(), "target.lease")
|
||||
if err := os.WriteFile(target, nil, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(target, leasePath); err != nil {
|
||||
t.Fatalf("SETUP: cannot plant the link: %v", err)
|
||||
}
|
||||
|
||||
shared, _, sharedErr := prepareSandboxRuntimeLeaseRecording(root)
|
||||
if shared != nil {
|
||||
shared.release()
|
||||
}
|
||||
if sharedErr == nil {
|
||||
t.Error("shared acquisition accepted a link at the lease name")
|
||||
}
|
||||
|
||||
cleanup, inUse, cleanupErr := tryAcquireSandboxRuntimeCleanupLease(root)
|
||||
if cleanup != nil {
|
||||
cleanup.release()
|
||||
}
|
||||
if cleanupErr == nil {
|
||||
t.Errorf("cleanup accepted a link at the lease name (inUse=%t); it would treat a lock on %s as proof the root is free to delete", inUse, target)
|
||||
}
|
||||
}
|
||||
|
||||
// CONTROL: the two sides still coordinate on an ordinary lease.
|
||||
func TestUnixSharedLeaseMakesCleanupReportInUse(t *testing.T) {
|
||||
base := t.TempDir()
|
||||
root := unixLeaseRootUnder(t, base)
|
||||
|
||||
held, _, err := prepareSandboxRuntimeLeaseRecording(root)
|
||||
if err != nil {
|
||||
t.Fatalf("cannot acquire a runtime lease: %v", err)
|
||||
}
|
||||
|
||||
lease, inUse, err := tryAcquireSandboxRuntimeCleanupLease(root)
|
||||
if lease != nil {
|
||||
lease.release()
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("cleanup failed against a legitimately held lease: %v", err)
|
||||
}
|
||||
if !inUse {
|
||||
t.Fatal("cleanup reported the runtime root free while a shared lease was held; it would delete a tree a live command is using")
|
||||
}
|
||||
|
||||
held.release()
|
||||
lease, inUse, err = tryAcquireSandboxRuntimeCleanupLease(root)
|
||||
if err != nil {
|
||||
t.Fatalf("cleanup failed after the shared lease was released: %v", err)
|
||||
}
|
||||
if inUse {
|
||||
t.Fatal("cleanup still reported the root in use after the only holder released it, so reclamation never happens")
|
||||
}
|
||||
lease.release()
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// runtimeRootUnderTest builds a root with the shape production uses, so the
|
||||
// components refuseAliasedRuntimeComponents walks stay inside test-owned
|
||||
// storage.
|
||||
//
|
||||
// Owned depth is the fixed names plus the digest, so a root placed directly in
|
||||
// t.TempDir() puts an owned component on /tmp, which the Unix ownership guard
|
||||
// correctly refuses because /tmp belongs to root. That is the guard working, not
|
||||
// a test environment problem, and it only shows up off Windows.
|
||||
func runtimeRootUnderTest(t *testing.T, leaf string) string {
|
||||
t.Helper()
|
||||
root := filepath.Join(t.TempDir(), "zero", "runtime", "v1", leaf)
|
||||
if err := os.MkdirAll(root, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return root
|
||||
}
|
||||
|
||||
// A LEASE IS OWNERSHIP FOR A TRANSACTION, NOT A SELECTION PROBE.
|
||||
//
|
||||
// Setup took a lease only to learn which root won and released it at once, so
|
||||
// nothing owned the selected root while the elevated helper provisioned the
|
||||
// tree, applied the ACL and stamp, installed network state and wrote the marker.
|
||||
// A command for another workspace scans the same runtime parent and excludes
|
||||
// only its own current root, so it can take this root's cleanup lease and
|
||||
// RemoveAll it mid-transaction; setup then publishes success for a pathname that
|
||||
// is gone.
|
||||
//
|
||||
// This pins the mechanism the fix relies on: a held lease is what makes the
|
||||
// cleanup's exclusive acquire fail.
|
||||
func TestAHeldRuntimeLeaseStopsConcurrentCleanup(t *testing.T) {
|
||||
root := runtimeRootUnderTest(t, "deadbeefdeadbeef")
|
||||
|
||||
lease, err := prepareSandboxRuntimeLease(root)
|
||||
if err != nil {
|
||||
t.Fatalf("acquire the runtime lease: %v", err)
|
||||
}
|
||||
removeSandboxRuntimeRootIfUnused(root)
|
||||
if _, err := os.Stat(root); err != nil {
|
||||
t.Fatalf("cleanup removed a root that setup was holding: %v", err)
|
||||
}
|
||||
lease.release()
|
||||
|
||||
// And once nothing holds it, cleanup does its job: without this half the test
|
||||
// would pass against a cleanup that never removes anything.
|
||||
removeSandboxRuntimeRootIfUnused(root)
|
||||
if _, err := os.Stat(root); !os.IsNotExist(err) {
|
||||
t.Fatalf("an unheld root survived cleanup: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The lease is on the root the caller names, so holding one root does not
|
||||
// protect a sibling: setup reserving its own selection must not stop the
|
||||
// cleanup doing its work elsewhere.
|
||||
func TestAHeldLeaseDoesNotProtectASiblingRoot(t *testing.T) {
|
||||
held := runtimeRootUnderTest(t, "aaaaaaaaaaaaaaaa")
|
||||
sibling := filepath.Join(filepath.Dir(held), "bbbbbbbbbbbbbbbb")
|
||||
if err := os.MkdirAll(sibling, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
lease, err := prepareSandboxRuntimeLease(held)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer lease.release()
|
||||
|
||||
removeSandboxRuntimeRootIfUnused(sibling)
|
||||
if _, err := os.Stat(sibling); !os.IsNotExist(err) {
|
||||
t.Errorf("holding one root blocked cleanup of an unrelated sibling: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(held); err != nil {
|
||||
t.Errorf("the held root was removed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// createdRuntimeDirsForTest builds identity-bound rollback records for paths a
|
||||
// test made itself, so a test can express "these are the directories this run
|
||||
// created" without restating the identity capture.
|
||||
func createdRuntimeDirsForTest(paths ...string) []windowsCreatedRuntimeDir {
|
||||
records := make([]windowsCreatedRuntimeDir, 0, len(paths))
|
||||
for _, path := range paths {
|
||||
identity, identified := runtimeDirIdentity(path)
|
||||
records = append(records, windowsCreatedRuntimeDir{path: path, identity: identity, identified: identified})
|
||||
}
|
||||
return records
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
//go:build !windows
|
||||
|
||||
package sandbox
|
||||
|
||||
// acquireRuntimeLeaseForPlatform creates and locks the lease through the same
|
||||
// rooted no-follow boundary the Windows side uses.
|
||||
//
|
||||
// A PRE-CHECK IS NOT A BOUNDARY. This used to call refuseAliasedRuntimeComponents
|
||||
// and then os.MkdirAll plus a pathname lease open. The guard answers about an
|
||||
// ABSENT component by saying there is nothing to alias, which is exactly the
|
||||
// state a fresh fallback root is in, and both calls that followed resolve the
|
||||
// name again. A link planted in between was therefore created through and
|
||||
// written into, and the next check could only report it after the writes.
|
||||
//
|
||||
// The alias guard is gone from this path rather than kept alongside: retaining it
|
||||
// would suggest the two together are the protection, when the descent is the
|
||||
// protection and the guard is the thing that could not be one. Other callers
|
||||
// still use it where a pathname really is all there is.
|
||||
func acquireRuntimeLeaseForPlatform(root string) (*sandboxRuntimeLease, []windowsCreatedRuntimeDir, error) {
|
||||
return acquireRuntimeLeaseRootedUnix(root)
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
//go:build windows
|
||||
|
||||
package sandbox
|
||||
|
||||
// acquireRuntimeLeaseForPlatform routes Windows through the rooted, no-follow
|
||||
// acquisition. See acquireRuntimeLeaseRooted for why the pathname walk it
|
||||
// replaces was unsafe.
|
||||
func acquireRuntimeLeaseForPlatform(root string) (*sandboxRuntimeLease, []windowsCreatedRuntimeDir, error) {
|
||||
return acquireRuntimeLeaseRooted(root)
|
||||
}
|
||||
@@ -1,299 +0,0 @@
|
||||
//go:build windows
|
||||
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"unsafe"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// seedRuntimeLeaseTree creates the owned tail and the lease the way a first run
|
||||
// does, then removes just the lease file so a test can put something else at that
|
||||
// name. The tree has to exist, or cleanup's descent has nothing to open and the
|
||||
// test would pass for the wrong reason.
|
||||
func seedRuntimeLeaseTree(t *testing.T, root string) string {
|
||||
t.Helper()
|
||||
lease, _, err := prepareSandboxRuntimeLeaseRecording(root)
|
||||
if err != nil {
|
||||
t.Skipf("cannot seed a runtime lease here: %v", err)
|
||||
}
|
||||
lease.release()
|
||||
leasePath := sandboxRuntimeLeasePath(root)
|
||||
if err := os.Remove(leasePath); err != nil {
|
||||
t.Fatalf("SETUP INVALID: cannot clear the seeded lease: %v", err)
|
||||
}
|
||||
return leasePath
|
||||
}
|
||||
|
||||
// plantFileLink puts a FILE symbolic link at leasePath pointing at an ordinary
|
||||
// file, and returns the target.
|
||||
//
|
||||
// A file link, not a junction: a junction is a directory and FILE_NON_DIRECTORY_FILE
|
||||
// already refuses it, so a junction here would pass against the defect and prove
|
||||
// nothing. Creating one needs SeCreateSymbolicLinkPrivilege, which an ordinary
|
||||
// unelevated account does not hold, so this skips there and runs on CI.
|
||||
func plantFileLink(t *testing.T, leasePath string) string {
|
||||
t.Helper()
|
||||
target := filepath.Join(t.TempDir(), "target.lease")
|
||||
if err := os.WriteFile(target, nil, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(target, leasePath); err != nil {
|
||||
t.Skipf("cannot create a file symbolic link here, which needs SeCreateSymbolicLinkPrivilege: %v", err)
|
||||
}
|
||||
// SETUP: it really is a reparse point, or nothing below is about links.
|
||||
info, err := os.Lstat(leasePath)
|
||||
if err != nil || info.Mode()&os.ModeSymlink == 0 {
|
||||
t.Fatalf("SETUP INVALID: %s is not a link (%v, %v)", leasePath, info, err)
|
||||
}
|
||||
return target
|
||||
}
|
||||
|
||||
// assertTargetUnlocked proves nobody holds a lock on the link's target, which is
|
||||
// the object the defect made the two sides fight over.
|
||||
func assertTargetUnlocked(t *testing.T, target string) {
|
||||
t.Helper()
|
||||
file, err := os.OpenFile(target, os.O_RDWR, 0o600)
|
||||
if err != nil {
|
||||
t.Fatalf("open the link target: %v", err)
|
||||
}
|
||||
defer func() { _ = file.Close() }()
|
||||
var overlapped windows.Overlapped
|
||||
flags := uint32(windows.LOCKFILE_EXCLUSIVE_LOCK | windows.LOCKFILE_FAIL_IMMEDIATELY)
|
||||
if err := windows.LockFileEx(windows.Handle(file.Fd()), flags, 0, 1, 0, &overlapped); err != nil {
|
||||
t.Fatalf("the link target is locked, so the refusal happened after the lease had already been taken on it: %v", err)
|
||||
}
|
||||
_ = windows.UnlockFileEx(windows.Handle(file.Fd()), 0, 1, 0, &overlapped)
|
||||
}
|
||||
|
||||
// FILE_OPEN_REPARSE_POINT SAYS DO NOT FOLLOW, NOT REFUSE.
|
||||
//
|
||||
// The rooted opener passed that flag and FILE_NON_DIRECTORY_FILE and treated the
|
||||
// pair as a guarantee. It is not: the flag returns a handle to the LINK, and
|
||||
// FILE_NON_DIRECTORY_FILE excludes directories rather than non-directory reparse
|
||||
// objects. A file symbolic link at <digest>.lease was opened, wrapped and locked
|
||||
// as though it were the lease.
|
||||
//
|
||||
// That matters because cleanup opened the same name by pathname with no
|
||||
// no-follow flag at all, so it locked the link's TARGET. Two holders, two
|
||||
// objects, both calls succeeding, and cleanup free to RemoveAll a runtime root a
|
||||
// live command was using.
|
||||
func TestSharedLeaseRefusesAFileLinkAtTheLeaseName(t *testing.T) {
|
||||
cacheRoot := t.TempDir()
|
||||
root := leaseRootUnder(t, cacheRoot)
|
||||
leasePath := seedRuntimeLeaseTree(t, root)
|
||||
target := plantFileLink(t, leasePath)
|
||||
|
||||
lease, _, err := prepareSandboxRuntimeLeaseRecording(root)
|
||||
if lease != nil {
|
||||
lease.release()
|
||||
}
|
||||
if err == nil {
|
||||
t.Fatal("shared acquisition accepted a file link at the lease name, so it is holding a lock on an object cleanup does not see")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "reparse point") {
|
||||
t.Errorf("refused for the wrong reason: %v", err)
|
||||
}
|
||||
assertTargetUnlocked(t, target)
|
||||
}
|
||||
|
||||
// And cleanup refuses the same object, so it cannot decide the root is free by
|
||||
// locking something else.
|
||||
func TestCleanupLeaseRefusesAFileLinkAtTheLeaseName(t *testing.T) {
|
||||
cacheRoot := t.TempDir()
|
||||
root := leaseRootUnder(t, cacheRoot)
|
||||
leasePath := seedRuntimeLeaseTree(t, root)
|
||||
target := plantFileLink(t, leasePath)
|
||||
|
||||
lease, inUse, err := tryAcquireSandboxRuntimeCleanupLease(root)
|
||||
if lease != nil {
|
||||
lease.release()
|
||||
}
|
||||
if err == nil {
|
||||
t.Fatalf("cleanup accepted a file link at the lease name (inUse=%t); it would treat a lock on %s as proof the runtime root is free to delete", inUse, target)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "reparse point") {
|
||||
t.Errorf("refused for the wrong reason: %v", err)
|
||||
}
|
||||
assertTargetUnlocked(t, target)
|
||||
}
|
||||
|
||||
// CONTROL: THE TWO SIDES REALLY DO COORDINATE ON AN ORDINARY LEASE.
|
||||
//
|
||||
// Without this, refusing everything would satisfy the two tests above. This one
|
||||
// runs everywhere, including on an unelevated box with no symbolic-link
|
||||
// privilege, so the mutual-exclusion contract stays pinned even where the link
|
||||
// cases skip.
|
||||
func TestASharedLeaseMakesCleanupReportInUse(t *testing.T) {
|
||||
cacheRoot := t.TempDir()
|
||||
root := leaseRootUnder(t, cacheRoot)
|
||||
|
||||
held, _, err := prepareSandboxRuntimeLeaseRecording(root)
|
||||
if err != nil {
|
||||
t.Skipf("cannot acquire a runtime lease here: %v", err)
|
||||
}
|
||||
|
||||
lease, inUse, err := tryAcquireSandboxRuntimeCleanupLease(root)
|
||||
if lease != nil {
|
||||
lease.release()
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("cleanup failed against a legitimately held lease: %v", err)
|
||||
}
|
||||
if !inUse {
|
||||
t.Fatal("cleanup reported the runtime root free while a shared lease was held; it would delete a tree a live command is using")
|
||||
}
|
||||
|
||||
held.release()
|
||||
|
||||
lease, inUse, err = tryAcquireSandboxRuntimeCleanupLease(root)
|
||||
if err != nil {
|
||||
t.Fatalf("cleanup failed after the shared lease was released: %v", err)
|
||||
}
|
||||
if inUse {
|
||||
t.Fatal("cleanup still reported the runtime root in use after the only holder released it, so reclamation never happens")
|
||||
}
|
||||
if lease == nil {
|
||||
t.Fatal("cleanup reported the root free but handed back no lease")
|
||||
}
|
||||
lease.release()
|
||||
}
|
||||
|
||||
// CONTROL: a legitimate lease is still SHARED between processes.
|
||||
//
|
||||
// The classification must not turn the shared lease into an exclusive one, or
|
||||
// two concurrent commands on one workspace would stop working.
|
||||
func TestAnOrdinaryLeaseIsStillSharedByTwoHolders(t *testing.T) {
|
||||
cacheRoot := t.TempDir()
|
||||
root := leaseRootUnder(t, cacheRoot)
|
||||
|
||||
first, _, err := prepareSandboxRuntimeLeaseRecording(root)
|
||||
if err != nil {
|
||||
t.Skipf("cannot acquire a runtime lease here: %v", err)
|
||||
}
|
||||
defer first.release()
|
||||
|
||||
second, _, err := prepareSandboxRuntimeLeaseRecording(root)
|
||||
if err != nil {
|
||||
t.Fatalf("a second holder was refused an existing ordinary lease: %v", err)
|
||||
}
|
||||
second.release()
|
||||
}
|
||||
|
||||
// reparseGUIDDataBuffer is the non-Microsoft form of REPARSE_GUID_DATA_BUFFER.
|
||||
//
|
||||
// Setting a tag with the Microsoft bit clear needs only write access to the file,
|
||||
// unlike a symbolic link, so this runs on an ordinary unelevated account and the
|
||||
// classification stays pinned where the link cases skip.
|
||||
type reparseGUIDDataBuffer struct {
|
||||
ReparseTag uint32
|
||||
ReparseDataLength uint16
|
||||
Reserved uint16
|
||||
ReparseGUID windows.GUID
|
||||
Data [16]byte
|
||||
}
|
||||
|
||||
// plantGenericReparsePoint turns an ordinary file at path into a reparse object.
|
||||
func plantGenericReparsePoint(t *testing.T, path string) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(path, nil, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
name, err := windows.UTF16PtrFromString(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
handle, err := windows.CreateFile(name,
|
||||
windows.GENERIC_WRITE|windows.FILE_WRITE_ATTRIBUTES, 0, nil,
|
||||
windows.OPEN_EXISTING,
|
||||
windows.FILE_FLAG_OPEN_REPARSE_POINT|windows.FILE_FLAG_BACKUP_SEMANTICS, 0)
|
||||
if err != nil {
|
||||
t.Skipf("cannot open %s to set a reparse tag: %v", path, err)
|
||||
}
|
||||
defer func() { _ = windows.CloseHandle(handle) }()
|
||||
|
||||
buffer := reparseGUIDDataBuffer{
|
||||
// Bit 31 clear, so this is a third-party tag and needs no privilege.
|
||||
ReparseTag: 0x00000042,
|
||||
ReparseDataLength: 16,
|
||||
ReparseGUID: windows.GUID{Data1: 0x5ee0e5f1, Data2: 0x1a11, Data3: 0x4d3b, Data4: [8]byte{0x9a, 0x77, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06}},
|
||||
}
|
||||
size := uint32(unsafe.Sizeof(buffer))
|
||||
var returned uint32
|
||||
if err := windows.DeviceIoControl(handle, windowsFSCTLSetReparsePoint,
|
||||
(*byte)(unsafe.Pointer(&buffer)), size, nil, 0, &returned, nil); err != nil {
|
||||
t.Skipf("cannot set a generic reparse tag here: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
const windowsFSCTLSetReparsePoint = 0x000900A4
|
||||
|
||||
// ANY REPARSE OBJECT AT THE LEASE NAME IS REFUSED, NOT ONLY A SYMBOLIC LINK.
|
||||
//
|
||||
// The requirement is that the name denotes an ordinary file, because that is what
|
||||
// makes the shared holder and cleanup lock the same thing. Keying on the link
|
||||
// shape instead would leave every other reparse tag accepted, and it would leave
|
||||
// this untested on any machine without the symbolic-link privilege.
|
||||
//
|
||||
// WHAT THIS CASE CANNOT SHOW, so nobody reads a local pass as more than it is:
|
||||
// an unknown third-party tag is unresolvable, so the OLD pathname cleanup fails
|
||||
// on it too, with ERROR_CANT_ACCESS_FILE rather than by classifying anything. It
|
||||
// therefore pins that both sites refuse, and the reason check below is what
|
||||
// separates a refusal from an accident. Only the symbolic-link cases above show
|
||||
// the half that matters most, a pathname open SUCCEEDING on the target, and those
|
||||
// need the privilege. Read their result in CI, not here.
|
||||
func TestLeaseRefusesAnyReparseObjectAtTheLeaseName(t *testing.T) {
|
||||
for name, acquire := range map[string]func(string) error{
|
||||
"shared": func(root string) error {
|
||||
lease, _, err := prepareSandboxRuntimeLeaseRecording(root)
|
||||
if lease != nil {
|
||||
lease.release()
|
||||
}
|
||||
return err
|
||||
},
|
||||
"cleanup": func(root string) error {
|
||||
lease, _, err := tryAcquireSandboxRuntimeCleanupLease(root)
|
||||
if lease != nil {
|
||||
lease.release()
|
||||
}
|
||||
return err
|
||||
},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
cacheRoot := t.TempDir()
|
||||
root := leaseRootUnder(t, cacheRoot)
|
||||
leasePath := seedRuntimeLeaseTree(t, root)
|
||||
plantGenericReparsePoint(t, leasePath)
|
||||
|
||||
// SETUP: the attribute really is set, or this asserts nothing.
|
||||
info, err := os.Lstat(leasePath)
|
||||
if err != nil || info.Mode()&os.ModeIrregular == 0 && info.Mode()&os.ModeSymlink == 0 {
|
||||
attrs, statErr := windowsFileAttributes(leasePath)
|
||||
if statErr != nil || attrs&windows.FILE_ATTRIBUTE_REPARSE_POINT == 0 {
|
||||
t.Fatalf("SETUP INVALID: %s is not a reparse object (attrs=%#x err=%v)", leasePath, attrs, statErr)
|
||||
}
|
||||
}
|
||||
|
||||
err = acquire(root)
|
||||
if err == nil {
|
||||
t.Fatalf("%s acquisition accepted a reparse object at the lease name", name)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "reparse point") {
|
||||
t.Errorf("refused for the wrong reason: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func windowsFileAttributes(path string) (uint32, error) {
|
||||
name, err := windows.UTF16PtrFromString(path)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return windows.GetFileAttributes(name)
|
||||
}
|
||||
@@ -1,282 +0,0 @@
|
||||
//go:build !windows
|
||||
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
// acquireRuntimeLeaseRootedUnix creates and locks the lease for root without
|
||||
// resolving an owned component by name.
|
||||
//
|
||||
// The lease is the FIRST thing setup writes, so it is the write a redirection has
|
||||
// to be caught before, not after. It is a sibling of the runtime root rather than
|
||||
// one of its owned components, which is why the alias guard never looked at it at
|
||||
// all: it walks the components, and the lease is not one of them.
|
||||
//
|
||||
// Only the components above the leaf are created here. The leaf belongs to
|
||||
// provisioning, which records it for rollback, and creating it here would mean
|
||||
// two owners for one directory.
|
||||
func acquireRuntimeLeaseRootedUnix(root string) (*sandboxRuntimeLease, []windowsCreatedRuntimeDir, error) {
|
||||
base, components, owned := windowsSandboxRuntimeOwnedTail(root)
|
||||
if !owned || len(components) == 0 {
|
||||
// Fail rather than fall back to the pathname walk: the walk is the defect.
|
||||
return nil, nil, fmt.Errorf("acquire sandbox runtime lease for %s: %w", root, errRuntimeTailNotOwned)
|
||||
}
|
||||
if runtimeLeasePreCreateBarrier != nil {
|
||||
runtimeLeasePreCreateBarrier()
|
||||
}
|
||||
// The base is the operator's, and may legitimately be a redirected or linked
|
||||
// temp location, so it is created and opened by name exactly as before.
|
||||
// Everything below it is Zero's and is addressed by descriptor.
|
||||
if err := os.MkdirAll(base, 0o700); err != nil {
|
||||
return nil, nil, fmt.Errorf("create sandbox runtime base: %w", err)
|
||||
}
|
||||
|
||||
// The lease sits beside the leaf, so the deepest directory needed here is the
|
||||
// leaf's parent.
|
||||
tail := make([]string, 0, len(components)-1)
|
||||
current := base
|
||||
for _, component := range components[:len(components)-1] {
|
||||
current = filepath.Join(current, component)
|
||||
tail = append(tail, current)
|
||||
}
|
||||
|
||||
// THE WHOLE ACQUISITION RETRIES, NOT JUST THE LOCK. A contender that waited
|
||||
// on the lease while cleanup held it exclusively can wake on an object cleanup
|
||||
// has since unlinked, and by then cleanup has compensated the tail this run
|
||||
// created as well. Re-taking the lock alone would lock a fresh file beside a
|
||||
// leaf that is gone, so a stale lock sends the caller back through tail
|
||||
// creation. The ledger accumulates across attempts: a directory this run made
|
||||
// on an earlier pass and cleanup did not remove is still this run's to
|
||||
// compensate.
|
||||
var created []windowsCreatedRuntimeDir
|
||||
for attempt := 1; ; attempt++ {
|
||||
made, parent, err := createRuntimeTailRetainingFD(base, tail)
|
||||
created = appendCreatedRuntimeDirs(created, made)
|
||||
if err != nil {
|
||||
return nil, created, err
|
||||
}
|
||||
handle, madeLease, err := acquireSharedRuntimeLeaseAtFD(parent, filepath.Base(sandboxRuntimeLeasePath(root)))
|
||||
_ = unix.Close(parent)
|
||||
if err == nil {
|
||||
return &sandboxRuntimeLease{handle: handle, root: root, createdFile: madeLease}, created, nil
|
||||
}
|
||||
if errors.Is(err, errRuntimeLeaseReplaced) && attempt < runtimeLeaseAcquireAttempts {
|
||||
continue
|
||||
}
|
||||
return nil, created, fmt.Errorf("acquire sandbox runtime lease: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// acquireSharedRuntimeLeaseAtFD opens the lease relative to a verified parent and
|
||||
// takes the shared lock on it.
|
||||
func acquireSharedRuntimeLeaseAtFD(parent int, name string) (runtimeLeaseHandle, bool, error) {
|
||||
file, created, err := openRuntimeLeaseAtFD(parent, name)
|
||||
if err != nil {
|
||||
return runtimeLeaseHandle{}, false, err
|
||||
}
|
||||
if err := unix.Flock(int(file.Fd()), unix.LOCK_SH); err != nil {
|
||||
// The lock is the last step, and until now the created fact went out only
|
||||
// alongside a lease object. Failing here returned no object, so this
|
||||
// invocation's own lease file stayed on disk with nothing recording it,
|
||||
// and its directory could never be compensated afterwards.
|
||||
err = undoCreatedRuntimeLeaseAtFD(created, parent, name, err)
|
||||
_ = file.Close()
|
||||
return runtimeLeaseHandle{}, false, err
|
||||
}
|
||||
// THE LOCK IS ON AN INODE, THE COORDINATION IS ON A NAME. Flock blocks while
|
||||
// cleanup holds the exclusive lock, and cleanup unlinks the lease name before
|
||||
// it lets go. A waiter then acquires the old, unlinked inode and returns a
|
||||
// lease that coordinates with nobody: the next cleanup creates a fresh file at
|
||||
// the name, locks it unopposed, and reports the root unused while this holder
|
||||
// is still running in it. So the lock is only a lease once the locked object
|
||||
// is still what the name resolves to. Reported by @gnanam1990.
|
||||
current, err := runtimeLeaseIsCurrentAtFD(parent, name, int(file.Fd()))
|
||||
if err != nil {
|
||||
_ = file.Close()
|
||||
return runtimeLeaseHandle{}, false, err
|
||||
}
|
||||
if !current {
|
||||
// Not undone by name, whatever created says: the name is no longer this
|
||||
// object's, so an unlink there would take somebody else's lease.
|
||||
_ = file.Close()
|
||||
return runtimeLeaseHandle{}, false, errRuntimeLeaseReplaced
|
||||
}
|
||||
return runtimeLeaseHandle{file: file}, created, nil
|
||||
}
|
||||
|
||||
// runtimeLeaseIsCurrentAtFD reports whether the locked lease is still the object
|
||||
// the lease name resolves to under parent.
|
||||
//
|
||||
// Both answers come from the descriptor side: the locked file's own identity and
|
||||
// the directory entry read relative to the retained parent, no-follow. A missing
|
||||
// entry, a different inode at the name, or a locked inode with no links left all
|
||||
// mean the coordination moved on while this call was waiting.
|
||||
func runtimeLeaseIsCurrentAtFD(parent int, name string, fd int) (bool, error) {
|
||||
var locked, entry unix.Stat_t
|
||||
if err := unix.Fstat(fd, &locked); err != nil {
|
||||
return false, fmt.Errorf("inspect the locked sandbox runtime lease %s: %w", name, err)
|
||||
}
|
||||
if locked.Nlink == 0 {
|
||||
return false, nil
|
||||
}
|
||||
if err := unix.Fstatat(parent, name, &entry, unix.AT_SYMLINK_NOFOLLOW); err != nil {
|
||||
if errors.Is(err, unix.ENOENT) {
|
||||
return false, nil
|
||||
}
|
||||
return false, fmt.Errorf("inspect the sandbox runtime lease entry %s: %w", name, err)
|
||||
}
|
||||
return locked.Dev == entry.Dev && locked.Ino == entry.Ino, nil
|
||||
}
|
||||
|
||||
// undoCreatedRuntimeLeaseAtFD removes a lease file this call created, when a
|
||||
// later step means no lease object will be returned to carry that fact.
|
||||
//
|
||||
// Relative to the verified parent descriptor, and only when this call is the one
|
||||
// that created the file: a lease that was already there belongs to whoever is
|
||||
// holding it.
|
||||
func undoCreatedRuntimeLeaseAtFD(created bool, parent int, name string, cause error) error {
|
||||
if !created {
|
||||
return cause
|
||||
}
|
||||
if undoErr := unix.Unlinkat(parent, name, 0); undoErr != nil {
|
||||
return fmt.Errorf("%w; and the lease file this run created could not be removed: %w", cause, undoErr)
|
||||
}
|
||||
return cause
|
||||
}
|
||||
|
||||
// openRuntimeLeaseAtFD opens or creates the lease under parent and proves it is
|
||||
// an ordinary file.
|
||||
//
|
||||
// O_NOFOLLOW so a symlink planted at the lease name is an ELOOP rather than an
|
||||
// open of its target, and a regular-file check besides, because O_NOFOLLOW says
|
||||
// nothing about a fifo or a device a local user can also create there. Both
|
||||
// holders have to end up on the same object or the lock protects nothing.
|
||||
func openRuntimeLeaseAtFD(parent int, name string) (*os.File, bool, error) {
|
||||
const flags = unix.O_RDWR | unix.O_NOFOLLOW | unix.O_CLOEXEC
|
||||
// O_EXCL FIRST, so the create is what reports the create. A Stat beforehand
|
||||
// answers about a moment that has passed by the time the open runs, and
|
||||
// compensation would then delete a lease another process had just made.
|
||||
created := true
|
||||
fd, err := unix.Openat(parent, name, flags|unix.O_CREAT|unix.O_EXCL, 0o600)
|
||||
if errors.Is(err, unix.EEXIST) {
|
||||
created = false
|
||||
fd, err = unix.Openat(parent, name, flags, 0)
|
||||
}
|
||||
if err != nil {
|
||||
if errors.Is(err, unix.ELOOP) {
|
||||
return nil, false, fmt.Errorf("refusing to use the sandbox runtime lease at %s: it is a link, so the holders would lock different objects: %w", name, err)
|
||||
}
|
||||
return nil, false, err
|
||||
}
|
||||
// Every failure from here on has already created the file when created is
|
||||
// true, and returns no lease object to carry that fact, so each one undoes
|
||||
// its own creation rather than leaving an unrecorded artifact behind.
|
||||
if runtimeCreationFailure != nil {
|
||||
if injected := runtimeCreationFailure(name); injected != nil {
|
||||
injected = undoCreatedRuntimeLeaseAtFD(created, parent, name, injected)
|
||||
_ = unix.Close(fd)
|
||||
return nil, false, injected
|
||||
}
|
||||
}
|
||||
var stat unix.Stat_t
|
||||
if err := unix.Fstat(fd, &stat); err != nil {
|
||||
err = undoCreatedRuntimeLeaseAtFD(created, parent, name, fmt.Errorf("inspect the sandbox runtime lease %s: %w", name, err))
|
||||
_ = unix.Close(fd)
|
||||
return nil, false, err
|
||||
}
|
||||
if stat.Mode&unix.S_IFMT != unix.S_IFREG {
|
||||
err := undoCreatedRuntimeLeaseAtFD(created, parent, name, fmt.Errorf("refusing to use the sandbox runtime lease at %s: it is not an ordinary file (mode %#o)", name, stat.Mode&unix.S_IFMT))
|
||||
_ = unix.Close(fd)
|
||||
return nil, false, err
|
||||
}
|
||||
if uid := unix.Getuid(); uid >= 0 && stat.Uid != uint32(uid) {
|
||||
err := undoCreatedRuntimeLeaseAtFD(created, parent, name, fmt.Errorf("refusing to use the sandbox runtime lease at %s: it is owned by uid %d, not %d", name, stat.Uid, uid))
|
||||
_ = unix.Close(fd)
|
||||
return nil, false, err
|
||||
}
|
||||
file := os.NewFile(uintptr(fd), name)
|
||||
if file == nil {
|
||||
err := undoCreatedRuntimeLeaseAtFD(created, parent, name, fmt.Errorf("wrap the sandbox runtime lease handle for %s", name))
|
||||
_ = unix.Close(fd)
|
||||
return nil, false, err
|
||||
}
|
||||
return file, created, nil
|
||||
}
|
||||
|
||||
// tryAcquireExclusiveRuntimeLeaseRootedUnix is cleanup's acquisition, resolved
|
||||
// the way acquisition resolves it.
|
||||
//
|
||||
// It opens and never creates the tree: a runtime root that is not there has no
|
||||
// lease to take, and cleanup rebuilding it in order to lock it would be inventing
|
||||
// the thing it is about to remove.
|
||||
func tryAcquireExclusiveRuntimeLeaseRootedUnix(root string) (runtimeLeaseHandle, bool, error) {
|
||||
base, components, owned := windowsSandboxRuntimeOwnedTail(root)
|
||||
if !owned || len(components) == 0 {
|
||||
return runtimeLeaseHandle{}, false, fmt.Errorf("open the sandbox runtime lease parent for %s: %w", root, errRuntimeTailNotOwned)
|
||||
}
|
||||
parent, err := unix.Open(base, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_CLOEXEC, 0)
|
||||
if err != nil {
|
||||
return runtimeLeaseHandle{}, false, fmt.Errorf("open sandbox runtime base %s: %w", base, err)
|
||||
}
|
||||
path := base
|
||||
for _, name := range components[:len(components)-1] {
|
||||
path = filepath.Join(path, name)
|
||||
child, openErr := unix.Openat(parent, name, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0)
|
||||
if openErr != nil {
|
||||
_ = unix.Close(parent)
|
||||
return runtimeLeaseHandle{}, false, fmt.Errorf("open sandbox runtime component %s: %w", path, openErr)
|
||||
}
|
||||
if err := refuseForeignRuntimeDirectory(child, path); err != nil {
|
||||
_ = unix.Close(child)
|
||||
_ = unix.Close(parent)
|
||||
return runtimeLeaseHandle{}, false, err
|
||||
}
|
||||
_ = unix.Close(parent)
|
||||
parent = child
|
||||
}
|
||||
defer func() { _ = unix.Close(parent) }()
|
||||
|
||||
name := filepath.Base(sandboxRuntimeLeasePath(root))
|
||||
for attempt := 1; ; attempt++ {
|
||||
file, created, err := openRuntimeLeaseAtFD(parent, name)
|
||||
if err != nil {
|
||||
return runtimeLeaseHandle{}, false, err
|
||||
}
|
||||
if err := unix.Flock(int(file.Fd()), unix.LOCK_EX|unix.LOCK_NB); err != nil {
|
||||
if errors.Is(err, unix.EWOULDBLOCK) || errors.Is(err, unix.EAGAIN) {
|
||||
// Somebody holds it, so it is not this call's to remove, whatever the
|
||||
// open reported. A file this call created cannot reach here anyway.
|
||||
_ = file.Close()
|
||||
return runtimeLeaseHandle{}, true, nil
|
||||
}
|
||||
err = undoCreatedRuntimeLeaseAtFD(created, parent, name, err)
|
||||
_ = file.Close()
|
||||
return runtimeLeaseHandle{}, false, err
|
||||
}
|
||||
// The exclusive side has the same window in miniature: between its open
|
||||
// and its lock another cleanup can unlink and replace the name, and an
|
||||
// exclusive lock on the old inode would then remove a tree a holder of the
|
||||
// new one is entering. Same check, same answer: only the current object
|
||||
// counts, and a stale one is reopened rather than reasoned about.
|
||||
current, err := runtimeLeaseIsCurrentAtFD(parent, name, int(file.Fd()))
|
||||
if err != nil {
|
||||
_ = file.Close()
|
||||
return runtimeLeaseHandle{}, false, err
|
||||
}
|
||||
if !current {
|
||||
_ = file.Close()
|
||||
if attempt < runtimeLeaseAcquireAttempts {
|
||||
continue
|
||||
}
|
||||
return runtimeLeaseHandle{}, false, errRuntimeLeaseReplaced
|
||||
}
|
||||
return runtimeLeaseHandle{file: file}, false, nil
|
||||
}
|
||||
}
|
||||
@@ -1,408 +0,0 @@
|
||||
//go:build windows
|
||||
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"unsafe"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// acquireRuntimeLeaseRooted creates and locks the lease file for root without
|
||||
// ever resolving an owned component by name.
|
||||
//
|
||||
// THE LEASE IS ACQUIRED BEFORE ANYTHING ELSE, SO IT CANNOT BE THE WEAK LINK.
|
||||
//
|
||||
// Provisioning already descends from the fixed cache or TEMP base through
|
||||
// retained no-follow handles, because a predictable owned component is exactly
|
||||
// what an ordinary same-account process can replace with a junction. Lease
|
||||
// acquisition ran first and did neither: it checked the components for aliases,
|
||||
// then called os.MkdirAll on the parent by pathname and opened
|
||||
// "<root>.lease" by pathname, both of which follow. A junction dropped on
|
||||
// "zero", "runtime" or "v1" between the check and either call put elevated
|
||||
// setup's first writes inside somebody else's tree, and restoring the component
|
||||
// afterwards left the later handle-relative provisioning working on the
|
||||
// legitimate tree so no post-check ever saw it.
|
||||
//
|
||||
// The lease file is a SIBLING of the runtime root rather than one of its owned
|
||||
// components, so refuseAliasedRuntimeComponents never inspected it at all. Here
|
||||
// it is created relative to the retained handle for the directory that contains
|
||||
// it, which is the deepest owned component, so its name is resolved exactly once
|
||||
// and relative to a verified object.
|
||||
//
|
||||
// Only the components above the leaf are created. The leaf itself belongs to
|
||||
// provisioning, which records it for rollback; creating it here would mean two
|
||||
// owners for one directory.
|
||||
func acquireRuntimeLeaseRooted(root string) (*sandboxRuntimeLease, []windowsCreatedRuntimeDir, error) {
|
||||
base, components, owned := windowsSandboxRuntimeOwnedTail(root)
|
||||
if !owned {
|
||||
// Fail rather than fall back to the pathname walk: the walk is the defect.
|
||||
return nil, nil, fmt.Errorf("acquire sandbox runtime lease for %s: %w", root, errRuntimeTailNotOwned)
|
||||
}
|
||||
if len(components) == 0 {
|
||||
return nil, nil, fmt.Errorf("acquire sandbox runtime lease for %s: %w", root, errRuntimeTailNotOwned)
|
||||
}
|
||||
// The base is the operator's, and may legitimately be a redirected cache or
|
||||
// TEMP location, so it is created and opened by name exactly as provisioning
|
||||
// does. Everything below it is Zero's and is addressed by handle.
|
||||
if runtimeLeasePreCreateBarrier != nil {
|
||||
runtimeLeasePreCreateBarrier()
|
||||
}
|
||||
if err := os.MkdirAll(base, 0o700); err != nil {
|
||||
return nil, nil, fmt.Errorf("create sandbox runtime base: %w", err)
|
||||
}
|
||||
|
||||
// The lease sits beside the leaf, so the deepest directory needed here is the
|
||||
// leaf's parent.
|
||||
parents := components[:len(components)-1]
|
||||
tail := make([]string, 0, len(parents))
|
||||
current := base
|
||||
for _, component := range parents {
|
||||
current = filepath.Join(current, component)
|
||||
tail = append(tail, current)
|
||||
}
|
||||
|
||||
// THE WHOLE ACQUISITION RETRIES, NOT JUST THE LOCK. A contender that waited
|
||||
// on the lease while cleanup held it exclusively can wake on an object cleanup
|
||||
// has since marked for deletion, and by then cleanup has compensated the tail
|
||||
// this run created as well. Re-taking the lock alone would lock a fresh file
|
||||
// beside a leaf that is gone, so a stale lock sends the caller back through
|
||||
// tail creation. The ledger accumulates across attempts: a directory this run
|
||||
// made on an earlier pass and cleanup did not remove is still this run's to
|
||||
// compensate.
|
||||
var created []windowsCreatedRuntimeDir
|
||||
for attempt := 1; ; attempt++ {
|
||||
made, parent, err := createRuntimeTailRetainingHandle(base, tail)
|
||||
created = appendCreatedRuntimeDirs(created, made)
|
||||
if err != nil {
|
||||
if parent != 0 {
|
||||
_ = windows.CloseHandle(parent)
|
||||
}
|
||||
return nil, created, err
|
||||
}
|
||||
handle, madeLease, err := acquireSharedRuntimeLeaseAt(parent, filepath.Base(sandboxRuntimeLeasePath(root)))
|
||||
_ = windows.CloseHandle(parent)
|
||||
if err == nil {
|
||||
return &sandboxRuntimeLease{handle: handle, root: root, createdFile: madeLease}, created, nil
|
||||
}
|
||||
if errors.Is(err, errRuntimeLeaseReplaced) && attempt < runtimeLeaseAcquireAttempts {
|
||||
continue
|
||||
}
|
||||
return nil, created, fmt.Errorf("acquire sandbox runtime lease: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// acquireSharedRuntimeLeaseAt is acquireSharedRuntimeLease with the file named
|
||||
// relative to a directory handle instead of by full path.
|
||||
func acquireSharedRuntimeLeaseAt(parent windows.Handle, name string) (runtimeLeaseHandle, bool, error) {
|
||||
objectName, err := windows.NewNTUnicodeString(name)
|
||||
if err != nil {
|
||||
return runtimeLeaseHandle{}, false, fmt.Errorf("encode sandbox runtime lease name %s: %w", name, err)
|
||||
}
|
||||
attributes := windows.OBJECT_ATTRIBUTES{
|
||||
RootDirectory: parent,
|
||||
ObjectName: objectName,
|
||||
Attributes: windows.OBJ_CASE_INSENSITIVE,
|
||||
}
|
||||
attributes.Length = uint32(unsafe.Sizeof(attributes))
|
||||
|
||||
var handle windows.Handle
|
||||
var iosb windows.IO_STATUS_BLOCK
|
||||
// FILE_OPEN_IF because the lease is shared: whoever gets there first creates
|
||||
// it. FILE_NON_DIRECTORY_FILE so a directory under that name is refused.
|
||||
//
|
||||
// FILE_OPEN_REPARSE_POINT IS NOT A REFUSAL. It says do not follow, so the call
|
||||
// returns a handle to the LINK, and FILE_NON_DIRECTORY_FILE excludes
|
||||
// directories rather than non-directory reparse objects. A file symbolic link
|
||||
// planted at <digest>.lease was therefore opened and locked as if it were the
|
||||
// lease. No-follow and classification are two requirements; the flag is only
|
||||
// the first, and the handle is asked for the second below.
|
||||
err = windows.NtCreateFile(
|
||||
&handle,
|
||||
windows.DELETE|windows.GENERIC_READ|windows.GENERIC_WRITE|windows.SYNCHRONIZE,
|
||||
&attributes,
|
||||
&iosb,
|
||||
nil,
|
||||
windows.FILE_ATTRIBUTE_NORMAL,
|
||||
windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE,
|
||||
windows.FILE_OPEN_IF,
|
||||
windows.FILE_NON_DIRECTORY_FILE|windows.FILE_SYNCHRONOUS_IO_NONALERT|windows.FILE_OPEN_REPARSE_POINT,
|
||||
0,
|
||||
0,
|
||||
)
|
||||
if err != nil {
|
||||
return runtimeLeaseHandle{}, false, err
|
||||
}
|
||||
// FILE_CREATED here, rather than a Stat before the call, because only the
|
||||
// create itself can distinguish the file it made from one that arrived a
|
||||
// moment earlier.
|
||||
created := iosb.Information == windowsFileCreatedDisposition
|
||||
// And every failure below has already created the file when that is true,
|
||||
// while returning no lease object to carry the fact, so each undoes its own
|
||||
// creation instead of leaving an unrecorded artifact in a directory
|
||||
// compensation then cannot empty.
|
||||
if runtimeCreationFailure != nil {
|
||||
if injected := runtimeCreationFailure(name); injected != nil {
|
||||
injected = undoCreatedRuntimeLease(created, handle, injected)
|
||||
_ = windows.CloseHandle(handle)
|
||||
return runtimeLeaseHandle{}, false, injected
|
||||
}
|
||||
}
|
||||
if err := refuseReparseRuntimeLeaseHandle(handle, name); err != nil {
|
||||
err = undoCreatedRuntimeLease(created, handle, err)
|
||||
_ = windows.CloseHandle(handle)
|
||||
return runtimeLeaseHandle{}, false, err
|
||||
}
|
||||
file := os.NewFile(uintptr(handle), name)
|
||||
if file == nil {
|
||||
err := undoCreatedRuntimeLease(created, handle, fmt.Errorf("wrap the sandbox runtime lease handle for %s", name))
|
||||
_ = windows.CloseHandle(handle)
|
||||
return runtimeLeaseHandle{}, false, err
|
||||
}
|
||||
lease := runtimeLeaseHandle{file: file}
|
||||
if err := windows.LockFileEx(windows.Handle(file.Fd()), 0, 0, 1, 0, &lease.overlapped); err != nil {
|
||||
err = undoCreatedRuntimeLease(created, windows.Handle(file.Fd()), err)
|
||||
_ = file.Close()
|
||||
return runtimeLeaseHandle{}, false, err
|
||||
}
|
||||
// THE LOCK IS ON AN OBJECT, THE COORDINATION IS ON A NAME. LockFileEx waits
|
||||
// while cleanup holds the exclusive lock, and cleanup marks the lease for
|
||||
// deletion before it lets go; this handle was opened with FILE_SHARE_DELETE
|
||||
// precisely so cleanup could. A waiter then locks a delete-pending object and
|
||||
// returns a lease that coordinates with nobody: once this handle closes the
|
||||
// name is free, the next cleanup creates a fresh file there, locks it
|
||||
// unopposed, and reports the root unused while this holder is still running
|
||||
// in it. So the lock is only a lease once the locked object is still what the
|
||||
// name resolves to. Reported by @gnanam1990.
|
||||
current, err := runtimeLeaseIsCurrentAt(parent, name, windows.Handle(file.Fd()))
|
||||
if err != nil {
|
||||
_ = file.Close()
|
||||
return runtimeLeaseHandle{}, false, err
|
||||
}
|
||||
if !current {
|
||||
// Not undone, whatever created says: the object is already going, and the
|
||||
// name now belongs to whoever comes next. Closing this handle is what lets
|
||||
// the name go so the retry can take a current one.
|
||||
_ = file.Close()
|
||||
return runtimeLeaseHandle{}, false, errRuntimeLeaseReplaced
|
||||
}
|
||||
return lease, created, nil
|
||||
}
|
||||
|
||||
// runtimeLeaseIsCurrentAt reports whether the locked lease is still the object
|
||||
// the lease name resolves to under parent.
|
||||
//
|
||||
// Both answers come from handles: the locked file's own identity, and a fresh
|
||||
// no-follow open of the name relative to the retained parent, asked for nothing
|
||||
// but attributes. A name that is gone or delete-pending, or that resolves to a
|
||||
// different volume and file index, means the coordination moved on while this
|
||||
// call was waiting.
|
||||
func runtimeLeaseIsCurrentAt(parent windows.Handle, name string, locked windows.Handle) (bool, error) {
|
||||
var lockedInfo windows.ByHandleFileInformation
|
||||
if err := windows.GetFileInformationByHandle(locked, &lockedInfo); err != nil {
|
||||
return false, fmt.Errorf("inspect the locked sandbox runtime lease %s: %w", name, err)
|
||||
}
|
||||
objectName, err := windows.NewNTUnicodeString(name)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("encode sandbox runtime lease name %s: %w", name, err)
|
||||
}
|
||||
attributes := windows.OBJECT_ATTRIBUTES{
|
||||
RootDirectory: parent,
|
||||
ObjectName: objectName,
|
||||
Attributes: windows.OBJ_CASE_INSENSITIVE,
|
||||
}
|
||||
attributes.Length = uint32(unsafe.Sizeof(attributes))
|
||||
var probe windows.Handle
|
||||
var iosb windows.IO_STATUS_BLOCK
|
||||
err = windows.NtCreateFile(
|
||||
&probe,
|
||||
windows.FILE_READ_ATTRIBUTES|windows.SYNCHRONIZE,
|
||||
&attributes,
|
||||
&iosb,
|
||||
nil,
|
||||
0,
|
||||
windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE,
|
||||
windows.FILE_OPEN,
|
||||
windows.FILE_NON_DIRECTORY_FILE|windows.FILE_SYNCHRONOUS_IO_NONALERT|windows.FILE_OPEN_REPARSE_POINT,
|
||||
0,
|
||||
0,
|
||||
)
|
||||
if err != nil {
|
||||
if errors.Is(err, windows.STATUS_OBJECT_NAME_NOT_FOUND) || errors.Is(err, windows.STATUS_DELETE_PENDING) {
|
||||
return false, nil
|
||||
}
|
||||
return false, fmt.Errorf("inspect the sandbox runtime lease entry %s: %w", name, err)
|
||||
}
|
||||
defer func() { _ = windows.CloseHandle(probe) }()
|
||||
var entryInfo windows.ByHandleFileInformation
|
||||
if err := windows.GetFileInformationByHandle(probe, &entryInfo); err != nil {
|
||||
return false, fmt.Errorf("inspect the sandbox runtime lease entry %s: %w", name, err)
|
||||
}
|
||||
return lockedInfo.VolumeSerialNumber == entryInfo.VolumeSerialNumber &&
|
||||
lockedInfo.FileIndexHigh == entryInfo.FileIndexHigh &&
|
||||
lockedInfo.FileIndexLow == entryInfo.FileIndexLow, nil
|
||||
}
|
||||
|
||||
// undoCreatedRuntimeLease removes a lease file this call created, when a later
|
||||
// step means no lease object will be returned to carry that fact.
|
||||
//
|
||||
// Through the handle, and only when this call is the one that created the file:
|
||||
// a lease that was already there belongs to whoever is holding it.
|
||||
func undoCreatedRuntimeLease(created bool, handle windows.Handle, cause error) error {
|
||||
if !created {
|
||||
return cause
|
||||
}
|
||||
if undoErr := removeWindowsObjectByHandle(handle); undoErr != nil {
|
||||
return fmt.Errorf("%w; and the lease file this run created could not be removed: %w", cause, undoErr)
|
||||
}
|
||||
return cause
|
||||
}
|
||||
|
||||
// refuseReparseRuntimeLeaseHandle proves the opened lease is an ordinary file.
|
||||
//
|
||||
// Asked of the HANDLE, not the name, so there is no second resolution for a
|
||||
// substitution to land in. This is the check the directory descent already makes
|
||||
// in openWindowsChildNoFollow; the lease carried the no-follow flag and not the
|
||||
// classification that has to go with it.
|
||||
func refuseReparseRuntimeLeaseHandle(handle windows.Handle, name string) error {
|
||||
var info windows.ByHandleFileInformation
|
||||
if err := windows.GetFileInformationByHandle(handle, &info); err != nil {
|
||||
return fmt.Errorf("inspect the sandbox runtime lease %s: %w", name, err)
|
||||
}
|
||||
if info.FileAttributes&windows.FILE_ATTRIBUTE_REPARSE_POINT != 0 {
|
||||
return fmt.Errorf("refusing to use the sandbox runtime lease at %s: a reparse point here means the shared and exclusive holders would lock different objects", name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// openRuntimeLeaseParentRooted opens, and never creates, the directory holding
|
||||
// the lease for root.
|
||||
//
|
||||
// Cleanup's side of the contract. It descends the same owned components from the
|
||||
// same base with the same no-follow opens acquisition uses, so both sides resolve
|
||||
// the lease name relative to a verified directory rather than by pathname.
|
||||
//
|
||||
// It creates nothing. A runtime tree that is not there has no lease to take, and
|
||||
// cleanup rebuilding the tree in order to lock it would be inventing the thing it
|
||||
// is about to remove.
|
||||
func openRuntimeLeaseParentRooted(root string) (windows.Handle, error) {
|
||||
base, components, owned := windowsSandboxRuntimeOwnedTail(root)
|
||||
if !owned || len(components) == 0 {
|
||||
return 0, fmt.Errorf("open the sandbox runtime lease parent for %s: %w", root, errRuntimeTailNotOwned)
|
||||
}
|
||||
parent, err := openWindowsDirectoryByName(base)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("open sandbox runtime base %s: %w", base, err)
|
||||
}
|
||||
// The lease is a SIBLING of the leaf, so the deepest directory needed here is
|
||||
// the leaf's parent, exactly as in acquisition.
|
||||
for _, name := range components[:len(components)-1] {
|
||||
child, openErr := openWindowsChildNoFollow(parent, name,
|
||||
windows.FILE_READ_ATTRIBUTES|windows.FILE_TRAVERSE, windows.FILE_DIRECTORY_FILE)
|
||||
if openErr != nil {
|
||||
_ = windows.CloseHandle(parent)
|
||||
return 0, openErr
|
||||
}
|
||||
_ = windows.CloseHandle(parent)
|
||||
parent = child
|
||||
}
|
||||
return parent, nil
|
||||
}
|
||||
|
||||
// tryAcquireExclusiveRuntimeLeaseRooted is cleanup's acquisition, resolved the
|
||||
// way acquisition resolves it.
|
||||
func tryAcquireExclusiveRuntimeLeaseRooted(root string) (runtimeLeaseHandle, bool, error) {
|
||||
parent, err := openRuntimeLeaseParentRooted(root)
|
||||
if err != nil {
|
||||
return runtimeLeaseHandle{}, false, err
|
||||
}
|
||||
defer func() { _ = windows.CloseHandle(parent) }()
|
||||
|
||||
name := filepath.Base(sandboxRuntimeLeasePath(root))
|
||||
objectName, err := windows.NewNTUnicodeString(name)
|
||||
if err != nil {
|
||||
return runtimeLeaseHandle{}, false, fmt.Errorf("encode sandbox runtime lease name %s: %w", name, err)
|
||||
}
|
||||
attributes := windows.OBJECT_ATTRIBUTES{
|
||||
RootDirectory: parent,
|
||||
ObjectName: objectName,
|
||||
Attributes: windows.OBJ_CASE_INSENSITIVE,
|
||||
}
|
||||
attributes.Length = uint32(unsafe.Sizeof(attributes))
|
||||
|
||||
for attempt := 1; ; attempt++ {
|
||||
var handle windows.Handle
|
||||
var iosb windows.IO_STATUS_BLOCK
|
||||
// FILE_OPEN_IF matches what cleanup did by pathname with O_CREATE: a
|
||||
// runtime root whose lease file is gone is held by nobody, and creating the
|
||||
// empty lease is how that is expressed.
|
||||
err = windows.NtCreateFile(
|
||||
&handle,
|
||||
windows.DELETE|windows.GENERIC_READ|windows.GENERIC_WRITE|windows.SYNCHRONIZE,
|
||||
&attributes,
|
||||
&iosb,
|
||||
nil,
|
||||
windows.FILE_ATTRIBUTE_NORMAL,
|
||||
windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE,
|
||||
windows.FILE_OPEN_IF,
|
||||
windows.FILE_NON_DIRECTORY_FILE|windows.FILE_SYNCHRONOUS_IO_NONALERT|windows.FILE_OPEN_REPARSE_POINT,
|
||||
0,
|
||||
0,
|
||||
)
|
||||
if err != nil {
|
||||
return runtimeLeaseHandle{}, false, err
|
||||
}
|
||||
created := iosb.Information == windowsFileCreatedDisposition
|
||||
if err := refuseReparseRuntimeLeaseHandle(handle, name); err != nil {
|
||||
err = undoCreatedRuntimeLease(created, handle, err)
|
||||
_ = windows.CloseHandle(handle)
|
||||
return runtimeLeaseHandle{}, false, err
|
||||
}
|
||||
file := os.NewFile(uintptr(handle), name)
|
||||
if file == nil {
|
||||
err := undoCreatedRuntimeLease(created, handle, fmt.Errorf("wrap the sandbox runtime lease handle for %s", name))
|
||||
_ = windows.CloseHandle(handle)
|
||||
return runtimeLeaseHandle{}, false, err
|
||||
}
|
||||
lease := runtimeLeaseHandle{file: file}
|
||||
flags := uint32(windows.LOCKFILE_EXCLUSIVE_LOCK | windows.LOCKFILE_FAIL_IMMEDIATELY)
|
||||
if err := windows.LockFileEx(windows.Handle(file.Fd()), flags, 0, 1, 0, &lease.overlapped); err != nil {
|
||||
if errors.Is(err, windows.ERROR_LOCK_VIOLATION) {
|
||||
// Somebody holds it, so it is not this call's to remove, whatever the
|
||||
// open reported. A file this call created cannot reach here anyway.
|
||||
_ = file.Close()
|
||||
return runtimeLeaseHandle{}, true, nil
|
||||
}
|
||||
err = undoCreatedRuntimeLease(created, windows.Handle(file.Fd()), err)
|
||||
_ = file.Close()
|
||||
return runtimeLeaseHandle{}, false, err
|
||||
}
|
||||
// The exclusive side has the same window in miniature: between its open
|
||||
// and its lock another cleanup can mark the object for deletion, and an
|
||||
// exclusive lock on that object would then remove a tree a holder of the
|
||||
// replacement is entering. Same check, same answer: only the current
|
||||
// object counts, and a stale one is reopened rather than reasoned about.
|
||||
current, err := runtimeLeaseIsCurrentAt(parent, name, windows.Handle(file.Fd()))
|
||||
if err != nil {
|
||||
_ = file.Close()
|
||||
return runtimeLeaseHandle{}, false, err
|
||||
}
|
||||
if !current {
|
||||
_ = file.Close()
|
||||
if attempt < runtimeLeaseAcquireAttempts {
|
||||
continue
|
||||
}
|
||||
return runtimeLeaseHandle{}, false, errRuntimeLeaseReplaced
|
||||
}
|
||||
return lease, false, nil
|
||||
}
|
||||
}
|
||||
|
||||
// windowsFileCreatedDisposition is the IO_STATUS_BLOCK Information value that
|
||||
// says NtCreateFile made the file rather than opening one that was there.
|
||||
// x/sys/windows does not export it.
|
||||
const windowsFileCreatedDisposition = 2
|
||||
@@ -3,6 +3,7 @@
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
@@ -12,15 +13,31 @@ type runtimeLeaseHandle struct {
|
||||
file *os.File
|
||||
}
|
||||
|
||||
// BOTH SIDES HAVE TO MEAN THE SAME OBJECT.
|
||||
//
|
||||
// Cleanup opened the lease by full pathname while acquisition opened it relative
|
||||
// to a verified parent, so a symlink at <digest>.lease gave the two of them
|
||||
// different files: a live command held a shared lock on one, cleanup took an
|
||||
// exclusive lock on the other, both succeeded, and cleanup went on to remove a
|
||||
// runtime root that was still in use.
|
||||
func tryAcquireExclusiveRuntimeLease(root string) (runtimeLeaseHandle, bool, error) {
|
||||
return tryAcquireExclusiveRuntimeLeaseRootedUnix(root)
|
||||
func acquireSharedRuntimeLease(path string) (runtimeLeaseHandle, error) {
|
||||
file, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o600)
|
||||
if err != nil {
|
||||
return runtimeLeaseHandle{}, err
|
||||
}
|
||||
if err := unix.Flock(int(file.Fd()), unix.LOCK_SH); err != nil {
|
||||
_ = file.Close()
|
||||
return runtimeLeaseHandle{}, err
|
||||
}
|
||||
return runtimeLeaseHandle{file: file}, nil
|
||||
}
|
||||
|
||||
func tryAcquireExclusiveRuntimeLease(path string) (runtimeLeaseHandle, bool, error) {
|
||||
file, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o600)
|
||||
if err != nil {
|
||||
return runtimeLeaseHandle{}, false, err
|
||||
}
|
||||
if err := unix.Flock(int(file.Fd()), unix.LOCK_EX|unix.LOCK_NB); err != nil {
|
||||
_ = file.Close()
|
||||
if errors.Is(err, unix.EWOULDBLOCK) || errors.Is(err, unix.EAGAIN) {
|
||||
return runtimeLeaseHandle{}, true, nil
|
||||
}
|
||||
return runtimeLeaseHandle{}, false, err
|
||||
}
|
||||
return runtimeLeaseHandle{file: file}, false, nil
|
||||
}
|
||||
|
||||
func (lease runtimeLeaseHandle) release() {
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A CONTENDER THAT WAITED OUT A CLEANUP HOLDS THE CURRENT LEASE, NOT THE OLD ONE.
|
||||
//
|
||||
// The schedule the exclusivity test does not reach. A command opens the lease
|
||||
// while cleanup holds it exclusively and waits for the shared lock. Cleanup
|
||||
// removes the lease name and the tree before it lets go. The waiter then wakes on
|
||||
// the old object: unlinked on POSIX, delete-pending on Windows. Locking it
|
||||
// succeeds, and the acquisition used to return that as a lease. Nothing else
|
||||
// could ever see it: the next cleanup created a fresh lease at the name, locked
|
||||
// it unopposed, and reported the root unused while the command was running in
|
||||
// it. The two locks each succeeded and coordinated nothing.
|
||||
//
|
||||
// Both seams are used as barriers, not as failures. The cleanup barrier is
|
||||
// where the contender is started, so its open lands while cleanup owns the lease;
|
||||
// the creation seam is installed there too, rather than up front, because
|
||||
// cleanup's own open consults it on POSIX and would otherwise signal before the
|
||||
// contender exists. Reported by @gnanam1990.
|
||||
func TestAContenderThatWaitedOutACleanupHoldsTheCurrentLease(t *testing.T) {
|
||||
root, _, leasePath := runtimeTreeAwaitingCompensation(t)
|
||||
// Only the leaf is this run's, the same shape as the exclusivity test: the
|
||||
// contender's retained parent handle must survive compensation, which is a
|
||||
// different race from the one under test.
|
||||
created := createdRuntimeDirsForTest(root)
|
||||
|
||||
opened := make(chan struct{})
|
||||
entered := make(chan struct{})
|
||||
var contender *sandboxRuntimeLease
|
||||
var contenderErr error
|
||||
runtimeCleanupExclusivityBarrier = func() {
|
||||
runtimeCreationFailure = func(string) error {
|
||||
select {
|
||||
case <-opened:
|
||||
default:
|
||||
close(opened)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
go func() {
|
||||
defer close(entered)
|
||||
contender, _, contenderErr = acquireRuntimeLeaseForPlatform(root)
|
||||
}()
|
||||
// The contender now holds a handle on the lease cleanup is about to
|
||||
// remove, and is waiting for the lock cleanup holds.
|
||||
<-opened
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
runtimeCleanupExclusivityBarrier = nil
|
||||
runtimeCreationFailure = nil
|
||||
})
|
||||
|
||||
if err := undoWindowsSetupRuntimeCreation(root, created, leasePath, true); err != nil {
|
||||
t.Fatalf("compensation failed: %v", err)
|
||||
}
|
||||
<-entered
|
||||
runtimeCreationFailure = nil
|
||||
if contenderErr != nil {
|
||||
t.Fatalf("the contender could not take the lease after cleanup finished: %v", contenderErr)
|
||||
}
|
||||
if contender == nil {
|
||||
t.Fatal("SETUP INVALID: the contender returned neither a lease nor an error")
|
||||
}
|
||||
defer contender.release()
|
||||
|
||||
// The proof is the next cleanup: if the contender holds the current object,
|
||||
// cleanup finds the root in use. If it holds the object cleanup already
|
||||
// removed, cleanup locks a fresh one and calls the root free.
|
||||
second, inUse, err := tryAcquireSandboxRuntimeCleanupLease(root)
|
||||
if err != nil {
|
||||
t.Fatalf("a second cleanup could not take the lease at all, so the contender is holding an object the name no longer resolves to: %v", err)
|
||||
}
|
||||
if !inUse {
|
||||
second.release()
|
||||
t.Fatal("a second cleanup found the root unused while the contender holds a lease on it: the contender locked the object cleanup had already removed")
|
||||
}
|
||||
if _, statErr := os.Stat(leasePath); statErr != nil {
|
||||
t.Errorf("the contender's lease is not at the lease name: %v", statErr)
|
||||
}
|
||||
|
||||
// CONTROL: once the contender lets go, the root really is free.
|
||||
contender.release()
|
||||
third, inUse, err := tryAcquireSandboxRuntimeCleanupLease(root)
|
||||
if err != nil {
|
||||
t.Fatalf("cleanup after the contender released: %v", err)
|
||||
}
|
||||
if inUse {
|
||||
t.Fatal("the root is still reported in use after the contender released")
|
||||
}
|
||||
third.release()
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
@@ -13,19 +14,34 @@ type runtimeLeaseHandle struct {
|
||||
overlapped windows.Overlapped
|
||||
}
|
||||
|
||||
// BOTH SIDES HAVE TO MEAN THE SAME OBJECT.
|
||||
//
|
||||
// This opened the lease by full pathname with os.OpenFile and no no-follow flag,
|
||||
// while shared acquisition opened it relative to a retained parent handle. A file
|
||||
// symbolic link at <digest>.lease therefore handed the two sides different
|
||||
// objects: setup or a running command held a shared lock on the link, cleanup
|
||||
// took an exclusive lock on its target, both calls succeeded, and cleanup went on
|
||||
// to RemoveAll a root somebody was still using.
|
||||
//
|
||||
// Mutual exclusion is a property of the OBJECT, so cleanup resolves the name the
|
||||
// way acquisition does and refuses the same reparse objects.
|
||||
func tryAcquireExclusiveRuntimeLease(root string) (runtimeLeaseHandle, bool, error) {
|
||||
return tryAcquireExclusiveRuntimeLeaseRooted(root)
|
||||
func acquireSharedRuntimeLease(path string) (runtimeLeaseHandle, error) {
|
||||
file, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o600)
|
||||
if err != nil {
|
||||
return runtimeLeaseHandle{}, err
|
||||
}
|
||||
handle := runtimeLeaseHandle{file: file}
|
||||
if err := windows.LockFileEx(windows.Handle(file.Fd()), 0, 0, 1, 0, &handle.overlapped); err != nil {
|
||||
_ = file.Close()
|
||||
return runtimeLeaseHandle{}, err
|
||||
}
|
||||
return handle, nil
|
||||
}
|
||||
|
||||
func tryAcquireExclusiveRuntimeLease(path string) (runtimeLeaseHandle, bool, error) {
|
||||
file, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o600)
|
||||
if err != nil {
|
||||
return runtimeLeaseHandle{}, false, err
|
||||
}
|
||||
handle := runtimeLeaseHandle{file: file}
|
||||
flags := uint32(windows.LOCKFILE_EXCLUSIVE_LOCK | windows.LOCKFILE_FAIL_IMMEDIATELY)
|
||||
if err := windows.LockFileEx(windows.Handle(file.Fd()), flags, 0, 1, 0, &handle.overlapped); err != nil {
|
||||
_ = file.Close()
|
||||
if errors.Is(err, windows.ERROR_LOCK_VIOLATION) {
|
||||
return runtimeLeaseHandle{}, true, nil
|
||||
}
|
||||
return runtimeLeaseHandle{}, false, err
|
||||
}
|
||||
return handle, false, nil
|
||||
}
|
||||
|
||||
func (lease runtimeLeaseHandle) release() {
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// THE SHAPE HAS TO HOLD FOR BOTH SPELLINGS OF THE FIRST COMPONENT.
|
||||
//
|
||||
// The cache-derived root uses the fixed name; the temp-derived fallback scopes
|
||||
// that component to the user wherever the temp root is shared. A tail that
|
||||
// stops matching loses the rooted no-follow traversal AND the owned-component
|
||||
// guard, and neither failure is visible where it happens.
|
||||
//
|
||||
// The scoped spelling does not exist on Windows, so without the seam this
|
||||
// branch could only ever be exercised by another platform's CI.
|
||||
func TestOwnedTailAcceptsBothFirstComponentSpellings(t *testing.T) {
|
||||
previous := fallbackOwnedNamesForMatch
|
||||
t.Cleanup(func() { fallbackOwnedNamesForMatch = previous })
|
||||
fallbackOwnedNamesForMatch = func() []string {
|
||||
return []string{windowsSandboxRuntimeOwnedNames[0] + "-u1001", "runtime", "v1"}
|
||||
}
|
||||
|
||||
base := filepath.Join("C:", "shared")
|
||||
for _, testCase := range []struct {
|
||||
name string
|
||||
first string
|
||||
want bool
|
||||
}{
|
||||
{"fixed cache spelling", windowsSandboxRuntimeOwnedNames[0], true},
|
||||
{"user-scoped fallback spelling", windowsSandboxRuntimeOwnedNames[0] + "-u1001", true},
|
||||
{"a different user's scope", windowsSandboxRuntimeOwnedNames[0] + "-u2002", false},
|
||||
{"an unrelated directory", "notzero", false},
|
||||
} {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
root := filepath.Join(base, testCase.first, "runtime", "v1", "abcdef0123456789")
|
||||
if _, _, ok := windowsSandboxRuntimeOwnedTail(root); ok != testCase.want {
|
||||
t.Errorf("owned tail for %s = %v, want %v", root, ok, testCase.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// The components below the first stay fixed for both spellings, or the
|
||||
// traversal would accept a tree Zero does not own.
|
||||
wrong := filepath.Join(base, windowsSandboxRuntimeOwnedNames[0]+"-u1001", "elsewhere", "v1", "abcdef0123456789")
|
||||
if _, _, ok := windowsSandboxRuntimeOwnedTail(wrong); ok {
|
||||
t.Errorf("owned tail accepted %s, whose middle component is not one Zero owns", wrong)
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
//go:build !windows
|
||||
|
||||
package sandbox
|
||||
|
||||
// physicalSandboxPath resolves path to the spelling the filesystem itself uses.
|
||||
//
|
||||
// Off Windows that is what canonicalSandboxWorkspaceRoot already does:
|
||||
// filepath.EvalSymlinks follows every symlink, and there is no junction to
|
||||
// follow. Two aliases remain unresolved and are handled by the identity walk in
|
||||
// runtimeRootWithinWorkspace instead: a differing case on a case-insensitive
|
||||
// volume, which EvalSymlinks preserves, and a bind mount, which no userspace
|
||||
// path API resolves because the kernel deliberately presents it as a real path.
|
||||
func physicalSandboxPath(path string) string {
|
||||
return canonicalSandboxWorkspaceRoot(path)
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
//go:build windows
|
||||
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
const (
|
||||
// GetFinalPathNameByHandle flags. golang.org/x/sys/windows does not export
|
||||
// these; both are zero, and naming them keeps the call site readable.
|
||||
fileNameNormalized = 0x0
|
||||
volumeNameDOS = 0x0
|
||||
)
|
||||
|
||||
// physicalSandboxPath resolves path to the spelling the filesystem itself uses.
|
||||
//
|
||||
// canonicalSandboxWorkspaceRoot cannot do this on Windows. filepath.EvalSymlinks
|
||||
// returns a directory JUNCTION unchanged, so a TEMP that reaches into the
|
||||
// workspace through one still measures as outside it, and that is how a runtime
|
||||
// tree ends up inside the very workspace the sandbox exists to confine.
|
||||
// GetFinalPathNameByHandle answers with the target's real path, junctions and
|
||||
// mount points followed and casing as stored on disk.
|
||||
//
|
||||
// This deliberately opens WITHOUT FILE_FLAG_OPEN_REPARSE_POINT, the opposite of
|
||||
// openWindowsACLTarget. That helper must refuse to follow a reparse point,
|
||||
// because following one is the path-swap it guards against. Here the whole
|
||||
// question is where the reparse point leads, and the answer is only ever used to
|
||||
// decide that a runtime root is contained, never that it is safe.
|
||||
func physicalSandboxPath(path string) string {
|
||||
cleaned := canonicalSandboxWorkspaceRoot(path)
|
||||
if cleaned == "" || cleaned == "." {
|
||||
return cleaned
|
||||
}
|
||||
// The runtime root does not exist yet at derivation time, which is the point.
|
||||
// Resolve the deepest ancestor that does exist and re-append the rest, the
|
||||
// same shape canonicalSandboxWorkspaceRoot uses for EvalSymlinks.
|
||||
remainder := ""
|
||||
current := cleaned
|
||||
for {
|
||||
if resolved, ok := finalWindowsPathName(current); ok {
|
||||
if remainder == "" {
|
||||
return resolved
|
||||
}
|
||||
return filepath.Join(resolved, remainder)
|
||||
}
|
||||
parent := filepath.Dir(current)
|
||||
if parent == current {
|
||||
// Nothing along the path could be opened. The cleaned form is the best
|
||||
// answer available, and the caller's spelling comparison already ran.
|
||||
return cleaned
|
||||
}
|
||||
remainder = filepath.Join(filepath.Base(current), remainder)
|
||||
current = parent
|
||||
}
|
||||
}
|
||||
|
||||
func finalWindowsPathName(path string) (string, bool) {
|
||||
utf16Path, err := windows.UTF16PtrFromString(path)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
// Zero desired access is enough: GetFinalPathNameByHandle reads metadata, so
|
||||
// this cannot be refused for lack of read rights on the directory contents.
|
||||
handle, err := windows.CreateFile(
|
||||
utf16Path,
|
||||
0,
|
||||
windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE,
|
||||
nil,
|
||||
windows.OPEN_EXISTING,
|
||||
windows.FILE_FLAG_BACKUP_SEMANTICS,
|
||||
0,
|
||||
)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
defer func() { _ = windows.CloseHandle(handle) }()
|
||||
|
||||
buffer := make([]uint16, windows.MAX_PATH)
|
||||
for attempt := 0; attempt < 2; attempt++ {
|
||||
n, err := windows.GetFinalPathNameByHandle(handle, &buffer[0], uint32(len(buffer)), fileNameNormalized|volumeNameDOS)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
if int(n) >= len(buffer) {
|
||||
// TWO DIFFERENT CONVENTIONS, and the boundary is where they meet. On
|
||||
// success the return value EXCLUDES the terminating null; on an
|
||||
// insufficient buffer it INCLUDES it. So n == len(buffer) cannot be
|
||||
// read as a complete path: a success that large would not have fitted
|
||||
// its own terminator, which means the only reading left is a required
|
||||
// size. Retrying on >= costs one extra call in a case that may not be
|
||||
// reachable and removes the need to be right about which it was.
|
||||
buffer = make([]uint16, int(n)+1)
|
||||
continue
|
||||
}
|
||||
if n == 0 {
|
||||
return "", false
|
||||
}
|
||||
return trimWindowsExtendedPrefix(windows.UTF16ToString(buffer[:n])), true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// trimWindowsExtendedPrefix converts the \\?\ form GetFinalPathNameByHandle
|
||||
// returns into an ordinary path, so it compares against paths the rest of this
|
||||
// package builds with filepath.Join. \\?\UNC\server\share becomes
|
||||
// \\server\share; anything else loses the \\?\ and keeps its drive letter.
|
||||
func trimWindowsExtendedPrefix(path string) string {
|
||||
if rest, ok := strings.CutPrefix(path, `\\?\UNC\`); ok {
|
||||
return `\\` + rest
|
||||
}
|
||||
return strings.TrimPrefix(path, `\\?\`)
|
||||
}
|
||||
@@ -1,208 +0,0 @@
|
||||
//go:build windows
|
||||
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// THE CREATION ITSELF HAS TO ESTABLISH THE IDENTITY.
|
||||
//
|
||||
// Win32 CreateFile cannot create a directory whatever disposition it is given,
|
||||
// so the CREATE_NEW attempt fell through to os.Mkdir plus a separate reopen on
|
||||
// EVERY real creation and the documented creation-handle contract never once
|
||||
// held. The runtime parent belongs to the ordinary user, who can rename the new
|
||||
// directory A away and drop an ordinary directory B at the predictable name in
|
||||
// between, and the ledger then records B for a directory this run never made.
|
||||
//
|
||||
// Driving the interleaving is not the point, and a barrier there would only
|
||||
// prove the window was still measurable. The property is that no reopen exists
|
||||
// to be raced: whatever the name resolves to afterwards, the recorded identity
|
||||
// is the object the create returned.
|
||||
func TestCreatedRuntimeDirIdentityComesFromTheCreation(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
path := filepath.Join(root, "created")
|
||||
|
||||
// THE DETERMINISTIC BARRIER. The old shape resolved the pathname a second
|
||||
// time after os.Mkdir, and that reopen is the whole window. Counting the
|
||||
// resolutions turns "the creation establishes the identity" into something
|
||||
// checked: on Windows the create returns the handle, so this must be zero.
|
||||
resolutions := 0
|
||||
restore := runtimeIdentityAfterCreate
|
||||
runtimeIdentityAfterCreate = func(p string) (string, bool) {
|
||||
resolutions++
|
||||
return restore(p)
|
||||
}
|
||||
t.Cleanup(func() { runtimeIdentityAfterCreate = restore })
|
||||
|
||||
identity, identified, err := createRuntimeDirIdentified(path)
|
||||
if err != nil || !identified {
|
||||
t.Fatalf("create: identity=%q identified=%v err=%v", identity, identified, err)
|
||||
}
|
||||
if resolutions != 0 {
|
||||
t.Errorf("the creation resolved the pathname again %d time(s); identity must come from the creation handle", resolutions)
|
||||
}
|
||||
info, statErr := os.Stat(path)
|
||||
if statErr != nil || !info.IsDir() {
|
||||
t.Fatalf("no directory was created: err=%v", statErr)
|
||||
}
|
||||
|
||||
// Substitute the whole directory the way the parent's owner could, then ask
|
||||
// what the name says now. The record must still describe what was created.
|
||||
aside := filepath.Join(root, "moved-aside")
|
||||
if err := os.Rename(path, aside); err != nil {
|
||||
t.Skipf("cannot rename the runtime directory on this filesystem: %v", err)
|
||||
}
|
||||
if err := os.Mkdir(path, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
substitute, ok := runtimeDirIdentity(path)
|
||||
if !ok {
|
||||
t.Fatal("the substitute could not be identified, so the comparison proves nothing")
|
||||
}
|
||||
if identity == substitute {
|
||||
t.Fatal("SETUP INVALID: the substitute has the same identity as the created directory")
|
||||
}
|
||||
moved, ok := runtimeDirIdentity(aside)
|
||||
if !ok {
|
||||
t.Fatal("the created directory could not be identified after the rename")
|
||||
}
|
||||
if identity != moved {
|
||||
t.Errorf("the recorded identity %q describes neither the created directory (%q) nor anything this run owns", identity, moved)
|
||||
}
|
||||
}
|
||||
|
||||
// A component another process created first must stay "not ours", and it must
|
||||
// keep saying so through os.IsExist, which is what the caller asks and which
|
||||
// does not unwrap a %w chain.
|
||||
func TestCreatedRuntimeDirRefusesAnExistingName(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "created")
|
||||
if _, _, err := createRuntimeDirIdentified(path); err != nil {
|
||||
t.Fatalf("first create: %v", err)
|
||||
}
|
||||
identity, identified, err := createRuntimeDirIdentified(path)
|
||||
if !os.IsExist(err) {
|
||||
t.Errorf("creating over an existing directory returned %v, want an IsExist error", err)
|
||||
}
|
||||
if identified || identity != "" {
|
||||
t.Errorf("a refused create still produced an ownership record: identity=%q identified=%v", identity, identified)
|
||||
}
|
||||
}
|
||||
|
||||
// THE THREE STATES ARE DIFFERENT FACTS.
|
||||
//
|
||||
// "Read it and there was nothing" and "could not read it" both used to arrive as
|
||||
// existed=false. The stamp writer uses FILE_OVERWRITE_IF and can replace an
|
||||
// existing stamp even where the read was denied, so that lie let a setup which
|
||||
// then FAILED delete an attestation it had no record of, leaving the previous
|
||||
// run's marker pointing at a runtime root it can no longer prove.
|
||||
func TestRuntimeStampSnapshotSeparatesAbsentPresentAndUnknown(t *testing.T) {
|
||||
t.Run("absent", func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
_, _, prior, state, err := snapshotRuntimeStampBound(root, windowsSandboxRuntimeStampName(testStampPlanHash))
|
||||
if err != nil {
|
||||
t.Fatalf("a readable root with no stamp is not an error: %v", err)
|
||||
}
|
||||
if state != runtimeStampAbsent {
|
||||
t.Errorf("state = %v, want absent", state)
|
||||
}
|
||||
if prior != nil {
|
||||
t.Errorf("absent produced prior bytes %q", prior)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("present", func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
want := []byte("prior-attestation")
|
||||
if err := os.WriteFile(filepath.Join(root, windowsSandboxRuntimeStampName(testStampPlanHash)), want, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, _, prior, state, err := snapshotRuntimeStampBound(root, windowsSandboxRuntimeStampName(testStampPlanHash))
|
||||
if err != nil {
|
||||
t.Fatalf("snapshot: %v", err)
|
||||
}
|
||||
if state != runtimeStampPresent {
|
||||
t.Errorf("state = %v, want present", state)
|
||||
}
|
||||
if string(prior) != string(want) {
|
||||
t.Errorf("prior = %q, want %q", prior, want)
|
||||
}
|
||||
})
|
||||
|
||||
// A stamp NAME that cannot be read as a file. The child open is refused for a
|
||||
// reason that is emphatically not "not found", which is the whole distinction.
|
||||
t.Run("unknown", func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
if err := os.Mkdir(filepath.Join(root, windowsSandboxRuntimeStampName(testStampPlanHash)), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, _, prior, state, err := snapshotRuntimeStampBound(root, windowsSandboxRuntimeStampName(testStampPlanHash))
|
||||
if err == nil {
|
||||
t.Fatal("an unreadable stamp was reported as a successful snapshot")
|
||||
}
|
||||
if state != runtimeStampUnknown {
|
||||
t.Errorf("state = %v, want unknown", state)
|
||||
}
|
||||
if prior != nil {
|
||||
t.Errorf("unknown produced prior bytes %q", prior)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// AND UNKNOWN MUST NOT AUTHORIZE COMPENSATION.
|
||||
//
|
||||
// The forward mutation is refused before it begins, so this asserts the second
|
||||
// half: a record that somehow reached compensation with an unproven prior state
|
||||
// leaves the current stamp alone and says so, rather than deleting it and
|
||||
// returning with nothing to put back.
|
||||
func TestUnknownPriorStampIsNeverCompensated(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
stampPath := filepath.Join(root, windowsSandboxRuntimeStampName(testStampPlanHash))
|
||||
current := []byte("the-attestation-of-the-previous-successful-setup")
|
||||
if err := os.WriteFile(stampPath, current, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
identity, identified := runtimeDirIdentity(root)
|
||||
if !identified {
|
||||
t.Fatal("SETUP INVALID: the runtime root could not be identified")
|
||||
}
|
||||
|
||||
snapshot := windowsSandboxStampSnapshot{
|
||||
path: stampPath,
|
||||
priorState: runtimeStampUnknown,
|
||||
root: root,
|
||||
rootIdentity: identity,
|
||||
rootIdentified: true,
|
||||
}
|
||||
err := snapshot.restore()
|
||||
if err == nil {
|
||||
t.Fatal("compensation acted on a prior state it never established, and reported success")
|
||||
}
|
||||
|
||||
after, readErr := os.ReadFile(stampPath)
|
||||
if readErr != nil {
|
||||
t.Fatalf("the existing stamp was destroyed by a rollback that had nothing to restore: %v", readErr)
|
||||
}
|
||||
if string(after) != string(current) {
|
||||
t.Errorf("the existing stamp was rewritten: got %q, want %q", after, current)
|
||||
}
|
||||
}
|
||||
|
||||
// And setup refuses BEFORE the ACL and stamp are applied, which is the half that
|
||||
// keeps the writer from running at all.
|
||||
func TestSetupRefusesAnUnreadablePriorStamp(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
if err := os.Mkdir(filepath.Join(root, windowsSandboxRuntimeStampName(testStampPlanHash)), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
snapshot, err := snapshotWindowsSandboxRuntimeStamp(root, testStampPlanHash)
|
||||
if err == nil {
|
||||
t.Fatal("setup accepted a snapshot that could not read the prior stamp")
|
||||
}
|
||||
if snapshot.priorState != runtimeStampUnknown {
|
||||
t.Errorf("the refused snapshot carried state %v, want unknown", snapshot.priorState)
|
||||
}
|
||||
}
|
||||
@@ -1,134 +0,0 @@
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A RECORDED ROOT IS HISTORY. IT MUST NOT BE RE-DERIVED FROM TODAY'S TEMP.
|
||||
//
|
||||
// Setup reaches the temp fallback when the preferred cache-derived root cannot
|
||||
// be leased, and it records that concrete root in the marker. Command selection
|
||||
// then recognised the record only by deriving today's fallback from this
|
||||
// process's TEMP and comparing. A later IDE, service or terminal running with a
|
||||
// different TEMP therefore matched neither of today's candidates: the record was
|
||||
// ignored, selection produced a tree setup never provisioned, and the runner
|
||||
// rejected the marker as out of date. Re-running setup from the original
|
||||
// environment repeats the original answer and does not make the other
|
||||
// environment converge.
|
||||
//
|
||||
// The record is now recognised by its own shape instead, which is the question
|
||||
// that was actually being asked: does this belong to this workspace and home.
|
||||
//
|
||||
// Not gated on Windows. The marker only exists where setup wrote one, so this is
|
||||
// Windows-only in practice, but keeping it platform-neutral means the
|
||||
// setup-to-command contract runs on every CI runner rather than only one.
|
||||
func TestARecordedFallbackSurvivesATempChange(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
workspace := canonicalSandboxWorkspaceRoot(t.TempDir())
|
||||
// The cache is an input of the preferred candidate, so it is owned as well.
|
||||
// Left alone it made the result depend on the machine, and gave this test two
|
||||
// more ways to skip instead of asserting.
|
||||
cacheRoot := t.TempDir()
|
||||
originalCacheDir := sandboxUserCacheDir
|
||||
sandboxUserCacheDir = func() (string, error) { return cacheRoot, nil }
|
||||
t.Cleanup(func() { sandboxUserCacheDir = originalCacheDir })
|
||||
|
||||
// EVERY temp variable, in both phases. With only TMP and TEMP set this
|
||||
// changed nothing on Unix: the fallback derived from the ambient TMPDIR, the
|
||||
// MkdirAll below created a workspace-digest tree outside anything this test
|
||||
// owns, the second derivation came back identical, and the test skipped
|
||||
// having asserted nothing and left the directory behind.
|
||||
tempA := t.TempDir()
|
||||
redirectSandboxTestTemp(t, tempA)
|
||||
recorded, err := fallbackSandboxRuntimeRoot(workspace)
|
||||
if err != nil {
|
||||
t.Fatalf("SETUP INVALID: no fallback runtime root under a temp this test owns: %v", err)
|
||||
}
|
||||
// Containment BEFORE the tree exists, so cleanup belongs to tempA and happens
|
||||
// even when an assertion below fails.
|
||||
requireWithinTestOwned(t, recorded, tempA)
|
||||
if err := os.MkdirAll(recorded, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
writeRecordedRoot(t, home, recorded)
|
||||
|
||||
// SETUP: with the original TEMP the record is honoured, so a failure after the
|
||||
// flip is about the flip and not about the record being unusable.
|
||||
preferred := preferredRuntimeRootFor(t, workspace)
|
||||
if got := pinnedSandboxRuntimeRoot(workspace, preferred, recorded, home); got != recorded {
|
||||
t.Fatalf("SETUP INVALID: the record is not honoured even before the temp change: got %q, want %q", got, recorded)
|
||||
}
|
||||
|
||||
// The same command, run later from an environment with a different TEMP.
|
||||
tempB := t.TempDir()
|
||||
redirectSandboxTestTemp(t, tempB)
|
||||
derivedNow, err := fallbackSandboxRuntimeRoot(workspace)
|
||||
if err != nil {
|
||||
t.Fatalf("SETUP INVALID: no fallback runtime root under the second temp: %v", err)
|
||||
}
|
||||
requireWithinTestOwned(t, derivedNow, tempB)
|
||||
// A FAILURE, NOT A SKIP. The redirect owns every input the derivation reads,
|
||||
// so an unmoved fallback means the premise of this test is broken, and a skip
|
||||
// here is how it passed on Unix without ever reaching the assertion below.
|
||||
if sameWindowsRuntimeRootPath(derivedNow, recorded) {
|
||||
t.Fatalf("SETUP INVALID: the temp change did not move the derived fallback (%s), so the assertion below would prove nothing", derivedNow)
|
||||
}
|
||||
|
||||
if got := pinnedSandboxRuntimeRoot(workspace, preferred, derivedNow, home); got != recorded {
|
||||
t.Fatalf("selection abandoned the root setup provisioned after only TEMP changed: got %q, want the recorded %q", got, recorded)
|
||||
}
|
||||
}
|
||||
|
||||
// preferredRuntimeRootFor is the cache-derived candidate, which must stay a
|
||||
// candidate: this change is about the fallback only.
|
||||
func preferredRuntimeRootFor(t *testing.T, workspace string) string {
|
||||
t.Helper()
|
||||
cacheRoot, err := sandboxUserCacheDir()
|
||||
if err != nil {
|
||||
t.Fatalf("SETUP INVALID: no user cache directory: %v", err)
|
||||
}
|
||||
root, err := sandboxRuntimeRootFor(workspace, canonicalSandboxWorkspaceRoot(cacheRoot))
|
||||
if err != nil {
|
||||
t.Fatalf("SETUP INVALID: no preferred runtime root under a cache this test owns: %v", err)
|
||||
}
|
||||
return root
|
||||
}
|
||||
|
||||
// AND THE PROTECTION THAT MADE THE OLD COMPARISON WORTH HAVING STAYS.
|
||||
//
|
||||
// Recognising the record by shape must not turn into recognising anybody's
|
||||
// record. A fallback root belonging to a DIFFERENT workspace has the same owned
|
||||
// shape and a different digest, so it is still refused.
|
||||
func TestARecordedFallbackForAnotherWorkspaceIsStillRefused(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
mine := canonicalSandboxWorkspaceRoot(t.TempDir())
|
||||
theirs := canonicalSandboxWorkspaceRoot(t.TempDir())
|
||||
|
||||
temp := t.TempDir()
|
||||
redirectSandboxTestTemp(t, temp)
|
||||
|
||||
theirRoot, err := fallbackSandboxRuntimeRoot(theirs)
|
||||
if err != nil {
|
||||
t.Fatalf("SETUP INVALID: no fallback runtime root under a temp this test owns: %v", err)
|
||||
}
|
||||
requireWithinTestOwned(t, theirRoot, temp)
|
||||
writeRecordedRoot(t, home, theirRoot)
|
||||
|
||||
if got := pinnedSandboxRuntimeRoot(mine, filepath.Join(t.TempDir(), "preferred"), filepath.Join(t.TempDir(), "fallback"), home); got != "" {
|
||||
t.Fatalf("pinned %q, which was provisioned for another workspace", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A record with no owned runtime shape at all is refused too, so the shape test
|
||||
// is doing work rather than waving anything through.
|
||||
func TestARecordedRootWithoutTheOwnedShapeIsRefused(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
workspace := canonicalSandboxWorkspaceRoot(t.TempDir())
|
||||
writeRecordedRoot(t, home, filepath.Join(t.TempDir(), "not-a-runtime-root"))
|
||||
|
||||
if got := pinnedSandboxRuntimeRoot(workspace, filepath.Join(t.TempDir(), "preferred"), filepath.Join(t.TempDir(), "fallback"), home); got != "" {
|
||||
t.Fatalf("pinned %q, which has none of the owned runtime shape", got)
|
||||
}
|
||||
}
|
||||
Loaded 100 of 194 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user