import { readFileSync } from "node:fs"; import { relative, resolve, sep } from "node:path"; export type KnowledgeKind = | "agent" | "concept" | "journal" | "lesson" | "map" | "organization" | "person" | "place" | "practice" | "project"; interface SourceMapping { pattern: string; decision: "stage" | "manual" | "deny"; kind: KnowledgeKind; topics: string[]; } export interface KnowledgePolicy { version: number; defaultDecision: "deny"; vaultRoot: string; sourceMappings: SourceMapping[]; blockedPrefixes: string[]; blockedWikilinkPrefixes: string[]; allowedRelationshipPredicates: string[]; allowedPeopleSlugs: string[]; peopleDefault: "deny"; } export interface SourceDecision { relativePath: string; decision: SourceMapping["decision"] | "deny"; mapping?: SourceMapping; reason: string; } export function loadKnowledgePolicy( path = resolve(process.cwd(), "knowledge/policy.json"), ): KnowledgePolicy { const policy = JSON.parse(readFileSync(path, "utf8")) as KnowledgePolicy; policy.vaultRoot = policy.vaultRoot.replace( /^\$HOME(?=\/|$)/, process.env.HOME ?? "", ); return policy; } function normalizePath(path: string): string { return path.split(sep).join("/").replace(/^\.\//, ""); } function matchesPattern(path: string, pattern: string): boolean { const escaped = pattern .replace(/[.+?^${}()|[\]\\]/g, "\\$&") .replace(/\*\*/g, "\0") .replace(/\*/g, "[^/]*") .replace(/\0/g, ".*"); return new RegExp(`^${escaped}$`).test(path); } export function decideKnowledgeSource( sourcePath: string, policy: KnowledgePolicy, ): SourceDecision { const vaultRoot = resolve(policy.vaultRoot); const absolute = resolve(sourcePath); const relativePath = normalizePath(relative(vaultRoot, absolute)); if (relativePath.startsWith("../") || relativePath === "..") { return { relativePath, decision: "deny", reason: "source is outside the configured vault" }; } const blocked = policy.blockedPrefixes.find((prefix) => relativePath.startsWith(prefix)); if (blocked) { return { relativePath, decision: "deny", reason: `source is under blocked prefix ${blocked}` }; } const mapping = policy.sourceMappings.find((item) => matchesPattern(relativePath, item.pattern)); if (!mapping) { return { relativePath, decision: policy.defaultDecision, reason: "no allowlisted source mapping" }; } return { relativePath, decision: mapping.decision, mapping, reason: `matched ${mapping.pattern}`, }; } export interface PrivacyFinding { severity: "block" | "review"; code: string; detail: string; } export function scanKnowledgeDraft( body: string, policy: KnowledgePolicy, ): PrivacyFinding[] { const findings: PrivacyFinding[] = []; const wikilinks = [...body.matchAll(/\[\[([^\]|#]+)(?:[|#][^\]]*)?\]\]/g)]; for (const match of wikilinks) { const target = match[1].trim(); const blocked = policy.blockedWikilinkPrefixes.find((prefix) => target.startsWith(prefix)); findings.push({ severity: blocked ? "block" : "review", code: blocked ? "private-wikilink" : "unmapped-wikilink", detail: target, }); } const checks: Array<[RegExp, PrivacyFinding["severity"], string, string]> = [ [/\/(?:home|Users)\/[A-Za-z0-9._/-]+/g, "block", "local-path", "local filesystem path"], [/\b[\w.+-]+@[\w.-]+\.[A-Za-z]{2,}\b/g, "review", "email-address", "email address"], [/(?:telegram|signal|discord):\s*\d+/gi, "block", "private-route", "private channel identifier"], [/\b(?:api[_ -]?key|app password|secret|credential)\b/gi, "review", "credential-language", "credential-related language"], [/<(?:script|iframe|object|embed)\b/gi, "block", "active-html", "active HTML"], ]; for (const [pattern, severity, code, label] of checks) { if (pattern.test(body)) findings.push({ severity, code, detail: label }); } return findings; } export function publicizeObsidianMarkdown(body: string, title: string): string { let transformed = body .replace(/^\[\[(?:agents\/co-3|co-3)(?:\|[^\]]+)?\]\]\s+said:\s*\n+/im, "") .replace(/\[\[([^\]|#]+)\|([^\]]+)\]\]/g, "$2") .replace(/\[\[([^\]#]+)(?:#[^\]]+)?\]\]/g, (_match, target: string) => { const label = target.split("/").pop() ?? target; return label.replace(/[-_]/g, " "); }) .trim(); const firstHeading = transformed.match(/^#\s+(.+)\n+/); if (firstHeading?.[1].trim().toLowerCase() === title.trim().toLowerCase()) { transformed = transformed.slice(firstHeading[0].length).trim(); } return transformed; }