@cjhyy/code-shell-core 0.7.0 → 0.7.1
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/dist/arena/arena.d.ts +2 -0
- package/dist/arena/arena.js +30 -1
- package/dist/arena/phases/adjudication.d.ts +2 -1
- package/dist/arena/phases/adjudication.js +2 -1
- package/dist/arena/phases/build-consensus.d.ts +2 -1
- package/dist/arena/phases/build-consensus.js +3 -1
- package/dist/arena/phases/cross-review.d.ts +3 -1
- package/dist/arena/phases/cross-review.js +8 -2
- package/dist/arena/phases/debate-rounds.d.ts +2 -1
- package/dist/arena/phases/debate-rounds.js +4 -2
- package/dist/arena/phases/participant-research.d.ts +2 -1
- package/dist/arena/phases/participant-research.js +3 -1
- package/dist/arena/phases/planning-detail-expansion.d.ts +2 -1
- package/dist/arena/phases/planning-detail-expansion.js +2 -1
- package/dist/arena/planner.d.ts +2 -1
- package/dist/arena/planner.js +2 -1
- package/dist/arena/types.d.ts +5 -1
- package/dist/cli/agent-server-tcp.js +13 -2
- package/dist/context/manager.d.ts +3 -3
- package/dist/context/manager.js +6 -6
- package/dist/context/token-counter.js +5 -3
- package/dist/context/tool-result-storage.d.ts +6 -0
- package/dist/context/tool-result-storage.js +25 -4
- package/dist/credentials/access.d.ts +10 -1
- package/dist/credentials/access.js +76 -2
- package/dist/credentials/index.d.ts +3 -2
- package/dist/credentials/index.js +2 -1
- package/dist/credentials/oauth.d.ts +6 -1
- package/dist/credentials/oauth.js +66 -1
- package/dist/credentials/store.d.ts +1 -1
- package/dist/credentials/store.js +16 -7
- package/dist/credentials/types.d.ts +38 -1
- package/dist/credentials/types.js +16 -1
- package/dist/engine/engine.d.ts +61 -32
- package/dist/engine/engine.js +289 -55
- package/dist/engine/goal.d.ts +5 -3
- package/dist/engine/session-title.d.ts +2 -1
- package/dist/engine/session-title.js +4 -1
- package/dist/engine/turn-loop.d.ts +18 -0
- package/dist/engine/turn-loop.js +202 -27
- package/dist/engine/types.d.ts +6 -2
- package/dist/git/worktree/crud.d.ts +3 -0
- package/dist/git/worktree/crud.js +32 -3
- package/dist/git/worktree/git-exec.d.ts +2 -2
- package/dist/git/worktree/git-exec.js +47 -11
- package/dist/git/worktree/query.d.ts +8 -7
- package/dist/git/worktree/query.js +27 -20
- package/dist/hooks/events.d.ts +3 -0
- package/dist/hooks/events.js +0 -3
- package/dist/hooks/goal-stop-hook.d.ts +14 -4
- package/dist/hooks/goal-stop-hook.js +626 -71
- package/dist/hooks/registry.js +3 -0
- package/dist/hooks/shell-runner.d.ts +12 -1
- package/dist/hooks/shell-runner.js +160 -9
- package/dist/index.d.ts +7 -6
- package/dist/index.js +6 -5
- package/dist/llm/client-base.js +12 -10
- package/dist/llm/types.d.ts +12 -5
- package/dist/plugins/pluginCommandHook.d.ts +4 -4
- package/dist/plugins/pluginCommandHook.js +111 -13
- package/dist/protocol/chat-session-manager.d.ts +13 -2
- package/dist/protocol/chat-session-manager.js +90 -18
- package/dist/protocol/chat-session.d.ts +12 -0
- package/dist/protocol/chat-session.js +30 -5
- package/dist/protocol/client.d.ts +5 -2
- package/dist/protocol/client.js +22 -1
- package/dist/protocol/server.d.ts +25 -11
- package/dist/protocol/server.js +290 -72
- package/dist/protocol/types.d.ts +34 -2
- package/dist/protocol/types.js +2 -0
- package/dist/services/dream-consolidation.d.ts +3 -0
- package/dist/services/dream-consolidation.js +4 -1
- package/dist/services/index.d.ts +1 -1
- package/dist/services/index.js +1 -1
- package/dist/services/oauth.d.ts +34 -10
- package/dist/services/oauth.js +233 -98
- package/dist/session/session-manager.d.ts +35 -6
- package/dist/session/session-manager.js +362 -26
- package/dist/session/transcript.d.ts +30 -1
- package/dist/session/transcript.js +119 -4
- package/dist/tool-system/builtin/agent.js +5 -1
- package/dist/tool-system/builtin/arena.js +1 -0
- package/dist/tool-system/builtin/background-jobs.d.ts +8 -5
- package/dist/tool-system/builtin/background-jobs.js +15 -11
- package/dist/tool-system/builtin/background-work.d.ts +6 -1
- package/dist/tool-system/builtin/background-work.js +5 -1
- package/dist/tool-system/builtin/bash.d.ts +3 -5
- package/dist/tool-system/builtin/bash.js +10 -5
- package/dist/tool-system/builtin/browser-tools.d.ts +2 -2
- package/dist/tool-system/builtin/edit.d.ts +2 -1
- package/dist/tool-system/builtin/edit.js +12 -4
- package/dist/tool-system/builtin/generate-video.d.ts +4 -0
- package/dist/tool-system/builtin/generate-video.js +138 -21
- package/dist/tool-system/builtin/glob.d.ts +2 -1
- package/dist/tool-system/builtin/glob.js +28 -3
- package/dist/tool-system/builtin/grep.d.ts +1 -0
- package/dist/tool-system/builtin/grep.js +82 -17
- package/dist/tool-system/builtin/index.d.ts +25 -11
- package/dist/tool-system/builtin/index.js +45 -2
- package/dist/tool-system/builtin/video-providers.d.ts +12 -15
- package/dist/tool-system/builtin/video-providers.js +1 -0
- package/dist/tool-system/builtin/view-image.d.ts +2 -2
- package/dist/tool-system/builtin/web-fetch.js +44 -3
- package/dist/tool-system/builtin/worktree.js +25 -7
- package/dist/tool-system/builtin/write.d.ts +2 -1
- package/dist/tool-system/builtin/write.js +14 -4
- package/dist/tool-system/context.d.ts +17 -5
- package/dist/tool-system/executor.js +24 -8
- package/dist/tool-system/mcp-manager.d.ts +6 -0
- package/dist/tool-system/mcp-manager.js +55 -5
- package/dist/tool-system/path-policy.d.ts +19 -0
- package/dist/tool-system/path-policy.js +62 -1
- package/dist/tool-system/permission.d.ts +43 -3
- package/dist/tool-system/permission.js +383 -30
- package/dist/tool-system/registry.d.ts +3 -2
- package/dist/tool-system/registry.js +52 -34
- package/dist/types.d.ts +15 -0
- package/package.json +1 -1
|
@@ -6,6 +6,42 @@ import { logger as rootPermLogger } from "../logging/logger.js";
|
|
|
6
6
|
export interface ApprovalBackend {
|
|
7
7
|
requestApproval(request: ApprovalRequest): Promise<ApprovalResult>;
|
|
8
8
|
}
|
|
9
|
+
export interface ApprovalRouteTarget {
|
|
10
|
+
connectionId: string;
|
|
11
|
+
sessionId: string;
|
|
12
|
+
generation: number;
|
|
13
|
+
}
|
|
14
|
+
export interface ApprovalConnectionHandler {
|
|
15
|
+
requestApproval(request: ApprovalRequest, target: ApprovalRouteTarget): Promise<ApprovalResult>;
|
|
16
|
+
ownershipLost?(targets: ApprovalRouteTarget[], reason: string): void;
|
|
17
|
+
}
|
|
18
|
+
export type ApprovalRegistrationResult = {
|
|
19
|
+
ok: true;
|
|
20
|
+
target: ApprovalRouteTarget;
|
|
21
|
+
} | {
|
|
22
|
+
ok: false;
|
|
23
|
+
conflict: ApprovalRouteTarget;
|
|
24
|
+
};
|
|
25
|
+
/**
|
|
26
|
+
* Process-local connection owner router. Handlers are connection-scoped while
|
|
27
|
+
* session ownership is an explicit binding with a monotonic generation.
|
|
28
|
+
*/
|
|
29
|
+
export declare class ApprovalRouter {
|
|
30
|
+
private readonly handlers;
|
|
31
|
+
private readonly owners;
|
|
32
|
+
private readonly fallbackConnections;
|
|
33
|
+
private nextGeneration;
|
|
34
|
+
addConnection(connectionId: string, handler: ApprovalConnectionHandler, options?: {
|
|
35
|
+
claimUnownedSessions?: boolean;
|
|
36
|
+
}): () => void;
|
|
37
|
+
register(sessionId: string, connectionId: string): ApprovalRegistrationResult;
|
|
38
|
+
resolve(request: ApprovalRequest): Promise<ApprovalResult> | null;
|
|
39
|
+
matches(target: ApprovalRouteTarget): boolean;
|
|
40
|
+
current(sessionId: string): ApprovalRouteTarget | null;
|
|
41
|
+
release(sessionId: string, connectionId: string, reason?: string): void;
|
|
42
|
+
deregister(connectionId: string, reason?: string): void;
|
|
43
|
+
hasConnections(): boolean;
|
|
44
|
+
}
|
|
9
45
|
export declare class HeadlessApprovalBackend implements ApprovalBackend {
|
|
10
46
|
private readonly mode;
|
|
11
47
|
constructor(mode: "approve-all" | "deny-all" | "approve-read-only");
|
|
@@ -45,18 +81,22 @@ export declare const CLOSED_SESSION_TOMBSTONE_LIMIT = 4096;
|
|
|
45
81
|
* the next time the user opens the project.
|
|
46
82
|
*/
|
|
47
83
|
export declare class InteractiveApprovalBackend implements ApprovalBackend {
|
|
84
|
+
private readonly approvalRouter;
|
|
48
85
|
private sessionStateById;
|
|
49
86
|
private closedSessionIds;
|
|
50
87
|
private promptFn;
|
|
51
88
|
private legacyPromptTurn;
|
|
52
89
|
private legacyContext;
|
|
90
|
+
constructor(approvalRouter?: ApprovalRouter);
|
|
53
91
|
setPromptFn(fn: (request: ApprovalRequest) => Promise<ApprovalResult>): void;
|
|
92
|
+
clearPromptFn(): void;
|
|
54
93
|
/**
|
|
55
94
|
* Has someone installed a real prompt callback? Engine consults this
|
|
56
95
|
* to decide whether to use the interactive backend (= talk to a UI)
|
|
57
96
|
* or fall back to HeadlessApprovalBackend (= deny-all). Without it,
|
|
58
|
-
*
|
|
59
|
-
*
|
|
97
|
+
* a protocol host registers its connection with ApprovalRouter during boot
|
|
98
|
+
* but before the first session is bound — without this check Engine would
|
|
99
|
+
* still fall through to deny-all because
|
|
60
100
|
* the engine couldn't tell the difference. */
|
|
61
101
|
hasPromptFn(): boolean;
|
|
62
102
|
/** Inject the project root so persistence writes to the right settings file. */
|
|
@@ -107,10 +147,10 @@ export declare function pathRuleArgsPattern(absPath: string, scope: "file" | "di
|
|
|
107
147
|
* JSON round-trip where a persisted RegExp comes back as a string.
|
|
108
148
|
*/
|
|
109
149
|
export declare function ruleMatches(rule: PermissionRule, toolName: string, args: Record<string, unknown>): boolean;
|
|
150
|
+
export declare function getApprovalRouter(): ApprovalRouter;
|
|
110
151
|
export declare function getInteractiveApprovalBackend(): InteractiveApprovalBackend;
|
|
111
152
|
export declare function openInteractiveApprovalSession(sessionId: string): void;
|
|
112
153
|
export declare function clearInteractiveApprovalSession(sessionId: string): void;
|
|
113
|
-
export declare function setInteractiveApprovalFn(fn: (request: ApprovalRequest) => Promise<ApprovalResult>): void;
|
|
114
154
|
type BashSafetyLevel = "safe-read" | "safe-write" | "unsafe" | "dangerous";
|
|
115
155
|
/**
|
|
116
156
|
* True when an otherwise-safe-read shell segment must be downgraded to `unsafe`
|
|
@@ -6,6 +6,96 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync } from "
|
|
|
6
6
|
import { randomUUID } from "node:crypto";
|
|
7
7
|
import { logger as rootPermLogger } from "../logging/logger.js";
|
|
8
8
|
import { READ_ONLY_TOOLS } from "./plan-mode-allowlist.js";
|
|
9
|
+
/**
|
|
10
|
+
* Process-local connection owner router. Handlers are connection-scoped while
|
|
11
|
+
* session ownership is an explicit binding with a monotonic generation.
|
|
12
|
+
*/
|
|
13
|
+
export class ApprovalRouter {
|
|
14
|
+
handlers = new Map();
|
|
15
|
+
owners = new Map();
|
|
16
|
+
fallbackConnections = new Set();
|
|
17
|
+
nextGeneration = 1;
|
|
18
|
+
addConnection(connectionId, handler, options) {
|
|
19
|
+
if (!connectionId)
|
|
20
|
+
throw new Error("approval connectionId is required");
|
|
21
|
+
if (this.handlers.has(connectionId)) {
|
|
22
|
+
throw new Error(`approval connection already registered: ${connectionId}`);
|
|
23
|
+
}
|
|
24
|
+
this.handlers.set(connectionId, handler);
|
|
25
|
+
if (options?.claimUnownedSessions)
|
|
26
|
+
this.fallbackConnections.add(connectionId);
|
|
27
|
+
return () => this.deregister(connectionId);
|
|
28
|
+
}
|
|
29
|
+
register(sessionId, connectionId) {
|
|
30
|
+
if (!sessionId)
|
|
31
|
+
throw new Error("approval sessionId is required");
|
|
32
|
+
if (!this.handlers.has(connectionId)) {
|
|
33
|
+
throw new Error(`approval connection is not registered: ${connectionId}`);
|
|
34
|
+
}
|
|
35
|
+
const current = this.owners.get(sessionId);
|
|
36
|
+
if (current?.connectionId === connectionId)
|
|
37
|
+
return { ok: true, target: current };
|
|
38
|
+
if (current)
|
|
39
|
+
return { ok: false, conflict: current };
|
|
40
|
+
const target = { connectionId, sessionId, generation: this.nextGeneration++ };
|
|
41
|
+
this.owners.set(sessionId, target);
|
|
42
|
+
return { ok: true, target };
|
|
43
|
+
}
|
|
44
|
+
resolve(request) {
|
|
45
|
+
if (!request.sessionId)
|
|
46
|
+
return null;
|
|
47
|
+
let target = this.owners.get(request.sessionId);
|
|
48
|
+
if (!target && this.fallbackConnections.size === 1) {
|
|
49
|
+
const connectionId = this.fallbackConnections.values().next().value;
|
|
50
|
+
if (connectionId) {
|
|
51
|
+
const registration = this.register(request.sessionId, connectionId);
|
|
52
|
+
if (registration.ok)
|
|
53
|
+
target = registration.target;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
if (!target)
|
|
57
|
+
return null;
|
|
58
|
+
const handler = this.handlers.get(target.connectionId);
|
|
59
|
+
if (!handler) {
|
|
60
|
+
this.owners.delete(request.sessionId);
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
return handler.requestApproval(request, target);
|
|
64
|
+
}
|
|
65
|
+
matches(target) {
|
|
66
|
+
const current = this.owners.get(target.sessionId);
|
|
67
|
+
return (current?.connectionId === target.connectionId && current.generation === target.generation);
|
|
68
|
+
}
|
|
69
|
+
current(sessionId) {
|
|
70
|
+
return this.owners.get(sessionId) ?? null;
|
|
71
|
+
}
|
|
72
|
+
release(sessionId, connectionId, reason = "approval session released") {
|
|
73
|
+
const current = this.owners.get(sessionId);
|
|
74
|
+
if (!current || current.connectionId !== connectionId)
|
|
75
|
+
return;
|
|
76
|
+
this.owners.delete(sessionId);
|
|
77
|
+
this.handlers.get(connectionId)?.ownershipLost?.([current], reason);
|
|
78
|
+
}
|
|
79
|
+
deregister(connectionId, reason = "approval connection disconnected") {
|
|
80
|
+
const handler = this.handlers.get(connectionId);
|
|
81
|
+
if (!handler)
|
|
82
|
+
return;
|
|
83
|
+
this.handlers.delete(connectionId);
|
|
84
|
+
this.fallbackConnections.delete(connectionId);
|
|
85
|
+
const lost = [];
|
|
86
|
+
for (const [sessionId, target] of this.owners) {
|
|
87
|
+
if (target.connectionId !== connectionId)
|
|
88
|
+
continue;
|
|
89
|
+
this.owners.delete(sessionId);
|
|
90
|
+
lost.push(target);
|
|
91
|
+
}
|
|
92
|
+
if (lost.length > 0)
|
|
93
|
+
handler.ownershipLost?.(lost, reason);
|
|
94
|
+
}
|
|
95
|
+
hasConnections() {
|
|
96
|
+
return this.handlers.size > 0;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
9
99
|
export class HeadlessApprovalBackend {
|
|
10
100
|
mode;
|
|
11
101
|
constructor(mode) {
|
|
@@ -117,6 +207,7 @@ export const CLOSED_SESSION_TOMBSTONE_LIMIT = 4096;
|
|
|
117
207
|
* the next time the user opens the project.
|
|
118
208
|
*/
|
|
119
209
|
export class InteractiveApprovalBackend {
|
|
210
|
+
approvalRouter;
|
|
120
211
|
// Session-scoped grants are bucketed by ApprovalRequest.sessionId and then
|
|
121
212
|
// keyed on the OPERATION (tool + narrowed argsPattern via buildProjectRule),
|
|
122
213
|
// NOT just the tool name. Approving `git status` for one session must not
|
|
@@ -132,18 +223,25 @@ export class InteractiveApprovalBackend {
|
|
|
132
223
|
savedProjectRules: [],
|
|
133
224
|
onProjectRules: null,
|
|
134
225
|
};
|
|
226
|
+
constructor(approvalRouter = new ApprovalRouter()) {
|
|
227
|
+
this.approvalRouter = approvalRouter;
|
|
228
|
+
}
|
|
135
229
|
setPromptFn(fn) {
|
|
136
230
|
this.promptFn = fn;
|
|
137
231
|
}
|
|
232
|
+
clearPromptFn() {
|
|
233
|
+
this.promptFn = null;
|
|
234
|
+
}
|
|
138
235
|
/**
|
|
139
236
|
* Has someone installed a real prompt callback? Engine consults this
|
|
140
237
|
* to decide whether to use the interactive backend (= talk to a UI)
|
|
141
238
|
* or fall back to HeadlessApprovalBackend (= deny-all). Without it,
|
|
142
|
-
*
|
|
143
|
-
*
|
|
239
|
+
* a protocol host registers its connection with ApprovalRouter during boot
|
|
240
|
+
* but before the first session is bound — without this check Engine would
|
|
241
|
+
* still fall through to deny-all because
|
|
144
242
|
* the engine couldn't tell the difference. */
|
|
145
243
|
hasPromptFn() {
|
|
146
|
-
return this.promptFn !== null;
|
|
244
|
+
return this.promptFn !== null || this.approvalRouter.hasConnections();
|
|
147
245
|
}
|
|
148
246
|
/** Inject the project root so persistence writes to the right settings file. */
|
|
149
247
|
setCwd(cwd) {
|
|
@@ -220,7 +318,7 @@ export class InteractiveApprovalBackend {
|
|
|
220
318
|
const cached = state ? this.checkSessionRules(req, state) : null;
|
|
221
319
|
if (cached)
|
|
222
320
|
return cached;
|
|
223
|
-
if (!this.promptFn) {
|
|
321
|
+
if (!this.promptFn && !this.approvalRouter.hasConnections()) {
|
|
224
322
|
return { approved: false, reason: "interactive approval backend has no prompt function" };
|
|
225
323
|
}
|
|
226
324
|
// Serialize prompts and re-check rules when our turn comes (see promptTurn
|
|
@@ -261,10 +359,27 @@ export class InteractiveApprovalBackend {
|
|
|
261
359
|
}
|
|
262
360
|
/** The actual interactive ask + rule recording (runs inside the prompt turn). */
|
|
263
361
|
async promptAndRecord(req, state) {
|
|
264
|
-
|
|
362
|
+
const hasOwner = !!req.sessionId && this.approvalRouter.current(req.sessionId) !== null;
|
|
363
|
+
const routed = hasOwner ? this.approvalRouter.resolve(req) : null;
|
|
364
|
+
let result;
|
|
365
|
+
if (routed) {
|
|
366
|
+
result = await routed;
|
|
367
|
+
}
|
|
368
|
+
else if (this.promptFn) {
|
|
369
|
+
result = await this.promptFn(req);
|
|
370
|
+
}
|
|
371
|
+
else if (req.sessionId && this.approvalRouter.hasConnections()) {
|
|
372
|
+
const fallback = this.approvalRouter.resolve(req);
|
|
373
|
+
result = fallback
|
|
374
|
+
? await fallback
|
|
375
|
+
: {
|
|
376
|
+
approved: false,
|
|
377
|
+
reason: `no approval connection owns session ${req.sessionId}`,
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
else {
|
|
265
381
|
return { approved: false, reason: "interactive approval backend has no prompt function" };
|
|
266
382
|
}
|
|
267
|
-
const result = await this.promptFn(req);
|
|
268
383
|
const scope = result.scope ?? (result.always ? "session" : "once");
|
|
269
384
|
const activeState = this.isActiveSessionState(req.sessionId, state) ? state : null;
|
|
270
385
|
const context = activeState ?? (!req.sessionId ? this.legacyContext : null);
|
|
@@ -521,11 +636,15 @@ function persistProjectRule(cwd, rule) {
|
|
|
521
636
|
writeFileSync(tmp, JSON.stringify(settings, null, 2) + "\n", "utf-8");
|
|
522
637
|
renameSync(tmp, file);
|
|
523
638
|
}
|
|
524
|
-
// Singleton interactive backend
|
|
639
|
+
// Singleton interactive backend and its multi-connection owner router.
|
|
640
|
+
const _approvalRouter = new ApprovalRouter();
|
|
525
641
|
let _interactiveBackend = null;
|
|
642
|
+
export function getApprovalRouter() {
|
|
643
|
+
return _approvalRouter;
|
|
644
|
+
}
|
|
526
645
|
export function getInteractiveApprovalBackend() {
|
|
527
646
|
if (!_interactiveBackend) {
|
|
528
|
-
_interactiveBackend = new InteractiveApprovalBackend();
|
|
647
|
+
_interactiveBackend = new InteractiveApprovalBackend(_approvalRouter);
|
|
529
648
|
}
|
|
530
649
|
return _interactiveBackend;
|
|
531
650
|
}
|
|
@@ -535,9 +654,6 @@ export function openInteractiveApprovalSession(sessionId) {
|
|
|
535
654
|
export function clearInteractiveApprovalSession(sessionId) {
|
|
536
655
|
getInteractiveApprovalBackend().clearSession(sessionId);
|
|
537
656
|
}
|
|
538
|
-
export function setInteractiveApprovalFn(fn) {
|
|
539
|
-
getInteractiveApprovalBackend().setPromptFn(fn);
|
|
540
|
-
}
|
|
541
657
|
const DANGEROUS_PATTERNS = [
|
|
542
658
|
/rm\s+-rf/,
|
|
543
659
|
/rm\s+-r\s+\//,
|
|
@@ -570,9 +686,6 @@ const SAFE_READ_PATTERNS = [
|
|
|
570
686
|
// are NOT classified safe-read. Plain predicates (-name, -type, -print) stay.
|
|
571
687
|
/^find\s(?!.*\s-(delete|exec(dir)?|ok(dir)?|fprintf?|fls)\b)/,
|
|
572
688
|
/^(grep|rg|ag|ack|fgrep|egrep)\s/,
|
|
573
|
-
// Word-boundary the git read subcommands so `git difftool -x <cmd>` (which
|
|
574
|
-
// runs an arbitrary external command) does NOT match on the `diff` branch.
|
|
575
|
-
/^(git\s+(status|log|diff|branch|show|blame|remote|tag|stash\s+list|rev-parse|describe))\b/,
|
|
576
689
|
/^(node|python|ruby|go|rustc|java|javac)\s+--version/,
|
|
577
690
|
/^(npm|pnpm|yarn|cargo|pip|gem|brew)\s+(list|ls|info|show|view|outdated|audit)/,
|
|
578
691
|
/^pwd$/,
|
|
@@ -583,6 +696,151 @@ const SAFE_READ_PATTERNS = [
|
|
|
583
696
|
/^env$/,
|
|
584
697
|
/^printenv/,
|
|
585
698
|
];
|
|
699
|
+
/** Split a command conservatively enough to validate git's argument shape. */
|
|
700
|
+
function splitShellWords(input) {
|
|
701
|
+
const words = [];
|
|
702
|
+
let word = "";
|
|
703
|
+
let quote = null;
|
|
704
|
+
let hasWord = false;
|
|
705
|
+
for (let i = 0; i < input.length; i++) {
|
|
706
|
+
const ch = input[i];
|
|
707
|
+
if (quote === null && /\s/.test(ch)) {
|
|
708
|
+
if (hasWord) {
|
|
709
|
+
words.push(word);
|
|
710
|
+
word = "";
|
|
711
|
+
hasWord = false;
|
|
712
|
+
}
|
|
713
|
+
continue;
|
|
714
|
+
}
|
|
715
|
+
if (ch === "\\" && quote !== "'") {
|
|
716
|
+
const next = input[++i];
|
|
717
|
+
if (next === undefined)
|
|
718
|
+
return null;
|
|
719
|
+
word += next;
|
|
720
|
+
hasWord = true;
|
|
721
|
+
continue;
|
|
722
|
+
}
|
|
723
|
+
// Single quotes make expansion syntax literal. Outside them, reject all
|
|
724
|
+
// parameter/command expansion rather than let expansion change a vetted
|
|
725
|
+
// git argument into an unsafe option or action at execution time.
|
|
726
|
+
if (quote !== "'" && (ch === "$" || ch === "`"))
|
|
727
|
+
return null;
|
|
728
|
+
if (ch === "'" || ch === '"') {
|
|
729
|
+
if (quote === null) {
|
|
730
|
+
quote = ch;
|
|
731
|
+
hasWord = true;
|
|
732
|
+
continue;
|
|
733
|
+
}
|
|
734
|
+
if (quote === ch) {
|
|
735
|
+
quote = null;
|
|
736
|
+
continue;
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
word += ch;
|
|
740
|
+
hasWord = true;
|
|
741
|
+
}
|
|
742
|
+
if (quote !== null)
|
|
743
|
+
return null;
|
|
744
|
+
if (hasWord)
|
|
745
|
+
words.push(word);
|
|
746
|
+
return words;
|
|
747
|
+
}
|
|
748
|
+
const GIT_LIST_STANDALONE_OPTIONS = new Set([
|
|
749
|
+
"--all",
|
|
750
|
+
"--color",
|
|
751
|
+
"--column",
|
|
752
|
+
"--ignore-case",
|
|
753
|
+
"--no-abbrev",
|
|
754
|
+
"--no-color",
|
|
755
|
+
"--no-column",
|
|
756
|
+
"--omit-empty",
|
|
757
|
+
"--remotes",
|
|
758
|
+
"--show-current",
|
|
759
|
+
"--verbose",
|
|
760
|
+
"-a",
|
|
761
|
+
"-r",
|
|
762
|
+
"-v",
|
|
763
|
+
"-vv",
|
|
764
|
+
]);
|
|
765
|
+
const GIT_LIST_VALUE_OPTIONS = new Set([
|
|
766
|
+
"--contains",
|
|
767
|
+
"--format",
|
|
768
|
+
"--merged",
|
|
769
|
+
"--no-contains",
|
|
770
|
+
"--no-merged",
|
|
771
|
+
"--points-at",
|
|
772
|
+
"--sort",
|
|
773
|
+
]);
|
|
774
|
+
const GIT_SAFE_GLOBAL_OPTIONS = new Set(["--no-pager", "--no-optional-locks"]);
|
|
775
|
+
function isSafeGitListArgs(args) {
|
|
776
|
+
let listMode = false;
|
|
777
|
+
let positionalOnly = false;
|
|
778
|
+
for (let i = 0; i < args.length; i++) {
|
|
779
|
+
const arg = args[i];
|
|
780
|
+
if (positionalOnly)
|
|
781
|
+
continue;
|
|
782
|
+
if (arg === "--") {
|
|
783
|
+
if (!listMode)
|
|
784
|
+
return false;
|
|
785
|
+
positionalOnly = true;
|
|
786
|
+
continue;
|
|
787
|
+
}
|
|
788
|
+
if (arg === "-l" || arg === "--list") {
|
|
789
|
+
listMode = true;
|
|
790
|
+
continue;
|
|
791
|
+
}
|
|
792
|
+
if (GIT_LIST_STANDALONE_OPTIONS.has(arg))
|
|
793
|
+
continue;
|
|
794
|
+
if ([...GIT_LIST_STANDALONE_OPTIONS, ...GIT_LIST_VALUE_OPTIONS].some((option) => arg.startsWith(`${option}=`))) {
|
|
795
|
+
continue;
|
|
796
|
+
}
|
|
797
|
+
if (GIT_LIST_VALUE_OPTIONS.has(arg)) {
|
|
798
|
+
if (++i >= args.length)
|
|
799
|
+
return false;
|
|
800
|
+
continue;
|
|
801
|
+
}
|
|
802
|
+
if (listMode && !arg.startsWith("-"))
|
|
803
|
+
continue;
|
|
804
|
+
return false;
|
|
805
|
+
}
|
|
806
|
+
return true;
|
|
807
|
+
}
|
|
808
|
+
/**
|
|
809
|
+
* Git is safe-read only for an explicit read subcommand and a read-only
|
|
810
|
+
* argument shape. In particular, branch/tag positional creation and all
|
|
811
|
+
* remote sub-actions fail closed instead of inheriting safety from the
|
|
812
|
+
* subcommand name.
|
|
813
|
+
*/
|
|
814
|
+
function isSafeReadGitCommand(segment) {
|
|
815
|
+
const words = splitShellWords(segment.trim());
|
|
816
|
+
if (!words || words[0] !== "git")
|
|
817
|
+
return false;
|
|
818
|
+
let subcommandIndex = 1;
|
|
819
|
+
while (GIT_SAFE_GLOBAL_OPTIONS.has(words[subcommandIndex]))
|
|
820
|
+
subcommandIndex++;
|
|
821
|
+
const subcommand = words[subcommandIndex];
|
|
822
|
+
const args = words.slice(subcommandIndex + 1);
|
|
823
|
+
const hasWriteOrExecutionOption = (gitArgs) => gitArgs.some((arg) => arg === "--ext-diff" ||
|
|
824
|
+
arg === "--textconv" ||
|
|
825
|
+
arg === "--output" ||
|
|
826
|
+
arg.startsWith("--output="));
|
|
827
|
+
if (["status", "log", "diff", "show", "blame", "rev-parse", "describe"].includes(subcommand)) {
|
|
828
|
+
return !hasWriteOrExecutionOption(args);
|
|
829
|
+
}
|
|
830
|
+
if (subcommand === "stash") {
|
|
831
|
+
return args[0] === "list" && !hasWriteOrExecutionOption(args.slice(1));
|
|
832
|
+
}
|
|
833
|
+
if (subcommand === "branch" || subcommand === "tag")
|
|
834
|
+
return isSafeGitListArgs(args);
|
|
835
|
+
if (subcommand === "remote") {
|
|
836
|
+
return args.every((arg) => arg === "-v" || arg === "--verbose");
|
|
837
|
+
}
|
|
838
|
+
return false;
|
|
839
|
+
}
|
|
840
|
+
function matchesSafeReadPattern(segment, allowArgumentless = false) {
|
|
841
|
+
return (isSafeReadGitCommand(segment) ||
|
|
842
|
+
SAFE_READ_PATTERNS.some((pattern) => pattern.test(segment) || (allowArgumentless && pattern.test(`${segment} `))));
|
|
843
|
+
}
|
|
586
844
|
// ─── Sensitive safe-read downgrade ────────────────────────────────
|
|
587
845
|
//
|
|
588
846
|
// `Read ~/.ssh/id_rsa` routes through path-policy (asks); the equivalent
|
|
@@ -643,17 +901,61 @@ const SAFE_WRITE_PATTERNS = [
|
|
|
643
901
|
// - top-level `;`, `&&`, `||`, newline split the command into
|
|
644
902
|
// segments. Every segment must independently classify as safe
|
|
645
903
|
// for the whole command to be safe.
|
|
646
|
-
// -
|
|
904
|
+
// - command substitution (` ` ` or `$(` ) outside single quotes, redirection
|
|
647
905
|
// (`>` `>>` `<` `<<`), process substitution (`<(` `>(`), and
|
|
648
906
|
// pipe-to-shell (`| sh`, `| bash`, ...) flag the command as
|
|
649
907
|
// dangerous.
|
|
650
|
-
// -
|
|
651
|
-
//
|
|
908
|
+
// - single quotes make command-substitution syntax literal; double quotes
|
|
909
|
+
// do not. Other quoted and backslash-escaped metacharacters are ignored —
|
|
910
|
+
// `echo "a; b"` is a single segment.
|
|
652
911
|
//
|
|
653
912
|
// This is intentionally detection-only, not a full shell parser. We
|
|
654
913
|
// only need to find unquoted metacharacters and split safely.
|
|
655
|
-
const
|
|
914
|
+
const PIPE_SHELL_COMMAND_RE = /^(sh|bash|zsh|dash|ksh|fish|python|python3|node|nodejs|ruby|perl|php)\b/;
|
|
915
|
+
/** Apply Bash line continuation before inspecting shell syntax. */
|
|
916
|
+
function normalizeShellLineContinuations(input) {
|
|
917
|
+
let normalized = "";
|
|
918
|
+
let quote = null;
|
|
919
|
+
for (let i = 0; i < input.length; i++) {
|
|
920
|
+
const ch = input[i];
|
|
921
|
+
const next = input[i + 1];
|
|
922
|
+
// Backslash-newline remains literal inside single quotes.
|
|
923
|
+
if (quote === "'") {
|
|
924
|
+
if (ch === quote)
|
|
925
|
+
quote = null;
|
|
926
|
+
normalized += ch;
|
|
927
|
+
continue;
|
|
928
|
+
}
|
|
929
|
+
if (ch === "\\") {
|
|
930
|
+
if (next === "\n") {
|
|
931
|
+
i++;
|
|
932
|
+
continue;
|
|
933
|
+
}
|
|
934
|
+
if (next === "\r" && input[i + 2] === "\n") {
|
|
935
|
+
i += 2;
|
|
936
|
+
continue;
|
|
937
|
+
}
|
|
938
|
+
// Preserve other escapes verbatim and do not treat an escaped quote as
|
|
939
|
+
// changing the quote state used by this normalization pass.
|
|
940
|
+
normalized += ch;
|
|
941
|
+
if (next !== undefined) {
|
|
942
|
+
normalized += next;
|
|
943
|
+
i++;
|
|
944
|
+
}
|
|
945
|
+
continue;
|
|
946
|
+
}
|
|
947
|
+
if (ch === "'" || ch === '"') {
|
|
948
|
+
if (quote === null)
|
|
949
|
+
quote = ch;
|
|
950
|
+
else if (quote === ch)
|
|
951
|
+
quote = null;
|
|
952
|
+
}
|
|
953
|
+
normalized += ch;
|
|
954
|
+
}
|
|
955
|
+
return normalized;
|
|
956
|
+
}
|
|
656
957
|
function scanShellCommand(input) {
|
|
958
|
+
const normalizedInput = normalizeShellLineContinuations(input);
|
|
657
959
|
const segments = [];
|
|
658
960
|
let buf = "";
|
|
659
961
|
let dangerous = false;
|
|
@@ -668,9 +970,9 @@ function scanShellCommand(input) {
|
|
|
668
970
|
segments.push(s);
|
|
669
971
|
buf = "";
|
|
670
972
|
};
|
|
671
|
-
while (i <
|
|
672
|
-
const ch =
|
|
673
|
-
const next =
|
|
973
|
+
while (i < normalizedInput.length) {
|
|
974
|
+
const ch = normalizedInput[i];
|
|
975
|
+
const next = normalizedInput[i + 1];
|
|
674
976
|
if (quote === null) {
|
|
675
977
|
// Escape: skip the next character
|
|
676
978
|
if (ch === "\\" && next !== undefined) {
|
|
@@ -727,12 +1029,28 @@ function scanShellCommand(input) {
|
|
|
727
1029
|
i++;
|
|
728
1030
|
continue;
|
|
729
1031
|
}
|
|
730
|
-
// Inside
|
|
1032
|
+
// Inside single quotes, every character other than the closing quote is
|
|
1033
|
+
// literal. In particular, `$(` and backticks do not execute.
|
|
1034
|
+
if (quote === "'") {
|
|
1035
|
+
if (ch === quote)
|
|
1036
|
+
quote = null;
|
|
1037
|
+
buf += ch;
|
|
1038
|
+
i++;
|
|
1039
|
+
continue;
|
|
1040
|
+
}
|
|
1041
|
+
// Inside double quotes, escapes still suppress substitution syntax, but
|
|
1042
|
+
// unescaped `$(` and backticks execute and must fail closed.
|
|
731
1043
|
if (ch === "\\" && next !== undefined) {
|
|
732
1044
|
buf += ch + next;
|
|
733
1045
|
i += 2;
|
|
734
1046
|
continue;
|
|
735
1047
|
}
|
|
1048
|
+
if (ch === "`" || (ch === "$" && next === "(")) {
|
|
1049
|
+
dangerous = true;
|
|
1050
|
+
buf += ch;
|
|
1051
|
+
i++;
|
|
1052
|
+
continue;
|
|
1053
|
+
}
|
|
736
1054
|
if (ch === quote) {
|
|
737
1055
|
quote = null;
|
|
738
1056
|
buf += ch;
|
|
@@ -751,6 +1069,37 @@ function scanShellCommand(input) {
|
|
|
751
1069
|
dangerous = true;
|
|
752
1070
|
return { segments, dangerous };
|
|
753
1071
|
}
|
|
1072
|
+
/** Split only on real shell pipes, preserving pipes inside quotes or escapes. */
|
|
1073
|
+
function splitUnquotedPipes(input) {
|
|
1074
|
+
const parts = [];
|
|
1075
|
+
let buf = "";
|
|
1076
|
+
let quote = null;
|
|
1077
|
+
for (let i = 0; i < input.length; i++) {
|
|
1078
|
+
const ch = input[i];
|
|
1079
|
+
const next = input[i + 1];
|
|
1080
|
+
if (ch === "\\" && quote !== "'" && next !== undefined) {
|
|
1081
|
+
buf += ch + next;
|
|
1082
|
+
i++;
|
|
1083
|
+
continue;
|
|
1084
|
+
}
|
|
1085
|
+
if (ch === "'" || ch === '"') {
|
|
1086
|
+
if (quote === null)
|
|
1087
|
+
quote = ch;
|
|
1088
|
+
else if (quote === ch)
|
|
1089
|
+
quote = null;
|
|
1090
|
+
buf += ch;
|
|
1091
|
+
continue;
|
|
1092
|
+
}
|
|
1093
|
+
if (ch === "|" && quote === null) {
|
|
1094
|
+
parts.push(buf.trim());
|
|
1095
|
+
buf = "";
|
|
1096
|
+
continue;
|
|
1097
|
+
}
|
|
1098
|
+
buf += ch;
|
|
1099
|
+
}
|
|
1100
|
+
parts.push(buf.trim());
|
|
1101
|
+
return parts;
|
|
1102
|
+
}
|
|
754
1103
|
function classifySegment(segment) {
|
|
755
1104
|
if (DANGEROUS_PATTERNS.some((p) => p.test(segment)))
|
|
756
1105
|
return "dangerous";
|
|
@@ -762,13 +1111,15 @@ function classifySegment(segment) {
|
|
|
762
1111
|
// `| nc ...` exfil tail — declaring a piped-to-network command safe-read.
|
|
763
1112
|
// We did not split on `|` in the scanner because pipes are not statement
|
|
764
1113
|
// boundaries; they're per-segment data flow.
|
|
765
|
-
|
|
1114
|
+
const parts = splitUnquotedPipes(segment);
|
|
1115
|
+
if (parts.length > 1) {
|
|
766
1116
|
// A pipe part may be an argument-less command (`ls`, `pwd`) whose trailing
|
|
767
1117
|
// space was stripped along with the `|`. Test each part both as-is (for
|
|
768
1118
|
// `$`-anchored patterns like /^pwd$/) and with a trailing space appended
|
|
769
1119
|
// (for `\s`-delimited patterns like /^ls\s/), so neither form is missed.
|
|
770
|
-
|
|
771
|
-
|
|
1120
|
+
if (parts.slice(1).some((part) => PIPE_SHELL_COMMAND_RE.test(part)))
|
|
1121
|
+
return "dangerous";
|
|
1122
|
+
const partIsSafeRead = (p) => matchesSafeReadPattern(p, true);
|
|
772
1123
|
if (parts.every(partIsSafeRead)) {
|
|
773
1124
|
// A sensitive read anywhere in the pipe (`cat ~/.ssh/id_rsa | base64`,
|
|
774
1125
|
// `env | grep KEY`) downgrades the whole pipe.
|
|
@@ -778,7 +1129,7 @@ function classifySegment(segment) {
|
|
|
778
1129
|
}
|
|
779
1130
|
return "unsafe";
|
|
780
1131
|
}
|
|
781
|
-
if (
|
|
1132
|
+
if (matchesSafeReadPattern(segment)) {
|
|
782
1133
|
return segmentIsSensitiveRead(segment) ? "unsafe" : "safe-read";
|
|
783
1134
|
}
|
|
784
1135
|
if (SAFE_WRITE_PATTERNS.some((p) => p.test(segment)))
|
|
@@ -803,8 +1154,6 @@ export function classifyBashCommand(command) {
|
|
|
803
1154
|
// separators we would split on.
|
|
804
1155
|
if (DANGEROUS_PATTERNS.some((p) => p.test(trimmed)))
|
|
805
1156
|
return "dangerous";
|
|
806
|
-
if (PIPE_TO_SHELL_RE.test(trimmed))
|
|
807
|
-
return "dangerous";
|
|
808
1157
|
const scan = scanShellCommand(trimmed);
|
|
809
1158
|
if (scan.dangerous)
|
|
810
1159
|
return "dangerous";
|
|
@@ -919,12 +1268,16 @@ export class PermissionClassifier {
|
|
|
919
1268
|
// YOLO classifier for Bash commands
|
|
920
1269
|
if (toolName === "Bash") {
|
|
921
1270
|
const level = classifyBashCommand(String(args.command ?? ""));
|
|
1271
|
+
// acceptEdits opts into workspace edits through the tool allowlist below;
|
|
1272
|
+
// it never opts into unattended shell execution. Explicit rules were
|
|
1273
|
+
// already honored above, and bypassPermissions returned before them.
|
|
1274
|
+
if (this.defaultMode === "acceptEdits")
|
|
1275
|
+
return "ask";
|
|
922
1276
|
if (level === "dangerous")
|
|
923
1277
|
return "ask";
|
|
924
1278
|
if (level === "safe-read")
|
|
925
1279
|
return "allow";
|
|
926
|
-
if (level === "safe-write" &&
|
|
927
|
-
(this.defaultMode === "acceptEdits" || this.defaultMode === "auto")) {
|
|
1280
|
+
if (level === "safe-write" && this.defaultMode === "auto") {
|
|
928
1281
|
return "allow";
|
|
929
1282
|
}
|
|
930
1283
|
if (level === "unsafe")
|
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
* Unified tool registry — built-in tools + MCP tools.
|
|
3
3
|
*/
|
|
4
4
|
import type { RegisteredTool, ToolDefinition, ToolResult } from "../types.js";
|
|
5
|
-
import { type BuiltinToolFn } from "./builtin/index.js";
|
|
6
5
|
import type { ToolContext } from "./context.js";
|
|
7
6
|
/**
|
|
8
7
|
* Default execution timeout for any tool that does not declare its own
|
|
@@ -13,12 +12,13 @@ export declare const DEFAULT_TOOL_TIMEOUT_MS = 120000;
|
|
|
13
12
|
export interface ToolRegistryOptions {
|
|
14
13
|
builtinTools?: readonly string[];
|
|
15
14
|
}
|
|
15
|
+
type ToolImplementation = (args: Record<string, unknown>, ctx?: ToolContext) => Promise<unknown>;
|
|
16
16
|
export declare class ToolRegistry {
|
|
17
17
|
private tools;
|
|
18
18
|
private builtinExecutors;
|
|
19
19
|
constructor(options?: ToolRegistryOptions);
|
|
20
20
|
private registerBuiltins;
|
|
21
|
-
registerTool(tool: RegisteredTool, executor?:
|
|
21
|
+
registerTool(tool: RegisteredTool, executor?: ToolImplementation): void;
|
|
22
22
|
unregisterTool(name: string): void;
|
|
23
23
|
getToolDefinitions(): ToolDefinition[];
|
|
24
24
|
getTool(name: string): RegisteredTool | undefined;
|
|
@@ -31,3 +31,4 @@ export declare class ToolRegistry {
|
|
|
31
31
|
listTools(): string[];
|
|
32
32
|
listToolsDetailed(): RegisteredTool[];
|
|
33
33
|
}
|
|
34
|
+
export {};
|