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
@@ -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.
|
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
|
## Edit modes
|
||||||
|
|
||||||
`mode` picks the wire format of the replacement `edit` tool:
|
`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
|
replaceRead: true
|
||||||
replaceGrep: true
|
replaceGrep: true
|
||||||
fuzzy: true # replace mode fuzzy matching
|
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
|
## Tests
|
||||||
|
|
||||||
```bash
|
```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
|
## Files
|
||||||
|
|
||||||
| File | Role |
|
| 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/hashline.js` | Hashline parser and applier |
|
||||||
| `lib/block.js` | `N*` block resolution (brackets, indentation, Markdown, decorators) |
|
| `lib/block.js` | `N*` block resolution (brackets, indentation, Markdown, decorators) |
|
||||||
| `lib/store.js` | Session snapshot store, Myers line diff, stale-tag rebase |
|
| `lib/store.js` | Session snapshot store, Myers line diff, stale-tag rebase |
|
||||||
|
|||||||
@@ -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));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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)
|
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) {
|
export function resolveMode(config, model) {
|
||||||
const m = String(model || '').toLowerCase()
|
const m = String(model || '').toLowerCase()
|
||||||
for (const [needle, mode] of Object.entries(config.modelVariants || {})) {
|
for (const [needle, mode] of Object.entries(config.modelVariants || {})) {
|
||||||
@@ -61,7 +118,13 @@ export function apply(ctx, config = {}) {
|
|||||||
replaceRead: true,
|
replaceRead: true,
|
||||||
replaceGrep: true,
|
replaceGrep: true,
|
||||||
replaceEdit: true,
|
replaceEdit: true,
|
||||||
|
replaceGlob: true,
|
||||||
fuzzy: 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,
|
...config,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -431,7 +494,8 @@ export function apply(ctx, config = {}) {
|
|||||||
function readTool(builtin) {
|
function readTool(builtin) {
|
||||||
return {
|
return {
|
||||||
...builtin,
|
...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: {
|
output: {
|
||||||
...builtin.output,
|
...builtin.output,
|
||||||
schema: { ...builtin.output.schema, properties: { ...builtin.output.schema.properties, tag: { type: 'string' }, rel: { type: 'string' } } },
|
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) } } : {}),
|
...(builtin.output.presentationMeta ? { presentationMeta: (args, value) => { const { tag: _t, rel: _r, ...rest } = value; return builtin.output.presentationMeta(args, rest) } } : {}),
|
||||||
},
|
},
|
||||||
async execute(args, exec) {
|
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 {
|
try {
|
||||||
const target = await resolveTarget(args.file_path, exec)
|
const target = await resolveTarget(args.file_path, exec)
|
||||||
const { text } = await readFile(target, exec)
|
const { text } = await readFile(target, exec)
|
||||||
@@ -467,7 +533,8 @@ export function apply(ctx, config = {}) {
|
|||||||
function grepTool(builtin) {
|
function grepTool(builtin) {
|
||||||
return {
|
return {
|
||||||
...builtin,
|
...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: {
|
output: {
|
||||||
...builtin.output,
|
...builtin.output,
|
||||||
schema: {
|
schema: {
|
||||||
@@ -486,25 +553,23 @@ export function apply(ctx, config = {}) {
|
|||||||
if (!groups.has(m.path)) groups.set(m.path, [])
|
if (!groups.has(m.path)) groups.set(m.path, [])
|
||||||
groups.get(m.path).push(m)
|
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)
|
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') }]
|
return [{ type: 'text', text: out.join('\n') }]
|
||||||
},
|
},
|
||||||
...(builtin.output.presentationMeta ? { presentationMeta: (args, value) => { const { tags: _t, ...rest } = value; return builtin.output.presentationMeta(args, rest) } } : {}),
|
...(builtin.output.presentationMeta ? { presentationMeta: (args, value) => { const { tags: _t, ...rest } = value; return builtin.output.presentationMeta(args, rest) } } : {}),
|
||||||
},
|
},
|
||||||
async execute(args, exec) {
|
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 = []
|
const tags = []
|
||||||
try {
|
try {
|
||||||
const store = storeFor(exec)
|
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
|
// ---------------------------------------------------------------- per-agent installation
|
||||||
|
|
||||||
const installed = new Map()
|
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).
|
// (at creation, or on the first request if the preset mounts later).
|
||||||
let wrappedRead = false
|
let wrappedRead = false
|
||||||
let wrappedGrep = false
|
let wrappedGrep = false
|
||||||
|
let wrappedGlob = false
|
||||||
const ensureWrappers = () => {
|
const ensureWrappers = () => {
|
||||||
if (cfg.replaceRead && !wrappedRead) {
|
if (cfg.replaceRead && !wrappedRead) {
|
||||||
const builtin = ctx.tools.get('read', agent)
|
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 (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) {
|
if (cfg.replaceGrep && !wrappedGrep) {
|
||||||
const builtin = ctx.tools.get('grep', agent)
|
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 (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 {} } })
|
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)
|
for (const agent of ctx.agents.list()) install(agent)
|
||||||
ctx.on('agent/created', ({ agent }) => { install(agent) })
|
ctx.on('agent/created', ({ agent }) => { install(agent) })
|
||||||
ctx.on('agent/disposed', ({ agent }) => { installed.get(agent)?.(); installed.delete(agent) })
|
ctx.on('agent/disposed', ({ agent }) => { installed.get(agent)?.(); installed.delete(agent) })
|
||||||
|
|||||||
+24
-4
@@ -1,16 +1,36 @@
|
|||||||
{
|
{
|
||||||
"name": "@local/omp-edit",
|
"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.",
|
"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,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"exports": {
|
"exports": {
|
||||||
".": "./index.js",
|
".": "./index.js",
|
||||||
|
"./client": "./client.js",
|
||||||
"./package.json": "./package.json",
|
"./package.json": "./package.json",
|
||||||
"./locale/*.json": "./locale/*.json"
|
"./locale/*.json": "./locale/*.json"
|
||||||
},
|
},
|
||||||
"files": ["index.js", "lib/*.js", "cordis.patch.yml", "locale/*.json"],
|
"files": [
|
||||||
"scripts": { "test": "node --test test/*.test.mjs" },
|
"index.js",
|
||||||
"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }
|
"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"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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)
|
assert.ok(h.ctx.tools.get('edit', agent).parameters.properties.old_string)
|
||||||
h.cleanup()
|
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()
|
||||||
|
})
|
||||||
Reference in new issue
Block a user