@tencent-ai/agent-sdk 0.3.186 → 0.3.188-dev.368febf.202606232001

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/cli/CHANGELOG.md +26 -0
  2. package/cli/dist/codebuddy-headless.js +339 -221
  3. package/cli/dist/web-ui/docs/cn/cli/cli-reference.md +8 -3
  4. package/cli/dist/web-ui/docs/cn/cli/env-vars.md +2 -1
  5. package/cli/dist/web-ui/docs/cn/cli/iam.md +9 -5
  6. package/cli/dist/web-ui/docs/cn/cli/interactive-mode.md +2 -2
  7. package/cli/dist/web-ui/docs/cn/cli/permission-modes.md +373 -101
  8. package/cli/dist/web-ui/docs/cn/cli/permissions.md +36 -14
  9. package/cli/dist/web-ui/docs/cn/cli/prewarm.md +134 -0
  10. package/cli/dist/web-ui/docs/cn/cli/release-notes/README.md +14 -0
  11. package/cli/dist/web-ui/docs/cn/cli/release-notes/v2.106.0.md +48 -0
  12. package/cli/dist/web-ui/docs/cn/cli/release-notes/v2.106.1.md +13 -0
  13. package/cli/dist/web-ui/docs/cn/cli/release-notes/v2.106.2.md +24 -0
  14. package/cli/dist/web-ui/docs/cn/cli/release-notes/v2.106.3.md +14 -0
  15. package/cli/dist/web-ui/docs/cn/cli/release-notes/v2.106.4.md +24 -0
  16. package/cli/dist/web-ui/docs/cn/cli/release-notes/v2.106.5.md +18 -0
  17. package/cli/dist/web-ui/docs/cn/cli/release-notes/v2.106.6.md +15 -0
  18. package/cli/dist/web-ui/docs/cn/cli/release-notes/v2.106.7.md +13 -0
  19. package/cli/dist/web-ui/docs/cn/cli/release-notes/v2.107.0.md +30 -0
  20. package/cli/dist/web-ui/docs/cn/cli/release-notes/v2.108.0.md +20 -0
  21. package/cli/dist/web-ui/docs/cn/cli/release-notes/v2.108.1.md +15 -0
  22. package/cli/dist/web-ui/docs/cn/cli/release-notes/v2.108.2.md +13 -0
  23. package/cli/dist/web-ui/docs/cn/cli/release-notes/v2.109.0.md +24 -0
  24. package/cli/dist/web-ui/docs/cn/cli/settings.md +166 -2
  25. package/cli/dist/web-ui/docs/en/cli/cli-reference.md +15 -10
  26. package/cli/dist/web-ui/docs/en/cli/env-vars.md +6 -5
  27. package/cli/dist/web-ui/docs/en/cli/iam.md +9 -5
  28. package/cli/dist/web-ui/docs/en/cli/interactive-mode.md +3 -3
  29. package/cli/dist/web-ui/docs/en/cli/permission-modes.md +373 -101
  30. package/cli/dist/web-ui/docs/en/cli/permissions.md +46 -24
  31. package/cli/dist/web-ui/docs/en/cli/prewarm.md +124 -0
  32. package/cli/dist/web-ui/docs/en/cli/release-notes/README.md +14 -0
  33. package/cli/dist/web-ui/docs/en/cli/release-notes/v2.106.0.md +48 -0
  34. package/cli/dist/web-ui/docs/en/cli/release-notes/v2.106.1.md +13 -0
  35. package/cli/dist/web-ui/docs/en/cli/release-notes/v2.106.2.md +24 -0
  36. package/cli/dist/web-ui/docs/en/cli/release-notes/v2.106.3.md +14 -0
  37. package/cli/dist/web-ui/docs/en/cli/release-notes/v2.106.4.md +24 -0
  38. package/cli/dist/web-ui/docs/en/cli/release-notes/v2.106.5.md +18 -0
  39. package/cli/dist/web-ui/docs/en/cli/release-notes/v2.106.6.md +15 -0
  40. package/cli/dist/web-ui/docs/en/cli/release-notes/v2.106.7.md +13 -0
  41. package/cli/dist/web-ui/docs/en/cli/release-notes/v2.107.0.md +30 -0
  42. package/cli/dist/web-ui/docs/en/cli/release-notes/v2.108.0.md +20 -0
  43. package/cli/dist/web-ui/docs/en/cli/release-notes/v2.108.1.md +15 -0
  44. package/cli/dist/web-ui/docs/en/cli/release-notes/v2.108.2.md +13 -0
  45. package/cli/dist/web-ui/docs/en/cli/release-notes/v2.109.0.md +24 -0
  46. package/cli/dist/web-ui/docs/en/cli/settings.md +166 -2
  47. package/cli/dist/web-ui/docs/search-index-en.json +1 -1
  48. package/cli/dist/web-ui/docs/search-index-zh.json +1 -1
  49. package/cli/dist/web-ui/docs/sidebar-en.json +1 -1
  50. package/cli/dist/web-ui/docs/sidebar-zh.json +1 -1
  51. package/cli/package.json +3 -2
  52. package/cli/product.cloudhosted.json +2 -2
  53. package/cli/product.internal.json +2 -2
  54. package/cli/product.ioa.json +2 -2
  55. package/cli/product.json +2 -2
  56. package/cli/product.selfhosted.json +2 -2
  57. package/cli/vendor/shim/genie-safe-delete.cjs +82 -0
  58. package/cli/vendor/shim/safe-bin/safe-delete-common.sh +144 -9
  59. package/cli/vendor/shim/safe-delete-bulk-guard.cjs +447 -0
  60. package/cli/vendor/shim/sitecustomize.py +100 -0
  61. package/package.json +3 -2
@@ -45,11 +45,115 @@ 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)" ]
51
68
  }
52
69
 
70
+ safe_delete_normalize_path() {
71
+ local p="$1"
72
+ while [ "$p" != "/" ] && [ "${p%/}" != "$p" ]; do
73
+ p="${p%/}"
74
+ done
75
+ printf '%s' "$p"
76
+ }
77
+
78
+ safe_delete_to_unix_path_if_possible() {
79
+ local p="$1"
80
+ if command -v cygpath >/dev/null 2>&1; then
81
+ cygpath -u "$p" 2>/dev/null && return 0
82
+ fi
83
+ printf '%s' "$p"
84
+ }
85
+
86
+ safe_delete_resolve_os_tmp_dir() {
87
+ local tmp="${TMPDIR:-${TMP:-${TEMP:-/tmp}}}"
88
+ safe_delete_to_unix_path_if_possible "$tmp"
89
+ }
90
+
91
+ safe_delete_abs_path() {
92
+ local p="$1"
93
+ local abs
94
+ if command -v python3 >/dev/null 2>&1; then
95
+ abs="$(python3 -c 'import os,sys; print(os.path.abspath(sys.argv[1]))' "$p" 2>/dev/null)" && {
96
+ safe_delete_to_unix_path_if_possible "$abs"
97
+ return 0
98
+ }
99
+ elif command -v python >/dev/null 2>&1; then
100
+ abs="$(python -c 'import os,sys; print(os.path.abspath(sys.argv[1]))' "$p" 2>/dev/null)" && {
101
+ safe_delete_to_unix_path_if_possible "$abs"
102
+ return 0
103
+ }
104
+ fi
105
+ case "$p" in
106
+ /*|[A-Za-z]:/*) abs="$p" ;;
107
+ *) abs="$PWD/$p" ;;
108
+ esac
109
+ abs="$(CDPATH= cd -P -- "$(dirname "$abs")" 2>/dev/null && printf '%s/%s' "$PWD" "$(basename "$abs")" || printf '%s' "$abs")"
110
+ safe_delete_to_unix_path_if_possible "$abs"
111
+ }
112
+
113
+ safe_delete_tmp_root_allowed() {
114
+ local root="$1"
115
+ [ -n "$root" ] || return 1
116
+ [ "$root" != "/" ] || return 1
117
+ [ -d "$root" ] || return 1
118
+ return 0
119
+ }
120
+
121
+ safe_delete_build_os_tmp_dirs() {
122
+ local seen="|" candidate root
123
+ for candidate in \
124
+ "$(safe_delete_resolve_os_tmp_dir)" \
125
+ "/tmp" \
126
+ "/private/tmp"
127
+ do
128
+ [ -n "$candidate" ] || continue
129
+ root="$(safe_delete_normalize_path "$(safe_delete_abs_path "$candidate")")"
130
+ safe_delete_tmp_root_allowed "$root" || continue
131
+ case "$seen" in
132
+ *"|$root|"*) ;;
133
+ *)
134
+ seen="${seen}${root}|"
135
+ printf '%s\n' "$root"
136
+ ;;
137
+ esac
138
+ done
139
+ }
140
+
141
+ SAFE_DELETE_OS_TMP_DIRS="$(safe_delete_build_os_tmp_dirs)"
142
+
143
+ safe_delete_is_under_os_tmp_dir() {
144
+ local abs
145
+ abs="$(safe_delete_normalize_path "$(safe_delete_abs_path "$1")")"
146
+ while IFS= read -r tmp_dir; do
147
+ [ -n "$tmp_dir" ] || continue
148
+ case "$abs" in
149
+ "$tmp_dir"/*) return 0 ;;
150
+ esac
151
+ done <<EOF
152
+ $SAFE_DELETE_OS_TMP_DIRS
153
+ EOF
154
+ return 1
155
+ }
156
+
53
157
  # ---- platform-specific trash helpers -----------------------------------------
54
158
 
55
159
  trash_darwin() {
@@ -260,11 +364,8 @@ trash_one() {
260
364
 
261
365
  try_trash() {
262
366
  local p="$1"
263
- local abs="$p"
264
- case "$abs" in
265
- /*|[A-Za-z]:/*) ;;
266
- *) abs="$PWD/$abs" ;;
267
- esac
367
+ local abs
368
+ abs="$(safe_delete_abs_path "$p")"
268
369
  if trash_one "$p"; then
269
370
  safe_delete_record_trash "$abs"
270
371
  return 0
@@ -329,6 +430,7 @@ safe_delete_rm() {
329
430
  fi
330
431
 
331
432
  local _exit=0
433
+ local trash_targets=()
332
434
  local p
333
435
  for p in "${targets[@]}"; do
334
436
  if [ -e "$p" ] || [ -L "$p" ]; then
@@ -344,9 +446,7 @@ safe_delete_rm() {
344
446
  continue
345
447
  fi
346
448
  fi
347
- if ! try_trash "$p"; then
348
- _exit=1
349
- fi
449
+ trash_targets+=("$p")
350
450
  elif $force; then
351
451
  # -f: silently skip non-existent files (match real rm behavior)
352
452
  :
@@ -356,6 +456,22 @@ safe_delete_rm() {
356
456
  _exit=1
357
457
  fi
358
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
359
475
  return $_exit
360
476
  }
361
477
 
@@ -390,6 +506,14 @@ safe_delete_unlink() {
390
506
  return 1
391
507
  fi
392
508
 
509
+ if ! safe_delete_bulk_guard_check "$target"; then
510
+ return 1
511
+ fi
512
+ if safe_delete_is_under_os_tmp_dir "$target"; then
513
+ "$REAL_UNLINK" -- "$target"
514
+ return $?
515
+ fi
516
+
393
517
  try_trash "$target"
394
518
  return $?
395
519
  }
@@ -427,6 +551,7 @@ safe_delete_rmdir() {
427
551
  fi
428
552
 
429
553
  local _exit=0
554
+ local trash_targets=()
430
555
  local p
431
556
  for p in "${targets[@]}"; do
432
557
  if [ ! -e "$p" ] && [ ! -L "$p" ]; then
@@ -444,7 +569,17 @@ safe_delete_rmdir() {
444
569
  _exit=1
445
570
  continue
446
571
  fi
447
- if ! try_trash "$p"; then
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
578
+ if safe_delete_is_under_os_tmp_dir "$p"; then
579
+ if ! "$REAL_RMDIR" -- "$p"; then
580
+ _exit=1
581
+ fi
582
+ elif ! try_trash "$p"; then
448
583
  _exit=1
449
584
  fi
450
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
+ }