opencode-harness-kit 0.0.0-stage → 0.1.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.
- package/GLOBAL.md +110 -0
- package/LICENSE +21 -0
- package/README.md +421 -2
- package/agents/build.md +66 -0
- package/agents/locate.md +48 -0
- package/agents/plan.md +61 -0
- package/agents/reviewer.md +62 -0
- package/agents/verify.md +54 -0
- package/bin/baseline.mjs +367 -0
- package/bin/git-hooks/pre-commit +30 -0
- package/bin/oc-harness.mjs +977 -0
- package/dist/index.js +976 -0
- package/docs/KNOWN-LIMITS.md +188 -0
- package/docs/RESEARCH.md +210 -0
- package/examples/muse-spark-1.3-contributor-free.json +41 -0
- package/package.json +77 -4
- package/profiles/deepseek-v4-flash-free.json +41 -0
- package/profiles/index.json +60 -0
- package/profiles/longcat-2.5-preview-free.json +41 -0
- package/profiles/schema.json +132 -0
- package/profiles/space-bunny-free.json +42 -0
- package/profiles/x-preview-f-free.json +41 -0
- package/retention.yml +110 -0
- package/skills/space-build/SKILL.md +54 -0
- package/skills/space-finish/SKILL.md +63 -0
- package/skills/space-plan/SKILL.md +101 -0
- package/skills/space-plan/reference/task-template.md +47 -0
- package/skills/space-review/SKILL.md +53 -0
- package/src/config.js +326 -0
- package/src/effort.js +98 -0
- package/src/guard.ts +59 -0
- package/src/index.ts +80 -0
- package/src/profile.js +333 -0
- package/src/prune.ts +258 -0
- package/src/router.ts +192 -0
- package/src/rules.js +496 -0
- package/src/telemetry.ts +98 -0
package/src/router.ts
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
// Model fallback across a profile's queue.
|
|
2
|
+
//
|
|
3
|
+
// The honest version of this feature: opencode has no hook that can change a
|
|
4
|
+
// live session's model. `chat.params` only sees temperature and sampling, the
|
|
5
|
+
// model on `chat.message` is read-only, and `config` runs once at startup. So
|
|
6
|
+
// failover here means two things, and only two:
|
|
7
|
+
//
|
|
8
|
+
// 1. At startup, if a health file marks the preferred model unhealthy, pick
|
|
9
|
+
// the next healthy profile before the session begins.
|
|
10
|
+
// 2. When a request fails, tell the agent plainly that the route is spent and
|
|
11
|
+
// a new session is needed, instead of letting it retry into a wall.
|
|
12
|
+
//
|
|
13
|
+
// Retry-on-429 is deliberately not attempted. Provider issue #42977 documents
|
|
14
|
+
// free models returning FreeUsageLimitError for ten days with no recovery, and
|
|
15
|
+
// the maintainers' own guidance is to mark the route unhealthy rather than
|
|
16
|
+
// retry it. The daily window resets at 00:00 UTC, so waiting inside a session
|
|
17
|
+
// is the wrong shape anyway.
|
|
18
|
+
|
|
19
|
+
import type { Plugin } from "@opencode-ai/plugin"
|
|
20
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs"
|
|
21
|
+
import { join } from "node:path"
|
|
22
|
+
import {
|
|
23
|
+
loadProfile,
|
|
24
|
+
pruneThreshold,
|
|
25
|
+
profileIdFromConfig,
|
|
26
|
+
reservedTokens,
|
|
27
|
+
resolveEffort,
|
|
28
|
+
} from "./profile.js"
|
|
29
|
+
|
|
30
|
+
interface Health {
|
|
31
|
+
unhealthy: Record<string, { at: string; reason: string }>
|
|
32
|
+
active?: string
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function healthPath(worktree: string): string {
|
|
36
|
+
return join(worktree, ".space", "health.json")
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function readHealth(worktree: string): Health {
|
|
40
|
+
try {
|
|
41
|
+
return JSON.parse(readFileSync(healthPath(worktree), "utf8"))
|
|
42
|
+
} catch {
|
|
43
|
+
return { unhealthy: {} }
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function writeHealth(worktree: string, h: Health) {
|
|
48
|
+
try {
|
|
49
|
+
mkdirSync(join(worktree, ".space"), { recursive: true })
|
|
50
|
+
writeFileSync(healthPath(worktree), JSON.stringify(h, null, 2))
|
|
51
|
+
} catch {
|
|
52
|
+
// Best effort.
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Patterns that mean "this route is spent", not "this request was bad". */
|
|
57
|
+
const SPENT = [
|
|
58
|
+
/FreeUsageLimitError/i,
|
|
59
|
+
/rate[_ -]?limit/i,
|
|
60
|
+
/\b429\b/,
|
|
61
|
+
/quota (?:exceeded|exhausted)/i,
|
|
62
|
+
/overloaded_error/i,
|
|
63
|
+
/model .*not found/i,
|
|
64
|
+
/no available provider/i,
|
|
65
|
+
]
|
|
66
|
+
|
|
67
|
+
function classify(text: string): string | null {
|
|
68
|
+
for (const p of SPENT) if (p.test(text)) return p.source
|
|
69
|
+
return null
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const WARN_TEXT =
|
|
73
|
+
"[opencode-harness-kit] This session's model route is rate limited or withdrawn. " +
|
|
74
|
+
"No plugin can swap a live session's model, so stop retrying and ask the human to start a " +
|
|
75
|
+
"new session. Run `oc-harness list` to see the fallback queue, or `oc-harness doctor` to " +
|
|
76
|
+
"check whether the profile still matches live metadata."
|
|
77
|
+
|
|
78
|
+
export const RouterPlugin: Plugin = async ({ worktree }) => {
|
|
79
|
+
let failedSessions = 0
|
|
80
|
+
let warned = false
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
// Runs once, before the session does any work.
|
|
84
|
+
config: async (config) => {
|
|
85
|
+
try {
|
|
86
|
+
const cfg = config as Record<string, any>
|
|
87
|
+
const health = readHealth(worktree)
|
|
88
|
+
|
|
89
|
+
// config.harness is stripped by schema validation, so the active
|
|
90
|
+
// profile comes from the config file on disk.
|
|
91
|
+
const activeId = cfg.harness?.profile ?? profileIdFromConfig()
|
|
92
|
+
if (activeId) {
|
|
93
|
+
const id: string = activeId
|
|
94
|
+
const mark = health.unhealthy[`opencode/${id}`]
|
|
95
|
+
if (mark) {
|
|
96
|
+
// Walk the queue for the first profile that is not marked down.
|
|
97
|
+
let chosen: string | null = id
|
|
98
|
+
try {
|
|
99
|
+
const queue = [id, ...(loadProfile(id).fallback ?? [])]
|
|
100
|
+
for (const candidate of queue) {
|
|
101
|
+
if (!health.unhealthy[`opencode/${candidate}`]) {
|
|
102
|
+
chosen = candidate
|
|
103
|
+
break
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
} catch {
|
|
107
|
+
// A broken profile must not stop opencode from starting.
|
|
108
|
+
}
|
|
109
|
+
if (chosen !== id) {
|
|
110
|
+
applyProfileToConfig(cfg, chosen)
|
|
111
|
+
health.active = chosen
|
|
112
|
+
writeHealth(worktree, health)
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
} catch {
|
|
117
|
+
// Startup must not fail because of the router.
|
|
118
|
+
}
|
|
119
|
+
},
|
|
120
|
+
|
|
121
|
+
event: async ({ event }) => {
|
|
122
|
+
const e = event as any
|
|
123
|
+
const type = e?.type
|
|
124
|
+
if (!type) return
|
|
125
|
+
|
|
126
|
+
const text = JSON.stringify(e)
|
|
127
|
+
const reason = classify(text)
|
|
128
|
+
if (!reason) return
|
|
129
|
+
|
|
130
|
+
// Only mark the model this session is actually using.
|
|
131
|
+
const modelID = e.properties?.modelID ?? e.properties?.model ?? e.properties?.modelID
|
|
132
|
+
const key = modelID ? `opencode/${modelID}` : undefined
|
|
133
|
+
if (!key) return
|
|
134
|
+
|
|
135
|
+
const health = readHealth(worktree)
|
|
136
|
+
if (health.unhealthy[key]) return // already recorded, do not spam
|
|
137
|
+
health.unhealthy[key] = { at: new Date().toISOString(), reason }
|
|
138
|
+
writeHealth(worktree, health)
|
|
139
|
+
failedSessions++
|
|
140
|
+
},
|
|
141
|
+
|
|
142
|
+
"chat.message": async (input, output) => {
|
|
143
|
+
// If this session has already hit a spent route, say so once, early,
|
|
144
|
+
// instead of letting the agent burn turns discovering it.
|
|
145
|
+
if (failedSessions === 0) return
|
|
146
|
+
if (warned) return
|
|
147
|
+
warned = true
|
|
148
|
+
|
|
149
|
+
// Appending to an existing text part avoids fabricating a Part, which
|
|
150
|
+
// needs id/sessionID/messageID this hook has no good source for.
|
|
151
|
+
const parts = output.parts ?? []
|
|
152
|
+
const last = parts[parts.length - 1]
|
|
153
|
+
if (last?.type === "text") {
|
|
154
|
+
last.text = `${last.text}\n\n${WARN_TEXT}`
|
|
155
|
+
return
|
|
156
|
+
}
|
|
157
|
+
// Nothing to attach to: the health file and telemetry still record it.
|
|
158
|
+
writeHealth(worktree, readHealth(worktree))
|
|
159
|
+
},
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Point the config at another profile, including its derived numbers. */
|
|
164
|
+
function applyProfileToConfig(cfg: Record<string, any>, profileId: string) {
|
|
165
|
+
try {
|
|
166
|
+
const p = loadProfile(profileId)
|
|
167
|
+
const model = `${p.provider}/${p.model}`
|
|
168
|
+
cfg.model = model
|
|
169
|
+
cfg.small_model = model
|
|
170
|
+
const variant = p.effort?.default
|
|
171
|
+
if (p.effort?.levels?.length && variant) cfg.variant = variant
|
|
172
|
+
else delete cfg.variant
|
|
173
|
+
|
|
174
|
+
for (const [role, want] of Object.entries(p.roles ?? {})) {
|
|
175
|
+
const agent = cfg.agent ?? (cfg.agent = {})
|
|
176
|
+
const existing = agent[role] ?? (agent[role] = {})
|
|
177
|
+
existing.model = model
|
|
178
|
+
const level = resolveEffort(p.effort?.levels ?? [], want)
|
|
179
|
+
if (level) existing.variant = level
|
|
180
|
+
else delete existing.variant
|
|
181
|
+
}
|
|
182
|
+
if (cfg.compaction) {
|
|
183
|
+
cfg.compaction.prune = true
|
|
184
|
+
cfg.compaction.reserved = reservedTokens(p)
|
|
185
|
+
}
|
|
186
|
+
cfg.harness = { ...(cfg.harness ?? {}), profile: profileId, threshold: pruneThreshold(p) }
|
|
187
|
+
} catch {
|
|
188
|
+
// Leave the config alone if the fallback profile cannot be read.
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export default RouterPlugin
|
package/src/rules.js
ADDED
|
@@ -0,0 +1,496 @@
|
|
|
1
|
+
// Pure guard logic, separate from the hook so it can be tested without a
|
|
2
|
+
// TypeScript runtime and reused by other tools.
|
|
3
|
+
|
|
4
|
+
import { homedir, tmpdir } from "node:os"
|
|
5
|
+
import { resolve as resolvePath, sep } from "node:path"
|
|
6
|
+
|
|
7
|
+
// Two ideas here:
|
|
8
|
+
//
|
|
9
|
+
// 1. Commands are split on shell separators before matching. Matching a raw
|
|
10
|
+
// string is what lets `cd repo && git push` through, which is the obvious
|
|
11
|
+
// hole in every regex guardrail. This is the "syntax-aware command
|
|
12
|
+
// permissioning" pattern.
|
|
13
|
+
//
|
|
14
|
+
// 2. Rules are scoped. A rule that spans two commands (a pipe into a shell, a
|
|
15
|
+
// fetch assigned to a credential name) has to see the whole string; a rule
|
|
16
|
+
// about one command must only see one command, or `git log --grep push`
|
|
17
|
+
// trips a push rule.
|
|
18
|
+
//
|
|
19
|
+
// 3. Splitting and matching are both quote-aware. Splitting naively on `;` and
|
|
20
|
+
// `&&` invents commands that are really one argument, and anchoring a rule
|
|
21
|
+
// at `^git` is defeated by any prefix (`env`, `sudo`, `git -C`). So the
|
|
22
|
+
// splitter walks the string tracking quotes, git rules look up the resolved
|
|
23
|
+
// subcommand, and every anchored rule is matched against the segment with
|
|
24
|
+
// its prefixes stripped.
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* macOS and Windows compare paths case-insensitively by default, so a scope
|
|
28
|
+
* check that compared case-sensitively would let `/Users/Admin/...` past a
|
|
29
|
+
* `/users/admin` root.
|
|
30
|
+
*/
|
|
31
|
+
const CASE_INSENSITIVE_FS = process.platform === "darwin" || process.platform === "win32"
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* @param {string} p
|
|
35
|
+
* @returns {string}
|
|
36
|
+
*/
|
|
37
|
+
function expandHome(p) {
|
|
38
|
+
if (p === "~") return homedir()
|
|
39
|
+
if (p.startsWith("~/")) return resolvePath(homedir(), p.slice(2))
|
|
40
|
+
return p
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Whether a write target stays inside the project. A relative path is resolved
|
|
45
|
+
* against the project root, which is where the agent's working directory is.
|
|
46
|
+
* @param {string} target
|
|
47
|
+
* @param {string} root
|
|
48
|
+
* @returns {boolean}
|
|
49
|
+
*/
|
|
50
|
+
export function insideRoot(target, root) {
|
|
51
|
+
if (!root) return true
|
|
52
|
+
const absTarget = resolvePath(root, expandHome(target))
|
|
53
|
+
const absRoot = resolvePath(root)
|
|
54
|
+
if (absTarget === absRoot) return true
|
|
55
|
+
const a = CASE_INSENSITIVE_FS ? absTarget.toLowerCase() : absTarget
|
|
56
|
+
const b = CASE_INSENSITIVE_FS ? absRoot.toLowerCase() : absRoot
|
|
57
|
+
return a.startsWith(b.endsWith(sep) ? b : b + sep)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* @typedef {{ name: string, test?: RegExp, check?: (text: string) => boolean,
|
|
62
|
+
* why: string, scope?: "segment" | "command", unless?: RegExp }} Rule
|
|
63
|
+
* @typedef {{ name: string, why: string, segment: string }} Hit
|
|
64
|
+
*/
|
|
65
|
+
|
|
66
|
+
const HEREDOC = /^<<(-)?(?:'([A-Za-z_][A-Za-z0-9_]*)'|"([A-Za-z_][A-Za-z0-9_]*)"|([A-Za-z_][A-Za-z0-9_]*))/
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The index just past a heredoc body that starts at `at`, or -1. A heredoc
|
|
70
|
+
* body is data handed to a command, never commands, so it must not be split.
|
|
71
|
+
* An agent writing a commit message that quotes the command it fixed would
|
|
72
|
+
* otherwise be blocked for describing the very thing it was asked to remove.
|
|
73
|
+
* @param {string} command
|
|
74
|
+
* @param {number} at
|
|
75
|
+
* @returns {number}
|
|
76
|
+
*/
|
|
77
|
+
function heredocEnd(command, at) {
|
|
78
|
+
if (command[at] !== "<" || command[at + 1] !== "<") return -1
|
|
79
|
+
if (command[at + 2] === "<") return -1 // <<< is a herestring, whose value is an argument
|
|
80
|
+
const m = HEREDOC.exec(command.slice(at))
|
|
81
|
+
if (!m) return -1
|
|
82
|
+
const delim = m[2] ?? m[3] ?? m[4]
|
|
83
|
+
const nl = command.indexOf("\n", at + m[0].length)
|
|
84
|
+
if (nl === -1) return -1
|
|
85
|
+
for (let p = nl + 1; ; ) {
|
|
86
|
+
const end = command.indexOf("\n", p)
|
|
87
|
+
const line = command.slice(p, end === -1 ? command.length : end)
|
|
88
|
+
if (line.trim() === delim) return end === -1 ? command.length : end
|
|
89
|
+
if (end === -1) return -1
|
|
90
|
+
p = end + 1
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Split a shell command into the individual commands it runs. Separators
|
|
96
|
+
* inside quotes, and heredoc bodies, are not command boundaries.
|
|
97
|
+
* @param {string} command
|
|
98
|
+
* @returns {string[]}
|
|
99
|
+
*/
|
|
100
|
+
export function segments(command) {
|
|
101
|
+
/** @type {string[]} */
|
|
102
|
+
const parts = []
|
|
103
|
+
let cur = ""
|
|
104
|
+
let quote = null
|
|
105
|
+
const flush = () => {
|
|
106
|
+
const t = cur.trim()
|
|
107
|
+
if (t) parts.push(t)
|
|
108
|
+
cur = ""
|
|
109
|
+
}
|
|
110
|
+
for (let i = 0; i < command.length; i++) {
|
|
111
|
+
const ch = command[i]
|
|
112
|
+
if (quote) {
|
|
113
|
+
cur += ch
|
|
114
|
+
if (ch === "\\" && quote === '"') {
|
|
115
|
+
if (i + 1 < command.length) cur += command[++i]
|
|
116
|
+
continue
|
|
117
|
+
}
|
|
118
|
+
if (ch === quote) quote = null
|
|
119
|
+
continue
|
|
120
|
+
}
|
|
121
|
+
if (ch === "\\") {
|
|
122
|
+
cur += ch
|
|
123
|
+
if (i + 1 < command.length) cur += command[++i]
|
|
124
|
+
continue
|
|
125
|
+
}
|
|
126
|
+
if (ch === "'" || ch === '"') {
|
|
127
|
+
quote = ch
|
|
128
|
+
cur += ch
|
|
129
|
+
continue
|
|
130
|
+
}
|
|
131
|
+
// Consume a heredoc body whole, separators and all, before any of its
|
|
132
|
+
// text can be mistaken for a command.
|
|
133
|
+
if (ch === "<" && command[i + 1] === "<") {
|
|
134
|
+
const end = heredocEnd(command, i)
|
|
135
|
+
if (end > i) {
|
|
136
|
+
cur += command.slice(i, end)
|
|
137
|
+
i = end - 1
|
|
138
|
+
continue
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
if (ch === "\n" || ch === ";") {
|
|
142
|
+
flush()
|
|
143
|
+
continue
|
|
144
|
+
}
|
|
145
|
+
if (ch === "|") {
|
|
146
|
+
flush()
|
|
147
|
+
if (command[i + 1] === "|") i++
|
|
148
|
+
continue
|
|
149
|
+
}
|
|
150
|
+
if (ch === "&" && command[i + 1] === "&") {
|
|
151
|
+
flush()
|
|
152
|
+
i++
|
|
153
|
+
continue
|
|
154
|
+
}
|
|
155
|
+
cur += ch
|
|
156
|
+
}
|
|
157
|
+
flush()
|
|
158
|
+
return parts
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Split one segment into words, honouring quotes so that a quoted phrase is
|
|
163
|
+
* one argument rather than several.
|
|
164
|
+
* @param {string} segment
|
|
165
|
+
* @returns {string[]}
|
|
166
|
+
*/
|
|
167
|
+
export function tokenize(segment) {
|
|
168
|
+
/** @type {string[]} */
|
|
169
|
+
const tokens = []
|
|
170
|
+
let cur = ""
|
|
171
|
+
let quote = null
|
|
172
|
+
let started = false
|
|
173
|
+
const flush = () => {
|
|
174
|
+
if (cur || started) tokens.push(cur)
|
|
175
|
+
cur = ""
|
|
176
|
+
started = false
|
|
177
|
+
}
|
|
178
|
+
for (let i = 0; i < segment.length; i++) {
|
|
179
|
+
const ch = segment[i]
|
|
180
|
+
if (quote) {
|
|
181
|
+
if (ch === "\\" && quote === '"') {
|
|
182
|
+
if (i + 1 < segment.length) cur += segment[++i]
|
|
183
|
+
continue
|
|
184
|
+
}
|
|
185
|
+
if (ch === quote) {
|
|
186
|
+
quote = null
|
|
187
|
+
continue
|
|
188
|
+
}
|
|
189
|
+
cur += ch
|
|
190
|
+
continue
|
|
191
|
+
}
|
|
192
|
+
if (ch === "\\" && i + 1 < segment.length) {
|
|
193
|
+
cur += segment[++i]
|
|
194
|
+
started = true
|
|
195
|
+
continue
|
|
196
|
+
}
|
|
197
|
+
if (ch === "'" || ch === '"') {
|
|
198
|
+
quote = ch
|
|
199
|
+
started = true
|
|
200
|
+
continue
|
|
201
|
+
}
|
|
202
|
+
if (/\s/.test(ch)) {
|
|
203
|
+
flush()
|
|
204
|
+
continue
|
|
205
|
+
}
|
|
206
|
+
cur += ch
|
|
207
|
+
}
|
|
208
|
+
flush()
|
|
209
|
+
return tokens
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const ENV_ASSIGN = /^[A-Za-z_][A-Za-z0-9_]*=/
|
|
213
|
+
const GIT_WRAPPERS = new Set(["env", "sudo", "doas", "nohup", "time", "command", "exec", "xargs"])
|
|
214
|
+
// Wrappers whose flag consumes the next word, e.g. `sudo -u deploy git push`.
|
|
215
|
+
const WRAPPER_VALUE_FLAGS = new Set(["-u", "--user", "-g", "--group", "-I", "-P", "-n", "-a", "-d"])
|
|
216
|
+
// git options that take a value, so the subcommand is the word after them.
|
|
217
|
+
const GIT_VALUE_FLAGS = new Set([
|
|
218
|
+
"-C",
|
|
219
|
+
"-c",
|
|
220
|
+
"--git-dir",
|
|
221
|
+
"--work-tree",
|
|
222
|
+
"--namespace",
|
|
223
|
+
"--super-prefix",
|
|
224
|
+
"--exec-path",
|
|
225
|
+
"--config-env",
|
|
226
|
+
])
|
|
227
|
+
const GIT_GLOBAL_FLAGS = new Set([
|
|
228
|
+
"--bare",
|
|
229
|
+
"--no-pager",
|
|
230
|
+
"--paginate",
|
|
231
|
+
"--no-replace-objects",
|
|
232
|
+
"--no-optional-locks",
|
|
233
|
+
"--literal-pathspecs",
|
|
234
|
+
"--glob-pathspecs",
|
|
235
|
+
"--icase-pathspecs",
|
|
236
|
+
])
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Skip the wrappers and environment assignments in front of a real command.
|
|
240
|
+
* `env FOO=1 sudo -u deploy git -C /repo push` resolves at index 4.
|
|
241
|
+
* @param {string[]} tokens
|
|
242
|
+
* @returns {number} index of the first real command word, or tokens.length
|
|
243
|
+
*/
|
|
244
|
+
function skipPrefixes(tokens) {
|
|
245
|
+
let i = 0
|
|
246
|
+
for (;;) {
|
|
247
|
+
const word = tokens[i]
|
|
248
|
+
if (word === undefined) return tokens.length
|
|
249
|
+
if (ENV_ASSIGN.test(word)) {
|
|
250
|
+
i++
|
|
251
|
+
continue
|
|
252
|
+
}
|
|
253
|
+
if (GIT_WRAPPERS.has(word)) {
|
|
254
|
+
i++
|
|
255
|
+
while (i < tokens.length && tokens[i].startsWith("-") && tokens[i] !== "-") {
|
|
256
|
+
const flag = tokens[i]
|
|
257
|
+
i++
|
|
258
|
+
if (WRAPPER_VALUE_FLAGS.has(flag) && i < tokens.length) i++
|
|
259
|
+
}
|
|
260
|
+
continue
|
|
261
|
+
}
|
|
262
|
+
return i
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* A segment with its prefixes removed, so a rule anchored at `^` sees the
|
|
268
|
+
* command it is actually about: `sudo rm -rf /` becomes `rm -rf /`.
|
|
269
|
+
* Segment rules are matched against this rather than the raw text, because
|
|
270
|
+
* without it every anchored rule is one `sudo` away from being decorative.
|
|
271
|
+
* @param {string} segment
|
|
272
|
+
* @returns {string} empty when the segment names no command
|
|
273
|
+
*/
|
|
274
|
+
export function stripPrefixes(segment) {
|
|
275
|
+
const tokens = tokenize(segment)
|
|
276
|
+
const i = skipPrefixes(tokens)
|
|
277
|
+
if (i >= tokens.length) return ""
|
|
278
|
+
const rest = tokens.slice(i)
|
|
279
|
+
const slash = Math.max(rest[0].lastIndexOf("/"), rest[0].lastIndexOf("\\"))
|
|
280
|
+
if (slash !== -1) rest[0] = rest[0].slice(slash + 1)
|
|
281
|
+
return rest.join(" ")
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Resolve the git subcommand a segment actually runs, or null if the segment
|
|
286
|
+
* does not run git.
|
|
287
|
+
* @param {string} segment
|
|
288
|
+
* @returns {string | null}
|
|
289
|
+
*/
|
|
290
|
+
export function gitSubcommand(segment) {
|
|
291
|
+
const tokens = tokenize(segment)
|
|
292
|
+
let i = skipPrefixes(tokens)
|
|
293
|
+
if (i >= tokens.length) return null
|
|
294
|
+
if (!/(^|[\\/])git(\.exe)?$/i.test(tokens[i])) return null
|
|
295
|
+
i++
|
|
296
|
+
// Skip only git's own global options. A flag that belongs to the
|
|
297
|
+
// subcommand (`git log --grep`) ends the search, or `git log --grep push`
|
|
298
|
+
// would resolve to `push`.
|
|
299
|
+
while (i < tokens.length) {
|
|
300
|
+
const flag = tokens[i]
|
|
301
|
+
if (GIT_VALUE_FLAGS.has(flag)) {
|
|
302
|
+
i += 2
|
|
303
|
+
continue
|
|
304
|
+
}
|
|
305
|
+
if (/^--[A-Za-z][\w-]*=/.test(flag) || GIT_GLOBAL_FLAGS.has(flag)) {
|
|
306
|
+
i++
|
|
307
|
+
continue
|
|
308
|
+
}
|
|
309
|
+
break
|
|
310
|
+
}
|
|
311
|
+
return tokens[i] ?? null
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const SHELL_BIN = /(^|[\\/])(sh|bash|zsh|dash|ksh)(\.exe)?$/i
|
|
315
|
+
const MAX_SHELL_DEPTH = 2
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* The command line hidden inside `sh -c "..."` or `eval "..."`.
|
|
319
|
+
* @param {string} segment
|
|
320
|
+
* @returns {string | null}
|
|
321
|
+
*/
|
|
322
|
+
export function shellPayload(segment) {
|
|
323
|
+
const tokens = tokenize(segment)
|
|
324
|
+
if (tokens.length < 2) return null
|
|
325
|
+
if (tokens[0] === "eval") return tokens.slice(1).join(" ") || null
|
|
326
|
+
if (!SHELL_BIN.test(tokens[0])) return null
|
|
327
|
+
const c = tokens.findIndex((t) => /^-[a-zA-Z]*c$/.test(t))
|
|
328
|
+
if (c === -1 || c === tokens.length - 1) return null
|
|
329
|
+
return tokens.slice(c + 1).join(" ") || null
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** @type {Rule[]} */
|
|
333
|
+
export const BASH_RULES = [
|
|
334
|
+
{
|
|
335
|
+
name: "secret exfiltration via shell",
|
|
336
|
+
scope: "command",
|
|
337
|
+
test: /\b(TOKEN|SECRET|KEY|PASSWORD|API_KEY)\s*=\s*\$\(\s*(curl|wget)\b/i,
|
|
338
|
+
why: "assigns a fetched value into a credential-named variable",
|
|
339
|
+
},
|
|
340
|
+
{
|
|
341
|
+
name: "remote delete over ssh",
|
|
342
|
+
scope: "command",
|
|
343
|
+
test: /\bssh\b[^|;&]*\brm\s+-[a-zA-Z]*[rf]/i,
|
|
344
|
+
why: "destructive command executed on another host",
|
|
345
|
+
},
|
|
346
|
+
{
|
|
347
|
+
name: "pipe remote script into a shell",
|
|
348
|
+
scope: "command",
|
|
349
|
+
test: /\b(curl|wget)\b[^|]*\|\s*(sudo\s+)?(sh|bash|zsh|python[0-9.]*)\b/i,
|
|
350
|
+
why: "downloads and executes a script in one step",
|
|
351
|
+
},
|
|
352
|
+
{
|
|
353
|
+
name: "git push with force",
|
|
354
|
+
scope: "segment",
|
|
355
|
+
// A dry run publishes nothing, so it is not the thing being gated.
|
|
356
|
+
check: (seg) =>
|
|
357
|
+
gitSubcommand(seg) === "push" &&
|
|
358
|
+
tokenize(seg).some((w) => w === "--force" || w === "-f" || w.startsWith("--force")),
|
|
359
|
+
why: "overwrites published history",
|
|
360
|
+
},
|
|
361
|
+
{
|
|
362
|
+
name: "git push",
|
|
363
|
+
scope: "segment",
|
|
364
|
+
// Flags are matched as whole words: a `-f` in `my-feature-f` is a branch
|
|
365
|
+
// name, not a force flag.
|
|
366
|
+
check: (seg) =>
|
|
367
|
+
gitSubcommand(seg) === "push" && !tokenize(seg).some((w) => w === "--dry-run" || w === "-n"),
|
|
368
|
+
why: "publishing is a human decision",
|
|
369
|
+
},
|
|
370
|
+
{
|
|
371
|
+
name: "recursive delete",
|
|
372
|
+
scope: "segment",
|
|
373
|
+
test: /^rm\s+(-[a-zA-Z]*[rf][a-zA-Z]*)\s+/i,
|
|
374
|
+
why: "rm with a recursive or force flag",
|
|
375
|
+
},
|
|
376
|
+
{
|
|
377
|
+
name: "chmod 777",
|
|
378
|
+
scope: "segment",
|
|
379
|
+
test: /^chmod\s+(-[a-zA-Z]+\s+)*777\b/,
|
|
380
|
+
why: "opens permissions on everything",
|
|
381
|
+
},
|
|
382
|
+
{
|
|
383
|
+
name: "history rewrite",
|
|
384
|
+
scope: "segment",
|
|
385
|
+
test: /^git\s+(reset\s+--hard|clean\s+-[a-zA-Z]*f|rebase\s+.*--force)/i,
|
|
386
|
+
why: "destroys work that only exists locally",
|
|
387
|
+
},
|
|
388
|
+
]
|
|
389
|
+
|
|
390
|
+
/** @type {Rule[]} */
|
|
391
|
+
export const WRITE_RULES = [
|
|
392
|
+
{
|
|
393
|
+
// `unless` exists because the safe variants are the same shape as the
|
|
394
|
+
// dangerous ones: `.env.example` is a template meant to be committed, and
|
|
395
|
+
// protecting it would block ordinary work.
|
|
396
|
+
name: "write to a dotenv file",
|
|
397
|
+
test: /(^|[\\/])\.env(\.|$)/i,
|
|
398
|
+
unless: /(^|[\\/])\.env\.(example|sample|template|defaults?)\b/i,
|
|
399
|
+
why: "dotenv files hold live credentials",
|
|
400
|
+
},
|
|
401
|
+
{
|
|
402
|
+
name: "write to a credentials file",
|
|
403
|
+
test: /(^|[\\/])\.(npmrc|pypirc|netrc|git-credentials)(\.|$)/i,
|
|
404
|
+
unless: /(^|[\\/])\.(npmrc|pypirc)\.example$/i,
|
|
405
|
+
why: "registry and git credentials",
|
|
406
|
+
},
|
|
407
|
+
{
|
|
408
|
+
name: "write to a private key",
|
|
409
|
+
test: /(^|[\\/])id_(rsa|ed25519|ecdsa|dsa)\b/i,
|
|
410
|
+
why: "private key material",
|
|
411
|
+
},
|
|
412
|
+
]
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* @param {Rule} rule
|
|
416
|
+
* @param {string} text
|
|
417
|
+
* @returns {boolean}
|
|
418
|
+
*/
|
|
419
|
+
function matches(rule, text) {
|
|
420
|
+
if (rule.check) return rule.check(text)
|
|
421
|
+
if (!rule.test?.test(text)) return false
|
|
422
|
+
return !rule.unless?.test(text)
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* @param {string} command
|
|
427
|
+
* @param {Rule[]} [rules]
|
|
428
|
+
* @param {number} [depth]
|
|
429
|
+
* @returns {Hit | null}
|
|
430
|
+
*/
|
|
431
|
+
export function checkBash(command, rules = BASH_RULES, depth = 0) {
|
|
432
|
+
if (!command || !command.trim()) return null
|
|
433
|
+
const parts = segments(command)
|
|
434
|
+
// Segment rules see the segment with its prefixes stripped, so `sudo rm -rf`
|
|
435
|
+
// and `env X=1 git reset --hard` are judged as the commands they run. The
|
|
436
|
+
// reported segment stays raw, because that is what the agent needs to see.
|
|
437
|
+
const normalized = parts.map((seg) => stripPrefixes(seg))
|
|
438
|
+
for (const rule of rules) {
|
|
439
|
+
if (rule.scope === "command") {
|
|
440
|
+
if (matches(rule, command)) return { name: rule.name, why: rule.why, segment: command }
|
|
441
|
+
continue
|
|
442
|
+
}
|
|
443
|
+
for (let i = 0; i < parts.length; i++) {
|
|
444
|
+
if (matches(rule, normalized[i])) return { name: rule.name, why: rule.why, segment: parts[i] }
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
// `bash -c "git push"` hides an entire command line behind one argument, so
|
|
448
|
+
// the rules have to be run again over the payload.
|
|
449
|
+
if (depth < MAX_SHELL_DEPTH) {
|
|
450
|
+
for (const seg of parts) {
|
|
451
|
+
const payload = shellPayload(seg)
|
|
452
|
+
if (!payload) continue
|
|
453
|
+
const hit = checkBash(payload, rules, depth + 1)
|
|
454
|
+
if (hit) return hit
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
return null
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* @param {string} target
|
|
462
|
+
* @param {Rule[]} [rules]
|
|
463
|
+
* @param {string} [root] the project directory, when the host knows it
|
|
464
|
+
* @returns {Hit | null}
|
|
465
|
+
*/
|
|
466
|
+
export function checkWrite(target, rules = WRITE_RULES, root = "") {
|
|
467
|
+
if (!target) return null
|
|
468
|
+
for (const rule of rules) {
|
|
469
|
+
if (!matches(rule, target)) continue
|
|
470
|
+
return { name: rule.name, why: rule.why, segment: target }
|
|
471
|
+
}
|
|
472
|
+
// Scope check, last: a path outside the project is still fine if it is a
|
|
473
|
+
// secret file we are refusing for a stronger reason.
|
|
474
|
+
//
|
|
475
|
+
// The OS temp directory is allowed. This kit tells agents to put scratch
|
|
476
|
+
// files in a temp dir outside the workspace, so forbidding every path outside
|
|
477
|
+
// the project would block a convention the harness itself sets.
|
|
478
|
+
//
|
|
479
|
+
// An unknown root is allowed through rather than blocked. This rule is a
|
|
480
|
+
// scope guard, not a safety guard -- blocking every write when the host did
|
|
481
|
+
// not tell us the worktree would make the tool unusable, and the host
|
|
482
|
+
// (opencode) always passes one.
|
|
483
|
+
if (root && !insideRoot(target, root) && !insideRoot(target, tmpdir())) {
|
|
484
|
+
return {
|
|
485
|
+
name: "write outside the project",
|
|
486
|
+
why: `this project's files are the ones you were asked to change, and ${root} is where they live`,
|
|
487
|
+
segment: target,
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
return null
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/** Names of every rule, for `oc-harness show` and documentation. */
|
|
494
|
+
export function ruleNames() {
|
|
495
|
+
return [...BASH_RULES, ...WRITE_RULES].map((r) => r.name)
|
|
496
|
+
}
|