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