diff --git a/.changeset/init-global-agent-trust.md b/.changeset/init-global-agent-trust.md new file mode 100644 index 00000000..f10a6df0 --- /dev/null +++ b/.changeset/init-global-agent-trust.md @@ -0,0 +1,5 @@ +--- +"@taskless/cli": patch +--- + +Claude Code's auto mode can refuse a Taskless command even when the project allows that exact version, because its classifier overrules project allow rules for downloaded code and reads its own exceptions only from your user settings. The setup wizard (`npx @taskless/cli` with no arguments) now lists every settings edit together, as aligned `ALLOW (project):`, `REMOVE (project):`, and `ALLOW (global):` statements, each naming the command and the file, under one question, "Make these changes to your settings?", and on yes adds one entry to the top-level `autoMode.allow` list in `~/.claude/settings.json` (or under `CLAUDE_CONFIG_DIR`). The entry starts `Taskless CLI:`, names no version, and covers a Taskless call only when it names an exact version; it is added once, after `"$defaults"` when `init` creates the list, a later install that finds it leaves it alone, and one that does not offers it again. Only the interactive wizard writes it: the batch `init` never writes outside the project, and instead warns when the entry is missing, naming the file, the key, and the text to add. `init --json` reports it under `globalTrust`. When a classifier does refuse a Taskless command, the skill now tells the agent to stop without retrying and hand you that entry, rather than offering a project allow rule that cannot help. diff --git a/.changeset/pinned-cli-permissions.md b/.changeset/pinned-cli-permissions.md index 2d7c8c02..d98b8e2b 100644 --- a/.changeset/pinned-cli-permissions.md +++ b/.changeset/pinned-cli-permissions.md @@ -6,6 +6,6 @@ Released builds now pin their own version. The installed skill and the `agent` r `init` now also reports stale Taskless pins in CI and git-hook files (GitHub Actions, GitLab CI, CircleCI, husky, lefthook, pre-commit, and the rest of the files `detect` recognizes) by file and line, through the same notice and `pinnedCli` JSON field as `package.json` pins. It recommends the version to move to and never edits the file. -`init` now also allows exactly the installed CLI in each agent it installs for, so a harness that gates download-and-run commands lets Taskless through without a prompt: Claude Code (`.claude/settings.json`), Cursor (`.cursor/permissions.json` and `.cursor/cli.json`), OpenCode (`opencode.json[c]`), and Codex (`.codex/rules/taskless.rules`, when Codex is present). Each rule names one version and cannot match another. Every edit is announced, as `Edited ` with a green `ALLOW:` line, and the wizard shows it and asks before writing. An upgrade replaces only the rule Taskless recorded, a rule you wrote is never touched, and a file that denies the command or does not parse is left alone and reported. `init --json` carries the same edits under `permissions`. Agents Taskless does not install into use per-command approval through the skill. +`init` now also allows exactly the installed CLI in each agent it installs for, so a harness that gates download-and-run commands lets Taskless through without a prompt: Claude Code (`.claude/settings.json`), Cursor (`.cursor/permissions.json` and `.cursor/cli.json`), OpenCode (`opencode.json[c]`), and Codex (`.codex/rules/taskless.rules`, when Codex is present). Each rule names one version and cannot match another. Every edit is announced, as `Edited ` with a green `ALLOW:` line, and the wizard shows it and asks before writing. Each file ends up with exactly one Taskless rule, the installed version's: any other allow rule for `@taskless/cli` or `@taskless/cli-nightly`, whatever version it names and however it got there, is removed and shown as a red `REMOVE:` line. Deny rules and rules for anything else are never touched, and a file that denies the command or does not parse is left alone and reported. `init --json` carries the same edits under `permissions`. Agents Taskless does not install into use per-command approval through the skill. The setup wizard's tool list now has one `.agents/` row, labelled with the detected harnesses that read it, such as `OpenCode, Codex, and other .agents harnesses (.agents/)`, instead of separate `Codex` and `Agent Skills` rows. Gemini CLI and Crush are now detected, and install the skill into `.agents/`. diff --git a/openspec/changes/archive/2026-10-15-init-global-agent-trust/.openspec.yaml b/openspec/changes/archive/2026-10-15-init-global-agent-trust/.openspec.yaml new file mode 100644 index 00000000..a216797d --- /dev/null +++ b/openspec/changes/archive/2026-10-15-init-global-agent-trust/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-15 diff --git a/openspec/changes/archive/2026-10-15-init-global-agent-trust/design.md b/openspec/changes/archive/2026-10-15-init-global-agent-trust/design.md new file mode 100644 index 00000000..2f588a21 --- /dev/null +++ b/openspec/changes/archive/2026-10-15-init-global-agent-trust/design.md @@ -0,0 +1,63 @@ +## Context + +`install/permissions.ts` writes one rule set per harness into project files, driven by `syncPermissions`, and decides what to remove from the manifest's `install.permissions` record: it removes exactly what it recorded and nothing else. The wizard calls it twice, once with `write: false` to preview in the summary step and once to write; `--no-interactive` calls it once and prints the edits afterwards. The skill body source is `skills/taskless/SKILL.md`, 121 lines against a 150-line cap enforced in `installed-documentation.test.ts`. + +Claude Code's auto mode reads `autoMode.{environment,allow,soft_deny,hard_deny}` as prose lists from user settings, where `"$defaults"` inside a list splices in the shipped entries. `claude auto-mode critique` reported "No custom auto mode rules found" when the section lived in a project's `.claude/settings.json`, which confirms the project file is not read for it. + +## Goals / Non-Goals + +**Goals:** + +- One Taskless rule per project permission file, regardless of how earlier rules got there. +- A shape for user-level trust that a second harness can join by adding one entry, without touching `syncPermissions` or the wizard. +- No path by which a non-interactive or agent-driven `init` writes outside the project. + +**Non-Goals:** + +- Codex. Its guardian auto-review appears to review only commands that would otherwise prompt, so a matching `prefix_rule` allow, which `init` already writes, should pre-empt it. That claim comes from third-party write-ups that disagree on config key names; it is not verified, so Codex does not join the table until it is. +- Removing the global entry on uninstall. It names no repo and covers every Taskless project the user has, so one repo's uninstall is not a reason to remove it. +- Proving the entry clears the classifier. That is a judgement by a model and cannot be tested in CI; see Risks. + +## Decisions + +**Recognize Taskless rules by pattern per harness, not by the record.** Each `Harness` gains `found(text): string[]`, returning every Taskless allow rule in its file, written the way its `edit` takes them in `remove`, and matching its own format: Claude `Bash( *)` and `Bash(:*)`; Cursor editor ``; Cursor CLI `Shell(npx:)` and `Shell(npx: *)`; OpenCode object keys and array resources; Codex `prefix_rule` patterns. `` is `npx` or `pnpm dlx` followed by `@taskless/cli` or `@taskless/cli-nightly`, with or without an `@` suffix. Each harness's `edit` already receives a `remove` list, so `syncPermissions` reads every file first and computes `remove` as `found` minus the wanted rules, instead of the recorded rules minus the wanted ones. The record still supplies `created`, for the delete-when-empty rule. _Alternative:_ keep the record as the authority and add a one-time migration that adopts existing Taskless rules into it. Rejected: it only fixes rules present at migration time, and the skill's fallback can add a new unrecorded rule at any point. + +Deny-shaped entries are never found, because `found` reads only allow lists (Claude `permissions.allow`, Cursor `terminalAllowlist` and `permissions.allow`, OpenCode `"allow"` values and `effect: "allow"`, Codex `decision="allow"`). + +**A separate `GlobalTrust` interface, not another `Harness`.** It has a different lifecycle: it is not recorded, not versioned, is written at most once, and is never removed. Folding it into `Harness` would put conditionals for all four through `syncPermissions`. Shape: + +```ts +interface GlobalTrust { + harness: string; // "Claude Code" + selectedBy: string; // ".claude" + path(environment: { home; env }): string; // honours CLAUDE_CONFIG_DIR + key: readonly string[]; // ["autoMode", "allow"] + seed: readonly string[]; // ["$defaults", entry], for a new list +} +``` + +Detection and the edit are shared, not per harness: an entry is present when the list holds a string starting with the label, and the edit appends the entry, or writes `seed` when the list is absent. `syncGlobalTrust({ selected, write, environment })` returns one outcome per applicable entry: `present`, `absent`, `written`, or `skipped` with a reason. `environment` defaults to the real home directory and `process.env`; tests pass their own, and the spawned-CLI tests set `HOME`, so no test reaches the real home directory. `GLOBAL_TRUST` is a one-element list; joining it is the only change a second harness needs. + +**The entry text**, fixed in source and identical across versions: + +> Taskless CLI: The npm packages @taskless/cli and @taskless/cli-nightly are tooling published by Taskless via npm trusted publishing (OIDC), installed by the user through the Taskless installer. Running either via npx or pnpm dlx is allowed only when the call names an exact version (e.g. @taskless/cli@1.2.3). Dist-tags such as @latest or @nightly, version ranges, and unversioned calls are not covered. + +It follows the shipped entries' `Name: description` form, which is also what makes label detection possible. The example version is deliberately fake so the text never ages. + +**`$defaults` only when creating the list.** An existing list without it is a choice the user made, to replace the defaults; adding it back would widen what their classifier allows. Edits go through the same `jsonc-parser` edit path as the project files, so formatting and comments survive. + +**One confirmation in the wizard, covering every settings edit.** The summary groups the project and global edits under one heading, as aligned `ALLOW (project):`, `REMOVE (project):`, and `ALLOW (global):` statements, and asks `Make these changes to your settings?`, defaulting to yes. Declining cancels the install and writes nothing, the same meaning the project confirm already had. _Alternative:_ a separate global confirm after the project one, so a user could set up the repo without touching their home directory. Rejected for the friction: two questions for one install, and the second asked on every run until the entry exists, since a decline is never recorded. + +**The warning is computed after writing.** Both modes call `syncGlobalTrust` read-only after the install and warn for each `absent` outcome, which covers the batch install, and a wizard install whose user file was skipped, with one code path. + +## Risks / Trade-offs + +- [The classifier ignores or misreads the entry] → The entry is guidance to a model, not a rule. Mitigation: the skill's fallback still handles a refusal, and it now gives the entry inline, so a user without it, or with a wording that does not land, still gets a path. The wording can change without a migration because detection is by label; existing users keep the old text, which is acceptable since it is still a valid entry. +- [Pattern removal deletes a Taskless rule the user wanted] → By decision, one line per file and it is the installed one. Every removal is shown as `REMOVE:` in the wizard preview before anything is written. +- [A recognizer is too broad and removes a non-Taskless rule] → The command must be `npx` or `pnpm dlx` immediately followed by one of two exact package names; tested per harness with near-misses (`@taskless/cli-foo`, `npx -y @taskless/cli`, other packages). +- [Writing to `~/.claude/settings.json` races a running Claude Code session] → Claude Code rewrites its settings file itself. Mitigation: read-modify-write in one step, immediately after the confirm, through a minimal `jsonc-parser` edit rather than a re-serialization. +- [The skill body grows] → About 15 lines against 29 lines of headroom under the cap. + +## Migration Plan + +None beyond the release. The first `init` after upgrading removes stale Taskless rules and shows them as `REMOVE:` lines. Rollback is a downgrade; stale rules removed by the new version are not restored, which is the intended state anyway. diff --git a/openspec/changes/archive/2026-10-15-init-global-agent-trust/proposal.md b/openspec/changes/archive/2026-10-15-init-global-agent-trust/proposal.md new file mode 100644 index 00000000..bc4c3ab8 --- /dev/null +++ b/openspec/changes/archive/2026-10-15-init-global-agent-trust/proposal.md @@ -0,0 +1,33 @@ +## Why + +A user onboarding in Claude Code's auto mode was stopped at `npx @taskless/cli-nightly@ info --json`, which the classifier labelled "Code from External", even though `.claude/settings.json` allowed exactly that invocation. Auto mode does not trust a project allow rule that runs downloaded code, and it does not read the `autoMode` section from a checked-in project file at all, so nothing `init` writes into the repo can clear it. The user already made that trust decision when they ran `npx @taskless/cli init`; the classifier cannot see it, because it judges only the session. The only place that decision can be carried into the session is the user's own settings, and the only moment a human is present to make it is the interactive install. + +The same investigation showed the project files drifting: an older Taskless rule for a previous nightly sat next to the current one, because it was added through the skill's fallback rather than by `init`, so the manifest never recorded it and an upgrade left it in place. A pinned rule for a version that is no longer installed allows nothing anyone wants. + +## What Changes + +- **Project permission files hold exactly one Taskless rule.** `init` removes every Taskless allow rule in a harness file it manages, whatever version it names and whether or not the manifest recorded it, and writes the rule for the installed invocation. Each removal is reported as a `REMOVE:` line. Deny rules and non-Taskless entries are never touched. **BREAKING** for the current spec: a Taskless rule the user wrote no longer survives an upgrade. +- **A per-harness global trust slot.** For a harness whose safety classifier can overrule a project allow rule, the interactive wizard offers to write one entry to that harness's user-level settings. Only Claude Code is implemented: a top-level `autoMode.allow` entry in `~/.claude/settings.json`, labelled `Taskless CLI:`, that names no version and states that a call is covered only when it names an exact version. When `init` creates the `allow` list it seeds it with `"$defaults"`, so the shipped exceptions stay in force. +- **Written once, confirmed separately, interactive only.** The entry is found by its label, so a later install sees it and leaves it alone. The wizard shows it in its summary as an edit to a file outside the project and asks for its own confirmation. `--no-interactive` never writes outside the project. Nothing about it is recorded in the repository manifest, which is shared by everyone who clones the repo. +- **A warning when it is missing.** Both modes read the user file and, when the entry is absent, print a warning naming the file and the entry to add. +- **The skill's blocked-command fallback is self-contained.** When a Taskless command is refused by an agent's safety classifier, the installed skill tells the agent to stop, not retry, and give the user the global entry, inline. It cannot point at an `agent ` recipe, because the recipe is fetched through the same blocked `npx` call. For Claude Code's auto mode it stops offering a project allow rule, which the classifier overrules. + +## Capabilities + +### New Capabilities + +- `agent-global-trust`: the user-level trust entry for harnesses whose classifier overrules project allow rules: which harnesses have one, its wording, how it is detected, when it is offered and written, and the warning when it is absent. + +### Modified Capabilities + +- `cli-init`: the manifest-recorded-rules requirement becomes "exactly one Taskless rule per permission file", removing unrecorded Taskless rules; the permission-edit report gains the user-level file. +- `skill-taskless`: the blocked-preflight fallback distinguishes a classifier refusal, carries the global entry inline, and drops the project allow-rule offer where the classifier overrules it. + +## Impact + +- `packages/cli/src/install/permissions.ts`: per-harness recognition of a Taskless rule in each file format (including the `Bash(:*)` form the skill used to propose); removal of unrecorded Taskless rules; the global trust slot and its Claude Code implementation. +- `packages/cli/src/wizard/index.ts`, `wizard/steps/summary.ts`: preview and separate confirm for the user-level edit. +- `packages/cli/src/commands/init.ts`: the missing-entry warning in `--no-interactive`; the `--json` envelope gains the global trust outcome. +- `packages/cli/src/agent/init.md` (the `init --json` shape) and the installed skill body template. +- Tests in `packages/cli/test/permissions.test.ts` and the wizard and skill-body tests. No home-directory writes in tests: the user settings path is injected. +- Delivery shape: **single PR**. The pieces are only meaningful together (the warning, the wizard offer, and the skill fallback all point at the same entry), and the whole change fits one reviewable diff. diff --git a/openspec/changes/archive/2026-10-15-init-global-agent-trust/specs/agent-global-trust/spec.md b/openspec/changes/archive/2026-10-15-init-global-agent-trust/specs/agent-global-trust/spec.md new file mode 100644 index 00000000..e376feb0 --- /dev/null +++ b/openspec/changes/archive/2026-10-15-init-global-agent-trust/specs/agent-global-trust/spec.md @@ -0,0 +1,128 @@ +## Purpose + +Carries the trust a user grants by installing Taskless into agents whose safety classifier overrules project allow rules, through one user-level entry that a human confirms once during the interactive install. + +## ADDED Requirements + +### Requirement: Harnesses with a global trust entry + +A harness SHALL have a global trust entry only when its safety classifier can refuse a command that the project permission file allows, and its user-level settings accept a written exception. The list is closed and SHALL be exactly: + +| Harness | File | Location | +| ----------- | ------------------------- | ------------------------------------------- | +| Claude Code | `~/.claude/settings.json` | top-level `autoMode.allow`, a list of prose | + +A harness SHALL be considered only when `init` installs the skill for it, by the same selection that decides its project permission rule. No other harness SHALL receive a global entry, read or written, until it is added to this table. + +#### Scenario: Only Claude Code is considered + +- **WHEN** `init` installs for `.cursor/` and `.opencode/` only +- **THEN** `init` SHALL NOT read or write any file outside the project for a global trust entry + +#### Scenario: Claude Code is considered when selected + +- **WHEN** `init` installs for `.claude/` +- **THEN** `init` SHALL read `~/.claude/settings.json` to look for the entry + +### Requirement: The global entry names no version and requires one + +The entry SHALL begin with the label `Taskless CLI:`. It SHALL name `@taskless/cli` and `@taskless/cli-nightly`, state that running either through `npx` or `pnpm dlx` is allowed only when the call names an exact version, and state that dist-tags such as `@latest` or `@nightly`, version ranges, and unversioned calls are not covered. It SHALL NOT name a version, so an upgrade never makes it stale. + +#### Scenario: The entry carries no version + +- **WHEN** the entry is written by any version of the CLI +- **THEN** its text SHALL be identical, and SHALL contain no version number + +#### Scenario: The entry states the pin requirement + +- **WHEN** the entry is read +- **THEN** it SHALL say a call is covered only when it names an exact version +- **AND** it SHALL say `@latest` is not covered + +### Requirement: The global entry is detected by its label + +`init` SHALL treat the entry as present when the harness's list holds any string beginning with `Taskless CLI:`, whatever follows the label. A present entry SHALL never be rewritten, replaced, or removed. + +#### Scenario: A reworded entry is left alone + +- **WHEN** `autoMode.allow` holds `"Taskless CLI: allowed with a pinned version"` +- **THEN** `init` SHALL treat the entry as present +- **AND** SHALL NOT edit the file + +### Requirement: The global entry is offered only in the interactive wizard + +When the entry is absent, the wizard SHALL show the proposed edit in its summary step, in the same group as the project permission edits and after them, as one line labelled `ALLOW (global):` that names the packages, says a call is covered only at an exact version, and names the file and key. Project and global edits SHALL be covered by the one confirmation the summary step asks, defaulting to yes; there SHALL be no separate question for the global entry. Declining that confirmation SHALL cancel the install and write nothing, inside or outside the project. + +`--no-interactive` and `--json` SHALL NEVER write a file outside the project. + +When written, the edit SHALL add the entry to the list, keeping every other key and entry in the file. When the file or the `allow` list does not exist, the written list SHALL be `["$defaults", ]`, so the harness's shipped exceptions stay in force. When the list exists without `"$defaults"`, `init` SHALL NOT add it, since the user chose to replace the defaults. When the file does not parse, `init` SHALL leave it unchanged and report it as skipped. + +Nothing about the entry SHALL be recorded in the project manifest, and a declined offer SHALL NOT be remembered anywhere: every wizard run that does not find the entry SHALL offer it again. + +#### Scenario: The wizard offers the entry + +- **WHEN** the wizard installs for `.claude/` and `~/.claude/settings.json` has no `Taskless CLI:` entry +- **THEN** the summary SHALL show an `ALLOW (global):` line naming `~/.claude/settings.json` and `autoMode.allow` +- **AND** the one confirmation SHALL be `Make these changes to your settings?` when nothing else is being removed + +#### Scenario: Declining writes nothing anywhere + +- **WHEN** the user declines the summary's confirmation +- **THEN** `~/.claude/settings.json` SHALL be byte-identical afterwards +- **AND** no project permission file SHALL be written + +#### Scenario: The global entry alone is still asked + +- **WHEN** the project's permission files already hold the installed rule and the entry is absent +- **THEN** the summary SHALL show only the `ALLOW (global):` line and ask the same confirmation + +#### Scenario: A declined offer is asked again + +- **WHEN** the user declined the entry on an earlier wizard run and it is still absent +- **THEN** the next wizard run SHALL show and ask for it again + +#### Scenario: A new list keeps the defaults + +- **WHEN** the entry is written to a `~/.claude/settings.json` with no `autoMode` key +- **THEN** `autoMode.allow` SHALL be `["$defaults", ]` +- **AND** every other key in the file SHALL be unchanged + +#### Scenario: An existing list is appended to + +- **WHEN** `autoMode.allow` is `["$defaults", "Internal Registry: ..."]` +- **THEN** the written list SHALL be `["$defaults", "Internal Registry: ...", ]` + +#### Scenario: Non-interactive never writes it + +- **WHEN** `init --no-interactive` runs for `.claude/` and the entry is absent +- **THEN** `~/.claude/settings.json` SHALL be byte-identical afterwards + +#### Scenario: The manifest does not record it + +- **WHEN** the wizard writes the entry +- **THEN** `.taskless/taskless.json` SHALL contain no reference to `~/.claude/settings.json` + +### Requirement: A missing global entry is warned about + +When a harness in the table is installed for and its entry is absent after `init` finishes, `init` SHALL print a warning, in both modes, naming the harness, the file, the top-level key, and the exact entry to add, saying that without it the harness's classifier may refuse Taskless commands that the project rule allows, and naming the CLI invocation, run with no arguments in a terminal, as the way to be offered it, since `init` itself never asks. Under `--json` the warning SHALL go to stderr, and the envelope SHALL report, per harness in the table, the file and whether the entry is present, was written, or was skipped with a reason. + +#### Scenario: Non-interactive warns + +- **WHEN** `init --no-interactive` runs for `.claude/` and the entry is absent +- **THEN** the output SHALL include a warning naming `~/.claude/settings.json`, `autoMode.allow`, and the entry text +- **AND** the warning SHALL name the installer invocation, run with no arguments in a terminal, as the way to be offered it + +#### Scenario: A skipped file warns after a wizard install + +- **WHEN** the wizard completes an install for `.claude/` and `~/.claude/settings.json` does not parse +- **THEN** the wizard SHALL print the same warning, with the reason the file was left alone + +#### Scenario: A present entry is silent + +- **WHEN** the entry is present +- **THEN** `init` SHALL print no global trust warning + +#### Scenario: JSON reports the state + +- **WHEN** `init --json --no-interactive` runs for `.claude/` and the entry is absent +- **THEN** the envelope SHALL report `~/.claude/settings.json` with the entry absent diff --git a/openspec/changes/archive/2026-10-15-init-global-agent-trust/specs/cli-init/spec.md b/openspec/changes/archive/2026-10-15-init-global-agent-trust/specs/cli-init/spec.md new file mode 100644 index 00000000..a4755622 --- /dev/null +++ b/openspec/changes/archive/2026-10-15-init-global-agent-trust/specs/cli-init/spec.md @@ -0,0 +1,94 @@ +## MODIFIED Requirements + +### Requirement: Init reports every permission edit + +Every time `init` edits or creates a harness permission file, its output SHALL name the file and each rule it changed, one per line, labelled so the reader cannot miss that a settings file was touched: + +``` +Edited .claude/settings.json + ALLOW: Bash(npx @taskless/cli@0.13.1 *) + REMOVE: Bash(npx @taskless/cli@0.13.0 *) +``` + +- `ALLOW: ` for a rule added, rendered in green. +- `REMOVE: ` for a Taskless rule taken out because it is not the installed invocation's rule, whether or not the manifest recorded it, rendered in red. +- `SKIPPED: ` with the reason, for a file left alone because it denies the invocation or does not parse, rendered in yellow. + +The first line SHALL say `Created` for a file Taskless created and `Edited` for one that existed. The labels SHALL carry the meaning on their own, so the output reads the same with color off (`NO_COLOR`, no TTY). Color SHALL come from the CLI's shared color setup, so it follows the real terminal. + +The wizard SHALL show each permission edit in its summary step, before anything is written, so the user sees it before confirming, as one statement per command and file, grouped with any global trust edit under one heading: `ALLOW (project):`, `REMOVE (project):`, or `SKIPPED (project):`, followed by the invocation the rule allows (so a harness whose rules come in two spellings reads as one statement) and, on the next line, `in `; a skip names the file and then the reason. Labels SHALL be padded to the widest label in the group so every statement starts in the same column, and each statement SHALL wrap under itself, on spaces only, to keep lines within 76 columns, so a version token is never split. One confirmation covers every settings edit: `Make these changes to your settings?`, or, when stubs are also being removed, the removal question extended with `, and make these changes to your settings?`. `--no-interactive` SHALL print the labelled lines above after writing. Under `--json` they SHALL go to stderr with the rest of the human summary, and the envelope SHALL carry the same information as data: per file, the path, whether it was created, the rules allowed and removed, and any skip reason. + +#### Scenario: A first install names the edit + +- **WHEN** `init --no-interactive` adds a rule to an existing `.claude/settings.json` +- **THEN** the output SHALL include `Edited .claude/settings.json` followed by `ALLOW: Bash( *)` +- **AND** with color on, the `ALLOW` line SHALL be green + +#### Scenario: An upgrade shows both sides + +- **WHEN** `init` replaces a recorded `0.13.0` rule with the `0.13.1` rule +- **THEN** the output SHALL list `ALLOW:` for the `0.13.1` rule and `REMOVE:` for the `0.13.0` rule under the file's name + +#### Scenario: An unrecorded Taskless rule is reported when removed + +- **WHEN** `init` removes `Bash(npx @taskless/cli-nightly@0.13.0-20261015220659xbf27a23 *)`, which the manifest did not record +- **THEN** the output SHALL list `REMOVE:` for that rule under the file's name + +#### Scenario: The wizard previews before writing + +- **WHEN** the wizard reaches its summary step for an install that will edit `.cursor/cli.json` +- **THEN** the summary SHALL show one `ALLOW (project):` statement for the invocation, with `in .cursor/cli.json` beneath it, before the user confirms +- **AND** nothing SHALL be written if the user cancels + +#### Scenario: Color off still reads + +- **WHEN** `NO_COLOR` is set +- **THEN** the lines SHALL carry the same `ALLOW:`, `REMOVE:`, and `SKIPPED:` labels with no escape sequences + +#### Scenario: JSON carries the edits as data + +- **WHEN** `init --json` edits a permission file +- **THEN** the envelope SHALL report that file's path, whether it was created, and the exact rules allowed and removed + +## ADDED Requirements + +### Requirement: Each permission file holds exactly one Taskless rule + +Each harness permission file `init` manages SHALL hold exactly one Taskless rule set afterwards: the rules for the installed invocation, from the table in the requirement that init allows the installed CLI. 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. For Claude Code this includes both the `Bash( *)` form and the `Bash(:*)` form. On every `init` the installer SHALL remove each Taskless rule that is not one of the installed invocation's rules, whether or not the manifest recorded it, and write the current ones. It 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 its Taskless rules removed. + +#### 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: 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:*)` +- **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 + +## REMOVED Requirements + +### Requirement: The manifest records permission rules, and an upgrade replaces only those + +**Reason**: An upgrade removed only the rules the manifest recorded, so a Taskless rule added any other way (by hand, or by the agent through the skill's fallback) outlived its version and sat next to the current rule, allowing a build nobody uses. Pins are exact so that a version change is a deliberate act, and the stale rule undermines that. + +**Migration**: Replaced by "Each permission file holds exactly one Taskless rule". The first `init` after upgrading removes every Taskless allow rule that is not the installed invocation's and lists each as `REMOVE:`. The manifest record is kept and still decides whether a file Taskless created may be deleted. diff --git a/openspec/changes/archive/2026-10-15-init-global-agent-trust/specs/skill-taskless/spec.md b/openspec/changes/archive/2026-10-15-init-global-agent-trust/specs/skill-taskless/spec.md new file mode 100644 index 00000000..61a1bc35 --- /dev/null +++ b/openspec/changes/archive/2026-10-15-init-global-agent-trust/specs/skill-taskless/spec.md @@ -0,0 +1,46 @@ +## MODIFIED Requirements + +### Requirement: Skill preflights the CLI before the first Taskless command + +The skill body SHALL instruct the agent to run `%(TASKLESS_CLI)s agent ok` once per session, before any other Taskless command, and to proceed only once it prints its confirmation line. Once it does, the agent SHALL go straight to the user's request, without surveying the install or offering a menu of topics. On an install whose harness permission rule is in place the preflight passes without a prompt; the guidance below is the fallback for when it does not. + +When the preflight, or any later Taskless command, is blocked by the harness or the user, the body SHALL instruct the agent to: + +1. Stop before doing any further Taskless work, and not retry the command or attempt to work around the block. +2. Send one message that names the exact command, and says it downloads and runs a package from npm. +3. When the refusal came from the agent's own safety classifier rather than a missing permission (in Claude Code, a denial by the auto mode classifier), say that an allow rule cannot clear it, and give the user the global trust entry, inline, with the file and key to add it under (in Claude Code, top-level `autoMode.allow` in `~/.claude/settings.json`, after `"$defaults"`). The agent SHALL NOT write that entry itself. It SHALL also offer per-command approval in chat, and leaving the classifier's mode, as the alternatives. +4. Otherwise, offer the user three ways forward: the agent adds an allow rule for exactly the invocation the skill names to the agent's permission settings, and if the harness blocks that edit too, gives the user the change to make themselves; the user approves it through their agent's permissions command (in Claude Code, `/permissions` with the rule `Bash(:*)`); or the user approves every Taskless command manually, in chat, as the agent runs it. +5. Never propose an allow rule that widens the invocation, such as a wildcard over the package name or its version. Where the harness's allowlist cannot scope to the one invocation, for example one that only allows a whole tool, offer per-command approval in chat instead of a rule. +6. Not ask in that message what the user wants Taskless to do. Their request stands, and the agent carries it out once the command is unblocked. + +The global trust entry and these instructions SHALL be written in the skill body itself, not fetched from an `agent ` recipe, because a recipe is fetched through the same command that is blocked. + +#### Scenario: Skill body runs the preflight first + +- **WHEN** the skill body is read from source +- **THEN** it SHALL instruct the agent to run `%(TASKLESS_CLI)s agent ok` before any other Taskless command in the session + +#### Scenario: Skill body handles a blocked preflight + +- **WHEN** the skill body is read from source +- **THEN** it SHALL instruct the agent, on a blocked preflight, to stop and name the exact command to the user in one message +- **AND** SHALL offer an allow rule for the exact invocation added by the agent, with the user making the change when the harness blocks the agent's edit +- **AND** SHALL offer the agent's permissions command, and per-command approval in chat +- **AND** SHALL NOT offer running the command with `!`, which unblocks only the preflight +- **AND** SHALL forbid an allow rule that wildcards the package name or version +- **AND** SHALL offer only per-command approval where the harness cannot allow the one invocation without allowing a whole tool +- **AND** SHALL forbid asking what the user wants Taskless to do in that message + +#### Scenario: Skill body handles a classifier refusal + +- **WHEN** the skill body is read from source +- **THEN** it SHALL instruct the agent, when a Taskless command is refused by the agent's safety classifier, to stop without retrying +- **AND** SHALL contain the global trust entry text inline, beginning `Taskless CLI:` +- **AND** SHALL name `~/.claude/settings.json` and `autoMode.allow` for Claude Code +- **AND** SHALL NOT offer a project allow rule for that refusal +- **AND** SHALL forbid the agent from writing the entry itself + +#### Scenario: The fallback does not depend on a recipe + +- **WHEN** the skill body is read from source +- **THEN** its blocked-command guidance SHALL NOT instruct the agent to run an `agent ` command to learn how to unblock diff --git a/openspec/changes/archive/2026-10-15-init-global-agent-trust/tasks.md b/openspec/changes/archive/2026-10-15-init-global-agent-trust/tasks.md new file mode 100644 index 00000000..1a5e1054 --- /dev/null +++ b/openspec/changes/archive/2026-10-15-init-global-agent-trust/tasks.md @@ -0,0 +1,31 @@ +## 1. One Taskless rule per project file + +- [x] 1.1 Add `isTaskless(entry)` to each harness in `install/permissions.ts` (Claude both `*` and `:*` forms, Cursor editor, Cursor CLI, OpenCode object and array, Codex), matching only allow entries; verify with per-harness tests covering a pinned rule, an unversioned rule, a near-miss package (`@taskless/cli-foo`), another package, and a deny entry +- [x] 1.2 Change `syncPermissions` to remove every Taskless entry present in a managed file that is not a wanted rule, keeping the record only for `created`; verify the "unrecorded Taskless rule is replaced", "colon form", "other entries are untouched" and "pre-existing file is never deleted" scenarios as tests in `permissions.test.ts` +- [x] 1.3 Update the module comment and the `PermissionEdit.removed` doc comment, which still say unrecorded rules are never touched; verify by reading the diff +- [x] 1.4 Confirm the wizard preview and `--no-interactive` output list each unrecorded removal as `REMOVE:`; verify with a wizard summary test and an `init --no-interactive` test + +## 2. Global trust entry + +- [x] 2.1 Add the `GlobalTrust` interface, the `GLOBAL_TRUST` list with the Claude Code entry and its fixed text, and `syncGlobalTrust(home, selected, { write })`; verify with tests using a temporary home for: absent file, no `autoMode`, existing list with and without `$defaults`, a reworded `Taskless CLI:` entry treated as present, an unparseable file skipped, and comments preserved +- [x] 2.2 Assert that the entry text contains no digit sequence shaped like a real version except the fixed example, and begins with `Taskless CLI:`; verify with a unit test +- [x] 2.3 Ensure only selected harnesses in the table are read: verify a `.cursor/`-only install never touches the temporary home + +## 3. Wizard and init + +- [x] 3.1 Show the global edit in the wizard summary under its own heading naming the file as outside the project, with an `ALLOW:` line, and ask a separate confirm after the project confirm; verify with summary tests for offer, accept, and decline (decline leaves the file byte-identical and the install completes) +- [x] 3.2 Make `--no-interactive` and `--json` call `syncGlobalTrust` read-only only; verify with a test that the temporary home's settings file is byte-identical after `init --no-interactive` +- [x] 3.3 Print the missing-entry warning in both modes after the install, to stderr under `--json`, naming harness, file, key, and entry text; add the per-harness global trust outcome to the `init --json` envelope; verify with output and envelope tests, including silence when present +- [x] 3.4 Update `packages/cli/src/agent/init.md` with the envelope's new field; verify the prompt parity tests pass + +## 4. Skill fallback + +- [x] 4.1 Rewrite the preflight section of `skills/taskless/SKILL.md` per the modified `skill-taskless` requirement: classifier refusal branch with the entry inline and the file and key named, no project rule offered for it, the agent never writing the entry, and no `agent ` lookup; verify with new assertions in `installed-documentation.test.ts` and the 150-line cap test +- [x] 4.2 Assert the skill body's entry text equals the `GLOBAL_TRUST` entry text, so the two cannot drift; verify with a test that imports both + +## 5. Checks and release note + +- [x] 5.1 Add a `patch` changeset describing the stale-rule removal and the wizard's offer; verify `.changeset/` holds exactly one new file +- [x] 5.2 Run `pnpm typecheck`, `pnpm lint`, and `pnpm --filter @taskless/cli test`; verify all pass +- [x] 5.3 Run `pnpm openspec validate init-global-agent-trust --strict`, then the pre-archive scenario check from CLAUDE.md for `cli-init` and `skill-taskless`; verify every prior scenario survives, and that the removed requirement "The manifest records permission rules, and an upgrade replaces only those" is replaced by "Each permission file holds exactly one Taskless rule" +- [x] 5.4 List as a follow-up in the PR body: manually, in a scratch repo under Claude Code auto mode with the entry in `~/.claude/settings.json`, run onboarding and record whether `info --json` is still refused; note the result in the PR body (a follow-up, not a merge gate) diff --git a/openspec/specs/agent-global-trust/spec.md b/openspec/specs/agent-global-trust/spec.md new file mode 100644 index 00000000..e369ee6c --- /dev/null +++ b/openspec/specs/agent-global-trust/spec.md @@ -0,0 +1,130 @@ +# agent-global-trust Specification + +## Purpose + +Carries the trust a user grants by installing Taskless into agents whose safety classifier overrules project allow rules, through one user-level entry that a human confirms once during the interactive install. + +## Requirements + +### Requirement: Harnesses with a global trust entry + +A harness SHALL have a global trust entry only when its safety classifier can refuse a command that the project permission file allows, and its user-level settings accept a written exception. The list is closed and SHALL be exactly: + +| Harness | File | Location | +| ----------- | ------------------------- | ------------------------------------------- | +| Claude Code | `~/.claude/settings.json` | top-level `autoMode.allow`, a list of prose | + +A harness SHALL be considered only when `init` installs the skill for it, by the same selection that decides its project permission rule. No other harness SHALL receive a global entry, read or written, until it is added to this table. + +#### Scenario: Only Claude Code is considered + +- **WHEN** `init` installs for `.cursor/` and `.opencode/` only +- **THEN** `init` SHALL NOT read or write any file outside the project for a global trust entry + +#### Scenario: Claude Code is considered when selected + +- **WHEN** `init` installs for `.claude/` +- **THEN** `init` SHALL read `~/.claude/settings.json` to look for the entry + +### Requirement: The global entry names no version and requires one + +The entry SHALL begin with the label `Taskless CLI:`. It SHALL name `@taskless/cli` and `@taskless/cli-nightly`, state that running either through `npx` or `pnpm dlx` is allowed only when the call names an exact version, and state that dist-tags such as `@latest` or `@nightly`, version ranges, and unversioned calls are not covered. It SHALL NOT name a version, so an upgrade never makes it stale. + +#### Scenario: The entry carries no version + +- **WHEN** the entry is written by any version of the CLI +- **THEN** its text SHALL be identical, and SHALL contain no version number + +#### Scenario: The entry states the pin requirement + +- **WHEN** the entry is read +- **THEN** it SHALL say a call is covered only when it names an exact version +- **AND** it SHALL say `@latest` is not covered + +### Requirement: The global entry is detected by its label + +`init` SHALL treat the entry as present when the harness's list holds any string beginning with `Taskless CLI:`, whatever follows the label. A present entry SHALL never be rewritten, replaced, or removed. + +#### Scenario: A reworded entry is left alone + +- **WHEN** `autoMode.allow` holds `"Taskless CLI: allowed with a pinned version"` +- **THEN** `init` SHALL treat the entry as present +- **AND** SHALL NOT edit the file + +### Requirement: The global entry is offered only in the interactive wizard + +When the entry is absent, the wizard SHALL show the proposed edit in its summary step, in the same group as the project permission edits and after them, as one line labelled `ALLOW (global):` that names the packages, says a call is covered only at an exact version, and names the file and key. Project and global edits SHALL be covered by the one confirmation the summary step asks, defaulting to yes; there SHALL be no separate question for the global entry. Declining that confirmation SHALL cancel the install and write nothing, inside or outside the project. + +`--no-interactive` and `--json` SHALL NEVER write a file outside the project. + +When written, the edit SHALL add the entry to the list, keeping every other key and entry in the file. When the file or the `allow` list does not exist, the written list SHALL be `["$defaults", ]`, so the harness's shipped exceptions stay in force. When the list exists without `"$defaults"`, `init` SHALL NOT add it, since the user chose to replace the defaults. When the file does not parse, `init` SHALL leave it unchanged and report it as skipped. + +Nothing about the entry SHALL be recorded in the project manifest, and a declined offer SHALL NOT be remembered anywhere: every wizard run that does not find the entry SHALL offer it again. + +#### Scenario: The wizard offers the entry + +- **WHEN** the wizard installs for `.claude/` and `~/.claude/settings.json` has no `Taskless CLI:` entry +- **THEN** the summary SHALL show an `ALLOW (global):` line naming `~/.claude/settings.json` and `autoMode.allow` +- **AND** the one confirmation SHALL be `Make these changes to your settings?` when nothing else is being removed + +#### Scenario: Declining writes nothing anywhere + +- **WHEN** the user declines the summary's confirmation +- **THEN** `~/.claude/settings.json` SHALL be byte-identical afterwards +- **AND** no project permission file SHALL be written + +#### Scenario: The global entry alone is still asked + +- **WHEN** the project's permission files already hold the installed rule and the entry is absent +- **THEN** the summary SHALL show only the `ALLOW (global):` line and ask the same confirmation + +#### Scenario: A declined offer is asked again + +- **WHEN** the user declined the entry on an earlier wizard run and it is still absent +- **THEN** the next wizard run SHALL show and ask for it again + +#### Scenario: A new list keeps the defaults + +- **WHEN** the entry is written to a `~/.claude/settings.json` with no `autoMode` key +- **THEN** `autoMode.allow` SHALL be `["$defaults", ]` +- **AND** every other key in the file SHALL be unchanged + +#### Scenario: An existing list is appended to + +- **WHEN** `autoMode.allow` is `["$defaults", "Internal Registry: ..."]` +- **THEN** the written list SHALL be `["$defaults", "Internal Registry: ...", ]` + +#### Scenario: Non-interactive never writes it + +- **WHEN** `init --no-interactive` runs for `.claude/` and the entry is absent +- **THEN** `~/.claude/settings.json` SHALL be byte-identical afterwards + +#### Scenario: The manifest does not record it + +- **WHEN** the wizard writes the entry +- **THEN** `.taskless/taskless.json` SHALL contain no reference to `~/.claude/settings.json` + +### Requirement: A missing global entry is warned about + +When a harness in the table is installed for and its entry is absent after `init` finishes, `init` SHALL print a warning, in both modes, naming the harness, the file, the top-level key, and the exact entry to add, saying that without it the harness's classifier may refuse Taskless commands that the project rule allows, and naming the CLI invocation, run with no arguments in a terminal, as the way to be offered it, since `init` itself never asks. Under `--json` the warning SHALL go to stderr, and the envelope SHALL report, per harness in the table, the file and whether the entry is present, was written, or was skipped with a reason. + +#### Scenario: Non-interactive warns + +- **WHEN** `init --no-interactive` runs for `.claude/` and the entry is absent +- **THEN** the output SHALL include a warning naming `~/.claude/settings.json`, `autoMode.allow`, and the entry text +- **AND** the warning SHALL name the installer invocation, run with no arguments in a terminal, as the way to be offered it + +#### Scenario: A skipped file warns after a wizard install + +- **WHEN** the wizard completes an install for `.claude/` and `~/.claude/settings.json` does not parse +- **THEN** the wizard SHALL print the same warning, with the reason the file was left alone + +#### Scenario: A present entry is silent + +- **WHEN** the entry is present +- **THEN** `init` SHALL print no global trust warning + +#### Scenario: JSON reports the state + +- **WHEN** `init --json --no-interactive` runs for `.claude/` and the entry is absent +- **THEN** the envelope SHALL report `~/.claude/settings.json` with the entry absent diff --git a/openspec/specs/cli-init/spec.md b/openspec/specs/cli-init/spec.md index 2a5e9d66..202f365b 100644 --- a/openspec/specs/cli-init/spec.md +++ b/openspec/specs/cli-init/spec.md @@ -981,12 +981,12 @@ Edited .claude/settings.json ``` - `ALLOW: ` for a rule added, rendered in green. -- `REMOVE: ` for a recorded rule taken out on upgrade, rendered in red. +- `REMOVE: ` for a Taskless rule taken out because it is not the installed invocation's rule, whether or not the manifest recorded it, rendered in red. - `SKIPPED: ` with the reason, for a file left alone because it denies the invocation or does not parse, rendered in yellow. The first line SHALL say `Created` for a file Taskless created and `Edited` for one that existed. The labels SHALL carry the meaning on their own, so the output reads the same with color off (`NO_COLOR`, no TTY). Color SHALL come from the CLI's shared color setup, so it follows the real terminal. -The wizard SHALL show the same lines in its summary step, before anything is written, so the user sees each permission edit before confirming. `--no-interactive` SHALL print them after writing. Under `--json` they SHALL go to stderr with the rest of the human summary, and the envelope SHALL carry the same information as data: per file, the path, whether it was created, the rules allowed and removed, and any skip reason. +The wizard SHALL show each permission edit in its summary step, before anything is written, so the user sees it before confirming, as one statement per command and file, grouped with any global trust edit under one heading: `ALLOW (project):`, `REMOVE (project):`, or `SKIPPED (project):`, followed by the invocation the rule allows (so a harness whose rules come in two spellings reads as one statement) and, on the next line, `in `; a skip names the file and then the reason. Labels SHALL be padded to the widest label in the group so every statement starts in the same column, and each statement SHALL wrap under itself, on spaces only, to keep lines within 76 columns, so a version token is never split. One confirmation covers every settings edit: `Make these changes to your settings?`, or, when stubs are also being removed, the removal question extended with `, and make these changes to your settings?`. `--no-interactive` SHALL print the labelled lines above after writing. Under `--json` they SHALL go to stderr with the rest of the human summary, and the envelope SHALL carry the same information as data: per file, the path, whether it was created, the rules allowed and removed, and any skip reason. #### Scenario: A first install names the edit @@ -999,10 +999,15 @@ The wizard SHALL show the same lines in its summary step, before anything is wri - **WHEN** `init` replaces a recorded `0.13.0` rule with the `0.13.1` rule - **THEN** the output SHALL list `ALLOW:` for the `0.13.1` rule and `REMOVE:` for the `0.13.0` rule under the file's name +#### Scenario: An unrecorded Taskless rule is reported when removed + +- **WHEN** `init` removes `Bash(npx @taskless/cli-nightly@0.13.0-20261015220659xbf27a23 *)`, which the manifest did not record +- **THEN** the output SHALL list `REMOVE:` for that rule under the file's name + #### Scenario: The wizard previews before writing - **WHEN** the wizard reaches its summary step for an install that will edit `.cursor/cli.json` -- **THEN** the summary SHALL show the file and its `ALLOW:` lines before the user confirms +- **THEN** the summary SHALL show one `ALLOW (project):` statement for the invocation, with `in .cursor/cli.json` beneath it, before the user confirms - **AND** nothing SHALL be written if the user cancels #### Scenario: Color off still reads @@ -1030,21 +1035,6 @@ The wizard SHALL show the same lines in its summary step, before anything is wri - **WHEN** `.cursor/cli.json` does not parse - **THEN** `init` SHALL leave it byte-identical and report it -### Requirement: The manifest records permission rules, and an upgrade replaces only those - -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. On a later `init` the installer SHALL remove exactly the recorded entries and write the current ones. It SHALL NOT remove or edit any entry it did not record, including a rule for Taskless the user wrote. 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. - -#### 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 user's own rule survives an upgrade - -- **WHEN** `.claude/settings.json` also holds `Bash(npx @taskless/cli@0.12.0 *)`, which the manifest does not record -- **THEN** an upgrade SHALL leave that entry in place - ### Requirement: Gemini CLI and Crush detection signals Gemini CLI and Crush SHALL be detected as harnesses that read `.agents/skills`, from their own documentation: Gemini CLI reads workspace skills from `.gemini/skills/` "or the `.agents/skills/` alias", and Crush lists `.agents/skills` among its project skill directories. Each SHALL install into `.agents/`. @@ -1062,3 +1052,36 @@ Gemini CLI SHALL be detected when a `.gemini/` directory or a `GEMINI.md` file e - **WHEN** `.crush/` exists in the project root - **THEN** Crush SHALL be detected - **AND** skills SHALL be installed to `.agents/skills/` + +### Requirement: Each permission file holds exactly one Taskless rule + +Each harness permission file `init` manages SHALL hold exactly one Taskless rule set afterwards: the rules for the installed invocation, from the table in the requirement that init allows the installed CLI. 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. For Claude Code this includes both the `Bash( *)` form and the `Bash(:*)` form. On every `init` the installer SHALL remove each Taskless rule that is not one of the installed invocation's rules, whether or not the manifest recorded it, and write the current ones. It 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 its Taskless rules removed. + +#### 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: 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:*)` +- **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 diff --git a/openspec/specs/skill-taskless/spec.md b/openspec/specs/skill-taskless/spec.md index 9d753ad4..a041d972 100644 --- a/openspec/specs/skill-taskless/spec.md +++ b/openspec/specs/skill-taskless/spec.md @@ -162,13 +162,16 @@ The skill body's topic table SHALL map a request to make, publish, regenerate, o The skill body SHALL instruct the agent to run `%(TASKLESS_CLI)s agent ok` once per session, before any other Taskless command, and to proceed only once it prints its confirmation line. Once it does, the agent SHALL go straight to the user's request, without surveying the install or offering a menu of topics. On an install whose harness permission rule is in place the preflight passes without a prompt; the guidance below is the fallback for when it does not. -When the preflight is blocked by the harness or the user, the body SHALL instruct the agent to: +When the preflight, or any later Taskless command, is blocked by the harness or the user, the body SHALL instruct the agent to: -1. Stop before doing any Taskless work, and not attempt to work around the block. +1. Stop before doing any further Taskless work, and not retry the command or attempt to work around the block. 2. Send one message that names the exact command, and says it downloads and runs a package from npm. -3. Offer the user three ways forward: the agent adds an allow rule for exactly the invocation the skill names to the agent's permission settings, and if the harness blocks that edit too, gives the user the change to make themselves; the user approves it through their agent's permissions command (in Claude Code, `/permissions` with the rule `Bash(:*)`); or the user approves every Taskless command manually, in chat, as the agent runs it. -4. Never propose an allow rule that widens the invocation, such as a wildcard over the package name or its version. Where the harness's allowlist cannot scope to the one invocation, for example one that only allows a whole tool, offer per-command approval in chat instead of a rule. -5. Not ask in that message what the user wants Taskless to do. Their request stands, and the agent carries it out once the command is unblocked. +3. When the refusal came from the agent's own safety classifier rather than a missing permission (in Claude Code, a denial by the auto mode classifier), say that an allow rule cannot clear it, and give the user the global trust entry, inline, with the file and key to add it under (in Claude Code, top-level `autoMode.allow` in `~/.claude/settings.json`, after `"$defaults"`). The agent SHALL NOT write that entry itself. It SHALL also offer per-command approval in chat, and leaving the classifier's mode, as the alternatives. +4. Otherwise, offer the user three ways forward: the agent adds an allow rule for exactly the invocation the skill names to the agent's permission settings, and if the harness blocks that edit too, gives the user the change to make themselves; the user approves it through their agent's permissions command (in Claude Code, `/permissions` with the rule `Bash(:*)`); or the user approves every Taskless command manually, in chat, as the agent runs it. +5. Never propose an allow rule that widens the invocation, such as a wildcard over the package name or its version. Where the harness's allowlist cannot scope to the one invocation, for example one that only allows a whole tool, offer per-command approval in chat instead of a rule. +6. Not ask in that message what the user wants Taskless to do. Their request stands, and the agent carries it out once the command is unblocked. + +The global trust entry and these instructions SHALL be written in the skill body itself, not fetched from an `agent ` recipe, because a recipe is fetched through the same command that is blocked. #### Scenario: Skill body runs the preflight first @@ -186,6 +189,20 @@ When the preflight is blocked by the harness or the user, the body SHALL instruc - **AND** SHALL offer only per-command approval where the harness cannot allow the one invocation without allowing a whole tool - **AND** SHALL forbid asking what the user wants Taskless to do in that message +#### Scenario: Skill body handles a classifier refusal + +- **WHEN** the skill body is read from source +- **THEN** it SHALL instruct the agent, when a Taskless command is refused by the agent's safety classifier, to stop without retrying +- **AND** SHALL contain the global trust entry text inline, beginning `Taskless CLI:` +- **AND** SHALL name `~/.claude/settings.json` and `autoMode.allow` for Claude Code +- **AND** SHALL NOT offer a project allow rule for that refusal +- **AND** SHALL forbid the agent from writing the entry itself + +#### Scenario: The fallback does not depend on a recipe + +- **WHEN** the skill body is read from source +- **THEN** its blocked-command guidance SHALL NOT instruct the agent to run an `agent ` command to learn how to unblock + ### Requirement: A request that names no task routes to onboarding The skill body SHALL route a request to use, try, or set up Taskless that names no task to `%(TASKLESS_CLI)s agent onboard`, without first offering a menu of topics. The onboard recipe's question SHALL be the only question the agent asks before onboarding begins. diff --git a/packages/cli/src/agent/init.md b/packages/cli/src/agent/init.md index 8e86f5df..3493e0bc 100644 --- a/packages/cli/src/agent/init.md +++ b/packages/cli/src/agent/init.md @@ -50,6 +50,10 @@ first step, not the whole job. { "file": ".claude/settings.json", "created": false, "allowed": ["Bash(%(TASKLESS_CLI)s *)"], "removed": [], "skipped": null } + ], + "globalTrust": [ + { "harness": "Claude Code", "file": "/Users/you/.claude/settings.json", + "key": "autoMode.allow", "state": "absent", "skipped": null } ] } ``` @@ -76,15 +80,26 @@ first step, not the whole job. because the pin and the project still disagree. - `permissions` is always present. Each entry is a harness settings file this run created, edited, or left alone: the rule allowing - exactly this CLI version (`allowed`), the rule for the previous - version it replaced (`removed`), or, when `skipped` is set, why the - file was left as it was (it denies the command, or does not parse). - These files are the user's; name each one when you tell them what to - commit. + exactly this CLI version (`allowed`), every other Taskless rule it + replaced, whatever version it named (`removed`), or, when `skipped` + is set, why the file was left as it was (it denies the command, or + does not parse). These files are the user's; name each one when you + tell them what to commit. + - `globalTrust` is always present, empty when no installed harness has + a classifier that overrules project rules. Each entry is that + harness's user-level settings file and whether its Taskless entry is + `present`, `absent`, or `skipped` (with the reason). `init` only + reads it; it is never written by this command, and you must not + write it either. When it is `absent`, tell the user that their + agent may refuse Taskless commands the project rule allows, and that + the printed warning names the entry to add themselves, or that + running `%(TASKLESS_CLI)s` with no arguments, in their own + terminal, offers to add it (`init` itself never asks). Without `--json`, the same facts print as prose: a per-target summary, each permission edit as `Edited ` with `ALLOW:` and `REMOVE:` - lines (or `SKIPPED:` with the reason), then a trailer naming the directories that changed and, after a + lines (or `SKIPPED:` with the reason), a warning for each missing + global trust entry, then a trailer naming the directories that changed and, after a version move, pointing at `update`, then a notice naming each stale pin and the version to move it to. diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index 96e24df0..171290fb 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -25,6 +25,11 @@ import { syncPermissions, type PermissionEdit, } from "../install/permissions"; +import { + globalTrustWarnings, + syncGlobalTrust, + type GlobalTrustOutcome, +} from "../install/global-trust"; import { readInstallState } from "../install/state"; import { getTelemetry } from "../telemetry"; import { getCliVersion } from "../wizard/intro"; @@ -135,6 +140,10 @@ export const initCommand = defineCommand({ // Every harness permission file this run created, edited, or left // alone, with the exact rules. Always present, empty when none. permissions: result.permissions, + // Each harness whose classifier overrules a project rule, with + // whether its user-level entry is present. Read only: the batch + // install never writes outside the project. Empty when none apply. + globalTrust: result.globalTrust, // Absent when nothing ran, so a caller distinguishes "the tree was // rewritten" from "nothing happened" by presence, never by reading // empty arrays out of it. @@ -340,6 +349,8 @@ async function runNonInteractive( pinnedCli: PinnedCli[]; /** Harness permission files this run created, edited, or left alone. */ permissions: PermissionEdit[]; + /** User-level trust entries, read and never written. */ + globalTrust: GlobalTrustOutcome[]; }> { // Under `--json`, stdout carries only the envelope printed by the caller. // This per-target summary is not on that envelope (it is finer-grained than @@ -463,16 +474,25 @@ async function runNonInteractive( } } + const selected = plan.targets + .filter((target) => target.mode === "reference") + .map((target) => target.dir); const permissions = await syncPermissions(cwd, { - selected: plan.targets - .filter((target) => target.mode === "reference") - .map((target) => target.dir), + selected, codexDetected: detected.some((tool) => tool.name === "Codex"), invocation: buildInvocation(), write: true, }); for (const line of describePermissionEdits(permissions)) log(line); + // Only the wizard, with a person there to confirm it, writes outside the + // project. An agent or a script running `init` would otherwise be widening + // its own classifier's trust, which is the one thing it must not do. + const globalTrust = await syncGlobalTrust({ selected, write: false }); + for (const warning of globalTrustWarnings(globalTrust, getCliPrefix())) { + log(warning); + } + return { reloadNotice, migrated, @@ -480,6 +500,7 @@ async function runNonInteractive( cliVersion, targets, permissions, + globalTrust, // A version move rewrites `install.cliVersion` in the manifest, which is // a tracked file, so it is a change even when every skill byte matched. changed: diff --git a/packages/cli/src/install/global-trust.ts b/packages/cli/src/install/global-trust.ts new file mode 100644 index 00000000..3f6648d9 --- /dev/null +++ b/packages/cli/src/install/global-trust.ts @@ -0,0 +1,244 @@ +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { homedir } from "node:os"; +import { dirname, join } from "node:path"; +import process from "node:process"; + +import { isRecord } from "../util/is-record"; +import { parseJson, set, valueAt } from "./permissions"; + +/** + * User-level trust for harnesses whose safety classifier overrules a project + * allow rule. + * + * Claude Code's auto mode does not trust a project allow rule that runs + * downloaded code, and does not read its exceptions from a checked-in project + * file at all, since a cloned repo could then whitelist itself. The user + * already made that trust decision when they installed Taskless; the only + * place it can be carried into a session is the user's own settings, and the + * only moment a human is there to make it is the interactive install. + * + * Unlike a project rule, the entry is personal state: it is never recorded in + * the project manifest, never names a version, is written at most once, and + * is never removed. A present entry is found by its label, so rewording the + * text later never touches an entry a user already has. + * + * The list is closed. A harness joins once it is verified that its classifier + * can refuse what its project file allows, and that its user settings accept + * a written exception. + */ + +/** The label every entry begins with, and how a present one is found. */ +const LABEL = "Taskless CLI:"; + +/** + * The entry itself, identical in every version. The example version is fake on + * purpose, so the text never ages. + * + * It names both packages, in every build, so it is not routed through + * `applyCliInvocation()`: a nightly rewriting it would hand the skill and the + * user settings two different texts. + */ +export const GLOBAL_TRUST_ENTRY = + // ast-grep-ignore: no-unrouted-cli-invocation + `${LABEL} The npm packages @taskless/cli and @taskless/cli-nightly are ` + + "tooling published by Taskless via npm trusted publishing (OIDC), installed " + + "by the user through the Taskless installer. Running either via npx or pnpm " + + "dlx is allowed only when the call names an exact version (e.g. " + + // ast-grep-ignore: no-unrouted-cli-invocation + "@taskless/cli@1.2.3). Dist-tags such as @latest or @nightly, version " + + "ranges, and unversioned calls are not covered."; + +/** Where a harness's user settings live, from the environment. */ +export interface GlobalTrustEnvironment { + home: string; + env: Readonly>; +} + +interface GlobalTrust { + harness: string; + /** The install target that brings this harness in. */ + selectedBy: string; + /** The settings file, absolute. */ + path(environment: GlobalTrustEnvironment): string; + /** The list the entry goes in. */ + key: readonly string[]; + /** The list to write when there is none, holding the entry. */ + seed: readonly string[]; +} + +const claudeCode: GlobalTrust = { + harness: "Claude Code", + selectedBy: ".claude", + // Claude Code moves its whole configuration directory with this variable. + // Set but empty is unset, or the path would resolve against the cwd. + path: ({ home, env }) => { + const directory = env.CLAUDE_CONFIG_DIR; + return join( + directory === undefined || directory === "" + ? join(home, ".claude") + : directory, + "settings.json" + ); + }, + key: ["autoMode", "allow"], + // `$defaults` splices in the shipped exceptions. A new list without it + // would replace them, and the user would lose every one. + seed: ["$defaults", GLOBAL_TRUST_ENTRY], +}; + +const GLOBAL_TRUST: readonly GlobalTrust[] = [claudeCode]; + +/** One harness's entry, in the shape `init --json` carries. */ +export interface GlobalTrustOutcome { + harness: string; + /** Absolute path of the settings file. */ + file: string; + /** The list the entry belongs in, dotted: `autoMode.allow`. */ + key: string; + /** + * `present`: already there, untouched. `absent`: missing, nothing written. + * `written`: added by this run. `skipped`: left alone, see `skipped`. + */ + state: "present" | "absent" | "written" | "skipped"; + /** Why the file was left alone, or `null` when it was not. */ + skipped: string | null; +} + +export interface SyncGlobalTrustOptions { + /** Tool directories the skill is installed into. */ + selected: readonly string[]; + /** `true` adds a missing entry; `false` only reports. */ + write: boolean; + /** Defaults to the real home directory and environment. */ + environment?: GlobalTrustEnvironment; +} + +async function readText(path: string): Promise { + try { + return await readFile(path, "utf8"); + } catch { + return undefined; + } +} + +type Inspection = + | { kind: "present" } + | { kind: "absent"; text: string } + | { kind: "skip"; reason: string }; + +/** Whether the file holds the entry, and the edited text when it does not. */ +function inspect(trust: GlobalTrust, current = "{}\n"): Inspection { + const root = parseJson(current); + if (!isRecord(root)) { + return { kind: "skip", reason: "does not parse, so it was left as it is" }; + } + for (let depth = 1; depth < trust.key.length; depth++) { + const parent = valueAt(root, trust.key.slice(0, depth)); + if (parent !== undefined && !isRecord(parent)) { + return { + kind: "skip", + reason: `${trust.key.slice(0, depth).join(".")} is not an object`, + }; + } + } + const list = valueAt(root, trust.key); + if (list === undefined) { + return { + kind: "absent", + text: set(current, [...trust.key], [...trust.seed]), + }; + } + if (!Array.isArray(list)) { + return { kind: "skip", reason: `${trust.key.join(".")} is not a list` }; + } + if (list.some((item) => typeof item === "string" && item.startsWith(LABEL))) { + return { kind: "present" }; + } + // An existing list without `$defaults` is the user replacing the defaults on + // purpose, so it is appended to as it stands. + return { + kind: "absent", + text: set(current, [...trust.key, list.length], GLOBAL_TRUST_ENTRY, { + isArrayInsertion: true, + }), + }; +} + +/** + * Report, and with `write` add, the global trust entry for each selected + * harness that has one. A present entry is never rewritten. + */ +export async function syncGlobalTrust( + options: SyncGlobalTrustOptions +): Promise { + const environment = options.environment ?? { + home: homedir(), + env: process.env, + }; + const outcomes: GlobalTrustOutcome[] = []; + for (const trust of GLOBAL_TRUST) { + if (!options.selected.includes(trust.selectedBy)) continue; + const file = trust.path(environment); + const base = { harness: trust.harness, file, key: trust.key.join(".") }; + const result = inspect(trust, await readText(file)); + if (result.kind === "skip") { + outcomes.push({ ...base, state: "skipped", skipped: result.reason }); + continue; + } + if (result.kind === "present" || !options.write) { + outcomes.push({ ...base, state: result.kind, skipped: null }); + continue; + } + await mkdir(dirname(file), { recursive: true }); + await writeFile(file, result.text, "utf8"); + outcomes.push({ ...base, state: "written", skipped: null }); + } + return outcomes; +} + +/** `~/…` for a path under the home directory, so the summary reads short. */ +function display(file: string, home: string): string { + return file.startsWith(`${home}/`) ? `~${file.slice(home.length)}` : file; +} + +/** + * The proposed edit for each missing entry, for the wizard's summary, beside + * the project's own lines: what it allows, and where it goes. `global` marks + * it as the one edit outside the project. + */ +export function describeGlobalTrust( + outcomes: readonly GlobalTrustOutcome[], + home: string = homedir() +): { what: string; where: string }[] { + return outcomes + .filter((outcome) => outcome.state === "absent") + .map((outcome) => ({ + // ast-grep-ignore: no-unrouted-cli-invocation + what: "@taskless/cli or @taskless/cli-nightly at an exact version", + where: `in ${display(outcome.file, home)} (${outcome.key})`, + })); +} + +/** + * A warning for each harness still missing its entry. `installer` is the + * command a person runs, with no arguments, to get the wizard that offers it. + */ +export function globalTrustWarnings( + outcomes: readonly GlobalTrustOutcome[], + installer: string, + home: string = homedir() +): string[] { + return outcomes + .filter( + (outcome) => outcome.state === "absent" || outcome.state === "skipped" + ) + .map( + (outcome) => + `${outcome.harness} may refuse Taskless commands that the project rule allows: its safety classifier overrules project allow rules. ` + + `To trust Taskless, add this entry to the top-level \`${outcome.key}\` list in ${display(outcome.file, home)}` + + (outcome.state === "skipped" + ? ` (left alone: ${outcome.skipped})` + : "") + + `, after "$defaults", or run \`${installer}\` with no arguments in a terminal, which offers to add it:\n ${GLOBAL_TRUST_ENTRY}` + ); +} diff --git a/packages/cli/src/install/notice-box.ts b/packages/cli/src/install/notice-box.ts index 437814dd..22a3e713 100644 --- a/packages/cli/src/install/notice-box.ts +++ b/packages/cli/src/install/notice-box.ts @@ -39,7 +39,7 @@ export type NoticeBlock = string | { items: readonly string[] }; * over-long line is allowed to overflow instead, and the box is then sized * around it. */ -function wrap(text: string, columns: number): string[] { +export function wrap(text: string, columns: number): string[] { const lines: string[] = []; let line = ""; for (const word of text.split(" ")) { diff --git a/packages/cli/src/install/permissions.ts b/packages/cli/src/install/permissions.ts index 342d6ed0..b1e50e0b 100644 --- a/packages/cli/src/install/permissions.ts +++ b/packages/cli/src/install/permissions.ts @@ -36,8 +36,12 @@ import { isRecord } from "../util/is-record"; * * - The rule is never wider than the invocation. Each uses that harness's * word-bounded form, so `@0.13.0` cannot match `@0.13.01`. - * - Only rules Taskless recorded in the manifest are ever removed, so a rule - * the user wrote, even one for Taskless, is never touched. + * - A file holds exactly one Taskless rule set: the installed invocation's. + * Every other allow rule for a Taskless invocation is removed, however it + * got there, since a pinned rule for a version that is no longer installed + * allows nothing anyone wants. Deny rules, and rules for anything else, are + * never touched. The manifest record decides only whether a file Taskless + * created may be deleted once it is empty. * - A file that denies the invocation, or does not parse, is left alone. * * The list is closed. A harness joins by becoming an install target with a @@ -53,7 +57,7 @@ export interface PermissionEdit { created: boolean; /** Rules added, as written in the file. */ allowed: string[]; - /** Rules Taskless recorded earlier and removed. */ + /** Taskless rules for another invocation, removed. */ removed: string[]; /** Why the file was left alone, or `null` when it was not. */ skipped: string | null; @@ -72,6 +76,11 @@ interface Harness { owns(file: string): boolean; /** The rules that allow exactly `invocation`. */ rules(invocation: string): string[]; + /** + * Every allow rule in `text` for a Taskless invocation, at any version, + * written the way {@link Harness.edit} takes it in `remove`. + */ + found(text: string | undefined): string[]; /** * Remove `remove` and add `add` to `text` (absent when the file does not * exist). `undefined` text in the result deletes the file. @@ -95,7 +104,7 @@ interface EditContext { const UNPARSEABLE = "does not parse, so it was left as it is"; /** Parse JSON or JSONC, or `undefined` when it does not parse. */ -function parseJson(text: string): unknown { +export function parseJson(text: string): unknown { const errors: ParseError[] = []; const value: unknown = parse(text, errors, { allowTrailingComma: true }); return errors.length > 0 ? undefined : value; @@ -228,7 +237,7 @@ function remove(text: string, path: (string | number)[]): string { * there is no neighbour to disturb, and as the fallback whenever a hand-placed * edit would not parse back to the value it was meant to write. */ -function set( +export function set( text: string, path: (string | number)[], value: unknown, @@ -254,7 +263,7 @@ function set( ); } -function valueAt(root: unknown, path: readonly string[]): unknown { +export function valueAt(root: unknown, path: readonly string[]): unknown { let current = root; for (const key of path) { if (!isRecord(current)) return undefined; @@ -391,12 +400,84 @@ function cursorCliDenies(root: unknown, invocation: string): boolean { }); } +// --- Recognizing a Taskless rule -------------------------------------------- + +/** + * A command that runs the Taskless CLI through a launcher, at any version or + * none: `npx @taskless/cli@0.13.0`, `pnpm dlx @taskless/cli-nightly`. The + * package name must end there, so `@taskless/cli-foo` is not Taskless. + */ +const TASKLESS_COMMAND = + /^(?:npx|pnpm dlx) @taskless\/cli(?:-nightly)?(?:@[^\s]+)?$/; + +/** Whether `command`, less a trailing ` *` glob, runs the Taskless CLI. */ +function isTasklessCommand(command: string): boolean { + return TASKLESS_COMMAND.test(command.replace(/ \*$/, "")); +} + +/** The strings in the list at `path` that `isTaskless` accepts. */ +function foundInList( + text: string | undefined, + path: readonly string[], + isTaskless: (entry: string) => boolean +): string[] { + if (text === undefined) return []; + const list = valueAt(parseJson(text), path); + if (!Array.isArray(list)) return []; + return list.filter( + (entry): entry is string => typeof entry === "string" && isTaskless(entry) + ); +} + +/** Claude Code: `Bash( *)`, `Bash(:*)`, or `Bash()`. */ +function isTasklessClaudeRule(rule: string): boolean { + const inner = /^Bash\((.*)\)$/s.exec(rule)?.[1]; + return inner !== undefined && isTasklessCommand(inner.replace(/:\*$/, "")); +} + +/** Cursor CLI: `Shell(:)`, with or without a trailing ` *`. */ +function isTasklessCursorCliRule(rule: string): boolean { + const match = /^Shell\(([^:)]+):(.*)\)$/s.exec(rule); + return match !== null && isTasklessCommand(`${match[1]} ${match[2]}`); +} + +/** + * The command a rule allows, in any harness's spelling, for a person to read: + * `Bash(npx @taskless/cli@0.13.0 *)` and `Shell(npx:@taskless/cli@0.13.0)` + * both read `npx @taskless/cli@0.13.0`. + */ +export function commandOfRule(rule: string): string { + const bash = /^Bash\((.*)\)$/s.exec(rule)?.[1]; + const shell = /^Shell\(([^:)]+):(.*)\)$/s.exec(rule); + const pattern = + /^prefix_rule\(pattern = (\[.*\]), decision = "allow"\)$/.exec(rule)?.[1]; + let command = rule; + if (bash !== undefined) { + command = bash.replace(/:\*$/, ""); + } else if (shell !== null) { + command = `${shell[1] ?? ""} ${shell[2] ?? ""}`; + } else if (pattern !== undefined) { + const words: unknown = parseJson(pattern); + if (Array.isArray(words)) command = words.join(" "); + } + return command.replace(/ \*$/, ""); +} + // --- The harnesses ---------------------------------------------------------- const CLAUDE_FILE = ".claude/settings.json"; const CURSOR_EDITOR_FILE = ".cursor/permissions.json"; const CURSOR_CLI_FILE = ".cursor/cli.json"; const CODEX_FILE = ".codex/rules/taskless.rules"; +/** + * How a Codex rules file Taskless wrote is recognised, even when the manifest + * record is gone. Matched as a prefix so the rest of the header can be + * reworded freely, but this mark itself must never change: every file written + * before the change would stop being recognised, and be skipped from then on + * as someone else's. + */ +const CODEX_MARK = "# Written by Taskless"; +const CODEX_HEADER = `${CODEX_MARK}: allows exactly the installed Taskless CLI.\n`; const OPENCODE_FILES = [ "opencode.json", "opencode.jsonc", @@ -416,6 +497,8 @@ function fixed(file: string): Pick { const claudeCode: Harness = { ...fixed(CLAUDE_FILE), rules: (invocation) => [`Bash(${invocation} *)`], + found: (text) => + foundInList(text, ["permissions", "allow"], isTasklessClaudeRule), edit: (text, add, remove, context) => editStringList(text, ["permissions", "allow"], add, remove, context, (r) => claudeDenies(r, context.invocation) @@ -425,6 +508,7 @@ const claudeCode: Harness = { const cursorEditor: Harness = { ...fixed(CURSOR_EDITOR_FILE), rules: (invocation) => [invocation], + found: (text) => foundInList(text, ["terminalAllowlist"], isTasklessCommand), edit: (text, add, remove, context) => editStringList(text, ["terminalAllowlist"], add, remove, context), }; @@ -436,6 +520,8 @@ const cursorCli: Harness = { const args = rest.join(" "); return [`Shell(${base ?? ""}:${args})`, `Shell(${base ?? ""}:${args} *)`]; }, + found: (text) => + foundInList(text, ["permissions", "allow"], isTasklessCursorCliRule), edit: (text, add, remove, context) => editStringList(text, ["permissions", "allow"], add, remove, context, (r) => cursorCliDenies(r, context.invocation) @@ -460,6 +546,27 @@ const openCode: Harness = { }, owns: (path) => OPENCODE_FILES.includes(path), rules: (invocation) => [`${invocation} *`], + found(text) { + if (text === undefined) return []; + const root = parseJson(text); + if (!isRecord(root)) return []; + if (Array.isArray(root.permissions)) { + return root.permissions.flatMap((entry) => + isRecord(entry) && + entry.action === "shell" && + entry.effect === "allow" && + typeof entry.resource === "string" && + isTasklessCommand(entry.resource) + ? [entry.resource] + : [] + ); + } + const bash = valueAt(root, ["permission", "bash"]); + if (!isRecord(bash)) return []; + return Object.entries(bash) + .filter(([glob, action]) => action === "allow" && isTasklessCommand(glob)) + .map(([glob]) => glob); + }, edit(text, add, remove, context) { let current = text ?? "{}\n"; let root = parseJson(current); @@ -555,18 +662,41 @@ const openCode: Harness = { /** * Codex, in a rules file Taskless owns outright, so the whole file is - * rewritten rather than edited. A file at that path Taskless did not record is - * someone else's and is left alone. Codex applies the strictest matching rule - * (forbidden over prompt over allow), so a deny elsewhere still wins without - * this file needing to look for it. + * rewritten rather than edited. A file at that path that Taskless neither + * recorded nor marked with {@link CODEX_MARK} is someone else's and is left + * alone. Codex applies the strictest matching rule (forbidden over prompt over + * allow), so a deny elsewhere still wins without this file needing to look + * for it. */ const codex: Harness = { ...fixed(CODEX_FILE), rules: (invocation) => [ `prefix_rule(pattern = ${JSON.stringify(invocation.split(" "))}, decision = "allow")`, ], + // Only a file Taskless wrote is read: one someone else put at this path is + // theirs, whatever it allows. + found(text) { + if (text?.startsWith(CODEX_MARK) !== true) return []; + return text.split("\n").filter((line) => { + const pattern = + /^prefix_rule\(pattern = (\[.*\]), decision = "allow"\)$/.exec( + line + )?.[1]; + if (pattern === undefined) return false; + const words: unknown = parseJson(pattern); + return ( + Array.isArray(words) && + words.every((word) => typeof word === "string") && + isTasklessCommand(words.join(" ")) + ); + }); + }, edit(text, add, remove, context) { - if (text !== undefined && !context.created && remove.length === 0) { + if ( + text !== undefined && + !context.created && + !text.startsWith(CODEX_MARK) + ) { return { kind: "skip", reason: "already exists and was not written by Taskless", @@ -582,7 +712,7 @@ const codex: Harness = { return { kind: "write", text: - "# Written by Taskless: allows exactly the installed Taskless CLI.\n" + + CODEX_HEADER + "# `taskless init` rewrites this file on every upgrade.\n" + rules.join("\n") + "\n", @@ -667,8 +797,9 @@ export interface SyncPermissionsOptions { /** * Bring every harness permission file in line with this install: add the rule - * for the invocation, remove rules Taskless recorded for any other version or - * for a harness no longer selected, and record what it wrote. + * for the invocation, remove every other Taskless rule in the file, and every + * Taskless rule from a recorded file whose harness is no longer selected, and + * record what it wrote. * * With `write: false` it reads the same files and reports the same edits * without touching anything, which is what the wizard previews. @@ -697,17 +828,17 @@ export async function syncPermissions( if (recorded !== undefined) next[file] = recorded; continue; } + const path = join(cwd, file); + const text = await readText(path); const want = desired.get(file)?.rules ?? []; const had = recorded?.rules ?? []; - const remove = had.filter((rule) => !want.includes(rule)); + const remove = harness.found(text).filter((rule) => !want.includes(rule)); const add = want.filter((rule) => !had.includes(rule)); if (add.length === 0 && remove.length === 0) { if (recorded !== undefined) next[file] = recorded; continue; } - const path = join(cwd, file); - const text = await readText(path); const created = recorded?.created ?? text === undefined; const result = harness.edit(text, add, remove, { invocation: options.invocation, @@ -727,7 +858,7 @@ export async function syncPermissions( const removed = remove; const rules = [ - ...had.filter((rule) => !remove.includes(rule)), + ...had.filter((rule) => want.includes(rule)), ...result.added, ]; if (rules.length > 0) next[file] = { rules, created }; diff --git a/packages/cli/src/wizard/index.ts b/packages/cli/src/wizard/index.ts index 96eb6cbc..cf353dd1 100644 --- a/packages/cli/src/wizard/index.ts +++ b/packages/cli/src/wizard/index.ts @@ -1,3 +1,6 @@ +import { homedir } from "node:os"; +import process from "node:process"; + import { intro, outro, cancel, log } from "@clack/prompts"; import { getOnboardTrailer } from "../commands/onboard"; @@ -9,6 +12,11 @@ import { getEmbeddedSkills, planToStateTargets, } from "../install/install"; +import { + globalTrustWarnings, + syncGlobalTrust, + type GlobalTrustEnvironment, +} from "../install/global-trust"; import { syncPermissions } from "../install/permissions"; import { findStalePins, getPinnedCliNotice } from "../install/pinned-cli"; import { getReloadNotice } from "../install/reload-notice"; @@ -25,6 +33,8 @@ import { renderSummaryAndConfirm } from "./steps/summary"; export interface RunWizardOptions { cwd: string; + /** Where user-level settings live. Defaults to the real home directory. */ + globalTrustEnvironment?: GlobalTrustEnvironment; } export interface WizardResult { @@ -76,7 +86,23 @@ export async function runWizard( write: false, }); - const proceed = await renderSummaryAndConfirm(diff, permissionPreview); + const globalTrustEnvironment = options.globalTrustEnvironment ?? { + home: homedir(), + env: process.env, + }; + const globalTrustOptions = { + selected: permissionOptions.selected, + environment: globalTrustEnvironment, + }; + const globalTrustPreview = await syncGlobalTrust({ + ...globalTrustOptions, + write: false, + }); + + const proceed = await renderSummaryAndConfirm(diff, permissionPreview, { + outcomes: globalTrustPreview, + home: globalTrustEnvironment.home, + }); if (!proceed) { cancel(`Install cancelled. Run \`${getCliPrefix()} init\` to try again.`); cancelledStep = "summary"; @@ -89,6 +115,23 @@ export async function runWizard( const cliVersion = getCliVersion(); await applyInstallPlan(options.cwd, plan, { cliVersion }); await syncPermissions(options.cwd, { ...permissionOptions, write: true }); + // Confirmed with the project edits, in the summary's one question. + if (globalTrustPreview.some((outcome) => outcome.state === "absent")) { + await syncGlobalTrust({ ...globalTrustOptions, write: true }); + } + // Read again rather than assumed, so a skipped file, and a write that did + // not land, still warn. + const globalTrust = await syncGlobalTrust({ + ...globalTrustOptions, + write: false, + }); + for (const warning of globalTrustWarnings( + globalTrust, + getCliPrefix(), + globalTrustEnvironment.home + )) { + log.warn(warning); + } outro("Taskless is ready to go."); const pinnedNotice = getPinnedCliNotice( diff --git a/packages/cli/src/wizard/steps/summary.ts b/packages/cli/src/wizard/steps/summary.ts index d1c0fb90..66a340d6 100644 --- a/packages/cli/src/wizard/steps/summary.ts +++ b/packages/cli/src/wizard/steps/summary.ts @@ -7,15 +7,18 @@ import chalk from "chalk"; import "../../util/color"; import { - describePermissionEdits, - type PermissionEdit, -} from "../../install/permissions"; + describeGlobalTrust, + type GlobalTrustOutcome, +} from "../../install/global-trust"; +import { commandOfRule, type PermissionEdit } from "../../install/permissions"; +import { wrap } from "../../install/notice-box"; import type { InstallDiff } from "../../install/state"; import { ask } from "../ask"; export async function renderSummaryAndConfirm( diff: InstallDiff, - permissions: readonly PermissionEdit[] = [] + permissions: readonly PermissionEdit[] = [], + globalTrust?: { outcomes: readonly GlobalTrustOutcome[]; home: string } ): Promise { const lines: string[] = []; @@ -51,39 +54,113 @@ export async function renderSummaryAndConfirm( lines.push(""); } - if (permissions.length > 0) { - lines.push( - chalk.bold("Agent permissions"), - ...describePermissionEdits(permissions), - "" - ); + // Every settings edit, project and global, in one group under one + // question: each is a line a person can read, with where it goes. + const settingsLines = layoutSettings([ + ...describeProjectEdits(permissions), + ...(globalTrust === undefined + ? [] + : describeGlobalTrust(globalTrust.outcomes, globalTrust.home).map( + (line) => + ({ label: "ALLOW (global):", tone: "allow", ...line }) as const + )), + ]); + if (settingsLines.length > 0) { + lines.push(chalk.bold("Agent settings"), ...settingsLines, ""); } log.message(lines.join("\n") || "No changes."); // A settings file is the user's, so an edit to one is confirmed even when // nothing is being removed. - const editsSettings = permissions.some((edit) => edit.skipped === null); + const editsSettings = + permissions.some((edit) => edit.skipped === null) || + (globalTrust?.outcomes.some((outcome) => outcome.state === "absent") ?? + false); if (!diff.hasRemovals && !editsSettings) { return true; } - if (!diff.hasRemovals) { - return ask("summary", () => - confirm({ - message: "Write the agent permission rules above?", - initialValue: true, - }) - ); - } - return ask("summary", () => confirm({ - message: buildRemovalConfirmMessage(diff), + message: diff.hasRemovals + ? buildRemovalConfirmMessage(diff, { editsSettings }) + : SETTINGS_QUESTION, initialValue: true, }) ); } +const SETTINGS_QUESTION = "Make these changes to your settings?"; + +/** One settings edit: what it allows or removes, and where. */ +interface SettingsLine { + label: string; + tone: "allow" | "remove" | "skip"; + what: string; + where: string; +} + +/** + * Lines inside clack's gutter stay within this many columns, so the summary + * fits an 80-column terminal. + */ +const SUMMARY_COLUMNS = 76; + +const TONE = { allow: chalk.green, remove: chalk.red, skip: chalk.yellow }; + +/** + * Labels padded to the widest one, so every statement starts in one column, + * and each statement wrapped under itself, never mid-token: a nightly version + * is one long token and must stay copyable. + */ +function layoutSettings(entries: readonly SettingsLine[]): string[] { + const width = Math.max(0, ...entries.map((entry) => entry.label.length)) + 1; + const indent = " ".repeat(width); + const lines: string[] = []; + for (const entry of entries) { + const body = [ + ...wrap(entry.what, SUMMARY_COLUMNS - width), + ...wrap(entry.where, SUMMARY_COLUMNS - width), + ]; + const [first = "", ...rest] = body; + lines.push( + `${TONE[entry.tone](entry.label)}${" ".repeat(width - entry.label.length)}${first}`, + ...rest.map((line) => `${indent}${line}`) + ); + } + return lines; +} + +/** + * Each project permission edit as one entry per command and file. A harness + * whose rules come in two spellings (Cursor's CLI writes the command bare and + * with arguments) reads as one entry, since both allow the same command. + */ +function describeProjectEdits( + edits: readonly PermissionEdit[] +): SettingsLine[] { + const entries: SettingsLine[] = []; + for (const edit of edits) { + if (edit.skipped !== null) { + entries.push({ + label: "SKIPPED (project):", + tone: "skip", + what: edit.file, + where: edit.skipped, + }); + continue; + } + const where = `in ${edit.file}`; + for (const what of new Set(edit.allowed.map((r) => commandOfRule(r)))) { + entries.push({ label: "ALLOW (project):", tone: "allow", what, where }); + } + for (const what of new Set(edit.removed.map((r) => commandOfRule(r)))) { + entries.push({ label: "REMOVE (project):", tone: "remove", what, where }); + } + } + return entries; +} + /** * Build an itemized removal confirmation: one clause per `reference` target * losing stubs, naming the directory and its removed stub count (skills + @@ -93,7 +170,10 @@ export async function renderSummaryAndConfirm( * to a generic prompt. Only meaningful when {@link InstallDiff.hasRemovals} * is true. */ -export function buildRemovalConfirmMessage(diff: InstallDiff): string { +export function buildRemovalConfirmMessage( + diff: InstallDiff, + options: { editsSettings?: boolean } = {} +): string { const clauses: string[] = []; for (const entry of diff.entries) { if (entry.mode === "canonical") continue; @@ -104,7 +184,11 @@ export function buildRemovalConfirmMessage(diff: InstallDiff): string { ); } if (clauses.length === 0) { - return "Some files recorded in the previous install will be removed. Proceed?"; + return options.editsSettings === true + ? "Some files recorded in the previous install will be removed. Proceed, and make these changes to your settings?" + : "Some files recorded in the previous install will be removed. Proceed?"; } - return `Remove Taskless from ${clauses.join(", ")}?`; + return options.editsSettings === true + ? `Remove Taskless from ${clauses.join(", ")}, and make these changes to your settings?` + : `Remove Taskless from ${clauses.join(", ")}?`; } diff --git a/packages/cli/test/global-trust.test.ts b/packages/cli/test/global-trust.test.ts new file mode 100644 index 00000000..f5e73550 --- /dev/null +++ b/packages/cli/test/global-trust.test.ts @@ -0,0 +1,218 @@ +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { + describeGlobalTrust, + GLOBAL_TRUST_ENTRY, + globalTrustWarnings, + syncGlobalTrust, + type GlobalTrustEnvironment, +} from "../src/install/global-trust"; + +let home: string; +let environment: GlobalTrustEnvironment; +let settings: string; + +beforeEach(async () => { + home = await mkdtemp(join(tmpdir(), "taskless-global-trust-")); + environment = { home, env: {} }; + settings = join(home, ".claude", "settings.json"); +}); + +afterEach(async () => { + await rm(home, { recursive: true, force: true }); +}); + +async function put(contents: string): Promise { + await mkdir(dirname(settings), { recursive: true }); + await writeFile(settings, contents); +} + +async function read(): Promise { + return readFile(settings, "utf8"); +} + +function sync(write: boolean, selected: string[] = [".claude"]) { + return syncGlobalTrust({ selected, write, environment }); +} + +describe("the entry", () => { + it("begins with its label and names no real version", () => { + expect(GLOBAL_TRUST_ENTRY.startsWith("Taskless CLI:")).toBe(true); + // The only version-shaped text is the fixed example. + expect(GLOBAL_TRUST_ENTRY.match(/\d+\.\d+\.\d+/g)).toEqual(["1.2.3"]); + }); + + it("states the pin requirement and excludes @latest", () => { + expect(GLOBAL_TRUST_ENTRY).toContain( + "only when the call names an exact version" + ); + expect(GLOBAL_TRUST_ENTRY).toContain("@latest"); + expect(GLOBAL_TRUST_ENTRY).toContain("not covered"); + }); +}); + +describe("syncGlobalTrust", () => { + it("seeds a new list with $defaults when the file is absent", async () => { + const outcomes = await sync(true); + + expect(outcomes).toEqual([ + { + harness: "Claude Code", + file: settings, + key: "autoMode.allow", + state: "written", + skipped: null, + }, + ]); + expect(JSON.parse(await read())).toEqual({ + autoMode: { allow: ["$defaults", GLOBAL_TRUST_ENTRY] }, + }); + }); + + it("adds autoMode to an existing file and keeps every other key", async () => { + await put('{\n "model": "opus",\n "permissions": { "allow": [] }\n}\n'); + await sync(true); + + expect(JSON.parse(await read())).toEqual({ + model: "opus", + permissions: { allow: [] }, + autoMode: { allow: ["$defaults", GLOBAL_TRUST_ENTRY] }, + }); + }); + + it("appends to an existing list", async () => { + await put( + JSON.stringify({ + autoMode: { allow: ["$defaults", "Internal Registry: ours"] }, + }) + ); + await sync(true); + + expect(JSON.parse(await read())).toEqual({ + autoMode: { + allow: ["$defaults", "Internal Registry: ours", GLOBAL_TRUST_ENTRY], + }, + }); + }); + + it("does not add $defaults to a list that left it out", async () => { + await put(JSON.stringify({ autoMode: { allow: ["Mine: only this"] } })); + await sync(true); + + expect(JSON.parse(await read())).toEqual({ + autoMode: { allow: ["Mine: only this", GLOBAL_TRUST_ENTRY] }, + }); + }); + + it("treats a reworded entry as present and leaves the file alone", async () => { + const original = JSON.stringify({ + autoMode: { + allow: ["$defaults", "Taskless CLI: allowed with a pinned version"], + }, + }); + await put(original); + const outcomes = await sync(true); + + expect(outcomes[0]?.state).toBe("present"); + expect(await read()).toBe(original); + }); + + it("only reports when not writing", async () => { + const original = "{}\n"; + await put(original); + const outcomes = await sync(false); + + expect(outcomes[0]?.state).toBe("absent"); + expect(await read()).toBe(original); + }); + + it("skips a file that does not parse", async () => { + await put("{ nope"); + const outcomes = await sync(true); + + expect(outcomes[0]).toMatchObject({ + state: "skipped", + skipped: "does not parse, so it was left as it is", + }); + expect(await read()).toBe("{ nope"); + }); + + it("skips an autoMode.allow that is not a list", async () => { + await put(JSON.stringify({ autoMode: { allow: "everything" } })); + const outcomes = await sync(true); + + expect(outcomes[0]?.skipped).toBe("autoMode.allow is not a list"); + }); + + it("keeps comments", async () => { + await put('{\n // mine\n "model": "opus"\n}\n'); + await sync(true); + + expect(await read()).toContain("// mine"); + }); + + it("follows CLAUDE_CONFIG_DIR", async () => { + const elsewhere = join(home, "elsewhere"); + const outcomes = await syncGlobalTrust({ + selected: [".claude"], + write: true, + environment: { home, env: { CLAUDE_CONFIG_DIR: elsewhere } }, + }); + + expect(outcomes[0]?.file).toBe(join(elsewhere, "settings.json")); + expect( + JSON.parse(await readFile(join(elsewhere, "settings.json"), "utf8")) + ).toEqual({ autoMode: { allow: ["$defaults", GLOBAL_TRUST_ENTRY] } }); + }); + + it("reads nothing for an install without .claude/", async () => { + // A file that would fail to parse proves it was never opened. + await put("{ nope"); + const outcomes = await sync(true, [".cursor", ".opencode"]); + + expect(outcomes).toEqual([]); + expect(await read()).toBe("{ nope"); + }); +}); + +describe("rendering", () => { + it("describes a missing entry as a global edit", async () => { + const outcomes = await sync(false); + expect(describeGlobalTrust(outcomes, home)).toEqual([ + { + what: "@taskless/cli or @taskless/cli-nightly at an exact version", + where: "in ~/.claude/settings.json (autoMode.allow)", + }, + ]); + }); + + it("warns with the file, key, and entry", async () => { + const outcomes = await sync(false); + const [warning] = globalTrustWarnings( + outcomes, + "npx @taskless/cli@1.2.3", + home + ); + + expect(warning).toContain("~/.claude/settings.json"); + // `init` never asks; the bare invocation is what opens the wizard. + expect(warning).toContain( + "run `npx @taskless/cli@1.2.3` with no arguments in a terminal" + ); + expect(warning).toContain("autoMode.allow"); + expect(warning).toContain(GLOBAL_TRUST_ENTRY); + }); + + it("is silent once the entry is present", async () => { + await sync(true); + const outcomes = await sync(false); + + expect(describeGlobalTrust(outcomes, home)).toEqual([]); + expect(globalTrustWarnings(outcomes, "npx @taskless/cli", home)).toEqual( + [] + ); + }); +}); diff --git a/packages/cli/test/init-no-interactive.test.ts b/packages/cli/test/init-no-interactive.test.ts index e27ab398..ffa7f495 100644 --- a/packages/cli/test/init-no-interactive.test.ts +++ b/packages/cli/test/init-no-interactive.test.ts @@ -489,6 +489,73 @@ describe("taskless init (the batch install)", () => { * swapped field leaves every unit test green while the user is told nothing * and their agent keeps serving the previous skills. */ + describe("the global trust entry", () => { + let home: string; + // A home of its own, so the run never reads the real user settings. + const run = (...args: string[]) => + execFileAsync("node", [binPath, "init", "-d", cwd, ...args], { + env: { ...process.env, HOME: home, CLAUDE_CONFIG_DIR: "" }, + }); + const userSettings = () => join(home, ".claude", "settings.json"); + + beforeEach(async () => { + home = await mkdtemp(join(tmpdir(), "taskless-init-home-")); + await mkdir(join(cwd, ".claude"), { recursive: true }); + }); + + afterEach(async () => { + await rm(home, { recursive: true, force: true }); + }); + + it("warns when it is missing and never writes it", async () => { + await mkdir(join(home, ".claude"), { recursive: true }); + await writeFile(userSettings(), "{}\n"); + const { stdout } = await run(); + + expect(stdout).toContain("autoMode.allow"); + expect(stdout).toContain("~/.claude/settings.json"); + expect(stdout).toContain("Taskless CLI:"); + expect(await readFile(userSettings(), "utf8")).toBe("{}\n"); + }); + + it("creates nothing in the home directory", async () => { + await run(); + + expect(await exists(join(home, ".claude"))).toBe(false); + }); + + it("is silent once the entry is present", async () => { + await mkdir(join(home, ".claude"), { recursive: true }); + await writeFile( + userSettings(), + JSON.stringify({ + autoMode: { allow: ["$defaults", "Taskless CLI: x"] }, + }) + ); + const { stdout } = await run(); + + expect(stdout).not.toContain("autoMode.allow"); + }); + + it("reports it on the --json envelope, with the warning on stderr", async () => { + const { stdout, stderr } = await run("--json"); + const envelope = JSON.parse(stdout) as { + globalTrust: Array<{ harness: string; file: string; state: string }>; + }; + + expect(envelope.globalTrust).toEqual([ + { + harness: "Claude Code", + file: userSettings(), + key: "autoMode.allow", + state: "absent", + skipped: null, + }, + ]); + expect(stderr).toContain("autoMode.allow"); + }); + }); + describe("the restart-your-agents banner", () => { it("prints on a second install whose recorded version moved", async () => { // Planting an older version is what makes the next run an upgrade. The diff --git a/packages/cli/test/installed-documentation.test.ts b/packages/cli/test/installed-documentation.test.ts index d7d00f31..f62c70c6 100644 --- a/packages/cli/test/installed-documentation.test.ts +++ b/packages/cli/test/installed-documentation.test.ts @@ -14,6 +14,7 @@ import { getEmbeddedSkills, } from "../src/install/install"; import { buildInvocation } from "../src/util/invocation"; +import { GLOBAL_TRUST_ENTRY } from "../src/install/global-trust"; import { ENGINES, RULES_DIRECTORY, @@ -206,6 +207,37 @@ describe("this repository's own installed Taskless docs", () => { expect(prose).toContain("do not offer a menu of topics first"); }); + it("hands over the global trust entry when a classifier refuses", async () => { + // Auto mode refused a pinned invocation that the project allowed, as + // "Code from External". No project rule clears that, and a recipe + // explaining what to do would be fetched through the same refused npx, + // so the remedy lives in the body itself. + const skill = await readFile( + resolve(repositoryRoot, "skills", "taskless", "SKILL.md"), + "utf8" + ); + const prose = skill.replaceAll(/\s+/g, " "); + expect(prose).toContain("Do not retry the command"); + expect(prose).toContain("by the Claude Code auto mode classifier"); + expect(prose).toContain("no allow rule can clear it"); + expect(prose).toContain("`autoMode.allow`"); + expect(prose).toContain("`~/.claude/settings.json`"); + expect(prose).toContain('after `"$defaults"`'); + expect(prose).toContain("Never write that entry yourself"); + // The project-rule remedies are for any OTHER block. + expect(prose).toContain("For any other block, offer three ways forward"); + }); + + it("carries the same global trust entry init writes", async () => { + // One text in two places: if they drift, the skill hands over an entry + // init would not recognise, or the reverse. + const skill = await readFile( + resolve(repositoryRoot, "skills", "taskless", "SKILL.md"), + "utf8" + ); + expect(skill).toContain(`\n${GLOBAL_TRUST_ENTRY}\n`); + }); + it("keeps the skill body within its 150-line cap", async () => { // `skill-taskless` caps the body at 150 lines of markdown. The cap guards // against unbounded growth, not against guidance the router needs: when diff --git a/packages/cli/test/permissions.test.ts b/packages/cli/test/permissions.test.ts index 57fe489b..60590e83 100644 --- a/packages/cli/test/permissions.test.ts +++ b/packages/cli/test/permissions.test.ts @@ -12,6 +12,7 @@ import chalk from "chalk"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { + commandOfRule, describePermissionEdits, syncPermissions, type SyncPermissionsOptions, @@ -144,24 +145,96 @@ describe("upgrades", () => { ).toEqual([`Bash(${V2} *)`]); }); - it("never removes a rule Taskless did not record", async () => { - // The user allowed an older Taskless themselves. + it("replaces a Taskless rule it did not record", async () => { + // Added by hand, or by an agent through the skill's fallback: either way + // it pins a version that is no longer installed. await put( ".claude/settings.json", JSON.stringify({ permissions: { allow: ["Bash(npx @taskless/cli@0.12.0 *)"] }, }) ); - await sync({ selected: [".claude"], invocation: V1 }); - await sync({ selected: [".claude"], invocation: V2 }); + const edits = await sync({ selected: [".claude"], invocation: V1 }); + + expect(edits[0]).toMatchObject({ + allowed: [`Bash(${V1} *)`], + removed: ["Bash(npx @taskless/cli@0.12.0 *)"], + }); + expect(await json(".claude/settings.json")).toEqual({ + permissions: { allow: [`Bash(${V1} *)`] }, + }); + }); + + it("replaces the colon form the skill used to propose", async () => { + const stale = + "Bash(npx @taskless/cli-nightly@0.13.0-20261015220659xbf27a23:*)"; + await put( + ".claude/settings.json", + JSON.stringify({ permissions: { allow: [stale] } }) + ); + const edits = await sync({ selected: [".claude"], invocation: V1 }); + + expect(edits[0]?.removed).toEqual([stale]); + expect(await json(".claude/settings.json")).toEqual({ + permissions: { allow: [`Bash(${V1} *)`] }, + }); + }); + + it("removes an unversioned Taskless rule too", async () => { + await put( + ".claude/settings.json", + JSON.stringify({ + permissions: { allow: ["Bash(pnpm dlx @taskless/cli *)"] }, + }) + ); + const edits = await sync({ selected: [".claude"], invocation: V1 }); + + expect(edits[0]?.removed).toEqual(["Bash(pnpm dlx @taskless/cli *)"]); + }); + + it("leaves deny rules and other packages alone", async () => { + const others = [ + "Bash(npx prettier *)", + "Bash(npx @taskless/cli-foo@1.0.0 *)", + "Bash(npx -y @taskless/cli@0.12.0 *)", + ]; + await put( + ".claude/settings.json", + JSON.stringify({ + permissions: { + allow: others, + deny: ["Bash(npx @taskless/cli@0.11.0 *)"], + }, + }) + ); + const edits = await sync({ selected: [".claude"], invocation: V1 }); + expect(edits[0]?.removed).toEqual([]); expect(await json(".claude/settings.json")).toEqual({ permissions: { - allow: ["Bash(npx @taskless/cli@0.12.0 *)", `Bash(${V2} *)`], + allow: [...others, `Bash(${V1} *)`], + deny: ["Bash(npx @taskless/cli@0.11.0 *)"], }, }); }); + it("is a no-op once the file holds only the current rule", async () => { + await sync({ selected: [".claude"], invocation: V1 }); + const edits = await sync({ selected: [".claude"], invocation: V1 }); + + expect(edits).toEqual([]); + }); + + it("leaves an unrecorded file alone for a harness it does not install", async () => { + const contents = JSON.stringify({ + permissions: { allow: ["Bash(npx @taskless/cli@0.12.0 *)"] }, + }); + await put(".claude/settings.json", contents); + await sync({ selected: [".cursor"], invocation: V1 }); + + expect(await text(".claude/settings.json")).toBe(contents); + }); + it("removes only its own line from a multi-line list", async () => { await put( ".claude/settings.json", @@ -224,6 +297,50 @@ describe("Cursor", () => { }); }); +describe("Cursor replacing a stale rule", () => { + it("replaces stale editor and CLI rules, leaving other entries", async () => { + await put( + ".cursor/permissions.json", + JSON.stringify({ + terminalAllowlist: [ + "npx @taskless/cli@0.12.0", + "npx @taskless/cli-foo", + ], + }) + ); + await put( + ".cursor/cli.json", + JSON.stringify({ + permissions: { + allow: [ + "Shell(npx:@taskless/cli@0.12.0)", + "Shell(npx:@taskless/cli@0.12.0 *)", + "Shell(git)", + ], + }, + }) + ); + const edits = await sync({ selected: [".cursor"] }); + + expect(edits.map((edit) => edit.removed)).toEqual([ + ["Shell(npx:@taskless/cli@0.12.0)", "Shell(npx:@taskless/cli@0.12.0 *)"], + ["npx @taskless/cli@0.12.0"], + ]); + expect(await json(".cursor/permissions.json")).toEqual({ + terminalAllowlist: ["npx @taskless/cli-foo", V1], + }); + expect(await json(".cursor/cli.json")).toEqual({ + permissions: { + allow: [ + "Shell(git)", + "Shell(npx:@taskless/cli@0.13.0)", + "Shell(npx:@taskless/cli@0.13.0 *)", + ], + }, + }); + }); +}); + describe("OpenCode", () => { // The object form applies the LAST matching rule. it("puts the rule after a catch-all in the object form, keeping comments", async () => { @@ -273,6 +390,59 @@ describe("OpenCode", () => { }); }); +describe("OpenCode replacing a stale rule", () => { + it("replaces a stale object-form rule but keeps a Taskless deny", async () => { + await put( + "opencode.json", + JSON.stringify({ + permission: { + bash: { + "npx @taskless/cli@0.12.0 *": "allow", + "npx @taskless/cli@0.11.0 *": "deny", + }, + }, + }) + ); + const edits = await sync({ selected: [".opencode"] }); + + // The deny on 0.11.0 does not cover 0.13.0, so the rule is still written. + expect(edits[0]?.removed).toEqual(["npx @taskless/cli@0.12.0 *"]); + expect(await json("opencode.json")).toEqual({ + permission: { + bash: { + "npx @taskless/cli@0.11.0 *": "deny", + [`${V1} *`]: "allow", + }, + }, + }); + }); + + it("replaces a stale array-form rule", async () => { + await put( + "opencode.json", + JSON.stringify({ + permissions: [ + { + action: "shell", + resource: "npx @taskless/cli@0.12.0 *", + effect: "allow", + }, + { action: "shell", resource: "rm *", effect: "ask" }, + ], + }) + ); + const edits = await sync({ selected: [".opencode"] }); + + expect(edits[0]?.removed).toEqual(["npx @taskless/cli@0.12.0 *"]); + expect(await json("opencode.json")).toEqual({ + permissions: [ + { action: "shell", resource: `${V1} *`, effect: "allow" }, + { action: "shell", resource: "rm *", effect: "ask" }, + ], + }); + }); +}); + describe("Codex", () => { it("writes its own rules file when Codex is detected", async () => { await sync({ selected: [".agents"], codexDetected: true }); @@ -287,6 +457,34 @@ describe("Codex", () => { expect(await exists(".codex")).toBe(false); }); + it("replaces a stale rule in a file Taskless headed but did not record", async () => { + await put( + ".codex/rules/taskless.rules", + "# Written by Taskless: allows exactly the installed Taskless CLI.\n" + + 'prefix_rule(pattern = ["npx","@taskless/cli@0.12.0"], decision = "allow")\n' + ); + const edits = await sync({ selected: [".agents"], codexDetected: true }); + + expect(edits[0]?.removed).toEqual([ + 'prefix_rule(pattern = ["npx","@taskless/cli@0.12.0"], decision = "allow")', + ]); + const rules = await text(".codex/rules/taskless.rules"); + expect(rules).toContain("@taskless/cli@0.13.0"); + expect(rules).not.toContain("@taskless/cli@0.12.0"); + }); + + it("recognises its own file by the mark even when the header is reworded", async () => { + await put( + ".codex/rules/taskless.rules", + "# Written by Taskless, in some older wording.\n" + + 'prefix_rule(pattern = ["npx","@taskless/cli@0.12.0"], decision = "allow")\n' + ); + const edits = await sync({ selected: [".agents"], codexDetected: true }); + + expect(edits[0]?.skipped).toBeNull(); + expect(await text(".codex/rules/taskless.rules")).not.toContain("0.12.0"); + }); + it("leaves a rules file at its path that Taskless did not write", async () => { await put(".codex/rules/taskless.rules", "# theirs\n"); const edits = await sync({ selected: [".agents"], codexDetected: true }); @@ -368,3 +566,18 @@ describe("describePermissionEdits", () => { } }); }); + +describe("commandOfRule", () => { + it("reads every harness's spelling as the command it allows", () => { + expect(commandOfRule(`Bash(${V1} *)`)).toBe(V1); + expect(commandOfRule(`Bash(${V1}:*)`)).toBe(V1); + expect(commandOfRule("Shell(npx:@taskless/cli@0.13.0 *)")).toBe(V1); + expect(commandOfRule(`${V1} *`)).toBe(V1); + expect(commandOfRule(V1)).toBe(V1); + expect( + commandOfRule( + 'prefix_rule(pattern = ["npx","@taskless/cli@0.13.0"], decision = "allow")' + ) + ).toBe(V1); + }); +}); diff --git a/packages/cli/test/wizard-integration.test.ts b/packages/cli/test/wizard-integration.test.ts index 7c6f64e9..4da731c4 100644 --- a/packages/cli/test/wizard-integration.test.ts +++ b/packages/cli/test/wizard-integration.test.ts @@ -7,7 +7,7 @@ import { writeFile, } from "node:fs/promises"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { dirname, join } from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const captureSpy = vi.fn(); @@ -29,11 +29,16 @@ const clackResponses: { summary?: boolean | symbol; } = {}; +// Every confirm the wizard asked, in order. +const confirmMessages: string[] = []; + // Captures the message of the summary confirm prompt for assertions. let summaryConfirmMessage: string | undefined; // Everything the wizard showed through `log.message`, in order. const loggedMessages: string[] = []; +// Everything it warned through `log.warn`. +const warnedMessages: string[] = []; vi.mock("@clack/prompts", () => ({ intro: () => {}, @@ -47,12 +52,15 @@ vi.mock("@clack/prompts", () => ({ loggedMessages.push(message); }, error: () => {}, - warn: () => {}, + warn: (message: string) => { + warnedMessages.push(message); + }, }, note: () => {}, isCancel: (value: unknown) => value === fakeCancelSymbol, multiselect: vi.fn(() => Promise.resolve(clackResponses.locations)), confirm: vi.fn(({ message }: { message: string }) => { + confirmMessages.push(message); summaryConfirmMessage = message; return Promise.resolve(clackResponses.summary); }), @@ -68,22 +76,36 @@ async function exists(path: string): Promise { } let cwd: string; +// A home of its own for every test, so no run reads or writes the real +// `~/.claude/settings.json`. +let globalTrustEnvironment: { home: string; env: Record }; + +function userSettings(): string { + return join(globalTrustEnvironment.home, ".claude", "settings.json"); +} beforeEach(async () => { cwd = await mkdtemp(join(tmpdir(), "taskless-wizard-e2e-")); + globalTrustEnvironment = { + home: await mkdtemp(join(tmpdir(), "taskless-wizard-home-")), + env: {}, + }; await mkdir(join(cwd, ".claude"), { recursive: true }); captureSpy.mockClear(); cancelSpy.mockClear(); clackResponses.locations = undefined; clackResponses.summary = undefined; + confirmMessages.length = 0; summaryConfirmMessage = undefined; loggedMessages.length = 0; + warnedMessages.length = 0; vi.stubEnv("TASKLESS_TOKEN", "stub-token"); }); afterEach(async () => { vi.unstubAllEnvs(); await rm(cwd, { recursive: true, force: true }); + await rm(globalTrustEnvironment.home, { recursive: true, force: true }); vi.resetModules(); }); @@ -93,7 +115,7 @@ describe("runWizard end-to-end", () => { clackResponses.summary = true; const { runWizard } = await import("../src/wizard"); - const result = await runWizard({ cwd }); + const result = await runWizard({ cwd, globalTrustEnvironment }); expect(result.status).toBe("completed"); expect(result.locations).toEqual([".claude"]); @@ -124,7 +146,7 @@ describe("runWizard end-to-end", () => { confirmMock.mockClear(); const { runWizard } = await import("../src/wizard"); - const result = await runWizard({ cwd }); + const result = await runWizard({ cwd, globalTrustEnvironment }); expect(result.status).toBe("completed"); const messages = confirmMock.mock.calls.map(([options]) => @@ -138,13 +160,13 @@ describe("runWizard end-to-end", () => { clackResponses.summary = true; const { runWizard } = await import("../src/wizard"); - await runWizard({ cwd }); + await runWizard({ cwd, globalTrustEnvironment }); expect( await exists(join(cwd, ".claude", "skills", "taskless", "SKILL.md")) ).toBe(true); // Re-run with the same selection — no diff, should complete cleanly. - await runWizard({ cwd }); + await runWizard({ cwd, globalTrustEnvironment }); expect( await exists(join(cwd, ".claude", "skills", "taskless", "SKILL.md")) ).toBe(true); @@ -154,7 +176,7 @@ describe("runWizard end-to-end", () => { clackResponses.locations = fakeCancelSymbol; const { runWizard } = await import("../src/wizard"); - const result = await runWizard({ cwd }); + const result = await runWizard({ cwd, globalTrustEnvironment }); expect(result.status).toBe("cancelled"); expect(result.cancelledStep).toBe("locations"); @@ -176,26 +198,140 @@ describe("runWizard end-to-end", () => { clackResponses.summary = false; const { runWizard } = await import("../src/wizard"); - const result = await runWizard({ cwd }); + const result = await runWizard({ cwd, globalTrustEnvironment }); // A fresh install removes nothing, so the confirm is there only because // a settings file would be edited. - expect(summaryConfirmMessage).toBe( - "Write the agent permission rules above?" - ); + expect(summaryConfirmMessage).toBe("Make these changes to your settings?"); const summary = loggedMessages.join("\n"); - expect(summary).toContain(".claude/settings.json"); - expect(summary).toMatch(/ALLOW: Bash\(npx @taskless\/cli@\S+ \*\)/); + expect(summary).toMatch( + /ALLOW \(project\): npx @taskless\/cli@\S+\n {17}in \.claude\/settings\.json/ + ); expect(result.status).toBe("cancelled"); expect(await exists(join(cwd, ".claude", "settings.json"))).toBe(false); }); + it("previews removing a Taskless rule it never recorded", async () => { + // The stale nightly rule an agent added through the skill's fallback. + const stale = + "Bash(npx @taskless/cli-nightly@0.13.0-20261015220659xbf27a23 *)"; + await writeFile( + join(cwd, ".claude", "settings.json"), + JSON.stringify({ permissions: { allow: [stale] } }) + ); + clackResponses.locations = [".claude"]; + clackResponses.summary = false; + + const { runWizard } = await import("../src/wizard"); + await runWizard({ cwd, globalTrustEnvironment }); + + const summary = loggedMessages.join("\n"); + // REMOVE is the widest label, so ALLOW pads to meet it. + expect(summary).toContain( + "REMOVE (project): npx @taskless/cli-nightly@0.13.0-20261015220659xbf27a23\n" + + " in .claude/settings.json" + ); + expect(summary).toMatch(/ALLOW \(project\): {2}npx @taskless\/cli@/); + const settings = summary.slice(summary.indexOf("Agent settings")); + for (const line of settings.split("\n")) { + expect(line.length).toBeLessThanOrEqual(76); + } + }); + + describe("global trust", () => { + it("lists project and global edits together under one question", async () => { + clackResponses.locations = [".claude"]; + clackResponses.summary = true; + + const { runWizard } = await import("../src/wizard"); + await runWizard({ cwd, globalTrustEnvironment }); + + const summary = loggedMessages.join("\n"); + // Labels padded to the widest, statements in one column, the file on + // its own line beneath, and nothing past 76 columns. + expect(summary).toMatch( + /ALLOW \(project\): npx @taskless\/cli@\S+\n {17}in \.claude\/settings\.json/ + ); + expect(summary).toContain( + "ALLOW (global): @taskless/cli or @taskless/cli-nightly at an exact version\n" + + " in ~/.claude/settings.json (autoMode.allow)" + ); + expect(confirmMessages).toEqual(["Make these changes to your settings?"]); + const settings = JSON.parse(await readFile(userSettings(), "utf8")) as { + autoMode: { allow: string[] }; + }; + expect(settings.autoMode.allow[0]).toBe("$defaults"); + expect(settings.autoMode.allow[1]).toMatch(/^Taskless CLI:/); + expect(warnedMessages).toEqual([]); + }); + + it("declining writes neither the project nor the global settings", async () => { + clackResponses.locations = [".claude"]; + clackResponses.summary = false; + + const { runWizard } = await import("../src/wizard"); + const result = await runWizard({ cwd, globalTrustEnvironment }); + + expect(result.status).toBe("cancelled"); + expect(await exists(userSettings())).toBe(false); + expect(await exists(join(cwd, ".claude", "settings.json"))).toBe(false); + }); + + it("asks again on the next run after a decline", async () => { + // Nothing records the answer: an absent entry is offered every time. + clackResponses.locations = [".claude"]; + clackResponses.summary = false; + + const { runWizard } = await import("../src/wizard"); + await runWizard({ cwd, globalTrustEnvironment }); + await runWizard({ cwd, globalTrustEnvironment }); + + expect( + loggedMessages.filter((message) => message.includes("ALLOW (global)")) + ).toHaveLength(2); + }); + + it("asks about the global entry alone when the project is current", async () => { + clackResponses.locations = [".claude"]; + clackResponses.summary = true; + const { runWizard } = await import("../src/wizard"); + // A first run with no home settings writes both; remove the global one. + await runWizard({ cwd, globalTrustEnvironment }); + await rm(userSettings()); + confirmMessages.length = 0; + loggedMessages.length = 0; + + await runWizard({ cwd, globalTrustEnvironment }); + + const summary = loggedMessages.join("\n"); + expect(summary).not.toContain("ALLOW (project)"); + expect(summary).toContain("ALLOW (global)"); + expect(confirmMessages).toEqual(["Make these changes to your settings?"]); + }); + + it("does not offer an entry that is already there", async () => { + await mkdir(dirname(userSettings()), { recursive: true }); + const original = JSON.stringify({ + autoMode: { allow: ["$defaults", "Taskless CLI: mine"] }, + }); + await writeFile(userSettings(), original); + clackResponses.locations = [".claude"]; + clackResponses.summary = true; + + const { runWizard } = await import("../src/wizard"); + await runWizard({ cwd, globalTrustEnvironment }); + + expect(loggedMessages.join("\n")).not.toContain("ALLOW (global)"); + expect(await readFile(userSettings(), "utf8")).toBe(original); + }); + }); + it("writes the permission rule once confirmed", async () => { clackResponses.locations = [".claude"]; clackResponses.summary = true; const { runWizard } = await import("../src/wizard"); - await runWizard({ cwd }); + await runWizard({ cwd, globalTrustEnvironment }); const settings = JSON.parse( await readFile(join(cwd, ".claude", "settings.json"), "utf8") @@ -228,7 +364,7 @@ describe("runWizard end-to-end", () => { }); const { runWizard } = await import("../src/wizard"); - const result = await runWizard({ cwd }); + const result = await runWizard({ cwd, globalTrustEnvironment }); expect(result.status).toBe("cancelled"); expect(result.cancelledStep).toBe("summary"); }); @@ -239,7 +375,7 @@ describe("runWizard end-to-end", () => { // First run: install Taskless into both .claude/ and .agents/. clackResponses.locations = [".claude", ".agents"]; clackResponses.summary = true; - await runWizard({ cwd }); + await runWizard({ cwd, globalTrustEnvironment }); const claudeSkill = join(cwd, ".claude", "skills", "taskless", "SKILL.md"); const agentsSkill = join(cwd, ".agents", "skills", "taskless", "SKILL.md"); @@ -250,7 +386,7 @@ describe("runWizard end-to-end", () => { summaryConfirmMessage = undefined; clackResponses.locations = [".agents"]; clackResponses.summary = true; - const result = await runWizard({ cwd }); + const result = await runWizard({ cwd, globalTrustEnvironment }); expect(result.status).toBe("completed"); // The .claude/ stub is removed; the .agents/ stub is retained. @@ -276,7 +412,7 @@ describe("runWizard end-to-end", () => { clackResponses.locations = [".claude", ".agents"]; clackResponses.summary = true; - await runWizard({ cwd }); + await runWizard({ cwd, globalTrustEnvironment }); const claudeSkill = join(cwd, ".claude", "skills", "taskless", "SKILL.md"); expect(await exists(claudeSkill)).toBe(true); @@ -284,7 +420,7 @@ describe("runWizard end-to-end", () => { // Uncheck .claude/ but decline the removal confirm — nothing is removed. clackResponses.locations = [".agents"]; clackResponses.summary = false; - const result = await runWizard({ cwd }); + const result = await runWizard({ cwd, globalTrustEnvironment }); expect(result.status).toBe("cancelled"); expect(result.cancelledStep).toBe("summary"); @@ -312,7 +448,9 @@ describe("runWizard with an unreadable manifest", () => { await writeFile(manifest, CORRUPT, "utf8"); const { runWizard } = await import("../src/wizard"); - await expect(runWizard({ cwd })).rejects.toThrow(/could not be read/); + await expect(runWizard({ cwd, globalTrustEnvironment })).rejects.toThrow( + /could not be read/ + ); // Closed through `cancel`, so the repair-or-delete remedy lands inside the // frame `intro()` opened instead of after it. @@ -340,7 +478,7 @@ describe("the wizard's restart-your-agents banner", () => { clackResponses.summary = true; const { runWizard } = await import("../src/wizard"); - await runWizard({ cwd }); + await runWizard({ cwd, globalTrustEnvironment }); // Plant an older recorded version, the same way // `init-no-interactive.test.ts` stages an upgrade: the build under test @@ -355,7 +493,7 @@ describe("the wizard's restart-your-agents banner", () => { const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); try { - const result = await runWizard({ cwd }); + const result = await runWizard({ cwd, globalTrustEnvironment }); expect(result.status).toBe("completed"); const printed = logSpy.mock.calls.map((call) => String(call[0])); @@ -377,7 +515,7 @@ describe("the wizard's restart-your-agents banner", () => { const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); try { const { runWizard } = await import("../src/wizard"); - const result = await runWizard({ cwd }); + const result = await runWizard({ cwd, globalTrustEnvironment }); expect(result.status).toBe("completed"); const printed = logSpy.mock.calls.map((call) => String(call[0])); @@ -394,11 +532,11 @@ describe("the wizard's restart-your-agents banner", () => { clackResponses.summary = true; const { runWizard } = await import("../src/wizard"); - await runWizard({ cwd }); + await runWizard({ cwd, globalTrustEnvironment }); const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); try { - const result = await runWizard({ cwd }); + const result = await runWizard({ cwd, globalTrustEnvironment }); expect(result.status).toBe("completed"); const printed = logSpy.mock.calls.map((call) => String(call[0])); @@ -428,7 +566,7 @@ describe("the wizard's stale-pin notice", () => { const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); try { const { runWizard } = await import("../src/wizard"); - const result = await runWizard({ cwd }); + const result = await runWizard({ cwd, globalTrustEnvironment }); expect(result.status).toBe("completed"); const printed = logSpy.mock.calls.map((call) => String(call[0])); @@ -453,7 +591,7 @@ describe("the wizard's stale-pin notice", () => { const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); try { const { runWizard } = await import("../src/wizard"); - await runWizard({ cwd }); + await runWizard({ cwd, globalTrustEnvironment }); const printed = logSpy.mock.calls.map((call) => String(call[0])); expect( printed.some((line) => line.includes("pins a Taskless CLI older")) diff --git a/packages/cli/test/wizard-steps.test.ts b/packages/cli/test/wizard-steps.test.ts index cd80e029..38b34ac7 100644 --- a/packages/cli/test/wizard-steps.test.ts +++ b/packages/cli/test/wizard-steps.test.ts @@ -211,6 +211,22 @@ describe("buildRemovalConfirmMessage", () => { ); }); + it("folds the settings changes into the same question", async () => { + const { buildRemovalConfirmMessage } = + await import("../src/wizard/steps/summary"); + const message = buildRemovalConfirmMessage( + { + entries: [diffEntry(".claude", ["taskless"])], + hasAdditions: false, + hasRemovals: true, + }, + { editsSettings: true } + ); + expect(message).toBe( + "Remove Taskless from .claude/ (1 stub), and make these changes to your settings?" + ); + }); + it("skips targets with no removals", async () => { const { buildRemovalConfirmMessage } = await import("../src/wizard/steps/summary"); diff --git a/skills/taskless/SKILL.md b/skills/taskless/SKILL.md index f2d54d7c..d86d31b8 100644 --- a/skills/taskless/SKILL.md +++ b/skills/taskless/SKILL.md @@ -43,9 +43,27 @@ request: no survey of the install, and no menu of what Taskless can do. settings of each harness it installs for, so the preflight usually passes. If the harness or the user blocks it, or blocks any later Taskless command, -stop before doing any Taskless work and do not try to get around the block. -Send one message that names the exact command you ran, says it downloads and -runs a package from npm, and offers three ways forward: +stop before doing any Taskless work. Do not retry the command or try to get +around the block. Send one message that names the exact command you ran and +says it downloads and runs a package from npm. The rest of that message +depends on what refused it. + +If your own safety classifier refused it, not a missing permission (in Claude +Code, a denial "by the Claude Code auto mode classifier"), no allow rule can +clear it: the classifier overrules project rules. Give the user this entry, to +add themselves to the top-level `autoMode.allow` list in +`~/.claude/settings.json`, after `"$defaults"`: + +```text +Taskless CLI: The npm packages @taskless/cli and @taskless/cli-nightly are tooling published by Taskless via npm trusted publishing (OIDC), installed by the user through the Taskless installer. Running either via npx or pnpm dlx is allowed only when the call names an exact version (e.g. @taskless/cli@1.2.3). Dist-tags such as @latest or @nightly, version ranges, and unversioned calls are not covered. +``` + +Never write that entry yourself: an agent widening its own classifier's trust +is exactly what the classifier refuses. Running `%(TASKLESS_CLI)s` with no +arguments, in their own terminal, also offers to add it. Until then, they can approve the exact command in chat, +which the classifier weighs but may still refuse, or leave auto mode. + +For any other block, offer three ways forward: - You add an allow rule for exactly the invocation `%(TASKLESS_CLI)s` to the agent's permission settings. If the harness blocks that edit too, give the