Skip to content

Commit c43a3eb

Browse files
authored
fix(coding-agents): run the opencode survey under our own agent, not plan mode (#3460)
* fix(coding-agents): run the opencode survey under our own agent, not plan mode Fixes #3450. The codebase survey ran as `opencode run --agent plan`, chosen as the read-only boundary for a session that reads untrusted repo files. But plan mode is not a permission boundary for what the survey needs to do — it is a PROMPT. Captured from a live opencode 1.18.9 session (fake model endpoint, so this is the request opencode actually builds, not an inference from behaviour), every user message carries: CRITICAL: Plan mode ACTIVE - you are in READ-ONLY phase. STRICTLY FORBIDDEN: ANY file edits, modifications, or system changes ... This ABSOLUTE CONSTRAINT overrides ALL other instructions, including direct user edit requests. while `hindsight_ingest_document` stays in the tool list — plan's ruleset denies only `edit`. So the survey is handed a tool and told not to use it, and whether the repo gets seeded depends on how literally the model reads "system changes". #3450 saw it stall on ~half their repos, the agent asking for an approval plan mode cannot grant even when the user says yes. Wording cannot fix this: the reminder claims to override all other instructions, and the reporter's transcript is the model saying exactly that back to them. So the survey now runs under an agent this plugin defines itself, through opencode's `config` hook — nothing lands in the user's opencode.json, and an entry they defined under that name wins. Measured on the same rig: --agent plan reminder in 9/9 captured requests --agent hindsight-survey reminder in 0, tools offered: glob, grep, read, hindsight_ingest_document It is also a TIGHTER sandbox than plan mode, which is what makes it safe for untrusted repo content. opencode drops denied tools from the model's tool list entirely, so `"*": "deny"` plus four allows is the boundary: no write, no bash, and no `task` — plan left all three reachable, and `task` reaches a subagent that can write. Probed live by asking the survey agent to create a file and to run touch: both refused, neither file appeared. Verified end to end against a live opencode with no agent in the user's config, so the definition could only come from the plugin: 4 ingest calls per run. * fix(coding-agents): pin the opencode config hook to the SDK's own Hooks type Code review on the parent commit: nothing checked that `config` is a hook name opencode actually calls. plugin-entry.ts casts the whole runtime object to the host's Hooks type — the other hooks take deliberately narrower params than the SDK declares, so they cannot be checked — which means a misnamed or re-signatured hook would compile, silently never fire, and leave the survey invoking an agent the host never heard of. Declaring just this hook as `Pick<Hooks, "config">` restores the check where it matters. Confirmed by renaming it: tsc now fails with "Object literal may only specify known properties, but 'configg' does not exist in type Pick<Hooks, "config">". The one cast that remains is `permission`, which the published type models with a fixed key set (edit/bash/webfetch/…) while the runtime takes arbitrary action names — opencode's own built-in `explore` agent is defined with `"*": "deny"` plus per-tool allows. Casting the agent entry beats widening it to `unknown`, which would drop the checking on `description`/`mode` as well. Re-verified against live opencode 1.18.9 after the change: 4 ingest calls, same as before. 499 tests, tsc and prettier clean.
1 parent 4afe43e commit c43a3eb

4 files changed

Lines changed: 149 additions & 11 deletions

File tree

hindsight-integrations/coding-agents/src/core/survey.test.ts

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
import { afterEach, describe, expect, it, vi } from "vitest";
2-
import { resolveClaudeBin, startCodebaseSurvey, SURVEY_PROMPT } from "./survey";
2+
import {
3+
resolveClaudeBin,
4+
startCodebaseSurvey,
5+
SURVEY_AGENT,
6+
SURVEY_AGENT_CONFIG,
7+
SURVEY_PROMPT,
8+
} from "./survey";
39

410
describe("resolveClaudeBin", () => {
511
const ORIGINAL_ENV = process.env.HINDSIGHT_CLAUDE_BIN;
@@ -125,8 +131,8 @@ describe("startCodebaseSurvey", () => {
125131
expect(options.env.HINDSIGHT_DISABLE_HOOKS).toBe("1");
126132
});
127133

128-
// ── opencode recipe (read-only plan agent; tools from the loaded plugin) ───────────────────────
129-
it("opencode: spawns `opencode run --agent plan` with the prompt", () => {
134+
// ── opencode recipe (our own read-only agent; tools from the loaded plugin) ────────────────────
135+
it("opencode: spawns `opencode run` under OUR survey agent, never the built-in plan agent", () => {
130136
const spawn = fakeSpawn();
131137
startCodebaseSurvey("/repo", {
132138
harness: "opencode",
@@ -135,10 +141,28 @@ describe("startCodebaseSurvey", () => {
135141
});
136142
const [bin, argv, options] = spawn.mock.calls[0];
137143
expect(bin).toBe("opencode");
138-
expect(argv).toEqual(["run", "--agent", "plan", SURVEY_PROMPT]);
144+
expect(argv).toEqual(["run", "--agent", SURVEY_AGENT, SURVEY_PROMPT]);
145+
// `plan` appends a read-only system-reminder that talks models out of the ingest call the
146+
// survey exists to make (#3450) — the whole point is not to run under it.
147+
expect(argv).not.toContain("plan");
139148
expect(options.env.HINDSIGHT_DISABLE_HOOKS).toBe("1");
140149
});
141150

151+
// The recipe above is only safe because the agent it names is read-only. opencode drops denied
152+
// tools from the model's tool list entirely, so this ruleset IS the sandbox.
153+
it("the survey agent denies everything except reading and the one ingest tool", () => {
154+
expect(SURVEY_AGENT_CONFIG.permission["*"]).toBe("deny");
155+
expect(SURVEY_AGENT_CONFIG.permission.hindsight_ingest_document).toBe("allow");
156+
const allowed = Object.entries(SURVEY_AGENT_CONFIG.permission)
157+
.filter(([, v]) => v === "allow")
158+
.map(([k]) => k)
159+
.sort();
160+
expect(allowed).toEqual(["glob", "grep", "hindsight_ingest_document", "read"]);
161+
// No write, no bash, and no `task` — which would reach a subagent that CAN write.
162+
for (const escape of ["write", "edit", "bash", "task", "patch"])
163+
expect(SURVEY_AGENT_CONFIG.permission).not.toHaveProperty(escape, "allow");
164+
});
165+
142166
// ── agent selection + fallback ─────────────────────────────────────────────────────────────────
143167
it("honors the HINDSIGHT_CODEX_BIN override for the codex binary", () => {
144168
const spawn = fakeSpawn();

hindsight-integrations/coding-agents/src/core/survey.ts

Lines changed: 44 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,9 @@
1717
* - codex : `codex exec --sandbox read-only -c mcp_servers.hindsight…` (inline MCP, self-contained)
1818
* - antigravity : `agy -p … --mode=plan --cwd <repo>` (read-only planning mode; MCP from the
1919
* global Antigravity customization config)
20-
* - opencode: `opencode run --agent plan …` (read-only agent; tools from the loaded plugin, which
21-
* under HINDSIGHT_DISABLE_HOOKS registers tools but skips seed/recall/write-back)
20+
* - opencode: `opencode run --agent hindsight-survey …` (a read-only agent OUR plugin defines —
21+
* see SURVEY_AGENT; tools from the loaded plugin, which under HINDSIGHT_DISABLE_HOOKS
22+
* registers tools but skips seed/recall/write-back)
2223
* Each is read-only sandboxed (prompt-injection safety, since the survey reads untrusted repo files)
2324
* and spawned with HINDSIGHT_DISABLE_HOOKS=1 so the survey's own session doesn't re-fire our hooks.
2425
*
@@ -58,6 +59,39 @@ export function resolveClaudeBin(explicit?: string): string {
5859
return "claude";
5960
}
6061

62+
/**
63+
* The opencode agent the survey runs under, defined by our own plugin (harness/opencode.ts feeds
64+
* this to opencode's `config` hook) so the recipe below needs nothing in the user's opencode.json.
65+
*
66+
* It replaces the built-in `plan` agent, which was chosen as the read-only boundary but appends a
67+
* system-reminder to every user message — "CRITICAL: Plan mode ACTIVE - you are in READ-ONLY phase
68+
* … ANY file edits, modifications, or system changes … This ABSOLUTE CONSTRAINT overrides ALL other
69+
* instructions" — while leaving plugin tools callable (plan denies only `edit`). The survey exists
70+
* to call hindsight_ingest_document, which reads as a "system change", so completion came down to
71+
* how literally a model took the reminder: #3450 saw the seed stall on ~half their repos, the agent
72+
* asking for permission that plan mode cannot grant. Prompt wording cannot win that argument — the
73+
* reminder claims to override all other instructions.
74+
*
75+
* The ruleset is also a TIGHTER boundary than plan's: opencode drops denied tools from the tool
76+
* list entirely, so the session is offered exactly read/grep/glob plus the one ingest tool — no
77+
* write, no bash, and no `task` (plan left all three reachable, and `task` reaches a subagent that
78+
* CAN write). Verified against opencode 1.18.9.
79+
*/
80+
export const SURVEY_AGENT = "hindsight-survey";
81+
82+
export const SURVEY_AGENT_CONFIG = {
83+
description:
84+
"One-time read-only structural survey that seeds this repository's Hindsight memory.",
85+
mode: "primary",
86+
permission: {
87+
"*": "deny",
88+
read: "allow",
89+
grep: "allow",
90+
glob: "allow",
91+
hindsight_ingest_document: "allow",
92+
},
93+
} as const;
94+
6195
/** Resolve a harness's agent CLI binary: explicit `claudeBin` (claude only) -> `HINDSIGHT_<AGENT>_BIN`
6296
* env override -> the bare command name (resolved on PATH at spawn time). */
6397
function resolveAgentBin(harness: SurveyHarness, claudeBin?: string): string {
@@ -232,13 +266,16 @@ function buildSurveyPlan(
232266
};
233267
}
234268
case "opencode": {
235-
// `opencode run` under the read-only `plan` agent (the injection boundary). The hindsight tools
236-
// come from the loaded plugin; under HINDSIGHT_DISABLE_HOOKS the plugin registers its tools but
237-
// skips seed/recall/write-back (runtime.ts), so this survey run doesn't re-seed itself. Model
238-
// left to opencode's configured default (its `provider/model` format differs per setup).
269+
// `opencode run` under the survey agent OUR OWN PLUGIN defines (harness/opencode.ts), which
270+
// is both the injection boundary and the reason the seed completes: the built-in `plan` agent
271+
// used to fill that slot, but its read-only system-reminder talked models out of the ingest
272+
// call the survey exists to make (#3450). The hindsight tools come from the loaded plugin;
273+
// under HINDSIGHT_DISABLE_HOOKS it registers tools but skips seed/recall/write-back
274+
// (runtime.ts), so this survey run doesn't re-seed itself. Model left to opencode's
275+
// configured default (its `provider/model` format differs per setup).
239276
return {
240277
bin,
241-
args: ["run", "--agent", "plan", SURVEY_PROMPT],
278+
args: ["run", "--agent", SURVEY_AGENT, SURVEY_PROMPT],
242279
env,
243280
};
244281
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
/**
2+
* The opencode adapter's `config` hook: it teaches the host about the survey agent, so the recipe
3+
* in core/survey.ts (`opencode run --agent hindsight-survey`) needs nothing in the user's
4+
* opencode.json. See SURVEY_AGENT in core/survey.ts for why the built-in `plan` agent can't do it.
5+
*/
6+
import { describe, expect, it } from "vitest";
7+
import { opencodeAdapter } from "./opencode";
8+
import type { RuntimeCore } from "../core/runtime";
9+
import { SURVEY_AGENT, SURVEY_AGENT_CONFIG } from "../core/survey";
10+
11+
/** Only the members createRuntime touches when it builds the hook object. */
12+
function fakeCore() {
13+
return { harness: "opencode", toolSpecs: () => [] } as unknown as RuntimeCore;
14+
}
15+
16+
type ConfigHook = (cfg: { agent?: Record<string, unknown> }) => Promise<void>;
17+
18+
function configHook(): ConfigHook {
19+
const runtime = opencodeAdapter.createRuntime(fakeCore()) as { config?: ConfigHook };
20+
if (!runtime.config) throw new Error("adapter exposes no config hook");
21+
return runtime.config;
22+
}
23+
24+
describe("opencode config hook", () => {
25+
it("defines the survey agent on a config that has no agents at all", async () => {
26+
const cfg: { agent?: Record<string, unknown> } = {};
27+
await configHook()(cfg);
28+
expect(cfg.agent?.[SURVEY_AGENT]).toEqual(SURVEY_AGENT_CONFIG);
29+
});
30+
31+
it("leaves the user's other agents alone", async () => {
32+
const mine = { description: "mine" };
33+
const cfg = { agent: { reviewer: mine } as Record<string, unknown> };
34+
await configHook()(cfg);
35+
expect(cfg.agent.reviewer).toBe(mine);
36+
expect(cfg.agent[SURVEY_AGENT]).toEqual(SURVEY_AGENT_CONFIG);
37+
});
38+
39+
it("does NOT overwrite a hindsight-survey agent the user defined themselves", async () => {
40+
// Their file is the last word: someone who redefines this name has a reason, and silently
41+
// replacing it would be a plugin overriding local config.
42+
const theirs = { description: "customised", mode: "primary" };
43+
const cfg = { agent: { [SURVEY_AGENT]: theirs } as Record<string, unknown> };
44+
await configHook()(cfg);
45+
expect(cfg.agent[SURVEY_AGENT]).toBe(theirs);
46+
});
47+
});

hindsight-integrations/coding-agents/src/harness/opencode.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
* This is the only opencode-specific file; everything it uses is in ../core.
1010
*/
1111
import { tool } from "@opencode-ai/plugin";
12+
import type { Config, Hooks } from "@opencode-ai/plugin";
1213
import type { RuntimeCore } from "../core/runtime";
1314
import type { HarnessAdapter } from "../core/types";
1415
import { diag } from "../core/diag";
@@ -19,10 +20,38 @@ import {
1920
type OcMessage,
2021
} from "../core/transcript-opencode";
2122
import { jsonChatReader } from "./registry";
23+
import { SURVEY_AGENT, SURVEY_AGENT_CONFIG } from "../core/survey";
2224

2325
// opencode message part shape for the per-turn prompt (structurally typed).
2426
type Part = { type?: string; text?: string };
2527

28+
/**
29+
* Teach the host about the survey agent (core/survey.ts spawns `opencode run --agent
30+
* hindsight-survey`), so the recipe needs nothing in the user's opencode.json.
31+
*
32+
* Declared as `Pick<Hooks, "config">` rather than inlined into the returned object, and that IS the
33+
* point: plugin-entry.ts casts the whole runtime object to the host's Hooks type (the other hooks
34+
* take deliberately narrower params than the SDK declares, so they cannot be checked), which means
35+
* nothing would catch this hook being misnamed or its signature changing under us — it would just
36+
* silently never fire, and the survey would die on an agent the host never heard of. Pinning this
37+
* one hook to the SDK's own type restores that check where it matters.
38+
*/
39+
const surveyAgentHook: Pick<Hooks, "config"> = {
40+
config: async (cfg: Config) => {
41+
cfg.agent ??= {};
42+
// Never overwrite an existing entry: a user who defined `hindsight-survey` themselves outranks
43+
// us.
44+
//
45+
// The cast covers one field the published type under-describes: `permission` is declared with a
46+
// fixed key set (edit/bash/webfetch/…), while the runtime takes arbitrary action names —
47+
// opencode's own built-in `explore` agent is defined with `"*": "deny"` plus per-tool allows,
48+
// and a live 1.18.9 session under this agent was offered exactly glob/grep/read/
49+
// hindsight_ingest_document. Casting the entry beats widening it to `unknown`, which would drop
50+
// the checking on `description`/`mode` too.
51+
cfg.agent[SURVEY_AGENT] ??= SURVEY_AGENT_CONFIG as NonNullable<Config["agent"]>[string];
52+
},
53+
};
54+
2655
const textOf = (parts: Part[]) =>
2756
(parts || [])
2857
.filter((p) => p?.type === "text" && p.text)
@@ -64,6 +93,7 @@ function createRuntime(core: RuntimeCore) {
6493
for (const spec of core.toolSpecs()) tools[spec.name] = toOpencodeTool(spec);
6594

6695
return {
96+
...surveyAgentHook,
6797
tool: tools,
6898
// Each user turn: recall on the prompt; the injection it builds is pushed by system.transform.
6999
"chat.message": async (input: { sessionID?: string }, output: { parts: Part[] }) => {

0 commit comments

Comments
 (0)