pi-netguard 0.0.0-stage → 0.1.0

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Thomas
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,77 @@
1
- # Temporary Holding Version
1
+ # pi-netguard
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ A small pi extension that asks before **recognized network-capable shell commands** run. Designed to add friction and visibility to unexpected downloads, uploads and other outbound activity, including the visible `curl` upload described in [Cohere's report](https://cohere.com/blog/concerning-behaviour-popular-open-weight-ai-model).
4
+
5
+ **This is a command approval guard, not a firewall or network monitor.** It inspects text before execution; it cannot observe actual connections or guarantee that traffic stays on your machine. No proxy, daemon, packet capture, elevated permissions or runtime dependencies.
6
+
7
+ ## Install
8
+
9
+ ```sh
10
+ pi install npm:pi-netguard
11
+ ```
12
+
13
+ Try it for one session without adding it to your settings:
14
+
15
+ ```sh
16
+ pi -e npm:pi-netguard
17
+ ```
18
+
19
+ From a checkout, use `pi -e ./src/index.ts` or `pi install /absolute/path/to/netguard`. On Windows, use a quoted absolute path, for example `pi install "C:\P2027\pi-plugins\netguard"`. Restart pi or run `/reload` after installation. Installing only to a project makes protection dependent on loading that project's resources; a personal installation is preferable for a guard.
20
+
21
+ Developed and tested with `@earendil-works/pi-coding-agent` 1.4.0 (Node 22.18+). This is the current pi package name; compatibility with older `@mariozechner` releases is unverified. See pi's [extension](https://pi.dev/docs/latest/extensions) and [package](https://pi.dev/docs/latest/packages) documentation.
22
+
23
+ ## Approval choices
24
+
25
+ Before a detected `bash` or `powershell` tool call executes, the dialog shows the reasons and the complete command, then offers:
26
+
27
+ - **Block** (first/default option). Escape or dismissal also blocks.
28
+ - **Allow once**. Only this invocation is approved.
29
+ - **Allow this exact command for this session**. The same command text, shell tool and working directory can run again without another prompt. Changing any of those requires fresh approval. This does not grant trust to a host, domain or executable in general.
30
+
31
+ Approval permits the **whole shell command**, including chained commands and subprocesses. Identical text can behave differently if files, environment variables, DNS, Git remotes or other external state change. When the agent writes or edits a script, session approvals for commands that run it are revoked. Other changes are not tracked, so prefer **Allow once** for commands that depend on mutable inputs.
32
+
33
+ Session approvals stay in memory. New, resumed or forked sessions, reloads and shutdown clear them. Use `/netguard reset` to revoke them immediately; `/netguard` reports the approval count. Resetting does not stop commands that are already running.
34
+
35
+ In print/JSON mode, or without an approval UI, detected calls are blocked. RPC clients must support pi's selection dialog. A dialog error, cancellation or aborted operation blocks the call. Calls that are not detected continue normally. Netguard emits local approval/block notifications and a status indicator; it does not send telemetry or create its own audit log.
36
+
37
+ ## What triggers a prompt
38
+
39
+ The detector conservatively scans the entire command, including visible pipelines, nested shell strings and inline code. Common Bash/PowerShell line continuations are joined for detection. Terminal controls and invisible formatting characters are escaped in the dialog.
40
+
41
+ | Visible pattern | Examples |
42
+ | --- | --- |
43
+ | URLs | HTTP(S), FTP, SSH, Git, WebSocket, S3, GCS, SMB and rsync URLs, including cloud metadata URLs |
44
+ | Transfer and network utilities | `curl`, `wget`, `ssh`, `scp`, `rsync`, `nc`, `socat`, `ping`, `nmap` |
45
+ | DNS utilities | `dig`, `nslookup`, `host`, `Resolve-DnsName` |
46
+ | PowerShell and Windows | `Invoke-WebRequest`, `irm`, `Start-BitsTransfer`, remoting (`Enter-PSSession`, `-ComputerName`), `\\server\share` paths, `certutil -urlcache`, `bitsadmin` |
47
+ | Cloud, cluster and code-hosting CLIs | `aws`, `az`, `gcloud`, `gsutil`, `azcopy`, `rclone`, `kubectl`, `helm`, `gh`, `glab`, `hf` |
48
+ | Git network operations | `clone`, `fetch`, `pull`, `push`, `ls-remote`, `submodule`, `remote update`, `send-email`, `lfs` |
49
+ | Package operations | Install, download, publish and registry subcommands of language and OS package managers (`npm`, `pip`, `uv`, `cargo`, `brew`, `apt`, `winget`, …); `npx`, `uvx`, `pipx`, `bunx` |
50
+ | Containers and model registries | `docker`/`podman` `pull`, `push`, `login`, `run`, `build`, `compose up`; `ollama pull` |
51
+ | Networking code | Common Python libraries, JS `fetch`, HTTP clients and socket APIs, .NET network classes, `/dev/tcp`, `/dev/udp` |
52
+ | Scripts written this session | Running a script (`python upload.py`, `./send.sh`) that the agent created or edited with networking code in this session |
53
+
54
+ These are text heuristics rather than a shell parser. Distinctive tool names match anywhere, so `echo curl` triggers. Names that are also common words (`host`, `http`, `az`, `gh`, `ping`) only match where a command starts, and Git and package verbs only match as the subcommand, so commit messages, paths and search terms such as `git commit -m "fix push"` or `rg host src` do not prompt. Downloads and GET requests require approval too, since URLs, DNS lookups and headers can carry data. No destination is implicitly trusted, including localhost.
55
+
56
+ Script tracking only covers scripts written through pi's `write` and `edit` tools in the current session, and only when the command names the script. Mentioning a script without running it (`git add upload.py`) does not prompt. Bare URLs in a script are ignored because they are common in configuration files.
57
+
58
+ ## Scope and limitations
59
+
60
+ - **Hidden network activity is missed.** Scripts that existed before the session or were created by other means, scripts imported by another script, `./binary`, shell aliases/functions, `make`, `npm test`, Git hooks and dependency lifecycle scripts can connect without visible matching text. An approved installer may execute arbitrary scripts and access other destinations.
61
+ - **Obfuscation bypasses detection.** Encoded code, constructed executable names, runtime imports, renamed utilities and unrecognized clients can evade the rules. Do not use this against a deliberately evasive agent as your only control.
62
+ - **Other tools and extensions are outside scope.** Custom/MCP tools, extension-owned network calls, pi's model-provider requests and user-entered `!` shell commands are not guarded. `write` and `edit` are not blocked; they are only inspected so that running the resulting script prompts. Tool code calling the host's guarded `bash`/`powershell` path is covered; direct process execution is not.
63
+ - **The extension shares pi's permissions.** An agent able to edit this extension or change its loading configuration can tamper with it. Install from a location the agent cannot write when practical. Other extensions can modify tool arguments after this guard approves them; only load trusted extensions.
64
+ - **No connection or payload inspection.** The guard does not resolve destinations, intercept redirects, inspect encrypted traffic, scan files for secrets, or stop background processes. A session approval authorizes matching command text, not a fixed network request.
65
+
66
+ For enforced egress control, pair this with an OS/container network policy or a mandatory proxy. That is a separate layer with platform-specific setup. This first version focuses on one task: put a user decision in front of recognizable network-capable commands.
67
+
68
+ ## Development
69
+
70
+ ```sh
71
+ npm ci --ignore-scripts
72
+ npm run check
73
+ ```
74
+
75
+ Tests exercise detection, approval scope, cancellation, session reset, concurrent calls and failure behavior without making network requests or running the example commands. The package includes TypeScript source that pi loads directly; there is no build step.
76
+
77
+ The development host currently pins an older `undici`; the root override selects a patched release for local development. It does not update a user's separately installed pi host.
package/package.json CHANGED
@@ -1,6 +1,53 @@
1
1
  {
2
2
  "name": "pi-netguard",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Ask before recognized network-capable shell commands in pi.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/thomas9120/netguard.git"
10
+ },
11
+ "homepage": "https://github.com/thomas9120/netguard#readme",
12
+ "bugs": {
13
+ "url": "https://github.com/thomas9120/netguard/issues"
14
+ },
15
+ "keywords": [
16
+ "pi-package",
17
+ "pi-extension",
18
+ "network",
19
+ "security"
20
+ ],
21
+ "files": [
22
+ "src",
23
+ "README.md",
24
+ "LICENSE"
25
+ ],
26
+ "pi": {
27
+ "extensions": [
28
+ "./src/index.ts"
29
+ ]
30
+ },
31
+ "scripts": {
32
+ "test": "node --test test/*.test.ts",
33
+ "typecheck": "tsc --noEmit",
34
+ "check": "npm run typecheck && npm test",
35
+ "prepublishOnly": "npm run check"
36
+ },
37
+ "engines": {
38
+ "node": ">=22.18.0"
39
+ },
40
+ "peerDependencies": {
41
+ "@earendil-works/pi-coding-agent": "*"
42
+ },
43
+ "overrides": {
44
+ "@earendil-works/pi-coding-agent": {
45
+ "undici": "8.12.3"
46
+ }
47
+ },
48
+ "devDependencies": {
49
+ "@earendil-works/pi-coding-agent": "1.4.0",
50
+ "@types/node": "^24.0.0",
51
+ "typescript": "^5.9.3"
52
+ }
53
+ }
package/src/detect.ts ADDED
@@ -0,0 +1,96 @@
1
+ // These are deliberately conservative text heuristics, not a shell parser.
2
+ // Scan the entire command to include pipelines, nested shells and inline scripts.
3
+
4
+ // Where a command name can start: line and pipeline starts, subshells, a shell's
5
+ // script argument (`bash -c '...'`) and common wrappers (`sudo`, `xargs`, `find -exec`).
6
+ const START = String.raw`(?:^|[\r\n;&|({\x60]|\$\(|\b(?:ba|z|da|k)?sh\s+(?:[-/]\S+\s+)*|\b(?:cmd|pwsh|powershell)(?:\.exe)?\s+(?:[-/]\S+\s+)*|(?<![\w-])(?:sudo|env|exec|xargs|time|timeout|nohup|nice|watch|command|then|do|else|-exec)(?:\s+(?:-\S*|\d\S*|[a-z_]\w*=\S*))*\s)\s*['"]?(?:[^\s'";&|()]*[\\/])?`;
7
+ // Options between a tool and its subcommand, such as `npm --prefix dir install`.
8
+ // Each token must have exactly one parse, or long commands backtrack exponentially.
9
+ const OPTIONS = String.raw`(?:\s+--?\w[\w-]*(?:[=\s][^\s;&|-]\S*)?)*`;
10
+
11
+ // Distinctive names match anywhere they appear as a word, including paths to the executable.
12
+ const word = (names: string) => new RegExp(String.raw`(?<![\w.-])(?:${names})(?![\w-])`, "i");
13
+ // Names that are also common words (`host`, `http`, `az`) match only in command position.
14
+ const command = (names: string) => new RegExp(String.raw`${START}(?:${names})(?:\.exe)?(?=$|[\s;&|)'"])`, "i");
15
+ const subcommand = (tools: string, verbs: string) =>
16
+ new RegExp(String.raw`(?<![\w.-])(?:${tools})(?:\.(?:exe|cmd))?['"]?${OPTIONS}\s+(?:${verbs})(?![\w-])`, "i");
17
+
18
+ const PACKAGE_VERBS = "install|add|update|upgrade|sync|lock|download|fetch|publish|push|restore|audit|outdated|search|info|login|require|create-project|create|cache|self\\s+update|python\\s+install|tool\\s+(?:install|run|update|upgrade|restore)";
19
+ const CODE_LIBRARIES = "requests|urllib\\d?|httpx|aiohttp|socket|smtplib|ftplib|telnetlib|paramiko|http\\.client|axios|undici|reqwest";
20
+ const JS_NETWORK_MODULES = "https?|http2|net|tls|dgram|ws|node-fetch";
21
+
22
+ const URL_REASON = "URL in command";
23
+ const rules: ReadonlyArray<readonly [string, ...RegExp[]]> = [
24
+ [URL_REASON,/\b(?:https?|ftps?|sftp|ssh|git|wss?|gopher|s3|gs|smb|rsync|scp):\/\//i],
25
+ ["network or file-transfer utility",
26
+ word("curl|wget2?|xh|aria2c|ssh|ssh-copy-id|scp|sftp|rsync|ncat|netcat|socat|telnet|lftp|tftp|ping6|traceroute|tracepath|nmap|mosh"),
27
+ command("http|https|nc|ftp|ping")],
28
+ ["DNS lookup utility", word("nslookup|Resolve-DnsName"), command("dig|host")],
29
+ ["PowerShell or Windows network command",
30
+ word("Invoke-WebRequest|Invoke-RestMethod|iwr|irm|Start-BitsTransfer|Test-NetConnection|Test-Connection|Send-MailMessage|Enter-PSSession|New-PSSession|New-CimSession|bitsadmin"),
31
+ /(?<![\w-])-(?:ComputerName|ToSession|FromSession)(?![\w-])/i,
32
+ /\bcertutil\b[^\r\n]*\s[-/](?:urlcache|verifyctl)\b/i],
33
+ ["Windows network share path", /(?<![^\s'"=(,;|&<>])\\\\(?!wsl(?:\.localhost|\$)\\)\w[\w.-]*\\[\w$]/i],
34
+ ["cloud, cluster or code-hosting CLI",
35
+ word("gcloud|gsutil|rclone|ossutil(?:64)?|aliyun|coscli|azcopy|s3cmd|kubectl|huggingface-cli|glab"),
36
+ command("aws|az|gh|hf|helm")],
37
+ ["Git network operation",
38
+ /(?<![\w.-])git(?:\.exe)?['"]?(?:\s+(?:-[Cc]\s+[^\s-]\S*|--?\w[\w-]*(?:=\S+)?))*\s+(?:clone|fetch|pull|push|ls-remote|submodule|remote\s+update|send-email|request-pull|svn|p4|lfs|archive\b[^\r\n;&|]*--remote)(?![\w-])/i],
39
+ ["package download or publishing",
40
+ subcommand("npm|pnpm|yarn|bun", `${PACKAGE_VERBS}|i|ci|up|view|dlx|x|exec|init\\s+(?!-)\\S+`),
41
+ subcommand("pip3?|uv|poetry|pdm|pipenv|cargo|gem|bundle|composer|dotnet|nuget|deno|conda|mamba|micromamba|brew|apt|apt-get|dnf|yum|zypper|apk|winget|choco|scoop|snap|flatpak", PACKAGE_VERBS),
42
+ word("npx|pnpx|bunx|uvx|pipx"),
43
+ /(?<![\w.-])yarn(?=\s*(?:$|[;&|)]))/i,
44
+ /(?<![\w.-])pacman\s+-S/i,
45
+ /\bgo\s+(?:get|install|mod\s+(?:download|tidy))\b/i],
46
+ ["container or model registry operation",
47
+ subcommand("docker-compose|podman-compose|docker|podman|nerdctl|ollama",
48
+ `pull|push|login|run|build|create|search|up|compose${OPTIONS}\\s+(?:up|pull|push|build|run|create)|buildx\\s+build|image\\s+(?:pull|push)|manifest\\s+push`)],
49
+ ["shell network device", /\/dev\/(?:tcp|udp)\//i],
50
+ ["networking code or API",
51
+ new RegExp(String.raw`\b(?:import|from)\s+['"]?(?:node:)?(?:${CODE_LIBRARIES}|${JS_NETWORK_MODULES})\b`, "i"),
52
+ new RegExp(String.raw`\b(?:require|import)\s*\(\s*['"](?:node:)?(?:${CODE_LIBRARIES}|${JS_NETWORK_MODULES})['"]`, "i"),
53
+ new RegExp(String.raw`\b(?:${CODE_LIBRARIES})(?:\s*(?:\.|::)\s*\w+)+\s*\(`, "i"),
54
+ /\b(?:fetch|axios|XMLHttpRequest|WebSocket)\s*\(|\b(?:https?|http2|net|tls|dgram)\s*\.\s*(?:request|get|connect|createConnection|createSocket)\s*\(/i,
55
+ /\bNet\.(?:\w+\.)*(?:TcpClient|UdpClient|WebClient|HttpClient|WebRequest|HttpWebRequest|Dns|Sockets)\b|\bnew\s+(?:TcpClient|UdpClient|WebClient|HttpClient)\s*\(/i,
56
+ /\bhttp\.server\b|\bnet\/http\b|\bopenssl\s+s_client\b/i],
57
+ ];
58
+
59
+ // Join common Bash and PowerShell line continuations before matching.
60
+ const joinContinuations = (command: string) => command.replace(/(?:\\|`)\r?\n/g, "");
61
+
62
+ export function detectNetwork(command: string): string[] {
63
+ const text = joinContinuations(command);
64
+ return rules.filter(([, ...patterns]) => patterns.some((pattern) => pattern.test(text))).map(([reason]) => reason);
65
+ }
66
+
67
+ // Scripts the agent writes are only visible to later shell commands by name, so remember
68
+ // which ones contain network-capable code. Bare URLs are common in configs and docs; skip them.
69
+ const SCRIPT_FILE = /^(?:[^.]+|.+\.(?:py|pyw|js|mjs|cjs|ts|mts|cts|sh|bash|zsh|ps1|psm1|rb|pl|php|lua|r|bat|cmd))$/i;
70
+
71
+ export function scriptName(path: string): string | undefined {
72
+ const name = path.split(/[\\/]/).pop();
73
+ return name && SCRIPT_FILE.test(name) ? name : undefined;
74
+ }
75
+
76
+ export function detectNetworkInScript(content: string): string[] {
77
+ return detectNetwork(content).filter((reason) => reason !== URL_REASON);
78
+ }
79
+
80
+ // Interpreters that take a script path among their arguments. The scan is bounded to keep
81
+ // long commands linear.
82
+ const RUNNERS = String.raw`(?<![\w.-])(?:python[\d.]*|py|node|deno|bun|tsx|ts-node|bash|sh|zsh|pwsh|powershell|ruby|perl|php|lua|Rscript|source|\.|uv\s+run|go\s+run)(?:\.exe)?\s[^\r\n;&|]{0,512}?`;
83
+ const escapeRegExp = (text: string) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
84
+
85
+ // Whether a command runs the named script through an interpreter or as a path (`./upload.sh`),
86
+ // as opposed to merely mentioning it (`git add upload.sh`).
87
+ export function runsScript(command: string, name: string): boolean {
88
+ return new RegExp(String.raw`(?:${RUNNERS}|${START})(?<![\w.-])${escapeRegExp(name)}(?![\w.-])`, "i").test(joinContinuations(command));
89
+ }
90
+
91
+ // Make terminal controls and bidi overrides visible instead of rendering them.
92
+ export function displayCommand(command: string): string {
93
+ return command.replace(/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/gu, (character) =>
94
+ `\\u{${character.codePointAt(0)!.toString(16)}}`,
95
+ );
96
+ }
package/src/index.ts ADDED
@@ -0,0 +1,106 @@
1
+ import type { ExtensionAPI, ToolCallEventResult } from "@earendil-works/pi-coding-agent";
2
+ import { detectNetwork, detectNetworkInScript, displayCommand, runsScript, scriptName } from "./detect.ts";
3
+
4
+ const BLOCK = "Block";
5
+ const ONCE = "Allow once";
6
+ const SESSION = "Allow this exact command for this session";
7
+
8
+ export default function netguard(pi: ExtensionAPI): void {
9
+ // Session approvals, keyed by tool, working directory and command text.
10
+ const approved = new Map<string, string>();
11
+ // Names of scripts written this session that contain network-capable code.
12
+ const networkScripts = new Set<string>();
13
+ let generation = 0;
14
+ let queue = Promise.resolve();
15
+
16
+ function reset(): void {
17
+ approved.clear();
18
+ generation++;
19
+ }
20
+
21
+ function trackScript(path: unknown, content: string, replacesFile: boolean): void {
22
+ const name = typeof path === "string" ? scriptName(path) : undefined;
23
+ if (!name) return;
24
+ // A changed script can do something new, so approvals that run it no longer apply.
25
+ for (const [key, command] of approved) if (runsScript(command, name)) approved.delete(key);
26
+ if (detectNetworkInScript(content).length > 0) networkScripts.add(name);
27
+ else if (replacesFile) networkScripts.delete(name);
28
+ }
29
+
30
+ pi.on("session_start", (_event, ctx) => {
31
+ reset();
32
+ networkScripts.clear();
33
+ if (ctx.hasUI) ctx.ui.setStatus("netguard", "netguard: approval required");
34
+ });
35
+ pi.on("session_shutdown", () => {
36
+ reset();
37
+ networkScripts.clear();
38
+ });
39
+
40
+ pi.registerCommand("netguard", {
41
+ description: "Show network guard status; /netguard reset revokes session approvals",
42
+ handler: async (args, ctx) => {
43
+ if (args.trim() === "reset") reset();
44
+ ctx.ui.notify(`Netguard active: ${approved.size} exact command(s) approved for this session. Use /netguard reset to revoke.`, "info");
45
+ },
46
+ });
47
+
48
+ pi.on("tool_call", async (event, ctx) => {
49
+ if (event.toolName === "write" && typeof event.input.content === "string") {
50
+ trackScript(event.input.path, event.input.content, true);
51
+ return;
52
+ }
53
+ if (event.toolName === "edit" && Array.isArray(event.input.edits)) {
54
+ const added = event.input.edits.map((edit: { newText?: unknown }) => String(edit?.newText ?? "")).join("\n");
55
+ trackScript(event.input.path, added, false);
56
+ return;
57
+ }
58
+ if (event.toolName !== "bash" && event.toolName !== "powershell") return;
59
+ const command = event.input.command;
60
+ if (typeof command !== "string") {
61
+ return { block: true, reason: "Netguard: shell command could not be inspected." };
62
+ }
63
+ const reasons = detectNetwork(command);
64
+ for (const name of networkScripts) {
65
+ if (runsScript(command, name)) reasons.push(`runs ${name}, written this session with network-capable code`);
66
+ }
67
+ if (reasons.length === 0) return;
68
+
69
+ const cwd = ctx.cwd;
70
+ const key = JSON.stringify([event.toolName, cwd, command]);
71
+ const started = generation;
72
+ // Serialize our dialogs even when the host dispatches tool calls concurrently.
73
+ const decision = queue.then(async (): Promise<ToolCallEventResult | undefined> => {
74
+ const block = (reason: string): ToolCallEventResult => ({ block: true, reason: `Netguard: ${reason}` });
75
+ if (started !== generation || ctx.signal?.aborted) return block("approval was cancelled.");
76
+ if (event.input.command !== command || ctx.cwd !== cwd) return block("command or working directory changed; retry for review.");
77
+ if (!ctx.hasUI) return block("recognized network-capable command requires interactive approval.");
78
+ if (approved.has(key)) return;
79
+
80
+ let choice: string | undefined;
81
+ try {
82
+ choice = await ctx.ui.select(
83
+ `Netguard: network approval required (${event.toolName})\n` +
84
+ `${reasons.join("; ")}\n\n${displayCommand(command)}\n\n` +
85
+ "Approval permits the whole command, including subprocesses. Session approval matches this command and working directory; files and environment may change.",
86
+ [BLOCK, ONCE, SESSION],
87
+ { signal: ctx.signal },
88
+ );
89
+ } catch {
90
+ return block("approval UI failed; command blocked.");
91
+ }
92
+
93
+ if (started !== generation || ctx.signal?.aborted) return block("approval was cancelled.");
94
+ if (event.input.command !== command || ctx.cwd !== cwd) return block("command or working directory changed during approval; retry for review.");
95
+ if (choice !== ONCE && choice !== SESSION) {
96
+ ctx.ui.notify("Netguard blocked a network-capable command.", "warning");
97
+ return block("network-capable command was not approved. Ask the user before retrying.");
98
+ }
99
+ if (choice === SESSION) approved.set(key, command);
100
+ ctx.ui.notify(choice === SESSION ? "Netguard approved this exact command for this session." : "Netguard approved this command once.", "info");
101
+ });
102
+ // A failed request must not poison subsequent requests in the queue.
103
+ queue = decision.then(() => undefined, () => undefined);
104
+ return decision;
105
+ });
106
+ }