incognito-sql 0.0.5a1__py3-none-any.whl

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.
Files changed (181) hide show
  1. incognito_sql/__init__.py +10 -0
  2. incognito_sql/__main__.py +5 -0
  3. incognito_sql/assess/__init__.py +6 -0
  4. incognito_sql/assess/command.py +47 -0
  5. incognito_sql/assess/model.py +87 -0
  6. incognito_sql/assess/report.py +66 -0
  7. incognito_sql/assess/run.py +146 -0
  8. incognito_sql/assess/truth.py +104 -0
  9. incognito_sql/audit_command.py +50 -0
  10. incognito_sql/check/__init__.py +1 -0
  11. incognito_sql/check/clients.py +99 -0
  12. incognito_sql/check/probes.py +106 -0
  13. incognito_sql/check/run.py +136 -0
  14. incognito_sql/cli.py +202 -0
  15. incognito_sql/connect/__init__.py +1 -0
  16. incognito_sql/connect/clients.py +143 -0
  17. incognito_sql/connect/release.py +25 -0
  18. incognito_sql/connect/run.py +172 -0
  19. incognito_sql/connect/write.py +122 -0
  20. incognito_sql/core/__init__.py +1 -0
  21. incognito_sql/core/audit.py +111 -0
  22. incognito_sql/core/audit_chain.py +149 -0
  23. incognito_sql/core/audit_verify.py +82 -0
  24. incognito_sql/core/budget.py +173 -0
  25. incognito_sql/core/config/__init__.py +28 -0
  26. incognito_sql/core/config/forms.py +36 -0
  27. incognito_sql/core/config/load.py +76 -0
  28. incognito_sql/core/config/model.py +223 -0
  29. incognito_sql/core/config/protection.py +147 -0
  30. incognito_sql/core/config/report.py +61 -0
  31. incognito_sql/core/config/row_rules.py +119 -0
  32. incognito_sql/core/db.py +137 -0
  33. incognito_sql/core/deadline.py +53 -0
  34. incognito_sql/core/describe.py +65 -0
  35. incognito_sql/core/display.py +128 -0
  36. incognito_sql/core/errors.py +44 -0
  37. incognito_sql/core/exec/__init__.py +1 -0
  38. incognito_sql/core/exec/run.py +88 -0
  39. incognito_sql/core/output/__init__.py +1 -0
  40. incognito_sql/core/output/detect.py +139 -0
  41. incognito_sql/core/output/mask.py +80 -0
  42. incognito_sql/core/output/secret.py +32 -0
  43. incognito_sql/core/output/shape.py +84 -0
  44. incognito_sql/core/output/suspects.py +49 -0
  45. incognito_sql/core/pipeline.py +194 -0
  46. incognito_sql/core/rate.py +45 -0
  47. incognito_sql/core/sql/__init__.py +1 -0
  48. incognito_sql/core/sql/aggregates.py +103 -0
  49. incognito_sql/core/sql/allowlist.py +107 -0
  50. incognito_sql/core/sql/allowlist_table.py +124 -0
  51. incognito_sql/core/sql/bind.py +120 -0
  52. incognito_sql/core/sql/bind_domain.py +105 -0
  53. incognito_sql/core/sql/bind_forms.py +94 -0
  54. incognito_sql/core/sql/bind_type.py +133 -0
  55. incognito_sql/core/sql/bind_values.py +52 -0
  56. incognito_sql/core/sql/binder.py +92 -0
  57. incognito_sql/core/sql/catalog.py +181 -0
  58. incognito_sql/core/sql/colref.py +29 -0
  59. incognito_sql/core/sql/constants.py +69 -0
  60. incognito_sql/core/sql/expand.py +93 -0
  61. incognito_sql/core/sql/families.py +61 -0
  62. incognito_sql/core/sql/flows.py +113 -0
  63. incognito_sql/core/sql/grouping.py +142 -0
  64. incognito_sql/core/sql/lineage.py +150 -0
  65. incognito_sql/core/sql/opaque.py +24 -0
  66. incognito_sql/core/sql/order_keys.py +80 -0
  67. incognito_sql/core/sql/origin.py +70 -0
  68. incognito_sql/core/sql/parse.py +81 -0
  69. incognito_sql/core/sql/policy.py +89 -0
  70. incognito_sql/core/sql/policy_places.py +78 -0
  71. incognito_sql/core/sql/policy_probes.py +94 -0
  72. incognito_sql/core/sql/policy_rules.py +149 -0
  73. incognito_sql/core/sql/policy_shape.py +121 -0
  74. incognito_sql/core/sql/probe_count.py +35 -0
  75. incognito_sql/core/sql/recheck.py +71 -0
  76. incognito_sql/core/sql/resolve.py +129 -0
  77. incognito_sql/core/sql/rewrite.py +153 -0
  78. incognito_sql/core/sql/row_mask.py +151 -0
  79. incognito_sql/core/sql/scan_fallible.py +170 -0
  80. incognito_sql/core/sql/scan_order.py +49 -0
  81. incognito_sql/core/sql/scan_spellings.py +55 -0
  82. incognito_sql/core/sql/shadow.py +105 -0
  83. incognito_sql/core/sql/shape.py +73 -0
  84. incognito_sql/core/sql/stars.py +27 -0
  85. incognito_sql/core/sql/strings.py +46 -0
  86. incognito_sql/core/sql/tiebreak.py +143 -0
  87. incognito_sql/core/sql/typefamily.py +85 -0
  88. incognito_sql/core/tokens/__init__.py +1 -0
  89. incognito_sql/core/tokens/canon.py +6 -0
  90. incognito_sql/core/tokens/crypto.py +80 -0
  91. incognito_sql/core/tokens/db.py +59 -0
  92. incognito_sql/core/tokens/format.py +13 -0
  93. incognito_sql/core/tokens/schema.sql +20 -0
  94. incognito_sql/core/tokens/store.py +150 -0
  95. incognito_sql/demo.py +91 -0
  96. incognito_sql/diff/__init__.py +1 -0
  97. incognito_sql/diff/changes.py +168 -0
  98. incognito_sql/diff/kinds.py +19 -0
  99. incognito_sql/diff/rules.py +66 -0
  100. incognito_sql/diff/run.py +49 -0
  101. incognito_sql/dpo/__init__.py +6 -0
  102. incognito_sql/dpo/command.py +81 -0
  103. incognito_sql/dpo/journal.py +95 -0
  104. incognito_sql/dpo/summary.py +77 -0
  105. incognito_sql/envfile.py +37 -0
  106. incognito_sql/init/__init__.py +1 -0
  107. incognito_sql/init/hints.py +35 -0
  108. incognito_sql/init/report.py +42 -0
  109. incognito_sql/init/run.py +142 -0
  110. incognito_sql/init/sampling.py +42 -0
  111. incognito_sql/init/scaffold.py +51 -0
  112. incognito_sql/levels/__init__.py +6 -0
  113. incognito_sql/levels/asker.py +120 -0
  114. incognito_sql/levels/chance.py +11 -0
  115. incognito_sql/levels/cuts.py +60 -0
  116. incognito_sql/levels/dialects.py +40 -0
  117. incognito_sql/levels/forms.py +77 -0
  118. incognito_sql/levels/fragment.py +101 -0
  119. incognito_sql/levels/infer.py +155 -0
  120. incognito_sql/levels/oracle.py +98 -0
  121. incognito_sql/levels/partial.py +68 -0
  122. incognito_sql/levels/series.py +146 -0
  123. incognito_sql/levels/target.py +78 -0
  124. incognito_sql/levels/textforms.py +61 -0
  125. incognito_sql/levels/verdict.py +40 -0
  126. incognito_sql/mcp/__init__.py +1 -0
  127. incognito_sql/mcp/http.py +48 -0
  128. incognito_sql/mcp/instructions.md +76 -0
  129. incognito_sql/mcp/refusals.py +188 -0
  130. incognito_sql/mcp/server.py +69 -0
  131. incognito_sql/mcp/token.py +19 -0
  132. incognito_sql/parser.py +33 -0
  133. incognito_sql/problems.py +14 -0
  134. incognito_sql/py.typed +0 -0
  135. incognito_sql/sdk/__init__.py +1 -0
  136. incognito_sql/serving.py +114 -0
  137. incognito_sql/setup/__init__.py +1 -0
  138. incognito_sql/setup/cases.py +16 -0
  139. incognito_sql/setup/closed.py +158 -0
  140. incognito_sql/setup/columns.py +76 -0
  141. incognito_sql/setup/commands.py +49 -0
  142. incognito_sql/setup/common.py +87 -0
  143. incognito_sql/setup/env.py +53 -0
  144. incognito_sql/setup/guide.py +88 -0
  145. incognito_sql/setup/keep.py +41 -0
  146. incognito_sql/setup/local.py +163 -0
  147. incognito_sql/setup/model.py +122 -0
  148. incognito_sql/setup/projects.py +101 -0
  149. incognito_sql/setup/secret.py +93 -0
  150. incognito_sql/setup/setkey.py +61 -0
  151. incognito_sql/setup/uservar.py +32 -0
  152. incognito_sql/setup/widen.py +25 -0
  153. incognito_sql/shell.py +30 -0
  154. incognito_sql/trial/__init__.py +1 -0
  155. incognito_sql/trial/run.py +111 -0
  156. incognito_sql/trial/verdict.py +104 -0
  157. incognito_sql/web/__init__.py +1 -0
  158. incognito_sql/web/app.py +92 -0
  159. incognito_sql/web/cards.py +109 -0
  160. incognito_sql/web/guard.py +75 -0
  161. incognito_sql/web/pages.py +75 -0
  162. incognito_sql/web/run.py +122 -0
  163. incognito_sql/web/session.py +107 -0
  164. incognito_sql/web/templates/index.html +2 -0
  165. incognito_sql/web/templates/layout.html +20 -0
  166. incognito_sql/web/templates/masked.html +6 -0
  167. incognito_sql/web/templates/person.html +4 -0
  168. incognito_sql/web/templates/value.html +3 -0
  169. incognito_sql/wiring.py +72 -0
  170. incognito_sql/wizard/__init__.py +1 -0
  171. incognito_sql/wizard/arrows.py +80 -0
  172. incognito_sql/wizard/columns.py +73 -0
  173. incognito_sql/wizard/prompts.py +111 -0
  174. incognito_sql/wizard/run.py +59 -0
  175. incognito_sql/wizard/walk.py +124 -0
  176. incognito_sql-0.0.5a1.dist-info/METADATA +799 -0
  177. incognito_sql-0.0.5a1.dist-info/RECORD +181 -0
  178. incognito_sql-0.0.5a1.dist-info/WHEEL +4 -0
  179. incognito_sql-0.0.5a1.dist-info/entry_points.txt +2 -0
  180. incognito_sql-0.0.5a1.dist-info/licenses/LICENSE +202 -0
  181. incognito_sql-0.0.5a1.dist-info/licenses/NOTICE +176 -0
@@ -0,0 +1,10 @@
1
+ """Pseudonymizing MCP server for LLM access to databases with personal data."""
2
+
3
+ import logging
4
+
5
+ __version__ = "0.0.0"
6
+
7
+ # sqlglot logs unparsable SQL verbatim (with its literals); keep it off stderr.
8
+ _sqlglot_log = logging.getLogger("sqlglot")
9
+ _sqlglot_log.addHandler(logging.NullHandler())
10
+ _sqlglot_log.propagate = False
@@ -0,0 +1,5 @@
1
+ """`python -m incognito_sql`: the same command line as `incognito-sql`."""
2
+
3
+ from incognito_sql.cli import main
4
+
5
+ raise SystemExit(main())
@@ -0,0 +1,6 @@
1
+ """`assess`: the disclosure levels of the operator's own database, under its profile.
2
+
3
+ The oracles of ``incognito_sql.levels`` ask a gateway opened on a copy of the operator's
4
+ declaration, as a server is asked; the truth they judge by is read from the database
5
+ directly. The report holds counts and column names, never a value.
6
+ """
@@ -0,0 +1,47 @@
1
+ """`incognito-sql assess`: its arguments, the run, the report and the exit code."""
2
+
3
+ import argparse
4
+ import sys
5
+ from pathlib import Path
6
+
7
+ from incognito_sql.assess.model import Source
8
+ from incognito_sql.assess.report import as_json, as_text, broken
9
+ from incognito_sql.assess.run import assess
10
+ from incognito_sql.core.config import Declaration
11
+
12
+ EXIT_BROKEN = 1
13
+ EXIT_FAILED = 2
14
+
15
+
16
+ def add_assess_parser(commands: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None:
17
+ """Add `assess` and its options to the command line."""
18
+ command = commands.add_parser(
19
+ "assess", help="judge the disclosure levels of this database under its profile"
20
+ )
21
+ command.add_argument("--config", type=Path, required=True, help="the service's declaration")
22
+ command.add_argument("--env-file", type=Path, help="KEY=VALUE secrets for unset variables")
23
+ command.add_argument("--targets", type=int, default=3, help="values probed per column")
24
+ command.add_argument(
25
+ "--capacity", action="store_true", help="also measure with the literal budget lifted"
26
+ )
27
+ command.add_argument("--json", action="store_true", help="print the report as JSON")
28
+ command.add_argument(
29
+ "--fail-on", choices=["breaks"], help="exit 1 when a column breaks its profile's claim"
30
+ )
31
+
32
+
33
+ def run_assess(args: argparse.Namespace, decl: Declaration, dsn: str, key: bytes) -> int:
34
+ """Assess, print the report on stdout, return the exit code.
35
+
36
+ A failure is named by its type alone: a database error quotes its query's
37
+ parameters, and the series' probes are made from the values.
38
+ """
39
+ try:
40
+ result = assess(
41
+ Source(args.config, decl, dsn, key), count=args.targets, capacity=args.capacity
42
+ )
43
+ except Exception as exc: # noqa: BLE001 -- any failure: never its message
44
+ sys.stderr.write(f"incognito-sql: assess failed: {type(exc).__name__}\n")
45
+ return EXIT_FAILED
46
+ sys.stdout.write((as_json(result) if args.json else as_text(result)) + "\n")
47
+ return EXIT_BROKEN if args.fail_on == "breaks" and broken(result) else 0
@@ -0,0 +1,87 @@
1
+ """What an assessment reads and what it found, column by column."""
2
+
3
+ from dataclasses import dataclass, field
4
+ from pathlib import Path
5
+
6
+ from incognito_sql.core.config import Declaration, Limits
7
+ from incognito_sql.levels.infer import Budgets, Inference
8
+ from incognito_sql.levels.verdict import Leaks, verdict
9
+
10
+
11
+ def budgets_of(limits: Limits) -> Budgets:
12
+ """Return the literal and probe budgets of ``limits`` and their window."""
13
+ return Budgets(limits.literal_budget, limits.probe_budget, limits.literal_window_s)
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class Source:
18
+ """The service's settings: its declaration file and what it was read into."""
19
+
20
+ config: Path
21
+ decl: Declaration
22
+ dsn: str
23
+ key: bytes
24
+
25
+
26
+ @dataclass
27
+ class ColumnLevels:
28
+ """What the oracles saw on one column: cuts that showed it, facts the series read."""
29
+
30
+ table: str
31
+ column: str
32
+ profile: str
33
+ opened: frozenset[str] # L3 channels the profile declares open
34
+ cuts: int = 0
35
+ whole: int = 0 # cuts that showed a value whole
36
+ parts: int = 0 # cuts that showed a part of one
37
+ measure: bool = False # the budgets lifted: values read whole are their capacity
38
+ budgets: Budgets = field(default_factory=lambda: budgets_of(Limits())) # the config's own
39
+ inference: Inference = field(default_factory=Inference)
40
+
41
+ @property
42
+ def name(self) -> str:
43
+ """Return ``table.column``."""
44
+ return f"{self.table}.{self.column}"
45
+
46
+ def verdict(self) -> str:
47
+ """Return whether the column keeps its profile's claim, and what it broke."""
48
+ leaks = Leaks(
49
+ shown=bool(self.whole),
50
+ read_whole=bool(self.inference.whole) and not self.measure,
51
+ parts=bool(self.parts),
52
+ inferred=frozenset(self.inference.open_channels()),
53
+ )
54
+ found = verdict(leaks, "L3", self.opened)
55
+ if found == "holds" and not any(self.inference.targets.values()):
56
+ return "holds cuts; no value to probe by a series"
57
+ return found
58
+
59
+
60
+ @dataclass
61
+ class HiddenColumn:
62
+ """A column the declaration does not name: every cut of it must be refused."""
63
+
64
+ table: str
65
+ column: str
66
+ cuts: int = 0
67
+ refused: int = 0
68
+
69
+ @property
70
+ def name(self) -> str:
71
+ """Return ``table.column``."""
72
+ return f"{self.table}.{self.column}"
73
+
74
+ def verdict(self) -> str:
75
+ """Return ``holds`` when every cut was refused."""
76
+ return (
77
+ "holds" if self.refused == self.cuts else f"breaks: {self.cuts - self.refused} answered"
78
+ )
79
+
80
+
81
+ @dataclass
82
+ class Assessment:
83
+ """Every identifier column under the default budget, and with it lifted if asked."""
84
+
85
+ columns: list[ColumnLevels]
86
+ capacity: list[ColumnLevels]
87
+ hidden: list[HiddenColumn] = field(default_factory=list)
@@ -0,0 +1,66 @@
1
+ """The assessment as the operator reads it: counts per column and level, never a value."""
2
+
3
+ import json
4
+
5
+ from incognito_sql.assess.model import Assessment, ColumnLevels, HiddenColumn
6
+ from incognito_sql.levels.infer import CHANNELS, HOURS_LEGEND, WHOLE
7
+
8
+
9
+ def _row(c: ColumnLevels) -> dict[str, object]:
10
+ series = c.inference
11
+ row: dict[str, object] = {
12
+ "column": c.name,
13
+ "profile": c.profile,
14
+ "L1 cuts": f"{c.whole}/{c.cuts}",
15
+ "L1 series": f"{series.whole}/{series.targets['prefix']}",
16
+ "L2 cuts": f"{c.parts}/{c.cuts}",
17
+ **{f"L3 {ch}": series.share(ch) for ch in CHANNELS},
18
+ "price whole": series.price(WHOLE),
19
+ }
20
+ if c.measure: # what the config's own budget would make the attacker wait
21
+ row["hours whole"] = series.hours(WHOLE, c.budgets)
22
+ row["verdict"] = c.verdict()
23
+ return row
24
+
25
+
26
+ def _hidden(c: HiddenColumn) -> dict[str, object]:
27
+ return {"column": c.name, "hidden: refused": f"{c.refused}/{c.cuts}", "verdict": c.verdict()}
28
+
29
+
30
+ def as_dict(result: Assessment) -> dict[str, list[dict[str, object]]]:
31
+ """Return the report as data: identifier columns, hidden ones, then the budget lifted."""
32
+ return {
33
+ "columns": [_row(c) for c in result.columns],
34
+ "hidden": [_hidden(c) for c in result.hidden],
35
+ "capacity": [_row(c) for c in result.capacity],
36
+ }
37
+
38
+
39
+ def as_json(result: Assessment) -> str:
40
+ """Return the report as JSON."""
41
+ return json.dumps(as_dict(result), ensure_ascii=False, indent=2)
42
+
43
+
44
+ def as_text(result: Assessment) -> str:
45
+ """Return the report as Markdown tables: verdicts first, capacity after."""
46
+ parts = []
47
+ for title, rows in as_dict(result).items():
48
+ if not rows:
49
+ continue
50
+ head = list(rows[0])
51
+ lines = [f"## {title}", "", "| " + " | ".join(head) + " |", "|---" * len(head) + "|"]
52
+ lines += ["| " + " | ".join(str(r[k]) for k in head) + " |" for r in rows]
53
+ if "hours whole" in head:
54
+ lines += ["", HOURS_LEGEND]
55
+ parts.append("\n".join(lines))
56
+ return "\n\n".join(parts) or "no identifier column of an entity table to assess"
57
+
58
+
59
+ def broken(result: Assessment) -> bool:
60
+ """Tell whether a column breaks its profile's claim, with the budget lifted too.
61
+
62
+ Lifted, a closed channel inferred is a hole of the policy the budget hid.
63
+ """
64
+ verdicts = [c.verdict() for c in [*result.columns, *result.capacity]]
65
+ verdicts += [c.verdict() for c in result.hidden]
66
+ return any(v.startswith("breaks") for v in verdicts)
@@ -0,0 +1,146 @@
1
+ """Run the oracles on each identifier column against a gateway of its own.
2
+
3
+ The gateway is opened on a copy of the declaration in a temporary directory: its own
4
+ literal budget, journal and tokens, so the assessment never spends the budget live
5
+ agents share nor raises its alarm. Cuts and series ask it as one connection, as one
6
+ agent session would; a second pass with the literal budget lifted measures what each
7
+ channel carries.
8
+ """
9
+
10
+ import json
11
+ import tempfile
12
+ import uuid
13
+ from collections.abc import Iterator
14
+ from contextlib import contextmanager
15
+ from pathlib import Path
16
+
17
+ import yaml
18
+ from sqlalchemy.engine import Engine
19
+
20
+ from incognito_sql.assess.model import Assessment, ColumnLevels, HiddenColumn, Source, budgets_of
21
+ from incognito_sql.assess.truth import (
22
+ DbTruth,
23
+ hidden_columns,
24
+ identifier_columns,
25
+ targets,
26
+ values,
27
+ )
28
+ from incognito_sql.core.config import load_declaration
29
+ from incognito_sql.core.config.model import column_operations, column_profile
30
+ from incognito_sql.core.config.protection import opens
31
+ from incognito_sql.core.db import make_engine
32
+ from incognito_sql.core.pipeline import Gateway
33
+ from incognito_sql.levels import asker as budgets
34
+ from incognito_sql.levels.asker import Ask, Asker, Reply
35
+ from incognito_sql.levels.cuts import cuts
36
+ from incognito_sql.levels.fragment import Fragments
37
+ from incognito_sql.levels.infer import CHANNELS
38
+ from incognito_sql.levels.oracle import Oracle
39
+ from incognito_sql.levels.partial import Visible, shown_in_part
40
+ from incognito_sql.levels.series import run_series
41
+ from incognito_sql.wiring import open_gateway
42
+
43
+ VALUES_CAP = 100_000 # distinct values of a column looked for in the answers
44
+ _LIFTED = 1_000_000
45
+
46
+
47
+ def assess(source: Source, *, count: int, capacity: bool) -> Assessment:
48
+ """Assess every identifier column of an entity table on ``count`` targets each."""
49
+ decl = source.decl
50
+ engine = make_engine(
51
+ source.dsn, decl.dialect, statement_timeout_ms=decl.limits.statement_timeout_ms
52
+ )
53
+ try:
54
+ hidden = [HiddenColumn(t, c) for t, c in hidden_columns(engine, decl)]
55
+ with _opened(source, lifted=False) as (gateway, connection):
56
+ for closed in hidden:
57
+ for _, sql in cuts(closed.table, closed.column, decl.dialect):
58
+ closed.cuts += 1
59
+ closed.refused += "error" in gateway.query(sql, connection)
60
+ columns = list(identifier_columns(decl))
61
+ held = [_column(source, engine, c, count, lifted=False) for c in columns]
62
+ lifted = (
63
+ [_column(source, engine, c, count, lifted=True) for c in columns] if capacity else []
64
+ )
65
+ finally:
66
+ engine.dispose()
67
+ return Assessment(held, lifted, hidden)
68
+
69
+
70
+ @contextmanager
71
+ def _opened(source: Source, *, lifted: bool) -> Iterator[tuple[Gateway, str]]:
72
+ """Open a gateway on a copy of the config: its own budget, audit log and tokens."""
73
+ with tempfile.TemporaryDirectory(prefix="incognito-assess-") as tmp:
74
+ copy = _copy(source.config, Path(tmp), lifted=lifted)
75
+ gateway = open_gateway(copy, load_declaration(copy), source.dsn, source.key)
76
+ try:
77
+ yield gateway, uuid.uuid4().hex
78
+ finally:
79
+ gateway.close()
80
+
81
+
82
+ def _column(
83
+ source: Source, engine: Engine, column: tuple[str, str, str], count: int, *, lifted: bool
84
+ ) -> ColumnLevels:
85
+ """Assess one column against a gateway of its own: no column spends another's budget."""
86
+ decl = source.decl
87
+ table, name, key = column
88
+ profile = column_profile(decl, table, name)
89
+ leaks = opens(column_operations(decl, table, name))
90
+ opened = frozenset(str(leak).removeprefix("L3 ") for leak in leaks)
91
+ own = budgets_of(decl.limits)
92
+ levels = ColumnLevels(table, name, profile, opened, measure=lifted, budgets=own)
93
+ tool, series = (
94
+ (budgets.CAPACITY_TOOL, budgets.CAPACITY_SERIES)
95
+ if lifted
96
+ else (budgets.TOOL_BUDGET, budgets.SERIES_BUDGET)
97
+ )
98
+ with _opened(source, lifted=lifted) as (gateway, connection):
99
+ if not lifted:
100
+ _cut(gateway, connection, values(engine, table, name, VALUES_CAP), levels, decl.dialect)
101
+ asker = Asker(_asking(gateway, connection), tool, series, decl.dialect)
102
+ for target in targets(engine, table, name, key, count):
103
+ for channel in CHANNELS:
104
+ levels.inference.add(run_series(asker, DbTruth(engine), target, channel))
105
+ levels.inference.settle()
106
+ return levels
107
+
108
+
109
+ def _cut(
110
+ gateway: Gateway, connection: str, hidden: list[object], levels: ColumnLevels, dialect: str
111
+ ) -> None:
112
+ oracle, fragments = Oracle(hidden), Fragments(hidden)
113
+ for _, sql in cuts(levels.table, levels.column, dialect):
114
+ answer = gateway.query(sql, connection)
115
+ levels.cuts += 1
116
+ if "error" in answer:
117
+ continue
118
+ text = json.dumps(answer, ensure_ascii=False, default=str)
119
+ whole = oracle.found(text)
120
+ levels.whole += bool(whole)
121
+ levels.parts += bool(shown_in_part(fragments.found(text), whole, (Visible([sql]),)))
122
+
123
+
124
+ def _asking(gateway: Gateway, connection: str) -> Ask:
125
+ def ask(sql: str) -> Reply:
126
+ answer, spend = gateway.ask(sql, connection)
127
+ spent = (spend.literals, spend.probes)
128
+ if "error" in answer:
129
+ return Reply(answered=False, spend=spent)
130
+ rows = answer.get("rows")
131
+ text = json.dumps(answer, default=str)
132
+ rows = rows if isinstance(rows, list) else None
133
+ return Reply(answered=True, rows=rows, text=text, spend=spent)
134
+
135
+ return ask
136
+
137
+
138
+ def _copy(config: Path, into: Path, *, lifted: bool) -> Path:
139
+ data = yaml.safe_load(config.read_text(encoding="utf-8"))
140
+ limits = data.setdefault("limits", {})
141
+ limits["query_rate"] = _LIFTED # a brake on a search, not a bound on what it learns
142
+ if lifted: # both: a budget left in place would cut the series and its cost short
143
+ limits["literal_budget"] = limits["probe_budget"] = _LIFTED
144
+ out = into / "assess.yaml"
145
+ out.write_text(yaml.safe_dump(data, sort_keys=False, allow_unicode=True), encoding="utf-8")
146
+ return out
@@ -0,0 +1,104 @@
1
+ """The database read directly: targets of the series, values to look for, and truth.
2
+
3
+ Read by the service's DSN through a read-only engine, never through the protection.
4
+ Queries are built with SQLAlchemy, which writes them in the database's dialect.
5
+ """
6
+
7
+ from collections.abc import Iterator
8
+
9
+ import sqlalchemy as sa
10
+ from sqlalchemy.engine import Engine
11
+
12
+ from incognito_sql.core.config import ColumnClass, Declaration
13
+ from incognito_sql.core.sql.catalog import reflect
14
+ from incognito_sql.levels.cuts import ROWS
15
+ from incognito_sql.levels.target import MIN_TARGET, Target
16
+
17
+ _POOL = 1000 # rows a target is chosen among
18
+
19
+
20
+ def _table(name: str, *columns: str) -> sa.TableClause:
21
+ return sa.table(name, *(sa.column(c) for c in columns))
22
+
23
+
24
+ class DbTruth:
25
+ """The truth of the series: the database compares and finds values itself."""
26
+
27
+ def __init__(self, engine: Engine) -> None:
28
+ """Read through ``engine``."""
29
+ self._engine = engine
30
+
31
+ def below(self, target: Target, probe: object) -> bool:
32
+ """Tell whether the target's value sorts below ``probe`` in the database."""
33
+ t = _table(target.table, target.key, target.column)
34
+ query = (
35
+ sa.select(sa.func.count())
36
+ .select_from(t)
37
+ .where(t.c[target.key] == target.row, t.c[target.column] < probe)
38
+ )
39
+ with self._engine.connect() as con:
40
+ return bool(con.execute(query).scalar())
41
+
42
+ def holds(self, target: Target, value: object) -> bool:
43
+ """Tell whether a row of the target's column holds ``value``."""
44
+ t = _table(target.table, target.column)
45
+ query = sa.select(sa.literal(1)).select_from(t).where(t.c[target.column] == value).limit(1)
46
+ with self._engine.connect() as con:
47
+ return con.execute(query).first() is not None
48
+
49
+
50
+ def identifier_columns(decl: Declaration) -> Iterator[tuple[str, str, str]]:
51
+ """Yield (table, column, key) of every identifier column of an entity table."""
52
+ keys = {e.table: e.pk for e in decl.entities}
53
+ for table in sorted(decl.columns):
54
+ for column, cls in sorted(decl.columns[table].items()):
55
+ if cls is ColumnClass.IDENTIFIER and table in keys:
56
+ yield table, column, keys[table]
57
+
58
+
59
+ def hidden_columns(engine: Engine, decl: Declaration) -> list[tuple[str, str]]:
60
+ """Return (table, column) of every database column the declaration does not name.
61
+
62
+ The tables and views are those the server reflects, named as it resolves them.
63
+ """
64
+ out = []
65
+ for table, columns in sorted(reflect(engine, decl.dialect).columns.items()):
66
+ named = decl.columns.get(table.lower(), {})
67
+ out += [(table, c) for c in sorted(columns) if c.lower() not in named]
68
+ return out
69
+
70
+
71
+ def targets(engine: Engine, table: str, column: str, key: str, count: int) -> list[Target]:
72
+ """Return up to ``count`` targets of one column, rows spread over its first ones."""
73
+ t = _table(table, key, column)
74
+ query = sa.select(t.c[key], t.c[column]).where(t.c[column].is_not(None)).order_by(t.c[key])
75
+ with engine.connect() as con:
76
+ rows = con.execute(query.limit(_POOL)).all()
77
+ pool = [
78
+ Target(table, key, row, column, value)
79
+ for row, value in rows
80
+ if isinstance(value, str | int | float) and len(str(value)) >= MIN_TARGET
81
+ ]
82
+ step = max(1, len(pool) // max(count, 1))
83
+ return pool[::step][:count]
84
+
85
+
86
+ def values(engine: Engine, table: str, column: str, cap: int) -> list[object]:
87
+ """Return what is looked for in a column's answers.
88
+
89
+ Up to ``cap`` distinct values, and those a cut can show whatever the sample holds:
90
+ the first rows and the extremes.
91
+ """
92
+ t = _table(table, column)
93
+ present = t.c[column].is_not(None)
94
+ queries = [
95
+ sa.select(t.c[column]).where(present).distinct().limit(cap),
96
+ sa.select(t.c[column]).where(present).limit(ROWS),
97
+ sa.select(sa.func.min(t.c[column])).where(present),
98
+ sa.select(sa.func.max(t.c[column])).where(present),
99
+ ]
100
+ found: dict[object, None] = {}
101
+ with engine.connect() as con:
102
+ for query in queries:
103
+ found.update((v, None) for (v,) in con.execute(query) if v is not None)
104
+ return list(found)
@@ -0,0 +1,50 @@
1
+ """`incognito-sql audit verify`: check the audit log's chain with the service's key."""
2
+
3
+ import argparse
4
+ import os
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ from incognito_sql.core.audit_chain import Anchor
9
+ from incognito_sql.core.audit_verify import verify
10
+ from incognito_sql.core.tokens.crypto import Cipher, load_key
11
+ from incognito_sql.envfile import EnvFileError, load_env_file
12
+ from incognito_sql.wiring import audit_path, store_path
13
+
14
+ EXIT_BROKEN, EXIT_UNREADABLE = 1, 2
15
+
16
+
17
+ def add_audit_parser(commands: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None:
18
+ """Add `audit verify` to the command line."""
19
+ audit = commands.add_parser("audit", help="the audit log")
20
+ actions = audit.add_subparsers(dest="audit_command", required=True)
21
+ check = actions.add_parser("verify", help="check that no line was edited, removed or added")
22
+ check.add_argument("--config", type=Path, required=True, help="the service's declaration")
23
+ check.add_argument("--env-file", type=Path, help="KEY=VALUE secrets for unset variables")
24
+
25
+
26
+ def run_audit(args: argparse.Namespace) -> int:
27
+ """Check the chain; print what was checked and the first break; return the exit code."""
28
+ log, store = audit_path(args.config), store_path(args.config)
29
+ try:
30
+ if args.env_file is not None:
31
+ load_env_file(args.env_file, os.environ)
32
+ cipher = Cipher(load_key())
33
+ anchor = Anchor(store) if store.exists() else None
34
+ try:
35
+ verdict = verify(log, cipher, anchor.get() if anchor else None)
36
+ finally:
37
+ if anchor is not None:
38
+ anchor.close()
39
+ except (OSError, EnvFileError, RuntimeError, ValueError) as exc:
40
+ sys.stderr.write(f"incognito-sql: audit verify: {type(exc).__name__}\n")
41
+ return EXIT_UNREADABLE
42
+ sys.stdout.write(
43
+ f"lines: {verdict.lines}; before the chain: {verdict.before_chain}; "
44
+ f"checked: {verdict.checked}; anchor: {verdict.anchor}\n"
45
+ )
46
+ if verdict.holds:
47
+ sys.stdout.write("the chain holds\n")
48
+ return 0
49
+ sys.stdout.write(f"broken: {verdict.broken}\n")
50
+ return EXIT_BROKEN
@@ -0,0 +1 @@
1
+ """`incognito-sql check`: try, as the agent's user, what an agent could do around the server."""
@@ -0,0 +1,99 @@
1
+ """MCP client settings this user can read, and the entries that start incognito-sql itself."""
2
+
3
+ import json
4
+ import re
5
+ import tomllib
6
+ from collections.abc import Iterator, Mapping
7
+ from pathlib import Path
8
+
9
+ from incognito_sql.check.probes import Finding
10
+
11
+ _NAMES = ("incognito-sql", "incognito_sql")
12
+ # A value that only names a variable the client fills in: `${env:X}`, `{env:X}`, `${X}`.
13
+ _REFERENCE = re.compile(r"\$?\{(?:env:)?[A-Za-z_][A-Za-z0-9_]*\}")
14
+
15
+
16
+ def client_settings(home: Path, cwd: Path, environ: Mapping[str, str]) -> list[tuple[str, Path]]:
17
+ """Return the MCP settings files of common clients, each with a fixed label.
18
+
19
+ A finding names the file by its label: a path built from a variable such as
20
+ CODEX_HOME may hold anything pasted there, and is not repeated.
21
+ """
22
+ codex = environ.get("CODEX_HOME")
23
+ files = [
24
+ ("Claude Code project file (.mcp.json)", cwd / ".mcp.json"),
25
+ ("Cursor project file (.cursor/mcp.json)", cwd / ".cursor" / "mcp.json"),
26
+ ("OpenCode project file (opencode.json)", cwd / "opencode.json"),
27
+ ("Claude Code user file (~/.claude.json)", home / ".claude.json"),
28
+ ("Cursor user file (~/.cursor/mcp.json)", home / ".cursor" / "mcp.json"),
29
+ (
30
+ "OpenCode user file (~/.config/opencode/opencode.json)",
31
+ home / ".config" / "opencode" / "opencode.json",
32
+ ),
33
+ ("Codex user file (~/.codex/config.toml)", home / ".codex" / "config.toml"),
34
+ (
35
+ "Claude Desktop file (macOS)",
36
+ home / "Library" / "Application Support" / "Claude" / "claude_desktop_config.json",
37
+ ),
38
+ ("Claude Desktop file (Linux)", home / ".config" / "Claude" / "claude_desktop_config.json"),
39
+ ]
40
+ if codex:
41
+ files.append(("Codex file ($CODEX_HOME/config.toml)", Path(codex) / "config.toml"))
42
+ if "APPDATA" in environ:
43
+ path = Path(environ["APPDATA"]) / "Claude" / "claude_desktop_config.json"
44
+ files.append(("Claude Desktop file (Windows)", path))
45
+ return files
46
+
47
+
48
+ def _servers(tree: object) -> Iterator[Mapping[str, object]]:
49
+ """Yield every server entry at any depth.
50
+
51
+ The keys are `mcpServers` (Claude Code nests them per project; Cursor),
52
+ `mcp_servers` (Codex) and `mcp` (OpenCode).
53
+ """
54
+ if isinstance(tree, dict):
55
+ for key in ("mcpServers", "mcp_servers", "mcp"):
56
+ servers = tree.get(key)
57
+ if isinstance(servers, dict):
58
+ yield from (s for s in servers.values() if isinstance(s, dict))
59
+ for value in tree.values():
60
+ yield from _servers(value)
61
+ elif isinstance(tree, list):
62
+ for value in tree:
63
+ yield from _servers(value)
64
+
65
+
66
+ def _starts_us(server: Mapping[str, object]) -> bool:
67
+ command = server.get("command", "")
68
+ words = list(command) if isinstance(command, list) else [command] # OpenCode: a list
69
+ args = server.get("args", [])
70
+ if isinstance(args, list):
71
+ words.extend(args)
72
+ # The script name, or the package run as a module (`python -m incognito_sql`).
73
+ return any(name in str(word) for word in words for name in _NAMES)
74
+
75
+
76
+ def _parsed(path: Path) -> object:
77
+ text = path.read_text(encoding="utf-8-sig")
78
+ return tomllib.loads(text) if path.suffix == ".toml" else json.loads(text)
79
+
80
+
81
+ def client_findings(files: list[tuple[str, Path]]) -> list[Finding]:
82
+ """Flag each client entry that runs incognito-sql as this user, with secrets beside it."""
83
+ found: list[Finding] = []
84
+ for label, path in files:
85
+ try:
86
+ servers = list(_servers(_parsed(path)))
87
+ except (OSError, ValueError, RecursionError): # unreadable, not JSON or TOML, too deep
88
+ continue
89
+ for server in servers:
90
+ if not _starts_us(server):
91
+ continue
92
+ env = server.get("env", server.get("environment")) # OpenCode: `environment`
93
+ what = f"{label} starts incognito-sql as this user"
94
+ if isinstance(env, dict) and any(
95
+ not _REFERENCE.fullmatch(str(value)) for value in env.values()
96
+ ):
97
+ what += ", with its secrets in the file"
98
+ found.append(Finding(what, hole=True, fix="run it as a service and connect over HTTP"))
99
+ return found