Skip to content

Commit 224bcd0

Browse files
RoyLinRoyLin
authored andcommitted
feat: v0.5.2 — Python + TypeScript SDKs; policy language is ACL
SDKs (dependency-free; author ACL policy, run the judge, submit events, stream typed decisions, read metrics): - sdk/python (a3s-sentry, asyncio, stdlib only) — 13 tests, 3 integration vs the real binary - sdk/typescript (@a3s-lab/sentry, Node 18+, built-ins only) — 14 tests, 1 integration - both contract-tested: an SSRF event round-trips to a block, and an SDK-authored ACL rule fires through the daemon's own parser Policy language renamed HCL -> ACL (the a3s config language), extension .acl. Naming/extension only: same grammar + same ordered 'rules = [ ... ]' list, so existing policies keep working. policy/rules.hcl -> policy/rules.acl; HELP/README/manifest/doc-comments updated.
1 parent 8164a94 commit 224bcd0

31 files changed

Lines changed: 2154 additions & 23 deletions

CHANGELOG.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,21 @@
11
# Changelog
22

3+
## [0.5.2] — Python + TypeScript SDKs; the policy language is ACL
4+
5+
### Added
6+
- **Python SDK** ([`sdk/python`](sdk/python), `a3s-sentry`) and **TypeScript SDK**
7+
([`sdk/typescript`](sdk/typescript), `@a3s-lab/sentry`) — dependency-free clients that author ACL
8+
policy in code, supervise the daemon, submit events, stream typed decisions, and read `/metrics` +
9+
`/healthz`. Both mirror the same model and are **contract-tested against the real binary**: an SSRF
10+
event round-trips to a `block`, and an SDK-authored ACL rule fires through the daemon's own parser.
11+
Python: 13 tests (3 integration); TypeScript: 14 tests (1 integration).
12+
13+
### Changed
14+
- **The policy language is now ACL** (the a3s config language), extension `.acl`
15+
`policy/rules.acl`, `A3S_SENTRY_POLICY=…/rules.acl`. Naming + extension only: the syntax is
16+
unchanged (the ordered `rules = [ … ]` list, parsed by the same grammar, preserving first-match-wins
17+
order), so existing policies keep working — just point the daemon at a `.acl` file.
18+
319
## [0.5.1] — release pipeline, container image, operator runbook
420

521
GA items (ii)/(iii)/(iv) — make sentry installable, deployable, and operable. No crate code change;

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "a3s-sentry"
3-
version = "0.5.1"
3+
version = "0.5.2"
44
edition = "2021"
55
license = "MIT"
66
description = "Tiered (L1 rules / L2 LLM / L3 agent) runtime security control for AI agents, built on a3s-observer."

README.md

Lines changed: 43 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ The three tiers trade cost for depth, so expensive judgment runs only on what ch
1717

1818
| tier | mechanism | latency | runs on |
1919
|---|---|---|---|
20-
| **L1** | deterministic regex rule engine (HCL-configurable) | µs | every event |
20+
| **L1** | deterministic regex rule engine (ACL-configurable) | µs | every event |
2121
| **L2** | a fast LLM classifier (OpenAI-compatible endpoint) | ~100s ms | events L1 escalates |
2222
| **L3** | a deep [a3s-code](https://github.com/AI45Lab/Code) agent with security skills | seconds–minutes | events L2 escalates |
2323

@@ -103,8 +103,8 @@ before exiting. (No signal-handling dependency.)
103103

104104
Ships a conservative built-in rule set (privesc, reverse shells, pipe-to-shell, disk overwrite,
105105
credential-file access, secret/injection markers in I/O, cloud-metadata SSRF). Only the unambiguous
106-
cases `block`; the rest `escalate` to L2/L3 rather than guess. Extend or override with an HCL policy
107-
(`A3S_SENTRY_POLICY=policy/rules.hcl`):
106+
cases `block`; the rest `escalate` to L2/L3 rather than guess. Extend or override with an ACL policy
107+
(`A3S_SENTRY_POLICY=policy/rules.acl`):
108108

109109
```hcl
110110
rules = [
@@ -113,22 +113,22 @@ rules = [
113113
]
114114
```
115115

116-
First match wins; no match = allow. See [`policy/rules.hcl`](policy/rules.hcl).
116+
First match wins; no match = allow. See [`policy/rules.acl`](policy/rules.acl).
117117

118118
## Dynamic policy & embedding
119119

120120
**Hot-reload.** The policy file is watched — rewrite it from any program (a controller, your config
121121
system, an operator) and the rules update **live within ~2s, no restart**. A parse error keeps the
122122
current rules, so a bad edit never disarms the engine. This is the language-agnostic way to drive
123-
sentry dynamically: your logic, in any language, rewrites the HCL.
123+
sentry dynamically: your logic, in any language, rewrites the ACL.
124124

125125
**Embed it.** sentry is a library — build the pipeline in-process and apply config changes at runtime:
126126

127127
```rust
128128
use a3s_sentry::{LiveRules, LlmJudge, Pipeline, Severity};
129129
use std::{sync::Arc, time::Duration};
130130

131-
let rules = Arc::new(LiveRules::new(Some("rules.hcl".into()))?); // hot-reloadable
131+
let rules = Arc::new(LiveRules::new(Some("rules.acl".into()))?); // hot-reloadable
132132
let pipeline = Pipeline::new(rules.clone()) // L1
133133
.with_l2(Arc::new(LlmJudge::new("http://llm:18051/v1", "glm", None, Duration::from_secs(10))))
134134
.speculate_above(Some(Severity::High)) // run L2 + L3 in parallel on high-risk
@@ -141,6 +141,42 @@ rules.reload()?; // force-apply config changes now (e.g. on a signal / admin A
141141
Every tier is a `Judge` trait impl, so you can swap L1/L2/L3 for your own (a different model, an
142142
in-house ruleset) and keep the escalation machinery.
143143

144+
## SDKs (Python · TypeScript)
145+
146+
Drive sentry from your own code — author ACL policy, run the judge, submit events, stream typed
147+
decisions, read metrics — instead of wiring stdin/stdout by hand. Both are dependency-free and mirror
148+
the same model; each is contract-tested against the real binary (an SSRF event round-trips to a
149+
`block`, and an SDK-authored ACL rule fires through the daemon's own parser).
150+
151+
- **Python**[`sdk/python`](sdk/python) (`pip install a3s-sentry`), async:
152+
153+
```python
154+
from a3s_sentry import Sentry, SentryConfig, Event, Policy, Rule, Verdict, Severity, Action
155+
156+
Policy([Rule("no-netcat", "ToolExec", r"(?i)\b(ncat|netcat)\b",
157+
Verdict.BLOCK, Severity.MEDIUM, "netcat", Action.DENY_EXEC)]).write("rules.acl")
158+
159+
async with Sentry(SentryConfig(policy="rules.acl", egress_deny="egress.txt")) as s:
160+
await s.submit(Event.egress(1, "169.254.169.254", 80)) # cloud-metadata SSRF
161+
async for audit in s.decisions():
162+
print(audit.decision.verdict, audit.subject); break
163+
```
164+
165+
- **TypeScript**[`sdk/typescript`](sdk/typescript) (`@a3s-lab/sentry`), Node 18+:
166+
167+
```ts
168+
import { Sentry, Policy, Event } from "@a3s-lab/sentry";
169+
170+
new Policy([{ name: "no-netcat", on: "ToolExec", match: "(?i)\\b(ncat|netcat)\\b",
171+
verdict: "block", severity: "medium", reason: "netcat", action: "deny-exec" }])
172+
.write("rules.acl");
173+
174+
const s = new Sentry({ policy: "rules.acl", egressDeny: "egress.txt" });
175+
await s.start();
176+
await s.submit(Event.egress(1, "169.254.169.254", 80));
177+
for await (const audit of s.decisions()) { console.log(audit.decision.verdict, audit.subject); break; }
178+
```
179+
144180
## Speculative parallelism
145181

146182
By default the tiers run serially (L2, then L3 only if L2 escalates). Set `A3S_SENTRY_SPECULATE=high`
@@ -176,7 +212,7 @@ SSH client… key material can be transmitted outbound after being loaded into m
176212

177213
| var | effect |
178214
|---|---|
179-
| `A3S_SENTRY_POLICY` | extra L1 rules (HCL); built-ins always apply; **hot-reloaded** (~2s) |
215+
| `A3S_SENTRY_POLICY` | extra L1 rules (ACL); built-ins always apply; **hot-reloaded** (~2s) |
180216
| `A3S_SENTRY_LLM_URL` | enable L2; OpenAI-compatible chat base URL (`…/v1`) |
181217
| `A3S_SENTRY_LLM_MODEL` / `_KEY` | L2 model name / bearer token |
182218
| `A3S_SENTRY_AGENT_BIN` | enable L3; the agent command (e.g. `scripts/l3-agent.mjs`) |

deploy/daemonset.yaml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ spec:
2828
- name: deny # deny-files: sentry writes, the guards read (one node-local scratch dir)
2929
emptyDir: {}
3030
- name: policy
31-
configMap: { name: a3s-sentry-policy } # rules.hcl — hot-reloaded if you update it
31+
configMap: { name: a3s-sentry-policy } # rules.acl — hot-reloaded if you update it
3232
- name: cgroup
3333
hostPath: { path: /sys/fs/cgroup }
3434
containers:
@@ -44,7 +44,7 @@ spec:
4444
args:
4545
- >
4646
A3S_OBSERVER_JSON=1 A3S_OBSERVER_SSL=1 a3s-observer-collector |
47-
A3S_SENTRY_POLICY=/etc/sentry/rules.hcl
47+
A3S_SENTRY_POLICY=/etc/sentry/rules.acl
4848
A3S_SENTRY_EGRESS_DENY=/deny/egress.txt
4949
A3S_SENTRY_FILE_DENY=/deny/file.txt A3S_SENTRY_EXEC_DENY=/deny/exec.txt
5050
A3S_SENTRY_LLM_URL="$LLM_URL" A3S_SENTRY_LLM_KEY="$LLM_KEY"

policy/rules.hcl renamed to policy/rules.acl

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
1-
# a3s-sentry — L1 site policy (HCL).
1+
# a3s-sentry — L1 site policy (ACL).
22
#
33
# These rules are evaluated BEFORE the built-in defaults, so an earlier match here overrides a
44
# built-in rule. First match wins; no match = allow. Rules are compiled at daemon startup (a bad
5-
# regex fails the load loudly). Point the daemon at this file with A3S_SENTRY_POLICY=policy/rules.hcl.
5+
# regex fails the load loudly). Point the daemon at this file with A3S_SENTRY_POLICY=policy/rules.acl.
66
#
77
# Each rule:
88
# name identifier (shows up in the audit reason)

sdk/python/.gitignore

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
__pycache__/
2+
*.pyc
3+
*.egg-info/
4+
dist/
5+
build/
6+
.pytest_cache/

sdk/python/README.md

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
# a3s-sentry — Python SDK
2+
3+
Author [a3s-sentry](../../) policy in code, run the judge, stream typed decisions, and read its
4+
metrics. Pure standard library — no dependencies. Async (`asyncio`).
5+
6+
```bash
7+
pip install a3s-sentry # or: pip install -e sdk/python
8+
```
9+
10+
## Author policy (ACL, hot-reloaded)
11+
12+
Rules are an ordered list (first match wins). `Policy.write` writes the ACL file atomically, so
13+
sentry's ~2s hot-reload never sees a half-written policy — rewrite it any time to update rules live.
14+
15+
```python
16+
from a3s_sentry import Policy, Rule, Verdict, Severity, Action
17+
18+
Policy([
19+
Rule("no-netcat", on="ToolExec", match=r"(?i)\b(ncat|netcat)\b",
20+
verdict=Verdict.BLOCK, severity=Severity.MEDIUM, reason="netcat", action=Action.DENY_EXEC),
21+
Rule("admin-egress", on="Egress", match=r"^10\.0\.99\.",
22+
verdict=Verdict.ESCALATE, severity=Severity.MEDIUM, reason="admin subnet"),
23+
]).write("rules.acl")
24+
```
25+
26+
## Run sentry, submit events, stream decisions
27+
28+
Sentry emits an audit line only for blocks / escalations / flagged events — plain benign allows are
29+
counted, not printed — so `decisions()` yields exactly the noteworthy ones.
30+
31+
```python
32+
import asyncio
33+
from a3s_sentry import Sentry, SentryConfig, Event, Verdict
34+
35+
async def main():
36+
cfg = SentryConfig(policy="rules.acl", egress_deny="egress-deny.txt",
37+
llm_url="http://llm:18051/v1") # L2 optional
38+
async with Sentry(cfg) as s:
39+
await s.submit(Event.egress(pid=1, peer="169.254.169.254", port=80)) # cloud-metadata SSRF
40+
async for audit in s.decisions():
41+
d = audit.decision
42+
print(d.verdict, audit.subject, "->", d.action and (d.action.kind, d.action.target))
43+
if d.verdict is Verdict.BLOCK:
44+
break
45+
46+
asyncio.run(main())
47+
```
48+
49+
Event builders: `tool_exec`, `egress`, `file_access`, `dns`, `ssl_content`, `security_action`.
50+
51+
## Read metrics
52+
53+
```python
54+
from a3s_sentry import MetricsClient
55+
56+
m = MetricsClient("127.0.0.1:9100") # SentryConfig(metrics_addr="127.0.0.1:9100")
57+
assert m.health()
58+
snap = m.metrics()
59+
# alarm on these two — both mean a block did not take effect:
60+
print(snap.overload_degraded, snap.enforce_failed)
61+
```
62+
63+
## Test
64+
65+
```bash
66+
cd sdk/python
67+
cargo build --manifest-path ../../Cargo.toml # build the sentry binary for the integration tests
68+
python -m unittest discover -s tests -t .
69+
```
70+
71+
The integration tests run the real `sentry` binary (from `target/debug`, or `A3S_SENTRY_BIN`) and are
72+
skipped if it isn't present.

sdk/python/a3s_sentry/__init__.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
"""Python SDK for a3s-sentry — author ACL policy, run the judge, stream typed decisions, read metrics."""
2+
3+
from .events import Event, Identity
4+
from .metrics import MetricsClient, MetricsSnapshot, parse_metrics
5+
from .policy import Policy, Rule
6+
from .process import Sentry, SentryConfig
7+
from .types import Action, Audit, Decision, EnforceAction, Severity, Verdict
8+
9+
__all__ = [
10+
"Verdict",
11+
"Severity",
12+
"Action",
13+
"EnforceAction",
14+
"Decision",
15+
"Audit",
16+
"Rule",
17+
"Policy",
18+
"Event",
19+
"Identity",
20+
"Sentry",
21+
"SentryConfig",
22+
"MetricsClient",
23+
"MetricsSnapshot",
24+
"parse_metrics",
25+
]
26+
27+
__version__ = "0.1.0"

sdk/python/a3s_sentry/events.py

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
"""Build the observer-shaped events you submit to sentry for judging.
2+
3+
Normally a3s-observer produces these from kernel signals; the builders here let any program feed
4+
sentry its own events (a custom pipeline, a test, a different telemetry source).
5+
"""
6+
7+
from __future__ import annotations
8+
9+
import json
10+
from dataclasses import dataclass
11+
from typing import List, Optional
12+
13+
14+
@dataclass
15+
class Identity:
16+
agent: Optional[str] = None
17+
task: Optional[str] = None
18+
session: Optional[str] = None
19+
20+
def to_json(self) -> dict:
21+
return {
22+
k: v
23+
for k, v in (("agent", self.agent), ("task", self.task), ("session", self.session))
24+
if v is not None
25+
}
26+
27+
28+
@dataclass
29+
class Event:
30+
"""An event envelope. Use the builders (:meth:`tool_exec`, …), not the constructor directly."""
31+
32+
kind: str
33+
fields: dict
34+
identity: Optional[Identity] = None
35+
provider: Optional[str] = None
36+
37+
def to_dict(self) -> dict:
38+
out: dict = {"event": {self.kind: self.fields}}
39+
if self.identity is not None:
40+
ident = self.identity.to_json()
41+
if ident:
42+
out["identity"] = ident
43+
if self.provider is not None:
44+
out["provider"] = self.provider
45+
return out
46+
47+
def to_line(self) -> str:
48+
return json.dumps(self.to_dict(), separators=(",", ":"))
49+
50+
@staticmethod
51+
def tool_exec(
52+
pid: int, argv: List[str], *, identity: Optional[Identity] = None, provider: Optional[str] = None
53+
) -> "Event":
54+
return Event("ToolExec", {"pid": pid, "argv": list(argv)}, identity, provider)
55+
56+
@staticmethod
57+
def egress(
58+
pid: int, peer: str, port: int = 0, *, identity: Optional[Identity] = None, provider: Optional[str] = None
59+
) -> "Event":
60+
return Event("Egress", {"pid": pid, "peer": peer, "port": port}, identity, provider)
61+
62+
@staticmethod
63+
def file_access(
64+
pid: int, path: str, write: bool = False, *, identity: Optional[Identity] = None, provider: Optional[str] = None
65+
) -> "Event":
66+
return Event("FileAccess", {"pid": pid, "path": path, "write": write}, identity, provider)
67+
68+
@staticmethod
69+
def dns(
70+
pid: int, query: str, *, identity: Optional[Identity] = None, provider: Optional[str] = None
71+
) -> "Event":
72+
return Event("Dns", {"pid": pid, "query": query}, identity, provider)
73+
74+
@staticmethod
75+
def ssl_content(
76+
pid: int, content: str, is_read: bool = False, *, identity: Optional[Identity] = None, provider: Optional[str] = None
77+
) -> "Event":
78+
return Event("SslContent", {"pid": pid, "is_read": is_read, "content": content}, identity, provider)
79+
80+
@staticmethod
81+
def security_action(
82+
pid: int, kind: str, detail: int = 0, *, identity: Optional[Identity] = None, provider: Optional[str] = None
83+
) -> "Event":
84+
return Event("SecurityAction", {"pid": pid, "kind": kind, "detail": detail}, identity, provider)

0 commit comments

Comments
 (0)