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.
Files changed (37) hide show
  1. fluffy_guard-0.1.0/.gitignore +9 -0
  2. fluffy_guard-0.1.0/LICENSE +21 -0
  3. fluffy_guard-0.1.0/PKG-INFO +284 -0
  4. fluffy_guard-0.1.0/README.md +237 -0
  5. fluffy_guard-0.1.0/migrations/0001_init.sql +48 -0
  6. fluffy_guard-0.1.0/migrations/0002_spend_ledger_index.sql +5 -0
  7. fluffy_guard-0.1.0/migrations/0003_confirm_state.sql +15 -0
  8. fluffy_guard-0.1.0/migrations/0004_permission_grants.sql +18 -0
  9. fluffy_guard-0.1.0/pyproject.toml +74 -0
  10. fluffy_guard-0.1.0/src/fluffy/__init__.py +48 -0
  11. fluffy_guard-0.1.0/src/fluffy/adapters/__init__.py +1 -0
  12. fluffy_guard-0.1.0/src/fluffy/adapters/langchain.py +84 -0
  13. fluffy_guard-0.1.0/src/fluffy/audit.py +110 -0
  14. fluffy_guard-0.1.0/src/fluffy/cli.py +87 -0
  15. fluffy_guard-0.1.0/src/fluffy/confirm.py +383 -0
  16. fluffy_guard-0.1.0/src/fluffy/context.py +80 -0
  17. fluffy_guard-0.1.0/src/fluffy/db.py +91 -0
  18. fluffy_guard-0.1.0/src/fluffy/exceptions.py +157 -0
  19. fluffy_guard-0.1.0/src/fluffy/guard.py +299 -0
  20. fluffy_guard-0.1.0/src/fluffy/permissions.py +467 -0
  21. fluffy_guard-0.1.0/src/fluffy/redact.py +205 -0
  22. fluffy_guard-0.1.0/src/fluffy/secrets.py +150 -0
  23. fluffy_guard-0.1.0/src/fluffy/spend.py +346 -0
  24. fluffy_guard-0.1.0/tests/bench/test_benchmarks.py +129 -0
  25. fluffy_guard-0.1.0/tests/conftest.py +160 -0
  26. fluffy_guard-0.1.0/tests/e2e/flows.py +182 -0
  27. fluffy_guard-0.1.0/tests/e2e/test_spec_acceptance.py +105 -0
  28. fluffy_guard-0.1.0/tests/test_audit.py +54 -0
  29. fluffy_guard-0.1.0/tests/test_cli.py +86 -0
  30. fluffy_guard-0.1.0/tests/test_confirm.py +369 -0
  31. fluffy_guard-0.1.0/tests/test_db.py +67 -0
  32. fluffy_guard-0.1.0/tests/test_guard.py +181 -0
  33. fluffy_guard-0.1.0/tests/test_langchain_adapter.py +151 -0
  34. fluffy_guard-0.1.0/tests/test_permissions.py +400 -0
  35. fluffy_guard-0.1.0/tests/test_redact.py +169 -0
  36. fluffy_guard-0.1.0/tests/test_secrets.py +79 -0
  37. fluffy_guard-0.1.0/tests/test_spend.py +315 -0
@@ -0,0 +1,9 @@
1
+ /fluffy/
2
+ __pycache__/
3
+ *.py[cod]
4
+ .venv/
5
+ *.egg-info/
6
+ dist/
7
+ .mypy_cache/
8
+ .ruff_cache/
9
+ .pytest_cache/
@@ -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;