Skip to content

Commit a8ff4ba

Browse files
Greg Andersonclaude
authored andcommitted
chore(hardening): ask before writing files a later command executes
In-workspace writes are approved without a prompt, which is the right default for source files: editing them is the job, and the change shows up in the diff before anything runs it. A handful of files do not fit that shape. Nothing happens when package.json, a Makefile, a workflow under .github, conftest.py or a pre-commit config is written, and then the next install, test run or CI job executes whatever they now say — so the write is the dangerous act and that is where the prompt has to be. Add execution-trigger-write-ask ahead of the blanket in-workspace write approval and ahead of auto mode, matching where the sensitive-file and git-control checks sit. Session approvals and user allow rules keep their precedence, so a decision already made is not re-asked. Only write access counts: reading package.json stays routine. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent b8a0c47 commit a8ff4ba

4 files changed

Lines changed: 165 additions & 2 deletions

File tree

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
'@moonshot-ai/agent-core-v2': minor
3+
---
4+
5+
Ask before writing a file that a later command will execute. Writes inside the
6+
workspace are approved without prompting, which is right for source files — the
7+
change is visible in the diff before anything runs it. It is not right for
8+
`package.json`, `Makefile`, `.github/workflows/*`, `conftest.py`,
9+
`.pre-commit-config.yaml` and friends: nothing happens when they are written,
10+
and then the next install, test run or CI job executes what they now say. The
11+
write is the dangerous act, so the prompt belongs there.
12+
13+
Reads are unaffected, ordinary source writes keep the fast path, and session
14+
approvals and user `allow` rules still take precedence, so a decision already
15+
made is not re-asked.

‎packages/agent-core-v2/src/agent/permissionPolicy/permissionPolicyService.ts‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { AutoModeAskUserQuestionDenyPermissionPolicyService } from '#/agent/perm
1515
import { DefaultToolApprovePermissionPolicyService } from '#/agent/permissionPolicy/policies/default-tool-approve';
1616
import { FallbackAskPermissionPolicyService } from '#/agent/permissionPolicy/policies/fallback-ask';
1717
import { GitControlPathAccessAskPermissionPolicyService } from '#/agent/permissionPolicy/policies/git-control-path-access-ask';
18+
import { ExecutionTriggerWriteAskPermissionPolicyService } from '#/agent/permissionPolicy/policies/execution-trigger-write-ask';
1819
import { GitCwdWriteApprovePermissionPolicyService } from '#/agent/permissionPolicy/policies/git-cwd-write-approve';
1920
import { SensitiveFileAccessAskPermissionPolicyService } from '#/agent/permissionPolicy/policies/sensitive-file-access-ask';
2021
import { SessionApprovalHistoryPermissionPolicyService } from '#/agent/permissionPolicy/policies/session-approval-history';
@@ -44,8 +45,9 @@ export class AgentPermissionPolicyService
4445
super();
4546
// Order matters: the first policy to return a result wins.
4647
//
47-
// `AutoModeApprove` sits after the two content-sensitive asks (secrets on
48-
// disk, and the .git control directory) rather than ahead of them, so
48+
// `AutoModeApprove` sits after the content-sensitive asks (secrets on
49+
// disk, the .git control directory, and files a later command executes)
50+
// rather than ahead of them, so
4951
// enabling auto mode speeds up ordinary work without also silently
5052
// waiving the checks that exist for the highest-consequence paths.
5153
// Everything else keeps its previous relative order: an explicit prior
@@ -59,6 +61,7 @@ export class AgentPermissionPolicyService
5961
this.instantiation.createInstance(UserConfiguredAllowPermissionPolicyService),
6062
this.instantiation.createInstance(SensitiveFileAccessAskPermissionPolicyService),
6163
this.instantiation.createInstance(GitControlPathAccessAskPermissionPolicyService),
64+
this.instantiation.createInstance(ExecutionTriggerWriteAskPermissionPolicyService),
6265
this.instantiation.createInstance(AutoModeApprovePermissionPolicyService),
6366
this.instantiation.createInstance(YoloModeApprovePermissionPolicyService),
6467
this.instantiation.createInstance(DefaultToolApprovePermissionPolicyService),
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import * as pathe from 'pathe';
2+
3+
import type { ResolvedToolExecutionHookContext } from '#/agent/toolExecutor/toolHooks';
4+
import { IHostEnvironment } from '#/os/interface/hostEnvironment';
5+
import type { IHostEnvironment as HostEnvironment } from '#/os/interface/hostEnvironment';
6+
import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext';
7+
import type { ISessionWorkspaceContext as WorkspaceContext } from '#/session/workspaceContext/workspaceContext';
8+
import type {
9+
PermissionPolicy,
10+
PermissionPolicyResult,
11+
} from '#/agent/permissionPolicy/types';
12+
13+
import { writeFileAccesses } from './path-utils';
14+
15+
/**
16+
* Files whose contents a routine follow-up command executes.
17+
*
18+
* Writes inside the workspace are otherwise approved without asking, which is
19+
* the right default for source files: editing them is the job, and the change
20+
* is visible in the diff before anything runs it. These are different. Nothing
21+
* happens when they are written, and then the next `npm install`, test run, or
22+
* CI job executes what they now say — so the write is the dangerous act and
23+
* the prompt has to happen there, not at the point it finally runs.
24+
*/
25+
const EXECUTION_TRIGGER_BASENAMES = new Set<string>([
26+
'package.json',
27+
'makefile',
28+
'gnumakefile',
29+
'justfile',
30+
'taskfile.yml',
31+
'taskfile.yaml',
32+
'jenkinsfile',
33+
'setup.py',
34+
'conftest.py',
35+
'.pre-commit-config.yaml',
36+
'.gitlab-ci.yml',
37+
'azure-pipelines.yml',
38+
]);
39+
40+
/**
41+
* Directories where every file is executed by CI or a git operation.
42+
* Compared against workspace-relative POSIX paths.
43+
*/
44+
const EXECUTION_TRIGGER_DIR_PREFIXES = [
45+
'.github/workflows/',
46+
'.github/actions/',
47+
'.circleci/',
48+
'.husky/',
49+
];
50+
51+
export function isExecutionTriggerPath(relativePath: string): boolean {
52+
const normalized = relativePath.replaceAll('\\', '/').toLowerCase();
53+
if (normalized.startsWith('../')) return false;
54+
if (EXECUTION_TRIGGER_BASENAMES.has(pathe.basename(normalized))) return true;
55+
return EXECUTION_TRIGGER_DIR_PREFIXES.some((prefix) => normalized.startsWith(prefix));
56+
}
57+
58+
/**
59+
* Ask before writing a file that a later command will execute.
60+
*
61+
* Sits ahead of the blanket in-workspace write approval, and ahead of auto
62+
* mode, for the same reason the sensitive-file check does: these are the
63+
* writes where "it was inside the repo" is not a good enough reason to skip
64+
* the prompt. Session history and user `allow` rules still take precedence,
65+
* so an operator who has decided this is fine is not asked twice.
66+
*/
67+
export class ExecutionTriggerWriteAskPermissionPolicyService implements PermissionPolicy {
68+
readonly name = 'execution-trigger-write-ask';
69+
70+
constructor(
71+
@IHostEnvironment private readonly env: HostEnvironment,
72+
@ISessionWorkspaceContext private readonly workspace: WorkspaceContext,
73+
) {}
74+
75+
evaluate(context: ResolvedToolExecutionHookContext): PermissionPolicyResult | undefined {
76+
const cwd = this.workspace.workDir;
77+
if (cwd.length === 0) return undefined;
78+
const writes = writeFileAccesses(context);
79+
if (writes.length === 0) return undefined;
80+
81+
const pathClass = this.env.pathClass;
82+
const base = pathClass === 'win32' ? cwd.toLowerCase() : cwd;
83+
const triggers = writes.some((access) => {
84+
const target = pathClass === 'win32' ? access.path.toLowerCase() : access.path;
85+
return isExecutionTriggerPath(pathe.relative(base, target));
86+
});
87+
return triggers ? { kind: 'ask' } : undefined;
88+
}
89+
}

‎packages/agent-core-v2/test/agent/permissionPolicy/permissionPolicyService.test.ts‎

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -354,6 +354,62 @@ describe('AgentPermissionPolicyService git cwd write approval', () => {
354354
vi.unstubAllEnvs();
355355
} });
356356

357+
it('asks before writing a file a later command executes', async () => {
358+
// These are approved-by-default in-workspace writes today; nothing runs at
359+
// write time, and then the next install/test/CI run executes them.
360+
for (const rel of [
361+
'package.json',
362+
'Makefile',
363+
'.github/workflows/ci.yml',
364+
'conftest.py',
365+
'.pre-commit-config.yaml',
366+
]) {
367+
await expect(evaluate({
368+
toolName: 'Write',
369+
args: { path: rel, content: 'x' },
370+
accesses: ToolAccesses.writeFile(join(workspaceDir, rel)),
371+
}), rel).resolves.toMatchObject({
372+
policyName: 'execution-trigger-write-ask',
373+
result: { kind: 'ask' },
374+
});
375+
}
376+
});
377+
378+
it('still asks for those files in auto mode', async () => {
379+
mode = 'auto';
380+
await expect(evaluate({
381+
toolName: 'Write',
382+
args: { path: 'package.json', content: 'x' },
383+
accesses: ToolAccesses.writeFile(join(workspaceDir, 'package.json')),
384+
})).resolves.toMatchObject({
385+
policyName: 'execution-trigger-write-ask',
386+
result: { kind: 'ask' },
387+
});
388+
});
389+
390+
it('leaves ordinary source writes on the fast path', async () => {
391+
for (const rel of ['src/a.ts', 'README.md', 'src/package.json.ts', 'docs/Makefile.md']) {
392+
await expect(evaluate({
393+
toolName: 'Write',
394+
args: { path: rel, content: 'x' },
395+
accesses: ToolAccesses.writeFile(join(workspaceDir, rel)),
396+
}), rel).resolves.toMatchObject({
397+
policyName: 'git-cwd-write-approve',
398+
result: { kind: 'approve' },
399+
});
400+
}
401+
});
402+
403+
it('does not fire on a read of an execution-triggering file', async () => {
404+
// Reading package.json is routine; only writing it is the risk.
405+
const result = await evaluate({
406+
toolName: 'Read',
407+
args: { path: 'package.json' },
408+
accesses: ToolAccesses.readFile(join(workspaceDir, 'package.json')),
409+
});
410+
expect(result?.policyName).not.toBe('execution-trigger-write-ask');
411+
});
412+
357413
it('does not use git-cwd approval in auto mode', async () => {
358414
mode = 'auto';
359415
await expect(evaluate({

0 commit comments

Comments
 (0)