Skip to content

Commit 5f2e81d

Browse files
Ufuk Altinokalfonso-magic-context
andcommitted
Fix re-audit Councils A+B findings (FK scope, identity divergence, lease split-brain, child leak, Pi primer seed)
Round-2 blind re-audit, councils A (embedding) + B (dreamer/smart-notes): Council A (both source-confirmed, both touch this round's code): - A#1: v49 ran a GLOBAL PRAGMA foreign_key_check after the FIRST table rebuild, before the git/chunk orphan pre-cleans ran — a pre-existing orphan in a later table failed the migration closed. Scoped assertForeignKeyIntegrity to the just-rebuilt table. +regression test seeding a chunk-table orphan. - A#2 (regression I introduced adding truncate to identity): the provider constructor's own this.modelId omitted truncate while canonical getEmbeddingProviderIdentity included it → writes/reads under divergent model_ids when truncate set. Mirrored truncate into the constructor. +parity test asserting provider.modelId === getEmbeddingProviderIdentity(config) across all identity-affecting fields. Council B: - cortexkit#5: buildHiddenAgentConfig dropped user permission/tools under lockPermissions but NOT prompt/system — a user dreamer.prompt could clobber the privacy- hardened retrospective prompt. Now strips prompt/system under lock too. +2 tests. - #1: failed map/verify/classify children (memory-pool text) were kept on the !phaseFailed path. Delete on success AND failure; still honor keep_subagents (memory-pool text, not raw user transcripts — unlike retrospective). Removed now-dead phaseFailed in all three runners. - cortexkit#3: lease heartbeat reclaimed an expired-but-free lease even after a >TTL gap, risking split-brain after a multi-minute stall (a sibling could have run in the gap). Declare lost when the gap exceeds a full TTL; short ≤TTL self-inflicted expiry still reclaims. +split-brain regression test. - Pi primer seed (single-reporter, real): scheduled refresh-primers never got primerRawProviderFactory (only the manual /ctx-dream path did) → scheduled Pi ran closed-book, defeating the open-book redesign. Threaded the factory through ProjectRegistration → scheduled executor; Pi supplies its JSONL factory. OpenCode unaffected (buildPrimerSeed reads opencode.db directly). Accepted/false-positive (documented A50/A51): #2 smart-note egress is the deliberate v1 design (manifest advisory, SSRF + secret-denylist are the real boundaries); content-edit-not-invalidating-check is correct (check keys on the trigger, not the body). Gate: plugin 2516/0, Pi 509/0, both lint-clean. Co-authored-by: Alfonso [Magic Context] <288211368+alfonso-magic-context@users.noreply.github.com>
1 parent 591bf8f commit 5f2e81d

14 files changed

Lines changed: 283 additions & 28 deletions

File tree

docs/AUDIT-KNOWN-ISSUES.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -819,3 +819,29 @@ window where it GCs off a stale trusted registration. Pi never calls the GC at
819819
all. Persisting the latch (a migration + cross-process write coordination) would
820820
add complexity for a window that does not exist given this ordering. If the
821821
ensureRegistered-before-GC pairing is ever broken, revisit this.
822+
823+
### A50. Smart-note compiled checks expose readFile + httpGet in one sandbox (egress is the accepted v1 design)
824+
Compiled smart-note checks run in a QuickJS WASM sandbox with BOTH a `readFile`
825+
capability (repo-relative, denylist-guarded) and an `httpGet` capability
826+
(SSRF-guarded, public hosts). An audit recurringly flags this as an exfiltration
827+
P0: a prompt-injected `surface_condition` could author a check that reads a repo
828+
file and POSTs it to an attacker host, and the capability manifest is advisory
829+
(not a runtime allowlist). This is the DELIBERATE v1 decision (see
830+
`.alfonso/plans/smart-notes-compiled-checks-spec.md`): v1 allows all external
831+
egress, the manifest is an audit artifact not a security boundary, and the actual
832+
boundaries are the fail-closed SSRF guard (blocks loopback/RFC1918/link-local/
833+
metadata, pins the socket to the validated IP) and the secret denylist
834+
(`.env*`, `.npmrc`, `.git`, `secrets/`). Rationale: the host agent that authored
835+
the note already reads files and hits the network with FAR fewer limits than the
836+
sandbox, so the marginal risk of sanctioned in-sandbox egress is small, and
837+
per-note user-approved egress was judged not worth the v1 friction. Per-note
838+
egress approval remains the planned hardening if real-world misuse appears. Not a
839+
regression; do not re-flag without new evidence of exploitation.
840+
841+
### A51. Content edits do not invalidate a smart-note's compiled check (correct — the check keys on the trigger, not the body)
842+
An audit flagged that editing a smart note's `content` leaves its compiled check
843+
stale. Verified a FALSE POSITIVE: the compiled check evaluates the
844+
`surface_condition` (the trigger), never the note body. `updateNote` resets the
845+
whole compiled-check lifecycle ONLY when `surface_condition` changes
846+
(`smartConditionChanged` in `storage-notes.ts`), which is correct — a body edit
847+
doesn't change what the check tests, so re-compiling would be wasted work.

packages/pi-plugin/src/dreamer/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,10 @@ export function registerPiDreamerProject(opts: PiDreamerOptions): void {
155155
// /ctx-dream path already uses.
156156
retrospectiveRawProvider: () =>
157157
new PiRetrospectiveRawProvider({ projectCwd: opts.projectDir }),
158+
// SCHEDULED refresh-primers likewise needs the Pi JSONL factory so its
159+
// open-book seed renders raw U:/TC: lines; without it the scheduled task
160+
// silently ran closed-book (the manual /ctx-dream path already wires this).
161+
primerRawProviderFactory: createPiPrimerRawProviderFactory(),
158162
}).then((timerCleanup) => {
159163
if (cancelled) {
160164
// Registration was cancelled before timer setup completed —

packages/plugin/src/agents/hidden-agent-registrations.ts

Lines changed: 27 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -270,21 +270,40 @@ export function buildHiddenAgentConfig(
270270
const {
271271
permission: overridePermission,
272272
tools: overrideTools,
273+
// Pulled out so they can be conditionally re-added: under lockPermissions
274+
// a user `prompt`/`system` override must NOT replace the hardened
275+
// system prompt (see below). Spreading `...rest` below would otherwise
276+
// clobber the positional `prompt` arg, since `...rest` lands after it.
277+
prompt: overridePrompt,
278+
system: overrideSystem,
273279
...rest
274280
} = (overrides ?? {}) as {
275281
permission?: Record<string, unknown>;
276282
tools?: Record<string, boolean>;
283+
prompt?: unknown;
284+
system?: unknown;
277285
[key: string]: unknown;
278286
};
279287
// When locked (privacy-critical agents), the user `tools` enable/disable map
280-
// is ALSO dropped — it is a privilege-escalation surface (a user
281-
// `dreamer.tools: { bash: true }` would otherwise re-enable a denied tool on
282-
// the retrospective agent). Unlocked agents keep their `tools` override.
283-
const restOverrides = lockPermissions
284-
? rest
285-
: overrideTools !== undefined
286-
? { ...rest, tools: overrideTools }
287-
: rest;
288+
// AND the `prompt`/`system` text are ALL dropped — each is a
289+
// privilege/behavior-escalation surface. A user `dreamer.tools: { bash: true }`
290+
// would otherwise re-enable a denied tool; a user `dreamer.prompt`/`system`
291+
// would otherwise replace the privacy-hardened prompt that, e.g., keeps the
292+
// retrospective agent from transcribing raw user text. Unlocked agents keep
293+
// their `tools`/`prompt`/`system` overrides.
294+
const promptOverrides: Record<string, unknown> = lockPermissions
295+
? {}
296+
: {
297+
...(overridePrompt !== undefined ? { prompt: overridePrompt } : {}),
298+
...(overrideSystem !== undefined ? { system: overrideSystem } : {}),
299+
};
300+
const restOverrides: Record<string, unknown> = lockPermissions
301+
? { ...rest, ...promptOverrides }
302+
: {
303+
...rest,
304+
...promptOverrides,
305+
...(overrideTools !== undefined ? { tools: overrideTools } : {}),
306+
};
288307
const basePermission = buildAllowOnlyPermission(allowedTools, agentLabel);
289308
return {
290309
prompt,

packages/plugin/src/features/magic-context/dreamer/classify.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -187,7 +187,6 @@ async function classifyOneChunk(
187187
signal: AbortSignal,
188188
): Promise<{ classified: number; changed: number }> {
189189
let agentSessionId: string | null = null;
190-
let phaseFailed = false;
191190
const startedAt = Date.now();
192191
try {
193192
const createResponse = await args.client.session.create({
@@ -249,7 +248,6 @@ async function classifyOneChunk(
249248
recordInvocation(args, startedAt, { status: "completed", messages: run.output });
250249
return applyClassifications(args, chunk, run.validated);
251250
} catch (error) {
252-
phaseFailed = true;
253251
const desc = describeError(error);
254252
log(
255253
`[dreamer] classify chunk failed: ${desc.brief}`,
@@ -259,7 +257,10 @@ async function classifyOneChunk(
259257
if (signal.aborted) throw error;
260258
return { classified: 0, changed: 0 };
261259
} finally {
262-
if (agentSessionId && !phaseFailed && !shouldKeepSubagents()) {
260+
// Delete on success AND failure (the failed child still holds the
261+
// memory-pool snapshot from the prompt). keep_subagents still honored —
262+
// memory-pool text, not raw user transcripts.
263+
if (agentSessionId && !shouldKeepSubagents()) {
263264
await args.client.session
264265
.delete({
265266
path: { id: agentSessionId },

packages/plugin/src/features/magic-context/dreamer/lease.test.ts

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
/// <reference types="bun-types" />
22

3-
import { describe, expect, it } from "bun:test";
3+
import { describe, expect, it, spyOn } from "bun:test";
44
import { mkdtempSync, rmSync } from "node:fs";
55
import { tmpdir } from "node:os";
66
import { join } from "node:path";
@@ -231,6 +231,47 @@ describe("startLeaseHeartbeat", () => {
231231
closeQuietly(db);
232232
});
233233

234+
it("declares lost (not reclaim) when the lease lapsed past a full TTL — split-brain guard", async () => {
235+
// A >TTL stall (e.g. machine sleep): our lease lapsed and a sibling could
236+
// have acquired AND mutated in the gap. Even though the lease may now be
237+
// free, blindly reclaiming + continuing on our stale snapshot is
238+
// split-brain — the heartbeat must declare lost. (A short ≤TTL gap still
239+
// reclaims; see the transient-tolerant test above.)
240+
const db = makeDb();
241+
const realNow = Date.now();
242+
const clock = { value: realNow };
243+
const nowSpy = spyOn(Date, "now").mockImplementation(() => clock.value);
244+
try {
245+
expect(acquireLease(db, "holder-a")).toBe(true);
246+
let lostReason: string | null = null;
247+
// Short interval so a real timer beat fires; the FIRST synchronous
248+
// beat confirms ownership at t0 (lastConfirmedAt = realNow).
249+
const hb = startLeaseHeartbeat(
250+
db,
251+
"holder-a",
252+
DREAMING_LEASE_KEY,
253+
(reason) => {
254+
lostReason = reason;
255+
},
256+
20,
257+
);
258+
expect(hb.lost).toBe(false);
259+
260+
// Jump the clock 3 minutes (> 2min TTL): our own lease has lapsed
261+
// (isLeaseActive false), so the next beat's renewLease fails and the
262+
// gap exceeds the TTL.
263+
clock.value = realNow + 3 * 60 * 1000;
264+
await sleep(60);
265+
266+
expect(hb.lost).toBe(true);
267+
expect(lostReason).toContain("past TTL");
268+
hb.stop();
269+
} finally {
270+
nowSpy.mockRestore();
271+
closeQuietly(db);
272+
}
273+
});
274+
234275
it("declares lost exactly once when a different holder actively owns the lease", async () => {
235276
const db = makeDb();
236277
expect(acquireLease(db, "holder-a")).toBe(true);

packages/plugin/src/features/magic-context/dreamer/lease.ts

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -187,10 +187,28 @@ export function startLeaseHeartbeat(
187187
const beat = () => {
188188
if (lost) return;
189189
try {
190-
// renew-or-reclaim: renewLease keeps it if still ours; acquireLease
191-
// reclaims an expired/free lease and only returns false when a
192-
// different holder is actively in possession.
193-
if (renewLease(db, holderId, leaseKey) || acquireLease(db, holderId, leaseKey)) {
190+
// Continuous ownership: renewLease keeps it if still ours. This is
191+
// the always-safe path — we never lost the lease.
192+
if (renewLease(db, holderId, leaseKey)) {
193+
lastConfirmedAt = Date.now();
194+
return;
195+
}
196+
// renewLease failed → we are no longer the recorded holder OR the
197+
// lease lapsed. If the gap since our last confirmed beat exceeds a
198+
// full TTL, the lease was provably claimable by another process for a
199+
// meaningful window — a sibling could have acquired AND mutated in the
200+
// gap (a >2min stall / machine sleep), so blindly reclaiming a now-free
201+
// lease and continuing on our stale snapshot is split-brain. Declare
202+
// lost instead. A SHORT delay (≤ TTL, e.g. a slightly-late 60s beat
203+
// causing self-inflicted expiry) still recovers via reclaim below.
204+
if (Date.now() - lastConfirmedAt > LEASE_DURATION_MS) {
205+
declareLost("lease lapsed past TTL — another holder may have run");
206+
return;
207+
}
208+
// reclaim an expired-but-free lease after only a short gap (our own
209+
// delayed beat); returns false only when a different holder is
210+
// actively in possession.
211+
if (acquireLease(db, holderId, leaseKey)) {
194212
lastConfirmedAt = Date.now();
195213
return;
196214
}

packages/plugin/src/features/magic-context/dreamer/map-memories.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -141,7 +141,6 @@ async function mapOneBatch(
141141
signal: AbortSignal,
142142
): Promise<{ mapped: number; independent: number }> {
143143
let agentSessionId: string | null = null;
144-
let phaseFailed = false;
145144
const startedAt = Date.now();
146145
try {
147146
const createResponse = await args.client.session.create({
@@ -199,7 +198,6 @@ async function mapOneBatch(
199198
recordInvocation(args, startedAt, { status: "completed", messages: run.output });
200199
return await applyBatchMappings(args, batch, run.validated);
201200
} catch (error) {
202-
phaseFailed = true;
203201
const desc = describeError(error);
204202
log(
205203
`[dreamer] map-memories batch failed: ${desc.brief}`,
@@ -211,7 +209,10 @@ async function mapOneBatch(
211209
if (signal.aborted) throw error;
212210
return { mapped: 0, independent: 0 };
213211
} finally {
214-
if (agentSessionId && !phaseFailed && !shouldKeepSubagents()) {
212+
// Delete on success AND failure (the failed child still holds the
213+
// memory-pool snapshot from the prompt). keep_subagents still honored —
214+
// memory-pool text, not raw user transcripts.
215+
if (agentSessionId && !shouldKeepSubagents()) {
215216
await args.client.session
216217
.delete({
217218
path: { id: agentSessionId },

packages/plugin/src/features/magic-context/dreamer/verify.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,6 @@ async function verifyOneBatch(
134134
signal: AbortSignal,
135135
): Promise<{ verified: number; updated: number; archived: number }> {
136136
let agentSessionId: string | null = null;
137-
let phaseFailed = false;
138137
const startedAt = Date.now();
139138
try {
140139
const createResponse = await args.client.session.create({
@@ -192,7 +191,6 @@ async function verifyOneBatch(
192191
recordInvocation(args, startedAt, { status: "completed", messages: run.output });
193192
return await applyVerifyManifest(args, batch, run.validated);
194193
} catch (error) {
195-
phaseFailed = true;
196194
const desc = describeError(error);
197195
log(
198196
`[dreamer] verify batch failed: ${desc.brief}`,
@@ -202,7 +200,13 @@ async function verifyOneBatch(
202200
if (signal.aborted) throw error;
203201
return { verified: 0, updated: 0, archived: 0 };
204202
} finally {
205-
if (agentSessionId && !phaseFailed && !shouldKeepSubagents()) {
203+
// Delete the child regardless of success/failure (a FAILED child still
204+
// holds the memory-pool snapshot fed into the prompt — leaving it only on
205+
// the failure path leaked them on disk). Still honor keep_subagents: this
206+
// child carries curated project memories (already in context.db), not raw
207+
// user text, so the user's explicit data-collection opt-in wins — unlike
208+
// the retrospective child, which is purged unconditionally.
209+
if (agentSessionId && !shouldKeepSubagents()) {
206210
await args.client.session
207211
.delete({
208212
path: { id: agentSessionId },

packages/plugin/src/features/magic-context/memory/embedding-openai.test.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,54 @@
11
import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test";
2+
import type { EmbeddingConfig } from "../../../config/schema/magic-context";
3+
import { getEmbeddingProviderIdentity } from "./embedding-identity";
24
import { embeddingModelsMatch, OpenAICompatibleEmbeddingProvider } from "./embedding-openai";
35

6+
describe("provider modelId matches canonical identity (write/read must agree)", () => {
7+
// The provider's own this.modelId is what WRITES are stored under; the
8+
// registry/GC/reads resolve via getEmbeddingProviderIdentity(config). Any
9+
// identity-affecting field present in one but not the other splits the
10+
// vector space → silent zero-results + GC of valid vectors. Lock parity
11+
// across every identity-affecting field.
12+
const cases: Array<{ name: string; config: EmbeddingConfig }> = [
13+
{
14+
name: "endpoint+model only",
15+
config: { provider: "openai-compatible", endpoint: "http://h/v1", model: "m" },
16+
},
17+
{
18+
name: "with api_key + input_type",
19+
config: {
20+
provider: "openai-compatible",
21+
endpoint: "http://h/v1",
22+
model: "m",
23+
api_key: "k",
24+
input_type: "passage",
25+
},
26+
},
27+
{
28+
name: "with truncate set",
29+
config: {
30+
provider: "openai-compatible",
31+
endpoint: "http://h/v1",
32+
model: "m",
33+
truncate: "END",
34+
},
35+
},
36+
];
37+
for (const c of cases) {
38+
test(c.name, () => {
39+
const provider = new OpenAICompatibleEmbeddingProvider({
40+
endpoint: c.config.endpoint,
41+
model: c.config.model,
42+
apiKey: c.config.provider === "openai-compatible" ? c.config.api_key : undefined,
43+
inputType:
44+
c.config.provider === "openai-compatible" ? c.config.input_type : undefined,
45+
truncate: c.config.provider === "openai-compatible" ? c.config.truncate : undefined,
46+
});
47+
expect(provider.modelId).toBe(getEmbeddingProviderIdentity(c.config));
48+
});
49+
}
50+
});
51+
452
describe("embeddingModelsMatch token-boundary semantics", () => {
553
test("exact match", () => {
654
expect(embeddingModelsMatch("qwen3-embedding-4b", "qwen3-embedding-4b")).toBe(true);

packages/plugin/src/features/magic-context/memory/embedding-openai.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,12 @@ export class OpenAICompatibleEmbeddingProvider implements EmbeddingProvider {
138138
model: this.model,
139139
...(this.apiKey ? { api_key: this.apiKey } : {}),
140140
...(this.inputType ? { input_type: this.inputType } : {}),
141+
// truncate participates in identity (it changes which text an
142+
// over-long input embeds). MUST mirror getEmbeddingProviderIdentity
143+
// exactly — a missing field here makes the provider write under a
144+
// different model_id than reads/GC resolve, silently zeroing results
145+
// and reaping valid vectors.
146+
...(this.truncate ? { truncate: this.truncate } : {}),
141147
});
142148
}
143149

0 commit comments

Comments
 (0)