@ijfw/memory-server 1.5.4 → 1.5.6

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.
Files changed (37) hide show
  1. package/package.json +15 -1
  2. package/src/brain/dream-pipeline.js +77 -14
  3. package/src/brain/dump-ingest.js +32 -0
  4. package/src/brain/entity-collapse.js +2 -2
  5. package/src/brain/export.js +60 -6
  6. package/src/brain/extractors/markdown.js +28 -2
  7. package/src/brain/layout-sentinel.js +19 -14
  8. package/src/brain/path-guard.js +17 -0
  9. package/src/brain/wiki-compiler.js +35 -39
  10. package/src/codex-agents.js +25 -2
  11. package/src/cross-orchestrator-cli.js +176 -18
  12. package/src/dashboard-server.js +36 -3
  13. package/src/dispatch/override.js +18 -2
  14. package/src/dispatch/signer-cli.js +14 -9
  15. package/src/dream/stage-runner.js +17 -0
  16. package/src/dream/state-file.js +15 -1
  17. package/src/extension-installer.js +91 -2
  18. package/src/extension-registry.js +15 -4
  19. package/src/handlers/brain-handler.js +44 -5
  20. package/src/lib/atomic-io.js +69 -12
  21. package/src/lib/shasum-verify.js +46 -22
  22. package/src/lib/ui-review-runner.js +7 -2
  23. package/src/lib/uispec-drift.js +8 -3
  24. package/src/lib/uispec-intake.js +5 -2
  25. package/src/memory/layout-migrations/001-visible-layer.js +71 -7
  26. package/src/memory/reader.js +111 -58
  27. package/src/orchestrator/merge-block-aware.js +75 -37
  28. package/src/orchestrator/post-done-runner.js +6 -1
  29. package/src/orchestrator/state-sdk.js +242 -14
  30. package/src/orchestrator/wave-state.js +22 -69
  31. package/src/recovery/checkpoint.js +30 -6
  32. package/src/recovery/code-fixer.js +52 -7
  33. package/src/runtime-mediator.js +2 -2
  34. package/src/server.js +57 -8
  35. package/src/swarm/planner.js +46 -1
  36. package/src/update-apply.js +27 -35
  37. package/src/update-check.js +6 -2
@@ -43,7 +43,11 @@ MCowBQYDK2VwAyEAL2lCdti0bYiFTGUo/hffy+NiBUBXdbDcdaDmjJS27i0=
43
43
  -----END PUBLIC KEY-----`;
44
44
 
45
45
  const DEFAULT_REGISTRY_URL = 'https://registry.ijfw.dev/publishers/v1.json';
46
- const FALLBACK_REGISTRY_URL = 'https://therealseandonahoe.gitlab.io/ijfw/registry/publishers/v1.json';
46
+ // Secondary fallback. The trust anchor is the embedded IJFW_REGISTRY_META_KEY_PEM
47
+ // (below), NOT the URL — so swapping hosts is safe. GitHub Pages publishes
48
+ // registry/publishers/v1.json from the FerroxLabs/ijfw main branch; the pages
49
+ // job is not yet wired (Phase B), so this URL 404s until the repo is populated.
50
+ const FALLBACK_REGISTRY_URL = 'https://ferroxlabs.github.io/ijfw/registry/publishers/v1.json';
47
51
  const MAX_REGISTRY_BYTES = 1024 * 1024; // 1 MiB cap
48
52
  const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 h (back-compat alias)
49
53
  const PUBLISHER_TTL_MS = 24 * 60 * 60 * 1000; // 24 h — B17 publisher half
@@ -831,7 +835,12 @@ export async function withSourceCache(source, mutator) {
831
835
  }
832
836
 
833
837
  // ---------------------------------------------------------------------------
834
- // Legacy single-source cache helpers (kept for v1.4.1 back-compat tests).
838
+ // Single-source cache helpers used by the production trust-store refresh
839
+ // path (`refreshTrustFromRegistry`, ~line 1350) AND by the offline fallback
840
+ // in `dispatch/extension.js:416`. NOT v1.4.1 back-compat scaffolding —
841
+ // V155-058 (v1.5.5) corrects the prior misleading comment that called this
842
+ // "kept for v1.4.1 back-compat tests"; deleting it would silently break the
843
+ // offline trust path on next federated-registry refresh failure.
835
844
  // ---------------------------------------------------------------------------
836
845
 
837
846
  export async function readCachedRegistry() {
@@ -859,8 +868,10 @@ export async function writeCachedRegistry(registry) {
859
868
  }
860
869
 
861
870
  // ---------------------------------------------------------------------------
862
- // applyRegistry — merge publishers + process revocations
863
- // (single-source path, retained for v1.4.1 callers + tests)
871
+ // applyRegistry — merge publishers + process revocations.
872
+ // Production callers: `refreshTrustFromRegistry` (this file, ~line 1376) and
873
+ // the offline-fallback path in `dispatch/extension.js:416`. V155-058 (v1.5.5)
874
+ // corrects an earlier "retained for v1.4.1 callers + tests" comment.
864
875
  // ---------------------------------------------------------------------------
865
876
 
866
877
  /**
@@ -162,9 +162,22 @@ function verbWikiGet(db, repoRoot, args) {
162
162
  return { ok: true, slug, path: found.path, type: found.type, markdown: readFileSync(found.path, 'utf8') };
163
163
  }
164
164
 
165
- function verbWikiCompile(db, repoRoot, args) {
165
+ // V155-049: wiki.compile `type` allowlist. Without this, an attacker-supplied
166
+ // `type = '../../etc'` would still pass downstream guards (slugify, path-guard
167
+ // containment) but produces confusing error messages because `pluralType()`
168
+ // silently appends 's'. Reject anything not in the documented set up front.
169
+ const WIKI_COMPILE_TYPES = new Set(['entity', 'concept', 'decision', 'milestone']);
170
+
171
+ async function verbWikiCompile(db, repoRoot, args) {
166
172
  if (!args.subject) return { ok: false, error: 'missing-subject' };
167
- return compileWikiPage(db, { repoRoot, type: args.type || 'entity', subject: args.subject });
173
+ // V155-049: type allowlist defense in depth before path-guard.
174
+ if (args.type !== undefined && args.type !== null && !WIKI_COMPILE_TYPES.has(args.type)) {
175
+ return { ok: false, error: 'invalid-type', type: args.type, allowed: [...WIKI_COMPILE_TYPES] };
176
+ }
177
+ // V155-015: compileWikiPage is now async (withFsLock is async). Awaited
178
+ // here so the outer handler's switch returns the resolved verdict, not
179
+ // a Promise — matching every other verb in this dispatcher.
180
+ return await compileWikiPage(db, { repoRoot, type: args.type || 'entity', subject: args.subject });
168
181
  }
169
182
 
170
183
  function verbWikiPromote(db, repoRoot, args) {
@@ -173,12 +186,23 @@ function verbWikiPromote(db, repoRoot, args) {
173
186
  const slug = slugify(args.slug);
174
187
  const found = findPage(paths.wikiDir, slug);
175
188
  if (!found) return { ok: false, error: 'page-not-found', slug };
176
- const globalDir = join(homedir(), 'IJFW', 'wiki', found.type);
189
+ const globalRoot = join(homedir(), 'IJFW');
190
+ const globalDir = join(globalRoot, 'wiki', found.type);
177
191
  mkdirSync(globalDir, { recursive: true });
178
192
  const dst = join(globalDir, `${slug}.md`);
193
+ // V155-033: defense-in-depth containment. Today slugify() blocks `..`
194
+ // traversal in the slug; a future slugify relaxation would re-open the
195
+ // hole. Reuse the same validator wiki.export uses, anchored at ~/IJFW.
196
+ const guard = validateSafeRepoPath(globalRoot, dst);
197
+ if (!guard.ok) return guard;
198
+ // V155-024/V155-040: capture pre-existence BEFORE the copy. The previous
199
+ // `existsSync(dst)` after copy was tautological — destination always exists
200
+ // post-copy — so `overwrote` always equalled `args.force === true`. The
201
+ // honest semantic is "did this call clobber a pre-existing file?".
202
+ const dstExistedBefore = existsSync(dst);
179
203
  // FLAG-2: refuse to overwrite an existing global page unless force=true.
180
204
  // Operator may have hand-curated content there; silent clobber is data loss.
181
- if (existsSync(dst) && args.force !== true) {
205
+ if (dstExistedBefore && args.force !== true) {
182
206
  return { ok: false, error: 'global-page-exists', dst, hint: 'pass force:true to overwrite' };
183
207
  }
184
208
  try {
@@ -189,7 +213,14 @@ function verbWikiPromote(db, repoRoot, args) {
189
213
  }
190
214
  return { ok: false, error: 'copy-failed', message: e.message };
191
215
  }
192
- return { ok: true, slug, type: found.type, src: found.path, dst, overwrote: args.force === true && existsSync(dst) };
216
+ return {
217
+ ok: true,
218
+ slug,
219
+ type: found.type,
220
+ src: found.path,
221
+ dst,
222
+ overwrote: dstExistedBefore && args.force === true,
223
+ };
193
224
  }
194
225
 
195
226
  function verbWikiExport(db, repoRoot, args) {
@@ -213,6 +244,14 @@ function verbConflictResolve(db, repoRoot, args) {
213
244
  if (!args.subject || !args.predicate || args.winnerId == null) {
214
245
  return { ok: false, error: 'missing-args' };
215
246
  }
247
+ // V155-065: typecheck winnerId. better-sqlite3 throws when given an array
248
+ // or object (`{winnerId:[1,2,3]}` → "SQLite3 can only bind numbers, strings,
249
+ // bigints, buffers, and null"). Previously that throw propagated through
250
+ // the outer catch as `{ok:false, error:'db-error', message:<stack>}`,
251
+ // leaking internal contract details. Reject at the boundary instead.
252
+ if (!Number.isInteger(args.winnerId) && typeof args.winnerId !== 'string') {
253
+ return { ok: false, error: 'winnerId-must-be-number-or-string', winnerId: args.winnerId };
254
+ }
216
255
  const supersede = args.supersede !== false; // default true
217
256
  if (!supersede) {
218
257
  // Pre-flight winner verify for the no-supersede path. (For supersede=true,
@@ -181,20 +181,77 @@ function sleepSync(ms) {
181
181
 
182
182
  // Log rotation -- caller of writeAtomic for log files invokes this first.
183
183
  // Each log capped at 1MB; rotate to <name>.log.1, delete <name>.log.2.
184
+ //
185
+ // V155-052 (v1.5.5): the original implementation was destructive-then-might-
186
+ // fail: unlink rot2 (oldest history), then rename rot1→rot2, then rename
187
+ // logPath→rot1. If the third rename failed (Windows AV scanner holds an open
188
+ // handle, e.g.) we'd already have thrown away the oldest history for no
189
+ // benefit while reporting `return true` (rotated). New shape:
190
+ // 1. Pre-check that logPath rename will succeed (renameSync is the most
191
+ // likely failure point on Windows). Do this by renaming through a tmp
192
+ // probe inside the same directory — atomic same-FS rename is the only
193
+ // reliable test short of actually doing the rotation.
194
+ // 2. Only then proceed with the destructive cleanup.
195
+ // 3. Return `{rotated, error?}` so callers can log a structured warning
196
+ // when rotation fails. Boolean-true legacy path preserved via
197
+ // `result.rotated`.
184
198
  export function rotateLogIfNeeded(logPath, maxBytes = 1024 * 1024) {
185
- try {
186
- if (!existsSync(logPath)) return false;
187
- const st = statSync(logPath);
188
- if (st.size < maxBytes) return false;
189
- const rot1 = `${logPath}.1`;
190
- const rot2 = `${logPath}.2`;
191
- try { if (existsSync(rot2)) unlinkSync(rot2); } catch { /* */ }
192
- try { if (existsSync(rot1)) renameSync(rot1, rot2); } catch { /* */ }
193
- try { renameSync(logPath, rot1); } catch { /* */ }
194
- return true;
195
- } catch {
196
- return false;
199
+ if (!existsSync(logPath)) return false;
200
+ let st;
201
+ try { st = statSync(logPath); }
202
+ catch (e) { return { rotated: false, error: `stat-failed: ${e?.message || e}` }; }
203
+ if (st.size < maxBytes) return false;
204
+ const rot1 = `${logPath}.1`;
205
+ const rot2 = `${logPath}.2`;
206
+
207
+ // V155-052 step 1: try to rename logPath rot1 directly. On POSIX this is
208
+ // atomic; on Windows it fails with EBUSY when the file is held. We DON'T
209
+ // touch rot1 or rot2 yet, so the existing rotation history stays intact
210
+ // until we know the live-log handoff is going to succeed. We need rot1 to
211
+ // be free first — but the prior rot1 contents are valuable, so we move
212
+ // them to rot2 BEFORE the live rename, and only after that succeeds do we
213
+ // remove the previous rot2.
214
+ //
215
+ // Sequence (loud on failure):
216
+ // (a) if rot2 exists: keep it for now (we still have a copy until step c)
217
+ // (b) rename rot1 → rot2_new (a unique temp name) — preserves both old
218
+ // generations while we attempt the live rename
219
+ // (c) rename logPath → rot1
220
+ // (d) on success: unlink the previous rot2 (which was preserved through
221
+ // step b under its temp name); rename rot2_new → rot2
222
+ // (e) on failure of (c): roll back step b
223
+ //
224
+ // The cost is one extra rename; the benefit is we never destroy oldest
225
+ // history except after the new history has landed.
226
+ const rot2Tmp = `${rot2}.tmp.${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`;
227
+ const hadRot1 = existsSync(rot1);
228
+ const hadRot2 = existsSync(rot2);
229
+
230
+ if (hadRot1) {
231
+ try { renameSync(rot1, rot2Tmp); }
232
+ catch (e) {
233
+ return { rotated: false, error: `pre-rotate move rot1→rot2.tmp failed: ${e?.message || e}` };
234
+ }
235
+ }
236
+ try { renameSync(logPath, rot1); }
237
+ catch (e) {
238
+ // Roll back step (b) so the prior rot1 is preserved.
239
+ if (hadRot1) {
240
+ try { renameSync(rot2Tmp, rot1); } catch { /* best-effort rollback */ }
241
+ }
242
+ return { rotated: false, error: `rotate logPath→rot1 failed: ${e?.message || e}` };
243
+ }
244
+ // Live rename succeeded. Now retire the previous rot2 and commit rot2Tmp
245
+ // as the new rot2. Both steps are best-effort: if they fail we still have
246
+ // a successful live rotation plus an over-N tmp file that can be cleaned
247
+ // out next run.
248
+ if (hadRot2) {
249
+ try { unlinkSync(rot2); } catch { /* best-effort */ }
250
+ }
251
+ if (hadRot1) {
252
+ try { renameSync(rot2Tmp, rot2); } catch { /* best-effort */ }
197
253
  }
254
+ return true;
198
255
  }
199
256
 
200
257
  // URL redactor -- strip query strings before logging per v3 sec 15
@@ -1,20 +1,20 @@
1
- // shasum-verify.js -- cross-verify a target npm release against the GitLab
1
+ // shasum-verify.js -- cross-verify a target npm release against the GitHub
2
2
  // release asset shasum. Second-factor integrity check on top of
3
3
  // `npm audit signatures` (F-SEC-7, v1.5.0 audit-H2.2).
4
4
  //
5
5
  // THREAT MODEL
6
6
  // `npm audit signatures` proves the tarball was signed by the package's
7
7
  // npm registry signing keys. This module independently fetches the
8
- // shasum the publisher recorded on the GitLab release page and compares
8
+ // shasum the publisher recorded on the GitHub release page and compares
9
9
  // it against the npm-reported tarball shasum. A divergence means either
10
- // the npm registry is serving a tampered tarball, OR the GitLab release
10
+ // the npm registry is serving a tampered tarball, OR the GitHub release
11
11
  // was tampered with, OR the publisher made an inconsistent release.
12
12
  // In all cases: refuse to install.
13
13
  //
14
14
  // MODES
15
- // verified -- npm and GitLab shasums both available and match.
15
+ // verified -- npm and GitHub shasums both available and match.
16
16
  // mismatch -- both available but DIFFER. Fail closed.
17
- // advisory -- GitLab side missing (older release, no shasum published,
17
+ // advisory -- GitHub side missing (older release, no shasum published,
18
18
  // or transient fetch failure). Caller decides whether to
19
19
  // proceed: interactive prompts for confirmation, non-
20
20
  // interactive must abort.
@@ -30,8 +30,15 @@
30
30
 
31
31
  import { spawnSync } from 'node:child_process';
32
32
 
33
- // Default GitLab project path. Kept here so tests can override.
34
- export const DEFAULT_GITLAB_PROJECT = 'therealseandonahoe%2Fijfw';
33
+ // Default GitHub repo (owner/name). Kept here so tests can override.
34
+ // NOTE: GitHub Releases API uses an owner/repo path -- NOT URL-encoded
35
+ // like the GitLab equivalent. Old name kept as an alias for backwards
36
+ // compatibility with any external callers that still pass a `project` opt.
37
+ export const DEFAULT_GITHUB_REPO = 'FerroxLabs/ijfw';
38
+ // Back-compat alias: callers passing `project` still resolve via the same
39
+ // option key path; the value semantics changed from URL-encoded
40
+ // "therealseandonahoe%2Fijfw" to "FerroxLabs/ijfw".
41
+ export const DEFAULT_GITLAB_PROJECT = DEFAULT_GITHUB_REPO;
35
42
 
36
43
  // Hex shasum extractor. Accepts standalone hex lines (sha1=40 hex, sha256=64
37
44
  // hex) or the common labelled forms used in release notes:
@@ -81,13 +88,24 @@ const DEFAULT_DEPS = Object.freeze({
81
88
  }
82
89
  return { ok: true, shasum: raw.toLowerCase() };
83
90
  },
84
- // (project, version) -> { ok, body, message }
85
- fetchGitlabReleaseBody(project, version) {
86
- const url = `https://gitlab.com/api/v4/projects/${project}/releases/v${version}`;
87
- const r = spawnSync('curl', ['-fsSL', '-H', 'User-Agent: ijfw', url], {
88
- encoding: 'utf8',
89
- timeout: 10_000,
90
- });
91
+ // (repo, version) -> { ok, body, message }
92
+ fetchGithubReleaseBody(repo, version) {
93
+ const url = `https://api.github.com/repos/${repo}/releases/tags/v${version}`;
94
+ const r = spawnSync(
95
+ 'curl',
96
+ [
97
+ '-fsSL',
98
+ '-H',
99
+ 'User-Agent: ijfw',
100
+ '-H',
101
+ 'Accept: application/vnd.github+json',
102
+ url,
103
+ ],
104
+ {
105
+ encoding: 'utf8',
106
+ timeout: 10_000,
107
+ },
108
+ );
91
109
  if (r.error) return { ok: false, message: `spawn-${r.error.code || 'unknown'}` };
92
110
  if (r.signal) return { ok: false, message: `killed by ${r.signal}` };
93
111
  if (r.status !== 0) {
@@ -95,7 +113,8 @@ const DEFAULT_DEPS = Object.freeze({
95
113
  }
96
114
  try {
97
115
  const data = JSON.parse(r.stdout || '{}');
98
- return { ok: true, body: data.description || '' };
116
+ // GitHub release schema uses `body` (GitLab used `description`).
117
+ return { ok: true, body: data.body || '' };
99
118
  } catch {
100
119
  return { ok: false, message: 'release JSON parse failed' };
101
120
  }
@@ -103,13 +122,18 @@ const DEFAULT_DEPS = Object.freeze({
103
122
  });
104
123
 
105
124
  // version: semver string (validated by caller)
106
- // opts: { pkg = '@ijfw/install', project = DEFAULT_GITLAB_PROJECT }
107
- // deps: optional mock injection
125
+ // opts: { pkg = '@ijfw/install', repo = DEFAULT_GITHUB_REPO }
126
+ // `project` is honored as a back-compat alias for `repo`.
127
+ // deps: optional mock injection. Accepts either `fetchGithubReleaseBody`
128
+ // (preferred) or the legacy `fetchGitlabReleaseBody` key.
108
129
  export function verifyShasumCrossSource(version, opts = {}, deps = DEFAULT_DEPS) {
109
130
  const pkg = opts.pkg || '@ijfw/install';
110
- const project = opts.project || DEFAULT_GITLAB_PROJECT;
131
+ const repo = opts.repo || opts.project || DEFAULT_GITHUB_REPO;
111
132
  const fetchNpm = deps.fetchNpmShasum || DEFAULT_DEPS.fetchNpmShasum;
112
- const fetchRelease = deps.fetchGitlabReleaseBody || DEFAULT_DEPS.fetchGitlabReleaseBody;
133
+ const fetchRelease =
134
+ deps.fetchGithubReleaseBody ||
135
+ deps.fetchGitlabReleaseBody || // back-compat: tests written against the old key still work
136
+ DEFAULT_DEPS.fetchGithubReleaseBody;
113
137
 
114
138
  const npmRes = fetchNpm(pkg, version);
115
139
  if (!npmRes.ok) {
@@ -123,7 +147,7 @@ export function verifyShasumCrossSource(version, opts = {}, deps = DEFAULT_DEPS)
123
147
  }
124
148
  const npmShasum = String(npmRes.shasum || '').toLowerCase();
125
149
 
126
- const releaseRes = fetchRelease(project, version);
150
+ const releaseRes = fetchRelease(repo, version);
127
151
  if (!releaseRes.ok) {
128
152
  return {
129
153
  ok: true,
@@ -142,7 +166,7 @@ export function verifyShasumCrossSource(version, opts = {}, deps = DEFAULT_DEPS)
142
166
  requiresConfirmation: true,
143
167
  npmShasum,
144
168
  releaseShasum: null,
145
- message: 'GitLab release does not list a shasum for this version; proceed only with explicit confirmation',
169
+ message: 'GitHub release does not list a shasum for this version; proceed only with explicit confirmation',
146
170
  };
147
171
  }
148
172
  if (releaseShasum.toLowerCase() !== npmShasum) {
@@ -159,6 +183,6 @@ export function verifyShasumCrossSource(version, opts = {}, deps = DEFAULT_DEPS)
159
183
  mode: 'verified',
160
184
  npmShasum,
161
185
  releaseShasum: releaseShasum.toLowerCase(),
162
- message: 'shasum cross-verified (npm dist.shasum matches GitLab release asset)',
186
+ message: 'shasum cross-verified (npm dist.shasum matches GitHub release asset)',
163
187
  };
164
188
  }
@@ -26,7 +26,7 @@
26
26
  // `peerInputs` and adapts them through the evaluator libs.
27
27
 
28
28
  import { existsSync, readFileSync, readdirSync, writeFileSync, mkdirSync } from 'node:fs';
29
- import { join, dirname, extname } from 'node:path';
29
+ import { join, dirname, extname, isAbsolute } from 'node:path';
30
30
  import {
31
31
  parseUISpec,
32
32
  scanCodeForTailwind,
@@ -80,7 +80,12 @@ function walkSourceFiles(scopes, projectRoot, opts = {}) {
80
80
  const maxFiles = typeof opts.maxFiles === 'number' ? opts.maxFiles : 2000;
81
81
  const out = [];
82
82
  for (const scope of scopes) {
83
- const abs = scope.startsWith('/') ? scope : join(projectRoot, scope);
83
+ // V155-044: isAbsolute() so Windows C:\… scopes aren't joined as relative,
84
+ // which previously yielded a non-existent path and a vacuous "everything
85
+ // passes" review verdict.
86
+ // TP-004 (v1.5.5 Trident): re-indented the comment + statement to match
87
+ // the enclosing for-body scope. Visual-only fix; the file already parsed.
88
+ const abs = isAbsolute(scope) ? scope : join(projectRoot, scope);
84
89
  if (!existsSync(abs)) continue;
85
90
  const stack = [abs];
86
91
  while (stack.length > 0 && out.length < maxFiles) {
@@ -13,7 +13,7 @@
13
13
  // Pure-stdlib. Graceful-degrade on every error path. No external network.
14
14
 
15
15
  import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
16
- import { join, extname } from 'node:path';
16
+ import { join, extname, isAbsolute } from 'node:path';
17
17
 
18
18
  // ---------------------------------------------------------------------------
19
19
  // UI-SPEC parser
@@ -103,7 +103,10 @@ export function measureBundleSize(opts = {}) {
103
103
  break;
104
104
  }
105
105
  }
106
- } else if (!opts.dir.startsWith('/')) {
106
+ } else if (!isAbsolute(opts.dir)) {
107
+ // V155-043: isAbsolute() so Windows absolute paths (C:\…) aren't mistakenly
108
+ // joined to projectRoot, which produces a garbage mid-string path and a
109
+ // false "build-dir-missing" verdict.
107
110
  dir = join(projectRoot, opts.dir);
108
111
  }
109
112
 
@@ -195,7 +198,9 @@ export function scanCodeForTailwind(scope, opts = {}) {
195
198
  let files = 0;
196
199
 
197
200
  for (const d of dirs) {
198
- const abs = d.startsWith('/') ? d : join(projectRoot, d);
201
+ // V155-043: isAbsolute() handles Windows C:\… paths correctly; the prior
202
+ // POSIX-only check silently skipped them.
203
+ const abs = isAbsolute(d) ? d : join(projectRoot, d);
199
204
  if (!existsSync(abs)) continue;
200
205
 
201
206
  const stack = [abs];
@@ -37,7 +37,7 @@
37
37
  // `advisory` strings rather than throwing.
38
38
 
39
39
  import { existsSync, readFileSync, statSync } from 'node:fs';
40
- import { resolve as pathResolve, extname } from 'node:path';
40
+ import { resolve as pathResolve, extname, isAbsolute } from 'node:path';
41
41
 
42
42
  const IMAGE_EXTS = new Set(['.png', '.jpg', '.jpeg', '.webp', '.gif']);
43
43
  const MAX_IMAGE_BYTES = 25 * 1024 * 1024;
@@ -54,7 +54,10 @@ export function fromImage(imagePath, opts = {}) {
54
54
  if (typeof imagePath !== 'string' || imagePath.length === 0) {
55
55
  return { ok: false, stub: null, error: 'no-path' };
56
56
  }
57
- const abs = imagePath.startsWith('/')
57
+ // V155-045: isAbsolute() for portable Windows handling; pathResolve()
58
+ // happens to be lenient with absolute Windows paths but the explicit check
59
+ // documents the contract and lets static analysis catch regressions.
60
+ const abs = isAbsolute(imagePath)
58
61
  ? imagePath
59
62
  : pathResolve(opts.projectRoot || process.cwd(), imagePath);
60
63
 
@@ -16,7 +16,7 @@
16
16
  // - copy-not-move keeps legacy .ijfw/ paths intact for one-version fallback
17
17
 
18
18
  import {
19
- existsSync, mkdirSync, statSync, readdirSync, cpSync,
19
+ existsSync, mkdirSync, statSync, readdirSync, cpSync, lstatSync,
20
20
  } from 'node:fs';
21
21
  import { join } from 'node:path';
22
22
  import {
@@ -25,6 +25,13 @@ import {
25
25
 
26
26
  const FRESHNESS_MS = 30_000;
27
27
 
28
+ // V155-056 (v1.5.5): bound walkMd recursion + skip symlinks. A same-uid
29
+ // attacker who plants `.ijfw/memory/loop -> ..` would otherwise drive walkMd
30
+ // into an infinite loop (or, worse, a symlink to `/var/log/syslog` could leak
31
+ // external paths into deferral stderr). Cap depth at 8 — real .ijfw trees are
32
+ // only 2-3 deep — and refuse to follow symlinks at directory entries.
33
+ const MAX_WALK_DEPTH = 8;
34
+
28
35
  const SCAFFOLD_DIRS = [
29
36
  ['ijfw', 'dump', 'inbox'],
30
37
  ['ijfw', 'dump', 'processed'],
@@ -34,24 +41,59 @@ const SCAFFOLD_DIRS = [
34
41
  ['ijfw', 'wiki', 'milestones'],
35
42
  ];
36
43
 
37
- function walkMd(dir) {
44
+ function walkMd(dir, depth = 0) {
38
45
  if (!existsSync(dir)) return [];
46
+ if (depth > MAX_WALK_DEPTH) return [];
39
47
  const out = [];
40
48
  for (const entry of readdirSync(dir, { withFileTypes: true })) {
41
49
  const p = join(dir, entry.name);
42
- if (entry.isDirectory()) out.push(...walkMd(p));
50
+ // V155-056: skip any symlink entry — both files and dirs. Symlink targets
51
+ // are NOT walked; this avoids the cycle/loop attack and keeps the report
52
+ // honest about what's actually inside the .ijfw tree.
53
+ if (entry.isSymbolicLink && entry.isSymbolicLink()) continue;
54
+ if (entry.isDirectory()) out.push(...walkMd(p, depth + 1));
43
55
  else if (entry.isFile() && entry.name.endsWith('.md')) out.push(p);
44
56
  }
45
57
  return out;
46
58
  }
47
59
 
60
+ /**
61
+ * V155-035 (v1.5.5): pre-scan a source subtree for symlinks. cpSync's Node
62
+ * default is `dereference:false` (writes are safe — the destination becomes
63
+ * a symlink), but the subsequent visible-layer reads via `walkMd` follow
64
+ * symlinks under their feet. Refusing symlinks in the source tree closes
65
+ * that read-time leak before the migration commits.
66
+ *
67
+ * Returns the first symlink path found, or null when the subtree is clean.
68
+ * Honors MAX_WALK_DEPTH so a malicious deep tree can't burn the call stack.
69
+ */
70
+ function findSymlinkInTree(dir, depth = 0) {
71
+ if (!existsSync(dir)) return null;
72
+ if (depth > MAX_WALK_DEPTH) return null;
73
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
74
+ const p = join(dir, entry.name);
75
+ let lst;
76
+ try { lst = lstatSync(p); }
77
+ catch { continue; }
78
+ if (lst.isSymbolicLink()) return p;
79
+ if (entry.isDirectory()) {
80
+ const found = findSymlinkInTree(p, depth + 1);
81
+ if (found) return found;
82
+ }
83
+ }
84
+ return null;
85
+ }
86
+
48
87
  function findFreshFiles(repoRoot) {
49
88
  const cutoff = Date.now() - FRESHNESS_MS;
50
89
  const candidates = [
51
90
  ...walkMd(join(repoRoot, '.ijfw', 'memory')),
52
91
  ...walkMd(join(repoRoot, '.ijfw', 'sessions')),
53
92
  ];
54
- return candidates.filter((p) => statSync(p).mtimeMs >= cutoff);
93
+ return candidates.filter((p) => {
94
+ try { return statSync(p).mtimeMs >= cutoff; }
95
+ catch { return false; }
96
+ });
55
97
  }
56
98
 
57
99
  export const DESCRIPTION =
@@ -108,16 +150,38 @@ export async function up(repoRoot) {
108
150
  // means existing destination files cause the COPY of THAT file to skip
109
151
  // silently, but the rest of the tree still copies. Behaviour: union of
110
152
  // existing visible files (winner) + legacy hidden files (filler).
153
+ //
154
+ // V155-035 (v1.5.5): pre-scan each source subtree for symlinks BEFORE
155
+ // committing the migration. cpSync's default does not follow symlinks
156
+ // (`dereference:false` per Node docs), so the write itself is safe — but
157
+ // the visible-layer reads via walkMd would follow them and leak the
158
+ // target's content into LLM context. We refuse the migration outright
159
+ // when a symlink is present in the source tree so the operator can
160
+ // resolve it before the layout flips to v2. `dereference: false` is
161
+ // also stated explicitly on the cpSync calls for forensic clarity.
111
162
  const memorySrc = join(repoRoot, '.ijfw', 'memory');
163
+ const sessionsSrc = join(repoRoot, '.ijfw', 'sessions');
164
+ for (const src of [memorySrc, sessionsSrc]) {
165
+ const sym = findSymlinkInTree(src);
166
+ if (sym) {
167
+ try {
168
+ process.stderr.write(
169
+ `[ijfw layout-migrate] aborted: symlink in source tree (${sym}) ` +
170
+ `would leak external content into the visible layer. ` +
171
+ `Remove or replace with a copy, then re-run.\n`
172
+ );
173
+ } catch { /* stderr may be detached */ }
174
+ return { skipped: true, reason: 'symlink-source-rejected', sample: sym };
175
+ }
176
+ }
112
177
  if (existsSync(memorySrc)) {
113
178
  cpSync(memorySrc, memoryDst,
114
- { recursive: true, force: false, errorOnExist: false });
179
+ { recursive: true, force: false, errorOnExist: false, dereference: false });
115
180
  copiedFiles += walkMd(memorySrc).length;
116
181
  }
117
- const sessionsSrc = join(repoRoot, '.ijfw', 'sessions');
118
182
  if (existsSync(sessionsSrc)) {
119
183
  cpSync(sessionsSrc, sessionsDst,
120
- { recursive: true, force: false, errorOnExist: false });
184
+ { recursive: true, force: false, errorOnExist: false, dereference: false });
121
185
  copiedFiles += walkMd(sessionsSrc).length;
122
186
  }
123
187
  for (const parts of SCAFFOLD_DIRS) {