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(':core:router')
|
||||
implementation project(':core:tools')
|
||||
implementation project(':core:toolintent')
|
||||
implementation project(':infrastructure:tools')
|
||||
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.rules.CycleExitRule
|
||||
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.artifactscas.DefaultMaterializingArtifactWriter
|
||||
import com.correx.infrastructure.inference.DefaultProviderRegistry
|
||||
@@ -104,6 +107,14 @@ fun main() {
|
||||
|
||||
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 engines = OrchestratorEngines(
|
||||
transitionResolver = DefaultTransitionResolver { condition, ctx -> condition.evaluate(ctx) },
|
||||
@@ -125,6 +136,8 @@ fun main() {
|
||||
promptResolver = { path -> InfrastructureModule.createPromptLoader().load(path) },
|
||||
toolRegistry = toolRegistry,
|
||||
toolExecutor = toolExecutor,
|
||||
toolCallAssessor = toolCallAssessor,
|
||||
workspacePolicy = workspacePolicy,
|
||||
)
|
||||
val orchestrator = DefaultSessionOrchestrator(
|
||||
repositories = repositories,
|
||||
|
||||
@@ -299,6 +299,9 @@ object ConfigLoader {
|
||||
fileReadEnabled = fileReadEnabled,
|
||||
fileWriteEnabled = fileWriteEnabled,
|
||||
fileEditEnabled = fileEditEnabled,
|
||||
workspaceRoot = asString(toolsSection["workspace_root"], ""),
|
||||
privilegedLocations = asStringList(toolsSection["privileged_locations"])
|
||||
.ifEmpty { ToolsConfig.DEFAULT_PRIVILEGED_LOCATIONS },
|
||||
)
|
||||
|
||||
val providers = providersList.mapNotNull { providerMap ->
|
||||
|
||||
@@ -39,7 +39,16 @@ data class ToolsConfig(
|
||||
val fileReadEnabled: Boolean = true,
|
||||
val fileWriteEnabled: 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
|
||||
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-store')
|
||||
implementation project(':core:risk')
|
||||
implementation project(':core:toolintent')
|
||||
implementation "org.slf4j:slf4j-api:2.0.16"
|
||||
}
|
||||
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.inference.InferenceRouter
|
||||
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.registry.ToolRegistry
|
||||
import com.correx.core.transitions.evaluation.PromptResolver
|
||||
@@ -20,4 +24,7 @@ data class OrchestratorEngines(
|
||||
val promptResolver: PromptResolver = PromptResolver { "" },
|
||||
val toolExecutor: ToolExecutor? = 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.ApprovalRequestedEvent
|
||||
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.ArtifactValidatingEvent
|
||||
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.ToolInvocationRequestedEvent
|
||||
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.WorkflowCompletedEvent
|
||||
import com.correx.core.events.events.WorkflowFailedEvent
|
||||
@@ -128,6 +137,9 @@ abstract class SessionOrchestrator(
|
||||
private val promptResolver: PromptResolver = engines.promptResolver
|
||||
private val toolExecutor: ToolExecutor? = engines.toolExecutor
|
||||
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
|
||||
internal val orchestrationRepository: OrchestrationRepository = repositories.orchestrationRepository
|
||||
protected open val tokenizer: Tokenizer? = null
|
||||
@@ -365,7 +377,46 @@ abstract class SessionOrchestrator(
|
||||
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
|
||||
} else {
|
||||
val approvalState = approvalRepository.getApprovalState(sessionId)
|
||||
@@ -433,6 +484,7 @@ abstract class SessionOrchestrator(
|
||||
tier = tier,
|
||||
validationReportId = domainRequest.validationReportId,
|
||||
riskSummaryId = null,
|
||||
riskSummary = plane2Risk,
|
||||
sessionId = sessionId,
|
||||
stageId = stageId,
|
||||
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(
|
||||
responseFormat: ResponseFormat,
|
||||
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
|
||||
|
||||
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.ToolExecutionFailedEvent
|
||||
import com.correx.core.events.events.ToolExecutionRejectedEvent
|
||||
import com.correx.core.events.events.ToolExecutionStartedEvent
|
||||
import com.correx.core.events.events.ToolInvocationRequestedEvent
|
||||
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.ToolInvocationStatus
|
||||
import com.correx.core.tools.state.ToolState
|
||||
@@ -40,6 +42,15 @@ class DefaultToolReducer : ToolReducer {
|
||||
is ToolExecutionRejectedEvent -> state.updateRecord(p.invocationId) {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 receipt: ToolReceipt? = null,
|
||||
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:approvals'
|
||||
include ':core:tools'
|
||||
include ':core:toolintent'
|
||||
include ':core:router'
|
||||
include ':core:sessions'
|
||||
include ':core:config'
|
||||
|
||||
@@ -14,7 +14,10 @@ dependencies {
|
||||
testImplementation(project(":core:inference"))
|
||||
testImplementation(project(":core:kernel"))
|
||||
testImplementation(project(":core:risk"))
|
||||
testImplementation(project(":core:tools"))
|
||||
testImplementation(project(":core:toolintent"))
|
||||
testImplementation(project(":infrastructure:persistence"))
|
||||
testImplementation("org.jetbrains.kotlin:kotlin-test")
|
||||
testImplementation(project(":core:artifacts"))
|
||||
testImplementation(project(":core:artifacts-store"))
|
||||
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