@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,47 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
|
|
3
|
+
const EXTENSION_ID = "pi-permission-system";
|
|
4
|
+
|
|
5
|
+
export const DEBUG_LOG_FILENAME = `${EXTENSION_ID}-debug.jsonl`;
|
|
6
|
+
export const REVIEW_LOG_FILENAME = `${EXTENSION_ID}-permission-review.jsonl`;
|
|
7
|
+
|
|
8
|
+
export function getGlobalConfigDir(agentDir: string): string {
|
|
9
|
+
return join(agentDir, "extensions", EXTENSION_ID);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function getGlobalConfigPath(agentDir: string): string {
|
|
13
|
+
return join(getGlobalConfigDir(agentDir), "config.json");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function getGlobalLogsDir(agentDir: string): string {
|
|
17
|
+
return join(getGlobalConfigDir(agentDir), "logs");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function getProjectConfigPath(cwd: string): string {
|
|
21
|
+
return join(cwd, ".pi", "extensions", EXTENSION_ID, "config.json");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Directory holding project-scoped custom agent definition files.
|
|
26
|
+
*
|
|
27
|
+
* `<cwd>/.pi/agents` is a Pi platform convention, also encoded by
|
|
28
|
+
* `@gotgenes/pi-subagents`' `loadCustomAgents` (`config/custom-agents.ts`).
|
|
29
|
+
* The two packages encode it independently — pi-permission-system has no
|
|
30
|
+
* dependency on pi-subagents (ADR-0002) — so this is this package's
|
|
31
|
+
* authoritative copy.
|
|
32
|
+
*/
|
|
33
|
+
export function getProjectAgentsDir(cwd: string): string {
|
|
34
|
+
return join(cwd, ".pi", "agents");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function getLegacyGlobalPolicyPath(agentDir: string): string {
|
|
38
|
+
return join(agentDir, "pi-permissions.jsonc");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function getLegacyProjectPolicyPath(cwd: string): string {
|
|
42
|
+
return join(cwd, ".pi", "agent", "pi-permissions.jsonc");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function getLegacyExtensionConfigPath(extensionRoot: string): string {
|
|
46
|
+
return join(extensionRoot, "config.json");
|
|
47
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { ResolvedPolicyPaths } from "./permission-manager";
|
|
2
|
+
|
|
3
|
+
export interface ResolvedConfigLogEntry {
|
|
4
|
+
globalConfigPath: string;
|
|
5
|
+
globalConfigExists: boolean;
|
|
6
|
+
projectConfigPath: string | null;
|
|
7
|
+
projectConfigExists: boolean;
|
|
8
|
+
agentsDir: string;
|
|
9
|
+
agentsDirExists: boolean;
|
|
10
|
+
projectAgentsDir: string | null;
|
|
11
|
+
projectAgentsDirExists: boolean;
|
|
12
|
+
legacyGlobalPolicyDetected: boolean;
|
|
13
|
+
legacyProjectPolicyDetected: boolean;
|
|
14
|
+
legacyExtensionConfigDetected: boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface BuildResolvedConfigLogEntryOptions {
|
|
18
|
+
policyPaths: ResolvedPolicyPaths;
|
|
19
|
+
legacyGlobalPolicyDetected?: boolean;
|
|
20
|
+
legacyProjectPolicyDetected?: boolean;
|
|
21
|
+
legacyExtensionConfigDetected?: boolean;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function buildResolvedConfigLogEntry(
|
|
25
|
+
options: BuildResolvedConfigLogEntryOptions,
|
|
26
|
+
): ResolvedConfigLogEntry {
|
|
27
|
+
return {
|
|
28
|
+
...options.policyPaths,
|
|
29
|
+
legacyGlobalPolicyDetected: options.legacyGlobalPolicyDetected ?? false,
|
|
30
|
+
legacyProjectPolicyDetected: options.legacyProjectPolicyDetected ?? false,
|
|
31
|
+
legacyExtensionConfigDetected:
|
|
32
|
+
options.legacyExtensionConfigDetected ?? false,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import {
|
|
2
|
+
existsSync,
|
|
3
|
+
mkdirSync,
|
|
4
|
+
renameSync,
|
|
5
|
+
unlinkSync,
|
|
6
|
+
writeFileSync,
|
|
7
|
+
} from "node:fs";
|
|
8
|
+
import { dirname, normalize } from "node:path";
|
|
9
|
+
import type {
|
|
10
|
+
ExtensionCommandContext,
|
|
11
|
+
ExtensionContext,
|
|
12
|
+
} from "@earendil-works/pi-coding-agent";
|
|
13
|
+
|
|
14
|
+
import { loadAndMergeConfigs, loadUnifiedConfig } from "./config-loader";
|
|
15
|
+
import {
|
|
16
|
+
getGlobalConfigPath,
|
|
17
|
+
getLegacyExtensionConfigPath,
|
|
18
|
+
getLegacyGlobalPolicyPath,
|
|
19
|
+
getLegacyProjectPolicyPath,
|
|
20
|
+
} from "./config-paths";
|
|
21
|
+
import { buildResolvedConfigLogEntry } from "./config-reporter";
|
|
22
|
+
import {
|
|
23
|
+
DEFAULT_EXTENSION_CONFIG,
|
|
24
|
+
EXTENSION_ROOT,
|
|
25
|
+
normalizePermissionSystemConfig,
|
|
26
|
+
type PermissionSystemExtensionConfig,
|
|
27
|
+
} from "./extension-config";
|
|
28
|
+
import type { ResolvedPolicyPaths } from "./policy-loader";
|
|
29
|
+
import type { DebugReviewLogger } from "./session-logger";
|
|
30
|
+
import { syncPermissionSystemStatus } from "./status";
|
|
31
|
+
|
|
32
|
+
/** Read-only view of the current config — for consumers that only read. */
|
|
33
|
+
export interface ConfigReader {
|
|
34
|
+
current(): PermissionSystemExtensionConfig;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Narrow subset of `ConfigStore` that `PermissionSession` depends on.
|
|
39
|
+
*
|
|
40
|
+
* Using an interface rather than the concrete class avoids private-member
|
|
41
|
+
* coupling between the class and test doubles.
|
|
42
|
+
*/
|
|
43
|
+
export interface SessionConfigStore extends ConfigReader {
|
|
44
|
+
refresh(ctx?: ExtensionContext): void;
|
|
45
|
+
logResolvedPaths(cwd?: string): void;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Narrow subset of `ConfigStore` for the `/permission-system` command.
|
|
50
|
+
*
|
|
51
|
+
* Using an interface rather than the concrete class avoids private-member
|
|
52
|
+
* coupling between the class and test doubles.
|
|
53
|
+
*/
|
|
54
|
+
export interface CommandConfigStore extends ConfigReader {
|
|
55
|
+
save(
|
|
56
|
+
next: PermissionSystemExtensionConfig,
|
|
57
|
+
ctx: ExtensionCommandContext,
|
|
58
|
+
): void;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Narrow view of the manager's resolved policy paths (for `logResolvedPaths`). */
|
|
62
|
+
export interface ResolvedPolicyPathProvider {
|
|
63
|
+
getResolvedPolicyPaths(): ResolvedPolicyPaths;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface ConfigStoreDeps {
|
|
67
|
+
agentDir: string;
|
|
68
|
+
policyPaths: ResolvedPolicyPathProvider;
|
|
69
|
+
logger: DebugReviewLogger;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Owns the mutable extension config and the operations that read/write it.
|
|
74
|
+
*
|
|
75
|
+
* Replaces the three `(runtime, …)` config free functions
|
|
76
|
+
* (`refreshExtensionConfig`, `saveExtensionConfig`, `logResolvedConfigPaths`)
|
|
77
|
+
* with methods that privately own `config` and `lastConfigWarning`.
|
|
78
|
+
*
|
|
79
|
+
* Implements {@link ConfigReader} so consumers that only read the current config
|
|
80
|
+
* can depend on the narrow interface rather than the full class.
|
|
81
|
+
*/
|
|
82
|
+
export class ConfigStore implements SessionConfigStore, CommandConfigStore {
|
|
83
|
+
private config: PermissionSystemExtensionConfig;
|
|
84
|
+
private lastConfigWarning: string | null = null;
|
|
85
|
+
|
|
86
|
+
constructor(private readonly deps: ConfigStoreDeps) {
|
|
87
|
+
this.config = { ...DEFAULT_EXTENSION_CONFIG };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Return the current extension config. */
|
|
91
|
+
current(): PermissionSystemExtensionConfig {
|
|
92
|
+
return this.config;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Reload merged config from disk.
|
|
97
|
+
*
|
|
98
|
+
* If `ctx` is provided, uses it to derive the cwd and sync UI status.
|
|
99
|
+
* Equivalent to `refreshExtensionConfig(runtime, ctx?)`.
|
|
100
|
+
*/
|
|
101
|
+
refresh(ctx?: ExtensionContext): void {
|
|
102
|
+
const cwd = ctx?.cwd ?? null;
|
|
103
|
+
const mergeResult = loadAndMergeConfigs(
|
|
104
|
+
this.deps.agentDir,
|
|
105
|
+
cwd ?? "",
|
|
106
|
+
EXTENSION_ROOT,
|
|
107
|
+
);
|
|
108
|
+
const runtimeConfig = normalizePermissionSystemConfig(mergeResult.merged);
|
|
109
|
+
this.config = runtimeConfig;
|
|
110
|
+
|
|
111
|
+
if (ctx?.hasUI) {
|
|
112
|
+
syncPermissionSystemStatus(ctx, runtimeConfig);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const warning =
|
|
116
|
+
mergeResult.issues.length > 0 ? mergeResult.issues.join("\n") : undefined;
|
|
117
|
+
|
|
118
|
+
if (warning && warning !== this.lastConfigWarning) {
|
|
119
|
+
this.lastConfigWarning = warning;
|
|
120
|
+
ctx?.ui.notify(warning, "warning");
|
|
121
|
+
} else if (!warning) {
|
|
122
|
+
this.lastConfigWarning = null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
this.deps.logger.debug("config.loaded", {
|
|
126
|
+
warning: warning ?? null,
|
|
127
|
+
debugLog: runtimeConfig.debugLog,
|
|
128
|
+
permissionReviewLog: runtimeConfig.permissionReviewLog,
|
|
129
|
+
yoloMode: runtimeConfig.yoloMode,
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Save updated runtime knobs to the global config file, then update
|
|
135
|
+
* the current config and sync UI status.
|
|
136
|
+
*
|
|
137
|
+
* Equivalent to `saveExtensionConfig(runtime, next, ctx)`.
|
|
138
|
+
*/
|
|
139
|
+
// Called via the CommandConfigStore interface from config-modal.ts — fallow cannot trace through interfaces.
|
|
140
|
+
// fallow-ignore-next-line unused-class-member
|
|
141
|
+
save(
|
|
142
|
+
next: PermissionSystemExtensionConfig,
|
|
143
|
+
ctx: ExtensionCommandContext,
|
|
144
|
+
): void {
|
|
145
|
+
const normalized = normalizePermissionSystemConfig(next);
|
|
146
|
+
const globalPath = getGlobalConfigPath(this.deps.agentDir);
|
|
147
|
+
|
|
148
|
+
const existing = loadUnifiedConfig(globalPath);
|
|
149
|
+
const merged = {
|
|
150
|
+
...existing.config,
|
|
151
|
+
debugLog: normalized.debugLog,
|
|
152
|
+
permissionReviewLog: normalized.permissionReviewLog,
|
|
153
|
+
yoloMode: normalized.yoloMode,
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
const tmpPath = `${globalPath}.tmp`;
|
|
157
|
+
try {
|
|
158
|
+
mkdirSync(dirname(globalPath), { recursive: true });
|
|
159
|
+
writeFileSync(tmpPath, `${JSON.stringify(merged, null, 2)}\n`, "utf-8");
|
|
160
|
+
renameSync(tmpPath, globalPath);
|
|
161
|
+
} catch (error) {
|
|
162
|
+
try {
|
|
163
|
+
if (existsSync(tmpPath)) {
|
|
164
|
+
unlinkSync(tmpPath);
|
|
165
|
+
}
|
|
166
|
+
} catch {
|
|
167
|
+
// Ignore cleanup failures.
|
|
168
|
+
}
|
|
169
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
170
|
+
ctx.ui.notify(
|
|
171
|
+
`Failed to save permission-system config at '${globalPath}': ${message}`,
|
|
172
|
+
"error",
|
|
173
|
+
);
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
this.config = normalized;
|
|
178
|
+
syncPermissionSystemStatus(ctx, normalized);
|
|
179
|
+
this.lastConfigWarning = null;
|
|
180
|
+
|
|
181
|
+
this.deps.logger.debug("config.saved", {
|
|
182
|
+
debugLog: normalized.debugLog,
|
|
183
|
+
permissionReviewLog: normalized.permissionReviewLog,
|
|
184
|
+
yoloMode: normalized.yoloMode,
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Write the resolved config path set to the review and debug logs.
|
|
190
|
+
*
|
|
191
|
+
* Equivalent to `logResolvedConfigPaths(runtime)`.
|
|
192
|
+
*/
|
|
193
|
+
logResolvedPaths(cwd?: string): void {
|
|
194
|
+
const policyPaths = this.deps.policyPaths.getResolvedPolicyPaths();
|
|
195
|
+
const { agentDir } = this.deps;
|
|
196
|
+
const legacyGlobalPolicyDetected = existsSync(
|
|
197
|
+
getLegacyGlobalPolicyPath(agentDir),
|
|
198
|
+
);
|
|
199
|
+
const legacyProjectPolicyDetected = cwd
|
|
200
|
+
? existsSync(getLegacyProjectPolicyPath(cwd))
|
|
201
|
+
: false;
|
|
202
|
+
const legacyExtConfigPath = getLegacyExtensionConfigPath(EXTENSION_ROOT);
|
|
203
|
+
const newGlobalPath = getGlobalConfigPath(agentDir);
|
|
204
|
+
const legacyExtensionConfigDetected =
|
|
205
|
+
normalize(legacyExtConfigPath) !== normalize(newGlobalPath) &&
|
|
206
|
+
existsSync(legacyExtConfigPath);
|
|
207
|
+
const entry = buildResolvedConfigLogEntry({
|
|
208
|
+
policyPaths,
|
|
209
|
+
legacyGlobalPolicyDetected,
|
|
210
|
+
legacyProjectPolicyDetected,
|
|
211
|
+
legacyExtensionConfigDetected,
|
|
212
|
+
});
|
|
213
|
+
this.deps.logger.review(
|
|
214
|
+
"config.resolved",
|
|
215
|
+
entry as unknown as Record<string, unknown>,
|
|
216
|
+
);
|
|
217
|
+
this.deps.logger.debug(
|
|
218
|
+
"config.resolved",
|
|
219
|
+
entry as unknown as Record<string, unknown>,
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Records the per-call terminal decision so an evaluated-and-allowed call is
|
|
3
|
+
* distinguishable from a never-evaluated one. The fail-closed boundary owns the
|
|
4
|
+
* recorder and calls exactly one of `recordDecision` / `recordError` per call.
|
|
5
|
+
*/
|
|
6
|
+
export interface DecisionRecorder {
|
|
7
|
+
/** Record a terminal allow/block decision (also bumps the tool-call count). */
|
|
8
|
+
recordDecision(action: "allow" | "block"): void;
|
|
9
|
+
/** Record a gate error that blocked fail-closed (also bumps the count). */
|
|
10
|
+
recordError(): void;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Narrow logging surface the summary needs: a debug line and a warning. */
|
|
14
|
+
export interface AuditLogger {
|
|
15
|
+
debug(event: string, details?: Record<string, unknown>): void;
|
|
16
|
+
warn(message: string): void;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Narrow surface the session-shutdown handler depends on. */
|
|
20
|
+
export interface DecisionSummaryWriter {
|
|
21
|
+
writeSummary(logger: AuditLogger): void;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* In-process, per-session decision counters.
|
|
26
|
+
*
|
|
27
|
+
* The boundary produces exactly one terminal decision per tool call, so
|
|
28
|
+
* `toolCalls` must always equal `allowed + blocked + errors`. `writeSummary`
|
|
29
|
+
* emits the counters on `session_shutdown` and flags any mismatch as a cheap
|
|
30
|
+
* structural self-check — a mismatch means a code path re-opened a silent
|
|
31
|
+
* (never-recorded) exit.
|
|
32
|
+
*/
|
|
33
|
+
export class DecisionAudit implements DecisionRecorder {
|
|
34
|
+
private toolCalls = 0;
|
|
35
|
+
private allowed = 0;
|
|
36
|
+
private blocked = 0;
|
|
37
|
+
private errors = 0;
|
|
38
|
+
|
|
39
|
+
recordDecision(action: "allow" | "block"): void {
|
|
40
|
+
this.toolCalls++;
|
|
41
|
+
if (action === "allow") {
|
|
42
|
+
this.allowed++;
|
|
43
|
+
} else {
|
|
44
|
+
this.blocked++;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
recordError(): void {
|
|
49
|
+
this.toolCalls++;
|
|
50
|
+
this.errors++;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Emit one `permission.session_summary` debug line with the counters. When
|
|
55
|
+
* `toolCalls !== allowed + blocked + errors`, also emit a warning — the
|
|
56
|
+
* invariant violation means a tool call resolved without a recorded terminal
|
|
57
|
+
* decision (a re-opened silent path).
|
|
58
|
+
*/
|
|
59
|
+
writeSummary(logger: AuditLogger): void {
|
|
60
|
+
const counts = {
|
|
61
|
+
toolCalls: this.toolCalls,
|
|
62
|
+
allowed: this.allowed,
|
|
63
|
+
blocked: this.blocked,
|
|
64
|
+
errors: this.errors,
|
|
65
|
+
};
|
|
66
|
+
logger.debug("permission.session_summary", counts);
|
|
67
|
+
if (this.toolCalls !== this.allowed + this.blocked + this.errors) {
|
|
68
|
+
logger.warn(
|
|
69
|
+
`[pi-permission-system] decision audit invariant violated: ${this.toolCalls} tool calls != ` +
|
|
70
|
+
`${this.allowed} allowed + ${this.blocked} blocked + ${this.errors} errors. ` +
|
|
71
|
+
"A tool call resolved without a recorded terminal decision.",
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import {
|
|
2
|
+
emitDecisionEvent,
|
|
3
|
+
type PermissionDecisionEvent,
|
|
4
|
+
type PermissionEventBus,
|
|
5
|
+
} from "./permission-events";
|
|
6
|
+
import type { SessionLogger } from "./session-logger";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Reports a permission gate's outcome to the review log and the decision
|
|
10
|
+
* channel. Groups the two side effects that always travel together:
|
|
11
|
+
* writing a structured review-log entry and broadcasting a decision event.
|
|
12
|
+
*/
|
|
13
|
+
export interface DecisionReporter {
|
|
14
|
+
writeReviewLog(event: string, details: Record<string, unknown>): void;
|
|
15
|
+
emitDecision(event: PermissionDecisionEvent): void;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Owns the `SessionLogger` and the event bus so neither the handler nor
|
|
20
|
+
* the runner has to reach through the session to its logger or close over
|
|
21
|
+
* the event bus directly.
|
|
22
|
+
*
|
|
23
|
+
* Built once in `PermissionGateHandler`'s constructor; shared between
|
|
24
|
+
* `handleToolCall` (gate runner + bypass branch) and `handleInput`.
|
|
25
|
+
*
|
|
26
|
+
* Answers "who owns the event bus" — the reporter does, not the session.
|
|
27
|
+
*/
|
|
28
|
+
export class GateDecisionReporter implements DecisionReporter {
|
|
29
|
+
constructor(
|
|
30
|
+
private readonly logger: SessionLogger,
|
|
31
|
+
private readonly events: PermissionEventBus,
|
|
32
|
+
) {}
|
|
33
|
+
|
|
34
|
+
writeReviewLog(event: string, details: Record<string, unknown>): void {
|
|
35
|
+
this.logger.review(event, details);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
emitDecision(event: PermissionDecisionEvent): void {
|
|
39
|
+
emitDecisionEvent(this.events, event);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import { EXTENSION_ID } from "./extension-config";
|
|
2
|
+
import type { BashCommandContext, PermissionCheckResult } from "./types";
|
|
3
|
+
|
|
4
|
+
// ── Extension attribution tag ──────────────────────────────────────────────
|
|
5
|
+
|
|
6
|
+
export const EXTENSION_TAG = `[${EXTENSION_ID}]`;
|
|
7
|
+
|
|
8
|
+
// ── Denial context discriminated union ─────────────────────────────────────
|
|
9
|
+
|
|
10
|
+
export type DenialContext =
|
|
11
|
+
| {
|
|
12
|
+
kind: "tool";
|
|
13
|
+
check: PermissionCheckResult;
|
|
14
|
+
agentName?: string;
|
|
15
|
+
input?: unknown;
|
|
16
|
+
}
|
|
17
|
+
| {
|
|
18
|
+
kind: "path";
|
|
19
|
+
toolName: string;
|
|
20
|
+
pathValue: string;
|
|
21
|
+
agentName?: string;
|
|
22
|
+
}
|
|
23
|
+
| {
|
|
24
|
+
kind: "external_directory";
|
|
25
|
+
toolName: string;
|
|
26
|
+
pathValue: string;
|
|
27
|
+
cwd: string;
|
|
28
|
+
agentName?: string;
|
|
29
|
+
}
|
|
30
|
+
| {
|
|
31
|
+
kind: "bash_external_directory";
|
|
32
|
+
command: string;
|
|
33
|
+
externalPaths: string[];
|
|
34
|
+
cwd: string;
|
|
35
|
+
agentName?: string;
|
|
36
|
+
}
|
|
37
|
+
| {
|
|
38
|
+
kind: "bash_path";
|
|
39
|
+
command: string;
|
|
40
|
+
pathValue: string;
|
|
41
|
+
agentName?: string;
|
|
42
|
+
}
|
|
43
|
+
| {
|
|
44
|
+
kind: "skill_read";
|
|
45
|
+
skillName: string;
|
|
46
|
+
readPath: string;
|
|
47
|
+
agentName?: string;
|
|
48
|
+
}
|
|
49
|
+
| {
|
|
50
|
+
kind: "skill_input";
|
|
51
|
+
skillName: string;
|
|
52
|
+
agentName?: string;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
// ── Public formatter API ───────────────────────────────────────────────────
|
|
56
|
+
|
|
57
|
+
/** Format the block reason when permission policy denies an operation. */
|
|
58
|
+
export function formatDenyReason(ctx: DenialContext): string {
|
|
59
|
+
return `${EXTENSION_TAG} ${buildDenyBody(ctx)}`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Format the block reason when no interactive UI is available to prompt. */
|
|
63
|
+
export function formatUnavailableReason(ctx: DenialContext): string {
|
|
64
|
+
return `${EXTENSION_TAG} ${buildUnavailableBody(ctx)}`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Format the block reason when the user denies at an interactive prompt. */
|
|
68
|
+
export function formatUserDeniedReason(
|
|
69
|
+
ctx: DenialContext,
|
|
70
|
+
denialReason?: string,
|
|
71
|
+
): string {
|
|
72
|
+
return `${EXTENSION_TAG} ${buildUserDeniedBody(ctx, denialReason)}`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ── Private body builders ──────────────────────────────────────────────────
|
|
76
|
+
|
|
77
|
+
function subject(agentName?: string): string {
|
|
78
|
+
return agentName ? `Agent '${agentName}'` : "Current agent";
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function reasonSuffix(denialReason?: string): string {
|
|
82
|
+
return denialReason ? ` Reason: ${denialReason}.` : "";
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function buildDenyBody(ctx: DenialContext): string {
|
|
86
|
+
switch (ctx.kind) {
|
|
87
|
+
case "tool":
|
|
88
|
+
return buildToolDenyBody(ctx);
|
|
89
|
+
case "path":
|
|
90
|
+
return `${subject(ctx.agentName)} is not permitted to access path '${ctx.pathValue}' via tool '${ctx.toolName}'.`;
|
|
91
|
+
case "external_directory":
|
|
92
|
+
return `${subject(ctx.agentName)} is not permitted to run tool '${ctx.toolName}' for path '${ctx.pathValue}' outside working directory '${ctx.cwd}'.`;
|
|
93
|
+
case "bash_external_directory":
|
|
94
|
+
return `${subject(ctx.agentName)} is not permitted to run bash command '${ctx.command}' which references path(s) outside working directory '${ctx.cwd}': ${ctx.externalPaths.join(", ")}.`;
|
|
95
|
+
case "bash_path":
|
|
96
|
+
return `${subject(ctx.agentName)} is not permitted to access path '${ctx.pathValue}' via tool 'bash'.`;
|
|
97
|
+
case "skill_read":
|
|
98
|
+
return `${subject(ctx.agentName)} is not permitted to access skill '${ctx.skillName}' via '${ctx.readPath}'.`;
|
|
99
|
+
case "skill_input":
|
|
100
|
+
return `${subject(ctx.agentName)} is not permitted to access skill '${ctx.skillName}'.`;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function buildToolDenyBody(
|
|
105
|
+
ctx: Extract<DenialContext, { kind: "tool" }>,
|
|
106
|
+
): string {
|
|
107
|
+
const parts: string[] = [];
|
|
108
|
+
const { check, agentName } = ctx;
|
|
109
|
+
|
|
110
|
+
if (agentName) {
|
|
111
|
+
parts.push(`Agent '${agentName}'`);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (isMcpCheck(check)) {
|
|
115
|
+
parts.push(`is not permitted to run MCP target '${check.target}'`);
|
|
116
|
+
} else {
|
|
117
|
+
parts.push(`is not permitted to run '${check.toolName}'`);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (check.command) {
|
|
121
|
+
parts.push(`command '${check.command}'`);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const qualifier = matchQualifier(check.matchedPattern, check.commandContext);
|
|
125
|
+
if (qualifier) {
|
|
126
|
+
parts.push(qualifier);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// reasonSuffix appends ` Reason: <reason>.` after the sentence-ending period.
|
|
130
|
+
return `${parts.join(" ")}.${reasonSuffix(check.reason)}`;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Human-readable label for a nested bash execution context, or `undefined` for
|
|
135
|
+
* a current-shell (top-level) command.
|
|
136
|
+
*/
|
|
137
|
+
export function describeBashCommandContext(
|
|
138
|
+
context?: BashCommandContext,
|
|
139
|
+
): string | undefined {
|
|
140
|
+
switch (context) {
|
|
141
|
+
case "command_substitution":
|
|
142
|
+
return "command substitution";
|
|
143
|
+
case "process_substitution":
|
|
144
|
+
return "process substitution";
|
|
145
|
+
case "subshell":
|
|
146
|
+
return "subshell";
|
|
147
|
+
default:
|
|
148
|
+
return undefined;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Build the parenthetical qualifier for a bash decision, folding the matched
|
|
154
|
+
* rule and (for a nested command) its execution context into one clause, e.g.
|
|
155
|
+
* `(matched 'rm *', inside command substitution)`. Returns `""` when neither
|
|
156
|
+
* applies.
|
|
157
|
+
*/
|
|
158
|
+
export function matchQualifier(
|
|
159
|
+
matchedPattern?: string,
|
|
160
|
+
context?: BashCommandContext,
|
|
161
|
+
): string {
|
|
162
|
+
const parts: string[] = [];
|
|
163
|
+
if (matchedPattern) {
|
|
164
|
+
parts.push(`matched '${matchedPattern}'`);
|
|
165
|
+
}
|
|
166
|
+
const label = describeBashCommandContext(context);
|
|
167
|
+
if (label) {
|
|
168
|
+
parts.push(`inside ${label}`);
|
|
169
|
+
}
|
|
170
|
+
return parts.length > 0 ? `(${parts.join(", ")})` : "";
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function buildUnavailableBody(ctx: DenialContext): string {
|
|
174
|
+
switch (ctx.kind) {
|
|
175
|
+
case "tool": {
|
|
176
|
+
const { check } = ctx;
|
|
177
|
+
if (check.toolName === "bash" && check.command) {
|
|
178
|
+
return `Running bash command '${check.command}' requires approval, but no interactive UI is available.`;
|
|
179
|
+
}
|
|
180
|
+
if (isMcpCheck(check)) {
|
|
181
|
+
return "Using tool 'mcp' requires approval, but no interactive UI is available.";
|
|
182
|
+
}
|
|
183
|
+
return `Using tool '${check.toolName}' requires approval, but no interactive UI is available.`;
|
|
184
|
+
}
|
|
185
|
+
case "path":
|
|
186
|
+
return `Accessing '${ctx.pathValue}' requires approval, but no interactive UI is available.`;
|
|
187
|
+
case "external_directory":
|
|
188
|
+
return `Accessing '${ctx.pathValue}' outside the working directory requires approval, but no interactive UI is available.`;
|
|
189
|
+
case "bash_external_directory":
|
|
190
|
+
return `Bash command '${ctx.command}' references path(s) outside the working directory and requires approval, but no interactive UI is available.`;
|
|
191
|
+
case "bash_path":
|
|
192
|
+
return `Bash command '${ctx.command}' accesses path '${ctx.pathValue}' which requires approval, but no interactive UI is available.`;
|
|
193
|
+
case "skill_read":
|
|
194
|
+
return `Accessing skill '${ctx.skillName}' requires approval, but no interactive UI is available.`;
|
|
195
|
+
case "skill_input":
|
|
196
|
+
return `Accessing skill '${ctx.skillName}' requires approval, but no interactive UI is available.`;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function buildUserDeniedBody(
|
|
201
|
+
ctx: DenialContext,
|
|
202
|
+
denialReason?: string,
|
|
203
|
+
): string {
|
|
204
|
+
switch (ctx.kind) {
|
|
205
|
+
case "tool": {
|
|
206
|
+
const { check } = ctx;
|
|
207
|
+
if (isMcpCheck(check)) {
|
|
208
|
+
return `User denied MCP target '${check.target}'.${reasonSuffix(denialReason)}`;
|
|
209
|
+
}
|
|
210
|
+
if (check.toolName === "bash" && check.command) {
|
|
211
|
+
return `User denied bash command '${check.command}'.${reasonSuffix(denialReason)}`;
|
|
212
|
+
}
|
|
213
|
+
return `User denied tool '${check.toolName}'.${reasonSuffix(denialReason)}`;
|
|
214
|
+
}
|
|
215
|
+
case "path":
|
|
216
|
+
return `User denied access to path '${ctx.pathValue}'.${reasonSuffix(denialReason)}`;
|
|
217
|
+
case "external_directory":
|
|
218
|
+
return `User denied external directory access for tool '${ctx.toolName}' path '${ctx.pathValue}'.${reasonSuffix(denialReason)}`;
|
|
219
|
+
case "bash_external_directory":
|
|
220
|
+
return `User denied external directory access for bash command '${ctx.command}'.${reasonSuffix(denialReason)}`;
|
|
221
|
+
case "bash_path":
|
|
222
|
+
return `User denied path access for bash command '${ctx.command}' (path '${ctx.pathValue}').${reasonSuffix(denialReason)}`;
|
|
223
|
+
case "skill_read":
|
|
224
|
+
return `User denied access to skill '${ctx.skillName}'.${reasonSuffix(denialReason)}`;
|
|
225
|
+
case "skill_input":
|
|
226
|
+
return `User denied access to skill '${ctx.skillName}'.${reasonSuffix(denialReason)}`;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function isMcpCheck(check: PermissionCheckResult): boolean {
|
|
231
|
+
return (check.source === "mcp" || check.toolName === "mcp") && !!check.target;
|
|
232
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Expand `~` and `$HOME` prefixes in a pattern to the OS home directory.
|
|
6
|
+
*
|
|
7
|
+
* Supported forms:
|
|
8
|
+
* - `~` → `homedir()`
|
|
9
|
+
* - `~/path` → `homedir()/path`
|
|
10
|
+
* - `~\path` → `homedir()\path` (Windows)
|
|
11
|
+
* - `$HOME` → `homedir()`
|
|
12
|
+
* - `$HOME/path` → `homedir()/path`
|
|
13
|
+
* - `$HOME\path` → `homedir()\path` (Windows)
|
|
14
|
+
*
|
|
15
|
+
* All other patterns are returned unchanged.
|
|
16
|
+
*/
|
|
17
|
+
export function expandHomePath(pattern: string): string {
|
|
18
|
+
if (pattern === "~" || pattern === "$HOME") {
|
|
19
|
+
return homedir();
|
|
20
|
+
}
|
|
21
|
+
if (pattern.startsWith("~/") || pattern.startsWith("~\\")) {
|
|
22
|
+
return join(homedir(), pattern.slice(2));
|
|
23
|
+
}
|
|
24
|
+
if (pattern.startsWith("$HOME/") || pattern.startsWith("$HOME\\")) {
|
|
25
|
+
return join(homedir(), pattern.slice(6));
|
|
26
|
+
}
|
|
27
|
+
return pattern;
|
|
28
|
+
}
|