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:
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user