@miphamai/cli 0.38.0 → 0.39.1

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 (62) hide show
  1. package/bin/mipham.ts +25 -0
  2. package/package.json +1 -1
  3. package/src/agent/sub-agent.ts +4 -3
  4. package/src/config/defaults.ts +19 -1
  5. package/src/config/keys-manager.ts +11 -1
  6. package/src/config/loader.ts +20 -10
  7. package/src/core/context.ts +86 -56
  8. package/src/core/credential-masker/output-scrub.ts +5 -0
  9. package/src/core/crsi-sandbox.ts +19 -7
  10. package/src/core/engine.ts +50 -33
  11. package/src/core/instructions.ts +4 -42
  12. package/src/core/permission.ts +7 -1
  13. package/src/core/red-team.ts +4 -4
  14. package/src/core/self-critique.ts +3 -1
  15. package/src/core/session-log.ts +225 -0
  16. package/src/core/session-store.ts +119 -34
  17. package/src/daemon/auth.ts +9 -6
  18. package/src/daemon/cors.ts +24 -16
  19. package/src/daemon/rate-limiter.ts +4 -0
  20. package/src/daemon/server.ts +24 -3
  21. package/src/index.tsx +54 -28
  22. package/src/mcp/http-transport.ts +2 -0
  23. package/src/mcp/oauth.ts +1 -1
  24. package/src/providers/llm-replay.ts +36 -0
  25. package/src/providers/llm.ts +16 -0
  26. package/src/providers/registry.ts +2 -1
  27. package/src/security/gate.ts +10 -0
  28. package/src/security/url.ts +37 -14
  29. package/src/shared/constants.ts +40 -54
  30. package/src/shared/mipham-models.json +50 -0
  31. package/src/shared/types.ts +53 -49
  32. package/src/skills/fork-executor.ts +3 -1
  33. package/src/skills/loader.ts +2 -1
  34. package/src/skills/seam.ts +18 -0
  35. package/src/tools/agent/agent.ts +1 -0
  36. package/src/tools/agent/skill.ts +1 -0
  37. package/src/tools/exec/bash.ts +140 -128
  38. package/src/tools/exec/git.ts +17 -0
  39. package/src/tools/file/read.ts +73 -66
  40. package/src/tools/file/write.ts +1 -1
  41. package/src/tools/index.ts +54 -132
  42. package/src/tools/network/web-fetch.ts +41 -26
  43. package/src/tools/seam.ts +25 -0
  44. package/src/tools/validation.ts +91 -0
  45. package/src/ui/commands.ts +17 -5
  46. package/src/ui/input.tsx +8 -1
  47. package/src/vajra/compose/assemble.ts +19 -0
  48. package/src/vajra/compose/bundle.ts +39 -0
  49. package/src/vajra/compose/dump.ts +5 -0
  50. package/src/vajra/compose/index.ts +4 -0
  51. package/src/vajra/compose/mount.ts +30 -0
  52. package/src/vajra/context.ts +183 -0
  53. package/src/vajra/events.ts +13 -0
  54. package/src/vajra/index.ts +4 -0
  55. package/src/vajra/leaf/plan-runner.ts +94 -0
  56. package/src/vajra/service.ts +15 -0
  57. package/src/workflow/primitives/agent.ts +3 -1
  58. package/src/workflow/runtime.ts +23 -15
  59. package/src/workflow/sandbox.ts +51 -9
  60. package/src/core/compaction-progress.ts +0 -205
  61. package/src/core/context-compact.ts +0 -124
  62. package/src/core/context-drain.ts +0 -113
@@ -1,13 +1,9 @@
1
1
  import type { ToolDefinition, CredentialMaskingConfig } from '../../shared/index.ts'
2
2
  import { sanitizeCommand } from '../../shared/sanitize.ts'
3
3
  import { DANGEROUS_GIT_PATTERNS } from './git.ts'
4
-
5
- // Injected at startup — set via index.tsx
6
- let credentialConfig: CredentialMaskingConfig | undefined
7
-
8
- export function setCredentialMaskingConfigForBash(config: CredentialMaskingConfig): void {
9
- credentialConfig = config
10
- }
4
+ import type { Service } from '../../vajra'
5
+ import { toolKey } from '../seam'
6
+ import { withValidation } from '../validation'
11
7
 
12
8
  // ── Dangerous command patterns ──
13
9
  const BLOCKED_PATTERNS = [
@@ -15,6 +11,12 @@ const BLOCKED_PATTERNS = [
15
11
  /\brm\s+-rf\s+\/(\s|$)/,
16
12
  /\brm\s+-rf\s+\/\*\s*$/,
17
13
  /\bsudo\s+rm\s+.*\//,
14
+ // rm -rf on home directory or any absolute path (relative dirs like node_modules stay allowed)
15
+ /\brm\s+-(?:rf|fr)\s+~(?:$|\s|\/)/,
16
+ /\brm\s+-(?:rf|fr)\s+\/(?![\s*])/,
17
+ // rm -rf on dangerous cwd globs
18
+ /\brm\s+-(?:rf|fr)\s+\*\s*$/,
19
+ /\brm\s+-(?:rf|fr)\s+\.\s*$/,
18
20
  // Filesystem manipulation
19
21
  /\bmkfs\./,
20
22
  /\bdd\s+if=/,
@@ -125,14 +127,11 @@ export function isBlocked(command: string): string | null {
125
127
  return `Command "${sanitizedFirstWord}" rejected by security policy.`
126
128
  }
127
129
 
128
- // Detect dangerous git operations invoked via Bash. The Git tool guards these,
129
- // but shelling out with `git push --force` would bypass it — apply the same
130
- // pattern list to any Bash command that starts with a git invocation.
131
- const gitInvocation = normalized.trim().match(/^(?:sudo\s+)?git(?:\s+|$)(.*)$/i)
132
- if (gitInvocation) {
133
- const subcommand = gitInvocation[1] || ''
130
+ // Detect dangerous git operations invoked via Bash — anywhere in the command,
131
+ // so `echo ok && git push --force` is still caught (not just `^git`).
132
+ if (/(?:^|[\s;&|])(?:sudo\s+)?(?:git|gh)\s+/.test(normalized)) {
134
133
  for (const { pattern, description } of DANGEROUS_GIT_PATTERNS) {
135
- if (pattern.test(subcommand)) {
134
+ if (pattern.test(normalized)) {
136
135
  return `Dangerous git command blocked: "${description}". Run manually if intended.`
137
136
  }
138
137
  }
@@ -298,136 +297,149 @@ function parseErrorLocations(stderr: string): ErrorLocation[] {
298
297
  return unique.slice(0, 10)
299
298
  }
300
299
 
301
- export const bashTool: ToolDefinition = {
302
- name: 'Bash',
303
- description:
304
- 'Execute a bash command. Returns stdout and stderr. Timeout: 120s. Use with caution.',
305
- category: 'exec',
306
- permission: 'ask',
307
- parameters: {
308
- type: 'object',
309
- properties: {
310
- command: { type: 'string', description: 'The bash command to execute' },
311
- description: {
312
- type: 'string',
313
- description: 'What this command does (for audit log)',
314
- },
315
- timeout: {
316
- type: 'integer',
317
- description: 'Timeout in milliseconds (max 600000)',
300
+ export function createBashTool(credentialConfig?: CredentialMaskingConfig): ToolDefinition {
301
+ return {
302
+ name: 'Bash',
303
+ description:
304
+ 'Execute a bash command. Returns stdout and stderr. Timeout: 120s. Use with caution.',
305
+ category: 'exec',
306
+ permission: 'ask',
307
+ parameters: {
308
+ type: 'object',
309
+ properties: {
310
+ command: { type: 'string', description: 'The bash command to execute' },
311
+ description: {
312
+ type: 'string',
313
+ description: 'What this command does (for audit log)',
314
+ },
315
+ timeout: {
316
+ type: 'integer',
317
+ description: 'Timeout in milliseconds (max 600000)',
318
+ },
318
319
  },
320
+ required: ['command'],
319
321
  },
320
- required: ['command'],
321
- },
322
- async execute(params, ctx) {
323
- const command = params.command as string
324
- const timeout = Math.min((params.timeout as number) || 120_000, 600_000)
325
-
326
- // P0-4: Worktree isolation — block cd escape attempts
327
- const WORKTREE_MARKER = '.claude/worktrees/'
328
- if (ctx.cwd.includes(WORKTREE_MARKER)) {
329
- const worktreeRoot = ctx.cwd.substring(0, ctx.cwd.indexOf(WORKTREE_MARKER))
330
- // Detect cd to absolute paths outside the worktree
331
- const cdEscapePattern = /\bcd\s+(?:"([^"]+)"|'([^']+)'|([^\s;|&]+))/
332
- const cdMatch = command.match(cdEscapePattern)
333
- if (cdMatch) {
334
- const target = cdMatch[1] || cdMatch[2] || cdMatch[3] || ''
335
- // Resolve relative to cwd
336
- const resolved = target.startsWith('/')
337
- ? target
338
- : `${ctx.cwd}/${target}`.replace(/\/\.\//g, '/')
339
- if (!resolved.startsWith(ctx.cwd) && !resolved.startsWith(worktreeRoot + '/')) {
340
- return {
341
- success: false,
342
- content: '',
343
- error:
344
- `Worktree isolation: cannot cd outside worktree directory. ` +
345
- `Attempted: ${target}. Use tools within the worktree only.`,
322
+ async execute(params, ctx) {
323
+ const command = params.command as string
324
+ const timeout = Math.min((params.timeout as number) || 120_000, 600_000)
325
+
326
+ // P0-4: Worktree isolation — block cd escape attempts
327
+ const WORKTREE_MARKER = '.claude/worktrees/'
328
+ if (ctx.cwd.includes(WORKTREE_MARKER)) {
329
+ const worktreeRoot = ctx.cwd.substring(0, ctx.cwd.indexOf(WORKTREE_MARKER))
330
+ // Detect cd to absolute paths outside the worktree
331
+ const cdEscapePattern = /\bcd\s+(?:"([^"]+)"|'([^']+)'|([^\s;|&]+))/
332
+ const cdMatch = command.match(cdEscapePattern)
333
+ if (cdMatch) {
334
+ const target = cdMatch[1] || cdMatch[2] || cdMatch[3] || ''
335
+ // Resolve relative to cwd
336
+ const resolved = target.startsWith('/')
337
+ ? target
338
+ : `${ctx.cwd}/${target}`.replace(/\/\.\//g, '/')
339
+ if (!resolved.startsWith(ctx.cwd) && !resolved.startsWith(worktreeRoot + '/')) {
340
+ return {
341
+ success: false,
342
+ content: '',
343
+ error:
344
+ `Worktree isolation: cannot cd outside worktree directory. ` +
345
+ `Attempted: ${target}. Use tools within the worktree only.`,
346
+ }
346
347
  }
347
348
  }
348
349
  }
349
- }
350
-
351
- // Security: check command against deny list
352
- const blockedReason = isBlocked(command)
353
- if (blockedReason) {
354
- return { success: false, content: '', error: blockedReason }
355
- }
356
350
 
357
- try {
358
- // ── Credential masking: filter sensitive env vars ──
359
- let spawnEnv: Record<string, string | undefined> | undefined
360
- if (credentialConfig?.enabled && credentialConfig.env_filter.enabled) {
361
- const { filterEnv } = await import('../../core/credential-masker')
362
- spawnEnv = filterEnv(process.env as Record<string, string | undefined>, credentialConfig)
351
+ // Security: check command against deny list
352
+ const blockedReason = isBlocked(command)
353
+ if (blockedReason) {
354
+ return { success: false, content: '', error: blockedReason }
363
355
  }
364
356
 
365
- const proc = Bun.spawn(['bash', '-c', command], {
366
- cwd: ctx.cwd,
367
- stdout: 'pipe',
368
- stderr: 'pipe',
369
- env: spawnEnv,
370
- })
371
-
372
- const timer = setTimeout(() => proc.kill(), timeout)
373
- const rawOutput = await new Response(proc.stdout).text()
374
- const exitCode = await proc.exited
375
- clearTimeout(timer)
376
-
377
- // Read stderr for violation detection and error reporting
378
- const rawStderr = await new Response(proc.stderr).text()
379
-
380
- // ── Credential masking: scrub output ──
381
- let output = rawOutput
382
- if (credentialConfig?.enabled && credentialConfig.output_scrubbing.enabled) {
383
- const { maskOutput } = await import('../../core/credential-masker')
384
- output = maskOutput(rawOutput, credentialConfig)
385
- }
357
+ try {
358
+ // ── Credential masking: filter sensitive env vars ──
359
+ let spawnEnv: Record<string, string | undefined> | undefined
360
+ if (credentialConfig?.enabled && credentialConfig.env_filter.enabled) {
361
+ const { filterEnv } = await import('../../core/credential-masker')
362
+ spawnEnv = filterEnv(process.env as Record<string, string | undefined>, credentialConfig)
363
+ }
386
364
 
387
- // ── Sandbox violation detection ──
388
- const violations = detectViolations(rawStderr)
365
+ const proc = Bun.spawn(['bash', '-c', command], {
366
+ cwd: ctx.cwd,
367
+ stdout: 'pipe',
368
+ stderr: 'pipe',
369
+ env: spawnEnv,
370
+ })
371
+
372
+ const timer = setTimeout(() => proc.kill(), timeout)
373
+ const rawOutput = await new Response(proc.stdout).text()
374
+ const exitCode = await proc.exited
375
+ clearTimeout(timer)
376
+
377
+ // Read stderr for violation detection and error reporting
378
+ const rawStderr = await new Response(proc.stderr).text()
379
+
380
+ // ── Credential masking: scrub output ──
381
+ let output = rawOutput
382
+ if (credentialConfig?.enabled && credentialConfig.output_scrubbing.enabled) {
383
+ const { maskOutput } = await import('../../core/credential-masker')
384
+ output = maskOutput(rawOutput, credentialConfig)
385
+ }
389
386
 
390
- if (exitCode !== 0) {
391
- const stderr =
392
- credentialConfig?.enabled && credentialConfig.output_scrubbing.enabled
393
- ? (await import('../../core/credential-masker')).maskOutput(rawStderr, credentialConfig)
394
- : rawStderr
395
- let errorContent = output.slice(0, 5_000)
396
- if (violations.length > 0) {
397
- errorContent += '\n\n── Sandbox Violations ──\n' + violations.join('\n')
387
+ // ── Sandbox violation detection ──
388
+ const violations = detectViolations(rawStderr)
389
+
390
+ if (exitCode !== 0) {
391
+ const stderr =
392
+ credentialConfig?.enabled && credentialConfig.output_scrubbing.enabled
393
+ ? (await import('../../core/credential-masker')).maskOutput(
394
+ rawStderr,
395
+ credentialConfig,
396
+ )
397
+ : rawStderr
398
+ let errorContent = output.slice(0, 5_000)
399
+ if (violations.length > 0) {
400
+ errorContent += '\n\n── Sandbox Violations ──\n' + violations.join('\n')
401
+ }
402
+ // Vibe coding fix: auto-parse error locations from stderr
403
+ const errorLocations = parseErrorLocations(rawStderr)
404
+ if (errorLocations.length > 0) {
405
+ errorContent +=
406
+ '\n\n── Error Locations (for quick fix) ──\n' +
407
+ errorLocations
408
+ .map(
409
+ (l) =>
410
+ ` ${l.file}:${l.line}` +
411
+ (l.col ? `:${l.col}` : '') +
412
+ (l.message ? ` — ${l.message}` : ''),
413
+ )
414
+ .join('\n')
415
+ }
416
+ return {
417
+ success: false,
418
+ content: errorContent,
419
+ error: `Exit code ${exitCode}: ${stderr.slice(0, 1_000)}`,
420
+ }
398
421
  }
399
- // Vibe coding fix: auto-parse error locations from stderr
400
- const errorLocations = parseErrorLocations(rawStderr)
401
- if (errorLocations.length > 0) {
402
- errorContent +=
403
- '\n\n── Error Locations (for quick fix) ──\n' +
404
- errorLocations
405
- .map(
406
- (l) =>
407
- ` ${l.file}:${l.line}` +
408
- (l.col ? `:${l.col}` : '') +
409
- (l.message ? ` — ${l.message}` : ''),
410
- )
411
- .join('\n')
422
+
423
+ let successContent = output.slice(0, 100_000) || '(no output)'
424
+ if (violations.length > 0) {
425
+ successContent += '\n\n── Sandbox Violations ──\n' + violations.join('\n')
412
426
  }
427
+ return { success: true, content: successContent }
428
+ } catch (err) {
413
429
  return {
414
430
  success: false,
415
- content: errorContent,
416
- error: `Exit code ${exitCode}: ${stderr.slice(0, 1_000)}`,
431
+ content: '',
432
+ error: `Command failed: ${String(err)}`,
417
433
  }
418
434
  }
435
+ },
436
+ }
437
+ }
419
438
 
420
- let successContent = output.slice(0, 100_000) || '(no output)'
421
- if (violations.length > 0) {
422
- successContent += '\n\n── Sandbox Violations ──\n' + violations.join('\n')
423
- }
424
- return { success: true, content: successContent }
425
- } catch (err) {
426
- return {
427
- success: false,
428
- content: '',
429
- error: `Command failed: ${String(err)}`,
430
- }
431
- }
439
+ export const bashToolService: Service = {
440
+ inject: ['credentials'],
441
+ apply(ctx) {
442
+ const credentialConfig = ctx.get<CredentialMaskingConfig>('credentials')
443
+ ctx.provide(toolKey('Bash'), withValidation(createBashTool(credentialConfig)))
432
444
  },
433
445
  }
@@ -28,6 +28,23 @@ export const DANGEROUS_GIT_PATTERNS: Array<{ pattern: RegExp; description: strin
28
28
  { pattern: /\bstash\s+clear\b/, description: 'stash clear' },
29
29
  // Identity spoofing via config
30
30
  { pattern: /\bconfig\s+.*user\./, description: 'config user.* (identity spoofing)' },
31
+ // Remote manipulation (repo redirection / exfiltration)
32
+ { pattern: /\bremote\s+(?:add|set-url|rename)\b/, description: 'remote add/set-url/rename' },
33
+ // History rewrite
34
+ { pattern: /\bcommit\s+.*--amend\b/, description: 'commit --amend (history rewrite)' },
35
+ {
36
+ pattern: /\bcommit\s+.*(?:--no-verify|-n)\b/,
37
+ description: 'commit --no-verify/-n (hook bypass)',
38
+ },
39
+ // Command-execution vectors via git config
40
+ {
41
+ pattern: /(?:-c\s+|--config[=\s]|config\s+)core\.(?:sshCommand|pager|askpass|editor)/,
42
+ description: 'git core.* command execution (sshCommand/pager/askpass/editor)',
43
+ },
44
+ {
45
+ pattern: /(?:-c\s+|--config[=\s]|config\s+)(?:alias\.|credential\.helper)/,
46
+ description: 'git alias/credential.helper command execution',
47
+ },
31
48
  ]
32
49
 
33
50
  /**
@@ -1,79 +1,86 @@
1
1
  import { readFileSync, existsSync, statSync } from 'node:fs'
2
2
  import type { ToolDefinition, CredentialMaskingConfig } from '../../shared/index.ts'
3
3
  import { resolveSafe } from '../../security/path'
4
+ import type { Service } from '../../vajra'
5
+ import { toolKey } from '../seam'
6
+ import { withValidation } from '../validation'
4
7
 
5
- // Injected at startup — set via index.tsx
6
- let credentialConfig: CredentialMaskingConfig | undefined
7
-
8
- export function setCredentialMaskingConfigForRead(config: CredentialMaskingConfig): void {
9
- credentialConfig = config
10
- }
11
-
12
- export const readTool: ToolDefinition = {
13
- name: 'Read',
14
- description: 'Read a file from the local filesystem. Supports offset and limit for large files.',
15
- category: 'file',
16
- permission: 'auto',
17
- parameters: {
18
- type: 'object',
19
- properties: {
20
- file_path: { type: 'string', description: 'Absolute path to the file to read' },
21
- offset: { type: 'integer', description: 'Line number to start reading from' },
22
- limit: { type: 'integer', description: 'Number of lines to read' },
8
+ export function createReadTool(credentialConfig?: CredentialMaskingConfig): ToolDefinition {
9
+ return {
10
+ name: 'Read',
11
+ description:
12
+ 'Read a file from the local filesystem. Supports offset and limit for large files.',
13
+ category: 'file',
14
+ permission: 'auto',
15
+ parameters: {
16
+ type: 'object',
17
+ properties: {
18
+ file_path: { type: 'string', description: 'Absolute path to the file to read' },
19
+ offset: { type: 'integer', description: 'Line number to start reading from' },
20
+ limit: { type: 'integer', description: 'Number of lines to read' },
21
+ },
22
+ required: ['file_path'],
23
23
  },
24
- required: ['file_path'],
25
- },
26
- async execute(params, ctx) {
27
- const filePath = resolveSafe(ctx.cwd, params.file_path as string)
28
- if (!existsSync(filePath)) {
29
- return { success: false, content: '', error: `File not found: ${filePath}` }
30
- }
31
- const stat = statSync(filePath)
32
- if (stat.isDirectory()) {
33
- return { success: false, content: '', error: `Path is a directory: ${filePath}` }
34
- }
35
- // Prevent OOM on single-line files (e.g. 500MB JSON blob)
36
- const MAX_FILE_SIZE = 50_000_000 // 50 MB
37
- if (stat.size > MAX_FILE_SIZE) {
38
- return {
39
- success: false,
40
- content: '',
41
- error: `File too large (${(stat.size / 1e6).toFixed(1)} MB). Max: 50 MB. Use offset/limit for large files.`,
24
+ async execute(params, ctx) {
25
+ const filePath = resolveSafe(ctx.cwd, params.file_path as string)
26
+ if (!existsSync(filePath)) {
27
+ return { success: false, content: '', error: `File not found: ${filePath}` }
28
+ }
29
+ const stat = statSync(filePath)
30
+ if (stat.isDirectory()) {
31
+ return { success: false, content: '', error: `Path is a directory: ${filePath}` }
42
32
  }
43
- }
44
- const offset = (params.offset as number) || 0
45
- const limit = (params.limit as number) || 2000
46
- const content = readFileSync(filePath, 'utf-8')
33
+ // Prevent OOM on single-line files (e.g. 500MB JSON blob)
34
+ const MAX_FILE_SIZE = 50_000_000 // 50 MB
35
+ if (stat.size > MAX_FILE_SIZE) {
36
+ return {
37
+ success: false,
38
+ content: '',
39
+ error: `File too large (${(stat.size / 1e6).toFixed(1)} MB). Max: 50 MB. Use offset/limit for large files.`,
40
+ }
41
+ }
42
+ const offset = (params.offset as number) || 0
43
+ const limit = (params.limit as number) || 2000
44
+ const content = readFileSync(filePath, 'utf-8')
47
45
 
48
- // ── Read tracking: mark file as read for Write tool safety ──
49
- ctx.readFiles?.add(filePath)
46
+ // ── Read tracking: mark file as read for Write tool safety ──
47
+ ctx.readFiles?.add(filePath)
50
48
 
51
- // ── Credential masking ──
52
- if (credentialConfig) {
53
- const { matchCredentialFile, maskContent, CREDENTIAL_SENTINEL } =
54
- await import('../../core/credential-masker')
55
- const rule = matchCredentialFile(filePath, credentialConfig)
56
- if (rule) {
57
- const masked = maskContent(content, rule)
58
- // Full-file mask: return sentinel immediately (offset/limit don't apply)
59
- if (masked === CREDENTIAL_SENTINEL) {
60
- return { success: true, content: masked }
49
+ // ── Credential masking ──
50
+ if (credentialConfig) {
51
+ const { matchCredentialFile, maskContent, CREDENTIAL_SENTINEL } =
52
+ await import('../../core/credential-masker')
53
+ const rule = matchCredentialFile(filePath, credentialConfig)
54
+ if (rule) {
55
+ const masked = maskContent(content, rule)
56
+ // Full-file mask: return sentinel immediately (offset/limit don't apply)
57
+ if (masked === CREDENTIAL_SENTINEL) {
58
+ return { success: true, content: masked }
59
+ }
60
+ // Extract mode: apply offset/limit on masked content
61
+ const maskedLines = masked.split('\n')
62
+ const maskedSlice = maskedLines.slice(offset, offset + limit)
63
+ const maskedResult = maskedSlice
64
+ .map((l, i) => `${String(offset + i + 1).padStart(6, ' ')}\t${l}`)
65
+ .join('\n')
66
+ return { success: true, content: maskedResult }
61
67
  }
62
- // Extract mode: apply offset/limit on masked content
63
- const maskedLines = masked.split('\n')
64
- const maskedSlice = maskedLines.slice(offset, offset + limit)
65
- const maskedResult = maskedSlice
66
- .map((l, i) => `${String(offset + i + 1).padStart(6, ' ')}\t${l}`)
67
- .join('\n')
68
- return { success: true, content: maskedResult }
69
68
  }
70
- }
71
69
 
72
- const lines = content.split('\n')
73
- const slice = lines.slice(offset, offset + limit)
74
- const result = slice
75
- .map((l, i) => `${String(offset + i + 1).padStart(6, ' ')}\t${l}`)
76
- .join('\n')
77
- return { success: true, content: result }
70
+ const lines = content.split('\n')
71
+ const slice = lines.slice(offset, offset + limit)
72
+ const result = slice
73
+ .map((l, i) => `${String(offset + i + 1).padStart(6, ' ')}\t${l}`)
74
+ .join('\n')
75
+ return { success: true, content: result }
76
+ },
77
+ }
78
+ }
79
+
80
+ export const readToolService: Service = {
81
+ inject: ['credentials'],
82
+ apply(ctx) {
83
+ const credentialConfig = ctx.get<CredentialMaskingConfig>('credentials')
84
+ ctx.provide(toolKey('Read'), withValidation(createReadTool(credentialConfig)))
78
85
  },
79
86
  }
@@ -32,7 +32,7 @@ export const writeTool: ToolDefinition = {
32
32
 
33
33
  // ── Read-before-write safety check ──
34
34
  const fileExists = existsSync(filePath)
35
- const wasRead = ctx.readFiles?.has(filePath) ?? true // if no tracking, allow
35
+ const wasRead = ctx.readFiles?.has(filePath) ?? false // fail closed: no tracking = not read
36
36
  if (fileExists && !wasRead) {
37
37
  return {
38
38
  success: false,