mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
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.
154 lines
4.4 KiB
Go
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"
|
|
}
|