@komarspn/pi-permission-system 16.0.2
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 +2234 -0
- package/LICENSE +21 -0
- package/README.md +158 -0
- package/config/config.example.json +39 -0
- package/package.json +82 -0
- package/schemas/permissions.schema.json +158 -0
- package/src/active-agent.ts +72 -0
- package/src/async-cache.ts +21 -0
- package/src/bash-arity.ts +210 -0
- package/src/builtin-tool-input-formatters.ts +82 -0
- package/src/canonicalize-path.ts +30 -0
- package/src/common.ts +121 -0
- package/src/config-loader.ts +432 -0
- package/src/config-modal.ts +259 -0
- package/src/config-paths.ts +47 -0
- package/src/config-reporter.ts +34 -0
- package/src/config-store.ts +222 -0
- package/src/decision-audit.ts +75 -0
- package/src/decision-reporter.ts +41 -0
- package/src/denial-messages.ts +232 -0
- package/src/expand-home.ts +28 -0
- package/src/extension-config.ts +79 -0
- package/src/extension-paths.ts +66 -0
- package/src/forwarded-permissions/io.ts +404 -0
- package/src/forwarded-permissions/permission-forwarder.ts +580 -0
- package/src/forwarding-manager.ts +74 -0
- package/src/gate-prompter.ts +12 -0
- package/src/handlers/before-agent-start.ts +94 -0
- package/src/handlers/gates/bash-command.ts +75 -0
- package/src/handlers/gates/bash-external-directory.ts +127 -0
- package/src/handlers/gates/bash-path-extractor.ts +15 -0
- package/src/handlers/gates/bash-path.ts +152 -0
- package/src/handlers/gates/bash-program.ts +1143 -0
- package/src/handlers/gates/bash-token-classification.ts +105 -0
- package/src/handlers/gates/candidate-check.ts +32 -0
- package/src/handlers/gates/descriptor.ts +81 -0
- package/src/handlers/gates/external-directory-messages.ts +20 -0
- package/src/handlers/gates/external-directory.ts +133 -0
- package/src/handlers/gates/helpers.ts +76 -0
- package/src/handlers/gates/path.ts +91 -0
- package/src/handlers/gates/runner.ts +186 -0
- package/src/handlers/gates/skill-input-gate-pipeline.ts +104 -0
- package/src/handlers/gates/skill-input.ts +46 -0
- package/src/handlers/gates/skill-read.ts +87 -0
- package/src/handlers/gates/tool-call-gate-pipeline.ts +129 -0
- package/src/handlers/gates/tool.ts +102 -0
- package/src/handlers/gates/types.ts +13 -0
- package/src/handlers/index.ts +3 -0
- package/src/handlers/lifecycle.ts +95 -0
- package/src/handlers/permission-gate-handler.ts +190 -0
- package/src/handlers/tool-call-boundary.ts +91 -0
- package/src/index.ts +225 -0
- package/src/input-normalizer.ts +157 -0
- package/src/logging.ts +113 -0
- package/src/mcp-targets.ts +170 -0
- package/src/node-modules-discovery.ts +76 -0
- package/src/normalize.ts +43 -0
- package/src/path-utils.ts +355 -0
- package/src/pattern-suggest.ts +132 -0
- package/src/permission-dialog.ts +138 -0
- package/src/permission-event-rpc.ts +223 -0
- package/src/permission-events.ts +266 -0
- package/src/permission-forwarding.ts +188 -0
- package/src/permission-gate.ts +94 -0
- package/src/permission-manager.ts +392 -0
- package/src/permission-merge.ts +32 -0
- package/src/permission-prompter.ts +142 -0
- package/src/permission-prompts.ts +93 -0
- package/src/permission-resolver.ts +109 -0
- package/src/permission-session.ts +189 -0
- package/src/permission-ui-prompt.ts +127 -0
- package/src/permissions-service.ts +63 -0
- package/src/persistent-approval-recorder.ts +139 -0
- package/src/policy-loader.ts +350 -0
- package/src/prompting-gateway.ts +104 -0
- package/src/rule.ts +188 -0
- package/src/scope-merge.ts +72 -0
- package/src/service-lifecycle.ts +49 -0
- package/src/service.ts +163 -0
- package/src/session-approval-recorder.ts +6 -0
- package/src/session-approval.ts +43 -0
- package/src/session-logger.ts +91 -0
- package/src/session-rules.ts +79 -0
- package/src/skill-prompt-sanitizer.ts +292 -0
- package/src/status.ts +35 -0
- package/src/subagent-context.ts +104 -0
- package/src/subagent-lifecycle-events.ts +72 -0
- package/src/subagent-registry.ts +105 -0
- package/src/synthesize.ts +92 -0
- package/src/system-prompt-sanitizer.ts +274 -0
- package/src/tool-access-extractor-registry.ts +68 -0
- package/src/tool-input-formatter-registry.ts +67 -0
- package/src/tool-input-preview.ts +34 -0
- package/src/tool-input-prompt-formatters.ts +63 -0
- package/src/tool-preview-formatter.ts +207 -0
- package/src/tool-registry.ts +148 -0
- package/src/types.ts +64 -0
- package/src/wildcard-matcher.ts +120 -0
- package/src/yolo-mode.ts +30 -0
- package/test/active-agent.test.ts +155 -0
- package/test/async-cache.test.ts +48 -0
- package/test/bash-arity.test.ts +144 -0
- package/test/bash-external-directory.test.ts +956 -0
- package/test/builtin-tool-input-formatters.test.ts +109 -0
- package/test/canonicalize-path.test.ts +93 -0
- package/test/common.test.ts +287 -0
- package/test/composition-root.test.ts +603 -0
- package/test/config-loader.test.ts +740 -0
- package/test/config-modal.test.ts +320 -0
- package/test/config-paths.test.ts +83 -0
- package/test/config-pipeline.test.ts +90 -0
- package/test/config-reporter.test.ts +147 -0
- package/test/config-store.test.ts +466 -0
- package/test/decision-audit.test.ts +72 -0
- package/test/decision-reporter.test.ts +112 -0
- package/test/denial-messages.test.ts +656 -0
- package/test/detect-permissive-bash-fallback.test.ts +56 -0
- package/test/expand-home.test.ts +93 -0
- package/test/extension-config.test.ts +129 -0
- package/test/extension-paths.test.ts +108 -0
- package/test/forwarded-permissions/io.test.ts +251 -0
- package/test/forwarding-manager.test.ts +194 -0
- package/test/handlers/before-agent-start.test.ts +317 -0
- package/test/handlers/external-directory-integration.test.ts +623 -0
- package/test/handlers/external-directory-session-dedup.test.ts +430 -0
- package/test/handlers/external-directory-symlink-acceptance.test.ts +149 -0
- package/test/handlers/gates/bash-command-metamorphic.test.ts +83 -0
- package/test/handlers/gates/bash-command.test.ts +191 -0
- package/test/handlers/gates/bash-external-directory.test.ts +269 -0
- package/test/handlers/gates/bash-path.test.ts +337 -0
- package/test/handlers/gates/bash-program.test.ts +410 -0
- package/test/handlers/gates/bash-token-classification.test.ts +241 -0
- package/test/handlers/gates/candidate-check.test.ts +52 -0
- package/test/handlers/gates/external-directory-messages.test.ts +61 -0
- package/test/handlers/gates/external-directory.test.ts +259 -0
- package/test/handlers/gates/helpers.test.ts +177 -0
- package/test/handlers/gates/path.test.ts +294 -0
- package/test/handlers/gates/runner.test.ts +447 -0
- package/test/handlers/gates/skill-input-gate-pipeline.test.ts +176 -0
- package/test/handlers/gates/skill-input.test.ts +131 -0
- package/test/handlers/gates/skill-read.test.ts +158 -0
- package/test/handlers/gates/tool-call-gate-pipeline.test.ts +252 -0
- package/test/handlers/gates/tool.test.ts +223 -0
- package/test/handlers/input-events.test.ts +168 -0
- package/test/handlers/input.test.ts +199 -0
- package/test/handlers/lifecycle.test.ts +221 -0
- package/test/handlers/tool-call-boundary.test.ts +145 -0
- package/test/handlers/tool-call-events.test.ts +277 -0
- package/test/handlers/tool-call.test.ts +395 -0
- package/test/handlers/validate-requested-tool.test.ts +92 -0
- package/test/helpers/gate-fixtures.ts +323 -0
- package/test/helpers/handler-fixtures.ts +335 -0
- package/test/helpers/make-fake-pi.ts +100 -0
- package/test/helpers/manager-harness.ts +112 -0
- package/test/helpers/session-fixtures.ts +204 -0
- package/test/input-normalizer.test.ts +367 -0
- package/test/logging.test.ts +51 -0
- package/test/mcp-targets.test.ts +233 -0
- package/test/node-modules-discovery.test.ts +97 -0
- package/test/normalize.test.ts +247 -0
- package/test/path-utils.test.ts +650 -0
- package/test/pattern-suggest.test.ts +248 -0
- package/test/permission-dialog.test.ts +241 -0
- package/test/permission-event-rpc.test.ts +541 -0
- package/test/permission-events.test.ts +402 -0
- package/test/permission-forwarder.test.ts +369 -0
- package/test/permission-forwarding.test.ts +315 -0
- package/test/permission-gate.test.ts +305 -0
- package/test/permission-manager-unified.test.ts +3368 -0
- package/test/permission-merge.test.ts +61 -0
- package/test/permission-prompter.test.ts +518 -0
- package/test/permission-prompts.test.ts +363 -0
- package/test/permission-resolver.test.ts +265 -0
- package/test/permission-session.test.ts +363 -0
- package/test/permission-ui-prompt.test.ts +146 -0
- package/test/permissions-service.test.ts +177 -0
- package/test/persistent-approval-recorder.test.ts +133 -0
- package/test/pi-infrastructure-read.test.ts +369 -0
- package/test/policy-loader.test.ts +561 -0
- package/test/prompting-gateway.test.ts +230 -0
- package/test/rule.test.ts +604 -0
- package/test/scope-merge.test.ts +116 -0
- package/test/service-lifecycle.test.ts +163 -0
- package/test/service.test.ts +308 -0
- package/test/session-approval.test.ts +75 -0
- package/test/session-logger.test.ts +200 -0
- package/test/session-rules.test.ts +304 -0
- package/test/session-start.test.ts +112 -0
- package/test/skill-prompt-sanitizer.test.ts +374 -0
- package/test/status.test.ts +10 -0
- package/test/subagent-context.test.ts +326 -0
- package/test/subagent-lifecycle-events.test.ts +132 -0
- package/test/subagent-registry.test.ts +145 -0
- package/test/synthesize.test.ts +300 -0
- package/test/system-prompt-sanitizer.test.ts +382 -0
- package/test/tool-access-extractor-registry.test.ts +77 -0
- package/test/tool-input-formatter-registry.test.ts +75 -0
- package/test/tool-input-preview.test.ts +129 -0
- package/test/tool-input-prompt-formatters.test.ts +115 -0
- package/test/tool-preview-formatter.test.ts +458 -0
- package/test/tool-registry.test.ts +197 -0
- package/test/wildcard-matcher.test.ts +424 -0
- package/test/yolo-mode.test.ts +188 -0
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import {
|
|
2
|
+
existsSync,
|
|
3
|
+
mkdirSync,
|
|
4
|
+
readFileSync,
|
|
5
|
+
renameSync,
|
|
6
|
+
unlinkSync,
|
|
7
|
+
writeFileSync,
|
|
8
|
+
} from "node:fs";
|
|
9
|
+
import { dirname } from "node:path";
|
|
10
|
+
import { stripJsonComments } from "./config-loader";
|
|
11
|
+
import { getGlobalConfigPath, getProjectConfigPath } from "./config-paths";
|
|
12
|
+
import type { SessionApproval } from "./session-approval";
|
|
13
|
+
import type { SessionLogger } from "./session-logger";
|
|
14
|
+
|
|
15
|
+
type PersistentApprovalScope = "project" | "global";
|
|
16
|
+
|
|
17
|
+
export interface PersistentApprovalRecorderDeps {
|
|
18
|
+
agentDir: string;
|
|
19
|
+
getCwd: () => string | undefined | null;
|
|
20
|
+
logger: SessionLogger;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Records user-approved allow rules into project/global permission config. */
|
|
24
|
+
export class PersistentApprovalRecorder {
|
|
25
|
+
constructor(private readonly deps: PersistentApprovalRecorderDeps) {}
|
|
26
|
+
|
|
27
|
+
recordApproval(scope: PersistentApprovalScope, approval: SessionApproval): void {
|
|
28
|
+
const configPath = this.getConfigPath(scope);
|
|
29
|
+
if (!configPath) {
|
|
30
|
+
this.deps.logger.warn(
|
|
31
|
+
"Cannot persist project permission approval because current project directory is unknown.",
|
|
32
|
+
);
|
|
33
|
+
this.deps.logger.review("permission_request.persistent_approval_failed", {
|
|
34
|
+
scope,
|
|
35
|
+
surface: approval.surface,
|
|
36
|
+
patterns: approval.patterns,
|
|
37
|
+
reason: "missing_project_directory",
|
|
38
|
+
});
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
try {
|
|
43
|
+
const config = readConfigObject(configPath);
|
|
44
|
+
addAllowPatterns(config, approval.surface, approval.patterns);
|
|
45
|
+
writeConfigAtomic(configPath, config);
|
|
46
|
+
this.deps.logger.review("permission_request.persistent_approval_recorded", {
|
|
47
|
+
scope,
|
|
48
|
+
configPath,
|
|
49
|
+
surface: approval.surface,
|
|
50
|
+
patterns: approval.patterns,
|
|
51
|
+
});
|
|
52
|
+
} catch (error) {
|
|
53
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
54
|
+
this.deps.logger.warn(
|
|
55
|
+
`Failed to persist ${scope} permission approval at '${configPath}': ${message}`,
|
|
56
|
+
);
|
|
57
|
+
this.deps.logger.review("permission_request.persistent_approval_failed", {
|
|
58
|
+
scope,
|
|
59
|
+
configPath,
|
|
60
|
+
surface: approval.surface,
|
|
61
|
+
patterns: approval.patterns,
|
|
62
|
+
reason: message,
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
private getConfigPath(scope: PersistentApprovalScope): string | null {
|
|
68
|
+
if (scope === "global") {
|
|
69
|
+
return getGlobalConfigPath(this.deps.agentDir);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const cwd = this.deps.getCwd();
|
|
73
|
+
return typeof cwd === "string" && cwd.trim().length > 0
|
|
74
|
+
? getProjectConfigPath(cwd)
|
|
75
|
+
: null;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function readConfigObject(path: string): Record<string, unknown> {
|
|
80
|
+
if (!existsSync(path)) {
|
|
81
|
+
return {};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const raw = readFileSync(path, "utf-8");
|
|
85
|
+
const parsed = JSON.parse(stripJsonComments(raw)) as unknown;
|
|
86
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
87
|
+
return {};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return parsed as Record<string, unknown>;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function addAllowPatterns(
|
|
94
|
+
config: Record<string, unknown>,
|
|
95
|
+
surface: string,
|
|
96
|
+
patterns: readonly string[],
|
|
97
|
+
): void {
|
|
98
|
+
const permission =
|
|
99
|
+
config.permission &&
|
|
100
|
+
typeof config.permission === "object" &&
|
|
101
|
+
!Array.isArray(config.permission)
|
|
102
|
+
? (config.permission as Record<string, unknown>)
|
|
103
|
+
: {};
|
|
104
|
+
|
|
105
|
+
const currentSurface = permission[surface];
|
|
106
|
+
const nextSurface: Record<string, unknown> =
|
|
107
|
+
currentSurface &&
|
|
108
|
+
typeof currentSurface === "object" &&
|
|
109
|
+
!Array.isArray(currentSurface)
|
|
110
|
+
? { ...(currentSurface as Record<string, unknown>) }
|
|
111
|
+
: typeof currentSurface === "string"
|
|
112
|
+
? { "*": currentSurface }
|
|
113
|
+
: {};
|
|
114
|
+
|
|
115
|
+
for (const pattern of patterns) {
|
|
116
|
+
nextSurface[pattern] = "allow";
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
permission[surface] = nextSurface;
|
|
120
|
+
config.permission = permission;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function writeConfigAtomic(path: string, config: Record<string, unknown>): void {
|
|
124
|
+
const tmpPath = `${path}.tmp`;
|
|
125
|
+
try {
|
|
126
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
127
|
+
writeFileSync(tmpPath, `${JSON.stringify(config, null, 2)}\n`, "utf-8");
|
|
128
|
+
renameSync(tmpPath, path);
|
|
129
|
+
} catch (error) {
|
|
130
|
+
try {
|
|
131
|
+
if (existsSync(tmpPath)) {
|
|
132
|
+
unlinkSync(tmpPath);
|
|
133
|
+
}
|
|
134
|
+
} catch {
|
|
135
|
+
// Ignore cleanup failures.
|
|
136
|
+
}
|
|
137
|
+
throw error;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
|
|
5
|
+
import { extractFrontmatter, parseSimpleYamlMap, toRecord } from "./common";
|
|
6
|
+
import {
|
|
7
|
+
loadUnifiedConfig,
|
|
8
|
+
normalizeUnifiedConfig,
|
|
9
|
+
stripJsonComments,
|
|
10
|
+
} from "./config-loader";
|
|
11
|
+
import { getGlobalConfigPath } from "./config-paths";
|
|
12
|
+
import type { ScopeConfig } from "./types";
|
|
13
|
+
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
// File-stamp helper
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
|
|
18
|
+
function getFileStamp(path: string): string {
|
|
19
|
+
try {
|
|
20
|
+
return String(statSync(path).mtimeMs);
|
|
21
|
+
} catch {
|
|
22
|
+
return "missing";
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
// MCP server-name reading helpers
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
function readConfiguredMcpServerNamesFromConfigPath(
|
|
31
|
+
configPath: string,
|
|
32
|
+
): string[] {
|
|
33
|
+
try {
|
|
34
|
+
const raw = readFileSync(configPath, "utf-8");
|
|
35
|
+
const parsed = JSON.parse(stripJsonComments(raw)) as unknown;
|
|
36
|
+
const root = toRecord(parsed);
|
|
37
|
+
const serverRecord = toRecord(root.mcpServers ?? root["mcp-servers"]);
|
|
38
|
+
|
|
39
|
+
return Object.keys(serverRecord)
|
|
40
|
+
.map((name) => name.trim())
|
|
41
|
+
.filter((name) => name.length > 0);
|
|
42
|
+
} catch {
|
|
43
|
+
return [];
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function getConfiguredMcpServerNamesFromPaths(
|
|
48
|
+
paths: readonly string[],
|
|
49
|
+
): string[] {
|
|
50
|
+
const seen = new Set<string>();
|
|
51
|
+
|
|
52
|
+
for (const path of paths) {
|
|
53
|
+
for (const name of readConfiguredMcpServerNamesFromConfigPath(path)) {
|
|
54
|
+
seen.add(name);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return [...seen].sort(
|
|
59
|
+
(left, right) => right.length - left.length || left.localeCompare(right),
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
// Resolved policy paths
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
export interface ResolvedPolicyPaths {
|
|
68
|
+
globalConfigPath: string;
|
|
69
|
+
globalConfigExists: boolean;
|
|
70
|
+
projectConfigPath: string | null;
|
|
71
|
+
projectConfigExists: boolean;
|
|
72
|
+
agentsDir: string;
|
|
73
|
+
agentsDirExists: boolean;
|
|
74
|
+
projectAgentsDir: string | null;
|
|
75
|
+
projectAgentsDirExists: boolean;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ---------------------------------------------------------------------------
|
|
79
|
+
// PolicyLoader interface
|
|
80
|
+
// ---------------------------------------------------------------------------
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Abstraction over file I/O for loading permission policy from disk.
|
|
84
|
+
* Implementations handle caching, path resolution, and config-issue
|
|
85
|
+
* accumulation. `PermissionManager` depends on this interface so that
|
|
86
|
+
* merge + evaluation logic can be tested with an in-memory stub.
|
|
87
|
+
*/
|
|
88
|
+
export interface PolicyLoader {
|
|
89
|
+
loadGlobalConfig(): ScopeConfig;
|
|
90
|
+
loadProjectConfig(): ScopeConfig;
|
|
91
|
+
loadAgentConfig(agentName?: string): ScopeConfig;
|
|
92
|
+
loadProjectAgentConfig(agentName?: string): ScopeConfig;
|
|
93
|
+
getConfiguredMcpServerNames(): readonly string[];
|
|
94
|
+
/** Combined mtime stamp for cache invalidation. */
|
|
95
|
+
getCacheStamp(agentName?: string): string;
|
|
96
|
+
/** Accumulated config-parse issues across all loads. */
|
|
97
|
+
getConfigIssues(): string[];
|
|
98
|
+
/** Resolved paths for the /permission-system show command. */
|
|
99
|
+
getResolvedPolicyPaths(): ResolvedPolicyPaths;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ---------------------------------------------------------------------------
|
|
103
|
+
// Default path factories (deferred until call-time, not module scope)
|
|
104
|
+
// ---------------------------------------------------------------------------
|
|
105
|
+
|
|
106
|
+
function defaultGlobalConfigPath(): string {
|
|
107
|
+
return getGlobalConfigPath(getAgentDir());
|
|
108
|
+
}
|
|
109
|
+
function defaultAgentsDir(): string {
|
|
110
|
+
return join(getAgentDir(), "agents");
|
|
111
|
+
}
|
|
112
|
+
function defaultGlobalMcpConfigPath(): string {
|
|
113
|
+
return join(getAgentDir(), "mcp.json");
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ---------------------------------------------------------------------------
|
|
117
|
+
// File cache helper type
|
|
118
|
+
// ---------------------------------------------------------------------------
|
|
119
|
+
|
|
120
|
+
type FileCacheEntry<TValue> = {
|
|
121
|
+
stamp: string;
|
|
122
|
+
value: TValue;
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
// ---------------------------------------------------------------------------
|
|
126
|
+
// Options shared between FilePolicyLoader and the backward-compat
|
|
127
|
+
// PermissionManager constructor.
|
|
128
|
+
// ---------------------------------------------------------------------------
|
|
129
|
+
|
|
130
|
+
export interface PolicyLoaderOptions {
|
|
131
|
+
globalConfigPath?: string;
|
|
132
|
+
agentsDir?: string;
|
|
133
|
+
projectGlobalConfigPath?: string;
|
|
134
|
+
projectAgentsDir?: string;
|
|
135
|
+
globalMcpConfigPath?: string;
|
|
136
|
+
mcpServerNames?: readonly string[];
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ---------------------------------------------------------------------------
|
|
140
|
+
// FilePolicyLoader — the production implementation
|
|
141
|
+
// ---------------------------------------------------------------------------
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Production `PolicyLoader` that reads config files from disk with
|
|
145
|
+
* mtime-based caching.
|
|
146
|
+
*/
|
|
147
|
+
export class FilePolicyLoader implements PolicyLoader {
|
|
148
|
+
private readonly globalConfigPath: string;
|
|
149
|
+
private readonly agentsDir: string;
|
|
150
|
+
private readonly projectGlobalConfigPath: string | null;
|
|
151
|
+
private readonly projectAgentsDir: string | null;
|
|
152
|
+
private readonly globalMcpConfigPath: string;
|
|
153
|
+
private readonly configuredMcpServerNamesOverride: readonly string[] | null;
|
|
154
|
+
|
|
155
|
+
private globalConfigCache: FileCacheEntry<ScopeConfig> | null = null;
|
|
156
|
+
private projectGlobalConfigCache: FileCacheEntry<ScopeConfig> | null = null;
|
|
157
|
+
private readonly agentConfigCache = new Map<
|
|
158
|
+
string,
|
|
159
|
+
FileCacheEntry<ScopeConfig>
|
|
160
|
+
>();
|
|
161
|
+
private readonly projectAgentConfigCache = new Map<
|
|
162
|
+
string,
|
|
163
|
+
FileCacheEntry<ScopeConfig>
|
|
164
|
+
>();
|
|
165
|
+
private configuredMcpServerNamesCache: FileCacheEntry<
|
|
166
|
+
readonly string[]
|
|
167
|
+
> | null = null;
|
|
168
|
+
private accumulatedConfigIssues: string[] = [];
|
|
169
|
+
|
|
170
|
+
constructor(options: PolicyLoaderOptions = {}) {
|
|
171
|
+
this.globalConfigPath =
|
|
172
|
+
options.globalConfigPath ?? defaultGlobalConfigPath();
|
|
173
|
+
this.agentsDir = options.agentsDir ?? defaultAgentsDir();
|
|
174
|
+
this.projectGlobalConfigPath = options.projectGlobalConfigPath ?? null;
|
|
175
|
+
this.projectAgentsDir = options.projectAgentsDir ?? null;
|
|
176
|
+
this.globalMcpConfigPath =
|
|
177
|
+
options.globalMcpConfigPath ?? defaultGlobalMcpConfigPath();
|
|
178
|
+
this.configuredMcpServerNamesOverride = options.mcpServerNames
|
|
179
|
+
? [
|
|
180
|
+
...new Set(
|
|
181
|
+
options.mcpServerNames
|
|
182
|
+
.map((name) => name.trim())
|
|
183
|
+
.filter((name) => name.length > 0),
|
|
184
|
+
),
|
|
185
|
+
]
|
|
186
|
+
: null;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// ── Config issue accumulation ────────────────────────────────────────
|
|
190
|
+
|
|
191
|
+
private accumulateConfigIssues(issues: string[]): void {
|
|
192
|
+
for (const issue of issues) {
|
|
193
|
+
if (!this.accumulatedConfigIssues.includes(issue)) {
|
|
194
|
+
this.accumulatedConfigIssues.push(issue);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
getConfigIssues(): string[] {
|
|
200
|
+
return [...this.accumulatedConfigIssues];
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// ── Scope loaders ────────────────────────────────────────────────────
|
|
204
|
+
|
|
205
|
+
loadGlobalConfig(): ScopeConfig {
|
|
206
|
+
const stamp = getFileStamp(this.globalConfigPath);
|
|
207
|
+
if (this.globalConfigCache?.stamp === stamp) {
|
|
208
|
+
return this.globalConfigCache.value;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const { config, issues } = loadUnifiedConfig(this.globalConfigPath);
|
|
212
|
+
this.accumulateConfigIssues(issues);
|
|
213
|
+
|
|
214
|
+
const value: ScopeConfig = {
|
|
215
|
+
permission: config.permission,
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
this.globalConfigCache = { stamp, value };
|
|
219
|
+
return value;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
loadProjectConfig(): ScopeConfig {
|
|
223
|
+
if (!this.projectGlobalConfigPath) {
|
|
224
|
+
return {};
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const stamp = getFileStamp(this.projectGlobalConfigPath);
|
|
228
|
+
if (this.projectGlobalConfigCache?.stamp === stamp) {
|
|
229
|
+
return this.projectGlobalConfigCache.value;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const { config, issues } = loadUnifiedConfig(this.projectGlobalConfigPath);
|
|
233
|
+
this.accumulateConfigIssues(issues);
|
|
234
|
+
|
|
235
|
+
const value: ScopeConfig = {
|
|
236
|
+
permission: config.permission,
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
this.projectGlobalConfigCache = { stamp, value };
|
|
240
|
+
return value;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
private loadScopeConfigFrom(
|
|
244
|
+
dir: string | null,
|
|
245
|
+
cache: Map<string, FileCacheEntry<ScopeConfig>>,
|
|
246
|
+
agentName?: string,
|
|
247
|
+
): ScopeConfig {
|
|
248
|
+
if (!dir || !agentName) {
|
|
249
|
+
return {};
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const filePath = join(dir, `${agentName}.md`);
|
|
253
|
+
const stamp = getFileStamp(filePath);
|
|
254
|
+
const cached = cache.get(agentName);
|
|
255
|
+
if (cached?.stamp === stamp) {
|
|
256
|
+
return cached.value;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
let value: ScopeConfig;
|
|
260
|
+
try {
|
|
261
|
+
const markdown = readFileSync(filePath, "utf-8");
|
|
262
|
+
const frontmatter = extractFrontmatter(markdown);
|
|
263
|
+
if (!frontmatter) {
|
|
264
|
+
value = {};
|
|
265
|
+
} else {
|
|
266
|
+
const parsed = parseSimpleYamlMap(frontmatter);
|
|
267
|
+
const { config, issues } = normalizeUnifiedConfig(parsed);
|
|
268
|
+
this.accumulateConfigIssues(issues);
|
|
269
|
+
value = { permission: config.permission };
|
|
270
|
+
}
|
|
271
|
+
} catch {
|
|
272
|
+
value = {};
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
cache.set(agentName, { stamp, value });
|
|
276
|
+
return value;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
loadAgentConfig(agentName?: string): ScopeConfig {
|
|
280
|
+
return this.loadScopeConfigFrom(
|
|
281
|
+
this.agentsDir,
|
|
282
|
+
this.agentConfigCache,
|
|
283
|
+
agentName,
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
loadProjectAgentConfig(agentName?: string): ScopeConfig {
|
|
288
|
+
return this.loadScopeConfigFrom(
|
|
289
|
+
this.projectAgentsDir,
|
|
290
|
+
this.projectAgentConfigCache,
|
|
291
|
+
agentName,
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// ── MCP server names ─────────────────────────────────────────────────
|
|
296
|
+
|
|
297
|
+
getConfiguredMcpServerNames(): readonly string[] {
|
|
298
|
+
if (this.configuredMcpServerNamesOverride) {
|
|
299
|
+
return this.configuredMcpServerNamesOverride;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const paths = [this.globalMcpConfigPath];
|
|
303
|
+
const stamp = paths
|
|
304
|
+
.map((path) => `${path}:${getFileStamp(path)}`)
|
|
305
|
+
.join("|");
|
|
306
|
+
if (this.configuredMcpServerNamesCache?.stamp === stamp) {
|
|
307
|
+
return this.configuredMcpServerNamesCache.value;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const value = getConfiguredMcpServerNamesFromPaths(paths);
|
|
311
|
+
this.configuredMcpServerNamesCache = { stamp, value };
|
|
312
|
+
return value;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// ── Cache stamp ───────────────────────────────────────────────────────
|
|
316
|
+
|
|
317
|
+
getCacheStamp(agentName?: string): string {
|
|
318
|
+
const agentStamp = agentName
|
|
319
|
+
? getFileStamp(join(this.agentsDir, `${agentName}.md`))
|
|
320
|
+
: "missing";
|
|
321
|
+
const projectStamp = this.projectGlobalConfigPath
|
|
322
|
+
? getFileStamp(this.projectGlobalConfigPath)
|
|
323
|
+
: "none";
|
|
324
|
+
const projectAgentStamp =
|
|
325
|
+
this.projectAgentsDir && agentName
|
|
326
|
+
? getFileStamp(join(this.projectAgentsDir, `${agentName}.md`))
|
|
327
|
+
: "none";
|
|
328
|
+
|
|
329
|
+
return `${getFileStamp(this.globalConfigPath)}|${projectStamp}|${agentStamp}|${projectAgentStamp}`;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// ── Resolved paths ────────────────────────────────────────────────────
|
|
333
|
+
|
|
334
|
+
getResolvedPolicyPaths(): ResolvedPolicyPaths {
|
|
335
|
+
return {
|
|
336
|
+
globalConfigPath: this.globalConfigPath,
|
|
337
|
+
globalConfigExists: existsSync(this.globalConfigPath),
|
|
338
|
+
projectConfigPath: this.projectGlobalConfigPath,
|
|
339
|
+
projectConfigExists: this.projectGlobalConfigPath
|
|
340
|
+
? existsSync(this.projectGlobalConfigPath)
|
|
341
|
+
: false,
|
|
342
|
+
agentsDir: this.agentsDir,
|
|
343
|
+
agentsDirExists: existsSync(this.agentsDir),
|
|
344
|
+
projectAgentsDir: this.projectAgentsDir,
|
|
345
|
+
projectAgentsDirExists: this.projectAgentsDir
|
|
346
|
+
? existsSync(this.projectAgentsDir)
|
|
347
|
+
: false,
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
import type { ConfigReader } from "./config-store";
|
|
4
|
+
import type { GatePrompter } from "./gate-prompter";
|
|
5
|
+
import type { PermissionPromptDecision } from "./permission-dialog";
|
|
6
|
+
import type {
|
|
7
|
+
PermissionPrompterApi,
|
|
8
|
+
PromptPermissionDetails,
|
|
9
|
+
} from "./permission-prompter";
|
|
10
|
+
import { isSubagentExecutionContext } from "./subagent-context";
|
|
11
|
+
import type { SubagentSessionRegistry } from "./subagent-registry";
|
|
12
|
+
import { canResolveAskPermissionRequest } from "./yolo-mode";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Dependencies required by PromptingGateway.
|
|
16
|
+
*
|
|
17
|
+
* All four fields are actively consumed:
|
|
18
|
+
* - `config` + `subagentSessionsDir` + `registry` drive `canConfirm()`.
|
|
19
|
+
* - `prompter` is called by `prompt()`.
|
|
20
|
+
*/
|
|
21
|
+
export interface PromptingGatewayDeps {
|
|
22
|
+
/** Read current config for the yolo-mode branch of the can-prompt policy. */
|
|
23
|
+
config: ConfigReader;
|
|
24
|
+
/** Static path used to detect a forwarding subagent context. */
|
|
25
|
+
subagentSessionsDir: string;
|
|
26
|
+
/** Process-global registry used to detect a registered child session. */
|
|
27
|
+
registry?: SubagentSessionRegistry;
|
|
28
|
+
/** Resolves the permission decision: direct UI dialog or forwarded to parent. */
|
|
29
|
+
prompter: PermissionPrompterApi;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The lifecycle slice of the gateway that PermissionSession drives.
|
|
34
|
+
*
|
|
35
|
+
* PermissionSession calls activate/deactivate to keep the gateway's stored
|
|
36
|
+
* context in sync with its own — the same pattern used for ForwardingController.
|
|
37
|
+
*/
|
|
38
|
+
export interface PromptingGatewayLifecycle {
|
|
39
|
+
activate(ctx: ExtensionContext): void;
|
|
40
|
+
deactivate(): void;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Context-owning implementation of the GatePrompter role.
|
|
45
|
+
*
|
|
46
|
+
* Owns the stored ExtensionContext and the "can we prompt?" policy
|
|
47
|
+
* (UI / subagent / yolo-mode), replacing the four twin methods
|
|
48
|
+
* that previously lived on PermissionSession.
|
|
49
|
+
*
|
|
50
|
+
* Lifecycle: PermissionSession drives activate/deactivate so the stored
|
|
51
|
+
* context mirrors the session context without independent call-site changes.
|
|
52
|
+
*/
|
|
53
|
+
export class PromptingGateway
|
|
54
|
+
implements GatePrompter, PromptingGatewayLifecycle
|
|
55
|
+
{
|
|
56
|
+
private context: ExtensionContext | null = null;
|
|
57
|
+
|
|
58
|
+
constructor(private readonly deps: PromptingGatewayDeps) {}
|
|
59
|
+
|
|
60
|
+
/** Store the current extension context. */
|
|
61
|
+
activate(ctx: ExtensionContext): void {
|
|
62
|
+
this.context = ctx;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Clear the stored context. */
|
|
66
|
+
deactivate(): void {
|
|
67
|
+
this.context = null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Whether an interactive permission prompt can be shown.
|
|
72
|
+
*
|
|
73
|
+
* Returns false when no context is active. Otherwise delegates to
|
|
74
|
+
* canResolveAskPermissionRequest, which checks hasUI, subagent status,
|
|
75
|
+
* and yolo-mode — relocating the policy from the index.ts closure.
|
|
76
|
+
*/
|
|
77
|
+
canConfirm(): boolean {
|
|
78
|
+
if (this.context === null) return false;
|
|
79
|
+
return canResolveAskPermissionRequest({
|
|
80
|
+
config: this.deps.config.current(),
|
|
81
|
+
hasUI: this.context.hasUI,
|
|
82
|
+
isSubagent: isSubagentExecutionContext(
|
|
83
|
+
this.context,
|
|
84
|
+
this.deps.subagentSessionsDir,
|
|
85
|
+
this.deps.registry,
|
|
86
|
+
),
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Prompt the user for a permission decision using the stored context.
|
|
92
|
+
*
|
|
93
|
+
* Rejects if no context is active — canConfirm() guards this in normal use.
|
|
94
|
+
* Implements {@link GatePrompter}.
|
|
95
|
+
*/
|
|
96
|
+
prompt(details: PromptPermissionDetails): Promise<PermissionPromptDecision> {
|
|
97
|
+
if (this.context === null) {
|
|
98
|
+
return Promise.reject(
|
|
99
|
+
new Error("prompt called before the session was activated"),
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
return this.deps.prompter.prompt(this.context, details);
|
|
103
|
+
}
|
|
104
|
+
}
|