@gajae-code/coding-agent 0.18.8 → 0.18.9

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.
Files changed (113) hide show
  1. package/CHANGELOG.md +98 -0
  2. package/dist/types/async/endpoint-id.d.ts +16 -0
  3. package/dist/types/async/support.d.ts +0 -14
  4. package/dist/types/commands/mcp-serve.d.ts +4 -0
  5. package/dist/types/discovery/mcp-json.d.ts +1 -0
  6. package/dist/types/eval/py/kernel.d.ts +18 -0
  7. package/dist/types/export/html/html-export-destination.d.ts +2 -0
  8. package/dist/types/extensibility/extensions/function-hooks-internal.d.ts +5 -0
  9. package/dist/types/extensibility/skill-management.d.ts +1 -0
  10. package/dist/types/gjc-runtime/psmux-detect.d.ts +3 -3
  11. package/dist/types/gjc-runtime/sidecar-bootstrap-env.d.ts +6 -0
  12. package/dist/types/gjc-runtime/state-writer.d.ts +3 -0
  13. package/dist/types/gjc-runtime/tmux-common.d.ts +8 -5
  14. package/dist/types/lsp/config.d.ts +8 -5
  15. package/dist/types/runtime-mcp/manager.d.ts +2 -1
  16. package/dist/types/runtime-mcp/mcp-redirect.d.ts +9 -0
  17. package/dist/types/runtime-mcp/oauth-public-url.d.ts +3 -1
  18. package/dist/types/runtime-mcp/project-host-values.d.ts +27 -0
  19. package/dist/types/sdk/broker/broker.d.ts +6 -1
  20. package/dist/types/sdk/broker/doctor-restart.d.ts +5 -0
  21. package/dist/types/sdk/broker/ensure.d.ts +14 -1
  22. package/dist/types/session/session-storage.d.ts +7 -0
  23. package/dist/types/skill-state/workflow-mutation-guard.d.ts +3 -0
  24. package/dist/types/task/isolation-dir.d.ts +26 -0
  25. package/dist/types/tools/ast-edit-write-path.d.ts +13 -0
  26. package/dist/types/tools/browser/cdp-url.d.ts +2 -0
  27. package/dist/types/tools/github-side-effect.d.ts +22 -0
  28. package/dist/types/tools/monitor-mutation.d.ts +9 -0
  29. package/package.json +7 -7
  30. package/src/async/endpoint-id.ts +26 -0
  31. package/src/async/job-manager.ts +19 -10
  32. package/src/async/support.ts +0 -25
  33. package/src/capability/index.ts +14 -16
  34. package/src/cli/doctor/repairs.ts +4 -1
  35. package/src/cli/doctor-supervisor.ts +3 -0
  36. package/src/cli/public-command-registry.ts +28 -6
  37. package/src/commands/mcp-serve.ts +7 -0
  38. package/src/commands/sdk.ts +453 -415
  39. package/src/config/file-lock.ts +9 -11
  40. package/src/config/model-profile-activation.ts +5 -5
  41. package/src/coordinator-mcp/server.ts +2 -0
  42. package/src/customization/inventory.ts +1 -13
  43. package/src/dap/config.ts +2 -2
  44. package/src/defaults/gjc/extensions/grok-cli-vendor/src/provider/register.ts +10 -6
  45. package/src/discovery/builtin.ts +3 -1
  46. package/src/discovery/helpers.ts +12 -9
  47. package/src/discovery/mcp-json.ts +20 -12
  48. package/src/eval/py/executor.ts +459 -77
  49. package/src/eval/py/kernel.ts +79 -13
  50. package/src/eval/py/tool-bridge.ts +4 -0
  51. package/src/export/html/html-export-destination.ts +16 -0
  52. package/src/export/html/index.ts +2 -6
  53. package/src/extensibility/extensions/function-hooks-internal.ts +27 -0
  54. package/src/extensibility/extensions/runner.ts +11 -1
  55. package/src/extensibility/gjc-plugins/mcp-policy.ts +2 -1
  56. package/src/extensibility/gjc-plugins/runtime-adapters.ts +27 -15
  57. package/src/extensibility/skill-management.ts +3 -0
  58. package/src/gjc-runtime/launch-worktree.ts +56 -5
  59. package/src/gjc-runtime/psmux-detect.ts +28 -4
  60. package/src/gjc-runtime/session-state-sidecar.ts +3 -2
  61. package/src/gjc-runtime/sidecar-bootstrap-env.ts +19 -0
  62. package/src/gjc-runtime/state-writer.ts +22 -8
  63. package/src/gjc-runtime/tmux-common.ts +8 -5
  64. package/src/internal-urls/docs-index.generated.ts +7 -7
  65. package/src/lsp/config.ts +12 -63
  66. package/src/memories/index.ts +88 -7
  67. package/src/modes/components/hook-editor.ts +1 -1
  68. package/src/modes/components/hook-selector.ts +1 -1
  69. package/src/modes/controllers/input-controller.ts +2 -2
  70. package/src/modes/controllers/runtime-mcp-command-controller.ts +21 -13
  71. package/src/modes/controllers/selector-controller.ts +3 -7
  72. package/src/modes/controllers/todo-command-controller.ts +1 -1
  73. package/src/modes/interactive-mode.ts +1 -1
  74. package/src/runtime-mcp/client.ts +4 -2
  75. package/src/runtime-mcp/json-rpc.ts +2 -1
  76. package/src/runtime-mcp/manager.ts +29 -11
  77. package/src/runtime-mcp/mcp-redirect.ts +73 -0
  78. package/src/runtime-mcp/oauth-flow.ts +11 -4
  79. package/src/runtime-mcp/oauth-public-url.ts +8 -2
  80. package/src/runtime-mcp/pool.ts +6 -0
  81. package/src/runtime-mcp/project-host-values.ts +46 -0
  82. package/src/runtime-mcp/transports/http.ts +2 -1
  83. package/src/sdk/acp/adapter.ts +2 -0
  84. package/src/sdk/broker/broker.ts +6 -0
  85. package/src/sdk/broker/doctor-restart.ts +4 -0
  86. package/src/sdk/broker/ensure.ts +29 -2
  87. package/src/sdk/host/session-runtime.test.ts +58 -9
  88. package/src/sdk/host/session-runtime.ts +65 -35
  89. package/src/sdk/router/session-router.ts +7 -6
  90. package/src/sdk/session.ts +2 -7
  91. package/src/session/agent-session.ts +59 -17
  92. package/src/session/artifacts.ts +3 -2
  93. package/src/session/internal/managed-session-scope.ts +403 -81
  94. package/src/session/internal/managed-session-storage.ts +125 -17
  95. package/src/session/session-manager.ts +12 -0
  96. package/src/session/session-storage.ts +30 -1
  97. package/src/skill-state/active-state.ts +13 -0
  98. package/src/skill-state/workflow-mutation-guard.ts +102 -10
  99. package/src/slash-commands/builtin-registry.ts +1 -1
  100. package/src/slash-commands/helpers/mcp.ts +8 -4
  101. package/src/task/isolation-dir.ts +143 -0
  102. package/src/task/worktree.ts +16 -7
  103. package/src/tools/ast-edit-write-path.ts +68 -0
  104. package/src/tools/ast-edit.ts +7 -2
  105. package/src/tools/browser/cdp-url.ts +18 -0
  106. package/src/tools/browser.ts +8 -1
  107. package/src/tools/gh.ts +10 -0
  108. package/src/tools/github-side-effect.ts +112 -0
  109. package/src/tools/monitor-mutation.ts +27 -0
  110. package/src/tools/monitor.ts +7 -0
  111. package/src/tools/python.ts +7 -6
  112. package/src/utils/external-editor.ts +3 -2
  113. package/src/web/insane/url-guard.ts +2 -1
@@ -0,0 +1,143 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import * as fs from "node:fs/promises";
3
+ import * as path from "node:path";
4
+
5
+ const ALLOCATION_ATTEMPTS = 8;
6
+
7
+ type PathApi = Pick<typeof path, "dirname" | "isAbsolute" | "relative" | "resolve" | "sep">;
8
+
9
+ function isErrno(error: unknown, code: string): boolean {
10
+ return typeof error === "object" && error !== null && "code" in error && error.code === code;
11
+ }
12
+
13
+ function defaultToken(): string {
14
+ return randomBytes(9).toString("hex");
15
+ }
16
+
17
+ /**
18
+ * Parent directory of `mergedDir`, but only when it is a single entry inside
19
+ * `worktreeRoot`. Root, empty, nested, and escaped paths are refused so
20
+ * cleanup cannot delete the worktree root or another session's tree by
21
+ * walking up.
22
+ */
23
+ export function resolveIsolationRemovalTarget(
24
+ mergedDir: string,
25
+ worktreeRoot: string,
26
+ pathApi: PathApi = path,
27
+ ): string {
28
+ if (mergedDir.length === 0) {
29
+ throw new Error("Refusing to remove an isolation directory for an empty merged path.");
30
+ }
31
+ if (worktreeRoot.length === 0) {
32
+ throw new Error("Refusing to remove an isolation directory without a worktree root.");
33
+ }
34
+ const baseDir = pathApi.resolve(pathApi.dirname(mergedDir));
35
+ const root = pathApi.resolve(worktreeRoot);
36
+ const relative = pathApi.relative(root, baseDir);
37
+ if (
38
+ relative.length === 0 ||
39
+ relative === "." ||
40
+ relative.startsWith("..") ||
41
+ pathApi.isAbsolute(relative) ||
42
+ relative.includes(pathApi.sep) ||
43
+ relative.includes("/") ||
44
+ relative.includes("\\")
45
+ ) {
46
+ throw new Error("Refusing to remove an isolation directory that is not a single worktree entry.");
47
+ }
48
+ return baseDir;
49
+ }
50
+
51
+ /**
52
+ * Create an exclusive directory next to `canonicalBaseDir`. Never removes
53
+ * `canonicalBaseDir` or any path that already exists.
54
+ */
55
+ export async function allocateDisjointIsolationDir(
56
+ canonicalBaseDir: string,
57
+ tokenFactory: () => string = defaultToken,
58
+ ): Promise<string> {
59
+ const parent = path.dirname(canonicalBaseDir);
60
+ const stem = path.basename(canonicalBaseDir);
61
+ if (stem.length === 0 || stem === "." || stem === ".." || stem.includes("/") || stem.includes("\\")) {
62
+ throw new Error("Isolation directory name is not a single path segment.");
63
+ }
64
+ await fs.mkdir(parent, { recursive: true, mode: 0o700 });
65
+
66
+ for (let attempt = 0; attempt < ALLOCATION_ATTEMPTS; attempt++) {
67
+ const token = tokenFactory();
68
+ if (!/^[0-9a-f]+$/i.test(token)) {
69
+ throw new Error("Isolation directory token is not hexadecimal.");
70
+ }
71
+ const name = `${stem}-${token}`;
72
+ const baseDir = path.join(parent, name);
73
+ const relative = path.relative(parent, baseDir);
74
+ if (relative !== name || path.isAbsolute(relative)) {
75
+ throw new Error("Isolation directory escaped its parent.");
76
+ }
77
+ try {
78
+ await fs.mkdir(baseDir, { mode: 0o700 });
79
+ return baseDir;
80
+ } catch (error) {
81
+ if (isErrno(error, "EEXIST")) continue;
82
+ if (isErrno(error, "EPERM")) {
83
+ try {
84
+ await fs.lstat(baseDir);
85
+ continue;
86
+ } catch (statError) {
87
+ if (!isErrno(statError, "ENOENT")) throw statError;
88
+ }
89
+ }
90
+ throw error;
91
+ }
92
+ }
93
+
94
+ throw new Error("Failed to allocate a disjoint task isolation directory.");
95
+ }
96
+
97
+ /**
98
+ * Confirm native teardown may see `mergedDir`.
99
+ *
100
+ * `isoStop` deletes that path. The parent entry and `merged` itself must
101
+ * already be real directories, because native removal follows symlinks.
102
+ * A missing parent means there is nothing to stop. This does not close a
103
+ * replacement that happens after the lstat returns.
104
+ */
105
+ export async function assertIsolationTeardownTarget(mergedDir: string, worktreeRoot: string): Promise<"stop" | "skip"> {
106
+ const baseDir = resolveIsolationRemovalTarget(mergedDir, worktreeRoot);
107
+ let baseInfo: { isSymbolicLink(): boolean };
108
+ try {
109
+ baseInfo = await fs.lstat(baseDir);
110
+ } catch (error) {
111
+ if (isErrno(error, "ENOENT")) return "skip";
112
+ throw error;
113
+ }
114
+ if (baseInfo.isSymbolicLink()) {
115
+ throw new Error("Refusing to remove a symlinked isolation directory.");
116
+ }
117
+ try {
118
+ const mergedInfo = await fs.lstat(mergedDir);
119
+ if (mergedInfo.isSymbolicLink()) {
120
+ throw new Error("Refusing to tear down a symlinked isolation merged directory.");
121
+ }
122
+ } catch (error) {
123
+ if (isErrno(error, "ENOENT")) return "stop";
124
+ throw error;
125
+ }
126
+ return "stop";
127
+ }
128
+
129
+ /** Remove the isolation directory that owns `mergedDir`. Missing directories are ignored. */
130
+ export async function removeIsolationDirectory(mergedDir: string, worktreeRoot: string): Promise<void> {
131
+ const baseDir = resolveIsolationRemovalTarget(mergedDir, worktreeRoot);
132
+ let info: { isSymbolicLink(): boolean };
133
+ try {
134
+ info = await fs.lstat(baseDir);
135
+ } catch (error) {
136
+ if (isErrno(error, "ENOENT")) return;
137
+ throw error;
138
+ }
139
+ if (info.isSymbolicLink()) {
140
+ throw new Error("Refusing to remove a symlinked isolation directory.");
141
+ }
142
+ await fs.rm(baseDir, { recursive: true, force: true });
143
+ }
@@ -5,8 +5,9 @@ import * as path from "node:path";
5
5
 
6
6
  import type * as natives from "@gajae-code/natives";
7
7
 
8
- import { getWorktreeDir, hashPath, logger, Snowflake } from "@gajae-code/utils";
8
+ import { getWorktreeDir, getWorktreesDir, hashPath, logger, Snowflake } from "@gajae-code/utils";
9
9
  import * as git from "../utils/git";
10
+ import { allocateDisjointIsolationDir, assertIsolationTeardownTarget, removeIsolationDirectory } from "./isolation-dir";
10
11
 
11
12
  let nativeWorktreeBindings: typeof import("@gajae-code/natives") | undefined;
12
13
 
@@ -401,8 +402,13 @@ export async function ensureIsolation(
401
402
  preferred?: IsoBackendKind,
402
403
  ): Promise<IsolationHandle> {
403
404
  const repoRoot = await getRepoRoot(baseCwd);
404
- const baseDir = getWorktreeDir(`${id}-${hashPath(repoRoot)}`);
405
+ // Task ids are allocated inside one session, so `<id>-<repo hash>` is shared
406
+ // by every fresh session's first task. Never remove that canonical directory.
407
+ // Each attempt gets an exclusive sibling and only that sibling is removed.
408
+ const canonicalBaseDir = getWorktreeDir(`${id}-${hashPath(repoRoot)}`);
409
+ const baseDir = await allocateDisjointIsolationDir(canonicalBaseDir);
405
410
  const mergedDir = path.join(baseDir, "merged");
411
+ const worktreeRoot = getWorktreesDir();
406
412
  const natives = nativeWorktree();
407
413
 
408
414
  const resolution = natives.isoResolve(preferred ?? null);
@@ -410,7 +416,7 @@ export async function ensureIsolation(
410
416
  let fallbackReason = resolution.reason ?? null;
411
417
 
412
418
  for (const candidate of candidates) {
413
- await fs.rm(baseDir, { recursive: true, force: true });
419
+ await removeIsolationDirectory(mergedDir, worktreeRoot);
414
420
  try {
415
421
  await natives.isoStart(candidate, repoRoot, mergedDir);
416
422
  return {
@@ -420,7 +426,7 @@ export async function ensureIsolation(
420
426
  fallbackReason,
421
427
  };
422
428
  } catch (err) {
423
- await fs.rm(baseDir, { recursive: true, force: true });
429
+ await removeIsolationDirectory(mergedDir, worktreeRoot);
424
430
  const message = errorMessage(err);
425
431
  if (!natives.isoIsUnavailableError(message)) {
426
432
  throw err;
@@ -434,6 +440,11 @@ export async function ensureIsolation(
434
440
 
435
441
  /** Tear down a handle returned by {@link ensureIsolation}. */
436
442
  export async function cleanupIsolation(handle: IsolationHandle): Promise<void> {
443
+ const worktreeRoot = getWorktreesDir();
444
+ // Native isoStop removes `mergedDir` and follows symlinks. Refuse a
445
+ // symlink that is already present before that call.
446
+ const teardown = await assertIsolationTeardownTarget(handle.mergedDir, worktreeRoot);
447
+ if (teardown === "skip") return;
437
448
  const natives = nativeWorktree();
438
449
  try {
439
450
  try {
@@ -446,9 +457,7 @@ export async function cleanupIsolation(handle: IsolationHandle): Promise<void> {
446
457
  });
447
458
  }
448
459
  } finally {
449
- // baseDir is the parent of the merged directory
450
- const baseDir = path.dirname(handle.mergedDir);
451
- await fs.rm(baseDir, { recursive: true, force: true });
460
+ await removeIsolationDirectory(handle.mergedDir, worktreeRoot);
452
461
  }
453
462
  }
454
463
 
@@ -0,0 +1,68 @@
1
+ import * as fs from "node:fs/promises";
2
+ import * as path from "node:path";
3
+
4
+ const MAX_ANCESTOR_WALK = 64;
5
+
6
+ function isInside(root: string, target: string): boolean {
7
+ const relative = path.relative(root, target);
8
+ return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
9
+ }
10
+
11
+ /**
12
+ * Path the native writer will mutate: realpath of an existing file, or realpath
13
+ * of the nearest existing ancestor plus the missing tail.
14
+ */
15
+ export async function canonicalizeAstEditWritePath(absolutePath: string): Promise<string> {
16
+ const suffix: string[] = [];
17
+ let current = path.resolve(absolutePath);
18
+ for (let depth = 0; depth < MAX_ANCESTOR_WALK; depth++) {
19
+ try {
20
+ const real = await fs.realpath(current);
21
+ return suffix.length === 0 ? real : path.join(real, ...suffix.toReversed());
22
+ } catch {
23
+ const parent = path.dirname(current);
24
+ if (parent === current) break;
25
+ suffix.push(path.basename(current));
26
+ current = parent;
27
+ }
28
+ }
29
+ return path.resolve(absolutePath);
30
+ }
31
+
32
+ /**
33
+ * Preview paths as the workflow guard must see them.
34
+ *
35
+ * The guard compares lexically against the session cwd. A raw realpath misses
36
+ * `.gjc/**` when that cwd is not already canonical (`/var` vs `/private/var`,
37
+ * or a symlinked workspace), so an in-workspace real path is reattached to `cwd`.
38
+ */
39
+ export async function resolveAstEditPreviewWritePaths(
40
+ cwd: string,
41
+ previewedPaths: readonly string[],
42
+ ): Promise<string[]> {
43
+ const resolvedCwd = path.resolve(cwd);
44
+ let realCwd = resolvedCwd;
45
+ try {
46
+ realCwd = await fs.realpath(resolvedCwd);
47
+ } catch {
48
+ // Missing cwd has no canonical spelling; keep the lexical root.
49
+ }
50
+ const resolved: string[] = [];
51
+ for (const previewed of previewedPaths) {
52
+ const lexical = path.resolve(resolvedCwd, previewed);
53
+ const real = await canonicalizeAstEditWritePath(lexical);
54
+ const relative = path.relative(realCwd, real);
55
+ const canonical =
56
+ isInside(realCwd, real) && relative !== ""
57
+ ? path.resolve(resolvedCwd, relative)
58
+ : isInside(realCwd, real)
59
+ ? resolvedCwd
60
+ : real;
61
+ // The guard matches `.gjc` lexically. Keep the preview spelling so a
62
+ // symlinked `.gjc` directory is still recognized, and add the canonical
63
+ // spelling so `src` → `.gjc` is recognized too.
64
+ resolved.push(previewed);
65
+ if (path.resolve(canonical) !== lexical) resolved.push(canonical);
66
+ }
67
+ return resolved;
68
+ }
@@ -13,11 +13,13 @@ import { assertWorkflowMutationRawPathsAllowed } from "../skill-state/workflow-m
13
13
  import { Ellipsis, fileHyperlink, renderStatusLine, renderTreeList, truncateToWidth } from "../tui";
14
14
  import { resolveFileDisplayMode } from "../utils/file-display-mode";
15
15
  import type { ToolSession } from ".";
16
+ import { resolveAstEditPreviewWritePaths } from "./ast-edit-write-path";
17
+ import { assertEditableFile } from "./auto-generated-guard";
16
18
  import { createFileRecorder, formatResultPath } from "./file-recorder";
17
19
  import { formatGroupedFiles } from "./grouped-file-output";
18
20
  import type { OutputMeta } from "./output-meta";
19
21
  import { resolveToolSearchScope } from "./path-utils";
20
- import { enforcePlanModeWrite } from "./plan-mode-guard";
22
+ import { enforcePlanModeWrite, resolvePlanPath } from "./plan-mode-guard";
21
23
  import {
22
24
  appendParseErrorsBulletList,
23
25
  capParseErrors,
@@ -344,8 +346,11 @@ export class AstEditTool implements AgentTool<typeof astEditSchema, AstEditToolD
344
346
  await assertWorkflowMutationRawPathsAllowed({
345
347
  cwd: this.session.cwd,
346
348
  sessionId: this.session.getSessionId?.() ?? undefined,
347
- rawPaths: previewedFiles,
349
+ rawPaths: await resolveAstEditPreviewWritePaths(this.session.cwd, previewedFiles),
348
350
  });
351
+ for (const filePath of previewedFiles) {
352
+ await assertEditableFile(resolvePlanPath(this.session, filePath), filePath, this.session.settings);
353
+ }
349
354
  const applyResult = await runAstEditOnce(multiTargets, resolvedSearchPath, globFilter, {
350
355
  rewrites: normalizedRewrites,
351
356
  dryRun: false,
@@ -0,0 +1,18 @@
1
+ const LOOPBACK_HOSTS = new Set(["127.0.0.1", "localhost", "::1", "[::1]"]);
2
+
3
+ /** Require an http(s) CDP URL whose host is loopback. */
4
+ export function assertLoopbackCdpUrl(cdpUrl: string): string {
5
+ let parsed: URL;
6
+ try {
7
+ parsed = new URL(cdpUrl);
8
+ } catch {
9
+ throw new Error(`Refusing non-loopback CDP endpoint: ${cdpUrl}`);
10
+ }
11
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
12
+ throw new Error(`Refusing non-loopback CDP endpoint: ${cdpUrl}`);
13
+ }
14
+ if (!LOOPBACK_HOSTS.has(parsed.hostname.toLowerCase())) {
15
+ throw new Error(`Refusing non-loopback CDP endpoint: ${cdpUrl}`);
16
+ }
17
+ return cdpUrl;
18
+ }
@@ -6,6 +6,7 @@ import * as z from "zod/v4";
6
6
  import browserDescription from "../prompts/tools/browser.md" with { type: "text" };
7
7
  import type { ToolSession } from "../sdk";
8
8
  import { type BrowserActionStep, compileActionSteps } from "./browser/actions";
9
+ import { assertLoopbackCdpUrl } from "./browser/cdp-url";
9
10
  import { isChromeProfileExecutableForLaunch, isEdgeExecutable, resolveSystemChromeForProfile } from "./browser/launch";
10
11
  import { chromeUserDataRoots, type DiscoveryEnv, defaultDiscoveryEnv } from "./browser/profile-discovery";
11
12
  import { acquireBrowser, type BrowserHandle, type BrowserKind, type BrowserKindTag } from "./browser/registry";
@@ -245,7 +246,13 @@ async function resolveBrowserKind(
245
246
  ): Promise<BrowserKind> {
246
247
  const app = params.app;
247
248
  if (app?.cdp_url) {
248
- return { kind: "connected", cdpUrl: app.cdp_url.replace(/\/+$/, "") };
249
+ const cdpUrl = app.cdp_url.replace(/\/+$/, "");
250
+ try {
251
+ assertLoopbackCdpUrl(cdpUrl);
252
+ } catch (error) {
253
+ throw new ToolError(error instanceof Error ? error.message : String(error));
254
+ }
255
+ return { kind: "connected", cdpUrl };
249
256
  }
250
257
  if (app?.browser === "chrome") {
251
258
  return resolveChromeProfileKind(app, session, signal, discoveryEnv);
package/src/tools/gh.ts CHANGED
@@ -8,10 +8,12 @@ import { getWorktreeDir, hashPath, isEnoent, prompt, untilAborted } from "@gajae
8
8
  import * as z from "zod/v4";
9
9
  import type { Settings } from "../config/settings";
10
10
  import githubDescription from "../prompts/tools/github.md" with { type: "text" };
11
+ import { assertWorkflowMutationAllowed } from "../skill-state/workflow-mutation-guard";
11
12
  import * as git from "../utils/git";
12
13
  import type { ToolSession } from ".";
13
14
  import { formatShortSha } from "./gh-format";
14
15
  import { type CacheStatus, getOrFetchView, resolveGithubCacheAuthKey } from "./github-cache";
16
+ import { githubMutationTool, isGithubReadOnlyOp } from "./github-side-effect";
15
17
  import type { OutputMeta } from "./output-meta";
16
18
  import { ToolError, throwIfAborted } from "./tool-errors";
17
19
  import { toolResult } from "./tool-result";
@@ -2591,6 +2593,14 @@ export class GithubTool implements AgentTool<typeof githubSchema, GhToolDetails>
2591
2593
  _context?: AgentToolContext,
2592
2594
  ): Promise<AgentToolResult<GhToolDetails>> {
2593
2595
  return untilAborted(signal, async () => {
2596
+ if (!isGithubReadOnlyOp(params.op)) {
2597
+ await assertWorkflowMutationAllowed({
2598
+ cwd: this.session.cwd,
2599
+ sessionId: this.session.getSessionId?.() ?? undefined,
2600
+ tool: githubMutationTool,
2601
+ args: params,
2602
+ });
2603
+ }
2594
2604
  switch (params.op) {
2595
2605
  case "repo_view":
2596
2606
  return executeRepoView(this.session, params, signal);
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Classify `github` tool operations for the approval gate and the planning
3
+ * mutation guard. Read-only ops stay available. Every other op, including a
4
+ * missing or unrecognized one, is a side effect and fails closed.
5
+ */
6
+ import type { AgentTool } from "@gajae-code/agent-core";
7
+
8
+ /** Name-only tool the planning guard uses when `GithubTool.execute` checks itself. */
9
+ export const githubMutationTool = {
10
+ name: "github",
11
+ label: "GitHub",
12
+ description: "github",
13
+ parameters: {},
14
+ execute: async () => ({ content: [] }),
15
+ } as AgentTool;
16
+
17
+ const GITHUB_READ_ONLY_OPS = new Set([
18
+ "repo_view",
19
+ "search_issues",
20
+ "search_prs",
21
+ "search_code",
22
+ "search_commits",
23
+ "search_repos",
24
+ "run_watch",
25
+ ]);
26
+
27
+ const GITHUB_SIDE_EFFECT_OPS = new Set(["pr_create", "pr_checkout", "pr_push"]);
28
+
29
+ const TITLE_MAX = 160;
30
+
31
+ function recordOf(args: unknown): Record<string, unknown> | undefined {
32
+ if (!args || typeof args !== "object" || Array.isArray(args)) return undefined;
33
+ return args as Record<string, unknown>;
34
+ }
35
+
36
+ function stringField(record: Record<string, unknown> | undefined, key: string): string | undefined {
37
+ const value = record?.[key];
38
+ if (typeof value !== "string") return undefined;
39
+ const trimmed = value.trim();
40
+ return trimmed.length > 0 ? trimmed : undefined;
41
+ }
42
+
43
+ function clip(value: string): string {
44
+ const cleaned = value
45
+ .replace(/[\u0000-\u001f\u007f]/g, " ")
46
+ .replace(/\s+/g, " ")
47
+ .trim();
48
+ if (cleaned.length <= TITLE_MAX) return cleaned;
49
+ return `${cleaned.slice(0, TITLE_MAX - 1)}…`;
50
+ }
51
+
52
+ function formatPr(value: unknown): string {
53
+ if (typeof value === "string" && value.trim()) return value.trim();
54
+ if (!Array.isArray(value)) return "the current pull request";
55
+ const parts = value.filter((item): item is string => typeof item === "string" && item.trim().length > 0);
56
+ return parts.length > 0 ? parts.join(", ") : "the current pull request";
57
+ }
58
+
59
+ export function githubOp(args: unknown): string | undefined {
60
+ const op = recordOf(args)?.op;
61
+ return typeof op === "string" ? op : undefined;
62
+ }
63
+
64
+ /** True only for a known read-only github op. Missing and unknown ops are not read-only. */
65
+ export function isGithubReadOnlyArgs(args: unknown): boolean {
66
+ const op = githubOp(args);
67
+ return op !== undefined && GITHUB_READ_ONLY_OPS.has(op);
68
+ }
69
+
70
+ export function isGithubReadOnlyOp(op: string): boolean {
71
+ return GITHUB_READ_ONLY_OPS.has(op);
72
+ }
73
+
74
+ /**
75
+ * Approval summary for a side-effect github call. The PR body is omitted so a
76
+ * token pasted into `body` is not copied into the permission title.
77
+ */
78
+ export function describeGithubSideEffect(args: unknown): string {
79
+ const record = recordOf(args);
80
+ const op = githubOp(args) ?? "";
81
+ if (op === "pr_push") {
82
+ const branch = stringField(record, "branch") ?? "the current branch";
83
+ const lease = record?.forceWithLease === true ? " with --force-with-lease" : "";
84
+ return clip(`Push ${branch} to its pull request branch${lease} using stored git credentials`);
85
+ }
86
+ if (op === "pr_checkout") {
87
+ const force = record?.force === true ? " and reset the local branch" : "";
88
+ return clip(
89
+ `Check out ${formatPr(record?.pr)} into ~/.gjc/wt, write branch git config, and may add a remote${force}`,
90
+ );
91
+ }
92
+ if (op === "pr_create") {
93
+ const repo = stringField(record, "repo") ?? "the current repository";
94
+ const head = stringField(record, "head") ?? "the current branch";
95
+ const base = stringField(record, "base") ?? "the default base";
96
+ return clip(`Create a pull request on ${repo} from ${head} into ${base} with gh`);
97
+ }
98
+ return "Run a github operation that is not read-only";
99
+ }
100
+
101
+ export function githubPermissionIntent(
102
+ args: unknown,
103
+ ): { toolName: string; title: string; cacheKey: string } | undefined {
104
+ if (isGithubReadOnlyArgs(args)) return undefined;
105
+ const op = githubOp(args);
106
+ const cacheOp = op && GITHUB_SIDE_EFFECT_OPS.has(op) ? op : "unspecified";
107
+ return {
108
+ toolName: "github",
109
+ title: describeGithubSideEffect(args),
110
+ cacheKey: `github:${cacheOp}`,
111
+ };
112
+ }
@@ -0,0 +1,27 @@
1
+ import type { AgentTool } from "@gajae-code/agent-core";
2
+ import { assertWorkflowMutationAllowed } from "../skill-state/workflow-mutation-guard";
3
+
4
+ const bashShapedTool = {
5
+ name: "bash",
6
+ label: "bash",
7
+ description: "bash",
8
+ parameters: {},
9
+ execute: async () => ({ content: [] }),
10
+ } as AgentTool;
11
+
12
+ /**
13
+ * Judge a monitor command with the same mutation rules as bash. Monitor is a
14
+ * background shell, so a planning-phase mutation must fail before the job starts.
15
+ */
16
+ export async function assertMonitorMutationAllowed(input: {
17
+ cwd: string;
18
+ sessionId?: string;
19
+ command: string;
20
+ }): Promise<void> {
21
+ await assertWorkflowMutationAllowed({
22
+ cwd: input.cwd,
23
+ sessionId: input.sessionId,
24
+ tool: bashShapedTool,
25
+ args: { command: input.command },
26
+ });
27
+ }
@@ -7,6 +7,7 @@ import { truncateTail } from "../session/streaming-output";
7
7
  import { lookupOwnedRegistration } from "../session/terminal-abort";
8
8
  import { BashTool } from "./bash";
9
9
  import type { ToolSession } from "./index";
10
+ import { assertMonitorMutationAllowed } from "./monitor-mutation";
10
11
  import { ToolError } from "./tool-errors";
11
12
  import { TOOL_TIMEOUTS } from "./tool-timeouts";
12
13
 
@@ -137,6 +138,12 @@ export class MonitorTool implements AgentTool<typeof monitorSchema, MonitorToolD
137
138
  throw new ToolError("Async execution is disabled; the monitor tool is unavailable in this session.");
138
139
  }
139
140
 
141
+ await assertMonitorMutationAllowed({
142
+ cwd: this.session.cwd,
143
+ sessionId: this.session.getSessionId?.() ?? undefined,
144
+ command: params.command,
145
+ });
146
+
140
147
  const persistent = params.persistent ?? false;
141
148
  const label = buildMonitorLabel(params);
142
149
  const ownerId = this.session.getAgentId?.() ?? undefined;
@@ -297,6 +297,13 @@ export function createSessionPythonTool(input: SessionPythonToolInput): AgentToo
297
297
  signal?.removeEventListener("abort", abortFromCaller);
298
298
  contextGeneration.abortControllers.delete(abortController);
299
299
  });
300
+ const settled = execution.then(
301
+ () => {},
302
+ () => {},
303
+ );
304
+ contextGeneration.completions.add(settled);
305
+ void settled.then(() => contextGeneration.completions.delete(settled));
306
+
300
307
  let completion: Promise<AgentToolResult>;
301
308
  try {
302
309
  completion = input.trackEvalExecution ? input.trackEvalExecution(execution, abortController) : execution;
@@ -308,12 +315,6 @@ export function createSessionPythonTool(input: SessionPythonToolInput): AgentToo
308
315
  void execution.catch(() => {});
309
316
  throw error;
310
317
  }
311
- const settled = completion.then(
312
- () => {},
313
- () => {},
314
- );
315
- contextGeneration.completions.add(settled);
316
- void settled.then(() => contextGeneration.completions.delete(settled));
317
318
  return await completion;
318
319
  },
319
320
  };
@@ -6,11 +6,12 @@ import { spawn } from "node:child_process";
6
6
  import * as fs from "node:fs/promises";
7
7
  import * as os from "node:os";
8
8
  import * as path from "node:path";
9
- import { $env, Snowflake } from "@gajae-code/utils";
9
+ import { $pickCredentialEnv, Snowflake } from "@gajae-code/utils";
10
10
 
11
11
  /** Returns the user's preferred editor command, or undefined if not configured. */
12
12
  export function getEditorCommand(): string | undefined {
13
- return $env.VISUAL || $env.EDITOR || undefined;
13
+ // The editor is executed, so a repository `.env` must not be able to choose it.
14
+ return $pickCredentialEnv("VISUAL", "EDITOR");
14
15
  }
15
16
 
16
17
  export interface OpenInEditorOptions {
@@ -74,7 +74,7 @@ function isBlockedHostname(hostname: string): boolean {
74
74
  function isPrivateIPv4(address: string): boolean {
75
75
  const parts = address.split(".").map(part => Number.parseInt(part, 10));
76
76
  if (parts.length !== 4 || parts.some(part => !Number.isInteger(part) || part < 0 || part > 255)) return true;
77
- const [a, b] = parts;
77
+ const [a, b, c] = parts;
78
78
  return (
79
79
  a === 0 || // unspecified / "this network"
80
80
  a === 10 || // RFC1918
@@ -83,6 +83,7 @@ function isPrivateIPv4(address: string): boolean {
83
83
  (a === 169 && b === 254) || // link-local
84
84
  (a === 172 && b >= 16 && b <= 31) || // RFC1918
85
85
  (a === 192 && b === 0) || // 192.0.0/24 & 192.0.2/24 (documentation/reserved)
86
+ (a === 192 && b === 88 && c === 99) || // 192.88.99.0/24 deprecated 6to4 anycast; 192.88.99.2/32 is not globally reachable
86
87
  (a === 192 && b === 168) || // RFC1918
87
88
  (a === 198 && (b === 18 || b === 19)) || // benchmarking 198.18/15
88
89
  (a === 198 && b === 51) || // 198.51.100/24 documentation