admission-gate 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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ak-skwaa-mahawk
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,194 @@
1
+ Metadata-Version: 2.4
2
+ Name: admission-gate
3
+ Version: 0.1.0
4
+ Summary: Deterministic admission gate with human review and cryptographic audit logging for CLI agents.
5
+ Author: ak-skwaa-mahawk
6
+ License: MIT
7
+ Classifier: Programming Language :: Python :: 3
8
+ Classifier: License :: OSI Approved :: MIT License
9
+ Classifier: Operating System :: OS Independent
10
+ Requires-Python: >=3.8
11
+ Description-Content-Type: text/markdown
12
+ License-File: LICENSE
13
+ Dynamic: license-file
14
+
15
+ # admission-gate
16
+
17
+ [![CI](https://github.com/ak-skwaa-mahawk/admission-gate/actions/workflows/ci.yml/badge.svg)](https://github.com/ak-skwaa-mahawk/admission-gate/actions/workflows/ci.yml)
18
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
19
+ [![Release](https://img.shields.io/github/v/release/ak-skwaa-mahawk/admission-gate)](https://github.com/ak-skwaa-mahawk/admission-gate/releases)
20
+
21
+ A lightweight admission gatekeeper and cryptographic audit logger for autonomous CLI agents.
22
+
23
+ ---
24
+ name: Path bypass / policy edge case
25
+ about: Report a path canonicalization escape or policy engine loophole
26
+ title: "[BYPASS]: "
27
+ labels: bug, security
28
+ ---
29
+
30
+ ### Environment
31
+ - OS: [e.g. Windows 11, macOS Sonoma, Ubuntu 24.04, Android Termux]
32
+ - Python Version: [e.g. 3.11]
33
+
34
+ ### Proposed Action
35
+ ```json
36
+ {
37
+ "action_id": "test_bypass",
38
+ "command": "...",
39
+ "target_path": "...",
40
+ "risk_tier": 1
41
+ }
42
+
43
+
44
+ ---
45
+
46
+ ### 3. Add an Integration Example Script
47
+
48
+ Include a short example script under `examples/agent_tool_example.py` demonstrating how an LLM tool loop hooks into `gated_shell()`:
49
+
50
+ ```python
51
+ """
52
+ Example: Hooking admission-gate into an agent tool execution loop.
53
+ """
54
+
55
+ from admission_gate import ActionProposal, gated_shell
56
+
57
+
58
+ def agent_shell_tool(command: str, target: str) -> str:
59
+ proposal = ActionProposal(
60
+ action_id="agent_call_001",
61
+ command=command,
62
+ target_path=target,
63
+ risk_tier=2,
64
+ )
65
+
66
+ executed, output, code = gated_shell(
67
+ proposal,
68
+ allowed_roots=["./workspace"],
69
+ log_path="audit_log.jsonl",
70
+ require_confirm=True,
71
+ )
72
+
73
+ if not executed:
74
+ return f"Refused by gate: {output}"
75
+
76
+ return f"Success (exit code {code}):\n{output}"
77
+
78
+
79
+ if __name__ == "__main__":
80
+ result = agent_shell_tool("ls -la", "./workspace")
81
+ print(result)
82
+
83
+
84
+
85
+
86
+ # 1. Create the issue template directory and file
87
+ mkdir -p .github/ISSUE_TEMPLATE
88
+ cat << 'EOF' > .github/ISSUE_TEMPLATE/path_bypass.md
89
+ ---
90
+ name: Path bypass / policy edge case
91
+ about: Report a path canonicalization escape or policy engine loophole
92
+ title: "[BYPASS]: "
93
+ labels: bug, security
94
+ ---
95
+
96
+ ### Environment
97
+ - OS: [e.g. Windows 11, macOS Sonoma, Ubuntu 24.04, Android Termux]
98
+ - Python Version: [e.g. 3.11]
99
+
100
+ ### Proposed Action
101
+ ```json
102
+ {
103
+ "action_id": "test_bypass",
104
+ "command": "...",
105
+ "target_path": "...",
106
+ "risk_tier": 1
107
+ }
108
+
109
+
110
+
111
+ # admission-gate
112
+
113
+ A lightweight admission gatekeeper and cryptographic audit logger for autonomous CLI agents.
114
+
115
+ When local scripts or LLMs execute commands in a subshell, developers are forced between two extremes: fully autonomous execution that risks destructive operations, or prompt fatigue from micro-approving dozens of benign commands. `admission-gate` sits between the agent and your shell:
116
+
117
+ 1. **Deterministic Filter**: Blocks dangerous patterns (`rm -rf /`) and restricts filesystem access to configured `allowed_roots` using strict path canonicalization.
118
+ 2. **Interactive TTY Confirmation**: Halts for human authorization (`[y/N]`) only on proposals that pass static policy checks.
119
+ 3. **SHA-256 Audit Trail**: Commits all proposals, policy outcomes, and approval decisions to an append-only, hash-chained log.
120
+
121
+ Requires Python 3.8+ with zero third-party dependencies.
122
+
123
+ ---
124
+
125
+ ## Quickstart
126
+
127
+ ```bash
128
+ git clone [https://github.com/ak-skwaa-mahawk/admission-gate.git](https://github.com/ak-skwaa-mahawk/admission-gate.git)
129
+ cd admission-gate
130
+ pip install .
131
+ ```
132
+
133
+ ---
134
+
135
+ ## Library Usage
136
+
137
+ Wrap your agent's shell execution tool so rejected actions never hit the subshell:
138
+
139
+ ```python
140
+ from admission_gate import ActionProposal, gated_shell
141
+
142
+ proposal = ActionProposal(
143
+ action_id="task_101",
144
+ command="rm scratch.tmp",
145
+ target_path="./workspace/scratch.tmp",
146
+ risk_tier=2,
147
+ )
148
+
149
+ # Returns (executed: bool, output: str, exit_code: int)
150
+ executed, output, code = gated_shell(
151
+ proposal,
152
+ allowed_roots=["./workspace"],
153
+ log_path="audit_log.jsonl",
154
+ require_confirm=True,
155
+ )
156
+
157
+ if not executed:
158
+ print(f"Action refused: {output}")
159
+ else:
160
+ print(f"Command succeeded:\n{output}")
161
+ ```
162
+
163
+ ### Standalone Policy Checks
164
+
165
+ ```python
166
+ from admission_gate import ActionProposal, evaluate
167
+
168
+ p = ActionProposal("chk_1", "cat /etc/shadow", "/etc/shadow", 1)
169
+ passed, reason = evaluate(p)
170
+ # passed -> False
171
+ # reason -> "Blocked: path resolves to protected directory '/etc'"
172
+ ```
173
+
174
+ ---
175
+
176
+ ## Verifying Audit Log Integrity
177
+
178
+ ```bash
179
+ python3 src/admission_gate/verify.py audit_log.jsonl
180
+ ```
181
+
182
+ ---
183
+
184
+ ## Running Tests
185
+
186
+ ```bash
187
+ PYTHONPATH=src python3 -m unittest discover -s tests
188
+ ```
189
+
190
+ ---
191
+
192
+ ## License
193
+
194
+ [MIT](LICENSE)
@@ -0,0 +1,180 @@
1
+ # admission-gate
2
+
3
+ [![CI](https://github.com/ak-skwaa-mahawk/admission-gate/actions/workflows/ci.yml/badge.svg)](https://github.com/ak-skwaa-mahawk/admission-gate/actions/workflows/ci.yml)
4
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
5
+ [![Release](https://img.shields.io/github/v/release/ak-skwaa-mahawk/admission-gate)](https://github.com/ak-skwaa-mahawk/admission-gate/releases)
6
+
7
+ A lightweight admission gatekeeper and cryptographic audit logger for autonomous CLI agents.
8
+
9
+ ---
10
+ name: Path bypass / policy edge case
11
+ about: Report a path canonicalization escape or policy engine loophole
12
+ title: "[BYPASS]: "
13
+ labels: bug, security
14
+ ---
15
+
16
+ ### Environment
17
+ - OS: [e.g. Windows 11, macOS Sonoma, Ubuntu 24.04, Android Termux]
18
+ - Python Version: [e.g. 3.11]
19
+
20
+ ### Proposed Action
21
+ ```json
22
+ {
23
+ "action_id": "test_bypass",
24
+ "command": "...",
25
+ "target_path": "...",
26
+ "risk_tier": 1
27
+ }
28
+
29
+
30
+ ---
31
+
32
+ ### 3. Add an Integration Example Script
33
+
34
+ Include a short example script under `examples/agent_tool_example.py` demonstrating how an LLM tool loop hooks into `gated_shell()`:
35
+
36
+ ```python
37
+ """
38
+ Example: Hooking admission-gate into an agent tool execution loop.
39
+ """
40
+
41
+ from admission_gate import ActionProposal, gated_shell
42
+
43
+
44
+ def agent_shell_tool(command: str, target: str) -> str:
45
+ proposal = ActionProposal(
46
+ action_id="agent_call_001",
47
+ command=command,
48
+ target_path=target,
49
+ risk_tier=2,
50
+ )
51
+
52
+ executed, output, code = gated_shell(
53
+ proposal,
54
+ allowed_roots=["./workspace"],
55
+ log_path="audit_log.jsonl",
56
+ require_confirm=True,
57
+ )
58
+
59
+ if not executed:
60
+ return f"Refused by gate: {output}"
61
+
62
+ return f"Success (exit code {code}):\n{output}"
63
+
64
+
65
+ if __name__ == "__main__":
66
+ result = agent_shell_tool("ls -la", "./workspace")
67
+ print(result)
68
+
69
+
70
+
71
+
72
+ # 1. Create the issue template directory and file
73
+ mkdir -p .github/ISSUE_TEMPLATE
74
+ cat << 'EOF' > .github/ISSUE_TEMPLATE/path_bypass.md
75
+ ---
76
+ name: Path bypass / policy edge case
77
+ about: Report a path canonicalization escape or policy engine loophole
78
+ title: "[BYPASS]: "
79
+ labels: bug, security
80
+ ---
81
+
82
+ ### Environment
83
+ - OS: [e.g. Windows 11, macOS Sonoma, Ubuntu 24.04, Android Termux]
84
+ - Python Version: [e.g. 3.11]
85
+
86
+ ### Proposed Action
87
+ ```json
88
+ {
89
+ "action_id": "test_bypass",
90
+ "command": "...",
91
+ "target_path": "...",
92
+ "risk_tier": 1
93
+ }
94
+
95
+
96
+
97
+ # admission-gate
98
+
99
+ A lightweight admission gatekeeper and cryptographic audit logger for autonomous CLI agents.
100
+
101
+ When local scripts or LLMs execute commands in a subshell, developers are forced between two extremes: fully autonomous execution that risks destructive operations, or prompt fatigue from micro-approving dozens of benign commands. `admission-gate` sits between the agent and your shell:
102
+
103
+ 1. **Deterministic Filter**: Blocks dangerous patterns (`rm -rf /`) and restricts filesystem access to configured `allowed_roots` using strict path canonicalization.
104
+ 2. **Interactive TTY Confirmation**: Halts for human authorization (`[y/N]`) only on proposals that pass static policy checks.
105
+ 3. **SHA-256 Audit Trail**: Commits all proposals, policy outcomes, and approval decisions to an append-only, hash-chained log.
106
+
107
+ Requires Python 3.8+ with zero third-party dependencies.
108
+
109
+ ---
110
+
111
+ ## Quickstart
112
+
113
+ ```bash
114
+ git clone [https://github.com/ak-skwaa-mahawk/admission-gate.git](https://github.com/ak-skwaa-mahawk/admission-gate.git)
115
+ cd admission-gate
116
+ pip install .
117
+ ```
118
+
119
+ ---
120
+
121
+ ## Library Usage
122
+
123
+ Wrap your agent's shell execution tool so rejected actions never hit the subshell:
124
+
125
+ ```python
126
+ from admission_gate import ActionProposal, gated_shell
127
+
128
+ proposal = ActionProposal(
129
+ action_id="task_101",
130
+ command="rm scratch.tmp",
131
+ target_path="./workspace/scratch.tmp",
132
+ risk_tier=2,
133
+ )
134
+
135
+ # Returns (executed: bool, output: str, exit_code: int)
136
+ executed, output, code = gated_shell(
137
+ proposal,
138
+ allowed_roots=["./workspace"],
139
+ log_path="audit_log.jsonl",
140
+ require_confirm=True,
141
+ )
142
+
143
+ if not executed:
144
+ print(f"Action refused: {output}")
145
+ else:
146
+ print(f"Command succeeded:\n{output}")
147
+ ```
148
+
149
+ ### Standalone Policy Checks
150
+
151
+ ```python
152
+ from admission_gate import ActionProposal, evaluate
153
+
154
+ p = ActionProposal("chk_1", "cat /etc/shadow", "/etc/shadow", 1)
155
+ passed, reason = evaluate(p)
156
+ # passed -> False
157
+ # reason -> "Blocked: path resolves to protected directory '/etc'"
158
+ ```
159
+
160
+ ---
161
+
162
+ ## Verifying Audit Log Integrity
163
+
164
+ ```bash
165
+ python3 src/admission_gate/verify.py audit_log.jsonl
166
+ ```
167
+
168
+ ---
169
+
170
+ ## Running Tests
171
+
172
+ ```bash
173
+ PYTHONPATH=src python3 -m unittest discover -s tests
174
+ ```
175
+
176
+ ---
177
+
178
+ ## License
179
+
180
+ [MIT](LICENSE)
@@ -0,0 +1,21 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61.0"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "admission-gate"
7
+ version = "0.1.0"
8
+ description = "Deterministic admission gate with human review and cryptographic audit logging for CLI agents."
9
+ readme = "README.md"
10
+ requires-python = ">=3.8"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "ak-skwaa-mahawk" }]
13
+ classifiers = [
14
+ "Programming Language :: Python :: 3",
15
+ "License :: OSI Approved :: MIT License",
16
+ "Operating System :: OS Independent",
17
+ ]
18
+
19
+ [project.scripts]
20
+ admission-gate = "admission_gate.gate:main"
21
+ verify-audit = "admission_gate.verify:main"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,13 @@
1
+ """Deterministic admission gate for CLI agent actions."""
2
+
3
+ from .gate import ActionProposal, PolicyEngine, AuditLogger, evaluate, gated_shell
4
+ from .verify import verify_log
5
+
6
+ __all__ = [
7
+ "ActionProposal",
8
+ "PolicyEngine",
9
+ "AuditLogger",
10
+ "evaluate",
11
+ "gated_shell",
12
+ "verify_log",
13
+ ]
@@ -0,0 +1,249 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ admission_gate.gate - Library interface and execution wrapper for CLI agent actions.
4
+ """
5
+
6
+ import argparse
7
+ import hashlib
8
+ import json
9
+ import os
10
+ import subprocess
11
+ import sys
12
+ import time
13
+ from dataclasses import asdict, dataclass
14
+ from typing import List, Optional, Tuple
15
+
16
+
17
+ @dataclass
18
+ class ActionProposal:
19
+ action_id: str
20
+ command: str
21
+ target_path: str
22
+ risk_tier: int = 1 # 1 = Low, 2 = Medium, 3 = High
23
+
24
+
25
+ class PolicyEngine:
26
+ BLOCKED_PATTERNS = ["rm -rf /", ":(){ :|:& };:", "/dev/sd", "> /dev/null"]
27
+
28
+ PROTECTED_PATHS = [
29
+ "/etc",
30
+ "/boot",
31
+ "/sys",
32
+ "/dev",
33
+ "/proc",
34
+ "C:\\Windows",
35
+ "C:\\Windows\\System32",
36
+ ]
37
+
38
+ @classmethod
39
+ def _canonicalize(cls, path: str) -> str:
40
+ expanded = os.path.expanduser(path)
41
+ if not os.path.exists(expanded):
42
+ parent = os.path.dirname(expanded) or "."
43
+ real_parent = os.path.realpath(parent)
44
+ norm = os.path.join(real_parent, os.path.basename(expanded))
45
+ else:
46
+ norm = os.path.realpath(expanded)
47
+
48
+ norm = os.path.normcase(norm)
49
+ if norm.startswith("\\\\?\\"):
50
+ norm = norm[4:]
51
+ return os.path.abspath(norm)
52
+
53
+ @classmethod
54
+ def _is_within(cls, child: str, parent: str) -> bool:
55
+ parent_dir = parent if parent.endswith(os.sep) else parent + os.sep
56
+ return child == parent or child.startswith(parent_dir)
57
+
58
+ @classmethod
59
+ def evaluate(
60
+ cls,
61
+ proposal: ActionProposal,
62
+ allowed_roots: Optional[List[str]] = None,
63
+ ) -> Tuple[bool, str]:
64
+ for pattern in cls.BLOCKED_PATTERNS:
65
+ if pattern in proposal.command:
66
+ return False, f"Blocked: matched hazardous pattern '{pattern}'"
67
+
68
+ target_norm = cls._canonicalize(proposal.target_path)
69
+
70
+ for raw_protected in cls.PROTECTED_PATHS:
71
+ protected_norm = cls._canonicalize(raw_protected)
72
+ if cls._is_within(target_norm, protected_norm):
73
+ return (
74
+ False,
75
+ f"Blocked: path resolves to protected directory '{raw_protected}'",
76
+ )
77
+
78
+ if allowed_roots:
79
+ in_allowed = False
80
+ for raw_allowed in allowed_roots:
81
+ allowed_norm = cls._canonicalize(raw_allowed)
82
+ if cls._is_within(target_norm, allowed_norm):
83
+ in_allowed = True
84
+ break
85
+ if not in_allowed:
86
+ allowed_str = ", ".join(allowed_roots)
87
+ return False, f"Blocked: path escapes allowed roots ({allowed_str})"
88
+
89
+ if proposal.risk_tier not in (1, 2, 3):
90
+ return False, "Blocked: invalid risk tier (must be 1, 2, or 3)"
91
+
92
+ return True, "Passed automated policy checks."
93
+
94
+
95
+ class AuditLogger:
96
+ def __init__(self, log_path: str = "audit_log.jsonl"):
97
+ self.log_path = log_path
98
+ self.last_hash = self._recover_tip_hash()
99
+
100
+ def _recover_tip_hash(self) -> str:
101
+ if not os.path.exists(self.log_path):
102
+ return "0" * 64
103
+ last_line = ""
104
+ with open(self.log_path, "r", encoding="utf-8") as f:
105
+ for line in f:
106
+ if line.strip():
107
+ last_line = line
108
+ if not last_line:
109
+ return "0" * 64
110
+ try:
111
+ return json.loads(last_line).get("entry_hash", "0" * 64)
112
+ except json.JSONDecodeError:
113
+ return "0" * 64
114
+
115
+ def commit(
116
+ self,
117
+ proposal: ActionProposal,
118
+ passed: bool,
119
+ reason: str,
120
+ human_decision: Optional[bool],
121
+ ) -> str:
122
+ payload = {
123
+ "prev_hash": self.last_hash,
124
+ "timestamp_ns": time.time_ns(),
125
+ "proposal": asdict(proposal),
126
+ "policy_passed": passed,
127
+ "policy_reason": reason,
128
+ "human_accepted": human_decision,
129
+ }
130
+ serialized = json.dumps(payload, sort_keys=True)
131
+ entry_hash = hashlib.sha256(serialized.encode("utf-8")).hexdigest()
132
+ record = {**payload, "entry_hash": entry_hash}
133
+
134
+ with open(self.log_path, "a", encoding="utf-8") as f:
135
+ f.write(json.dumps(record) + "\n")
136
+
137
+ self.last_hash = entry_hash
138
+ return entry_hash
139
+
140
+
141
+ def _prompt_tty(message: str) -> bool:
142
+ try:
143
+ tty_path = "CON:" if os.name == "nt" else "/dev/tty"
144
+ with open(tty_path, "r") as t_in, open(tty_path, "w") as t_out:
145
+ t_out.write(message)
146
+ t_out.flush()
147
+ res = t_in.readline().strip().lower()
148
+ return res in ("y", "yes")
149
+ except (OSError, IOError):
150
+ return False
151
+
152
+
153
+ def evaluate(
154
+ proposal: ActionProposal, allowed_roots: Optional[List[str]] = None
155
+ ) -> Tuple[bool, str]:
156
+ return PolicyEngine.evaluate(proposal, allowed_roots=allowed_roots)
157
+
158
+
159
+ def gated_shell(
160
+ proposal: ActionProposal,
161
+ allowed_roots: Optional[List[str]] = None,
162
+ log_path: str = "audit_log.jsonl",
163
+ require_confirm: bool = True,
164
+ ) -> Tuple[bool, str, int]:
165
+ logger = AuditLogger(log_path=log_path)
166
+ passed, reason = evaluate(proposal, allowed_roots=allowed_roots)
167
+
168
+ decision = None
169
+ if passed:
170
+ if require_confirm:
171
+ msg = f"[Agent Gate] Authorize command '{proposal.command}' on target '{proposal.target_path}'? [y/N]: "
172
+ decision = _prompt_tty(msg)
173
+ else:
174
+ decision = True
175
+ else:
176
+ decision = False
177
+
178
+ logger.commit(proposal, passed, reason, decision)
179
+
180
+ if not (passed and decision):
181
+ err_msg = reason if not passed else "Execution rejected by operator."
182
+ return False, err_msg, -1
183
+
184
+ res = subprocess.run(
185
+ proposal.command,
186
+ shell=True,
187
+ stdout=subprocess.PIPE,
188
+ stderr=subprocess.PIPE,
189
+ text=True,
190
+ )
191
+ output = res.stdout if res.returncode == 0 else res.stderr
192
+ return True, output, res.returncode
193
+
194
+
195
+ def main():
196
+ parser = argparse.ArgumentParser(
197
+ description="Deterministic admission gate for CLI agent actions."
198
+ )
199
+ parser.add_argument(
200
+ "--allow-root",
201
+ action="append",
202
+ dest="allowed_roots",
203
+ help="Allowed filesystem boundary (can be specified multiple times)",
204
+ )
205
+ parser.add_argument(
206
+ "--log-file",
207
+ default="audit_log.jsonl",
208
+ help="Path to write the audit trail (default: audit_log.jsonl)",
209
+ )
210
+ parser.add_argument(
211
+ "--no-confirm",
212
+ action="store_true",
213
+ help="Bypass interactive TTY confirmation (policy checks still enforced)",
214
+ )
215
+ args = parser.parse_args()
216
+
217
+ logger = AuditLogger(log_path=args.log_file)
218
+ print(f"[Agent Gate] Online. Log: {args.log_file} (Tip: {logger.last_hash[:16]}...)")
219
+
220
+ if not sys.stdin.isatty():
221
+ for line in sys.stdin:
222
+ raw = line.strip()
223
+ if not raw:
224
+ continue
225
+ try:
226
+ data = json.loads(raw)
227
+ proposal = ActionProposal(
228
+ action_id=str(data["action_id"]),
229
+ command=str(data["command"]),
230
+ target_path=str(data["target_path"]),
231
+ risk_tier=int(data.get("risk_tier", 1)),
232
+ )
233
+ executed, out, code = gated_shell(
234
+ proposal,
235
+ allowed_roots=args.allowed_roots,
236
+ log_path=args.log_file,
237
+ require_confirm=not args.no_confirm,
238
+ )
239
+ status = f"Code {code}" if executed else "Blocked"
240
+ print(f"Result [{proposal.action_id}]: {status} - {out.strip()}")
241
+ except Exception as e:
242
+ print(f"Error parsing line: {e}", file=sys.stderr)
243
+ else:
244
+ print("Reading stdin for JSON proposals. Example:")
245
+ print('{"action_id": "1", "command": "echo test", "target_path": "./workspace", "risk_tier": 1}')
246
+
247
+
248
+ if __name__ == "__main__":
249
+ main()
@@ -0,0 +1,74 @@
1
+ #!/usr/bin/env python3
2
+ """Audit log verification CLI."""
3
+
4
+ import argparse
5
+ import hashlib
6
+ import json
7
+ import sys
8
+ from typing import Tuple
9
+
10
+
11
+ def verify_log(log_path: str) -> Tuple[bool, int, str]:
12
+ expected_prev = "0" * 64
13
+ count = 0
14
+
15
+ with open(log_path, "r", encoding="utf-8") as f:
16
+ for idx, line in enumerate(f, start=1):
17
+ raw = line.strip()
18
+ if not raw:
19
+ continue
20
+
21
+ entry = json.loads(raw)
22
+ recorded_hash = entry.get("entry_hash")
23
+
24
+ if entry.get("prev_hash") != expected_prev:
25
+ return (
26
+ False,
27
+ idx,
28
+ f"Chain break at line {idx}: expected prev_hash {expected_prev[:16]}, got {entry.get('prev_hash', '')[:16]}",
29
+ )
30
+
31
+ payload = {k: v for k, v in entry.items() if k != "entry_hash"}
32
+ serialized = json.dumps(payload, sort_keys=True)
33
+ recalculated = hashlib.sha256(serialized.encode("utf-8")).hexdigest()
34
+
35
+ if recorded_hash != recalculated:
36
+ return (
37
+ False,
38
+ idx,
39
+ f"Tamper detected at line {idx}: hash mismatch",
40
+ )
41
+
42
+ expected_prev = recorded_hash
43
+ count += 1
44
+
45
+ return True, count, expected_prev
46
+
47
+
48
+ def main():
49
+ parser = argparse.ArgumentParser(
50
+ description="Verify SHA-256 hash-chain integrity of an admission-gate audit log."
51
+ )
52
+ parser.add_argument(
53
+ "log_path",
54
+ nargs="?",
55
+ default="audit_log.jsonl",
56
+ help="Path to the audit log JSONL file (default: audit_log.jsonl)",
57
+ )
58
+ args = parser.parse_args()
59
+
60
+ try:
61
+ ok, count, detail = verify_log(args.log_path)
62
+ if ok:
63
+ print(f"[PASS] Audit log intact. Verified {count} records. Tip: {detail[:16]}...")
64
+ sys.exit(0)
65
+ else:
66
+ print(f"[FAIL] {detail}", file=sys.stderr)
67
+ sys.exit(1)
68
+ except FileNotFoundError:
69
+ print(f"[ERROR] Log file not found: {args.log_path}", file=sys.stderr)
70
+ sys.exit(2)
71
+
72
+
73
+ if __name__ == "__main__":
74
+ main()
@@ -0,0 +1,194 @@
1
+ Metadata-Version: 2.4
2
+ Name: admission-gate
3
+ Version: 0.1.0
4
+ Summary: Deterministic admission gate with human review and cryptographic audit logging for CLI agents.
5
+ Author: ak-skwaa-mahawk
6
+ License: MIT
7
+ Classifier: Programming Language :: Python :: 3
8
+ Classifier: License :: OSI Approved :: MIT License
9
+ Classifier: Operating System :: OS Independent
10
+ Requires-Python: >=3.8
11
+ Description-Content-Type: text/markdown
12
+ License-File: LICENSE
13
+ Dynamic: license-file
14
+
15
+ # admission-gate
16
+
17
+ [![CI](https://github.com/ak-skwaa-mahawk/admission-gate/actions/workflows/ci.yml/badge.svg)](https://github.com/ak-skwaa-mahawk/admission-gate/actions/workflows/ci.yml)
18
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
19
+ [![Release](https://img.shields.io/github/v/release/ak-skwaa-mahawk/admission-gate)](https://github.com/ak-skwaa-mahawk/admission-gate/releases)
20
+
21
+ A lightweight admission gatekeeper and cryptographic audit logger for autonomous CLI agents.
22
+
23
+ ---
24
+ name: Path bypass / policy edge case
25
+ about: Report a path canonicalization escape or policy engine loophole
26
+ title: "[BYPASS]: "
27
+ labels: bug, security
28
+ ---
29
+
30
+ ### Environment
31
+ - OS: [e.g. Windows 11, macOS Sonoma, Ubuntu 24.04, Android Termux]
32
+ - Python Version: [e.g. 3.11]
33
+
34
+ ### Proposed Action
35
+ ```json
36
+ {
37
+ "action_id": "test_bypass",
38
+ "command": "...",
39
+ "target_path": "...",
40
+ "risk_tier": 1
41
+ }
42
+
43
+
44
+ ---
45
+
46
+ ### 3. Add an Integration Example Script
47
+
48
+ Include a short example script under `examples/agent_tool_example.py` demonstrating how an LLM tool loop hooks into `gated_shell()`:
49
+
50
+ ```python
51
+ """
52
+ Example: Hooking admission-gate into an agent tool execution loop.
53
+ """
54
+
55
+ from admission_gate import ActionProposal, gated_shell
56
+
57
+
58
+ def agent_shell_tool(command: str, target: str) -> str:
59
+ proposal = ActionProposal(
60
+ action_id="agent_call_001",
61
+ command=command,
62
+ target_path=target,
63
+ risk_tier=2,
64
+ )
65
+
66
+ executed, output, code = gated_shell(
67
+ proposal,
68
+ allowed_roots=["./workspace"],
69
+ log_path="audit_log.jsonl",
70
+ require_confirm=True,
71
+ )
72
+
73
+ if not executed:
74
+ return f"Refused by gate: {output}"
75
+
76
+ return f"Success (exit code {code}):\n{output}"
77
+
78
+
79
+ if __name__ == "__main__":
80
+ result = agent_shell_tool("ls -la", "./workspace")
81
+ print(result)
82
+
83
+
84
+
85
+
86
+ # 1. Create the issue template directory and file
87
+ mkdir -p .github/ISSUE_TEMPLATE
88
+ cat << 'EOF' > .github/ISSUE_TEMPLATE/path_bypass.md
89
+ ---
90
+ name: Path bypass / policy edge case
91
+ about: Report a path canonicalization escape or policy engine loophole
92
+ title: "[BYPASS]: "
93
+ labels: bug, security
94
+ ---
95
+
96
+ ### Environment
97
+ - OS: [e.g. Windows 11, macOS Sonoma, Ubuntu 24.04, Android Termux]
98
+ - Python Version: [e.g. 3.11]
99
+
100
+ ### Proposed Action
101
+ ```json
102
+ {
103
+ "action_id": "test_bypass",
104
+ "command": "...",
105
+ "target_path": "...",
106
+ "risk_tier": 1
107
+ }
108
+
109
+
110
+
111
+ # admission-gate
112
+
113
+ A lightweight admission gatekeeper and cryptographic audit logger for autonomous CLI agents.
114
+
115
+ When local scripts or LLMs execute commands in a subshell, developers are forced between two extremes: fully autonomous execution that risks destructive operations, or prompt fatigue from micro-approving dozens of benign commands. `admission-gate` sits between the agent and your shell:
116
+
117
+ 1. **Deterministic Filter**: Blocks dangerous patterns (`rm -rf /`) and restricts filesystem access to configured `allowed_roots` using strict path canonicalization.
118
+ 2. **Interactive TTY Confirmation**: Halts for human authorization (`[y/N]`) only on proposals that pass static policy checks.
119
+ 3. **SHA-256 Audit Trail**: Commits all proposals, policy outcomes, and approval decisions to an append-only, hash-chained log.
120
+
121
+ Requires Python 3.8+ with zero third-party dependencies.
122
+
123
+ ---
124
+
125
+ ## Quickstart
126
+
127
+ ```bash
128
+ git clone [https://github.com/ak-skwaa-mahawk/admission-gate.git](https://github.com/ak-skwaa-mahawk/admission-gate.git)
129
+ cd admission-gate
130
+ pip install .
131
+ ```
132
+
133
+ ---
134
+
135
+ ## Library Usage
136
+
137
+ Wrap your agent's shell execution tool so rejected actions never hit the subshell:
138
+
139
+ ```python
140
+ from admission_gate import ActionProposal, gated_shell
141
+
142
+ proposal = ActionProposal(
143
+ action_id="task_101",
144
+ command="rm scratch.tmp",
145
+ target_path="./workspace/scratch.tmp",
146
+ risk_tier=2,
147
+ )
148
+
149
+ # Returns (executed: bool, output: str, exit_code: int)
150
+ executed, output, code = gated_shell(
151
+ proposal,
152
+ allowed_roots=["./workspace"],
153
+ log_path="audit_log.jsonl",
154
+ require_confirm=True,
155
+ )
156
+
157
+ if not executed:
158
+ print(f"Action refused: {output}")
159
+ else:
160
+ print(f"Command succeeded:\n{output}")
161
+ ```
162
+
163
+ ### Standalone Policy Checks
164
+
165
+ ```python
166
+ from admission_gate import ActionProposal, evaluate
167
+
168
+ p = ActionProposal("chk_1", "cat /etc/shadow", "/etc/shadow", 1)
169
+ passed, reason = evaluate(p)
170
+ # passed -> False
171
+ # reason -> "Blocked: path resolves to protected directory '/etc'"
172
+ ```
173
+
174
+ ---
175
+
176
+ ## Verifying Audit Log Integrity
177
+
178
+ ```bash
179
+ python3 src/admission_gate/verify.py audit_log.jsonl
180
+ ```
181
+
182
+ ---
183
+
184
+ ## Running Tests
185
+
186
+ ```bash
187
+ PYTHONPATH=src python3 -m unittest discover -s tests
188
+ ```
189
+
190
+ ---
191
+
192
+ ## License
193
+
194
+ [MIT](LICENSE)
@@ -0,0 +1,12 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ src/admission_gate/__init__.py
5
+ src/admission_gate/gate.py
6
+ src/admission_gate/verify.py
7
+ src/admission_gate.egg-info/PKG-INFO
8
+ src/admission_gate.egg-info/SOURCES.txt
9
+ src/admission_gate.egg-info/dependency_links.txt
10
+ src/admission_gate.egg-info/entry_points.txt
11
+ src/admission_gate.egg-info/top_level.txt
12
+ tests/test_gate.py
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ admission-gate = admission_gate.gate:main
3
+ verify-audit = admission_gate.verify:main
@@ -0,0 +1 @@
1
+ admission_gate
@@ -0,0 +1,55 @@
1
+ import os
2
+ import tempfile
3
+ import unittest
4
+ from admission_gate.gate import ActionProposal, PolicyEngine, AuditLogger, evaluate
5
+ from admission_gate.verify import verify_log
6
+
7
+
8
+ class TestPolicyEngine(unittest.TestCase):
9
+ def test_blocks_dangerous_commands(self):
10
+ p = ActionProposal("1", "rm -rf /", "./workspace", 1)
11
+ passed, _ = evaluate(p)
12
+ self.assertFalse(passed)
13
+
14
+ def test_blocks_protected_directories(self):
15
+ p = ActionProposal("2", "touch /etc/config", "/etc/config", 1)
16
+ passed, _ = evaluate(p)
17
+ self.assertFalse(passed)
18
+
19
+ def test_enforces_allowed_roots(self):
20
+ with tempfile.TemporaryDirectory() as tmp:
21
+ allowed = os.path.join(tmp, "allowed")
22
+ os.makedirs(allowed, exist_ok=True)
23
+
24
+ good_p = ActionProposal("3", "touch test", os.path.join(allowed, "file.txt"), 1)
25
+ passed, _ = evaluate(good_p, allowed_roots=[allowed])
26
+ self.assertTrue(passed)
27
+
28
+ bad_p = ActionProposal("4", "touch test", os.path.join(tmp, "outside.txt"), 1)
29
+ passed, _ = evaluate(bad_p, allowed_roots=[allowed])
30
+ self.assertFalse(passed)
31
+
32
+
33
+ class TestAuditChain(unittest.TestCase):
34
+ def test_log_and_verification(self):
35
+ with tempfile.NamedTemporaryFile(suffix=".jsonl", delete=False) as f:
36
+ log_path = f.name
37
+
38
+ try:
39
+ logger = AuditLogger(log_path=log_path)
40
+ p1 = ActionProposal("1", "echo hi", "./workspace", 1)
41
+ p2 = ActionProposal("2", "ls", "./workspace", 1)
42
+
43
+ logger.commit(p1, True, "passed", True)
44
+ logger.commit(p2, True, "passed", False)
45
+
46
+ ok, count, _ = verify_log(log_path)
47
+ self.assertTrue(ok)
48
+ self.assertEqual(count, 2)
49
+ finally:
50
+ if os.path.exists(log_path):
51
+ os.remove(log_path)
52
+
53
+
54
+ if __name__ == "__main__":
55
+ unittest.main()