@100xprompt/brain 0.0.0-stage → 0.1.1
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.
- package/LICENSE +48 -0
- package/README.md +235 -2
- package/package.json +48 -4
- package/src/adapters/aider/index.ts +8 -0
- package/src/adapters/aider/reader.ts +129 -0
- package/src/adapters/aider/records.ts +220 -0
- package/src/adapters/aider/writer.ts +269 -0
- package/src/adapters/amazon-q-cli/index.ts +8 -0
- package/src/adapters/amazon-q-cli/reader.ts +84 -0
- package/src/adapters/amazon-q-cli/records.ts +191 -0
- package/src/adapters/amazon-q-cli/writer.ts +370 -0
- package/src/adapters/amazon-q-ide/index.ts +9 -0
- package/src/adapters/amazon-q-ide/reader.ts +131 -0
- package/src/adapters/amazon-q-ide/records.ts +141 -0
- package/src/adapters/amazon-q-ide/writer.ts +325 -0
- package/src/adapters/antigravity/index.ts +9 -0
- package/src/adapters/antigravity/proto.ts +194 -0
- package/src/adapters/antigravity/reader.ts +424 -0
- package/src/adapters/antigravity/records.ts +236 -0
- package/src/adapters/antigravity/writer.ts +463 -0
- package/src/adapters/auggie/index.ts +36 -0
- package/src/adapters/claude-code/index.ts +8 -0
- package/src/adapters/claude-code/reader.ts +639 -0
- package/src/adapters/claude-code/records.ts +88 -0
- package/src/adapters/claude-code/writer.ts +329 -0
- package/src/adapters/cline/index.ts +12 -0
- package/src/adapters/cline/reader.ts +432 -0
- package/src/adapters/cline/records.ts +151 -0
- package/src/adapters/cline/vscdb.ts +56 -0
- package/src/adapters/cline/writer.ts +656 -0
- package/src/adapters/cline/xml.ts +177 -0
- package/src/adapters/cline4/index.ts +9 -0
- package/src/adapters/cline4/reader.ts +179 -0
- package/src/adapters/cline4/records.ts +92 -0
- package/src/adapters/cline4/writer.ts +361 -0
- package/src/adapters/codebuff/index.ts +8 -0
- package/src/adapters/codebuff/project.ts +207 -0
- package/src/adapters/codebuff/reader.ts +215 -0
- package/src/adapters/codebuff/records.ts +173 -0
- package/src/adapters/codebuff/writer.ts +383 -0
- package/src/adapters/codex/index.ts +8 -0
- package/src/adapters/codex/native.ts +239 -0
- package/src/adapters/codex/reader.ts +747 -0
- package/src/adapters/codex/records.ts +114 -0
- package/src/adapters/codex/writer.ts +479 -0
- package/src/adapters/continue/index.ts +9 -0
- package/src/adapters/continue/reader.ts +68 -0
- package/src/adapters/continue/records.ts +418 -0
- package/src/adapters/continue/writer.ts +162 -0
- package/src/adapters/copilot-cli/index.ts +9 -0
- package/src/adapters/copilot-cli/reader.ts +186 -0
- package/src/adapters/copilot-cli/records.ts +241 -0
- package/src/adapters/copilot-cli/writer.ts +353 -0
- package/src/adapters/crush/index.ts +8 -0
- package/src/adapters/crush/reader.ts +180 -0
- package/src/adapters/crush/records.ts +169 -0
- package/src/adapters/crush/writer.ts +333 -0
- package/src/adapters/cursor-cli/index.ts +405 -0
- package/src/adapters/cursor-ide/agentkv.ts +95 -0
- package/src/adapters/cursor-ide/index.ts +501 -0
- package/src/adapters/cursor-ide/transcripts.ts +122 -0
- package/src/adapters/cursor-ide/workspaces.ts +119 -0
- package/src/adapters/cursor-ide/writer.ts +170 -0
- package/src/adapters/devin/index.ts +8 -0
- package/src/adapters/devin/reader.ts +305 -0
- package/src/adapters/devin/records.ts +277 -0
- package/src/adapters/devin/writer.ts +523 -0
- package/src/adapters/factory-droid/index.ts +12 -0
- package/src/adapters/factory-droid/reader.ts +184 -0
- package/src/adapters/factory-droid/records.ts +79 -0
- package/src/adapters/factory-droid/writer.ts +350 -0
- package/src/adapters/gemini-cli/exchanges.ts +228 -0
- package/src/adapters/gemini-cli/index.ts +396 -0
- package/src/adapters/gemini-cli/records.ts +521 -0
- package/src/adapters/gemini-cli/writer.ts +354 -0
- package/src/adapters/goose/index.ts +8 -0
- package/src/adapters/goose/reader.ts +138 -0
- package/src/adapters/goose/records.ts +199 -0
- package/src/adapters/goose/writer.ts +340 -0
- package/src/adapters/grok-cli/image.ts +44 -0
- package/src/adapters/grok-cli/index.ts +8 -0
- package/src/adapters/grok-cli/reader.ts +302 -0
- package/src/adapters/grok-cli/records.ts +172 -0
- package/src/adapters/grok-cli/writer.ts +329 -0
- package/src/adapters/hermes/index.ts +8 -0
- package/src/adapters/hermes/reader.ts +249 -0
- package/src/adapters/hermes/records.ts +285 -0
- package/src/adapters/hermes/writer.ts +452 -0
- package/src/adapters/iflow-cli/index.ts +72 -0
- package/src/adapters/iflow-cli/records.ts +215 -0
- package/src/adapters/iflow-cli/writer.ts +204 -0
- package/src/adapters/index.ts +112 -0
- package/src/adapters/kilo-cli/index.ts +21 -0
- package/src/adapters/kilo-code/index.ts +11 -0
- package/src/adapters/kilo-code/writer.ts +92 -0
- package/src/adapters/kimi/index.ts +9 -0
- package/src/adapters/kimi/reader.ts +92 -0
- package/src/adapters/kimi/records.ts +221 -0
- package/src/adapters/kimi/writer.ts +367 -0
- package/src/adapters/kiro-cli/index.ts +8 -0
- package/src/adapters/kiro-cli/reader.ts +87 -0
- package/src/adapters/kiro-cli/records.ts +260 -0
- package/src/adapters/kiro-cli/writer.ts +305 -0
- package/src/adapters/kiro-ide/index.ts +8 -0
- package/src/adapters/kiro-ide/writer.ts +52 -0
- package/src/adapters/letta/index.ts +8 -0
- package/src/adapters/letta/reader.ts +177 -0
- package/src/adapters/letta/records.ts +119 -0
- package/src/adapters/letta/writer.ts +413 -0
- package/src/adapters/mastracode/index.ts +8 -0
- package/src/adapters/mastracode/reader.ts +323 -0
- package/src/adapters/mastracode/records.ts +336 -0
- package/src/adapters/mastracode/writer.ts +385 -0
- package/src/adapters/mistral-vibe/index.ts +9 -0
- package/src/adapters/mistral-vibe/reader.ts +62 -0
- package/src/adapters/mistral-vibe/records.ts +104 -0
- package/src/adapters/mistral-vibe/writer.ts +359 -0
- package/src/adapters/muse/index.ts +10 -0
- package/src/adapters/muse/reader.ts +332 -0
- package/src/adapters/muse/records.ts +104 -0
- package/src/adapters/muse/writer.ts +387 -0
- package/src/adapters/omp/index.ts +13 -0
- package/src/adapters/omp/reader.ts +314 -0
- package/src/adapters/omp/records.ts +153 -0
- package/src/adapters/omp/writer.ts +216 -0
- package/src/adapters/opencode/ids.ts +52 -0
- package/src/adapters/opencode/index.ts +31 -0
- package/src/adapters/opencode/note.ts +8 -0
- package/src/adapters/opencode/reader.ts +452 -0
- package/src/adapters/opencode/tools.ts +174 -0
- package/src/adapters/opencode/v2.ts +367 -0
- package/src/adapters/opencode/writer.ts +379 -0
- package/src/adapters/openhands/index.ts +8 -0
- package/src/adapters/openhands/reader.ts +222 -0
- package/src/adapters/openhands/records.ts +136 -0
- package/src/adapters/openhands/writer.ts +404 -0
- package/src/adapters/pearai/index.ts +10 -0
- package/src/adapters/pi/index.ts +28 -0
- package/src/adapters/pi/reader.ts +261 -0
- package/src/adapters/pi/records.ts +99 -0
- package/src/adapters/pi/writer.ts +362 -0
- package/src/adapters/qoder-cli/index.ts +8 -0
- package/src/adapters/qoder-cli/reader.ts +83 -0
- package/src/adapters/qoder-cli/records.ts +45 -0
- package/src/adapters/qoder-cli/writer.ts +72 -0
- package/src/adapters/qwen-code/index.ts +84 -0
- package/src/adapters/qwen-code/records.ts +194 -0
- package/src/adapters/qwen-code/writer.ts +224 -0
- package/src/adapters/tabnine-cli/index.ts +8 -0
- package/src/adapters/tabnine-cli/reader.ts +106 -0
- package/src/adapters/tabnine-cli/records.ts +33 -0
- package/src/adapters/tabnine-cli/writer.ts +156 -0
- package/src/adapters/types.ts +73 -0
- package/src/adapters/void/index.ts +9 -0
- package/src/adapters/void/reader.ts +48 -0
- package/src/adapters/void/records.ts +285 -0
- package/src/adapters/void/writer.ts +93 -0
- package/src/adapters/vscode-copilot/index.ts +9 -0
- package/src/adapters/vscode-copilot/reader.ts +212 -0
- package/src/adapters/vscode-copilot/records.ts +388 -0
- package/src/adapters/vscode-copilot/shared.ts +68 -0
- package/src/adapters/vscode-copilot/writer.ts +546 -0
- package/src/adapters/zed/index.ts +8 -0
- package/src/adapters/zed/reader.ts +198 -0
- package/src/adapters/zed/records.ts +87 -0
- package/src/adapters/zed/writer.ts +341 -0
- package/src/brief-writers.ts +71 -0
- package/src/brief.ts +238 -0
- package/src/cli.ts +351 -0
- package/src/dashboard/cli.ts +148 -0
- package/src/dashboard/conversions.ts +170 -0
- package/src/dashboard/report.ts +261 -0
- package/src/dashboard/send.ts +213 -0
- package/src/dashboard/state.ts +95 -0
- package/src/fsutil.ts +133 -0
- package/src/gateway/common.ts +232 -0
- package/src/gateway/connect.ts +847 -0
- package/src/gateway/connectors/aider.ts +356 -0
- package/src/gateway/connectors/amazon-q-cli.ts +6 -0
- package/src/gateway/connectors/amazon-q-ide.ts +6 -0
- package/src/gateway/connectors/antigravity.ts +6 -0
- package/src/gateway/connectors/auggie.ts +6 -0
- package/src/gateway/connectors/cline-sdk.ts +85 -0
- package/src/gateway/connectors/cline.ts +34 -0
- package/src/gateway/connectors/codebuff.ts +6 -0
- package/src/gateway/connectors/continue.ts +579 -0
- package/src/gateway/connectors/copilot-cli.ts +138 -0
- package/src/gateway/connectors/crush.ts +313 -0
- package/src/gateway/connectors/cursor-cli.ts +6 -0
- package/src/gateway/connectors/cursor.ts +6 -0
- package/src/gateway/connectors/devin.ts +6 -0
- package/src/gateway/connectors/factory-droid.ts +308 -0
- package/src/gateway/connectors/goose.ts +79 -0
- package/src/gateway/connectors/grok-cli.ts +82 -0
- package/src/gateway/connectors/hermes.ts +230 -0
- package/src/gateway/connectors/iflow-cli.ts +50 -0
- package/src/gateway/connectors/index.ts +86 -0
- package/src/gateway/connectors/kilo-cli.ts +434 -0
- package/src/gateway/connectors/kilo-code.ts +32 -0
- package/src/gateway/connectors/kimi-code.ts +241 -0
- package/src/gateway/connectors/kiro-cli.ts +6 -0
- package/src/gateway/connectors/kiro-ide.ts +6 -0
- package/src/gateway/connectors/letta-code.ts +170 -0
- package/src/gateway/connectors/mastracode.ts +91 -0
- package/src/gateway/connectors/mistral-vibe.ts +495 -0
- package/src/gateway/connectors/muse.ts +27 -0
- package/src/gateway/connectors/omp.ts +279 -0
- package/src/gateway/connectors/openhands.ts +296 -0
- package/src/gateway/connectors/pearai.ts +52 -0
- package/src/gateway/connectors/pi.ts +357 -0
- package/src/gateway/connectors/qoder-cli.ts +6 -0
- package/src/gateway/connectors/qwen-code.ts +411 -0
- package/src/gateway/connectors/roo.ts +43 -0
- package/src/gateway/connectors/tabnine-cli.ts +6 -0
- package/src/gateway/connectors/void.ts +6 -0
- package/src/gateway/connectors/vscode-copilot.ts +286 -0
- package/src/gateway/connectors/windsurf.ts +6 -0
- package/src/gateway/connectors/zed.ts +29 -0
- package/src/gateway/signin.ts +323 -0
- package/src/handoff.ts +104 -0
- package/src/hooks.ts +455 -0
- package/src/memory/audit.ts +514 -0
- package/src/memory/blobs.ts +94 -0
- package/src/memory/cli.ts +117 -0
- package/src/memory/distill.ts +381 -0
- package/src/memory/externalize.ts +435 -0
- package/src/memory/lookups.ts +70 -0
- package/src/memory/mcp-install.ts +401 -0
- package/src/memory/mcp-server.ts +296 -0
- package/src/memory/prepare.ts +179 -0
- package/src/memory/preview.ts +154 -0
- package/src/memory/project.ts +35 -0
- package/src/memory/redact.ts +72 -0
- package/src/memory/relevance.ts +187 -0
- package/src/memory/repomap.ts +516 -0
- package/src/memory/store.ts +270 -0
- package/src/memory/sync.ts +166 -0
- package/src/memory/tidy.ts +193 -0
- package/src/memory/types.ts +118 -0
- package/src/org/admin-ops.ts +96 -0
- package/src/org/auth.ts +112 -0
- package/src/org/backup.ts +115 -0
- package/src/org/cli.ts +438 -0
- package/src/org/client.ts +404 -0
- package/src/org/config.ts +44 -0
- package/src/org/crypto.ts +537 -0
- package/src/org/db.ts +435 -0
- package/src/org/graph.ts +318 -0
- package/src/org/oidc.ts +435 -0
- package/src/org/policy.ts +398 -0
- package/src/org/server.ts +920 -0
- package/src/org/sso.ts +316 -0
- package/src/org/tls.ts +66 -0
- package/src/org/types.ts +193 -0
- package/src/self.ts +68 -0
- package/src/session/attachments.ts +53 -0
- package/src/session/peekcache.ts +50 -0
- package/src/session/types.ts +104 -0
- package/src/session/util.ts +130 -0
- 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
|
+
}
|