pan-wizard 3.21.1 → 3.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pan-wizard",
3
- "version": "3.21.1",
3
+ "version": "3.22.0",
4
4
  "description": "Command a bot army for your codebase: an Opus Mission Control delegates whole-project goals to specialist squads and ships behind a human merge gate. Five AI CLIs, zero context rot.",
5
5
  "bin": {
6
6
  "pan-wizard": "bin/install.js"
@@ -24,12 +24,49 @@ const { readMemory, parseEntries, listMemoryAgents, compactMemory, DEFAULT_MAX_E
24
24
 
25
25
  const DEFAULT_KEEP = 12; // recent bullets kept inline per section
26
26
  const STATE_ARCHIVE_FILE = 'state-archive.md';
27
+ const QUARANTINE_FILE = 'quarantine.md';
27
28
 
28
29
  // Sections whose bullet lists grow unbounded and are safe to reconcile.
29
30
  const APPEND_HEAVY = /\b(decisions|blockers|concerns|pending todos|todos|session continuity|accumulated context|recent activity)\b/i;
30
31
  // A bullet that is just a placeholder — dropped once real entries exist.
31
32
  const PLACEHOLDER = /^-\s*(none(\s+yet)?|n\/a|tbd|todo|—|-)\.?\s*$/i;
32
33
 
34
+ // Memory-injection defense (threat: a subagent writes an instruction/directive
35
+ // into the always-loaded memory that a LATER agent or run reads and OBEYS — a
36
+ // cross-generation prompt injection, exactly the "agent writes self-serving
37
+ // directives into persistent memory for successors" vector in the OpenAI
38
+ // rogue-agent incident, Reuters 2026-07). See ADR-0040. state.md is agent-writable
39
+ // (decisions/blockers/notes), so during reconcile any bullet that reads like a
40
+ // directive AIMED at the agent/system is QUARANTINED out of standing memory (moved
41
+ // to .planning/memory/quarantine.md, reversible) rather than carried forward —
42
+ // nothing agent-authored becomes standing instruction without human review (the
43
+ // merge gate). High-precision, injection-flavored patterns only, to minimize false
44
+ // positives; a legitimate note caught here is recoverable from quarantine.md.
45
+ const DIRECTIVE_PATTERNS = [
46
+ /\bignore\s+(all\s+|any\s+|these\s+)?(previous|prior|earlier|above)\b/i,
47
+ /\bdisregard\s+(the\s+|all\s+|any\s+|your\s+)?(previous|prior|above|earlier|instructions|rules|guidelines|guardrails)\b/i,
48
+ /\byou\s+are\s+now\b/i,
49
+ /\bnew\s+instructions?\s*:/i,
50
+ /\b(the\s+)?system\s+prompt\b/i,
51
+ /\b(as|acting\s+as|being)\s+(an?\s+)?(admin|administrator|root|superuser|developer\s+with)\b/i,
52
+ /\bpre-?authoriz(e|ed|ation)\b/i,
53
+ /\b(do\s+not|don'?t|never)\s+(tell|inform|notify|ask|alert)\s+the\s+(user|human|operator)\b/i,
54
+ /\bwithout\s+(asking|telling|notifying|informing|alerting)\s+the\s+(user|human|operator)\b/i,
55
+ /\b(bypass|skip|disable|override|remove|turn\s+off)\s+(the\s+)?(merge[-\s]?gate|human[-\s]?(approval|gate|review)|approval[-\s]?gate|safety[-\s]?(harness|check|guard|gate)|verification[-\s]?gate|review[-\s]?gate)\b/i,
56
+ /\b(always|automatically)\s+(approve|auto-?approve|accept|merge|confirm|say\s+yes)\b/i,
57
+ /\bauto-?approve\s+(all|any|every|everything)\b/i,
58
+ /\boverride\s+(the\s+)?(human|approval|merge[-\s]?gate|safety)\b/i,
59
+ ];
60
+
61
+ /**
62
+ * True when a memory entry reads like a directive aimed at the agent/system
63
+ * (an injection), as opposed to a descriptive project note. Pure + zero-dep.
64
+ */
65
+ function isSuspiciousDirective(text) {
66
+ if (typeof text !== 'string' || !text) return false;
67
+ return DIRECTIVE_PATTERNS.some((re) => re.test(text));
68
+ }
69
+
33
70
  const isHeading = (l) => /^#{1,6}\s+\S/.test(l);
34
71
  const headingText = (l) => (l.match(/^#{1,6}\s+(.*)$/) || [, ''])[1];
35
72
  const isBullet = (l) => /^-\s+\S/.test(l);
@@ -64,7 +101,7 @@ function joinSections(sections) {
64
101
  * its indented continuation lines, so a bullet is never orphaned from its detail.
65
102
  * Overflow entries are pushed to `archived`. Returns { lines, changed }.
66
103
  */
67
- function reconcileBullets(lines, keepN, archived) {
104
+ function reconcileBullets(lines, keepN, archived, quarantined) {
68
105
  const firstB = lines.findIndex(isBullet);
69
106
  if (firstB === -1) return { lines, changed: false };
70
107
 
@@ -87,9 +124,22 @@ function reconcileBullets(lines, keepN, archived) {
87
124
  }
88
125
  const trailer = rest.slice(i);
89
126
 
127
+ // 0. QUARANTINE injected directives FIRST — a bullet that reads like an
128
+ // instruction aimed at the agent never survives into standing memory; it is
129
+ // moved to the quarantine file for human review (memory-injection defense).
130
+ const safe = [];
131
+ let quarantinedHere = 0;
132
+ for (const e of entries) {
133
+ if (quarantined && isSuspiciousDirective(e.key)) {
134
+ quarantined.push(e.lines.join('\n'));
135
+ quarantinedHere++;
136
+ } else {
137
+ safe.push(e);
138
+ }
139
+ }
90
140
  // 1. dedupe (keep first occurrence)
91
141
  const seen = new Set();
92
- const deduped = entries.filter((e) => (seen.has(e.key) ? false : (seen.add(e.key), true)));
142
+ const deduped = safe.filter((e) => (seen.has(e.key) ? false : (seen.add(e.key), true)));
93
143
  // 2. strip placeholders once real entries exist
94
144
  const real = deduped.filter((e) => !PLACEHOLDER.test(e.key));
95
145
  const kept0 = real.length ? real : deduped;
@@ -102,7 +152,7 @@ function reconcileBullets(lines, keepN, archived) {
102
152
  }
103
153
  for (const d of dropped) archived.push(d.lines.join('\n'));
104
154
 
105
- const changed = deduped.length !== entries.length || kept0.length !== deduped.length || dropped.length > 0;
155
+ const changed = quarantinedHere > 0 || deduped.length !== safe.length || kept0.length !== deduped.length || dropped.length > 0;
106
156
  const newLines = [...pre, ...kept.flatMap((e) => e.lines), ...trailer];
107
157
  // Preserve the section's trailing blank line (the blank that separates it from
108
158
  // the next heading) so reconciling never collapses two sections together.
@@ -119,23 +169,22 @@ function optimizeStateContent(content, opts = {}) {
119
169
  const keepN = Number.isFinite(opts.keep) && opts.keep > 0 ? opts.keep : DEFAULT_KEEP;
120
170
  const sections = parseSections(content);
121
171
  const archived = [];
172
+ const quarantined = [];
122
173
  const sectionsTouched = [];
123
174
  let changed = false;
124
175
 
125
176
  for (const s of sections) {
126
177
  if (s.heading === null) continue;
127
178
  if (!APPEND_HEAVY.test(headingText(s.heading))) continue;
128
- const before = archived.length;
129
- const r = reconcileBullets(s.lines, keepN, archived);
179
+ const r = reconcileBullets(s.lines, keepN, archived, quarantined);
130
180
  if (r.changed) {
131
181
  s.lines = r.lines;
132
182
  changed = true;
133
183
  sectionsTouched.push(headingText(s.heading).trim());
134
184
  }
135
- void before;
136
185
  }
137
186
 
138
- return { content: changed ? joinSections(sections) : content, changed, archived, sectionsTouched };
187
+ return { content: changed ? joinSections(sections) : content, changed, archived, quarantined, sectionsTouched };
139
188
  }
140
189
 
141
190
  // ─── Command ────────────────────────────────────────────────────────────────
@@ -154,6 +203,30 @@ function appendArchive(cwd, entries, now) {
154
203
  fs.appendFileSync(p, block, 'utf-8');
155
204
  }
156
205
 
206
+ function quarantinePath(cwd) {
207
+ return path.join(planningPath(cwd), MEMORY_DIR, QUARANTINE_FILE);
208
+ }
209
+
210
+ /**
211
+ * Append quarantined directive-like entries to a dated, human-review file. These
212
+ * were pulled OUT of standing memory because they read like injected instructions
213
+ * (memory-injection defense). Reversible — a human can review and, if legitimate,
214
+ * restore an entry by hand. The file leads with a warning so it is never loaded
215
+ * as trusted instruction memory.
216
+ */
217
+ function appendQuarantine(cwd, entries, now) {
218
+ if (!entries.length) return;
219
+ const p = quarantinePath(cwd);
220
+ const fresh = !fs.existsSync(p);
221
+ fs.mkdirSync(path.dirname(p), { recursive: true });
222
+ const stamp = now || '(undated)';
223
+ const header = fresh
224
+ ? '# Quarantined memory (DO NOT auto-load as instructions)\n\nEntries below were pulled out of standing memory during reconcile because they read like\ndirectives aimed at the agent (possible cross-generation prompt injection). They are\nNOT trusted instructions. Review each; restore to state.md by hand only if legitimate.\n'
225
+ : '';
226
+ const block = `${header}\n## Quarantined ${stamp}\n\n${entries.join('\n')}\n`;
227
+ fs.appendFileSync(p, block, 'utf-8');
228
+ }
229
+
157
230
  /**
158
231
  * `memory optimize [--apply] [--keep N]` — reconcile state.md + consolidate
159
232
  * over-budget agent logs. Dry-run by default: reports what WOULD change.
@@ -172,12 +245,15 @@ function cmdMemoryOptimize(cwd, opts = {}, raw) {
172
245
  changed: opt.changed,
173
246
  sections_touched: opt.sectionsTouched,
174
247
  archived_entries: opt.archived.length,
248
+ quarantined_entries: opt.quarantined.length,
175
249
  before_bytes: Buffer.byteLength(before),
176
250
  after_bytes: Buffer.byteLength(opt.content),
177
251
  };
178
252
  result.archived = opt.archived.length;
253
+ result.quarantined = opt.quarantined.length;
179
254
  if (apply && opt.changed) {
180
255
  appendArchive(cwd, opt.archived, opts.now);
256
+ appendQuarantine(cwd, opt.quarantined, opts.now);
181
257
  writeStateMd(statePath, opt.content, cwd);
182
258
  }
183
259
  } else {
@@ -199,8 +275,10 @@ function cmdMemoryOptimize(cwd, opts = {}, raw) {
199
275
  }
200
276
  } catch { /* agent sweep is best-effort */ }
201
277
 
278
+ const q = result.quarantined || 0;
279
+ const quarantineNote = q ? `; ${q} directive-like entr${q === 1 ? 'y' : 'ies'} QUARANTINED` : '';
202
280
  const summary = result.state.changed
203
- ? `${apply ? 'optimized' : 'would optimize'} state.md (${result.state.sections_touched.join(', ')}); ${result.archived} entr${result.archived === 1 ? 'y' : 'ies'} archived${result.agents.length ? `; ${result.agents.length} agent log(s)` : ''}`
281
+ ? `${apply ? 'optimized' : 'would optimize'} state.md (${result.state.sections_touched.join(', ')}); ${result.archived} entr${result.archived === 1 ? 'y' : 'ies'} archived${quarantineNote}${result.agents.length ? `; ${result.agents.length} agent log(s)` : ''}`
204
282
  : `state.md already lean${result.agents.length ? `; ${result.agents.length} agent log(s) over budget` : ''} — nothing to do`;
205
283
  output(result, raw, summary);
206
284
  }
@@ -239,8 +317,9 @@ function maybeAutoOptimizeMemory(cwd, opts = {}) {
239
317
  const opt = optimizeStateContent(before, { keep: opts.keep });
240
318
  if (!opt.changed) return { optimized: false, reason: 'clean' };
241
319
  appendArchive(cwd, opt.archived, opts.now);
320
+ appendQuarantine(cwd, opt.quarantined, opts.now);
242
321
  writeStateMd(statePath, opt.content, cwd);
243
- return { optimized: true, sections: opt.sectionsTouched, archived: opt.archived.length };
322
+ return { optimized: true, sections: opt.sectionsTouched, archived: opt.archived.length, quarantined: opt.quarantined.length };
244
323
  } catch {
245
324
  return { optimized: false, reason: 'error' };
246
325
  }
@@ -248,6 +327,6 @@ function maybeAutoOptimizeMemory(cwd, opts = {}) {
248
327
 
249
328
  module.exports = {
250
329
  optimizeStateContent, reconcileBullets, parseSections, joinSections, cmdMemoryOptimize,
251
- maybeAutoOptimizeMemory, autoOptimizeEnabled,
252
- APPEND_HEAVY, PLACEHOLDER, DEFAULT_KEEP, STATE_ARCHIVE_FILE,
330
+ maybeAutoOptimizeMemory, autoOptimizeEnabled, isSuspiciousDirective,
331
+ APPEND_HEAVY, PLACEHOLDER, DIRECTIVE_PATTERNS, DEFAULT_KEEP, STATE_ARCHIVE_FILE, QUARANTINE_FILE,
253
332
  };
@@ -32,6 +32,24 @@ const {
32
32
  upsertAgentsMdSection,
33
33
  ensureClaudeMdImport,
34
34
  } = require('./agents-md.cjs');
35
+ const { isSuspiciousDirective } = require('./memory-optimize.cjs');
36
+
37
+ /**
38
+ * Scan a procedural-memory file (AGENTS.md / CLAUDE.md) for lines that read like
39
+ * directives aimed at the agent — a memory-injection risk in the ALWAYS-loaded
40
+ * instruction files (ADR-0040). rebuild owns only the marker-fenced PAN section
41
+ * (regenerated from a fixed template, so it can't be poisoned); user content is
42
+ * preserved by contract, so here we WARN rather than auto-edit — surfacing
43
+ * suspect lines for human review instead of silently rewriting the user's file.
44
+ */
45
+ function scanForDirectives(file, content, warnings) {
46
+ if (typeof content !== 'string') return;
47
+ content.split('\n').forEach((line, i) => {
48
+ if (isSuspiciousDirective(line)) {
49
+ warnings.push({ file, line: i + 1, text: line.trim().slice(0, 200) });
50
+ }
51
+ });
52
+ }
35
53
 
36
54
  // Source repo root — mirrors experiment.cjs / install.js. __dirname is
37
55
  // .../pan-wizard-core/bin/lib, so three levels up is the repo (or install) root.
@@ -106,6 +124,7 @@ function cmdMemoryRebuild(cwd, opts = {}, raw) {
106
124
 
107
125
  const runtimes = detectRuntimes(cwd);
108
126
  const targets = [];
127
+ const warnings = [];
109
128
 
110
129
  // 1. AGENTS.md — universal PAN section (all runtimes read it natively).
111
130
  {
@@ -113,6 +132,7 @@ function cmdMemoryRebuild(cwd, opts = {}, raw) {
113
132
  const existing = safeReadFile(p);
114
133
  const desired = upsertAgentsMdSection(existing, buildAgentsMdSection());
115
134
  targets.push({ file: 'AGENTS.md', ...rebuildFile(p, existing, desired, apply) });
135
+ scanForDirectives('AGENTS.md', desired, warnings);
116
136
  }
117
137
 
118
138
  // 2. CLAUDE.md — Claude bridge, only when the Claude runtime is installed.
@@ -121,6 +141,7 @@ function cmdMemoryRebuild(cwd, opts = {}, raw) {
121
141
  const existing = safeReadFile(p);
122
142
  const desired = ensureClaudeMdImport(existing);
123
143
  targets.push({ file: 'CLAUDE.md', ...rebuildFile(p, existing, desired, apply) });
144
+ scanForDirectives('CLAUDE.md', desired, warnings);
124
145
  }
125
146
 
126
147
  // 3. state.md — re-derive YAML frontmatter from the body (progress/status).
@@ -139,10 +160,12 @@ function cmdMemoryRebuild(cwd, opts = {}, raw) {
139
160
  runtimes,
140
161
  rebuilt: targets,
141
162
  changed_count: changed.length,
163
+ directive_warnings: warnings,
142
164
  };
143
- const summary = changed.length === 0
165
+ const warnNote = warnings.length ? `; ⚠ ${warnings.length} directive-like line(s) in procedural memory — review (not auto-edited)` : '';
166
+ const summary = (changed.length === 0
144
167
  ? `tools memory already current (${targets.map((t) => t.file).join(', ')}) — nothing to do`
145
- : `${apply ? 'rebuilt' : 'would rebuild'} ${changed.map((t) => `${t.file} (${t.action})`).join(', ')}`;
168
+ : `${apply ? 'rebuilt' : 'would rebuild'} ${changed.map((t) => `${t.file} (${t.action})`).join(', ')}`) + warnNote;
146
169
  output(result, raw, summary);
147
170
  }
148
171
 
@@ -151,6 +174,7 @@ module.exports = {
151
174
  detectRuntimes,
152
175
  isInsideSourceRepo,
153
176
  rebuildFile,
177
+ scanForDirectives,
154
178
  PAN_SOURCE_ROOT,
155
179
  RUNTIME_DIRS,
156
180
  };
@@ -27,6 +27,14 @@ hard-depend on, and the go/no-go facts that can only be settled on a real ZCode
27
27
  - **User-global subagents only.** No repo-scoped rosters or per-project model profiles.
28
28
  - **Genuinely lost:** scheduled self-resuming multi-day campaigns (no headless/daemon),
29
29
  background execution, committable permissions, and custom slash-commands.
30
+ - **Bridge scope is tool/resource exposure ONLY — never agent session state.** The MCP
31
+ server exposes `pan-tools` verbs as `tools/call` + `resources/read`; it deliberately
32
+ does not represent rich agent session state (diffs, streaming, live thread lifecycle).
33
+ This is by design, not an omission: MCP cannot faithfully carry that state — a point
34
+ OpenAI made explicitly when it built the Codex "harness" as a native Rust core rather
35
+ than over MCP (2026). Do NOT extend the bridge to stream diffs or proxy session state
36
+ through it; keep the CLI's JSON contract as the tool contract. (Codex-adapter audit,
37
+ 2026-07-30.)
30
38
 
31
39
  ## Format-drift policy
32
40
 
@@ -21,6 +21,8 @@ pan-zcode/mcp (this subsystem) a thin, zero-dep bridge — verbs → MCP tools
21
21
  pan-wizard-core (reused as-is) the deterministic engine; .planning/ stays the state store
22
22
  ```
23
23
 
24
+ **Scope boundary (by design):** the bridge exposes `pan-tools` verbs as MCP tools/resources and nothing more. It intentionally does **not** carry rich agent *session state* — diffs, streaming, live thread lifecycle — because MCP can't faithfully represent it (the reason OpenAI built the Codex harness as a native Rust core rather than over MCP). Keep the bridge to tool/resource exposure; the CLI's JSON contract is the tool contract. See `KNOWN-BETA-RISKS.md`.
25
+
24
26
  ## Status — M1–M5 built (M0 is the human verify spike)
25
27
 
26
28
  - **M1 — bridge core.** `mcp/tool-registry.cjs` (pure verb→tool/resource map, with a hard