@gotgenes/pi-permission-system 16.2.0 → 17.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/CHANGELOG.md +34 -0
- package/README.md +3 -2
- package/package.json +1 -1
- package/src/access-intent/access-intent.ts +8 -10
- package/src/access-intent/access-path.ts +39 -16
- package/src/access-intent/bash/command-enumeration.ts +79 -2
- package/src/access-intent/bash/cwd-projection.ts +16 -14
- package/src/access-intent/bash/parser.ts +2 -0
- package/src/access-intent/bash/program.ts +3 -0
- package/src/builtin-tool-input-formatters.ts +1 -1
- package/src/config-loader.ts +7 -7
- package/src/forwarded-permissions/permission-forwarder.ts +1 -1
- package/src/handlers/gates/bash-command.ts +15 -1
- package/src/handlers/gates/bash-external-directory.ts +1 -1
- package/src/handlers/gates/bash-path.ts +14 -14
- package/src/handlers/gates/external-directory.ts +3 -4
- package/src/handlers/gates/path.ts +11 -7
- package/src/handlers/gates/skill-read.ts +1 -1
- package/src/handlers/gates/tool-call-gate-pipeline.ts +1 -1
- package/src/handlers/permission-gate-handler.ts +1 -2
- package/src/handlers/tool-call-boundary.ts +1 -2
- package/src/input-normalizer.ts +1 -1
- package/src/mcp-targets.ts +1 -1
- package/src/normalize.ts +1 -1
- package/src/path-utils.ts +1 -1
- package/src/permission-manager.ts +1 -1
- package/src/permission-prompts.ts +1 -1
- package/src/policy-loader.ts +2 -2
- package/src/tool-input-prompt-formatters.ts +1 -1
- package/src/tool-preview-formatter.ts +1 -1
- package/src/tool-registry.ts +1 -1
- package/src/{common.ts → value-guards.ts} +0 -66
- package/src/yaml-frontmatter.ts +65 -0
- package/test/access-intent/access-path.test.ts +83 -23
- package/test/access-intent/bash/node-text.test.ts +1 -0
- package/test/access-intent/bash/program.test.ts +116 -17
- package/test/handlers/external-directory-integration.test.ts +40 -153
- package/test/handlers/external-directory-session-dedup.test.ts +38 -262
- package/test/handlers/gates/bash-command.test.ts +63 -0
- package/test/handlers/gates/bash-external-directory.test.ts +1 -2
- package/test/handlers/gates/bash-path.test.ts +9 -9
- package/test/handlers/gates/external-directory-policy.test.ts +10 -18
- package/test/handlers/gates/path.test.ts +43 -12
- package/test/helpers/external-directory-fixtures.ts +269 -0
- package/test/helpers/gate-fixtures.ts +1 -2
- package/test/permission-resolver.test.ts +5 -32
- package/test/{common.test.ts → value-guards.test.ts} +2 -96
- package/test/yaml-frontmatter.test.ts +91 -0
|
@@ -1,5 +1,16 @@
|
|
|
1
|
-
import { describe, expect, it } from "vitest";
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
2
|
|
|
3
|
+
// Mock node:fs so realpathSync (used by canonicalizePath) is controllable.
|
|
4
|
+
// Default implementation is identity — lexical tests are unaffected.
|
|
5
|
+
const realpathSync = vi.hoisted(() =>
|
|
6
|
+
vi.fn<(path: string) => string>((p) => p),
|
|
7
|
+
);
|
|
8
|
+
vi.mock("node:fs", () => ({
|
|
9
|
+
realpathSync,
|
|
10
|
+
default: { realpathSync },
|
|
11
|
+
}));
|
|
12
|
+
|
|
13
|
+
import { AccessPath } from "#src/access-intent/access-path";
|
|
3
14
|
import type { GateDescriptor } from "#src/handlers/gates/descriptor";
|
|
4
15
|
import { isGateDescriptor } from "#src/handlers/gates/descriptor";
|
|
5
16
|
import { describePathGate } from "#src/handlers/gates/path";
|
|
@@ -27,6 +38,11 @@ function makeTcc(overrides: Partial<ToolCallContext> = {}): ToolCallContext {
|
|
|
27
38
|
// ── tests ──────────────────────────────────────────────────────────────────
|
|
28
39
|
|
|
29
40
|
describe("describePathGate", () => {
|
|
41
|
+
beforeEach(() => {
|
|
42
|
+
realpathSync.mockReset();
|
|
43
|
+
realpathSync.mockImplementation((p: string) => p);
|
|
44
|
+
});
|
|
45
|
+
|
|
30
46
|
it("returns null for non-path-bearing tools", () => {
|
|
31
47
|
const resolver = makeResolver();
|
|
32
48
|
const result = describePathGate(
|
|
@@ -152,16 +168,31 @@ describe("describePathGate", () => {
|
|
|
152
168
|
expect(result.decision.value).toBe(".env");
|
|
153
169
|
});
|
|
154
170
|
|
|
155
|
-
it("resolves the path surface with
|
|
171
|
+
it("resolves the path surface with an access-path intent and agent name", () => {
|
|
156
172
|
const resolver = makeResolver(makeCheckResult({ state: "allow" }));
|
|
157
173
|
describePathGate(makeTcc({ agentName: "my-agent" }), resolver);
|
|
158
174
|
expect(resolver.resolve).toHaveBeenCalledWith({
|
|
159
|
-
kind: "
|
|
175
|
+
kind: "access-path",
|
|
160
176
|
surface: "path",
|
|
161
|
-
|
|
177
|
+
path: AccessPath.forPath(".env", { cwd: "/test/project" }),
|
|
162
178
|
agentName: "my-agent",
|
|
163
179
|
});
|
|
164
180
|
});
|
|
181
|
+
|
|
182
|
+
it("emits an access-path whose matchValues include the symlink-resolved form (#486)", () => {
|
|
183
|
+
// /test/project/.env is a symlink to /vault/secret.env.
|
|
184
|
+
realpathSync.mockImplementation((p: string) =>
|
|
185
|
+
p === "/test/project/.env" ? "/vault/secret.env" : p,
|
|
186
|
+
);
|
|
187
|
+
const resolver = makeResolver(makeCheckResult({ state: "allow" }));
|
|
188
|
+
describePathGate(makeTcc(), resolver);
|
|
189
|
+
|
|
190
|
+
const intent = resolver.resolve.mock.lastCall?.[0];
|
|
191
|
+
expect(intent?.kind).toBe("access-path");
|
|
192
|
+
expect(intent?.kind === "access-path" && intent.path.matchValues()).toEqual(
|
|
193
|
+
["/test/project/.env", ".env", "/vault/secret.env"],
|
|
194
|
+
);
|
|
195
|
+
});
|
|
165
196
|
});
|
|
166
197
|
|
|
167
198
|
// Home-relative path characterization (#350) ──────────────────────────────
|
|
@@ -189,9 +220,9 @@ describe("describePathGate — home-relative paths", () => {
|
|
|
189
220
|
pathValue: "~/.ssh/config",
|
|
190
221
|
});
|
|
191
222
|
expect(resolver.resolve).toHaveBeenCalledWith({
|
|
192
|
-
kind: "
|
|
223
|
+
kind: "access-path",
|
|
193
224
|
surface: "path",
|
|
194
|
-
|
|
225
|
+
path: AccessPath.forPath("~/.ssh/config", { cwd: "/test/project" }),
|
|
195
226
|
agentName: undefined,
|
|
196
227
|
});
|
|
197
228
|
});
|
|
@@ -246,9 +277,9 @@ describe("describePathGate — extension and MCP tools (#352)", () => {
|
|
|
246
277
|
);
|
|
247
278
|
expect(isGateDescriptor(result)).toBe(true);
|
|
248
279
|
expect(resolver.resolve).toHaveBeenCalledWith({
|
|
249
|
-
kind: "
|
|
280
|
+
kind: "access-path",
|
|
250
281
|
surface: "path",
|
|
251
|
-
|
|
282
|
+
path: AccessPath.forPath(".env", { cwd: "/test/project" }),
|
|
252
283
|
agentName: undefined,
|
|
253
284
|
});
|
|
254
285
|
});
|
|
@@ -263,9 +294,9 @@ describe("describePathGate — extension and MCP tools (#352)", () => {
|
|
|
263
294
|
);
|
|
264
295
|
expect(isGateDescriptor(result)).toBe(true);
|
|
265
296
|
expect(resolver.resolve).toHaveBeenCalledWith({
|
|
266
|
-
kind: "
|
|
297
|
+
kind: "access-path",
|
|
267
298
|
surface: "path",
|
|
268
|
-
|
|
299
|
+
path: AccessPath.forPath(".env", { cwd: "/test/project" }),
|
|
269
300
|
agentName: undefined,
|
|
270
301
|
});
|
|
271
302
|
});
|
|
@@ -280,9 +311,9 @@ describe("describePathGate — extension and MCP tools (#352)", () => {
|
|
|
280
311
|
extractorLookup("ffgrep", "target"),
|
|
281
312
|
);
|
|
282
313
|
expect(resolver.resolve).toHaveBeenCalledWith({
|
|
283
|
-
kind: "
|
|
314
|
+
kind: "access-path",
|
|
284
315
|
surface: "path",
|
|
285
|
-
|
|
316
|
+
path: AccessPath.forPath("/etc/passwd", { cwd: "/test/project" }),
|
|
286
317
|
agentName: undefined,
|
|
287
318
|
});
|
|
288
319
|
});
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared fixtures for the external-directory handler-pipeline tests.
|
|
3
|
+
*
|
|
4
|
+
* Targets the collapsed external-directory gate (Phase 6 Step 5, #477).
|
|
5
|
+
* Consumed by external-directory-integration.test.ts and
|
|
6
|
+
* external-directory-session-dedup.test.ts.
|
|
7
|
+
*/
|
|
8
|
+
import { vi } from "vitest";
|
|
9
|
+
|
|
10
|
+
import { GateDecisionReporter } from "#src/decision-reporter";
|
|
11
|
+
import type { GatePrompter } from "#src/gate-prompter";
|
|
12
|
+
import { GateRunner } from "#src/handlers/gates/runner";
|
|
13
|
+
import { SkillInputGatePipeline } from "#src/handlers/gates/skill-input-gate-pipeline";
|
|
14
|
+
import { ToolCallGatePipeline } from "#src/handlers/gates/tool-call-gate-pipeline";
|
|
15
|
+
import { PermissionGateHandler } from "#src/handlers/permission-gate-handler";
|
|
16
|
+
import type { ScopedPermissionManager } from "#src/permission-manager";
|
|
17
|
+
import type { SessionLogger } from "#src/session-logger";
|
|
18
|
+
import type { PermissionCheckResult, PermissionState } from "#src/types";
|
|
19
|
+
import { wildcardMatch } from "#src/wildcard-matcher";
|
|
20
|
+
|
|
21
|
+
import {
|
|
22
|
+
getDecisionEvents,
|
|
23
|
+
makeEvents,
|
|
24
|
+
makeSurfaceCheck,
|
|
25
|
+
makeToolRegistry,
|
|
26
|
+
} from "#test/helpers/handler-fixtures";
|
|
27
|
+
import {
|
|
28
|
+
makeRealResolver,
|
|
29
|
+
makeRealSession,
|
|
30
|
+
} from "#test/helpers/session-fixtures";
|
|
31
|
+
|
|
32
|
+
// ── Shared constants ───────────────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
/** Working-directory used by the external-directory handler-pipeline tests. */
|
|
35
|
+
export const EXT_DIR_CWD = "/test/project";
|
|
36
|
+
|
|
37
|
+
/** An external path (outside {@link EXT_DIR_CWD}) used across the test suite. */
|
|
38
|
+
export const EXTERNAL_PATH = "/outside/project/file.ts";
|
|
39
|
+
|
|
40
|
+
/** All path-bearing tools subject to the external-directory gate. */
|
|
41
|
+
export const ALL_PATH_BEARING_TOOLS = [
|
|
42
|
+
"read",
|
|
43
|
+
"write",
|
|
44
|
+
"edit",
|
|
45
|
+
"find",
|
|
46
|
+
"grep",
|
|
47
|
+
"ls",
|
|
48
|
+
];
|
|
49
|
+
|
|
50
|
+
/** Path-bearing tools where the path is optional (no input → gate is skipped). */
|
|
51
|
+
export const OPTIONAL_PATH_TOOLS = ["find", "grep", "ls"];
|
|
52
|
+
|
|
53
|
+
/** Full tool set used as the default registry in external-directory tests. */
|
|
54
|
+
export const ALL_TOOLS = [...ALL_PATH_BEARING_TOOLS, "bash"];
|
|
55
|
+
|
|
56
|
+
// ── Setup builders ─────────────────────────────────────────────────────────
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Builds a `checkPermission` mock for external-directory tests.
|
|
60
|
+
*
|
|
61
|
+
* Routes `external_directory` to `externalDirectoryState`, `path` to allow
|
|
62
|
+
* with `source: "special"` (so the cross-cutting path gate is transparent),
|
|
63
|
+
* and every other surface to `toolState` (default: allow).
|
|
64
|
+
*/
|
|
65
|
+
export function makeExtDirCheck(
|
|
66
|
+
externalDirectoryState: PermissionState,
|
|
67
|
+
toolState: PermissionState = "allow",
|
|
68
|
+
) {
|
|
69
|
+
return makeSurfaceCheck(
|
|
70
|
+
{
|
|
71
|
+
external_directory: { state: externalDirectoryState },
|
|
72
|
+
path: { state: "allow", source: "special" },
|
|
73
|
+
},
|
|
74
|
+
{ state: toolState },
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** GatePrompter stub that approves with `state: "approved"`. */
|
|
79
|
+
export function makeApprovingPrompter(): GatePrompter {
|
|
80
|
+
return {
|
|
81
|
+
canConfirm: vi.fn().mockReturnValue(true),
|
|
82
|
+
prompt: vi
|
|
83
|
+
.fn<GatePrompter["prompt"]>()
|
|
84
|
+
.mockResolvedValue({ approved: true, state: "approved" }),
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* GatePrompter stub that denies.
|
|
90
|
+
*
|
|
91
|
+
* Pass `denialReason` to simulate a user who explains the refusal.
|
|
92
|
+
*/
|
|
93
|
+
export function makeDenyingPrompter(denialReason?: string): GatePrompter {
|
|
94
|
+
return {
|
|
95
|
+
canConfirm: vi.fn().mockReturnValue(true),
|
|
96
|
+
prompt: vi
|
|
97
|
+
.fn<GatePrompter["prompt"]>()
|
|
98
|
+
.mockResolvedValue(
|
|
99
|
+
denialReason !== undefined
|
|
100
|
+
? { approved: false, state: "denied", denialReason }
|
|
101
|
+
: { approved: false, state: "denied" },
|
|
102
|
+
),
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** GatePrompter stub that reports no UI is available (`canConfirm: false`). */
|
|
107
|
+
export function makeUnavailablePrompter(): GatePrompter {
|
|
108
|
+
return {
|
|
109
|
+
canConfirm: vi.fn().mockReturnValue(false),
|
|
110
|
+
prompt: vi.fn<GatePrompter["prompt"]>(),
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ── Query helpers ──────────────────────────────────────────────────────────
|
|
115
|
+
|
|
116
|
+
/** Find the `external_directory` decision event from the events mock. */
|
|
117
|
+
export function findExtDirDecision(events: ReturnType<typeof makeEvents>) {
|
|
118
|
+
return getDecisionEvents(events).find(
|
|
119
|
+
(d) => d.surface === "external_directory",
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Return the `permission_request.blocked` review-log entries from the logger mock. */
|
|
124
|
+
export function blockReviewEntries(logger: SessionLogger) {
|
|
125
|
+
return (logger.review as ReturnType<typeof vi.fn>).mock.calls.filter(
|
|
126
|
+
([eventName]: string[]) => eventName === "permission_request.blocked",
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ── Session-dedup wiring ──────────────────────────────────────────────────
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Installs the session-aware `check(intent)` mock on the permission manager.
|
|
134
|
+
*
|
|
135
|
+
* Returns `ask` for `external_directory` on first access; re-checks recorded
|
|
136
|
+
* session rules on subsequent calls and returns `allow` (source: "session")
|
|
137
|
+
* when a `wildcardMatch` covers the path.
|
|
138
|
+
*/
|
|
139
|
+
export function makeExtDirDedupCheck(
|
|
140
|
+
permissionManager: ScopedPermissionManager,
|
|
141
|
+
): void {
|
|
142
|
+
vi.mocked(permissionManager.check).mockImplementation(
|
|
143
|
+
(intent, rules): PermissionCheckResult => {
|
|
144
|
+
const { surface } = intent;
|
|
145
|
+
const pathValue =
|
|
146
|
+
intent.kind === "path-values" ? (intent.values[0] ?? null) : null;
|
|
147
|
+
|
|
148
|
+
if (surface === "external_directory") {
|
|
149
|
+
if (pathValue && rules && rules.length > 0) {
|
|
150
|
+
const match = rules.findLast(
|
|
151
|
+
(r) =>
|
|
152
|
+
r.surface === "external_directory" &&
|
|
153
|
+
wildcardMatch(r.pattern, pathValue),
|
|
154
|
+
);
|
|
155
|
+
if (match) {
|
|
156
|
+
return {
|
|
157
|
+
state: "allow",
|
|
158
|
+
toolName: surface,
|
|
159
|
+
source: "session",
|
|
160
|
+
origin: "session",
|
|
161
|
+
matchedPattern: match.pattern,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return {
|
|
166
|
+
state: "ask",
|
|
167
|
+
toolName: surface,
|
|
168
|
+
source: "special",
|
|
169
|
+
origin: "global",
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return {
|
|
174
|
+
state: "allow",
|
|
175
|
+
toolName: surface,
|
|
176
|
+
source: "tool",
|
|
177
|
+
origin: "builtin",
|
|
178
|
+
};
|
|
179
|
+
},
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** GatePrompter stub that approves for the session (`state: "approved_for_session"`). */
|
|
184
|
+
function makeSessionApprovingPrompter(): GatePrompter {
|
|
185
|
+
return {
|
|
186
|
+
canConfirm: vi.fn().mockReturnValue(true),
|
|
187
|
+
prompt: vi
|
|
188
|
+
.fn<GatePrompter["prompt"]>()
|
|
189
|
+
.mockResolvedValue({ approved: true, state: "approved_for_session" }),
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Builds the fully-wired session-dedup handler with real collaborators.
|
|
195
|
+
*
|
|
196
|
+
* Unlike `makeHandler`, this wires `makeRealSession` + `makeRealResolver`
|
|
197
|
+
* manually so the caller can access the raw `session` for shutdown tests.
|
|
198
|
+
*
|
|
199
|
+
* Returns `{ handler, prompter, session }`.
|
|
200
|
+
*/
|
|
201
|
+
export function makeDedupWiring(prompter?: GatePrompter) {
|
|
202
|
+
const { session, permissionManager, sessionRules, logger } =
|
|
203
|
+
makeRealSession();
|
|
204
|
+
const { resolver } = makeRealResolver(permissionManager, sessionRules);
|
|
205
|
+
makeExtDirDedupCheck(permissionManager);
|
|
206
|
+
const events = makeEvents();
|
|
207
|
+
const reporter = new GateDecisionReporter(logger, events);
|
|
208
|
+
const resolvedPrompter: GatePrompter =
|
|
209
|
+
prompter ?? makeSessionApprovingPrompter();
|
|
210
|
+
const runner = new GateRunner(
|
|
211
|
+
resolver,
|
|
212
|
+
sessionRules,
|
|
213
|
+
resolvedPrompter,
|
|
214
|
+
reporter,
|
|
215
|
+
);
|
|
216
|
+
const handler = new PermissionGateHandler(
|
|
217
|
+
session,
|
|
218
|
+
makeToolRegistry({
|
|
219
|
+
getAll: vi
|
|
220
|
+
.fn()
|
|
221
|
+
.mockReturnValue([
|
|
222
|
+
{ name: "read" },
|
|
223
|
+
{ name: "write" },
|
|
224
|
+
{ name: "edit" },
|
|
225
|
+
{ name: "bash" },
|
|
226
|
+
]),
|
|
227
|
+
}),
|
|
228
|
+
new ToolCallGatePipeline(resolver, session),
|
|
229
|
+
new SkillInputGatePipeline(resolver),
|
|
230
|
+
runner,
|
|
231
|
+
);
|
|
232
|
+
return { handler, prompter: resolvedPrompter, session };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Builds the session-dedup handler without exposing the raw session.
|
|
237
|
+
*
|
|
238
|
+
* Wraps `makeDedupWiring`; returns `{ handler, prompter }`.
|
|
239
|
+
* Use `makeDedupWiring` when the test also needs `session.shutdown()`.
|
|
240
|
+
*/
|
|
241
|
+
export function makeDeduplicatingHandler(prompter?: GatePrompter) {
|
|
242
|
+
const { handler, prompter: resolvedPrompter } = makeDedupWiring(prompter);
|
|
243
|
+
return { handler, prompter: resolvedPrompter };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// ── Event builders ─────────────────────────────────────────────────────────
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Builds a tool-call event in the shape that external-directory-session-dedup
|
|
250
|
+
* tests use — `toolName` field (not `name`); both are accepted by
|
|
251
|
+
* `getToolNameFromValue`.
|
|
252
|
+
*/
|
|
253
|
+
export function makeExtDirToolEvent(
|
|
254
|
+
toolName: string,
|
|
255
|
+
path: string,
|
|
256
|
+
toolCallId = "tc-1",
|
|
257
|
+
) {
|
|
258
|
+
return { type: "tool_call" as const, toolCallId, toolName, input: { path } };
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** Builds a bash tool-call event for external-directory session-dedup tests. */
|
|
262
|
+
export function makeExtDirBashEvent(command: string, toolCallId = "tc-1") {
|
|
263
|
+
return {
|
|
264
|
+
type: "tool_call" as const,
|
|
265
|
+
toolCallId,
|
|
266
|
+
toolName: "bash",
|
|
267
|
+
input: { command },
|
|
268
|
+
};
|
|
269
|
+
}
|
|
@@ -213,8 +213,7 @@ export function makePathDispatchResolver(
|
|
|
213
213
|
}
|
|
214
214
|
return defaultResult;
|
|
215
215
|
}
|
|
216
|
-
const values =
|
|
217
|
-
intent.kind === "access-path" ? intent.path.matchValues() : intent.values;
|
|
216
|
+
const values = intent.path.matchValues();
|
|
218
217
|
for (const value of values) {
|
|
219
218
|
if (value in byPath) return byPath[value];
|
|
220
219
|
}
|
|
@@ -120,28 +120,7 @@ describe("PermissionResolver", () => {
|
|
|
120
120
|
});
|
|
121
121
|
});
|
|
122
122
|
|
|
123
|
-
describe("resolve —
|
|
124
|
-
it("forwards a path-values intent with the current session ruleset", () => {
|
|
125
|
-
const { resolver, permissionManager } = makeResolver();
|
|
126
|
-
|
|
127
|
-
resolver.resolve({
|
|
128
|
-
kind: "path-values",
|
|
129
|
-
surface: "path",
|
|
130
|
-
values: ["/proj/src/a.ts", "src/a.ts"],
|
|
131
|
-
agentName: "agent-x",
|
|
132
|
-
});
|
|
133
|
-
|
|
134
|
-
expect(permissionManager.check).toHaveBeenCalledWith(
|
|
135
|
-
{
|
|
136
|
-
kind: "path-values",
|
|
137
|
-
surface: "path",
|
|
138
|
-
values: ["/proj/src/a.ts", "src/a.ts"],
|
|
139
|
-
agentName: "agent-x",
|
|
140
|
-
},
|
|
141
|
-
[],
|
|
142
|
-
);
|
|
143
|
-
});
|
|
144
|
-
|
|
123
|
+
describe("resolve — session ruleset threading", () => {
|
|
145
124
|
it("applies a recorded session approval on the next call", () => {
|
|
146
125
|
const pm = makePermissionManager();
|
|
147
126
|
const sessionRules = new SessionRules();
|
|
@@ -151,9 +130,9 @@ describe("PermissionResolver", () => {
|
|
|
151
130
|
SessionApproval.single("path", "src/*"),
|
|
152
131
|
);
|
|
153
132
|
resolver.resolve({
|
|
154
|
-
kind: "path
|
|
133
|
+
kind: "access-path",
|
|
155
134
|
surface: "path",
|
|
156
|
-
|
|
135
|
+
path: AccessPath.forPath("src/a.ts", { cwd: "/proj" }),
|
|
157
136
|
});
|
|
158
137
|
|
|
159
138
|
const passedRules = vi.mocked(pm.check).mock.calls[0][1];
|
|
@@ -169,10 +148,7 @@ describe("PermissionResolver", () => {
|
|
|
169
148
|
describe("resolve — access-path intent", () => {
|
|
170
149
|
it("unwraps the AccessPath via matchValues() into a path-values intent", () => {
|
|
171
150
|
const { resolver, permissionManager } = makeResolver();
|
|
172
|
-
const accessPath = AccessPath.
|
|
173
|
-
"/tmp/x",
|
|
174
|
-
"/workspace",
|
|
175
|
-
);
|
|
151
|
+
const accessPath = AccessPath.forPath("/tmp/x", { cwd: "/workspace" });
|
|
176
152
|
|
|
177
153
|
resolver.resolve({
|
|
178
154
|
kind: "access-path",
|
|
@@ -202,10 +178,7 @@ describe("PermissionResolver", () => {
|
|
|
202
178
|
matchedPattern: "/tmp/*",
|
|
203
179
|
});
|
|
204
180
|
const { resolver } = makeResolver(pm);
|
|
205
|
-
const accessPath = AccessPath.
|
|
206
|
-
"/tmp/x",
|
|
207
|
-
"/workspace",
|
|
208
|
-
);
|
|
181
|
+
const accessPath = AccessPath.forPath("/tmp/x", { cwd: "/workspace" });
|
|
209
182
|
|
|
210
183
|
const result = resolver.resolve({
|
|
211
184
|
kind: "access-path",
|
|
@@ -1,19 +1,13 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { describe, expect, it, test } from "vitest";
|
|
2
2
|
|
|
3
3
|
import {
|
|
4
|
-
extractFrontmatter,
|
|
5
4
|
getNonEmptyString,
|
|
6
5
|
isDenyWithReason,
|
|
7
6
|
isPermissionState,
|
|
8
7
|
normalizeOptionalPositiveInt,
|
|
9
8
|
normalizeOptionalStringArray,
|
|
10
|
-
parseSimpleYamlMap,
|
|
11
9
|
toRecord,
|
|
12
|
-
} from "#src/
|
|
13
|
-
|
|
14
|
-
afterEach(() => {
|
|
15
|
-
vi.restoreAllMocks();
|
|
16
|
-
});
|
|
10
|
+
} from "#src/value-guards";
|
|
17
11
|
|
|
18
12
|
describe("toRecord", () => {
|
|
19
13
|
test("returns empty object for null", () => {
|
|
@@ -130,94 +124,6 @@ describe("isDenyWithReason", () => {
|
|
|
130
124
|
});
|
|
131
125
|
});
|
|
132
126
|
|
|
133
|
-
describe("extractFrontmatter", () => {
|
|
134
|
-
test("returns empty string when no frontmatter delimiter", () => {
|
|
135
|
-
expect(extractFrontmatter("# Hello\nSome content")).toBe("");
|
|
136
|
-
});
|
|
137
|
-
|
|
138
|
-
test("returns empty string when only opening delimiter with no closing", () => {
|
|
139
|
-
expect(extractFrontmatter("---\nkey: value")).toBe("");
|
|
140
|
-
});
|
|
141
|
-
|
|
142
|
-
test("returns frontmatter body between delimiters", () => {
|
|
143
|
-
const markdown = "---\nissue: 1\ntitle: Test\n---\n# Content";
|
|
144
|
-
expect(extractFrontmatter(markdown)).toBe("issue: 1\ntitle: Test");
|
|
145
|
-
});
|
|
146
|
-
|
|
147
|
-
test("returns empty string when file does not start with ---", () => {
|
|
148
|
-
expect(extractFrontmatter("content\n---\nkey: val\n---")).toBe("");
|
|
149
|
-
});
|
|
150
|
-
|
|
151
|
-
test("handles CRLF line endings", () => {
|
|
152
|
-
const markdown = "---\r\nissue: 5\r\n---\r\n# Content";
|
|
153
|
-
expect(extractFrontmatter(markdown)).toBe("issue: 5");
|
|
154
|
-
});
|
|
155
|
-
|
|
156
|
-
test("returns empty string for empty string input", () => {
|
|
157
|
-
expect(extractFrontmatter("")).toBe("");
|
|
158
|
-
});
|
|
159
|
-
|
|
160
|
-
test("returns empty frontmatter for --- \\n--- with nothing between", () => {
|
|
161
|
-
const markdown = "---\n---\n# Content";
|
|
162
|
-
expect(extractFrontmatter(markdown)).toBe("");
|
|
163
|
-
});
|
|
164
|
-
});
|
|
165
|
-
|
|
166
|
-
describe("parseSimpleYamlMap", () => {
|
|
167
|
-
test("returns empty object for empty string", () => {
|
|
168
|
-
expect(parseSimpleYamlMap("")).toEqual({});
|
|
169
|
-
});
|
|
170
|
-
|
|
171
|
-
test("parses simple key-value pairs", () => {
|
|
172
|
-
const yaml = "issue: 21\ntitle: Test";
|
|
173
|
-
expect(parseSimpleYamlMap(yaml)).toEqual({ issue: "21", title: "Test" });
|
|
174
|
-
});
|
|
175
|
-
|
|
176
|
-
test("strips surrounding quotes from values", () => {
|
|
177
|
-
const yaml = 'title: "My Title"';
|
|
178
|
-
expect(parseSimpleYamlMap(yaml)).toEqual({ title: "My Title" });
|
|
179
|
-
|
|
180
|
-
const yaml2 = "title: 'My Title'";
|
|
181
|
-
expect(parseSimpleYamlMap(yaml2)).toEqual({ title: "My Title" });
|
|
182
|
-
});
|
|
183
|
-
|
|
184
|
-
test("skips lines without colon or with colon at position 0", () => {
|
|
185
|
-
const yaml = "no separator here\n:starts-with-colon: val\nkey: val";
|
|
186
|
-
const result = parseSimpleYamlMap(yaml);
|
|
187
|
-
expect(result.key).toBe("val");
|
|
188
|
-
expect(result["no separator here"]).toBeUndefined();
|
|
189
|
-
});
|
|
190
|
-
|
|
191
|
-
test("skips comment lines", () => {
|
|
192
|
-
const yaml = "# This is a comment\nkey: value";
|
|
193
|
-
expect(parseSimpleYamlMap(yaml)).toEqual({ key: "value" });
|
|
194
|
-
});
|
|
195
|
-
|
|
196
|
-
test("skips blank lines", () => {
|
|
197
|
-
const yaml = "\n\nkey: value\n\n";
|
|
198
|
-
expect(parseSimpleYamlMap(yaml)).toEqual({ key: "value" });
|
|
199
|
-
});
|
|
200
|
-
|
|
201
|
-
test("parses nested map (child indented under parent)", () => {
|
|
202
|
-
const yaml = "parent:\n child: nested_value";
|
|
203
|
-
const result = parseSimpleYamlMap(yaml);
|
|
204
|
-
expect(result.parent).toEqual({ child: "nested_value" });
|
|
205
|
-
});
|
|
206
|
-
|
|
207
|
-
test("handles multi-line values correctly (second line is new key)", () => {
|
|
208
|
-
const yaml = "key1: val1\nkey2: val2";
|
|
209
|
-
const result = parseSimpleYamlMap(yaml);
|
|
210
|
-
expect(result.key1).toBe("val1");
|
|
211
|
-
expect(result.key2).toBe("val2");
|
|
212
|
-
});
|
|
213
|
-
|
|
214
|
-
test("strips quotes from keys", () => {
|
|
215
|
-
const yaml = '"quoted-key": value';
|
|
216
|
-
const result = parseSimpleYamlMap(yaml);
|
|
217
|
-
expect(result["quoted-key"]).toBe("value");
|
|
218
|
-
});
|
|
219
|
-
});
|
|
220
|
-
|
|
221
127
|
describe("normalizeOptionalStringArray", () => {
|
|
222
128
|
it("returns the array for a valid string array", () => {
|
|
223
129
|
expect(normalizeOptionalStringArray(["a", "b", "c"])).toEqual([
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { describe, expect, test } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { extractFrontmatter, parseSimpleYamlMap } from "#src/yaml-frontmatter";
|
|
4
|
+
|
|
5
|
+
describe("extractFrontmatter", () => {
|
|
6
|
+
test("returns empty string when no frontmatter delimiter", () => {
|
|
7
|
+
expect(extractFrontmatter("# Hello\nSome content")).toBe("");
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
test("returns empty string when only opening delimiter with no closing", () => {
|
|
11
|
+
expect(extractFrontmatter("---\nkey: value")).toBe("");
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test("returns frontmatter body between delimiters", () => {
|
|
15
|
+
const markdown = "---\nissue: 1\ntitle: Test\n---\n# Content";
|
|
16
|
+
expect(extractFrontmatter(markdown)).toBe("issue: 1\ntitle: Test");
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("returns empty string when file does not start with ---", () => {
|
|
20
|
+
expect(extractFrontmatter("content\n---\nkey: val\n---")).toBe("");
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("handles CRLF line endings", () => {
|
|
24
|
+
const markdown = "---\r\nissue: 5\r\n---\r\n# Content";
|
|
25
|
+
expect(extractFrontmatter(markdown)).toBe("issue: 5");
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("returns empty string for empty string input", () => {
|
|
29
|
+
expect(extractFrontmatter("")).toBe("");
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("returns empty frontmatter for --- \\n--- with nothing between", () => {
|
|
33
|
+
const markdown = "---\n---\n# Content";
|
|
34
|
+
expect(extractFrontmatter(markdown)).toBe("");
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
describe("parseSimpleYamlMap", () => {
|
|
39
|
+
test("returns empty object for empty string", () => {
|
|
40
|
+
expect(parseSimpleYamlMap("")).toEqual({});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("parses simple key-value pairs", () => {
|
|
44
|
+
const yaml = "issue: 21\ntitle: Test";
|
|
45
|
+
expect(parseSimpleYamlMap(yaml)).toEqual({ issue: "21", title: "Test" });
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("strips surrounding quotes from values", () => {
|
|
49
|
+
const yaml = 'title: "My Title"';
|
|
50
|
+
expect(parseSimpleYamlMap(yaml)).toEqual({ title: "My Title" });
|
|
51
|
+
|
|
52
|
+
const yaml2 = "title: 'My Title'";
|
|
53
|
+
expect(parseSimpleYamlMap(yaml2)).toEqual({ title: "My Title" });
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("skips lines without colon or with colon at position 0", () => {
|
|
57
|
+
const yaml = "no separator here\n:starts-with-colon: val\nkey: val";
|
|
58
|
+
const result = parseSimpleYamlMap(yaml);
|
|
59
|
+
expect(result.key).toBe("val");
|
|
60
|
+
expect(result["no separator here"]).toBeUndefined();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("skips comment lines", () => {
|
|
64
|
+
const yaml = "# This is a comment\nkey: value";
|
|
65
|
+
expect(parseSimpleYamlMap(yaml)).toEqual({ key: "value" });
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("skips blank lines", () => {
|
|
69
|
+
const yaml = "\n\nkey: value\n\n";
|
|
70
|
+
expect(parseSimpleYamlMap(yaml)).toEqual({ key: "value" });
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("parses nested map (child indented under parent)", () => {
|
|
74
|
+
const yaml = "parent:\n child: nested_value";
|
|
75
|
+
const result = parseSimpleYamlMap(yaml);
|
|
76
|
+
expect(result.parent).toEqual({ child: "nested_value" });
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("handles multi-line values correctly (second line is new key)", () => {
|
|
80
|
+
const yaml = "key1: val1\nkey2: val2";
|
|
81
|
+
const result = parseSimpleYamlMap(yaml);
|
|
82
|
+
expect(result.key1).toBe("val1");
|
|
83
|
+
expect(result.key2).toBe("val2");
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("strips quotes from keys", () => {
|
|
87
|
+
const yaml = '"quoted-key": value';
|
|
88
|
+
const result = parseSimpleYamlMap(yaml);
|
|
89
|
+
expect(result["quoted-key"]).toBe("value");
|
|
90
|
+
});
|
|
91
|
+
});
|