mixdog 0.9.34 → 0.9.36

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 (71) hide show
  1. package/README.md +6 -0
  2. package/package.json +4 -2
  3. package/scripts/_devtools-stub.mjs +1 -0
  4. package/scripts/_jitter-fuzz.jsx +42 -0
  5. package/scripts/_jitter-fuzz.mjs +44410 -0
  6. package/scripts/_jitter-fuzz2.jsx +30 -0
  7. package/scripts/_jitter-fuzz2.mjs +44400 -0
  8. package/scripts/_jitter-probe.jsx +35 -0
  9. package/scripts/_jitter-probe.mjs +44397 -0
  10. package/scripts/_jp2.jsx +16 -0
  11. package/scripts/_jp2.mjs +45614 -0
  12. package/scripts/agent-live-arg-guard-smoke.mjs +20 -0
  13. package/scripts/agent-live-path-suffix-smoke.mjs +34 -0
  14. package/scripts/agent-live-toolcall-args-smoke.mjs +45 -0
  15. package/scripts/apply-patch-edit-smoke.mjs +71 -0
  16. package/scripts/async-notify-settlement-test.mjs +99 -0
  17. package/scripts/forwarder-rebind-tail-test.mjs +67 -0
  18. package/scripts/live-worker-smoke.mjs +1 -1
  19. package/scripts/pending-completion-drop-test.mjs +46 -27
  20. package/scripts/provider-toolcall-test.mjs +1 -0
  21. package/scripts/recall-bench-cases.json +1 -0
  22. package/scripts/session-bench.mjs +44 -0
  23. package/scripts/shell-hardening-test.mjs +209 -0
  24. package/scripts/ship-mode-test.mjs +98 -0
  25. package/scripts/steering-drain-buckets-test.mjs +59 -0
  26. package/scripts/tool-smoke.mjs +46 -10
  27. package/scripts/worker-notify-rejection-test.mjs +51 -0
  28. package/src/lib/mixdog-debug.cjs +69 -0
  29. package/src/rules/agent/00-common.md +4 -5
  30. package/src/rules/agent/00-core.md +13 -20
  31. package/src/rules/agent/20-skip-protocol.md +3 -8
  32. package/src/rules/agent/30-explorer.md +31 -54
  33. package/src/rules/lead/01-general.md +3 -6
  34. package/src/rules/lead/lead-brief.md +10 -16
  35. package/src/rules/lead/lead-tool.md +3 -5
  36. package/src/rules/shared/01-tool.md +7 -9
  37. package/src/runtime/agent/orchestrator/agent-trace-io.mjs +13 -2
  38. package/src/runtime/agent/orchestrator/mcp/client.mjs +7 -7
  39. package/src/runtime/agent/orchestrator/providers/gemini.mjs +19 -23
  40. package/src/runtime/agent/orchestrator/providers/model-catalog.mjs +21 -5
  41. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +15 -0
  42. package/src/runtime/agent/orchestrator/session/manager/context-meta.mjs +13 -13
  43. package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +21 -7
  44. package/src/runtime/agent/orchestrator/tools/bash-policy-scan.mjs +1 -5
  45. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +71 -1
  46. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +17 -9
  47. package/src/runtime/agent/orchestrator/tools/builtin/shell-analysis.mjs +127 -4
  48. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +105 -28
  49. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +2 -2
  50. package/src/runtime/agent/orchestrator/tools/patch-tool-defs.mjs +1 -1
  51. package/src/runtime/agent/orchestrator/tools/shell-exec-policy.mjs +7 -3
  52. package/src/runtime/memory/lib/memory-embed.mjs +1 -1
  53. package/src/runtime/memory/lib/query-handlers.mjs +25 -10
  54. package/src/runtime/memory/tool-defs.mjs +3 -3
  55. package/src/runtime/shared/background-tasks.mjs +21 -1
  56. package/src/runtime/shared/tool-execution-contract.mjs +29 -16
  57. package/src/session-runtime/model-route-api.mjs +3 -3
  58. package/src/session-runtime/provider-auth-api.mjs +13 -0
  59. package/src/session-runtime/runtime-core.mjs +40 -6
  60. package/src/session-runtime/tool-catalog.mjs +3 -2
  61. package/src/session-runtime/workflow-agents-api.mjs +2 -2
  62. package/src/standalone/agent-tool/notify.mjs +14 -1
  63. package/src/tui/dist/index.mjs +69 -29
  64. package/src/tui/engine/context-state.mjs +13 -4
  65. package/src/tui/engine/session-flow.mjs +26 -13
  66. package/src/tui/engine/turn.mjs +7 -8
  67. package/src/tui/engine.mjs +4 -2
  68. package/src/vendor/statusline/bin/statusline-route.mjs +60 -4
  69. package/src/vendor/statusline/src/gateway/route-meta.mjs +37 -8
  70. package/src/workflows/default/WORKFLOW.md +26 -32
  71. package/src/workflows/solo/WORKFLOW.md +12 -13
@@ -22,6 +22,11 @@ import {
22
22
  analyzeShellCommandEffects,
23
23
  foregroundLongCommandHint,
24
24
  isAutobackgroundingAllowed,
25
+ preflightPowerShellHygiene,
26
+ shellSplitSegments,
27
+ shellSplitPipelineSegments,
28
+ shellTokenize,
29
+ stripShellProbeWrappers,
25
30
  } from './shell-analysis.mjs';
26
31
  import {
27
32
  cancelBackgroundTask,
@@ -55,6 +60,59 @@ export function _trackedDriftNoteAfter(_scope, _pre) {
55
60
  return '';
56
61
  }
57
62
 
63
+ // Search-style commands and `git diff --exit-code` use exit 1 as a SIGNAL
64
+ // (no match / has diff), not a failure. Benign ONLY when exitCode===1, no
65
+ // signal, stderr blank, AND the command is a SINGLE pipeline (no ;/&&/||, so
66
+ // exit 1's origin is unambiguous — a mixed chain stays Error). Quote/comment
67
+ // aware via the shared shell tokenizers, so quoted/commented `;` `|` `grep`
68
+ // can never masquerade as a connector/command and hide a real failure.
69
+ const _SEARCH_HEADS = new Set(['select-string', 'sls', 'grep', 'egrep', 'fgrep', 'findstr']);
70
+ const _GIT_GLOBAL_VALUE_OPTS = new Set(['-c', '-C', '--git-dir', '--work-tree', '--namespace', '--exec-path', '--config-env']);
71
+ // Command/process/subshell substitution or a backslash/backtick-escaped pipe
72
+ // or connector can make the shared tokenizer mis-split the top level and hide
73
+ // the failing stage. If any such construct is present, refuse benign (Error).
74
+ const _AMBIGUOUS_SYNTAX = /\$\(|\$\{|<\(|>\(|`|\\\s*(?:\||&|;|\n)/;
75
+ function _stripShellComment(text) {
76
+ let out = '';
77
+ let quote = null;
78
+ for (let i = 0; i < text.length; i++) {
79
+ const ch = text[i];
80
+ if (quote) { out += ch; if (ch === quote) quote = null; continue; }
81
+ if (ch === '\'' || ch === '"') { quote = ch; out += ch; continue; }
82
+ if (ch === '#' && (i === 0 || /\s/.test(text[i - 1]))) break;
83
+ out += ch;
84
+ }
85
+ return out;
86
+ }
87
+ function _normalizeHead(tok) {
88
+ return String(tok || '').replace(/\.exe$/i, '').split(/[\\/]/).pop().toLowerCase();
89
+ }
90
+ export function _isBenignSearchExitOne(command, exitCode, signal, stderr) {
91
+ if (signal || exitCode !== 1) return false;
92
+ if (stderr && stderr.trim()) return false;
93
+ const text = _stripShellComment(String(command || ''));
94
+ if (_AMBIGUOUS_SYNTAX.test(text)) return false; // subshell/subst/escaped pipe → ambiguous
95
+ const segments = shellSplitSegments(text);
96
+ if (segments.length !== 1) return false; // ;/&&/|| chain → ambiguous, stay Error
97
+ const stages = shellSplitPipelineSegments(segments[0]);
98
+ const last = stages[stages.length - 1] || segments[0];
99
+ const raw = shellTokenize(last);
100
+ if (!raw) return false; // unbalanced quotes
101
+ const tokens = stripShellProbeWrappers(raw);
102
+ if (!tokens.length) return false;
103
+ const head = _normalizeHead(tokens[0]);
104
+ if (_SEARCH_HEADS.has(head)) return true;
105
+ if (head !== 'git') return false;
106
+ // `git [global-opts] diff ...` only — exact `diff` subcommand, never
107
+ // diff-index/diff-files/difftool — with exit-code semantics.
108
+ let i = 1;
109
+ while (i < tokens.length && tokens[i].startsWith('-')) {
110
+ i += (_GIT_GLOBAL_VALUE_OPTS.has(tokens[i]) && !tokens[i].includes('=')) ? 2 : 1;
111
+ }
112
+ if (tokens[i] !== 'diff') return false;
113
+ return tokens.slice(i + 1).some((t) => t === '--exit-code' || t === '--quiet' || t === '--check');
114
+ }
115
+
58
116
  // Combine an existing session abort signal with an externally-supplied
59
117
  // AbortSignal (e.g. the MCP/request signal threaded through options.abortSignal).
60
118
  // Returns null when neither is present so existing session-only behavior is
@@ -196,6 +254,17 @@ export async function executeBashTool(args, workDir, options = {}) {
196
254
  if (wmicRewrite?.error) return `Error: ${wmicRewrite.error}`;
197
255
  if (wmicRewrite?.command) command = wmicRewrite.command;
198
256
 
257
+ // PowerShell hygiene preflight (Windows PS-only; POSIX no-op): losslessly
258
+ // rewrite MSYS `/x/…` drive paths, and hard-block bash-only syntax
259
+ // (grep|tail|sed|awk pipeline stages, `$PID=` reassignment, `&&` on PS 5.1)
260
+ // with PowerShell-native hints so the agent retries with valid syntax.
261
+ const psHygiene = preflightPowerShellHygiene(command, {
262
+ shellType: resolvedSpec.shellType,
263
+ shellName: resolvedSpec.shell,
264
+ });
265
+ if (psHygiene.block) return psHygiene.block;
266
+ command = psHygiene.command;
267
+
199
268
  const _execPolicyBlock = checkExecPolicyMessage(command);
200
269
  if (_execPolicyBlock) {
201
270
  return _execPolicyBlock;
@@ -455,7 +524,8 @@ export async function executeBashTool(args, workDir, options = {}) {
455
524
  ? 'SIGTERM'
456
525
  : (result.killed ? 'SIGKILL' : (result.signal || null));
457
526
  const exitCode = signal ? null : result.exitCode;
458
- const isReallyErrored = !!signal || (exitCode !== 0 && exitCode !== null);
527
+ const benignExitOne = _isBenignSearchExitOne(command, exitCode, signal, stderr);
528
+ const isReallyErrored = !!signal || (exitCode !== 0 && exitCode !== null && !benignExitOne);
459
529
  const _driftNote = '';
460
530
  // Distinct timeout marker so callers see "killed by timeout after Nms"
461
531
  // vs an external signal (e.g. user Ctrl-C, OOM kill). result.timedOut
@@ -26,12 +26,20 @@ function _shellMaxTimeoutMs() {
26
26
  return Math.max(parsed > 0 ? parsed : 600_000, _shellDefaultTimeoutMs());
27
27
  }
28
28
 
29
+ // PowerShell-only syntax cheat, injected into the shell tool description when
30
+ // the host default shell is PowerShell (win32). process.platform is fixed for
31
+ // the process lifetime, so this is evaluated once at module load.
32
+ const _shellSyntaxCheat =
33
+ process.platform === 'win32'
34
+ ? ' PowerShell: grep→Select-String, tail→Get-Content -Tail, head→Get-Content -TotalCount, /c/→C:\\, && 미지원 시 ; 사용, $PID 예약.'
35
+ : '';
36
+
29
37
  export const BUILTIN_TOOLS = [
30
38
  {
31
39
  name: 'read',
32
40
  title: 'Mixdog Read',
33
41
  annotations: { title: 'Mixdog Read', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, compressible: false },
34
- description: 'Read known file path(s) with optional numeric offset+limit windows. Batch paths/regions as real arrays in one call. Not for directory listing.',
42
+ description: 'Read verified file path(s). Unknown path find first. Batch paths/regions as real arrays in one call. Not for directory listing.',
35
43
  inputSchema: {
36
44
  type: 'object',
37
45
  properties: {
@@ -56,7 +64,7 @@ export const BUILTIN_TOOLS = [
56
64
  minItems: 1,
57
65
  },
58
66
  ],
59
- description: 'File path. Batch spans via path[] or {path,offset,limit}[] regions. Pass arrays directly; JSON strings are legacy recovery only.',
67
+ description: 'Verified file path. Batch spans via path[] or {path,offset,limit}[] regions. Pass arrays directly; JSON strings are legacy recovery only.',
60
68
  },
61
69
  offset: { type: 'number', minimum: 0, description: 'Numeric lines to skip before reading; 0 starts at line 1. Continue with offset:N.' },
62
70
  limit: { type: 'number', minimum: 1, description: 'Numeric max lines to return after offset.' },
@@ -67,7 +75,7 @@ export const BUILTIN_TOOLS = [
67
75
  name: 'shell',
68
76
  title: 'Mixdog Shell',
69
77
  annotations: { title: 'Mixdog Shell', readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true, compressible: true },
70
- description: `Run programs or change system state. Set shell: powershell or bash. Not for reading, listing, or searching files. Batch independent commands: ;/&& in one call, or parallel calls in the same turn. ${TOOL_ASYNC_EXECUTION_CONTRACT}`,
78
+ description: `Run programs or change system state. Set shell: powershell or bash. Not for reading, listing, or searching files. Batch independent commands: ;/&& in one call, or parallel calls in the same turn.${_shellSyntaxCheat} ${TOOL_ASYNC_EXECUTION_CONTRACT}`,
71
79
  inputSchema: {
72
80
  type: 'object',
73
81
  properties: {
@@ -101,7 +109,7 @@ export const BUILTIN_TOOLS = [
101
109
  name: 'grep',
102
110
  title: 'Mixdog Grep',
103
111
  annotations: { title: 'Mixdog Grep', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, compressible: true },
104
- description: 'Exact text/regex in a KNOWN scope. files_with_matches/count for broad anchors, content_with_context for narrow answers.',
112
+ description: 'Exact text/regex in a verified file/dir scope. Unknown scope → find/glob first. files_with_matches/count for broad anchors, content_with_context for narrow answers.',
105
113
  inputSchema: {
106
114
  type: 'object',
107
115
  properties: {
@@ -117,7 +125,7 @@ export const BUILTIN_TOOLS = [
117
125
  { type: 'string' },
118
126
  { type: 'array', items: { type: 'string' }, minItems: 1 },
119
127
  ],
120
- description: 'Known file or dir. Array = several scopes.',
128
+ description: 'Verified file or dir. Array = several scopes.',
121
129
  },
122
130
  glob: {
123
131
  anyOf: [
@@ -140,7 +148,7 @@ export const BUILTIN_TOOLS = [
140
148
  name: 'glob',
141
149
  title: 'Mixdog Glob',
142
150
  annotations: { title: 'Mixdog Glob', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, compressible: true },
143
- description: 'Find files by exact glob. Batch patterns and roots as arrays in one call.',
151
+ description: 'Find files by exact glob from verified roots. Batch patterns and roots as arrays in one call.',
144
152
  inputSchema: {
145
153
  type: 'object',
146
154
  properties: {
@@ -168,7 +176,7 @@ export const BUILTIN_TOOLS = [
168
176
  name: 'find',
169
177
  title: 'Mixdog Find Files',
170
178
  annotations: { title: 'Mixdog Find Files', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, compressible: true },
171
- description: 'Find files by partial path/name, searching dot-directories (e.g. .git-adjacent, .mixdog) too. The right tool for locating a file at an unknown, possibly machine-wide path run it from a verified broad root. Returns verified paths. Batch names as query[].',
179
+ description: 'Find files by partial path/name, including dot-directories. Use for unverified path/name guesses; returns verified paths. Batch query[].',
172
180
  inputSchema: {
173
181
  type: 'object',
174
182
  properties: {
@@ -189,7 +197,7 @@ export const BUILTIN_TOOLS = [
189
197
  name: 'list',
190
198
  title: 'Mixdog List Directory',
191
199
  annotations: { title: 'Mixdog List Directory', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, compressible: true },
192
- description: 'List known directory entries. Batch dirs as path[].',
200
+ description: 'List verified directories. Unknown dir → find first. Batch dirs as path[].',
193
201
  inputSchema: {
194
202
  type: 'object',
195
203
  properties: {
@@ -198,7 +206,7 @@ export const BUILTIN_TOOLS = [
198
206
  { type: 'string' },
199
207
  { type: 'array', items: { type: 'string' }, minItems: 1 },
200
208
  ],
201
- description: 'Directory, or path[] to list several directories in one call.',
209
+ description: 'Verified directory, or path[] to list several directories in one call.',
202
210
  },
203
211
  head_limit: { type: 'number', description: 'Max entries.' },
204
212
  offset: { type: 'number', description: 'Skip N entries for paging.' },
@@ -21,7 +21,7 @@ const SHELL_MUTATION_PATTERN = /(?:^|[;&|\n]\s*)(?:touch|mkdir|mktemp|rm|rmdir|m
21
21
  const SHELL_READ_ONLY_SEGMENT_RE = /^(?:cd|pwd|echo|printf|env|printenv|set|unset|export|alias|unalias|type|which|whereis|ls|dir|cat|head|tail|wc|grep|rg|find|git\s+(?:status|diff|show|log|rev-parse|branch|remote|ls-files)|stat|readlink|realpath|basename|dirname|sort|uniq|cut|sed\s+-n|awk|ps|whoami|uname|date|true|false|test|\[)\b/i;
22
22
  const SHELL_GLOBAL_MUTATORS = new Set(['npm', 'pnpm', 'yarn', 'bun', 'pip', 'pip3', 'python', 'python3', 'git', 'cargo', 'go', 'make', 'cmake', 'dd']);
23
23
 
24
- function shellSplitSegments(command) {
24
+ export function shellSplitSegments(command) {
25
25
  const parts = [];
26
26
  let current = '';
27
27
  let quote = null;
@@ -65,7 +65,7 @@ function shellSplitSegments(command) {
65
65
  return parts;
66
66
  }
67
67
 
68
- function shellTokenize(segment) {
68
+ export function shellTokenize(segment) {
69
69
  const tokens = [];
70
70
  let current = '';
71
71
  let quote = null;
@@ -206,7 +206,7 @@ function hasDynamicShellBits(value) {
206
206
  return hasShellVariableExpansion(value) || hasShellGlobMeta(value);
207
207
  }
208
208
 
209
- function shellSplitPipelineSegments(segment) {
209
+ export function shellSplitPipelineSegments(segment) {
210
210
  const parts = [];
211
211
  let current = '';
212
212
  let quote = null;
@@ -245,7 +245,7 @@ function shellSplitPipelineSegments(segment) {
245
245
  return parts;
246
246
  }
247
247
 
248
- function stripShellProbeWrappers(tokens) {
248
+ export function stripShellProbeWrappers(tokens) {
249
249
  const out = stripShellAssignments(tokens || []);
250
250
  let idx = 0;
251
251
  while (idx < out.length) {
@@ -468,6 +468,129 @@ function buildLargeShellFileProbeMessage(fullPath, sizeBytes, cmd, cwd) {
468
468
  return `large-file shell probe blocked: \`${cmd}\` is targeting \`${display}\` (${kb} KB).`;
469
469
  }
470
470
 
471
+ // ---------------------------------------------------------------------------
472
+ // PowerShell hygiene preflight (Windows PS host only). Two behaviors:
473
+ // (1) LOSSLESS auto-substitution: MSYS/Git-Bash `/x/…` absolute paths (x is a
474
+ // single drive letter) are impossible on Windows, so they are rewritten
475
+ // to `X:\…` and execution continues.
476
+ // (2) HARD BLOCK + hint: Unix-only commands used as the first token of any
477
+ // pipeline stage (grep/egrep/fgrep, tail, head, sed, awk), `$PID`
478
+ // reassignment, and `&&` on Windows PowerShell 5.1 are rejected with a
479
+ // PowerShell-native correction hint so the agent retries valid syntax.
480
+ // POSIX shells are a strict no-op.
481
+ // ---------------------------------------------------------------------------
482
+ const MSYS_ABS_PATH_RE = /(^|[\s"'=,(])\/([A-Za-z])\/([^\s"'`|&;<>()]*)/g;
483
+
484
+ // Replace every character that lives inside a single/double-quoted string with a
485
+ // filler ('\0'), keeping the string length 1:1 so match offsets stay aligned.
486
+ // Quote delimiters are preserved (they never match /x/, &&, or $PID=), and a
487
+ // backtick inside double quotes escapes the next char (PS escape). Callers run
488
+ // the drive-rewrite and bash-ism regexes against the mask so quoted literals /
489
+ // regex patterns are never treated as real path tokens or connectors.
490
+ function maskQuotedRegions(command) {
491
+ const FILL = '\0';
492
+ let out = '';
493
+ let quote = null;
494
+ let escaped = false;
495
+ for (let i = 0; i < command.length; i++) {
496
+ const ch = command[i];
497
+ if (escaped) { out += FILL; escaped = false; continue; }
498
+ if (quote) {
499
+ if (quote === '"' && ch === '`') { out += FILL; escaped = true; continue; }
500
+ if (ch === quote) { out += ch; quote = null; continue; }
501
+ out += FILL;
502
+ continue;
503
+ }
504
+ if (ch === '\'' || ch === '"') { quote = ch; out += ch; continue; }
505
+ out += ch;
506
+ }
507
+ return out;
508
+ }
509
+
510
+ function rewriteMsysDrivePaths(command) {
511
+ const src = String(command);
512
+ const mask = maskQuotedRegions(src);
513
+ const re = new RegExp(MSYS_ABS_PATH_RE.source, 'g');
514
+ let changed = false;
515
+ let out = '';
516
+ let last = 0;
517
+ let m;
518
+ // Matches occur only in unquoted regions (quoted chars are '\0' in `mask`),
519
+ // so the captured groups hold the real, unmasked path text.
520
+ while ((m = re.exec(mask)) !== null) {
521
+ const [full, pre, drive, rest] = m;
522
+ out += src.slice(last, m.index);
523
+ out += `${pre}${drive.toUpperCase()}:\\${rest.replace(/\//g, '\\')}`;
524
+ last = m.index + full.length;
525
+ changed = true;
526
+ }
527
+ out += src.slice(last);
528
+ return { command: changed ? out : src, changed };
529
+ }
530
+
531
+ // Unix-only command → PowerShell-native replacement hint. Keyed by the bare
532
+ // first token of a pipeline stage (path prefix stripped), so `Select-String`
533
+ // and other real cmdlets never match, and a filename/arg that merely contains
534
+ // "grep" is ignored (only the command token is judged).
535
+ const POWERSHELL_BASHISM_HINTS = {
536
+ grep: 'grep → Select-String (e.g. `Select-String -Pattern foo -Path file`)',
537
+ egrep: 'egrep → Select-String -Pattern <regex>',
538
+ fgrep: 'fgrep → Select-String -SimpleMatch',
539
+ tail: 'tail → Get-Content -Tail N',
540
+ head: 'head → Get-Content -TotalCount N',
541
+ sed: 'sed → Select-String / ForEach-Object { $_ -replace \'a\',\'b\' }',
542
+ awk: 'awk → Select-String / ForEach-Object { ($_ -split \'\\s+\')[N] }',
543
+ };
544
+
545
+ export function preflightPowerShellHygiene(command, { shellType, shellName } = {}) {
546
+ const original = String(command || '');
547
+ if (shellType !== 'powershell' || !original.trim()) {
548
+ return { command: original, block: null, note: null };
549
+ }
550
+ // (1) lossless MSYS `/x/…` → `X:\…` rewrite; execution continues.
551
+ const { command: rewritten, changed } = rewriteMsysDrivePaths(original);
552
+ const note = changed
553
+ ? 'note: rewrote MSYS-style `/x/…` absolute path(s) to Windows `X:\\…`.'
554
+ : null;
555
+
556
+ // (2) hard-block bash-isms. `powershell.exe` (Windows PS 5.1) lacks `&&`;
557
+ // `pwsh` (PS 7+) supports it, so only legacy PS blocks `&&`.
558
+ const name = String(shellName || '').toLowerCase();
559
+ const isLegacyPS = /powershell/.test(name) && !/pwsh/.test(name);
560
+ const violations = [];
561
+ // `$PID=` reassignment and `&&` connectors are judged on a quote-masked
562
+ // copy so quoted literals (`echo "a && b"`, `Write-Output '$PID=1'`) and
563
+ // regex/search args are never mistaken for real syntax.
564
+ const masked = maskQuotedRegions(rewritten);
565
+
566
+ for (const segment of shellSplitSegments(rewritten)) {
567
+ for (const stage of shellSplitPipelineSegments(segment)) {
568
+ const tokens = stripShellProbeWrappers(shellTokenize(stage) || []);
569
+ if (tokens.length === 0) continue;
570
+ const first = String(tokens[0] || '').toLowerCase().replace(/^.*[\\/]/, '');
571
+ if (POWERSHELL_BASHISM_HINTS[first]) {
572
+ violations.push(`\`${first}\` is a Unix command: ${POWERSHELL_BASHISM_HINTS[first]}`);
573
+ }
574
+ }
575
+ }
576
+ if (/\$PID\s*=(?!=)/i.test(masked)) {
577
+ violations.push('`$PID` is a reserved PowerShell automatic variable — do not reassign it (use a different name).');
578
+ }
579
+ if (isLegacyPS && /(?:^|[^&])&&(?:[^&]|$)/.test(masked)) {
580
+ violations.push('`&&` is not supported in Windows PowerShell 5.1 — use `;` to sequence or issue separate calls.');
581
+ }
582
+
583
+ if (violations.length > 0) {
584
+ const hints = [...new Set(violations)];
585
+ return {
586
+ command: rewritten,
587
+ note,
588
+ block: `PowerShell preflight blocked this command (bash syntax on a PowerShell host). Fix and retry:\n- ${hints.join('\n- ')}`,
589
+ };
590
+ }
591
+ return { command: rewritten, note, block: null };
592
+ }
593
+
471
594
  export async function preflightShellLargeFileProbe(command, cwd) {
472
595
  const text = String(command || '').trim();
473
596
  let localCwd = resolve(cwd || process.cwd());
@@ -412,6 +412,11 @@ const TIMER_MAX_MS = 2_147_483_647;
412
412
  // and `kill` actions already hold the completed outcome, so they cancel the
413
413
  // armed watcher here to prevent a double-notify when its next poll fires.
414
414
  const backgroundShellJobWatchers = new Map();
415
+ // Registry-level safety net keyed by jobId. It is intentionally independent of
416
+ // the fs.watch watcher below: if the watcher is cancelled, misses an event, or
417
+ // cannot be armed because the direct notifyFn is unavailable, this poller still
418
+ // reconciles the shell job into the shared background-task row.
419
+ const shellTaskSafetyPollers = new Map();
415
420
  // Persistent notify ctx per jobId, set at FIRST arm and surviving cancel — so a
416
421
  // re-arm after a task wait timeout can reconstruct the notify wiring without the
417
422
  // caller threading the ctx back in. Deleted only in the watcher's cleanup() on
@@ -422,6 +427,93 @@ const jobNotifyCtxByJobId = new Map();
422
427
  // consuming the outcome, so the watcher must stay cancelled; the last waiter to
423
428
  // leave (count===0) owns the decision to re-arm a still-running job.
424
429
  const jobWaitWaiterCountByJobId = new Map();
430
+ const SHELL_TASK_SAFETY_POLL_MS = 5_000;
431
+ function shellJobTaskStatus(status) {
432
+ if (status === 'completed') return 'completed';
433
+ if (status === 'cancelled') return 'cancelled';
434
+ return 'failed';
435
+ }
436
+ function buildShellCompletion(jobId, detail, reason) {
437
+ const startedAtMs = Date.parse(detail?.startedAt || '');
438
+ const finishedAtMs = Date.parse(detail?.finishedAt || '') || Date.now();
439
+ const elapsedMs = Number.isFinite(startedAtMs) ? Math.max(0, finishedAtMs - startedAtMs) : null;
440
+ const exitCode = (typeof detail?.exitCode === 'number') ? detail.exitCode : null;
441
+ const status = detail?.status || (reason === 'timeout' ? 'running' : 'unknown');
442
+ const body = renderShellCompletionEnvelope({
443
+ jobId,
444
+ status,
445
+ exitCode,
446
+ elapsedMs,
447
+ command: detail?.command,
448
+ summary: detail?.summary,
449
+ stdoutPreview: detail?.stdoutPreview,
450
+ stderrPreview: detail?.stderrPreview,
451
+ mergeStderr: detail?.mergeStderr,
452
+ });
453
+ const taskStatus = shellJobTaskStatus(status);
454
+ return {
455
+ status,
456
+ taskStatus,
457
+ exitCode,
458
+ body,
459
+ instruction: shellCompletionInstruction({ jobId, status, exitCode }),
460
+ result: shellJobPublicTaskResult(detail),
461
+ error: taskStatus === 'failed'
462
+ ? (detail?.error || (status === 'running' ? 'background shell watcher deadline reached' : null))
463
+ : null,
464
+ };
465
+ }
466
+ function clearShellTaskSafetyNet(jobId) {
467
+ const timer = shellTaskSafetyPollers.get(jobId);
468
+ if (!timer) return;
469
+ try { clearInterval(timer); } catch { /* ignore */ }
470
+ shellTaskSafetyPollers.delete(jobId);
471
+ }
472
+ function armShellTaskSafetyNet(jobId) {
473
+ if (!jobId || shellTaskSafetyPollers.has(jobId)) return;
474
+ const tick = () => {
475
+ try {
476
+ const task = getBackgroundTask(jobId);
477
+ if (!task || task.status !== 'running') {
478
+ clearShellTaskSafetyNet(jobId);
479
+ return;
480
+ }
481
+ const detail = attachJobInsights(refreshShellJob(jobId)) || readShellJobDetail(jobId);
482
+ if (!detail) {
483
+ completeBackgroundTask(jobId, {
484
+ status: 'failed',
485
+ error: 'shell job detail missing during safety reconciliation',
486
+ resultType: 'shell_task_result',
487
+ terminalReason: 'shell-safety-missing-detail',
488
+ });
489
+ clearShellTaskSafetyNet(jobId);
490
+ return;
491
+ }
492
+ if (detail.status === 'running') return;
493
+ // While a manual `task wait` caller is consuming the result
494
+ // synchronously, do not send the async completion. The wait path
495
+ // marks the task terminal with notify:false, and this poller will
496
+ // stop on its next tick.
497
+ if ((jobWaitWaiterCountByJobId.get(jobId) || 0) > 0) return;
498
+ const completion = buildShellCompletion(jobId, detail, 'safety');
499
+ completeBackgroundTask(jobId, {
500
+ status: completion.taskStatus,
501
+ result: completion.result,
502
+ resultText: completion.body,
503
+ error: completion.error,
504
+ resultType: 'shell_task_result',
505
+ instruction: completion.instruction,
506
+ terminalReason: 'shell-safety-reconcile',
507
+ });
508
+ clearShellTaskSafetyNet(jobId);
509
+ } catch (err) {
510
+ try { process.stderr.write(`[shell-jobs] shell task safety poll failed: jobId=${jobId} err=${err?.message ?? String(err)}\n`); } catch { /* ignore */ }
511
+ }
512
+ };
513
+ const timer = setInterval(tick, SHELL_TASK_SAFETY_POLL_MS);
514
+ if (typeof timer.unref === 'function') timer.unref();
515
+ shellTaskSafetyPollers.set(jobId, timer);
516
+ }
425
517
  function markShellJobCancelledByShutdown(jobId, reason = 'shutdown') {
426
518
  const detail = readShellJobDetail(jobId);
427
519
  if (!detail || detail.status !== 'running') return false;
@@ -470,6 +562,7 @@ export function shutdownShellJobs(reason = 'runtime-close', { sync = false } = {
470
562
  try { if (markShellJobCancelledByShutdown(jobId, reason)) marked += 1; } catch { /* ignore */ }
471
563
  }
472
564
  backgroundShellJobWatchers.clear();
565
+ for (const jobId of [...shellTaskSafetyPollers.keys()]) clearShellTaskSafetyNet(jobId);
473
566
  jobNotifyCtxByJobId.clear();
474
567
  jobWaitWaiterCountByJobId.clear();
475
568
  return { killed: livePids.length, cancelledJobs: marked, cancelledWatchers: watcherJobIds.length };
@@ -488,6 +581,7 @@ export function cancelBackgroundShellJobWatch(jobId) {
488
581
  // notifyCtx may be omitted on RE-ARM — it then falls back to the persistent
489
582
  // ctx captured at first arm (jobNotifyCtxByJobId).
490
583
  export function watchBackgroundShellJob(jobId, notifyCtx) {
584
+ armShellTaskSafetyNet(jobId);
491
585
  const ctx = (notifyCtx && typeof notifyCtx.notifyFn === 'function')
492
586
  ? normalizeToolNotifyContext(notifyCtx)
493
587
  : (jobId ? jobNotifyCtxByJobId.get(jobId) : null);
@@ -539,42 +633,25 @@ export function watchBackgroundShellJob(jobId, notifyCtx) {
539
633
  try {
540
634
  const detail = attachJobInsights(refreshShellJob(jobId)) || readShellJobDetail(jobId);
541
635
  if (!detail) return;
542
- const startedAtMs = Date.parse(detail.startedAt || '');
543
- const finishedAtMs = Date.parse(detail.finishedAt || '') || Date.now();
544
- const elapsedMs = Number.isFinite(startedAtMs) ? Math.max(0, finishedAtMs - startedAtMs) : null;
545
- const exitCode = (typeof detail.exitCode === 'number') ? detail.exitCode : null;
546
- const status = detail.status || (reason === 'timeout' ? 'running' : 'unknown');
547
- const body = renderShellCompletionEnvelope({
548
- jobId,
549
- status,
550
- exitCode,
551
- elapsedMs,
552
- command: detail.command,
553
- summary: detail.summary,
554
- stdoutPreview: detail.stdoutPreview,
555
- stderrPreview: detail.stderrPreview,
556
- mergeStderr: detail.mergeStderr,
557
- });
558
- const taskStatus = status === 'completed'
559
- ? 'completed'
560
- : (status === 'cancelled' ? 'cancelled' : 'failed');
561
- const instruction = shellCompletionInstruction({ jobId, status, exitCode });
636
+ const completion = buildShellCompletion(jobId, detail, reason);
562
637
  const completedTask = completeBackgroundTask(jobId, {
563
- status: taskStatus,
564
- result: shellJobPublicTaskResult(detail),
565
- resultText: body,
566
- error: taskStatus === 'failed' ? (detail.error || (status === 'running' ? 'background shell watcher deadline reached' : null)) : null,
638
+ status: completion.taskStatus,
639
+ result: completion.result,
640
+ resultText: completion.body,
641
+ error: completion.error,
567
642
  resultType: 'shell_task_result',
568
- instruction,
643
+ instruction: completion.instruction,
644
+ terminalReason: `shell-watcher-${reason || 'done'}`,
569
645
  });
646
+ clearShellTaskSafetyNet(jobId);
570
647
  if (completedTask) return;
571
648
  notifyToolCompletion({
572
649
  surface: 'shell',
573
650
  id: jobId,
574
- status: taskStatus,
575
- text: body,
651
+ status: completion.taskStatus,
652
+ text: completion.body,
576
653
  resultType: 'shell_task_result',
577
- instruction,
654
+ instruction: completion.instruction,
578
655
  context: ctx,
579
656
  logPrefix: 'shell-jobs',
580
657
  });
@@ -3,12 +3,12 @@ export const CODE_GRAPH_TOOL_DEFS = [
3
3
  name: 'code_graph',
4
4
  title: 'Code Graph',
5
5
  annotations: { title: 'Code Graph', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, compressible: false, compressibleLossless: true },
6
- description: 'Repo-local code structure/flow lookup (not web): symbols/references/calls/deps. Batch targets as symbols[]/files[] in one call.',
6
+ description: 'Repo-local code structure/flow lookup (not web): symbols/references/calls/deps. Known symbols or verified files only. Batch symbols[]/files[].',
7
7
  inputSchema: {
8
8
  type: 'object',
9
9
  properties: {
10
10
  mode: { type: 'string', enum: ['overview', 'imports', 'dependents', 'related', 'impact', 'symbols', 'find_symbol', 'symbol_search', 'search', 'references', 'callers'], description: 'Repo-local graph operation.' },
11
- files: { anyOf: [{ type: 'string' }, { type: 'array', items: { type: 'string' }, minItems: 1 }], description: 'Source file(s); array fans out per file. `file` alias too.' },
11
+ files: { anyOf: [{ type: 'string' }, { type: 'array', items: { type: 'string' }, minItems: 1 }], description: 'Verified source file(s); array fans out per file. `file` alias too.' },
12
12
  symbols: { anyOf: [{ type: 'string' }, { type: 'array', items: { type: 'string' }, minItems: 1 }], description: 'Identifier(s)/keyword(s); array batches in one call. `symbol` alias too. Required for callers/callees/references/find_symbol/symbol_search.' },
13
13
  symbol: { type: 'string', description: 'Singular alias for symbols.' },
14
14
  file: { type: 'string', description: 'Singular alias for files.' },
@@ -36,7 +36,7 @@ export const PATCH_TOOL_DEFS = [
36
36
  inputSchema: {
37
37
  type: 'object',
38
38
  properties: {
39
- patch: { type: 'string', description: 'Patch text. V4A preferred; include all known edits in listed order.' },
39
+ patch: { type: 'string', description: 'Patch text. V4A preferred; use one file block per target file with exact current context; include all known edits in listed order.' },
40
40
  format: { type: 'string', enum: ['unified', 'v4a'], description: 'Auto-detected.' },
41
41
  base_path: { type: 'string', description: 'Repo root.' },
42
42
  dry_run: { type: 'boolean', description: 'Default false. true = validate only, no write.' },
@@ -1,6 +1,6 @@
1
1
  'use strict';
2
2
 
3
- import { getDestructiveCommandWarning } from './destructive-warning.mjs';
3
+ import { getDestructiveCommandWarning, stripQuotedAndHeredoc } from './destructive-warning.mjs';
4
4
  import { isBlockedCommand, WRAPPER_NAMES } from './shell-policy.mjs';
5
5
 
6
6
  /** @typedef {'allow'|'warn-prompt'|'deny'} ExecPolicyDecision */
@@ -11,7 +11,7 @@ const EXEC_POLICY_DENY_PATTERNS = [
11
11
  /\b(?:sh|bash|zsh|dash|pwsh|powershell)(?:\.exe)?\s+<\s*\(/i,
12
12
  /\bInvoke-Expression\b/i,
13
13
  /\biex\s+/i,
14
- /\bStart-Process\b[^\n]*\b-Verb\s+RunAs\b/i,
14
+ /\bStart-Process\b[^\n]*\s-Verb\s+RunAs\b/i,
15
15
  ];
16
16
 
17
17
  const EXEC_POLICY_DENY_COMMANDS = new Set([
@@ -45,8 +45,12 @@ export function classifyExecPolicy(command) {
45
45
  if (isBlockedCommand(text)) {
46
46
  return { decision: 'deny', reason: 'destructive or system-destabilising pattern (hard block)' };
47
47
  }
48
+ // Hard-deny only real executable syntax. The policy scanner also sees user
49
+ // search strings / regex literals (e.g. `-match 'powershell|bash|grep'`), so
50
+ // high-risk pipe-to-shell / IEX / RunAs patterns must ignore quoted spans.
51
+ const executableText = stripQuotedAndHeredoc(text);
48
52
  for (const pat of EXEC_POLICY_DENY_PATTERNS) {
49
- if (pat.test(text)) {
53
+ if (pat.test(executableText)) {
50
54
  return { decision: 'deny', reason: 'high-risk shell invocation (pipe-to-shell, elevated launcher, or remote-exec pattern)' };
51
55
  }
52
56
  }
@@ -130,7 +130,7 @@ export function isRawEmbeddable(role, content) {
130
130
 
131
131
  const RAW_EMBED_SQL_EXCLUDE_ROLE_VALUES = [...RAW_EMBED_EXCLUDE_ROLES]
132
132
  const RAW_EMBED_SQL_ALWAYS_EXCLUDE_CONTENT_RE =
133
- '^\\s*(\\[tool_call\\b|\\[tool_result\\b|\\[mixdog-runtime\\]|The async (shell task|agent task|\\S+ execution|\\S+) .*has finished\\b.*review this result in your next step|background task\\b)'
133
+ '^\\s*(\\[tool_call(?:\\s|\\]|$)|\\[tool_result(?:\\s|\\]|$)|\\[mixdog-runtime\\]|The async (shell task|agent task|\\S+ execution|\\S+) .*has finished(\\s|$).*review this result in your next step|background task(\\s|$))'
134
134
  const RAW_EMBED_SQL_NON_CONVERSATION_EXCLUDE_CONTENT_RE =
135
135
  '^\\s*\\[(system|log|offload|debug|trace|info|warn|warning|error|fatal)\\]'
136
136