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

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.
@@ -119,6 +119,63 @@ const DESTRUCTIVE_PATTERNS = [
119
119
  // History destruction
120
120
  { pattern: 'history -c' },
121
121
  { pattern: ' >/dev/null 2>&1; rm' },
122
+ // ---------------------------------------------------------------
123
+ // Patterns ported from KeiSeiKit `destructive-guard.sh` (Apache-2.0)
124
+ // and `safety-guard.sh` BLOCKED_PATTERNS array. Upstream source:
125
+ // /tmp/KeiSeiKit/hooks/destructive-guard.sh (lines 7-13)
126
+ // /tmp/KeiSeiKit/hooks/safety-guard.sh (lines 14-50)
127
+ // Attribution: licenses/keiseikit-LICENSE-NOTICE.md.
128
+ //
129
+ // The patterns below need word-boundary matching because their
130
+ // tokens (kill, halt, reboot, ...) appear as substrings of common
131
+ // unrelated words (skills, default, chrooted-rebooter, etc.).
132
+ // Substring `.includes` cannot express that — `regex` is required.
133
+ // ---------------------------------------------------------------
134
+ // Process termination — `kill`, `pkill`, `killall` at command head
135
+ // or after `sudo`. Matches `kill 1234`, `kill -9 $$`, `sudo killall
136
+ // node`, but NOT `skill issue` (no leading boundary) or
137
+ // `git commit -m "skill kill story"` (the kill is inside a quoted
138
+ // string — quote-aware split handled upstream; here we still need
139
+ // the boundary). Anchored to start-of-component or `sudo ` prefix.
140
+ {
141
+ pattern: 'kill',
142
+ regex: /^(?:sudo\s+)?(?:pkill|killall|kill)\b/,
143
+ },
144
+ // System power state — reboot / shutdown / halt / poweroff / init 0
145
+ // / init 6. KeiSei matches these anywhere in the command; we
146
+ // tighten to start-of-component or `sudo ` prefix to avoid FPs on
147
+ // file paths or variable names containing the substring.
148
+ {
149
+ pattern: 'reboot',
150
+ regex: /^(?:sudo\s+)?reboot\b/,
151
+ },
152
+ {
153
+ pattern: 'shutdown',
154
+ regex: /^(?:sudo\s+)?shutdown\b/,
155
+ },
156
+ {
157
+ pattern: 'halt',
158
+ regex: /^(?:sudo\s+)?halt\b/,
159
+ },
160
+ {
161
+ pattern: 'poweroff',
162
+ regex: /^(?:sudo\s+)?poweroff\b/,
163
+ },
164
+ {
165
+ pattern: 'init 0',
166
+ regex: /^(?:sudo\s+)?init\s+0\b/,
167
+ },
168
+ {
169
+ pattern: 'init 6',
170
+ regex: /^(?:sudo\s+)?init\s+6\b/,
171
+ },
172
+ // `git clean -f` (without -dx) — KeiSei lists this as destructive
173
+ // because it still deletes untracked files. Pugi previously only
174
+ // gated `git clean -fdx`; broaden to any `-f` variant.
175
+ {
176
+ pattern: 'git clean -f',
177
+ regex: /\bgit\s+clean\s+-[A-Za-z]*f/,
178
+ },
122
179
  ];
123
180
  /**
124
181
  * Compound separators. We split on `&&`, `||`, `;`, `|` to classify
@@ -622,7 +679,15 @@ function classifyComponent(cmd, ctx) {
622
679
  }
623
680
  function findDestructiveMatch(cmd) {
624
681
  const upper = cmd.toUpperCase();
625
- for (const { pattern, caseInsensitive } of DESTRUCTIVE_PATTERNS) {
682
+ for (const { pattern, caseInsensitive, regex } of DESTRUCTIVE_PATTERNS) {
683
+ if (regex) {
684
+ // Word-boundary regex form (KeiSei-derived patterns). Match
685
+ // against the trimmed component so `^` anchors to command head,
686
+ // not surrounding whitespace from the compound split.
687
+ if (regex.test(cmd.trim()))
688
+ return pattern;
689
+ continue;
690
+ }
626
691
  if (caseInsensitive) {
627
692
  if (upper.includes(pattern))
628
693
  return pattern;
@@ -697,8 +762,17 @@ function detectProtectedWrite(cmd, ctx) {
697
762
  // Captures `sort -o`, `uniq <in> <out>`, `sed -i` files, `awk '... > "file"'`,
698
763
  // and `>` / `>>` redirections without surrounding whitespace.
699
764
  const writeTargets = extractWriteTargets(cmd);
765
+ // Strip heredoc bodies before substring scan. Heredoc payloads are
766
+ // DATA (file contents the script writes), not commands the shell
767
+ // executes — a `package.json` body containing `/usr/local/bin/...`
768
+ // would FP as "Write into protected path: /usr/" under the broad
769
+ // includes() scan below. The per-target check at the bottom of this
770
+ // function still catches real `cat > /usr/file << EOF` attempts
771
+ // because extractWriteTargets reads the redirection target, not the
772
+ // heredoc body. CEO dogfood 2026-05-28 (#28 follow-up).
773
+ const cmdForScan = stripHeredocBodies(cmd);
700
774
  for (const needle of PROTECTED_PATH_SUBSTRINGS) {
701
- if (!cmd.includes(needle))
775
+ if (!cmdForScan.includes(needle))
702
776
  continue;
703
777
  // Reading from a protected path is allowed at the classifier
704
778
  // layer (the permission engine still gates `read`); writing is
@@ -760,6 +834,56 @@ function detectProtectedWrite(cmd, ctx) {
760
834
  * Conservative — we do not try to resolve shell vars or globs; the
761
835
  * caller still gates absolute paths via `looksAbsoluteOutsideWorkspace`.
762
836
  */
837
+ /**
838
+ * Strip heredoc bodies so substring scans (e.g. `cmd.includes('/usr/')`)
839
+ * do not false-positive on file content the script is *writing*. A
840
+ * heredoc starts с `<< 'WORD'` (or `<< WORD` / `<<-WORD`) and ends на a
841
+ * line containing only WORD. The body between is DATA, not commands.
842
+ *
843
+ * Best-effort: handles single-heredoc-per-command (the common case)
844
+ * AND multiple sequential heredocs. Nested heredocs (heredoc-inside-
845
+ * heredoc) are rare and out of scope — the substring scan still gates
846
+ * the outer command, just без stripping the nested body. Per-target
847
+ * detection at detectProtectedWrite's tail loop catches real
848
+ * `cat > /usr/file << EOF` attacks regardless of body content.
849
+ *
850
+ * CEO dogfood 2026-05-28 (#28): `cat > package.json << 'EOF'\n{"bin":
851
+ * "/usr/local/bin/foo"}\nEOF` was rejected as "Write into protected
852
+ * path: /usr/" because the broad substring scan saw `/usr/` in the
853
+ * JSON body. With heredoc-body stripping, the scan now sees only
854
+ * `cat > package.json << 'EOF' EOF` which contains no protected path.
855
+ */
856
+ function stripHeredocBodies(cmd) {
857
+ // Match `<< [-]'WORD'` or `<< [-]"WORD"` or `<< [-]WORD` (quoted form
858
+ // disables variable expansion in real bash; we treat all three the
859
+ // same for stripping). Capture the WORD so we can find the close
860
+ // marker.
861
+ const heredocStart = /<<-?\s*(['"]?)([A-Za-z_][A-Za-z0-9_]*)\1/g;
862
+ let out = cmd;
863
+ let safetyLoops = 0;
864
+ let match;
865
+ while ((match = heredocStart.exec(out)) !== null) {
866
+ if (++safetyLoops > 16)
867
+ break;
868
+ const word = match[2];
869
+ if (!word)
870
+ continue;
871
+ const headEnd = match.index + match[0].length;
872
+ // Find the close-marker line: `\n<optional indent>WORD<\n|$>`.
873
+ const closeRegex = new RegExp(`\\n\\s*${word}(?:\\n|$)`);
874
+ closeRegex.lastIndex = headEnd;
875
+ const closeMatch = closeRegex.exec(out.slice(headEnd));
876
+ if (!closeMatch)
877
+ break;
878
+ const closeStart = headEnd + closeMatch.index;
879
+ const closeEnd = closeStart + closeMatch[0].length;
880
+ // Replace heredoc body + close marker с single space so the regex
881
+ // iterator's lastIndex stays meaningful.
882
+ out = out.slice(0, headEnd) + ' ' + out.slice(closeEnd);
883
+ heredocStart.lastIndex = headEnd + 1;
884
+ }
885
+ return out;
886
+ }
763
887
  function extractWriteTargets(cmd) {
764
888
  const targets = [];
765
889
  // Shell redirection (`>`, `>>`) with optional whitespace. Skip
@@ -831,8 +955,13 @@ function detectProtectedRead(cmd) {
831
955
  firstToken === 'find';
832
956
  if (!isReadTool)
833
957
  return null;
958
+ // Strip heredoc bodies so `cat > config << 'EOF'\n... /etc/... \nEOF`
959
+ // does not FP as "Read from protected path" when first-token=`cat` +
960
+ // redirection writes к workspace-local file. Heredoc payload is data.
961
+ // CEO dogfood 2026-05-28 (#28).
962
+ const cmdForScan = stripHeredocBodies(cmd);
834
963
  for (const needle of PROTECTED_PATH_SUBSTRINGS) {
835
- if (cmd.includes(needle)) {
964
+ if (cmdForScan.includes(needle)) {
836
965
  return {
837
966
  reason: `Read from protected path: ${needle}`,
838
967
  matched: needle,
@@ -1168,6 +1168,33 @@ export async function runCli(argv) {
1168
1168
  // Propagating via env keeps the session module transport-free.
1169
1169
  if (flags.allowFetch)
1170
1170
  process.env.PUGI_ALLOW_FETCH = '1';
1171
+ // CEO P0 (2026-05-28): auto-init pre-flight on the bare REPL
1172
+ // boot path. PR #628 wired `runAutoInitPreflight` into every
1173
+ // engine command (`pugi code/fix/build/...`) but the bare
1174
+ // `pugi` REPL entry boots straight into the workspace label
1175
+ // resolver — which surfaces the "(not bound — run /init OR cd
1176
+ // into project)" banner without ever asking the operator
1177
+ // whether к initialise the workspace. CEO escalation: closed
1178
+ // multiple times wrong; the operator hits the banner and walks
1179
+ // away thinking Pugi is broken. We use the REPL-tuned variant
1180
+ // that NEVER throws — `n`, `--bare`, `--no-init`, non-TTY all
1181
+ // fall through к the legacy "not bound" banner so the existing
1182
+ // contract (booting REPL still works without `.pugi/`) holds.
1183
+ // The prompt fires ONLY on the happy path: interactive TTY,
1184
+ // no `.pugi/`, no opt-out flag. Wrapped in a defensive try/
1185
+ // catch — the scaffold may legitimately fail (read-only fs,
1186
+ // permission denied) but the REPL still needs to boot so the
1187
+ // operator gets a usable surface к diagnose the failure.
1188
+ try {
1189
+ await runReplAutoInitPreflight(process.cwd(), flags);
1190
+ }
1191
+ catch (error) {
1192
+ // Surface the scaffold error on stderr but proceed to mount
1193
+ // the REPL. Crashing here would regress the splash-fallback
1194
+ // path the dispatcher used to take when `.pugi/` was missing.
1195
+ const message = error instanceof Error ? error.message : String(error);
1196
+ process.stderr.write(`Auto-init failed: ${message}\n`);
1197
+ }
1171
1198
  // α6.2: peek the npm registry for a newer @pugi/cli before
1172
1199
  // mounting Ink. Wrapped in a try/catch belt-and-braces even
1173
1200
  // though `checkForUpdate` already swallows every failure mode —
@@ -1187,6 +1214,12 @@ export async function runCli(argv) {
1187
1214
  await renderRepl({
1188
1215
  apiUrl: runtimeConfig.apiUrl,
1189
1216
  apiKey: runtimeConfig.apiKey,
1217
+ // Re-resolve AFTER the auto-init pre-flight so a freshly-
1218
+ // scaffolded `.pugi/PUGI.md` flips the splash label from
1219
+ // "(not bound — run /init OR cd into project)" к the project
1220
+ // basename in the same boot cycle. `workspaceLabel` is a
1221
+ // pure function over `process.cwd()`; calling it twice is
1222
+ // cheap.
1190
1223
  workspaceLabel: workspaceLabel(process.cwd()),
1191
1224
  cliVersion: PUGI_CLI_VERSION,
1192
1225
  updateBanner,
@@ -6466,6 +6499,27 @@ async function runAutoInitPreflight(root, flags) {
6466
6499
  throw new Error('Run pugi init first');
6467
6500
  }
6468
6501
  }
6502
+ export async function runReplAutoInitPreflight(root, flags, overrides = {}) {
6503
+ const interactive = overrides.interactive ?? isInteractive(flags);
6504
+ return ensureInitializedHelper({
6505
+ cwd: root,
6506
+ interactive,
6507
+ // Leak L22 (2026-05-27): `--bare` short-circuits BEFORE the prompt.
6508
+ // Bare mode is the operator's explicit "disable project auto-
6509
+ // discovery" signal — scaffolding `.pugi/` would directly violate
6510
+ // that contract. Treat `--bare` the same as `--no-init` for the
6511
+ // pre-flight gate.
6512
+ skip: flags.noInit
6513
+ || flags.bare
6514
+ || process.env.PUGI_NO_AUTO_INIT === '1'
6515
+ || process.env.PUGI_BARE === '1',
6516
+ prompt: overrides.prompt ?? (async (question) => readSingleChoice(question)),
6517
+ scaffold: overrides.scaffold
6518
+ ?? (async (input) => {
6519
+ await scaffoldPugiWorkspace({ cwd: input.cwd, noDefaults: flags.noDefaults });
6520
+ }),
6521
+ });
6522
+ }
6469
6523
  /**
6470
6524
  * Wave 6 UX (2026-05-27): async pre-flight wrapper around the
6471
6525
  * `ensureAuthenticatedHelper` from `core/auth/ensure-authenticated.ts`.
@@ -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.43');
47
+ export const PUGI_CLI_VERSION = sanitizeSemver('0.1.0-beta.44');
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pugi/cli",
3
- "version": "0.1.0-beta.43",
3
+ "version": "0.1.0-beta.44",
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.43"
58
+ "@pugi/sdk": "0.1.0-beta.44"
59
59
  },
60
60
  "devDependencies": {
61
61
  "@types/node": "^22.0.0",