agent-tool-firewall 0.2.0__py3-none-any.whl
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.
- agent_firewall/__init__.py +47 -0
- agent_firewall/cli.py +77 -0
- agent_firewall/client.py +95 -0
- agent_firewall/config.py +42 -0
- agent_firewall/context.py +14 -0
- agent_firewall/data/firewall.example.yaml +64 -0
- agent_firewall/db/__init__.py +5 -0
- agent_firewall/db/models.py +86 -0
- agent_firewall/decorator.py +195 -0
- agent_firewall/guards/__init__.py +33 -0
- agent_firewall/guards/groundedness_checker.py +455 -0
- agent_firewall/guards/injection_detector.py +155 -0
- agent_firewall/guards/pii_masker.py +199 -0
- agent_firewall/guards/pipeline.py +201 -0
- agent_firewall/models.py +44 -0
- agent_firewall/policy_engine.py +123 -0
- agent_firewall/server/__init__.py +1 -0
- agent_firewall/server/api.py +279 -0
- agent_firewall/server/app.py +154 -0
- agent_firewall/server/auth.py +131 -0
- agent_firewall/server/expiry.py +66 -0
- agent_firewall/ui/static/dashboard.js +373 -0
- agent_firewall/ui/static/styles.css +238 -0
- agent_firewall/ui/templates/base.html +26 -0
- agent_firewall/ui/templates/dashboard.html +43 -0
- agent_firewall/ui/templates/login.html +25 -0
- agent_firewall/waiter.py +70 -0
- agent_tool_firewall-0.2.0.dist-info/METADATA +300 -0
- agent_tool_firewall-0.2.0.dist-info/RECORD +33 -0
- agent_tool_firewall-0.2.0.dist-info/WHEEL +5 -0
- agent_tool_firewall-0.2.0.dist-info/entry_points.txt +2 -0
- agent_tool_firewall-0.2.0.dist-info/licenses/LICENSE +21 -0
- agent_tool_firewall-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Agent Firewall — human-in-the-loop tool call approval."""
|
|
2
|
+
|
|
3
|
+
from agent_firewall.context import set_current_user
|
|
4
|
+
from agent_firewall.decorator import guarded_tool
|
|
5
|
+
from agent_firewall.policy_engine import PolicyEngine
|
|
6
|
+
from agent_firewall.server.app import create_app
|
|
7
|
+
|
|
8
|
+
# Guard modules — injection detection, PII masking, groundedness checking.
|
|
9
|
+
from agent_firewall.guards.injection_detector import InjectionScore, score_chunk
|
|
10
|
+
from agent_firewall.guards.pii_masker import (
|
|
11
|
+
PIIMasker,
|
|
12
|
+
mask_pii,
|
|
13
|
+
unmask_pii,
|
|
14
|
+
reset_pii_masker,
|
|
15
|
+
get_pii_mapping,
|
|
16
|
+
)
|
|
17
|
+
from agent_firewall.guards.groundedness_checker import (
|
|
18
|
+
ClaimResult,
|
|
19
|
+
GroundednessReport,
|
|
20
|
+
check_groundedness,
|
|
21
|
+
format_grounded_answer,
|
|
22
|
+
)
|
|
23
|
+
from agent_firewall.guards.pipeline import GuardPipeline, GuardResult
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
"guarded_tool",
|
|
27
|
+
"set_current_user",
|
|
28
|
+
"PolicyEngine",
|
|
29
|
+
"create_app",
|
|
30
|
+
# Guards
|
|
31
|
+
"InjectionScore",
|
|
32
|
+
"score_chunk",
|
|
33
|
+
"PIIMasker",
|
|
34
|
+
"mask_pii",
|
|
35
|
+
"unmask_pii",
|
|
36
|
+
"reset_pii_masker",
|
|
37
|
+
"get_pii_mapping",
|
|
38
|
+
"ClaimResult",
|
|
39
|
+
"GroundednessReport",
|
|
40
|
+
"check_groundedness",
|
|
41
|
+
"format_grounded_answer",
|
|
42
|
+
"GuardPipeline",
|
|
43
|
+
"GuardResult",
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
__version__ = "0.2.0"
|
|
47
|
+
|
agent_firewall/cli.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
import shutil
|
|
6
|
+
|
|
7
|
+
import uvicorn
|
|
8
|
+
|
|
9
|
+
from agent_firewall.config import get_settings
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _example_policy_path() -> Path | None:
|
|
13
|
+
"""Resolve firewall.example.yaml for wheel installs and local checkouts."""
|
|
14
|
+
candidates = [
|
|
15
|
+
Path(__file__).resolve().parent / "data" / "firewall.example.yaml",
|
|
16
|
+
Path.cwd() / "firewall.example.yaml",
|
|
17
|
+
Path(__file__).resolve().parents[1] / "firewall.example.yaml",
|
|
18
|
+
]
|
|
19
|
+
return next((p for p in candidates if p.exists()), None)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def main(argv: list[str] | None = None) -> None:
|
|
23
|
+
parser = argparse.ArgumentParser(prog="agent-firewall", description="Agent Firewall CLI")
|
|
24
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
25
|
+
|
|
26
|
+
serve = sub.add_parser("serve", help="Start API + approval dashboard")
|
|
27
|
+
serve.add_argument("--host", default="127.0.0.1")
|
|
28
|
+
serve.add_argument("--port", type=int, default=8000)
|
|
29
|
+
serve.add_argument("--db", default=None, help="SQLite DB path")
|
|
30
|
+
serve.add_argument("--policy", default=None, help="firewall.yaml path")
|
|
31
|
+
serve.add_argument("--reload", action="store_true")
|
|
32
|
+
|
|
33
|
+
init = sub.add_parser("init-policy", help="Copy firewall.example.yaml to ./firewall.yaml")
|
|
34
|
+
init.add_argument("--force", action="store_true")
|
|
35
|
+
|
|
36
|
+
args = parser.parse_args(argv)
|
|
37
|
+
|
|
38
|
+
if args.command == "init-policy":
|
|
39
|
+
example = _example_policy_path()
|
|
40
|
+
if example is None:
|
|
41
|
+
raise SystemExit("firewall.example.yaml not found")
|
|
42
|
+
dest = Path.cwd() / "firewall.yaml"
|
|
43
|
+
if dest.exists() and not args.force:
|
|
44
|
+
raise SystemExit(f"{dest} already exists (use --force to overwrite)")
|
|
45
|
+
shutil.copy(example, dest)
|
|
46
|
+
print(f"Wrote {dest}")
|
|
47
|
+
return
|
|
48
|
+
|
|
49
|
+
if args.command == "serve":
|
|
50
|
+
settings = get_settings(db_path=args.db, policy_path=args.policy)
|
|
51
|
+
if args.db:
|
|
52
|
+
import os
|
|
53
|
+
|
|
54
|
+
os.environ["FIREWALL_DB_PATH"] = args.db
|
|
55
|
+
if args.policy:
|
|
56
|
+
import os
|
|
57
|
+
|
|
58
|
+
os.environ["FIREWALL_POLICY_PATH"] = args.policy
|
|
59
|
+
|
|
60
|
+
policy_path = Path(settings.policy_path)
|
|
61
|
+
if not policy_path.exists():
|
|
62
|
+
print(
|
|
63
|
+
f"Warning: policy file not found at {policy_path.resolve()}. "
|
|
64
|
+
f"Run `agent-firewall init-policy` or pass --policy."
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
uvicorn.run(
|
|
68
|
+
"agent_firewall.server.app:create_app",
|
|
69
|
+
factory=True,
|
|
70
|
+
host=args.host,
|
|
71
|
+
port=args.port,
|
|
72
|
+
reload=args.reload,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
if __name__ == "__main__":
|
|
77
|
+
main()
|
agent_firewall/client.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
import requests
|
|
6
|
+
|
|
7
|
+
from agent_firewall.config import get_settings
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class FirewallClientError(RuntimeError):
|
|
11
|
+
pass
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class FirewallClient:
|
|
15
|
+
def __init__(self, base_url: str | None = None, timeout: float = 10.0):
|
|
16
|
+
settings = get_settings(firewall_url=base_url)
|
|
17
|
+
self.base_url = settings.firewall_url
|
|
18
|
+
self.timeout = timeout
|
|
19
|
+
|
|
20
|
+
def _url(self, path: str) -> str:
|
|
21
|
+
return f"{self.base_url}{path}"
|
|
22
|
+
|
|
23
|
+
def _request(self, method: str, path: str, **kwargs) -> Any:
|
|
24
|
+
try:
|
|
25
|
+
resp = requests.request(method, self._url(path), timeout=self.timeout, **kwargs)
|
|
26
|
+
except requests.RequestException as exc:
|
|
27
|
+
raise FirewallClientError(
|
|
28
|
+
f"Agent Firewall backend unreachable at {self.base_url}: {exc}"
|
|
29
|
+
) from exc
|
|
30
|
+
if resp.status_code >= 400:
|
|
31
|
+
raise FirewallClientError(
|
|
32
|
+
f"{method} {path} failed ({resp.status_code}): {resp.text}"
|
|
33
|
+
)
|
|
34
|
+
if resp.content:
|
|
35
|
+
return resp.json()
|
|
36
|
+
return None
|
|
37
|
+
|
|
38
|
+
def create_pending(
|
|
39
|
+
self,
|
|
40
|
+
*,
|
|
41
|
+
tool_name: str,
|
|
42
|
+
payload: dict[str, Any],
|
|
43
|
+
risk_level: str,
|
|
44
|
+
matched_rule: str,
|
|
45
|
+
requested_by: str,
|
|
46
|
+
timeout_seconds: int,
|
|
47
|
+
) -> int:
|
|
48
|
+
data = self._request(
|
|
49
|
+
"POST",
|
|
50
|
+
"/api/pending",
|
|
51
|
+
json={
|
|
52
|
+
"tool_name": tool_name,
|
|
53
|
+
"payload": payload,
|
|
54
|
+
"risk_level": risk_level,
|
|
55
|
+
"matched_rule": matched_rule,
|
|
56
|
+
"requested_by": requested_by,
|
|
57
|
+
"timeout_seconds": timeout_seconds,
|
|
58
|
+
},
|
|
59
|
+
)
|
|
60
|
+
return int(data["id"])
|
|
61
|
+
|
|
62
|
+
def get_pending(self, pending_id: int) -> dict[str, Any]:
|
|
63
|
+
return self._request("GET", f"/api/pending/{pending_id}")
|
|
64
|
+
|
|
65
|
+
def mark_executed(self, pending_id: int, error_message: str | None = None) -> dict[str, Any]:
|
|
66
|
+
return self._request(
|
|
67
|
+
"POST",
|
|
68
|
+
f"/api/pending/{pending_id}/executed",
|
|
69
|
+
json={"error_message": error_message},
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
def log_audit(
|
|
73
|
+
self,
|
|
74
|
+
*,
|
|
75
|
+
tool_name: str,
|
|
76
|
+
payload: dict[str, Any],
|
|
77
|
+
decision: str,
|
|
78
|
+
requested_by: str,
|
|
79
|
+
matched_rule: str,
|
|
80
|
+
pending_action_id: int | None = None,
|
|
81
|
+
decided_by: str | None = None,
|
|
82
|
+
) -> None:
|
|
83
|
+
self._request(
|
|
84
|
+
"POST",
|
|
85
|
+
"/api/audit",
|
|
86
|
+
json={
|
|
87
|
+
"tool_name": tool_name,
|
|
88
|
+
"payload": payload,
|
|
89
|
+
"decision": decision,
|
|
90
|
+
"requested_by": requested_by,
|
|
91
|
+
"matched_rule": matched_rule,
|
|
92
|
+
"pending_action_id": pending_action_id,
|
|
93
|
+
"decided_by": decided_by,
|
|
94
|
+
},
|
|
95
|
+
)
|
agent_firewall/config.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from dotenv import load_dotenv
|
|
8
|
+
|
|
9
|
+
load_dotenv()
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True)
|
|
13
|
+
class Settings:
|
|
14
|
+
firewall_url: str = "http://127.0.0.1:8000"
|
|
15
|
+
db_path: str = "./firewall.db"
|
|
16
|
+
policy_path: str = "./firewall.yaml"
|
|
17
|
+
auth_mode: str = "local" # local | passthrough
|
|
18
|
+
session_secret: str = "dev-secret-change-me"
|
|
19
|
+
passthrough_header: str = "X-Forwarded-User"
|
|
20
|
+
poll_interval_seconds: float = 1.5
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def get_settings(
|
|
24
|
+
db_path: str | None = None,
|
|
25
|
+
policy_path: str | None = None,
|
|
26
|
+
firewall_url: str | None = None,
|
|
27
|
+
) -> Settings:
|
|
28
|
+
return Settings(
|
|
29
|
+
firewall_url=(firewall_url or os.getenv("FIREWALL_URL", "http://127.0.0.1:8000")).rstrip(
|
|
30
|
+
"/"
|
|
31
|
+
),
|
|
32
|
+
db_path=db_path or os.getenv("FIREWALL_DB_PATH", "./firewall.db"),
|
|
33
|
+
policy_path=policy_path or os.getenv("FIREWALL_POLICY_PATH", "./firewall.yaml"),
|
|
34
|
+
auth_mode=os.getenv("FIREWALL_AUTH_MODE", "local").lower(),
|
|
35
|
+
session_secret=os.getenv("FIREWALL_SESSION_SECRET", "dev-secret-change-me"),
|
|
36
|
+
passthrough_header=os.getenv("FIREWALL_PASSTHROUGH_HEADER", "X-Forwarded-User"),
|
|
37
|
+
poll_interval_seconds=float(os.getenv("FIREWALL_POLL_INTERVAL", "1.5")),
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def resolve_path(path: str) -> Path:
|
|
42
|
+
return Path(path).expanduser().resolve()
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from contextvars import ContextVar
|
|
4
|
+
|
|
5
|
+
current_user: ContextVar[str] = ContextVar("current_user", default="unknown")
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def set_current_user(user_id: str) -> None:
|
|
9
|
+
"""Set the requesting user identity for subsequent guarded tool calls."""
|
|
10
|
+
current_user.set(user_id)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def get_current_user() -> str:
|
|
14
|
+
return current_user.get()
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# Copy to firewall.yaml and adjust for your project.
|
|
2
|
+
# Demo users (local auth). Change passwords before any real deployment.
|
|
3
|
+
|
|
4
|
+
users:
|
|
5
|
+
- username: admin
|
|
6
|
+
password: admin123
|
|
7
|
+
display_name: Admin Approver
|
|
8
|
+
- username: alice
|
|
9
|
+
password: alice123
|
|
10
|
+
display_name: Alice
|
|
11
|
+
|
|
12
|
+
rules:
|
|
13
|
+
- name: high-value-payments
|
|
14
|
+
match:
|
|
15
|
+
tool_name: send_payment
|
|
16
|
+
payload_contains:
|
|
17
|
+
field: amount
|
|
18
|
+
operator: ">"
|
|
19
|
+
value: 1000
|
|
20
|
+
action: require_approval
|
|
21
|
+
risk_level: high
|
|
22
|
+
timeout_seconds: 120
|
|
23
|
+
|
|
24
|
+
- name: low-value-payments
|
|
25
|
+
match:
|
|
26
|
+
tool_name: send_payment
|
|
27
|
+
payload_contains:
|
|
28
|
+
field: amount
|
|
29
|
+
operator: "<="
|
|
30
|
+
value: 1000
|
|
31
|
+
action: allow
|
|
32
|
+
risk_level: low
|
|
33
|
+
|
|
34
|
+
- name: any-deletion
|
|
35
|
+
match:
|
|
36
|
+
tool_name: "delete_*"
|
|
37
|
+
action: require_approval
|
|
38
|
+
risk_level: high
|
|
39
|
+
timeout_seconds: 90
|
|
40
|
+
|
|
41
|
+
- name: block-transfer-all
|
|
42
|
+
match:
|
|
43
|
+
tool_name: transfer_all_funds
|
|
44
|
+
action: block
|
|
45
|
+
risk_level: high
|
|
46
|
+
|
|
47
|
+
# ---------------------------------------------------------------------------
|
|
48
|
+
# Guard pipeline — injection detection and PII scanning.
|
|
49
|
+
# These run BEFORE policy rules and can escalate the action (never downgrade).
|
|
50
|
+
# Groundedness is a standalone post-answer API (check_groundedness /
|
|
51
|
+
# format_grounded_answer), not part of this pre-tool pipeline.
|
|
52
|
+
# ---------------------------------------------------------------------------
|
|
53
|
+
guards:
|
|
54
|
+
injection_detection:
|
|
55
|
+
enabled: true
|
|
56
|
+
block_threshold: 0.8 # score >= 0.8 → auto-block
|
|
57
|
+
approval_threshold: 0.5 # score >= 0.5 → require approval
|
|
58
|
+
scan_fields: null # null = scan all string fields
|
|
59
|
+
|
|
60
|
+
pii_policy:
|
|
61
|
+
enabled: true
|
|
62
|
+
action: require_approval # allow | require_approval | block
|
|
63
|
+
scan_fields: null # null = scan all string fields
|
|
64
|
+
types: [EMAIL, PHONE, CREDIT_CARD, SSN, IPV4]
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import datetime, timezone
|
|
4
|
+
|
|
5
|
+
from sqlalchemy import DateTime, ForeignKey, Integer, String, Text, create_engine
|
|
6
|
+
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, sessionmaker
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def utcnow() -> datetime:
|
|
10
|
+
return datetime.now(timezone.utc)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Base(DeclarativeBase):
|
|
14
|
+
pass
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class User(Base):
|
|
18
|
+
__tablename__ = "users"
|
|
19
|
+
|
|
20
|
+
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
|
21
|
+
username: Mapped[str] = mapped_column(String(128), unique=True, nullable=False)
|
|
22
|
+
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
|
|
23
|
+
display_name: Mapped[str] = mapped_column(String(255), nullable=False)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class PendingAction(Base):
|
|
27
|
+
__tablename__ = "pending_actions"
|
|
28
|
+
|
|
29
|
+
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
|
30
|
+
tool_name: Mapped[str] = mapped_column(String(255), nullable=False)
|
|
31
|
+
payload_json: Mapped[str] = mapped_column(Text, nullable=False)
|
|
32
|
+
risk_level: Mapped[str] = mapped_column(String(32), nullable=False, default="medium")
|
|
33
|
+
matched_rule: Mapped[str] = mapped_column(String(255), nullable=False, default="")
|
|
34
|
+
status: Mapped[str] = mapped_column(String(32), nullable=False, default="pending")
|
|
35
|
+
requested_by: Mapped[str] = mapped_column(String(255), nullable=False, default="unknown")
|
|
36
|
+
requested_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
|
37
|
+
decided_by: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
|
38
|
+
decided_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
|
39
|
+
reason: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
40
|
+
timeout_seconds: Mapped[int] = mapped_column(Integer, nullable=False, default=120)
|
|
41
|
+
executed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
|
42
|
+
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class AuditLog(Base):
|
|
46
|
+
__tablename__ = "audit_log"
|
|
47
|
+
|
|
48
|
+
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
|
49
|
+
pending_action_id: Mapped[int | None] = mapped_column(
|
|
50
|
+
Integer, ForeignKey("pending_actions.id"), nullable=True
|
|
51
|
+
)
|
|
52
|
+
tool_name: Mapped[str] = mapped_column(String(255), nullable=False)
|
|
53
|
+
payload_json: Mapped[str] = mapped_column(Text, nullable=False)
|
|
54
|
+
decision: Mapped[str] = mapped_column(String(32), nullable=False)
|
|
55
|
+
requested_by: Mapped[str] = mapped_column(String(255), nullable=False, default="unknown")
|
|
56
|
+
decided_by: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
|
57
|
+
timestamp: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
|
58
|
+
matched_rule: Mapped[str] = mapped_column(String(255), nullable=False, default="")
|
|
59
|
+
reason: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def make_engine(db_path: str):
|
|
63
|
+
# SQLite needs absolute-ish URL; keep simple file path.
|
|
64
|
+
url = f"sqlite:///{db_path}"
|
|
65
|
+
return create_engine(url, connect_args={"check_same_thread": False})
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def make_session_factory(db_path: str):
|
|
69
|
+
engine = make_engine(db_path)
|
|
70
|
+
return engine, sessionmaker(bind=engine, autoflush=False, autocommit=False)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _ensure_sqlite_columns(engine) -> None:
|
|
74
|
+
"""Add columns introduced after first install (SQLite has no migrations)."""
|
|
75
|
+
if engine.dialect.name != "sqlite":
|
|
76
|
+
return
|
|
77
|
+
with engine.begin() as conn:
|
|
78
|
+
rows = conn.exec_driver_sql("PRAGMA table_info(audit_log)").fetchall()
|
|
79
|
+
columns = {row[1] for row in rows}
|
|
80
|
+
if rows and "reason" not in columns:
|
|
81
|
+
conn.exec_driver_sql("ALTER TABLE audit_log ADD COLUMN reason TEXT")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def init_db(engine) -> None:
|
|
85
|
+
Base.metadata.create_all(bind=engine)
|
|
86
|
+
_ensure_sqlite_columns(engine)
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from functools import wraps
|
|
7
|
+
from typing import Any, TypeVar
|
|
8
|
+
|
|
9
|
+
from agent_firewall.client import FirewallClient
|
|
10
|
+
from agent_firewall.config import get_settings
|
|
11
|
+
from agent_firewall.context import get_current_user
|
|
12
|
+
from agent_firewall.models import PolicyAction
|
|
13
|
+
from agent_firewall.policy_engine import PolicyEngine
|
|
14
|
+
from agent_firewall.waiter import start_approval_waiter
|
|
15
|
+
|
|
16
|
+
F = TypeVar("F", bound=Callable[..., Any])
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
_policy_engine: PolicyEngine | None = None
|
|
21
|
+
_guard_pipeline: Any = None # lazy import to avoid circular imports
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _get_policy_engine() -> PolicyEngine:
|
|
25
|
+
global _policy_engine
|
|
26
|
+
if _policy_engine is None:
|
|
27
|
+
settings = get_settings()
|
|
28
|
+
_policy_engine = PolicyEngine(settings.policy_path)
|
|
29
|
+
return _policy_engine
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _get_guard_pipeline():
|
|
33
|
+
"""Build or return the cached ``GuardPipeline`` from the policy YAML ``guards:`` section."""
|
|
34
|
+
global _guard_pipeline
|
|
35
|
+
if _guard_pipeline is None:
|
|
36
|
+
from agent_firewall.guards.pipeline import GuardPipeline
|
|
37
|
+
|
|
38
|
+
engine = _get_policy_engine()
|
|
39
|
+
guards_config = engine.guards_config
|
|
40
|
+
if guards_config:
|
|
41
|
+
_guard_pipeline = GuardPipeline.from_policy_config(guards_config)
|
|
42
|
+
else:
|
|
43
|
+
# No guards configured — return a no-op pipeline.
|
|
44
|
+
_guard_pipeline = GuardPipeline()
|
|
45
|
+
return _guard_pipeline
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def reload_policy() -> None:
|
|
49
|
+
global _guard_pipeline
|
|
50
|
+
_get_policy_engine().reload()
|
|
51
|
+
_guard_pipeline = None # Force rebuild on next call.
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def guarded_tool(
|
|
55
|
+
require_approval: bool | None = None,
|
|
56
|
+
risk_level_hint: str | None = None,
|
|
57
|
+
scan_injection: bool | None = None,
|
|
58
|
+
scan_pii: bool | None = None,
|
|
59
|
+
) -> Callable[[F], F]:
|
|
60
|
+
"""
|
|
61
|
+
Intercept tool calls and apply YAML policy (+ optional decorator override).
|
|
62
|
+
|
|
63
|
+
Non-blocking on require_approval: returns a dashboard message immediately and
|
|
64
|
+
executes the real tool in a background waiter after approve.
|
|
65
|
+
|
|
66
|
+
Parameters
|
|
67
|
+
----------
|
|
68
|
+
require_approval:
|
|
69
|
+
Force HITL regardless of YAML policy.
|
|
70
|
+
risk_level_hint:
|
|
71
|
+
Override the risk level for this tool.
|
|
72
|
+
scan_injection:
|
|
73
|
+
Override the YAML ``guards.injection_detection.enabled`` setting for
|
|
74
|
+
this specific tool. ``True`` to force scanning, ``False`` to skip.
|
|
75
|
+
scan_pii:
|
|
76
|
+
Override the YAML ``guards.pii_policy.enabled`` setting for this
|
|
77
|
+
specific tool. ``True`` to force scanning, ``False`` to skip.
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
def decorator(fn: F) -> F:
|
|
81
|
+
@wraps(fn)
|
|
82
|
+
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
83
|
+
engine = _get_policy_engine()
|
|
84
|
+
# Prefer kwargs as payload (typical tool calling). Merge positional by name if needed.
|
|
85
|
+
payload = dict(kwargs)
|
|
86
|
+
decision = engine.evaluate(fn.__name__, payload)
|
|
87
|
+
|
|
88
|
+
if require_approval is True:
|
|
89
|
+
from agent_firewall.models import PolicyDecision
|
|
90
|
+
|
|
91
|
+
decision = PolicyDecision(
|
|
92
|
+
action=PolicyAction.REQUIRE_APPROVAL,
|
|
93
|
+
risk_level=risk_level_hint or decision.risk_level or "high",
|
|
94
|
+
matched_rule=f"decorator-override:{decision.matched_rule}",
|
|
95
|
+
timeout_seconds=decision.timeout_seconds or 120,
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
# --- Run guard pipeline (injection + PII) ---
|
|
99
|
+
guard_details: dict[str, Any] = {}
|
|
100
|
+
try:
|
|
101
|
+
pipeline = _get_guard_pipeline()
|
|
102
|
+
|
|
103
|
+
# Apply per-decorator overrides.
|
|
104
|
+
if scan_injection is not None:
|
|
105
|
+
pipeline.injection_enabled = scan_injection
|
|
106
|
+
if scan_pii is not None:
|
|
107
|
+
pipeline.pii_enabled = scan_pii
|
|
108
|
+
|
|
109
|
+
guard_result = pipeline.evaluate(fn.__name__, payload)
|
|
110
|
+
guard_details = guard_result.guard_details
|
|
111
|
+
|
|
112
|
+
# Guards can escalate the action but never downgrade it.
|
|
113
|
+
if guard_result.action == PolicyAction.BLOCK and decision.action != PolicyAction.BLOCK:
|
|
114
|
+
from agent_firewall.models import PolicyDecision
|
|
115
|
+
|
|
116
|
+
decision = PolicyDecision(
|
|
117
|
+
action=PolicyAction.BLOCK,
|
|
118
|
+
risk_level="critical",
|
|
119
|
+
matched_rule=f"guard:{guard_result.reason}",
|
|
120
|
+
timeout_seconds=decision.timeout_seconds,
|
|
121
|
+
)
|
|
122
|
+
elif (
|
|
123
|
+
guard_result.action == PolicyAction.REQUIRE_APPROVAL
|
|
124
|
+
and decision.action == PolicyAction.ALLOW
|
|
125
|
+
):
|
|
126
|
+
from agent_firewall.models import PolicyDecision
|
|
127
|
+
|
|
128
|
+
decision = PolicyDecision(
|
|
129
|
+
action=PolicyAction.REQUIRE_APPROVAL,
|
|
130
|
+
risk_level=risk_level_hint or "high",
|
|
131
|
+
matched_rule=f"guard:{guard_result.reason}",
|
|
132
|
+
timeout_seconds=decision.timeout_seconds or 120,
|
|
133
|
+
)
|
|
134
|
+
except Exception: # noqa: BLE001
|
|
135
|
+
logger.warning("Guard pipeline error for %s — proceeding with policy only", fn.__name__, exc_info=True)
|
|
136
|
+
|
|
137
|
+
client = FirewallClient()
|
|
138
|
+
requested_by = get_current_user()
|
|
139
|
+
|
|
140
|
+
# Merge guard scan details into the audit payload so the dashboard can show them.
|
|
141
|
+
audit_extra: dict[str, Any] = {}
|
|
142
|
+
if guard_details:
|
|
143
|
+
audit_extra["guard_scan"] = guard_details
|
|
144
|
+
|
|
145
|
+
if decision.action == PolicyAction.BLOCK:
|
|
146
|
+
client.log_audit(
|
|
147
|
+
tool_name=fn.__name__,
|
|
148
|
+
payload={**payload, **audit_extra},
|
|
149
|
+
decision="auto_block",
|
|
150
|
+
requested_by=requested_by,
|
|
151
|
+
matched_rule=decision.matched_rule,
|
|
152
|
+
)
|
|
153
|
+
return (
|
|
154
|
+
f"Blocked by Agent Firewall policy rule '{decision.matched_rule}'. "
|
|
155
|
+
f"Tool '{fn.__name__}' was not executed."
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
if decision.action == PolicyAction.ALLOW:
|
|
159
|
+
client.log_audit(
|
|
160
|
+
tool_name=fn.__name__,
|
|
161
|
+
payload={**payload, **audit_extra},
|
|
162
|
+
decision="auto_allow",
|
|
163
|
+
requested_by=requested_by,
|
|
164
|
+
matched_rule=decision.matched_rule,
|
|
165
|
+
)
|
|
166
|
+
return fn(*args, **kwargs)
|
|
167
|
+
|
|
168
|
+
# require_approval
|
|
169
|
+
pending_id = client.create_pending(
|
|
170
|
+
tool_name=fn.__name__,
|
|
171
|
+
payload={**payload, **audit_extra},
|
|
172
|
+
risk_level=risk_level_hint or decision.risk_level,
|
|
173
|
+
matched_rule=decision.matched_rule,
|
|
174
|
+
requested_by=requested_by,
|
|
175
|
+
timeout_seconds=decision.timeout_seconds or 120,
|
|
176
|
+
)
|
|
177
|
+
start_approval_waiter(
|
|
178
|
+
pending_id=pending_id,
|
|
179
|
+
timeout_seconds=decision.timeout_seconds or 120,
|
|
180
|
+
fn=fn,
|
|
181
|
+
args=args,
|
|
182
|
+
kwargs=kwargs,
|
|
183
|
+
client=client,
|
|
184
|
+
)
|
|
185
|
+
settings = get_settings()
|
|
186
|
+
return (
|
|
187
|
+
f"This tool requires human approval. Request #{pending_id} was added to the "
|
|
188
|
+
f"Agent Firewall dashboard ({settings.firewall_url}). "
|
|
189
|
+
f"Sign in and approve or reject it there. "
|
|
190
|
+
f"Payload: {json.dumps(payload, default=str)}"
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
return wrapper # type: ignore[return-value]
|
|
194
|
+
|
|
195
|
+
return decorator
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Agent Firewall — pluggable guardrails (injection, PII, groundedness)."""
|
|
2
|
+
|
|
3
|
+
from agent_firewall.guards.injection_detector import InjectionScore, score_chunk
|
|
4
|
+
from agent_firewall.guards.pii_masker import (
|
|
5
|
+
PIIMasker,
|
|
6
|
+
get_pii_mapping,
|
|
7
|
+
mask_pii,
|
|
8
|
+
reset_pii_masker,
|
|
9
|
+
unmask_pii,
|
|
10
|
+
)
|
|
11
|
+
from agent_firewall.guards.groundedness_checker import (
|
|
12
|
+
ClaimResult,
|
|
13
|
+
GroundednessReport,
|
|
14
|
+
check_groundedness,
|
|
15
|
+
format_grounded_answer,
|
|
16
|
+
)
|
|
17
|
+
from agent_firewall.guards.pipeline import GuardPipeline, GuardResult
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"InjectionScore",
|
|
21
|
+
"score_chunk",
|
|
22
|
+
"PIIMasker",
|
|
23
|
+
"mask_pii",
|
|
24
|
+
"unmask_pii",
|
|
25
|
+
"reset_pii_masker",
|
|
26
|
+
"get_pii_mapping",
|
|
27
|
+
"ClaimResult",
|
|
28
|
+
"GroundednessReport",
|
|
29
|
+
"check_groundedness",
|
|
30
|
+
"format_grounded_answer",
|
|
31
|
+
"GuardPipeline",
|
|
32
|
+
"GuardResult",
|
|
33
|
+
]
|