@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,380 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* §3.2-d pinned argv grammar for the cc-grok-bridge (one shared parser
|
|
3
|
+
* source for matcher and launcher; the exported FIXTURES table is the
|
|
4
|
+
* single fixture suite the hook tests reuse).
|
|
5
|
+
*
|
|
6
|
+
* invocation := NODE LAUNCHER [--last] prompt
|
|
7
|
+
* prompt := '--prompt-file' PATH | '--' SINGLE_LINE_NONEMPTY_PROMPT
|
|
8
|
+
*
|
|
9
|
+
* Rules:
|
|
10
|
+
* - NODE and LAUNCHER are expansion-free literal tokens byte-equal to the
|
|
11
|
+
* registration-time canonical paths (a symlink literal, a tilde/glob/$VAR
|
|
12
|
+
* expansion, or a PATH-resolved name never byte-equals a canonical
|
|
13
|
+
* absolute literal — indirection dies here).
|
|
14
|
+
* - `--last` appears at most once, only in the slot shown.
|
|
15
|
+
* - the positional prompt is a single literal token, never empty, contains
|
|
16
|
+
* no `\n` or `\r`, and its FIRST CHARACTER MUST NOT BE `-`: the prompt
|
|
17
|
+
* rides the child argv as the `-p` value, so a dash-leading word would be
|
|
18
|
+
* re-parsed as a flag by the Grok CLI (probed: clap misparse, exit 2) —
|
|
19
|
+
* dash-leading prompts must use the `--prompt-file` form instead.
|
|
20
|
+
* - PATH for --prompt-file is non-empty; containment is checked at match
|
|
21
|
+
* time by the hook, not here.
|
|
22
|
+
* - unknown shapes fail closed with a machine-readable `reason`.
|
|
23
|
+
*/
|
|
24
|
+
import { lexCommand } from './lexer.mjs'
|
|
25
|
+
|
|
26
|
+
/** Example canonical anchor pair used by the shared fixture table. */
|
|
27
|
+
export const CANONICAL_NODE = '/usr/local/bin/node'
|
|
28
|
+
export const CANONICAL_LAUNCHER = '/opt/dsh-cc/cc-grok-bridge/scripts/grok-review-run.mjs'
|
|
29
|
+
|
|
30
|
+
const fail = (reason) => ({ ok: false, layer: 'argv', reason })
|
|
31
|
+
const ok = (value) => ({ ok: true, layer: 'argv', value })
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Parse a lexer-resolved argv (words with { text, expansion }) against the
|
|
35
|
+
* pinned grammar. `node`/`launcher` are the byte-pinned canonical anchors.
|
|
36
|
+
*/
|
|
37
|
+
export function parseArgv(argv, { node, launcher }) {
|
|
38
|
+
if (argv.length < 2) return fail('missing-anchor')
|
|
39
|
+
if (argv[0].expansion) return fail('expansion-in-argv0')
|
|
40
|
+
if (argv[1].expansion) return fail('expansion-in-argv1')
|
|
41
|
+
if (argv[0].text !== node) return fail('argv0-byte-mismatch')
|
|
42
|
+
if (argv[1].text !== launcher) return fail('argv1-byte-mismatch')
|
|
43
|
+
let i = 2
|
|
44
|
+
let last = false
|
|
45
|
+
if (argv[i] !== undefined && argv[i].text === '--last') {
|
|
46
|
+
last = true
|
|
47
|
+
i++
|
|
48
|
+
}
|
|
49
|
+
const rest = argv.slice(i)
|
|
50
|
+
if (rest.length === 0) return fail('missing-prompt')
|
|
51
|
+
if (rest[0].text === '--prompt-file') {
|
|
52
|
+
if (rest.length < 2 || rest[1].text === '') return fail('missing-prompt-file-path')
|
|
53
|
+
if (rest.length > 2) return fail('trailing-arguments')
|
|
54
|
+
return ok({ last, prompt: { kind: 'prompt-file', path: rest[1].text } })
|
|
55
|
+
}
|
|
56
|
+
if (rest[0].text !== '--') return fail('unexpected-token')
|
|
57
|
+
if (rest.length < 2) return fail('missing-prompt')
|
|
58
|
+
if (rest.length > 2) return fail('trailing-arguments')
|
|
59
|
+
const prompt = rest[1].text
|
|
60
|
+
if (prompt === '') return fail('empty-prompt')
|
|
61
|
+
if (prompt.includes('\n') || prompt.includes('\r')) return fail('control-char-in-prompt')
|
|
62
|
+
if (prompt.startsWith('-')) return fail('dash-leading-inline-prompt')
|
|
63
|
+
return ok({ last, prompt: { kind: 'inline', text: prompt } })
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Full matcher pipeline: lex the raw bash command to exactly one simple
|
|
68
|
+
* command, then pin its argv to the grammar.
|
|
69
|
+
*/
|
|
70
|
+
export function matchInvocation(raw, { node = CANONICAL_NODE, launcher = CANONICAL_LAUNCHER } = {}) {
|
|
71
|
+
const lex = lexCommand(raw)
|
|
72
|
+
if (!lex.ok) return lex
|
|
73
|
+
return parseArgv(lex.argv, { node, launcher })
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Shared fixture table (one-shared-parser-source rule). Every row runs
|
|
78
|
+
* through matchInvocation; positive rows pin the normalized parse, negative
|
|
79
|
+
* rows carry `reason` = the violated rule. `layer` documents which layer
|
|
80
|
+
* rejects (or accepts) the row; lexer-layer rows fail regardless of anchors.
|
|
81
|
+
*/
|
|
82
|
+
export const FIXTURES = [
|
|
83
|
+
// ---- positive: pinned normalized shapes ----
|
|
84
|
+
{
|
|
85
|
+
name: 'inline prompt',
|
|
86
|
+
layer: 'argv',
|
|
87
|
+
input: `${CANONICAL_NODE} ${CANONICAL_LAUNCHER} -- "review the failing spec"`,
|
|
88
|
+
ok: true,
|
|
89
|
+
value: { last: false, prompt: { kind: 'inline', text: 'review the failing spec' } },
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
name: 'inline prompt with --last in its only slot',
|
|
93
|
+
layer: 'argv',
|
|
94
|
+
input: `${CANONICAL_NODE} ${CANONICAL_LAUNCHER} --last -- "continue the review"`,
|
|
95
|
+
ok: true,
|
|
96
|
+
value: { last: true, prompt: { kind: 'inline', text: 'continue the review' } },
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
name: 'prompt-file form',
|
|
100
|
+
layer: 'argv',
|
|
101
|
+
input: `${CANONICAL_NODE} ${CANONICAL_LAUNCHER} --prompt-file /tmp/prompts/p.txt`,
|
|
102
|
+
ok: true,
|
|
103
|
+
value: { last: false, prompt: { kind: 'prompt-file', path: '/tmp/prompts/p.txt' } },
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
name: 'prompt-file form with --last',
|
|
107
|
+
layer: 'argv',
|
|
108
|
+
input: `${CANONICAL_NODE} ${CANONICAL_LAUNCHER} --last --prompt-file /tmp/prompts/p.txt`,
|
|
109
|
+
ok: true,
|
|
110
|
+
value: { last: true, prompt: { kind: 'prompt-file', path: '/tmp/prompts/p.txt' } },
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
name: 'positional prompt may itself be --last (data after --)',
|
|
114
|
+
layer: 'argv',
|
|
115
|
+
// D9: `--last` after `--` starts with `-`, so the dash-leading rule
|
|
116
|
+
// rejects it as an inline prompt (it rides the `-p` argv value).
|
|
117
|
+
input: `${CANONICAL_NODE} ${CANONICAL_LAUNCHER} -- --last`,
|
|
118
|
+
ok: false,
|
|
119
|
+
reason: 'dash-leading-inline-prompt',
|
|
120
|
+
},
|
|
121
|
+
{
|
|
122
|
+
name: 'quoted multi-word prompt is one token',
|
|
123
|
+
layer: 'argv',
|
|
124
|
+
input: `${CANONICAL_NODE} ${CANONICAL_LAUNCHER} -- 'multi word prompt'`,
|
|
125
|
+
ok: true,
|
|
126
|
+
value: { last: false, prompt: { kind: 'inline', text: 'multi word prompt' } },
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
name: 'expansion-flagged prompt word is accepted as data (never executed)',
|
|
130
|
+
layer: 'argv',
|
|
131
|
+
input: `${CANONICAL_NODE} ${CANONICAL_LAUNCHER} -- '$(rm -rf /)'`,
|
|
132
|
+
ok: true,
|
|
133
|
+
value: { last: false, prompt: { kind: 'inline', text: '$(rm -rf /)' } },
|
|
134
|
+
},
|
|
135
|
+
{
|
|
136
|
+
name: 'hostile install path survives as concatenated quoted word',
|
|
137
|
+
layer: 'argv',
|
|
138
|
+
// The POSIX single-quote escape `'\''` needs `\\''` in a JS template
|
|
139
|
+
// literal: a lone `\` is swallowed (collapses to `''`, an empty quoted
|
|
140
|
+
// segment), which would lex the path to "quotes", not "quote's".
|
|
141
|
+
input: `${CANONICAL_NODE} '/opt/dsh space/quote'\\''s/cc-grok-bridge/scripts/grok-review-run.mjs' -- p`,
|
|
142
|
+
launcher: '/opt/dsh space/quote\'s/cc-grok-bridge/scripts/grok-review-run.mjs',
|
|
143
|
+
ok: true,
|
|
144
|
+
value: { last: false, prompt: { kind: 'inline', text: 'p' } },
|
|
145
|
+
},
|
|
146
|
+
// ---- negative: lexer layer ----
|
|
147
|
+
{
|
|
148
|
+
name: 'unquoted semicolon (compound command)',
|
|
149
|
+
layer: 'lexer',
|
|
150
|
+
input: `${CANONICAL_NODE} ${CANONICAL_LAUNCHER} -- p; rm -rf /`,
|
|
151
|
+
ok: false,
|
|
152
|
+
reason: 'unquoted-operator',
|
|
153
|
+
},
|
|
154
|
+
{
|
|
155
|
+
name: 'unquoted && (compound command)',
|
|
156
|
+
layer: 'lexer',
|
|
157
|
+
input: `${CANONICAL_NODE} ${CANONICAL_LAUNCHER} -- p && echo owned`,
|
|
158
|
+
ok: false,
|
|
159
|
+
reason: 'unquoted-operator',
|
|
160
|
+
},
|
|
161
|
+
{
|
|
162
|
+
name: 'unquoted || (compound command)',
|
|
163
|
+
layer: 'lexer',
|
|
164
|
+
input: `${CANONICAL_NODE} ${CANONICAL_LAUNCHER} -- p || echo fallback`,
|
|
165
|
+
ok: false,
|
|
166
|
+
reason: 'unquoted-operator',
|
|
167
|
+
},
|
|
168
|
+
{
|
|
169
|
+
name: 'unquoted pipe',
|
|
170
|
+
layer: 'lexer',
|
|
171
|
+
input: `${CANONICAL_NODE} ${CANONICAL_LAUNCHER} -- p | tee /tmp/leak`,
|
|
172
|
+
ok: false,
|
|
173
|
+
reason: 'unquoted-operator',
|
|
174
|
+
},
|
|
175
|
+
{
|
|
176
|
+
name: 'unquoted redirect out',
|
|
177
|
+
layer: 'lexer',
|
|
178
|
+
input: `${CANONICAL_NODE} ${CANONICAL_LAUNCHER} -- p > /tmp/out`,
|
|
179
|
+
ok: false,
|
|
180
|
+
reason: 'unquoted-operator',
|
|
181
|
+
},
|
|
182
|
+
{
|
|
183
|
+
name: 'unquoted redirect in',
|
|
184
|
+
layer: 'lexer',
|
|
185
|
+
input: `${CANONICAL_NODE} ${CANONICAL_LAUNCHER} -- p < /etc/passwd`,
|
|
186
|
+
ok: false,
|
|
187
|
+
reason: 'unquoted-operator',
|
|
188
|
+
},
|
|
189
|
+
{
|
|
190
|
+
name: 'unquoted newline (second command on next line)',
|
|
191
|
+
layer: 'lexer',
|
|
192
|
+
input: `${CANONICAL_NODE} ${CANONICAL_LAUNCHER} -- p\ncurl evil.example`,
|
|
193
|
+
ok: false,
|
|
194
|
+
reason: 'unquoted-operator',
|
|
195
|
+
},
|
|
196
|
+
{
|
|
197
|
+
name: 'env-assignment prefix',
|
|
198
|
+
layer: 'lexer',
|
|
199
|
+
input: `BASH_ENV=/tmp/x ${CANONICAL_NODE} ${CANONICAL_LAUNCHER} -- p`,
|
|
200
|
+
ok: false,
|
|
201
|
+
reason: 'env-assignment-prefix',
|
|
202
|
+
},
|
|
203
|
+
{
|
|
204
|
+
name: 'unquoted $() command substitution',
|
|
205
|
+
layer: 'lexer',
|
|
206
|
+
input: `${CANONICAL_NODE} ${CANONICAL_LAUNCHER} -- $(echo p)`,
|
|
207
|
+
ok: false,
|
|
208
|
+
reason: 'command-substitution-unquoted',
|
|
209
|
+
},
|
|
210
|
+
{
|
|
211
|
+
name: 'unquoted backtick command substitution',
|
|
212
|
+
layer: 'lexer',
|
|
213
|
+
input: `${CANONICAL_NODE} ${CANONICAL_LAUNCHER} -- \`echo p\``,
|
|
214
|
+
ok: false,
|
|
215
|
+
reason: 'command-substitution-unquoted',
|
|
216
|
+
},
|
|
217
|
+
{
|
|
218
|
+
name: '$() inside double quotes (bash evaluates it)',
|
|
219
|
+
layer: 'lexer',
|
|
220
|
+
input: `${CANONICAL_NODE} ${CANONICAL_LAUNCHER} -- "$(echo p)"`,
|
|
221
|
+
ok: false,
|
|
222
|
+
reason: 'command-substitution-double-quoted',
|
|
223
|
+
},
|
|
224
|
+
{
|
|
225
|
+
name: 'backticks inside double quotes',
|
|
226
|
+
layer: 'lexer',
|
|
227
|
+
input: `${CANONICAL_NODE} ${CANONICAL_LAUNCHER} -- "\`echo p\`"`,
|
|
228
|
+
ok: false,
|
|
229
|
+
reason: 'command-substitution-double-quoted',
|
|
230
|
+
},
|
|
231
|
+
{
|
|
232
|
+
name: 'unclosed quote',
|
|
233
|
+
layer: 'lexer',
|
|
234
|
+
input: `${CANONICAL_NODE} ${CANONICAL_LAUNCHER} -- 'p`,
|
|
235
|
+
ok: false,
|
|
236
|
+
reason: 'unclosed-quote',
|
|
237
|
+
},
|
|
238
|
+
// ---- negative: expansion-bearing anchor tokens ----
|
|
239
|
+
{
|
|
240
|
+
name: 'tilde expansion in argv[0]',
|
|
241
|
+
layer: 'argv',
|
|
242
|
+
input: `~/bin/node ${CANONICAL_LAUNCHER} -- p`,
|
|
243
|
+
ok: false,
|
|
244
|
+
reason: 'expansion-in-argv0',
|
|
245
|
+
},
|
|
246
|
+
{
|
|
247
|
+
name: 'glob expansion in launcher path',
|
|
248
|
+
layer: 'argv',
|
|
249
|
+
input: `${CANONICAL_NODE} /opt/dsh-cc/*/scripts/grok-review-run.mjs -- p`,
|
|
250
|
+
ok: false,
|
|
251
|
+
reason: 'expansion-in-argv1',
|
|
252
|
+
},
|
|
253
|
+
{
|
|
254
|
+
name: '$VAR expansion in argv[0]',
|
|
255
|
+
layer: 'argv',
|
|
256
|
+
input: `$NODE_BIN ${CANONICAL_LAUNCHER} -- p`,
|
|
257
|
+
ok: false,
|
|
258
|
+
reason: 'expansion-in-argv0',
|
|
259
|
+
},
|
|
260
|
+
// ---- negative: argv layer ----
|
|
261
|
+
{
|
|
262
|
+
name: 'forged launcher path (absolute but not the anchor)',
|
|
263
|
+
layer: 'argv',
|
|
264
|
+
input: `${CANONICAL_NODE} /tmp/evil/scripts/grok-review-run.mjs -- p`,
|
|
265
|
+
ok: false,
|
|
266
|
+
reason: 'argv1-byte-mismatch',
|
|
267
|
+
},
|
|
268
|
+
{
|
|
269
|
+
name: 'PATH-trampoline interpreter bare name',
|
|
270
|
+
layer: 'argv',
|
|
271
|
+
input: `node ${CANONICAL_LAUNCHER} -- p`,
|
|
272
|
+
ok: false,
|
|
273
|
+
reason: 'argv0-byte-mismatch',
|
|
274
|
+
},
|
|
275
|
+
{
|
|
276
|
+
name: 'symlink-literal anchor (bytes differ from the canonical launcher)',
|
|
277
|
+
layer: 'argv',
|
|
278
|
+
input: `${CANONICAL_NODE} /tmp/link-to-grok-review-run.mjs -- p`,
|
|
279
|
+
ok: false,
|
|
280
|
+
reason: 'argv1-byte-mismatch',
|
|
281
|
+
},
|
|
282
|
+
{
|
|
283
|
+
name: 'duplicate --last',
|
|
284
|
+
layer: 'argv',
|
|
285
|
+
input: `${CANONICAL_NODE} ${CANONICAL_LAUNCHER} --last --last -- p`,
|
|
286
|
+
ok: false,
|
|
287
|
+
reason: 'unexpected-token',
|
|
288
|
+
},
|
|
289
|
+
{
|
|
290
|
+
name: 'misplaced --last after the prompt',
|
|
291
|
+
layer: 'argv',
|
|
292
|
+
input: `${CANONICAL_NODE} ${CANONICAL_LAUNCHER} -- p --last`,
|
|
293
|
+
ok: false,
|
|
294
|
+
reason: 'trailing-arguments',
|
|
295
|
+
},
|
|
296
|
+
{
|
|
297
|
+
name: 'misplaced --last alone (no prompt)',
|
|
298
|
+
layer: 'argv',
|
|
299
|
+
input: `${CANONICAL_NODE} ${CANONICAL_LAUNCHER} --last`,
|
|
300
|
+
ok: false,
|
|
301
|
+
reason: 'missing-prompt',
|
|
302
|
+
},
|
|
303
|
+
{
|
|
304
|
+
name: 'no prompt at all',
|
|
305
|
+
layer: 'argv',
|
|
306
|
+
input: `${CANONICAL_NODE} ${CANONICAL_LAUNCHER}`,
|
|
307
|
+
ok: false,
|
|
308
|
+
reason: 'missing-prompt',
|
|
309
|
+
},
|
|
310
|
+
{
|
|
311
|
+
name: 'empty positional prompt',
|
|
312
|
+
layer: 'argv',
|
|
313
|
+
input: `${CANONICAL_NODE} ${CANONICAL_LAUNCHER} -- ""`,
|
|
314
|
+
ok: false,
|
|
315
|
+
reason: 'empty-prompt',
|
|
316
|
+
},
|
|
317
|
+
{
|
|
318
|
+
name: '-- with no prompt token',
|
|
319
|
+
layer: 'argv',
|
|
320
|
+
input: `${CANONICAL_NODE} ${CANONICAL_LAUNCHER} --`,
|
|
321
|
+
ok: false,
|
|
322
|
+
reason: 'missing-prompt',
|
|
323
|
+
},
|
|
324
|
+
{
|
|
325
|
+
name: 'dash-leading inline prompt is rejected (the -p argv value would be re-parsed as a flag)',
|
|
326
|
+
layer: 'argv',
|
|
327
|
+
input: `${CANONICAL_NODE} ${CANONICAL_LAUNCHER} -- -restart-from-scratch`,
|
|
328
|
+
ok: false,
|
|
329
|
+
reason: 'dash-leading-inline-prompt',
|
|
330
|
+
},
|
|
331
|
+
{
|
|
332
|
+
name: 'newline-bearing positional prompt',
|
|
333
|
+
layer: 'argv',
|
|
334
|
+
input: `${CANONICAL_NODE} ${CANONICAL_LAUNCHER} -- "line one\nline two"`,
|
|
335
|
+
ok: false,
|
|
336
|
+
reason: 'control-char-in-prompt',
|
|
337
|
+
},
|
|
338
|
+
{
|
|
339
|
+
name: 'carriage-return-bearing positional prompt',
|
|
340
|
+
layer: 'argv',
|
|
341
|
+
input: `${CANONICAL_NODE} ${CANONICAL_LAUNCHER} -- "line one\rline two"`,
|
|
342
|
+
ok: false,
|
|
343
|
+
reason: 'control-char-in-prompt',
|
|
344
|
+
},
|
|
345
|
+
{
|
|
346
|
+
name: 'unknown flag before the prompt',
|
|
347
|
+
layer: 'argv',
|
|
348
|
+
input: `${CANONICAL_NODE} ${CANONICAL_LAUNCHER} --json -- p`,
|
|
349
|
+
ok: false,
|
|
350
|
+
reason: 'unexpected-token',
|
|
351
|
+
},
|
|
352
|
+
{
|
|
353
|
+
name: '--prompt-file without a path',
|
|
354
|
+
layer: 'argv',
|
|
355
|
+
input: `${CANONICAL_NODE} ${CANONICAL_LAUNCHER} --prompt-file`,
|
|
356
|
+
ok: false,
|
|
357
|
+
reason: 'missing-prompt-file-path',
|
|
358
|
+
},
|
|
359
|
+
{
|
|
360
|
+
name: '--prompt-file with empty path',
|
|
361
|
+
layer: 'argv',
|
|
362
|
+
input: `${CANONICAL_NODE} ${CANONICAL_LAUNCHER} --prompt-file ""`,
|
|
363
|
+
ok: false,
|
|
364
|
+
reason: 'missing-prompt-file-path',
|
|
365
|
+
},
|
|
366
|
+
{
|
|
367
|
+
name: '--prompt-file with trailing arguments',
|
|
368
|
+
layer: 'argv',
|
|
369
|
+
input: `${CANONICAL_NODE} ${CANONICAL_LAUNCHER} --prompt-file /tmp/p.txt -- q`,
|
|
370
|
+
ok: false,
|
|
371
|
+
reason: 'trailing-arguments',
|
|
372
|
+
},
|
|
373
|
+
{
|
|
374
|
+
name: 'positional prompt with trailing extra argument',
|
|
375
|
+
layer: 'argv',
|
|
376
|
+
input: `${CANONICAL_NODE} ${CANONICAL_LAUNCHER} -- p extra`,
|
|
377
|
+
ok: false,
|
|
378
|
+
reason: 'trailing-arguments',
|
|
379
|
+
},
|
|
380
|
+
]
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* canonical.mjs — the single arming/derivation source for the
|
|
3
|
+
* cc-grok-bridge hooks.
|
|
4
|
+
*
|
|
5
|
+
* Every hook (PreToolUse allow, SessionStart context) derives the canonical
|
|
6
|
+
* anchor pair and the refusal/arming verdict through THIS module only; the
|
|
7
|
+
* specs pin that no hook re-implements the derivation.
|
|
8
|
+
*
|
|
9
|
+
* Canonical anchors: `realpath(process.execPath)` of the calling hook
|
|
10
|
+
* process plus the realpath of the sibling `scripts/grok-review-run.mjs`
|
|
11
|
+
* resolved from the hook's own `import.meta.url`. Refusal set: the
|
|
12
|
+
* canonicalized session cwd plus the canonical tmp roots, unrealpathable
|
|
13
|
+
* entries skipped. The Grok review lane also refuses win32 (the launcher
|
|
14
|
+
* refuses it too).
|
|
15
|
+
*/
|
|
16
|
+
import { realpathSync } from 'node:fs'
|
|
17
|
+
import { tmpdir } from 'node:os'
|
|
18
|
+
import { fileURLToPath } from 'node:url'
|
|
19
|
+
|
|
20
|
+
export const safeReal = (p) => {
|
|
21
|
+
try {
|
|
22
|
+
return realpathSync(p)
|
|
23
|
+
} catch {
|
|
24
|
+
return null
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Containment against canonicalized roots (path-boundary aware). */
|
|
29
|
+
export const inside = (p, roots) => p !== null && roots.some((root) => p === root || p.startsWith(root + '/'))
|
|
30
|
+
|
|
31
|
+
/** POSIX single-quote escape: bytes inside the quotes are pure data. */
|
|
32
|
+
export const posixSingleQuote = (s) => `'` + String(s).replaceAll(`'`, `'\\''`) + `'`
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Derive the canonical anchor pair from the CALLING hook's own location
|
|
36
|
+
* (pass that hook's `import.meta.url`). Returns null when either anchor is
|
|
37
|
+
* unrealpathable.
|
|
38
|
+
*/
|
|
39
|
+
export function deriveAnchors(hookUrl) {
|
|
40
|
+
const node = safeReal(process.execPath)
|
|
41
|
+
const launcher = safeReal(fileURLToPath(new URL('../scripts/grok-review-run.mjs', hookUrl)))
|
|
42
|
+
if (node === null || launcher === null) return null
|
|
43
|
+
return { node, launcher }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Refusal set: session cwd plus canonical tmp roots. */
|
|
47
|
+
export function refusalRoots(cwdRaw) {
|
|
48
|
+
return [safeReal(cwdRaw), safeReal(tmpdir()), safeReal('/tmp')].filter((p) => p !== null)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Arming predicate with machine-readable reasons. `hookUrl` is the calling
|
|
53
|
+
* hook's `import.meta.url`; `env` defaults to `process.env` and `platform`
|
|
54
|
+
* to `process.platform` (the hooks pass nothing and inherit both).
|
|
55
|
+
*/
|
|
56
|
+
export function arming(cwdRaw, { hookUrl, env = process.env, platform = process.platform } = {}) {
|
|
57
|
+
const anchors = deriveAnchors(hookUrl)
|
|
58
|
+
if (anchors === null) return { armed: false, reason: 'anchor-unrealpathable' }
|
|
59
|
+
if (platform === 'win32') return { armed: false, reason: 'platform-win32' }
|
|
60
|
+
const cwd = safeReal(typeof cwdRaw === 'string' && cwdRaw !== '' ? cwdRaw : process.cwd())
|
|
61
|
+
if (cwd === null) return { armed: false, reason: 'anchor-unrealpathable' }
|
|
62
|
+
const roots = refusalRoots(cwd)
|
|
63
|
+
if (inside(anchors.node, roots) || inside(anchors.launcher, roots)) {
|
|
64
|
+
return { armed: false, reason: 'anchor-under-writable-root' }
|
|
65
|
+
}
|
|
66
|
+
if (env.BASH_ENV) return { armed: false, reason: 'bash-env-set' }
|
|
67
|
+
if (env.ENV) return { armed: false, reason: 'env-set' }
|
|
68
|
+
return { armed: true, node: anchors.node, launcher: anchors.launcher, cwd }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The ARMED additionalContext block: the exact canonical invocation to type,
|
|
73
|
+
* both anchor paths POSIX single-quote-escaped so hostile install paths
|
|
74
|
+
* survive as pure data.
|
|
75
|
+
*/
|
|
76
|
+
export function armedText({ node, launcher }) {
|
|
77
|
+
const q = posixSingleQuote
|
|
78
|
+
return [
|
|
79
|
+
'cc-grok-bridge: the Grok review lane is ARMED. Use EXACTLY this canonical invocation for a Grok review:',
|
|
80
|
+
`${q(node)} ${q(launcher)} -- 'the review request, single line'`,
|
|
81
|
+
'For a multi-line prompt (or any request whose first character is `-` — dash-leading inline prompts are rejected by the launcher), write the text to a file inside this workspace (or the canonical tmpdir), then run:',
|
|
82
|
+
`${q(node)} ${q(launcher)} --prompt-file 'the prompt file path'`,
|
|
83
|
+
'Add --last ONLY when the user explicitly asks to continue the previous review (it resumes the most recent review thread for this workspace).',
|
|
84
|
+
].join('\n')
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The REFUSED additionalContext block: machine reason in plain words. */
|
|
88
|
+
export function refusedText(reason) {
|
|
89
|
+
const words =
|
|
90
|
+
reason === 'anchor-under-writable-root'
|
|
91
|
+
? "the plugin's launcher sits under this session's workspace, expected in dsh-cc repo dev sessions"
|
|
92
|
+
: reason === 'bash-env-set'
|
|
93
|
+
? 'the ambient BASH_ENV variable is non-empty (shell-function takeover defense)'
|
|
94
|
+
: reason === 'env-set'
|
|
95
|
+
? 'the ambient ENV variable is non-empty (shell-function takeover defense)'
|
|
96
|
+
: reason === 'platform-win32'
|
|
97
|
+
? 'platform win32 is unsupported — the launcher refuses it too'
|
|
98
|
+
: 'the plugin\'s canonical anchors could not be resolved in this environment'
|
|
99
|
+
return [
|
|
100
|
+
`cc-grok-bridge: the Grok review lane is NOT armed (reason: ${reason} — ${words}).`,
|
|
101
|
+
'Grok review today goes through the normal, approval-requiring path; do NOT guess or construct the bridge\'s canonical bash invocation manually.',
|
|
102
|
+
].join('\n')
|
|
103
|
+
}
|