Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .agents/artifacts/TECHNICAL_DEBT-wave26.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
1 change: 1 addition & 0 deletions .agents/context/current.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
6 changes: 3 additions & 3 deletions claims.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@
"networkMillis": 300
},
"tests": {
"count": 1501,
"assertions": 7207,
"files": 234
"count": 1503,
"assertions": 7211,
"files": 235
}
}
1 change: 1 addition & 0 deletions docs/cli-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <query> [file.jsonl] [--window N] Full-text transcript search with bookends

Wiki & Entity Knowledge Compounding:
Expand Down
4 changes: 4 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 <query> [file.jsonl] [--window N] Full-text transcript search with bookends

Wiki & Entity Knowledge Compounding:
Expand Down Expand Up @@ -257,6 +259,8 @@ export async function main(argv: string[]): Promise<number> {
return handleRecallCommand(parsed);
case "preflight":
return handlePreflightCommand(parsed);
case "project":
return handleProjectCommand(parsed);
case "search-transcript":
case "search-transcripts":
return handleSearchTranscriptCommand(parsed);
Expand Down
30 changes: 30 additions & 0 deletions src/cli/project.ts
Original file line number Diff line number Diff line change
@@ -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<number> {
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<string, unknown> = {};
if (existsSync(configPath)) {
try {
config = JSON.parse(readFileSync(configPath, "utf8")) as Record<string, unknown>;
} 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;
}
82 changes: 77 additions & 5 deletions src/retrieval/code_context.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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<string, number>();
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
Expand Down Expand Up @@ -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;
Expand All @@ -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;
}
1 change: 1 addition & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -387,6 +387,7 @@ export type AuditOperation =
| "verify"
| "verify_refused"
| "restore"
| "read"
| "conflict_detected"
| "conflict_detected_dry_run"
| "conflict_resolved"
Expand Down
51 changes: 51 additions & 0 deletions test/security_read_audit.test.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
});
});
Loading