diff --git a/docs/ai-runtime-contract.md b/docs/ai-runtime-contract.md index 6d89c86..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,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에 +전송해서는 안 되며, 데모가 아닌 실제 개인정보를 사용하기 전에는 개인정보 처리 기준을 +다시 확정해야 합니다. ## 응답 계약 @@ -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` 같은 안전한 진단값만 저장합니다. 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/AiRuntimePrivacyPolicy.java b/src/main/java/com/fowoco/server/aiintegration/application/validation/AiRuntimeBoundaryPolicy.java similarity index 56% rename from src/main/java/com/fowoco/server/aiintegration/application/validation/AiRuntimePrivacyPolicy.java rename to src/main/java/com/fowoco/server/aiintegration/application/validation/AiRuntimeBoundaryPolicy.java index e851c38..49cf910 100644 --- a/src/main/java/com/fowoco/server/aiintegration/application/validation/AiRuntimePrivacyPolicy.java +++ b/src/main/java/com/fowoco/server/aiintegration/application/validation/AiRuntimeBoundaryPolicy.java @@ -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 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("(? 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." + ); } } @@ -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) { 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/test/java/com/fowoco/server/aiintegration/application/model/AiRuntimeOutboundContractTest.java b/src/test/java/com/fowoco/server/aiintegration/application/model/AiRuntimeOutboundContractTest.java index 67240fd..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,24 +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", "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 b51cdde..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,21 +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입니다", - "외국인등록번호 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("forbiddenCredentialKeys") + void rejectsServiceCredentialKeys(String fieldKey) { + assertFailure( + () -> new AiRuntimeBoundaryPolicy().validateKey(fieldKey), + AiRuntimeFailureCode.SENSITIVE_DATA_REJECTED + ); + } + + static Stream forbiddenCredentialKeys() { + return Stream.of( + "access_token", + "authorization", + "password", + "api_key", + "service_secret" ); } @@ -131,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"), @@ -146,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,