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

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
@@ -17,7 +17,7 @@ export const beta1DefaultBudgets = {
17
17
  code: { maxTokens: 80_000, maxToolCalls: 20 },
18
18
  build: { maxTokens: 200_000, maxToolCalls: 30 },
19
19
  plan: { maxTokens: 200_000, maxToolCalls: 8 },
20
- explain: { maxTokens: 20_000, maxToolCalls: 5 },
20
+ explain: { maxTokens: 40_000, maxToolCalls: 5 },
21
21
  review_triple: { maxTokens: 100_000, maxToolCalls: 10 },
22
22
  };
23
23
  /**
@@ -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.41');
47
+ export const PUGI_CLI_VERSION = sanitizeSemver('0.1.0-beta.43');
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.
@@ -21,11 +21,121 @@
21
21
  * based and apply equally to pwsh and sh.
22
22
  */
23
23
  import { spawnSync } from 'node:child_process';
24
- import { evaluateBashPermission } from '../core/permission.js';
24
+ import { listDestructivePatterns } from '../core/bash-classifier.js';
25
25
  import { recordToolCall, recordToolResult } from '../core/session.js';
26
26
  export const POWERSHELL_OUTPUT_CAP_BYTES = 64 * 1024;
27
27
  export const POWERSHELL_DEFAULT_TIMEOUT_MS = 30_000;
28
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
+ }
29
139
  /** Cached binary path so repeated calls inside one session skip the probe. */
30
140
  let cachedShellBinary;
31
141
  function resolveShellBinary() {
@@ -74,20 +184,22 @@ function buildChildEnv() {
74
184
  */
75
185
  export function powerShellToolSync(input, ctx) {
76
186
  const cmd = input.cmd ?? '';
77
- const additionalDirectories = ctx.additionalDirectories ?? [];
78
187
  const source = ctx.source ?? 'agent';
79
188
  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
- });
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);
85
197
  if (decision.decision !== 'allow') {
86
198
  const reason = `Permission ${decision.decision}: ${decision.reason}`;
87
199
  recordToolResult(ctx.session, toolCallId, 'error', reason);
88
200
  return {
89
201
  stdout: '',
90
- stderr: `Permission denied: ${decision.reason}`,
202
+ stderr: `Permission ${decision.decision}: ${decision.reason}`,
91
203
  exitCode: 126,
92
204
  truncated: false,
93
205
  timedOut: false,
@@ -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.41",
3
+ "version": "0.1.0-beta.43",
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.41"
58
+ "@pugi/sdk": "0.1.0-beta.43"
59
59
  },
60
60
  "devDependencies": {
61
61
  "@types/node": "^22.0.0",