feat: lift shell read restrictions and page bash/read tool outputs to 200 lines

This commit is contained in:
Agent committed 2026-10-12 22:33:11 +00:00
1 parent ee78e6d3a8
commit de37af4b8c
7 files changed
+213 -32

No files matched your search

+101 -7
View File
@@ -18,6 +18,9 @@
* delegate to them; edit is replaced outright).
*/
import path from 'node:path'
import fs from 'node:fs'
import os from 'node:os'
import { randomUUID } from 'node:crypto'
import { SnapshotStore, rebaseOps } from './lib/store.js'
import { parseHashline, applySection } from './lib/hashline.js'
import { applyReplace } from './lib/replace.js'
@@ -41,6 +44,9 @@ 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.
//
// The guard only blocks WRITING through the shell. Reading (cat/head/tail/sed -n/grep/find/ls -R …)
// is allowed; the bash wrapper pages its output instead (see pageShellResult).
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.'],
@@ -52,15 +58,13 @@ const SHELL_RULES = [
// are checked separately by inlineScriptViolation(): harmless computation passes, anything that
// can reach the filesystem or spawn a process is denied.
[/(^|\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.'],
[/(^|\s)find\s.*\s-(delete|fprint\w*|fls|fprintf)\b/, 'find -delete/-fprint write or remove files — use the edit tool (read-only find is fine).'],
[/(^|\s)(awk|gawk)\s+.*(-i\s*inplace|--in-place)/, 'awk in-place edits are not allowed — use the edit tool.'],
]
/** awk programs live in quotes (blanked above), so writes from inside them are checked on the raw command. */
const AWK_WRITE = /(^|[\s;&|(])g?awk\b[\s\S]*(\bsystem\s*\(|\bprintf?\b[^;}\n]*>|\|\s*["'][^"']*["']\s*[;}]|\bclose\s*\()/
// ---------------------------------------------------------------- inline scripts
//
// Allowlist, not denylist: an inline script is allowed only when its body contains NO identifier
@@ -171,6 +175,7 @@ export function inlineScriptViolation(cmd) {
export function shellViolation(cmd) {
const inline = inlineScriptViolation(cmd)
if (inline) return inline
if (AWK_WRITE.test(String(cmd))) return `Blocked by omp-edit guard: this awk program writes files or runs commands — use the edit tool. (command: ${String(cmd).slice(0, 120)})`
const blanked = String(cmd)
.replace(/'[^']*'/g, "''")
.replace(/"(?:\\.|[^"\\])*"/g, '""')
@@ -192,6 +197,77 @@ export function shellViolation(cmd) {
return null
}
// ---------------------------------------------------------------- shell output paging
//
// bash may read any file, but the model sees at most `pageLines` lines of a stream per call.
// The complete output (the executor's spill file when it truncated, else the text itself) is kept
// in a file under the OS temp dir and the footer tells the model how to read the next lines with
// the paged `read` tool.
const OUTPUT_DIR = path.join(os.tmpdir(), 'omp-edit-output')
const OUTPUT_TTL_MS = 24 * 3600 * 1000
const MAX_FULL_BYTES = 64 * 1024 * 1024
/** Write the full output of a stream to a private temp file, sweeping files older than a day. */
function saveFullOutput(text) {
fs.mkdirSync(OUTPUT_DIR, { recursive: true, mode: 0o700 })
try {
const now = Date.now()
for (const f of fs.readdirSync(OUTPUT_DIR)) {
const p = path.join(OUTPUT_DIR, f)
try { if (now - fs.statSync(p).mtimeMs > OUTPUT_TTL_MS) fs.unlinkSync(p) } catch {}
}
} catch {}
const file = path.join(OUTPUT_DIR, `${randomUUID()}.txt`)
fs.writeFileSync(file, text, { mode: 0o600 })
return file
}
/**
* Cut `text` to its first `pageLines` lines plus a footer that names the lines still unseen and
* the read call that fetches them. Returns null when everything fits in one page.
* @param {string} text full stream text
* @param {number} pageLines page size
* @param {(text: string) => string} save persists the full text, returning its path
* @param {string} [label] stream name shown in the footer
*/
export function pageText(text, pageLines, save, label = 'output') {
const body = text.endsWith('\n') ? text.slice(0, -1) : text
const lines = body.split('\n')
if (lines.length <= pageLines) return null
const file = save(text)
const next = pageLines + 1
const nextEnd = Math.min(pageLines * 2, lines.length)
const head = lines.slice(0, pageLines).join('\n')
return {
file,
total: lines.length,
text: `${head}\n[${label} lines 1-${pageLines} of ${lines.length}. Read lines ${next}-${nextEnd}: read file_path="${file}" offset=${next} (up to ${pageLines} lines per call)]`,
}
}
/** Page the stdout/stderr of a finished foreground bash result; other result kinds pass through. */
export function pageShellResult(value, pageLines, save = saveFullOutput) {
if (!isObj(value) || value.kind !== 'foreground') return value
const out = { ...value }
for (const stream of ['stdout', 'stderr']) {
const s = value[stream]
if (!isObj(s) || typeof s.text !== 'string') continue
let full = s.text
if (s.truncated) {
// The executor kept only the tail; the complete stream is in its spill file.
try {
if (!s.spillPath || fs.statSync(s.spillPath).size > MAX_FULL_BYTES) continue
full = fs.readFileSync(s.spillPath, 'utf8')
} catch { continue }
}
const paged = pageText(full, pageLines, save, stream)
if (!paged) continue
out[stream] = { ...s, text: paged.text, truncated: false, spillPath: paged.file }
}
return out
}
/** Clone a parameters schema, documenting the page cap on `limit`. */
function capLimit(params, max) {
const p = structuredClone(params ?? { type: 'object', properties: {} })
@@ -229,6 +305,8 @@ export function apply(ctx, config = {}) {
replaceGrep: true,
replaceEdit: true,
replaceGlob: true,
// bash may read anything; its output is paged to pageLines per call (writes stay guarded).
replaceBash: true,
fuzzy: true,
// read/grep/glob return at most this many lines/matches/paths per call.
pageLines: 200,
@@ -722,6 +800,17 @@ export function apply(ctx, config = {}) {
}
}
/** Wrap the built-in bash: the model sees at most `pageLines` lines per stream, the rest is readable with `read`. */
function bashTool(builtin) {
return {
...builtin,
description: `${builtin.description} Reading files and searching through the shell is fine; writing files through the shell is blocked (use edit/write). Output is capped at ${cfg.pageLines} lines per stream: the full output is saved to a file named in the footer, so read the next lines with read (file_path + offset).`,
async execute(args, exec) {
return pageShellResult(await builtin.execute(args, exec), cfg.pageLines)
},
}
}
// ---------------------------------------------------------------- per-agent installation
const installed = new Map()
@@ -775,6 +864,7 @@ export function apply(ctx, config = {}) {
let wrappedRead = false
let wrappedGrep = false
let wrappedGlob = false
let wrappedBash = false
const ensureWrappers = () => {
if (cfg.replaceRead && !wrappedRead) {
const builtin = ctx.tools.get('read', agent)
@@ -788,6 +878,10 @@ export function apply(ctx, config = {}) {
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 }
}
if (cfg.replaceBash && !wrappedBash) {
const builtin = ctx.tools.get('bash', agent)
if (builtin && !builtin.__ompWrapped) { const t = bashTool(builtin); t.__ompWrapped = true; reg(scoped.tools.register(t)); wrappedBash = true }
}
}
ensureWrappers()
ensure = ensureWrappers