actgate 0.2.0__tar.gz → 0.3.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.
- {actgate-0.2.0 → actgate-0.3.0}/PKG-INFO +25 -2
- {actgate-0.2.0 → actgate-0.3.0}/README.md +24 -1
- {actgate-0.2.0 → actgate-0.3.0}/actgate/__init__.py +1 -1
- {actgate-0.2.0 → actgate-0.3.0}/actgate/cli.py +80 -1
- {actgate-0.2.0 → actgate-0.3.0}/actgate.egg-info/PKG-INFO +25 -2
- {actgate-0.2.0 → actgate-0.3.0}/pyproject.toml +1 -1
- {actgate-0.2.0 → actgate-0.3.0}/tests/test_actgate.py +71 -0
- {actgate-0.2.0 → actgate-0.3.0}/LICENSE +0 -0
- {actgate-0.2.0 → actgate-0.3.0}/actgate/__main__.py +0 -0
- {actgate-0.2.0 → actgate-0.3.0}/actgate/core/__init__.py +0 -0
- {actgate-0.2.0 → actgate-0.3.0}/actgate/core/intent.py +0 -0
- {actgate-0.2.0 → actgate-0.3.0}/actgate/core/ledger.py +0 -0
- {actgate-0.2.0 → actgate-0.3.0}/actgate/core/mcp_proxy.py +0 -0
- {actgate-0.2.0 → actgate-0.3.0}/actgate/core/mcp_rpc.py +0 -0
- {actgate-0.2.0 → actgate-0.3.0}/actgate/core/verify.py +0 -0
- {actgate-0.2.0 → actgate-0.3.0}/actgate.egg-info/SOURCES.txt +0 -0
- {actgate-0.2.0 → actgate-0.3.0}/actgate.egg-info/dependency_links.txt +0 -0
- {actgate-0.2.0 → actgate-0.3.0}/actgate.egg-info/entry_points.txt +0 -0
- {actgate-0.2.0 → actgate-0.3.0}/actgate.egg-info/requires.txt +0 -0
- {actgate-0.2.0 → actgate-0.3.0}/actgate.egg-info/top_level.txt +0 -0
- {actgate-0.2.0 → actgate-0.3.0}/setup.cfg +0 -0
- {actgate-0.2.0 → actgate-0.3.0}/tests/test_mcp_proxy.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: actgate
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Local intent ledger and MCP tool-call proxy with approval gate
|
|
5
5
|
License-Expression: MIT
|
|
6
6
|
Project-URL: Repository, https://github.com/kartsan03/actgate
|
|
@@ -27,6 +27,7 @@ Dynamic: license-file
|
|
|
27
27
|
# ActGate
|
|
28
28
|
|
|
29
29
|
[](https://github.com/kartsan03/actgate/actions/workflows/ci.yml)
|
|
30
|
+
[](https://pypi.org/project/actgate/)
|
|
30
31
|
[](pyproject.toml)
|
|
31
32
|
[](LICENSE)
|
|
32
33
|
|
|
@@ -40,6 +41,12 @@ only after approve.
|
|
|
40
41
|
|
|
41
42
|
## Install
|
|
42
43
|
|
|
44
|
+
```
|
|
45
|
+
pip install actgate
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Dev:
|
|
49
|
+
|
|
43
50
|
```
|
|
44
51
|
pip install -e .[dev]
|
|
45
52
|
```
|
|
@@ -49,6 +56,7 @@ pip install -e .[dev]
|
|
|
49
56
|
```
|
|
50
57
|
actgate init
|
|
51
58
|
actgate propose --tool shell.exec --args '{"cmd":"ls"}' --blast-tags fs.read
|
|
59
|
+
actgate pending
|
|
52
60
|
actgate dry-run <intent_id>
|
|
53
61
|
actgate approve <intent_id>
|
|
54
62
|
actgate verify
|
|
@@ -64,6 +72,8 @@ actgate init
|
|
|
64
72
|
actgate mcp --upstream python -m some_mcp_server
|
|
65
73
|
```
|
|
66
74
|
|
|
75
|
+
Use the same `--root` (or the same working directory) for `init`, `approve`/`deny`, and `mcp`, so they share one ledger.
|
|
76
|
+
|
|
67
77
|
Flow:
|
|
68
78
|
|
|
69
79
|
1. Client `tools/list` is forwarded to upstream. Only `tools/call` is gated;
|
|
@@ -78,11 +88,24 @@ Flow:
|
|
|
78
88
|
Bare `verify` checks hash-chain integrity only. Set `ACTGATE_SEAL_KEY` for
|
|
79
89
|
optional HMAC seals, or pass `verify --require-seal`.
|
|
80
90
|
|
|
91
|
+
|
|
92
|
+
## Human loop (HITL)
|
|
93
|
+
|
|
94
|
+
Typical MCP approval loop:
|
|
95
|
+
|
|
96
|
+
1. Client hits `actgate mcp --upstream ...` and gets `ACTGATE_PENDING`.
|
|
97
|
+
2. Human: `actgate pending` (or `actgate pending --watch`) to see undecided proposes.
|
|
98
|
+
3. `actgate approve <intent_id>` or `actgate deny <intent_id>`.
|
|
99
|
+
4. Client retries the same `tools/call`; proxy executes once.
|
|
100
|
+
|
|
101
|
+
`pending` is the propose-without-decision queue. `--watch` polls (default 1s) and
|
|
102
|
+
prints newly pending intents as JSON until Ctrl-C (clean exit 0).
|
|
103
|
+
|
|
81
104
|
## Exit codes
|
|
82
105
|
|
|
83
106
|
| Code | Meaning |
|
|
84
107
|
|------|---------|
|
|
85
|
-
| 0 | ok (propose, approve, verify clean, show/list) |
|
|
108
|
+
| 0 | ok (propose, approve, verify clean, show/list/pending) |
|
|
86
109
|
| 1 | deny recorded, or verify found a broken chain / bad seal |
|
|
87
110
|
| 2 | setup error (missing ledger, bad path, invalid args) |
|
|
88
111
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# ActGate
|
|
2
2
|
|
|
3
3
|
[](https://github.com/kartsan03/actgate/actions/workflows/ci.yml)
|
|
4
|
+
[](https://pypi.org/project/actgate/)
|
|
4
5
|
[](pyproject.toml)
|
|
5
6
|
[](LICENSE)
|
|
6
7
|
|
|
@@ -14,6 +15,12 @@ only after approve.
|
|
|
14
15
|
|
|
15
16
|
## Install
|
|
16
17
|
|
|
18
|
+
```
|
|
19
|
+
pip install actgate
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Dev:
|
|
23
|
+
|
|
17
24
|
```
|
|
18
25
|
pip install -e .[dev]
|
|
19
26
|
```
|
|
@@ -23,6 +30,7 @@ pip install -e .[dev]
|
|
|
23
30
|
```
|
|
24
31
|
actgate init
|
|
25
32
|
actgate propose --tool shell.exec --args '{"cmd":"ls"}' --blast-tags fs.read
|
|
33
|
+
actgate pending
|
|
26
34
|
actgate dry-run <intent_id>
|
|
27
35
|
actgate approve <intent_id>
|
|
28
36
|
actgate verify
|
|
@@ -38,6 +46,8 @@ actgate init
|
|
|
38
46
|
actgate mcp --upstream python -m some_mcp_server
|
|
39
47
|
```
|
|
40
48
|
|
|
49
|
+
Use the same `--root` (or the same working directory) for `init`, `approve`/`deny`, and `mcp`, so they share one ledger.
|
|
50
|
+
|
|
41
51
|
Flow:
|
|
42
52
|
|
|
43
53
|
1. Client `tools/list` is forwarded to upstream. Only `tools/call` is gated;
|
|
@@ -52,11 +62,24 @@ Flow:
|
|
|
52
62
|
Bare `verify` checks hash-chain integrity only. Set `ACTGATE_SEAL_KEY` for
|
|
53
63
|
optional HMAC seals, or pass `verify --require-seal`.
|
|
54
64
|
|
|
65
|
+
|
|
66
|
+
## Human loop (HITL)
|
|
67
|
+
|
|
68
|
+
Typical MCP approval loop:
|
|
69
|
+
|
|
70
|
+
1. Client hits `actgate mcp --upstream ...` and gets `ACTGATE_PENDING`.
|
|
71
|
+
2. Human: `actgate pending` (or `actgate pending --watch`) to see undecided proposes.
|
|
72
|
+
3. `actgate approve <intent_id>` or `actgate deny <intent_id>`.
|
|
73
|
+
4. Client retries the same `tools/call`; proxy executes once.
|
|
74
|
+
|
|
75
|
+
`pending` is the propose-without-decision queue. `--watch` polls (default 1s) and
|
|
76
|
+
prints newly pending intents as JSON until Ctrl-C (clean exit 0).
|
|
77
|
+
|
|
55
78
|
## Exit codes
|
|
56
79
|
|
|
57
80
|
| Code | Meaning |
|
|
58
81
|
|------|---------|
|
|
59
|
-
| 0 | ok (propose, approve, verify clean, show/list) |
|
|
82
|
+
| 0 | ok (propose, approve, verify clean, show/list/pending) |
|
|
60
83
|
| 1 | deny recorded, or verify found a broken chain / bad seal |
|
|
61
84
|
| 2 | setup error (missing ledger, bad path, invalid args) |
|
|
62
85
|
|
|
@@ -4,9 +4,11 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import argparse
|
|
6
6
|
import json
|
|
7
|
+
import math
|
|
7
8
|
import sys
|
|
9
|
+
import time
|
|
8
10
|
from pathlib import Path
|
|
9
|
-
from typing import Any
|
|
11
|
+
from typing import Any, Callable
|
|
10
12
|
|
|
11
13
|
from actgate import __version__
|
|
12
14
|
from actgate.core.intent import Intent, IntentError, build_intent
|
|
@@ -205,6 +207,65 @@ def cmd_list(args: argparse.Namespace) -> int:
|
|
|
205
207
|
return 0
|
|
206
208
|
|
|
207
209
|
|
|
210
|
+
def collect_pending(ledger: Ledger) -> list[dict[str, Any]]:
|
|
211
|
+
"""Propose events with no approve/deny yet (HITL queue)."""
|
|
212
|
+
seen: dict[str, dict[str, Any]] = {}
|
|
213
|
+
for entry in ledger.read_entries():
|
|
214
|
+
iid = entry.get("intent_id")
|
|
215
|
+
if not iid:
|
|
216
|
+
continue
|
|
217
|
+
row = seen.setdefault(
|
|
218
|
+
iid,
|
|
219
|
+
{
|
|
220
|
+
"intent_id": iid,
|
|
221
|
+
"tool": None,
|
|
222
|
+
"seq": entry["seq"],
|
|
223
|
+
"ts": entry.get("ts"),
|
|
224
|
+
"intent": None,
|
|
225
|
+
},
|
|
226
|
+
)
|
|
227
|
+
if entry.get("action") == "propose":
|
|
228
|
+
intent = entry.get("intent") or {}
|
|
229
|
+
row["tool"] = intent.get("tool")
|
|
230
|
+
row["intent"] = intent
|
|
231
|
+
row["seq"] = entry["seq"]
|
|
232
|
+
row["ts"] = entry.get("ts")
|
|
233
|
+
elif entry.get("action") in ("approve", "deny"):
|
|
234
|
+
row["_decided"] = True
|
|
235
|
+
rows = [r for r in seen.values() if not r.pop("_decided", False) and r.get("intent") is not None]
|
|
236
|
+
return sorted(rows, key=lambda r: r["seq"])
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def cmd_pending(args: argparse.Namespace) -> int:
|
|
240
|
+
try:
|
|
241
|
+
ledger = _ledger_from_args(args)
|
|
242
|
+
if not ledger.exists():
|
|
243
|
+
raise LedgerError(f"ledger not found: {ledger.path}")
|
|
244
|
+
except LedgerError as exc:
|
|
245
|
+
_eprint(str(exc))
|
|
246
|
+
return 2
|
|
247
|
+
|
|
248
|
+
if not getattr(args, "watch", False):
|
|
249
|
+
print(json.dumps(collect_pending(ledger), indent=2))
|
|
250
|
+
return 0
|
|
251
|
+
|
|
252
|
+
interval = float(getattr(args, "interval", 1.0))
|
|
253
|
+
if not math.isfinite(interval) or interval <= 0:
|
|
254
|
+
_eprint("--interval must be a finite number > 0")
|
|
255
|
+
return 2
|
|
256
|
+
sleep_fn: Callable[[float], None] = getattr(args, "_sleep", time.sleep)
|
|
257
|
+
seen: set[str] = set()
|
|
258
|
+
try:
|
|
259
|
+
while True:
|
|
260
|
+
rows = collect_pending(ledger)
|
|
261
|
+
fresh = [r for r in rows if r["intent_id"] not in seen]
|
|
262
|
+
for r in rows:
|
|
263
|
+
seen.add(r["intent_id"])
|
|
264
|
+
if fresh:
|
|
265
|
+
print(json.dumps(fresh, indent=2), flush=True)
|
|
266
|
+
sleep_fn(interval)
|
|
267
|
+
except KeyboardInterrupt:
|
|
268
|
+
return 0
|
|
208
269
|
|
|
209
270
|
|
|
210
271
|
def cmd_mcp(args: argparse.Namespace) -> int:
|
|
@@ -220,6 +281,7 @@ def cmd_mcp(args: argparse.Namespace) -> int:
|
|
|
220
281
|
_eprint(str(exc))
|
|
221
282
|
return 2
|
|
222
283
|
|
|
284
|
+
|
|
223
285
|
def build_parser() -> argparse.ArgumentParser:
|
|
224
286
|
parser = argparse.ArgumentParser(
|
|
225
287
|
prog="actgate",
|
|
@@ -272,6 +334,23 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
272
334
|
p_list = sub.add_parser("list", help="list intents and status")
|
|
273
335
|
p_list.set_defaults(func=cmd_list)
|
|
274
336
|
|
|
337
|
+
p_pend = sub.add_parser(
|
|
338
|
+
"pending",
|
|
339
|
+
help="list undecided proposes (HITL queue); optional --watch",
|
|
340
|
+
)
|
|
341
|
+
p_pend.add_argument(
|
|
342
|
+
"--watch",
|
|
343
|
+
action="store_true",
|
|
344
|
+
help="poll and print newly pending intents until Ctrl-C",
|
|
345
|
+
)
|
|
346
|
+
p_pend.add_argument(
|
|
347
|
+
"--interval",
|
|
348
|
+
type=float,
|
|
349
|
+
default=1.0,
|
|
350
|
+
help="watch poll interval seconds (default: 1)",
|
|
351
|
+
)
|
|
352
|
+
p_pend.set_defaults(func=cmd_pending)
|
|
353
|
+
|
|
275
354
|
p_mcp = sub.add_parser(
|
|
276
355
|
"mcp",
|
|
277
356
|
help="stdio MCP proxy: gate tools/call until ledger approve",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: actgate
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Local intent ledger and MCP tool-call proxy with approval gate
|
|
5
5
|
License-Expression: MIT
|
|
6
6
|
Project-URL: Repository, https://github.com/kartsan03/actgate
|
|
@@ -27,6 +27,7 @@ Dynamic: license-file
|
|
|
27
27
|
# ActGate
|
|
28
28
|
|
|
29
29
|
[](https://github.com/kartsan03/actgate/actions/workflows/ci.yml)
|
|
30
|
+
[](https://pypi.org/project/actgate/)
|
|
30
31
|
[](pyproject.toml)
|
|
31
32
|
[](LICENSE)
|
|
32
33
|
|
|
@@ -40,6 +41,12 @@ only after approve.
|
|
|
40
41
|
|
|
41
42
|
## Install
|
|
42
43
|
|
|
44
|
+
```
|
|
45
|
+
pip install actgate
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Dev:
|
|
49
|
+
|
|
43
50
|
```
|
|
44
51
|
pip install -e .[dev]
|
|
45
52
|
```
|
|
@@ -49,6 +56,7 @@ pip install -e .[dev]
|
|
|
49
56
|
```
|
|
50
57
|
actgate init
|
|
51
58
|
actgate propose --tool shell.exec --args '{"cmd":"ls"}' --blast-tags fs.read
|
|
59
|
+
actgate pending
|
|
52
60
|
actgate dry-run <intent_id>
|
|
53
61
|
actgate approve <intent_id>
|
|
54
62
|
actgate verify
|
|
@@ -64,6 +72,8 @@ actgate init
|
|
|
64
72
|
actgate mcp --upstream python -m some_mcp_server
|
|
65
73
|
```
|
|
66
74
|
|
|
75
|
+
Use the same `--root` (or the same working directory) for `init`, `approve`/`deny`, and `mcp`, so they share one ledger.
|
|
76
|
+
|
|
67
77
|
Flow:
|
|
68
78
|
|
|
69
79
|
1. Client `tools/list` is forwarded to upstream. Only `tools/call` is gated;
|
|
@@ -78,11 +88,24 @@ Flow:
|
|
|
78
88
|
Bare `verify` checks hash-chain integrity only. Set `ACTGATE_SEAL_KEY` for
|
|
79
89
|
optional HMAC seals, or pass `verify --require-seal`.
|
|
80
90
|
|
|
91
|
+
|
|
92
|
+
## Human loop (HITL)
|
|
93
|
+
|
|
94
|
+
Typical MCP approval loop:
|
|
95
|
+
|
|
96
|
+
1. Client hits `actgate mcp --upstream ...` and gets `ACTGATE_PENDING`.
|
|
97
|
+
2. Human: `actgate pending` (or `actgate pending --watch`) to see undecided proposes.
|
|
98
|
+
3. `actgate approve <intent_id>` or `actgate deny <intent_id>`.
|
|
99
|
+
4. Client retries the same `tools/call`; proxy executes once.
|
|
100
|
+
|
|
101
|
+
`pending` is the propose-without-decision queue. `--watch` polls (default 1s) and
|
|
102
|
+
prints newly pending intents as JSON until Ctrl-C (clean exit 0).
|
|
103
|
+
|
|
81
104
|
## Exit codes
|
|
82
105
|
|
|
83
106
|
| Code | Meaning |
|
|
84
107
|
|------|---------|
|
|
85
|
-
| 0 | ok (propose, approve, verify clean, show/list) |
|
|
108
|
+
| 0 | ok (propose, approve, verify clean, show/list/pending) |
|
|
86
109
|
| 1 | deny recorded, or verify found a broken chain / bad seal |
|
|
87
110
|
| 2 | setup error (missing ledger, bad path, invalid args) |
|
|
88
111
|
|
|
@@ -149,3 +149,74 @@ def test_forged_consistent_chain_verifies_without_seal(root: Path) -> None:
|
|
|
149
149
|
assert main(["verify"]) == 0
|
|
150
150
|
assert verify_ledger(ledger).ok
|
|
151
151
|
assert main(["verify", "--require-seal"]) == 1
|
|
152
|
+
|
|
153
|
+
def test_pending_empty(root: Path, capsys: pytest.CaptureFixture[str]) -> None:
|
|
154
|
+
assert main(["pending"]) == 0
|
|
155
|
+
out = json.loads(capsys.readouterr().out)
|
|
156
|
+
assert out == []
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def test_pending_with_propose(root: Path, capsys: pytest.CaptureFixture[str]) -> None:
|
|
160
|
+
assert main(["propose", "--tool", "shell.exec", "--args", '{"cmd":"ls"}']) == 0
|
|
161
|
+
capsys.readouterr()
|
|
162
|
+
assert main(["pending"]) == 0
|
|
163
|
+
rows = json.loads(capsys.readouterr().out)
|
|
164
|
+
assert len(rows) == 1
|
|
165
|
+
assert rows[0]["tool"] == "shell.exec"
|
|
166
|
+
assert rows[0]["intent_id"]
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def test_pending_clears_after_approve(root: Path, capsys: pytest.CaptureFixture[str]) -> None:
|
|
170
|
+
assert main(["propose", "--tool", "t", "--args", "{}"]) == 0
|
|
171
|
+
iid = Ledger.open(root=root).read_entries()[0]["intent_id"]
|
|
172
|
+
capsys.readouterr()
|
|
173
|
+
assert main(["pending"]) == 0
|
|
174
|
+
assert len(json.loads(capsys.readouterr().out)) == 1
|
|
175
|
+
assert main(["approve", iid]) == 0
|
|
176
|
+
capsys.readouterr()
|
|
177
|
+
assert main(["pending"]) == 0
|
|
178
|
+
assert json.loads(capsys.readouterr().out) == []
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def test_pending_watch_deterministic(root: Path, capsys: pytest.CaptureFixture[str]) -> None:
|
|
182
|
+
from actgate.cli import build_parser, cmd_pending
|
|
183
|
+
|
|
184
|
+
assert main(["propose", "--tool", "watch.me", "--args", '{"n":1}']) == 0
|
|
185
|
+
capsys.readouterr()
|
|
186
|
+
n = {"i": 0}
|
|
187
|
+
|
|
188
|
+
def fake_sleep(_interval: float) -> None:
|
|
189
|
+
n["i"] += 1
|
|
190
|
+
if n["i"] == 1:
|
|
191
|
+
assert main(["propose", "--tool", "watch.two", "--args", '{"n":2}']) == 0
|
|
192
|
+
return
|
|
193
|
+
raise KeyboardInterrupt
|
|
194
|
+
|
|
195
|
+
args = build_parser().parse_args(["pending", "--watch", "--interval", "0.01"])
|
|
196
|
+
args._sleep = fake_sleep
|
|
197
|
+
assert cmd_pending(args) == 0
|
|
198
|
+
out = capsys.readouterr().out
|
|
199
|
+
# two flushed JSON arrays
|
|
200
|
+
assert "watch.me" in out
|
|
201
|
+
assert "watch.two" in out
|
|
202
|
+
assert n["i"] == 2
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def test_pending_clears_after_deny(root: Path, capsys: pytest.CaptureFixture[str]) -> None:
|
|
206
|
+
assert main(["propose", "--tool", "t", "--args", "{}"]) == 0
|
|
207
|
+
iid = Ledger.open(root=root).read_entries()[0]["intent_id"]
|
|
208
|
+
capsys.readouterr()
|
|
209
|
+
assert main(["pending"]) == 0
|
|
210
|
+
assert len(json.loads(capsys.readouterr().out)) == 1
|
|
211
|
+
assert main(["deny", iid]) == 1
|
|
212
|
+
capsys.readouterr()
|
|
213
|
+
assert main(["pending"]) == 0
|
|
214
|
+
assert json.loads(capsys.readouterr().out) == []
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def test_pending_watch_rejects_invalid_interval(root: Path) -> None:
|
|
218
|
+
assert main(["pending", "--watch", "--interval", "0"]) == 2
|
|
219
|
+
assert main(["pending", "--watch", "--interval", "-1"]) == 2
|
|
220
|
+
assert main(["pending", "--watch", "--interval", "nan"]) == 2
|
|
221
|
+
assert main(["pending", "--watch", "--interval", "inf"]) == 2
|
|
222
|
+
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|