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.
@@ -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 []