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
+363 -21

No files matched your search

+41 -2
View File
@@ -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 |
+115
View File
@@ -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));
},
};
},
});
+141 -15
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
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 (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)
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) })
+24 -4
View File
@@ -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"
]
}
}
}
+42
View File
@@ -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()
})