Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/reference/file-formats.md
Original file line number Diff line number Diff line change
Expand Up @@ -909,6 +909,8 @@ For Kiro, this generates tool permission settings in `.kiro/agents/default.json`
- `bash` maps to `toolsSettings.shell.allowedCommands` / `toolsSettings.shell.deniedCommands`
- `read` maps to `toolsSettings.read.allowedPaths` / `toolsSettings.read.deniedPaths`
- `edit` / `write` map to `toolsSettings.write.allowedPaths` / `toolsSettings.write.deniedPaths`
- `grep` maps to `toolsSettings.grep.allowedPaths` / `toolsSettings.grep.deniedPaths`
- `glob` maps to `toolsSettings.glob.allowedPaths` / `toolsSettings.glob.deniedPaths` (both emitted only when a rule is present, so existing configs do not gain empty tables)
- `webfetch` / `websearch` with pattern `*` map to `allowedTools` entries (`web_fetch` / `web_search`)
- `ask` rules are skipped with a warning (Kiro config does not support explicit ask entries)

Expand Down
2 changes: 2 additions & 0 deletions skills/rulesync/file-formats.md
Original file line number Diff line number Diff line change
Expand Up @@ -909,6 +909,8 @@ For Kiro, this generates tool permission settings in `.kiro/agents/default.json`
- `bash` maps to `toolsSettings.shell.allowedCommands` / `toolsSettings.shell.deniedCommands`
- `read` maps to `toolsSettings.read.allowedPaths` / `toolsSettings.read.deniedPaths`
- `edit` / `write` map to `toolsSettings.write.allowedPaths` / `toolsSettings.write.deniedPaths`
- `grep` maps to `toolsSettings.grep.allowedPaths` / `toolsSettings.grep.deniedPaths`
- `glob` maps to `toolsSettings.glob.allowedPaths` / `toolsSettings.glob.deniedPaths` (both emitted only when a rule is present, so existing configs do not gain empty tables)
- `webfetch` / `websearch` with pattern `*` map to `allowedTools` entries (`web_fetch` / `web_search`)
- `ask` rules are skipped with a warning (Kiro config does not support explicit ask entries)

Expand Down
60 changes: 60 additions & 0 deletions src/features/permissions/kiro-permissions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,66 @@ describe("KiroPermissions", () => {
expect(json.permission.webfetch?.["*"]).toBe("allow");
});

it("should map grep/glob categories to Kiro toolsSettings", async () => {
const rulesyncPermissions = new RulesyncPermissions({
outputRoot: testDir,
relativeDirPath: ".rulesync",
relativeFilePath: "permissions.json",
fileContent: JSON.stringify({
permission: {
grep: { "src/**": "allow", "secrets/**": "deny" },
glob: { "**/*.ts": "allow" },
},
}),
});

const kiroPermissions = await KiroPermissions.fromRulesyncPermissions({
outputRoot: testDir,
rulesyncPermissions,
});

const content = JSON.parse(kiroPermissions.getFileContent());
expect(content.toolsSettings.grep.allowedPaths).toEqual(["src/**"]);
expect(content.toolsSettings.grep.deniedPaths).toEqual(["secrets/**"]);
expect(content.toolsSettings.glob.allowedPaths).toEqual(["**/*.ts"]);
});

it("should not emit empty grep/glob tables when unused", async () => {
const rulesyncPermissions = new RulesyncPermissions({
outputRoot: testDir,
relativeDirPath: ".rulesync",
relativeFilePath: "permissions.json",
fileContent: JSON.stringify({ permission: { bash: { "git *": "allow" } } }),
});

const kiroPermissions = await KiroPermissions.fromRulesyncPermissions({
outputRoot: testDir,
rulesyncPermissions,
});

const content = JSON.parse(kiroPermissions.getFileContent());
expect(content.toolsSettings.grep).toBeUndefined();
expect(content.toolsSettings.glob).toBeUndefined();
});

it("should round-trip grep/glob from Kiro toolsSettings", () => {
const kiroPermissions = new KiroPermissions({
outputRoot: testDir,
relativeDirPath: join(".kiro", "agents"),
relativeFilePath: "default.json",
fileContent: JSON.stringify({
toolsSettings: {
grep: { allowedPaths: ["src/**"], deniedPaths: ["secrets/**"] },
glob: { allowedPaths: ["**/*.ts"], deniedPaths: [] },
},
}),
});

const json = kiroPermissions.toRulesyncPermissions().getJson();
expect(json.permission.grep).toEqual({ "src/**": "allow", "secrets/**": "deny" });
expect(json.permission.glob).toEqual({ "**/*.ts": "allow" });
});

it("should load existing .kiro/agents/default.json", async () => {
const kiroDir = join(testDir, ".kiro", "agents");
await ensureDir(kiroDir);
Expand Down
141 changes: 88 additions & 53 deletions src/features/permissions/kiro-permissions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { z } from "zod/mini";

import { KIRO_AGENTS_DIR_PATH, KIRO_HOOKS_FILE_NAME } from "../../constants/kiro-paths.js";
import type { ValidationResult } from "../../types/ai-file.js";
import type { PermissionsConfig } from "../../types/permissions.js";
import type { PermissionAction, PermissionsConfig } from "../../types/permissions.js";
import { formatError } from "../../utils/error.js";
import { readFileContentOrNull } from "../../utils/file.js";
import { RulesyncPermissions } from "./rulesync-permissions.js";
Expand Down Expand Up @@ -95,31 +95,22 @@ export class KiroPermissions extends ToolPermissions {
const permission: PermissionsConfig["permission"] = {};
const toolsSettings = parsed.toolsSettings ?? {};

const shellSettings = asRecord(toolsSettings.shell);
const shellAllow = asStringArray(shellSettings.allowedCommands);
const shellDeny = asStringArray(shellSettings.deniedCommands);
if (shellAllow.length > 0 || shellDeny.length > 0) {
permission.bash = {};
for (const pattern of shellAllow) permission.bash[pattern] = "allow";
for (const pattern of shellDeny) permission.bash[pattern] = "deny";
}

const readSettings = asRecord(toolsSettings.read);
const readAllow = asStringArray(readSettings.allowedPaths);
const readDeny = asStringArray(readSettings.deniedPaths);
if (readAllow.length > 0 || readDeny.length > 0) {
permission.read = {};
for (const pattern of readAllow) permission.read[pattern] = "allow";
for (const pattern of readDeny) permission.read[pattern] = "deny";
}
const shellRules = rulesFromArrays(
asRecord(toolsSettings.shell),
"allowedCommands",
"deniedCommands",
);
if (Object.keys(shellRules).length > 0) permission.bash = shellRules;

const writeSettings = asRecord(toolsSettings.write);
const writeAllow = asStringArray(writeSettings.allowedPaths);
const writeDeny = asStringArray(writeSettings.deniedPaths);
if (writeAllow.length > 0 || writeDeny.length > 0) {
permission.write = {};
for (const pattern of writeAllow) permission.write[pattern] = "allow";
for (const pattern of writeDeny) permission.write[pattern] = "deny";
// read/write/grep/glob all use `{ allowedPaths, deniedPaths }` under their
// own toolsSettings key, mapping 1:1 to the canonical category name.
for (const category of ["read", "write", "grep", "glob"] as const) {
const rules = rulesFromArrays(
asRecord(toolsSettings[category]),
"allowedPaths",
"deniedPaths",
);
if (Object.keys(rules).length > 0) permission[category] = rules;
}

const allowedTools = new Set(parsed.allowedTools ?? []);
Expand Down Expand Up @@ -166,18 +157,14 @@ function buildKiroPermissionsFromRulesync({
const nextAllowedTools = new Set(existing.allowedTools ?? []);
const nextToolsSettings = { ...asRecord(existing.toolsSettings) };

const shell: { allowedCommands: string[]; deniedCommands: string[] } = {
allowedCommands: [],
deniedCommands: [],
};
const read: { allowedPaths: string[]; deniedPaths: string[] } = {
allowedPaths: [],
deniedPaths: [],
};
const write: { allowedPaths: string[]; deniedPaths: string[] } = {
allowedPaths: [],
deniedPaths: [],
// Path/command categories map to a `{ <allowKey>: [], <denyKey>: [] }` table
// under a `toolsSettings` key. `edit` and `write` both fold into `write`.
const pathBuckets: Record<string, { allow: string[]; deny: string[] }> = {};
const pushPath = (key: string, action: PermissionAction, pattern: string): void => {
const bucket = (pathBuckets[key] ??= { allow: [], deny: [] });
(action === "allow" ? bucket.allow : bucket.deny).push(pattern);
};
const shell = { allowedCommands: [] as string[], deniedCommands: [] as string[] };

for (const [category, rules] of Object.entries(config.permission)) {
for (const [pattern, action] of Object.entries(rules)) {
Expand All @@ -187,32 +174,30 @@ function buildKiroPermissionsFromRulesync({
}
if (category === "bash") {
(action === "allow" ? shell.allowedCommands : shell.deniedCommands).push(pattern);
} else if (category === "read") {
(action === "allow" ? read.allowedPaths : read.deniedPaths).push(pattern);
} else if (category === "read" || category === "grep" || category === "glob") {
pushPath(category, action, pattern);
} else if (category === "edit" || category === "write") {
(action === "allow" ? write.allowedPaths : write.deniedPaths).push(pattern);
pushPath("write", action, pattern);
} else if (category === "webfetch" || category === "websearch") {
if (pattern !== "*") {
logger?.warn(
`Kiro ${category} supports only wildcard (*) via allowedTools. Skipping rule: ${pattern}`,
);
continue;
}
const toolName = category === "webfetch" ? "web_fetch" : "web_search";
if (action === "allow") {
nextAllowedTools.add(toolName);
} else {
nextAllowedTools.delete(toolName);
}
applyKiroWebPermission({ category, pattern, action, nextAllowedTools, logger });
} else {
logger?.warn(`Kiro permissions do not support category: ${category}. Skipping.`);
}
}
}

// `shell`/`read`/`write` are always emitted (even empty) to match the prior
// behavior; `grep`/`glob` are only emitted when they carry a rule so existing
// configs do not gain empty tables.
nextToolsSettings.shell = shell;
nextToolsSettings.read = read;
nextToolsSettings.write = write;
nextToolsSettings.read = pathTable(pathBuckets.read);
nextToolsSettings.write = pathTable(pathBuckets.write);
for (const key of ["grep", "glob"] as const) {
const bucket = pathBuckets[key];
if (bucket && (bucket.allow.length > 0 || bucket.deny.length > 0)) {
nextToolsSettings[key] = pathTable(bucket);
}
}

return {
...existing,
Expand All @@ -221,6 +206,40 @@ function buildKiroPermissionsFromRulesync({
};
}

function pathTable(bucket: { allow: string[]; deny: string[] } | undefined): {
allowedPaths: string[];
deniedPaths: string[];
} {
return { allowedPaths: bucket?.allow ?? [], deniedPaths: bucket?.deny ?? [] };
}

function applyKiroWebPermission({
category,
pattern,
action,
nextAllowedTools,
logger,
}: {
category: "webfetch" | "websearch";
pattern: string;
action: PermissionAction;
nextAllowedTools: Set<string>;
logger?: ToolPermissionsFromRulesyncPermissionsParams["logger"];
}): void {
if (pattern !== "*") {
logger?.warn(
`Kiro ${category} supports only wildcard (*) via allowedTools. Skipping rule: ${pattern}`,
);
return;
}
const toolName = category === "webfetch" ? "web_fetch" : "web_search";
if (action === "allow") {
nextAllowedTools.add(toolName);
} else {
nextAllowedTools.delete(toolName);
}
}

function asRecord(value: unknown): Record<string, unknown> {
const result = UnknownRecordSchema.safeParse(value);
return result.success ? result.data : {};
Expand All @@ -231,3 +250,19 @@ function asStringArray(value: unknown): string[] {
? value.filter((item): item is string => typeof item === "string")
: [];
}

/**
* Build a canonical `{ pattern: action }` map from a Kiro tool settings record's
* allow/deny string arrays (e.g. `allowedPaths`/`deniedPaths` or
* `allowedCommands`/`deniedCommands`).
*/
function rulesFromArrays(
settings: Record<string, unknown>,
allowKey: string,
denyKey: string,
): Record<string, PermissionAction> {
const rules: Record<string, PermissionAction> = {};
for (const pattern of asStringArray(settings[allowKey])) rules[pattern] = "allow";
for (const pattern of asStringArray(settings[denyKey])) rules[pattern] = "deny";
return rules;
}
Loading