fluffy-guard 0.1.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.
- fluffy_guard-0.1.0/.gitignore +9 -0
- fluffy_guard-0.1.0/LICENSE +21 -0
- fluffy_guard-0.1.0/PKG-INFO +284 -0
- fluffy_guard-0.1.0/README.md +237 -0
- fluffy_guard-0.1.0/migrations/0001_init.sql +48 -0
- fluffy_guard-0.1.0/migrations/0002_spend_ledger_index.sql +5 -0
- fluffy_guard-0.1.0/migrations/0003_confirm_state.sql +15 -0
- fluffy_guard-0.1.0/migrations/0004_permission_grants.sql +18 -0
- fluffy_guard-0.1.0/pyproject.toml +74 -0
- fluffy_guard-0.1.0/src/fluffy/__init__.py +48 -0
- fluffy_guard-0.1.0/src/fluffy/adapters/__init__.py +1 -0
- fluffy_guard-0.1.0/src/fluffy/adapters/langchain.py +84 -0
- fluffy_guard-0.1.0/src/fluffy/audit.py +110 -0
- fluffy_guard-0.1.0/src/fluffy/cli.py +87 -0
- fluffy_guard-0.1.0/src/fluffy/confirm.py +383 -0
- fluffy_guard-0.1.0/src/fluffy/context.py +80 -0
- fluffy_guard-0.1.0/src/fluffy/db.py +91 -0
- fluffy_guard-0.1.0/src/fluffy/exceptions.py +157 -0
- fluffy_guard-0.1.0/src/fluffy/guard.py +299 -0
- fluffy_guard-0.1.0/src/fluffy/permissions.py +467 -0
- fluffy_guard-0.1.0/src/fluffy/redact.py +205 -0
- fluffy_guard-0.1.0/src/fluffy/secrets.py +150 -0
- fluffy_guard-0.1.0/src/fluffy/spend.py +346 -0
- fluffy_guard-0.1.0/tests/bench/test_benchmarks.py +129 -0
- fluffy_guard-0.1.0/tests/conftest.py +160 -0
- fluffy_guard-0.1.0/tests/e2e/flows.py +182 -0
- fluffy_guard-0.1.0/tests/e2e/test_spec_acceptance.py +105 -0
- fluffy_guard-0.1.0/tests/test_audit.py +54 -0
- fluffy_guard-0.1.0/tests/test_cli.py +86 -0
- fluffy_guard-0.1.0/tests/test_confirm.py +369 -0
- fluffy_guard-0.1.0/tests/test_db.py +67 -0
- fluffy_guard-0.1.0/tests/test_guard.py +181 -0
- fluffy_guard-0.1.0/tests/test_langchain_adapter.py +151 -0
- fluffy_guard-0.1.0/tests/test_permissions.py +400 -0
- fluffy_guard-0.1.0/tests/test_redact.py +169 -0
- fluffy_guard-0.1.0/tests/test_secrets.py +79 -0
- fluffy_guard-0.1.0/tests/test_spend.py +315 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 fluffy contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: fluffy-guard
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A drop-in guard layer for autonomous agents: secret redaction, hard spend caps, typed confirmation for destructive actions, and in-conversation permission changes.
|
|
5
|
+
Author: fluffy contributors
|
|
6
|
+
License: MIT License
|
|
7
|
+
|
|
8
|
+
Copyright (c) 2026 fluffy contributors
|
|
9
|
+
|
|
10
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
11
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
12
|
+
in the Software without restriction, including without limitation the rights
|
|
13
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
14
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
15
|
+
furnished to do so, subject to the following conditions:
|
|
16
|
+
|
|
17
|
+
The above copyright notice and this permission notice shall be included in all
|
|
18
|
+
copies or substantial portions of the Software.
|
|
19
|
+
|
|
20
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
21
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
22
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
23
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
24
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
25
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
26
|
+
SOFTWARE.
|
|
27
|
+
License-File: LICENSE
|
|
28
|
+
Classifier: Development Status :: 3 - Alpha
|
|
29
|
+
Classifier: Intended Audience :: Developers
|
|
30
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
31
|
+
Classifier: Programming Language :: Python :: 3
|
|
32
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
33
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
34
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
35
|
+
Classifier: Typing :: Typed
|
|
36
|
+
Requires-Python: >=3.11
|
|
37
|
+
Provides-Extra: dev
|
|
38
|
+
Requires-Dist: langchain<1.0,>=0.3; extra == 'dev'
|
|
39
|
+
Requires-Dist: mypy; extra == 'dev'
|
|
40
|
+
Requires-Dist: pytest; extra == 'dev'
|
|
41
|
+
Requires-Dist: pytest-asyncio; extra == 'dev'
|
|
42
|
+
Requires-Dist: pytest-benchmark; extra == 'dev'
|
|
43
|
+
Requires-Dist: ruff; extra == 'dev'
|
|
44
|
+
Provides-Extra: langchain
|
|
45
|
+
Requires-Dist: langchain-core<2,>=0.3; extra == 'langchain'
|
|
46
|
+
Description-Content-Type: text/markdown
|
|
47
|
+
|
|
48
|
+
# fluffy
|
|
49
|
+
|
|
50
|
+
A drop-in guard layer for autonomous agents: secret redaction, hard spend
|
|
51
|
+
caps, typed confirmation for destructive actions, and in-conversation
|
|
52
|
+
permission changes — at well under 20 ms per guarded call.
|
|
53
|
+
|
|
54
|
+
fluffy is a library, not a proxy: your host framework imports it and wraps
|
|
55
|
+
tool callables. Untagged tools pay ~5 µs of overhead and touch no I/O; guarded
|
|
56
|
+
tools go through SQLite-backed, crash-safe checks. Zero runtime dependencies
|
|
57
|
+
(stdlib only). MIT licensed.
|
|
58
|
+
|
|
59
|
+
## Install
|
|
60
|
+
|
|
61
|
+
```sh
|
|
62
|
+
pip install fluffy-guard # core, stdlib-only
|
|
63
|
+
pip install 'fluffy-guard[langchain]' # + the LangChain adapter
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
The distribution is named `fluffy-guard` (the `fluffy` name on PyPI was
|
|
67
|
+
taken); you still `import fluffy` in code.
|
|
68
|
+
|
|
69
|
+
## 5-minute quickstart
|
|
70
|
+
|
|
71
|
+
One `Guard` per agent process. Wrap one tool of each kind and watch a block
|
|
72
|
+
happen:
|
|
73
|
+
|
|
74
|
+
```python
|
|
75
|
+
import fluffy
|
|
76
|
+
from fluffy import (
|
|
77
|
+
DestructiveSpec, Guard, PermissionRequest, SpendPolicy, SpendSpec, ToolMeta,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
guard = Guard(db_path="~/.fluffy/state.db") # opens SQLite, installs redaction
|
|
81
|
+
|
|
82
|
+
# --- 1. Secrets: agents only ever see handles -------------------------------
|
|
83
|
+
guard.secret_store.put("stripe_key", "sk_live_...real value...")
|
|
84
|
+
|
|
85
|
+
def call_api(key: str) -> str:
|
|
86
|
+
return f"authenticated with {key}"
|
|
87
|
+
|
|
88
|
+
api = guard.wrap(call_api, meta=ToolMeta(name="api.call"))
|
|
89
|
+
api("{{secret:stripe_key}}")
|
|
90
|
+
# the tool received the real value; the result, all logs, and every audit row
|
|
91
|
+
# only ever contain "{{secret:stripe_key}}" (plus Luhn-card / API-key /
|
|
92
|
+
# high-entropy pattern scrubbing via fluffy.redact()).
|
|
93
|
+
|
|
94
|
+
# --- 2. Spend caps: $25/day default, atomic reserve-then-settle -------------
|
|
95
|
+
guard.add_spend_policy(SpendPolicy(card_id="ops")) # $25 per-use, $25/day
|
|
96
|
+
|
|
97
|
+
charge = guard.wrap(
|
|
98
|
+
lambda *, amount_cents: f"charged {amount_cents}",
|
|
99
|
+
meta=ToolMeta(
|
|
100
|
+
name="stripe.charge",
|
|
101
|
+
tags={"spend"},
|
|
102
|
+
spend=SpendSpec(card_id="ops", amount_from=lambda a, k: k["amount_cents"]),
|
|
103
|
+
),
|
|
104
|
+
)
|
|
105
|
+
charge(amount_cents=1000) # $10: fine
|
|
106
|
+
charge(amount_cents=5000) # raises fluffy.SpendLimitExceeded:
|
|
107
|
+
# "Blocked: $50.00 requested, per-use cap $25.00, $10.00 already spent today;
|
|
108
|
+
# $15.00 remaining." <- relay this string to the agent verbatim
|
|
109
|
+
|
|
110
|
+
# --- 3. Destructive actions: typed confirmation over the human channel ------
|
|
111
|
+
delete = guard.wrap(
|
|
112
|
+
lambda name: f"deleted {name}",
|
|
113
|
+
meta=ToolMeta(
|
|
114
|
+
name="delete_project",
|
|
115
|
+
tags={"destructive"},
|
|
116
|
+
destructive=DestructiveSpec(
|
|
117
|
+
resource_kind="project",
|
|
118
|
+
summary_from=lambda a, k: f"This deletes the project {a[0]!r}. "
|
|
119
|
+
"This cannot be undone.",
|
|
120
|
+
),
|
|
121
|
+
),
|
|
122
|
+
)
|
|
123
|
+
try:
|
|
124
|
+
delete("my-project")
|
|
125
|
+
except fluffy.ConfirmationRequired as exc:
|
|
126
|
+
# Show exc.summary to the human. The phrase is DELIBERATELY not in the
|
|
127
|
+
# exception the agent sees — the HOST fetches it out-of-band:
|
|
128
|
+
phrase = guard.challenge_phrase(exc.challenge_id) # e.g. "DELETE 42"
|
|
129
|
+
# ...human types it into YOUR ui, not the agent's chat...
|
|
130
|
+
assert guard.confirm(exc.challenge_id, typed_phrase_from_human)
|
|
131
|
+
delete("my-project", fluffy_challenge_id=exc.challenge_id) # runs once
|
|
132
|
+
|
|
133
|
+
# --- 4. Permissions: raise a cap mid-conversation ---------------------------
|
|
134
|
+
decision = guard.request_permission_sync(
|
|
135
|
+
PermissionRequest(kind="budget_increase", subject="ops", value=1500,
|
|
136
|
+
duration="once", rationale="the gadget costs $40")
|
|
137
|
+
)
|
|
138
|
+
if decision.approved:
|
|
139
|
+
charge(amount_cents=4000) # succeeds exactly once; the grant is consumed
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
Every denial inherits from `fluffy.Blocked`, so a host catches one type; every
|
|
143
|
+
message is pre-formatted plain English the agent can relay verbatim.
|
|
144
|
+
|
|
145
|
+
## LangChain adapter
|
|
146
|
+
|
|
147
|
+
```python
|
|
148
|
+
from fluffy.adapters.langchain import guard_tools
|
|
149
|
+
|
|
150
|
+
tools = guard_tools(lc_tools, guard, metas={"buy_gadget": ToolMeta(...)})
|
|
151
|
+
# Blocked -> langchain_core.tools.ToolException(str(e)); set
|
|
152
|
+
# handle_tool_error=True on the tool and the agent loop sees the block as a
|
|
153
|
+
# normal observation it can relay to the user.
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
Tools without a `metas` entry are wrapped untagged (secret resolution +
|
|
157
|
+
redaction only, no I/O).
|
|
158
|
+
|
|
159
|
+
## Per-guard configuration reference
|
|
160
|
+
|
|
161
|
+
### Guard
|
|
162
|
+
|
|
163
|
+
| Parameter | Default | Meaning |
|
|
164
|
+
|---|---|---|
|
|
165
|
+
| `db_path` | `~/.fluffy/state.db` | SQLite state (WAL, `busy_timeout=5000`); migrations run at init |
|
|
166
|
+
| `secret_store` | `MemorySecretStore()` | anything implementing the `SecretStore` protocol (`put/resolve/known_values/items`) |
|
|
167
|
+
| `approvers` | `[ConsoleApprover()]` | the permission approver chain, first non-abstain wins |
|
|
168
|
+
|
|
169
|
+
`ToolMeta(name, tags, spend, destructive)` — tags in `{"spend",
|
|
170
|
+
"destructive", "restricted"}` route a call through the guard pipeline; any
|
|
171
|
+
other call takes the no-I/O fast path.
|
|
172
|
+
|
|
173
|
+
### Secrets & redaction (D4)
|
|
174
|
+
|
|
175
|
+
- Handles look like `{{secret:name}}`; values are substituted at the last
|
|
176
|
+
moment before execution and masked back on the way out (raw, URL-encoded,
|
|
177
|
+
and base64 forms).
|
|
178
|
+
- Pattern scrub: Luhn-valid 13–19-digit card numbers, `sk-…`/`sk_live_…`/
|
|
179
|
+
`ghp_…`/`AKIA…` keys, and 32+-char tokens with ≥ 4.5 bits/char Shannon
|
|
180
|
+
entropy.
|
|
181
|
+
- `fluffy.redact(text)` for transcripts; a `logging.Filter` covers the root
|
|
182
|
+
logger; the audit writer redacts unconditionally.
|
|
183
|
+
|
|
184
|
+
### Spend guard (D5)
|
|
185
|
+
|
|
186
|
+
| `SpendPolicy` field | Default | Meaning |
|
|
187
|
+
|---|---|---|
|
|
188
|
+
| `card_id` | — | ledger key; referenced by `SpendSpec(card_id=...)` |
|
|
189
|
+
| `per_use_cap_cents` | `2500` | hard cap per call |
|
|
190
|
+
| `daily_cap_cents` | `2500` | hard cap per calendar day |
|
|
191
|
+
| `tz` | `America/Los_Angeles` | timezone that defines "day" (computed at query time; no reset job) |
|
|
192
|
+
|
|
193
|
+
Two-phase and atomic: the cap check and the reservation share one
|
|
194
|
+
`BEGIN IMMEDIATE` transaction, so concurrent over-cap racing is impossible.
|
|
195
|
+
Reservations orphaned by a crash stop counting after 15 minutes. All money is
|
|
196
|
+
integer cents.
|
|
197
|
+
|
|
198
|
+
### Confirmation gate (D6)
|
|
199
|
+
|
|
200
|
+
- Declared, not inferred: `tags={"destructive"}` +
|
|
201
|
+
`DestructiveSpec(resource_kind, summary_from)`. Safety net: a tool name
|
|
202
|
+
matching `delete|drop|destroy|remove|truncate|migrate` without a spec fails
|
|
203
|
+
at `wrap()` time (`GuardConfigError`) — declare or whitelist.
|
|
204
|
+
- Challenges: phrase `DELETE <nn>` with a fresh 2-digit nonce, 5-minute
|
|
205
|
+
expiry, single-use, voided after 3 wrong attempts.
|
|
206
|
+
- `guard.confirm(id, phrase, remember=True)` whitelists the
|
|
207
|
+
(tool, resource_kind) pair; future matches skip the gate and audit as
|
|
208
|
+
`whitelisted`.
|
|
209
|
+
- The phrase travels over the human channel via
|
|
210
|
+
`guard.challenge_phrase(challenge_id)` — it is intentionally **not** in the
|
|
211
|
+
`ConfirmationRequired` payload the agent sees (see threat model).
|
|
212
|
+
|
|
213
|
+
### Permission broker (D7)
|
|
214
|
+
|
|
215
|
+
- Two request kinds only: `budget_increase` (value = increase delta in cents;
|
|
216
|
+
spend caps become base + active grants, `once` grants consumed atomically by
|
|
217
|
+
the spend that uses them) and `access_grant` (tools tagged `"restricted"`
|
|
218
|
+
deny with `PermissionDenied` unless a live grant for the tool name exists).
|
|
219
|
+
- Approvers implement one async method `decide(req) -> Decision | None`
|
|
220
|
+
(`None` = abstain). Ships with `ConsoleApprover` (default) and
|
|
221
|
+
`GuardianBot(auto_approve_under_cents=100)` — off unless you add it to the
|
|
222
|
+
chain. A Slack/web approver is a one-method class.
|
|
223
|
+
- Grant lifetime is the approver's call (`Decision.expires_in_s`), never the
|
|
224
|
+
requesting agent's.
|
|
225
|
+
|
|
226
|
+
### Audit
|
|
227
|
+
|
|
228
|
+
One event vocabulary across all four guards — see
|
|
229
|
+
[docs/events.md](docs/events.md). Inspect with `guard.audit_tail(n)` or:
|
|
230
|
+
|
|
231
|
+
```sh
|
|
232
|
+
fluffy audit tail -n 50
|
|
233
|
+
fluffy audit grep stripe.charge
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
## Performance
|
|
237
|
+
|
|
238
|
+
Budgets are CI-enforced on every PR (`tests/bench/`, D8): untagged overhead
|
|
239
|
+
< 1 ms, spend-guarded call < 20 ms p95, 100-step mixed job < 0.5 s total added
|
|
240
|
+
wall time. Measured locally (Apple Silicon, Python 3.12, WAL SQLite):
|
|
241
|
+
|
|
242
|
+
| Benchmark | Budget | Measured |
|
|
243
|
+
|---|---|---|
|
|
244
|
+
| untagged wrapped call overhead | < 1 ms | ~0.005 ms |
|
|
245
|
+
| spend-guarded call | < 20 ms p95 | ~0.09 ms mean, < 1.2 ms max |
|
|
246
|
+
| 100-step mixed job (60 untagged / 30 spend / 10 whitelisted) | < 0.5 s | ~0.003 s |
|
|
247
|
+
|
|
248
|
+
The fast path is a set intersection: a call with no guard tags never touches
|
|
249
|
+
SQLite.
|
|
250
|
+
|
|
251
|
+
## Threat model — what fluffy does NOT defend against
|
|
252
|
+
|
|
253
|
+
Honesty section. fluffy is a guard layer inside your process, not a sandbox:
|
|
254
|
+
|
|
255
|
+
- **A malicious or compromised host process.** fluffy shares the interpreter
|
|
256
|
+
with the host; anything with code execution can call the tool functions
|
|
257
|
+
directly, read the secret store's memory, or edit the SQLite state.
|
|
258
|
+
- **A prompt-injected agent confirming its own destructive actions.** That is
|
|
259
|
+
why the confirmation phrase is *not* in the `ConfirmationRequired` exception:
|
|
260
|
+
the agent cannot see it. Hosts must fetch it with
|
|
261
|
+
`guard.challenge_phrase(id)` and collect the typed phrase from the human
|
|
262
|
+
over a channel the agent does not write to. If you paste the phrase into the
|
|
263
|
+
agent's own conversation, you have reopened the hole.
|
|
264
|
+
- **Side channels outside wrapped tools.** Only calls that go through
|
|
265
|
+
`guard.wrap()` are guarded. An agent with raw `subprocess`, network, or
|
|
266
|
+
filesystem access can spend, delete, and leak without fluffy ever seeing it.
|
|
267
|
+
Wrap every tool; give the agent nothing unwrapped.
|
|
268
|
+
- Redaction is best-effort defense in depth: known values (and their encoded
|
|
269
|
+
forms) plus common secret shapes. A secret transformed in ways fluffy cannot
|
|
270
|
+
recognize (e.g. rot13) can still leak.
|
|
271
|
+
|
|
272
|
+
## Development
|
|
273
|
+
|
|
274
|
+
```sh
|
|
275
|
+
uv venv && uv pip install -e '.[dev]'
|
|
276
|
+
uv run pytest -q # fast suite (e2e excluded)
|
|
277
|
+
uv run pytest tests/bench # D8 budget gate
|
|
278
|
+
uv run pytest -m e2e -q # spec acceptance against the built wheel
|
|
279
|
+
uv run ruff check . && uv run ruff format --check .
|
|
280
|
+
uv run mypy --strict src
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
See [CONTRIBUTING.md](CONTRIBUTING.md). Release notes:
|
|
284
|
+
[docs/RELEASE_NOTES_0.1.0.md](docs/RELEASE_NOTES_0.1.0.md).
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
# fluffy
|
|
2
|
+
|
|
3
|
+
A drop-in guard layer for autonomous agents: secret redaction, hard spend
|
|
4
|
+
caps, typed confirmation for destructive actions, and in-conversation
|
|
5
|
+
permission changes — at well under 20 ms per guarded call.
|
|
6
|
+
|
|
7
|
+
fluffy is a library, not a proxy: your host framework imports it and wraps
|
|
8
|
+
tool callables. Untagged tools pay ~5 µs of overhead and touch no I/O; guarded
|
|
9
|
+
tools go through SQLite-backed, crash-safe checks. Zero runtime dependencies
|
|
10
|
+
(stdlib only). MIT licensed.
|
|
11
|
+
|
|
12
|
+
## Install
|
|
13
|
+
|
|
14
|
+
```sh
|
|
15
|
+
pip install fluffy-guard # core, stdlib-only
|
|
16
|
+
pip install 'fluffy-guard[langchain]' # + the LangChain adapter
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
The distribution is named `fluffy-guard` (the `fluffy` name on PyPI was
|
|
20
|
+
taken); you still `import fluffy` in code.
|
|
21
|
+
|
|
22
|
+
## 5-minute quickstart
|
|
23
|
+
|
|
24
|
+
One `Guard` per agent process. Wrap one tool of each kind and watch a block
|
|
25
|
+
happen:
|
|
26
|
+
|
|
27
|
+
```python
|
|
28
|
+
import fluffy
|
|
29
|
+
from fluffy import (
|
|
30
|
+
DestructiveSpec, Guard, PermissionRequest, SpendPolicy, SpendSpec, ToolMeta,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
guard = Guard(db_path="~/.fluffy/state.db") # opens SQLite, installs redaction
|
|
34
|
+
|
|
35
|
+
# --- 1. Secrets: agents only ever see handles -------------------------------
|
|
36
|
+
guard.secret_store.put("stripe_key", "sk_live_...real value...")
|
|
37
|
+
|
|
38
|
+
def call_api(key: str) -> str:
|
|
39
|
+
return f"authenticated with {key}"
|
|
40
|
+
|
|
41
|
+
api = guard.wrap(call_api, meta=ToolMeta(name="api.call"))
|
|
42
|
+
api("{{secret:stripe_key}}")
|
|
43
|
+
# the tool received the real value; the result, all logs, and every audit row
|
|
44
|
+
# only ever contain "{{secret:stripe_key}}" (plus Luhn-card / API-key /
|
|
45
|
+
# high-entropy pattern scrubbing via fluffy.redact()).
|
|
46
|
+
|
|
47
|
+
# --- 2. Spend caps: $25/day default, atomic reserve-then-settle -------------
|
|
48
|
+
guard.add_spend_policy(SpendPolicy(card_id="ops")) # $25 per-use, $25/day
|
|
49
|
+
|
|
50
|
+
charge = guard.wrap(
|
|
51
|
+
lambda *, amount_cents: f"charged {amount_cents}",
|
|
52
|
+
meta=ToolMeta(
|
|
53
|
+
name="stripe.charge",
|
|
54
|
+
tags={"spend"},
|
|
55
|
+
spend=SpendSpec(card_id="ops", amount_from=lambda a, k: k["amount_cents"]),
|
|
56
|
+
),
|
|
57
|
+
)
|
|
58
|
+
charge(amount_cents=1000) # $10: fine
|
|
59
|
+
charge(amount_cents=5000) # raises fluffy.SpendLimitExceeded:
|
|
60
|
+
# "Blocked: $50.00 requested, per-use cap $25.00, $10.00 already spent today;
|
|
61
|
+
# $15.00 remaining." <- relay this string to the agent verbatim
|
|
62
|
+
|
|
63
|
+
# --- 3. Destructive actions: typed confirmation over the human channel ------
|
|
64
|
+
delete = guard.wrap(
|
|
65
|
+
lambda name: f"deleted {name}",
|
|
66
|
+
meta=ToolMeta(
|
|
67
|
+
name="delete_project",
|
|
68
|
+
tags={"destructive"},
|
|
69
|
+
destructive=DestructiveSpec(
|
|
70
|
+
resource_kind="project",
|
|
71
|
+
summary_from=lambda a, k: f"This deletes the project {a[0]!r}. "
|
|
72
|
+
"This cannot be undone.",
|
|
73
|
+
),
|
|
74
|
+
),
|
|
75
|
+
)
|
|
76
|
+
try:
|
|
77
|
+
delete("my-project")
|
|
78
|
+
except fluffy.ConfirmationRequired as exc:
|
|
79
|
+
# Show exc.summary to the human. The phrase is DELIBERATELY not in the
|
|
80
|
+
# exception the agent sees — the HOST fetches it out-of-band:
|
|
81
|
+
phrase = guard.challenge_phrase(exc.challenge_id) # e.g. "DELETE 42"
|
|
82
|
+
# ...human types it into YOUR ui, not the agent's chat...
|
|
83
|
+
assert guard.confirm(exc.challenge_id, typed_phrase_from_human)
|
|
84
|
+
delete("my-project", fluffy_challenge_id=exc.challenge_id) # runs once
|
|
85
|
+
|
|
86
|
+
# --- 4. Permissions: raise a cap mid-conversation ---------------------------
|
|
87
|
+
decision = guard.request_permission_sync(
|
|
88
|
+
PermissionRequest(kind="budget_increase", subject="ops", value=1500,
|
|
89
|
+
duration="once", rationale="the gadget costs $40")
|
|
90
|
+
)
|
|
91
|
+
if decision.approved:
|
|
92
|
+
charge(amount_cents=4000) # succeeds exactly once; the grant is consumed
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Every denial inherits from `fluffy.Blocked`, so a host catches one type; every
|
|
96
|
+
message is pre-formatted plain English the agent can relay verbatim.
|
|
97
|
+
|
|
98
|
+
## LangChain adapter
|
|
99
|
+
|
|
100
|
+
```python
|
|
101
|
+
from fluffy.adapters.langchain import guard_tools
|
|
102
|
+
|
|
103
|
+
tools = guard_tools(lc_tools, guard, metas={"buy_gadget": ToolMeta(...)})
|
|
104
|
+
# Blocked -> langchain_core.tools.ToolException(str(e)); set
|
|
105
|
+
# handle_tool_error=True on the tool and the agent loop sees the block as a
|
|
106
|
+
# normal observation it can relay to the user.
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
Tools without a `metas` entry are wrapped untagged (secret resolution +
|
|
110
|
+
redaction only, no I/O).
|
|
111
|
+
|
|
112
|
+
## Per-guard configuration reference
|
|
113
|
+
|
|
114
|
+
### Guard
|
|
115
|
+
|
|
116
|
+
| Parameter | Default | Meaning |
|
|
117
|
+
|---|---|---|
|
|
118
|
+
| `db_path` | `~/.fluffy/state.db` | SQLite state (WAL, `busy_timeout=5000`); migrations run at init |
|
|
119
|
+
| `secret_store` | `MemorySecretStore()` | anything implementing the `SecretStore` protocol (`put/resolve/known_values/items`) |
|
|
120
|
+
| `approvers` | `[ConsoleApprover()]` | the permission approver chain, first non-abstain wins |
|
|
121
|
+
|
|
122
|
+
`ToolMeta(name, tags, spend, destructive)` — tags in `{"spend",
|
|
123
|
+
"destructive", "restricted"}` route a call through the guard pipeline; any
|
|
124
|
+
other call takes the no-I/O fast path.
|
|
125
|
+
|
|
126
|
+
### Secrets & redaction (D4)
|
|
127
|
+
|
|
128
|
+
- Handles look like `{{secret:name}}`; values are substituted at the last
|
|
129
|
+
moment before execution and masked back on the way out (raw, URL-encoded,
|
|
130
|
+
and base64 forms).
|
|
131
|
+
- Pattern scrub: Luhn-valid 13–19-digit card numbers, `sk-…`/`sk_live_…`/
|
|
132
|
+
`ghp_…`/`AKIA…` keys, and 32+-char tokens with ≥ 4.5 bits/char Shannon
|
|
133
|
+
entropy.
|
|
134
|
+
- `fluffy.redact(text)` for transcripts; a `logging.Filter` covers the root
|
|
135
|
+
logger; the audit writer redacts unconditionally.
|
|
136
|
+
|
|
137
|
+
### Spend guard (D5)
|
|
138
|
+
|
|
139
|
+
| `SpendPolicy` field | Default | Meaning |
|
|
140
|
+
|---|---|---|
|
|
141
|
+
| `card_id` | — | ledger key; referenced by `SpendSpec(card_id=...)` |
|
|
142
|
+
| `per_use_cap_cents` | `2500` | hard cap per call |
|
|
143
|
+
| `daily_cap_cents` | `2500` | hard cap per calendar day |
|
|
144
|
+
| `tz` | `America/Los_Angeles` | timezone that defines "day" (computed at query time; no reset job) |
|
|
145
|
+
|
|
146
|
+
Two-phase and atomic: the cap check and the reservation share one
|
|
147
|
+
`BEGIN IMMEDIATE` transaction, so concurrent over-cap racing is impossible.
|
|
148
|
+
Reservations orphaned by a crash stop counting after 15 minutes. All money is
|
|
149
|
+
integer cents.
|
|
150
|
+
|
|
151
|
+
### Confirmation gate (D6)
|
|
152
|
+
|
|
153
|
+
- Declared, not inferred: `tags={"destructive"}` +
|
|
154
|
+
`DestructiveSpec(resource_kind, summary_from)`. Safety net: a tool name
|
|
155
|
+
matching `delete|drop|destroy|remove|truncate|migrate` without a spec fails
|
|
156
|
+
at `wrap()` time (`GuardConfigError`) — declare or whitelist.
|
|
157
|
+
- Challenges: phrase `DELETE <nn>` with a fresh 2-digit nonce, 5-minute
|
|
158
|
+
expiry, single-use, voided after 3 wrong attempts.
|
|
159
|
+
- `guard.confirm(id, phrase, remember=True)` whitelists the
|
|
160
|
+
(tool, resource_kind) pair; future matches skip the gate and audit as
|
|
161
|
+
`whitelisted`.
|
|
162
|
+
- The phrase travels over the human channel via
|
|
163
|
+
`guard.challenge_phrase(challenge_id)` — it is intentionally **not** in the
|
|
164
|
+
`ConfirmationRequired` payload the agent sees (see threat model).
|
|
165
|
+
|
|
166
|
+
### Permission broker (D7)
|
|
167
|
+
|
|
168
|
+
- Two request kinds only: `budget_increase` (value = increase delta in cents;
|
|
169
|
+
spend caps become base + active grants, `once` grants consumed atomically by
|
|
170
|
+
the spend that uses them) and `access_grant` (tools tagged `"restricted"`
|
|
171
|
+
deny with `PermissionDenied` unless a live grant for the tool name exists).
|
|
172
|
+
- Approvers implement one async method `decide(req) -> Decision | None`
|
|
173
|
+
(`None` = abstain). Ships with `ConsoleApprover` (default) and
|
|
174
|
+
`GuardianBot(auto_approve_under_cents=100)` — off unless you add it to the
|
|
175
|
+
chain. A Slack/web approver is a one-method class.
|
|
176
|
+
- Grant lifetime is the approver's call (`Decision.expires_in_s`), never the
|
|
177
|
+
requesting agent's.
|
|
178
|
+
|
|
179
|
+
### Audit
|
|
180
|
+
|
|
181
|
+
One event vocabulary across all four guards — see
|
|
182
|
+
[docs/events.md](docs/events.md). Inspect with `guard.audit_tail(n)` or:
|
|
183
|
+
|
|
184
|
+
```sh
|
|
185
|
+
fluffy audit tail -n 50
|
|
186
|
+
fluffy audit grep stripe.charge
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
## Performance
|
|
190
|
+
|
|
191
|
+
Budgets are CI-enforced on every PR (`tests/bench/`, D8): untagged overhead
|
|
192
|
+
< 1 ms, spend-guarded call < 20 ms p95, 100-step mixed job < 0.5 s total added
|
|
193
|
+
wall time. Measured locally (Apple Silicon, Python 3.12, WAL SQLite):
|
|
194
|
+
|
|
195
|
+
| Benchmark | Budget | Measured |
|
|
196
|
+
|---|---|---|
|
|
197
|
+
| untagged wrapped call overhead | < 1 ms | ~0.005 ms |
|
|
198
|
+
| spend-guarded call | < 20 ms p95 | ~0.09 ms mean, < 1.2 ms max |
|
|
199
|
+
| 100-step mixed job (60 untagged / 30 spend / 10 whitelisted) | < 0.5 s | ~0.003 s |
|
|
200
|
+
|
|
201
|
+
The fast path is a set intersection: a call with no guard tags never touches
|
|
202
|
+
SQLite.
|
|
203
|
+
|
|
204
|
+
## Threat model — what fluffy does NOT defend against
|
|
205
|
+
|
|
206
|
+
Honesty section. fluffy is a guard layer inside your process, not a sandbox:
|
|
207
|
+
|
|
208
|
+
- **A malicious or compromised host process.** fluffy shares the interpreter
|
|
209
|
+
with the host; anything with code execution can call the tool functions
|
|
210
|
+
directly, read the secret store's memory, or edit the SQLite state.
|
|
211
|
+
- **A prompt-injected agent confirming its own destructive actions.** That is
|
|
212
|
+
why the confirmation phrase is *not* in the `ConfirmationRequired` exception:
|
|
213
|
+
the agent cannot see it. Hosts must fetch it with
|
|
214
|
+
`guard.challenge_phrase(id)` and collect the typed phrase from the human
|
|
215
|
+
over a channel the agent does not write to. If you paste the phrase into the
|
|
216
|
+
agent's own conversation, you have reopened the hole.
|
|
217
|
+
- **Side channels outside wrapped tools.** Only calls that go through
|
|
218
|
+
`guard.wrap()` are guarded. An agent with raw `subprocess`, network, or
|
|
219
|
+
filesystem access can spend, delete, and leak without fluffy ever seeing it.
|
|
220
|
+
Wrap every tool; give the agent nothing unwrapped.
|
|
221
|
+
- Redaction is best-effort defense in depth: known values (and their encoded
|
|
222
|
+
forms) plus common secret shapes. A secret transformed in ways fluffy cannot
|
|
223
|
+
recognize (e.g. rot13) can still leak.
|
|
224
|
+
|
|
225
|
+
## Development
|
|
226
|
+
|
|
227
|
+
```sh
|
|
228
|
+
uv venv && uv pip install -e '.[dev]'
|
|
229
|
+
uv run pytest -q # fast suite (e2e excluded)
|
|
230
|
+
uv run pytest tests/bench # D8 budget gate
|
|
231
|
+
uv run pytest -m e2e -q # spec acceptance against the built wheel
|
|
232
|
+
uv run ruff check . && uv run ruff format --check .
|
|
233
|
+
uv run mypy --strict src
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
See [CONTRIBUTING.md](CONTRIBUTING.md). Release notes:
|
|
237
|
+
[docs/RELEASE_NOTES_0.1.0.md](docs/RELEASE_NOTES_0.1.0.md).
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
-- 0001_init: full D3 schema. All five tables are owned by FLUF-1 even though
|
|
2
|
+
-- later tickets populate most of them.
|
|
3
|
+
|
|
4
|
+
CREATE TABLE IF NOT EXISTS audit_log (
|
|
5
|
+
id INTEGER PRIMARY KEY,
|
|
6
|
+
call_id TEXT,
|
|
7
|
+
ts TEXT,
|
|
8
|
+
tool TEXT,
|
|
9
|
+
event TEXT,
|
|
10
|
+
decision TEXT,
|
|
11
|
+
detail_json TEXT -- post-redaction, always
|
|
12
|
+
);
|
|
13
|
+
|
|
14
|
+
CREATE TABLE IF NOT EXISTS spend_ledger (
|
|
15
|
+
id INTEGER PRIMARY KEY,
|
|
16
|
+
card_id TEXT,
|
|
17
|
+
ts TEXT,
|
|
18
|
+
amount_cents INTEGER,
|
|
19
|
+
state TEXT CHECK (state IN ('reserved', 'settled', 'released')),
|
|
20
|
+
call_id TEXT
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
CREATE TABLE IF NOT EXISTS confirmations (
|
|
24
|
+
challenge_id TEXT PRIMARY KEY,
|
|
25
|
+
call_id TEXT,
|
|
26
|
+
phrase TEXT,
|
|
27
|
+
summary TEXT,
|
|
28
|
+
created_ts TEXT,
|
|
29
|
+
expires_ts TEXT,
|
|
30
|
+
used INTEGER DEFAULT 0
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
CREATE TABLE IF NOT EXISTS action_whitelist (
|
|
34
|
+
tool TEXT,
|
|
35
|
+
resource_kind TEXT,
|
|
36
|
+
added_ts TEXT,
|
|
37
|
+
PRIMARY KEY (tool, resource_kind)
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
CREATE TABLE IF NOT EXISTS permissions (
|
|
41
|
+
id INTEGER PRIMARY KEY,
|
|
42
|
+
kind TEXT,
|
|
43
|
+
subject TEXT,
|
|
44
|
+
value_json TEXT,
|
|
45
|
+
granted_ts TEXT,
|
|
46
|
+
expires_ts TEXT,
|
|
47
|
+
decider TEXT
|
|
48
|
+
);
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
-- 0002_spend_ledger_index: the daily-sum query runs while SpendInterceptor
|
|
2
|
+
-- holds the BEGIN IMMEDIATE write lock; without an index it full-scans the
|
|
3
|
+
-- ledger under that lock. (card_id, ts) matches the query's WHERE clause.
|
|
4
|
+
|
|
5
|
+
CREATE INDEX idx_spend_ledger_card_ts ON spend_ledger (card_id, ts);
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
-- 0003_confirm_state: FLUF-3 challenge lifecycle columns.
|
|
2
|
+
--
|
|
3
|
+
-- D6 requires an attempt counter (3 strikes voids), a confirmed/voided state,
|
|
4
|
+
-- and "remember" whitelisting keyed by (tool, resource_kind) — but
|
|
5
|
+
-- guard.confirm() only receives a challenge_id, so the challenge row must
|
|
6
|
+
-- carry the tool name and resource kind it was created for. Additive-only
|
|
7
|
+
-- extension of the D3 schema via the D3 migration mechanism.
|
|
8
|
+
|
|
9
|
+
ALTER TABLE confirmations ADD COLUMN tool TEXT;
|
|
10
|
+
ALTER TABLE confirmations ADD COLUMN resource_kind TEXT;
|
|
11
|
+
ALTER TABLE confirmations ADD COLUMN attempts INTEGER NOT NULL DEFAULT 0;
|
|
12
|
+
-- state: 'pending' -> 'confirmed' (correct phrase) -> consumed via used=1.
|
|
13
|
+
-- "Voided" is not a stored state: it is derived from attempts >= 3, so one
|
|
14
|
+
-- fact has one representation.
|
|
15
|
+
ALTER TABLE confirmations ADD COLUMN state TEXT NOT NULL DEFAULT 'pending';
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
-- 0004_permission_grants: FLUF-4 columns the permission broker needs on the
|
|
2
|
+
-- FLUF-1 permissions table.
|
|
3
|
+
--
|
|
4
|
+
-- duration -- 'once' | 'persistent' (D7 request model)
|
|
5
|
+
-- consumed_ts -- NULL while the grant is live; set when a 'once' grant is
|
|
6
|
+
-- consumed (inside the same spend transaction that used it,
|
|
7
|
+
-- or by the first allowed call to a restricted tool)
|
|
8
|
+
--
|
|
9
|
+
-- Active-grant lookups filter on (kind, subject) and always carry the
|
|
10
|
+
-- live-grant predicate, so the index is partial on consumed_ts IS NULL:
|
|
11
|
+
-- consumed once-rows drop out of it instead of accumulating in the range
|
|
12
|
+
-- scan forever.
|
|
13
|
+
|
|
14
|
+
ALTER TABLE permissions ADD COLUMN duration TEXT DEFAULT 'persistent';
|
|
15
|
+
ALTER TABLE permissions ADD COLUMN consumed_ts TEXT;
|
|
16
|
+
|
|
17
|
+
CREATE INDEX idx_permissions_live_kind_subject ON permissions (kind, subject)
|
|
18
|
+
WHERE consumed_ts IS NULL;
|