claude-flow 3.6.24 → 3.6.25

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 (26) hide show
  1. package/README.md +8 -2
  2. package/package.json +1 -1
  3. package/v3/@claude-flow/cli/README.md +8 -2
  4. package/v3/@claude-flow/cli/bin/cli.js +21 -0
  5. package/v3/@claude-flow/cli/bin/mcp-server.js +16 -0
  6. package/v3/@claude-flow/cli/dist/src/commands/appliance.js +8 -10
  7. package/v3/@claude-flow/cli/dist/src/commands/guidance.js +1 -5
  8. package/v3/@claude-flow/cli/dist/src/commands/performance.js +3 -3
  9. package/v3/@claude-flow/cli/dist/src/commands/process.js +6 -7
  10. package/v3/@claude-flow/cli/dist/src/commands/verify.js +24 -3
  11. package/v3/@claude-flow/cli/dist/src/encryption/vault.d.ts +94 -0
  12. package/v3/@claude-flow/cli/dist/src/encryption/vault.js +172 -0
  13. package/v3/@claude-flow/cli/dist/src/fs-secure.d.ts +67 -0
  14. package/v3/@claude-flow/cli/dist/src/fs-secure.js +74 -0
  15. package/v3/@claude-flow/cli/dist/src/mcp-tools/github-tools.js +122 -31
  16. package/v3/@claude-flow/cli/dist/src/mcp-tools/hooks-tools.js +2 -2
  17. package/v3/@claude-flow/cli/dist/src/mcp-tools/memory-tools.js +7 -12
  18. package/v3/@claude-flow/cli/dist/src/mcp-tools/session-tools.js +24 -12
  19. package/v3/@claude-flow/cli/dist/src/mcp-tools/terminal-tools.js +22 -7
  20. package/v3/@claude-flow/cli/dist/src/mcp-tools/validate-input.d.ts +12 -0
  21. package/v3/@claude-flow/cli/dist/src/mcp-tools/validate-input.js +56 -0
  22. package/v3/@claude-flow/cli/dist/src/memory/memory-initializer.js +17 -16
  23. package/v3/@claude-flow/cli/dist/src/transfer/ipfs/upload.js +2 -0
  24. package/v3/@claude-flow/cli/dist/src/update/executor.d.ts +1 -0
  25. package/v3/@claude-flow/cli/dist/src/update/executor.js +43 -7
  26. package/v3/@claude-flow/cli/package.json +1 -1
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Restricted-permission file helpers.
3
+ *
4
+ * audit_1776853149979: session/memory/terminal stores were written with the
5
+ * process umask, which on most macOS/Linux setups leaves them world-readable
6
+ * (mode 0644). They contain conversation snapshots, agent prompts, and
7
+ * terminal command history — anyone else on the host can read them.
8
+ *
9
+ * These helpers write atomically and force mode 0600 (files) / 0700 (dirs).
10
+ * chmod fails silently on Windows, where POSIX modes don't apply — that's
11
+ * fine, the OS-level ACL surface there is different.
12
+ *
13
+ * ADR-096 Phase 2: optional opt-in encryption-at-rest. When the caller
14
+ * passes `encrypt: true` AND the env-gated vault is enabled, payloads are
15
+ * AES-256-GCM-encrypted before hitting disk. Reads use the magic-byte
16
+ * sniff so legacy plaintext files keep working unchanged during the
17
+ * incremental migration.
18
+ */
19
+ import { chmodSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
20
+ import { decryptBuffer, encryptBuffer, getKey, isEncryptedBlob, isEncryptionEnabled, } from './encryption/vault.js';
21
+ /**
22
+ * Create a directory tree with mode 0700 (owner-only). No-op if exists.
23
+ * Uses recursive: true so missing parents are created with the same mode.
24
+ */
25
+ export function mkdirRestricted(path) {
26
+ mkdirSync(path, { recursive: true, mode: 0o700 });
27
+ }
28
+ /**
29
+ * Write a file and tighten its permissions to mode 0600 (owner read/write).
30
+ *
31
+ * Two call signatures, both supported (the legacy positional one keeps
32
+ * existing call sites working without churn):
33
+ *
34
+ * writeFileRestricted(path, data) // plaintext, utf-8
35
+ * writeFileRestricted(path, data, 'utf-8') // legacy: encoding only
36
+ * writeFileRestricted(path, data, { encrypt: true }) // opt-in encryption
37
+ */
38
+ export function writeFileRestricted(path, data, optsOrEncoding = 'utf-8') {
39
+ const opts = typeof optsOrEncoding === 'string'
40
+ ? { encoding: optsOrEncoding }
41
+ : optsOrEncoding;
42
+ const encoding = opts.encoding ?? 'utf-8';
43
+ let payload = data;
44
+ if (opts.encrypt && isEncryptionEnabled()) {
45
+ const plaintext = Buffer.isBuffer(data) ? data : Buffer.from(data, encoding);
46
+ payload = encryptBuffer(plaintext, getKey());
47
+ }
48
+ // For encrypted payloads we always have a Buffer — pass through without an
49
+ // encoding so writeFileSync doesn't try to text-decode it.
50
+ if (Buffer.isBuffer(payload)) {
51
+ writeFileSync(path, payload);
52
+ }
53
+ else {
54
+ writeFileSync(path, payload, encoding);
55
+ }
56
+ try {
57
+ chmodSync(path, 0o600);
58
+ }
59
+ catch {
60
+ // Windows / FS without POSIX modes — silently skip.
61
+ }
62
+ }
63
+ export function readFileMaybeEncrypted(path, encoding = 'utf-8') {
64
+ const raw = readFileSync(path);
65
+ let plain;
66
+ if (isEncryptedBlob(raw)) {
67
+ plain = decryptBuffer(raw, getKey());
68
+ }
69
+ else {
70
+ plain = raw;
71
+ }
72
+ return encoding === null ? plain : plain.toString(encoding);
73
+ }
74
+ //# sourceMappingURL=fs-secure.js.map
@@ -8,7 +8,7 @@ import { getProjectCwd } from './types.js';
8
8
  import { validateIdentifier, validateText } from './validate-input.js';
9
9
  import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
10
10
  import { join } from 'node:path';
11
- import { execSync } from 'node:child_process';
11
+ import { execFileSync, execSync } from 'node:child_process';
12
12
  // Storage paths
13
13
  const STORAGE_DIR = '.claude-flow';
14
14
  const GITHUB_DIR = 'github';
@@ -41,7 +41,20 @@ function saveGitHubStore(store) {
41
41
  ensureGitHubDir();
42
42
  writeFileSync(getGitHubPath(), JSON.stringify(store, null, 2), 'utf-8');
43
43
  }
44
- /** Run a shell command, return stdout or null on failure */
44
+ /**
45
+ * Run a shell command, return stdout or null on failure.
46
+ *
47
+ * SECURITY (audit_1776853149979): only call this with a STATIC command
48
+ * string (no template-string interpolation of user input). For any
49
+ * caller that needs to pass dynamic / user-supplied values, use
50
+ * runArgv below — it routes through execFileSync with shell:false so
51
+ * backticks, $(...), ;, and friends become literal argv bytes.
52
+ *
53
+ * The shell-string form is preserved here only because the surviving
54
+ * callers (`gh issue list ...`, `git rev-list --count HEAD`, …) use
55
+ * pipes / wc -l and need a shell. Any new caller with user input
56
+ * MUST use runArgv.
57
+ */
45
58
  function run(cmd, cwd) {
46
59
  try {
47
60
  return execSync(cmd, { encoding: 'utf-8', timeout: 15000, cwd: cwd || getProjectCwd(), stdio: ['pipe', 'pipe', 'pipe'] }).trim();
@@ -50,6 +63,49 @@ function run(cmd, cwd) {
50
63
  return null;
51
64
  }
52
65
  }
66
+ /**
67
+ * Run a program with an argv array (no shell). Use this for any callsite
68
+ * that mixes user input into the command line — argv elements aren't
69
+ * interpreted by /bin/sh, so shell metacharacters in user-supplied
70
+ * strings stay literal.
71
+ */
72
+ function runArgv(file, args, cwd) {
73
+ try {
74
+ return execFileSync(file, args, {
75
+ encoding: 'utf-8',
76
+ timeout: 15000,
77
+ cwd: cwd || getProjectCwd(),
78
+ stdio: ['pipe', 'pipe', 'pipe'],
79
+ shell: false,
80
+ }).trim();
81
+ }
82
+ catch {
83
+ return null;
84
+ }
85
+ }
86
+ /**
87
+ * Coerce a user-supplied PR / issue / run number to a positive integer.
88
+ * Returns null if the input can't be safely passed as an argv element to
89
+ * gh (which would otherwise accept any string).
90
+ */
91
+ function toPositiveInt(value) {
92
+ const n = typeof value === 'number' ? value : Number(value);
93
+ if (!Number.isFinite(n) || !Number.isInteger(n) || n <= 0 || n > 2 ** 31)
94
+ return null;
95
+ return n;
96
+ }
97
+ const LABEL_RE = /^[A-Za-z0-9][A-Za-z0-9 _\-./]{0,63}$/;
98
+ function sanitizeLabels(value) {
99
+ if (!Array.isArray(value))
100
+ return null;
101
+ const out = [];
102
+ for (const v of value) {
103
+ if (typeof v !== 'string' || !LABEL_RE.test(v))
104
+ return null;
105
+ out.push(v);
106
+ }
107
+ return out;
108
+ }
53
109
  /** Check if gh CLI is available */
54
110
  function hasGhCli() {
55
111
  return run('gh --version') !== null;
@@ -219,7 +275,17 @@ export const githubTools = [
219
275
  const headBranch = input.branch || run('git rev-parse --abbrev-ref HEAD') || 'feature';
220
276
  const baseBranch = input.baseBranch || 'main';
221
277
  const body = input.body || '';
222
- const result = run(`gh pr create --title "${title.replace(/"/g, '\\"')}" --base "${baseBranch}" --head "${headBranch}" --body "${body.replace(/"/g, '\\"')}"`);
278
+ // audit_1776853149979: title/body only had length validation, and
279
+ // the inline .replace(/"/g, '\\"') was a porous escape (no handling
280
+ // of `, $(...), \). Routes via argv array now — no shell to
281
+ // interpret metas.
282
+ const result = runArgv('gh', [
283
+ 'pr', 'create',
284
+ '--title', title,
285
+ '--base', baseBranch,
286
+ '--head', headBranch,
287
+ '--body', body,
288
+ ]);
223
289
  if (result) {
224
290
  return { success: true, _real: true, action: 'created', url: result };
225
291
  }
@@ -232,9 +298,15 @@ export const githubTools = [
232
298
  return { success: true, source: 'local-store', action: 'created', pullRequest: pr };
233
299
  }
234
300
  if (action === 'review') {
235
- const prNumber = input.prNumber;
301
+ // audit_1776853149979: prNumber was typed `number` in schema but only
302
+ // cast at runtime, so a string "1; touch /tmp/x" would interpolate
303
+ // into the shell. Coerce + validate as positive integer.
304
+ const prNumber = toPositiveInt(input.prNumber);
236
305
  if (gh && prNumber) {
237
- const raw = run(`gh pr view ${prNumber} --json number,title,state,body,additions,deletions,changedFiles,reviews,mergeable,statusCheckRollup`);
306
+ const raw = runArgv('gh', [
307
+ 'pr', 'view', String(prNumber),
308
+ '--json', 'number,title,state,body,additions,deletions,changedFiles,reviews,mergeable,statusCheckRollup',
309
+ ]);
238
310
  if (raw) {
239
311
  try {
240
312
  return { success: true, _real: true, action: 'review', pullRequest: JSON.parse(raw) };
@@ -242,18 +314,18 @@ export const githubTools = [
242
314
  catch { /* fall through */ }
243
315
  }
244
316
  }
245
- return { success: false, error: prNumber ? 'gh CLI not available or PR not found. Install gh: https://cli.github.com' : 'prNumber is required for review.' };
317
+ return { success: false, error: prNumber ? 'gh CLI not available or PR not found. Install gh: https://cli.github.com' : 'prNumber is required (positive integer) for review.' };
246
318
  }
247
319
  if (action === 'merge') {
248
- const prNumber = input.prNumber;
320
+ const prNumber = toPositiveInt(input.prNumber);
249
321
  if (gh && prNumber) {
250
- const result = run(`gh pr merge ${prNumber} --merge`);
322
+ const result = runArgv('gh', ['pr', 'merge', String(prNumber), '--merge']);
251
323
  if (result !== null) {
252
324
  return { success: true, _real: true, action: 'merged', prNumber, mergedAt: new Date().toISOString() };
253
325
  }
254
326
  }
255
327
  // Fallback: local store
256
- const prKey = Object.keys(store.prs).find(k => k.includes(String(prNumber)));
328
+ const prKey = prNumber ? Object.keys(store.prs).find(k => k.includes(String(prNumber))) : undefined;
257
329
  if (prKey && store.prs[prKey]) {
258
330
  store.prs[prKey].status = 'merged';
259
331
  saveGitHubStore(store);
@@ -261,14 +333,14 @@ export const githubTools = [
261
333
  return { success: true, source: 'local-store', action: 'merged', prNumber, mergedAt: new Date().toISOString() };
262
334
  }
263
335
  if (action === 'close') {
264
- const prNumber = input.prNumber;
336
+ const prNumber = toPositiveInt(input.prNumber);
265
337
  if (gh && prNumber) {
266
- const result = run(`gh pr close ${prNumber}`);
338
+ const result = runArgv('gh', ['pr', 'close', String(prNumber)]);
267
339
  if (result !== null) {
268
340
  return { success: true, _real: true, action: 'closed', prNumber, closedAt: new Date().toISOString() };
269
341
  }
270
342
  }
271
- const prKey = Object.keys(store.prs).find(k => k.includes(String(prNumber)));
343
+ const prKey = prNumber ? Object.keys(store.prs).find(k => k.includes(String(prNumber))) : undefined;
272
344
  if (prKey && store.prs[prKey]) {
273
345
  store.prs[prKey].status = 'closed';
274
346
  saveGitHubStore(store);
@@ -336,10 +408,16 @@ export const githubTools = [
336
408
  if (action === 'create') {
337
409
  const title = input.title || 'New Issue';
338
410
  const body = input.body || '';
339
- const labels = input.labels || [];
411
+ // audit_1776853149979: labels was joined into a shell string with no
412
+ // validation of the label content. sanitizeLabels rejects anything
413
+ // outside [A-Za-z0-9 _\-./] and caps each label at 64 chars.
414
+ const labels = sanitizeLabels(input.labels) ?? [];
340
415
  if (gh) {
341
- const labelArg = labels.length > 0 ? ` --label "${labels.join(',')}"` : '';
342
- const result = run(`gh issue create --title "${title.replace(/"/g, '\\"')}" --body "${body.replace(/"/g, '\\"')}"${labelArg}`);
416
+ const argv = ['issue', 'create', '--title', title, '--body', body];
417
+ if (labels.length > 0) {
418
+ argv.push('--label', labels.join(','));
419
+ }
420
+ const result = runArgv('gh', argv);
343
421
  if (result) {
344
422
  return { success: true, _real: true, action: 'created', url: result };
345
423
  }
@@ -351,37 +429,45 @@ export const githubTools = [
351
429
  return { success: true, source: 'local-store', action: 'created', issue };
352
430
  }
353
431
  if (action === 'update') {
354
- const issueNumber = input.issueNumber;
432
+ const issueNumber = toPositiveInt(input.issueNumber);
355
433
  if (gh && issueNumber) {
356
- const parts = [];
434
+ const argv = ['issue', 'edit', String(issueNumber)];
357
435
  if (input.title)
358
- parts.push(`--title "${input.title.replace(/"/g, '\\"')}"`);
359
- if (input.labels)
360
- parts.push(`--add-label "${input.labels.join(',')}"`);
361
- if (parts.length > 0) {
362
- const result = run(`gh issue edit ${issueNumber} ${parts.join(' ')}`);
436
+ argv.push('--title', input.title);
437
+ if (input.labels) {
438
+ const labels = sanitizeLabels(input.labels);
439
+ if (labels === null)
440
+ return { success: false, error: 'labels contains disallowed characters' };
441
+ if (labels.length > 0)
442
+ argv.push('--add-label', labels.join(','));
443
+ }
444
+ if (argv.length > 3) {
445
+ const result = runArgv('gh', argv);
363
446
  if (result !== null)
364
447
  return { success: true, _real: true, action: 'updated', issueNumber };
365
448
  }
366
449
  }
367
- const issueKey = Object.keys(store.issues).find(k => k.includes(String(issueNumber)));
450
+ const issueKey = issueNumber ? Object.keys(store.issues).find(k => k.includes(String(issueNumber))) : undefined;
368
451
  if (issueKey && store.issues[issueKey]) {
369
452
  if (input.title)
370
453
  store.issues[issueKey].title = input.title;
371
- if (input.labels)
372
- store.issues[issueKey].labels = input.labels;
454
+ if (input.labels) {
455
+ const labels = sanitizeLabels(input.labels);
456
+ if (labels !== null)
457
+ store.issues[issueKey].labels = labels;
458
+ }
373
459
  saveGitHubStore(store);
374
460
  }
375
461
  return { success: true, source: 'local-store', action: 'updated', issueNumber };
376
462
  }
377
463
  if (action === 'close') {
378
- const issueNumber = input.issueNumber;
464
+ const issueNumber = toPositiveInt(input.issueNumber);
379
465
  if (gh && issueNumber) {
380
- const result = run(`gh issue close ${issueNumber}`);
466
+ const result = runArgv('gh', ['issue', 'close', String(issueNumber)]);
381
467
  if (result !== null)
382
468
  return { success: true, _real: true, action: 'closed', issueNumber, closedAt: new Date().toISOString() };
383
469
  }
384
- const issueKey = Object.keys(store.issues).find(k => k.includes(String(issueNumber)));
470
+ const issueKey = issueNumber ? Object.keys(store.issues).find(k => k.includes(String(issueNumber))) : undefined;
385
471
  if (issueKey && store.issues[issueKey]) {
386
472
  store.issues[issueKey].status = 'closed';
387
473
  saveGitHubStore(store);
@@ -450,7 +536,12 @@ export const githubTools = [
450
536
  if (action === 'status') {
451
537
  const workflowId = input.workflowId;
452
538
  if (workflowId) {
453
- const raw = run(`gh run view ${workflowId} --json databaseId,displayTitle,status,conclusion,jobs`);
539
+ // workflowId is already validated by validateIdentifier above, but
540
+ // route through argv anyway for consistency / defense-in-depth.
541
+ const raw = runArgv('gh', [
542
+ 'run', 'view', workflowId,
543
+ '--json', 'databaseId,displayTitle,status,conclusion,jobs',
544
+ ]);
454
545
  if (raw) {
455
546
  try {
456
547
  return { success: true, _real: true, run: JSON.parse(raw) };
@@ -471,7 +562,7 @@ export const githubTools = [
471
562
  const workflowId = input.workflowId;
472
563
  const ref = input.ref || 'main';
473
564
  if (workflowId) {
474
- const result = run(`gh workflow run "${workflowId}" --ref "${ref}"`);
565
+ const result = runArgv('gh', ['workflow', 'run', workflowId, '--ref', ref]);
475
566
  if (result !== null)
476
567
  return { success: true, _real: true, action: 'triggered', workflowId, ref };
477
568
  }
@@ -480,7 +571,7 @@ export const githubTools = [
480
571
  if (action === 'cancel') {
481
572
  const workflowId = input.workflowId;
482
573
  if (workflowId) {
483
- const result = run(`gh run cancel ${workflowId}`);
574
+ const result = runArgv('gh', ['run', 'cancel', workflowId]);
484
575
  if (result !== null)
485
576
  return { success: true, _real: true, action: 'cancelled', runId: workflowId };
486
577
  }
@@ -1388,8 +1388,8 @@ export const hooksPretrain = {
1388
1388
  const repoPath = resolve(params.path || '.');
1389
1389
  const depth = params.depth || 'medium';
1390
1390
  const startTime = performance.now();
1391
- // Real file scanning — count files by extension, extract patterns
1392
- const { readdirSync, statSync } = await import('node:fs');
1391
+ // Real file scanning — count files by extension, extract patterns.
1392
+ // (readdirSync/statSync already imported statically at the top.)
1393
1393
  const extCounts = {};
1394
1394
  let filesAnalyzed = 0;
1395
1395
  let totalLines = 0;
@@ -9,7 +9,8 @@
9
9
  *
10
10
  * @module v3/cli/mcp-tools/memory-tools
11
11
  */
12
- import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'fs';
12
+ import { existsSync, mkdirSync, readdirSync, readFileSync, unlinkSync, writeFileSync } from 'fs';
13
+ import { homedir } from 'os';
13
14
  import { join, resolve } from 'path';
14
15
  import { validateIdentifier } from './validate-input.js';
15
16
  // #1604: Align with memory-initializer.ts — single source of truth is .swarm/memory.db
@@ -598,7 +599,6 @@ export const memoryTools = [
598
599
  handler: async (input) => {
599
600
  await ensureInitialized();
600
601
  const { storeEntry } = await getMemoryFunctions();
601
- const { homedir } = await import('os');
602
602
  const ns = input.namespace || 'claude-memories';
603
603
  if (input.namespace) {
604
604
  const vNs = validateIdentifier(ns, 'namespace');
@@ -613,15 +613,13 @@ export const memoryTools = [
613
613
  // Scan all projects
614
614
  if (existsSync(claudeProjectsDir)) {
615
615
  try {
616
- const projects = readFileSync; // just need fs methods already imported
617
- const { readdirSync: readDir } = await import('fs');
618
- for (const project of readDir(claudeProjectsDir, { withFileTypes: true })) {
616
+ for (const project of readdirSync(claudeProjectsDir, { withFileTypes: true })) {
619
617
  if (!project.isDirectory())
620
618
  continue;
621
619
  const memDir = join(claudeProjectsDir, project.name, 'memory');
622
620
  if (!existsSync(memDir))
623
621
  continue;
624
- for (const file of readDir(memDir).filter((f) => f.endsWith('.md'))) {
622
+ for (const file of readdirSync(memDir).filter((f) => f.endsWith('.md'))) {
625
623
  memoryFiles.push({ path: join(memDir, file), project: project.name, file });
626
624
  }
627
625
  }
@@ -636,8 +634,7 @@ export const memoryTools = [
636
634
  const memDir = join(claudeProjectsDir, projectHash, 'memory');
637
635
  if (existsSync(memDir)) {
638
636
  try {
639
- const { readdirSync: readDir } = await import('fs');
640
- for (const file of readDir(memDir).filter((f) => f.endsWith('.md'))) {
637
+ for (const file of readdirSync(memDir).filter((f) => f.endsWith('.md'))) {
641
638
  memoryFiles.push({ path: join(memDir, file), project: projectHash, file });
642
639
  }
643
640
  }
@@ -704,21 +701,19 @@ export const memoryTools = [
704
701
  inputSchema: { type: 'object', properties: {} },
705
702
  handler: async () => {
706
703
  await ensureInitialized();
707
- const { homedir } = await import('os');
708
704
  // Count Claude memory files
709
705
  const claudeProjectsDir = join(homedir(), '.claude', 'projects');
710
706
  let claudeFiles = 0;
711
707
  let claudeProjects = 0;
712
708
  if (existsSync(claudeProjectsDir)) {
713
709
  try {
714
- const { readdirSync: readDir } = await import('fs');
715
- for (const project of readDir(claudeProjectsDir, { withFileTypes: true })) {
710
+ for (const project of readdirSync(claudeProjectsDir, { withFileTypes: true })) {
716
711
  if (!project.isDirectory())
717
712
  continue;
718
713
  const memDir = join(claudeProjectsDir, project.name, 'memory');
719
714
  if (!existsSync(memDir))
720
715
  continue;
721
- const files = readDir(memDir).filter((f) => f.endsWith('.md'));
716
+ const files = readdirSync(memDir).filter((f) => f.endsWith('.md'));
722
717
  if (files.length > 0) {
723
718
  claudeProjects++;
724
719
  claudeFiles += files.length;
@@ -3,9 +3,10 @@
3
3
  *
4
4
  * Tool definitions for session management with file persistence.
5
5
  */
6
- import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync, unlinkSync, statSync } from 'node:fs';
6
+ import { existsSync, readFileSync, readdirSync, unlinkSync, statSync } from 'node:fs';
7
7
  import { join } from 'node:path';
8
8
  import { getProjectCwd } from './types.js';
9
+ import { mkdirRestricted, readFileMaybeEncrypted, writeFileRestricted, } from '../fs-secure.js';
9
10
  import { validateIdentifier, validateText } from './validate-input.js';
10
11
  // Storage paths
11
12
  const STORAGE_DIR = '.claude-flow';
@@ -21,14 +22,17 @@ function getSessionPath(sessionId) {
21
22
  function ensureSessionDir() {
22
23
  const dir = getSessionDir();
23
24
  if (!existsSync(dir)) {
24
- mkdirSync(dir, { recursive: true });
25
+ mkdirRestricted(dir);
25
26
  }
26
27
  }
27
28
  function loadSession(sessionId) {
28
29
  try {
29
30
  const path = getSessionPath(sessionId);
30
31
  if (existsSync(path)) {
31
- const data = readFileSync(path, 'utf-8');
32
+ // ADR-096 Phase 2: readFileMaybeEncrypted transparently handles both
33
+ // legacy plaintext sessions and post-migration encrypted ones via the
34
+ // RFE1 magic-byte sniff.
35
+ const data = readFileMaybeEncrypted(path, 'utf-8');
32
36
  return JSON.parse(data);
33
37
  }
34
38
  }
@@ -39,7 +43,12 @@ function loadSession(sessionId) {
39
43
  }
40
44
  function saveSession(session) {
41
45
  ensureSessionDir();
42
- writeFileSync(getSessionPath(session.sessionId), JSON.stringify(session, null, 2), 'utf-8');
46
+ // audit_1776853149979: session JSON contains memory snapshots and agent
47
+ // prompts — restrict to owner read/write.
48
+ // ADR-096 Phase 2: opt-in encrypt-at-rest. The encrypt flag is honored
49
+ // only when CLAUDE_FLOW_ENCRYPT_AT_REST is set; otherwise the legacy
50
+ // plaintext path runs unchanged.
51
+ writeFileRestricted(getSessionPath(session.sessionId), JSON.stringify(session, null, 2), { encrypt: true });
43
52
  }
44
53
  function listSessions() {
45
54
  ensureSessionDir();
@@ -48,7 +57,9 @@ function listSessions() {
48
57
  const sessions = [];
49
58
  for (const file of files) {
50
59
  try {
51
- const data = readFileSync(join(dir, file), 'utf-8');
60
+ // ADR-096 Phase 2: same magic-byte sniff for the listing path so a
61
+ // mixed plaintext+encrypted dir still enumerates cleanly.
62
+ const data = readFileMaybeEncrypted(join(dir, file), 'utf-8');
52
63
  sessions.push(JSON.parse(data));
53
64
  }
54
65
  catch {
@@ -192,12 +203,13 @@ export const sessionTools = [
192
203
  }
193
204
  }
194
205
  if (session) {
195
- // Restore data to respective stores (legacy JSON for backward compat)
206
+ // Restore data to respective stores (legacy JSON for backward compat).
207
+ // audit_1776853149979: tighten perms on the restored stores too.
196
208
  if (session.data?.memory) {
197
209
  const memoryDir = join(getProjectCwd(), STORAGE_DIR, 'memory');
198
210
  if (!existsSync(memoryDir))
199
- mkdirSync(memoryDir, { recursive: true });
200
- writeFileSync(join(memoryDir, 'store.json'), JSON.stringify(session.data.memory, null, 2), 'utf-8');
211
+ mkdirRestricted(memoryDir);
212
+ writeFileRestricted(join(memoryDir, 'store.json'), JSON.stringify(session.data.memory, null, 2));
201
213
  // Also populate active sql.js SQLite database so memory-tools can find entries
202
214
  try {
203
215
  const { storeEntry } = await import('../memory/memory-initializer.js');
@@ -224,14 +236,14 @@ export const sessionTools = [
224
236
  if (session.data?.tasks) {
225
237
  const taskDir = join(getProjectCwd(), STORAGE_DIR, 'tasks');
226
238
  if (!existsSync(taskDir))
227
- mkdirSync(taskDir, { recursive: true });
228
- writeFileSync(join(taskDir, 'store.json'), JSON.stringify(session.data.tasks, null, 2), 'utf-8');
239
+ mkdirRestricted(taskDir);
240
+ writeFileRestricted(join(taskDir, 'store.json'), JSON.stringify(session.data.tasks, null, 2));
229
241
  }
230
242
  if (session.data?.agents) {
231
243
  const agentDir = join(getProjectCwd(), STORAGE_DIR, 'agents');
232
244
  if (!existsSync(agentDir))
233
- mkdirSync(agentDir, { recursive: true });
234
- writeFileSync(join(agentDir, 'store.json'), JSON.stringify(session.data.agents, null, 2), 'utf-8');
245
+ mkdirRestricted(agentDir);
246
+ writeFileRestricted(join(agentDir, 'store.json'), JSON.stringify(session.data.agents, null, 2));
235
247
  }
236
248
  return {
237
249
  sessionId: session.sessionId,
@@ -4,8 +4,9 @@
4
4
  * Terminal session management with real command execution.
5
5
  */
6
6
  import { getProjectCwd } from './types.js';
7
- import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
8
- import { validateIdentifier, validatePath, validateText } from './validate-input.js';
7
+ import { existsSync } from 'node:fs';
8
+ import { mkdirRestricted, readFileMaybeEncrypted, writeFileRestricted, } from '../fs-secure.js';
9
+ import { validateEnv, validateIdentifier, validatePath, validateText } from './validate-input.js';
9
10
  import { join } from 'node:path';
10
11
  import { execSync } from 'node:child_process';
11
12
  // Storage paths
@@ -21,14 +22,17 @@ function getTerminalPath() {
21
22
  function ensureTerminalDir() {
22
23
  const dir = getTerminalDir();
23
24
  if (!existsSync(dir)) {
24
- mkdirSync(dir, { recursive: true });
25
+ mkdirRestricted(dir);
25
26
  }
26
27
  }
27
28
  function loadTerminalStore() {
28
29
  try {
29
30
  const path = getTerminalPath();
30
31
  if (existsSync(path)) {
31
- return JSON.parse(readFileSync(path, 'utf-8'));
32
+ // ADR-096 Phase 3: readFileMaybeEncrypted handles both legacy
33
+ // plaintext stores and post-migration encrypted ones via the RFE1
34
+ // magic-byte sniff.
35
+ return JSON.parse(readFileMaybeEncrypted(path, 'utf-8'));
32
36
  }
33
37
  }
34
38
  catch {
@@ -38,7 +42,12 @@ function loadTerminalStore() {
38
42
  }
39
43
  function saveTerminalStore(store) {
40
44
  ensureTerminalDir();
41
- writeFileSync(getTerminalPath(), JSON.stringify(store, null, 2), 'utf-8');
45
+ // audit_1776853149979: terminal command history can contain credentials
46
+ // pasted into commands; restrict to owner read/write (mode 0600).
47
+ // ADR-096 Phase 3: opt-in AES-256-GCM encrypt-at-rest. Honored only
48
+ // when CLAUDE_FLOW_ENCRYPT_AT_REST is set; otherwise legacy plaintext
49
+ // path runs unchanged.
50
+ writeFileRestricted(getTerminalPath(), JSON.stringify(store, null, 2), { encrypt: true });
42
51
  }
43
52
  export const terminalTools = [
44
53
  {
@@ -54,7 +63,7 @@ export const terminalTools = [
54
63
  },
55
64
  },
56
65
  handler: async (input) => {
57
- // Validate user-provided input (#1425)
66
+ // Validate user-provided input (#1425, audit_1776853149979)
58
67
  if (input.name) {
59
68
  const v = validateText(input.name, 'name', 256);
60
69
  if (!v.valid)
@@ -65,6 +74,12 @@ export const terminalTools = [
65
74
  if (!v.valid)
66
75
  return { success: false, error: v.error };
67
76
  }
77
+ // env is merged into execSync's process env on every command; reject
78
+ // loader/runtime hijack vars (LD_PRELOAD, NODE_OPTIONS, …) and enforce
79
+ // POSIX-shaped names + null-byte-free values.
80
+ const vEnv = validateEnv(input.env, 'env');
81
+ if (!vEnv.valid)
82
+ return { success: false, error: vEnv.error };
68
83
  const store = loadTerminalStore();
69
84
  const id = `term-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
70
85
  const session = {
@@ -75,7 +90,7 @@ export const terminalTools = [
75
90
  lastActivity: new Date().toISOString(),
76
91
  workingDir: input.workingDir || getProjectCwd(),
77
92
  history: [],
78
- env: input.env || {},
93
+ env: vEnv.sanitized,
79
94
  };
80
95
  store.sessions[id] = session;
81
96
  saveTerminalStore(store);
@@ -34,6 +34,18 @@ export declare function validatePath(value: unknown, label: string): ValidationR
34
34
  * Allows most characters but rejects shell metacharacters that could cause injection.
35
35
  */
36
36
  export declare function validateText(value: unknown, label: string, maxLen?: number): ValidationResult;
37
+ export interface EnvValidationResult {
38
+ valid: boolean;
39
+ sanitized: Record<string, string>;
40
+ error?: string;
41
+ }
42
+ /**
43
+ * Validate a Record<string,string> of environment variables: enforce POSIX
44
+ * names, reject hijack-prone names (LD_PRELOAD, NODE_OPTIONS, …), forbid null
45
+ * bytes in values, and cap value length so a malicious caller can't bloat the
46
+ * stored session past reasonable bounds.
47
+ */
48
+ export declare function validateEnv(value: unknown, label?: string): EnvValidationResult;
37
49
  /**
38
50
  * Assert validation or throw with a structured error.
39
51
  */