Yeachan-Heo/oh-my-codex · error · Error

invalid state key

Error message

invalid state key

What it means

Thrown by normalizeHookPluginStateKey when the key contains '..' or starts with '/'. These patterns would allow path traversal out of the plugin's state directory, so they are rejected as a path-safety guard.

Source

Thrown at src/hooks/extensibility/sdk/plugin-state.ts:20

import { mkdir, readFile, unlink, writeFile } from 'fs/promises';
import { dirname, join } from 'path';
import type { HookPluginSdk } from '../types.js';
import { hookPluginDataPath, hookPluginRootDir, sanitizeHookPluginName } from './paths.js';

async function readJsonIfExists<T>(path: string, fallback: T): Promise<T> {
  if (!existsSync(path)) return fallback;
  try {
    return JSON.parse(await readFile(path, 'utf-8')) as T;
  } catch {
    return fallback;
  }
}

export function normalizeHookPluginStateKey(key: string): string {
  const trimmed = key.trim();
  if (!trimmed) throw new Error('state key is required');
  if (trimmed.includes('..') || trimmed.startsWith('/')) {
    throw new Error('invalid state key');
  }
  return trimmed;
}

export function createHookPluginStateApi(
  cwd: string,
  pluginName: string,
): HookPluginSdk['state'] {
  const dataPath = hookPluginDataPath(cwd, pluginName);

  async function readData(): Promise<Record<string, unknown>> {
    return readJsonIfExists<Record<string, unknown>>(dataPath, {});
  }

  async function writeData(value: Record<string, unknown>): Promise<void> {
    await mkdir(dirname(dataPath), { recursive: true });
    await writeFile(dataPath, JSON.stringify(value, null, 2));
  }

View on GitHub (pinned to 3ad79a8a6f)

Solutions

  1. Sanitize keys: strip leading '/', replace '..' segments, or encode the raw identifier (e.g. base64/hex or slugify) before passing it as a state key
  2. Use flat, slug-like keys such as 'my-plugin-session-1' derived from trusted identifiers
  3. Validate external keys against /^[A-Za-z0-9._-]+$/ before using them as state keys

Example fix

// before
const key = userInput; // '../../etc/passwd'
state.get(key);

// after
const key = slugify(userInput); // 'etc-passwd' with dots/traversal removed
state.get(key);
Defensive patterns

Strategy: validation

Validate before calling

import { normalizeHookPluginStateKey } from './plugin-state.js';

function safeStateKey(raw: string): string {
  return normalizeHookPluginStateKey(raw.replace(/\.\./g, '').replace(/^\/+/, '').replace(/[^\w.-]+/g, '-'));
}

Type guard

function isSafeStateKey(key: string): boolean {
  const t = key.trim();
  return t.length > 0 && !t.includes('..') && !t.startsWith('/') && /^[A-Za-z0-9._-]+$/.test(t);
}

Try / catch

try { state.get(key); } catch (err) {
  if ((err as Error).message === 'invalid state key') return state.get(sanitize(key));
  throw err;
}

Prevention

When it happens

Trigger: Calling plugin state APIs with a key like '../other-plugin', 'a/../b', '/absolute/path', or any key containing '..' after trimming. Keys are used to build state file paths, so traversal segments are forbidden.

Common situations: Deriving state keys from user input or file names without sanitization; using slash-prefixed keys copied from a path constant; keys built from external identifiers that legitimately contain '..' sequences.

Related errors


AI-assisted analysis of Yeachan-Heo/oh-my-codex@3ad79a8a6f (2026-08-27). Data as JSON: /api/errors/71aa022bd5e8cda2. Report an issue: GitHub.