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.
- {tide_runtime-0.2.5 → tide_runtime-0.2.7}/PKG-INFO +22 -2
- {tide_runtime-0.2.5 → tide_runtime-0.2.7}/README.md +21 -1
- {tide_runtime-0.2.5 → tide_runtime-0.2.7}/pyproject.toml +4 -1
- tide_runtime-0.2.7/tests/test_runtime.py +354 -0
- {tide_runtime-0.2.5 → tide_runtime-0.2.7}/tide/__init__.py +1 -1
- tide_runtime-0.2.7/tide/cli.py +304 -0
- tide_runtime-0.2.7/tide/instrument.py +468 -0
- tide_runtime-0.2.7/tide/packs.py +70 -0
- {tide_runtime-0.2.5 → tide_runtime-0.2.7}/tide_runtime.egg-info/PKG-INFO +22 -2
- {tide_runtime-0.2.5 → tide_runtime-0.2.7}/tide_runtime.egg-info/SOURCES.txt +4 -0
- tide_runtime-0.2.7/tide_runtime.egg-info/entry_points.txt +2 -0
- tide_runtime-0.2.5/tests/test_runtime.py +0 -184
- {tide_runtime-0.2.5 → tide_runtime-0.2.7}/setup.cfg +0 -0
- {tide_runtime-0.2.5 → tide_runtime-0.2.7}/tide/policy.py +0 -0
- {tide_runtime-0.2.5 → tide_runtime-0.2.7}/tide/store.py +0 -0
- {tide_runtime-0.2.5 → tide_runtime-0.2.7}/tide_runtime.egg-info/dependency_links.txt +0 -0
- {tide_runtime-0.2.5 → tide_runtime-0.2.7}/tide_runtime.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: tide-runtime
|
|
3
|
-
Version: 0.2.
|
|
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
|
|
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
|
|
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.
|
|
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()
|