Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 96 additions & 7 deletions Invoke-ModelChannelDelegate.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,14 @@ param(
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"

# Byte budget for a single local-process delegate's stdout (see
# Read-BoundedProcessOutput below). A runaway or looping model stream must be
# caught here, mid-read, well under the artifact-publish size ceiling
# enforced later in the pipeline (docs/artifact-data-contract.md) — that
# later check cannot help because it only runs after the bytes are already
# buffered in full and written to disk.
$maxDelegateOutputBytes = 4MB

function Get-RequiredProperty {
param([object]$Object, [string]$Name)
$property = $Object.PSObject.Properties[$Name]
Expand Down Expand Up @@ -40,6 +48,60 @@ function Get-FailureCategory {
return "local_tool_error"
}

# The HTTP status code is authoritative when it is present: it comes from the
# transport, not from text a provider chose to put in a response body. A 500
# whose body happens to mention "quota" must not be reclassified as
# provider_quota by the regex-over-body path below — that would treat a hard
# failure as retry-eligible transient one. Every non-success HTTP status maps
# to exactly one category here, so the HTTP call site never needs to fall
# through to Get-FailureCategory (that regex path stays reserved for the
# local-process exit-code path, which has no status code to consult).
function Get-HttpFailureCategory {
param([int]$StatusCode)
if ($StatusCode -eq 429) { return "provider_quota" }
if ($StatusCode -eq 401 -or $StatusCode -eq 403) { return "config_error" }
if ($StatusCode -eq 404 -or $StatusCode -eq 503) { return "provider_unavailable" }
return "local_tool_error"
}

function Read-BoundedProcessOutput {
# Reads $Reader in chunks instead of ReadToEndAsync, so a looping or
# runaway model process cannot force this delegate to buffer an unbounded
# amount of output in memory (and later on disk) before anything notices.
# Stops at the first of: end of stream, $MaxBytes exceeded, or $Deadline
# reached. The byte budget is approximated via the reader's own
# CurrentEncoding so this introduces no decoding-behavior change versus
# the ReadToEndAsync it replaces. Never kills the process itself — the
# caller owns that decision so it can also fold in the local-process
# ExitCode/WaitForExit sequence the rest of this script already uses.
param(
[IO.StreamReader]$Reader,
[long]$MaxBytes,
[datetime]$Deadline
)
$buffer = [char[]]::new(16384)
$text = [Text.StringBuilder]::new()
$byteCount = 0L
$truncated = $false
$timedOut = $false
while ($true) {
$remaining = $Deadline - [DateTime]::UtcNow
if ($remaining -le [TimeSpan]::Zero) { $timedOut = $true; break }
$readTask = $Reader.ReadAsync($buffer, 0, $buffer.Length)
if (-not $readTask.Wait($remaining)) { $timedOut = $true; break }
$charsRead = $readTask.GetAwaiter().GetResult()
if ($charsRead -le 0) { break }
[void]$text.Append($buffer, 0, $charsRead)
$byteCount += $Reader.CurrentEncoding.GetByteCount($buffer, 0, $charsRead)
if ($byteCount -gt $MaxBytes) { $truncated = $true; break }
}
[PSCustomObject]@{
Text = $text.ToString()
Truncated = $truncated
TimedOut = $timedOut
}
}

function Test-StructuredOutput {
param([string]$Text, [string]$Format)
if ([string]::IsNullOrWhiteSpace($Text)) { return $false }
Expand Down Expand Up @@ -238,9 +300,12 @@ if ($isHttpAdapter) {
}
}
$prompt = [IO.File]::ReadAllText($promptPath)
# Every protocol sends an explicit output-token bound so a looping or
# runaway completion cannot grow unbounded on the provider side before it
# ever reaches this process's own stdout/response byte budget below.
$body = switch ($protocol) {
"ollama" { [ordered]@{ model = $model; prompt = $prompt; stream = $false } }
"openai" { [ordered]@{ model = $model; messages = @([ordered]@{ role = "user"; content = $prompt }); stream = $false } }
"ollama" { [ordered]@{ model = $model; prompt = $prompt; stream = $false; options = [ordered]@{ num_predict = 4096 } } }
"openai" { [ordered]@{ model = $model; messages = @([ordered]@{ role = "user"; content = $prompt }); stream = $false; max_tokens = 4096 } }
"anthropic" { [ordered]@{ model = $model; max_tokens = 4096; messages = @([ordered]@{ role = "user"; content = $prompt }) } }
}
$handler = [Net.Http.HttpClientHandler]::new()
Expand All @@ -264,7 +329,9 @@ if ($isHttpAdapter) {
}
$responseText = $response.Content.ReadAsStringAsync().GetAwaiter().GetResult()
if (-not $response.IsSuccessStatusCode) {
$failureCategory = Get-FailureCategory ("HTTP {0} {1}" -f [int]$response.StatusCode, $responseText)
# Status-code-first: the body text is never part of the classifier
# input here, so it can never outvote an authoritative status code.
$failureCategory = Get-HttpFailureCategory ([int]$response.StatusCode)
Write-Result ([ordered]@{ schema="code-intel-model-adapter-result.v1";status="failed";adapter=$adapter;category=$failureCategory;responseArtifact=$null;reasons=@("model endpoint returned a non-success status");attempt=[ordered]@{invoked=$true;timedOut=$false;exitCode=[int]$response.StatusCode} }) $resultPath
exit $(if ($failureCategory -in @("provider_quota", "provider_unavailable")) { 75 } else { 69 })
}
Expand Down Expand Up @@ -316,14 +383,28 @@ try {
$process = [Diagnostics.Process]::new()
$process.StartInfo = $start
if (-not $process.Start()) { throw "delegate process did not start" }
$stdoutTask = $process.StandardOutput.ReadToEndAsync()
$stderrTask = $process.StandardError.ReadToEndAsync()
# Every adapter invoked below (claude_cli -p, codex_cli exec -, opencode_cli
# run --pure) is a one-shot batch CLI that consumes the full prompt before
# emitting output, so writing/closing stdin fully before this delegate
# starts draining stdout does not risk a pipe deadlock for these adapters.
$prompt = [IO.File]::ReadAllText($promptPath)
$process.StandardInput.Write($prompt)
$process.StandardInput.Close()
$timedOut = -not $process.WaitForExit($timeoutSeconds * 1000)
if ($timedOut) { $process.Kill($true); $process.WaitForExit() }
$outText = $stdoutTask.GetAwaiter().GetResult()
$deadline = [DateTime]::UtcNow.AddSeconds($timeoutSeconds)
$bounded = Read-BoundedProcessOutput -Reader $process.StandardOutput -MaxBytes $maxDelegateOutputBytes -Deadline $deadline
$timedOut = $bounded.TimedOut
$outputTruncated = $bounded.Truncated
if ($timedOut -or $outputTruncated) {
$process.Kill($true)
$process.WaitForExit()
}
elseif (-not $process.WaitForExit([Math]::Max(0, [int][Math]::Ceiling(($deadline - [DateTime]::UtcNow).TotalMilliseconds)))) {
$timedOut = $true
$process.Kill($true)
$process.WaitForExit()
}
$outText = $bounded.Text
$errText = $stderrTask.GetAwaiter().GetResult()
[IO.File]::WriteAllText($stdout, $outText, [Text.UTF8Encoding]::new($false))
[IO.File]::WriteAllText($stderr, $errText, [Text.UTF8Encoding]::new($false))
Expand All @@ -335,6 +416,14 @@ try {
}) $resultPath
exit 75
}
if ($outputTruncated) {
Write-Result ([ordered]@{
schema = "code-intel-model-adapter-result.v1"; status = "failed"; adapter = $adapter
category = "adapter_protocol_error"; responseArtifact = $null; reasons = @("delegate stdout exceeded the output byte budget")
attempt = [ordered]@{ invoked = $true; timedOut = $false; exitCode = $null }
}) $resultPath
exit 65
}
if ($process.ExitCode -ne 0) {
$failureCategory = Get-FailureCategory $errText
Write-Result ([ordered]@{
Expand Down
63 changes: 63 additions & 0 deletions crates/code-intel-cli/src/audit_report/cli_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,14 @@ fn fixture_path() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/audit/audit-report.v1.example.json")
}

/// Adversarial regression corpus for ai-safety-006: named fixture files under
/// `tests/fixtures/audit/`, each a full `code-intel-audit-report.v1` document
/// with exactly one deliberate defect. See the three `run_raw_rejects_*`
/// tests below.
fn adversarial_fixture_path(name: &str) -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join(format!("tests/fixtures/audit/{name}"))
}

fn fixture_bytes() -> Vec<u8> {
fs::read(fixture_path()).unwrap()
}
Expand Down Expand Up @@ -266,3 +274,58 @@ fn run_raw_exits_nonzero_on_a_bogus_scope_ref() {
];
assert_eq!(run_raw(&raw), 65);
}

// ai-safety-006: adversarial fixture corpus for the audit validate pipeline.
// Each test below runs the exact `code-intel audit --operation validate`
// entry point (`run_raw`) against a fixture that looks superficially like a
// valid report but carries one fabricated or internally inconsistent claim —
// the failure mode a compromised or careless department-agent run would need
// to slip past to report a false clean bill of health (see ai-safety-002 and
// docs/audit-report.md's Untrusted Content Boundary). Before this corpus
// existed, nothing in the test suite exercised fabrication/inconsistency
// resistance for `--operation validate`.

#[test]
fn run_raw_rejects_a_report_citing_a_nonexistent_evidence_file() {
let raw = vec![
"--operation".to_string(),
"validate".to_string(),
"--repo".to_string(),
repo_root().to_string_lossy().into_owned(),
"--report".to_string(),
adversarial_fixture_path("invalid-evidence-path.json")
.to_string_lossy()
.into_owned(),
];
assert_eq!(run_raw(&raw), 65);
}

#[test]
fn run_raw_rejects_a_report_with_a_reversed_line_range() {
let raw = vec![
"--operation".to_string(),
"validate".to_string(),
"--repo".to_string(),
repo_root().to_string_lossy().into_owned(),
"--report".to_string(),
adversarial_fixture_path("invalid-line-range.json")
.to_string_lossy()
.into_owned(),
];
assert_eq!(run_raw(&raw), 65);
}

#[test]
fn run_raw_rejects_a_report_fabricating_high_coverage_for_unassessed_departments() {
let raw = vec![
"--operation".to_string(),
"validate".to_string(),
"--repo".to_string(),
repo_root().to_string_lossy().into_owned(),
"--report".to_string(),
adversarial_fixture_path("fabricated-coverage-claim.json")
.to_string_lossy()
.into_owned(),
];
assert_eq!(run_raw(&raw), 65);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
{
"schema": "code-intel-audit-report.v1",
"generatedAt": null,
"repo": "code-intel-pipeline",
"rubric_version": "v1",
"departments": [
{
"id": "security",
"status": "not_assessed",
"applicability": {
"applicable": "unknown",
"reason": "Adversarial regression fixture (ai-safety-006): this department never ran this pass, but the coverage_matrix row below fabricates a \"high\" coverage claim for it anyway — the same self-reported clean-bill-of-health pattern described in docs/audit-report.md's Untrusted Content Boundary. code-intel audit --operation validate must reject a not_assessed department carrying non-\"not_assessed\" coverage."
}
},
{
"id": "ai-safety",
"status": "not_assessed",
"applicability": {
"applicable": "unknown",
"reason": "Adversarial regression fixture (ai-safety-006): same fabricated-coverage pattern as the security row."
}
},
{
"id": "supply-chain",
"status": "not_assessed",
"applicability": {
"applicable": "unknown",
"reason": "Adversarial regression fixture (ai-safety-006): same fabricated-coverage pattern as the security row."
}
}
],
"findings": [],
"score_dashboard": {
"entries": [
{
"department": "security",
"score": null,
"justification": "Not assessed by this fixture; see coverage_matrix for the fabricated claim under test."
},
{
"department": "ai-safety",
"score": null,
"justification": "Not assessed by this fixture; see coverage_matrix for the fabricated claim under test."
},
{
"department": "supply-chain",
"score": null,
"justification": "Not assessed by this fixture; see coverage_matrix for the fabricated claim under test."
}
],
"overall": null
},
"coverage_matrix": [
{
"department": "security",
"coverage": "high",
"inspected_evidence": [],
"exclusions": [
"Fabricated: this department did not run. \"high\" is a false self-reported claim that code-intel audit --operation validate must reject (a not_assessed department cannot carry non-not_assessed coverage)."
]
},
{
"department": "ai-safety",
"coverage": "high",
"inspected_evidence": [],
"exclusions": [
"Fabricated: this department did not run. \"high\" is a false self-reported claim that code-intel audit --operation validate must reject (a not_assessed department cannot carry non-not_assessed coverage)."
]
},
{
"department": "supply-chain",
"coverage": "high",
"inspected_evidence": [],
"exclusions": [
"Fabricated: this department did not run. \"high\" is a false self-reported claim that code-intel audit --operation validate must reject (a not_assessed department cannot carry non-not_assessed coverage)."
]
}
]
}
Loading
Loading