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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/claude-demo-blockers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@bradygaster/squad-sdk": patch
"@bradygaster/squad-cli": patch
---

Claude Code: `squad init`/`squad upgrade` add a scoped `.claude/settings.json` allowlist for Squad's own tools (the coordinator's `squad_state` tools by name, `squad cast`, `squad client sync` and the reviewer-lockout steps other than `override`; launcher rules only for Squad's own launcher; rollback removes only the rules Squad recorded adding), `squad cast` refuses to write through a symlink,, turned off with `"claudePermissions": "off"`. The non-Copilot coordinators run the reviewer gate on every code or test change unasked, treat a known gap against the issue or a recorded decision as a REJECT, never let an author review their own work, and name the recorded decision that shapes the work.
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,8 @@ Claude Code is the first host after GitHub Copilot where Squad uses the host's o
- `.squad/squad.mjs` is a launcher Squad commits. It finds a Squad CLI on each machine: `SQUAD_CLI`, the project's `node_modules`, then `squad` on PATH. It skips a CLI older than the one that wrote it, and uses `npx` only with `SQUAD_ALLOW_NPX=1`. It shares its code with the plugin's launcher.
- Claude hooks reach the launcher through `$CLAUDE_PROJECT_DIR`. Standalone bundle installs write `squad state-mcp`. A CLI run through `npx` keeps the exact-version `npx` command.
- `squad doctor` flags machine-specific launch commands and a missing launcher. Run `squad upgrade` once to fix an existing repository.
- **Squad's own tools no longer prompt in Claude Code.** In a demo dry run, a fresh `squad init --client claude` project hit 13 permission prompts while casting the team, and the coordinator ended up hand-writing charters and asking the user to run `squad cast` themselves. `squad init` and `squad upgrade` now add a scoped allowlist to `.claude/settings.json`: the `squad_state` tools the coordinator uses, by name (never the experimental backend's approval tools), and `squad cast`, `squad client sync` and the reviewer-lockout steps (`reject`, `assign`, `submit`, `approve`, `status`; never `override`), as `squad …` and, when `.mcp.json` runs Squad's own launcher, as `node .squad/squad.mjs …`. Nothing else is allowed, and `squad cast` now refuses to write through a symbolic link. Your own rules come first and are never touched; Squad records the rules it added in `.claude/.squad-permissions.json`, and rollback removes only those, and `"claudePermissions": "off"` in `.squad/config.json` turns it off. Claude Code applies project allow rules only after the folder is trusted (the trust prompt on your first interactive `claude` run there).
- **The reviewer gate runs on its own and means it.** In the same dry run, the coordinator declared a bug fix done without the lead's review even though `routing.md` named the lead as the approval gate; when asked, the lead listed six known gaps against the issue and still approved; and a reviewer's own fix went unreviewed. The non-Copilot coordinators now send every code or test change to the reviewer before calling it done, unasked; the reviewer is never an author, judges against the issue and the team's recorded decisions (a known gap against either is a REJECT, not an advisory), and code a reviewer writes needs a different reviewer. The coordinator also names a recorded decision when it shapes the work ("Per the team decision of <date>: …").
- **Coordinators wait for a human's sign-off.** When `team.md` or `routing.md` says a human must sign off on work (a release, tag, publish, merge or deploy), the Claude, Kiro, OpenCode, Cursor, Codex and Antigravity coordinators no longer treat the request itself as the sign-off. They name the rule and the human, ask whether you are that human or have their sign-off, go ahead only on an explicit yes, and record it in the decisions inbox. In a non-interactive run they stop at the gate. Seen in a live test: asked to "cut the 1.0.0 release", the Claude coordinator used to bump, tag and run `npm publish` itself.
- `squad_state` works when the team folder is reached through a symlink, such as macOS `/tmp` (really `/private/tmp`) or a linked team root. Squad stores the folder's resolved path but was given paths in the original spelling, so it refused reads and writes there with "Path traversal blocked". Paths that really leave the team folder, including through a symlink inside it, are still refused.
- The Claude Code plugin has an icon (the Squad logo, 512 px), so `claude plugin validate` reports no warnings. Images are marked binary in `.gitattributes`, so the plugin folder's LF rule never rewrites them.
Expand Down
10 changes: 10 additions & 0 deletions docs/src/content/docs/scenarios/client-compatibility.md
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,16 @@ Interactive Claude Code doesn't load project-scoped `.mcp.json` servers until yo
| **Hooks** | ✅ Opt-in, advisory | Off by default. See [Governance hooks](#governance-hooks). |
| **`squad watch --execute`** | ✅ Preset | `squad watch --execute --agent-preset claude` (or `"agentPreset": "claude"` under `watch` in `.squad/config.json`) runs each round as `claude -p … --agent squad` with a `squad_state`-only MCP config written by the `squad` CLI (never the repository's `.mcp.json`, whose other servers keep Claude Code's approval). Execute mode defaults to `--permission-mode bypassPermissions`, the counterpart of Copilot's `--yolo`; override it with `--claude-flags`. Fleet dispatch (`--dispatch-mode fleet`) stays Copilot-only. |

### Permissions for Squad's own tools

`squad init --client claude` and `squad upgrade --client claude` add these rules to `permissions.allow` in `.claude/settings.json`, so the coordinator can cast a team and use its state without a permission prompt for each call:

- each `squad_state` tool the coordinator uses, by name: `mcp__squad_state__squad_decide`, `…__squad_state_read`, `…_write`, `…_append`, `…_delete`, `…_list`, `…_create_if_absent`, `…_health` and `…_upstreams` (in plugin mode, the same tools under `mcp__plugin_squad_squad_state__`). Not the whole server: the experimental backend tools, such as approving a harness request, and the `memory.*` tools still ask.
- `Bash(squad cast:*)`, `Bash(squad client sync:*)`, and the reviewer-lockout steps `squad lockout reject`, `assign`, `submit`, `approve` and `status`. Not `squad lockout override`, which readmits a rejected author on a human's authority and always asks.
- The same commands through the repository launcher, `node .squad/squad.mjs …`, only while `.mcp.json` runs Squad's own launcher from a folder inside the repository. A `squad.mjs` Squad didn't write is never pre-approved.

Nothing else is allowed: no edits, no other commands, no other MCP servers. `squad cast` refuses to write through a symbolic link, so a pre-approved cast can't be pointed at a file outside the team folder. Your own rules stay first and are never changed. Squad records the rules it added in `.claude/.squad-permissions.json` (commit it with `settings.json`), and removing Claude Code from the team, or turning the allowlist off, removes only those: a rule you had already written stays. Claude Code applies a project's allow rules only once you've trusted the folder (the trust prompt on your first interactive `claude` run there); until then, and in headless runs in an untrusted folder, it ignores them and says so. To turn the allowlist off, set `"claudePermissions": "off"` in `.squad/config.json` and run `squad upgrade --client claude`.

### Governance hooks

To turn the hooks on, add `"claudeHooks": "advisory"` to `.squad/config.json` and run `squad upgrade --client claude`. Squad then adds these hooks to `.claude/settings.json`:
Expand Down
45 changes: 45 additions & 0 deletions packages/squad-cli/src/cli/commands/cast.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
* @module cli/commands/cast
*/

import * as fs from 'node:fs';
import * as path from 'node:path';
import {
FSStorageProvider,
Expand All @@ -27,6 +28,48 @@ import { fatal, fatalNoSquad } from '../core/errors.js';

const storage = new FSStorageProvider();

/**
* The first symlink on the way from `root` down to `target` (`root` itself may be a link: a linked
* team or external state directory), or null. `squad cast` can run without an approval prompt in
* Claude Code, so it must not follow a planted link out of the team directory.
*/
export function symlinkUnder(root: string, target: string): string | null {
const rel = path.relative(root, target);
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return null;
let cur = root;
for (const part of rel.split(path.sep)) {
cur = path.join(cur, part);
try {
if (fs.lstatSync(cur).isSymbolicLink()) return cur;
} catch {
return null; // Does not exist yet: nothing below it can be a link either.
}
}
return null;
}

/** Refuse to cast when a file `squad cast` writes is reached through a symlink. */
function assertCastTargetsConfined(squadDir: string, teamRoot: string, agentDir: string): void {
const castingDir = path.join(squadDir, 'casting');
let castingEntries: string[] = [];
try {
castingEntries = fs.readdirSync(castingDir).map((f) => path.join(castingDir, f));
} catch {
// No casting directory yet.
}
const targets: Array<[string, string]> = [
...[agentDir, path.join(agentDir, 'charter.md'), path.join(agentDir, 'history.md'),
path.join(squadDir, 'team.md'), path.join(squadDir, 'routing.md'), castingDir, ...castingEntries]
.map((t): [string, string] => [squadDir, t]),
[teamRoot, path.join(teamRoot, 'squad.config.ts')],
[teamRoot, path.join(teamRoot, '.github', 'agents', 'squad.agent.md')],
];
for (const [root, target] of targets) {
const link = symlinkUnder(root, target);
if (link) fatal(`Refusing to cast: ${link} is a symbolic link. squad cast only writes real files inside the team directory.`);
}
}

export interface CastAddOptions {
name: string;
role: string;
Expand Down Expand Up @@ -329,6 +372,8 @@ export async function runCastAdd(cwd: string, options: CastAddOptions): Promise<
const charterPath = path.join(agentDir, 'charter.md');
const historyPath = path.join(agentDir, 'history.md');

assertCastTargetsConfined(squadDir, teamRoot, agentDir);

if (storage.existsSync(charterPath) && !options.force) {
fatal(`Agent "${rawName}" already exists at ${charterPath} (use --force to overwrite)`);
}
Expand Down
2 changes: 1 addition & 1 deletion packages/squad-cli/src/cli/commands/state-mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ const SERVER_INFO = {
version: getPackageVersion(),
};

const MCP_TOOL_ALIASES: Record<string, string> = {
export const MCP_TOOL_ALIASES: Record<string, string> = {
squad_decide: 'squad_decide',
squad_state_read: 'squad_state_read',
squad_state_write: 'squad_state_write',
Expand Down
160 changes: 153 additions & 7 deletions packages/squad-sdk/src/client/adapters/claude-hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -197,18 +197,153 @@ export function mergeClaudeHooks(settings: Json, mcp: McpLaunchSpec | null): Jso
return { ...stripped, hooks };
}

// ---------------------------------------------------------------------------------------------
// Permission allowlist: Squad's own tools, so the coordinator's first run isn't a wall of prompts.
// ---------------------------------------------------------------------------------------------

export type ClaudePermissionsMode = 'off' | 'squad';

/** `claudePermissions` from `<squadDir>/config.json`. On (`"squad"`) unless it is `"off"`. */
export function readClaudePermissionsMode(squadAbs: string): ClaudePermissionsMode {
try {
const cfg = JSON.parse(fs.readFileSync(path.join(squadAbs, 'config.json'), 'utf8')) as { claudePermissions?: unknown };
return cfg.claudePermissions === 'off' ? 'off' : 'squad';
} catch {
return 'squad';
}
}

/**
* The `squad_state` tools the coordinator uses, allowed one by one. Not the whole server: with
* `--experimental-backend` it also serves backend tools such as `squad_backend_respond_approval`,
* which must stay behind the user's approval. Keep in step with state-mcp's `MCP_TOOL_ALIASES`.
*/
export const SQUAD_STATE_PERMISSION_TOOLS = [
'squad_decide', 'squad_state_read', 'squad_state_write', 'squad_state_append', 'squad_state_delete',
'squad_state_list', 'squad_state_create_if_absent', 'squad_state_health', 'squad_state_upstreams',
] as const;
/**
* The `squad` subcommands the coordinator runs with Bash: casting, subagent sync and the reviewer
* lockout steps. Not `lockout override`: that readmits a rejected author on a human's authority.
*/
const SQUAD_BASH_SUBCOMMANDS = [
'cast', 'client sync',
'lockout reject', 'lockout assign', 'lockout submit', 'lockout approve', 'lockout status',
] as const;
/** The plugin's `squad_state` server (`mcp__plugin_<plugin>_squad_state`); see CLAUDE_PLUGIN_MCP_PREFIX. */
const PLUGIN_SQUAD_STATE_SERVER = 'mcp__plugin_squad_squad_state';

/** A plain repository-relative directory (`.squad`, `team/.squad`): no absolute path, no `.` or `..` part. */
function isPlainRelativeDir(dir: string): boolean {
const parts = dir.split('/');
return parts.every((p) => p !== '.' && p !== '..' && /^[\w.-]+$/.test(p));
}

/**
* The `permissions.allow` rules Squad adds: each `squad_state` tool the coordinator uses, and its
* CLI subcommands as `squad …` on PATH and, when `launcherDir` is given, through that directory's
* repository launcher (`node <launcherDir>/squad.mjs …`). Nothing else: no Edit, no general Bash,
* no other MCP tool. A `launcherDir` that isn't a plain relative directory gets no launcher rules.
*/
export function claudePermissionRules(launcherDir: string | null, plugin: boolean): string[] {
const server = plugin ? PLUGIN_SQUAD_STATE_SERVER : 'mcp__squad_state';
const dir = launcherDir?.replace(/\\/g, '/');
const launcher = dir && isPlainRelativeDir(dir) ? `node ${dir}/squad.mjs` : null;
return [
...SQUAD_STATE_PERMISSION_TOOLS.map((t) => `${server}__${t}`),
...SQUAD_BASH_SUBCOMMANDS.map((c) => `Bash(squad ${c}:*)`),
Comment thread
bradygaster marked this conversation as resolved.
Resolved
...(launcher ? SQUAD_BASH_SUBCOMMANDS.map((c) => `Bash(${launcher} ${c}:*)`) : []),
];
}

/**
* Which allow rules Squad added, so rollback and refresh remove only those. A rule the user already
* had is never recorded, so it stays when Squad's are removed.
*/
const PERMISSIONS_STATE = '.claude/.squad-permissions.json';
const PERMISSIONS_OWNER = 'Squad Claude permissions';

export function readClaudePermissionsOwnership(root: string): string[] {
const file = path.join(root, PERMISSIONS_STATE);
if (!fs.existsSync(file)) return [];
const state: unknown = JSON.parse(fs.readFileSync(file, 'utf8'));
if (!isObject(state) || state['owner'] !== PERMISSIONS_OWNER || !Array.isArray(state['added']) ||
!state['added'].every((r) => typeof r === 'string')) {
throw new Error(`Refusing to overwrite ${PERMISSIONS_STATE}: not a Squad ownership record`);
}
return state['added'] as string[];
}

export interface ClaudePermissionsMerge {
settings: Json;
/** The rules now in `permissions.allow` because Squad added them. */
owned: string[];
}

/**
* Refresh Squad's allow rules. `owned` is what Squad added before: those no longer in `rules` are
* removed; rules missing from `allow` are appended and recorded; a rule already present that Squad
* did not add is the user's, left in place and not recorded. `rules` null or empty: remove Squad's
* only. User rules keep their order. Untouched input is returned as is.
*/
export function mergeClaudePermissions(settings: Json, rules: readonly string[] | null, owned: readonly string[] = []): ClaudePermissionsMerge {
const want = rules ?? [];
const perms = settings['permissions'];
if (perms !== undefined && !isObject(perms)) {
if (want.length === 0) return { settings, owned: [] };
throw new Error('Refusing to overwrite "permissions" in .claude/settings.json: expected a JSON object');
}
const allow = isObject(perms) ? perms['allow'] : undefined;
if (allow !== undefined && !Array.isArray(allow)) {
if (want.length === 0) return { settings, owned: [] };
throw new Error('Refusing to overwrite "permissions.allow" in .claude/settings.json: expected an array');
}
const ours = new Set(owned);
const kept = ((allow as unknown[] | undefined) ?? []).filter((r) => !(typeof r === 'string' && ours.has(r) && !want.includes(r)));
const nextOwned: string[] = [];
for (const rule of want) {
if (!kept.includes(rule)) {
kept.push(rule);
nextOwned.push(rule);
} else if (ours.has(rule)) {
nextOwned.push(rule);
}
}
const nextPerms: Json = { ...(perms ?? {}) };
if (kept.length > 0) nextPerms['allow'] = kept;
else delete nextPerms['allow'];
const out: Json = { ...settings };
if (Object.keys(nextPerms).length > 0) out['permissions'] = nextPerms;
else delete out['permissions'];
return { settings: JSON.stringify(out) === JSON.stringify(settings) ? settings : out, owned: nextOwned };
}

/** Remove the allow rules Squad added (`owned`), and `allow`/`permissions` they leave empty. */
export function stripClaudePermissions(settings: Json, owned: readonly string[]): Json {
return mergeClaudePermissions(settings, null, owned).settings;
}

/**
* The `.claude/settings.json` entry. Emitted while hooks are on, and while the file exists (so
* turning hooks off, or rolling Claude back, removes Squad's handlers). Never creates the file
* just to leave it empty.
* The `.claude/settings.json` entry: Squad's permission allowlist (on unless `claudePermissions` is
* `"off"`), its hooks while they are on, and the plugin keys in plugin mode. Emitted while any of
* those is on, and while the file exists (so turning one off, or rolling Claude back, removes
* Squad's entries).
*/
export function projectClaudeHooks(ctx: ClientContext, opts: { plugin?: boolean } = {}): ProjectionFile[] {
const squadAbs = path.resolve(ctx.root, ctx.squadDir ?? '.squad');
const plugin = opts.plugin === true;
// In plugin mode the plugin registers the hooks; the project file only carries the plugin keys.
const on = !plugin && readClaudeHooksMode(squadAbs) === 'advisory' && claudeHookLaunch(ctx.mcp, 'Stop') !== null;
// Launcher rules only when the committed MCP command is Squad's own repository launcher: an
// unmanaged `squad.mjs` Squad refused to overwrite is never pre-approved.
const launcher = ctx.mcp ? repoLauncherPath(ctx.mcp) : null;
const rules = readClaudePermissionsMode(squadAbs) === 'squad'
? claudePermissionRules(launcher ? launcher.slice(0, -'/squad.mjs'.length) : null, plugin)
: null;
let ownership = readClaudePluginSettingsOwnership(ctx.root);
if (!on && !plugin && !ownership && !fs.existsSync(path.join(ctx.root, CLAUDE_SETTINGS_PATH))) return [];
let owned = readClaudePermissionsOwnership(ctx.root);
const permissionsTracked = rules !== null || owned.length > 0;
if (!on && !permissionsTracked && !plugin && !ownership && !fs.existsSync(path.join(ctx.root, CLAUDE_SETTINGS_PATH))) return [];
const coordinator = path.join(ctx.root, '.claude/agents/squad.md');
const projectMcp = fs.existsSync(coordinator) &&
fs.readFileSync(coordinator, 'utf8').includes('Generated by Squad. Managed file:');
Expand All @@ -229,12 +364,23 @@ export function projectClaudeHooks(ctx: ClientContext, opts: { plugin?: boolean
projectMcp,
}
: ownership;
const next = mergeClaudePluginSettings(mergeClaudeHooks(existing, on ? ctx.mcp : null), plugin, nextOwnership);
const merged = mergeClaudePermissions(
mergeClaudePluginSettings(mergeClaudeHooks(existing, on ? ctx.mcp : null), plugin, nextOwnership),
rules,
owned,
);
ownership = nextOwnership;
return next;
owned = merged.owned;
return merged.settings;
},
remove: (existing) => mergeClaudePluginSettings(stripClaudeHooks(existing), false, ownership),
remove: (existing) => stripClaudePermissions(mergeClaudePluginSettings(stripClaudeHooks(existing), false, ownership), owned),
},
...(permissionsTracked ? [{
kind: 'json' as const,
path: PERMISSIONS_STATE,
update: () => owned.length > 0 ? { owner: PERMISSIONS_OWNER, added: [...owned] } : {},
remove: () => ({}),
}] : []),
...(plugin || ownership ? [{
kind: 'json' as const,
path: PLUGIN_SETTINGS_STATE,
Expand Down
Loading
Loading