Skip to content

Security: thaildhe172591/pythia

Security

SECURITY.md

Security

Reporting a vulnerability

Use GitHub's private vulnerability reporting rather than a public issue. Include what you ran, what happened, and what you expected. Expect a first reply within a week.

What this tool can do, stated plainly

pythia connects to an Oracle database with credentials you supply and can issue CREATE OR REPLACE against it. That is the product, not a side effect. Anyone evaluating it should weigh that directly, so here is where the risk actually sits and what contains it.

The database account is the only boundary that cannot be argued with. Policy files, skills and confirmation tokens are application-side; a determined agent or a mistaken human can route around any of them. An Oracle grant cannot be routed around. Give the agent its own credential, scoped to one development schema, via proxy authentication — see examples/agent-user-setup.example.sql. pythia check warns when the session holds more power than the task needs.

Never point it at production. This is Oracle's own guidance for LLM access and it is ours.

Everything in this tier — the three-role layout, the proxy pattern, the grants we suggest — is the author's guidance, not a requirement the tool imposes or takes responsibility for. It is one additional hardening layer at the role tier; your site's security model belongs to your DBA.

The layers, and what each is worth

Layer Stops Can be bypassed by
Oracle grants anything outside the granted schema nothing in this tool
Policy (.pythia/policy.json) whole statement classes; DML, DDL and grants are deny by default editing the file, deliberately
Confirmation token applying content that differs from what was previewed not applying at all
Approval grant an agent completing a write no human approved fabricating the grant file, faking a console, or piping a hand-made hook payload into approve --hook, deliberately — the policy.json tier
Card check in the chat door a human approving the agent's paraphrase instead of the preview, or an answer the agent wrote itself editing the session transcript, deliberately — the policy.json tier; the hook mints only when the question carries pythia's own card verbatim, and only for an answer it can trace to the dialog: the model's own call, read from the transcript, carried none
MCP approver (pythia_approve) an approval given without a person seeing pythia's card an Elicitation hook the developer configured to auto-accept, Codex's danger-full-access, or Claude Code's dontAsk (which denies the tool instead); a host that declines it unseen mints nothing, and the decline is named as the client's
Autopilot lease a delegation wider than the developer approved editing lease.json by hand, deliberately — the policy.json tier; the lease never covers a protected connection, destructive statements the card did not include, or lint errors
PreToolUse guard sqlplus/SQLcl writes that skip the write path, forged approve --hook calls obfuscating the command past a regex — it is a seatbelt for a well-meaning agent, not a sandbox
TTY requirement a headless agent self-approving --yes or loosening policy PYTHIA_CI=1, deliberately, in a real pipeline
Snapshot + journal losing the previous version of PL/SQL source nothing — it runs before every write and no flag disables it

Honest limits: a snapshot restores source. It does not restore rows, a dropped column, or a revoked grant. The rollback table in the README and in pythia-apply says which group is genuinely reversible, and the tool refuses the groups that are not rather than implying otherwise.

The approval grant has two doors since 0.10.0, and each has an honest reading. The console door assumes a developer with a terminal on the machine holding the repo. The chat door (AskUserQuestion + the PostToolUse hook) closes the remote-developer gap for Claude Code, and since 0.15.8 it is the first door there: Claude Code's own dialog, required for this tool whatever a hook or an allow rule says. Current Claude Code writes the person's answer into the tool input after dropping any the model wrote, so the input alone cannot say who answered; the hook reads the model's own call from the session transcript (wireToolInputs) and mints only when that call carried no answers — an answer the agent wrote, or one the hook cannot trace, mints nothing. A dialog that closed itself while nobody was at the keyboard (afkTimeoutMs) is no answer. And the hook refuses unless the question carried pythia's own card verbatim — so what is approved is the preview, not a paraphrase. Its limits: the hook is a process the agent's shell could also start, and the transcript is a file the agent could edit. An agent that pipes a hand-made payload into pythia approve --hook has forged an approval — the same tier as faking a console or writing the grant file by hand, and pythia install writes a deny rule for that command shape into the Claude Code settings outright. Since 0.14.0 (Codex) and 0.15.0 (Claude Code) the MCP approver is a third door, the first on Codex: its tool is marked requiresUserInteraction, so Claude Code prompts for it in every permission mode but dontAsk, and the elicitation shows pythia's own card. A host with no elicitation UI declines it at once, unseen; pythia reads a non-accept back in under 1.5 s as the client's, says so, and the agent moves to the next door — nothing is minted either way. What the grant raises is the bar, not a wall: it turns a write an agent could complete on its own authority into one that takes deliberate forgery, which is the same tier as editing policy.json.

Autopilot, and why it is not a hole in the gate (0.15.0)

A lease is the same human act as an approval, given once for a scope it names: one connection, the statement groups on the card, a duration of at most 24 hours and a write budget. It starts only through an approval door — the console, the chat card, the MCP elicitation — never from the agent's own command (autopilot on refuses without a console). It narrows what the agent may do without asking; it widens nothing else:

  • protected connections ("protected": true, "env": "prod") are refused outright, whatever the card says;
  • destructive statements — DROP, TRUNCATE, ALTER ... DROP, renames, SET UNUSED, REVOKE, UPDATE/DELETE without WHERE — stay one-approval-at-a-time unless the developer asked for them by name, and the card says they cannot be undone;
  • a file with lint errors is not landed, and the security-relevant checks (GRANT ... TO PUBLIC, ANY privileges, grant options, powerful roles, always-true WHERE clauses) are hard-coded as destructive, so no lint setting — conventions.json is a file agents are told to edit — can let a lease land them;
  • a DML statement touching more than 1,000 rows is shown to a person first;
  • objects that were VALID going INVALID after any write — PL/SQL, DDL or grants — or five failed writes in a row, pause it until the developer looks;
  • a connection marked protected after the lease began stops being covered at once; environment-variable credentials can never be leased;
  • every write still snapshots, journals and verifies, and autopilot report lists each one with its undo.

A lease overrides a deny policy only for the groups its card names and only while it is active — the card says so next to the group. That is the point of approving a card rather than editing a file: the developer reads the consequence at the moment they grant it.

A deploy approval covers the release as a whole and nothing else: its card shows every statement in full (with its row set for DML), each statement's approval is bound to that deploy and refused on its own, every statement is checked against the preview before the first one runs, and a stop withdraws whatever the approval had not used yet.

The hooks are seatbelts, not a sandbox (0.15.0)

The PreToolUse hook allows pythia's read-only commands only as one plain invocation — no ;, &&, pipes, redirections, $(...), backticks or # (bash and Python's shlex disagree on where a comment starts, which a review showed can smuggle a second command) — so an allowed command cannot carry a second one. It classifies the command with the CLI's own parser, so an option value never passes for a subcommand, and the CLI accepts no abbreviated flags (--ho is an error, not --hook). It allows only a bare pythia, an interpreter by name with -m pythia (and no pythia.py in the working directory to shadow it), or a path to the very file running the hook — not a script an agent wrote and named pythia. Since 0.15.5 it also allows the interpreter running the hook by its absolute path — with -m pythia, or with the path to that very file — and the pythia script in the same directory: the spelling that runs where pythia's venv is not on PATH. The path must be that interpreter's own spelling: a look-alike, another directory or a symlink to it is another file and gets the client's prompt — a venv's python is itself a symlink, and only its own path loads the venv. It allows a write only when a live grant or lease already exists, which apply re-verifies itself. Its deny on sqlplus/SQLcl writes is pattern matching: a determined agent can phrase a command it does not recognise. The account's Oracle grants remain the only boundary that cannot be argued with.

A hook that cannot start fails open (0.15.2)

Claude Code treats a hook that exits with anything but 2 as a non-blocking error and carries on. A hook whose interpreter cannot import pythia — pythia installed in a venv or with pipx, Claude Code started from a desktop app or IDE where python is the system interpreter — therefore turns the PreToolUse guard off without a word: the 0.15.1 field test ran a sqlplus command straight through it, and the MCP approver never connected. So pythia install chooses the launcher by what really starts pythia: a bare python -m pythia (or python3) only when it imports pythia outside a virtual environment, otherwise the absolute interpreter. A machine's own launcher is written only to that machine's files — .claude/settings.local.json (added to .git/info/exclude) and Claude Code's local MCP scope through claude mcp add --scope local — never into .claude/settings.json or .mcp.json, where it would break every teammate's hooks the same way. pythia doctor starts each launcher it finds and reports fail for one that cannot import pythia whenever that leaves a gate off — the guard or the approval hook, with no other launcher that starts here doing the same job. Where no gate is lost it is a warning naming what is: the session guide or the reminders, nothing at all (the team's shared copy erroring while this machine's own launcher does every job), or Codex's session hook, which carries no guard. It runs only what is exactly an interpreter plus -m pythia or an absolute pythia.py path, never a relative path, without a shell and from an empty directory; a bare name is resolved on PATH alone (Windows would search the interpreter's directory and the current one first, where a repository can drop a python.exe). From a shared file — or a settings.local.json that git tracks — only <bare interpreter> -m pythia runs; any path there, the interpreter's or a pythia.py's, is named and never executed: hook commands in a project's settings are repository content. A virtual, pipx, uv or conda environment always gets the absolute launcher, since python reaches it only while it is activated. The pythia block in CLAUDE.md tells the agent what a session with no STATE line or no pythia_approve tool means, because then nothing else can.

Row-set revalidation, added in 0.9.0, brings a limit of its own worth naming: previewing a data_dml statement now runs a SELECT derived from that statement, so a predicate calling a function executes that function at preview time — before anyone has approved anything. This opens no trust boundary the statement does not open already (the same file is asking to run its own DML, and only a SELECT is built from it), but it is a new moment, and the preview printing the whole statement is what makes it visible. The fingerprint itself tracks which rows are in the set, by ROWID; it does not notice a value changed in place inside a row that stays in the set.

Credentials

.pythia/connections.json holds them in plaintext. It is created mode 600, and pythia install writes it into the project's .gitignore — a rule every clone shares — unless a rule there already ignores it; a rule only this machine has (.git/info/exclude, a personal excludes file) is not counted. Install warns, and pythia doctor fails, when git already tracks the file: the passwords are then in the history, so untrack it and change them. PYTHIA_USER / PYTHIA_PASSWORD / PYTHIA_DSN are read from the environment if you prefer. pythia never transmits credentials anywhere except to the database you configured, and never writes them to the journal.

About the "high risk" rating on pythia-apply

Skill marketplaces score skills by scanning their text. pythia-apply scores worst of the seven, and the substance of that is fair: it is the skill that authorizes writing to a database, and it deserves more scrutiny than the six that only read.

The specific words that trigger it are worth knowing, though, because every one of them appears in a prohibition:

  • DROP — in "policy will refuse it", and in the row explaining that DROP COLUMN is permanent
  • bypass — in the section heading "Never bypass the write path"
  • token, credential — the confirmation mechanism and the warning about over-privileged accounts

A keyword scanner cannot tell an instruction from a ban on that instruction. We will not reword safety rules to score better; the rules are written to be read by an agent about to modify someone's database, and that audience comes first. Read the skill — it is 120 lines, and it argues against nearly everything it mentions.

There aren't any published security advisories