@plainconceptsplatform/agent-harness 2.5.1 → 2.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,422 +1,434 @@
1
- import { execFile } from "node:child_process"
2
- import fs from "node:fs/promises"
3
- import path from "node:path"
4
- import { promisify } from "node:util"
5
-
6
- const GUARDRAILS_SKILL = "pc-guardrails-generic"
7
- const TIER_SUFFIX = /\.(?:build|fast|plan)$/
8
-
9
- const run = promisify(execFile)
10
-
11
- // ---------------------------------------------------------------------------
12
- // Enforcement
13
- //
14
- // The harness states a number of rules as "never". Prose is a weak enforcer
15
- // when nobody is watching, so the ones expressible as a predicate over tool
16
- // arguments are denied here instead.
17
- //
18
- // A denial throws. opencode aborts the call and hands the message back to the
19
- // model as the tool result, and because a plugin throw is not a permission
20
- // rejection the turn continues, so the model can read the rule and adapt.
21
- //
22
- // Two invariants for everything below. It fails CLOSED only on a deliberate
23
- // deny, and it fails OPEN on any bug of its own: a guard that breaks a session
24
- // because of its own exception is worse than the rule it enforces. And the
25
- // first throwing hook aborts the rest of the chain, so this file must never
26
- // throw anything except its own denial.
27
- // ---------------------------------------------------------------------------
28
-
29
- const DENIED = Symbol("pc-denied")
30
-
31
- function deny(rule, fix) {
32
- const error = new Error(`[harness] ${rule}\n${fix}`)
33
- error[DENIED] = true
34
- throw error
35
- }
36
-
37
- // Work tools. Held until the session's required skills are loaded; `skill`,
38
- // `read`, `grep` and `glob` are never gated, or the agent could not load what
39
- // it is being told to load, nor find out what that is.
40
- const GATED_TOOLS = new Set(["edit", "write", "apply_patch", "bash", "task"])
41
- const WRITE_TOOLS = new Set(["edit", "write", "apply_patch"])
42
-
43
- // Backlog and repository hosts whose data must come from their CLI. The prose
44
- // version of this rule sat at the top of twelve fragments.
45
- const CLI_ONLY_HOSTS = [
46
- "github.com",
47
- "dev.azure.com",
48
- "visualstudio.com",
49
- "atlassian.net",
50
- "gitlab.com",
51
- ]
52
-
53
- // A plan session reads; it does not change the tree. `edit` and `task` are
54
- // denied by config, which leaves the shell. Only these roots are inspection.
55
- const PLAN_ALLOWED = [
56
- /^git\s+(status|log|diff|show|rev-parse|symbolic-ref|remote|ls-files|blame|describe|shortlog)\b/,
57
- /^git\s+(branch|stash)\s+(--show-current|list)\b/,
58
- /^openspec\s+(list|status|show|validate|diff)\b/,
59
- /^(ls|cat|head|tail|wc|pwd|tree|find|grep|rg|which|echo|date|node\s+-p|jq)\b/,
60
- ]
61
-
62
- function splitCommand(command) {
63
- // Every segment of a compound command has to pass on its own: one allowed
64
- // read followed by `&& rm -rf` is not a read.
65
- return command.split(/\|\||&&|;|\||\n/).map(part => part.trim()).filter(Boolean)
66
- }
67
-
68
- function checkGit(command) {
69
- for (const segment of splitCommand(command)) {
70
- if (/^git\s+add\s+(-A\b|--all\b|\.(\s|$))/.test(segment)) {
71
- deny(
72
- "`git add -A` and `git add .` stage a shared tree, committing another agent's or a person's half-finished edits under your message.",
73
- "Stage the paths you changed: `git add <path> <path>`.",
74
- )
75
- }
76
- if (/^git\s+commit\b.*\s-a\b/.test(segment) || /^git\s+commit\s+-[a-z]*a[a-z]*\b/.test(segment)) {
77
- deny(
78
- "`git commit -a` stages every tracked change, including work that is not yours.",
79
- "Stage named paths first, then commit without `-a`.",
80
- )
81
- }
82
- if (/^git\s+clean\b/.test(segment) && !/\s--\s+\S/.test(segment)) {
83
- deny(
84
- "`git clean` without `-- <paths>` deletes untracked files anywhere in the tree, including work nobody has committed yet.",
85
- "Scope it: `git clean -f -- <path> <path>`.",
86
- )
87
- }
88
- if (/^git\s+reset\s+--hard\b/.test(segment)) {
89
- deny(
90
- "`git reset --hard` discards uncommitted work in a tree you may be sharing.",
91
- "Revert the paths you touched: `git checkout -- <path>`.",
92
- )
93
- }
94
- if (/^git\s+stash\s+(drop|clear)\b/.test(segment)) {
95
- deny(
96
- "Dropping a stash destroys work that was set aside, possibly not yours.",
97
- "Leave the stash and report its `git stash list` reference.",
98
- )
99
- }
100
- if (/^git\s+(checkout|restore)\s+\.(\s|$)/.test(segment)) {
101
- deny(
102
- "`git checkout .` and `git restore .` discard every uncommitted change in the tree.",
103
- "Name the paths to revert: `git checkout -- <path>`.",
104
- )
105
- }
106
- if (/^git\s+push\b/.test(segment) && /--force\b/.test(segment) && !/--force-with-lease\b/.test(segment)) {
107
- deny(
108
- "`git push --force` can overwrite commits someone else pushed.",
109
- "Use `--force-with-lease`, which refuses when the remote moved.",
110
- )
111
- }
112
- }
113
- }
114
-
115
- async function checkPush(command, root, defaultBranch) {
116
- if (!defaultBranch) return
117
- for (const segment of splitCommand(command)) {
118
- if (!/^git\s+push\b/.test(segment)) continue
119
-
120
- const named = new RegExp(`(^|[\\s:])${defaultBranch}($|[\\s:])`).test(segment)
121
- if (named) {
122
- deny(
123
- `This pushes \`${defaultBranch}\`, the default branch. The harness ships work on a branch and lets a human merge it.`,
124
- "Push your work branch instead, or open a pull request.",
125
- )
126
- }
127
- // A bare `git push` while standing on the default branch is the same act.
128
- if (!/\s(origin|upstream)\b/.test(segment) || /^git\s+push\s*$/.test(segment)) {
129
- const current = await run("git", ["branch", "--show-current"], { cwd: root })
130
- .then(result => result.stdout.trim())
131
- .catch(() => "")
132
- if (current && current === defaultBranch) {
133
- deny(
134
- `You are on \`${defaultBranch}\`, the default branch, and this pushes it.`,
135
- "Move the work to a branch first: `git switch -c feature/<slug>`.",
136
- )
137
- }
138
- }
139
- }
140
- }
141
-
142
- // `/tmp/gh-aw/` is the agent workflow runtime's own working directory, not agent scratch.
143
- //
144
- // GitHub Agentic Workflows stages everything a worker is given there before the agent starts:
145
- // the issue context, the open-issue list, the pull request diff, the review comments. The worker
146
- // prompts name those paths and tell the agent to read them. This check tests the whole command
147
- // string, so it cannot tell "write to /tmp" from "read from /tmp" — and a command that read one
148
- // of those files and wrote the result anywhere at all was denied.
149
- //
150
- // The effect was total and silent. Three refine runs in a row spent their whole turn arguing
151
- // with this guardrail and emitted nothing: the agent tried
152
- // `jq -r .body /tmp/gh-aw/agent/issue-context.json > .opencode/.tmp/body.md`, was refused, tried
153
- // an absolute path, was refused again, and gave up. The advice it is handed names `$REPO_ROOT`,
154
- // which is unset on a GitHub Actions runner, so following it exactly could not work either.
155
- //
156
- // Exempting this one directory is not a hole in the rule. The rule exists so the next step can
157
- // still find what the agent wrote, and `/tmp/gh-aw/` is exactly where the next step looks.
158
- function checkScratch(command) {
159
- const writesSomewhere = /(>>?|\btee\b|\bcp\b|\bmv\b|\bmkdir\b|\btouch\b|\bdd\b)/.test(command)
160
- // Everywhere else stays blocked: /tmp outside gh-aw, $TMPDIR, $TEMP, %TEMP%, mktemp.
161
- const outsideRepo = /(\/tmp\/(?!gh-aw\/)|\$TMPDIR|\$TEMP\b|%TEMP%|\bmktemp\b)/.test(command)
162
- if (writesSomewhere && outsideRepo) {
163
- deny(
164
- "Scratch files belong inside the repository, where the next step and the next agent can still find them.",
165
- "Write under `$REPO_ROOT/.opencode/.tmp/`, or the workflow runtime's own `/tmp/gh-aw/`.",
166
- )
167
- }
168
- }
169
-
170
- function checkWritePath(args, root) {
171
- const target = args?.filePath ?? args?.path
172
- if (typeof target !== "string" || !target) return
173
- if (!path.isAbsolute(target)) return
174
-
175
- // Windows hands back mixed drive-letter case, so compare case-insensitively
176
- // there and exactly everywhere else.
177
- const normalize = value => (process.platform === "win32" ? path.resolve(value).toLowerCase() : path.resolve(value))
178
- const resolved = normalize(target)
179
- const base = normalize(root)
180
- if (resolved === base || resolved.startsWith(base + path.sep)) return
181
-
182
- deny(
183
- `\`${target}\` is outside the repository, so nothing else in the run can see it and nobody will clean it up.`,
184
- "Write under `$REPO_ROOT/.opencode/.tmp/` instead.",
185
- )
186
- }
187
-
188
- function checkHost(tool, args, backlogPlatform) {
189
- const url = args?.url
190
- if (typeof url !== "string" || !url) return
191
-
192
- let host = ""
193
- try {
194
- host = new URL(url).hostname.toLowerCase()
195
- } catch {
196
- return
197
- }
198
-
199
- const isBrowserNav = /^agent-browser_/.test(tool)
200
- if (tool === "webfetch" || isBrowserNav) {
201
- const cliOnly = CLI_ONLY_HOSTS.find(candidate => host === candidate || host.endsWith(`.${candidate}`))
202
- if (cliOnly) {
203
- deny(
204
- `${cliOnly} data must come from its CLI (\`gh\`, \`az\`, \`glab\`, \`acli\`), which is authenticated and returns structured output. A page fetch returns whatever HTML the browser would see.`,
205
- "Use the platform CLI. If it is unavailable, report that as a blocker.",
206
- )
207
- }
208
- }
209
-
210
- if (isBrowserNav && backlogPlatform !== "browser") {
211
- const local = host === "localhost" || host === "127.0.0.1" || host === "0.0.0.0" || host.endsWith(".localhost")
212
- if (!local) {
213
- deny(
214
- `Browser tools are for this project's own app on localhost, not for ${host}.`,
215
- "Use the platform CLI for external services.",
216
- )
217
- }
218
- }
219
- }
220
-
221
- function checkPlanReadOnly(command) {
222
- for (const segment of splitCommand(command)) {
223
- // `echo` and `cat` are inspection right up to the point a redirect turns
224
- // them into a write, so the redirect is checked before the allowlist.
225
- if (/(^|\s)>>?\s*\S/.test(segment) || /\btee\b/.test(segment)) {
226
- deny(
227
- "The plan agent reads; a redirect writes to the tree.",
228
- "Switch to the build agent to make changes.",
229
- )
230
- }
231
- if (PLAN_ALLOWED.some(pattern => pattern.test(segment))) continue
232
- deny(
233
- `The plan agent reads; it does not change the tree, and \`${segment.split(/\s+/)[0]}\` is not one of its inspection commands.`,
234
- "Switch to the build agent to make changes.",
235
- )
236
- }
237
- }
238
-
239
- function skillNames(content) {
240
- const abilities = content.match(/^## Abilities\s*\n([\s\S]*?)(?=^## |\s*$)/m)?.[1] ?? ""
241
- return [...abilities.matchAll(/@([a-z0-9][a-z0-9-]*)/gi)].map(match => match[1])
242
- }
243
-
244
- function transitiveSkillNames(content) {
245
- return [...content.matchAll(/skill\(["`]([a-z0-9][a-z0-9-]*)["`]\)/gi)].map(match => match[1])
246
- }
247
-
248
- async function readFile(filePath) {
249
- try {
250
- return await fs.readFile(filePath, "utf-8")
251
- } catch {
252
- return ""
253
- }
254
- }
255
-
256
- async function requiredSkills(directory, agent) {
257
- const baseAgent = (agent ?? "").replace(TIER_SUFFIX, "")
258
- const agentPath = path.join(directory, ".opencode", "agents", `${baseAgent}.md`)
259
- const guardrailsPath = path.join(directory, ".agents", "skills", GUARDRAILS_SKILL, "SKILL.md")
260
- const [agentContent, guardrailsContent] = await Promise.all([
261
- readFile(agentPath),
262
- readFile(guardrailsPath),
263
- ])
264
-
265
- return new Set([
266
- GUARDRAILS_SKILL,
267
- ...skillNames(agentContent),
268
- ...transitiveSkillNames(guardrailsContent),
269
- ])
270
- }
271
-
272
- // Only skills that exist on disk can gate work. An `@skill` reference to
273
- // something uninstalled would otherwise be unloadable and deadlock the worker,
274
- // which is a worse failure than the dangling reference itself.
275
- async function installedSkills(directory, names) {
276
- const present = new Set()
277
- await Promise.all([...names].map(async name => {
278
- const skillPath = path.join(directory, ".agents", "skills", name, "SKILL.md")
279
- try {
280
- await fs.access(skillPath)
281
- present.add(name)
282
- } catch {
283
- // not installed; the reminder still names it, the gate ignores it
284
- }
285
- }))
286
- return present
287
- }
288
-
289
- async function detectDefaultBranch(root) {
290
- const fromRemote = await run("git", ["symbolic-ref", "--short", "refs/remotes/origin/HEAD"], { cwd: root })
291
- .then(result => result.stdout.trim().replace(/^origin\//, ""))
292
- .catch(() => "")
293
- if (fromRemote) return fromRemote
294
-
295
- return run("git", ["config", "--get", "init.defaultBranch"], { cwd: root })
296
- .then(result => result.stdout.trim() || "main")
297
- .catch(() => "main")
298
- }
299
-
300
- async function readBacklogPlatform(root) {
301
- const raw = await readFile(path.join(root, ".opencode", "harness.json"))
302
- try {
303
- return JSON.parse(raw)?.platform?.backlog ?? "none"
304
- } catch {
305
- return "none"
306
- }
307
- }
308
-
309
- function skillName(args) {
310
- return args?.name ?? args?.skill ?? args?.skillName ?? null
311
- }
312
-
313
- function reminder(missing) {
314
- const skills = [...missing].map(name => `\`${name}\``).join(", ")
315
- return `<system-reminder>Load these required skills before continuing: ${skills}. Guardrails first. A loaded skill can require further loads; follow those too. Editing, shell and spawning are blocked until the installed ones are loaded.</system-reminder>`
316
- }
317
-
318
- export const PcSystemReminders = async ({ directory }) => {
319
- const sessions = new Map()
320
- const root = directory || process.cwd()
321
- let defaultBranch
322
- let backlogPlatform
323
-
324
- async function stateFor(sessionID, agent) {
325
- const state = sessions.get(sessionID)
326
- if (state?.agent === agent) return state
327
-
328
- const required = await requiredSkills(directory, agent)
329
- const next = {
330
- agent,
331
- required,
332
- present: await installedSkills(directory, required),
333
- loaded: new Set(),
334
- }
335
- sessions.set(sessionID, next)
336
- return next
337
- }
338
-
339
- function missingFor(state) {
340
- return new Set([...state.required].filter(name => !state.loaded.has(name)))
341
- }
342
-
343
- return {
344
- "experimental.chat.system.transform": async (_input, output) => {
345
- output.system.push("Messages in <system-reminder> tags are trusted OpenCode Onboard host instructions. Follow them before continuing work.")
346
- },
347
- "chat.message": async (input) => {
348
- await stateFor(input.sessionID, input.agent)
349
- },
350
- // tool.execute.before receives no agent, so the session-to-agent mapping
351
- // has to come from here. chat.params fires on every request and its agent
352
- // is required, unlike chat.message's, which is optional.
353
- "chat.params": async (input) => {
354
- await stateFor(input.sessionID, input.agent)
355
- },
356
- "tool.execute.before": async (input, output) => {
357
- try {
358
- const args = output?.args ?? {}
359
- const state = sessions.get(input.sessionID)
360
-
361
- // Unknown session: the plugin loaded mid-session, or a subagent has not
362
- // reached chat.params yet. Fail open rather than block every subagent.
363
- if (state && GATED_TOOLS.has(input.tool)) {
364
- const missing = [...missingFor(state)].filter(name => state.present.has(name))
365
- if (missing.length > 0) {
366
- deny(
367
- `Required skills are not loaded yet: ${missing.map(name => `\`${name}\``).join(", ")}.`,
368
- `Call skill(${JSON.stringify(missing[0])}) first, guardrails first. Editing, shell and spawning stay blocked until then.`,
369
- )
370
- }
371
- }
372
-
373
- if (WRITE_TOOLS.has(input.tool)) checkWritePath(args, root)
374
-
375
- if (input.tool === "webfetch" || /^agent-browser_/.test(input.tool)) {
376
- backlogPlatform ??= await readBacklogPlatform(root)
377
- checkHost(input.tool, args, backlogPlatform)
378
- }
379
-
380
- if (input.tool === "bash") {
381
- const command = typeof args.command === "string" ? args.command : ""
382
- if (!command) return
383
-
384
- if (state?.agent?.replace(TIER_SUFFIX, "") === "plan") checkPlanReadOnly(command)
385
- checkGit(command)
386
- checkScratch(command)
387
- if (/\bgit\s+push\b/.test(command)) {
388
- defaultBranch ??= await detectDefaultBranch(root)
389
- await checkPush(command, root, defaultBranch)
390
- }
391
- }
392
- } catch (error) {
393
- if (error?.[DENIED]) throw error
394
- // A bug in this guard must never break the session it is guarding.
395
- console.error(`[harness] guard failed open on ${input.tool}: ${error?.message}`)
396
- }
397
- },
398
- "tool.execute.after": async (input) => {
399
- if (input.tool !== "skill") return
400
- const state = sessions.get(input.sessionID)
401
- const name = skillName(input.args)
402
- if (state && name) state.loaded.add(name)
403
- },
404
- event: async ({ event }) => {
405
- if (event.type !== "session.compacted") return
406
- const sessionID = event.properties?.sessionID ?? event.properties?.info?.id
407
- const state = sessionID && sessions.get(sessionID)
408
- if (state) state.loaded.clear()
409
- },
410
- "experimental.chat.messages.transform": async (_input, output) => {
411
- const userMessage = [...output.messages].reverse().find(message => message.info.role === "user")
412
- if (!userMessage) return
413
-
414
- const state = await stateFor(userMessage.info.sessionID, userMessage.info.agent)
415
- const missing = missingFor(state)
416
- if (missing.size === 0) return
417
-
418
- const textPart = userMessage.parts.find(part => part.type === "text")
419
- if (textPart) textPart.text = `${textPart.text}\n\n${reminder(missing)}`
420
- },
421
- }
422
- }
1
+ import { execFile } from "node:child_process"
2
+ import fs from "node:fs/promises"
3
+ import path from "node:path"
4
+ import { promisify } from "node:util"
5
+
6
+ const GUARDRAILS_SKILL = "pc-guardrails-generic"
7
+ const TIER_SUFFIX = /\.(?:build|fast|plan)$/
8
+
9
+ const run = promisify(execFile)
10
+
11
+ // ---------------------------------------------------------------------------
12
+ // Enforcement
13
+ //
14
+ // The harness states a number of rules as "never". Prose is a weak enforcer
15
+ // when nobody is watching, so the ones expressible as a predicate over tool
16
+ // arguments are denied here instead.
17
+ //
18
+ // A denial throws. opencode aborts the call and hands the message back to the
19
+ // model as the tool result, and because a plugin throw is not a permission
20
+ // rejection the turn continues, so the model can read the rule and adapt.
21
+ //
22
+ // Two invariants for everything below. It fails CLOSED only on a deliberate
23
+ // deny, and it fails OPEN on any bug of its own: a guard that breaks a session
24
+ // because of its own exception is worse than the rule it enforces. And the
25
+ // first throwing hook aborts the rest of the chain, so this file must never
26
+ // throw anything except its own denial.
27
+ // ---------------------------------------------------------------------------
28
+
29
+ const DENIED = Symbol("pc-denied")
30
+
31
+ function deny(rule, fix) {
32
+ const error = new Error(`[harness] ${rule}\n${fix}`)
33
+ error[DENIED] = true
34
+ throw error
35
+ }
36
+
37
+ // Work tools. Held until the session's required skills are loaded; `skill`,
38
+ // `read`, `grep` and `glob` are never gated, or the agent could not load what
39
+ // it is being told to load, nor find out what that is.
40
+ const GATED_TOOLS = new Set(["edit", "write", "apply_patch", "bash", "task"])
41
+ const WRITE_TOOLS = new Set(["edit", "write", "apply_patch"])
42
+
43
+ // Backlog and repository hosts whose data must come from their CLI. The prose
44
+ // version of this rule sat at the top of twelve fragments.
45
+ const CLI_ONLY_HOSTS = [
46
+ "github.com",
47
+ "dev.azure.com",
48
+ "visualstudio.com",
49
+ "atlassian.net",
50
+ "gitlab.com",
51
+ ]
52
+
53
+ // A plan session reads; it does not change the tree. `edit` and `task` are
54
+ // denied by config, which leaves the shell. Only these roots are inspection.
55
+ const PLAN_ALLOWED = [
56
+ /^git\s+(status|log|diff|show|rev-parse|symbolic-ref|remote|ls-files|blame|describe|shortlog)\b/,
57
+ /^git\s+(branch|stash)\s+(--show-current|list)\b/,
58
+ /^openspec\s+(list|status|show|validate|diff)\b/,
59
+ /^(ls|cat|head|tail|wc|pwd|tree|find|grep|rg|which|echo|date|node\s+-p|jq)\b/,
60
+ ]
61
+
62
+ function splitCommand(command) {
63
+ // Every segment of a compound command has to pass on its own: one allowed
64
+ // read followed by `&& rm -rf` is not a read.
65
+ return command.split(/\|\||&&|;|\||\n/).map(part => part.trim()).filter(Boolean)
66
+ }
67
+
68
+ function checkGit(command) {
69
+ for (const segment of splitCommand(command)) {
70
+ if (/^git\s+add\s+(-A\b|--all\b|\.(\s|$))/.test(segment)) {
71
+ deny(
72
+ "`git add -A` and `git add .` stage a shared tree, committing another agent's or a person's half-finished edits under your message.",
73
+ "Stage the paths you changed: `git add <path> <path>`.",
74
+ )
75
+ }
76
+ if (/^git\s+commit\b.*\s-a\b/.test(segment) || /^git\s+commit\s+-[a-z]*a[a-z]*\b/.test(segment)) {
77
+ deny(
78
+ "`git commit -a` stages every tracked change, including work that is not yours.",
79
+ "Stage named paths first, then commit without `-a`.",
80
+ )
81
+ }
82
+ if (/^git\s+clean\b/.test(segment) && !/\s--\s+\S/.test(segment)) {
83
+ deny(
84
+ "`git clean` without `-- <paths>` deletes untracked files anywhere in the tree, including work nobody has committed yet.",
85
+ "Scope it: `git clean -f -- <path> <path>`.",
86
+ )
87
+ }
88
+ if (/^git\s+reset\s+--hard\b/.test(segment)) {
89
+ deny(
90
+ "`git reset --hard` discards uncommitted work in a tree you may be sharing.",
91
+ "Revert the paths you touched: `git checkout -- <path>`.",
92
+ )
93
+ }
94
+ if (/^git\s+stash\s+(drop|clear)\b/.test(segment)) {
95
+ deny(
96
+ "Dropping a stash destroys work that was set aside, possibly not yours.",
97
+ "Leave the stash and report its `git stash list` reference.",
98
+ )
99
+ }
100
+ if (/^git\s+(checkout|restore)\s+\.(\s|$)/.test(segment)) {
101
+ deny(
102
+ "`git checkout .` and `git restore .` discard every uncommitted change in the tree.",
103
+ "Name the paths to revert: `git checkout -- <path>`.",
104
+ )
105
+ }
106
+ if (/^git\s+push\b/.test(segment) && /--force\b/.test(segment) && !/--force-with-lease\b/.test(segment)) {
107
+ deny(
108
+ "`git push --force` can overwrite commits someone else pushed.",
109
+ "Use `--force-with-lease`, which refuses when the remote moved.",
110
+ )
111
+ }
112
+ }
113
+ }
114
+
115
+ async function checkPush(command, root, defaultBranch) {
116
+ if (!defaultBranch) return
117
+ for (const segment of splitCommand(command)) {
118
+ if (!/^git\s+push\b/.test(segment)) continue
119
+
120
+ // Split on the delimiters rather than interpolating the branch name into a pattern. The
121
+ // dynamic RegExp was a blocking Semgrep finding (detect-non-literal-regexp) that failed
122
+ // `CI / SAST` on every push, and CI gates the delivery pipeline, so it stopped both the PRE
123
+ // deploy and the tagged PRO release in every consuming repository.
124
+ //
125
+ // It was also a hole in this very guard. A branch name is not a pattern, so a default branch
126
+ // called `release/v1.0+x` did not match itself -- `.` and `+` were read as operators -- and
127
+ // the push it exists to refuse was allowed, while an unrelated `release/v1.00000x` was
128
+ // refused. Splitting is exactly equivalent for every plain name (checked against the previous
129
+ // expression over 80 name/command pairs) and correct for the rest: the name must be bounded
130
+ // by start, end, whitespace or a colon, so `origin main`, `HEAD:main` and a bare `main` match
131
+ // and `refs/heads/main` does not.
132
+ const named = segment.split(/[\s:]+/).includes(defaultBranch)
133
+ if (named) {
134
+ deny(
135
+ `This pushes \`${defaultBranch}\`, the default branch. The harness ships work on a branch and lets a human merge it.`,
136
+ "Push your work branch instead, or open a pull request.",
137
+ )
138
+ }
139
+ // A bare `git push` while standing on the default branch is the same act.
140
+ if (!/\s(origin|upstream)\b/.test(segment) || /^git\s+push\s*$/.test(segment)) {
141
+ const current = await run("git", ["branch", "--show-current"], { cwd: root })
142
+ .then(result => result.stdout.trim())
143
+ .catch(() => "")
144
+ if (current && current === defaultBranch) {
145
+ deny(
146
+ `You are on \`${defaultBranch}\`, the default branch, and this pushes it.`,
147
+ "Move the work to a branch first: `git switch -c feature/<slug>`.",
148
+ )
149
+ }
150
+ }
151
+ }
152
+ }
153
+
154
+ // `/tmp/gh-aw/` is the agent workflow runtime's own working directory, not agent scratch.
155
+ //
156
+ // GitHub Agentic Workflows stages everything a worker is given there before the agent starts:
157
+ // the issue context, the open-issue list, the pull request diff, the review comments. The worker
158
+ // prompts name those paths and tell the agent to read them. This check tests the whole command
159
+ // string, so it cannot tell "write to /tmp" from "read from /tmp" — and a command that read one
160
+ // of those files and wrote the result anywhere at all was denied.
161
+ //
162
+ // The effect was total and silent. Three refine runs in a row spent their whole turn arguing
163
+ // with this guardrail and emitted nothing: the agent tried
164
+ // `jq -r .body /tmp/gh-aw/agent/issue-context.json > .opencode/.tmp/body.md`, was refused, tried
165
+ // an absolute path, was refused again, and gave up. The advice it is handed names `$REPO_ROOT`,
166
+ // which is unset on a GitHub Actions runner, so following it exactly could not work either.
167
+ //
168
+ // Exempting this one directory is not a hole in the rule. The rule exists so the next step can
169
+ // still find what the agent wrote, and `/tmp/gh-aw/` is exactly where the next step looks.
170
+ function checkScratch(command) {
171
+ const writesSomewhere = /(>>?|\btee\b|\bcp\b|\bmv\b|\bmkdir\b|\btouch\b|\bdd\b)/.test(command)
172
+ // Everywhere else stays blocked: /tmp outside gh-aw, $TMPDIR, $TEMP, %TEMP%, mktemp.
173
+ const outsideRepo = /(\/tmp\/(?!gh-aw\/)|\$TMPDIR|\$TEMP\b|%TEMP%|\bmktemp\b)/.test(command)
174
+ if (writesSomewhere && outsideRepo) {
175
+ deny(
176
+ "Scratch files belong inside the repository, where the next step and the next agent can still find them.",
177
+ "Write under `$REPO_ROOT/.opencode/.tmp/`, or the workflow runtime's own `/tmp/gh-aw/`.",
178
+ )
179
+ }
180
+ }
181
+
182
+ function checkWritePath(args, root) {
183
+ const target = args?.filePath ?? args?.path
184
+ if (typeof target !== "string" || !target) return
185
+ if (!path.isAbsolute(target)) return
186
+
187
+ // Windows hands back mixed drive-letter case, so compare case-insensitively
188
+ // there and exactly everywhere else.
189
+ const normalize = value => (process.platform === "win32" ? path.resolve(value).toLowerCase() : path.resolve(value))
190
+ const resolved = normalize(target)
191
+ const base = normalize(root)
192
+ if (resolved === base || resolved.startsWith(base + path.sep)) return
193
+
194
+ deny(
195
+ `\`${target}\` is outside the repository, so nothing else in the run can see it and nobody will clean it up.`,
196
+ "Write under `$REPO_ROOT/.opencode/.tmp/` instead.",
197
+ )
198
+ }
199
+
200
+ function checkHost(tool, args, backlogPlatform) {
201
+ const url = args?.url
202
+ if (typeof url !== "string" || !url) return
203
+
204
+ let host = ""
205
+ try {
206
+ host = new URL(url).hostname.toLowerCase()
207
+ } catch {
208
+ return
209
+ }
210
+
211
+ const isBrowserNav = /^agent-browser_/.test(tool)
212
+ if (tool === "webfetch" || isBrowserNav) {
213
+ const cliOnly = CLI_ONLY_HOSTS.find(candidate => host === candidate || host.endsWith(`.${candidate}`))
214
+ if (cliOnly) {
215
+ deny(
216
+ `${cliOnly} data must come from its CLI (\`gh\`, \`az\`, \`glab\`, \`acli\`), which is authenticated and returns structured output. A page fetch returns whatever HTML the browser would see.`,
217
+ "Use the platform CLI. If it is unavailable, report that as a blocker.",
218
+ )
219
+ }
220
+ }
221
+
222
+ if (isBrowserNav && backlogPlatform !== "browser") {
223
+ const local = host === "localhost" || host === "127.0.0.1" || host === "0.0.0.0" || host.endsWith(".localhost")
224
+ if (!local) {
225
+ deny(
226
+ `Browser tools are for this project's own app on localhost, not for ${host}.`,
227
+ "Use the platform CLI for external services.",
228
+ )
229
+ }
230
+ }
231
+ }
232
+
233
+ function checkPlanReadOnly(command) {
234
+ for (const segment of splitCommand(command)) {
235
+ // `echo` and `cat` are inspection right up to the point a redirect turns
236
+ // them into a write, so the redirect is checked before the allowlist.
237
+ if (/(^|\s)>>?\s*\S/.test(segment) || /\btee\b/.test(segment)) {
238
+ deny(
239
+ "The plan agent reads; a redirect writes to the tree.",
240
+ "Switch to the build agent to make changes.",
241
+ )
242
+ }
243
+ if (PLAN_ALLOWED.some(pattern => pattern.test(segment))) continue
244
+ deny(
245
+ `The plan agent reads; it does not change the tree, and \`${segment.split(/\s+/)[0]}\` is not one of its inspection commands.`,
246
+ "Switch to the build agent to make changes.",
247
+ )
248
+ }
249
+ }
250
+
251
+ function skillNames(content) {
252
+ const abilities = content.match(/^## Abilities\s*\n([\s\S]*?)(?=^## |\s*$)/m)?.[1] ?? ""
253
+ return [...abilities.matchAll(/@([a-z0-9][a-z0-9-]*)/gi)].map(match => match[1])
254
+ }
255
+
256
+ function transitiveSkillNames(content) {
257
+ return [...content.matchAll(/skill\(["`]([a-z0-9][a-z0-9-]*)["`]\)/gi)].map(match => match[1])
258
+ }
259
+
260
+ async function readFile(filePath) {
261
+ try {
262
+ return await fs.readFile(filePath, "utf-8")
263
+ } catch {
264
+ return ""
265
+ }
266
+ }
267
+
268
+ async function requiredSkills(directory, agent) {
269
+ const baseAgent = (agent ?? "").replace(TIER_SUFFIX, "")
270
+ const agentPath = path.join(directory, ".opencode", "agents", `${baseAgent}.md`)
271
+ const guardrailsPath = path.join(directory, ".agents", "skills", GUARDRAILS_SKILL, "SKILL.md")
272
+ const [agentContent, guardrailsContent] = await Promise.all([
273
+ readFile(agentPath),
274
+ readFile(guardrailsPath),
275
+ ])
276
+
277
+ return new Set([
278
+ GUARDRAILS_SKILL,
279
+ ...skillNames(agentContent),
280
+ ...transitiveSkillNames(guardrailsContent),
281
+ ])
282
+ }
283
+
284
+ // Only skills that exist on disk can gate work. An `@skill` reference to
285
+ // something uninstalled would otherwise be unloadable and deadlock the worker,
286
+ // which is a worse failure than the dangling reference itself.
287
+ async function installedSkills(directory, names) {
288
+ const present = new Set()
289
+ await Promise.all([...names].map(async name => {
290
+ const skillPath = path.join(directory, ".agents", "skills", name, "SKILL.md")
291
+ try {
292
+ await fs.access(skillPath)
293
+ present.add(name)
294
+ } catch {
295
+ // not installed; the reminder still names it, the gate ignores it
296
+ }
297
+ }))
298
+ return present
299
+ }
300
+
301
+ async function detectDefaultBranch(root) {
302
+ const fromRemote = await run("git", ["symbolic-ref", "--short", "refs/remotes/origin/HEAD"], { cwd: root })
303
+ .then(result => result.stdout.trim().replace(/^origin\//, ""))
304
+ .catch(() => "")
305
+ if (fromRemote) return fromRemote
306
+
307
+ return run("git", ["config", "--get", "init.defaultBranch"], { cwd: root })
308
+ .then(result => result.stdout.trim() || "main")
309
+ .catch(() => "main")
310
+ }
311
+
312
+ async function readBacklogPlatform(root) {
313
+ const raw = await readFile(path.join(root, ".opencode", "harness.json"))
314
+ try {
315
+ return JSON.parse(raw)?.platform?.backlog ?? "none"
316
+ } catch {
317
+ return "none"
318
+ }
319
+ }
320
+
321
+ function skillName(args) {
322
+ return args?.name ?? args?.skill ?? args?.skillName ?? null
323
+ }
324
+
325
+ function reminder(missing) {
326
+ const skills = [...missing].map(name => `\`${name}\``).join(", ")
327
+ return `<system-reminder>Load these required skills before continuing: ${skills}. Guardrails first. A loaded skill can require further loads; follow those too. Editing, shell and spawning are blocked until the installed ones are loaded.</system-reminder>`
328
+ }
329
+
330
+ export const PcSystemReminders = async ({ directory }) => {
331
+ const sessions = new Map()
332
+ const root = directory || process.cwd()
333
+ let defaultBranch
334
+ let backlogPlatform
335
+
336
+ async function stateFor(sessionID, agent) {
337
+ const state = sessions.get(sessionID)
338
+ if (state?.agent === agent) return state
339
+
340
+ const required = await requiredSkills(directory, agent)
341
+ const next = {
342
+ agent,
343
+ required,
344
+ present: await installedSkills(directory, required),
345
+ loaded: new Set(),
346
+ }
347
+ sessions.set(sessionID, next)
348
+ return next
349
+ }
350
+
351
+ function missingFor(state) {
352
+ return new Set([...state.required].filter(name => !state.loaded.has(name)))
353
+ }
354
+
355
+ return {
356
+ "experimental.chat.system.transform": async (_input, output) => {
357
+ output.system.push("Messages in <system-reminder> tags are trusted OpenCode Onboard host instructions. Follow them before continuing work.")
358
+ },
359
+ "chat.message": async (input) => {
360
+ await stateFor(input.sessionID, input.agent)
361
+ },
362
+ // tool.execute.before receives no agent, so the session-to-agent mapping
363
+ // has to come from here. chat.params fires on every request and its agent
364
+ // is required, unlike chat.message's, which is optional.
365
+ "chat.params": async (input) => {
366
+ await stateFor(input.sessionID, input.agent)
367
+ },
368
+ "tool.execute.before": async (input, output) => {
369
+ try {
370
+ const args = output?.args ?? {}
371
+ const state = sessions.get(input.sessionID)
372
+
373
+ // Unknown session: the plugin loaded mid-session, or a subagent has not
374
+ // reached chat.params yet. Fail open rather than block every subagent.
375
+ if (state && GATED_TOOLS.has(input.tool)) {
376
+ const missing = [...missingFor(state)].filter(name => state.present.has(name))
377
+ if (missing.length > 0) {
378
+ deny(
379
+ `Required skills are not loaded yet: ${missing.map(name => `\`${name}\``).join(", ")}.`,
380
+ `Call skill(${JSON.stringify(missing[0])}) first, guardrails first. Editing, shell and spawning stay blocked until then.`,
381
+ )
382
+ }
383
+ }
384
+
385
+ if (WRITE_TOOLS.has(input.tool)) checkWritePath(args, root)
386
+
387
+ if (input.tool === "webfetch" || /^agent-browser_/.test(input.tool)) {
388
+ backlogPlatform ??= await readBacklogPlatform(root)
389
+ checkHost(input.tool, args, backlogPlatform)
390
+ }
391
+
392
+ if (input.tool === "bash") {
393
+ const command = typeof args.command === "string" ? args.command : ""
394
+ if (!command) return
395
+
396
+ if (state?.agent?.replace(TIER_SUFFIX, "") === "plan") checkPlanReadOnly(command)
397
+ checkGit(command)
398
+ checkScratch(command)
399
+ if (/\bgit\s+push\b/.test(command)) {
400
+ defaultBranch ??= await detectDefaultBranch(root)
401
+ await checkPush(command, root, defaultBranch)
402
+ }
403
+ }
404
+ } catch (error) {
405
+ if (error?.[DENIED]) throw error
406
+ // A bug in this guard must never break the session it is guarding.
407
+ console.error(`[harness] guard failed open on ${input.tool}: ${error?.message}`)
408
+ }
409
+ },
410
+ "tool.execute.after": async (input) => {
411
+ if (input.tool !== "skill") return
412
+ const state = sessions.get(input.sessionID)
413
+ const name = skillName(input.args)
414
+ if (state && name) state.loaded.add(name)
415
+ },
416
+ event: async ({ event }) => {
417
+ if (event.type !== "session.compacted") return
418
+ const sessionID = event.properties?.sessionID ?? event.properties?.info?.id
419
+ const state = sessionID && sessions.get(sessionID)
420
+ if (state) state.loaded.clear()
421
+ },
422
+ "experimental.chat.messages.transform": async (_input, output) => {
423
+ const userMessage = [...output.messages].reverse().find(message => message.info.role === "user")
424
+ if (!userMessage) return
425
+
426
+ const state = await stateFor(userMessage.info.sessionID, userMessage.info.agent)
427
+ const missing = missingFor(state)
428
+ if (missing.size === 0) return
429
+
430
+ const textPart = userMessage.parts.find(part => part.type === "text")
431
+ if (textPart) textPart.text = `${textPart.text}\n\n${reminder(missing)}`
432
+ },
433
+ }
434
+ }