claude-flow 3.6.24 → 3.6.26
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 +8 -2
- package/package.json +1 -1
- package/v3/@claude-flow/cli/README.md +8 -2
- package/v3/@claude-flow/cli/bin/cli.js +21 -0
- package/v3/@claude-flow/cli/bin/mcp-server.js +16 -0
- package/v3/@claude-flow/cli/dist/src/commands/appliance.js +8 -10
- package/v3/@claude-flow/cli/dist/src/commands/doctor.js +90 -2
- package/v3/@claude-flow/cli/dist/src/commands/guidance.js +1 -5
- package/v3/@claude-flow/cli/dist/src/commands/performance.js +3 -3
- package/v3/@claude-flow/cli/dist/src/commands/process.js +6 -7
- package/v3/@claude-flow/cli/dist/src/commands/verify.js +24 -3
- package/v3/@claude-flow/cli/dist/src/encryption/vault.d.ts +94 -0
- package/v3/@claude-flow/cli/dist/src/encryption/vault.js +172 -0
- package/v3/@claude-flow/cli/dist/src/fs-secure.d.ts +67 -0
- package/v3/@claude-flow/cli/dist/src/fs-secure.js +74 -0
- package/v3/@claude-flow/cli/dist/src/mcp-tools/github-tools.js +122 -31
- package/v3/@claude-flow/cli/dist/src/mcp-tools/hooks-tools.js +2 -2
- package/v3/@claude-flow/cli/dist/src/mcp-tools/memory-tools.js +7 -12
- package/v3/@claude-flow/cli/dist/src/mcp-tools/session-tools.js +24 -12
- package/v3/@claude-flow/cli/dist/src/mcp-tools/terminal-tools.js +22 -7
- package/v3/@claude-flow/cli/dist/src/mcp-tools/validate-input.d.ts +12 -0
- package/v3/@claude-flow/cli/dist/src/mcp-tools/validate-input.js +56 -0
- package/v3/@claude-flow/cli/dist/src/memory/memory-initializer.js +17 -16
- package/v3/@claude-flow/cli/dist/src/transfer/ipfs/upload.js +2 -0
- package/v3/@claude-flow/cli/dist/src/update/executor.d.ts +1 -0
- package/v3/@claude-flow/cli/dist/src/update/executor.js +43 -7
- package/v3/@claude-flow/cli/package.json +1 -1
- package/.claude/scheduled_tasks.lock +0 -1
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Encryption-at-rest vault primitives (ADR-096 Phase 1).
|
|
3
|
+
*
|
|
4
|
+
* Goal: provide deterministic encrypt/decrypt of arbitrary Buffers with a
|
|
5
|
+
* symmetric key, using a magic-byte format so readers of older plaintext
|
|
6
|
+
* stores can detect-then-pass-through during the migration window.
|
|
7
|
+
*
|
|
8
|
+
* Phase 1 deliberately ships only the cipher primitives + the env-var key
|
|
9
|
+
* source. Keychain (keytar) and interactive passphrase resolution land in
|
|
10
|
+
* a follow-up iteration so the blast radius of this commit is limited to
|
|
11
|
+
* a single self-contained module with no native dependencies.
|
|
12
|
+
*
|
|
13
|
+
* Wire format (output of encryptBuffer):
|
|
14
|
+
*
|
|
15
|
+
* +---------+-----------+----------------+--------+
|
|
16
|
+
* | magic 4 | iv 12 | ciphertext N | tag 16 |
|
|
17
|
+
* +---------+-----------+----------------+--------+
|
|
18
|
+
* "RFE1" random AES-256-GCM GCM
|
|
19
|
+
*
|
|
20
|
+
* The magic distinguishes encrypted blobs from plaintext during the
|
|
21
|
+
* incremental migration: readers call isEncryptedBlob() and either
|
|
22
|
+
* decryptBuffer() or treat the bytes as plaintext, so existing
|
|
23
|
+
* .claude-flow/sessions/*.json files keep working unchanged.
|
|
24
|
+
*/
|
|
25
|
+
import { createCipheriv, createDecipheriv, randomBytes, timingSafeEqual, } from 'node:crypto';
|
|
26
|
+
// ── Constants ────────────────────────────────────────────────────────────────
|
|
27
|
+
/** ASCII "RFE1" — Ruflo File Encrypted v1. 4 bytes. */
|
|
28
|
+
export const MAGIC = Buffer.from([0x52, 0x46, 0x45, 0x31]); // "RFE1"
|
|
29
|
+
const MAGIC_LEN = MAGIC.length; // 4
|
|
30
|
+
const IV_LEN = 12; // GCM-recommended nonce size
|
|
31
|
+
const TAG_LEN = 16; // GCM auth tag
|
|
32
|
+
const KEY_LEN = 32; // AES-256
|
|
33
|
+
const ALG = 'aes-256-gcm';
|
|
34
|
+
const MIN_BLOB_LEN = MAGIC_LEN + IV_LEN + TAG_LEN; // empty plaintext still has these
|
|
35
|
+
const ENV_ENABLE_FLAG = 'CLAUDE_FLOW_ENCRYPT_AT_REST';
|
|
36
|
+
const ENV_KEY_VAR = 'CLAUDE_FLOW_ENCRYPTION_KEY';
|
|
37
|
+
// ── Public API ───────────────────────────────────────────────────────────────
|
|
38
|
+
/**
|
|
39
|
+
* True when at-rest encryption should be applied to writes.
|
|
40
|
+
*
|
|
41
|
+
* Truthy values: "1", "true", "yes", "on" (case-insensitive). Anything else
|
|
42
|
+
* — including unset — keeps the legacy plaintext path. This is the gate
|
|
43
|
+
* that lets the 1865-test baseline keep passing unchanged while users opt
|
|
44
|
+
* into encryption.
|
|
45
|
+
*/
|
|
46
|
+
export function isEncryptionEnabled() {
|
|
47
|
+
const v = process.env[ENV_ENABLE_FLAG];
|
|
48
|
+
if (typeof v !== 'string')
|
|
49
|
+
return false;
|
|
50
|
+
const norm = v.trim().toLowerCase();
|
|
51
|
+
return norm === '1' || norm === 'true' || norm === 'yes' || norm === 'on';
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Resolve a 32-byte encryption key from CLAUDE_FLOW_ENCRYPTION_KEY.
|
|
55
|
+
*
|
|
56
|
+
* Phase 1 supports only the env-var source; keychain and passphrase
|
|
57
|
+
* resolution are deferred to a follow-up iteration (see ADR-096). When
|
|
58
|
+
* encryption is enabled but no key resolves, this throws with a clear
|
|
59
|
+
* message rather than silently falling back to plaintext (fail-closed).
|
|
60
|
+
*
|
|
61
|
+
* Accepted encodings (auto-detected by length):
|
|
62
|
+
* - 64-char hex (32 bytes)
|
|
63
|
+
* - 44-char base64 (32 bytes + padding)
|
|
64
|
+
* - exactly 32 raw bytes (rare; for callers that pre-decode)
|
|
65
|
+
*
|
|
66
|
+
* Anything else is rejected — we'd rather fail loudly than encrypt with a
|
|
67
|
+
* truncated key.
|
|
68
|
+
*/
|
|
69
|
+
export function getKey() {
|
|
70
|
+
const raw = process.env[ENV_KEY_VAR];
|
|
71
|
+
if (!raw) {
|
|
72
|
+
throw new Error(`${ENV_ENABLE_FLAG} is set but ${ENV_KEY_VAR} is not. ` +
|
|
73
|
+
`Provide a 32-byte key as 64-char hex or 44-char base64. ` +
|
|
74
|
+
`See ADR-096 for keychain/passphrase support (coming in a follow-up).`);
|
|
75
|
+
}
|
|
76
|
+
return decodeKey(raw);
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Decode a key string. Exposed for testing and for the future passphrase
|
|
80
|
+
* resolver, which will scrypt-derive a Buffer and hand it back through here
|
|
81
|
+
* to share the same length-check.
|
|
82
|
+
*/
|
|
83
|
+
export function decodeKey(raw) {
|
|
84
|
+
const trimmed = raw.trim();
|
|
85
|
+
// Hex first — strict 64 chars [0-9a-fA-F]
|
|
86
|
+
if (/^[0-9a-fA-F]{64}$/.test(trimmed)) {
|
|
87
|
+
return Buffer.from(trimmed, 'hex');
|
|
88
|
+
}
|
|
89
|
+
// Base64 — accept padded 44-char or unpadded 43-char forms
|
|
90
|
+
if (/^[A-Za-z0-9+/]{43}=?$/.test(trimmed)) {
|
|
91
|
+
const buf = Buffer.from(trimmed, 'base64');
|
|
92
|
+
if (buf.length === KEY_LEN)
|
|
93
|
+
return buf;
|
|
94
|
+
}
|
|
95
|
+
throw new Error(`Invalid ${ENV_KEY_VAR}: expected 32-byte key as 64-char hex or 44-char base64`);
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Encrypt a plaintext Buffer with AES-256-GCM. Returns the wire-format
|
|
99
|
+
* blob: magic(4) || iv(12) || ciphertext(N) || tag(16).
|
|
100
|
+
*
|
|
101
|
+
* The IV is freshly randomized per call. Reusing a (key, iv) pair under
|
|
102
|
+
* GCM is catastrophic — every call MUST produce a different IV. Node's
|
|
103
|
+
* randomBytes is csprng-backed so this is automatic; the function takes
|
|
104
|
+
* no IV input deliberately.
|
|
105
|
+
*/
|
|
106
|
+
export function encryptBuffer(plaintext, key) {
|
|
107
|
+
if (!Buffer.isBuffer(plaintext)) {
|
|
108
|
+
throw new TypeError('encryptBuffer: plaintext must be a Buffer');
|
|
109
|
+
}
|
|
110
|
+
if (!Buffer.isBuffer(key) || key.length !== KEY_LEN) {
|
|
111
|
+
throw new TypeError(`encryptBuffer: key must be a ${KEY_LEN}-byte Buffer`);
|
|
112
|
+
}
|
|
113
|
+
const iv = randomBytes(IV_LEN);
|
|
114
|
+
const cipher = createCipheriv(ALG, key, iv);
|
|
115
|
+
const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]);
|
|
116
|
+
const tag = cipher.getAuthTag();
|
|
117
|
+
return Buffer.concat([MAGIC, iv, ciphertext, tag]);
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Decrypt a wire-format blob. Verifies the magic byte (sanity), parses
|
|
121
|
+
* iv + ciphertext + tag, runs AES-256-GCM decrypt, and lets the GCM
|
|
122
|
+
* auth tag fail loudly on tamper (Node throws "Unsupported state or
|
|
123
|
+
* unable to authenticate data" — we let that propagate).
|
|
124
|
+
*
|
|
125
|
+
* Pre-condition: caller has already determined this is an encrypted
|
|
126
|
+
* blob via isEncryptedBlob(). decryptBuffer throws on bad magic so a
|
|
127
|
+
* mistaken plaintext blob still fails loudly rather than producing
|
|
128
|
+
* garbage.
|
|
129
|
+
*/
|
|
130
|
+
export function decryptBuffer(blob, key) {
|
|
131
|
+
if (!Buffer.isBuffer(blob)) {
|
|
132
|
+
throw new TypeError('decryptBuffer: blob must be a Buffer');
|
|
133
|
+
}
|
|
134
|
+
if (!Buffer.isBuffer(key) || key.length !== KEY_LEN) {
|
|
135
|
+
throw new TypeError(`decryptBuffer: key must be a ${KEY_LEN}-byte Buffer`);
|
|
136
|
+
}
|
|
137
|
+
if (blob.length < MIN_BLOB_LEN) {
|
|
138
|
+
throw new Error(`decryptBuffer: blob too short (${blob.length}B; need >= ${MIN_BLOB_LEN}B)`);
|
|
139
|
+
}
|
|
140
|
+
const magic = blob.subarray(0, MAGIC_LEN);
|
|
141
|
+
// timingSafeEqual to avoid an oracle on the magic bytes specifically;
|
|
142
|
+
// not strictly required (the magic isn't secret) but cheap and correct.
|
|
143
|
+
if (!timingSafeEqual(magic, MAGIC)) {
|
|
144
|
+
throw new Error('decryptBuffer: bad magic — blob is not Ruflo-encrypted (RFE1)');
|
|
145
|
+
}
|
|
146
|
+
const iv = blob.subarray(MAGIC_LEN, MAGIC_LEN + IV_LEN);
|
|
147
|
+
const tag = blob.subarray(blob.length - TAG_LEN);
|
|
148
|
+
const ciphertext = blob.subarray(MAGIC_LEN + IV_LEN, blob.length - TAG_LEN);
|
|
149
|
+
const decipher = createDecipheriv(ALG, key, iv);
|
|
150
|
+
decipher.setAuthTag(tag);
|
|
151
|
+
return Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Magic-byte sniff. True iff the blob starts with the RFE1 magic AND is
|
|
155
|
+
* long enough to be a valid encrypted blob. Used by readers during the
|
|
156
|
+
* incremental migration: legacy plaintext files return false and flow
|
|
157
|
+
* through the existing read path unchanged.
|
|
158
|
+
*
|
|
159
|
+
* Note: this is a heuristic. A plaintext file that happens to start with
|
|
160
|
+
* "RFE1" would be misdetected — we accept that vanishingly small risk
|
|
161
|
+
* because (a) the four bytes 0x52,0x46,0x45,0x31 are an unusual prefix
|
|
162
|
+
* for JSON (`{`, `[`) or SQLite (`SQLite format 3`), and (b) decryption
|
|
163
|
+
* will then fail with a clear error rather than silently corrupt.
|
|
164
|
+
*/
|
|
165
|
+
export function isEncryptedBlob(blob) {
|
|
166
|
+
if (!Buffer.isBuffer(blob))
|
|
167
|
+
return false;
|
|
168
|
+
if (blob.length < MIN_BLOB_LEN)
|
|
169
|
+
return false;
|
|
170
|
+
return timingSafeEqual(blob.subarray(0, MAGIC_LEN), MAGIC);
|
|
171
|
+
}
|
|
172
|
+
//# sourceMappingURL=vault.js.map
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Restricted-permission file helpers.
|
|
3
|
+
*
|
|
4
|
+
* audit_1776853149979: session/memory/terminal stores were written with the
|
|
5
|
+
* process umask, which on most macOS/Linux setups leaves them world-readable
|
|
6
|
+
* (mode 0644). They contain conversation snapshots, agent prompts, and
|
|
7
|
+
* terminal command history — anyone else on the host can read them.
|
|
8
|
+
*
|
|
9
|
+
* These helpers write atomically and force mode 0600 (files) / 0700 (dirs).
|
|
10
|
+
* chmod fails silently on Windows, where POSIX modes don't apply — that's
|
|
11
|
+
* fine, the OS-level ACL surface there is different.
|
|
12
|
+
*
|
|
13
|
+
* ADR-096 Phase 2: optional opt-in encryption-at-rest. When the caller
|
|
14
|
+
* passes `encrypt: true` AND the env-gated vault is enabled, payloads are
|
|
15
|
+
* AES-256-GCM-encrypted before hitting disk. Reads use the magic-byte
|
|
16
|
+
* sniff so legacy plaintext files keep working unchanged during the
|
|
17
|
+
* incremental migration.
|
|
18
|
+
*/
|
|
19
|
+
/**
|
|
20
|
+
* Create a directory tree with mode 0700 (owner-only). No-op if exists.
|
|
21
|
+
* Uses recursive: true so missing parents are created with the same mode.
|
|
22
|
+
*/
|
|
23
|
+
export declare function mkdirRestricted(path: string): void;
|
|
24
|
+
/**
|
|
25
|
+
* Options for writeFileRestricted. Object form so we can grow the API
|
|
26
|
+
* without churning every call site.
|
|
27
|
+
*/
|
|
28
|
+
export interface WriteOptions {
|
|
29
|
+
/** Buffer encoding when `data` is a string. Ignored for Buffer payloads. */
|
|
30
|
+
encoding?: BufferEncoding;
|
|
31
|
+
/**
|
|
32
|
+
* If true AND encryption is globally enabled (CLAUDE_FLOW_ENCRYPT_AT_REST),
|
|
33
|
+
* encrypt the payload with AES-256-GCM before writing. If encryption is
|
|
34
|
+
* NOT enabled, this flag is silently ignored — the legacy plaintext path
|
|
35
|
+
* runs unchanged. Default: false.
|
|
36
|
+
*/
|
|
37
|
+
encrypt?: boolean;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Write a file and tighten its permissions to mode 0600 (owner read/write).
|
|
41
|
+
*
|
|
42
|
+
* Two call signatures, both supported (the legacy positional one keeps
|
|
43
|
+
* existing call sites working without churn):
|
|
44
|
+
*
|
|
45
|
+
* writeFileRestricted(path, data) // plaintext, utf-8
|
|
46
|
+
* writeFileRestricted(path, data, 'utf-8') // legacy: encoding only
|
|
47
|
+
* writeFileRestricted(path, data, { encrypt: true }) // opt-in encryption
|
|
48
|
+
*/
|
|
49
|
+
export declare function writeFileRestricted(path: string, data: string | Buffer, optsOrEncoding?: BufferEncoding | WriteOptions): void;
|
|
50
|
+
/**
|
|
51
|
+
* Read a file and transparently decrypt if it carries the RFE1 magic.
|
|
52
|
+
*
|
|
53
|
+
* Returns a string when the caller asks for one (default utf-8). Returns
|
|
54
|
+
* a Buffer when `encoding` is null. This matches Node's readFileSync
|
|
55
|
+
* shape so the function is a near-drop-in replacement.
|
|
56
|
+
*
|
|
57
|
+
* Migration semantics:
|
|
58
|
+
* - If the file IS encrypted, decrypt and return.
|
|
59
|
+
* - If the file is NOT encrypted, return its raw bytes (string-decoded
|
|
60
|
+
* under `encoding` if requested).
|
|
61
|
+
*
|
|
62
|
+
* That means a reader can be migrated *first*, before its writer flips
|
|
63
|
+
* `encrypt: true`, without breaking on the legacy plaintext path.
|
|
64
|
+
*/
|
|
65
|
+
export declare function readFileMaybeEncrypted(path: string, encoding?: BufferEncoding): string;
|
|
66
|
+
export declare function readFileMaybeEncrypted(path: string, encoding: null): Buffer;
|
|
67
|
+
//# sourceMappingURL=fs-secure.d.ts.map
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Restricted-permission file helpers.
|
|
3
|
+
*
|
|
4
|
+
* audit_1776853149979: session/memory/terminal stores were written with the
|
|
5
|
+
* process umask, which on most macOS/Linux setups leaves them world-readable
|
|
6
|
+
* (mode 0644). They contain conversation snapshots, agent prompts, and
|
|
7
|
+
* terminal command history — anyone else on the host can read them.
|
|
8
|
+
*
|
|
9
|
+
* These helpers write atomically and force mode 0600 (files) / 0700 (dirs).
|
|
10
|
+
* chmod fails silently on Windows, where POSIX modes don't apply — that's
|
|
11
|
+
* fine, the OS-level ACL surface there is different.
|
|
12
|
+
*
|
|
13
|
+
* ADR-096 Phase 2: optional opt-in encryption-at-rest. When the caller
|
|
14
|
+
* passes `encrypt: true` AND the env-gated vault is enabled, payloads are
|
|
15
|
+
* AES-256-GCM-encrypted before hitting disk. Reads use the magic-byte
|
|
16
|
+
* sniff so legacy plaintext files keep working unchanged during the
|
|
17
|
+
* incremental migration.
|
|
18
|
+
*/
|
|
19
|
+
import { chmodSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
20
|
+
import { decryptBuffer, encryptBuffer, getKey, isEncryptedBlob, isEncryptionEnabled, } from './encryption/vault.js';
|
|
21
|
+
/**
|
|
22
|
+
* Create a directory tree with mode 0700 (owner-only). No-op if exists.
|
|
23
|
+
* Uses recursive: true so missing parents are created with the same mode.
|
|
24
|
+
*/
|
|
25
|
+
export function mkdirRestricted(path) {
|
|
26
|
+
mkdirSync(path, { recursive: true, mode: 0o700 });
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Write a file and tighten its permissions to mode 0600 (owner read/write).
|
|
30
|
+
*
|
|
31
|
+
* Two call signatures, both supported (the legacy positional one keeps
|
|
32
|
+
* existing call sites working without churn):
|
|
33
|
+
*
|
|
34
|
+
* writeFileRestricted(path, data) // plaintext, utf-8
|
|
35
|
+
* writeFileRestricted(path, data, 'utf-8') // legacy: encoding only
|
|
36
|
+
* writeFileRestricted(path, data, { encrypt: true }) // opt-in encryption
|
|
37
|
+
*/
|
|
38
|
+
export function writeFileRestricted(path, data, optsOrEncoding = 'utf-8') {
|
|
39
|
+
const opts = typeof optsOrEncoding === 'string'
|
|
40
|
+
? { encoding: optsOrEncoding }
|
|
41
|
+
: optsOrEncoding;
|
|
42
|
+
const encoding = opts.encoding ?? 'utf-8';
|
|
43
|
+
let payload = data;
|
|
44
|
+
if (opts.encrypt && isEncryptionEnabled()) {
|
|
45
|
+
const plaintext = Buffer.isBuffer(data) ? data : Buffer.from(data, encoding);
|
|
46
|
+
payload = encryptBuffer(plaintext, getKey());
|
|
47
|
+
}
|
|
48
|
+
// For encrypted payloads we always have a Buffer — pass through without an
|
|
49
|
+
// encoding so writeFileSync doesn't try to text-decode it.
|
|
50
|
+
if (Buffer.isBuffer(payload)) {
|
|
51
|
+
writeFileSync(path, payload);
|
|
52
|
+
}
|
|
53
|
+
else {
|
|
54
|
+
writeFileSync(path, payload, encoding);
|
|
55
|
+
}
|
|
56
|
+
try {
|
|
57
|
+
chmodSync(path, 0o600);
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
// Windows / FS without POSIX modes — silently skip.
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
export function readFileMaybeEncrypted(path, encoding = 'utf-8') {
|
|
64
|
+
const raw = readFileSync(path);
|
|
65
|
+
let plain;
|
|
66
|
+
if (isEncryptedBlob(raw)) {
|
|
67
|
+
plain = decryptBuffer(raw, getKey());
|
|
68
|
+
}
|
|
69
|
+
else {
|
|
70
|
+
plain = raw;
|
|
71
|
+
}
|
|
72
|
+
return encoding === null ? plain : plain.toString(encoding);
|
|
73
|
+
}
|
|
74
|
+
//# sourceMappingURL=fs-secure.js.map
|
|
@@ -8,7 +8,7 @@ import { getProjectCwd } from './types.js';
|
|
|
8
8
|
import { validateIdentifier, validateText } from './validate-input.js';
|
|
9
9
|
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
10
10
|
import { join } from 'node:path';
|
|
11
|
-
import { execSync } from 'node:child_process';
|
|
11
|
+
import { execFileSync, execSync } from 'node:child_process';
|
|
12
12
|
// Storage paths
|
|
13
13
|
const STORAGE_DIR = '.claude-flow';
|
|
14
14
|
const GITHUB_DIR = 'github';
|
|
@@ -41,7 +41,20 @@ function saveGitHubStore(store) {
|
|
|
41
41
|
ensureGitHubDir();
|
|
42
42
|
writeFileSync(getGitHubPath(), JSON.stringify(store, null, 2), 'utf-8');
|
|
43
43
|
}
|
|
44
|
-
/**
|
|
44
|
+
/**
|
|
45
|
+
* Run a shell command, return stdout or null on failure.
|
|
46
|
+
*
|
|
47
|
+
* SECURITY (audit_1776853149979): only call this with a STATIC command
|
|
48
|
+
* string (no template-string interpolation of user input). For any
|
|
49
|
+
* caller that needs to pass dynamic / user-supplied values, use
|
|
50
|
+
* runArgv below — it routes through execFileSync with shell:false so
|
|
51
|
+
* backticks, $(...), ;, and friends become literal argv bytes.
|
|
52
|
+
*
|
|
53
|
+
* The shell-string form is preserved here only because the surviving
|
|
54
|
+
* callers (`gh issue list ...`, `git rev-list --count HEAD`, …) use
|
|
55
|
+
* pipes / wc -l and need a shell. Any new caller with user input
|
|
56
|
+
* MUST use runArgv.
|
|
57
|
+
*/
|
|
45
58
|
function run(cmd, cwd) {
|
|
46
59
|
try {
|
|
47
60
|
return execSync(cmd, { encoding: 'utf-8', timeout: 15000, cwd: cwd || getProjectCwd(), stdio: ['pipe', 'pipe', 'pipe'] }).trim();
|
|
@@ -50,6 +63,49 @@ function run(cmd, cwd) {
|
|
|
50
63
|
return null;
|
|
51
64
|
}
|
|
52
65
|
}
|
|
66
|
+
/**
|
|
67
|
+
* Run a program with an argv array (no shell). Use this for any callsite
|
|
68
|
+
* that mixes user input into the command line — argv elements aren't
|
|
69
|
+
* interpreted by /bin/sh, so shell metacharacters in user-supplied
|
|
70
|
+
* strings stay literal.
|
|
71
|
+
*/
|
|
72
|
+
function runArgv(file, args, cwd) {
|
|
73
|
+
try {
|
|
74
|
+
return execFileSync(file, args, {
|
|
75
|
+
encoding: 'utf-8',
|
|
76
|
+
timeout: 15000,
|
|
77
|
+
cwd: cwd || getProjectCwd(),
|
|
78
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
79
|
+
shell: false,
|
|
80
|
+
}).trim();
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Coerce a user-supplied PR / issue / run number to a positive integer.
|
|
88
|
+
* Returns null if the input can't be safely passed as an argv element to
|
|
89
|
+
* gh (which would otherwise accept any string).
|
|
90
|
+
*/
|
|
91
|
+
function toPositiveInt(value) {
|
|
92
|
+
const n = typeof value === 'number' ? value : Number(value);
|
|
93
|
+
if (!Number.isFinite(n) || !Number.isInteger(n) || n <= 0 || n > 2 ** 31)
|
|
94
|
+
return null;
|
|
95
|
+
return n;
|
|
96
|
+
}
|
|
97
|
+
const LABEL_RE = /^[A-Za-z0-9][A-Za-z0-9 _\-./]{0,63}$/;
|
|
98
|
+
function sanitizeLabels(value) {
|
|
99
|
+
if (!Array.isArray(value))
|
|
100
|
+
return null;
|
|
101
|
+
const out = [];
|
|
102
|
+
for (const v of value) {
|
|
103
|
+
if (typeof v !== 'string' || !LABEL_RE.test(v))
|
|
104
|
+
return null;
|
|
105
|
+
out.push(v);
|
|
106
|
+
}
|
|
107
|
+
return out;
|
|
108
|
+
}
|
|
53
109
|
/** Check if gh CLI is available */
|
|
54
110
|
function hasGhCli() {
|
|
55
111
|
return run('gh --version') !== null;
|
|
@@ -219,7 +275,17 @@ export const githubTools = [
|
|
|
219
275
|
const headBranch = input.branch || run('git rev-parse --abbrev-ref HEAD') || 'feature';
|
|
220
276
|
const baseBranch = input.baseBranch || 'main';
|
|
221
277
|
const body = input.body || '';
|
|
222
|
-
|
|
278
|
+
// audit_1776853149979: title/body only had length validation, and
|
|
279
|
+
// the inline .replace(/"/g, '\\"') was a porous escape (no handling
|
|
280
|
+
// of `, $(...), \). Routes via argv array now — no shell to
|
|
281
|
+
// interpret metas.
|
|
282
|
+
const result = runArgv('gh', [
|
|
283
|
+
'pr', 'create',
|
|
284
|
+
'--title', title,
|
|
285
|
+
'--base', baseBranch,
|
|
286
|
+
'--head', headBranch,
|
|
287
|
+
'--body', body,
|
|
288
|
+
]);
|
|
223
289
|
if (result) {
|
|
224
290
|
return { success: true, _real: true, action: 'created', url: result };
|
|
225
291
|
}
|
|
@@ -232,9 +298,15 @@ export const githubTools = [
|
|
|
232
298
|
return { success: true, source: 'local-store', action: 'created', pullRequest: pr };
|
|
233
299
|
}
|
|
234
300
|
if (action === 'review') {
|
|
235
|
-
|
|
301
|
+
// audit_1776853149979: prNumber was typed `number` in schema but only
|
|
302
|
+
// cast at runtime, so a string "1; touch /tmp/x" would interpolate
|
|
303
|
+
// into the shell. Coerce + validate as positive integer.
|
|
304
|
+
const prNumber = toPositiveInt(input.prNumber);
|
|
236
305
|
if (gh && prNumber) {
|
|
237
|
-
const raw =
|
|
306
|
+
const raw = runArgv('gh', [
|
|
307
|
+
'pr', 'view', String(prNumber),
|
|
308
|
+
'--json', 'number,title,state,body,additions,deletions,changedFiles,reviews,mergeable,statusCheckRollup',
|
|
309
|
+
]);
|
|
238
310
|
if (raw) {
|
|
239
311
|
try {
|
|
240
312
|
return { success: true, _real: true, action: 'review', pullRequest: JSON.parse(raw) };
|
|
@@ -242,18 +314,18 @@ export const githubTools = [
|
|
|
242
314
|
catch { /* fall through */ }
|
|
243
315
|
}
|
|
244
316
|
}
|
|
245
|
-
return { success: false, error: prNumber ? 'gh CLI not available or PR not found. Install gh: https://cli.github.com' : 'prNumber is required for review.' };
|
|
317
|
+
return { success: false, error: prNumber ? 'gh CLI not available or PR not found. Install gh: https://cli.github.com' : 'prNumber is required (positive integer) for review.' };
|
|
246
318
|
}
|
|
247
319
|
if (action === 'merge') {
|
|
248
|
-
const prNumber = input.prNumber;
|
|
320
|
+
const prNumber = toPositiveInt(input.prNumber);
|
|
249
321
|
if (gh && prNumber) {
|
|
250
|
-
const result =
|
|
322
|
+
const result = runArgv('gh', ['pr', 'merge', String(prNumber), '--merge']);
|
|
251
323
|
if (result !== null) {
|
|
252
324
|
return { success: true, _real: true, action: 'merged', prNumber, mergedAt: new Date().toISOString() };
|
|
253
325
|
}
|
|
254
326
|
}
|
|
255
327
|
// Fallback: local store
|
|
256
|
-
const prKey = Object.keys(store.prs).find(k => k.includes(String(prNumber)));
|
|
328
|
+
const prKey = prNumber ? Object.keys(store.prs).find(k => k.includes(String(prNumber))) : undefined;
|
|
257
329
|
if (prKey && store.prs[prKey]) {
|
|
258
330
|
store.prs[prKey].status = 'merged';
|
|
259
331
|
saveGitHubStore(store);
|
|
@@ -261,14 +333,14 @@ export const githubTools = [
|
|
|
261
333
|
return { success: true, source: 'local-store', action: 'merged', prNumber, mergedAt: new Date().toISOString() };
|
|
262
334
|
}
|
|
263
335
|
if (action === 'close') {
|
|
264
|
-
const prNumber = input.prNumber;
|
|
336
|
+
const prNumber = toPositiveInt(input.prNumber);
|
|
265
337
|
if (gh && prNumber) {
|
|
266
|
-
const result =
|
|
338
|
+
const result = runArgv('gh', ['pr', 'close', String(prNumber)]);
|
|
267
339
|
if (result !== null) {
|
|
268
340
|
return { success: true, _real: true, action: 'closed', prNumber, closedAt: new Date().toISOString() };
|
|
269
341
|
}
|
|
270
342
|
}
|
|
271
|
-
const prKey = Object.keys(store.prs).find(k => k.includes(String(prNumber)));
|
|
343
|
+
const prKey = prNumber ? Object.keys(store.prs).find(k => k.includes(String(prNumber))) : undefined;
|
|
272
344
|
if (prKey && store.prs[prKey]) {
|
|
273
345
|
store.prs[prKey].status = 'closed';
|
|
274
346
|
saveGitHubStore(store);
|
|
@@ -336,10 +408,16 @@ export const githubTools = [
|
|
|
336
408
|
if (action === 'create') {
|
|
337
409
|
const title = input.title || 'New Issue';
|
|
338
410
|
const body = input.body || '';
|
|
339
|
-
|
|
411
|
+
// audit_1776853149979: labels was joined into a shell string with no
|
|
412
|
+
// validation of the label content. sanitizeLabels rejects anything
|
|
413
|
+
// outside [A-Za-z0-9 _\-./] and caps each label at 64 chars.
|
|
414
|
+
const labels = sanitizeLabels(input.labels) ?? [];
|
|
340
415
|
if (gh) {
|
|
341
|
-
const
|
|
342
|
-
|
|
416
|
+
const argv = ['issue', 'create', '--title', title, '--body', body];
|
|
417
|
+
if (labels.length > 0) {
|
|
418
|
+
argv.push('--label', labels.join(','));
|
|
419
|
+
}
|
|
420
|
+
const result = runArgv('gh', argv);
|
|
343
421
|
if (result) {
|
|
344
422
|
return { success: true, _real: true, action: 'created', url: result };
|
|
345
423
|
}
|
|
@@ -351,37 +429,45 @@ export const githubTools = [
|
|
|
351
429
|
return { success: true, source: 'local-store', action: 'created', issue };
|
|
352
430
|
}
|
|
353
431
|
if (action === 'update') {
|
|
354
|
-
const issueNumber = input.issueNumber;
|
|
432
|
+
const issueNumber = toPositiveInt(input.issueNumber);
|
|
355
433
|
if (gh && issueNumber) {
|
|
356
|
-
const
|
|
434
|
+
const argv = ['issue', 'edit', String(issueNumber)];
|
|
357
435
|
if (input.title)
|
|
358
|
-
|
|
359
|
-
if (input.labels)
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
436
|
+
argv.push('--title', input.title);
|
|
437
|
+
if (input.labels) {
|
|
438
|
+
const labels = sanitizeLabels(input.labels);
|
|
439
|
+
if (labels === null)
|
|
440
|
+
return { success: false, error: 'labels contains disallowed characters' };
|
|
441
|
+
if (labels.length > 0)
|
|
442
|
+
argv.push('--add-label', labels.join(','));
|
|
443
|
+
}
|
|
444
|
+
if (argv.length > 3) {
|
|
445
|
+
const result = runArgv('gh', argv);
|
|
363
446
|
if (result !== null)
|
|
364
447
|
return { success: true, _real: true, action: 'updated', issueNumber };
|
|
365
448
|
}
|
|
366
449
|
}
|
|
367
|
-
const issueKey = Object.keys(store.issues).find(k => k.includes(String(issueNumber)));
|
|
450
|
+
const issueKey = issueNumber ? Object.keys(store.issues).find(k => k.includes(String(issueNumber))) : undefined;
|
|
368
451
|
if (issueKey && store.issues[issueKey]) {
|
|
369
452
|
if (input.title)
|
|
370
453
|
store.issues[issueKey].title = input.title;
|
|
371
|
-
if (input.labels)
|
|
372
|
-
|
|
454
|
+
if (input.labels) {
|
|
455
|
+
const labels = sanitizeLabels(input.labels);
|
|
456
|
+
if (labels !== null)
|
|
457
|
+
store.issues[issueKey].labels = labels;
|
|
458
|
+
}
|
|
373
459
|
saveGitHubStore(store);
|
|
374
460
|
}
|
|
375
461
|
return { success: true, source: 'local-store', action: 'updated', issueNumber };
|
|
376
462
|
}
|
|
377
463
|
if (action === 'close') {
|
|
378
|
-
const issueNumber = input.issueNumber;
|
|
464
|
+
const issueNumber = toPositiveInt(input.issueNumber);
|
|
379
465
|
if (gh && issueNumber) {
|
|
380
|
-
const result =
|
|
466
|
+
const result = runArgv('gh', ['issue', 'close', String(issueNumber)]);
|
|
381
467
|
if (result !== null)
|
|
382
468
|
return { success: true, _real: true, action: 'closed', issueNumber, closedAt: new Date().toISOString() };
|
|
383
469
|
}
|
|
384
|
-
const issueKey = Object.keys(store.issues).find(k => k.includes(String(issueNumber)));
|
|
470
|
+
const issueKey = issueNumber ? Object.keys(store.issues).find(k => k.includes(String(issueNumber))) : undefined;
|
|
385
471
|
if (issueKey && store.issues[issueKey]) {
|
|
386
472
|
store.issues[issueKey].status = 'closed';
|
|
387
473
|
saveGitHubStore(store);
|
|
@@ -450,7 +536,12 @@ export const githubTools = [
|
|
|
450
536
|
if (action === 'status') {
|
|
451
537
|
const workflowId = input.workflowId;
|
|
452
538
|
if (workflowId) {
|
|
453
|
-
|
|
539
|
+
// workflowId is already validated by validateIdentifier above, but
|
|
540
|
+
// route through argv anyway for consistency / defense-in-depth.
|
|
541
|
+
const raw = runArgv('gh', [
|
|
542
|
+
'run', 'view', workflowId,
|
|
543
|
+
'--json', 'databaseId,displayTitle,status,conclusion,jobs',
|
|
544
|
+
]);
|
|
454
545
|
if (raw) {
|
|
455
546
|
try {
|
|
456
547
|
return { success: true, _real: true, run: JSON.parse(raw) };
|
|
@@ -471,7 +562,7 @@ export const githubTools = [
|
|
|
471
562
|
const workflowId = input.workflowId;
|
|
472
563
|
const ref = input.ref || 'main';
|
|
473
564
|
if (workflowId) {
|
|
474
|
-
const result =
|
|
565
|
+
const result = runArgv('gh', ['workflow', 'run', workflowId, '--ref', ref]);
|
|
475
566
|
if (result !== null)
|
|
476
567
|
return { success: true, _real: true, action: 'triggered', workflowId, ref };
|
|
477
568
|
}
|
|
@@ -480,7 +571,7 @@ export const githubTools = [
|
|
|
480
571
|
if (action === 'cancel') {
|
|
481
572
|
const workflowId = input.workflowId;
|
|
482
573
|
if (workflowId) {
|
|
483
|
-
const result =
|
|
574
|
+
const result = runArgv('gh', ['run', 'cancel', workflowId]);
|
|
484
575
|
if (result !== null)
|
|
485
576
|
return { success: true, _real: true, action: 'cancelled', runId: workflowId };
|
|
486
577
|
}
|
|
@@ -1388,8 +1388,8 @@ export const hooksPretrain = {
|
|
|
1388
1388
|
const repoPath = resolve(params.path || '.');
|
|
1389
1389
|
const depth = params.depth || 'medium';
|
|
1390
1390
|
const startTime = performance.now();
|
|
1391
|
-
// Real file scanning — count files by extension, extract patterns
|
|
1392
|
-
|
|
1391
|
+
// Real file scanning — count files by extension, extract patterns.
|
|
1392
|
+
// (readdirSync/statSync already imported statically at the top.)
|
|
1393
1393
|
const extCounts = {};
|
|
1394
1394
|
let filesAnalyzed = 0;
|
|
1395
1395
|
let totalLines = 0;
|