@dsh-cc/plugin-cc-grok-bridge 0.8.1
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/.claude-plugin/plugin.json +5 -0
- package/LICENSE +201 -0
- package/README.i18n.yaml +6 -0
- package/README.md +120 -0
- package/README.zh.md +97 -0
- package/commands/review.md +25 -0
- package/hooks/grok-review-allow.mjs +75 -0
- package/hooks/grok-review-context.mjs +38 -0
- package/hooks/hooks.json +27 -0
- package/package.json +22 -0
- package/scripts/grok-review-run.mjs +508 -0
- package/scripts/lib/argv.mjs +380 -0
- package/scripts/lib/canonical.mjs +103 -0
- package/scripts/lib/home.mjs +240 -0
- package/scripts/lib/lexer.mjs +145 -0
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shadow-state primitives for the cc-grok-bridge launcher (§3.1 steps 5–6):
|
|
3
|
+
* canonical per-cwd state home under a uid-suffixed canonical-tmpdir
|
|
4
|
+
* subroot, fail-loud ownership/mode validation, a single-flight lock with
|
|
5
|
+
* heartbeat (NO signal/exit handlers inside this module — the launcher owns
|
|
6
|
+
* the lifecycle and receives a sync `release()`), the D14 allowlist sweep
|
|
7
|
+
* (runs BEFORE the credential sync), the D19 nofollow atomic credential
|
|
8
|
+
* sync of `auth.json` only, and the D10 child-env builder.
|
|
9
|
+
*/
|
|
10
|
+
import {
|
|
11
|
+
closeSync,
|
|
12
|
+
constants as fsConstants,
|
|
13
|
+
fstatSync,
|
|
14
|
+
lstatSync,
|
|
15
|
+
mkdirSync,
|
|
16
|
+
openSync,
|
|
17
|
+
readFileSync,
|
|
18
|
+
readdirSync,
|
|
19
|
+
renameSync,
|
|
20
|
+
rmSync,
|
|
21
|
+
realpathSync,
|
|
22
|
+
utimesSync,
|
|
23
|
+
writeFileSync,
|
|
24
|
+
writeSync,
|
|
25
|
+
fsyncSync,
|
|
26
|
+
} from 'node:fs'
|
|
27
|
+
import { createHash, randomBytes, randomUUID } from 'node:crypto'
|
|
28
|
+
import os from 'node:os'
|
|
29
|
+
import path from 'node:path'
|
|
30
|
+
|
|
31
|
+
const LOCK_STALE_MS = 6 * 60 * 60 * 1000
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* R = <canonical tmpdir>/grok-review-home-<uid>, H = R/sha256(realpath(cwd))
|
|
35
|
+
* (canonicalize FIRST — macOS /var and /tmp are symlinks and every
|
|
36
|
+
* path-boundary comparison is against canonical realpaths).
|
|
37
|
+
*/
|
|
38
|
+
export function homePaths({ cwd = process.cwd(), tmpdir = os.tmpdir() } = {}) {
|
|
39
|
+
const tmp = realpathSync(tmpdir)
|
|
40
|
+
const R = path.join(tmp, `grok-review-home-${process.getuid()}`)
|
|
41
|
+
const H = path.join(
|
|
42
|
+
R,
|
|
43
|
+
createHash('sha256').update(realpathSync(cwd)).digest('hex').slice(0, 16),
|
|
44
|
+
)
|
|
45
|
+
return { tmp, R, H }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function assertOwnPrivateDir(dir, label) {
|
|
49
|
+
const st = lstatSync(dir) // throws if missing → fail loud
|
|
50
|
+
if (st.isSymbolicLink()) throw new Error(`${label} ${dir} is a symlink`)
|
|
51
|
+
if (!st.isDirectory()) throw new Error(`${label} ${dir} is not a directory`)
|
|
52
|
+
if (st.uid !== process.getuid()) throw new Error(`${label} ${dir} is not owned by the current uid`)
|
|
53
|
+
if ((st.mode & 0o777) !== 0o700) throw new Error(`${label} ${dir} mode is not 0700`)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Parent chain: only directories-that-are-not-symlinks are checked — a
|
|
58
|
+
* root-owned /tmp is normal and must not fail.
|
|
59
|
+
*/
|
|
60
|
+
function assertPlainAncestor(dir) {
|
|
61
|
+
const st = lstatSync(dir)
|
|
62
|
+
if (st.isSymbolicLink()) throw new Error(`parent ${dir} is a symlink`)
|
|
63
|
+
if (!st.isDirectory()) throw new Error(`parent ${dir} is not a directory`)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** mkdir -m 0700 R and H, then lstat-validate both. */
|
|
67
|
+
export function prepareHome(opts = {}) {
|
|
68
|
+
const { tmp, R, H } = homePaths(opts)
|
|
69
|
+
let parent = path.dirname(R)
|
|
70
|
+
while (parent !== path.dirname(parent)) {
|
|
71
|
+
assertPlainAncestor(parent)
|
|
72
|
+
parent = path.dirname(parent)
|
|
73
|
+
}
|
|
74
|
+
mkdirSync(R, { mode: 0o700, recursive: true })
|
|
75
|
+
assertOwnPrivateDir(R, 'state subroot')
|
|
76
|
+
mkdirSync(H, { mode: 0o700, recursive: true })
|
|
77
|
+
assertOwnPrivateDir(H, 'workspace home')
|
|
78
|
+
return { tmp, R, H }
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Acquire the single-flight lock `mkdir H/.lock`. Carries an owner nonce,
|
|
83
|
+
* is heartbeat-touched while held, stale (>6h mtime) is reclaimed. This is
|
|
84
|
+
* an accidental-concurrency guard for cooperative callers, NOT an
|
|
85
|
+
* adversarial cost cap — a same-UID process can always delete it or run
|
|
86
|
+
* grok directly. NO signal/exit handlers are wired here (D18): the
|
|
87
|
+
* launcher registers its own lifecycle and calls the returned sync
|
|
88
|
+
* `release()` in its finalize path.
|
|
89
|
+
*/
|
|
90
|
+
export function acquireLock(H) {
|
|
91
|
+
const lockDir = path.join(H, '.lock')
|
|
92
|
+
try {
|
|
93
|
+
mkdirSync(lockDir, { mode: 0o700 })
|
|
94
|
+
} catch {
|
|
95
|
+
const st = lstatSync(lockDir)
|
|
96
|
+
if (Date.now() - st.mtimeMs > LOCK_STALE_MS) {
|
|
97
|
+
rmSync(lockDir, { recursive: true, force: true })
|
|
98
|
+
mkdirSync(lockDir, { mode: 0o700 })
|
|
99
|
+
} else {
|
|
100
|
+
throw new Error(`another launch holds ${lockDir} (not stale)`)
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
writeFileSync(path.join(lockDir, 'owner'), `${randomUUID()}\n`, { mode: 0o600 })
|
|
104
|
+
const heartbeat = setInterval(() => {
|
|
105
|
+
try {
|
|
106
|
+
utimesSync(lockDir, new Date(), new Date())
|
|
107
|
+
} catch {
|
|
108
|
+
/* lock vanished underneath us — release will notice too */
|
|
109
|
+
}
|
|
110
|
+
}, 60_000)
|
|
111
|
+
heartbeat.unref() // hygiene handle: never keep a process alive by itself
|
|
112
|
+
let released = false
|
|
113
|
+
return () => {
|
|
114
|
+
if (released) return
|
|
115
|
+
released = true
|
|
116
|
+
clearInterval(heartbeat)
|
|
117
|
+
try {
|
|
118
|
+
rmSync(lockDir, { recursive: true, force: true })
|
|
119
|
+
} catch {
|
|
120
|
+
/* best-effort */
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* D14 allowlist sweep (probe P13): enumerate H's children; keep EXACTLY
|
|
127
|
+
* `auth.json`, `sessions`, `.lock`; remove (recursive force) everything
|
|
128
|
+
* else. Kept names are lstat-type-validated: `sessions` absent-or-real-dir,
|
|
129
|
+
* `auth.json` absent-or-regular-file — symlink/foreign types are removed.
|
|
130
|
+
* Any sweep failure is fail-closed (throws — the sweep is a security
|
|
131
|
+
* control, not hygiene).
|
|
132
|
+
*/
|
|
133
|
+
export function sweepHome(H) {
|
|
134
|
+
for (const name of readdirSync(H)) {
|
|
135
|
+
if (name === '.lock') continue
|
|
136
|
+
const p = path.join(H, name)
|
|
137
|
+
if (name === 'auth.json' || name === 'sessions') {
|
|
138
|
+
let st
|
|
139
|
+
try {
|
|
140
|
+
st = lstatSync(p)
|
|
141
|
+
} catch {
|
|
142
|
+
continue // vanished between readdir and lstat — nothing to sweep
|
|
143
|
+
}
|
|
144
|
+
const valid = name === 'sessions' ? st.isDirectory() : st.isFile()
|
|
145
|
+
if (valid && !st.isSymbolicLink()) continue
|
|
146
|
+
}
|
|
147
|
+
rmSync(p, { recursive: true, force: true })
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* D19 credential sync inside the lock: ONLY `auth.json`, source
|
|
153
|
+
* `os.homedir()/.grok/auth.json`. Source lstat regular-file → O_NOFOLLOW
|
|
154
|
+
* open → fstat THAT fd for regular-file again → write H/.tmp-<random> 0600
|
|
155
|
+
* → fsync → atomic rename (overwrite). Source errors in
|
|
156
|
+
* {ENOENT, ENOTDIR, EACCES, EPERM} ⇒ missing/unreadable class: DELETE the
|
|
157
|
+
* shadow copy and warn on stderr ONLY when ambient XAI_API_KEY is unset;
|
|
158
|
+
* any other error aborts (throws).
|
|
159
|
+
*
|
|
160
|
+
* Source home is os.homedir() — POSIX honors $HOME, which is the test seam.
|
|
161
|
+
*/
|
|
162
|
+
const MISSING_CLASS = new Set(['ENOENT', 'ENOTDIR', 'EACCES', 'EPERM'])
|
|
163
|
+
|
|
164
|
+
const fsIo = {
|
|
165
|
+
lstatSync,
|
|
166
|
+
openSync,
|
|
167
|
+
fstatSync,
|
|
168
|
+
readFileSync,
|
|
169
|
+
writeSync,
|
|
170
|
+
closeSync,
|
|
171
|
+
fsyncSync,
|
|
172
|
+
renameSync,
|
|
173
|
+
rmSync,
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* `io` injects the fs-level ops (unit-test seam: post-open fstat swap,
|
|
178
|
+
* .tmp residue on rename failure); production default is the real fs.
|
|
179
|
+
*/
|
|
180
|
+
export function syncCredentials(H, { home = os.homedir(), env = process.env, warn = (m) => console.error(m), io = fsIo } = {}) {
|
|
181
|
+
const src = path.join(home, '.grok', 'auth.json')
|
|
182
|
+
const dst = path.join(H, 'auth.json')
|
|
183
|
+
let st
|
|
184
|
+
try {
|
|
185
|
+
st = io.lstatSync(src)
|
|
186
|
+
} catch (e) {
|
|
187
|
+
if (MISSING_CLASS.has(e?.code)) {
|
|
188
|
+
io.rmSync(dst, { force: true })
|
|
189
|
+
if (!env.XAI_API_KEY) {
|
|
190
|
+
warn(`grok-review: source ${src} missing/unreadable — removed shadow copy`)
|
|
191
|
+
}
|
|
192
|
+
return
|
|
193
|
+
}
|
|
194
|
+
throw e
|
|
195
|
+
}
|
|
196
|
+
if (st.isSymbolicLink() || !st.isFile()) {
|
|
197
|
+
throw new Error(`source ${src} is not a regular file`)
|
|
198
|
+
}
|
|
199
|
+
// O_NOFOLLOW at open: even if src were swapped to a symlink between the
|
|
200
|
+
// lstat above and here, the open itself refuses to follow it.
|
|
201
|
+
const rfd = io.openSync(src, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW)
|
|
202
|
+
try {
|
|
203
|
+
// Post-open fstat recheck on the SAME fd.
|
|
204
|
+
if (!io.fstatSync(rfd).isFile()) throw new Error(`source ${src} is not a regular file`)
|
|
205
|
+
const data = io.readFileSync(rfd)
|
|
206
|
+
const tmpDst = path.join(H, `.tmp-${randomBytes(8).toString('hex')}`)
|
|
207
|
+
const wfd = io.openSync(
|
|
208
|
+
tmpDst,
|
|
209
|
+
fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL | fsConstants.O_NOFOLLOW,
|
|
210
|
+
0o600,
|
|
211
|
+
)
|
|
212
|
+
try {
|
|
213
|
+
io.writeSync(wfd, data)
|
|
214
|
+
io.fsyncSync(wfd)
|
|
215
|
+
io.renameSync(tmpDst, dst) // atomic, overwrite always
|
|
216
|
+
} catch (e) {
|
|
217
|
+
try {
|
|
218
|
+
io.rmSync(tmpDst, { force: true })
|
|
219
|
+
} catch {
|
|
220
|
+
/* best-effort residue cleanup */
|
|
221
|
+
}
|
|
222
|
+
throw e
|
|
223
|
+
} finally {
|
|
224
|
+
io.closeSync(wfd)
|
|
225
|
+
}
|
|
226
|
+
} finally {
|
|
227
|
+
io.closeSync(rfd)
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* D10 child env: pass the ambient env through with GROK_HOME pointed at the
|
|
233
|
+
* shadow home, minus GROK_SESSION_ID / GROK_AGENT (session claims) and
|
|
234
|
+
* GROK_SANDBOX (undefined profiles fail closed, probe P10). Pure helper —
|
|
235
|
+
* never mutates the caller's env object.
|
|
236
|
+
*/
|
|
237
|
+
export function buildChildEnv(env, H) {
|
|
238
|
+
const { GROK_SESSION_ID: _s, GROK_AGENT: _a, GROK_SANDBOX: _x, ...rest } = env
|
|
239
|
+
return { ...rest, GROK_HOME: H }
|
|
240
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* §3.2-a mini POSIX-shell lexer for the codex-rescue-bridge matcher.
|
|
3
|
+
*
|
|
4
|
+
* Grammar (hostile-input constraints, all strict):
|
|
5
|
+
*
|
|
6
|
+
* command := word (blank+ word)* EOF-without-operator
|
|
7
|
+
* word := segment* ; segments concatenate into one word
|
|
8
|
+
* segment := unquoted | '\'' single-quoted '\'' | '"' double-quoted '"'
|
|
9
|
+
* | '\' escape | '$' expansion | '`' | '~' | glob
|
|
10
|
+
*
|
|
11
|
+
* Rejections (fail closed, machine-readable `reason`):
|
|
12
|
+
* - any UNQUOTED operator: `;` `&&` `||` `|` `>` `<` newline -> 'unquoted-operator'
|
|
13
|
+
* - env-assignment prefix on the first word (`FOO=1 cmd`) -> 'env-assignment-prefix'
|
|
14
|
+
* - command substitution in UNQUOTED context ($(), backticks) -> 'command-substitution-unquoted'
|
|
15
|
+
* - command substitution in DOUBLE-QUOTED context -> 'command-substitution-double-quoted'
|
|
16
|
+
* (bash evaluates those inside double quotes; SINGLE-QUOTED bytes are pure
|
|
17
|
+
* data and pass — this is what lets hostile install paths be expressed)
|
|
18
|
+
* - unclosed quote -> 'unclosed-quote'
|
|
19
|
+
*
|
|
20
|
+
* Expansion-bearing words (tilde at word start, glob `*?[`, `$VAR`/`${...}`,
|
|
21
|
+
* `$` of any kind) are flagged with `expansion: true` so the matcher can
|
|
22
|
+
* require argv[0]/argv[1] to be expansion-free literal bytes.
|
|
23
|
+
*
|
|
24
|
+
* One word may concatenate quoted and unquoted segments ('a'\''b' is the
|
|
25
|
+
* POSIX spelling of a literal `a'b`) — byte equality of the concatenated
|
|
26
|
+
* text is what the matcher pins, never the quoting.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
export function lexCommand(raw) {
|
|
30
|
+
const fail = (reason) => ({ ok: false, layer: 'lexer', reason })
|
|
31
|
+
const argv = []
|
|
32
|
+
let word = null // { text, expansion } | null
|
|
33
|
+
const flush = () => {
|
|
34
|
+
if (word !== null) {
|
|
35
|
+
argv.push(word)
|
|
36
|
+
word = null
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
let i = 0
|
|
40
|
+
while (i < raw.length) {
|
|
41
|
+
const c = raw[i]
|
|
42
|
+
if (c === ' ' || c === '\t') {
|
|
43
|
+
flush()
|
|
44
|
+
i++
|
|
45
|
+
continue
|
|
46
|
+
}
|
|
47
|
+
if (c === '\n' || c === '\r') {
|
|
48
|
+
flush()
|
|
49
|
+
if (argv.length > 0) return fail('unquoted-operator')
|
|
50
|
+
i++
|
|
51
|
+
continue
|
|
52
|
+
}
|
|
53
|
+
if (';|&<>'.includes(c)) {
|
|
54
|
+
return fail('unquoted-operator')
|
|
55
|
+
}
|
|
56
|
+
if (c === '#' && word === null) {
|
|
57
|
+
// comment: skip to end of line
|
|
58
|
+
while (i < raw.length && raw[i] !== '\n') i++
|
|
59
|
+
continue
|
|
60
|
+
}
|
|
61
|
+
if (word === null) word = { text: '', expansion: false }
|
|
62
|
+
while (i < raw.length) {
|
|
63
|
+
const ch = raw[i]
|
|
64
|
+
if (ch === '\'') {
|
|
65
|
+
// single-quoted segment: pure data, no expansions ever
|
|
66
|
+
i++
|
|
67
|
+
const start = i
|
|
68
|
+
while (i < raw.length && raw[i] !== '\'') i++
|
|
69
|
+
if (i >= raw.length) return fail('unclosed-quote')
|
|
70
|
+
word.text += raw.slice(start, i)
|
|
71
|
+
i++ // closing quote
|
|
72
|
+
continue
|
|
73
|
+
}
|
|
74
|
+
if (ch === '"') {
|
|
75
|
+
// double-quoted segment: bash still evaluates $() and backticks here
|
|
76
|
+
i++
|
|
77
|
+
while (i < raw.length && raw[i] !== '"') {
|
|
78
|
+
const d = raw[i]
|
|
79
|
+
if (d === '\\') {
|
|
80
|
+
if (i + 1 >= raw.length) return fail('unclosed-quote')
|
|
81
|
+
const n = raw[i + 1]
|
|
82
|
+
if (n === '$' || n === '`' || n === '"' || n === '\\') {
|
|
83
|
+
word.text += n
|
|
84
|
+
i += 2
|
|
85
|
+
} else {
|
|
86
|
+
word.text += d
|
|
87
|
+
i++
|
|
88
|
+
}
|
|
89
|
+
continue
|
|
90
|
+
}
|
|
91
|
+
if (d === '`') return fail('command-substitution-double-quoted')
|
|
92
|
+
if (d === '$') {
|
|
93
|
+
if (raw[i + 1] === '(') return fail('command-substitution-double-quoted')
|
|
94
|
+
word.expansion = true
|
|
95
|
+
word.text += d
|
|
96
|
+
i++
|
|
97
|
+
continue
|
|
98
|
+
}
|
|
99
|
+
// quoted newline is data here; the argv grammar rejects it in prompts
|
|
100
|
+
word.text += d
|
|
101
|
+
i++
|
|
102
|
+
}
|
|
103
|
+
if (i >= raw.length) return fail('unclosed-quote')
|
|
104
|
+
i++ // closing quote
|
|
105
|
+
continue
|
|
106
|
+
}
|
|
107
|
+
if (ch === '$') {
|
|
108
|
+
if (raw[i + 1] === '(') return fail('command-substitution-unquoted')
|
|
109
|
+
word.expansion = true
|
|
110
|
+
word.text += ch
|
|
111
|
+
i++
|
|
112
|
+
continue
|
|
113
|
+
}
|
|
114
|
+
if (ch === '`') return fail('command-substitution-unquoted')
|
|
115
|
+
if (ch === '\\' && i + 1 < raw.length) {
|
|
116
|
+
// escape: the next byte is a literal (a backslash-newline is a
|
|
117
|
+
// continuation, not an operator)
|
|
118
|
+
word.text += raw[i + 1]
|
|
119
|
+
i += 2
|
|
120
|
+
continue
|
|
121
|
+
}
|
|
122
|
+
if (ch === '~' && word.text === '') {
|
|
123
|
+
// tilde expansion only at the start of an unquoted word
|
|
124
|
+
word.expansion = true
|
|
125
|
+
word.text += ch
|
|
126
|
+
i++
|
|
127
|
+
continue
|
|
128
|
+
}
|
|
129
|
+
if ('*?['.includes(ch)) {
|
|
130
|
+
word.expansion = true
|
|
131
|
+
word.text += ch
|
|
132
|
+
i++
|
|
133
|
+
continue
|
|
134
|
+
}
|
|
135
|
+
if (' \t\n\r;|&<>'.includes(ch)) break // back to outer loop, which rules on it
|
|
136
|
+
word.text += ch
|
|
137
|
+
i++
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
flush()
|
|
141
|
+
if (argv.length > 0 && /^[A-Za-z_][A-Za-z0-9_]*=/.test(argv[0].text)) {
|
|
142
|
+
return fail('env-assignment-prefix')
|
|
143
|
+
}
|
|
144
|
+
return { ok: true, layer: 'lexer', argv }
|
|
145
|
+
}
|