@tiens.nguyen/gu-cli 1.0.859 → 1.0.860

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,35 @@
1
+ // "Yes, always in this folder" for the destructive-command question (GH #215).
2
+ // The agent reads the same file (gonext_agent_chat._cmd_allowed_in) before it asks. Only the SOFT
3
+ // question ("a destructive command ...") can be skipped by it - sudo, the denylist and deletes
4
+ // outside the project are never covered by it.
5
+ import { readFileSync, writeFileSync, mkdirSync, realpathSync } from "node:fs";
6
+ import { dirname, join, sep } from "node:path";
7
+ import { homedir } from "node:os";
8
+
9
+ export const commandAllowPath = () => process.env.GU_COMMAND_ALLOW || join(homedir(), ".gonext", "command-allow.json");
10
+ const real = (p) => {
11
+ try {
12
+ return realpathSync(p);
13
+ } catch {
14
+ return p;
15
+ }
16
+ };
17
+ export function commandAllowList() {
18
+ try {
19
+ const f = JSON.parse(readFileSync(commandAllowPath(), "utf8")).folders;
20
+ return Array.isArray(f) ? f : [];
21
+ } catch {
22
+ return [];
23
+ }
24
+ }
25
+ /** Is `folder` on the list, or inside a folder that is (a project covers its subfolders)? */
26
+ export function commandAllowedIn(folder) {
27
+ const f = real(folder);
28
+ return commandAllowList().some((r) => f === r || f.startsWith(r.replace(/[\\/]+$/, "") + sep));
29
+ }
30
+ export function commandAllowIn(folder, allow = true) {
31
+ const f = real(folder);
32
+ const folders = [...commandAllowList().filter((x) => x !== f), ...(allow ? [f] : [])];
33
+ mkdirSync(dirname(commandAllowPath()), { recursive: true });
34
+ writeFileSync(commandAllowPath(), JSON.stringify({ folders }, null, 2));
35
+ }
@@ -7971,8 +7971,19 @@ def _ws_shell_command_risk(scan, denyset, wd="", allowset=None, roots=None):
7971
7971
  return (None, None, f"it runs text as code ({a[0]})")
7972
7972
  if "$" in a[0] or "`" in a[0]:
7973
7973
  return (None, None, f"the program to run is only known at run time ({a[0][:40]})")
7974
+ # `cd sub && rm -rf x`: judge a delete from the folder the shell will be in when it runs.
7975
+ cwds, cur = {}, wd
7976
+ for c in top:
7977
+ a = c["argv"]
7978
+ if a and a[0] == "cd" and len(a) >= 2 and c["sep"] not in ("|", "|&"):
7979
+ cur = os.path.normpath(os.path.join(cur or os.getcwd(),
7980
+ os.path.expanduser(_shell_path_to_native(a[1]))))
7981
+ cwds[id(c)] = cur
7974
7982
  for c in every:
7975
- if _WS_DESTRUCTIVE_RE.search(" ".join(c["argv"])) and not _agent_created_owns_delete(c["argv"], wd):
7983
+ here = cwds.get(id(c), wd)
7984
+ if (_WS_DESTRUCTIVE_RE.search(" ".join(c["argv"]))
7985
+ and not _agent_created_owns_delete(c["argv"], wd)
7986
+ and not _ws_delete_inside_only(c["argv"], here, roots)):
7976
7987
  return (None, None, "a destructive command (deletes files / wipes disk / powers off)")
7977
7988
  return (None, None, None)
7978
7989
 
@@ -8905,6 +8916,42 @@ def _ws_is_inside(path, roots):
8905
8916
  return any(path == r or path.startswith(r.rstrip(os.sep) + os.sep) for r in roots)
8906
8917
 
8907
8918
 
8919
+ def _ws_delete_inside_only(argv, wd, roots=None):
8920
+ """True when `argv` is an rm/rmdir/unlink whose EVERY target is inside a registered project
8921
+ (the user's own build output, node_modules, scratch files...) - deleting those is the normal
8922
+ work of a project, so the destructive question is not asked. Still asks for: the project root
8923
+ itself, its .git folder, a path only known at run time ($VAR, `cmd`), anything outside."""
8924
+ if not argv:
8925
+ return False
8926
+ runner = os.path.basename(argv[0]).lower()
8927
+ if runner.endswith(".exe"):
8928
+ runner = runner[:-4]
8929
+ if runner not in ("rm", "rmdir", "unlink"):
8930
+ return False
8931
+ targets = _ws_delete_targets(argv)
8932
+ if not targets:
8933
+ return False
8934
+ ws = [os.path.realpath(r["path"]) for r in (_WS_ROOTS if roots is None else roots) if r.get("path")]
8935
+ if not ws:
8936
+ return False
8937
+ for t in targets:
8938
+ if "$" in t or "`" in t:
8939
+ return False
8940
+ if any(ch in t for ch in "*?[") and os.path.basename(t).startswith("."):
8941
+ return False # `.*` would also match .git
8942
+ p = os.path.expanduser(_shell_path_to_native(t))
8943
+ try:
8944
+ p = os.path.realpath(p if os.path.isabs(p) else os.path.join(wd or os.getcwd(), p))
8945
+ except OSError:
8946
+ return False
8947
+ home = next((r for r in ws if _ws_is_inside(p, [r])), None)
8948
+ if home is None or p == home:
8949
+ return False
8950
+ if os.path.relpath(p, home).split(os.sep)[0] == ".git":
8951
+ return False
8952
+ return True
8953
+
8954
+
8908
8955
  def _ws_delete_outside_reason(argv, wd, roots=None):
8909
8956
  """Why this delete needs the user's yes, or None when every target is inside a workspace or
8910
8957
  the temp folder. Paths are resolved from `wd`, with ~ expanded and symlinks followed."""
@@ -9015,6 +9062,29 @@ def _with_attribution(argv, settings):
9015
9062
  return argv, ""
9016
9063
 
9017
9064
 
9065
+ # GH #215: "Yes, always in this folder" for the SOFT destructive question. ~/.gonext/command-allow.json
9066
+ # ({"folders": [...]}) is written by the terminal (command-allow.mjs). It never covers a hard reason
9067
+ # (sudo, denylist, delete outside the project) - those keep asking.
9068
+ _CMD_ALLOW_ASK = "a destructive command"
9069
+
9070
+
9071
+ def _cmd_allow_path():
9072
+ return os.environ.get("GU_COMMAND_ALLOW") or os.path.join(os.path.expanduser("~"), ".gonext", "command-allow.json")
9073
+
9074
+
9075
+ def _cmd_allowed_in(folder):
9076
+ """Is `folder` on the always-allow list, or inside a folder that is?"""
9077
+ if not folder:
9078
+ return False
9079
+ try:
9080
+ with open(_cmd_allow_path(), encoding="utf-8") as f:
9081
+ folders = json.load(f).get("folders") or []
9082
+ real = os.path.realpath(folder)
9083
+ return any(isinstance(r, str) and _ws_is_inside(real, [r]) for r in folders)
9084
+ except (OSError, ValueError, AttributeError):
9085
+ return False
9086
+
9087
+
9018
9088
  def _ws_command_risk(command, argv, denyset, wd=""):
9019
9089
  """Classify a command for the run policy. Returns (hard_reason, ask_reason), either
9020
9090
  None when N/A:
@@ -9037,7 +9107,8 @@ def _ws_command_risk(command, argv, denyset, wd=""):
9037
9107
  outside = _ws_delete_outside_reason(argv, wd)
9038
9108
  if outside:
9039
9109
  return (outside, outside)
9040
- if _WS_DESTRUCTIVE_RE.search(cmd) and not _agent_created_owns_delete(argv, wd):
9110
+ if (_WS_DESTRUCTIVE_RE.search(cmd) and not _agent_created_owns_delete(argv, wd)
9111
+ and not _ws_delete_inside_only(argv, wd)):
9041
9112
  return (None, "a destructive command (deletes files / wipes disk / powers off)")
9042
9113
  return (None, None)
9043
9114
 
@@ -15709,6 +15780,13 @@ def run_agent_chat(cfg):
15709
15780
  f"Needs {_priv} → cannot run here ({_ws_access_line(_facts)})"})
15710
15781
  _last_obs["text"] = msg
15711
15782
  return msg
15783
+ # GH #215: the user said "Yes, always in this folder" to the destructive question.
15784
+ if (_ask_reason and not _hard_reason and _ask_reason.startswith(_CMD_ALLOW_ASK)
15785
+ and _cmd_allowed_in(wd or _WS_ACTIVE or _default_ws_root())):
15786
+ # accept-edits OFF still confirms every command, so only the destructive
15787
+ # question is dropped, not that one.
15788
+ _ask_reason = None if _accept_edits else (
15789
+ "accept edits is off, so every command is confirmed first")
15712
15790
  if _ask_reason:
15713
15791
  if _priv and _facts:
15714
15792
  _ask_reason += f" — {_facts['why']}"
package/gu-repl.mjs CHANGED
@@ -140,6 +140,7 @@ import {
140
140
  import { completedFlush, splitLiveStream } from "./completed-flush.mjs";
141
141
  import { isScaffoldStep, isGroupableStep, stepGroupMark, stepGroupRows, relativizePaths } from "./step-group.mjs";
142
142
  import { clearLogsNoticeText } from "./clear-logs.mjs";
143
+ import { commandAllowIn, commandAllowedIn } from "./command-allow.mjs";
143
144
  import { allowIn, allowedIn, bridgeCommand, browserReport, bridgeStatus, extensionDir, findDevInstalls, helpInstall, installExtensionFiles, installSteps, localLeftovers, openExtensionsPage, removeLocalFiles, STORE_URL, stopBridge } from "./browser-command.mjs";
144
145
  import { commandLogFile, killShell, logTail, markAnnounced, readShells, recapQuestion, runningShells, shellsToRecap, statusText as shellStatusText, renderShellDetails, shellPanelRows, shellPickerLabel, shellState, shellsNotice } from "./shell-details.mjs";
145
146
  import { discoverSkills, matchSkillCommand } from "./skills.mjs";
@@ -10699,6 +10700,9 @@ let approvalOpenedOver = null;
10699
10700
  let approvalDeadline = 0;
10700
10701
  let listDeadline = 0;
10701
10702
  let listTimeoutIndex = -1;
10703
+ /** How long a command approval ("Allow running this command?") waits before it answers No by itself.
10704
+ * GH #214: was 170 s; under 30 s so an absent user does not stall the turn. The python side waits 180 s. */
10705
+ const COMMAND_APPROVAL_MS = 25000;
10702
10706
  /** "2:49" above a minute, "45s" below; never negative. */
10703
10707
  function countdownLabel(msLeft) {
10704
10708
  const s = Math.max(0, Math.ceil(msLeft / 1000));
@@ -10774,7 +10778,7 @@ function onApprovalKey(key) {
10774
10778
  // Ctrl+C is delivered via readline "SIGINT" (onInterrupt), which also denies — see there.
10775
10779
  }
10776
10780
  // Show the picker and resolve to the user's choice. Non-TTY (piped) can't pick → deny
10777
- // (safe). Auto-denies after ~170s so an absent user never hangs the turn (and stays
10781
+ // (safe). A command approval auto-denies after COMMAND_APPROVAL_MS (25s, GH #214) so an absent user never hangs the turn (well
10778
10782
  // under the python side's 180s wait); Enter is instant since Yes is preselected.
10779
10783
  // The command carries a "__MAXSTEP__::<message>" sentinel when it's the step-budget
10780
10784
  // continue prompt (task #80) rather than a risky-command approval. That case shows a
@@ -10941,7 +10945,7 @@ async function approvalPrompt(command) {
10941
10945
  const idx = await pickFromList(
10942
10946
  ["Yes", "Yes for all later ones (turns accept edits on)", "No"],
10943
10947
  0,
10944
- { timeoutMs: 170000, timeoutIndex: 2 }
10948
+ { timeoutMs: COMMAND_APPROVAL_MS, timeoutIndex: 2 }
10945
10949
  );
10946
10950
  if (idx === 1 && editMode === "manual") {
10947
10951
  switchEditMode();
@@ -10949,6 +10953,27 @@ async function approvalPrompt(command) {
10949
10953
  }
10950
10954
  return { allow: idx === 0 || idx === 1, dontAsk: false };
10951
10955
  }
10956
+ // GH #215 — "Yes, always in this folder". ONLY for the soft "a destructive command" question
10957
+ // (the reason text comes from _ws_command_risk in gonext_agent_chat.py). sudo, the denylist and
10958
+ // deletes outside the project give a different reason, so they keep the plain Yes/No below.
10959
+ if (process.stdin.isTTY && why.startsWith("a destructive command")) {
10960
+ printApprovalCommand(shown, { toggle: false }); // a list picker follows — shown whole, wrapped
10961
+ process.stdout.write(indentBlock(why, { indent: GUTTER, paint: dim }) + "\n");
10962
+ const idx = await pickFromList(
10963
+ ["Yes", "Yes, always in this folder", "No"],
10964
+ 0,
10965
+ { timeoutMs: COMMAND_APPROVAL_MS, timeoutIndex: 2 }
10966
+ );
10967
+ if (idx === 1) {
10968
+ try {
10969
+ commandAllowIn(resolve(process.cwd()), true);
10970
+ process.stdout.write(dim(" (won't ask about destructive commands in this folder again — /command forget undoes it)\n"));
10971
+ } catch (e) {
10972
+ process.stdout.write(yellow(` could not save that (${e?.message || e}) — allowed this once\n`));
10973
+ }
10974
+ }
10975
+ return { allow: idx === 0 || idx === 1, dontAsk: false };
10976
+ }
10952
10977
  const allow = await new Promise((resolve) => {
10953
10978
  if (!process.stdin.isTTY) {
10954
10979
  process.stdout.write("\n" + GUTTER + dim(`(approval needed for: ${shown}${why ? ` — ${why}` : ""} — no interactive terminal, skipping)`) + "\n");
@@ -10960,9 +10985,9 @@ async function approvalPrompt(command) {
10960
10985
  approvalSel = 0;
10961
10986
  approvalResolve = resolve;
10962
10987
  approvalActive = true;
10963
- approvalDeadline = Date.now() + 170000;
10988
+ approvalDeadline = Date.now() + COMMAND_APPROVAL_MS;
10964
10989
  drawApprovalOptions(false);
10965
- const t = setTimeout(() => finishApproval(false, true), 170000);
10990
+ const t = setTimeout(() => finishApproval(false, true), COMMAND_APPROVAL_MS);
10966
10991
  // #135: redraw the two option rows once a second so the countdown on "No" ticks down.
10967
10992
  const tick = setInterval(() => {
10968
10993
  if (approvalActive) drawApprovalOptions(true);
@@ -14564,6 +14589,17 @@ async function main() {
14564
14589
  paintBar();
14565
14590
  continue;
14566
14591
  }
14592
+ if (line === "/command forget" || line === "/command status") {
14593
+ const here = resolve(process.cwd());
14594
+ if (line === "/command forget") commandAllowIn(here, false);
14595
+ console.log("\n" + GUTTER + (line === "/command forget"
14596
+ ? accent("✓ gu asks again before a destructive command in this folder.")
14597
+ : (commandAllowedIn(here)
14598
+ ? accent("Destructive commands run without asking in this folder.") + dim(" (/command forget undoes it)")
14599
+ : dim("gu asks before a destructive command in this folder."))) + "\n");
14600
+ paintBar();
14601
+ continue;
14602
+ }
14567
14603
  if (line === "/model") {
14568
14604
  await chooseModel();
14569
14605
  await refreshNoModelAtAll(); // #188: a coding model may have just been set → drop its row
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiens.nguyen/gu-cli",
3
- "version": "1.0.859",
3
+ "version": "1.0.860",
4
4
  "description": "Gu CLI — the gu terminal plus the local worker that runs agent / OCR / PDF / embedding jobs on your Mac (Ollama / MLX / OpenAI-compatible).",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -131,6 +131,7 @@
131
131
  "proc-identity.mjs",
132
132
  "win-path.mjs",
133
133
  "browser-command.mjs",
134
+ "command-allow.mjs",
134
135
  "browser-bridge.mjs",
135
136
  "browser-chrome.mjs",
136
137
  "browser-extension/manifest.json",