@bahulam/code 2.6.14 → 2.6.15

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": "@bahulam/code",
3
- "version": "2.6.14",
3
+ "version": "2.6.15",
4
4
  "description": "Bahulam Code \u2014 abundance, in your terminal. CLI-first, reliability-first, sub-agents, 65.6% SWE-bench Verified.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -49,8 +49,8 @@
49
49
  "web-tree-sitter": "^0.26.9"
50
50
  },
51
51
  "optionalDependencies": {
52
- "@bahulam/runtime-darwin-arm64": "2.6.14",
53
- "@bahulam/runtime-linux-x64": "2.6.14",
54
- "@bahulam/runtime-win32-x64": "2.6.14"
52
+ "@bahulam/runtime-darwin-arm64": "2.6.15",
53
+ "@bahulam/runtime-linux-x64": "2.6.15",
54
+ "@bahulam/runtime-win32-x64": "2.6.15"
55
55
  }
56
56
  }
@@ -1,5 +1,6 @@
1
1
  import * as fs from 'node:fs';
2
2
  import * as path from 'node:path';
3
+ import { isSensitiveConfigPath } from './safety.mjs';
3
4
 
4
5
  const REDACTED = 'REDACTED';
5
6
  const MAX_ARG_LOG_CHARS = 4000;
@@ -53,10 +54,32 @@ function sanitizeForLog(value, depth = 0) {
53
54
  function safeArgs(args) {
54
55
  if (args?.command) return redactSensitive(String(args.command)).slice(0, MAX_ARG_LOG_CHARS);
55
56
  if (args?.file_path || args?.path) return redactSensitive(String(args.file_path || args.path)).slice(0, MAX_ARG_LOG_CHARS);
57
+ if (Array.isArray(args?.files)) {
58
+ try {
59
+ const { files, ...rest } = args;
60
+ return JSON.stringify({
61
+ ...sanitizeForLog(rest),
62
+ files: files.slice(0, 100).map(file => sanitizeProjectFileArg(file)),
63
+ }).slice(0, MAX_ARG_LOG_CHARS);
64
+ } catch {
65
+ return '[files omitted]';
66
+ }
67
+ }
56
68
  try { return JSON.stringify(sanitizeForLog(args || {})).slice(0, MAX_ARG_LOG_CHARS); }
57
69
  catch { return redactSensitive(String(args || '')).slice(0, MAX_ARG_LOG_CHARS); }
58
70
  }
59
71
 
72
+ function sanitizeProjectFileArg(file) {
73
+ if (typeof file === 'string') return redactSensitive(file);
74
+ if (!file || typeof file !== 'object') return sanitizeForLog(file);
75
+ const out = sanitizeForLog(file);
76
+ const filePath = file.path || file.file_path || '';
77
+ if (isSensitiveConfigPath(filePath) && typeof out.content === 'string') {
78
+ out.content = '[redacted sensitive config]';
79
+ }
80
+ return out;
81
+ }
82
+
60
83
  function safeText(value, max = 500) {
61
84
  if (!value) return undefined;
62
85
  return redactSensitive(String(value)).slice(0, max);
@@ -25,7 +25,7 @@ import {
25
25
  defaultOptions as approvalOptions,
26
26
  } from '../ui/approval.mjs';
27
27
  import { clearInputPrompt, isInputDockMounted, moveToContent, renderDockOverlay } from '../ui/input-dock.mjs';
28
- import { validateShellCommand } from './safety.mjs';
28
+ import { isApprovalRequiredWritePath, isSensitiveConfigPath, validateShellCommand } from './safety.mjs';
29
29
  import { classifyCommand } from '../permissions/command-classifier.mjs';
30
30
  import { ApprovalLog } from './approval-log.mjs';
31
31
  import { TrustStore } from './trust.mjs';
@@ -46,9 +46,19 @@ const WRITE_TOOLS = new Set([
46
46
 
47
47
  function defaultWhy(tier, tool, args) {
48
48
  const subject = approvalSummary(tool, args);
49
+ const protectedTarget = protectedWriteTarget(tool, args);
50
+ if (protectedTarget) {
51
+ const target = protectedTarget.path;
52
+ if (protectedTarget.sensitive) {
53
+ return `Edits sensitive environment config (${target}). Confirm before writing; values stay redacted in summaries and diffs.`;
54
+ }
55
+ return `Edits protected project file (${subject || target}). Confirm before writing.`;
56
+ }
49
57
  switch (tier) {
50
58
  case TIERS.SENSITIVE_READ:
51
59
  return `Reads a sensitive path (${subject || 'secret-like file'}). Confirm before exposing its contents to the agent.`;
60
+ case TIERS.PROTECTED_EDIT:
61
+ return `Edits a protected project file. Confirm before writing.`;
52
62
  case TIERS.SHELL_DANGEROUS:
53
63
  return `Shell command matches a high-risk pattern (rm -rf, sudo, force push, etc.). Confirm before running.`;
54
64
  case TIERS.DESTRUCTIVE:
@@ -62,6 +72,26 @@ function defaultWhy(tier, tool, args) {
62
72
  }
63
73
  }
64
74
 
75
+ function protectedWriteTarget(tool, args = {}) {
76
+ const paths = writeTargetPaths(tool, args);
77
+ const path = paths.find(isApprovalRequiredWritePath);
78
+ if (!path) return null;
79
+ return { path, sensitive: isSensitiveConfigPath(path) };
80
+ }
81
+
82
+ function writeTargetPaths(tool, args = {}) {
83
+ const key = String(tool || '').toLowerCase();
84
+ if (key === 'write_file' || key === 'edit_file') {
85
+ return [args.file_path || args.path].filter(Boolean);
86
+ }
87
+ if (key === 'write_project') {
88
+ return (args.files || [])
89
+ .map(file => typeof file === 'string' ? file : file?.path || file?.file_path)
90
+ .filter(Boolean);
91
+ }
92
+ return [];
93
+ }
94
+
65
95
  function approvalSummary(tool, args = {}) {
66
96
  const summary = toolDisplaySummary(tool, args);
67
97
  if (tool !== 'shell') return summary;
@@ -118,6 +148,8 @@ export class ApprovalManager {
118
148
  this.history = [];
119
149
  this.rejectionHints = [];
120
150
  this._rl = null;
151
+ this._approvalPromptActive = false;
152
+ this._approvalPromptWasRaw = false;
121
153
  }
122
154
 
123
155
  setReadline(rl) { this._rl = rl; }
@@ -128,6 +160,29 @@ export class ApprovalManager {
128
160
  this._approvalPromptEnd = onApprovalPromptEnd || null;
129
161
  }
130
162
 
163
+ _beginApprovalInput() {
164
+ if (this._approvalPromptActive || !process.stdin.isTTY) return false;
165
+ this._approvalPromptActive = true;
166
+ this._approvalPromptWasRaw = process.stdin.isRaw;
167
+ if (this._execPause) this._execPause();
168
+ if (this._rl) this._rl.pause();
169
+ if (typeof process.stdin.setRawMode === 'function') {
170
+ process.stdin.setRawMode(true);
171
+ }
172
+ process.stdin.resume();
173
+ return true;
174
+ }
175
+
176
+ _endApprovalInput() {
177
+ if (!this._approvalPromptActive) return;
178
+ if (typeof process.stdin.setRawMode === 'function') {
179
+ process.stdin.setRawMode(this._approvalPromptWasRaw || false);
180
+ }
181
+ if (this._rl) this._rl.resume();
182
+ if (this._execResume) this._execResume();
183
+ this._approvalPromptActive = false;
184
+ }
185
+
131
186
  revoke() {
132
187
  const wasActive = this.approveAll || this.approvedToolTypes.size > 0;
133
188
  this.approveAll = false;
@@ -275,6 +330,9 @@ export class ApprovalManager {
275
330
  printedHeight = block.split('\n').length;
276
331
  };
277
332
 
333
+ const ownsApprovalInput = isInteractive ? this._beginApprovalInput() : false;
334
+
335
+ try {
278
336
  if (isInteractive) drawPrompt();
279
337
 
280
338
  // ── Input loop ─────────────────────────────────────────────────
@@ -334,7 +392,7 @@ export class ApprovalManager {
334
392
  return { approved: true, tier };
335
393
 
336
394
  case 'allow-session': {
337
- if (!this.policy.hitl?.allowSessionTrust) return this._prompt(toolName, args, context);
395
+ if (!this.policy.hitl?.allowSessionTrust) return await this._prompt(toolName, args, context);
338
396
  const rule = this.trustStore.add({ tool: toolName, args, tier, scope: 'SESSION' });
339
397
  write(` ${GREEN}✓${RST} ${DIM}trusted for this session: ${rule.pattern}${RST}\n\n`);
340
398
  this.history.push({ tool: toolName, decision: 'session-trust', tier, time: Date.now(), rule_id: rule.id });
@@ -343,7 +401,7 @@ export class ApprovalManager {
343
401
  }
344
402
 
345
403
  case 'allow-project': {
346
- if (!this.policy.hitl?.allowProjectTrust) return this._prompt(toolName, args, context);
404
+ if (!this.policy.hitl?.allowProjectTrust) return await this._prompt(toolName, args, context);
347
405
  const rule = this.trustStore.add({ tool: toolName, args, tier, scope: 'PROJECT' });
348
406
  write(` ${GREEN}✓${RST} ${DIM}trusted for this project: ${rule.pattern}${RST}\n\n`);
349
407
  this.history.push({ tool: toolName, decision: 'project-trust', tier, time: Date.now(), rule_id: rule.id });
@@ -362,7 +420,7 @@ export class ApprovalManager {
362
420
  }
363
421
 
364
422
  case 'allow-all':
365
- if (requiresExplicitApproval(tier)) return this._prompt(toolName, args, context);
423
+ if (requiresExplicitApproval(tier)) return await this._prompt(toolName, args, context);
366
424
  this.approveAll = true;
367
425
  write(` ${GREEN}✓✓${RST} ${DIM}allow-all activated${RST}\n\n`);
368
426
  this.history.push({ tool: toolName, decision: 'approve-all', tier, time: Date.now() });
@@ -370,7 +428,7 @@ export class ApprovalManager {
370
428
  return { approved: true, tier };
371
429
 
372
430
  case 'allow-type':
373
- if (requiresExplicitApproval(tier)) return this._prompt(toolName, args, context);
431
+ if (requiresExplicitApproval(tier)) return await this._prompt(toolName, args, context);
374
432
  this.approvedToolTypes.add(toolName);
375
433
  this.history.push({ tool: toolName, decision: 'type-approve', tier, time: Date.now() });
376
434
  this.approvalLog.append({ tool: toolName, args, tier, decision: 'type-approve', scope: 'session', prompt });
@@ -380,7 +438,7 @@ export class ApprovalManager {
380
438
  case 'why':
381
439
  write(`\n ${DIM}${(context.reason || why).slice(0, 400)}${RST}\n\n`);
382
440
  printedHeight = 0;
383
- return this._prompt(toolName, args, context);
441
+ return await this._prompt(toolName, args, context);
384
442
 
385
443
  case 'edit':
386
444
  case 'replan':
@@ -395,7 +453,10 @@ export class ApprovalManager {
395
453
  }
396
454
 
397
455
  default:
398
- return this._prompt(toolName, args, context);
456
+ return await this._prompt(toolName, args, context);
457
+ }
458
+ } finally {
459
+ if (ownsApprovalInput) this._endApprovalInput();
399
460
  }
400
461
  }
401
462
 
@@ -406,16 +467,25 @@ export class ApprovalManager {
406
467
  return;
407
468
  }
408
469
 
409
- if (this._execPause) this._execPause();
410
- if (this._rl) this._rl.pause();
470
+ const managesInput = !this._approvalPromptActive;
471
+ if (managesInput) {
472
+ if (this._execPause) this._execPause();
473
+ if (this._rl) this._rl.pause();
474
+ }
411
475
 
412
476
  const wasRaw = process.stdin.isRaw;
413
- process.stdin.setRawMode(true);
477
+ if (managesInput && typeof process.stdin.setRawMode === 'function') {
478
+ process.stdin.setRawMode(true);
479
+ }
414
480
  process.stdin.resume();
415
481
  process.stdin.once('data', (data) => {
416
- process.stdin.setRawMode(wasRaw || false);
417
- if (this._rl) this._rl.resume();
418
- if (this._execResume) this._execResume();
482
+ if (managesInput) {
483
+ if (typeof process.stdin.setRawMode === 'function') {
484
+ process.stdin.setRawMode(wasRaw || false);
485
+ }
486
+ if (this._rl) this._rl.resume();
487
+ if (this._execResume) this._execResume();
488
+ }
419
489
 
420
490
  const bytes = [...data];
421
491
  const str = data.toString();
@@ -444,8 +514,11 @@ export class ApprovalManager {
444
514
  return;
445
515
  }
446
516
 
447
- if (this._execPause) this._execPause();
448
- if (this._rl) this._rl.pause();
517
+ const managesInput = !this._approvalPromptActive;
518
+ if (managesInput) {
519
+ if (this._execPause) this._execPause();
520
+ if (this._rl) this._rl.pause();
521
+ }
449
522
 
450
523
  const wasRaw = process.stdin.isRaw;
451
524
  if (typeof process.stdin.setRawMode === 'function') {
@@ -460,8 +533,10 @@ export class ApprovalManager {
460
533
  if (typeof process.stdin.setRawMode === 'function') {
461
534
  process.stdin.setRawMode(wasRaw || false);
462
535
  }
463
- if (this._rl) this._rl.resume();
464
- if (this._execResume) this._execResume();
536
+ if (managesInput) {
537
+ if (this._rl) this._rl.resume();
538
+ if (this._execResume) this._execResume();
539
+ }
465
540
  };
466
541
  const finish = () => {
467
542
  cleanup();
@@ -535,6 +610,7 @@ function promptForLog(tool, args, tier, why) {
535
610
  function approvalTitleForLog(tier) {
536
611
  switch (tier) {
537
612
  case TIERS.SENSITIVE_READ:
613
+ case TIERS.PROTECTED_EDIT:
538
614
  case TIERS.SHELL_DANGEROUS:
539
615
  case TIERS.DESTRUCTIVE:
540
616
  return 'EXPLICIT APPROVAL';
@@ -8,7 +8,7 @@ export function buildFileDiff({
8
8
  after = '',
9
9
  cwd = process.cwd(),
10
10
  context = 3,
11
- maxDiffLines = 400,
11
+ maxDiffLines = Infinity,
12
12
  } = {}) {
13
13
  const oldText = before == null ? '' : String(before);
14
14
  const newText = after == null ? '' : String(after);
@@ -14,12 +14,15 @@
14
14
  * intent can't be hidden behind a friendly description.
15
15
  */
16
16
 
17
+ import { isApprovalRequiredWritePath, isSensitiveConfigPath } from './safety.mjs';
18
+
17
19
  // ── Tier enum ────────────────────────────────────────────────────────────
18
20
 
19
21
  export const TIERS = Object.freeze({
20
22
  READ: 'read',
21
23
  SENSITIVE_READ: 'sensitive-read',
22
24
  LOCAL_EDIT: 'local-edit',
25
+ PROTECTED_EDIT: 'protected-edit',
23
26
  SHELL_SAFE: 'shell-safe',
24
27
  SHELL_MEDIUM: 'shell-medium',
25
28
  SHELL_DANGEROUS: 'shell-dangerous',
@@ -38,6 +41,7 @@ export const BEHAVIOR = Object.freeze({
38
41
  [TIERS.READ]: 'auto',
39
42
  [TIERS.SENSITIVE_READ]: 'prompt-explicit',
40
43
  [TIERS.LOCAL_EDIT]: 'auto-with-undo',
44
+ [TIERS.PROTECTED_EDIT]: 'prompt-explicit',
41
45
  [TIERS.SHELL_SAFE]: 'auto',
42
46
  [TIERS.SHELL_MEDIUM]: 'prompt-safe',
43
47
  [TIERS.SHELL_DANGEROUS]: 'prompt-explicit',
@@ -77,6 +81,14 @@ const LOCAL_EDIT_TOOLS = new Set([
77
81
  'workflow_create_multi', 'workflow_sync_multi',
78
82
  ]);
79
83
 
84
+ const WRITE_PATH_KEYS = [
85
+ 'path', 'file_path', 'file', 'target',
86
+ ];
87
+
88
+ const WRITE_PATH_ARRAY_KEYS = [
89
+ 'paths', 'file_paths', 'targets',
90
+ ];
91
+
80
92
  const DESTRUCTIVE_TOOLS = new Set([
81
93
  'delete_file', 'skill_remove',
82
94
  ]);
@@ -219,6 +231,7 @@ const TIER_ORDER = [
219
231
  TIERS.SENSITIVE_READ,
220
232
  TIERS.SHELL_SAFE,
221
233
  TIERS.LOCAL_EDIT,
234
+ TIERS.PROTECTED_EDIT,
222
235
  TIERS.NETWORK,
223
236
  TIERS.SHELL_MEDIUM,
224
237
  TIERS.DESTRUCTIVE,
@@ -243,7 +256,9 @@ export function classify(tool, args = {}) {
243
256
  if (READ_TOOLS.has(tool)) {
244
257
  return isSensitiveRead(args) ? TIERS.SENSITIVE_READ : TIERS.READ;
245
258
  }
246
- if (LOCAL_EDIT_TOOLS.has(tool)) return TIERS.LOCAL_EDIT;
259
+ if (LOCAL_EDIT_TOOLS.has(tool)) {
260
+ return isApprovalRequiredWrite(args) ? TIERS.PROTECTED_EDIT : TIERS.LOCAL_EDIT;
261
+ }
247
262
  if (DESTRUCTIVE_TOOLS.has(tool)) return TIERS.DESTRUCTIVE;
248
263
  if (NETWORK_TOOLS.has(tool)) return TIERS.NETWORK;
249
264
 
@@ -273,6 +288,7 @@ export function label(tier) {
273
288
  case TIERS.READ: return 'READ';
274
289
  case TIERS.SENSITIVE_READ: return 'SENSITIVE-READ';
275
290
  case TIERS.LOCAL_EDIT: return 'LOCAL-EDIT';
291
+ case TIERS.PROTECTED_EDIT: return 'PROTECTED-EDIT';
276
292
  case TIERS.SHELL_SAFE: return 'SHELL-SAFE';
277
293
  case TIERS.SHELL_MEDIUM: return 'SHELL-MEDIUM';
278
294
  case TIERS.SHELL_DANGEROUS: return 'SHELL-DANGEROUS';
@@ -310,11 +326,15 @@ export function isSensitiveReadPath(filePath = '') {
310
326
 
311
327
  const parts = normalized.split('/').filter(Boolean);
312
328
  const base = parts[parts.length - 1] || normalized;
313
- if (base === '.env') return true;
329
+ if (isSensitiveConfigPath(base)) return true;
314
330
  if (/\.pem$/i.test(base)) return true;
315
331
  return parts.includes('secrets');
316
332
  }
317
333
 
334
+ export function isApprovalRequiredWrite(args = {}) {
335
+ return extractWritePaths(args).some(isApprovalRequiredWritePath);
336
+ }
337
+
318
338
  function extractReadPaths(args = {}) {
319
339
  const paths = [];
320
340
  for (const key of READ_PATH_KEYS) {
@@ -335,3 +355,34 @@ function extractReadPaths(args = {}) {
335
355
  }
336
356
  return paths;
337
357
  }
358
+
359
+ function extractWritePaths(args = {}) {
360
+ const paths = [];
361
+ for (const key of WRITE_PATH_KEYS) {
362
+ if (typeof args[key] === 'string') paths.push(args[key]);
363
+ }
364
+ for (const key of WRITE_PATH_ARRAY_KEYS) {
365
+ const value = args[key];
366
+ if (!Array.isArray(value)) continue;
367
+ for (const item of value) {
368
+ if (typeof item === 'string') {
369
+ paths.push(item);
370
+ } else if (item && typeof item === 'object') {
371
+ for (const nestedKey of WRITE_PATH_KEYS) {
372
+ if (typeof item[nestedKey] === 'string') paths.push(item[nestedKey]);
373
+ }
374
+ }
375
+ }
376
+ }
377
+ if (Array.isArray(args.files)) {
378
+ for (const item of args.files) {
379
+ if (typeof item === 'string') paths.push(item);
380
+ else if (item && typeof item === 'object') {
381
+ for (const nestedKey of WRITE_PATH_KEYS) {
382
+ if (typeof item[nestedKey] === 'string') paths.push(item[nestedKey]);
383
+ }
384
+ }
385
+ }
386
+ }
387
+ return paths;
388
+ }
@@ -28,6 +28,18 @@ const PROTECTED_NAMES = new Set([
28
28
  'go.sum',
29
29
  ]);
30
30
 
31
+ const APPROVAL_REQUIRED_WRITE_NAMES = new Set([
32
+ '.env',
33
+ '.env.local',
34
+ '.env.production',
35
+ 'package.json',
36
+ 'package-lock.json',
37
+ 'yarn.lock',
38
+ 'pnpm-lock.yaml',
39
+ 'Cargo.lock',
40
+ 'go.sum',
41
+ ]);
42
+
31
43
  /** Directory names that indicate a source root — never delete the dir itself. */
32
44
  const SOURCE_DIRS = new Set([
33
45
  'src',
@@ -80,13 +92,35 @@ const HIGH_RISK_COMMANDS = [
80
92
 
81
93
  // ── Validation Functions ─────────────────────────────────────
82
94
 
95
+ function basenameFor(filePath = '') {
96
+ return path.basename(String(filePath || '').replace(/\\/g, '/'));
97
+ }
98
+
99
+ function isEnvLikeName(name = '') {
100
+ return /^\.env(?:\.|$)/.test(String(name || ''));
101
+ }
102
+
103
+ function isProtectedName(name = '') {
104
+ return PROTECTED_NAMES.has(name) || isEnvLikeName(name);
105
+ }
106
+
107
+ export function isSensitiveConfigPath(filePath = '') {
108
+ return isEnvLikeName(basenameFor(filePath));
109
+ }
110
+
111
+ export function isApprovalRequiredWritePath(filePath = '') {
112
+ const basename = basenameFor(filePath);
113
+ return isSensitiveConfigPath(filePath) || APPROVAL_REQUIRED_WRITE_NAMES.has(basename);
114
+ }
115
+
83
116
  /**
84
117
  * Check if a path is protected (should not be deleted/overwritten entirely).
85
118
  * @param {string} filePath - Absolute path
86
119
  * @param {string} cwd - Current working directory
87
- * @returns {{ safe: boolean, reason?: string }}
120
+ * @param {{ allowApprovalRequiredWrite?: boolean }} options
121
+ * @returns {{ safe: boolean, reason?: string, requiresApproval?: boolean, sensitive?: boolean, protected?: boolean }}
88
122
  */
89
- export function validatePath(filePath, cwd = process.cwd()) {
123
+ export function validatePath(filePath, cwd = process.cwd(), options = {}) {
90
124
  if (!filePath) return { safe: false, reason: 'Empty path' };
91
125
 
92
126
  const resolved = path.resolve(cwd, filePath);
@@ -100,7 +134,16 @@ export function validatePath(filePath, cwd = process.cwd()) {
100
134
  }
101
135
 
102
136
  // Block protected file names at any level
103
- if (PROTECTED_NAMES.has(basename) && !resolved.includes('node_modules/')) {
137
+ if (isProtectedName(basename) && !resolved.includes('node_modules/')) {
138
+ if (options.allowApprovalRequiredWrite && isApprovalRequiredWritePath(resolved)) {
139
+ return {
140
+ safe: true,
141
+ reason: `Approval required for protected path: ${basename}`,
142
+ requiresApproval: true,
143
+ protected: true,
144
+ sensitive: isSensitiveConfigPath(resolved),
145
+ };
146
+ }
104
147
  return { safe: false, reason: `Protected path: ${basename}` };
105
148
  }
106
149
 
@@ -172,16 +215,29 @@ export function validateShellCommand(command) {
172
215
  * @returns {{ safe: boolean, reason?: string }}
173
216
  */
174
217
  export function validateWrite(filePath, content, cwd = process.cwd()) {
175
- const pathCheck = validatePath(filePath, cwd);
218
+ const pathCheck = validatePath(filePath, cwd, { allowApprovalRequiredWrite: true });
176
219
  if (!pathCheck.safe) return pathCheck;
177
220
 
178
221
  const resolved = path.resolve(cwd, filePath);
222
+ const basename = path.basename(resolved);
179
223
 
180
224
  // Don't allow overwriting .git internals
181
225
  if (resolved.includes('/.git/')) {
182
226
  return { safe: false, reason: 'Cannot write to .git directory' };
183
227
  }
184
228
 
229
+ if (pathCheck.requiresApproval) {
230
+ return {
231
+ safe: true,
232
+ reason: pathCheck.sensitive
233
+ ? `Sensitive config write requires approval: ${basename}`
234
+ : `Protected file write requires approval: ${basename}`,
235
+ requiresApproval: true,
236
+ protected: true,
237
+ sensitive: pathCheck.sensitive,
238
+ };
239
+ }
240
+
185
241
  // Warn if writing a very large file (> 1MB)
186
242
  if (content && content.length > 1_000_000) {
187
243
  return { safe: true, reason: `Large file write: ${(content.length / 1024).toFixed(0)}KB` };
@@ -196,6 +252,7 @@ export function validateWrite(filePath, content, cwd = process.cwd()) {
196
252
  export function getSafetyRules() {
197
253
  return {
198
254
  protectedNames: [...PROTECTED_NAMES],
255
+ approvalRequiredWriteNames: [...APPROVAL_REQUIRED_WRITE_NAMES],
199
256
  sourceDirs: [...SOURCE_DIRS],
200
257
  blockedPatterns: DANGEROUS_SHELL_PATTERNS.length,
201
258
  highRiskPatterns: HIGH_RISK_COMMANDS.length,
@@ -10,7 +10,7 @@
10
10
 
11
11
  import { createToolRegistry } from '../tools/registry.mjs';
12
12
  import { detectCommandType, filterOutput } from './output-filter.mjs';
13
- import { validatePath, validateDelete, validateShellCommand, validateWrite } from './safety.mjs';
13
+ import { isSensitiveConfigPath, validatePath, validateDelete, validateShellCommand, validateWrite } from './safety.mjs';
14
14
  import { classifyCommand, isExitCodeError } from '../permissions/command-classifier.mjs';
15
15
  import { analyzeCode } from '../context/ast-parser.mjs';
16
16
  import { ProjectRegistry } from '../tools/project-overview.mjs';
@@ -366,18 +366,35 @@ export function createToolExecutor({
366
366
  return result;
367
367
  }
368
368
 
369
+ function buildResultFileDiff(filePath, before, after) {
370
+ const diff = buildFileDiff({
371
+ filePath,
372
+ before,
373
+ after,
374
+ cwd: projectRootFor(filePath),
375
+ });
376
+ if (!isSensitiveConfigPath(filePath)) return diff;
377
+ return {
378
+ ...diff,
379
+ hunks: [],
380
+ unified: '',
381
+ redacted: true,
382
+ sensitive: true,
383
+ redaction_reason: 'Sensitive config diff redacted',
384
+ };
385
+ }
386
+
369
387
  function attachFileDiff(result, filePath, before, after) {
370
388
  try {
371
- const diff = buildFileDiff({
372
- filePath,
373
- before,
374
- after,
375
- cwd: projectRootFor(filePath),
376
- });
389
+ const diff = buildResultFileDiff(filePath, before, after);
377
390
  result.file_diff = diff;
378
391
  result.diff = diff.unified;
379
392
  result.lines_added = diff.lines_added;
380
393
  result.lines_removed = diff.lines_removed;
394
+ if (diff.redacted) {
395
+ result.output = `File updated: ${diff.relative_path || filePath}\nDiff redacted for sensitive config file.`;
396
+ result.redacted = true;
397
+ }
381
398
  } catch { /* best effort */ }
382
399
  return result;
383
400
  }
@@ -821,12 +838,7 @@ export function createToolExecutor({
821
838
 
822
839
  await occRegistry.call('Write', { file_path: filePath, content });
823
840
  const after = readTextIfExists(filePath);
824
- diffs.push(buildFileDiff({
825
- filePath,
826
- before,
827
- after,
828
- cwd: projectRootFor(filePath),
829
- }));
841
+ diffs.push(buildResultFileDiff(filePath, before, after));
830
842
  updateProjectIndex(filePath);
831
843
  results.push(rawPath);
832
844
  } catch (err) {
@@ -96,11 +96,13 @@ export class TrustStore {
96
96
  }
97
97
  }
98
98
  if ((reask.on_risk_increase ?? this.policy.hitl?.reaskOnRiskIncrease) && rule.tier && tier) {
99
- const order = [TIERS.READ, TIERS.SHELL_SAFE, TIERS.LOCAL_EDIT, TIERS.SHELL_MEDIUM, TIERS.NETWORK, TIERS.SHELL_DANGEROUS, TIERS.DESTRUCTIVE];
99
+ const order = [TIERS.READ, TIERS.SHELL_SAFE, TIERS.LOCAL_EDIT, TIERS.PROTECTED_EDIT, TIERS.SHELL_MEDIUM, TIERS.NETWORK, TIERS.SHELL_DANGEROUS, TIERS.DESTRUCTIVE];
100
100
  if (order.indexOf(tier) > order.indexOf(rule.tier)) return 'risk tier increased';
101
101
  }
102
- if ((this.policy.hitl?.alwaysAskForDangerous ?? true) && (tier === TIERS.SHELL_DANGEROUS || tier === TIERS.DESTRUCTIVE)) {
103
- return 'dangerous tier requires fresh approval';
102
+ if ((this.policy.hitl?.alwaysAskForDangerous ?? true) && (tier === TIERS.SHELL_DANGEROUS || tier === TIERS.DESTRUCTIVE || tier === TIERS.PROTECTED_EDIT)) {
103
+ return tier === TIERS.PROTECTED_EDIT
104
+ ? 'protected edit requires fresh approval'
105
+ : 'dangerous tier requires fresh approval';
104
106
  }
105
107
  if ((reask.on_command_shape_change ?? this.policy.hitl?.reaskOnCommandShapeChange) && rule.command_shape && args.command) {
106
108
  if (rule.command_shape !== commandShape(args.command)) return 'command shape changed';
package/src/index.mjs CHANGED
@@ -35,7 +35,7 @@ import { printBanner, printProjectInfo, printHints, printAuthStatus, printStyled
35
35
  import { ContextRetriever } from './context/retriever.mjs';
36
36
  import { loadSettings } from './config/settings.mjs';
37
37
 
38
- const VERSION = '2.6.14';
38
+ const VERSION = '2.6.15';
39
39
 
40
40
  // ── Arg Parsing (consolidated from index.mjs + cli-args.mjs) ──
41
41