返回 DeepSeek-Reasonix
constraints.go
根目录 / internal / runtimepolicy / constraints.go
1 package runtimepolicy
2
3 import (
4 "path/filepath"
5 "regexp"
6 "strings"
7
8 "reasonix/internal/shellparse"
9 )
10
11 // Constraints are explicit user or host limits. They never encode task
12 // complexity, security keywords, or file counts.
13 type Constraints struct {
14 ForbidMutation bool
15 ForbidTests bool
16 AllowedChecks []string
17 ForbidExternal bool
18 // AllowRebuild records that the user explicitly asked to rewrite a file
19 // completely. It only ever waives the read-before-overwrite requirement for
20 // a file the same instruction names; the model can never set it.
21 AllowRebuild bool
22 // RebuildPaths are the resolved files an AllowRebuild instruction named.
23 // The waiver is a membership test over this host-recorded set, never a
24 // re-parse of instruction text at write time.
25 RebuildPaths []string
26 PlanModeReadOnly bool
27 Notes []string
28 }
29
30 // ParseConstraints accepts only explicit forbid/limit phrasing.
31 func ParseConstraints(instruction string) Constraints {
32 var c Constraints
33 lower := strings.ToLower(instruction)
34 if hasGlobalMutationBan(lower) {
35 c.ForbidMutation = true
36 c.Notes = append(c.Notes, "user_forbid_mutation")
37 }
38 if matchesAny(lower, []string{
39 "不要测试", "别跑测试", "不用测试", "跳过测试", "不要跑测试",
40 "don't run tests", "do not run tests", "no tests", "skip tests",
41 "without tests", "don't test", "do not test",
42 }) {
43 c.ForbidTests = true
44 c.Notes = append(c.Notes, "user_forbid_tests")
45 }
46 if matchesAny(lower, []string{
47 "完全重写", "从头重写", "整个重写", "直接重写", "覆盖重写", "整个文件重写",
48 "from scratch", "rewrite it completely", "rewrite the file completely",
49 "overwrite it completely", "replace it entirely", "rebuild the file",
50 "rewrite this file", "rewrite the whole file",
51 }) {
52 c.AllowRebuild = true
53 c.Notes = append(c.Notes, "user_allow_rebuild")
54 }
55 if cmds := parseAllowedChecks(instruction); len(cmds) > 0 {
56 c.AllowedChecks = cmds
57 c.Notes = append(c.Notes, "user_allowed_checks")
58 }
59 if matchesAny(lower, []string{
60 "不要 push", "不要push", "别 push", "别push", "不要推送", "不要发布",
61 "don't push", "do not push", "no push", "don't publish", "do not publish",
62 "no publish", "don't deploy", "do not deploy",
63 }) {
64 c.ForbidExternal = true
65 c.Notes = append(c.Notes, "user_forbid_external")
66 }
67 return c
68 }
69
70 // hasGlobalMutationBan distinguishes a turn-wide read-only instruction from a
71 // scoped protection such as "do not change any config". The latter still lets
72 // the requested output or an unrelated implementation target be written.
73 func hasGlobalMutationBan(instruction string) bool {
74 for _, clause := range mutationConstraintClauses(instruction) {
75 clause = strings.TrimSpace(strings.TrimLeft(clause, "-*•0123456789. )\t"))
76 if clause == "" {
77 continue
78 }
79 if hasExplicitReadOnlyClause(clause) || hasGlobalNegatedMutationClause(clause) {
80 return true
81 }
82 }
83 return false
84 }
85
86 func mutationConstraintClauses(instruction string) []string {
87 return strings.FieldsFunc(instruction, func(r rune) bool {
88 switch r {
89 case '\n', '\r', '.', '!', '?', ';', '。', '!', '?', ';':
90 return true
91 default:
92 return false
93 }
94 })
95 }
96
97 func hasExplicitReadOnlyClause(clause string) bool {
98 if hasMutationContinuation(clause) {
99 return false
100 }
101 for _, phrase := range []string{
102 "analyze only", "analysis only", "read-only review", "read only review",
103 "reproduce only", "reproduce but don't fix", "reproduce but do not fix",
104 "只分析", "仅分析", "只看不改", "复现但不修复", "只复现", "仅复现",
105 } {
106 if strings.Contains(clause, phrase) {
107 return true
108 }
109 }
110 trimmed := strings.TrimSpace(clause)
111 return trimmed == "read-only" || strings.HasPrefix(trimmed, "read-only ") ||
112 trimmed == "read only" || strings.HasPrefix(trimmed, "read only ") ||
113 trimmed == "只读" || strings.HasPrefix(trimmed, "只读")
114 }
115
116 func hasMutationContinuation(clause string) bool {
117 for _, marker := range []string{" then ", " and then ", " but then ", "然后", "再", "接着"} {
118 _, tail, ok := strings.Cut(clause, marker)
119 if !ok {
120 continue
121 }
122 if matchesAny(tail, []string{
123 "fix", "repair", "implement", "write", "edit", "change", "modify", "create", "commit", "push",
124 "修复", "实现", "编写", "写入", "编辑", "修改", "创建", "提交", "推送",
125 }) {
126 return true
127 }
128 }
129 return false
130 }
131
132 func hasGlobalNegatedMutationClause(clause string) bool {
133 if describesReadOnlyActor(clause) {
134 return false
135 }
136 for _, phrase := range []string{
137 "don't modify", "do not modify", "don't change", "do not change",
138 "don't edit", "do not edit", "without modifying", "without changes",
139 } {
140 if tail, ok := textAfterPhrase(clause, phrase); ok && globalMutationTail(tail) {
141 return true
142 }
143 }
144 for _, phrase := range []string{"don't fix", "do not fix", "no fix"} {
145 if tail, ok := textAfterPhrase(clause, phrase); ok && globalFixTail(tail) {
146 return true
147 }
148 }
149 if tail, ok := textAfterPhrase(clause, "no changes"); ok && globalNoChangesTail(tail) {
150 return true
151 }
152 if tail, ok := textAfterPhrase(clause, "make no changes"); ok && globalNoChangesTail(tail) {
153 return true
154 }
155 for _, phrase := range []string{"不要修改", "不要改动", "不要改", "别修改", "别改", "勿修改"} {
156 if tail, ok := textAfterPhrase(clause, phrase); ok && globalChineseMutationTail(tail) {
157 return true
158 }
159 }
160 for _, phrase := range []string{"不要修复", "不要修", "别修复", "别修"} {
161 if tail, ok := textAfterPhrase(clause, phrase); ok && globalChineseFixTail(tail) {
162 return true
163 }
164 }
165 return false
166 }
167
168 func describesReadOnlyActor(clause string) bool {
169 return matchesAny(clause, []string{
170 "reviewer", "sub-agent", "subagent", "child agent", "child", "planner",
171 "审查者", "评审者", "子代理", "子 agent", "规划器",
172 }) && matchesAny(clause, []string{"read-only", "read only", "只读"})
173 }
174
175 func textAfterPhrase(clause, phrase string) (string, bool) {
176 _, tail, ok := strings.Cut(clause, phrase)
177 return strings.TrimSpace(tail), ok
178 }
179
180 func globalMutationTail(tail string) bool {
181 if tail == "" {
182 return true
183 }
184 if strings.HasPrefix(tail, ":") {
185 return false
186 }
187 return hasBroadTarget(tail)
188 }
189
190 func globalFixTail(tail string) bool {
191 return tail == "" || startsWithAnyWord(tail, []string{"anything", "anything else", "any issue", "any issues"})
192 }
193
194 func globalNoChangesTail(tail string) bool {
195 if tail == "" {
196 return true
197 }
198 return startsWithAnyWord(tail, []string{
199 "anywhere", "at all", "to anything", "to the workspace", "to the repository", "to the repo", "to the codebase",
200 })
201 }
202
203 func hasBroadTarget(tail string) bool {
204 return startsWithAnyWord(tail, []string{
205 "anything", "anything else", "the workspace", "this workspace", "workspace",
206 "the repository", "this repository", "repository", "the repo", "this repo", "repo",
207 "the codebase", "this codebase", "codebase", "any file", "any files", "all files", "the source tree",
208 })
209 }
210
211 func startsWithAnyWord(value string, prefixes []string) bool {
212 value = strings.TrimSpace(value)
213 for _, prefix := range prefixes {
214 if value == prefix || strings.HasPrefix(value, prefix+" ") || strings.HasPrefix(value, prefix+",") {
215 return true
216 }
217 }
218 return false
219 }
220
221 func globalChineseMutationTail(tail string) bool {
222 if tail == "" {
223 return true
224 }
225 if strings.HasPrefix(tail, ":") || strings.HasPrefix(tail, ":") {
226 return false
227 }
228 return startsWithAnyChinese(tail, []string{
229 "任何内容", "任何东西", "任何文件", "所有文件", "工作区", "当前工作区",
230 "仓库", "当前仓库", "代码库", "当前代码库", "源码树",
231 })
232 }
233
234 func globalChineseFixTail(tail string) bool {
235 return tail == "" || startsWithAnyChinese(tail, []string{"任何问题", "任何内容", "其他任何问题"})
236 }
237
238 func startsWithAnyChinese(value string, prefixes []string) bool {
239 value = strings.TrimSpace(value)
240 for _, prefix := range prefixes {
241 if strings.HasPrefix(value, prefix) {
242 return true
243 }
244 }
245 return false
246 }
247
248 // StripQuotedConstraints removes fenced and quoted spans so cited phrases
249 // cannot bind the host.
250 func StripQuotedConstraints(raw string) string {
251 s := stripFences(raw)
252 s = stripInlineCode(s)
253 s = stripQuoted(s, '"', '"')
254 s = stripQuoted(s, '“', '”')
255 s = stripQuoted(s, '「', '」')
256 return strings.TrimSpace(s)
257 }
258
259 // rebuildPathPattern extracts candidate file tokens from one instruction clause.
260 var rebuildPathPattern = regexp.MustCompile("`[^`]+`|\"[^\"]+\"|'[^']+'|[A-Za-z0-9_./\\\\:-]+")
261
262 // ParseRebuildPaths resolves the files an instruction names in a clause that
263 // itself grants AllowRebuild. Callers record the result once per turn and
264 // authorize a rebuild by membership, so model-authored text can never grant the
265 // waiver at write time.
266 func ParseRebuildPaths(instruction, baseDir string) []string {
267 var paths []string
268 for _, clause := range strings.FieldsFunc(instruction, func(r rune) bool {
269 return strings.ContainsRune("\n;;。!?!?", r)
270 }) {
271 if !ParseConstraints(clause).AllowRebuild {
272 continue
273 }
274 lower := strings.ToLower(clause)
275 if matchesAny(lower, []string{"不要", "别", "not ", "don't", "禁止"}) {
276 continue
277 }
278 for _, token := range rebuildPathPattern.FindAllString(clause, -1) {
279 token = strings.Trim(token, "`\"'")
280 if token == "" {
281 continue
282 }
283 if !filepath.IsAbs(token) {
284 token = filepath.Join(baseDir, token)
285 }
286 paths = append(paths, filepath.Clean(token))
287 }
288 }
289 return paths
290 }
291
292 func (c Constraints) AllowsMutation() bool {
293 return !c.ForbidMutation && !c.PlanModeReadOnly
294 }
295
296 func (c Constraints) AllowsTests() bool { return !c.ForbidTests }
297
298 func (c Constraints) AllowsExternal() bool { return !c.ForbidExternal }
299
300 func (c Constraints) AllowsCommand(command string) bool {
301 if !c.AllowsTests() {
302 return false
303 }
304 command = strings.TrimSpace(command)
305 if command == "" || len(c.AllowedChecks) == 0 {
306 return true
307 }
308 for _, allowed := range c.AllowedChecks {
309 if strings.EqualFold(strings.TrimSpace(allowed), command) {
310 return true
311 }
312 }
313 commandFields, malformed := shellparse.StaticFields(command)
314 if malformed != "" || len(commandFields) == 0 {
315 return false
316 }
317 for _, allowed := range c.AllowedChecks {
318 allowedFields, malformed := shellparse.StaticFields(strings.TrimSpace(allowed))
319 if malformed == "" && len(allowedFields) > 0 && hasFieldPrefix(commandFields, allowedFields) {
320 return true
321 }
322 }
323 return false
324 }
325
326 func parseAllowedChecks(instruction string) []string {
327 patterns := []*regexp.Regexp{
328 regexp.MustCompile(`(?i)只跑\s+([^\n,,;;]+)`),
329 regexp.MustCompile(`(?i)只运行\s+([^\n,,;;]+)`),
330 regexp.MustCompile(`(?i)only\s+run\s+([^\n,;]+)`),
331 regexp.MustCompile(`(?i)just\s+run\s+([^\n,;]+)`),
332 }
333 var out []string
334 for _, re := range patterns {
335 m := re.FindStringSubmatch(instruction)
336 if len(m) < 2 {
337 continue
338 }
339 cmd := strings.Trim(strings.TrimSpace(m[1]), "\"'`。.")
340 if cmd != "" {
341 out = append(out, cmd)
342 }
343 }
344 return out
345 }
346
347 func matchesAny(lower string, needles []string) bool {
348 for _, n := range needles {
349 if n != "" && strings.Contains(lower, strings.ToLower(n)) {
350 return true
351 }
352 }
353 return false
354 }
355
356 func hasFieldPrefix(fields, prefix []string) bool {
357 if len(prefix) > len(fields) {
358 return false
359 }
360 for i := range prefix {
361 if !strings.EqualFold(fields[i], prefix[i]) {
362 return false
363 }
364 }
365 return true
366 }
367
368 func stripFences(s string) string {
369 var b strings.Builder
370 inFence := false
371 for line := range strings.SplitSeq(s, "\n") {
372 trim := strings.TrimSpace(line)
373 if strings.HasPrefix(trim, "```") {
374 inFence = !inFence
375 continue
376 }
377 if !inFence {
378 b.WriteString(line)
379 b.WriteByte('\n')
380 }
381 }
382 return b.String()
383 }
384
385 func stripInlineCode(s string) string {
386 var b strings.Builder
387 in := false
388 for _, r := range s {
389 if r == '`' {
390 in = !in
391 continue
392 }
393 if !in {
394 b.WriteRune(r)
395 }
396 }
397 return b.String()
398 }
399
400 func stripQuoted(s string, open, close rune) string {
401 var b strings.Builder
402 in := false
403 for _, r := range s {
404 if !in && r == open {
405 in = true
406 continue
407 }
408 if in && r == close {
409 in = false
410 continue
411 }
412 if !in {
413 b.WriteRune(r)
414 }
415 }
416 return b.String()
417 }
418
418 lines GO