diff --git a/.agents/artifacts/TECHNICAL_DEBT-wave26.md b/.agents/artifacts/TECHNICAL_DEBT-wave26.md index b4e9e4b..d77b085 100644 --- a/.agents/artifacts/TECHNICAL_DEBT-wave26.md +++ b/.agents/artifacts/TECHNICAL_DEBT-wave26.md @@ -8,4 +8,4 @@ | W26-4 | `associated` is ids-only and not fused into ranking/token accounting | `src/retrieval.ts` | Fuse with `why_retrieved` vocabulary or document the consumer contract | | W26-5 | Recursive CTE work is not bounded by `maxNodes` before projection | `src/associative.ts` | Push the limit into the recursive member | -- **W26-6**: no audit event for cross-project (`scope: "all"`) or untrusted reads; the audit enum has no read operation. +- ~~**W26-6**: no audit event for cross-project/untrusted reads~~ **done**: `read` audit operation and events. diff --git a/.agents/context/current.md b/.agents/context/current.md index d7648b3..1d5d0e0 100644 --- a/.agents/context/current.md +++ b/.agents/context/current.md @@ -180,6 +180,7 @@ on any blocking failure, so CI runs it. Report: --- ## 🏁 Recently Landed Waves (Verified on `dev`) +- [x] **Sensitive-read audit + project identity** (2026-11-09) — `read` audit events for `scope: "all"`/untrusted; configured project via env/config/`memory project`. - [x] **Code-context security boundary** (2026-11-09) — project-scoped by default, untrusted opt-in, read-time redaction/sanitization, bounded inputs; `muse_memory_for_code` accepts project/include_untrusted. - [x] **Unified code resolver** (2026-11-09) — `resolveMemoriesForCode` is the single matcher; pre-flight and PR context adapt it. - [x] **Pre-flight surfaces** (2026-11-09) — PreToolUse hook + read-only `muse_preflight` MCP tool; 119 tools. diff --git a/claims.json b/claims.json index e812efa..f601fdb 100644 --- a/claims.json +++ b/claims.json @@ -14,8 +14,8 @@ "networkMillis": 300 }, "tests": { - "count": 1501, - "assertions": 7207, - "files": 234 + "count": 1503, + "assertions": 7211, + "files": 235 } } diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 04639eb..77e0fb7 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -60,6 +60,7 @@ Context & Retrieval Commands: embed [status|build|reindex] Manage semantic embeddings (llama.cpp / OpenAI-compatible endpoint; opt-in) recall [query] Search active/confirmed memories with filters preflight [files...] Speculative context for the files being edited + project [name] Show or set the configured project identity search-transcript [file.jsonl] [--window N] Full-text transcript search with bookends Wiki & Entity Knowledge Compounding: diff --git a/src/cli.ts b/src/cli.ts index 4a601c8..5dd643c 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -30,6 +30,7 @@ import { handleHarvestAutoCommand, handleReindexCommand, } from "./cli/retrieval.ts"; +import { handleProjectCommand } from "./cli/project.ts"; import { handleCurrentCommand, handleUserCommand, handleCoreCommand } from "./cli/persona.ts"; import { handleEmbedCommand } from "./cli/embed.ts"; import { handleWikiCommand } from "./cli/wiki.ts"; @@ -139,6 +140,7 @@ Context & Retrieval Commands: embed [status|build|reindex] Manage semantic embeddings (llama.cpp / OpenAI-compatible endpoint; opt-in) recall [query] Search active/confirmed memories with filters preflight [files...] Speculative context for the files being edited + project [name] Show or set the configured project identity search-transcript [file.jsonl] [--window N] Full-text transcript search with bookends Wiki & Entity Knowledge Compounding: @@ -257,6 +259,8 @@ export async function main(argv: string[]): Promise { return handleRecallCommand(parsed); case "preflight": return handlePreflightCommand(parsed); + case "project": + return handleProjectCommand(parsed); case "search-transcript": case "search-transcripts": return handleSearchTranscriptCommand(parsed); diff --git a/src/cli/project.ts b/src/cli/project.ts new file mode 100644 index 0000000..b2e2c2a --- /dev/null +++ b/src/cli/project.ts @@ -0,0 +1,30 @@ +import { existsSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { requireRoot, usageError } from "./shared.ts"; +import type { ParsedArgs } from "./shared.ts"; +import { resolveProjectIdentity } from "../retrieval/code_context.ts"; + +export async function handleProjectCommand({ positional, flags }: ParsedArgs): Promise { + const ctx = requireRoot(flags); + if (!ctx) return 1; + const name = positional[0]; + if (!name) { + const identity = resolveProjectIdentity(ctx.store); + console.log(`${identity.project} (${identity.source})`); + return 0; + } + if (!/^[A-Za-z0-9._-]+$/.test(name)) return usageError("project name must match [A-Za-z0-9._-]+"); + const configPath = join(ctx.memoryDir, "config.json"); + let config: Record = {}; + if (existsSync(configPath)) { + try { + config = JSON.parse(readFileSync(configPath, "utf8")) as Record; + } catch { + return usageError(`cannot parse ${configPath}`); + } + } + config.project = name; + writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n", "utf8"); + console.log(`project set to ${name}`); + return 0; +} diff --git a/src/retrieval/code_context.ts b/src/retrieval/code_context.ts index 5d3a61a..18c861b 100644 --- a/src/retrieval/code_context.ts +++ b/src/retrieval/code_context.ts @@ -1,5 +1,8 @@ +import { existsSync, readFileSync } from "node:fs"; +import { basename, dirname, join } from "node:path"; import type { Store } from "../store.ts"; import { list } from "../store.ts"; +import { recordAuditEvent } from "../audit.ts"; import type { ScoredEntry } from "../retrieval.ts"; import type { MemoryEntry } from "../types.ts"; @@ -48,20 +51,87 @@ function isExpired(entry: MemoryEntry, now: number): boolean { return false; } -/** Dominant project in the store; keeps unscoped callers fail-closed. */ -export function inferProject(store: Store): string { +export interface ProjectIdentity { + project: string; + source: "env" | "config" | "inferred" | "directory" | "default"; +} + +function dominantProject(store: Store): string | undefined { const counts = new Map(); for (const entry of list(store)) { if (entry.project) counts.set(entry.project, (counts.get(entry.project) ?? 0) + 1); } const ranked = [...counts.entries()].sort((a, b) => b[1] - a[1]); - if (ranked.length === 0) return "default"; + if (ranked.length === 0) return undefined; if (ranked.length > 1 && ranked[0][1] === ranked[1][1]) { - throw new Error("Ambiguous project scope: pass an explicit project"); + throw new Error("Ambiguous project scope: pass an explicit project or configure one"); } return ranked[0][0]; } +/** + * Explicit project identity for CLI/MCP defaults: + * env override → .memory/config.json project → unambiguous store project → + * workspace directory name → "default". + */ +export function resolveProjectIdentity(store: Store): ProjectIdentity { + const env = process.env.MUSE_PROJECT?.trim(); + if (env) return { project: env, source: "env" }; + + const memoryDir = store.memoryDir ?? store.dir; + const configPath = join(memoryDir, "config.json"); + if (existsSync(configPath)) { + try { + const config = JSON.parse(readFileSync(configPath, "utf8")) as { project?: unknown }; + if (typeof config.project === "string" && config.project.trim()) { + return { project: config.project.trim(), source: "config" }; + } + } catch { + // invalid config must not block reads; fall through to inference + } + } + + const inferred = dominantProject(store); + if (inferred) return { project: inferred, source: "inferred" }; + + const directory = basename(dirname(memoryDir)); + if (directory && directory !== "." && directory !== "/") { + return { project: directory, source: "directory" }; + } + return { project: "default", source: "default" }; +} + +export function inferProject(store: Store): string { + return resolveProjectIdentity(store).project; +} + +function auditSensitiveRead( + store: Store, + input: CodeContextInput, + project: string | undefined, + matches: number, +): void { + if (input.scope !== "all" && input.includeUntrusted !== true) return; + try { + recordAuditEvent(store.memoryDir ?? store.dir, { + operation: "read", + entry_id: "code_context", + project, + actor: "retrieval", + reason: input.scope === "all" ? "cross_project_read" : "untrusted_read", + details: { + scope: input.scope ?? "project", + includeUntrusted: input.includeUntrusted === true, + files: (input.files ?? []).slice(0, 15), + symbols: (input.symbols ?? []).slice(0, 50), + matches, + }, + }); + } catch { + // audit must never break a read + } +} + /** * W26-1: the single file/symbol → memory resolver. Both the pre-flight * anchored-invariant surface and the PR-context resolver are adapters over @@ -107,7 +177,7 @@ export function resolveMemoriesForCode(store: Store, input: CodeContextInput): S matched.push({ entry, score: Math.min(1, base + anchorBoost + verified), why_retrieved: [reason] }); } - return matched + const result = matched .sort((a, b) => { const byScore = b.score - a.score; if (byScore !== 0) return byScore; @@ -116,4 +186,6 @@ export function resolveMemoriesForCode(store: Store, input: CodeContextInput): S return (Number.isNaN(bt) ? 0 : bt) - (Number.isNaN(at) ? 0 : at); }) .slice(0, Math.min(Math.max(input.limit ?? 10, 1), 50)); + auditSensitiveRead(store, input, project, result.length); + return result; } diff --git a/src/types.ts b/src/types.ts index b880fef..9445e2f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -387,6 +387,7 @@ export type AuditOperation = | "verify" | "verify_refused" | "restore" + | "read" | "conflict_detected" | "conflict_detected_dry_run" | "conflict_resolved" diff --git a/test/security_read_audit.test.ts b/test/security_read_audit.test.ts new file mode 100644 index 0000000..c9a2186 --- /dev/null +++ b/test/security_read_audit.test.ts @@ -0,0 +1,51 @@ +import { describe, test, expect } from "bun:test"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { openStore, propose, save, get } from "../src/store.ts"; +import { resolveMemoriesForCode, resolveProjectIdentity } from "../src/retrieval/code_context.ts"; +import { getAuditTrail } from "../src/audit.ts"; + +function fixture() { + const root = mkdtempSync(join(tmpdir(), "muse-read-audit-")); + const memoryDir = join(root, ".memory"); + mkdirSync(memoryDir, { recursive: true }); + const store = openStore(memoryDir); + return { root, memoryDir, store }; +} + +describe("security: sensitive reads and project identity", () => { + test("cross-project and untrusted reads are audited; normal reads are not", () => { + const { memoryDir, store } = fixture(); + try { + const own = propose(store, { content: "own", project: "p", type: "constraint", confirmed: true }); + save(store, { ...get(store, own.id)!, anchors: [{ id: "a1", kind: "file", file_path: "src/a.ts" }] }); + const foreign = propose(store, { content: "foreign", project: "other", type: "constraint", confirmed: true }); + save(store, { ...get(store, foreign.id)!, anchors: [{ id: "a2", kind: "file", file_path: "src/a.ts" }] }); + + resolveMemoriesForCode(store, { files: ["src/a.ts"], project: "p" }); + expect(getAuditTrail(memoryDir, { operation: "read" })).toHaveLength(0); + + resolveMemoriesForCode(store, { files: ["src/a.ts"], scope: "all" }); + resolveMemoriesForCode(store, { files: ["src/a.ts"], project: "p", includeUntrusted: true }); + const reads = getAuditTrail(memoryDir, { operation: "read" }); + expect(reads).toHaveLength(2); + expect(reads.map((entry) => entry.reason).sort()).toEqual(["cross_project_read", "untrusted_read"]); + } finally { + rmSync(join(memoryDir, ".."), { recursive: true, force: true }); + } + }); + + test("configured project identity overrides dominant inference", () => { + const { memoryDir, store } = fixture(); + try { + propose(store, { content: "one", project: "p", confirmed: true }); + propose(store, { content: "two", project: "p", confirmed: true }); + propose(store, { content: "three", project: "other", confirmed: true }); + writeFileSync(join(memoryDir, "config.json"), JSON.stringify({ project: "other" }), "utf8"); + expect(resolveProjectIdentity(store)).toEqual({ project: "other", source: "config" }); + } finally { + rmSync(join(memoryDir, ".."), { recursive: true, force: true }); + } + }); +});