@tiens.nguyen/gu-cli 1.0.859 → 1.0.861
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/command-allow.mjs +35 -0
- package/gonext_agent_chat.py +80 -2
- package/gu-repl.mjs +40 -4
- package/package.json +2 -1
|
@@ -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
|
+
}
|
package/gonext_agent_chat.py
CHANGED
|
@@ -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
|
-
|
|
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).
|
|
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:
|
|
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() +
|
|
10988
|
+
approvalDeadline = Date.now() + COMMAND_APPROVAL_MS;
|
|
10964
10989
|
drawApprovalOptions(false);
|
|
10965
|
-
const t = setTimeout(() => finishApproval(false, true),
|
|
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.
|
|
3
|
+
"version": "1.0.861",
|
|
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",
|