diff --git a/README.md b/README.md index f80b6f3..34a6dec 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,40 @@ Every tagged `read`/`grep`/`edit` records a snapshot. When the file changed sinc A tag that was never issued for that file (e.g. copied from another file) is rejected with a hint. +## Paging: read, grep and glob + +Like oh-my-pi, outputs are cut into pages so the model never pulls a whole file or huge search into context at once: +- **`read`**: at most **200 lines** per call (`limit` above 200 is capped); the footer gives the `offset` to continue. +- **`grep`**: at most **200 matches** per call, grouped under `[PATH#TAG]` headers. Pass `offset` for the next page (`… call grep again with offset=200`). +- **`glob`**: at most **200 paths** per call, with the same `offset` cursor. + +Change the page size with `pageLines`. + +## Guard rule + +A `tools/pre-execute` listener makes the agent use these tools instead of working around them through the shell. Denied calls get a short reason that names the right tool. + +| Denied | Use instead | +| --- | --- | +| `sed -i`, `perl -i`, `ed`/`ex`, `tee`, `>`/`>>` into files, heredocs, `truncate`/`dd`, `python -c`/`node -e`… | `edit` (hashline) | +| `cat`/`head`/`tail`/`less`/`bat`/`nl FILE`, `sed -n`, `awk` | `read` (paged) | +| `grep`/`rg`/`ag`/`ack` over files | `grep` (paged, tagged) | +| `find`/`fd`/`locate`, `ls -R` | `glob` | +| `str_replace_editor`, `apply_patch` tools; `write` on an **existing** file | `edit` (`write` stays allowed for brand-new files) | + +What stays allowed: +- Text inside quotes is ignored (`git commit -m "fix > bug"`). +- Redirection to `/dev/null` is fine. +- Filters on piped output keep working (`npm test 2>&1 | tail -5`, `ps aux | grep node`). + +If the guard can't check whether a path exists, `write` is denied rather than allowed (it fails closed). + +Turn the guard off with `guard: false`, or change the list of blocked tools with `guardTools`. + +## Diff cards in the Web UI + +The plugin ships a web client (`client.js`) that registers an `edit` tool view. Hashline, sloppy and apply_patch calls then render as DSH's native green/red diff card (from the applied hunks in `meta.diffs`), with `+N -M` counts, instead of raw patch text. + ## Edit modes `mode` picks the wire format of the replacement `edit` tool: @@ -130,19 +164,24 @@ Set options in the `config` of the row in `~/.dsh/profiles/web/cordis.patch.yml` replaceRead: true replaceGrep: true fuzzy: true # replace mode fuzzy matching + replaceGlob: true + pageLines: 200 # read/grep/glob page size + guard: true # deny shell edits/reads/searches and non-hashline write tools + guardTools: [write, str_replace_editor, apply_patch] # write: only existing files are denied ``` ## Tests ```bash -npm test # node --test test/*.test.mjs — 32 tests (engine + integration on the real DSH fs-local backend) +npm test # node --test test/*.test.mjs — 34 tests (engine + integration on the real DSH fs-local backend) ``` ## Files | File | Role | | --- | --- | -| `index.js` | DSH plugin: per-agent tool registration, mode selection, fs policy, diff cards | +| `index.js` | DSH plugin: per-agent tool registration, mode selection, fs policy, paging, guard rule | +| `client.js` | Web client: `edit` tool view rendering hashline/patch results as the native diff card | | `lib/hashline.js` | Hashline parser and applier | | `lib/block.js` | `N*` block resolution (brackets, indentation, Markdown, decorators) | | `lib/store.js` | Session snapshot store, Myers line diff, stale-tag rebase | diff --git a/client.js b/client.js new file mode 100644 index 0000000..595c260 --- /dev/null +++ b/client.js @@ -0,0 +1,115 @@ +// omp-edit Web client: render hashline / patch edits as DSH's native diff card. +// +// The stock `edit` row derives its diff from `file_path` + `old_string`/`new_string` arguments. +// Hashline, sloppy and apply_patch calls carry one `input` string instead, so the stock row falls +// back to raw text. This keyed toolview (priority -1 shadows the stock one) renders the applied +// hunks the host already persists in `meta.diffs` with the shared DiffBlock primitive. +window.__ModuleLoader__.load({ + id: '@local/omp-edit', + factory(require) { + const React = require('react'); + const P = require('@deepseek-ai/dsh-client-ui-primitives'); + const h = React.createElement; + + const CSS = ` +.ompe-row{display:flex;flex-direction:column;gap:6px;min-width:0} +.ompe-head{display:flex;align-items:center;gap:6px;min-width:0;height:24px;padding:0 4px;border:0;background:none;color:var(--dsw-alias-label-secondary);font:inherit;font-size:13px;cursor:pointer;border-radius:var(--dsw-radius-sm);text-align:left} +.ompe-head:hover{background:var(--dsw-alias-interactive-bg-hover)} +.ompe-title{color:var(--dsw-alias-label-primary);flex:none} +.ompe-sum{color:var(--dsw-alias-label-caption);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0} +.ompe-add{color:var(--dsw-alias-state-success,#3fb950);flex:none} +.ompe-del{color:var(--dsw-alias-state-danger,#f85149);flex:none} +.ompe-err{color:var(--dsw-alias-state-danger,#f85149)} +.ompe-text{margin:0;padding:8px;border-radius:var(--dsw-radius-md);background:var(--dsw-alias-bg-l2,rgba(127,127,127,.08));white-space:pre-wrap;font:12px/1.5 var(--dsw-font-mono,ui-monospace,monospace);color:var(--dsw-alias-label-secondary);max-height:320px;overflow:auto} +`; + let cssDone = false; + const installCss = () => { + if (cssDone || typeof document === 'undefined') return; + const s = document.createElement('style'); + s.dataset.ompEdit = ''; + s.textContent = CSS; + document.head.appendChild(s); + cssDone = true; + }; + + const Icon = P.IconEditOutlineRegular || (() => null); + const Chevron = P.IconChevronRightOutlineRegular || (() => null); + + function readDiffs(meta) { + const d = meta && typeof meta === 'object' && Array.isArray(meta.diffs) ? meta.diffs : []; + return d.filter((x) => x && typeof x.path === 'string' && typeof x.newText === 'string' && (x.oldText === null || typeof x.oldText === 'string')); + } + + function countLines(diffs) { + // Approximate +/- counts from hunk texts (context lines are shared by both sides). + let add = 0; let del = 0; + for (const d of diffs) { + const a = d.oldText === null ? [] : d.oldText.split('\n'); + const b = d.newText.split('\n'); + const common = new Set(a.filter((l) => b.includes(l))); + add += b.filter((l) => !common.has(l)).length; + del += a.filter((l) => !common.has(l)).length; + } + return { add, del }; + } + + function argsSummary(argsRaw) { + try { + const a = JSON.parse(argsRaw || '{}'); + if (typeof a.file_path === 'string') return a.file_path; + if (typeof a.input === 'string') { + const files = [...a.input.matchAll(/^\[(.+?)#[0-9A-Fa-f]{4}\]\s*$/gm)].map((m) => m[1]); + const more = [...a.input.matchAll(/^\*\*\* (?:Edit|Update|Add|Delete) File:\s*(.+?)(?:\s+all)?\s*$/gm)].map((m) => m[1]); + return [...new Set([...files, ...more])].join(', '); + } + } catch { /* partial arguments */ } + return ''; + } + + const labels = (t) => ({ + codeLabel: t('code') || 'Code', wrapLabel: t('wrap') || 'Wrap', unwrapLabel: t('unwrap') || 'No wrap', + copy: t('copy') || 'Copy', copied: t('copied') || 'Copied', + collapseAria: 'Collapse', expandAria: (n) => `Show ${n} more lines`, collapse: t('collapse') || 'Collapse', expand: (n) => `Show ${n} more lines`, + }); + + function EditRow(props) { + const { block, t, phase } = props; + React.useEffect(installCss, []); + const [open, setOpen] = React.useState(true); + const done = block && 'kind' in block; + const diffs = done && !block.isError ? readDiffs(block.meta) : []; + const argsRaw = block?.call?.argsRaw ?? block?.argsRaw ?? ''; + const files = diffs.length ? [...new Set(diffs.map((d) => d.path))].join(', ') : argsSummary(argsRaw); + const { add, del } = countLines(diffs); + const text = done && Array.isArray(block.content) ? block.content.filter((c) => c.type === 'text').map((c) => c.text).join('\n') : ''; + const running = !done || phase === 'preparing'; + const title = (typeof t === 'function' && t('tool.title.edit')) || 'Edit'; + return h('div', { className: 'ompe-row' }, + h('button', { type: 'button', className: 'ompe-head', onClick: () => setOpen((v) => !v), 'aria-expanded': open }, + h('span', { style: { transform: open ? 'rotate(90deg)' : 'none', transition: 'transform .12s', display: 'inline-flex' } }, h(Chevron, { size: 12 })), + h(Icon, { size: 14 }), + h('span', { className: 'ompe-title' }, running ? `${title}…` : title), + files ? h('span', { className: 'ompe-sum' }, files) : null, + diffs.length ? h('span', { className: 'ompe-add' }, `+${add}`) : null, + diffs.length ? h('span', { className: 'ompe-del' }, `-${del}`) : null, + done && block.isError ? h('span', { className: 'ompe-err' }, '✕') : null), + open && diffs.length && P.DiffBlock + ? h(P.DiffBlock, { diffs, labels: labels(typeof t === 'function' ? t : () => ''), maxLines: 24 }) + : open && done && text + ? h('pre', { className: `ompe-text${block.isError ? ' ompe-err' : ''}` }, text) + : null); + } + + return { + inject: ['slots'], + apply(ctx) { + ctx.slots.inject('tool.call.toolview', () => ctx.slots.register({ + name: 'tool.call.toolview', + key: 'edit', + locale: 'conversation', + priority: -1, + }, EditRow)); + }, + }; + }, +}); diff --git a/index.js b/index.js index d176631..47f9853 100644 --- a/index.js +++ b/index.js @@ -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) }) diff --git a/package.json b/package.json index e688bfb..65fdd77 100644 --- a/package.json +++ b/package.json @@ -1,16 +1,36 @@ { "name": "@local/omp-edit", - "version": "1.0.0", + "version": "1.1.0", "description": "oh-my-pi style editing for DSH: replaces the edit tool with hashline patches (content-hash tagged line anchors, block ops, cut/paste registers, multi-file, stale-tag recovery), plus replace/sloppy/apply_patch modes; tags read and grep output.", "private": true, "type": "module", "license": "MIT", "exports": { ".": "./index.js", + "./client": "./client.js", "./package.json": "./package.json", "./locale/*.json": "./locale/*.json" }, - "files": ["index.js", "lib/*.js", "cordis.patch.yml", "locale/*.json"], - "scripts": { "test": "node --test test/*.test.mjs" }, - "dsh": { "bundle": { "patch": "./cordis.patch.yml" } } + "files": [ + "index.js", + "client.js", + "lib/*.js", + "cordis.patch.yml", + "locale/*.json" + ], + "scripts": { + "test": "node --test test/*.test.mjs" + }, + "dsh": { + "bundle": { + "patch": "./cordis.patch.yml" + }, + "client": { + "platform": "web", + "immediately": true, + "inject": [ + "@deepseek-ai/dsh-client-ui-tool" + ] + } + } } diff --git a/test/integration.test.mjs b/test/integration.test.mjs index 64aa9b8..97b9f0a 100644 --- a/test/integration.test.mjs +++ b/test/integration.test.mjs @@ -152,3 +152,45 @@ test('model switch re-registers the edit tool in the agent scope', async () => { assert.ok(h.ctx.tools.get('edit', agent).parameters.properties.old_string) h.cleanup() }) + +test('guard denies shell edits/reads/searches and non-hashline write tools via tools/pre-execute', async () => { + const { h } = await setup() + const gate = async (name, args) => { + const cbs = [...(h.listeners.get('tools/pre-execute') ?? [])] + const exec = { name, arguments: args, agent: { session: { header: { cwd: h.dir } } }, signal: new AbortController().signal } + const next = () => (cbs.shift() ?? (async () => ({ kind: 'allow' })))(exec, next) + return next() + } + assert.equal((await gate('bash', { command: 'sed -i s/a/b/ f.txt' })).kind, 'deny') + assert.equal((await gate('bash', { command: 'grep -rn foo .' })).kind, 'deny') + assert.equal((await gate('bash', { command: 'cat README.md' })).kind, 'deny') + h.write('exists.txt', 'old\n') + const d = await gate('write', { file_path: 'exists.txt', content: 'y' }) + assert.equal(d.kind, 'deny') + assert.match(d.reason, /only create new files/) + assert.equal((await gate('write', { file_path: 'brand-new.txt', content: 'y' })).kind, 'allow') + assert.equal((await gate('str_replace_editor', { command: 'create', path: 'z' })).kind, 'deny') + // A stat failure must not fail open. + const realStat = h.ctx.fs.stat + h.ctx.fs.stat = async () => { throw Object.assign(new Error('permission denied'), { code: 'EACCES' }) } + const e = await gate('write', { file_path: 'locked.txt', content: 'y' }) + h.ctx.fs.stat = realStat + assert.equal(e.kind, 'deny') + assert.match(e.reason, /could not check/) + assert.equal((await gate('bash', { command: 'npm test 2>&1 | tail -5' })).kind, 'allow') + assert.equal((await gate('edit', { input: '[a#0000]\nPUT 1.=1:\n+x' })).kind, 'allow') + h.cleanup() +}) + +test('grep pages at pageLines with an offset cursor', async () => { + const { h, agent } = await setup({ pageLines: 3 }) + h.write('many.txt', Array.from({ length: 8 }, (_, i) => `hit ${i}`).join('\n') + '\n') + const p1 = await h.call(agent, 'grep', { pattern: 'hit' }) + assert.match(p1.text, /8 matches in 1 file — showing 1-3/) + assert.match(p1.text, /call grep again with offset=3/) + const p2 = await h.call(agent, 'grep', { pattern: 'hit', offset: 6 }) + assert.match(p2.text, /showing 7-8/) + assert.match(p2.text, /^8:hit 7$/m) + assert.doesNotMatch(p2.text, /offset=/) + h.cleanup() +})