返回 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 // plan mode or an inherited parent only; user prose never sets it
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 matchesAny(lower, []string{
35 "不要测试", "别跑测试", "不用测试", "跳过测试", "不要跑测试",
36 "don't run tests", "do not run tests", "no tests", "skip tests",
37 "without tests", "don't test", "do not test",
38 }) {
39 c.ForbidTests = true
40 c.Notes = append(c.Notes, "user_forbid_tests")
41 }
42 if matchesAny(lower, []string{
43 "完全重写", "从头重写", "整个重写", "直接重写", "覆盖重写", "整个文件重写",
44 "from scratch", "rewrite it completely", "rewrite the file completely",
45 "overwrite it completely", "replace it entirely", "rebuild the file",
46 "rewrite this file", "rewrite the whole file",
47 }) {
48 c.AllowRebuild = true
49 c.Notes = append(c.Notes, "user_allow_rebuild")
50 }
51 if cmds := parseAllowedChecks(instruction); len(cmds) > 0 {
52 c.AllowedChecks = cmds
53 c.Notes = append(c.Notes, "user_allowed_checks")
54 }
55 if matchesAny(lower, []string{
56 "不要 push", "不要push", "别 push", "别push", "不要推送", "不要发布",
57 "don't push", "do not push", "no push", "don't publish", "do not publish",
58 "no publish", "don't deploy", "do not deploy",
59 }) {
60 c.ForbidExternal = true
61 c.Notes = append(c.Notes, "user_forbid_external")
62 }
63 return c
64 }
65
66 // StripQuotedConstraints removes fenced and quoted spans so cited phrases
67 // cannot bind the host.
68 func StripQuotedConstraints(raw string) string {
69 s := stripFences(raw)
70 s = stripInlineCode(s)
71 s = stripQuoted(s, '"', '"')
72 s = stripQuoted(s, '“', '”')
73 s = stripQuoted(s, '「', '」')
74 return strings.TrimSpace(s)
75 }
76
77 // rebuildPathPattern extracts candidate file tokens from one instruction clause.
78 var rebuildPathPattern = regexp.MustCompile("`[^`]+`|\"[^\"]+\"|'[^']+'|[A-Za-z0-9_./\\\\:-]+")
79
80 // ParseRebuildPaths resolves the files an instruction names in a clause that
81 // itself grants AllowRebuild. Callers record the result once per turn and
82 // authorize a rebuild by membership, so model-authored text can never grant the
83 // waiver at write time.
84 func ParseRebuildPaths(instruction, baseDir string) []string {
85 var paths []string
86 for _, clause := range strings.FieldsFunc(instruction, func(r rune) bool {
87 return strings.ContainsRune("\n;;。!?!?", r)
88 }) {
89 if !ParseConstraints(clause).AllowRebuild {
90 continue
91 }
92 lower := strings.ToLower(clause)
93 if matchesAny(lower, []string{"不要", "别", "not ", "don't", "禁止"}) {
94 continue
95 }
96 for _, token := range rebuildPathPattern.FindAllString(clause, -1) {
97 token = strings.Trim(token, "`\"'")
98 if token == "" {
99 continue
100 }
101 if !filepath.IsAbs(token) {
102 token = filepath.Join(baseDir, token)
103 }
104 paths = append(paths, filepath.Clean(token))
105 }
106 }
107 return paths
108 }
109
110 func (c Constraints) AllowsMutation() bool {
111 return !c.ForbidMutation && !c.PlanModeReadOnly
112 }
113
114 func (c Constraints) AllowsTests() bool { return !c.ForbidTests }
115
116 func (c Constraints) AllowsExternal() bool { return !c.ForbidExternal }
117
118 func (c Constraints) AllowsCommand(command string) bool {
119 if !c.AllowsTests() {
120 return false
121 }
122 command = strings.TrimSpace(command)
123 if command == "" || len(c.AllowedChecks) == 0 {
124 return true
125 }
126 for _, allowed := range c.AllowedChecks {
127 if strings.EqualFold(strings.TrimSpace(allowed), command) {
128 return true
129 }
130 }
131 commandFields, malformed := shellparse.StaticFields(command)
132 if malformed != "" || len(commandFields) == 0 {
133 return false
134 }
135 for _, allowed := range c.AllowedChecks {
136 allowedFields, malformed := shellparse.StaticFields(strings.TrimSpace(allowed))
137 if malformed == "" && len(allowedFields) > 0 && hasFieldPrefix(commandFields, allowedFields) {
138 return true
139 }
140 }
141 return false
142 }
143
144 func parseAllowedChecks(instruction string) []string {
145 patterns := []*regexp.Regexp{
146 regexp.MustCompile(`(?i)只跑\s+([^\n,,;;]+)`),
147 regexp.MustCompile(`(?i)只运行\s+([^\n,,;;]+)`),
148 regexp.MustCompile(`(?i)only\s+run\s+([^\n,;]+)`),
149 regexp.MustCompile(`(?i)just\s+run\s+([^\n,;]+)`),
150 }
151 var out []string
152 for _, re := range patterns {
153 m := re.FindStringSubmatch(instruction)
154 if len(m) < 2 {
155 continue
156 }
157 cmd := strings.Trim(strings.TrimSpace(m[1]), "\"'`。.")
158 if cmd != "" {
159 out = append(out, cmd)
160 }
161 }
162 return out
163 }
164
165 func matchesAny(lower string, needles []string) bool {
166 for _, n := range needles {
167 if n != "" && strings.Contains(lower, strings.ToLower(n)) {
168 return true
169 }
170 }
171 return false
172 }
173
174 func hasFieldPrefix(fields, prefix []string) bool {
175 if len(prefix) > len(fields) {
176 return false
177 }
178 for i := range prefix {
179 if !strings.EqualFold(fields[i], prefix[i]) {
180 return false
181 }
182 }
183 return true
184 }
185
186 func stripFences(s string) string {
187 var b strings.Builder
188 inFence := false
189 for line := range strings.SplitSeq(s, "\n") {
190 trim := strings.TrimSpace(line)
191 if strings.HasPrefix(trim, "```") {
192 inFence = !inFence
193 continue
194 }
195 if !inFence {
196 b.WriteString(line)
197 b.WriteByte('\n')
198 }
199 }
200 return b.String()
201 }
202
203 func stripInlineCode(s string) string {
204 var b strings.Builder
205 in := false
206 for _, r := range s {
207 if r == '`' {
208 in = !in
209 continue
210 }
211 if !in {
212 b.WriteRune(r)
213 }
214 }
215 return b.String()
216 }
217
218 func stripQuoted(s string, open, close rune) string {
219 var b strings.Builder
220 in := false
221 for _, r := range s {
222 if !in && r == open {
223 in = true
224 continue
225 }
226 if in && r == close {
227 in = false
228 continue
229 }
230 if !in {
231 b.WriteRune(r)
232 }
233 }
234 return b.String()
235 }
236
236 lines GO