@lazyingart/agintiflow 0.20.56 → 0.20.58

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/README.md CHANGED
@@ -73,6 +73,16 @@ aginti auth venice
73
73
  aginti login grsai
74
74
  ```
75
75
 
76
+ Provider signup and key pages:
77
+
78
+ | Provider | Register / key page | API base URL used by AgInTiFlow |
79
+ | --- | --- | --- |
80
+ | DeepSeek | [https://platform.deepseek.com/api_keys](https://platform.deepseek.com/api_keys) | `https://api.deepseek.com` |
81
+ | Venice | [https://venice.ai/settings/api](https://venice.ai/settings/api) | `https://api.venice.ai/api/v1` |
82
+ | OpenAI | [https://platform.openai.com/api-keys](https://platform.openai.com/api-keys) | `https://api.openai.com/v1` |
83
+ | Qwen / DashScope | [https://bailian.console.aliyun.com/](https://bailian.console.aliyun.com/) | `https://dashscope-intl.aliyuncs.com/compatible-mode/v1` |
84
+ | GRS AI image tools | [https://grsai.ai/dashboard/api-keys](https://grsai.ai/dashboard/api-keys) | Configure with `/auxiliary grsai` or `aginti login grsai` |
85
+
76
86
  Launch the web UI from the same project:
77
87
 
78
88
  ```bash
@@ -133,6 +143,31 @@ aginti --resume <session-id> \
133
143
  "Take a fresh screenshot of the running app in the emulator, save it with a durable filename in this project, and keep git status clean."
134
144
  ```
135
145
 
146
+ Permission behavior is intentionally consistent: writes inside the current project are allowed through file tools, network/setup runs are normal in approved Docker workspace mode, and outside-project or trusted-host actions stop with a clear blocker plus a suggested rerun command. See [runtime modes and autonomy](docs/runtime-modes-and-autonomy.md) for the full contract.
147
+
148
+ ## Permission Recipes
149
+
150
+ Use these when you want explicit control instead of the default interactive policy:
151
+
152
+ | Mode | Command | What it permits |
153
+ | --- | --- | --- |
154
+ | Strict inspection | `aginti --sandbox-mode docker-readonly --package-install-policy block --allow-shell --no-file-tools --no-web-search "inspect this project without edits"` | Enforced read-only project inspection through shell commands such as `ls`, `rg`, `cat`, and test commands that do not write. No file-tool writes, web calls, workspace writes, or installs. |
155
+ | Full write in current folder | `aginti --sandbox-mode docker-workspace --package-install-policy allow --approve-package-installs --allow-shell --allow-file-tools "build and test this project"` | Read/write inside the current project folder, run network/setup commands in Docker, keep host safer. |
156
+ | Full host computer access | `aginti --sandbox-mode host --package-install-policy allow --approve-package-installs --allow-shell --allow-file-tools --allow-destructive "perform the trusted host maintenance task"` | Direct host shell and destructive actions. Use only when you trust the task and want whole-host access. |
157
+
158
+ For resume:
159
+
160
+ ```bash
161
+ aginti --resume <session-id> \
162
+ --sandbox-mode host \
163
+ --package-install-policy allow \
164
+ --approve-package-installs \
165
+ --allow-shell \
166
+ --allow-file-tools \
167
+ --allow-destructive \
168
+ "continue with trusted host access"
169
+ ```
170
+
136
171
  ## Real Screenshots
137
172
 
138
173
  | CLI launch | Web app overview |
@@ -42,6 +42,77 @@ Docker package installs are safe when they match the sandbox contract.
42
42
 
43
43
  This is why a task can safely install Python packages in Docker, but cannot keep an interactive tmux server alive inside a one-shot Docker command.
44
44
 
45
+ ## Permission Contract
46
+
47
+ The runtime must be consistent when an action is not permitted. A blocked action should not become a loop of slight command variants, and a failed setup step should not be reported as success just because a stale folder already exists.
48
+
49
+ Current contract:
50
+
51
+ - Workspace file tools may read and write inside the configured project folder when file tools are enabled.
52
+ - Workspace writes outside that folder, secret paths, `.git` internals, and dependency folders such as `node_modules` are blocked.
53
+ - `git clone`, `git fetch`, `git pull --ff-only`, `git push`, `curl`, and `wget` are classified as network operations.
54
+ - Network/setup commands are allowed in `docker-workspace` only when package installs are approved.
55
+ - Host `sudo` and host OS package installs are not automatic. The agent should ask the user to run a manual command or switch to a safer Docker setup.
56
+ - Destructive host shell/git operations need explicit trusted host mode.
57
+
58
+ When a tool is blocked, the tool result includes `permissionAdvice` with three choices: refuse/stop, approve a safer rerun, or switch to a trusted host run. The model prompt requires the agent to present that blocker and avoid retrying variants until the user approves a path.
59
+
60
+ The common controlled rerun shape is:
61
+
62
+ ```bash
63
+ aginti --resume <session-id> \
64
+ --cwd /path/to/project \
65
+ --sandbox-mode docker-workspace \
66
+ --package-install-policy allow \
67
+ --approve-package-installs \
68
+ --allow-shell \
69
+ --allow-file-tools \
70
+ "Continue after approval and verify the output was created in this run."
71
+ ```
72
+
73
+ Three practical permission recipes:
74
+
75
+ ```bash
76
+ # Strict inspection: enforced read-only shell inspection, no file-tool writes, no web, no installs.
77
+ aginti --sandbox-mode docker-readonly \
78
+ --package-install-policy block \
79
+ --allow-shell \
80
+ --no-file-tools \
81
+ --no-web-search \
82
+ "inspect this project without edits"
83
+
84
+ # Full write in the current project folder, with setup commands isolated in Docker.
85
+ aginti --sandbox-mode docker-workspace \
86
+ --package-install-policy allow \
87
+ --approve-package-installs \
88
+ --allow-shell \
89
+ --allow-file-tools \
90
+ "build and test this project"
91
+
92
+ # Full host computer access for trusted maintenance.
93
+ aginti --sandbox-mode host \
94
+ --package-install-policy allow \
95
+ --approve-package-installs \
96
+ --allow-shell \
97
+ --allow-file-tools \
98
+ --allow-destructive \
99
+ "perform the trusted host maintenance task"
100
+ ```
101
+
102
+ For host-only work, use the stricter trusted form deliberately:
103
+
104
+ ```bash
105
+ aginti --resume <session-id> \
106
+ --cwd /path/to/project \
107
+ --sandbox-mode host \
108
+ --package-install-policy allow \
109
+ --approve-package-installs \
110
+ --allow-shell \
111
+ --allow-file-tools \
112
+ --allow-destructive \
113
+ "Continue after trusted host approval. Inspect first and keep unrelated files untouched."
114
+ ```
115
+
45
116
  ## Recommended Defaults
46
117
 
47
118
  Default daily coding:
@@ -103,4 +174,3 @@ The documentation should be maintained as product code:
103
174
  - Add smoke tests when a workflow becomes behavior, not just guidance.
104
175
  - Update `AGINTI.md` for project-specific operating memory.
105
176
  - Treat docs changes as part of the release checklist before publishing npm.
106
-
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lazyingart/agintiflow",
3
- "version": "0.20.56",
3
+ "version": "0.20.58",
4
4
  "type": "module",
5
5
  "description": "Low-cost, project-aware Web and CLI agents with DeepSeek/Venice/OpenAI routing, visible tool calls, durable sessions, scouts, AAPS, SCS, and guarded local execution.",
6
6
  "license": "Apache-2.0",
package/public/app.js CHANGED
@@ -930,6 +930,14 @@ function renderWorkspacePanel(workspace = lastWorkspace, activity = lastWorkspac
930
930
  const label = blocked ? t("blockedLabel") : t("changedLabel");
931
931
  const path = escapeHtml(item.path || "");
932
932
  const reason = blocked ? `<div class="subtle">${escapeHtml(item.reason || "")}</div>` : "";
933
+ const advice =
934
+ blocked && item.permissionAdvice
935
+ ? `<div class="subtle"><strong>${escapeHtml(item.permissionAdvice.summary || "Permission advice")}</strong>${
936
+ item.permissionAdvice.suggestedCommand
937
+ ? `<br /><code>${escapeHtml(item.permissionAdvice.suggestedCommand)}</code>`
938
+ : ""
939
+ }</div>`
940
+ : "";
933
941
  const diff = item.diff ? `<pre class="change-diff">${renderDiffHtml(item.diff, 1200)}</pre>` : "";
934
942
  const hashes =
935
943
  item.beforeHash || item.afterHash
@@ -944,6 +952,7 @@ function renderWorkspacePanel(workspace = lastWorkspace, activity = lastWorkspac
944
952
  </div>
945
953
  <strong class="change-path">${path}</strong>
946
954
  ${reason}
955
+ ${advice}
947
956
  ${hashes}
948
957
  ${diff}
949
958
  </article>
@@ -53,8 +53,10 @@ assert(skipped.skipped === "source-checkout", "source checkout update guard fail
53
53
  const tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "agintiflow-auto-update-"));
54
54
  const previousHome = process.env.AGINTIFLOW_HOME;
55
55
  const previousStartupInterval = process.env.AGINTIFLOW_AUTO_UPDATE_STARTUP_INTERVAL_MS;
56
+ const previousCi = process.env.CI;
56
57
  process.env.AGINTIFLOW_HOME = tempHome;
57
58
  process.env.AGINTIFLOW_AUTO_UPDATE_STARTUP_INTERVAL_MS = String(24 * 60 * 60 * 1000);
59
+ delete process.env.CI;
58
60
  await fs.mkdir(tempHome, { recursive: true });
59
61
  await fs.writeFile(
60
62
  path.join(tempHome, "update-check.json"),
@@ -96,5 +98,7 @@ if (previousHome === undefined) delete process.env.AGINTIFLOW_HOME;
96
98
  else process.env.AGINTIFLOW_HOME = previousHome;
97
99
  if (previousStartupInterval === undefined) delete process.env.AGINTIFLOW_AUTO_UPDATE_STARTUP_INTERVAL_MS;
98
100
  else process.env.AGINTIFLOW_AUTO_UPDATE_STARTUP_INTERVAL_MS = previousStartupInterval;
101
+ if (previousCi === undefined) delete process.env.CI;
102
+ else process.env.CI = previousCi;
99
103
 
100
104
  console.log("auto-update smoke ok");
@@ -6,9 +6,11 @@ import { fileURLToPath } from "node:url";
6
6
  import { repairModelMessageHistory, runAgent } from "../src/agent-runner.js";
7
7
  import { resolveRuntimeConfig } from "../src/config.js";
8
8
  import { readCodebaseMap } from "../src/codebase-map.js";
9
+ import { evaluateCommandPolicy } from "../src/command-policy.js";
9
10
  import { engineeringGuidanceForTask, recommendedMaxStepsForTask } from "../src/engineering-guidance.js";
10
11
  import { selectModelRoute } from "../src/model-routing.js";
11
12
  import { listParallelScouts, runParallelScouts, shouldRunParallelScouts } from "../src/parallel-scouts.js";
13
+ import { buildFailedCommandAdvice, buildPermissionAdvice } from "../src/permission-advice.js";
12
14
  import { SessionStore } from "../src/session-store.js";
13
15
  import { searchWeb } from "../src/web-search.js";
14
16
  import { executeWorkspaceTool } from "../src/workspace-tools.js";
@@ -202,6 +204,48 @@ try {
202
204
  guidance.includes("find . -type d -name __pycache__"),
203
205
  "engineering guidance did not include recursive Python transient checks"
204
206
  );
207
+ const dockerWorkspacePolicy = {
208
+ allowShellTool: true,
209
+ useDockerSandbox: true,
210
+ sandboxMode: "docker-workspace",
211
+ packageInstallPolicy: "allow",
212
+ commandCwd: workspace,
213
+ };
214
+ const curlPolicy = evaluateCommandPolicy("curl -s -o /dev/null -w '%{http_code}' https://github.com/lazyingart/AgInTiFlow.git", dockerWorkspacePolicy);
215
+ assert(curlPolicy.allowed, "curl URL probe with flags should be allowed in docker-workspace allow mode");
216
+ assert(curlPolicy.needsNetwork, "curl URL probe with flags was not classified as network");
217
+ const clonePolicy = evaluateCommandPolicy("git clone https://github.com/lazyingart/AgInTiFlow.git", dockerWorkspacePolicy);
218
+ assert(clonePolicy.allowed, "git clone should be allowed in docker-workspace allow mode");
219
+ assert(clonePolicy.needsNetwork, "git clone was not classified as network");
220
+ assert(clonePolicy.writesWorkspace, "git clone was not classified as workspace-writing");
221
+ const unsafeCloneTarget = evaluateCommandPolicy("git clone https://github.com/lazyingart/AgInTiFlow.git ../AgInTiFlow", dockerWorkspacePolicy);
222
+ assert(!unsafeCloneTarget.allowed, "git clone outside the workspace should be blocked");
223
+ const blockedClonePolicy = evaluateCommandPolicy("git clone https://github.com/lazyingart/AgInTiFlow.git", {
224
+ ...dockerWorkspacePolicy,
225
+ packageInstallPolicy: "block",
226
+ });
227
+ assert(!blockedClonePolicy.allowed, "git clone should be blocked when Docker package/network setup is blocked");
228
+ const permissionAdvice = buildPermissionAdvice({
229
+ toolName: "run_command",
230
+ args: { command: "git clone https://github.com/lazyingart/AgInTiFlow.git" },
231
+ guard: blockedClonePolicy,
232
+ config: dockerWorkspacePolicy,
233
+ state: { sessionId: "coding-policy-smoke" },
234
+ });
235
+ assert(permissionAdvice.suggestedCommand.includes("coding-policy-smoke"), "permission advice did not include resume session id");
236
+ assert(permissionAdvice.suggestedCommand.includes("--sandbox-mode docker-workspace"), "permission advice did not suggest docker-workspace recovery");
237
+ const failedNetworkAdvice = buildFailedCommandAdvice({
238
+ args: { command: "git clone https://github.com/lazyingart/AgInTiFlow.git" },
239
+ commandPolicy: clonePolicy,
240
+ commandResult: { ok: false, stderr: "fatal: unable to access 'https://github.com/lazyingart/AgInTiFlow.git/': Could not resolve host: github.com" },
241
+ config: dockerWorkspacePolicy,
242
+ state: { sessionId: "coding-network-smoke" },
243
+ });
244
+ assert(failedNetworkAdvice?.failureKind === "network", "network failure advice was not generated");
245
+ assert(
246
+ failedNetworkAdvice.instruction.includes("Stop and present this blocker"),
247
+ "network failure advice did not tell the model to stop and ask"
248
+ );
205
249
  assert(
206
250
  shouldRunParallelScouts(
207
251
  {
@@ -459,6 +503,8 @@ try {
459
503
  "large_profile_pro_route",
460
504
  "auto_system_pro_route",
461
505
  "auto_engineering_guidance",
506
+ "command_policy_git_clone_network",
507
+ "permission_recovery_advice",
462
508
  "parallel_scout_trigger",
463
509
  "parallel_scout_roster",
464
510
  "parallel_scout_count_clamp",
@@ -27,6 +27,7 @@ import { hostShellOption, platformInfo, platformLabel } from "./platform.js";
27
27
  import { captureTmuxPane, listTmuxSessions, sendTmuxKeys, startTmuxSession } from "./tmux-tools.js";
28
28
  import { languageInstruction } from "./i18n.js";
29
29
  import { flushHousekeeping } from "./housekeeping.js";
30
+ import { buildFailedCommandAdvice, buildPermissionAdvice } from "./permission-advice.js";
30
31
  import {
31
32
  buildSupervisorInstruction,
32
33
  createScsPlan,
@@ -358,6 +359,8 @@ async function createInitialState(config, sessionId) {
358
359
  ? `A shell command tool is available inside Docker sandbox mode ${config.sandboxMode}. Docker workspace mode with approved package installs supports broader setup and network commands. The project is mounted at /workspace and the persistent agent toolchain is mounted at /aginti-env with caches under /aginti-cache.`
359
360
  : `A host shell command tool is available under the configured trust policy on ${platformLabel(platform)}. On native Windows, prefer PowerShell/cmd-compatible commands or switch to WSL/Docker for bash-like toolchains.`
360
361
  : "No shell command tool is available.",
362
+ "Permission contract: current-workspace file writes are allowed through workspace file tools when enabled. Outside-workspace paths, host sudo, host OS package installs, destructive git/shell actions, and blocked network/setup must not be bypassed by retrying variants. If a tool result includes permissionAdvice or suggestedCommand, stop, explain the blocker, and ask the user to approve/rerun that mode or choose a safer workspace-relative path.",
363
+ "If an operation fails but a directory, artifact, or file already exists, treat it as pre-existing unless you have evidence this run created or updated it. Verify expected outputs before claiming success.",
361
364
  config.allowShellTool
362
365
  ? "Host tmux tools are available for long-running terminals: list sessions, capture panes, send safe keys/text, and start detached sessions. Prefer these tools for monitoring long installs/tests/dev servers without blocking; capture before sending input and never send secrets or sudo passwords. Do not start or install tmux inside Docker run_command containers because those containers are short-lived."
363
366
  : "",
@@ -856,12 +859,20 @@ async function executeTool(browserState, toolCall, snapshot, config, store, obse
856
859
  });
857
860
 
858
861
  if (!guard.allowed) {
862
+ const permissionAdvice = buildPermissionAdvice({
863
+ toolName: toolCall.function.name,
864
+ args: safeArgs,
865
+ guard,
866
+ config,
867
+ state,
868
+ });
859
869
  await store.appendEvent("tool.blocked", {
860
870
  toolName: toolCall.function.name,
861
871
  args: safeArgs,
862
872
  reason: guard.reason,
863
873
  category: guard.category,
864
874
  needsApproval: guard.needsApproval,
875
+ permissionAdvice,
865
876
  });
866
877
  observers.event("tool.blocked", {
867
878
  toolName: toolCall.function.name,
@@ -869,6 +880,7 @@ async function executeTool(browserState, toolCall, snapshot, config, store, obse
869
880
  reason: guard.reason,
870
881
  category: guard.category,
871
882
  needsApproval: guard.needsApproval,
883
+ permissionAdvice,
872
884
  });
873
885
  return {
874
886
  ok: false,
@@ -876,6 +888,7 @@ async function executeTool(browserState, toolCall, snapshot, config, store, obse
876
888
  reason: guard.reason,
877
889
  category: guard.category,
878
890
  needsApproval: guard.needsApproval,
891
+ permissionAdvice,
879
892
  toolName: toolCall.function.name,
880
893
  args: safeArgs,
881
894
  };
@@ -1014,17 +1027,27 @@ async function executeTool(browserState, toolCall, snapshot, config, store, obse
1014
1027
  const result = await executeWorkspaceTool(toolCall.function.name, args, config);
1015
1028
  const eventResult = sanitizeToolResult(result);
1016
1029
  if (result.blocked) {
1030
+ const permissionAdvice = buildPermissionAdvice({
1031
+ toolName: toolCall.function.name,
1032
+ args: safeArgs,
1033
+ guard: result,
1034
+ config,
1035
+ state,
1036
+ });
1037
+ result.permissionAdvice = permissionAdvice;
1017
1038
  await store.appendEvent("tool.blocked", {
1018
1039
  toolName: toolCall.function.name,
1019
1040
  args: safeArgs,
1020
1041
  reason: result.reason,
1021
1042
  category: result.category,
1043
+ permissionAdvice,
1022
1044
  });
1023
1045
  observers.event("tool.blocked", {
1024
1046
  toolName: toolCall.function.name,
1025
1047
  args: safeArgs,
1026
1048
  reason: result.reason,
1027
1049
  category: result.category,
1050
+ permissionAdvice,
1028
1051
  });
1029
1052
  return result;
1030
1053
  }
@@ -1049,6 +1072,15 @@ async function executeTool(browserState, toolCall, snapshot, config, store, obse
1049
1072
  await ensureDockerSandboxReady(config, observers);
1050
1073
  }
1051
1074
  const commandResult = await runShellCommand(String(args.command), config, policy);
1075
+ const permissionAdvice = commandResult.ok === false
1076
+ ? buildFailedCommandAdvice({
1077
+ args: safeArgs,
1078
+ commandPolicy: policy,
1079
+ commandResult,
1080
+ config,
1081
+ state,
1082
+ })
1083
+ : null;
1052
1084
  const result = {
1053
1085
  ok: commandResult.ok !== false,
1054
1086
  toolName: "run_command",
@@ -1062,6 +1094,7 @@ async function executeTool(browserState, toolCall, snapshot, config, store, obse
1062
1094
  writesWorkspace: Boolean(policy.writesWorkspace),
1063
1095
  },
1064
1096
  ...commandResult,
1097
+ ...(permissionAdvice ? { permissionAdvice } : {}),
1065
1098
  };
1066
1099
  await store.appendEvent("tool.completed", result);
1067
1100
  observers.event("tool.completed", result);
@@ -1743,6 +1776,7 @@ export async function runAgent(config) {
1743
1776
  commandPolicy: toolResult.commandPolicy,
1744
1777
  blocked: Boolean(toolResult.blocked),
1745
1778
  error: toolResult.error || toolResult.reason || "",
1779
+ permissionAdvice: toolResult.permissionAdvice || null,
1746
1780
  });
1747
1781
  }
1748
1782
 
@@ -41,13 +41,14 @@ export const MAIN_AUTH_PROVIDERS = [
41
41
  label: "Qwen",
42
42
  keyName: "QWEN_API_KEY",
43
43
  description: "Qwen OpenAI-compatible route",
44
+ keyUrl: "https://bailian.console.aliyun.com/",
44
45
  },
45
46
  {
46
47
  id: "venice",
47
48
  label: "Venice",
48
49
  keyName: "VENICE_API_KEY",
49
50
  description: "Venice OpenAI-compatible text and image routes",
50
- keyUrl: "https://venice.ai",
51
+ keyUrl: "https://venice.ai/settings/api",
51
52
  },
52
53
  ];
53
54
 
@@ -56,6 +57,7 @@ const AUXILIARY_AUTH_PROVIDER = {
56
57
  label: "GRS AI / Nano Banana",
57
58
  keyName: "GRSAI",
58
59
  description: "optional image generation",
60
+ keyUrl: "https://grsai.ai/dashboard/api-keys",
59
61
  };
60
62
 
61
63
  const AUTH_ALIASES = {
@@ -42,8 +42,8 @@ const SAFE_WORKSPACE_WRITE_PATTERNS = [/^mkdir\s+-p\s+[-\w./]+$/];
42
42
  const PERMISSION_CHANGE_PATTERNS = [/^(?:sudo\s+)?chmod\s+[-+=,rwxugoXst0-7]+\s+[-\w./]+$/];
43
43
 
44
44
  const NETWORK_FETCH_PATTERNS = [
45
- /^curl\s+(?:-[A-Za-z0-9]*\s+)*https?:\/\/\S+(?:\s+-o\s+[-\w./]+)?$/,
46
- /^wget\s+(?:-[A-Za-z0-9]*\s+)*(?:-O\s+[-\w./]+\s+)?https?:\/\/\S+$/,
45
+ /^curl\b(?=[\s\S]*https?:\/\/\S+)[\s\S]*$/,
46
+ /^wget\b(?=[\s\S]*https?:\/\/\S+)[\s\S]*$/,
47
47
  ];
48
48
 
49
49
  const GIT_WORKFLOW_PATTERNS = [
@@ -163,6 +163,29 @@ function isSafeVirtualWorkspaceDir(value) {
163
163
  return isSafeRelativeDir(normalized.replace(/^\/workspace\//, ""));
164
164
  }
165
165
 
166
+ function classifyGitClone(normalized) {
167
+ const match = normalized.match(
168
+ /^git\s+clone(?:\s+--depth\s+\d+)?(?:\s+--branch\s+[-\w./]+)?\s+(https:\/\/\S+)(?:\s+([-\w./]+))?$/
169
+ );
170
+ if (!match) return null;
171
+
172
+ const target = match[2] || "";
173
+ if (target && !isSafeRelativeDir(target) && !isSafeVirtualWorkspaceDir(target)) {
174
+ return {
175
+ category: "blocked",
176
+ reason: `git clone target must be a safe workspace-relative directory: ${target}`,
177
+ };
178
+ }
179
+
180
+ return {
181
+ category: "git-remote",
182
+ needsNetwork: true,
183
+ writesWorkspace: true,
184
+ virtualWorkspacePath: Boolean(target && isSafeVirtualWorkspaceDir(target)),
185
+ reason: "Git clone writes into the workspace and requires network access.",
186
+ };
187
+ }
188
+
166
189
  function classifySimpleCommand(normalized) {
167
190
  if (ALWAYS_BLOCKED_PATTERNS.some((pattern) => pattern.test(normalized))) {
168
191
  return { category: "blocked", reason: "Command is blocked because it may expose secrets or publish packages." };
@@ -205,6 +228,8 @@ function classifySimpleCommand(normalized) {
205
228
  reason: "Git workflow command. Agent should run git status/diff first and stop on conflicts or divergence.",
206
229
  };
207
230
  }
231
+ const gitCloneClassification = classifyGitClone(normalized);
232
+ if (gitCloneClassification) return gitCloneClassification;
208
233
 
209
234
  const lowered = ` ${normalized.toLowerCase()} `;
210
235
  if (BLOCKED_WRITE_TOKENS.some((part) => lowered.includes(part))) {
@@ -561,6 +561,12 @@ function printCommandOutputLog(data = {}) {
561
561
  for (const line of preview.shown) outputLine(`${color(" | ", ansi.systemBg)} ${line}`);
562
562
  if (preview.hidden > 0) outputLine(`${color(" | ", ansi.systemBg)} ... ${preview.hidden} more line(s) folded`);
563
563
  }
564
+ if (data.permissionAdvice) {
565
+ const advice = data.permissionAdvice;
566
+ outputLine(`${label("perm", ansi.red)} ${compactLine(advice.summary || advice.reason || "Permission advice available.", 104)}`);
567
+ if (advice.suggestedCommand) outputLine(`${color(" | ", ansi.red)} rerun: ${compactLine(advice.suggestedCommand, 120)}`);
568
+ if (advice.trustedHostCommand) outputLine(`${color(" | ", ansi.red)} host: ${compactLine(advice.trustedHostCommand, 120)}`);
569
+ }
564
570
  }
565
571
 
566
572
  function printHeading(text) {
@@ -3199,6 +3205,12 @@ async function runPrompt(prompt, state, packageDir) {
3199
3205
  printWorkspaceChange(data);
3200
3206
  } else if (type === "tool.blocked") {
3201
3207
  printStatusEvent(state, "tool_blocked", data.toolName || data.reason || "unknown");
3208
+ if (data.permissionAdvice) {
3209
+ const advice = data.permissionAdvice;
3210
+ outputLine(`${label("perm", ansi.red)} ${compactLine(advice.summary || advice.reason || "Permission advice available.", 104)}`);
3211
+ if (advice.suggestedCommand) outputLine(`${color(" | ", ansi.red)} rerun: ${compactLine(advice.suggestedCommand, 120)}`);
3212
+ if (advice.trustedHostCommand) outputLine(`${color(" | ", ansi.red)} host: ${compactLine(advice.trustedHostCommand, 120)}`);
3213
+ }
3202
3214
  } else if (type === "loop.guard") {
3203
3215
  printStatusEvent(state, "loop_guard", data.toolName || "");
3204
3216
  } else if (type === "conversation.queued_input_applied") {
@@ -0,0 +1,199 @@
1
+ import { redactSensitiveText } from "./redaction.js";
2
+
3
+ const NETWORK_FAILURE_PATTERNS = [
4
+ /could not resolve host/i,
5
+ /temporary failure in name resolution/i,
6
+ /name or service not known/i,
7
+ /network is unreachable/i,
8
+ /failed to connect/i,
9
+ /connection timed out/i,
10
+ /unable to access ['"].*?:/i,
11
+ ];
12
+
13
+ function quoteShell(value = "") {
14
+ const text = String(value || "");
15
+ return `'${text.replace(/'/g, `'\\''`)}'`;
16
+ }
17
+
18
+ function compactLine(value = "", max = 220) {
19
+ const text = redactSensitiveText(String(value || "")).replace(/\s+/g, " ").trim();
20
+ return text.length <= max ? text : `${text.slice(0, max - 3)}...`;
21
+ }
22
+
23
+ function sessionIdFrom(config = {}, state = {}) {
24
+ return state.sessionId || config.resume || config.sessionId || "<session-id>";
25
+ }
26
+
27
+ function cwdFrom(config = {}) {
28
+ return config.commandCwd || process.cwd();
29
+ }
30
+
31
+ function resumeCommand({ config = {}, state = {}, sandboxMode = "docker-workspace", destructive = false, prompt = "Continue the same task from the last blocker. Do not repeat the blocked command without new permission evidence." } = {}) {
32
+ const parts = [
33
+ "aginti",
34
+ "--resume",
35
+ quoteShell(sessionIdFrom(config, state)),
36
+ "--cwd",
37
+ quoteShell(cwdFrom(config)),
38
+ "--sandbox-mode",
39
+ sandboxMode,
40
+ "--package-install-policy",
41
+ "allow",
42
+ "--approve-package-installs",
43
+ "--allow-shell",
44
+ "--allow-file-tools",
45
+ ];
46
+ if (destructive) parts.push("--allow-destructive");
47
+ parts.push(quoteShell(prompt));
48
+ return parts.join(" ");
49
+ }
50
+
51
+ function currentMode(config = {}) {
52
+ return {
53
+ sandboxMode: config.sandboxMode || "",
54
+ packageInstallPolicy: config.packageInstallPolicy || "",
55
+ allowShellTool: Boolean(config.allowShellTool),
56
+ allowFileTools: Boolean(config.allowFileTools),
57
+ allowDestructive: Boolean(config.allowDestructive),
58
+ commandCwd: cwdFrom(config),
59
+ };
60
+ }
61
+
62
+ function adviceForCategory(category = "", { toolName = "", args = {}, config = {}, state = {}, reason = "" } = {}) {
63
+ const command = compactLine(args.command || "");
64
+ const base = {
65
+ category: category || "permission",
66
+ reason: compactLine(reason || "The runtime policy blocked this operation."),
67
+ currentMode: currentMode(config),
68
+ blockedOperation: command ? `run_command: ${command}` : toolName,
69
+ instruction:
70
+ "Stop and present this blocker to the user instead of repeatedly trying variants. Continue only after the user approves a safer mode, changes the workspace, or gives a replacement instruction.",
71
+ };
72
+
73
+ if (category === "workspace-path") {
74
+ return {
75
+ ...base,
76
+ summary:
77
+ "The requested path is outside the configured project workspace or is a protected path. Current-folder writes are allowed; outside-folder writes need the user to change the working directory or choose a trusted run.",
78
+ options: [
79
+ "Refuse: keep all outputs inside the current workspace and ask for a workspace-relative path.",
80
+ "Allow this project: rerun from the intended project folder with --cwd <project-folder>.",
81
+ "Trusted host: only if the user explicitly wants host-wide writes, rerun in host mode with --allow-destructive.",
82
+ ],
83
+ suggestedCommand: resumeCommand({
84
+ config,
85
+ state,
86
+ sandboxMode: "host",
87
+ destructive: true,
88
+ prompt: "Continue after the user approved writing outside the previous workspace. Keep a clear audit trail and do not touch unrelated files.",
89
+ }),
90
+ };
91
+ }
92
+
93
+ if (category === "host-sudo" || category === "system-package-install") {
94
+ return {
95
+ ...base,
96
+ summary:
97
+ "Host sudo and host OS package installs are not run automatically. Use Docker workspace setup when possible, or ask the user to run the exact host command manually.",
98
+ options: [
99
+ "Refuse: report the exact missing dependency and the manual host command.",
100
+ "Allow contained setup: rerun in docker-workspace with package installs approved.",
101
+ "Manual host setup: user runs the sudo command, then resumes the session.",
102
+ ],
103
+ suggestedCommand: resumeCommand({
104
+ config,
105
+ state,
106
+ sandboxMode: "docker-workspace",
107
+ prompt: "Continue using Docker workspace setup where possible. If host sudo is still required, stop and provide the exact manual command.",
108
+ }),
109
+ };
110
+ }
111
+
112
+ if (category === "package-install" || category === "env-setup" || category === "network-fetch" || category === "git-remote") {
113
+ return {
114
+ ...base,
115
+ summary:
116
+ "This operation needs network or environment setup. It is allowed when shell is enabled in docker-workspace mode with package installs approved.",
117
+ options: [
118
+ "Refuse: stop and explain that network/setup is not approved.",
119
+ "Allow this task: rerun with docker-workspace and approved package installs.",
120
+ "Trusted host: use host mode only when the user specifically needs host tools or host network behavior.",
121
+ ],
122
+ suggestedCommand: resumeCommand({
123
+ config,
124
+ state,
125
+ sandboxMode: "docker-workspace",
126
+ prompt: "Continue the same task with network/setup approved in Docker workspace mode. Verify the operation actually creates the expected output before reporting success.",
127
+ }),
128
+ };
129
+ }
130
+
131
+ if (category === "destructive" || category === "permission-change" || category === "general-shell") {
132
+ return {
133
+ ...base,
134
+ summary:
135
+ "This command is broader or destructive enough to require a stronger trust mode. Prefer Docker workspace for project-local work; use trusted host mode only when necessary.",
136
+ options: [
137
+ "Refuse: explain the blocked command and ask for a safer project-local alternative.",
138
+ "Allow contained broad shell: rerun in docker-workspace with package installs approved.",
139
+ "Allow trusted host: rerun in host mode with --allow-destructive when the user accepts host risk.",
140
+ ],
141
+ suggestedCommand: resumeCommand({
142
+ config,
143
+ state,
144
+ sandboxMode: "docker-workspace",
145
+ prompt: "Continue with broad shell access inside Docker workspace. Avoid destructive host actions and verify outputs before finishing.",
146
+ }),
147
+ trustedHostCommand: resumeCommand({
148
+ config,
149
+ state,
150
+ sandboxMode: "host",
151
+ destructive: true,
152
+ prompt: "Continue after the user approved trusted host execution. Inspect first, avoid unrelated files, and keep git status understandable.",
153
+ }),
154
+ };
155
+ }
156
+
157
+ return {
158
+ ...base,
159
+ summary:
160
+ "The current runtime policy blocked this tool. Ask the user whether to keep the task inside the current workspace, approve a stronger mode, or stop.",
161
+ options: [
162
+ "Refuse: report the blocker and do not continue.",
163
+ "Allow this task: rerun with explicit shell/file/package flags appropriate to the blocker.",
164
+ "Change request: ask the user for a safer workspace-relative output or a manual setup step.",
165
+ ],
166
+ suggestedCommand: resumeCommand({ config, state }),
167
+ };
168
+ }
169
+
170
+ export function buildPermissionAdvice({ toolName = "", args = {}, guard = {}, config = {}, state = {}, reason = "" } = {}) {
171
+ const category = guard.category || "permission";
172
+ return adviceForCategory(category, {
173
+ toolName,
174
+ args,
175
+ config,
176
+ state,
177
+ reason: reason || guard.reason || "",
178
+ });
179
+ }
180
+
181
+ export function looksLikeNetworkFailure(result = {}) {
182
+ const text = `${result.stdout || ""}\n${result.stderr || ""}`;
183
+ return NETWORK_FAILURE_PATTERNS.some((pattern) => pattern.test(text));
184
+ }
185
+
186
+ export function buildFailedCommandAdvice({ args = {}, commandPolicy = {}, commandResult = {}, config = {}, state = {} } = {}) {
187
+ if (!looksLikeNetworkFailure(commandResult)) return null;
188
+ return {
189
+ ...adviceForCategory(commandPolicy.needsNetwork ? "network-fetch" : "general-shell", {
190
+ toolName: "run_command",
191
+ args,
192
+ config,
193
+ state,
194
+ reason:
195
+ "The command failed with a network-resolution/connectivity error. Do not report success unless a later check proves the expected artifact was created in this run.",
196
+ }),
197
+ failureKind: "network",
198
+ };
199
+ }
package/web.js CHANGED
@@ -527,6 +527,7 @@ async function collectWorkspaceActivity(limit = 24) {
527
527
  path: event.data?.args?.path || "",
528
528
  reason: event.data?.reason || "",
529
529
  category: event.data?.category || "",
530
+ permissionAdvice: event.data?.permissionAdvice || null,
530
531
  });
531
532
  }
532
533
  }