feat: plane-2 tool-call intent validation (path containment slice)
Implements the full vertical slice for invariant #9 tool-call assessment: - core:toolintent — new module with ToolCallRule seam, ToolCallAssessor, WorldProbe/FileSystemWorldProbe, WorkspacePolicy, PathContainmentRule, and RiskMapping (assessment → RiskSummary / AssessedIssue) - core:tools — ToolCallAssessmentRecord + ToolInvocationRecord.assessment field; DefaultToolReducer handles ToolCallAssessedEvent (replay proof) - core:config — ToolsConfig gains workspaceRoot + privilegedLocations; ConfigLoader parses both; DEFAULT_PRIVILEGED_LOCATIONS built-in - core:kernel — OrchestratorEngines gains toolCallAssessor/workspacePolicy/ worldProbe fields; SessionOrchestrator.dispatchToolCalls runs runPlane2Assessment before the tier gate: BLOCK → hard-reject without executing; PROMPT_USER → elevates tier into approval path with plane2Risk in the ApprovalRequestedEvent - apps/server — constructs PathContainmentRule + ToolCallAssessor + WorkspacePolicy from config and wires them into OrchestratorEngines Assessment is recorded as ToolCallAssessedEvent (environment observed once, facts stored, replay reads events — invariant #9). Assessor and WorldProbe are only invoked on the live orchestrator path, never in replay.
This commit is contained in:
@@ -37,6 +37,7 @@ dependencies {
|
|||||||
implementation project(':infrastructure:inference:commons')
|
implementation project(':infrastructure:inference:commons')
|
||||||
implementation project(':core:router')
|
implementation project(':core:router')
|
||||||
implementation project(':core:tools')
|
implementation project(':core:tools')
|
||||||
|
implementation project(':core:toolintent')
|
||||||
implementation project(':infrastructure:tools')
|
implementation project(':infrastructure:tools')
|
||||||
implementation project(':infrastructure:tools:filesystem')
|
implementation project(':infrastructure:tools:filesystem')
|
||||||
|
|
||||||
|
|||||||
@@ -38,6 +38,9 @@ import com.correx.core.validation.pipeline.ValidationPipeline
|
|||||||
import com.correx.core.validation.semantic.SemanticValidator
|
import com.correx.core.validation.semantic.SemanticValidator
|
||||||
import com.correx.core.validation.semantic.rules.CycleExitRule
|
import com.correx.core.validation.semantic.rules.CycleExitRule
|
||||||
import com.correx.core.validation.semantic.rules.MissingToolRule
|
import com.correx.core.validation.semantic.rules.MissingToolRule
|
||||||
|
import com.correx.core.toolintent.ToolCallAssessor
|
||||||
|
import com.correx.core.toolintent.WorkspacePolicy
|
||||||
|
import com.correx.core.toolintent.rules.PathContainmentRule
|
||||||
import com.correx.infrastructure.InfrastructureModule
|
import com.correx.infrastructure.InfrastructureModule
|
||||||
import com.correx.infrastructure.artifactscas.DefaultMaterializingArtifactWriter
|
import com.correx.infrastructure.artifactscas.DefaultMaterializingArtifactWriter
|
||||||
import com.correx.infrastructure.inference.DefaultProviderRegistry
|
import com.correx.infrastructure.inference.DefaultProviderRegistry
|
||||||
@@ -104,6 +107,14 @@ fun main() {
|
|||||||
|
|
||||||
logToolInfo(sandboxRoot, workingDir, shellAllowedExecutables, toolRegistry)
|
logToolInfo(sandboxRoot, workingDir, shellAllowedExecutables, toolRegistry)
|
||||||
|
|
||||||
|
val workspaceRoot = System.getenv("CORREX_WORKSPACE_ROOT")
|
||||||
|
?.let { Path.of(it) }
|
||||||
|
?: toolsConfig.workspaceRoot.takeIf { it.isNotEmpty() }?.let { Path.of(it) }
|
||||||
|
?: workingDir
|
||||||
|
val privilegedLocations = toolsConfig.privilegedLocations.map { Path.of(it) }
|
||||||
|
val workspacePolicy = WorkspacePolicy(workspaceRoot, privilegedLocations)
|
||||||
|
val toolCallAssessor = ToolCallAssessor(rules = listOf(PathContainmentRule()))
|
||||||
|
|
||||||
val inferenceRouter = DefaultInferenceRouter(infraRegistry, FirstAvailableRoutingStrategy())
|
val inferenceRouter = DefaultInferenceRouter(infraRegistry, FirstAvailableRoutingStrategy())
|
||||||
val engines = OrchestratorEngines(
|
val engines = OrchestratorEngines(
|
||||||
transitionResolver = DefaultTransitionResolver { condition, ctx -> condition.evaluate(ctx) },
|
transitionResolver = DefaultTransitionResolver { condition, ctx -> condition.evaluate(ctx) },
|
||||||
@@ -125,6 +136,8 @@ fun main() {
|
|||||||
promptResolver = { path -> InfrastructureModule.createPromptLoader().load(path) },
|
promptResolver = { path -> InfrastructureModule.createPromptLoader().load(path) },
|
||||||
toolRegistry = toolRegistry,
|
toolRegistry = toolRegistry,
|
||||||
toolExecutor = toolExecutor,
|
toolExecutor = toolExecutor,
|
||||||
|
toolCallAssessor = toolCallAssessor,
|
||||||
|
workspacePolicy = workspacePolicy,
|
||||||
)
|
)
|
||||||
val orchestrator = DefaultSessionOrchestrator(
|
val orchestrator = DefaultSessionOrchestrator(
|
||||||
repositories = repositories,
|
repositories = repositories,
|
||||||
|
|||||||
@@ -299,6 +299,9 @@ object ConfigLoader {
|
|||||||
fileReadEnabled = fileReadEnabled,
|
fileReadEnabled = fileReadEnabled,
|
||||||
fileWriteEnabled = fileWriteEnabled,
|
fileWriteEnabled = fileWriteEnabled,
|
||||||
fileEditEnabled = fileEditEnabled,
|
fileEditEnabled = fileEditEnabled,
|
||||||
|
workspaceRoot = asString(toolsSection["workspace_root"], ""),
|
||||||
|
privilegedLocations = asStringList(toolsSection["privileged_locations"])
|
||||||
|
.ifEmpty { ToolsConfig.DEFAULT_PRIVILEGED_LOCATIONS },
|
||||||
)
|
)
|
||||||
|
|
||||||
val providers = providersList.mapNotNull { providerMap ->
|
val providers = providersList.mapNotNull { providerMap ->
|
||||||
|
|||||||
@@ -39,7 +39,16 @@ data class ToolsConfig(
|
|||||||
val fileReadEnabled: Boolean = true,
|
val fileReadEnabled: Boolean = true,
|
||||||
val fileWriteEnabled: Boolean = true,
|
val fileWriteEnabled: Boolean = true,
|
||||||
val fileEditEnabled: Boolean = true,
|
val fileEditEnabled: Boolean = true,
|
||||||
)
|
val workspaceRoot: String = "",
|
||||||
|
val privilegedLocations: List<String> = DEFAULT_PRIVILEGED_LOCATIONS,
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
val DEFAULT_PRIVILEGED_LOCATIONS: List<String> = listOf(
|
||||||
|
"/etc", "/usr", "/bin", "/sbin", "/boot", "/lib", "/lib64",
|
||||||
|
"/sys", "/proc", "/dev", "/root",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class ProviderConfig(
|
data class ProviderConfig(
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package com.correx.core.config
|
||||||
|
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
class WorkspaceConfigTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `defaults when keys absent`() {
|
||||||
|
val tools = ToolsConfig()
|
||||||
|
assertEquals("", tools.workspaceRoot)
|
||||||
|
assertTrue(tools.privilegedLocations.contains("/etc"))
|
||||||
|
assertTrue(tools.privilegedLocations.contains("/usr"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `default privileged locations includes system paths`() {
|
||||||
|
val defaults = ToolsConfig.DEFAULT_PRIVILEGED_LOCATIONS
|
||||||
|
assertTrue(defaults.contains("/etc"))
|
||||||
|
assertTrue(defaults.contains("/usr"))
|
||||||
|
assertTrue(defaults.contains("/bin"))
|
||||||
|
assertTrue(defaults.contains("/root"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `parseToml parses workspace_root from tools section`() {
|
||||||
|
val toml = """
|
||||||
|
[tools]
|
||||||
|
workspace_root = "/my/workspace"
|
||||||
|
privileged_locations = ["/etc", "/usr", "/custom"]
|
||||||
|
""".trimIndent()
|
||||||
|
|
||||||
|
val parseTomlMethod = ConfigLoader::class.java.getDeclaredMethod("parseToml", String::class.java)
|
||||||
|
parseTomlMethod.isAccessible = true
|
||||||
|
val result = parseTomlMethod.invoke(ConfigLoader, toml) as CorrexConfig
|
||||||
|
|
||||||
|
assertEquals("/my/workspace", result.tools.workspaceRoot)
|
||||||
|
assertEquals(listOf("/etc", "/usr", "/custom"), result.tools.privilegedLocations)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `parseToml uses default privileged locations when key absent`() {
|
||||||
|
val toml = """
|
||||||
|
[tools]
|
||||||
|
workspace_root = "/x"
|
||||||
|
""".trimIndent()
|
||||||
|
|
||||||
|
val parseTomlMethod = ConfigLoader::class.java.getDeclaredMethod("parseToml", String::class.java)
|
||||||
|
parseTomlMethod.isAccessible = true
|
||||||
|
val result = parseTomlMethod.invoke(ConfigLoader, toml) as CorrexConfig
|
||||||
|
|
||||||
|
assertEquals(ToolsConfig.DEFAULT_PRIVILEGED_LOCATIONS, result.tools.privilegedLocations)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ dependencies {
|
|||||||
implementation project(':core:artifacts')
|
implementation project(':core:artifacts')
|
||||||
implementation project(':core:artifacts-store')
|
implementation project(':core:artifacts-store')
|
||||||
implementation project(':core:risk')
|
implementation project(':core:risk')
|
||||||
|
implementation project(':core:toolintent')
|
||||||
implementation "org.slf4j:slf4j-api:2.0.16"
|
implementation "org.slf4j:slf4j-api:2.0.16"
|
||||||
}
|
}
|
||||||
tasks.named("koverVerify").configure { enabled = false }
|
tasks.named("koverVerify").configure { enabled = false }
|
||||||
|
|||||||
+7
@@ -4,6 +4,10 @@ import com.correx.core.approvals.domain.ApprovalEngine
|
|||||||
import com.correx.core.context.builder.ContextPackBuilder
|
import com.correx.core.context.builder.ContextPackBuilder
|
||||||
import com.correx.core.inference.InferenceRouter
|
import com.correx.core.inference.InferenceRouter
|
||||||
import com.correx.core.risk.RiskAssessor
|
import com.correx.core.risk.RiskAssessor
|
||||||
|
import com.correx.core.toolintent.FileSystemWorldProbe
|
||||||
|
import com.correx.core.toolintent.ToolCallAssessor
|
||||||
|
import com.correx.core.toolintent.WorkspacePolicy
|
||||||
|
import com.correx.core.toolintent.WorldProbe
|
||||||
import com.correx.core.tools.contract.ToolExecutor
|
import com.correx.core.tools.contract.ToolExecutor
|
||||||
import com.correx.core.tools.registry.ToolRegistry
|
import com.correx.core.tools.registry.ToolRegistry
|
||||||
import com.correx.core.transitions.evaluation.PromptResolver
|
import com.correx.core.transitions.evaluation.PromptResolver
|
||||||
@@ -20,4 +24,7 @@ data class OrchestratorEngines(
|
|||||||
val promptResolver: PromptResolver = PromptResolver { "" },
|
val promptResolver: PromptResolver = PromptResolver { "" },
|
||||||
val toolExecutor: ToolExecutor? = null,
|
val toolExecutor: ToolExecutor? = null,
|
||||||
val toolRegistry: ToolRegistry? = null,
|
val toolRegistry: ToolRegistry? = null,
|
||||||
|
val toolCallAssessor: ToolCallAssessor? = null,
|
||||||
|
val workspacePolicy: WorkspacePolicy? = null,
|
||||||
|
val worldProbe: WorldProbe = FileSystemWorldProbe(),
|
||||||
)
|
)
|
||||||
|
|||||||
+87
-1
@@ -23,6 +23,7 @@ import com.correx.core.context.model.EntryRole
|
|||||||
import com.correx.core.events.events.ApprovalDecisionResolvedEvent
|
import com.correx.core.events.events.ApprovalDecisionResolvedEvent
|
||||||
import com.correx.core.events.events.ApprovalRequestedEvent
|
import com.correx.core.events.events.ApprovalRequestedEvent
|
||||||
import com.correx.core.events.events.ArtifactCreatedEvent
|
import com.correx.core.events.events.ArtifactCreatedEvent
|
||||||
|
import com.correx.core.events.events.ToolCallAssessedEvent
|
||||||
import com.correx.core.events.events.ArtifactValidatedEvent
|
import com.correx.core.events.events.ArtifactValidatedEvent
|
||||||
import com.correx.core.events.events.ArtifactValidatingEvent
|
import com.correx.core.events.events.ArtifactValidatingEvent
|
||||||
import com.correx.core.events.events.EventMetadata
|
import com.correx.core.events.events.EventMetadata
|
||||||
@@ -39,6 +40,14 @@ import com.correx.core.events.events.SteeringNoteAddedEvent
|
|||||||
import com.correx.core.events.events.ToolExecutionRejectedEvent
|
import com.correx.core.events.events.ToolExecutionRejectedEvent
|
||||||
import com.correx.core.events.events.ToolInvocationRequestedEvent
|
import com.correx.core.events.events.ToolInvocationRequestedEvent
|
||||||
import com.correx.core.events.events.ToolRequest
|
import com.correx.core.events.events.ToolRequest
|
||||||
|
import com.correx.core.events.risk.RiskAction
|
||||||
|
import com.correx.core.events.risk.RiskSummary
|
||||||
|
import com.correx.core.toolintent.ToolCallAssessmentInput
|
||||||
|
import com.correx.core.toolintent.ToolCallAssessor
|
||||||
|
import com.correx.core.toolintent.WorkspacePolicy
|
||||||
|
import com.correx.core.toolintent.WorldProbe
|
||||||
|
import com.correx.core.toolintent.toAssessedIssues
|
||||||
|
import com.correx.core.toolintent.toRiskSummary
|
||||||
import com.correx.core.events.events.TransitionExecutedEvent
|
import com.correx.core.events.events.TransitionExecutedEvent
|
||||||
import com.correx.core.events.events.WorkflowCompletedEvent
|
import com.correx.core.events.events.WorkflowCompletedEvent
|
||||||
import com.correx.core.events.events.WorkflowFailedEvent
|
import com.correx.core.events.events.WorkflowFailedEvent
|
||||||
@@ -128,6 +137,9 @@ abstract class SessionOrchestrator(
|
|||||||
private val promptResolver: PromptResolver = engines.promptResolver
|
private val promptResolver: PromptResolver = engines.promptResolver
|
||||||
private val toolExecutor: ToolExecutor? = engines.toolExecutor
|
private val toolExecutor: ToolExecutor? = engines.toolExecutor
|
||||||
private val toolRegistry: ToolRegistry? = engines.toolRegistry
|
private val toolRegistry: ToolRegistry? = engines.toolRegistry
|
||||||
|
private val toolCallAssessor: ToolCallAssessor? = engines.toolCallAssessor
|
||||||
|
private val workspacePolicy: WorkspacePolicy? = engines.workspacePolicy
|
||||||
|
private val worldProbe: WorldProbe = engines.worldProbe
|
||||||
private val inferenceRepository: InferenceRepository = repositories.inferenceRepository
|
private val inferenceRepository: InferenceRepository = repositories.inferenceRepository
|
||||||
internal val orchestrationRepository: OrchestrationRepository = repositories.orchestrationRepository
|
internal val orchestrationRepository: OrchestrationRepository = repositories.orchestrationRepository
|
||||||
protected open val tokenizer: Tokenizer? = null
|
protected open val tokenizer: Tokenizer? = null
|
||||||
@@ -365,7 +377,46 @@ abstract class SessionOrchestrator(
|
|||||||
request = request,
|
request = request,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
if (tier.isAtMost(Tier.T1)) {
|
val plane2Risk: RiskSummary? = runPlane2Assessment(
|
||||||
|
sessionId, stageId, invocationId, toolCall.function.name, request,
|
||||||
|
)?.let { assessment ->
|
||||||
|
if (assessment.recommendedAction == RiskAction.BLOCK) {
|
||||||
|
emit(
|
||||||
|
sessionId,
|
||||||
|
ToolExecutionRejectedEvent(
|
||||||
|
invocationId = invocationId,
|
||||||
|
sessionId = sessionId,
|
||||||
|
toolName = toolCall.function.name,
|
||||||
|
tier = tier,
|
||||||
|
reason = "blocked by tool-call policy",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val sourceId = toolCall.id ?: invocationId.value
|
||||||
|
return@flatMap listOf(
|
||||||
|
ContextEntry(
|
||||||
|
id = ContextEntryId(UUID.randomUUID().toString()),
|
||||||
|
layer = ContextLayer.L2,
|
||||||
|
sourceType = "assistantToolCall",
|
||||||
|
sourceId = sourceId,
|
||||||
|
content = Json.encodeToString(ToolCallRequest.serializer(), toolCall),
|
||||||
|
tokenEstimate = estimateTokens(toolCall.function.arguments),
|
||||||
|
role = EntryRole.ASSISTANT,
|
||||||
|
),
|
||||||
|
ContextEntry(
|
||||||
|
id = ContextEntryId(UUID.randomUUID().toString()),
|
||||||
|
layer = ContextLayer.L2,
|
||||||
|
sourceType = "toolResult",
|
||||||
|
sourceId = sourceId,
|
||||||
|
content = "BLOCKED: ${assessment.rationale.joinToString("; ")}",
|
||||||
|
tokenEstimate = estimateTokens(assessment.rationale.joinToString("; ")),
|
||||||
|
role = EntryRole.TOOL,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
assessment
|
||||||
|
}
|
||||||
|
val plane2Prompts = plane2Risk?.recommendedAction == RiskAction.PROMPT_USER
|
||||||
|
if (tier.isAtMost(Tier.T1) && !plane2Prompts) {
|
||||||
// no approval needed
|
// no approval needed
|
||||||
} else {
|
} else {
|
||||||
val approvalState = approvalRepository.getApprovalState(sessionId)
|
val approvalState = approvalRepository.getApprovalState(sessionId)
|
||||||
@@ -433,6 +484,7 @@ abstract class SessionOrchestrator(
|
|||||||
tier = tier,
|
tier = tier,
|
||||||
validationReportId = domainRequest.validationReportId,
|
validationReportId = domainRequest.validationReportId,
|
||||||
riskSummaryId = null,
|
riskSummaryId = null,
|
||||||
|
riskSummary = plane2Risk,
|
||||||
sessionId = sessionId,
|
sessionId = sessionId,
|
||||||
stageId = stageId,
|
stageId = stageId,
|
||||||
projectId = null,
|
projectId = null,
|
||||||
@@ -538,6 +590,40 @@ abstract class SessionOrchestrator(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private suspend fun runPlane2Assessment(
|
||||||
|
sessionId: SessionId,
|
||||||
|
stageId: StageId,
|
||||||
|
invocationId: ToolInvocationId,
|
||||||
|
toolName: String,
|
||||||
|
request: ToolRequest,
|
||||||
|
): RiskSummary? {
|
||||||
|
val assessor = toolCallAssessor ?: return null
|
||||||
|
val policy = workspacePolicy ?: return null
|
||||||
|
val capabilities = toolRegistry?.resolve(toolName)?.requiredCapabilities ?: emptySet()
|
||||||
|
val assessment = assessor.assess(
|
||||||
|
ToolCallAssessmentInput(
|
||||||
|
request = request,
|
||||||
|
capabilities = capabilities,
|
||||||
|
workspace = policy,
|
||||||
|
probe = worldProbe,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
emit(
|
||||||
|
sessionId,
|
||||||
|
ToolCallAssessedEvent(
|
||||||
|
invocationId = invocationId,
|
||||||
|
sessionId = sessionId,
|
||||||
|
stageId = stageId,
|
||||||
|
toolName = toolName,
|
||||||
|
issues = assessment.toAssessedIssues(),
|
||||||
|
observations = assessment.observations,
|
||||||
|
disposition = assessment.disposition,
|
||||||
|
timestampMs = Clock.System.now().toEpochMilliseconds(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return assessment.toRiskSummary()
|
||||||
|
}
|
||||||
|
|
||||||
private suspend fun buildSchemaEntries(
|
private suspend fun buildSchemaEntries(
|
||||||
responseFormat: ResponseFormat,
|
responseFormat: ResponseFormat,
|
||||||
stageId: StageId,
|
stageId: StageId,
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
plugins {
|
||||||
|
id 'java-library'
|
||||||
|
id 'org.jetbrains.kotlin.jvm'
|
||||||
|
id 'org.jetbrains.kotlin.plugin.serialization'
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation project(':core:events')
|
||||||
|
implementation project(':core:tools')
|
||||||
|
implementation project(':core:validation')
|
||||||
|
testImplementation "org.jetbrains.kotlin:kotlin-test"
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package com.correx.core.toolintent
|
||||||
|
|
||||||
|
import com.correx.core.events.events.AssessedIssue
|
||||||
|
import com.correx.core.events.risk.RiskAction
|
||||||
|
import com.correx.core.events.risk.RiskLevel
|
||||||
|
import com.correx.core.events.risk.RiskSignal
|
||||||
|
import com.correx.core.events.risk.RiskSummary
|
||||||
|
import com.correx.core.validation.model.ValidationSeverity
|
||||||
|
|
||||||
|
fun ToolCallAssessment.toRiskSummary(): RiskSummary {
|
||||||
|
val errorCount = issues.count { it.severity == ValidationSeverity.ERROR }
|
||||||
|
val level = when (disposition) {
|
||||||
|
RiskAction.BLOCK -> RiskLevel.CRITICAL
|
||||||
|
RiskAction.PROMPT_USER -> RiskLevel.MEDIUM
|
||||||
|
RiskAction.PROCEED -> RiskLevel.LOW
|
||||||
|
}
|
||||||
|
return RiskSummary(
|
||||||
|
level = level,
|
||||||
|
signals = if (errorCount > 0) listOf(RiskSignal.ValidationErrors(errorCount)) else emptyList(),
|
||||||
|
recommendedAction = disposition,
|
||||||
|
rationale = issues.map { "[${it.code}] ${it.message}" },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun ToolCallAssessment.toAssessedIssues(): List<AssessedIssue> =
|
||||||
|
issues.map { AssessedIssue(it.code, it.message, it.severity.name) }
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package com.correx.core.toolintent
|
||||||
|
|
||||||
|
import com.correx.core.events.risk.RiskAction
|
||||||
|
|
||||||
|
/** Open, config-driven registry of plane-2 rules. New rules plug in here. */
|
||||||
|
class ToolCallAssessor(private val rules: List<ToolCallRule>) {
|
||||||
|
|
||||||
|
fun assess(input: ToolCallAssessmentInput): ToolCallAssessment {
|
||||||
|
val results = rules.filter { it.appliesTo(input.capabilities) }.map { it.assess(input) }
|
||||||
|
return ToolCallAssessment(
|
||||||
|
issues = results.flatMap { it.issues },
|
||||||
|
observations = results.flatMap { it.observations },
|
||||||
|
disposition = results.fold(RiskAction.PROCEED) { acc, r -> maxAction(acc, r.disposition) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun maxAction(a: RiskAction, b: RiskAction): RiskAction =
|
||||||
|
if (a.severityRank() >= b.severityRank()) a else b
|
||||||
|
|
||||||
|
private fun RiskAction.severityRank(): Int = when (this) {
|
||||||
|
RiskAction.PROCEED -> 0
|
||||||
|
RiskAction.PROMPT_USER -> 1
|
||||||
|
RiskAction.BLOCK -> 2
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package com.correx.core.toolintent
|
||||||
|
|
||||||
|
import com.correx.core.events.events.ToolCallObservation
|
||||||
|
import com.correx.core.events.events.ToolRequest
|
||||||
|
import com.correx.core.events.risk.RiskAction
|
||||||
|
import com.correx.core.tools.contract.ToolCapability
|
||||||
|
import com.correx.core.validation.model.ValidationIssue
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Plane-2 rule. Dispatches on declared tool [ToolCapability]s (never tool names),
|
||||||
|
* assesses the proposed call against bounded real-world state, and records the
|
||||||
|
* facts it observed so replay reads them back (invariant #9).
|
||||||
|
*/
|
||||||
|
interface ToolCallRule {
|
||||||
|
fun appliesTo(capabilities: Set<ToolCapability>): Boolean
|
||||||
|
fun assess(input: ToolCallAssessmentInput): ToolCallAssessment
|
||||||
|
}
|
||||||
|
|
||||||
|
data class ToolCallAssessmentInput(
|
||||||
|
val request: ToolRequest,
|
||||||
|
val capabilities: Set<ToolCapability>,
|
||||||
|
val workspace: WorkspacePolicy,
|
||||||
|
val probe: WorldProbe,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class ToolCallAssessment(
|
||||||
|
val issues: List<ValidationIssue> = emptyList(),
|
||||||
|
val observations: List<ToolCallObservation> = emptyList(),
|
||||||
|
val disposition: RiskAction = RiskAction.PROCEED,
|
||||||
|
)
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package com.correx.core.toolintent
|
||||||
|
|
||||||
|
import java.nio.file.Path
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Workspace containment policy shared with the future in-place write-seam
|
||||||
|
* (sandboxing thread). [workspaceRoot] is the only writable/allowed tree;
|
||||||
|
* [privilegedLocations] are hard-deny targets (system dirs).
|
||||||
|
*/
|
||||||
|
data class WorkspacePolicy(
|
||||||
|
val workspaceRoot: Path,
|
||||||
|
val privilegedLocations: List<Path> = emptyList(),
|
||||||
|
)
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package com.correx.core.toolintent
|
||||||
|
|
||||||
|
import java.nio.file.Files
|
||||||
|
import java.nio.file.Path
|
||||||
|
|
||||||
|
/** Abstracts filesystem observation so rules are testable and replay never re-stats. */
|
||||||
|
interface WorldProbe {
|
||||||
|
fun exists(path: Path): Boolean
|
||||||
|
|
||||||
|
/** Symlink-resolved real path if it exists; otherwise the normalized absolute path. */
|
||||||
|
fun resolveReal(path: Path): Path
|
||||||
|
}
|
||||||
|
|
||||||
|
class FileSystemWorldProbe : WorldProbe {
|
||||||
|
override fun exists(path: Path): Boolean = Files.exists(path)
|
||||||
|
|
||||||
|
override fun resolveReal(path: Path): Path =
|
||||||
|
runCatching { path.toRealPath() }.getOrElse { path.toAbsolutePath().normalize() }
|
||||||
|
}
|
||||||
+89
@@ -0,0 +1,89 @@
|
|||||||
|
package com.correx.core.toolintent.rules
|
||||||
|
|
||||||
|
import com.correx.core.events.events.ToolCallObservation
|
||||||
|
import com.correx.core.events.risk.RiskAction
|
||||||
|
import com.correx.core.toolintent.ToolCallAssessment
|
||||||
|
import com.correx.core.toolintent.ToolCallAssessmentInput
|
||||||
|
import com.correx.core.toolintent.ToolCallRule
|
||||||
|
import com.correx.core.toolintent.maxAction
|
||||||
|
import com.correx.core.tools.contract.ToolCapability
|
||||||
|
import com.correx.core.validation.model.ValidationIssue
|
||||||
|
import com.correx.core.validation.model.ValidationSeverity
|
||||||
|
import java.nio.file.Path
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Effect-based path containment. Dispatches on FILE_READ / FILE_WRITE. For every
|
||||||
|
* path-like string argument, records {path, resolved, exists, inWorkspace, privileged}
|
||||||
|
* and raises: privileged → BLOCK, outside-workspace → PROMPT_USER.
|
||||||
|
* Symlink escapes are caught because resolution goes through WorldProbe.resolveReal
|
||||||
|
* (toRealPath). Fail-closed: anything that doesn't resolve into the workspace is
|
||||||
|
* treated as outside.
|
||||||
|
*/
|
||||||
|
@Suppress("ReturnCount")
|
||||||
|
class PathContainmentRule : ToolCallRule {
|
||||||
|
|
||||||
|
override fun appliesTo(capabilities: Set<ToolCapability>): Boolean =
|
||||||
|
ToolCapability.FILE_WRITE in capabilities || ToolCapability.FILE_READ in capabilities
|
||||||
|
|
||||||
|
override fun assess(input: ToolCallAssessmentInput): ToolCallAssessment {
|
||||||
|
val workspaceReal = input.probe.resolveReal(input.workspace.workspaceRoot)
|
||||||
|
val privilegedReal = input.workspace.privilegedLocations.map { input.probe.resolveReal(it) }
|
||||||
|
|
||||||
|
val issues = mutableListOf<ValidationIssue>()
|
||||||
|
val observations = mutableListOf<ToolCallObservation>()
|
||||||
|
var disposition = RiskAction.PROCEED
|
||||||
|
|
||||||
|
for (raw in candidatePaths(input)) {
|
||||||
|
val candidate = Path.of(raw)
|
||||||
|
val resolvedInput = if (candidate.isAbsolute) candidate else input.workspace.workspaceRoot.resolve(candidate)
|
||||||
|
val resolvedReal = input.probe.resolveReal(resolvedInput)
|
||||||
|
val exists = input.probe.exists(resolvedInput)
|
||||||
|
val privileged = privilegedReal.any { resolvedReal.startsWith(it) }
|
||||||
|
val inWorkspace = resolvedReal.startsWith(workspaceReal)
|
||||||
|
|
||||||
|
observations += ToolCallObservation(
|
||||||
|
ruleCode = RULE_CODE,
|
||||||
|
facts = mapOf(
|
||||||
|
"path" to raw,
|
||||||
|
"resolved" to resolvedReal.toString(),
|
||||||
|
"exists" to exists.toString(),
|
||||||
|
"inWorkspace" to inWorkspace.toString(),
|
||||||
|
"privileged" to privileged.toString(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
when {
|
||||||
|
privileged -> {
|
||||||
|
issues += ValidationIssue(
|
||||||
|
code = "PRIVILEGED_LOCATION",
|
||||||
|
message = "Tool '${input.request.toolName}' targets privileged location: $raw",
|
||||||
|
severity = ValidationSeverity.ERROR,
|
||||||
|
)
|
||||||
|
disposition = maxAction(disposition, RiskAction.BLOCK)
|
||||||
|
}
|
||||||
|
!inWorkspace -> {
|
||||||
|
issues += ValidationIssue(
|
||||||
|
code = "PATH_OUTSIDE_WORKSPACE",
|
||||||
|
message = "Tool '${input.request.toolName}' targets path outside workspace: $raw",
|
||||||
|
severity = ValidationSeverity.WARNING,
|
||||||
|
)
|
||||||
|
disposition = maxAction(disposition, RiskAction.PROMPT_USER)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ToolCallAssessment(issues = issues, observations = observations, disposition = disposition)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun candidatePaths(input: ToolCallAssessmentInput): List<String> =
|
||||||
|
input.request.parameters.values
|
||||||
|
.filterIsInstance<String>()
|
||||||
|
.filter { it.looksLikePath() }
|
||||||
|
|
||||||
|
private fun String.looksLikePath(): Boolean =
|
||||||
|
isNotBlank() && (startsWith("/") || startsWith("~") || contains("/") || contains(".."))
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val RULE_CODE = "PATH_CONTAINMENT"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
package com.correx.core.toolintent
|
||||||
|
|
||||||
|
import com.correx.core.events.events.ToolRequest
|
||||||
|
import com.correx.core.events.risk.RiskAction
|
||||||
|
import com.correx.core.events.types.SessionId
|
||||||
|
import com.correx.core.events.types.StageId
|
||||||
|
import com.correx.core.events.types.ToolInvocationId
|
||||||
|
import com.correx.core.toolintent.rules.PathContainmentRule
|
||||||
|
import com.correx.core.tools.contract.ToolCapability
|
||||||
|
import java.nio.file.Path
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertFalse
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
class PathContainmentRuleTest {
|
||||||
|
|
||||||
|
private val workspace = Path.of("/work/project")
|
||||||
|
private val rule = PathContainmentRule()
|
||||||
|
|
||||||
|
/** Fake probe: resolveReal returns the normalized absolute path, except for the
|
||||||
|
* symlink test where a specific path is redirected to a privileged target. */
|
||||||
|
private class FakeProbe(val redirect: Map<Path, Path> = emptyMap()) : WorldProbe {
|
||||||
|
override fun exists(path: Path) = true
|
||||||
|
override fun resolveReal(path: Path): Path =
|
||||||
|
redirect[path.toAbsolutePath().normalize()] ?: path.toAbsolutePath().normalize()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun input(
|
||||||
|
pathArg: String,
|
||||||
|
probe: WorldProbe,
|
||||||
|
privileged: List<Path> = listOf(Path.of("/etc"), Path.of("/usr")),
|
||||||
|
) = ToolCallAssessmentInput(
|
||||||
|
request = ToolRequest(
|
||||||
|
ToolInvocationId("i"), SessionId("s"), StageId("st"), "file_write",
|
||||||
|
mapOf("path" to pathArg, "mode" to "0644"),
|
||||||
|
),
|
||||||
|
capabilities = setOf(ToolCapability.FILE_WRITE),
|
||||||
|
workspace = WorkspacePolicy(workspace, privileged),
|
||||||
|
probe = probe,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `applies only to file capabilities`() {
|
||||||
|
assertTrue(rule.appliesTo(setOf(ToolCapability.FILE_READ)))
|
||||||
|
assertTrue(rule.appliesTo(setOf(ToolCapability.FILE_WRITE)))
|
||||||
|
assertFalse(rule.appliesTo(setOf(ToolCapability.NETWORK_ACCESS)))
|
||||||
|
assertFalse(rule.appliesTo(emptySet()))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `in-workspace path proceeds with no issues`() {
|
||||||
|
val r = rule.assess(input("/work/project/src/A.kt", FakeProbe()))
|
||||||
|
assertEquals(RiskAction.PROCEED, r.disposition)
|
||||||
|
assertTrue(r.issues.isEmpty())
|
||||||
|
assertEquals("true", r.observations.single().facts["inWorkspace"])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `outside-workspace path prompts the user`() {
|
||||||
|
val r = rule.assess(input("/tmp/scratch.txt", FakeProbe()))
|
||||||
|
assertEquals(RiskAction.PROMPT_USER, r.disposition)
|
||||||
|
assertEquals("PATH_OUTSIDE_WORKSPACE", r.issues.single().code)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `privileged location is blocked`() {
|
||||||
|
val r = rule.assess(input("/etc/passwd", FakeProbe()))
|
||||||
|
assertEquals(RiskAction.BLOCK, r.disposition)
|
||||||
|
assertEquals("PRIVILEGED_LOCATION", r.issues.single().code)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `symlink inside workspace that resolves to privileged is blocked`() {
|
||||||
|
val link = Path.of("/work/project/evil")
|
||||||
|
val probe = FakeProbe(redirect = mapOf(link to Path.of("/etc/shadow")))
|
||||||
|
val r = rule.assess(input("/work/project/evil", probe))
|
||||||
|
assertEquals(RiskAction.BLOCK, r.disposition)
|
||||||
|
assertEquals("true", r.observations.single().facts["privileged"])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `non-path string parameters are ignored`() {
|
||||||
|
val r = rule.assess(input("0644", FakeProbe())) // not path-like; "mode" also ignored
|
||||||
|
assertEquals(RiskAction.PROCEED, r.disposition)
|
||||||
|
assertTrue(r.observations.isEmpty())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package com.correx.core.toolintent
|
||||||
|
|
||||||
|
import com.correx.core.events.events.ToolRequest
|
||||||
|
import com.correx.core.events.risk.RiskAction
|
||||||
|
import com.correx.core.events.types.SessionId
|
||||||
|
import com.correx.core.events.types.StageId
|
||||||
|
import com.correx.core.events.types.ToolInvocationId
|
||||||
|
import com.correx.core.tools.contract.ToolCapability
|
||||||
|
import java.nio.file.Path
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
|
||||||
|
class ToolCallAssessorTest {
|
||||||
|
|
||||||
|
private fun ruleReturning(action: RiskAction, applies: Boolean) = object : ToolCallRule {
|
||||||
|
override fun appliesTo(capabilities: Set<ToolCapability>) = applies
|
||||||
|
override fun assess(input: ToolCallAssessmentInput) = ToolCallAssessment(disposition = action)
|
||||||
|
}
|
||||||
|
|
||||||
|
private val input = ToolCallAssessmentInput(
|
||||||
|
request = ToolRequest(ToolInvocationId("i"), SessionId("s"), StageId("st"), "t", emptyMap()),
|
||||||
|
capabilities = setOf(ToolCapability.FILE_WRITE),
|
||||||
|
workspace = WorkspacePolicy(Path.of("/work")),
|
||||||
|
probe = FileSystemWorldProbe(),
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `folds to the strictest disposition across applicable rules`() {
|
||||||
|
val assessor = ToolCallAssessor(
|
||||||
|
listOf(
|
||||||
|
ruleReturning(RiskAction.PROCEED, applies = true),
|
||||||
|
ruleReturning(RiskAction.BLOCK, applies = true),
|
||||||
|
ruleReturning(RiskAction.PROMPT_USER, applies = true),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assertEquals(RiskAction.BLOCK, assessor.assess(input).disposition)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `non-applicable rules are skipped`() {
|
||||||
|
val assessor = ToolCallAssessor(listOf(ruleReturning(RiskAction.BLOCK, applies = false)))
|
||||||
|
assertEquals(RiskAction.PROCEED, assessor.assess(input).disposition)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,12 +1,14 @@
|
|||||||
package com.correx.core.tools
|
package com.correx.core.tools
|
||||||
|
|
||||||
import com.correx.core.events.events.StoredEvent
|
import com.correx.core.events.events.StoredEvent
|
||||||
|
import com.correx.core.events.events.ToolCallAssessedEvent
|
||||||
import com.correx.core.events.events.ToolExecutionCompletedEvent
|
import com.correx.core.events.events.ToolExecutionCompletedEvent
|
||||||
import com.correx.core.events.events.ToolExecutionFailedEvent
|
import com.correx.core.events.events.ToolExecutionFailedEvent
|
||||||
import com.correx.core.events.events.ToolExecutionRejectedEvent
|
import com.correx.core.events.events.ToolExecutionRejectedEvent
|
||||||
import com.correx.core.events.events.ToolExecutionStartedEvent
|
import com.correx.core.events.events.ToolExecutionStartedEvent
|
||||||
import com.correx.core.events.events.ToolInvocationRequestedEvent
|
import com.correx.core.events.events.ToolInvocationRequestedEvent
|
||||||
import com.correx.core.events.types.ToolInvocationId
|
import com.correx.core.events.types.ToolInvocationId
|
||||||
|
import com.correx.core.tools.state.ToolCallAssessmentRecord
|
||||||
import com.correx.core.tools.state.ToolInvocationRecord
|
import com.correx.core.tools.state.ToolInvocationRecord
|
||||||
import com.correx.core.tools.state.ToolInvocationStatus
|
import com.correx.core.tools.state.ToolInvocationStatus
|
||||||
import com.correx.core.tools.state.ToolState
|
import com.correx.core.tools.state.ToolState
|
||||||
@@ -40,6 +42,15 @@ class DefaultToolReducer : ToolReducer {
|
|||||||
is ToolExecutionRejectedEvent -> state.updateRecord(p.invocationId) {
|
is ToolExecutionRejectedEvent -> state.updateRecord(p.invocationId) {
|
||||||
it.copy(status = ToolInvocationStatus.REJECTED, completedAt = event.metadata.timestamp)
|
it.copy(status = ToolInvocationStatus.REJECTED, completedAt = event.metadata.timestamp)
|
||||||
}
|
}
|
||||||
|
is ToolCallAssessedEvent -> state.updateRecord(p.invocationId) {
|
||||||
|
it.copy(
|
||||||
|
assessment = ToolCallAssessmentRecord(
|
||||||
|
issues = p.issues,
|
||||||
|
observations = p.observations,
|
||||||
|
disposition = p.disposition,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
else -> state
|
else -> state
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package com.correx.core.tools.state
|
||||||
|
|
||||||
|
import com.correx.core.events.events.AssessedIssue
|
||||||
|
import com.correx.core.events.events.ToolCallObservation
|
||||||
|
import com.correx.core.events.risk.RiskAction
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ToolCallAssessmentRecord(
|
||||||
|
val issues: List<AssessedIssue> = emptyList(),
|
||||||
|
val observations: List<ToolCallObservation> = emptyList(),
|
||||||
|
val disposition: RiskAction,
|
||||||
|
)
|
||||||
@@ -16,5 +16,6 @@ data class ToolInvocationRecord(
|
|||||||
val status: ToolInvocationStatus,
|
val status: ToolInvocationStatus,
|
||||||
val receipt: ToolReceipt? = null,
|
val receipt: ToolReceipt? = null,
|
||||||
val requestedAt: Instant,
|
val requestedAt: Instant,
|
||||||
val completedAt: Instant? = null
|
val completedAt: Instant? = null,
|
||||||
|
val assessment: ToolCallAssessmentRecord? = null,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
package com.correx.core.tools
|
||||||
|
|
||||||
|
import com.correx.core.approvals.Tier
|
||||||
|
import com.correx.core.events.events.AssessedIssue
|
||||||
|
import com.correx.core.events.events.EventMetadata
|
||||||
|
import com.correx.core.events.events.StoredEvent
|
||||||
|
import com.correx.core.events.events.ToolCallAssessedEvent
|
||||||
|
import com.correx.core.events.events.ToolCallObservation
|
||||||
|
import com.correx.core.events.events.ToolInvocationRequestedEvent
|
||||||
|
import com.correx.core.events.events.ToolRequest
|
||||||
|
import com.correx.core.events.risk.RiskAction
|
||||||
|
import com.correx.core.events.types.EventId
|
||||||
|
import com.correx.core.events.types.SessionId
|
||||||
|
import com.correx.core.events.types.StageId
|
||||||
|
import com.correx.core.events.types.ToolInvocationId
|
||||||
|
import com.correx.core.tools.state.ToolState
|
||||||
|
import kotlinx.datetime.Clock
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertNotNull
|
||||||
|
import kotlin.test.assertNull
|
||||||
|
|
||||||
|
class ToolCallAssessmentReducerTest {
|
||||||
|
|
||||||
|
private val reducer = DefaultToolReducer()
|
||||||
|
private val inv = ToolInvocationId("inv-1")
|
||||||
|
private val session = SessionId("s-1")
|
||||||
|
private val stage = StageId("st-1")
|
||||||
|
|
||||||
|
private fun stored(payload: com.correx.core.events.events.EventPayload, seq: Long) = StoredEvent(
|
||||||
|
metadata = EventMetadata(
|
||||||
|
eventId = EventId("e-$seq"),
|
||||||
|
sessionId = session,
|
||||||
|
timestamp = Clock.System.now(),
|
||||||
|
schemaVersion = 1,
|
||||||
|
causationId = null,
|
||||||
|
correlationId = null,
|
||||||
|
),
|
||||||
|
sequence = seq,
|
||||||
|
sessionSequence = seq,
|
||||||
|
payload = payload,
|
||||||
|
)
|
||||||
|
|
||||||
|
private val requested = ToolInvocationRequestedEvent(
|
||||||
|
invocationId = inv,
|
||||||
|
sessionId = session,
|
||||||
|
stageId = stage,
|
||||||
|
toolName = "file_write",
|
||||||
|
tier = Tier.T1,
|
||||||
|
request = ToolRequest(inv, session, stage, "file_write", mapOf("path" to "/etc/passwd")),
|
||||||
|
)
|
||||||
|
|
||||||
|
private val assessed = ToolCallAssessedEvent(
|
||||||
|
invocationId = inv,
|
||||||
|
sessionId = session,
|
||||||
|
stageId = stage,
|
||||||
|
toolName = "file_write",
|
||||||
|
issues = listOf(AssessedIssue("PRIVILEGED_LOCATION", "privileged: /etc/passwd", "ERROR")),
|
||||||
|
observations = listOf(ToolCallObservation("PATH_CONTAINMENT", mapOf("privileged" to "true"))),
|
||||||
|
disposition = RiskAction.BLOCK,
|
||||||
|
timestampMs = 1L,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `assessment is reduced onto the matching invocation record`() {
|
||||||
|
var state = ToolState()
|
||||||
|
state = reducer.reduce(state, stored(requested, 0))
|
||||||
|
state = reducer.reduce(state, stored(assessed, 1))
|
||||||
|
|
||||||
|
val record = state.invocations.single()
|
||||||
|
val assessment = assertNotNull(record.assessment)
|
||||||
|
assertEquals(RiskAction.BLOCK, assessment.disposition)
|
||||||
|
assertEquals("PRIVILEGED_LOCATION", assessment.issues.single().code)
|
||||||
|
assertEquals("true", assessment.observations.single().facts["privileged"])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `assessment for unknown invocation is a no-op`() {
|
||||||
|
val state = reducer.reduce(ToolState(), stored(assessed, 0))
|
||||||
|
assertEquals(0, state.invocations.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `assessment record fields are correctly mapped from event`() {
|
||||||
|
var state = ToolState()
|
||||||
|
state = reducer.reduce(state, stored(requested, 0))
|
||||||
|
state = reducer.reduce(state, stored(assessed, 1))
|
||||||
|
|
||||||
|
val assessment = assertNotNull(state.invocations.single().assessment)
|
||||||
|
assertEquals(1, assessment.issues.size)
|
||||||
|
assertEquals(1, assessment.observations.size)
|
||||||
|
assertEquals("PATH_CONTAINMENT", assessment.observations.single().ruleCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `record without assessment has null assessment`() {
|
||||||
|
var state = ToolState()
|
||||||
|
state = reducer.reduce(state, stored(requested, 0))
|
||||||
|
assertNull(state.invocations.single().assessment)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,6 +23,7 @@ include ':core:artifacts-store'
|
|||||||
include ':core:validation'
|
include ':core:validation'
|
||||||
include ':core:approvals'
|
include ':core:approvals'
|
||||||
include ':core:tools'
|
include ':core:tools'
|
||||||
|
include ':core:toolintent'
|
||||||
include ':core:router'
|
include ':core:router'
|
||||||
include ':core:sessions'
|
include ':core:sessions'
|
||||||
include ':core:config'
|
include ':core:config'
|
||||||
|
|||||||
@@ -14,7 +14,10 @@ dependencies {
|
|||||||
testImplementation(project(":core:inference"))
|
testImplementation(project(":core:inference"))
|
||||||
testImplementation(project(":core:kernel"))
|
testImplementation(project(":core:kernel"))
|
||||||
testImplementation(project(":core:risk"))
|
testImplementation(project(":core:risk"))
|
||||||
|
testImplementation(project(":core:tools"))
|
||||||
|
testImplementation(project(":core:toolintent"))
|
||||||
testImplementation(project(":infrastructure:persistence"))
|
testImplementation(project(":infrastructure:persistence"))
|
||||||
|
testImplementation("org.jetbrains.kotlin:kotlin-test")
|
||||||
testImplementation(project(":core:artifacts"))
|
testImplementation(project(":core:artifacts"))
|
||||||
testImplementation(project(":core:artifacts-store"))
|
testImplementation(project(":core:artifacts-store"))
|
||||||
testImplementation(project(":testing:fixtures"))
|
testImplementation(project(":testing:fixtures"))
|
||||||
|
|||||||
@@ -0,0 +1,277 @@
|
|||||||
|
import com.correx.core.approvals.ApprovalProjector
|
||||||
|
import com.correx.core.approvals.DefaultApprovalReducer
|
||||||
|
import com.correx.core.approvals.DefaultApprovalRepository
|
||||||
|
import com.correx.core.approvals.Tier
|
||||||
|
import com.correx.core.approvals.domain.DefaultApprovalEngine
|
||||||
|
import com.correx.core.artifacts.DefaultArtifactReducer
|
||||||
|
import com.correx.core.events.events.ApprovalRequestedEvent
|
||||||
|
import com.correx.core.events.events.ToolCallAssessedEvent
|
||||||
|
import com.correx.core.events.events.ToolExecutionRejectedEvent
|
||||||
|
import com.correx.core.events.events.ToolRequest
|
||||||
|
import com.correx.core.events.execution.RetryPolicy
|
||||||
|
import com.correx.core.events.risk.RiskAction
|
||||||
|
import com.correx.core.events.types.ProviderId
|
||||||
|
import com.correx.core.events.types.SessionId
|
||||||
|
import com.correx.core.events.types.StageId
|
||||||
|
import com.correx.core.events.types.TransitionId
|
||||||
|
import com.correx.core.inference.CapabilityScore
|
||||||
|
import com.correx.core.inference.FinishReason
|
||||||
|
import com.correx.core.inference.InferenceProvider
|
||||||
|
import com.correx.core.inference.InferenceRepository
|
||||||
|
import com.correx.core.inference.InferenceRequest
|
||||||
|
import com.correx.core.inference.InferenceResponse
|
||||||
|
import com.correx.core.inference.InferenceRouter
|
||||||
|
import com.correx.core.inference.InferenceState
|
||||||
|
import com.correx.core.inference.ModelCapability
|
||||||
|
import com.correx.core.inference.ProviderHealth
|
||||||
|
import com.correx.core.inference.Tokenizer
|
||||||
|
import com.correx.core.inference.ToolCallFunction
|
||||||
|
import com.correx.core.inference.ToolCallRequest
|
||||||
|
import com.correx.core.inference.TokenUsage
|
||||||
|
import com.correx.core.kernel.orchestration.DefaultOrchestrationReducer
|
||||||
|
import com.correx.core.kernel.orchestration.DefaultSessionOrchestrator
|
||||||
|
import com.correx.core.kernel.orchestration.OrchestrationConfig
|
||||||
|
import com.correx.core.kernel.orchestration.OrchestrationProjector
|
||||||
|
import com.correx.core.kernel.orchestration.OrchestrationRepository
|
||||||
|
import com.correx.core.kernel.orchestration.OrchestratorEngines
|
||||||
|
import com.correx.core.kernel.orchestration.OrchestratorRepositories
|
||||||
|
import com.correx.core.kernel.retry.DefaultRetryCoordinator
|
||||||
|
import com.correx.core.risk.DefaultRiskAssessor
|
||||||
|
import com.correx.core.sessions.DefaultSessionReducer
|
||||||
|
import com.correx.core.sessions.DefaultSessionRepository
|
||||||
|
import com.correx.core.sessions.SessionProjector
|
||||||
|
import com.correx.core.sessions.projections.replay.DefaultEventReplayer
|
||||||
|
import com.correx.core.sessions.projections.replay.EventReplayer
|
||||||
|
import com.correx.core.toolintent.ToolCallAssessment
|
||||||
|
import com.correx.core.toolintent.ToolCallAssessmentInput
|
||||||
|
import com.correx.core.toolintent.ToolCallAssessor
|
||||||
|
import com.correx.core.toolintent.ToolCallRule
|
||||||
|
import com.correx.core.toolintent.WorkspacePolicy
|
||||||
|
import com.correx.core.tools.contract.Tool
|
||||||
|
import com.correx.core.tools.contract.ToolCapability
|
||||||
|
import com.correx.core.tools.contract.ToolExecutor
|
||||||
|
import com.correx.core.tools.contract.ToolResult
|
||||||
|
import com.correx.core.tools.contract.ValidationResult
|
||||||
|
import com.correx.core.tools.registry.ToolRegistry
|
||||||
|
import com.correx.core.transitions.graph.StageConfig
|
||||||
|
import com.correx.core.transitions.graph.TransitionEdge
|
||||||
|
import com.correx.core.transitions.graph.WorkflowGraph
|
||||||
|
import com.correx.core.transitions.resolution.DefaultTransitionResolver
|
||||||
|
import com.correx.core.validation.pipeline.ValidationPipeline
|
||||||
|
import com.correx.infrastructure.persistence.InMemoryEventStore
|
||||||
|
import com.correx.infrastructure.persistence.artifact.LiveArtifactRepository
|
||||||
|
import com.correx.testing.contracts.fixtures.artifactstore.NoopArtifactStore
|
||||||
|
import com.correx.testing.fixtures.context.ContextFixtures
|
||||||
|
import com.correx.testing.fixtures.cyclePolicyMissingValidator
|
||||||
|
import com.correx.testing.fixtures.inference.MockTokenizer
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import kotlinx.coroutines.withTimeout
|
||||||
|
import kotlinx.coroutines.yield
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
import kotlinx.serialization.json.buildJsonObject
|
||||||
|
import java.nio.file.Path
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertNotNull
|
||||||
|
import kotlin.test.assertNull
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
@Suppress("LongMethod")
|
||||||
|
class ToolCallGateTest {
|
||||||
|
|
||||||
|
private inner class FakeFileWriteTool(override val tier: Tier = Tier.T1) : Tool {
|
||||||
|
override val name = "file_write"
|
||||||
|
override val description = "fake file write"
|
||||||
|
override val parametersSchema: JsonObject = buildJsonObject {}
|
||||||
|
override val requiredCapabilities: Set<ToolCapability> = setOf(ToolCapability.FILE_WRITE)
|
||||||
|
override fun validateRequest(request: ToolRequest): ValidationResult = ValidationResult.Valid
|
||||||
|
}
|
||||||
|
|
||||||
|
private inner class FakeToolRegistry(private val tool: Tool) : ToolRegistry {
|
||||||
|
override fun resolve(name: String): Tool? = if (name == tool.name) tool else null
|
||||||
|
override fun all(): List<Tool> = listOf(tool)
|
||||||
|
}
|
||||||
|
|
||||||
|
private inner class RecordingExecutor : ToolExecutor {
|
||||||
|
val executeCalled = AtomicBoolean(false)
|
||||||
|
override suspend fun execute(request: ToolRequest): ToolResult {
|
||||||
|
executeCalled.set(true)
|
||||||
|
return ToolResult.Success(invocationId = request.invocationId, output = "ok", exitCode = 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private inner class ToolCallingProvider(private val toolName: String) : InferenceProvider {
|
||||||
|
override val id = ProviderId("tool-caller")
|
||||||
|
override val name = "tool-caller"
|
||||||
|
override val tokenizer: Tokenizer = MockTokenizer()
|
||||||
|
private var callCount = 0
|
||||||
|
|
||||||
|
override suspend fun infer(request: InferenceRequest): InferenceResponse {
|
||||||
|
callCount++
|
||||||
|
return if (callCount == 1) {
|
||||||
|
InferenceResponse(
|
||||||
|
requestId = request.requestId,
|
||||||
|
text = "",
|
||||||
|
finishReason = FinishReason.ToolCall,
|
||||||
|
tokensUsed = TokenUsage(10, 5),
|
||||||
|
latencyMs = 0,
|
||||||
|
toolCalls = listOf(
|
||||||
|
ToolCallRequest(
|
||||||
|
id = "tc-1",
|
||||||
|
function = ToolCallFunction(name = toolName, arguments = "{}"),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
InferenceResponse(
|
||||||
|
requestId = request.requestId,
|
||||||
|
text = "done",
|
||||||
|
finishReason = FinishReason.Stop,
|
||||||
|
tokensUsed = TokenUsage(10, 5),
|
||||||
|
latencyMs = 0,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun healthCheck(): ProviderHealth = ProviderHealth.Healthy
|
||||||
|
override fun capabilities(): Set<CapabilityScore> = setOf(CapabilityScore(ModelCapability.General, 1.0))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ruleReturning(action: RiskAction): ToolCallRule = object : ToolCallRule {
|
||||||
|
override fun appliesTo(capabilities: Set<ToolCapability>) = true
|
||||||
|
override fun assess(input: ToolCallAssessmentInput) = ToolCallAssessment(disposition = action)
|
||||||
|
}
|
||||||
|
|
||||||
|
private val workspace = Path.of("/work")
|
||||||
|
|
||||||
|
private fun buildOrchestrator(
|
||||||
|
executor: ToolExecutor,
|
||||||
|
tool: Tool,
|
||||||
|
assessorRule: ToolCallRule?,
|
||||||
|
): Pair<DefaultSessionOrchestrator, InMemoryEventStore> {
|
||||||
|
val eventStore = InMemoryEventStore()
|
||||||
|
val artifactStore = NoopArtifactStore()
|
||||||
|
val toolRegistry = FakeToolRegistry(tool)
|
||||||
|
|
||||||
|
val assessor = assessorRule?.let { ToolCallAssessor(listOf(it)) }
|
||||||
|
val policy = if (assessor != null) WorkspacePolicy(workspace) else null
|
||||||
|
|
||||||
|
val provider = ToolCallingProvider(tool.name)
|
||||||
|
val inferenceRouter = object : InferenceRouter {
|
||||||
|
override suspend fun route(stageId: StageId, requiredCapabilities: Set<ModelCapability>): InferenceProvider =
|
||||||
|
provider
|
||||||
|
}
|
||||||
|
|
||||||
|
val approvalRepository = DefaultApprovalRepository(
|
||||||
|
DefaultEventReplayer(eventStore, ApprovalProjector(DefaultApprovalReducer())),
|
||||||
|
)
|
||||||
|
|
||||||
|
val repositories = OrchestratorRepositories(
|
||||||
|
eventStore = eventStore,
|
||||||
|
inferenceRepository = InferenceRepository(object : EventReplayer<InferenceState> {
|
||||||
|
override fun rebuild(sessionId: SessionId) = InferenceState()
|
||||||
|
}),
|
||||||
|
orchestrationRepository = OrchestrationRepository(
|
||||||
|
DefaultEventReplayer(eventStore, OrchestrationProjector(DefaultOrchestrationReducer())),
|
||||||
|
),
|
||||||
|
sessionRepository = DefaultSessionRepository(
|
||||||
|
DefaultEventReplayer(eventStore, SessionProjector(DefaultSessionReducer())),
|
||||||
|
),
|
||||||
|
artifactRepository = LiveArtifactRepository(eventStore, DefaultArtifactReducer()),
|
||||||
|
approvalRepository = approvalRepository,
|
||||||
|
)
|
||||||
|
|
||||||
|
val engines = OrchestratorEngines(
|
||||||
|
transitionResolver = DefaultTransitionResolver { _, _ -> true },
|
||||||
|
contextPackBuilder = ContextFixtures.simpleBuilder(),
|
||||||
|
inferenceRouter = inferenceRouter,
|
||||||
|
validationPipeline = ValidationPipeline(validators = listOf(cyclePolicyMissingValidator())),
|
||||||
|
approvalEngine = DefaultApprovalEngine(),
|
||||||
|
riskAssessor = DefaultRiskAssessor(),
|
||||||
|
toolExecutor = executor,
|
||||||
|
toolRegistry = toolRegistry,
|
||||||
|
toolCallAssessor = assessor,
|
||||||
|
workspacePolicy = policy,
|
||||||
|
)
|
||||||
|
|
||||||
|
val orchestrator = DefaultSessionOrchestrator(
|
||||||
|
repositories = repositories,
|
||||||
|
engines = engines,
|
||||||
|
retryCoordinator = DefaultRetryCoordinator(eventStore),
|
||||||
|
artifactStore = artifactStore,
|
||||||
|
)
|
||||||
|
|
||||||
|
return Pair(orchestrator, eventStore)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun singleStageGraph(allowedTools: Set<String> = setOf("file_write")): WorkflowGraph =
|
||||||
|
WorkflowGraph(
|
||||||
|
id = "gate-test",
|
||||||
|
stages = mapOf(
|
||||||
|
StageId("A") to StageConfig(allowedTools = allowedTools),
|
||||||
|
),
|
||||||
|
transitions = setOf(
|
||||||
|
TransitionEdge(TransitionId("t1"), StageId("A"), StageId("done"), condition = { true }),
|
||||||
|
),
|
||||||
|
start = StageId("A"),
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `BLOCK rule prevents executor from being called and emits rejected event`(): Unit = runBlocking {
|
||||||
|
val executor = RecordingExecutor()
|
||||||
|
val (orchestrator, eventStore) = buildOrchestrator(executor, FakeFileWriteTool(), ruleReturning(RiskAction.BLOCK))
|
||||||
|
val sessionId = SessionId("gate-block")
|
||||||
|
val config = OrchestrationConfig(retryPolicy = RetryPolicy(maxAttempts = 1, backoffMs = 0))
|
||||||
|
|
||||||
|
orchestrator.run(sessionId, singleStageGraph(), config)
|
||||||
|
|
||||||
|
val events = eventStore.read(sessionId)
|
||||||
|
assertTrue(!executor.executeCalled.get(), "executor must NOT be called when BLOCK")
|
||||||
|
assertNotNull(events.find { it.payload is ToolCallAssessedEvent }, "ToolCallAssessedEvent must be emitted")
|
||||||
|
assertNotNull(events.find { it.payload is ToolExecutionRejectedEvent }, "ToolExecutionRejectedEvent must be emitted")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `PROMPT_USER rule on T2 tool triggers approval with plane2 risk summary`(): Unit = runBlocking {
|
||||||
|
val executor = RecordingExecutor()
|
||||||
|
// Use T2 so the engine does not auto-approve (PROMPT mode auto-approves up to T1 only)
|
||||||
|
val (orchestrator, eventStore) = buildOrchestrator(
|
||||||
|
executor, FakeFileWriteTool(Tier.T2), ruleReturning(RiskAction.PROMPT_USER),
|
||||||
|
)
|
||||||
|
val sessionId = SessionId("gate-prompt")
|
||||||
|
val config = OrchestrationConfig(retryPolicy = RetryPolicy(maxAttempts = 1, backoffMs = 0))
|
||||||
|
|
||||||
|
// Run in background; orchestrator will pause waiting for approval
|
||||||
|
val job = launch { orchestrator.run(sessionId, singleStageGraph(), config) }
|
||||||
|
|
||||||
|
// Wait for ApprovalRequestedEvent
|
||||||
|
withTimeout(5_000) {
|
||||||
|
while (eventStore.read(sessionId).none { it.payload is ApprovalRequestedEvent }) {
|
||||||
|
yield()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val approval = eventStore.read(sessionId).firstNotNullOfOrNull { it.payload as? ApprovalRequestedEvent }
|
||||||
|
assertNotNull(approval, "ApprovalRequestedEvent must be emitted for PROMPT_USER plane-2 rule on T1 tool")
|
||||||
|
assertEquals(RiskAction.PROMPT_USER, approval?.riskSummary?.recommendedAction)
|
||||||
|
|
||||||
|
job.cancel()
|
||||||
|
job.join()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `null assessor means executor is called normally (regression guard)`(): Unit = runBlocking {
|
||||||
|
val executor = RecordingExecutor()
|
||||||
|
val (orchestrator, eventStore) = buildOrchestrator(executor, FakeFileWriteTool(), assessorRule = null)
|
||||||
|
val sessionId = SessionId("gate-null")
|
||||||
|
val config = OrchestrationConfig(retryPolicy = RetryPolicy(maxAttempts = 1, backoffMs = 0))
|
||||||
|
|
||||||
|
orchestrator.run(sessionId, singleStageGraph(), config)
|
||||||
|
|
||||||
|
val events = eventStore.read(sessionId)
|
||||||
|
assertTrue(executor.executeCalled.get(), "executor must be called when assessor is null")
|
||||||
|
assertNull(events.find { it.payload is ToolCallAssessedEvent }, "ToolCallAssessedEvent must NOT be emitted when assessor is null")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user