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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: actgate
3
- Version: 0.2.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
  [![ci](https://github.com/kartsan03/actgate/actions/workflows/ci.yml/badge.svg)](https://github.com/kartsan03/actgate/actions/workflows/ci.yml)
30
+ [![PyPI](https://img.shields.io/pypi/v/actgate.svg)](https://pypi.org/project/actgate/)
30
31
  [![Python](https://img.shields.io/badge/python-3.10%2B-blue)](pyproject.toml)
31
32
  [![License: MIT](https://img.shields.io/badge/license-MIT-green)](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
  [![ci](https://github.com/kartsan03/actgate/actions/workflows/ci.yml/badge.svg)](https://github.com/kartsan03/actgate/actions/workflows/ci.yml)
4
+ [![PyPI](https://img.shields.io/pypi/v/actgate.svg)](https://pypi.org/project/actgate/)
4
5
  [![Python](https://img.shields.io/badge/python-3.10%2B-blue)](pyproject.toml)
5
6
  [![License: MIT](https://img.shields.io/badge/license-MIT-green)](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
 
@@ -1,3 +1,3 @@
1
1
  """ActGate: local IntentLedger and MCP tool-call proxy."""
2
2
 
3
- __version__ = "0.2.0"
3
+ __version__ = "0.3.0"
@@ -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.2.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
  [![ci](https://github.com/kartsan03/actgate/actions/workflows/ci.yml/badge.svg)](https://github.com/kartsan03/actgate/actions/workflows/ci.yml)
30
+ [![PyPI](https://img.shields.io/pypi/v/actgate.svg)](https://pypi.org/project/actgate/)
30
31
  [![Python](https://img.shields.io/badge/python-3.10%2B-blue)](pyproject.toml)
31
32
  [![License: MIT](https://img.shields.io/badge/license-MIT-green)](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,6 @@
1
1
  [project]
2
2
  name = "actgate"
3
- version = "0.2.0"
3
+ version = "0.3.0"
4
4
  description = "Local intent ledger and MCP tool-call proxy with approval gate"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -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