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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tide-runtime
3
- Version: 0.2.6
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
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "tide-runtime"
7
- version = "0.2.6"
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"
@@ -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()
@@ -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.2.6"
29
+ __version__ = "0.2.7"
30
30
 
31
31
  __all__ = [
32
32
  "configure",
@@ -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.6
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
 
@@ -3,6 +3,7 @@ pyproject.toml
3
3
  tests/test_runtime.py
4
4
  tide/__init__.py
5
5
  tide/cli.py
6
+ tide/instrument.py
6
7
  tide/packs.py
7
8
  tide/policy.py
8
9
  tide/store.py
File without changes
File without changes
File without changes