network-ai 3.3.0 → 3.3.2

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,699 +1,699 @@
1
- #!/usr/bin/env python3
2
- """
3
- AuthGuardian Permission Checker
4
-
5
- Evaluates permission requests for accessing sensitive resources
6
- (DATABASE, PAYMENTS, EMAIL, FILE_EXPORT).
7
-
8
- Usage:
9
- python check_permission.py --agent AGENT_ID --resource RESOURCE_TYPE \
10
- --justification "REASON" [--scope SCOPE]
11
- python check_permission.py --active-grants [--agent AGENT_ID] [--json]
12
- python check_permission.py --audit-summary [--last N] [--json]
13
-
14
- Examples:
15
- python check_permission.py --agent data_analyst --resource DATABASE \
16
- --justification "Need customer order history for sales report" \
17
- --scope "read:orders"
18
-
19
- python check_permission.py --active-grants
20
- python check_permission.py --active-grants --agent data_analyst --json
21
- python check_permission.py --audit-summary --last 20 --json
22
- """
23
-
24
- import argparse
25
- import json
26
- import re
27
- import sys
28
- import uuid
29
- from datetime import datetime, timedelta, timezone
30
- from pathlib import Path
31
- from typing import Any, Optional
32
-
33
- # Configuration
34
- GRANT_TOKEN_TTL_MINUTES = 5
35
- GRANTS_FILE = Path(__file__).parent.parent / "data" / "active_grants.json"
36
- AUDIT_LOG = Path(__file__).parent.parent / "data" / "audit_log.jsonl"
37
-
38
- # Default trust levels for known agents
39
- DEFAULT_TRUST_LEVELS = {
40
- "orchestrator": 0.9,
41
- "data_analyst": 0.8,
42
- "strategy_advisor": 0.7,
43
- "risk_assessor": 0.85,
44
- }
45
-
46
- # Base risk scores for resource types
47
- BASE_RISKS = {
48
- "DATABASE": 0.5, # Internal database access
49
- "PAYMENTS": 0.7, # Payment/financial systems
50
- "EMAIL": 0.4, # Email sending capability
51
- "FILE_EXPORT": 0.6, # Exporting data to files
52
- }
53
-
54
- # Default restrictions by resource type
55
- RESTRICTIONS = {
56
- "DATABASE": ["read_only", "max_records:100"],
57
- "PAYMENTS": ["read_only", "no_pii_fields", "audit_required"],
58
- "EMAIL": ["rate_limit:10_per_minute"],
59
- "FILE_EXPORT": ["anonymize_pii", "local_only"],
60
- }
61
-
62
-
63
- def ensure_data_dir():
64
- """Ensure data directory exists."""
65
- data_dir = Path(__file__).parent.parent / "data"
66
- data_dir.mkdir(exist_ok=True)
67
- return data_dir
68
-
69
-
70
- def detect_injection(justification: str) -> bool:
71
- """
72
- Detect prompt-injection and manipulation patterns in justifications.
73
-
74
- Returns True if the justification looks like a prompt-injection attempt.
75
- """
76
- injection_patterns = [
77
- r'ignore\s+(previous|above|prior|all)',
78
- r'override\s+(policy|restriction|rule|permission|security)',
79
- r'system\s*prompt',
80
- r'you\s+are\s+(now|a)',
81
- r'act\s+as\s+(if|a|an)',
82
- r'pretend\s+(to|that|you)',
83
- r'bypass\s+(security|check|restriction|auth)',
84
- r'grant\s+(me|access|permission)\s+(anyway|regardless|now)',
85
- r'disregard\s+(policy|rule|restriction|previous)',
86
- r'admin\s+(mode|access|override)',
87
- r'sudo\b',
88
- r'jailbreak',
89
- r'do\s+not\s+(check|verify|validate|restrict)',
90
- r'skip\s+(validation|verification|check)',
91
- r'trust\s+level\s*[:=]',
92
- r'score\s*[:=]+\s*[\d.]',
93
- ]
94
- text = justification.lower()
95
- for pattern in injection_patterns:
96
- if re.search(pattern, text):
97
- return True
98
- return False
99
-
100
-
101
- def score_justification(justification: str) -> float:
102
- """
103
- Score the quality of a justification with hardened validation.
104
-
105
- Defenses against prompt injection and keyword stuffing:
106
- - Injection pattern detection (immediate reject)
107
- - Maximum length cap (prevents obfuscation in long text)
108
- - Keyword-stuffing detection (penalises unnatural keyword density)
109
- - Unique-word ratio check (catches copy-paste padding)
110
- - Structural coherence (requires natural sentence structure)
111
-
112
- Criteria (after safety checks):
113
- - Length (more detail = better, but capped)
114
- - Contains task-related keywords (capped contribution)
115
- - Contains specificity keywords (capped contribution)
116
- - No test/debug keywords
117
- - Structural coherence bonus
118
- """
119
- # ----- Hard reject: injection patterns -----
120
- if detect_injection(justification):
121
- return 0.0
122
-
123
- # ----- Hard reject: empty or whitespace-only -----
124
- stripped = justification.strip()
125
- if not stripped:
126
- return 0.0
127
-
128
- # ----- Hard cap: excessively long justifications are suspicious -----
129
- MAX_JUSTIFICATION_LENGTH = 500
130
- if len(stripped) > MAX_JUSTIFICATION_LENGTH:
131
- return 0.1 # Suspiciously long — allow re-submission with concise text
132
-
133
- words = stripped.split()
134
- word_count = len(words)
135
-
136
- # ----- Hard reject: too few words to be meaningful -----
137
- if word_count < 3:
138
- return 0.1
139
-
140
- # ----- Repetition / padding detection -----
141
- unique_words = set(w.lower() for w in words)
142
- unique_ratio = len(unique_words) / word_count # word_count >= 3 guaranteed above
143
- if unique_ratio < 0.4:
144
- return 0.1 # More than 60% repeated words — likely padding
145
-
146
- # ----- Keyword-stuffing detection -----
147
- task_keywords = re.findall(
148
- r'\b(task|purpose|need|require|generate|analyze|create|process)\b',
149
- stripped, re.IGNORECASE,
150
- )
151
- specificity_keywords = re.findall(
152
- r'\b(specific|particular|exact|quarterly|annual|report|summary)\b',
153
- stripped, re.IGNORECASE,
154
- )
155
- total_matched = len(task_keywords) + len(specificity_keywords)
156
- keyword_density = total_matched / word_count # word_count >= 3 guaranteed above
157
- if keyword_density > 0.5:
158
- return 0.1 # More than half the words are scoring keywords — stuffing
159
-
160
- # ----- Scoring (defensive caps per category) -----
161
- score = 0.0
162
-
163
- # Length contribution (max 0.25)
164
- if len(stripped) > 20:
165
- score += 0.15
166
- if len(stripped) > 50:
167
- score += 0.10
168
-
169
- # Task keyword presence (max 0.20, but only first match counts)
170
- if task_keywords:
171
- score += 0.20
172
-
173
- # Specificity keyword presence (max 0.20, but only first match counts)
174
- if specificity_keywords:
175
- score += 0.20
176
-
177
- # No test/debug markers (max 0.15)
178
- if not re.search(r'\b(test|debug|try|experiment)\b', stripped, re.IGNORECASE):
179
- score += 0.15
180
-
181
- # Structural coherence: sentence-like structure (max 0.20)
182
- # Must contain at least one verb-like pattern and read like prose
183
- has_verb = bool(re.search(
184
- r'\b(is|are|was|were|need|needs|require|requires|must|should|will|'
185
- r'generate|generating|analyze|analyzing|create|creating|process|processing|'
186
- r'prepare|preparing|compile|compiling|review|reviewing|access|accessing|'
187
- r'retrieve|retrieving|export|exporting|send|sending|run|running)\b',
188
- stripped, re.IGNORECASE,
189
- ))
190
- has_noun_object = bool(re.search(
191
- r'\b(data|report|records|invoices?|orders?|customers?|accounts?|'
192
- r'transactions?|files?|emails?|results?|metrics?|statistics?|'
193
- r'analysis|documents?|exports?|payments?|entries|logs?|summaries)\b',
194
- stripped, re.IGNORECASE,
195
- ))
196
- if has_verb and has_noun_object:
197
- score += 0.20
198
-
199
- return min(score, 1.0)
200
-
201
-
202
- def assess_risk(resource_type: str, scope: Optional[str] = None) -> float:
203
- """
204
- Assess the risk level of a permission request.
205
-
206
- Factors:
207
- - Base risk of resource type
208
- - Scope breadth (broad scopes = higher risk)
209
- - Write operations (higher risk)
210
- """
211
- risk = BASE_RISKS.get(resource_type, 0.5)
212
-
213
- # Broad scopes increase risk
214
- if not scope or scope in ("*", "all"):
215
- risk += 0.2
216
-
217
- # Write operations increase risk
218
- if scope and re.search(r'\b(write|delete|update|modify|create)\b', scope, re.IGNORECASE):
219
- risk += 0.2
220
-
221
- return min(risk, 1.0)
222
-
223
-
224
- def generate_grant_token() -> str:
225
- """Generate a unique grant token."""
226
- return f"grant_{uuid.uuid4().hex}"
227
-
228
-
229
- def log_audit(action: str, details: dict[str, Any]) -> None:
230
- """Append entry to audit log."""
231
- ensure_data_dir()
232
- entry: dict[str, Any] = {
233
- "timestamp": datetime.now(timezone.utc).isoformat(),
234
- "action": action,
235
- "details": details
236
- }
237
- with open(AUDIT_LOG, "a") as f:
238
- f.write(json.dumps(entry) + "\n")
239
-
240
-
241
- def save_grant(grant: dict[str, Any]) -> None:
242
- """Save grant to persistent storage."""
243
- ensure_data_dir()
244
- grants = {}
245
- if GRANTS_FILE.exists():
246
- try:
247
- grants = json.loads(GRANTS_FILE.read_text())
248
- except json.JSONDecodeError:
249
- grants = {}
250
-
251
- grants[grant["token"]] = grant
252
- GRANTS_FILE.write_text(json.dumps(grants, indent=2))
253
-
254
-
255
- def evaluate_permission(agent_id: str, resource_type: str,
256
- justification: str, scope: Optional[str] = None) -> dict[str, Any]:
257
- """
258
- Evaluate a permission request using weighted scoring.
259
-
260
- Weights:
261
- - Justification Quality: 40%
262
- - Agent Trust Level: 30%
263
- - Risk Assessment: 30%
264
- """
265
- # Log the request
266
- log_audit("permission_request", {
267
- "agent_id": agent_id,
268
- "resource_type": resource_type,
269
- "justification": justification,
270
- "scope": scope
271
- })
272
-
273
- # 1. Justification Quality (40% weight)
274
- justification_score = score_justification(justification)
275
- if justification_score < 0.3:
276
- return {
277
- "granted": False,
278
- "reason": "Justification is insufficient. Please provide specific task context.",
279
- "scores": {
280
- "justification": justification_score,
281
- "trust": None,
282
- "risk": None
283
- }
284
- }
285
-
286
- # 2. Agent Trust Level (30% weight)
287
- trust_level = DEFAULT_TRUST_LEVELS.get(agent_id, 0.5)
288
- if trust_level < 0.4:
289
- return {
290
- "granted": False,
291
- "reason": "Agent trust level is below threshold. Escalate to human operator.",
292
- "scores": {
293
- "justification": justification_score,
294
- "trust": trust_level,
295
- "risk": None
296
- }
297
- }
298
-
299
- # 3. Risk Assessment (30% weight)
300
- risk_score = assess_risk(resource_type, scope)
301
- if risk_score > 0.8:
302
- return {
303
- "granted": False,
304
- "reason": "Risk assessment exceeds acceptable threshold. Narrow the requested scope.",
305
- "scores": {
306
- "justification": justification_score,
307
- "trust": trust_level,
308
- "risk": risk_score
309
- }
310
- }
311
-
312
- # Calculate weighted approval score
313
- weighted_score = (
314
- justification_score * 0.4 +
315
- trust_level * 0.3 +
316
- (1 - risk_score) * 0.3
317
- )
318
-
319
- if weighted_score < 0.5:
320
- return {
321
- "granted": False,
322
- "reason": f"Combined evaluation score ({weighted_score:.2f}) below threshold (0.5).",
323
- "scores": {
324
- "justification": justification_score,
325
- "trust": trust_level,
326
- "risk": risk_score,
327
- "weighted": weighted_score
328
- }
329
- }
330
-
331
- # Generate grant
332
- token = generate_grant_token()
333
- expires_at = (datetime.now(timezone.utc) + timedelta(minutes=GRANT_TOKEN_TTL_MINUTES)).isoformat()
334
- restrictions = RESTRICTIONS.get(resource_type, [])
335
-
336
- grant: dict[str, Any] = {
337
- "token": token,
338
- "agent_id": agent_id,
339
- "resource_type": resource_type,
340
- "scope": scope,
341
- "expires_at": expires_at,
342
- "restrictions": restrictions,
343
- "granted_at": datetime.now(timezone.utc).isoformat()
344
- }
345
-
346
- # Save grant and log
347
- save_grant(grant)
348
- log_audit("permission_granted", grant)
349
-
350
- return {
351
- "granted": True,
352
- "token": token,
353
- "expires_at": expires_at,
354
- "restrictions": restrictions,
355
- "scores": {
356
- "justification": justification_score,
357
- "trust": trust_level,
358
- "risk": risk_score,
359
- "weighted": weighted_score
360
- }
361
- }
362
-
363
-
364
- def list_active_grants(agent_filter: Optional[str] = None, as_json: bool = False) -> int:
365
- """
366
- Show which agents currently hold access to which APIs with expiry times.
367
-
368
- Reads data/active_grants.json, filters out expired grants,
369
- and displays remaining grants with TTL.
370
- """
371
- if not GRANTS_FILE.exists():
372
- if as_json:
373
- print(json.dumps({"grants": [], "total": 0, "expired_cleaned": 0}))
374
- else:
375
- print("No active grants. (No grants file found.)")
376
- return 0
377
-
378
- try:
379
- grants = json.loads(GRANTS_FILE.read_text())
380
- except (json.JSONDecodeError, OSError):
381
- if as_json:
382
- print(json.dumps({"error": "Could not read grants file"}))
383
- else:
384
- print("Error: Could not read grants file.")
385
- return 1
386
-
387
- now = datetime.now(timezone.utc)
388
- active: list[dict[str, Any]] = []
389
- expired_count = 0
390
-
391
- for token, grant in grants.items():
392
- try:
393
- expires_at = datetime.fromisoformat(grant["expires_at"])
394
- except (KeyError, ValueError):
395
- expired_count += 1
396
- continue
397
-
398
- if expires_at <= now:
399
- expired_count += 1
400
- continue
401
-
402
- if agent_filter and grant.get("agent_id") != agent_filter:
403
- continue
404
-
405
- remaining = expires_at - now
406
- minutes_left = remaining.total_seconds() / 60
407
-
408
- active.append({
409
- "token": token[:16] + "..." if len(token) > 16 else token,
410
- "token_full": token,
411
- "agent_id": grant.get("agent_id", "unknown"),
412
- "resource_type": grant.get("resource_type", "unknown"),
413
- "scope": grant.get("scope"),
414
- "granted_at": grant.get("granted_at", "unknown"),
415
- "expires_at": grant["expires_at"],
416
- "minutes_remaining": round(minutes_left, 1),
417
- "restrictions": grant.get("restrictions", []),
418
- })
419
-
420
- # Sort by expiry (soonest first)
421
- active.sort(key=lambda g: g["expires_at"])
422
-
423
- if as_json:
424
- # In JSON mode, include full tokens
425
- output: dict[str, Any] = {
426
- "grants": active,
427
- "total": len(active),
428
- "expired_cleaned": expired_count,
429
- }
430
- print(json.dumps(output, indent=2))
431
- else:
432
- if not active:
433
- filter_msg = f" for agent '{agent_filter}'" if agent_filter else ""
434
- print(f"No active grants{filter_msg}. ({expired_count} expired.)")
435
- else:
436
- filter_msg = f" (agent: {agent_filter})" if agent_filter else ""
437
- print(f"Active Grants{filter_msg}:")
438
- print(f"{'='*70}")
439
- for g in active:
440
- print(f" Agent: {g['agent_id']}")
441
- print(f" Resource: {g['resource_type']}")
442
- if g["scope"]:
443
- print(f" Scope: {g['scope']}")
444
- print(f" Token: {g['token']}")
445
- print(f" Granted: {g['granted_at']}")
446
- print(f" Expires: {g['expires_at']}")
447
- print(f" Remaining: {g['minutes_remaining']} min")
448
- if g["restrictions"]:
449
- print(f" Restrictions: {', '.join(g['restrictions'])}")
450
- print(f" {'-'*66}")
451
- print(f"\nTotal: {len(active)} active, {expired_count} expired")
452
-
453
- return 0
454
-
455
-
456
- def audit_summary(last_n: int = 20, as_json: bool = False) -> int:
457
- """
458
- Summarize recent permission requests, grants, and denials.
459
-
460
- Parses data/audit_log.jsonl and produces per-agent and per-resource
461
- breakdowns plus recent activity.
462
- """
463
- if not AUDIT_LOG.exists():
464
- if as_json:
465
- print(json.dumps({"entries": 0, "summary": {}, "recent": []}))
466
- else:
467
- print("No audit log found. (No permission requests recorded yet.)")
468
- return 0
469
-
470
- entries: list[dict[str, Any]] = []
471
- try:
472
- with open(AUDIT_LOG, "r") as f:
473
- for line in f:
474
- line = line.strip()
475
- if line:
476
- try:
477
- entries.append(json.loads(line))
478
- except json.JSONDecodeError:
479
- continue
480
- except OSError:
481
- if as_json:
482
- print(json.dumps({"error": "Could not read audit log"}))
483
- else:
484
- print("Error: Could not read audit log.")
485
- return 1
486
-
487
- if not entries:
488
- if as_json:
489
- print(json.dumps({"entries": 0, "summary": {}, "recent": []}))
490
- else:
491
- print("Audit log is empty.")
492
- return 0
493
-
494
- # Aggregate stats
495
- total_requests = 0
496
- total_grants = 0
497
- by_agent: dict[str, dict[str, int]] = {}
498
- by_resource: dict[str, dict[str, int]] = {}
499
-
500
- for entry in entries:
501
- action = entry.get("action", "")
502
- details = entry.get("details", {})
503
- agent_id = details.get("agent_id", "unknown")
504
- resource_type = details.get("resource_type", "unknown")
505
-
506
- if action == "permission_request":
507
- total_requests += 1
508
- by_agent.setdefault(agent_id, {"requests": 0, "grants": 0})
509
- by_agent[agent_id]["requests"] += 1
510
- by_resource.setdefault(resource_type, {"requests": 0, "grants": 0})
511
- by_resource[resource_type]["requests"] += 1
512
- elif action == "permission_granted":
513
- total_grants += 1
514
- by_agent.setdefault(agent_id, {"requests": 0, "grants": 0})
515
- by_agent[agent_id]["grants"] += 1
516
- by_resource.setdefault(resource_type, {"requests": 0, "grants": 0})
517
- by_resource[resource_type]["grants"] += 1
518
-
519
- total_denials = total_requests - total_grants
520
-
521
- # Recent entries (last N)
522
- recent = entries[-last_n:]
523
-
524
- # Time range
525
- first_ts = entries[0].get("timestamp", "unknown")
526
- last_ts = entries[-1].get("timestamp", "unknown")
527
-
528
- if as_json:
529
- output: dict[str, Any] = {
530
- "total_entries": len(entries),
531
- "total_requests": total_requests,
532
- "total_grants": total_grants,
533
- "total_denials": total_denials,
534
- "time_range": {"first": first_ts, "last": last_ts},
535
- "by_agent": by_agent,
536
- "by_resource": by_resource,
537
- "recent": recent[-last_n:],
538
- }
539
- print(json.dumps(output, indent=2))
540
- else:
541
- print("Audit Summary")
542
- print(f"{'='*70}")
543
- print(f" Log entries: {len(entries)}")
544
- print(f" Time range: {first_ts}")
545
- print(f" {last_ts}")
546
- print(f"")
547
- print(f" Requests: {total_requests}")
548
- print(f" Grants: {total_grants}")
549
- print(f" Denials: {total_denials}")
550
- grant_rate = (total_grants / total_requests * 100) if total_requests > 0 else 0
551
- print(f" Grant Rate: {grant_rate:.0f}%")
552
-
553
- if by_agent:
554
- print(f"\n By Agent:")
555
- print(f" {'-'*50}")
556
- print(f" {'Agent':<20} {'Requests':>10} {'Grants':>10} {'Denials':>10}")
557
- print(f" {'-'*50}")
558
- for agent_id, stats in sorted(by_agent.items()):
559
- denials = stats["requests"] - stats["grants"]
560
- print(f" {agent_id:<20} {stats['requests']:>10} {stats['grants']:>10} {denials:>10}")
561
-
562
- if by_resource:
563
- print(f"\n By Resource:")
564
- print(f" {'-'*50}")
565
- print(f" {'Resource':<20} {'Requests':>10} {'Grants':>10} {'Denials':>10}")
566
- print(f" {'-'*50}")
567
- for resource_type, stats in sorted(by_resource.items()):
568
- denials = stats["requests"] - stats["grants"]
569
- print(f" {resource_type:<20} {stats['requests']:>10} {stats['grants']:>10} {denials:>10}")
570
-
571
- print(f"\n Recent Activity (last {min(last_n, len(recent))}):")
572
- print(f" {'-'*66}")
573
- for entry in recent:
574
- ts = entry.get("timestamp", "?")[:19]
575
- action = entry.get("action", "?")
576
- details = entry.get("details", {})
577
- agent_id = details.get("agent_id", "?")
578
- resource_type = details.get("resource_type", "?")
579
- symbol = "GRANT" if action == "permission_granted" else "REQ" if action == "permission_request" else action.upper()
580
- print(f" {ts} [{symbol:>5}] {agent_id} -> {resource_type}")
581
-
582
- return 0
583
-
584
-
585
- def main():
586
- parser = argparse.ArgumentParser(
587
- description="AuthGuardian Permission Checker",
588
- formatter_class=argparse.RawDescriptionHelpFormatter,
589
- epilog="""
590
- Examples:
591
- Check permission:
592
- %(prog)s --agent data_analyst --resource DATABASE \\
593
- --justification "Need Q4 invoice data for quarterly report"
594
-
595
- List active grants:
596
- %(prog)s --active-grants
597
- %(prog)s --active-grants --agent data_analyst --json
598
-
599
- View audit summary:
600
- %(prog)s --audit-summary
601
- %(prog)s --audit-summary --last 50 --json
602
- """
603
- )
604
-
605
- # Action flags
606
- parser.add_argument(
607
- "--active-grants",
608
- action="store_true",
609
- help="List all active (non-expired) permission grants"
610
- )
611
- parser.add_argument(
612
- "--audit-summary",
613
- action="store_true",
614
- help="Show audit log summary with per-agent and per-resource breakdowns"
615
- )
616
- parser.add_argument(
617
- "--last",
618
- type=int,
619
- default=20,
620
- help="Number of recent audit entries to show (default: 20)"
621
- )
622
-
623
- # Permission check args (required only for check mode)
624
- parser.add_argument(
625
- "--agent", "-a",
626
- help="Agent ID requesting permission (required for check; optional filter for --active-grants)"
627
- )
628
- parser.add_argument(
629
- "--resource", "-r",
630
- choices=["DATABASE", "PAYMENTS", "EMAIL", "FILE_EXPORT"],
631
- help="Resource type to access"
632
- )
633
- parser.add_argument(
634
- "--justification", "-j",
635
- help="Business justification for the request"
636
- )
637
- parser.add_argument(
638
- "--scope", "-s",
639
- help="Specific scope of access (e.g., 'read:invoices')"
640
- )
641
- parser.add_argument(
642
- "--json",
643
- action="store_true",
644
- help="Output result as JSON"
645
- )
646
-
647
- args = parser.parse_args()
648
-
649
- # --- Action: --active-grants ---
650
- if args.active_grants:
651
- sys.exit(list_active_grants(agent_filter=args.agent, as_json=args.json))
652
-
653
- # --- Action: --audit-summary ---
654
- if args.audit_summary:
655
- sys.exit(audit_summary(last_n=args.last, as_json=args.json))
656
-
657
- # --- Default action: permission check ---
658
- if not args.agent:
659
- parser.error("--agent is required for permission checks")
660
- if not args.resource:
661
- parser.error("--resource is required for permission checks")
662
- if not args.justification:
663
- parser.error("--justification is required for permission checks")
664
-
665
- result = evaluate_permission(
666
- agent_id=args.agent,
667
- resource_type=args.resource,
668
- justification=args.justification,
669
- scope=args.scope
670
- )
671
-
672
- if args.json:
673
- print(json.dumps(result, indent=2))
674
- else:
675
- if result["granted"]:
676
- print("GRANTED")
677
- print(f"Token: {result['token']}")
678
- print(f"Expires: {result['expires_at']}")
679
- print(f"Restrictions: {', '.join(result['restrictions'])}")
680
- else:
681
- print("DENIED")
682
- print(f"Reason: {result['reason']}")
683
-
684
- print("\nEvaluation Scores:")
685
- scores = result["scores"]
686
- if scores.get("justification") is not None:
687
- print(f" Justification: {scores['justification']:.2f}")
688
- if scores.get("trust") is not None:
689
- print(f" Trust Level: {scores['trust']:.2f}")
690
- if scores.get("risk") is not None:
691
- print(f" Risk Score: {scores['risk']:.2f}")
692
- if scores.get("weighted") is not None:
693
- print(f" Weighted: {scores['weighted']:.2f}")
694
-
695
- sys.exit(0 if result["granted"] else 1)
696
-
697
-
698
- if __name__ == "__main__":
699
- main()
1
+ #!/usr/bin/env python3
2
+ """
3
+ AuthGuardian Permission Checker
4
+
5
+ Evaluates permission requests for accessing sensitive resources
6
+ (DATABASE, PAYMENTS, EMAIL, FILE_EXPORT).
7
+
8
+ Usage:
9
+ python check_permission.py --agent AGENT_ID --resource RESOURCE_TYPE \
10
+ --justification "REASON" [--scope SCOPE]
11
+ python check_permission.py --active-grants [--agent AGENT_ID] [--json]
12
+ python check_permission.py --audit-summary [--last N] [--json]
13
+
14
+ Examples:
15
+ python check_permission.py --agent data_analyst --resource DATABASE \
16
+ --justification "Need customer order history for sales report" \
17
+ --scope "read:orders"
18
+
19
+ python check_permission.py --active-grants
20
+ python check_permission.py --active-grants --agent data_analyst --json
21
+ python check_permission.py --audit-summary --last 20 --json
22
+ """
23
+
24
+ import argparse
25
+ import json
26
+ import re
27
+ import sys
28
+ import uuid
29
+ from datetime import datetime, timedelta, timezone
30
+ from pathlib import Path
31
+ from typing import Any, Optional
32
+
33
+ # Configuration
34
+ GRANT_TOKEN_TTL_MINUTES = 5
35
+ GRANTS_FILE = Path(__file__).parent.parent / "data" / "active_grants.json"
36
+ AUDIT_LOG = Path(__file__).parent.parent / "data" / "audit_log.jsonl"
37
+
38
+ # Default trust levels for known agents
39
+ DEFAULT_TRUST_LEVELS = {
40
+ "orchestrator": 0.9,
41
+ "data_analyst": 0.8,
42
+ "strategy_advisor": 0.7,
43
+ "risk_assessor": 0.85,
44
+ }
45
+
46
+ # Base risk scores for resource types
47
+ BASE_RISKS = {
48
+ "DATABASE": 0.5, # Internal database access
49
+ "PAYMENTS": 0.7, # Payment/financial systems
50
+ "EMAIL": 0.4, # Email sending capability
51
+ "FILE_EXPORT": 0.6, # Exporting data to files
52
+ }
53
+
54
+ # Default restrictions by resource type
55
+ RESTRICTIONS = {
56
+ "DATABASE": ["read_only", "max_records:100"],
57
+ "PAYMENTS": ["read_only", "no_pii_fields", "audit_required"],
58
+ "EMAIL": ["rate_limit:10_per_minute"],
59
+ "FILE_EXPORT": ["anonymize_pii", "local_only"],
60
+ }
61
+
62
+
63
+ def ensure_data_dir():
64
+ """Ensure data directory exists."""
65
+ data_dir = Path(__file__).parent.parent / "data"
66
+ data_dir.mkdir(exist_ok=True)
67
+ return data_dir
68
+
69
+
70
+ def detect_injection(justification: str) -> bool:
71
+ """
72
+ Detect prompt-injection and manipulation patterns in justifications.
73
+
74
+ Returns True if the justification looks like a prompt-injection attempt.
75
+ """
76
+ injection_patterns = [
77
+ r'ignore\s+(previous|above|prior|all)',
78
+ r'override\s+(policy|restriction|rule|permission|security)',
79
+ r'system\s*prompt',
80
+ r'you\s+are\s+(now|a)',
81
+ r'act\s+as\s+(if|a|an)',
82
+ r'pretend\s+(to|that|you)',
83
+ r'bypass\s+(security|check|restriction|auth)',
84
+ r'grant\s+(me|access|permission)\s+(anyway|regardless|now)',
85
+ r'disregard\s+(policy|rule|restriction|previous)',
86
+ r'admin\s+(mode|access|override)',
87
+ r'sudo\b',
88
+ r'jailbreak',
89
+ r'do\s+not\s+(check|verify|validate|restrict)',
90
+ r'skip\s+(validation|verification|check)',
91
+ r'trust\s+level\s*[:=]',
92
+ r'score\s*[:=]+\s*[\d.]',
93
+ ]
94
+ text = justification.lower()
95
+ for pattern in injection_patterns:
96
+ if re.search(pattern, text):
97
+ return True
98
+ return False
99
+
100
+
101
+ def score_justification(justification: str) -> float:
102
+ """
103
+ Score the quality of a justification with hardened validation.
104
+
105
+ Defenses against prompt injection and keyword stuffing:
106
+ - Injection pattern detection (immediate reject)
107
+ - Maximum length cap (prevents obfuscation in long text)
108
+ - Keyword-stuffing detection (penalises unnatural keyword density)
109
+ - Unique-word ratio check (catches copy-paste padding)
110
+ - Structural coherence (requires natural sentence structure)
111
+
112
+ Criteria (after safety checks):
113
+ - Length (more detail = better, but capped)
114
+ - Contains task-related keywords (capped contribution)
115
+ - Contains specificity keywords (capped contribution)
116
+ - No test/debug keywords
117
+ - Structural coherence bonus
118
+ """
119
+ # ----- Hard reject: injection patterns -----
120
+ if detect_injection(justification):
121
+ return 0.0
122
+
123
+ # ----- Hard reject: empty or whitespace-only -----
124
+ stripped = justification.strip()
125
+ if not stripped:
126
+ return 0.0
127
+
128
+ # ----- Hard cap: excessively long justifications are suspicious -----
129
+ MAX_JUSTIFICATION_LENGTH = 500
130
+ if len(stripped) > MAX_JUSTIFICATION_LENGTH:
131
+ return 0.1 # Suspiciously long — allow re-submission with concise text
132
+
133
+ words = stripped.split()
134
+ word_count = len(words)
135
+
136
+ # ----- Hard reject: too few words to be meaningful -----
137
+ if word_count < 3:
138
+ return 0.1
139
+
140
+ # ----- Repetition / padding detection -----
141
+ unique_words = set(w.lower() for w in words)
142
+ unique_ratio = len(unique_words) / word_count # word_count >= 3 guaranteed above
143
+ if unique_ratio < 0.4:
144
+ return 0.1 # More than 60% repeated words — likely padding
145
+
146
+ # ----- Keyword-stuffing detection -----
147
+ task_keywords = re.findall(
148
+ r'\b(task|purpose|need|require|generate|analyze|create|process)\b',
149
+ stripped, re.IGNORECASE,
150
+ )
151
+ specificity_keywords = re.findall(
152
+ r'\b(specific|particular|exact|quarterly|annual|report|summary)\b',
153
+ stripped, re.IGNORECASE,
154
+ )
155
+ total_matched = len(task_keywords) + len(specificity_keywords)
156
+ keyword_density = total_matched / word_count # word_count >= 3 guaranteed above
157
+ if keyword_density > 0.5:
158
+ return 0.1 # More than half the words are scoring keywords — stuffing
159
+
160
+ # ----- Scoring (defensive caps per category) -----
161
+ score = 0.0
162
+
163
+ # Length contribution (max 0.25)
164
+ if len(stripped) > 20:
165
+ score += 0.15
166
+ if len(stripped) > 50:
167
+ score += 0.10
168
+
169
+ # Task keyword presence (max 0.20, but only first match counts)
170
+ if task_keywords:
171
+ score += 0.20
172
+
173
+ # Specificity keyword presence (max 0.20, but only first match counts)
174
+ if specificity_keywords:
175
+ score += 0.20
176
+
177
+ # No test/debug markers (max 0.15)
178
+ if not re.search(r'\b(test|debug|try|experiment)\b', stripped, re.IGNORECASE):
179
+ score += 0.15
180
+
181
+ # Structural coherence: sentence-like structure (max 0.20)
182
+ # Must contain at least one verb-like pattern and read like prose
183
+ has_verb = bool(re.search(
184
+ r'\b(is|are|was|were|need|needs|require|requires|must|should|will|'
185
+ r'generate|generating|analyze|analyzing|create|creating|process|processing|'
186
+ r'prepare|preparing|compile|compiling|review|reviewing|access|accessing|'
187
+ r'retrieve|retrieving|export|exporting|send|sending|run|running)\b',
188
+ stripped, re.IGNORECASE,
189
+ ))
190
+ has_noun_object = bool(re.search(
191
+ r'\b(data|report|records|invoices?|orders?|customers?|accounts?|'
192
+ r'transactions?|files?|emails?|results?|metrics?|statistics?|'
193
+ r'analysis|documents?|exports?|payments?|entries|logs?|summaries)\b',
194
+ stripped, re.IGNORECASE,
195
+ ))
196
+ if has_verb and has_noun_object:
197
+ score += 0.20
198
+
199
+ return min(score, 1.0)
200
+
201
+
202
+ def assess_risk(resource_type: str, scope: Optional[str] = None) -> float:
203
+ """
204
+ Assess the risk level of a permission request.
205
+
206
+ Factors:
207
+ - Base risk of resource type
208
+ - Scope breadth (broad scopes = higher risk)
209
+ - Write operations (higher risk)
210
+ """
211
+ risk = BASE_RISKS.get(resource_type, 0.5)
212
+
213
+ # Broad scopes increase risk
214
+ if not scope or scope in ("*", "all"):
215
+ risk += 0.2
216
+
217
+ # Write operations increase risk
218
+ if scope and re.search(r'\b(write|delete|update|modify|create)\b', scope, re.IGNORECASE):
219
+ risk += 0.2
220
+
221
+ return min(risk, 1.0)
222
+
223
+
224
+ def generate_grant_token() -> str:
225
+ """Generate a unique grant token."""
226
+ return f"grant_{uuid.uuid4().hex}"
227
+
228
+
229
+ def log_audit(action: str, details: dict[str, Any]) -> None:
230
+ """Append entry to audit log."""
231
+ ensure_data_dir()
232
+ entry: dict[str, Any] = {
233
+ "timestamp": datetime.now(timezone.utc).isoformat(),
234
+ "action": action,
235
+ "details": details
236
+ }
237
+ with open(AUDIT_LOG, "a") as f:
238
+ f.write(json.dumps(entry) + "\n")
239
+
240
+
241
+ def save_grant(grant: dict[str, Any]) -> None:
242
+ """Save grant to persistent storage."""
243
+ ensure_data_dir()
244
+ grants = {}
245
+ if GRANTS_FILE.exists():
246
+ try:
247
+ grants = json.loads(GRANTS_FILE.read_text())
248
+ except json.JSONDecodeError:
249
+ grants = {}
250
+
251
+ grants[grant["token"]] = grant
252
+ GRANTS_FILE.write_text(json.dumps(grants, indent=2))
253
+
254
+
255
+ def evaluate_permission(agent_id: str, resource_type: str,
256
+ justification: str, scope: Optional[str] = None) -> dict[str, Any]:
257
+ """
258
+ Evaluate a permission request using weighted scoring.
259
+
260
+ Weights:
261
+ - Justification Quality: 40%
262
+ - Agent Trust Level: 30%
263
+ - Risk Assessment: 30%
264
+ """
265
+ # Log the request
266
+ log_audit("permission_request", {
267
+ "agent_id": agent_id,
268
+ "resource_type": resource_type,
269
+ "justification": justification,
270
+ "scope": scope
271
+ })
272
+
273
+ # 1. Justification Quality (40% weight)
274
+ justification_score = score_justification(justification)
275
+ if justification_score < 0.3:
276
+ return {
277
+ "granted": False,
278
+ "reason": "Justification is insufficient. Please provide specific task context.",
279
+ "scores": {
280
+ "justification": justification_score,
281
+ "trust": None,
282
+ "risk": None
283
+ }
284
+ }
285
+
286
+ # 2. Agent Trust Level (30% weight)
287
+ trust_level = DEFAULT_TRUST_LEVELS.get(agent_id, 0.5)
288
+ if trust_level < 0.4:
289
+ return {
290
+ "granted": False,
291
+ "reason": "Agent trust level is below threshold. Escalate to human operator.",
292
+ "scores": {
293
+ "justification": justification_score,
294
+ "trust": trust_level,
295
+ "risk": None
296
+ }
297
+ }
298
+
299
+ # 3. Risk Assessment (30% weight)
300
+ risk_score = assess_risk(resource_type, scope)
301
+ if risk_score > 0.8:
302
+ return {
303
+ "granted": False,
304
+ "reason": "Risk assessment exceeds acceptable threshold. Narrow the requested scope.",
305
+ "scores": {
306
+ "justification": justification_score,
307
+ "trust": trust_level,
308
+ "risk": risk_score
309
+ }
310
+ }
311
+
312
+ # Calculate weighted approval score
313
+ weighted_score = (
314
+ justification_score * 0.4 +
315
+ trust_level * 0.3 +
316
+ (1 - risk_score) * 0.3
317
+ )
318
+
319
+ if weighted_score < 0.5:
320
+ return {
321
+ "granted": False,
322
+ "reason": f"Combined evaluation score ({weighted_score:.2f}) below threshold (0.5).",
323
+ "scores": {
324
+ "justification": justification_score,
325
+ "trust": trust_level,
326
+ "risk": risk_score,
327
+ "weighted": weighted_score
328
+ }
329
+ }
330
+
331
+ # Generate grant
332
+ token = generate_grant_token()
333
+ expires_at = (datetime.now(timezone.utc) + timedelta(minutes=GRANT_TOKEN_TTL_MINUTES)).isoformat()
334
+ restrictions = RESTRICTIONS.get(resource_type, [])
335
+
336
+ grant: dict[str, Any] = {
337
+ "token": token,
338
+ "agent_id": agent_id,
339
+ "resource_type": resource_type,
340
+ "scope": scope,
341
+ "expires_at": expires_at,
342
+ "restrictions": restrictions,
343
+ "granted_at": datetime.now(timezone.utc).isoformat()
344
+ }
345
+
346
+ # Save grant and log
347
+ save_grant(grant)
348
+ log_audit("permission_granted", grant)
349
+
350
+ return {
351
+ "granted": True,
352
+ "token": token,
353
+ "expires_at": expires_at,
354
+ "restrictions": restrictions,
355
+ "scores": {
356
+ "justification": justification_score,
357
+ "trust": trust_level,
358
+ "risk": risk_score,
359
+ "weighted": weighted_score
360
+ }
361
+ }
362
+
363
+
364
+ def list_active_grants(agent_filter: Optional[str] = None, as_json: bool = False) -> int:
365
+ """
366
+ Show which agents currently hold access to which APIs with expiry times.
367
+
368
+ Reads data/active_grants.json, filters out expired grants,
369
+ and displays remaining grants with TTL.
370
+ """
371
+ if not GRANTS_FILE.exists():
372
+ if as_json:
373
+ print(json.dumps({"grants": [], "total": 0, "expired_cleaned": 0}))
374
+ else:
375
+ print("No active grants. (No grants file found.)")
376
+ return 0
377
+
378
+ try:
379
+ grants = json.loads(GRANTS_FILE.read_text())
380
+ except (json.JSONDecodeError, OSError):
381
+ if as_json:
382
+ print(json.dumps({"error": "Could not read grants file"}))
383
+ else:
384
+ print("Error: Could not read grants file.")
385
+ return 1
386
+
387
+ now = datetime.now(timezone.utc)
388
+ active: list[dict[str, Any]] = []
389
+ expired_count = 0
390
+
391
+ for token, grant in grants.items():
392
+ try:
393
+ expires_at = datetime.fromisoformat(grant["expires_at"])
394
+ except (KeyError, ValueError):
395
+ expired_count += 1
396
+ continue
397
+
398
+ if expires_at <= now:
399
+ expired_count += 1
400
+ continue
401
+
402
+ if agent_filter and grant.get("agent_id") != agent_filter:
403
+ continue
404
+
405
+ remaining = expires_at - now
406
+ minutes_left = remaining.total_seconds() / 60
407
+
408
+ active.append({
409
+ "token": token[:16] + "..." if len(token) > 16 else token,
410
+ "token_full": token,
411
+ "agent_id": grant.get("agent_id", "unknown"),
412
+ "resource_type": grant.get("resource_type", "unknown"),
413
+ "scope": grant.get("scope"),
414
+ "granted_at": grant.get("granted_at", "unknown"),
415
+ "expires_at": grant["expires_at"],
416
+ "minutes_remaining": round(minutes_left, 1),
417
+ "restrictions": grant.get("restrictions", []),
418
+ })
419
+
420
+ # Sort by expiry (soonest first)
421
+ active.sort(key=lambda g: g["expires_at"])
422
+
423
+ if as_json:
424
+ # In JSON mode, include full tokens
425
+ output: dict[str, Any] = {
426
+ "grants": active,
427
+ "total": len(active),
428
+ "expired_cleaned": expired_count,
429
+ }
430
+ print(json.dumps(output, indent=2))
431
+ else:
432
+ if not active:
433
+ filter_msg = f" for agent '{agent_filter}'" if agent_filter else ""
434
+ print(f"No active grants{filter_msg}. ({expired_count} expired.)")
435
+ else:
436
+ filter_msg = f" (agent: {agent_filter})" if agent_filter else ""
437
+ print(f"Active Grants{filter_msg}:")
438
+ print(f"{'='*70}")
439
+ for g in active:
440
+ print(f" Agent: {g['agent_id']}")
441
+ print(f" Resource: {g['resource_type']}")
442
+ if g["scope"]:
443
+ print(f" Scope: {g['scope']}")
444
+ print(f" Token: {g['token']}")
445
+ print(f" Granted: {g['granted_at']}")
446
+ print(f" Expires: {g['expires_at']}")
447
+ print(f" Remaining: {g['minutes_remaining']} min")
448
+ if g["restrictions"]:
449
+ print(f" Restrictions: {', '.join(g['restrictions'])}")
450
+ print(f" {'-'*66}")
451
+ print(f"\nTotal: {len(active)} active, {expired_count} expired")
452
+
453
+ return 0
454
+
455
+
456
+ def audit_summary(last_n: int = 20, as_json: bool = False) -> int:
457
+ """
458
+ Summarize recent permission requests, grants, and denials.
459
+
460
+ Parses data/audit_log.jsonl and produces per-agent and per-resource
461
+ breakdowns plus recent activity.
462
+ """
463
+ if not AUDIT_LOG.exists():
464
+ if as_json:
465
+ print(json.dumps({"entries": 0, "summary": {}, "recent": []}))
466
+ else:
467
+ print("No audit log found. (No permission requests recorded yet.)")
468
+ return 0
469
+
470
+ entries: list[dict[str, Any]] = []
471
+ try:
472
+ with open(AUDIT_LOG, "r") as f:
473
+ for line in f:
474
+ line = line.strip()
475
+ if line:
476
+ try:
477
+ entries.append(json.loads(line))
478
+ except json.JSONDecodeError:
479
+ continue
480
+ except OSError:
481
+ if as_json:
482
+ print(json.dumps({"error": "Could not read audit log"}))
483
+ else:
484
+ print("Error: Could not read audit log.")
485
+ return 1
486
+
487
+ if not entries:
488
+ if as_json:
489
+ print(json.dumps({"entries": 0, "summary": {}, "recent": []}))
490
+ else:
491
+ print("Audit log is empty.")
492
+ return 0
493
+
494
+ # Aggregate stats
495
+ total_requests = 0
496
+ total_grants = 0
497
+ by_agent: dict[str, dict[str, int]] = {}
498
+ by_resource: dict[str, dict[str, int]] = {}
499
+
500
+ for entry in entries:
501
+ action = entry.get("action", "")
502
+ details = entry.get("details", {})
503
+ agent_id = details.get("agent_id", "unknown")
504
+ resource_type = details.get("resource_type", "unknown")
505
+
506
+ if action == "permission_request":
507
+ total_requests += 1
508
+ by_agent.setdefault(agent_id, {"requests": 0, "grants": 0})
509
+ by_agent[agent_id]["requests"] += 1
510
+ by_resource.setdefault(resource_type, {"requests": 0, "grants": 0})
511
+ by_resource[resource_type]["requests"] += 1
512
+ elif action == "permission_granted":
513
+ total_grants += 1
514
+ by_agent.setdefault(agent_id, {"requests": 0, "grants": 0})
515
+ by_agent[agent_id]["grants"] += 1
516
+ by_resource.setdefault(resource_type, {"requests": 0, "grants": 0})
517
+ by_resource[resource_type]["grants"] += 1
518
+
519
+ total_denials = total_requests - total_grants
520
+
521
+ # Recent entries (last N)
522
+ recent = entries[-last_n:]
523
+
524
+ # Time range
525
+ first_ts = entries[0].get("timestamp", "unknown")
526
+ last_ts = entries[-1].get("timestamp", "unknown")
527
+
528
+ if as_json:
529
+ output: dict[str, Any] = {
530
+ "total_entries": len(entries),
531
+ "total_requests": total_requests,
532
+ "total_grants": total_grants,
533
+ "total_denials": total_denials,
534
+ "time_range": {"first": first_ts, "last": last_ts},
535
+ "by_agent": by_agent,
536
+ "by_resource": by_resource,
537
+ "recent": recent[-last_n:],
538
+ }
539
+ print(json.dumps(output, indent=2))
540
+ else:
541
+ print("Audit Summary")
542
+ print(f"{'='*70}")
543
+ print(f" Log entries: {len(entries)}")
544
+ print(f" Time range: {first_ts}")
545
+ print(f" {last_ts}")
546
+ print(f"")
547
+ print(f" Requests: {total_requests}")
548
+ print(f" Grants: {total_grants}")
549
+ print(f" Denials: {total_denials}")
550
+ grant_rate = (total_grants / total_requests * 100) if total_requests > 0 else 0
551
+ print(f" Grant Rate: {grant_rate:.0f}%")
552
+
553
+ if by_agent:
554
+ print(f"\n By Agent:")
555
+ print(f" {'-'*50}")
556
+ print(f" {'Agent':<20} {'Requests':>10} {'Grants':>10} {'Denials':>10}")
557
+ print(f" {'-'*50}")
558
+ for agent_id, stats in sorted(by_agent.items()):
559
+ denials = stats["requests"] - stats["grants"]
560
+ print(f" {agent_id:<20} {stats['requests']:>10} {stats['grants']:>10} {denials:>10}")
561
+
562
+ if by_resource:
563
+ print(f"\n By Resource:")
564
+ print(f" {'-'*50}")
565
+ print(f" {'Resource':<20} {'Requests':>10} {'Grants':>10} {'Denials':>10}")
566
+ print(f" {'-'*50}")
567
+ for resource_type, stats in sorted(by_resource.items()):
568
+ denials = stats["requests"] - stats["grants"]
569
+ print(f" {resource_type:<20} {stats['requests']:>10} {stats['grants']:>10} {denials:>10}")
570
+
571
+ print(f"\n Recent Activity (last {min(last_n, len(recent))}):")
572
+ print(f" {'-'*66}")
573
+ for entry in recent:
574
+ ts = entry.get("timestamp", "?")[:19]
575
+ action = entry.get("action", "?")
576
+ details = entry.get("details", {})
577
+ agent_id = details.get("agent_id", "?")
578
+ resource_type = details.get("resource_type", "?")
579
+ symbol = "GRANT" if action == "permission_granted" else "REQ" if action == "permission_request" else action.upper()
580
+ print(f" {ts} [{symbol:>5}] {agent_id} -> {resource_type}")
581
+
582
+ return 0
583
+
584
+
585
+ def main():
586
+ parser = argparse.ArgumentParser(
587
+ description="AuthGuardian Permission Checker",
588
+ formatter_class=argparse.RawDescriptionHelpFormatter,
589
+ epilog="""
590
+ Examples:
591
+ Check permission:
592
+ %(prog)s --agent data_analyst --resource DATABASE \\
593
+ --justification "Need Q4 invoice data for quarterly report"
594
+
595
+ List active grants:
596
+ %(prog)s --active-grants
597
+ %(prog)s --active-grants --agent data_analyst --json
598
+
599
+ View audit summary:
600
+ %(prog)s --audit-summary
601
+ %(prog)s --audit-summary --last 50 --json
602
+ """
603
+ )
604
+
605
+ # Action flags
606
+ parser.add_argument(
607
+ "--active-grants",
608
+ action="store_true",
609
+ help="List all active (non-expired) permission grants"
610
+ )
611
+ parser.add_argument(
612
+ "--audit-summary",
613
+ action="store_true",
614
+ help="Show audit log summary with per-agent and per-resource breakdowns"
615
+ )
616
+ parser.add_argument(
617
+ "--last",
618
+ type=int,
619
+ default=20,
620
+ help="Number of recent audit entries to show (default: 20)"
621
+ )
622
+
623
+ # Permission check args (required only for check mode)
624
+ parser.add_argument(
625
+ "--agent", "-a",
626
+ help="Agent ID requesting permission (required for check; optional filter for --active-grants)"
627
+ )
628
+ parser.add_argument(
629
+ "--resource", "-r",
630
+ choices=["DATABASE", "PAYMENTS", "EMAIL", "FILE_EXPORT"],
631
+ help="Resource type to access"
632
+ )
633
+ parser.add_argument(
634
+ "--justification", "-j",
635
+ help="Business justification for the request"
636
+ )
637
+ parser.add_argument(
638
+ "--scope", "-s",
639
+ help="Specific scope of access (e.g., 'read:invoices')"
640
+ )
641
+ parser.add_argument(
642
+ "--json",
643
+ action="store_true",
644
+ help="Output result as JSON"
645
+ )
646
+
647
+ args = parser.parse_args()
648
+
649
+ # --- Action: --active-grants ---
650
+ if args.active_grants:
651
+ sys.exit(list_active_grants(agent_filter=args.agent, as_json=args.json))
652
+
653
+ # --- Action: --audit-summary ---
654
+ if args.audit_summary:
655
+ sys.exit(audit_summary(last_n=args.last, as_json=args.json))
656
+
657
+ # --- Default action: permission check ---
658
+ if not args.agent:
659
+ parser.error("--agent is required for permission checks")
660
+ if not args.resource:
661
+ parser.error("--resource is required for permission checks")
662
+ if not args.justification:
663
+ parser.error("--justification is required for permission checks")
664
+
665
+ result = evaluate_permission(
666
+ agent_id=args.agent,
667
+ resource_type=args.resource,
668
+ justification=args.justification,
669
+ scope=args.scope
670
+ )
671
+
672
+ if args.json:
673
+ print(json.dumps(result, indent=2))
674
+ else:
675
+ if result["granted"]:
676
+ print("GRANTED")
677
+ print(f"Token: {result['token']}")
678
+ print(f"Expires: {result['expires_at']}")
679
+ print(f"Restrictions: {', '.join(result['restrictions'])}")
680
+ else:
681
+ print("DENIED")
682
+ print(f"Reason: {result['reason']}")
683
+
684
+ print("\nEvaluation Scores:")
685
+ scores = result["scores"]
686
+ if scores.get("justification") is not None:
687
+ print(f" Justification: {scores['justification']:.2f}")
688
+ if scores.get("trust") is not None:
689
+ print(f" Trust Level: {scores['trust']:.2f}")
690
+ if scores.get("risk") is not None:
691
+ print(f" Risk Score: {scores['risk']:.2f}")
692
+ if scores.get("weighted") is not None:
693
+ print(f" Weighted: {scores['weighted']:.2f}")
694
+
695
+ sys.exit(0 if result["granted"] else 1)
696
+
697
+
698
+ if __name__ == "__main__":
699
+ main()