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:
Agent committed 2026-10-11 00:10:19 +00:00
1 parent 71953ffb8f
commit 0dae0ee53d
5 files changed
+364 -22

No files matched your search

+142 -16
View File
@@ -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) })