@nebutra/agent-runtime 0.2.1 → 0.2.2

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.
Files changed (101) hide show
  1. package/LICENSE +21 -676
  2. package/dist/adapters/index.d.ts +24 -5
  3. package/dist/adapters/index.js +27 -0
  4. package/dist/adapters/index.js.map +1 -1
  5. package/dist/chunk-Q62VKHIT.js +178 -0
  6. package/dist/chunk-Q62VKHIT.js.map +1 -0
  7. package/dist/{chunk-MUF7ZZTO.js → chunk-R5HOSQUW.js} +2 -1
  8. package/dist/{chunk-MUF7ZZTO.js.map → chunk-R5HOSQUW.js.map} +1 -1
  9. package/dist/index.d.ts +1 -1
  10. package/dist/index.js +14 -17
  11. package/dist/index.js.map +1 -1
  12. package/dist/orchestration.d.ts +42 -1
  13. package/dist/orchestration.js +7 -3
  14. package/dist/sandbox.js +1 -1
  15. package/package.json +80 -30
  16. package/.turbo/turbo-build.log +0 -130
  17. package/.turbo/turbo-test.log +0 -47
  18. package/.turbo/turbo-typecheck.log +0 -4
  19. package/CHANGELOG.md +0 -250
  20. package/dist/chunk-MX2WL43P.js +0 -90
  21. package/dist/chunk-MX2WL43P.js.map +0 -1
  22. package/examples/pulsar-quickstart.ts +0 -35
  23. package/examples/resume-branch.ts +0 -45
  24. package/examples/subagent-fanout.ts +0 -20
  25. package/src/adapters/dispatcher-sse.test.ts +0 -218
  26. package/src/adapters/dispatcher-sse.ts +0 -222
  27. package/src/adapters/index.ts +0 -18
  28. package/src/adapters/mcp-catalog.test.ts +0 -213
  29. package/src/adapters/mcp-catalog.ts +0 -188
  30. package/src/adapters/prisma-rollout.test.ts +0 -153
  31. package/src/adapters/prisma-rollout.ts +0 -104
  32. package/src/agent-runtime.test.ts +0 -176
  33. package/src/artifact-stream.test.ts +0 -330
  34. package/src/artifact-stream.ts +0 -453
  35. package/src/channel-gateway.test.ts +0 -432
  36. package/src/channel-gateway.ts +0 -357
  37. package/src/cli.ts +0 -49
  38. package/src/code-review.test.ts +0 -501
  39. package/src/code-review.ts +0 -495
  40. package/src/command-suggestions.test.ts +0 -251
  41. package/src/command-suggestions.ts +0 -338
  42. package/src/commands.test.ts +0 -184
  43. package/src/commands.ts +0 -140
  44. package/src/commit-message.test.ts +0 -249
  45. package/src/commit-message.ts +0 -180
  46. package/src/context-compaction.test.ts +0 -522
  47. package/src/context-compaction.ts +0 -438
  48. package/src/definitions.test.ts +0 -78
  49. package/src/definitions.ts +0 -190
  50. package/src/deployment-status.test.ts +0 -215
  51. package/src/deployment-status.ts +0 -227
  52. package/src/design-context.test.ts +0 -195
  53. package/src/design-context.ts +0 -198
  54. package/src/dispatcher.test.ts +0 -234
  55. package/src/dispatcher.ts +0 -189
  56. package/src/durable-turn.test.ts +0 -209
  57. package/src/durable-turn.ts +0 -135
  58. package/src/edit-planner.test.ts +0 -204
  59. package/src/edit-planner.ts +0 -325
  60. package/src/fuzzy-match.test.ts +0 -311
  61. package/src/fuzzy-match.ts +0 -444
  62. package/src/hook-pipeline.test.ts +0 -279
  63. package/src/hook-pipeline.ts +0 -373
  64. package/src/inbound-admission.test.ts +0 -394
  65. package/src/inbound-admission.ts +0 -246
  66. package/src/index.ts +0 -49
  67. package/src/loop.test.ts +0 -161
  68. package/src/loop.ts +0 -211
  69. package/src/mcp-bridge.test.ts +0 -165
  70. package/src/mcp-bridge.ts +0 -81
  71. package/src/memory-provider.test.ts +0 -232
  72. package/src/memory-provider.ts +0 -257
  73. package/src/model.ts +0 -168
  74. package/src/orchestration.test.ts +0 -53
  75. package/src/orchestration.ts +0 -146
  76. package/src/permission-ruleset.test.ts +0 -301
  77. package/src/permission-ruleset.ts +0 -1
  78. package/src/policy.ts +0 -151
  79. package/src/project-repo.test.ts +0 -232
  80. package/src/project-repo.ts +0 -311
  81. package/src/protocol.ts +0 -159
  82. package/src/pulsar.test.ts +0 -156
  83. package/src/pulsar.ts +0 -322
  84. package/src/rollout-store-persistent.test.ts +0 -217
  85. package/src/rollout-store-persistent.ts +0 -166
  86. package/src/rollout.ts +0 -150
  87. package/src/sandbox.ts +0 -113
  88. package/src/session-share.test.ts +0 -360
  89. package/src/session-share.ts +0 -310
  90. package/src/skill-distillation.test.ts +0 -177
  91. package/src/skill-distillation.ts +0 -369
  92. package/src/skills.test.ts +0 -277
  93. package/src/skills.ts +0 -277
  94. package/src/subagents.test.ts +0 -290
  95. package/src/subagents.ts +0 -332
  96. package/src/tools.test.ts +0 -12
  97. package/src/tools.ts +0 -129
  98. package/src/workbench.test.ts +0 -0
  99. package/src/workbench.ts +0 -0
  100. package/tsconfig.json +0 -12
  101. package/tsup.config.ts +0 -36
@@ -1,53 +0,0 @@
1
- import { describe, expect, it } from "vitest";
2
- import { type Brief, costReport, fanOutSubagents, planSubagentDispatch } from "./orchestration";
3
-
4
- function brief(name: string, dependsOn: readonly string[] = []): Brief {
5
- return {
6
- id: name,
7
- objective: `do ${name}`,
8
- outputFormat: { type: "object", properties: { value: { type: "string" } } },
9
- allowedTools: ["read"],
10
- contextRefs: [],
11
- boundaries: ["do not write outside scope"],
12
- budget: { durationMs: 1_000, costUsd: 0.01, tokenLimit: 1_000 },
13
- dependsOn,
14
- };
15
- }
16
-
17
- describe("subagent orchestration", () => {
18
- it("defaults dependent briefs to sequential dispatch", () => {
19
- const plan = planSubagentDispatch([brief("research"), brief("write", ["research"])]);
20
- expect(plan.strategy).toBe("sequential");
21
- expect(plan.order.map((item) => item.id)).toEqual(["research", "write"]);
22
- });
23
-
24
- it("refuses fan-out when briefs depend on each other", () => {
25
- expect(() =>
26
- planSubagentDispatch([brief("research"), brief("write", ["research"])], {
27
- strategy: "fanout",
28
- }),
29
- ).toThrow(/fan-out|depend/i);
30
- });
31
-
32
- it("runs independent fan-out briefs and preserves result ownership", async () => {
33
- const results = await fanOutSubagents([brief("logo"), brief("copy")], async (item) => ({
34
- briefId: item.id,
35
- output: { value: item.objective },
36
- usage: { inputTokens: 10, cachedInputTokens: 0, outputTokens: 3, reasoningOutputTokens: 0 },
37
- durationMs: 5,
38
- }));
39
-
40
- expect(results.map((result) => result.briefId)).toEqual(["logo", "copy"]);
41
- expect(costReport(results)).toEqual({
42
- totalInputTokens: 20,
43
- totalOutputTokens: 6,
44
- totalReasoningTokens: 0,
45
- maxDurationMs: 5,
46
- subagents: 2,
47
- });
48
- });
49
-
50
- it("requires precise brief fields", () => {
51
- expect(() => planSubagentDispatch([{ ...brief("bad"), objective: "" }])).toThrow(/objective/i);
52
- });
53
- });
@@ -1,146 +0,0 @@
1
- import type { TurnUsage } from "./model";
2
-
3
- export interface BudgetCap {
4
- readonly durationMs: number;
5
- readonly costUsd: number;
6
- readonly tokenLimit: number;
7
- }
8
-
9
- export interface Brief {
10
- readonly id: string;
11
- readonly objective: string;
12
- readonly outputFormat: Record<string, unknown>;
13
- readonly allowedTools: readonly string[];
14
- readonly contextRefs: readonly string[];
15
- readonly boundaries: readonly string[];
16
- readonly budget: BudgetCap;
17
- readonly dependsOn?: readonly string[];
18
- }
19
-
20
- export type DispatchStrategy = "auto" | "sequential" | "fanout";
21
-
22
- export interface DispatchPlan {
23
- readonly strategy: Exclude<DispatchStrategy, "auto">;
24
- readonly order: readonly Brief[];
25
- }
26
-
27
- export interface DispatchPlanOptions {
28
- readonly strategy?: DispatchStrategy;
29
- }
30
-
31
- export interface SubagentResult {
32
- readonly briefId: string;
33
- readonly output: unknown;
34
- readonly usage: TurnUsage;
35
- readonly durationMs: number;
36
- }
37
-
38
- export interface SubagentCostReport {
39
- readonly totalInputTokens: number;
40
- readonly totalOutputTokens: number;
41
- readonly totalReasoningTokens: number;
42
- readonly maxDurationMs: number;
43
- readonly subagents: number;
44
- }
45
-
46
- function fail(message: string, suggestion: string): never {
47
- throw new Error(`${message}. Suggestion: ${suggestion}`);
48
- }
49
-
50
- function validateBrief(brief: Brief): void {
51
- if (!brief.id.trim()) fail("brief.id is required", "Use a stable role or task id.");
52
- if (!brief.objective.trim()) {
53
- fail("brief.objective is required", "Write a concrete objective before dispatching.");
54
- }
55
- if (brief.allowedTools.length === 0) {
56
- fail("brief.allowedTools is required", "Constrain each subagent to an explicit tool scope.");
57
- }
58
- if (brief.boundaries.length === 0) {
59
- fail("brief.boundaries is required", "State at least one boundary for the worker.");
60
- }
61
- if (brief.budget.tokenLimit <= 0 || brief.budget.durationMs <= 0 || brief.budget.costUsd < 0) {
62
- fail("brief.budget is invalid", "Set positive token/time caps and a non-negative cost cap.");
63
- }
64
- }
65
-
66
- function dependencySet(briefs: readonly Brief[]): Set<string> {
67
- const dependencies = new Set<string>();
68
- for (const brief of briefs) {
69
- for (const dep of brief.dependsOn ?? []) dependencies.add(dep);
70
- }
71
- return dependencies;
72
- }
73
-
74
- function topologicalOrder(briefs: readonly Brief[]): Brief[] {
75
- const byId = new Map(briefs.map((brief) => [brief.id, brief]));
76
- const visited = new Set<string>();
77
- const visiting = new Set<string>();
78
- const ordered: Brief[] = [];
79
-
80
- const visit = (brief: Brief): void => {
81
- if (visited.has(brief.id)) return;
82
- if (visiting.has(brief.id)) {
83
- fail(
84
- `subagent dependency cycle includes '${brief.id}'`,
85
- "Remove the cycle or collapse the dependent work into one sequential brief.",
86
- );
87
- }
88
- visiting.add(brief.id);
89
- for (const dep of brief.dependsOn ?? []) {
90
- const dependency = byId.get(dep);
91
- if (dependency) visit(dependency);
92
- }
93
- visiting.delete(brief.id);
94
- visited.add(brief.id);
95
- ordered.push(brief);
96
- };
97
-
98
- for (const brief of briefs) visit(brief);
99
- return ordered;
100
- }
101
-
102
- export function planSubagentDispatch(
103
- briefs: readonly Brief[],
104
- options: DispatchPlanOptions = {},
105
- ): DispatchPlan {
106
- if (briefs.length === 0) {
107
- fail("at least one brief is required", "Create a concrete worker brief before dispatching.");
108
- }
109
- for (const brief of briefs) validateBrief(brief);
110
-
111
- const dependencies = dependencySet(briefs);
112
- if ((options.strategy ?? "auto") === "fanout" && dependencies.size > 0) {
113
- fail(
114
- "fan-out cannot run briefs that depend on each other",
115
- "Use sequential dispatch for dependent work or split independent briefs only.",
116
- );
117
- }
118
-
119
- const order = topologicalOrder(briefs);
120
- const strategy =
121
- options.strategy === "fanout" || (options.strategy === "auto" && dependencies.size === 0)
122
- ? "fanout"
123
- : "sequential";
124
- return { strategy, order };
125
- }
126
-
127
- export async function fanOutSubagents(
128
- briefs: readonly Brief[],
129
- run: (brief: Brief) => Promise<SubagentResult>,
130
- ): Promise<readonly SubagentResult[]> {
131
- const plan = planSubagentDispatch(briefs, { strategy: "fanout" });
132
- return Promise.all(plan.order.map((brief) => run(brief)));
133
- }
134
-
135
- export function costReport(results: readonly SubagentResult[]): SubagentCostReport {
136
- return {
137
- totalInputTokens: results.reduce((sum, result) => sum + result.usage.inputTokens, 0),
138
- totalOutputTokens: results.reduce((sum, result) => sum + result.usage.outputTokens, 0),
139
- totalReasoningTokens: results.reduce(
140
- (sum, result) => sum + result.usage.reasoningOutputTokens,
141
- 0,
142
- ),
143
- maxDurationMs: results.reduce((max, result) => Math.max(max, result.durationMs), 0),
144
- subagents: results.length,
145
- };
146
- }
@@ -1,301 +0,0 @@
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
- });
@@ -1 +0,0 @@
1
- export * from "@nebutra/execution-policy";
package/src/policy.ts DELETED
@@ -1,151 +0,0 @@
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
- }