| 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 |