amness 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +212 -2
- package/dist/cli/acpcli.d.ts +7 -0
- package/dist/cli/acpcli.js +14 -0
- package/dist/cli/amness.d.ts +2 -0
- package/dist/cli/amness.js +693 -0
- package/dist/cli/benchcli.d.ts +5 -0
- package/dist/cli/benchcli.js +160 -0
- package/dist/cli/config.d.ts +79 -0
- package/dist/cli/config.js +54 -0
- package/dist/cli/demo.d.ts +13 -0
- package/dist/cli/demo.js +44 -0
- package/dist/cli/detect.d.ts +2 -0
- package/dist/cli/detect.js +20 -0
- package/dist/cli/evalcli.d.ts +15 -0
- package/dist/cli/evalcli.js +198 -0
- package/dist/cli/format.d.ts +5 -0
- package/dist/cli/format.js +55 -0
- package/dist/cli/headless.d.ts +2 -0
- package/dist/cli/headless.js +169 -0
- package/dist/cli/model.d.ts +11 -0
- package/dist/cli/model.js +19 -0
- package/dist/cli/plugincli.d.ts +2 -0
- package/dist/cli/plugincli.js +176 -0
- package/dist/cli/schedulecli.d.ts +2 -0
- package/dist/cli/schedulecli.js +66 -0
- package/dist/cli/state.d.ts +11 -0
- package/dist/cli/state.js +19 -0
- package/dist/cli/tui/ansi.d.ts +38 -0
- package/dist/cli/tui/ansi.js +142 -0
- package/dist/cli/tui/app.d.ts +328 -0
- package/dist/cli/tui/app.js +1358 -0
- package/dist/cli/tui/blocks.d.ts +151 -0
- package/dist/cli/tui/blocks.js +128 -0
- package/dist/cli/tui/brand.d.ts +9 -0
- package/dist/cli/tui/brand.js +16 -0
- package/dist/cli/tui/canvas.d.ts +92 -0
- package/dist/cli/tui/canvas.js +199 -0
- package/dist/cli/tui/cards.d.ts +44 -0
- package/dist/cli/tui/cards.js +215 -0
- package/dist/cli/tui/clipboard.d.ts +5 -0
- package/dist/cli/tui/clipboard.js +29 -0
- package/dist/cli/tui/commands.d.ts +10 -0
- package/dist/cli/tui/commands.js +649 -0
- package/dist/cli/tui/complete.d.ts +39 -0
- package/dist/cli/tui/complete.js +113 -0
- package/dist/cli/tui/custom.d.ts +9 -0
- package/dist/cli/tui/custom.js +34 -0
- package/dist/cli/tui/diffview.d.ts +25 -0
- package/dist/cli/tui/diffview.js +130 -0
- package/dist/cli/tui/editor.d.ts +60 -0
- package/dist/cli/tui/editor.js +230 -0
- package/dist/cli/tui/full.d.ts +86 -0
- package/dist/cli/tui/full.js +630 -0
- package/dist/cli/tui/keys.d.ts +31 -0
- package/dist/cli/tui/keys.js +134 -0
- package/dist/cli/tui/markdown.d.ts +37 -0
- package/dist/cli/tui/markdown.js +155 -0
- package/dist/cli/tui/mentions.d.ts +15 -0
- package/dist/cli/tui/mentions.js +48 -0
- package/dist/cli/tui/modal.d.ts +26 -0
- package/dist/cli/tui/modal.js +107 -0
- package/dist/cli/tui/overlays.d.ts +98 -0
- package/dist/cli/tui/overlays.js +237 -0
- package/dist/cli/tui/pager.d.ts +15 -0
- package/dist/cli/tui/pager.js +66 -0
- package/dist/cli/tui/pricing.d.ts +9 -0
- package/dist/cli/tui/pricing.js +23 -0
- package/dist/cli/tui/rich.d.ts +16 -0
- package/dist/cli/tui/rich.js +72 -0
- package/dist/cli/tui/screen.d.ts +30 -0
- package/dist/cli/tui/screen.js +62 -0
- package/dist/cli/tui/session.d.ts +77 -0
- package/dist/cli/tui/session.js +103 -0
- package/dist/cli/tui/sidebar.d.ts +84 -0
- package/dist/cli/tui/sidebar.js +114 -0
- package/dist/cli/tui/text.d.ts +15 -0
- package/dist/cli/tui/text.js +94 -0
- package/dist/cli/tui/theme.d.ts +59 -0
- package/dist/cli/tui/theme.js +148 -0
- package/dist/cli/tui/themes.d.ts +56 -0
- package/dist/cli/tui/themes.js +80 -0
- package/dist/cli/tui/tokens.d.ts +39 -0
- package/dist/cli/tui/tokens.js +71 -0
- package/dist/cli/tui/view.d.ts +60 -0
- package/dist/cli/tui/view.js +178 -0
- package/dist/cli/ui.d.ts +34 -0
- package/dist/cli/ui.js +224 -0
- package/dist/src/acp.d.ts +28 -0
- package/dist/src/acp.js +111 -0
- package/dist/src/agents.d.ts +23 -0
- package/dist/src/agents.js +71 -0
- package/dist/src/anthropic.d.ts +30 -0
- package/dist/src/anthropic.js +91 -0
- package/dist/src/approver.d.ts +15 -0
- package/dist/src/approver.js +40 -0
- package/dist/src/bench.d.ts +67 -0
- package/dist/src/bench.js +201 -0
- package/dist/src/bundle.d.ts +36 -0
- package/dist/src/bundle.js +55 -0
- package/dist/src/chaos.d.ts +15 -0
- package/dist/src/chaos.js +26 -0
- package/dist/src/code.d.ts +187 -0
- package/dist/src/code.js +590 -0
- package/dist/src/codeindex.d.ts +65 -0
- package/dist/src/codeindex.js +300 -0
- package/dist/src/deep.d.ts +62 -0
- package/dist/src/deep.js +177 -0
- package/dist/src/diff.d.ts +8 -0
- package/dist/src/diff.js +74 -0
- package/dist/src/dynamic.d.ts +42 -0
- package/dist/src/dynamic.js +133 -0
- package/dist/src/embed.d.ts +8 -0
- package/dist/src/embed.js +25 -0
- package/dist/src/evalcases.d.ts +11 -0
- package/dist/src/evalcases.js +196 -0
- package/dist/src/evals.d.ts +197 -0
- package/dist/src/evals.js +340 -0
- package/dist/src/gitflow.d.ts +27 -0
- package/dist/src/gitflow.js +56 -0
- package/dist/src/grade.d.ts +42 -0
- package/dist/src/grade.js +96 -0
- package/dist/src/guidance.d.ts +15 -0
- package/dist/src/guidance.js +68 -0
- package/dist/src/hardcases.d.ts +2 -0
- package/dist/src/hardcases.js +94 -0
- package/dist/src/harness.d.ts +411 -0
- package/dist/src/harness.js +725 -0
- package/dist/src/hooks.d.ts +44 -0
- package/dist/src/hooks.js +116 -0
- package/dist/src/images.d.ts +13 -0
- package/dist/src/images.js +27 -0
- package/dist/src/index.d.ts +57 -0
- package/dist/src/index.js +57 -0
- package/dist/src/ledger.d.ts +58 -0
- package/dist/src/ledger.js +78 -0
- package/dist/src/live.d.ts +16 -0
- package/dist/src/live.js +80 -0
- package/dist/src/lsp.d.ts +31 -0
- package/dist/src/lsp.js +147 -0
- package/dist/src/memory.d.ts +128 -0
- package/dist/src/memory.js +390 -0
- package/dist/src/metrics.d.ts +3 -0
- package/dist/src/metrics.js +42 -0
- package/dist/src/mining.d.ts +49 -0
- package/dist/src/mining.js +126 -0
- package/dist/src/openai.d.ts +50 -0
- package/dist/src/openai.js +307 -0
- package/dist/src/parallel.d.ts +66 -0
- package/dist/src/parallel.js +135 -0
- package/dist/src/patch.d.ts +45 -0
- package/dist/src/patch.js +219 -0
- package/dist/src/permissions.d.ts +46 -0
- package/dist/src/permissions.js +245 -0
- package/dist/src/plugins.d.ts +148 -0
- package/dist/src/plugins.js +225 -0
- package/dist/src/pluginscan.d.ts +8 -0
- package/dist/src/pluginscan.js +20 -0
- package/dist/src/procs.d.ts +41 -0
- package/dist/src/procs.js +94 -0
- package/dist/src/profiles.d.ts +36 -0
- package/dist/src/profiles.js +61 -0
- package/dist/src/progress.d.ts +29 -0
- package/dist/src/progress.js +75 -0
- package/dist/src/prompts.d.ts +10 -0
- package/dist/src/prompts.js +28 -0
- package/dist/src/ratelimit.d.ts +34 -0
- package/dist/src/ratelimit.js +116 -0
- package/dist/src/replay.d.ts +25 -0
- package/dist/src/replay.js +99 -0
- package/dist/src/review.d.ts +20 -0
- package/dist/src/review.js +49 -0
- package/dist/src/route.d.ts +9 -0
- package/dist/src/route.js +30 -0
- package/dist/src/router.d.ts +61 -0
- package/dist/src/router.js +126 -0
- package/dist/src/sandbox.d.ts +13 -0
- package/dist/src/sandbox.js +68 -0
- package/dist/src/schedule.d.ts +20 -0
- package/dist/src/schedule.js +59 -0
- package/dist/src/schema.d.ts +13 -0
- package/dist/src/schema.js +36 -0
- package/dist/src/secrets.d.ts +10 -0
- package/dist/src/secrets.js +41 -0
- package/dist/src/shell.d.ts +34 -0
- package/dist/src/shell.js +95 -0
- package/dist/src/skills.d.ts +20 -0
- package/dist/src/skills.js +112 -0
- package/dist/src/snapshot.d.ts +20 -0
- package/dist/src/snapshot.js +93 -0
- package/dist/src/stats.d.ts +24 -0
- package/dist/src/stats.js +62 -0
- package/dist/src/strategies.d.ts +16 -0
- package/dist/src/strategies.js +35 -0
- package/dist/src/suites.d.ts +30 -0
- package/dist/src/suites.js +112 -0
- package/dist/src/trace.d.ts +63 -0
- package/dist/src/trace.js +153 -0
- package/dist/src/tune.d.ts +38 -0
- package/dist/src/tune.js +49 -0
- package/dist/src/web.d.ts +19 -0
- package/dist/src/web.js +56 -0
- package/dist/src/worktree.d.ts +24 -0
- package/dist/src/worktree.js +85 -0
- package/docs/agent-loop.md +222 -0
- package/docs/architecture.md +228 -0
- package/docs/bench.md +293 -0
- package/docs/budgets.md +185 -0
- package/docs/build-your-own-harness.md +424 -0
- package/docs/cookbook.md +375 -0
- package/docs/development.md +150 -0
- package/docs/evaluation.md +450 -0
- package/docs/features.md +1320 -0
- package/docs/getting-started.md +196 -0
- package/docs/headless.md +183 -0
- package/docs/images/approval.png +0 -0
- package/docs/images/chat.png +0 -0
- package/docs/images/daylight.png +0 -0
- package/docs/images/loop.png +0 -0
- package/docs/images/palette.png +0 -0
- package/docs/images/running.png +0 -0
- package/docs/images/subagent-done.png +0 -0
- package/docs/images/subagent-running.png +0 -0
- package/docs/images/welcome.png +0 -0
- package/docs/index.md +25 -0
- package/docs/library.md +276 -0
- package/docs/plugins.md +691 -0
- package/docs/providers.md +175 -0
- package/docs/safety.md +237 -0
- package/docs/usage.md +348 -0
- package/docs/why.md +155 -0
- package/docs/writing-guide.md +39 -0
- package/package.json +72 -4
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
const strip = (p) => p.replace(/^\s+|\s+$/g, "").replace(/^"|"$/g, "").replace(/^[ab]\//, "");
|
|
2
|
+
export function parsePatch(text) {
|
|
3
|
+
const L = text.replace(/\r\n/g, "\n").split("\n");
|
|
4
|
+
return L.some((l) => l.startsWith("*** Begin Patch") || /^\*\*\* (Update|Add|Delete) File:/.test(l)) ? parseEnvelope(L) : parseUnified(L);
|
|
5
|
+
}
|
|
6
|
+
function parseEnvelope(L) {
|
|
7
|
+
const ops = [];
|
|
8
|
+
let cur, hunk;
|
|
9
|
+
const flush = () => { if (cur && hunk && hunk.lines.length)
|
|
10
|
+
cur.hunks.push(hunk); hunk = undefined; };
|
|
11
|
+
for (const l of L) {
|
|
12
|
+
let m;
|
|
13
|
+
if (l.startsWith("*** Begin Patch") || l.startsWith("*** End Patch") || l.startsWith("*** End of File")) {
|
|
14
|
+
flush();
|
|
15
|
+
if (l.startsWith("*** End Patch"))
|
|
16
|
+
break;
|
|
17
|
+
continue;
|
|
18
|
+
}
|
|
19
|
+
if ((m = /^\*\*\* (Update|Add|Delete) File:\s*(.+)$/.exec(l))) {
|
|
20
|
+
flush();
|
|
21
|
+
cur = { path: strip(m[2]), op: m[1].toLowerCase(), hunks: [] };
|
|
22
|
+
ops.push(cur);
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
if (!cur)
|
|
26
|
+
continue;
|
|
27
|
+
if ((m = /^\*\*\* Move to:\s*(.+)$/.exec(l))) {
|
|
28
|
+
cur.moveTo = strip(m[1]);
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
if (l.startsWith("@@")) {
|
|
32
|
+
flush();
|
|
33
|
+
hunk = { anchor: l.slice(2).trim() || undefined, lines: [] };
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
if (cur.op === "delete")
|
|
37
|
+
continue;
|
|
38
|
+
const c = l[0];
|
|
39
|
+
if (c === "+" || c === "-" || c === " " || l === "") {
|
|
40
|
+
hunk ??= { lines: [] };
|
|
41
|
+
hunk.lines.push({ t: (c === "+" || c === "-" ? c : " "), s: l === "" ? "" : l.slice(1) });
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
flush();
|
|
45
|
+
for (const o of ops)
|
|
46
|
+
if (o.op === "add") {
|
|
47
|
+
const all = o.hunks.flatMap((h) => h.lines.map((x) => x.s));
|
|
48
|
+
o.hunks = [{ lines: all.map((s) => ({ t: "+", s })) }];
|
|
49
|
+
}
|
|
50
|
+
return ops;
|
|
51
|
+
}
|
|
52
|
+
function parseUnified(L) {
|
|
53
|
+
const ops = [];
|
|
54
|
+
let cur, hunk, from = "";
|
|
55
|
+
const flush = () => { if (hunk) {
|
|
56
|
+
while (hunk.lines.length && hunk.lines.at(-1).t === " " && hunk.lines.at(-1).s === "")
|
|
57
|
+
hunk.lines.pop();
|
|
58
|
+
} if (cur && hunk && hunk.lines.length)
|
|
59
|
+
cur.hunks.push(hunk); hunk = undefined; }; // the blank line after the last newline is not context
|
|
60
|
+
for (let i = 0; i < L.length; i++) {
|
|
61
|
+
const l = L[i];
|
|
62
|
+
if (l.startsWith("--- ") && L[i + 1]?.startsWith("+++ ")) {
|
|
63
|
+
flush();
|
|
64
|
+
from = l.slice(4).split("\t")[0];
|
|
65
|
+
const to = L[++i].slice(4).split("\t")[0];
|
|
66
|
+
const add = /^\/dev\/null/.test(from), del = /^\/dev\/null/.test(to);
|
|
67
|
+
cur = { path: strip(del ? from : to), op: add ? "add" : del ? "delete" : "update", hunks: [] };
|
|
68
|
+
ops.push(cur);
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
if (!cur)
|
|
72
|
+
continue;
|
|
73
|
+
const h = /^@@ -(\d+)(?:,\d+)? \+\d+(?:,\d+)? @@\s*(.*)$/.exec(l);
|
|
74
|
+
if (h) {
|
|
75
|
+
flush();
|
|
76
|
+
hunk = { hint: Number(h[1]), anchor: h[2] || undefined, lines: [] };
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
if (!hunk || l.startsWith("\\"))
|
|
80
|
+
continue;
|
|
81
|
+
const c = l[0];
|
|
82
|
+
if (c === "+" || c === "-" || c === " ")
|
|
83
|
+
hunk.lines.push({ t: c, s: l.slice(1) });
|
|
84
|
+
else if (l === "")
|
|
85
|
+
hunk.lines.push({ t: " ", s: "" });
|
|
86
|
+
}
|
|
87
|
+
flush();
|
|
88
|
+
return ops;
|
|
89
|
+
}
|
|
90
|
+
/** Every path a patch touches (for permission checks). */
|
|
91
|
+
export function patchPaths(text) { try {
|
|
92
|
+
return [...new Set(parsePatch(text).flatMap((o) => [o.path, ...(o.moveTo ? [o.moveTo] : [])]))];
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
return [];
|
|
96
|
+
} }
|
|
97
|
+
const same = (a, b) => a === b || a.trimEnd() === b.trimEnd();
|
|
98
|
+
const loose = (a, b) => a.trim() === b.trim();
|
|
99
|
+
/** First index >= from where `seq` matches the file lines (exactly, then ignoring trailing space, then ignoring indentation). */
|
|
100
|
+
function find(file, seq, from) {
|
|
101
|
+
if (!seq.length)
|
|
102
|
+
return { at: Math.min(from, file.length), fuzzy: false };
|
|
103
|
+
for (const [eq, fuzzy] of [[(a, b) => a === b, false], [same, false], [loose, true]]) {
|
|
104
|
+
for (let i = from; i + seq.length <= file.length; i++) {
|
|
105
|
+
let ok = true;
|
|
106
|
+
for (let j = 0; j < seq.length && ok; j++)
|
|
107
|
+
ok = eq(file[i + j], seq[j]);
|
|
108
|
+
if (ok)
|
|
109
|
+
return { at: i, fuzzy };
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return undefined;
|
|
113
|
+
}
|
|
114
|
+
/** Re-indent replacement lines when the match was found ignoring indentation: shift by the difference in leading whitespace of the first matched line. */
|
|
115
|
+
function reindent(newLines, oldFirst, fileFirst) {
|
|
116
|
+
const lead = (s) => /^\s*/.exec(s)[0], a = lead(oldFirst), b = lead(fileFirst);
|
|
117
|
+
if (a === b)
|
|
118
|
+
return newLines;
|
|
119
|
+
return newLines.map((l) => (l.trim() === "" ? l : l.startsWith(a) ? b + l.slice(a.length) : l));
|
|
120
|
+
}
|
|
121
|
+
export function applyOps(ops, read) {
|
|
122
|
+
const applied = [], errors = [], seen = new Set();
|
|
123
|
+
for (const o of ops) {
|
|
124
|
+
if (seen.has(o.path)) {
|
|
125
|
+
errors.push(`${o.path}: appears twice in the patch; put all its hunks under one heading`);
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
seen.add(o.path);
|
|
129
|
+
const before = read(o.path);
|
|
130
|
+
if (o.op === "add") {
|
|
131
|
+
if (before !== undefined)
|
|
132
|
+
errors.push(`${o.path}: cannot add, the file already exists (use Update File)`);
|
|
133
|
+
else
|
|
134
|
+
applied.push({ path: o.path, before: null, after: o.hunks.flatMap((h) => h.lines.map((x) => x.s)).join("\n") + "\n", fuzzy: 0 });
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
if (before === undefined) {
|
|
138
|
+
errors.push(`${o.path}: no such file`);
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
if (o.op === "delete") {
|
|
142
|
+
applied.push({ path: o.path, before, after: null, fuzzy: 0 });
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
const trailing = before.endsWith("\n"), file = before.split("\n");
|
|
146
|
+
if (trailing)
|
|
147
|
+
file.pop();
|
|
148
|
+
let cursor = 0, fuzzy = 0, bad = false;
|
|
149
|
+
o.hunks.forEach((h, hi) => {
|
|
150
|
+
if (bad)
|
|
151
|
+
return;
|
|
152
|
+
const oldSeq = h.lines.filter((x) => x.t !== "+").map((x) => x.s), newSeq = h.lines.filter((x) => x.t !== "-").map((x) => x.s);
|
|
153
|
+
let from = cursor;
|
|
154
|
+
if (h.anchor) {
|
|
155
|
+
const a = find(file, [h.anchor], cursor);
|
|
156
|
+
if (a)
|
|
157
|
+
from = a.at;
|
|
158
|
+
} // "@@ function name" narrows where to look
|
|
159
|
+
else if (h.hint && h.hint - 1 >= cursor && h.hint - 1 <= file.length) {
|
|
160
|
+
const near = find(file, oldSeq, Math.max(cursor, h.hint - 1 - 3));
|
|
161
|
+
if (near && near.at <= h.hint - 1 + 3)
|
|
162
|
+
from = near.at;
|
|
163
|
+
}
|
|
164
|
+
const m = find(file, oldSeq, from) ?? (from > cursor ? find(file, oldSeq, cursor) : undefined);
|
|
165
|
+
if (!m) {
|
|
166
|
+
errors.push(`${o.path}, hunk ${hi + 1}: the lines to replace were not found${h.anchor ? ` after "${h.anchor}"` : ""}. First line looked for: ${JSON.stringify(oldSeq.find((s) => s.trim()) ?? oldSeq[0] ?? "")}`);
|
|
167
|
+
bad = true;
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
if (m.fuzzy)
|
|
171
|
+
fuzzy++;
|
|
172
|
+
file.splice(m.at, oldSeq.length, ...(m.fuzzy ? reindent(newSeq, oldSeq[0] ?? "", file[m.at] ?? "") : newSeq));
|
|
173
|
+
cursor = m.at + newSeq.length;
|
|
174
|
+
});
|
|
175
|
+
if (!bad)
|
|
176
|
+
applied.push({ path: o.moveTo ?? o.path, before, after: file.join("\n") + (trailing ? "\n" : ""), fuzzy });
|
|
177
|
+
if (!bad && o.moveTo && o.moveTo !== o.path)
|
|
178
|
+
applied.push({ path: o.path, before, after: null, fuzzy: 0 });
|
|
179
|
+
}
|
|
180
|
+
return { applied: errors.length ? [] : applied, errors };
|
|
181
|
+
}
|
|
182
|
+
/** Replace `old` by `nw` in `content`: exactly once. When there is no exact match and `old` spans whole lines, a match that differs only in indentation or trailing space is accepted if it is unique (and reported). */
|
|
183
|
+
export function replaceOnce(content, old, nw) {
|
|
184
|
+
if (!old)
|
|
185
|
+
return { error: "`old` is empty" };
|
|
186
|
+
const n = content.split(old).length - 1;
|
|
187
|
+
if (n === 1)
|
|
188
|
+
return { out: content.replace(old, () => nw) };
|
|
189
|
+
if (n > 1)
|
|
190
|
+
return { error: `\`old\` matched ${n} times; it must match exactly once. Include more surrounding lines.` };
|
|
191
|
+
const oldLines = old.replace(/\n$/, "").split("\n"), file = content.split("\n"), hits = [];
|
|
192
|
+
if (oldLines.length >= 1 && (oldLines.length > 1 || oldLines[0].trim() === (file.find((l) => l.trim() === oldLines[0].trim()) ?? "\0").trim()))
|
|
193
|
+
for (let i = 0; i + oldLines.length <= file.length; i++) {
|
|
194
|
+
let ok = true;
|
|
195
|
+
for (let j = 0; j < oldLines.length && ok; j++)
|
|
196
|
+
ok = file[i + j].trim() === oldLines[j].trim();
|
|
197
|
+
if (ok)
|
|
198
|
+
hits.push(i);
|
|
199
|
+
}
|
|
200
|
+
if (hits.length === 1) {
|
|
201
|
+
const rep = reindent(nw.replace(/\n$/, "").split("\n"), oldLines[0], file[hits[0]]);
|
|
202
|
+
file.splice(hits[0], oldLines.length, ...rep);
|
|
203
|
+
return { out: file.join("\n"), fuzzy: true };
|
|
204
|
+
}
|
|
205
|
+
return { error: hits.length > 1 ? `\`old\` did not match exactly, and ${hits.length} places match ignoring indentation; include more surrounding lines.` : "`old` was not found in the file (check the exact text, including indentation; read the file again if it may have changed)." };
|
|
206
|
+
}
|
|
207
|
+
/** Apply several edits in order to one file's text; all-or-nothing. */
|
|
208
|
+
export function replaceMany(content, edits) {
|
|
209
|
+
let cur = content, fuzzy = 0;
|
|
210
|
+
for (const [i, e] of edits.entries()) {
|
|
211
|
+
const r = replaceOnce(cur, e.old, e.new);
|
|
212
|
+
if ("error" in r)
|
|
213
|
+
return { error: `edit ${i + 1} of ${edits.length}: ${r.error}` };
|
|
214
|
+
cur = r.out;
|
|
215
|
+
if (r.fuzzy)
|
|
216
|
+
fuzzy++;
|
|
217
|
+
}
|
|
218
|
+
return { out: cur, fuzzy };
|
|
219
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
export type PermConfig = {
|
|
2
|
+
allow?: string[];
|
|
3
|
+
ask?: string[];
|
|
4
|
+
deny?: string[];
|
|
5
|
+
};
|
|
6
|
+
export type Verdict = {
|
|
7
|
+
action: "allow" | "ask" | "deny" | "none";
|
|
8
|
+
rule?: string;
|
|
9
|
+
reason?: string; /** came from the built-in floor: session shortcuts must not bypass it */
|
|
10
|
+
floor?: boolean;
|
|
11
|
+
};
|
|
12
|
+
export type PermissionChecker = {
|
|
13
|
+
check(tool: string, args: Record<string, any>): Verdict;
|
|
14
|
+
};
|
|
15
|
+
/** Split a shell command into the commands it runs. Quotes are respected; anything we cannot reason about (subshells, backticks) is reported. */
|
|
16
|
+
export declare function splitCommand(cmd: string): {
|
|
17
|
+
parts: string[];
|
|
18
|
+
opaque: boolean;
|
|
19
|
+
};
|
|
20
|
+
/** A command the way the shell will see it, for detection: quotes and backslash escapes inside words removed (r""m, r\m, "rm"), $'\x72m' decoded, ${IFS} turned into a space. Never used to RUN anything. */
|
|
21
|
+
export declare function normalizeCommand(cmd: string): string;
|
|
22
|
+
/** The command plus every other form it may take: normalised, and the text of nested `sh -c '...'`, `bash -c "..."` and `eval "..."` (recursively). All of them are checked against the floor and the risk list. */
|
|
23
|
+
export declare function commandViews(cmd: string, depth?: number): string[];
|
|
24
|
+
/** Decoding a payload and running it is how checks get dodged; it always deserves a human look. */
|
|
25
|
+
export declare const OBFUSCATED_EXEC: RegExp;
|
|
26
|
+
export declare function floorCheck(tool: string, args: Record<string, any>, cwd: string): Verdict;
|
|
27
|
+
export declare class Permissions implements PermissionChecker {
|
|
28
|
+
private cfg;
|
|
29
|
+
private cwd;
|
|
30
|
+
constructor(cfg: PermConfig, cwd: string);
|
|
31
|
+
static projectFile(cwd: string): string;
|
|
32
|
+
/** `deny` and `ask` from any file always apply (they only restrict); `allow` from a project file needs `trustProject`, since it removes prompts. */
|
|
33
|
+
static load(cwd: string, o?: {
|
|
34
|
+
trustProject?: boolean;
|
|
35
|
+
userDir?: string;
|
|
36
|
+
}): Permissions;
|
|
37
|
+
/** Append a rule to the project file (used by the approval dialog and `/permissions`). */
|
|
38
|
+
static addProjectRule(cwd: string, list: "allow" | "ask" | "deny", rule: string): void;
|
|
39
|
+
rules(): Required<PermConfig>;
|
|
40
|
+
private matches;
|
|
41
|
+
/** Per-command matching for shell: a compound command is checked piece by piece. */
|
|
42
|
+
private anyMatch;
|
|
43
|
+
check(tool: string, args: Record<string, any>): Verdict;
|
|
44
|
+
}
|
|
45
|
+
/** Fingerprint of the project files that can remove prompts or run commands (hooks and permission rules): approve once, re-approve when they change. */
|
|
46
|
+
export declare function projectConfigFingerprint(cwd: string): string | undefined;
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
// Permission rules and a hard floor. Rules live in .amness/permissions.json (project) and ~/.amness/permissions.json (yours):
|
|
2
|
+
// { "allow": ["sh(npm test*)", "edit(src/**)"], "ask": ["sh(git commit*)"], "deny": ["sh(git push*)", "edit(.env)"] }
|
|
3
|
+
// Order of precedence: floor > deny > ask > allow > the permission mode. The floor holds in EVERY mode, yolo included.
|
|
4
|
+
import { createHash } from "node:crypto";
|
|
5
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
6
|
+
import { homedir } from "node:os";
|
|
7
|
+
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
8
|
+
import { patchPaths } from "./patch.js";
|
|
9
|
+
const SHELL = new Set(["sh", "sh_start"]), EDIT = new Set(["edit_file", "write_file", "apply_patch"]), READ = new Set(["read_file", "grep", "list_files"]);
|
|
10
|
+
const kindOf = (tool) => (SHELL.has(tool) ? "sh" : EDIT.has(tool) ? "edit" : READ.has(tool) ? "read" : tool);
|
|
11
|
+
/** `*` matches within a path segment... except in commands, where it matches anything; `**` crosses folders. */
|
|
12
|
+
const glob = (pat, loose) => {
|
|
13
|
+
const re = pat.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*\*/g, "\u0000").replace(/\*/g, loose ? ".*" : "[^/]*").replace(/\u0000/g, ".*").replace(/\?/g, loose ? "." : "[^/]");
|
|
14
|
+
return new RegExp(`^${re}$`, "s");
|
|
15
|
+
};
|
|
16
|
+
const parseRule = (r) => { const m = /^([\w:.-]+?)(?:\((.*)\))?$/s.exec(r.trim()); return m ? { kind: m[1], pat: m[2] } : undefined; };
|
|
17
|
+
/** Split a shell command into the commands it runs. Quotes are respected; anything we cannot reason about (subshells, backticks) is reported. */
|
|
18
|
+
export function splitCommand(cmd) {
|
|
19
|
+
const parts = [];
|
|
20
|
+
let cur = "", q = "", opaque = false;
|
|
21
|
+
for (let i = 0; i < cmd.length; i++) {
|
|
22
|
+
const c = cmd[i], n = cmd[i + 1];
|
|
23
|
+
if (q) {
|
|
24
|
+
if (c === q)
|
|
25
|
+
q = "";
|
|
26
|
+
else if (c === "\\" && q === '"') {
|
|
27
|
+
cur += c + (n ?? "");
|
|
28
|
+
i++;
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
else if (q === '"' && (c === "`" || (c === "$" && n === "(")))
|
|
32
|
+
opaque = true;
|
|
33
|
+
cur += c;
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
if (c === "'" || c === '"') {
|
|
37
|
+
q = c;
|
|
38
|
+
cur += c;
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
if (c === "\\") {
|
|
42
|
+
cur += c + (n ?? "");
|
|
43
|
+
i++;
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
if (c === "`" || (c === "$" && n === "(") || ((c === "<" || c === ">") && n === "("))
|
|
47
|
+
opaque = true;
|
|
48
|
+
if (c === ";" || c === "\n" || c === "|" || c === "&") {
|
|
49
|
+
if (cur.trim())
|
|
50
|
+
parts.push(cur.trim());
|
|
51
|
+
cur = "";
|
|
52
|
+
if ((c === "|" || c === "&") && n === c)
|
|
53
|
+
i++;
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
cur += c;
|
|
57
|
+
}
|
|
58
|
+
if (cur.trim())
|
|
59
|
+
parts.push(cur.trim());
|
|
60
|
+
return { parts, opaque };
|
|
61
|
+
}
|
|
62
|
+
;
|
|
63
|
+
/** A command the way the shell will see it, for detection: quotes and backslash escapes inside words removed (r""m, r\m, "rm"), $'\x72m' decoded, ${IFS} turned into a space. Never used to RUN anything. */
|
|
64
|
+
export function normalizeCommand(cmd) {
|
|
65
|
+
let s = cmd.replace(/\$'((?:[^'\\]|\\.)*)'/g, (_m, body) => body.replace(/\\x([0-9a-fA-F]{2})/g, (_x, h) => String.fromCharCode(parseInt(h, 16))).replace(/\\([0-7]{1,3})/g, (_x, o) => String.fromCharCode(parseInt(o, 8))).replace(/\\(.)/g, "$1"));
|
|
66
|
+
s = s.replace(/\$\{IFS\}|\$IFS/g, " ");
|
|
67
|
+
return s.replace(/\\(?=[A-Za-z0-9_./~-])/g, "").replace(/(["'])\1/g, "").replace(/["']/g, "");
|
|
68
|
+
}
|
|
69
|
+
/** The command plus every other form it may take: normalised, and the text of nested `sh -c '...'`, `bash -c "..."` and `eval "..."` (recursively). All of them are checked against the floor and the risk list. */
|
|
70
|
+
export function commandViews(cmd, depth = 0) {
|
|
71
|
+
const views = new Set([cmd, normalizeCommand(cmd)]);
|
|
72
|
+
if (depth < 3)
|
|
73
|
+
for (const m of cmd.matchAll(/\b(?:ba|z|da|k)?sh\s+(?:-\w*c\w*)\s+(?:"((?:[^"\\]|\\.)*)"|'([^']*)')|\beval\s+(?:"((?:[^"\\]|\\.)*)"|'([^']*)'|(\S+))/g)) {
|
|
74
|
+
const inner = m[1] ?? m[2] ?? m[3] ?? m[4] ?? m[5];
|
|
75
|
+
if (inner)
|
|
76
|
+
for (const v of commandViews(inner, depth + 1))
|
|
77
|
+
views.add(v);
|
|
78
|
+
}
|
|
79
|
+
return [...views];
|
|
80
|
+
}
|
|
81
|
+
/** Decoding a payload and running it is how checks get dodged; it always deserves a human look. */
|
|
82
|
+
export const OBFUSCATED_EXEC = /\b(?:base64|b64decode|xxd\s+-r|openssl\s+(?:enc|base64)\b)[^|;&]*\|\s*(?:sudo\s+)?(?:ba|z|da|k)?sh\b|\beval\s+["']?\$\(|\b(?:ba|z|da|k)?sh\s+<\(\s*(?:curl|wget)|\bpython3?\s+-c\s+["'][^"']*(?:base64|exec\(|__import__)/;
|
|
83
|
+
// ---- the floor: refused or always-asked, whatever the mode ------------------------------------------------------------------------------
|
|
84
|
+
const PROTECTED = /(^|\/)\.amness\/(hooks|permissions)\.json$/;
|
|
85
|
+
const FLOOR_DENY = [
|
|
86
|
+
[/\brm\s+(?:-\S+\s+)*-\S*[rRf]\S*\s+(?:-\S+\s+)*(?:\/\*?|~\/?\*?|\$HOME\/?\*?|\$\{HOME\}\/?\*?|\.\.(?:\/\S*)?)(?=\s|$)/, "recursive delete of the root, your home folder or a parent folder"],
|
|
87
|
+
[/(?:^|[\s;&|])(?:sudo\s+)?mkfs(?:\.\w+)?\b/, "formatting a filesystem"],
|
|
88
|
+
[/\bdd\b[^|;&]*\bof=\/dev\/(?:sd|nvme|disk|hd|mmcblk)/, "writing raw data to a disk device"],
|
|
89
|
+
[/>\s*\/dev\/(?:sd|nvme|disk|hd|mmcblk)/, "writing to a disk device"],
|
|
90
|
+
[/:\(\)\s*\{\s*:\s*\|\s*:\s*&\s*\}\s*;\s*:/, "a fork bomb"],
|
|
91
|
+
[/\bchmod\s+-R\s+0?777\s+\/(?:\s|$)/, "making the whole filesystem world-writable"],
|
|
92
|
+
[/\bgit\s+(?:-\S+\s+)*push\b[^|;&]*\s(?:--force(?!-with-lease)|-f)\b[^|;&]*\b(?:main|master)\b/, "force-pushing to main or master"],
|
|
93
|
+
// Windows families (PowerShell and cmd): the same kinds of damage
|
|
94
|
+
[/\b(?:Remove-Item|ri|rm|del|erase)\b[^|;&\n]*-Recurse\b[^|;&\n]*(?:\s|['"])(?:[A-Za-z]:[\\/]?(?=\s|['"]|$)|[\\/](?=\s|['"]|$)|~(?=[\\/]?(?:\s|['"]|$))|\$HOME\b|\$env:(?:USERPROFILE|HOMEPATH|SystemRoot|SystemDrive)\b)/i, "recursive delete of a drive, your home folder or the system folder"],
|
|
95
|
+
[/\b(?:rd|rmdir|del|erase)\s+(?:\/[a-z]\s+)*\/s\b[^&|\n]*\s(?:[A-Za-z]:[\\/]?(?:\*(?:\.\*)?)?|%(?:USERPROFILE|HOMEPATH|SystemRoot|SystemDrive)%)(?=\s|$)/i, "recursive delete of a drive, your home folder or the system folder"],
|
|
96
|
+
[/\bFormat-Volume\b|\bformat(?:\.com)?\s+[A-Za-z]:(?=\s|$)|\bClear-Disk\b|\bInitialize-Disk\b/i, "formatting or wiping a disk"],
|
|
97
|
+
];
|
|
98
|
+
const FLOOR_ASK = [
|
|
99
|
+
[/\b(?:curl|wget)\b[^|;&]*\|\s*(?:sudo\s+)?(?:ba|z|da|k)?sh\b/, "downloads and runs a script"],
|
|
100
|
+
[/\bgit\s+(?:-\S+\s+)*push\b[^|;&]*\s(?:--force|--force-with-lease|-f)\b/, "force-pushes"],
|
|
101
|
+
[/\b(?:Invoke-WebRequest|iwr|Invoke-RestMethod|irm|curl|wget)\b[^|;\n]*\|\s*(?:Invoke-Expression|iex)\b/i, "downloads and runs a script"],
|
|
102
|
+
[/(?:^|[\s;&|])-(?:EncodedCommand|enc|ec)\s+\S+|\bFromBase64String\b[^;\n]*\b(?:Invoke-Expression|iex)\b/i, "runs an encoded command"],
|
|
103
|
+
];
|
|
104
|
+
const WRITES_PROTECTED = /\.amness\/(?:hooks|permissions)\.json/;
|
|
105
|
+
const READONLY_VERB = /^\s*(?:cat|head|tail|less|grep|rg|ls|wc|stat|file|diff)\b/;
|
|
106
|
+
/** The text of an apply_patch call: inline, or read from the patch file it names (so rules and the floor see the real paths). */
|
|
107
|
+
const patchOf = (args, cwd) => { if (typeof args.patch === "string")
|
|
108
|
+
return args.patch; try {
|
|
109
|
+
return typeof args.file === "string" ? readFileSync(resolve(cwd, args.file), "utf8") : "";
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
return "";
|
|
113
|
+
} };
|
|
114
|
+
export function floorCheck(tool, args, cwd) {
|
|
115
|
+
if (tool === "apply_patch") {
|
|
116
|
+
for (const p of patchPaths(patchOf(args, cwd))) {
|
|
117
|
+
const v = floorCheck("edit_file", { path: p }, cwd);
|
|
118
|
+
if (v.action !== "none")
|
|
119
|
+
return v;
|
|
120
|
+
}
|
|
121
|
+
return { action: "none" };
|
|
122
|
+
}
|
|
123
|
+
const kind = kindOf(tool);
|
|
124
|
+
if (kind === "edit" && typeof args.path === "string") {
|
|
125
|
+
const rel = relative(cwd, resolve(cwd, args.path)).split("\\").join("/");
|
|
126
|
+
if (rel === ".git" || rel.startsWith(".git/"))
|
|
127
|
+
return { action: "deny", floor: true, reason: "the .git folder is protected: change history with git commands you can see, not by editing its files" };
|
|
128
|
+
if (PROTECTED.test(rel))
|
|
129
|
+
return { action: "deny", floor: true, reason: "the agent may not edit its own hooks or permission rules" };
|
|
130
|
+
}
|
|
131
|
+
if (kind === "sh" && typeof args.cmd === "string") {
|
|
132
|
+
const cmd = args.cmd;
|
|
133
|
+
if (WRITES_PROTECTED.test(cmd) && !splitCommand(cmd).parts.every((p) => READONLY_VERB.test(p)))
|
|
134
|
+
return { action: "deny", floor: true, reason: "the agent may not change its own hooks or permission rules" };
|
|
135
|
+
const views = commandViews(cmd);
|
|
136
|
+
for (const [re, why] of FLOOR_DENY)
|
|
137
|
+
if (views.some((v) => re.test(v)))
|
|
138
|
+
return { action: "deny", floor: true, reason: why };
|
|
139
|
+
for (const [re, why] of FLOOR_ASK)
|
|
140
|
+
if (views.some((v) => re.test(v)))
|
|
141
|
+
return { action: "ask", floor: true, reason: why };
|
|
142
|
+
if (OBFUSCATED_EXEC.test(cmd) || OBFUSCATED_EXEC.test(views[1] ?? ""))
|
|
143
|
+
return { action: "ask", floor: true, reason: "decodes or downloads a payload and runs it" };
|
|
144
|
+
}
|
|
145
|
+
return { action: "none" };
|
|
146
|
+
}
|
|
147
|
+
const readCfg = (file) => {
|
|
148
|
+
if (!existsSync(file))
|
|
149
|
+
return {};
|
|
150
|
+
const j = JSON.parse(readFileSync(file, "utf8")), out = {};
|
|
151
|
+
for (const k of ["allow", "ask", "deny"]) {
|
|
152
|
+
const l = j[k];
|
|
153
|
+
if (l === undefined)
|
|
154
|
+
continue;
|
|
155
|
+
if (!Array.isArray(l) || l.some((x) => typeof x !== "string" || !parseRule(x)))
|
|
156
|
+
throw new Error(`${file}: "${k}" must be a list of rules like "sh(npm test*)" or "edit(src/**)"`);
|
|
157
|
+
out[k] = l;
|
|
158
|
+
}
|
|
159
|
+
return out;
|
|
160
|
+
};
|
|
161
|
+
export class Permissions {
|
|
162
|
+
cfg;
|
|
163
|
+
cwd;
|
|
164
|
+
constructor(cfg, cwd) { this.cfg = { allow: cfg.allow ?? [], ask: cfg.ask ?? [], deny: cfg.deny ?? [] }; this.cwd = resolve(cwd); }
|
|
165
|
+
static projectFile(cwd) { return join(cwd, ".amness", "permissions.json"); }
|
|
166
|
+
/** `deny` and `ask` from any file always apply (they only restrict); `allow` from a project file needs `trustProject`, since it removes prompts. */
|
|
167
|
+
static load(cwd, o = {}) {
|
|
168
|
+
const user = readCfg(join(o.userDir ?? process.env.AMNESS_HOME ?? join(homedir(), ".amness"), "permissions.json")), proj = readCfg(Permissions.projectFile(cwd));
|
|
169
|
+
return new Permissions({ allow: [...(user.allow ?? []), ...(o.trustProject ? proj.allow ?? [] : [])], ask: [...(user.ask ?? []), ...(proj.ask ?? [])], deny: [...(user.deny ?? []), ...(proj.deny ?? [])] }, cwd);
|
|
170
|
+
}
|
|
171
|
+
/** Append a rule to the project file (used by the approval dialog and `/permissions`). */
|
|
172
|
+
static addProjectRule(cwd, list, rule) {
|
|
173
|
+
if (!parseRule(rule))
|
|
174
|
+
throw new Error(`not a rule: ${rule}`);
|
|
175
|
+
const f = Permissions.projectFile(cwd);
|
|
176
|
+
let cur = {};
|
|
177
|
+
try {
|
|
178
|
+
cur = existsSync(f) ? JSON.parse(readFileSync(f, "utf8")) : {};
|
|
179
|
+
}
|
|
180
|
+
catch { /* start fresh */ }
|
|
181
|
+
cur[list] = [...new Set([...(cur[list] ?? []), rule])];
|
|
182
|
+
mkdirSync(dirname(f), { recursive: true });
|
|
183
|
+
writeFileSync(f, JSON.stringify(cur, null, 2) + "\n");
|
|
184
|
+
}
|
|
185
|
+
rules() { return this.cfg; }
|
|
186
|
+
matches(rule, tool, args) {
|
|
187
|
+
const r = parseRule(rule);
|
|
188
|
+
if (!r)
|
|
189
|
+
return false;
|
|
190
|
+
const kind = kindOf(tool);
|
|
191
|
+
if (r.kind !== kind && r.kind !== tool)
|
|
192
|
+
return false;
|
|
193
|
+
if (r.pat === undefined)
|
|
194
|
+
return true;
|
|
195
|
+
if (kind === "sh")
|
|
196
|
+
return typeof args.cmd === "string" && glob(r.pat, true).test(args.cmd.trim());
|
|
197
|
+
const p = typeof args.path === "string" ? args.path : typeof args.pattern === "string" ? args.pattern : undefined;
|
|
198
|
+
if (p !== undefined && (kind === "edit" || kind === "read")) {
|
|
199
|
+
const rel = relative(this.cwd, isAbsolute(p) ? p : resolve(this.cwd, p)).split("\\").join("/");
|
|
200
|
+
return glob(r.pat, false).test(rel) || glob(r.pat, false).test(p);
|
|
201
|
+
}
|
|
202
|
+
return glob(r.pat, true).test(JSON.stringify(args));
|
|
203
|
+
}
|
|
204
|
+
/** Per-command matching for shell: a compound command is checked piece by piece. */
|
|
205
|
+
anyMatch(list, tool, args) {
|
|
206
|
+
if (kindOf(tool) === "sh" && typeof args.cmd === "string") {
|
|
207
|
+
const { parts } = splitCommand(args.cmd);
|
|
208
|
+
return list.find((rule) => this.matches(rule, tool, args) || parts.some((p) => this.matches(rule, tool, { ...args, cmd: p })));
|
|
209
|
+
}
|
|
210
|
+
return list.find((r) => this.matches(r, tool, args));
|
|
211
|
+
}
|
|
212
|
+
check(tool, args) {
|
|
213
|
+
if (tool === "apply_patch") { // a patch is judged file by file: any denied file denies it, any "ask" asks, and it is only allowed when every file is
|
|
214
|
+
const paths = patchPaths(patchOf(args, this.cwd)), per = paths.map((p) => this.check("edit_file", { path: p }));
|
|
215
|
+
return per.find((v) => v.action === "deny") ?? per.find((v) => v.action === "ask") ?? (per.length && per.every((v) => v.action === "allow") ? { action: "allow", rule: per[0].rule } : { action: "none" });
|
|
216
|
+
}
|
|
217
|
+
const floor = floorCheck(tool, args, this.cwd);
|
|
218
|
+
if (floor.action !== "none")
|
|
219
|
+
return floor;
|
|
220
|
+
const deny = this.anyMatch(this.cfg.deny, tool, args);
|
|
221
|
+
if (deny)
|
|
222
|
+
return { action: "deny", rule: deny, reason: `denied by your permission rule \`${deny}\`` };
|
|
223
|
+
const ask = this.anyMatch(this.cfg.ask, tool, args);
|
|
224
|
+
if (ask)
|
|
225
|
+
return { action: "ask", rule: ask, reason: `matches your rule \`${ask}\`, which requires approval` };
|
|
226
|
+
if (kindOf(tool) === "sh" && typeof args.cmd === "string") {
|
|
227
|
+
const { parts, opaque } = splitCommand(args.cmd);
|
|
228
|
+
if (!opaque && parts.length && parts.every((p) => this.cfg.allow.some((r) => this.matches(r, tool, { ...args, cmd: p }))))
|
|
229
|
+
return { action: "allow", rule: this.cfg.allow.find((r) => this.matches(r, tool, { ...args, cmd: parts[0] })) };
|
|
230
|
+
return { action: "none" };
|
|
231
|
+
}
|
|
232
|
+
const allow = this.cfg.allow.find((r) => this.matches(r, tool, args));
|
|
233
|
+
return allow ? { action: "allow", rule: allow } : { action: "none" };
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
/** Fingerprint of the project files that can remove prompts or run commands (hooks and permission rules): approve once, re-approve when they change. */
|
|
237
|
+
export function projectConfigFingerprint(cwd) {
|
|
238
|
+
const files = [join(cwd, ".amness", "hooks.json"), Permissions.projectFile(cwd)].filter(existsSync);
|
|
239
|
+
if (!files.length)
|
|
240
|
+
return undefined;
|
|
241
|
+
const h = createHash("sha256");
|
|
242
|
+
for (const f of files)
|
|
243
|
+
h.update(f).update(readFileSync(f));
|
|
244
|
+
return h.digest("hex").slice(0, 16);
|
|
245
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import type { Case, MetricFn, Variant } from "./evals.ts";
|
|
2
|
+
import type { Grader } from "./grade.ts";
|
|
3
|
+
import type { Guard, Message, Model, Tool } from "./harness.ts";
|
|
4
|
+
import type { MemoryBackend } from "./memory.ts";
|
|
5
|
+
import type { RouteInfo } from "./router.ts";
|
|
6
|
+
import type { HookEvent, HookRunner } from "./hooks.ts";
|
|
7
|
+
export type HookReturn = {
|
|
8
|
+
block?: string;
|
|
9
|
+
context?: string;
|
|
10
|
+
args?: Record<string, unknown>; /** PreModel only: the tools that may be called in this turn */
|
|
11
|
+
tools?: string[];
|
|
12
|
+
then?: string;
|
|
13
|
+
} | string | void;
|
|
14
|
+
export type HookHandler = (payload: Record<string, unknown> & {
|
|
15
|
+
event: HookEvent;
|
|
16
|
+
cwd: string;
|
|
17
|
+
}) => HookReturn | Promise<HookReturn>;
|
|
18
|
+
/** A harness variant for `amness eval`, as data (switch guards off, add plugins, change the prompt) or as code (`build` returns the whole Variant). */
|
|
19
|
+
export type VariantSpec = {
|
|
20
|
+
off?: Guard[];
|
|
21
|
+
maxSteps?: number; /** run with the case's verify command as the finish gate (default true) */
|
|
22
|
+
verify?: boolean;
|
|
23
|
+
review?: boolean;
|
|
24
|
+
/** plugins (by name) active in this variant, in addition to the plugin that defines it */ plugins?: string[];
|
|
25
|
+
system?: (base: string) => string;
|
|
26
|
+
build?: (model: Model, o: {
|
|
27
|
+
maxSteps?: number;
|
|
28
|
+
maxTokens?: number;
|
|
29
|
+
}) => Variant;
|
|
30
|
+
};
|
|
31
|
+
/** What a plugin's slash command can do: print, send a message to the agent, run a command, read the folder. Nothing else of the UI is exposed on purpose. */
|
|
32
|
+
export type PluginCtx = {
|
|
33
|
+
cwd: string;
|
|
34
|
+
say(text: string): void; /** send a message to the agent, as if you had typed it */
|
|
35
|
+
ask(text: string): Promise<void>;
|
|
36
|
+
run(cmd: string, timeoutMs?: number): Promise<{
|
|
37
|
+
code: number;
|
|
38
|
+
output: string;
|
|
39
|
+
}>;
|
|
40
|
+
};
|
|
41
|
+
export type PluginCommand = {
|
|
42
|
+
name: string;
|
|
43
|
+
help: string;
|
|
44
|
+
args?: string;
|
|
45
|
+
run(ctx: PluginCtx, arg: string): void | Promise<void>;
|
|
46
|
+
};
|
|
47
|
+
/** A tool call about to run, as a policy sees it. */
|
|
48
|
+
export type PolicyCall = {
|
|
49
|
+
tool: string;
|
|
50
|
+
args: Record<string, unknown>;
|
|
51
|
+
cwd: string;
|
|
52
|
+
};
|
|
53
|
+
export type PolicyDecision = {
|
|
54
|
+
action: "allow" | "ask" | "deny";
|
|
55
|
+
reason?: string;
|
|
56
|
+
plugin?: string;
|
|
57
|
+
};
|
|
58
|
+
export type PolicyResult = "allow" | "ask" | "deny" | {
|
|
59
|
+
action: "allow" | "ask" | "deny";
|
|
60
|
+
reason?: string;
|
|
61
|
+
} | void;
|
|
62
|
+
/**
|
|
63
|
+
* Ask every plugin's `policy`. A deny always wins, then an ask, then an allow. The caller decides what each answer may do:
|
|
64
|
+
* the harness never lets a policy relax the safety floor or the untrusted-text check, only tighten them or skip an ordinary prompt.
|
|
65
|
+
*/
|
|
66
|
+
export declare function checkPolicies(plugins: Plugin[], call: PolicyCall): Promise<PolicyDecision | undefined>;
|
|
67
|
+
/** A finish gate: your own check that must pass before the agent may finish. It sees what really happened (files changed, whether tests passed after the last edit) and can run commands. */
|
|
68
|
+
export type GateCtx = {
|
|
69
|
+
summary: string;
|
|
70
|
+
task: string;
|
|
71
|
+
changed: string[];
|
|
72
|
+
testsAfterLastEdit?: boolean;
|
|
73
|
+
notes: string[];
|
|
74
|
+
cwd: string;
|
|
75
|
+
run: PluginCtx["run"]; /** how many times this gate has already rejected this task */
|
|
76
|
+
rejections: number;
|
|
77
|
+
};
|
|
78
|
+
export type GateResult = boolean | string | string[] | {
|
|
79
|
+
pass: boolean;
|
|
80
|
+
findings?: string[];
|
|
81
|
+
} | void;
|
|
82
|
+
export type FinishGate = {
|
|
83
|
+
name: string; /** rejections before the run may finish anyway (default 2, so a wrong gate cannot trap the agent) */
|
|
84
|
+
maxRejections?: number;
|
|
85
|
+
check(ctx: GateCtx): GateResult | Promise<GateResult>;
|
|
86
|
+
};
|
|
87
|
+
/** Several reviewers vote: the finish is accepted when at least `quorum` of them pass ("all", "majority", or a number). */
|
|
88
|
+
export declare function votingGate(name: string, reviewers: {
|
|
89
|
+
name: string;
|
|
90
|
+
review(ctx: GateCtx): GateResult | Promise<GateResult>;
|
|
91
|
+
}[], quorum?: "all" | "majority" | number, maxRejections?: number): FinishGate;
|
|
92
|
+
export type Plugin = {
|
|
93
|
+
name: string;
|
|
94
|
+
description?: string;
|
|
95
|
+
tools?: Tool[];
|
|
96
|
+
hooks?: Partial<Record<HookEvent, HookHandler>>;
|
|
97
|
+
/** a handler that throws blocks instead of warning (for hooks that enforce a rule) */ failClosed?: boolean;
|
|
98
|
+
/** your own context strategy (compaction, clearing, memory injection): called before every model request, may return a replacement message list */ context?: (msgs: Message[], info: {
|
|
99
|
+
step: number;
|
|
100
|
+
promptTokens: number;
|
|
101
|
+
budget: number;
|
|
102
|
+
}) => Message[] | void | Promise<Message[] | void>;
|
|
103
|
+
/** replace the built-in memory (notes saved and recalled across sessions) with your own backend; the first plugin that provides one wins */ memory?: MemoryBackend | ((cwd: string) => MemoryBackend);
|
|
104
|
+
/** choose the model for each request: return a model id (same provider; needs `modelById`), or nothing to use the default. Sees the conversation, its size and what the last steps were. */ route?: (info: RouteInfo) => string | undefined | void;
|
|
105
|
+
/** a short line shown in the sidebar (and updated as you work): branch policy, build state, ticket id, anything. Keep it fast: it runs on every redraw (the result is cached for one second). */ status?: (ctx: {
|
|
106
|
+
cwd: string;
|
|
107
|
+
running: boolean;
|
|
108
|
+
}) => string | undefined | void;
|
|
109
|
+
/** policy as code: decide allow, ask or deny for each tool call before it runs. It can tighten anything, and it can skip an ordinary approval prompt, but it can never loosen the safety floor or the untrusted-text check. */ policy?: (call: PolicyCall) => PolicyResult | Promise<PolicyResult>;
|
|
110
|
+
/** finish gates: your own checks that must pass before the agent may finish (see GateCtx) */ gates?: FinishGate[];
|
|
111
|
+
/** slash commands for the chat (a name that a built-in command already uses is skipped) */ commands?: PluginCommand[];
|
|
112
|
+
/** rewrite the system prompt (runs once per conversation start) */ system?: (base: string, ctx: {
|
|
113
|
+
cwd: string;
|
|
114
|
+
}) => string;
|
|
115
|
+
/** evaluation: tasks with graders written as functions, and harness variants */ cases?: Case[]; /** graders for real sessions (`amness plugin grade`): functions of a recorded session that return a score */
|
|
116
|
+
graders?: Record<string, Grader>; /** custom numbers to record for every eval run (see MetricFn) */
|
|
117
|
+
metrics?: Record<string, MetricFn>;
|
|
118
|
+
variants?: Record<string, VariantSpec>;
|
|
119
|
+
};
|
|
120
|
+
/** Identity function that gives you types and completion while writing a plugin. */
|
|
121
|
+
export declare const definePlugin: (p: Plugin) => Plugin;
|
|
122
|
+
export declare const projectPluginDir: (cwd: string) => string;
|
|
123
|
+
export declare const projectPluginFiles: (cwd: string) => string[];
|
|
124
|
+
/** A fingerprint of the project's plugin files: approval is remembered for exactly this content. */
|
|
125
|
+
export declare function pluginFingerprint(cwd: string): string | undefined;
|
|
126
|
+
export type Loaded = {
|
|
127
|
+
plugins: Plugin[];
|
|
128
|
+
warnings: string[]; /** project plugins exist but were not loaded because they are not approved */
|
|
129
|
+
untrusted?: {
|
|
130
|
+
count: number;
|
|
131
|
+
fingerprint: string;
|
|
132
|
+
};
|
|
133
|
+
};
|
|
134
|
+
/** Import one plugin file (also used by `amness eval --plugin <file>`, where passing the file is the approval). */
|
|
135
|
+
export declare function importPlugins(file: string): Promise<{
|
|
136
|
+
plugins: Plugin[];
|
|
137
|
+
warnings: string[];
|
|
138
|
+
}>;
|
|
139
|
+
/** User plugins always load; project plugins only when `trustProject`. Problems are warnings, never exceptions: a broken plugin must not stop the agent starting. */
|
|
140
|
+
export declare function loadPlugins(cwd: string, o?: {
|
|
141
|
+
trustProject?: boolean;
|
|
142
|
+
userDir?: string;
|
|
143
|
+
}): Promise<Loaded>;
|
|
144
|
+
/** Hooks from plugins, run in-process after the shell hooks. Same rules as shell hooks: the first block stops the rest, context accumulates, the last rewritten args win. */
|
|
145
|
+
export declare function composeHooks(shell: HookRunner | undefined, plugins: Plugin[], cwd: string): HookRunner | undefined;
|
|
146
|
+
export declare const applySystem: (plugins: Plugin[], base: string, cwd: string) => string;
|
|
147
|
+
/** A starting point for `amness plugin new <name>`. */
|
|
148
|
+
export declare const pluginTemplate: (name: string) => string;
|