praxis-agent 0.55.3 → 0.56.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -2
- package/dist/application/session-service.js +53 -14
- package/dist/application/subagent-service.js +1 -0
- package/dist/cli/interactive.js +2 -2
- package/dist/cli/process-signal.d.ts +3 -0
- package/dist/cli/process-signal.js +5 -0
- package/dist/cli/protocol.d.ts +1 -0
- package/dist/cli/protocol.js +21 -2
- package/dist/cli-runtime.js +11 -1
- package/dist/cli.js +4 -2
- package/dist/evals/eval-contract.js +9 -1
- package/dist/mcp/praxis-mcp-server.js +13 -1
- package/dist/permissions/claude-permission-resolver.js +147 -4
- package/dist/platform/atomic-write.js +2 -0
- package/dist/tools/apply-patch.d.ts +25 -0
- package/dist/tools/apply-patch.js +128 -0
- package/dist/tools/claude-interactive-tools.js +8 -0
- package/dist/tools/local-tools.d.ts +1 -0
- package/dist/tools/local-tools.js +102 -1
- package/dist/tools/team-member-tools.js +5 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -172,8 +172,9 @@ troubleshooting. Run `praxis --help` for the authoritative command surface.
|
|
|
172
172
|
deterministic resize-aware URL/form elicitation rendering, and measured
|
|
173
173
|
context budgets; print mode,
|
|
174
174
|
structured JSON/JSONL, context compaction, tool loops, and bounded execution.
|
|
175
|
-
- **Built-in tools** — read, write, edit,
|
|
176
|
-
|
|
175
|
+
- **Built-in tools** — read, write, edit, `ApplyPatch` for bounded ordered exact
|
|
176
|
+
multi-file replacements, glob, search, shell, notebook, PDF, image, web,
|
|
177
|
+
scheduled prompts, workflows, and worktrees.
|
|
177
178
|
- **Shell lifecycle** — foreground Bash allows up to 10 minutes and carries a
|
|
178
179
|
validated final working directory across calls in the same session without
|
|
179
180
|
leaking state across sessions or overriding an explicit `/cd`.
|
|
@@ -51,6 +51,7 @@ import { completeMeteredModelRequest } from './metered-model-completion.js';
|
|
|
51
51
|
import { SessionMemoryController, SessionMemoryStateError, SessionMemoryStore, } from './session-memory.js';
|
|
52
52
|
import { FilteredToolRegistry } from '../tools/filtered-tool-registry.js';
|
|
53
53
|
import { DeferredToolCatalog } from '../tools/deferred-tool-catalog.js';
|
|
54
|
+
import { parseApplyPatchInput } from '../tools/apply-patch.js';
|
|
54
55
|
import { ClaudeCapabilityToolRegistry, resolveClaudeToolCapabilities, } from '../tools/claude-capabilities.js';
|
|
55
56
|
import { generateToolUseSummary } from './tool-use-summary.js';
|
|
56
57
|
import { ClaudeUserMessageToolRegistry, } from '../tools/claude-user-message.js';
|
|
@@ -88,6 +89,21 @@ function mainAgentToolNames(tools, agent) {
|
|
|
88
89
|
.map(({ name }) => name)
|
|
89
90
|
.filter((name) => (!requested || requested.has(name)) && !disallowed.has(name));
|
|
90
91
|
}
|
|
92
|
+
function mutationPaths(call) {
|
|
93
|
+
if (call.name === 'ApplyPatch')
|
|
94
|
+
return parseApplyPatchInput(call.input).map((edit) => edit.file_path);
|
|
95
|
+
if (call.name === 'Write' || call.name === 'Edit') {
|
|
96
|
+
return typeof call.input.file_path === 'string'
|
|
97
|
+
? [call.input.file_path]
|
|
98
|
+
: [];
|
|
99
|
+
}
|
|
100
|
+
if (call.name === 'NotebookEdit') {
|
|
101
|
+
return typeof call.input.notebook_path === 'string'
|
|
102
|
+
? [call.input.notebook_path]
|
|
103
|
+
: [];
|
|
104
|
+
}
|
|
105
|
+
return [];
|
|
106
|
+
}
|
|
91
107
|
function isSessionCandidateError(error) {
|
|
92
108
|
return (error instanceof NativeTranscriptIndexCandidateError ||
|
|
93
109
|
['ENOENT', 'ENOTDIR', 'ELOOP'].includes(error.code ?? ''));
|
|
@@ -1168,6 +1184,7 @@ export class ClaudeSessionService {
|
|
|
1168
1184
|
'Read',
|
|
1169
1185
|
'LSP',
|
|
1170
1186
|
'Edit',
|
|
1187
|
+
'ApplyPatch',
|
|
1171
1188
|
'Write',
|
|
1172
1189
|
'NotebookEdit',
|
|
1173
1190
|
'WebFetch',
|
|
@@ -2869,16 +2886,18 @@ export class ClaudeSessionService {
|
|
|
2869
2886
|
}),
|
|
2870
2887
|
prepare: (call, context) => interactiveMessageTools.prepare(call, context),
|
|
2871
2888
|
execute: async (call, context) => {
|
|
2872
|
-
const
|
|
2873
|
-
|
|
2874
|
-
: call.name === 'NotebookEdit'
|
|
2875
|
-
? call.input.notebook_path
|
|
2876
|
-
: undefined;
|
|
2877
|
-
if (typeof path !== 'string') {
|
|
2889
|
+
const paths = mutationPaths(call);
|
|
2890
|
+
if (paths.length === 0) {
|
|
2878
2891
|
return interactiveMessageTools.execute(call, context);
|
|
2879
2892
|
}
|
|
2880
|
-
|
|
2881
|
-
|
|
2893
|
+
const historyPaths = call.name === 'Write' ||
|
|
2894
|
+
call.name === 'Edit' ||
|
|
2895
|
+
call.name === 'ApplyPatch'
|
|
2896
|
+
? (await Promise.all(paths.map(async (path) => (await this.options.interactiveTools?.isPlanFile(sessionId, path))
|
|
2897
|
+
? null
|
|
2898
|
+
: path))).filter((path) => path !== null)
|
|
2899
|
+
: paths;
|
|
2900
|
+
if (historyPaths.length === 0) {
|
|
2882
2901
|
return interactiveMessageTools.execute(call, context);
|
|
2883
2902
|
}
|
|
2884
2903
|
const snapshotMessageId = currentPromptId ??
|
|
@@ -2888,21 +2907,31 @@ export class ClaudeSessionService {
|
|
|
2888
2907
|
if (!snapshotMessageId || !assistantMessageId) {
|
|
2889
2908
|
throw new Error('Claude file history could not link tool call');
|
|
2890
2909
|
}
|
|
2891
|
-
const
|
|
2910
|
+
const preparedMutations = [];
|
|
2911
|
+
try {
|
|
2912
|
+
for (const path of [...new Set(historyPaths)])
|
|
2913
|
+
preparedMutations.push(await fileHistory.prepareMutation(projectionSnapshot().entries, snapshotMessageId, path));
|
|
2914
|
+
}
|
|
2915
|
+
catch (error) {
|
|
2916
|
+
await Promise.all(preparedMutations.map((mutation) => mutation.rollback()));
|
|
2917
|
+
throw error;
|
|
2918
|
+
}
|
|
2892
2919
|
let result;
|
|
2893
2920
|
try {
|
|
2894
2921
|
result = await interactiveMessageTools.execute(call, context);
|
|
2895
2922
|
}
|
|
2896
2923
|
catch (error) {
|
|
2897
|
-
await
|
|
2924
|
+
await Promise.all(preparedMutations.map((mutation) => mutation.rollback()));
|
|
2898
2925
|
throw error;
|
|
2899
2926
|
}
|
|
2900
2927
|
if (result.isError) {
|
|
2901
|
-
await
|
|
2928
|
+
await Promise.all(preparedMutations.map((mutation) => mutation.rollback()));
|
|
2902
2929
|
return result;
|
|
2903
2930
|
}
|
|
2904
|
-
const
|
|
2905
|
-
|
|
2931
|
+
const entries = preparedMutations
|
|
2932
|
+
.map((mutation) => mutation.commit(assistantMessageId))
|
|
2933
|
+
.filter((entry) => entry !== null);
|
|
2934
|
+
for (const entry of entries) {
|
|
2906
2935
|
await persistence.commit({
|
|
2907
2936
|
kind: 'messages',
|
|
2908
2937
|
input: {
|
|
@@ -3105,7 +3134,9 @@ export class ClaudeSessionService {
|
|
|
3105
3134
|
if (call.name === 'Read') {
|
|
3106
3135
|
turnMemory.recordRead(path);
|
|
3107
3136
|
}
|
|
3108
|
-
else if (call.name === 'Write' ||
|
|
3137
|
+
else if (call.name === 'Write' ||
|
|
3138
|
+
call.name === 'Edit' ||
|
|
3139
|
+
call.name === 'ApplyPatch') {
|
|
3109
3140
|
projectMemoryMaintained = true;
|
|
3110
3141
|
}
|
|
3111
3142
|
}
|
|
@@ -3117,6 +3148,14 @@ export class ClaudeSessionService {
|
|
|
3117
3148
|
}
|
|
3118
3149
|
}
|
|
3119
3150
|
}
|
|
3151
|
+
else if (call.name === 'ApplyPatch') {
|
|
3152
|
+
for (const accessedPath of toolResult.accessedPaths ?? []) {
|
|
3153
|
+
const resolvedAccessedPath = resolve(this.activeCwd(), accessedPath);
|
|
3154
|
+
if (isPathWithin(this.options.projectMemoryDirectory, resolvedAccessedPath)) {
|
|
3155
|
+
projectMemoryMaintained = true;
|
|
3156
|
+
}
|
|
3157
|
+
}
|
|
3158
|
+
}
|
|
3120
3159
|
}
|
|
3121
3160
|
if (toolResult.isError ||
|
|
3122
3161
|
call.name !== 'Read' ||
|
package/dist/cli/interactive.js
CHANGED
|
@@ -1885,7 +1885,7 @@ export function InteractiveApp({ dataPlane = resolveDataPlane(), configRoot: sup
|
|
|
1885
1885
|
}
|
|
1886
1886
|
break;
|
|
1887
1887
|
case 'tool-call':
|
|
1888
|
-
if (['Edit', 'Write', 'NotebookEdit'].includes(event.call.name))
|
|
1888
|
+
if (['Edit', 'Write', 'ApplyPatch', 'NotebookEdit'].includes(event.call.name))
|
|
1889
1889
|
turnMutatedFilesRef.current = true;
|
|
1890
1890
|
permissionCallsRef.current.set(event.call.id, event.call);
|
|
1891
1891
|
append({
|
|
@@ -4055,7 +4055,7 @@ export function InteractiveApp({ dataPlane = resolveDataPlane(), configRoot: sup
|
|
|
4055
4055
|
}
|
|
4056
4056
|
if (permission.kind === 'tool' &&
|
|
4057
4057
|
selected.action === 'allow-session-edits') {
|
|
4058
|
-
for (const rule of ['Write', 'Edit', 'NotebookEdit']) {
|
|
4058
|
+
for (const rule of ['Write', 'Edit', 'ApplyPatch', 'NotebookEdit']) {
|
|
4059
4059
|
if (!immediatePermissionRulesRef.current.includes(rule))
|
|
4060
4060
|
immediatePermissionRulesRef.current.push(rule);
|
|
4061
4061
|
}
|
package/dist/cli/protocol.d.ts
CHANGED
package/dist/cli/protocol.js
CHANGED
|
@@ -1929,9 +1929,16 @@ export class StreamJsonOutput {
|
|
|
1929
1929
|
event.state === 'completed' ||
|
|
1930
1930
|
event.state === 'cancelled' ||
|
|
1931
1931
|
event.state === 'failed') {
|
|
1932
|
-
|
|
1933
|
-
this.
|
|
1932
|
+
const shouldFlush = event.state === 'cancelled'
|
|
1933
|
+
? this.turnText.length > 0 ||
|
|
1934
|
+
this.turnThinking.length > 0 ||
|
|
1935
|
+
this.turnCalls.length > 0
|
|
1936
|
+
: event.state !== 'failed' ||
|
|
1937
|
+
this.pendingFailureMessage === undefined;
|
|
1938
|
+
if (shouldFlush)
|
|
1934
1939
|
this.flushAssistant();
|
|
1940
|
+
else if (event.state === 'cancelled')
|
|
1941
|
+
this.flushPartialOnly();
|
|
1935
1942
|
if (event.state === 'awaiting-permission')
|
|
1936
1943
|
this.writeSessionState('requires_action');
|
|
1937
1944
|
}
|
|
@@ -2492,6 +2499,18 @@ export class StreamJsonOutput {
|
|
|
2492
2499
|
});
|
|
2493
2500
|
this.finishPartialTail();
|
|
2494
2501
|
}
|
|
2502
|
+
flushPartialOnly() {
|
|
2503
|
+
if (!this.includePartialMessages ||
|
|
2504
|
+
this.assistantFlushed ||
|
|
2505
|
+
this.partialEvents.length === 0)
|
|
2506
|
+
return;
|
|
2507
|
+
this.assistantFlushed = true;
|
|
2508
|
+
this.writePartialMessageStart();
|
|
2509
|
+
for (const event of this.partialEvents)
|
|
2510
|
+
this.write(event);
|
|
2511
|
+
this.partialEvents = [];
|
|
2512
|
+
this.pendingPartialStop = undefined;
|
|
2513
|
+
}
|
|
2495
2514
|
finishTurn() {
|
|
2496
2515
|
this.flushAssistant();
|
|
2497
2516
|
this.turnActive = false;
|
package/dist/cli-runtime.js
CHANGED
|
@@ -66,6 +66,7 @@ import { launchTmuxWorktree } from './platform/tmux-worktree.js';
|
|
|
66
66
|
import { claudeSandboxRuntime } from './sandbox/claude-sandbox-runtime.js';
|
|
67
67
|
import { nativeSandboxTempDirectory, loadClaudeSandboxSettings, } from './sandbox/claude-sandbox-settings.js';
|
|
68
68
|
import { createErrorResult, createSuccessResult, isHeadlessCostCommand, matchHeadlessColorCommand, parseCliInvocation, projectProtocolTimings, readStreamJsonMessages, StreamJsonOutput, } from './cli/protocol.js';
|
|
69
|
+
import { isDirectProcessSigint } from './cli/process-signal.js';
|
|
69
70
|
import { executeProviderAuthCommand } from './cli/provider-auth-command.js';
|
|
70
71
|
import { describeClaudePlugin, initClaudePlugin, installClaudePlugin, loadClaudePlugins, readPluginRegistry, setClaudePluginEnabled, uninstallClaudePlugin, updateClaudePlugin, validateClaudePlugin, } from './plugins/claude-plugin-runtime.js';
|
|
71
72
|
import { addClaudeMarketplace, disableAllNativePlugins, installClaudeMarketplacePlugin, listClaudeMarketplaceAvailablePlugins, listNativePluginRecords, readClaudeKnownMarketplaces, removeClaudeMarketplace, setNativePluginEnabled, saveClaudePluginConfig, uninstallNativePlugin, updateClaudeMarketplace, updateNativePlugin, validateClaudeMarketplace, } from './plugins/claude-plugin-marketplace.js';
|
|
@@ -1795,7 +1796,7 @@ const createDefaultService = async ({ eventSink, requireProvider, hooksOnly = fa
|
|
|
1795
1796
|
const filteredTools = new FilteredToolRegistry(extensionAndLspTools, {
|
|
1796
1797
|
...(cli.tools === undefined
|
|
1797
1798
|
? simpleMode
|
|
1798
|
-
? { tools: ['Bash', 'Edit', 'Read'] }
|
|
1799
|
+
? { tools: ['Bash', 'Edit', 'ApplyPatch', 'Read'] }
|
|
1799
1800
|
: {}
|
|
1800
1801
|
: { tools: selectedBaseTools ?? [] }),
|
|
1801
1802
|
disallowedTools: cli.disallowedTools,
|
|
@@ -6016,6 +6017,15 @@ export async function run(argv, io = consoleIO, dependencies = createDefaultDepe
|
|
|
6016
6017
|
}
|
|
6017
6018
|
catch (error) {
|
|
6018
6019
|
if (isCancellation(error, signal)) {
|
|
6020
|
+
if (isDirectProcessSigint(signal)) {
|
|
6021
|
+
try {
|
|
6022
|
+
if (parseCliInvocation(argv).print)
|
|
6023
|
+
return 0;
|
|
6024
|
+
}
|
|
6025
|
+
catch {
|
|
6026
|
+
// Preserve the ordinary cancellation boundary when parsing fails.
|
|
6027
|
+
}
|
|
6028
|
+
}
|
|
6019
6029
|
io.stderr('Praxis run cancelled.\n');
|
|
6020
6030
|
return 130;
|
|
6021
6031
|
}
|
package/dist/cli.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { realpathSync } from 'node:fs';
|
|
3
3
|
import { createRequire } from 'node:module';
|
|
4
4
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
5
|
+
import { DIRECT_PROCESS_SIGINT } from './cli/process-signal.js';
|
|
5
6
|
export { parseContextEnvironment, parseProviderEnvironment, } from './providers/environment.js';
|
|
6
7
|
const VERSION = createRequire(import.meta.url)('../package.json').version;
|
|
7
8
|
export async function run(argv, io, dependencies, signal) {
|
|
@@ -36,14 +37,15 @@ function isDirectExecution(moduleUrl, argvPath) {
|
|
|
36
37
|
}
|
|
37
38
|
if (isDirectExecution(import.meta.url, process.argv[1])) {
|
|
38
39
|
const controller = new AbortController();
|
|
40
|
+
const cancelWithSigint = () => controller.abort(DIRECT_PROCESS_SIGINT);
|
|
39
41
|
const cancel = () => controller.abort();
|
|
40
|
-
process.on('SIGINT',
|
|
42
|
+
process.on('SIGINT', cancelWithSigint);
|
|
41
43
|
process.on('SIGTERM', cancel);
|
|
42
44
|
try {
|
|
43
45
|
process.exitCode = await run(process.argv.slice(2), undefined, undefined, controller.signal);
|
|
44
46
|
}
|
|
45
47
|
finally {
|
|
46
|
-
process.removeListener('SIGINT',
|
|
48
|
+
process.removeListener('SIGINT', cancelWithSigint);
|
|
47
49
|
process.removeListener('SIGTERM', cancel);
|
|
48
50
|
}
|
|
49
51
|
}
|
|
@@ -10,7 +10,15 @@ function toolName(rule) {
|
|
|
10
10
|
}
|
|
11
11
|
function gatedTool(rule) {
|
|
12
12
|
const name = toolName(rule);
|
|
13
|
-
return ([
|
|
13
|
+
return ([
|
|
14
|
+
'Bash',
|
|
15
|
+
'Write',
|
|
16
|
+
'Edit',
|
|
17
|
+
'ApplyPatch',
|
|
18
|
+
'NotebookEdit',
|
|
19
|
+
'WebFetch',
|
|
20
|
+
'WebSearch',
|
|
21
|
+
].includes(name) || name.startsWith('mcp__'));
|
|
14
22
|
}
|
|
15
23
|
function grants(requested, allowed) {
|
|
16
24
|
const name = toolName(requested);
|
|
@@ -247,6 +247,7 @@ class PraxisMcpRuntime {
|
|
|
247
247
|
sensitiveValues = sensitiveEnvironmentValues(process.env);
|
|
248
248
|
toolRegistryPromise;
|
|
249
249
|
closed = false;
|
|
250
|
+
messageHistory = [];
|
|
250
251
|
constructor(options) {
|
|
251
252
|
this.options = options;
|
|
252
253
|
this.localTools = new LocalToolRegistry({
|
|
@@ -261,6 +262,7 @@ class PraxisMcpRuntime {
|
|
|
261
262
|
'Bash',
|
|
262
263
|
'Read',
|
|
263
264
|
'Edit',
|
|
265
|
+
'ApplyPatch',
|
|
264
266
|
'Write',
|
|
265
267
|
'NotebookEdit',
|
|
266
268
|
'Glob',
|
|
@@ -307,10 +309,20 @@ class PraxisMcpRuntime {
|
|
|
307
309
|
const call = { id: randomUUID(), name, input };
|
|
308
310
|
const context = {
|
|
309
311
|
cwd: this.options.cwd,
|
|
312
|
+
messages: [...this.messageHistory],
|
|
310
313
|
...(signal ? { signal } : {}),
|
|
311
314
|
};
|
|
312
315
|
const prepared = await registry.prepare(call, context);
|
|
313
|
-
|
|
316
|
+
const result = await registry.execute(prepared, context);
|
|
317
|
+
if (name === 'Read' && !result.isError) {
|
|
318
|
+
this.messageHistory.push({ role: 'assistant', content: '', toolCalls: [prepared] }, {
|
|
319
|
+
role: 'tool',
|
|
320
|
+
toolCallId: prepared.id,
|
|
321
|
+
content: result.content,
|
|
322
|
+
isError: false,
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
return result;
|
|
314
326
|
}
|
|
315
327
|
async close() {
|
|
316
328
|
if (this.closed)
|
|
@@ -3,7 +3,8 @@ import { homedir } from 'node:os';
|
|
|
3
3
|
import { realpath } from 'node:fs/promises';
|
|
4
4
|
import { resolveProjectIdentity } from '../platform/project-identity.js';
|
|
5
5
|
import { sanitizeProjectPath } from '../platform/project-path-key.js';
|
|
6
|
-
import { annotateAutoModePermissionOutcome, annotatePermissionDecision, } from '../core/runtime.js';
|
|
6
|
+
import { annotateAutoModePermissionOutcome, annotatePermissionDecision, autoModePermissionOutcome, permissionDecisionSource, } from '../core/runtime.js';
|
|
7
|
+
import { parseApplyPatchInput } from '../tools/apply-patch.js';
|
|
7
8
|
import { loadClaudeAutoModeConfig, } from './claude-auto-classifier.js';
|
|
8
9
|
import { analyzeBashCommands, validateBashSemantics } from './bash-ast.js';
|
|
9
10
|
import { shellPermissionMatchCandidates } from './bash-normalization.js';
|
|
@@ -30,6 +31,7 @@ const DEFAULT_BEHAVIOR = {
|
|
|
30
31
|
Grep: 'allow',
|
|
31
32
|
Write: 'ask',
|
|
32
33
|
Edit: 'ask',
|
|
34
|
+
ApplyPatch: 'ask',
|
|
33
35
|
NotebookEdit: 'ask',
|
|
34
36
|
Glob: 'allow',
|
|
35
37
|
LSP: 'allow',
|
|
@@ -50,6 +52,7 @@ const FILE_TOOLS = new Set([
|
|
|
50
52
|
'Read',
|
|
51
53
|
'Write',
|
|
52
54
|
'Edit',
|
|
55
|
+
'ApplyPatch',
|
|
53
56
|
'NotebookEdit',
|
|
54
57
|
'Glob',
|
|
55
58
|
'Grep',
|
|
@@ -206,6 +209,13 @@ function permissionTarget(call) {
|
|
|
206
209
|
? call.input.notebook_path
|
|
207
210
|
: null;
|
|
208
211
|
}
|
|
212
|
+
if (call.name === 'ApplyPatch') {
|
|
213
|
+
const edits = call.input.edits;
|
|
214
|
+
const first = Array.isArray(edits) ? edits[0] : undefined;
|
|
215
|
+
return isRecord(first) && typeof first.file_path === 'string'
|
|
216
|
+
? first.file_path
|
|
217
|
+
: null;
|
|
218
|
+
}
|
|
209
219
|
if (call.name === 'WebFetch') {
|
|
210
220
|
return typeof call.input.url === 'string' ? call.input.url : null;
|
|
211
221
|
}
|
|
@@ -217,7 +227,7 @@ function permissionTarget(call) {
|
|
|
217
227
|
function matchesRule(rule, call, cwd, homeDirectory) {
|
|
218
228
|
const toolMatches = rule.toolName === call.name ||
|
|
219
229
|
(rule.toolName === 'Edit' &&
|
|
220
|
-
['Edit', 'Write', 'NotebookEdit'].includes(call.name)) ||
|
|
230
|
+
['Edit', 'Write', 'NotebookEdit', 'ApplyPatch'].includes(call.name)) ||
|
|
221
231
|
(rule.toolName === 'Read' && ['Read', 'Glob', 'Grep'].includes(call.name));
|
|
222
232
|
if (!toolMatches)
|
|
223
233
|
return false;
|
|
@@ -400,6 +410,131 @@ export class ClaudePermissionResolver {
|
|
|
400
410
|
...call,
|
|
401
411
|
input: { ...call.input, command: subcommand },
|
|
402
412
|
});
|
|
413
|
+
if (call.name === 'ApplyPatch') {
|
|
414
|
+
const edits = parseApplyPatchInput(call.input);
|
|
415
|
+
const originalEdits = context?.originalCall?.name === 'ApplyPatch'
|
|
416
|
+
? parseApplyPatchInput(context.originalCall.input)
|
|
417
|
+
: edits;
|
|
418
|
+
if (originalEdits.length !== edits.length) {
|
|
419
|
+
throw new Error('ApplyPatch original and canonical edits must match');
|
|
420
|
+
}
|
|
421
|
+
const targetInputs = edits.map((edit, index) => {
|
|
422
|
+
const originalEdit = originalEdits[index];
|
|
423
|
+
if (!originalEdit) {
|
|
424
|
+
throw new Error('ApplyPatch original and canonical edits must match');
|
|
425
|
+
}
|
|
426
|
+
const targetCall = {
|
|
427
|
+
...call,
|
|
428
|
+
name: 'Edit',
|
|
429
|
+
input: { ...edit },
|
|
430
|
+
};
|
|
431
|
+
const applyView = {
|
|
432
|
+
...call,
|
|
433
|
+
input: { edits: [{ ...edit }] },
|
|
434
|
+
};
|
|
435
|
+
const originalView = {
|
|
436
|
+
...call,
|
|
437
|
+
input: { edits: [{ ...originalEdit }] },
|
|
438
|
+
};
|
|
439
|
+
const originalTargetCall = {
|
|
440
|
+
...targetCall,
|
|
441
|
+
input: { ...originalEdit },
|
|
442
|
+
};
|
|
443
|
+
return {
|
|
444
|
+
edit,
|
|
445
|
+
originalEdit,
|
|
446
|
+
targetCall,
|
|
447
|
+
applyView,
|
|
448
|
+
originalView,
|
|
449
|
+
originalTargetCall,
|
|
450
|
+
};
|
|
451
|
+
});
|
|
452
|
+
const directDenied = targetInputs.flatMap(({ applyView, originalView, targetCall, originalTargetCall }) => effectiveRules('deny').filter((rule) => matchesRule(rule, applyView, cwd, this.homeDirectory) ||
|
|
453
|
+
matchesRule(rule, originalView, cwd, this.homeDirectory) ||
|
|
454
|
+
matchesRule(rule, targetCall, cwd, this.homeDirectory) ||
|
|
455
|
+
matchesRule(rule, originalTargetCall, cwd, this.homeDirectory)))[0];
|
|
456
|
+
if (directDenied) {
|
|
457
|
+
const suffix = directDenied.pattern === null ? '' : `(${directDenied.pattern})`;
|
|
458
|
+
return annotatePermissionDecision({
|
|
459
|
+
behavior: 'deny',
|
|
460
|
+
reason: `Denied by Claude permission rule ${directDenied.toolName}${suffix}`,
|
|
461
|
+
}, 'rule');
|
|
462
|
+
}
|
|
463
|
+
const targetDecisions = await Promise.all(targetInputs.map(async ({ originalEdit, targetCall, applyView, originalView }) => {
|
|
464
|
+
if (permissionMode === 'plan') {
|
|
465
|
+
return annotatePermissionDecision({
|
|
466
|
+
behavior: 'deny',
|
|
467
|
+
reason: 'Cannot use ApplyPatch while in plan mode',
|
|
468
|
+
}, 'mode');
|
|
469
|
+
}
|
|
470
|
+
const directAsked = effectiveRules('ask').find((rule) => matchesRule(rule, applyView, cwd, this.homeDirectory) ||
|
|
471
|
+
matchesRule(rule, originalView, cwd, this.homeDirectory));
|
|
472
|
+
if (directAsked)
|
|
473
|
+
return this.askDecision(targetCall, cwd, permissionMode, context, 'rule', undefined, true);
|
|
474
|
+
const directAllowed = effectiveRules('allow').find((rule) => matchesRule(rule, applyView, cwd, this.homeDirectory) ||
|
|
475
|
+
matchesRule(rule, originalView, cwd, this.homeDirectory));
|
|
476
|
+
const resolved = await this.resolve(targetCall, context
|
|
477
|
+
? {
|
|
478
|
+
...context,
|
|
479
|
+
originalCall: {
|
|
480
|
+
...call,
|
|
481
|
+
input: { edits: [{ ...originalEdit }] },
|
|
482
|
+
},
|
|
483
|
+
}
|
|
484
|
+
: { cwd, originalCall: originalView });
|
|
485
|
+
if (resolved.behavior === 'allow' &&
|
|
486
|
+
permissionDecisionSource(resolved) === 'default' &&
|
|
487
|
+
!directAllowed) {
|
|
488
|
+
return this.askDecision(targetCall, cwd, permissionMode, context
|
|
489
|
+
? {
|
|
490
|
+
...context,
|
|
491
|
+
originalCall: {
|
|
492
|
+
...call,
|
|
493
|
+
input: { edits: [{ ...originalEdit }] },
|
|
494
|
+
},
|
|
495
|
+
}
|
|
496
|
+
: { cwd, originalCall: originalView }, 'default', undefined, true);
|
|
497
|
+
}
|
|
498
|
+
if (directAllowed &&
|
|
499
|
+
resolved.behavior === 'ask' &&
|
|
500
|
+
resolved.reason !==
|
|
501
|
+
'Path is outside allowed working directories' &&
|
|
502
|
+
(permissionMode !== 'auto' || !this.shouldClassify(targetCall))) {
|
|
503
|
+
return annotatePermissionDecision({ behavior: 'allow' }, 'rule');
|
|
504
|
+
}
|
|
505
|
+
return resolved;
|
|
506
|
+
}));
|
|
507
|
+
const deniedDecision = targetDecisions.find((decision) => decision.behavior === 'deny');
|
|
508
|
+
if (deniedDecision)
|
|
509
|
+
return deniedDecision;
|
|
510
|
+
const askedDecisions = targetDecisions.filter((decision) => decision.behavior === 'ask');
|
|
511
|
+
if (askedDecisions.length > 0) {
|
|
512
|
+
const first = askedDecisions[0];
|
|
513
|
+
if (!first)
|
|
514
|
+
throw new Error('ApplyPatch permission decision is missing');
|
|
515
|
+
const suggestions = [
|
|
516
|
+
...new Map(askedDecisions
|
|
517
|
+
.flatMap((decision) => decision.behavior === 'ask' ? (decision.suggestions ?? []) : [])
|
|
518
|
+
.map((suggestion) => [JSON.stringify(suggestion), suggestion])).values(),
|
|
519
|
+
];
|
|
520
|
+
const aggregate = {
|
|
521
|
+
...first,
|
|
522
|
+
suggestions,
|
|
523
|
+
};
|
|
524
|
+
const source = permissionDecisionSource(first);
|
|
525
|
+
const annotated = source
|
|
526
|
+
? annotatePermissionDecision(aggregate, source)
|
|
527
|
+
: aggregate;
|
|
528
|
+
const outcome = autoModePermissionOutcome(first);
|
|
529
|
+
return outcome
|
|
530
|
+
? annotateAutoModePermissionOutcome(annotated, outcome)
|
|
531
|
+
: annotated;
|
|
532
|
+
}
|
|
533
|
+
const allowed = targetDecisions.find((decision) => decision.behavior === 'allow');
|
|
534
|
+
if (allowed)
|
|
535
|
+
return allowed;
|
|
536
|
+
throw new Error('ApplyPatch permission resolution produced no decision');
|
|
537
|
+
}
|
|
403
538
|
const denied = matchingRule('deny') ??
|
|
404
539
|
subcommands
|
|
405
540
|
.map((subcommand) => matchingRule('deny', subcommandCall(subcommand)))
|
|
@@ -414,6 +549,7 @@ export class ClaudePermissionResolver {
|
|
|
414
549
|
if (permissionMode === 'plan' &&
|
|
415
550
|
(call.name === 'Write' ||
|
|
416
551
|
call.name === 'Edit' ||
|
|
552
|
+
call.name === 'ApplyPatch' ||
|
|
417
553
|
call.name === 'NotebookEdit')) {
|
|
418
554
|
return annotatePermissionDecision({
|
|
419
555
|
behavior: 'deny',
|
|
@@ -558,7 +694,7 @@ export class ClaudePermissionResolver {
|
|
|
558
694
|
absolutePath,
|
|
559
695
|
]),
|
|
560
696
|
];
|
|
561
|
-
const write = ['Write', 'Edit', 'NotebookEdit'].includes(call.name);
|
|
697
|
+
const write = ['Write', 'Edit', 'ApplyPatch', 'NotebookEdit'].includes(call.name);
|
|
562
698
|
const internalRoots = write
|
|
563
699
|
? internalEditableRoots
|
|
564
700
|
: internalReadableRoots;
|
|
@@ -585,6 +721,7 @@ export class ClaudePermissionResolver {
|
|
|
585
721
|
if (permissionMode === 'acceptEdits' &&
|
|
586
722
|
(call.name === 'Write' ||
|
|
587
723
|
call.name === 'Edit' ||
|
|
724
|
+
call.name === 'ApplyPatch' ||
|
|
588
725
|
call.name === 'NotebookEdit')) {
|
|
589
726
|
return annotatePermissionDecision({ behavior: 'allow' }, 'mode');
|
|
590
727
|
}
|
|
@@ -651,7 +788,12 @@ export class ClaudePermissionResolver {
|
|
|
651
788
|
FILE_TOOLS.has(call.name) &&
|
|
652
789
|
permissionTarget(call)
|
|
653
790
|
? {
|
|
654
|
-
suggestions: filePermissionSuggestions(permissionTarget(call) ?? cwd, cwd, [
|
|
791
|
+
suggestions: filePermissionSuggestions(permissionTarget(call) ?? cwd, cwd, [
|
|
792
|
+
'Write',
|
|
793
|
+
'Edit',
|
|
794
|
+
'ApplyPatch',
|
|
795
|
+
'NotebookEdit',
|
|
796
|
+
].includes(call.name)
|
|
655
797
|
? 'write'
|
|
656
798
|
: 'read', (effectivePermissionMode(context?.permissionUpdates) ??
|
|
657
799
|
permissionMode), false),
|
|
@@ -664,6 +806,7 @@ export class ClaudePermissionResolver {
|
|
|
664
806
|
return true;
|
|
665
807
|
if (call.name === 'Write' ||
|
|
666
808
|
call.name === 'Edit' ||
|
|
809
|
+
call.name === 'ApplyPatch' ||
|
|
667
810
|
call.name === 'NotebookEdit' ||
|
|
668
811
|
call.name === 'WebFetch' ||
|
|
669
812
|
call.name === 'WebSearch' ||
|
|
@@ -8,6 +8,8 @@ export async function writeFileAtomically(filePath, content, options = {}) {
|
|
|
8
8
|
const handle = await open(temporary, 'wx', options.mode ?? 0o600);
|
|
9
9
|
try {
|
|
10
10
|
await handle.writeFile(content);
|
|
11
|
+
if (options.mode !== undefined)
|
|
12
|
+
await handle.chmod(options.mode);
|
|
11
13
|
await handle.sync();
|
|
12
14
|
}
|
|
13
15
|
finally {
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { ModelToolDefinition } from '../core/runtime.js';
|
|
2
|
+
export declare const APPLY_PATCH_MAX_EDITS = 32;
|
|
3
|
+
export declare const APPLY_PATCH_MAX_FILES = 8;
|
|
4
|
+
export declare const APPLY_PATCH_MAX_INPUT_BYTES: number;
|
|
5
|
+
export interface ApplyPatchEdit {
|
|
6
|
+
file_path: string;
|
|
7
|
+
old_string: string;
|
|
8
|
+
new_string: string;
|
|
9
|
+
}
|
|
10
|
+
export interface ApplyPatchPlanFile {
|
|
11
|
+
filePath: string;
|
|
12
|
+
before: string;
|
|
13
|
+
after: string;
|
|
14
|
+
linesAdded: number;
|
|
15
|
+
linesRemoved: number;
|
|
16
|
+
}
|
|
17
|
+
export interface ApplyPatchPlan {
|
|
18
|
+
files: readonly ApplyPatchPlanFile[];
|
|
19
|
+
linesAdded: number;
|
|
20
|
+
linesRemoved: number;
|
|
21
|
+
}
|
|
22
|
+
export declare const APPLY_PATCH_DEFINITION: ModelToolDefinition;
|
|
23
|
+
export declare function parseApplyPatchInput(input: unknown): ApplyPatchEdit[];
|
|
24
|
+
export declare function planApplyPatch(edits: readonly ApplyPatchEdit[], sources: ReadonlyMap<string, string>, maxFileBytes: number): ApplyPatchPlan;
|
|
25
|
+
//# sourceMappingURL=apply-patch.d.ts.map
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { countLineChanges } from './line-changes.js';
|
|
2
|
+
export const APPLY_PATCH_MAX_EDITS = 32;
|
|
3
|
+
export const APPLY_PATCH_MAX_FILES = 8;
|
|
4
|
+
export const APPLY_PATCH_MAX_INPUT_BYTES = 256 * 1024;
|
|
5
|
+
export const APPLY_PATCH_DEFINITION = {
|
|
6
|
+
name: 'ApplyPatch',
|
|
7
|
+
description: 'Applies a bounded batch of exact, unique string replacements to existing files.',
|
|
8
|
+
inputSchema: {
|
|
9
|
+
$schema: 'https://json-schema.org/draft/2020-12/schema',
|
|
10
|
+
type: 'object',
|
|
11
|
+
properties: {
|
|
12
|
+
edits: {
|
|
13
|
+
type: 'array',
|
|
14
|
+
minItems: 1,
|
|
15
|
+
maxItems: APPLY_PATCH_MAX_EDITS,
|
|
16
|
+
items: {
|
|
17
|
+
type: 'object',
|
|
18
|
+
properties: {
|
|
19
|
+
file_path: { type: 'string', minLength: 1 },
|
|
20
|
+
old_string: { type: 'string', minLength: 1 },
|
|
21
|
+
new_string: { type: 'string' },
|
|
22
|
+
},
|
|
23
|
+
required: ['file_path', 'old_string', 'new_string'],
|
|
24
|
+
additionalProperties: false,
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
required: ['edits'],
|
|
29
|
+
additionalProperties: false,
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
function objectInput(input, label) {
|
|
33
|
+
if (!input || typeof input !== 'object' || Array.isArray(input))
|
|
34
|
+
throw new Error(`${label} must be an object`);
|
|
35
|
+
return input;
|
|
36
|
+
}
|
|
37
|
+
function exactKeys(input, keys) {
|
|
38
|
+
const unexpected = Object.keys(input).find((key) => !keys.includes(key));
|
|
39
|
+
if (unexpected)
|
|
40
|
+
throw new Error(`Unexpected ApplyPatch field: ${unexpected}`);
|
|
41
|
+
}
|
|
42
|
+
function stringInput(input, key, nonEmpty = false) {
|
|
43
|
+
const value = input[key];
|
|
44
|
+
if (typeof value !== 'string' || (nonEmpty && value.length === 0))
|
|
45
|
+
throw new Error(`${key} must be ${nonEmpty ? 'a non-empty ' : 'a '}string`);
|
|
46
|
+
return value;
|
|
47
|
+
}
|
|
48
|
+
export function parseApplyPatchInput(input) {
|
|
49
|
+
let encoded;
|
|
50
|
+
try {
|
|
51
|
+
encoded = JSON.stringify(input);
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
throw new Error('ApplyPatch input must be JSON-serializable');
|
|
55
|
+
}
|
|
56
|
+
if (encoded === undefined)
|
|
57
|
+
throw new Error('ApplyPatch input must be JSON-serializable');
|
|
58
|
+
if (Buffer.byteLength(encoded, 'utf8') > APPLY_PATCH_MAX_INPUT_BYTES)
|
|
59
|
+
throw new Error('ApplyPatch input exceeds 256 KiB');
|
|
60
|
+
const object = objectInput(input, 'ApplyPatch input');
|
|
61
|
+
exactKeys(object, ['edits']);
|
|
62
|
+
const rawEdits = object.edits;
|
|
63
|
+
if (!Array.isArray(rawEdits) ||
|
|
64
|
+
rawEdits.length < 1 ||
|
|
65
|
+
rawEdits.length > APPLY_PATCH_MAX_EDITS)
|
|
66
|
+
throw new Error('ApplyPatch edits must contain 1 to 32 operations');
|
|
67
|
+
return rawEdits.map((raw, index) => {
|
|
68
|
+
const item = objectInput(raw, `ApplyPatch edits[${index}]`);
|
|
69
|
+
exactKeys(item, ['file_path', 'old_string', 'new_string']);
|
|
70
|
+
const edit = {
|
|
71
|
+
file_path: stringInput(item, 'file_path', true),
|
|
72
|
+
old_string: stringInput(item, 'old_string', true),
|
|
73
|
+
new_string: stringInput(item, 'new_string'),
|
|
74
|
+
};
|
|
75
|
+
if (edit.old_string === edit.new_string)
|
|
76
|
+
throw new Error('new_string must differ from old_string');
|
|
77
|
+
return edit;
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
export function planApplyPatch(edits, sources, maxFileBytes) {
|
|
81
|
+
if (edits.length < 1 || edits.length > APPLY_PATCH_MAX_EDITS)
|
|
82
|
+
throw new Error('ApplyPatch edits must contain 1 to 32 operations');
|
|
83
|
+
if (new Set(edits.map((edit) => edit.file_path)).size > APPLY_PATCH_MAX_FILES)
|
|
84
|
+
throw new Error('ApplyPatch may touch at most 8 files');
|
|
85
|
+
const snapshots = new Map();
|
|
86
|
+
for (const edit of edits) {
|
|
87
|
+
if (!edit.old_string)
|
|
88
|
+
throw new Error('old_string must be non-empty');
|
|
89
|
+
if (edit.old_string === edit.new_string)
|
|
90
|
+
throw new Error('new_string must differ from old_string');
|
|
91
|
+
if (!snapshots.has(edit.file_path)) {
|
|
92
|
+
const source = sources.get(edit.file_path);
|
|
93
|
+
if (source === undefined)
|
|
94
|
+
throw new Error(`ApplyPatch source is missing: ${edit.file_path}`);
|
|
95
|
+
snapshots.set(edit.file_path, source);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
for (const edit of edits) {
|
|
99
|
+
const source = snapshots.get(edit.file_path);
|
|
100
|
+
if (source === undefined)
|
|
101
|
+
throw new Error(`ApplyPatch source is missing: ${edit.file_path}`);
|
|
102
|
+
const first = source.indexOf(edit.old_string);
|
|
103
|
+
if (first < 0)
|
|
104
|
+
throw new Error('old_string was not found');
|
|
105
|
+
const second = source.indexOf(edit.old_string, first + edit.old_string.length);
|
|
106
|
+
if (second >= 0)
|
|
107
|
+
throw new Error('old_string must match exactly once');
|
|
108
|
+
const after = source.slice(0, first) +
|
|
109
|
+
edit.new_string +
|
|
110
|
+
source.slice(first + edit.old_string.length);
|
|
111
|
+
if (Buffer.byteLength(after, 'utf8') > maxFileBytes)
|
|
112
|
+
throw new Error(`Edited content exceeds ${maxFileBytes} bytes`);
|
|
113
|
+
snapshots.set(edit.file_path, after);
|
|
114
|
+
}
|
|
115
|
+
const files = [...snapshots].map(([filePath, after]) => {
|
|
116
|
+
const before = sources.get(filePath);
|
|
117
|
+
if (before === undefined)
|
|
118
|
+
throw new Error(`ApplyPatch source is missing: ${filePath}`);
|
|
119
|
+
const changes = countLineChanges(before, after);
|
|
120
|
+
return { filePath, before, after, ...changes };
|
|
121
|
+
});
|
|
122
|
+
return {
|
|
123
|
+
files,
|
|
124
|
+
linesAdded: files.reduce((total, file) => total + file.linesAdded, 0),
|
|
125
|
+
linesRemoved: files.reduce((total, file) => total + file.linesRemoved, 0),
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
//# sourceMappingURL=apply-patch.js.map
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { mkdir, readFile, realpath } from 'node:fs/promises';
|
|
2
2
|
import { basename, dirname, join, resolve } from 'node:path';
|
|
3
3
|
import { resolveToolSchedulingPolicy } from '../core/tool-scheduling-policy.js';
|
|
4
|
+
import { parseApplyPatchInput } from './apply-patch.js';
|
|
4
5
|
const ASK_USER_QUESTION = {
|
|
5
6
|
name: 'AskUserQuestion',
|
|
6
7
|
description: "Use this tool only when you are blocked on a decision that is genuinely the user's to make: one you cannot resolve from the request, the code, or sensible defaults. Users can always provide custom text. Use multiSelect for non-exclusive choices. In plan mode, use this tool to clarify requirements before ExitPlanMode; do not use it to request plan approval.",
|
|
@@ -354,6 +355,13 @@ Use AskUserQuestion for decisions that genuinely require the user. Write a compl
|
|
|
354
355
|
typeof call.input.file_path === 'string') {
|
|
355
356
|
return this.resolvePlanFile(state, call, context);
|
|
356
357
|
}
|
|
358
|
+
if (state.mode === 'plan' && call.name === 'ApplyPatch') {
|
|
359
|
+
const edits = parseApplyPatchInput(call.input);
|
|
360
|
+
if ((await Promise.all(edits.map((edit) => this.isPlanFileForState(state, edit.file_path)))).every(Boolean)) {
|
|
361
|
+
return { behavior: 'allow' };
|
|
362
|
+
}
|
|
363
|
+
return this.resolver(state.mode).resolve(call, context);
|
|
364
|
+
}
|
|
357
365
|
const decision = await this.resolver(this.planPermissionMode(sessionId)).resolve(call, context);
|
|
358
366
|
return this.applyRetainedPrompts(state, call, decision);
|
|
359
367
|
}
|
|
@@ -12,6 +12,8 @@ import { countLineChanges } from './line-changes.js';
|
|
|
12
12
|
import { editNotebook, formatNotebookForRead } from './notebook.js';
|
|
13
13
|
import { openPdf } from './pdf.js';
|
|
14
14
|
import { validateBashPathSafety } from '../permissions/bash-path-safety.js';
|
|
15
|
+
import { APPLY_PATCH_DEFINITION, APPLY_PATCH_MAX_FILES, parseApplyPatchInput, planApplyPatch, } from './apply-patch.js';
|
|
16
|
+
import { writeFileAtomically } from '../platform/atomic-write.js';
|
|
15
17
|
import { protectedWritePathReason } from '../permissions/bypass-immune-paths.js';
|
|
16
18
|
import { effectiveAdditionalDirectories, shellInputIsReadOnly, } from '../permissions/permission-updates.js';
|
|
17
19
|
const REPORT_FINDINGS_DEFINITION = {
|
|
@@ -160,6 +162,7 @@ const TOOL_DEFINITIONS = [
|
|
|
160
162
|
additionalProperties: false,
|
|
161
163
|
},
|
|
162
164
|
},
|
|
165
|
+
APPLY_PATCH_DEFINITION,
|
|
163
166
|
{
|
|
164
167
|
name: 'NotebookEdit',
|
|
165
168
|
description: 'Replace, insert, or delete one cell in a Jupyter notebook after reading it.',
|
|
@@ -627,7 +630,15 @@ export class LocalToolRegistry {
|
|
|
627
630
|
this.additionalReadDirectories.length === 0) {
|
|
628
631
|
return definitions;
|
|
629
632
|
}
|
|
630
|
-
return definitions.map((definition) => [
|
|
633
|
+
return definitions.map((definition) => [
|
|
634
|
+
'Read',
|
|
635
|
+
'Write',
|
|
636
|
+
'Edit',
|
|
637
|
+
'ApplyPatch',
|
|
638
|
+
'NotebookEdit',
|
|
639
|
+
'Glob',
|
|
640
|
+
'Grep',
|
|
641
|
+
].includes(definition.name)
|
|
631
642
|
? {
|
|
632
643
|
...definition,
|
|
633
644
|
description: `${definition.description}${this.additionalDirectories.length === 0
|
|
@@ -731,6 +742,26 @@ export class LocalToolRegistry {
|
|
|
731
742
|
this.mutationTargetExisted.set(prepared, targetExisted);
|
|
732
743
|
return prepared;
|
|
733
744
|
}
|
|
745
|
+
case 'ApplyPatch': {
|
|
746
|
+
const edits = parseApplyPatchInput(call.input);
|
|
747
|
+
const preparedEdits = [];
|
|
748
|
+
for (const edit of edits) {
|
|
749
|
+
const filePath = await this.filePath(edit.file_path, false, false, context);
|
|
750
|
+
this.assertProtectedWritePath(filePath);
|
|
751
|
+
if (!(await this.wasSuccessfullyRead(filePath, context.messages ?? [], context))) {
|
|
752
|
+
throw new Error('<tool_use_error>File has not been read yet. Read it first before writing to it.</tool_use_error>');
|
|
753
|
+
}
|
|
754
|
+
preparedEdits.push({
|
|
755
|
+
file_path: filePath,
|
|
756
|
+
old_string: edit.old_string,
|
|
757
|
+
new_string: edit.new_string,
|
|
758
|
+
});
|
|
759
|
+
}
|
|
760
|
+
if (new Set(preparedEdits.map((edit) => edit.file_path)).size >
|
|
761
|
+
APPLY_PATCH_MAX_FILES)
|
|
762
|
+
throw new Error('ApplyPatch may touch at most 8 files');
|
|
763
|
+
return { ...call, input: { edits: preparedEdits } };
|
|
764
|
+
}
|
|
734
765
|
case 'NotebookEdit': {
|
|
735
766
|
const requestedPath = stringInput(call.input, 'notebook_path');
|
|
736
767
|
if (!isAbsolute(requestedPath)) {
|
|
@@ -839,6 +870,8 @@ export class LocalToolRegistry {
|
|
|
839
870
|
return this.write(prepared, context, targetExisted);
|
|
840
871
|
case 'Edit':
|
|
841
872
|
return this.edit(prepared, targetExisted);
|
|
873
|
+
case 'ApplyPatch':
|
|
874
|
+
return this.applyPatch(prepared, context);
|
|
842
875
|
case 'NotebookEdit':
|
|
843
876
|
return this.notebookEdit(prepared);
|
|
844
877
|
case 'Glob':
|
|
@@ -1222,6 +1255,74 @@ export class LocalToolRegistry {
|
|
|
1222
1255
|
await handle.close();
|
|
1223
1256
|
}
|
|
1224
1257
|
}
|
|
1258
|
+
async applyPatch(call, context) {
|
|
1259
|
+
const edits = parseApplyPatchInput(call.input);
|
|
1260
|
+
const sources = new Map();
|
|
1261
|
+
const modes = new Map();
|
|
1262
|
+
const identities = new Map();
|
|
1263
|
+
const paths = [];
|
|
1264
|
+
for (const edit of edits) {
|
|
1265
|
+
const filePath = edit.file_path;
|
|
1266
|
+
if (sources.has(filePath))
|
|
1267
|
+
continue;
|
|
1268
|
+
paths.push(filePath);
|
|
1269
|
+
const handle = await open(filePath, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
1270
|
+
try {
|
|
1271
|
+
await this.assertStablePath(filePath);
|
|
1272
|
+
const metadata = await handle.stat();
|
|
1273
|
+
if (!metadata.isFile())
|
|
1274
|
+
throw new Error(`Not a file: ${filePath}`);
|
|
1275
|
+
if (metadata.size > this.maxFileBytes)
|
|
1276
|
+
throw new Error(`File exceeds ${this.maxFileBytes} byte edit limit`);
|
|
1277
|
+
sources.set(filePath, await handle.readFile('utf8'));
|
|
1278
|
+
identities.set(filePath, { dev: metadata.dev, ino: metadata.ino });
|
|
1279
|
+
modes.set(filePath, metadata.mode & 0o7777);
|
|
1280
|
+
}
|
|
1281
|
+
finally {
|
|
1282
|
+
await handle.close();
|
|
1283
|
+
}
|
|
1284
|
+
}
|
|
1285
|
+
const plan = planApplyPatch(edits, sources, this.maxFileBytes);
|
|
1286
|
+
for (const file of plan.files) {
|
|
1287
|
+
if (context.signal?.aborted)
|
|
1288
|
+
throw abortError();
|
|
1289
|
+
const mode = modes.get(file.filePath);
|
|
1290
|
+
const committed = await writeFileAtomically(file.filePath, file.after, {
|
|
1291
|
+
...(mode === undefined ? {} : { mode }),
|
|
1292
|
+
beforeCommit: async () => {
|
|
1293
|
+
let handle;
|
|
1294
|
+
try {
|
|
1295
|
+
handle = await open(file.filePath, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
1296
|
+
await this.assertStablePath(file.filePath);
|
|
1297
|
+
const metadata = await handle.stat();
|
|
1298
|
+
if (!metadata.isFile())
|
|
1299
|
+
return false;
|
|
1300
|
+
const identity = identities.get(file.filePath);
|
|
1301
|
+
if (!identity ||
|
|
1302
|
+
metadata.dev !== identity.dev ||
|
|
1303
|
+
metadata.ino !== identity.ino)
|
|
1304
|
+
return false;
|
|
1305
|
+
return (await handle.readFile('utf8')) === file.before;
|
|
1306
|
+
}
|
|
1307
|
+
catch {
|
|
1308
|
+
return false;
|
|
1309
|
+
}
|
|
1310
|
+
finally {
|
|
1311
|
+
await handle?.close();
|
|
1312
|
+
}
|
|
1313
|
+
},
|
|
1314
|
+
});
|
|
1315
|
+
if (!committed)
|
|
1316
|
+
throw new Error('Tool input changed after permission approval');
|
|
1317
|
+
}
|
|
1318
|
+
return {
|
|
1319
|
+
content: `Applied ${edits.length} replacement(s) to ${paths.length} file(s)`,
|
|
1320
|
+
isError: false,
|
|
1321
|
+
linesAdded: plan.linesAdded,
|
|
1322
|
+
linesRemoved: plan.linesRemoved,
|
|
1323
|
+
accessedPaths: paths,
|
|
1324
|
+
};
|
|
1325
|
+
}
|
|
1225
1326
|
async edit(call, preparedTargetExisted) {
|
|
1226
1327
|
const filePath = stringInput(call.input, 'file_path');
|
|
1227
1328
|
this.assertProtectedWritePath(filePath);
|
|
@@ -5,8 +5,9 @@ import { analyzeBashStructure, normalizeBashWrapperArgv, } from '../permissions/
|
|
|
5
5
|
import { validateBashPathSafety } from '../permissions/bash-path-safety.js';
|
|
6
6
|
import { shellInputIsReadOnly } from '../permissions/permission-updates.js';
|
|
7
7
|
import { isPathWithin } from '../platform/path-containment.js';
|
|
8
|
+
import { parseApplyPatchInput } from './apply-patch.js';
|
|
8
9
|
const READ_ONLY_TOOLS = new Set(['Read', 'Glob', 'Grep', 'Bash']);
|
|
9
|
-
const WRITE_TOOLS = new Set([...READ_ONLY_TOOLS, 'Write', 'Edit']);
|
|
10
|
+
const WRITE_TOOLS = new Set([...READ_ONLY_TOOLS, 'Write', 'Edit', 'ApplyPatch']);
|
|
10
11
|
const SAFE_GIT_COMMANDS = new Set([
|
|
11
12
|
'status',
|
|
12
13
|
'diff',
|
|
@@ -88,6 +89,9 @@ function within(root, candidate) {
|
|
|
88
89
|
function toolInputPaths(call) {
|
|
89
90
|
if (call.name === 'Read' || call.name === 'Write' || call.name === 'Edit')
|
|
90
91
|
return [call.input.file_path];
|
|
92
|
+
if (call.name === 'ApplyPatch') {
|
|
93
|
+
return parseApplyPatchInput(call.input).map((edit) => edit.file_path);
|
|
94
|
+
}
|
|
91
95
|
if (call.name === 'Glob' || call.name === 'Grep')
|
|
92
96
|
return call.input.path === undefined ? [] : [call.input.path];
|
|
93
97
|
return [];
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "praxis-agent",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.56.0",
|
|
4
4
|
"description": "Local-first, single-user general agent for the command line.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "wuqisen",
|
|
@@ -62,7 +62,7 @@
|
|
|
62
62
|
"verify:ci-coverage": "node scripts/verify-ci-coverage.mjs",
|
|
63
63
|
"verify:fixture-contracts": "node scripts/verify-fixture-contracts.mjs",
|
|
64
64
|
"test:fixtures": "node scripts/run-fixture-contracts.mjs",
|
|
65
|
-
"test:eval:baseline": "vitest run src/evals/coding-baseline.test.ts src/evals/project-eval-comparison.test.ts"
|
|
65
|
+
"test:eval:baseline": "vitest run src/evals/coding-baseline.test.ts src/evals/project-eval-comparison.test.ts src/evals/apply-patch-admission.test.ts"
|
|
66
66
|
},
|
|
67
67
|
"engines": {
|
|
68
68
|
"node": ">=24"
|