actgate 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.
actgate-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Tymur Kartsan
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.
actgate-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,113 @@
1
+ Metadata-Version: 2.4
2
+ Name: actgate
3
+ Version: 0.1.0
4
+ Summary: Local intent ledger and approval gate for tool actions
5
+ License-Expression: MIT
6
+ Project-URL: Repository, https://github.com/kartsan03/actgate
7
+ Project-URL: Issues, https://github.com/kartsan03/actgate/issues
8
+ Project-URL: Changelog, https://github.com/kartsan03/actgate/blob/main/CHANGELOG.md
9
+ Keywords: intent,ledger,approval,agent,cli,security,audit
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Operating System :: OS Independent
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Topic :: Security
19
+ Classifier: Topic :: Software Development :: Quality Assurance
20
+ Requires-Python: >=3.10
21
+ Description-Content-Type: text/markdown
22
+ License-File: LICENSE
23
+ Provides-Extra: dev
24
+ Requires-Dist: pytest>=8; extra == "dev"
25
+ Dynamic: license-file
26
+
27
+ # ActGate
28
+
29
+ [![ci](https://github.com/kartsan03/actgate/actions/workflows/ci.yml/badge.svg)](https://github.com/kartsan03/actgate/actions/workflows/ci.yml)
30
+ [![Python](https://img.shields.io/badge/python-3.10%2B-blue)](pyproject.toml)
31
+ [![License: MIT](https://img.shields.io/badge/license-MIT-green)](LICENSE)
32
+
33
+ Local IntentLedger: propose a tool action, record approve or deny in an
34
+ append-only hash-chained ledger, verify the chain before you trust it.
35
+
36
+ This is not an MCP proxy yet. This is not a SaaS. Everything runs offline
37
+ against files on disk.
38
+
39
+ dry-run and approve record decisions only; they do not execute tools.
40
+
41
+ ## Install
42
+
43
+ ```
44
+ pip install -e .[dev]
45
+ ```
46
+
47
+ ## Quickstart
48
+
49
+ ```
50
+ actgate init
51
+ actgate propose --tool shell.exec --args '{"cmd":"ls"}' --blast-tags fs.read
52
+ actgate dry-run <intent_id>
53
+ actgate approve <intent_id>
54
+ actgate verify
55
+ actgate list
56
+ ```
57
+
58
+ Deny path:
59
+
60
+ ```
61
+ actgate deny <intent_id> --reason "too broad"
62
+ # exits 1
63
+ ```
64
+
65
+ ## Exit codes
66
+
67
+ | Code | Meaning |
68
+ |------|---------|
69
+ | 0 | ok (propose, approve, verify clean, show/list) |
70
+ | 1 | deny recorded, or verify found a broken chain / bad seal |
71
+ | 2 | setup error (missing ledger, bad path, invalid args) |
72
+
73
+ ## Intent shape
74
+
75
+ ```json
76
+ {
77
+ "tool": "shell.exec",
78
+ "args": {"cmd": "ls"},
79
+ "args_hash": null,
80
+ "blast_tags": ["fs.read"],
81
+ "requested_mode": "execute",
82
+ "created_at": "2026-09-06T00:00:00+00:00"
83
+ }
84
+ ```
85
+
86
+ Provide either `args` or `args_hash` (sha256 of canonical JSON args). Optional
87
+ `blast_tags` and `requested_mode`.
88
+
89
+ ## Ledger
90
+
91
+ `.actgate/ledger.jsonl` is append-only. Each line has `prev_hash` / `entry_hash`
92
+ (sha256). Bare `verify` checks chain integrity only: a rewritten but
93
+ internally consistent chain still passes. It is not a signature check unless
94
+ you opt in.
95
+
96
+ Optional authenticity: set `ACTGATE_SEAL_KEY` when writing so entries get an
97
+ HMAC seal. Then `verify` (with the key set) requires matching seals, or pass
98
+ `verify --require-seal` to fail when seals are missing.
99
+
100
+ Path escapes outside the ledger root are rejected (exit 2).
101
+
102
+ ## What this is not
103
+
104
+ - Not an MCP proxy (yet)
105
+ - Not a hosted approval product
106
+ - No network calls in the core path
107
+
108
+ ## Development
109
+
110
+ ```
111
+ pip install -e .[dev]
112
+ pytest
113
+ ```
@@ -0,0 +1,87 @@
1
+ # ActGate
2
+
3
+ [![ci](https://github.com/kartsan03/actgate/actions/workflows/ci.yml/badge.svg)](https://github.com/kartsan03/actgate/actions/workflows/ci.yml)
4
+ [![Python](https://img.shields.io/badge/python-3.10%2B-blue)](pyproject.toml)
5
+ [![License: MIT](https://img.shields.io/badge/license-MIT-green)](LICENSE)
6
+
7
+ Local IntentLedger: propose a tool action, record approve or deny in an
8
+ append-only hash-chained ledger, verify the chain before you trust it.
9
+
10
+ This is not an MCP proxy yet. This is not a SaaS. Everything runs offline
11
+ against files on disk.
12
+
13
+ dry-run and approve record decisions only; they do not execute tools.
14
+
15
+ ## Install
16
+
17
+ ```
18
+ pip install -e .[dev]
19
+ ```
20
+
21
+ ## Quickstart
22
+
23
+ ```
24
+ actgate init
25
+ actgate propose --tool shell.exec --args '{"cmd":"ls"}' --blast-tags fs.read
26
+ actgate dry-run <intent_id>
27
+ actgate approve <intent_id>
28
+ actgate verify
29
+ actgate list
30
+ ```
31
+
32
+ Deny path:
33
+
34
+ ```
35
+ actgate deny <intent_id> --reason "too broad"
36
+ # exits 1
37
+ ```
38
+
39
+ ## Exit codes
40
+
41
+ | Code | Meaning |
42
+ |------|---------|
43
+ | 0 | ok (propose, approve, verify clean, show/list) |
44
+ | 1 | deny recorded, or verify found a broken chain / bad seal |
45
+ | 2 | setup error (missing ledger, bad path, invalid args) |
46
+
47
+ ## Intent shape
48
+
49
+ ```json
50
+ {
51
+ "tool": "shell.exec",
52
+ "args": {"cmd": "ls"},
53
+ "args_hash": null,
54
+ "blast_tags": ["fs.read"],
55
+ "requested_mode": "execute",
56
+ "created_at": "2026-09-06T00:00:00+00:00"
57
+ }
58
+ ```
59
+
60
+ Provide either `args` or `args_hash` (sha256 of canonical JSON args). Optional
61
+ `blast_tags` and `requested_mode`.
62
+
63
+ ## Ledger
64
+
65
+ `.actgate/ledger.jsonl` is append-only. Each line has `prev_hash` / `entry_hash`
66
+ (sha256). Bare `verify` checks chain integrity only: a rewritten but
67
+ internally consistent chain still passes. It is not a signature check unless
68
+ you opt in.
69
+
70
+ Optional authenticity: set `ACTGATE_SEAL_KEY` when writing so entries get an
71
+ HMAC seal. Then `verify` (with the key set) requires matching seals, or pass
72
+ `verify --require-seal` to fail when seals are missing.
73
+
74
+ Path escapes outside the ledger root are rejected (exit 2).
75
+
76
+ ## What this is not
77
+
78
+ - Not an MCP proxy (yet)
79
+ - Not a hosted approval product
80
+ - No network calls in the core path
81
+
82
+ ## Development
83
+
84
+ ```
85
+ pip install -e .[dev]
86
+ pytest
87
+ ```
@@ -0,0 +1,3 @@
1
+ """ActGate: local IntentLedger for tool-action propose / approve / deny."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,3 @@
1
+ from actgate.cli import main
2
+
3
+ raise SystemExit(main())
@@ -0,0 +1,268 @@
1
+ """ActGate CLI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import sys
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from actgate import __version__
12
+ from actgate.core.intent import Intent, IntentError, build_intent
13
+ from actgate.core.ledger import Ledger, LedgerError, resolve_ledger_path
14
+ from actgate.core.verify import verify_ledger
15
+
16
+
17
+ def _eprint(msg: str) -> None:
18
+ print(msg, file=sys.stderr)
19
+
20
+
21
+ def _ledger_from_args(args: argparse.Namespace) -> Ledger:
22
+ root = Path(args.root).resolve() if getattr(args, "root", None) else Path.cwd()
23
+ path = getattr(args, "ledger", None)
24
+ return Ledger.open(root=root, path=path)
25
+
26
+
27
+ def cmd_init(args: argparse.Namespace) -> int:
28
+ target = Path(args.directory).resolve() if args.directory else Path.cwd()
29
+ ledger = Ledger.open(root=target)
30
+ ledger.ensure()
31
+ print(f"initialized {ledger.path}")
32
+ return 0
33
+
34
+
35
+ def _parse_args_json(raw: str | None) -> dict[str, Any] | None:
36
+ if raw is None:
37
+ return None
38
+ try:
39
+ data = json.loads(raw)
40
+ except json.JSONDecodeError as exc:
41
+ raise IntentError(f"invalid --args JSON: {exc}") from exc
42
+ if not isinstance(data, dict):
43
+ raise IntentError("--args must be a JSON object")
44
+ return data
45
+
46
+
47
+ def cmd_propose(args: argparse.Namespace) -> int:
48
+ try:
49
+ intent = build_intent(
50
+ tool=args.tool,
51
+ args=_parse_args_json(args.args),
52
+ args_hash=args.args_hash,
53
+ blast_tags=[t for t in (args.blast_tags or "").split(",") if t],
54
+ requested_mode=args.mode,
55
+ )
56
+ ledger = _ledger_from_args(args)
57
+ if not ledger.exists():
58
+ ledger.ensure()
59
+ entry = ledger.append("propose", intent=intent)
60
+ except (IntentError, LedgerError) as exc:
61
+ _eprint(str(exc))
62
+ return 2
63
+ print(json.dumps({"intent_id": intent.id, "entry_hash": entry["entry_hash"]}, indent=2))
64
+ return 0
65
+
66
+
67
+ def cmd_dry_run(args: argparse.Namespace) -> int:
68
+ try:
69
+ ledger = _ledger_from_args(args)
70
+ if not ledger.exists():
71
+ raise LedgerError(f"ledger not found: {ledger.path}")
72
+ proposal = ledger.get_proposal(args.intent_id)
73
+ if proposal is None:
74
+ raise LedgerError(f"no propose event for intent_id={args.intent_id}")
75
+ decision = ledger.latest_decision(args.intent_id)
76
+ except LedgerError as exc:
77
+ _eprint(str(exc))
78
+ return 2
79
+ out = {
80
+ "intent_id": args.intent_id,
81
+ "intent": proposal.get("intent"),
82
+ "decision": None if decision is None else decision.get("action"),
83
+ "would_execute": decision is not None and decision.get("action") == "approve",
84
+ }
85
+ print(json.dumps(out, indent=2))
86
+ return 0
87
+
88
+
89
+ def cmd_approve(args: argparse.Namespace) -> int:
90
+ try:
91
+ ledger = _ledger_from_args(args)
92
+ if not ledger.exists():
93
+ raise LedgerError(f"ledger not found: {ledger.path}")
94
+ proposal = ledger.get_proposal(args.intent_id)
95
+ if proposal is None:
96
+ raise LedgerError(f"no propose event for intent_id={args.intent_id}")
97
+ intent = Intent.from_dict(proposal["intent"])
98
+ existing = ledger.latest_decision(args.intent_id)
99
+ if existing is not None:
100
+ raise LedgerError(
101
+ f"intent already decided: {existing.get('action')} ({existing.get('entry_hash')})"
102
+ )
103
+ entry = ledger.append(
104
+ "approve",
105
+ intent=intent,
106
+ decision="approved",
107
+ reason=args.reason,
108
+ )
109
+ except (IntentError, LedgerError) as exc:
110
+ _eprint(str(exc))
111
+ return 2
112
+ print(json.dumps({"intent_id": intent.id, "action": "approve", "entry_hash": entry["entry_hash"]}, indent=2))
113
+ return 0
114
+
115
+
116
+ def cmd_deny(args: argparse.Namespace) -> int:
117
+ try:
118
+ ledger = _ledger_from_args(args)
119
+ if not ledger.exists():
120
+ raise LedgerError(f"ledger not found: {ledger.path}")
121
+ proposal = ledger.get_proposal(args.intent_id)
122
+ if proposal is None:
123
+ raise LedgerError(f"no propose event for intent_id={args.intent_id}")
124
+ intent = Intent.from_dict(proposal["intent"])
125
+ existing = ledger.latest_decision(args.intent_id)
126
+ if existing is not None:
127
+ raise LedgerError(
128
+ f"intent already decided: {existing.get('action')} ({existing.get('entry_hash')})"
129
+ )
130
+ entry = ledger.append(
131
+ "deny",
132
+ intent=intent,
133
+ decision="denied",
134
+ reason=args.reason or "denied",
135
+ )
136
+ except (IntentError, LedgerError) as exc:
137
+ _eprint(str(exc))
138
+ return 2
139
+ print(json.dumps({"intent_id": intent.id, "action": "deny", "entry_hash": entry["entry_hash"]}, indent=2))
140
+ return 1
141
+
142
+
143
+ def cmd_verify(args: argparse.Namespace) -> int:
144
+ try:
145
+ ledger = _ledger_from_args(args)
146
+ if not ledger.exists():
147
+ raise LedgerError(f"ledger not found: {ledger.path}")
148
+ result = verify_ledger(
149
+ ledger, require_seal=True if getattr(args, "require_seal", False) else None
150
+ )
151
+ except LedgerError as exc:
152
+ _eprint(str(exc))
153
+ return 2
154
+ if result.ok:
155
+ print(json.dumps({"ok": True, "entries": result.entries}, indent=2))
156
+ return 0
157
+ _eprint("verify failed:")
158
+ for err in result.errors:
159
+ _eprint(f" - {err}")
160
+ return 1
161
+
162
+
163
+ def cmd_show(args: argparse.Namespace) -> int:
164
+ try:
165
+ ledger = _ledger_from_args(args)
166
+ if not ledger.exists():
167
+ raise LedgerError(f"ledger not found: {ledger.path}")
168
+ events = ledger.find_intent_events(args.intent_id)
169
+ if not events:
170
+ raise LedgerError(f"unknown intent_id={args.intent_id}")
171
+ except LedgerError as exc:
172
+ _eprint(str(exc))
173
+ return 2
174
+ print(json.dumps({"intent_id": args.intent_id, "events": events}, indent=2))
175
+ return 0
176
+
177
+
178
+ def cmd_list(args: argparse.Namespace) -> int:
179
+ try:
180
+ ledger = _ledger_from_args(args)
181
+ if not ledger.exists():
182
+ raise LedgerError(f"ledger not found: {ledger.path}")
183
+ seen: dict[str, dict[str, Any]] = {}
184
+ for entry in ledger.read_entries():
185
+ iid = entry.get("intent_id")
186
+ if not iid:
187
+ continue
188
+ row = seen.setdefault(
189
+ iid,
190
+ {"intent_id": iid, "tool": None, "status": "proposed", "seq": entry["seq"]},
191
+ )
192
+ if entry.get("action") == "propose":
193
+ intent = entry.get("intent") or {}
194
+ row["tool"] = intent.get("tool")
195
+ row["seq"] = entry["seq"]
196
+ elif entry.get("action") in ("approve", "deny"):
197
+ row["status"] = "approved" if entry["action"] == "approve" else "denied"
198
+ except LedgerError as exc:
199
+ _eprint(str(exc))
200
+ return 2
201
+ rows = sorted(seen.values(), key=lambda r: r["seq"])
202
+ print(json.dumps(rows, indent=2))
203
+ return 0
204
+
205
+
206
+ def build_parser() -> argparse.ArgumentParser:
207
+ parser = argparse.ArgumentParser(
208
+ prog="actgate",
209
+ description="Local IntentLedger: propose, approve, deny, verify tool intents.",
210
+ )
211
+ parser.add_argument("--version", action="version", version=f"actgate {__version__}")
212
+ parser.add_argument("--root", default=None, help="project root containing .actgate/")
213
+ parser.add_argument("--ledger", default=None, help="explicit ledger.jsonl path (must stay under root)")
214
+
215
+ sub = parser.add_subparsers(dest="command", required=True)
216
+
217
+ p_init = sub.add_parser("init", help="create .actgate/ledger.jsonl")
218
+ p_init.add_argument("directory", nargs="?", default=None)
219
+ p_init.set_defaults(func=cmd_init)
220
+
221
+ p_prop = sub.add_parser("propose", help="append a propose event")
222
+ p_prop.add_argument("--tool", required=True)
223
+ p_prop.add_argument("--args", default=None, help="JSON object of tool args")
224
+ p_prop.add_argument("--args-hash", dest="args_hash", default=None)
225
+ p_prop.add_argument("--blast-tags", dest="blast_tags", default="", help="comma-separated tags")
226
+ p_prop.add_argument("--mode", dest="mode", default="execute", help="requested_mode")
227
+ p_prop.set_defaults(func=cmd_propose)
228
+
229
+ p_dry = sub.add_parser("dry-run", help="show intent and whether it would execute")
230
+ p_dry.add_argument("intent_id")
231
+ p_dry.set_defaults(func=cmd_dry_run)
232
+
233
+ p_ok = sub.add_parser("approve", help="append an approve event")
234
+ p_ok.add_argument("intent_id")
235
+ p_ok.add_argument("--reason", default=None)
236
+ p_ok.set_defaults(func=cmd_approve)
237
+
238
+ p_no = sub.add_parser("deny", help="append a deny event (exit 1)")
239
+ p_no.add_argument("intent_id")
240
+ p_no.add_argument("--reason", default=None)
241
+ p_no.set_defaults(func=cmd_deny)
242
+
243
+ p_ver = sub.add_parser("verify", help="verify ledger hash chain (exit 0/1/2)")
244
+ p_ver.add_argument(
245
+ "--require-seal",
246
+ action="store_true",
247
+ help="fail if entries lack HMAC seals (use with ACTGATE_SEAL_KEY)",
248
+ )
249
+ p_ver.set_defaults(func=cmd_verify)
250
+
251
+ p_show = sub.add_parser("show", help="show events for an intent")
252
+ p_show.add_argument("intent_id")
253
+ p_show.set_defaults(func=cmd_show)
254
+
255
+ p_list = sub.add_parser("list", help="list intents and status")
256
+ p_list.set_defaults(func=cmd_list)
257
+
258
+ return parser
259
+
260
+
261
+ def main(argv: list[str] | None = None) -> int:
262
+ parser = build_parser()
263
+ args = parser.parse_args(argv)
264
+ try:
265
+ return int(args.func(args))
266
+ except LedgerError as exc:
267
+ _eprint(str(exc))
268
+ return 2
@@ -0,0 +1,17 @@
1
+ """Core IntentLedger pieces: intent, ledger, verify."""
2
+
3
+ from actgate.core.intent import Intent, IntentError, build_intent, hash_args
4
+ from actgate.core.ledger import Ledger, LedgerError, resolve_ledger_path
5
+ from actgate.core.verify import VerifyResult, verify_ledger
6
+
7
+ __all__ = [
8
+ "Intent",
9
+ "IntentError",
10
+ "build_intent",
11
+ "hash_args",
12
+ "Ledger",
13
+ "LedgerError",
14
+ "resolve_ledger_path",
15
+ "VerifyResult",
16
+ "verify_ledger",
17
+ ]
@@ -0,0 +1,83 @@
1
+ """Intent model: tool action proposals."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import uuid
8
+ from dataclasses import asdict, dataclass, field
9
+ from datetime import datetime, timezone
10
+ from typing import Any
11
+
12
+
13
+ class IntentError(ValueError):
14
+ """Invalid intent input (setup error)."""
15
+
16
+
17
+ def _utc_now() -> str:
18
+ return datetime.now(timezone.utc).replace(microsecond=0).isoformat()
19
+
20
+
21
+ def canonical_json(obj: Any) -> str:
22
+ return json.dumps(obj, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
23
+
24
+
25
+ def hash_args(args: Any) -> str:
26
+ return hashlib.sha256(canonical_json(args).encode("utf-8")).hexdigest()
27
+
28
+
29
+ @dataclass
30
+ class Intent:
31
+ tool: str
32
+ created_at: str
33
+ id: str = field(default_factory=lambda: uuid.uuid4().hex)
34
+ args: dict[str, Any] | None = None
35
+ args_hash: str | None = None
36
+ blast_tags: list[str] = field(default_factory=list)
37
+ requested_mode: str | None = None
38
+
39
+ def to_dict(self) -> dict[str, Any]:
40
+ return asdict(self)
41
+
42
+ @classmethod
43
+ def from_dict(cls, data: dict[str, Any]) -> "Intent":
44
+ return cls(
45
+ id=data["id"],
46
+ tool=data["tool"],
47
+ created_at=data["created_at"],
48
+ args=data.get("args"),
49
+ args_hash=data.get("args_hash"),
50
+ blast_tags=list(data.get("blast_tags") or []),
51
+ requested_mode=data.get("requested_mode"),
52
+ )
53
+
54
+
55
+ def build_intent(
56
+ tool: str,
57
+ args: dict[str, Any] | None = None,
58
+ args_hash: str | None = None,
59
+ blast_tags: list[str] | None = None,
60
+ requested_mode: str | None = None,
61
+ created_at: str | None = None,
62
+ intent_id: str | None = None,
63
+ ) -> Intent:
64
+ tool = (tool or "").strip()
65
+ if not tool:
66
+ raise IntentError("tool is required")
67
+ if args is None and not args_hash:
68
+ raise IntentError("provide args or args_hash")
69
+ if args is not None and args_hash:
70
+ computed = hash_args(args)
71
+ if computed != args_hash:
72
+ raise IntentError("args_hash does not match args")
73
+ if args is not None and not args_hash:
74
+ args_hash = hash_args(args)
75
+ return Intent(
76
+ id=intent_id or uuid.uuid4().hex,
77
+ tool=tool,
78
+ args=args,
79
+ args_hash=args_hash,
80
+ blast_tags=list(blast_tags or []),
81
+ requested_mode=requested_mode,
82
+ created_at=created_at or _utc_now(),
83
+ )
@@ -0,0 +1,151 @@
1
+ """Append-only sha256-chained ledger.jsonl."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import hmac
7
+ import json
8
+ import os
9
+ from dataclasses import dataclass
10
+ from datetime import datetime, timezone
11
+ from pathlib import Path
12
+ from typing import Any, Iterator
13
+
14
+ from actgate.core.intent import Intent, canonical_json
15
+
16
+
17
+ GENESIS = "0" * 64
18
+ LEDGER_DIR = ".actgate"
19
+ LEDGER_NAME = "ledger.jsonl"
20
+
21
+
22
+ class LedgerError(ValueError):
23
+ """Ledger path or I/O setup error."""
24
+
25
+
26
+ def _utc_now() -> str:
27
+ return datetime.now(timezone.utc).replace(microsecond=0).isoformat()
28
+
29
+
30
+ def resolve_ledger_path(
31
+ root: Path | str | None = None, path: Path | str | None = None
32
+ ) -> Path:
33
+ """Resolve ledger file path; reject escapes outside the chosen root."""
34
+ base = Path(root).resolve() if root else Path.cwd().resolve()
35
+ if path is not None:
36
+ candidate = Path(path).expanduser()
37
+ if not candidate.is_absolute():
38
+ candidate = (base / candidate).resolve()
39
+ else:
40
+ candidate = candidate.resolve()
41
+ try:
42
+ candidate.relative_to(base)
43
+ except ValueError as exc:
44
+ raise LedgerError(f"path escapes ledger root: {candidate}") from exc
45
+ return candidate
46
+ return (base / LEDGER_DIR / LEDGER_NAME).resolve()
47
+
48
+
49
+ def seal_key() -> bytes | None:
50
+ raw = os.environ.get("ACTGATE_SEAL_KEY")
51
+ if not raw:
52
+ return None
53
+ return raw.encode("utf-8")
54
+
55
+
56
+ def entry_payload_for_hash(entry: dict[str, Any]) -> str:
57
+ """Canonical payload excluding entry_hash and seal."""
58
+ payload = {k: v for k, v in entry.items() if k not in ("entry_hash", "seal")}
59
+ return canonical_json(payload)
60
+
61
+
62
+ def compute_entry_hash(entry: dict[str, Any]) -> str:
63
+ return hashlib.sha256(entry_payload_for_hash(entry).encode("utf-8")).hexdigest()
64
+
65
+
66
+ def compute_seal(entry_hash: str, key: bytes) -> str:
67
+ return hmac.new(key, entry_hash.encode("utf-8"), hashlib.sha256).hexdigest()
68
+
69
+
70
+ @dataclass
71
+ class Ledger:
72
+ path: Path
73
+
74
+ @classmethod
75
+ def open(
76
+ cls, root: Path | str | None = None, path: Path | str | None = None
77
+ ) -> "Ledger":
78
+ return cls(path=resolve_ledger_path(root=root, path=path))
79
+
80
+ def ensure(self) -> None:
81
+ self.path.parent.mkdir(parents=True, exist_ok=True)
82
+ if not self.path.exists():
83
+ self.path.touch()
84
+
85
+ def exists(self) -> bool:
86
+ return self.path.is_file()
87
+
88
+ def read_entries(self) -> list[dict[str, Any]]:
89
+ if not self.exists():
90
+ return []
91
+ entries: list[dict[str, Any]] = []
92
+ text = self.path.read_text(encoding="utf-8")
93
+ for line_no, line in enumerate(text.splitlines(), start=1):
94
+ line = line.strip()
95
+ if not line:
96
+ continue
97
+ try:
98
+ entries.append(json.loads(line))
99
+ except json.JSONDecodeError as exc:
100
+ raise LedgerError(f"malformed ledger line {line_no}: {exc}") from exc
101
+ return entries
102
+
103
+ def iter_entries(self) -> Iterator[dict[str, Any]]:
104
+ yield from self.read_entries()
105
+
106
+ def last_hash(self) -> str:
107
+ entries = self.read_entries()
108
+ if not entries:
109
+ return GENESIS
110
+ return entries[-1]["entry_hash"]
111
+
112
+ def append(
113
+ self, action: str, intent: Intent | None = None, **extra: Any
114
+ ) -> dict[str, Any]:
115
+ self.ensure()
116
+ entries = self.read_entries()
117
+ seq = (entries[-1]["seq"] + 1) if entries else 1
118
+ prev_hash = entries[-1]["entry_hash"] if entries else GENESIS
119
+ entry: dict[str, Any] = {
120
+ "seq": seq,
121
+ "prev_hash": prev_hash,
122
+ "action": action,
123
+ "ts": _utc_now(),
124
+ }
125
+ if intent is not None:
126
+ entry["intent_id"] = intent.id
127
+ entry["intent"] = intent.to_dict()
128
+ entry.update(extra)
129
+ entry["entry_hash"] = compute_entry_hash(entry)
130
+ key = seal_key()
131
+ if key is not None:
132
+ entry["seal"] = compute_seal(entry["entry_hash"], key)
133
+ with self.path.open("a", encoding="utf-8") as fh:
134
+ fh.write(json.dumps(entry, ensure_ascii=False, sort_keys=True) + chr(10))
135
+ return entry
136
+
137
+ def find_intent_events(self, intent_id: str) -> list[dict[str, Any]]:
138
+ return [e for e in self.read_entries() if e.get("intent_id") == intent_id]
139
+
140
+ def latest_decision(self, intent_id: str) -> dict[str, Any] | None:
141
+ events = self.find_intent_events(intent_id)
142
+ for event in reversed(events):
143
+ if event.get("action") in ("approve", "deny"):
144
+ return event
145
+ return None
146
+
147
+ def get_proposal(self, intent_id: str) -> dict[str, Any] | None:
148
+ for event in self.find_intent_events(intent_id):
149
+ if event.get("action") == "propose":
150
+ return event
151
+ return None
@@ -0,0 +1,71 @@
1
+ """Ledger chain verification."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from typing import Any
7
+
8
+ from actgate.core.ledger import (
9
+ GENESIS,
10
+ LedgerError,
11
+ Ledger,
12
+ compute_entry_hash,
13
+ compute_seal,
14
+ seal_key,
15
+ )
16
+
17
+
18
+ @dataclass
19
+ class VerifyResult:
20
+ ok: bool
21
+ errors: list[str] = field(default_factory=list)
22
+ entries: int = 0
23
+
24
+ @property
25
+ def exit_code(self) -> int:
26
+ return 0 if self.ok else 1
27
+
28
+
29
+ def verify_ledger(ledger: Ledger, require_seal: bool | None = None) -> VerifyResult:
30
+ """Verify hash chain and optional HMAC seals.
31
+
32
+ Returns ok=False (exit 1) on broken chain or bad/missing seal when required.
33
+ """
34
+ errors: list[str] = []
35
+ try:
36
+ entries = ledger.read_entries()
37
+ except LedgerError as exc:
38
+ return VerifyResult(ok=False, errors=[str(exc)])
39
+
40
+ key = seal_key()
41
+ if require_seal is None:
42
+ require_seal = key is not None
43
+
44
+ prev = GENESIS
45
+ for i, entry in enumerate(entries):
46
+ seq = entry.get("seq")
47
+ if seq != i + 1:
48
+ errors.append(f"seq mismatch at index {i}: expected {i + 1}, got {seq}")
49
+ if entry.get("prev_hash") != prev:
50
+ errors.append(
51
+ f"prev_hash mismatch at seq {seq}: expected {prev}, got {entry.get('prev_hash')}"
52
+ )
53
+ expected_hash = compute_entry_hash(entry)
54
+ if entry.get("entry_hash") != expected_hash:
55
+ errors.append(
56
+ f"entry_hash mismatch at seq {seq}: expected {expected_hash}, got {entry.get('entry_hash')}"
57
+ )
58
+ if require_seal:
59
+ if key is None:
60
+ errors.append(f"seal required but ACTGATE_SEAL_KEY unset at seq {seq}")
61
+ else:
62
+ seal = entry.get("seal")
63
+ if not seal:
64
+ errors.append(f"missing seal at seq {seq}")
65
+ else:
66
+ expected_seal = compute_seal(entry["entry_hash"], key)
67
+ if not (seal == expected_seal):
68
+ errors.append(f"bad seal at seq {seq}")
69
+ prev = entry.get("entry_hash", "")
70
+
71
+ return VerifyResult(ok=not errors, errors=errors, entries=len(entries))
@@ -0,0 +1,113 @@
1
+ Metadata-Version: 2.4
2
+ Name: actgate
3
+ Version: 0.1.0
4
+ Summary: Local intent ledger and approval gate for tool actions
5
+ License-Expression: MIT
6
+ Project-URL: Repository, https://github.com/kartsan03/actgate
7
+ Project-URL: Issues, https://github.com/kartsan03/actgate/issues
8
+ Project-URL: Changelog, https://github.com/kartsan03/actgate/blob/main/CHANGELOG.md
9
+ Keywords: intent,ledger,approval,agent,cli,security,audit
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Operating System :: OS Independent
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Topic :: Security
19
+ Classifier: Topic :: Software Development :: Quality Assurance
20
+ Requires-Python: >=3.10
21
+ Description-Content-Type: text/markdown
22
+ License-File: LICENSE
23
+ Provides-Extra: dev
24
+ Requires-Dist: pytest>=8; extra == "dev"
25
+ Dynamic: license-file
26
+
27
+ # ActGate
28
+
29
+ [![ci](https://github.com/kartsan03/actgate/actions/workflows/ci.yml/badge.svg)](https://github.com/kartsan03/actgate/actions/workflows/ci.yml)
30
+ [![Python](https://img.shields.io/badge/python-3.10%2B-blue)](pyproject.toml)
31
+ [![License: MIT](https://img.shields.io/badge/license-MIT-green)](LICENSE)
32
+
33
+ Local IntentLedger: propose a tool action, record approve or deny in an
34
+ append-only hash-chained ledger, verify the chain before you trust it.
35
+
36
+ This is not an MCP proxy yet. This is not a SaaS. Everything runs offline
37
+ against files on disk.
38
+
39
+ dry-run and approve record decisions only; they do not execute tools.
40
+
41
+ ## Install
42
+
43
+ ```
44
+ pip install -e .[dev]
45
+ ```
46
+
47
+ ## Quickstart
48
+
49
+ ```
50
+ actgate init
51
+ actgate propose --tool shell.exec --args '{"cmd":"ls"}' --blast-tags fs.read
52
+ actgate dry-run <intent_id>
53
+ actgate approve <intent_id>
54
+ actgate verify
55
+ actgate list
56
+ ```
57
+
58
+ Deny path:
59
+
60
+ ```
61
+ actgate deny <intent_id> --reason "too broad"
62
+ # exits 1
63
+ ```
64
+
65
+ ## Exit codes
66
+
67
+ | Code | Meaning |
68
+ |------|---------|
69
+ | 0 | ok (propose, approve, verify clean, show/list) |
70
+ | 1 | deny recorded, or verify found a broken chain / bad seal |
71
+ | 2 | setup error (missing ledger, bad path, invalid args) |
72
+
73
+ ## Intent shape
74
+
75
+ ```json
76
+ {
77
+ "tool": "shell.exec",
78
+ "args": {"cmd": "ls"},
79
+ "args_hash": null,
80
+ "blast_tags": ["fs.read"],
81
+ "requested_mode": "execute",
82
+ "created_at": "2026-09-06T00:00:00+00:00"
83
+ }
84
+ ```
85
+
86
+ Provide either `args` or `args_hash` (sha256 of canonical JSON args). Optional
87
+ `blast_tags` and `requested_mode`.
88
+
89
+ ## Ledger
90
+
91
+ `.actgate/ledger.jsonl` is append-only. Each line has `prev_hash` / `entry_hash`
92
+ (sha256). Bare `verify` checks chain integrity only: a rewritten but
93
+ internally consistent chain still passes. It is not a signature check unless
94
+ you opt in.
95
+
96
+ Optional authenticity: set `ACTGATE_SEAL_KEY` when writing so entries get an
97
+ HMAC seal. Then `verify` (with the key set) requires matching seals, or pass
98
+ `verify --require-seal` to fail when seals are missing.
99
+
100
+ Path escapes outside the ledger root are rejected (exit 2).
101
+
102
+ ## What this is not
103
+
104
+ - Not an MCP proxy (yet)
105
+ - Not a hosted approval product
106
+ - No network calls in the core path
107
+
108
+ ## Development
109
+
110
+ ```
111
+ pip install -e .[dev]
112
+ pytest
113
+ ```
@@ -0,0 +1,17 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ actgate/__init__.py
5
+ actgate/__main__.py
6
+ actgate/cli.py
7
+ actgate.egg-info/PKG-INFO
8
+ actgate.egg-info/SOURCES.txt
9
+ actgate.egg-info/dependency_links.txt
10
+ actgate.egg-info/entry_points.txt
11
+ actgate.egg-info/requires.txt
12
+ actgate.egg-info/top_level.txt
13
+ actgate/core/__init__.py
14
+ actgate/core/intent.py
15
+ actgate/core/ledger.py
16
+ actgate/core/verify.py
17
+ tests/test_actgate.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ actgate = actgate.cli:main
@@ -0,0 +1,3 @@
1
+
2
+ [dev]
3
+ pytest>=8
@@ -0,0 +1 @@
1
+ actgate
@@ -0,0 +1,39 @@
1
+ [project]
2
+ name = "actgate"
3
+ version = "0.1.0"
4
+ description = "Local intent ledger and approval gate for tool actions"
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ requires-python = ">=3.10"
8
+ keywords = ["intent", "ledger", "approval", "agent", "cli", "security", "audit"]
9
+ classifiers = [
10
+ "Development Status :: 3 - Alpha",
11
+ "Intended Audience :: Developers",
12
+ "Operating System :: OS Independent",
13
+ "Programming Language :: Python :: 3",
14
+ "Programming Language :: Python :: 3.10",
15
+ "Programming Language :: Python :: 3.11",
16
+ "Programming Language :: Python :: 3.12",
17
+ "Programming Language :: Python :: 3.13",
18
+ "Topic :: Security",
19
+ "Topic :: Software Development :: Quality Assurance",
20
+ ]
21
+ dependencies = []
22
+
23
+ [project.urls]
24
+ Repository = "https://github.com/kartsan03/actgate"
25
+ Issues = "https://github.com/kartsan03/actgate/issues"
26
+ Changelog = "https://github.com/kartsan03/actgate/blob/main/CHANGELOG.md"
27
+
28
+ [project.optional-dependencies]
29
+ dev = ["pytest>=8"]
30
+
31
+ [project.scripts]
32
+ actgate = "actgate.cli:main"
33
+
34
+ [build-system]
35
+ requires = ["setuptools>=77"]
36
+ build-backend = "setuptools.build_meta"
37
+
38
+ [tool.setuptools.packages.find]
39
+ include = ["actgate*"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,151 @@
1
+ """ActGate MVP tests: propose/approve/deny/verify/path escapes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ from pathlib import Path
8
+
9
+ import pytest
10
+
11
+ from actgate.cli import main
12
+ from actgate.core.intent import IntentError, build_intent, hash_args
13
+ from actgate.core.ledger import GENESIS, Ledger, LedgerError, resolve_ledger_path
14
+ from actgate.core.verify import verify_ledger
15
+
16
+
17
+ @pytest.fixture
18
+ def root(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
19
+ monkeypatch.chdir(tmp_path)
20
+ monkeypatch.delenv("ACTGATE_SEAL_KEY", raising=False)
21
+ assert main(["init"]) == 0
22
+ return tmp_path
23
+
24
+
25
+ def test_propose_approve_verify(root: Path) -> None:
26
+ rc = main(["propose", "--tool", "shell.exec", "--args", '{"cmd":"ls"}', "--blast-tags", "fs.read"])
27
+ assert rc == 0
28
+ ledger = Ledger.open(root=root)
29
+ proposal = [e for e in ledger.read_entries() if e["action"] == "propose"][0]
30
+ iid = proposal["intent_id"]
31
+ assert main(["dry-run", iid]) == 0
32
+ assert main(["approve", iid]) == 0
33
+ assert main(["verify"]) == 0
34
+ assert main(["show", iid]) == 0
35
+ assert main(["list"]) == 0
36
+ entries = ledger.read_entries()
37
+ assert entries[0]["prev_hash"] == GENESIS
38
+ assert entries[1]["prev_hash"] == entries[0]["entry_hash"]
39
+
40
+
41
+ def test_deny_exits_one(root: Path) -> None:
42
+ assert main(["propose", "--tool", "fs.write", "--args", '{"path":"/tmp/x"}']) == 0
43
+ iid = Ledger.open(root=root).read_entries()[0]["intent_id"]
44
+ assert main(["deny", iid, "--reason", "too broad"]) == 1
45
+ assert main(["verify"]) == 0
46
+ decision = Ledger.open(root=root).latest_decision(iid)
47
+ assert decision is not None
48
+ assert decision["action"] == "deny"
49
+
50
+
51
+ def test_broken_chain_verify_fails(root: Path) -> None:
52
+ assert main(["propose", "--tool", "t", "--args", "{}"]) == 0
53
+ ledger = Ledger.open(root=root)
54
+ path = ledger.path
55
+ lines = path.read_text().splitlines()
56
+ entry = json.loads(lines[0])
57
+ entry["entry_hash"] = "deadbeef" * 8
58
+ path.write_text(json.dumps(entry, sort_keys=True) + "\n")
59
+ assert main(["verify"]) == 1
60
+
61
+
62
+ def test_hmac_seal(root: Path, monkeypatch: pytest.MonkeyPatch) -> None:
63
+ monkeypatch.setenv("ACTGATE_SEAL_KEY", "secret-test-key")
64
+ assert main(["propose", "--tool", "t", "--args", '{"a":1}']) == 0
65
+ entry = Ledger.open(root=root).read_entries()[0]
66
+ assert "seal" in entry
67
+ assert main(["verify"]) == 0
68
+ # tamper seal
69
+ path = Ledger.open(root=root).path
70
+ entry["seal"] = "00" * 32
71
+ path.write_text(json.dumps(entry, sort_keys=True) + "\n")
72
+ assert main(["verify"]) == 1
73
+
74
+
75
+ def test_path_escape_rejected(root: Path) -> None:
76
+ with pytest.raises(LedgerError, match="path escapes"):
77
+ resolve_ledger_path(root=root, path="/etc/passwd")
78
+ # CLI path escape
79
+ assert main(["--ledger", "/tmp/evil.jsonl", "verify"]) == 2
80
+
81
+
82
+ def test_args_hash_only(root: Path) -> None:
83
+ h = hash_args({"cmd": "echo"})
84
+ assert main(["propose", "--tool", "shell.exec", "--args-hash", h]) == 0
85
+ intent = Ledger.open(root=root).read_entries()[0]["intent"]
86
+ assert intent["args"] is None
87
+ assert intent["args_hash"] == h
88
+
89
+
90
+ def test_build_intent_validation() -> None:
91
+ with pytest.raises(IntentError):
92
+ build_intent(tool="", args={})
93
+ with pytest.raises(IntentError):
94
+ build_intent(tool="t")
95
+ with pytest.raises(IntentError):
96
+ build_intent(tool="t", args={"a": 1}, args_hash="nope")
97
+
98
+
99
+ def test_missing_ledger_setup_error(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
100
+ monkeypatch.chdir(tmp_path)
101
+ assert main(["verify"]) == 2
102
+ assert main(["list"]) == 2
103
+
104
+
105
+ def test_double_decision_setup_error(root: Path) -> None:
106
+ assert main(["propose", "--tool", "t", "--args", "{}"]) == 0
107
+ iid = Ledger.open(root=root).read_entries()[0]["intent_id"]
108
+ assert main(["approve", iid]) == 0
109
+ assert main(["deny", iid]) == 2
110
+
111
+
112
+ def test_forged_consistent_chain_verifies_without_seal(root: Path) -> None:
113
+ """Without ACTGATE_SEAL_KEY, verify is integrity-only (not authenticity)."""
114
+ from actgate.core.ledger import compute_entry_hash
115
+
116
+ assert main(["propose", "--tool", "shell.exec", "--args", '{"cmd":"ls"}']) == 0
117
+ iid = Ledger.open(root=root).read_entries()[0]["intent_id"]
118
+ assert main(["approve", iid]) == 0
119
+ ledger = Ledger.open(root=root)
120
+ propose, approve = ledger.read_entries()
121
+ intent = dict(propose["intent"])
122
+ intent["tool"] = "forged.exec"
123
+ forged_propose = {
124
+ "seq": 1,
125
+ "action": "propose",
126
+ "intent_id": iid,
127
+ "intent": intent,
128
+ "prev_hash": GENESIS,
129
+ "created_at": propose.get("ts", propose.get("created_at")),
130
+ }
131
+ forged_propose["entry_hash"] = compute_entry_hash(forged_propose)
132
+ forged_approve = {
133
+ "seq": 2,
134
+ "action": "approve",
135
+ "intent_id": iid,
136
+ "intent": intent,
137
+ "decision": "approved",
138
+ "reason": approve.get("reason"),
139
+ "prev_hash": forged_propose["entry_hash"],
140
+ "created_at": approve.get("ts", approve.get("created_at")),
141
+ }
142
+ forged_approve["entry_hash"] = compute_entry_hash(forged_approve)
143
+ ledger.path.write_text(
144
+ json.dumps(forged_propose, sort_keys=True)
145
+ + "\n"
146
+ + json.dumps(forged_approve, sort_keys=True)
147
+ + "\n"
148
+ )
149
+ assert main(["verify"]) == 0
150
+ assert verify_ledger(ledger).ok
151
+ assert main(["verify", "--require-seal"]) == 1