Skip to content

Commit 7799f52

Browse files
committed
feat: add read permission exemption paths and apply to the scan paths for skills
1 parent d23fccd commit 7799f52

3 files changed

Lines changed: 110 additions & 3 deletions

File tree

src/common/permissions.ts

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ export type ComputeToolCallPermissionsOptions = {
6060
projectRoot: string;
6161
toolCalls: unknown[];
6262
settings?: Required<PermissionSettings>;
63+
readPermissionExemptPaths?: string[];
6364
resolveSnippetPath?: (sessionId: string, snippetId: string) => string | null | undefined;
6465
};
6566

@@ -159,6 +160,7 @@ export function computeToolCallPermissions(options: ComputeToolCallPermissionsOp
159160
sessionId: options.sessionId,
160161
projectRoot: options.projectRoot,
161162
toolCall,
163+
readPermissionExemptPaths: options.readPermissionExemptPaths,
162164
resolveSnippetPath: options.resolveSnippetPath,
163165
});
164166
const permission = evaluatePermissionScopes(request.scopes, options.settings);
@@ -182,6 +184,7 @@ export function describeToolPermissionRequest(options: {
182184
sessionId: string;
183185
projectRoot: string;
184186
toolCall: PermissionToolCall;
187+
readPermissionExemptPaths?: string[];
185188
resolveSnippetPath?: (sessionId: string, snippetId: string) => string | null | undefined;
186189
}): AskPermissionRequest {
187190
const name = options.toolCall.function.name;
@@ -193,7 +196,10 @@ export function describeToolPermissionRequest(options: {
193196
toolCallId: options.toolCall.id,
194197
name,
195198
command: formatToolPathCommand("read", filePath),
196-
scopes: filePath ? [isPathInProject(options.projectRoot, filePath) ? "read-in-cwd" : "read-out-cwd"] : [],
199+
scopes:
200+
filePath && !isPathInAnyDirectory(options.projectRoot, filePath, options.readPermissionExemptPaths)
201+
? [isPathInProject(options.projectRoot, filePath) ? "read-in-cwd" : "read-out-cwd"]
202+
: [],
197203
};
198204
}
199205

@@ -386,6 +392,30 @@ export function isPathInProject(projectRoot: string, filePath: string): boolean
386392
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
387393
}
388394

395+
export function isPathInAnyDirectory(
396+
projectRoot: string,
397+
filePath: string,
398+
directories: string[] | undefined
399+
): boolean {
400+
if (!directories?.length) {
401+
return false;
402+
}
403+
404+
const normalized = normalizeFilePath(filePath);
405+
const absolutePath = isAbsoluteFilePath(normalized) ? normalized : path.resolve(projectRoot, normalized);
406+
for (const directory of directories) {
407+
const normalizedDirectory = normalizeFilePath(directory);
408+
const absoluteDirectory = isAbsoluteFilePath(normalizedDirectory)
409+
? normalizedDirectory
410+
: path.resolve(projectRoot, normalizedDirectory);
411+
const relative = path.relative(path.resolve(absoluteDirectory), path.resolve(absolutePath));
412+
if (relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative))) {
413+
return true;
414+
}
415+
}
416+
return false;
417+
}
418+
389419
export function hasUserPermissionReplies(value: { permissions?: unknown; alwaysAllows?: unknown }): boolean {
390420
return Boolean(
391421
(Array.isArray(value.permissions) && value.permissions.length > 0) ||

src/session.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -796,14 +796,18 @@ ${agentInstructions}
796796
}
797797
}
798798

799-
async listSkills(sessionId?: string): Promise<SkillInfo[]> {
799+
private getSkillScanRoots(): Array<{ root: string; displayRoot: string }> {
800800
const homeDir = os.homedir();
801-
const skillRoots = [
801+
return [
802802
{ root: path.join(this.projectRoot, ".deepcode", "skills"), displayRoot: "./.deepcode/skills" },
803803
{ root: path.join(this.projectRoot, ".agents", "skills"), displayRoot: "./.agents/skills" },
804804
{ root: path.join(homeDir, ".deepcode", "skills"), displayRoot: "~/.deepcode/skills" },
805805
{ root: path.join(homeDir, ".agents", "skills"), displayRoot: "~/.agents/skills" },
806806
];
807+
}
808+
809+
async listSkills(sessionId?: string): Promise<SkillInfo[]> {
810+
const skillRoots = this.getSkillScanRoots();
807811
const skillsByName = new Map<string, SkillInfo>();
808812

809813
const collectSkills = (root: string, displayRoot: string): SkillInfo[] => {
@@ -1354,6 +1358,7 @@ ${agentInstructions}
13541358
projectRoot: this.projectRoot,
13551359
toolCalls,
13561360
settings: this.getResolvedSettings().permissions,
1361+
readPermissionExemptPaths: this.getSkillScanRoots().map((entry) => entry.root),
13571362
resolveSnippetPath: (id, snippetId) => getSnippet(id, snippetId)?.filePath,
13581363
})
13591364
: null;

src/tests/permissions.test.ts

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
computeToolCallPermissions,
99
evaluatePermissionScopes,
1010
hasUserPermissionReplies,
11+
isPathInAnyDirectory,
1112
parseBashSideEffects,
1213
} from "../common/permissions";
1314

@@ -126,6 +127,77 @@ test("computeToolCallPermissions only asks for scopes not already allowed", () =
126127
);
127128
});
128129

130+
test("computeToolCallPermissions allows read tool calls under skill scan paths", () => {
131+
const projectRoot = createTempDir("deepcode-permissions-skill-read-workspace-");
132+
const home = createTempDir("deepcode-permissions-skill-read-home-");
133+
const skillRoot = path.join(home, ".agents", "skills");
134+
const skillResourcePath = path.join(skillRoot, "pdf", "scripts", "extract.py");
135+
const outsidePath = path.join(home, "notes.txt");
136+
const plan = computeToolCallPermissions({
137+
sessionId: "session-1",
138+
projectRoot,
139+
readPermissionExemptPaths: [skillRoot],
140+
settings: {
141+
allow: [],
142+
deny: [],
143+
ask: [],
144+
defaultMode: "askAll",
145+
},
146+
toolCalls: [
147+
{
148+
id: "call-skill-read",
149+
type: "function",
150+
function: { name: "read", arguments: JSON.stringify({ file_path: skillResourcePath }) },
151+
},
152+
{
153+
id: "call-outside-read",
154+
type: "function",
155+
function: { name: "read", arguments: JSON.stringify({ file_path: outsidePath }) },
156+
},
157+
],
158+
});
159+
160+
assert.deepEqual(plan.permissions, [
161+
{ toolCallId: "call-skill-read", permission: "allow" },
162+
{ toolCallId: "call-outside-read", permission: "ask" },
163+
]);
164+
assert.deepEqual(
165+
plan.askPermissions.map((item) => ({ id: item.toolCallId, scopes: item.scopes })),
166+
[{ id: "call-outside-read", scopes: ["read-out-cwd"] }]
167+
);
168+
});
169+
170+
test("isPathInAnyDirectory matches absolute and project-relative directories without sibling leaks", () => {
171+
const projectRoot = createTempDir("deepcode-permissions-directory-match-workspace-");
172+
const home = createTempDir("deepcode-permissions-directory-match-home-");
173+
const absoluteSkillRoot = path.join(home, ".agents", "skills");
174+
const relativeSkillRoot = path.join(".deepcode", "skills");
175+
176+
assert.equal(
177+
isPathInAnyDirectory(projectRoot, path.join(absoluteSkillRoot, "pdf", "scripts", "extract.py"), [
178+
absoluteSkillRoot,
179+
]),
180+
true
181+
);
182+
assert.equal(
183+
isPathInAnyDirectory(projectRoot, path.join(projectRoot, relativeSkillRoot, "local", "SKILL.md"), [
184+
relativeSkillRoot,
185+
]),
186+
true
187+
);
188+
assert.equal(
189+
isPathInAnyDirectory(projectRoot, path.join(`${absoluteSkillRoot}-backup`, "extract.py"), [absoluteSkillRoot]),
190+
false
191+
);
192+
assert.equal(
193+
isPathInAnyDirectory(projectRoot, path.join(projectRoot, ".deepcode", "skills-extra", "file.md"), [
194+
relativeSkillRoot,
195+
]),
196+
false
197+
);
198+
assert.equal(isPathInAnyDirectory(projectRoot, path.join(home, "notes.txt"), undefined), false);
199+
});
200+
129201
test("appendProjectPermissionAllows writes unique project-level allow scopes", () => {
130202
const projectRoot = createTempDir("deepcode-permission-settings-");
131203
const settingsPath = path.join(projectRoot, ".deepcode", "settings.json");

0 commit comments

Comments
 (0)