@cjhyy/code-shell-core 0.8.9 → 0.8.11

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 (76) hide show
  1. package/dist/automation/scheduler.js +49 -0
  2. package/dist/automation/store.d.ts +1 -1
  3. package/dist/automation/store.js +184 -10
  4. package/dist/cli/agent-server-stdio.js +7 -0
  5. package/dist/credentials/store.d.ts +14 -0
  6. package/dist/credentials/store.js +245 -42
  7. package/dist/engine/engine.js +45 -6
  8. package/dist/engine/file-history-hook.js +24 -5
  9. package/dist/engine/run-types.d.ts +9 -0
  10. package/dist/engine/turn-loop.js +9 -8
  11. package/dist/goal/lifecycle.d.ts +2 -0
  12. package/dist/goal/lifecycle.js +56 -33
  13. package/dist/index.d.ts +2 -3
  14. package/dist/index.internal.d.ts +1 -0
  15. package/dist/index.internal.js +1 -0
  16. package/dist/index.js +2 -2
  17. package/dist/links/cli.d.ts +2 -0
  18. package/dist/links/cli.js +11 -4
  19. package/dist/model-catalog/index.js +19 -4
  20. package/dist/model-catalog/save-entry.js +122 -61
  21. package/dist/model-catalog/types.js +27 -23
  22. package/dist/panel-apps/installer.js +27 -14
  23. package/dist/panel-apps/registry.js +60 -12
  24. package/dist/plugins/installedPlugins.d.ts +4 -0
  25. package/dist/plugins/installedPlugins.js +70 -30
  26. package/dist/plugins/installer/types.d.ts +12 -12
  27. package/dist/plugins/installer/update.js +37 -38
  28. package/dist/plugins/knownMarketplaces.d.ts +7 -3
  29. package/dist/plugins/knownMarketplaces.js +127 -23
  30. package/dist/plugins/pluginCatalog.js +18 -4
  31. package/dist/plugins/pluginHookApproval.js +56 -60
  32. package/dist/plugins/pluginMcpApproval.js +50 -52
  33. package/dist/profile/catalog-store.js +39 -4
  34. package/dist/profile/catalog.js +55 -15
  35. package/dist/profile/store.js +51 -21
  36. package/dist/protocol/chat-session-manager.d.ts +9 -0
  37. package/dist/protocol/chat-session-manager.js +13 -0
  38. package/dist/protocol/chat-session.d.ts +5 -0
  39. package/dist/protocol/chat-session.js +1 -0
  40. package/dist/protocol/server.d.ts +2 -0
  41. package/dist/protocol/server.js +75 -29
  42. package/dist/protocol/types.d.ts +8 -0
  43. package/dist/run/FileRunStore.d.ts +2 -0
  44. package/dist/run/FileRunStore.js +153 -18
  45. package/dist/run/Heartbeat.js +63 -4
  46. package/dist/services/auto-dream.js +39 -17
  47. package/dist/services/session-memory.js +107 -8
  48. package/dist/session/file-history.d.ts +63 -2
  49. package/dist/session/file-history.js +593 -86
  50. package/dist/session/session-manager.d.ts +1 -0
  51. package/dist/session/session-manager.js +52 -21
  52. package/dist/session/transcript.js +33 -3
  53. package/dist/session/undo-target.d.ts +15 -6
  54. package/dist/session/undo-target.js +26 -9
  55. package/dist/settings/manager.d.ts +22 -3
  56. package/dist/settings/manager.js +185 -50
  57. package/dist/settings/schema.d.ts +3 -3
  58. package/dist/sources/adapters/local-files.js +49 -4
  59. package/dist/sources/catalog.js +64 -18
  60. package/dist/sources/types.d.ts +3 -3
  61. package/dist/sources/types.js +7 -4
  62. package/dist/themes/installer.js +192 -28
  63. package/dist/tool-system/builtin/add-marketplace.js +21 -1
  64. package/dist/tool-system/builtin/cron.d.ts +2 -1
  65. package/dist/tool-system/builtin/cron.js +20 -6
  66. package/dist/tool-system/builtin/index.js +44 -0
  67. package/dist/tool-system/builtin/install-capability.d.ts +52 -0
  68. package/dist/tool-system/builtin/install-capability.js +1057 -0
  69. package/dist/tool-system/builtin/skill.js +3 -1
  70. package/dist/tool-system/executor.js +1 -0
  71. package/dist/tool-system/registry.js +5 -0
  72. package/dist/tool-system/sandbox/index.d.ts +1 -0
  73. package/dist/tool-system/sandbox/index.js +4 -1
  74. package/dist/utils/file-mutex.d.ts +2 -0
  75. package/dist/utils/file-mutex.js +29 -4
  76. package/package.json +2 -1
@@ -3,14 +3,14 @@
3
3
  *
4
4
  * Priority: CLI flags > local > project > user > managed
5
5
  */
6
- import { readFileSync, existsSync, mkdirSync, writeFileSync, renameSync, copyFileSync, } from "node:fs";
6
+ import { closeSync, constants, existsSync, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, realpathSync, } from "node:fs";
7
7
  import { join, dirname, extname } from "node:path";
8
8
  import { homedir } from "node:os";
9
9
  import { parse as parseYaml } from "yaml";
10
10
  import { validateSettings } from "./schema.js";
11
11
  import { migrateModels } from "../migrate-models.js";
12
12
  import { migrateConfig, CONFIG_VERSION_KEY } from "./migrate-config.js";
13
- import { acquireFileLock } from "../utils/file-mutex.js";
13
+ import { acquireFileLock, writeFileAtomic } from "../utils/file-mutex.js";
14
14
  /**
15
15
  * Resolve the user's home directory. Prefers `process.env.HOME` so that
16
16
  * runtime env overrides (set after process start, e.g. in tests) actually
@@ -52,6 +52,7 @@ export function isProtectedSettingKey(key) {
52
52
  return PROTECTED_SETTING_ROOTS.has(root);
53
53
  }
54
54
  const FORBIDDEN_SETTING_KEY_SEGMENTS = new Set(["__proto__", "prototype", "constructor"]);
55
+ const MAX_SETTINGS_FILE_BYTES = 4 * 1024 * 1024;
55
56
  function isForbiddenSettingKeySegment(key) {
56
57
  return FORBIDDEN_SETTING_KEY_SEGMENTS.has(key);
57
58
  }
@@ -205,9 +206,13 @@ export class SettingsManager {
205
206
  }
206
207
  if (readProject) {
207
208
  // 3. Project
208
- this.loadJsonFile(join(this.cwd, ".code-shell", "settings.json"), "project", 2);
209
+ const projectPath = this.tryProjectSettingsPath(this.cwd, "settings.json");
210
+ if (projectPath)
211
+ this.loadJsonFile(projectPath, "project", 2);
209
212
  // 4. Local
210
- this.loadJsonFile(join(this.cwd, ".code-shell", "settings.local.json"), "local", 3);
213
+ const localPath = this.tryProjectSettingsPath(this.cwd, "settings.local.json");
214
+ if (localPath)
215
+ this.loadJsonFile(localPath, "local", 3);
211
216
  }
212
217
  // 5. CLI flags (highest priority)
213
218
  if (flagOverrides && Object.keys(flagOverrides).length > 0) {
@@ -225,7 +230,9 @@ export class SettingsManager {
225
230
  this.applyConfigMigration(join(this.userConfigDir(), "settings.json"), "user");
226
231
  }
227
232
  if (readProject) {
228
- this.applyConfigMigration(join(this.cwd, ".code-shell", "settings.json"), "project");
233
+ const projectPath = this.tryProjectSettingsPath(this.cwd, "settings.json");
234
+ if (projectPath)
235
+ this.applyConfigMigration(projectPath, "project");
229
236
  }
230
237
  // Workspace-trust gate: an untrusted project must not influence execution
231
238
  // through dangerous fields committed into its own .code-shell/settings.*.
@@ -250,15 +257,17 @@ export class SettingsManager {
250
257
  // single physical file. Gated on readUser: under non-full scope we must
251
258
  // not read — let alone rewrite — the host's ~/.code-shell/settings.json.
252
259
  const userPath = join(this.userConfigDir(), "settings.json");
253
- if (readUser && existsSync(userPath)) {
260
+ if (readUser && resolveConfigPath(userPath) === userPath) {
254
261
  try {
255
- const userRaw = sanitizeSettingsObject(JSON.parse(readFileSync(userPath, "utf-8")));
262
+ const userRaw = parseConfigFile(userPath);
263
+ if (!userRaw)
264
+ throw new Error("invalid user settings");
256
265
  const result = migrateModels({
257
266
  providers: userRaw.providers ?? [],
258
267
  models: userRaw.models ?? [],
259
268
  });
260
269
  if (result.changed) {
261
- copyFileSync(userPath, `${userPath}.bak`);
270
+ this.writeBackup(userPath);
262
271
  const migrated = {
263
272
  ...userRaw,
264
273
  providers: result.providers,
@@ -295,10 +304,10 @@ export class SettingsManager {
295
304
  * so this load() already sees the migrated shape.
296
305
  */
297
306
  applyConfigMigration(path, sourceName) {
298
- if (!existsSync(path))
307
+ if (resolveConfigPath(path) !== path)
299
308
  return;
300
309
  try {
301
- const parsed = JSON.parse(readFileSync(path, "utf-8"));
310
+ const parsed = parseConfigFile(path);
302
311
  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
303
312
  return;
304
313
  const raw = sanitizeSettingsObject(parsed);
@@ -313,7 +322,7 @@ export class SettingsManager {
313
322
  };
314
323
  if (JSON.stringify(stripStamp(raw)) === JSON.stringify(stripStamp(result.config)))
315
324
  return;
316
- copyFileSync(path, `${path}.bak`);
325
+ this.writeBackup(path);
317
326
  // Atomic write (tmp+rename) so a concurrent load can't read a half-written
318
327
  // migrated file — matches the normal save path (atomicWriteJson). The file
319
328
  // exists here (existsSync guard above), so the recursive mkdir is a no-op.
@@ -351,34 +360,20 @@ export class SettingsManager {
351
360
  */
352
361
  saveUserSetting(key, value) {
353
362
  const path = join(this.userConfigDir(), "settings.json");
354
- mkdirSync(dirname(path), { recursive: true });
363
+ assertSafeSettingsWriteTarget(path);
355
364
  // Lock spans read → modify → write; see mutateSettingsFile for why the
356
365
  // atomic rename alone was not enough. This path keeps its own sanitizing
357
366
  // read (readJsonObject does not sanitize) so behaviour is unchanged apart
358
367
  // from the added serialization.
359
368
  const release = acquireFileLock(path);
360
369
  try {
361
- let current = {};
362
- if (existsSync(path)) {
363
- try {
364
- const raw = readFileSync(path, "utf-8");
365
- const parsed = JSON.parse(raw);
366
- if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
367
- current = sanitizeSettingsObject(parsed);
368
- }
369
- }
370
- catch {
371
- // Corrupt file — overwrite rather than crash.
372
- }
373
- }
370
+ const current = parseConfigFile(path) ?? {};
374
371
  setDottedSetting(current, key, value);
375
372
  // Atomic write: stage to .tmp, then rename, so a concurrent read can't
376
373
  // catch a half-written file. mode 0o600 — settings.json can hold plaintext
377
374
  // API keys, so it must be owner-only like credentials.json (store.ts:56),
378
375
  // not world-readable (default umask leaves 0o644 otherwise).
379
- const tmp = `${path}.${process.pid}.${Date.now()}.tmp`;
380
- writeFileSync(tmp, JSON.stringify(current, null, 2), { encoding: "utf-8", mode: 0o600 });
381
- renameSync(tmp, path);
376
+ this.atomicWriteJson(path, current);
382
377
  }
383
378
  finally {
384
379
  release();
@@ -392,14 +387,31 @@ export class SettingsManager {
392
387
  * cache invalidation mirror saveUserSetting.
393
388
  */
394
389
  saveProjectSetting(key, value, cwd) {
395
- // projectSettingsPath throws on an empty cwd (boundary guard) — keep that.
396
- const path = this.projectSettingsPath(cwd);
390
+ this.validateProjectCwd(cwd, "project");
397
391
  // Don't resurrect a deleted project root: atomicWriteJson's recursive mkdir
398
392
  // of <cwd>/.code-shell recreates `cwd` itself as an empty shell when cwd is
399
393
  // gone. A non-empty cwd that no longer exists means the project was deleted
400
394
  // — skip the write rather than recreate it.
401
395
  if (!existsSync(cwd))
402
396
  return;
397
+ const path = this.projectSettingsPath(cwd);
398
+ this.mutateSettingsFile(path, (current) => {
399
+ setDottedSetting(current, key, value);
400
+ });
401
+ this.invalidate();
402
+ }
403
+ /**
404
+ * Persist a machine-private setting for one project. The local layer has
405
+ * higher precedence than the shared project layer and lives at
406
+ * `${cwd}/.code-shell/settings.local.json`, matching the file already read
407
+ * by {@link load}. It is useful for MCP endpoints or policy that should not
408
+ * be shared with collaborators.
409
+ */
410
+ saveLocalSetting(key, value, cwd) {
411
+ this.validateProjectCwd(cwd, "local");
412
+ if (!existsSync(cwd))
413
+ return;
414
+ const path = this.localSettingsPath(cwd);
403
415
  this.mutateSettingsFile(path, (current) => {
404
416
  setDottedSetting(current, key, value);
405
417
  });
@@ -412,6 +424,19 @@ export class SettingsManager {
412
424
  */
413
425
  deleteProjectSetting(key, cwd) {
414
426
  const path = this.projectSettingsPath(cwd);
427
+ this.deleteSettingFromFile(path, key);
428
+ }
429
+ /** Delete one dotted key from the machine-private project settings layer. */
430
+ deleteLocalSetting(key, cwd) {
431
+ const path = this.localSettingsPath(cwd);
432
+ this.deleteSettingFromFile(path, key);
433
+ }
434
+ /** Delete one dotted key from the user settings layer. */
435
+ deleteUserSetting(key) {
436
+ const path = join(this.userConfigDir(), "settings.json");
437
+ this.deleteSettingFromFile(path, key);
438
+ }
439
+ deleteSettingFromFile(path, key) {
415
440
  // Must be YAML-aware, symmetric with saveProjectSetting: a project with only
416
441
  // settings.yaml has no .json, so the old `existsSync(path)` guard returned
417
442
  // here and the override survived (read/merge ARE yaml-aware → UI shows
@@ -442,13 +467,18 @@ export class SettingsManager {
442
467
  * overlay math needs the project overlay and the user/global baseline
443
468
  * separately — the merged get() collapses provenance and can't express
444
469
  * tri-state inheritance. user → ~/.code-shell/settings.json, project →
445
- * ${cwd}/.code-shell/settings.json. Only keys actually present in the file
446
- * are returned (defaults are not synthesized), so an absent file → {}.
470
+ * ${cwd}/.code-shell/settings.json, local
471
+ * ${cwd}/.code-shell/settings.local.json. Only keys actually present in the
472
+ * file are returned (defaults are not synthesized), so an absent file → {}.
447
473
  */
448
474
  getForScope(scope, cwd) {
449
475
  const path = scope === "user"
450
476
  ? join(this.userConfigDir(), "settings.json")
451
- : this.projectSettingsPath(cwd ?? this.cwd);
477
+ : scope === "local"
478
+ ? this.tryProjectSettingsPath(cwd ?? this.cwd, "settings.local.json")
479
+ : this.tryProjectSettingsPath(cwd ?? this.cwd, "settings.json");
480
+ if (!path)
481
+ return {};
452
482
  const raw = this.readJsonObject(path);
453
483
  // validateSettings applies defaults; for a scope view we want only the
454
484
  // file's own keys, so validate then project back the present keys.
@@ -456,21 +486,55 @@ export class SettingsManager {
456
486
  const out = {};
457
487
  for (const k of Object.keys(raw))
458
488
  out[k] = validated[k];
459
- // Same workspace-trust gate as load(): getForScope("project") is a direct
460
- // file read that bypasses the merge, so an untrusted project's dangerous
461
- // fields (e.g. localEnvironment.setupScripts shell run at worktree setup)
462
- // must be stripped here too. See DANGEROUS_PROJECT_FIELDS.
463
- if (scope === "project" && !this.projectTrusted) {
489
+ // Same workspace-trust gate as load(): project/local scope reads bypass the
490
+ // merge, so an untrusted project's dangerous fields (for example an MCP
491
+ // server or setup script) must be stripped here too.
492
+ if ((scope === "project" || scope === "local") && !this.projectTrusted) {
464
493
  for (const field of DANGEROUS_PROJECT_FIELDS)
465
494
  delete out[field];
466
495
  }
467
496
  return out;
468
497
  }
469
498
  projectSettingsPath(cwd) {
499
+ const path = this.tryProjectSettingsPath(cwd, "settings.json", true);
500
+ if (!path)
501
+ throw new Error("unsafe project settings directory");
502
+ return path;
503
+ }
504
+ localSettingsPath(cwd) {
505
+ const path = this.tryProjectSettingsPath(cwd, "settings.local.json", true);
506
+ if (!path)
507
+ throw new Error("unsafe local settings directory");
508
+ return path;
509
+ }
510
+ validateProjectCwd(cwd, layer) {
470
511
  if (!cwd || cwd.trim().length === 0) {
471
- throw new Error("project setting write requires a non-empty cwd");
512
+ throw new Error(`${layer} setting write requires a non-empty cwd`);
513
+ }
514
+ }
515
+ /** Resolve the project root once and refuse a linked/non-directory state root. */
516
+ tryProjectSettingsPath(cwd, filename, strict = false) {
517
+ this.validateProjectCwd(cwd, filename === "settings.json" ? "project" : "local");
518
+ if (!existsSync(cwd))
519
+ return join(cwd, ".code-shell", filename);
520
+ try {
521
+ const root = realpathSync(cwd);
522
+ if (!lstatSync(root).isDirectory())
523
+ throw new Error("project root is not a directory");
524
+ const stateDir = join(root, ".code-shell");
525
+ if (existsSync(stateDir)) {
526
+ const info = lstatSync(stateDir);
527
+ if (info.isSymbolicLink() || !info.isDirectory()) {
528
+ throw new Error("project .code-shell must be a real directory");
529
+ }
530
+ }
531
+ return join(stateDir, filename);
532
+ }
533
+ catch (error) {
534
+ if (strict)
535
+ throw error;
536
+ return null;
472
537
  }
473
- return join(cwd, ".code-shell", "settings.json");
474
538
  }
475
539
  readJsonObject(path) {
476
540
  // Resolve to a sibling .yaml/.yml when the .json layer is absent so
@@ -481,12 +545,22 @@ export class SettingsManager {
481
545
  return parseConfigFile(resolved) ?? {};
482
546
  }
483
547
  atomicWriteJson(path, data) {
484
- mkdirSync(dirname(path), { recursive: true });
548
+ assertSafeSettingsWriteTarget(path);
549
+ const serialized = JSON.stringify(data, null, 2);
550
+ if (Buffer.byteLength(serialized, "utf8") > MAX_SETTINGS_FILE_BYTES) {
551
+ throw new Error(`settings file exceeds ${MAX_SETTINGS_FILE_BYTES} bytes`);
552
+ }
485
553
  // mode 0o600: settings.json may hold plaintext API keys — owner-only, see
486
554
  // saveUserSetting above and credentials/store.ts.
487
- const tmp = `${path}.${process.pid}.${Date.now()}.tmp`;
488
- writeFileSync(tmp, JSON.stringify(data, null, 2), { encoding: "utf-8", mode: 0o600 });
489
- renameSync(tmp, path);
555
+ writeFileAtomic(path, serialized, 0o600);
556
+ }
557
+ writeBackup(path) {
558
+ const content = readBoundedRegularFile(path);
559
+ if (content === null)
560
+ throw new Error("settings backup source is unsafe");
561
+ const backupPath = `${path}.bak`;
562
+ assertSafeSettingsWriteTarget(backupPath);
563
+ writeFileAtomic(backupPath, content, 0o600);
490
564
  }
491
565
  /**
492
566
  * Read-modify-write a settings file under a cross-process lock.
@@ -506,9 +580,10 @@ export class SettingsManager {
506
580
  * mutates it in place; returning false skips the write.
507
581
  */
508
582
  mutateSettingsFile(path, mutate) {
509
- mkdirSync(dirname(path), { recursive: true });
583
+ assertSafeSettingsWriteTarget(path);
510
584
  const release = acquireFileLock(path);
511
585
  try {
586
+ assertSafeSettingsWriteTarget(path);
512
587
  // Re-read INSIDE the lock: a snapshot taken before acquiring it would be
513
588
  // exactly the stale value that drops the other writer's key.
514
589
  const current = this.readJsonObject(path);
@@ -577,10 +652,10 @@ export class SettingsManager {
577
652
  * what an absent/empty layer means.
578
653
  */
579
654
  function parseConfigFile(path) {
580
- if (!existsSync(path))
581
- return null;
582
655
  try {
583
- const content = readFileSync(path, "utf-8");
656
+ const content = readBoundedRegularFile(path);
657
+ if (content === null)
658
+ return null;
584
659
  const ext = extname(path).toLowerCase();
585
660
  const parsed = ext === ".yaml" || ext === ".yml" ? parseYaml(content) : JSON.parse(content);
586
661
  if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
@@ -592,6 +667,49 @@ function parseConfigFile(path) {
592
667
  }
593
668
  return null;
594
669
  }
670
+ /** Read through a no-follow descriptor so a settings-file symlink cannot escape its layer. */
671
+ function readBoundedRegularFile(path) {
672
+ let fd;
673
+ try {
674
+ fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW);
675
+ const info = fstatSync(fd);
676
+ if (!info.isFile() || info.size > MAX_SETTINGS_FILE_BYTES)
677
+ return null;
678
+ return readFileSync(fd, "utf8");
679
+ }
680
+ catch {
681
+ return null;
682
+ }
683
+ finally {
684
+ if (fd !== undefined)
685
+ closeSync(fd);
686
+ }
687
+ }
688
+ /**
689
+ * Writers must never follow a linked settings directory or replace an unusual
690
+ * filesystem object. Atomic rename protects the file contents, but without
691
+ * this boundary check a project-controlled `.code-shell` directory symlink
692
+ * redirects the entire write outside the workspace.
693
+ */
694
+ function assertSafeSettingsWriteTarget(path) {
695
+ const parent = dirname(path);
696
+ if (!existsSync(parent))
697
+ mkdirSync(parent, { recursive: true });
698
+ const parentInfo = lstatSync(parent);
699
+ if (parentInfo.isSymbolicLink() || !parentInfo.isDirectory()) {
700
+ throw new Error("settings directory must be a real directory");
701
+ }
702
+ try {
703
+ const targetInfo = lstatSync(path);
704
+ if (targetInfo.isSymbolicLink() || !targetInfo.isFile()) {
705
+ throw new Error("settings target must be a regular file");
706
+ }
707
+ }
708
+ catch (error) {
709
+ if (error.code !== "ENOENT")
710
+ throw error;
711
+ }
712
+ }
595
713
  /**
596
714
  * Given the JSON path for a settings layer (e.g. .../settings.json or
597
715
  * .../settings.local.json), return the path that should actually be read:
@@ -600,16 +718,33 @@ function parseConfigFile(path) {
600
718
  * hand-written read-only alternative. Returns null when no layer file exists.
601
719
  */
602
720
  function resolveConfigPath(jsonPath) {
603
- if (existsSync(jsonPath))
721
+ const jsonStatus = configCandidateStatus(jsonPath);
722
+ if (jsonStatus === "safe")
604
723
  return jsonPath;
724
+ if (jsonStatus === "unsafe")
725
+ return null;
605
726
  const base = jsonPath.replace(/\.json$/, "");
606
727
  for (const ext of [".yaml", ".yml"]) {
607
728
  const candidate = `${base}${ext}`;
608
- if (existsSync(candidate))
729
+ const status = configCandidateStatus(candidate);
730
+ if (status === "safe")
609
731
  return candidate;
732
+ if (status === "unsafe")
733
+ return null;
610
734
  }
611
735
  return null;
612
736
  }
737
+ function configCandidateStatus(path) {
738
+ try {
739
+ const info = lstatSync(path);
740
+ return !info.isSymbolicLink() && info.isFile() && info.size <= MAX_SETTINGS_FILE_BYTES
741
+ ? "safe"
742
+ : "unsafe";
743
+ }
744
+ catch (error) {
745
+ return error.code === "ENOENT" ? "missing" : "unsafe";
746
+ }
747
+ }
613
748
  function merge(base, override) {
614
749
  const result = {};
615
750
  for (const [key, value] of Object.entries(base)) {
@@ -681,7 +681,7 @@ export declare const SettingsSchema: z.ZodObject<{
681
681
  */
682
682
  sources: z.ZodOptional<z.ZodArray<z.ZodObject<{
683
683
  sourceId: z.ZodString;
684
- scopes: z.ZodArray<z.ZodString, "many">;
684
+ scopes: z.ZodEffects<z.ZodArray<z.ZodEffects<z.ZodString, string, string>, "many">, string[], string[]>;
685
685
  readPolicy: z.ZodDefault<z.ZodEnum<["ask", "deny"]>>;
686
686
  }, "strip", z.ZodTypeAny, {
687
687
  sourceId: string;
@@ -1705,7 +1705,7 @@ export declare const SettingsSchema: z.ZodObject<{
1705
1705
  */
1706
1706
  sources: z.ZodOptional<z.ZodArray<z.ZodObject<{
1707
1707
  sourceId: z.ZodString;
1708
- scopes: z.ZodArray<z.ZodString, "many">;
1708
+ scopes: z.ZodEffects<z.ZodArray<z.ZodEffects<z.ZodString, string, string>, "many">, string[], string[]>;
1709
1709
  readPolicy: z.ZodDefault<z.ZodEnum<["ask", "deny"]>>;
1710
1710
  }, "strip", z.ZodTypeAny, {
1711
1711
  sourceId: string;
@@ -2729,7 +2729,7 @@ export declare const SettingsSchema: z.ZodObject<{
2729
2729
  */
2730
2730
  sources: z.ZodOptional<z.ZodArray<z.ZodObject<{
2731
2731
  sourceId: z.ZodString;
2732
- scopes: z.ZodArray<z.ZodString, "many">;
2732
+ scopes: z.ZodEffects<z.ZodArray<z.ZodEffects<z.ZodString, string, string>, "many">, string[], string[]>;
2733
2733
  readPolicy: z.ZodDefault<z.ZodEnum<["ask", "deny"]>>;
2734
2734
  }, "strip", z.ZodTypeAny, {
2735
2735
  sourceId: string;
@@ -3,7 +3,7 @@
3
3
  * 文件在 ${cwd}/.code-shell/uploads/ 内;读取前规范化 resourceId,
4
4
  * 并校验消解 symlink 后的真实路径仍在 uploads 目录内。
5
5
  */
6
- import { existsSync, readdirSync, readFileSync, realpathSync, statSync } from "node:fs";
6
+ import { existsSync, lstatSync, readdirSync, readFileSync, realpathSync, statSync } from "node:fs";
7
7
  import { basename, dirname, isAbsolute, join, normalize, relative, resolve, sep } from "node:path";
8
8
  import { truncateUtf8Bytes } from "../truncate-utf8.js";
9
9
  export const LOCAL_FILES_SOURCE_ID = "project-uploads";
@@ -76,9 +76,32 @@ function canonicalResourceId(resourceId) {
76
76
  return canonical.split(sep).join("/");
77
77
  }
78
78
  function resolveInsideUploads(cwd, resourceId) {
79
- const root = realpathSync(uploadsDir(cwd));
79
+ const workspace = realpathSync(resolve(cwd));
80
+ const stateDir = join(workspace, ".code-shell");
81
+ const stateInfo = lstatSync(stateDir);
82
+ if (stateInfo.isSymbolicLink() || !stateInfo.isDirectory()) {
83
+ throw new Error("project state directory must be a regular directory");
84
+ }
85
+ const stateReal = realpathSync(stateDir);
86
+ if (relative(workspace, stateReal).startsWith(`..${sep}`) || relative(workspace, stateReal) === "..") {
87
+ throw new Error("project state directory escapes cwd");
88
+ }
89
+ const uploadPath = join(stateReal, "uploads");
90
+ const uploadInfo = lstatSync(uploadPath);
91
+ if (uploadInfo.isSymbolicLink() || !uploadInfo.isDirectory()) {
92
+ throw new Error("uploads directory must be a regular directory");
93
+ }
94
+ const root = realpathSync(uploadPath);
95
+ const uploadRel = relative(stateReal, root);
96
+ if (uploadRel === ".." || uploadRel.startsWith(`..${sep}`) || isAbsolute(uploadRel)) {
97
+ throw new Error("uploads directory escapes cwd");
98
+ }
80
99
  const canonicalId = canonicalResourceId(resourceId);
81
100
  const candidate = resolve(root, ...canonicalId.split("/"));
101
+ const candidateInfo = lstatSync(candidate);
102
+ if (candidateInfo.isSymbolicLink() || !candidateInfo.isFile()) {
103
+ throw new Error(`resource escapes uploads or is not a regular file: ${resourceId}`);
104
+ }
82
105
  const real = realpathSync(candidate);
83
106
  if (real !== root && !real.startsWith(`${root}${sep}`)) {
84
107
  throw new Error(`resource escapes uploads dir: ${resourceId}`);
@@ -115,13 +138,35 @@ export function listLocalFiles(cwd) {
115
138
  const directory = uploadsDir(cwd);
116
139
  if (!existsSync(directory))
117
140
  return [];
118
- return readdirSync(directory, { withFileTypes: true })
141
+ let workspace;
142
+ let directoryReal;
143
+ try {
144
+ workspace = realpathSync(resolve(cwd));
145
+ const stateDir = join(workspace, ".code-shell");
146
+ const stateInfo = lstatSync(stateDir);
147
+ const directoryInfo = lstatSync(directory);
148
+ if (stateInfo.isSymbolicLink() ||
149
+ !stateInfo.isDirectory() ||
150
+ directoryInfo.isSymbolicLink() ||
151
+ !directoryInfo.isDirectory()) {
152
+ return [];
153
+ }
154
+ directoryReal = realpathSync(directory);
155
+ const rel = relative(workspace, directoryReal);
156
+ if (rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel))
157
+ return [];
158
+ }
159
+ catch {
160
+ return [];
161
+ }
162
+ return readdirSync(directoryReal, { withFileTypes: true })
119
163
  .filter((entry) => entry.isFile())
164
+ .slice(0, 10_000)
120
165
  .sort((left, right) => left.name.localeCompare(right.name))
121
166
  .map((entry) => ({
122
167
  id: entry.name,
123
168
  scopeId: "uploads",
124
169
  name: entry.name,
125
- sizeBytes: statSync(join(directory, entry.name)).size,
170
+ sizeBytes: statSync(join(directoryReal, entry.name)).size,
126
171
  }));
127
172
  }
@@ -1,25 +1,39 @@
1
1
  /** 全局数据源目录:codeShellHome()/sources.json。损坏条目隔离,原子写。 */
2
- import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
2
+ import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync } from "node:fs";
3
3
  import { dirname, join } from "node:path";
4
4
  import { logger } from "../logging/logger.js";
5
5
  import { codeShellHome } from "../session/session-manager.js";
6
+ import { mutateJsonFile } from "../utils/file-mutex.js";
6
7
  import { SourceDefinitionSchema } from "./types.js";
8
+ const MAX_CATALOG_BYTES = 4 * 1024 * 1024;
9
+ const MAX_CATALOG_SOURCES = 1_000;
10
+ const MAX_SOURCE_DEFINITION_BYTES = 1024 * 1024;
7
11
  export function sourceCatalogPath() {
8
12
  return join(codeShellHome(), "sources.json");
9
13
  }
10
- function load() {
11
- const path = sourceCatalogPath();
12
- if (!existsSync(path))
14
+ function parseCatalog(rawText) {
15
+ if (rawText === undefined)
16
+ return [];
17
+ if (Buffer.byteLength(rawText, "utf8") > MAX_CATALOG_BYTES)
13
18
  return [];
14
19
  try {
15
- const raw = JSON.parse(readFileSync(path, "utf-8"));
20
+ const raw = JSON.parse(rawText);
16
21
  if (raw.version !== 1 || !Array.isArray(raw.sources))
17
22
  return [];
18
- const sources = [];
19
- for (const entry of raw.sources) {
23
+ const sources = new Map();
24
+ for (const entry of raw.sources.slice(0, MAX_CATALOG_SOURCES)) {
20
25
  const parsed = SourceDefinitionSchema.safeParse(entry);
21
26
  if (parsed.success) {
22
- sources.push(parsed.data);
27
+ let encoded;
28
+ try {
29
+ encoded = JSON.stringify(parsed.data);
30
+ }
31
+ catch {
32
+ continue;
33
+ }
34
+ if (Buffer.byteLength(encoded, "utf8") <= MAX_SOURCE_DEFINITION_BYTES) {
35
+ sources.set(parsed.data.id, parsed.data);
36
+ }
23
37
  }
24
38
  else {
25
39
  logger.warn("sources.catalog_entry_invalid", {
@@ -28,7 +42,7 @@ function load() {
28
42
  });
29
43
  }
30
44
  }
31
- return sources;
45
+ return [...sources.values()];
32
46
  }
33
47
  catch (error) {
34
48
  logger.warn("sources.catalog_unreadable", {
@@ -38,15 +52,32 @@ function load() {
38
52
  return [];
39
53
  }
40
54
  }
41
- function persist(sources) {
55
+ function load() {
42
56
  const path = sourceCatalogPath();
43
- mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
44
- const tmp = `${path}.${process.pid}.tmp`;
45
- writeFileSync(tmp, `${JSON.stringify({ version: 1, sources }, null, 2)}\n`, {
46
- encoding: "utf-8",
57
+ if (!existsSync(path))
58
+ return [];
59
+ try {
60
+ const info = lstatSync(path);
61
+ if (info.isSymbolicLink() || !info.isFile() || info.size > MAX_CATALOG_BYTES)
62
+ return [];
63
+ return parseCatalog(readFileSync(path, "utf-8"));
64
+ }
65
+ catch {
66
+ return [];
67
+ }
68
+ }
69
+ function mutateCatalog(mutation) {
70
+ const path = sourceCatalogPath();
71
+ const directory = dirname(path);
72
+ mkdirSync(directory, { recursive: true, mode: 0o700 });
73
+ if (process.platform !== "win32")
74
+ chmodSync(directory, 0o700);
75
+ mutateJsonFile(path, {
76
+ parse: parseCatalog,
77
+ serialize: (sources) => `${JSON.stringify({ version: 1, sources }, null, 2)}\n`,
78
+ mutation: (current) => ({ value: mutation(current) }),
47
79
  mode: 0o600,
48
80
  });
49
- renameSync(tmp, path);
50
81
  }
51
82
  export function listSourceDefinitions() {
52
83
  return load().sort((a, b) => a.id.localeCompare(b.id));
@@ -56,9 +87,24 @@ export function readSourceDefinition(id) {
56
87
  }
57
88
  export function saveSourceDefinition(definition) {
58
89
  const parsed = SourceDefinitionSchema.parse(definition);
59
- const rest = load().filter((source) => source.id !== parsed.id);
60
- persist([...rest, parsed]);
90
+ let encoded;
91
+ try {
92
+ encoded = JSON.stringify(parsed);
93
+ }
94
+ catch {
95
+ throw new Error("source definition must be JSON-serializable");
96
+ }
97
+ if (Buffer.byteLength(encoded, "utf8") > MAX_SOURCE_DEFINITION_BYTES) {
98
+ throw new Error("source definition exceeds the size limit");
99
+ }
100
+ mutateCatalog((current) => {
101
+ const rest = current.filter((source) => source.id !== parsed.id);
102
+ if (rest.length >= MAX_CATALOG_SOURCES)
103
+ throw new Error("source catalog is full");
104
+ return [...rest, parsed];
105
+ });
61
106
  }
62
107
  export function deleteSourceDefinition(id) {
63
- persist(load().filter((source) => source.id !== id));
108
+ SourceDefinitionSchema.shape.id.parse(id);
109
+ mutateCatalog((current) => current.filter((source) => source.id !== id));
64
110
  }
@@ -10,8 +10,8 @@ export type SourceKind = (typeof SOURCE_KINDS)[number];
10
10
  export declare const SourceDefinitionSchema: z.ZodObject<{
11
11
  id: z.ZodString;
12
12
  kind: z.ZodEnum<["mock", "mcp-resource", "local-files"]>;
13
- label: z.ZodString;
14
- description: z.ZodOptional<z.ZodString>;
13
+ label: z.ZodEffects<z.ZodString, string, string>;
14
+ description: z.ZodOptional<z.ZodEffects<z.ZodString, string, string>>;
15
15
  /** 按 kind 的 adapter 配置(如 mcp-resource: { server })。 */
16
16
  adapterConfig: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
17
17
  /** 指向全局 CredentialStore 的 id;local-files/mock 不需要。 */
@@ -38,7 +38,7 @@ export type SourceDefinition = z.infer<typeof SourceDefinitionSchema>;
38
38
  export declare const WorkspaceSourceBindingSchema: z.ZodObject<{
39
39
  sourceId: z.ZodString;
40
40
  /** 显式勾选的 scope id;空数组 = 什么都不可见(不是"全部")。 */
41
- scopes: z.ZodArray<z.ZodString, "many">;
41
+ scopes: z.ZodEffects<z.ZodArray<z.ZodEffects<z.ZodString, string, string>, "many">, string[], string[]>;
42
42
  /** ask(默认,ReadSource 每次审批)| deny(只许 list metadata,禁读内容)。无 allow 档(ADR §1.2)。 */
43
43
  readPolicy: z.ZodDefault<z.ZodEnum<["ask", "deny"]>>;
44
44
  }, "strip", z.ZodTypeAny, {