Files
SivanCola 72b725862f fix(agent): preserve workspace invalidation for hook effects
Problem: A hook can change files before refusing a tool or outside a reader's
declared paths without notifying an opted-in workspace sink. Nested writers
also omit publication until an outer writer happens to complete.

Root cause: Invalidation is gated by concrete tool execution and classification,
and publication belongs to the batch caller rather than the invocation owner.

Fix: Track possible tool-hook effects separately from Executed, publish through
the shared invocation exit after cleanup, and share result metadata projection
between batch and nested calls. Unknown hook scope invalidates content, tree and
Git metadata without claiming a confirmed write or widening authorization.

Verification: Full root 238-package inventory passed; complete agent/event race,
eight boot direct/capability hook cases under race, original-owner compiling
negative controls, and provisional-lease composition passed. Formatting, vet,
full lint and make check passed without changing the baseline. Local evidence
is macOS only; optional sink wiring and evidence accounting remain separate.
2026-10-25 07:29:49 +08:00

154 lines
4.4 KiB
Go

package agent
import (
"encoding/json"
"path/filepath"
"strings"
"sync"
"reasonix/internal/base/shellparse"
"reasonix/internal/contract/event"
"reasonix/internal/safety/evidence"
)
type workspaceEffectiveCall struct {
name string
args json.RawMessage
readOnly bool
}
var workspaceMutationSignalMu sync.Mutex
func finalizeWorkspaceMutationOutcome(out *toolOutcome, plan *toolCallPlan) {
out.executed = plan.executed
if plan.evidenceName != "" {
out.effective = workspaceEffectiveCall{
name: plan.evidenceName, args: append([]byte(nil), plan.evidenceArgs...), readOnly: plan.readOnly,
}
}
if plan.hookMayHaveMutated {
out.workspaceMutation = &event.WorkspaceMutation{
ToolID: plan.call.ID, ToolName: plan.evidenceName,
AllPaths: true, Content: true, Tree: true, WorkingTree: true, GitMeta: true,
}
return
}
if !plan.executed || isMCPLifecycleConnectTarget(plan.runTool) {
return
}
if mutation, ok := workspaceMutationForCall(plan.call.ID, plan.evidenceName, plan.evidenceArgs, plan.readOnly); ok {
out.workspaceMutation = &mutation
}
}
func attachWorkspaceMutation(result *event.Tool, mutation *event.WorkspaceMutation) {
if mutation == nil {
return
}
result.WorkspaceMutation = true
result.WorkspacePaths = append([]string(nil), mutation.Paths...)
result.WorkspaceAllPaths = mutation.AllPaths
}
// tool.before can turn nominally read-only parallel calls into writers. Keep
// the optional sink callback serial while publishing from each worker as soon
// as that concrete replacement completes.
func recordWorkspaceMutation(sink event.Sink, mutation *event.WorkspaceMutation) {
if mutation == nil {
return
}
workspaceMutationSignalMu.Lock()
defer workspaceMutationSignalMu.Unlock()
event.RecordWorkspaceMutation(sink, *mutation)
}
// workspaceMutationForCall classifies host resource invalidation independently
// from the delivery evidence ledger. Delivery asks whether a call invalidates a
// completed-review receipt; the desktop asks which workspace resources may have
// changed. Those contracts intentionally differ for operations such as a bare
// git commit, which changes HEAD/index/history without changing file contents.
func workspaceMutationForCall(toolID, toolName string, args json.RawMessage, readOnly bool) (event.WorkspaceMutation, bool) {
toolName = strings.TrimSpace(toolName)
if toolName == "" || readOnly || evidence.IsNonMutationMetaTool(toolName) || workspaceHostStateOnlyTool(toolName) {
return event.WorkspaceMutation{}, false
}
paths := evidence.ToolCallPaths(args)
mutation := event.WorkspaceMutation{
ToolID: toolID,
ToolName: toolName,
Paths: paths,
AllPaths: toolName == "bash" || len(paths) == 0,
Content: true,
Tree: true,
WorkingTree: true,
}
if toolName == "bash" {
mutation.GitMeta = bashMayChangeGitMetadata(bashCommandFromArgs(args))
}
return mutation, true
}
func workspaceHostStateOnlyTool(toolName string) bool {
if strings.HasPrefix(toolName, "mcp_connect__") {
return true
}
switch toolName {
case "kill_shell", "remember", "forget":
return true
default:
return false
}
}
func bashMayChangeGitMetadata(command string) bool {
segments, _, ok := shellparse.SplitTopLevel(command)
if !ok {
return commandMentionsGitExecutable(command)
}
for _, segment := range segments {
fields, malformed := shellparse.StaticFields(strings.TrimSpace(segment))
if malformed != "" {
if commandMentionsGitExecutable(segment) {
return true
}
continue
}
fields = stripWorkspaceEnvPrefix(fields)
if len(fields) == 0 || !isGitExecutable(fields[0]) {
continue
}
// A non-read-only bash call that reaches an explicit git executable is
// conservatively Git metadata changing. Host read-only classification
// has already removed status/diff/log-style calls before this point.
return true
}
return false
}
func stripWorkspaceEnvPrefix(fields []string) []string {
for len(fields) > 0 {
if fields[0] == "env" || (strings.Contains(fields[0], "=") && !strings.HasPrefix(fields[0], "-")) {
fields = fields[1:]
continue
}
break
}
return fields
}
func commandMentionsGitExecutable(command string) bool {
for field := range strings.FieldsSeq(command) {
field = strings.Trim(field, "'\"(){}[];|&")
if isGitExecutable(field) {
return true
}
}
return false
}
func isGitExecutable(field string) bool {
base := strings.ToLower(filepath.Base(strings.TrimSpace(field)))
return base == "git" || base == "git.exe"
}