@ijfw/memory-server 1.5.4 → 1.5.5
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 +15 -1
- package/src/brain/dream-pipeline.js +77 -14
- package/src/brain/dump-ingest.js +32 -0
- package/src/brain/entity-collapse.js +2 -2
- package/src/brain/export.js +60 -6
- package/src/brain/extractors/markdown.js +28 -2
- package/src/brain/layout-sentinel.js +19 -14
- package/src/brain/path-guard.js +17 -0
- package/src/brain/wiki-compiler.js +35 -39
- package/src/codex-agents.js +25 -2
- package/src/cross-orchestrator-cli.js +176 -18
- package/src/dashboard-server.js +36 -3
- package/src/dispatch/override.js +18 -2
- package/src/dispatch/signer-cli.js +14 -9
- package/src/dream/stage-runner.js +17 -0
- package/src/dream/state-file.js +15 -1
- package/src/extension-installer.js +91 -2
- package/src/extension-registry.js +15 -4
- package/src/handlers/brain-handler.js +44 -5
- package/src/lib/atomic-io.js +69 -12
- package/src/lib/shasum-verify.js +46 -22
- package/src/lib/ui-review-runner.js +7 -2
- package/src/lib/uispec-drift.js +8 -3
- package/src/lib/uispec-intake.js +5 -2
- package/src/memory/layout-migrations/001-visible-layer.js +71 -7
- package/src/memory/reader.js +111 -58
- package/src/orchestrator/merge-block-aware.js +75 -37
- package/src/orchestrator/post-done-runner.js +6 -1
- package/src/orchestrator/state-sdk.js +242 -14
- package/src/orchestrator/wave-state.js +22 -69
- package/src/recovery/checkpoint.js +30 -6
- package/src/recovery/code-fixer.js +52 -7
- package/src/runtime-mediator.js +2 -2
- package/src/server.js +57 -8
- package/src/swarm/planner.js +46 -1
- package/src/update-apply.js +27 -35
- 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
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 (
|
|
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 {
|
|
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,
|
package/src/lib/atomic-io.js
CHANGED
|
@@ -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
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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
|
package/src/lib/shasum-verify.js
CHANGED
|
@@ -1,20 +1,20 @@
|
|
|
1
|
-
// shasum-verify.js -- cross-verify a target npm release against the
|
|
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
|
|
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
|
|
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
|
|
15
|
+
// verified -- npm and GitHub shasums both available and match.
|
|
16
16
|
// mismatch -- both available but DIFFER. Fail closed.
|
|
17
|
-
// advisory --
|
|
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
|
|
34
|
-
|
|
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
|
-
// (
|
|
85
|
-
|
|
86
|
-
const url = `https://
|
|
87
|
-
const r = spawnSync(
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
|
|
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',
|
|
107
|
-
//
|
|
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
|
|
131
|
+
const repo = opts.repo || opts.project || DEFAULT_GITHUB_REPO;
|
|
111
132
|
const fetchNpm = deps.fetchNpmShasum || DEFAULT_DEPS.fetchNpmShasum;
|
|
112
|
-
const fetchRelease =
|
|
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(
|
|
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: '
|
|
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
|
|
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
|
-
|
|
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) {
|
package/src/lib/uispec-drift.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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];
|
package/src/lib/uispec-intake.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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) =>
|
|
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) {
|