Skip to content

Commit a300c47

Browse files
authored
Merge pull request #2043 from dyoshikawa/resolve-scrap-issue-2000-amp-permissions
feat(amp): emit amp.permissions for allow/ask/argument-specific deny
2 parents e51bb73 + 9e03fab commit a300c47

4 files changed

Lines changed: 490 additions & 38 deletions

File tree

docs/reference/file-formats.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -885,4 +885,6 @@ For Vibe (mistral-vibe), this generates per-tool `[tools.<tool>]` tables in the
885885

886886
For Takt, this generates the `default_permission_mode` under `provider_profiles.<provider>` in the shared `.takt/config.yaml` (project mode) or `~/.takt/config.yaml` (global mode). Takt does not have per-tool / per-pattern rules; tool gating is a single coarse mode per provider profile, ordered `readonly` < `edit` < `full` (`readonly` may only read, `edit` may also edit/write files, `full` may also run shell commands). The active provider is named by the top-level `provider:` key (defaulting to `claude`). The mapping is therefore **lossy**: on generate, a single mode is derived with this precedence — (1) any `deny` rule anywhere ⇒ `readonly` (conservative — keep the narrowest mode whenever the user expressed any restriction); (2) else any `edit`/`write` category `allow` rule ⇒ `edit`; (3) else any `bash` category `allow` rule ⇒ `full`; (4) else ⇒ `readonly` (safe default). On import, `full` ⇄ `bash: { "*": "allow" }`, `edit` ⇄ `edit: { "*": "allow" }`, and `readonly` (or an unset/unknown mode) ⇄ `bash: { "*": "deny" }`. `config.yaml` is shared with other Takt settings, so the mode is merged in place — the active provider's other keys (e.g. `step_permission_overrides`), every other provider profile, and all other top-level keys are preserved — and the file is never deleted. See the [Takt configuration docs](https://github.com/nrslib/takt/blob/main/docs/configuration.md).
887887

888+
For Amp, this writes to the shared `.amp/settings.json` (project mode) or `~/.config/amp/settings.json` (global mode), using **two** permission surfaces. In rulesync's canonical model the category name **is** the Amp tool name. A **whole-tool deny** (pattern `*`) is written to the bare `amp.tools.disable` array (the tool name is pushed verbatim, preserving `builtin:` prefixes and the `*` glob) for backwards compatibility. Every **lossy** case is written to the ordered `amp.permissions` array instead of being dropped: an **argument-specific deny** (pattern `!== "*"`) becomes `{ tool, action: "reject", matches: { cmd: <pattern> } }`, and every `allow` / `ask` rule becomes `{ tool, action, matches?: { cmd } }` (the `matches` object is omitted for the `*` catch-all). Amp evaluates `amp.permissions` **first-match-wins**, so generated entries are ordered deterministically and fail-closed: sorted by tool name, then entries **with** `matches.cmd` (more specific) before catch-alls, then by action priority **`reject` < `ask` < `allow`**, then by `cmd`. `amp.permissions` is Amp's documented **legacy / backwards-compatibility** surface — it remains functional and is the only place to express `allow`/`ask` and argument-specific `reject` rules. **Ownership:** rulesync OWNS and wholesale-replaces the `allow`/`ask`/`reject` entries on every generate, but **preserves any existing `action: "delegate"` entry** (rulesync's canonical model has no `delegate` equivalent); preserved `delegate` entries are placed **after** the rulesync-generated entries (so the regenerated rules take precedence under first-match-wins). On **import**, both keys are read and merged into one canonical config: `amp.tools.disable[tool]` → `{ tool: { "*": "deny" } }`, and each `amp.permissions` entry → `{ tool: { (matches?.cmd ?? "*"): mapped } }` (`reject` → `deny`, `allow` → `allow`, `ask` → `ask`; `delegate` is skipped). When both sources target the same tool+pattern, the **most restrictive action wins** (`deny` > `ask` > `allow`). The settings file is shared with the MCP feature (`amp.mcpServers`), so all other keys are preserved on round-trip and the file is never deleted. Tool names and `cmd` patterns that are prototype-pollution keys (`__proto__`, `constructor`, `prototype`) are skipped defensively. See the [Amp manual](https://ampcode.com/manual).
889+
888890
> **Note: Interaction with ignore feature.** Both the ignore feature and the permissions feature can manage `Read` tool deny entries in `.claude/settings.json`. When both features configure the `Read` tool, the **permissions feature takes precedence** and a warning is emitted. If you only need to restrict file reads based on glob patterns, use the ignore feature (`.rulesync/.aiignore`). Use permissions only when you need fine-grained `allow`/`ask`/`deny` control over the `Read` tool.

skills/rulesync/file-formats.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -885,4 +885,6 @@ For Vibe (mistral-vibe), this generates per-tool `[tools.<tool>]` tables in the
885885

886886
For Takt, this generates the `default_permission_mode` under `provider_profiles.<provider>` in the shared `.takt/config.yaml` (project mode) or `~/.takt/config.yaml` (global mode). Takt does not have per-tool / per-pattern rules; tool gating is a single coarse mode per provider profile, ordered `readonly` < `edit` < `full` (`readonly` may only read, `edit` may also edit/write files, `full` may also run shell commands). The active provider is named by the top-level `provider:` key (defaulting to `claude`). The mapping is therefore **lossy**: on generate, a single mode is derived with this precedence — (1) any `deny` rule anywhere ⇒ `readonly` (conservative — keep the narrowest mode whenever the user expressed any restriction); (2) else any `edit`/`write` category `allow` rule ⇒ `edit`; (3) else any `bash` category `allow` rule ⇒ `full`; (4) else ⇒ `readonly` (safe default). On import, `full` ⇄ `bash: { "*": "allow" }`, `edit` ⇄ `edit: { "*": "allow" }`, and `readonly` (or an unset/unknown mode) ⇄ `bash: { "*": "deny" }`. `config.yaml` is shared with other Takt settings, so the mode is merged in place — the active provider's other keys (e.g. `step_permission_overrides`), every other provider profile, and all other top-level keys are preserved — and the file is never deleted. See the [Takt configuration docs](https://github.com/nrslib/takt/blob/main/docs/configuration.md).
887887

888+
For Amp, this writes to the shared `.amp/settings.json` (project mode) or `~/.config/amp/settings.json` (global mode), using **two** permission surfaces. In rulesync's canonical model the category name **is** the Amp tool name. A **whole-tool deny** (pattern `*`) is written to the bare `amp.tools.disable` array (the tool name is pushed verbatim, preserving `builtin:` prefixes and the `*` glob) for backwards compatibility. Every **lossy** case is written to the ordered `amp.permissions` array instead of being dropped: an **argument-specific deny** (pattern `!== "*"`) becomes `{ tool, action: "reject", matches: { cmd: <pattern> } }`, and every `allow` / `ask` rule becomes `{ tool, action, matches?: { cmd } }` (the `matches` object is omitted for the `*` catch-all). Amp evaluates `amp.permissions` **first-match-wins**, so generated entries are ordered deterministically and fail-closed: sorted by tool name, then entries **with** `matches.cmd` (more specific) before catch-alls, then by action priority **`reject` < `ask` < `allow`**, then by `cmd`. `amp.permissions` is Amp's documented **legacy / backwards-compatibility** surface — it remains functional and is the only place to express `allow`/`ask` and argument-specific `reject` rules. **Ownership:** rulesync OWNS and wholesale-replaces the `allow`/`ask`/`reject` entries on every generate, but **preserves any existing `action: "delegate"` entry** (rulesync's canonical model has no `delegate` equivalent); preserved `delegate` entries are placed **after** the rulesync-generated entries (so the regenerated rules take precedence under first-match-wins). On **import**, both keys are read and merged into one canonical config: `amp.tools.disable[tool]` → `{ tool: { "*": "deny" } }`, and each `amp.permissions` entry → `{ tool: { (matches?.cmd ?? "*"): mapped } }` (`reject` → `deny`, `allow` → `allow`, `ask` → `ask`; `delegate` is skipped). When both sources target the same tool+pattern, the **most restrictive action wins** (`deny` > `ask` > `allow`). The settings file is shared with the MCP feature (`amp.mcpServers`), so all other keys are preserved on round-trip and the file is never deleted. Tool names and `cmd` patterns that are prototype-pollution keys (`__proto__`, `constructor`, `prototype`) are skipped defensively. See the [Amp manual](https://ampcode.com/manual).
889+
888890
> **Note: Interaction with ignore feature.** Both the ignore feature and the permissions feature can manage `Read` tool deny entries in `.claude/settings.json`. When both features configure the `Read` tool, the **permissions feature takes precedence** and a warning is emitted. If you only need to restrict file reads based on glob patterns, use the ignore feature (`.rulesync/.aiignore`). Use permissions only when you need fine-grained `allow`/`ask`/`deny` control over the `Read` tool.

src/features/permissions/amp-permissions.test.ts

Lines changed: 230 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ describe("AmpPermissions", () => {
4747
});
4848

4949
describe("fromRulesyncPermissions", () => {
50-
it("maps deny rules to amp.tools.disable, skipping allow/ask", async () => {
50+
it("keeps whole-tool deny in amp.tools.disable and emits allow/ask as amp.permissions", async () => {
5151
const rulesyncPermissions = makeRulesyncPermissions(testDir, {
5252
edit_file: { "*": "deny" },
5353
read_file: { "*": "allow" },
@@ -60,7 +60,80 @@ describe("AmpPermissions", () => {
6060
});
6161
const json = JSON.parse(instance.getFileContent());
6262

63+
// Whole-tool deny stays on the legacy disable surface.
6364
expect(json["amp.tools.disable"]).toEqual(["edit_file"]);
65+
// allow/ask are no longer dropped: they become amp.permissions entries.
66+
// Ordering is globally fail-closed (ask before allow).
67+
expect(json["amp.permissions"]).toEqual([
68+
{ tool: "web", action: "ask" },
69+
{ tool: "read_file", action: "allow" },
70+
]);
71+
});
72+
73+
it("emits an argument-specific deny as a reject entry with matches.cmd", async () => {
74+
const rulesyncPermissions = makeRulesyncPermissions(testDir, {
75+
bash: { "*": "deny", "git *": "deny" },
76+
});
77+
78+
const instance = await AmpPermissions.fromRulesyncPermissions({
79+
outputRoot: testDir,
80+
rulesyncPermissions,
81+
});
82+
const json = JSON.parse(instance.getFileContent());
83+
84+
// The whole-tool deny stays in disable; the argument-specific deny becomes reject.
85+
expect(json["amp.tools.disable"]).toEqual(["bash"]);
86+
expect(json["amp.permissions"]).toEqual([
87+
{ tool: "bash", action: "reject", matches: { cmd: "git *" } },
88+
]);
89+
});
90+
91+
it("orders amp.permissions specific-before-catch-all and reject<ask<allow per tool", async () => {
92+
const rulesyncPermissions = makeRulesyncPermissions(testDir, {
93+
bash: {
94+
"*": "allow",
95+
"rm *": "deny",
96+
"sudo *": "ask",
97+
"git *": "allow",
98+
},
99+
});
100+
101+
const instance = await AmpPermissions.fromRulesyncPermissions({
102+
outputRoot: testDir,
103+
rulesyncPermissions,
104+
});
105+
const json = JSON.parse(instance.getFileContent());
106+
107+
expect(json["amp.tools.disable"]).toEqual([]);
108+
// Entries with matches.cmd come first (sorted reject<ask<allow then cmd),
109+
// and the catch-all allow comes last.
110+
expect(json["amp.permissions"]).toEqual([
111+
{ tool: "bash", action: "reject", matches: { cmd: "rm *" } },
112+
{ tool: "bash", action: "ask", matches: { cmd: "sudo *" } },
113+
{ tool: "bash", action: "allow", matches: { cmd: "git *" } },
114+
{ tool: "bash", action: "allow" },
115+
]);
116+
});
117+
118+
it("emits every reject before any allow so a glob-tool allow cannot shadow a specific reject", async () => {
119+
// `mcp__*` is a glob tool whose catch-all allow would, under Amp's
120+
// first-match-wins, shadow the specific `mcp__github` reject if emitted
121+
// first. Global fail-closed ordering puts all rejects ahead.
122+
const rulesyncPermissions = makeRulesyncPermissions(testDir, {
123+
"mcp__*": { "*": "allow" },
124+
mcp__github: { "deploy *": "deny" },
125+
});
126+
127+
const instance = await AmpPermissions.fromRulesyncPermissions({
128+
outputRoot: testDir,
129+
rulesyncPermissions,
130+
});
131+
const json = JSON.parse(instance.getFileContent());
132+
133+
expect(json["amp.permissions"]).toEqual([
134+
{ tool: "mcp__github", action: "reject", matches: { cmd: "deploy *" } },
135+
{ tool: "mcp__*", action: "allow" },
136+
]);
64137
});
65138

66139
it("preserves builtin: prefixes and the * glob verbatim, sorted and deduped", async () => {
@@ -111,6 +184,56 @@ describe("AmpPermissions", () => {
111184

112185
expect(instance.getRelativeFilePath()).toBe("settings.jsonc");
113186
});
187+
188+
it("preserves a pre-existing delegate entry, placing it after generated entries", async () => {
189+
await writeFileContent(
190+
join(testDir, ".amp", "settings.json"),
191+
JSON.stringify({
192+
"amp.permissions": [
193+
{ tool: "bash", action: "delegate", matches: { cmd: "deploy *" } },
194+
// A user-authored allow that rulesync owns and should regenerate (wholesale-replace).
195+
{ tool: "bash", action: "allow", matches: { cmd: "stale *" } },
196+
],
197+
}),
198+
);
199+
const rulesyncPermissions = makeRulesyncPermissions(testDir, {
200+
bash: { "git *": "allow" },
201+
});
202+
203+
const instance = await AmpPermissions.fromRulesyncPermissions({
204+
outputRoot: testDir,
205+
rulesyncPermissions,
206+
});
207+
const json = JSON.parse(instance.getFileContent());
208+
209+
expect(json["amp.permissions"]).toEqual([
210+
// Regenerated rulesync entry first.
211+
{ tool: "bash", action: "allow", matches: { cmd: "git *" } },
212+
// Pre-existing delegate survives, placed after generated entries.
213+
{ tool: "bash", action: "delegate", matches: { cmd: "deploy *" } },
214+
]);
215+
});
216+
217+
it("removes amp.permissions when nothing is generated and no delegate is preserved", async () => {
218+
await writeFileContent(
219+
join(testDir, ".amp", "settings.json"),
220+
JSON.stringify({
221+
"amp.permissions": [{ tool: "bash", action: "allow", matches: { cmd: "old *" } }],
222+
}),
223+
);
224+
const rulesyncPermissions = makeRulesyncPermissions(testDir, {
225+
edit_file: { "*": "deny" },
226+
});
227+
228+
const instance = await AmpPermissions.fromRulesyncPermissions({
229+
outputRoot: testDir,
230+
rulesyncPermissions,
231+
});
232+
const json = JSON.parse(instance.getFileContent());
233+
234+
expect(json["amp.tools.disable"]).toEqual(["edit_file"]);
235+
expect("amp.permissions" in json).toBe(false);
236+
});
114237
});
115238

116239
describe("fromFile", () => {
@@ -140,6 +263,90 @@ describe("AmpPermissions", () => {
140263
expect(config.permission["builtin:Bash"]).toEqual({ "*": "deny" });
141264
expect(config.permission["*"]).toEqual({ "*": "deny" });
142265
});
266+
267+
it("imports amp.permissions entries back into canonical actions", async () => {
268+
await writeFileContent(
269+
join(testDir, ".amp", "settings.json"),
270+
JSON.stringify({
271+
"amp.permissions": [
272+
{ tool: "read_file", action: "allow" },
273+
{ tool: "web", action: "ask" },
274+
{ tool: "bash", action: "reject", matches: { cmd: "rm *" } },
275+
{ tool: "bash", action: "allow", matches: { cmd: "git *" } },
276+
],
277+
}),
278+
);
279+
280+
const instance = await AmpPermissions.fromFile({ outputRoot: testDir });
281+
const config = JSON.parse(instance.toRulesyncPermissions().getFileContent());
282+
283+
expect(config.permission.read_file).toEqual({ "*": "allow" });
284+
expect(config.permission.web).toEqual({ "*": "ask" });
285+
expect(config.permission.bash).toEqual({ "rm *": "deny", "git *": "allow" });
286+
});
287+
288+
it("skips delegate entries on import (no canonical equivalent)", async () => {
289+
await writeFileContent(
290+
join(testDir, ".amp", "settings.json"),
291+
JSON.stringify({
292+
"amp.permissions": [
293+
{ tool: "bash", action: "delegate", matches: { cmd: "deploy *" } },
294+
{ tool: "bash", action: "allow", matches: { cmd: "git *" } },
295+
],
296+
}),
297+
);
298+
299+
const instance = await AmpPermissions.fromFile({ outputRoot: testDir });
300+
const config = JSON.parse(instance.toRulesyncPermissions().getFileContent());
301+
302+
expect(config.permission.bash).toEqual({ "git *": "allow" });
303+
});
304+
305+
it("merges both sources and lets deny/reject win on conflict (fail-closed)", async () => {
306+
await writeFileContent(
307+
join(testDir, ".amp", "settings.json"),
308+
JSON.stringify({
309+
"amp.tools.disable": ["bash"],
310+
// amp.permissions has a catch-all allow for the same tool+pattern.
311+
"amp.permissions": [{ tool: "bash", action: "allow" }],
312+
}),
313+
);
314+
315+
const instance = await AmpPermissions.fromFile({ outputRoot: testDir });
316+
const config = JSON.parse(instance.toRulesyncPermissions().getFileContent());
317+
318+
// disable → bash:{"*":"deny"}; the allow on the same key loses to deny.
319+
expect(config.permission.bash).toEqual({ "*": "deny" });
320+
});
321+
});
322+
323+
describe("round-trip", () => {
324+
it("round-trips allow/ask/reject and whole-tool deny through Amp and back", async () => {
325+
const original = {
326+
bash: { "*": "deny", "git *": "allow", "rm *": "deny", "sudo *": "ask" },
327+
read_file: { "*": "allow" },
328+
web: { "*": "ask" },
329+
};
330+
const rulesyncPermissions = makeRulesyncPermissions(testDir, original);
331+
332+
const exported = await AmpPermissions.fromRulesyncPermissions({
333+
outputRoot: testDir,
334+
rulesyncPermissions,
335+
});
336+
// Re-read the generated settings file shape into a fresh instance.
337+
await writeFileContent(join(testDir, ".amp", "settings.json"), exported.getFileContent());
338+
const reimported = await AmpPermissions.fromFile({ outputRoot: testDir });
339+
const config = JSON.parse(reimported.toRulesyncPermissions().getFileContent());
340+
341+
expect(config.permission.bash).toEqual({
342+
"*": "deny",
343+
"git *": "allow",
344+
"rm *": "deny",
345+
"sudo *": "ask",
346+
});
347+
expect(config.permission.read_file).toEqual({ "*": "allow" });
348+
expect(config.permission.web).toEqual({ "*": "ask" });
349+
});
143350
});
144351

145352
describe("isDeletable", () => {
@@ -186,5 +393,27 @@ describe("AmpPermissions", () => {
186393
});
187394
expect(instance.validate().success).toBe(true);
188395
});
396+
397+
it("rejects a non-array amp.permissions", () => {
398+
const instance = new AmpPermissions({
399+
outputRoot: testDir,
400+
relativeDirPath: ".amp",
401+
relativeFilePath: "settings.json",
402+
fileContent: JSON.stringify({ "amp.permissions": "nope" }),
403+
});
404+
expect(instance.validate().success).toBe(false);
405+
});
406+
407+
it("accepts a valid amp.permissions array", () => {
408+
const instance = new AmpPermissions({
409+
outputRoot: testDir,
410+
relativeDirPath: ".amp",
411+
relativeFilePath: "settings.json",
412+
fileContent: JSON.stringify({
413+
"amp.permissions": [{ tool: "bash", action: "allow" }],
414+
}),
415+
});
416+
expect(instance.validate().success).toBe(true);
417+
});
189418
});
190419
});

0 commit comments

Comments
 (0)