Skip to content

Local input and filesystem errors are reported as model-service connectivity or authorization failures #36

Description

@mariohercules

Summary

scanFailureMessage classifies every scan failure with classifyConnectionFailure, which matches bare words such as network, connection, permission denied, 401 and 403 anywhere in the message — including inside repository paths, Git refs and filesystem error text supplied by the user. When the classification is anything other than unknown, the original message is discarded and replaced with connectivity or authorization advice that does not apply.

Classification is applied to the whole failure:

function scanFailureMessage(
error: unknown,
authentication: ScanAuthentication | null,
): string {
switch (classifyConnectionFailure(error)) {
case "unauthorized":
return authentication?.method === "api_key"
? `Authentication failed using ${authentication.source}. ` +
"Your ChatGPT sign-in was not used. " +
"Retry with '--auth chatgpt' or provide a valid API key."
: "Authentication failed using stored ChatGPT credentials. " +
"Sign in again with 'codex-security login' or provide a valid API key.";
case "forbidden":
return authentication?.method === "api_key"
? `The API key from ${authentication.source} cannot access the configured model. ` +
"Retry with '--auth chatgpt' or use an API key with model access."
: "The stored ChatGPT credentials cannot access the configured model. " +
"Use an account or API key with model access.";
case "rate_limited":
return "The configured account reached its rate limit. Wait and retry.";
case "network_error":
return "The model service could not be reached. Check your network connection and try again.";
case "timeout":
return "The connection timed out. Check your network connection and try again.";
case "unknown":
return cliErrorMessage(error);
}
}

and the patterns are plain substring matches:

export function classifyConnectionFailure(
error: unknown,
):
| "rate_limited"
| "unauthorized"
| "forbidden"
| "network_error"
| "timeout"
| "unknown" {
const message = error instanceof Error ? error.message : String(error);
if (/\b(?:sqlite3?|database|workbench)\b/iu.test(message)) {
return "unknown";
}
if (
/\brate[_ -]?limit(?:ed|[_ -]exceeded)?\b|\b429\b|\btoo many requests\b/iu.test(
message,
)
) {
return "rate_limited";
}
if (
/\b401\b|\bunauthori[sz]ed\b|\binvalid[_ -](?:api[_ -]?key|authentication|token|credentials?)\b|\b(?:expired|revoked)[_ -](?:api[_ -]?key|token|credentials?)\b|\b(?:api[_ -]?key|token|credentials?)(?: has)? (?:expired|been revoked)\b/iu.test(
message,
)
) {
return "unauthorized";
}
if (
/\b403\b|\bforbidden\b|\bpermission denied\b|\b(?:model|organization|project) access\b|\b(?:access denied|do not have access|not authorized|insufficient permissions)\b|\bmodel[_ -]?not[_ -]?found\b/iu.test(
message,
)
) {
return "forbidden";
}
if (
/\b(?:ENOTFOUND|ECONNRESET|ECONNREFUSED|EHOSTUNREACH|ETIMEDOUT)\b|\b(?:network|connection|TLS|DNS)\b|\berror sending request\b/iu.test(
message,
)
) {
return "network_error";
}
if (/\b(?:timed? out|timeout)\b/iu.test(message)) return "timeout";
return "unknown";

Only the unknown branch calls cliErrorMessage(error); every other branch returns fixed advice, so the real cause reaches neither stderr nor the JSON error field.

The false-positive class is already known — classifyConnectionFailure short-circuits on sqlite/database/workbench text and tests-ts/api.test.ts pins that guard — but the guard covers only workbench and database wording, not user-supplied paths, refs, or filesystem errors.

Affected version and environment

  • Released package: @openai/codex-security@0.1.1
  • Confirmed on current main at f22d4a36f26d16287bcdfd707b369116e02a08c3
  • macOS 26.5.2 (build 25F84)
  • Node.js v24.5.0
  • Bun 1.3.14

Steps to reproduce

Driving the real main() with the existing tests-ts/cli-fixtures.ts harness, where the injected run() rejects with the error the SDK would genuinely raise:

const err = capture();
const exitCode = await main(
  ["scan", ".", "--path", "src/network/client.ts"],
  out.stream,
  err.stream,
  dependencies({
    onRun: () => {
      throw new InvalidTargetError("Path target does not exist: src/network/client.ts");
    },
  }),
);

Observed results for three realistic failures:

### missing --path containing the word 'network'
thrown by run():  Path target does not exist: src/network/client.ts
shown to user:    codex-security: The model service could not be reached. Check your network connection and try again.
exit code:        2

### unknown git ref containing the word 'connection'
thrown by run():  unknown Git ref: origin/connection-fix
shown to user:    codex-security: The model service could not be reached. Check your network connection and try again.
exit code:        2

### read-only TMPDIR (EACCES from mkdtemp)
thrown by run():  EACCES: permission denied, mkdtemp '/tmp/openai-codex-security-home-XXXXXX'
shown to user:    codex-security: The stored ChatGPT credentials cannot access the configured model. Use an account or API key with model access.
exit code:        2

The exact messages come from InvalidTargetError in src/targets.ts (Path target does not exist: and unknown Git ref:) and from createIsolatedHome's mkdtemp in src/runtime.ts.

Expected behavior

Connectivity and authorization advice should be produced only for failures that actually originate from the model transport. Local input validation errors and filesystem errors should surface their own message so the user can act on the real cause.

Actual behavior

A mistyped path, a missing Git ref, or an unwritable temporary directory is reported as a network outage or a credential problem, and the actual error text is dropped entirely.

Impact

Beyond the immediate confusion, this actively hides the cause during diagnosis. A user following the printed advice will check their network or reconfigure credentials while the real problem is a typo in --path.

This may also be worth considering in relation to #26, where a scan failed with "The model service could not be reached" while codex exec worked from the same shell. Whatever the underlying cause there was, this masking is what prevented the real error from being visible in the report.

The same classifier also gates retry-versus-fatal handling in the event stream (src/api.ts, the error event branch), so a stream error whose text merely contains "permission denied" is treated as an unrecoverable authorization failure rather than a retryable one.

Suggested direction

Apply classifyConnectionFailure only to errors known to come from the Codex transport, and let local validation and filesystem errors fall through to cliErrorMessage. Alternatively, always include the original message alongside the advice so no failure is reported without its cause.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions