@miphamai/cli 0.38.0 → 0.39.0
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/bin/mipham.ts +25 -0
- package/package.json +1 -1
- package/src/agent/sub-agent.ts +4 -3
- package/src/config/defaults.ts +19 -1
- package/src/config/keys-manager.ts +11 -1
- package/src/config/loader.ts +20 -10
- package/src/core/context.ts +86 -56
- package/src/core/credential-masker/output-scrub.ts +5 -0
- package/src/core/crsi-sandbox.ts +19 -7
- package/src/core/engine.ts +50 -33
- package/src/core/instructions.ts +4 -42
- package/src/core/permission.ts +7 -1
- package/src/core/red-team.ts +4 -4
- package/src/core/self-critique.ts +3 -1
- package/src/core/session-log.ts +225 -0
- package/src/core/session-store.ts +119 -34
- package/src/daemon/auth.ts +9 -6
- package/src/daemon/cors.ts +24 -16
- package/src/daemon/rate-limiter.ts +4 -0
- package/src/daemon/server.ts +24 -3
- package/src/index.tsx +54 -28
- package/src/mcp/http-transport.ts +2 -0
- package/src/mcp/oauth.ts +1 -1
- package/src/providers/llm-replay.ts +36 -0
- package/src/providers/llm.ts +16 -0
- package/src/providers/registry.ts +2 -1
- package/src/security/gate.ts +10 -0
- package/src/security/url.ts +37 -14
- package/src/shared/constants.ts +4 -486
- package/src/shared/types.ts +6 -437
- package/src/skills/fork-executor.ts +3 -1
- package/src/skills/loader.ts +2 -1
- package/src/skills/seam.ts +18 -0
- package/src/tools/agent/agent.ts +1 -0
- package/src/tools/agent/skill.ts +1 -0
- package/src/tools/exec/bash.ts +140 -128
- package/src/tools/exec/git.ts +17 -0
- package/src/tools/file/read.ts +73 -66
- package/src/tools/file/write.ts +1 -1
- package/src/tools/index.ts +54 -132
- package/src/tools/network/web-fetch.ts +41 -26
- package/src/tools/seam.ts +25 -0
- package/src/tools/validation.ts +91 -0
- package/src/ui/commands.ts +17 -5
- package/src/vajra/compose/assemble.ts +19 -0
- package/src/vajra/compose/bundle.ts +39 -0
- package/src/vajra/compose/dump.ts +5 -0
- package/src/vajra/compose/index.ts +4 -0
- package/src/vajra/compose/mount.ts +30 -0
- package/src/vajra/context.ts +183 -0
- package/src/vajra/events.ts +13 -0
- package/src/vajra/index.ts +4 -0
- package/src/vajra/leaf/plan-runner.ts +94 -0
- package/src/vajra/service.ts +15 -0
- package/src/workflow/primitives/agent.ts +3 -1
- package/src/workflow/runtime.ts +23 -15
- package/src/workflow/sandbox.ts +51 -9
- package/src/core/compaction-progress.ts +0 -205
- package/src/core/context-compact.ts +0 -124
- package/src/core/context-drain.ts +0 -113
package/src/tools/exec/bash.ts
CHANGED
|
@@ -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
|
-
|
|
6
|
-
|
|
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
|
|
129
|
-
//
|
|
130
|
-
|
|
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(
|
|
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
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
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
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
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
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
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
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
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
|
-
|
|
388
|
-
|
|
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
|
-
|
|
391
|
-
const
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
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
|
-
|
|
400
|
-
|
|
401
|
-
if (
|
|
402
|
-
|
|
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:
|
|
416
|
-
error: `
|
|
431
|
+
content: '',
|
|
432
|
+
error: `Command failed: ${String(err)}`,
|
|
417
433
|
}
|
|
418
434
|
}
|
|
435
|
+
},
|
|
436
|
+
}
|
|
437
|
+
}
|
|
419
438
|
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
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
|
}
|
package/src/tools/exec/git.ts
CHANGED
|
@@ -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
|
/**
|
package/src/tools/file/read.ts
CHANGED
|
@@ -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
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
49
|
-
|
|
46
|
+
// ── Read tracking: mark file as read for Write tool safety ──
|
|
47
|
+
ctx.readFiles?.add(filePath)
|
|
50
48
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
}
|
package/src/tools/file/write.ts
CHANGED
|
@@ -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) ??
|
|
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,
|