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 +21 -0
- actgate-0.1.0/PKG-INFO +113 -0
- actgate-0.1.0/README.md +87 -0
- actgate-0.1.0/actgate/__init__.py +3 -0
- actgate-0.1.0/actgate/__main__.py +3 -0
- actgate-0.1.0/actgate/cli.py +268 -0
- actgate-0.1.0/actgate/core/__init__.py +17 -0
- actgate-0.1.0/actgate/core/intent.py +83 -0
- actgate-0.1.0/actgate/core/ledger.py +151 -0
- actgate-0.1.0/actgate/core/verify.py +71 -0
- actgate-0.1.0/actgate.egg-info/PKG-INFO +113 -0
- actgate-0.1.0/actgate.egg-info/SOURCES.txt +17 -0
- actgate-0.1.0/actgate.egg-info/dependency_links.txt +1 -0
- actgate-0.1.0/actgate.egg-info/entry_points.txt +2 -0
- actgate-0.1.0/actgate.egg-info/requires.txt +3 -0
- actgate-0.1.0/actgate.egg-info/top_level.txt +1 -0
- actgate-0.1.0/pyproject.toml +39 -0
- actgate-0.1.0/setup.cfg +4 -0
- actgate-0.1.0/tests/test_actgate.py +151 -0
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
|
+
[](https://github.com/kartsan03/actgate/actions/workflows/ci.yml)
|
|
30
|
+
[](pyproject.toml)
|
|
31
|
+
[](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
|
+
```
|
actgate-0.1.0/README.md
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# ActGate
|
|
2
|
+
|
|
3
|
+
[](https://github.com/kartsan03/actgate/actions/workflows/ci.yml)
|
|
4
|
+
[](pyproject.toml)
|
|
5
|
+
[](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,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
|
+
[](https://github.com/kartsan03/actgate/actions/workflows/ci.yml)
|
|
30
|
+
[](pyproject.toml)
|
|
31
|
+
[](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 @@
|
|
|
1
|
+
|
|
@@ -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*"]
|
actgate-0.1.0/setup.cfg
ADDED
|
@@ -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
|