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.
- package/README.md +3 -1
- package/cli.js +33 -4
- package/concepts/ar/UltraPlan.md +1 -1
- package/concepts/da/UltraPlan.md +1 -1
- package/concepts/de/UltraPlan.md +1 -1
- package/concepts/en/UltraPlan.md +1 -1
- package/concepts/es/UltraPlan.md +1 -1
- package/concepts/fr/UltraPlan.md +1 -1
- package/concepts/it/UltraPlan.md +1 -1
- package/concepts/ja/UltraPlan.md +1 -1
- package/concepts/ko/UltraPlan.md +1 -1
- package/concepts/no/UltraPlan.md +1 -1
- package/concepts/pl/UltraPlan.md +1 -1
- package/concepts/pt-BR/UltraPlan.md +1 -1
- package/concepts/ru/UltraPlan.md +1 -1
- package/concepts/th/UltraPlan.md +1 -1
- package/concepts/tr/UltraPlan.md +1 -1
- package/concepts/uk/UltraPlan.md +1 -1
- package/concepts/zh/UltraPlan.md +1 -1
- package/concepts/zh-TW/UltraPlan.md +1 -1
- package/dist/assets/App-DM-fbhNd.js +2 -0
- package/dist/assets/App-DOZI5HME.css +1 -0
- package/dist/assets/{MdxEditorPanel-Dpn-4ydO.css → MdxEditorPanel-DTfdj6GS.css} +1 -1
- package/dist/assets/{MdxEditorPanel-3j8rS3IT.js → MdxEditorPanel-DjwOQDh0.js} +1 -1
- package/dist/assets/Mobile-BD-hefIH.js +1 -0
- package/dist/assets/ProxyStatsModal-pYa_0QVL.js +1 -0
- package/dist/assets/{_baseUniq-B3e4JaL0.js → _baseUniq-CwQykUuc.js} +1 -1
- package/dist/assets/{arc-DnoPn_qY.js → arc-EdUdBxtG.js} +1 -1
- package/dist/assets/{architectureDiagram-Q4EWVU46-DrPKTbxH.js → architectureDiagram-Q4EWVU46-DtBIdElY.js} +1 -1
- package/dist/assets/{blockDiagram-DXYQGD6D-CEaO0Vcw.js → blockDiagram-DXYQGD6D-guNZIKkZ.js} +1 -1
- package/dist/assets/{c4Diagram-AHTNJAMY-BLaawFeg.js → c4Diagram-AHTNJAMY-xV5vB-Z9.js} +1 -1
- package/dist/assets/{channel-DnYR1fF4.js → channel-DYEktNap.js} +1 -1
- package/dist/assets/{chunk-4BX2VUAB-Bfd4edKZ.js → chunk-4BX2VUAB-ZTkItpvj.js} +1 -1
- package/dist/assets/{chunk-4TB4RGXK-t2nMQmY7.js → chunk-4TB4RGXK-dYIewyyR.js} +1 -1
- package/dist/assets/{chunk-55IACEB6-C6tLOjGX.js → chunk-55IACEB6-CSsPNgPX.js} +1 -1
- package/dist/assets/{chunk-EDXVE4YY-dWCvxbCb.js → chunk-EDXVE4YY-CnMiuqzQ.js} +1 -1
- package/dist/assets/{chunk-FMBD7UC4-BHELZLPK.js → chunk-FMBD7UC4-CVpATPZ3.js} +1 -1
- package/dist/assets/{chunk-OYMX7WX6-C8XTkH1S.js → chunk-OYMX7WX6-Dpq4s8dW.js} +1 -1
- package/dist/assets/{chunk-QZHKN3VN-CD0j6SAs.js → chunk-QZHKN3VN-Bnojh6X6.js} +1 -1
- package/dist/assets/{chunk-YZCP3GAM-D4dKfXJJ.js → chunk-YZCP3GAM-BYqK4Dsa.js} +1 -1
- package/dist/assets/classDiagram-6PBFFD2Q-ClGF33bF.js +1 -0
- package/dist/assets/classDiagram-v2-HSJHXN6E-ClGF33bF.js +1 -0
- package/dist/assets/clone-CFj55h49.js +1 -0
- package/dist/assets/{cose-bilkent-S5V4N54A-BF2HsiSD.js → cose-bilkent-S5V4N54A-OJ7mmWyV.js} +1 -1
- package/dist/assets/{dagre-KV5264BT-f3h967yx.js → dagre-KV5264BT-B1Kwd_Rk.js} +1 -1
- package/dist/assets/{diagram-5BDNPKRD-B9PzHQfh.js → diagram-5BDNPKRD-D8qrw8sp.js} +1 -1
- package/dist/assets/{diagram-G4DWMVQ6-kR953Bqm.js → diagram-G4DWMVQ6-ip-N4ho8.js} +1 -1
- package/dist/assets/{diagram-MMDJMWI5-DAewa2eg.js → diagram-MMDJMWI5-2PZaskUF.js} +1 -1
- package/dist/assets/{diagram-TYMM5635-C8HNTH6I.js → diagram-TYMM5635-CeAOQUZP.js} +1 -1
- package/dist/assets/{erDiagram-SMLLAGMA-BQ5P8wWK.js → erDiagram-SMLLAGMA-BCBaEYEb.js} +1 -1
- package/dist/assets/{flowDiagram-DWJPFMVM-BIpEkrMR.js → flowDiagram-DWJPFMVM-CsGyIqcX.js} +1 -1
- package/dist/assets/{ganttDiagram-T4ZO3ILL-CWqSfB5v.js → ganttDiagram-T4ZO3ILL-COsj3FZ-.js} +1 -1
- package/dist/assets/{gitGraphDiagram-UUTBAWPF-Ba-9yEbQ.js → gitGraphDiagram-UUTBAWPF-DvQVQGJG.js} +1 -1
- package/dist/assets/{graph-D3zXKQ6P.js → graph-BSB9fDSP.js} +1 -1
- package/dist/assets/{index-G3xd-gLo.js → index-BRw4Iz2O.js} +1 -1
- package/dist/assets/{index-DClD_EkN.js → index-Bo-12bug.js} +1 -1
- package/dist/assets/{index-DSd5P3WQ.js → index-BuVComcY.js} +1 -1
- package/dist/assets/{index-HLrnYsI1.js → index-CNNAYjkF.js} +1 -1
- package/dist/assets/{index-Bm3A5VLb.js → index-DKufsAzx.js} +1 -1
- package/dist/assets/{index-C9N0rZ7Z.js → index-DXBfwPYD.js} +1 -1
- package/dist/assets/index-DkIhbLjr.js +2 -0
- package/dist/assets/{index-Nvk8r3tO.css → index-Dz36p_bA.css} +1 -1
- package/dist/assets/{index-Q8wp4Nof.js → index-eM2ih5n7.js} +1 -1
- package/dist/assets/{infoDiagram-42DDH7IO-Cxm79ZJR.js → infoDiagram-42DDH7IO-eNucS4iW.js} +1 -1
- package/dist/assets/{ishikawaDiagram-UXIWVN3A-Cd0vZP1B.js → ishikawaDiagram-UXIWVN3A-Dxxj-gUX.js} +1 -1
- package/dist/assets/{journeyDiagram-VCZTEJTY-B2b57HXk.js → journeyDiagram-VCZTEJTY-yJJzVEBb.js} +1 -1
- package/dist/assets/{jszip.min-Bj5uiEb2.js → jszip.min-ByI0ds5u.js} +1 -1
- package/dist/assets/{kanban-definition-6JOO6SKY-YCERiUCO.js → kanban-definition-6JOO6SKY-2rfTi2zw.js} +1 -1
- package/dist/assets/{layout-Do5TlO2U.js → layout-DTmHj0Wm.js} +1 -1
- package/dist/assets/{linear-BiJNTDgN.js → linear-BOA3a9FE.js} +1 -1
- package/dist/assets/{mermaid.core-Dg0McRZt.js → mermaid.core-5H-NR79H.js} +3 -3
- package/dist/assets/{min-T51iefht.js → min-CpjFTEec.js} +1 -1
- package/dist/assets/{mindmap-definition-QFDTVHPH-Cu_GJxY3.js → mindmap-definition-QFDTVHPH-B8KCjFgB.js} +1 -1
- package/dist/assets/{pieDiagram-DEJITSTG-DldHHZXi.js → pieDiagram-DEJITSTG-BARy96g5.js} +1 -1
- package/dist/assets/{quadrantDiagram-34T5L4WZ-BzyTPNAJ.js → quadrantDiagram-34T5L4WZ-DYoTapuI.js} +1 -1
- package/dist/assets/{requirementDiagram-MS252O5E-C5xxFkvG.js → requirementDiagram-MS252O5E-EeANLLK2.js} +1 -1
- package/dist/assets/{sankeyDiagram-XADWPNL6-OxdjoaE1.js → sankeyDiagram-XADWPNL6-DmQ-YVPj.js} +1 -1
- package/dist/assets/seqResourceLoaders-BdWMUSrC.css +41 -0
- package/dist/assets/seqResourceLoaders-Dzy1J6G9.js +2 -0
- package/dist/assets/{sequenceDiagram-FGHM5R23-C7c89uIS.js → sequenceDiagram-FGHM5R23-SZtW0rYC.js} +1 -1
- package/dist/assets/{stateDiagram-FHFEXIEX-ssdJnDrO.js → stateDiagram-FHFEXIEX-CEygsIld.js} +1 -1
- package/dist/assets/{stateDiagram-v2-QKLJ7IA2-BP-0WClK.js → stateDiagram-v2-QKLJ7IA2-BU1XPhBe.js} +1 -1
- package/dist/assets/{timeline-definition-GMOUNBTQ-sWScEpSM.js → timeline-definition-GMOUNBTQ-DhPBaQBt.js} +1 -1
- package/dist/assets/{vendor-antd-DKIytXho.js → vendor-antd-CS_S2mBN.js} +1 -1
- package/dist/assets/{vendor-codemirror-4qgXq03k.js → vendor-codemirror-B5l-VbBO.js} +1 -1
- package/dist/assets/{vendor-mdxeditor-D5Z5D9fq.js → vendor-mdxeditor-Cz09a1_I.js} +2 -2
- package/dist/assets/{vendor-qrcode-B7lb5qk1.js → vendor-qrcode-qbbntD6-.js} +1 -1
- package/dist/assets/{vendor-virtuoso-BqoXlbhH.js → vendor-virtuoso-Sk8fQ4Hx.js} +1 -1
- package/dist/assets/{vennDiagram-DHZGUBPP-DNfXR0ge.js → vennDiagram-DHZGUBPP-NniTHxDw.js} +1 -1
- package/dist/assets/{wardley-RL74JXVD-JXd--CRD.js → wardley-RL74JXVD-D9SNOrDw.js} +1 -1
- package/dist/assets/{wardleyDiagram-NUSXRM2D-DHmNyVPM.js → wardleyDiagram-NUSXRM2D-DvHXKNza.js} +1 -1
- package/dist/assets/{xychartDiagram-5P7HB3ND-CTb0gltZ.js → xychartDiagram-5P7HB3ND-CaiwPTQB.js} +1 -1
- package/dist/index.html +5 -5
- package/package.json +1 -1
- package/server/interceptor.js +192 -24
- package/server/lib/ask/ask-store.js +7 -3
- package/server/lib/async-file-lock.js +29 -5
- package/server/lib/auth.js +71 -54
- package/server/lib/chat-queue.js +67 -4
- package/server/lib/config-backup.js +8 -5
- package/server/lib/credential-access.js +233 -0
- package/server/lib/credential-migrate.js +147 -0
- package/server/lib/credential-store.js +120 -0
- package/server/lib/credential-vault.js +112 -0
- package/server/lib/file-access-policy.js +16 -0
- package/server/lib/git-diff.js +49 -20
- package/server/lib/im/im-config.js +81 -31
- package/server/lib/im-deny.js +22 -8
- package/server/lib/json-store.js +264 -0
- package/server/lib/log-watcher.js +9 -1
- package/server/lib/prefs-store.js +8 -33
- package/server/lib/same-origin.js +22 -0
- package/server/lib/session-pin-store.js +7 -14
- package/server/lib/v2/convert.js +1 -1
- package/server/lib/v2/layout.js +28 -3
- package/server/lib/v2/live-feed.js +41 -4
- package/server/lib/v2/resume-list.js +150 -0
- package/server/lib/v2/session-select.js +63 -14
- package/server/lib/v2/v2-writer.js +172 -34
- package/server/lib/v2/view-router.js +83 -0
- package/server/lib/view-root.js +209 -0
- package/server/pty-manager.js +800 -163
- package/server/routes/ask-perm.js +21 -1
- package/server/routes/auth.js +21 -1
- package/server/routes/events.js +123 -7
- package/server/routes/expert.js +46 -14
- package/server/routes/files-content.js +72 -7
- package/server/routes/files-fs.js +28 -3
- package/server/routes/git.js +31 -5
- package/server/routes/im.js +15 -2
- package/server/routes/preferences.js +59 -46
- package/server/routes/project-meta.js +32 -3
- package/server/routes/proxy-stats.js +33 -5
- package/server/routes/resume.js +302 -0
- package/server/routes/search.js +23 -1
- package/server/routes/skills.js +18 -2
- package/server/routes/v2.js +6 -1
- package/server/routes/workspaces.js +10 -3
- package/server/server.js +199 -25
- package/server/system-prompt-templates/presets/GLM-5.2.md +0 -1
- package/server/system-prompt-templates/presets/GLM-5.3.md +0 -1
- package/server/system-prompt-templates/presets/Qwen-3.md +0 -1
- package/server/system-prompt-templates/presets/deepseek-v4-flash.md +0 -1
- package/server/system-prompt-templates/presets/deepseek-v4-pro.md +0 -1
- package/server/system-prompt-templates/presets/kimi-k2.7-code.md +0 -1
- package/server/system-prompt-templates/presets/kimi-k3.md +1 -1
- package/server/system-prompt-templates/systemPromptModel.md +0 -1
- package/server/workspace-registry.js +41 -35
- package/ultraAgents/code-expert.json +1 -1
- package/dist/assets/App-M61pqQEx.css +0 -1
- package/dist/assets/App-_HfxMJvv.js +0 -2
- package/dist/assets/Mobile-DaRLB7Gh.js +0 -1
- package/dist/assets/ProxyStatsModal-dlPIDlx-.js +0 -1
- package/dist/assets/classDiagram-6PBFFD2Q-BecPAAuk.js +0 -1
- package/dist/assets/classDiagram-v2-HSJHXN6E-BecPAAuk.js +0 -1
- package/dist/assets/clone-B0_ghF3F.js +0 -1
- package/dist/assets/index-CjbBUDGP.js +0 -2
- package/dist/assets/seqResourceLoaders-B_FSQh3U.css +0 -41
- 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);
|
package/server/lib/git-diff.js
CHANGED
|
@@ -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
|
|
75
|
-
*
|
|
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
|
|
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
|
-
//
|
|
115
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
15
|
-
import { join
|
|
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
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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(
|
|
184
|
-
switch (type) {
|
|
185
|
-
case '
|
|
186
|
-
|
|
187
|
-
|
|
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,
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
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).
|
|
244
|
-
* secret is
|
|
245
|
-
*
|
|
246
|
-
* (plaintext)
|
|
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
|
|
254
|
-
|
|
255
|
-
|
|
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
|
-
|
|
259
|
-
|
|
307
|
+
mutateJsonSync(getPrefsPath(), (prefs) => {
|
|
308
|
+
prefs[desc.prefKey] = encodeForDisk(id, normalized, cleared);
|
|
309
|
+
}, { mode: 0o600 });
|
|
260
310
|
return normalized;
|
|
261
311
|
}
|
package/server/lib/im-deny.js
CHANGED
|
@@ -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
|
-
//
|
|
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
|
|
47
|
-
|
|
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)' };
|