@gotgenes/pi-permission-system 8.1.0 → 8.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +8 -0
- package/package.json +1 -1
- package/src/handlers/gates/bash-external-directory.ts +2 -4
- package/src/handlers/gates/bash-path.ts +2 -4
- package/src/handlers/gates/descriptor.ts +6 -6
- package/src/handlers/gates/external-directory.ts +2 -4
- package/src/handlers/gates/helpers.ts +30 -1
- package/src/handlers/gates/path.ts +2 -4
- package/src/handlers/gates/runner.ts +29 -56
- package/src/handlers/gates/tool.ts +5 -4
- package/src/handlers/permission-gate-handler.ts +4 -3
- package/src/permission-manager.ts +6 -49
- package/src/permission-session.ts +3 -2
- package/src/scope-merge.ts +72 -0
- package/src/session-approval.ts +43 -0
- package/src/session-rules.ts +13 -0
- package/test/handlers/external-directory-integration.test.ts +1 -1
- package/test/handlers/external-directory-session-dedup.test.ts +15 -12
- package/test/handlers/gates/bash-external-directory.test.ts +11 -9
- package/test/handlers/gates/external-directory.test.ts +2 -5
- package/test/handlers/gates/helpers.test.ts +81 -0
- package/test/handlers/gates/path.test.ts +2 -2
- package/test/handlers/gates/runner.test.ts +18 -23
- package/test/handlers/gates/tool.test.ts +2 -2
- package/test/handlers/input-events.test.ts +1 -1
- package/test/handlers/input.test.ts +1 -1
- package/test/handlers/tool-call-events.test.ts +1 -1
- package/test/handlers/tool-call.test.ts +1 -1
- package/test/permission-session.test.ts +6 -3
- package/test/scope-merge.test.ts +116 -0
- package/test/session-approval.test.ts +75 -0
- package/test/session-rules.test.ts +49 -0
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,14 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [8.2.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v8.1.0...pi-permission-system-v8.2.0) (2026-05-31)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Features
|
|
12
|
+
|
|
13
|
+
* add SessionApproval value object and SessionRules.record ([8f98d92](https://github.com/gotgenes/pi-packages/commit/8f98d9223a424b0993d51c2d9106e7d01c6819d7))
|
|
14
|
+
* centralize decision-event construction in buildDecisionEvent ([19c2c83](https://github.com/gotgenes/pi-packages/commit/19c2c837b1907a4c302105ee86715533477247d4))
|
|
15
|
+
|
|
8
16
|
## [8.1.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v8.0.0...pi-permission-system-v8.1.0) (2026-05-31)
|
|
9
17
|
|
|
10
18
|
|
package/package.json
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { getNonEmptyString, toRecord } from "#src/common";
|
|
2
2
|
import type { Rule } from "#src/rule";
|
|
3
|
+
import { SessionApproval } from "#src/session-approval";
|
|
3
4
|
import { deriveApprovalPattern } from "#src/session-rules";
|
|
4
5
|
import type { PermissionCheckResult } from "#src/types";
|
|
5
6
|
import { extractExternalPathsFromBashCommand } from "./bash-path-extractor";
|
|
@@ -106,10 +107,7 @@ export async function describeBashExternalDirectoryGate(
|
|
|
106
107
|
cwd: tcc.cwd,
|
|
107
108
|
agentName: tcc.agentName ?? undefined,
|
|
108
109
|
},
|
|
109
|
-
sessionApproval:
|
|
110
|
-
surface: "external_directory",
|
|
111
|
-
patterns,
|
|
112
|
-
},
|
|
110
|
+
sessionApproval: SessionApproval.multiple("external_directory", patterns),
|
|
113
111
|
promptDetails: {
|
|
114
112
|
source: "tool_call",
|
|
115
113
|
agentName: tcc.agentName,
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { getNonEmptyString, toRecord } from "#src/common";
|
|
2
2
|
import type { Rule } from "#src/rule";
|
|
3
|
+
import { SessionApproval } from "#src/session-approval";
|
|
3
4
|
import { deriveApprovalPattern } from "#src/session-rules";
|
|
4
5
|
import type { PermissionCheckResult } from "#src/types";
|
|
5
6
|
import { extractTokensForPathRules } from "./bash-path-extractor";
|
|
@@ -117,10 +118,7 @@ export async function describeBashPathGate(
|
|
|
117
118
|
pathValue: worstToken,
|
|
118
119
|
agentName: tcc.agentName ?? undefined,
|
|
119
120
|
},
|
|
120
|
-
sessionApproval:
|
|
121
|
-
surface: "path",
|
|
122
|
-
pattern,
|
|
123
|
-
},
|
|
121
|
+
sessionApproval: SessionApproval.single("path", pattern),
|
|
124
122
|
promptDetails: {
|
|
125
123
|
source: "tool_call",
|
|
126
124
|
agentName: tcc.agentName,
|
|
@@ -3,6 +3,7 @@ import type { PermissionPromptDecision } from "#src/permission-dialog";
|
|
|
3
3
|
import type { PermissionDecisionEvent } from "#src/permission-events";
|
|
4
4
|
import type { PromptPermissionDetails } from "#src/permission-prompter";
|
|
5
5
|
import type { Rule } from "#src/rule";
|
|
6
|
+
import type { SessionApproval } from "#src/session-approval";
|
|
6
7
|
import type { PermissionCheckResult, PermissionState } from "#src/types";
|
|
7
8
|
|
|
8
9
|
// ── Descriptor types ───────────────────────────────────────────────────────
|
|
@@ -22,12 +23,11 @@ export interface GateDescriptor {
|
|
|
22
23
|
/** Structured denial context — the runner formats messages from this. */
|
|
23
24
|
denialContext: DenialContext;
|
|
24
25
|
/**
|
|
25
|
-
* Session-approval suggestion for "for this session" option.
|
|
26
|
-
*
|
|
26
|
+
* Session-approval suggestion for the "for this session" option.
|
|
27
|
+
* Wraps either a single pattern or multiple patterns behind a unified
|
|
28
|
+
* interface — the runner never needs to know which case applies.
|
|
27
29
|
*/
|
|
28
|
-
sessionApproval?:
|
|
29
|
-
| { surface: string; pattern: string }
|
|
30
|
-
| { surface: string; patterns: string[] };
|
|
30
|
+
sessionApproval?: SessionApproval;
|
|
31
31
|
/** Details passed to the interactive permission prompt (requestId is added by the runner). */
|
|
32
32
|
promptDetails: Omit<PromptPermissionDetails, "requestId">;
|
|
33
33
|
/** Extra context fields written to the review log alongside gate outcomes. */
|
|
@@ -87,7 +87,7 @@ export interface GateRunnerDeps {
|
|
|
87
87
|
sessionRules?: Rule[],
|
|
88
88
|
): PermissionCheckResult;
|
|
89
89
|
getSessionRuleset(): Rule[];
|
|
90
|
-
|
|
90
|
+
recordSessionApproval(approval: SessionApproval): void;
|
|
91
91
|
writeReviewLog(event: string, details: Record<string, unknown>): void;
|
|
92
92
|
emitDecision(event: PermissionDecisionEvent): void;
|
|
93
93
|
canConfirm(): boolean;
|
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
isPiInfrastructureRead,
|
|
5
5
|
normalizePathForComparison,
|
|
6
6
|
} from "#src/path-utils";
|
|
7
|
+
import { SessionApproval } from "#src/session-approval";
|
|
7
8
|
import { deriveApprovalPattern } from "#src/session-rules";
|
|
8
9
|
import type { GateResult } from "./descriptor";
|
|
9
10
|
import { formatExternalDirectoryAskPrompt } from "./external-directory-messages";
|
|
@@ -83,10 +84,7 @@ export function describeExternalDirectoryGate(
|
|
|
83
84
|
cwd: tcc.cwd,
|
|
84
85
|
agentName: tcc.agentName ?? undefined,
|
|
85
86
|
},
|
|
86
|
-
sessionApproval:
|
|
87
|
-
surface: "external_directory",
|
|
88
|
-
pattern,
|
|
89
|
-
},
|
|
87
|
+
sessionApproval: SessionApproval.single("external_directory", pattern),
|
|
90
88
|
promptDetails: {
|
|
91
89
|
source: "tool_call",
|
|
92
90
|
agentName: tcc.agentName,
|
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type {
|
|
2
|
+
PermissionDecisionEvent,
|
|
3
|
+
PermissionDecisionResolution,
|
|
4
|
+
} from "#src/permission-events";
|
|
2
5
|
import type { PermissionCheckResult } from "#src/types";
|
|
3
6
|
|
|
4
7
|
/**
|
|
@@ -17,6 +20,32 @@ export function deriveDecisionValue(
|
|
|
17
20
|
return toolName;
|
|
18
21
|
}
|
|
19
22
|
|
|
23
|
+
/**
|
|
24
|
+
* Build a `PermissionDecisionEvent` from the gate's inputs.
|
|
25
|
+
*
|
|
26
|
+
* Centralises the `origin / agentName / matchedPattern ?? null` normalization
|
|
27
|
+
* that is otherwise duplicated across the session-hit path and the gate-result
|
|
28
|
+
* path in `runGateCheck`.
|
|
29
|
+
*/
|
|
30
|
+
export function buildDecisionEvent(
|
|
31
|
+
decision: { surface: string; value: string },
|
|
32
|
+
check: Pick<PermissionCheckResult, "origin" | "matchedPattern">,
|
|
33
|
+
agentName: string | null,
|
|
34
|
+
result: "allow" | "deny",
|
|
35
|
+
resolution: PermissionDecisionResolution,
|
|
36
|
+
): PermissionDecisionEvent {
|
|
37
|
+
return {
|
|
38
|
+
surface: decision.surface,
|
|
39
|
+
value: decision.value,
|
|
40
|
+
result,
|
|
41
|
+
resolution,
|
|
42
|
+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- ?? null normalises undefined to null for the log record
|
|
43
|
+
origin: check.origin ?? null,
|
|
44
|
+
agentName: agentName ?? null,
|
|
45
|
+
matchedPattern: check.matchedPattern ?? null,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
20
49
|
/**
|
|
21
50
|
* Map the gate outcome back to a PermissionDecisionResolution.
|
|
22
51
|
*
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { getPathBearingToolPath } from "#src/path-utils";
|
|
2
2
|
import type { Rule } from "#src/rule";
|
|
3
|
+
import { SessionApproval } from "#src/session-approval";
|
|
3
4
|
import { deriveApprovalPattern } from "#src/session-rules";
|
|
4
5
|
import type { PermissionCheckResult } from "#src/types";
|
|
5
6
|
import type { GateDescriptor, GateResult } from "./descriptor";
|
|
@@ -55,10 +56,7 @@ export function describePathGate(
|
|
|
55
56
|
pathValue: filePath,
|
|
56
57
|
agentName: tcc.agentName ?? undefined,
|
|
57
58
|
},
|
|
58
|
-
sessionApproval:
|
|
59
|
-
surface: "path",
|
|
60
|
-
pattern,
|
|
61
|
-
},
|
|
59
|
+
sessionApproval: SessionApproval.single("path", pattern),
|
|
62
60
|
promptDetails: {
|
|
63
61
|
source: "tool_call",
|
|
64
62
|
agentName: tcc.agentName,
|
|
@@ -7,7 +7,7 @@ import type { PermissionPromptDecision } from "#src/permission-dialog";
|
|
|
7
7
|
import { applyPermissionGate } from "#src/permission-gate";
|
|
8
8
|
import type { PermissionCheckResult } from "#src/types";
|
|
9
9
|
import type { GateDescriptor, GateRunnerDeps } from "./descriptor";
|
|
10
|
-
import { deriveResolution } from "./helpers";
|
|
10
|
+
import { buildDecisionEvent, deriveResolution } from "./helpers";
|
|
11
11
|
import type { GateOutcome } from "./types";
|
|
12
12
|
|
|
13
13
|
/**
|
|
@@ -56,37 +56,21 @@ export async function runGateCheck(
|
|
|
56
56
|
resolution: "session_approved",
|
|
57
57
|
sessionApprovalPattern: check.matchedPattern,
|
|
58
58
|
});
|
|
59
|
-
deps.emitDecision(
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
});
|
|
59
|
+
deps.emitDecision(
|
|
60
|
+
buildDecisionEvent(
|
|
61
|
+
descriptor.decision,
|
|
62
|
+
check,
|
|
63
|
+
agentName,
|
|
64
|
+
"allow",
|
|
65
|
+
"session_approved",
|
|
66
|
+
),
|
|
67
|
+
);
|
|
69
68
|
return { action: "allow" };
|
|
70
69
|
}
|
|
71
70
|
|
|
72
71
|
// 3. Apply the deny/ask/allow gate
|
|
73
72
|
const canConfirm = deps.canConfirm();
|
|
74
73
|
|
|
75
|
-
// Resolve the first pattern for applyPermissionGate's sessionApproval param
|
|
76
|
-
const singleSessionApproval = descriptor.sessionApproval
|
|
77
|
-
? "pattern" in descriptor.sessionApproval
|
|
78
|
-
? {
|
|
79
|
-
surface: descriptor.sessionApproval.surface,
|
|
80
|
-
pattern: descriptor.sessionApproval.pattern,
|
|
81
|
-
}
|
|
82
|
-
: descriptor.sessionApproval.patterns.length > 0
|
|
83
|
-
? {
|
|
84
|
-
surface: descriptor.sessionApproval.surface,
|
|
85
|
-
pattern: descriptor.sessionApproval.patterns[0],
|
|
86
|
-
}
|
|
87
|
-
: undefined
|
|
88
|
-
: undefined;
|
|
89
|
-
|
|
90
74
|
// Construct messages from the centralized formatter.
|
|
91
75
|
const messages = {
|
|
92
76
|
denyReason: formatDenyReason(descriptor.denialContext),
|
|
@@ -99,7 +83,7 @@ export async function runGateCheck(
|
|
|
99
83
|
const gateResult = await applyPermissionGate({
|
|
100
84
|
state: check.state,
|
|
101
85
|
canConfirm,
|
|
102
|
-
sessionApproval:
|
|
86
|
+
sessionApproval: descriptor.sessionApproval?.toGateApproval(),
|
|
103
87
|
promptForApproval: async () => {
|
|
104
88
|
const decision = await deps.promptPermission({
|
|
105
89
|
requestId: toolCallId,
|
|
@@ -119,37 +103,26 @@ export async function runGateCheck(
|
|
|
119
103
|
gateResult.action === "allow" && gateResult.sessionApproval !== undefined;
|
|
120
104
|
|
|
121
105
|
// 5. Emit decision event
|
|
122
|
-
deps.emitDecision(
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
106
|
+
deps.emitDecision(
|
|
107
|
+
buildDecisionEvent(
|
|
108
|
+
descriptor.decision,
|
|
109
|
+
check,
|
|
110
|
+
agentName,
|
|
111
|
+
gateResult.action === "allow" ? "allow" : "deny",
|
|
112
|
+
deriveResolution(
|
|
113
|
+
check.state,
|
|
114
|
+
gateResult.action,
|
|
115
|
+
hasSessionApproval,
|
|
116
|
+
canConfirm,
|
|
117
|
+
autoApproved,
|
|
118
|
+
),
|
|
132
119
|
),
|
|
133
|
-
|
|
134
|
-
origin: check.origin ?? null,
|
|
135
|
-
agentName: agentName ?? null,
|
|
136
|
-
matchedPattern: check.matchedPattern ?? null,
|
|
137
|
-
});
|
|
120
|
+
);
|
|
138
121
|
|
|
139
|
-
// 6. Record session approval
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
for (const pattern of descriptor.sessionApproval.patterns) {
|
|
144
|
-
deps.approveSessionRule(descriptor.sessionApproval.surface, pattern);
|
|
145
|
-
}
|
|
146
|
-
} else {
|
|
147
|
-
deps.approveSessionRule(
|
|
148
|
-
descriptor.sessionApproval.surface,
|
|
149
|
-
descriptor.sessionApproval.pattern,
|
|
150
|
-
);
|
|
151
|
-
}
|
|
152
|
-
}
|
|
122
|
+
// 6. Record session approval — tell the store; it owns the per-pattern loop
|
|
123
|
+
// hasSessionApproval already implies gateResult.action === "allow"
|
|
124
|
+
if (hasSessionApproval && descriptor.sessionApproval) {
|
|
125
|
+
deps.recordSessionApproval(descriptor.sessionApproval);
|
|
153
126
|
}
|
|
154
127
|
|
|
155
128
|
if (gateResult.action === "block") {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { getPathBearingToolPath, PATH_BEARING_TOOLS } from "#src/path-utils";
|
|
2
2
|
import { suggestSessionPattern } from "#src/pattern-suggest";
|
|
3
3
|
import { formatAskPrompt } from "#src/permission-prompts";
|
|
4
|
+
import { SessionApproval } from "#src/session-approval";
|
|
4
5
|
import type { ToolPreviewFormatter } from "#src/tool-preview-formatter";
|
|
5
6
|
import type { PermissionCheckResult } from "#src/types";
|
|
6
7
|
import type { GateDescriptor } from "./descriptor";
|
|
@@ -61,10 +62,10 @@ export function describeToolGate(
|
|
|
61
62
|
agentName: tcc.agentName ?? undefined,
|
|
62
63
|
input: tcc.input,
|
|
63
64
|
},
|
|
64
|
-
sessionApproval:
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
65
|
+
sessionApproval: SessionApproval.single(
|
|
66
|
+
suggestion.surface,
|
|
67
|
+
suggestion.pattern,
|
|
68
|
+
),
|
|
68
69
|
promptDetails: {
|
|
69
70
|
source: "tool_call",
|
|
70
71
|
agentName: tcc.agentName,
|
|
@@ -99,14 +99,15 @@ export class PermissionGateHandler {
|
|
|
99
99
|
sessionRules,
|
|
100
100
|
) => this.session.checkPermission(surface, input, agent, sessionRules);
|
|
101
101
|
const getSessionRuleset = () => this.session.getSessionRuleset();
|
|
102
|
-
const
|
|
103
|
-
|
|
102
|
+
const recordSessionApproval: GateRunnerDeps["recordSessionApproval"] = (
|
|
103
|
+
approval,
|
|
104
|
+
) => this.session.recordSessionApproval(approval);
|
|
104
105
|
|
|
105
106
|
// ── Shared runner deps (built once, reused for all gates) ────────────
|
|
106
107
|
const runnerDeps: GateRunnerDeps = {
|
|
107
108
|
checkPermission,
|
|
108
109
|
getSessionRuleset,
|
|
109
|
-
|
|
110
|
+
recordSessionApproval,
|
|
110
111
|
writeReviewLog,
|
|
111
112
|
emitDecision,
|
|
112
113
|
canConfirm,
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { isPermissionState } from "./common";
|
|
2
2
|
import { normalizeInput } from "./input-normalizer";
|
|
3
3
|
import { normalizeFlatConfig } from "./normalize";
|
|
4
|
-
import { mergeFlatPermissions } from "./permission-merge";
|
|
5
4
|
import {
|
|
6
5
|
FilePolicyLoader,
|
|
7
6
|
type PolicyLoader,
|
|
@@ -10,6 +9,7 @@ import {
|
|
|
10
9
|
} from "./policy-loader";
|
|
11
10
|
import type { Rule, RuleOrigin, Ruleset } from "./rule";
|
|
12
11
|
import { evaluate, evaluateFirst } from "./rule";
|
|
12
|
+
import { mergeScopesWithOrigins } from "./scope-merge";
|
|
13
13
|
import {
|
|
14
14
|
composeRuleset,
|
|
15
15
|
synthesizeBaseline,
|
|
@@ -90,58 +90,15 @@ export class PermissionManager {
|
|
|
90
90
|
const agentConfig = this.loader.loadAgentConfig(agentName);
|
|
91
91
|
const projectAgentConfig = this.loader.loadProjectAgentConfig(agentName);
|
|
92
92
|
|
|
93
|
-
// Merge permission objects across scopes (lowest → highest precedence)
|
|
94
|
-
//
|
|
95
|
-
// (surface, pattern) entry
|
|
96
|
-
|
|
97
|
-
const origins: OriginMap = new Map();
|
|
98
|
-
let mergedPermission: FlatPermissionConfig = {};
|
|
99
|
-
|
|
100
|
-
for (const [scopeName, scope] of [
|
|
93
|
+
// Merge permission objects across scopes (lowest → highest precedence),
|
|
94
|
+
// building a parallel origin map that tracks which scope contributed each
|
|
95
|
+
// (surface, pattern) entry.
|
|
96
|
+
const { mergedPermission, origins } = mergeScopesWithOrigins([
|
|
101
97
|
["global", globalConfig],
|
|
102
98
|
["project", projectConfig],
|
|
103
99
|
["agent", agentConfig],
|
|
104
100
|
["project-agent", projectAgentConfig],
|
|
105
|
-
]
|
|
106
|
-
if (!scope.permission) continue;
|
|
107
|
-
|
|
108
|
-
for (const [surface, value] of Object.entries(scope.permission)) {
|
|
109
|
-
const baseVal = mergedPermission[surface];
|
|
110
|
-
/* eslint-disable @typescript-eslint/no-unnecessary-condition -- defensive null/type checks; config values may differ at runtime */
|
|
111
|
-
const bothObjects =
|
|
112
|
-
typeof baseVal === "object" &&
|
|
113
|
-
baseVal !== null &&
|
|
114
|
-
typeof value === "object" &&
|
|
115
|
-
value !== null;
|
|
116
|
-
/* eslint-enable @typescript-eslint/no-unnecessary-condition */
|
|
117
|
-
|
|
118
|
-
if (bothObjects) {
|
|
119
|
-
// Shallow-merge: each incoming pattern is attributed to this scope;
|
|
120
|
-
// existing patterns from lower scopes keep their earlier origin.
|
|
121
|
-
if (!origins.has(surface)) origins.set(surface, new Map());
|
|
122
|
-
for (const pattern of Object.keys(value)) {
|
|
123
|
-
origins.get(surface)?.set(pattern, scopeName);
|
|
124
|
-
}
|
|
125
|
-
} else {
|
|
126
|
-
// Full replacement: this scope takes over the entire surface entry.
|
|
127
|
-
const surfaceOrigins = new Map<string, RuleOrigin>();
|
|
128
|
-
if (typeof value === "string") {
|
|
129
|
-
surfaceOrigins.set("*", scopeName);
|
|
130
|
-
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- defensive null check
|
|
131
|
-
} else if (typeof value === "object" && value !== null) {
|
|
132
|
-
for (const pattern of Object.keys(value)) {
|
|
133
|
-
surfaceOrigins.set(pattern, scopeName);
|
|
134
|
-
}
|
|
135
|
-
}
|
|
136
|
-
origins.set(surface, surfaceOrigins);
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
mergedPermission = mergeFlatPermissions(
|
|
141
|
-
mergedPermission,
|
|
142
|
-
scope.permission,
|
|
143
|
-
);
|
|
144
|
-
}
|
|
101
|
+
]);
|
|
145
102
|
|
|
146
103
|
// Extract the universal fallback from permission["*"].
|
|
147
104
|
// The "*" key feeds synthesizeDefaults() only — it is NOT included as a
|
|
@@ -12,6 +12,7 @@ import type { PermissionManager } from "./permission-manager";
|
|
|
12
12
|
import type { PromptPermissionDetails } from "./permission-prompter";
|
|
13
13
|
import type { Rule } from "./rule";
|
|
14
14
|
import { createPermissionManagerForCwd } from "./runtime";
|
|
15
|
+
import type { SessionApproval } from "./session-approval";
|
|
15
16
|
import type { SessionLogger } from "./session-logger";
|
|
16
17
|
import { SessionRules } from "./session-rules";
|
|
17
18
|
import type { SkillPromptEntry } from "./skill-prompt-sanitizer";
|
|
@@ -127,8 +128,8 @@ export class PermissionSession {
|
|
|
127
128
|
return this.sessionRules.getRuleset();
|
|
128
129
|
}
|
|
129
130
|
|
|
130
|
-
|
|
131
|
-
this.sessionRules.
|
|
131
|
+
recordSessionApproval(approval: SessionApproval): void {
|
|
132
|
+
this.sessionRules.record(approval);
|
|
132
133
|
}
|
|
133
134
|
|
|
134
135
|
// ── Session lifecycle ────────────────────────────────────────────────────
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { mergeFlatPermissions } from "#src/permission-merge";
|
|
2
|
+
import type { RuleOrigin } from "#src/rule";
|
|
3
|
+
import type { FlatPermissionConfig, ScopeConfig } from "#src/types";
|
|
4
|
+
|
|
5
|
+
/** Surface → (pattern → originating scope). */
|
|
6
|
+
type OriginMap = Map<string, Map<string, RuleOrigin>>;
|
|
7
|
+
|
|
8
|
+
/** Result of merging permission objects across scopes with provenance tracking. */
|
|
9
|
+
export interface MergedScopes {
|
|
10
|
+
/** Fully merged flat permission config (lowest → highest precedence). */
|
|
11
|
+
mergedPermission: FlatPermissionConfig;
|
|
12
|
+
/** Maps each surface to a per-pattern origin (which scope contributed it). */
|
|
13
|
+
origins: OriginMap;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Merge permission objects across scopes (lowest → highest precedence) while
|
|
18
|
+
* tracking which scope contributed each (surface, pattern) entry.
|
|
19
|
+
*
|
|
20
|
+
* Mirrors mergeFlatPermissions() semantics for origin attribution:
|
|
21
|
+
* - Both base and incoming are objects → shallow-merge: each incoming pattern
|
|
22
|
+
* is attributed to this scope; patterns the higher scope does not redefine
|
|
23
|
+
* keep their earlier origin.
|
|
24
|
+
* - Otherwise → full replacement: this scope takes over the entire surface
|
|
25
|
+
* entry, discarding all lower-scope attribution.
|
|
26
|
+
*/
|
|
27
|
+
export function mergeScopesWithOrigins(
|
|
28
|
+
scopes: readonly (readonly [RuleOrigin, ScopeConfig])[],
|
|
29
|
+
): MergedScopes {
|
|
30
|
+
const origins: OriginMap = new Map();
|
|
31
|
+
let mergedPermission: FlatPermissionConfig = {};
|
|
32
|
+
|
|
33
|
+
for (const [scopeName, scope] of scopes) {
|
|
34
|
+
if (!scope.permission) continue;
|
|
35
|
+
|
|
36
|
+
for (const [surface, value] of Object.entries(scope.permission)) {
|
|
37
|
+
const baseVal = mergedPermission[surface];
|
|
38
|
+
/* eslint-disable @typescript-eslint/no-unnecessary-condition -- defensive null/type checks; config values may differ at runtime */
|
|
39
|
+
const bothObjects =
|
|
40
|
+
typeof baseVal === "object" &&
|
|
41
|
+
baseVal !== null &&
|
|
42
|
+
typeof value === "object" &&
|
|
43
|
+
value !== null;
|
|
44
|
+
/* eslint-enable @typescript-eslint/no-unnecessary-condition */
|
|
45
|
+
|
|
46
|
+
if (bothObjects) {
|
|
47
|
+
// Shallow-merge: each incoming pattern is attributed to this scope;
|
|
48
|
+
// existing patterns from lower scopes keep their earlier origin.
|
|
49
|
+
if (!origins.has(surface)) origins.set(surface, new Map());
|
|
50
|
+
for (const pattern of Object.keys(value)) {
|
|
51
|
+
origins.get(surface)?.set(pattern, scopeName);
|
|
52
|
+
}
|
|
53
|
+
} else {
|
|
54
|
+
// Full replacement: this scope takes over the entire surface entry.
|
|
55
|
+
const surfaceOrigins = new Map<string, RuleOrigin>();
|
|
56
|
+
if (typeof value === "string") {
|
|
57
|
+
surfaceOrigins.set("*", scopeName);
|
|
58
|
+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- defensive null check
|
|
59
|
+
} else if (typeof value === "object" && value !== null) {
|
|
60
|
+
for (const pattern of Object.keys(value)) {
|
|
61
|
+
surfaceOrigins.set(pattern, scopeName);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
origins.set(surface, surfaceOrigins);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
mergedPermission = mergeFlatPermissions(mergedPermission, scope.permission);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return { mergedPermission, origins };
|
|
72
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Value object for a session-scoped approval: one surface, one-or-more patterns.
|
|
3
|
+
*
|
|
4
|
+
* Owned by gate descriptors and passed to the session store — the runner never
|
|
5
|
+
* needs to know whether there is one pattern or many.
|
|
6
|
+
*/
|
|
7
|
+
export class SessionApproval {
|
|
8
|
+
private constructor(
|
|
9
|
+
readonly surface: string,
|
|
10
|
+
readonly patterns: readonly string[],
|
|
11
|
+
) {}
|
|
12
|
+
|
|
13
|
+
/** Create an approval for a single pattern (the common case). */
|
|
14
|
+
static single(surface: string, pattern: string): SessionApproval {
|
|
15
|
+
return new SessionApproval(surface, [pattern]);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Create an approval for multiple patterns (e.g. bash external-directory
|
|
20
|
+
* gates that cover several uncovered paths in one prompt).
|
|
21
|
+
*/
|
|
22
|
+
static multiple(
|
|
23
|
+
surface: string,
|
|
24
|
+
patterns: readonly string[],
|
|
25
|
+
): SessionApproval {
|
|
26
|
+
return new SessionApproval(surface, [...patterns]);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Representative pattern for the interactive prompt — the first, if any. */
|
|
30
|
+
get representativePattern(): string | undefined {
|
|
31
|
+
return this.patterns[0];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Single-pattern shape `applyPermissionGate` echoes back to the caller.
|
|
36
|
+
* Returns `undefined` when patterns is empty (degenerate case).
|
|
37
|
+
*/
|
|
38
|
+
toGateApproval(): { surface: string; pattern: string } | undefined {
|
|
39
|
+
const pattern = this.representativePattern;
|
|
40
|
+
if (pattern === undefined) return undefined;
|
|
41
|
+
return { surface: this.surface, pattern };
|
|
42
|
+
}
|
|
43
|
+
}
|
package/src/session-rules.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { dirname, sep } from "node:path";
|
|
2
2
|
|
|
3
3
|
import type { Ruleset } from "./rule";
|
|
4
|
+
import type { SessionApproval } from "./session-approval";
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* Ephemeral in-memory store of session-scoped permission approvals.
|
|
@@ -29,6 +30,18 @@ export class SessionRules {
|
|
|
29
30
|
return [...this.rules];
|
|
30
31
|
}
|
|
31
32
|
|
|
33
|
+
/**
|
|
34
|
+
* Record all patterns from a `SessionApproval` value object.
|
|
35
|
+
*
|
|
36
|
+
* The loop lives here so callers never need to know whether an approval
|
|
37
|
+
* carries one pattern or many — they just tell the store to record it.
|
|
38
|
+
*/
|
|
39
|
+
record(approval: SessionApproval): void {
|
|
40
|
+
for (const pattern of approval.patterns) {
|
|
41
|
+
this.approve(approval.surface, pattern);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
32
45
|
/** Remove all session approvals. */
|
|
33
46
|
clear(): void {
|
|
34
47
|
this.rules = [];
|
|
@@ -113,7 +113,7 @@ function makeSession(
|
|
|
113
113
|
checkPermission: makeCheckPermission("deny"),
|
|
114
114
|
getToolPermission: vi.fn().mockReturnValue("allow"),
|
|
115
115
|
getSessionRuleset: vi.fn().mockReturnValue([]),
|
|
116
|
-
|
|
116
|
+
recordSessionApproval: vi.fn(),
|
|
117
117
|
getActiveSkillEntries: vi.fn().mockReturnValue([]),
|
|
118
118
|
getInfrastructureDirs: vi.fn().mockReturnValue([]),
|
|
119
119
|
getInfrastructureReadPaths: vi.fn().mockReturnValue([]),
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* external path only prompt once — the session-approval recorded by the
|
|
4
4
|
* first call covers the second.
|
|
5
5
|
*
|
|
6
|
-
* These tests use stateful mocks: `
|
|
6
|
+
* These tests use stateful mocks: `recordSessionApproval` records rules,
|
|
7
7
|
* and `checkPermission` consults them via `getSessionRuleset`, mirroring
|
|
8
8
|
* the real interaction between PermissionSession, SessionRules, and
|
|
9
9
|
* PermissionManager.
|
|
@@ -15,6 +15,7 @@ import { DEFAULT_EXTENSION_CONFIG } from "#src/extension-config";
|
|
|
15
15
|
import { PermissionGateHandler } from "#src/handlers/permission-gate-handler";
|
|
16
16
|
import type { PermissionSession } from "#src/permission-session";
|
|
17
17
|
import type { Rule } from "#src/rule";
|
|
18
|
+
import type { SessionApproval } from "#src/session-approval";
|
|
18
19
|
import type { ToolRegistry } from "#src/tool-registry";
|
|
19
20
|
import type { PermissionCheckResult } from "#src/types";
|
|
20
21
|
import { wildcardMatch } from "#src/wildcard-matcher";
|
|
@@ -53,7 +54,7 @@ function makeCtx(overrides: Partial<ExtensionContext> = {}): ExtensionContext {
|
|
|
53
54
|
* Build a PermissionSession mock with stateful session-rule tracking.
|
|
54
55
|
*
|
|
55
56
|
* `checkPermission` returns "ask" for `external_directory` unless a
|
|
56
|
-
* matching session rule exists (via `
|
|
57
|
+
* matching session rule exists (via `recordSessionApproval`), in which case
|
|
57
58
|
* it returns "allow" with `source: "session"`. All other surfaces return
|
|
58
59
|
* "allow" by default.
|
|
59
60
|
*/
|
|
@@ -115,16 +116,18 @@ function makeStatefulSession(
|
|
|
115
116
|
},
|
|
116
117
|
);
|
|
117
118
|
|
|
118
|
-
const
|
|
119
|
+
const recordSessionApproval = vi
|
|
119
120
|
.fn()
|
|
120
|
-
.mockImplementation((
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
121
|
+
.mockImplementation((approval: SessionApproval) => {
|
|
122
|
+
for (const pattern of approval.patterns) {
|
|
123
|
+
sessionRules.push({
|
|
124
|
+
surface: approval.surface,
|
|
125
|
+
pattern,
|
|
126
|
+
action: "allow",
|
|
127
|
+
layer: "session",
|
|
128
|
+
origin: "session",
|
|
129
|
+
});
|
|
130
|
+
}
|
|
128
131
|
});
|
|
129
132
|
|
|
130
133
|
const getSessionRuleset = vi.fn().mockImplementation(() => [...sessionRules]);
|
|
@@ -136,7 +139,7 @@ function makeStatefulSession(
|
|
|
136
139
|
checkPermission,
|
|
137
140
|
getToolPermission: vi.fn().mockReturnValue("allow"),
|
|
138
141
|
getSessionRuleset,
|
|
139
|
-
|
|
142
|
+
recordSessionApproval,
|
|
140
143
|
getActiveSkillEntries: vi.fn().mockReturnValue([]),
|
|
141
144
|
getInfrastructureDirs: vi.fn().mockReturnValue([]),
|
|
142
145
|
getInfrastructureReadPaths: vi.fn().mockReturnValue([]),
|
|
@@ -93,9 +93,8 @@ describe("describeBashExternalDirectoryGate", () => {
|
|
|
93
93
|
expect(isGateDescriptor(result)).toBe(true);
|
|
94
94
|
const desc = result as GateDescriptor;
|
|
95
95
|
expect(desc.sessionApproval).toBeDefined();
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
expect(patterns.length).toBeGreaterThan(0);
|
|
96
|
+
if (!desc.sessionApproval) return;
|
|
97
|
+
expect(desc.sessionApproval.patterns.length).toBeGreaterThan(0);
|
|
99
98
|
});
|
|
100
99
|
|
|
101
100
|
it("returns GateBypass when all external paths are config-level allowed", async () => {
|
|
@@ -211,8 +210,9 @@ describe("describeBashExternalDirectoryGate", () => {
|
|
|
211
210
|
);
|
|
212
211
|
expect(isGateDescriptor(result)).toBe(true);
|
|
213
212
|
const desc = result as GateDescriptor;
|
|
214
|
-
|
|
215
|
-
|
|
213
|
+
expect(desc.sessionApproval).toBeDefined();
|
|
214
|
+
if (!desc.sessionApproval) return;
|
|
215
|
+
expect(desc.sessionApproval.patterns.length).toBe(1);
|
|
216
216
|
expect(desc.preCheck?.state).toBe("ask");
|
|
217
217
|
});
|
|
218
218
|
|
|
@@ -236,8 +236,9 @@ describe("describeBashExternalDirectoryGate", () => {
|
|
|
236
236
|
const desc = result as GateDescriptor;
|
|
237
237
|
expect(desc.preCheck?.state).toBe("deny");
|
|
238
238
|
// Both paths are uncovered (neither is allow), so both patterns are included.
|
|
239
|
-
|
|
240
|
-
|
|
239
|
+
expect(desc.sessionApproval).toBeDefined();
|
|
240
|
+
if (!desc.sessionApproval) return;
|
|
241
|
+
expect(desc.sessionApproval.patterns.length).toBe(2);
|
|
241
242
|
});
|
|
242
243
|
|
|
243
244
|
it("only includes uncovered paths when some are session-covered", async () => {
|
|
@@ -259,7 +260,8 @@ describe("describeBashExternalDirectoryGate", () => {
|
|
|
259
260
|
expect(isGateDescriptor(result)).toBe(true);
|
|
260
261
|
const desc = result as GateDescriptor;
|
|
261
262
|
// Should have patterns only for the uncovered path
|
|
262
|
-
|
|
263
|
-
|
|
263
|
+
expect(desc.sessionApproval).toBeDefined();
|
|
264
|
+
if (!desc.sessionApproval) return;
|
|
265
|
+
expect(desc.sessionApproval.patterns.length).toBe(1);
|
|
264
266
|
});
|
|
265
267
|
});
|
|
@@ -126,11 +126,8 @@ describe("describeExternalDirectoryGate", () => {
|
|
|
126
126
|
["/test/agent"],
|
|
127
127
|
) as GateDescriptor;
|
|
128
128
|
expect(result.sessionApproval).toBeDefined();
|
|
129
|
-
expect(result.sessionApproval).
|
|
130
|
-
|
|
131
|
-
"external_directory",
|
|
132
|
-
);
|
|
133
|
-
expect(result.sessionApproval).toHaveProperty("pattern");
|
|
129
|
+
expect(result.sessionApproval?.surface).toBe("external_directory");
|
|
130
|
+
expect(result.sessionApproval?.representativePattern).toBeDefined();
|
|
134
131
|
});
|
|
135
132
|
|
|
136
133
|
it("denialContext contains the external path and cwd", () => {
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
2
|
|
|
3
3
|
import {
|
|
4
|
+
buildDecisionEvent,
|
|
4
5
|
deriveDecisionValue,
|
|
5
6
|
deriveResolution,
|
|
6
7
|
} from "#src/handlers/gates/helpers";
|
|
8
|
+
import type { PermissionCheckResult } from "#src/types";
|
|
7
9
|
|
|
8
10
|
describe("deriveDecisionValue", () => {
|
|
9
11
|
it("returns command for bash", () => {
|
|
@@ -82,3 +84,82 @@ describe("deriveResolution", () => {
|
|
|
82
84
|
);
|
|
83
85
|
});
|
|
84
86
|
});
|
|
87
|
+
|
|
88
|
+
describe("buildDecisionEvent", () => {
|
|
89
|
+
function makeCheck(
|
|
90
|
+
overrides: Partial<PermissionCheckResult> = {},
|
|
91
|
+
): PermissionCheckResult {
|
|
92
|
+
return {
|
|
93
|
+
state: "allow",
|
|
94
|
+
toolName: "read",
|
|
95
|
+
source: "tool",
|
|
96
|
+
origin: "builtin",
|
|
97
|
+
matchedPattern: "*",
|
|
98
|
+
...overrides,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
it("builds a decision event with all fields populated", () => {
|
|
103
|
+
const event = buildDecisionEvent(
|
|
104
|
+
{ surface: "read", value: "read" },
|
|
105
|
+
makeCheck({ origin: "global", matchedPattern: "read" }),
|
|
106
|
+
"test-agent",
|
|
107
|
+
"allow",
|
|
108
|
+
"policy_allow",
|
|
109
|
+
);
|
|
110
|
+
expect(event).toEqual({
|
|
111
|
+
surface: "read",
|
|
112
|
+
value: "read",
|
|
113
|
+
result: "allow",
|
|
114
|
+
resolution: "policy_allow",
|
|
115
|
+
origin: "global",
|
|
116
|
+
agentName: "test-agent",
|
|
117
|
+
matchedPattern: "read",
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("normalises undefined origin to null", () => {
|
|
122
|
+
const event = buildDecisionEvent(
|
|
123
|
+
{ surface: "bash", value: "git status" },
|
|
124
|
+
makeCheck({ origin: undefined }),
|
|
125
|
+
null,
|
|
126
|
+
"allow",
|
|
127
|
+
"user_approved",
|
|
128
|
+
);
|
|
129
|
+
expect(event.origin).toBeNull();
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("normalises null agentName to null", () => {
|
|
133
|
+
const event = buildDecisionEvent(
|
|
134
|
+
{ surface: "read", value: "read" },
|
|
135
|
+
makeCheck(),
|
|
136
|
+
null,
|
|
137
|
+
"deny",
|
|
138
|
+
"policy_deny",
|
|
139
|
+
);
|
|
140
|
+
expect(event.agentName).toBeNull();
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("normalises undefined matchedPattern to null", () => {
|
|
144
|
+
const event = buildDecisionEvent(
|
|
145
|
+
{ surface: "read", value: "read" },
|
|
146
|
+
makeCheck({ matchedPattern: undefined }),
|
|
147
|
+
null,
|
|
148
|
+
"deny",
|
|
149
|
+
"policy_deny",
|
|
150
|
+
);
|
|
151
|
+
expect(event.matchedPattern).toBeNull();
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("passes result and resolution through", () => {
|
|
155
|
+
const event = buildDecisionEvent(
|
|
156
|
+
{ surface: "bash", value: "rm -rf /" },
|
|
157
|
+
makeCheck(),
|
|
158
|
+
null,
|
|
159
|
+
"deny",
|
|
160
|
+
"user_denied",
|
|
161
|
+
);
|
|
162
|
+
expect(event.result).toBe("deny");
|
|
163
|
+
expect(event.resolution).toBe("user_denied");
|
|
164
|
+
});
|
|
165
|
+
});
|
|
@@ -160,8 +160,8 @@ describe("describePathGate", () => {
|
|
|
160
160
|
getSessionRuleset,
|
|
161
161
|
) as GateDescriptor;
|
|
162
162
|
expect(result.sessionApproval).toBeDefined();
|
|
163
|
-
expect(result.sessionApproval).
|
|
164
|
-
expect(result.sessionApproval).
|
|
163
|
+
expect(result.sessionApproval?.surface).toBe("path");
|
|
164
|
+
expect(result.sessionApproval?.representativePattern).toBeDefined();
|
|
165
165
|
});
|
|
166
166
|
|
|
167
167
|
it("descriptor denialContext references the file path and tool name", () => {
|
|
@@ -7,6 +7,7 @@ import type {
|
|
|
7
7
|
GateRunnerDeps,
|
|
8
8
|
} from "#src/handlers/gates/descriptor";
|
|
9
9
|
import { runGateCheck } from "#src/handlers/gates/runner";
|
|
10
|
+
import { SessionApproval } from "#src/session-approval";
|
|
10
11
|
import type { PermissionCheckResult } from "#src/types";
|
|
11
12
|
|
|
12
13
|
// ── helpers ────────────────────────────────────────────────────────────────
|
|
@@ -61,7 +62,7 @@ function makeRunnerDeps(
|
|
|
61
62
|
return {
|
|
62
63
|
checkPermission: vi.fn().mockReturnValue(makeCheckResult("allow")),
|
|
63
64
|
getSessionRuleset: vi.fn().mockReturnValue([]),
|
|
64
|
-
|
|
65
|
+
recordSessionApproval: vi.fn(),
|
|
65
66
|
writeReviewLog: vi.fn(),
|
|
66
67
|
emitDecision: vi.fn(),
|
|
67
68
|
canConfirm: vi.fn().mockReturnValue(true),
|
|
@@ -167,7 +168,7 @@ describe("runGateCheck", () => {
|
|
|
167
168
|
.mockResolvedValue({ approved: true, state: "approved_for_session" }),
|
|
168
169
|
});
|
|
169
170
|
const descriptor = makeDescriptor({
|
|
170
|
-
sessionApproval:
|
|
171
|
+
sessionApproval: SessionApproval.single("read", "*"),
|
|
171
172
|
});
|
|
172
173
|
const result = await runGateCheck(descriptor, null, "tc-1", deps);
|
|
173
174
|
expect(result).toEqual({ action: "allow" });
|
|
@@ -176,33 +177,27 @@ describe("runGateCheck", () => {
|
|
|
176
177
|
resolution: "user_approved_for_session",
|
|
177
178
|
}),
|
|
178
179
|
);
|
|
179
|
-
expect(deps.
|
|
180
|
+
expect(deps.recordSessionApproval).toHaveBeenCalledWith(
|
|
181
|
+
SessionApproval.single("read", "*"),
|
|
182
|
+
);
|
|
180
183
|
});
|
|
181
184
|
|
|
182
|
-
it("calls
|
|
185
|
+
it("calls recordSessionApproval once with the full SessionApproval when sessionApproval has multiple patterns", async () => {
|
|
183
186
|
const deps = makeRunnerDeps({
|
|
184
187
|
checkPermission: vi.fn().mockReturnValue(makeCheckResult("ask")),
|
|
185
188
|
promptPermission: vi
|
|
186
189
|
.fn()
|
|
187
190
|
.mockResolvedValue({ approved: true, state: "approved_for_session" }),
|
|
188
191
|
});
|
|
189
|
-
const
|
|
190
|
-
sessionApproval: {
|
|
191
|
-
surface: "external_directory",
|
|
192
|
-
patterns: ["/outside/a/*", "/outside/b/*"],
|
|
193
|
-
},
|
|
194
|
-
});
|
|
195
|
-
const result = await runGateCheck(descriptor, null, "tc-1", deps);
|
|
196
|
-
expect(result).toEqual({ action: "allow" });
|
|
197
|
-
expect(deps.approveSessionRule).toHaveBeenCalledTimes(2);
|
|
198
|
-
expect(deps.approveSessionRule).toHaveBeenCalledWith(
|
|
199
|
-
"external_directory",
|
|
192
|
+
const approval = SessionApproval.multiple("external_directory", [
|
|
200
193
|
"/outside/a/*",
|
|
201
|
-
);
|
|
202
|
-
expect(deps.approveSessionRule).toHaveBeenCalledWith(
|
|
203
|
-
"external_directory",
|
|
204
194
|
"/outside/b/*",
|
|
205
|
-
);
|
|
195
|
+
]);
|
|
196
|
+
const descriptor = makeDescriptor({ sessionApproval: approval });
|
|
197
|
+
const result = await runGateCheck(descriptor, null, "tc-1", deps);
|
|
198
|
+
expect(result).toEqual({ action: "allow" });
|
|
199
|
+
expect(deps.recordSessionApproval).toHaveBeenCalledTimes(1);
|
|
200
|
+
expect(deps.recordSessionApproval).toHaveBeenCalledWith(approval);
|
|
206
201
|
});
|
|
207
202
|
|
|
208
203
|
it("returns block and emits user_denied when ask + user denies", async () => {
|
|
@@ -317,7 +312,7 @@ describe("runGateCheck", () => {
|
|
|
317
312
|
);
|
|
318
313
|
});
|
|
319
314
|
|
|
320
|
-
it("does not call
|
|
315
|
+
it("does not call recordSessionApproval when user approves once (no sessionApproval)", async () => {
|
|
321
316
|
const deps = makeRunnerDeps({
|
|
322
317
|
checkPermission: vi.fn().mockReturnValue(makeCheckResult("ask")),
|
|
323
318
|
promptPermission: vi
|
|
@@ -325,7 +320,7 @@ describe("runGateCheck", () => {
|
|
|
325
320
|
.mockResolvedValue({ approved: true, state: "approved" }),
|
|
326
321
|
});
|
|
327
322
|
await runGateCheck(makeDescriptor(), null, "tc-1", deps);
|
|
328
|
-
expect(deps.
|
|
323
|
+
expect(deps.recordSessionApproval).not.toHaveBeenCalled();
|
|
329
324
|
});
|
|
330
325
|
|
|
331
326
|
it("uses preCheck result directly instead of calling checkPermission", async () => {
|
|
@@ -348,7 +343,7 @@ describe("runGateCheck", () => {
|
|
|
348
343
|
);
|
|
349
344
|
});
|
|
350
345
|
|
|
351
|
-
it("does not call
|
|
346
|
+
it("does not call recordSessionApproval when user approves for session but no sessionApproval on descriptor", async () => {
|
|
352
347
|
const deps = makeRunnerDeps({
|
|
353
348
|
checkPermission: vi.fn().mockReturnValue(makeCheckResult("ask")),
|
|
354
349
|
promptPermission: vi
|
|
@@ -357,7 +352,7 @@ describe("runGateCheck", () => {
|
|
|
357
352
|
});
|
|
358
353
|
// No sessionApproval on descriptor
|
|
359
354
|
await runGateCheck(makeDescriptor(), null, "tc-1", deps);
|
|
360
|
-
expect(deps.
|
|
355
|
+
expect(deps.recordSessionApproval).not.toHaveBeenCalled();
|
|
361
356
|
});
|
|
362
357
|
|
|
363
358
|
describe("denialContext formatting", () => {
|
|
@@ -142,8 +142,8 @@ describe("describeToolGate", () => {
|
|
|
142
142
|
makeFormatter(),
|
|
143
143
|
);
|
|
144
144
|
expect(desc.sessionApproval).toBeDefined();
|
|
145
|
-
expect(desc.sessionApproval
|
|
146
|
-
expect(desc.sessionApproval
|
|
145
|
+
expect(desc.sessionApproval?.surface).toBe("bash");
|
|
146
|
+
expect(desc.sessionApproval?.representativePattern).toBeDefined();
|
|
147
147
|
});
|
|
148
148
|
|
|
149
149
|
it("populates promptDetails with correct fields", () => {
|
|
@@ -55,7 +55,7 @@ function makeSession(
|
|
|
55
55
|
}),
|
|
56
56
|
getToolPermission: vi.fn().mockReturnValue("allow"),
|
|
57
57
|
getSessionRuleset: vi.fn().mockReturnValue([]),
|
|
58
|
-
|
|
58
|
+
recordSessionApproval: vi.fn(),
|
|
59
59
|
canPrompt: vi.fn().mockReturnValue(true),
|
|
60
60
|
prompt: vi.fn().mockResolvedValue({ approved: true, state: "approved" }),
|
|
61
61
|
createPermissionRequestId: vi.fn().mockReturnValue("req-id"),
|
|
@@ -43,7 +43,7 @@ function makeSession(
|
|
|
43
43
|
checkPermission: vi.fn().mockReturnValue({ state: "allow" }),
|
|
44
44
|
getToolPermission: vi.fn().mockReturnValue("allow"),
|
|
45
45
|
getSessionRuleset: vi.fn().mockReturnValue([]),
|
|
46
|
-
|
|
46
|
+
recordSessionApproval: vi.fn(),
|
|
47
47
|
canPrompt: vi.fn().mockReturnValue(true),
|
|
48
48
|
prompt: vi.fn().mockResolvedValue({ approved: true, state: "approved" }),
|
|
49
49
|
createPermissionRequestId: vi.fn().mockReturnValue("req-id"),
|
|
@@ -77,7 +77,7 @@ function makeSession(
|
|
|
77
77
|
checkPermission: vi.fn().mockReturnValue(makeCheckResult("allow")),
|
|
78
78
|
getToolPermission: vi.fn().mockReturnValue("allow"),
|
|
79
79
|
getSessionRuleset: vi.fn().mockReturnValue([]),
|
|
80
|
-
|
|
80
|
+
recordSessionApproval: vi.fn(),
|
|
81
81
|
getActiveSkillEntries: vi.fn().mockReturnValue([]),
|
|
82
82
|
getInfrastructureDirs: vi
|
|
83
83
|
.fn()
|
|
@@ -68,7 +68,7 @@ function makeSession(
|
|
|
68
68
|
checkPermission: vi.fn().mockReturnValue(makePermissionResult("allow")),
|
|
69
69
|
getToolPermission: vi.fn().mockReturnValue("allow"),
|
|
70
70
|
getSessionRuleset: vi.fn().mockReturnValue([]),
|
|
71
|
-
|
|
71
|
+
recordSessionApproval: vi.fn(),
|
|
72
72
|
getActiveSkillEntries: vi.fn().mockReturnValue([]),
|
|
73
73
|
getInfrastructureDirs: vi
|
|
74
74
|
.fn()
|
|
@@ -36,6 +36,7 @@ import {
|
|
|
36
36
|
PermissionSession,
|
|
37
37
|
type PermissionSessionRuntimeDeps,
|
|
38
38
|
} from "#src/permission-session";
|
|
39
|
+
import { SessionApproval } from "#src/session-approval";
|
|
39
40
|
import type { SessionLogger } from "#src/session-logger";
|
|
40
41
|
import type { SkillPromptEntry } from "#src/skill-prompt-sanitizer";
|
|
41
42
|
|
|
@@ -219,9 +220,11 @@ describe("PermissionSession", () => {
|
|
|
219
220
|
expect(rules).toEqual([]);
|
|
220
221
|
});
|
|
221
222
|
|
|
222
|
-
it("delegates
|
|
223
|
+
it("delegates recordSessionApproval to internal SessionRules", () => {
|
|
223
224
|
const { session } = createSession();
|
|
224
|
-
session.
|
|
225
|
+
session.recordSessionApproval(
|
|
226
|
+
SessionApproval.single("bash", "/usr/bin/*"),
|
|
227
|
+
);
|
|
225
228
|
const rules = session.getSessionRuleset();
|
|
226
229
|
expect(rules).toHaveLength(1);
|
|
227
230
|
expect(rules[0]).toMatchObject({
|
|
@@ -325,7 +328,7 @@ describe("PermissionSession", () => {
|
|
|
325
328
|
describe("shutdown", () => {
|
|
326
329
|
it("clears session rules", () => {
|
|
327
330
|
const { session } = createSession();
|
|
328
|
-
session.
|
|
331
|
+
session.recordSessionApproval(SessionApproval.single("bash", "*"));
|
|
329
332
|
expect(session.getSessionRuleset()).toHaveLength(1);
|
|
330
333
|
|
|
331
334
|
session.shutdown();
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import type { MergedScopes } from "#src/scope-merge";
|
|
3
|
+
import { mergeScopesWithOrigins } from "#src/scope-merge";
|
|
4
|
+
|
|
5
|
+
describe("mergeScopesWithOrigins", () => {
|
|
6
|
+
it("returns empty result for empty scopes array", () => {
|
|
7
|
+
const result: MergedScopes = mergeScopesWithOrigins([]);
|
|
8
|
+
expect(result.mergedPermission).toEqual({});
|
|
9
|
+
expect(result.origins.size).toBe(0);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it("attributes a string surface value to the contributing scope via the '*' pattern", () => {
|
|
13
|
+
const result = mergeScopesWithOrigins([
|
|
14
|
+
["global", { permission: { bash: "allow" } }],
|
|
15
|
+
]);
|
|
16
|
+
expect(result.mergedPermission).toEqual({ bash: "allow" });
|
|
17
|
+
expect(result.origins.get("bash")?.get("*")).toBe("global");
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("attributes each pattern of an object surface value to the contributing scope", () => {
|
|
21
|
+
const result = mergeScopesWithOrigins([
|
|
22
|
+
[
|
|
23
|
+
"project",
|
|
24
|
+
{ permission: { bash: { "git *": "allow", "npm *": "deny" } } },
|
|
25
|
+
],
|
|
26
|
+
]);
|
|
27
|
+
expect(result.mergedPermission).toEqual({
|
|
28
|
+
bash: { "git *": "allow", "npm *": "deny" },
|
|
29
|
+
});
|
|
30
|
+
expect(result.origins.get("bash")?.get("git *")).toBe("project");
|
|
31
|
+
expect(result.origins.get("bash")?.get("npm *")).toBe("project");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it(
|
|
35
|
+
"shallow-merge: patterns not redefined by the higher scope keep their lower-scope origin;" +
|
|
36
|
+
" patterns the higher scope defines switch to the higher scope",
|
|
37
|
+
() => {
|
|
38
|
+
const result = mergeScopesWithOrigins([
|
|
39
|
+
[
|
|
40
|
+
"global",
|
|
41
|
+
{ permission: { bash: { "ls *": "allow", "git *": "allow" } } },
|
|
42
|
+
],
|
|
43
|
+
["project", { permission: { bash: { "git *": "deny" } } }],
|
|
44
|
+
]);
|
|
45
|
+
expect(result.mergedPermission).toEqual({
|
|
46
|
+
bash: { "ls *": "allow", "git *": "deny" },
|
|
47
|
+
});
|
|
48
|
+
// "ls *" was not touched by project — retains global attribution
|
|
49
|
+
expect(result.origins.get("bash")?.get("ls *")).toBe("global");
|
|
50
|
+
// "git *" was overridden by project — switches to project attribution
|
|
51
|
+
expect(result.origins.get("bash")?.get("git *")).toBe("project");
|
|
52
|
+
},
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
it("full replacement (string over object): higher scope re-attributes the entire surface to its own origin", () => {
|
|
56
|
+
const result = mergeScopesWithOrigins([
|
|
57
|
+
["global", { permission: { bash: { "ls *": "allow" } } }],
|
|
58
|
+
["project", { permission: { bash: "deny" } }],
|
|
59
|
+
]);
|
|
60
|
+
expect(result.mergedPermission).toEqual({ bash: "deny" });
|
|
61
|
+
// The string value produces a single "*" pattern for the replacing scope
|
|
62
|
+
expect(result.origins.get("bash")?.get("*")).toBe("project");
|
|
63
|
+
// The former "ls *" pattern from global is gone — origins are replaced, not merged
|
|
64
|
+
expect(result.origins.get("bash")?.has("ls *")).toBe(false);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("full replacement (object over string): higher scope re-attributes the entire surface to its own origin", () => {
|
|
68
|
+
const result = mergeScopesWithOrigins([
|
|
69
|
+
["global", { permission: { bash: "ask" } }],
|
|
70
|
+
["project", { permission: { bash: { "git *": "deny" } } }],
|
|
71
|
+
]);
|
|
72
|
+
expect(result.mergedPermission).toEqual({ bash: { "git *": "deny" } });
|
|
73
|
+
// The object value attributes each pattern to the replacing scope
|
|
74
|
+
expect(result.origins.get("bash")?.get("git *")).toBe("project");
|
|
75
|
+
// The former "*" attribution from global is gone
|
|
76
|
+
expect(result.origins.get("bash")?.has("*")).toBe(false);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("applies four-scope precedence in lowest→highest order (global → project → agent → project-agent)", () => {
|
|
80
|
+
const result = mergeScopesWithOrigins([
|
|
81
|
+
["global", { permission: { read: "ask" } }],
|
|
82
|
+
["project", { permission: { write: "deny" } }],
|
|
83
|
+
["agent", { permission: { bash: "deny" } }],
|
|
84
|
+
["project-agent", { permission: { mcp: "allow" } }],
|
|
85
|
+
]);
|
|
86
|
+
expect(result.mergedPermission).toEqual({
|
|
87
|
+
read: "ask",
|
|
88
|
+
write: "deny",
|
|
89
|
+
bash: "deny",
|
|
90
|
+
mcp: "allow",
|
|
91
|
+
});
|
|
92
|
+
expect(result.origins.get("read")?.get("*")).toBe("global");
|
|
93
|
+
expect(result.origins.get("write")?.get("*")).toBe("project");
|
|
94
|
+
expect(result.origins.get("bash")?.get("*")).toBe("agent");
|
|
95
|
+
expect(result.origins.get("mcp")?.get("*")).toBe("project-agent");
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("skips scopes with no permission key, contributing nothing to either map", () => {
|
|
99
|
+
const result = mergeScopesWithOrigins([
|
|
100
|
+
["global", {}],
|
|
101
|
+
["project", { permission: { bash: "allow" } }],
|
|
102
|
+
]);
|
|
103
|
+
expect(result.mergedPermission).toEqual({ bash: "allow" });
|
|
104
|
+
expect(result.origins.get("bash")?.get("*")).toBe("project");
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("attributes the universal '*' surface like any other (downstream reads origins.get('*')?.get('*') for universalFallbackOrigin)", () => {
|
|
108
|
+
const result = mergeScopesWithOrigins([
|
|
109
|
+
["global", { permission: { "*": "deny" } }],
|
|
110
|
+
["project", { permission: { "*": "allow" } }],
|
|
111
|
+
]);
|
|
112
|
+
expect(result.mergedPermission).toEqual({ "*": "allow" });
|
|
113
|
+
// Both scopes write a string — each is a full replacement; project wins last
|
|
114
|
+
expect(result.origins.get("*")?.get("*")).toBe("project");
|
|
115
|
+
});
|
|
116
|
+
});
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { SessionApproval } from "#src/session-approval";
|
|
4
|
+
|
|
5
|
+
describe("SessionApproval", () => {
|
|
6
|
+
describe("single", () => {
|
|
7
|
+
it("stores surface and one pattern", () => {
|
|
8
|
+
const approval = SessionApproval.single("bash", "git *");
|
|
9
|
+
expect(approval.surface).toBe("bash");
|
|
10
|
+
expect(approval.patterns).toEqual(["git *"]);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("representativePattern returns the pattern", () => {
|
|
14
|
+
const approval = SessionApproval.single("bash", "git *");
|
|
15
|
+
expect(approval.representativePattern).toBe("git *");
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("toGateApproval returns { surface, pattern }", () => {
|
|
19
|
+
const approval = SessionApproval.single("bash", "git *");
|
|
20
|
+
expect(approval.toGateApproval()).toEqual({
|
|
21
|
+
surface: "bash",
|
|
22
|
+
pattern: "git *",
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe("multiple", () => {
|
|
28
|
+
it("stores surface and all patterns", () => {
|
|
29
|
+
const approval = SessionApproval.multiple("external_directory", [
|
|
30
|
+
"/outside/a/*",
|
|
31
|
+
"/outside/b/*",
|
|
32
|
+
]);
|
|
33
|
+
expect(approval.surface).toBe("external_directory");
|
|
34
|
+
expect(approval.patterns).toEqual(["/outside/a/*", "/outside/b/*"]);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("representativePattern returns the first pattern", () => {
|
|
38
|
+
const approval = SessionApproval.multiple("external_directory", [
|
|
39
|
+
"/outside/a/*",
|
|
40
|
+
"/outside/b/*",
|
|
41
|
+
]);
|
|
42
|
+
expect(approval.representativePattern).toBe("/outside/a/*");
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("toGateApproval returns { surface, pattern } using the first pattern", () => {
|
|
46
|
+
const approval = SessionApproval.multiple("external_directory", [
|
|
47
|
+
"/outside/a/*",
|
|
48
|
+
"/outside/b/*",
|
|
49
|
+
]);
|
|
50
|
+
expect(approval.toGateApproval()).toEqual({
|
|
51
|
+
surface: "external_directory",
|
|
52
|
+
pattern: "/outside/a/*",
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("defensive copy — mutating the source array does not affect patterns", () => {
|
|
57
|
+
const source = ["/outside/a/*", "/outside/b/*"];
|
|
58
|
+
const approval = SessionApproval.multiple("external_directory", source);
|
|
59
|
+
source.push("/outside/c/*");
|
|
60
|
+
expect(approval.patterns).toEqual(["/outside/a/*", "/outside/b/*"]);
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
describe("empty patterns (degenerate case)", () => {
|
|
65
|
+
it("representativePattern returns undefined", () => {
|
|
66
|
+
const approval = SessionApproval.multiple("external_directory", []);
|
|
67
|
+
expect(approval.representativePattern).toBeUndefined();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("toGateApproval returns undefined", () => {
|
|
71
|
+
const approval = SessionApproval.multiple("external_directory", []);
|
|
72
|
+
expect(approval.toGateApproval()).toBeUndefined();
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
});
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
2
|
|
|
3
3
|
import { evaluate } from "#src/rule";
|
|
4
|
+
import { SessionApproval } from "#src/session-approval";
|
|
4
5
|
import { deriveApprovalPattern, SessionRules } from "#src/session-rules";
|
|
5
6
|
|
|
6
7
|
// ── SessionRules ───────────────────────────────────────────────────────────
|
|
@@ -66,6 +67,54 @@ describe("SessionRules", () => {
|
|
|
66
67
|
});
|
|
67
68
|
});
|
|
68
69
|
|
|
70
|
+
describe("record", () => {
|
|
71
|
+
it("records a single-pattern approval as one rule", () => {
|
|
72
|
+
const rules = new SessionRules();
|
|
73
|
+
rules.record(SessionApproval.single("bash", "git *"));
|
|
74
|
+
expect(rules.getRuleset()).toEqual([
|
|
75
|
+
{
|
|
76
|
+
surface: "bash",
|
|
77
|
+
pattern: "git *",
|
|
78
|
+
action: "allow",
|
|
79
|
+
layer: "session",
|
|
80
|
+
origin: "session",
|
|
81
|
+
},
|
|
82
|
+
]);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("records a multi-pattern approval as one rule per pattern", () => {
|
|
86
|
+
const rules = new SessionRules();
|
|
87
|
+
rules.record(
|
|
88
|
+
SessionApproval.multiple("external_directory", [
|
|
89
|
+
"/outside/a/*",
|
|
90
|
+
"/outside/b/*",
|
|
91
|
+
]),
|
|
92
|
+
);
|
|
93
|
+
expect(rules.getRuleset()).toHaveLength(2);
|
|
94
|
+
expect(rules.getRuleset()[0].pattern).toBe("/outside/a/*");
|
|
95
|
+
expect(rules.getRuleset()[1].pattern).toBe("/outside/b/*");
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("records each rule with the correct surface", () => {
|
|
99
|
+
const rules = new SessionRules();
|
|
100
|
+
rules.record(
|
|
101
|
+
SessionApproval.multiple("external_directory", [
|
|
102
|
+
"/outside/a/*",
|
|
103
|
+
"/outside/b/*",
|
|
104
|
+
]),
|
|
105
|
+
);
|
|
106
|
+
for (const rule of rules.getRuleset()) {
|
|
107
|
+
expect(rule.surface).toBe("external_directory");
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("records nothing for an empty patterns list", () => {
|
|
112
|
+
const rules = new SessionRules();
|
|
113
|
+
rules.record(SessionApproval.multiple("external_directory", []));
|
|
114
|
+
expect(rules.getRuleset()).toEqual([]);
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
69
118
|
describe("evaluate() integration", () => {
|
|
70
119
|
it("returns allow for a path under an approved directory", () => {
|
|
71
120
|
const session = new SessionRules();
|