Remove allow always button (#7159)

* feat: save permissions to config

* fix: preserve wildcard default in jsonc scalar promotion and skip dispose for permission saves

* fix: update CLI permission hint to reflect permanent persistence

* fix: avoid mutating input in mergeConfig permission normalization

* chore: update kilo-vscode visual regression baselines

* fix(vscode): remove Allow Always button from PermissionDock

* fix(kilo-vscode): removed unreachable onDecide option

* chore: update kilo-vscode visual regression baselines

* fix: scope patchJsonc scalar promotion to permission keys only

* fix: toConfig always uses object format to preserve existing granular rules

* fix: preserve metadata.rules order in saveAlwaysRules for correct precedence

* feat:show non-bash tool permissions on dropdown

* fix: include always patterns in saveAlwaysRules validation loop

* test(kilo-vscode): remove write mixed-decisions screenshot test for single-rule dropdown

* test: updated visual tests

* fix: formatting

* chore: update kilo-vscode visual regression baselines

* fix: use scalar format in toConfig for PermissionAction-only permissions

* fix: serialize scalar-only permissions as scalars for all patterns

* fix: skip non-wildcard patterns for scalar-only permissions in toConfig

* fix: promote doom_loop to PermissionRule for per-tool persistence

* fix: keep doom_loop as PermissionAction and skip non-wildcard scalar-only patterns in toConfig

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
This commit is contained in:
Imanol Maiztegui
2026-03-17 18:44:04 +01:00
committed by GitHub
co-authored by github-actions[bot]
parent ac0a3ae997
commit 0adb514da4
47 changed files with 191 additions and 119 deletions
+1
View File
@@ -1868,6 +1868,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
sessionID: perm.sessionID,
toolName: perm.permission,
patterns: perm.patterns,
always: perm.always,
args: perm.metadata,
message: `Permission required: ${perm.permission}`,
tool: perm.tool,
@@ -167,6 +167,7 @@ export type WebviewMessage =
sessionID: string
toolName: string
patterns: string[]
always: string[]
args: Record<string, unknown>
message: string
tool?: { messageID: string; callID: string }
@@ -240,6 +241,7 @@ export function mapSSEEventToWebviewMessage(event: Event, sessionID: string | un
sessionID: event.properties.sessionID,
toolName: event.properties.permission,
patterns: event.properties.patterns ?? [],
always: event.properties.always ?? [],
args: event.properties.metadata,
message: `Permission required: ${event.properties.permission}`,
tool: event.properties.tool,
@@ -160,22 +160,6 @@ test.describe("Permission Dock Dropdown — write", () => {
const root = page.locator("#storybook-root")
await expect(root).toHaveScreenshot(["permission-dock-dropdown", "write-expanded-pending.png"])
})
test("rules expanded — mixed decisions", async ({ page }) => {
await page.goto(storyUrl(STORY_ID), { waitUntil: "load" })
await disableAnimations(page)
await page.waitForSelector("#storybook-root *", { state: "attached" })
await openDropdown(page)
// Approve first rule, deny second (dispatchEvent bypasses tooltip overlays)
const approveButtons = page.locator('[data-slot="permission-rule-toggle"][data-variant="approve"]')
const denyButtons = page.locator('[data-slot="permission-rule-toggle"][data-variant="deny"]')
await approveButtons.first().dispatchEvent("click")
await denyButtons.nth(1).dispatchEvent("click")
const root = page.locator("#storybook-root")
await expect(root).toHaveScreenshot(["permission-dock-dropdown", "write-rules-mixed.png"])
})
})
// ---------------------------------------------------------------------------
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:321afc41e5908e73656b1a8d5e3c32f23f54d10dee12655506b4b58348230305
size 13718
oid sha256:3ef8ec5886054bf0e7ae288c66ce5175204e74728c3e37f3669c551be98755b7
size 12476
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:666d73c69b19e8c79815129b9d17747dbf755c316f3e6da0cba6977966b9cccc
size 13906
oid sha256:54ff3bd3916245aa1dc65384db4a0b5908b4d78cc3700157112f1be63ce054f9
size 12663
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:b0608142ccb5bab0468ae44060bed17c5ba98b8ee2d5a477627644d1c91e760a
size 13952
oid sha256:9edcfe9f76314110e9c1795ee0008ca35187ce28a19c86cfcf1f8bf499fe151c
size 12714
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:7dd67617fdfc9ea6c4b0f54895d930d76c5c712b158e4be93967a3a3239a53b2
size 14143
oid sha256:4e314513c9b4e964ea3b9a7cdbfc561e57eab2fa867aadfd370d1c0ad68c11b8
size 12903
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:43b16bc81164f310ae40f7ebe6459281f374be323dfc07e3bef6b2d1608b6637
size 20835
oid sha256:a3b9925b5424dd81e0fd60dc3cc4307730abdc010cccd73e7243292880dd6b09
size 16833
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:ee48a78cd2fe43c34d0014262ac794e48e5405d2116140d08219fc8e1621be0c
size 18516
oid sha256:ef1d55223be06fd9d16566bc80a525c84c933a337ee4ccf2c41809e4984571b6
size 17276
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:f69b6545bd98003edf8c7208eb655066a24847e055cf0a5492c99ff67bbbab38
size 14621
oid sha256:5fb585654180101c29bd4e7efbc7129bf29e8681bc764cdae0d8233fe302a29e
size 12711
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:5a7db3c77ca7dee3a74fe3fd08b45e8d32a00193d1fb13ec7b4e2b1671dac7a3
size 14831
oid sha256:c0702dda15977143eeac1557040a583946f2fed4b1610c97a772cff4ae3f2482
size 12842
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:c4aa38c3ade48132b3f0083438ef71c0e34f180db3bbd4d70455d6bb34a1225c
size 19202
oid sha256:9784fcf6612167075eb796887c9ac6ad8ab1596ef03dd0ace2b159f82dcb3675
size 17978
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:e46b79adada26121c948ce33b78ba78c8b0b13dc153708b60a56f1cfc6034503
size 20095
oid sha256:606cc8d90900a26682852c9eb14d24305f31672ecd40265954b7b85cca7d3c48
size 18850
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:699b49cbb768c3a9cea64878a01b5507a1f304a14f12363f2329ad07c8cc35cb
size 15082
oid sha256:ab71bff42d0bd60c84f75cc04d91fdae8516667913152a0ae4e792268368c9d1
size 13844
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:60e6d4b1d0e860195c26752e50a97e6d22d7911d029e7d08d24f46b327b21a01
size 14075
oid sha256:8d37fd5942ec471c7f534b7b65ac568cdde7cd1b7f3542c50a72500ac3823cae
size 12827
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:4ff80ac6cd76dbb278e6483c2e5c352095fc893ae5f5d5cb2eb70ded640bfc0e
size 16913
oid sha256:04377b0ddbaa0e2f4f126adff431db902fbff65ce653f2bc59434d150a833578
size 15638
@@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:6531e4d0e975a29d538bc7b609a987071afc0997b396fd06e52cbbf2b7ef2e26
size 17467
@@ -284,6 +284,7 @@ describe("mapSSEEventToWebviewMessage", () => {
expect(msg.permission.args).toEqual({ path: "/foo" })
expect(msg.permission.message).toBe("Permission required: read_file")
expect(msg.permission.patterns).toEqual(["**/*.ts"])
expect(msg.permission.always).toEqual([])
}
})
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:de6be977bd3805fdbbbb1fdaf3498201c1097adea26ebe460c56b681d1828e39
size 9457
oid sha256:c5ada07e94ff10490732ba4b27d8e204102816aa00e472324a31ee9f8c7e8484
size 9470
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:3164d510232a0457cf935a3032f736825486f133e89994e544c678ccc7e6849a
size 30652
oid sha256:0bbec9564c9fa46fce155ada8e176641e540c912461102b7b424d317b3283c38
size 31033
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:dc40d7bbc5f3eaee3b48b4f90046a10e54db3288e71214eec190e4b0fa61cc16
size 22777
oid sha256:48ebccc3ab71403efa74803f140b17d97a000989b64dd42c65d8f9774d059cb8
size 23139
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:3da4a2d29601e45eb4b5fe007a62a203c2b5151b2d74437d22630d2ddc8582a6
size 28723
oid sha256:371621a81d9c42d1dc327492fb2e3d2516857dfa189adf3c924ac6951d33e910
size 29073
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:bc5f6d5b31970c33aaf062756bbdd73637db17f1bc74c1558e9a81c33c5d602d
size 4582
oid sha256:742b7939e4b5a803413aad42418fe60f8d138bf6153087baae8cc22fba4202ae
size 4755
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:cd828877cfcbb7d3f3af475c2ca5f92b2fd627ae41f1a03725ab91a948b40833
size 4704
oid sha256:5ab73f8c0585e9f889c51073428aa0e7082d7aecdc3d5b0c459720c1b860c436
size 4890
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:cf635f68a3a3ea84caa026f3be0481f21a2a7424acd8d2fdc138f8018f843af8
oid sha256:0e29bb3e5a3d408f6a8edabbf12a59a53c3e39e7fd3efa7cadc0e3c7c380e5cc
size 18908
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:cb8dd7f0ecbbeed00e1321c050964b54f832a571c8acbc37269de205bd64a483
size 13788
oid sha256:c6226c4c30415c1e8d1103003464f3a7c86df362bb7b49d2d37400acfd4c1372
size 12528
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:484ec50824ececf29d8b1464fb94ef48637d4f97b9a3c68dcf3ae5d8f157dcf6
size 15502
oid sha256:fb035f3cea015f0a5e693ac3bef31848c5c596642948ddd3f524699cd3637b23
size 14245
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:22c9bc250a347451c3a194f3ec701bfaf9db67f3104d12bde92adffc08bcd30b
size 6263
oid sha256:61c9ddc7067281976b9183e0696952d1ed2f3453c67f54b3c8766ddf1f9b3ba5
size 6402
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:8f7fd08433524c113a2d7b3fed8bef5c023d9a451621f658b836c7967ca954aa
size 8310
oid sha256:52f85f9f63eccd6217f37616c525df311e4d0a558547ae127366a7388e859dee
size 8395
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:ab9edfc18d0d7094f7c5410d7b08e0012c2128f1638b7090aad9301f127473ed
size 17425
oid sha256:1cf3791372e3e69fd759f2596e72211c982e8694990103c178f1aed0c59229f1
size 16139
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:05783f1a5b4c8631dee2582b243a724eb4fb811cde894f86ce3575e2da1a997e
size 17416
oid sha256:1f3dc016ba452b8eaf69c5f4532d0edc4915c43c345a62029fcbaad46f8ac902
size 16140
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:24364efcbb54ed842a9af0c630e6b2a4f5df37d8cabb651e18dcbebda37a26c5
size 17221
oid sha256:469f10e66c7505ff9034663696fdefc400f21803612fb5df8a58f4f991155ac3
size 15945
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:e8e117613ac4378e82739c266c2e2f818e9ed79bc0e84e0ebe7b8f61c02b88c0
size 14836
oid sha256:36b72898b310b3c4e57fbc506e5ee663d74ed64ed2967d8a9d1201da50904bf0
size 13583
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:2259e821d911a5455a4bdd72716d5ae1de1a5fd5b6aaad666d41846edac716cc
size 15079
oid sha256:6a1ebf498979d1e4371169657582cfb4fe0cf50f2dce9a666c8b3f66355057e1
size 13824
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:3fc2414e502c0f064fc8d15e5f14ccdc5c250ded619b8362472d2a41fb522de9
size 15023
oid sha256:cce4f8672aba4f77ed12ecd59fa0b4846ee6673d59e91de748e5f3333f124292
size 13766
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:acb0efba94927a8ada6a16a66bd82160a1f41d320a2f512114da54aaa2cc1574
size 16143
oid sha256:2fc8f143da673f83abe1def14f87aa325ecfb8127de9c5f66c97096e43dc1f28
size 14876
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:0dcef8172fb810da542e5d4f364a60fa8cb0d0d3a212ad939e0117ca91b5b57c
size 30461
oid sha256:b3f472c100d4432064af12da764bd44baa3295ad4b20c6158cc3fa467667797a
size 30794
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:7e3d24b86091c0041a1e7426209a1d1d826b656f04b9fed1d662e3d5b67aa95f
size 6185
oid sha256:74d53efec74b56fcd689b1b2de4facb0570306995a6f5451d38e8303b65bbcc6
size 6187
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:c21917a9f4f80354ac18a7c4ced1f204a1b28bed4ac5fb0cf34e339dbdc7d5da
size 15929
oid sha256:38d2cef0a125b5b6bba81af716c1accd522a60e0c915c8f9a7be502382bfcb2b
size 14661
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:d2c75a7bb06035f07f364c7fb7a1eb0b813663658d018e4eea881c48cc0e276c
size 8698
oid sha256:ce16f0acdd7d82bb2b1bfd993e015cf89f1d20d50d2c045a6ad08fd4a6382c50
size 8931
@@ -4,7 +4,9 @@
* Uses kilo-ui's DockPrompt component for proper surface styling.
*
* Per-rule toggles allow users to approve/deny individual permission rules for future requests.
* The command buttons (Deny / Allow Always / Allow Once) control the current command.
* For bash, the hierarchical rules from metadata.rules are shown.
* For other tools, the always array is shown so users can configure per-tool permissions.
* The command buttons (Deny / Allow Once) control the current command.
* When all rules are toggled ✓, the command auto-runs.
*/
@@ -24,14 +26,14 @@ let rulesExpandedPreference = false
export const PermissionDock: Component<{
request: PermissionRequest
responding: boolean
onDecide: (response: "once" | "always" | "reject", approvedAlways: string[], deniedAlways: string[]) => void
onDecide: (response: "once" | "reject", approvedAlways: string[], deniedAlways: string[]) => void
}> = (props) => {
const session = useSession()
const language = useLanguage()
const fromChild = () => props.request.sessionID !== session.currentSessionID()
// Bash sends fine-grained rules via metadata.rules; other tools have no dropdown.
const rules = () => props.request.args?.rules ?? []
// Bash sends fine-grained rules via metadata.rules; other tools use the always array.
const rules = () => props.request.args?.rules ?? props.request.always ?? []
// Rules like "git *" or "git log *" — strip the trailing wildcard for display.
// A bare "*" (global wildcard) becomes empty so only the tool name shows.
const label = (rule: string) => (rule === "*" ? "" : rule.replace(/ \*$/, ""))
@@ -43,7 +45,6 @@ export const PermissionDock: Component<{
const [decisions, setDecisions] = createSignal<Record<number, RuleDecision>>({})
const [expanded, setExpanded] = createSignal(rulesExpandedPreference)
const hasDenied = () => Object.values(decisions()).some((d) => d === "denied")
const hasRules = () => rules().length > 0
const toggleExpanded = () => {
@@ -191,17 +192,6 @@ export const PermissionDock: Component<{
>
{language.t("ui.permission.allowOnce")}
</Button>
<Button
variant="secondary"
size="small"
onClick={() => {
const { approved, denied } = collectRules()
props.onDecide("always", approved, denied)
}}
disabled={props.responding || hasDenied()}
>
{language.t("ui.permission.allowAlways")}
</Button>
<Button
variant="ghost"
size="small"
@@ -162,7 +162,8 @@ const globPermission: PermissionRequest = {
sessionID: SESSION_ID,
toolName: "glob",
patterns: ["**/*.md"],
args: { pattern: "**/*.md", rules: ["**/*.md"] },
always: ["*"],
args: { pattern: "**/*.md" },
tool: { messageID: ASST_MSG_ID, callID: "call-glob-001" },
}
@@ -171,6 +172,7 @@ const bashPermission: PermissionRequest = {
sessionID: SESSION_ID,
toolName: "bash",
patterns: ["bun test"],
always: ["bun *"],
args: { command: "bun test", rules: ["bun *", "bun test"] },
tool: { messageID: ASST_MSG_ID, callID: "call-bash-001" },
}
@@ -180,7 +182,8 @@ const dockPermission: PermissionRequest = {
sessionID: SESSION_ID,
toolName: "write",
patterns: ["src/main.tsx", "src/utils.ts"],
args: { rules: ["src/main.tsx", "src/utils.ts"] },
always: ["*"],
args: {},
// No `tool` field — this is a non-tool (dock) permission
}
@@ -292,7 +295,8 @@ const todoWritePermission: PermissionRequest = {
sessionID: SESSION_ID,
toolName: "todowrite",
patterns: ["*"],
args: { rules: ["*"] },
always: ["*"],
args: {},
tool: { messageID: ASST_MSG_ID, callID: "call-todo-001" },
}
@@ -612,7 +616,8 @@ const editPermission: PermissionRequest = {
sessionID: SESSION_ID,
toolName: "edit",
patterns: ["src/components/App.tsx", "src/utils/helpers.ts"],
args: { rules: ["src/components/App.tsx", "src/utils/helpers.ts"] },
always: ["*"],
args: {},
tool: { messageID: ASST_MSG_ID, callID: "call-edit-001" },
}
@@ -645,7 +650,8 @@ const websearchPermission: PermissionRequest = {
sessionID: SESSION_ID,
toolName: "websearch",
patterns: ["*"],
args: { rules: ["*"] },
always: ["*"],
args: {},
tool: { messageID: ASST_MSG_ID, callID: "call-websearch-001" },
}
@@ -677,8 +683,9 @@ const externalDirPermission: PermissionRequest = {
id: "perm-extdir-001",
sessionID: SESSION_ID,
toolName: "external_directory",
patterns: ["/home/user/other-project/config.json"],
args: { rules: ["/home/user/other-project/config.json"] },
patterns: ["/home/user/other-project/*"],
always: ["/home/user/other-project/*"],
args: { filepath: "/home/user/other-project/config.json" },
tool: { messageID: ASST_MSG_ID, callID: "call-extdir-001" },
}
@@ -711,6 +718,7 @@ const bashManyRulesPermission: PermissionRequest = {
sessionID: SESSION_ID,
toolName: "bash",
patterns: ["npm install"],
always: ["npm install *"],
args: {
command: "npm install",
rules: ["npm *", "npm install", "npm run *", "npm test", "npm run build", "npx *"],
@@ -747,6 +755,7 @@ const subagentPermission: PermissionRequest = {
sessionID: "child-session-001",
toolName: "bash",
patterns: ["git status"],
always: ["git status *"],
args: { command: "git status", rules: ["git *", "git status"] },
tool: { messageID: ASST_MSG_ID, callID: "call-subagent-001" },
}
@@ -157,6 +157,7 @@ export interface PermissionRequest {
sessionID: string
toolName: string
patterns: string[]
always: string[]
args: Record<string, unknown> & { rules?: string[] }
message?: string
tool?: { messageID: string; callID: string }
+4 -1
View File
@@ -1563,7 +1563,10 @@ export namespace Config {
})
}
export async function updateGlobal(config: Info) {
// kilocode_change start — add dispose option to skip Instance.disposeAll for permission-only changes
export async function updateGlobal(config: Info, options?: { dispose?: boolean }) {
const dispose = options?.dispose ?? true
// kilocode_change end
const filepath = globalConfigFile()
const before = await Filesystem.readText(filepath).catch((err: any) => {
if (err.code === "ENOENT") return "{}"
+29 -5
View File
@@ -66,12 +66,36 @@ export namespace PermissionNext {
}
// kilocode_change start — inverse of fromConfig: convert rules back to config format
/**
* Permissions typed as PermissionAction in the config schema (scalar-only).
* These must be serialized as "allow"/"deny"/"ask", not as { "*": "allow" }.
*/
const SCALAR_ONLY_PERMISSIONS = new Set([
"todowrite",
"todoread",
"question",
"webfetch",
"websearch",
"codesearch",
"doom_loop",
])
export function toConfig(rules: Ruleset): Config.Permission {
const result: Config.Permission = {}
for (const rule of rules) {
const existing = result[rule.permission]
// Scalar-only permissions (e.g. websearch, todowrite, doom_loop) only
// accept PermissionAction ("allow"/"deny"/"ask"), not object form.
// Use scalar format for "*"; skip non-wildcard patterns (they can't be
// represented in the config schema — they only work in-memory).
if (SCALAR_ONLY_PERMISSIONS.has(rule.permission)) {
if (rule.pattern === "*") result[rule.permission] = rule.action
continue
}
if (existing === undefined) {
// Always use object format to avoid replacing existing granular rules
// Use object format to avoid replacing existing granular rules
// when merged via updateGlobal (e.g. { read: "allow" } would wipe
// { read: { "*": "ask", "src/*": "allow" } })
result[rule.permission] = { [rule.pattern]: rule.action }
@@ -194,15 +218,15 @@ export namespace PermissionNext {
const existing = s.pending[input.requestID]
if (!existing) throw new NotFoundError({ message: `Permission request ${input.requestID} not found` })
const validRules = new Set(existing.info.metadata?.rules ?? [])
// Combine metadata.rules (bash hierarchy) and always (all tools).
// Set preserves insertion order and deduplicates.
const validRules = new Set([...(existing.info.metadata?.rules ?? []), ...existing.info.always])
const permission = existing.info.permission
// Build rules in metadata.rules order so broader patterns come before
// specific ones, preserving intended precedence for evaluate(findLast).
const approvedSet = new Set(input.approvedAlways ?? [])
const deniedSet = new Set(input.deniedAlways ?? [])
const newRules: Ruleset = []
for (const pattern of existing.info.metadata?.rules ?? []) {
for (const pattern of validRules) {
if (approvedSet.has(pattern)) newRules.push({ permission, pattern, action: "allow" })
if (deniedSet.has(pattern)) newRules.push({ permission, pattern, action: "deny" })
}
@@ -82,7 +82,7 @@ describe("saveAlwaysRules", () => {
})
})
test("ignores patterns not in metadata.rules", async () => {
test("ignores patterns not in metadata.rules or always", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
@@ -97,7 +97,7 @@ describe("saveAlwaysRules", () => {
ruleset: [],
})
// "curl" is not in metadata.rules — should be silently ignored
// "curl" is not in metadata.rules or always — should be silently ignored
await PermissionNext.saveAlwaysRules({
requestID: "permission_3",
approvedAlways: ["npm install", "curl http://evil.com"],
@@ -133,6 +133,40 @@ describe("saveAlwaysRules", () => {
})
})
test("accepts patterns from always array (non-bash tools)", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
const askPromise = PermissionNext.ask({
id: "permission_nonbash",
sessionID: "session_test",
permission: "read",
patterns: ["src/main.ts"],
metadata: {},
always: ["*"],
ruleset: [],
})
// "*" is in always — should be accepted even without metadata.rules
await PermissionNext.saveAlwaysRules({ requestID: "permission_nonbash", approvedAlways: ["*"] })
await PermissionNext.reply({ requestID: "permission_nonbash", reply: "once" })
await expect(askPromise).resolves.toBeUndefined()
// "*" wildcard should auto-allow any read
const result = await PermissionNext.ask({
sessionID: "session_test",
permission: "read",
patterns: ["any/file.ts"],
metadata: {},
always: [],
ruleset: [],
})
expect(result).toBeUndefined()
},
})
})
test("accepts hierarchy patterns from metadata.rules", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
@@ -60,3 +60,29 @@ test("toConfig - roundtrip with fromConfig (object)", () => {
const result = PermissionNext.toConfig(rules)
expect(result).toEqual(config)
})
test("toConfig - scalar-only permission uses scalar format", () => {
const result = PermissionNext.toConfig([{ permission: "websearch", pattern: "*", action: "allow" }])
expect(result).toEqual({ websearch: "allow" })
})
test("toConfig - scalar-only permission with non-wildcard pattern is skipped", () => {
// doom_loop uses always: [toolName], so pattern can be "bash" etc.
// Non-wildcard patterns for scalar-only permissions can't be represented
// in the config schema — they only work in-memory (known limitation).
const result = PermissionNext.toConfig([{ permission: "doom_loop", pattern: "bash", action: "allow" }])
expect(result).toEqual({})
})
test("toConfig - mixed scalar-only and rule-capable permissions", () => {
const result = PermissionNext.toConfig([
{ permission: "websearch", pattern: "*", action: "allow" },
{ permission: "todowrite", pattern: "*", action: "allow" },
{ permission: "bash", pattern: "npm *", action: "allow" },
])
expect(result).toEqual({
websearch: "allow",
todowrite: "allow",
bash: { "npm *": "allow" },
})
})