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.
- package/README.md +6 -0
- package/package.json +4 -2
- package/scripts/_devtools-stub.mjs +1 -0
- package/scripts/_jitter-fuzz.jsx +42 -0
- package/scripts/_jitter-fuzz.mjs +44410 -0
- package/scripts/_jitter-fuzz2.jsx +30 -0
- package/scripts/_jitter-fuzz2.mjs +44400 -0
- package/scripts/_jitter-probe.jsx +35 -0
- package/scripts/_jitter-probe.mjs +44397 -0
- package/scripts/_jp2.jsx +16 -0
- package/scripts/_jp2.mjs +45614 -0
- package/scripts/agent-live-arg-guard-smoke.mjs +20 -0
- package/scripts/agent-live-path-suffix-smoke.mjs +34 -0
- package/scripts/agent-live-toolcall-args-smoke.mjs +45 -0
- package/scripts/apply-patch-edit-smoke.mjs +71 -0
- package/scripts/async-notify-settlement-test.mjs +99 -0
- package/scripts/forwarder-rebind-tail-test.mjs +67 -0
- package/scripts/live-worker-smoke.mjs +1 -1
- package/scripts/pending-completion-drop-test.mjs +46 -27
- package/scripts/provider-toolcall-test.mjs +1 -0
- package/scripts/recall-bench-cases.json +1 -0
- package/scripts/session-bench.mjs +44 -0
- package/scripts/shell-hardening-test.mjs +209 -0
- package/scripts/ship-mode-test.mjs +98 -0
- package/scripts/steering-drain-buckets-test.mjs +59 -0
- package/scripts/tool-smoke.mjs +46 -10
- package/scripts/worker-notify-rejection-test.mjs +51 -0
- package/src/lib/mixdog-debug.cjs +69 -0
- package/src/rules/agent/00-common.md +4 -5
- package/src/rules/agent/00-core.md +13 -20
- package/src/rules/agent/20-skip-protocol.md +3 -8
- package/src/rules/agent/30-explorer.md +31 -54
- package/src/rules/lead/01-general.md +3 -6
- package/src/rules/lead/lead-brief.md +10 -16
- package/src/rules/lead/lead-tool.md +3 -5
- package/src/rules/shared/01-tool.md +7 -9
- package/src/runtime/agent/orchestrator/agent-trace-io.mjs +13 -2
- package/src/runtime/agent/orchestrator/mcp/client.mjs +7 -7
- package/src/runtime/agent/orchestrator/providers/gemini.mjs +19 -23
- package/src/runtime/agent/orchestrator/providers/model-catalog.mjs +21 -5
- package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +15 -0
- package/src/runtime/agent/orchestrator/session/manager/context-meta.mjs +13 -13
- package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +21 -7
- package/src/runtime/agent/orchestrator/tools/bash-policy-scan.mjs +1 -5
- package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +71 -1
- package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +17 -9
- package/src/runtime/agent/orchestrator/tools/builtin/shell-analysis.mjs +127 -4
- package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +105 -28
- package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +2 -2
- package/src/runtime/agent/orchestrator/tools/patch-tool-defs.mjs +1 -1
- package/src/runtime/agent/orchestrator/tools/shell-exec-policy.mjs +7 -3
- package/src/runtime/memory/lib/memory-embed.mjs +1 -1
- package/src/runtime/memory/lib/query-handlers.mjs +25 -10
- package/src/runtime/memory/tool-defs.mjs +3 -3
- package/src/runtime/shared/background-tasks.mjs +21 -1
- package/src/runtime/shared/tool-execution-contract.mjs +29 -16
- package/src/session-runtime/model-route-api.mjs +3 -3
- package/src/session-runtime/provider-auth-api.mjs +13 -0
- package/src/session-runtime/runtime-core.mjs +40 -6
- package/src/session-runtime/tool-catalog.mjs +3 -2
- package/src/session-runtime/workflow-agents-api.mjs +2 -2
- package/src/standalone/agent-tool/notify.mjs +14 -1
- package/src/tui/dist/index.mjs +69 -29
- package/src/tui/engine/context-state.mjs +13 -4
- package/src/tui/engine/session-flow.mjs +26 -13
- package/src/tui/engine/turn.mjs +7 -8
- package/src/tui/engine.mjs +4 -2
- package/src/vendor/statusline/bin/statusline-route.mjs +60 -4
- package/src/vendor/statusline/src/gateway/route-meta.mjs +37 -8
- package/src/workflows/default/WORKFLOW.md +26 -32
- 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
|
|
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
|
|
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: '
|
|
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
|
|
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
|
|
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: '
|
|
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,
|
|
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
|
|
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: '
|
|
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
|
|
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:
|
|
565
|
-
resultText: body,
|
|
566
|
-
error:
|
|
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.
|
|
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: '
|
|
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]*\
|
|
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(
|
|
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
|
|
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
|
|