v1.1.0: native diff card client, 200-line paging for read/grep/glob, guard rule against shell edits/reads/searches
This commit is contained in:
1 parent
71953ffb8f
commit
0dae0ee53d
5 files changed
+364
-22
No files matched your search
@@ -38,6 +38,63 @@ const DEFAULT_WEAK = ['kimi', 'mimo', 'minimax', 'deepseek', 'stepfun', 'codex-s
|
||||
|
||||
const isObj = (v) => v !== null && typeof v === 'object' && !Array.isArray(v)
|
||||
|
||||
// Shell patterns the guard rejects. Each entry: [regex, message]. Matching runs per command
|
||||
// segment (split on ; && || |) after quoted strings are blanked, so text inside quotes such as a
|
||||
// commit message or an echo argument cannot trigger it.
|
||||
const SHELL_RULES = [
|
||||
[/(^|\s)sed\s+(-[a-zA-Z]*i|--in-place)/, 'in-place sed edits are not allowed — use the edit tool (hashline patch).'],
|
||||
[/(^|\s)perl\s+-[a-zA-Z]*i/, 'perl -i edits are not allowed — use the edit tool.'],
|
||||
[/(^|\s)(ed|ex)\s/, 'ed/ex edits are not allowed — use the edit tool.'],
|
||||
[/(^|\s)(tee)\s+(-a\s+)?[^-\s]/, 'writing files with tee is not allowed — use the edit tool (or write for new files).'],
|
||||
[/(^|[^0-9&<>])>>?\s*(?!&|\/dev\/(null|stderr|stdout))[^\s|&;]/, 'shell redirection into files is not allowed — use the edit tool (or write for new files).'],
|
||||
[/<<-?\s*['"]?\w+/, 'heredocs are not allowed for writing files — use the edit tool (or write for new files).'],
|
||||
[/(^|\s)(python3?|node|ruby|perl)\s+-[ce]\s/, 'inline interpreter scripts are not allowed for file reads/writes — use read/grep/edit.'],
|
||||
[/(^|\s)(truncate|dd)\s/, 'truncate/dd are not allowed — use the edit tool.'],
|
||||
// A pipe consumer (`… | head`) only trims command output and stays allowed: segments start after the pipe.
|
||||
[/^(cat|head|tail|less|more|bat|nl)\s+(?:-\S+\s+)*[^-\s]/, 'reading files through the shell is not allowed — use the read tool (paged, 200 lines per call).', /^(head|tail)\s+(?:-[a-zA-Z]*\s*\d*\s*|-\d+\s*|--?\w+(=\S+)?\s*)*$/],
|
||||
[/(^|\s)(grep|egrep|fgrep|rg|ag|ack)\s/, 'searching through the shell is not allowed — use the grep tool (paged, hashline-tagged).', /^(grep|egrep|fgrep|rg)\s+(?:-\S+\s+)*(?:''|""|\S+)\s*$/],
|
||||
[/(^|\s)(find|fd|locate)\s/, 'finding files through the shell is not allowed — use the glob tool.'],
|
||||
[/(^|\s)ls\s+(-[a-zA-Z]*R|--recursive)/, 'recursive listings are not allowed — use the glob tool.'],
|
||||
[/(^|\s)(awk|gawk)\s/, 'awk is not allowed for reading or editing files — use read/grep/edit.'],
|
||||
[/(^|\s)sed\s+(-n|--quiet)/, 'sed -n is not allowed for reading files — use the read tool with offset.'],
|
||||
]
|
||||
|
||||
/** Return a denial message when `cmd` mutates, reads or searches files through the shell. */
|
||||
export function shellViolation(cmd) {
|
||||
const blanked = String(cmd)
|
||||
.replace(/'[^']*'/g, "''")
|
||||
.replace(/"(?:\\.|[^"\\])*"/g, '""')
|
||||
// Heredocs and redirections are checked on the whole line (quotes already blanked).
|
||||
// Split into commands; remember which ones read a pipe (their file-less form only filters output).
|
||||
const parts = blanked.split(/(;|&&|\|\||\||\n)/)
|
||||
let piped = false
|
||||
for (let i = 0; i < parts.length; i += 2) {
|
||||
const seg = parts[i].trim().replace(/^(sudo|env\s+\S+=\S+|command|exec|time|nice)\s+/, '')
|
||||
const fromPipe = piped
|
||||
piped = parts[i + 1] === '|'
|
||||
if (!seg) continue
|
||||
for (const [re, msg, pipeOk] of SHELL_RULES) {
|
||||
if (!re.test(seg)) continue
|
||||
if (fromPipe && pipeOk && pipeOk.test(seg)) continue
|
||||
return `Blocked by omp-edit guard: ${msg} (command: ${String(cmd).slice(0, 120)})`
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** Clone a parameters schema, documenting the page cap on `limit`. */
|
||||
function capLimit(params, max) {
|
||||
const p = structuredClone(params ?? { type: 'object', properties: {} })
|
||||
if (p.properties?.limit) p.properties.limit = { ...p.properties.limit, description: `Maximum lines to return (default and max ${max}).` }
|
||||
return p
|
||||
}
|
||||
/** Clone a parameters schema and add an integer `offset` property. */
|
||||
function withOffset(params, description) {
|
||||
const p = structuredClone(params ?? { type: 'object', properties: {} })
|
||||
p.properties = { ...(p.properties ?? {}), offset: { type: 'integer', description } }
|
||||
return p
|
||||
}
|
||||
|
||||
export function resolveMode(config, model) {
|
||||
const m = String(model || '').toLowerCase()
|
||||
for (const [needle, mode] of Object.entries(config.modelVariants || {})) {
|
||||
@@ -61,7 +118,13 @@ export function apply(ctx, config = {}) {
|
||||
replaceRead: true,
|
||||
replaceGrep: true,
|
||||
replaceEdit: true,
|
||||
replaceGlob: true,
|
||||
fuzzy: true,
|
||||
// read/grep/glob return at most this many lines/matches/paths per call.
|
||||
pageLines: 200,
|
||||
// Guard: deny non-hashline mutations and shell-based reading/searching.
|
||||
guard: true,
|
||||
guardTools: ['write', 'str_replace_editor', 'apply_patch'],
|
||||
...config,
|
||||
}
|
||||
|
||||
@@ -431,7 +494,8 @@ export function apply(ctx, config = {}) {
|
||||
function readTool(builtin) {
|
||||
return {
|
||||
...builtin,
|
||||
description: `${builtin.description}${P.READ_HASHLINE_NOTE}`,
|
||||
description: `${builtin.description} Returns at most ${cfg.pageLines} lines per call; continue with offset from the footer.${P.READ_HASHLINE_NOTE}`,
|
||||
parameters: capLimit(builtin.parameters, cfg.pageLines),
|
||||
output: {
|
||||
...builtin.output,
|
||||
schema: { ...builtin.output.schema, properties: { ...builtin.output.schema.properties, tag: { type: 'string' }, rel: { type: 'string' } } },
|
||||
@@ -444,7 +508,9 @@ export function apply(ctx, config = {}) {
|
||||
...(builtin.output.presentationMeta ? { presentationMeta: (args, value) => { const { tag: _t, rel: _r, ...rest } = value; return builtin.output.presentationMeta(args, rest) } } : {}),
|
||||
},
|
||||
async execute(args, exec) {
|
||||
const value = await builtin.execute(args, exec)
|
||||
// Page size: at most `pageLines` lines per call; the footer tells the model the next offset.
|
||||
const limit = Math.min(Math.max(1, Math.floor(args.limit ?? cfg.pageLines)), cfg.pageLines)
|
||||
const value = await builtin.execute({ ...args, limit }, exec)
|
||||
try {
|
||||
const target = await resolveTarget(args.file_path, exec)
|
||||
const { text } = await readFile(target, exec)
|
||||
@@ -467,7 +533,8 @@ export function apply(ctx, config = {}) {
|
||||
function grepTool(builtin) {
|
||||
return {
|
||||
...builtin,
|
||||
description: `${builtin.description}${P.GREP_HASHLINE_NOTE}`,
|
||||
description: `Search file contents with a ripgrep regular expression. Returns at most ${cfg.pageLines} matching lines per call, grouped by file; pass offset to page through more.${P.GREP_HASHLINE_NOTE}`,
|
||||
parameters: withOffset(builtin.parameters, 'Skip this many matches (paging; the footer gives the next offset).'),
|
||||
output: {
|
||||
...builtin.output,
|
||||
schema: {
|
||||
@@ -486,25 +553,23 @@ export function apply(ctx, config = {}) {
|
||||
if (!groups.has(m.path)) groups.set(m.path, [])
|
||||
groups.get(m.path).push(m)
|
||||
}
|
||||
const max = 250
|
||||
let shown = 0
|
||||
const out = []
|
||||
for (const [p, ms] of groups) {
|
||||
if (shown >= max) break
|
||||
out.push(tags[p] ? formatHeader(p, tags[p]) : p)
|
||||
for (const m of ms) {
|
||||
if (shown++ >= max) break
|
||||
out.push(numbered(m.lineNumber, m.line.length > 400 ? `${m.line.slice(0, 400)}…` : m.line))
|
||||
}
|
||||
}
|
||||
if (rest.matches.length > max) out.push(`(${rest.matches.length - max} more matches not shown; narrow the pattern or path.)`)
|
||||
if (!rest.matches.length) return builtin.output.render(args, rest)
|
||||
const start = Math.max(0, Math.floor(args.offset ?? 0))
|
||||
const page = rest.matches.slice(start, start + cfg.pageLines)
|
||||
const out = [`${rest.matches.length} match${rest.matches.length === 1 ? '' : 'es'} in ${groups.size} file${groups.size === 1 ? '' : 's'}${start || page.length < rest.matches.length ? ` — showing ${start + 1}-${start + page.length}` : ''}`]
|
||||
let last = null
|
||||
for (const m of page) {
|
||||
if (m.path !== last) { out.push(tags[m.path] ? formatHeader(m.path, tags[m.path]) : m.path); last = m.path }
|
||||
out.push(numbered(m.lineNumber, m.line.length > 400 ? `${m.line.slice(0, 400)}…` : m.line))
|
||||
}
|
||||
if (start + page.length < rest.matches.length) out.push(`(${rest.matches.length - start - page.length} more; call grep again with offset=${start + page.length})`)
|
||||
return [{ type: 'text', text: out.join('\n') }]
|
||||
},
|
||||
...(builtin.output.presentationMeta ? { presentationMeta: (args, value) => { const { tags: _t, ...rest } = value; return builtin.output.presentationMeta(args, rest) } } : {}),
|
||||
},
|
||||
async execute(args, exec) {
|
||||
const value = await builtin.execute(args, exec)
|
||||
const { offset: _o, ...rgArgs } = args
|
||||
const value = await builtin.execute(rgArgs, exec)
|
||||
const tags = []
|
||||
try {
|
||||
const store = storeFor(exec)
|
||||
@@ -522,6 +587,31 @@ export function apply(ctx, config = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Wrap the built-in glob: page file lists at `pageLines` paths with an offset cursor. */
|
||||
function globTool(builtin) {
|
||||
return {
|
||||
...builtin,
|
||||
description: `Find files by glob pattern (e.g. "**/*.ts"). Returns at most ${cfg.pageLines} paths per call, newest first; pass offset to page.`,
|
||||
parameters: withOffset(builtin.parameters, 'Skip this many paths (paging; the footer gives the next offset).'),
|
||||
output: {
|
||||
...builtin.output,
|
||||
render: (args, value) => {
|
||||
const list = Array.isArray(value?.paths) ? value.paths : Array.isArray(value?.files) ? value.files : null
|
||||
if (!list) return builtin.output.render(args, value)
|
||||
const start = Math.max(0, Math.floor(args.offset ?? 0))
|
||||
const page = list.slice(start, start + cfg.pageLines)
|
||||
const lines = [`${list.length} file${list.length === 1 ? '' : 's'}${start || page.length < list.length ? ` — showing ${start + 1}-${start + page.length}` : ''}`, ...page.map((x) => (typeof x === 'string' ? x : x.path ?? String(x)))]
|
||||
if (start + page.length < list.length) lines.push(`(${list.length - start - page.length} more; call glob again with offset=${start + page.length})`)
|
||||
return [{ type: 'text', text: lines.join('\n') }]
|
||||
},
|
||||
},
|
||||
async execute(args, exec) {
|
||||
const { offset: _o, ...rest } = args
|
||||
return builtin.execute(rest, exec)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- per-agent installation
|
||||
|
||||
const installed = new Map()
|
||||
@@ -574,11 +664,16 @@ export function apply(ctx, config = {}) {
|
||||
// (at creation, or on the first request if the preset mounts later).
|
||||
let wrappedRead = false
|
||||
let wrappedGrep = false
|
||||
let wrappedGlob = false
|
||||
const ensureWrappers = () => {
|
||||
if (cfg.replaceRead && !wrappedRead) {
|
||||
const builtin = ctx.tools.get('read', agent)
|
||||
if (builtin && !builtin.__ompWrapped) { const t = readTool(builtin); t.__ompWrapped = true; reg(scoped.tools.register(t)); wrappedRead = true }
|
||||
}
|
||||
if (cfg.replaceGlob && !wrappedGlob) {
|
||||
const builtin = ctx.tools.get('glob', agent)
|
||||
if (builtin && !builtin.__ompWrapped) { const t = globTool(builtin); t.__ompWrapped = true; reg(scoped.tools.register(t)); wrappedGlob = true }
|
||||
}
|
||||
if (cfg.replaceGrep && !wrappedGrep) {
|
||||
const builtin = ctx.tools.get('grep', agent)
|
||||
if (builtin && !builtin.__ompWrapped) { const t = grepTool(builtin); t.__ompWrapped = true; reg(scoped.tools.register(t)); wrappedGrep = true }
|
||||
@@ -601,6 +696,37 @@ export function apply(ctx, config = {}) {
|
||||
installed.set(agent, () => { for (const d of disposers.splice(0).reverse()) { try { d() } catch {} } })
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- guard rule
|
||||
// Edits go through `edit` (hashline), reading through `read`, searching through `grep`/`glob`.
|
||||
// Shell commands that mutate files or read/search them are denied with a pointer to the tool.
|
||||
if (cfg.guard) {
|
||||
ctx.on('tools/pre-execute', async (exec, next) => {
|
||||
const name = exec.name
|
||||
if (cfg.guardTools.includes(name)) {
|
||||
// `write` may still create brand-new files (hashline only patches existing ones).
|
||||
if (name === 'write' && isObj(exec.arguments) && typeof exec.arguments.file_path === 'string') {
|
||||
let exists
|
||||
try {
|
||||
const target = await resolveTarget(exec.arguments.file_path, exec)
|
||||
exists = (await ctx.fs.stat(target, exec.signal)) != null // fs.stat resolves undefined when absent
|
||||
} catch (e) {
|
||||
if (e?.code === 'ENOENT' || e?.code === 'FS_NOT_FOUND') exists = false
|
||||
else return { kind: 'deny', reason: `omp-edit guard could not check ${exec.arguments.file_path} (${e?.message ?? e}); write is only allowed for new files.` }
|
||||
}
|
||||
if (!exists) return next()
|
||||
return { kind: 'deny', reason: `"write" may only create new files; ${exec.arguments.file_path} already exists. Change it with the edit tool: read it, then send a [PATH#TAG] hashline patch.` }
|
||||
}
|
||||
return { kind: 'deny', reason: `"${name}" is disabled by the omp-edit guard. Change files with the edit tool: read the file, then send a [PATH#TAG] hashline patch.` }
|
||||
}
|
||||
if ((name === 'bash' || name === 'pwsh' || name === 'bash_persistent') && isObj(exec.arguments)) {
|
||||
const cmd = String(exec.arguments.command ?? exec.arguments.cmd ?? '')
|
||||
const hit = shellViolation(cmd)
|
||||
if (hit) return { kind: 'deny', reason: hit }
|
||||
}
|
||||
return next()
|
||||
})
|
||||
}
|
||||
|
||||
for (const agent of ctx.agents.list()) install(agent)
|
||||
ctx.on('agent/created', ({ agent }) => { install(agent) })
|
||||
ctx.on('agent/disposed', ({ agent }) => { installed.get(agent)?.(); installed.delete(agent) })
|
||||
|
||||
Reference in new issue
Block a user