pythia-plsql 0.9.0__tar.gz → 0.10.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {pythia_plsql-0.9.0/scripts/pythia_plsql.egg-info → pythia_plsql-0.10.0}/PKG-INFO +9 -6
- {pythia_plsql-0.9.0 → pythia_plsql-0.10.0}/README.md +8 -5
- {pythia_plsql-0.9.0 → pythia_plsql-0.10.0}/pyproject.toml +1 -1
- pythia_plsql-0.10.0/queries/current-schema.sql +6 -0
- {pythia_plsql-0.9.0 → pythia_plsql-0.10.0}/scripts/pythia.py +226 -58
- {pythia_plsql-0.9.0 → pythia_plsql-0.10.0/scripts/pythia_plsql.egg-info}/PKG-INFO +9 -6
- {pythia_plsql-0.9.0 → pythia_plsql-0.10.0}/scripts/pythia_plsql.egg-info/SOURCES.txt +1 -0
- {pythia_plsql-0.9.0 → pythia_plsql-0.10.0}/skills/pythia-apply/SKILL.md +17 -17
- {pythia_plsql-0.9.0 → pythia_plsql-0.10.0}/tests/test_phase3.py +154 -2
- {pythia_plsql-0.9.0 → pythia_plsql-0.10.0}/LICENSE +0 -0
- {pythia_plsql-0.9.0 → pythia_plsql-0.10.0}/queries/compile-errors.sql +0 -0
- {pythia_plsql-0.9.0 → pythia_plsql-0.10.0}/queries/dependencies.sql +0 -0
- {pythia_plsql-0.9.0 → pythia_plsql-0.10.0}/queries/impact.sql +0 -0
- {pythia_plsql-0.9.0 → pythia_plsql-0.10.0}/queries/invalid-objects.sql +0 -0
- {pythia_plsql-0.9.0 → pythia_plsql-0.10.0}/queries/name-occupants.sql +0 -0
- {pythia_plsql-0.9.0 → pythia_plsql-0.10.0}/queries/object-names.sql +0 -0
- {pythia_plsql-0.9.0 → pythia_plsql-0.10.0}/queries/object-source.sql +0 -0
- {pythia_plsql-0.9.0 → pythia_plsql-0.10.0}/queries/plscope-enabled.sql +0 -0
- {pythia_plsql-0.9.0 → pythia_plsql-0.10.0}/queries/plscope-statements.sql +0 -0
- {pythia_plsql-0.9.0 → pythia_plsql-0.10.0}/queries/plscope-usages.sql +0 -0
- {pythia_plsql-0.9.0 → pythia_plsql-0.10.0}/queries/session-privileges.sql +0 -0
- {pythia_plsql-0.9.0 → pythia_plsql-0.10.0}/queries/similar-candidates.sql +0 -0
- {pythia_plsql-0.9.0 → pythia_plsql-0.10.0}/queries/source.sql +0 -0
- {pythia_plsql-0.9.0 → pythia_plsql-0.10.0}/scripts/pythia_plsql.egg-info/dependency_links.txt +0 -0
- {pythia_plsql-0.9.0 → pythia_plsql-0.10.0}/scripts/pythia_plsql.egg-info/entry_points.txt +0 -0
- {pythia_plsql-0.9.0 → pythia_plsql-0.10.0}/scripts/pythia_plsql.egg-info/requires.txt +0 -0
- {pythia_plsql-0.9.0 → pythia_plsql-0.10.0}/scripts/pythia_plsql.egg-info/top_level.txt +0 -0
- {pythia_plsql-0.9.0 → pythia_plsql-0.10.0}/setup.cfg +0 -0
- {pythia_plsql-0.9.0 → pythia_plsql-0.10.0}/skills/pythia-conventions/SKILL.md +0 -0
- {pythia_plsql-0.9.0 → pythia_plsql-0.10.0}/skills/pythia-explore/SKILL.md +0 -0
- {pythia_plsql-0.9.0 → pythia_plsql-0.10.0}/skills/pythia-explore/reference/data-dictionary.md +0 -0
- {pythia_plsql-0.9.0 → pythia_plsql-0.10.0}/skills/pythia-impact/SKILL.md +0 -0
- {pythia_plsql-0.9.0 → pythia_plsql-0.10.0}/skills/pythia-review/SKILL.md +0 -0
- {pythia_plsql-0.9.0 → pythia_plsql-0.10.0}/skills/pythia-review/reference/antipatterns.md +0 -0
- {pythia_plsql-0.9.0 → pythia_plsql-0.10.0}/skills/pythia-setup/SKILL.md +0 -0
- {pythia_plsql-0.9.0 → pythia_plsql-0.10.0}/skills/pythia-skill-author/SKILL.md +0 -0
- {pythia_plsql-0.9.0 → pythia_plsql-0.10.0}/skills/pythia-spec/SKILL.md +0 -0
- {pythia_plsql-0.9.0 → pythia_plsql-0.10.0}/skills/pythia-write/SKILL.md +0 -0
- {pythia_plsql-0.9.0 → pythia_plsql-0.10.0}/skills/pythia-write/reference/patterns.md +0 -0
- {pythia_plsql-0.9.0 → pythia_plsql-0.10.0}/skills/using-pythia/SKILL.md +0 -0
- {pythia_plsql-0.9.0 → pythia_plsql-0.10.0}/tests/test_install.py +0 -0
- {pythia_plsql-0.9.0 → pythia_plsql-0.10.0}/tests/test_phase1.py +0 -0
- {pythia_plsql-0.9.0 → pythia_plsql-0.10.0}/tests/test_phase2.py +0 -0
- {pythia_plsql-0.9.0 → pythia_plsql-0.10.0}/tests/test_phase5.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pythia-plsql
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.10.0
|
|
4
4
|
Summary: PL/SQL development for AI agents on Oracle Database - expert data-dictionary queries, impact analysis, and a snapshot-verified write path with honest rollback.
|
|
5
5
|
License: MIT
|
|
6
6
|
Project-URL: Homepage, https://github.com/thaildhe172591/pythia
|
|
@@ -101,11 +101,14 @@ through one door: **snapshot → impact → preview → token → approve → ap
|
|
|
101
101
|
verify → report**. DDL self-commits in Oracle, so the snapshot is the only real
|
|
102
102
|
undo — it runs first and no flag disables it. A content-bound token guarantees
|
|
103
103
|
what lands is byte-for-byte what was previewed, and a **developer-minted grant**
|
|
104
|
-
guarantees a person approved it at all
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
104
|
+
guarantees a person approved it at all — through one of two doors, both the
|
|
105
|
+
developer's. In chat: the agent asks with `AskUserQuestion`, the question text
|
|
106
|
+
being pythia's own approval card (`pythia approve --card <token>`), and the
|
|
107
|
+
answer **Approve** — written into the hook payload by Claude Code, never by
|
|
108
|
+
the agent — mints the one-time grant. At a console: `pythia approve <token>`.
|
|
109
|
+
Without that grant `apply --confirm` refuses. A headless agent cannot `--yes`
|
|
110
|
+
its own writes, cannot mint approval, and cannot loosen policy — each of those
|
|
111
|
+
takes a human, answering or typing.
|
|
109
112
|
|
|
110
113
|
The same discipline holds when the *developer* does the work: `src` and
|
|
111
114
|
`impact` silently snapshot what they read, so even a change made by hand in
|
|
@@ -87,11 +87,14 @@ through one door: **snapshot → impact → preview → token → approve → ap
|
|
|
87
87
|
verify → report**. DDL self-commits in Oracle, so the snapshot is the only real
|
|
88
88
|
undo — it runs first and no flag disables it. A content-bound token guarantees
|
|
89
89
|
what lands is byte-for-byte what was previewed, and a **developer-minted grant**
|
|
90
|
-
guarantees a person approved it at all
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
90
|
+
guarantees a person approved it at all — through one of two doors, both the
|
|
91
|
+
developer's. In chat: the agent asks with `AskUserQuestion`, the question text
|
|
92
|
+
being pythia's own approval card (`pythia approve --card <token>`), and the
|
|
93
|
+
answer **Approve** — written into the hook payload by Claude Code, never by
|
|
94
|
+
the agent — mints the one-time grant. At a console: `pythia approve <token>`.
|
|
95
|
+
Without that grant `apply --confirm` refuses. A headless agent cannot `--yes`
|
|
96
|
+
its own writes, cannot mint approval, and cannot loosen policy — each of those
|
|
97
|
+
takes a human, answering or typing.
|
|
95
98
|
|
|
96
99
|
The same discipline holds when the *developer* does the work: `src` and
|
|
97
100
|
`impact` silently snapshot what they read, so even a change made by hand in
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "pythia-plsql"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.10.0"
|
|
8
8
|
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."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = { text = "MIT" }
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
-- Purpose: the schema an unqualified CREATE lands in for this session. A proxy
|
|
2
|
+
-- or a mis-set connections.json can put it somewhere other than the
|
|
3
|
+
-- schema pythia snapshots and verifies; apply refuses that mismatch.
|
|
4
|
+
-- Binds: none
|
|
5
|
+
-- Returns: CURRENT_SCHEMA
|
|
6
|
+
select sys_context('userenv', 'current_schema') current_schema from dual
|
|
@@ -84,6 +84,7 @@ QUERY_BINDS = {
|
|
|
84
84
|
"source.sql": {"s", "n"},
|
|
85
85
|
"object-source.sql": {"s", "n", "t"},
|
|
86
86
|
"session-privileges.sql": set(),
|
|
87
|
+
"current-schema.sql": set(),
|
|
87
88
|
"name-occupants.sql": {"s", "n"},
|
|
88
89
|
"object-names.sql": {"s"},
|
|
89
90
|
}
|
|
@@ -382,8 +383,11 @@ HEADLESS_YES_MSG = (
|
|
|
382
383
|
|
|
383
384
|
APPROVE_HEADLESS_MSG = (
|
|
384
385
|
"approve is the developer's command — it needs a real console.\n"
|
|
385
|
-
"Agents
|
|
386
|
-
"
|
|
386
|
+
"Agents ask instead: AskUserQuestion with the text of `approve --card "
|
|
387
|
+
"<token>` and the options Approve / Reject — the pythia hook mints the "
|
|
388
|
+
"grant when the developer answers Approve. Or a person runs approve in "
|
|
389
|
+
"their own terminal.\n(Real pipelines apply with --yes and PYTHIA_CI=1 "
|
|
390
|
+
"instead.)")
|
|
387
391
|
|
|
388
392
|
|
|
389
393
|
def grant_refusal(status, token, grant, conn_name, file_hint):
|
|
@@ -393,7 +397,10 @@ def grant_refusal(status, token, grant, conn_name, file_hint):
|
|
|
393
397
|
preview_line = f" {invocation()} apply {file_hint}"
|
|
394
398
|
if status == "missing":
|
|
395
399
|
return ("The confirmation token matches, but no developer approval is "
|
|
396
|
-
"on file.\nAsk
|
|
400
|
+
"on file.\nAsk in chat — AskUserQuestion carrying the text of\n"
|
|
401
|
+
f" {invocation()} approve --card {token}\nwith the options "
|
|
402
|
+
"Approve / Reject (the pythia hook mints the grant on Approve) "
|
|
403
|
+
"— or ask the developer to run, in their own terminal "
|
|
397
404
|
f"(agents cannot run it):\n{approve_line}\n"
|
|
398
405
|
"Then re-run this command.")
|
|
399
406
|
if status == "expired":
|
|
@@ -1129,11 +1136,14 @@ def grant_path(root, token):
|
|
|
1129
1136
|
return grants_root(root) / f"{token}.json"
|
|
1130
1137
|
|
|
1131
1138
|
|
|
1132
|
-
def mint_grant(root, token, conn_name, now=None, revalidate=None
|
|
1139
|
+
def mint_grant(root, token, conn_name, now=None, revalidate=None,
|
|
1140
|
+
approver="console", session=None):
|
|
1133
1141
|
"""Record one developer approval. Single use, short-lived, bound to the
|
|
1134
1142
|
connection the preview ran on. `revalidate` is the row-set fingerprint the
|
|
1135
1143
|
human was shown — audit data, not a second gate: the enforcement is the
|
|
1136
|
-
token, which already hashes that same fingerprint.
|
|
1144
|
+
token, which already hashes that same fingerprint. `approver` says which
|
|
1145
|
+
door the human came through — console or chat — and `session` is the
|
|
1146
|
+
chat session's id; both are audit data too."""
|
|
1137
1147
|
import datetime
|
|
1138
1148
|
now = now or datetime.datetime.now()
|
|
1139
1149
|
rec = {"token": token,
|
|
@@ -1142,7 +1152,9 @@ def mint_grant(root, token, conn_name, now=None, revalidate=None):
|
|
|
1142
1152
|
"expires_at": (now + datetime.timedelta(
|
|
1143
1153
|
minutes=GRANT_TTL_MINUTES)).isoformat(timespec="seconds"),
|
|
1144
1154
|
"used_at": None,
|
|
1145
|
-
"revalidate": revalidate or None
|
|
1155
|
+
"revalidate": revalidate or None,
|
|
1156
|
+
"approver": approver,
|
|
1157
|
+
"session": session or None}
|
|
1146
1158
|
d = grants_root(root)
|
|
1147
1159
|
d.mkdir(parents=True, exist_ok=True)
|
|
1148
1160
|
grant_path(root, token).write_text(json.dumps(rec, indent=2) + "\n",
|
|
@@ -1437,6 +1449,14 @@ def cmd_check(conn, schema, ns):
|
|
|
1437
1449
|
(select count(*) from all_triggers where owner = :s) triggers
|
|
1438
1450
|
from dual""", {"s": schema})
|
|
1439
1451
|
emit_table(ns, cols, rows, False)
|
|
1452
|
+
current = session_schema(conn)
|
|
1453
|
+
if current and current != str(schema).upper():
|
|
1454
|
+
print("\n" + paint(
|
|
1455
|
+
f"! Session schema is {current}, but this connection is configured "
|
|
1456
|
+
f"for {schema}: unqualified writes would land in {current} while "
|
|
1457
|
+
f"pythia reads {schema}. apply refuses until connections.json says "
|
|
1458
|
+
f"\"schema\": \"{current}\".", "yellow", color_enabled(sys.stderr)),
|
|
1459
|
+
file=sys.stderr)
|
|
1440
1460
|
warn = privilege_warning(conn, schema, ns.conn_user)
|
|
1441
1461
|
if warn:
|
|
1442
1462
|
print("\n" + paint(warn, "yellow", color_enabled(sys.stderr)),
|
|
@@ -1942,6 +1962,31 @@ def privilege_warning(conn, schema, conn_user):
|
|
|
1942
1962
|
"explains what is at stake.")
|
|
1943
1963
|
|
|
1944
1964
|
|
|
1965
|
+
def session_schema(conn):
|
|
1966
|
+
"""Where an unqualified name lands for this session, or None if the
|
|
1967
|
+
database did not say (it always does on Oracle; test doubles may not)."""
|
|
1968
|
+
_, rows = run_query(conn, load_query("current-schema.sql"), {})
|
|
1969
|
+
return str(rows[0][0]).upper() if rows and rows[0] and rows[0][0] else None
|
|
1970
|
+
|
|
1971
|
+
|
|
1972
|
+
def refuse_schema_mismatch(conn, schema, ns):
|
|
1973
|
+
"""An unqualified CREATE lands in the session's current schema, while
|
|
1974
|
+
every snapshot, impact and verify query here asks the configured one. If
|
|
1975
|
+
those differ — a proxy into another user, a stale `schema` in
|
|
1976
|
+
connections.json — the write would land in one place and be verified in
|
|
1977
|
+
another, and "Applied, compiled clean" would be about the wrong object.
|
|
1978
|
+
Found live: PYTHIA_AGENT[PYTHIA_DEV] with schema still set to PYTHIA."""
|
|
1979
|
+
current = session_schema(conn)
|
|
1980
|
+
if current and current != str(schema).upper():
|
|
1981
|
+
sys.exit(f"Refused: this session's schema is {current!r}, but connection "
|
|
1982
|
+
f"{getattr(ns, 'conn_name', None) or '?'!r} is configured for "
|
|
1983
|
+
f"{schema!r}.\nAn unqualified statement would land in {current!r} "
|
|
1984
|
+
f"while pythia snapshots and verifies {schema!r}.\nSet "
|
|
1985
|
+
f"\"schema\": \"{current}\" for this connection in "
|
|
1986
|
+
f".pythia/connections.json (or connect as {schema!r}), then "
|
|
1987
|
+
"preview again.")
|
|
1988
|
+
|
|
1989
|
+
|
|
1945
1990
|
def run_apply(conn, schema, ns, file_text, origin=None):
|
|
1946
1991
|
"""The six steps: SNAPSHOT, IMPACT, PREVIEW, APPLY, VERIFY, REPORT.
|
|
1947
1992
|
Returns the exit code. Refusals raise SystemExit (exit 1)."""
|
|
@@ -2002,6 +2047,8 @@ def run_apply(conn, schema, ns, file_text, origin=None):
|
|
|
2002
2047
|
# journal records the statement itself so at least *what ran* is kept.
|
|
2003
2048
|
otype, name = group.upper(), "STATEMENT"
|
|
2004
2049
|
|
|
2050
|
+
refuse_schema_mismatch(conn, schema, ns) # the write must land where it is verified
|
|
2051
|
+
|
|
2005
2052
|
# 1. SNAPSHOT — before anything else, unconditionally.
|
|
2006
2053
|
db_source = ""
|
|
2007
2054
|
row_set = None
|
|
@@ -2111,10 +2158,12 @@ def run_apply(conn, schema, ns, file_text, origin=None):
|
|
|
2111
2158
|
f"{journal_root(ns.project_root) / entry / 'restore.sql'}",
|
|
2112
2159
|
"dim", en))
|
|
2113
2160
|
if not confirmed:
|
|
2114
|
-
print("\n To apply —
|
|
2115
|
-
print("
|
|
2161
|
+
print("\n To apply — the developer approves, then the agent confirms:")
|
|
2162
|
+
print(" in chat: the agent asks (AskUserQuestion) with the text of "
|
|
2163
|
+
+ paint(f"{invocation()} approve --card {token}", "cyan", en))
|
|
2164
|
+
print(" or in a terminal: "
|
|
2116
2165
|
+ paint(f"{invocation()} approve {token}", "cyan", en))
|
|
2117
|
-
print(" then the agent:
|
|
2166
|
+
print(" then the agent: "
|
|
2118
2167
|
+ paint(f"{invocation()} apply {ns.file} --confirm {token}",
|
|
2119
2168
|
"cyan", en))
|
|
2120
2169
|
if not confirmed:
|
|
@@ -2229,7 +2278,10 @@ def cmd_apply(conn, schema, ns):
|
|
|
2229
2278
|
path = pathlib.Path(ns.file)
|
|
2230
2279
|
if not path.is_file():
|
|
2231
2280
|
sys.exit(f"No such file: {path}")
|
|
2232
|
-
|
|
2281
|
+
# utf-8-sig: Windows editors prepend a BOM, which read as a stray byte
|
|
2282
|
+
# turned every such file into "cannot classify". A BOM-less file reads
|
|
2283
|
+
# identically.
|
|
2284
|
+
code = run_apply(conn, schema, ns, path.read_text(encoding="utf-8-sig"))
|
|
2233
2285
|
if code:
|
|
2234
2286
|
sys.exit(code)
|
|
2235
2287
|
|
|
@@ -2274,14 +2326,13 @@ def token_mismatch_message(ns, row_set):
|
|
|
2274
2326
|
f" {invocation()} apply {ns.file}")
|
|
2275
2327
|
|
|
2276
2328
|
|
|
2277
|
-
def
|
|
2278
|
-
"""
|
|
2279
|
-
|
|
2280
|
-
|
|
2281
|
-
|
|
2282
|
-
|
|
2283
|
-
|
|
2284
|
-
entry, meta = find_preview_by_token(ns.project_root, token)
|
|
2329
|
+
def approval_card(root, token):
|
|
2330
|
+
"""What a human approves, in pythia's own words, read from the preview's
|
|
2331
|
+
journal entry and never recomputed. Returns (entry, meta, lines). The same
|
|
2332
|
+
text is printed at the console, asked with in chat, and checked by the
|
|
2333
|
+
hook — so what is approved is what was shown, whichever door it came
|
|
2334
|
+
through. Refuses a token no pending preview carries."""
|
|
2335
|
+
entry, meta = find_preview_by_token(root, token)
|
|
2285
2336
|
if not meta:
|
|
2286
2337
|
sys.exit(f"No pending preview carries token {token!r}.\n"
|
|
2287
2338
|
"Approve what you have seen: run the preview first, then "
|
|
@@ -2291,49 +2342,154 @@ def cmd_approve(conn, schema, ns):
|
|
|
2291
2342
|
sys.exit(f"Token {token!r} belongs to a preview that was already "
|
|
2292
2343
|
"applied.\nPreview again to get a fresh token:\n"
|
|
2293
2344
|
f" {invocation()} apply <file>")
|
|
2294
|
-
conn_name = meta.get("connection") or ""
|
|
2295
|
-
prune_expired_grants(ns.project_root)
|
|
2296
|
-
row_set = meta.get("row_set")
|
|
2297
|
-
rec = mint_grant(ns.project_root, token, conn_name,
|
|
2298
|
-
revalidate=fingerprint_text(row_set))
|
|
2299
|
-
obj, otype = meta.get("object", "?"), meta.get("type", "?")
|
|
2300
2345
|
group = meta.get("group", "")
|
|
2301
|
-
|
|
2302
|
-
|
|
2303
|
-
"object": obj, "type": otype, "group": group,
|
|
2304
|
-
"rows": (row_set or {}).get("count"),
|
|
2305
|
-
"entry": entry, "minted_at": rec["minted_at"],
|
|
2306
|
-
"expires_at": rec["expires_at"],
|
|
2307
|
-
"ttl_minutes": GRANT_TTL_MINUTES}))
|
|
2308
|
-
return
|
|
2309
|
-
en = getattr(ns, "color", False)
|
|
2310
|
-
print()
|
|
2346
|
+
row_set = meta.get("row_set")
|
|
2347
|
+
lines = []
|
|
2311
2348
|
if group and group != "plsql_source":
|
|
2312
2349
|
# no object identity, no snapshot: show the statement itself and say
|
|
2313
2350
|
# plainly that there is no undo waiting behind it
|
|
2314
|
-
stmt = read_journal_entry(
|
|
2315
|
-
|
|
2316
|
-
for ln in stmt.splitlines()
|
|
2317
|
-
print(f" {ln}")
|
|
2351
|
+
stmt = read_journal_entry(root, entry)["after"].strip()
|
|
2352
|
+
lines.append(f"Approving a {group} statement on {meta.get('schema', '?')}:")
|
|
2353
|
+
lines += [f" {ln}" for ln in stmt.splitlines()]
|
|
2318
2354
|
if row_set:
|
|
2319
2355
|
# the rows themselves, exactly as the preview showed them: what is
|
|
2320
2356
|
# approved has to be what was seen
|
|
2321
|
-
|
|
2322
|
-
|
|
2323
|
-
|
|
2324
|
-
|
|
2325
|
-
"it cannot be undone from the journal.", "yellow", en))
|
|
2357
|
+
lines.append("")
|
|
2358
|
+
lines += render_row_set(row_set, False, indent=" ")
|
|
2359
|
+
lines.append("There is no snapshot for this group — after commit it "
|
|
2360
|
+
"cannot be undone from the journal.")
|
|
2326
2361
|
else:
|
|
2327
|
-
|
|
2328
|
-
|
|
2362
|
+
lines.append(f"Approving: {meta.get('object', '?')} "
|
|
2363
|
+
f"({meta.get('type', '?')}) in {meta.get('schema', '?')}")
|
|
2329
2364
|
if meta.get("summary"):
|
|
2330
|
-
|
|
2331
|
-
|
|
2332
|
-
|
|
2333
|
-
|
|
2334
|
-
|
|
2335
|
-
|
|
2336
|
-
|
|
2365
|
+
lines.append(f"Impact: {meta['summary'].lstrip('- ')}")
|
|
2366
|
+
lines.append(f"Previewed {entry[:19].replace('T', ' ')} on connection "
|
|
2367
|
+
f"{meta.get('connection') or '?'}.")
|
|
2368
|
+
return entry, meta, lines
|
|
2369
|
+
|
|
2370
|
+
|
|
2371
|
+
def squash(text):
|
|
2372
|
+
"""Whitespace-insensitive form, for 'did the question carry the card'."""
|
|
2373
|
+
return " ".join(str(text).split()).lower()
|
|
2374
|
+
|
|
2375
|
+
|
|
2376
|
+
TOKEN_IN_QUESTION = re.compile(r"approve\s+(?:--card\s+)?([0-9a-f]{6,})\b", re.I)
|
|
2377
|
+
|
|
2378
|
+
|
|
2379
|
+
def hook_approve(ns):
|
|
2380
|
+
"""The chat door. Claude Code runs this as a PostToolUse hook on
|
|
2381
|
+
AskUserQuestion, with the payload on stdin; the developer's answer in it
|
|
2382
|
+
was filled in by the client, not the agent. A grant is minted only when
|
|
2383
|
+
the answer is exactly Approve AND the question carried pythia's own card
|
|
2384
|
+
for that token — otherwise the human approved the agent's words, not the
|
|
2385
|
+
preview, and that is no approval. Everything else is silence: a hook that
|
|
2386
|
+
talks on every question is noise."""
|
|
2387
|
+
try:
|
|
2388
|
+
payload = json.load(sys.stdin)
|
|
2389
|
+
except ValueError:
|
|
2390
|
+
return
|
|
2391
|
+
if not isinstance(payload, dict) or payload.get("tool_name") != "AskUserQuestion":
|
|
2392
|
+
return
|
|
2393
|
+
resp = payload.get("tool_response")
|
|
2394
|
+
answers = (resp.get("answers") if isinstance(resp, dict) else None) \
|
|
2395
|
+
or (payload.get("tool_input") or {}).get("answers") or {}
|
|
2396
|
+
notes = []
|
|
2397
|
+
for question, answer in answers.items():
|
|
2398
|
+
m = TOKEN_IN_QUESTION.search(str(question))
|
|
2399
|
+
if not m:
|
|
2400
|
+
continue
|
|
2401
|
+
token = m.group(1).lower()
|
|
2402
|
+
if squash(answer) != "approve":
|
|
2403
|
+
notes.append(f"{token}: the developer did NOT approve (answered "
|
|
2404
|
+
f"{str(answer)!r}). No grant minted — do not apply; "
|
|
2405
|
+
"ask what should change.")
|
|
2406
|
+
continue
|
|
2407
|
+
try:
|
|
2408
|
+
_, meta, body = approval_card(ns.project_root, token)
|
|
2409
|
+
except SystemExit as e:
|
|
2410
|
+
notes.append(f"{token}: {e}")
|
|
2411
|
+
continue
|
|
2412
|
+
if squash("\n".join(body)) not in squash(question):
|
|
2413
|
+
notes.append(f"{token}: the question did not carry pythia's approval "
|
|
2414
|
+
"card verbatim, so the developer approved your words, "
|
|
2415
|
+
"not the preview. No grant minted. Run "
|
|
2416
|
+
f"`{invocation()} approve --card {token}` and ask "
|
|
2417
|
+
"again with exactly that text.")
|
|
2418
|
+
continue
|
|
2419
|
+
prune_expired_grants(ns.project_root)
|
|
2420
|
+
mint_grant(ns.project_root, token, meta.get("connection") or "",
|
|
2421
|
+
revalidate=fingerprint_text(meta.get("row_set")),
|
|
2422
|
+
approver="chat", session=payload.get("session_id"))
|
|
2423
|
+
notes.append(f"{token}: approved in chat — grant minted, single use, "
|
|
2424
|
+
f"expires in {GRANT_TTL_MINUTES} minutes. Run: "
|
|
2425
|
+
f"{invocation()} apply <file> --confirm {token}")
|
|
2426
|
+
if notes:
|
|
2427
|
+
print(json.dumps({"hookSpecificOutput": {
|
|
2428
|
+
"hookEventName": "PostToolUse",
|
|
2429
|
+
"additionalContext": "pythia: " + "\n".join(notes)}}))
|
|
2430
|
+
|
|
2431
|
+
|
|
2432
|
+
def cmd_approve(conn, schema, ns):
|
|
2433
|
+
"""The developer's half of the gate: one human act that lets the agent's
|
|
2434
|
+
next `apply --confirm` through — at a real console, or in chat via the
|
|
2435
|
+
hook (--hook). --card prints what the agent asks with and mints nothing.
|
|
2436
|
+
Touches no database — a terminal with no connection configured can still
|
|
2437
|
+
approve."""
|
|
2438
|
+
if getattr(ns, "hook", False):
|
|
2439
|
+
return hook_approve(ns)
|
|
2440
|
+
tokens = [ns.token] if isinstance(ns.token, str) else list(ns.token or [])
|
|
2441
|
+
tokens = [str(t).strip().lower() for t in tokens]
|
|
2442
|
+
card_only = getattr(ns, "card", False)
|
|
2443
|
+
if not tokens:
|
|
2444
|
+
sys.exit(f"Usage: {invocation()} approve <token> [<token> ...]\n"
|
|
2445
|
+
f" {invocation()} approve --card <token>")
|
|
2446
|
+
if not card_only and not console_session(ns):
|
|
2447
|
+
sys.exit(APPROVE_HEADLESS_MSG)
|
|
2448
|
+
en = getattr(ns, "color", False)
|
|
2449
|
+
out = []
|
|
2450
|
+
for token in tokens:
|
|
2451
|
+
entry, meta, body = approval_card(ns.project_root, token)
|
|
2452
|
+
conn_name = meta.get("connection") or ""
|
|
2453
|
+
row_set = meta.get("row_set")
|
|
2454
|
+
rec = None
|
|
2455
|
+
if not card_only:
|
|
2456
|
+
prune_expired_grants(ns.project_root)
|
|
2457
|
+
rec = mint_grant(ns.project_root, token, conn_name,
|
|
2458
|
+
revalidate=fingerprint_text(row_set))
|
|
2459
|
+
if ns.json:
|
|
2460
|
+
card = f"{invocation()} approve {token}\n" + "\n".join(body)
|
|
2461
|
+
out.append({"ok": True, "token": token, "conn": conn_name,
|
|
2462
|
+
"object": meta.get("object", "?"),
|
|
2463
|
+
"type": meta.get("type", "?"),
|
|
2464
|
+
"group": meta.get("group", ""),
|
|
2465
|
+
"rows": (row_set or {}).get("count"),
|
|
2466
|
+
"entry": entry, "card": card,
|
|
2467
|
+
"question": {"question": card, "header": "pythia",
|
|
2468
|
+
"multiSelect": False,
|
|
2469
|
+
"options": [
|
|
2470
|
+
{"label": "Approve",
|
|
2471
|
+
"description": "mint the one-time grant; the agent then runs apply --confirm"},
|
|
2472
|
+
{"label": "Reject",
|
|
2473
|
+
"description": "nothing is written; say what should change"}]},
|
|
2474
|
+
"minted": rec is not None,
|
|
2475
|
+
"minted_at": (rec or {}).get("minted_at"),
|
|
2476
|
+
"expires_at": (rec or {}).get("expires_at"),
|
|
2477
|
+
"ttl_minutes": GRANT_TTL_MINUTES})
|
|
2478
|
+
continue
|
|
2479
|
+
print()
|
|
2480
|
+
if card_only:
|
|
2481
|
+
# plain text on purpose: this goes into a question, not a terminal
|
|
2482
|
+
print(f"{invocation()} approve {token}")
|
|
2483
|
+
print("\n".join(body))
|
|
2484
|
+
continue
|
|
2485
|
+
for ln in body:
|
|
2486
|
+
print(f" {paint(ln, 'yellow', en) if ln.startswith('There is no snapshot') else ln}")
|
|
2487
|
+
print(paint(f"\n Grant minted — single use, expires in "
|
|
2488
|
+
f"{GRANT_TTL_MINUTES} minutes.", "green", en))
|
|
2489
|
+
print(" The agent may now run: "
|
|
2490
|
+
+ paint(f"{invocation()} apply <file> --confirm {token}", "cyan", en))
|
|
2491
|
+
if ns.json:
|
|
2492
|
+
print(json.dumps(out[0] if len(out) == 1 else out))
|
|
2337
2493
|
|
|
2338
2494
|
|
|
2339
2495
|
def run_restore(conn, schema, ns):
|
|
@@ -2513,7 +2669,9 @@ ASK relay apply previews verbatim and wait for a real yes. >=10 dependents
|
|
|
2513
2669
|
exit 3 (written-but-broken) are relayed, never routed around. Do not
|
|
2514
2670
|
read exit codes through a pipe.
|
|
2515
2671
|
DO writes go through `pythia apply` only - snapshot, token, verify. The
|
|
2516
|
-
developer
|
|
2672
|
+
developer approves: you ask in chat (AskUserQuestion, text from
|
|
2673
|
+
`pythia approve --card <token>`, options Approve/Reject) or they run
|
|
2674
|
+
`pythia approve <token>` themselves; you never mint it. Full
|
|
2517
2675
|
contract: `pythia guide`.
|
|
2518
2676
|
"""
|
|
2519
2677
|
|
|
@@ -2565,10 +2723,13 @@ Stop at exactly these moments; a guess past any of them is a defect.
|
|
|
2565
2723
|
One door for writes: snapshot -> impact -> preview -> token -> approve ->
|
|
2566
2724
|
apply -> verify -> report.
|
|
2567
2725
|
|
|
2568
|
-
approve THE DEVELOPER'S
|
|
2569
|
-
|
|
2570
|
-
|
|
2571
|
-
|
|
2726
|
+
approve THE DEVELOPER'S ACT: mints the one-time grant that
|
|
2727
|
+
--confirm requires. Two doors, both theirs: answer
|
|
2728
|
+
Approve to your AskUserQuestion (question text =
|
|
2729
|
+
`approve --card <token>`, verbatim - the hook mints
|
|
2730
|
+
only then), or `approve <token>` at their own
|
|
2731
|
+
terminal. An agent never mints it - that is the
|
|
2732
|
+
point.
|
|
2572
2733
|
apply the six-step write; --confirm binds to the preview
|
|
2573
2734
|
AND to the developer's approval
|
|
2574
2735
|
journal restore undo, through the same six steps and the same gate
|
|
@@ -3142,7 +3303,14 @@ def build_parser():
|
|
|
3142
3303
|
s = sub.add_parser("approve", parents=[common()],
|
|
3143
3304
|
help="the developer approves a preview: mints the "
|
|
3144
3305
|
"one-time grant apply --confirm requires")
|
|
3145
|
-
s.add_argument("token", help="
|
|
3306
|
+
s.add_argument("token", nargs="*", help="token(s) printed by the preview")
|
|
3307
|
+
s.add_argument("--card", action="store_true",
|
|
3308
|
+
help="print the approval card the agent asks with in chat "
|
|
3309
|
+
"(AskUserQuestion); mints nothing, needs no console")
|
|
3310
|
+
s.add_argument("--hook", action="store_true",
|
|
3311
|
+
help="Claude Code PostToolUse hook on AskUserQuestion: "
|
|
3312
|
+
"reads the payload on stdin and mints the grant the "
|
|
3313
|
+
"developer answered Approve to")
|
|
3146
3314
|
sub.add_parser("connections", parents=[common()],
|
|
3147
3315
|
help="list configured connections — names, users, "
|
|
3148
3316
|
"targets; never passwords")
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pythia-plsql
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.10.0
|
|
4
4
|
Summary: PL/SQL development for AI agents on Oracle Database - expert data-dictionary queries, impact analysis, and a snapshot-verified write path with honest rollback.
|
|
5
5
|
License: MIT
|
|
6
6
|
Project-URL: Homepage, https://github.com/thaildhe172591/pythia
|
|
@@ -101,11 +101,14 @@ through one door: **snapshot → impact → preview → token → approve → ap
|
|
|
101
101
|
verify → report**. DDL self-commits in Oracle, so the snapshot is the only real
|
|
102
102
|
undo — it runs first and no flag disables it. A content-bound token guarantees
|
|
103
103
|
what lands is byte-for-byte what was previewed, and a **developer-minted grant**
|
|
104
|
-
guarantees a person approved it at all
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
104
|
+
guarantees a person approved it at all — through one of two doors, both the
|
|
105
|
+
developer's. In chat: the agent asks with `AskUserQuestion`, the question text
|
|
106
|
+
being pythia's own approval card (`pythia approve --card <token>`), and the
|
|
107
|
+
answer **Approve** — written into the hook payload by Claude Code, never by
|
|
108
|
+
the agent — mints the one-time grant. At a console: `pythia approve <token>`.
|
|
109
|
+
Without that grant `apply --confirm` refuses. A headless agent cannot `--yes`
|
|
110
|
+
its own writes, cannot mint approval, and cannot loosen policy — each of those
|
|
111
|
+
takes a human, answering or typing.
|
|
109
112
|
|
|
110
113
|
The same discipline holds when the *developer* does the work: `src` and
|
|
111
114
|
`impact` silently snapshot what they read, so even a change made by hand in
|
|
@@ -26,15 +26,13 @@ Three moves are FORBIDDEN for agents, and the CLI enforces all three (no
|
|
|
26
26
|
terminal attached → refusal):
|
|
27
27
|
|
|
28
28
|
- `--yes` — it is the developer's flag, never yours.
|
|
29
|
-
- `pythia approve <token>`
|
|
30
|
-
|
|
31
|
-
|
|
29
|
+
- Minting the approval. `pythia approve <token>` is the developer's console
|
|
30
|
+
command; an agent cannot run it. Your door is to ASK (step 3): the pythia
|
|
31
|
+
hook mints the grant only from the developer's own answer to your question.
|
|
32
32
|
- `pythia policy set <group>` to anything LOOSER — loosening policy is the
|
|
33
33
|
developer's decision; hand them the exact command to run themselves.
|
|
34
34
|
|
|
35
|
-
One move is yours
|
|
36
|
-
preview ends your turn; the token is used only after approval arrives as a NEW
|
|
37
|
-
message.
|
|
35
|
+
One move is yours: `--confirm` — only after `Approve`, or a console approve.
|
|
38
36
|
|
|
39
37
|
## When the change will NOT go through apply
|
|
40
38
|
|
|
@@ -55,16 +53,17 @@ confirms a number you already knew.
|
|
|
55
53
|
2. **Relay the preview — verbatim.** The diff, the `impact:` line, every `!`
|
|
56
54
|
warning, exactly as printed. The developer approves what they see, not
|
|
57
55
|
your paraphrase.
|
|
58
|
-
3. **
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
56
|
+
3. **Ask for approval — one question per token.** `pythia approve --card
|
|
57
|
+
<token>` prints the card; ask an `AskUserQuestion` whose text is that card
|
|
58
|
+
verbatim (header `pythia`; options exactly `Approve` / `Reject`, no
|
|
59
|
+
"(Recommended)"; up to four tokens per call). `Approve` mints the grant via
|
|
60
|
+
the hook. Anything else — `Reject`, free text, silence, an earlier preview's
|
|
61
|
+
yes — is not approval: stop and ask what should change. File changed → step 1.
|
|
62
|
+
No hook installed? Relay `pythia approve <token>` for their own terminal.
|
|
64
63
|
4. **Apply** by running the exact `then the agent:` line pythia printed. Two
|
|
65
64
|
refusals are normal here, and neither is a malfunction:
|
|
66
|
-
- *"no developer approval is on file"* —
|
|
67
|
-
Say so and wait; retrying does not create
|
|
65
|
+
- *"no developer approval is on file"* — the answer was not `Approve`, or
|
|
66
|
+
the hook is not installed. Say so and wait; retrying does not create it.
|
|
68
67
|
- *"the confirmation token does not match"* — the file or the database
|
|
69
68
|
changed since the preview. Go back to step 1, never "retry". If the fresh
|
|
70
69
|
preview's before-side differs from what you last saw, say so: someone may
|
|
@@ -85,8 +84,8 @@ confirms a number you already knew.
|
|
|
85
84
|
## Restores
|
|
86
85
|
|
|
87
86
|
`pythia journal restore <id>` is itself a write: same six steps, same gate,
|
|
88
|
-
approval included. Preview the reverse diff,
|
|
89
|
-
|
|
87
|
+
approval included. Preview the reverse diff, ask with the card the same way,
|
|
88
|
+
then confirm. Restoring an object that did not exist before
|
|
90
89
|
means DROP — policy refuses it under `structural: deny`, and that is correct.
|
|
91
90
|
|
|
92
91
|
## Batch mode
|
|
@@ -135,7 +134,8 @@ affected count and up to ten rows, and `--confirm` refuses if that set moved.
|
|
|
135
134
|
| "apply refused it; run-sql will take it" | The refusal is the product working. Relay it. |
|
|
136
135
|
| "I'll restore quietly to clean up my mistake" | Restores are writes. Same gate, same visibility. |
|
|
137
136
|
| "`$?` said 0 after I piped to tail" | That was tail's 0. Read pythia's own words, or its unpiped code. |
|
|
138
|
-
| "I'll run approve myself to unblock this" | The developer's
|
|
137
|
+
| "I'll run approve myself to unblock this" | The developer's act. Console approve refuses you; the hook mints only from *their* answer. Routing around it is what this gate exists to stop. |
|
|
138
|
+
| "I'll summarise the card so the question reads nicer" | The hook refuses a paraphrase — the developer must approve pythia's words, not yours. Paste the card verbatim. |
|
|
139
139
|
|
|
140
140
|
## When NOT to use this skill
|
|
141
141
|
|
|
@@ -240,6 +240,7 @@ def base_script(errors=(), invalid_after=None, db_source=OLD_SRC):
|
|
|
240
240
|
"from all_errors": ([("NAME", "TYPE", "SEQUENCE", "LINE", "POSITION",
|
|
241
241
|
"ATTRIBUTE", "TEXT")], list(errors)),
|
|
242
242
|
"from session_privs": ([("PRIVILEGE",)], []),
|
|
243
|
+
"current_schema": ([("CURRENT_SCHEMA",)], [("APP",)]),
|
|
243
244
|
# main-namespace occupants of the target name (type-conflict check)
|
|
244
245
|
"object_name = upper(:n)": ([("OBJECT_TYPE",)], [("PACKAGE BODY",)]),
|
|
245
246
|
}
|
|
@@ -1128,9 +1129,10 @@ def test_preview_prints_both_follow_up_lines():
|
|
|
1128
1129
|
pythia.run_apply(conn, "APP", apply_ns(td, file="f.sql"), NEW_FILE)
|
|
1129
1130
|
out = buf.getvalue()
|
|
1130
1131
|
tok = pythia.apply_token("PACKAGE BODY", "PKG_ORDER", NEW_FILE, OLD_SRC)
|
|
1131
|
-
assert f"approve {tok}" in out
|
|
1132
|
+
assert f"approve --card {tok}" in out # the chat door
|
|
1133
|
+
assert f"approve {tok}" in out # the console door
|
|
1132
1134
|
assert f"apply f.sql --confirm {tok}" in out
|
|
1133
|
-
assert "
|
|
1135
|
+
assert "in chat" in out and "terminal" in out
|
|
1134
1136
|
|
|
1135
1137
|
|
|
1136
1138
|
def test_journal_restore_needs_a_grant_too():
|
|
@@ -1338,6 +1340,156 @@ def test_approve_needs_no_database():
|
|
|
1338
1340
|
assert "approve" in pythia.COMMANDS
|
|
1339
1341
|
|
|
1340
1342
|
|
|
1343
|
+
# --- the chat door: approve --card / approve --hook ---------------------------
|
|
1344
|
+
|
|
1345
|
+
def _hook_payload(question, answer, tool="AskUserQuestion", session="sess-1"):
|
|
1346
|
+
q = {"question": question, "header": "pythia", "multiSelect": False,
|
|
1347
|
+
"options": [{"label": "Approve", "description": "mint"},
|
|
1348
|
+
{"label": "Reject", "description": "stop"}]}
|
|
1349
|
+
return {"hook_event_name": "PostToolUse", "session_id": session,
|
|
1350
|
+
"tool_name": tool, "tool_input": {"questions": [q]},
|
|
1351
|
+
"tool_response": {"questions": [q], "answers": {question: answer}}}
|
|
1352
|
+
|
|
1353
|
+
|
|
1354
|
+
def _run_hook(td, payload):
|
|
1355
|
+
"""A headless agent session with the hook payload on stdin — exactly how
|
|
1356
|
+
Claude Code invokes it. Returns stdout."""
|
|
1357
|
+
import contextlib
|
|
1358
|
+
import io
|
|
1359
|
+
buf = io.StringIO()
|
|
1360
|
+
with _NoTTY():
|
|
1361
|
+
sys.stdin = io.StringIO(json.dumps(payload) if not isinstance(payload, str)
|
|
1362
|
+
else payload)
|
|
1363
|
+
with contextlib.redirect_stdout(buf):
|
|
1364
|
+
pythia.cmd_approve(None, None, approve_ns(td, [], hook=True))
|
|
1365
|
+
return buf.getvalue()
|
|
1366
|
+
|
|
1367
|
+
|
|
1368
|
+
def _pending_preview(td, token="7f3a91"):
|
|
1369
|
+
pythia.write_journal_entry(
|
|
1370
|
+
td, "PACKAGE BODY", "PKG_ORDER", OLD_SRC, NEW_FILE,
|
|
1371
|
+
{"token": token, "connection": "DEV", "schema": "APP",
|
|
1372
|
+
"group": "plsql_source", "applied": False,
|
|
1373
|
+
"summary": "12 dependent objects, 11 currently VALID"})
|
|
1374
|
+
_, _, body = pythia.approval_card(td, token)
|
|
1375
|
+
return f"pythia approve {token}\n" + "\n".join(body)
|
|
1376
|
+
|
|
1377
|
+
|
|
1378
|
+
def test_hook_mints_the_grant_the_developer_approved_in_chat():
|
|
1379
|
+
"""The developer answered Approve to a question carrying pythia's own
|
|
1380
|
+
card: that is the console act, through the other door. The grant says
|
|
1381
|
+
which door, and which session."""
|
|
1382
|
+
with tempfile.TemporaryDirectory() as td:
|
|
1383
|
+
card = _pending_preview(td)
|
|
1384
|
+
out = _run_hook(td, _hook_payload(" Please approve:\n" + card, "Approve"))
|
|
1385
|
+
g = pythia.read_grant(td, "7f3a91")
|
|
1386
|
+
assert g and g["approver"] == "chat" and g["session"] == "sess-1"
|
|
1387
|
+
assert g["conn"] == "DEV" and g["used_at"] is None
|
|
1388
|
+
ctx = json.loads(out)["hookSpecificOutput"]
|
|
1389
|
+
assert ctx["hookEventName"] == "PostToolUse"
|
|
1390
|
+
assert "--confirm 7f3a91" in ctx["additionalContext"]
|
|
1391
|
+
|
|
1392
|
+
|
|
1393
|
+
def test_hook_refuses_a_question_that_is_not_pythias_card():
|
|
1394
|
+
"""An agent's paraphrase is not a preview. If the question did not carry
|
|
1395
|
+
the card verbatim, the human approved the agent's words — no grant, and
|
|
1396
|
+
the agent is told to ask again with the card."""
|
|
1397
|
+
with tempfile.TemporaryDirectory() as td:
|
|
1398
|
+
_pending_preview(td)
|
|
1399
|
+
out = _run_hook(td, _hook_payload(
|
|
1400
|
+
"pythia approve 7f3a91\nJust a tiny, totally safe change.", "Approve"))
|
|
1401
|
+
assert pythia.read_grant(td, "7f3a91") is None
|
|
1402
|
+
assert "approve --card 7f3a91" in json.loads(out)["hookSpecificOutput"]["additionalContext"]
|
|
1403
|
+
|
|
1404
|
+
|
|
1405
|
+
def test_hook_mints_nothing_on_reject_other_tools_or_garbage():
|
|
1406
|
+
with tempfile.TemporaryDirectory() as td:
|
|
1407
|
+
card = _pending_preview(td)
|
|
1408
|
+
out = _run_hook(td, _hook_payload(card, "Reject"))
|
|
1409
|
+
assert pythia.read_grant(td, "7f3a91") is None
|
|
1410
|
+
assert "did not approve" in out.lower()
|
|
1411
|
+
assert _run_hook(td, _hook_payload(card, "Approve", tool="Bash")) == ""
|
|
1412
|
+
assert _run_hook(td, "not json at all") == ""
|
|
1413
|
+
assert _run_hook(td, _hook_payload("no token here", "Approve")) == ""
|
|
1414
|
+
assert not pythia.grants_root(td).exists()
|
|
1415
|
+
# a token no preview carries cannot be approved blind, in chat either
|
|
1416
|
+
out = _run_hook(td, _hook_payload("pythia approve abc123", "Approve"))
|
|
1417
|
+
assert "no pending preview" in out.lower()
|
|
1418
|
+
assert pythia.read_grant(td, "abc123") is None
|
|
1419
|
+
|
|
1420
|
+
|
|
1421
|
+
def test_approve_card_needs_no_console_and_mints_nothing():
|
|
1422
|
+
"""--card is the agent's half: it prints what to ask with. Headless is
|
|
1423
|
+
fine, and nothing is minted by printing."""
|
|
1424
|
+
import contextlib
|
|
1425
|
+
import io
|
|
1426
|
+
with tempfile.TemporaryDirectory() as td, _NoTTY():
|
|
1427
|
+
_pending_preview(td)
|
|
1428
|
+
buf = io.StringIO()
|
|
1429
|
+
with contextlib.redirect_stdout(buf):
|
|
1430
|
+
pythia.cmd_approve(None, None, approve_ns(td, ["7f3a91"], card=True))
|
|
1431
|
+
out = buf.getvalue()
|
|
1432
|
+
assert "approve 7f3a91" in out and "PKG_ORDER" in out
|
|
1433
|
+
assert "12 dependent objects" in out and "DEV" in out
|
|
1434
|
+
assert "\x1b[" not in out # plain text: it goes into a question
|
|
1435
|
+
assert not pythia.grants_root(td).exists()
|
|
1436
|
+
buf = io.StringIO()
|
|
1437
|
+
with contextlib.redirect_stdout(buf):
|
|
1438
|
+
pythia.cmd_approve(None, None, approve_ns(td, ["7f3a91"], card=True,
|
|
1439
|
+
json=True))
|
|
1440
|
+
d = json.loads(buf.getvalue())
|
|
1441
|
+
assert d["minted"] is False and d["question"]["header"] == "pythia"
|
|
1442
|
+
assert [o["label"] for o in d["question"]["options"]] == ["Approve", "Reject"]
|
|
1443
|
+
assert d["card"] in d["question"]["question"]
|
|
1444
|
+
|
|
1445
|
+
|
|
1446
|
+
def test_approve_several_tokens_in_one_console_act():
|
|
1447
|
+
"""Four previews, one approve line — not four."""
|
|
1448
|
+
import contextlib
|
|
1449
|
+
import io
|
|
1450
|
+
with tempfile.TemporaryDirectory() as td:
|
|
1451
|
+
_pending_preview(td, "aa1111")
|
|
1452
|
+
_pending_preview(td, "bb2222")
|
|
1453
|
+
with contextlib.redirect_stdout(io.StringIO()):
|
|
1454
|
+
pythia.cmd_approve(None, None,
|
|
1455
|
+
approve_ns(td, ["aa1111", "bb2222"], console=True))
|
|
1456
|
+
assert pythia.read_grant(td, "aa1111")["approver"] == "console"
|
|
1457
|
+
assert pythia.read_grant(td, "bb2222")["approver"] == "console"
|
|
1458
|
+
|
|
1459
|
+
|
|
1460
|
+
def test_apply_reads_a_bom_file_as_the_statement_it_is():
|
|
1461
|
+
"""Windows editors prepend a UTF-8 BOM. Read as a stray byte it turned a
|
|
1462
|
+
plain CREATE OR REPLACE into 'cannot classify' — a refusal with the wrong
|
|
1463
|
+
reason, found live in 0.10.0's dogfood run."""
|
|
1464
|
+
import contextlib
|
|
1465
|
+
import io
|
|
1466
|
+
import pathlib
|
|
1467
|
+
with tempfile.TemporaryDirectory() as td:
|
|
1468
|
+
f = pathlib.Path(td) / "f.sql"
|
|
1469
|
+
import codecs
|
|
1470
|
+
f.write_bytes(codecs.BOM_UTF8 + NEW_FILE.encode("utf-8"))
|
|
1471
|
+
buf = io.StringIO()
|
|
1472
|
+
with contextlib.redirect_stdout(buf):
|
|
1473
|
+
pythia.cmd_apply(FakeConn(base_script()), "APP",
|
|
1474
|
+
apply_ns(td, file=str(f)))
|
|
1475
|
+
assert "approve --card" in buf.getvalue() # it previewed
|
|
1476
|
+
|
|
1477
|
+
|
|
1478
|
+
def test_apply_refuses_when_the_session_schema_is_not_the_configured_one():
|
|
1479
|
+
"""A proxy into another user, or a stale `schema` in connections.json:
|
|
1480
|
+
an unqualified CREATE would land in one schema while pythia snapshots
|
|
1481
|
+
and verifies another. Refuse before the snapshot, with the fix."""
|
|
1482
|
+
with tempfile.TemporaryDirectory() as td:
|
|
1483
|
+
script = base_script()
|
|
1484
|
+
script["current_schema"] = ([("CURRENT_SCHEMA",)], [("PYTHIA_DEV",)])
|
|
1485
|
+
conn = FakeConn(script)
|
|
1486
|
+
expect_exit(lambda: pythia.run_apply(conn, "APP", apply_ns(td, file="f.sql"),
|
|
1487
|
+
NEW_FILE),
|
|
1488
|
+
"PYTHIA_DEV", "configured for 'APP'", "connections.json")
|
|
1489
|
+
assert wrote_ddl(conn) == []
|
|
1490
|
+
assert not pythia.journal_root(td).exists() # refused before step 1
|
|
1491
|
+
|
|
1492
|
+
|
|
1341
1493
|
def main():
|
|
1342
1494
|
failed = 0
|
|
1343
1495
|
for name, fn in sorted(globals().items()):
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{pythia_plsql-0.9.0 → pythia_plsql-0.10.0}/scripts/pythia_plsql.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{pythia_plsql-0.9.0 → pythia_plsql-0.10.0}/skills/pythia-explore/reference/data-dictionary.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|