@gotgenes/pi-permission-system 10.7.0 → 10.7.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 +14 -0
- package/package.json +5 -5
- package/src/handlers/lifecycle.ts +6 -3
- package/src/index.ts +18 -21
- package/src/permission-prompts.ts +8 -1
- package/src/permission-session.ts +7 -3
- package/src/session-logger.ts +41 -30
- package/test/handlers/external-directory-integration.test.ts +6 -9
- package/test/handlers/lifecycle.test.ts +7 -8
- package/test/helpers/handler-fixtures.ts +1 -3
- package/test/helpers/session-fixtures.ts +0 -1
- package/test/permission-prompts.test.ts +67 -0
- package/test/permission-session.test.ts +31 -0
- package/test/session-logger.test.ts +14 -14
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,20 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [10.7.2](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v10.7.1...pi-permission-system-v10.7.2) (2026-06-10)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Miscellaneous Chores
|
|
12
|
+
|
|
13
|
+
* **deps:** bump tooling dependencies to latest minor/patch ([8b9105d](https://github.com/gotgenes/pi-packages/commit/8b9105d4011816fe8085dfed3a3b9d7bc9918c56))
|
|
14
|
+
|
|
15
|
+
## [10.7.1](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v10.7.0...pi-permission-system-v10.7.1) (2026-06-09)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
### Bug Fixes
|
|
19
|
+
|
|
20
|
+
* surface full chained command in bash permission prompt ([#333](https://github.com/gotgenes/pi-packages/issues/333)) ([7f448fb](https://github.com/gotgenes/pi-packages/commit/7f448fb6e394bc37f94c98e04332abdcc8528c46))
|
|
21
|
+
|
|
8
22
|
## [10.7.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v10.6.0...pi-permission-system-v10.7.0) (2026-06-09)
|
|
9
23
|
|
|
10
24
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gotgenes/pi-permission-system",
|
|
3
|
-
"version": "10.7.
|
|
3
|
+
"version": "10.7.2",
|
|
4
4
|
"description": "Permission enforcement extension for the Pi coding agent.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -60,17 +60,17 @@
|
|
|
60
60
|
"@earendil-works/pi-tui": ">=0.75.0"
|
|
61
61
|
},
|
|
62
62
|
"devDependencies": {
|
|
63
|
-
"@biomejs/biome": "^2.4.
|
|
63
|
+
"@biomejs/biome": "^2.4.16",
|
|
64
64
|
"@earendil-works/pi-coding-agent": "0.75.4",
|
|
65
65
|
"@earendil-works/pi-tui": "0.75.4",
|
|
66
66
|
"@types/node": "^22.15.3",
|
|
67
|
-
"rumdl": "^0.
|
|
67
|
+
"rumdl": "^0.2.10",
|
|
68
68
|
"typescript": "^6.0.3",
|
|
69
|
-
"vitest": "^4.1.
|
|
69
|
+
"vitest": "^4.1.8"
|
|
70
70
|
},
|
|
71
71
|
"dependencies": {
|
|
72
72
|
"tree-sitter-bash": "^0.25.1",
|
|
73
|
-
"web-tree-sitter": "^0.26.
|
|
73
|
+
"web-tree-sitter": "^0.26.9"
|
|
74
74
|
},
|
|
75
75
|
"scripts": {
|
|
76
76
|
"check": "tsc --noEmit",
|
|
@@ -3,6 +3,7 @@ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
|
3
3
|
import type { PermissionResolver } from "#src/permission-resolver";
|
|
4
4
|
import type { PermissionSession } from "#src/permission-session";
|
|
5
5
|
import type { ServiceLifecycle } from "#src/service-lifecycle";
|
|
6
|
+
import type { SessionLogger } from "#src/session-logger";
|
|
6
7
|
import { PERMISSION_SYSTEM_STATUS_KEY } from "#src/status";
|
|
7
8
|
|
|
8
9
|
/** Minimal subset of SessionStartEvent used by this handler. */
|
|
@@ -24,12 +25,14 @@ interface ResourcesDiscoverPayload {
|
|
|
24
25
|
* - `serviceLifecycle` — owns the process-global service publication;
|
|
25
26
|
* `activate` publishes (skipped for registered subagent children) and emits
|
|
26
27
|
* the ready event; `teardown` unsubscribes all session listeners and unpublishes
|
|
28
|
+
* - `logger` — injected directly; replaces the former `session.logger` reach-through
|
|
27
29
|
*/
|
|
28
30
|
export class SessionLifecycleHandler {
|
|
29
31
|
constructor(
|
|
30
32
|
private readonly session: PermissionSession,
|
|
31
33
|
private readonly resolver: PermissionResolver,
|
|
32
34
|
private readonly serviceLifecycle: ServiceLifecycle,
|
|
35
|
+
private readonly logger: SessionLogger,
|
|
33
36
|
) {}
|
|
34
37
|
|
|
35
38
|
handleSessionStart(
|
|
@@ -43,11 +46,11 @@ export class SessionLifecycleHandler {
|
|
|
43
46
|
const agentName = this.session.resolveAgentName(ctx);
|
|
44
47
|
const policyIssues = this.resolver.getConfigIssues(agentName ?? undefined);
|
|
45
48
|
for (const issue of policyIssues) {
|
|
46
|
-
this.
|
|
49
|
+
this.logger.warn(issue);
|
|
47
50
|
}
|
|
48
51
|
|
|
49
52
|
if (event.reason === "reload") {
|
|
50
|
-
this.
|
|
53
|
+
this.logger.debug("lifecycle.reload", {
|
|
51
54
|
triggeredBy: "session_start",
|
|
52
55
|
reason: event.reason,
|
|
53
56
|
cwd: ctx.cwd,
|
|
@@ -68,7 +71,7 @@ export class SessionLifecycleHandler {
|
|
|
68
71
|
}
|
|
69
72
|
|
|
70
73
|
this.session.reload();
|
|
71
|
-
this.
|
|
74
|
+
this.logger.debug("lifecycle.reload", {
|
|
72
75
|
triggeredBy: "resources_discover",
|
|
73
76
|
reason: event.reason,
|
|
74
77
|
cwd: this.session.getRuntimeContext()?.cwd ?? null,
|
package/src/index.ts
CHANGED
|
@@ -28,7 +28,7 @@ import { PermissionSession } from "./permission-session";
|
|
|
28
28
|
import { LocalPermissionsService } from "./permissions-service";
|
|
29
29
|
import { PromptingGateway } from "./prompting-gateway";
|
|
30
30
|
import { PermissionServiceLifecycle } from "./service-lifecycle";
|
|
31
|
-
import {
|
|
31
|
+
import { PermissionSessionLogger } from "./session-logger";
|
|
32
32
|
import { SessionRules } from "./session-rules";
|
|
33
33
|
import { subscribeSubagentLifecycle } from "./subagent-lifecycle-events";
|
|
34
34
|
import { getSubagentSessionRegistry } from "./subagent-registry";
|
|
@@ -43,22 +43,19 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
|
|
|
43
43
|
const formatterRegistry = new ToolInputFormatterRegistry();
|
|
44
44
|
registerBuiltinToolInputFormatters(formatterRegistry);
|
|
45
45
|
|
|
46
|
-
//
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
//
|
|
50
|
-
|
|
46
|
+
// Both `configStore` and `session` are forward-declared so the logger's
|
|
47
|
+
// lazy thunks can close over them without a cast or null-init holder.
|
|
48
|
+
// TypeScript exempts closure captures from definite-assignment analysis;
|
|
49
|
+
// all synchronous reads occur after the assignments below.
|
|
50
|
+
// eslint-disable-next-line prefer-const -- forward-declared let; `const` requires an initializer
|
|
51
|
+
let configStore: ConfigStore;
|
|
52
|
+
// eslint-disable-next-line prefer-const -- forward-declared let; `const` requires an initializer
|
|
53
|
+
let session: PermissionSession;
|
|
51
54
|
|
|
52
|
-
|
|
53
|
-
// reach the UI once PermissionSession is constructed. Starts as null;
|
|
54
|
-
// notify is a best-effort sink (no-op at factory-init when there is no UI).
|
|
55
|
-
let sessionNotify: PermissionSession | null = null;
|
|
56
|
-
|
|
57
|
-
const logger = createSessionLogger({
|
|
55
|
+
const logger = new PermissionSessionLogger({
|
|
58
56
|
globalLogsDir: paths.globalLogsDir,
|
|
59
57
|
getConfig: () => configStore.current(),
|
|
60
|
-
notify: (message) =>
|
|
61
|
-
sessionNotify?.getRuntimeContext()?.ui.notify(message, "warning"),
|
|
58
|
+
notify: (message) => session.notify(message),
|
|
62
59
|
});
|
|
63
60
|
|
|
64
61
|
configStore = new ConfigStore({
|
|
@@ -85,8 +82,6 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
|
|
|
85
82
|
forwarder,
|
|
86
83
|
});
|
|
87
84
|
|
|
88
|
-
configStore.refresh();
|
|
89
|
-
|
|
90
85
|
const gateway = new PromptingGateway({
|
|
91
86
|
config: configStore,
|
|
92
87
|
subagentSessionsDir: paths.subagentSessionsDir,
|
|
@@ -94,9 +89,8 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
|
|
|
94
89
|
prompter,
|
|
95
90
|
});
|
|
96
91
|
|
|
97
|
-
|
|
92
|
+
session = new PermissionSession(
|
|
98
93
|
paths,
|
|
99
|
-
logger,
|
|
100
94
|
new ForwardingManager(
|
|
101
95
|
paths.subagentSessionsDir,
|
|
102
96
|
forwarder,
|
|
@@ -108,8 +102,10 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
|
|
|
108
102
|
gateway,
|
|
109
103
|
);
|
|
110
104
|
|
|
111
|
-
//
|
|
112
|
-
|
|
105
|
+
// refresh() must run after `session` is assigned: a debug-write IO failure
|
|
106
|
+
// triggers the logger's notify sink — `session.notify(m)` — which no-ops
|
|
107
|
+
// on the null context but requires `session` to be bound.
|
|
108
|
+
configStore.refresh();
|
|
113
109
|
|
|
114
110
|
const configPath = getGlobalConfigPath(agentDir);
|
|
115
111
|
registerPermissionSystemCommand(pi, {
|
|
@@ -163,10 +159,11 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
|
|
|
163
159
|
session,
|
|
164
160
|
resolver,
|
|
165
161
|
serviceLifecycle,
|
|
162
|
+
logger,
|
|
166
163
|
);
|
|
167
164
|
const agentPrep = new AgentPrepHandler(session, resolver, toolRegistry);
|
|
168
165
|
|
|
169
|
-
const reporter = new GateDecisionReporter(
|
|
166
|
+
const reporter = new GateDecisionReporter(logger, pi.events);
|
|
170
167
|
const gateRunner = new GateRunner(resolver, sessionRules, gateway, reporter);
|
|
171
168
|
const toolCallGatePipeline = new ToolCallGatePipeline(
|
|
172
169
|
resolver,
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { getNonEmptyString, toRecord } from "./common";
|
|
1
2
|
import { matchQualifier } from "./denial-messages";
|
|
2
3
|
import type { SkillPromptEntry } from "./skill-prompt-sanitizer";
|
|
3
4
|
import type { ToolPreviewFormatter } from "./tool-preview-formatter";
|
|
@@ -37,12 +38,18 @@ export function formatAskPrompt(
|
|
|
37
38
|
const subject = agentName ? `Agent '${agentName}'` : "Current agent";
|
|
38
39
|
|
|
39
40
|
if (result.toolName === "bash") {
|
|
41
|
+
const subCommand = result.command ?? "";
|
|
40
42
|
const qualifier = matchQualifier(
|
|
41
43
|
result.matchedPattern,
|
|
42
44
|
result.commandContext,
|
|
43
45
|
);
|
|
44
46
|
const qualifierInfo = qualifier ? ` ${qualifier}` : "";
|
|
45
|
-
|
|
47
|
+
const fullCommand = getNonEmptyString(toRecord(input).command);
|
|
48
|
+
const fullCommandInfo =
|
|
49
|
+
fullCommand && fullCommand !== subCommand
|
|
50
|
+
? ` (full command: '${fullCommand}')`
|
|
51
|
+
: "";
|
|
52
|
+
return `${subject} requested bash command '${subCommand}'${qualifierInfo}${fullCommandInfo}. Allow this command?`;
|
|
46
53
|
}
|
|
47
54
|
|
|
48
55
|
if ((result.source === "mcp" || result.toolName === "mcp") && result.target) {
|
|
@@ -13,7 +13,6 @@ import type { ToolCallGateInputs } from "./handlers/gates/tool-call-gate-pipelin
|
|
|
13
13
|
import type { ScopedPermissionManager } from "./permission-manager";
|
|
14
14
|
import type { PromptingGatewayLifecycle } from "./prompting-gateway";
|
|
15
15
|
|
|
16
|
-
import type { SessionLogger } from "./session-logger";
|
|
17
16
|
import type { SessionRules } from "./session-rules";
|
|
18
17
|
import type { SkillPromptEntry } from "./skill-prompt-sanitizer";
|
|
19
18
|
import {
|
|
@@ -31,7 +30,6 @@ import {
|
|
|
31
30
|
*
|
|
32
31
|
* Constructor deps:
|
|
33
32
|
* - `ExtensionPaths` — immutable path constants
|
|
34
|
-
* - `SessionLogger` — debug + review + warn
|
|
35
33
|
* - `ForwardingController` — polling lifecycle
|
|
36
34
|
* - `SessionConfigStore` — owns extension config; provides refresh, log, read
|
|
37
35
|
* - `PromptingGatewayLifecycle` — prompting lifecycle forwarded via activate/deactivate
|
|
@@ -45,7 +43,6 @@ export class PermissionSession implements ToolCallGateInputs {
|
|
|
45
43
|
|
|
46
44
|
constructor(
|
|
47
45
|
private readonly paths: ExtensionPaths,
|
|
48
|
-
readonly logger: SessionLogger,
|
|
49
46
|
private readonly forwarding: ForwardingController,
|
|
50
47
|
private readonly permissionManager: ScopedPermissionManager,
|
|
51
48
|
private readonly sessionRules: SessionRules,
|
|
@@ -74,6 +71,13 @@ export class PermissionSession implements ToolCallGateInputs {
|
|
|
74
71
|
return this.context;
|
|
75
72
|
}
|
|
76
73
|
|
|
74
|
+
// ── UI notifications ────────────────────────────────────────────────────
|
|
75
|
+
|
|
76
|
+
/** Surface a warning message to the user via the active UI context, if any. */
|
|
77
|
+
notify(message: string): void {
|
|
78
|
+
this.context?.ui.notify(message, "warning");
|
|
79
|
+
}
|
|
80
|
+
|
|
77
81
|
// ── Session lifecycle ────────────────────────────────────────────────────
|
|
78
82
|
|
|
79
83
|
/**
|
package/src/session-logger.ts
CHANGED
|
@@ -4,7 +4,10 @@ import {
|
|
|
4
4
|
ensurePermissionSystemLogsDirectory,
|
|
5
5
|
type PermissionSystemExtensionConfig,
|
|
6
6
|
} from "./extension-config";
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
createPermissionSystemLogger,
|
|
9
|
+
type PermissionSystemLogger,
|
|
10
|
+
} from "./logging";
|
|
8
11
|
|
|
9
12
|
/**
|
|
10
13
|
* Narrowest logging seam — consumers that only write review-log entries.
|
|
@@ -44,37 +47,45 @@ export interface SessionLoggerDeps {
|
|
|
44
47
|
}
|
|
45
48
|
|
|
46
49
|
/**
|
|
47
|
-
*
|
|
50
|
+
* Concrete `SessionLogger` implementation.
|
|
48
51
|
*
|
|
49
|
-
* Composes the JSONL log writer, owns the IO-failure warning
|
|
50
|
-
* and routes both IO-failure warnings and explicit warn() calls
|
|
51
|
-
* the injected notify sink. No ExtensionRuntime reference required.
|
|
52
|
+
* Composes the JSONL log writer, privately owns the IO-failure warning
|
|
53
|
+
* dedup Set, and routes both IO-failure warnings and explicit warn() calls
|
|
54
|
+
* through the injected notify sink. No ExtensionRuntime reference required.
|
|
52
55
|
*/
|
|
53
|
-
export
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
56
|
+
export class PermissionSessionLogger implements SessionLogger {
|
|
57
|
+
private readonly writer: PermissionSystemLogger;
|
|
58
|
+
private readonly reported = new Set<string>();
|
|
59
|
+
private readonly notify: (message: string) => void;
|
|
60
|
+
|
|
61
|
+
constructor(deps: SessionLoggerDeps) {
|
|
62
|
+
this.writer = createPermissionSystemLogger({
|
|
63
|
+
getConfig: deps.getConfig,
|
|
64
|
+
debugLogPath: join(deps.globalLogsDir, DEBUG_LOG_FILENAME),
|
|
65
|
+
reviewLogPath: join(deps.globalLogsDir, REVIEW_LOG_FILENAME),
|
|
66
|
+
ensureLogsDirectory: () =>
|
|
67
|
+
ensurePermissionSystemLogsDirectory(deps.globalLogsDir),
|
|
68
|
+
});
|
|
69
|
+
this.notify = deps.notify;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
debug(event: string, details?: Record<string, unknown>): void {
|
|
73
|
+
const warning = this.writer.debug(event, details);
|
|
74
|
+
if (warning) this.reportOnce(warning);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
review(event: string, details?: Record<string, unknown>): void {
|
|
78
|
+
const warning = this.writer.review(event, details);
|
|
79
|
+
if (warning) this.reportOnce(warning);
|
|
80
|
+
}
|
|
61
81
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
reported.add(warning);
|
|
66
|
-
deps.notify(warning);
|
|
67
|
-
};
|
|
82
|
+
warn(message: string): void {
|
|
83
|
+
this.notify(message);
|
|
84
|
+
}
|
|
68
85
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
review: (event, details) => {
|
|
75
|
-
const warning = writer.review(event, details);
|
|
76
|
-
if (warning) reportOnce(warning);
|
|
77
|
-
},
|
|
78
|
-
warn: (message) => deps.notify(message),
|
|
79
|
-
};
|
|
86
|
+
private reportOnce(warning: string): void {
|
|
87
|
+
if (this.reported.has(warning)) return;
|
|
88
|
+
this.reported.add(warning);
|
|
89
|
+
this.notify(warning);
|
|
90
|
+
}
|
|
80
91
|
}
|
|
@@ -191,14 +191,13 @@ describe("external_directory policy state — allow", () => {
|
|
|
191
191
|
});
|
|
192
192
|
|
|
193
193
|
it("does not write a block review-log entry when external_directory is allow", async () => {
|
|
194
|
-
const { handler,
|
|
194
|
+
const { handler, logger } = makeHandler({
|
|
195
195
|
session: { checkPermission: makeExtDirCheck("allow") },
|
|
196
196
|
tools: ALL_TOOLS,
|
|
197
197
|
});
|
|
198
198
|
const event = makeToolCallEvent("read", { input: { path: EXTERNAL_PATH } });
|
|
199
199
|
await handler.handleToolCall(event, makeCtx());
|
|
200
|
-
const reviewCalls = (
|
|
201
|
-
.calls;
|
|
200
|
+
const reviewCalls = (logger.review as ReturnType<typeof vi.fn>).mock.calls;
|
|
202
201
|
const blockEntries = reviewCalls.filter(
|
|
203
202
|
([eventName]: string[]) => eventName === "permission_request.blocked",
|
|
204
203
|
);
|
|
@@ -301,14 +300,13 @@ describe("external_directory policy state — deny", () => {
|
|
|
301
300
|
});
|
|
302
301
|
|
|
303
302
|
it("writes review-log entry with resolution policy_denied", async () => {
|
|
304
|
-
const { handler,
|
|
303
|
+
const { handler, logger } = makeHandler({
|
|
305
304
|
session: { checkPermission: makeExtDirCheck("deny") },
|
|
306
305
|
tools: ALL_TOOLS,
|
|
307
306
|
});
|
|
308
307
|
const event = makeToolCallEvent("read", { input: { path: EXTERNAL_PATH } });
|
|
309
308
|
await handler.handleToolCall(event, makeCtx());
|
|
310
|
-
const reviewCalls = (
|
|
311
|
-
.calls;
|
|
309
|
+
const reviewCalls = (logger.review as ReturnType<typeof vi.fn>).mock.calls;
|
|
312
310
|
const blockEntries = reviewCalls.filter(
|
|
313
311
|
([eventName]: string[]) => eventName === "permission_request.blocked",
|
|
314
312
|
);
|
|
@@ -458,7 +456,7 @@ describe("external_directory policy state — ask", () => {
|
|
|
458
456
|
});
|
|
459
457
|
|
|
460
458
|
it("writes review-log entry with confirmation_unavailable when no UI", async () => {
|
|
461
|
-
const { handler,
|
|
459
|
+
const { handler, logger } = makeHandler({
|
|
462
460
|
session: { checkPermission: makeExtDirCheck("ask") },
|
|
463
461
|
prompter: {
|
|
464
462
|
canConfirm: vi.fn().mockReturnValue(false),
|
|
@@ -468,8 +466,7 @@ describe("external_directory policy state — ask", () => {
|
|
|
468
466
|
});
|
|
469
467
|
const event = makeToolCallEvent("read", { input: { path: EXTERNAL_PATH } });
|
|
470
468
|
await handler.handleToolCall(event, makeCtx({ hasUI: false }));
|
|
471
|
-
const reviewCalls = (
|
|
472
|
-
.calls;
|
|
469
|
+
const reviewCalls = (logger.review as ReturnType<typeof vi.fn>).mock.calls;
|
|
473
470
|
const blockEntries = reviewCalls.filter(
|
|
474
471
|
([eventName]: string[]) => eventName === "permission_request.blocked",
|
|
475
472
|
);
|
|
@@ -5,6 +5,7 @@ import type { ServiceLifecycle } from "#src/service-lifecycle";
|
|
|
5
5
|
|
|
6
6
|
import { makeCtx } from "#test/helpers/handler-fixtures";
|
|
7
7
|
import {
|
|
8
|
+
makeLogger,
|
|
8
9
|
makeRealResolver,
|
|
9
10
|
makeRealSession,
|
|
10
11
|
} from "#test/helpers/session-fixtures";
|
|
@@ -19,14 +20,8 @@ vi.mock("../../src/status", () => ({
|
|
|
19
20
|
// ── helpers ────────────────────────────────────────────────────────────────
|
|
20
21
|
|
|
21
22
|
function makeSetup(opts?: { configIssues?: string[] }) {
|
|
22
|
-
const {
|
|
23
|
-
|
|
24
|
-
permissionManager,
|
|
25
|
-
sessionRules,
|
|
26
|
-
logger,
|
|
27
|
-
forwarding,
|
|
28
|
-
configStore,
|
|
29
|
-
} = makeRealSession();
|
|
23
|
+
const { session, permissionManager, sessionRules, forwarding, configStore } =
|
|
24
|
+
makeRealSession();
|
|
30
25
|
const { resolver } = makeRealResolver(permissionManager, sessionRules);
|
|
31
26
|
if (opts?.configIssues) {
|
|
32
27
|
vi.mocked(permissionManager.getConfigIssues).mockReturnValue(
|
|
@@ -37,10 +32,14 @@ function makeSetup(opts?: { configIssues?: string[] }) {
|
|
|
37
32
|
activate: vi.fn<ServiceLifecycle["activate"]>(),
|
|
38
33
|
teardown: vi.fn<ServiceLifecycle["teardown"]>(),
|
|
39
34
|
};
|
|
35
|
+
// Use a session-independent logger so assertions verify direct injection,
|
|
36
|
+
// not reach-through to session.logger.
|
|
37
|
+
const logger = makeLogger();
|
|
40
38
|
const handler = new SessionLifecycleHandler(
|
|
41
39
|
session,
|
|
42
40
|
resolver,
|
|
43
41
|
serviceLifecycle,
|
|
42
|
+
logger,
|
|
44
43
|
);
|
|
45
44
|
return {
|
|
46
45
|
handler,
|
|
@@ -25,7 +25,6 @@ import { PermissionGateHandler } from "#src/handlers/permission-gate-handler";
|
|
|
25
25
|
import type { PermissionDecisionEvent } from "#src/permission-events";
|
|
26
26
|
import { PERMISSIONS_DECISION_CHANNEL } from "#src/permission-events";
|
|
27
27
|
import type { Rule } from "#src/rule";
|
|
28
|
-
import type { SessionLogger } from "#src/session-logger";
|
|
29
28
|
import { SessionRules } from "#src/session-rules";
|
|
30
29
|
import type { ToolRegistry } from "#src/tool-registry";
|
|
31
30
|
import type { PermissionCheckResult, PermissionState } from "#src/types";
|
|
@@ -49,8 +48,6 @@ import {
|
|
|
49
48
|
*/
|
|
50
49
|
export type MockGateHandlerSession = ToolCallGateInputs &
|
|
51
50
|
SkillInputGateInputs & {
|
|
52
|
-
/** Logger shape expected by GateDecisionReporter. */
|
|
53
|
-
logger: SessionLogger;
|
|
54
51
|
/** 4-arg form so surface-check mocks can receive optional rules. */
|
|
55
52
|
checkPermission(
|
|
56
53
|
surface: string,
|
|
@@ -296,6 +293,7 @@ export function makeHandler(overrides?: {
|
|
|
296
293
|
handler,
|
|
297
294
|
events,
|
|
298
295
|
session,
|
|
296
|
+
logger,
|
|
299
297
|
toolRegistry,
|
|
300
298
|
prompter,
|
|
301
299
|
recorder,
|
|
@@ -151,6 +151,73 @@ describe("formatAskPrompt", () => {
|
|
|
151
151
|
expect(result).toContain("matched 'git *'");
|
|
152
152
|
});
|
|
153
153
|
|
|
154
|
+
test("appends full command when input contains a chain that differs from the sub-command", () => {
|
|
155
|
+
const result = formatAskPrompt(
|
|
156
|
+
toolResult("bash", { command: "rm -rf ." }),
|
|
157
|
+
undefined,
|
|
158
|
+
{ command: 'echo "hello" && rm -rf .' },
|
|
159
|
+
makeFormatter(),
|
|
160
|
+
);
|
|
161
|
+
expect(result).toBe(
|
|
162
|
+
`Current agent requested bash command 'rm -rf .' (full command: 'echo "hello" && rm -rf .'). Allow this command?`,
|
|
163
|
+
);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test("suppresses full-command suffix when input command matches the sub-command (no chain)", () => {
|
|
167
|
+
const result = formatAskPrompt(
|
|
168
|
+
toolResult("bash", { command: "git push" }),
|
|
169
|
+
undefined,
|
|
170
|
+
{ command: "git push" },
|
|
171
|
+
makeFormatter(),
|
|
172
|
+
);
|
|
173
|
+
expect(result).not.toContain("full command:");
|
|
174
|
+
expect(result).toBe(
|
|
175
|
+
"Current agent requested bash command 'git push'. Allow this command?",
|
|
176
|
+
);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test("suppresses full-command suffix when input is undefined", () => {
|
|
180
|
+
const result = formatAskPrompt(
|
|
181
|
+
toolResult("bash", { command: "git push" }),
|
|
182
|
+
undefined,
|
|
183
|
+
undefined,
|
|
184
|
+
makeFormatter(),
|
|
185
|
+
);
|
|
186
|
+
expect(result).not.toContain("full command:");
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test("suppresses full-command suffix when input has no command field", () => {
|
|
190
|
+
const result = formatAskPrompt(
|
|
191
|
+
toolResult("bash", { command: "git push" }),
|
|
192
|
+
undefined,
|
|
193
|
+
{ unrelated: "value" },
|
|
194
|
+
makeFormatter(),
|
|
195
|
+
);
|
|
196
|
+
expect(result).not.toContain("full command:");
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test("suppresses full-command suffix when input command is empty", () => {
|
|
200
|
+
const result = formatAskPrompt(
|
|
201
|
+
toolResult("bash", { command: "git push" }),
|
|
202
|
+
undefined,
|
|
203
|
+
{ command: "" },
|
|
204
|
+
makeFormatter(),
|
|
205
|
+
);
|
|
206
|
+
expect(result).not.toContain("full command:");
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test("places full-command suffix after the qualifier and before the terminal sentence", () => {
|
|
210
|
+
const result = formatAskPrompt(
|
|
211
|
+
toolResult("bash", { command: "rm -rf foo", matchedPattern: "rm *" }),
|
|
212
|
+
undefined,
|
|
213
|
+
{ command: "cd /tmp && rm -rf foo" },
|
|
214
|
+
makeFormatter(),
|
|
215
|
+
);
|
|
216
|
+
expect(result).toBe(
|
|
217
|
+
"Current agent requested bash command 'rm -rf foo' (matched 'rm *') (full command: 'cd /tmp && rm -rf foo'). Allow this command?",
|
|
218
|
+
);
|
|
219
|
+
});
|
|
220
|
+
|
|
154
221
|
test("formats bash prompt with nested execution context", () => {
|
|
155
222
|
const result = formatAskPrompt(
|
|
156
223
|
toolResult("bash", {
|
|
@@ -388,4 +388,35 @@ describe("PermissionSession", () => {
|
|
|
388
388
|
expect(session.getRuntimeContext()).toBeNull();
|
|
389
389
|
});
|
|
390
390
|
});
|
|
391
|
+
|
|
392
|
+
describe("notify", () => {
|
|
393
|
+
it("forwards the message to ctx.ui.notify with 'warning' severity after activation", () => {
|
|
394
|
+
const { session } = createSession();
|
|
395
|
+
const ctx = makeCtx();
|
|
396
|
+
session.activate(ctx);
|
|
397
|
+
|
|
398
|
+
session.notify("something went wrong");
|
|
399
|
+
|
|
400
|
+
expect(ctx.ui.notify).toHaveBeenCalledOnce();
|
|
401
|
+
expect(ctx.ui.notify).toHaveBeenCalledWith(
|
|
402
|
+
"something went wrong",
|
|
403
|
+
"warning",
|
|
404
|
+
);
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
it("is a no-op and does not throw before activation", () => {
|
|
408
|
+
const { session } = createSession();
|
|
409
|
+
|
|
410
|
+
expect(() => session.notify("msg")).not.toThrow();
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
it("is a no-op and does not throw after deactivation", () => {
|
|
414
|
+
const { session } = createSession();
|
|
415
|
+
const ctx = makeCtx();
|
|
416
|
+
session.activate(ctx);
|
|
417
|
+
session.deactivate();
|
|
418
|
+
|
|
419
|
+
expect(() => session.notify("msg")).not.toThrow();
|
|
420
|
+
});
|
|
421
|
+
});
|
|
391
422
|
});
|
|
@@ -8,7 +8,7 @@ import {
|
|
|
8
8
|
type PermissionSystemExtensionConfig,
|
|
9
9
|
} from "#src/extension-config";
|
|
10
10
|
import type { SessionLoggerDeps } from "#src/session-logger";
|
|
11
|
-
import {
|
|
11
|
+
import { PermissionSessionLogger } from "#src/session-logger";
|
|
12
12
|
|
|
13
13
|
// ── helpers ────────────────────────────────────────────────────────────────
|
|
14
14
|
|
|
@@ -42,9 +42,9 @@ function makeBlockedLogsDir(): string {
|
|
|
42
42
|
return join(barrier, "logs");
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
-
// ──
|
|
45
|
+
// ── PermissionSessionLogger ────────────────────────────────────────────────────
|
|
46
46
|
|
|
47
|
-
describe("
|
|
47
|
+
describe("PermissionSessionLogger", () => {
|
|
48
48
|
// ── debug ────────────────────────────────────────────────────────────────
|
|
49
49
|
|
|
50
50
|
describe("debug", () => {
|
|
@@ -52,7 +52,7 @@ describe("createSessionLogger", () => {
|
|
|
52
52
|
const deps = makeDeps({
|
|
53
53
|
getConfig: () => ({ ...DEFAULT_EXTENSION_CONFIG, debugLog: true }),
|
|
54
54
|
});
|
|
55
|
-
const logger =
|
|
55
|
+
const logger = new PermissionSessionLogger(deps);
|
|
56
56
|
|
|
57
57
|
logger.debug("test.event", { key: "value" });
|
|
58
58
|
|
|
@@ -63,7 +63,7 @@ describe("createSessionLogger", () => {
|
|
|
63
63
|
it("does not write to the debug log when debugLog is false", () => {
|
|
64
64
|
// DEFAULT_EXTENSION_CONFIG.debugLog === false
|
|
65
65
|
const deps = makeDeps();
|
|
66
|
-
const logger =
|
|
66
|
+
const logger = new PermissionSessionLogger(deps);
|
|
67
67
|
|
|
68
68
|
logger.debug("test.event");
|
|
69
69
|
|
|
@@ -76,7 +76,7 @@ describe("createSessionLogger", () => {
|
|
|
76
76
|
const deps = makeDeps({
|
|
77
77
|
getConfig: () => ({ ...DEFAULT_EXTENSION_CONFIG, debugLog }),
|
|
78
78
|
});
|
|
79
|
-
const logger =
|
|
79
|
+
const logger = new PermissionSessionLogger(deps);
|
|
80
80
|
debugLog = false;
|
|
81
81
|
|
|
82
82
|
logger.debug("test.event");
|
|
@@ -91,7 +91,7 @@ describe("createSessionLogger", () => {
|
|
|
91
91
|
it("writes a JSONL line to the review log file when permissionReviewLog is true", () => {
|
|
92
92
|
// DEFAULT_EXTENSION_CONFIG.permissionReviewLog === true
|
|
93
93
|
const deps = makeDeps();
|
|
94
|
-
const logger =
|
|
94
|
+
const logger = new PermissionSessionLogger(deps);
|
|
95
95
|
|
|
96
96
|
logger.review("permission.granted", { agentName: "coder" });
|
|
97
97
|
|
|
@@ -106,7 +106,7 @@ describe("createSessionLogger", () => {
|
|
|
106
106
|
permissionReviewLog: false,
|
|
107
107
|
}),
|
|
108
108
|
});
|
|
109
|
-
const logger =
|
|
109
|
+
const logger = new PermissionSessionLogger(deps);
|
|
110
110
|
|
|
111
111
|
logger.review("permission.granted");
|
|
112
112
|
|
|
@@ -123,7 +123,7 @@ describe("createSessionLogger", () => {
|
|
|
123
123
|
globalLogsDir: makeBlockedLogsDir(),
|
|
124
124
|
getConfig: () => ({ ...DEFAULT_EXTENSION_CONFIG, debugLog: true }),
|
|
125
125
|
});
|
|
126
|
-
const logger =
|
|
126
|
+
const logger = new PermissionSessionLogger(deps);
|
|
127
127
|
|
|
128
128
|
logger.debug("test.event");
|
|
129
129
|
|
|
@@ -138,7 +138,7 @@ describe("createSessionLogger", () => {
|
|
|
138
138
|
globalLogsDir: makeBlockedLogsDir(),
|
|
139
139
|
getConfig: () => ({ ...DEFAULT_EXTENSION_CONFIG, debugLog: true }),
|
|
140
140
|
});
|
|
141
|
-
const logger =
|
|
141
|
+
const logger = new PermissionSessionLogger(deps);
|
|
142
142
|
|
|
143
143
|
logger.debug("event.one");
|
|
144
144
|
logger.debug("event.two");
|
|
@@ -155,7 +155,7 @@ describe("createSessionLogger", () => {
|
|
|
155
155
|
permissionReviewLog: true,
|
|
156
156
|
}),
|
|
157
157
|
});
|
|
158
|
-
const logger =
|
|
158
|
+
const logger = new PermissionSessionLogger(deps);
|
|
159
159
|
|
|
160
160
|
logger.debug("event.one"); // emits warning
|
|
161
161
|
logger.review("event.two"); // same error message → suppressed
|
|
@@ -169,7 +169,7 @@ describe("createSessionLogger", () => {
|
|
|
169
169
|
describe("warn", () => {
|
|
170
170
|
it("calls notify with the message directly", () => {
|
|
171
171
|
const deps = makeDeps();
|
|
172
|
-
const logger =
|
|
172
|
+
const logger = new PermissionSessionLogger(deps);
|
|
173
173
|
|
|
174
174
|
logger.warn("Something went wrong");
|
|
175
175
|
|
|
@@ -178,7 +178,7 @@ describe("createSessionLogger", () => {
|
|
|
178
178
|
|
|
179
179
|
it("calls notify for every warn — not deduplicated", () => {
|
|
180
180
|
const deps = makeDeps();
|
|
181
|
-
const logger =
|
|
181
|
+
const logger = new PermissionSessionLogger(deps);
|
|
182
182
|
|
|
183
183
|
logger.warn("same message");
|
|
184
184
|
logger.warn("same message");
|
|
@@ -192,7 +192,7 @@ describe("createSessionLogger", () => {
|
|
|
192
192
|
getConfig: () => ({ ...DEFAULT_EXTENSION_CONFIG }),
|
|
193
193
|
notify: () => {},
|
|
194
194
|
};
|
|
195
|
-
const logger =
|
|
195
|
+
const logger = new PermissionSessionLogger(deps);
|
|
196
196
|
|
|
197
197
|
expect(() => logger.warn("test")).not.toThrow();
|
|
198
198
|
});
|