tide-runtime 0.2.6__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.6 → tide_runtime-0.2.7}/PKG-INFO +11 -1
- {tide_runtime-0.2.6 → tide_runtime-0.2.7}/README.md +10 -0
- {tide_runtime-0.2.6 → tide_runtime-0.2.7}/pyproject.toml +1 -1
- {tide_runtime-0.2.6 → tide_runtime-0.2.7}/tests/test_runtime.py +113 -0
- {tide_runtime-0.2.6 → tide_runtime-0.2.7}/tide/__init__.py +1 -1
- {tide_runtime-0.2.6 → tide_runtime-0.2.7}/tide/cli.py +64 -0
- tide_runtime-0.2.7/tide/instrument.py +468 -0
- {tide_runtime-0.2.6 → tide_runtime-0.2.7}/tide_runtime.egg-info/PKG-INFO +11 -1
- {tide_runtime-0.2.6 → tide_runtime-0.2.7}/tide_runtime.egg-info/SOURCES.txt +1 -0
- {tide_runtime-0.2.6 → tide_runtime-0.2.7}/setup.cfg +0 -0
- {tide_runtime-0.2.6 → tide_runtime-0.2.7}/tide/packs.py +0 -0
- {tide_runtime-0.2.6 → tide_runtime-0.2.7}/tide/policy.py +0 -0
- {tide_runtime-0.2.6 → tide_runtime-0.2.7}/tide/store.py +0 -0
- {tide_runtime-0.2.6 → tide_runtime-0.2.7}/tide_runtime.egg-info/dependency_links.txt +0 -0
- {tide_runtime-0.2.6 → tide_runtime-0.2.7}/tide_runtime.egg-info/entry_points.txt +0 -0
- {tide_runtime-0.2.6 → 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
|
|
@@ -19,6 +19,16 @@ tide init --pack support-refunds --environment prod
|
|
|
19
19
|
`tide init --dry-run --pack <name>` prints the policy before writing it.
|
|
20
20
|
Built-in packs: `baseline`, `support-refunds`, `sql-analyst`, `mcp-baseline`.
|
|
21
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
|
+
|
|
22
32
|
```python
|
|
23
33
|
from tide import configure, controlled_tool, record_outcome
|
|
24
34
|
|
|
@@ -11,6 +11,16 @@ tide init --pack support-refunds --environment prod
|
|
|
11
11
|
`tide init --dry-run --pack <name>` prints the policy before writing it.
|
|
12
12
|
Built-in packs: `baseline`, `support-refunds`, `sql-analyst`, `mcp-baseline`.
|
|
13
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
|
+
|
|
14
24
|
```python
|
|
15
25
|
from tide import configure, controlled_tool, record_outcome
|
|
16
26
|
|
|
@@ -2,7 +2,9 @@ import json
|
|
|
2
2
|
import os
|
|
3
3
|
import shutil
|
|
4
4
|
import sys
|
|
5
|
+
import ast
|
|
5
6
|
import tempfile
|
|
7
|
+
import textwrap
|
|
6
8
|
import threading
|
|
7
9
|
import time
|
|
8
10
|
import unittest
|
|
@@ -236,6 +238,117 @@ class TideRuntimeTest(unittest.TestCase):
|
|
|
236
238
|
self.assertIn("Earned use by tool", out.getvalue())
|
|
237
239
|
self.assertIn("crm.get_lead", out.getvalue())
|
|
238
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
|
+
|
|
239
352
|
|
|
240
353
|
if __name__ == "__main__":
|
|
241
354
|
unittest.main()
|
|
@@ -6,6 +6,7 @@ import os
|
|
|
6
6
|
import sys
|
|
7
7
|
|
|
8
8
|
from . import __version__
|
|
9
|
+
from . import instrument
|
|
9
10
|
from . import packs
|
|
10
11
|
from . import store
|
|
11
12
|
|
|
@@ -185,6 +186,43 @@ def _packs(args):
|
|
|
185
186
|
return 0
|
|
186
187
|
|
|
187
188
|
|
|
189
|
+
def _scan(args):
|
|
190
|
+
plan_path = args.plan
|
|
191
|
+
plan = instrument.scan(
|
|
192
|
+
root=args.path,
|
|
193
|
+
pack=args.pack,
|
|
194
|
+
plan_path=None if args.no_write else plan_path,
|
|
195
|
+
environment=args.environment,
|
|
196
|
+
)
|
|
197
|
+
if args.format == "json":
|
|
198
|
+
print(json.dumps(plan, indent=2, sort_keys=True))
|
|
199
|
+
else:
|
|
200
|
+
print(instrument.text_summary(plan))
|
|
201
|
+
if not args.no_write:
|
|
202
|
+
print("\ntide: wrote plan -> %s" % plan_path)
|
|
203
|
+
print("tide: review it, then run `tide apply --plan %s --interactive` or `--yes`." % plan_path)
|
|
204
|
+
return 0
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _apply(args):
|
|
208
|
+
if not args.yes and not args.interactive and not args.id:
|
|
209
|
+
print("tide: apply needs --interactive, --yes, or --id <action-id>", file=sys.stderr)
|
|
210
|
+
return 1
|
|
211
|
+
result = instrument.apply_plan(
|
|
212
|
+
plan_path=args.plan,
|
|
213
|
+
yes=args.yes,
|
|
214
|
+
only_ids=args.id,
|
|
215
|
+
write_policy=args.write_policy,
|
|
216
|
+
interactive=args.interactive,
|
|
217
|
+
)
|
|
218
|
+
print("tide: applied %d wrapper(s)" % result["applied"])
|
|
219
|
+
if result["policy_written"]:
|
|
220
|
+
print("tide: wrote .tide/ledger/policy.json from the plan pack")
|
|
221
|
+
for item in result["skipped"]:
|
|
222
|
+
print("tide: skipped %s - %s" % (item.get("id"), item.get("reason")))
|
|
223
|
+
return 0 if result["applied"] or result["policy_written"] else 1
|
|
224
|
+
|
|
225
|
+
|
|
188
226
|
def build_parser():
|
|
189
227
|
parser = argparse.ArgumentParser(description="Tide local policy and approvals for AI agent tools.")
|
|
190
228
|
parser.add_argument("--version", action="version", version="tide-runtime %s" % __version__)
|
|
@@ -202,6 +240,32 @@ def build_parser():
|
|
|
202
240
|
show_packs.add_argument("name", nargs="?", choices=packs.names())
|
|
203
241
|
show_packs.set_defaults(func=_packs)
|
|
204
242
|
|
|
243
|
+
scan = sub.add_parser("scan", help="find risky Python tools and write a reviewable wrapper plan")
|
|
244
|
+
scan.add_argument("--path", default=".")
|
|
245
|
+
scan.add_argument("--pack", choices=packs.names(), default="baseline")
|
|
246
|
+
scan.add_argument("--environment", choices=("dev", "prod"), default=None)
|
|
247
|
+
scan.add_argument("--plan", default=instrument.PLAN_FILE)
|
|
248
|
+
scan.add_argument("--format", choices=("text", "json"), default="text")
|
|
249
|
+
scan.add_argument("--no-write", action="store_true")
|
|
250
|
+
scan.set_defaults(func=_scan)
|
|
251
|
+
|
|
252
|
+
plan = sub.add_parser("plan", help="alias for scan")
|
|
253
|
+
plan.add_argument("--path", default=".")
|
|
254
|
+
plan.add_argument("--pack", choices=packs.names(), default="baseline")
|
|
255
|
+
plan.add_argument("--environment", choices=("dev", "prod"), default=None)
|
|
256
|
+
plan.add_argument("--plan", default=instrument.PLAN_FILE)
|
|
257
|
+
plan.add_argument("--format", choices=("text", "json"), default="text")
|
|
258
|
+
plan.add_argument("--no-write", action="store_true")
|
|
259
|
+
plan.set_defaults(func=_scan)
|
|
260
|
+
|
|
261
|
+
apply = sub.add_parser("apply", help="apply wrappers from a Tide plan")
|
|
262
|
+
apply.add_argument("--plan", default=instrument.PLAN_FILE)
|
|
263
|
+
apply.add_argument("--interactive", action="store_true")
|
|
264
|
+
apply.add_argument("--yes", action="store_true")
|
|
265
|
+
apply.add_argument("--id", action="append", help="apply one action id; can be repeated")
|
|
266
|
+
apply.add_argument("--write-policy", action="store_true")
|
|
267
|
+
apply.set_defaults(func=_apply)
|
|
268
|
+
|
|
205
269
|
approvals = sub.add_parser("approvals", help="list pending approval requests")
|
|
206
270
|
approvals.add_argument("--project", default=DEFAULT_PROJECT)
|
|
207
271
|
approvals.set_defaults(func=_approvals)
|
|
@@ -0,0 +1,468 @@
|
|
|
1
|
+
"""Scan Python agents and apply Tide wrappers around risky tools."""
|
|
2
|
+
|
|
3
|
+
import ast
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
from datetime import datetime, timezone
|
|
8
|
+
|
|
9
|
+
from . import packs
|
|
10
|
+
from . import policy
|
|
11
|
+
from . import store
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
PLAN_FILE = os.path.join(".tide", "tide-plan.json")
|
|
15
|
+
|
|
16
|
+
SKIP_DIRS = {
|
|
17
|
+
".git",
|
|
18
|
+
".hg",
|
|
19
|
+
".mypy_cache",
|
|
20
|
+
".pytest_cache",
|
|
21
|
+
".ruff_cache",
|
|
22
|
+
".tide",
|
|
23
|
+
".venv",
|
|
24
|
+
"__pycache__",
|
|
25
|
+
"build",
|
|
26
|
+
"dist",
|
|
27
|
+
"node_modules",
|
|
28
|
+
"site-packages",
|
|
29
|
+
"venv",
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
TOOL_DECORATORS = {
|
|
33
|
+
"tool",
|
|
34
|
+
"function_tool",
|
|
35
|
+
"mcp.tool",
|
|
36
|
+
"server.tool",
|
|
37
|
+
"app.tool",
|
|
38
|
+
"agent.tool",
|
|
39
|
+
"structured_tool",
|
|
40
|
+
"StructuredTool.from_function",
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
RISK_RULES = [
|
|
44
|
+
("destructive", (
|
|
45
|
+
"delete", "destroy", "drop", "truncate", "purge", "remove", "rmtree",
|
|
46
|
+
"rm -rf", "kubectl delete", "terraform destroy",
|
|
47
|
+
)),
|
|
48
|
+
("money_movement", (
|
|
49
|
+
"refund", "charge", "payment", "payout", "transfer", "pay_invoice",
|
|
50
|
+
"invoice.pay", "stripe.refund", "stripe.refunds.create",
|
|
51
|
+
)),
|
|
52
|
+
("customer_visible", (
|
|
53
|
+
"send_email", "email.send", "gmail.send", "reply", "send_message",
|
|
54
|
+
"post_message", "chat.postmessage", "zendesk.ticket.reply",
|
|
55
|
+
"intercom.message", "slack.chat.postmessage",
|
|
56
|
+
)),
|
|
57
|
+
("internal_write", (
|
|
58
|
+
"create", "update", "upsert", "insert", "merge", "write", "patch",
|
|
59
|
+
"set_", "close_ticket", "stage", "finalize", "commit", "deploy",
|
|
60
|
+
"hubspot", "salesforce", "zendesk", "jira", "linear",
|
|
61
|
+
)),
|
|
62
|
+
]
|
|
63
|
+
|
|
64
|
+
READ_ONLY_HINTS = (
|
|
65
|
+
"get", "list", "search", "read", "fetch", "lookup", "select", "query",
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
HELPER_NAME_HINTS = (
|
|
69
|
+
"agent", "client", "summarize", "summary", "format", "parse", "classify",
|
|
70
|
+
"schema", "prompt", "config", "factory", "builder",
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
ACTION_NAME_HINTS = {
|
|
74
|
+
"destructive": ("delete", "destroy", "drop", "truncate", "purge", "remove"),
|
|
75
|
+
"money_movement": ("refund", "charge", "payment", "payout", "transfer", "pay"),
|
|
76
|
+
"customer_visible": ("send", "reply", "message", "email", "notify", "post"),
|
|
77
|
+
"internal_write": ("create", "update", "upsert", "insert", "write", "patch", "merge", "close", "finalize", "deploy", "set_"),
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
CAPTURE_HINTS = (
|
|
81
|
+
"id", "amount", "email", "query", "sql", "charge", "refund", "customer",
|
|
82
|
+
"ticket", "deal", "lead", "account", "path", "command", "cmd", "recipient",
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def now_iso():
|
|
87
|
+
return datetime.now(timezone.utc).isoformat()
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def relpath(path, root):
|
|
91
|
+
return os.path.relpath(path, root).replace(os.sep, "/")
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def walk_python(root):
|
|
95
|
+
for base, dirs, files in os.walk(root):
|
|
96
|
+
dirs[:] = [d for d in dirs if d not in SKIP_DIRS]
|
|
97
|
+
for filename in files:
|
|
98
|
+
if filename.endswith(".py"):
|
|
99
|
+
yield os.path.join(base, filename)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def expr_name(node):
|
|
103
|
+
if isinstance(node, ast.Name):
|
|
104
|
+
return node.id
|
|
105
|
+
if isinstance(node, ast.Attribute):
|
|
106
|
+
parent = expr_name(node.value)
|
|
107
|
+
return (parent + "." if parent else "") + node.attr
|
|
108
|
+
if isinstance(node, ast.Call):
|
|
109
|
+
return expr_name(node.func)
|
|
110
|
+
return ""
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def decorator_name(node):
|
|
114
|
+
if isinstance(node, ast.Call):
|
|
115
|
+
return expr_name(node.func)
|
|
116
|
+
return expr_name(node)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def is_controlled(fn):
|
|
120
|
+
for dec in fn.decorator_list:
|
|
121
|
+
name = decorator_name(dec).lower()
|
|
122
|
+
if name == "controlled_tool" or name.endswith(".controlled_tool"):
|
|
123
|
+
return True
|
|
124
|
+
return False
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def is_tool_decorator(name):
|
|
128
|
+
lowered = name.lower()
|
|
129
|
+
if lowered in TOOL_DECORATORS:
|
|
130
|
+
return True
|
|
131
|
+
return lowered.endswith(".tool") or lowered.endswith(".as_tool")
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
class FunctionSignals(ast.NodeVisitor):
|
|
135
|
+
def __init__(self):
|
|
136
|
+
self.calls = []
|
|
137
|
+
self.strings = []
|
|
138
|
+
|
|
139
|
+
def visit_Call(self, node):
|
|
140
|
+
name = expr_name(node.func)
|
|
141
|
+
if name:
|
|
142
|
+
self.calls.append(name)
|
|
143
|
+
self.generic_visit(node)
|
|
144
|
+
|
|
145
|
+
def visit_Constant(self, node):
|
|
146
|
+
if isinstance(node.value, str):
|
|
147
|
+
self.strings.append(node.value[:200])
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def word_blob(fn, signals):
|
|
151
|
+
parts = [fn.name]
|
|
152
|
+
parts.extend(decorator_name(dec) for dec in fn.decorator_list)
|
|
153
|
+
parts.extend(signals.calls)
|
|
154
|
+
parts.extend(signals.strings)
|
|
155
|
+
return " ".join(parts).lower().replace("_", ".")
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def best_risk(fn, signals):
|
|
159
|
+
blob = word_blob(fn, signals)
|
|
160
|
+
reasons = []
|
|
161
|
+
for risk, hints in RISK_RULES:
|
|
162
|
+
matched = [hint for hint in hints if hint in blob]
|
|
163
|
+
if matched:
|
|
164
|
+
reasons.append("matched %s hint(s): %s" % (risk, ", ".join(matched[:4])))
|
|
165
|
+
return risk, reasons
|
|
166
|
+
if any(hint in blob for hint in READ_ONLY_HINTS):
|
|
167
|
+
return "read_only", ["matched read-only hint"]
|
|
168
|
+
return "internal_write", ["tool surface without a more specific risk hint"]
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def confidence(fn, risk_class, has_tool_decorator, reasons):
|
|
172
|
+
if has_tool_decorator and risk_class not in ("read_only",):
|
|
173
|
+
return "high"
|
|
174
|
+
if risk_class in ("destructive", "money_movement", "customer_visible"):
|
|
175
|
+
return "high"
|
|
176
|
+
if risk_class == "internal_write":
|
|
177
|
+
return "medium"
|
|
178
|
+
if has_tool_decorator:
|
|
179
|
+
return "medium"
|
|
180
|
+
return "low"
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def should_propose(fn, risk_class, has_tool_decorator):
|
|
184
|
+
if has_tool_decorator:
|
|
185
|
+
return True
|
|
186
|
+
name = fn.name.lower()
|
|
187
|
+
if name.startswith("_") or name == "main":
|
|
188
|
+
return False
|
|
189
|
+
if risk_class == "read_only":
|
|
190
|
+
return False
|
|
191
|
+
if any(hint in name for hint in HELPER_NAME_HINTS):
|
|
192
|
+
return False
|
|
193
|
+
return any(hint in name for hint in ACTION_NAME_HINTS.get(risk_class, ()))
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def capture_args(fn):
|
|
197
|
+
args = []
|
|
198
|
+
arguments = list(fn.args.args)
|
|
199
|
+
if arguments and arguments[0].arg in ("self", "cls"):
|
|
200
|
+
arguments = arguments[1:]
|
|
201
|
+
for arg in arguments + list(fn.args.kwonlyargs):
|
|
202
|
+
lowered = arg.arg.lower()
|
|
203
|
+
if any(hint in lowered for hint in CAPTURE_HINTS):
|
|
204
|
+
args.append(arg.arg)
|
|
205
|
+
return args[:5]
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def infer_tool_name(fn, signals):
|
|
209
|
+
blob = word_blob(fn, signals)
|
|
210
|
+
if "stripe" in blob and "refund" in blob:
|
|
211
|
+
return "stripe.refund"
|
|
212
|
+
if "shopify" in blob and "refund" in blob:
|
|
213
|
+
return "shopify.refund"
|
|
214
|
+
if "zendesk" in blob and "reply" in blob:
|
|
215
|
+
return "zendesk.ticket.reply"
|
|
216
|
+
if "intercom" in blob and "message" in blob:
|
|
217
|
+
return "intercom.message.send"
|
|
218
|
+
if "slack" in blob and "postmessage" in blob:
|
|
219
|
+
return "slack.chat.postMessage"
|
|
220
|
+
if "gmail" in blob and "send" in blob:
|
|
221
|
+
return "gmail.send"
|
|
222
|
+
if "sql" in blob or "query" in blob:
|
|
223
|
+
return "sql.query"
|
|
224
|
+
if "hubspot" in blob:
|
|
225
|
+
return "hubspot.%s" % fn.name
|
|
226
|
+
if "salesforce" in blob:
|
|
227
|
+
return "salesforce.%s" % fn.name
|
|
228
|
+
return fn.name
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def decorator_for(action):
|
|
232
|
+
capture = action.get("capture") or []
|
|
233
|
+
parts = [
|
|
234
|
+
"name=%r" % action["tool_name"],
|
|
235
|
+
"risk_class=%r" % action["risk_class"],
|
|
236
|
+
]
|
|
237
|
+
if capture:
|
|
238
|
+
parts.append("capture=%r" % capture)
|
|
239
|
+
return "@controlled_tool(%s)" % ", ".join(parts)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def scan_file(path, root, policy_obj):
|
|
243
|
+
try:
|
|
244
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
245
|
+
source = f.read()
|
|
246
|
+
tree = ast.parse(source, filename=path)
|
|
247
|
+
except (OSError, SyntaxError, UnicodeDecodeError) as exc:
|
|
248
|
+
return [{
|
|
249
|
+
"id": None,
|
|
250
|
+
"status": "manual_review",
|
|
251
|
+
"language": "python",
|
|
252
|
+
"file": relpath(path, root),
|
|
253
|
+
"line": 1,
|
|
254
|
+
"function": None,
|
|
255
|
+
"reason": "could not parse file: %s" % exc,
|
|
256
|
+
}]
|
|
257
|
+
|
|
258
|
+
actions = []
|
|
259
|
+
for node in ast.walk(tree):
|
|
260
|
+
if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
261
|
+
continue
|
|
262
|
+
signals = FunctionSignals()
|
|
263
|
+
signals.visit(node)
|
|
264
|
+
has_tool_decorator = any(is_tool_decorator(decorator_name(dec)) for dec in node.decorator_list)
|
|
265
|
+
if is_controlled(node):
|
|
266
|
+
actions.append({
|
|
267
|
+
"id": None,
|
|
268
|
+
"status": "already_wrapped",
|
|
269
|
+
"language": "python",
|
|
270
|
+
"file": relpath(path, root),
|
|
271
|
+
"line": node.lineno,
|
|
272
|
+
"function": node.name,
|
|
273
|
+
"tool_name": infer_tool_name(node, signals),
|
|
274
|
+
})
|
|
275
|
+
continue
|
|
276
|
+
risk_class, reasons = best_risk(node, signals)
|
|
277
|
+
if not should_propose(node, risk_class, has_tool_decorator):
|
|
278
|
+
continue
|
|
279
|
+
env = policy_obj.get("environment") or "prod"
|
|
280
|
+
decision, decision_reason = policy.decide(policy_obj, infer_tool_name(node, signals), risk_class, env)
|
|
281
|
+
action = {
|
|
282
|
+
"id": None,
|
|
283
|
+
"status": "proposed",
|
|
284
|
+
"language": "python",
|
|
285
|
+
"file": relpath(path, root),
|
|
286
|
+
"line": node.lineno,
|
|
287
|
+
"function": node.name,
|
|
288
|
+
"async": isinstance(node, ast.AsyncFunctionDef),
|
|
289
|
+
"tool_name": infer_tool_name(node, signals),
|
|
290
|
+
"risk_class": risk_class,
|
|
291
|
+
"capture": capture_args(node),
|
|
292
|
+
"confidence": confidence(node, risk_class, has_tool_decorator, reasons),
|
|
293
|
+
"framework_tool": has_tool_decorator,
|
|
294
|
+
"reasons": reasons,
|
|
295
|
+
"policy_decision": decision,
|
|
296
|
+
"policy_reason": decision_reason,
|
|
297
|
+
}
|
|
298
|
+
action["decorator"] = decorator_for(action)
|
|
299
|
+
action["accepted"] = action["confidence"] in ("high", "medium")
|
|
300
|
+
actions.append(action)
|
|
301
|
+
return actions
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def scan(root=".", pack="baseline", plan_path=None, environment=None):
|
|
305
|
+
root = os.path.abspath(root)
|
|
306
|
+
policy_obj = packs.get(pack, environment=environment)
|
|
307
|
+
actions = []
|
|
308
|
+
for path in walk_python(root):
|
|
309
|
+
actions.extend(scan_file(path, root, policy_obj))
|
|
310
|
+
proposed = [a for a in actions if a.get("status") == "proposed"]
|
|
311
|
+
for i, action in enumerate(proposed, 1):
|
|
312
|
+
action["id"] = "wrap-%04d" % i
|
|
313
|
+
summary = {
|
|
314
|
+
"files_scanned": sum(1 for _ in walk_python(root)),
|
|
315
|
+
"proposed": len(proposed),
|
|
316
|
+
"already_wrapped": len([a for a in actions if a.get("status") == "already_wrapped"]),
|
|
317
|
+
"manual_review": len([a for a in actions if a.get("status") == "manual_review"]),
|
|
318
|
+
"high_confidence": len([a for a in proposed if a.get("confidence") == "high"]),
|
|
319
|
+
"medium_confidence": len([a for a in proposed if a.get("confidence") == "medium"]),
|
|
320
|
+
"low_confidence": len([a for a in proposed if a.get("confidence") == "low"]),
|
|
321
|
+
}
|
|
322
|
+
plan = {
|
|
323
|
+
"version": 1,
|
|
324
|
+
"generated_at": now_iso(),
|
|
325
|
+
"root": root,
|
|
326
|
+
"pack": pack,
|
|
327
|
+
"policy": policy_obj,
|
|
328
|
+
"summary": summary,
|
|
329
|
+
"actions": actions,
|
|
330
|
+
}
|
|
331
|
+
if plan_path:
|
|
332
|
+
write_plan(plan, plan_path)
|
|
333
|
+
return plan
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def write_plan(plan, path):
|
|
337
|
+
directory = os.path.dirname(path)
|
|
338
|
+
if directory:
|
|
339
|
+
os.makedirs(directory, exist_ok=True)
|
|
340
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
341
|
+
json.dump(plan, f, indent=2, sort_keys=True)
|
|
342
|
+
f.write("\n")
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def load_plan(path):
|
|
346
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
347
|
+
return json.load(f)
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def add_import(lines):
|
|
351
|
+
for i, line in enumerate(lines):
|
|
352
|
+
if re.match(r"\s*from\s+tide\s+import\s+", line):
|
|
353
|
+
if "controlled_tool" in line:
|
|
354
|
+
return lines, False
|
|
355
|
+
stripped = line.rstrip("\n")
|
|
356
|
+
if "(" in stripped:
|
|
357
|
+
return lines[:i] + ["from tide import controlled_tool\n"] + lines[i:], True
|
|
358
|
+
lines[i] = stripped + ", controlled_tool\n"
|
|
359
|
+
return lines, False
|
|
360
|
+
|
|
361
|
+
insert = 0
|
|
362
|
+
if lines and lines[0].startswith("#!"):
|
|
363
|
+
insert = 1
|
|
364
|
+
if len(lines) > insert and "coding" in lines[insert].lower():
|
|
365
|
+
insert += 1
|
|
366
|
+
try:
|
|
367
|
+
tree = ast.parse("".join(lines))
|
|
368
|
+
if tree.body and isinstance(tree.body[0], ast.Expr) and isinstance(getattr(tree.body[0], "value", None), ast.Constant) and isinstance(tree.body[0].value.value, str):
|
|
369
|
+
insert = max(insert, tree.body[0].end_lineno or tree.body[0].lineno)
|
|
370
|
+
except SyntaxError:
|
|
371
|
+
pass
|
|
372
|
+
return lines[:insert] + ["from tide import controlled_tool\n"] + lines[insert:], True
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def function_has_wrapper(lines, line_no):
|
|
376
|
+
start = max(0, line_no - 8)
|
|
377
|
+
end = max(0, line_no - 1)
|
|
378
|
+
return any("@controlled_tool" in line for line in lines[start:end])
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def apply_plan(plan_path=PLAN_FILE, yes=False, only_ids=None, write_policy=False, interactive=False):
|
|
382
|
+
plan = load_plan(plan_path)
|
|
383
|
+
root = plan.get("root") or "."
|
|
384
|
+
only = set(only_ids or [])
|
|
385
|
+
proposed = [a for a in plan.get("actions", []) if a.get("status") == "proposed"]
|
|
386
|
+
selected = []
|
|
387
|
+
skipped = []
|
|
388
|
+
|
|
389
|
+
for action in proposed:
|
|
390
|
+
if only and action.get("id") not in only:
|
|
391
|
+
continue
|
|
392
|
+
if not action.get("accepted", True):
|
|
393
|
+
skipped.append({"id": action.get("id"), "reason": "accepted=false"})
|
|
394
|
+
continue
|
|
395
|
+
if action.get("confidence") == "low" and not yes:
|
|
396
|
+
skipped.append({"id": action.get("id"), "reason": "low confidence requires --yes or explicit id"})
|
|
397
|
+
continue
|
|
398
|
+
if interactive:
|
|
399
|
+
prompt = "%s %s:%s %s [%s -> %s] apply? [Y/n] " % (
|
|
400
|
+
action.get("id"), action.get("file"), action.get("line"),
|
|
401
|
+
action.get("function"), action.get("risk_class"), action.get("policy_decision"),
|
|
402
|
+
)
|
|
403
|
+
answer = input(prompt).strip().lower()
|
|
404
|
+
if answer not in ("", "y", "yes"):
|
|
405
|
+
skipped.append({"id": action.get("id"), "reason": "declined"})
|
|
406
|
+
continue
|
|
407
|
+
selected.append(action)
|
|
408
|
+
|
|
409
|
+
by_file = {}
|
|
410
|
+
for action in selected:
|
|
411
|
+
by_file.setdefault(action["file"], []).append(action)
|
|
412
|
+
|
|
413
|
+
applied = []
|
|
414
|
+
for rel, actions in by_file.items():
|
|
415
|
+
path = os.path.join(root, rel)
|
|
416
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
417
|
+
lines = f.readlines()
|
|
418
|
+
file_applied = []
|
|
419
|
+
for action in sorted(actions, key=lambda a: int(a["line"]), reverse=True):
|
|
420
|
+
line_no = int(action["line"])
|
|
421
|
+
if function_has_wrapper(lines, line_no):
|
|
422
|
+
skipped.append({"id": action.get("id"), "reason": "already wrapped"})
|
|
423
|
+
continue
|
|
424
|
+
indent = re.match(r"^(\s*)", lines[line_no - 1]).group(1)
|
|
425
|
+
lines.insert(line_no - 1, indent + action["decorator"] + "\n")
|
|
426
|
+
file_applied.append(action)
|
|
427
|
+
if file_applied:
|
|
428
|
+
lines, _ = add_import(lines)
|
|
429
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
430
|
+
f.writelines(lines)
|
|
431
|
+
applied.extend(file_applied)
|
|
432
|
+
|
|
433
|
+
if write_policy:
|
|
434
|
+
ledger_dir = os.path.join(root, ".tide", "ledger")
|
|
435
|
+
store.ensure_project(ledger_dir)
|
|
436
|
+
policy_path = os.path.join(ledger_dir, store.POLICY_FILE)
|
|
437
|
+
with open(policy_path, "w", encoding="utf-8") as f:
|
|
438
|
+
json.dump(plan["policy"], f, indent=2, sort_keys=True)
|
|
439
|
+
f.write("\n")
|
|
440
|
+
|
|
441
|
+
result = {
|
|
442
|
+
"applied": len(applied),
|
|
443
|
+
"skipped": skipped,
|
|
444
|
+
"files_changed": sorted({a["file"] for a in applied}),
|
|
445
|
+
"policy_written": bool(write_policy),
|
|
446
|
+
}
|
|
447
|
+
return result
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def text_summary(plan):
|
|
451
|
+
lines = []
|
|
452
|
+
summary = plan["summary"]
|
|
453
|
+
lines.append("Tide scan: %d proposed wrapper(s), %d already wrapped, %d manual review" % (
|
|
454
|
+
summary["proposed"], summary["already_wrapped"], summary["manual_review"],
|
|
455
|
+
))
|
|
456
|
+
for action in plan["actions"]:
|
|
457
|
+
if action.get("status") != "proposed":
|
|
458
|
+
continue
|
|
459
|
+
lines.append(
|
|
460
|
+
"%s %s:%s %s -> %s [%s, %s, %s]" % (
|
|
461
|
+
action["id"], action["file"], action["line"], action["function"],
|
|
462
|
+
action["tool_name"], action["risk_class"], action["policy_decision"],
|
|
463
|
+
action["confidence"],
|
|
464
|
+
)
|
|
465
|
+
)
|
|
466
|
+
for reason in action.get("reasons", [])[:2]:
|
|
467
|
+
lines.append(" - %s" % reason)
|
|
468
|
+
return "\n".join(lines)
|
|
@@ -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
|
|
@@ -19,6 +19,16 @@ tide init --pack support-refunds --environment prod
|
|
|
19
19
|
`tide init --dry-run --pack <name>` prints the policy before writing it.
|
|
20
20
|
Built-in packs: `baseline`, `support-refunds`, `sql-analyst`, `mcp-baseline`.
|
|
21
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
|
+
|
|
22
32
|
```python
|
|
23
33
|
from tide import configure, controlled_tool, record_outcome
|
|
24
34
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|