@orquentra/codebuddy 2.6.0 → 2.7.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.
- package/CLAUDE_CODEX.md +6 -4
- package/WORKFLOW.md +5 -5
- package/dist/{chunk-U4HAJLIB.js → chunk-3QGW53GD.js} +16 -3
- package/dist/chunk-3QGW53GD.js.map +1 -0
- package/dist/{chunk-L5AYZC3F.js → chunk-5EDGAZQN.js} +438 -68
- package/dist/chunk-5EDGAZQN.js.map +1 -0
- package/dist/chunk-5UH3BEQR.js +32 -0
- package/dist/chunk-5UH3BEQR.js.map +1 -0
- package/dist/{chunk-JFDRBPYF.js → chunk-6JCKMIQY.js} +2 -2
- package/dist/{chunk-UN57FKQX.js → chunk-AK2UAE32.js} +152 -862
- package/dist/chunk-AK2UAE32.js.map +1 -0
- package/dist/{chunk-GDJH7XJC.js → chunk-AL6VBTNA.js} +77 -3
- package/dist/chunk-AL6VBTNA.js.map +1 -0
- package/dist/{chunk-WHZSVY2L.js → chunk-AQHPEJR6.js} +94 -23
- package/dist/chunk-AQHPEJR6.js.map +1 -0
- package/dist/{chunk-PCE4STAY.js → chunk-BQM7FJ7D.js} +6 -6
- package/dist/chunk-BQM7FJ7D.js.map +1 -0
- package/dist/{chunk-XBJQKCLU.js → chunk-CAEPHLHC.js} +13 -9
- package/dist/chunk-CAEPHLHC.js.map +1 -0
- package/dist/chunk-CG5B3LYG.js +66 -0
- package/dist/chunk-CG5B3LYG.js.map +1 -0
- package/dist/chunk-CSRD5OQA.js +79 -0
- package/dist/chunk-CSRD5OQA.js.map +1 -0
- package/dist/{chunk-IHQM2ZDV.js → chunk-DFV2SK6C.js} +1 -1
- package/dist/{chunk-IHQM2ZDV.js.map → chunk-DFV2SK6C.js.map} +1 -1
- package/dist/chunk-EEUFU7KV.js +1634 -0
- package/dist/chunk-EEUFU7KV.js.map +1 -0
- package/dist/{chunk-UFZD3JAA.js → chunk-ESZXCZIN.js} +3 -3
- package/dist/{chunk-PYLH4MH5.js → chunk-FJOTP632.js} +1 -1
- package/dist/{chunk-SYWJG5NA.js → chunk-GKNYMNFM.js} +17 -7
- package/dist/{chunk-SYWJG5NA.js.map → chunk-GKNYMNFM.js.map} +1 -1
- package/dist/{chunk-JPHJBCZB.js → chunk-HPOR6YXA.js} +3 -2
- package/dist/chunk-HPOR6YXA.js.map +1 -0
- package/dist/chunk-JUAFCLRB.js +1014 -0
- package/dist/chunk-JUAFCLRB.js.map +1 -0
- package/dist/{chunk-776EQXDV.js → chunk-KNR2P2UY.js} +110 -83
- package/dist/chunk-KNR2P2UY.js.map +1 -0
- package/dist/{chunk-N34XHWBA.js → chunk-LCSBL5TT.js} +24 -11
- package/dist/chunk-LCSBL5TT.js.map +1 -0
- package/dist/{chunk-QDBDYCM2.js → chunk-MSUAVR3R.js} +76 -11
- package/dist/chunk-MSUAVR3R.js.map +1 -0
- package/dist/{chunk-6MKEIDX5.js → chunk-SFMIMLXR.js} +2 -2
- package/dist/chunk-TGFTKWOR.js +16 -0
- package/dist/chunk-TGFTKWOR.js.map +1 -0
- package/dist/chunk-TUR6YKEE.js +55 -0
- package/dist/chunk-TUR6YKEE.js.map +1 -0
- package/dist/chunk-Y7D7PUBE.js +321 -0
- package/dist/chunk-Y7D7PUBE.js.map +1 -0
- package/dist/{chunk-VAKBJV26.js → chunk-YJIA2MFL.js} +918 -2238
- package/dist/chunk-YJIA2MFL.js.map +1 -0
- package/dist/cli/index.js +14 -3
- package/dist/cli/index.js.map +1 -1
- package/dist/{cloud-7WM3YLI4.js → cloud-LXZUR3U2.js} +3 -3
- package/dist/{codebuddy-DM-i0Fu5.d.ts → codebuddy-DO7ZhoWn.d.ts} +37 -3
- package/dist/config-GEZFLXK6.js +12 -0
- package/dist/docker-autostart-KDME7TVC.js +12 -0
- package/dist/index.d.ts +48 -3
- package/dist/index.js +32 -25
- package/dist/langgraph/index.d.ts +1 -1
- package/dist/lean-command-CWJAVQ5G.js +18 -0
- package/dist/local-ST4WMRCG.js +17 -0
- package/dist/{main-HFHLGID4.js → main-AHNGHC4L.js} +327 -329
- package/dist/main-AHNGHC4L.js.map +1 -0
- package/dist/{memory-4RGVLPKG.js → memory-TGHVTBME.js} +9 -8
- package/dist/memory-TGHVTBME.js.map +1 -0
- package/dist/operations-7BTAAUHF.js +26 -0
- package/dist/postgres-docker-QQZQ2ABK.js +16 -0
- package/dist/pretool-command-3SXQSMAO.js +13 -0
- package/dist/{rerank-3P3C2UHN.js → rerank-IQAEWLQR.js} +6 -5
- package/dist/rerank-IQAEWLQR.js.map +1 -0
- package/dist/runtime-E7JQZ2BZ.js +46 -0
- package/dist/runtime-E7JQZ2BZ.js.map +1 -0
- package/dist/stdio-HOV4P3DQ.js +12 -0
- package/dist/stdio-HOV4P3DQ.js.map +1 -0
- package/dist/summaries-XV3JGGCC.js +20 -0
- package/dist/summaries-XV3JGGCC.js.map +1 -0
- package/dist/{zvec-MAPSJQSR.js → zvec-KP4KNB7Y.js} +10 -3
- package/dist/zvec-KP4KNB7Y.js.map +1 -0
- package/package.json +7 -10
- package/src/db/migrations/0006_fact_idempotency_key.sql +2 -0
- package/src/db/migrations/meta/0006_snapshot.json +1258 -0
- package/src/db/migrations/meta/_journal.json +7 -0
- package/dist/chunk-776EQXDV.js.map +0 -1
- package/dist/chunk-GDJH7XJC.js.map +0 -1
- package/dist/chunk-JPHJBCZB.js.map +0 -1
- package/dist/chunk-L5AYZC3F.js.map +0 -1
- package/dist/chunk-N34XHWBA.js.map +0 -1
- package/dist/chunk-PCE4STAY.js.map +0 -1
- package/dist/chunk-PQK3GKZH.js +0 -98
- package/dist/chunk-PQK3GKZH.js.map +0 -1
- package/dist/chunk-QDBDYCM2.js.map +0 -1
- package/dist/chunk-U4HAJLIB.js.map +0 -1
- package/dist/chunk-UN57FKQX.js.map +0 -1
- package/dist/chunk-VAKBJV26.js.map +0 -1
- package/dist/chunk-WHZSVY2L.js.map +0 -1
- package/dist/chunk-XBJQKCLU.js.map +0 -1
- package/dist/config-KFEHLV6W.js +0 -11
- package/dist/lean-command-C5YPFKHP.js +0 -15
- package/dist/local-OTOY56B4.js +0 -13
- package/dist/main-HFHLGID4.js.map +0 -1
- package/dist/memory-4RGVLPKG.js.map +0 -1
- package/dist/operations-LOBAQRLO.js +0 -24
- package/dist/pretool-command-MQQ2UNQM.js +0 -10
- package/dist/rerank-3P3C2UHN.js.map +0 -1
- package/dist/summaries-ZBV5IN7Y.js +0 -18
- /package/dist/{chunk-JFDRBPYF.js.map → chunk-6JCKMIQY.js.map} +0 -0
- /package/dist/{chunk-UFZD3JAA.js.map → chunk-ESZXCZIN.js.map} +0 -0
- /package/dist/{chunk-PYLH4MH5.js.map → chunk-FJOTP632.js.map} +0 -0
- /package/dist/{chunk-6MKEIDX5.js.map → chunk-SFMIMLXR.js.map} +0 -0
- /package/dist/{cloud-7WM3YLI4.js.map → cloud-LXZUR3U2.js.map} +0 -0
- /package/dist/{config-KFEHLV6W.js.map → config-GEZFLXK6.js.map} +0 -0
- /package/dist/{lean-command-C5YPFKHP.js.map → docker-autostart-KDME7TVC.js.map} +0 -0
- /package/dist/{local-OTOY56B4.js.map → lean-command-CWJAVQ5G.js.map} +0 -0
- /package/dist/{operations-LOBAQRLO.js.map → local-ST4WMRCG.js.map} +0 -0
- /package/dist/{pretool-command-MQQ2UNQM.js.map → operations-7BTAAUHF.js.map} +0 -0
- /package/dist/{summaries-ZBV5IN7Y.js.map → postgres-docker-QQZQ2ABK.js.map} +0 -0
- /package/dist/{zvec-MAPSJQSR.js.map → pretool-command-3SXQSMAO.js.map} +0 -0
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
// src/
|
|
1
|
+
// src/core/sensitive.ts
|
|
2
|
+
import { readdir, readFile, stat } from "fs/promises";
|
|
3
|
+
import { join } from "path";
|
|
2
4
|
var PATTERNS = [
|
|
3
5
|
{ label: "private-key", regex: /-----BEGIN(?: [A-Z0-9]+)* PRIVATE KEY-----/ },
|
|
4
6
|
{ label: "aws-access-key", regex: /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/ },
|
|
@@ -107,6 +109,74 @@ function scanFields(fields) {
|
|
|
107
109
|
}
|
|
108
110
|
return { sensitive: reasons.length > 0, reasons };
|
|
109
111
|
}
|
|
112
|
+
var CREDENTIAL_PATH = /(?:^|\/)(?:\.env(?!\.(?:example|sample|template|dist)$)(?:\.[^/]*)?|[^/]*\.(?:pem|key|p12|pfx|jks|keystore)|id_(?:rsa|dsa|ecdsa|ed25519)(?:\.[^/]*)?|credentials(?:\.[^/]*)?|\.npmrc|\.pypirc|\.netrc|\.pgpass|\.git-credentials|[^/]*secrets?\.(?:ya?ml|json|toml|ini|env))$/i;
|
|
113
|
+
function isCredentialPath(path) {
|
|
114
|
+
return CREDENTIAL_PATH.test(path.replace(/\\/g, "/"));
|
|
115
|
+
}
|
|
116
|
+
var LINE_PATTERNS = PATTERNS.filter(
|
|
117
|
+
({ label }) => label !== "credential-assignment" && label !== "email"
|
|
118
|
+
).map(({ regex }) => regex);
|
|
119
|
+
var SECRET_KEY = /(?:api[_-]?key|secret|token|passw(?:or)?d|pwd|access[_-]?key|private[_-]?key|credential)s?\w{0,40}["']?\s*[:=]\s*/gi;
|
|
120
|
+
function assignsSecret(line) {
|
|
121
|
+
for (const match of line.matchAll(SECRET_KEY)) {
|
|
122
|
+
const rest = line.slice((match.index ?? 0) + match[0].length);
|
|
123
|
+
const quote = rest[0];
|
|
124
|
+
if (quote === '"' || quote === "'" || quote === "`") {
|
|
125
|
+
const end = rest.indexOf(quote, 1);
|
|
126
|
+
if ((end < 0 ? rest.length - 1 : end - 1) >= 6) return true;
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
const value = /^[^\s"',;)]+/.exec(rest)?.[0] ?? "";
|
|
130
|
+
if (value.length < 6) continue;
|
|
131
|
+
if (/[([]/.test(value)) continue;
|
|
132
|
+
if (/^[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*$/.test(value) && !/\d/.test(value)) continue;
|
|
133
|
+
if (/^[\d_.]+$/.test(value)) continue;
|
|
134
|
+
return true;
|
|
135
|
+
}
|
|
136
|
+
return false;
|
|
137
|
+
}
|
|
138
|
+
var MAX_LINE_CHARS = 4e3;
|
|
139
|
+
var KEY_BLOCK_START = /-----BEGIN(?: [A-Z0-9]+)* PRIVATE KEY-----/;
|
|
140
|
+
var KEY_BLOCK_END = /-----END(?: [A-Z0-9]+)* PRIVATE KEY-----/;
|
|
141
|
+
var REDACTED_LINE = "[REDACTED: possible secret]";
|
|
142
|
+
function redactSourceText(text, knownValues = [], diff = false) {
|
|
143
|
+
let inKey = false;
|
|
144
|
+
return text.split("\n").map((line) => {
|
|
145
|
+
const start = KEY_BLOCK_START.test(line);
|
|
146
|
+
const secret = inKey || start || line.length > MAX_LINE_CHARS || assignsSecret(line) || LINE_PATTERNS.some((regex) => regex.test(line)) || // Paths split at "/" so `src/features/Auth2/LoginForm.tsx` is not one token.
|
|
147
|
+
hasHighEntropyToken(line.replace(/\//g, " ")) || knownValues.some((value) => line.includes(value));
|
|
148
|
+
if (start) inKey = true;
|
|
149
|
+
if (KEY_BLOCK_END.test(line)) inKey = false;
|
|
150
|
+
if (!secret) return line;
|
|
151
|
+
const marker = diff && /^[+\- ]/.test(line) && !/^(?:\+\+\+|---) /.test(line) ? line[0] : "";
|
|
152
|
+
return marker + REDACTED_LINE;
|
|
153
|
+
}).join("\n");
|
|
154
|
+
}
|
|
155
|
+
async function loadKnownSecretValues(repositoryRoot) {
|
|
156
|
+
const values = /* @__PURE__ */ new Set();
|
|
157
|
+
let names = [];
|
|
158
|
+
try {
|
|
159
|
+
names = await readdir(repositoryRoot);
|
|
160
|
+
} catch {
|
|
161
|
+
return [];
|
|
162
|
+
}
|
|
163
|
+
for (const name of names.filter(isCredentialPath)) {
|
|
164
|
+
try {
|
|
165
|
+
const path = join(repositoryRoot, name);
|
|
166
|
+
const info = await stat(path);
|
|
167
|
+
if (!info.isFile() || info.size > 64e3) continue;
|
|
168
|
+
const text = await readFile(path, "utf8");
|
|
169
|
+
for (const line of text.split(/\r?\n/)) {
|
|
170
|
+
const value = /[:=]\s*["']?([^\s"',;]{8,})/.exec(line)?.[1];
|
|
171
|
+
if (value && (assignsSecret(line) || scanSensitive(value).sensitive || /\d/.test(value))) {
|
|
172
|
+
values.add(value);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
} catch {
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return [...values];
|
|
179
|
+
}
|
|
110
180
|
|
|
111
181
|
export {
|
|
112
182
|
scanSensitive,
|
|
@@ -114,6 +184,10 @@ export {
|
|
|
114
184
|
redactSensitive,
|
|
115
185
|
claimText,
|
|
116
186
|
redactPersisted,
|
|
117
|
-
scanFields
|
|
187
|
+
scanFields,
|
|
188
|
+
isCredentialPath,
|
|
189
|
+
REDACTED_LINE,
|
|
190
|
+
redactSourceText,
|
|
191
|
+
loadKnownSecretValues
|
|
118
192
|
};
|
|
119
|
-
//# sourceMappingURL=chunk-
|
|
193
|
+
//# sourceMappingURL=chunk-AL6VBTNA.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/core/sensitive.ts"],"sourcesContent":["import { readdir, readFile, stat } from \"node:fs/promises\";\nimport { join } from \"node:path\";\n\n/**\n * Sensitive-content scan. Secrets and personal data must never be auto-saved\n * as durable memory — a matched candidate is forced into the review queue,\n * flagged, so a human decides. This is a conservative detector: false positives\n * (an extra review item) are acceptable; false negatives (a leaked key in\n * committed Markdown) are not.\n *\n * The scan only ever *routes to review* — it never blocks a turn, deletes\n * content, or rejects on its own. Adding a reason at most moves a candidate from\n * \"auto-save\" to \"human decides\".\n */\n\nexport type SensitivityResult = {\n sensitive: boolean;\n reasons: string[];\n};\n\nconst PATTERNS: Array<{ label: string; regex: RegExp }> = [\n { label: \"private-key\", regex: /-----BEGIN(?: [A-Z0-9]+)* PRIVATE KEY-----/ },\n { label: \"aws-access-key\", regex: /\\b(?:AKIA|ASIA)[0-9A-Z]{16}\\b/ },\n { label: \"hf-token\", regex: /\\bhf_[A-Za-z0-9]{16,}\\b/ },\n { label: \"github-token\", regex: /\\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{20,}\\b/ },\n // GitHub fine-grained personal access tokens.\n {\n label: \"github-fine-grained-pat\",\n regex: /\\bgithub_pat_[A-Za-z0-9_]{22,255}_[A-Za-z0-9]{22,255}\\b/,\n },\n { label: \"npm-token\", regex: /\\bnpm_[A-Za-z0-9]{36}\\b/ },\n { label: \"gitlab-token\", regex: /\\bglpat-[A-Za-z0-9_-]{20,}\\b/ },\n { label: \"slack-token\", regex: /\\bxox[baprs]-[A-Za-z0-9-]{10,}\\b/ },\n { label: \"bearer-token\", regex: /\\bBearer\\s+[A-Za-z0-9._-]{12,}\\b/ },\n // Vendor API keys with distinctive, low-false-positive prefixes.\n { label: \"anthropic-key\", regex: /\\bsk-ant-[A-Za-z0-9_-]{20,}\\b/ },\n { label: \"openai-key\", regex: /\\bsk-(?!ant-)[A-Za-z0-9_-]{20,}\\b/ },\n { label: \"google-api-key\", regex: /\\bAIza[0-9A-Za-z_-]{35}\\b/ },\n { label: \"stripe-key\", regex: /\\b(?:sk|rk|pk)_(?:live|test)_[A-Za-z0-9]{16,}\\b/ },\n // A JWT: three base64url segments. Common in auth-related pastes.\n { label: \"jwt\", regex: /\\beyJ[A-Za-z0-9_-]{10,}\\.[A-Za-z0-9_-]{10,}\\.[A-Za-z0-9_-]{10,}\\b/ },\n // Credentials embedded in a connection URL: scheme://user:password@host.\n {\n label: \"connection-string-credentials\",\n regex: /\\b[a-z][a-z0-9+.-]*:\\/\\/[^\\s:/@]+:[^\\s:/@]+@/i,\n },\n {\n // Catches `password: x`, `api_key = x`, and the prose form `password is x`.\n // Deliberately eager — an over-flagged review item is cheaper than a leak.\n label: \"credential-assignment\",\n regex:\n /\\b(?:api[_-]?key|secret|token|password|passwd|pwd|access[_-]?key|authorization|auth[_-]?token|credential[s]?)\\b\\s*(?:[:=]|\\bis\\b|\\bwas\\b|\\bequals\\b)\\s*\\S{6,}/i,\n },\n // HTTP Basic-auth style inline credentials outside a URL scheme\n // (`Authorization: Basic dXNlcjpwYXNz`).\n {\n label: \"basic-auth-header\",\n regex: /\\bAuthorization\\s*[:=]\\s*Basic\\s+[A-Za-z0-9+/=]{8,}/i,\n },\n { label: \"email\", regex: /\\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}\\b/ },\n];\n\n/**\n * Deterministic, review-oriented high-entropy heuristic. Flags a token that\n * looks like a random secret — long, mixed character classes, high Shannon\n * entropy — that the prefix-based patterns above wouldn't catch. Tuned to stay\n * quiet on ordinary code and prose: identifiers are usually low-entropy words\n * (`camelCase`, `snake_case`), and hex hashes / SHAs lack the required mix of\n * upper, lower, and digit. Never a hard block; at most one extra review item.\n * OpenCode session/message/part ids (`ses_` + 12 hex + 14 random base62) are\n * host identifiers recorded as provenance, not secrets, so they are skipped;\n * otherwise every OpenCode capture would be queued for review.\n */\nconst OPENCODE_ID = /^(?:ses|msg|prt)_[0-9a-f]{12}[0-9A-Za-z]{14}$/;\n\nexport function hasHighEntropyToken(text: string): boolean {\n for (const token of text.split(/[^A-Za-z0-9+/_=-]+/)) {\n if (token.length < 24 || token.length > 200 || OPENCODE_ID.test(token)) continue;\n if (!/[a-z]/.test(token) || !/[A-Z]/.test(token) || !/[0-9]/.test(token)) continue;\n if (shannonEntropy(token) >= 3.5) return true;\n }\n return false;\n}\n\nfunction shannonEntropy(value: string): number {\n const counts = new Map<string, number>();\n for (const char of value) counts.set(char, (counts.get(char) ?? 0) + 1);\n let entropy = 0;\n for (const count of counts.values()) {\n const p = count / value.length;\n entropy -= p * Math.log2(p);\n }\n return entropy;\n}\n\nexport function scanSensitive(text: string): SensitivityResult {\n const reasons: string[] = [];\n for (const { label, regex } of PATTERNS) {\n if (regex.test(text) && !reasons.includes(label)) reasons.push(label);\n }\n if (hasHighEntropyToken(text)) reasons.push(\"high-entropy-string\");\n return { sensitive: reasons.length > 0, reasons };\n}\n\nexport const REDACTED = \"[REDACTED SENSITIVE CONTENT]\";\n\n/** Whole-field redaction: a field that scans sensitive is replaced, never partially kept. */\nexport function redactSensitive(text: string): string {\n return scanSensitive(text).sensitive ? REDACTED : text;\n}\n\n/**\n * The claim: fields that together state one memory. A credential can be split\n * across them (\"password\" / \"is\" / \"hunter2secret\"), so they are scanned\n * jointly and redacted as a unit.\n */\nexport const CLAIM_KEYS = [\"subject\", \"predicate\", \"object\", \"content\"] as const;\n\n/** Independent free-text provenance fields (string or string[]), redacted one by one. */\nexport const PROVENANCE_TEXT_KEYS = [\n \"paths\",\n \"verification\",\n \"reason\",\n \"createdByAgent\",\n \"sourcePlanId\",\n \"sourceSessionId\",\n \"introducedBy\",\n] as const;\n\n/** The claim fields joined in reading order, for a joint scan. */\nexport function claimText(record: Partial<Record<(typeof CLAIM_KEYS)[number], unknown>>): string {\n return CLAIM_KEYS.map((key) => record[key])\n .filter((value): value is string => typeof value === \"string\")\n .join(\" \");\n}\n\n/**\n * Persistence-boundary redaction: returns a copy of `record` with every\n * free-text field redacted. If any claim field, or the joined claim, scans\n * sensitive, ALL claim fields are replaced (a sibling may hold the value half\n * of the credential). Provenance fields are redacted individually. Idempotent,\n * so re-persisting (e.g. approving a queued item) never re-introduces a raw\n * value. Structural fields (ids, fingerprint, namespace, timestamps, enums)\n * are left alone.\n */\nexport function redactPersisted<T extends object>(record: T): T {\n const out = { ...record } as Record<string, unknown>;\n const present = CLAIM_KEYS.filter((key) => typeof out[key] === \"string\");\n const claimSensitive =\n present.length > 0 &&\n (scanSensitive(claimText(out)).sensitive ||\n present.some((key) => scanSensitive(out[key] as string).sensitive));\n if (claimSensitive) for (const key of present) out[key] = REDACTED;\n for (const key of PROVENANCE_TEXT_KEYS) {\n const value = out[key];\n if (typeof value === \"string\") out[key] = redactSensitive(value);\n else if (Array.isArray(value)) {\n out[key] = value.map((item) => (typeof item === \"string\" ? redactSensitive(item) : item));\n }\n }\n return out as T;\n}\n\n/** Scan several surfaces separately and merge the reason labels (never the matched text). */\nexport function scanFields(fields: Array<string | null | undefined>): SensitivityResult {\n const reasons: string[] = [];\n for (const field of fields) {\n if (!field) continue;\n for (const reason of scanSensitive(field).reasons) {\n if (!reasons.includes(reason)) reasons.push(reason);\n }\n }\n return { sensitive: reasons.length > 0, reasons };\n}\n\n/**\n * Paths whose contents are credentials by convention. Context output names\n * them (so an agent knows they changed) but never reads or shows their bodies.\n * Templates (`.env.example`, `.env.sample`, `.env.template`) are ordinary text.\n */\nconst CREDENTIAL_PATH =\n /(?:^|\\/)(?:\\.env(?!\\.(?:example|sample|template|dist)$)(?:\\.[^/]*)?|[^/]*\\.(?:pem|key|p12|pfx|jks|keystore)|id_(?:rsa|dsa|ecdsa|ed25519)(?:\\.[^/]*)?|credentials(?:\\.[^/]*)?|\\.npmrc|\\.pypirc|\\.netrc|\\.pgpass|\\.git-credentials|[^/]*secrets?\\.(?:ya?ml|json|toml|ini|env))$/i;\n\nexport function isCredentialPath(path: string): boolean {\n return CREDENTIAL_PATH.test(path.replace(/\\\\/g, \"/\"));\n}\n\n/** Prefix patterns from PATTERNS that are precise enough to act on per line. */\nconst LINE_PATTERNS = PATTERNS.filter(\n ({ label }) => label !== \"credential-assignment\" && label !== \"email\",\n).map(({ regex }) => regex);\n\n/** A credential-named key and its separator; `\\w{0,40}` keeps the scan linear. */\nconst SECRET_KEY =\n /(?:api[_-]?key|secret|token|passw(?:or)?d|pwd|access[_-]?key|private[_-]?key|credential)s?\\w{0,40}[\"']?\\s*[:=]\\s*/gi;\n\n/**\n * A credential-named key assigned a literal: `db_password: p@ss.w0rd`,\n * `API_TOKEN = \"x\"`, `\"token\": \"x\"`, `SECRET=x9`. Code is not a value: calls and\n * indexing (`getToken()`, `os.environ[\"X\"]`), member paths without digits\n * (`req.body.password`), bare words such as type annotations\n * (`token: string`) and plain numbers (`TOKEN_LIMIT = 4096`).\n * ponytail: an unquoted all-letter password (`password: hunter`) is missed; a\n * quoted literal under a secret-named key (`secretName = 'my-app'`) is masked.\n */\nfunction assignsSecret(line: string): boolean {\n for (const match of line.matchAll(SECRET_KEY)) {\n const rest = line.slice((match.index ?? 0) + match[0].length);\n const quote = rest[0];\n if (quote === '\"' || quote === \"'\" || quote === \"`\") {\n const end = rest.indexOf(quote, 1);\n if ((end < 0 ? rest.length - 1 : end - 1) >= 6) return true;\n continue;\n }\n const value = /^[^\\s\"',;)]+/.exec(rest)?.[0] ?? \"\";\n if (value.length < 6) continue;\n if (/[([]/.test(value)) continue;\n if (/^[A-Za-z_$][\\w$]*(?:\\.[A-Za-z_$][\\w$]*)*$/.test(value) && !/\\d/.test(value)) continue;\n if (/^[\\d_.]+$/.test(value)) continue;\n return true;\n }\n return false;\n}\n\n/** Longer lines (minified bundles, data blobs) are never shown: too long to scan or read. */\nconst MAX_LINE_CHARS = 4_000;\nconst KEY_BLOCK_START = /-----BEGIN(?: [A-Z0-9]+)* PRIVATE KEY-----/;\nconst KEY_BLOCK_END = /-----END(?: [A-Z0-9]+)* PRIVATE KEY-----/;\nexport const REDACTED_LINE = \"[REDACTED: possible secret]\";\n\n/**\n * Line-level redaction for file text sent to an agent (diffs, source spans,\n * signatures). A line that looks like it carries a secret, a line inside a\n * private-key block, or a line holding one of `knownValues` is replaced, so\n * ordinary code around it stays readable. A diff line keeps its `+`/`-`/` `\n * marker when `diff` is set.\n */\nexport function redactSourceText(\n text: string,\n knownValues: readonly string[] = [],\n diff = false,\n): string {\n let inKey = false;\n return text\n .split(\"\\n\")\n .map((line) => {\n const start = KEY_BLOCK_START.test(line);\n const secret =\n inKey ||\n start ||\n line.length > MAX_LINE_CHARS ||\n assignsSecret(line) ||\n LINE_PATTERNS.some((regex) => regex.test(line)) ||\n // Paths split at \"/\" so `src/features/Auth2/LoginForm.tsx` is not one token.\n hasHighEntropyToken(line.replace(/\\//g, \" \")) ||\n knownValues.some((value) => line.includes(value));\n if (start) inKey = true;\n if (KEY_BLOCK_END.test(line)) inKey = false;\n if (!secret) return line;\n const marker = diff && /^[+\\- ]/.test(line) && !/^(?:\\+\\+\\+|---) /.test(line) ? line[0] : \"\";\n return marker + REDACTED_LINE;\n })\n .join(\"\\n\");\n}\n\n/**\n * Values assigned in root-level credential files (`.env`, `credentials.json`, …),\n * so a key copied from `.env` into a comment elsewhere is still masked, the way\n * CI masks known secrets. Read locally only; the values are never output.\n * ponytail: root-level files only; scan nested credential files if leaks show up there.\n */\nexport async function loadKnownSecretValues(repositoryRoot: string): Promise<string[]> {\n const values = new Set<string>();\n let names: string[] = [];\n try {\n names = await readdir(repositoryRoot);\n } catch {\n return [];\n }\n for (const name of names.filter(isCredentialPath)) {\n try {\n const path = join(repositoryRoot, name);\n const info = await stat(path);\n if (!info.isFile() || info.size > 64_000) continue;\n const text = await readFile(path, \"utf8\");\n // Only values that look like secrets: `NODE_ENV=production` must not\n // mask every line that says \"production\".\n for (const line of text.split(/\\r?\\n/)) {\n const value = /[:=]\\s*[\"']?([^\\s\"',;]{8,})/.exec(line)?.[1];\n if (value && (assignsSecret(line) || scanSensitive(value).sensitive || /\\d/.test(value))) {\n values.add(value);\n }\n }\n } catch {\n // Unreadable: nothing to mask from it.\n }\n }\n return [...values];\n}\n"],"mappings":"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","names":[]}
|
|
@@ -2,11 +2,17 @@ import {
|
|
|
2
2
|
failSoft,
|
|
3
3
|
readHookInput,
|
|
4
4
|
writeStdout
|
|
5
|
-
} from "./chunk-
|
|
5
|
+
} from "./chunk-FJOTP632.js";
|
|
6
|
+
import {
|
|
7
|
+
recordTrace
|
|
8
|
+
} from "./chunk-TUR6YKEE.js";
|
|
9
|
+
import {
|
|
10
|
+
hookRoot
|
|
11
|
+
} from "./chunk-TGFTKWOR.js";
|
|
6
12
|
import {
|
|
7
13
|
extractSymbolTable,
|
|
8
14
|
supportsSymbols
|
|
9
|
-
} from "./chunk-
|
|
15
|
+
} from "./chunk-Y7D7PUBE.js";
|
|
10
16
|
|
|
11
17
|
// src/hooks/pretool.ts
|
|
12
18
|
import { open, stat } from "fs/promises";
|
|
@@ -39,6 +45,7 @@ async function decidePreTool(input, env = process.env) {
|
|
|
39
45
|
const toolInput = input.tool_input ?? {};
|
|
40
46
|
if (input.tool_name === "Grep") return capGrep(toolInput);
|
|
41
47
|
if (input.tool_name === "Read") return guardRead(resolve(input.cwd ?? process.cwd()), toolInput);
|
|
48
|
+
if (input.tool_name === "Bash") return guardBash(resolve(input.cwd ?? process.cwd()), toolInput);
|
|
42
49
|
return null;
|
|
43
50
|
}
|
|
44
51
|
function capGrep(toolInput) {
|
|
@@ -50,10 +57,7 @@ function capGrep(toolInput) {
|
|
|
50
57
|
}
|
|
51
58
|
};
|
|
52
59
|
}
|
|
53
|
-
async function
|
|
54
|
-
if (toolInput.offset !== void 0 || toolInput.limit !== void 0) return null;
|
|
55
|
-
const filePath = toolInput.file_path;
|
|
56
|
-
if (typeof filePath !== "string" || !filePath) return null;
|
|
60
|
+
async function largeFile(root, filePath) {
|
|
57
61
|
const absolute = isAbsolute(filePath) ? resolve(filePath) : resolve(root, filePath);
|
|
58
62
|
const rel = relative(root, absolute);
|
|
59
63
|
if (rel === "" || rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel)) return null;
|
|
@@ -73,21 +77,71 @@ async function guardRead(root, toolInput) {
|
|
|
73
77
|
if (lines.at(-1) === "") lines.pop();
|
|
74
78
|
if (lines.length <= TOOL_GUARD.readLines && size <= TOOL_GUARD.readBytes) return null;
|
|
75
79
|
const path = rel.replace(/\\/g, "/");
|
|
76
|
-
|
|
80
|
+
return { path, lines: lines.length, size, outline: outlineOf(path, text, lines) };
|
|
81
|
+
}
|
|
82
|
+
async function guardRead(root, toolInput) {
|
|
83
|
+
if (toolInput.offset !== void 0 || toolInput.limit !== void 0) return null;
|
|
84
|
+
const filePath = toolInput.file_path;
|
|
85
|
+
if (typeof filePath !== "string" || !filePath) return null;
|
|
86
|
+
const file = await largeFile(root, filePath);
|
|
87
|
+
if (!file) return null;
|
|
88
|
+
return deny(file, [
|
|
89
|
+
"Read only the part you need with offset and limit (offset = first line, limit = line count).",
|
|
90
|
+
"To find specific text, use Grep with output_mode content and -n instead of reading the file.",
|
|
91
|
+
`To read the whole file anyway, call Read again with limit: ${file.lines}.`
|
|
92
|
+
]);
|
|
93
|
+
}
|
|
94
|
+
function deny(file, advice) {
|
|
77
95
|
return {
|
|
78
96
|
hookSpecificOutput: {
|
|
79
97
|
hookEventName: "PreToolUse",
|
|
80
98
|
permissionDecision: "deny",
|
|
81
99
|
permissionDecisionReason: [
|
|
82
|
-
`CodeBuddy read guard: ${path} is ${lines
|
|
83
|
-
`Outline: ${outline.join("; ")}`,
|
|
84
|
-
|
|
85
|
-
"To find specific text, use Grep with output_mode content and -n instead of reading the file.",
|
|
86
|
-
`To read the whole file anyway, call Read again with limit: ${lines.length}.`
|
|
100
|
+
`CodeBuddy read guard: ${file.path} is ${file.lines} lines (~${Math.ceil(file.size / 4)} tokens), too large to read whole.`,
|
|
101
|
+
`Outline: ${file.outline.join("; ")}`,
|
|
102
|
+
...advice
|
|
87
103
|
].join("\n")
|
|
88
104
|
}
|
|
89
105
|
};
|
|
90
106
|
}
|
|
107
|
+
var WHOLE_FILE_PRINTERS = /* @__PURE__ */ new Set(["cat", "less", "more", "nl", "bat", "batcat"]);
|
|
108
|
+
var SHELL_SYNTAX = /[|;&<>`$(){}*?[\]\n\\]/;
|
|
109
|
+
async function guardBash(root, toolInput) {
|
|
110
|
+
const command = typeof toolInput.command === "string" ? toolInput.command.trim() : "";
|
|
111
|
+
if (!command || SHELL_SYNTAX.test(command)) return null;
|
|
112
|
+
const tokens = (command.match(/"[^"]*"|'[^']*'|\S+/g) ?? []).map(
|
|
113
|
+
(token) => token.replace(/^(["'])(.*)\1$/, "$2")
|
|
114
|
+
);
|
|
115
|
+
while (tokens[0] && /^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[0])) tokens.shift();
|
|
116
|
+
const program = (tokens.shift() ?? "").split("/").at(-1) ?? "";
|
|
117
|
+
const files = [];
|
|
118
|
+
if (WHOLE_FILE_PRINTERS.has(program)) {
|
|
119
|
+
for (const token of tokens) if (!token.startsWith("-")) files.push(token);
|
|
120
|
+
} else if (program === "head" || program === "tail") {
|
|
121
|
+
let lines = 10;
|
|
122
|
+
for (let index = 0; index < tokens.length; index += 1) {
|
|
123
|
+
const token = tokens[index] ?? "";
|
|
124
|
+
const count = /^-n$|^--lines$/.test(token) ? tokens[++index] : token.match(/^(?:-n|--lines=)\+?(\d+)$/)?.[1] ?? token.match(/^-(\d+)$/)?.[1];
|
|
125
|
+
if (count !== void 0 && /^\+?\d+$/.test(count)) lines = Number(count.replace("+", ""));
|
|
126
|
+
else if (token === "-f" || token === "-F" || token === "--follow") return null;
|
|
127
|
+
else if (!token.startsWith("-")) files.push(token);
|
|
128
|
+
}
|
|
129
|
+
if (lines <= TOOL_GUARD.readLines) return null;
|
|
130
|
+
} else {
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
for (const filePath of files) {
|
|
134
|
+
const file = await largeFile(root, filePath);
|
|
135
|
+
if (file) {
|
|
136
|
+
return deny(file, [
|
|
137
|
+
"Read only the part you need with the Read tool (offset = first line, limit = line count) or `sed -n 'START,ENDp' FILE`.",
|
|
138
|
+
"To find specific text, use Grep with output_mode content and -n instead of printing the file.",
|
|
139
|
+
`To read the whole file anyway, use Read with limit: ${file.lines}.`
|
|
140
|
+
]);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
91
145
|
async function readText(path) {
|
|
92
146
|
const handle = await open(path, "r");
|
|
93
147
|
try {
|
|
@@ -103,8 +157,12 @@ function outlineOf(path, text, lines) {
|
|
|
103
157
|
...entries.slice(0, TOOL_GUARD.outlineEntries),
|
|
104
158
|
`\u2026 ${entries.length - TOOL_GUARD.outlineEntries} more`
|
|
105
159
|
] : entries;
|
|
106
|
-
|
|
107
|
-
|
|
160
|
+
const all = supportsSymbols(path) ? extractSymbolTable(text, path) : [];
|
|
161
|
+
if (all[0]?.kind === "heading") {
|
|
162
|
+
return cap(all.map((heading) => `${heading.signature.slice(0, 80)} L${heading.startLine}`));
|
|
163
|
+
}
|
|
164
|
+
if (all.length > 0) {
|
|
165
|
+
const table = all.filter(
|
|
108
166
|
(symbol) => symbol.endLine - symbol.startLine + 1 >= TOOL_GUARD.outlineMinSymbolLines
|
|
109
167
|
);
|
|
110
168
|
const topLevel = table.filter((symbol) => symbol.kind !== "method");
|
|
@@ -117,12 +175,6 @@ function outlineOf(path, text, lines) {
|
|
|
117
175
|
];
|
|
118
176
|
}
|
|
119
177
|
}
|
|
120
|
-
if (/\.mdx?$/i.test(path)) {
|
|
121
|
-
const headings = lines.flatMap(
|
|
122
|
-
(line, i) => /^#{1,4}\s+\S/.test(line) ? [`${line.trim().slice(0, 80)} L${i + 1}`] : []
|
|
123
|
-
);
|
|
124
|
-
if (headings.length > 0) return cap(headings);
|
|
125
|
-
}
|
|
126
178
|
const blocks = [];
|
|
127
179
|
for (let start = 1; start <= lines.length; start += TOOL_GUARD.blockLines) {
|
|
128
180
|
blocks.push(`L${start}-${Math.min(lines.length, start + TOOL_GUARD.blockLines - 1)}`);
|
|
@@ -133,12 +185,31 @@ function outlineOf(path, text, lines) {
|
|
|
133
185
|
// src/hooks/pretool-command.ts
|
|
134
186
|
async function runPreToolHookCommand() {
|
|
135
187
|
await failSoft(async () => {
|
|
136
|
-
const
|
|
137
|
-
|
|
188
|
+
const input = await readHookInput();
|
|
189
|
+
const decision = await decidePreTool(input);
|
|
190
|
+
if (!decision) return;
|
|
191
|
+
await writeStdout(JSON.stringify(decision));
|
|
192
|
+
const reason = decision.hookSpecificOutput.permissionDecisionReason;
|
|
193
|
+
if (decision.hookSpecificOutput.permissionDecision === "deny" && reason) {
|
|
194
|
+
const full = Number(reason.match(/\(~(\d+) tokens\)/)?.[1] ?? 0);
|
|
195
|
+
const outline = Math.ceil(reason.length / 4);
|
|
196
|
+
await recordTrace(hookRoot(input.cwd), {
|
|
197
|
+
kind: "guard",
|
|
198
|
+
name: input.tool_name ?? "tool",
|
|
199
|
+
ms: 0,
|
|
200
|
+
ok: true,
|
|
201
|
+
tokensOut: outline,
|
|
202
|
+
...input.session_id ? { session: input.session_id } : {},
|
|
203
|
+
detail: {
|
|
204
|
+
file: reason.match(/read guard: (\S+) is/)?.[1] ?? "",
|
|
205
|
+
avoided: Math.max(0, full - outline)
|
|
206
|
+
}
|
|
207
|
+
});
|
|
208
|
+
}
|
|
138
209
|
});
|
|
139
210
|
}
|
|
140
211
|
|
|
141
212
|
export {
|
|
142
213
|
runPreToolHookCommand
|
|
143
214
|
};
|
|
144
|
-
//# sourceMappingURL=chunk-
|
|
215
|
+
//# sourceMappingURL=chunk-AQHPEJR6.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/hooks/pretool.ts","../src/hooks/pretool-command.ts"],"sourcesContent":["import { open, stat } from \"node:fs/promises\";\nimport { extname, isAbsolute, relative, resolve, sep } from \"node:path\";\nimport { extractSymbolTable, supportsSymbols } from \"../indexer/symbols.js\";\nimport type { HookInput } from \"./runtime.js\";\n\n/**\n * PreToolUse tool guard (Claude Code): cut tool output that would otherwise\n * stay in context for the rest of the session, without touching permissions.\n *\n * - Read of a large file with no range → denied with an outline (symbols or\n * headings with line ranges) and how to read a range or the whole file.\n * The model sees the deny reason; any Read with offset/limit passes.\n * - Grep in content mode with no head_limit → the same input plus a limit.\n * - Bash that only prints a large file whole (`cat`, `less`, `head -n 5000`) →\n * denied the same way; any other command (pipes, builds, tests) passes.\n *\n * Never emits `permissionDecision: \"allow\"`: Claude Code checks permissions on\n * the (possibly rewritten) input as usual. Verified on Claude Code 2.1.287.\n */\n/**\n * Only files whose full read is clearly expensive: below this, an agent that\n * re-reads the whole file after the outline (observed live) costs a turn for\n * nothing.\n */\nexport const TOOL_GUARD = {\n readLines: 1_000,\n readBytes: 80_000,\n /** Symbols shorter than this are noise in an outline (one-line constants). */\n outlineMinSymbolLines: 3,\n /** Fewer top-level symbols than this: list class methods too. */\n minTopLevelEntries: 10,\n outlineEntries: 40,\n blockLines: 100,\n grepHeadLimit: 100,\n} as const;\n\n/** Read renders these itself (images, PDFs, notebooks); never guard them. */\nconst SPECIAL_READ = new Set([\n \".png\",\n \".jpg\",\n \".jpeg\",\n \".gif\",\n \".webp\",\n \".bmp\",\n \".svg\",\n \".pdf\",\n \".ipynb\",\n]);\nconst MAX_GUARD_BYTES = 2_000_000;\n\nexport type PreToolDecision = {\n hookSpecificOutput: {\n hookEventName: \"PreToolUse\";\n permissionDecision?: \"deny\";\n permissionDecisionReason?: string;\n updatedInput?: Record<string, unknown>;\n };\n} | null;\n\nexport async function decidePreTool(\n input: HookInput,\n env: NodeJS.ProcessEnv = process.env,\n): Promise<PreToolDecision> {\n if (env.CODEBUDDY_TOOL_GUARD === \"0\") return null;\n const toolInput = input.tool_input ?? {};\n if (input.tool_name === \"Grep\") return capGrep(toolInput);\n if (input.tool_name === \"Read\") return guardRead(resolve(input.cwd ?? process.cwd()), toolInput);\n if (input.tool_name === \"Bash\") return guardBash(resolve(input.cwd ?? process.cwd()), toolInput);\n return null;\n}\n\nfunction capGrep(toolInput: Record<string, unknown>): PreToolDecision {\n if (toolInput.output_mode !== \"content\" || toolInput.head_limit !== undefined) return null;\n return {\n hookSpecificOutput: {\n hookEventName: \"PreToolUse\",\n updatedInput: { ...toolInput, head_limit: TOOL_GUARD.grepHeadLimit },\n },\n };\n}\n\n/** A file inside the repository too large to read whole, with its outline; else null. */\nexport async function largeFile(\n root: string,\n filePath: string,\n): Promise<{ path: string; lines: number; size: number; outline: string[] } | null> {\n const absolute = isAbsolute(filePath) ? resolve(filePath) : resolve(root, filePath);\n const rel = relative(root, absolute);\n if (rel === \"\" || rel === \"..\" || rel.startsWith(`..${sep}`) || isAbsolute(rel)) return null;\n if (SPECIAL_READ.has(extname(absolute).toLowerCase())) return null;\n let size: number;\n try {\n const info = await stat(absolute);\n if (!info.isFile()) return null;\n size = info.size;\n } catch {\n return null;\n }\n if (size > MAX_GUARD_BYTES) return null; // Read refuses huge files itself.\n const text = await readText(absolute);\n if (text === null) return null;\n const lines = text.split(/\\r?\\n/);\n if (lines.at(-1) === \"\") lines.pop();\n if (lines.length <= TOOL_GUARD.readLines && size <= TOOL_GUARD.readBytes) return null;\n const path = rel.replace(/\\\\/g, \"/\");\n return { path, lines: lines.length, size, outline: outlineOf(path, text, lines) };\n}\n\nasync function guardRead(\n root: string,\n toolInput: Record<string, unknown>,\n): Promise<PreToolDecision> {\n if (toolInput.offset !== undefined || toolInput.limit !== undefined) return null;\n const filePath = toolInput.file_path;\n if (typeof filePath !== \"string\" || !filePath) return null;\n const file = await largeFile(root, filePath);\n if (!file) return null;\n return deny(file, [\n \"Read only the part you need with offset and limit (offset = first line, limit = line count).\",\n \"To find specific text, use Grep with output_mode content and -n instead of reading the file.\",\n `To read the whole file anyway, call Read again with limit: ${file.lines}.`,\n ]);\n}\n\nfunction deny(\n file: { path: string; lines: number; size: number; outline: string[] },\n advice: string[],\n): PreToolDecision {\n return {\n hookSpecificOutput: {\n hookEventName: \"PreToolUse\",\n permissionDecision: \"deny\",\n permissionDecisionReason: [\n `CodeBuddy read guard: ${file.path} is ${file.lines} lines (~${Math.ceil(file.size / 4)} tokens), too large to read whole.`,\n `Outline: ${file.outline.join(\"; \")}`,\n ...advice,\n ].join(\"\\n\"),\n },\n };\n}\n\n/** Programs that print whole files; `head`/`tail` only when asked for more lines than the guard. */\nconst WHOLE_FILE_PRINTERS = new Set([\"cat\", \"less\", \"more\", \"nl\", \"bat\", \"batcat\"]);\n/**\n * Anything that makes a command more than one plain read: pipes, lists,\n * redirection, substitution, globbing over many files. Such commands are never\n * touched, so builds, tests, pipelines and scripts behave exactly as before.\n */\nconst SHELL_SYNTAX = /[|;&<>`$(){}*?[\\]\\n\\\\]/;\n\n/**\n * Bash that would print a large file whole (`cat src/big.ts`, `head -n 5000 x`).\n * Claude reads files through Bash to get around the Read guard; the output then\n * stays in context for the rest of the session. Only a single plain read\n * command of a file over the Read guard's size is denied, with the same outline\n * and how to read a range; every other command passes untouched.\n */\nasync function guardBash(\n root: string,\n toolInput: Record<string, unknown>,\n): Promise<PreToolDecision> {\n const command = typeof toolInput.command === \"string\" ? toolInput.command.trim() : \"\";\n if (!command || SHELL_SYNTAX.test(command)) return null;\n const tokens = (command.match(/\"[^\"]*\"|'[^']*'|\\S+/g) ?? []).map((token) =>\n token.replace(/^([\"'])(.*)\\1$/, \"$2\"),\n );\n // Leading `VAR=value` assignments do not change what runs.\n while (tokens[0] && /^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[0])) tokens.shift();\n const program = (tokens.shift() ?? \"\").split(\"/\").at(-1) ?? \"\";\n const files: string[] = [];\n if (WHOLE_FILE_PRINTERS.has(program)) {\n for (const token of tokens) if (!token.startsWith(\"-\")) files.push(token);\n } else if (program === \"head\" || program === \"tail\") {\n let lines = 10;\n for (let index = 0; index < tokens.length; index += 1) {\n const token = tokens[index] ?? \"\";\n const count = /^-n$|^--lines$/.test(token)\n ? tokens[++index]\n : (token.match(/^(?:-n|--lines=)\\+?(\\d+)$/)?.[1] ?? token.match(/^-(\\d+)$/)?.[1]);\n if (count !== undefined && /^\\+?\\d+$/.test(count)) lines = Number(count.replace(\"+\", \"\"));\n else if (token === \"-f\" || token === \"-F\" || token === \"--follow\") return null;\n else if (!token.startsWith(\"-\")) files.push(token);\n }\n if (lines <= TOOL_GUARD.readLines) return null;\n } else {\n return null;\n }\n for (const filePath of files) {\n const file = await largeFile(root, filePath);\n if (file) {\n return deny(file, [\n \"Read only the part you need with the Read tool (offset = first line, limit = line count) or `sed -n 'START,ENDp' FILE`.\",\n \"To find specific text, use Grep with output_mode content and -n instead of printing the file.\",\n `To read the whole file anyway, use Read with limit: ${file.lines}.`,\n ]);\n }\n }\n return null;\n}\n\n/** Whole file as UTF-8 text, or null when it looks binary. */\nasync function readText(path: string): Promise<string | null> {\n const handle = await open(path, \"r\");\n try {\n const content = await handle.readFile();\n if (content.includes(0)) return null;\n return content.toString(\"utf8\");\n } finally {\n await handle.close();\n }\n}\n\nfunction outlineOf(path: string, text: string, lines: string[]): string[] {\n const cap = (entries: string[]) =>\n entries.length > TOOL_GUARD.outlineEntries\n ? [\n ...entries.slice(0, TOOL_GUARD.outlineEntries),\n `… ${entries.length - TOOL_GUARD.outlineEntries} more`,\n ]\n : entries;\n const all = supportsSymbols(path) ? extractSymbolTable(text, path) : [];\n if (all[0]?.kind === \"heading\") {\n return cap(all.map((heading) => `${heading.signature.slice(0, 80)} L${heading.startLine}`));\n }\n if (all.length > 0) {\n const table = all.filter(\n (symbol) => symbol.endLine - symbol.startLine + 1 >= TOOL_GUARD.outlineMinSymbolLines,\n );\n const topLevel = table.filter((symbol) => symbol.kind !== \"method\");\n // One big class says little on its own; then list its methods (`Class.method`).\n const symbols = topLevel.length >= TOOL_GUARD.minTopLevelEntries ? topLevel : table;\n if (symbols.length > 0) {\n // The symbol table is capped, so say where the outline stops covering the file.\n const covered = Math.max(...symbols.map((symbol) => symbol.endLine));\n return [\n ...cap(symbols.map((s) => `${s.name} L${s.startLine}-${s.endLine}`)),\n ...(covered < lines.length ? [`not outlined L${covered + 1}-${lines.length}`] : []),\n ];\n }\n }\n const blocks: string[] = [];\n for (let start = 1; start <= lines.length; start += TOOL_GUARD.blockLines) {\n blocks.push(`L${start}-${Math.min(lines.length, start + TOOL_GUARD.blockLines - 1)}`);\n }\n return cap(blocks);\n}\n","import { recordTrace } from \"../tracing/log.js\";\nimport { decidePreTool } from \"./pretool.js\";\nimport { hookRoot } from \"./root.js\";\nimport { failSoft, readHookInput, writeStdout } from \"./stdio.js\";\n\n/** `codebuddy hooks pretool`: runs on every Read/Grep, so it loads only its own module. */\nexport async function runPreToolHookCommand(): Promise<void> {\n await failSoft(async () => {\n const input = await readHookInput();\n const decision = await decidePreTool(input);\n if (!decision) return;\n await writeStdout(JSON.stringify(decision));\n const reason = decision.hookSpecificOutput.permissionDecisionReason;\n if (decision.hookSpecificOutput.permissionDecision === \"deny\" && reason) {\n // Measured, not modelled: the file the agent did not load whole, minus the\n // outline it got instead. Shown by `codebuddy dashboard`.\n const full = Number(reason.match(/\\(~(\\d+) tokens\\)/)?.[1] ?? 0);\n const outline = Math.ceil(reason.length / 4);\n await recordTrace(hookRoot(input.cwd), {\n kind: \"guard\",\n name: input.tool_name ?? \"tool\",\n ms: 0,\n ok: true,\n tokensOut: outline,\n ...(input.session_id ? { session: input.session_id } : {}),\n detail: {\n file: reason.match(/read guard: (\\S+) is/)?.[1] ?? \"\",\n avoided: Math.max(0, full - outline),\n },\n });\n }\n });\n}\n"],"mappings":"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","names":[]}
|
|
@@ -1,23 +1,23 @@
|
|
|
1
1
|
import {
|
|
2
2
|
chatJson
|
|
3
|
-
} from "./chunk-
|
|
3
|
+
} from "./chunk-ESZXCZIN.js";
|
|
4
4
|
import {
|
|
5
5
|
NON_SOURCE_PATH,
|
|
6
6
|
TEST_PATH,
|
|
7
7
|
loadFreshIndex,
|
|
8
8
|
loadProjectArchitectureMap,
|
|
9
9
|
readBoundedSource
|
|
10
|
-
} from "./chunk-
|
|
10
|
+
} from "./chunk-5EDGAZQN.js";
|
|
11
11
|
import {
|
|
12
12
|
redactSensitive,
|
|
13
13
|
scanSensitive
|
|
14
|
-
} from "./chunk-
|
|
14
|
+
} from "./chunk-AL6VBTNA.js";
|
|
15
15
|
import {
|
|
16
16
|
resolveLlmConfig
|
|
17
|
-
} from "./chunk-
|
|
17
|
+
} from "./chunk-SFMIMLXR.js";
|
|
18
18
|
import {
|
|
19
19
|
writeAtomic
|
|
20
|
-
} from "./chunk-
|
|
20
|
+
} from "./chunk-CG5B3LYG.js";
|
|
21
21
|
|
|
22
22
|
// src/llm/summaries.ts
|
|
23
23
|
import { mkdir, readFile } from "fs/promises";
|
|
@@ -201,4 +201,4 @@ export {
|
|
|
201
201
|
loadLlmSummaries,
|
|
202
202
|
summarizeRepository
|
|
203
203
|
};
|
|
204
|
-
//# sourceMappingURL=chunk-
|
|
204
|
+
//# sourceMappingURL=chunk-BQM7FJ7D.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/llm/summaries.ts"],"sourcesContent":["import { mkdir, readFile } from \"node:fs/promises\";\nimport { join } from \"node:path\";\nimport { z } from \"zod\";\nimport {\n loadProjectArchitectureMap,\n NON_SOURCE_PATH,\n readBoundedSource,\n TEST_PATH,\n} from \"../context/discovery.js\";\nimport { writeAtomic } from \"../core/markdown-store-fs.js\";\nimport { redactSensitive, scanSensitive } from \"../core/sensitive.js\";\nimport { loadFreshIndex } from \"../indexer/engine.js\";\nimport type { IndexEntry, IndexManifest } from \"../indexer/types.js\";\nimport { chatJson } from \"./client.js\";\nimport { type EnabledLlmConfig, resolveLlmConfig } from \"./config.js\";\n\n/**\n * One-line descriptions of files and their main symbols, written by the BYOK\n * model off the prompt path (`codebuddy llm summarize`, `watch`) and cached by\n * content hash. Retrieval reads them so a paraphrased task can match a file\n * whose own text never uses the task's words. Generated text is a hint for\n * ordering only; it never satisfies the evidence floor on its own.\n */\nexport const SUMMARY_PROMPT_VERSION = 1;\nconst LIMITS = {\n fileChars: 160,\n symbolChars: 100,\n signatures: 12,\n headLines: 20,\n /** ~600 tokens per file: long-line (minified) files are cut, never sent whole. */\n headChars: 1_600,\n signatureChars: 160,\n batchTokens: 5_000,\n batchFiles: 8,\n outputTokensPerFile: 120,\n tokensPerMinute: 6_000,\n timeoutMs: 20_000,\n} as const;\n\nexport type LlmSummary = {\n hash: string;\n model: string;\n version: number;\n summary: string;\n symbols: Record<string, string>;\n};\n\ntype Store = Record<string, LlmSummary>;\nconst storePath = (root: string) => join(root, \".codebuddy\", \"cache\", \"llm-summaries.json\");\n\nasync function readStore(root: string): Promise<Store> {\n try {\n return JSON.parse(await readFile(storePath(root), \"utf8\")) as Store;\n } catch {\n return {};\n }\n}\n\nasync function writeStore(root: string, store: Store): Promise<void> {\n await mkdir(join(root, \".codebuddy\", \"cache\"), { recursive: true });\n await writeAtomic(storePath(root), `${JSON.stringify(store)}\\n`);\n}\n\n/** Summaries whose content hash still matches the current index. */\nexport async function loadLlmSummaries(\n root: string,\n manifest: IndexManifest,\n): Promise<Map<string, LlmSummary>> {\n const store = await readStore(root);\n return new Map(\n Object.entries(store).filter(([path, entry]) => manifest.entries[path]?.hash === entry.hash),\n );\n}\n\nconst outputSchema = z.object({\n files: z.array(\n z.object({\n path: z.string(),\n summary: z.string(),\n symbols: z.array(z.object({ name: z.string(), summary: z.string() })).default([]),\n }),\n ),\n});\n\nconst SYSTEM = [\n \"You describe source files for a code search index.\",\n 'Answer with JSON only: {\"files\":[{\"path\":\"...\",\"summary\":\"...\",\"symbols\":[{\"name\":\"...\",\"summary\":\"...\"}]}]}.',\n `For each input file: one sentence (max ${LIMITS.fileChars} chars) saying what it does, using the words a developer would search for;`,\n `for its main symbols (only names listed in \"signatures\"), one short phrase each (max ${LIMITS.symbolChars} chars).`,\n \"Never invent paths or symbol names.\",\n].join(\" \");\n\nexport type SummarizeResult = {\n summarized: number;\n skipped: number;\n remaining: number;\n stoppedBy: \"done\" | \"limit\" | \"budget\" | \"cooldown\" | \"disabled\" | \"aborted\" | \"error\";\n};\n\nexport async function summarizeRepository(\n root: string,\n options: {\n limit?: number;\n onProgress?: (done: number, total: number) => void;\n signal?: AbortSignal;\n /** Injected config (tests, CLI); defaults to `resolveLlmConfig`. */\n config?: EnabledLlmConfig;\n /** Wait between batches to stay under the free tier's tokens/minute. */\n pace?: boolean;\n batchFiles?: number;\n } = {},\n): Promise<SummarizeResult> {\n const resolved = options.config ?? (await resolveLlmConfig(root));\n if (!resolved.enabled || !resolved.features.summaries) {\n return { summarized: 0, skipped: 0, remaining: 0, stoppedBy: \"disabled\" };\n }\n const config = resolved;\n const { manifest } = await loadFreshIndex(root);\n const store = await readStore(root);\n for (const path of Object.keys(store)) if (!manifest.entries[path]) delete store[path];\n\n const pending = await orderByImportance(\n root,\n manifest,\n Object.values(manifest.entries).filter((entry) => {\n const done = store[entry.path];\n return !(\n done?.hash === entry.hash &&\n done.model === config.model &&\n done.version === SUMMARY_PROMPT_VERSION\n );\n }),\n );\n const limit = options.limit ?? Number.POSITIVE_INFINITY;\n let summarized = 0;\n let skipped = 0;\n let index = 0;\n let stoppedBy: SummarizeResult[\"stoppedBy\"] = \"done\";\n\n while (index < pending.length) {\n if (options.signal?.aborted) {\n stoppedBy = \"aborted\";\n break;\n }\n if (summarized >= limit) {\n stoppedBy = \"limit\";\n break;\n }\n const batch: Array<{ entry: IndexEntry; input: FileInput }> = [];\n let tokens = 0;\n while (index < pending.length && batch.length < (options.batchFiles ?? LIMITS.batchFiles)) {\n if (summarized + batch.length >= limit) break;\n const entry = pending[index] as IndexEntry;\n const input = await fileInput(root, entry);\n if (input === null) {\n skipped++;\n index++;\n continue;\n }\n const cost = Math.ceil(JSON.stringify(input).length / 4);\n if (batch.length > 0 && tokens + cost > LIMITS.batchTokens - 600) break;\n batch.push({ entry, input });\n tokens += cost;\n index++;\n }\n if (batch.length === 0) continue;\n\n const started = Date.now();\n const result = await chatJson(config, {\n system: SYSTEM,\n user: JSON.stringify({ files: batch.map(({ input }) => input) }),\n schema: outputSchema,\n maxTokens: LIMITS.outputTokensPerFile * batch.length,\n deadlineMs: LIMITS.timeoutMs,\n purpose: \"background\",\n repositoryRoot: root,\n });\n if (!result.ok) {\n // A batch the provider rejects outright (bad output, or e.g. 413 for one\n // file) is skipped, not retried forever; limits and outages stop the run.\n if (result.reason === \"invalid\" || (result.reason === \"http\" && batch.length === 1)) {\n skipped += batch.length;\n continue;\n }\n index -= batch.length;\n stoppedBy =\n result.reason === \"budget\"\n ? \"budget\"\n : result.reason === \"cooldown\" || result.reason === \"rate-limited\"\n ? \"cooldown\"\n : \"error\";\n break;\n }\n for (const { entry } of batch) {\n const answer = result.value.files.find((file) => file.path === entry.path);\n if (!answer) {\n skipped++;\n continue;\n }\n const known = new Set((entry.symbolTable ?? []).map((symbol) => symbol.name));\n store[entry.path] = {\n hash: entry.hash,\n model: config.model,\n version: SUMMARY_PROMPT_VERSION,\n summary: answer.summary.trim().slice(0, LIMITS.fileChars),\n symbols: Object.fromEntries(\n answer.symbols\n .filter((symbol) => known.has(symbol.name))\n .map((symbol) => [symbol.name, symbol.summary.trim().slice(0, LIMITS.symbolChars)]),\n ),\n };\n summarized++;\n }\n await writeStore(root, store);\n options.onProgress?.(index, pending.length);\n if (options.pace !== false) {\n const wait = (result.tokens / LIMITS.tokensPerMinute) * 60_000 - (Date.now() - started);\n if (wait > 0) await new Promise((resolve) => setTimeout(resolve, wait));\n }\n }\n await writeStore(root, store);\n return { summarized, skipped, remaining: pending.length - index, stoppedBy };\n}\n\ntype FileInput = {\n path: string;\n language: string;\n summary: string;\n signatures: string[];\n head: string;\n};\n\n/** What is sent for one file; null for unreadable or secret-bearing files. */\nasync function fileInput(root: string, entry: IndexEntry): Promise<FileInput | null> {\n const source = await readBoundedSource(root, entry.path);\n if (source === null || scanSensitive(source).sensitive) return null;\n return {\n path: entry.path,\n language: entry.language,\n summary: redactSensitive(entry.summary),\n signatures: (entry.symbolTable ?? [])\n .slice(0, LIMITS.signatures)\n .map((symbol) => redactSensitive(symbol.signature.slice(0, LIMITS.signatureChars))),\n head: redactSensitive(\n source.split(/\\r?\\n/).slice(0, LIMITS.headLines).join(\"\\n\").slice(0, LIMITS.headChars),\n ),\n };\n}\n\n/** Most-imported source first; tests and non-source files last. */\nasync function orderByImportance(\n root: string,\n manifest: IndexManifest,\n entries: IndexEntry[],\n): Promise<IndexEntry[]> {\n const fanIn = new Map<string, number>();\n try {\n for (const edge of (await loadProjectArchitectureMap(root, manifest)).edges) {\n fanIn.set(edge.to, (fanIn.get(edge.to) ?? 0) + 1);\n }\n } catch {\n // No map: path order is still a usable order.\n }\n const tier = (path: string) => (TEST_PATH.test(path) || NON_SOURCE_PATH.test(path) ? 1 : 0);\n return [...entries].sort(\n (left, right) =>\n tier(left.path) - tier(right.path) ||\n (fanIn.get(right.path) ?? 0) - (fanIn.get(left.path) ?? 0) ||\n left.path.localeCompare(right.path),\n );\n}\n"],"mappings":"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","names":[]}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
2
|
getModelContextWindow
|
|
3
|
-
} from "./chunk-
|
|
3
|
+
} from "./chunk-HPOR6YXA.js";
|
|
4
4
|
|
|
5
5
|
// src/planner/budget.ts
|
|
6
6
|
import { Tiktoken } from "js-tiktoken/lite";
|
|
@@ -16,19 +16,23 @@ var PIECE = new RegExp(cl100kBase.pat_str, "gu");
|
|
|
16
16
|
var SPECIAL_TOKENS = Object.keys(cl100kBase.special_tokens);
|
|
17
17
|
var MAX_CACHED_PIECES = 2e5;
|
|
18
18
|
var pieceTokens = /* @__PURE__ */ new Map();
|
|
19
|
+
var MAX_PIECE_CHARS = 256;
|
|
19
20
|
function countTokens(text) {
|
|
20
21
|
if (!text) return 0;
|
|
21
22
|
try {
|
|
22
23
|
if (SPECIAL_TOKENS.some((token) => text.includes(token))) throw new Error("special token");
|
|
23
24
|
let total = 0;
|
|
24
|
-
for (const [
|
|
25
|
-
let
|
|
26
|
-
|
|
27
|
-
count =
|
|
28
|
-
if (
|
|
29
|
-
|
|
25
|
+
for (const [whole] of text.matchAll(PIECE)) {
|
|
26
|
+
for (let start = 0; start < whole.length; start += MAX_PIECE_CHARS) {
|
|
27
|
+
const piece = whole.slice(start, start + MAX_PIECE_CHARS);
|
|
28
|
+
let count = pieceTokens.get(piece);
|
|
29
|
+
if (count === void 0) {
|
|
30
|
+
count = getEncoder().encode(piece).length;
|
|
31
|
+
if (pieceTokens.size >= MAX_CACHED_PIECES) pieceTokens.clear();
|
|
32
|
+
pieceTokens.set(piece, count);
|
|
33
|
+
}
|
|
34
|
+
total += count;
|
|
30
35
|
}
|
|
31
|
-
total += count;
|
|
32
36
|
}
|
|
33
37
|
return total;
|
|
34
38
|
} catch {
|
|
@@ -100,4 +104,4 @@ export {
|
|
|
100
104
|
computeContentHash,
|
|
101
105
|
computeFactHash
|
|
102
106
|
};
|
|
103
|
-
//# sourceMappingURL=chunk-
|
|
107
|
+
//# sourceMappingURL=chunk-CAEPHLHC.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/planner/budget.ts","../src/core/ids.ts"],"sourcesContent":["import { Tiktoken } from \"js-tiktoken/lite\";\nimport cl100kBase from \"js-tiktoken/ranks/cl100k_base\";\nimport { getModelContextWindow } from \"../providers/models.js\";\n\nlet encoder: Tiktoken | null = null;\n\nfunction getEncoder(): Tiktoken {\n if (!encoder) {\n encoder = new Tiktoken(cl100kBase);\n }\n return encoder;\n}\n\n/** cl100k pre-tokenizer: BPE merges never cross these pieces, so counts add up. */\nconst PIECE = new RegExp(cl100kBase.pat_str, \"gu\");\nconst SPECIAL_TOKENS = Object.keys(cl100kBase.special_tokens);\n// ponytail: cleared when full rather than LRU; source and JSON pieces repeat\n// heavily, so a simple bound keeps the hit rate high.\nconst MAX_CACHED_PIECES = 200_000;\nconst pieceTokens = new Map<string, number>();\nconst MAX_PIECE_CHARS = 256;\n\n/**\n * Exact cl100k token count. A context request tokenizes nearly the same JSON\n * several times (stats, stages, budget fixed point, delivery), so BPE results\n * are memoized per pre-tokenized piece; the sum equals `encode(text).length`.\n */\nexport function countTokens(text: string): number {\n if (!text) return 0;\n try {\n // `encode` rejects special-token text; keep the same fallback for it.\n if (SPECIAL_TOKENS.some((token) => text.includes(token))) throw new Error(\"special token\");\n let total = 0;\n for (const [whole] of text.matchAll(PIECE)) {\n // BPE is quadratic in piece length: one 32 KB run of \"x\" took over a\n // minute and blocked the MCP server. Long pieces are counted in chunks.\n // ponytail: may over-count by ≤ 1 token per chunk on pieces > 256 chars\n // (real text almost never has them); exact again if js-tiktoken gets linear.\n for (let start = 0; start < whole.length; start += MAX_PIECE_CHARS) {\n const piece = whole.slice(start, start + MAX_PIECE_CHARS);\n let count = pieceTokens.get(piece);\n if (count === undefined) {\n count = getEncoder().encode(piece).length;\n if (pieceTokens.size >= MAX_CACHED_PIECES) pieceTokens.clear();\n pieceTokens.set(piece, count);\n }\n total += count;\n }\n }\n return total;\n } catch {\n return Math.max(1, Math.ceil(text.length / 4));\n }\n}\n\nexport type BudgetClampResult = {\n budget: number;\n clamped: boolean;\n reason?: string;\n};\n\nexport function clampBudgetForCallerModel(\n requestedBudget: number,\n callerModel: string | undefined,\n): BudgetClampResult {\n if (!callerModel) {\n return { budget: requestedBudget, clamped: false };\n }\n const ctx = getModelContextWindow(callerModel);\n if (!ctx) {\n return { budget: requestedBudget, clamped: false };\n }\n const headroom = Math.floor(ctx * 0.75);\n if (requestedBudget > headroom) {\n return {\n budget: headroom,\n clamped: true,\n reason: `budget ${requestedBudget} exceeds 75% of ${callerModel} context window (${ctx}); clamped to ${headroom}.`,\n };\n }\n return { budget: requestedBudget, clamped: false };\n}\n","import { createHash, randomBytes } from \"node:crypto\";\n\nconst CROCKFORD = \"0123456789ABCDEFGHJKMNPQRSTVWXYZ\";\n\nexport function generateUlid(now: number = Date.now()): string {\n let timePart = \"\";\n let ts = now;\n for (let i = 0; i < 10; i++) {\n timePart = (CROCKFORD[ts % 32] ?? \"0\") + timePart;\n ts = Math.floor(ts / 32);\n }\n\n const bytes = randomBytes(10);\n let randPart = \"\";\n for (let i = 0; i < 16; i++) {\n const bitOffset = i * 5;\n const byteIndex = Math.floor(bitOffset / 8);\n const bitInByte = bitOffset % 8;\n const hi = bytes[byteIndex] ?? 0;\n const lo = bytes[byteIndex + 1] ?? 0;\n const combined = ((hi << 8) | lo) >>> 0;\n const shift = 16 - bitInByte - 5;\n const value = (combined >>> shift) & 0x1f;\n randPart += CROCKFORD[value] ?? \"0\";\n }\n\n return timePart + randPart;\n}\n\nexport function generateSessionId(now?: number): string {\n return `sess_${generateUlid(now).toLowerCase()}`;\n}\n\nexport function generateEntityId(prefix: string, now?: number): string {\n return `${prefix}_${generateUlid(now).toLowerCase()}`;\n}\n\nexport function computeContentHash(namespace: string, sessionId: string, content: string): string {\n return createHash(\"sha256\").update(`${namespace}\\x1f${sessionId}\\x1f${content}`).digest(\"hex\");\n}\n\nexport function computeFactHash(namespace: string, content: string): string {\n return createHash(\"sha256\").update(`${namespace}\\x1ffact\\x1f${content}`).digest(\"hex\");\n}\n"],"mappings":"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","names":[]}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// src/core/markdown-store-fs.ts
|
|
2
|
+
import { lstat, open, rename } from "fs/promises";
|
|
3
|
+
import { join, relative, resolve, sep } from "path";
|
|
4
|
+
async function assertNoSymlinkBetween(root, target) {
|
|
5
|
+
const absoluteRoot = resolve(root);
|
|
6
|
+
const absoluteTarget = resolve(target);
|
|
7
|
+
const relativeTarget = relative(absoluteRoot, absoluteTarget);
|
|
8
|
+
if (relativeTarget === ".." || relativeTarget.startsWith(`..${sep}`)) {
|
|
9
|
+
throw new Error(`Path escapes repository root: ${target}`);
|
|
10
|
+
}
|
|
11
|
+
let current = absoluteRoot;
|
|
12
|
+
const parts = relativeTarget.split(sep).filter(Boolean);
|
|
13
|
+
for (const part of parts) {
|
|
14
|
+
current = join(current, part);
|
|
15
|
+
try {
|
|
16
|
+
const info = await lstat(current);
|
|
17
|
+
if (info.isSymbolicLink()) {
|
|
18
|
+
throw new Error(`Refusing to use symlinked path: ${current}`);
|
|
19
|
+
}
|
|
20
|
+
} catch (error) {
|
|
21
|
+
if (error.code === "ENOENT") return;
|
|
22
|
+
throw error;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
async function writeAtomic(path, document, options) {
|
|
27
|
+
const temporaryPath = `${path}.${process.pid}.${Date.now()}.tmp`;
|
|
28
|
+
const handle = await open(temporaryPath, "wx", options?.mode ?? 384);
|
|
29
|
+
try {
|
|
30
|
+
await handle.writeFile(document, "utf8");
|
|
31
|
+
await handle.sync();
|
|
32
|
+
} finally {
|
|
33
|
+
await handle.close();
|
|
34
|
+
}
|
|
35
|
+
await rename(temporaryPath, path);
|
|
36
|
+
}
|
|
37
|
+
function parseFrontMatter(document) {
|
|
38
|
+
if (!document.startsWith("---\n")) {
|
|
39
|
+
throw new Error("Markdown file must start with YAML front matter.");
|
|
40
|
+
}
|
|
41
|
+
const closingIndex = document.indexOf("\n---\n", 4);
|
|
42
|
+
if (closingIndex === -1) {
|
|
43
|
+
throw new Error("Markdown file has unterminated YAML front matter.");
|
|
44
|
+
}
|
|
45
|
+
return {
|
|
46
|
+
frontMatter: document.slice(4, closingIndex),
|
|
47
|
+
content: document.slice(closingIndex + 5)
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
function composeMarkdown(frontMatterYaml, body) {
|
|
51
|
+
const trimmedFrontMatter = frontMatterYaml.trimEnd();
|
|
52
|
+
const content = body.endsWith("\n") ? body : `${body}
|
|
53
|
+
`;
|
|
54
|
+
return `---
|
|
55
|
+
${trimmedFrontMatter}
|
|
56
|
+
---
|
|
57
|
+
${content}`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export {
|
|
61
|
+
assertNoSymlinkBetween,
|
|
62
|
+
writeAtomic,
|
|
63
|
+
parseFrontMatter,
|
|
64
|
+
composeMarkdown
|
|
65
|
+
};
|
|
66
|
+
//# sourceMappingURL=chunk-CG5B3LYG.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/core/markdown-store-fs.ts"],"sourcesContent":["import { lstat, open, rename } from \"node:fs/promises\";\nimport { join, relative, resolve, sep } from \"node:path\";\n\n/**\n * Filesystem helpers shared by the Markdown-backed stores\n * (`MemoryFileStore`, `PlanFileStore`). These enforce the security\n * invariants that keep `.codebuddy/` writes inside the repository root:\n * no path traversal, no symlinked path components, and crash-safe atomic\n * writes. Keep this module small and audited; both stores depend on it.\n */\n\n/**\n * Resolve `path` and assert it stays inside `root`. Returns the absolute\n * path. Throws if the resolved path escapes the root.\n */\nexport function resolveWithinRoot(root: string, path: string): string {\n const absoluteRoot = resolve(root);\n const absolute = resolve(path);\n const relativePath = relative(absoluteRoot, absolute);\n if (relativePath === \"..\" || relativePath.startsWith(`..${sep}`) || relativePath === \"\") {\n throw new Error(`Path escapes repository root: ${path}`);\n }\n return absolute;\n}\n\n/**\n * Walk each path component between `root` and `target` and refuse if any\n * component is a symbolic link. Prevents a planted symlink from\n * redirecting a write outside the repository. Missing components are\n * fine — the caller creates them.\n */\nexport async function assertNoSymlinkBetween(root: string, target: string): Promise<void> {\n const absoluteRoot = resolve(root);\n const absoluteTarget = resolve(target);\n const relativeTarget = relative(absoluteRoot, absoluteTarget);\n if (relativeTarget === \"..\" || relativeTarget.startsWith(`..${sep}`)) {\n throw new Error(`Path escapes repository root: ${target}`);\n }\n\n let current = absoluteRoot;\n const parts = relativeTarget.split(sep).filter(Boolean);\n for (const part of parts) {\n current = join(current, part);\n try {\n const info = await lstat(current);\n if (info.isSymbolicLink()) {\n throw new Error(`Refusing to use symlinked path: ${current}`);\n }\n } catch (error) {\n if ((error as NodeJS.ErrnoException).code === \"ENOENT\") return;\n throw error;\n }\n }\n}\n\n/**\n * Write `document` to `path` atomically: write to a unique temp file with\n * an exclusive (`wx`) create, fsync, then rename into place. A crash mid-\n * write leaves either the old file or nothing — never a partial file.\n *\n * `mode` sets permissions on NEWLY created files (an existing file keeps its\n * mode, matching rename semantics). Secrets-bearing configs should use 0o600.\n */\nexport async function writeAtomic(\n path: string,\n document: string,\n options?: { mode?: number },\n): Promise<void> {\n const temporaryPath = `${path}.${process.pid}.${Date.now()}.tmp`;\n const handle = await open(temporaryPath, \"wx\", options?.mode ?? 0o600);\n try {\n await handle.writeFile(document, \"utf8\");\n await handle.sync();\n } finally {\n await handle.close();\n }\n await rename(temporaryPath, path);\n}\n\n/**\n * Split a Markdown document with leading YAML front matter into its\n * front-matter text and body. Throws on malformed or unterminated\n * front matter so hand-edited files fail loudly rather than silently\n * losing fields.\n */\nexport function parseFrontMatter(document: string): { frontMatter: string; content: string } {\n if (!document.startsWith(\"---\\n\")) {\n throw new Error(\"Markdown file must start with YAML front matter.\");\n }\n const closingIndex = document.indexOf(\"\\n---\\n\", 4);\n if (closingIndex === -1) {\n throw new Error(\"Markdown file has unterminated YAML front matter.\");\n }\n return {\n frontMatter: document.slice(4, closingIndex),\n content: document.slice(closingIndex + 5),\n };\n}\n\n/**\n * Compose a Markdown document from serialized front-matter YAML and a\n * body. Ensures exactly one trailing newline on the body so files are\n * stable across rewrites (minimal git diffs).\n */\nexport function composeMarkdown(frontMatterYaml: string, body: string): string {\n const trimmedFrontMatter = frontMatterYaml.trimEnd();\n const content = body.endsWith(\"\\n\") ? body : `${body}\\n`;\n return `---\\n${trimmedFrontMatter}\\n---\\n${content}`;\n}\n"],"mappings":";AAAA,SAAS,OAAO,MAAM,cAAc;AACpC,SAAS,MAAM,UAAU,SAAS,WAAW;AA8B7C,eAAsB,uBAAuB,MAAc,QAA+B;AACxF,QAAM,eAAe,QAAQ,IAAI;AACjC,QAAM,iBAAiB,QAAQ,MAAM;AACrC,QAAM,iBAAiB,SAAS,cAAc,cAAc;AAC5D,MAAI,mBAAmB,QAAQ,eAAe,WAAW,KAAK,GAAG,EAAE,GAAG;AACpE,UAAM,IAAI,MAAM,iCAAiC,MAAM,EAAE;AAAA,EAC3D;AAEA,MAAI,UAAU;AACd,QAAM,QAAQ,eAAe,MAAM,GAAG,EAAE,OAAO,OAAO;AACtD,aAAW,QAAQ,OAAO;AACxB,cAAU,KAAK,SAAS,IAAI;AAC5B,QAAI;AACF,YAAM,OAAO,MAAM,MAAM,OAAO;AAChC,UAAI,KAAK,eAAe,GAAG;AACzB,cAAM,IAAI,MAAM,mCAAmC,OAAO,EAAE;AAAA,MAC9D;AAAA,IACF,SAAS,OAAO;AACd,UAAK,MAAgC,SAAS,SAAU;AACxD,YAAM;AAAA,IACR;AAAA,EACF;AACF;AAUA,eAAsB,YACpB,MACA,UACA,SACe;AACf,QAAM,gBAAgB,GAAG,IAAI,IAAI,QAAQ,GAAG,IAAI,KAAK,IAAI,CAAC;AAC1D,QAAM,SAAS,MAAM,KAAK,eAAe,MAAM,SAAS,QAAQ,GAAK;AACrE,MAAI;AACF,UAAM,OAAO,UAAU,UAAU,MAAM;AACvC,UAAM,OAAO,KAAK;AAAA,EACpB,UAAE;AACA,UAAM,OAAO,MAAM;AAAA,EACrB;AACA,QAAM,OAAO,eAAe,IAAI;AAClC;AAQO,SAAS,iBAAiB,UAA4D;AAC3F,MAAI,CAAC,SAAS,WAAW,OAAO,GAAG;AACjC,UAAM,IAAI,MAAM,kDAAkD;AAAA,EACpE;AACA,QAAM,eAAe,SAAS,QAAQ,WAAW,CAAC;AAClD,MAAI,iBAAiB,IAAI;AACvB,UAAM,IAAI,MAAM,mDAAmD;AAAA,EACrE;AACA,SAAO;AAAA,IACL,aAAa,SAAS,MAAM,GAAG,YAAY;AAAA,IAC3C,SAAS,SAAS,MAAM,eAAe,CAAC;AAAA,EAC1C;AACF;AAOO,SAAS,gBAAgB,iBAAyB,MAAsB;AAC7E,QAAM,qBAAqB,gBAAgB,QAAQ;AACnD,QAAM,UAAU,KAAK,SAAS,IAAI,IAAI,OAAO,GAAG,IAAI;AAAA;AACpD,SAAO;AAAA,EAAQ,kBAAkB;AAAA;AAAA,EAAU,OAAO;AACpD;","names":[]}
|