forgedev 1.2.0 → 1.3.0
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.
- package/README.md +57 -10
- package/bin/chainproof.js +126 -0
- package/package.json +25 -7
- package/src/chainproof-bridge.js +330 -0
- package/src/ci-mode.js +85 -0
- package/src/claude-configurator.js +86 -49
- package/src/cli.js +30 -7
- package/src/composer.js +159 -34
- package/src/doctor-checks-chainproof.js +106 -0
- package/src/doctor-checks.js +39 -20
- package/src/doctor-prompts.js +9 -9
- package/src/doctor.js +37 -4
- package/src/guided.js +3 -3
- package/src/index.js +31 -10
- package/src/init-mode.js +64 -11
- package/src/menu.js +178 -0
- package/src/prompts.js +5 -12
- package/src/recommender.js +134 -10
- package/src/scanner.js +57 -2
- package/src/uat-generator.js +204 -189
- package/src/update-check.js +9 -4
- package/src/update.js +1 -1
- package/src/utils.js +64 -5
- package/templates/ai/guardrails-py/backend/app/ai/__init__.py +29 -0
- package/templates/ai/guardrails-py/backend/app/ai/audit_log.py +133 -0
- package/templates/ai/guardrails-py/backend/app/ai/client.py.template +323 -0
- package/templates/ai/guardrails-py/backend/app/ai/health.py.template +157 -0
- package/templates/ai/guardrails-py/backend/app/ai/input_guard.py +98 -0
- package/templates/ai/guardrails-ts/src/lib/ai/audit-log.ts.template +164 -0
- package/templates/ai/guardrails-ts/src/lib/ai/client.ts.template +403 -0
- package/templates/ai/guardrails-ts/src/lib/ai/health.ts.template +165 -0
- package/templates/ai/guardrails-ts/src/lib/ai/index.ts.template +17 -0
- package/templates/ai/guardrails-ts/src/lib/ai/input-guard.ts.template +124 -0
- package/templates/auth/nextauth/src/lib/auth.ts.template +12 -7
- package/templates/backend/express/Dockerfile.template +18 -0
- package/templates/backend/express/package.json.template +33 -0
- package/templates/backend/express/src/index.ts.template +34 -0
- package/templates/backend/express/src/routes/health.ts.template +27 -0
- package/templates/backend/express/tsconfig.json +17 -0
- package/templates/backend/fastapi/backend/Dockerfile.template +5 -0
- package/templates/backend/fastapi/backend/app/api/health.py.template +1 -1
- package/templates/backend/fastapi/backend/app/core/config.py.template +1 -1
- package/templates/backend/fastapi/backend/app/core/errors.py +1 -1
- package/templates/backend/fastapi/backend/app/main.py.template +3 -1
- package/templates/backend/fastapi/backend/requirements.txt.template +2 -0
- package/templates/backend/hono/Dockerfile.template +18 -0
- package/templates/backend/hono/package.json.template +31 -0
- package/templates/backend/hono/src/index.ts.template +32 -0
- package/templates/backend/hono/src/routes/health.ts.template +27 -0
- package/templates/backend/hono/tsconfig.json +18 -0
- package/templates/base/docs/uat/UAT_TEMPLATE.md.template +1 -1
- package/templates/chainproof/base/.chainproof/config.json.template +11 -0
- package/templates/chainproof/base/.chainproof/mcp-server.mjs +310 -0
- package/templates/chainproof/base/.mcp.json +9 -0
- package/templates/chainproof/fastapi/.chainproof/middleware.json.template +14 -0
- package/templates/chainproof/nextjs/.chainproof/hooks.json.template +19 -0
- package/templates/chainproof/polyglot/.chainproof/config.json.template +21 -0
- package/templates/claude-code/agents/architect.md +25 -11
- package/templates/claude-code/agents/build-error-resolver.md +19 -5
- package/templates/claude-code/agents/chief-of-staff.md +42 -8
- package/templates/claude-code/agents/code-quality-reviewer.md +14 -0
- package/templates/claude-code/agents/database-reviewer.md +15 -1
- package/templates/claude-code/agents/deep-reviewer.md +191 -0
- package/templates/claude-code/agents/doc-updater.md +19 -5
- package/templates/claude-code/agents/docs-lookup.md +19 -5
- package/templates/claude-code/agents/e2e-runner.md +26 -12
- package/templates/claude-code/agents/enforcement-gate.md +102 -0
- package/templates/claude-code/agents/frontend-builder.md +188 -0
- package/templates/claude-code/agents/harness-optimizer.md +36 -1
- package/templates/claude-code/agents/loop-operator.md +27 -13
- package/templates/claude-code/agents/planner.md +21 -7
- package/templates/claude-code/agents/product-strategist.md +24 -10
- package/templates/claude-code/agents/production-readiness.md +14 -0
- package/templates/claude-code/agents/prompt-auditor.md +115 -0
- package/templates/claude-code/agents/refactor-cleaner.md +22 -8
- package/templates/claude-code/agents/security-reviewer.md +14 -0
- package/templates/claude-code/agents/spec-validator.md +15 -1
- package/templates/claude-code/agents/tdd-guide.md +21 -7
- package/templates/claude-code/agents/uat-validator.md +14 -0
- package/templates/claude-code/claude-md/base.md +14 -7
- package/templates/claude-code/claude-md/fastapi.md +8 -8
- package/templates/claude-code/claude-md/fullstack.md +6 -6
- package/templates/claude-code/claude-md/hono.md +18 -0
- package/templates/claude-code/claude-md/nextjs.md +5 -5
- package/templates/claude-code/claude-md/remix.md +18 -0
- package/templates/claude-code/commands/audit-security.md +14 -0
- package/templates/claude-code/commands/audit-spec.md +14 -0
- package/templates/claude-code/commands/audit-wiring.md +14 -0
- package/templates/claude-code/commands/build-fix.md +28 -0
- package/templates/claude-code/commands/build-ui.md +59 -0
- package/templates/claude-code/commands/code-review.md +53 -31
- package/templates/claude-code/commands/fix-loop.md +211 -0
- package/templates/claude-code/commands/full-audit.md +36 -8
- package/templates/claude-code/commands/generate-prd.md +1 -1
- package/templates/claude-code/commands/generate-sdd.md +74 -0
- package/templates/claude-code/commands/generate-uat.md +107 -35
- package/templates/claude-code/commands/help.md +68 -0
- package/templates/claude-code/commands/live-uat.md +268 -0
- package/templates/claude-code/commands/optimize-claude-md.md +15 -1
- package/templates/claude-code/commands/plan.md +3 -3
- package/templates/claude-code/commands/pre-pr.md +57 -19
- package/templates/claude-code/commands/product-strategist.md +21 -0
- package/templates/claude-code/commands/resume-session.md +10 -10
- package/templates/claude-code/commands/run-uat.md +59 -2
- package/templates/claude-code/commands/save-session.md +10 -10
- package/templates/claude-code/commands/simplify.md +36 -0
- package/templates/claude-code/commands/tdd.md +17 -18
- package/templates/claude-code/commands/verify-all.md +24 -0
- package/templates/claude-code/commands/verify-intent.md +55 -0
- package/templates/claude-code/commands/workflows.md +52 -40
- package/templates/claude-code/hooks/polyglot.json +10 -1
- package/templates/claude-code/hooks/python.json +10 -1
- package/templates/claude-code/hooks/scripts/autofix-polyglot.mjs +2 -2
- package/templates/claude-code/hooks/scripts/autofix-python.mjs +1 -1
- package/templates/claude-code/hooks/scripts/autofix-typescript.mjs +1 -1
- package/templates/claude-code/hooks/scripts/code-hygiene.mjs +293 -0
- package/templates/claude-code/hooks/scripts/pre-commit-gate.mjs +207 -0
- package/templates/claude-code/hooks/typescript.json +10 -1
- package/templates/claude-code/skills/ai-prompts/SKILL.md +119 -41
- package/templates/claude-code/skills/git-workflow/SKILL.md +5 -5
- package/templates/claude-code/skills/nextjs/SKILL.md +1 -1
- package/templates/claude-code/skills/playwright/SKILL.md +5 -5
- package/templates/claude-code/skills/security-api/SKILL.md +1 -1
- package/templates/claude-code/skills/security-web/SKILL.md +1 -1
- package/templates/claude-code/skills/testing-patterns/SKILL.md +9 -9
- package/templates/database/prisma-postgres/{.env.example → .env.example.template} +1 -0
- package/templates/database/sqlalchemy-postgres/{.env.example → .env.example.template} +1 -0
- package/templates/docs-portal/fastapi/backend/app/portal/__pycache__/docs_reader.cpython-314.pyc +0 -0
- package/templates/docs-portal/fastapi/backend/app/portal/docs_reader.py +201 -0
- package/templates/docs-portal/fastapi/backend/app/portal/html_renderer.py +229 -0
- package/templates/docs-portal/fastapi/backend/app/portal/router.py.template +35 -0
- package/templates/docs-portal/nextjs/src/app/portal/[category]/[slug]/page.tsx +81 -0
- package/templates/docs-portal/nextjs/src/app/portal/[category]/page.tsx +65 -0
- package/templates/docs-portal/nextjs/src/app/portal/layout.tsx.template +54 -0
- package/templates/docs-portal/nextjs/src/app/portal/page.tsx +85 -0
- package/templates/docs-portal/nextjs/src/components/portal/markdown-renderer.tsx +101 -0
- package/templates/docs-portal/nextjs/src/components/portal/mobile-portal-nav.tsx +81 -0
- package/templates/docs-portal/nextjs/src/components/portal/portal-nav.tsx +86 -0
- package/templates/docs-portal/nextjs/src/lib/docs.ts +139 -0
- package/templates/frontend/nextjs/package.json.template +3 -1
- package/templates/frontend/react/index.html.template +12 -0
- package/templates/frontend/react/package.json.template +34 -0
- package/templates/frontend/react/src/App.tsx.template +10 -0
- package/templates/frontend/react/src/index.css +1 -0
- package/templates/frontend/react/src/main.tsx +10 -0
- package/templates/frontend/react/tsconfig.json +17 -0
- package/templates/frontend/react/vite.config.ts.template +15 -0
- package/templates/frontend/react/vitest.config.ts +9 -0
- package/templates/frontend/remix/app/root.tsx.template +31 -0
- package/templates/frontend/remix/app/routes/_index.tsx.template +19 -0
- package/templates/frontend/remix/app/routes/api.health.ts.template +10 -0
- package/templates/frontend/remix/app/tailwind.css +1 -0
- package/templates/frontend/remix/package.json.template +39 -0
- package/templates/frontend/remix/tsconfig.json +18 -0
- package/templates/frontend/remix/vite.config.ts.template +7 -0
- package/templates/infra/github-actions/.github/workflows/ci.yml.template +3 -0
- package/docs/00-README.md +0 -310
- package/docs/01-universal-prompt-library.md +0 -1049
- package/docs/02-claude-code-mastery-playbook.md +0 -283
- package/docs/03-multi-agent-verification.md +0 -565
- package/docs/04-errata-and-verification-checklist.md +0 -284
- package/docs/05-universal-scaffolder-vision.md +0 -452
- package/docs/06-confidence-assessment-and-repo-prompt.md +0 -407
- package/docs/errata.md +0 -58
- package/docs/multi-agent-verification.md +0 -66
- package/docs/playbook.md +0 -95
- package/docs/prompt-library.md +0 -160
- package/docs/uat/UAT_CHECKLIST.csv +0 -9
- package/docs/uat/UAT_TEMPLATE.md +0 -163
- package/templates/claude-code/commands/done.md +0 -19
- /package/{docs/plans/.gitkeep → templates/docs-portal/fastapi/backend/app/portal/__init__.py} +0 -0
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""AI Input Guard — Prompt injection detection and input sanitization.
|
|
2
|
+
|
|
3
|
+
Compliance: EU AI Act Art. 15 (robustness), NIST AI RMF Manage 2.2
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import re
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class InputValidationResult:
|
|
12
|
+
blocked: bool
|
|
13
|
+
reason: str | None = None
|
|
14
|
+
detected_patterns: list[str] = field(default_factory=list)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
# --- Injection Detection Patterns ---
|
|
18
|
+
|
|
19
|
+
INJECTION_PATTERNS: list[tuple[re.Pattern, str]] = [
|
|
20
|
+
# Direct instruction override
|
|
21
|
+
(re.compile(r"ignore\s+(all\s+)?(previous|prior|above)\s+(instructions|prompts|rules)", re.I), "instruction-override"),
|
|
22
|
+
(re.compile(r"disregard\s+(all\s+)?(previous|prior|above|your)\s+(instructions|prompts|rules|training)", re.I), "instruction-override"),
|
|
23
|
+
(re.compile(r"forget\s+(all\s+)?(previous|prior|your)\s+(instructions|context|rules)", re.I), "instruction-override"),
|
|
24
|
+
|
|
25
|
+
# Role manipulation
|
|
26
|
+
(re.compile(r"you\s+are\s+now\s+(a|an|the)\s+", re.I), "role-manipulation"),
|
|
27
|
+
(re.compile(r"act\s+as\s+(if\s+you\s+are|a|an)\s+", re.I), "role-manipulation"),
|
|
28
|
+
(re.compile(r"pretend\s+(to\s+be|you\s+are)\s+", re.I), "role-manipulation"),
|
|
29
|
+
(re.compile(r"from\s+now\s+on\s+(you|your)\s+", re.I), "role-manipulation"),
|
|
30
|
+
|
|
31
|
+
# System prompt extraction
|
|
32
|
+
(re.compile(r"what\s+(is|are)\s+your\s+(system\s+)?(prompt|instructions|rules)", re.I), "prompt-extraction"),
|
|
33
|
+
(re.compile(r"show\s+me\s+your\s+(system\s+)?(prompt|instructions)", re.I), "prompt-extraction"),
|
|
34
|
+
(re.compile(r"repeat\s+(your|the)\s+(system\s+)?(prompt|instructions)", re.I), "prompt-extraction"),
|
|
35
|
+
(re.compile(r"print\s+(your|the)\s+(system\s+)?(prompt|instructions)", re.I), "prompt-extraction"),
|
|
36
|
+
|
|
37
|
+
# Delimiter injection
|
|
38
|
+
(re.compile(r"</?system>", re.I), "delimiter-injection"),
|
|
39
|
+
(re.compile(r"\[INST\]", re.I), "delimiter-injection"),
|
|
40
|
+
(re.compile(r"<\|im_start\|", re.I), "delimiter-injection"),
|
|
41
|
+
(re.compile(r"###\s*(system|instruction|human|assistant)", re.I), "delimiter-injection"),
|
|
42
|
+
|
|
43
|
+
# Data exfiltration
|
|
44
|
+
(re.compile(r"send\s+(this|the|all)\s+(data|info|conversation)\s+to", re.I), "data-exfiltration"),
|
|
45
|
+
(re.compile(r"forward\s+(this|everything)\s+to", re.I), "data-exfiltration"),
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
SUSPICIOUS_PATTERNS: list[tuple[re.Pattern, str]] = [
|
|
49
|
+
(re.compile(r"eval\s*\(", re.I), "code-execution"),
|
|
50
|
+
(re.compile(r"exec\s*\(", re.I), "code-execution"),
|
|
51
|
+
(re.compile(r"import\s+(?:os|subprocess)|subprocess\.", re.I), "code-execution"),
|
|
52
|
+
(re.compile(r"[A-Za-z0-9+/]{100,}={0,2}", re.I), "encoded-payload"),
|
|
53
|
+
]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def validate_input(text: str) -> InputValidationResult:
|
|
57
|
+
"""Validate input for prompt injection and safety concerns."""
|
|
58
|
+
import unicodedata
|
|
59
|
+
# Normalize Unicode to defeat homoglyph attacks (e.g., Cyrillic "а" for Latin "a")
|
|
60
|
+
normalized = unicodedata.normalize("NFKC", text)
|
|
61
|
+
detected: list[str] = []
|
|
62
|
+
|
|
63
|
+
for pattern, name in INJECTION_PATTERNS:
|
|
64
|
+
if pattern.search(normalized):
|
|
65
|
+
detected.append(name)
|
|
66
|
+
|
|
67
|
+
if detected:
|
|
68
|
+
unique = list(set(detected))
|
|
69
|
+
return InputValidationResult(
|
|
70
|
+
blocked=True,
|
|
71
|
+
reason=f"Potential prompt injection detected: {', '.join(unique)}",
|
|
72
|
+
detected_patterns=unique,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
suspicious: list[str] = []
|
|
76
|
+
for pattern, name in SUSPICIOUS_PATTERNS:
|
|
77
|
+
if pattern.search(normalized):
|
|
78
|
+
suspicious.append(name)
|
|
79
|
+
|
|
80
|
+
if suspicious:
|
|
81
|
+
return InputValidationResult(
|
|
82
|
+
blocked=False,
|
|
83
|
+
reason=f"Suspicious patterns detected (not blocked): {', '.join(suspicious)}",
|
|
84
|
+
detected_patterns=suspicious,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
return InputValidationResult(blocked=False)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def sanitize_input(text: str) -> str:
|
|
91
|
+
"""Remove known dangerous patterns from input."""
|
|
92
|
+
sanitized = re.sub(r"</?system>", "", text, flags=re.I)
|
|
93
|
+
sanitized = re.sub(r"\[INST\]", "", sanitized, flags=re.I)
|
|
94
|
+
sanitized = re.sub(r"<\|im_start\|[^>]*>?", "", sanitized, flags=re.I)
|
|
95
|
+
sanitized = re.sub(r"<\|im_end\|>?", "", sanitized, flags=re.I)
|
|
96
|
+
sanitized = re.sub(r"###\s*(system|instruction|human|assistant)", "", sanitized, flags=re.I)
|
|
97
|
+
sanitized = sanitized.replace("\x00", "")
|
|
98
|
+
return sanitized
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AI Audit Logger — Structured logging of all AI interactions.
|
|
3
|
+
*
|
|
4
|
+
* Compliance: EU AI Act Art. 12 (logging and traceability),
|
|
5
|
+
* NIST AI RMF Manage 1.3 (monitoring)
|
|
6
|
+
*
|
|
7
|
+
* Every AI call is logged with:
|
|
8
|
+
* - Input preview (truncated, no PII in logs)
|
|
9
|
+
* - Output confidence score
|
|
10
|
+
* - Model version and parameters
|
|
11
|
+
* - Human review decisions
|
|
12
|
+
* - Latency and token usage
|
|
13
|
+
* - Error details
|
|
14
|
+
*
|
|
15
|
+
* Logs are structured JSON for easy ingestion into observability platforms
|
|
16
|
+
* (Datadog, Grafana, ELK, etc.)
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
export interface AuditEntry {
|
|
20
|
+
/** Unique interaction ID (matches AIResponse.auditId) */
|
|
21
|
+
id: string;
|
|
22
|
+
/** ISO 8601 timestamp */
|
|
23
|
+
timestamp: string;
|
|
24
|
+
/** Model identifier */
|
|
25
|
+
model: string;
|
|
26
|
+
/** Business purpose of this AI call */
|
|
27
|
+
purpose: string;
|
|
28
|
+
/** Truncated input for traceability (never log full PII) */
|
|
29
|
+
inputPreview: string;
|
|
30
|
+
/** Confidence score (0-1) */
|
|
31
|
+
confidence: number;
|
|
32
|
+
/** Whether human review was triggered */
|
|
33
|
+
needsHumanReview: boolean;
|
|
34
|
+
/** Total latency in milliseconds */
|
|
35
|
+
latencyMs: number;
|
|
36
|
+
/** Token usage for cost tracking */
|
|
37
|
+
tokenUsage?: { inputTokens: number; outputTokens: number };
|
|
38
|
+
/** Whether the call succeeded */
|
|
39
|
+
success: boolean;
|
|
40
|
+
/** Error message if failed */
|
|
41
|
+
error?: string;
|
|
42
|
+
/** Human reviewer action (if reviewed) */
|
|
43
|
+
humanAction?: 'approved' | 'rejected' | 'modified';
|
|
44
|
+
/** Human reviewer ID (if reviewed) */
|
|
45
|
+
humanReviewerId?: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface AuditLogConfig {
|
|
49
|
+
/** Where to send logs: 'console' or custom handler */
|
|
50
|
+
destination: 'console' | 'custom';
|
|
51
|
+
/** Custom log handler */
|
|
52
|
+
handler?: (entry: AuditEntry) => void;
|
|
53
|
+
/** Log level filter: only log entries with confidence below this */
|
|
54
|
+
confidenceAlertThreshold?: number;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
class AIAuditLog {
|
|
58
|
+
private config: AuditLogConfig;
|
|
59
|
+
private entries: AuditEntry[] = [];
|
|
60
|
+
private maxInMemory = 1000;
|
|
61
|
+
|
|
62
|
+
constructor(config?: Partial<AuditLogConfig>) {
|
|
63
|
+
this.config = {
|
|
64
|
+
destination: config?.destination || 'console',
|
|
65
|
+
handler: config?.handler,
|
|
66
|
+
confidenceAlertThreshold: config?.confidenceAlertThreshold ?? 0.5,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
log(entry: AuditEntry): void {
|
|
71
|
+
// In-memory buffer for health metrics
|
|
72
|
+
this.entries.push(entry);
|
|
73
|
+
if (this.entries.length > this.maxInMemory) {
|
|
74
|
+
this.entries = this.entries.slice(-this.maxInMemory);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Structured log output (exclude inputPreview to avoid PII in logs)
|
|
78
|
+
const { inputPreview: _preview, ...safeEntry } = entry;
|
|
79
|
+
const logEntry = {
|
|
80
|
+
level: entry.success ? 'info' : 'error',
|
|
81
|
+
type: 'ai_interaction',
|
|
82
|
+
...safeEntry,
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
switch (this.config.destination) {
|
|
86
|
+
case 'console':
|
|
87
|
+
if (!entry.success || (entry.confidence < (this.config.confidenceAlertThreshold ?? 0.5))) {
|
|
88
|
+
console.warn('[AI_AUDIT]', JSON.stringify(logEntry));
|
|
89
|
+
} else {
|
|
90
|
+
console.log('[AI_AUDIT]', JSON.stringify(logEntry));
|
|
91
|
+
}
|
|
92
|
+
break;
|
|
93
|
+
case 'custom':
|
|
94
|
+
this.config.handler?.(entry);
|
|
95
|
+
break;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Record a human review decision against an existing audit entry.
|
|
101
|
+
*/
|
|
102
|
+
recordHumanReview(auditId: string, action: 'approved' | 'rejected' | 'modified', reviewerId?: string): void {
|
|
103
|
+
const entry = this.entries.find(e => e.id === auditId);
|
|
104
|
+
if (!entry) {
|
|
105
|
+
console.warn(`[AI_AUDIT] Cannot record human review: audit entry ${auditId} not found`);
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
entry.humanAction = action;
|
|
109
|
+
entry.humanReviewerId = reviewerId;
|
|
110
|
+
// Re-emit to log destination without adding duplicate to buffer
|
|
111
|
+
const logEntry = {
|
|
112
|
+
level: 'info',
|
|
113
|
+
type: 'ai_interaction_review',
|
|
114
|
+
...entry,
|
|
115
|
+
};
|
|
116
|
+
if (this.config.destination === 'console') {
|
|
117
|
+
console.log('[AI_AUDIT]', JSON.stringify(logEntry));
|
|
118
|
+
} else if (this.config.destination === 'custom') {
|
|
119
|
+
this.config.handler?.(entry);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Get recent entries for monitoring dashboard.
|
|
125
|
+
*/
|
|
126
|
+
getRecentEntries(count = 50): AuditEntry[] {
|
|
127
|
+
return this.entries.slice(-count);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Get aggregate stats for AI health reporting.
|
|
132
|
+
*/
|
|
133
|
+
getStats(windowMs = 3600_000): {
|
|
134
|
+
totalCalls: number;
|
|
135
|
+
successRate: number;
|
|
136
|
+
avgConfidence: number;
|
|
137
|
+
avgLatencyMs: number;
|
|
138
|
+
humanReviewRate: number;
|
|
139
|
+
errorRate: number;
|
|
140
|
+
} {
|
|
141
|
+
const cutoff = new Date(Date.now() - windowMs).toISOString();
|
|
142
|
+
const recent = this.entries.filter(e => e.timestamp >= cutoff);
|
|
143
|
+
|
|
144
|
+
if (recent.length === 0) {
|
|
145
|
+
return { totalCalls: 0, successRate: 1, avgConfidence: 0, avgLatencyMs: 0, humanReviewRate: 0, errorRate: 0 };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const successes = recent.filter(e => e.success).length;
|
|
149
|
+
const reviews = recent.filter(e => e.needsHumanReview).length;
|
|
150
|
+
|
|
151
|
+
return {
|
|
152
|
+
totalCalls: recent.length,
|
|
153
|
+
successRate: successes / recent.length,
|
|
154
|
+
avgConfidence: recent.reduce((sum, e) => sum + e.confidence, 0) / recent.length,
|
|
155
|
+
avgLatencyMs: recent.reduce((sum, e) => sum + e.latencyMs, 0) / recent.length,
|
|
156
|
+
humanReviewRate: reviews / recent.length,
|
|
157
|
+
errorRate: 1 - (successes / recent.length),
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// --- Singleton ---
|
|
163
|
+
|
|
164
|
+
export const aiAuditLog = new AIAuditLog();
|
|
@@ -0,0 +1,403 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AI Client — Central wrapper for all LLM interactions.
|
|
3
|
+
*
|
|
4
|
+
* Every AI call goes through this client, which provides:
|
|
5
|
+
* - Input validation and prompt injection detection
|
|
6
|
+
* - Output validation against Zod schemas
|
|
7
|
+
* - Confidence scoring with human review routing
|
|
8
|
+
* - Structured audit logging (EU AI Act Art. 12)
|
|
9
|
+
* - AI disclosure headers (EU AI Act Art. 50)
|
|
10
|
+
* - Health metrics collection (NIST AI RMF Manage 3.2)
|
|
11
|
+
*
|
|
12
|
+
* Compliance: EU AI Act (2024/1689), NIST AI RMF 1.0
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import Anthropic from '@anthropic-ai/sdk';
|
|
16
|
+
import { z, type ZodSchema } from 'zod';
|
|
17
|
+
import { aiAuditLog, type AuditEntry } from './audit-log.js';
|
|
18
|
+
import { validateInput, type InputValidationResult } from './input-guard.js';
|
|
19
|
+
import { aiHealthMetrics } from './health.js';
|
|
20
|
+
|
|
21
|
+
// --- Configuration ---
|
|
22
|
+
|
|
23
|
+
export interface AIClientConfig {
|
|
24
|
+
/** Anthropic API key (defaults to ANTHROPIC_API_KEY env var) */
|
|
25
|
+
apiKey?: string;
|
|
26
|
+
/** Default model to use */
|
|
27
|
+
model?: string;
|
|
28
|
+
/** Confidence threshold below which human review is required (0-1) */
|
|
29
|
+
confidenceThreshold?: number;
|
|
30
|
+
/** Maximum input length in characters */
|
|
31
|
+
maxInputLength?: number;
|
|
32
|
+
/** Enable prompt injection detection */
|
|
33
|
+
detectInjection?: boolean;
|
|
34
|
+
/** Enable structured audit logging */
|
|
35
|
+
auditLog?: boolean;
|
|
36
|
+
/** Custom moderation function (return true to block) */
|
|
37
|
+
moderator?: (input: string) => Promise<boolean>;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const DEFAULT_CONFIG: Required<AIClientConfig> = {
|
|
41
|
+
apiKey: process.env.ANTHROPIC_API_KEY || '',
|
|
42
|
+
model: 'claude-sonnet-4-20250514',
|
|
43
|
+
confidenceThreshold: 0.7,
|
|
44
|
+
maxInputLength: 100_000,
|
|
45
|
+
detectInjection: true,
|
|
46
|
+
auditLog: true,
|
|
47
|
+
moderator: async () => false,
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
// --- Core Client ---
|
|
51
|
+
|
|
52
|
+
export class AIClient {
|
|
53
|
+
private client: Anthropic;
|
|
54
|
+
private config: Required<AIClientConfig>;
|
|
55
|
+
|
|
56
|
+
constructor(config: AIClientConfig = {}) {
|
|
57
|
+
this.config = { ...DEFAULT_CONFIG, ...config };
|
|
58
|
+
if (!this.config.apiKey) {
|
|
59
|
+
throw new Error('ANTHROPIC_API_KEY environment variable is required');
|
|
60
|
+
}
|
|
61
|
+
this.client = new Anthropic({ apiKey: this.config.apiKey });
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Generate a structured response validated against a Zod schema.
|
|
66
|
+
*
|
|
67
|
+
* This is the primary method for AI interactions. It:
|
|
68
|
+
* 1. Validates and sanitizes input
|
|
69
|
+
* 2. Calls the model
|
|
70
|
+
* 3. Parses and validates output against the schema
|
|
71
|
+
* 4. Scores confidence
|
|
72
|
+
* 5. Logs the interaction for audit
|
|
73
|
+
* 6. Routes to human review if confidence is low
|
|
74
|
+
*/
|
|
75
|
+
async generate<T>(options: {
|
|
76
|
+
prompt: string;
|
|
77
|
+
schema: ZodSchema<T>;
|
|
78
|
+
systemPrompt?: string;
|
|
79
|
+
context?: string;
|
|
80
|
+
/** Override model for this call */
|
|
81
|
+
model?: string;
|
|
82
|
+
/** Override confidence threshold for this call */
|
|
83
|
+
confidenceThreshold?: number;
|
|
84
|
+
/** Max retries on validation failure */
|
|
85
|
+
maxRetries?: number;
|
|
86
|
+
/** Purpose tag for audit log */
|
|
87
|
+
purpose?: string;
|
|
88
|
+
}): Promise<AIResponse<T>> {
|
|
89
|
+
const startTime = Date.now();
|
|
90
|
+
const model = options.model || this.config.model;
|
|
91
|
+
const threshold = options.confidenceThreshold ?? this.config.confidenceThreshold;
|
|
92
|
+
const maxRetries = options.maxRetries ?? 2;
|
|
93
|
+
|
|
94
|
+
// Step 1: Input validation
|
|
95
|
+
const inputValidation = await this.validateInputs(options.prompt, options.context);
|
|
96
|
+
if (inputValidation.blocked) {
|
|
97
|
+
const result = this.buildBlockedResponse<T>(inputValidation, startTime, model, options.purpose);
|
|
98
|
+
return result;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Step 2: Call model with retries on parse failure
|
|
102
|
+
let lastError: Error | null = null;
|
|
103
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
104
|
+
try {
|
|
105
|
+
const response = await this.callModel(
|
|
106
|
+
options.prompt,
|
|
107
|
+
options.systemPrompt,
|
|
108
|
+
options.context,
|
|
109
|
+
model,
|
|
110
|
+
options.schema,
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
// Step 3: Parse and validate output
|
|
114
|
+
const parsed = options.schema.safeParse(response.content);
|
|
115
|
+
if (!parsed.success) {
|
|
116
|
+
lastError = new Error(`Output validation failed: ${parsed.error.message}`);
|
|
117
|
+
if (attempt < maxRetries) continue;
|
|
118
|
+
break;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Step 4: Score confidence
|
|
122
|
+
const confidence = this.scoreConfidence(response);
|
|
123
|
+
const needsReview = confidence < threshold;
|
|
124
|
+
|
|
125
|
+
// Step 5: Build response
|
|
126
|
+
const result: AISuccessResponse<T> = {
|
|
127
|
+
success: true,
|
|
128
|
+
data: parsed.data,
|
|
129
|
+
confidence,
|
|
130
|
+
needsHumanReview: needsReview,
|
|
131
|
+
model,
|
|
132
|
+
latencyMs: Date.now() - startTime,
|
|
133
|
+
tokenUsage: response.usage,
|
|
134
|
+
aiGenerated: true,
|
|
135
|
+
auditId: crypto.randomUUID(),
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
// Step 6: Audit log
|
|
139
|
+
if (this.config.auditLog) {
|
|
140
|
+
this.logInteraction(result, options.prompt, options.purpose);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Step 7: Health metrics
|
|
144
|
+
aiHealthMetrics.recordCall({
|
|
145
|
+
model,
|
|
146
|
+
latencyMs: result.latencyMs,
|
|
147
|
+
confidence,
|
|
148
|
+
success: true,
|
|
149
|
+
tokenUsage: response.usage,
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
return result;
|
|
153
|
+
|
|
154
|
+
} catch (err) {
|
|
155
|
+
lastError = err instanceof Error ? err : new Error(String(err));
|
|
156
|
+
if (attempt < maxRetries) continue;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// All retries exhausted
|
|
161
|
+
aiHealthMetrics.recordCall({
|
|
162
|
+
model,
|
|
163
|
+
latencyMs: Date.now() - startTime,
|
|
164
|
+
confidence: 0,
|
|
165
|
+
success: false,
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
return {
|
|
169
|
+
success: false,
|
|
170
|
+
model,
|
|
171
|
+
latencyMs: Date.now() - startTime,
|
|
172
|
+
aiGenerated: true,
|
|
173
|
+
auditId: crypto.randomUUID(),
|
|
174
|
+
error: lastError?.message || 'AI call failed after retries',
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Simple text generation without schema validation.
|
|
180
|
+
* Still applies input guards, audit logging, and confidence scoring.
|
|
181
|
+
*/
|
|
182
|
+
async generateText(options: {
|
|
183
|
+
prompt: string;
|
|
184
|
+
systemPrompt?: string;
|
|
185
|
+
context?: string;
|
|
186
|
+
model?: string;
|
|
187
|
+
purpose?: string;
|
|
188
|
+
}): Promise<AIResponse<string>> {
|
|
189
|
+
return this.generate({
|
|
190
|
+
...options,
|
|
191
|
+
schema: z.any().transform(String),
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// --- Private Methods ---
|
|
196
|
+
|
|
197
|
+
private async validateInputs(
|
|
198
|
+
prompt: string,
|
|
199
|
+
context?: string,
|
|
200
|
+
): Promise<InputValidationResult> {
|
|
201
|
+
const fullInput = context ? `${prompt}\n${context}` : prompt;
|
|
202
|
+
|
|
203
|
+
if (fullInput.length > this.config.maxInputLength) {
|
|
204
|
+
return { blocked: true, reason: `Input exceeds maximum length (${this.config.maxInputLength} chars)` };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (this.config.detectInjection) {
|
|
208
|
+
const result = validateInput(fullInput);
|
|
209
|
+
if (result.blocked) return result;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (this.config.moderator) {
|
|
213
|
+
const blocked = await this.config.moderator(fullInput);
|
|
214
|
+
if (blocked) return { blocked: true, reason: 'Content blocked by moderation policy' };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
return { blocked: false };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
private async callModel(
|
|
221
|
+
prompt: string,
|
|
222
|
+
systemPrompt: string | undefined,
|
|
223
|
+
context: string | undefined,
|
|
224
|
+
model: string,
|
|
225
|
+
schema: ZodSchema,
|
|
226
|
+
) {
|
|
227
|
+
const userContent = context
|
|
228
|
+
? `${prompt}\n\nContext:\n${context}`
|
|
229
|
+
: prompt;
|
|
230
|
+
|
|
231
|
+
const messages: Anthropic.MessageParam[] = [
|
|
232
|
+
{ role: 'user', content: userContent },
|
|
233
|
+
];
|
|
234
|
+
|
|
235
|
+
const response = await this.client.messages.create({
|
|
236
|
+
model,
|
|
237
|
+
max_tokens: 4096,
|
|
238
|
+
system: systemPrompt || `You are an AI assistant for {{PROJECT_NAME}}. Respond with valid JSON matching the requested schema. Be precise and factual.`,
|
|
239
|
+
messages,
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
const textBlock = response.content.find(
|
|
243
|
+
(block): block is Anthropic.TextBlock => block.type === 'text'
|
|
244
|
+
);
|
|
245
|
+
|
|
246
|
+
return {
|
|
247
|
+
content: this.extractJSON(textBlock?.text || ''),
|
|
248
|
+
usage: {
|
|
249
|
+
inputTokens: response.usage.input_tokens,
|
|
250
|
+
outputTokens: response.usage.output_tokens,
|
|
251
|
+
},
|
|
252
|
+
stopReason: response.stop_reason,
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
private extractJSON(text: string): unknown {
|
|
257
|
+
// Try direct parse first
|
|
258
|
+
try {
|
|
259
|
+
return JSON.parse(text);
|
|
260
|
+
} catch {
|
|
261
|
+
// Extract JSON from markdown code blocks
|
|
262
|
+
const jsonMatch = text.match(/```(?:json)?\s*\n?([\s\S]*?)\n?```/);
|
|
263
|
+
if (jsonMatch) {
|
|
264
|
+
try {
|
|
265
|
+
return JSON.parse(jsonMatch[1].trim());
|
|
266
|
+
} catch {
|
|
267
|
+
// Fall through to return raw text
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
// Return raw text for string schema
|
|
271
|
+
return text;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
private scoreConfidence(response: {
|
|
276
|
+
stopReason: string | null;
|
|
277
|
+
usage: { inputTokens: number; outputTokens: number };
|
|
278
|
+
}): number {
|
|
279
|
+
let score = 0.85; // Base confidence
|
|
280
|
+
|
|
281
|
+
// Penalize if model was cut off
|
|
282
|
+
if (response.stopReason === 'max_tokens') {
|
|
283
|
+
score -= 0.3;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// Penalize very short responses (likely incomplete)
|
|
287
|
+
if (response.usage.outputTokens < 10) {
|
|
288
|
+
score -= 0.2;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// Penalize very long responses (may indicate hallucination loops)
|
|
292
|
+
if (response.usage.outputTokens > 3000) {
|
|
293
|
+
score -= 0.1;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
return Math.max(0, Math.min(1, score));
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
private logInteraction<T>(
|
|
300
|
+
result: AISuccessResponse<T>,
|
|
301
|
+
prompt: string,
|
|
302
|
+
purpose?: string,
|
|
303
|
+
): void {
|
|
304
|
+
const entry: AuditEntry = {
|
|
305
|
+
id: result.auditId,
|
|
306
|
+
timestamp: new Date().toISOString(),
|
|
307
|
+
model: result.model,
|
|
308
|
+
purpose: purpose || 'unspecified',
|
|
309
|
+
inputPreview: prompt.slice(0, 100) + (prompt.length > 100 ? '...' : ''),
|
|
310
|
+
confidence: result.confidence,
|
|
311
|
+
needsHumanReview: result.needsHumanReview,
|
|
312
|
+
latencyMs: result.latencyMs,
|
|
313
|
+
tokenUsage: result.tokenUsage,
|
|
314
|
+
success: true,
|
|
315
|
+
};
|
|
316
|
+
aiAuditLog.log(entry);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
private buildBlockedResponse<T>(
|
|
320
|
+
validation: InputValidationResult,
|
|
321
|
+
startTime: number,
|
|
322
|
+
model: string,
|
|
323
|
+
purpose?: string,
|
|
324
|
+
): AIErrorResponse {
|
|
325
|
+
const result: AIErrorResponse = {
|
|
326
|
+
success: false,
|
|
327
|
+
model,
|
|
328
|
+
latencyMs: Date.now() - startTime,
|
|
329
|
+
aiGenerated: false,
|
|
330
|
+
auditId: crypto.randomUUID(),
|
|
331
|
+
error: `Input blocked: ${validation.reason}`,
|
|
332
|
+
blocked: true,
|
|
333
|
+
};
|
|
334
|
+
|
|
335
|
+
if (this.config.auditLog) {
|
|
336
|
+
aiAuditLog.log({
|
|
337
|
+
id: result.auditId,
|
|
338
|
+
timestamp: new Date().toISOString(),
|
|
339
|
+
model,
|
|
340
|
+
purpose: purpose || 'unspecified',
|
|
341
|
+
inputPreview: '[BLOCKED]',
|
|
342
|
+
confidence: 0,
|
|
343
|
+
needsHumanReview: false,
|
|
344
|
+
latencyMs: result.latencyMs,
|
|
345
|
+
success: false,
|
|
346
|
+
error: validation.reason,
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
return result;
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// --- Types ---
|
|
355
|
+
|
|
356
|
+
interface AIResponseBase {
|
|
357
|
+
/** Model used for this call */
|
|
358
|
+
model: string;
|
|
359
|
+
/** Total latency in milliseconds */
|
|
360
|
+
latencyMs: number;
|
|
361
|
+
/** EU AI Act Art. 50: flag indicating AI-generated content */
|
|
362
|
+
aiGenerated: boolean;
|
|
363
|
+
/** Unique ID for audit trail */
|
|
364
|
+
auditId: string;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
export interface AISuccessResponse<T> extends AIResponseBase {
|
|
368
|
+
success: true;
|
|
369
|
+
/** The validated, typed response data */
|
|
370
|
+
data: T;
|
|
371
|
+
/** Confidence score (0-1). Below threshold triggers human review */
|
|
372
|
+
confidence: number;
|
|
373
|
+
/** Whether this response needs human review before acting on it */
|
|
374
|
+
needsHumanReview: boolean;
|
|
375
|
+
/** Token usage for cost tracking */
|
|
376
|
+
tokenUsage?: { inputTokens: number; outputTokens: number };
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
export interface AIErrorResponse extends AIResponseBase {
|
|
380
|
+
success: false;
|
|
381
|
+
/** Error message describing the failure */
|
|
382
|
+
error: string;
|
|
383
|
+
/** Whether input was blocked by guards */
|
|
384
|
+
blocked?: boolean;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
export type AIResponse<T> = AISuccessResponse<T> | AIErrorResponse;
|
|
388
|
+
|
|
389
|
+
// --- Singleton ---
|
|
390
|
+
|
|
391
|
+
let _defaultClient: AIClient | null = null;
|
|
392
|
+
|
|
393
|
+
export function getAIClient(config?: AIClientConfig): AIClient {
|
|
394
|
+
if (!_defaultClient || config) {
|
|
395
|
+
if (_defaultClient && config) {
|
|
396
|
+
console.warn('AIClient already initialized. Ignoring new config. Use new AIClient(config) for custom instances.');
|
|
397
|
+
}
|
|
398
|
+
if (!_defaultClient) {
|
|
399
|
+
_defaultClient = new AIClient(config);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
return _defaultClient;
|
|
403
|
+
}
|