@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.
@@ -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
+ }