@gotgenes/pi-permission-system 14.0.0 → 15.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +23 -0
- package/package.json +1 -1
- package/src/bash-arity.ts +24 -0
- package/src/handlers/before-agent-start.ts +19 -37
- package/src/input-normalizer.ts +7 -1
- package/src/pattern-suggest.ts +8 -4
- package/src/permission-manager.ts +0 -5
- package/src/permission-resolver.ts +0 -4
- package/src/permission-session.ts +5 -14
- package/src/system-prompt-sanitizer.ts +50 -7
- package/test/bash-arity.test.ts +39 -1
- package/test/handlers/before-agent-start.test.ts +107 -2
- package/test/helpers/session-fixtures.ts +0 -1
- package/test/input-normalizer.test.ts +28 -0
- package/test/pattern-suggest.test.ts +24 -0
- package/test/permission-manager-unified.test.ts +0 -1
- package/test/permission-resolver.test.ts +0 -14
- package/test/permission-session.test.ts +1 -45
- package/test/system-prompt-sanitizer.test.ts +109 -22
- package/src/before-agent-start-cache.ts +0 -37
- package/src/cache-key-gate.ts +0 -32
- package/test/before-agent-start-cache.test.ts +0 -59
- package/test/cache-key-gate.test.ts +0 -85
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,29 @@ 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
|
+
## [15.0.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v14.0.1...pi-permission-system-v15.0.0) (2026-06-20)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### ⚠ BREAKING CHANGES
|
|
12
|
+
|
|
13
|
+
* the wire system prompt now lists the active tools (narrowed to the permission-allowed set) in the `Available tools:` section. Previously the permission system removed that section entirely, so the model saw no tool listing. Sessions that relied on the empty-listing behavior will now see the narrowed listing.
|
|
14
|
+
|
|
15
|
+
### Bug Fixes
|
|
16
|
+
|
|
17
|
+
* narrow the Available tools section to the active set instead of stripping it ([#437](https://github.com/gotgenes/pi-packages/issues/437)) ([dc0b97d](https://github.com/gotgenes/pi-packages/commit/dc0b97d7571d6f3a5cf0b0e15172f0d2d92b050a))
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
### Documentation
|
|
21
|
+
|
|
22
|
+
* describe Available-tools narrowing and drop the prompt-cache module ([#437](https://github.com/gotgenes/pi-packages/issues/437)) ([4112057](https://github.com/gotgenes/pi-packages/commit/411205711c5574aebb7add9edf9e035d21614946))
|
|
23
|
+
|
|
24
|
+
## [14.0.1](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v14.0.0...pi-permission-system-v14.0.1) (2026-06-19)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
### Bug Fixes
|
|
28
|
+
|
|
29
|
+
* **pi-permission-system:** strip shell comment lines from bash commands before matching ([d045591](https://github.com/gotgenes/pi-packages/commit/d0455915d6d4ce50534884639e516a9e1ef38976))
|
|
30
|
+
|
|
8
31
|
## [14.0.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v13.2.0...pi-permission-system-v14.0.0) (2026-06-17)
|
|
9
32
|
|
|
10
33
|
|
package/package.json
CHANGED
package/src/bash-arity.ts
CHANGED
|
@@ -184,3 +184,27 @@ export function prefix(tokens: string[]): string[] {
|
|
|
184
184
|
// Unknown command — default arity 1.
|
|
185
185
|
return [tokens[0]];
|
|
186
186
|
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Remove shell comment lines from a bash command string.
|
|
190
|
+
*
|
|
191
|
+
* A comment line is one whose first non-whitespace character is `#`. Agents
|
|
192
|
+
* frequently prepend descriptive comments before the real command
|
|
193
|
+
* (e.g. `"# Check debug logs\nfind ..."`); such prefixes defeat wildcard
|
|
194
|
+
* pattern matching and session-approval suggestions, which tokenize the
|
|
195
|
+
* leading text. Stripping comment lines lets matching operate on the actual
|
|
196
|
+
* command.
|
|
197
|
+
*
|
|
198
|
+
* The original command is never returned: when every line is a comment (or
|
|
199
|
+
* the input is blank) an empty string is returned, and each caller applies
|
|
200
|
+
* its own fallback.
|
|
201
|
+
*
|
|
202
|
+
* @param command - Raw bash command, possibly multi-line.
|
|
203
|
+
* @returns The command with comment lines removed and surrounding whitespace
|
|
204
|
+
* trimmed, or an empty string when nothing meaningful remains.
|
|
205
|
+
*/
|
|
206
|
+
export function stripBashCommentLines(command: string): string {
|
|
207
|
+
const lines = command.split("\n");
|
|
208
|
+
const meaningful = lines.filter((line) => !/^\s*#/.test(line));
|
|
209
|
+
return meaningful.join("\n").trim();
|
|
210
|
+
}
|
|
@@ -2,10 +2,6 @@ import type {
|
|
|
2
2
|
BeforeAgentStartEventResult,
|
|
3
3
|
ExtensionContext,
|
|
4
4
|
} from "@earendil-works/pi-coding-agent";
|
|
5
|
-
import {
|
|
6
|
-
createActiveToolsCacheKey,
|
|
7
|
-
createBeforeAgentStartPromptStateKey,
|
|
8
|
-
} from "#src/before-agent-start-cache";
|
|
9
5
|
import type { PermissionResolver } from "#src/permission-resolver";
|
|
10
6
|
import type { PermissionSession } from "#src/permission-session";
|
|
11
7
|
import { resolveSkillPromptEntries } from "#src/skill-prompt-sanitizer";
|
|
@@ -35,9 +31,14 @@ export function shouldExposeTool(
|
|
|
35
31
|
/**
|
|
36
32
|
* Handles the `before_agent_start` event: tool filtering + prompt sanitization.
|
|
37
33
|
*
|
|
34
|
+
* Recomputes the active tool set and the returned system-prompt override on
|
|
35
|
+
* every fire (no memoization): the override must be returned each turn so that
|
|
36
|
+
* skill filtering is reapplied and the wire prompt stays byte-stable, rather
|
|
37
|
+
* than letting Pi reset to its skill-unfiltered base prompt on a cache hit.
|
|
38
|
+
*
|
|
38
39
|
* Constructor deps:
|
|
39
40
|
* - `session` — encapsulates all mutable session state and lifecycle operations
|
|
40
|
-
* - `resolver` — owns permission-query surface: `getToolPermission`,
|
|
41
|
+
* - `resolver` — owns permission-query surface: `getToolPermission`, skill check
|
|
41
42
|
* - `toolRegistry` — Pi tool API subset (getActive + setActive)
|
|
42
43
|
*/
|
|
43
44
|
export class AgentPrepHandler {
|
|
@@ -73,40 +74,21 @@ export class AgentPrepHandler {
|
|
|
73
74
|
}
|
|
74
75
|
}
|
|
75
76
|
|
|
76
|
-
|
|
77
|
-
this.session.activeToolsGate.runIfChanged(activeToolsCacheKey, () => {
|
|
78
|
-
this.toolRegistry.setActive(allowedTools);
|
|
79
|
-
});
|
|
77
|
+
this.toolRegistry.setActive(allowedTools);
|
|
80
78
|
|
|
81
|
-
const
|
|
79
|
+
const toolPromptResult = sanitizeAvailableToolsSection(
|
|
80
|
+
event.systemPrompt,
|
|
81
|
+
allowedTools,
|
|
82
|
+
);
|
|
83
|
+
const skillPromptResult = resolveSkillPromptEntries(
|
|
84
|
+
toolPromptResult.prompt,
|
|
85
|
+
this.resolver,
|
|
82
86
|
agentName,
|
|
83
|
-
|
|
84
|
-
permissionStamp: this.resolver.getPolicyCacheStamp(
|
|
85
|
-
agentName ?? undefined,
|
|
86
|
-
),
|
|
87
|
-
systemPrompt: event.systemPrompt,
|
|
88
|
-
allowedToolNames: allowedTools,
|
|
89
|
-
});
|
|
90
|
-
|
|
91
|
-
const promptResult = this.session.promptStateGate.runIfChanged(
|
|
92
|
-
promptStateCacheKey,
|
|
93
|
-
() => {
|
|
94
|
-
const toolPromptResult = sanitizeAvailableToolsSection(
|
|
95
|
-
event.systemPrompt,
|
|
96
|
-
allowedTools,
|
|
97
|
-
);
|
|
98
|
-
const skillPromptResult = resolveSkillPromptEntries(
|
|
99
|
-
toolPromptResult.prompt,
|
|
100
|
-
this.resolver,
|
|
101
|
-
agentName,
|
|
102
|
-
ctx.cwd,
|
|
103
|
-
);
|
|
104
|
-
this.session.setActiveSkillEntries(skillPromptResult.entries);
|
|
105
|
-
return skillPromptResult.prompt !== event.systemPrompt
|
|
106
|
-
? { systemPrompt: skillPromptResult.prompt }
|
|
107
|
-
: {};
|
|
108
|
-
},
|
|
87
|
+
ctx.cwd,
|
|
109
88
|
);
|
|
110
|
-
|
|
89
|
+
this.session.setActiveSkillEntries(skillPromptResult.entries);
|
|
90
|
+
return skillPromptResult.prompt !== event.systemPrompt
|
|
91
|
+
? { systemPrompt: skillPromptResult.prompt }
|
|
92
|
+
: {};
|
|
111
93
|
}
|
|
112
94
|
}
|
package/src/input-normalizer.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { stripBashCommentLines } from "./bash-arity";
|
|
1
2
|
import { getNonEmptyString, toRecord } from "./common";
|
|
2
3
|
import { createMcpPermissionTargets } from "./mcp-targets";
|
|
3
4
|
import { getPathPolicyValues, PATH_BEARING_TOOLS } from "./path-utils";
|
|
@@ -92,9 +93,14 @@ export function normalizeInput(
|
|
|
92
93
|
if (toolName === "bash") {
|
|
93
94
|
const record = toRecord(input);
|
|
94
95
|
const command = typeof record.command === "string" ? record.command : "";
|
|
96
|
+
// Strip leading shell comment lines so pattern matching operates on the
|
|
97
|
+
// actual command, not a `# description` prefix agents often prepend.
|
|
98
|
+
// Fall back to the raw command when stripping leaves nothing, so an
|
|
99
|
+
// all-comment command still evaluates against its literal text.
|
|
100
|
+
const matchValue = stripBashCommentLines(command) || command;
|
|
95
101
|
return {
|
|
96
102
|
surface: "bash",
|
|
97
|
-
values: [
|
|
103
|
+
values: [matchValue],
|
|
98
104
|
resultExtras: { command },
|
|
99
105
|
};
|
|
100
106
|
}
|
package/src/pattern-suggest.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { prefix } from "./bash-arity";
|
|
1
|
+
import { prefix, stripBashCommentLines } from "./bash-arity";
|
|
2
2
|
import { PATH_BEARING_TOOLS } from "./path-utils";
|
|
3
3
|
import { deriveApprovalPattern } from "./session-rules";
|
|
4
4
|
|
|
@@ -26,11 +26,15 @@ export interface SessionApprovalSuggestion {
|
|
|
26
26
|
export function suggestBashPattern(command: string): string {
|
|
27
27
|
const trimmed = command.trim();
|
|
28
28
|
if (!trimmed) return "";
|
|
29
|
-
|
|
30
|
-
|
|
29
|
+
// Strip leading shell comment lines so the suggestion is based on the
|
|
30
|
+
// actual command, not a `# description` prefix agents often prepend.
|
|
31
|
+
const stripped = stripBashCommentLines(trimmed);
|
|
32
|
+
if (!stripped) return "";
|
|
33
|
+
const tokens = stripped.split(/\s+/);
|
|
34
|
+
if (tokens.length === 1) return stripped;
|
|
31
35
|
const meaningful = prefix(tokens);
|
|
32
36
|
if (meaningful.length >= tokens.length) {
|
|
33
|
-
return `${
|
|
37
|
+
return `${stripped}*`;
|
|
34
38
|
}
|
|
35
39
|
return `${meaningful.join(" ")} *`;
|
|
36
40
|
}
|
|
@@ -84,7 +84,6 @@ export interface ScopedPermissionManager {
|
|
|
84
84
|
): PermissionCheckResult;
|
|
85
85
|
getToolPermission(toolName: string, agentName?: string): PermissionState;
|
|
86
86
|
getConfigIssues(agentName?: string): string[];
|
|
87
|
-
getPolicyCacheStamp(agentName?: string): string;
|
|
88
87
|
}
|
|
89
88
|
|
|
90
89
|
export interface PermissionManagerOptions extends PolicyLoaderOptions {
|
|
@@ -144,10 +143,6 @@ export class PermissionManager implements ScopedPermissionManager {
|
|
|
144
143
|
return this.loader.getResolvedPolicyPaths();
|
|
145
144
|
}
|
|
146
145
|
|
|
147
|
-
getPolicyCacheStamp(agentName?: string): string {
|
|
148
|
-
return this.loader.getCacheStamp(agentName);
|
|
149
|
-
}
|
|
150
|
-
|
|
151
146
|
private resolvePermissions(agentName?: string): ResolvedPermissions {
|
|
152
147
|
const cacheKey = agentName ?? "__global__";
|
|
153
148
|
const stamp = this.loader.getCacheStamp(agentName);
|
|
@@ -106,8 +106,4 @@ export class PermissionResolver implements ScopedPermissionResolver {
|
|
|
106
106
|
getConfigIssues(agentName?: string): string[] {
|
|
107
107
|
return this.permissionManager.getConfigIssues(agentName);
|
|
108
108
|
}
|
|
109
|
-
|
|
110
|
-
getPolicyCacheStamp(agentName?: string): string {
|
|
111
|
-
return this.permissionManager.getPolicyCacheStamp(agentName);
|
|
112
|
-
}
|
|
113
109
|
}
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import { CacheKeyGate } from "#src/cache-key-gate";
|
|
3
2
|
import {
|
|
4
3
|
getActiveAgentName,
|
|
5
4
|
getActiveAgentNameFromSystemPrompt,
|
|
@@ -38,8 +37,6 @@ export class PermissionSession implements ToolCallGateInputs {
|
|
|
38
37
|
private context: ExtensionContext | null = null;
|
|
39
38
|
private skillEntries: SkillPromptEntry[] = [];
|
|
40
39
|
private knownAgentName: string | null = null;
|
|
41
|
-
readonly activeToolsGate = new CacheKeyGate();
|
|
42
|
-
readonly promptStateGate = new CacheKeyGate();
|
|
43
40
|
|
|
44
41
|
constructor(
|
|
45
42
|
private readonly paths: ExtensionPaths,
|
|
@@ -83,38 +80,32 @@ export class PermissionSession implements ToolCallGateInputs {
|
|
|
83
80
|
/**
|
|
84
81
|
* Reset all mutable state for a new session.
|
|
85
82
|
*
|
|
86
|
-
* Configures the injected PermissionManager for `ctx.cwd`, clears
|
|
87
|
-
*
|
|
83
|
+
* Configures the injected PermissionManager for `ctx.cwd`, clears skill
|
|
84
|
+
* entries, and activates the new context.
|
|
88
85
|
*/
|
|
89
86
|
resetForNewSession(ctx: ExtensionContext): void {
|
|
90
87
|
this.permissionManager.configureForCwd(ctx.cwd);
|
|
91
88
|
this.skillEntries = [];
|
|
92
|
-
this.activeToolsGate.reset();
|
|
93
|
-
this.promptStateGate.reset();
|
|
94
89
|
this.activate(ctx);
|
|
95
90
|
}
|
|
96
91
|
|
|
97
92
|
/**
|
|
98
|
-
* Shut down the session: clear rules,
|
|
99
|
-
*
|
|
93
|
+
* Shut down the session: clear rules, skill entries, and deactivate
|
|
94
|
+
* context + forwarding.
|
|
100
95
|
*/
|
|
101
96
|
shutdown(): void {
|
|
102
97
|
this.sessionRules.clear();
|
|
103
98
|
this.skillEntries = [];
|
|
104
|
-
this.activeToolsGate.reset();
|
|
105
|
-
this.promptStateGate.reset();
|
|
106
99
|
this.deactivate();
|
|
107
100
|
}
|
|
108
101
|
|
|
109
102
|
/**
|
|
110
|
-
* Reload permission manager and clear
|
|
103
|
+
* Reload permission manager and clear skill entries for the current context.
|
|
111
104
|
* Used on config reload (e.g. `resources_discover` with reason "reload").
|
|
112
105
|
*/
|
|
113
106
|
reload(): void {
|
|
114
107
|
this.permissionManager.configureForCwd(this.context?.cwd);
|
|
115
108
|
this.skillEntries = [];
|
|
116
|
-
this.activeToolsGate.reset();
|
|
117
|
-
this.promptStateGate.reset();
|
|
118
109
|
}
|
|
119
110
|
|
|
120
111
|
// ── Skill entries ──────────────────────────────────────────────────────
|
|
@@ -135,16 +135,59 @@ function findSection(
|
|
|
135
135
|
return { start, end };
|
|
136
136
|
}
|
|
137
137
|
|
|
138
|
-
|
|
138
|
+
/**
|
|
139
|
+
* Tool name from an `Available tools:` bullet (`- read: …` -> `read`), or
|
|
140
|
+
* `null` for non-tool lines (blank lines, boilerplate prose). Matches the
|
|
141
|
+
* first token after the bullet marker, with or without a trailing colon.
|
|
142
|
+
*/
|
|
143
|
+
function extractToolBulletName(line: string): string | null {
|
|
144
|
+
const match = /^\s*-\s+([A-Za-z0-9_-]+)/.exec(line);
|
|
145
|
+
return match ? match[1] : null;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Narrow the `Available tools:` section to the allowed tools: keep allowed-tool
|
|
150
|
+
* bullet lines and any non-tool prose, drop denied/inactive bullet lines. When
|
|
151
|
+
* no tool bullet survives, remove the section header too. This mirrors what Pi
|
|
152
|
+
* itself renders for the active tool set, so the result is byte-stable across
|
|
153
|
+
* turns regardless of whether the input still carries the full default listing.
|
|
154
|
+
*/
|
|
155
|
+
function narrowAvailableToolsSection(
|
|
139
156
|
lines: readonly string[],
|
|
140
|
-
|
|
157
|
+
allowedTools: ReadonlySet<string>,
|
|
141
158
|
): { lines: string[]; removed: boolean } {
|
|
159
|
+
const section = findSection(lines, AVAILABLE_TOOLS_SECTION_HEADER);
|
|
142
160
|
if (!section) {
|
|
143
161
|
return { lines: [...lines], removed: false };
|
|
144
162
|
}
|
|
145
163
|
|
|
164
|
+
const before = lines.slice(0, section.start);
|
|
165
|
+
const header = lines[section.start];
|
|
166
|
+
const body = lines.slice(section.start + 1, section.end);
|
|
167
|
+
const after = lines.slice(section.end);
|
|
168
|
+
|
|
169
|
+
const filteredBody = body.filter((line) => {
|
|
170
|
+
const toolName = extractToolBulletName(line);
|
|
171
|
+
if (toolName === null) {
|
|
172
|
+
return true; // keep blank lines and non-tool boilerplate
|
|
173
|
+
}
|
|
174
|
+
return allowedTools.has(toolName);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
const removed = filteredBody.length !== body.length;
|
|
178
|
+
if (!removed) {
|
|
179
|
+
return { lines: [...lines], removed: false };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const hasToolBullet = filteredBody.some(
|
|
183
|
+
(line) => extractToolBulletName(line) !== null,
|
|
184
|
+
);
|
|
185
|
+
if (!hasToolBullet) {
|
|
186
|
+
return { lines: [...before, ...after], removed: true };
|
|
187
|
+
}
|
|
188
|
+
|
|
146
189
|
return {
|
|
147
|
-
lines: [...
|
|
190
|
+
lines: [...before, header, ...filteredBody, ...after],
|
|
148
191
|
removed: true,
|
|
149
192
|
};
|
|
150
193
|
}
|
|
@@ -212,15 +255,15 @@ export function sanitizeAvailableToolsSection(
|
|
|
212
255
|
allowedToolNames.map((toolName) => toolName.trim()).filter(Boolean),
|
|
213
256
|
);
|
|
214
257
|
const normalizedLines = normalizePrompt(systemPrompt).split("\n");
|
|
215
|
-
const
|
|
258
|
+
const narrowedToolsSection = narrowAvailableToolsSection(
|
|
216
259
|
normalizedLines,
|
|
217
|
-
|
|
260
|
+
allowedTools,
|
|
218
261
|
);
|
|
219
262
|
const sanitizedGuidelines = sanitizeGuidelinesSection(
|
|
220
|
-
|
|
263
|
+
narrowedToolsSection.lines,
|
|
221
264
|
allowedTools,
|
|
222
265
|
);
|
|
223
|
-
const removed =
|
|
266
|
+
const removed = narrowedToolsSection.removed || sanitizedGuidelines.removed;
|
|
224
267
|
|
|
225
268
|
return {
|
|
226
269
|
prompt: removed
|
package/test/bash-arity.test.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
|
-
import { ARITY, prefix } from "#src/bash-arity";
|
|
2
|
+
import { ARITY, prefix, stripBashCommentLines } from "#src/bash-arity";
|
|
3
3
|
|
|
4
4
|
describe("ARITY dictionary", () => {
|
|
5
5
|
it("is exported as a plain object", () => {
|
|
@@ -104,3 +104,41 @@ describe("prefix", () => {
|
|
|
104
104
|
expect(prefix(["ls", "-la", "/tmp"])).toEqual(["ls"]);
|
|
105
105
|
});
|
|
106
106
|
});
|
|
107
|
+
|
|
108
|
+
describe("stripBashCommentLines", () => {
|
|
109
|
+
it("removes a single leading comment line", () => {
|
|
110
|
+
expect(
|
|
111
|
+
stripBashCommentLines("# Check debug logs\nfind /home -type f"),
|
|
112
|
+
).toBe("find /home -type f");
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("removes multiple leading comment lines", () => {
|
|
116
|
+
expect(
|
|
117
|
+
stripBashCommentLines("# Step 1\n# Step 2\ngit status --short"),
|
|
118
|
+
).toBe("git status --short");
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("returns empty string when all lines are comments", () => {
|
|
122
|
+
expect(stripBashCommentLines("# just a comment")).toBe("");
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("returns empty string for blank input", () => {
|
|
126
|
+
expect(stripBashCommentLines("")).toBe("");
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("returns the command unchanged when no comment lines are present", () => {
|
|
130
|
+
expect(stripBashCommentLines("grep -rn foo src/")).toBe(
|
|
131
|
+
"grep -rn foo src/",
|
|
132
|
+
);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("trims surrounding whitespace from the result", () => {
|
|
136
|
+
expect(stripBashCommentLines("\n\n ls -la \n")).toBe("ls -la");
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("treats indented comment lines as comments", () => {
|
|
140
|
+
expect(stripBashCommentLines(" # indented comment\necho hi")).toBe(
|
|
141
|
+
"echo hi",
|
|
142
|
+
);
|
|
143
|
+
});
|
|
144
|
+
});
|
|
@@ -171,7 +171,7 @@ describe("AgentPrepHandler.handle", () => {
|
|
|
171
171
|
]);
|
|
172
172
|
});
|
|
173
173
|
|
|
174
|
-
it("calls setActive
|
|
174
|
+
it("calls setActive on every turn (no dedup gate)", async () => {
|
|
175
175
|
const { handler, toolRegistry } = makeSetup({
|
|
176
176
|
toolRegistry: {
|
|
177
177
|
getActive: vi.fn().mockReturnValue(["read"]),
|
|
@@ -179,7 +179,40 @@ describe("AgentPrepHandler.handle", () => {
|
|
|
179
179
|
});
|
|
180
180
|
await handler.handle(makeEvent(), makeCtx());
|
|
181
181
|
await handler.handle(makeEvent(), makeCtx());
|
|
182
|
-
expect(toolRegistry.setActive).
|
|
182
|
+
expect(toolRegistry.setActive).toHaveBeenCalledTimes(2);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it("filters a denied skill from the systemPrompt on every turn, not just the first", async () => {
|
|
186
|
+
const systemPrompt = [
|
|
187
|
+
"You are an assistant.",
|
|
188
|
+
"",
|
|
189
|
+
"<available_skills>",
|
|
190
|
+
" <skill>",
|
|
191
|
+
" <name>secret</name>",
|
|
192
|
+
" <description>A denied skill</description>",
|
|
193
|
+
" <location>/skills/secret/SKILL.md</location>",
|
|
194
|
+
" </skill>",
|
|
195
|
+
"</available_skills>",
|
|
196
|
+
].join("\n");
|
|
197
|
+
const { handler, permissionManager } = makeSetup();
|
|
198
|
+
vi.mocked(permissionManager.checkPermission).mockImplementation(
|
|
199
|
+
(surface) =>
|
|
200
|
+
surface === "skill"
|
|
201
|
+
? makeCheckResult({ state: "deny" })
|
|
202
|
+
: makeCheckResult(),
|
|
203
|
+
);
|
|
204
|
+
|
|
205
|
+
const first = await handler.handle(makeEvent(systemPrompt), makeCtx());
|
|
206
|
+
const second = await handler.handle(makeEvent(systemPrompt), makeCtx());
|
|
207
|
+
|
|
208
|
+
expect(first).toHaveProperty("systemPrompt");
|
|
209
|
+
expect((first as { systemPrompt: string }).systemPrompt).not.toContain(
|
|
210
|
+
"secret",
|
|
211
|
+
);
|
|
212
|
+
expect(second).toHaveProperty("systemPrompt");
|
|
213
|
+
expect((second as { systemPrompt: string }).systemPrompt).not.toContain(
|
|
214
|
+
"secret",
|
|
215
|
+
);
|
|
183
216
|
});
|
|
184
217
|
|
|
185
218
|
it("returns empty object on repeated calls with unchanged inputs", async () => {
|
|
@@ -209,4 +242,76 @@ describe("AgentPrepHandler.handle", () => {
|
|
|
209
242
|
const result = await handler.handle(makeEvent(prompt), makeCtx());
|
|
210
243
|
expect(result).toEqual({});
|
|
211
244
|
});
|
|
245
|
+
|
|
246
|
+
it("narrows a denied tool out of the Available tools listing without removing the section", async () => {
|
|
247
|
+
const systemPrompt = [
|
|
248
|
+
"Available tools:",
|
|
249
|
+
"- read: Read file contents",
|
|
250
|
+
"- bash: Run shell commands",
|
|
251
|
+
].join("\n");
|
|
252
|
+
const { handler, permissionManager } = makeSetup({
|
|
253
|
+
toolRegistry: {
|
|
254
|
+
getActive: vi.fn().mockReturnValue(["read", "bash"]),
|
|
255
|
+
},
|
|
256
|
+
});
|
|
257
|
+
vi.mocked(permissionManager.getToolPermission).mockImplementation((tool) =>
|
|
258
|
+
tool === "bash" ? "deny" : "allow",
|
|
259
|
+
);
|
|
260
|
+
|
|
261
|
+
const result = await handler.handle(makeEvent(systemPrompt), makeCtx());
|
|
262
|
+
|
|
263
|
+
expect(result.systemPrompt).toBeDefined();
|
|
264
|
+
const out = result.systemPrompt ?? "";
|
|
265
|
+
expect(out).toContain("Available tools:");
|
|
266
|
+
expect(out).toContain("- read: Read file contents");
|
|
267
|
+
expect(out).not.toContain("- bash");
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it("keeps the wire system prompt byte-stable across the tool-listing drift between turns", async () => {
|
|
271
|
+
const fullProse = [
|
|
272
|
+
"You are an assistant.",
|
|
273
|
+
"",
|
|
274
|
+
"Available tools:",
|
|
275
|
+
"- bash: Run shell commands",
|
|
276
|
+
"- read: Read file contents",
|
|
277
|
+
"- edit: Edit a file",
|
|
278
|
+
"- write: Write a file",
|
|
279
|
+
"",
|
|
280
|
+
"Guidelines:",
|
|
281
|
+
"- use bash for file operations like ls, rg, find",
|
|
282
|
+
"- use read to examine files instead of cat or sed.",
|
|
283
|
+
"- Be concise in your responses",
|
|
284
|
+
].join("\n");
|
|
285
|
+
const narrowedProse = [
|
|
286
|
+
"You are an assistant.",
|
|
287
|
+
"",
|
|
288
|
+
"Available tools:",
|
|
289
|
+
"- read: Read file contents",
|
|
290
|
+
"- edit: Edit a file",
|
|
291
|
+
"- write: Write a file",
|
|
292
|
+
"",
|
|
293
|
+
"Guidelines:",
|
|
294
|
+
"- use read to examine files instead of cat or sed.",
|
|
295
|
+
"- Be concise in your responses",
|
|
296
|
+
].join("\n");
|
|
297
|
+
const { handler, permissionManager } = makeSetup({
|
|
298
|
+
toolRegistry: {
|
|
299
|
+
getActive: vi.fn().mockReturnValue(["bash", "read", "edit", "write"]),
|
|
300
|
+
},
|
|
301
|
+
});
|
|
302
|
+
vi.mocked(permissionManager.getToolPermission).mockImplementation((tool) =>
|
|
303
|
+
tool === "bash" ? "deny" : "allow",
|
|
304
|
+
);
|
|
305
|
+
|
|
306
|
+
// Turn 1: Pi feeds the full default listing.
|
|
307
|
+
const first = await handler.handle(makeEvent(fullProse), makeCtx());
|
|
308
|
+
// Turn 2: Pi's setActive rebuild means the event now carries the narrowed
|
|
309
|
+
// listing, so the override the handler returns must still match turn 1.
|
|
310
|
+
const second = await handler.handle(makeEvent(narrowedProse), makeCtx());
|
|
311
|
+
|
|
312
|
+
const wire1 = first.systemPrompt ?? fullProse;
|
|
313
|
+
const wire2 = second.systemPrompt ?? narrowedProse;
|
|
314
|
+
expect(wire1).toBe(narrowedProse);
|
|
315
|
+
expect(wire2).toBe(narrowedProse);
|
|
316
|
+
});
|
|
212
317
|
});
|
|
@@ -120,7 +120,6 @@ export function makeFakePermissionManager() {
|
|
|
120
120
|
.fn<(toolName: string, agentName?: string) => PermissionState>()
|
|
121
121
|
.mockReturnValue("allow"),
|
|
122
122
|
getConfigIssues: vi.fn((): string[] => []),
|
|
123
|
-
getPolicyCacheStamp: vi.fn((): string => "stamp-1"),
|
|
124
123
|
};
|
|
125
124
|
}
|
|
126
125
|
|
|
@@ -198,6 +198,34 @@ describe("normalizeInput — non-MCP surfaces", () => {
|
|
|
198
198
|
expect(result.values).toEqual([""]);
|
|
199
199
|
expect(result.resultExtras).toEqual({ command: "" });
|
|
200
200
|
});
|
|
201
|
+
|
|
202
|
+
it("strips leading comment lines from values but keeps original in resultExtras", () => {
|
|
203
|
+
const cmd = "# Check debug logs\nfind /home -path '*debug*' -type f";
|
|
204
|
+
const result = normalizeInput("bash", { command: cmd }, []);
|
|
205
|
+
expect(result.values).toEqual(["find /home -path '*debug*' -type f"]);
|
|
206
|
+
expect(result.resultExtras).toEqual({ command: cmd });
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it("strips multiple comment lines", () => {
|
|
210
|
+
const cmd = "# Step 1\n# Step 2\ngit status --short";
|
|
211
|
+
const result = normalizeInput("bash", { command: cmd }, []);
|
|
212
|
+
expect(result.values).toEqual(["git status --short"]);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it("preserves command when no comment lines present", () => {
|
|
216
|
+
const result = normalizeInput(
|
|
217
|
+
"bash",
|
|
218
|
+
{ command: "grep -rn foo src/" },
|
|
219
|
+
[],
|
|
220
|
+
);
|
|
221
|
+
expect(result.values).toEqual(["grep -rn foo src/"]);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it("falls back to original when all lines are comments", () => {
|
|
225
|
+
const cmd = "# just a comment";
|
|
226
|
+
const result = normalizeInput("bash", { command: cmd }, []);
|
|
227
|
+
expect(result.values).toEqual(["# just a comment"]);
|
|
228
|
+
});
|
|
201
229
|
});
|
|
202
230
|
|
|
203
231
|
describe("path-bearing tools (read, write, edit, grep, find, ls)", () => {
|
|
@@ -42,6 +42,30 @@ describe("suggestBashPattern", () => {
|
|
|
42
42
|
"docker compose up *",
|
|
43
43
|
);
|
|
44
44
|
});
|
|
45
|
+
|
|
46
|
+
it("strips leading comment lines and suggests based on the actual command", () => {
|
|
47
|
+
expect(
|
|
48
|
+
suggestBashPattern(
|
|
49
|
+
"# Check debug logs\nfind /home -path '*debug*' -type f",
|
|
50
|
+
),
|
|
51
|
+
).toBe("find *");
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("strips multiple leading comment lines", () => {
|
|
55
|
+
expect(suggestBashPattern("# Step 1\n# Step 2\ngit status --short")).toBe(
|
|
56
|
+
"git status *",
|
|
57
|
+
);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("returns empty for comment-only input", () => {
|
|
61
|
+
expect(suggestBashPattern("# just a comment")).toBe("");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("handles mixed comment and command lines", () => {
|
|
65
|
+
expect(suggestBashPattern("# description\nrm -rf ./build; echo done")).toBe(
|
|
66
|
+
"rm *",
|
|
67
|
+
);
|
|
68
|
+
});
|
|
45
69
|
});
|
|
46
70
|
|
|
47
71
|
describe("suggestMcpPattern", () => {
|
|
@@ -1586,7 +1586,6 @@ describe("PermissionManager — configureForCwd and agentDir option", () => {
|
|
|
1586
1586
|
expect(typeof scoped.checkPermission).toBe("function");
|
|
1587
1587
|
expect(typeof scoped.getToolPermission).toBe("function");
|
|
1588
1588
|
expect(typeof scoped.getConfigIssues).toBe("function");
|
|
1589
|
-
expect(typeof scoped.getPolicyCacheStamp).toBe("function");
|
|
1590
1589
|
});
|
|
1591
1590
|
|
|
1592
1591
|
it("construction with { agentDir } reads global config from getGlobalConfigPath(agentDir)", () => {
|
|
@@ -42,7 +42,6 @@ function makePermissionManager() {
|
|
|
42
42
|
.fn<(toolName: string, agentName?: string) => PermissionState>()
|
|
43
43
|
.mockReturnValue("allow"),
|
|
44
44
|
getConfigIssues: vi.fn((): string[] => []),
|
|
45
|
-
getPolicyCacheStamp: vi.fn((): string => "stamp-1"),
|
|
46
45
|
};
|
|
47
46
|
}
|
|
48
47
|
|
|
@@ -263,17 +262,4 @@ describe("PermissionResolver", () => {
|
|
|
263
262
|
expect(result).toEqual(["issue-1"]);
|
|
264
263
|
});
|
|
265
264
|
});
|
|
266
|
-
|
|
267
|
-
describe("getPolicyCacheStamp", () => {
|
|
268
|
-
it("delegates to permissionManager.getPolicyCacheStamp", () => {
|
|
269
|
-
const pm = makePermissionManager();
|
|
270
|
-
vi.mocked(pm.getPolicyCacheStamp).mockReturnValue("stamp-abc");
|
|
271
|
-
const { resolver } = makeResolver(pm);
|
|
272
|
-
|
|
273
|
-
const result = resolver.getPolicyCacheStamp("agent-1");
|
|
274
|
-
|
|
275
|
-
expect(pm.getPolicyCacheStamp).toHaveBeenCalledWith("agent-1");
|
|
276
|
-
expect(result).toBe("stamp-abc");
|
|
277
|
-
});
|
|
278
|
-
});
|
|
279
265
|
});
|
|
@@ -103,23 +103,6 @@ describe("PermissionSession", () => {
|
|
|
103
103
|
expect(pm.configureForCwd).toHaveBeenCalledWith("/new/project");
|
|
104
104
|
});
|
|
105
105
|
|
|
106
|
-
it("clears cache keys", () => {
|
|
107
|
-
const { session } = createSession();
|
|
108
|
-
// Prime both gates with a key
|
|
109
|
-
session.activeToolsGate.runIfChanged("key-1", () => {});
|
|
110
|
-
session.promptStateGate.runIfChanged("key-2", () => {});
|
|
111
|
-
|
|
112
|
-
session.resetForNewSession(makeCtx());
|
|
113
|
-
|
|
114
|
-
// After reset, the same keys should run the effect again
|
|
115
|
-
const toolsEffect = vi.fn();
|
|
116
|
-
const promptEffect = vi.fn();
|
|
117
|
-
session.activeToolsGate.runIfChanged("key-1", toolsEffect);
|
|
118
|
-
session.promptStateGate.runIfChanged("key-2", promptEffect);
|
|
119
|
-
expect(toolsEffect).toHaveBeenCalledOnce();
|
|
120
|
-
expect(promptEffect).toHaveBeenCalledOnce();
|
|
121
|
-
});
|
|
122
|
-
|
|
123
106
|
it("clears skill entries", () => {
|
|
124
107
|
const { session } = createSession();
|
|
125
108
|
session.setActiveSkillEntries([makeSkillEntry("test")]);
|
|
@@ -163,23 +146,6 @@ describe("PermissionSession", () => {
|
|
|
163
146
|
expect(sessionRules.getRuleset()).toEqual([]);
|
|
164
147
|
});
|
|
165
148
|
|
|
166
|
-
it("clears cache keys", () => {
|
|
167
|
-
const { session } = createSession();
|
|
168
|
-
// Prime both gates with a key
|
|
169
|
-
session.activeToolsGate.runIfChanged("k1", () => {});
|
|
170
|
-
session.promptStateGate.runIfChanged("k2", () => {});
|
|
171
|
-
|
|
172
|
-
session.shutdown();
|
|
173
|
-
|
|
174
|
-
// After shutdown, the same keys should run the effect again
|
|
175
|
-
const toolsEffect = vi.fn();
|
|
176
|
-
const promptEffect = vi.fn();
|
|
177
|
-
session.activeToolsGate.runIfChanged("k1", toolsEffect);
|
|
178
|
-
session.promptStateGate.runIfChanged("k2", promptEffect);
|
|
179
|
-
expect(toolsEffect).toHaveBeenCalledOnce();
|
|
180
|
-
expect(promptEffect).toHaveBeenCalledOnce();
|
|
181
|
-
});
|
|
182
|
-
|
|
183
149
|
it("clears skill entries", () => {
|
|
184
150
|
const { session } = createSession();
|
|
185
151
|
session.setActiveSkillEntries([makeSkillEntry("s")]);
|
|
@@ -333,22 +299,12 @@ describe("PermissionSession", () => {
|
|
|
333
299
|
expect(pm.configureForCwd).toHaveBeenCalledWith("/project");
|
|
334
300
|
});
|
|
335
301
|
|
|
336
|
-
it("clears
|
|
302
|
+
it("clears skill entries", () => {
|
|
337
303
|
const { session } = createSession();
|
|
338
|
-
// Prime both gates with a key
|
|
339
|
-
session.activeToolsGate.runIfChanged("k1", () => {});
|
|
340
|
-
session.promptStateGate.runIfChanged("k2", () => {});
|
|
341
304
|
session.setActiveSkillEntries([makeSkillEntry("s")]);
|
|
342
305
|
|
|
343
306
|
session.reload();
|
|
344
307
|
|
|
345
|
-
// After reload, the same keys should run the effect again
|
|
346
|
-
const toolsEffect = vi.fn();
|
|
347
|
-
const promptEffect = vi.fn();
|
|
348
|
-
session.activeToolsGate.runIfChanged("k1", toolsEffect);
|
|
349
|
-
session.promptStateGate.runIfChanged("k2", promptEffect);
|
|
350
|
-
expect(toolsEffect).toHaveBeenCalledOnce();
|
|
351
|
-
expect(promptEffect).toHaveBeenCalledOnce();
|
|
352
308
|
expect(session.getActiveSkillEntries()).toEqual([]);
|
|
353
309
|
});
|
|
354
310
|
});
|
|
@@ -20,14 +20,26 @@ function prompt(...sections: string[]): string {
|
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
describe("sanitizeAvailableToolsSection — Available tools section", () => {
|
|
23
|
-
test("
|
|
23
|
+
test("keeps allowed tool lines and the header, drops denied ones", () => {
|
|
24
24
|
const input = prompt(
|
|
25
25
|
availableToolsSection(["bash", "read"]),
|
|
26
26
|
"Other content",
|
|
27
27
|
);
|
|
28
|
-
const result = sanitizeAvailableToolsSection(input, ["
|
|
28
|
+
const result = sanitizeAvailableToolsSection(input, ["read"]);
|
|
29
29
|
expect(result.removed).toBe(true);
|
|
30
|
-
expect(result.prompt).
|
|
30
|
+
expect(result.prompt).toContain("Available tools:");
|
|
31
|
+
expect(result.prompt).toContain("- read");
|
|
32
|
+
expect(result.prompt).not.toContain("- bash");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("leaves the section untouched when every tool is allowed", () => {
|
|
36
|
+
const input = prompt(
|
|
37
|
+
availableToolsSection(["bash", "read"]),
|
|
38
|
+
"Other content",
|
|
39
|
+
);
|
|
40
|
+
const result = sanitizeAvailableToolsSection(input, ["bash", "read"]);
|
|
41
|
+
expect(result.removed).toBe(false);
|
|
42
|
+
expect(result.prompt).toBe(input);
|
|
31
43
|
});
|
|
32
44
|
|
|
33
45
|
// Bug #33: findSection extends to lines.length when no subsequent recognised
|
|
@@ -37,7 +49,18 @@ describe("sanitizeAvailableToolsSection — Available tools section", () => {
|
|
|
37
49
|
availableToolsSection(["bash", "read"]),
|
|
38
50
|
"Other content",
|
|
39
51
|
);
|
|
40
|
-
const result = sanitizeAvailableToolsSection(input, ["
|
|
52
|
+
const result = sanitizeAvailableToolsSection(input, ["read"]);
|
|
53
|
+
expect(result.prompt).toContain("Other content");
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("removes the whole section when no tool is allowed", () => {
|
|
57
|
+
const input = prompt(
|
|
58
|
+
availableToolsSection(["bash", "read"]),
|
|
59
|
+
"Other content",
|
|
60
|
+
);
|
|
61
|
+
const result = sanitizeAvailableToolsSection(input, []);
|
|
62
|
+
expect(result.removed).toBe(true);
|
|
63
|
+
expect(result.prompt).not.toContain("Available tools:");
|
|
41
64
|
expect(result.prompt).toContain("Other content");
|
|
42
65
|
});
|
|
43
66
|
|
|
@@ -48,15 +71,18 @@ describe("sanitizeAvailableToolsSection — Available tools section", () => {
|
|
|
48
71
|
expect(result.prompt).toBe(input);
|
|
49
72
|
});
|
|
50
73
|
|
|
51
|
-
test("
|
|
52
|
-
const input =
|
|
53
|
-
"
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
74
|
+
test("keeps non-tool boilerplate prose near the section", () => {
|
|
75
|
+
const input = [
|
|
76
|
+
"Available tools:",
|
|
77
|
+
"- read: Read file contents",
|
|
78
|
+
"- bash: Run shell commands",
|
|
79
|
+
"",
|
|
80
|
+
"In addition to the tools above, you may have access to other custom tools depending on the project.",
|
|
81
|
+
].join("\n");
|
|
82
|
+
const result = sanitizeAvailableToolsSection(input, ["read"]);
|
|
83
|
+
expect(result.prompt).toContain("- read: Read file contents");
|
|
84
|
+
expect(result.prompt).not.toContain("- bash: Run shell commands");
|
|
85
|
+
expect(result.prompt).toContain("In addition to the tools above");
|
|
60
86
|
});
|
|
61
87
|
|
|
62
88
|
test("returns original prompt reference unchanged when nothing is removed", () => {
|
|
@@ -64,6 +90,47 @@ describe("sanitizeAvailableToolsSection — Available tools section", () => {
|
|
|
64
90
|
const result = sanitizeAvailableToolsSection(input, []);
|
|
65
91
|
expect(result.prompt).toBe(input);
|
|
66
92
|
});
|
|
93
|
+
|
|
94
|
+
test("narrowing the full listing yields the already-narrowed listing (cache byte-stability)", () => {
|
|
95
|
+
const allowed = ["read", "edit", "write"];
|
|
96
|
+
const fullProse = [
|
|
97
|
+
"You are an assistant.",
|
|
98
|
+
"",
|
|
99
|
+
"Available tools:",
|
|
100
|
+
"- bash: Run shell commands",
|
|
101
|
+
"- read: Read file contents",
|
|
102
|
+
"- edit: Edit a file",
|
|
103
|
+
"- write: Write a file",
|
|
104
|
+
"",
|
|
105
|
+
"Guidelines:",
|
|
106
|
+
"- use bash for file operations like ls, rg, find",
|
|
107
|
+
"- use read to examine files instead of cat or sed.",
|
|
108
|
+
"- Be concise in your responses",
|
|
109
|
+
].join("\n");
|
|
110
|
+
const narrowedProse = [
|
|
111
|
+
"You are an assistant.",
|
|
112
|
+
"",
|
|
113
|
+
"Available tools:",
|
|
114
|
+
"- read: Read file contents",
|
|
115
|
+
"- edit: Edit a file",
|
|
116
|
+
"- write: Write a file",
|
|
117
|
+
"",
|
|
118
|
+
"Guidelines:",
|
|
119
|
+
"- use read to examine files instead of cat or sed.",
|
|
120
|
+
"- Be concise in your responses",
|
|
121
|
+
].join("\n");
|
|
122
|
+
|
|
123
|
+
const fromFull = sanitizeAvailableToolsSection(fullProse, allowed).prompt;
|
|
124
|
+
const fromNarrowed = sanitizeAvailableToolsSection(
|
|
125
|
+
narrowedProse,
|
|
126
|
+
allowed,
|
|
127
|
+
).prompt;
|
|
128
|
+
|
|
129
|
+
// Idempotent on the already-narrowed input Pi feeds back on later turns.
|
|
130
|
+
expect(fromNarrowed).toBe(narrowedProse);
|
|
131
|
+
// Turn 1 (full) and turn 2+ (narrowed) produce identical wire bytes.
|
|
132
|
+
expect(fromFull).toBe(fromNarrowed);
|
|
133
|
+
});
|
|
67
134
|
});
|
|
68
135
|
|
|
69
136
|
describe("sanitizeAvailableToolsSection — Guidelines section", () => {
|
|
@@ -208,18 +275,18 @@ describe("sanitizeAvailableToolsSection — findSection boundary edge cases", ()
|
|
|
208
275
|
expect(result.prompt).toContain("Important user note");
|
|
209
276
|
});
|
|
210
277
|
|
|
211
|
-
test("section at EOF
|
|
278
|
+
test("section at EOF is removed entirely when no tool is allowed", () => {
|
|
212
279
|
const input = availableToolsSection(["bash", "read"]);
|
|
213
|
-
const result = sanitizeAvailableToolsSection(input, [
|
|
280
|
+
const result = sanitizeAvailableToolsSection(input, []);
|
|
214
281
|
expect(result.removed).toBe(true);
|
|
215
282
|
expect(result.prompt).toBe("");
|
|
216
283
|
});
|
|
217
284
|
|
|
218
|
-
test("section followed by blank lines then prose — prose survives", () => {
|
|
285
|
+
test("section followed by blank lines then prose — prose survives removal", () => {
|
|
219
286
|
const input = ["Available tools:", "- bash", "", "", "Custom note"].join(
|
|
220
287
|
"\n",
|
|
221
288
|
);
|
|
222
|
-
const result = sanitizeAvailableToolsSection(input, [
|
|
289
|
+
const result = sanitizeAvailableToolsSection(input, []);
|
|
223
290
|
expect(result.removed).toBe(true);
|
|
224
291
|
expect(result.prompt).toContain("Custom note");
|
|
225
292
|
expect(result.prompt).not.toContain("Available tools:");
|
|
@@ -230,7 +297,7 @@ describe("sanitizeAvailableToolsSection — findSection boundary edge cases", ()
|
|
|
230
297
|
// Moved from permission-system.test.ts catch-all (#342)
|
|
231
298
|
// ---------------------------------------------------------------------------
|
|
232
299
|
|
|
233
|
-
test("System prompt sanitizer
|
|
300
|
+
test("System prompt sanitizer keeps the active tools in the Available tools section", () => {
|
|
234
301
|
const prompt = [
|
|
235
302
|
"Available tools:",
|
|
236
303
|
"- read: Read file contents",
|
|
@@ -245,11 +312,31 @@ test("System prompt sanitizer removes the Available tools section and surroundin
|
|
|
245
312
|
|
|
246
313
|
const result = sanitizeAvailableToolsSection(prompt, ["read", "mcp"]);
|
|
247
314
|
|
|
248
|
-
expect(result.removed).toBe(
|
|
249
|
-
expect(result.prompt).
|
|
250
|
-
expect(result.prompt).
|
|
315
|
+
expect(result.removed).toBe(false);
|
|
316
|
+
expect(result.prompt).toContain("Available tools:");
|
|
317
|
+
expect(result.prompt).toContain("- read: Read file contents");
|
|
318
|
+
expect(result.prompt).toContain("- mcp: Discover");
|
|
319
|
+
expect(result.prompt).toContain("In addition to the tools above");
|
|
251
320
|
expect(result.prompt).toMatch(/Guidelines:/);
|
|
252
|
-
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
test("System prompt sanitizer drops a denied tool's line but keeps the section", () => {
|
|
324
|
+
const prompt = [
|
|
325
|
+
"Available tools:",
|
|
326
|
+
"- read: Read file contents",
|
|
327
|
+
"- mcp: Discover, inspect, and call MCP tools across configured servers",
|
|
328
|
+
"",
|
|
329
|
+
"Guidelines:",
|
|
330
|
+
"- Use mcp for MCP discovery first: search by capability, describe one exact tool name, then call it.",
|
|
331
|
+
"- Be concise in your responses",
|
|
332
|
+
].join("\n");
|
|
333
|
+
|
|
334
|
+
const result = sanitizeAvailableToolsSection(prompt, ["read"]);
|
|
335
|
+
|
|
336
|
+
expect(result.removed).toBe(true);
|
|
337
|
+
expect(result.prompt).toContain("Available tools:");
|
|
338
|
+
expect(result.prompt).toContain("- read: Read file contents");
|
|
339
|
+
expect(result.prompt).not.toContain("- mcp: Discover");
|
|
253
340
|
});
|
|
254
341
|
|
|
255
342
|
test("System prompt sanitizer removes denied tool guidelines while keeping global guidance", () => {
|
|
@@ -1,37 +0,0 @@
|
|
|
1
|
-
export interface BeforeAgentStartPromptStateInput {
|
|
2
|
-
agentName: string | null;
|
|
3
|
-
cwd: string;
|
|
4
|
-
permissionStamp: string;
|
|
5
|
-
systemPrompt: string;
|
|
6
|
-
allowedToolNames: readonly string[];
|
|
7
|
-
}
|
|
8
|
-
|
|
9
|
-
function normalizeAgentName(agentName: string | null): string {
|
|
10
|
-
return agentName ?? "";
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
function normalizePrompt(prompt: string): string {
|
|
14
|
-
return prompt.replace(/\r\n/g, "\n");
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
function createCacheKey(parts: readonly unknown[]): string {
|
|
18
|
-
return JSON.stringify(parts);
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
export function createActiveToolsCacheKey(
|
|
22
|
-
allowedToolNames: readonly string[],
|
|
23
|
-
): string {
|
|
24
|
-
return createCacheKey(allowedToolNames);
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
export function createBeforeAgentStartPromptStateKey(
|
|
28
|
-
input: BeforeAgentStartPromptStateInput,
|
|
29
|
-
): string {
|
|
30
|
-
return createCacheKey([
|
|
31
|
-
normalizeAgentName(input.agentName),
|
|
32
|
-
input.cwd,
|
|
33
|
-
input.permissionStamp,
|
|
34
|
-
createActiveToolsCacheKey(input.allowedToolNames),
|
|
35
|
-
normalizePrompt(input.systemPrompt),
|
|
36
|
-
]);
|
|
37
|
-
}
|
package/src/cache-key-gate.ts
DELETED
|
@@ -1,32 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Owns a previous cache key and conditionally runs an effect when the key changes.
|
|
3
|
-
*
|
|
4
|
-
* Encapsulates the prev !== next comparison that previously lived in three places:
|
|
5
|
-
* the session's inline `!==`, the handler's ask-then-tell orchestration, and the
|
|
6
|
-
* (test-only-alive) `shouldApplyCachedAgentStartState` free function.
|
|
7
|
-
*
|
|
8
|
-
* Semantics:
|
|
9
|
-
* - On a changed key: runs `effect`, commits `nextKey`, returns the effect's value.
|
|
10
|
-
* - On an unchanged key: skips `effect`, returns `undefined`.
|
|
11
|
-
* - `reset()` re-arms the gate (used by session lifecycle: `resetForNewSession`,
|
|
12
|
-
* `shutdown`, `reload`).
|
|
13
|
-
*
|
|
14
|
-
* Commit ordering is run-then-commit: the key is saved only after `effect` returns.
|
|
15
|
-
* If `effect` throws, the key stays uncommitted and the next call retries.
|
|
16
|
-
*/
|
|
17
|
-
export class CacheKeyGate {
|
|
18
|
-
private previousKey: string | null = null;
|
|
19
|
-
|
|
20
|
-
runIfChanged<T>(nextKey: string, effect: () => T): T | undefined {
|
|
21
|
-
if (this.previousKey === nextKey) {
|
|
22
|
-
return undefined;
|
|
23
|
-
}
|
|
24
|
-
const result = effect();
|
|
25
|
-
this.previousKey = nextKey;
|
|
26
|
-
return result;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
reset(): void {
|
|
30
|
-
this.previousKey = null;
|
|
31
|
-
}
|
|
32
|
-
}
|
|
@@ -1,59 +0,0 @@
|
|
|
1
|
-
import { writeFileSync } from "node:fs";
|
|
2
|
-
import { expect, test } from "vitest";
|
|
3
|
-
import { createBeforeAgentStartPromptStateKey } from "#src/before-agent-start-cache";
|
|
4
|
-
import { createManager } from "#test/helpers/manager-harness";
|
|
5
|
-
|
|
6
|
-
test("Before-agent-start prompt cache invalidates on permission changes while runtime enforcement stays authoritative", () => {
|
|
7
|
-
const { manager, globalConfigPath, cleanup } = createManager({
|
|
8
|
-
permission: { "*": "allow", write: "deny" },
|
|
9
|
-
});
|
|
10
|
-
|
|
11
|
-
try {
|
|
12
|
-
const baselineStamp = manager.getPolicyCacheStamp();
|
|
13
|
-
const baselineKey = createBeforeAgentStartPromptStateKey({
|
|
14
|
-
agentName: null,
|
|
15
|
-
cwd: "C:/workspace/project",
|
|
16
|
-
permissionStamp: baselineStamp,
|
|
17
|
-
systemPrompt: "Available tools:\n- read\n- write",
|
|
18
|
-
allowedToolNames: ["read"],
|
|
19
|
-
});
|
|
20
|
-
|
|
21
|
-
expect(manager.checkPermission("write", {}, undefined).state).toBe("deny");
|
|
22
|
-
|
|
23
|
-
const updatedConfig = `${JSON.stringify(
|
|
24
|
-
{ permission: { "*": "allow", write: "allow" } },
|
|
25
|
-
null,
|
|
26
|
-
2,
|
|
27
|
-
)}\n`;
|
|
28
|
-
|
|
29
|
-
let updatedStamp = baselineStamp;
|
|
30
|
-
for (
|
|
31
|
-
let attempt = 0;
|
|
32
|
-
attempt < 10 && updatedStamp === baselineStamp;
|
|
33
|
-
attempt += 1
|
|
34
|
-
) {
|
|
35
|
-
const waitUntil = Date.now() + 2;
|
|
36
|
-
while (Date.now() < waitUntil) {
|
|
37
|
-
// Wait for the filesystem timestamp granularity to advance.
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
writeFileSync(globalConfigPath, updatedConfig, "utf8");
|
|
41
|
-
updatedStamp = manager.getPolicyCacheStamp();
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
expect(updatedStamp).not.toBe(baselineStamp);
|
|
45
|
-
|
|
46
|
-
const invalidatedKey = createBeforeAgentStartPromptStateKey({
|
|
47
|
-
agentName: null,
|
|
48
|
-
cwd: "C:/workspace/project",
|
|
49
|
-
permissionStamp: updatedStamp,
|
|
50
|
-
systemPrompt: "Available tools:\n- read\n- write",
|
|
51
|
-
allowedToolNames: ["read", "write"],
|
|
52
|
-
});
|
|
53
|
-
|
|
54
|
-
expect(invalidatedKey).not.toBe(baselineKey);
|
|
55
|
-
expect(manager.checkPermission("write", {}, undefined).state).toBe("allow");
|
|
56
|
-
} finally {
|
|
57
|
-
cleanup();
|
|
58
|
-
}
|
|
59
|
-
});
|
|
@@ -1,85 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it, vi } from "vitest";
|
|
2
|
-
|
|
3
|
-
import { CacheKeyGate } from "#src/cache-key-gate";
|
|
4
|
-
|
|
5
|
-
describe("CacheKeyGate", () => {
|
|
6
|
-
describe("runIfChanged", () => {
|
|
7
|
-
it("runs the effect and returns its value when the key is new (null previous)", () => {
|
|
8
|
-
const gate = new CacheKeyGate();
|
|
9
|
-
const effect = vi.fn(() => "result");
|
|
10
|
-
|
|
11
|
-
const result = gate.runIfChanged("key-a", effect);
|
|
12
|
-
|
|
13
|
-
expect(effect).toHaveBeenCalledOnce();
|
|
14
|
-
expect(result).toBe("result");
|
|
15
|
-
});
|
|
16
|
-
|
|
17
|
-
it("commits the key so a second call with the same key skips the effect", () => {
|
|
18
|
-
const gate = new CacheKeyGate();
|
|
19
|
-
const effect = vi.fn(() => "result");
|
|
20
|
-
|
|
21
|
-
gate.runIfChanged("key-a", effect);
|
|
22
|
-
const result = gate.runIfChanged("key-a", effect);
|
|
23
|
-
|
|
24
|
-
expect(effect).toHaveBeenCalledOnce();
|
|
25
|
-
expect(result).toBeUndefined();
|
|
26
|
-
});
|
|
27
|
-
|
|
28
|
-
it("runs the effect when the key changes", () => {
|
|
29
|
-
const gate = new CacheKeyGate();
|
|
30
|
-
const effect = vi.fn((n: number) => n);
|
|
31
|
-
|
|
32
|
-
gate.runIfChanged("key-a", () => effect(1));
|
|
33
|
-
const result = gate.runIfChanged("key-b", () => effect(2));
|
|
34
|
-
|
|
35
|
-
expect(effect).toHaveBeenCalledTimes(2);
|
|
36
|
-
expect(result).toBe(2);
|
|
37
|
-
});
|
|
38
|
-
|
|
39
|
-
it("returns undefined when the key is unchanged", () => {
|
|
40
|
-
const gate = new CacheKeyGate();
|
|
41
|
-
gate.runIfChanged("key-a", vi.fn());
|
|
42
|
-
|
|
43
|
-
const result = gate.runIfChanged("key-a", vi.fn());
|
|
44
|
-
|
|
45
|
-
expect(result).toBeUndefined();
|
|
46
|
-
});
|
|
47
|
-
|
|
48
|
-
it("does not commit the key if the effect throws", () => {
|
|
49
|
-
const gate = new CacheKeyGate();
|
|
50
|
-
const throwing = vi.fn(() => {
|
|
51
|
-
throw new Error("oops");
|
|
52
|
-
});
|
|
53
|
-
const fallback = vi.fn(() => "ok");
|
|
54
|
-
|
|
55
|
-
expect(() => gate.runIfChanged("key-a", throwing)).toThrow("oops");
|
|
56
|
-
|
|
57
|
-
// Same key should run again since the first call threw
|
|
58
|
-
gate.runIfChanged("key-a", fallback);
|
|
59
|
-
expect(fallback).toHaveBeenCalledOnce();
|
|
60
|
-
});
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
describe("reset", () => {
|
|
64
|
-
it("re-arms the gate so the same key runs again on the next call", () => {
|
|
65
|
-
const gate = new CacheKeyGate();
|
|
66
|
-
const effect = vi.fn(() => "ok");
|
|
67
|
-
|
|
68
|
-
gate.runIfChanged("key-a", effect);
|
|
69
|
-
gate.reset();
|
|
70
|
-
gate.runIfChanged("key-a", effect);
|
|
71
|
-
|
|
72
|
-
expect(effect).toHaveBeenCalledTimes(2);
|
|
73
|
-
});
|
|
74
|
-
|
|
75
|
-
it("is idempotent when called on a fresh gate", () => {
|
|
76
|
-
const gate = new CacheKeyGate();
|
|
77
|
-
gate.reset();
|
|
78
|
-
const effect = vi.fn(() => "ok");
|
|
79
|
-
|
|
80
|
-
gate.runIfChanged("key-a", effect);
|
|
81
|
-
|
|
82
|
-
expect(effect).toHaveBeenCalledOnce();
|
|
83
|
-
});
|
|
84
|
-
});
|
|
85
|
-
});
|