@zeroroot-ai/zerocool 0.9.1 → 0.9.3

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.
@@ -39,9 +39,9 @@
39
39
  * launcher names — GIBSON_CG_JWT, GIBSON_CALLBACK_ENDPOINT,
40
40
  * GIBSON_AGENT_TASK_B64, GIBSON_MISSION_ID, GIBSON_MISSION_RUN_ID,
41
41
  * GIBSON_AGENT_RUN_ID, GIBSON_MODEL, GIBSON_TRACE_ID, GIBSON_SPAN_ID — are
42
- * spelled in the SDK, against gibson `internal/engine/harness/sandboxed/agent.go`
43
- * (the `envAgent*` constants). This file spells none of them. A second spelling
44
- * here is how the two drift, which is the defect zerocool-plugins#7 records.
42
+ * spelled in the SDK, against the launcher's own constants. This file spells
43
+ * none of them. A second spelling here is how the two drift, which is the
44
+ * defect zerocool-plugins#7 records.
45
45
  *
46
46
  * FOUR VARIABLES ARE STILL READ HERE, and every one of them is a local option
47
47
  * of this host rather than part of the launcher contract. The launcher never
@@ -189,6 +189,8 @@ export interface DispatchDeps {
189
189
  watch?: WatchDeps;
190
190
  /** Fix seams: no GitLab, no graph and no checkout in a test. */
191
191
  fix?: FixDeps;
192
+ /** The component's environment, where the connector configuration lives. Defaults to `process.env`. */
193
+ env?: NodeJS.ProcessEnv;
192
194
  }
193
195
  /**
194
196
  * The fix task's injectable seams.
@@ -254,7 +256,8 @@ export declare function runSourceAnalysisDispatch(ctx: DispatchContext, deps?: D
254
256
  * `zerocool.task` selector, "watch"
255
257
  * `application` the Application this watch follows (required)
256
258
  * `gitlab.project` `group/project` on the instance (required)
257
- * `gitlab.url` instance base url; defaults to gitlab.com
259
+ * `gitlab.url` instance base url, https, host in ZEROCOOL_GITLAB_HOSTS;
260
+ * defaults to the one allowed host
258
261
  * `gitlab.ref` branch to watch; defaults to `main`
259
262
  * `gitlab.credential` tenant secret holding the project access token
260
263
  * `repository.url` clone url the Scan mission checks out (required)
@@ -287,12 +290,15 @@ export declare function runWatchDispatch(ctx: DispatchContext, deps?: DispatchDe
287
290
  * `zerocool.task` selector, "fix"
288
291
  * `application` the Application whose Findings are worked (required)
289
292
  * `gitlab.project` `group/project` on the instance (required)
290
- * `gitlab.url` instance base url; defaults to gitlab.com
293
+ * `gitlab.url` instance base url, https, host in ZEROCOOL_GITLAB_HOSTS;
294
+ * defaults to the one allowed host
291
295
  * `gitlab.ref` branch merge requests target; defaults to `main`
292
296
  * `gitlab.credential` tenant secret holding the project access token
293
297
  * `repository.commit` the commit the Scan ran against — what the status lands on
294
298
  * `pipeline.url` the pipeline page, linked from the commit status
295
299
  * `fix.max_merge_requests` cap on merge requests opened in one pass
300
+ * `fix.auto_merge` `true` arms merge-when-pipeline-succeeds on the merge
301
+ * requests this pass opens. Default off: a person merges.
296
302
  */
297
303
  export declare function runFixDispatch(ctx: DispatchContext, deps?: DispatchDeps): Promise<AgentOutcome>;
298
304
  /**
package/dist/dispatch.js CHANGED
@@ -41,9 +41,9 @@
41
41
  * launcher names — GIBSON_CG_JWT, GIBSON_CALLBACK_ENDPOINT,
42
42
  * GIBSON_AGENT_TASK_B64, GIBSON_MISSION_ID, GIBSON_MISSION_RUN_ID,
43
43
  * GIBSON_AGENT_RUN_ID, GIBSON_MODEL, GIBSON_TRACE_ID, GIBSON_SPAN_ID — are
44
- * spelled in the SDK, against gibson `internal/engine/harness/sandboxed/agent.go`
45
- * (the `envAgent*` constants). This file spells none of them. A second spelling
46
- * here is how the two drift, which is the defect zerocool-plugins#7 records.
44
+ * spelled in the SDK, against the launcher's own constants. This file spells
45
+ * none of them. A second spelling here is how the two drift, which is the
46
+ * defect zerocool-plugins#7 records.
47
47
  *
48
48
  * FOUR VARIABLES ARE STILL READ HERE, and every one of them is a local option
49
49
  * of this host rather than part of the launcher contract. The launcher never
@@ -65,7 +65,7 @@ import { observe, readSandboxDispatch, sandboxHarness, taskKnowledge, } from "@z
65
65
  import { join } from "node:path";
66
66
  import { taskFindingsBackend } from "./findings.js";
67
67
  import { harnessFindingSource, harnessFindingStatus, runFix, } from "./fix.js";
68
- import { gitlabRest, gitlabRestWriter } from "./gitlab.js";
68
+ import { allowedGitLabHosts, gitlabRest, gitlabRestWriter, resolveGitLabBaseUrl } from "./gitlab.js";
69
69
  import { runOpencode } from "./opencode-run.js";
70
70
  import { DEFAULT_REF, harnessScanLauncher, runWatchLoop, worldCheckpoints, } from "./watch.js";
71
71
  import { formatSourceAnalysis, harnessTriageModel, runSourceAnalysis, } from "./source-analysis.js";
@@ -293,7 +293,8 @@ export async function runSourceAnalysisDispatch(ctx, deps = {}) {
293
293
  * `zerocool.task` selector, "watch"
294
294
  * `application` the Application this watch follows (required)
295
295
  * `gitlab.project` `group/project` on the instance (required)
296
- * `gitlab.url` instance base url; defaults to gitlab.com
296
+ * `gitlab.url` instance base url, https, host in ZEROCOOL_GITLAB_HOSTS;
297
+ * defaults to the one allowed host
297
298
  * `gitlab.ref` branch to watch; defaults to `main`
298
299
  * `gitlab.credential` tenant secret holding the project access token
299
300
  * `repository.url` clone url the Scan mission checks out (required)
@@ -319,6 +320,9 @@ export async function runWatchDispatch(ctx, deps = {}) {
319
320
  throw new Error("watch: the mission node set no `gitlab.project` in the task context, so there is no " +
320
321
  "project to poll.");
321
322
  }
323
+ // The host the tenant's token is sent to is checked before the token is
324
+ // fetched, against the connector configuration and never the mission node.
325
+ const gitlabBaseUrl = w.gitlab ? undefined : resolveGitLabBaseUrl(ctx.taskContext["gitlab.url"], allowedGitLabHosts(deps.env ?? process.env));
322
326
  // Checked here, not at the first trigger. The checked-in Scan mission requires
323
327
  // all seven of its parameters — CUE refuses an incomplete render rather than
324
328
  // substituting an empty string — so a watch missing one is already broken. It
@@ -345,7 +349,7 @@ export async function runWatchDispatch(ctx, deps = {}) {
345
349
  gitlabRest({
346
350
  projectPath,
347
351
  token: await (w.credential ?? harnessCredential(harness))(credentialName),
348
- ...(ctx.taskContext["gitlab.url"] ? { baseUrl: ctx.taskContext["gitlab.url"] } : {}),
352
+ baseUrl: gitlabBaseUrl,
349
353
  });
350
354
  const checkpoints = w.checkpoints ?? worldCheckpoints(harness, application);
351
355
  const scans = w.scans ??
@@ -410,12 +414,15 @@ export async function runWatchDispatch(ctx, deps = {}) {
410
414
  * `zerocool.task` selector, "fix"
411
415
  * `application` the Application whose Findings are worked (required)
412
416
  * `gitlab.project` `group/project` on the instance (required)
413
- * `gitlab.url` instance base url; defaults to gitlab.com
417
+ * `gitlab.url` instance base url, https, host in ZEROCOOL_GITLAB_HOSTS;
418
+ * defaults to the one allowed host
414
419
  * `gitlab.ref` branch merge requests target; defaults to `main`
415
420
  * `gitlab.credential` tenant secret holding the project access token
416
421
  * `repository.commit` the commit the Scan ran against — what the status lands on
417
422
  * `pipeline.url` the pipeline page, linked from the commit status
418
423
  * `fix.max_merge_requests` cap on merge requests opened in one pass
424
+ * `fix.auto_merge` `true` arms merge-when-pipeline-succeeds on the merge
425
+ * requests this pass opens. Default off: a person merges.
419
426
  */
420
427
  export async function runFixDispatch(ctx, deps = {}) {
421
428
  const f = deps.fix ?? {};
@@ -433,6 +440,8 @@ export async function runFixDispatch(ctx, deps = {}) {
433
440
  throw new Error("fix: the mission node set no `gitlab.project` in the task context, so there is no " +
434
441
  "project to open a merge request against.");
435
442
  }
443
+ // Same check as the watch: the host is the connector's choice, not the node's.
444
+ const gitlabBaseUrl = f.gitlab ? undefined : resolveGitLabBaseUrl(ctx.taskContext["gitlab.url"], allowedGitLabHosts(deps.env ?? process.env));
436
445
  // No default planner and no default workspace: see FixDeps. A Fix that
437
446
  // cannot change anything or cannot test what it changed must not report a
438
447
  // clean pass over a backlog it never touched.
@@ -450,7 +459,7 @@ export async function runFixDispatch(ctx, deps = {}) {
450
459
  gitlabRestWriter({
451
460
  projectPath,
452
461
  token: await (f.credential ?? harnessCredential(harness))(credentialName),
453
- ...(ctx.taskContext["gitlab.url"] ? { baseUrl: ctx.taskContext["gitlab.url"] } : {}),
462
+ baseUrl: gitlabBaseUrl,
454
463
  });
455
464
  const findings = f.findings ?? harnessFindingSource(taskKnowledge(harness));
456
465
  const status = f.status ??
@@ -460,6 +469,7 @@ export async function runFixDispatch(ctx, deps = {}) {
460
469
  application,
461
470
  commit,
462
471
  targetRef,
472
+ autoMerge: ctx.taskContext["fix.auto_merge"] === "true",
463
473
  findings,
464
474
  status,
465
475
  planner: f.planner,
package/dist/fix.d.ts CHANGED
@@ -4,10 +4,17 @@ import type { FileChange, GitLabWriter, MergeRequest } from "./gitlab.js";
4
4
  *
5
5
  * After a Scan mission lands, the agent works the Application's open Findings
6
6
  * in priority order. For each one it can act on it rewrites the repository,
7
- * runs the repository's own tests, and only then opens a merge request set to
8
- * merge itself when the pipeline succeeds. It records the scan verdict on the
9
- * commit as `gibson/scan` and posts one note saying what it fixed, what it did
10
- * not, and why.
7
+ * runs the repository's own tests, and only then opens a merge request for a
8
+ * person to review. It records the scan verdict on the commit as
9
+ * `gibson/scan` and posts one note saying what it fixed, what it did not, and
10
+ * why.
11
+ *
12
+ * A PERSON MERGES, UNLESS THE APPLICATION OPTED IN. The planner is steered by
13
+ * the Finding and by the repository content it triaged, which is the
14
+ * untrusted input of this whole task. So a merge request is never armed to
15
+ * merge itself by default. `autoMerge` arms `merge_when_pipeline_succeeds`
16
+ * on the requests one pass opens, and it is set only from the Application's
17
+ * explicit `fix.auto_merge` opt-in in the task context.
11
18
  *
12
19
  * THE ORDER IS TESTS, THEN PUSH. A fix that breaks the build is worse than the
13
20
  * Finding: it stops every other fix behind it. So the change is applied to a
@@ -176,6 +183,8 @@ export interface RunFixOptions {
176
183
  pipelineUrl?: string;
177
184
  /** Cap on merge requests opened in one pass, so a backlog cannot flood review. */
178
185
  maxMergeRequests?: number;
186
+ /** Arm auto-merge on the merge requests this pass opens. Default false. See the module note. */
187
+ autoMerge?: boolean;
179
188
  onEvent?: (e: FixEvent) => void;
180
189
  }
181
190
  /** Default ceiling on merge requests opened by one pass. */
@@ -203,8 +212,8 @@ export declare function fixBranch(finding: FixableFinding): string;
203
212
  export declare function countsBySeverity(findings: FixableFinding[]): Record<string, number>;
204
213
  /** The one-line verdict GitLab shows beside the commit. */
205
214
  export declare function statusDescription(summary: FixSummary): string;
206
- /** The merge request description. Names the Finding it closes and how. */
207
- export declare function mergeRequestBody(finding: FixableFinding, plan: FixPlan): string;
215
+ /** The merge request description. Names the Finding it closes, how, and who merges. */
216
+ export declare function mergeRequestBody(finding: FixableFinding, plan: FixPlan, autoMerge?: boolean): string;
208
217
  /** The note posted on each merge request: what this pass did, in full. */
209
218
  export declare function noteBody(summary: FixSummary): string;
210
219
  /**
package/dist/fix.js CHANGED
@@ -5,10 +5,17 @@
5
5
  *
6
6
  * After a Scan mission lands, the agent works the Application's open Findings
7
7
  * in priority order. For each one it can act on it rewrites the repository,
8
- * runs the repository's own tests, and only then opens a merge request set to
9
- * merge itself when the pipeline succeeds. It records the scan verdict on the
10
- * commit as `gibson/scan` and posts one note saying what it fixed, what it did
11
- * not, and why.
8
+ * runs the repository's own tests, and only then opens a merge request for a
9
+ * person to review. It records the scan verdict on the commit as
10
+ * `gibson/scan` and posts one note saying what it fixed, what it did not, and
11
+ * why.
12
+ *
13
+ * A PERSON MERGES, UNLESS THE APPLICATION OPTED IN. The planner is steered by
14
+ * the Finding and by the repository content it triaged, which is the
15
+ * untrusted input of this whole task. So a merge request is never armed to
16
+ * merge itself by default. `autoMerge` arms `merge_when_pipeline_succeeds`
17
+ * on the requests one pass opens, and it is set only from the Application's
18
+ * explicit `fix.auto_merge` opt-in in the task context.
12
19
  *
13
20
  * THE ORDER IS TESTS, THEN PUSH. A fix that breaks the build is worse than the
14
21
  * Finding: it stops every other fix behind it. So the change is applied to a
@@ -112,7 +119,8 @@ export async function runFix(opts) {
112
119
  try {
113
120
  const branch = fixBranch(finding);
114
121
  await opts.gitlab.commitToBranch(branch, opts.targetRef, `${plan.summary}\n\n${plan.detail}`, changes);
115
- const mr = await opts.gitlab.openMergeRequest(branch, opts.targetRef, plan.summary, mergeRequestBody(finding, plan));
122
+ const autoMerge = opts.autoMerge === true;
123
+ const mr = await opts.gitlab.openMergeRequest(branch, opts.targetRef, plan.summary, mergeRequestBody(finding, plan, autoMerge), { autoMerge });
116
124
  emit({ type: "fix.mr", findingId: finding.id, iid: mr.iid, webUrl: mr.webUrl });
117
125
  opened++;
118
126
  await opts.status.markFixing(finding.id, mr);
@@ -193,8 +201,8 @@ export function statusDescription(summary) {
193
201
  return "no open findings";
194
202
  return `${fixing} merge request(s) opened, ${merged} merged, ${unfixed} left for a human`;
195
203
  }
196
- /** The merge request description. Names the Finding it closes and how. */
197
- export function mergeRequestBody(finding, plan) {
204
+ /** The merge request description. Names the Finding it closes, how, and who merges. */
205
+ export function mergeRequestBody(finding, plan, autoMerge = false) {
198
206
  return [
199
207
  plan.detail,
200
208
  "",
@@ -203,9 +211,10 @@ export function mergeRequestBody(finding, plan) {
203
211
  `Where: ${finding.placeLabel} \`${finding.placeKey}\``,
204
212
  `Severity: ${finding.severity}${finding.priority ? ` · priority ${finding.priority}` : ""}`,
205
213
  "",
206
- "Opened by the Gibson always-on agent. It merges itself when the pipeline",
207
- "succeeds. The finding is marked fixed on merge and verified only by a",
208
- "later rescan.",
214
+ autoMerge
215
+ ? "Opened by the Gibson always-on agent. This Application opted in to auto-merge, so it merges itself when the pipeline succeeds."
216
+ : "Opened by the Gibson always-on agent for review. A person merges it.",
217
+ "The finding is marked fixed on merge and verified only by a later rescan.",
209
218
  ].join("\n");
210
219
  }
211
220
  /** The note posted on each merge request: what this pass did, in full. */
package/dist/gitlab.d.ts CHANGED
@@ -24,6 +24,27 @@
24
24
  export declare const TRIGGER_STATUS = "success";
25
25
  /** GitLab's public host, when a tenant names no other instance. */
26
26
  export declare const GITLAB_DEFAULT_URL = "https://gitlab.com";
27
+ /**
28
+ * The connector configuration that says which GitLab hosts a tenant's token
29
+ * may be sent to: a comma-separated list of host names in the component's
30
+ * environment, set by the operator, never by a mission node. Default:
31
+ * `gitlab.com`.
32
+ */
33
+ export declare const GITLAB_HOSTS_ENV = "ZEROCOOL_GITLAB_HOSTS";
34
+ /** Read the allow list from the environment. */
35
+ export declare function allowedGitLabHosts(env: NodeJS.ProcessEnv): string[];
36
+ /**
37
+ * The instance base URL a dispatch may use, checked against the allow list.
38
+ *
39
+ * `gitlab.url` comes from the mission node's task context, and the token it
40
+ * would be sent with is the tenant's project access token. So the value is
41
+ * not trusted on its own: the scheme must be https, the host must be one the
42
+ * connector configuration names, and the URL carries no credential, query or
43
+ * fragment. An empty `gitlab.url` means the one allowed host, and is refused
44
+ * when the allow list names several, because guessing would pick a host the
45
+ * mission author did not.
46
+ */
47
+ export declare function resolveGitLabBaseUrl(requested: string | undefined, allowedHosts: string[]): string;
27
48
  /** One pipeline, reduced to what a trigger decision and a Scan mission need. */
28
49
  export interface Pipeline {
29
50
  /** GitLab's own pipeline id. Monotonic per project, and the checkpoint key. */
@@ -52,7 +73,7 @@ export interface GitLabRestOptions {
52
73
  projectPath: string;
53
74
  /** The project access token, resolved from the tenant secret store. */
54
75
  token: string;
55
- /** The instance. Defaults to {@link GITLAB_DEFAULT_URL}. */
76
+ /** The instance, https only. Defaults to {@link GITLAB_DEFAULT_URL}. Resolve it with {@link resolveGitLabBaseUrl}. */
56
77
  baseUrl?: string;
57
78
  /** Injectable transport. Defaults to the global `fetch`. */
58
79
  fetch?: typeof globalThis.fetch;
@@ -85,6 +106,11 @@ export interface MergeRequest {
85
106
  /** GitLab's own state: `opened`, `merged`, `closed`, `locked`. */
86
107
  state: string;
87
108
  }
109
+ /** What {@link GitLabWriter.openMergeRequest} may be told beyond the request itself. */
110
+ export interface OpenMergeRequestOptions {
111
+ /** Arm `merge_when_pipeline_succeeds`. Default false: a person merges. */
112
+ autoMerge?: boolean;
113
+ }
88
114
  /** One file the Fix rewrote, as the commit API wants it. */
89
115
  export interface FileChange {
90
116
  path: string;
@@ -98,8 +124,12 @@ export interface GitLabWriter {
98
124
  * commit is not a state this can leave behind.
99
125
  */
100
126
  commitToBranch(branch: string, startRef: string, message: string, changes: FileChange[]): Promise<string>;
101
- /** Open a merge request set to merge itself when its pipeline succeeds. */
102
- openMergeRequest(branch: string, targetRef: string, title: string, description: string): Promise<MergeRequest>;
127
+ /**
128
+ * Open a merge request for a person to review. Auto-merge on a green
129
+ * pipeline is armed only when `opts.autoMerge` is true, which the Fix sets
130
+ * from the Application's explicit opt-in and nothing else.
131
+ */
132
+ openMergeRequest(branch: string, targetRef: string, title: string, description: string, opts?: OpenMergeRequestOptions): Promise<MergeRequest>;
103
133
  /** The current state of a merge request, so a later pass can see it merged. */
104
134
  mergeRequestState(iid: number): Promise<MergeRequest>;
105
135
  /** Post a note on a merge request. */
package/dist/gitlab.js CHANGED
@@ -26,6 +26,69 @@
26
26
  export const TRIGGER_STATUS = "success";
27
27
  /** GitLab's public host, when a tenant names no other instance. */
28
28
  export const GITLAB_DEFAULT_URL = "https://gitlab.com";
29
+ /**
30
+ * The connector configuration that says which GitLab hosts a tenant's token
31
+ * may be sent to: a comma-separated list of host names in the component's
32
+ * environment, set by the operator, never by a mission node. Default:
33
+ * `gitlab.com`.
34
+ */
35
+ export const GITLAB_HOSTS_ENV = "ZEROCOOL_GITLAB_HOSTS";
36
+ /** Read the allow list from the environment. */
37
+ export function allowedGitLabHosts(env) {
38
+ const raw = env[GITLAB_HOSTS_ENV] ?? "";
39
+ const hosts = raw
40
+ .split(",")
41
+ .map((h) => h.trim().toLowerCase())
42
+ .filter((h) => h.length > 0);
43
+ return hosts.length > 0 ? hosts : ["gitlab.com"];
44
+ }
45
+ /**
46
+ * The instance base URL a dispatch may use, checked against the allow list.
47
+ *
48
+ * `gitlab.url` comes from the mission node's task context, and the token it
49
+ * would be sent with is the tenant's project access token. So the value is
50
+ * not trusted on its own: the scheme must be https, the host must be one the
51
+ * connector configuration names, and the URL carries no credential, query or
52
+ * fragment. An empty `gitlab.url` means the one allowed host, and is refused
53
+ * when the allow list names several, because guessing would pick a host the
54
+ * mission author did not.
55
+ */
56
+ export function resolveGitLabBaseUrl(requested, allowedHosts) {
57
+ const allowed = allowedHosts.map((h) => h.toLowerCase());
58
+ if (allowed.length === 0)
59
+ throw new Error(`${GITLAB_HOSTS_ENV} names no GitLab host, so no token can be sent anywhere`);
60
+ const wanted = (requested ?? "").trim();
61
+ if (!wanted) {
62
+ if (allowed.length === 1)
63
+ return `https://${allowed[0]}`;
64
+ throw new Error(`gitlab.url is not set and ${GITLAB_HOSTS_ENV} allows several hosts (${allowed.join(", ")}); the task context must name one`);
65
+ }
66
+ let url;
67
+ try {
68
+ url = new URL(wanted);
69
+ }
70
+ catch {
71
+ throw new Error(`gitlab.url ${JSON.stringify(wanted)} is not a URL`);
72
+ }
73
+ if (url.protocol !== "https:") {
74
+ throw new Error(`gitlab.url ${JSON.stringify(wanted)} is not https; the tenant's token travels only over https`);
75
+ }
76
+ if (url.username || url.password || url.search || url.hash) {
77
+ throw new Error(`gitlab.url ${JSON.stringify(wanted)} carries a credential, a query or a fragment; only scheme, host and path are read`);
78
+ }
79
+ const host = url.hostname.toLowerCase();
80
+ if (!allowed.includes(host)) {
81
+ throw new Error(`gitlab.url host ${JSON.stringify(host)} is not in ${GITLAB_HOSTS_ENV} (${allowed.join(", ")}); the connector configuration names the hosts a token may be sent to`);
82
+ }
83
+ return `${url.origin}${url.pathname}`.replace(/\/+$/, "");
84
+ }
85
+ /** Both clients take the base through here, so a caller cannot hand them a plaintext host. */
86
+ function httpsBase(baseUrl) {
87
+ const base = (baseUrl || GITLAB_DEFAULT_URL).replace(/\/+$/, "");
88
+ if (!base.startsWith("https://"))
89
+ throw new Error(`GitLab base url ${JSON.stringify(base)} is not https`);
90
+ return base;
91
+ }
29
92
  /**
30
93
  * Read one pipeline out of GitLab's JSON. A row without an id or a sha is
31
94
  * dropped rather than guessed at: a Scan mission keyed on a missing commit
@@ -56,7 +119,7 @@ export function parsePipeline(raw) {
56
119
  * request.
57
120
  */
58
121
  export function gitlabRest(opts) {
59
- const base = (opts.baseUrl || GITLAB_DEFAULT_URL).replace(/\/+$/, "");
122
+ const base = httpsBase(opts.baseUrl);
60
123
  const project = encodeURIComponent(opts.projectPath);
61
124
  const doFetch = opts.fetch ?? globalThis.fetch;
62
125
  return {
@@ -93,7 +156,7 @@ export const SCAN_STATUS_CONTEXT = "gibson/scan";
93
156
  * code — never a header.
94
157
  */
95
158
  export function gitlabRestWriter(opts) {
96
- const base = (opts.baseUrl || GITLAB_DEFAULT_URL).replace(/\/+$/, "");
159
+ const base = httpsBase(opts.baseUrl);
97
160
  const project = encodeURIComponent(opts.projectPath);
98
161
  const doFetch = opts.fetch ?? globalThis.fetch;
99
162
  const api = `${base}/api/v4/projects/${project}`;
@@ -141,7 +204,7 @@ export function gitlabRestWriter(opts) {
141
204
  }
142
205
  return sha;
143
206
  },
144
- async openMergeRequest(branch, targetRef, title, description) {
207
+ async openMergeRequest(branch, targetRef, title, description, opts) {
145
208
  const created = asMergeRequest(await call("POST", "/merge_requests", {
146
209
  source_branch: branch,
147
210
  target_branch: targetRef,
@@ -149,15 +212,21 @@ export function gitlabRestWriter(opts) {
149
212
  description,
150
213
  remove_source_branch: true,
151
214
  }), branch);
152
- // Auto-merge is a separate call, and a refusal is not fatal: the merge
153
- // request is open and correct either way, and a human can merge it. A
154
- // thrown error here would lose a good merge request over a race with the
155
- // pipeline that has not started yet.
156
- try {
157
- await call("PUT", `/merge_requests/${created.iid}/merge`, { merge_when_pipeline_succeeds: true });
158
- }
159
- catch {
160
- // Reported by the caller through the note, not swallowed silently.
215
+ // The merge request is open for a person. Machine-written code lands on
216
+ // the customer's branch by itself only where the Application opted in:
217
+ // anything that steers the planner, including the repository content it
218
+ // triaged, would otherwise land as soon as CI went green.
219
+ if (opts?.autoMerge === true) {
220
+ // Auto-merge is a separate call, and a refusal is not fatal: the merge
221
+ // request is open and correct either way, and a human can merge it. A
222
+ // thrown error here would lose a good merge request over a race with
223
+ // the pipeline that has not started yet.
224
+ try {
225
+ await call("PUT", `/merge_requests/${created.iid}/merge`, { merge_when_pipeline_succeeds: true });
226
+ }
227
+ catch {
228
+ // Reported by the caller through the note, not swallowed silently.
229
+ }
161
230
  }
162
231
  return created;
163
232
  },
@@ -7,11 +7,19 @@ import { decodeToolInput, type GibsonClients, type ToolInvocation } from "@zeror
7
7
  * dispatch path — mission node → work queue → this process → result → mission
8
8
  * completion — without the result depending on anything about the target.
9
9
  *
10
- * SAFETY. This is a fetch, not a scanner: one request, no redirect chasing
11
- * beyond what fetch does by default, no body execution, and a hard timeout. The
12
- * body is measured, never returned — a probe that echoes arbitrary remote
13
- * content back into a mission's knowledge graph is an injection surface, and
14
- * the size is what the caller actually asked for.
10
+ * SAFETY. This is a fetch, not a scanner: one request, no redirect followed,
11
+ * no body execution, and a hard timeout. The body is measured, never returned:
12
+ * a probe that echoes arbitrary remote content back into a mission's knowledge
13
+ * graph is an injection surface, and the size is what the caller asked for.
14
+ *
15
+ * NO INTERNAL TARGET. The probe runs inside the agent container, next to the
16
+ * instance metadata service, the Kubernetes API and every internal service.
17
+ * A dispatch names the URL, so the host is resolved first and every address
18
+ * it resolves to must be a public unicast address. Loopback, link-local,
19
+ * RFC 1918, carrier-grade NAT, IPv6 unique-local and link-local, the
20
+ * unspecified address and multicast are refused before any connection. A
21
+ * redirect is reported as its status and never followed, so a public host
22
+ * cannot bounce the probe inward.
15
23
  */
16
24
  export interface ProbeResult {
17
25
  url: string;
@@ -23,8 +31,29 @@ export interface ProbeResult {
23
31
  elapsedMs: number;
24
32
  }
25
33
  export declare const HTTP_PROBE_TIMEOUT_MS = 15000;
34
+ /** One resolved address, as `dns.lookup` with `all: true` returns it. */
35
+ export interface ResolvedAddress {
36
+ address: string;
37
+ family: number;
38
+ }
39
+ /** The seams a test replaces: name resolution and the transport. */
40
+ export interface ProbeDeps {
41
+ lookup?: (host: string) => Promise<ResolvedAddress[]>;
42
+ fetch?: typeof globalThis.fetch;
43
+ }
44
+ /**
45
+ * True when the address is not a public unicast address. Written as the
46
+ * ranges the probe refuses, so each one is named and testable.
47
+ */
48
+ export declare function isInternalAddress(address: string): boolean;
49
+ /**
50
+ * Resolve the host and refuse it when any address it maps to is internal. A
51
+ * host that resolves to nothing is refused too: the probe has nothing to
52
+ * check, so it does not connect.
53
+ */
54
+ export declare function assertPublicHost(hostname: string, lookup: (host: string) => Promise<ResolvedAddress[]>): Promise<void>;
26
55
  /** Perform the probe. Exported separately from the handler so it is testable without a work item. */
27
- export declare function probe(url: string, timeoutMs?: number): Promise<ProbeResult>;
56
+ export declare function probe(url: string, timeoutMs?: number, deps?: ProbeDeps): Promise<ProbeResult>;
28
57
  /**
29
58
  * Work handler: takes a claimed tool invocation and returns the probe result.
30
59
  *
@@ -1,18 +1,122 @@
1
1
  // SPDX-License-Identifier: Elastic-2.0
2
2
  // Copyright 2026 Zero Root AI
3
+ import { lookup as dnsLookup } from "node:dns/promises";
4
+ import { isIP } from "node:net";
3
5
  import { decodeToolInput } from "@zeroroot-ai/sdk";
4
6
  export const HTTP_PROBE_TIMEOUT_MS = 15_000;
7
+ const defaultLookup = async (host) => dnsLookup(host, { all: true });
8
+ /** Parse dotted IPv4 into its four octets, or undefined. */
9
+ function ipv4Octets(address) {
10
+ if (isIP(address) !== 4)
11
+ return undefined;
12
+ return address.split(".").map((o) => Number(o));
13
+ }
14
+ /**
15
+ * True when the address is not a public unicast address. Written as the
16
+ * ranges the probe refuses, so each one is named and testable.
17
+ */
18
+ export function isInternalAddress(address) {
19
+ const v4 = ipv4Octets(address);
20
+ if (v4) {
21
+ const [a, b] = v4;
22
+ if (a === 0)
23
+ return true; // 0.0.0.0/8, "this network"
24
+ if (a === 10)
25
+ return true; // RFC 1918
26
+ if (a === 127)
27
+ return true; // loopback
28
+ if (a === 100 && b >= 64 && b <= 127)
29
+ return true; // 100.64.0.0/10, carrier-grade NAT
30
+ if (a === 169 && b === 254)
31
+ return true; // link-local, and the cloud metadata service
32
+ if (a === 172 && b >= 16 && b <= 31)
33
+ return true; // RFC 1918
34
+ if (a === 192 && b === 168)
35
+ return true; // RFC 1918
36
+ if (a >= 224)
37
+ return true; // multicast and reserved
38
+ return false;
39
+ }
40
+ if (isIP(address) !== 6)
41
+ return true; // not an address at all: refuse
42
+ const groups = ipv6Groups(address);
43
+ if (!groups)
44
+ return true;
45
+ const [first] = groups;
46
+ const leadingZero = groups.slice(0, 5).every((g) => g === 0);
47
+ // ::ffff:a.b.c.d (IPv4-mapped) and ::a.b.c.d (IPv4-compatible, deprecated)
48
+ // are judged as their IPv4 part. The URL parser writes the mapped form in
49
+ // hex, so the last two groups are read back as four octets.
50
+ if (leadingZero && (groups[5] === 0xffff || (groups[5] === 0 && (groups[6] !== 0 || groups[7] > 1)))) {
51
+ const hi = groups[6];
52
+ const lo = groups[7];
53
+ return isInternalAddress(`${hi >> 8}.${hi & 0xff}.${lo >> 8}.${lo & 0xff}`);
54
+ }
55
+ if (groups.every((g) => g === 0))
56
+ return true; // ::, unspecified
57
+ if (leadingZero && groups[5] === 0 && groups[6] === 0 && groups[7] === 1)
58
+ return true; // ::1, loopback
59
+ if ((first & 0xfe00) === 0xfc00)
60
+ return true; // fc00::/7, unique local
61
+ if ((first & 0xffc0) === 0xfe80)
62
+ return true; // fe80::/10, link-local
63
+ if ((first & 0xff00) === 0xff00)
64
+ return true; // ff00::/8, multicast
65
+ return false;
66
+ }
67
+ /** Expand an IPv6 address into its eight 16-bit groups, or undefined. */
68
+ function ipv6Groups(address) {
69
+ let text = address.toLowerCase();
70
+ const zone = text.indexOf("%");
71
+ if (zone >= 0)
72
+ text = text.slice(0, zone);
73
+ // A trailing dotted quad becomes two hex groups.
74
+ const dotted = /(\d+\.\d+\.\d+\.\d+)$/.exec(text);
75
+ if (dotted) {
76
+ const [a, b, c, d] = dotted[1].split(".").map(Number);
77
+ text = `${text.slice(0, -dotted[1].length)}${((a << 8) | b).toString(16)}:${((c << 8) | d).toString(16)}`;
78
+ }
79
+ const halves = text.split("::");
80
+ if (halves.length > 2)
81
+ return undefined;
82
+ const head = halves[0] ? halves[0].split(":") : [];
83
+ const tail = halves.length === 2 && halves[1] ? halves[1].split(":") : [];
84
+ const fill = 8 - head.length - tail.length;
85
+ if (fill < 0 || (halves.length === 1 && fill !== 0))
86
+ return undefined;
87
+ const parts = [...head, ...Array(fill).fill("0"), ...tail];
88
+ const groups = parts.map((p) => Number.parseInt(p || "0", 16));
89
+ return groups.some((g) => Number.isNaN(g) || g < 0 || g > 0xffff) ? undefined : groups;
90
+ }
91
+ /**
92
+ * Resolve the host and refuse it when any address it maps to is internal. A
93
+ * host that resolves to nothing is refused too: the probe has nothing to
94
+ * check, so it does not connect.
95
+ */
96
+ export async function assertPublicHost(hostname, lookup) {
97
+ const bare = hostname.startsWith("[") && hostname.endsWith("]") ? hostname.slice(1, -1) : hostname;
98
+ const addresses = isIP(bare) ? [{ address: bare, family: isIP(bare) }] : await lookup(bare);
99
+ if (addresses.length === 0)
100
+ throw new Error(`http_probe refuses ${hostname}: it resolves to no address`);
101
+ for (const { address } of addresses) {
102
+ if (isInternalAddress(address)) {
103
+ throw new Error(`http_probe refuses ${hostname}: it resolves to ${address}, which is not a public address`);
104
+ }
105
+ }
106
+ }
5
107
  /** Perform the probe. Exported separately from the handler so it is testable without a work item. */
6
- export async function probe(url, timeoutMs = HTTP_PROBE_TIMEOUT_MS) {
108
+ export async function probe(url, timeoutMs = HTTP_PROBE_TIMEOUT_MS, deps = {}) {
7
109
  const parsed = new URL(url);
8
110
  if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
9
111
  throw new Error(`unsupported scheme ${parsed.protocol} (http and https only)`);
10
112
  }
113
+ await assertPublicHost(parsed.hostname, deps.lookup ?? defaultLookup);
114
+ const doFetch = deps.fetch ?? globalThis.fetch;
11
115
  const started = Date.now();
12
116
  const ctl = new AbortController();
13
117
  const timer = setTimeout(() => ctl.abort(), timeoutMs);
14
118
  try {
15
- const res = await fetch(parsed.toString(), { method: "GET", signal: ctl.signal });
119
+ const res = await doFetch(parsed.toString(), { method: "GET", redirect: "manual", signal: ctl.signal });
16
120
  const body = await res.arrayBuffer();
17
121
  return {
18
122
  url: parsed.toString(),
package/dist/index.js CHANGED
@@ -54,8 +54,7 @@ export const GibsonPlugin = async () => {
54
54
  // first Capability Grant handshake and the host key it registers is persisted
55
55
  // at `hostKeyPath`. Every later start re-registers by proving possession of
56
56
  // that host key — the daemon routes on credential type: `host+jwt` is
57
- // re-registration, anything else is first registration
58
- // (gibson `internal/server/daemon/capabilitygrant_register.go:134-155`).
57
+ // re-registration, anything else is first registration.
59
58
  //
60
59
  // So the bootstrap token is passed ONLY when no host key exists yet. Replaying
61
60
  // a one-time token on every start would be rejected, and it would mean asking
@@ -6,10 +6,15 @@
6
6
  * the `config` hook, so a user configures nothing.
7
7
  *
8
8
  * The command is the published package, not a path inside this plugin: the
9
- * server is shared by every host and released on the SDK train, so pinning it
10
- * to whatever this plugin happens to bundle would freeze the tool set.
9
+ * server is shared by every host and released on the SDK train.
10
+ *
11
+ * The version is exact. `npx --yes` runs whatever the registry serves with no
12
+ * prompt, so a floating tag would run one compromised publish on every
13
+ * machine at session start. The pin is the one in tools/hosts/package.json,
14
+ * which Dependabot bumps. `pins.test.ts` in claude-gibson fails when the two
15
+ * differ or when any install path floats again.
11
16
  */
12
- export declare const GIBSON_MCP_PACKAGE = "@zeroroot-ai/gibson-mcp@latest";
17
+ export declare const GIBSON_MCP_PACKAGE = "@zeroroot-ai/gibson-mcp@0.2.1";
13
18
  export interface OpencodeMcpLocal {
14
19
  type: "local";
15
20
  command: string[];
@@ -8,10 +8,15 @@
8
8
  * the `config` hook, so a user configures nothing.
9
9
  *
10
10
  * The command is the published package, not a path inside this plugin: the
11
- * server is shared by every host and released on the SDK train, so pinning it
12
- * to whatever this plugin happens to bundle would freeze the tool set.
11
+ * server is shared by every host and released on the SDK train.
12
+ *
13
+ * The version is exact. `npx --yes` runs whatever the registry serves with no
14
+ * prompt, so a floating tag would run one compromised publish on every
15
+ * machine at session start. The pin is the one in tools/hosts/package.json,
16
+ * which Dependabot bumps. `pins.test.ts` in claude-gibson fails when the two
17
+ * differ or when any install path floats again.
13
18
  */
14
- export const GIBSON_MCP_PACKAGE = "@zeroroot-ai/gibson-mcp@latest";
19
+ export const GIBSON_MCP_PACKAGE = "@zeroroot-ai/gibson-mcp@0.2.1";
15
20
  /** The `mcp.gibson` entry. `environment` carries nothing secret. */
16
21
  export function gibsonMcpServer(env = process.env) {
17
22
  const environment = {};
@@ -52,7 +52,7 @@ export interface OpencodeRunOptions {
52
52
  sessionId?: string;
53
53
  /** Hard deadline. The child is killed when it elapses. */
54
54
  timeoutMs?: number;
55
- /** Extra environment for the child, merged over the parent's. */
55
+ /** Extra environment for the child, merged over the allow-listed parent environment. */
56
56
  env?: NodeJS.ProcessEnv;
57
57
  /** Binary to run. Overridable for tests and for a pinned install. */
58
58
  bin?: string;
@@ -78,6 +78,13 @@ export declare function createLineStreamer(onLine: (line: string) => void): {
78
78
  push: (chunk: string) => void;
79
79
  flush: () => void;
80
80
  };
81
+ /**
82
+ * The environment the opencode child gets: the allow list above from `parent`,
83
+ * then `extra` on top. `extra` is the per-dispatch grant from
84
+ * `dispatchChildEnv`, which is the only way a `GIBSON_CALLBACK_*` name reaches
85
+ * the child.
86
+ */
87
+ export declare function opencodeChildEnv(parent: NodeJS.ProcessEnv, extra?: NodeJS.ProcessEnv): NodeJS.ProcessEnv;
81
88
  /** Build the argv for a headless run. Exported so a test can assert it. */
82
89
  export declare function opencodeArgs(opts: OpencodeRunOptions): string[];
83
90
  /**
@@ -80,6 +80,81 @@ export function createLineStreamer(onLine) {
80
80
  },
81
81
  };
82
82
  }
83
+ /**
84
+ * Environment names and prefixes the opencode child may see. Everything else
85
+ * is dropped, so the launcher's component grant (`GIBSON_CG_JWT`), the
86
+ * one-time enrollment token (`GIBSON_BOOTSTRAP_TOKEN`) and the launch task
87
+ * (`GIBSON_AGENT_TASK_B64`) never reach a process that runs with `--auto`
88
+ * and has a shell. `knowledge-source.ts` states the child contract: it reads
89
+ * `GIBSON_CALLBACK_ENDPOINT` and `GIBSON_CALLBACK_TOKEN`, which the caller
90
+ * passes in `opts.env`, and nothing of the launch environment.
91
+ *
92
+ * `GIBSON_` names are listed one by one and never by prefix: the grant and
93
+ * the token share that prefix with the addressing the child plugin reads.
94
+ * The provider credential passes through because opencode itself reads it
95
+ * for a model that is not served through the Gibson shim, the same posture
96
+ * `claude-member/src/env.ts` takes for Claude Code.
97
+ */
98
+ const CHILD_ENV_ALLOW_EXACT = [
99
+ "PATH",
100
+ "HOME",
101
+ "USER",
102
+ "SHELL",
103
+ "TZ",
104
+ "TERM",
105
+ "TMPDIR",
106
+ "NO_PROXY",
107
+ "no_proxy",
108
+ "HTTP_PROXY",
109
+ "http_proxy",
110
+ "HTTPS_PROXY",
111
+ "https_proxy",
112
+ "CLOUD_ML_REGION",
113
+ // Addressing the child plugin reads (index.ts, mcp-config.ts). No secret.
114
+ "GIBSON_PLATFORM_URL",
115
+ "GIBSON_DAEMON_URL",
116
+ "GIBSON_HOST_KEY_PATH",
117
+ "GIBSON_AGENT_MODE",
118
+ "GIBSON_SHIM_PORT",
119
+ "GIBSON_TARGET_ID",
120
+ "GIBSON_CA_CERT",
121
+ "GIBSON_OPENCODE_SESSION_ID",
122
+ ];
123
+ const CHILD_ENV_ALLOW_PREFIX = [
124
+ "LANG",
125
+ "LC_",
126
+ "XDG_",
127
+ "NODE_",
128
+ "SSL_CERT_",
129
+ "OPENCODE_",
130
+ "ZEROCOOL_",
131
+ "ANTHROPIC_",
132
+ "OPENAI_",
133
+ "AWS_",
134
+ "GOOGLE_",
135
+ "AZURE_",
136
+ ];
137
+ /**
138
+ * The environment the opencode child gets: the allow list above from `parent`,
139
+ * then `extra` on top. `extra` is the per-dispatch grant from
140
+ * `dispatchChildEnv`, which is the only way a `GIBSON_CALLBACK_*` name reaches
141
+ * the child.
142
+ */
143
+ export function opencodeChildEnv(parent, extra = {}) {
144
+ const out = {};
145
+ for (const [k, v] of Object.entries(parent)) {
146
+ if (v === undefined)
147
+ continue;
148
+ if (!CHILD_ENV_ALLOW_EXACT.includes(k) && !CHILD_ENV_ALLOW_PREFIX.some((p) => k.startsWith(p)))
149
+ continue;
150
+ out[k] = v;
151
+ }
152
+ for (const [k, v] of Object.entries(extra)) {
153
+ if (v !== undefined)
154
+ out[k] = v;
155
+ }
156
+ return out;
157
+ }
83
158
  /** Build the argv for a headless run. Exported so a test can assert it. */
84
159
  export function opencodeArgs(opts) {
85
160
  const args = ["run", "--format", "json", "--dir", opts.dir];
@@ -109,7 +184,7 @@ export async function runOpencode(opts) {
109
184
  return await new Promise((resolve, reject) => {
110
185
  const child = spawn(bin, args, {
111
186
  cwd: opts.dir,
112
- env: { ...process.env, ...opts.env },
187
+ env: opencodeChildEnv(process.env, opts.env),
113
188
  stdio: ["ignore", "pipe", "pipe"],
114
189
  ...(opts.timeoutMs && opts.timeoutMs > 0 ? { timeout: opts.timeoutMs, killSignal: "SIGTERM" } : {}),
115
190
  });
@@ -57,11 +57,10 @@ export async function agentHandler(item, opts) {
57
57
  // pass it back — see the ADR on why this is not yet automatic.
58
58
  sessionId: item.context.opencode_session_id,
59
59
  timeoutMs: item.timeoutMs,
60
- // The task-scoped callback seam. gibson mints the grant per dispatch
61
- // (`internal/engine/harness/implementation.go:1205-1210`) so a dispatched run
62
- // reaches the harness as the TASK rather than as this component. The same
63
- // passthrough the sandboxed shape uses (`dispatch.ts`), so the child sees one
64
- // environment however it was launched.
60
+ // The task-scoped callback seam. The platform mints the grant per dispatch,
61
+ // so a dispatched run reaches the harness as the TASK rather than as this
62
+ // component. The same passthrough the sandboxed shape uses (`dispatch.ts`),
63
+ // so the child sees one environment however it was launched.
65
64
  env: dispatchChildEnv({
66
65
  callbackEndpoint: item.callbackEndpoint,
67
66
  callbackToken: item.callbackToken,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zeroroot-ai/zerocool",
3
- "version": "0.9.1",
3
+ "version": "0.9.3",
4
4
  "description": "opencode host adapter for Gibson (zerocool): zero-config LLM through the harness, ambient knowledge, and the Gibson MCP server registered for you. The tools come from @zeroroot-ai/gibson-mcp.",
5
5
  "license": "Elastic-2.0",
6
6
  "repository": {
@@ -29,7 +29,7 @@
29
29
  "@opencode-ai/plugin": "*"
30
30
  },
31
31
  "devDependencies": {
32
- "@opencode-ai/plugin": "^1.18.27"
32
+ "@opencode-ai/plugin": "^1.18.29"
33
33
  },
34
34
  "bin": {
35
35
  "zerocool-serve": "dist/serve.js",