@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.
Files changed (118) hide show
  1. package/dist/arena/arena.d.ts +2 -0
  2. package/dist/arena/arena.js +30 -1
  3. package/dist/arena/phases/adjudication.d.ts +2 -1
  4. package/dist/arena/phases/adjudication.js +2 -1
  5. package/dist/arena/phases/build-consensus.d.ts +2 -1
  6. package/dist/arena/phases/build-consensus.js +3 -1
  7. package/dist/arena/phases/cross-review.d.ts +3 -1
  8. package/dist/arena/phases/cross-review.js +8 -2
  9. package/dist/arena/phases/debate-rounds.d.ts +2 -1
  10. package/dist/arena/phases/debate-rounds.js +4 -2
  11. package/dist/arena/phases/participant-research.d.ts +2 -1
  12. package/dist/arena/phases/participant-research.js +3 -1
  13. package/dist/arena/phases/planning-detail-expansion.d.ts +2 -1
  14. package/dist/arena/phases/planning-detail-expansion.js +2 -1
  15. package/dist/arena/planner.d.ts +2 -1
  16. package/dist/arena/planner.js +2 -1
  17. package/dist/arena/types.d.ts +5 -1
  18. package/dist/cli/agent-server-tcp.js +13 -2
  19. package/dist/context/manager.d.ts +3 -3
  20. package/dist/context/manager.js +6 -6
  21. package/dist/context/token-counter.js +5 -3
  22. package/dist/context/tool-result-storage.d.ts +6 -0
  23. package/dist/context/tool-result-storage.js +25 -4
  24. package/dist/credentials/access.d.ts +10 -1
  25. package/dist/credentials/access.js +76 -2
  26. package/dist/credentials/index.d.ts +3 -2
  27. package/dist/credentials/index.js +2 -1
  28. package/dist/credentials/oauth.d.ts +6 -1
  29. package/dist/credentials/oauth.js +66 -1
  30. package/dist/credentials/store.d.ts +1 -1
  31. package/dist/credentials/store.js +16 -7
  32. package/dist/credentials/types.d.ts +38 -1
  33. package/dist/credentials/types.js +16 -1
  34. package/dist/engine/engine.d.ts +61 -32
  35. package/dist/engine/engine.js +289 -55
  36. package/dist/engine/goal.d.ts +5 -3
  37. package/dist/engine/session-title.d.ts +2 -1
  38. package/dist/engine/session-title.js +4 -1
  39. package/dist/engine/turn-loop.d.ts +18 -0
  40. package/dist/engine/turn-loop.js +202 -27
  41. package/dist/engine/types.d.ts +6 -2
  42. package/dist/git/worktree/crud.d.ts +3 -0
  43. package/dist/git/worktree/crud.js +32 -3
  44. package/dist/git/worktree/git-exec.d.ts +2 -2
  45. package/dist/git/worktree/git-exec.js +47 -11
  46. package/dist/git/worktree/query.d.ts +8 -7
  47. package/dist/git/worktree/query.js +27 -20
  48. package/dist/hooks/events.d.ts +3 -0
  49. package/dist/hooks/events.js +0 -3
  50. package/dist/hooks/goal-stop-hook.d.ts +14 -4
  51. package/dist/hooks/goal-stop-hook.js +626 -71
  52. package/dist/hooks/registry.js +3 -0
  53. package/dist/hooks/shell-runner.d.ts +12 -1
  54. package/dist/hooks/shell-runner.js +160 -9
  55. package/dist/index.d.ts +7 -6
  56. package/dist/index.js +6 -5
  57. package/dist/llm/client-base.js +12 -10
  58. package/dist/llm/types.d.ts +12 -5
  59. package/dist/plugins/pluginCommandHook.d.ts +4 -4
  60. package/dist/plugins/pluginCommandHook.js +111 -13
  61. package/dist/protocol/chat-session-manager.d.ts +13 -2
  62. package/dist/protocol/chat-session-manager.js +90 -18
  63. package/dist/protocol/chat-session.d.ts +12 -0
  64. package/dist/protocol/chat-session.js +30 -5
  65. package/dist/protocol/client.d.ts +5 -2
  66. package/dist/protocol/client.js +22 -1
  67. package/dist/protocol/server.d.ts +25 -11
  68. package/dist/protocol/server.js +290 -72
  69. package/dist/protocol/types.d.ts +34 -2
  70. package/dist/protocol/types.js +2 -0
  71. package/dist/services/dream-consolidation.d.ts +3 -0
  72. package/dist/services/dream-consolidation.js +4 -1
  73. package/dist/services/index.d.ts +1 -1
  74. package/dist/services/index.js +1 -1
  75. package/dist/services/oauth.d.ts +34 -10
  76. package/dist/services/oauth.js +233 -98
  77. package/dist/session/session-manager.d.ts +35 -6
  78. package/dist/session/session-manager.js +362 -26
  79. package/dist/session/transcript.d.ts +30 -1
  80. package/dist/session/transcript.js +119 -4
  81. package/dist/tool-system/builtin/agent.js +5 -1
  82. package/dist/tool-system/builtin/arena.js +1 -0
  83. package/dist/tool-system/builtin/background-jobs.d.ts +8 -5
  84. package/dist/tool-system/builtin/background-jobs.js +15 -11
  85. package/dist/tool-system/builtin/background-work.d.ts +6 -1
  86. package/dist/tool-system/builtin/background-work.js +5 -1
  87. package/dist/tool-system/builtin/bash.d.ts +3 -5
  88. package/dist/tool-system/builtin/bash.js +10 -5
  89. package/dist/tool-system/builtin/browser-tools.d.ts +2 -2
  90. package/dist/tool-system/builtin/edit.d.ts +2 -1
  91. package/dist/tool-system/builtin/edit.js +12 -4
  92. package/dist/tool-system/builtin/generate-video.d.ts +4 -0
  93. package/dist/tool-system/builtin/generate-video.js +138 -21
  94. package/dist/tool-system/builtin/glob.d.ts +2 -1
  95. package/dist/tool-system/builtin/glob.js +28 -3
  96. package/dist/tool-system/builtin/grep.d.ts +1 -0
  97. package/dist/tool-system/builtin/grep.js +82 -17
  98. package/dist/tool-system/builtin/index.d.ts +25 -11
  99. package/dist/tool-system/builtin/index.js +45 -2
  100. package/dist/tool-system/builtin/video-providers.d.ts +12 -15
  101. package/dist/tool-system/builtin/video-providers.js +1 -0
  102. package/dist/tool-system/builtin/view-image.d.ts +2 -2
  103. package/dist/tool-system/builtin/web-fetch.js +44 -3
  104. package/dist/tool-system/builtin/worktree.js +25 -7
  105. package/dist/tool-system/builtin/write.d.ts +2 -1
  106. package/dist/tool-system/builtin/write.js +14 -4
  107. package/dist/tool-system/context.d.ts +17 -5
  108. package/dist/tool-system/executor.js +24 -8
  109. package/dist/tool-system/mcp-manager.d.ts +6 -0
  110. package/dist/tool-system/mcp-manager.js +55 -5
  111. package/dist/tool-system/path-policy.d.ts +19 -0
  112. package/dist/tool-system/path-policy.js +62 -1
  113. package/dist/tool-system/permission.d.ts +43 -3
  114. package/dist/tool-system/permission.js +383 -30
  115. package/dist/tool-system/registry.d.ts +3 -2
  116. package/dist/tool-system/registry.js +52 -34
  117. package/dist/types.d.ts +15 -0
  118. 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
- * the agent-server-stdio entry which wires setInteractiveApprovalFn
59
- * during server boot would still fall through to deny-all because
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
- * the agent-server-stdio entry which wires setInteractiveApprovalFn
143
- * during server boot would still fall through to deny-all because
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
- if (!this.promptFn) {
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 for use by the UI
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
- // - unquoted command substitution (` ` ` or `$(` ), redirection
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
- // - quoted (`'…'`, `"…"`) and backslash-escaped characters are
651
- // ignored `echo "a; b"` is a single segment.
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 PIPE_TO_SHELL_RE = /\|\s*(sh|bash|zsh|dash|ksh|fish|python|python3|node|nodejs|ruby|perl|php)\b/;
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 < input.length) {
672
- const ch = input[i];
673
- const next = input[i + 1];
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 a quote
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
- if (segment.includes("|")) {
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
- const parts = segment.split("|").map((p) => p.trim());
771
- const partIsSafeRead = (p) => SAFE_READ_PATTERNS.some((re) => re.test(p) || re.test(`${p} `));
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 (SAFE_READ_PATTERNS.some((p) => p.test(segment))) {
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?: BuiltinToolFn): void;
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 {};