Metadata-Version: 2.4
Name: savants-guard
Version: 0.6.1
Summary: Deterministic guardrails for AI agents. Your agent follows your rules. Always.
Author-email: Savants <hello@savants.dev>
License: MIT
Project-URL: Homepage, https://savants.dev
Project-URL: Documentation, https://savants.dev/docs/getting-started
Project-URL: Repository, https://github.com/savants/savants
Project-URL: Issues, https://github.com/savants/savants/issues
Keywords: ai,agent,guardrails,safety,llm,deterministic
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Topic :: Software Development :: Libraries
Classifier: Topic :: Security
Requires-Python: >=3.10
Description-Content-Type: text/markdown
Provides-Extra: managed
Requires-Dist: httpx>=0.27; extra == "managed"
Provides-Extra: dev
Requires-Dist: pytest; extra == "dev"
Requires-Dist: pytest-asyncio; extra == "dev"
Requires-Dist: httpx; extra == "dev"

# savants-guard

Deterministic guardrails for AI agents. Block dangerous actions, suggest alternatives, rewrite commands, enforce spend limits.

```
pip install savants-guard
```

## Quick start

```python
from savants_guard import create_guard

guard = create_guard([
    "when action contains 'delete' and environment eq 'production' then block",
    "when spend gt 100 then require_approval",
])

result = guard.check({"action": "delete_database", "environment": "production"})
print(result.blocked)  # True
print(result.rule)     # "when action contains 'delete'..."
```

## Action types

Rules end with an action. Four actions available, from soft to hard:

```python
guard = create_guard([
    "when command contains 'chmod 777' then suggest 'Use chmod 755 for directories'",
    "when command contains 'git push --force' then rewrite 'git push --force-with-lease'",
    "when command contains 'npm publish' then ask 'Publishing is permanent'",
    "when command contains 'rm -rf /' then block",
])
```

| Action | `result.blocked` | `result.allowed` | `result.suggestion` |
|--------|-------------------|-------------------|---------------------|
| `suggest 'msg'` | `False` | `False` | The alternative suggestion |
| `rewrite 'cmd'` | `False` | `False` | The replacement command |
| `ask 'reason'` | `False` | `False` | The reason for approval |
| `block` | `True` | `False` | `None` |
| *(no match)* | `False` | `True` | `None` |

## GuardResult fields

`guard.check()` returns a `GuardResult` with:

- **`blocked`** — `True` for `block` and `require_approval`
- **`allowed`** — `True` only when no rule matched
- **`action`** — `"block"`, `"suggest"`, `"rewrite"`, `"ask"`, `"require_approval"`, or `None`
- **`suggestion`** — message from `suggest`, replacement from `rewrite`, or reason from `ask`
- **`rule`** — the DSL rule that matched, or `None`
- **`context`** — the context dict you passed in

## Gating on the result

**`not blocked` is not the same as allowed.** `suggest`, `rewrite` and `ask`
all return `blocked=False` **and** `allowed=False` — a middle state that means
"a rule matched and wants something from you". The obvious gate therefore lets
them through:

```python
# WRONG — lets suggest/rewrite/ask run unchallenged
if result.blocked:
    raise SystemExit("refused")
run(cmd)
```

Gate on `allowed` instead, and branch on `action` for the middle states:

```python
result = guard.check({"command": cmd})

if result.allowed:
    run(cmd)                                  # no rule matched
elif result.action == "block":
    raise SystemExit(f"refused: {result.rule}")
elif result.action == "rewrite":
    run(result.suggestion)                    # the replacement command
elif result.action in ("ask", "require_approval"):
    if confirm(result.suggestion):            # the reason, for the human
        run(cmd)
elif result.action == "suggest":
    print(f"warning: {result.suggestion}")
    run(cmd)                                  # advisory only
```

If you only want a hard stop and do not care about the middle states, the
one-liner is `if not result.allowed:` — never `if result.blocked:`.

`@guard.wrap` raises `GuardError` for `block` and `require_approval` only. It
deliberately does **not** raise on `suggest`, `rewrite` or `ask`, because those
actions carry information for the caller rather than a refusal — handle them on
the `check()` result.

## Presets

Each preset reads specific context keys. A `check()` whose context supplies
none of them cannot be evaluated: the result carries
`unmatched_context=True` and emits a `RuntimeWarning`, because an unmatched
key is unknown rather than safe.

```python
from savants_guard import production_safety, spend_limit, business_hours, deploy_safety

# blocks delete/terminate/drop/remove in production
# reads: action, and the environment as either `environment` or `env`
guard = production_safety()
guard.check({"action": "delete_database", "environment": "production"}).blocked  # True
guard.check({"action": "delete_database", "env": "production"}).blocked          # True

# A check the guard could not DECIDE is reported, not silently allowed. If the
# context affirms part of a blocking rule and is silent on the rest, the result
# carries `partial_matches` and emits a RuntimeWarning naming the missing keys:
result = guard.check({"action": "delete_database"})   # no environment supplied
result.blocked              # False -- the rule genuinely cannot fire
result.unmatched_context    # True  -- but this allow is UNANSWERABLE, not safe
result.partial_matches      # [{"rule": "...", "missing_keys": ["environment"]}]

# requires approval over the threshold
# reads: any of amount, spend, cost
guard = spend_limit(100)
guard.check({"amount": 500}).blocked                                            # True

# blocks actions on Saturday/Sunday
# reads: day_of_week
guard = business_hours()
guard.check({"day_of_week": "Saturday"}).blocked                                # True

# blocks risky Friday deploys
# reads: action, day_of_week, risk_score, test_pass_rate, skip_final_snapshot
guard = deploy_safety()
```

## Wrap decorator

Protect functions with `@guard.wrap` — raises `GuardError` when blocked:

```python
from savants_guard import create_guard, GuardError

guard = create_guard(["when action contains 'delete' then block"])

@guard.wrap
def dangerous_action(**kwargs):
    return "executed"

try:
    dangerous_action(action="delete_db")
except GuardError as e:
    print(e.rule)          # "when action contains 'delete' then block"
    print(e.guard_action)  # "block"
```

## Runtime rule management

```python
guard = create_guard([])

guard.add_rule("when action contains 'delete' then block")
print(guard.list_rules())  # ["when action contains 'delete' then block"]

guard.check({"action": "delete"})
guard.check({"action": "read"})
print(guard.get_log())     # [{timestamp, context, result}, ...]
```

## Rule evaluation

**First match wins.** Rules evaluate in order. Put softer rules before harder ones:

```python
guard = create_guard([
    "when action eq 'deploy' then suggest 'Use staging first'",  # fires first
    "when action eq 'deploy' then block",                        # never reached
])
```

## DSL operators

`eq`, `neq`, `gt`, `gte`, `lt`, `lte`, `contains`, `not_contains`, `starts_with`, `ends_with`, `matches`, `in`, `not_in`, `is_true`, `is_false`, `is_empty`, `is_not_empty`

Combine with `and` / `or`:

```python
"when action contains 'delete' and environment eq 'production' then block"
"when environment eq 'staging' or environment eq 'development' then allow"
```

`then allow` short-circuits: a matching allow rule ends evaluation, so an
allowlist rule placed **before** a block exempts what it matches. Put it
after, and the block wins — order is the whole mechanism.

Unknown actions are refused rather than parsed into silence. `then blokc`
raises `ValueError` naming the typo instead of producing a rule that can
never fire, and a malformed rule raises instead of being dropped — a guard
must never come back with fewer rules than you wrote. Comments (`#`, `//`)
and blank lines are skipped, not errors.

```python
create_guard(["when action eq 'x' then blokc"])
# ValueError: ... uses unknown action 'blokc'. Known actions: ['allow', ...]
```

## Framework integrations

Same guard, any framework. Install the framework, import the integration:

### LangChain

```python
from savants_guard import create_guard
from savants_guard.integrations import langchain_callback

guard = create_guard(["when action contains 'delete' then block"])
handler = langchain_callback(guard)

# Add to any LangChain agent
agent.invoke(input, config={"callbacks": [handler]})
```

### CrewAI

```python
from savants_guard import create_guard
from savants_guard.integrations import crewai_hook

guard = create_guard(["when action contains 'delete' then block"])
crewai_hook(guard)  # registers globally as @before_tool_call
```

### OpenAI Agents SDK

```python
from savants_guard import create_guard
from savants_guard.integrations import openai_tool_guardrail

guard = create_guard(["when action contains 'delete' then block"])
guardrail = openai_tool_guardrail(guard)

@function_tool(tool_input_guardrails=[guardrail])
def delete_user(user_id: str) -> str:
    ...
```

No framework dependency required. Integrations import the framework only when called. If the framework isn't installed, you get a clear error message.

## Links

- [Documentation](https://savants.dev/docs/getting-started)
- [GitHub](https://github.com/savants/savants)
- [PyPI](https://pypi.org/project/savants-guard/)

## License

MIT
