agentsguard 0.2.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.
- agentsguard-0.2.0/LICENSE +46 -0
- agentsguard-0.2.0/PKG-INFO +143 -0
- agentsguard-0.2.0/README.md +117 -0
- agentsguard-0.2.0/agentguard/__init__.py +1 -0
- agentsguard-0.2.0/agentguard/approval_engine.py +194 -0
- agentsguard-0.2.0/agentguard/audit.py +31 -0
- agentsguard-0.2.0/agentguard/cli.py +1189 -0
- agentsguard-0.2.0/agentguard/config.py +37 -0
- agentsguard-0.2.0/agentguard/guards.py +69 -0
- agentsguard-0.2.0/agentguard/presets.py +42 -0
- agentsguard-0.2.0/agentguard/relay_client.py +271 -0
- agentsguard-0.2.0/agentguard/risk_engine.py +86 -0
- agentsguard-0.2.0/agentguard/storage.py +72 -0
- agentsguard-0.2.0/agentguard/telegram_bot.py +569 -0
- agentsguard-0.2.0/agentsguard.egg-info/PKG-INFO +143 -0
- agentsguard-0.2.0/agentsguard.egg-info/SOURCES.txt +20 -0
- agentsguard-0.2.0/agentsguard.egg-info/dependency_links.txt +1 -0
- agentsguard-0.2.0/agentsguard.egg-info/entry_points.txt +2 -0
- agentsguard-0.2.0/agentsguard.egg-info/requires.txt +6 -0
- agentsguard-0.2.0/agentsguard.egg-info/top_level.txt +1 -0
- agentsguard-0.2.0/pyproject.toml +45 -0
- agentsguard-0.2.0/setup.cfg +4 -0
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
AgentGuard — Proprietary Software License
|
|
2
|
+
Copyright (c) 2026 Ismail Lamdouar. All rights reserved.
|
|
3
|
+
|
|
4
|
+
IMPORTANT — READ CAREFULLY. This is a proprietary, source-available license.
|
|
5
|
+
The source code of this package is published so that it can be installed and
|
|
6
|
+
inspected. Publication does NOT place it in the public domain and does NOT
|
|
7
|
+
grant any rights beyond those stated below.
|
|
8
|
+
|
|
9
|
+
1. Grant of use.
|
|
10
|
+
You are granted a limited, non-exclusive, non-transferable, revocable license
|
|
11
|
+
to download and install the AgentGuard command-line software (the "Software")
|
|
12
|
+
and to run it for your own personal or internal business use as a client of
|
|
13
|
+
the AgentGuard service.
|
|
14
|
+
|
|
15
|
+
2. Restrictions. You may NOT, without prior written permission from the copyright
|
|
16
|
+
holder:
|
|
17
|
+
(a) copy, redistribute, republish, sublicense, sell, rent, or lease the
|
|
18
|
+
Software or any substantial portion of its source code;
|
|
19
|
+
(b) modify, adapt, translate, or create derivative works of the Software, or
|
|
20
|
+
use its source code (in whole or in part) in another product or service;
|
|
21
|
+
(c) operate, host, or offer a competing or substitute relay/backend service
|
|
22
|
+
using the Software or knowledge derived from its source code;
|
|
23
|
+
(d) remove or alter any copyright, trademark, or other proprietary notices.
|
|
24
|
+
|
|
25
|
+
3. Reverse engineering. The source is provided for transparency and to enable
|
|
26
|
+
installation. Reading it is permitted; using it to clone, reimplement, or
|
|
27
|
+
circumvent the AgentGuard service or its security controls is not.
|
|
28
|
+
|
|
29
|
+
4. Ownership. The Software is licensed, not sold. All title, ownership, and
|
|
30
|
+
intellectual property rights in and to the Software remain with the copyright
|
|
31
|
+
holder.
|
|
32
|
+
|
|
33
|
+
5. No warranty. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
34
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
|
35
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, AND NONINFRINGEMENT.
|
|
36
|
+
|
|
37
|
+
6. Limitation of liability. IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE FOR
|
|
38
|
+
ANY CLAIM, DAMAGES, OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
|
|
39
|
+
TORT, OR OTHERWISE, ARISING FROM, OUT OF, OR IN CONNECTION WITH THE SOFTWARE
|
|
40
|
+
OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
41
|
+
|
|
42
|
+
7. Termination. This license terminates automatically if you breach any term.
|
|
43
|
+
On termination you must cease all use and destroy all copies of the Software.
|
|
44
|
+
|
|
45
|
+
For commercial licensing, redistribution rights, or permissions beyond this
|
|
46
|
+
license, contact: lamdouarismail@gmail.com
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: agentsguard
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Approve or deny your AI coding agent's risky commands from your phone, with an audit trail.
|
|
5
|
+
Author: AgentGuard
|
|
6
|
+
License: Proprietary
|
|
7
|
+
Project-URL: Homepage, https://github.com/ismailLamdouar/agentguard
|
|
8
|
+
Project-URL: Issues, https://github.com/ismailLamdouar/agentguard/issues
|
|
9
|
+
Keywords: claude,claude-code,ai-agent,approval,human-in-the-loop,telegram,guardrail,security
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Environment :: Console
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: Other/Proprietary License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Topic :: Security
|
|
16
|
+
Classifier: Topic :: Software Development :: Quality Assurance
|
|
17
|
+
Requires-Python: >=3.9
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
License-File: LICENSE
|
|
20
|
+
Requires-Dist: click>=8.1
|
|
21
|
+
Requires-Dist: requests>=2.31
|
|
22
|
+
Requires-Dist: qrcode>=7.4
|
|
23
|
+
Provides-Extra: car
|
|
24
|
+
Requires-Dist: gTTS>=2.3; extra == "car"
|
|
25
|
+
Dynamic: license-file
|
|
26
|
+
|
|
27
|
+
# AgentGuard
|
|
28
|
+
|
|
29
|
+
**A guardrail layer for autonomous coding agents.** AgentGuard classifies the risk of
|
|
30
|
+
every shell command and file edit your AI coding agent attempts, routes the risky ones to
|
|
31
|
+
your phone for approval, and — critically — **denies by default** when no one responds.
|
|
32
|
+
Every decision is logged.
|
|
33
|
+
|
|
34
|
+
> Not a remote-control app. Anthropic's
|
|
35
|
+
> [Remote Control](https://code.claude.com/docs/en/remote-control) already lets you drive a
|
|
36
|
+
> Claude session from your phone. AgentGuard is the *policy layer* underneath: it decides
|
|
37
|
+
> what an agent is *allowed* to do, enforces protected files unconditionally, fails safe,
|
|
38
|
+
> and is built to gate **any** agent — not just one vendor's.
|
|
39
|
+
|
|
40
|
+
## Why it exists
|
|
41
|
+
|
|
42
|
+
Native permission prompts (and Remote Control's mirrored version of them) ask "allow this?"
|
|
43
|
+
with no risk model, no protected-file enforcement, and no fail-safe: ignore the prompt and
|
|
44
|
+
nothing is denied. AgentGuard adds the missing governance layer:
|
|
45
|
+
|
|
46
|
+
| | Native prompt / Remote Control | AgentGuard |
|
|
47
|
+
|---|---|---|
|
|
48
|
+
| Risk classification | — | CRITICAL → LOW, defaults to "ask" |
|
|
49
|
+
| Protected files (`.env`, CI, lockfiles, `.claude/`) | — | Always re-affirm, bypass auto-allow |
|
|
50
|
+
| No-response behavior | nothing denied | **default-deny** (fail-safe timeout) |
|
|
51
|
+
| Phone-set guards / auto-rules | — | yes |
|
|
52
|
+
| Audit trail | — | every classify/approve/deny logged |
|
|
53
|
+
|
|
54
|
+
## How it works
|
|
55
|
+
|
|
56
|
+
A `PreToolUse` hook intercepts the agent's tool call, a classifier scores it, and:
|
|
57
|
+
|
|
58
|
+
- **LOW** → auto-approve.
|
|
59
|
+
- **CRITICAL** → auto-deny.
|
|
60
|
+
- **MEDIUM / HIGH** → sent to your phone with a diff/snippet; blocks until you decide.
|
|
61
|
+
- **Protected file** (gate config, secrets, supply-chain/CI) → always reaches you, regardless of score.
|
|
62
|
+
- **No decision within the timeout** → **deny** (fail-safe).
|
|
63
|
+
|
|
64
|
+
## Install
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
pip install agentsguard # the CLI command is `agentguard`
|
|
68
|
+
agentguard install-hooks # wire hooks into this project (.claude/settings.local.json)
|
|
69
|
+
agentguard install-hooks --global # …or all projects (~/.claude/settings.json)
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Then restart Claude Code (or run `/hooks`). It's idempotent and leaves any other hooks in place.
|
|
73
|
+
|
|
74
|
+
## Approval channels
|
|
75
|
+
|
|
76
|
+
AgentGuard is transport-agnostic — the gate is the product, the channel is a detail:
|
|
77
|
+
|
|
78
|
+
- **Telegram** (default, zero infra):
|
|
79
|
+
```bash
|
|
80
|
+
agentguard init # bot token + chat id
|
|
81
|
+
```
|
|
82
|
+
- **Cloud relay + mobile app** (approvals from anywhere):
|
|
83
|
+
```bash
|
|
84
|
+
agentguard pair # link this machine to the phone app
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Quick manual test (no agent needed):
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
agentguard approve-command "git push origin main" # exits 0 (allow) / 1 (deny)
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## Approval modes (`agentguard mode`)
|
|
94
|
+
|
|
95
|
+
| Mode | Who approves | Use when |
|
|
96
|
+
|------|--------------|----------|
|
|
97
|
+
| `phone` (default) | **Only your phone.** The local popup is suppressed — the hook tells Claude Code allow/deny directly, so work continues the instant you tap. | You're away, or don't want anyone at the keyboard approving for you. |
|
|
98
|
+
| `laptop` | **Only the local prompt.** No phone notifications; the hook steps aside. | You're at the desk and don't want phone pings. |
|
|
99
|
+
|
|
100
|
+
`agentguard mode laptop` takes effect immediately (read at runtime).
|
|
101
|
+
|
|
102
|
+
## Claude Code hook
|
|
103
|
+
|
|
104
|
+
`install-hooks` wires the gating + notification hooks with the correct local path filled in.
|
|
105
|
+
The gating hook looks like:
|
|
106
|
+
|
|
107
|
+
```json
|
|
108
|
+
{
|
|
109
|
+
"hooks": {
|
|
110
|
+
"PreToolUse": [
|
|
111
|
+
{
|
|
112
|
+
"matcher": "Bash",
|
|
113
|
+
"hooks": [
|
|
114
|
+
{ "type": "command", "command": "agentguard hook --timeout 1800", "timeout": 1800 }
|
|
115
|
+
]
|
|
116
|
+
}
|
|
117
|
+
]
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
- **Critical invariant:** the inner `--timeout` must be **≤** the outer `"timeout"`, or Claude
|
|
123
|
+
Code kills the hook before your phone can respond. `install-hooks` keeps them matched.
|
|
124
|
+
- **Hook *config* changes** take effect only after restarting Claude Code (snapshotted at
|
|
125
|
+
session start). **Hook *code*** is live with an editable install.
|
|
126
|
+
|
|
127
|
+
## Other commands
|
|
128
|
+
|
|
129
|
+
```bash
|
|
130
|
+
agentguard logs -n 20 # activity timeline
|
|
131
|
+
agentguard pending # is a command awaiting me, or did it stop?
|
|
132
|
+
agentguard resume # lift a Stop kill-switch
|
|
133
|
+
agentguard instructions # show instructions sent from the phone
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
## Storage
|
|
137
|
+
|
|
138
|
+
All state lives in `~/.agentguard/`: `config.json`, `approvals.json`, `instructions.json`,
|
|
139
|
+
`audit_log.json`.
|
|
140
|
+
|
|
141
|
+
## License
|
|
142
|
+
|
|
143
|
+
Proprietary. See [LICENSE](LICENSE).
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# AgentGuard
|
|
2
|
+
|
|
3
|
+
**A guardrail layer for autonomous coding agents.** AgentGuard classifies the risk of
|
|
4
|
+
every shell command and file edit your AI coding agent attempts, routes the risky ones to
|
|
5
|
+
your phone for approval, and — critically — **denies by default** when no one responds.
|
|
6
|
+
Every decision is logged.
|
|
7
|
+
|
|
8
|
+
> Not a remote-control app. Anthropic's
|
|
9
|
+
> [Remote Control](https://code.claude.com/docs/en/remote-control) already lets you drive a
|
|
10
|
+
> Claude session from your phone. AgentGuard is the *policy layer* underneath: it decides
|
|
11
|
+
> what an agent is *allowed* to do, enforces protected files unconditionally, fails safe,
|
|
12
|
+
> and is built to gate **any** agent — not just one vendor's.
|
|
13
|
+
|
|
14
|
+
## Why it exists
|
|
15
|
+
|
|
16
|
+
Native permission prompts (and Remote Control's mirrored version of them) ask "allow this?"
|
|
17
|
+
with no risk model, no protected-file enforcement, and no fail-safe: ignore the prompt and
|
|
18
|
+
nothing is denied. AgentGuard adds the missing governance layer:
|
|
19
|
+
|
|
20
|
+
| | Native prompt / Remote Control | AgentGuard |
|
|
21
|
+
|---|---|---|
|
|
22
|
+
| Risk classification | — | CRITICAL → LOW, defaults to "ask" |
|
|
23
|
+
| Protected files (`.env`, CI, lockfiles, `.claude/`) | — | Always re-affirm, bypass auto-allow |
|
|
24
|
+
| No-response behavior | nothing denied | **default-deny** (fail-safe timeout) |
|
|
25
|
+
| Phone-set guards / auto-rules | — | yes |
|
|
26
|
+
| Audit trail | — | every classify/approve/deny logged |
|
|
27
|
+
|
|
28
|
+
## How it works
|
|
29
|
+
|
|
30
|
+
A `PreToolUse` hook intercepts the agent's tool call, a classifier scores it, and:
|
|
31
|
+
|
|
32
|
+
- **LOW** → auto-approve.
|
|
33
|
+
- **CRITICAL** → auto-deny.
|
|
34
|
+
- **MEDIUM / HIGH** → sent to your phone with a diff/snippet; blocks until you decide.
|
|
35
|
+
- **Protected file** (gate config, secrets, supply-chain/CI) → always reaches you, regardless of score.
|
|
36
|
+
- **No decision within the timeout** → **deny** (fail-safe).
|
|
37
|
+
|
|
38
|
+
## Install
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
pip install agentsguard # the CLI command is `agentguard`
|
|
42
|
+
agentguard install-hooks # wire hooks into this project (.claude/settings.local.json)
|
|
43
|
+
agentguard install-hooks --global # …or all projects (~/.claude/settings.json)
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Then restart Claude Code (or run `/hooks`). It's idempotent and leaves any other hooks in place.
|
|
47
|
+
|
|
48
|
+
## Approval channels
|
|
49
|
+
|
|
50
|
+
AgentGuard is transport-agnostic — the gate is the product, the channel is a detail:
|
|
51
|
+
|
|
52
|
+
- **Telegram** (default, zero infra):
|
|
53
|
+
```bash
|
|
54
|
+
agentguard init # bot token + chat id
|
|
55
|
+
```
|
|
56
|
+
- **Cloud relay + mobile app** (approvals from anywhere):
|
|
57
|
+
```bash
|
|
58
|
+
agentguard pair # link this machine to the phone app
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Quick manual test (no agent needed):
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
agentguard approve-command "git push origin main" # exits 0 (allow) / 1 (deny)
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## Approval modes (`agentguard mode`)
|
|
68
|
+
|
|
69
|
+
| Mode | Who approves | Use when |
|
|
70
|
+
|------|--------------|----------|
|
|
71
|
+
| `phone` (default) | **Only your phone.** The local popup is suppressed — the hook tells Claude Code allow/deny directly, so work continues the instant you tap. | You're away, or don't want anyone at the keyboard approving for you. |
|
|
72
|
+
| `laptop` | **Only the local prompt.** No phone notifications; the hook steps aside. | You're at the desk and don't want phone pings. |
|
|
73
|
+
|
|
74
|
+
`agentguard mode laptop` takes effect immediately (read at runtime).
|
|
75
|
+
|
|
76
|
+
## Claude Code hook
|
|
77
|
+
|
|
78
|
+
`install-hooks` wires the gating + notification hooks with the correct local path filled in.
|
|
79
|
+
The gating hook looks like:
|
|
80
|
+
|
|
81
|
+
```json
|
|
82
|
+
{
|
|
83
|
+
"hooks": {
|
|
84
|
+
"PreToolUse": [
|
|
85
|
+
{
|
|
86
|
+
"matcher": "Bash",
|
|
87
|
+
"hooks": [
|
|
88
|
+
{ "type": "command", "command": "agentguard hook --timeout 1800", "timeout": 1800 }
|
|
89
|
+
]
|
|
90
|
+
}
|
|
91
|
+
]
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
- **Critical invariant:** the inner `--timeout` must be **≤** the outer `"timeout"`, or Claude
|
|
97
|
+
Code kills the hook before your phone can respond. `install-hooks` keeps them matched.
|
|
98
|
+
- **Hook *config* changes** take effect only after restarting Claude Code (snapshotted at
|
|
99
|
+
session start). **Hook *code*** is live with an editable install.
|
|
100
|
+
|
|
101
|
+
## Other commands
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
agentguard logs -n 20 # activity timeline
|
|
105
|
+
agentguard pending # is a command awaiting me, or did it stop?
|
|
106
|
+
agentguard resume # lift a Stop kill-switch
|
|
107
|
+
agentguard instructions # show instructions sent from the phone
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
## Storage
|
|
111
|
+
|
|
112
|
+
All state lives in `~/.agentguard/`: `config.json`, `approvals.json`, `instructions.json`,
|
|
113
|
+
`audit_log.json`.
|
|
114
|
+
|
|
115
|
+
## License
|
|
116
|
+
|
|
117
|
+
Proprietary. See [LICENSE](LICENSE).
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.2.0"
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from datetime import datetime
|
|
3
|
+
|
|
4
|
+
from . import guards, presets, risk_engine, telegram_bot
|
|
5
|
+
from .audit import log_event, now_iso
|
|
6
|
+
from .config import load_config
|
|
7
|
+
from .storage import add_approval, add_instruction, list_approvals
|
|
8
|
+
|
|
9
|
+
DEFAULT_TIMEOUT = 120
|
|
10
|
+
DEFAULT_DEDUP_WINDOW = 10
|
|
11
|
+
|
|
12
|
+
# Decisions that count as "already answered" for idempotency purposes.
|
|
13
|
+
_FINAL_DECISIONS = (
|
|
14
|
+
"approved", "denied", "stopped", "auto_approved", "auto_denied", "timeout",
|
|
15
|
+
"allow_all", "guard_denied",
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
_RISK_RANK = {risk_engine.LOW: 0, risk_engine.MEDIUM: 1, risk_engine.HIGH: 2, risk_engine.CRITICAL: 3}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _rank(level: str) -> int:
|
|
23
|
+
return _RISK_RANK.get(level, 1)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class NotConfigured(Exception):
|
|
27
|
+
pass
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _recent_decision(command: str, window_seconds: int):
|
|
31
|
+
"""Return the most recent final decision for an identical command made within
|
|
32
|
+
`window_seconds`, else None. Lets a duplicate hook fire reuse a just-made
|
|
33
|
+
decision instead of posting a second, stale prompt."""
|
|
34
|
+
if window_seconds <= 0:
|
|
35
|
+
return None
|
|
36
|
+
now = datetime.now()
|
|
37
|
+
for rec in reversed(list_approvals()):
|
|
38
|
+
if rec.get("command") != command:
|
|
39
|
+
continue
|
|
40
|
+
decision = rec.get("decision")
|
|
41
|
+
if decision not in _FINAL_DECISIONS:
|
|
42
|
+
continue
|
|
43
|
+
ts = rec.get("timestamp")
|
|
44
|
+
try:
|
|
45
|
+
when = datetime.fromisoformat(ts) if ts else None
|
|
46
|
+
except ValueError:
|
|
47
|
+
when = None
|
|
48
|
+
if when is None:
|
|
49
|
+
return None
|
|
50
|
+
# The newest matching command decides it: reuse only if still fresh.
|
|
51
|
+
return decision if (now - when).total_seconds() <= window_seconds else None
|
|
52
|
+
return None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _save_instruction(text: str) -> None:
|
|
56
|
+
add_instruction(text, now_iso())
|
|
57
|
+
log_event("instruction_added", text=text)
|
|
58
|
+
# A "don't touch X" instruction becomes an enforced guard.
|
|
59
|
+
keyword = guards.maybe_add_from_instruction(text)
|
|
60
|
+
if keyword:
|
|
61
|
+
log_event("guard_added", keyword=keyword, text=text)
|
|
62
|
+
cfg = load_config()
|
|
63
|
+
token, chat_id = cfg.get("telegram_bot_token"), cfg.get("telegram_chat_id")
|
|
64
|
+
if token and chat_id:
|
|
65
|
+
try:
|
|
66
|
+
telegram_bot.send_message(
|
|
67
|
+
token, chat_id,
|
|
68
|
+
f"🛡️ *Guard set* — I'll block commands touching `{keyword}`.",
|
|
69
|
+
)
|
|
70
|
+
except Exception:
|
|
71
|
+
pass
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def request_approval(command: str, timeout: int = DEFAULT_TIMEOUT, risk_override: str = None, tool_name: str = None, details: str = None, prompt: str = None, session_id: str = None) -> str:
|
|
75
|
+
"""Run the full approval flow for a command. Returns one of:
|
|
76
|
+
'approved', 'denied', 'stopped', 'auto_approved', 'auto_denied', 'timeout'.
|
|
77
|
+
|
|
78
|
+
`risk_override` forces the severity instead of classifying `command` — used
|
|
79
|
+
for non-Bash tools (Write/Edit), whose subject string isn't a shell command
|
|
80
|
+
and must not be run through the regex classifier (a path like `cat.py` would
|
|
81
|
+
falsely match LOW and auto-approve).
|
|
82
|
+
"""
|
|
83
|
+
risk = risk_override or risk_engine.classify(command)
|
|
84
|
+
log_event("approval_requested", command=command, risk=risk)
|
|
85
|
+
|
|
86
|
+
cfg = load_config()
|
|
87
|
+
|
|
88
|
+
# Cloud-relay backend: when paired with the relay (`backend: "relay"`), route
|
|
89
|
+
# the whole approval through it (guards, auto-rules, push, and the phone
|
|
90
|
+
# decision all live server-side). The Telegram path below is the alternative.
|
|
91
|
+
from . import relay_client
|
|
92
|
+
|
|
93
|
+
if relay_client.is_relay_configured(cfg):
|
|
94
|
+
kind = "Shell Command" if not tool_name or tool_name == "Bash" else "File Edit"
|
|
95
|
+
decision = relay_client.request_approval_relay(command, risk, kind=kind, timeout=timeout, cfg=cfg, details=details, prompt=prompt, session_id=session_id)
|
|
96
|
+
add_approval({"timestamp": now_iso(), "command": command, "risk": risk, "decision": decision})
|
|
97
|
+
log_event("relay_decision", command=command, risk=risk, decision=decision)
|
|
98
|
+
return decision
|
|
99
|
+
|
|
100
|
+
# Phone-set guards ("Do not touch auth") hard-block any matching command,
|
|
101
|
+
# regardless of risk — before it can be auto-approved or reach the phone.
|
|
102
|
+
guard = guards.violated_by(command)
|
|
103
|
+
if guard:
|
|
104
|
+
add_approval({"timestamp": now_iso(), "command": command, "risk": risk, "decision": "guard_denied"})
|
|
105
|
+
log_event("guard_blocked", command=command, risk=risk, guard=guard.get("keyword"))
|
|
106
|
+
token, chat_id = cfg.get("telegram_bot_token"), cfg.get("telegram_chat_id")
|
|
107
|
+
if token and chat_id:
|
|
108
|
+
try:
|
|
109
|
+
telegram_bot.send_message(
|
|
110
|
+
token, chat_id,
|
|
111
|
+
f"🛡️ *Blocked by your guard* (_{guard.get('text')}_):\n```\n{command}\n```",
|
|
112
|
+
)
|
|
113
|
+
except Exception:
|
|
114
|
+
pass
|
|
115
|
+
return "guard_denied"
|
|
116
|
+
|
|
117
|
+
mirror_all = bool(cfg.get("mirror_all", False))
|
|
118
|
+
car_mode = bool(cfg.get("car_mode", False))
|
|
119
|
+
|
|
120
|
+
if car_mode:
|
|
121
|
+
# Car Mode: auto-approve anything below the ask threshold so routine
|
|
122
|
+
# commands don't interrupt you while driving; only risk >= threshold
|
|
123
|
+
# reaches the phone (CRITICAL is asked, not auto-denied, here).
|
|
124
|
+
threshold = cfg.get("car_ask_above", "high")
|
|
125
|
+
if _rank(risk) < _rank(threshold):
|
|
126
|
+
add_approval({"timestamp": now_iso(), "command": command, "risk": risk, "decision": "auto_approved"})
|
|
127
|
+
log_event("car_auto_approved", command=command, risk=risk, threshold=threshold)
|
|
128
|
+
return "auto_approved"
|
|
129
|
+
elif not mirror_all:
|
|
130
|
+
# In mirror mode every command is forwarded to the phone — the auto-approve
|
|
131
|
+
# (LOW) and auto-deny (CRITICAL) shortcuts are skipped so nothing is decided
|
|
132
|
+
# without you.
|
|
133
|
+
if risk == risk_engine.LOW:
|
|
134
|
+
add_approval({"timestamp": now_iso(), "command": command, "risk": risk, "decision": "auto_approved"})
|
|
135
|
+
log_event("auto_approved", command=command, risk=risk)
|
|
136
|
+
return "auto_approved"
|
|
137
|
+
|
|
138
|
+
if risk == risk_engine.CRITICAL:
|
|
139
|
+
add_approval({"timestamp": now_iso(), "command": command, "risk": risk, "decision": "auto_denied"})
|
|
140
|
+
log_event("auto_denied", command=command, risk=risk, reason="critical_default")
|
|
141
|
+
return "auto_denied"
|
|
142
|
+
|
|
143
|
+
token = cfg.get("telegram_bot_token")
|
|
144
|
+
chat_id = cfg.get("telegram_chat_id")
|
|
145
|
+
if not token or not chat_id:
|
|
146
|
+
raise NotConfigured("Run `agentguard init` first.")
|
|
147
|
+
|
|
148
|
+
# Idempotency: a duplicate hook fire for a command decided moments ago reuses
|
|
149
|
+
# that decision instead of posting a second, stale Approve/Deny prompt.
|
|
150
|
+
window = int(cfg.get("dedup_window_seconds", DEFAULT_DEDUP_WINDOW))
|
|
151
|
+
prior = _recent_decision(command, window)
|
|
152
|
+
if prior is not None:
|
|
153
|
+
log_event("dedup_reused", command=command, risk=risk, decision=prior)
|
|
154
|
+
verb = "approved" if prior in ("approved", "auto_approved") else prior
|
|
155
|
+
try:
|
|
156
|
+
telegram_bot.send_message(
|
|
157
|
+
token,
|
|
158
|
+
chat_id,
|
|
159
|
+
f"↩️ *Already answered* ({verb}) — not asking again:\n```\n{command}\n```",
|
|
160
|
+
)
|
|
161
|
+
except Exception:
|
|
162
|
+
pass
|
|
163
|
+
return prior
|
|
164
|
+
|
|
165
|
+
quick = presets.for_command(command)
|
|
166
|
+
car_mode = bool(cfg.get("car_mode"))
|
|
167
|
+
openai_key = cfg.get("openai_api_key") or os.environ.get("OPENAI_API_KEY")
|
|
168
|
+
message_id = telegram_bot.send_approval_request(token, chat_id, command, risk, presets=quick, allow_all_label=tool_name)
|
|
169
|
+
log_event("telegram_sent", command=command, risk=risk, message_id=message_id)
|
|
170
|
+
if car_mode and cfg.get("car_speak", True):
|
|
171
|
+
telegram_bot.speak(token, chat_id, f"Approval needed. {risk} risk. Command: {command}. Reply approve, deny, or stop.")
|
|
172
|
+
|
|
173
|
+
decision = telegram_bot.wait_for_decision(
|
|
174
|
+
token=token,
|
|
175
|
+
chat_id=chat_id,
|
|
176
|
+
message_id=message_id,
|
|
177
|
+
command=command,
|
|
178
|
+
risk=risk,
|
|
179
|
+
timeout=timeout,
|
|
180
|
+
on_instruction=_save_instruction,
|
|
181
|
+
presets=quick,
|
|
182
|
+
allow_all_label=tool_name,
|
|
183
|
+
car_mode=car_mode,
|
|
184
|
+
openai_key=openai_key,
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
add_approval({
|
|
188
|
+
"timestamp": now_iso(),
|
|
189
|
+
"command": command,
|
|
190
|
+
"risk": risk,
|
|
191
|
+
"decision": decision,
|
|
192
|
+
})
|
|
193
|
+
log_event(decision, command=command, risk=risk)
|
|
194
|
+
return decision
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from datetime import datetime
|
|
3
|
+
|
|
4
|
+
from .config import AUDIT_FILE, ensure_dir
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def now_iso() -> str:
|
|
8
|
+
return datetime.now().isoformat(timespec="seconds")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def log_event(event: str, **fields) -> None:
|
|
12
|
+
ensure_dir()
|
|
13
|
+
record = {"timestamp": now_iso(), "event": event, **fields}
|
|
14
|
+
try:
|
|
15
|
+
items = json.loads(AUDIT_FILE.read_text(encoding="utf-8"))
|
|
16
|
+
if not isinstance(items, list):
|
|
17
|
+
items = []
|
|
18
|
+
except (json.JSONDecodeError, FileNotFoundError):
|
|
19
|
+
items = []
|
|
20
|
+
items.append(record)
|
|
21
|
+
AUDIT_FILE.write_text(json.dumps(items, indent=2), encoding="utf-8")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def load_log() -> list:
|
|
25
|
+
if not AUDIT_FILE.exists():
|
|
26
|
+
return []
|
|
27
|
+
try:
|
|
28
|
+
data = json.loads(AUDIT_FILE.read_text(encoding="utf-8"))
|
|
29
|
+
return data if isinstance(data, list) else []
|
|
30
|
+
except json.JSONDecodeError:
|
|
31
|
+
return []
|