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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions src/agent/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,9 @@ export type AgentToolsetArgs = {
settings?: Settings | (() => Settings | undefined);
catalog?: readonly ProviderCatalogEntry[] | (() => readonly ProviderCatalogEntry[]);
profiles?: AgentProfile[] | (() => AgentProfile[]);
// Opt-in: dispatch each sub-agent into its own git worktree instead of
// sharing this session's cwd. See src/subagent/worktree.ts.
useWorktree?: boolean;
};
};

Expand Down Expand Up @@ -229,6 +232,9 @@ export async function createAgentToolset(args: AgentToolsetArgs): Promise<AgentT
...(args.subAgent.catalog !== undefined ? { catalog: args.subAgent.catalog } : {}),
...(args.subAgent.profiles !== undefined ? { profiles: args.subAgent.profiles } : {}),
...(args.getBlobReader !== undefined ? { getBlobReader: args.getBlobReader } : {}),
...(args.subAgent.useWorktree !== undefined
? { useWorktree: args.subAgent.useWorktree }
: {}),
}),
...(args.subAgent.profiles !== undefined
? [
Expand Down
32 changes: 22 additions & 10 deletions src/permission/gate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,21 +89,19 @@ describe("preGrantGuardReason / isRequestCoveredByGrant guard parity", () => {
});
});

// A sub-agent runs in its own git worktree, so its requests carry that
// worktree as cwd while the gate's restriction closure stays anchored to the
// session cwd that built it. The same relative path resolves differently
// against the two anchors, so coverage must use the gate's anchor: otherwise a
// path evaluate() called restricted reads as unrestricted at reconciliation
// time and a broad grant drains it without ever prompting.
describe("grant coverage anchors path restriction to the gate, not the request", () => {
// Relative path tokens rebind to the request's process cwd before the gate's
// restriction closure judges them, so a sub-agent worktree's relative targets
// match what the shell will open. Absolute paths still pass through the
// session-anchored restriction (workspace + registered worktree roots).
describe("grant coverage rebinds relative paths to the request process cwd", () => {
const root = mkdtempSync(join(tmpdir(), "gate-anchor-"));
const sessionCwd = join(root, "main");
const git = (args: string[], cwd: string) => execFileSync("git", args, { cwd, stdio: "ignore" });
mkdirSync(sessionCwd);
git(["init", "-q"], sessionCwd);
git(["config", "user.email", "t@example.com"], sessionCwd);
git(["config", "user.name", "t"], sessionCwd);
writeFileSync(join(sessionCwd, "outside-file"), "secret\n");
writeFileSync(join(sessionCwd, "seed.txt"), "seed\n");
git(["add", "."], sessionCwd);
git(["commit", "-qm", "seed"], sessionCwd);
const agentCwd = join(sessionCwd, "agent-x");
Expand All @@ -114,15 +112,29 @@ describe("grant coverage anchors path restriction to the gate, not the request",
createWorktreeRootsProvider(sessionCwd),
).isRestricted;

test("a sub-agent request reaching outside its worktree stays uncovered", () => {
test("a relative path that lands outside the workspace stays uncovered", () => {
// agent-x → ../../escape is outside root/main (and outside any worktree root).
const request: PermissionRequest = {
tool: "run_shell",
action: "Run",
subject: "cat ../outside-file",
subject: "cat ../../escape",
scopes: [],
cwd: agentCwd,
};
const grant: Approval = { tool: "run_shell", pattern: "cat *" };
expect(isRequestCoveredByGrant(request, grant, undefined, sessionRestricted)).toBe(false);
});

test("a relative path inside the registered worktree is not forced-restricted", () => {
writeFileSync(join(agentCwd, "local.txt"), "ok\n");
const request: PermissionRequest = {
tool: "run_shell",
action: "Run",
subject: "cat local.txt",
scopes: [],
cwd: agentCwd,
};
const grant: Approval = { tool: "run_shell", pattern: "cat *" };
expect(isRequestCoveredByGrant(request, grant, undefined, sessionRestricted)).toBe(true);
});
});
51 changes: 39 additions & 12 deletions src/permission/gate.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { ToolCall } from "@intx/types/runtime";
import { isAbsolute, resolve } from "node:path";
import type { Approval, ApprovalOutcome, GrantScope, PermissionRequest, RequestApproval } from "./types.js";
import {
classifyTool,
Expand Down Expand Up @@ -75,6 +76,23 @@ function segmentGuard(segment: string, isRestricted: (path: string, isWrite: boo
return undefined;
}

// Relative path tokens in a shell command resolve against the process cwd of the
// agent that issued the call — not the session cwd that built the gate. Absolute
// paths pass through unchanged so createPathRestriction still judges them against
// the session workspace + registered worktree roots. Without this rebinding, a
// sub-agent in an isolated worktree would have `cat secrets.txt` auto-allowed or
// restriction-checked as if it opened `$SESSION/secrets.txt` while the shell
// actually opened `$WORKTREE/secrets.txt`.
function bindRestrictedToProcessCwd(
isRestricted: (path: string, isWrite: boolean) => boolean,
processCwd: string,
): (path: string, isWrite: boolean) => boolean {
return (path, isWrite) => {
const anchored = isAbsolute(path) ? path : resolve(processCwd, path);
return isRestricted(anchored, isWrite);
};
}

// Every guard a run_shell request must clear BEFORE it is ever matched
// against a grant — hard-deny and forced-ask checks that no grant, however
// broad, is allowed to bypass. This is the single place that sequence is
Expand All @@ -94,8 +112,12 @@ export function preGrantGuardReason(
if (segments.length === 0) return "empty command";
const blockReason = runShellAuthzBlockReason(fullCommand);
if (blockReason !== undefined) return blockReason;
// Prefer the request's process cwd when present so reconciliation uses the
// same relative-path anchor evaluate() used when the prompt was raised.
const restricted =
request.cwd !== undefined ? bindRestrictedToProcessCwd(isRestricted, request.cwd) : isRestricted;
for (const segment of segments) {
const guard = segmentGuard(segment, isRestricted);
const guard = segmentGuard(segment, restricted);
if (guard !== undefined) {
return guard.kind === "secret"
? `${segment} references a sensitive path`
Expand Down Expand Up @@ -290,10 +312,17 @@ export function createPermissionGate(options: PermissionGateOptions): Permission

const evaluate = async (call: ToolCall): Promise<GateVerdict> => {
if (skipPermissions) return { allowed: true };
// Sub-agent tool calls run under ALS identity (identity-context.ts). The
// process cwd is the worktree (or session when no identity is set); every
// relative-path judgment below must use it so auto-allow and restriction
// match what the shell will open.
const subAgentIdentity = getSubAgentIdentity();
const effectiveCwd = subAgentIdentity?.cwd ?? resolvedCwd;
const isRestrictedHere = bindRestrictedToProcessCwd(isRestricted, effectiveCwd);
// A call targeting a restricted path (outside the workspace, or a write
// under .agent-state) drops from allow to ask, so it never auto-allows on
// tier or shell-safety below.
const restricted = callTargetsRestricted(call, isRestricted);
const restricted = callTargetsRestricted(call, isRestrictedHere);
const shellCmd =
call.name === "run_shell" && typeof call.arguments.command === "string"
? call.arguments.command
Expand All @@ -307,7 +336,7 @@ export function createPermissionGate(options: PermissionGateOptions): Permission
if (!restricted && classifyTool(call.name, mcpTiers) === "allow") {
return { allowed: true };
}
if (!restricted && !shellReferencesSecret && isAutoAllowedShellCall(call, cwd)) {
if (!restricted && !shellReferencesSecret && isAutoAllowedShellCall(call, effectiveCwd)) {
return { allowed: true };
}
if (auto) {
Expand All @@ -317,7 +346,7 @@ export function createPermissionGate(options: PermissionGateOptions): Permission
// operator prompt. Everything else auto-allows. Path-keyed secret
// reads stay hard-denied by secret-guard; shell that only *mentions*
// a secret path is ask so an explicit one-time approval can pass it.
const shellRule = autoShellRuleForCall(call, isRestricted);
const shellRule = autoShellRuleForCall(call, isRestrictedHere);
if (shellRule?.effect === "deny") return { allowed: false, reason: shellRule.reason };
if (shellRule === undefined) return { allowed: true };
} else if (!restricted && AUTO_ALLOWED_TOOLS.has(call.name)) {
Expand All @@ -327,11 +356,8 @@ export function createPermissionGate(options: PermissionGateOptions): Permission
// blanket-allowed; fall through to the operator prompt below.
}

// A sub-agent's own tool calls run under its identity in ALS (see
// identity-context.ts, wired from subagent/run.ts). When present, the
// prompt is attributed to that sub-agent instead of the top-level session.
const subAgentIdentity = getSubAgentIdentity();
const effectiveCwd = subAgentIdentity?.cwd ?? resolvedCwd;
// When present, the prompt is attributed to that sub-agent instead of the
// top-level session.
for (const rawRequest of buildRequests(call)) {
const request: typeof rawRequest = {
...rawRequest,
Expand Down Expand Up @@ -373,7 +399,7 @@ export function createPermissionGate(options: PermissionGateOptions): Permission
// restriction check below for the same rule applied within a chain.
if (
!fullReferencesSecret &&
!commandTargetsRestricted(fullCommand, isRestricted) &&
!commandTargetsRestricted(fullCommand, isRestrictedHere) &&
segments.length > 1 &&
hasExactFullCommandGrant(request.tool, fullCommand, approvals, activeProviderModel, effectiveCwd)
) {
Expand All @@ -389,7 +415,7 @@ export function createPermissionGate(options: PermissionGateOptions): Permission
// replay for a guarded one just because the pattern also matches it.
// segmentGuard is the same guard preGrantGuardReason applies before
// isRequestCoveredByGrant lets a queued request skip the prompt.
const guard = segmentGuard(segment, isRestricted);
const guard = segmentGuard(segment, isRestrictedHere);
if (guard !== undefined) {
if (guard.kind === "secret") anySecret = true;
needsOperator = true;
Expand All @@ -399,7 +425,8 @@ export function createPermissionGate(options: PermissionGateOptions): Permission
continue;
}
// Safe pipeline tails (`| sort`) and pure no-ops (`|| true`) skip.
if (isAutoAllowedShellSegment(segment, cwd)) {
// Containment is judged against the process cwd, not the session cwd.
if (isAutoAllowedShellSegment(segment, effectiveCwd)) {
continue;
}
needsOperator = true;
Expand Down
66 changes: 66 additions & 0 deletions src/permission/permission.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2838,3 +2838,69 @@ describe("sub-agent identity on permission requests", () => {
expect(withB?.cwd).toBe("/repo-b");
});
});

describe("sub-agent auto-allow uses the process cwd, not the session cwd", () => {
test("a relative read inside the worktree auto-allows under the process cwd", async () => {
// Nested worktree under the session so absolute paths stay inside the
// workspace roots. Auto-allow must still judge containment against the
// worktree (process cwd), not the session — this case is the happy path
// where the relative target lands inside the worktree either way.
const root = mkdtempSync(join(tmpdir(), "gate-eff-cwd-"));
const sessionCwd = join(root, "session");
const agentCwd = join(sessionCwd, "agent-x");
mkdirSync(sessionCwd);
mkdirSync(agentCwd);
writeFileSync(join(agentCwd, "local.txt"), "worktree-local\n");

let prompted = false;
const gate = createPermissionGate({
approvals: [],
requestApproval: async () => {
prompted = true;
return { allow: true };
},
interactive: true,
skipPermissions: false,
cwd: sessionCwd,
});
const { runWithSubAgentIdentity } = await import("../subagent/identity-context.js");
const verdict = await runWithSubAgentIdentity(
{ description: "Worktree worker", cwd: agentCwd },
() => gate.evaluate(shellCall("cat local.txt")),
);
expect(verdict).toEqual({ allowed: true });
expect(prompted).toBe(false);
});

test("a relative read that escapes the worktree but not the session is not auto-allowed", async () => {
// Worktree nested under the session: `cat ../session-only.txt` resolves
// inside the session when judged against session cwd (bug → auto-allow)
// but escapes the worktree when judged against the process cwd (correct →
// ask).
const root = mkdtempSync(join(tmpdir(), "gate-escape-wt-"));
const sessionCwd = join(root, "session");
mkdirSync(sessionCwd);
writeFileSync(join(sessionCwd, "session-only.txt"), "only-in-session\n");
const agentCwd = join(sessionCwd, "agent-x");
mkdirSync(agentCwd);

let prompted = false;
const gate = createPermissionGate({
approvals: [],
requestApproval: async () => {
prompted = true;
return { allow: true };
},
interactive: true,
skipPermissions: false,
cwd: sessionCwd,
});
const { runWithSubAgentIdentity } = await import("../subagent/identity-context.js");
const verdict = await runWithSubAgentIdentity(
{ description: "Nested worktree", cwd: agentCwd },
() => gate.evaluate(shellCall("cat ../session-only.txt")),
);
expect(verdict).toEqual({ allowed: true });
expect(prompted).toBe(true);
});
});
9 changes: 9 additions & 0 deletions src/subagent/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,3 +112,12 @@ export {
taskToolDefinition,
type TaskToolDeps,
} from "./task-tool.js";

export {
cleanupSubAgentWorktree,
createSubAgentWorktree,
WorktreeError,
type SubAgentWorktree,
type WorktreeCleanupResult,
type WorktreeExec,
} from "./worktree.js";
12 changes: 7 additions & 5 deletions src/subagent/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -205,11 +205,12 @@ export function createSubAgentRunController(
};
}

// Spin up an isolated, autonomous agent loop against the same working tree,
// hand it one task, and return its final report. The sub-agent shares the
// dispatcher's cwd so its edits land in the real repo, but gets its own posix
// tool instances and its own git-backed context store so the two loops never
// trample each other's state.
// Spin up an isolated, autonomous agent loop, hand it one task, and return
// its final report. `params.cwd` is either the dispatcher's own cwd (shared
// mode) or a worktree snapshotted from the dispatcher's last commit
// (isolated mode, see task-tool.ts's useWorktree) — either way this loop
// gets its own posix tool instances and its own git-backed context store so
// the two loops never trample each other's state.
export async function runSubAgent(params: RunSubAgentParams): Promise<string> {
return withSubAgentSlot(() => runSubAgentInner(params), {
reentrant: params.nested === true,
Expand Down Expand Up @@ -338,6 +339,7 @@ async function runSubAgentInner(params: RunSubAgentParams): Promise<string> {
...(nd.catalog !== undefined ? { catalog: nd.catalog } : {}),
...(nd.profiles !== undefined ? { profiles: nd.profiles } : {}),
...(nd.parentSessionId !== undefined ? { parentSessionId: nd.parentSessionId } : {}),
...(nd.useWorktree !== undefined ? { useWorktree: nd.useWorktree } : {}),
}),
...(nd.profiles !== undefined
? [
Expand Down
Loading
Loading