From fa32b02c5f83f0b5c46a7173e46c8508a4ccda25 Mon Sep 17 00:00:00 2001 From: hywznn Date: Tue, 28 Jul 2026 08:37:14 +0900 Subject: [PATCH 1/2] =?UTF-8?q?fix(ai):=20Agent=20=EC=9A=94=EC=B2=AD?= =?UTF-8?q?=EC=97=90=EC=84=9C=20PII=20=ED=95=84=EB=93=9C=20=EC=A0=9C?= =?UTF-8?q?=EC=99=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 실명·생년월일·이메일·주소·서명 등 구조화된 PII key와 이메일 값을 AI 경계에서 거부합니다. 마스킹 문자열을 전송하지 않고 Agent는 누락 field key만 반환하도록 계약 문서를 명확히 합니다. --- docs/ai-runtime-contract.md | 9 ++++++- .../validation/AiRuntimePrivacyPolicy.java | 20 +++++++++++++++ .../model/AiRuntimeOutboundContractTest.java | 5 ++++ .../AiRuntimeContractValidatorTest.java | 25 +++++++++++++++++++ 4 files changed, 58 insertions(+), 1 deletion(-) diff --git a/docs/ai-runtime-contract.md b/docs/ai-runtime-contract.md index 6d89c86..c0ea4fb 100644 --- a/docs/ai-runtime-contract.md +++ b/docs/ai-runtime-contract.md @@ -70,6 +70,12 @@ Prompt, Agent Pipeline, Provider retry와 모델 선택은 `fowoco/ai` 책임입 근로자 Context에는 여권번호, 외국인등록번호, 전화번호, 계좌번호, 법적 실명, 원본 문서와 Worker Link token을 추가하지 않습니다. +`***`, `OOO` 같은 마스킹 문자열도 보내지 않습니다. 실제 값을 복원할 방법이 없고 +문서 결과에 그대로 들어갈 수 있기 때문입니다. Agent는 누락된 정보가 있으면 +`missingSlots`로 **field key만** 요청합니다. Server는 현재 계약에 이미 포함된 비식별 +업무정보만 사용하고, 실명·생년월일·이메일·주소·서명 같은 PII key는 요청과 응답 모두에서 +거부합니다. 실제 PII를 최종 HWPX에 넣는 Late Binding은 MVP 범위 밖입니다. + ## 응답 계약 ```json @@ -119,7 +125,8 @@ Worker Link token을 추가하지 않습니다. - Workflow가 허용하지 않은 slot - 0 미만 또는 1 초과 confidence - 중복 candidate reference와 잘못된 outcome 구조 -- 여권번호·외국인등록번호·전화번호·계좌번호·Bearer Token·Secret이 섞인 값 +- 실명·생년월일·이메일·주소·서명·사진·여권번호·외국인등록번호·전화번호·계좌번호 + 또는 Bearer Token·Secret이 섞인 key와 값 거부 예외에는 발견한 원문을 넣지 않습니다. 앞으로 #24 AiAttempt에는 `AiRuntimeFailureCode`와 `requestId` 같은 안전한 진단값만 저장합니다. diff --git a/src/main/java/com/fowoco/server/aiintegration/application/validation/AiRuntimePrivacyPolicy.java b/src/main/java/com/fowoco/server/aiintegration/application/validation/AiRuntimePrivacyPolicy.java index e851c38..be1b896 100644 --- a/src/main/java/com/fowoco/server/aiintegration/application/validation/AiRuntimePrivacyPolicy.java +++ b/src/main/java/com/fowoco/server/aiintegration/application/validation/AiRuntimePrivacyPolicy.java @@ -21,9 +21,21 @@ public class AiRuntimePrivacyPolicy { "registrationnumber", "residentnumber", "rrn", + "legalname", + "displayname", + "birthdate", + "dateofbirth", + "workeremail", + "personalemail", + "workeraddress", + "homeaddress", + "addressinkorea", + "homecountryaddress", "phone", "accountnumber", "bankaccount", + "signature", + "photo", "token", "password", "secret", @@ -32,14 +44,21 @@ public class AiRuntimePrivacyPolicy { "여권번호", "외국인등록번호", "주민등록번호", + "법적성명", + "생년월일", + "이메일", + "상세주소", "전화", "계좌", + "서명", "비밀번호" ); private static final Pattern REGISTRATION_NUMBER = Pattern.compile("(? sensitiveInstructions() { return Stream.of( "연락처는 010-1234-5678입니다", + "근로자 이메일은 worker@example.com입니다", "외국인등록번호 990101-5123456", "passport_number: M12345678", "Authorization: Bearer secret-token-value", @@ -60,6 +61,30 @@ static Stream sensitiveInstructions() { ); } + @ParameterizedTest + @MethodSource("forbiddenPiiKeys") + void rejectsPiiFieldKeysInsteadOfReturningMaskedPlaceholders(String fieldKey) { + assertFailure( + () -> new AiRuntimePrivacyPolicy().validateKey(fieldKey), + AiRuntimeFailureCode.SENSITIVE_DATA_REJECTED + ); + } + + static Stream forbiddenPiiKeys() { + return Stream.of( + "worker_legal_name", + "worker_display_name", + "worker_birthdate", + "worker_email", + "worker_home_address", + "employee_signature", + "passport_number", + "alien_registration_number", + "phone", + "bank_account" + ); + } + @Test void rejectsMismatchedRequestIdWithoutLeakingRawResponse() { AiAnalysisResponse response = new AiAnalysisResponse( From 949305e43c99a715500ebe86ded2da65711b0cd5 Mon Sep 17 00:00:00 2001 From: hywznn Date: Tue, 28 Jul 2026 08:47:57 +0900 Subject: [PATCH 2/2] =?UTF-8?q?fix(ai):=20=EB=8D=B0=EB=AA=A8=20=EC=9B=90?= =?UTF-8?q?=EB=B3=B8=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EC=A0=84=EB=8B=AC=20?= =?UTF-8?q?=ED=97=88=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 마스킹 입력 계약을 원문 입력 계약으로 변경하고 Agent가 요청한 실제 field 값을 Worker context에 포함할 수 있게 합니다. 가상 데이터 데모에서는 PII 차단을 제거하되 API Key, JWT, Bearer Token, 비밀번호 같은 서비스 인증정보 차단은 유지합니다. --- docs/ai-runtime-contract.md | 37 +++--- .../application/model/AiAnalysisRequest.java | 4 +- ...dAnalysisInput.java => AnalysisInput.java} | 12 +- .../model/MaskedWorkerContext.java | 25 ---- .../application/model/WorkerContext.java | 33 +++++ .../validation/AiRuntimeBoundaryPolicy.java | 85 +++++++++++++ .../AiRuntimeContractValidator.java | 53 +++++--- .../validation/AiRuntimePrivacyPolicy.java | 117 ------------------ .../model/AiRuntimeOutboundContractTest.java | 36 +++--- .../AiRuntimeContractValidatorTest.java | 55 ++++---- .../ValidatingAiRuntimeClientTest.java | 6 +- .../support/AiRuntimeContractFixture.java | 22 +++- 12 files changed, 246 insertions(+), 239 deletions(-) rename src/main/java/com/fowoco/server/aiintegration/application/model/{MaskedAnalysisInput.java => AnalysisInput.java} (59%) delete mode 100644 src/main/java/com/fowoco/server/aiintegration/application/model/MaskedWorkerContext.java create mode 100644 src/main/java/com/fowoco/server/aiintegration/application/model/WorkerContext.java create mode 100644 src/main/java/com/fowoco/server/aiintegration/application/validation/AiRuntimeBoundaryPolicy.java delete mode 100644 src/main/java/com/fowoco/server/aiintegration/application/validation/AiRuntimePrivacyPolicy.java diff --git a/docs/ai-runtime-contract.md b/docs/ai-runtime-contract.md index c0ea4fb..c4b72b8 100644 --- a/docs/ai-runtime-contract.md +++ b/docs/ai-runtime-contract.md @@ -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) @@ -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": [ @@ -64,17 +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`로 바꾸지 않고 원본으로 전달합니다. -`***`, `OOO` 같은 마스킹 문자열도 보내지 않습니다. 실제 값을 복원할 방법이 없고 -문서 결과에 그대로 들어갈 수 있기 때문입니다. Agent는 누락된 정보가 있으면 -`missingSlots`로 **field key만** 요청합니다. Server는 현재 계약에 이미 포함된 비식별 -업무정보만 사용하고, 실명·생년월일·이메일·주소·서명 같은 PII key는 요청과 응답 모두에서 -거부합니다. 실제 PII를 최종 HWPX에 넣는 Late Binding은 MVP 범위 밖입니다. +단, API Key·JWT·Bearer Token·비밀번호·Worker Link token 같은 **서비스 인증정보는 +업무 데이터가 아니므로 계속 차단**합니다. 이 계약으로 실제 근로자 데이터를 외부 LLM에 +전송해서는 안 되며, 데모가 아닌 실제 개인정보를 사용하기 전에는 개인정보 처리 기준을 +다시 확정해야 합니다. ## 응답 계약 @@ -125,8 +135,7 @@ Worker Link token을 추가하지 않습니다. - Workflow가 허용하지 않은 slot - 0 미만 또는 1 초과 confidence - 중복 candidate reference와 잘못된 outcome 구조 -- 실명·생년월일·이메일·주소·서명·사진·여권번호·외국인등록번호·전화번호·계좌번호 - 또는 Bearer Token·Secret이 섞인 key와 값 +- API Key·JWT·Bearer Token·비밀번호·Worker Link token 같은 서비스 인증정보 거부 예외에는 발견한 원문을 넣지 않습니다. 앞으로 #24 AiAttempt에는 `AiRuntimeFailureCode`와 `requestId` 같은 안전한 진단값만 저장합니다. diff --git a/src/main/java/com/fowoco/server/aiintegration/application/model/AiAnalysisRequest.java b/src/main/java/com/fowoco/server/aiintegration/application/model/AiAnalysisRequest.java index 6dc7a99..37c1b2c 100644 --- a/src/main/java/com/fowoco/server/aiintegration/application/model/AiAnalysisRequest.java +++ b/src/main/java/com/fowoco/server/aiintegration/application/model/AiAnalysisRequest.java @@ -12,7 +12,7 @@ public record AiAnalysisRequest( String contractVersion, String requiredKnowledgeVersion, long deadlineMs, - MaskedAnalysisInput maskedInput + AnalysisInput analysisInput ) { public AiAnalysisRequest { @@ -20,6 +20,6 @@ public record AiAnalysisRequest( 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"); } } diff --git a/src/main/java/com/fowoco/server/aiintegration/application/model/MaskedAnalysisInput.java b/src/main/java/com/fowoco/server/aiintegration/application/model/AnalysisInput.java similarity index 59% rename from src/main/java/com/fowoco/server/aiintegration/application/model/MaskedAnalysisInput.java rename to src/main/java/com/fowoco/server/aiintegration/application/model/AnalysisInput.java index 3dfea28..f1847c2 100644 --- a/src/main/java/com/fowoco/server/aiintegration/application/model/MaskedAnalysisInput.java +++ b/src/main/java/com/fowoco/server/aiintegration/application/model/AnalysisInput.java @@ -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 workers, +public record AnalysisInput( + String instruction, + List workers, List 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); diff --git a/src/main/java/com/fowoco/server/aiintegration/application/model/MaskedWorkerContext.java b/src/main/java/com/fowoco/server/aiintegration/application/model/MaskedWorkerContext.java deleted file mode 100644 index 191cb8f..0000000 --- a/src/main/java/com/fowoco/server/aiintegration/application/model/MaskedWorkerContext.java +++ /dev/null @@ -1,25 +0,0 @@ -package com.fowoco.server.aiintegration.application.model; - -import java.time.LocalDate; -import java.util.Objects; -import java.util.UUID; - -/** - * Minimum worker context allowed to cross the AI boundary. - * - *

Legal name, phone, passport number, residence number, account number, and document contents - * must never be added here.

- */ -public record MaskedWorkerContext( - UUID workerRef, - String preferredLanguage, - String workStatus, - LocalDate stayExpiryDate -) { - - public MaskedWorkerContext { - Objects.requireNonNull(workerRef, "workerRef must not be null"); - Objects.requireNonNull(preferredLanguage, "preferredLanguage must not be null"); - Objects.requireNonNull(workStatus, "workStatus must not be null"); - } -} diff --git a/src/main/java/com/fowoco/server/aiintegration/application/model/WorkerContext.java b/src/main/java/com/fowoco/server/aiintegration/application/model/WorkerContext.java new file mode 100644 index 0000000..ff2935f --- /dev/null +++ b/src/main/java/com/fowoco/server/aiintegration/application/model/WorkerContext.java @@ -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. + * + *

{@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.

+ */ +public record WorkerContext( + UUID workerRef, + String displayName, + String nationalityCode, + String preferredLanguage, + String workStatus, + LocalDate stayExpiryDate, + LocalDate contractStartDate, + LocalDate contractEndDate, + Map 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); + } +} diff --git a/src/main/java/com/fowoco/server/aiintegration/application/validation/AiRuntimeBoundaryPolicy.java b/src/main/java/com/fowoco/server/aiintegration/application/validation/AiRuntimeBoundaryPolicy.java new file mode 100644 index 0000000..49cf910 --- /dev/null +++ b/src/main/java/com/fowoco/server/aiintegration/application/validation/AiRuntimeBoundaryPolicy.java @@ -0,0 +1,85 @@ +package com.fowoco.server.aiintegration.application.validation; + +import com.fowoco.server.aiintegration.application.error.AiRuntimeContractException; +import com.fowoco.server.aiintegration.application.error.AiRuntimeFailureCode; +import java.text.Normalizer; +import java.util.Locale; +import java.util.Set; +import java.util.regex.Pattern; +import org.springframework.stereotype.Component; + +/** + * Allows original demo data while preventing service credentials from crossing the AI boundary. + */ +@Component +public class AiRuntimeBoundaryPolicy { + + private static final Set FORBIDDEN_KEY_PARTS = Set.of( + "token", + "password", + "secret", + "authorization", + "prompt", + "apikey", + "토큰", + "인증", + "비밀", + "api키", + "비밀번호" + ); + 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+" + ); + + public void validateText(String value, int maxLength, boolean required) { + if (value == null || value.isBlank()) { + if (required) { + reject(AiRuntimeFailureCode.INVALID_REQUEST_CONTRACT, "Required AI contract text is missing."); + } + return; + } + String normalized = Normalizer.normalize(value.trim(), Normalizer.Form.NFKC); + if (normalized.length() > maxLength) { + reject(AiRuntimeFailureCode.INVALID_REQUEST_CONTRACT, "AI contract text exceeds its size limit."); + } + if (containsCredential(normalized)) { + reject( + AiRuntimeFailureCode.SENSITIVE_DATA_REJECTED, + "Service credential was rejected at the AI boundary." + ); + } + } + + public void validateKey(String key) { + if (key == null || key.isBlank()) { + reject(AiRuntimeFailureCode.INVALID_REQUEST_CONTRACT, "AI contract key is missing."); + } + String normalizedKey = Normalizer.normalize(key, Normalizer.Form.NFKC) + .toLowerCase(Locale.ROOT) + .replace("_", "") + .replace("-", ""); + boolean forbidden = FORBIDDEN_KEY_PARTS.stream() + .map(part -> part.replace("_", "").replace("-", "")) + .anyMatch(normalizedKey::contains); + if (forbidden) { + reject( + AiRuntimeFailureCode.SENSITIVE_DATA_REJECTED, + "Service credential key was rejected at the AI boundary." + ); + } + } + + 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) { + throw new AiRuntimeContractException(failureCode, safeMessage); + } +} diff --git a/src/main/java/com/fowoco/server/aiintegration/application/validation/AiRuntimeContractValidator.java b/src/main/java/com/fowoco/server/aiintegration/application/validation/AiRuntimeContractValidator.java index d1fcf60..68ef4f2 100644 --- a/src/main/java/com/fowoco/server/aiintegration/application/validation/AiRuntimeContractValidator.java +++ b/src/main/java/com/fowoco/server/aiintegration/application/validation/AiRuntimeContractValidator.java @@ -6,7 +6,7 @@ import com.fowoco.server.aiintegration.application.model.AiAnalysisResponse; import com.fowoco.server.aiintegration.application.model.AiCandidate; import com.fowoco.server.aiintegration.application.model.AiRuntimeVersions; -import com.fowoco.server.aiintegration.application.model.MaskedWorkerContext; +import com.fowoco.server.aiintegration.application.model.WorkerContext; import com.fowoco.server.aiintegration.application.model.WorkflowConstraint; import java.math.BigDecimal; import java.util.HashMap; @@ -32,10 +32,10 @@ public class AiRuntimeContractValidator { private static final Pattern IDENTIFIER = Pattern.compile("[A-Za-z][A-Za-z0-9._-]{0,127}"); private static final Pattern CANDIDATE_REF = Pattern.compile("[A-Za-z0-9][A-Za-z0-9_-]{0,63}"); - private final AiRuntimePrivacyPolicy privacyPolicy; + private final AiRuntimeBoundaryPolicy boundaryPolicy; - public AiRuntimeContractValidator(AiRuntimePrivacyPolicy privacyPolicy) { - this.privacyPolicy = privacyPolicy; + public AiRuntimeContractValidator(AiRuntimeBoundaryPolicy boundaryPolicy) { + this.boundaryPolicy = boundaryPolicy; } public void validateRequest(AiAnalysisRequest request) { @@ -47,7 +47,7 @@ public void validateRequest(AiAnalysisRequest request) { if (request.deadlineMs() < MIN_DEADLINE_MS || request.deadlineMs() > MAX_DEADLINE_MS) { reject(AiRuntimeFailureCode.INVALID_REQUEST_CONTRACT, "AI Runtime deadline is outside the allowed range."); } - privacyPolicy.validateText(request.maskedInput().maskedInstruction(), 10_000, true); + boundaryPolicy.validateText(request.analysisInput().instruction(), 10_000, true); validateWorkers(request); validateWorkflowConstraints(request); } @@ -72,15 +72,15 @@ public void validateResponse(AiAnalysisRequest request, AiAnalysisResponse respo } Map> allowedSlotsByWorkflow = allowedSlotsByWorkflow(request); - Set allowedWorkers = request.maskedInput().workers().stream() - .map(MaskedWorkerContext::workerRef) + Set allowedWorkers = request.analysisInput().workers().stream() + .map(WorkerContext::workerRef) .collect(java.util.stream.Collectors.toUnmodifiableSet()); Set candidateRefs = new HashSet<>(); response.candidates().forEach(candidate -> validateCandidate(candidate, allowedWorkers, allowedSlotsByWorkflow, candidateRefs)); response.validationErrors().forEach(error -> { validateIdentifier(error.code(), AiRuntimeFailureCode.INVALID_RESPONSE_CONTRACT); - privacyPolicy.validateKey(error.field()); + boundaryPolicy.validateKey(error.field()); validateIdentifier(error.field(), AiRuntimeFailureCode.INVALID_RESPONSE_CONTRACT); }); if (response.outcome() == com.fowoco.server.aiintegration.application.model.AiAnalysisOutcome.REVIEW_REQUIRED @@ -93,24 +93,39 @@ public void validateResponse(AiAnalysisRequest request, AiAnalysisResponse respo } private void validateWorkers(AiAnalysisRequest request) { - var workers = request.maskedInput().workers(); + var workers = request.analysisInput().workers(); if (workers.isEmpty() || workers.size() > MAX_WORKERS) { reject(AiRuntimeFailureCode.INVALID_REQUEST_CONTRACT, "AI Runtime worker context count is invalid."); } Set workerRefs = new HashSet<>(); - for (MaskedWorkerContext worker : workers) { + for (WorkerContext worker : workers) { if (!workerRefs.add(worker.workerRef())) { reject(AiRuntimeFailureCode.INVALID_REQUEST_CONTRACT, "AI Runtime worker reference is duplicated."); } - privacyPolicy.validateText(worker.preferredLanguage(), 32, true); - privacyPolicy.validateText(worker.workStatus(), 32, true); - validateIdentifier(worker.preferredLanguage(), AiRuntimeFailureCode.INVALID_REQUEST_CONTRACT); + boundaryPolicy.validateText(worker.displayName(), 120, true); + boundaryPolicy.validateText(worker.nationalityCode(), 10, false); + boundaryPolicy.validateText(worker.preferredLanguage(), 32, false); + boundaryPolicy.validateText(worker.workStatus(), 32, true); + if (worker.preferredLanguage() != null) { + validateIdentifier(worker.preferredLanguage(), AiRuntimeFailureCode.INVALID_REQUEST_CONTRACT); + } validateIdentifier(worker.workStatus(), AiRuntimeFailureCode.INVALID_REQUEST_CONTRACT); + if (worker.requestedFields().size() > 100) { + reject( + AiRuntimeFailureCode.INVALID_REQUEST_CONTRACT, + "AI Runtime requested field count is invalid." + ); + } + worker.requestedFields().forEach((key, value) -> { + boundaryPolicy.validateKey(key); + validateIdentifier(key, AiRuntimeFailureCode.INVALID_REQUEST_CONTRACT); + boundaryPolicy.validateText(value, 4_000, true); + }); } } private void validateWorkflowConstraints(AiAnalysisRequest request) { - var workflows = request.maskedInput().workflowConstraints(); + var workflows = request.analysisInput().workflowConstraints(); if (workflows.isEmpty() || workflows.size() > MAX_WORKFLOWS) { reject(AiRuntimeFailureCode.INVALID_REQUEST_CONTRACT, "AI Runtime Workflow constraint count is invalid."); } @@ -121,7 +136,7 @@ private void validateWorkflowConstraints(AiAnalysisRequest request) { reject(AiRuntimeFailureCode.INVALID_REQUEST_CONTRACT, "AI Runtime Workflow constraint is invalid."); } workflow.allowedSlotKeys().forEach(slot -> { - privacyPolicy.validateKey(slot); + boundaryPolicy.validateKey(slot); validateIdentifier(slot, AiRuntimeFailureCode.INVALID_REQUEST_CONTRACT); }); } @@ -130,7 +145,7 @@ private void validateWorkflowConstraints(AiAnalysisRequest request) { private void validateResponseVersions(AiAnalysisRequest request, AiRuntimeVersions versions) { validateVersion(versions.agentVersion(), AiRuntimeFailureCode.INVALID_RESPONSE_CONTRACT); validateIdentifier(versions.modelProvider(), AiRuntimeFailureCode.INVALID_RESPONSE_CONTRACT); - privacyPolicy.validateText(versions.modelName(), 128, true); + boundaryPolicy.validateText(versions.modelName(), 128, true); validateVersion(versions.modelVersion(), AiRuntimeFailureCode.INVALID_RESPONSE_CONTRACT); validateVersion(versions.promptVersion(), AiRuntimeFailureCode.INVALID_RESPONSE_CONTRACT); validateVersion(versions.contextPackVersion(), AiRuntimeFailureCode.INVALID_RESPONSE_CONTRACT); @@ -167,7 +182,7 @@ private void validateCandidate( } candidate.extractedSlots().forEach((key, value) -> { validateAllowedSlot(key, allowedSlots); - privacyPolicy.validateText(value, 4_000, true); + boundaryPolicy.validateText(value, 4_000, true); }); Set missingSlots = new HashSet<>(); candidate.missingSlots().forEach(slot -> { @@ -179,7 +194,7 @@ private void validateCandidate( } private void validateAllowedSlot(String slot, Set allowedSlots) { - privacyPolicy.validateKey(slot); + boundaryPolicy.validateKey(slot); validateIdentifier(slot, AiRuntimeFailureCode.INVALID_RESPONSE_CONTRACT); if (!allowedSlots.contains(slot)) { reject(AiRuntimeFailureCode.UNEXPECTED_SLOT, "AI Runtime returned an unexpected slot."); @@ -188,7 +203,7 @@ private void validateAllowedSlot(String slot, Set allowedSlots) { private Map> allowedSlotsByWorkflow(AiAnalysisRequest request) { Map> allowed = new HashMap<>(); - request.maskedInput().workflowConstraints() + request.analysisInput().workflowConstraints() .forEach(workflow -> allowed.put(workflow.workflowId(), workflow.allowedSlotKeys())); return Map.copyOf(allowed); } diff --git a/src/main/java/com/fowoco/server/aiintegration/application/validation/AiRuntimePrivacyPolicy.java b/src/main/java/com/fowoco/server/aiintegration/application/validation/AiRuntimePrivacyPolicy.java deleted file mode 100644 index be1b896..0000000 --- a/src/main/java/com/fowoco/server/aiintegration/application/validation/AiRuntimePrivacyPolicy.java +++ /dev/null @@ -1,117 +0,0 @@ -package com.fowoco.server.aiintegration.application.validation; - -import com.fowoco.server.aiintegration.application.error.AiRuntimeContractException; -import com.fowoco.server.aiintegration.application.error.AiRuntimeFailureCode; -import java.text.Normalizer; -import java.util.Locale; -import java.util.Set; -import java.util.regex.Pattern; -import org.springframework.stereotype.Component; - -/** - * Rejects sensitive values before they cross the AI boundary or enter an AiRun candidate. - */ -@Component -public class AiRuntimePrivacyPolicy { - - private static final Set FORBIDDEN_KEY_PARTS = Set.of( - "passportnumber", - "passportno", - "alienregistrationnumber", - "registrationnumber", - "residentnumber", - "rrn", - "legalname", - "displayname", - "birthdate", - "dateofbirth", - "workeremail", - "personalemail", - "workeraddress", - "homeaddress", - "addressinkorea", - "homecountryaddress", - "phone", - "accountnumber", - "bankaccount", - "signature", - "photo", - "token", - "password", - "secret", - "authorization", - "prompt", - "여권번호", - "외국인등록번호", - "주민등록번호", - "법적성명", - "생년월일", - "이메일", - "상세주소", - "전화", - "계좌", - "서명", - "비밀번호" - ); - private static final Pattern REGISTRATION_NUMBER = - Pattern.compile("(? 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."); - } - } - - public void validateKey(String key) { - if (key == null || key.isBlank()) { - reject(AiRuntimeFailureCode.INVALID_REQUEST_CONTRACT, "AI contract key is missing."); - } - String normalizedKey = Normalizer.normalize(key, Normalizer.Form.NFKC) - .toLowerCase(Locale.ROOT) - .replace("_", "") - .replace("-", ""); - boolean forbidden = FORBIDDEN_KEY_PARTS.stream() - .map(part -> part.replace("_", "").replace("-", "")) - .anyMatch(normalizedKey::contains); - if (forbidden) { - reject(AiRuntimeFailureCode.SENSITIVE_DATA_REJECTED, "Sensitive key was rejected at the AI boundary."); - } - } - - private boolean containsSensitiveValue(String value) { - return REGISTRATION_NUMBER.matcher(value).find() - || PHONE_NUMBER.matcher(value).find() - || EMAIL_ADDRESS.matcher(value).find() - || BEARER_TOKEN.matcher(value).find() - || SECRET_ASSIGNMENT.matcher(value).find() - || LABELED_SENSITIVE_VALUE.matcher(value).find(); - } - - private void reject(AiRuntimeFailureCode failureCode, String safeMessage) { - throw new AiRuntimeContractException(failureCode, safeMessage); - } -} diff --git a/src/test/java/com/fowoco/server/aiintegration/application/model/AiRuntimeOutboundContractTest.java b/src/test/java/com/fowoco/server/aiintegration/application/model/AiRuntimeOutboundContractTest.java index 7bba89f..89c72a1 100644 --- a/src/test/java/com/fowoco/server/aiintegration/application/model/AiRuntimeOutboundContractTest.java +++ b/src/test/java/com/fowoco/server/aiintegration/application/model/AiRuntimeOutboundContractTest.java @@ -12,9 +12,10 @@ class AiRuntimeOutboundContractTest { private final ObjectMapper objectMapper = new ObjectMapper(); @Test - void outboundJsonContainsOnlyTheExplicitMaskedContract() throws Exception { + void outboundJsonContainsOriginalDemoDataWithoutServiceCredentials() throws Exception { JsonNode json = objectMapper.valueToTree(validRequest()); - JsonNode worker = json.get("maskedInput").get("workers").get(0); + JsonNode input = json.get("analysisInput"); + JsonNode worker = input.get("workers").get(0); assertThat(json.properties().stream().map(java.util.Map.Entry::getKey).toList()) .containsExactlyInAnyOrder( @@ -23,29 +24,34 @@ void outboundJsonContainsOnlyTheExplicitMaskedContract() throws Exception { "contractVersion", "requiredKnowledgeVersion", "deadlineMs", - "maskedInput" + "analysisInput" ); assertThat(worker.properties().stream().map(java.util.Map.Entry::getKey).toList()) .containsExactlyInAnyOrder( "workerRef", + "displayName", + "nationalityCode", "preferredLanguage", "workStatus", - "stayExpiryDate" + "stayExpiryDate", + "contractStartDate", + "contractEndDate", + "requestedFields" ); + assertThat(input.get("instruction").textValue()) + .contains("응웬반안", "010-1234-5678"); + assertThat(worker.get("requestedFields").get("legal_name").textValue()) + .isEqualTo("NGUYEN VAN AN"); + assertThat(worker.get("requestedFields").get("passport_number").textValue()) + .isEqualTo("M12345678"); + assertThat(worker.get("requestedFields").get("email").textValue()) + .isEqualTo("worker@example.com"); assertThat(json.toString().toLowerCase()) .doesNotContain( - "passportnumber", - "alienregistrationnumber", - "phone", - "accountnumber", - "legalname", - "displayname", - "birthdate", - "email", - "address", - "signature", "token", - "authorization" + "authorization", + "password", + "api_key" ); } } diff --git a/src/test/java/com/fowoco/server/aiintegration/application/validation/AiRuntimeContractValidatorTest.java b/src/test/java/com/fowoco/server/aiintegration/application/validation/AiRuntimeContractValidatorTest.java index 5507ac6..3067794 100644 --- a/src/test/java/com/fowoco/server/aiintegration/application/validation/AiRuntimeContractValidatorTest.java +++ b/src/test/java/com/fowoco/server/aiintegration/application/validation/AiRuntimeContractValidatorTest.java @@ -33,7 +33,7 @@ class AiRuntimeContractValidatorTest { private final AiRuntimeContractValidator validator = - new AiRuntimeContractValidator(new AiRuntimePrivacyPolicy()); + new AiRuntimeContractValidator(new AiRuntimeBoundaryPolicy()); @Test void acceptsValidRequestAndResponse() { @@ -42,46 +42,38 @@ void acceptsValidRequestAndResponse() { } @ParameterizedTest - @MethodSource("sensitiveInstructions") - void rejectsSensitiveInstructionBeforeOutboundCall(String instruction) { + @MethodSource("credentialInstructions") + void rejectsServiceCredentialsBeforeOutboundCall(String instruction) { assertFailure( () -> validator.validateRequest(AiRuntimeContractFixture.requestWithInstruction(instruction)), AiRuntimeFailureCode.SENSITIVE_DATA_REJECTED ); } - static Stream sensitiveInstructions() { + static Stream credentialInstructions() { return Stream.of( - "연락처는 010-1234-5678입니다", - "근로자 이메일은 worker@example.com입니다", - "외국인등록번호 990101-5123456", - "passport_number: M12345678", "Authorization: Bearer secret-token-value", - "api_key=do-not-send-this" + "api_key=do-not-send-this", + "JWT eyJ12345678.abcdefgh12345678.signature12345678" ); } @ParameterizedTest - @MethodSource("forbiddenPiiKeys") - void rejectsPiiFieldKeysInsteadOfReturningMaskedPlaceholders(String fieldKey) { + @MethodSource("forbiddenCredentialKeys") + void rejectsServiceCredentialKeys(String fieldKey) { assertFailure( - () -> new AiRuntimePrivacyPolicy().validateKey(fieldKey), + () -> new AiRuntimeBoundaryPolicy().validateKey(fieldKey), AiRuntimeFailureCode.SENSITIVE_DATA_REJECTED ); } - static Stream forbiddenPiiKeys() { + static Stream forbiddenCredentialKeys() { return Stream.of( - "worker_legal_name", - "worker_display_name", - "worker_birthdate", - "worker_email", - "worker_home_address", - "employee_signature", - "passport_number", - "alien_registration_number", - "phone", - "bank_account" + "access_token", + "authorization", + "password", + "api_key", + "service_secret" ); } @@ -156,14 +148,14 @@ void rejectsCandidateOutsideRequestAllowList() { ); assertFailure( () -> validator.validateResponse(validRequest(), responseWithCandidate(unknownSlot)), - AiRuntimeFailureCode.SENSITIVE_DATA_REJECTED + AiRuntimeFailureCode.UNEXPECTED_SLOT ); } @Test - void rejectsSensitiveCandidateValueAndKeepsExceptionMessageSafe() { - AiCandidate sensitiveCandidate = new AiCandidate( - "candidate-sensitive", + void acceptsOriginalPiiCandidateValueWhenTheWorkflowAllowsTheSlot() { + AiCandidate originalValueCandidate = new AiCandidate( + "candidate-original-value", WORKER_REF, WORKFLOW_ID, Map.of("contract_end_date", "담당자 전화 010-1234-5678"), @@ -171,13 +163,10 @@ void rejectsSensitiveCandidateValueAndKeepsExceptionMessageSafe() { BigDecimal.ONE ); - assertThatThrownBy(() -> validator.validateResponse( + assertThatCode(() -> validator.validateResponse( validRequest(), - responseWithCandidate(sensitiveCandidate) - )).isInstanceOfSatisfying(AiRuntimeContractException.class, exception -> { - assertThat(exception.failureCode()).isEqualTo(AiRuntimeFailureCode.SENSITIVE_DATA_REJECTED); - assertThat(exception.getMessage()).doesNotContain("010-1234-5678"); - }); + responseWithCandidate(originalValueCandidate) + )).doesNotThrowAnyException(); } private AiAnalysisResponse responseWithVersions(AiRuntimeVersions versions) { diff --git a/src/test/java/com/fowoco/server/aiintegration/application/validation/ValidatingAiRuntimeClientTest.java b/src/test/java/com/fowoco/server/aiintegration/application/validation/ValidatingAiRuntimeClientTest.java index f190237..0a16ea8 100644 --- a/src/test/java/com/fowoco/server/aiintegration/application/validation/ValidatingAiRuntimeClientTest.java +++ b/src/test/java/com/fowoco/server/aiintegration/application/validation/ValidatingAiRuntimeClientTest.java @@ -13,7 +13,7 @@ class ValidatingAiRuntimeClientTest { private final AiRuntimeContractValidator validator = - new AiRuntimeContractValidator(new AiRuntimePrivacyPolicy()); + new AiRuntimeContractValidator(new AiRuntimeBoundaryPolicy()); @Test void validatesBothSidesAndCapturesOneAttemptWithoutTransparentRetry() { @@ -31,7 +31,9 @@ void rejectedInputNeverReachesTransport() { fake.enqueueResponse(validResponse()); ValidatingAiRuntimeClient client = new ValidatingAiRuntimeClient(fake, validator); - assertThatThrownBy(() -> client.analyze(requestWithInstruction("전화 010-1234-5678"))) + assertThatThrownBy(() -> client.analyze(requestWithInstruction( + "Authorization: Bearer secret-token-value" + ))) .isInstanceOf(AiRuntimeContractException.class); assertThat(fake.receivedRequests()).isEmpty(); } diff --git a/src/test/java/com/fowoco/server/aiintegration/support/AiRuntimeContractFixture.java b/src/test/java/com/fowoco/server/aiintegration/support/AiRuntimeContractFixture.java index f744cde..ee274b2 100644 --- a/src/test/java/com/fowoco/server/aiintegration/support/AiRuntimeContractFixture.java +++ b/src/test/java/com/fowoco/server/aiintegration/support/AiRuntimeContractFixture.java @@ -5,8 +5,8 @@ import com.fowoco.server.aiintegration.application.model.AiAnalysisResponse; import com.fowoco.server.aiintegration.application.model.AiCandidate; import com.fowoco.server.aiintegration.application.model.AiRuntimeVersions; -import com.fowoco.server.aiintegration.application.model.MaskedAnalysisInput; -import com.fowoco.server.aiintegration.application.model.MaskedWorkerContext; +import com.fowoco.server.aiintegration.application.model.AnalysisInput; +import com.fowoco.server.aiintegration.application.model.WorkerContext; import com.fowoco.server.aiintegration.application.model.WorkflowConstraint; import java.math.BigDecimal; import java.time.LocalDate; @@ -29,7 +29,7 @@ private AiRuntimeContractFixture() { public static AiAnalysisRequest validRequest() { return requestWithInstruction( - "workerRef 30000000-0000-0000-0000-000000000001의 체류연장 준비" + "가상 근로자 응웬반안(010-1234-5678)의 체류연장 준비" ); } @@ -40,13 +40,23 @@ public static AiAnalysisRequest requestWithInstruction(String instruction) { CONTRACT_VERSION, KNOWLEDGE_VERSION, 10_000, - new MaskedAnalysisInput( + new AnalysisInput( instruction, - List.of(new MaskedWorkerContext( + List.of(new WorkerContext( WORKER_REF, + "응웬반안", + "VN", "vi", "ACTIVE", - LocalDate.of(2026, 12, 31) + LocalDate.of(2026, 12, 31), + LocalDate.of(2026, 1, 1), + LocalDate.of(2026, 12, 31), + Map.of( + "legal_name", "NGUYEN VAN AN", + "passport_number", "M12345678", + "phone", "010-1234-5678", + "email", "worker@example.com" + ) )), List.of(new WorkflowConstraint( WORKFLOW_ID,