@gotgenes/pi-permission-system 8.1.0 → 8.2.1
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 +15 -0
- package/package.json +1 -1
- package/src/config-loader.ts +53 -46
- package/src/handlers/gates/bash-external-directory.ts +2 -4
- package/src/handlers/gates/bash-path-extractor.ts +135 -169
- package/src/handlers/gates/bash-path.ts +2 -4
- package/src/handlers/gates/bash-token-classification.ts +105 -0
- 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/config-loader.test.ts +82 -0
- package/test/handlers/before-agent-start.test.ts +2 -20
- package/test/handlers/external-directory-integration.test.ts +44 -82
- package/test/handlers/external-directory-session-dedup.test.ts +17 -41
- package/test/handlers/gates/bash-external-directory.test.ts +11 -9
- package/test/handlers/gates/bash-path.test.ts +5 -26
- package/test/handlers/gates/bash-token-classification.test.ts +241 -0
- 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 +5 -14
- package/test/handlers/gates/runner.test.ts +95 -113
- package/test/handlers/gates/tool.test.ts +2 -2
- package/test/handlers/input-events.test.ts +42 -95
- package/test/handlers/input.test.ts +3 -71
- package/test/handlers/lifecycle.test.ts +3 -20
- package/test/handlers/tool-call-events.test.ts +30 -127
- package/test/handlers/tool-call.test.ts +21 -110
- package/test/helpers/gate-fixtures.ts +105 -0
- package/test/helpers/handler-fixtures.ts +141 -0
- package/test/helpers/manager-harness.ts +51 -0
- package/test/permission-session.test.ts +7 -22
- package/test/permission-system.test.ts +4 -40
- package/test/scope-merge.test.ts +116 -0
- package/test/session-approval.test.ts +75 -0
- package/test/session-rules.test.ts +49 -0
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure, synchronous token-classification helpers for bash path extraction.
|
|
3
|
+
*
|
|
4
|
+
* Exports two classifiers consumed by `bash-path-extractor.ts`:
|
|
5
|
+
* - `classifyTokenAsPathCandidate` — strict gate for the external-directory guard.
|
|
6
|
+
* - `classifyTokenAsRuleCandidate` — broader gate for cross-cutting `path` rules.
|
|
7
|
+
*
|
|
8
|
+
* Both classifiers share the private `rejectNonPathToken` predicate that captures
|
|
9
|
+
* the seven rejection cases common to both (the production clone this module was
|
|
10
|
+
* extracted to eliminate).
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
// ── Public classifiers ─────────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Strict path-candidate classifier for the external-directory guard.
|
|
17
|
+
*
|
|
18
|
+
* Accepts tokens that unambiguously look like filesystem paths:
|
|
19
|
+
* - Absolute paths (starting with `/`)
|
|
20
|
+
* - Home-relative paths (starting with `~/`)
|
|
21
|
+
* - Parent-traversal paths (containing `..`)
|
|
22
|
+
*
|
|
23
|
+
* Returns the raw token string if it qualifies, or `null` to skip.
|
|
24
|
+
*/
|
|
25
|
+
export function classifyTokenAsPathCandidate(token: string): string | null {
|
|
26
|
+
if (rejectNonPathToken(token)) return null;
|
|
27
|
+
|
|
28
|
+
if (token.startsWith("/")) return token;
|
|
29
|
+
if (token.startsWith("~/")) return token;
|
|
30
|
+
if (token.includes("..")) return token;
|
|
31
|
+
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Broader token classifier for cross-cutting `path` permission rules.
|
|
37
|
+
*
|
|
38
|
+
* Accepts the same shapes as `classifyTokenAsPathCandidate`, plus:
|
|
39
|
+
* - Dot-files and `./`-relative paths (starting with `.`)
|
|
40
|
+
* - Any relative path containing `/` (e.g. `src/foo.ts`)
|
|
41
|
+
*
|
|
42
|
+
* The `~/foo` case is covered by `includes("/")` — no separate `~/` branch needed.
|
|
43
|
+
*
|
|
44
|
+
* Does NOT require the strict "must start with `/` or `~/` or contain `..`"
|
|
45
|
+
* gate that the external-directory classifier uses.
|
|
46
|
+
*
|
|
47
|
+
* Returns the raw token string if it qualifies, or `null` to skip.
|
|
48
|
+
*/
|
|
49
|
+
export function classifyTokenAsRuleCandidate(token: string): string | null {
|
|
50
|
+
if (rejectNonPathToken(token)) return null;
|
|
51
|
+
|
|
52
|
+
if (token.startsWith(".")) return token;
|
|
53
|
+
if (token.includes("/")) return token; // covers ~/ paths and all relative paths with /
|
|
54
|
+
if (token.includes("..")) return token; // bare ".." (no slash)
|
|
55
|
+
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ── Private rejection predicate ────────────────────────────────────────────
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* URL pattern to skip tokens that look like URLs rather than paths.
|
|
63
|
+
*/
|
|
64
|
+
const URL_PATTERN = /^[a-z][a-z0-9+.-]*:\/\//i;
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Regex metacharacter sequences that are never found in real filesystem paths.
|
|
68
|
+
* If a token contains any of these, it is almost certainly a regex pattern
|
|
69
|
+
* (e.g. a grep argument) rather than a path.
|
|
70
|
+
*/
|
|
71
|
+
const REGEX_METACHAR_PATTERN = /\.\*|\.\+|\\\||\\\(|\\\)|\[.*?\]|\^\//;
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Shared rejection prelude: returns `true` when a token can never be a
|
|
75
|
+
* filesystem path, regardless of which classifier is asking.
|
|
76
|
+
*
|
|
77
|
+
* Rejects: empty tokens, flags (leading `-`), env assignments (`FOO=/bar`),
|
|
78
|
+
* URLs, `@scope/package` patterns, bare-slash tokens, and regex metacharacter
|
|
79
|
+
* sequences.
|
|
80
|
+
*/
|
|
81
|
+
function rejectNonPathToken(token: string): boolean {
|
|
82
|
+
if (!token) return true;
|
|
83
|
+
if (token.startsWith("-")) return true;
|
|
84
|
+
|
|
85
|
+
// Env assignment: = appears before any / (FOO=/bar is an assignment,
|
|
86
|
+
// /foo=bar is not because the slash comes first).
|
|
87
|
+
const eqIndex = token.indexOf("=");
|
|
88
|
+
const slashIndex = token.indexOf("/");
|
|
89
|
+
if (eqIndex !== -1 && (slashIndex === -1 || eqIndex < slashIndex))
|
|
90
|
+
return true;
|
|
91
|
+
|
|
92
|
+
if (URL_PATTERN.test(token)) return true;
|
|
93
|
+
|
|
94
|
+
// @scope/package patterns (npm scoped packages) — but @/ is allowed through
|
|
95
|
+
// since it looks like an absolute-rooted path, not an npm scope.
|
|
96
|
+
if (token.startsWith("@") && !token.startsWith("@/")) return true;
|
|
97
|
+
|
|
98
|
+
// Bare-slash tokens (/, //, ///) resolve to filesystem root and are never
|
|
99
|
+
// meaningful path arguments in practice.
|
|
100
|
+
if (/^\/+$/.test(token)) return true;
|
|
101
|
+
|
|
102
|
+
if (REGEX_METACHAR_PATTERN.test(token)) return true;
|
|
103
|
+
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
@@ -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 = [];
|