InnerI commited on
Commit
2de7380
·
verified ·
1 Parent(s): 31e8c88

Upload 2 files

Browse files
Files changed (2) hide show
  1. app.py +777 -0
  2. requirements.txt +8 -0
app.py ADDED
@@ -0,0 +1,777 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ import os, json, time, hmac, hashlib, sqlite3, base64, secrets
3
+ from typing import Any, Dict, List, Optional, Tuple
4
+
5
+ import gradio as gr
6
+ from jsonschema import validate, ValidationError
7
+ import jwt
8
+ import stripe
9
+ from fastapi import FastAPI, Request, HTTPException
10
+ from fastapi.responses import JSONResponse
11
+ from gradio.routes import mount_gradio_app
12
+
13
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
14
+ from cryptography.hazmat.primitives import serialization
15
+ import ast, operator as op, datetime as dt
16
+
17
+ # ---------------- Config ----------------
18
+ APP_TITLE = "Inner I Studio — Secure Agent Hub"
19
+ DB_PATH = os.getenv("INNERI_DB_PATH", "inneri_studio.db")
20
+ RECEIPT_SIGNING_KEY = os.getenv("INNERI_RECEIPT_SIGNING_KEY", "dev_only_change_me")
21
+ JWT_SIGNING_KEY = os.getenv("INNERI_JWT_SIGNING_KEY", "dev_jwt_change_me")
22
+ ADMIN_PASSWORD = os.getenv("INNERI_ADMIN_PASSWORD", "admin")
23
+
24
+ # Stripe (optional)
25
+ STRIPE_SECRET_KEY = os.getenv("STRIPE_SECRET_KEY", "")
26
+ STRIPE_WEBHOOK_SECRET = os.getenv("STRIPE_WEBHOOK_SECRET", "")
27
+
28
+ # Subscription prices
29
+ STRIPE_PRICE_PRO = os.getenv("STRIPE_PRICE_PRO", "") # Pro / Builder
30
+ STRIPE_PRICE_ENTERPRISE = os.getenv("STRIPE_PRICE_ENTERPRISE", "") # Enterprise / Sovereign
31
+
32
+ # Credit pack prices (one-time)
33
+ STRIPE_PRICE_CREDITS_STARTER = os.getenv("STRIPE_PRICE_CREDITS_STARTER", "") # 250
34
+ STRIPE_PRICE_CREDITS_CREATOR = os.getenv("STRIPE_PRICE_CREDITS_CREATOR", "") # 750
35
+ STRIPE_PRICE_CREDITS_POWER = os.getenv("STRIPE_PRICE_CREDITS_POWER", "") # 2500
36
+
37
+ PUBLIC_BASE_URL = os.getenv("PUBLIC_BASE_URL", "") # e.g. https://<user>-<space>.hf.space
38
+
39
+ STRIPE_ENABLED = bool(STRIPE_SECRET_KEY and PUBLIC_BASE_URL)
40
+ if STRIPE_SECRET_KEY:
41
+ stripe.api_key = STRIPE_SECRET_KEY
42
+
43
+ # ---- Plans baked in ----
44
+ PLANS = {
45
+ "free": {"name": "Observer", "runs_per_day": 25, "max_tool_risk": "low"},
46
+ "pro": {"name": "Builder", "runs_per_day": 500, "max_tool_risk": "med"},
47
+ "enterprise": {"name": "Sovereign", "runs_per_day": 5000, "max_tool_risk": "high"},
48
+ }
49
+
50
+ # Credit packs (runs)
51
+ CREDIT_PACKS = {
52
+ "credits_starter": {"name": "Starter Pack", "credits": 250, "price_env": "STRIPE_PRICE_CREDITS_STARTER"},
53
+ "credits_creator": {"name": "Creator Pack", "credits": 750, "price_env": "STRIPE_PRICE_CREDITS_CREATOR"},
54
+ "credits_power": {"name": "Power Pack", "credits": 2500, "price_env": "STRIPE_PRICE_CREDITS_POWER"},
55
+ }
56
+
57
+ # Optional: if user exceeds daily runs, allow using credits to continue (only for free tier)
58
+ RUN_CREDIT_COST = 1
59
+
60
+ # ---------------- Helpers ----------------
61
+ def b64url(data: bytes) -> str:
62
+ return base64.urlsafe_b64encode(data).decode("utf-8").rstrip("=")
63
+
64
+ def canonical_json(obj: Any) -> str:
65
+ return json.dumps(obj, separators=(",", ":"), sort_keys=True, ensure_ascii=False)
66
+
67
+ def sha256_hex(s: str) -> str:
68
+ return hashlib.sha256(s.encode("utf-8")).hexdigest()
69
+
70
+ def sign_hmac(payload: Dict[str, Any], key: str) -> str:
71
+ mac = hmac.new(key.encode("utf-8"), canonical_json(payload).encode("utf-8"), hashlib.sha256).digest()
72
+ return b64url(mac)
73
+
74
+ def now_unix() -> int:
75
+ return int(time.time())
76
+
77
+ def today_yyyymmdd() -> str:
78
+ return time.strftime("%Y%m%d", time.gmtime())
79
+
80
+ # ---------------- DB ----------------
81
+ def db() -> sqlite3.Connection:
82
+ conn = sqlite3.connect(DB_PATH, check_same_thread=False)
83
+ conn.row_factory = sqlite3.Row
84
+ return conn
85
+
86
+ def gen_ed25519_keypair() -> Tuple[str, str]:
87
+ priv = Ed25519PrivateKey.generate()
88
+ priv_pem = priv.private_bytes(
89
+ encoding=serialization.Encoding.PEM,
90
+ format=serialization.PrivateFormat.PKCS8,
91
+ encryption_algorithm=serialization.NoEncryption(),
92
+ ).decode("utf-8")
93
+ pub_pem = priv.public_key().public_bytes(
94
+ encoding=serialization.Encoding.PEM,
95
+ format=serialization.PublicFormat.SubjectPublicKeyInfo,
96
+ ).decode("utf-8")
97
+ return priv_pem, pub_pem
98
+
99
+ def seed_tools(conn: sqlite3.Connection):
100
+ tools = [
101
+ ("echo", "Echo", "Returns the provided text.", "low",
102
+ {"type":"object","properties":{"text":{"type":"string","maxLength":2000}},"required":["text"],"additionalProperties":False}),
103
+ ("time_now", "Time Now", "Returns server time (UTC).", "low",
104
+ {"type":"object","properties":{},"additionalProperties":False}),
105
+ ("math_eval", "Math Eval", "Evaluates a simple arithmetic expression.", "med",
106
+ {"type":"object","properties":{"expression":{"type":"string","maxLength":200}},"required":["expression"],"additionalProperties":False}),
107
+ ]
108
+ cur = conn.cursor()
109
+ for t in tools:
110
+ cur.execute("INSERT OR IGNORE INTO tools(tool_id,name,description,risk,json_schema,enabled,version) VALUES(?,?,?,?,?,?,?)",
111
+ (t[0], t[1], t[2], t[3], json.dumps(t[4]), 1, 1))
112
+ conn.commit()
113
+
114
+ def init_db():
115
+ conn = db()
116
+ cur = conn.cursor()
117
+ cur.executescript("""
118
+ CREATE TABLE IF NOT EXISTS users (
119
+ user_id INTEGER PRIMARY KEY AUTOINCREMENT,
120
+ email TEXT UNIQUE NOT NULL,
121
+ password_hash TEXT NOT NULL,
122
+ role TEXT NOT NULL DEFAULT 'user',
123
+ tier TEXT NOT NULL DEFAULT 'free',
124
+ credits INTEGER NOT NULL DEFAULT 0,
125
+ stripe_customer_id TEXT,
126
+ subscription_status TEXT DEFAULT 'none',
127
+ created_at INTEGER NOT NULL
128
+ );
129
+
130
+ CREATE TABLE IF NOT EXISTS agents (
131
+ agent_id TEXT PRIMARY KEY,
132
+ display_name TEXT NOT NULL,
133
+ verification_level TEXT NOT NULL DEFAULT 'none',
134
+ risk_tier TEXT NOT NULL DEFAULT 'low',
135
+ public_key_pem TEXT NOT NULL,
136
+ created_at INTEGER NOT NULL
137
+ );
138
+
139
+ CREATE TABLE IF NOT EXISTS tools (
140
+ tool_id TEXT PRIMARY KEY,
141
+ name TEXT NOT NULL,
142
+ description TEXT NOT NULL,
143
+ risk TEXT NOT NULL DEFAULT 'low',
144
+ json_schema TEXT NOT NULL,
145
+ enabled INTEGER NOT NULL DEFAULT 1,
146
+ version INTEGER NOT NULL DEFAULT 1
147
+ );
148
+
149
+ CREATE TABLE IF NOT EXISTS reputations (
150
+ agent_id TEXT PRIMARY KEY,
151
+ score INTEGER NOT NULL DEFAULT 50,
152
+ updated_at INTEGER NOT NULL
153
+ );
154
+
155
+ CREATE TABLE IF NOT EXISTS usage_daily (
156
+ user_id INTEGER NOT NULL,
157
+ day TEXT NOT NULL,
158
+ runs INTEGER NOT NULL DEFAULT 0,
159
+ PRIMARY KEY (user_id, day)
160
+ );
161
+
162
+ CREATE TABLE IF NOT EXISTS audit_log (
163
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
164
+ ts INTEGER NOT NULL,
165
+ actor_agent_id TEXT,
166
+ actor_user_id INTEGER,
167
+ action TEXT NOT NULL,
168
+ request_json TEXT NOT NULL,
169
+ result_json TEXT NOT NULL,
170
+ prev_hash TEXT,
171
+ row_hash TEXT NOT NULL
172
+ );
173
+
174
+ CREATE TABLE IF NOT EXISTS sessions (
175
+ token TEXT PRIMARY KEY,
176
+ user_id INTEGER NOT NULL,
177
+ created_at INTEGER NOT NULL,
178
+ expires_at INTEGER NOT NULL
179
+ );
180
+ """)
181
+ conn.commit()
182
+
183
+ # admin user (email=admin@local)
184
+ cur.execute("SELECT user_id FROM users WHERE email=?", ("admin@local",))
185
+ if not cur.fetchone():
186
+ ph = sha256_hex("admin@local:" + ADMIN_PASSWORD)
187
+ cur.execute("INSERT INTO users(email,password_hash,role,tier,credits,created_at) VALUES(?,?,?,?,?,?)",
188
+ ("admin@local", ph, "admin", "pro", 0, now_unix()))
189
+ conn.commit()
190
+
191
+ # seed tools
192
+ cur.execute("SELECT tool_id FROM tools WHERE tool_id='echo'")
193
+ if not cur.fetchone():
194
+ seed_tools(conn)
195
+
196
+ # seed a safe public agent
197
+ cur.execute("SELECT agent_id FROM agents WHERE agent_id='agent_public_01'")
198
+ if not cur.fetchone():
199
+ _, pub = gen_ed25519_keypair()
200
+ cur.execute("INSERT INTO agents(agent_id,display_name,verification_level,risk_tier,public_key_pem,created_at) VALUES(?,?,?,?,?,?)",
201
+ ("agent_public_01", "Inner I Public Agent", "none", "low", pub, now_unix()))
202
+ cur.execute("INSERT OR IGNORE INTO reputations(agent_id,score,updated_at) VALUES(?,?,?)",
203
+ ("agent_public_01", 50, now_unix()))
204
+ conn.commit()
205
+
206
+ conn.close()
207
+
208
+ def audit_append(actor_agent_id: Optional[str], actor_user_id: Optional[int], action: str, request_obj: Dict[str, Any], result_obj: Dict[str, Any]) -> Dict[str, Any]:
209
+ conn = db()
210
+ cur = conn.cursor()
211
+ cur.execute("SELECT row_hash FROM audit_log ORDER BY id DESC LIMIT 1")
212
+ row = cur.fetchone()
213
+ prev_hash = row["row_hash"] if row else None
214
+ row_obj = {"actor_agent_id": actor_agent_id, "actor_user_id": actor_user_id, "action": action, "request": request_obj, "result": result_obj, "prev_hash": prev_hash}
215
+ row_hash = sha256_hex(canonical_json(row_obj))
216
+ cur.execute("INSERT INTO audit_log(ts,actor_agent_id,actor_user_id,action,request_json,result_json,prev_hash,row_hash) VALUES(?,?,?,?,?,?,?,?)",
217
+ (now_unix(), actor_agent_id, actor_user_id, action, json.dumps(request_obj), json.dumps(result_obj), prev_hash, row_hash))
218
+ conn.commit()
219
+ audit_id = cur.lastrowid
220
+ conn.close()
221
+ return {"audit_id": audit_id, "row_hash": row_hash, "prev_hash": prev_hash}
222
+
223
+ # ---------------- Auth ----------------
224
+ def hash_pw(email: str, password: str) -> str:
225
+ return sha256_hex(f"{email}:{password}")
226
+
227
+ def signup(email: str, password: str) -> str:
228
+ email = email.strip().lower()
229
+ if not email or "@" not in email:
230
+ raise gr.Error("Enter a valid email")
231
+ if len(password) < 8:
232
+ raise gr.Error("Password must be 8+ chars")
233
+ conn = db()
234
+ cur = conn.cursor()
235
+ try:
236
+ cur.execute("INSERT INTO users(email,password_hash,role,tier,credits,created_at) VALUES(?,?,?,?,?,?)",
237
+ (email, hash_pw(email, password), "user", "free", 0, now_unix()))
238
+ conn.commit()
239
+ except sqlite3.IntegrityError:
240
+ conn.close()
241
+ raise gr.Error("Email already registered")
242
+ conn.close()
243
+ return "✅ account_created"
244
+
245
+ def login(email: str, password: str) -> Tuple[str, str]:
246
+ email = email.strip().lower()
247
+ conn = db()
248
+ cur = conn.cursor()
249
+ cur.execute("SELECT user_id,password_hash FROM users WHERE email=?", (email,))
250
+ row = cur.fetchone()
251
+ conn.close()
252
+ if not row:
253
+ return "", "❌ user_not_found"
254
+ if hash_pw(email, password) != row["password_hash"]:
255
+ return "", "❌ bad_password"
256
+ token = b64url(secrets.token_bytes(24))
257
+ now = now_unix()
258
+ exp = now + 3600 * 8
259
+ conn = db()
260
+ cur = conn.cursor()
261
+ cur.execute("INSERT INTO sessions(token,user_id,created_at,expires_at) VALUES(?,?,?,?)", (token, row["user_id"], now, exp))
262
+ conn.commit()
263
+ conn.close()
264
+ return token, "✅ logged_in"
265
+
266
+ def get_session_user(session_token: str) -> Dict[str, Any]:
267
+ conn = db()
268
+ cur = conn.cursor()
269
+ cur.execute("""
270
+ SELECT u.user_id,u.email,u.role,u.tier,u.credits,u.subscription_status,s.expires_at
271
+ FROM sessions s JOIN users u ON u.user_id=s.user_id
272
+ WHERE s.token=?
273
+ """, (session_token,))
274
+ row = cur.fetchone()
275
+ conn.close()
276
+ if not row:
277
+ raise gr.Error("Not logged in")
278
+ if int(row["expires_at"]) < now_unix():
279
+ raise gr.Error("Session expired")
280
+ return dict(row)
281
+
282
+ def require_admin(session_token: str) -> Dict[str, Any]:
283
+ u = get_session_user(session_token)
284
+ if u.get("role") != "admin":
285
+ raise gr.Error("Admin only")
286
+ return u
287
+
288
+ # ---------------- Limits ----------------
289
+ def check_and_increment_run(user: Dict[str, Any]) -> None:
290
+ day = today_yyyymmdd()
291
+ tier = user["tier"] if user["role"] != "admin" else "enterprise"
292
+ if tier not in PLANS:
293
+ tier = "free"
294
+ limit = PLANS[tier]["runs_per_day"]
295
+
296
+ conn = db()
297
+ cur = conn.cursor()
298
+ cur.execute("INSERT OR IGNORE INTO usage_daily(user_id,day,runs) VALUES(?,?,0)", (user["user_id"], day))
299
+ cur.execute("SELECT runs FROM usage_daily WHERE user_id=? AND day=?", (user["user_id"], day))
300
+ runs = int(cur.fetchone()["runs"])
301
+
302
+ # Always increment the run counter once we allow the run.
303
+ if runs >= limit and user["role"] != "admin":
304
+ # Free tier may continue using credits (optional)
305
+ if user["tier"] == "free" and user["credits"] >= RUN_CREDIT_COST:
306
+ # consume credits (pay-as-you-go burst)
307
+ cur.execute("UPDATE users SET credits=credits-? WHERE user_id=? AND credits>=?",
308
+ (RUN_CREDIT_COST, user["user_id"], RUN_CREDIT_COST))
309
+ if cur.rowcount != 1:
310
+ conn.close()
311
+ raise gr.Error(f"Daily limit reached ({limit}). Upgrade to {PLANS['pro']['name']} or buy credits.")
312
+ else:
313
+ conn.close()
314
+ raise gr.Error(f"Daily limit reached ({limit}). Upgrade to {PLANS['pro']['name']} or buy credits.")
315
+
316
+ cur.execute("UPDATE usage_daily SET runs=runs+1 WHERE user_id=? AND day=?", (user["user_id"], day))
317
+ conn.commit()
318
+ conn.close()
319
+ raise gr.Error(f"Daily limit reached ({limit}). Upgrade to Pro.")
320
+ cur.execute("UPDATE usage_daily SET runs=runs+1 WHERE user_id=? AND day=?", (user["user_id"], day))
321
+ conn.commit()
322
+ conn.close()
323
+
324
+ def maybe_charge_credit(user: Dict[str, Any]) -> None:
325
+ # Credits are consumed only when a free user exceeds the daily limit (burst mode).
326
+ return
327
+ raise gr.Error("No credits left. Buy a credit pack or upgrade to Pro.")
328
+ conn.commit()
329
+ conn.close()
330
+
331
+ # ---------------- Policy + tools ----------------
332
+ def policy_decide(agent: Dict[str, Any], request: Dict[str, Any]) -> Dict[str, Any]:
333
+ if "secret" in (request.get("data_scopes") or []):
334
+ return {"allow": False, "mode": "deny", "reasons": ["secret_scope_blocked"]}
335
+ tools = request.get("tools") or []
336
+ if agent.get("risk_tier") == "high" or any(t.get("risk") == "high" for t in tools):
337
+ return {"allow": False, "mode": "deny", "reasons": ["high_risk_blocked"]}
338
+ if agent.get("verification_level") == "none" or any(t.get("risk") == "med" for t in tools):
339
+ return {"allow": True, "mode": "sandbox", "reasons": ["sandbox_unverified_or_med"]}
340
+ return {"allow": True, "mode": "normal", "reasons": []}
341
+
342
+ _OPS = {ast.Add: op.add, ast.Sub: op.sub, ast.Mult: op.mul, ast.Div: op.truediv, ast.Pow: op.pow, ast.USub: op.neg, ast.Mod: op.mod, ast.FloorDiv: op.floordiv}
343
+ def _eval(node):
344
+ if isinstance(node, ast.Num):
345
+ return node.n
346
+ if isinstance(node, ast.BinOp) and type(node.op) in _OPS:
347
+ return _OPS[type(node.op)](_eval(node.left), _eval(node.right))
348
+ if isinstance(node, ast.UnaryOp) and type(node.op) in _OPS:
349
+ return _OPS[type(node.op)](_eval(node.operand))
350
+ raise ValueError("Unsupported expression")
351
+
352
+ def run_tool(tool_id: str, args: Dict[str, Any]) -> Dict[str, Any]:
353
+ if tool_id == "echo":
354
+ return {"text": args["text"]}
355
+ if tool_id == "time_now":
356
+ return {"utc": dt.datetime.utcnow().isoformat() + "Z"}
357
+ if tool_id == "math_eval":
358
+ tree = ast.parse(args["expression"], mode="eval").body
359
+ return {"value": _eval(tree)}
360
+ raise ValueError(f"Unknown tool_id: {tool_id}")
361
+
362
+ def secure_call(user: Dict[str, Any], agent_id: str, intent: str, tools: List[Dict[str, Any]]) -> Dict[str, Any]:
363
+ check_and_increment_run(user)
364
+ conn = db()
365
+ cur = conn.cursor()
366
+ cur.execute("SELECT * FROM agents WHERE agent_id=?", (agent_id,))
367
+ a = cur.fetchone()
368
+ if not a:
369
+ conn.close()
370
+ raise gr.Error("agent_not_found")
371
+ agent = dict(a)
372
+
373
+ tools_meta = []
374
+ for tc in tools:
375
+ cur.execute("SELECT * FROM tools WHERE tool_id=?", (tc["tool_id"],))
376
+ t = cur.fetchone()
377
+ if not t or int(t["enabled"]) != 1:
378
+ conn.close()
379
+ raise gr.Error(f"tool_not_found_or_disabled: {tc['tool_id']}")
380
+ tools_meta.append({"tool_id": t["tool_id"], "risk": t["risk"], "schema": json.loads(t["json_schema"])})
381
+
382
+ decision = policy_decide(agent, {"intent": intent, "tools": [{"tool_id": tm["tool_id"], "risk": tm["risk"]} for tm in tools_meta], "data_scopes": ["public"]})
383
+ if not decision["allow"]:
384
+ conn.close()
385
+ audit_append(agent_id, user["user_id"], "secure_call.deny", {"intent": intent, "tools": tools}, {"decision": decision})
386
+ return {"denied": True, "decision": decision}
387
+
388
+ mode = decision["mode"]
389
+ outputs = []
390
+ # Tier-based tool risk gating (monetization + safety)
391
+ tier = user["tier"] if user["role"] != "admin" else "enterprise"
392
+ if tier not in PLANS:
393
+ tier = "free"
394
+ tier_max = PLANS[tier]["max_tool_risk"]
395
+ risk_rank = {"low": 1, "med": 2, "high": 3}
396
+ tier_max_rank = risk_rank[tier_max]
397
+ for tc, tm in zip(tools, tools_meta):
398
+ try:
399
+ validate(instance=tc.get("args", {}), schema=tm["schema"])
400
+ except ValidationError:
401
+ conn.close()
402
+ raise gr.Error(f"args_schema_invalid: {tm['tool_id']}")
403
+
404
+ # Plan gating: block tools above tier max risk
405
+ if risk_rank.get(tm["risk"], 3) > tier_max_rank:
406
+ outputs.append({"tool_id": tm["tool_id"], "blocked": True, "reason": f"plan_blocks_{tm['risk']}_risk"})
407
+ continue
408
+ if mode == "sandbox" and tm["risk"] != "low":
409
+ outputs.append({"tool_id": tm["tool_id"], "blocked": True, "reason": "sandbox_mode"})
410
+ continue
411
+
412
+ try:
413
+ outputs.append({"tool_id": tm["tool_id"], "output": run_tool(tm["tool_id"], tc.get("args", {}))})
414
+ except Exception as e:
415
+ outputs.append({"tool_id": tm["tool_id"], "error": str(e)})
416
+
417
+ # receipts + audit chain
418
+ receipt = {
419
+ "ts_unix": now_unix(),
420
+ "agent_id": agent_id,
421
+ "user_id": user["user_id"],
422
+ "intent": intent,
423
+ "mode": mode,
424
+ "decision": decision,
425
+ "outputs_hash": sha256_hex(canonical_json(outputs)),
426
+ }
427
+ receipt["signature"] = sign_hmac(receipt, RECEIPT_SIGNING_KEY)
428
+ audit = audit_append(agent_id, user["user_id"], "secure_call.run", {"intent": intent, "tools": tools}, {"outputs": outputs, "receipt": receipt})
429
+ conn.close()
430
+ return {"outputs": outputs, "receipt": receipt, "audit": audit}
431
+
432
+ # ---------------- Admin actions ----------------
433
+ def create_agent_admin(session_token: str, agent_id: str, display_name: str, risk_tier: str, verification_level: str):
434
+ require_admin(session_token)
435
+ priv_pem, pub_pem = gen_ed25519_keypair()
436
+ conn = db()
437
+ cur = conn.cursor()
438
+ cur.execute("INSERT INTO agents(agent_id,display_name,verification_level,risk_tier,public_key_pem,created_at) VALUES(?,?,?,?,?,?)",
439
+ (agent_id, display_name, verification_level, risk_tier, pub_pem, now_unix()))
440
+ conn.commit()
441
+ conn.close()
442
+ return "✅ agent_created (save private key)", priv_pem, pub_pem
443
+
444
+ def list_agents_admin(session_token: str):
445
+ require_admin(session_token)
446
+ conn = db()
447
+ cur = conn.cursor()
448
+ cur.execute("SELECT agent_id,display_name,verification_level,risk_tier,created_at FROM agents ORDER BY created_at DESC")
449
+ rows = cur.fetchall()
450
+ conn.close()
451
+ return [[r["agent_id"], r["display_name"], r["verification_level"], r["risk_tier"], str(r["created_at"])] for r in rows]
452
+
453
+ def list_tools_admin(session_token: str):
454
+ require_admin(session_token)
455
+ conn = db()
456
+ cur = conn.cursor()
457
+ cur.execute("SELECT tool_id,name,risk,enabled,version,description FROM tools ORDER BY tool_id")
458
+ rows = cur.fetchall()
459
+ conn.close()
460
+ return [[r["tool_id"], r["name"], r["risk"], "yes" if int(r["enabled"])==1 else "no", str(r["version"]), r["description"]] for r in rows]
461
+
462
+ def upsert_tool_admin(session_token: str, tool_id: str, name: str, description: str, risk: str, enabled: bool, schema_json: str):
463
+ require_admin(session_token)
464
+ try:
465
+ schema = json.loads(schema_json)
466
+ if schema.get("type") != "object":
467
+ raise ValueError("schema.type must be object")
468
+ except Exception as e:
469
+ raise gr.Error(f"Invalid schema JSON: {e}")
470
+ conn = db()
471
+ cur = conn.cursor()
472
+ cur.execute("SELECT version FROM tools WHERE tool_id=?", (tool_id,))
473
+ row = cur.fetchone()
474
+ version = (int(row["version"]) + 1) if row else 1
475
+ cur.execute("""
476
+ INSERT INTO tools(tool_id,name,description,risk,json_schema,enabled,version)
477
+ VALUES(?,?,?,?,?,?,?)
478
+ ON CONFLICT(tool_id) DO UPDATE SET
479
+ name=excluded.name,
480
+ description=excluded.description,
481
+ risk=excluded.risk,
482
+ json_schema=excluded.json_schema,
483
+ enabled=excluded.enabled,
484
+ version=?
485
+ """, (tool_id, name, description, risk, json.dumps(schema), 1 if enabled else 0, version, version))
486
+ conn.commit()
487
+ conn.close()
488
+ return f"✅ saved_tool `{tool_id}` v{version}"
489
+
490
+ def read_audit_admin(session_token: str, limit: int):
491
+ require_admin(session_token)
492
+ conn = db()
493
+ cur = conn.cursor()
494
+ cur.execute("SELECT id,ts,actor_agent_id,actor_user_id,action,prev_hash,row_hash FROM audit_log ORDER BY id DESC LIMIT ?", (limit,))
495
+ rows = cur.fetchall()
496
+ conn.close()
497
+ return [[str(r["id"]), str(r["ts"]), str(r["actor_agent_id"] or ""), str(r["actor_user_id"] or ""), r["action"], str(r["prev_hash"] or ""), r["row_hash"]] for r in rows]
498
+
499
+ def admin_set_user_tier(session_token: str, email: str, tier: str) -> str:
500
+ require_admin(session_token)
501
+ email = email.strip().lower()
502
+ if tier not in ("free","pro"):
503
+ raise gr.Error("tier must be free or pro")
504
+ conn = db()
505
+ cur = conn.cursor()
506
+ cur.execute("UPDATE users SET tier=? WHERE email=?", (tier, email))
507
+ conn.commit()
508
+ n = cur.rowcount
509
+ conn.close()
510
+ return f"✅ updated {n} user(s)"
511
+
512
+ # ---------------- Billing ----------------
513
+ def user_me(session_token: str) -> str:
514
+ u = get_session_user(session_token)
515
+ safe = {k: u[k] for k in ["user_id","email","role","tier","credits","subscription_status"]}
516
+ safe["plan_name"] = PLANS.get(safe["tier"], PLANS["free"])["name"] if safe["role"] != "admin" else PLANS["enterprise"]["name"]
517
+ safe["stripe_enabled"] = STRIPE_ENABLED
518
+ return json.dumps(safe, indent=2)
519
+
520
+ def create_checkout_session(session_token: str, kind: str) -> str:
521
+ if not STRIPE_ENABLED:
522
+ raise gr.Error("Stripe not configured (set STRIPE_SECRET_KEY + PUBLIC_BASE_URL).")
523
+ u = get_session_user(session_token)
524
+
525
+ # Subscriptions
526
+ if kind == "pro":
527
+ if not STRIPE_PRICE_PRO:
528
+ raise gr.Error("STRIPE_PRICE_PRO not set")
529
+ line_items = [{"price": STRIPE_PRICE_PRO, "quantity": 1}]
530
+ mode = "subscription"
531
+ meta = {"user_id": str(u["user_id"]), "kind": "pro"}
532
+ elif kind == "enterprise":
533
+ if not STRIPE_PRICE_ENTERPRISE:
534
+ raise gr.Error("STRIPE_PRICE_ENTERPRISE not set")
535
+ line_items = [{"price": STRIPE_PRICE_ENTERPRISE, "quantity": 1}]
536
+ mode = "subscription"
537
+ meta = {"user_id": str(u["user_id"]), "kind": "enterprise"}
538
+
539
+ # Credit packs (one-time)
540
+ elif kind in CREDIT_PACKS:
541
+ pack = CREDIT_PACKS[kind]
542
+ price_id = os.getenv(pack["price_env"], "")
543
+ if not price_id:
544
+ raise gr.Error(f"{pack['price_env']} not set")
545
+ line_items = [{"price": price_id, "quantity": 1}]
546
+ mode = "payment"
547
+ meta = {"user_id": str(u["user_id"]), "kind": kind}
548
+
549
+ else:
550
+ raise gr.Error("Unknown checkout kind")
551
+
552
+ success_url = f"{PUBLIC_BASE_URL}/?paid=1"
553
+ cancel_url = f"{PUBLIC_BASE_URL}/?canceled=1"
554
+
555
+ sess = stripe.checkout.Session.create(
556
+ mode=mode,
557
+ line_items=line_items,
558
+ client_reference_id=str(u["user_id"]),
559
+ metadata=meta,
560
+ success_url=success_url,
561
+ cancel_url=cancel_url,
562
+ )
563
+ return sess.url
564
+
565
+ # ---------------- UI ----------------
566
+ init_db()
567
+
568
+ def chat_turn(session_token: str, agent_id: str, user_msg: str, tool_plan_json: str, history):
569
+ u = get_session_user(session_token)
570
+ tools = []
571
+ if tool_plan_json.strip():
572
+ try:
573
+ tools = json.loads(tool_plan_json)
574
+ if not isinstance(tools, list):
575
+ raise ValueError("tool_plan must be a JSON list")
576
+ tools = [{"tool_id": t["tool_id"], "args": t.get("args", {})} for t in tools]
577
+ except Exception as e:
578
+ raise gr.Error(f"Bad tool_plan JSON: {e}")
579
+ if not tools:
580
+ tools = [{"tool_id":"echo","args":{"text": user_msg}}]
581
+
582
+ res = secure_call(u, agent_id=agent_id, intent="studio_chat_turn", tools=tools)
583
+ reply = {"tier": u["tier"], "outputs": res["outputs"], "receipt": res["receipt"], "audit": res["audit"]}
584
+ history = history + [(user_msg, json.dumps(reply, indent=2))]
585
+ return history, ""
586
+
587
+ with gr.Blocks(title=APP_TITLE) as demo:
588
+ gr.Markdown(f"# {APP_TITLE}\nPublic users: chat + workflows. Admin: control plane + audit.")
589
+
590
+ with gr.Tab("Account"):
591
+ gr.Markdown("### Signup")
592
+ su_email = gr.Textbox(label="Email")
593
+ su_pass = gr.Textbox(label="Password (8+ chars)", type="password")
594
+ su_btn = gr.Button("Create account")
595
+ su_out = gr.Markdown()
596
+ su_btn.click(signup, inputs=[su_email, su_pass], outputs=[su_out])
597
+
598
+ gr.Markdown("### Login")
599
+ li_email = gr.Textbox(label="Email", value="admin@local")
600
+ li_pass = gr.Textbox(label="Password", type="password", value="")
601
+ li_btn = gr.Button("Login")
602
+ session = gr.Textbox(label="Session Token (keep private)", interactive=False)
603
+ li_out = gr.Markdown()
604
+ li_btn.click(login, inputs=[li_email, li_pass], outputs=[session, li_out])
605
+
606
+ gr.Markdown("### Me")
607
+ me_btn = gr.Button("Refresh")
608
+ me_out = gr.Code(label="Current user")
609
+ me_btn.click(user_me, inputs=[session], outputs=[me_out])
610
+
611
+ with gr.Tab("Studio Chat"):
612
+ agent_id = gr.Textbox(label="Agent ID", value="agent_public_01")
613
+ tool_plan = gr.Textbox(label="tool_plan (optional JSON list)", lines=6,
614
+ value='[{"tool_id":"echo","args":{"text":"Hello from Inner I Studio"}}]')
615
+ chat = gr.Chatbot(height=420)
616
+ msg = gr.Textbox(label="Message")
617
+ send = gr.Button("Send")
618
+ clear = gr.Button("Clear")
619
+ state = gr.State([])
620
+ send.click(chat_turn, inputs=[session, agent_id, msg, tool_plan, state], outputs=[chat, msg])
621
+ clear.click(lambda: ([], ""), outputs=[chat, msg])
622
+
623
+ with gr.Tab("Billing"):
624
+ gr.Markdown(
625
+ f"### Plans
626
+ "
627
+ f"- **{PLANS['free']['name']} (Free)**: {PLANS['free']['runs_per_day']} runs/day, low-risk tools
628
+ "
629
+ f"- **{PLANS['pro']['name']} (Pro)**: {PLANS['pro']['runs_per_day']} runs/day, medium-risk tools
630
+ "
631
+ f"- **{PLANS['enterprise']['name']} (Enterprise)**: {PLANS['enterprise']['runs_per_day']} runs/day, high-risk tools
632
+
633
+ "
634
+ "Stripe billing is built-in (optional). If Stripe isn't configured, you can set the Space to Private/Paid as a paywall."
635
+ )
636
+ pro_btn = gr.Button(f"Subscribe — {PLANS['pro']['name']}")
637
+ ent_btn = gr.Button(f"Subscribe — {PLANS['enterprise']['name']}")
638
+ starter_btn = gr.Button("Buy Credits — Starter (250)")
639
+ creator_btn = gr.Button("Buy Credits — Creator (750)")
640
+ power_btn = gr.Button("Buy Credits — Power (2500)")
641
+ pay_url = gr.Textbox(label="Checkout URL", interactive=False)
642
+
643
+ pro_btn.click(lambda s: create_checkout_session(s, "pro"), inputs=[session], outputs=[pay_url])
644
+ ent_btn.click(lambda s: create_checkout_session(s, "enterprise"), inputs=[session], outputs=[pay_url])
645
+ starter_btn.click(lambda s: create_checkout_session(s, "credits_starter"), inputs=[session], outputs=[pay_url])
646
+ creator_btn.click(lambda s: create_checkout_session(s, "credits_creator"), inputs=[session], outputs=[pay_url])
647
+ power_btn.click(lambda s: create_checkout_session(s, "credits_power"), inputs=[session], outputs=[pay_url])
648
+
649
+ # Admin-only tabs (enforced server-side by require_admin)
650
+ with gr.Tab("Admin — Agents"):
651
+ gr.Markdown("Admin only.")
652
+ a_id = gr.Textbox(label="agent_id", value="agent_builder_01")
653
+ a_name = gr.Textbox(label="display_name", value="Inner I Builder Agent")
654
+ a_risk = gr.Dropdown(["low","med","high"], value="low", label="risk_tier")
655
+ a_ver = gr.Dropdown(["none","basic","full","continuous"], value="basic", label="verification_level")
656
+ a_create = gr.Button("Create Agent + Keys")
657
+ a_status = gr.Markdown()
658
+ a_priv = gr.Textbox(label="Private Key PEM (SAVE SECURELY)", lines=10)
659
+ a_pub = gr.Textbox(label="Public Key PEM", lines=8)
660
+ a_create.click(create_agent_admin, inputs=[session,a_id,a_name,a_risk,a_ver], outputs=[a_status,a_priv,a_pub])
661
+
662
+ a_refresh = gr.Button("Refresh Agents")
663
+ a_tbl = gr.Dataframe(headers=["agent_id","display_name","verification","risk","created_at"], interactive=False)
664
+ a_refresh.click(list_agents_admin, inputs=[session], outputs=[a_tbl])
665
+
666
+ with gr.Tab("Admin — Tools"):
667
+ gr.Markdown("Admin only.")
668
+ t_refresh = gr.Button("Refresh Tools")
669
+ t_tbl = gr.Dataframe(headers=["tool_id","name","risk","enabled","version","description"], interactive=False)
670
+ t_refresh.click(list_tools_admin, inputs=[session], outputs=[t_tbl])
671
+
672
+ t_id = gr.Textbox(label="tool_id", value="echo")
673
+ t_name = gr.Textbox(label="name", value="Echo")
674
+ t_desc = gr.Textbox(label="description", value="Returns provided text.")
675
+ t_risk = gr.Dropdown(["low","med","high"], value="low", label="risk")
676
+ t_enabled = gr.Checkbox(value=True, label="enabled")
677
+ t_schema = gr.Textbox(label="json_schema (JSON)", lines=8, value=json.dumps({
678
+ "type":"object","properties":{"text":{"type":"string","maxLength":2000}},"required":["text"],"additionalProperties": False
679
+ }, indent=2))
680
+ t_save = gr.Button("Save Tool")
681
+ t_out = gr.Markdown()
682
+ t_save.click(upsert_tool_admin, inputs=[session,t_id,t_name,t_desc,t_risk,t_enabled,t_schema], outputs=[t_out])
683
+
684
+ with gr.Tab("Admin — Audit"):
685
+ gr.Markdown("Admin only.")
686
+ lim = gr.Slider(10, 200, value=50, step=10, label="rows")
687
+ aud_btn = gr.Button("Load Audit")
688
+ aud_tbl = gr.Dataframe(headers=["id","ts","actor_agent","actor_user","action","prev_hash","row_hash"], interactive=False)
689
+ aud_btn.click(read_audit_admin, inputs=[session, lim], outputs=[aud_tbl])
690
+
691
+ with gr.Tab("Admin — Users"):
692
+ gr.Markdown("Admin only. Emergency tier changes.")
693
+ u_email = gr.Textbox(label="User email")
694
+ u_tier = gr.Dropdown(["free","pro"], value="pro", label="Set tier")
695
+ u_btn = gr.Button("Update tier")
696
+ u_out = gr.Markdown()
697
+ u_btn.click(admin_set_user_tier, inputs=[session,u_email,u_tier], outputs=[u_out])
698
+
699
+ gr.Markdown("---\n**Inner I Principle:** If it can’t be verified, it shouldn’t run.")
700
+
701
+ # ---------------- FastAPI + Stripe webhook ----------------
702
+ app = FastAPI()
703
+
704
+ @app.get("/healthz")
705
+ def healthz():
706
+ return {"ok": True, "stripe_enabled": STRIPE_ENABLED}
707
+
708
+ @app.post("/stripe/webhook")
709
+ async def stripe_webhook(request: Request):
710
+ if not STRIPE_WEBHOOK_SECRET:
711
+ raise HTTPException(status_code=400, detail="webhook_not_configured")
712
+ payload = await request.body()
713
+ sig = request.headers.get("stripe-signature", "")
714
+ try:
715
+ event = stripe.Webhook.construct_event(payload=payload, sig_header=sig, secret=STRIPE_WEBHOOK_SECRET)
716
+ except Exception as e:
717
+ raise HTTPException(status_code=400, detail=f"bad_signature:{e}")
718
+
719
+ etype = event["type"]
720
+ data = event["data"]["object"]
721
+
722
+ def get_uid(obj) -> Optional[int]:
723
+ md = obj.get("metadata") or {}
724
+ uid = md.get("user_id")
725
+ if uid and str(uid).isdigit():
726
+ return int(uid)
727
+ cr = obj.get("client_reference_id")
728
+ if cr and str(cr).isdigit():
729
+ return int(cr)
730
+ return None
731
+
732
+ conn = db()
733
+ cur = conn.cursor()
734
+
735
+ if etype == "checkout.session.completed":
736
+ uid = get_uid(data)
737
+ kind = (data.get("metadata") or {}).get("kind", "")
738
+ customer = data.get("customer")
739
+ if uid:
740
+ if customer:
741
+ cur.execute("UPDATE users SET stripe_customer_id=? WHERE user_id=?", (customer, uid))
742
+
743
+ if kind == "pro":
744
+ cur.execute("UPDATE users SET tier='pro', subscription_status='active' WHERE user_id=?", (uid,))
745
+ elif kind == "enterprise":
746
+ cur.execute("UPDATE users SET tier='enterprise', subscription_status='active' WHERE user_id=?", (uid,))
747
+ elif kind in CREDIT_PACKS:
748
+ amt = CREDIT_PACKS[kind]["credits"]
749
+ cur.execute("UPDATE users SET credits=credits+? WHERE user_id=?", (amt, uid))
750
+
751
+ conn.commit()
752
+
753
+ elif etype in ("customer.subscription.created", "customer.subscription.updated"):
754
+ customer = data.get("customer")
755
+ status = data.get("status", "unknown")
756
+ if customer:
757
+ # If subscription active/trialing -> keep enterprise if already, else pro. If not active -> free.
758
+ if status in ("active", "trialing"):
759
+ cur.execute("SELECT tier FROM users WHERE stripe_customer_id=?", (customer,))
760
+ row = cur.fetchone()
761
+ current = (row["tier"] if row else "free")
762
+ new_tier = "enterprise" if current == "enterprise" else "pro"
763
+ else:
764
+ new_tier = "free"
765
+ cur.execute("UPDATE users SET tier=?, subscription_status=? WHERE stripe_customer_id=?", (new_tier, status, customer))
766
+ conn.commit()
767
+
768
+ elif etype == "customer.subscription.deleted":
769
+ customer = data.get("customer")
770
+ if customer:
771
+ cur.execute("UPDATE users SET tier='free', subscription_status='canceled' WHERE stripe_customer_id=?", (customer,))
772
+ conn.commit()
773
+
774
+ conn.close()
775
+ return JSONResponse({"received": True, "type": etype})
776
+
777
+ app = mount_gradio_app(app, demo, path="/")
requirements.txt ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ gradio==4.44.1
2
+ fastapi==0.115.6
3
+ uvicorn==0.30.6
4
+ pydantic==2.9.2
5
+ PyJWT==2.9.0
6
+ jsonschema==4.23.0
7
+ cryptography==43.0.3
8
+ stripe==10.12.0