@100xprompt/brain 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (213) hide show
  1. package/LICENSE +48 -0
  2. package/README.md +172 -2
  3. package/package.json +48 -4
  4. package/src/adapters/aider/index.ts +8 -0
  5. package/src/adapters/aider/reader.ts +129 -0
  6. package/src/adapters/aider/records.ts +220 -0
  7. package/src/adapters/aider/writer.ts +269 -0
  8. package/src/adapters/amazon-q-cli/index.ts +8 -0
  9. package/src/adapters/amazon-q-cli/reader.ts +84 -0
  10. package/src/adapters/amazon-q-cli/records.ts +191 -0
  11. package/src/adapters/amazon-q-cli/writer.ts +370 -0
  12. package/src/adapters/amazon-q-ide/index.ts +9 -0
  13. package/src/adapters/amazon-q-ide/reader.ts +131 -0
  14. package/src/adapters/amazon-q-ide/records.ts +141 -0
  15. package/src/adapters/amazon-q-ide/writer.ts +325 -0
  16. package/src/adapters/antigravity/index.ts +9 -0
  17. package/src/adapters/antigravity/proto.ts +194 -0
  18. package/src/adapters/antigravity/reader.ts +424 -0
  19. package/src/adapters/antigravity/records.ts +236 -0
  20. package/src/adapters/antigravity/writer.ts +463 -0
  21. package/src/adapters/auggie/index.ts +36 -0
  22. package/src/adapters/claude-code/index.ts +8 -0
  23. package/src/adapters/claude-code/reader.ts +639 -0
  24. package/src/adapters/claude-code/records.ts +88 -0
  25. package/src/adapters/claude-code/writer.ts +329 -0
  26. package/src/adapters/cline/index.ts +12 -0
  27. package/src/adapters/cline/reader.ts +432 -0
  28. package/src/adapters/cline/records.ts +151 -0
  29. package/src/adapters/cline/vscdb.ts +56 -0
  30. package/src/adapters/cline/writer.ts +656 -0
  31. package/src/adapters/cline/xml.ts +177 -0
  32. package/src/adapters/cline4/index.ts +9 -0
  33. package/src/adapters/cline4/reader.ts +179 -0
  34. package/src/adapters/cline4/records.ts +92 -0
  35. package/src/adapters/cline4/writer.ts +361 -0
  36. package/src/adapters/codebuff/index.ts +8 -0
  37. package/src/adapters/codebuff/project.ts +207 -0
  38. package/src/adapters/codebuff/reader.ts +215 -0
  39. package/src/adapters/codebuff/records.ts +173 -0
  40. package/src/adapters/codebuff/writer.ts +383 -0
  41. package/src/adapters/codex/index.ts +8 -0
  42. package/src/adapters/codex/native.ts +239 -0
  43. package/src/adapters/codex/reader.ts +747 -0
  44. package/src/adapters/codex/records.ts +114 -0
  45. package/src/adapters/codex/writer.ts +479 -0
  46. package/src/adapters/continue/index.ts +9 -0
  47. package/src/adapters/continue/reader.ts +68 -0
  48. package/src/adapters/continue/records.ts +418 -0
  49. package/src/adapters/continue/writer.ts +162 -0
  50. package/src/adapters/copilot-cli/index.ts +9 -0
  51. package/src/adapters/copilot-cli/reader.ts +186 -0
  52. package/src/adapters/copilot-cli/records.ts +241 -0
  53. package/src/adapters/copilot-cli/writer.ts +353 -0
  54. package/src/adapters/crush/index.ts +8 -0
  55. package/src/adapters/crush/reader.ts +180 -0
  56. package/src/adapters/crush/records.ts +169 -0
  57. package/src/adapters/crush/writer.ts +333 -0
  58. package/src/adapters/cursor-cli/index.ts +405 -0
  59. package/src/adapters/cursor-ide/agentkv.ts +95 -0
  60. package/src/adapters/cursor-ide/index.ts +501 -0
  61. package/src/adapters/cursor-ide/transcripts.ts +122 -0
  62. package/src/adapters/cursor-ide/workspaces.ts +119 -0
  63. package/src/adapters/cursor-ide/writer.ts +170 -0
  64. package/src/adapters/devin/index.ts +8 -0
  65. package/src/adapters/devin/reader.ts +305 -0
  66. package/src/adapters/devin/records.ts +277 -0
  67. package/src/adapters/devin/writer.ts +523 -0
  68. package/src/adapters/factory-droid/index.ts +12 -0
  69. package/src/adapters/factory-droid/reader.ts +184 -0
  70. package/src/adapters/factory-droid/records.ts +79 -0
  71. package/src/adapters/factory-droid/writer.ts +350 -0
  72. package/src/adapters/gemini-cli/exchanges.ts +228 -0
  73. package/src/adapters/gemini-cli/index.ts +396 -0
  74. package/src/adapters/gemini-cli/records.ts +521 -0
  75. package/src/adapters/gemini-cli/writer.ts +354 -0
  76. package/src/adapters/goose/index.ts +8 -0
  77. package/src/adapters/goose/reader.ts +138 -0
  78. package/src/adapters/goose/records.ts +199 -0
  79. package/src/adapters/goose/writer.ts +340 -0
  80. package/src/adapters/grok-cli/image.ts +44 -0
  81. package/src/adapters/grok-cli/index.ts +8 -0
  82. package/src/adapters/grok-cli/reader.ts +302 -0
  83. package/src/adapters/grok-cli/records.ts +172 -0
  84. package/src/adapters/grok-cli/writer.ts +329 -0
  85. package/src/adapters/hermes/index.ts +8 -0
  86. package/src/adapters/hermes/reader.ts +249 -0
  87. package/src/adapters/hermes/records.ts +285 -0
  88. package/src/adapters/hermes/writer.ts +452 -0
  89. package/src/adapters/iflow-cli/index.ts +72 -0
  90. package/src/adapters/iflow-cli/records.ts +215 -0
  91. package/src/adapters/iflow-cli/writer.ts +204 -0
  92. package/src/adapters/index.ts +112 -0
  93. package/src/adapters/kilo-cli/index.ts +21 -0
  94. package/src/adapters/kilo-code/index.ts +11 -0
  95. package/src/adapters/kilo-code/writer.ts +92 -0
  96. package/src/adapters/kimi/index.ts +9 -0
  97. package/src/adapters/kimi/reader.ts +92 -0
  98. package/src/adapters/kimi/records.ts +221 -0
  99. package/src/adapters/kimi/writer.ts +367 -0
  100. package/src/adapters/kiro-cli/index.ts +8 -0
  101. package/src/adapters/kiro-cli/reader.ts +87 -0
  102. package/src/adapters/kiro-cli/records.ts +260 -0
  103. package/src/adapters/kiro-cli/writer.ts +305 -0
  104. package/src/adapters/kiro-ide/index.ts +8 -0
  105. package/src/adapters/kiro-ide/writer.ts +52 -0
  106. package/src/adapters/letta/index.ts +8 -0
  107. package/src/adapters/letta/reader.ts +177 -0
  108. package/src/adapters/letta/records.ts +119 -0
  109. package/src/adapters/letta/writer.ts +413 -0
  110. package/src/adapters/mastracode/index.ts +8 -0
  111. package/src/adapters/mastracode/reader.ts +323 -0
  112. package/src/adapters/mastracode/records.ts +336 -0
  113. package/src/adapters/mastracode/writer.ts +385 -0
  114. package/src/adapters/mistral-vibe/index.ts +9 -0
  115. package/src/adapters/mistral-vibe/reader.ts +62 -0
  116. package/src/adapters/mistral-vibe/records.ts +104 -0
  117. package/src/adapters/mistral-vibe/writer.ts +359 -0
  118. package/src/adapters/muse/index.ts +10 -0
  119. package/src/adapters/muse/reader.ts +332 -0
  120. package/src/adapters/muse/records.ts +104 -0
  121. package/src/adapters/muse/writer.ts +387 -0
  122. package/src/adapters/omp/index.ts +13 -0
  123. package/src/adapters/omp/reader.ts +314 -0
  124. package/src/adapters/omp/records.ts +153 -0
  125. package/src/adapters/omp/writer.ts +216 -0
  126. package/src/adapters/opencode/ids.ts +52 -0
  127. package/src/adapters/opencode/index.ts +31 -0
  128. package/src/adapters/opencode/note.ts +8 -0
  129. package/src/adapters/opencode/reader.ts +452 -0
  130. package/src/adapters/opencode/tools.ts +174 -0
  131. package/src/adapters/opencode/v2.ts +367 -0
  132. package/src/adapters/opencode/writer.ts +379 -0
  133. package/src/adapters/openhands/index.ts +8 -0
  134. package/src/adapters/openhands/reader.ts +222 -0
  135. package/src/adapters/openhands/records.ts +136 -0
  136. package/src/adapters/openhands/writer.ts +404 -0
  137. package/src/adapters/pearai/index.ts +10 -0
  138. package/src/adapters/pi/index.ts +28 -0
  139. package/src/adapters/pi/reader.ts +261 -0
  140. package/src/adapters/pi/records.ts +99 -0
  141. package/src/adapters/pi/writer.ts +362 -0
  142. package/src/adapters/qoder-cli/index.ts +8 -0
  143. package/src/adapters/qoder-cli/reader.ts +83 -0
  144. package/src/adapters/qoder-cli/records.ts +45 -0
  145. package/src/adapters/qoder-cli/writer.ts +72 -0
  146. package/src/adapters/qwen-code/index.ts +84 -0
  147. package/src/adapters/qwen-code/records.ts +194 -0
  148. package/src/adapters/qwen-code/writer.ts +224 -0
  149. package/src/adapters/tabnine-cli/index.ts +8 -0
  150. package/src/adapters/tabnine-cli/reader.ts +106 -0
  151. package/src/adapters/tabnine-cli/records.ts +33 -0
  152. package/src/adapters/tabnine-cli/writer.ts +156 -0
  153. package/src/adapters/types.ts +73 -0
  154. package/src/adapters/void/index.ts +9 -0
  155. package/src/adapters/void/reader.ts +48 -0
  156. package/src/adapters/void/records.ts +285 -0
  157. package/src/adapters/void/writer.ts +93 -0
  158. package/src/adapters/vscode-copilot/index.ts +9 -0
  159. package/src/adapters/vscode-copilot/reader.ts +212 -0
  160. package/src/adapters/vscode-copilot/records.ts +388 -0
  161. package/src/adapters/vscode-copilot/shared.ts +68 -0
  162. package/src/adapters/vscode-copilot/writer.ts +546 -0
  163. package/src/adapters/zed/index.ts +8 -0
  164. package/src/adapters/zed/reader.ts +198 -0
  165. package/src/adapters/zed/records.ts +87 -0
  166. package/src/adapters/zed/writer.ts +341 -0
  167. package/src/brief-writers.ts +71 -0
  168. package/src/brief.ts +238 -0
  169. package/src/cli.ts +326 -0
  170. package/src/fsutil.ts +133 -0
  171. package/src/gateway/connect.ts +845 -0
  172. package/src/gateway/signin.ts +323 -0
  173. package/src/handoff.ts +104 -0
  174. package/src/hooks.ts +455 -0
  175. package/src/memory/audit.ts +514 -0
  176. package/src/memory/blobs.ts +94 -0
  177. package/src/memory/cli.ts +117 -0
  178. package/src/memory/distill.ts +381 -0
  179. package/src/memory/externalize.ts +435 -0
  180. package/src/memory/lookups.ts +70 -0
  181. package/src/memory/mcp-install.ts +401 -0
  182. package/src/memory/mcp-server.ts +296 -0
  183. package/src/memory/prepare.ts +179 -0
  184. package/src/memory/preview.ts +154 -0
  185. package/src/memory/project.ts +35 -0
  186. package/src/memory/redact.ts +72 -0
  187. package/src/memory/relevance.ts +187 -0
  188. package/src/memory/repomap.ts +516 -0
  189. package/src/memory/store.ts +270 -0
  190. package/src/memory/sync.ts +166 -0
  191. package/src/memory/tidy.ts +193 -0
  192. package/src/memory/types.ts +118 -0
  193. package/src/org/admin-ops.ts +96 -0
  194. package/src/org/auth.ts +112 -0
  195. package/src/org/backup.ts +115 -0
  196. package/src/org/cli.ts +438 -0
  197. package/src/org/client.ts +404 -0
  198. package/src/org/config.ts +44 -0
  199. package/src/org/crypto.ts +537 -0
  200. package/src/org/db.ts +435 -0
  201. package/src/org/graph.ts +318 -0
  202. package/src/org/oidc.ts +435 -0
  203. package/src/org/policy.ts +398 -0
  204. package/src/org/server.ts +920 -0
  205. package/src/org/sso.ts +316 -0
  206. package/src/org/tls.ts +66 -0
  207. package/src/org/types.ts +193 -0
  208. package/src/self.ts +68 -0
  209. package/src/session/attachments.ts +53 -0
  210. package/src/session/peekcache.ts +50 -0
  211. package/src/session/types.ts +104 -0
  212. package/src/session/util.ts +130 -0
  213. package/src/sqlite.ts +48 -0
@@ -0,0 +1,398 @@
1
+ // brain org: the organisation's content policy, as pure functions. No I/O, no model.
2
+ //
3
+ // Used twice: by the client (a courtesy: denied items never leave the machine) and by the server (the real check: it never
4
+ // trusts the client). Both call the same code, so the answers cannot drift.
5
+ //
6
+ // Everything here is written to be run on hostile input:
7
+ // * globs are matched by a segment-level matcher, never compiled to a regex, so a glob cannot cause backtracking;
8
+ // * regex deny terms (`/pattern/flags`) are accepted only when a static guard proves they cannot backtrack badly
9
+ // (no nested or overlapping repetition, no backreferences, no lookaround, at most two unbounded repeats, 200 chars);
10
+ // a term that fails the guard is rejected by normalizePolicy; if one still reaches checkItem it FAILS CLOSED (item refused).
11
+
12
+ import type { MemoryItem, MemoryKind } from "../memory/types.ts"
13
+ import { DEFAULT_POLICY, SCOPES, scopeRank } from "./types.ts"
14
+ import type { Policy, Scope } from "./types.ts"
15
+
16
+ export const MEMORY_KINDS: MemoryKind[] = ["decision", "constraint", "preference", "failed", "file", "command", "summary", "state"]
17
+
18
+ const MAX_GLOB = 500
19
+ const MAX_TERM = 500
20
+ const MAX_ENTRIES = 500
21
+ const MAX_REGEX = 200
22
+ /** Items bigger than this (all text fields together) are refused rather than scanned partly: a partial scan would be a bypass. */
23
+ const MAX_ITEM_CHARS = 64_000
24
+ const MAX_UNBOUNDED_REPEATS = 2
25
+
26
+ // ---- paths and globs ----------------------------------------------------------------------------------------------
27
+
28
+ /** Splits a path (or glob) into segments: `\` counts as `/`, a leading `./`, `~/`, `/` or drive (`C:/`) is dropped, `.` and `..` are resolved. */
29
+ function segmentsOf(p: string, keepWild = false): string[] {
30
+ let s = p.replace(/\\/g, "/")
31
+ s = s.replace(/^[A-Za-z]:(?=\/|$)/, "")
32
+ const out: string[] = []
33
+ for (const seg of s.split("/")) {
34
+ if (seg === "" || seg === ".") continue
35
+ if (seg === "~" && out.length === 0) continue
36
+ if (seg === "..") {
37
+ if (!keepWild || out.length > 0) out.pop()
38
+ continue
39
+ }
40
+ out.push(seg)
41
+ }
42
+ return out
43
+ }
44
+
45
+ /** `*` and `?` inside one segment; iterative, linear-ish, no regex. */
46
+ function segMatch(pat: string, s: string): boolean {
47
+ let p = 0
48
+ let i = 0
49
+ let star = -1
50
+ let mark = 0
51
+ while (i < s.length) {
52
+ if (p < pat.length && (pat[p] === "?" || pat[p] === s[i]) && pat[p] !== "*") {
53
+ p++
54
+ i++
55
+ } else if (p < pat.length && pat[p] === "*") {
56
+ star = p++
57
+ mark = i
58
+ } else if (star >= 0) {
59
+ p = star + 1
60
+ i = ++mark
61
+ } else return false
62
+ }
63
+ while (p < pat.length && pat[p] === "*") p++
64
+ return p === pat.length
65
+ }
66
+
67
+ function matchSegs(g: string[], s: string[]): boolean {
68
+ // dp over (glob index, path index); a `**` segment matches zero or more whole segments.
69
+ const gl = g.length
70
+ const sl = s.length
71
+ let row: boolean[] = new Array(sl + 1).fill(false)
72
+ row[sl] = true
73
+ for (let gi = gl - 1; gi >= 0; gi--) {
74
+ const next: boolean[] = new Array(sl + 1).fill(false)
75
+ const isStar2 = g[gi] === "**"
76
+ for (let si = sl; si >= 0; si--) {
77
+ if (isStar2) next[si] = row[si] === true || (si < sl && next[si + 1] === true)
78
+ else next[si] = si < sl && row[si + 1] === true && segMatch(g[gi]!, s[si]!)
79
+ }
80
+ row = next
81
+ }
82
+ return row[0] === true
83
+ }
84
+
85
+ /**
86
+ * Case-sensitive, anchored glob match on `/`-separated paths (`\` is read as `/`).
87
+ * `*` any characters within one segment `?` one character within a segment `**` (as a whole segment) any number of segments
88
+ * A leading `./`, `~/`, `/` or drive letter is ignored on both sides, and `.`/`..` segments are resolved in the path.
89
+ * A glob that names a directory matches everything under it and the directory itself: `secrets/**` matches `secrets`, `secrets/a/b.txt`.
90
+ * So does a plain `secrets` (every ancestor directory of the path is tried).
91
+ */
92
+ export function globMatch(glob: string, path: string): boolean {
93
+ if (typeof glob !== "string" || typeof path !== "string" || glob.length === 0 || glob.length > MAX_GLOB) return false
94
+ // Collapse `a**b` inside a segment to `a*b`; a segment of only stars becomes `**`.
95
+ const g = segmentsOf(glob, true).map((x) => (/^\*{2,}$/.test(x) ? "**" : x.replace(/\*{2,}/g, "*")))
96
+ if (g.length === 0) return false
97
+ const s = segmentsOf(path)
98
+ for (let n = s.length; n >= 1; n--) if (matchSegs(g, s.slice(0, n))) return true
99
+ return false
100
+ }
101
+
102
+ /** Like globMatch, but also when the glob matches any trailing part of the path: `secrets/**` catches `/home/me/app/secrets/k.pem`. Denial errs on the side of denying. */
103
+ function globHits(glob: string, path: string): boolean {
104
+ const s = segmentsOf(path)
105
+ for (let from = 0; from < s.length; from++) if (globMatch(glob, s.slice(from).join("/"))) return true
106
+ return false
107
+ }
108
+
109
+ const EXTENSIONS = new Set(
110
+ (
111
+ "ts tsx js jsx mjs cjs json jsonc yaml yml toml ini cfg conf env pem key crt cer p12 pfx kdbx md txt log csv tsv sql db sqlite " +
112
+ "py rb go rs java kt swift c h cc cpp hpp cs php sh bash zsh fish ps1 bat cmd html htm css scss less vue svelte xml lock " +
113
+ "tf tfvars hcl dockerfile gradle properties plist npmrc netrc pdf docx xlsx pptx zip tar gz tgz"
114
+ ).split(" "),
115
+ )
116
+
117
+ const stripTail = (t: string): string =>
118
+ t
119
+ .replace(/(?::\d+)+$/, "") // file.ts:12:3
120
+ .replace(/[.,:;!?]+$/, "")
121
+
122
+ /** Tokens in a text that look like file paths: contain a `/` (or `\`), or end in a known extension, or are dotfiles like `.env`; not URLs. Backslashes come back as `/`. */
123
+ export function pathsIn(text: string): string[] {
124
+ if (typeof text !== "string" || text.length === 0) return []
125
+ const noUrls = text.replace(/[A-Za-z][A-Za-z0-9+.-]*:\/\/\S*/g, " ").replace(/\bwww\.\S*/gi, " ").replace(/\bmailto:\S*/gi, " ")
126
+ const out: string[] = []
127
+ const seen = new Set<string>()
128
+ for (const raw of noUrls.split(/[\s"'`()[\]{}<>,;|=*]+/)) {
129
+ if (!raw) continue
130
+ // `PATH=/a:/b`-style lists: split on a colon that starts another path, but keep `C:\` drive prefixes.
131
+ const drive = /^[A-Za-z]:[\\/]/.test(raw) ? raw.slice(0, 2) : ""
132
+ const rest = drive ? raw.slice(2) : raw
133
+ const parts = rest.split(/:(?=[\\/~]|\.\.?[\\/])/)
134
+ parts.forEach((part, idx) => {
135
+ const tok = stripTail((idx === 0 ? drive : "") + part).replace(/\\/g, "/")
136
+ if (tok.length < 2 || tok.length > 1000) return
137
+ if (!/[A-Za-z0-9_]/.test(tok)) return
138
+ let isPath = tok.includes("/")
139
+ if (!isPath) {
140
+ const dot = tok.lastIndexOf(".")
141
+ if (dot >= 0 && dot < tok.length - 1) isPath = EXTENSIONS.has(tok.slice(dot + 1).toLowerCase()) && /^[\w.@+-]+$/.test(tok)
142
+ if (!isPath && /^\.[A-Za-z][\w-]{2,}(\.[\w-]+)*$/.test(tok)) isPath = true
143
+ }
144
+ if (isPath && !seen.has(tok)) {
145
+ seen.add(tok)
146
+ out.push(tok)
147
+ }
148
+ })
149
+ }
150
+ return out
151
+ }
152
+
153
+ // ---- deny terms: substrings and guarded regexes -------------------------------------------------------------------
154
+
155
+ interface Quant {
156
+ len: number
157
+ /** Can repeat (max > 1). */
158
+ loops: boolean
159
+ /** No upper bound, or a large one. */
160
+ unbounded: boolean
161
+ }
162
+
163
+ function quantAt(src: string, i: number): Quant | undefined {
164
+ const c = src[i]
165
+ let q: Quant | undefined
166
+ if (c === "*" || c === "+") q = { len: 1, loops: true, unbounded: true }
167
+ else if (c === "?") q = { len: 1, loops: false, unbounded: false }
168
+ else if (c === "{") {
169
+ const m = /^\{(\d+)(?:(,)(\d*))?\}/.exec(src.slice(i, i + 24))
170
+ if (m) {
171
+ const min = Number(m[1])
172
+ const max = m[2] ? (m[3] === "" ? Infinity : Number(m[3])) : min
173
+ q = { len: m[0].length, loops: max > 1, unbounded: max > 50 }
174
+ }
175
+ }
176
+ if (q && src[i + q.len] === "?") q.len++ // lazy
177
+ return q
178
+ }
179
+
180
+ /** Throws a plain Error saying why a regex deny term is refused. Returns the compiled RegExp when it is safe. */
181
+ export function safeRegex(src: string, flags: string): RegExp {
182
+ if (src.length === 0) throw new Error("empty regex")
183
+ if (src.length > MAX_REGEX) throw new Error(`regex longer than ${MAX_REGEX} characters`)
184
+ if (!/^[imsu]*$/.test(flags) || new Set(flags).size !== flags.length) throw new Error(`unsupported regex flags "${flags}" (allowed: i m s u)`)
185
+ if (/\(\?<?[=!]/.test(src)) throw new Error("lookahead and lookbehind are not allowed")
186
+ type Frame = { quant: boolean; alt: boolean }
187
+ const stack: Frame[] = [{ quant: false, alt: false }]
188
+ let unbounded = 0
189
+ for (let i = 0; i < src.length; i++) {
190
+ const c = src[i]!
191
+ if (c === "\\") {
192
+ const n = src[i + 1]
193
+ if (n === undefined) throw new Error("dangling backslash")
194
+ if (/[1-9]/.test(n) || n === "k") throw new Error("backreferences are not allowed")
195
+ i++
196
+ const q = quantAt(src, i + 1)
197
+ if (q) {
198
+ stack[stack.length - 1]!.quant = true
199
+ if (q.unbounded) unbounded++
200
+ i += q.len
201
+ }
202
+ continue
203
+ }
204
+ if (c === "[") {
205
+ let j = i + 1
206
+ if (src[j] === "^") j++
207
+ if (src[j] === "]") j++
208
+ while (j < src.length && src[j] !== "]") j += src[j] === "\\" ? 2 : 1
209
+ if (j >= src.length) throw new Error("unterminated character class")
210
+ i = j
211
+ const q = quantAt(src, i + 1)
212
+ if (q) {
213
+ stack[stack.length - 1]!.quant = true
214
+ if (q.unbounded) unbounded++
215
+ i += q.len
216
+ }
217
+ continue
218
+ }
219
+ if (c === "(") {
220
+ stack.push({ quant: false, alt: false })
221
+ if (src[i + 1] === "?") {
222
+ if (src[i + 2] === ":") i += 2
223
+ else if (src[i + 2] === "<" && /^\(\?<[A-Za-z_$][\w$]*>/.test(src.slice(i, i + 40))) i = src.indexOf(">", i)
224
+ else throw new Error("only plain and non-capturing groups are allowed")
225
+ }
226
+ continue
227
+ }
228
+ if (c === ")") {
229
+ if (stack.length < 2) throw new Error("unbalanced parenthesis")
230
+ const f = stack.pop()!
231
+ const q = quantAt(src, i + 1)
232
+ const parent = stack[stack.length - 1]!
233
+ if (q) {
234
+ if (q.loops && (f.quant || f.alt)) throw new Error("a repeated group may not contain another repeat or an alternation (catastrophic backtracking)")
235
+ if (q.unbounded) unbounded++
236
+ parent.quant = true
237
+ i += q.len
238
+ } else if (f.quant) parent.quant = true
239
+ continue
240
+ }
241
+ if (c === "|") {
242
+ stack[stack.length - 1]!.alt = true
243
+ continue
244
+ }
245
+ const q = quantAt(src, i + 1)
246
+ if (q) {
247
+ stack[stack.length - 1]!.quant = true
248
+ if (q.unbounded) unbounded++
249
+ i += q.len
250
+ }
251
+ }
252
+ if (stack.length !== 1) throw new Error("unbalanced parenthesis")
253
+ if (unbounded > MAX_UNBOUNDED_REPEATS) throw new Error(`more than ${MAX_UNBOUNDED_REPEATS} unbounded repeats (*, +, {n,}) can backtrack too much`)
254
+ try {
255
+ return new RegExp(src, flags)
256
+ } catch (e) {
257
+ throw new Error(`invalid regex: ${(e as Error).message}`)
258
+ }
259
+ }
260
+
261
+ type Term = { kind: "substr"; needle: string } | { kind: "regex"; re: RegExp }
262
+
263
+ /** `/pattern/flags` with valid flags is a regex; anything else (`/etc/passwd`, `/usr/bin/`... see the note) is a plain case-insensitive substring. */
264
+ function parseTerm(term: string): Term {
265
+ const m = /^\/(.+)\/([a-z]*)$/s.exec(term)
266
+ if (m && /^[imsu]*$/.test(m[2]!) && new Set(m[2]).size === m[2]!.length) return { kind: "regex", re: safeRegex(m[1]!, m[2]!) }
267
+ return { kind: "substr", needle: term.toLowerCase() }
268
+ }
269
+
270
+ const termCache = new Map<string, Term | Error>()
271
+ function termFor(term: string): Term | Error {
272
+ let t = termCache.get(term)
273
+ if (!t) {
274
+ try {
275
+ t = parseTerm(term)
276
+ } catch (e) {
277
+ t = e as Error
278
+ }
279
+ if (termCache.size > 2000) termCache.clear()
280
+ termCache.set(term, t)
281
+ }
282
+ return t
283
+ }
284
+
285
+ // ---- checkItem ----------------------------------------------------------------------------------------------------
286
+
287
+ /** All the text an item carries, in one string. */
288
+ function textOf(item: MemoryItem): string {
289
+ const parts: string[] = [item.name, item.text]
290
+ for (const e of item.entities ?? []) parts.push(e.name, e.type ?? "")
291
+ for (const r of item.relations ?? []) parts.push(r.from, r.to, r.type)
292
+ for (const p of item.procedures ?? []) parts.push(p.trigger ?? "", p.action)
293
+ return parts.filter((x) => typeof x === "string").join("\n")
294
+ }
295
+
296
+ /**
297
+ * Does the item pass the organisation's policy? Refused when its kind is not allowed, it mentions a denied path, or it contains a denied term.
298
+ * The reason names the rule (the glob or the term: the policy is visible to every member), never the item's text.
299
+ */
300
+ export function checkItem(item: MemoryItem, policy: Policy): { ok: true } | { ok: false; reason: string } {
301
+ if (policy.allowedKinds && !policy.allowedKinds.includes(item.kind)) return { ok: false, reason: `kind "${String(item.kind)}" is not allowed by policy` }
302
+ const blob = textOf(item)
303
+ if (blob.length > MAX_ITEM_CHARS) return { ok: false, reason: "item is too large to check against policy" }
304
+ if (policy.denyPaths.length > 0) {
305
+ const paths = pathsIn(blob)
306
+ for (const glob of policy.denyPaths) for (const p of paths) if (globHits(glob, p)) return { ok: false, reason: `mentions a path denied by policy (${glob})` }
307
+ }
308
+ if (policy.denyTerms.length > 0) {
309
+ const lower = blob.toLowerCase()
310
+ for (const term of policy.denyTerms) {
311
+ const t = termFor(term)
312
+ if (t instanceof Error) return { ok: false, reason: `policy term "${term}" is unusable (${t.message}); item refused` }
313
+ if (t.kind === "substr" ? lower.includes(t.needle) : t.re.test(blob)) return { ok: false, reason: `contains a term denied by policy (${term})` }
314
+ }
315
+ }
316
+ return { ok: true }
317
+ }
318
+
319
+ // ---- scope --------------------------------------------------------------------------------------------------------
320
+
321
+ /** The scope an item gets: the requested one (never above `policy.maxScope`), or the policy default (clamped to the ceiling). */
322
+ export function effectiveScope(requested: Scope | undefined, policy: Policy): { ok: true; scope: Scope } | { ok: false; code: "scope_above_policy"; reason: string } {
323
+ if (requested === undefined) {
324
+ const d = scopeRank(policy.defaultScope) > scopeRank(policy.maxScope) ? policy.maxScope : policy.defaultScope
325
+ return { ok: true, scope: d }
326
+ }
327
+ if (!SCOPES.includes(requested)) return { ok: false, code: "scope_above_policy", reason: `unknown scope "${String(requested)}"` }
328
+ if (scopeRank(requested) > scopeRank(policy.maxScope)) {
329
+ return { ok: false, code: "scope_above_policy", reason: `scope "${requested}" is wider than this organisation allows (maximum: "${policy.maxScope}")` }
330
+ }
331
+ return { ok: true, scope: requested }
332
+ }
333
+
334
+ // ---- normalizePolicy ----------------------------------------------------------------------------------------------
335
+
336
+ const KEYS = new Set(["maxScope", "defaultScope", "denyPaths", "denyTerms", "allowedKinds", "retentionDays"])
337
+
338
+ function stringList(name: string, v: unknown, max: number, check?: (s: string) => void): string[] {
339
+ if (!Array.isArray(v)) throw new Error(`${name} must be an array of strings`)
340
+ if (v.length > MAX_ENTRIES) throw new Error(`${name} has more than ${MAX_ENTRIES} entries`)
341
+ const out: string[] = []
342
+ for (const s of v) {
343
+ if (typeof s !== "string") throw new Error(`${name} must contain only strings`)
344
+ if (s.trim().length === 0) throw new Error(`${name} must not contain empty entries`)
345
+ if (s.length > max) throw new Error(`${name} entry longer than ${max} characters`)
346
+ if (s.includes("\0")) throw new Error(`${name} entry contains a NUL character`)
347
+ if (check) check(s)
348
+ if (!out.includes(s)) out.push(s)
349
+ }
350
+ return out
351
+ }
352
+
353
+ /** Validates a policy as received (a JSON body, a file) and fills the defaults. Throws a plain Error with the reason. Unknown keys are errors. */
354
+ export function normalizePolicy(raw: unknown): Policy {
355
+ if (raw === undefined) return { ...DEFAULT_POLICY, denyPaths: [], denyTerms: [] }
356
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) throw new Error("policy must be a JSON object")
357
+ const o = raw as Record<string, unknown>
358
+ for (const k of Object.keys(o)) if (!KEYS.has(k)) throw new Error(`unknown policy field "${k}"`)
359
+ const scopeOf = (name: string, v: unknown, fallback: Scope): Scope => {
360
+ if (v === undefined) return fallback
361
+ if (typeof v !== "string" || !SCOPES.includes(v as Scope)) throw new Error(`${name} must be one of ${SCOPES.join(", ")}`)
362
+ return v as Scope
363
+ }
364
+ const maxScope = scopeOf("maxScope", o.maxScope, DEFAULT_POLICY.maxScope)
365
+ const defaultScope = scopeOf("defaultScope", o.defaultScope, DEFAULT_POLICY.defaultScope)
366
+ if (scopeRank(defaultScope) > scopeRank(maxScope)) throw new Error(`defaultScope "${defaultScope}" is wider than maxScope "${maxScope}"`)
367
+ const policy: Policy = {
368
+ maxScope,
369
+ defaultScope,
370
+ denyPaths: o.denyPaths === undefined ? [] : stringList("denyPaths", o.denyPaths, MAX_GLOB),
371
+ denyTerms:
372
+ o.denyTerms === undefined
373
+ ? []
374
+ : stringList("denyTerms", o.denyTerms, MAX_TERM, (s) => {
375
+ try {
376
+ parseTerm(s)
377
+ } catch (e) {
378
+ throw new Error(`denyTerms entry ${JSON.stringify(s.slice(0, 60))}: ${(e as Error).message}`)
379
+ }
380
+ }),
381
+ }
382
+ if (o.allowedKinds !== undefined && o.allowedKinds !== null) {
383
+ if (!Array.isArray(o.allowedKinds)) throw new Error("allowedKinds must be an array of kinds")
384
+ if (o.allowedKinds.length === 0) throw new Error("allowedKinds must not be empty (omit it to allow every kind)")
385
+ const kinds: MemoryKind[] = []
386
+ for (const k of o.allowedKinds) {
387
+ if (typeof k !== "string" || !MEMORY_KINDS.includes(k as MemoryKind)) throw new Error(`allowedKinds: unknown kind ${JSON.stringify(k)} (known: ${MEMORY_KINDS.join(", ")})`)
388
+ if (!kinds.includes(k as MemoryKind)) kinds.push(k as MemoryKind)
389
+ }
390
+ policy.allowedKinds = kinds
391
+ }
392
+ if (o.retentionDays !== undefined && o.retentionDays !== null) {
393
+ const d = o.retentionDays
394
+ if (typeof d !== "number" || !Number.isInteger(d) || d < 1 || d > 36500) throw new Error("retentionDays must be a whole number of days between 1 and 36500")
395
+ policy.retentionDays = d
396
+ }
397
+ return policy
398
+ }