Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 24 additions & 8 deletions docs/ai-runtime-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ AI 서버에 무엇을 보낼 수 있는지 먼저 좁혀 놓고, AI가 돌려
```text
AiRunWorker (#24, 후속)
→ ValidatingAiRuntimeClient
1. 요청 개인정보·허용 범위 검사
1. 요청 크기·Service credential 검사
2. AiRuntimeClient transport를 정확히 한 번 호출
3. 응답 ID·version·worker·workflow·slot 재검사
→ FakeAiRuntimeClient (test)
Expand All @@ -35,14 +35,24 @@ Prompt, Agent Pipeline, Provider retry와 모델 선택은 `fowoco/ai` 책임입
"contractVersion": "1.0.0",
"requiredKnowledgeVersion": "0.2.0",
"deadlineMs": 10000,
"maskedInput": {
"maskedInstruction": "workerRef 30000000-0000-0000-0000-000000000001의 체류연장 준비",
"analysisInput": {
"instruction": "가상 근로자 응웬반안(010-1234-5678)의 체류연장 준비",
"workers": [
{
"workerRef": "30000000-0000-0000-0000-000000000001",
"displayName": "응웬반안",
"nationalityCode": "VN",
"preferredLanguage": "vi",
"workStatus": "ACTIVE",
"stayExpiryDate": "2026-12-31"
"stayExpiryDate": "2026-12-31",
"contractStartDate": "2026-01-01",
"contractEndDate": "2026-12-31",
"requestedFields": {
"legal_name": "NGUYEN VAN AN",
"passport_number": "M12345678",
"phone": "010-1234-5678",
"email": "worker@example.com"
}
}
],
"workflowConstraints": [
Expand All @@ -64,11 +74,17 @@ Prompt, Agent Pipeline, Provider retry와 모델 선택은 `fowoco/ai` 책임입
- `contractVersion`: 양쪽이 같은 JSON 계약을 사용하는지 확인합니다.
- `requiredKnowledgeVersion`: Server와 Runtime이 같은 Workflow release를 사용하게 합니다.
- `deadlineMs`: 이번 시도 전체에서 남은 실행 시간입니다.
- `maskedInstruction`: 이름과 식별번호를 `workerRef`로 바꾼 자연어입니다.
- `instruction`: HR이 입력한 원문입니다. 현재 데모에서는 가상 근로자 데이터만 사용합니다.
- `requestedFields`: Agent가 요구한 field의 원본값입니다. Server가 가진 값만 넣습니다.
- `workflowConstraints`: Knowledge projection에서 가져온 Workflow와 slot allow-list입니다.

근로자 Context에는 여권번호, 외국인등록번호, 전화번호, 계좌번호, 법적 실명, 원본 문서와
Worker Link token을 추가하지 않습니다.
현재 데모에서는 PII 마스킹과 차단을 적용하지 않습니다. 실명·여권번호·전화번호 등
Agent가 문서 작성에 요구한 값은 `***`, `OOO`로 바꾸지 않고 원본으로 전달합니다.

단, API Key·JWT·Bearer Token·비밀번호·Worker Link token 같은 **서비스 인증정보는
업무 데이터가 아니므로 계속 차단**합니다. 이 계약으로 실제 근로자 데이터를 외부 LLM에
전송해서는 안 되며, 데모가 아닌 실제 개인정보를 사용하기 전에는 개인정보 처리 기준을
다시 확정해야 합니다.

## 응답 계약

Expand Down Expand Up @@ -119,7 +135,7 @@ Worker Link token을 추가하지 않습니다.
- Workflow가 허용하지 않은 slot
- 0 미만 또는 1 초과 confidence
- 중복 candidate reference와 잘못된 outcome 구조
- 여권번호·외국인등록번호·전화번호·계좌번호·Bearer Token·Secret이 섞인 값
- API Key·JWT·Bearer Token·비밀번호·Worker Link token 같은 서비스 인증정보

거부 예외에는 발견한 원문을 넣지 않습니다. 앞으로 #24 AiAttempt에는
`AiRuntimeFailureCode`와 `requestId` 같은 안전한 진단값만 저장합니다.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,14 @@ public record AiAnalysisRequest(
String contractVersion,
String requiredKnowledgeVersion,
long deadlineMs,
MaskedAnalysisInput maskedInput
AnalysisInput analysisInput
) {

public AiAnalysisRequest {
Objects.requireNonNull(requestId, "requestId must not be null");
Objects.requireNonNull(attemptId, "attemptId must not be null");
Objects.requireNonNull(contractVersion, "contractVersion must not be null");
Objects.requireNonNull(requiredKnowledgeVersion, "requiredKnowledgeVersion must not be null");
Objects.requireNonNull(maskedInput, "maskedInput must not be null");
Objects.requireNonNull(analysisInput, "analysisInput must not be null");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,16 @@
import java.util.Objects;

/**
* Pseudonymized instruction and allow-listed context sent to the AI Runtime.
* Original HR instruction and Worker context sent to the AI Runtime for the current demo.
*/
public record MaskedAnalysisInput(
String maskedInstruction,
List<MaskedWorkerContext> workers,
public record AnalysisInput(
String instruction,
List<WorkerContext> workers,
List<WorkflowConstraint> workflowConstraints
) {

public MaskedAnalysisInput {
Objects.requireNonNull(maskedInstruction, "maskedInstruction must not be null");
public AnalysisInput {
Objects.requireNonNull(instruction, "instruction must not be null");
Objects.requireNonNull(workers, "workers must not be null");
Objects.requireNonNull(workflowConstraints, "workflowConstraints must not be null");
workers = List.copyOf(workers);
Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package com.fowoco.server.aiintegration.application.model;

import java.time.LocalDate;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;

/**
* Worker data sent to the AI Runtime for the current fake-data demo.
*
* <p>{@code requestedFields} carries the original values requested by the Agent. Service
* credentials, JWTs, passwords, and Worker Link tokens must never be placed in this map.</p>
*/
public record WorkerContext(
UUID workerRef,
String displayName,
String nationalityCode,
String preferredLanguage,
String workStatus,
LocalDate stayExpiryDate,
LocalDate contractStartDate,
LocalDate contractEndDate,
Map<String, String> requestedFields
) {

public WorkerContext {
Objects.requireNonNull(workerRef, "workerRef must not be null");
Objects.requireNonNull(displayName, "displayName must not be null");
Objects.requireNonNull(workStatus, "workStatus must not be null");
Objects.requireNonNull(requestedFields, "requestedFields must not be null");
requestedFields = Map.copyOf(requestedFields);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,47 +9,31 @@
import org.springframework.stereotype.Component;

/**
* Rejects sensitive values before they cross the AI boundary or enter an AiRun candidate.
* Allows original demo data while preventing service credentials from crossing the AI boundary.
*/
@Component
public class AiRuntimePrivacyPolicy {
public class AiRuntimeBoundaryPolicy {

private static final Set<String> FORBIDDEN_KEY_PARTS = Set.of(
"passportnumber",
"passportno",
"alienregistrationnumber",
"registrationnumber",
"residentnumber",
"rrn",
"phone",
"accountnumber",
"bankaccount",
"token",
"password",
"secret",
"authorization",
"prompt",
"여권번호",
"외국인등록번호",
"주민등록번호",
"전화",
"계좌",
"apikey",
"토큰",
"인증",
"비밀",
"api키",
"비밀번호"
);
private static final Pattern REGISTRATION_NUMBER =
Pattern.compile("(?<!\\d)\\d{6}-?[1-8]\\d{6}(?!\\d)");
private static final Pattern PHONE_NUMBER =
Pattern.compile("(?<!\\d)01\\d[- .]?\\d{3,4}[- .]?\\d{4}(?!\\d)");
private static final Pattern BEARER_TOKEN =
Pattern.compile("(?i)\\bbearer\\s+[A-Za-z0-9._~+/=-]{8,}");
private static final Pattern JWT =
Pattern.compile("\\beyJ[A-Za-z0-9_-]{8,}\\.[A-Za-z0-9_-]{8,}\\.[A-Za-z0-9_-]{8,}\\b");
private static final Pattern SECRET_ASSIGNMENT = Pattern.compile(
"(?i)\\b(api[_-]?key|password|secret|token)\\s*[:=]\\s*\\S+"
);
private static final Pattern LABELED_SENSITIVE_VALUE = Pattern.compile(
"(?i)(passport[_ -]?(number|no)|alien[_ -]?registration[_ -]?(number|no)"
+ "|bank[_ -]?account|여권번호|외국인등록번호|계좌번호)"
+ "(?:은|는)?\\s*[:=]?\\s*[A-Za-z0-9-]{6,}"
);

public void validateText(String value, int maxLength, boolean required) {
if (value == null || value.isBlank()) {
Expand All @@ -62,8 +46,11 @@ public void validateText(String value, int maxLength, boolean required) {
if (normalized.length() > maxLength) {
reject(AiRuntimeFailureCode.INVALID_REQUEST_CONTRACT, "AI contract text exceeds its size limit.");
}
if (containsSensitiveValue(normalized)) {
reject(AiRuntimeFailureCode.SENSITIVE_DATA_REJECTED, "Sensitive data was rejected at the AI boundary.");
if (containsCredential(normalized)) {
reject(
AiRuntimeFailureCode.SENSITIVE_DATA_REJECTED,
"Service credential was rejected at the AI boundary."
);
}
}

Expand All @@ -79,16 +66,17 @@ public void validateKey(String key) {
.map(part -> part.replace("_", "").replace("-", ""))
.anyMatch(normalizedKey::contains);
if (forbidden) {
reject(AiRuntimeFailureCode.SENSITIVE_DATA_REJECTED, "Sensitive key was rejected at the AI boundary.");
reject(
AiRuntimeFailureCode.SENSITIVE_DATA_REJECTED,
"Service credential key was rejected at the AI boundary."
);
}
}

private boolean containsSensitiveValue(String value) {
return REGISTRATION_NUMBER.matcher(value).find()
|| PHONE_NUMBER.matcher(value).find()
|| BEARER_TOKEN.matcher(value).find()
|| SECRET_ASSIGNMENT.matcher(value).find()
|| LABELED_SENSITIVE_VALUE.matcher(value).find();
private boolean containsCredential(String value) {
return BEARER_TOKEN.matcher(value).find()
|| JWT.matcher(value).find()
|| SECRET_ASSIGNMENT.matcher(value).find();
}

private void reject(AiRuntimeFailureCode failureCode, String safeMessage) {
Expand Down
Loading
Loading