@pugi/cli 0.1.0-beta.47 → 0.1.0-beta.49

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.
@@ -346,6 +346,93 @@ const BUILD_TEST_PREFIXES = [
346
346
  'tsc -p',
347
347
  'eslint',
348
348
  'prettier --check',
349
+ // P0 fix 2026-05-29 (#37 CRITICAL): customer-blocking gap surfaced
350
+ // during dogfood. Engine emitted `chmod +x build.sh`, `node script.js`,
351
+ // `python3 -m pytest`, `git status`, `pnpm build`, `docker ps`, etc.
352
+ // and the classifier returned `unknown` → permission matrix denied
353
+ // в bypassPermissions mode (which the customer expected to auto-allow
354
+ // basic dev tools). Customers could not run ANY real build/test/git
355
+ // workflow through Pugi.
356
+ //
357
+ // The prefixes below cover three classes of developer tooling that
358
+ // are always allowed in `auto`/`dontAsk`/`bypassPermissions` modes,
359
+ // `ask` in interactive modes, and `deny` in `plan` (read-only) mode:
360
+ // - Language runtimes: `node`, `python`, `python3`, `ruby`, etc.
361
+ // - Native build chains: `gcc`, `clang`, `cmake`, `rustc`, etc.
362
+ // - Container/k8s read-class: `docker ps/inspect/logs`, `kubectl get`.
363
+ //
364
+ // Destructive variants are already gated upstream by DESTRUCTIVE_PATTERNS
365
+ // (e.g. `docker system prune`, `kubectl delete --all`). The first-token
366
+ // gate in classifyComponent runs THIS list before the unknown fallback.
367
+ //
368
+ // Language runtime invocations (first-token match, with or without args).
369
+ 'node',
370
+ 'python',
371
+ 'python3',
372
+ 'ruby',
373
+ 'perl',
374
+ 'php',
375
+ 'deno',
376
+ 'bun',
377
+ 'tsx',
378
+ 'ts-node',
379
+ // Native build chains.
380
+ 'gcc',
381
+ 'g++',
382
+ 'clang',
383
+ 'clang++',
384
+ 'cmake',
385
+ 'rustc',
386
+ 'javac',
387
+ 'java',
388
+ // Container/k8s read-class (the destructive subcommands are pre-empted
389
+ // by DESTRUCTIVE_PATTERNS: `docker system prune`, `kubectl delete --all`,
390
+ // `kubectl delete namespace`).
391
+ 'docker ps',
392
+ 'docker images',
393
+ 'docker inspect',
394
+ 'docker logs',
395
+ 'docker version',
396
+ 'docker info',
397
+ 'docker exec',
398
+ 'docker run',
399
+ 'docker stop',
400
+ 'docker start',
401
+ 'docker restart',
402
+ 'docker rm',
403
+ 'docker rmi',
404
+ 'docker build',
405
+ 'docker tag',
406
+ 'docker compose',
407
+ 'docker-compose',
408
+ 'kubectl get',
409
+ 'kubectl describe',
410
+ 'kubectl logs',
411
+ 'kubectl exec',
412
+ 'kubectl apply',
413
+ 'kubectl create',
414
+ 'kubectl rollout',
415
+ 'kubectl port-forward',
416
+ 'kubectl config',
417
+ // Git read+write surface (network ops already handled by NETWORK_PREFIXES;
418
+ // destructive ops `reset --hard`/`clean -fdx`/`push --force` blocked above).
419
+ // Note: WRITE_WORKSPACE_PREFIXES already covers `git commit/add/checkout/...`.
420
+ // These entries handle plain `git rev-list`, `git cherry-pick`, `git worktree`,
421
+ // `git submodule`, etc that customer scripts commonly invoke.
422
+ 'git rev-list',
423
+ 'git cherry-pick',
424
+ 'git worktree',
425
+ 'git submodule',
426
+ 'git blame',
427
+ 'git describe',
428
+ 'git tag --list',
429
+ 'git tag -l',
430
+ 'git for-each-ref',
431
+ 'git ls-remote',
432
+ // gh CLI (GitHub). `gh repo delete` / `gh release delete` reach into
433
+ // network operations but are non-destructive for the local workspace.
434
+ // Permission matrix asks before allowing in auto.
435
+ 'gh',
349
436
  ];
350
437
  /** Single-token read-only commands. Argument-free entries match exact. */
351
438
  const READ_TOKENS = new Set([
@@ -384,6 +471,16 @@ const READ_TOKENS = new Set([
384
471
  'cut',
385
472
  'sort',
386
473
  'uniq',
474
+ // P0 fix 2026-05-29 (#37 CRITICAL): structured-data inspection tools
475
+ // are pure stdin/stdout transformers (no FS write, no network) when
476
+ // не paired с `>` redirection (the redirection branch above promotes
477
+ // к write_workspace independently). Common в dev scripts for parsing
478
+ // package.json, tsconfig.json, Helm values.yaml, etc.
479
+ // `tee` is INTENTIONALLY excluded — it writes by definition, even
480
+ // в protected paths (`tee /etc/...` is already in DESTRUCTIVE_PATTERNS).
481
+ 'jq',
482
+ 'yq',
483
+ 'column',
387
484
  ]);
388
485
  const READ_PREFIXES = [
389
486
  'git status',
@@ -418,6 +515,16 @@ const WRITE_WORKSPACE_PREFIXES = [
418
515
  'git tag',
419
516
  'git rebase',
420
517
  'git merge',
518
+ // P0 fix 2026-05-29 (#37 CRITICAL): file-permission ops are common
519
+ // в build scripts (`chmod +x build.sh`, `chown $USER file`). The
520
+ // destructive variants (`chmod 777 /`, `chmod -R 777 /`, `chmod -R
521
+ // 777 ~`, `chown -R root /`, `chown -R / ...`) are pre-empted by
522
+ // DESTRUCTIVE_PATTERNS which runs BEFORE this list — safe to add
523
+ // here for the non-destructive path. detectProtectedWrite's `\bchmod\b`
524
+ // / `\bchown\b` regex also catches writes into protected paths
525
+ // regardless of this list.
526
+ 'chmod ',
527
+ 'chown ',
421
528
  ];
422
529
  /**
423
530
  * Protected-write triggers. If a command writes to any of these paths
@@ -636,6 +743,25 @@ function classifyComponent(cmd, ctx) {
636
743
  if (trimmed === 'make' || trimmed.startsWith('make ')) {
637
744
  return { class: 'build_test', reason: 'make runner', matched: 'make' };
638
745
  }
746
+ // 7c. Operator-override safe tokens (P0 fix 2026-05-29 #37).
747
+ // `PUGI_CLASSIFIER_EXTRA_SAFE=tool1,tool2,...` extends the BUILD_TEST
748
+ // first-token list at runtime. This is a security-sensitive escape
749
+ // hatch — operators can add their custom build tools without a
750
+ // recompile. Destructive patterns ALREADY ran above (step 1) so this
751
+ // cannot whitelist `rm`, `mkfs`, `git push --force`, etc. The match
752
+ // is strict first-token equality — not substring — and the env var
753
+ // is read fresh on every classify call so tests can mutate it.
754
+ const extraSafe = readExtraSafeTokens();
755
+ if (extraSafe.size > 0) {
756
+ const firstTokenForExtraSafe = trimmed.split(/\s+/)[0] ?? '';
757
+ if (extraSafe.has(firstTokenForExtraSafe)) {
758
+ return {
759
+ class: 'build_test',
760
+ reason: `PUGI_CLASSIFIER_EXTRA_SAFE override: ${firstTokenForExtraSafe}`,
761
+ matched: firstTokenForExtraSafe,
762
+ };
763
+ }
764
+ }
639
765
  // 7c. Bare `cd <path>` (inside workspace — the cwd-escape detector
640
766
  // upgrades the class to write_protected when the target is
641
767
  // outside). Standalone `cd` (HOME) is escape, also handled by the
@@ -756,6 +882,40 @@ function nestingDepth(cmd, open, close) {
756
882
  function escapeRegex(s) {
757
883
  return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
758
884
  }
885
+ /**
886
+ * Operator-override safe tokens. Read from `PUGI_CLASSIFIER_EXTRA_SAFE`
887
+ * (comma-separated). Allows operators to extend the BUILD_TEST first-
888
+ * token list at runtime for site-specific tooling без recompile.
889
+ *
890
+ * Security note: destructive substring patterns run BEFORE this gate
891
+ * (step 1 in classifyComponent), so this cannot whitelist `rm`, `mkfs`,
892
+ * `git push --force`, etc. The env var only adds tools to the benign
893
+ * build_test class. Invalid entries (empty strings, тokens containing
894
+ * shell metas) are silently dropped to avoid surprising classifications.
895
+ *
896
+ * Read fresh on every call so per-test mutations work и so operators
897
+ * can update without restarting the agent loop. The cost (one env var
898
+ * read + Set construction per call) is negligible for the classifier's
899
+ * call frequency.
900
+ */
901
+ function readExtraSafeTokens() {
902
+ const raw = process.env.PUGI_CLASSIFIER_EXTRA_SAFE;
903
+ if (!raw || raw.trim() === '')
904
+ return new Set();
905
+ const tokens = new Set();
906
+ for (const candidate of raw.split(',')) {
907
+ const trimmed = candidate.trim();
908
+ if (trimmed === '')
909
+ continue;
910
+ // Reject anything containing shell metas or whitespace — only bare
911
+ // tool names allowed. Defends against accidental
912
+ // `PUGI_CLASSIFIER_EXTRA_SAFE='rm -rf /'` smuggling.
913
+ if (/[\s;|&<>$`(){}\[\]'"\\]/.test(trimmed))
914
+ continue;
915
+ tokens.add(trimmed);
916
+ }
917
+ return tokens;
918
+ }
759
919
  function detectProtectedWrite(cmd, ctx) {
760
920
  // Surface every write target this command produces so we can both
761
921
  // protected-path-check and outside-workspace-check them uniformly.
@@ -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');
47
+ export const PUGI_CLI_VERSION = sanitizeSemver('0.1.0-beta.49');
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.
package/dist/tui/repl.js CHANGED
@@ -108,17 +108,20 @@ export function Repl(props) {
108
108
  setSplashVisible(false);
109
109
  }
110
110
  }, [splashVisible, state.agents.length, state.transcript.length]);
111
- // CEO P0 #2 (2026-05-29): same dismissal contract for the welcome
112
- // banner. The banner is the "boot card" once any agent fires or
113
- // the transcript gains а row, the banner clears так the conversation
114
- // pane owns the vertical real estate.
111
+ // CEO P0 #2 (2026-05-29) v2: welcome banner stays until the operator
112
+ // actively engages the loopfirst agent spawn. Boot-time auto-init
113
+ // emits system rows into `state.transcript` (skip-trust hints, dirty
114
+ // tree warnings) which used к dismiss the banner within ~2s, hiding
115
+ // the brand mascot before the operator could read it. Drop the
116
+ // `transcript.length` trigger; agent spawn (= real dispatch) remains
117
+ // the sole signal that the operator stopped reading the banner.
115
118
  useEffect(() => {
116
119
  if (!welcomeVisible)
117
120
  return;
118
- if (state.agents.length > 0 || state.transcript.length > 0) {
121
+ if (state.agents.length > 0) {
119
122
  setWelcomeVisible(false);
120
123
  }
121
- }, [welcomeVisible, state.agents.length, state.transcript.length]);
124
+ }, [welcomeVisible, state.agents.length]);
122
125
  const personaNames = useMemo(() => buildPersonaNameMap(), []);
123
126
  const { exit } = useApp();
124
127
  const handleSubmit = useCallback((line) => {
@@ -255,7 +258,7 @@ export function Repl(props) {
255
258
  // input, and the input stays the sole focusable surface adjacent
256
259
  // to the cursor row, so all keystrokes route through it.
257
260
  const altScreenRows = process.stdout.rows ?? 24;
258
- return (_jsxs(Box, { flexDirection: "column", paddingX: 1, minHeight: altScreenRows, children: [props.updateBanner ? _jsx(UpdateBanner, { result: props.updateBanner }) : null, welcomeVisible && props.welcomeData ? (_jsx(WelcomeBanner, { data: props.welcomeData, mascotPrePrinted: props.mascotPrePrinted === true, autoInitStatus: props.autoInitStatus ?? null })) : null, splashVisible ? (_jsx(ReplSplash, { cliVersion: state.cliVersion, workspaceLabel: state.workspaceLabel, plan: props.splashPlan, model: props.splashModel, tenant: props.splashTenant, onDismiss: dismissSplash, mascotPrePrinted: props.mascotPrePrinted === true })) : null, _jsx(Header, { state: state }), _jsx(Box, { flexDirection: "column", marginTop: 1, flexGrow: 1, justifyContent: "flex-end", children: overlay === 'help' ? (_jsx(HelpOverlay, {})) : overlay === 'roster' ? (_jsx(RosterOverlay, {})) : overlay === 'farewell' ? (_jsx(FarewellOverlay, {})) : (_jsx(MainArea, { state: state, personaNames: personaNames, nowEpochMs: tickNow, hideToolStream: props.hideToolStream === true, toolStreamCollapsed: toolStreamCollapsed })) }), state.pendingAsk ? (_jsx(Box, { marginTop: 1, children: _jsx(AskModal, { tag: state.pendingAsk, onResolve: handleAskResolve }) })) : null, state.pendingPlanReview ? (_jsx(Box, { marginTop: 1, children: _jsx(PlanReviewModal, { tag: state.pendingPlanReview, onResolve: handlePlanReviewResolve }) })) : null, _jsxs(Box, { flexDirection: "column", marginTop: 1, children: [overlay === 'farewell' || modalActive ? null : (_jsx(InputBox, { onSubmit: handleSubmit, onExit: handleExit, onCancel: handleCancel, onWalkback: handleWalkback, onCyclePermissionMode: handleCyclePermissionMode, now: props.now,
261
+ return (_jsxs(Box, { flexDirection: "column", paddingX: 1, minHeight: altScreenRows, children: [welcomeVisible && props.welcomeData ? (_jsx(WelcomeBanner, { data: props.welcomeData, mascotPrePrinted: props.mascotPrePrinted === true, autoInitStatus: props.autoInitStatus ?? null })) : null, splashVisible ? (_jsx(ReplSplash, { cliVersion: state.cliVersion, workspaceLabel: state.workspaceLabel, plan: props.splashPlan, model: props.splashModel, tenant: props.splashTenant, onDismiss: dismissSplash, mascotPrePrinted: props.mascotPrePrinted === true })) : null, _jsx(Header, { state: state }), _jsx(Box, { flexDirection: "column", marginTop: 1, flexGrow: 1, justifyContent: "flex-end", children: overlay === 'help' ? (_jsx(HelpOverlay, {})) : overlay === 'roster' ? (_jsx(RosterOverlay, {})) : overlay === 'farewell' ? (_jsx(FarewellOverlay, {})) : (_jsx(MainArea, { state: state, personaNames: personaNames, nowEpochMs: tickNow, hideToolStream: props.hideToolStream === true, toolStreamCollapsed: toolStreamCollapsed })) }), state.pendingAsk ? (_jsx(Box, { marginTop: 1, children: _jsx(AskModal, { tag: state.pendingAsk, onResolve: handleAskResolve }) })) : null, state.pendingPlanReview ? (_jsx(Box, { marginTop: 1, children: _jsx(PlanReviewModal, { tag: state.pendingPlanReview, onResolve: handlePlanReviewResolve }) })) : null, _jsxs(Box, { flexDirection: "column", marginTop: 1, children: [overlay === 'farewell' || modalActive ? null : (_jsx(InputBox, { onSubmit: handleSubmit, onExit: handleExit, onCancel: handleCancel, onWalkback: handleWalkback, onCyclePermissionMode: handleCyclePermissionMode, now: props.now,
259
262
  // Slug from process.cwd() (full path) so two workspaces with
260
263
  // the same basename do not share history. state.workspaceLabel
261
264
  // is the basename only. Codex review P2.
@@ -263,7 +266,7 @@ export function Repl(props) {
263
266
  // α7 cost-meter sprint — surface accumulated session totals
264
267
  // + per-turn delta flash on the status bar's top row. The
265
268
  // session module owns accumulation; the bar is a pure render.
266
- sessionTokensIn: state.sessionTokensIn, sessionTokensOut: state.sessionTokensOut, sessionCostUsd: state.sessionCostUsd, sessionStartedAtEpochMs: state.sessionStartedAtEpochMs, lastTurnDelta: state.lastTurnDelta })] })] }));
269
+ sessionTokensIn: state.sessionTokensIn, sessionTokensOut: state.sessionTokensOut, sessionCostUsd: state.sessionCostUsd, sessionStartedAtEpochMs: state.sessionStartedAtEpochMs, lastTurnDelta: state.lastTurnDelta }), props.updateBanner ? _jsx(UpdateBanner, { result: props.updateBanner }) : null] })] }));
267
270
  }
268
271
  function Header({ state }) {
269
272
  // Leak L30 (2026-05-27): the header `.io` brand accent + connection
@@ -13,6 +13,13 @@ export function resolveDisplayedLatest(npmLatest, serverRecommended) {
13
13
  ? serverRecommended
14
14
  : npmLatest;
15
15
  }
16
+ /**
17
+ * Claude-Code-style corner banner: single line, dim orange, right
18
+ * aligned. Renders to the bottom of the REPL frame so it never
19
+ * displaces conversation content. Only mounts when an update is
20
+ * actually available; identical to no-op when the registry poll
21
+ * reports current. Suppress with `PUGI_SKIP_UPDATE_BANNER=1`.
22
+ */
16
23
  export function UpdateBanner({ result }) {
17
24
  const command = upgradeCommand(result.method);
18
25
  // Read the cache lazily inside the render so a server response that
@@ -21,6 +28,6 @@ export function UpdateBanner({ result }) {
21
28
  // do per render.
22
29
  const serverRecommended = getCachedServerRecommendation();
23
30
  const displayedLatest = resolveDisplayedLatest(result.latest, serverRecommended);
24
- return (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsxs(Box, { children: [_jsx(Text, { dimColor: true, children: '─ ' }), _jsx(Text, { bold: true, color: "#3da9fc", children: 'Pugi ' }), _jsx(Text, { children: result.installed }), _jsx(Text, { dimColor: true, children: ' (installed) ' }), _jsx(Text, { bold: true, children: displayedLatest }), _jsx(Text, { dimColor: true, children: ' (latest)' })] }), _jsxs(Box, { children: [_jsx(Text, { dimColor: true, children: ' Update: ' }), _jsx(Text, { color: "#3da9fc", children: command })] }), _jsx(Box, { children: _jsx(Text, { dimColor: true, children: ' Skip with PUGI_SKIP_UPDATE_BANNER=1' }) })] }));
31
+ return (_jsx(Box, { justifyContent: "flex-end", children: _jsxs(Text, { color: "#d97706", children: [_jsx(Text, { dimColor: true, children: 'Update available! ' }), _jsx(Text, { dimColor: true, children: `v${displayedLatest} Run: ` }), _jsx(Text, { bold: true, children: command })] }) }));
25
32
  }
26
33
  //# sourceMappingURL=update-banner.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pugi/cli",
3
- "version": "0.1.0-beta.47",
3
+ "version": "0.1.0-beta.49",
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.47"
58
+ "@pugi/sdk": "0.1.0-beta.49"
59
59
  },
60
60
  "devDependencies": {
61
61
  "@types/node": "^22.0.0",