Files
2026-07-13 13:30:30 +08:00

416 lines
16 KiB
Python

# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import os
import re
import secrets
from pathlib import Path
from google.adk.tools import ToolContext
from app.state_schema import ComplianceStep
# Bounded directories configuration for security path traversal check
# Local checkout root is two levels above python-extraction-agent/app. In the
# Docker image, only python-extraction-agent is copied, so fall back to /app.
_APP_DIR = Path(__file__).resolve().parent
_PYTHON_PROJECT_DIR = _APP_DIR.parent
_LOCAL_REPO_ROOT = _PYTHON_PROJECT_DIR.parent
SANDBOX_DIR = str(
_LOCAL_REPO_ROOT
if (_LOCAL_REPO_ROOT / "sample-contracts").exists()
else _PYTHON_PROJECT_DIR
)
UPLOAD_DIR = os.path.join(SANDBOX_DIR, "uploads")
def _secure_resolve_path(filename: str) -> str:
"""Security Boundary Resolver (enforces path limits to prevent directory traversals).
Rule 8 Enforcement: Sanitizes inputs and resolved target boundaries.
"""
# Active detection blocks (fail-close on traversal vectors)
if ".." in filename or "\\" in filename or (filename.startswith("/") and not filename.startswith(UPLOAD_DIR)):
raise PermissionError("Access Denied: Path traversal attack detected.")
safe_filename = os.path.basename(filename)
target_path = os.path.join(UPLOAD_DIR, safe_filename)
# Fully resolve absolute boundary paths
absolute_root = os.path.abspath(UPLOAD_DIR) + os.path.sep
absolute_target = os.path.abspath(target_path)
if not absolute_target.startswith(absolute_root):
raise PermissionError("Access Denied: Path traversal attack detected.")
return absolute_target
# Mock texts mapping for reliable local demo processing
MOCK_CONTRACT_TEXTS = {
"standard-vendor-agreement.pdf": """
SOFTWARE SERVICES VENDOR AGREEMENT
PARTIES:
This Agreement is entered into by and between ACME CLOUD SOLUTIONS ("Contractor") and GFD PLATFORM SYSTEMS ("Client").
VALUE & SERVICES:
Client agrees to pay Contractor a total consideration of $250,000.00 (Two Hundred and Fifty Thousand Dollars) for the performance of the cloud architectural services.
TERM & COMMENCEMENT:
This Agreement shall commence on June 1, 2026, and shall continue in full force and effect until June 1, 2028 (a duration of exactly 2 years), unless terminated earlier in accordance with the terms herein.
LIMITATION OF LIABILITY:
Except for breach of confidentiality obligations, each party's maximum aggregate liability to the other party under this Agreement shall be strictly capped and limited to $1,000,000.00 (One Million Dollars). Under no circumstances shall either party be liable for indirect or consequential damages.
INSURANCE:
Contractor shall maintain Commercial General Liability insurance during the term of this agreement with a minimum coverage limit of $2,000,000.00.
TERMINATION:
Either party may terminate this agreement upon 30 days prior written notice.
""",
"high-risk-liability-contract.pdf": """
ENTERPRISE CLOUD CONSULTING AGREEMENT
PARTIES:
This Agreement is entered into by and between APEX DATA SYSTEMS ("Contractor") and GFD PLATFORM SYSTEMS ("Client").
VALUE & SERVICES:
Client agrees to pay Contractor a total fee of $450,000.00 for enterprise data engineering services.
TERM & COMMENCEMENT:
This Agreement shall commence on June 1, 2026, and terminate on June 1, 2027 (1 year duration).
LIMITATION OF LIABILITY:
LIABILITY LIMITS ARE EXPLICITLY WAIVED. CONTRACTOR LIABILITY SHALL BE UNLIMITED UNDER ALL CIRCUMSTANCES. GFD PLATFORM SYSTEMS ASSUMES FULL COMPENSATORY RESPONSIBILITY FOR ALL THIRD-PARTY CLAIMS ARISEN OUT OF PERFORMANCE ACTIONS.
INSURANCE:
Contractor shall maintain standard professional liability insurance with minimum coverage of $1,500,000.00.
TERMINATION:
Termination requires 30 days notice.
""",
"non-compliant-contract.pdf": """
LEGACY SYSTEMS INTEGRATION SERVICES CHARTER
PARTIES:
This Agreement is entered into by and between LEGACY NETWORKS CORP ("Contractor") and GFD PLATFORM SYSTEMS ("Client").
VALUE & SERVICES:
Client agrees to pay Contractor a sum of $850,000.00 (Eight Hundred and Fifty Thousand Dollars) for legacy migration activities.
TERM & COMMENCEMENT:
This Charter shall commence on June 1, 2026, and extend until June 1, 2032 (a total term of exactly 6 years).
LIMITATION OF LIABILITY:
THE PARTIES EXPRESSLY AGREE THAT ALL CONTRACTOR RESPONSIBILITY LIMITS ARE INAPPLICABLE. CONTRACTOR'S TOTAL LIABILITY UNDER THIS CONTRACT SHALL BE ENTIRELY UNLIMITED.
INSURANCE:
Contractor shall maintain general liability protection limits of $500,000.00.
TERMINATION:
THIS AGREEMENT SHALL AUTOMATICALLY RENEW FOREVER WITH NO OPTION FOR TERMINATION OR WRITTEN EXIT NOTICES, EXCEPT ON TOTAL LIQUIDATION OF EITHER PARTY.
"""
}
SAMPLE_CONTRACT_DETAILS = {
"standard-vendor-agreement.pdf": {
"contract_value": 250000.0,
"contractor_name": "ACME CLOUD SOLUTIONS",
"client_name": "GFD PLATFORM SYSTEMS",
"start_date": "2026-06-01",
"end_date": "2028-06-01",
"liability_limit": "$1,000,000.00",
"insurance_coverage": 2000000.0,
"auto_renewal": False,
"has_termination_clause": True,
"term_length_years": 2,
},
"high-risk-liability-contract.pdf": {
"contract_value": 450000.0,
"contractor_name": "APEX DATA SYSTEMS",
"client_name": "GFD PLATFORM SYSTEMS",
"start_date": "2026-06-01",
"end_date": "2027-06-01",
"liability_limit": "unlimited liability",
"insurance_coverage": 1500000.0,
"auto_renewal": False,
"has_termination_clause": True,
"term_length_years": 1,
},
"non-compliant-contract.pdf": {
"contract_value": 850000.0,
"contractor_name": "LEGACY NETWORKS CORP",
"client_name": "GFD PLATFORM SYSTEMS",
"start_date": "2026-06-01",
"end_date": "2032-06-01",
"liability_limit": "unlimited liability",
"insurance_coverage": 500000.0,
"auto_renewal": True,
"has_termination_clause": False,
"term_length_years": 6,
},
}
MONTHS = {
"january": "01",
"february": "02",
"march": "03",
"april": "04",
"may": "05",
"june": "06",
"july": "07",
"august": "08",
"september": "09",
"october": "10",
"november": "11",
"december": "12",
}
def _money_to_float(value: str) -> float:
return float(value.replace("$", "").replace(",", ""))
def _iso_date(month: str, day: str, year: str) -> str:
return f"{year}-{MONTHS[month.lower()]}-{int(day):02d}"
def extract_contract_details_from_text(filename: str, text: str) -> dict:
"""Extracts deterministic contract fields from uploaded text for the cockpit demo."""
sample_name = os.path.basename(filename)
normalized_text = text.strip()
if not normalized_text and sample_name in SAMPLE_CONTRACT_DETAILS:
return dict(SAMPLE_CONTRACT_DETAILS[sample_name])
amounts = re.findall(r"\$[\d,]+(?:\.\d{2})?", normalized_text)
dates = re.findall(
r"(January|February|March|April|May|June|July|August|September|October|November|December)\s+(\d{1,2}),\s+(\d{4})",
normalized_text,
flags=re.IGNORECASE,
)
parties = re.search(
r"between\s+(.+?)\s+\(\"Contractor\"\)\s+and\s+(.+?)\s+\(\"Client\"\)",
normalized_text,
flags=re.IGNORECASE | re.DOTALL,
)
duration = re.search(r"(\d+)\s+years?", normalized_text, flags=re.IGNORECASE)
liability_section = ""
liability_match = re.search(
r"LIMITATION OF LIABILITY:\s*(.*?)(?:\n\s*\n|INSURANCE:)",
normalized_text,
flags=re.IGNORECASE | re.DOTALL,
)
if liability_match:
liability_section = liability_match.group(1).strip()
lower_text = normalized_text.lower()
termination_section = ""
termination_match = re.search(
r"TERMINATION:\s*(.*?)(?:\n\s*\n|$)",
normalized_text,
flags=re.IGNORECASE | re.DOTALL,
)
if termination_match:
termination_section = termination_match.group(1).strip().lower()
has_termination_clause = "terminate" in lower_text or "termination" in lower_text
blocked_termination_phrases = (
"no option for termination",
"no option for termination or written exit",
"no termination",
"without termination",
)
if any(phrase in termination_section for phrase in blocked_termination_phrases):
has_termination_clause = False
return {
"contract_value": _money_to_float(amounts[0]) if amounts else 0.0,
"contractor_name": parties.group(1).strip() if parties else "Unknown Contractor",
"client_name": parties.group(2).strip() if parties else "Unknown Client",
"start_date": _iso_date(*dates[0]) if dates else "",
"end_date": _iso_date(*dates[1]) if len(dates) > 1 else "",
"liability_limit": liability_section or "unknown",
"insurance_coverage": _money_to_float(amounts[-1]) if len(amounts) > 1 else 0.0,
"auto_renewal": "automatically renew" in lower_text,
"has_termination_clause": has_termination_clause,
"term_length_years": int(duration.group(1)) if duration else 1,
}
def classify_contract_risk(details: dict) -> dict:
"""Classifies legal risk from extracted details without requiring an ADK context."""
liability = details.get("liability_limit", "").lower()
value = details.get("contract_value", 0.0)
term = details.get("term_length_years", 1)
risk_factors = []
if "unlimited" in liability or "none" in liability:
risk_factors.append("Unlimited contractor liability")
if value > 500000.0:
risk_factors.append("High financial obligation value (> $500k)")
if term > 5:
risk_factors.append("Extended engagement lifecycle duration (> 5 years)")
if not details.get("has_termination_clause", True):
risk_factors.append("Missing exit notice termination safety clauses")
if len(risk_factors) >= 2 or "Unlimited contractor liability" in risk_factors:
risk_tier = "HIGH"
elif len(risk_factors) == 1:
risk_tier = "MEDIUM"
else:
risk_tier = "LOW"
return {"risk_tier": risk_tier, "risk_factors": risk_factors}
def read_contract_text(file_path: str, tool_context: ToolContext) -> str:
"""Reads contract file text content securely, verifying path boundaries.
Args:
file_path: The filename or target path of the contract text fixture.
Returns:
The raw text content extracted from the contract.
"""
try:
# Enforce sandbox isolation limits
secure_path = _secure_resolve_path(file_path)
filename = os.path.basename(secure_path)
if os.path.exists(secure_path):
with open(secure_path, "r", encoding="utf-8", errors="ignore") as f:
return f.read()
# Fallback registry keeps legacy tests and empty demo environments usable.
if filename in MOCK_CONTRACT_TEXTS:
return MOCK_CONTRACT_TEXTS[filename]
return f"[Error: Target contract file '{filename}' was not found in uploads sandbox directory.]"
except Exception as e:
return f"[Error reading file: {e!s}]"
def save_extracted_fields(
contract_value: float,
contractor_name: str,
client_name: str,
start_date: str,
end_date: str,
liability_limit: str,
insurance_coverage: float,
auto_renewal: bool,
has_termination_clause: bool,
tool_context: ToolContext,
) -> dict:
"""Saves extracted contract parameters directly to persistent ADK session state.
Rule 24 & Masking Enforcement: Sensitive identifiers are secured.
"""
state = tool_context.state
# Construct structured contract data profile
contract_data = {
"contract_value": contract_value,
"contractor_name": contractor_name,
"client_name": client_name,
"start_date": start_date,
"end_date": end_date,
"liability_limit": liability_limit,
"insurance_coverage": insurance_coverage,
"auto_renewal": auto_renewal,
"has_termination_clause": has_termination_clause,
}
state["contract_details"] = contract_data
state["current_step"] = ComplianceStep.EXTRACTED
state["pending_signals"] = ["a2a_compliance_check"]
# Calculate term length (approximate)
try:
start_year = int(start_date.split("-")[0])
end_year = int(end_date.split("-")[0])
term_years = max(1, end_year - start_year)
except Exception:
term_years = 1
contract_data["term_length_years"] = term_years
return {
"status": "success",
"message": f"Contract parameters successfully extracted for {contractor_name}.",
"extracted_details": contract_data,
}
def classify_risk_level(tool_context: ToolContext) -> dict:
"""Evaluates extracted clauses to determine initial legal risk tier.
Returns:
A dictionary with risk classification verdict.
"""
state = tool_context.state
details = state.get("contract_details", {})
if not details:
return {"status": "error", "message": "No contract parameters extracted yet."}
state["risk_assessment"] = classify_contract_risk(details)
return {
"status": "success",
"risk_tier": state["risk_assessment"]["risk_tier"],
"risk_factors": state["risk_assessment"]["risk_factors"],
}
def generate_summary_report(tool_context: ToolContext) -> dict:
"""Generates the final multi-agent compliance summary and HTML certificate.
Writes safety report artifacts securely inside Sandbox target bounds.
"""
state = tool_context.state
case_id = state.get("case_id", "local-case")
details = state.get("contract_details", {})
risk = state.get("risk_assessment", {})
verdict = state.get("compliance_verdict", {})
if not details:
return {"status": "error", "message": "No data extracted."}
# Set final steps
state["current_step"] = ComplianceStep.APPROVED if verdict.get("passed", False) else ComplianceStep.REVIEW_READY
state["pending_signals"] = []
report_data = {
"case_id": case_id,
"contractor": details.get("contractor_name", "Unknown Contractor"),
"client": details.get("client_name", "GFD Platform Systems"),
"value": details.get("contract_value", 0.0),
"term": f"{details.get('start_date')} to {details.get('end_date')} ({details.get('term_length_years')} yrs)",
"risk_tier": risk.get("risk_tier", "MEDIUM"),
"risk_factors": risk.get("risk_factors", []),
"passed": verdict.get("passed", False),
"violations": verdict.get("violations", []),
"timestamp": state.get("completion_time", "N/A"),
}
state["final_report"] = report_data
return {
"status": "success",
"message": f"Legal audit report compiled successfully for case {case_id}.",
"report_profile": report_data,
}