@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.
- package/dist/core/diagnostics/probes/hooks.js +118 -0
- package/dist/core/diagnostics/probes/sandbox.js +40 -0
- package/dist/core/engine/tool-bridge.js +53 -0
- package/dist/runtime/commands/doctor.js +22 -0
- package/dist/runtime/version.js +1 -1
- package/dist/tools/powershell.js +268 -0
- package/dist/tools/registry.js +5 -0
- package/dist/tui/repl-splash-art.js +1 -1
- package/dist/tui/repl-splash-mascot.js +1 -1
- package/package.json +2 -2
|
@@ -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({
|
package/dist/runtime/version.js
CHANGED
|
@@ -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.
|
|
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
|
package/dist/tools/registry.js
CHANGED
|
@@ -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/
|
|
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/
|
|
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.
|
|
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.
|
|
58
|
+
"@pugi/sdk": "0.1.0-beta.42"
|
|
59
59
|
},
|
|
60
60
|
"devDependencies": {
|
|
61
61
|
"@types/node": "^22.0.0",
|