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.
- oathline_guard-0.1.0/LICENSE +21 -0
- oathline_guard-0.1.0/PKG-INFO +135 -0
- oathline_guard-0.1.0/README.md +112 -0
- oathline_guard-0.1.0/oathline_guard/__init__.py +6 -0
- oathline_guard-0.1.0/oathline_guard/__main__.py +31 -0
- oathline_guard-0.1.0/oathline_guard/approve.py +146 -0
- oathline_guard-0.1.0/oathline_guard/demo/__init__.py +0 -0
- oathline_guard-0.1.0/oathline_guard/demo/fake_db.py +117 -0
- oathline_guard-0.1.0/oathline_guard/demo/setup.py +45 -0
- oathline_guard-0.1.0/oathline_guard/guard.py +410 -0
- oathline_guard-0.1.0/oathline_guard/record.py +433 -0
- oathline_guard-0.1.0/oathline_guard/registry.py +33 -0
- oathline_guard-0.1.0/oathline_guard/stdio.py +47 -0
- oathline_guard-0.1.0/oathline_guard.egg-info/PKG-INFO +135 -0
- oathline_guard-0.1.0/oathline_guard.egg-info/SOURCES.txt +19 -0
- oathline_guard-0.1.0/oathline_guard.egg-info/dependency_links.txt +1 -0
- oathline_guard-0.1.0/oathline_guard.egg-info/entry_points.txt +2 -0
- oathline_guard-0.1.0/oathline_guard.egg-info/requires.txt +1 -0
- oathline_guard-0.1.0/oathline_guard.egg-info/top_level.txt +1 -0
- oathline_guard-0.1.0/pyproject.toml +35 -0
- oathline_guard-0.1.0/setup.cfg +4 -0
|
@@ -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
|