Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .claude/settings.local.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"permissions": {
"allow": [
"Bash(grep -E \"\\\\.ts$|\\\\.js$|\\\\.json$\")",
"Bash(xargs wc:*)",
"Bash(bun test:*)",
"Bash(bun run:*)"
]
}
}
Comment on lines +1 to +10
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This file is named settings.local.json, but it’s committed to the repo. “.local” configs are typically developer-machine specific; consider renaming to a non-local config (if it’s meant to be shared) or add it to .gitignore (if it’s meant to stay local) to avoid unintended permission settings being versioned.

Copilot uses AI. Check for mistakes.
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
name: CI

on:
push:
branches: [master]
pull_request:
branches: [develop, master]

Expand Down
18 changes: 16 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ on:

permissions:
contents: write
id-token: write

jobs:
test:
Expand Down Expand Up @@ -41,6 +42,8 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: oven-sh/setup-bun@v2
with:
bun-version: latest
Expand All @@ -49,14 +52,25 @@ jobs:
node-version: "22"
registry-url: "https://registry.npmjs.org"
- run: bun install
- name: Determine version
id: version
run: |
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
TAG="${{ inputs.tag }}"
else
PKG_VERSION=$(node -p "require('./package.json').version")
TAG="v${PKG_VERSION}"
fi
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
npm version "${TAG#v}" --no-git-tag-version --allow-same-version
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

npm version ... --no-git-tag-version updates package.json in the CI workspace, but the workflow then creates a git tag without committing that version bump. This can produce tags/releases whose repository package.json version doesn’t match the published artifact. Consider either committing the version change before tagging, or avoid mutating package.json and instead publish using the version already in the repo.

Suggested change
npm version "${TAG#v}" --no-git-tag-version --allow-same-version

Copilot uses AI. Check for mistakes.
- name: Publish to npm
run: npm publish --access public
run: npm publish --provenance --access public
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Create tag and release
env:
GH_TOKEN: ${{ github.token }}
run: |
TAG="v$(node -p "require('./package.json').version")"
TAG="${{ steps.version.outputs.tag }}"
git tag "$TAG" 2>/dev/null && git push origin "$TAG" || echo "Tag exists"
gh release create "$TAG" --title "$TAG" --generate-notes || echo "Release exists"
3 changes: 3 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 7 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@
],
"author": "lleontor705",
"license": "MIT",
"publishConfig": {
"access": "public"
},
"repository": {
"type": "git",
"url": "https://github.com/lleontor705/opencode-cli-enforcer.git"
Expand All @@ -29,12 +32,16 @@
],
"dependencies": {
"@opencode-ai/plugin": "^1.2.26",
"cockatiel": "^3.2.1",
"execa": "^9.6.1"
},
"devDependencies": {
"@types/bun": "latest",
"typescript": "^5.7.0"
},
"engines": {
"bun": ">=1.3.5"
},
"scripts": {
"test": "bun test",
"test:watch": "bun test --watch",
Expand Down
38 changes: 38 additions & 0 deletions src/error-classifier.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
/**
* Error Classification — determines retry strategy based on error type.
*
* Categories:
* transient → retry with standard backoff
* rate_limit → retry with longer delay
* permanent → do not retry, fallback immediately
* crash → do not retry, fallback immediately
*/

export type ErrorClass = "transient" | "rate_limit" | "permanent" | "crash"

export function classifyError(error: any): ErrorClass {
const msg = String(error?.message || error?.stderr || "")

// Crash: process killed, binary not found
if (error?.exitCode === 137 || msg.includes("SIGKILL") || msg.includes("ENOENT")) {
return "crash"
}

// Rate limit: HTTP 429 or quota errors
if (msg.includes("429") || msg.includes("rate limit") || msg.includes("quota")) {
return "rate_limit"
}

// Permanent: auth failures, not found
if (
msg.includes("auth") ||
msg.includes("401") ||
msg.includes("403") ||
msg.includes("not found")
Comment on lines +15 to +31
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

classifyError performs case-sensitive substring checks (e.g., "rate limit", "quota", "auth"), which will miss common variants like "Rate Limit"/"AUTH". Normalize the message (e.g., toLowerCase()) before matching to make classification consistent and reliable.

Suggested change
// Crash: process killed, binary not found
if (error?.exitCode === 137 || msg.includes("SIGKILL") || msg.includes("ENOENT")) {
return "crash"
}
// Rate limit: HTTP 429 or quota errors
if (msg.includes("429") || msg.includes("rate limit") || msg.includes("quota")) {
return "rate_limit"
}
// Permanent: auth failures, not found
if (
msg.includes("auth") ||
msg.includes("401") ||
msg.includes("403") ||
msg.includes("not found")
const normalizedMsg = msg.toLowerCase()
// Crash: process killed, binary not found
if (error?.exitCode === 137 || normalizedMsg.includes("sigkill") || normalizedMsg.includes("enoent")) {
return "crash"
}
// Rate limit: HTTP 429 or quota errors
if (normalizedMsg.includes("429") || normalizedMsg.includes("rate limit") || normalizedMsg.includes("quota")) {
return "rate_limit"
}
// Permanent: auth failures, not found
if (
normalizedMsg.includes("auth") ||
normalizedMsg.includes("401") ||
normalizedMsg.includes("403") ||
normalizedMsg.includes("not found")

Copilot uses AI. Check for mistakes.
) {
return "permanent"
}

// Everything else is transient (timeout, network, etc.)
return "transient"
}
10 changes: 8 additions & 2 deletions src/executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

import { execa } from "execa"
import type { CliDef } from "./cli-defs"
import { getSafeEnv } from "./safe-env"
import { redactSecrets } from "./redact"

/** Prompts longer than this (chars) are delivered via stdin to avoid OS arg-length limits. */
export const STDIN_THRESHOLD = 30_000
Expand All @@ -30,6 +32,7 @@ export async function executeCliOnce(
maxBuffer: 10 * 1024 * 1024,
reject: false,
windowsHide: true,
env: getSafeEnv(),
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

env: getSafeEnv() does not enforce an allowlist with execa’s default behavior: execa extends process.env unless extendEnv: false is set, so secrets from the parent process can still be inherited. Set extendEnv: false (and keep an explicit allowlist) to actually prevent leaking environment variables to spawned CLIs.

Suggested change
env: getSafeEnv(),
env: getSafeEnv(),
extendEnv: false,

Copilot uses AI. Check for mistakes.
...(useStdin ? { input: prompt } : {}),
...(signal ? { cancelSignal: signal } : {}),
})
Expand All @@ -47,8 +50,11 @@ export async function executeCliOnce(
}

if (result.failed && result.exitCode !== 0) {
const msg = result.stderr?.trim() || result.message || `Exit code ${result.exitCode}`
throw new Error(`CLI '${def.name}' failed: ${msg}`)
const rawMsg = result.stderr?.trim() || result.message || `Exit code ${result.exitCode}`
const msg = redactSecrets(rawMsg)
throw Object.assign(new Error(`CLI '${def.name}' failed: ${msg}`), {
exitCode: result.exitCode,
})
}

return {
Expand Down
4 changes: 3 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { DEFAULT_RETRY_CONFIG } from "./retry"
import { detectAllClis, type CliAvailability } from "./detection"
import { truncate } from "./executor"
import { executeWithResilience, type ResilienceContext, type UsageStats } from "./resilience"
import { redactSecrets } from "./redact"

// Agents that should NOT receive CLI injection
const NO_CLI_AGENTS = new Set(["orchestrator", "task_decomposer"])
Expand Down Expand Up @@ -141,7 +142,8 @@ Rules: One concern per call. Split large requests. Include "CLI Consultations" i
return {
...response,
stdout: truncate(response.stdout, 50_000),
stderr: truncate(response.stderr, 5_000),
stderr: redactSecrets(truncate(response.stderr, 5_000)),
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redaction is applied after truncation (redactSecrets(truncate(...))). If a secret is truncated mid-token, it may no longer match the {20,} pattern and could leak partially. Redact before truncating (or ensure the redaction patterns also catch truncated fragments) to avoid exposing partial credentials.

Suggested change
stderr: redactSecrets(truncate(response.stderr, 5_000)),
stderr: truncate(redactSecrets(response.stderr), 5_000),

Copilot uses AI. Check for mistakes.
error: response.error ? redactSecrets(response.error) : null,
}
},
}),
Expand Down
48 changes: 48 additions & 0 deletions src/policies.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
/**
* Cockatiel Resilience Policies — composable retry, circuit breaker,
* bulkhead, and timeout policies using cockatiel.
*
* Composition order (outermost → innermost):
* timeout → retry → circuit breaker → bulkhead
*/

import {
CircuitBreakerPolicy,
ConsecutiveBreaker,
retry,
handleAll,
wrap,
bulkhead,
timeout,
ExponentialBackoff,
type IPolicy,
} from "cockatiel"

/** Per-CLI bulkhead: max 2 concurrent, queue up to 3 */
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment says “Per-CLI bulkhead”, but cliBulkhead is a single shared bulkhead instance. Either create a bulkhead per CLI (e.g., a factory or map keyed by CLI) or adjust the comment to reflect the actual shared behavior.

Suggested change
/** Per-CLI bulkhead: max 2 concurrent, queue up to 3 */
/** Shared CLI bulkhead: max 2 concurrent, queue up to 3 */

Copilot uses AI. Check for mistakes.
export const cliBulkhead = bulkhead(2, 3)

/** Circuit breaker: open after 3 consecutive failures, half-open after 30s */
export const circuitBreaker = new CircuitBreakerPolicy(handleAll, {
halfOpenAfter: 30_000,
breaker: new ConsecutiveBreaker(3),
})

/** Retry with decorrelated jitter (AWS best practice) */
export const retryPolicy = retry(handleAll, {
maxAttempts: 3,
backoff: new ExponentialBackoff({ initialDelay: 1000, maxDelay: 30000 }),
})

/** Default timeout: 30 seconds */
export const timeoutPolicy = timeout(30_000)

/**
* Composed resilient policy: timeout → retry → circuit breaker → bulkhead.
* Wrap calls with `resilientPolicy.execute(fn)`.
*/
export const resilientPolicy: IPolicy = wrap(
timeoutPolicy,
retryPolicy,
circuitBreaker,
cliBulkhead,
)
Comment on lines +43 to +48
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

policies.ts exports cockatiel policies and adds a new dependency, but nothing in src/ imports or uses these policies right now. If the intent is to switch the resilience engine to cockatiel, wire resilientPolicy into the execution path; otherwise remove the unused module/dependency to avoid shipping dead code.

Copilot uses AI. Check for mistakes.
10 changes: 10 additions & 0 deletions src/redact.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
/**
* Secret Redaction — removes API keys and tokens from text before
* returning it to the user in error messages or logs.
*/

export function redactSecrets(text: string): string {
return text
.replace(/(?:sk-|key-|AIza|ant-api)[a-zA-Z0-9_-]{20,}/g, "[REDACTED]")
.replace(/Bearer\s+[a-zA-Z0-9._-]+/g, "Bearer [REDACTED]")
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The redaction regex can match inside longer words because it lacks word boundaries (e.g., it could redact a long string containing "sk-" in the middle). Add appropriate boundaries/preceding context to reduce false positives while still catching real keys.

Suggested change
.replace(/Bearer\s+[a-zA-Z0-9._-]+/g, "Bearer [REDACTED]")
.replace(/(^|\s)Bearer\s+[a-zA-Z0-9._-]+/g, "$1Bearer [REDACTED]")

Copilot uses AI. Check for mistakes.
}
34 changes: 28 additions & 6 deletions src/resilience.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,13 @@ import {
recordFailure,
} from "./circuit-breaker"
import type { RetryConfig } from "./retry"
import { DEFAULT_RETRY_CONFIG, calculateDelay, sleep, isRetryableError } from "./retry"
import { DEFAULT_RETRY_CONFIG, calculateDelay, sleep } from "./retry"
import { executeCliOnce } from "./executor"
import type { CliAvailability } from "./detection"
import type { Platform } from "./platform"
import type { CircuitState } from "./circuit-breaker"
import { classifyError, type ErrorClass } from "./error-classifier"
import { redactSecrets } from "./redact"

// ─── Structured Response (MCP pattern) ─────────────────────────────────────

Expand All @@ -32,6 +34,7 @@ export interface CliResponse {
used_fallback: boolean
fallback_chain: string[]
error: string | null
error_class: ErrorClass | null
circuit_state: CircuitState
attempt: number
max_attempts: number
Expand Down Expand Up @@ -128,21 +131,37 @@ export async function executeWithResilience(
used_fallback: cliName !== targetCli,
fallback_chain: fallbackChain,
error: null,
error_class: null,
circuit_state: breaker.state,
attempt: attempt + 1,
max_attempts: ctx.retryConfig.maxRetries + 1,
}
} catch (err: unknown) {
stats.failures++

const retryable = isRetryableError(err)
const isLastAttempt = attempt === ctx.retryConfig.maxRetries
const errorClass = classifyError(err)

// permanent and crash errors: skip retries, fallback immediately
if (errorClass === "permanent" || errorClass === "crash") {
recordFailure(breaker, ctx.breakerConfig)
break // try next CLI in fallback chain
}

// rate_limit: wait longer before retrying
if (errorClass === "rate_limit") {
const rateLimitDelay = calculateDelay(attempt + 1, {
...ctx.retryConfig,
baseDelayMs: ctx.retryConfig.baseDelayMs * 3,
})
await sleep(rateLimitDelay)
}
Comment on lines +150 to +157
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For rate_limit errors you sleep inside the catch block, but the next loop iteration will also sleep in the attempt > 0 block. This results in double delays (rate-limit delay + standard backoff) for every retry after a rate limit. Consider folding the rate-limit multiplier into the normal per-attempt delay calculation, or skip the top-of-loop delay when the previous error was rate_limit.

Copilot uses AI. Check for mistakes.

if (!retryable || isLastAttempt) {
const isLastAttempt = attempt === ctx.retryConfig.maxRetries
if (isLastAttempt) {
recordFailure(breaker, ctx.breakerConfig)
break // try next CLI in fallback chain
}
// retryable — loop continues
// transient or rate_limit — loop continues with retry
}
}
}
Expand All @@ -158,7 +177,10 @@ export async function executeWithResilience(
timed_out: false,
used_fallback: fallbackChain.length > 1,
fallback_chain: fallbackChain,
error: `All CLI providers exhausted. Tried: ${fallbackChain.join(" → ")}. Check cli_status for details.`,
error: redactSecrets(
`All CLI providers exhausted. Tried: ${fallbackChain.join(" → ")}. Check cli_status for details.`,
),
error_class: "transient",
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The final “All CLIs exhausted” response always sets error_class: "transient", which can mislead callers when the last/majority failures were permanent, crash, or rate_limit. Track the last (or most severe) errorClass seen and return that instead of a constant.

Suggested change
error_class: "transient",
error_class: null,

Copilot uses AI. Check for mistakes.
circuit_state: ctx.breakers.get(targetCli)!.state,
attempt: ctx.retryConfig.maxRetries + 1,
max_attempts: ctx.retryConfig.maxRetries + 1,
Expand Down
30 changes: 30 additions & 0 deletions src/safe-env.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
/**
* Environment Variable Filtering — only passes safe variables to
* spawned CLI processes, preventing accidental secret leakage.
*/

export const SAFE_ENV_VARS = [
"PATH",
"HOME",
"USER",
"TERM",
"SHELL",
"LANG",
"LC_ALL",
"ANTHROPIC_API_KEY",
"GOOGLE_API_KEY",
"OPENAI_API_KEY",
"GEMINI_API_KEY",
"CODEX_API_KEY",
"HTTP_PROXY",
"HTTPS_PROXY",
"NO_PROXY",
]

export function getSafeEnv(): Record<string, string> {
const env: Record<string, string> = {}
for (const key of SAFE_ENV_VARS) {
if (process.env[key]) env[key] = process.env[key]!
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

getSafeEnv drops variables whose value is an empty string because it uses a truthiness check. If an allowed var is intentionally set to "" it should still be forwarded; check against undefined/null instead of truthiness.

Suggested change
if (process.env[key]) env[key] = process.env[key]!
const value = process.env[key]
if (value !== undefined && value !== null) {
env[key] = value
}

Copilot uses AI. Check for mistakes.
}
return env
}
Loading
Loading