Skip to content

Commit 9de0699

Browse files
committed
fix: close 5 findings from Oracle audit wave 4 (2 security P0 + 3 correctness)
Three parallel read-only Oracles (search/recall; tagger core + token caching; CLI + config security). Cores verified safe (composite tag identity, FIFO owner derivation, tagger cache invalidation, strip/replay purity; unified single-embedding, git-commit execFile safety, no sqlite-vec; runtime config secret boundary + SSRF guard + atomic raw-merge). 5 source-confirmed fixes; cache/embedding-identity items banked (D2 reinforced, D10-D14). SECURITY P0 (the malicious-repo boundary — CLI side bypassed the runtime's hardening): - Pi doctor embedding probe expanded project-config {env:}/{file:} tokens (doctor-pi.ts). A repo's .pi/magic-context.jsonc could resolve {env:ANTHROPIC_API_KEY} and we'd send it to a repo-chosen endpoint. Now read project config with isProjectConfig:true (tokens stay literal), strip unsafe project fields, and drop the inherited user api_key on endpoint redirect — mirroring the runtime loader. - Pi doctor/issue redaction was weaker than OpenCode (diagnostics-pi.ts): a log line with github_pat_/ghp_/AKIA/hf_/JWT/Slack tokens was NOT redacted and could land in a public `doctor --issue` report; the raw "Last plugin log line" was printed unsanitized. Now delegate to the shared comprehensive redactor (kept the original looser sk-/Bearer/key= patterns as a SUPERSET so short tokens still redact), and sanitizeDiagnosticText the last-log-line print. CORRECTNESS: - message-index single-message double-insert (message-index.ts). indexSingleMessage used a DEFERRED transaction; FTS5 has no UNIQUE, so two processes handling the same terminal message.updated could both pass the already-indexed check and double-insert. Switched to BEGIN IMMEDIATE so the in-lock re-check serializes — mirrors indexMessagesAfterOrdinal. - search ordinal cutoff applied after LIMIT (search.ts). runMessageFtsQuery fetched LIMIT*3 rows then filtered messageOrdinal>cutoff in JS, so when the top-ranked rows are all live-tail, older eligible hits below the limit are never seen and explicit ctx_search could return nothing. Added a cutoff-aware SQL statement (predicate before LIMIT); hot-path auto-search (cutoff=null) unchanged. - config array-element recovery collapsed to all-defaults (prune-config-leaf.ts). An invalid array leaf (e.g. system_prompt_injection.skip_signatures[0]) made pruneNestedConfigLeaf return null → recovery dropped the WHOLE config to defaults, losing valid siblings. Now prune the nearest owning field (→ its default) when the path descends into a non-object. +2 tests. Gate: plugin 2176/0, Pi 471/0, CLI 179/0, tsc+biome clean all.
1 parent 6ba4aa1 commit 9de0699

6 files changed

Lines changed: 147 additions & 20 deletions

File tree

packages/cli/src/commands/doctor-pi.ts

Lines changed: 44 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,10 @@ import {
1111
import { createRequire } from "node:module";
1212
import { homedir } from "node:os";
1313
import { dirname, isAbsolute, join } from "node:path";
14-
14+
import {
15+
dropInheritedEmbeddingKeyOnRedirect,
16+
stripUnsafeProjectConfigFields,
17+
} from "@magic-context/core/config/project-security";
1518
import { MagicContextConfigSchema } from "@magic-context/core/config/schema/magic-context";
1619
import { substituteConfigVariables } from "@magic-context/core/config/variable";
1720
import {
@@ -48,6 +51,7 @@ import {
4851
isPiMagicContextPackageEntry,
4952
} from "../lib/pi-package-entry";
5053
import { type PromptIO, promptIO } from "../lib/prompts";
54+
import { sanitizeDiagnosticText } from "../lib/redaction";
5155
import { runV22BackfillCommands, type V22BackfillCommandArgs } from "../lib/v22-backfill-commands";
5256
import { writePiSettingsPackage } from "./setup-pi";
5357

@@ -264,13 +268,21 @@ function projectConfigPath(cwd: string): string {
264268
return join(cwd, ".pi", "magic-context.jsonc");
265269
}
266270

267-
function readConfigForEmbedding(path: string): Record<string, unknown> | null {
271+
function readConfigForEmbedding(
272+
path: string,
273+
isProjectConfig: boolean,
274+
): Record<string, unknown> | null {
268275
if (!existsSync(path)) return null;
269276
try {
270277
const rawText = readFileSync(path, "utf-8");
278+
// SECURITY: project-level config must NOT expand {env:}/{file:} tokens —
279+
// a malicious repo could otherwise resolve {env:ANTHROPIC_API_KEY} into a
280+
// field we then send to a repo-chosen endpoint. Mirror the runtime loader
281+
// (isProjectConfig leaves tokens literal for project config).
271282
const substituted = substituteConfigVariables({
272283
text: rawText,
273284
configPath: path,
285+
isProjectConfig,
274286
});
275287
return parseJsonc(substituted.text) as Record<string, unknown>;
276288
} catch {
@@ -579,16 +591,33 @@ async function runHealthChecks(options: {
579591
options.deps.closeDatabase();
580592
}
581593

582-
const embeddingConfigs = [userConfigPath, projectPath]
583-
.map(readConfigForEmbedding)
584-
.filter((config): config is Record<string, unknown> => config !== null);
594+
// Read user config (tokens expand) and project config (tokens stay literal —
595+
// secret boundary) separately so we can apply the same redirect-drop the
596+
// runtime loader does: if the PROJECT redirects embedding.endpoint without its
597+
// own api_key, the inherited USER api_key must NOT be merged in (it would be
598+
// sent to the repo-chosen endpoint — exfiltration).
599+
const userRaw = readConfigForEmbedding(userConfigPath, false);
600+
const projectRaw = readConfigForEmbedding(projectPath, true);
601+
if (projectRaw) {
602+
// Strip project-config fields that must never come from a repo (agent
603+
// prompts, sqlite.*, etc.) before they can influence the probe.
604+
stripUnsafeProjectConfigFields(projectRaw);
605+
}
585606
const mergedEmbedding: Record<string, unknown> = {};
586-
for (const config of embeddingConfigs) {
587-
const embedding = config.embedding;
607+
for (const config of [userRaw, projectRaw]) {
608+
const embedding = config?.embedding;
588609
if (embedding && typeof embedding === "object" && !Array.isArray(embedding)) {
589610
Object.assign(mergedEmbedding, embedding);
590611
}
591612
}
613+
// Drop the inherited user api_key if the project redirected the endpoint.
614+
if (projectRaw) {
615+
dropInheritedEmbeddingKeyOnRedirect(
616+
projectRaw,
617+
{ embedding: mergedEmbedding },
618+
userRaw ?? undefined,
619+
);
620+
}
592621
if (mergedEmbedding.provider === "openai-compatible") {
593622
const endpoint =
594623
typeof mergedEmbedding.endpoint === "string" ? mergedEmbedding.endpoint.trim() : "";
@@ -715,7 +744,14 @@ async function runHealthChecks(options: {
715744
.map((line) => line.trim())
716745
.filter(Boolean);
717746
add(results, "info", `Log file: ${logPath} (${sizeKb} KB)`);
718-
add(results, "info", `Last plugin log line: ${lines.at(-1) ?? "<empty log>"}`);
747+
// Sanitize before printing — a raw log line can carry a secret/path the
748+
// user then pastes into a public issue.
749+
const lastLine = lines.at(-1);
750+
add(
751+
results,
752+
"info",
753+
`Last plugin log line: ${lastLine ? sanitizeDiagnosticText(lastLine) : "<empty log>"}`,
754+
);
719755
} else {
720756
add(results, "info", `No plugin log file yet at ${logPath}`);
721757
}

packages/cli/src/lib/diagnostics-pi.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import {
2424
hasPiMagicContextPackage,
2525
isPiMagicContextPackageEntry,
2626
} from "./pi-package-entry";
27+
import { redactSecretText } from "./redaction";
2728

2829
export interface PiConfigDiagnostic {
2930
path: string;
@@ -162,7 +163,13 @@ function currentUserHash(): string {
162163
}
163164

164165
function redactSecretString(value: string): string {
165-
return value
166+
// Apply the shared comprehensive redactor (OpenCode parity: adds
167+
// github_pat_/ghp_/hf_/AKIA/Slack/Google/JWT and generic key=value forms that
168+
// the bespoke version leaked) AND then the original looser patterns as a
169+
// SUPERSET — the shared `sk-` pattern requires 32+ chars (real key length),
170+
// so keep the looser `sk-{12,}` here too so short/synthetic sk- tokens are
171+
// still caught. Redaction is safer over-broad than under.
172+
return redactSecretText(value)
166173
.replace(/Bearer\s+[A-Za-z0-9._~+\-/=]+/g, "Bearer <REDACTED>")
167174
.replace(/sk-[A-Za-z0-9_-]{12,}/g, "sk-<REDACTED>")
168175
.replace(/api[_-]?key=([^\s&]+)/gi, "api_key=<REDACTED>")

packages/plugin/src/config/prune-config-leaf.test.ts

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,9 +32,32 @@ describe("pruneNestedConfigLeaf", () => {
3232
expect(block.git_commit_indexing).toEqual({ enabled: false, since_days: 99999 });
3333
});
3434

35-
it("returns null when an intermediate path segment is not an object", () => {
36-
const block = { git_commit_indexing: true };
37-
expect(pruneNestedConfigLeaf(block, ["git_commit_indexing", "since_days"])).toBeNull();
35+
it("prunes the owning field when an intermediate segment is not an object", () => {
36+
// The path descends into a non-object (here `git_commit_indexing` is a
37+
// bare bool, not a block). We can't reach `since_days`, but we prune the
38+
// owning field so it falls back to its default — NOT collapse to all
39+
// defaults (which dropping to null upstream would cause).
40+
const block = { git_commit_indexing: true, keep_me: { enabled: false } };
41+
const result = pruneNestedConfigLeaf(block, ["git_commit_indexing", "since_days"]);
42+
expect(result).not.toBeNull();
43+
expect(result?.removed).toBe("git_commit_indexing");
44+
expect(result?.block).toEqual({ keep_me: { enabled: false } });
45+
});
46+
47+
it("prunes an invalid array-element leaf to its owning field", () => {
48+
// system_prompt_injection.skip_signatures[0] invalid → Zod path descends
49+
// into the array. Prune skip_signatures (→ default), keep siblings.
50+
const block = {
51+
system_prompt_injection: { enabled: true, skip_signatures: [123] },
52+
};
53+
const result = pruneNestedConfigLeaf(block, [
54+
"system_prompt_injection",
55+
"skip_signatures",
56+
0,
57+
]);
58+
expect(result).not.toBeNull();
59+
expect(result?.removed).toBe("system_prompt_injection.skip_signatures");
60+
expect(result?.block).toEqual({ system_prompt_injection: { enabled: true } });
3861
});
3962

4063
it("returns null when the leaf is absent", () => {

packages/plugin/src/config/prune-config-leaf.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,23 @@ export function pruneNestedConfigLeaf(
3939
for (let i = 0; i < relativePath.length - 1; i++) {
4040
const seg = String(relativePath[i]);
4141
const child = cursor[seg];
42-
if (!isPlainObject(child)) return null;
42+
if (!isPlainObject(child)) {
43+
// The path descends into a non-object (e.g. an ARRAY element, like an
44+
// invalid `system_prompt_injection.skip_signatures[0]`). We can't reach
45+
// the deeper leaf — but we CAN prune the nearest OWNING field (the
46+
// array/primitive at `seg`) so it falls back to its schema default,
47+
// preserving valid siblings. Without this we'd return null → the whole
48+
// config collapses to all-defaults, dropping unrelated user settings.
49+
if (!(seg in cursor)) return null;
50+
delete cursor[seg];
51+
return {
52+
block: result,
53+
removed: relativePath
54+
.slice(0, i + 1)
55+
.map(String)
56+
.join("."),
57+
};
58+
}
4359
const clonedChild: Record<string, unknown> = { ...child };
4460
cursor[seg] = clonedChild;
4561
cursor = clonedChild;

packages/plugin/src/features/magic-context/message-index.ts

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -188,9 +188,29 @@ function indexSingleMessageInTransaction(
188188
}
189189

190190
export function indexSingleMessage(db: Database, sessionId: string, message: RawMessage): boolean {
191-
return db.transaction(() =>
192-
indexSingleMessageInTransaction(db, sessionId, message, Date.now()),
193-
)();
191+
// BEGIN IMMEDIATE (not a deferred db.transaction): message_history_fts is a
192+
// plain FTS5 table with NO UNIQUE constraint, and the dedup is the
193+
// isMessageAlreadyIndexed SELECT inside the body. Under a DEFERRED transaction
194+
// two processes handling the same terminal message.updated can both pass that
195+
// SELECT before either inserts → duplicate FTS rows. Taking the writer lock up
196+
// front serializes them, so the second's in-lock re-check sees the first's
197+
// insert and skips. Mirrors indexMessagesAfterOrdinal.
198+
db.exec("BEGIN IMMEDIATE");
199+
let committed = false;
200+
try {
201+
const result = indexSingleMessageInTransaction(db, sessionId, message, Date.now());
202+
db.exec("COMMIT");
203+
committed = true;
204+
return result;
205+
} finally {
206+
if (!committed) {
207+
try {
208+
db.exec("ROLLBACK");
209+
} catch {
210+
// already closed by an earlier failure
211+
}
212+
}
213+
}
194214
}
195215

196216
export function indexMessagesAfterOrdinal(

packages/plugin/src/features/magic-context/search.ts

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ interface MessageSearchRow {
5555
}
5656

5757
const messageSearchStatements = new WeakMap<Database, PreparedStatement>();
58+
const messageSearchStatementsWithCutoff = new WeakMap<Database, PreparedStatement>();
5859

5960
export type SearchSource = "memory" | "message" | "git_commit";
6061

@@ -251,6 +252,25 @@ function getMessageSearchStatement(db: Database): PreparedStatement {
251252
return stmt;
252253
}
253254

255+
/**
256+
* Cutoff-aware variant: filters `message_ordinal <= cutoff` IN SQL, BEFORE the
257+
* LIMIT. The JS-side post-filter in runMessageFtsQuery applies the cutoff AFTER
258+
* fetching `LIMIT` rows, so when the top-ranked rows are all live-tail (above the
259+
* cutoff) they're fetched-then-discarded and older eligible hits below the limit
260+
* are never seen — explicit ctx_search could then return nothing. Pushing the
261+
* predicate into SQL makes LIMIT count only already-eligible rows.
262+
*/
263+
function getMessageSearchStatementWithCutoff(db: Database): PreparedStatement {
264+
let stmt = messageSearchStatementsWithCutoff.get(db);
265+
if (!stmt) {
266+
stmt = db.prepare(
267+
"SELECT message_ordinal AS messageOrdinal, message_id AS messageId, role, content FROM message_history_fts WHERE session_id = ? AND message_history_fts MATCH ? AND CAST(message_ordinal AS INTEGER) <= ? ORDER BY bm25(message_history_fts), CAST(message_ordinal AS INTEGER) ASC LIMIT ?",
268+
);
269+
messageSearchStatementsWithCutoff.set(db, stmt);
270+
}
271+
return stmt;
272+
}
273+
254274
const ftsRowCountStatements = new WeakMap<Database, PreparedStatement>();
255275
const ftsMatchCountStatements = new WeakMap<Database, PreparedStatement>();
256276

@@ -614,9 +634,13 @@ function runMessageFtsQuery(
614634
cutoff: number | null,
615635
): NormalizedMessageRow[] {
616636
if (ftsQuery.length === 0) return [];
617-
const rows = getMessageSearchStatement(db)
618-
.all(sessionId, ftsQuery, fetchLimit)
619-
.map((row) => row as MessageSearchRow);
637+
// Apply the ordinal cutoff IN SQL (before LIMIT) so live-tail matches can't
638+
// crowd out older eligible hits; null cutoff keeps the original statement.
639+
const rows = (
640+
cutoff !== null
641+
? getMessageSearchStatementWithCutoff(db).all(sessionId, ftsQuery, cutoff, fetchLimit)
642+
: getMessageSearchStatement(db).all(sessionId, ftsQuery, fetchLimit)
643+
).map((row) => row as MessageSearchRow);
620644

621645
const result: NormalizedMessageRow[] = [];
622646
for (const row of rows) {
@@ -629,7 +653,8 @@ function runMessageFtsQuery(
629653
) {
630654
continue;
631655
}
632-
// Skip messages still in the live context (not yet compartmentalized).
656+
// Defense-in-depth: the SQL cutoff above already excluded these, but keep
657+
// the guard so a future caller that skips the cutoff statement stays safe.
633658
if (cutoff !== null && messageOrdinal > cutoff) {
634659
continue;
635660
}

0 commit comments

Comments
 (0)