@tencent-ai/codebuddy-code 2.109.2 → 2.109.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tencent-ai/codebuddy-code",
3
- "version": "2.109.2",
3
+ "version": "2.109.3",
4
4
  "description": "Use CodeBuddy, Tencent's AI assistant, right from your terminal. CodeBuddy can understand your codebase, edit files, run terminal commands, and handle entire workflows for you.",
5
5
  "main": "lib/node/index.js",
6
6
  "typings": "lib/node/index.d.ts",
@@ -824,6 +824,6 @@
824
824
  "SelectImage": true,
825
825
  "SkipToolCallSupportCheck": true
826
826
  },
827
- "commit": "58a450bdac648b0df71fe5e176e35583306d6003",
828
- "date": "2026-06-23T04:53:58.543Z"
827
+ "commit": "68b3db4d874ad5eedb2a970970318018ad0b63af",
828
+ "date": "2026-06-24T02:47:45.617Z"
829
829
  }
@@ -703,6 +703,6 @@
703
703
  }
704
704
  }
705
705
  },
706
- "commit": "58a450bdac648b0df71fe5e176e35583306d6003",
707
- "date": "2026-06-23T04:53:58.445Z"
706
+ "commit": "68b3db4d874ad5eedb2a970970318018ad0b63af",
707
+ "date": "2026-06-24T02:47:45.591Z"
708
708
  }
package/product.ioa.json CHANGED
@@ -1106,6 +1106,6 @@
1106
1106
  }
1107
1107
  }
1108
1108
  },
1109
- "commit": "58a450bdac648b0df71fe5e176e35583306d6003",
1110
- "date": "2026-06-23T04:53:58.508Z"
1109
+ "commit": "68b3db4d874ad5eedb2a970970318018ad0b63af",
1110
+ "date": "2026-06-24T02:47:45.596Z"
1111
1111
  }
package/product.json CHANGED
@@ -1757,6 +1757,6 @@
1757
1757
  "deferLoading": true
1758
1758
  }
1759
1759
  ],
1760
- "commit": "58a450bdac648b0df71fe5e176e35583306d6003",
1761
- "date": "2026-06-23T04:53:58.431Z"
1760
+ "commit": "68b3db4d874ad5eedb2a970970318018ad0b63af",
1761
+ "date": "2026-06-24T02:47:45.629Z"
1762
1762
  }
@@ -339,6 +339,6 @@
339
339
  "ScheduledTasks": true,
340
340
  "SkipToolCallSupportCheck": true
341
341
  },
342
- "commit": "58a450bdac648b0df71fe5e176e35583306d6003",
343
- "date": "2026-06-23T04:53:58.498Z"
342
+ "commit": "68b3db4d874ad5eedb2a970970318018ad0b63af",
343
+ "date": "2026-06-24T02:47:45.607Z"
344
344
  }
@@ -101,6 +101,34 @@ function recordTrash(absPath) {
101
101
  }
102
102
  }
103
103
 
104
+ function checkBulkDeleteGuard(absPath) {
105
+ const guardPath = process.env.CODEBUDDY_SAFE_DELETE_BULK_GUARD;
106
+ const nodeBin = process.env.CODEBUDDY_NODE_BIN;
107
+ if (!process.env.CODEBUDDY_SAFE_DELETE_BULK_STATE_DIR || !process.env.CODEBUDDY_TOOL_CALL_ID) {
108
+ return;
109
+ }
110
+ if (!guardPath || !nodeBin || !fs.existsSync(guardPath)) {
111
+ throw new Error(`[safe-delete][SAFE_DELETE_BULK_GUARD_ERROR] msg=helper-unavailable guard=${guardPath || 'unset'}`);
112
+ }
113
+ try {
114
+ execFileSync(nodeBin, [guardPath, 'check', '--target', absPath], {
115
+ stdio: ['ignore', 'ignore', 'pipe'],
116
+ timeout: 10_000,
117
+ windowsHide: true,
118
+ env: {
119
+ ...process.env,
120
+ NODE_OPTIONS: '',
121
+ },
122
+ });
123
+ } catch (e) {
124
+ const detail = e && e.stderr ? String(e.stderr).trim() : '';
125
+ if (detail) {
126
+ throw new Error(detail);
127
+ }
128
+ throw new Error(`[safe-delete][SAFE_DELETE_BULK_GUARD_ERROR] ${(e && e.message) || String(e)}`);
129
+ }
130
+ }
131
+
104
132
  // ---------------------------------------------------------------------------
105
133
  // 平台分发
106
134
  // ---------------------------------------------------------------------------
@@ -419,6 +447,7 @@ function writeTrashInfo(absPath, baseName) {
419
447
  * @param {string} absPath 绝对路径
420
448
  */
421
449
  function tryTrash(absPath) {
450
+ checkBulkDeleteGuard(absPath);
422
451
  trashItem(absPath); // 成功静默;失败抛错,调用方不应继续真删
423
452
  recordTrash(absPath);
424
453
  return true;
@@ -45,6 +45,23 @@ safe_delete_record_trash() {
45
45
  "$path_json" "$timestamp" >> "$CODEBUDDY_SAFE_DELETE_REPORT_PATH" 2>/dev/null || true
46
46
  }
47
47
 
48
+ safe_delete_bulk_guard_check() {
49
+ [ "$#" -gt 0 ] || return 0
50
+ [ -n "${CODEBUDDY_SAFE_DELETE_BULK_STATE_DIR:-}" ] || return 0
51
+ [ -n "${CODEBUDDY_TOOL_CALL_ID:-}" ] || return 0
52
+ if [ -z "${CODEBUDDY_NODE_BIN:-}" ] || [ -z "${CODEBUDDY_SAFE_DELETE_BULK_GUARD:-}" ] || [ ! -f "$CODEBUDDY_SAFE_DELETE_BULK_GUARD" ]; then
53
+ echo "[safe-delete][SAFE_DELETE_BULK_GUARD_ERROR] msg=helper-unavailable guard=${CODEBUDDY_SAFE_DELETE_BULK_GUARD:-unset}" >&2
54
+ return 1
55
+ fi
56
+
57
+ local args=()
58
+ local p
59
+ for p in "$@"; do
60
+ args+=(--target "$(safe_delete_abs_path "$p")")
61
+ done
62
+ NODE_OPTIONS= "$CODEBUDDY_NODE_BIN" "$CODEBUDDY_SAFE_DELETE_BULK_GUARD" check "${args[@]}"
63
+ }
64
+
48
65
  is_dir_empty() {
49
66
  local p="$1"
50
67
  [ -z "$(ls -A "$p" 2>/dev/null)" ]
@@ -347,11 +364,8 @@ trash_one() {
347
364
 
348
365
  try_trash() {
349
366
  local p="$1"
350
- local abs="$p"
351
- case "$abs" in
352
- /*|[A-Za-z]:/*) ;;
353
- *) abs="$PWD/$abs" ;;
354
- esac
367
+ local abs
368
+ abs="$(safe_delete_abs_path "$p")"
355
369
  if trash_one "$p"; then
356
370
  safe_delete_record_trash "$abs"
357
371
  return 0
@@ -416,6 +430,7 @@ safe_delete_rm() {
416
430
  fi
417
431
 
418
432
  local _exit=0
433
+ local trash_targets=()
419
434
  local p
420
435
  for p in "${targets[@]}"; do
421
436
  if [ -e "$p" ] || [ -L "$p" ]; then
@@ -431,19 +446,7 @@ safe_delete_rm() {
431
446
  continue
432
447
  fi
433
448
  fi
434
- if safe_delete_is_under_os_tmp_dir "$p"; then
435
- local native_args=()
436
- $force && native_args+=("-f")
437
- $recursive && native_args+=("-r")
438
- $allow_dir && native_args+=("-d")
439
- if ! "$REAL_RM" "${native_args[@]}" -- "$p"; then
440
- _exit=1
441
- fi
442
- continue
443
- fi
444
- if ! try_trash "$p"; then
445
- _exit=1
446
- fi
449
+ trash_targets+=("$p")
447
450
  elif $force; then
448
451
  # -f: silently skip non-existent files (match real rm behavior)
449
452
  :
@@ -453,6 +456,22 @@ safe_delete_rm() {
453
456
  _exit=1
454
457
  fi
455
458
  done
459
+ if ! safe_delete_bulk_guard_check "${trash_targets[@]}"; then
460
+ return 1
461
+ fi
462
+ for p in "${trash_targets[@]}"; do
463
+ if safe_delete_is_under_os_tmp_dir "$p"; then
464
+ local native_args=()
465
+ $force && native_args+=("-f")
466
+ $recursive && native_args+=("-r")
467
+ $allow_dir && native_args+=("-d")
468
+ if ! "$REAL_RM" "${native_args[@]}" -- "$p"; then
469
+ _exit=1
470
+ fi
471
+ elif ! try_trash "$p"; then
472
+ _exit=1
473
+ fi
474
+ done
456
475
  return $_exit
457
476
  }
458
477
 
@@ -487,6 +506,9 @@ safe_delete_unlink() {
487
506
  return 1
488
507
  fi
489
508
 
509
+ if ! safe_delete_bulk_guard_check "$target"; then
510
+ return 1
511
+ fi
490
512
  if safe_delete_is_under_os_tmp_dir "$target"; then
491
513
  "$REAL_UNLINK" -- "$target"
492
514
  return $?
@@ -529,6 +551,7 @@ safe_delete_rmdir() {
529
551
  fi
530
552
 
531
553
  local _exit=0
554
+ local trash_targets=()
532
555
  local p
533
556
  for p in "${targets[@]}"; do
534
557
  if [ ! -e "$p" ] && [ ! -L "$p" ]; then
@@ -546,13 +569,17 @@ safe_delete_rmdir() {
546
569
  _exit=1
547
570
  continue
548
571
  fi
572
+ trash_targets+=("$p")
573
+ done
574
+ if ! safe_delete_bulk_guard_check "${trash_targets[@]}"; then
575
+ return 1
576
+ fi
577
+ for p in "${trash_targets[@]}"; do
549
578
  if safe_delete_is_under_os_tmp_dir "$p"; then
550
579
  if ! "$REAL_RMDIR" -- "$p"; then
551
580
  _exit=1
552
581
  fi
553
- continue
554
- fi
555
- if ! try_trash "$p"; then
582
+ elif ! try_trash "$p"; then
556
583
  _exit=1
557
584
  fi
558
585
  done
@@ -0,0 +1,447 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const crypto = require('crypto');
5
+ const fs = require('fs');
6
+ const path = require('path');
7
+
8
+ const CONFIRM_MARKER = '[safe-delete][SAFE_DELETE_BULK_CONFIRM_REQUIRED]';
9
+ const REJECTED_MARKER = '[safe-delete][SAFE_DELETE_BULK_REJECTED]';
10
+ const ERROR_MARKER = '[safe-delete][SAFE_DELETE_BULK_GUARD_ERROR]';
11
+ const DEFAULT_THRESHOLD = 20;
12
+ const LOCK_TIMEOUT_MS = 2000;
13
+ const STALE_LOCK_MS = 5 * 60 * 1000;
14
+ const TURN_STATE_TTL_MS = 7 * 24 * 60 * 60 * 1000;
15
+ const TARGET_SAMPLE_LIMIT = 5;
16
+ const DISPLAY_COUNT_LIMIT = 9999;
17
+
18
+ class GuardError extends Error {}
19
+
20
+ function fail(message) {
21
+ throw new GuardError(message);
22
+ }
23
+
24
+ function parseArgs(argv) {
25
+ const command = argv[2];
26
+ const targets = [];
27
+ let scope;
28
+ for (let i = 3; i < argv.length; i++) {
29
+ const arg = argv[i];
30
+ if (arg === '--target') {
31
+ const target = argv[++i];
32
+ if (!target) fail('missing --target value');
33
+ targets.push(target);
34
+ } else if (arg === '--scope') {
35
+ scope = argv[++i];
36
+ if (!scope) fail('missing --scope value');
37
+ } else {
38
+ fail(`unknown argument: ${arg}`);
39
+ }
40
+ }
41
+ return { command, targets, scope };
42
+ }
43
+
44
+ function requireEnv(name) {
45
+ const value = process.env[name];
46
+ if (!value) fail(`${name} is not set`);
47
+ return value;
48
+ }
49
+
50
+ function normalizeThreshold(value) {
51
+ const raw = value === undefined || value === null || value === '' ? DEFAULT_THRESHOLD : value;
52
+ const threshold = Number.parseInt(String(raw), 10);
53
+ if (!Number.isFinite(threshold) || threshold <= 0) fail(`invalid threshold: ${value}`);
54
+ return threshold;
55
+ }
56
+
57
+ function createSafeDeleteBulkGuardContext(input) {
58
+ if (!input || !input.stateRoot || !input.sessionId || !input.toolCallId) {
59
+ fail('missing bulk guard context');
60
+ }
61
+ const threshold = normalizeThreshold(input.threshold);
62
+ const sessionId = input.sessionId;
63
+ const conversationRequestId = input.conversationRequestId;
64
+ const sessionHash = crypto.createHash('sha256').update(sessionId).digest('hex');
65
+ const sessionDir = path.join(input.stateRoot, sessionHash);
66
+ return {
67
+ stateRoot: input.stateRoot,
68
+ sessionId,
69
+ toolCallId: input.toolCallId,
70
+ requestId: conversationRequestId || input.toolCallId,
71
+ threshold,
72
+ conversationRequestId,
73
+ sessionHash,
74
+ sessionDir,
75
+ statePath: path.join(sessionDir, 'state.json'),
76
+ };
77
+ }
78
+
79
+ function getContext() {
80
+ return createSafeDeleteBulkGuardContext({
81
+ stateRoot: requireEnv('CODEBUDDY_SAFE_DELETE_BULK_STATE_DIR'),
82
+ sessionId: requireEnv('CODEBUDDY_SESSION_ID'),
83
+ toolCallId: requireEnv('CODEBUDDY_TOOL_CALL_ID'),
84
+ conversationRequestId: process.env.CODEBUDDY_CONVERSATION_REQUEST_ID,
85
+ threshold: process.env.CODEBUDDY_SAFE_DELETE_BULK_THRESHOLD || DEFAULT_THRESHOLD,
86
+ });
87
+ }
88
+
89
+ function defaultState() {
90
+ return { requests: {}, toolApprovals: {}, requestRejections: {} };
91
+ }
92
+
93
+ function normalizeState(parsed) {
94
+ if (!parsed || typeof parsed !== 'object') return defaultState();
95
+ return {
96
+ requests: parsed.requests && typeof parsed.requests === 'object' ? parsed.requests : {},
97
+ toolApprovals: parsed.toolApprovals && typeof parsed.toolApprovals === 'object' ? parsed.toolApprovals : {},
98
+ requestRejections: parsed.requestRejections && typeof parsed.requestRejections === 'object' ? parsed.requestRejections : {},
99
+ };
100
+ }
101
+
102
+ function readState(statePath) {
103
+ try {
104
+ return normalizeState(JSON.parse(fs.readFileSync(statePath, 'utf-8')));
105
+ } catch (error) {
106
+ if (error && error.code === 'ENOENT') return defaultState();
107
+ fail(`failed to read state: ${error && error.message ? error.message : String(error)}`);
108
+ }
109
+ }
110
+
111
+ function writeState(statePath, state) {
112
+ const tmpPath = `${statePath}.${process.pid}.tmp`;
113
+ fs.writeFileSync(tmpPath, `${JSON.stringify(state)}\n`, 'utf-8');
114
+ fs.renameSync(tmpPath, statePath);
115
+ }
116
+
117
+ function normalizeRequest(request, now) {
118
+ if (!request || typeof request !== 'object') return { count: 0, updatedAt: now };
119
+ return {
120
+ count: Number.isFinite(request.count) ? request.count : 0,
121
+ updatedAt: Number.isFinite(request.updatedAt) ? request.updatedAt : now,
122
+ };
123
+ }
124
+
125
+ function normalizeToolApproval(approval, now) {
126
+ if (!approval || typeof approval !== 'object') return { approved: false, updatedAt: now };
127
+ return {
128
+ approved: approval.approved === true,
129
+ updatedAt: Number.isFinite(approval.updatedAt) ? approval.updatedAt : now,
130
+ };
131
+ }
132
+
133
+ function normalizeRequestRejection(rejection, now) {
134
+ if (!rejection || typeof rejection !== 'object') {
135
+ return { rejected: false, updatedAt: now };
136
+ }
137
+ const targets = Array.isArray(rejection.targets)
138
+ ? rejection.targets.filter(target => typeof target === 'string').slice(0, TARGET_SAMPLE_LIMIT)
139
+ : [];
140
+ return {
141
+ rejected: rejection.rejected === true,
142
+ updatedAt: Number.isFinite(rejection.updatedAt) ? rejection.updatedAt : now,
143
+ count: Number.isFinite(rejection.count) ? rejection.count : 0,
144
+ threshold: Number.isFinite(rejection.threshold) ? rejection.threshold : DEFAULT_THRESHOLD,
145
+ targets,
146
+ targetCount: Number.isFinite(rejection.targetCount) ? rejection.targetCount : targets.length,
147
+ };
148
+ }
149
+
150
+ function pruneExpired(state, now) {
151
+ let changed = false;
152
+ const nextRequests = {};
153
+ for (const [requestId, rawRequest] of Object.entries(state.requests)) {
154
+ const request = normalizeRequest(rawRequest, now);
155
+ if (now - request.updatedAt > TURN_STATE_TTL_MS) {
156
+ changed = true;
157
+ continue;
158
+ }
159
+ nextRequests[requestId] = request;
160
+ if (
161
+ !rawRequest
162
+ || typeof rawRequest !== 'object'
163
+ || rawRequest.count !== request.count
164
+ || rawRequest.updatedAt !== request.updatedAt
165
+ ) {
166
+ changed = true;
167
+ }
168
+ }
169
+ if (changed) state.requests = nextRequests;
170
+
171
+ const nextToolApprovals = {};
172
+ for (const [toolCallId, rawApproval] of Object.entries(state.toolApprovals)) {
173
+ const approval = normalizeToolApproval(rawApproval, now);
174
+ if (now - approval.updatedAt > TURN_STATE_TTL_MS) {
175
+ changed = true;
176
+ continue;
177
+ }
178
+ nextToolApprovals[toolCallId] = approval;
179
+ if (
180
+ !rawApproval
181
+ || typeof rawApproval !== 'object'
182
+ || rawApproval.approved !== approval.approved
183
+ || rawApproval.updatedAt !== approval.updatedAt
184
+ ) {
185
+ changed = true;
186
+ }
187
+ }
188
+ if (changed) state.toolApprovals = nextToolApprovals;
189
+
190
+ const nextRequestRejections = {};
191
+ for (const [requestId, rawRejection] of Object.entries(state.requestRejections || {})) {
192
+ const rejection = normalizeRequestRejection(rawRejection, now);
193
+ if (now - rejection.updatedAt > TURN_STATE_TTL_MS) {
194
+ changed = true;
195
+ continue;
196
+ }
197
+ if (rejection.rejected) {
198
+ nextRequestRejections[requestId] = rejection;
199
+ }
200
+ if (
201
+ !rawRejection
202
+ || typeof rawRejection !== 'object'
203
+ || rawRejection.rejected !== rejection.rejected
204
+ || rawRejection.updatedAt !== rejection.updatedAt
205
+ || rawRejection.count !== rejection.count
206
+ || rawRejection.threshold !== rejection.threshold
207
+ || rawRejection.targetCount !== rejection.targetCount
208
+ ) {
209
+ changed = true;
210
+ }
211
+ }
212
+ if (changed) state.requestRejections = nextRequestRejections;
213
+ return changed;
214
+ }
215
+
216
+ function sleepMs(ms) {
217
+ try {
218
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
219
+ } catch (_) {
220
+ const end = Date.now() + ms;
221
+ while (Date.now() < end) {
222
+ // fallback for constrained Node runtimes
223
+ }
224
+ }
225
+ }
226
+
227
+ function removeStaleLock(lockDir) {
228
+ try {
229
+ const stat = fs.statSync(lockDir);
230
+ if (!stat.isDirectory() || Date.now() - stat.mtimeMs <= STALE_LOCK_MS) return false;
231
+ fs.rmSync(lockDir, { recursive: true, force: true });
232
+ return true;
233
+ } catch (_) {
234
+ return false;
235
+ }
236
+ }
237
+
238
+ function withLock(context, fn) {
239
+ fs.mkdirSync(context.sessionDir, { recursive: true });
240
+ const lockDir = path.join(context.sessionDir, '.lock');
241
+ const started = Date.now();
242
+ for (;;) {
243
+ try {
244
+ fs.mkdirSync(lockDir);
245
+ break;
246
+ } catch (error) {
247
+ if (!error || error.code !== 'EEXIST') fail(`failed to acquire lock: ${error && error.message ? error.message : String(error)}`);
248
+ if (removeStaleLock(lockDir)) continue;
249
+ if (Date.now() - started > LOCK_TIMEOUT_MS) fail('state lock timeout');
250
+ sleepMs(25);
251
+ }
252
+ }
253
+ try {
254
+ return fn(context.statePath);
255
+ } finally {
256
+ try { fs.rmdirSync(lockDir); } catch (_) { /* best effort */ }
257
+ }
258
+ }
259
+
260
+ function normalizeTargetPath(target) {
261
+ if (process.platform !== 'win32' || typeof target !== 'string') return target;
262
+ if (/^[A-Za-z]:[\\/]/.test(target)) return target;
263
+ const msysMatch = target.match(/^\/([A-Za-z])(?:\/(.*))?$/);
264
+ if (!msysMatch) return target;
265
+ const drive = msysMatch[1].toUpperCase();
266
+ const rest = (msysMatch[2] || '').replace(/\//g, '\\');
267
+ return rest ? `${drive}:\\${rest}` : `${drive}:\\`;
268
+ }
269
+
270
+ function countTarget(target, remaining) {
271
+ if (remaining <= 0) return 0;
272
+ target = normalizeTargetPath(target);
273
+ let stat;
274
+ try {
275
+ stat = fs.lstatSync(target);
276
+ } catch (error) {
277
+ if (error && error.code === 'ENOENT') return 0;
278
+ fail(`failed to stat target: ${target}`);
279
+ }
280
+ if (!stat.isDirectory() || stat.isSymbolicLink()) return 1;
281
+
282
+ let dir;
283
+ try {
284
+ dir = fs.opendirSync(target);
285
+ } catch (_) {
286
+ fail(`failed to read directory: ${target}`);
287
+ }
288
+ let count = 0;
289
+ try {
290
+ for (;;) {
291
+ if (count >= remaining) break;
292
+ const entry = dir.readSync();
293
+ if (!entry) break;
294
+ if (entry.isDirectory() && !entry.isSymbolicLink()) {
295
+ count += countTarget(path.join(target, entry.name), remaining - count);
296
+ } else {
297
+ count += 1;
298
+ }
299
+ }
300
+ } finally {
301
+ try { dir.closeSync(); } catch (_) { /* best effort */ }
302
+ }
303
+ return count;
304
+ }
305
+
306
+ function countTargets(targets, limit) {
307
+ let count = 0;
308
+ for (const target of targets) {
309
+ if (count >= limit) break;
310
+ count += countTarget(target, limit - count);
311
+ }
312
+ return count;
313
+ }
314
+
315
+ function checkSafeDeleteBulkGuard(context, targets) {
316
+ if (!targets || targets.length === 0) fail('check requires at least one target');
317
+ let decision = { kind: 'allow' };
318
+ withLock(context, statePath => {
319
+ const now = Date.now();
320
+ const state = readState(statePath);
321
+ const pruned = pruneExpired(state, now);
322
+ const rejection = normalizeRequestRejection(state.requestRejections && state.requestRejections[context.requestId], now);
323
+ if (rejection.rejected) {
324
+ decision = {
325
+ kind: 'rejected',
326
+ payload: {
327
+ count: rejection.count,
328
+ threshold: rejection.threshold,
329
+ scope: 'turn',
330
+ targets: rejection.targets,
331
+ targetCount: rejection.targetCount,
332
+ },
333
+ };
334
+ if (pruned) writeState(statePath, state);
335
+ return;
336
+ }
337
+ const request = normalizeRequest(state.requests[context.requestId], now);
338
+ const approval = normalizeToolApproval(state.toolApprovals[context.toolCallId], now);
339
+ const deleteCount = countTargets(targets, DISPLAY_COUNT_LIMIT);
340
+ if (deleteCount === 0) {
341
+ if (pruned) writeState(statePath, state);
342
+ return;
343
+ }
344
+ const totalCount = request.count + deleteCount;
345
+ if (approval.approved) {
346
+ state.requests[context.requestId] = { count: totalCount, updatedAt: now };
347
+ writeState(statePath, state);
348
+ return;
349
+ }
350
+ if (totalCount >= context.threshold) {
351
+ decision = {
352
+ kind: 'confirmRequired',
353
+ payload: {
354
+ count: totalCount,
355
+ threshold: context.threshold,
356
+ scope: 'turn',
357
+ targets: targets.slice(0, TARGET_SAMPLE_LIMIT),
358
+ targetCount: targets.length,
359
+ },
360
+ };
361
+ if (pruned) writeState(statePath, state);
362
+ return;
363
+ }
364
+ state.requests[context.requestId] = { count: totalCount, updatedAt: now };
365
+ writeState(statePath, state);
366
+ });
367
+ return decision;
368
+ }
369
+
370
+ function handleCheck(args) {
371
+ if (args.targets.length === 0) fail('check requires at least one --target');
372
+ const decision = checkSafeDeleteBulkGuard(getContext(), args.targets);
373
+ if (decision.kind === 'confirmRequired') {
374
+ process.stderr.write(`${CONFIRM_MARKER} ${JSON.stringify(decision.payload)}\n`);
375
+ process.exit(2);
376
+ }
377
+ if (decision.kind === 'rejected') {
378
+ process.stderr.write(`${REJECTED_MARKER} ${JSON.stringify(decision.payload)}\n`);
379
+ process.exit(3);
380
+ }
381
+ }
382
+
383
+ function approveSafeDeleteBulkGuard(context, scope) {
384
+ if (scope && scope !== 'turn') fail('approve requires scope turn');
385
+ withLock(context, statePath => {
386
+ const now = Date.now();
387
+ const state = readState(statePath);
388
+ pruneExpired(state, now);
389
+ state.toolApprovals[context.toolCallId] = { approved: true, updatedAt: now };
390
+ writeState(statePath, state);
391
+ });
392
+ }
393
+
394
+ function rejectSafeDeleteBulkGuard(context, payload) {
395
+ const normalized = normalizeRequestRejection({ ...payload, rejected: true }, Date.now());
396
+ withLock(context, statePath => {
397
+ const now = Date.now();
398
+ const state = readState(statePath);
399
+ pruneExpired(state, now);
400
+ state.requests[context.requestId] = {
401
+ count: normalized.count,
402
+ updatedAt: now,
403
+ };
404
+ state.requestRejections[context.requestId] = {
405
+ rejected: true,
406
+ updatedAt: now,
407
+ count: normalized.count,
408
+ threshold: normalized.threshold,
409
+ targets: normalized.targets,
410
+ targetCount: normalized.targetCount,
411
+ };
412
+ delete state.toolApprovals[context.toolCallId];
413
+ writeState(statePath, state);
414
+ });
415
+ }
416
+
417
+ function handleApprove(args) {
418
+ if (args.scope && args.scope !== 'turn') fail('approve requires scope turn');
419
+ approveSafeDeleteBulkGuard(getContext(), args.scope);
420
+ }
421
+
422
+ function main() {
423
+ const args = parseArgs(process.argv);
424
+ if (args.command === 'check') return handleCheck(args);
425
+ if (args.command === 'approve') return handleApprove(args);
426
+ fail(`unknown command: ${args.command || '(empty)'}`);
427
+ }
428
+
429
+ module.exports = {
430
+ approveSafeDeleteBulkGuard,
431
+ checkSafeDeleteBulkGuard,
432
+ createSafeDeleteBulkGuardContext,
433
+ rejectSafeDeleteBulkGuard,
434
+ DEFAULT_THRESHOLD,
435
+ CONFIRM_MARKER,
436
+ REJECTED_MARKER,
437
+ ERROR_MARKER,
438
+ };
439
+
440
+ if (require.main === module) {
441
+ try {
442
+ main();
443
+ } catch (error) {
444
+ process.stderr.write(`${ERROR_MARKER} ${error && error.message ? error.message : String(error)}\n`);
445
+ process.exit(1);
446
+ }
447
+ }