@polderlabs/bizar 4.0.0 → 4.2.1
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/README.md +11 -14
- package/bizar-dash/CHANGELOG.md +1 -1
- package/bizar-dash/src/server/api.mjs +2 -2
- package/bizar-dash/src/server/artifacts-store.mjs +4 -4
- package/bizar-dash/src/server/memory-git.mjs +142 -0
- package/bizar-dash/src/server/memory-lightrag.mjs +767 -0
- package/bizar-dash/src/server/memory-store.mjs +129 -10
- package/bizar-dash/src/server/mod-security.mjs +2 -2
- package/bizar-dash/src/server/routes/memory.mjs +174 -15
- package/bizar-dash/src/server/server.mjs +1 -1
- package/bizar-dash/src/server/state.mjs +2 -2
- package/bizar-dash/src/web/views/Config.tsx +461 -1
- package/bizar-dash/tests/memory-cli-readlistdelete.test.mjs +405 -0
- package/bizar-dash/tests/memory-cli-setup.test.mjs +382 -0
- package/bizar-dash/tests/memory-cli.test.mjs +542 -0
- package/bizar-dash/tests/memory-config.test.mjs +422 -0
- package/bizar-dash/tests/memory-conflicts.test.mjs +229 -0
- package/bizar-dash/tests/memory-git.test.mjs +109 -1
- package/bizar-dash/tests/memory-lightrag.test.mjs +153 -0
- package/bizar-dash/tests/memory-namespace.test.mjs +404 -0
- package/bizar-dash/tests/memory-path-safety.test.mjs +427 -0
- package/bizar-dash/tests/memory-protocol-drift.test.mjs +45 -0
- package/bizar-dash/tests/memory-roundtrip.test.mjs +219 -0
- package/cli/banner.mjs +1 -1
- package/cli/bin.mjs +4 -4
- package/cli/bootstrap.mjs +1 -1
- package/cli/copy.mjs +22 -16
- package/cli/doctor.mjs +4 -4
- package/cli/doctor.test.mjs +2 -2
- package/cli/init.mjs +2 -2
- package/cli/install.mjs +21 -16
- package/cli/memory.mjs +952 -37
- package/cli/utils.mjs +6 -3
- package/config/AGENTS.md +7 -7
- package/config/agents/_shared/AGENT_BASELINE.md +59 -61
- package/config/opencode.json +13 -38
- package/config/skills/memory-protocol/SKILL.md +105 -0
- package/config/skills/obsidian/SKILL.md +58 -1
- package/install.sh +11 -1
- package/package.json +2 -2
- package/plugins/bizar/index.ts +7 -0
- package/plugins/bizar/src/commands.ts +42 -1
- package/plugins/bizar/src/tools/open-kb.ts +191 -0
- package/plugins/bizar/tests/commands.test.ts +36 -0
|
@@ -0,0 +1,427 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tests/memory-path-safety.test.mjs
|
|
3
|
+
*
|
|
4
|
+
* Tests for path traversal and injection attacks against resolveSafe() and
|
|
5
|
+
* the writeNote / readNote / deleteNote surface.
|
|
6
|
+
*
|
|
7
|
+
* Gap closed: memory-system-map §8.2 HIGH gap #9 "Path traversal safety" and
|
|
8
|
+
* §9 Layer 1 "resolveSafe() path traversal — ../, null bytes, absolute paths,
|
|
9
|
+
* symlink escapes".
|
|
10
|
+
*
|
|
11
|
+
* Each attack vector is tested against writeNote (should throw), readNote
|
|
12
|
+
* (should return null), and deleteNote (should return false).
|
|
13
|
+
*
|
|
14
|
+
* NOTE: If any of these paths are NOT rejected by the current implementation,
|
|
15
|
+
* that IS a finding — document it here (do NOT fix it; the test documents
|
|
16
|
+
* expected behaviour vs actual behaviour).
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { describe, it, beforeEach, afterEach } from 'node:test';
|
|
20
|
+
import assert from 'node:assert/strict';
|
|
21
|
+
import { tmpdir } from 'node:os';
|
|
22
|
+
import { join } from 'node:path';
|
|
23
|
+
import { mkdirSync, rmSync, symlinkSync, writeFileSync, readFileSync, existsSync, lstatSync } from 'node:fs';
|
|
24
|
+
|
|
25
|
+
const TEST_MEMORY_STORE = await import('../src/server/memory-store.mjs').then((m) => m);
|
|
26
|
+
|
|
27
|
+
const uid = () => `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
28
|
+
|
|
29
|
+
/** Returns a valid minimal note for passing schema validation. */
|
|
30
|
+
function validNote(overrides = {}) {
|
|
31
|
+
return {
|
|
32
|
+
frontmatter: {
|
|
33
|
+
memory_id: `path-safety-${uid().slice(0, 8)}`,
|
|
34
|
+
type: 'session_summary',
|
|
35
|
+
project_id: 'path-safety-test',
|
|
36
|
+
status: 'active',
|
|
37
|
+
confidence: 'verified',
|
|
38
|
+
created: new Date().toISOString(),
|
|
39
|
+
updated: new Date().toISOString(),
|
|
40
|
+
tags: [],
|
|
41
|
+
title: 'Path safety test',
|
|
42
|
+
...overrides.frontmatter,
|
|
43
|
+
},
|
|
44
|
+
body: 'path safety body content',
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
describe('memory-path-safety: resolveSafe() via writeNote', () => {
|
|
49
|
+
let projectRoot;
|
|
50
|
+
|
|
51
|
+
beforeEach(() => {
|
|
52
|
+
projectRoot = join(tmpdir(), `path-safety-${uid()}`);
|
|
53
|
+
mkdirSync(projectRoot, { recursive: true });
|
|
54
|
+
mkdirSync(join(projectRoot, '.bizar'), { recursive: true });
|
|
55
|
+
TEST_MEMORY_STORE.saveConfig(projectRoot, {
|
|
56
|
+
version: 1,
|
|
57
|
+
backend: 'bizar-local',
|
|
58
|
+
projectId: 'path-safety-test',
|
|
59
|
+
memoryRepo: {
|
|
60
|
+
mode: 'local-only',
|
|
61
|
+
path: join(projectRoot, '.obsidian'),
|
|
62
|
+
remote: null,
|
|
63
|
+
branch: 'main',
|
|
64
|
+
namespace: null,
|
|
65
|
+
},
|
|
66
|
+
namespaces: {
|
|
67
|
+
project: 'projects/path-safety-test',
|
|
68
|
+
global: 'global/bizar',
|
|
69
|
+
user: 'users/tester',
|
|
70
|
+
},
|
|
71
|
+
lightrag: { enabled: false, host: '127.0.0.1', port: 9621, workingDir: join(projectRoot, '.bizar', 'lightrag') },
|
|
72
|
+
git: { autoPullOnSessionStart: false, autoCommitOnMemoryWrite: false, autoPushOnSessionEnd: false, commitAuthor: 'Bizar Memory <bizar-memory@local>', commitMessageTemplate: 'memory(path-safety): {summary}' },
|
|
73
|
+
});
|
|
74
|
+
TEST_MEMORY_STORE.initVault(projectRoot);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
afterEach(() => {
|
|
78
|
+
try { rmSync(projectRoot, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
// Each entry: path, whether resolveSafe rejects it (true=rejected/throws),
|
|
82
|
+
// and a note explaining the finding if it's not rejected as expected.
|
|
83
|
+
const attackPaths = [
|
|
84
|
+
{
|
|
85
|
+
label: '../../../etc/passwd',
|
|
86
|
+
path: '../../../etc/passwd',
|
|
87
|
+
shouldReject: true,
|
|
88
|
+
finding: null,
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
label: '/etc/passwd (absolute)',
|
|
92
|
+
path: '/etc/passwd',
|
|
93
|
+
shouldReject: true,
|
|
94
|
+
finding: null,
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
label: 'notes/../escape.md (mid-path dotdot — now rejected by segment check)',
|
|
98
|
+
path: 'notes/../escape.md',
|
|
99
|
+
shouldReject: true,
|
|
100
|
+
finding: null,
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
label: 'notes\\x00null.md (null byte — correctly rejected)',
|
|
104
|
+
path: 'notes\x00null.md',
|
|
105
|
+
shouldReject: true,
|
|
106
|
+
finding: null,
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
label: 'notes/../../global/bizar/secret.md (cross-namespace)',
|
|
110
|
+
path: 'notes/../../global/bizar/secret.md',
|
|
111
|
+
shouldReject: true,
|
|
112
|
+
finding: null,
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
label: '../projects/other/escape.md',
|
|
116
|
+
path: '../projects/other/escape.md',
|
|
117
|
+
shouldReject: true,
|
|
118
|
+
finding: null,
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
label: '..\\..\\etc\\passwd (backslash variant)',
|
|
122
|
+
path: '..\\..\\etc\\passwd',
|
|
123
|
+
shouldReject: true,
|
|
124
|
+
finding: null,
|
|
125
|
+
},
|
|
126
|
+
{
|
|
127
|
+
label: 'global/../../etc/passwd',
|
|
128
|
+
path: 'global/../../etc/passwd',
|
|
129
|
+
shouldReject: true,
|
|
130
|
+
finding: null,
|
|
131
|
+
},
|
|
132
|
+
];
|
|
133
|
+
|
|
134
|
+
for (const { label, path, shouldReject, finding } of attackPaths) {
|
|
135
|
+
if (shouldReject) {
|
|
136
|
+
it(`writeNote REJECTS: ${label}`, () => {
|
|
137
|
+
assert.throws(
|
|
138
|
+
() => TEST_MEMORY_STORE.writeNote(projectRoot, path, validNote()),
|
|
139
|
+
/Invalid note path/i,
|
|
140
|
+
`[${label}] writeNote should throw for path: ${path}`,
|
|
141
|
+
);
|
|
142
|
+
});
|
|
143
|
+
} else {
|
|
144
|
+
// Document the finding: path is NOT rejected
|
|
145
|
+
it(`FINDING — writeNote ACCEPTS: ${label}`, () => {
|
|
146
|
+
let thrown = false;
|
|
147
|
+
let result;
|
|
148
|
+
try {
|
|
149
|
+
result = TEST_MEMORY_STORE.writeNote(projectRoot, path, validNote());
|
|
150
|
+
} catch (e) {
|
|
151
|
+
thrown = true;
|
|
152
|
+
}
|
|
153
|
+
// This documents the ACTUAL behaviour (not the desired behaviour)
|
|
154
|
+
assert.strictEqual(
|
|
155
|
+
thrown,
|
|
156
|
+
false,
|
|
157
|
+
`[FINDING] ${label}: path was unexpectedly rejected. Update this test if the behaviour changes.`,
|
|
158
|
+
);
|
|
159
|
+
assert.ok(
|
|
160
|
+
result?.relPath,
|
|
161
|
+
`[FINDING] ${label}: writeNote accepted this path; relPath=${result?.relPath}. ${finding}`,
|
|
162
|
+
);
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
describe('memory-path-safety: resolveSafe() via readNote', () => {
|
|
169
|
+
let projectRoot;
|
|
170
|
+
|
|
171
|
+
beforeEach(() => {
|
|
172
|
+
projectRoot = join(tmpdir(), `path-safety-read-${uid()}`);
|
|
173
|
+
mkdirSync(projectRoot, { recursive: true });
|
|
174
|
+
mkdirSync(join(projectRoot, '.bizar'), { recursive: true });
|
|
175
|
+
TEST_MEMORY_STORE.saveConfig(projectRoot, {
|
|
176
|
+
version: 1,
|
|
177
|
+
backend: 'bizar-local',
|
|
178
|
+
projectId: 'path-safety-test',
|
|
179
|
+
memoryRepo: {
|
|
180
|
+
mode: 'local-only',
|
|
181
|
+
path: join(projectRoot, '.obsidian'),
|
|
182
|
+
remote: null,
|
|
183
|
+
branch: 'main',
|
|
184
|
+
namespace: null,
|
|
185
|
+
},
|
|
186
|
+
namespaces: {
|
|
187
|
+
project: 'projects/path-safety-test',
|
|
188
|
+
global: 'global/bizar',
|
|
189
|
+
user: 'users/tester',
|
|
190
|
+
},
|
|
191
|
+
lightrag: { enabled: false, host: '127.0.0.1', port: 9621, workingDir: join(projectRoot, '.bizar', 'lightrag') },
|
|
192
|
+
git: { autoPullOnSessionStart: false, autoCommitOnMemoryWrite: false, autoPushOnSessionEnd: false, commitAuthor: 'Bizar Memory <bizar-memory@local>', commitMessageTemplate: 'memory(path-safety): {summary}' },
|
|
193
|
+
});
|
|
194
|
+
TEST_MEMORY_STORE.initVault(projectRoot);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
afterEach(() => {
|
|
198
|
+
try { rmSync(projectRoot, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
const attackPaths = [
|
|
202
|
+
{ label: '../etc/passwd', path: '../../../etc/passwd' },
|
|
203
|
+
{ label: '/etc/passwd absolute', path: '/etc/passwd' },
|
|
204
|
+
{ label: 'notes/../escape.md', path: 'notes/../escape.md' },
|
|
205
|
+
{ label: 'null byte injection', path: 'notes\x00null.md' },
|
|
206
|
+
{ label: 'cross-namespace escape', path: 'notes/../../global/bizar/secret.md' },
|
|
207
|
+
];
|
|
208
|
+
|
|
209
|
+
for (const { label, path } of attackPaths) {
|
|
210
|
+
it(`readNote returns null for: ${label}`, () => {
|
|
211
|
+
const result = TEST_MEMORY_STORE.readNote(projectRoot, path);
|
|
212
|
+
assert.strictEqual(result, null, `[${label}] readNote should return null for path: ${path}`);
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
describe('memory-path-safety: resolveSafe() via deleteNote', () => {
|
|
218
|
+
let projectRoot;
|
|
219
|
+
|
|
220
|
+
beforeEach(() => {
|
|
221
|
+
projectRoot = join(tmpdir(), `path-safety-del-${uid()}`);
|
|
222
|
+
mkdirSync(projectRoot, { recursive: true });
|
|
223
|
+
mkdirSync(join(projectRoot, '.bizar'), { recursive: true });
|
|
224
|
+
TEST_MEMORY_STORE.saveConfig(projectRoot, {
|
|
225
|
+
version: 1,
|
|
226
|
+
backend: 'bizar-local',
|
|
227
|
+
projectId: 'path-safety-test',
|
|
228
|
+
memoryRepo: {
|
|
229
|
+
mode: 'local-only',
|
|
230
|
+
path: join(projectRoot, '.obsidian'),
|
|
231
|
+
remote: null,
|
|
232
|
+
branch: 'main',
|
|
233
|
+
namespace: null,
|
|
234
|
+
},
|
|
235
|
+
namespaces: {
|
|
236
|
+
project: 'projects/path-safety-test',
|
|
237
|
+
global: 'global/bizar',
|
|
238
|
+
user: 'users/tester',
|
|
239
|
+
},
|
|
240
|
+
lightrag: { enabled: false, host: '127.0.0.1', port: 9621, workingDir: join(projectRoot, '.bizar', 'lightrag') },
|
|
241
|
+
git: { autoPullOnSessionStart: false, autoCommitOnMemoryWrite: false, autoPushOnSessionEnd: false, commitAuthor: 'Bizar Memory <bizar-memory@local>', commitMessageTemplate: 'memory(path-safety): {summary}' },
|
|
242
|
+
});
|
|
243
|
+
TEST_MEMORY_STORE.initVault(projectRoot);
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
afterEach(() => {
|
|
247
|
+
try { rmSync(projectRoot, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
const attackPaths = [
|
|
251
|
+
{ label: '../etc/passwd', path: '../../../etc/passwd' },
|
|
252
|
+
{ label: '/etc/passwd absolute', path: '/etc/passwd' },
|
|
253
|
+
{ label: 'notes/../escape.md', path: 'notes/../escape.md' },
|
|
254
|
+
{ label: 'null byte injection', path: 'notes\x00null.md' },
|
|
255
|
+
{ label: 'cross-namespace escape', path: 'notes/../../global/bizar/secret.md' },
|
|
256
|
+
];
|
|
257
|
+
|
|
258
|
+
for (const { label, path } of attackPaths) {
|
|
259
|
+
it(`deleteNote returns false for: ${label}`, () => {
|
|
260
|
+
const result = TEST_MEMORY_STORE.deleteNote(projectRoot, path);
|
|
261
|
+
assert.strictEqual(result, false, `[${label}] deleteNote should return false for path: ${path}`);
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
describe('memory-path-safety: symlink traversal', () => {
|
|
267
|
+
let projectRoot;
|
|
268
|
+
let vaultRoot;
|
|
269
|
+
|
|
270
|
+
beforeEach(() => {
|
|
271
|
+
projectRoot = join(tmpdir(), `path-safety-sym-${uid()}`);
|
|
272
|
+
mkdirSync(projectRoot, { recursive: true });
|
|
273
|
+
mkdirSync(join(projectRoot, '.bizar'), { recursive: true });
|
|
274
|
+
vaultRoot = join(projectRoot, '.obsidian');
|
|
275
|
+
mkdirSync(vaultRoot, { recursive: true });
|
|
276
|
+
|
|
277
|
+
TEST_MEMORY_STORE.saveConfig(projectRoot, {
|
|
278
|
+
version: 1,
|
|
279
|
+
backend: 'bizar-local',
|
|
280
|
+
projectId: 'path-safety-test',
|
|
281
|
+
memoryRepo: {
|
|
282
|
+
mode: 'local-only',
|
|
283
|
+
path: vaultRoot,
|
|
284
|
+
remote: null,
|
|
285
|
+
branch: 'main',
|
|
286
|
+
namespace: null,
|
|
287
|
+
},
|
|
288
|
+
namespaces: {
|
|
289
|
+
project: 'projects/path-safety-test',
|
|
290
|
+
global: 'global/bizar',
|
|
291
|
+
user: 'users/tester',
|
|
292
|
+
},
|
|
293
|
+
lightrag: { enabled: false, host: '127.0.0.1', port: 9621, workingDir: join(projectRoot, '.bizar', 'lightrag') },
|
|
294
|
+
git: { autoPullOnSessionStart: false, autoCommitOnMemoryWrite: false, autoPushOnSessionEnd: false, commitAuthor: 'Bizar Memory <bizar-memory@local>', commitMessageTemplate: 'memory(path-safety): {summary}' },
|
|
295
|
+
});
|
|
296
|
+
TEST_MEMORY_STORE.initVault(projectRoot);
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
afterEach(() => {
|
|
300
|
+
try { rmSync(projectRoot, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
301
|
+
try { rmSync(join(tmpdir(), `symlink-outside-${uid().slice(0, 8)}.md`), { force: true }); } catch { /* ignore */ }
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
it('REGRESSION: symlink inside vault pointing to outside file — writeNote now REJECTS (symlink guard)', () => {
|
|
305
|
+
// Symlink guard (Fix 2): resolveSafe() now refuses to write through a
|
|
306
|
+
// pre-existing symlink inside the vault. The previous behaviour followed
|
|
307
|
+
// the symlink, allowing the outside file to be overwritten.
|
|
308
|
+
|
|
309
|
+
const outsideFile = join(tmpdir(), `symlink-outside-${uid()}.md`);
|
|
310
|
+
writeFileSync(outsideFile, 'ORIGINAL CONTENT — should not be overwritten', 'utf8');
|
|
311
|
+
|
|
312
|
+
const symlinkPath = join(vaultRoot, 'escape-symlink.md');
|
|
313
|
+
symlinkSync(outsideFile, symlinkPath);
|
|
314
|
+
|
|
315
|
+
try {
|
|
316
|
+
assert.throws(
|
|
317
|
+
() => TEST_MEMORY_STORE.writeNote(projectRoot, 'escape-symlink.md', validNote()),
|
|
318
|
+
/Invalid note path/i,
|
|
319
|
+
'writeNote should refuse to follow a pre-existing symlink',
|
|
320
|
+
);
|
|
321
|
+
|
|
322
|
+
// Verify: the outside file is UNTOUCHED
|
|
323
|
+
const content = readFileSync(outsideFile, 'utf8');
|
|
324
|
+
assert.ok(
|
|
325
|
+
!content.includes('memory_id:'),
|
|
326
|
+
'Outside file should NOT be overwritten — symlink guard prevented write through symlink',
|
|
327
|
+
);
|
|
328
|
+
assert.strictEqual(
|
|
329
|
+
content,
|
|
330
|
+
'ORIGINAL CONTENT — should not be overwritten',
|
|
331
|
+
'Outside file content should be exactly what we wrote',
|
|
332
|
+
);
|
|
333
|
+
} finally {
|
|
334
|
+
try { rmSync(outsideFile, { force: true }); } catch { /* ignore */ }
|
|
335
|
+
try { rmSync(symlinkPath, { force: true }); } catch { /* ignore */ }
|
|
336
|
+
}
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
it('REGRESSION: readNote via symlink returns null (symlink guard blocks info disclosure)', () => {
|
|
340
|
+
// Symlink guard (Fix 2): readNote now refuses to follow a pre-existing
|
|
341
|
+
// symlink, so the outside file's content is NOT returned.
|
|
342
|
+
|
|
343
|
+
const outsideFile = join(tmpdir(), `symlink-read-outside-${uid()}.md`);
|
|
344
|
+
const secretContent = 'SECRET FROM OUTSIDE THE VAULT — information disclosure';
|
|
345
|
+
writeFileSync(outsideFile, secretContent, 'utf8');
|
|
346
|
+
|
|
347
|
+
const symlinkPath = join(vaultRoot, 'disclose-symlink.md');
|
|
348
|
+
symlinkSync(outsideFile, symlinkPath);
|
|
349
|
+
|
|
350
|
+
try {
|
|
351
|
+
const result = TEST_MEMORY_STORE.readNote(projectRoot, 'disclose-symlink.md');
|
|
352
|
+
assert.strictEqual(
|
|
353
|
+
result,
|
|
354
|
+
null,
|
|
355
|
+
'readNote should return null for a symlink path (symlink guard)',
|
|
356
|
+
);
|
|
357
|
+
} finally {
|
|
358
|
+
try { rmSync(outsideFile, { force: true }); } catch { /* ignore */ }
|
|
359
|
+
try { rmSync(symlinkPath, { force: true }); } catch { /* ignore */ }
|
|
360
|
+
}
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
it('REGRESSION: filenames containing `..` as a substring (not as a segment) are still accepted', () => {
|
|
364
|
+
// Fix 3: segment-level check rejects `..` SEGMENTS but allows substrings.
|
|
365
|
+
// `my..note.md` is a valid filename.
|
|
366
|
+
|
|
367
|
+
const note = validNote();
|
|
368
|
+
const relPath = 'decisions/my..note.md';
|
|
369
|
+
const result = TEST_MEMORY_STORE.writeNote(projectRoot, relPath, note);
|
|
370
|
+
assert.strictEqual(result.relPath, relPath, 'filename with `..` substring should be accepted');
|
|
371
|
+
|
|
372
|
+
const readBack = TEST_MEMORY_STORE.readNote(projectRoot, relPath);
|
|
373
|
+
assert.ok(readBack, 'readBack should succeed');
|
|
374
|
+
assert.strictEqual(readBack.relPath, relPath, 'readBack relPath should match');
|
|
375
|
+
|
|
376
|
+
// Cleanup
|
|
377
|
+
TEST_MEMORY_STORE.deleteNote(projectRoot, relPath);
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
it('REGRESSION: dangling symlink (target does not exist) is also rejected by symlink guard', () => {
|
|
381
|
+
// The symlink guard must use lstatSync directly — existsSync follows
|
|
382
|
+
// symlinks and returns false for dangling ones, which would have
|
|
383
|
+
// bypassed the original guard. This test ensures the dangling case
|
|
384
|
+
// is covered too.
|
|
385
|
+
|
|
386
|
+
const danglingTarget = join(tmpdir(), `dangling-symlink-target-${uid()}.md`);
|
|
387
|
+
// Do NOT create danglingTarget — it should remain non-existent.
|
|
388
|
+
|
|
389
|
+
const symlinkPath = join(vaultRoot, 'dangling-symlink.md');
|
|
390
|
+
symlinkSync(danglingTarget, symlinkPath);
|
|
391
|
+
|
|
392
|
+
try {
|
|
393
|
+
// Sanity: existsSync says false (follows the symlink), lstatSync says
|
|
394
|
+
// true (does not follow).
|
|
395
|
+
assert.strictEqual(
|
|
396
|
+
existsSync(symlinkPath),
|
|
397
|
+
false,
|
|
398
|
+
'precondition: existsSync returns false for dangling symlink',
|
|
399
|
+
);
|
|
400
|
+
assert.strictEqual(
|
|
401
|
+
lstatSync(symlinkPath).isSymbolicLink(),
|
|
402
|
+
true,
|
|
403
|
+
'precondition: lstatSync identifies the symlink correctly',
|
|
404
|
+
);
|
|
405
|
+
|
|
406
|
+
assert.throws(
|
|
407
|
+
() => TEST_MEMORY_STORE.writeNote(projectRoot, 'dangling-symlink.md', validNote()),
|
|
408
|
+
/Invalid note path/i,
|
|
409
|
+
'writeNote should refuse dangling symlinks too (uses lstatSync directly)',
|
|
410
|
+
);
|
|
411
|
+
|
|
412
|
+
assert.strictEqual(
|
|
413
|
+
TEST_MEMORY_STORE.readNote(projectRoot, 'dangling-symlink.md'),
|
|
414
|
+
null,
|
|
415
|
+
'readNote should return null for dangling symlinks',
|
|
416
|
+
);
|
|
417
|
+
|
|
418
|
+
assert.strictEqual(
|
|
419
|
+
TEST_MEMORY_STORE.deleteNote(projectRoot, 'dangling-symlink.md'),
|
|
420
|
+
false,
|
|
421
|
+
'deleteNote should refuse dangling symlinks',
|
|
422
|
+
);
|
|
423
|
+
} finally {
|
|
424
|
+
try { rmSync(symlinkPath, { force: true }); } catch { /* ignore */ }
|
|
425
|
+
}
|
|
426
|
+
});
|
|
427
|
+
});
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { readFileSync } from 'node:fs';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { dirname, resolve } from 'node:path';
|
|
6
|
+
|
|
7
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
8
|
+
const REPO_ROOT = resolve(__dirname, '..', '..');
|
|
9
|
+
|
|
10
|
+
function read(rel) {
|
|
11
|
+
return readFileSync(resolve(REPO_ROOT, rel), 'utf8');
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
test('memory-protocol SKILL.md has MANDATORY framing at the top', () => {
|
|
15
|
+
const skill = read('config/skills/memory-protocol/SKILL.md');
|
|
16
|
+
// The "MANDATORY" badge must appear in the first 10 lines
|
|
17
|
+
const head = skill.split('\n').slice(0, 10).join('\n');
|
|
18
|
+
assert.match(head, /MANDATORY/, 'memory-protocol/SKILL.md must mark the protocol as MANDATORY in the first 10 lines');
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test('AGENT_BASELINE.md section 5 has MANDATORY marker', () => {
|
|
22
|
+
const baseline = read('config/agents/_shared/AGENT_BASELINE.md');
|
|
23
|
+
// Find section 5 header and the next 20 lines
|
|
24
|
+
const section5Idx = baseline.indexOf('## 5.');
|
|
25
|
+
assert.ok(section5Idx >= 0, 'AGENT_BASELINE.md must have a section 5');
|
|
26
|
+
const section5Head = baseline.slice(section5Idx, section5Idx + 600);
|
|
27
|
+
assert.match(section5Head, /MANDATORY/, 'AGENT_BASELINE.md section 5 must mark memory check as MANDATORY');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test('AGENT_BASELINE.md section 13 has mandatory language', () => {
|
|
31
|
+
const baseline = read('config/agents/_shared/AGENT_BASELINE.md');
|
|
32
|
+
const section13Idx = baseline.indexOf('## 13.');
|
|
33
|
+
assert.ok(section13Idx >= 0, 'AGENT_BASELINE.md must have a section 13');
|
|
34
|
+
const section13Head = baseline.slice(section13Idx, section13Idx + 3000);
|
|
35
|
+
// Either the ⚠️ ENFORCED marker we just added, or the existing "mandatory, not optional" line
|
|
36
|
+
const hasMandatory = /MANDATORY/.test(section13Head) || /mandatory, not optional/i.test(section13Head);
|
|
37
|
+
assert.ok(hasMandatory, 'AGENT_BASELINE.md section 13 must contain MANDATORY framing or the existing "mandatory, not optional" language');
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test('AGENT_BASELINE.md session-start bootstrap commands are present', () => {
|
|
41
|
+
const baseline = read('config/agents/_shared/AGENT_BASELINE.md');
|
|
42
|
+
// The actual commands agents must run
|
|
43
|
+
assert.match(baseline, /bizar memory search/, 'AGENT_BASELINE.md must reference `bizar memory search`');
|
|
44
|
+
assert.match(baseline, /bizar memory status/, 'AGENT_BASELINE.md must reference `bizar memory status`');
|
|
45
|
+
});
|