oathline-guard 0.1.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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Nexxt Nest Group Pty Ltd (ABN 52 689 862 922)
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,135 @@
1
+ Metadata-Version: 2.4
2
+ Name: oathline-guard
3
+ Version: 0.1.0
4
+ Summary: An MCP guard where an AI agent's yes means exactly what the person saw: reads pass, every other tool call waits for a coded yes in a second terminal, runs once, and is recorded in Oathline's hash-chained log.
5
+ Author: Nexxt Nest Group Pty Ltd
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/oathline/oathline-guard
8
+ Project-URL: Repository, https://github.com/oathline/oathline-guard
9
+ Project-URL: Issues, https://github.com/oathline/oathline-guard/issues
10
+ Project-URL: Changelog, https://github.com/oathline/oathline-guard/blob/main/CHANGELOG.md
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3 :: Only
13
+ Classifier: Programming Language :: Python :: 3.10
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Topic :: Security
18
+ Requires-Python: >=3.10
19
+ Description-Content-Type: text/markdown
20
+ License-File: LICENSE
21
+ Requires-Dist: oathline<0.3,>=0.2.0
22
+ Dynamic: license-file
23
+
24
+ # oathline-guard
25
+
26
+ **An AI agent's yes means exactly what the person saw.**
27
+
28
+ oathline-guard sits between your MCP client (Claude Desktop, Claude Code, Cursor) and a real stdio MCP server. Reads you list
29
+ pass straight through. Every other tool call waits: a person, in a second terminal, sees the exact call in the guard's own words
30
+ and types its four-character code. Then it runs once, with exactly those arguments, and [Oathline](https://github.com/oathline/oathline)'s
31
+ hash-chained log records what was shown, heard, confirmed and run.
32
+
33
+ In April 2026 a coding agent deleted a production database and its backups in nine seconds, with no approval step. This is the
34
+ same request, through the guard:
35
+
36
+ ```text
37
+ db / drop_database
38
+ name: prod
39
+ code: 44NF
40
+ Type the code to run it, or n to refuse: n
41
+ Refused and recorded (c-508e50ec). Nothing will run.
42
+ ```
43
+
44
+ The agent only ever gets "Not run ... waiting for a person's yes"; the code is never sent to it, so it cannot approve itself.
45
+
46
+ ## Quickstart (about 5 minutes, with the demo database)
47
+
48
+ **1. Install** (Python 3.10 or later):
49
+
50
+ ```
51
+ pip install oathline-guard
52
+ ```
53
+
54
+ **2. Prepare the demo:**
55
+
56
+ ```
57
+ oathline-guard demo
58
+ ```
59
+
60
+ It writes the read list (the tools that pass without a yes) to `~/oathline-guard-demo/reads.json`:
61
+
62
+ ```json
63
+ {"server": "db", "reads": ["list_databases", "list_tables"]}
64
+ ```
65
+
66
+ and prints the block for Claude Desktop with your machine's real paths (it runs everything through the Python you installed
67
+ with, so `PATH` and `python` vs `python3` do not matter). It approves nothing and runs nothing.
68
+
69
+ **3. Add the block to Claude Desktop.** Open `claude_desktop_config.json` (Settings > Developer > Edit Config; on macOS it is
70
+ in `~/Library/Application Support/Claude/`, on Windows in `%APPDATA%\Claude\`), paste the printed block, then restart Claude
71
+ Desktop. By hand, the block has this shape:
72
+
73
+ ```json
74
+ {
75
+ "mcpServers": {
76
+ "db": {
77
+ "command": "oathline-guard",
78
+ "args": ["run", "--server", "db", "--reads", "/path/to/reads.json", "--human", "you",
79
+ "--", "python", "-m", "oathline_guard.demo.fake_db", "--state", "/path/to/db.json"]
80
+ }
81
+ }
82
+ }
83
+ ```
84
+
85
+ `oathline_guard.demo.fake_db` is a tiny database server with two reads and two writes (`drop_database`, `delete_backups`) and
86
+ no protection of its own. Claude Desktop starts it with a short PATH: written by hand, if `oathline-guard` is not found, use
87
+ the full path that `pip` printed, and if `python` is not found (on macOS often only `python3` exists), write `python3` or the
88
+ interpreter's full path. The block `oathline-guard demo` prints avoids both.
89
+
90
+ **4. Open the approve window** in a second terminal and leave it open:
91
+
92
+ ```
93
+ oathline-guard approve --server db
94
+ ```
95
+
96
+ **5. Ask Claude:** "clean up the old database". The reads run. The drop waits, and the approve window shows it as above: type
97
+ `n` to refuse, or the code to let it run once. After a yes, the agent's next identical call runs it; a call with different
98
+ arguments is a new request that waits again.
99
+
100
+ To guard a real server, put its own command after `--` and list its read-only tools in `reads.json`. Tools not on the list
101
+ always wait, whatever the server's own annotations (`readOnlyHint`) say.
102
+
103
+ ## What it guarantees, and what it does not
104
+
105
+ - Reads on your list pass untouched; everything else is held, and the real server is not called until the yes.
106
+ - The words in the window are the guard's: the tool and every argument, never the server's description. A value cannot add a
107
+ line of its own; long values are shown whole and bound by hash, up to 16,000 characters of window text and Oathline Core's
108
+ 8 KB cap on a call's items. A call beyond either is refused before anything is shown (`text_too_large`, `items_too_large`):
109
+ split it.
110
+ - The yes runs the stored arguments exactly once. A wrong code decides nothing; a no is recorded, and a later yes is too late.
111
+ - The approve window reads only from a terminal: an agent's shell tool cannot pipe the code in.
112
+
113
+ Limits, said plainly:
114
+
115
+ - **A pseudo-terminal passes the terminal check.** It stops a pipe, not a determined local process with a pty.
116
+ - **An agent with a raw shell or a raw token goes around any MCP guard.** The guard governs the calls that pass through it;
117
+ switch off the client's own write tools for the session if the guard must be the only way to act.
118
+ - **No timeout on an upstream call yet**, and the agent retries after the yes (no waiting notification yet). On Windows, when
119
+ the upstream is started through a virtual environment's `python.exe` (a launcher that holds the pipes), an upstream that
120
+ closes its output but keeps running is noticed only when it exits.
121
+ - **stdio servers only**; one person per guarded server; the terminal is the only approval channel.
122
+ - **Secret arguments are not masked yet**: a secret in an argument is shown in the window.
123
+ - **Heard is not understood:** the record proves the words were shown and answered, not that they were read.
124
+
125
+ ## The record
126
+
127
+ Each guarded server keeps its own folder (default `~/.oathline-guard/<server>/`) with Oathline Core's audit chain and token
128
+ store. The chain for one call reads `REQUEST AUTHORIZED PROPOSED_BY_AGENT SHOWN HEARD CONFIRMED_BY_HUMAN EXECUTING EXECUTED`, or
129
+ ends at `REFUSED_BY_HUMAN`; `verify()` on the audit log checks every link.
130
+
131
+ ## Licence
132
+
133
+ MIT. Copyright (c) 2026 Nexxt Nest Group Pty Ltd. Built on [Oathline Core](https://pypi.org/project/oathline/).
134
+
135
+ <!-- mcp-name: io.github.oathline/oathline-guard -->
@@ -0,0 +1,112 @@
1
+ # oathline-guard
2
+
3
+ **An AI agent's yes means exactly what the person saw.**
4
+
5
+ oathline-guard sits between your MCP client (Claude Desktop, Claude Code, Cursor) and a real stdio MCP server. Reads you list
6
+ pass straight through. Every other tool call waits: a person, in a second terminal, sees the exact call in the guard's own words
7
+ and types its four-character code. Then it runs once, with exactly those arguments, and [Oathline](https://github.com/oathline/oathline)'s
8
+ hash-chained log records what was shown, heard, confirmed and run.
9
+
10
+ In April 2026 a coding agent deleted a production database and its backups in nine seconds, with no approval step. This is the
11
+ same request, through the guard:
12
+
13
+ ```text
14
+ db / drop_database
15
+ name: prod
16
+ code: 44NF
17
+ Type the code to run it, or n to refuse: n
18
+ Refused and recorded (c-508e50ec). Nothing will run.
19
+ ```
20
+
21
+ The agent only ever gets "Not run ... waiting for a person's yes"; the code is never sent to it, so it cannot approve itself.
22
+
23
+ ## Quickstart (about 5 minutes, with the demo database)
24
+
25
+ **1. Install** (Python 3.10 or later):
26
+
27
+ ```
28
+ pip install oathline-guard
29
+ ```
30
+
31
+ **2. Prepare the demo:**
32
+
33
+ ```
34
+ oathline-guard demo
35
+ ```
36
+
37
+ It writes the read list (the tools that pass without a yes) to `~/oathline-guard-demo/reads.json`:
38
+
39
+ ```json
40
+ {"server": "db", "reads": ["list_databases", "list_tables"]}
41
+ ```
42
+
43
+ and prints the block for Claude Desktop with your machine's real paths (it runs everything through the Python you installed
44
+ with, so `PATH` and `python` vs `python3` do not matter). It approves nothing and runs nothing.
45
+
46
+ **3. Add the block to Claude Desktop.** Open `claude_desktop_config.json` (Settings > Developer > Edit Config; on macOS it is
47
+ in `~/Library/Application Support/Claude/`, on Windows in `%APPDATA%\Claude\`), paste the printed block, then restart Claude
48
+ Desktop. By hand, the block has this shape:
49
+
50
+ ```json
51
+ {
52
+ "mcpServers": {
53
+ "db": {
54
+ "command": "oathline-guard",
55
+ "args": ["run", "--server", "db", "--reads", "/path/to/reads.json", "--human", "you",
56
+ "--", "python", "-m", "oathline_guard.demo.fake_db", "--state", "/path/to/db.json"]
57
+ }
58
+ }
59
+ }
60
+ ```
61
+
62
+ `oathline_guard.demo.fake_db` is a tiny database server with two reads and two writes (`drop_database`, `delete_backups`) and
63
+ no protection of its own. Claude Desktop starts it with a short PATH: written by hand, if `oathline-guard` is not found, use
64
+ the full path that `pip` printed, and if `python` is not found (on macOS often only `python3` exists), write `python3` or the
65
+ interpreter's full path. The block `oathline-guard demo` prints avoids both.
66
+
67
+ **4. Open the approve window** in a second terminal and leave it open:
68
+
69
+ ```
70
+ oathline-guard approve --server db
71
+ ```
72
+
73
+ **5. Ask Claude:** "clean up the old database". The reads run. The drop waits, and the approve window shows it as above: type
74
+ `n` to refuse, or the code to let it run once. After a yes, the agent's next identical call runs it; a call with different
75
+ arguments is a new request that waits again.
76
+
77
+ To guard a real server, put its own command after `--` and list its read-only tools in `reads.json`. Tools not on the list
78
+ always wait, whatever the server's own annotations (`readOnlyHint`) say.
79
+
80
+ ## What it guarantees, and what it does not
81
+
82
+ - Reads on your list pass untouched; everything else is held, and the real server is not called until the yes.
83
+ - The words in the window are the guard's: the tool and every argument, never the server's description. A value cannot add a
84
+ line of its own; long values are shown whole and bound by hash, up to 16,000 characters of window text and Oathline Core's
85
+ 8 KB cap on a call's items. A call beyond either is refused before anything is shown (`text_too_large`, `items_too_large`):
86
+ split it.
87
+ - The yes runs the stored arguments exactly once. A wrong code decides nothing; a no is recorded, and a later yes is too late.
88
+ - The approve window reads only from a terminal: an agent's shell tool cannot pipe the code in.
89
+
90
+ Limits, said plainly:
91
+
92
+ - **A pseudo-terminal passes the terminal check.** It stops a pipe, not a determined local process with a pty.
93
+ - **An agent with a raw shell or a raw token goes around any MCP guard.** The guard governs the calls that pass through it;
94
+ switch off the client's own write tools for the session if the guard must be the only way to act.
95
+ - **No timeout on an upstream call yet**, and the agent retries after the yes (no waiting notification yet). On Windows, when
96
+ the upstream is started through a virtual environment's `python.exe` (a launcher that holds the pipes), an upstream that
97
+ closes its output but keeps running is noticed only when it exits.
98
+ - **stdio servers only**; one person per guarded server; the terminal is the only approval channel.
99
+ - **Secret arguments are not masked yet**: a secret in an argument is shown in the window.
100
+ - **Heard is not understood:** the record proves the words were shown and answered, not that they were read.
101
+
102
+ ## The record
103
+
104
+ Each guarded server keeps its own folder (default `~/.oathline-guard/<server>/`) with Oathline Core's audit chain and token
105
+ store. The chain for one call reads `REQUEST AUTHORIZED PROPOSED_BY_AGENT SHOWN HEARD CONFIRMED_BY_HUMAN EXECUTING EXECUTED`, or
106
+ ends at `REFUSED_BY_HUMAN`; `verify()` on the audit log checks every link.
107
+
108
+ ## Licence
109
+
110
+ MIT. Copyright (c) 2026 Nexxt Nest Group Pty Ltd. Built on [Oathline Core](https://pypi.org/project/oathline/).
111
+
112
+ <!-- mcp-name: io.github.oathline/oathline-guard -->
@@ -0,0 +1,6 @@
1
+ """oathline-guard: an MCP guard on Oathline Core 0.2.0 where the yes means exactly what the person saw.
2
+
3
+ Reads pass; every other tool call is a heard proposal: shown on a channel the agent does not control, heard there, run once with the
4
+ stored arguments by the Guard process. Standard library + `oathline` (Core 0.2.0, from PyPI)."""
5
+
6
+ __version__ = "0.1.0"
@@ -0,0 +1,31 @@
1
+ """oathline-guard run ... | approve ... | demo ..."""
2
+ import sys
3
+
4
+ USAGE = ("usage: oathline-guard run --server NAME [--reads READS.json] [--home DIR] [--human NAME] [--shown-text] "
5
+ "-- <server command ...>\n"
6
+ " oathline-guard approve --server NAME [--home DIR] [--once]\n"
7
+ " oathline-guard demo [--dir DIR] [--human NAME] (prepares the quickstart; approves and runs nothing)\n"
8
+ " (or python -m oathline_guard ...)")
9
+
10
+
11
+ def main(argv=None) -> int:
12
+ argv = list(sys.argv[1:] if argv is None else argv)
13
+ if not argv or argv[0] in ("-h", "--help"):
14
+ print(USAGE)
15
+ return 0 if argv else 2
16
+ cmd, rest = argv[0], argv[1:]
17
+ if cmd == "run":
18
+ from .guard import run
19
+ return run(rest)
20
+ if cmd == "approve":
21
+ from .approve import main as approve_main
22
+ return approve_main(rest)
23
+ if cmd == "demo":
24
+ from .demo.setup import main as demo_main
25
+ return demo_main(rest)
26
+ print(f"oathline-guard: unknown command {cmd!r}", file=sys.stderr)
27
+ return 2
28
+
29
+
30
+ if __name__ == "__main__":
31
+ sys.exit(main())
@@ -0,0 +1,146 @@
1
+ """`oathline-guard approve`: the person's window.
2
+
3
+ python -m oathline_guard approve --server NAME [--home DIR] [--once]
4
+
5
+ For each call waiting for a yes it prints the Guard's own text (the tool, every argument in the Guard's words, long values whole
6
+ behind "| ", the code last), re-hashes the blocks and records SHOWN, then takes what the person types: the right code is the yes
7
+ ROW, `n` is a RECORDED no, a wrong code decides nothing and asks again. HEARD is recorded on the first answer typed, whatever it is
8
+ (a wrong code or `n` included): the person demonstrably had the text in front of them; nothing else is written on a wrong code.
9
+ It never confirms and never runs anything: the Guard process, which holds the server's pipe, runs the call on the agent's next
10
+ identical call. It reads only from a terminal (an agent's shell tool cannot type the code; a pseudo-terminal does satisfy the
11
+ check, which is this rule's known limit) and only for the person the Guard was started with.
12
+
13
+ Within one window: an expired call is never printed (its code included), "expired" is said once; a call shown and left unanswered is
14
+ not shown again until the window is restarted; a text the console cannot encode is not printed and not SHOWN ("cannot display").
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import argparse
19
+ import os
20
+ import sys
21
+ import time
22
+
23
+ from .record import Record, server_name_ok
24
+
25
+ PROMPT = "Type the code to run it, or n to refuse: "
26
+ TRIES = 3
27
+
28
+
29
+ class NotATerminal(Exception):
30
+ pass
31
+
32
+
33
+ def _stdin_is_terminal() -> bool:
34
+ try:
35
+ return bool(sys.stdin.isatty())
36
+ except Exception: # noqa: BLE001
37
+ return False
38
+
39
+
40
+ def _can_display(text: str, out) -> bool:
41
+ """The console can show these exact characters (otherwise it crashes, or shows '??' while SHOWN hashes the text)."""
42
+ try:
43
+ encoding = out.encoding or "utf-8"
44
+ except AttributeError: # a plain StringIO: str in, str out
45
+ encoding = "utf-8"
46
+ try:
47
+ text.encode(encoding, "strict")
48
+ return True
49
+ except (UnicodeEncodeError, LookupError):
50
+ return False
51
+
52
+
53
+ def window(rec: Record, *, ask, out, isatty=None, once: bool = False, poll: float = 1.0, max_rounds=None) -> int:
54
+ """Answer the waiting calls; with once=True those waiting now, then return; max_rounds bounds the loop (tests). -> how many
55
+ calls were answered. `ask(prompt, call) -> str` reads one line from the person; `out` is where the text is printed."""
56
+ if not (isatty if isatty is not None else _stdin_is_terminal)(): # resolved when called
57
+ raise NotATerminal("the code must be typed by a person at a terminal; stdin is not one")
58
+ human = rec.human
59
+ answered, rounds = 0, 0
60
+ seen: set = set() # shown in this window and left unanswered: not shown again
61
+ said_empty = False
62
+ while True:
63
+ rounds += 1
64
+ for call in rec.expire_due():
65
+ print(f"({call} expired before an answer; nothing decided, nothing will run)", file=out)
66
+ waiting = [row for row in rec.pending() if row["call"] not in seen]
67
+ if not waiting and once and answered == 0 and not said_empty:
68
+ print("Nothing is waiting for a yes.", file=out)
69
+ said_empty = True
70
+ for row in waiting:
71
+ call = row["call"]
72
+ seen.add(call)
73
+ text = rec.text(call)
74
+ if not _can_display(text, out):
75
+ print(f"({call}: this console cannot display the text; nothing shown or decided. Use a UTF-8 terminal.)", file=out)
76
+ continue
77
+ print("", file=out)
78
+ print(text, file=out)
79
+ shown = rec.show(call, text)
80
+ if not shown.get("ok"):
81
+ print(f"(not shown: {shown.get('error')}; nothing decided for {call})", file=out)
82
+ continue
83
+ heard = False
84
+ for _ in range(TRIES):
85
+ answer = str(ask(PROMPT, call) or "").strip()
86
+ if not answer:
87
+ continue
88
+ if not heard:
89
+ h = rec.hear(call)
90
+ if not h.get("ok"):
91
+ print(f"(not heard: {h.get('error')}; nothing decided for {call})", file=out)
92
+ break
93
+ heard = True
94
+ if answer.lower() in ("n", "no"):
95
+ rec.no(call, human)
96
+ print(f"Refused and recorded ({call}). Nothing will run.", file=out)
97
+ answered += 1
98
+ break
99
+ if rec.yes(call, answer, human):
100
+ print(f"Yes recorded ({call}). It runs once, on the agent's next identical call.", file=out)
101
+ answered += 1
102
+ break
103
+ print("Wrong code: nothing decided. Read the text again and type its code.", file=out)
104
+ else:
105
+ print(f"No answer for {call}; it stays waiting (restart the window to see it again).", file=out)
106
+ if once or (max_rounds is not None and rounds >= max_rounds):
107
+ return answered
108
+ time.sleep(poll)
109
+
110
+
111
+ def main(argv: list[str]) -> int:
112
+ if not _stdin_is_terminal(): # the terminal rule first, before anything is opened
113
+ print("oathline-guard approve: refused: the code must be typed by a person at a terminal; stdin is not one", file=sys.stderr)
114
+ return 2
115
+ ap = argparse.ArgumentParser(prog="oathline-guard approve")
116
+ ap.add_argument("--server", required=True)
117
+ ap.add_argument("--home", default=os.path.join(os.path.expanduser("~"), ".oathline-guard"))
118
+ ap.add_argument("--once", action="store_true", help="answer what is waiting now, then exit")
119
+ a = ap.parse_args(argv)
120
+ if not server_name_ok(a.server):
121
+ print(f"oathline-guard approve: --server {a.server!r} must be one plain name (letters, digits, '_', '.', '-')", file=sys.stderr)
122
+ return 2
123
+ folder = os.path.join(a.home, a.server)
124
+ if not os.path.isfile(os.path.join(folder, "guard.db")):
125
+ print(f"oathline-guard approve: no guard record for server {a.server!r} in {a.home} (start the guard with --human first)",
126
+ file=sys.stderr)
127
+ return 2
128
+ # the console's encoding is left as it is: forcing UTF-8 would make _can_display always pass and send UTF-8 bytes
129
+ # to a console that shows something else; a console that cannot encode the text is told so and nothing is SHOWN
130
+ rec = Record(folder, a.server)
131
+ try:
132
+ if not rec.human:
133
+ print("oathline-guard approve: the guard was started without --human: nobody can answer", file=sys.stderr)
134
+ return 2
135
+ try:
136
+ # the same terminal check main() made, looked up now (a default argument is bound when window() is defined)
137
+ window(rec, ask=lambda prompt, call: input(prompt), out=sys.stdout, once=a.once, isatty=lambda: _stdin_is_terminal())
138
+ except NotATerminal as exc:
139
+ print(f"oathline-guard approve: refused: {exc}", file=sys.stderr)
140
+ return 2
141
+ except (KeyboardInterrupt, EOFError):
142
+ print("", file=sys.stdout)
143
+ return 130
144
+ return 0
145
+ finally:
146
+ rec.close()
File without changes
@@ -0,0 +1,117 @@
1
+ """fake-db-mcp: the demo MCP server of the PocketOS quickstart. Its whole state is one JSON file:
2
+ databases, each with tables and backups. Two reads and two writes; no guard of its own (that is the point).
3
+
4
+ python -m oathline_guard.demo.fake_db --state PATH
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import argparse
9
+ import json
10
+ import os
11
+ import sys
12
+
13
+ from .. import stdio
14
+
15
+ FRESH = {"prod": {"tables": ["users", "orders", "invoices"], "backups": ["prod-2026-11-07", "prod-2026-11-08"]},
16
+ "staging": {"tables": ["users", "orders"], "backups": ["staging-2026-11-08"]}}
17
+
18
+
19
+ def _schema(props=None, required=()):
20
+ return {"type": "object", "properties": props or {}, "required": list(required), "additionalProperties": False}
21
+
22
+
23
+ _NAME = {"type": "string", "description": "a database name"}
24
+ TOOLS = [
25
+ {"name": "list_databases", "description": "List the databases.", "inputSchema": _schema(),
26
+ "annotations": {"readOnlyHint": True}},
27
+ {"name": "list_tables", "description": "List the tables of a database.", "inputSchema": _schema({"database": _NAME}, ["database"]),
28
+ "annotations": {"readOnlyHint": True}},
29
+ {"name": "drop_database", "description": "Tidy up an unused database.", # the description is the server's own words
30
+ "inputSchema": _schema({"name": _NAME}, ["name"]), "annotations": {"readOnlyHint": False, "destructiveHint": True}},
31
+ {"name": "delete_backups", "description": "Free space by removing old backups.",
32
+ "inputSchema": _schema({"name": _NAME}, ["name"]), "annotations": {"readOnlyHint": False, "destructiveHint": True}},
33
+ ]
34
+
35
+
36
+ class Db:
37
+ def __init__(self, path):
38
+ self.path = path
39
+ if not os.path.exists(path):
40
+ self.save(json.loads(json.dumps(FRESH)))
41
+
42
+ def load(self):
43
+ with open(self.path, encoding="utf-8") as fh:
44
+ return json.load(fh)
45
+
46
+ def save(self, state):
47
+ tmp = self.path + ".tmp"
48
+ with open(tmp, "w", encoding="utf-8") as fh:
49
+ json.dump(state, fh, indent=1)
50
+ os.replace(tmp, self.path)
51
+
52
+ def call(self, name, args):
53
+ s = self.load()
54
+ if name == "list_databases":
55
+ return json.dumps(sorted(s)), False
56
+ db = str(args.get("database") or args.get("name") or "")
57
+ if db not in s:
58
+ return f"no database {db!r}", True
59
+ if name == "list_tables":
60
+ return json.dumps(s[db]["tables"]), False
61
+ if name == "drop_database":
62
+ del s[db]
63
+ self.save(s)
64
+ return f"dropped {db}", False
65
+ if name == "delete_backups":
66
+ n = len(s[db]["backups"])
67
+ s[db]["backups"] = []
68
+ self.save(s)
69
+ return f"deleted {n} backups of {db}", False
70
+ return f"unknown tool {name}", True
71
+
72
+
73
+ def serve(db: Db, stdin=sys.stdin, stdout=sys.stdout) -> int:
74
+ names = {t["name"] for t in TOOLS}
75
+ while True:
76
+ msg = stdio.read(stdin)
77
+ if msg is None:
78
+ return 0
79
+ if msg == "<parse error>":
80
+ stdio.write(stdout, stdio.error(None, -32700, "parse error"))
81
+ continue
82
+ if not isinstance(msg, dict) or "id" not in msg:
83
+ continue
84
+ mid, method, params = msg["id"], msg.get("method"), msg.get("params") or {}
85
+ if method == "initialize":
86
+ stdio.write(stdout, stdio.result(mid, {"protocolVersion": stdio.agreed_version(params.get("protocolVersion")),
87
+ "capabilities": {"tools": {"listChanged": False}},
88
+ "serverInfo": {"name": "fake-db-mcp", "version": "0.1"}}))
89
+ elif method == "ping":
90
+ stdio.write(stdout, stdio.result(mid, {}))
91
+ elif method == "tools/list":
92
+ stdio.write(stdout, stdio.result(mid, {"tools": TOOLS}))
93
+ elif method == "tools/call":
94
+ name = params.get("name")
95
+ if name not in names:
96
+ stdio.write(stdout, stdio.error(mid, -32602, f"unknown tool: {name}"))
97
+ continue
98
+ text, is_error = db.call(name, params.get("arguments") or {})
99
+ stdio.write(stdout, stdio.result(mid, stdio.text_result(text, is_error=is_error)))
100
+ else:
101
+ stdio.write(stdout, stdio.error(mid, -32601, f"method not found: {method}"))
102
+
103
+
104
+ def main(argv=None) -> int:
105
+ ap = argparse.ArgumentParser(prog="fake-db-mcp")
106
+ ap.add_argument("--state", required=True)
107
+ a = ap.parse_args(argv)
108
+ try:
109
+ sys.stdout.reconfigure(encoding="utf-8", newline="\n")
110
+ sys.stdin.reconfigure(encoding="utf-8")
111
+ except Exception: # noqa: BLE001
112
+ pass
113
+ return serve(Db(a.state))
114
+
115
+
116
+ if __name__ == "__main__":
117
+ sys.exit(main())
@@ -0,0 +1,45 @@
1
+ """`oathline-guard demo`: prepare the quickstart on this machine, then stop.
2
+
3
+ oathline-guard demo [--dir DIR] [--human NAME]
4
+
5
+ It writes DIR/reads.json (the demo server's two reads) and prints the block for claude_desktop_config.json with this machine's
6
+ absolute paths: this Python interpreter runs the guard (`-m oathline_guard run ...`) and the demo database server, so the block
7
+ does not depend on PATH or on `python` vs `python3`. An existing reads.json is kept. It approves nothing and runs nothing.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import json
13
+ import os
14
+ import sys
15
+
16
+ READS = {"server": "db", "reads": ["list_databases", "list_tables"]}
17
+
18
+
19
+ def main(argv: list[str]) -> int:
20
+ ap = argparse.ArgumentParser(prog="oathline-guard demo")
21
+ ap.add_argument("--dir", default=os.path.join(os.path.expanduser("~"), "oathline-guard-demo"))
22
+ ap.add_argument("--human", default="you", help="the name of the person who approves (you)")
23
+ a = ap.parse_args(argv)
24
+ folder = os.path.abspath(a.dir)
25
+ os.makedirs(folder, exist_ok=True)
26
+ reads = os.path.join(folder, "reads.json")
27
+ if os.path.exists(reads):
28
+ note = f"kept the existing read list: {reads}"
29
+ else:
30
+ with open(reads, "w", encoding="utf-8") as fh:
31
+ json.dump(READS, fh)
32
+ fh.write("\n")
33
+ note = f"wrote the read list: {reads}"
34
+ py = os.path.abspath(sys.executable)
35
+ block = {"mcpServers": {"db": {"command": py, "args": [
36
+ "-m", "oathline_guard", "run", "--server", "db", "--reads", reads, "--human", a.human,
37
+ "--", py, "-m", "oathline_guard.demo.fake_db", "--state", os.path.join(folder, "db.json")]}}}
38
+ print(note)
39
+ print("\n1. Add this to claude_desktop_config.json (Claude Desktop: Settings > Developer > Edit Config), then restart Claude"
40
+ " Desktop:\n")
41
+ print(json.dumps(block, indent=2))
42
+ print("\n2. In a second terminal, open the approve window and leave it open:\n\n oathline-guard approve --server db\n\n"
43
+ f" (or, with nothing needed on PATH: {py} -m oathline_guard approve --server db)\n")
44
+ print('3. Ask Claude: "clean up the old database". The reads run; the drop waits for your code in the approve window.')
45
+ return 0