@pugi/cli 0.1.0-beta.40 → 0.1.0-beta.41

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.
@@ -1,5 +1,6 @@
1
1
  import { editTool, globTool, grepTool, OperatorAbortedError, readTool, StaleReadError, writeTool, } from '../../tools/file-tools.js';
2
2
  import { bashToolSync } from '../../tools/bash.js';
3
+ import { powerShellToolSync } from '../../tools/powershell.js';
3
4
  import { askUser } from '../../tools/ask-user.js';
4
5
  import { askUserQuestionJsonSchema, dispatchAskUserQuestion, } from '../../tools/ask-user-question.js';
5
6
  import { skillInvoke, skillList } from '../../tools/skill-tool.js';
@@ -83,6 +84,12 @@ const WIRED_TOOLS = new Set([
83
84
  'grep',
84
85
  'glob',
85
86
  'bash',
87
+ // Leak L6 (2026-05-28): PowerShell tool for Windows-first workflows.
88
+ // Same bash permission class — destructive-pattern classifier applies.
89
+ // Plan mode excludes shell tools by design (read-only); the planMode
90
+ // check on the schema side already handles that, so we just list it
91
+ // alongside 'bash' here.
92
+ 'powershell',
86
93
  'ask_user_question',
87
94
  'skill',
88
95
  'skills_list',
@@ -394,6 +401,19 @@ export function buildToolsSchema(kind, options = { allowFetch: false, allowSearc
394
401
  command: { type: 'string', description: 'Single shell command to execute.' },
395
402
  },
396
403
  },
404
+ }, {
405
+ name: 'powershell',
406
+ description: 'Run a PowerShell command via `pwsh -NoProfile -Command` (or `powershell.exe` fallback on Windows). Same security posture as bash — destructive pattern gate applies. 30s default timeout, 120s max. Output capped at 64KB. Returns {exitCode, stdout, stderr, truncated, shellBinary}. Prefer the dedicated bash tool for /bin/sh scripts; use this when the operator needs native pwsh cmdlets or *.ps1 syntax.',
407
+ parameters: {
408
+ type: 'object',
409
+ additionalProperties: false,
410
+ required: ['command'],
411
+ properties: {
412
+ command: { type: 'string', description: 'Single PowerShell command or script.' },
413
+ cwd: { type: 'string', description: 'Optional cwd; defaults to workspace root.' },
414
+ timeoutMs: { type: 'number', description: 'Hard timeout (default 30000, max 120000).' },
415
+ },
416
+ },
397
417
  },
398
418
  // β7 L5+T11 (2026-05-26): transactional multi-file edit. Either
399
419
  // all entries land or none do — failures roll the workspace back
@@ -1004,6 +1024,30 @@ function dispatchTool(name, args, ctx) {
1004
1024
  const body = parts.filter(Boolean).join('\n');
1005
1025
  return body || '(no output)';
1006
1026
  }
1027
+ case 'powershell': {
1028
+ // Leak L6 (2026-05-28): pwsh dispatcher. Permission gate reuses the
1029
+ // bash classifier so destructive patterns block the same way.
1030
+ const command = requireString(args, 'command');
1031
+ const cwd = optionalString(args, 'cwd');
1032
+ const timeoutMs = optionalNumber(args, 'timeoutMs');
1033
+ const psResult = powerShellToolSync({ cmd: command, ...(cwd !== undefined ? { cwd } : {}), ...(timeoutMs !== undefined ? { timeoutMs } : {}) }, {
1034
+ root: ctx.root,
1035
+ settings: ctx.settings,
1036
+ session: ctx.session,
1037
+ source: 'agent',
1038
+ });
1039
+ const parts = [
1040
+ `exit=${psResult.exitCode}`,
1041
+ `shell=${psResult.shellBinary}`,
1042
+ psResult.stdout ? `stdout:\n${psResult.stdout}` : '',
1043
+ psResult.stderr ? `stderr:\n${psResult.stderr}` : '',
1044
+ ];
1045
+ if (psResult.truncated)
1046
+ parts.push('truncated=true');
1047
+ if (psResult.timedOut)
1048
+ parts.push('timedOut=true');
1049
+ return parts.filter(Boolean).join('\n') || '(no output)';
1050
+ }
1007
1051
  default:
1008
1052
  // Exhaustive; unreachable because of the WIRED_TOOLS guard above.
1009
1053
  throw new Error(`unhandled tool: ${name}`);
@@ -1180,6 +1224,15 @@ function optionalString(obj, key) {
1180
1224
  }
1181
1225
  return v;
1182
1226
  }
1227
+ function optionalNumber(obj, key) {
1228
+ const v = obj[key];
1229
+ if (v === undefined || v === null)
1230
+ return undefined;
1231
+ if (typeof v !== 'number' || !Number.isFinite(v)) {
1232
+ throw new Error(`tool argument "${key}" must be a finite number when present`);
1233
+ }
1234
+ return v;
1235
+ }
1183
1236
  /**
1184
1237
  * β7 L5+T11: dispatch the model-emitted `multi_edit` tool call. The
1185
1238
  * tool returns a structured result envelope; we serialize it to JSON
@@ -44,7 +44,7 @@ export function sanitizeSemver(raw) {
44
44
  * during import). When bumping the CLI version BOTH literals must be
45
45
  * updated; the release smoke-test (`pack:smoke`) verifies they agree.
46
46
  */
47
- export const PUGI_CLI_VERSION = sanitizeSemver('0.1.0-beta.40');
47
+ export const PUGI_CLI_VERSION = sanitizeSemver('0.1.0-beta.41');
48
48
  /**
49
49
  * Outbound: the CLI's installed semver. Read at request time by
50
50
  * `version-interceptor.ts` and injected on every `fetch` call.
@@ -0,0 +1,156 @@
1
+ /**
2
+ * PowerShell tool — leak L6 (Pugi parity catch-up 2026-05-28).
3
+ *
4
+ * Windows operators cannot run native `*.ps1` scripts via the bash tool
5
+ * (which spawns `/bin/sh`). This tool spawns `pwsh -NoProfile -Command`
6
+ * на cross-platform PowerShell 7+ binary so Windows-first workflows are
7
+ * first-class на Pugi.
8
+ *
9
+ * Clean-room re-implementation. Surface mirrors bashTool's permission
10
+ * gate, env sanitiser, output cap, timeout, and exit-code propagation;
11
+ * the only difference is the shell binary selection. Per-platform
12
+ * resolution:
13
+ * - All OS: try `pwsh` on $PATH first (PowerShell 7+ cross-platform).
14
+ * - Windows fallback: `powershell.exe` (Windows PowerShell 5.1 baked-in).
15
+ * - Other OS without pwsh: tool returns a clear "powershell binary
16
+ * not found" error so the operator can install pwsh or fall back
17
+ * к bash.
18
+ *
19
+ * Permission class: reuses the bash classifier — destructive patterns,
20
+ * sandbox detection, and additional-directories checks are command-string
21
+ * based and apply equally to pwsh and sh.
22
+ */
23
+ import { spawnSync } from 'node:child_process';
24
+ import { evaluateBashPermission } from '../core/permission.js';
25
+ import { recordToolCall, recordToolResult } from '../core/session.js';
26
+ export const POWERSHELL_OUTPUT_CAP_BYTES = 64 * 1024;
27
+ export const POWERSHELL_DEFAULT_TIMEOUT_MS = 30_000;
28
+ export const POWERSHELL_MAX_TIMEOUT_MS = 120_000;
29
+ /** Cached binary path so repeated calls inside one session skip the probe. */
30
+ let cachedShellBinary;
31
+ function resolveShellBinary() {
32
+ if (cachedShellBinary !== undefined)
33
+ return cachedShellBinary;
34
+ // Try pwsh (cross-platform PowerShell 7+) first.
35
+ const pwshProbe = spawnSync('pwsh', ['-NoProfile', '-Command', 'exit 0'], {
36
+ encoding: 'utf8',
37
+ stdio: ['ignore', 'ignore', 'ignore'],
38
+ timeout: 3000,
39
+ });
40
+ if (pwshProbe.status === 0) {
41
+ cachedShellBinary = 'pwsh';
42
+ return 'pwsh';
43
+ }
44
+ // Windows fallback к the baked-in PowerShell 5.1.
45
+ if (process.platform === 'win32') {
46
+ const wpsProbe = spawnSync('powershell.exe', ['-NoProfile', '-Command', 'exit 0'], {
47
+ encoding: 'utf8',
48
+ stdio: ['ignore', 'ignore', 'ignore'],
49
+ timeout: 3000,
50
+ });
51
+ if (wpsProbe.status === 0) {
52
+ cachedShellBinary = 'powershell.exe';
53
+ return 'powershell.exe';
54
+ }
55
+ }
56
+ cachedShellBinary = null;
57
+ return null;
58
+ }
59
+ function sanitizeTimeout(value) {
60
+ if (value === undefined || !Number.isFinite(value) || value <= 0) {
61
+ return POWERSHELL_DEFAULT_TIMEOUT_MS;
62
+ }
63
+ return Math.min(value, POWERSHELL_MAX_TIMEOUT_MS);
64
+ }
65
+ function buildChildEnv() {
66
+ const env = { ...process.env };
67
+ delete env['PUGI_API_KEY'];
68
+ delete env['PUGI_LOGIN_TOKEN'];
69
+ return env;
70
+ }
71
+ /**
72
+ * Sync PowerShell dispatch. Mirrors bashToolSync shape so dispatchTool
73
+ * can call either tool with the same context shape.
74
+ */
75
+ export function powerShellToolSync(input, ctx) {
76
+ const cmd = input.cmd ?? '';
77
+ const additionalDirectories = ctx.additionalDirectories ?? [];
78
+ const source = ctx.source ?? 'agent';
79
+ const toolCallId = recordToolCall(ctx.session, 'powershell', cmd);
80
+ const decision = evaluateBashPermission(cmd, ctx.settings.permissions.mode, {
81
+ workspaceRoot: ctx.root,
82
+ additionalDirectories,
83
+ source,
84
+ });
85
+ if (decision.decision !== 'allow') {
86
+ const reason = `Permission ${decision.decision}: ${decision.reason}`;
87
+ recordToolResult(ctx.session, toolCallId, 'error', reason);
88
+ return {
89
+ stdout: '',
90
+ stderr: `Permission denied: ${decision.reason}`,
91
+ exitCode: 126,
92
+ truncated: false,
93
+ timedOut: false,
94
+ shellBinary: 'unresolved',
95
+ };
96
+ }
97
+ const shellBinary = resolveShellBinary();
98
+ if (shellBinary === null) {
99
+ const reason = 'powershell binary not found (tried pwsh' +
100
+ (process.platform === 'win32' ? ', powershell.exe' : '') +
101
+ '). Install PowerShell 7+ from https://aka.ms/powershell or use the bash tool instead.';
102
+ recordToolResult(ctx.session, toolCallId, 'error', reason);
103
+ return {
104
+ stdout: '',
105
+ stderr: reason,
106
+ exitCode: 127,
107
+ truncated: false,
108
+ timedOut: false,
109
+ shellBinary: 'unavailable',
110
+ };
111
+ }
112
+ const timeoutMs = sanitizeTimeout(input.timeoutMs);
113
+ const childEnv = buildChildEnv();
114
+ const cwd = input.cwd ?? ctx.root;
115
+ const result = spawnSync(shellBinary, ['-NoProfile', '-Command', cmd], {
116
+ cwd,
117
+ env: childEnv,
118
+ encoding: 'utf8',
119
+ stdio: ['ignore', 'pipe', 'pipe'],
120
+ timeout: timeoutMs,
121
+ maxBuffer: 10 * 1024 * 1024,
122
+ });
123
+ const stdoutFull = (result.stdout ?? '').toString();
124
+ const stderrFull = (result.stderr ?? '').toString();
125
+ const combined = stdoutFull.length + stderrFull.length;
126
+ const truncated = combined > POWERSHELL_OUTPUT_CAP_BYTES;
127
+ let stdoutOut = stdoutFull;
128
+ let stderrOut = stderrFull;
129
+ if (truncated) {
130
+ const halfCap = POWERSHELL_OUTPUT_CAP_BYTES / 2;
131
+ stdoutOut = stdoutFull.slice(0, halfCap);
132
+ stderrOut = stderrFull.slice(0, halfCap);
133
+ }
134
+ const timedOut = result.error?.code === 'ETIMEDOUT' ||
135
+ result.signal === 'SIGTERM';
136
+ const exitCode = timedOut ? 124 : result.status ?? 1;
137
+ if (timedOut) {
138
+ recordToolResult(ctx.session, toolCallId, 'error', `powershell timed out after ${timeoutMs}ms`);
139
+ }
140
+ else {
141
+ recordToolResult(ctx.session, toolCallId, 'success', `powershell exit=${exitCode} bytes=${combined} binary=${shellBinary}`);
142
+ }
143
+ return {
144
+ stdout: stdoutOut,
145
+ stderr: stderrOut,
146
+ exitCode,
147
+ truncated,
148
+ timedOut,
149
+ shellBinary,
150
+ };
151
+ }
152
+ /** Visible-for-spec helper: forces a re-probe on next call. */
153
+ export function _resetShellBinaryCacheForSpec() {
154
+ cachedShellBinary = undefined;
155
+ }
156
+ //# sourceMappingURL=powershell.js.map
@@ -26,6 +26,11 @@ const registry = [
26
26
  // concurrencySafe = false because the journal serialises one dispatch
27
27
  // per session.
28
28
  { name: 'multi_edit', permission: 'edit', risk: 'medium', concurrencySafe: false, m1: true },
29
+ // Leak L6 (2026-05-28): PowerShell tool for Windows-first workflows. Same
30
+ // bash permission class — destructive-pattern classification fires the
31
+ // same gate. concurrencySafe = false because spawn-shell child cwd /
32
+ // env carry-over could race across parallel agent calls.
33
+ { name: 'powershell', permission: 'bash', risk: 'high', concurrencySafe: false, m1: false },
29
34
  { name: 'question', permission: 'none', risk: 'low', concurrencySafe: false, m1: true },
30
35
  { name: 'read', permission: 'read', risk: 'low', concurrencySafe: true, m1: true },
31
36
  { name: 'skill', permission: 'read', risk: 'low', concurrencySafe: true, m1: true },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pugi/cli",
3
- "version": "0.1.0-beta.40",
3
+ "version": "0.1.0-beta.41",
4
4
  "description": "Pugi CLI - terminal-native software execution system",
5
5
  "homepage": "https://pugi.io",
6
6
  "repository": {
@@ -55,7 +55,7 @@
55
55
  "undici": "^8.3.0",
56
56
  "zod": "^3.23.0",
57
57
  "@pugi/personas": "0.1.2",
58
- "@pugi/sdk": "0.1.0-beta.40"
58
+ "@pugi/sdk": "0.1.0-beta.41"
59
59
  },
60
60
  "devDependencies": {
61
61
  "@types/node": "^22.0.0",