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
5 changes: 5 additions & 0 deletions .changeset/restore-removed-permission-rules.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@taskless/cli": patch
---

`taskless init` now puts back a Taskless allow rule that was removed from a harness permission file (`.claude/settings.json`, `.cursor/cli.json`, and the rest) by a hand edit, a revert, or a merge. Previously the install trusted its own record over the file, so a removed rule stayed removed and the wizard showed no edit. A permission file that was deleted is recreated with the rule.
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
## Why

`init` decides which permission rules to add by comparing against the manifest record instead of the file. When a rule the record lists is taken out of a harness permission file (a hand edit, a revert, a merge), `init` believes it is still there and never writes it back, and the wizard preview shows no edit. The requirement already says each file SHALL hold the installed package's rules afterwards, and that the record decides only whether a file may be deleted; the code contradicted both. Reported in taskless/cli#624.

## What Changes

- Read which Taskless rules a permission file holds from the file, for adding as well as removing. A recorded rule missing from the file is added back and reported.
- A recorded file that was deleted is written again, and recorded as created by Taskless, so it is deleted again once it holds nothing else.
- The record never lists a rule twice when a rule is restored.

## Capabilities

### New Capabilities

None.

### Modified Capabilities

- `cli-init`: the permission-file requirement states that presence is read from the file, with scenarios for a removed rule and a deleted file.

## Impact

- `packages/cli/src/install/permissions.ts` (`syncPermissions`) and `packages/cli/test/permissions.test.ts`.
- Delivery shape: **single PR**. A one-function fix and its tests fit one review.
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
## MODIFIED Requirements

### Requirement: Each permission file holds one Taskless rule set per installed package

Each harness permission file `init` manages SHALL hold, afterwards, the installed invocation's package's rules: the rules for the installed invocation, from the table in the requirement that init allows the installed CLI, and the rule for its launcher, from the requirement that init allows the installed launcher. A Taskless rule is an allow entry, in that harness's format, whose command is an invocation of `@taskless/cli` or `@taskless/cli-nightly` through `npx` or `pnpm dlx`, at any version, with or without a version, or whose command is a launcher path `./.taskless/bin/cli.mjs` or `./.taskless/bin/cli-nightly.mjs`. A rule belongs to the package its command names; `cli.mjs` belongs to `@taskless/cli` and `cli-nightly.mjs` to `@taskless/cli-nightly`. For Claude Code this includes both the `Bash(<invocation> *)` form and the `Bash(<invocation>:*)` form. On every `init` the installer SHALL remove each Taskless rule of the installed invocation's package that is not one of the installed invocation's rules, whether or not the manifest recorded it, and write the current ones.

The two packages are not symmetric. A nightly `init` SHALL NOT remove a `@taskless/cli` rule, so a nightly installs beside the release. A release `init` is the move back to mainline: it SHALL remove every `@taskless/cli-nightly` rule in the files it visits, whether or not the manifest recorded it. An invocation that names neither package, such as a `self` build's path, SHALL remove no Taskless rule. The installer SHALL NOT remove or edit a deny entry, or any entry that is not a Taskless rule.

The manifest SHALL record, under `install.permissions`, each file Taskless wrote a rule to, the exact rule entries it added, and whether Taskless created the file. The record decides only whether a file may be deleted. Whether a file holds a rule SHALL be read from the file, never from the record: a recorded rule the file no longer holds SHALL be written again and reported as allowed, and a recorded file that no longer exists SHALL be written again, holding the installed rules, and recorded as one Taskless created. A file Taskless created SHALL be deleted when removing its entries leaves it holding nothing else; a file that existed before SHALL never be deleted. A recorded file for a harness no longer selected SHALL have the installed package's Taskless rules removed, and, on a release `init`, the nightly's. A nightly `init` SHALL keep the release's recorded rules, so a file Taskless created is deleted once the last package's rules leave it.

#### Scenario: An upgrade swaps the version

- **WHEN** the manifest records `Bash(npx @taskless/cli@0.13.0 *)` in `.claude/settings.json` and `init` runs at `0.13.1`
- **THEN** the file SHALL contain `Bash(npx @taskless/cli@0.13.1 *)` and not the `0.13.0` rule
- **AND** the manifest SHALL record only the `0.13.1` rule

#### Scenario: A recorded rule removed from the file is written again

- **WHEN** the manifest records `Bash(npx @taskless/cli@0.13.0 *)` in `.claude/settings.json`, the entry has been removed from the file, and `init` runs at `0.13.0`
- **THEN** the file SHALL contain `Bash(npx @taskless/cli@0.13.0 *)` again
- **AND** `init` SHALL report the file as edited, with that rule as allowed
- **AND** the manifest SHALL record the rule once

#### Scenario: A recorded file that was deleted is written again

- **WHEN** the manifest records `.claude/settings.json` as a file Taskless did not create, the file has been deleted, and `init` runs
- **THEN** `init` SHALL create the file holding the installed rules and report it as created
- **AND** the manifest SHALL record the file as one Taskless created

#### Scenario: A file that agrees with the record is not edited

- **WHEN** `.claude/settings.json` holds exactly the rules the manifest records for the installed invocation, and `init` runs again at the same version
- **THEN** `init` SHALL report no edit and SHALL NOT change the file

#### Scenario: An unrecorded Taskless rule is replaced

- **WHEN** `.claude/settings.json` also holds `Bash(npx @taskless/cli@0.12.0 *)`, which the manifest does not record
- **THEN** an upgrade SHALL remove that entry
- **AND** the file SHALL hold exactly one Taskless rule, for the installed invocation

#### Scenario: The colon form the skill proposed is replaced

- **WHEN** `.claude/settings.json` holds `Bash(npx @taskless/cli-nightly@0.13.0-20261015220659xbf27a23:*)` and a nightly `init` runs
- **THEN** `init` SHALL remove it and write the installed invocation's rule

#### Scenario: Other entries are untouched

- **WHEN** `.claude/settings.json` holds `Bash(npx prettier *)` and a deny entry `Bash(npx @taskless/cli@0.11.0 *)`
- **THEN** `init` SHALL leave both entries in place

#### Scenario: A pre-existing file is never deleted

- **WHEN** removing Taskless rules from a `.claude/settings.json` that Taskless did not create leaves its `permissions.allow` empty
- **THEN** the file SHALL remain

#### Scenario: A nightly installs beside the release's rules

- **WHEN** `init` at `npx @taskless/cli@0.13.0` has written its rule, and a nightly `init` then runs
- **THEN** `.claude/settings.json` SHALL hold the release's rule and the nightly's rule
- **AND** running the nightly's `init` again SHALL report no edit

#### Scenario: A release install removes the nightly's rules

- **WHEN** `.claude/settings.json` holds the release's rules and a nightly's, recorded or not, and the release's `init` runs
- **THEN** every `@taskless/cli-nightly` rule, including `Bash(./.taskless/bin/cli-nightly.mjs *)`, SHALL be removed and reported as removed
- **AND** the release's rules SHALL remain

#### Scenario: A nightly upgrade swaps only the nightly's rule

- **WHEN** `.claude/settings.json` holds the release's rule and an older nightly's rule, and a newer nightly `init` runs
- **THEN** the older nightly rule SHALL be removed and the newer one written
- **AND** the release's rule SHALL remain

#### Scenario: A created file outlives the nightly's removal

- **WHEN** Taskless created `.claude/settings.json` holding the release's and a nightly's rules, and the nightly's `init` runs with `.claude/` no longer selected
- **THEN** the file SHALL remain, holding the release's rule
- **AND** once the release's `init` runs the same way, the file SHALL be deleted

#### Scenario: Removing a harness removes its launcher rule

- **WHEN** the manifest records `Bash(./.taskless/bin/cli-nightly.mjs *)` in `.claude/settings.json` and the nightly's `init` runs with `.claude/` no longer selected
- **THEN** that rule SHALL be removed with the nightly's other Taskless rules
- **AND** `Bash(./.taskless/bin/cli.mjs *)` SHALL remain
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
## 1. Fix

- [x] 1.1 Compute the rules to add from the file's Taskless rules, not the manifest record
- [x] 1.2 Record a recreated file as created by Taskless, and never list a rule twice in the record

## 2. Tests

- [x] 2.1 For every harness: a recorded rule missing from the file is added back and reported
- [x] 2.2 For every harness: a deleted recorded file is recreated with the rule
- [x] 2.3 For every harness: a record and file that agree produce no edit
20 changes: 19 additions & 1 deletion openspec/specs/cli-init/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -1322,14 +1322,32 @@ Each harness permission file `init` manages SHALL hold, afterwards, the installe

The two packages are not symmetric. A nightly `init` SHALL NOT remove a `@taskless/cli` rule, so a nightly installs beside the release. A release `init` is the move back to mainline: it SHALL remove every `@taskless/cli-nightly` rule in the files it visits, whether or not the manifest recorded it. An invocation that names neither package, such as a `self` build's path, SHALL remove no Taskless rule. The installer SHALL NOT remove or edit a deny entry, or any entry that is not a Taskless rule.

The manifest SHALL record, under `install.permissions`, each file Taskless wrote a rule to, the exact rule entries it added, and whether Taskless created the file. The record decides only whether a file may be deleted: a file Taskless created SHALL be deleted when removing its entries leaves it holding nothing else; a file that existed before SHALL never be deleted. A recorded file for a harness no longer selected SHALL have the installed package's Taskless rules removed, and, on a release `init`, the nightly's. A nightly `init` SHALL keep the release's recorded rules, so a file Taskless created is deleted once the last package's rules leave it.
The manifest SHALL record, under `install.permissions`, each file Taskless wrote a rule to, the exact rule entries it added, and whether Taskless created the file. The record decides only whether a file may be deleted. Whether a file holds a rule SHALL be read from the file, never from the record: a recorded rule the file no longer holds SHALL be written again and reported as allowed, and a recorded file that no longer exists SHALL be written again, holding the installed rules, and recorded as one Taskless created. A file Taskless created SHALL be deleted when removing its entries leaves it holding nothing else; a file that existed before SHALL never be deleted. A recorded file for a harness no longer selected SHALL have the installed package's Taskless rules removed, and, on a release `init`, the nightly's. A nightly `init` SHALL keep the release's recorded rules, so a file Taskless created is deleted once the last package's rules leave it.

#### Scenario: An upgrade swaps the version

- **WHEN** the manifest records `Bash(npx @taskless/cli@0.13.0 *)` in `.claude/settings.json` and `init` runs at `0.13.1`
- **THEN** the file SHALL contain `Bash(npx @taskless/cli@0.13.1 *)` and not the `0.13.0` rule
- **AND** the manifest SHALL record only the `0.13.1` rule

#### Scenario: A recorded rule removed from the file is written again

- **WHEN** the manifest records `Bash(npx @taskless/cli@0.13.0 *)` in `.claude/settings.json`, the entry has been removed from the file, and `init` runs at `0.13.0`
- **THEN** the file SHALL contain `Bash(npx @taskless/cli@0.13.0 *)` again
- **AND** `init` SHALL report the file as edited, with that rule as allowed
- **AND** the manifest SHALL record the rule once

#### Scenario: A recorded file that was deleted is written again

- **WHEN** the manifest records `.claude/settings.json` as a file Taskless did not create, the file has been deleted, and `init` runs
- **THEN** `init` SHALL create the file holding the installed rules and report it as created
- **AND** the manifest SHALL record the file as one Taskless created

#### Scenario: A file that agrees with the record is not edited

- **WHEN** `.claude/settings.json` holds exactly the rules the manifest records for the installed invocation, and `init` runs again at the same version
- **THEN** `init` SHALL report no edit and SHALL NOT change the file

#### Scenario: An unrecorded Taskless rule is replaced

- **WHEN** `.claude/settings.json` also holds `Bash(npx @taskless/cli@0.12.0 *)`, which the manifest does not record
Expand Down
23 changes: 14 additions & 9 deletions packages/cli/src/install/permissions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -906,18 +906,21 @@ export async function syncPermissions(
const text = await readText(path);
const want = desired.get(file)?.rules ?? [];
const had = recorded?.rules ?? [];
const remove = harness
.found(text)
.filter(
(rule) => (isOwn(rule) && !want.includes(rule)) || isRetired(rule)
);
const add = want.filter((rule) => !had.includes(rule));
// Both sides are read from the file, never the record: a rule the record
// lists but the file lost (a hand edit, a revert, a merge) is added back.
const present = harness.found(text);
const remove = present.filter(
(rule) => (isOwn(rule) && !want.includes(rule)) || isRetired(rule)
);
const add = want.filter((rule) => !present.includes(rule));
if (add.length === 0 && remove.length === 0) {
if (recorded !== undefined) next[file] = recorded;
continue;
}

const created = recorded?.created ?? text === undefined;
// A file this run writes from nothing is one Taskless created, whatever
// an earlier record said about a file that has since been deleted.
const created = text === undefined || recorded?.created === true;
const result = harness.edit(text, add, remove, {
invocation: options.invocation,
...(options.launcher === undefined ? {} : { launcher: options.launcher }),
Expand All @@ -937,8 +940,10 @@ export async function syncPermissions(

const removed = remove;
const rules = [
...had.filter((rule) => want.includes(rule) || isOther(rule)),
...result.added,
...new Set([
...had.filter((rule) => want.includes(rule) || isOther(rule)),
...result.added,
]),
];
if (rules.length > 0) next[file] = { rules, created };
if (result.added.length === 0 && removed.length === 0) continue;
Expand Down
123 changes: 123 additions & 0 deletions packages/cli/test/permissions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,18 @@ async function json(path: string): Promise<unknown> {
return JSON.parse(await text(path)) as unknown;
}

/** The manifest's permission record for `file`. */
async function record(
file: string
): Promise<{ rules: string[]; created: boolean } | undefined> {
const manifest = (await json(".taskless/taskless.json")) as {
install: {
permissions?: Record<string, { rules: string[]; created: boolean }>;
};
};
return manifest.install.permissions?.[file];
}

function sync(
options: Partial<SyncPermissionsOptions> & { selected: string[] }
): ReturnType<typeof syncPermissions> {
Expand Down Expand Up @@ -882,3 +894,114 @@ describe("launcher rules", () => {
expect(commandOfRule(`Bash(${RL} *)`)).toBe(RL);
});
});

describe("a recorded rule the file no longer holds", () => {
/** Each harness's file, its rule, and the file with that rule taken out. */
const harnesses: {
name: string;
selected: string[];
codex?: boolean;
file: string;
rule: string;
/** How the rule reads in the file's text. */
written: string;
without: string;
}[] = [
{
name: "Claude Code",
selected: [".claude"],
file: ".claude/settings.json",
rule: `Bash(${V1} *)`,
written: `"Bash(${V1} *)"`,
without: '{ "permissions": { "allow": [] } }\n',
},
{
name: "the Cursor editor",
selected: [".cursor"],
file: ".cursor/permissions.json",
rule: V1,
written: `"${V1}"`,
without: '{ "terminalAllowlist": [] }\n',
},
{
name: "the Cursor CLI",
selected: [".cursor"],
file: ".cursor/cli.json",
rule: "Shell(npx:@taskless/cli@0.13.0 *)",
written: '"Shell(npx:@taskless/cli@0.13.0 *)"',
without: '{ "permissions": { "allow": [] } }\n',
},
{
name: "OpenCode",
selected: [".opencode"],
file: ".opencode/opencode.json",
rule: `${V1} *`,
written: `"${V1} *"`,
without: '{ "permission": { "bash": {} } }\n',
},
{
name: "Codex",
selected: [".agents"],
codex: true,
file: ".codex/rules/taskless.rules",
rule: 'prefix_rule(pattern = ["npx","@taskless/cli@0.13.0"], decision = "allow")',
written:
'prefix_rule(pattern = ["npx","@taskless/cli@0.13.0"], decision = "allow")',
without:
"# Written by Taskless: allows exactly the installed Taskless CLI.\n",
},
];

for (const harness of harnesses) {
const options = {
selected: harness.selected,
codex: harness.codex ?? false,
};

describe(harness.name, () => {
it("is added back, and reported as an edit", async () => {
await sync(options);
const first = await record(harness.file);
expect(first?.rules).toContain(harness.rule);
await put(harness.file, harness.without);

const edits = await sync(options);
const edit = edits.find((found) => found.file === harness.file);
expect(edit).toMatchObject({ created: false, skipped: null });
expect(edit?.allowed).toContain(harness.rule);
expect(await text(harness.file)).toContain(harness.written);
// The record lists the rule once, not once per restore.
const restored = await record(harness.file);
expect(
restored?.rules.filter((rule) => rule === harness.rule)
).toHaveLength(1);
expect(await sync(options)).toEqual([]);
});

it("is written to a new file when the file was deleted", async () => {
// The file existed before Taskless, so the record says it did not
// create it.
await put(harness.file, harness.without);
await sync(options);
expect(await record(harness.file)).toMatchObject({ created: false });
await rm(join(cwd, harness.file));

const edits = await sync(options);
const edit = edits.find((found) => found.file === harness.file);
expect(edit).toMatchObject({ created: true, skipped: null });
expect(edit?.allowed).toContain(harness.rule);
expect(await text(harness.file)).toContain(harness.written);
// Taskless made this file, so it may delete it once it is empty.
expect(await record(harness.file)).toMatchObject({ created: true });
});

it("makes no edit when the record and the file agree", async () => {
await sync(options);
const before = await text(harness.file);

expect(await sync(options)).toEqual([]);
expect(await text(harness.file)).toBe(before);
});
});
}
});
Loading