@nebutra/agent-runtime 0.2.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/.turbo/turbo-build.log +115 -0
- package/.turbo/turbo-test.log +44 -0
- package/.turbo/turbo-typecheck.log +4 -0
- package/CHANGELOG.md +253 -0
- package/LICENSE +676 -0
- package/README.md +50 -0
- package/dist/adapters/dispatcher-sse.d.ts +68 -0
- package/dist/adapters/dispatcher-sse.js +11 -0
- package/dist/adapters/dispatcher-sse.js.map +1 -0
- package/dist/adapters/index.d.ts +12 -0
- package/dist/adapters/index.js +21 -0
- package/dist/adapters/index.js.map +1 -0
- package/dist/adapters/mcp-catalog.d.ts +58 -0
- package/dist/adapters/mcp-catalog.js +9 -0
- package/dist/adapters/mcp-catalog.js.map +1 -0
- package/dist/adapters/prisma-rollout.d.ts +60 -0
- package/dist/adapters/prisma-rollout.js +7 -0
- package/dist/adapters/prisma-rollout.js.map +1 -0
- package/dist/chunk-24ZXP7FI.js +93 -0
- package/dist/chunk-24ZXP7FI.js.map +1 -0
- package/dist/chunk-2DA6Q6TN.js +126 -0
- package/dist/chunk-2DA6Q6TN.js.map +1 -0
- package/dist/chunk-37BBB2P2.js +73 -0
- package/dist/chunk-37BBB2P2.js.map +1 -0
- package/dist/chunk-57W3AR43.js +52 -0
- package/dist/chunk-57W3AR43.js.map +1 -0
- package/dist/chunk-5N4644PB.js +67 -0
- package/dist/chunk-5N4644PB.js.map +1 -0
- package/dist/chunk-5YS7WAPS.js +177 -0
- package/dist/chunk-5YS7WAPS.js.map +1 -0
- package/dist/chunk-6EGG2OZC.js +13 -0
- package/dist/chunk-6EGG2OZC.js.map +1 -0
- package/dist/chunk-7BUOF367.js +126 -0
- package/dist/chunk-7BUOF367.js.map +1 -0
- package/dist/chunk-BJBBR3QA.js +121 -0
- package/dist/chunk-BJBBR3QA.js.map +1 -0
- package/dist/chunk-CGRCUKGT.js +73 -0
- package/dist/chunk-CGRCUKGT.js.map +1 -0
- package/dist/chunk-FUG5DT2C.js +75 -0
- package/dist/chunk-FUG5DT2C.js.map +1 -0
- package/dist/chunk-LO24VOA3.js +199 -0
- package/dist/chunk-LO24VOA3.js.map +1 -0
- package/dist/chunk-MUF7ZZTO.js +57 -0
- package/dist/chunk-MUF7ZZTO.js.map +1 -0
- package/dist/chunk-NN7DATXA.js +46 -0
- package/dist/chunk-NN7DATXA.js.map +1 -0
- package/dist/chunk-PGGWSUTM.js +33 -0
- package/dist/chunk-PGGWSUTM.js.map +1 -0
- package/dist/chunk-RDKYDMXT.js +135 -0
- package/dist/chunk-RDKYDMXT.js.map +1 -0
- package/dist/chunk-YYFPDBJG.js +63 -0
- package/dist/chunk-YYFPDBJG.js.map +1 -0
- package/dist/chunk-ZMYX5VBU.js +135 -0
- package/dist/chunk-ZMYX5VBU.js.map +1 -0
- package/dist/chunk-ZTSKS42I.js +131 -0
- package/dist/chunk-ZTSKS42I.js.map +1 -0
- package/dist/commands.d.ts +74 -0
- package/dist/commands.js +10 -0
- package/dist/commands.js.map +1 -0
- package/dist/definitions.d.ts +94 -0
- package/dist/definitions.js +15 -0
- package/dist/definitions.js.map +1 -0
- package/dist/dispatcher.d.ts +50 -0
- package/dist/dispatcher.js +8 -0
- package/dist/dispatcher.js.map +1 -0
- package/dist/durable-turn.d.ts +58 -0
- package/dist/durable-turn.js +9 -0
- package/dist/durable-turn.js.map +1 -0
- package/dist/hook-pipeline.d.ts +114 -0
- package/dist/hook-pipeline.js +13 -0
- package/dist/hook-pipeline.js.map +1 -0
- package/dist/index.d.ts +1874 -0
- package/dist/index.js +3117 -0
- package/dist/index.js.map +1 -0
- package/dist/loop.d.ts +78 -0
- package/dist/loop.js +9 -0
- package/dist/loop.js.map +1 -0
- package/dist/mcp-bridge.d.ts +48 -0
- package/dist/mcp-bridge.js +8 -0
- package/dist/mcp-bridge.js.map +1 -0
- package/dist/model.d.ts +154 -0
- package/dist/model.js +9 -0
- package/dist/model.js.map +1 -0
- package/dist/policy.d.ts +130 -0
- package/dist/policy.js +23 -0
- package/dist/policy.js.map +1 -0
- package/dist/protocol.d.ts +170 -0
- package/dist/protocol.js +15 -0
- package/dist/protocol.js.map +1 -0
- package/dist/rollout-store-persistent.d.ts +48 -0
- package/dist/rollout-store-persistent.js +9 -0
- package/dist/rollout-store-persistent.js.map +1 -0
- package/dist/rollout.d.ts +82 -0
- package/dist/rollout.js +15 -0
- package/dist/rollout.js.map +1 -0
- package/dist/sandbox.d.ts +65 -0
- package/dist/sandbox.js +15 -0
- package/dist/sandbox.js.map +1 -0
- package/dist/skills.d.ts +93 -0
- package/dist/skills.js +10 -0
- package/dist/skills.js.map +1 -0
- package/dist/subagents.d.ts +129 -0
- package/dist/subagents.js +21 -0
- package/dist/subagents.js.map +1 -0
- package/dist/tools.d.ts +77 -0
- package/dist/tools.js +9 -0
- package/dist/tools.js.map +1 -0
- package/package.json +74 -0
- package/src/adapters/dispatcher-sse.test.ts +218 -0
- package/src/adapters/dispatcher-sse.ts +222 -0
- package/src/adapters/index.ts +18 -0
- package/src/adapters/mcp-catalog.test.ts +213 -0
- package/src/adapters/mcp-catalog.ts +188 -0
- package/src/adapters/prisma-rollout.test.ts +153 -0
- package/src/adapters/prisma-rollout.ts +104 -0
- package/src/agent-runtime.test.ts +176 -0
- package/src/artifact-stream.test.ts +330 -0
- package/src/artifact-stream.ts +453 -0
- package/src/channel-gateway.test.ts +432 -0
- package/src/channel-gateway.ts +357 -0
- package/src/code-review.test.ts +501 -0
- package/src/code-review.ts +495 -0
- package/src/command-suggestions.test.ts +251 -0
- package/src/command-suggestions.ts +338 -0
- package/src/commands.test.ts +184 -0
- package/src/commands.ts +140 -0
- package/src/commit-message.test.ts +249 -0
- package/src/commit-message.ts +180 -0
- package/src/context-compaction.test.ts +522 -0
- package/src/context-compaction.ts +434 -0
- package/src/definitions.test.ts +78 -0
- package/src/definitions.ts +190 -0
- package/src/deployment-status.test.ts +215 -0
- package/src/deployment-status.ts +227 -0
- package/src/design-context.test.ts +195 -0
- package/src/design-context.ts +198 -0
- package/src/dispatcher.test.ts +234 -0
- package/src/dispatcher.ts +189 -0
- package/src/durable-turn.test.ts +209 -0
- package/src/durable-turn.ts +135 -0
- package/src/edit-planner.test.ts +204 -0
- package/src/edit-planner.ts +325 -0
- package/src/fuzzy-match.test.ts +311 -0
- package/src/fuzzy-match.ts +444 -0
- package/src/hook-pipeline.test.ts +279 -0
- package/src/hook-pipeline.ts +373 -0
- package/src/inbound-admission.test.ts +394 -0
- package/src/inbound-admission.ts +246 -0
- package/src/index.ts +47 -0
- package/src/loop.test.ts +161 -0
- package/src/loop.ts +211 -0
- package/src/mcp-bridge.test.ts +165 -0
- package/src/mcp-bridge.ts +76 -0
- package/src/memory-provider.test.ts +232 -0
- package/src/memory-provider.ts +257 -0
- package/src/model.ts +168 -0
- package/src/permission-ruleset.test.ts +301 -0
- package/src/permission-ruleset.ts +200 -0
- package/src/policy.ts +151 -0
- package/src/project-repo.test.ts +232 -0
- package/src/project-repo.ts +311 -0
- package/src/protocol.ts +159 -0
- package/src/rollout-store-persistent.test.ts +217 -0
- package/src/rollout-store-persistent.ts +166 -0
- package/src/rollout.ts +150 -0
- package/src/sandbox.ts +113 -0
- package/src/session-share.test.ts +360 -0
- package/src/session-share.ts +310 -0
- package/src/skill-distillation.test.ts +177 -0
- package/src/skill-distillation.ts +369 -0
- package/src/skills.test.ts +277 -0
- package/src/skills.ts +255 -0
- package/src/subagents.test.ts +290 -0
- package/src/subagents.ts +332 -0
- package/src/tools.ts +126 -0
- package/src/workbench.test.ts +0 -0
- package/src/workbench.ts +0 -0
- package/tsconfig.json +12 -0
- package/tsup.config.ts +33 -0
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
type Action,
|
|
4
|
+
BUILTIN_ARITY,
|
|
5
|
+
commandPermissionKey,
|
|
6
|
+
commandPrefix,
|
|
7
|
+
evaluate,
|
|
8
|
+
type Rule,
|
|
9
|
+
type Ruleset,
|
|
10
|
+
wildcardMatch,
|
|
11
|
+
} from "./permission-ruleset.js";
|
|
12
|
+
|
|
13
|
+
describe("wildcardMatch — anchored full-string glob", () => {
|
|
14
|
+
it("matches literal strings exactly", () => {
|
|
15
|
+
expect(wildcardMatch("git", "git")).toBe(true);
|
|
16
|
+
expect(wildcardMatch("git", "npm")).toBe(false);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("is anchored (no partial matches)", () => {
|
|
20
|
+
expect(wildcardMatch("git status", "git")).toBe(false);
|
|
21
|
+
expect(wildcardMatch("agit", "git")).toBe(false);
|
|
22
|
+
expect(wildcardMatch("gitx", "git")).toBe(false);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("treats * as any run including empty", () => {
|
|
26
|
+
expect(wildcardMatch("", "*")).toBe(true);
|
|
27
|
+
expect(wildcardMatch("anything at all", "*")).toBe(true);
|
|
28
|
+
expect(wildcardMatch("abc", "a*c")).toBe(true);
|
|
29
|
+
expect(wildcardMatch("ac", "a*c")).toBe(true);
|
|
30
|
+
expect(wildcardMatch("abbbbc", "a*c")).toBe(true);
|
|
31
|
+
expect(wildcardMatch("abd", "a*c")).toBe(false);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("treats ? as exactly one char", () => {
|
|
35
|
+
expect(wildcardMatch("a", "?")).toBe(true);
|
|
36
|
+
expect(wildcardMatch("", "?")).toBe(false);
|
|
37
|
+
expect(wildcardMatch("ab", "?")).toBe(false);
|
|
38
|
+
expect(wildcardMatch("abc", "a?c")).toBe(true);
|
|
39
|
+
expect(wildcardMatch("ac", "a?c")).toBe(false);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("combines * and ? together", () => {
|
|
43
|
+
expect(wildcardMatch("file.test.ts", "*.ts")).toBe(true);
|
|
44
|
+
// `*` swallows "file.test", then ".t" + one `?` (= "s") consumes ".ts"
|
|
45
|
+
expect(wildcardMatch("file.test.ts", "*.t?")).toBe(true);
|
|
46
|
+
// ".t??" needs two trailing chars after ".t" — only one ("s") remains
|
|
47
|
+
expect(wildcardMatch("file.test.ts", "*.t??")).toBe(false);
|
|
48
|
+
expect(wildcardMatch("file.test.tsx", "*.t??")).toBe(true);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("treats regex metacharacters in the pattern as literals", () => {
|
|
52
|
+
expect(wildcardMatch("a.b", "a.b")).toBe(true);
|
|
53
|
+
expect(wildcardMatch("axb", "a.b")).toBe(false);
|
|
54
|
+
expect(wildcardMatch("a+b", "a+b")).toBe(true);
|
|
55
|
+
expect(wildcardMatch("(x)", "(x)")).toBe(true);
|
|
56
|
+
expect(wildcardMatch("a$b^", "a$b^")).toBe(true);
|
|
57
|
+
expect(wildcardMatch("a[b]c", "a[b]c")).toBe(true);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("empty pattern matches only empty string", () => {
|
|
61
|
+
expect(wildcardMatch("", "")).toBe(true);
|
|
62
|
+
expect(wildcardMatch("x", "")).toBe(false);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
describe("SPECIAL RULE — trailing ' *' is optional", () => {
|
|
66
|
+
it('pattern "git *" matches the head-only "git"', () => {
|
|
67
|
+
expect(wildcardMatch("git", "git *")).toBe(true);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('pattern "git *" matches "git <rest>"', () => {
|
|
71
|
+
expect(wildcardMatch("git status", "git *")).toBe(true);
|
|
72
|
+
expect(wildcardMatch("git commit -m x", "git *")).toBe(true);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('pattern "git *" still requires the head prefix', () => {
|
|
76
|
+
expect(wildcardMatch("npm", "git *")).toBe(false);
|
|
77
|
+
expect(wildcardMatch("gitx", "git *")).toBe(false);
|
|
78
|
+
expect(wildcardMatch("", "git *")).toBe(false);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("the space before * is required for the optional rule (the space is consumed)", () => {
|
|
82
|
+
// "git " (head + trailing space, nothing after) does NOT match the
|
|
83
|
+
// head-only branch (which is exactly "git"), but matches via "git " + empty *
|
|
84
|
+
expect(wildcardMatch("git ", "git *")).toBe(true);
|
|
85
|
+
expect(wildcardMatch("git", "git *")).toBe(true);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('multi-token head before " *"', () => {
|
|
89
|
+
expect(wildcardMatch("npm run", "npm run *")).toBe(true);
|
|
90
|
+
expect(wildcardMatch("npm run dev", "npm run *")).toBe(true);
|
|
91
|
+
expect(wildcardMatch("npm", "npm run *")).toBe(false);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('a bare "*" pattern is not treated as the optional-suffix rule', () => {
|
|
95
|
+
expect(wildcardMatch("", "*")).toBe(true);
|
|
96
|
+
expect(wildcardMatch("x", "*")).toBe(true);
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("handles adversarial patterns with many wildcards without catastrophic backtracking", () => {
|
|
101
|
+
const pattern = "*".repeat(30);
|
|
102
|
+
const subject = "a".repeat(200);
|
|
103
|
+
const start = Date.now();
|
|
104
|
+
expect(wildcardMatch(subject, pattern)).toBe(true);
|
|
105
|
+
expect(wildcardMatch("", pattern)).toBe(true);
|
|
106
|
+
// a near-worst-case alternation that would explode under naive regex backtracking
|
|
107
|
+
const mixed = `${"*a".repeat(30)}*`;
|
|
108
|
+
expect(wildcardMatch(`${"a".repeat(100)}`, mixed)).toBe(true);
|
|
109
|
+
expect(wildcardMatch(`${"b".repeat(100)}`, mixed)).toBe(false);
|
|
110
|
+
expect(Date.now() - start).toBeLessThan(500);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("is deterministic across repeated calls", () => {
|
|
114
|
+
for (let i = 0; i < 50; i++) {
|
|
115
|
+
expect(wildcardMatch("git push origin main", "git *")).toBe(true);
|
|
116
|
+
expect(wildcardMatch("rm -rf /", "git *")).toBe(false);
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
describe("evaluate — two-dimensional first-match wildcard resolution", () => {
|
|
122
|
+
const allowGitRead: Rule = { permission: "bash", pattern: "git status", action: "allow" };
|
|
123
|
+
const denyRm: Rule = { permission: "bash", pattern: "rm *", action: "deny" };
|
|
124
|
+
const askGit: Rule = { permission: "bash", pattern: "git *", action: "ask" };
|
|
125
|
+
|
|
126
|
+
it("returns the first matching rule (order matters)", () => {
|
|
127
|
+
const set: Ruleset = [allowGitRead, askGit, denyRm];
|
|
128
|
+
const r = evaluate("bash", "git status", set);
|
|
129
|
+
expect(r).toEqual(allowGitRead);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("falls through to the next rule when the first does not match", () => {
|
|
133
|
+
const set: Ruleset = [allowGitRead, askGit];
|
|
134
|
+
const r = evaluate("bash", "git push", set);
|
|
135
|
+
expect(r).toEqual(askGit);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("requires BOTH permission AND pattern to match (two-dimensional)", () => {
|
|
139
|
+
const set: Ruleset = [{ permission: "edit", pattern: "git *", action: "allow" }];
|
|
140
|
+
// pattern matches but permission does not -> no match -> default ask
|
|
141
|
+
const r = evaluate("bash", "git status", set);
|
|
142
|
+
expect(r).toEqual({ permission: "bash", pattern: "*", action: "ask" });
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("supports wildcard permissions", () => {
|
|
146
|
+
const set: Ruleset = [{ permission: "*", pattern: "git *", action: "allow" }];
|
|
147
|
+
expect(evaluate("bash", "git status", set).action).toBe("allow");
|
|
148
|
+
expect(evaluate("edit", "git status", set).action).toBe("allow");
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("concatenates multiple rulesets in argument order", () => {
|
|
152
|
+
const base: Ruleset = [{ permission: "bash", pattern: "*", action: "ask" }];
|
|
153
|
+
const overrides: Ruleset = [{ permission: "bash", pattern: "git *", action: "allow" }];
|
|
154
|
+
// base comes first -> its catch-all wins over the later override
|
|
155
|
+
expect(evaluate("bash", "git status", base, overrides).action).toBe("ask");
|
|
156
|
+
// reversed order -> the specific allow is reached first
|
|
157
|
+
expect(evaluate("bash", "git status", overrides, base).action).toBe("allow");
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("returns the fail-safe default (ask) when nothing matches", () => {
|
|
161
|
+
const r = evaluate("bash", "curl http://evil", []);
|
|
162
|
+
expect(r).toEqual({ permission: "bash", pattern: "*", action: "ask" });
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("default carries the queried permission and pattern verbatim", () => {
|
|
166
|
+
const r = evaluate("net", "https://example.com/x", [
|
|
167
|
+
{ permission: "bash", pattern: "*", action: "allow" },
|
|
168
|
+
]);
|
|
169
|
+
expect(r.permission).toBe("net");
|
|
170
|
+
expect(r.pattern).toBe("*");
|
|
171
|
+
expect(r.action).toBe("ask");
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it("does not mutate the input rulesets", () => {
|
|
175
|
+
const a: Ruleset = [{ permission: "bash", pattern: "git *", action: "allow" }];
|
|
176
|
+
const b: Ruleset = [{ permission: "bash", pattern: "*", action: "deny" }];
|
|
177
|
+
const aSnap = JSON.stringify(a);
|
|
178
|
+
const bSnap = JSON.stringify(b);
|
|
179
|
+
evaluate("bash", "git status", a, b);
|
|
180
|
+
evaluate("bash", "rm -rf", a, b);
|
|
181
|
+
expect(JSON.stringify(a)).toBe(aSnap);
|
|
182
|
+
expect(JSON.stringify(b)).toBe(bSnap);
|
|
183
|
+
expect(a.length).toBe(1);
|
|
184
|
+
expect(b.length).toBe(1);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it("is deterministic", () => {
|
|
188
|
+
const set: Ruleset = [askGit, denyRm];
|
|
189
|
+
for (let i = 0; i < 25; i++) {
|
|
190
|
+
expect(evaluate("bash", "git pull", set).action).toBe("ask");
|
|
191
|
+
expect(evaluate("bash", "rm x", set).action).toBe("deny");
|
|
192
|
+
}
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("Action type accepts the three documented values", () => {
|
|
196
|
+
const actions: Action[] = ["allow", "deny", "ask"];
|
|
197
|
+
expect(actions).toHaveLength(3);
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
describe("commandPrefix — longest-prefix-wins extraction", () => {
|
|
202
|
+
it("uses the built-in arity for git (2)", () => {
|
|
203
|
+
expect(commandPrefix(["git", "checkout", "main"])).toEqual(["git", "checkout"]);
|
|
204
|
+
expect(commandPrefix(["git", "status"])).toEqual(["git", "status"]);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it("prefers the longest matching prefix (npm run = 3 over npm = 2)", () => {
|
|
208
|
+
expect(commandPrefix(["npm", "run", "dev"])).toEqual(["npm", "run", "dev"]);
|
|
209
|
+
expect(commandPrefix(["npm", "install", "left-pad"])).toEqual(["npm", "install"]);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it("falls back to the first token for unknown commands", () => {
|
|
213
|
+
expect(commandPrefix(["foobar", "a", "b", "c"])).toEqual(["foobar"]);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it("returns an empty array for empty tokens", () => {
|
|
217
|
+
expect(commandPrefix([])).toEqual([]);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it("clamps the slice when arity exceeds the token count", () => {
|
|
221
|
+
expect(commandPrefix(["git"])).toEqual(["git"]);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it("respects single-arity commands", () => {
|
|
225
|
+
expect(commandPrefix(["ls", "-la", "src"])).toEqual(["ls"]);
|
|
226
|
+
expect(commandPrefix(["cat", "file.ts"])).toEqual(["cat"]);
|
|
227
|
+
expect(commandPrefix(["rm", "x", "y"])).toEqual(["rm"]);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it("merges a caller-supplied arity over the built-in table", () => {
|
|
231
|
+
expect(commandPrefix(["git", "checkout", "main"], { git: 1 })).toEqual(["git"]);
|
|
232
|
+
// extend with a new command
|
|
233
|
+
expect(commandPrefix(["terraform", "apply", "-auto"], { terraform: 2 })).toEqual([
|
|
234
|
+
"terraform",
|
|
235
|
+
"apply",
|
|
236
|
+
]);
|
|
237
|
+
// a multi-word override
|
|
238
|
+
expect(commandPrefix(["pnpm", "dlx", "create-x", "y"], { "pnpm dlx": 3 })).toEqual([
|
|
239
|
+
"pnpm",
|
|
240
|
+
"dlx",
|
|
241
|
+
"create-x",
|
|
242
|
+
]);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it("does not mutate the caller arity or the built-in table", () => {
|
|
246
|
+
const override = { git: 1 };
|
|
247
|
+
const snap = JSON.stringify(override);
|
|
248
|
+
const builtinSnap = JSON.stringify(BUILTIN_ARITY);
|
|
249
|
+
commandPrefix(["git", "a", "b"], override);
|
|
250
|
+
expect(JSON.stringify(override)).toBe(snap);
|
|
251
|
+
expect(JSON.stringify(BUILTIN_ARITY)).toBe(builtinSnap);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it("is deterministic", () => {
|
|
255
|
+
for (let i = 0; i < 25; i++) {
|
|
256
|
+
expect(commandPrefix(["docker", "compose", "up"])).toEqual(["docker", "compose"]);
|
|
257
|
+
}
|
|
258
|
+
});
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
describe("commandPermissionKey — flag-stripped prefix key", () => {
|
|
262
|
+
it("strips dash-prefixed tokens then applies the prefix", () => {
|
|
263
|
+
// Faithful model: tokens starting with "-" are dropped wholesale. There
|
|
264
|
+
// is no flag-argument awareness, so non-dash operands survive.
|
|
265
|
+
expect(commandPermissionKey("git --no-pager commit -m x")).toBe("git commit");
|
|
266
|
+
// "." is not a flag, so it survives and fills the git:2 slot.
|
|
267
|
+
expect(commandPermissionKey("git -C . commit -m x")).toBe("git .");
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it("derives a simple key for unknown commands (first token)", () => {
|
|
271
|
+
expect(commandPermissionKey("curl -sSL https://example.com")).toBe("curl");
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it("handles npm run with flags interleaved", () => {
|
|
275
|
+
expect(commandPermissionKey("npm --silent run build --prod")).toBe("npm run build");
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it("collapses arbitrary whitespace between tokens", () => {
|
|
279
|
+
expect(commandPermissionKey("git checkout\tmain")).toBe("git checkout");
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it("returns an empty string for an empty/whitespace command", () => {
|
|
283
|
+
expect(commandPermissionKey("")).toBe("");
|
|
284
|
+
expect(commandPermissionKey(" ")).toBe("");
|
|
285
|
+
expect(commandPermissionKey("--only --flags")).toBe("");
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
it("feeds cleanly into evaluate as the pattern dimension", () => {
|
|
289
|
+
const set: Ruleset = [{ permission: "bash", pattern: "git commit", action: "ask" }];
|
|
290
|
+
const key = commandPermissionKey("git -C /repo commit -m 'msg'");
|
|
291
|
+
expect(evaluate("bash", key, set).action).toBe("ask");
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
it("is deterministic", () => {
|
|
295
|
+
for (let i = 0; i < 25; i++) {
|
|
296
|
+
// No flag-argument awareness: "-n" is dropped, "ns" survives and fills
|
|
297
|
+
// the kubectl:2 slot, yielding a stable "kubectl ns".
|
|
298
|
+
expect(commandPermissionKey("kubectl -n ns get pods")).toBe("kubectl ns");
|
|
299
|
+
}
|
|
300
|
+
});
|
|
301
|
+
});
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Permission ruleset evaluator.
|
|
3
|
+
*
|
|
4
|
+
* A small, pure, stateless re-expression of a two-dimensional wildcard
|
|
5
|
+
* permission model plus a bash-command-prefix extractor. No global state,
|
|
6
|
+
* no I/O, no mutation of inputs — every function is referentially transparent.
|
|
7
|
+
*
|
|
8
|
+
* The model has two independent dimensions per rule:
|
|
9
|
+
* - `permission` — the capability namespace (e.g. "bash", "edit", "net")
|
|
10
|
+
* - `pattern` — the concrete subject within that namespace
|
|
11
|
+
* A rule applies only when BOTH dimensions match the query via {@link wildcardMatch}.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** The decision a rule yields. Unknown queries fail safe to "ask". */
|
|
15
|
+
export type Action = "allow" | "deny" | "ask";
|
|
16
|
+
|
|
17
|
+
/** A single permission rule. Both dimensions are matched as wildcard globs. */
|
|
18
|
+
export interface Rule {
|
|
19
|
+
permission: string;
|
|
20
|
+
pattern: string;
|
|
21
|
+
action: Action;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** An ordered list of rules. Earlier rules take precedence. */
|
|
25
|
+
export type Ruleset = Rule[];
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Anchored full-string glob matcher.
|
|
29
|
+
*
|
|
30
|
+
* Semantics:
|
|
31
|
+
* - `*` matches any run of characters, including the empty run.
|
|
32
|
+
* - `?` matches exactly one character.
|
|
33
|
+
* - Every other character is matched literally (regex metacharacters in
|
|
34
|
+
* `pattern` carry no special meaning).
|
|
35
|
+
* - The match is anchored: the entire `str` must be consumed.
|
|
36
|
+
*
|
|
37
|
+
* Special rule (ported faithfully): if `pattern` ends with `" *"` (a single
|
|
38
|
+
* space immediately followed by `*`), that trailing ` *` is OPTIONAL. The
|
|
39
|
+
* pattern then matches both `"<head> <rest>"` and exactly `"<head>"` with
|
|
40
|
+
* nothing after it. For example `"git *"` matches `"git"` and `"git status"`.
|
|
41
|
+
*
|
|
42
|
+
* Implemented with a backtracking two-pointer scan whose `*` handling uses a
|
|
43
|
+
* single saved restart position, giving O(|str| * |pattern|) worst case with
|
|
44
|
+
* no catastrophic blow-up.
|
|
45
|
+
*/
|
|
46
|
+
export function wildcardMatch(str: string, pattern: string): boolean {
|
|
47
|
+
if (isOptionalTrailingStar(pattern)) {
|
|
48
|
+
const head = pattern.slice(0, -2); // drop the trailing " *"
|
|
49
|
+
// Head-only branch: the whole string equals the head, matched as a glob.
|
|
50
|
+
if (globMatch(str, head)) {
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
53
|
+
// Otherwise the full "<head> *" pattern must match (space is consumed).
|
|
54
|
+
return globMatch(str, pattern);
|
|
55
|
+
}
|
|
56
|
+
return globMatch(str, pattern);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* True when `pattern` ends in a literal space followed by `*`, and that `*`
|
|
61
|
+
* is the final character. A bare `"*"` (no preceding space) is NOT treated
|
|
62
|
+
* as the optional-suffix form.
|
|
63
|
+
*/
|
|
64
|
+
function isOptionalTrailingStar(pattern: string): boolean {
|
|
65
|
+
return pattern.length >= 2 && pattern.endsWith(" *");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Core anchored glob match using linear-time backtracking. Only `*` can
|
|
70
|
+
* backtrack, and it uses a single restart marker (the classic two-pointer
|
|
71
|
+
* algorithm), so there is no exponential backtracking.
|
|
72
|
+
*/
|
|
73
|
+
function globMatch(str: string, pattern: string): boolean {
|
|
74
|
+
let s = 0;
|
|
75
|
+
let p = 0;
|
|
76
|
+
let starP = -1;
|
|
77
|
+
let starS = 0;
|
|
78
|
+
|
|
79
|
+
while (s < str.length) {
|
|
80
|
+
const pc = p < pattern.length ? pattern[p] : undefined;
|
|
81
|
+
if (pc === "*") {
|
|
82
|
+
// Record the restart point and tentatively consume zero chars.
|
|
83
|
+
starP = p;
|
|
84
|
+
starS = s;
|
|
85
|
+
p += 1;
|
|
86
|
+
} else if (pc === "?" || pc === str[s]) {
|
|
87
|
+
p += 1;
|
|
88
|
+
s += 1;
|
|
89
|
+
} else if (starP !== -1) {
|
|
90
|
+
// Backtrack: let the last `*` swallow one more character.
|
|
91
|
+
p = starP + 1;
|
|
92
|
+
starS += 1;
|
|
93
|
+
s = starS;
|
|
94
|
+
} else {
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Consume any trailing `*` segments in the pattern.
|
|
100
|
+
while (p < pattern.length && pattern[p] === "*") {
|
|
101
|
+
p += 1;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return p === pattern.length;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Resolve a permission query against one or more rulesets.
|
|
109
|
+
*
|
|
110
|
+
* Rulesets are concatenated in argument order (no mutation) and scanned
|
|
111
|
+
* front-to-back. The FIRST rule whose `permission` and `pattern` both match
|
|
112
|
+
* the query (via {@link wildcardMatch}) is returned. If no rule matches, a
|
|
113
|
+
* fail-safe default is returned: the queried permission/pattern with action
|
|
114
|
+
* `"ask"` (unknown → ask, never silently allow).
|
|
115
|
+
*/
|
|
116
|
+
export function evaluate(permission: string, pattern: string, ...rulesets: Ruleset[]): Rule {
|
|
117
|
+
for (const ruleset of rulesets) {
|
|
118
|
+
for (const rule of ruleset) {
|
|
119
|
+
if (wildcardMatch(permission, rule.permission) && wildcardMatch(pattern, rule.pattern)) {
|
|
120
|
+
return rule;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return { permission, pattern: "*", action: "ask" };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Built-in command arity table. Maps a (possibly multi-word) command prefix
|
|
129
|
+
* to the number of leading tokens that constitute its "human-understandable
|
|
130
|
+
* command" for permission matching. Longest matching prefix wins.
|
|
131
|
+
*
|
|
132
|
+
* Frozen so the shared default cannot be mutated by callers.
|
|
133
|
+
*/
|
|
134
|
+
export const BUILTIN_ARITY: Readonly<Record<string, number>> = Object.freeze({
|
|
135
|
+
git: 2,
|
|
136
|
+
npm: 2,
|
|
137
|
+
"npm run": 3,
|
|
138
|
+
docker: 2,
|
|
139
|
+
kubectl: 2,
|
|
140
|
+
cargo: 2,
|
|
141
|
+
pnpm: 2,
|
|
142
|
+
yarn: 2,
|
|
143
|
+
go: 2,
|
|
144
|
+
ls: 1,
|
|
145
|
+
cat: 1,
|
|
146
|
+
cd: 1,
|
|
147
|
+
rm: 1,
|
|
148
|
+
cp: 1,
|
|
149
|
+
mv: 1,
|
|
150
|
+
mkdir: 1,
|
|
151
|
+
echo: 1,
|
|
152
|
+
grep: 1,
|
|
153
|
+
python: 1,
|
|
154
|
+
node: 1,
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Extract the human-understandable command from already-split, flag-free
|
|
159
|
+
* shell tokens.
|
|
160
|
+
*
|
|
161
|
+
* Strategy: try the longest prefix first. For `len` from `tokens.length` down
|
|
162
|
+
* to 1, if `tokens.slice(0, len).join(" ")` is a key in the (merged) arity
|
|
163
|
+
* table, return `tokens.slice(0, arity[thatPrefix])`. If the tokens are empty,
|
|
164
|
+
* return `[]`. Otherwise default to the first token only.
|
|
165
|
+
*
|
|
166
|
+
* `arity` is shallow-merged OVER the built-in table; neither the caller's
|
|
167
|
+
* object nor the built-in table is mutated.
|
|
168
|
+
*/
|
|
169
|
+
export function commandPrefix(
|
|
170
|
+
tokens: string[],
|
|
171
|
+
arity?: Record<string, number> | undefined,
|
|
172
|
+
): string[] {
|
|
173
|
+
if (tokens.length === 0) {
|
|
174
|
+
return [];
|
|
175
|
+
}
|
|
176
|
+
const table: Record<string, number> = { ...BUILTIN_ARITY, ...(arity ?? {}) };
|
|
177
|
+
|
|
178
|
+
for (let len = tokens.length; len >= 1; len -= 1) {
|
|
179
|
+
const prefixKey = tokens.slice(0, len).join(" ");
|
|
180
|
+
const take = table[prefixKey];
|
|
181
|
+
if (take !== undefined) {
|
|
182
|
+
// Clamp to the available token count; never expand beyond the input.
|
|
183
|
+
return tokens.slice(0, Math.min(take, tokens.length));
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return tokens.slice(0, 1);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Derive the `pattern` to feed {@link evaluate} for a bash permission.
|
|
192
|
+
*
|
|
193
|
+
* Splits `command` on arbitrary whitespace, drops tokens that begin with `-`
|
|
194
|
+
* (flags are not conceptually part of the command identity), applies
|
|
195
|
+
* {@link commandPrefix}, and joins the result with single spaces.
|
|
196
|
+
*/
|
|
197
|
+
export function commandPermissionKey(command: string): string {
|
|
198
|
+
const tokens = command.split(/\s+/).filter((token) => token.length > 0 && !token.startsWith("-"));
|
|
199
|
+
return commandPrefix(tokens).join(" ");
|
|
200
|
+
}
|
package/src/policy.ts
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Approval + capability policy (WRAP — capability #8).
|
|
3
|
+
*
|
|
4
|
+
* Faithful re-expression of the upstream two orthogonal axes:
|
|
5
|
+
* 1. Approval policy — *when* to ask a human (AskForApproval).
|
|
6
|
+
* 2. Capability policy — *what* an external executor is permitted to do
|
|
7
|
+
* (SandboxPolicy). Policy/semantics only; enforcement is out of scope
|
|
8
|
+
* and lives behind the external-sandbox seam (see ./sandbox).
|
|
9
|
+
*
|
|
10
|
+
* Plus the static rule decision (execpolicy `Decision`) and the human's rich
|
|
11
|
+
* answer (`ReviewDecision`). Built on `@nebutra/permissions` for *who may
|
|
12
|
+
* approve*; this module models *what tier / what answer*.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { z } from "zod";
|
|
16
|
+
|
|
17
|
+
// ── Axis 1: approval policy ──────────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
/** Fine-grained per-category gates. `false` = auto-reject (not auto-ask). */
|
|
20
|
+
export const granularApprovalConfigSchema = z.object({
|
|
21
|
+
sandboxApproval: z.boolean(),
|
|
22
|
+
rules: z.boolean(),
|
|
23
|
+
skillApproval: z.boolean().default(false),
|
|
24
|
+
requestPermissions: z.boolean().default(false),
|
|
25
|
+
mcpElicitations: z.boolean(),
|
|
26
|
+
});
|
|
27
|
+
export type GranularApprovalConfig = z.infer<typeof granularApprovalConfigSchema>;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Approval tier.
|
|
31
|
+
* - `unless_trusted` : only known-safe read-only ops auto-approved; else ask.
|
|
32
|
+
* - `on_failure` : DEPRECATED — auto-run sandboxed, escalate on failure.
|
|
33
|
+
* - `on_request` : the model decides when to ask (default).
|
|
34
|
+
* - `granular` : per-category booleans.
|
|
35
|
+
* - `never` : failures returned to the model, never escalated.
|
|
36
|
+
*/
|
|
37
|
+
export const approvalPolicySchema = z.discriminatedUnion("kind", [
|
|
38
|
+
z.object({ kind: z.literal("unless_trusted") }),
|
|
39
|
+
z.object({ kind: z.literal("on_failure") }),
|
|
40
|
+
z.object({ kind: z.literal("on_request") }),
|
|
41
|
+
z.object({ kind: z.literal("granular"), config: granularApprovalConfigSchema }),
|
|
42
|
+
z.object({ kind: z.literal("never") }),
|
|
43
|
+
]);
|
|
44
|
+
export type ApprovalPolicy = z.infer<typeof approvalPolicySchema>;
|
|
45
|
+
|
|
46
|
+
export const DEFAULT_APPROVAL_POLICY: ApprovalPolicy = { kind: "on_request" };
|
|
47
|
+
|
|
48
|
+
// ── Axis 2: capability policy ────────────────────────────────────────────────
|
|
49
|
+
|
|
50
|
+
/** A writable root with read-only sub-paths and escalation-protected names. */
|
|
51
|
+
export const writableRootSchema = z.object({
|
|
52
|
+
root: z.string(),
|
|
53
|
+
readOnlySubpaths: z.array(z.string()).default([]),
|
|
54
|
+
/** Names that must never be writable even under this root (e.g. ".git/hooks"). */
|
|
55
|
+
protectedMetadataNames: z.array(z.string()).default([]),
|
|
56
|
+
});
|
|
57
|
+
export type WritableRoot = z.infer<typeof writableRootSchema>;
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Capability policy — a vocabulary of *capabilities*, not OS mechanisms.
|
|
61
|
+
* Per the dual-track governance decision, `external_sandbox` is the default
|
|
62
|
+
* posture: this runtime never enforces isolation itself.
|
|
63
|
+
*/
|
|
64
|
+
export const capabilityPolicySchema = z.discriminatedUnion("kind", [
|
|
65
|
+
z.object({ kind: z.literal("danger_full_access") }),
|
|
66
|
+
z.object({ kind: z.literal("read_only"), networkAccess: z.boolean().default(false) }),
|
|
67
|
+
z.object({
|
|
68
|
+
kind: z.literal("external_sandbox"),
|
|
69
|
+
networkAccess: z.boolean().default(false),
|
|
70
|
+
}),
|
|
71
|
+
z.object({
|
|
72
|
+
kind: z.literal("workspace_write"),
|
|
73
|
+
writableRoots: z.array(writableRootSchema).default([]),
|
|
74
|
+
networkAccess: z.boolean().default(false),
|
|
75
|
+
excludeTmpdir: z.boolean().default(false),
|
|
76
|
+
excludeSlashTmp: z.boolean().default(false),
|
|
77
|
+
}),
|
|
78
|
+
]);
|
|
79
|
+
export type CapabilityPolicy = z.infer<typeof capabilityPolicySchema>;
|
|
80
|
+
|
|
81
|
+
/** Default posture for a multi-tenant web runtime (governance: ExternalSandbox). */
|
|
82
|
+
export const DEFAULT_CAPABILITY_POLICY: CapabilityPolicy = {
|
|
83
|
+
kind: "external_sandbox",
|
|
84
|
+
networkAccess: false,
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
// ── Static rule decision (policy-as-data evaluator output) ────────────────────
|
|
88
|
+
|
|
89
|
+
/** Classification of a command *before* approval. */
|
|
90
|
+
export type RuleDecision = "allow" | "prompt" | "forbidden";
|
|
91
|
+
|
|
92
|
+
// ── The human's answer ───────────────────────────────────────────────────────
|
|
93
|
+
|
|
94
|
+
/** A session-scoped amendment that remembers an allowed command prefix. */
|
|
95
|
+
export interface RuleAmendment {
|
|
96
|
+
readonly allowPrefix: readonly string[];
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Persist allow/deny for future requests to the same host. */
|
|
100
|
+
export interface NetworkPolicyAmendment {
|
|
101
|
+
readonly host: string;
|
|
102
|
+
readonly effect: "allow" | "deny";
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* The reviewer's rich answer. Mirrors upstream `ReviewDecision`; `denied`
|
|
107
|
+
* is the safe default.
|
|
108
|
+
*/
|
|
109
|
+
export type ReviewDecision =
|
|
110
|
+
| { readonly kind: "approved" }
|
|
111
|
+
| { readonly kind: "approved_rule_amendment"; readonly amendment: RuleAmendment }
|
|
112
|
+
| { readonly kind: "approved_for_session" }
|
|
113
|
+
| { readonly kind: "network_policy_amendment"; readonly amendment: NetworkPolicyAmendment }
|
|
114
|
+
| { readonly kind: "denied" }
|
|
115
|
+
| { readonly kind: "timed_out" }
|
|
116
|
+
| { readonly kind: "abort" };
|
|
117
|
+
|
|
118
|
+
export const DENIED: ReviewDecision = { kind: "denied" };
|
|
119
|
+
|
|
120
|
+
/** Whether a decision permits the action to proceed. */
|
|
121
|
+
export function isApproval(decision: ReviewDecision): boolean {
|
|
122
|
+
return (
|
|
123
|
+
decision.kind === "approved" ||
|
|
124
|
+
decision.kind === "approved_rule_amendment" ||
|
|
125
|
+
decision.kind === "approved_for_session"
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Resolve a static rule decision against an approval policy into whether the
|
|
131
|
+
* action proceeds without a human, must be asked, or is auto-rejected.
|
|
132
|
+
* Pure; the actual prompting/escalation transport lives in ./protocol.
|
|
133
|
+
*/
|
|
134
|
+
export function resolveRuleDecision(
|
|
135
|
+
rule: RuleDecision,
|
|
136
|
+
policy: ApprovalPolicy,
|
|
137
|
+
): "auto_allow" | "ask_human" | "auto_reject" {
|
|
138
|
+
if (rule === "forbidden") return "auto_reject";
|
|
139
|
+
if (rule === "allow") return "auto_allow";
|
|
140
|
+
// rule === "prompt"
|
|
141
|
+
switch (policy.kind) {
|
|
142
|
+
case "never":
|
|
143
|
+
return "auto_reject";
|
|
144
|
+
case "unless_trusted":
|
|
145
|
+
case "on_request":
|
|
146
|
+
case "on_failure":
|
|
147
|
+
return "ask_human";
|
|
148
|
+
case "granular":
|
|
149
|
+
return policy.config.rules ? "ask_human" : "auto_reject";
|
|
150
|
+
}
|
|
151
|
+
}
|