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
15 changes: 15 additions & 0 deletions .changeset/hardening-execution-trigger-writes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
'@moonshot-ai/agent-core-v2': minor
---

Ask before writing a file that a later command will execute. Writes inside the
workspace are approved without prompting, which is right for source files — the
change is visible in the diff before anything runs it. It is not right for
`package.json`, `Makefile`, `.github/workflows/*`, `conftest.py`,
`.pre-commit-config.yaml` and friends: nothing happens when they are written,
and then the next install, test run or CI job executes what they now say. The
write is the dangerous act, so the prompt belongs there.

Reads are unaffected, ordinary source writes keep the fast path, and session
approvals and user `allow` rules still take precedence, so a decision already
made is not re-asked.
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { AutoModeAskUserQuestionDenyPermissionPolicyService } from '#/agent/perm
import { DefaultToolApprovePermissionPolicyService } from '#/agent/permissionPolicy/policies/default-tool-approve';
import { FallbackAskPermissionPolicyService } from '#/agent/permissionPolicy/policies/fallback-ask';
import { GitControlPathAccessAskPermissionPolicyService } from '#/agent/permissionPolicy/policies/git-control-path-access-ask';
import { ExecutionTriggerWriteAskPermissionPolicyService } from '#/agent/permissionPolicy/policies/execution-trigger-write-ask';
import { GitCwdWriteApprovePermissionPolicyService } from '#/agent/permissionPolicy/policies/git-cwd-write-approve';
import { SensitiveFileAccessAskPermissionPolicyService } from '#/agent/permissionPolicy/policies/sensitive-file-access-ask';
import { SessionApprovalHistoryPermissionPolicyService } from '#/agent/permissionPolicy/policies/session-approval-history';
Expand Down Expand Up @@ -44,8 +45,9 @@ export class AgentPermissionPolicyService
super();
// Order matters: the first policy to return a result wins.
//
// `AutoModeApprove` sits after the two content-sensitive asks (secrets on
// disk, and the .git control directory) rather than ahead of them, so
// `AutoModeApprove` sits after the content-sensitive asks (secrets on
// disk, the .git control directory, and files a later command executes)
// rather than ahead of them, so
// enabling auto mode speeds up ordinary work without also silently
// waiving the checks that exist for the highest-consequence paths.
// Everything else keeps its previous relative order: an explicit prior
Expand All @@ -59,6 +61,7 @@ export class AgentPermissionPolicyService
this.instantiation.createInstance(UserConfiguredAllowPermissionPolicyService),
this.instantiation.createInstance(SensitiveFileAccessAskPermissionPolicyService),
this.instantiation.createInstance(GitControlPathAccessAskPermissionPolicyService),
this.instantiation.createInstance(ExecutionTriggerWriteAskPermissionPolicyService),
this.instantiation.createInstance(AutoModeApprovePermissionPolicyService),
this.instantiation.createInstance(YoloModeApprovePermissionPolicyService),
this.instantiation.createInstance(DefaultToolApprovePermissionPolicyService),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import * as pathe from 'pathe';

import type { ResolvedToolExecutionHookContext } from '#/agent/toolExecutor/toolHooks';
import { IHostEnvironment } from '#/os/interface/hostEnvironment';
import type { IHostEnvironment as HostEnvironment } from '#/os/interface/hostEnvironment';
import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext';
import type { ISessionWorkspaceContext as WorkspaceContext } from '#/session/workspaceContext/workspaceContext';
import type {
PermissionPolicy,
PermissionPolicyResult,
} from '#/agent/permissionPolicy/types';

import { writeFileAccesses } from './path-utils';

/**
* Files whose contents a routine follow-up command executes.
*
* Writes inside the workspace are otherwise approved without asking, which is
* the right default for source files: editing them is the job, and the change
* is visible in the diff before anything runs it. These are different. Nothing
* happens when they are written, and then the next `npm install`, test run, or
* CI job executes what they now say — so the write is the dangerous act and
* the prompt has to happen there, not at the point it finally runs.
*/
const EXECUTION_TRIGGER_BASENAMES = new Set<string>([
'package.json',
'makefile',
'gnumakefile',
'justfile',
'taskfile.yml',
'taskfile.yaml',
'jenkinsfile',
'setup.py',
'conftest.py',
'.pre-commit-config.yaml',
'.gitlab-ci.yml',
'azure-pipelines.yml',
]);

/**
* Directories where every file is executed by CI or a git operation.
* Compared against workspace-relative POSIX paths.
*/
const EXECUTION_TRIGGER_DIR_PREFIXES = [
'.github/workflows/',
'.github/actions/',
'.circleci/',
'.husky/',
];

export function isExecutionTriggerPath(relativePath: string): boolean {
const normalized = relativePath.replaceAll('\\', '/').toLowerCase();
if (normalized.startsWith('../')) return false;
if (EXECUTION_TRIGGER_BASENAMES.has(pathe.basename(normalized))) return true;
return EXECUTION_TRIGGER_DIR_PREFIXES.some((prefix) => normalized.startsWith(prefix));
}

/**
* Ask before writing a file that a later command will execute.
*
* Sits ahead of the blanket in-workspace write approval, and ahead of auto
* mode, for the same reason the sensitive-file check does: these are the
* writes where "it was inside the repo" is not a good enough reason to skip
* the prompt. Session history and user `allow` rules still take precedence,
* so an operator who has decided this is fine is not asked twice.
*/
export class ExecutionTriggerWriteAskPermissionPolicyService implements PermissionPolicy {
readonly name = 'execution-trigger-write-ask';

constructor(
@IHostEnvironment private readonly env: HostEnvironment,
@ISessionWorkspaceContext private readonly workspace: WorkspaceContext,
) {}

evaluate(context: ResolvedToolExecutionHookContext): PermissionPolicyResult | undefined {
const cwd = this.workspace.workDir;
if (cwd.length === 0) return undefined;
const writes = writeFileAccesses(context);
if (writes.length === 0) return undefined;

const pathClass = this.env.pathClass;
const base = pathClass === 'win32' ? cwd.toLowerCase() : cwd;
const triggers = writes.some((access) => {
const target = pathClass === 'win32' ? access.path.toLowerCase() : access.path;
return isExecutionTriggerPath(pathe.relative(base, target));
});
return triggers ? { kind: 'ask' } : undefined;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -354,6 +354,62 @@ describe('AgentPermissionPolicyService git cwd write approval', () => {
vi.unstubAllEnvs();
} });

it('asks before writing a file a later command executes', async () => {
// These are approved-by-default in-workspace writes today; nothing runs at
// write time, and then the next install/test/CI run executes them.
for (const rel of [
'package.json',
'Makefile',
'.github/workflows/ci.yml',
'conftest.py',
'.pre-commit-config.yaml',
]) {
await expect(evaluate({
toolName: 'Write',
args: { path: rel, content: 'x' },
accesses: ToolAccesses.writeFile(join(workspaceDir, rel)),
}), rel).resolves.toMatchObject({
policyName: 'execution-trigger-write-ask',
result: { kind: 'ask' },
});
}
});

it('still asks for those files in auto mode', async () => {
mode = 'auto';
await expect(evaluate({
toolName: 'Write',
args: { path: 'package.json', content: 'x' },
accesses: ToolAccesses.writeFile(join(workspaceDir, 'package.json')),
})).resolves.toMatchObject({
policyName: 'execution-trigger-write-ask',
result: { kind: 'ask' },
});
});

it('leaves ordinary source writes on the fast path', async () => {
for (const rel of ['src/a.ts', 'README.md', 'src/package.json.ts', 'docs/Makefile.md']) {
await expect(evaluate({
toolName: 'Write',
args: { path: rel, content: 'x' },
accesses: ToolAccesses.writeFile(join(workspaceDir, rel)),
}), rel).resolves.toMatchObject({
policyName: 'git-cwd-write-approve',
result: { kind: 'approve' },
});
}
});

it('does not fire on a read of an execution-triggering file', async () => {
// Reading package.json is routine; only writing it is the risk.
const result = await evaluate({
toolName: 'Read',
args: { path: 'package.json' },
accesses: ToolAccesses.readFile(join(workspaceDir, 'package.json')),
});
expect(result?.policyName).not.toBe('execution-trigger-write-ask');
});

it('does not use git-cwd approval in auto mode', async () => {
mode = 'auto';
await expect(evaluate({
Expand Down
Loading