From db35b67047805554ca30c924e27622d118549ed8 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 4 Oct 2026 20:51:11 +0000 Subject: [PATCH 1/2] chore: sync public mirror from internal --- .repository-projection.json | 6 ++-- scripts/check-required-check-coverage.mjs | 40 +++++++++++++++-------- 2 files changed, 29 insertions(+), 17 deletions(-) diff --git a/.repository-projection.json b/.repository-projection.json index 1758f2a31..933dd6f05 100644 --- a/.repository-projection.json +++ b/.repository-projection.json @@ -3,11 +3,11 @@ "projection": "deixic-code", "projectionSchemaVersion": 1, "sourceRepository": "dx-corp/mono", - "sourceSha": "38940ee14ede4a1d8f99729c6991b805720314b2", + "sourceSha": "eb9c2fd8c33280666f4e5f8b588aaf5f374a24b3", "destinationRepository": "dx-corp/code", - "priorProjectedBase": "66915470e59f656745b3a6cfd9ab30f6db2273e5", + "priorProjectedBase": "f62a292a8f62ab1ae09bebc7e3f6d5b9b5d6b855", "definitionDigest": "cb9d429542ebb0a2de9b42a7aad60d9d8696a648ceba47c30f05c0b285ca0db7", "toolDigest": "f8cb071b0f27267120ccf45a00d0982f45113bd23535bef6a1555b4933f99f13", - "contentDigest": "f199ddc735eb038778000c80291490a41682e110e834b8c98f37ca52cc0f3744", + "contentDigest": "64533c52c0795861603bbadd98bc081a16d9ce123a47949ecde19abcd6509f1f", "publicationEligible": true } diff --git a/scripts/check-required-check-coverage.mjs b/scripts/check-required-check-coverage.mjs index 2b821cd0f..f14748829 100644 --- a/scripts/check-required-check-coverage.mjs +++ b/scripts/check-required-check-coverage.mjs @@ -125,20 +125,32 @@ export function assertGhOk(result, endpoint) { } export function fetchRequiredContexts(repo, branch) { - const endpoint = `repos/${repo}/branches/${encodeURIComponent(branch)}/protection`; - const result = spawnSync( - "gh", - ["api", endpoint, "--jq", "[.required_status_checks.checks[]?.context]"], - { encoding: "utf8" }, - ); - const stdout = assertGhOk(result, endpoint); - try { - return JSON.parse(stdout); - } catch { - throw new CoverageBlindSpotError( - `gh api ${endpoint} returned invalid JSON`, - ); - } + const branchPath = `repos/${repo}/branches/${encodeURIComponent(branch)}`; + const read = (endpoint, jq) => { + const result = spawnSync("gh", ["api", endpoint, "--jq", jq], { + encoding: "utf8", + }); + const stdout = assertGhOk(result, endpoint); + try { + return JSON.parse(stdout); + } catch { + throw new CoverageBlindSpotError( + `gh api ${endpoint} returned invalid JSON`, + ); + } + }; + return [ + ...new Set([ + ...read( + `${branchPath}/protection`, + "[.required_status_checks.checks[]?.context]", + ), + ...read( + `repos/${repo}/rules/branches/${encodeURIComponent(branch)}`, + '[.[] | select(.type == "required_status_checks") | .parameters.required_status_checks[].context]', + ), + ]), + ]; } /** From f6c55ff369c7591d998fc24f4b1b4ce7d971ccf6 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 4 Oct 2026 23:13:39 +0000 Subject: [PATCH 2/2] chore: sync public mirror from internal --- .repository-projection.json | 4 +- Cargo.lock | 1 + scripts/check-required-status-checks.mjs | 87 +++-- scripts/check-required-status-checks.test.mjs | 47 ++- scripts/pr-ready-to-merge.mjs | 29 +- scripts/pr-ready-to-merge.test.mjs | 73 ++++ vendor/dex-loop/Cargo.toml | 1 + vendor/dex-loop/src/content_policy.rs | 267 +++++++++++++ vendor/dex-loop/src/context.rs | 2 +- vendor/dex-loop/src/engine.rs | 108 +++++- vendor/dex-loop/src/lib.rs | 5 + vendor/dex-loop/src/voice.rs | 5 +- vendor/dex-loop/tests/content_policy.rs | 353 ++++++++++++++++++ 13 files changed, 931 insertions(+), 51 deletions(-) create mode 100644 scripts/pr-ready-to-merge.test.mjs create mode 100644 vendor/dex-loop/src/content_policy.rs create mode 100644 vendor/dex-loop/tests/content_policy.rs diff --git a/.repository-projection.json b/.repository-projection.json index 933dd6f05..2e5922ad4 100644 --- a/.repository-projection.json +++ b/.repository-projection.json @@ -3,11 +3,11 @@ "projection": "deixic-code", "projectionSchemaVersion": 1, "sourceRepository": "dx-corp/mono", - "sourceSha": "eb9c2fd8c33280666f4e5f8b588aaf5f374a24b3", + "sourceSha": "6619b92b1c7046d41afc4ec4e2378c94b3583913", "destinationRepository": "dx-corp/code", "priorProjectedBase": "f62a292a8f62ab1ae09bebc7e3f6d5b9b5d6b855", "definitionDigest": "cb9d429542ebb0a2de9b42a7aad60d9d8696a648ceba47c30f05c0b285ca0db7", "toolDigest": "f8cb071b0f27267120ccf45a00d0982f45113bd23535bef6a1555b4933f99f13", - "contentDigest": "64533c52c0795861603bbadd98bc081a16d9ce123a47949ecde19abcd6509f1f", + "contentDigest": "07cdf7a268ab5ba55ce8db85b62d9507646cb8ffe4399a1b4d9099facdbda507", "publicationEligible": true } diff --git a/Cargo.lock b/Cargo.lock index 26efb3a86..61468b86d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2045,6 +2045,7 @@ dependencies = [ "thiserror 2.0.20", "tokio", "tokio-util", + "url", ] [[package]] diff --git a/scripts/check-required-status-checks.mjs b/scripts/check-required-status-checks.mjs index 4982b727c..dcdfa2f54 100644 --- a/scripts/check-required-status-checks.mjs +++ b/scripts/check-required-status-checks.mjs @@ -68,18 +68,21 @@ function parseArgs(argv) { return args; } -function fetchRequiredContexts(repo, branch, { strict = false } = {}) { +export function fetchRequiredContexts( + repo, + branch, + { strict = false, run = spawnSync } = {}, +) { const endpoint = `repos/${repo}/branches/${encodeURIComponent(branch)}/protection`; - const result = spawnSync( - "gh", - ["api", endpoint, "--jq", "[.required_status_checks.checks[]?.context]"], - { encoding: "utf8" }, - ); - if (result.error) { - throw new Error(`failed to run gh: ${result.error.message}`); - } - if (result.status !== 0) { - const detail = (result.stderr || "").trim() || "unknown error"; + const rulesEndpoint = `repos/${repo}/rules/branches/${encodeURIComponent(branch)}`; + const gh = (path, jq) => { + const result = run("gh", ["api", path, "--jq", jq], { encoding: "utf8" }); + if (result.error) { + throw new Error(`failed to run gh: ${result.error.message}`); + } + return result; + }; + const unreadable = (detail) => { // A token without Administration read on branch protection gets 403/404. // In strict mode (the repo whose invariant this is) that must fail the // job: the whole point of this check is to prove required checks @@ -87,26 +90,54 @@ function fetchRequiredContexts(repo, branch, { strict = false } = {}) { // must not let it go green silently. Non-strict callers (forks/other // repos that can't hold the token or grant) still degrade to a // warning + pass. - if (/HTTP (403|404)/.test(detail)) { - if (strict) { - throw new Error( - `gh api ${endpoint} failed: ${detail}. The token cannot read branch ` + - "protection (needs Administration read) on a repository where this " + - "invariant runs in --strict mode; it must fail closed rather than " + - "silently skip.", - ); - } - console.warn( - `::warning::INVARIANT NOT VERIFIED: gh api ${endpoint} failed: ${detail}. ` + - "The token cannot read branch protection (needs Administration read), so " + - "required contexts could not be enumerated. This job passing does NOT mean " + - "required checks are reportable.", + if (strict) { + throw new Error( + `gh api ${endpoint} failed: ${detail}. The token cannot read branch ` + + "protection (needs Administration read) on a repository where this " + + "invariant runs in --strict mode; it must fail closed rather than " + + "silently skip.", ); - return null; } - throw new Error(`gh api ${endpoint} failed: ${detail}`); + console.warn( + `::warning::INVARIANT NOT VERIFIED: gh api ${endpoint} failed: ${detail}. ` + + "The token cannot read branch protection (needs Administration read), so " + + "required contexts could not be enumerated. This job passing does NOT mean " + + "required checks are reportable.", + ); + return null; + }; + + const classic = gh(endpoint, "[.required_status_checks.checks[]?.context]"); + let classicContexts = []; + let classicNotFound = false; + if (classic.status === 0) { + classicContexts = JSON.parse(classic.stdout); + } else { + const detail = (classic.stderr || "").trim() || "unknown error"; + if (/HTTP 404/.test(detail)) { + classicNotFound = true; + } else if (/HTTP 403/.test(detail)) { + return unreadable(detail); + } else { + throw new Error(`gh api ${endpoint} failed: ${detail}`); + } + } + + const rules = gh( + rulesEndpoint, + '[.[] | select(.type == "required_status_checks") | .parameters.required_status_checks[].context]', + ); + if (rules.status !== 0) { + const detail = (rules.stderr || "").trim() || "unknown error"; + throw new Error(`gh api ${rulesEndpoint} failed: ${detail}`); + } + const contexts = [ + ...new Set([...classicContexts, ...JSON.parse(rules.stdout)]), + ]; + if (classicNotFound && contexts.length === 0) { + return unreadable("HTTP 404 and no ruleset required_status_checks"); } - return JSON.parse(result.stdout); + return contexts; } function stripComment(line) { diff --git a/scripts/check-required-status-checks.test.mjs b/scripts/check-required-status-checks.test.mjs index feb62e627..126f9483d 100644 --- a/scripts/check-required-status-checks.test.mjs +++ b/scripts/check-required-status-checks.test.mjs @@ -4,7 +4,10 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { test } from "node:test"; -import { evaluateRequiredStatusChecks } from "./check-required-status-checks.mjs"; +import { + evaluateRequiredStatusChecks, + fetchRequiredContexts, +} from "./check-required-status-checks.mjs"; function withWorkflows(files, contexts) { const root = mkdtempSync(join(tmpdir(), "required-checks-")); @@ -117,3 +120,45 @@ jobs: assert.equal(failures.length, 1); assert.match(failures[0], /never runs on pull_request/u); }); + +test("fetchRequiredContexts unions classic protection and ruleset contexts", () => { + const ok = (stdout) => ({ status: 0, stdout, stderr: "" }); + const run = (_cmd, args) => + args[1].includes("/rules/") + ? ok('["validate","platform-ci"]') + : ok('["validate","legacy"]'); + assert.deepEqual( + fetchRequiredContexts("dx-corp/mono", "main", { strict: true, run }), + ["validate", "legacy", "platform-ci"], + ); +}); + +test("fetchRequiredContexts treats classic 404 as empty and reads the ruleset", () => { + const run = (_cmd, args) => + args[1].includes("/rules/") + ? { status: 0, stdout: '["validate","semgrep"]', stderr: "" } + : { status: 1, stdout: "", stderr: "gh: Not Found (HTTP 404)" }; + assert.deepEqual( + fetchRequiredContexts("dx-corp/mono", "main", { strict: true, run }), + ["validate", "semgrep"], + ); +}); + +test("fetchRequiredContexts strict still fails on classic 404 with no ruleset checks", () => { + const run = (_cmd, args) => + args[1].includes("/rules/") + ? { status: 0, stdout: "[]", stderr: "" } + : { status: 1, stdout: "", stderr: "gh: Not Found (HTTP 404)" }; + assert.throws( + () => fetchRequiredContexts("dx-corp/mono", "main", { strict: true, run }), + /fail closed/u, + ); +}); + +test("fetchRequiredContexts strict still fails on classic 403", () => { + const run = () => ({ status: 1, stdout: "", stderr: "gh: Forbidden (HTTP 403)" }); + assert.throws( + () => fetchRequiredContexts("dx-corp/mono", "main", { strict: true, run }), + /fail closed/u, + ); +}); diff --git a/scripts/pr-ready-to-merge.mjs b/scripts/pr-ready-to-merge.mjs index e34dbd236..2abd34534 100755 --- a/scripts/pr-ready-to-merge.mjs +++ b/scripts/pr-ready-to-merge.mjs @@ -436,17 +436,30 @@ export function fetchRequiredStatusChecks(repo, branch, queryGh = ghJson) { if (!branch) { return null; } + const encoded = encodeURIComponent(branch); try { - const data = queryGh([ - "api", - `repos/${repo}/branches/${encodeURIComponent(branch)}/protection/required_status_checks`, - ]); - return Array.from( - new Set([ + let classic = []; + try { + const data = queryGh([ + "api", + `repos/${repo}/branches/${encoded}/protection/required_status_checks`, + ]); + classic = [ ...(data.contexts ?? []), ...(data.checks ?? []).map((check) => check.context).filter(Boolean), - ]), - ); + ]; + } catch (error) { + if (!/HTTP 404/.test(`${error?.message ?? ""}${error?.stderr ?? ""}`)) { + throw error; + } + } + const rules = queryGh(["api", `repos/${repo}/rules/branches/${encoded}`]); + const ruleset = rules + .filter((rule) => rule.type === "required_status_checks") + .flatMap((rule) => rule.parameters?.required_status_checks ?? []) + .map((check) => check.context) + .filter(Boolean); + return Array.from(new Set([...classic, ...ruleset])); } catch { return null; } diff --git a/scripts/pr-ready-to-merge.test.mjs b/scripts/pr-ready-to-merge.test.mjs new file mode 100644 index 000000000..e893841d6 --- /dev/null +++ b/scripts/pr-ready-to-merge.test.mjs @@ -0,0 +1,73 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { fetchRequiredStatusChecks } from "./pr-ready-to-merge.mjs"; + +const rules = [ + { type: "pull_request", parameters: {} }, + { + type: "required_status_checks", + parameters: { + required_status_checks: [{ context: "validate" }, { context: "platform-ci" }], + }, + }, +]; + +function fakeGh({ classic, ruleset }) { + const calls = []; + const queryGh = (args) => { + calls.push(args[1]); + const result = args[1].includes("/protection/") ? classic : ruleset; + if (result instanceof Error) { + throw result; + } + return result; + }; + return { calls, queryGh }; +} + +test("classic 404 is an empty classic set and the ruleset supplies the contexts", () => { + const { calls, queryGh } = fakeGh({ + classic: new Error("gh: Required status checks not enabled (HTTP 404)"), + ruleset: rules, + }); + assert.deepEqual(fetchRequiredStatusChecks("dx-corp/mono", "main", queryGh), [ + "validate", + "platform-ci", + ]); + assert.deepEqual(calls, [ + "repos/dx-corp/mono/branches/main/protection/required_status_checks", + "repos/dx-corp/mono/rules/branches/main", + ]); +}); + +test("classic and ruleset contexts are unioned without duplicates", () => { + const { queryGh } = fakeGh({ + classic: { contexts: ["validate"], checks: [{ context: "legacy" }] }, + ruleset: rules, + }); + assert.deepEqual( + fetchRequiredStatusChecks("dx-corp/mono", "main", queryGh).sort(), + ["legacy", "platform-ci", "validate"], + ); +}); + +test("non-404 classic errors still return null", () => { + const { queryGh } = fakeGh({ + classic: new Error("gh: Resource not accessible by integration (HTTP 403)"), + ruleset: rules, + }); + assert.equal(fetchRequiredStatusChecks("dx-corp/mono", "main", queryGh), null); +}); + +test("ruleset errors return null", () => { + const { queryGh } = fakeGh({ + classic: { contexts: ["validate"], checks: [] }, + ruleset: new Error("HTTP 500"), + }); + assert.equal(fetchRequiredStatusChecks("dx-corp/mono", "main", queryGh), null); +}); + +test("missing branch returns null", () => { + assert.equal(fetchRequiredStatusChecks("dx-corp/mono", "", () => []), null); +}); diff --git a/vendor/dex-loop/Cargo.toml b/vendor/dex-loop/Cargo.toml index 173bac029..e5b8d5fba 100644 --- a/vendor/dex-loop/Cargo.toml +++ b/vendor/dex-loop/Cargo.toml @@ -22,6 +22,7 @@ thiserror = "2.0" # still does no I/O of its own. tokio = { version = "1.48", features = ["time"] } tokio-util = "0.7" +url = { version = "2.5", features = ["serde"] } [dev-dependencies] proptest = "1.9" diff --git a/vendor/dex-loop/src/content_policy.rs b/vendor/dex-loop/src/content_policy.rs new file mode 100644 index 000000000..343dac696 --- /dev/null +++ b/vendor/dex-loop/src/content_policy.rs @@ -0,0 +1,267 @@ +//! Pure checks over authored content, independent of tools, transport and storage. + +use crate::TurnContentPolicy; + +/// Prose and citation targets have different meanings for writing rules. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct AuthoredContent { + pub prose: String, + pub citation_urls: Vec, + word_count: usize, +} + +impl AuthoredContent { + /// Preserve visible labels, but never treat a URL target as authored prose. + pub fn from_text(text: &str) -> Self { + let mut content = Self { + word_count: text.split_whitespace().count(), + ..Self::default() + }; + let mut rest = text; + loop { + let lower = rest.to_ascii_lowercase(); + let start = [lower.find("http://"), lower.find("https://")] + .into_iter() + .flatten() + .min(); + let Some(start) = start else { + content.prose.push_str(rest); + break; + }; + content.prose.push_str(&rest[..start]); + let target = &rest[start..]; + let end = target + .find(|c: char| { + c.is_whitespace() || matches!(c, ')' | ']' | '}' | '>' | '"' | '\'') + }) + .unwrap_or(target.len()); + let candidate = target[..end].trim_end_matches(['.', ',', ';', ':', '!', '?']); + if http_url(candidate).is_some() { + content.citation_urls.push(candidate.into()); + } + // Keep word boundaries around removed targets. + content.prose.push(' '); + rest = &target[end..]; + } + content + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ContentPolicyScope { + Response, + Progress, + Artifact, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ContentPolicyViolation { + pub code: &'static str, + /// Safe feedback: never quotes rejected prose or URLs. + pub message: String, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ContentPolicyEvaluation { + pub word_count: usize, + pub violations: Vec, +} + +impl TurnContentPolicy { + pub fn has_deterministic_controls(&self) -> bool { + !self.required_terms.is_empty() + || !self.forbidden_terms.is_empty() + || self.require_citations + || !self.allowed_citation_domains.is_empty() + || self.max_response_words > 0 + } +} + +fn http_url(value: &str) -> Option { + url::Url::parse(value) + .ok() + .filter(|url| matches!(url.scheme(), "http" | "https") && url.host_str().is_some()) +} + +pub fn evaluate_content_policy( + policy: &TurnContentPolicy, + content: &AuthoredContent, + scope: ContentPolicyScope, +) -> ContentPolicyEvaluation { + let mut evaluation = ContentPolicyEvaluation { + word_count: content.word_count, + violations: Vec::new(), + }; + let normalized = content.prose.to_ascii_lowercase(); + let mut reject = |code, message: &str| { + evaluation.violations.push(ContentPolicyViolation { + code, + message: message.into(), + }) + }; + if scope != ContentPolicyScope::Progress { + for term in &policy.required_terms { + if !normalized.contains(&term.to_ascii_lowercase()) { + reject( + "artifact_style_guide_required_term_missing", + "Content is missing a required workspace term.", + ); + } + } + } + for term in &policy.forbidden_terms { + if !term.is_empty() && normalized.contains(&term.to_ascii_lowercase()) { + reject( + "artifact_style_guide_forbidden_term", + "Content contains a prohibited workspace term.", + ); + } + } + let citations: Vec<_> = content + .citation_urls + .iter() + .filter_map(|value| http_url(value)) + .collect(); + if scope != ContentPolicyScope::Progress && policy.require_citations && citations.is_empty() { + reject( + "content_policy_citation_required", + "Content must include a valid HTTP or HTTPS source link.", + ); + } + if !policy.allowed_citation_domains.is_empty() + && citations.iter().any(|url| { + url.host_str().is_none_or(|host| { + !policy.allowed_citation_domains.iter().any(|allowed| { + host.eq_ignore_ascii_case(allowed) + || host + .to_ascii_lowercase() + .ends_with(&format!(".{}", allowed.to_ascii_lowercase())) + }) + }) + }) + { + reject( + "content_policy_citation_domain_forbidden", + "Content cites a domain outside the workspace allowlist.", + ); + } + if scope == ContentPolicyScope::Response + && policy.max_response_words > 0 + && content.word_count > policy.max_response_words as usize + { + reject( + "content_policy_response_too_long", + "Response exceeds the workspace word limit.", + ); + } + evaluation +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn url_targets_are_not_prose_but_link_labels_are() { + let policy = TurnContentPolicy { + required_terms: vec!["Deixic".into()], + forbidden_terms: vec!["foster".into()], + ..Default::default() + }; + let missing = AuthoredContent::from_text("Read [Evidence](https://example.com/Deixic)."); + assert_eq!( + evaluate_content_policy(&policy, &missing, ContentPolicyScope::Response).violations[0] + .code, + "artifact_style_guide_required_term_missing" + ); + let good = AuthoredContent::from_text("Read [Deixic](https://example.com/foster)."); + assert!( + evaluate_content_policy(&policy, &good, ContentPolicyScope::Response) + .violations + .is_empty() + ); + let bad = AuthoredContent::from_text("[FOSTER](https://example.com/Deixic) Deixic"); + assert_eq!( + evaluate_content_policy(&policy, &bad, ContentPolicyScope::Response).violations[0].code, + "artifact_style_guide_forbidden_term" + ); + } + + #[test] + fn citation_hosts_match_only_exact_or_dot_delimited_subdomains() { + let policy = TurnContentPolicy { + require_citations: true, + allowed_citation_domains: vec!["Example.com".into()], + ..Default::default() + }; + for link in ["https://example.com/a", "http://docs.example.com/a"] { + assert!( + evaluate_content_policy( + &policy, + &AuthoredContent::from_text(link), + ContentPolicyScope::Artifact + ) + .violations + .is_empty() + ); + } + for link in [ + "https://evilexample.com/a", + "https://example.com.evil.invalid/a", + "https://example.com@evil.invalid/a", + ] { + assert_eq!( + evaluate_content_policy( + &policy, + &AuthoredContent::from_text(link), + ContentPolicyScope::Artifact + ) + .violations[0] + .code, + "content_policy_citation_domain_forbidden" + ); + } + assert_eq!( + evaluate_content_policy( + &policy, + &AuthoredContent::from_text("No source"), + ContentPolicyScope::Artifact + ) + .violations[0] + .code, + "content_policy_citation_required" + ); + } + + #[test] + fn progress_does_not_require_final_terms_citations_or_response_length() { + let policy = TurnContentPolicy { + required_terms: vec!["Deixic".into()], + require_citations: true, + max_response_words: 1, + ..Default::default() + }; + let content = AuthoredContent::from_text("Checking the evidence."); + assert!( + evaluate_content_policy(&policy, &content, ContentPolicyScope::Progress) + .violations + .is_empty() + ); + assert_eq!( + evaluate_content_policy(&policy, &content, ContentPolicyScope::Response) + .violations + .len(), + 3 + ); + let linked = AuthoredContent::from_text("Deixic [Source](https://example.com/a)"); + assert_eq!( + evaluate_content_policy(&policy, &linked, ContentPolicyScope::Response).word_count, + 2 + ); + assert!( + evaluate_content_policy(&policy, &linked, ContentPolicyScope::Artifact) + .violations + .is_empty() + ); + } +} diff --git a/vendor/dex-loop/src/context.rs b/vendor/dex-loop/src/context.rs index 9c130ede2..effbb25de 100644 --- a/vendor/dex-loop/src/context.rs +++ b/vendor/dex-loop/src/context.rs @@ -367,7 +367,7 @@ impl Context { } /// The current turn's host-resolved writing policy and voice choice, as - /// logged on its `UserMessage`. Prompt data only. + /// logged on its `UserMessage`. Writing rules grant no execution authority. pub fn voice(&self) -> Option<&crate::TurnVoice> { self.voice.as_ref() } diff --git a/vendor/dex-loop/src/engine.rs b/vendor/dex-loop/src/engine.rs index 735b9b957..1e7801f96 100644 --- a/vendor/dex-loop/src/engine.rs +++ b/vendor/dex-loop/src/engine.rs @@ -30,6 +30,7 @@ use tokio_util::sync::CancellationToken; use crate::budget::{Budget, BudgetAxis}; use crate::compaction::{Compactor, NoCompaction}; +use crate::content_policy::{AuthoredContent, ContentPolicyScope, evaluate_content_policy}; use crate::context::{CallState, Context, Decision, Status}; use crate::event::{ AUTO_APPROVER, ApprovalId, CallId, Cursor, ErrorCode, Event, Outcome, PrincipalId, @@ -548,6 +549,13 @@ where let mut filter = self.sanitizer.filter(); let mut thinking = Thinking::new(self.sanitizer.filter()); let mut text = String::new(); + let content_policy = ctx + .voice() + .and_then(|voice| voice.policy.as_ref()) + .filter(|policy| policy.has_deterministic_controls()) + .cloned(); + let mut visible_thinking = Vec::new(); + let mut controlled_bytes = 0usize; let mut calls = Vec::new(); let mut failure = None; let mut wall_exceeded = false; @@ -604,22 +612,46 @@ where None => StreamStep::Ended, }, }; + if content_policy.is_some() + && let StreamStep::Chunk(Ok( + ModelChunk::Text(delta) | ModelChunk::Thinking(delta), + )) = &outcome + { + controlled_bytes = controlled_bytes.saturating_add(delta.len()); + if controlled_bytes > MAX_CONTROLLED_PROSE_BYTES { + failure = Some(ModelError { + class: crate::ErrorClass::Rejected, + message: "content_policy_output_too_large: Controlled prose exceeds the validation buffer limit.".into(), + }); + break; + } + } match outcome { StreamStep::Chunk(Ok(ModelChunk::Thinking(delta))) => { if let Some(summary) = thinking.push(&delta, !text.is_empty()) { - let event = [Event::ThinkingDelta { text: summary }]; - prefetch.reads.drive(self.log.append(&event)).await?; + if content_policy.is_some() { + visible_thinking.push(summary); + } else { + let event = [Event::ThinkingDelta { text: summary }]; + prefetch.reads.drive(self.log.append(&event)).await?; + } } } StreamStep::Chunk(Ok(ModelChunk::Text(delta))) => { if let Some(summary) = thinking.flush() { - let event = [Event::ThinkingDelta { text: summary }]; - prefetch.reads.drive(self.log.append(&event)).await?; + if content_policy.is_some() { + visible_thinking.push(summary); + } else { + let event = [Event::ThinkingDelta { text: summary }]; + prefetch.reads.drive(self.log.append(&event)).await?; + } } let safe = filter.push(&delta); if !safe.is_empty() { text.push_str(&safe); - prefetch.reads.drive(self.log.append_text(safe)).await?; + if content_policy.is_none() { + prefetch.reads.drive(self.log.append_text(safe)).await?; + } } } StreamStep::Chunk(Ok(ModelChunk::ToolCall { name, args })) => { @@ -679,17 +711,51 @@ where } if failure.is_none() && !wall_exceeded { if let Some(summary) = thinking.flush() { - let event = [Event::ThinkingDelta { text: summary }]; - prefetch.reads.drive(self.log.append(&event)).await?; + if content_policy.is_some() { + visible_thinking.push(summary); + } else { + let event = [Event::ThinkingDelta { text: summary }]; + prefetch.reads.drive(self.log.append(&event)).await?; + } } let tail = filter.finish(); if !tail.is_empty() { text.push_str(&tail); - prefetch.reads.drive(self.log.append_text(tail)).await?; + if content_policy.is_none() { + prefetch.reads.drive(self.log.append_text(tail)).await?; + } } } } + if failure.is_none() + && !wall_exceeded + && !cancel.is_cancelled() + && let Some(policy) = &content_policy + { + let progress = evaluate_content_policy( + policy, + &AuthoredContent::from_text(&visible_thinking.concat()), + ContentPolicyScope::Progress, + ); + let scope = if calls.is_empty() { + ContentPolicyScope::Response + } else { + ContentPolicyScope::Progress + }; + let response = + evaluate_content_policy(policy, &AuthoredContent::from_text(&text), scope); + if let Some(violation) = progress + .violations + .first() + .or_else(|| response.violations.first()) + { + failure = Some(ModelError { + class: crate::ErrorClass::Rejected, + message: format!("{}: {}", violation.code, violation.message), + }); + } + } for (cursor, event) in std::mem::take(&mut prefetch.unobserved) { ctx.observe(cursor, &event); } @@ -711,7 +777,11 @@ where self.emit(ctx, events).await?; return Ok(Some(Exit::Failed)); } - if failure.is_some() && !text.is_empty() && !cancel.is_cancelled() { + if content_policy.is_none() + && failure.is_some() + && !text.is_empty() + && !cancel.is_cancelled() + { // The customer already read this text. Keep it as the // answer, visibly marked as cut off, instead of withdrawing // it: a long answer that loses its stream near the end (a @@ -753,6 +823,9 @@ where return Ok(Some(Exit::Failed)); } if cancel.is_cancelled() { + if content_policy.is_some() { + text.clear(); + } // Keep what the customer saw; calls from a cut stream never run, // so their continuation state is not kept either. let mut events = pending_usage; @@ -767,6 +840,21 @@ where self.emit(ctx, events).await?; return self.interrupt(ctx, prefetch).await.map(Some); } + if content_policy.is_some() { + let events: Vec<_> = visible_thinking + .into_iter() + .map(|text| Event::ThinkingDelta { text }) + .collect(); + if !events.is_empty() { + prefetch.reads.drive(self.log.append(&events)).await?; + } + if !text.is_empty() { + prefetch + .reads + .drive(self.log.append_text(text.clone())) + .await?; + } + } if !calls.is_empty() && self.budget.answer_only(step.saturating_sub(1)) { // Asked for a tool on the call that offered none. Nothing can run // it, so the turn ends here instead of looping. (Nothing was @@ -2009,6 +2097,8 @@ fn search_spec() -> ToolSpec { /// Most thinking summary one attempt shows; the rest is dropped. const MAX_THINKING_BYTES: usize = 16 * 1024; +/// A policy-controlled step may not retain unbounded unvalidated model prose. +const MAX_CONTROLLED_PROSE_BYTES: usize = 256 * 1024; /// Held thinking is written once it reaches this size (the first piece is /// written at once, so progress appears as soon as the model starts). const THINKING_FLUSH_BYTES: usize = 240; diff --git a/vendor/dex-loop/src/lib.rs b/vendor/dex-loop/src/lib.rs index 2b5e5691b..4c3fccabb 100644 --- a/vendor/dex-loop/src/lib.rs +++ b/vendor/dex-loop/src/lib.rs @@ -23,6 +23,7 @@ mod budget; mod codemode_output; mod codemode_state; mod compaction; +mod content_policy; mod context; mod engine; mod event; @@ -35,6 +36,10 @@ pub use budget::{Budget, BudgetAxis, RemainingBudget}; pub use compaction::{ Compaction, CompactionPlan, Compactor, NoCompaction, Summarize, Summary, Threshold, }; +pub use content_policy::{ + AuthoredContent, ContentPolicyEvaluation, ContentPolicyScope, ContentPolicyViolation, + evaluate_content_policy, +}; pub use context::{ AttachmentInput, Context, Entry, MAX_CONTEXT_ATTACHMENTS, Message, TOOL_EVIDENCE_LIMIT, ToolEvidence, diff --git a/vendor/dex-loop/src/voice.rs b/vendor/dex-loop/src/voice.rs index 4e4fd3d8f..72e6939d5 100644 --- a/vendor/dex-loop/src/voice.rs +++ b/vendor/dex-loop/src/voice.rs @@ -2,8 +2,9 @@ //! with the sender's voice choice already applied by the authenticated host. //! //! This is prompt data, never authority. It grants no tool, connector, or -//! model access, and the loop never reads it: the model port renders it into -//! the turn's stored context. Logged on the turn's `UserMessage`, so every +//! model access. The model port renders guidance into stored context; the loop +//! checks deterministic writing rules before exposing model prose. Logged on +//! the turn's `UserMessage`, so every //! step, resume, and replica of the turn writes under the same policy even if //! the workspace edits its style guide mid-turn. diff --git a/vendor/dex-loop/tests/content_policy.rs b/vendor/dex-loop/tests/content_policy.rs new file mode 100644 index 000000000..ac8898c6c --- /dev/null +++ b/vendor/dex-loop/tests/content_policy.rs @@ -0,0 +1,353 @@ +//! Writing rules are enforced before model prose becomes visible or effects run. + +#[allow(dead_code)] +mod support; + +use std::time::Duration; + +use dex_loop::{ + ApprovalMode, Budget, CancellationToken, Context, Event, Exit, TurnContentPolicy, TurnId, + TurnVoice, +}; +use serde_json::json; +use support::*; + +fn budget() -> Budget { + Budget { + max_steps: 10, + max_tokens: 1_000_000, + max_cost_micros: 1_000_000, + wall: Duration::from_secs(30), + } +} + +fn start(log: &FakeLog, policy: TurnContentPolicy) -> Context { + log.host_append(Event::UserMessage { + turn: TurnId::new("policy-turn"), + message_id: None, + principal: alice(), + text: "Prepare the update".into(), + attachments: Vec::new(), + client_tools: Vec::new(), + authorized_tools: Vec::new(), + model_binding: None, + voice: Some(Box::new(TurnVoice { + policy: Some(policy), + tone: Vec::new(), + })), + approval_mode: ApprovalMode::Interactive, + }); + log.rehydrate() +} + +#[tokio::test] +async fn prohibited_text_split_across_chunks_never_leaks_or_dispatches_a_mutation() { + let log = FakeLog::default(); + let model = FakeModel::new(vec![ + vec![ + text("We fos"), + text("ter growth."), + call("update", json!({})), + usage(3, 4, 7), + ], + vec![text("Updated.")], + ]); + let tools = FakeTools::new(vec![write_tool("update")]); + let mut ctx = start( + &log, + TurnContentPolicy { + forbidden_terms: vec!["foster".into()], + ..TurnContentPolicy::default() + }, + ); + assert_eq!( + engine(&log, &model, &tools, budget()) + .run(&mut ctx, &CancellationToken::new()) + .await, + Ok(Exit::Failed) + ); + assert!( + log.text_writes().is_empty(), + "unchecked text reached the visible log" + ); + assert!( + tools.runs().is_empty(), + "a rejected step dispatched its mutation" + ); + assert_eq!(ctx.usage().cost_micros, 7); + assert_eq!(ctx, log.rehydrate()); +} + +#[tokio::test] +async fn guidance_only_policy_preserves_incremental_streaming() { + let log = FakeLog::default(); + let model = FakeModel::new(vec![vec![text("Hel"), text("lo")]]); + let tools = FakeTools::new(vec![]); + let mut ctx = start( + &log, + TurnContentPolicy { + response_guidance: "Keep it brief.".into(), + ..TurnContentPolicy::default() + }, + ); + assert_eq!( + engine(&log, &model, &tools, budget()) + .run(&mut ctx, &CancellationToken::new()) + .await, + Ok(Exit::Done) + ); + assert_eq!(log.text_writes(), vec!["Hel", "lo"]); +} + +#[tokio::test] +async fn prohibited_thinking_is_rejected_before_any_visible_prose() { + let log = FakeLog::default(); + let model = FakeModel::new(vec![vec![ + thinking("fos"), + thinking("ter"), + text("Safe answer"), + usage(2, 3, 5), + ]]); + let tools = FakeTools::new(vec![]); + let mut ctx = start( + &log, + TurnContentPolicy { + forbidden_terms: vec!["foster".into()], + ..Default::default() + }, + ); + assert_eq!( + engine(&log, &model, &tools, budget()) + .run(&mut ctx, &CancellationToken::new()) + .await, + Ok(Exit::Failed) + ); + assert!(log.text_writes().is_empty()); + assert!( + !log.events() + .iter() + .any(|event| matches!(event, Event::ThinkingDelta { .. } | Event::Final { .. })) + ); + assert_eq!(ctx.usage().cost_micros, 5); + assert_eq!(ctx, log.rehydrate()); +} + +#[tokio::test] +async fn partial_controlled_answer_is_not_published_as_a_cutoff_answer() { + let log = FakeLog::default(); + let model = FakeModel::new(vec![vec![ + text("Deixic partial"), + usage(2, 3, 5), + Err(dex_loop::ModelError { + class: dex_loop::ErrorClass::Truncated, + message: "Stream ended early".into(), + }), + ]]); + let tools = FakeTools::new(vec![]); + let mut ctx = start( + &log, + TurnContentPolicy { + required_terms: vec!["Deixic".into()], + ..Default::default() + }, + ); + assert_eq!( + engine(&log, &model, &tools, budget()) + .run(&mut ctx, &CancellationToken::new()) + .await, + Ok(Exit::Failed) + ); + assert!(log.text_writes().is_empty()); + assert!(!log.events().iter().any(|event| matches!( + event, + Event::Final { .. } | Event::ModelStepCompleted { .. } + ))); + assert_eq!(ctx.usage().cost_micros, 5); + assert_eq!(ctx, log.rehydrate()); +} + +#[tokio::test] +async fn controlled_output_is_bounded_before_publication() { + let log = FakeLog::default(); + let oversized = "x".repeat(256 * 1024 + 1); + let model = FakeModel::new(vec![vec![text(&oversized)]]); + let tools = FakeTools::new(vec![]); + let mut ctx = start( + &log, + TurnContentPolicy { + forbidden_terms: vec!["foster".into()], + ..Default::default() + }, + ); + assert_eq!( + engine(&log, &model, &tools, budget()) + .run(&mut ctx, &CancellationToken::new()) + .await, + Ok(Exit::Failed) + ); + assert!(log.text_writes().is_empty()); + assert!(log.events().iter().any(|event| matches!(event, Event::Error { message, .. } if message.starts_with("content_policy_output_too_large:")))); +} + +#[tokio::test] +async fn canceled_controlled_answer_leaves_no_unchecked_text_in_recovery() { + let log = FakeLog::default(); + let model = FakeModel::new(vec![vec![text("Deixic partial"), usage(2, 3, 5)]]).hanging(); + let tools = FakeTools::new(vec![]); + let mut ctx = start( + &log, + TurnContentPolicy { + required_terms: vec!["Deixic".into()], + ..Default::default() + }, + ); + let engine = engine(&log, &model, &tools, budget()); + let cancel = CancellationToken::new(); + let (exit, ()) = tokio::join!(engine.run(&mut ctx, &cancel), async { + tokio::time::sleep(Duration::from_millis(20)).await; + cancel.cancel(); + }); + assert_eq!(exit, Ok(Exit::Interrupted)); + assert!(log.text_writes().is_empty()); + assert!( + !log.events().iter().any( + |event| matches!(event, Event::ModelStepCompleted { text, .. } if !text.is_empty()) + ) + ); + assert_eq!(ctx.usage().cost_micros, 5); + assert_eq!(ctx, log.rehydrate()); +} + +#[tokio::test] +async fn rehydrated_policy_requires_final_terms_sources_and_response_word_limit() { + for (policy, answer) in [ + ( + TurnContentPolicy { + required_terms: vec!["Deixic".into()], + ..Default::default() + }, + "Evidence only", + ), + ( + TurnContentPolicy { + require_citations: true, + ..Default::default() + }, + "No source", + ), + ( + TurnContentPolicy { + allowed_citation_domains: vec!["example.com".into()], + ..Default::default() + }, + "[Source](https://evilexample.com/a)", + ), + ( + TurnContentPolicy { + max_response_words: 1, + ..Default::default() + }, + "Too many words", + ), + ] { + let log = FakeLog::default(); + start(&log, policy); + let mut recovered = log.rehydrate(); + let model = FakeModel::new(vec![vec![text(answer)]]); + let tools = FakeTools::new(vec![]); + assert_eq!( + engine(&log, &model, &tools, budget()) + .run(&mut recovered, &CancellationToken::new()) + .await, + Ok(Exit::Failed) + ); + assert!(log.text_writes().is_empty()); + assert_eq!(recovered, log.rehydrate()); + } +} + +#[tokio::test] +async fn rejected_step_closes_prefetched_reads_without_committing_them_or_writes() { + let log = FakeLog::default(); + let model = FakeModel::new(vec![vec![ + call("search", json!({"key": "a"})), + text("fos"), + text("ter"), + call("update", json!({})), + usage(2, 3, 5), + ]]) + .with_chunk_delay(Duration::from_millis(5)); + let tools = FakeTools::new(vec![read_tool("search"), write_tool("update")]); + let mut ctx = start( + &log, + TurnContentPolicy { + forbidden_terms: vec!["foster".into()], + ..Default::default() + }, + ); + assert_eq!( + engine(&log, &model, &tools, budget()) + .run(&mut ctx, &CancellationToken::new()) + .await, + Ok(Exit::Failed) + ); + assert!(log.text_writes().is_empty()); + assert_eq!(tools.run_ids(), vec!["policy-turn-1-0"]); + assert_eq!( + log.events() + .iter() + .filter(|event| matches!(event, Event::ToolStarted { .. })) + .count(), + 1 + ); + assert_eq!( + log.events() + .iter() + .filter(|event| matches!(event, Event::ToolFinished { .. })) + .count(), + 1 + ); + assert!( + !log.events() + .iter() + .any(|event| matches!(event, Event::ModelStepCompleted { .. })) + ); + assert_eq!(ctx.usage().cost_micros, 5); + assert_eq!(ctx, log.rehydrate()); +} + +#[tokio::test] +async fn compliant_controlled_answer_and_progress_are_published_after_validation() { + let log = FakeLog::default(); + let model = FakeModel::new(vec![vec![ + thinking("Checking evidence."), + text("Dei"), + text("xic approved. [Source](https://docs.example.com/a)"), + usage(2, 3, 5), + ]]); + let tools = FakeTools::new(vec![]); + let mut ctx = start( + &log, + TurnContentPolicy { + required_terms: vec!["Deixic".into()], + require_citations: true, + allowed_citation_domains: vec!["example.com".into()], + max_response_words: 3, + ..Default::default() + }, + ); + assert_eq!( + engine(&log, &model, &tools, budget()) + .run(&mut ctx, &CancellationToken::new()) + .await, + Ok(Exit::Done) + ); + assert_eq!( + log.text_writes(), + vec!["Deixic approved. [Source](https://docs.example.com/a)"] + ); + assert!(log.events().iter().any( + |event| matches!(event, Event::ThinkingDelta { text } if text == "Checking evidence.") + )); + assert_eq!(ctx, log.rehydrate()); +}