From a8ff4ba6c8c345fae079c1d1ff6f852d937ea984 Mon Sep 17 00:00:00 2001 From: Greg Anderson Date: Wed, 12 Aug 2026 21:55:13 -0600 Subject: [PATCH] chore(hardening): ask before writing files a later command executes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../hardening-execution-trigger-writes.md | 15 ++++ .../permissionPolicyService.ts | 7 +- .../policies/execution-trigger-write-ask.ts | 89 +++++++++++++++++++ .../permissionPolicyService.test.ts | 56 ++++++++++++ 4 files changed, 165 insertions(+), 2 deletions(-) create mode 100644 .changeset/hardening-execution-trigger-writes.md create mode 100644 packages/agent-core-v2/src/agent/permissionPolicy/policies/execution-trigger-write-ask.ts diff --git a/.changeset/hardening-execution-trigger-writes.md b/.changeset/hardening-execution-trigger-writes.md new file mode 100644 index 00000000..628114e5 --- /dev/null +++ b/.changeset/hardening-execution-trigger-writes.md @@ -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. diff --git a/packages/agent-core-v2/src/agent/permissionPolicy/permissionPolicyService.ts b/packages/agent-core-v2/src/agent/permissionPolicy/permissionPolicyService.ts index 9f53b74b..ce120915 100644 --- a/packages/agent-core-v2/src/agent/permissionPolicy/permissionPolicyService.ts +++ b/packages/agent-core-v2/src/agent/permissionPolicy/permissionPolicyService.ts @@ -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'; @@ -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 @@ -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), diff --git a/packages/agent-core-v2/src/agent/permissionPolicy/policies/execution-trigger-write-ask.ts b/packages/agent-core-v2/src/agent/permissionPolicy/policies/execution-trigger-write-ask.ts new file mode 100644 index 00000000..8f2c852e --- /dev/null +++ b/packages/agent-core-v2/src/agent/permissionPolicy/policies/execution-trigger-write-ask.ts @@ -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([ + '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; + } +} diff --git a/packages/agent-core-v2/test/agent/permissionPolicy/permissionPolicyService.test.ts b/packages/agent-core-v2/test/agent/permissionPolicy/permissionPolicyService.test.ts index 48e2efdd..bcf1df0a 100644 --- a/packages/agent-core-v2/test/agent/permissionPolicy/permissionPolicyService.test.ts +++ b/packages/agent-core-v2/test/agent/permissionPolicy/permissionPolicyService.test.ts @@ -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({