From c13ca33be70236bea35145ec59f38d6c70552375 Mon Sep 17 00:00:00 2001 From: Sterling Date: Sat, 21 Mar 2026 20:22:17 -0400 Subject: [PATCH 1/9] chore: open PR for P120 codex-proxy build From 6a54d8b1cb5e3721931c25916df4e6fa01f13b68 Mon Sep 17 00:00:00 2001 From: Sterling Date: Sun, 22 Mar 2026 09:53:46 -0400 Subject: [PATCH 2/9] Fix emitDelta return guard and onTokenUsageUpdated slot leak MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug 1: add return after triggerCleanup in emitDelta when accumulatedSize exceeds maxSize — without it, subsequent deltas still accumulate content after the error cleanup is triggered. Bug 2: call triggerCleanup after sendNonStreamingResponse in onTokenUsageUpdated grace period path — slot was never released, in-flight entry was never removed, archive never ran. Co-Authored-By: Claude Sonnet 4.6 --- src/client/app-server.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/client/app-server.ts b/src/client/app-server.ts index c4b8664..afb0201 100644 --- a/src/client/app-server.ts +++ b/src/client/app-server.ts @@ -608,6 +608,7 @@ export class AppServerClient { message: 'Response too large', errorType: 'server_error', }); + return; } } } @@ -661,8 +662,9 @@ export class AppServerClient { if (!inflight.stream && inflight.gracePeriodTimer) { clearTimeout(inflight.gracePeriodTimer); inflight.gracePeriodTimer = null; - // Send the non-streaming response now + // Send the non-streaming response now, then release the slot and archive this.sendNonStreamingResponse(inflight); + this.triggerCleanup(inflight, { type: 'success' }); } } From d20289500f98562f65e90ce5275faed31868381e Mon Sep 17 00:00:00 2001 From: Sterling Date: Sun, 22 Mar 2026 10:05:25 -0400 Subject: [PATCH 3/9] =?UTF-8?q?fix:=20M1-11=20=E2=80=94=20delta=20turnId?= =?UTF-8?q?=20not=20used=20to=20resolve=20buffer=20(app-server.ts)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In onAgentMessageDelta, destructured turnId was never applied to inflight.turnId. Spec requires: delta notifications themselves carry turnId and must set inflight.turnId if not already set, triggering buffer replay. Without this, the race window where deltas arrive before both turn/start response and turn/started notification leaves the buffer unresolved until turn/started eventually arrives. Fix: if !inflight.turnId && turnId, set inflight.turnId = turnId and call flushDeltaBuffer before falling through to emit current delta. Co-Authored-By: Claude Sonnet 4.6 --- src/client/app-server.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/client/app-server.ts b/src/client/app-server.ts index afb0201..b195871 100644 --- a/src/client/app-server.ts +++ b/src/client/app-server.ts @@ -493,7 +493,17 @@ export class AppServerClient { } if (inflight.cleanupDone) return; - // If turnId not yet set, buffer the delta + // If turnId not yet set, try to resolve from delta params before buffering + if (!inflight.turnId) { + if (turnId) { + // Delta carries turnId — resolve it now and flush buffer + inflight.turnId = turnId; + this.flushDeltaBuffer(inflight); + // Fall through to emit current delta normally (turnId is now set) + } + } + + // If turnId still not set after attempting resolution, buffer the delta if (!inflight.turnId) { // Check buffer limits inflight.deltaBufferSize += delta.length; From ce94433b0916540fabd4c932cb59d3b83b77378d Mon Sep 17 00:00:00 2001 From: Sterling Date: Sun, 22 Mar 2026 22:10:11 -0400 Subject: [PATCH 4/9] Fix TINFOIL rejections: env-var injection guard + localhost CORS (PROJECT-SPEC.md) --- scripts/codex-auth-check.sh | 12 ++++++------ src/server/routes.ts | 10 ++++++++-- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/scripts/codex-auth-check.sh b/scripts/codex-auth-check.sh index a219e62..5bc9f27 100755 --- a/scripts/codex-auth-check.sh +++ b/scripts/codex-auth-check.sh @@ -20,10 +20,10 @@ fi # ─── Extract access token ───────────────────────────────────────────────────── -ACCESS_TOKEN=$(python3 -c " -import json, sys +ACCESS_TOKEN=$(AUTH_FILE="${AUTH_FILE}" python3 -c " +import json, sys, os try: - data = json.load(open('${AUTH_FILE}')) + data = json.load(open(os.environ['AUTH_FILE'])) token = data.get('accessToken') or data.get('access_token') or data.get('token') if not token: print('', end='') @@ -43,10 +43,10 @@ fi # ─── Decode JWT exp claim ───────────────────────────────────────────────────── -EXP=$(python3 -c " -import base64, json, sys +EXP=$(ACCESS_TOKEN="${ACCESS_TOKEN}" python3 -c " +import base64, json, sys, os -token = '${ACCESS_TOKEN}' +token = os.environ['ACCESS_TOKEN'] parts = token.split('.') if len(parts) != 3: print(-1) diff --git a/src/server/routes.ts b/src/server/routes.ts index 0221f78..d0a04b5 100644 --- a/src/server/routes.ts +++ b/src/server/routes.ts @@ -138,8 +138,14 @@ export function buildRouter(client: AppServerClient): Router { router.use(limiter); // ─── CORS ────────────────────────────────────────────────────────────────── - router.use((_req: Request, res: Response, next: NextFunction) => { - res.setHeader('Access-Control-Allow-Origin', '*'); + // Restrict to localhost origins only — this proxy is local-only + router.use((req: Request, res: Response, next: NextFunction) => { + const origin = req.headers.origin; + if (origin && /^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/.test(origin)) { + res.setHeader('Access-Control-Allow-Origin', origin); + } else { + res.setHeader('Access-Control-Allow-Origin', 'http://localhost'); + } res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); res.setHeader('Access-Control-Allow-Headers', 'Authorization, Content-Type'); next(); From faf0f7710e734935ef435de045d0443fced73020 Mon Sep 17 00:00:00 2001 From: Sterling Date: Mon, 23 Mar 2026 04:17:58 -0400 Subject: [PATCH 5/9] fix(auth-check): also check tokens.access_token in auth.json The script only checked top-level keys (accessToken, access_token, token) but the actual auth.json nests the token under tokens.access_token. This caused hourly false alarms in #infra-agent-swarm. --- scripts/codex-auth-check.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/codex-auth-check.sh b/scripts/codex-auth-check.sh index 5bc9f27..e70db97 100755 --- a/scripts/codex-auth-check.sh +++ b/scripts/codex-auth-check.sh @@ -24,7 +24,7 @@ ACCESS_TOKEN=$(AUTH_FILE="${AUTH_FILE}" python3 -c " import json, sys, os try: data = json.load(open(os.environ['AUTH_FILE'])) - token = data.get('accessToken') or data.get('access_token') or data.get('token') + token = data.get('accessToken') or data.get('access_token') or data.get('token') or (data.get('tokens') or {}).get('access_token') if not token: print('', end='') else: From 080561786f567789fefe4c088778911d22a64328 Mon Sep 17 00:00:00 2001 From: Sterling Date: Mon, 23 Mar 2026 09:46:36 -0400 Subject: [PATCH 6/9] =?UTF-8?q?fix:=20deploy=20fixes=20=E2=80=94=20ESM/CJS?= =?UTF-8?q?=20ecosystem=20config,=20response=20field=20names,=20flat=20thr?= =?UTF-8?q?eadId?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three issues found and fixed during production deploy: 1. ecosystem.config.js → .cjs: package.json has "type": "module", PM2 can't load CommonJS module.exports in ESM context. 2. model/list and thread/list responses use `data` field, not `models`/`threads`. Fixed in types and app-server code. 3. TurnCompleted notification sends flat `threadId`, not nested `thread.id`. Made handler resilient to both shapes. Co-Authored-By: Claude Opus 4.6 --- ecosystem.config.js => ecosystem.config.cjs | 0 src/client/app-server.ts | 6 +++--- src/types/codex.ts | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) rename ecosystem.config.js => ecosystem.config.cjs (100%) diff --git a/ecosystem.config.js b/ecosystem.config.cjs similarity index 100% rename from ecosystem.config.js rename to ecosystem.config.cjs diff --git a/src/client/app-server.ts b/src/client/app-server.ts index b195871..48ebdd8 100644 --- a/src/client/app-server.ts +++ b/src/client/app-server.ts @@ -738,7 +738,7 @@ export class AppServerClient { } private onTurnCompleted(params: TurnCompletedParams): void { - const threadId = params.thread.id; + const threadId = params.thread?.id ?? (params as unknown as { threadId: string }).threadId; const turn = params.turn; const inflight = this.inFlightRequests.get(threadId); @@ -1057,7 +1057,7 @@ export class AppServerClient { if (cursor) params.cursor = cursor; const result = await this.sendRequest('model/list', params); - models.push(...result.models); + models.push(...result.data); cursor = result.nextCursor ?? undefined; } while (cursor); @@ -1100,7 +1100,7 @@ export class AppServerClient { const result = await this.sendRequest('thread/list', params); cursor = result.nextCursor ?? undefined; - for (const thread of result.threads) { + for (const thread of result.data) { // Check in-flight map immediately before each archive if (this.inFlightRequests.has(thread.id)) { log.debug('Skipping active thread in orphan sweep', { threadId: thread.id }); diff --git a/src/types/codex.ts b/src/types/codex.ts index 9e32c5f..75b06b1 100644 --- a/src/types/codex.ts +++ b/src/types/codex.ts @@ -171,7 +171,7 @@ export interface ThreadListItem { } export interface ThreadListResult { - threads: ThreadListItem[]; + data: ThreadListItem[]; nextCursor: string | null; } @@ -198,7 +198,7 @@ export interface Model { } export interface ModelListResult { - models: Model[]; + data: Model[]; nextCursor: string | null; } From 4dfc55309f07de9a2b103e3b5d5ecc616f09c755 Mon Sep 17 00:00:00 2001 From: Sterling Date: Mon, 23 Mar 2026 09:46:48 -0400 Subject: [PATCH 7/9] =?UTF-8?q?docs:=20fix=20PM2=20command=20in=20CLAUDE.m?= =?UTF-8?q?d=20=E2=80=94=20use=20.cjs=20extension?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 29692d5..e398558 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -43,9 +43,9 @@ OpenAI Chat Completions–compatible HTTP proxy that routes inference through th ## PM2 Management ``` -pm2 startOrRestart ecosystem.config.js -pm2 save +cd ~/codex-proxy && pm2 start ecosystem.config.cjs && pm2 save ``` +Note: ecosystem config uses `.cjs` extension because `package.json` has `"type": "module"`. ## Manual Re-auth Procedure 1. `codex login --device-auth` on gpu1 From 1b983f57f4a84c9581592488aa4b35783b2284ce Mon Sep 17 00:00:00 2001 From: Sterling Date: Mon, 23 Mar 2026 19:51:08 -0400 Subject: [PATCH 8/9] Route codex-auth-check alerts to #status-updates channel Changed ALERT_CHANNEL from #infra-agent-swarm (1475832162648461316) to #status-updates (1485787606561062942) to reduce noise in the architecture discussion channel. Co-Authored-By: Claude Opus 4.6 --- scripts/codex-auth-check.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/codex-auth-check.sh b/scripts/codex-auth-check.sh index e70db97..937a91f 100755 --- a/scripts/codex-auth-check.sh +++ b/scripts/codex-auth-check.sh @@ -1,12 +1,12 @@ #!/usr/bin/env bash # Codex auth health check — reads ~/.codex/auth.json, decodes JWT exp claim, -# alerts via Discord #infra-agent-swarm (1475832162648461316) if expiry within 48 hours. +# alerts via Discord #status-updates (1485787606561062942) if expiry within 48 hours. # Schedule: every 1 hour via OpenClaw cron. set -euo pipefail AUTH_FILE="${HOME}/.codex/auth.json" -ALERT_CHANNEL="1475832162648461316" +ALERT_CHANNEL="1485787606561062942" WARN_SECONDS=$((48 * 3600)) # 48 hours # ─── Read auth.json ─────────────────────────────────────────────────────────── From b4684ea7207d59cd1a4ae5ee281e73cdc1d8a204 Mon Sep 17 00:00:00 2001 From: Sterling Date: Tue, 24 Mar 2026 22:22:08 -0400 Subject: [PATCH 9/9] feat: policy-gated tool approval (v1.1-tool-approval.spec.md) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements per-request approve/deny decisions for Codex CLI tool execution requests instead of v1.0 blanket denial. - src/policy/tool-policy.ts: pure evaluation functions for commandExecution and fileChange. Configurable via ~/codex-proxy/tool-policy.json, reloaded on SIGHUP. Fail-safe: missing/malformed config → all-deny. - src/types/codex.ts: add CommandExecutionParams, FileChangeParams, NetworkApprovalContext, CommandAction, and approval response types. - src/client/app-server.ts: wire policy evaluation into handleServerRequest(). commandExecution and fileChange use policy; permissions always denied; legacy methods fall through to policy. - tool-policy.json: default config — denylist blocks shells/privesc/network tools; allowlist covers read/build/git/util ops; protected paths cover ~/.openclaw, ~/.ssh, /etc, /var; allowedWritePaths: ~/codex-proxy and /tmp. - CLAUDE.md: add invariant #11, forbidden pattern #3, env vars. - tests/tool-policy.test.mjs: 43 tests covering allowlist, denylist priority, word-boundary matching, command chaining, protected paths, network denial, null/missing params, commandActions auto-approve, kill switch, fail-safe. Co-Authored-By: Claude Sonnet 4.6 --- CLAUDE.md | 5 +- package.json | 3 +- src/client/app-server.ts | 66 +++++++-- src/policy/tool-policy.ts | 286 +++++++++++++++++++++++++++++++++++++ src/types/codex.ts | 58 ++++++++ tests/tool-policy.test.mjs | 171 ++++++++++++++++++++++ tool-policy.json | 56 ++++++++ 7 files changed, 635 insertions(+), 10 deletions(-) create mode 100644 src/policy/tool-policy.ts create mode 100644 tests/tool-policy.test.mjs create mode 100644 tool-policy.json diff --git a/CLAUDE.md b/CLAUDE.md index e398558..23fe258 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -18,13 +18,14 @@ OpenAI Chat Completions–compatible HTTP proxy that routes inference through th 8. `thread/start` failure routes through cleanup function (not inline release) 9. Use `Model.model` (NOT `Model.id`) for `/v1/models` id field 10. `sourceKinds: ["custom"]` on `thread/list` for orphan reconciliation +11. The proxy evaluates CLI-native tool execution requests against a policy config. Commands and file writes are approved/denied per policy rules. MCP/dynamic tools remain denied. ## Forbidden Patterns - No reverse-engineering private APIs (no direct chatgpt.com calls) - No credential extraction or impersonation -- No tool execution passthrough - No thread reuse across requests - No OpenClaw tool mapping injection +- No MCP/dynamic tool execution. CLI-native tools are policy-gated — see `tool-policy.json`. Override: `CODEX_TOOL_APPROVAL=deny` disables all approvals. ## Environment Variables - `CODEX_PROXY_PORT` (default 3460) @@ -40,6 +41,8 @@ OpenAI Chat Completions–compatible HTTP proxy that routes inference through th - `CODEX_ORPHAN_SWEEP_INTERVAL_MS` (default 900000) - `CODEX_DEGRADATION_THRESHOLD` (default 5) - `CODEX_MAX_RESPONSE_SIZE` (default 5242880) +- `CODEX_TOOL_APPROVAL` — set to `deny` to revert all tool approvals to blanket denial (v1.0 behavior) +- `CODEX_TOOL_POLICY_PATH` — override path to `tool-policy.json` (default `~/codex-proxy/tool-policy.json`) ## PM2 Management ``` diff --git a/package.json b/package.json index 4d4eaf4..3476f0e 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,8 @@ "type": "module", "scripts": { "build": "tsc", - "start": "node dist/server/standalone.js" + "start": "node dist/server/standalone.js", + "test": "npm run build && node tests/tool-policy.test.mjs" }, "dependencies": { "express": "^4.21.2", diff --git a/src/client/app-server.ts b/src/client/app-server.ts index 48ebdd8..88f9e76 100644 --- a/src/client/app-server.ts +++ b/src/client/app-server.ts @@ -28,16 +28,23 @@ import type { ErrorNotificationParams, AnyServerRequest, CommandExecutionDenial, + CommandExecutionApproval, + CommandExecutionParams, FileChangeDenial, + FileChangeApproval, + FileChangeParams, PermissionsRequestApprovalResponse, ToolRequestUserInputResponse, ToolCallDenial, McpElicitationDenial, ApplyPatchDenial, + ApplyPatchApprovalResult, ExecCommandDenial, + ExecCommandApprovalResult, TurnInterruptParams, UserInput, } from '../types/codex.js'; +import { evaluateCommandExecution, evaluateFileChange, getConfig } from '../policy/tool-policy.js'; // ─── Module-level ID counter (NEVER resets) ────────────────────────────────── let nextId = 1; @@ -405,16 +412,33 @@ export class AppServerClient { switch (method) { case 'item/commandExecution/requestApproval': { - const denial: CommandExecutionDenial = { decision: 'decline' }; - result = denial; + const params = req.params as CommandExecutionParams | undefined; + const decision = evaluateCommandExecution(params, getConfig()); + log.info('Tool policy', { method, decision: decision.approved ? 'APPROVED' : 'DENIED', reason: decision.reason, command: params?.command }); + if (decision.approved) { + const approval: CommandExecutionApproval = { decision: 'accept' }; + result = approval; + } else { + const denial: CommandExecutionDenial = { decision: 'decline' }; + result = denial; + } break; } case 'item/fileChange/requestApproval': { - const denial: FileChangeDenial = { decision: 'decline' }; - result = denial; + const params = req.params as FileChangeParams | undefined; + const decision = evaluateFileChange(params, getConfig()); + log.info('Tool policy', { method, decision: decision.approved ? 'APPROVED' : 'DENIED', reason: decision.reason, grantRoot: params?.grantRoot }); + if (decision.approved) { + const approval: FileChangeApproval = { decision: 'accept' }; + result = approval; + } else { + const denial: FileChangeDenial = { decision: 'decline' }; + result = denial; + } break; } case 'item/permissions/requestApproval': { + log.info('Tool policy', { method, decision: 'DENIED', reason: 'permissions always denied' }); const denial: PermissionsRequestApprovalResponse = { permissions: {}, scope: 'turn' }; result = denial; break; @@ -435,13 +459,39 @@ export class AppServerClient { break; } case 'applyPatchApproval': { - const denial: ApplyPatchDenial = { decision: 'denied' }; - result = denial; + // Legacy: treat as file change — extract path from params if available + const rawParams = req.params as Record | undefined; + const grantRoot = typeof rawParams?.['path'] === 'string' ? rawParams['path'] : null; + const decision = evaluateFileChange( + grantRoot ? { itemId: '', threadId: '', turnId: '', grantRoot } : null, + getConfig(), + ); + log.info('Tool policy', { method, decision: decision.approved ? 'APPROVED' : 'DENIED', reason: decision.reason, grantRoot }); + if (decision.approved) { + const approval: ApplyPatchApprovalResult = { decision: 'approved' }; + result = approval; + } else { + const denial: ApplyPatchDenial = { decision: 'denied' }; + result = denial; + } break; } case 'execCommandApproval': { - const denial: ExecCommandDenial = { decision: 'denied' }; - result = denial; + // Legacy: treat as command execution + const rawParams = req.params as Record | undefined; + const command = typeof rawParams?.['command'] === 'string' ? rawParams['command'] : null; + const decision = evaluateCommandExecution( + command ? { itemId: '', threadId: '', turnId: '', command } : null, + getConfig(), + ); + log.info('Tool policy', { method, decision: decision.approved ? 'APPROVED' : 'DENIED', reason: decision.reason, command }); + if (decision.approved) { + const approval: ExecCommandApprovalResult = { decision: 'approved' }; + result = approval; + } else { + const denial: ExecCommandDenial = { decision: 'denied' }; + result = denial; + } break; } default: diff --git a/src/policy/tool-policy.ts b/src/policy/tool-policy.ts new file mode 100644 index 0000000..f0dcbbb --- /dev/null +++ b/src/policy/tool-policy.ts @@ -0,0 +1,286 @@ +// Policy-gated tool approval — pure functions, no I/O + +import { readFileSync } from 'fs'; +import { homedir } from 'os'; +import { join, isAbsolute } from 'path'; +import type { CommandExecutionParams, FileChangeParams } from '../types/codex.js'; + +// ─── Config ─────────────────────────────────────────────────────────────────── + +export interface PolicyConfig { + version: 1; + enabled: boolean; + commandDenyPatterns: string[]; + commandAllowPrefixes: string[]; + protectedPaths: string[]; + protectedFiles: string[]; + allowedWritePaths: string[]; + denyNetwork: boolean; +} + +export interface PolicyDecision { + approved: boolean; + reason: string; +} + +// ─── Tilde resolution ───────────────────────────────────────────────────────── + +function resolveTilde(p: string): string { + if (p.startsWith('~/') || p === '~') { + return join(homedir(), p.slice(1)); + } + return p; +} + +function resolvePaths(paths: string[]): string[] { + return paths.map(resolveTilde); +} + +// ─── Config loader ──────────────────────────────────────────────────────────── + +const DEFAULT_POLICY_PATH = join(homedir(), 'codex-proxy', 'tool-policy.json'); + +// All-deny config used when file is missing or unparseable +const ALL_DENY_CONFIG: PolicyConfig = { + version: 1, + enabled: true, + commandDenyPatterns: ['.*'], + commandAllowPrefixes: [], + protectedPaths: [], + protectedFiles: [], + allowedWritePaths: [], + denyNetwork: true, +}; + +function resolveConfigPaths(raw: PolicyConfig): PolicyConfig { + return { + ...raw, + protectedPaths: resolvePaths(raw.protectedPaths), + allowedWritePaths: resolvePaths(raw.allowedWritePaths), + }; +} + +function loadConfig(path: string): PolicyConfig { + try { + const text = readFileSync(path, 'utf-8'); + const parsed = JSON.parse(text) as PolicyConfig; + if (parsed.version !== 1) { + throw new Error(`Unsupported policy version: ${parsed.version}`); + } + return resolveConfigPaths(parsed); + } catch { + return ALL_DENY_CONFIG; + } +} + +// Module-level config reference — swapped atomically on SIGHUP +let _policyPath = process.env['CODEX_TOOL_POLICY_PATH'] ?? DEFAULT_POLICY_PATH; +let _config: PolicyConfig = loadConfig(_policyPath); + +const log = { + info: (msg: string, data?: unknown) => { + console.log(JSON.stringify({ level: 'info', msg, ...flattenData(data) })); + }, + warn: (msg: string, data?: unknown) => { + console.warn(JSON.stringify({ level: 'warn', msg, ...flattenData(data) })); + }, +}; + +function flattenData(data?: unknown): Record { + if (!data || typeof data !== 'object') return {}; + return data as Record; +} + +export function getConfig(): PolicyConfig { + return _config; +} + +export function reloadConfig(): void { + try { + const text = readFileSync(_policyPath, 'utf-8'); + const parsed = JSON.parse(text) as PolicyConfig; + if (parsed.version !== 1) { + throw new Error(`Unsupported policy version: ${parsed.version}`); + } + _config = resolveConfigPaths(parsed); + log.info('Tool policy reloaded', { path: _policyPath }); + } catch (err) { + log.warn('Tool policy reload failed — retaining old config', { + path: _policyPath, + error: String(err), + }); + } +} + +// Register SIGHUP handler once +process.on('SIGHUP', reloadConfig); + +// ─── Command chaining: split on ; && || | ───────────────────────────────── + +function splitCommandChain(command: string): string[] { + // Split on ; && || | — but we want to keep the sub-commands, not the operators + // Use a regex that matches the delimiters + return command.split(/\s*(?:;|&&|\|\||(? s.trim()).filter(Boolean); +} + +// ─── Word-boundary-aware prefix match ──────────────────────────────────────── + +function matchesPrefix(command: string, prefix: string): boolean { + if (command === prefix) return true; + if (command.startsWith(prefix + ' ')) return true; + if (command.startsWith(prefix + '\t')) return true; + return false; +} + +// ─── Protected path check ───────────────────────────────────────────────────── + +function containsProtectedPath(command: string, config: PolicyConfig): string | null { + for (const p of config.protectedPaths) { + if (command.includes(p)) return p; + } + for (const f of config.protectedFiles) { + // word-boundary-ish check: filename appears in command + const re = new RegExp(`(?:^|[/\\s])${escapeRegex(f)}(?:[\\s$]|$)`); + if (re.test(command) || command.endsWith('/' + f) || command.includes('/' + f + ' ') || command.includes('/' + f + '\t')) return f; + // also plain filename at start or after whitespace + if (command === f || command.startsWith(f + ' ') || command.includes(' ' + f + ' ') || command.endsWith(' ' + f)) return f; + } + return null; +} + +function escapeRegex(s: string): string { + return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +// ─── Command execution policy ───────────────────────────────────────────────── + +export function evaluateCommandExecution( + params: CommandExecutionParams | undefined | null, + config: PolicyConfig, +): PolicyDecision { + // Kill switch + if (process.env['CODEX_TOOL_APPROVAL'] === 'deny') { + return { approved: false, reason: 'kill switch: CODEX_TOOL_APPROVAL=deny' }; + } + + // Policy disabled → deny (fail-safe) + if (!config.enabled) { + return { approved: false, reason: 'policy disabled' }; + } + + // Null/missing params → deny + if (!params) { + return { approved: false, reason: 'null params' }; + } + + const command = params.command; + + // Null/missing/empty command → deny + if (command == null || command === '') { + return { approved: false, reason: 'null or empty command' }; + } + + // Step 1: Network deny + if (params.networkApprovalContext != null && config.denyNetwork) { + return { approved: false, reason: `network denied: ${params.networkApprovalContext.host}` }; + } + + // Split for chaining checks + const subCommands = splitCommandChain(command); + const denyPatterns = config.commandDenyPatterns.map(p => new RegExp(p)); + + // Step 2: Deny list — check full command AND each sub-command + const toCheck = [command, ...subCommands]; + for (const part of toCheck) { + for (const re of denyPatterns) { + if (re.test(part)) { + return { approved: false, reason: `deny pattern: ${re.source}` }; + } + } + } + + // Step 3: Protected path check — scan full command string + const hitPath = containsProtectedPath(command, config); + if (hitPath) { + return { approved: false, reason: `protected path: ${hitPath}` }; + } + + // Step 4: commandActions — if all are read-only types, approve + if (params.commandActions != null && params.commandActions.length > 0) { + const readOnlyTypes = new Set(['read', 'listFiles', 'search']); + const allReadOnly = params.commandActions.every(a => readOnlyTypes.has(a.type)); + if (allReadOnly) { + return { approved: true, reason: 'commandActions: all read-only' }; + } + } + + // Step 5: Allow list — check first sub-command + const firstSubCommand = subCommands[0] ?? command; + for (const prefix of config.commandAllowPrefixes) { + if (matchesPrefix(firstSubCommand, prefix)) { + return { approved: true, reason: `allow prefix: ${prefix}` }; + } + } + + // Step 6: Default deny + return { approved: false, reason: 'not in allowlist' }; +} + +// ─── File change policy ─────────────────────────────────────────────────────── + +export function evaluateFileChange( + params: FileChangeParams | undefined | null, + config: PolicyConfig, +): PolicyDecision { + // Kill switch + if (process.env['CODEX_TOOL_APPROVAL'] === 'deny') { + return { approved: false, reason: 'kill switch: CODEX_TOOL_APPROVAL=deny' }; + } + + // Policy disabled → deny (fail-safe) + if (!config.enabled) { + return { approved: false, reason: 'policy disabled' }; + } + + // Null/missing params → deny + if (!params) { + return { approved: false, reason: 'null params' }; + } + + const grantRoot = params.grantRoot; + + // Null/missing/empty grantRoot → deny + if (grantRoot == null || grantRoot === '') { + return { approved: false, reason: 'null or empty grantRoot' }; + } + + const resolvedPath = isAbsolute(grantRoot) ? grantRoot : join(process.cwd(), grantRoot); + + // Protected path check + for (const p of config.protectedPaths) { + if (resolvedPath.startsWith(p) || resolvedPath === p) { + return { approved: false, reason: `protected path: ${p}` }; + } + } + + // Protected file check — filename in path + for (const f of config.protectedFiles) { + const basename = resolvedPath.split('/').pop() ?? ''; + if (basename === f) { + return { approved: false, reason: `protected file: ${f}` }; + } + if (resolvedPath.includes('/' + f + '/') || resolvedPath.endsWith('/' + f)) { + return { approved: false, reason: `protected file in path: ${f}` }; + } + } + + // Allowed write paths + for (const ap of config.allowedWritePaths) { + if (resolvedPath.startsWith(ap) || resolvedPath === ap) { + return { approved: true, reason: `allowed write path: ${ap}` }; + } + } + + // Default deny + return { approved: false, reason: 'not in allowed write paths' }; +} diff --git a/src/types/codex.ts b/src/types/codex.ts index 75b06b1..c9bc9bd 100644 --- a/src/types/codex.ts +++ b/src/types/codex.ts @@ -354,6 +354,64 @@ export interface ChatGptAuthTokensRefresh extends ServerRequestBase { method: 'account/chatgptAuthTokens/refresh'; } +// ─── Tool approval param types (from Codex JSON schemas) ───────────────────── + +export interface NetworkApprovalContext { + host: string; + protocol: 'http' | 'https' | 'socks5Tcp' | 'socks5Udp'; +} + +export type CommandAction = + | { type: 'read' } + | { type: 'listFiles' } + | { type: 'search' } + | { type: 'unknown' }; + +export interface CommandExecutionParams { + itemId: string; + threadId: string; + turnId: string; + command?: string | null; + cwd?: string | null; + commandActions?: CommandAction[] | null; + networkApprovalContext?: NetworkApprovalContext | null; + reason?: string | null; +} + +export interface FileChangeParams { + itemId: string; + threadId: string; + turnId: string; + grantRoot?: string | null; + reason?: string | null; +} + +// Approval response types +export interface CommandExecutionApproval { + decision: 'accept'; +} + +export interface FileChangeApproval { + decision: 'accept'; +} + +export interface PermissionsGrant { + permissions: { + network?: { enabled: boolean }; + fileSystem?: { read: string[]; write: string[] }; + }; + scope: 'session' | 'turn'; +} + +// Legacy approval response types (distinct from denial types above) +export interface ApplyPatchApprovalResult { + decision: 'approved'; +} + +export interface ExecCommandApprovalResult { + decision: 'approved'; +} + export type AnyServerRequest = | CommandExecutionRequestApproval | FileChangeRequestApproval diff --git a/tests/tool-policy.test.mjs b/tests/tool-policy.test.mjs new file mode 100644 index 0000000..d7c665c --- /dev/null +++ b/tests/tool-policy.test.mjs @@ -0,0 +1,171 @@ +// Tool policy unit tests — plain Node.js, no test framework required +// Run: node tests/tool-policy.test.mjs + +import { homedir } from 'os'; +import { join } from 'path'; + +// Import compiled output +const { evaluateCommandExecution, evaluateFileChange } = await import('../dist/policy/tool-policy.js'); + +let passed = 0; +let failed = 0; + +function assert(condition, label) { + if (condition) { + console.log(` ✓ ${label}`); + passed++; + } else { + console.error(` ✗ ${label}`); + failed++; + } +} + +function makeConfig(overrides = {}) { + const home = homedir(); + return { + version: 1, + enabled: true, + commandDenyPatterns: [ + '^bash\\b', '^sh\\b', '^zsh\\b', + '^/bin/bash\\b', '^/bin/sh\\b', '^/bin/zsh\\b', + '^sudo\\b', '^docker\\b', '^ssh\\b', '^scp\\b', '^rsync\\b', '^env\\b', + '^curl\\b', '^wget\\b', '^nc\\b', '^ncat\\b', '^socat\\b', + 'rm\\s+-rf\\s+/', + '\\|\\s*sh\\b', '\\|\\s*bash\\b', '\\|\\s*zsh\\b', + ], + commandAllowPrefixes: [ + 'ls', 'cat', 'head', 'tail', 'find', 'grep', 'rg', 'wc', 'file', 'stat', 'tree', 'diff', + 'node', 'npm', 'npx', 'tsc', 'jest', 'vitest', 'eslint', 'prettier', + 'git status', 'git log', 'git diff', 'git show', 'git branch', + 'git add', 'git commit', 'git checkout', 'git stash', + 'echo', 'printf', 'date', 'which', 'pwd', 'cd', 'mkdir', 'cp', 'mv', 'touch', + 'sed', 'awk', + ], + protectedPaths: [ + `${home}/.openclaw`, + `${home}/.ssh`, + `${home}/.pm2`, + `${home}/.config`, + '/etc', + '/var', + ], + protectedFiles: [ + 'tool-policy.json', 'openclaw.json', 'SOUL.md', 'MEMORY.md', 'AGENTS.md', 'IDENTITY.md', + ], + allowedWritePaths: [ + `${home}/codex-proxy`, + '/tmp', + ], + denyNetwork: true, + ...overrides, + }; +} + +const cfg = makeConfig(); +const home = homedir(); + +// ─── Command execution tests ────────────────────────────────────────────────── + +console.log('\nCommand execution — allowlist:'); +assert(evaluateCommandExecution({ itemId:'', threadId:'', turnId:'', command:'ls' }, cfg).approved, 'ls approved'); +assert(evaluateCommandExecution({ itemId:'', threadId:'', turnId:'', command:'ls -la' }, cfg).approved, 'ls -la approved'); +assert(evaluateCommandExecution({ itemId:'', threadId:'', turnId:'', command:'cat file.txt' }, cfg).approved, 'cat file.txt approved'); +assert(evaluateCommandExecution({ itemId:'', threadId:'', turnId:'', command:'git diff HEAD' }, cfg).approved, 'git diff HEAD approved'); +assert(evaluateCommandExecution({ itemId:'', threadId:'', turnId:'', command:'node index.js' }, cfg).approved, 'node index.js approved'); +assert(evaluateCommandExecution({ itemId:'', threadId:'', turnId:'', command:'npm install' }, cfg).approved, 'npm install approved'); + +console.log('\nCommand execution — word boundary:'); +assert(!evaluateCommandExecution({ itemId:'', threadId:'', turnId:'', command:'catalog' }, cfg).approved, 'catalog NOT approved (prefix boundary)'); +assert(evaluateCommandExecution({ itemId:'', threadId:'', turnId:'', command:'cat' }, cfg).approved, 'cat exact match approved'); + +console.log('\nCommand execution — denylist:'); +assert(!evaluateCommandExecution({ itemId:'', threadId:'', turnId:'', command:'bash' }, cfg).approved, 'bash denied'); +assert(!evaluateCommandExecution({ itemId:'', threadId:'', turnId:'', command:'sudo rm file' }, cfg).approved, 'sudo denied'); +assert(!evaluateCommandExecution({ itemId:'', threadId:'', turnId:'', command:'docker run ubuntu' }, cfg).approved, 'docker denied'); +assert(!evaluateCommandExecution({ itemId:'', threadId:'', turnId:'', command:'curl https://example.com' }, cfg).approved, 'curl denied'); +assert(!evaluateCommandExecution({ itemId:'', threadId:'', turnId:'', command:'rm -rf /' }, cfg).approved, 'rm -rf / denied'); +assert(!evaluateCommandExecution({ itemId:'', threadId:'', turnId:'', command:'cat file | bash' }, cfg).approved, 'pipe to bash denied'); +assert(!evaluateCommandExecution({ itemId:'', threadId:'', turnId:'', command:'env sudo rm file' }, cfg).approved, 'env prefix denied'); + +console.log('\nCommand execution — denylist wins over allowlist:'); +assert(!evaluateCommandExecution({ itemId:'', threadId:'', turnId:'', command:'ls && sudo rm -rf /' }, cfg).approved, 'chained: ls && sudo → denied'); +assert(!evaluateCommandExecution({ itemId:'', threadId:'', turnId:'', command:'cat file; sudo something' }, cfg).approved, 'chained: cat; sudo → denied'); + +console.log('\nCommand execution — protected paths:'); +assert(!evaluateCommandExecution({ itemId:'', threadId:'', turnId:'', command:`cat ${home}/.ssh/id_rsa` }, cfg).approved, 'cat ~/.ssh/id_rsa denied'); +assert(!evaluateCommandExecution({ itemId:'', threadId:'', turnId:'', command:`ls ${home}/.openclaw` }, cfg).approved, 'ls ~/.openclaw denied'); + +console.log('\nCommand execution — network denial:'); +assert(!evaluateCommandExecution({ + itemId:'', threadId:'', turnId:'', command:'git clone https://github.com/foo', + networkApprovalContext: { host: 'github.com', protocol: 'https' }, +}, cfg).approved, 'network context denied when denyNetwork=true'); +assert(evaluateCommandExecution({ + itemId:'', threadId:'', turnId:'', command:'git status', + networkApprovalContext: { host: 'github.com', protocol: 'https' }, +}, makeConfig({ denyNetwork: false })).approved, 'network allowed when denyNetwork=false'); + +console.log('\nCommand execution — null/missing:'); +assert(!evaluateCommandExecution(null, cfg).approved, 'null params denied'); +assert(!evaluateCommandExecution(undefined, cfg).approved, 'undefined params denied'); +assert(!evaluateCommandExecution({ itemId:'', threadId:'', turnId:'', command: null }, cfg).approved, 'null command denied'); +assert(!evaluateCommandExecution({ itemId:'', threadId:'', turnId:'', command: '' }, cfg).approved, 'empty command denied'); + +console.log('\nCommand execution — commandActions read-only:'); +assert(evaluateCommandExecution({ + itemId:'', threadId:'', turnId:'', command:'arbitrary-cmd', + commandActions: [{ type: 'read' }, { type: 'listFiles' }], +}, cfg).approved, 'all-read commandActions approved'); +assert(!evaluateCommandExecution({ + itemId:'', threadId:'', turnId:'', command:'arbitrary-cmd', + commandActions: [{ type: 'read' }, { type: 'unknown' }], +}, cfg).approved, 'unknown commandAction not auto-approved'); + +console.log('\nCommand execution — default deny:'); +assert(!evaluateCommandExecution({ itemId:'', threadId:'', turnId:'', command:'unknowncmd foo' }, cfg).approved, 'unlisted command denied by default'); + +// ─── File change tests ──────────────────────────────────────────────────────── + +console.log('\nFile change — allowed paths:'); +assert(evaluateFileChange({ itemId:'', threadId:'', turnId:'', grantRoot:`${home}/codex-proxy/src/foo.ts` }, cfg).approved, '~/codex-proxy/ write approved'); +assert(evaluateFileChange({ itemId:'', threadId:'', turnId:'', grantRoot:'/tmp/output.txt' }, cfg).approved, '/tmp write approved'); + +console.log('\nFile change — protected paths:'); +assert(!evaluateFileChange({ itemId:'', threadId:'', turnId:'', grantRoot:`${home}/.openclaw/config` }, cfg).approved, '~/.openclaw denied'); +assert(!evaluateFileChange({ itemId:'', threadId:'', turnId:'', grantRoot:`${home}/.ssh/id_rsa` }, cfg).approved, '~/.ssh denied'); +assert(!evaluateFileChange({ itemId:'', threadId:'', turnId:'', grantRoot:'/etc/passwd' }, cfg).approved, '/etc denied'); + +console.log('\nFile change — protected files:'); +assert(!evaluateFileChange({ itemId:'', threadId:'', turnId:'', grantRoot:`${home}/codex-proxy/tool-policy.json` }, cfg).approved, 'tool-policy.json denied'); +assert(!evaluateFileChange({ itemId:'', threadId:'', turnId:'', grantRoot:`${home}/codex-proxy/SOUL.md` }, cfg).approved, 'SOUL.md denied'); +assert(!evaluateFileChange({ itemId:'', threadId:'', turnId:'', grantRoot:`${home}/codex-proxy/MEMORY.md` }, cfg).approved, 'MEMORY.md denied'); + +console.log('\nFile change — null/missing:'); +assert(!evaluateFileChange(null, cfg).approved, 'null params denied'); +assert(!evaluateFileChange({ itemId:'', threadId:'', turnId:'', grantRoot: null }, cfg).approved, 'null grantRoot denied'); +assert(!evaluateFileChange({ itemId:'', threadId:'', turnId:'', grantRoot: '' }, cfg).approved, 'empty grantRoot denied'); + +console.log('\nFile change — default deny:'); +assert(!evaluateFileChange({ itemId:'', threadId:'', turnId:'', grantRoot:`${home}/some-other-project/file.ts` }, cfg).approved, 'unlisted path denied by default'); + +// ─── Kill switch ────────────────────────────────────────────────────────────── + +console.log('\nKill switch (CODEX_TOOL_APPROVAL=deny):'); +process.env['CODEX_TOOL_APPROVAL'] = 'deny'; +assert(!evaluateCommandExecution({ itemId:'', threadId:'', turnId:'', command:'ls' }, cfg).approved, 'kill switch: ls denied'); +assert(!evaluateFileChange({ itemId:'', threadId:'', turnId:'', grantRoot:`${home}/codex-proxy/foo.ts` }, cfg).approved, 'kill switch: write denied'); +delete process.env['CODEX_TOOL_APPROVAL']; + +// ─── Fail-safe ──────────────────────────────────────────────────────────────── + +console.log('\nFail-safe (disabled policy):'); +const disabledCfg = makeConfig({ enabled: false }); +assert(!evaluateCommandExecution({ itemId:'', threadId:'', turnId:'', command:'ls' }, disabledCfg).approved, 'disabled policy denies ls'); + +// ─── Summary ───────────────────────────────────────────────────────────────── + +console.log(`\n${'─'.repeat(50)}`); +console.log(`Results: ${passed} passed, ${failed} failed`); +if (failed > 0) { + process.exit(1); +} diff --git a/tool-policy.json b/tool-policy.json new file mode 100644 index 0000000..775b2a3 --- /dev/null +++ b/tool-policy.json @@ -0,0 +1,56 @@ +{ + "version": 1, + "enabled": true, + "commandDenyPatterns": [ + "^bash\\b", + "^sh\\b", + "^zsh\\b", + "^/bin/bash\\b", + "^/bin/sh\\b", + "^/bin/zsh\\b", + "^sudo\\b", + "^docker\\b", + "^ssh\\b", + "^scp\\b", + "^rsync\\b", + "^env\\b", + "^curl\\b", + "^wget\\b", + "^nc\\b", + "^ncat\\b", + "^socat\\b", + "rm\\s+-rf\\s+/", + "\\|\\s*sh\\b", + "\\|\\s*bash\\b", + "\\|\\s*zsh\\b" + ], + "commandAllowPrefixes": [ + "ls", "cat", "head", "tail", "find", "grep", "rg", "wc", "file", "stat", "tree", "diff", + "node", "npm", "npx", "tsc", "jest", "vitest", "eslint", "prettier", + "git status", "git log", "git diff", "git show", "git branch", + "git add", "git commit", "git checkout", "git stash", + "echo", "printf", "date", "which", "pwd", "cd", "mkdir", "cp", "mv", "touch", + "sed", "awk" + ], + "protectedPaths": [ + "~/.openclaw", + "~/.ssh", + "~/.pm2", + "~/.config", + "/etc", + "/var" + ], + "protectedFiles": [ + "tool-policy.json", + "openclaw.json", + "SOUL.md", + "MEMORY.md", + "AGENTS.md", + "IDENTITY.md" + ], + "allowedWritePaths": [ + "~/codex-proxy", + "/tmp" + ], + "denyNetwork": true +}