@minhspark/codex-mcp-bridge 1.10.0 → 1.11.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/CHANGELOG.md +39 -0
- package/LICENSE +21 -21
- package/README.md +16 -2
- package/package.json +2 -2
- package/scripts/check-claude-bridge.mjs +37 -37
- package/scripts/install-launch-agent.mjs +93 -93
- package/scripts/smoke.mjs +50 -50
- package/scripts/sync-version.mjs +17 -10
- package/src/app-server-client.mjs +414 -414
- package/src/claude-bridge.mjs +322 -322
- package/src/index.mjs +44 -15
- package/src/security-policy.mjs +206 -149
- package/src/turn.mjs +140 -140
package/src/index.mjs
CHANGED
|
@@ -19,7 +19,7 @@ import {
|
|
|
19
19
|
import { runTurn } from "./turn.mjs";
|
|
20
20
|
import { BridgeSecurityPolicy } from "./security-policy.mjs";
|
|
21
21
|
|
|
22
|
-
const VERSION = "1.
|
|
22
|
+
const VERSION = "1.11.0";
|
|
23
23
|
const log = (msg) => process.stderr.write(`[codex-mcp-bridge] ${msg}\n`);
|
|
24
24
|
|
|
25
25
|
/**
|
|
@@ -43,14 +43,38 @@ const textResult = (text, isError = false) => ({
|
|
|
43
43
|
|
|
44
44
|
const failure = (err) => textResult(`Codex bridge error: ${err?.message ?? String(err)}`, true);
|
|
45
45
|
|
|
46
|
+
/**
|
|
47
|
+
* Decides whether this bridge may act on a thread, before anything acts on it.
|
|
48
|
+
*
|
|
49
|
+
* Under `roots` the answer depends on where the thread works, which only
|
|
50
|
+
* `thread/read` reports - and it must be asked before `thread/resume`, because
|
|
51
|
+
* resuming takes the per-thread writer lock away from whoever else has the
|
|
52
|
+
* thread open. Reading first means a thread outside every root is refused
|
|
53
|
+
* without ever being locked. A thread the bridge already owns or the operator
|
|
54
|
+
* allowlisted skips the round-trip entirely: its answer cannot change.
|
|
55
|
+
*/
|
|
56
|
+
async function assertThreadAccess(threadId) {
|
|
57
|
+
if (security.isThreadAuthorized(threadId)) return null;
|
|
58
|
+
if (security.threadPolicy !== "roots") {
|
|
59
|
+
security.assertThread(threadId);
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
const res = await client.call("thread/read", { threadId });
|
|
63
|
+
const thread = res?.thread ?? res ?? {};
|
|
64
|
+
security.assertThread(threadId, thread.cwd);
|
|
65
|
+
security.assertCwd(thread.cwd);
|
|
66
|
+
return thread;
|
|
67
|
+
}
|
|
68
|
+
|
|
46
69
|
function formatThreadRow(t) {
|
|
47
70
|
const title = t.name || (t.preview ?? "").replace(/\s+/g, " ").slice(0, 70) || "(no title)";
|
|
48
71
|
const updated = t.updatedAt ? new Date(t.updatedAt * 1000).toISOString().replace("T", " ").slice(0, 16) : "?";
|
|
49
72
|
const status = t.status?.type ?? "?";
|
|
50
73
|
const deepLink = IS_MACOS && hasCodexDesktopApp() ? `\n open: ${codexThreadUrl(t.id)}` : "";
|
|
51
|
-
const authorized = security.isThreadAuthorized(t.id)
|
|
74
|
+
const authorized = security.isThreadAuthorized(t.id, t.cwd)
|
|
52
75
|
? ""
|
|
53
|
-
: "\n NOT AUTHORIZED: add this id to CODEX_BRIDGE_ALLOWED_THREADS
|
|
76
|
+
: "\n NOT AUTHORIZED: add this id to CODEX_BRIDGE_ALLOWED_THREADS, or set " +
|
|
77
|
+
"CODEX_BRIDGE_THREAD_POLICY=roots to reach every thread inside an allowed root";
|
|
54
78
|
return `- ${t.id}\n title: ${title}\n cwd: ${t.cwd ?? "?"}\n updated: ${updated} status: ${status} source: ${t.source ?? "?"}${deepLink}${authorized}`;
|
|
55
79
|
}
|
|
56
80
|
|
|
@@ -143,14 +167,7 @@ server.registerTool(
|
|
|
143
167
|
async ({ threadId, prompt, timeoutSec, cwd, model, effort, openInApp }) => {
|
|
144
168
|
let openNote = null;
|
|
145
169
|
try {
|
|
146
|
-
|
|
147
|
-
if (openInApp) {
|
|
148
|
-
try {
|
|
149
|
-
openNote = `opened in Codex app: ${await openThreadInCodexApp(threadId)}`;
|
|
150
|
-
} catch (err) {
|
|
151
|
-
openNote = `could not open the thread in the Codex app: ${err.message}`;
|
|
152
|
-
}
|
|
153
|
-
}
|
|
170
|
+
await assertThreadAccess(threadId);
|
|
154
171
|
let resolvedCwd = null;
|
|
155
172
|
if (cwd) {
|
|
156
173
|
const workspace = resolveWorkspacePath(cwd);
|
|
@@ -160,6 +177,18 @@ server.registerTool(
|
|
|
160
177
|
}
|
|
161
178
|
const attached = await client.ensureThreadAttached(threadId, resolvedCwd ? { cwd: resolvedCwd } : {});
|
|
162
179
|
security.assertCwd(attached.thread?.cwd);
|
|
180
|
+
/**
|
|
181
|
+
* Opening the thread in the app comes after both gates. It ran first
|
|
182
|
+
* once, which meant a thread this bridge was about to refuse still got
|
|
183
|
+
* raised on screen - a refusal that leaked which threads exist.
|
|
184
|
+
*/
|
|
185
|
+
if (openInApp) {
|
|
186
|
+
try {
|
|
187
|
+
openNote = `opened in Codex app: ${await openThreadInCodexApp(threadId)}`;
|
|
188
|
+
} catch (err) {
|
|
189
|
+
openNote = `could not open the thread in the Codex app: ${err.message}`;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
163
192
|
const result = await runTurn(client, {
|
|
164
193
|
threadId,
|
|
165
194
|
input: [{ type: "text", text: prompt }],
|
|
@@ -291,7 +320,7 @@ server.registerTool(
|
|
|
291
320
|
},
|
|
292
321
|
async ({ threadId, limit }) => {
|
|
293
322
|
try {
|
|
294
|
-
|
|
323
|
+
await assertThreadAccess(threadId);
|
|
295
324
|
const res = await client.call("thread/read", { threadId, includeTurns: true });
|
|
296
325
|
const thread = res?.thread ?? res ?? {};
|
|
297
326
|
security.assertCwd(thread.cwd);
|
|
@@ -334,7 +363,7 @@ server.registerTool(
|
|
|
334
363
|
},
|
|
335
364
|
async ({ threadId, turnId }) => {
|
|
336
365
|
try {
|
|
337
|
-
|
|
366
|
+
await assertThreadAccess(threadId);
|
|
338
367
|
const thread = await client.call("thread/read", { threadId });
|
|
339
368
|
security.assertCwd((thread?.thread ?? thread)?.cwd);
|
|
340
369
|
await client.call("turn/interrupt", { threadId, turnId });
|
|
@@ -368,7 +397,7 @@ server.registerTool(
|
|
|
368
397
|
},
|
|
369
398
|
async ({ threadId, background }) => {
|
|
370
399
|
try {
|
|
371
|
-
|
|
400
|
+
await assertThreadAccess(threadId);
|
|
372
401
|
const thread = await client.call("thread/read", { threadId });
|
|
373
402
|
security.assertCwd((thread?.thread ?? thread)?.cwd);
|
|
374
403
|
const url = await openThreadInCodexApp(threadId, { activate: !background });
|
|
@@ -442,7 +471,7 @@ server.registerTool(
|
|
|
442
471
|
`defaults: model ${DEFAULT_MODEL ?? "(from ~/.codex/config.toml)"}, effort ${DEFAULT_EFFORT ?? "(from ~/.codex/config.toml)"}`,
|
|
443
472
|
`app-server: ${client.url} - ${up ? "live" : "not reachable"}`,
|
|
444
473
|
`autostart: ${client.autoStart ? "on" : "off"} approvals: ${client.approval}`,
|
|
445
|
-
`security: ${security.summary().authorizedThreads} authorized thread(s), ${security.summary().allowedRoots.length} allowed root(s), sandbox ${security.sandbox},
|
|
474
|
+
`security: thread policy ${security.threadPolicy} (${security.summary().authorizedThreads} pre-authorized thread(s)), ${security.summary().allowedRoots.length} allowed root(s), sandbox ${security.sandbox}, approvals ${security.approvalPolicy}`,
|
|
446
475
|
`live threads: ${liveThreads ?? "(unknown)"}`,
|
|
447
476
|
`claude desktop config: ${claudeDesktopConfigPath()}`,
|
|
448
477
|
];
|
package/src/security-policy.mjs
CHANGED
|
@@ -1,149 +1,206 @@
|
|
|
1
|
-
import { realpathSync } from "node:fs";
|
|
2
|
-
import path from "node:path";
|
|
3
|
-
|
|
4
|
-
const APPROVAL_POLICIES = new Set(["untrusted", "on-failure", "on-request", "never"]);
|
|
5
|
-
const SANDBOXES = new Set(["read-only", "workspace-write"]);
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
*
|
|
10
|
-
* the
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
head
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
.
|
|
46
|
-
.
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
.
|
|
54
|
-
.
|
|
55
|
-
.
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
const
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
this.
|
|
82
|
-
this.
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
if (!
|
|
106
|
-
throw new Error(
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
1
|
+
import { realpathSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
const APPROVAL_POLICIES = new Set(["untrusted", "on-failure", "on-request", "never"]);
|
|
5
|
+
const SANDBOXES = new Set(["read-only", "workspace-write"]);
|
|
6
|
+
const THREAD_POLICIES = new Set(["owned", "roots"]);
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Resolves the deepest ancestor that exists and re-appends the rest, rather
|
|
10
|
+
* than giving up on the whole path when the leaf is missing. Falling back to
|
|
11
|
+
* the unresolved path made containment depend on the platform: macOS puts
|
|
12
|
+
* temporary and home directories behind symlinks (/var -> /private/var), so an
|
|
13
|
+
* allowed root canonicalised while a missing candidate did not, and the two
|
|
14
|
+
* stopped sharing a prefix; on Linux, with no symlink in the way, the same
|
|
15
|
+
* pair matched. Same policy, opposite answer, decided by a detail of the disk.
|
|
16
|
+
*
|
|
17
|
+
* Resolving the existing prefix keeps the protection that matters: a symlink
|
|
18
|
+
* pointing out of an allowed root resolves to where it really goes, so it is
|
|
19
|
+
* still recognised as outside.
|
|
20
|
+
*/
|
|
21
|
+
function canonicalPath(input) {
|
|
22
|
+
const resolved = path.resolve(input);
|
|
23
|
+
let head = resolved;
|
|
24
|
+
const missing = [];
|
|
25
|
+
for (;;) {
|
|
26
|
+
try {
|
|
27
|
+
return path.join(realpathSync.native(head), ...missing);
|
|
28
|
+
} catch {
|
|
29
|
+
const parent = path.dirname(head);
|
|
30
|
+
if (parent === head) return resolved;
|
|
31
|
+
missing.unshift(path.basename(head));
|
|
32
|
+
head = parent;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function isWithin(root, candidate) {
|
|
38
|
+
const relative = path.relative(root, candidate);
|
|
39
|
+
return relative === "" || (relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function parseList(value) {
|
|
43
|
+
return new Set(
|
|
44
|
+
String(value ?? "")
|
|
45
|
+
.split(",")
|
|
46
|
+
.map((entry) => entry.trim())
|
|
47
|
+
.filter(Boolean),
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function parseRoots(value) {
|
|
52
|
+
return String(value ?? "")
|
|
53
|
+
.split(path.delimiter)
|
|
54
|
+
.map((entry) => entry.trim())
|
|
55
|
+
.filter(Boolean)
|
|
56
|
+
.map(canonicalPath);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function assertAllowedAppServerUrl(value) {
|
|
60
|
+
let parsed;
|
|
61
|
+
try {
|
|
62
|
+
parsed = new URL(value);
|
|
63
|
+
} catch {
|
|
64
|
+
throw new Error(`Invalid CODEX_APP_SERVER_URL: ${value}`);
|
|
65
|
+
}
|
|
66
|
+
if (!new Set(["ws:", "wss:"]).has(parsed.protocol)) {
|
|
67
|
+
throw new Error(`CODEX_APP_SERVER_URL must use ws:// or wss://: ${value}`);
|
|
68
|
+
}
|
|
69
|
+
const hostname = parsed.hostname.toLowerCase().replace(/^\[|\]$/g, "");
|
|
70
|
+
const loopback = hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1";
|
|
71
|
+
if (!loopback) {
|
|
72
|
+
throw new Error(
|
|
73
|
+
`Refusing non-loopback app-server endpoint ${value}; the bridge only supports authenticated local app-servers`,
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
return parsed;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export class BridgeSecurityPolicy {
|
|
80
|
+
constructor(env = process.env) {
|
|
81
|
+
this.allowedThreadIds = parseList(env.CODEX_BRIDGE_ALLOWED_THREADS);
|
|
82
|
+
this.allowedRoots = parseRoots(env.CODEX_BRIDGE_ALLOWED_ROOTS);
|
|
83
|
+
this.ownedThreadIds = new Set();
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Which threads this bridge may act on, beyond the ones it created itself.
|
|
87
|
+
*
|
|
88
|
+
* `owned` was the only behaviour until now, and it does not merely
|
|
89
|
+
* restrict the everyday workflow - it makes it impossible. A thread opened
|
|
90
|
+
* in the Codex app or the VS Code extension is given its id at that
|
|
91
|
+
* moment, so it can never have appeared in CODEX_BRIDGE_ALLOWED_THREADS
|
|
92
|
+
* beforehand; and the bridge-owned set lives in memory, so it empties
|
|
93
|
+
* every time the MCP server restarts. That left the operator allowlisting
|
|
94
|
+
* an id that is already stale by the next turn, and every thread a human
|
|
95
|
+
* actually opened answered "not authorized".
|
|
96
|
+
*
|
|
97
|
+
* `roots` grants on the workspace instead of the id: a thread already
|
|
98
|
+
* working inside a directory the operator declared in scope is reachable.
|
|
99
|
+
* This is not a weaker gate bolted on - it is the same containment every
|
|
100
|
+
* acting tool already enforces on the cwd it is handed, applied to the cwd
|
|
101
|
+
* the thread itself reports. It stays opt-in so an existing install cannot
|
|
102
|
+
* widen silently on upgrade.
|
|
103
|
+
*/
|
|
104
|
+
this.threadPolicy = env.CODEX_BRIDGE_THREAD_POLICY ?? "owned";
|
|
105
|
+
if (!THREAD_POLICIES.has(this.threadPolicy)) {
|
|
106
|
+
throw new Error(`CODEX_BRIDGE_THREAD_POLICY must be owned or roots: ${this.threadPolicy}`);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
this.approvalPolicy = env.CODEX_BRIDGE_APPROVAL_POLICY ?? "on-request";
|
|
110
|
+
if (!APPROVAL_POLICIES.has(this.approvalPolicy)) {
|
|
111
|
+
throw new Error(`Invalid CODEX_BRIDGE_APPROVAL_POLICY: ${this.approvalPolicy}`);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
this.sandbox = env.CODEX_BRIDGE_SANDBOX ?? "workspace-write";
|
|
115
|
+
if (!SANDBOXES.has(this.sandbox)) {
|
|
116
|
+
throw new Error(`CODEX_BRIDGE_SANDBOX must be read-only or workspace-write: ${this.sandbox}`);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
registerThread(threadId) {
|
|
121
|
+
if (threadId) this.ownedThreadIds.add(threadId);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* `cwd` is optional because the id almost always arrives before the
|
|
126
|
+
* workspace does: a caller holds an id from a listing, and the cwd is only
|
|
127
|
+
* known once the thread has been read. Under `owned` the answer never
|
|
128
|
+
* depended on the workspace, so omitting it changes nothing. Under `roots`
|
|
129
|
+
* an unknown workspace is never a grant - a thread that cannot be placed
|
|
130
|
+
* inside a root is refused exactly like one placed outside it.
|
|
131
|
+
*/
|
|
132
|
+
isThreadAuthorized(threadId, cwd) {
|
|
133
|
+
if (this.ownedThreadIds.has(threadId) || this.allowedThreadIds.has(threadId)) return true;
|
|
134
|
+
if (this.threadPolicy !== "roots") return false;
|
|
135
|
+
return cwd == null ? false : this.isCwdAuthorized(cwd);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
assertThread(threadId, cwd) {
|
|
139
|
+
if (this.isThreadAuthorized(threadId, cwd)) return;
|
|
140
|
+
|
|
141
|
+
if (this.threadPolicy === "roots") {
|
|
142
|
+
/**
|
|
143
|
+
* Refused here rather than waved through to a later cwd check, because
|
|
144
|
+
* the caller has to attach to a thread before it can act on it, and
|
|
145
|
+
* attaching takes the per-thread writer lock away from whoever else has
|
|
146
|
+
* the thread open. Deciding afterwards would mean a thread outside every
|
|
147
|
+
* root still got locked on the way to being rejected.
|
|
148
|
+
*/
|
|
149
|
+
throw new Error(
|
|
150
|
+
cwd == null
|
|
151
|
+
? `Codex thread ${threadId} reports no workspace, so it cannot be matched against CODEX_BRIDGE_ALLOWED_ROOTS`
|
|
152
|
+
: `Codex thread ${threadId} works in ${cwd}, which is outside CODEX_BRIDGE_ALLOWED_ROOTS`,
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (!this.allowedThreadIds.size && !this.ownedThreadIds.size) {
|
|
157
|
+
throw new Error(
|
|
158
|
+
"No authorized Codex threads are configured. Set CODEX_BRIDGE_ALLOWED_THREADS, create one with " +
|
|
159
|
+
"start_codex_thread, or set CODEX_BRIDGE_THREAD_POLICY=roots to reach any thread already working " +
|
|
160
|
+
"inside CODEX_BRIDGE_ALLOWED_ROOTS.",
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
throw new Error(
|
|
164
|
+
`Codex thread ${threadId} is not authorized for this bridge. Add it to CODEX_BRIDGE_ALLOWED_THREADS, ` +
|
|
165
|
+
"or set CODEX_BRIDGE_THREAD_POLICY=roots to reach any thread inside CODEX_BRIDGE_ALLOWED_ROOTS.",
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Listing is gated on the workspace root, not on the send allowlist. Gating
|
|
171
|
+
* both ways left no path to a thread id at all: you cannot allowlist a
|
|
172
|
+
* thread whose id you have no way to learn, so the only usable thread was
|
|
173
|
+
* one the bridge had created itself. An operator who names a root has
|
|
174
|
+
* declared that project in scope, and under the default `owned` policy the
|
|
175
|
+
* id is still useless without being allowlisted for the calls that act.
|
|
176
|
+
*/
|
|
177
|
+
filterThreads(threads) {
|
|
178
|
+
return threads.filter((thread) => this.isCwdAuthorized(thread?.cwd));
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
isCwdAuthorized(cwd) {
|
|
182
|
+
if (!this.allowedRoots.length || !cwd) return false;
|
|
183
|
+
const candidate = canonicalPath(cwd);
|
|
184
|
+
return this.allowedRoots.some((root) => isWithin(root, candidate));
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
assertCwd(cwd) {
|
|
188
|
+
if (!this.allowedRoots.length) {
|
|
189
|
+
throw new Error(
|
|
190
|
+
"No authorized workspace roots are configured. Set CODEX_BRIDGE_ALLOWED_ROOTS to one or more project directories.",
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
if (this.isCwdAuthorized(cwd)) return;
|
|
194
|
+
throw new Error(`Working directory is outside CODEX_BRIDGE_ALLOWED_ROOTS: ${cwd}`);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
summary() {
|
|
198
|
+
return {
|
|
199
|
+
authorizedThreads: this.allowedThreadIds.size + this.ownedThreadIds.size,
|
|
200
|
+
allowedRoots: this.allowedRoots,
|
|
201
|
+
threadPolicy: this.threadPolicy,
|
|
202
|
+
approvalPolicy: this.approvalPolicy,
|
|
203
|
+
sandbox: this.sandbox,
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
}
|