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
2 changes: 1 addition & 1 deletion .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
},
"metadata": {
"description": "PL/SQL development kit for AI agents on Oracle Database",
"version": "0.14.0"
"version": "0.14.1"
},
"plugins": [
{
Expand Down
2 changes: 1 addition & 1 deletion .claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "pythia",
"version": "0.14.0",
"version": "0.14.1",
"description": "PL/SQL development for AI agents on Oracle Database - expert data-dictionary queries, impact analysis, and a snapshot-verified write path with honest rollback.",
"author": {
"name": "thaild"
Expand Down
23 changes: 23 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,28 @@
# Changelog

## 0.14.1 — 2026-09-14

- **install no longer breaks Codex's config.** 0.14.0 wrote an
`[approval_policy.granular]` table carrying only `mcp_elicitations = true`,
but that table requires several fields on Codex v0.154 (`sandbox_approval`,
`rules`, …) and an incomplete one fails at config-load — the developer's
Codex would not start (`missing field sandbox_approval`). Seen the first
time 0.14.0 was installed against a real Codex. install now writes **only**
`[mcp_servers.pythia]`; MCP elicitations surface under the default
interactive approval policy without any granular table, and the approval
policy is the developer's to set, not pythia's. The rest of 0.14.0 is
confirmed working against live Codex v0.154: the server connects, the
`pythia_approve` elicitation renders its card with Approve/Reject, and
Approve mints a grant (`approver="mcp"`).
- **requirements.toml is no longer written.** The danger-full-access pin
became a printed warning instead. That file is enterprise-managed config and
its schema is version-fragile in exactly the way that just broke
`config.toml`; the MCP approver is fail-closed regardless, and the
elicitation asks for an explicit Approve.
- The `pythia_approve` result now tells the agent to run `apply --confirm`
itself on the file it previewed, rather than reading like a command for the
developer to type by hand.

## 0.14.0 — 2026-09-14

- **The Claude approve-and-execute experience, now on Codex.** 0.13.0 put the
Expand Down
2 changes: 1 addition & 1 deletion npm/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "pythia-plsql",
"version": "0.14.0",
"version": "0.14.1",
"description": "One-command installer for pythia: PL/SQL development for AI agents on Oracle Database. Installs the Python CLI, the skill pack, and the .pythia/ config scaffold.",
"bin": {
"pythia-plsql": "bin.js"
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "pythia-plsql"
version = "0.14.0"
version = "0.14.1"
description = "PL/SQL development for AI agents on Oracle Database - expert data-dictionary queries, impact analysis, and a snapshot-verified write path with honest rollback."
readme = "README.md"
license = { text = "MIT" }
Expand Down
84 changes: 34 additions & 50 deletions scripts/pythia.py
Original file line number Diff line number Diff line change
Expand Up @@ -3484,9 +3484,11 @@ def mcp_approve_decision(root, token, elicit):
prune_expired_grants(root)
mint_grant(root, token, meta.get("connection") or "",
revalidate=fingerprint_text(meta.get("row_set")), approver="mcp")
return _mcp_text(f"Approved — grant minted for {token}, single use, expires "
f"in {GRANT_TTL_MINUTES} minutes. Now run: "
f"{invocation()} apply <file> --confirm {token}")
return _mcp_text(f"Approved — grant minted for {token} (single use, "
f"{GRANT_TTL_MINUTES} min). Now run apply --confirm on the "
"file you previewed YOURSELF — do not hand the developer a "
f"command to type: {invocation()} apply <file> --confirm "
f"{token}")


def mcp_serve(reader, writer, root):
Expand Down Expand Up @@ -3558,33 +3560,30 @@ def cmd_mcp(conn, schema, ns):
# table header inside a string/comment is not worth a parser here.
CODEX_MCP_BEGIN = "# pythia:begin (managed by `pythia install`)"
CODEX_MCP_END = "# pythia:end"
# Only the server roster — nothing else. An earlier attempt also wrote an
# [approval_policy.granular] table, but that table requires several fields
# (sandbox_approval, rules, ...) and an incomplete one broke Codex v0.154 at
# config-load time. Elicitations surface under the default interactive
# approval policy without any of that, and the developer's approval policy is
# theirs, not ours to set.
CODEX_MCP_BLOCK = (f"{CODEX_MCP_BEGIN}\n"
"[mcp_servers.pythia]\n"
'command = "python"\n'
'args = ["-m", "pythia", "mcp"]\n\n'
"[approval_policy.granular]\n"
"mcp_elicitations = true\n"
'args = ["-m", "pythia", "mcp"]\n'
f"{CODEX_MCP_END}")
# requirements.toml is a separate admin-enforced file; forbidding these two is
# what closes the danger-full-access elicitation auto-approve hole.
CODEX_REQUIREMENTS = (f"{CODEX_MCP_BEGIN}\n"
"# pythia needs elicitations to reach a human, so the two\n"
"# modes that would auto-answer or skip them are disallowed.\n"
'allowed_approval_policies = ["untrusted", "on-request", "on-failure"]\n'
'allowed_sandbox_modes = ["read-only", "workspace-write"]\n'
f"{CODEX_MCP_END}")


def merge_codex_mcp_config(path):
"""Append the MCP block to <project>/.codex/config.toml. Returns
(path, action): 'created' | 'appended' | 'present' | 'conflict'. Refuses
(writes nothing) if either table already exists without our marker."""
"""Append the MCP server registration to <project>/.codex/config.toml.
Returns (path, action): 'created' | 'appended' | 'present' | 'conflict'.
Refuses (writes nothing) if [mcp_servers.pythia] already exists without our
marker — the stdlib cannot safely merge TOML, so a collision is reported,
never overwritten."""
path = pathlib.Path(path)
old = path.read_text(encoding="utf-8") if path.is_file() else None
if old is not None and CODEX_MCP_BEGIN in old:
return path, "present"
if old is not None and ("[mcp_servers.pythia]" in old
or "[approval_policy.granular]" in old):
if old is not None and "[mcp_servers.pythia]" in old:
return path, "conflict"
if old is None:
new, action = CODEX_MCP_BLOCK + "\n", "created"
Expand All @@ -3596,21 +3595,6 @@ def merge_codex_mcp_config(path):
return path, action


def merge_codex_requirements(path):
"""Write <project>/.codex/requirements.toml forbidding danger-full-access
and never-approval. Returns (path, action): 'created' | 'present' |
'conflict'. An existing file without our marker is left untouched."""
path = pathlib.Path(path)
old = path.read_text(encoding="utf-8") if path.is_file() else None
if old is not None and CODEX_MCP_BEGIN in old:
return path, "present"
if old is not None:
return path, "conflict"
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(CODEX_REQUIREMENTS + "\n", encoding="utf-8")
return path, "created"


def install_claude_hooks(base_dir, events=None):
"""Merge the two hooks and the deny rule into <base>/.claude/settings.json
— the project's, or the home directory's for -g. The file is Claude
Expand Down Expand Up @@ -3693,28 +3677,28 @@ def report_codex_hooks(project_root, ns):


def report_codex_mcp(project_root, ns):
"""Project scope: register the write-free MCP approver in Codex's config and
pin the sandbox so elicitations always reach a human. --no-hooks skips it,
as with the other Codex wiring. Needs Codex >= v0.120; older Codex ignores
the server and the console approve path still works."""
"""Project scope: register the write-free MCP approver in Codex's config.
--no-hooks skips it, as with the other Codex wiring. Needs Codex >= v0.120
for elicitation; older Codex ignores the server and the console approve
path still works."""
if getattr(ns, "no_hooks", False):
return
base = pathlib.Path(project_root) / ".codex"
cfg_path, cfg = merge_codex_mcp_config(base / "config.toml")
req_path, req = merge_codex_requirements(base / "requirements.toml")
cfg_path, cfg = merge_codex_mcp_config(pathlib.Path(project_root)
/ ".codex" / "config.toml")
if cfg in ("created", "appended"):
print(f"\nRegistered the pythia MCP approver in {cfg_path} "
"(Codex >= v0.120; run /mcp to trust it).")
elif cfg == "conflict":
print(f"\n! {cfg_path} already defines [mcp_servers.pythia] or "
"[approval_policy.granular]; add this yourself:\n"
+ CODEX_MCP_BLOCK)
if req == "created":
print(f"Pinned the sandbox in {req_path} (no danger-full-access, so "
"elicitations always ask a human).")
elif req == "conflict":
print(f"\n! {req_path} already exists; add these constraints to it:\n"
+ CODEX_REQUIREMENTS)
print(f"\n! {cfg_path} already defines [mcp_servers.pythia]; add this "
"yourself:\n" + CODEX_MCP_BLOCK)
else:
print(f"\npythia MCP approver already registered in {cfg_path}.")
# The one bypass of the elicitation is Codex auto-approving it under the
# danger-full-access sandbox. pythia does not write requirements.toml to
# forbid that — it is enterprise-managed config, and the schema is
# version-fragile — so it warns instead.
print("Do not run Codex under `danger-full-access` with pythia: that "
"sandbox auto-approves elicitations, bypassing your Approve.")


def cmd_install(conn, schema, ns):
Expand Down
53 changes: 20 additions & 33 deletions tests/test_mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,11 @@ def elicit(message, schema):

res = pythia.mcp_approve_decision(td, token, elicit)
assert res["isError"] is False
assert token in res["content"][0]["text"]
text = res["content"][0]["text"]
assert token in text
# the message tells the AGENT to continue itself, not the developer to
# type a command — 0.14.1 fix
assert "yourself" in text.lower()
assert "PKG_ORDER" in seen["message"] # the card was shown
assert seen["schema"]["properties"]["decision"]["enum"] == ["Approve", "Reject"]
g = pythia.read_grant(td, token)
Expand Down Expand Up @@ -200,7 +204,10 @@ def test_merge_codex_mcp_config_appends_once_when_clean():
body = path.read_text(encoding="utf-8")
assert "[mcp_servers.pythia]" in body
assert 'args = ["-m", "pythia", "mcp"]' in body
assert "mcp_elicitations = true" in body
# 0.14.1: we do NOT touch approval_policy — an incomplete granular table
# broke Codex v0.154 config loading, and elicitations work under the
# default interactive policy anyway.
assert "approval_policy" not in body
assert pythia.merge_codex_mcp_config(path)[1] == "present" # idempotent


Expand All @@ -216,43 +223,20 @@ def test_merge_codex_mcp_config_preserves_other_content():
assert "[mcp_servers.pythia]" in body


def test_merge_codex_mcp_config_refuses_on_table_collision():
def test_merge_codex_mcp_config_refuses_on_server_collision():
with tempfile.TemporaryDirectory() as td:
path = pathlib.Path(td) / ".codex" / "config.toml"
path.parent.mkdir(parents=True)
original = "[approval_policy.granular]\nmcp_elicitations = false\n"
original = '[mcp_servers.pythia]\ncommand = "other"\n'
path.write_text(original, encoding="utf-8")
p, action = pythia.merge_codex_mcp_config(path)
assert action == "conflict"
assert path.read_text(encoding="utf-8") == original # untouched


def test_merge_codex_requirements_creates_and_is_idempotent():
with tempfile.TemporaryDirectory() as td:
path = pathlib.Path(td) / ".codex" / "requirements.toml"
p, action = pythia.merge_codex_requirements(path)
assert action == "created"
body = path.read_text(encoding="utf-8")
assert "allowed_approval_policies" in body and "allowed_sandbox_modes" in body
assert "danger-full-access" not in body # the forbidden mode is absent
assert '"never"' not in body # the forbidden policy is absent
assert pythia.merge_codex_requirements(path)[1] == "present"


def test_merge_codex_requirements_refuses_an_existing_file():
with tempfile.TemporaryDirectory() as td:
path = pathlib.Path(td) / ".codex" / "requirements.toml"
path.parent.mkdir(parents=True)
original = "# org policy\nallowed_sandbox_modes = []\n"
path.write_text(original, encoding="utf-8")
p, action = pythia.merge_codex_requirements(path)
assert action == "conflict"
assert path.read_text(encoding="utf-8") == original # untouched


# --- Task 5: wire the MCP install into cmd_install (project scope) ----------

def test_cmd_install_registers_the_mcp_server_and_pins_the_sandbox():
def test_cmd_install_registers_the_mcp_server_and_warns_off_danger():
import argparse
import contextlib
old = os.environ.get("PATH")
Expand All @@ -261,13 +245,16 @@ def test_cmd_install_registers_the_mcp_server_and_pins_the_sandbox():
with tempfile.TemporaryDirectory() as td:
ns = argparse.Namespace(project_root=td, glob=False, source=None,
color=False, json=False, no_hooks=False)
with contextlib.redirect_stdout(io.StringIO()):
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
pythia.cmd_install(None, None, ns)
cfg = pathlib.Path(td) / ".codex" / "config.toml"
req = pathlib.Path(td) / ".codex" / "requirements.toml"
assert "[mcp_servers.pythia]" in cfg.read_text(encoding="utf-8")
assert "mcp_elicitations = true" in cfg.read_text(encoding="utf-8")
assert "allowed_sandbox_modes" in req.read_text(encoding="utf-8")
body = cfg.read_text(encoding="utf-8")
assert "[mcp_servers.pythia]" in body
assert "approval_policy" not in body # 0.14.1: never touched
# requirements.toml is no longer written; the pin became a warning
assert not (pathlib.Path(td) / ".codex" / "requirements.toml").exists()
assert "danger-full-access" in buf.getvalue() # the warning
finally:
if old is not None:
os.environ["PATH"] = old
Expand Down