@nebutra/agent-runtime 0.2.1 → 2.0.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.
Files changed (114) hide show
  1. package/LICENSE +21 -676
  2. package/README.md +33 -10
  3. package/dist/adapters/index.d.ts +24 -5
  4. package/dist/adapters/index.js +27 -0
  5. package/dist/adapters/index.js.map +1 -1
  6. package/dist/{chunk-KCNN4QUQ.js → chunk-5UGIUYJR.js} +4 -4
  7. package/dist/chunk-7ELA4SWE.js +73 -0
  8. package/dist/chunk-7ELA4SWE.js.map +1 -0
  9. package/dist/chunk-Q62VKHIT.js +178 -0
  10. package/dist/chunk-Q62VKHIT.js.map +1 -0
  11. package/dist/chunk-VXILZAXK.js +541 -0
  12. package/dist/chunk-VXILZAXK.js.map +1 -0
  13. package/dist/command-exec.d.ts +45 -0
  14. package/dist/command-exec.js +15 -0
  15. package/dist/command-exec.js.map +1 -0
  16. package/dist/index.d.ts +3 -2
  17. package/dist/index.js +81 -28
  18. package/dist/index.js.map +1 -1
  19. package/dist/orchestration.d.ts +42 -1
  20. package/dist/orchestration.js +7 -3
  21. package/dist/pulsar.js +2 -2
  22. package/dist/sandbox-DfcRttXt.d.ts +219 -0
  23. package/dist/sandbox.d.ts +2 -64
  24. package/dist/sandbox.js +25 -3
  25. package/package.json +81 -30
  26. package/.turbo/turbo-build.log +0 -130
  27. package/.turbo/turbo-test.log +0 -47
  28. package/.turbo/turbo-typecheck.log +0 -4
  29. package/CHANGELOG.md +0 -250
  30. package/dist/chunk-MUF7ZZTO.js +0 -57
  31. package/dist/chunk-MUF7ZZTO.js.map +0 -1
  32. package/dist/chunk-MX2WL43P.js +0 -90
  33. package/dist/chunk-MX2WL43P.js.map +0 -1
  34. package/examples/pulsar-quickstart.ts +0 -35
  35. package/examples/resume-branch.ts +0 -45
  36. package/examples/subagent-fanout.ts +0 -20
  37. package/src/adapters/dispatcher-sse.test.ts +0 -218
  38. package/src/adapters/dispatcher-sse.ts +0 -222
  39. package/src/adapters/index.ts +0 -18
  40. package/src/adapters/mcp-catalog.test.ts +0 -213
  41. package/src/adapters/mcp-catalog.ts +0 -188
  42. package/src/adapters/prisma-rollout.test.ts +0 -153
  43. package/src/adapters/prisma-rollout.ts +0 -104
  44. package/src/agent-runtime.test.ts +0 -176
  45. package/src/artifact-stream.test.ts +0 -330
  46. package/src/artifact-stream.ts +0 -453
  47. package/src/channel-gateway.test.ts +0 -432
  48. package/src/channel-gateway.ts +0 -357
  49. package/src/cli.ts +0 -49
  50. package/src/code-review.test.ts +0 -501
  51. package/src/code-review.ts +0 -495
  52. package/src/command-suggestions.test.ts +0 -251
  53. package/src/command-suggestions.ts +0 -338
  54. package/src/commands.test.ts +0 -184
  55. package/src/commands.ts +0 -140
  56. package/src/commit-message.test.ts +0 -249
  57. package/src/commit-message.ts +0 -180
  58. package/src/context-compaction.test.ts +0 -522
  59. package/src/context-compaction.ts +0 -438
  60. package/src/definitions.test.ts +0 -78
  61. package/src/definitions.ts +0 -190
  62. package/src/deployment-status.test.ts +0 -215
  63. package/src/deployment-status.ts +0 -227
  64. package/src/design-context.test.ts +0 -195
  65. package/src/design-context.ts +0 -198
  66. package/src/dispatcher.test.ts +0 -234
  67. package/src/dispatcher.ts +0 -189
  68. package/src/durable-turn.test.ts +0 -209
  69. package/src/durable-turn.ts +0 -135
  70. package/src/edit-planner.test.ts +0 -204
  71. package/src/edit-planner.ts +0 -325
  72. package/src/fuzzy-match.test.ts +0 -311
  73. package/src/fuzzy-match.ts +0 -444
  74. package/src/hook-pipeline.test.ts +0 -279
  75. package/src/hook-pipeline.ts +0 -373
  76. package/src/inbound-admission.test.ts +0 -394
  77. package/src/inbound-admission.ts +0 -246
  78. package/src/index.ts +0 -49
  79. package/src/loop.test.ts +0 -161
  80. package/src/loop.ts +0 -211
  81. package/src/mcp-bridge.test.ts +0 -165
  82. package/src/mcp-bridge.ts +0 -81
  83. package/src/memory-provider.test.ts +0 -232
  84. package/src/memory-provider.ts +0 -257
  85. package/src/model.ts +0 -168
  86. package/src/orchestration.test.ts +0 -53
  87. package/src/orchestration.ts +0 -146
  88. package/src/permission-ruleset.test.ts +0 -301
  89. package/src/permission-ruleset.ts +0 -1
  90. package/src/policy.ts +0 -151
  91. package/src/project-repo.test.ts +0 -232
  92. package/src/project-repo.ts +0 -311
  93. package/src/protocol.ts +0 -159
  94. package/src/pulsar.test.ts +0 -156
  95. package/src/pulsar.ts +0 -322
  96. package/src/rollout-store-persistent.test.ts +0 -217
  97. package/src/rollout-store-persistent.ts +0 -166
  98. package/src/rollout.ts +0 -150
  99. package/src/sandbox.ts +0 -113
  100. package/src/session-share.test.ts +0 -360
  101. package/src/session-share.ts +0 -310
  102. package/src/skill-distillation.test.ts +0 -177
  103. package/src/skill-distillation.ts +0 -369
  104. package/src/skills.test.ts +0 -277
  105. package/src/skills.ts +0 -277
  106. package/src/subagents.test.ts +0 -290
  107. package/src/subagents.ts +0 -332
  108. package/src/tools.test.ts +0 -12
  109. package/src/tools.ts +0 -129
  110. package/src/workbench.test.ts +0 -0
  111. package/src/workbench.ts +0 -0
  112. package/tsconfig.json +0 -12
  113. package/tsup.config.ts +0 -36
  114. /package/dist/{chunk-KCNN4QUQ.js.map → chunk-5UGIUYJR.js.map} +0 -0
@@ -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
- }