@dsh-cc/plugin-cc-codex-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 +91 -0
- package/README.zh.md +76 -0
- package/commands/rescue.md +30 -0
- package/hooks/codex-rescue-allow.mjs +76 -0
- package/hooks/codex-rescue-context.mjs +38 -0
- package/hooks/hooks.json +27 -0
- package/package.json +22 -0
- package/scripts/codex-rescue-run.mjs +182 -0
- package/scripts/lib/argv.mjs +368 -0
- package/scripts/lib/canonical.mjs +99 -0
- package/scripts/lib/home.mjs +179 -0
- package/scripts/lib/lexer.mjs +145 -0
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* §3.1 launcher for the codex-rescue-bridge.
|
|
4
|
+
*
|
|
5
|
+
* codex-rescue-run.mjs [--last] -- <single-line prompt>
|
|
6
|
+
* codex-rescue-run.mjs [--last] --prompt-file <path>
|
|
7
|
+
*
|
|
8
|
+
* Redirects CODEX_HOME to a per-cwd shadow home under the canonical tmpdir
|
|
9
|
+
* (§3.1 steps 1–4, scripts/lib/home.mjs), resolves and validates the Codex
|
|
10
|
+
* CLI, spawns `codex exec --sandbox danger-full-access ... -` with the prompt
|
|
11
|
+
* on stdin, and prints last-message.txt at the end.
|
|
12
|
+
*/
|
|
13
|
+
import { spawn } from 'node:child_process'
|
|
14
|
+
import {
|
|
15
|
+
closeSync,
|
|
16
|
+
constants as fsConstants,
|
|
17
|
+
existsSync,
|
|
18
|
+
fstatSync,
|
|
19
|
+
openSync,
|
|
20
|
+
readFileSync,
|
|
21
|
+
realpathSync,
|
|
22
|
+
} from 'node:fs'
|
|
23
|
+
import { fileURLToPath } from 'node:url'
|
|
24
|
+
import os from 'node:os'
|
|
25
|
+
import path from 'node:path'
|
|
26
|
+
import { parseArgv } from './lib/argv.mjs'
|
|
27
|
+
import { acquireLock, prepareHome, syncCredentials } from './lib/home.mjs'
|
|
28
|
+
|
|
29
|
+
const USAGE =
|
|
30
|
+
'usage: codex-rescue-run.mjs [--last] -- <single-line prompt>\n' +
|
|
31
|
+
' codex-rescue-run.mjs [--last] --prompt-file <path>'
|
|
32
|
+
|
|
33
|
+
const die = (msg, code = 1) => {
|
|
34
|
+
const e = new Error(`codex-rescue: ${msg}`)
|
|
35
|
+
e.exitCode = code
|
|
36
|
+
throw e
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Canonicalized outer writable roots: {cwd, tmpdir, /tmp} (§3.1 step 6). */
|
|
40
|
+
function writableRoots() {
|
|
41
|
+
const roots = []
|
|
42
|
+
for (const p of [process.cwd(), os.tmpdir(), '/tmp']) {
|
|
43
|
+
try {
|
|
44
|
+
roots.push(realpathSync(p))
|
|
45
|
+
} catch {
|
|
46
|
+
/* absent root — nothing to confine against */
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return roots
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const outsideRoots = (p, roots) => !roots.some((r) => p === r || p.startsWith(r + path.sep))
|
|
53
|
+
|
|
54
|
+
function resolveCodex(roots) {
|
|
55
|
+
for (const dir of (process.env.PATH ?? '').split(path.delimiter)) {
|
|
56
|
+
if (!dir) continue
|
|
57
|
+
let real
|
|
58
|
+
try {
|
|
59
|
+
real = realpathSync(path.join(dir, 'codex'))
|
|
60
|
+
} catch {
|
|
61
|
+
continue
|
|
62
|
+
}
|
|
63
|
+
// The executed binary must sit outside every canonicalized writable
|
|
64
|
+
// root: nothing the model can write may become the CLI (§3.1 step 6).
|
|
65
|
+
if (!outsideRoots(real, roots)) continue
|
|
66
|
+
return real
|
|
67
|
+
}
|
|
68
|
+
die('codex CLI not found on PATH outside the writable roots')
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const PROMPT_CAP = 256 * 1024
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* --prompt-file (§3.1 step 5): O_NOFOLLOW open, fstat regular-file, read
|
|
75
|
+
* from THAT same fd with the byte cap. The check→open ancestor-swap race is
|
|
76
|
+
* the §4 accepted residual — this channel is hygiene-only (prompt text the
|
|
77
|
+
* model could already read and inline itself), data egress into a networked
|
|
78
|
+
* model run, not an egress gate.
|
|
79
|
+
*/
|
|
80
|
+
function readPromptFile(p) {
|
|
81
|
+
let fd
|
|
82
|
+
try {
|
|
83
|
+
fd = openSync(p, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW)
|
|
84
|
+
} catch (e) {
|
|
85
|
+
die(`prompt-file unreadable: ${e.message}`)
|
|
86
|
+
}
|
|
87
|
+
try {
|
|
88
|
+
if (!fstatSync(fd).isFile()) die('prompt-file is not a regular file')
|
|
89
|
+
if (fstatSync(fd).size > PROMPT_CAP) die(`prompt-file exceeds the ${PROMPT_CAP}-byte cap`)
|
|
90
|
+
return readFileSync(fd).toString('utf8')
|
|
91
|
+
} finally {
|
|
92
|
+
closeSync(fd)
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Spawn codex with the prompt on a piped stdin, written then closed. */
|
|
97
|
+
function runCodex(codex, args, env, promptText) {
|
|
98
|
+
return new Promise((resolve) => {
|
|
99
|
+
const child = spawn(codex, args, { env, stdio: ['pipe', 'inherit', 'inherit'] })
|
|
100
|
+
// Write the full prompt then CLOSE stdin — the EOF is the prompt
|
|
101
|
+
// terminator (§5 write/close EOF path). Child stdout/stderr inherit;
|
|
102
|
+
// CODEX_HOME lives only in this child env object, process.env is never
|
|
103
|
+
// mutated.
|
|
104
|
+
child.stdin.end(promptText)
|
|
105
|
+
child.on('error', (e) => {
|
|
106
|
+
console.error(`codex-rescue: spawn failed: ${e.message}`)
|
|
107
|
+
resolve(1) // never throw from an event callback — the finally must run
|
|
108
|
+
})
|
|
109
|
+
child.on('close', (code) => resolve(code ?? 1))
|
|
110
|
+
})
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function main() {
|
|
114
|
+
if (process.platform === 'win32') {
|
|
115
|
+
// The design's probes (§2) targeted POSIX; fail loud instead of guessing.
|
|
116
|
+
die('unsupported platform win32')
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const roots = writableRoots()
|
|
120
|
+
|
|
121
|
+
// Self-assert at startup (§3.1 step 6, critic F1 belt): the interpreter
|
|
122
|
+
// must sit outside every writable root — one line of defense-in-depth
|
|
123
|
+
// against a mismatched/PATH-swapped node.
|
|
124
|
+
const selfNode = realpathSync(process.execPath)
|
|
125
|
+
if (!outsideRoots(selfNode, roots)) {
|
|
126
|
+
die('node interpreter sits inside a canonicalized writable root')
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Parse own argv with the shared grammar. Real argv has no shell quoting
|
|
130
|
+
// left: map each arg to { text, expansion: false }.
|
|
131
|
+
const launcherPath = realpathSync(fileURLToPath(import.meta.url))
|
|
132
|
+
const words = [
|
|
133
|
+
{ text: selfNode, expansion: false },
|
|
134
|
+
{ text: launcherPath, expansion: false },
|
|
135
|
+
...process.argv.slice(2).map((a) => ({ text: a, expansion: false })),
|
|
136
|
+
]
|
|
137
|
+
const parsed = parseArgv(words, { node: selfNode, launcher: launcherPath })
|
|
138
|
+
if (!parsed.ok) die(`invalid invocation (${parsed.reason})\n${USAGE}`, 2)
|
|
139
|
+
const { last, prompt } = parsed.value
|
|
140
|
+
|
|
141
|
+
const { H } = prepareHome({ cwd: process.cwd(), tmpdir: os.tmpdir() })
|
|
142
|
+
const releaseLock = acquireLock(H)
|
|
143
|
+
try {
|
|
144
|
+
syncCredentials(H)
|
|
145
|
+
const codex = resolveCodex(roots)
|
|
146
|
+
const promptText =
|
|
147
|
+
prompt.kind === 'inline' ? prompt.text : readPromptFile(prompt.path)
|
|
148
|
+
|
|
149
|
+
const args = [
|
|
150
|
+
'exec',
|
|
151
|
+
'--sandbox',
|
|
152
|
+
'danger-full-access',
|
|
153
|
+
'--cd',
|
|
154
|
+
realpathSync(process.cwd()),
|
|
155
|
+
'-o',
|
|
156
|
+
path.join(H, 'last-message.txt'),
|
|
157
|
+
]
|
|
158
|
+
if (last) args.push('resume', '--last')
|
|
159
|
+
// The literal `-` selects stdin on BOTH shapes (fresh exec and
|
|
160
|
+
// `resume --last`, per the 0.153.4 help text): prompt content is always
|
|
161
|
+
// stdin data, so leading-dash prompts are never re-parsed as flags.
|
|
162
|
+
args.push('-')
|
|
163
|
+
|
|
164
|
+
const code = await runCodex(codex, args, { ...process.env, CODEX_HOME: H }, promptText)
|
|
165
|
+
|
|
166
|
+
const outFile = path.join(H, 'last-message.txt')
|
|
167
|
+
if (existsSync(outFile)) process.stdout.write(readFileSync(outFile))
|
|
168
|
+
return code
|
|
169
|
+
} finally {
|
|
170
|
+
releaseLock()
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
main().then(
|
|
175
|
+
(code) => {
|
|
176
|
+
process.exitCode = code
|
|
177
|
+
},
|
|
178
|
+
(e) => {
|
|
179
|
+
console.error(e?.message ?? e)
|
|
180
|
+
process.exitCode = e?.exitCode ?? 1
|
|
181
|
+
},
|
|
182
|
+
)
|
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* §3.2-d pinned argv grammar for the codex-rescue-bridge (§5: one shared
|
|
3
|
+
* parser source for matcher and launcher; the exported FIXTURES table is
|
|
4
|
+
* the single fixture suite PR-2's 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, no
|
|
16
|
+
* newline, and MAY start with `-` (it reaches Codex via stdin, so it is
|
|
17
|
+
* data, never a flag).
|
|
18
|
+
* - PATH for --prompt-file is non-empty; containment is checked at match
|
|
19
|
+
* time by the PR-2 hook, not here.
|
|
20
|
+
* - unknown shapes fail closed with a machine-readable `reason`.
|
|
21
|
+
*/
|
|
22
|
+
import { lexCommand } from './lexer.mjs'
|
|
23
|
+
|
|
24
|
+
/** Example canonical anchor pair used by the shared fixture table. */
|
|
25
|
+
export const CANONICAL_NODE = '/usr/local/bin/node'
|
|
26
|
+
export const CANONICAL_LAUNCHER = '/opt/dsh-cc/cc-codex-bridge/scripts/codex-rescue-run.mjs'
|
|
27
|
+
|
|
28
|
+
const fail = (reason) => ({ ok: false, layer: 'argv', reason })
|
|
29
|
+
const ok = (value) => ({ ok: true, layer: 'argv', value })
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Parse a lexer-resolved argv (words with { text, expansion }) against the
|
|
33
|
+
* pinned grammar. `node`/`launcher` are the byte-pinned canonical anchors.
|
|
34
|
+
*/
|
|
35
|
+
export function parseArgv(argv, { node, launcher }) {
|
|
36
|
+
if (argv.length < 2) return fail('missing-anchor')
|
|
37
|
+
if (argv[0].expansion) return fail('expansion-in-argv0')
|
|
38
|
+
if (argv[1].expansion) return fail('expansion-in-argv1')
|
|
39
|
+
if (argv[0].text !== node) return fail('argv0-byte-mismatch')
|
|
40
|
+
if (argv[1].text !== launcher) return fail('argv1-byte-mismatch')
|
|
41
|
+
let i = 2
|
|
42
|
+
let last = false
|
|
43
|
+
if (argv[i] !== undefined && argv[i].text === '--last') {
|
|
44
|
+
last = true
|
|
45
|
+
i++
|
|
46
|
+
}
|
|
47
|
+
const rest = argv.slice(i)
|
|
48
|
+
if (rest.length === 0) return fail('missing-prompt')
|
|
49
|
+
if (rest[0].text === '--prompt-file') {
|
|
50
|
+
if (rest.length < 2 || rest[1].text === '') return fail('missing-prompt-file-path')
|
|
51
|
+
if (rest.length > 2) return fail('trailing-arguments')
|
|
52
|
+
return ok({ last, prompt: { kind: 'prompt-file', path: rest[1].text } })
|
|
53
|
+
}
|
|
54
|
+
if (rest[0].text !== '--') return fail('unexpected-token')
|
|
55
|
+
if (rest.length < 2) return fail('missing-prompt')
|
|
56
|
+
if (rest.length > 2) return fail('trailing-arguments')
|
|
57
|
+
const prompt = rest[1].text
|
|
58
|
+
if (prompt === '') return fail('empty-prompt')
|
|
59
|
+
if (prompt.includes('\n')) return fail('prompt-newline')
|
|
60
|
+
return ok({ last, prompt: { kind: 'inline', text: prompt } })
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Full matcher pipeline: lex the raw bash command to exactly one simple
|
|
65
|
+
* command (§3.2-a), then pin its argv to the grammar (§3.2-d).
|
|
66
|
+
*/
|
|
67
|
+
export function matchInvocation(raw, { node = CANONICAL_NODE, launcher = CANONICAL_LAUNCHER } = {}) {
|
|
68
|
+
const lex = lexCommand(raw)
|
|
69
|
+
if (!lex.ok) return lex
|
|
70
|
+
return parseArgv(lex.argv, { node, launcher })
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Shared fixture table (§5 one-shared-parser-source rule). Every row runs
|
|
75
|
+
* through matchInvocation; positive rows pin the normalized parse, negative
|
|
76
|
+
* rows carry `reason` = the violated rule. `layer` documents which layer
|
|
77
|
+
* rejects (or accepts) the row; lexer-layer rows fail regardless of anchors.
|
|
78
|
+
*/
|
|
79
|
+
export const FIXTURES = [
|
|
80
|
+
// ---- positive: pinned normalized shapes ----
|
|
81
|
+
{
|
|
82
|
+
name: 'inline prompt',
|
|
83
|
+
layer: 'argv',
|
|
84
|
+
input: `${CANONICAL_NODE} ${CANONICAL_LAUNCHER} -- "review the failing spec"`,
|
|
85
|
+
ok: true,
|
|
86
|
+
value: { last: false, prompt: { kind: 'inline', text: 'review the failing spec' } },
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
name: 'inline prompt with --last in its only slot',
|
|
90
|
+
layer: 'argv',
|
|
91
|
+
input: `${CANONICAL_NODE} ${CANONICAL_LAUNCHER} --last -- "continue the rescue"`,
|
|
92
|
+
ok: true,
|
|
93
|
+
value: { last: true, prompt: { kind: 'inline', text: 'continue the rescue' } },
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
name: 'prompt-file form',
|
|
97
|
+
layer: 'argv',
|
|
98
|
+
input: `${CANONICAL_NODE} ${CANONICAL_LAUNCHER} --prompt-file /tmp/prompts/p.txt`,
|
|
99
|
+
ok: true,
|
|
100
|
+
value: { last: false, prompt: { kind: 'prompt-file', path: '/tmp/prompts/p.txt' } },
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
name: 'prompt-file form with --last',
|
|
104
|
+
layer: 'argv',
|
|
105
|
+
input: `${CANONICAL_NODE} ${CANONICAL_LAUNCHER} --last --prompt-file /tmp/prompts/p.txt`,
|
|
106
|
+
ok: true,
|
|
107
|
+
value: { last: true, prompt: { kind: 'prompt-file', path: '/tmp/prompts/p.txt' } },
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
name: 'positional prompt may itself be --last (data after --)',
|
|
111
|
+
layer: 'argv',
|
|
112
|
+
input: `${CANONICAL_NODE} ${CANONICAL_LAUNCHER} -- --last`,
|
|
113
|
+
ok: true,
|
|
114
|
+
value: { last: false, prompt: { kind: 'inline', text: '--last' } },
|
|
115
|
+
},
|
|
116
|
+
{
|
|
117
|
+
name: 'positional prompt may start with - (stdin handoff makes it data)',
|
|
118
|
+
layer: 'argv',
|
|
119
|
+
input: `${CANONICAL_NODE} ${CANONICAL_LAUNCHER} -- -restart-from-scratch`,
|
|
120
|
+
ok: true,
|
|
121
|
+
value: { last: false, prompt: { kind: 'inline', text: '-restart-from-scratch' } },
|
|
122
|
+
},
|
|
123
|
+
{
|
|
124
|
+
name: 'quoted multi-word prompt is one token',
|
|
125
|
+
layer: 'argv',
|
|
126
|
+
input: `${CANONICAL_NODE} ${CANONICAL_LAUNCHER} -- 'multi word prompt'`,
|
|
127
|
+
ok: true,
|
|
128
|
+
value: { last: false, prompt: { kind: 'inline', text: 'multi word prompt' } },
|
|
129
|
+
},
|
|
130
|
+
{
|
|
131
|
+
name: 'command substitution inside single quotes is pure data',
|
|
132
|
+
layer: 'lexer',
|
|
133
|
+
input: `${CANONICAL_NODE} ${CANONICAL_LAUNCHER} -- '$(rm -rf /)'`,
|
|
134
|
+
ok: true,
|
|
135
|
+
value: { last: false, prompt: { kind: 'inline', text: '$(rm -rf /)' } },
|
|
136
|
+
},
|
|
137
|
+
{
|
|
138
|
+
name: 'hostile install path survives as concatenated quoted word',
|
|
139
|
+
layer: 'argv',
|
|
140
|
+
// The POSIX single-quote escape `'\''` needs `\\''` in a JS template
|
|
141
|
+
// literal: a lone `\` is swallowed (collapses to `''`, an empty quoted
|
|
142
|
+
// segment), which would lex the path to "quotes", not "quote's".
|
|
143
|
+
input: `${CANONICAL_NODE} '/opt/dsh space/quote'\\''s/cc-codex-bridge/scripts/codex-rescue-run.mjs' -- p`,
|
|
144
|
+
launcher: '/opt/dsh space/quote\'s/cc-codex-bridge/scripts/codex-rescue-run.mjs',
|
|
145
|
+
ok: true,
|
|
146
|
+
value: { last: false, prompt: { kind: 'inline', text: 'p' } },
|
|
147
|
+
},
|
|
148
|
+
// ---- negative: lexer layer (§3.2-a) ----
|
|
149
|
+
{
|
|
150
|
+
name: 'unquoted semicolon (compound command)',
|
|
151
|
+
layer: 'lexer',
|
|
152
|
+
input: `${CANONICAL_NODE} ${CANONICAL_LAUNCHER} -- p; rm -rf /`,
|
|
153
|
+
ok: false,
|
|
154
|
+
reason: 'unquoted-operator',
|
|
155
|
+
},
|
|
156
|
+
{
|
|
157
|
+
name: 'unquoted && (compound command)',
|
|
158
|
+
layer: 'lexer',
|
|
159
|
+
input: `${CANONICAL_NODE} ${CANONICAL_LAUNCHER} -- p && echo owned`,
|
|
160
|
+
ok: false,
|
|
161
|
+
reason: 'unquoted-operator',
|
|
162
|
+
},
|
|
163
|
+
{
|
|
164
|
+
name: 'unquoted || (compound command)',
|
|
165
|
+
layer: 'lexer',
|
|
166
|
+
input: `${CANONICAL_NODE} ${CANONICAL_LAUNCHER} -- p || echo fallback`,
|
|
167
|
+
ok: false,
|
|
168
|
+
reason: 'unquoted-operator',
|
|
169
|
+
},
|
|
170
|
+
{
|
|
171
|
+
name: 'unquoted pipe',
|
|
172
|
+
layer: 'lexer',
|
|
173
|
+
input: `${CANONICAL_NODE} ${CANONICAL_LAUNCHER} -- p | tee /tmp/leak`,
|
|
174
|
+
ok: false,
|
|
175
|
+
reason: 'unquoted-operator',
|
|
176
|
+
},
|
|
177
|
+
{
|
|
178
|
+
name: 'unquoted redirect out',
|
|
179
|
+
layer: 'lexer',
|
|
180
|
+
input: `${CANONICAL_NODE} ${CANONICAL_LAUNCHER} -- p > /tmp/out`,
|
|
181
|
+
ok: false,
|
|
182
|
+
reason: 'unquoted-operator',
|
|
183
|
+
},
|
|
184
|
+
{
|
|
185
|
+
name: 'unquoted redirect in',
|
|
186
|
+
layer: 'lexer',
|
|
187
|
+
input: `${CANONICAL_NODE} ${CANONICAL_LAUNCHER} -- p < /etc/passwd`,
|
|
188
|
+
ok: false,
|
|
189
|
+
reason: 'unquoted-operator',
|
|
190
|
+
},
|
|
191
|
+
{
|
|
192
|
+
name: 'unquoted newline (second command on next line)',
|
|
193
|
+
layer: 'lexer',
|
|
194
|
+
input: `${CANONICAL_NODE} ${CANONICAL_LAUNCHER} -- p\ncurl evil.example`,
|
|
195
|
+
ok: false,
|
|
196
|
+
reason: 'unquoted-operator',
|
|
197
|
+
},
|
|
198
|
+
{
|
|
199
|
+
name: 'env-assignment prefix',
|
|
200
|
+
layer: 'lexer',
|
|
201
|
+
input: `BASH_ENV=/tmp/x ${CANONICAL_NODE} ${CANONICAL_LAUNCHER} -- p`,
|
|
202
|
+
ok: false,
|
|
203
|
+
reason: 'env-assignment-prefix',
|
|
204
|
+
},
|
|
205
|
+
{
|
|
206
|
+
name: 'unquoted $() command substitution',
|
|
207
|
+
layer: 'lexer',
|
|
208
|
+
input: `${CANONICAL_NODE} ${CANONICAL_LAUNCHER} -- $(echo p)`,
|
|
209
|
+
ok: false,
|
|
210
|
+
reason: 'command-substitution-unquoted',
|
|
211
|
+
},
|
|
212
|
+
{
|
|
213
|
+
name: 'unquoted backtick command substitution',
|
|
214
|
+
layer: 'lexer',
|
|
215
|
+
input: `${CANONICAL_NODE} ${CANONICAL_LAUNCHER} -- \`echo p\``,
|
|
216
|
+
ok: false,
|
|
217
|
+
reason: 'command-substitution-unquoted',
|
|
218
|
+
},
|
|
219
|
+
{
|
|
220
|
+
name: '$() inside double quotes (bash evaluates it)',
|
|
221
|
+
layer: 'lexer',
|
|
222
|
+
input: `${CANONICAL_NODE} ${CANONICAL_LAUNCHER} -- "$(echo p)"`,
|
|
223
|
+
ok: false,
|
|
224
|
+
reason: 'command-substitution-double-quoted',
|
|
225
|
+
},
|
|
226
|
+
{
|
|
227
|
+
name: 'backticks inside double quotes',
|
|
228
|
+
layer: 'lexer',
|
|
229
|
+
input: `${CANONICAL_NODE} ${CANONICAL_LAUNCHER} -- "\`echo p\`"`,
|
|
230
|
+
ok: false,
|
|
231
|
+
reason: 'command-substitution-double-quoted',
|
|
232
|
+
},
|
|
233
|
+
{
|
|
234
|
+
name: 'unclosed quote',
|
|
235
|
+
layer: 'lexer',
|
|
236
|
+
input: `${CANONICAL_NODE} ${CANONICAL_LAUNCHER} -- 'p`,
|
|
237
|
+
ok: false,
|
|
238
|
+
reason: 'unclosed-quote',
|
|
239
|
+
},
|
|
240
|
+
// ---- negative: expansion-bearing anchor tokens (§3.2-b) ----
|
|
241
|
+
{
|
|
242
|
+
name: 'tilde expansion in argv[0]',
|
|
243
|
+
layer: 'argv',
|
|
244
|
+
input: `~/bin/node ${CANONICAL_LAUNCHER} -- p`,
|
|
245
|
+
ok: false,
|
|
246
|
+
reason: 'expansion-in-argv0',
|
|
247
|
+
},
|
|
248
|
+
{
|
|
249
|
+
name: 'glob expansion in launcher path',
|
|
250
|
+
layer: 'argv',
|
|
251
|
+
input: `${CANONICAL_NODE} /opt/dsh-cc/*/scripts/codex-rescue-run.mjs -- p`,
|
|
252
|
+
ok: false,
|
|
253
|
+
reason: 'expansion-in-argv1',
|
|
254
|
+
},
|
|
255
|
+
{
|
|
256
|
+
name: '$VAR expansion in argv[0]',
|
|
257
|
+
layer: 'argv',
|
|
258
|
+
input: `$NODE_BIN ${CANONICAL_LAUNCHER} -- p`,
|
|
259
|
+
ok: false,
|
|
260
|
+
reason: 'expansion-in-argv0',
|
|
261
|
+
},
|
|
262
|
+
// ---- negative: argv layer (§3.2-b/d) ----
|
|
263
|
+
{
|
|
264
|
+
name: 'forged launcher path (absolute but not the anchor)',
|
|
265
|
+
layer: 'argv',
|
|
266
|
+
input: `${CANONICAL_NODE} /tmp/evil/scripts/codex-rescue-run.mjs -- p`,
|
|
267
|
+
ok: false,
|
|
268
|
+
reason: 'argv1-byte-mismatch',
|
|
269
|
+
},
|
|
270
|
+
{
|
|
271
|
+
name: 'PATH-trampoline interpreter bare name',
|
|
272
|
+
layer: 'argv',
|
|
273
|
+
input: `node ${CANONICAL_LAUNCHER} -- p`,
|
|
274
|
+
ok: false,
|
|
275
|
+
reason: 'argv0-byte-mismatch',
|
|
276
|
+
},
|
|
277
|
+
{
|
|
278
|
+
name: 'symlink-literal anchor (bytes differ from the canonical launcher)',
|
|
279
|
+
layer: 'argv',
|
|
280
|
+
input: `${CANONICAL_NODE} /tmp/link-to-codex-rescue-run.mjs -- p`,
|
|
281
|
+
ok: false,
|
|
282
|
+
reason: 'argv1-byte-mismatch',
|
|
283
|
+
},
|
|
284
|
+
{
|
|
285
|
+
name: 'duplicate --last',
|
|
286
|
+
layer: 'argv',
|
|
287
|
+
input: `${CANONICAL_NODE} ${CANONICAL_LAUNCHER} --last --last -- p`,
|
|
288
|
+
ok: false,
|
|
289
|
+
reason: 'unexpected-token',
|
|
290
|
+
},
|
|
291
|
+
{
|
|
292
|
+
name: 'misplaced --last after the prompt',
|
|
293
|
+
layer: 'argv',
|
|
294
|
+
input: `${CANONICAL_NODE} ${CANONICAL_LAUNCHER} -- p --last`,
|
|
295
|
+
ok: false,
|
|
296
|
+
reason: 'trailing-arguments',
|
|
297
|
+
},
|
|
298
|
+
{
|
|
299
|
+
name: 'misplaced --last alone (no prompt)',
|
|
300
|
+
layer: 'argv',
|
|
301
|
+
input: `${CANONICAL_NODE} ${CANONICAL_LAUNCHER} --last`,
|
|
302
|
+
ok: false,
|
|
303
|
+
reason: 'missing-prompt',
|
|
304
|
+
},
|
|
305
|
+
{
|
|
306
|
+
name: 'no prompt at all',
|
|
307
|
+
layer: 'argv',
|
|
308
|
+
input: `${CANONICAL_NODE} ${CANONICAL_LAUNCHER}`,
|
|
309
|
+
ok: false,
|
|
310
|
+
reason: 'missing-prompt',
|
|
311
|
+
},
|
|
312
|
+
{
|
|
313
|
+
name: 'empty positional prompt',
|
|
314
|
+
layer: 'argv',
|
|
315
|
+
input: `${CANONICAL_NODE} ${CANONICAL_LAUNCHER} -- ""`,
|
|
316
|
+
ok: false,
|
|
317
|
+
reason: 'empty-prompt',
|
|
318
|
+
},
|
|
319
|
+
{
|
|
320
|
+
name: '-- with no prompt token',
|
|
321
|
+
layer: 'argv',
|
|
322
|
+
input: `${CANONICAL_NODE} ${CANONICAL_LAUNCHER} --`,
|
|
323
|
+
ok: false,
|
|
324
|
+
reason: 'missing-prompt',
|
|
325
|
+
},
|
|
326
|
+
{
|
|
327
|
+
name: 'newline-bearing positional prompt',
|
|
328
|
+
layer: 'argv',
|
|
329
|
+
input: `${CANONICAL_NODE} ${CANONICAL_LAUNCHER} -- "line one\nline two"`,
|
|
330
|
+
ok: false,
|
|
331
|
+
reason: 'prompt-newline',
|
|
332
|
+
},
|
|
333
|
+
{
|
|
334
|
+
name: 'unknown flag before the prompt',
|
|
335
|
+
layer: 'argv',
|
|
336
|
+
input: `${CANONICAL_NODE} ${CANONICAL_LAUNCHER} --json -- p`,
|
|
337
|
+
ok: false,
|
|
338
|
+
reason: 'unexpected-token',
|
|
339
|
+
},
|
|
340
|
+
{
|
|
341
|
+
name: '--prompt-file without a path',
|
|
342
|
+
layer: 'argv',
|
|
343
|
+
input: `${CANONICAL_NODE} ${CANONICAL_LAUNCHER} --prompt-file`,
|
|
344
|
+
ok: false,
|
|
345
|
+
reason: 'missing-prompt-file-path',
|
|
346
|
+
},
|
|
347
|
+
{
|
|
348
|
+
name: '--prompt-file with empty path',
|
|
349
|
+
layer: 'argv',
|
|
350
|
+
input: `${CANONICAL_NODE} ${CANONICAL_LAUNCHER} --prompt-file ""`,
|
|
351
|
+
ok: false,
|
|
352
|
+
reason: 'missing-prompt-file-path',
|
|
353
|
+
},
|
|
354
|
+
{
|
|
355
|
+
name: '--prompt-file with trailing arguments',
|
|
356
|
+
layer: 'argv',
|
|
357
|
+
input: `${CANONICAL_NODE} ${CANONICAL_LAUNCHER} --prompt-file /tmp/p.txt -- q`,
|
|
358
|
+
ok: false,
|
|
359
|
+
reason: 'trailing-arguments',
|
|
360
|
+
},
|
|
361
|
+
{
|
|
362
|
+
name: 'positional prompt with trailing extra argument',
|
|
363
|
+
layer: 'argv',
|
|
364
|
+
input: `${CANONICAL_NODE} ${CANONICAL_LAUNCHER} -- p extra`,
|
|
365
|
+
ok: false,
|
|
366
|
+
reason: 'trailing-arguments',
|
|
367
|
+
},
|
|
368
|
+
]
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* canonical.mjs — the single arming/derivation source for the
|
|
3
|
+
* codex-rescue-bridge hooks (plan §9 S3, §5 anchor-lint "one source").
|
|
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 (§3.1): `realpath(process.execPath)` of the calling hook
|
|
10
|
+
* process plus the realpath of the sibling `scripts/codex-rescue-run.mjs`
|
|
11
|
+
* resolved from the hook's own `import.meta.url`. Refusal set (§3.2-e): the
|
|
12
|
+
* canonicalized session cwd plus the canonical tmp roots, unrealpathable
|
|
13
|
+
* entries skipped.
|
|
14
|
+
*/
|
|
15
|
+
import { realpathSync } from 'node:fs'
|
|
16
|
+
import { tmpdir } from 'node:os'
|
|
17
|
+
import { fileURLToPath } from 'node:url'
|
|
18
|
+
|
|
19
|
+
export const safeReal = (p) => {
|
|
20
|
+
try {
|
|
21
|
+
return realpathSync(p)
|
|
22
|
+
} catch {
|
|
23
|
+
return null
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Containment against canonicalized roots (path-boundary aware). */
|
|
28
|
+
export const inside = (p, roots) => p !== null && roots.some((root) => p === root || p.startsWith(root + '/'))
|
|
29
|
+
|
|
30
|
+
/** POSIX single-quote escape: bytes inside the quotes are pure data. */
|
|
31
|
+
export const posixSingleQuote = (s) => `'` + String(s).replaceAll(`'`, `'\\''`) + `'`
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Derive the canonical anchor pair from the CALLING hook's own location
|
|
35
|
+
* (pass that hook's `import.meta.url`). Returns null when either anchor is
|
|
36
|
+
* unrealpathable.
|
|
37
|
+
*/
|
|
38
|
+
export function deriveAnchors(hookUrl) {
|
|
39
|
+
const node = safeReal(process.execPath)
|
|
40
|
+
const launcher = safeReal(fileURLToPath(new URL('../scripts/codex-rescue-run.mjs', hookUrl)))
|
|
41
|
+
if (node === null || launcher === null) return null
|
|
42
|
+
return { node, launcher }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Refusal set (§3.2-e): session cwd plus canonical tmp roots. */
|
|
46
|
+
export function refusalRoots(cwdRaw) {
|
|
47
|
+
return [safeReal(cwdRaw), safeReal(tmpdir()), safeReal('/tmp')].filter((p) => p !== null)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Arming predicate with machine-readable reasons. `hookUrl` is the calling
|
|
52
|
+
* hook's `import.meta.url`; `env` defaults to `process.env` (the hooks pass
|
|
53
|
+
* nothing and inherit it).
|
|
54
|
+
*/
|
|
55
|
+
export function arming(cwdRaw, { hookUrl, env = process.env } = {}) {
|
|
56
|
+
const anchors = deriveAnchors(hookUrl)
|
|
57
|
+
if (anchors === null) return { armed: false, reason: 'anchor-unrealpathable' }
|
|
58
|
+
const cwd = safeReal(typeof cwdRaw === 'string' && cwdRaw !== '' ? cwdRaw : process.cwd())
|
|
59
|
+
if (cwd === null) return { armed: false, reason: 'anchor-unrealpathable' }
|
|
60
|
+
const roots = refusalRoots(cwd)
|
|
61
|
+
if (inside(anchors.node, roots) || inside(anchors.launcher, roots)) {
|
|
62
|
+
return { armed: false, reason: 'anchor-under-writable-root' }
|
|
63
|
+
}
|
|
64
|
+
if (env.BASH_ENV) return { armed: false, reason: 'bash-env-set' }
|
|
65
|
+
if (env.ENV) return { armed: false, reason: 'env-set' }
|
|
66
|
+
return { armed: true, node: anchors.node, launcher: anchors.launcher, cwd }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The ARMED additionalContext block: the exact canonical invocation to type,
|
|
71
|
+
* both anchor paths POSIX single-quote-escaped so hostile install paths
|
|
72
|
+
* survive as pure data.
|
|
73
|
+
*/
|
|
74
|
+
export function armedText({ node, launcher }) {
|
|
75
|
+
const q = posixSingleQuote
|
|
76
|
+
return [
|
|
77
|
+
'cc-codex-bridge: the Codex rescue lane is ARMED. Use EXACTLY this canonical invocation for a rescue:',
|
|
78
|
+
`${q(node)} ${q(launcher)} -- 'the rescue prompt, single line'`,
|
|
79
|
+
'For a multi-line prompt, write the text to a file inside this workspace (or the canonical tmpdir), then run:',
|
|
80
|
+
`${q(node)} ${q(launcher)} --prompt-file 'the prompt file path'`,
|
|
81
|
+
'Add --last ONLY when the user explicitly asks to continue the previous rescue (it resumes the most recent rescue thread).',
|
|
82
|
+
].join('\n')
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** The REFUSED additionalContext block: machine reason in plain words. */
|
|
86
|
+
export function refusedText(reason) {
|
|
87
|
+
const words =
|
|
88
|
+
reason === 'anchor-under-writable-root'
|
|
89
|
+
? "the plugin's launcher sits under this session's workspace, expected in dsh-cc repo dev sessions"
|
|
90
|
+
: reason === 'bash-env-set'
|
|
91
|
+
? 'the ambient BASH_ENV variable is non-empty (shell-function takeover defense)'
|
|
92
|
+
: reason === 'env-set'
|
|
93
|
+
? 'the ambient ENV variable is non-empty (shell-function takeover defense)'
|
|
94
|
+
: 'the plugin\'s canonical anchors could not be resolved in this environment'
|
|
95
|
+
return [
|
|
96
|
+
`cc-codex-bridge: the Codex rescue lane is NOT armed (reason: ${reason} — ${words}).`,
|
|
97
|
+
'Codex rescue today goes through the normal, approval-requiring path; do NOT guess or construct the bridge\'s canonical bash invocation manually.',
|
|
98
|
+
].join('\n')
|
|
99
|
+
}
|