tide-runtime 0.2.5__tar.gz → 0.2.7__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tide-runtime
3
- Version: 0.2.5
3
+ Version: 0.2.7
4
4
  Summary: Tide runtime policy, approvals, and earned-use ledger for AI agent tools.
5
5
  Project-URL: Repository, https://github.com/rippletideco/rippletide-package
6
6
  Requires-Python: >=3.8
@@ -13,9 +13,22 @@ earned-use logging.
13
13
 
14
14
  ```bash
15
15
  pip install tide-runtime
16
- tide ledger init
16
+ tide init --pack support-refunds --environment prod
17
17
  ```
18
18
 
19
+ `tide init --dry-run --pack <name>` prints the policy before writing it.
20
+ Built-in packs: `baseline`, `support-refunds`, `sql-analyst`, `mcp-baseline`.
21
+
22
+ To have Tide suggest wrappers for an existing Python agent:
23
+
24
+ ```bash
25
+ tide scan --path . --pack support-refunds
26
+ $EDITOR .tide/tide-plan.json
27
+ tide apply --interactive --write-policy
28
+ ```
29
+
30
+ Use `tide apply --yes --write-policy` to accept the reviewed plan non-interactively.
31
+
19
32
  ```python
20
33
  from tide import configure, controlled_tool, record_outcome
21
34
 
@@ -35,3 +48,10 @@ except Exception:
35
48
 
36
49
  Default files live in `.tide/ledger/`. Override with `TIDE_LEDGER_DIR` or
37
50
  `configure(project="...")`.
51
+
52
+ ```bash
53
+ tide approvals
54
+ tide approve <id>
55
+ tide deny <id> --reason "not in scope"
56
+ tide report
57
+ ```
@@ -5,9 +5,22 @@ earned-use logging.
5
5
 
6
6
  ```bash
7
7
  pip install tide-runtime
8
- tide ledger init
8
+ tide init --pack support-refunds --environment prod
9
9
  ```
10
10
 
11
+ `tide init --dry-run --pack <name>` prints the policy before writing it.
12
+ Built-in packs: `baseline`, `support-refunds`, `sql-analyst`, `mcp-baseline`.
13
+
14
+ To have Tide suggest wrappers for an existing Python agent:
15
+
16
+ ```bash
17
+ tide scan --path . --pack support-refunds
18
+ $EDITOR .tide/tide-plan.json
19
+ tide apply --interactive --write-policy
20
+ ```
21
+
22
+ Use `tide apply --yes --write-policy` to accept the reviewed plan non-interactively.
23
+
11
24
  ```python
12
25
  from tide import configure, controlled_tool, record_outcome
13
26
 
@@ -27,3 +40,10 @@ except Exception:
27
40
 
28
41
  Default files live in `.tide/ledger/`. Override with `TIDE_LEDGER_DIR` or
29
42
  `configure(project="...")`.
43
+
44
+ ```bash
45
+ tide approvals
46
+ tide approve <id>
47
+ tide deny <id> --reason "not in scope"
48
+ tide report
49
+ ```
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "tide-runtime"
7
- version = "0.2.5"
7
+ version = "0.2.7"
8
8
  description = "Tide runtime policy, approvals, and earned-use ledger for AI agent tools."
9
9
  requires-python = ">=3.8"
10
10
  readme = "README.md"
@@ -12,5 +12,8 @@ readme = "README.md"
12
12
  [project.urls]
13
13
  Repository = "https://github.com/rippletideco/rippletide-package"
14
14
 
15
+ [project.scripts]
16
+ tide = "tide.cli:main"
17
+
15
18
  [tool.setuptools]
16
19
  packages = ["tide"]
@@ -0,0 +1,354 @@
1
+ import json
2
+ import os
3
+ import shutil
4
+ import sys
5
+ import ast
6
+ import tempfile
7
+ import textwrap
8
+ import threading
9
+ import time
10
+ import unittest
11
+ from contextlib import redirect_stdout
12
+ from io import StringIO
13
+
14
+ sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
15
+
16
+ import tide
17
+ from tide import cli as tide_cli
18
+ from tide import packs as tide_packs
19
+ from tide import policy as tide_policy
20
+ from tide import store as tide_store
21
+
22
+
23
+ def events(project):
24
+ return tide_store.read_jsonl(project, tide_store.EVENTS_FILE)
25
+
26
+
27
+ class TideRuntimeTest(unittest.TestCase):
28
+ def setUp(self):
29
+ self.project = tempfile.mkdtemp(prefix="tide-ledger-test-")
30
+ tide.configure(project=self.project, agent_id="test-agent",
31
+ workflow_id="test-flow", environment="prod",
32
+ on_approval="raise", approval_timeout=5, approval_poll=0.05)
33
+
34
+ def tearDown(self):
35
+ shutil.rmtree(self.project, ignore_errors=True)
36
+
37
+ def test_policy_matrix_defaults(self):
38
+ pol = tide_policy.DEFAULT_POLICY
39
+ self.assertEqual(tide_policy.decide(pol, "t", "read_only", "prod")[0], "allow")
40
+ self.assertEqual(tide_policy.decide(pol, "t", "internal_write", "prod")[0], "require_approval")
41
+ self.assertEqual(tide_policy.decide(pol, "t", "internal_write", "dev")[0], "allow_with_log")
42
+ self.assertEqual(tide_policy.decide(pol, "t", "destructive", "prod")[0], "block")
43
+
44
+ def test_policy_fails_closed(self):
45
+ pol = tide_policy.DEFAULT_POLICY
46
+ self.assertEqual(tide_policy.decide(pol, "t", "not_a_class", "prod")[0], "block")
47
+ self.assertEqual(tide_policy.decide({"decisions": {}}, "t", "read_only", "prod")[0], "block")
48
+
49
+ def test_per_tool_override_wins(self):
50
+ pol = json.loads(json.dumps(tide_policy.DEFAULT_POLICY))
51
+ pol["tools"] = {"crm.nuke": {"decision": "block", "reason": "never"}}
52
+ decision, reason = tide_policy.decide(pol, "crm.nuke", "read_only", "prod")
53
+ self.assertEqual(decision, "block")
54
+ self.assertEqual(reason, "never")
55
+
56
+ def test_dynamic_risk_class(self):
57
+ def classify(named):
58
+ sql = named["sql"].lower()
59
+ return "destructive" if "drop " in sql else "read_only"
60
+
61
+ @tide.controlled_tool(name="sql.query", risk_class=classify)
62
+ def query(sql):
63
+ return "ok"
64
+
65
+ self.assertEqual(query("select * from customers"), "ok")
66
+ with self.assertRaises(tide.LedgerBlocked):
67
+ query("drop table customers")
68
+
69
+ def test_allow_records_event_with_hashes_and_redaction(self):
70
+ @tide.controlled_tool(name="crm.get_lead", risk_class="read_only", capture=["lead_id"])
71
+ def get_lead(lead_id, secret_note):
72
+ return {"lead_id": lead_id, "name": "Ada"}
73
+
74
+ result = get_lead("L-1", secret_note="do not store")
75
+ self.assertEqual(result["name"], "Ada")
76
+ e = events(self.project)[0]
77
+ self.assertEqual(e["policy_decision"], "allow")
78
+ self.assertEqual(e["captured"], {"lead_id": "L-1"})
79
+ self.assertNotIn("do not store", json.dumps(e))
80
+ self.assertTrue(e["input_hash"].startswith("sha256:"))
81
+ self.assertTrue(e["output_hash"].startswith("sha256:"))
82
+ self.assertIsNone(e["error_code"])
83
+
84
+ def test_block_raises_and_records(self):
85
+ @tide.controlled_tool(name="crm.delete_account", risk_class="destructive")
86
+ def delete_account(account_id):
87
+ raise AssertionError("must never run")
88
+
89
+ with self.assertRaises(tide.LedgerBlocked):
90
+ delete_account("A-1")
91
+ e = events(self.project)[0]
92
+ self.assertEqual(e["policy_decision"], "block")
93
+ self.assertEqual(e["error_code"], "blocked_by_policy")
94
+
95
+ def test_require_approval_raises_pending_and_records_request(self):
96
+ @tide.controlled_tool(name="crm.update_lead", risk_class="internal_write")
97
+ def update_lead(lead_id):
98
+ return "updated"
99
+
100
+ with self.assertRaises(tide.LedgerPending) as ctx:
101
+ update_lead("L-1")
102
+ approvals = tide_store.read_jsonl(self.project, tide_store.APPROVALS_FILE)
103
+ self.assertEqual(approvals[0]["type"], "request")
104
+ self.assertEqual(approvals[0]["approval_id"], ctx.exception.approval_id)
105
+ self.assertEqual(events(self.project)[0]["approval_status"], "pending")
106
+
107
+ def test_grant_is_hash_bound_and_one_shot(self):
108
+ @tide.controlled_tool(name="crm.update_lead", risk_class="internal_write")
109
+ def update_lead(lead_id):
110
+ return "updated"
111
+
112
+ with self.assertRaises(tide.LedgerPending) as ctx:
113
+ update_lead("L-1")
114
+ approval_id = ctx.exception.approval_id
115
+ tide_store.append_jsonl(self.project, tide_store.APPROVALS_FILE, {
116
+ "type": "decision", "approval_id": approval_id,
117
+ "decision": "approved", "approver": "tester",
118
+ "reason": None, "created_at": tide_store.now_iso(),
119
+ })
120
+ with self.assertRaises(tide.LedgerPending):
121
+ update_lead("L-2")
122
+ self.assertEqual(update_lead("L-1"), "updated")
123
+ ran = [e for e in events(self.project) if e["approval_status"] == "approved"]
124
+ self.assertEqual(len(ran), 1)
125
+ with self.assertRaises(tide.LedgerPending):
126
+ update_lead("L-1")
127
+
128
+ def test_wait_mode_proceeds_after_approval(self):
129
+ tide.configure(project=self.project, environment="prod",
130
+ on_approval="wait", approval_timeout=5, approval_poll=0.05)
131
+
132
+ @tide.controlled_tool(name="crm.update_lead", risk_class="internal_write")
133
+ def update_lead(lead_id):
134
+ return "updated"
135
+
136
+ def approve_soon():
137
+ time.sleep(0.2)
138
+ reqs = tide_store.read_jsonl(self.project, tide_store.APPROVALS_FILE)
139
+ tide_store.append_jsonl(self.project, tide_store.APPROVALS_FILE, {
140
+ "type": "decision", "approval_id": reqs[0]["approval_id"],
141
+ "decision": "approved", "approver": "tester",
142
+ "reason": None, "created_at": tide_store.now_iso(),
143
+ })
144
+
145
+ t = threading.Thread(target=approve_soon)
146
+ t.start()
147
+ self.assertEqual(update_lead("L-1"), "updated")
148
+ t.join()
149
+ self.assertEqual(events(self.project)[0]["approval_status"], "approved")
150
+
151
+ def test_wait_mode_denied_raises(self):
152
+ tide.configure(project=self.project, environment="prod",
153
+ on_approval="wait", approval_timeout=5, approval_poll=0.05)
154
+
155
+ @tide.controlled_tool(name="crm.update_lead", risk_class="internal_write")
156
+ def update_lead(lead_id):
157
+ raise AssertionError("must never run")
158
+
159
+ def deny_soon():
160
+ time.sleep(0.2)
161
+ reqs = tide_store.read_jsonl(self.project, tide_store.APPROVALS_FILE)
162
+ tide_store.append_jsonl(self.project, tide_store.APPROVALS_FILE, {
163
+ "type": "decision", "approval_id": reqs[0]["approval_id"],
164
+ "decision": "denied", "approver": "tester",
165
+ "reason": "not now", "created_at": tide_store.now_iso(),
166
+ })
167
+
168
+ t = threading.Thread(target=deny_soon)
169
+ t.start()
170
+ with self.assertRaises(tide.LedgerDenied):
171
+ update_lead("L-1")
172
+ t.join()
173
+ e = events(self.project)[0]
174
+ self.assertEqual(e["approval_status"], "denied")
175
+ self.assertEqual(e["error_code"], "approval_denied")
176
+
177
+ def test_outcome_links_to_trace(self):
178
+ @tide.controlled_tool(name="crm.get_lead", risk_class="read_only")
179
+ def get_lead(lead_id):
180
+ return "lead"
181
+
182
+ get_lead("L-1")
183
+ tide.record_outcome("success", human_corrected=False)
184
+ outcome = tide_store.read_jsonl(self.project, tide_store.OUTCOMES_FILE)[0]
185
+ self.assertEqual(outcome["trace_id"], events(self.project)[0]["trace_id"])
186
+ self.assertEqual(outcome["status"], "success")
187
+
188
+ def test_packs_generate_reviewable_policy(self):
189
+ pol = tide_packs.get("support-refunds", environment="prod")
190
+ self.assertEqual(pol["pack"], "support-refunds")
191
+ self.assertEqual(pol["tools"]["stripe.refund"]["decision"], "require_approval")
192
+ self.assertEqual(pol["tools"]["stripe.customer.delete"]["decision"], "block")
193
+
194
+ def test_cli_init_writes_pack_policy(self):
195
+ out = StringIO()
196
+ with redirect_stdout(out):
197
+ code = tide_cli.main(["init", "--project", self.project, "--pack", "sql-analyst", "--force"])
198
+ self.assertEqual(code, 0)
199
+ pol = tide_store.load_policy(self.project)
200
+ self.assertEqual(pol["pack"], "sql-analyst")
201
+ self.assertEqual(pol["decisions"]["internal_write"]["prod"], "block")
202
+
203
+ def test_cli_dry_run_does_not_write_policy(self):
204
+ shutil.rmtree(self.project)
205
+ out = StringIO()
206
+ with redirect_stdout(out):
207
+ code = tide_cli.main(["init", "--project", self.project, "--pack", "mcp-baseline", "--dry-run"])
208
+ self.assertEqual(code, 0)
209
+ self.assertIn('"pack": "mcp-baseline"', out.getvalue())
210
+ self.assertFalse(os.path.exists(os.path.join(self.project, tide_store.POLICY_FILE)))
211
+
212
+ def test_cli_approves_pending_request(self):
213
+ @tide.controlled_tool(name="stripe.refund", risk_class="money_movement", capture=["charge_id"])
214
+ def refund(charge_id):
215
+ return "refunded"
216
+
217
+ with self.assertRaises(tide.LedgerPending) as ctx:
218
+ refund("ch_1")
219
+ prefix = ctx.exception.approval_id[:8]
220
+ out = StringIO()
221
+ with redirect_stdout(out):
222
+ code = tide_cli.main(["approve", prefix, "--project", self.project, "--reason", "ok"])
223
+ self.assertEqual(code, 0)
224
+ self.assertIn("one-shot", out.getvalue())
225
+ self.assertEqual(tide_store.approval_state(self.project, ctx.exception.approval_id), "approved")
226
+
227
+ def test_cli_report_prints_earned_use(self):
228
+ @tide.controlled_tool(name="crm.get_lead", risk_class="read_only")
229
+ def get_lead(lead_id):
230
+ return "lead"
231
+
232
+ get_lead("L-1")
233
+ tide.record_outcome("success")
234
+ out = StringIO()
235
+ with redirect_stdout(out):
236
+ code = tide_cli.main(["report", "--project", self.project])
237
+ self.assertEqual(code, 0)
238
+ self.assertIn("Earned use by tool", out.getvalue())
239
+ self.assertIn("crm.get_lead", out.getvalue())
240
+
241
+ def test_cli_scan_generates_wrapper_plan(self):
242
+ agent = tempfile.mkdtemp(prefix="tide-agent-scan-")
243
+ self.addCleanup(lambda: shutil.rmtree(agent, ignore_errors=True))
244
+ with open(os.path.join(agent, "agent.py"), "w", encoding="utf-8") as f:
245
+ f.write(textwrap.dedent("""
246
+ from langchain.tools import tool
247
+
248
+ @tool
249
+ def issue_refund(charge_id, amount):
250
+ return stripe.refunds.create(charge=charge_id, amount=amount)
251
+ """).lstrip())
252
+
253
+ plan = os.path.join(agent, ".tide", "tide-plan.json")
254
+ out = StringIO()
255
+ with redirect_stdout(out):
256
+ code = tide_cli.main(["scan", "--path", agent, "--pack", "support-refunds", "--plan", plan])
257
+ self.assertEqual(code, 0)
258
+ with open(plan, "r", encoding="utf-8") as f:
259
+ data = json.load(f)
260
+ action = data["actions"][0]
261
+ self.assertEqual(action["risk_class"], "money_movement")
262
+ self.assertEqual(action["policy_decision"], "require_approval")
263
+ self.assertEqual(action["capture"], ["charge_id", "amount"])
264
+ self.assertIn("issue_refund", out.getvalue())
265
+
266
+ def test_cli_apply_adds_wrapper_import_and_policy(self):
267
+ agent = tempfile.mkdtemp(prefix="tide-agent-apply-")
268
+ self.addCleanup(lambda: shutil.rmtree(agent, ignore_errors=True))
269
+ target = os.path.join(agent, "agent.py")
270
+ with open(target, "w", encoding="utf-8") as f:
271
+ f.write(textwrap.dedent("""
272
+ def send_customer_email(recipient_email, body):
273
+ return gmail.send(to=recipient_email, body=body)
274
+ """).lstrip())
275
+
276
+ plan = os.path.join(agent, ".tide", "tide-plan.json")
277
+ with redirect_stdout(StringIO()):
278
+ self.assertEqual(tide_cli.main(["scan", "--path", agent, "--pack", "support-refunds", "--plan", plan]), 0)
279
+ self.assertEqual(tide_cli.main(["apply", "--plan", plan, "--yes", "--write-policy"]), 0)
280
+ with open(target, "r", encoding="utf-8") as f:
281
+ source = f.read()
282
+ self.assertIn("from tide import controlled_tool", source)
283
+ self.assertIn("@controlled_tool(name='gmail.send', risk_class='customer_visible', capture=['recipient_email'])", source)
284
+ ast.parse(source)
285
+ pol = tide_store.load_policy(os.path.join(agent, ".tide", "ledger"))
286
+ self.assertEqual(pol["pack"], "support-refunds")
287
+
288
+ def test_cli_apply_preserves_framework_decorator_order(self):
289
+ agent = tempfile.mkdtemp(prefix="tide-agent-order-")
290
+ self.addCleanup(lambda: shutil.rmtree(agent, ignore_errors=True))
291
+ target = os.path.join(agent, "agent.py")
292
+ with open(target, "w", encoding="utf-8") as f:
293
+ f.write(textwrap.dedent("""
294
+ from langchain.tools import tool
295
+
296
+ @tool
297
+ def update_deal(deal_id, stage):
298
+ return hubspot.crm.deals.basic_api.update(deal_id, {"stage": stage})
299
+ """).lstrip())
300
+
301
+ plan = os.path.join(agent, ".tide", "tide-plan.json")
302
+ with redirect_stdout(StringIO()):
303
+ self.assertEqual(tide_cli.main(["scan", "--path", agent, "--pack", "baseline", "--plan", plan]), 0)
304
+ self.assertEqual(tide_cli.main(["apply", "--plan", plan, "--yes"]), 0)
305
+ with open(target, "r", encoding="utf-8") as f:
306
+ lines = [line.strip() for line in f.readlines()]
307
+ self.assertLess(lines.index("@tool"), lines.index("@controlled_tool(name='hubspot.update_deal', risk_class='internal_write', capture=['deal_id'])"))
308
+ self.assertLess(lines.index("@controlled_tool(name='hubspot.update_deal', risk_class='internal_write', capture=['deal_id'])"), lines.index("def update_deal(deal_id, stage):"))
309
+
310
+ def test_cli_scan_reports_already_wrapped(self):
311
+ agent = tempfile.mkdtemp(prefix="tide-agent-wrapped-")
312
+ self.addCleanup(lambda: shutil.rmtree(agent, ignore_errors=True))
313
+ with open(os.path.join(agent, "agent.py"), "w", encoding="utf-8") as f:
314
+ f.write(textwrap.dedent("""
315
+ from tide import controlled_tool
316
+
317
+ @controlled_tool(name="stripe.refund", risk_class="money_movement")
318
+ def issue_refund(charge_id):
319
+ return stripe.refunds.create(charge=charge_id)
320
+ """).lstrip())
321
+
322
+ plan = os.path.join(agent, ".tide", "tide-plan.json")
323
+ with redirect_stdout(StringIO()):
324
+ self.assertEqual(tide_cli.main(["scan", "--path", agent, "--plan", plan]), 0)
325
+ with open(plan, "r", encoding="utf-8") as f:
326
+ data = json.load(f)
327
+ self.assertEqual(data["summary"]["proposed"], 0)
328
+ self.assertEqual(data["summary"]["already_wrapped"], 1)
329
+
330
+ def test_cli_scan_skips_common_helpers(self):
331
+ agent = tempfile.mkdtemp(prefix="tide-agent-helpers-")
332
+ self.addCleanup(lambda: shutil.rmtree(agent, ignore_errors=True))
333
+ with open(os.path.join(agent, "agent.py"), "w", encoding="utf-8") as f:
334
+ f.write(textwrap.dedent("""
335
+ def create_sql_agent(db):
336
+ return object()
337
+
338
+ def summarize_tool_args(args):
339
+ return str(args)
340
+
341
+ def main():
342
+ return create_sql_agent(None)
343
+ """).lstrip())
344
+
345
+ plan = os.path.join(agent, ".tide", "tide-plan.json")
346
+ with redirect_stdout(StringIO()):
347
+ self.assertEqual(tide_cli.main(["scan", "--path", agent, "--plan", plan]), 0)
348
+ with open(plan, "r", encoding="utf-8") as f:
349
+ data = json.load(f)
350
+ self.assertEqual(data["summary"]["proposed"], 0)
351
+
352
+
353
+ if __name__ == "__main__":
354
+ unittest.main()
@@ -26,7 +26,7 @@ import time
26
26
  from . import policy as _policy
27
27
  from . import store as _store
28
28
 
29
- __version__ = "0.1.0"
29
+ __version__ = "0.2.7"
30
30
 
31
31
  __all__ = [
32
32
  "configure",