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

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.
@@ -0,0 +1,118 @@
1
+ /**
2
+ * HOOKS probe — verifies `.pugi/hooks-mvp.json` and `.pugi/hook-chains.json`
3
+ * exist + parse + carry their declared shape. Absence is OK (most workspaces
4
+ * don't ship hooks); presence с invalid JSON is a deployment-blocking error
5
+ * the operator wants surfaced before a tool dispatch fires a malformed hook
6
+ * and the model sees a confusing failure.
7
+ *
8
+ * Validation tier:
9
+ * 1. JSON.parse — catches typos / trailing commas / bad escapes.
10
+ * 2. Top-level shape sniff — `hooks-mvp.json` MUST be an object с a
11
+ * `hooks` array property; `hook-chains.json` MUST be an object
12
+ * с string-keyed event names mapping to arrays.
13
+ * 3. Per-entry require-fields sniff — minimal так что probe stays cheap.
14
+ *
15
+ * Out of scope: full Zod schema validation. That lives in the hook loader
16
+ * itself (apps/pugi-cli/src/core/hooks/v2/loader.ts). The probe is a
17
+ * fast sanity check; the loader produces the canonical error message
18
+ * when a hook actually fires.
19
+ */
20
+ function loadHookFile(fs, path) {
21
+ if (!fs.existsSync(path))
22
+ return null;
23
+ try {
24
+ const raw = fs.readFileSync(path, 'utf8');
25
+ return { path, parsed: JSON.parse(raw) };
26
+ }
27
+ catch (error) {
28
+ const message = error instanceof Error ? error.message : String(error);
29
+ return { parseError: `${path}: ${message}` };
30
+ }
31
+ }
32
+ function validateMvpShape(parsed) {
33
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
34
+ return 'top-level value must be an object';
35
+ }
36
+ const hooks = parsed['hooks'];
37
+ if (hooks !== undefined && !Array.isArray(hooks)) {
38
+ return '`hooks` property must be an array when present';
39
+ }
40
+ return null;
41
+ }
42
+ function validateChainsShape(parsed) {
43
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
44
+ return 'top-level value must be an object';
45
+ }
46
+ for (const [event, body] of Object.entries(parsed)) {
47
+ if (!Array.isArray(body)) {
48
+ return `event "${event}": value must be an array of hook entries`;
49
+ }
50
+ }
51
+ return null;
52
+ }
53
+ export function probeHooks(ctx, fs) {
54
+ const mvpPath = `${ctx.cwd}/.pugi/hooks-mvp.json`;
55
+ const chainsPath = `${ctx.cwd}/.pugi/hook-chains.json`;
56
+ const mvp = loadHookFile(fs, mvpPath);
57
+ const chains = loadHookFile(fs, chainsPath);
58
+ const issues = [];
59
+ let mvpCount = 0;
60
+ let chainEvents = 0;
61
+ if (mvp !== null) {
62
+ if ('parseError' in mvp) {
63
+ issues.push(`hooks-mvp.json parse failed: ${mvp.parseError}`);
64
+ }
65
+ else {
66
+ const shapeIssue = validateMvpShape(mvp.parsed);
67
+ if (shapeIssue) {
68
+ issues.push(`hooks-mvp.json: ${shapeIssue}`);
69
+ }
70
+ else {
71
+ const hooksArr = mvp.parsed['hooks'];
72
+ mvpCount = Array.isArray(hooksArr) ? hooksArr.length : 0;
73
+ }
74
+ }
75
+ }
76
+ if (chains !== null) {
77
+ if ('parseError' in chains) {
78
+ issues.push(`hook-chains.json parse failed: ${chains.parseError}`);
79
+ }
80
+ else {
81
+ const shapeIssue = validateChainsShape(chains.parsed);
82
+ if (shapeIssue) {
83
+ issues.push(`hook-chains.json: ${shapeIssue}`);
84
+ }
85
+ else {
86
+ chainEvents = Object.keys(chains.parsed).length;
87
+ }
88
+ }
89
+ }
90
+ if (issues.length > 0) {
91
+ return {
92
+ name: 'HOOKS',
93
+ status: 'error',
94
+ detail: issues.join('; '),
95
+ remediation: 'Fix the JSON syntax / shape, or delete the file if hooks are not in use. The hook loader surfaces the canonical error when a hook actually fires; this probe catches the problem before the first dispatch.',
96
+ };
97
+ }
98
+ if (mvp === null && chains === null) {
99
+ return {
100
+ name: 'HOOKS',
101
+ status: 'skipped',
102
+ detail: 'no hook config files in .pugi/ (workspace has not opted into hooks)',
103
+ };
104
+ }
105
+ const parts = [];
106
+ if (mvp !== null && !('parseError' in mvp)) {
107
+ parts.push(`hooks-mvp.json OK (${mvpCount} entr${mvpCount === 1 ? 'y' : 'ies'})`);
108
+ }
109
+ if (chains !== null && !('parseError' in chains)) {
110
+ parts.push(`hook-chains.json OK (${chainEvents} event${chainEvents === 1 ? '' : 's'})`);
111
+ }
112
+ return {
113
+ name: 'HOOKS',
114
+ status: 'ok',
115
+ detail: parts.join('; '),
116
+ };
117
+ }
118
+ //# sourceMappingURL=hooks.js.map
@@ -0,0 +1,40 @@
1
+ /**
2
+ * SANDBOX probe — surfaces the current OS-level sandbox posture (Leak L1
3
+ * spec: sandbox-adapter.ts macOS Seatbelt / Linux Landlock / WSL2 detect).
4
+ *
5
+ * Pugi sandbox enforcement is tracked under task #5 (P0/L1+L16). Until
6
+ * that lands, this probe reports the platform's available primitive and
7
+ * a clear "not yet armed" warning so the operator sees the gap in
8
+ * `pugi doctor` instead of assuming bash dispatches run jailed.
9
+ *
10
+ * When the sandbox does ship, the probe upgrade path:
11
+ * - Replace the static "not_armed" detail with a real config probe
12
+ * (read .pugi/settings.json::sandbox.mode, verify the OS primitive
13
+ * resolves, return ok when both line up).
14
+ * - Keep the same probe NAME so doctor output / spec assertions
15
+ * don't churn.
16
+ */
17
+ export function probeSandbox(_ctx) {
18
+ const platform = process.platform;
19
+ let availablePrimitive;
20
+ switch (platform) {
21
+ case 'darwin':
22
+ availablePrimitive = 'macOS Seatbelt (/usr/bin/sandbox-exec)';
23
+ break;
24
+ case 'linux':
25
+ availablePrimitive = 'Linux Landlock / nsjail (kernel-dependent)';
26
+ break;
27
+ case 'win32':
28
+ availablePrimitive = 'Windows AppContainer / Job Object';
29
+ break;
30
+ default:
31
+ availablePrimitive = `unknown platform ${platform}`;
32
+ }
33
+ return {
34
+ name: 'SANDBOX',
35
+ status: 'warn',
36
+ detail: `OS primitive available: ${availablePrimitive}. Sandbox enforcement NOT yet armed (Pugi task #5 pending — bash tool currently runs с full process privileges).`,
37
+ remediation: 'Bash tool dispatches run unsandboxed today. Track progress on the OS-level sandbox adapter via the operator-trust roadmap. Until then, rely on the bash classifier denylist + permission FSM.',
38
+ };
39
+ }
40
+ //# sourceMappingURL=sandbox.js.map
@@ -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
@@ -51,6 +51,8 @@ import { probeSession } from '../../core/diagnostics/probes/session.js';
51
51
  import { probeDenialTracking } from '../../core/diagnostics/probes/denial-tracking.js';
52
52
  import { probeBareMode } from '../../core/diagnostics/probes/bare-mode.js';
53
53
  import { probePugiMdHierarchy } from '../../core/diagnostics/probes/pugi-md.js';
54
+ import { probeSandbox } from '../../core/diagnostics/probes/sandbox.js';
55
+ import { probeHooks } from '../../core/diagnostics/probes/hooks.js';
54
56
  /**
55
57
  * Default API URL when no PUGI_API_URL env override is set. Mirrors
56
58
  * the constant in `core/credentials.ts` (kept local to avoid an
@@ -219,6 +221,26 @@ export function buildDefaultProbes(ctx, options = {}) {
219
221
  // ambient `PUGI.md` / `CLAUDE.md` files the cwd → homedir walk
220
222
  // discovered, and the closest path. `skipped` when bare mode is
221
223
  // active (walk disabled) or zero files found.
224
+ // Leak L3 (2026-05-28): SANDBOX row. Reports the platform's available
225
+ // OS-level sandbox primitive (Seatbelt / Landlock / AppContainer) and
226
+ // surfaces a `warning` status until the bash-tool sandbox adapter (#5)
227
+ // is armed. Operator-trust gap visibility — better к flag "not yet
228
+ // jailed" loud than let operators assume it's already on.
229
+ {
230
+ name: 'SANDBOX',
231
+ run: async () => probeSandbox(ctx),
232
+ },
233
+ // Leak L3 (2026-05-28): HOOKS row. Validates `.pugi/hooks-mvp.json`
234
+ // + `.pugi/hook-chains.json` syntax + shape before the first tool
235
+ // dispatch fires. Absence = skipped (most workspaces don't ship
236
+ // hooks); bad JSON = error с remediation hint.
237
+ {
238
+ name: 'HOOKS',
239
+ run: async () => probeHooks(ctx, {
240
+ existsSync,
241
+ readFileSync: (p, encoding) => readFileSync(p, encoding),
242
+ }),
243
+ },
222
244
  {
223
245
  name: 'PUGI.md HIERARCHY',
224
246
  run: async () => probePugiMdHierarchy({
@@ -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.42');
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,268 @@
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 { listDestructivePatterns } from '../core/bash-classifier.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
+ /**
30
+ * PowerShell-specific destructive patterns. Layered ON TOP of the
31
+ * shared `listDestructivePatterns()` from the bash classifier (which
32
+ * covers `rm -rf`, `DROP TABLE`, etc — patterns that also surface в
33
+ * pwsh-via-aliases). These are the cmdlet forms unique to pwsh.
34
+ *
35
+ * Patterns are case-insensitive matched against the command string
36
+ * (pwsh cmdlets accept any case: `remove-item -force` == `Remove-Item -Force`).
37
+ */
38
+ const PWSH_DESTRUCTIVE_PATTERNS = [
39
+ // Recursive force delete via cmdlet
40
+ 'remove-item -recurse -force',
41
+ 'remove-item -force -recurse',
42
+ 'ri -recurse -force',
43
+ 'ri -force -recurse',
44
+ 'rmdir -recurse -force',
45
+ 'rmdir -force -recurse',
46
+ // Disk / volume operations
47
+ 'format-volume',
48
+ 'clear-disk',
49
+ 'reset-physicaldisk',
50
+ // System state
51
+ 'stop-computer',
52
+ 'restart-computer',
53
+ 'shutdown',
54
+ // Security weakening
55
+ 'set-executionpolicy unrestricted',
56
+ 'set-executionpolicy bypass',
57
+ // Service / process attack surface
58
+ 'invoke-webrequest', // common phishing-script vector when piped to iex
59
+ 'iex (new-object', // download-execute pattern
60
+ // Credential exfil
61
+ 'get-credential | export-clixml',
62
+ ];
63
+ /**
64
+ * Normalize whitespace before pattern matching: collapse runs of
65
+ * whitespace к single space + lowercase. Defends against the
66
+ * `iex(New-Object`/`IEX (New-Object` style bypass where pattern
67
+ * `iex (new-object` would miss the no-space or double-space variant.
68
+ */
69
+ function normalizeForMatch(text) {
70
+ return text.toLowerCase().replace(/\s+/g, ' ');
71
+ }
72
+ function findPwshDestructiveMatch(cmd) {
73
+ const normalized = normalizeForMatch(cmd);
74
+ for (const pattern of PWSH_DESTRUCTIVE_PATTERNS) {
75
+ if (normalized.includes(normalizeForMatch(pattern)))
76
+ return pattern;
77
+ }
78
+ // Fall back к the shared bash destructive list (covers cross-shell
79
+ // patterns like `rm -rf /`, `DROP DATABASE`). Shared patterns may
80
+ // contain uppercase (case-insensitive SQL verbs); normalize both
81
+ // sides before compare.
82
+ const shared = listDestructivePatterns();
83
+ for (const pattern of shared) {
84
+ if (normalized.includes(normalizeForMatch(pattern)))
85
+ return pattern;
86
+ }
87
+ return null;
88
+ }
89
+ /**
90
+ * PowerShell-aware permission decision. Differs from
91
+ * `evaluateBashPermission` в two ways:
92
+ *
93
+ * 1. Default class is `allow` (after destructive check) instead of
94
+ * `unknown → deny`. The bash classifier rejects any first-token
95
+ * it does not recognise — appropriate for bash where every verb
96
+ * is a separate binary, hostile for pwsh where the Verb-Noun
97
+ * cmdlet convention means thousands of legitimate verbs exist
98
+ * (`Get-Process`, `$PSVersionTable`, `Select-Object`, ...).
99
+ *
100
+ * 2. Destructive patterns combine the shared bash denylist (covers
101
+ * cross-shell patterns like `rm -rf`) с pwsh-specific cmdlet
102
+ * forms (`Remove-Item -Recurse -Force`, `Format-Volume`, etc).
103
+ *
104
+ * Mode FSM mirrors bash: plan → deny ALL, ask → ask, auto/bypass → allow,
105
+ * destructive class → deny unless `bypassPermissions + human + ENV override`.
106
+ */
107
+ function evaluatePwshPermission(cmd, mode, source) {
108
+ const destructive = findPwshDestructiveMatch(cmd);
109
+ if (destructive !== null) {
110
+ const overrideOk = mode === 'bypassPermissions' &&
111
+ source === 'human' &&
112
+ process.env['PUGI_DESTRUCTIVE_OVERRIDE'] === '1';
113
+ if (overrideOk) {
114
+ return {
115
+ decision: 'allow',
116
+ reason: `destructive pwsh pattern '${destructive}' allowed via override (bypassPermissions + human + PUGI_DESTRUCTIVE_OVERRIDE=1)`,
117
+ };
118
+ }
119
+ return {
120
+ decision: 'deny',
121
+ reason: `destructive pwsh pattern '${destructive}' is always denied (override requires bypassPermissions + human + PUGI_DESTRUCTIVE_OVERRIDE=1)`,
122
+ };
123
+ }
124
+ // Non-destructive pwsh command — mode FSM.
125
+ switch (mode) {
126
+ case 'plan':
127
+ return { decision: 'deny', reason: 'plan mode denies all shell dispatches' };
128
+ case 'ask':
129
+ case 'acceptEdits':
130
+ return { decision: 'ask', reason: 'pwsh command requires operator confirmation' };
131
+ case 'auto':
132
+ case 'dontAsk':
133
+ case 'bypassPermissions':
134
+ return { decision: 'allow', reason: 'pwsh command allowed by mode' };
135
+ default:
136
+ return { decision: 'ask', reason: `unknown mode ${mode}; defaulting к ask` };
137
+ }
138
+ }
139
+ /** Cached binary path so repeated calls inside one session skip the probe. */
140
+ let cachedShellBinary;
141
+ function resolveShellBinary() {
142
+ if (cachedShellBinary !== undefined)
143
+ return cachedShellBinary;
144
+ // Try pwsh (cross-platform PowerShell 7+) first.
145
+ const pwshProbe = spawnSync('pwsh', ['-NoProfile', '-Command', 'exit 0'], {
146
+ encoding: 'utf8',
147
+ stdio: ['ignore', 'ignore', 'ignore'],
148
+ timeout: 3000,
149
+ });
150
+ if (pwshProbe.status === 0) {
151
+ cachedShellBinary = 'pwsh';
152
+ return 'pwsh';
153
+ }
154
+ // Windows fallback к the baked-in PowerShell 5.1.
155
+ if (process.platform === 'win32') {
156
+ const wpsProbe = spawnSync('powershell.exe', ['-NoProfile', '-Command', 'exit 0'], {
157
+ encoding: 'utf8',
158
+ stdio: ['ignore', 'ignore', 'ignore'],
159
+ timeout: 3000,
160
+ });
161
+ if (wpsProbe.status === 0) {
162
+ cachedShellBinary = 'powershell.exe';
163
+ return 'powershell.exe';
164
+ }
165
+ }
166
+ cachedShellBinary = null;
167
+ return null;
168
+ }
169
+ function sanitizeTimeout(value) {
170
+ if (value === undefined || !Number.isFinite(value) || value <= 0) {
171
+ return POWERSHELL_DEFAULT_TIMEOUT_MS;
172
+ }
173
+ return Math.min(value, POWERSHELL_MAX_TIMEOUT_MS);
174
+ }
175
+ function buildChildEnv() {
176
+ const env = { ...process.env };
177
+ delete env['PUGI_API_KEY'];
178
+ delete env['PUGI_LOGIN_TOKEN'];
179
+ return env;
180
+ }
181
+ /**
182
+ * Sync PowerShell dispatch. Mirrors bashToolSync shape so dispatchTool
183
+ * can call either tool with the same context shape.
184
+ */
185
+ export function powerShellToolSync(input, ctx) {
186
+ const cmd = input.cmd ?? '';
187
+ const source = ctx.source ?? 'agent';
188
+ const toolCallId = recordToolCall(ctx.session, 'powershell', cmd);
189
+ // pwsh-aware permission gate (NOT the bash classifier). Bash classifier
190
+ // would reject `$PSVersionTable`, `Get-Process`, etc as "Unrecognized
191
+ // command" → default-deny, making the pwsh tool useless. The pwsh gate
192
+ // applies the shared destructive denylist (rm -rf / DROP TABLE) + a
193
+ // pwsh-specific list (Remove-Item -Recurse -Force / Format-Volume /
194
+ // Set-ExecutionPolicy Unrestricted / iex (New-Object ...)) and
195
+ // defaults non-destructive cmdlets к allow under mode FSM.
196
+ const decision = evaluatePwshPermission(cmd, ctx.settings.permissions.mode, source);
197
+ if (decision.decision !== 'allow') {
198
+ const reason = `Permission ${decision.decision}: ${decision.reason}`;
199
+ recordToolResult(ctx.session, toolCallId, 'error', reason);
200
+ return {
201
+ stdout: '',
202
+ stderr: `Permission ${decision.decision}: ${decision.reason}`,
203
+ exitCode: 126,
204
+ truncated: false,
205
+ timedOut: false,
206
+ shellBinary: 'unresolved',
207
+ };
208
+ }
209
+ const shellBinary = resolveShellBinary();
210
+ if (shellBinary === null) {
211
+ const reason = 'powershell binary not found (tried pwsh' +
212
+ (process.platform === 'win32' ? ', powershell.exe' : '') +
213
+ '). Install PowerShell 7+ from https://aka.ms/powershell or use the bash tool instead.';
214
+ recordToolResult(ctx.session, toolCallId, 'error', reason);
215
+ return {
216
+ stdout: '',
217
+ stderr: reason,
218
+ exitCode: 127,
219
+ truncated: false,
220
+ timedOut: false,
221
+ shellBinary: 'unavailable',
222
+ };
223
+ }
224
+ const timeoutMs = sanitizeTimeout(input.timeoutMs);
225
+ const childEnv = buildChildEnv();
226
+ const cwd = input.cwd ?? ctx.root;
227
+ const result = spawnSync(shellBinary, ['-NoProfile', '-Command', cmd], {
228
+ cwd,
229
+ env: childEnv,
230
+ encoding: 'utf8',
231
+ stdio: ['ignore', 'pipe', 'pipe'],
232
+ timeout: timeoutMs,
233
+ maxBuffer: 10 * 1024 * 1024,
234
+ });
235
+ const stdoutFull = (result.stdout ?? '').toString();
236
+ const stderrFull = (result.stderr ?? '').toString();
237
+ const combined = stdoutFull.length + stderrFull.length;
238
+ const truncated = combined > POWERSHELL_OUTPUT_CAP_BYTES;
239
+ let stdoutOut = stdoutFull;
240
+ let stderrOut = stderrFull;
241
+ if (truncated) {
242
+ const halfCap = POWERSHELL_OUTPUT_CAP_BYTES / 2;
243
+ stdoutOut = stdoutFull.slice(0, halfCap);
244
+ stderrOut = stderrFull.slice(0, halfCap);
245
+ }
246
+ const timedOut = result.error?.code === 'ETIMEDOUT' ||
247
+ result.signal === 'SIGTERM';
248
+ const exitCode = timedOut ? 124 : result.status ?? 1;
249
+ if (timedOut) {
250
+ recordToolResult(ctx.session, toolCallId, 'error', `powershell timed out after ${timeoutMs}ms`);
251
+ }
252
+ else {
253
+ recordToolResult(ctx.session, toolCallId, 'success', `powershell exit=${exitCode} bytes=${combined} binary=${shellBinary}`);
254
+ }
255
+ return {
256
+ stdout: stdoutOut,
257
+ stderr: stderrOut,
258
+ exitCode,
259
+ truncated,
260
+ timedOut,
261
+ shellBinary,
262
+ };
263
+ }
264
+ /** Visible-for-spec helper: forces a re-probe on next call. */
265
+ export function _resetShellBinaryCacheForSpec() {
266
+ cachedShellBinary = undefined;
267
+ }
268
+ //# 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 },
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * Hand-crafted at 9 rows × 20 columns to read as a pug at a single
5
5
  * glance — references the cyber-zoo hero glyph in
6
- * `apps/clawhost-web/public/brand/hero-pug.png`: blocky pug face with
6
+ * `apps/console-web/public/brand/hero-pug.png`: blocky pug face with
7
7
  * angular ear flaps on either side of the head, forehead crease,
8
8
  * angular cyan eyes (`◉`), smushed snout, undershot jaw, and a small
9
9
  * cyan circuit chip (`▐■▌`) on the lower-right cheek.
@@ -22,7 +22,7 @@
22
22
  *
23
23
  * Generation (operator-side, one-shot):
24
24
  * chafa --size 80x40 --symbols=vhalf --colors=full \
25
- * apps/clawhost-web/public/brand/hero-pug.png \
25
+ * apps/console-web/public/brand/hero-pug.png \
26
26
  * > apps/pugi-cli/assets/pugi-mascot.ansi
27
27
  *
28
28
  * The output is committed verbatim to the repo and shipped inside the
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.42",
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.42"
59
59
  },
60
60
  "devDependencies": {
61
61
  "@types/node": "^22.0.0",