---
phase: 01-foundation
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
  - /Users/shalev/Desktop/AIM/security-server/src/main.py
autonomous: true

must_haves:
  truths:
    - "POST /api/security/scan with JSON body containing text + guardline_specs returns 200 with per-scanner breakdown"
    - "POST /api/security/scan with invalid/missing X-Api-Key returns 401"
    - "POST /api/security/scan with missing text or guardline_specs returns 400"
    - "GET /api/security/health/auth with valid X-Api-Key returns 200"
    - "GET /api/security/health/auth with invalid X-Api-Key returns 401"
  artifacts:
    - path: "/Users/shalev/Desktop/AIM/security-server/src/main.py"
      provides: "/api/security/scan route + /api/security/health/auth route + process_sdk() function"
      contains: "def scan_sdk"
  key_links:
    - from: "scan_sdk route"
      to: "src.text.process_text()"
      via: "process_sdk() function call"
      pattern: "process_text.*guardline_specs"
    - from: "scan_sdk route"
      to: "@api_key_required decorator"
      via: "Flask decorator"
      pattern: "@api_key_required"
---

<objective>
Add the `/api/security/scan` endpoint and an authenticated health endpoint to the existing AIM security server so the SDK has a JSON API to call.

Purpose: The SDK needs a server endpoint that accepts inline guardline_specs (instead of fetching from DB) and returns a per-scanner result breakdown. The existing `/review` endpoint fetches guardlines from the backend DB and returns a flat result -- it cannot serve the SDK's use case.

Output: Two new routes on the existing security_blueprint, plus a `process_sdk()` helper function, all in the existing `src/main.py` file.
</objective>

<execution_context>
@/Users/shalev/.claude/get-shit-done/workflows/execute-plan.md
@/Users/shalev/.claude/get-shit-done/templates/summary.md
</execution_context>

<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/01-foundation/01-CONTEXT.md
@.planning/phases/01-foundation/01-RESEARCH.md

Server codebase (read these to understand existing patterns):
@/Users/shalev/Desktop/AIM/security-server/src/main.py
@/Users/shalev/Desktop/AIM/security-server/src/text.py
@/Users/shalev/Desktop/AIM/security-server/src/structures.py
@/Users/shalev/Desktop/AIM/security-server/src/wrappers.py
@/Users/shalev/Desktop/AIM/security-server/src/policies.py
@/Users/shalev/Desktop/AIM/security-server/src/actions.py
@/Users/shalev/Desktop/AIM/security-server/src/gating.py
@/Users/shalev/Desktop/AIM/security-server/src/env.py
</context>

<tasks>

<task type="auto">
  <name>Task 1: Add process_sdk() function and /api/security/scan endpoint</name>
  <files>/Users/shalev/Desktop/AIM/security-server/src/main.py</files>
  <action>
Add two new functions and one new route to `src/main.py` in the AIM security-server repo (NOT the meshulash_guard repo).

**1. `_build_tc_policies_from_specs()` helper**

TCRecognizer.classify() requires a populated `tc_policies` list -- passing `tc_policies=[]` causes it to return empty TCPredictions immediately (see tc.py line 157: `if not tc_policies: return ...`). For the SDK flow, guardline_specs come inline from the client, so we must extract TC entries and build the tc_policies list in the same format that `_adapt_tc_policies()` in policies.py produces.

Add this helper function:

```python
def _build_tc_policies_from_specs(guardline_specs: dict) -> list[dict]:
    """Extract TC policies from inline guardline_specs for TCRecognizer.classify().

    The SDK sends guardline_specs with TC config embedded. TCRecognizer.classify()
    requires tc_policies as a list[dict] with keys: id, name, head, labels, thresholds,
    level, action, allowlist. This mirrors the format produced by policies._adapt_tc_policies().
    """
    tc_policies: list[dict] = []
    for gl_id, spec in guardline_specs.items():
        if not (spec or {}).get("types", {}).get("tc"):
            continue
        gl_name = spec.get("name", "")
        gl_level = int(spec.get("level", 1))
        gl_action = spec.get("action", "log")
        allowlist = spec.get("allowlist", [])
        # TC entries live in spec["required"]["tc"] as list of [head, label] pairs.
        # Group by head to build one tc_policy dict per unique head.
        required_tc = ((spec.get("required") or {}).get("tc") or [])
        head_groups: dict[str, list] = {}
        head_thresholds: dict[str, dict[str, float]] = {}
        for entry in required_tc:
            if not isinstance(entry, (list, tuple)) or len(entry) < 2:
                continue
            head_raw, label = str(entry[0]).strip(), str(entry[1]).strip()
            if not head_raw or not label:
                continue
            head_groups.setdefault(head_raw, []).append(label)
            head_thresholds.setdefault(head_raw, {})[label] = 0.0
            head_thresholds[head_raw][label.lower()] = 0.0
        for head, labels in head_groups.items():
            tc_policies.append({
                "id": f"{gl_id}:tc:{head}",
                "name": gl_name,
                "head": head,
                "labels": labels,
                "thresholds": head_thresholds.get(head, {}),
                "level": gl_level,
                "action": gl_action,
                "allowlist": allowlist,
            })
    return tc_policies
```

**2. `process_sdk()` function**

Add a `process_sdk(text, guardline_specs)` function that runs the 5-stage pipeline directly (not via `process_text` as a black box) to get access to the gating_result for per-scanner breakdown:

```python
def process_sdk(text: str, guardline_specs: dict) -> dict:
    from src.text import normalize_whitespace, validate_matches, score_matches
    from src.recognizers import get_registry
    from src.recognizers.regex import CustomRegexRecognizer
    from src.recognizers.tc import TCRecognizer
    from src.gating import gate, parse_policy_id
    from src.actions import apply_action, construct_clean_result
    from src.structures import Status

    if not text:
        clean = construct_clean_result(text)
        scanners = {glid: {"triggered": False, "score": 0.0, "detections": []} for glid in guardline_specs}
        return {
            "status": clean[2].value if hasattr(clean[2], 'value') else str(clean[2]),
            "processed_text": clean[0],
            "placeholders": clean[3],
            "level": clean[1],
            "scanners": scanners,
            "risk_categories": [],
        }

    text_normalized = normalize_whitespace(text)

    # Build tc_policies from inline guardline_specs
    tc_policies = _build_tc_policies_from_specs(guardline_specs)

    # Stage 1: RECOGNIZE
    all_matches = get_registry().detect_all(text_normalized)
    all_matches += CustomRegexRecognizer(guardline_specs).detect(text_normalized)
    tc_recognizer = TCRecognizer()
    tc_predictions = tc_recognizer.classify(text_normalized, tc_policies, guardline_specs)

    # Stage 2: VALIDATE
    validated = validate_matches(all_matches, text_normalized)

    # Stage 3: SCORE
    scored = score_matches(validated)

    # Stage 4: GATE
    gating_result = gate(scored, tc_predictions, text_normalized, guardline_specs)

    # Stage 5: ACT
    action_result = apply_action(text_normalized, gating_result)
    processed_text, level, status, placeholders, extras = action_result

    # Build per-scanner response from gating_result
    scanners = {}
    for glid, spec in guardline_specs.items():
        triggered = glid in gating_result.triggered_guardlines
        scanner_detections = []
        if triggered:
            for det in gating_result.merged_detections:
                for pol in det.policies:
                    parsed = parse_policy_id(pol.id)
                    if parsed and parsed.guardline_id == glid:
                        scanner_detections.append({
                            "label": pol.name,
                            "text": text_normalized[det.start:det.end],
                            "start": det.start,
                            "end": det.end,
                            "confidence": 1.0,
                            "action": spec.get("action", "log"),
                        })
                        break  # one detection entry per Detection object per scanner
        scanners[glid] = {
            "triggered": triggered,
            "score": 1.0 if triggered else 0.0,
            "detections": scanner_detections,
        }

    return {
        "status": status.value if hasattr(status, 'value') else str(status),
        "processed_text": processed_text,
        "placeholders": placeholders,
        "level": level,
        "scanners": scanners,
        "risk_categories": extras.get("risk_categories", []),
    }
```

**3. `/api/security/scan` route**

Add a route on `security_blueprint`:
```python
@security_blueprint.route("/scan", methods=["POST"])
@api_key_required
def scan_sdk():
```

The route must:
- Check `request.is_json` first. If JSON: `data = request.get_json(silent=True)`, return 400 if None.
- Elif `request.form`: `data = request.form.to_dict()` (future multipart support).
- Else: return 415 Unsupported Media Type.
- Extract `text = data.get("text")` and `guardline_specs = data.get("guardline_specs")`.
- Return 400 if either is missing.
- Call `process_sdk(text=text, guardline_specs=guardline_specs)`.
- Return the result as JSON with 200.
- Wrap in try/except for `ConnectionError` (from backend auth failure) returning 401, and general `Exception` returning 500.

Note: API key validation happens implicitly -- the `@api_key_required` decorator stores the key in `g.api_key`. For the SDK flow with inline guardline_specs, we do NOT call `src.policies.get_policies()` (that would fetch from DB). However, we still need to validate the API key is legitimate. Add a validation call to the backend: make a lightweight GET to `{BACKEND_URL}/api/guardlines/with-api-key` with the api_key header (same as existing pattern in `_get_guardlines_with_api_key`). If it returns non-200, return 401. This validates the key without using the returned guardlines.

The existing `@api_key_required` decorator only stores the key -- it doesn't validate. The existing flow validates implicitly when `get_policies()` calls the backend. For the SDK flow, add an explicit validation step in `scan_sdk()`:

```python
# Validate API key via backend (same pattern as existing guardline fetch)
import requests as requests_lib
from src.env import BACKEND_URL, TIMEOUT

headers = {"X-Api-Key": g.api_key}
if hasattr(g, "tenant_id") and g.tenant_id:
    headers["X-Tenant-Id"] = g.tenant_id
try:
    resp = requests_lib.get(f"{BACKEND_URL}/api/guardlines/with-api-key", headers=headers, timeout=TIMEOUT)
    if resp.status_code != 200:
        return jsonify({"error": "Invalid or expired API key"}), 401
except Exception:
    return jsonify({"error": "Backend validation unavailable"}), 503
```

Import `requests` the library carefully -- `from flask import request` is already in scope. Import as `import requests as requests_lib` at function scope, or add `import requests as requests_lib` at the module top alongside the existing flask imports.

**4. Authenticated health endpoint**

Add a new route:
```python
@security_blueprint.route("/health/auth", methods=["GET"])
@api_key_required
def health_auth():
```

This validates the API key the same way as scan_sdk (backend call), then returns 200 OK. The SDK's `Guard.__init__()` calls this to validate credentials on construction.

**JSON Contract (response from /api/security/scan):**

```json
{
  "status": "clean|secured|blocked",
  "processed_text": "the processed text with placeholders",
  "placeholders": {"[LABEL-HASH]": "original_text"},
  "level": 0,
  "scanners": {
    "scanner_id": {
      "triggered": true,
      "score": 0.85,
      "detections": [
        {
          "label": "EMAIL_ADDRESS",
          "text": "john@test.com",
          "start": 12,
          "end": 25,
          "confidence": 0.95,
          "action": "replace"
        }
      ]
    }
  },
  "risk_categories": ["PII"]
}
```
  </action>
  <verify>
The server starts without import errors. Manually verify (or write a test) that:
1. `curl -X POST http://localhost:5000/api/security/scan -H "Content-Type: application/json" -H "X-Api-Key: test-key" -d '{"text": "hello", "guardline_specs": {}}'` returns a JSON response (may be 401 if key is invalid, but should NOT be 404 or 500).
2. `curl http://localhost:5000/api/security/health/auth -H "X-Api-Key: test-key"` returns 200 or 401 (not 404).
3. No existing tests break: `cd /Users/shalev/Desktop/AIM/security-server && python -m pytest tests/ -x 2>/dev/null || echo "No test suite or tests passed"`
  </verify>
  <done>
- /api/security/scan accepts JSON POST with text + guardline_specs, runs the 5-stage pipeline with inline specs, returns per-scanner breakdown JSON
- /api/security/scan returns 401 for invalid API key, 400 for missing fields, 415 for wrong content type
- /api/security/health/auth validates API key via backend call and returns 200 (valid) or 401 (invalid)
- TC policies are correctly extracted from guardline_specs via _build_tc_policies_from_specs() so TCRecognizer.classify() actually runs classification
- Existing /review and /review/with-api-key endpoints unchanged and working
  </done>
</task>

</tasks>

<verification>
1. Server starts: `cd /Users/shalev/Desktop/AIM/security-server && python -c "from src.main import app; print('OK')"`
2. Route registered: `python -c "from src.main import app; print([rule.rule for rule in app.url_map.iter_rules() if 'scan' in rule.rule or 'health' in rule.rule])"`
3. Existing routes preserved: ensure `/api/security/review` and `/api/security/review/with-api-key` still in url_map
</verification>

<success_criteria>
- POST /api/security/scan endpoint exists on security_blueprint, decorated with @api_key_required
- process_sdk() calls pipeline stages with inline guardline_specs and builds per-scanner response
- _build_tc_policies_from_specs() extracts TC config from guardline_specs into the tc_policies format TCRecognizer.classify() requires
- GET /api/security/health/auth endpoint validates API key via backend
- Response JSON matches the contract: status, processed_text, placeholders, level, scanners (per-scanner dict), risk_categories
- No existing functionality broken
</success_criteria>

<output>
After completion, create `.planning/phases/01-foundation/01-01-SUMMARY.md`
</output>
