返回 DeepSeek-Reasonix
classify_profile.go
根目录 / internal / evidence / classify_profile.go
1 package evidence
2
3 import (
4 "encoding/json"
5 "strings"
6
7 "reasonix/internal/shellsafe"
8 )
9
10 // ClassifyEffect returns the concrete effect profile for one invocation.
11 func ClassifyEffect(in EffectInput) EffectProfile {
12 name := strings.ToLower(strings.TrimSpace(in.ToolName))
13 args := json.RawMessage(in.Args)
14 if isShellToolName(name) {
15 return classifyBashEffect(args, in)
16 }
17 if in.Hint.Present && in.Hint.ReadOnly {
18 return readOnlyProfile(targetsFrom(in, nil), ReasonHintReadOnly)
19 }
20 if in.StaticReadOnly || IsNonMutationMetaTool(in.ToolName) {
21 return readOnlyProfile(targetsFrom(in, nil), ReasonReadOnly)
22 }
23 switch name {
24 case "ask", "todo_write", "complete_step", "job_output", "bash_output", "wait":
25 return readOnlyProfile(nil, ReasonReadOnly)
26 case "remember", "forget", "set_session_title", "job_kill", "kill_shell":
27 return EffectProfile{Known: true, HostState: true, Reason: ReasonHostState}
28 }
29 profile := writerProfile(in)
30 if in.Hint.Present {
31 applyCallHint(&profile, in.Hint)
32 }
33 return profile
34 }
35
36 func isShellToolName(name string) bool {
37 switch strings.ToLower(strings.TrimSpace(name)) {
38 case "bash", "pwsh", "powershell", "shell":
39 return true
40 default:
41 return false
42 }
43 }
44
45 func classifyBashEffect(args json.RawMessage, in EffectInput) EffectProfile {
46 var fields map[string]json.RawMessage
47 if err := json.Unmarshal(args, &fields); err != nil {
48 return opaqueProfile(in, ReasonUnknown)
49 }
50 command := stringField(fields, "command")
51 effect := shellsafe.ClassifyBash(command)
52 if effect.Certainty != shellsafe.EffectKnown {
53 if bashCommandIsVerification(command) {
54 return readOnlyProfile(nil, ReasonReadOnly)
55 }
56 // Unproven bash is a pathless workspace write. It is not an MCP opaque
57 // writer: permission and the shell contract still own the invocation.
58 return EffectProfile{
59 WorkspaceWrite: true,
60 ExecutesCode: effect.ExecutesCode,
61 UsesNetwork: effect.UsesNetwork,
62 Reason: EffectReason(effect.Reason),
63 }
64 }
65 profile := EffectProfile{
66 Known: true,
67 ReadOnly: effect.Writes == 0,
68 WorkspaceWrite: effect.Writes&shellsafe.WriteWorkspaceContent != 0,
69 RepoMetadata: effect.Writes&shellsafe.WriteRepositoryMetadata != 0,
70 HostState: effect.Writes&shellsafe.WriteHostState != 0,
71 ExternalState: effect.Writes&shellsafe.WriteExternalState != 0,
72 ExecutesCode: effect.ExecutesCode,
73 UsesNetwork: effect.UsesNetwork,
74 Reason: EffectReason(commandEffectReason(effect)),
75 }
76 if profile.ReadOnly {
77 profile.Reason = ReasonReadOnly
78 } else if profile.ExternalState {
79 profile.Reason = ReasonExternalState
80 } else if profile.HostState {
81 profile.Reason = ReasonHostState
82 } else if profile.WorkspaceWrite {
83 profile.Reason = ReasonWorkspaceWrite
84 } else if profile.RepoMetadata {
85 profile.Reason = ReasonRepoMetadata
86 }
87 applyBashShape(&profile, effect.CommandFamily, command)
88 profile.Targets = bashTargets(profile)
89 return profile
90 }
91
92 func applyBashShape(profile *EffectProfile, family, command string) {
93 family = strings.ToLower(strings.TrimSpace(family))
94 lower := strings.ToLower(command)
95 switch {
96 case family == "git push" || strings.HasPrefix(family, "git push"):
97 profile.ExternalState = true
98 profile.UsesNetwork = true
99 if containsForceFlag(lower) {
100 profile.Destructive = true
101 profile.Irreversible = true
102 }
103 case family == "git clean" && profile.WorkspaceWrite:
104 profile.Destructive = true
105 case strings.Contains(family, "publish") || strings.Contains(family, "deploy"):
106 profile.ExternalState = true
107 profile.UsesNetwork = true
108 }
109 if strings.Contains(lower, "rm -rf") || strings.Contains(lower, "rm -fr") {
110 profile.Destructive = true
111 profile.Irreversible = true
112 }
113 }
114
115 func containsForceFlag(command string) bool {
116 for field := range strings.FieldsSeq(command) {
117 switch field {
118 case "-f", "--force", "--force-with-lease":
119 return true
120 }
121 }
122 return false
123 }
124
125 func bashTargets(p EffectProfile) []Target {
126 switch {
127 case p.ExternalState:
128 return []Target{{Kind: TargetExternal}}
129 case p.HostState:
130 return []Target{{Kind: TargetHost}}
131 case p.RepoMetadata && !p.WorkspaceWrite:
132 return []Target{{Kind: TargetRepo}}
133 default:
134 return nil
135 }
136 }
137
138 func writerProfile(in EffectInput) EffectProfile {
139 paths := declaredPaths(in)
140 if !in.Hint.Present && !in.Hint.Known && len(paths) == 0 && looksOpaqueName(in.ToolName) {
141 return opaqueProfile(in, ReasonOpaqueWriter)
142 }
143 profile := EffectProfile{
144 Known: true,
145 WorkspaceWrite: true,
146 Reason: ReasonWorkspaceWrite,
147 Targets: fileTargets(paths),
148 }
149 if in.Hint.Destructive || isDestructiveTool(in.ToolName) {
150 profile.Destructive = true
151 profile.Reason = ReasonDestructive
152 }
153 return profile
154 }
155
156 func applyCallHint(profile *EffectProfile, hint CallHint) {
157 profile.Destructive = profile.Destructive || hint.Destructive
158 profile.Privileged = profile.Privileged || hint.Privileged
159 profile.UsesNetwork = profile.UsesNetwork || hint.UsesNetwork
160 profile.ExecutesCode = profile.ExecutesCode || hint.ExecutesCode
161 if hint.Destructive {
162 profile.Reason = ReasonHintDestructive
163 }
164 if len(hint.Targets) > 0 && len(profile.Targets) == 0 {
165 profile.Targets = fileTargets(hint.Targets)
166 }
167 }
168
169 func readOnlyProfile(targets []Target, reason EffectReason) EffectProfile {
170 return EffectProfile{Known: true, ReadOnly: true, Reason: reason, Targets: append([]Target(nil), targets...)}
171 }
172
173 func opaqueProfile(in EffectInput, reason EffectReason) EffectProfile {
174 if reason == "" {
175 reason = ReasonOpaqueWriter
176 }
177 return EffectProfile{
178 WorkspaceWrite: true,
179 Destructive: in.Hint.Destructive,
180 Privileged: in.Hint.Privileged || looksPrivilegedName(in.ToolName),
181 UsesNetwork: in.Hint.UsesNetwork,
182 ExecutesCode: in.Hint.ExecutesCode,
183 Targets: fileTargets(declaredPaths(in)),
184 Reason: reason,
185 }
186 }
187
188 func declaredPaths(in EffectInput) []string {
189 var paths []string
190 if len(in.ActualPaths) > 0 {
191 paths = append(paths, in.ActualPaths...)
192 } else {
193 paths = append(paths, ToolCallPaths(in.Args)...)
194 }
195 if in.Hint.Present {
196 paths = append(paths, in.Hint.Targets...)
197 }
198 return uniquePaths(paths)
199 }
200
201 func targetsFrom(in EffectInput, extra []string) []Target {
202 paths := append(declaredPaths(in), extra...)
203 return fileTargets(uniquePaths(paths))
204 }
205
206 func fileTargets(paths []string) []Target {
207 if len(paths) == 0 {
208 return nil
209 }
210 out := make([]Target, 0, len(paths))
211 for _, p := range paths {
212 p = strings.TrimSpace(p)
213 if p == "" {
214 continue
215 }
216 kind := TargetFile
217 if strings.HasSuffix(p, "/") {
218 kind = TargetDirectory
219 }
220 out = append(out, Target{Path: p, Kind: kind})
221 }
222 return out
223 }
224
225 func uniquePaths(paths []string) []string {
226 seen := make(map[string]bool, len(paths))
227 var out []string
228 for _, p := range paths {
229 p = strings.TrimSpace(p)
230 if p == "" || seen[p] {
231 continue
232 }
233 seen[p] = true
234 out = append(out, p)
235 }
236 return out
237 }
238
239 func looksOpaqueName(name string) bool {
240 lower := strings.ToLower(strings.TrimSpace(name))
241 return strings.HasPrefix(lower, "mcp__") || strings.HasPrefix(lower, "mcp-tool:")
242 }
243
244 func looksPrivilegedName(name string) bool {
245 lower := strings.ToLower(strings.TrimSpace(name))
246 for _, hint := range []string{"mcp__", "install_source", "install_skill", "plugin"} {
247 if strings.Contains(lower, hint) {
248 return true
249 }
250 }
251 return false
252 }
253
254 func isDestructiveTool(name string) bool {
255 switch strings.ToLower(strings.TrimSpace(name)) {
256 case "delete_file", "delete_symbol", "remove_file":
257 return true
258 default:
259 return false
260 }
261 }
262
262 lines GO