@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.
- package/LICENSE +21 -676
- package/README.md +33 -10
- package/dist/adapters/index.d.ts +24 -5
- package/dist/adapters/index.js +27 -0
- package/dist/adapters/index.js.map +1 -1
- package/dist/{chunk-KCNN4QUQ.js → chunk-5UGIUYJR.js} +4 -4
- package/dist/chunk-7ELA4SWE.js +73 -0
- package/dist/chunk-7ELA4SWE.js.map +1 -0
- package/dist/chunk-Q62VKHIT.js +178 -0
- package/dist/chunk-Q62VKHIT.js.map +1 -0
- package/dist/chunk-VXILZAXK.js +541 -0
- package/dist/chunk-VXILZAXK.js.map +1 -0
- package/dist/command-exec.d.ts +45 -0
- package/dist/command-exec.js +15 -0
- package/dist/command-exec.js.map +1 -0
- package/dist/index.d.ts +3 -2
- package/dist/index.js +81 -28
- package/dist/index.js.map +1 -1
- package/dist/orchestration.d.ts +42 -1
- package/dist/orchestration.js +7 -3
- package/dist/pulsar.js +2 -2
- package/dist/sandbox-DfcRttXt.d.ts +219 -0
- package/dist/sandbox.d.ts +2 -64
- package/dist/sandbox.js +25 -3
- package/package.json +81 -30
- package/.turbo/turbo-build.log +0 -130
- package/.turbo/turbo-test.log +0 -47
- package/.turbo/turbo-typecheck.log +0 -4
- package/CHANGELOG.md +0 -250
- package/dist/chunk-MUF7ZZTO.js +0 -57
- package/dist/chunk-MUF7ZZTO.js.map +0 -1
- package/dist/chunk-MX2WL43P.js +0 -90
- package/dist/chunk-MX2WL43P.js.map +0 -1
- package/examples/pulsar-quickstart.ts +0 -35
- package/examples/resume-branch.ts +0 -45
- package/examples/subagent-fanout.ts +0 -20
- package/src/adapters/dispatcher-sse.test.ts +0 -218
- package/src/adapters/dispatcher-sse.ts +0 -222
- package/src/adapters/index.ts +0 -18
- package/src/adapters/mcp-catalog.test.ts +0 -213
- package/src/adapters/mcp-catalog.ts +0 -188
- package/src/adapters/prisma-rollout.test.ts +0 -153
- package/src/adapters/prisma-rollout.ts +0 -104
- package/src/agent-runtime.test.ts +0 -176
- package/src/artifact-stream.test.ts +0 -330
- package/src/artifact-stream.ts +0 -453
- package/src/channel-gateway.test.ts +0 -432
- package/src/channel-gateway.ts +0 -357
- package/src/cli.ts +0 -49
- package/src/code-review.test.ts +0 -501
- package/src/code-review.ts +0 -495
- package/src/command-suggestions.test.ts +0 -251
- package/src/command-suggestions.ts +0 -338
- package/src/commands.test.ts +0 -184
- package/src/commands.ts +0 -140
- package/src/commit-message.test.ts +0 -249
- package/src/commit-message.ts +0 -180
- package/src/context-compaction.test.ts +0 -522
- package/src/context-compaction.ts +0 -438
- package/src/definitions.test.ts +0 -78
- package/src/definitions.ts +0 -190
- package/src/deployment-status.test.ts +0 -215
- package/src/deployment-status.ts +0 -227
- package/src/design-context.test.ts +0 -195
- package/src/design-context.ts +0 -198
- package/src/dispatcher.test.ts +0 -234
- package/src/dispatcher.ts +0 -189
- package/src/durable-turn.test.ts +0 -209
- package/src/durable-turn.ts +0 -135
- package/src/edit-planner.test.ts +0 -204
- package/src/edit-planner.ts +0 -325
- package/src/fuzzy-match.test.ts +0 -311
- package/src/fuzzy-match.ts +0 -444
- package/src/hook-pipeline.test.ts +0 -279
- package/src/hook-pipeline.ts +0 -373
- package/src/inbound-admission.test.ts +0 -394
- package/src/inbound-admission.ts +0 -246
- package/src/index.ts +0 -49
- package/src/loop.test.ts +0 -161
- package/src/loop.ts +0 -211
- package/src/mcp-bridge.test.ts +0 -165
- package/src/mcp-bridge.ts +0 -81
- package/src/memory-provider.test.ts +0 -232
- package/src/memory-provider.ts +0 -257
- package/src/model.ts +0 -168
- package/src/orchestration.test.ts +0 -53
- package/src/orchestration.ts +0 -146
- package/src/permission-ruleset.test.ts +0 -301
- package/src/permission-ruleset.ts +0 -1
- package/src/policy.ts +0 -151
- package/src/project-repo.test.ts +0 -232
- package/src/project-repo.ts +0 -311
- package/src/protocol.ts +0 -159
- package/src/pulsar.test.ts +0 -156
- package/src/pulsar.ts +0 -322
- package/src/rollout-store-persistent.test.ts +0 -217
- package/src/rollout-store-persistent.ts +0 -166
- package/src/rollout.ts +0 -150
- package/src/sandbox.ts +0 -113
- package/src/session-share.test.ts +0 -360
- package/src/session-share.ts +0 -310
- package/src/skill-distillation.test.ts +0 -177
- package/src/skill-distillation.ts +0 -369
- package/src/skills.test.ts +0 -277
- package/src/skills.ts +0 -277
- package/src/subagents.test.ts +0 -290
- package/src/subagents.ts +0 -332
- package/src/tools.test.ts +0 -12
- package/src/tools.ts +0 -129
- package/src/workbench.test.ts +0 -0
- package/src/workbench.ts +0 -0
- package/tsconfig.json +0 -12
- package/tsup.config.ts +0 -36
- /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
|
-
});
|
package/src/orchestration.ts
DELETED
|
@@ -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
|
-
}
|