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
108 changes: 108 additions & 0 deletions docs/security/protected-path-edits.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
# Protected-path edit grants

## What are Claude Code protected paths?

Claude Code maintains a hardcoded set of **protected paths** that are never
auto-approved for writes, even in `acceptEdits` permission mode. The relevant
prefixes for phax are:

- `.claude/` (except `.claude/worktrees/`)

Other protected prefixes (`.git/`, `.vscode/`, `.idea/`) are enforced by Claude
Code but are out of scope for phax grants.

## Why headless runs cannot reach them

phax runs Claude Code headless with `--permission-mode acceptEdits`, which
auto-approves edits inside the writable directories (the worktree root plus any
`--add-dir` paths). Protected paths are checked **before** that permission
evaluation, so an `Edit(.claude/**)` entry in `permissions.allow` has no effect —
the write is silently denied.

The only full overrides are:

- `--permission-mode bypassPermissions` — drops the entire jail (Bash allow-list
and filesystem bounds), unacceptable for secure runs.
- A `PreToolUse` **hook** that returns an explicit `allow` decision for a single
tool call.

## The PreToolUse hook approach

phax generates a narrow `PreToolUse` hook scoped to exactly the protected paths
a phase declares and passes it to `claude` via `--settings`. The hook:

1. Receives the tool name and input as JSON on stdin from Claude Code.
2. Calls the domain decision (`decideProtectedPathApproval`).
3. Emits `{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"allow"}}`
for an approved path; emits nothing (exits 0) otherwise.

The rest of the secure jail — Bash allow-list, filesystem bounds — is untouched.

## Trust model: plan declares, config grants

The operator opts in at the `phax.json` level; a plan then declares what it
needs. phax enforces that a declared protected path must be covered by an
opted-in prefix — a plan cannot widen its own protected-write surface.

### Operator opt-in (`phax.json`)

Add protected path-prefixes to `security.filesystem.allowWriteProtected`:

```json
{
"security": {
"filesystem": {
"allowWriteProtected": [".claude/skills/"]
}
}
}
```

An absent or empty array means the feature is off and behavior is unchanged.

### Phase declaration (`plan.md`)

List the specific protected files in the phase's planned-file sections:

```markdown
### Planned files to edit

- .claude/skills/phax-planning/SKILL.md
```

### Preflight enforcement

Before spawning the phase agent, phax resolves each declared protected path
against the opted-in prefixes:

- **Covered** (declared path falls under an `allowWriteProtected` prefix) →
path is added to `approvedProtectedPaths`; the hook will approve it at
runtime.
- **Uncovered** (protected but not opted into by config) → preflight fails with
a `SecurityPreflightError` naming the phase and the offending path.

Non-protected paths are never checked here.

## Required PHAX security configuration changes

When a plan phase needs to edit a `.claude/**` file, add the covering prefix to
`phax.json` before running. Without it the preflight will fail before any agent
spawns.

Example: to allow editing `.claude/skills/phax-planning/SKILL.md`, add:

```json
"security": {
"filesystem": {
"allowWriteProtected": [".claude/skills/"]
}
}
```

## Provider scope

Protected paths are a Claude Code concept. The codex and mistral-vibe adapters
sandbox the filesystem at the worktree level and do not block `.claude/**`, so
they need no hook. The `approvedProtectedPaths` field is computed and recorded in
`security.json` for all providers (audit parity), but only the Claude Code
adapter consumes it to generate a hook.
7 changes: 7 additions & 0 deletions phax.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,13 @@
"items": {
"$ref": "#/$defs/NonEmptyString"
}
},
"allowWriteProtected": {
"type": "array",
"description": "Protected path-prefixes (e.g. \".claude/skills/\") the operator opts into allowing a phase to edit via a scoped PreToolUse hook. A phase declaring a file under one of these prefixes receives a narrow edit grant; a declared protected file not covered by any prefix fails preflight.",
"items": {
"$ref": "#/$defs/NonEmptyString"
}
}
},
"additionalProperties": false
Expand Down
10 changes: 9 additions & 1 deletion scripts/generate-usage-spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,12 @@ function emitArg(arg: Argument, indent: string): string {
return `${indent}arg "${name}"`;
}

// Internal/hook subcommands use a __ prefix by convention and are excluded
// from the usage spec.
function isInternalCommand(cmd: Command): boolean {
return cmd.name().startsWith("__");
}

function emitCommand(cmd: Command, indent: string, parentPath = ""): string[] {
const cmdPath = parentPath ? `${parentPath} ${cmd.name()}` : cmd.name();
const lines: string[] = [`${indent}cmd "${cmd.name()}" {`];
Expand All @@ -90,6 +96,7 @@ function emitCommand(cmd: Command, indent: string, parentPath = ""): string[] {
}

for (const sub of cmd.commands) {
if (isInternalCommand(sub)) continue;
lines.push("");
lines.push(...emitCommand(sub, inner, cmdPath));
}
Expand Down Expand Up @@ -136,8 +143,9 @@ export function generateUsageSpec(): string {
}
lines.push(``);

// All top-level commands.
// All visible top-level commands (internal __ commands are excluded).
for (const cmd of program.commands) {
if (isInternalCommand(cmd)) continue;
lines.push(...emitCommand(cmd, ""));
lines.push(``);
}
Expand Down
43 changes: 43 additions & 0 deletions src/app/executePlan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ import {
checkRequiredCommands,
computeFrozenAgentCommands,
} from "../domain/security/agentCommands.js";
import { resolveProtectedApprovals } from "../domain/security/protectedPaths.js";
import { resolveSecurityPolicy } from "../domain/security/resolvePolicy.js";
import { cleanupPhase } from "./cleanup.js";
import { commitPhase } from "./commit.js";
Expand Down Expand Up @@ -513,13 +514,24 @@ export function executePlan(
requiredCommands: plan.run.requiredCommands,
provider: binding.provider,
});
const resumePlannedPaths = [
...phase.plannedFilesToCreate,
...phase.plannedFilesToEdit,
...phase.optionalFilesToEdit,
];
const resumeProtectedApprovals = resolveProtectedApprovals({
plannedPaths: resumePlannedPaths,
allowWriteProtected: securityPolicy.filesystem.allowWriteProtected,
worktreeRoot: worktreePath as string,
});
agentOptions = {
provider: binding.provider,
model: binding.model,
effort: binding.effort,
cwd: worktreePath as string,
security: securityPolicy,
agentCommands: resumeFrozenResult.records.map((r) => r.command),
approvedProtectedPaths: resumeProtectedApprovals.approved,
outputJsonlPath: join(phaseFolderPath, "output.jsonl"),
phaseFolderPath,
};
Expand Down Expand Up @@ -607,6 +619,35 @@ export function executePlan(
worktreePath: worktreePath as string,
config: config.security,
});

// Preflight: verify all declared protected paths are covered by the
// operator's allowWriteProtected opt-in before spawning the agent.
const phasePlannedPaths = [
...phase.plannedFilesToCreate,
...phase.plannedFilesToEdit,
...phase.optionalFilesToEdit,
];
const protectedApprovals = resolveProtectedApprovals({
plannedPaths: phasePlannedPaths,
allowWriteProtected: securityPolicy.filesystem.allowWriteProtected,
worktreeRoot: worktreePath as string,
});
// Protected paths only block in secure mode (that is where Claude Code's
// acceptEdits sandbox is active). In unsafe/isolated mode there is no
// jail to circumvent, so the hook is irrelevant and we skip the check.
if (securityPolicy.mode === "secure" && protectedApprovals.uncovered.length > 0) {
return yield* Effect.fail(
new SecurityPreflightError({
message: [
`Security preflight failed: phase "${phase.id}" declares ${protectedApprovals.uncovered.length} protected path(s) not covered by security.filesystem.allowWriteProtected in phax.json.`,
`Uncovered: ${protectedApprovals.uncovered.map((p) => `"${p}"`).join(", ")}`,
`Add a matching prefix to security.filesystem.allowWriteProtected in phax.json before running.`,
].join("\n"),
missing: protectedApprovals.uncovered,
}),
);
}

const securityFilter: SecurityFilter = (provider) => {
if (securityMode !== "secure") {
return { allowed: true };
Expand Down Expand Up @@ -689,6 +730,7 @@ export function executePlan(
marks: postureMarks,
agentCommands: frozenResult.records,
providerSkippedForSecurity: resolution.skippedForSecurity ?? [],
approvedProtectedPaths: protectedApprovals.approved,
};
yield* fs.writeAtomic(
join(phaseFolderPath, "security.json"),
Expand Down Expand Up @@ -744,6 +786,7 @@ export function executePlan(
cwd: worktreePath as string,
security: securityPolicy,
agentCommands: frozenResult.records.map((r) => r.command),
approvedProtectedPaths: protectedApprovals.approved,
outputJsonlPath: join(phaseFolderPath, "output.jsonl"),
phaseFolderPath,
};
Expand Down
71 changes: 71 additions & 0 deletions src/cli/commands/approveProtectedPath.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { decideProtectedPathApproval } from "../../domain/security/protectedPaths.js";
import {
parseClaudeHookPayload,
PHAX_APPROVED_PATHS_ENV,
} from "../../schemas/claudeHookPayload.js";

const ALLOW_OUTPUT = JSON.stringify({
hookSpecificOutput: {
hookEventName: "PreToolUse",
permissionDecision: "allow",
},
});

function readApprovedPaths(): readonly string[] {
const raw = process.env[PHAX_APPROVED_PATHS_ENV];
if (!raw) return [];
try {
const parsed: unknown = JSON.parse(raw);
if (Array.isArray(parsed) && parsed.every((x) => typeof x === "string")) {
return parsed as string[];
}
return [];
} catch {
return [];
}
}

async function readStdin(): Promise<string> {
return new Promise((resolve) => {
let data = "";
process.stdin.setEncoding("utf8");
process.stdin.on("data", (chunk: string) => {
data += chunk;
});
process.stdin.on("end", () => resolve(data));
process.stdin.resume();
});
}

/**
* Thin CLI entry point for the Claude Code PreToolUse hook.
*
* Reads the hook payload from stdin, decodes it, and calls the domain
* decision with the approved paths from the PHAX_APPROVED_PATHS env var.
* On "allow" prints the Claude permissionDecision JSON; on "defer" prints
* nothing and exits 0 so Claude's normal protected-path handling applies.
*
* Contains no business logic — all decisions are in decideProtectedPathApproval.
*/
export async function runApproveProtectedPath(): Promise<number> {
const approvedAbsolutePaths = readApprovedPaths();
const stdinText = await readStdin();
const payload = parseClaudeHookPayload(stdinText.trim());

if (payload === undefined) {
// Unrecognized payload — defer to Claude's default handling.
return 0;
}

const decision = decideProtectedPathApproval({
approvedAbsolutePaths,
toolName: payload.tool_name,
filePath: payload.tool_input.file_path,
});

if (decision === "allow") {
process.stdout.write(ALLOW_OUTPUT + "\n");
}

return 0;
}
8 changes: 7 additions & 1 deletion src/cli/introspect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@ export interface CommandNode {
subcommands: CommandNode[];
}

// Internal/hook subcommands use a __ prefix by convention and are excluded
// from the public CLI surface (usage spec, help, parity gate).
function isInternalCommand(cmd: Command): boolean {
return cmd.name().startsWith("__");
}

function walkCommand(cmd: Command): CommandNode {
const flags = cmd.options
.map((opt) => opt.long)
Expand All @@ -17,7 +23,7 @@ function walkCommand(cmd: Command): CommandNode {
return {
name: cmd.name(),
flags,
subcommands: cmd.commands.map(walkCommand),
subcommands: cmd.commands.filter((sub) => !isInternalCommand(sub)).map(walkCommand),
};
}

Expand Down
11 changes: 11 additions & 0 deletions src/cli/program.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import { runInit } from "./commands/init.js";
import { registerSchemaCommand } from "./commands/schema.js";
import { runCompletions } from "./commands/completions.js";
import { runReport } from "./commands/report.js";
import { runApproveProtectedPath } from "./commands/approveProtectedPath.js";

export function buildProgram(): Command {
const program = new Command();
Expand Down Expand Up @@ -384,6 +385,16 @@ export function buildProgram(): Command {
registerSkillsCommand(program, consoleOutput);
registerSchemaCommand(program, consoleOutput);

// Hidden subcommand invoked by the generated Claude Code PreToolUse hook;
// not intended for direct user use.
const approveCmd = new Command("__approve-protected-path")
.description("Internal: Claude Code PreToolUse hook for protected-path approval")
.action(async () => {
const exitCode = await runApproveProtectedPath();
process.exit(exitCode);
});
program.addCommand(approveCmd, { hidden: true });

// Wire long help and examples into the runtime --help output after all
// registrations so commands from *Register.ts files are covered without
// touching those files.
Expand Down
Loading
Loading