cc-viewer 1.9.0 → 1.9.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.
Files changed (159) hide show
  1. package/README.md +3 -1
  2. package/cli.js +33 -4
  3. package/concepts/ar/UltraPlan.md +1 -1
  4. package/concepts/da/UltraPlan.md +1 -1
  5. package/concepts/de/UltraPlan.md +1 -1
  6. package/concepts/en/UltraPlan.md +1 -1
  7. package/concepts/es/UltraPlan.md +1 -1
  8. package/concepts/fr/UltraPlan.md +1 -1
  9. package/concepts/it/UltraPlan.md +1 -1
  10. package/concepts/ja/UltraPlan.md +1 -1
  11. package/concepts/ko/UltraPlan.md +1 -1
  12. package/concepts/no/UltraPlan.md +1 -1
  13. package/concepts/pl/UltraPlan.md +1 -1
  14. package/concepts/pt-BR/UltraPlan.md +1 -1
  15. package/concepts/ru/UltraPlan.md +1 -1
  16. package/concepts/th/UltraPlan.md +1 -1
  17. package/concepts/tr/UltraPlan.md +1 -1
  18. package/concepts/uk/UltraPlan.md +1 -1
  19. package/concepts/zh/UltraPlan.md +1 -1
  20. package/concepts/zh-TW/UltraPlan.md +1 -1
  21. package/dist/assets/App-DM-fbhNd.js +2 -0
  22. package/dist/assets/App-DOZI5HME.css +1 -0
  23. package/dist/assets/{MdxEditorPanel-Dpn-4ydO.css → MdxEditorPanel-DTfdj6GS.css} +1 -1
  24. package/dist/assets/{MdxEditorPanel-3j8rS3IT.js → MdxEditorPanel-DjwOQDh0.js} +1 -1
  25. package/dist/assets/Mobile-BD-hefIH.js +1 -0
  26. package/dist/assets/ProxyStatsModal-pYa_0QVL.js +1 -0
  27. package/dist/assets/{_baseUniq-B3e4JaL0.js → _baseUniq-CwQykUuc.js} +1 -1
  28. package/dist/assets/{arc-DnoPn_qY.js → arc-EdUdBxtG.js} +1 -1
  29. package/dist/assets/{architectureDiagram-Q4EWVU46-DrPKTbxH.js → architectureDiagram-Q4EWVU46-DtBIdElY.js} +1 -1
  30. package/dist/assets/{blockDiagram-DXYQGD6D-CEaO0Vcw.js → blockDiagram-DXYQGD6D-guNZIKkZ.js} +1 -1
  31. package/dist/assets/{c4Diagram-AHTNJAMY-BLaawFeg.js → c4Diagram-AHTNJAMY-xV5vB-Z9.js} +1 -1
  32. package/dist/assets/{channel-DnYR1fF4.js → channel-DYEktNap.js} +1 -1
  33. package/dist/assets/{chunk-4BX2VUAB-Bfd4edKZ.js → chunk-4BX2VUAB-ZTkItpvj.js} +1 -1
  34. package/dist/assets/{chunk-4TB4RGXK-t2nMQmY7.js → chunk-4TB4RGXK-dYIewyyR.js} +1 -1
  35. package/dist/assets/{chunk-55IACEB6-C6tLOjGX.js → chunk-55IACEB6-CSsPNgPX.js} +1 -1
  36. package/dist/assets/{chunk-EDXVE4YY-dWCvxbCb.js → chunk-EDXVE4YY-CnMiuqzQ.js} +1 -1
  37. package/dist/assets/{chunk-FMBD7UC4-BHELZLPK.js → chunk-FMBD7UC4-CVpATPZ3.js} +1 -1
  38. package/dist/assets/{chunk-OYMX7WX6-C8XTkH1S.js → chunk-OYMX7WX6-Dpq4s8dW.js} +1 -1
  39. package/dist/assets/{chunk-QZHKN3VN-CD0j6SAs.js → chunk-QZHKN3VN-Bnojh6X6.js} +1 -1
  40. package/dist/assets/{chunk-YZCP3GAM-D4dKfXJJ.js → chunk-YZCP3GAM-BYqK4Dsa.js} +1 -1
  41. package/dist/assets/classDiagram-6PBFFD2Q-ClGF33bF.js +1 -0
  42. package/dist/assets/classDiagram-v2-HSJHXN6E-ClGF33bF.js +1 -0
  43. package/dist/assets/clone-CFj55h49.js +1 -0
  44. package/dist/assets/{cose-bilkent-S5V4N54A-BF2HsiSD.js → cose-bilkent-S5V4N54A-OJ7mmWyV.js} +1 -1
  45. package/dist/assets/{dagre-KV5264BT-f3h967yx.js → dagre-KV5264BT-B1Kwd_Rk.js} +1 -1
  46. package/dist/assets/{diagram-5BDNPKRD-B9PzHQfh.js → diagram-5BDNPKRD-D8qrw8sp.js} +1 -1
  47. package/dist/assets/{diagram-G4DWMVQ6-kR953Bqm.js → diagram-G4DWMVQ6-ip-N4ho8.js} +1 -1
  48. package/dist/assets/{diagram-MMDJMWI5-DAewa2eg.js → diagram-MMDJMWI5-2PZaskUF.js} +1 -1
  49. package/dist/assets/{diagram-TYMM5635-C8HNTH6I.js → diagram-TYMM5635-CeAOQUZP.js} +1 -1
  50. package/dist/assets/{erDiagram-SMLLAGMA-BQ5P8wWK.js → erDiagram-SMLLAGMA-BCBaEYEb.js} +1 -1
  51. package/dist/assets/{flowDiagram-DWJPFMVM-BIpEkrMR.js → flowDiagram-DWJPFMVM-CsGyIqcX.js} +1 -1
  52. package/dist/assets/{ganttDiagram-T4ZO3ILL-CWqSfB5v.js → ganttDiagram-T4ZO3ILL-COsj3FZ-.js} +1 -1
  53. package/dist/assets/{gitGraphDiagram-UUTBAWPF-Ba-9yEbQ.js → gitGraphDiagram-UUTBAWPF-DvQVQGJG.js} +1 -1
  54. package/dist/assets/{graph-D3zXKQ6P.js → graph-BSB9fDSP.js} +1 -1
  55. package/dist/assets/{index-G3xd-gLo.js → index-BRw4Iz2O.js} +1 -1
  56. package/dist/assets/{index-DClD_EkN.js → index-Bo-12bug.js} +1 -1
  57. package/dist/assets/{index-DSd5P3WQ.js → index-BuVComcY.js} +1 -1
  58. package/dist/assets/{index-HLrnYsI1.js → index-CNNAYjkF.js} +1 -1
  59. package/dist/assets/{index-Bm3A5VLb.js → index-DKufsAzx.js} +1 -1
  60. package/dist/assets/{index-C9N0rZ7Z.js → index-DXBfwPYD.js} +1 -1
  61. package/dist/assets/index-DkIhbLjr.js +2 -0
  62. package/dist/assets/{index-Nvk8r3tO.css → index-Dz36p_bA.css} +1 -1
  63. package/dist/assets/{index-Q8wp4Nof.js → index-eM2ih5n7.js} +1 -1
  64. package/dist/assets/{infoDiagram-42DDH7IO-Cxm79ZJR.js → infoDiagram-42DDH7IO-eNucS4iW.js} +1 -1
  65. package/dist/assets/{ishikawaDiagram-UXIWVN3A-Cd0vZP1B.js → ishikawaDiagram-UXIWVN3A-Dxxj-gUX.js} +1 -1
  66. package/dist/assets/{journeyDiagram-VCZTEJTY-B2b57HXk.js → journeyDiagram-VCZTEJTY-yJJzVEBb.js} +1 -1
  67. package/dist/assets/{jszip.min-Bj5uiEb2.js → jszip.min-ByI0ds5u.js} +1 -1
  68. package/dist/assets/{kanban-definition-6JOO6SKY-YCERiUCO.js → kanban-definition-6JOO6SKY-2rfTi2zw.js} +1 -1
  69. package/dist/assets/{layout-Do5TlO2U.js → layout-DTmHj0Wm.js} +1 -1
  70. package/dist/assets/{linear-BiJNTDgN.js → linear-BOA3a9FE.js} +1 -1
  71. package/dist/assets/{mermaid.core-Dg0McRZt.js → mermaid.core-5H-NR79H.js} +3 -3
  72. package/dist/assets/{min-T51iefht.js → min-CpjFTEec.js} +1 -1
  73. package/dist/assets/{mindmap-definition-QFDTVHPH-Cu_GJxY3.js → mindmap-definition-QFDTVHPH-B8KCjFgB.js} +1 -1
  74. package/dist/assets/{pieDiagram-DEJITSTG-DldHHZXi.js → pieDiagram-DEJITSTG-BARy96g5.js} +1 -1
  75. package/dist/assets/{quadrantDiagram-34T5L4WZ-BzyTPNAJ.js → quadrantDiagram-34T5L4WZ-DYoTapuI.js} +1 -1
  76. package/dist/assets/{requirementDiagram-MS252O5E-C5xxFkvG.js → requirementDiagram-MS252O5E-EeANLLK2.js} +1 -1
  77. package/dist/assets/{sankeyDiagram-XADWPNL6-OxdjoaE1.js → sankeyDiagram-XADWPNL6-DmQ-YVPj.js} +1 -1
  78. package/dist/assets/seqResourceLoaders-BdWMUSrC.css +41 -0
  79. package/dist/assets/seqResourceLoaders-Dzy1J6G9.js +2 -0
  80. package/dist/assets/{sequenceDiagram-FGHM5R23-C7c89uIS.js → sequenceDiagram-FGHM5R23-SZtW0rYC.js} +1 -1
  81. package/dist/assets/{stateDiagram-FHFEXIEX-ssdJnDrO.js → stateDiagram-FHFEXIEX-CEygsIld.js} +1 -1
  82. package/dist/assets/{stateDiagram-v2-QKLJ7IA2-BP-0WClK.js → stateDiagram-v2-QKLJ7IA2-BU1XPhBe.js} +1 -1
  83. package/dist/assets/{timeline-definition-GMOUNBTQ-sWScEpSM.js → timeline-definition-GMOUNBTQ-DhPBaQBt.js} +1 -1
  84. package/dist/assets/{vendor-antd-DKIytXho.js → vendor-antd-CS_S2mBN.js} +1 -1
  85. package/dist/assets/{vendor-codemirror-4qgXq03k.js → vendor-codemirror-B5l-VbBO.js} +1 -1
  86. package/dist/assets/{vendor-mdxeditor-D5Z5D9fq.js → vendor-mdxeditor-Cz09a1_I.js} +2 -2
  87. package/dist/assets/{vendor-qrcode-B7lb5qk1.js → vendor-qrcode-qbbntD6-.js} +1 -1
  88. package/dist/assets/{vendor-virtuoso-BqoXlbhH.js → vendor-virtuoso-Sk8fQ4Hx.js} +1 -1
  89. package/dist/assets/{vennDiagram-DHZGUBPP-DNfXR0ge.js → vennDiagram-DHZGUBPP-NniTHxDw.js} +1 -1
  90. package/dist/assets/{wardley-RL74JXVD-JXd--CRD.js → wardley-RL74JXVD-D9SNOrDw.js} +1 -1
  91. package/dist/assets/{wardleyDiagram-NUSXRM2D-DHmNyVPM.js → wardleyDiagram-NUSXRM2D-DvHXKNza.js} +1 -1
  92. package/dist/assets/{xychartDiagram-5P7HB3ND-CTb0gltZ.js → xychartDiagram-5P7HB3ND-CaiwPTQB.js} +1 -1
  93. package/dist/index.html +5 -5
  94. package/package.json +1 -1
  95. package/server/interceptor.js +192 -24
  96. package/server/lib/ask/ask-store.js +7 -3
  97. package/server/lib/async-file-lock.js +29 -5
  98. package/server/lib/auth.js +71 -54
  99. package/server/lib/chat-queue.js +67 -4
  100. package/server/lib/config-backup.js +8 -5
  101. package/server/lib/credential-access.js +233 -0
  102. package/server/lib/credential-migrate.js +147 -0
  103. package/server/lib/credential-store.js +120 -0
  104. package/server/lib/credential-vault.js +112 -0
  105. package/server/lib/file-access-policy.js +16 -0
  106. package/server/lib/git-diff.js +49 -20
  107. package/server/lib/im/im-config.js +81 -31
  108. package/server/lib/im-deny.js +22 -8
  109. package/server/lib/json-store.js +264 -0
  110. package/server/lib/log-watcher.js +9 -1
  111. package/server/lib/prefs-store.js +8 -33
  112. package/server/lib/same-origin.js +22 -0
  113. package/server/lib/session-pin-store.js +7 -14
  114. package/server/lib/v2/convert.js +1 -1
  115. package/server/lib/v2/layout.js +28 -3
  116. package/server/lib/v2/live-feed.js +41 -4
  117. package/server/lib/v2/resume-list.js +150 -0
  118. package/server/lib/v2/session-select.js +63 -14
  119. package/server/lib/v2/v2-writer.js +172 -34
  120. package/server/lib/v2/view-router.js +83 -0
  121. package/server/lib/view-root.js +209 -0
  122. package/server/pty-manager.js +800 -163
  123. package/server/routes/ask-perm.js +21 -1
  124. package/server/routes/auth.js +21 -1
  125. package/server/routes/events.js +123 -7
  126. package/server/routes/expert.js +46 -14
  127. package/server/routes/files-content.js +72 -7
  128. package/server/routes/files-fs.js +28 -3
  129. package/server/routes/git.js +31 -5
  130. package/server/routes/im.js +15 -2
  131. package/server/routes/preferences.js +59 -46
  132. package/server/routes/project-meta.js +32 -3
  133. package/server/routes/proxy-stats.js +33 -5
  134. package/server/routes/resume.js +302 -0
  135. package/server/routes/search.js +23 -1
  136. package/server/routes/skills.js +18 -2
  137. package/server/routes/v2.js +6 -1
  138. package/server/routes/workspaces.js +10 -3
  139. package/server/server.js +199 -25
  140. package/server/system-prompt-templates/presets/GLM-5.2.md +0 -1
  141. package/server/system-prompt-templates/presets/GLM-5.3.md +0 -1
  142. package/server/system-prompt-templates/presets/Qwen-3.md +0 -1
  143. package/server/system-prompt-templates/presets/deepseek-v4-flash.md +0 -1
  144. package/server/system-prompt-templates/presets/deepseek-v4-pro.md +0 -1
  145. package/server/system-prompt-templates/presets/kimi-k2.7-code.md +0 -1
  146. package/server/system-prompt-templates/presets/kimi-k3.md +1 -1
  147. package/server/system-prompt-templates/systemPromptModel.md +0 -1
  148. package/server/workspace-registry.js +41 -35
  149. package/ultraAgents/code-expert.json +1 -1
  150. package/dist/assets/App-M61pqQEx.css +0 -1
  151. package/dist/assets/App-_HfxMJvv.js +0 -2
  152. package/dist/assets/Mobile-DaRLB7Gh.js +0 -1
  153. package/dist/assets/ProxyStatsModal-dlPIDlx-.js +0 -1
  154. package/dist/assets/classDiagram-6PBFFD2Q-BecPAAuk.js +0 -1
  155. package/dist/assets/classDiagram-v2-HSJHXN6E-BecPAAuk.js +0 -1
  156. package/dist/assets/clone-B0_ghF3F.js +0 -1
  157. package/dist/assets/index-CjbBUDGP.js +0 -2
  158. package/dist/assets/seqResourceLoaders-B_FSQh3U.css +0 -41
  159. package/dist/assets/seqResourceLoaders-hqLhsiGO.js +0 -2
@@ -0,0 +1,112 @@
1
+ // Credential vault — real at-rest encryption for cc-viewer's secrets.
2
+ //
3
+ // The legacy encodings were obfuscation, not security: profile.json held apiKeys in plaintext,
4
+ // and the LAN password / IM app secrets were base64 inside preferences.json (trivially reversible;
5
+ // the code comments said so themselves). This module upgrades them to AES-256-GCM with a
6
+ // per-record random IV and a machine-local master key.
7
+ //
8
+ // Key model: a single 256-bit `master.key` (0600) generated on first use and stored next to the
9
+ // data root. This is the standard trade-off for a headless background server (no interactive
10
+ // passphrase prompt, no OS-keychain native dep). At-rest on a single machine it is inherently
11
+ // bounded — anyone who reads BOTH master.key and the ciphertext can decrypt — but it raises the
12
+ // bar from "plaintext / base64 in a world-readable-shape file" to "needs the key file too", and it
13
+ // is the prerequisite for any cloud handling (only ciphertext, never plaintext, may leave the
14
+ // machine; the master key never does).
15
+ //
16
+ // Boundary: L1-lib. Path-injected (no LOG_DIR import) so it stays a pure primitive.
17
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, chmodSync } from 'node:fs';
18
+ import { dirname } from 'node:path';
19
+ import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto';
20
+
21
+ const KEY_MODE = 0o600;
22
+ const IV_BYTES = 12; // GCM standard
23
+ const TAG_BYTES = 16; // GCM auth tag
24
+ const KEY_BYTES = 32; // AES-256
25
+
26
+ // Process-local cache so we don't re-read/re-stat the key file on every encrypt. Keyed by the
27
+ // master.key path so tests that redirect LOG_DIR get a fresh key per path.
28
+ const _keyCache = new Map();
29
+
30
+ /**
31
+ * Load (or lazily create) the master key at `keyPath`. Created with randomBytes(32) + 0600 on
32
+ * first use. Throws if the key file exists but is malformed (wrong length) — better to fail loud
33
+ * than silently re-derive and orphan existing ciphertext.
34
+ */
35
+ export function loadMasterKey(keyPath) {
36
+ // Cache is only valid while the on-disk key still exists. If the key file was removed out
37
+ // from under us (a wipe of the data root, an OS cleanup), a stale cached key would let a
38
+ // writer re-encrypt with a key that no longer exists on disk — leaving ciphertext that a
39
+ // later cold process cannot read, and splitting state from _keyUsable()'s disk check.
40
+ if (_keyCache.has(keyPath) && existsSync(keyPath)) return _keyCache.get(keyPath);
41
+ _keyCache.delete(keyPath);
42
+ let key;
43
+ if (existsSync(keyPath)) {
44
+ const raw = readFileSync(keyPath);
45
+ // Stored raw (32 bytes). Tolerate a trailing newline for hand-inspectability.
46
+ const trimmed = raw.subarray(0, KEY_BYTES);
47
+ if (trimmed.length !== KEY_BYTES) {
48
+ throw new Error(`credential-vault: master key at ${keyPath} has ${trimmed.length} bytes, expected ${KEY_BYTES}`);
49
+ }
50
+ key = Buffer.from(trimmed);
51
+ } else {
52
+ key = randomBytes(KEY_BYTES);
53
+ mkdirSync(dirname(keyPath), { recursive: true, mode: 0o700 });
54
+ writeFileSync(keyPath, key, { mode: KEY_MODE });
55
+ try { chmodSync(keyPath, KEY_MODE); } catch { /* best-effort; non-POSIX */ }
56
+ }
57
+ _keyCache.set(keyPath, key);
58
+ return key;
59
+ }
60
+
61
+ /**
62
+ * Encrypt a UTF-8 secret → base64(iv | tag | ciphertext). Returns '' for empty input so callers
63
+ * can distinguish "no secret" from "a secret that decrypts to empty".
64
+ */
65
+ export function encryptSecret(keyPath, plain) {
66
+ if (!plain) return '';
67
+ const key = loadMasterKey(keyPath);
68
+ const iv = randomBytes(IV_BYTES);
69
+ const cipher = createCipheriv('aes-256-gcm', key, iv);
70
+ const ct = Buffer.concat([cipher.update(String(plain), 'utf-8'), cipher.final()]);
71
+ const tag = cipher.getAuthTag();
72
+ return Buffer.concat([iv, tag, ct]).toString('base64');
73
+ }
74
+
75
+ /**
76
+ * Decrypt a value produced by encryptSecret. Returns '' for empty input. Throws on malformed
77
+ * input or auth-tag mismatch (tampered / wrong key) — callers decide whether to fall back to a
78
+ * legacy encoding or surface the error.
79
+ */
80
+ export function decryptSecret(keyPath, stored) {
81
+ if (!stored) return '';
82
+ const key = loadMasterKey(keyPath);
83
+ const buf = Buffer.from(String(stored), 'base64');
84
+ if (buf.length < IV_BYTES + TAG_BYTES) {
85
+ throw new Error('credential-vault: ciphertext too short');
86
+ }
87
+ const iv = buf.subarray(0, IV_BYTES);
88
+ const tag = buf.subarray(IV_BYTES, IV_BYTES + TAG_BYTES);
89
+ const ct = buf.subarray(IV_BYTES + TAG_BYTES);
90
+ const decipher = createDecipheriv('aes-256-gcm', key, iv);
91
+ decipher.setAuthTag(tag);
92
+ return Buffer.concat([decipher.update(ct), decipher.final()]).toString('utf-8');
93
+ }
94
+
95
+ /**
96
+ * Heuristic: does `stored` look like vault ciphertext (vs a legacy plaintext/base64 value)?
97
+ * Used by the one-time migration to detect already-migrated values. Not a security boundary —
98
+ * just a shape check (base64 that decodes to ≥ IV+TAG bytes).
99
+ */
100
+ export function looksEncrypted(stored) {
101
+ if (!stored || typeof stored !== 'string') return false;
102
+ if (!/^[A-Za-z0-9+/=]+$/.test(stored)) return false;
103
+ let buf;
104
+ try { buf = Buffer.from(stored, 'base64'); } catch { return false; }
105
+ return buf.length >= IV_BYTES + TAG_BYTES + 1;
106
+ }
107
+
108
+ /** Test hook: drop the cached key for `keyPath` (or all) so a fresh one is (re)generated. */
109
+ export function _resetKeyCache(keyPath) {
110
+ if (keyPath === undefined) _keyCache.clear();
111
+ else _keyCache.delete(keyPath);
112
+ }
@@ -15,6 +15,7 @@ import { resolve, basename, sep, join } from 'node:path';
15
15
  import { homedir, platform, tmpdir } from 'node:os';
16
16
  import { getClaudeConfigDir, onLogDirChange } from '../../findcc.js';
17
17
  import { loadWorkspaces } from '../workspace-registry.js';
18
+ import { getBackupRoot } from './config-backup.js';
18
19
 
19
20
  const osPlatform = platform();
20
21
  const isWin = osPlatform === 'win32';
@@ -91,6 +92,10 @@ const SENSITIVE_CLAUDE_FILES = new Set([
91
92
  '.credentials.json',
92
93
  'settings.json',
93
94
  'settings.local.json',
95
+ // cc-viewer 凭证 vault(credentials.json 密文)与主密钥(master.key):即使 ~/.claude 在 allowlist,
96
+ // 也绝不跨网读(master.key 同时被下方 .key 文件名规则覆盖,这里双保险)。
97
+ 'credentials.json',
98
+ 'master.key',
94
99
  ]);
95
100
 
96
101
  // ─── allowlist roots 缓存 ──────────────────────────────────────────────────────
@@ -205,6 +210,17 @@ export function isReadAllowed(absPath) {
205
210
  }
206
211
  }
207
212
 
213
+ // 2b) Deny the cc-viewer config-backup dir tree — every rolling backup holds credentials.json
214
+ // ciphertext + master.key, which together are the full decryption kit. The dir defaults to
215
+ // ~/.claude/cc-viewer-config-backups/ (a sibling of LOG_DIR), but it must be denied whenever it
216
+ // lands inside an allowlist root, regardless of whether LOG_DIR was moved.
217
+ try {
218
+ const backupReal = realpathSync(getBackupRoot());
219
+ if (isInsideRoot(real, backupReal)) {
220
+ return { ok: false, reason: 'sensitive-config-backup' };
221
+ }
222
+ } catch { /* backup root missing / not realpath-able → no extra deny */ }
223
+
208
224
  // 3) 项目内文件豁免 sensitive 文件名(允许 fixtures/test-cert.pem 等合法 fixture)
209
225
  const projectRoot = getProjectRoot();
210
226
  const isInProj = isInsideRoot(real, projectRoot);
@@ -71,8 +71,12 @@ export function countUntrackedLines(cwd, file) {
71
71
  * them. Returns an empty list when HEAD cannot be resolved (unborn branch) or
72
72
  * the log command fails.
73
73
  *
74
- * Each commit includes its changed files via a single `git log --name-status` call,
75
- * to avoid one git invocation per commit.
74
+ * Each commit includes its changed files (with real A/M/D/R status) plus per-commit
75
+ * insertions/deletions. A single `git log --numstat` would give stats but no status,
76
+ * and rename lines would carry a pseudo-path (`src/{a.js => b.js}`), so the file list
77
+ * still comes from one `git log --name-status` call and the stats from a second
78
+ * `git log --numstat` call, joined by commit hash. Passing `--no-renames` to both
79
+ * keeps rename entries as plain A + D real paths instead.
76
80
  *
77
81
  * @param {string} cwd
78
82
  * @param {object} [opts]
@@ -108,33 +112,53 @@ export async function getUnpushedCommits(cwd, { maxCommits = 100 } = {}) {
108
112
  const hasUpstream = !!upstream;
109
113
  const resultBranch = detached ? null : branch;
110
114
 
111
- // Use NUL separators between fields and a sentinel between commits to avoid
115
+ // Use unit separators between fields and a sentinel between commits to avoid
112
116
  // getting fooled by tabs/newlines inside commit subjects.
113
117
  // Format: <hash>\x1f<author>\x1f<date>\x1f<subject>\n
114
- // Followed by one `<status>\t<path>` line per file (from --name-status).
115
- // Commits separated by `\x1e` (record separator).
118
+ // name-status pass: one `<status>\t<path>` line per file (R###\told\tnew for
119
+ // renames). numstat pass: one `<added>\t<removed>\t<path>` line per file
120
+ // (binary files report `-\t-\t<path>`). Commits separated by `\x1e`.
121
+ // `--no-renames` turns renames into plain A + D entries so every path is real.
116
122
  const COMMIT_SEP = '\x1e';
117
123
  const FIELD_SEP = '\x1f';
118
- let stdout = '';
124
+ const prettyArg = `--pretty=format:${COMMIT_SEP}%H${FIELD_SEP}%an${FIELD_SEP}%aI${FIELD_SEP}%s`;
125
+ const logArgs = ['log', `--max-count=${maxCommits}`, '--no-renames'];
126
+ const execOpts = { cwd, encoding: 'utf-8', timeout: 8000, maxBuffer: 10 * 1024 * 1024 };
127
+ let nameStatusOut = '';
128
+ let numstatOut = '';
119
129
  try {
120
- const r = await execFileAsync(
121
- 'git',
122
- [
123
- 'log',
124
- `--max-count=${maxCommits}`,
125
- `--pretty=format:${COMMIT_SEP}%H${FIELD_SEP}%an${FIELD_SEP}%aI${FIELD_SEP}%s`,
126
- '--name-status',
127
- ...rangeArgs,
128
- ],
129
- { cwd, encoding: 'utf-8', timeout: 8000, maxBuffer: 10 * 1024 * 1024 }
130
- );
131
- stdout = r.stdout;
130
+ const r = await execFileAsync('git', [...logArgs, prettyArg, '--name-status', ...rangeArgs], execOpts);
131
+ nameStatusOut = r.stdout;
132
132
  } catch {
133
133
  return { commits: [], hasUpstream, branch: resultBranch, upstream };
134
134
  }
135
+ try {
136
+ const r = await execFileAsync('git', [...logArgs, prettyArg, '--numstat', ...rangeArgs], execOpts);
137
+ numstatOut = r.stdout;
138
+ } catch {
139
+ numstatOut = ''; // stats are best-effort — keep the file list even if numstat fails
140
+ }
141
+
142
+ // Sum per-commit line stats from the numstat pass, keyed by full hash.
143
+ const statsByHash = new Map();
144
+ for (const block of numstatOut.split(COMMIT_SEP)) {
145
+ if (!block) continue;
146
+ const lines = block.split(/\r?\n/);
147
+ const parts = (lines[0] || '').split(FIELD_SEP);
148
+ if (parts.length < 4) continue;
149
+ let insertions = 0;
150
+ let deletions = 0;
151
+ for (let i = 1; i < lines.length; i++) {
152
+ const cols = lines[i].split('\t');
153
+ if (cols.length < 3) continue;
154
+ insertions += parseInt(cols[0], 10) || 0; // binary '-' → NaN → 0
155
+ deletions += parseInt(cols[1], 10) || 0;
156
+ }
157
+ statsByHash.set(parts[0], { insertions, deletions });
158
+ }
135
159
 
136
160
  const commits = [];
137
- const blocks = stdout.split(COMMIT_SEP).filter(Boolean);
161
+ const blocks = nameStatusOut.split(COMMIT_SEP).filter(Boolean);
138
162
  for (const block of blocks) {
139
163
  // git on Windows 在 piped 模式输出 CRLF;split('\n') 会让 fp 末尾带 \r,前端文件名乱码。
140
164
  const lines = block.split(/\r?\n/);
@@ -149,10 +173,13 @@ export async function getUnpushedCommits(cwd, { maxCommits = 100 } = {}) {
149
173
  const tab = line.indexOf('\t');
150
174
  if (tab < 0) continue;
151
175
  const st = line.substring(0, tab).trim();
152
- const fp = line.substring(tab + 1);
176
+ // name-status rename rows carry two paths (old\tnew); with --no-renames
177
+ // they should not appear, but keep the new path if one ever slips through.
178
+ const fp = line.substring(tab + 1).split('\t').pop();
153
179
  if (!fp) continue;
154
180
  files.push({ status: st[0] || 'M', file: fp });
155
181
  }
182
+ const stats = statsByHash.get(hash) || { insertions: 0, deletions: 0 };
156
183
  commits.push({
157
184
  hash,
158
185
  shortHash: hash.substring(0, 7),
@@ -160,6 +187,8 @@ export async function getUnpushedCommits(cwd, { maxCommits = 100 } = {}) {
160
187
  date,
161
188
  subject,
162
189
  files,
190
+ insertions: stats.insertions,
191
+ deletions: stats.deletions,
163
192
  });
164
193
  }
165
194
 
@@ -11,8 +11,10 @@
11
11
  // (`secret`: appSecret) are both base64-encoded on disk so preferences.json never
12
12
  // shows them in literal plaintext. This is light obfuscation, NOT encryption. The
13
13
  // admin API masks secret fields entirely (→ hasSecret).
14
- import { existsSync, readFileSync, writeFileSync, mkdirSync, chmodSync } from 'node:fs';
15
- import { join, dirname } from 'node:path';
14
+ import { existsSync, readFileSync } from 'node:fs';
15
+ import { join } from 'node:path';
16
+ import { mutateJsonSync } from '../json-store.js';
17
+ import { readSecretOr, writeSecret } from '../credential-access.js';
16
18
  import { LOG_DIR } from '../../../findcc.js';
17
19
 
18
20
  const MIN_CHUNK = 500;
@@ -130,16 +132,10 @@ function readPrefs() {
130
132
  }
131
133
  }
132
134
 
133
- function writePrefs(prefs) {
134
- const p = getPrefsPath();
135
- const dir = dirname(p);
136
- if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 });
137
- writeFileSync(p, JSON.stringify(prefs, null, 2), { mode: 0o600 });
138
- // writeFileSync's mode only applies on creation; re-assert 0600 — the file now carries
139
- // the (base64) secrets.
140
- try { chmodSync(p, 0o600); } catch { /* best-effort; non-POSIX or race */ }
141
- }
142
-
135
+ // Credential fields (`cred`: appKey/appId/botId, LOW sensitivity) stay base64 in preferences.json.
136
+ // Secret fields (`secret`: appSecret/botToken) live in the encrypted credential vault
137
+ // (credentials.json, AES-256-GCM) — never in preferences.json. The legacy base64 secret fields
138
+ // are migrated out at startup and cleared. Secrets are keyed `<platform>.<field>`.
143
139
  export function encodeSecret(plain) {
144
140
  return plain ? Buffer.from(plain, 'utf-8').toString('base64') : '';
145
141
  }
@@ -148,6 +144,11 @@ export function decodeSecret(stored) {
148
144
  try { return Buffer.from(stored, 'base64').toString('utf-8'); } catch { return ''; }
149
145
  }
150
146
 
147
+ // Vault ref for an IM platform secret field. Exported so the one-time migration
148
+ // (credential-migrate.js) derives refs from the SAME source as the runtime readers — a divergence
149
+ // would silently orphan every migrated secret.
150
+ export function secretRef(id, fieldKey) { return `${id}.${fieldKey}`; }
151
+
151
152
  function clampChunk(n, dflt = DEFAULT_CHUNK) {
152
153
  const v = Number(n);
153
154
  if (!Number.isFinite(v)) return dflt; // missing/invalid → the field's default (per-platform, e.g. Discord 1900)
@@ -180,13 +181,19 @@ function normField(type, v, dflt) {
180
181
  }
181
182
  }
182
183
 
183
- function decodeField(type, v, dflt) {
184
- switch (type) {
185
- case 'cred':
186
- case 'secret': return decodeSecret(v);
187
- case 'bool': return v !== undefined && v !== null ? !!v : (dflt !== undefined ? !!dflt : false);
184
+ function decodeField(id, f, v) {
185
+ switch (f.type) {
186
+ case 'secret': {
187
+ // Vault-first, legacy base64 as the one-version read-fallback. readSecretOr distinguishes
188
+ // "unreadable" (lost key / tampered) from "absent"; for a bridge secret, unreadable and
189
+ // absent both degrade to "bridge won't start" (hasCreds false) — a down bot, never a leak.
190
+ const legacyPlain = decodeSecret(v);
191
+ return readSecretOr('im-secret', secretRef(id, f.key), legacyPlain).value;
192
+ }
193
+ case 'cred': return decodeSecret(v);
194
+ case 'bool': return v !== undefined && v !== null ? !!v : (f.default !== undefined ? !!f.default : false);
188
195
  case 'idlist': return normalizeIdList(v);
189
- case 'chunk': return clampChunk(v, dflt);
196
+ case 'chunk': return clampChunk(v, f.default);
190
197
  case 'region': return v === 'lark' ? 'lark' : 'feishu';
191
198
  default: return typeof v === 'string' ? v : '';
192
199
  }
@@ -204,15 +211,30 @@ export function normalize(id, cfg) {
204
211
  function decodeStored(id, stored) {
205
212
  const desc = DESCRIPTORS[id];
206
213
  const out = {};
207
- for (const f of desc.fields) out[f.key] = decodeField(f.type, stored ? stored[f.key] : undefined, f.default);
214
+ for (const f of desc.fields) {
215
+ // No on-disk entry for this field → its default. For a secret that ALSO means "don't
216
+ // consult the vault": a stray vault entry must not resurrect a secret for a platform the
217
+ // prefs say nothing about (e.g. after preferences.json was wiped while credentials.json
218
+ // survived). Only a field actually present on disk resolves through the vault.
219
+ if (!stored || typeof stored !== 'object' || !(f.key in stored)) {
220
+ out[f.key] = normField(f.type, undefined, f.default);
221
+ continue;
222
+ }
223
+ out[f.key] = decodeField(id, f, stored[f.key]);
224
+ }
208
225
  return out;
209
226
  }
210
227
 
211
- function encodeForDisk(id, n) {
228
+ // On-disk shape for preferences.json[platform]: cred fields stay base64; a secret field is
229
+ // CLEARED (empty string) when the vault write succeeded, or kept as base64 when it failed (so
230
+ // the secret is not lost). `cleared` maps fieldKey → bool per secret field.
231
+ function encodeForDisk(id, n, cleared = {}) {
212
232
  const desc = DESCRIPTORS[id];
213
233
  const out = {};
214
234
  for (const f of desc.fields) {
215
- out[f.key] = (f.type === 'cred' || f.type === 'secret') ? encodeSecret(n[f.key]) : n[f.key];
235
+ if (f.type === 'secret') out[f.key] = cleared[f.key] ? '' : encodeSecret(n[f.key]);
236
+ else if (f.type === 'cred') out[f.key] = encodeSecret(n[f.key]);
237
+ else out[f.key] = n[f.key];
216
238
  }
217
239
  return out;
218
240
  }
@@ -240,22 +262,50 @@ export function loadState(id) {
240
262
 
241
263
  /**
242
264
  * Persist a platform's config (read-merge-write, preserving all other prefs and other
243
- * platforms). If a secret field is empty AND a secret is already stored, the existing
244
- * secret is PRESERVED (lets the admin edit other fields without re-typing the secret).
245
- * To remove the secret, disable the bridge. Stored base64; returns the in-memory
246
- * (plaintext) normalized shape.
265
+ * platforms). Secret fields are written to the credential vault; if a secret field is empty
266
+ * AND a secret is already stored (in the vault), the existing secret is PRESERVED (lets the
267
+ * admin edit other fields without re-typing the secret). To remove the secret, disable the
268
+ * bridge. cred fields stay base64 in preferences.json. Returns the in-memory (plaintext)
269
+ * normalized shape.
247
270
  */
248
271
  export function saveConfig(id, cfg) {
249
272
  const desc = DESCRIPTORS[id];
250
- const prefs = readPrefs();
251
273
  const normalized = normalize(id, cfg);
274
+ // Resolve each secret field's EFFECTIVE plaintext BEFORE writing. An empty field means "keep
275
+ // the stored one". The stored value may live in the VAULT and/or (pre-migration) in the legacy
276
+ // base64 preferences field — try the vault first, then the legacy field, so an unreadable vault
277
+ // (lost key / corrupt) never resolves a still-present legacy secret to empty and wipes it.
278
+ const legacyPlain = {};
279
+ const vaultUnreadableNoFallback = new Set();
280
+ mutateJsonSync(getPrefsPath(), (prefs) => {
281
+ const storedCfg = prefs[desc.prefKey];
282
+ for (const f of desc.fields) {
283
+ if (f.type !== 'secret') continue;
284
+ const ref = secretRef(id, f.key);
285
+ legacyPlain[f.key] = decodeSecret(storedCfg && storedCfg[f.key]);
286
+ if (!normalized[f.key]) {
287
+ const { value, unreadable } = readSecretOr('im-secret', ref, legacyPlain[f.key]);
288
+ if (value) normalized[f.key] = value;
289
+ else if (legacyPlain[f.key]) normalized[f.key] = legacyPlain[f.key];
290
+ else if (unreadable) vaultUnreadableNoFallback.add(f.key); // vault unreadable & nothing on disk
291
+ }
292
+ }
293
+ }, { mode: 0o600 });
294
+ // Write each resolved secret to the vault. A field whose vault write failed, or whose vault is
295
+ // unreadable with no legacy fallback, KEEPS its legacy base64 in preferences.json (secret not
296
+ // lost); only a field safely written to the vault is cleared from preferences.json.
297
+ const cleared = {};
252
298
  for (const f of desc.fields) {
253
- if (f.type === 'secret' && !normalized[f.key]) {
254
- const existing = decodeSecret(prefs[desc.prefKey] && prefs[desc.prefKey][f.key]);
255
- if (existing) normalized[f.key] = existing;
299
+ if (f.type !== 'secret') continue;
300
+ const ref = secretRef(id, f.key);
301
+ if (vaultUnreadableNoFallback.has(f.key)) {
302
+ cleared[f.key] = false; // vault unreadable & no legacy copy → keep whatever is on disk
303
+ } else {
304
+ cleared[f.key] = normalized[f.key] ? writeSecret('im-secret', ref, normalized[f.key]) : true;
256
305
  }
257
306
  }
258
- prefs[desc.prefKey] = encodeForDisk(id, normalized);
259
- writePrefs(prefs);
307
+ mutateJsonSync(getPrefsPath(), (prefs) => {
308
+ prefs[desc.prefKey] = encodeForDisk(id, normalized, cleared);
309
+ }, { mode: 0o600 });
260
310
  return normalized;
261
311
  }
@@ -8,16 +8,25 @@
8
8
  // 注意:worker 的工作目录在 ~/.claude/cc-viewer/IM_<id>/ 下,因此**不能**整体封禁 ~/.claude,
9
9
  // 只精确保护其中的全局 settings/hooks 与 preferences.json(IM 密钥),其余留给 worker 正常读写。
10
10
  import os from 'node:os';
11
- import { resolve } from 'node:path';
11
+ import { resolve, basename } from 'node:path';
12
12
 
13
13
  // 凭证目录:读 + 写都拒(含密钥/令牌)。
14
- const CRED_DIRS = ['.ssh', '.aws', '.gnupg', '.kube', '.docker', '.config/gcloud'];
14
+ const CRED_DIRS = ['.ssh', '.aws', '.gnupg', '.kube', '.docker', '.config/gcloud', '.claude/cc-viewer-config-backups'];
15
15
  // 家目录下的 shell 启动文件:写拒(被改写可植入持久化)。
16
16
  const WRITE_HOME_FILES = ['.bashrc', '.zshrc', '.bash_profile', '.zprofile', '.zshenv', '.profile', '.npmrc', '.netrc'];
17
- // 精确文件:写拒(保护 deny 机制本身与 IM 密钥)。相对家目录。
18
- const WRITE_REL_PATHS = ['.claude/settings.json', '.claude/settings.local.json', '.claude/cc-viewer/preferences.json'];
19
- // 精确文件:读拒(含令牌/密钥)。相对家目录。
20
- const READ_REL_PATHS = ['.npmrc', '.netrc', '.claude/cc-viewer/preferences.json'];
17
+ // Global settings/hooks: write-deny (protects the deny mechanism itself). Resolved against ~.
18
+ const WRITE_REL_PATHS = ['.claude/settings.json', '.claude/settings.local.json', '.claude/cc-viewer/preferences.json', '.claude/cc-viewer/credentials.json', '.claude/cc-viewer/master.key'];
19
+ // Token/secret files under home: read-deny. Resolved against ~.
20
+ const READ_REL_PATHS = ['.npmrc', '.netrc', '.claude/cc-viewer/preferences.json', '.claude/cc-viewer/credentials.json', '.claude/cc-viewer/master.key'];
21
+
22
+ // Vault file names (bare-name match, any directory): LOG_DIR can be moved by --log-dir /
23
+ // POST /api/preferences {logDir}, so hardcoding ~/.claude/cc-viewer would lose the vault whenever
24
+ // the root moves. Match by file name so it stays denied regardless of root. Case-insensitive and
25
+ // EXACT (does NOT match .bak/.old/.txt suffix variants — those are covered by the Bash-layer /i
26
+ // regex and the backup-dir rule); basename grabs the last segment across POSIX/win32 (pathOf's
27
+ // resolve would make lastIndexOf('/') miss on win32 backslashes).
28
+ const VAULT_FILE_RE = /^(credentials\.json|master\.key)$/i;
29
+ function isVaultFile(abs) { return VAULT_FILE_RE.test(basename(abs)); }
21
30
 
22
31
  // Bash 命令硬拦截规则。每条 { re, reason }。
23
32
  const BASH_DENY_RULES = [
@@ -43,8 +52,11 @@ const BASH_DENY_RULES = [
43
52
  { re: /\b(curl|wget)\b[^\n]*\s-{1,2}(d|data|data-binary|data-raw|post-file|F|form|T|upload-file)\b/i, reason: 'outbound data upload (exfil risk)' },
44
53
  { re: /\b(curl|wget)\b[^\n]*@\//i, reason: 'outbound file upload (exfil risk)' },
45
54
  // 凭证 / 密钥文件访问(Bash 层;与下面 Read/Write 路径层互为补充——cat 等会绕过路径层)。
46
- // 覆盖 SSH/AWS/GnuPG/k8s/docker/gcloud/gh/npm/netrc + cc-viewer 自身的 IM 密钥库 preferences.json + 全局 settings。
47
- { re: /(id_rsa|id_ed25519|id_ecdsa|authorized_keys|\.ssh\/|\.aws\/|\.gnupg\/|\.kube\/|\.docker\/|\.config\/(gcloud|gh)\/|\.netrc|\.npmrc|cc-viewer\/preferences\.json|\.claude\/settings(\.local)?\.json)\b/i, reason: 'access to credential / secret files' },
55
+ // 覆盖 SSH/AWS/GnuPG/k8s/docker/gcloud/gh/npm/netrc + cc-viewer 自身的 IM 密钥库 preferences.json
56
+ // + 凭证 vault(credentials.json/master.key,含 cc-viewer-config-backups 备份目录) + 全局 settings。
57
+ // credentials.json/master.key 用裸文件名匹配(不限 cc-viewer/ 前缀),并含 cred*/master* glob 形式,
58
+ // 防止经备份目录、相对路径或 shell glob(cred*.json master*)绕过——IM 通道宁可拦多。
59
+ { re: /(id_rsa|id_ed25519|id_ecdsa|authorized_keys|\.ssh\/|\.aws\/|\.gnupg\/|\.kube\/|\.docker\/|\.config\/(gcloud|gh)\/|\.netrc|\.npmrc|cc-viewer\/preferences\.json|credentials\.json|master\.key|cc-viewer-config-backups|\.claude\/settings(\.local)?\.json)\b|(cred\*|master\*)/i, reason: 'access to credential / secret files' },
48
60
  ];
49
61
 
50
62
  function underAny(absPath, roots) {
@@ -82,6 +94,7 @@ export function evaluateImDeny(toolName, toolInput = {}, opts = {}) {
82
94
  if (toolName === 'Read') {
83
95
  const abs = pathOf(toolInput, home);
84
96
  if (!abs) return { deny: false };
97
+ if (isVaultFile(abs)) return { deny: true, reason: 'read of the credential vault / master key' };
85
98
  if (underAny(abs, credRoots)) return { deny: true, reason: 'read of a credential directory' };
86
99
  if (READ_REL_PATHS.some((rel) => abs === resolve(home, rel))) return { deny: true, reason: 'read of a secret/credential file' };
87
100
  return { deny: false };
@@ -90,6 +103,7 @@ export function evaluateImDeny(toolName, toolInput = {}, opts = {}) {
90
103
  if (toolName === 'Edit' || toolName === 'Write' || toolName === 'NotebookEdit') {
91
104
  const abs = pathOf(toolInput, home);
92
105
  if (!abs) return { deny: false };
106
+ if (isVaultFile(abs)) return { deny: true, reason: 'write to the credential vault / master key' };
93
107
  if (underAny(abs, credRoots)) return { deny: true, reason: 'write to a credential directory' };
94
108
  if (WRITE_HOME_FILES.some((f) => abs === resolve(home, f))) return { deny: true, reason: 'write to a shell startup / credential file' };
95
109
  if (WRITE_REL_PATHS.some((rel) => abs === resolve(home, rel))) return { deny: true, reason: 'write to protected global config (settings/hooks or IM secrets)' };