@bahulam/code 2.6.13 → 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.
@@ -272,7 +272,7 @@ export async function runHeadless({ instruction, model, timeout = 300, maxCost,
272
272
  tool_calls: data?.tool_calls || 0,
273
273
  success: data?.success !== false,
274
274
  });
275
- emit({ type: 'sub_agent', ...data });
275
+ emit({ ...data, type: 'sub_agent', agent_type: data?.type || '' });
276
276
  log(`SubAgent done: ${data?.type} (${data?.tool_calls} tools, ${data?.duration_s}s)`);
277
277
  }
278
278
 
@@ -360,8 +360,19 @@ export async function runHeadless({ instruction, model, timeout = 300, maxCost,
360
360
  const subAgentToolBreakdown = countBreakdown(toolCalls.filter(t => t.internal));
361
361
 
362
362
  const subAgentReportedToolCount = subAgents.reduce((sum, sa) => sum + (sa.tool_calls || 0), 0);
363
- const subAgentToolCount = backendSubAgentToolCount ?? Math.max(subAgentReportedToolCount, subAgentForwardedToolCount);
364
- const totalToolCount = backendToolCount ?? (primaryToolCount + subAgentToolCount);
363
+ const observedSubAgentToolCount = Math.max(
364
+ subAgentReportedToolCount,
365
+ subAgentForwardedToolCount,
366
+ );
367
+ const subAgentToolCount = (
368
+ backendSubAgentToolCount && backendSubAgentToolCount > 0
369
+ )
370
+ ? backendSubAgentToolCount
371
+ : observedSubAgentToolCount;
372
+ const computedToolCount = primaryToolCount + subAgentToolCount;
373
+ const totalToolCount = backendToolCount != null
374
+ ? Math.max(backendToolCount, computedToolCount)
375
+ : computedToolCount;
365
376
  const primaryToolTotal = backendPrimaryToolCount ?? (
366
377
  backendToolCount != null
367
378
  ? Math.max(0, totalToolCount - subAgentToolCount)
@@ -21,11 +21,11 @@ const MAX_ITERATIONS = 50;
21
21
  const TOOL_SCHEMAS = [
22
22
  {
23
23
  name: 'shell',
24
- description: 'Run a shell command and return stdout/stderr. Use for: installing packages, running tests, builds, git operations, or any terminal command.',
24
+ description: 'Run one non-interactive shell command and return stdout/stderr. Do not use command substitution, backticks, or $(); run separate simple shell calls instead.',
25
25
  input_schema: {
26
26
  type: 'object',
27
27
  properties: {
28
- command: { type: 'string', description: 'The shell command to execute' },
28
+ command: { type: 'string', description: 'The command to execute. Avoid backticks and $(); split dependent checks into separate commands.' },
29
29
  timeout: { type: 'number', description: 'Timeout in milliseconds (default: 120000)' },
30
30
  },
31
31
  required: ['command'],
@@ -478,6 +478,7 @@ export class LocalAgent {
478
478
  'You are Bahulam Code, Bahulam\'s AI coding agent running in local mode.',
479
479
  'You have access to tools for reading, writing, and executing code.',
480
480
  'Use tools to accomplish the user\'s request. Be concise and direct.',
481
+ 'For shell tools, never use command substitution, backticks, or $(). Run separate simple commands and carry values forward in text.',
481
482
  ];
482
483
  if (context.cwd) parts.push(`Working directory: ${context.cwd}`);
483
484
  if (context.gitBranch) parts.push(`Git branch: ${context.gitBranch}`);
@@ -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';
@@ -74,6 +74,14 @@ export function createToolExecutor({
74
74
  return path.resolve(cwd, value);
75
75
  }
76
76
 
77
+ function blockedShellOutput(reason) {
78
+ const text = String(reason || 'Blocked by shell safety policy').trim();
79
+ const hint = /command substitution|backticks|\$\(\)/i.test(text)
80
+ ? 'Retry with separate simple shell commands instead of backticks or $().'
81
+ : 'Work only inside a registered project root.';
82
+ return `BLOCKED: ${text}. ${hint}`;
83
+ }
84
+
77
85
  function longRunningObservationTimeoutMs() {
78
86
  const configured = Number(process.env.KEPLER_LONG_RUNNING_TIMEOUT_MS);
79
87
  return Number.isFinite(configured) && configured > 0 ? configured : 15_000;
@@ -358,18 +366,35 @@ export function createToolExecutor({
358
366
  return result;
359
367
  }
360
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
+
361
387
  function attachFileDiff(result, filePath, before, after) {
362
388
  try {
363
- const diff = buildFileDiff({
364
- filePath,
365
- before,
366
- after,
367
- cwd: projectRootFor(filePath),
368
- });
389
+ const diff = buildResultFileDiff(filePath, before, after);
369
390
  result.file_diff = diff;
370
391
  result.diff = diff.unified;
371
392
  result.lines_added = diff.lines_added;
372
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
+ }
373
398
  } catch { /* best effort */ }
374
399
  return result;
375
400
  }
@@ -578,7 +603,7 @@ export function createToolExecutor({
578
603
  if (!shellCheck.safe) {
579
604
  return {
580
605
  success: false,
581
- output: `BLOCKED: ${shellCheck.reason}. Work only inside a registered project root.`,
606
+ output: blockedShellOutput(shellCheck.reason),
582
607
  _tool: 'shell', _blocked: true,
583
608
  };
584
609
  }
@@ -588,7 +613,7 @@ export function createToolExecutor({
588
613
  if (classification.classification === 'blocked') {
589
614
  return {
590
615
  success: false,
591
- output: `BLOCKED: ${classification.reason}`,
616
+ output: blockedShellOutput(classification.reason),
592
617
  _tool: 'shell', _blocked: true,
593
618
  };
594
619
  }
@@ -813,12 +838,7 @@ export function createToolExecutor({
813
838
 
814
839
  await occRegistry.call('Write', { file_path: filePath, content });
815
840
  const after = readTextIfExists(filePath);
816
- diffs.push(buildFileDiff({
817
- filePath,
818
- before,
819
- after,
820
- cwd: projectRootFor(filePath),
821
- }));
841
+ diffs.push(buildResultFileDiff(filePath, before, after));
822
842
  updateProjectIndex(filePath);
823
843
  results.push(rawPath);
824
844
  } catch (err) {
@@ -1513,7 +1533,9 @@ print('OK: replaced')
1513
1533
  const created = listLocalAgents(process.cwd()).find(agent => agent.slug === result.slug);
1514
1534
  const payload = {
1515
1535
  ...result,
1516
- agent: created ? compactAgentMetadata(created) : null,
1536
+ agent: created
1537
+ ? { ...compactAgentMetadata(created), spec: created.spec }
1538
+ : null,
1517
1539
  next_actions: [
1518
1540
  `Edit ${result.filePath}`,
1519
1541
  `Run /agents sync ${result.slug} when ready`,
@@ -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.13';
38
+ const VERSION = '2.6.15';
39
39
 
40
40
  // ── Arg Parsing (consolidated from index.mjs + cli-args.mjs) ──
41
41