use crate::envelope::{CheckResult, Decision, Severity, Violation};
use crate::manifest::Manifest;
pub const RULE_UNVALIDATED_INPUT: &str = "contract.boundary.unvalidated_input";
pub const RULE_NEW_SUPPRESSION: &str = "pushkin.suppression.new";
pub const RULE_PROTECTED_PATH: &str = "pushkin.protected_path";
pub const RULE_READ_ONLY_PATH: &str = "pushkin.read_only_path";
pub const RULE_RAW_READ: &str = "pushkin.retrieval.raw_read";
pub const RULE_CONTENT_UNAVAILABLE: &str = "pushkin.content_unavailable";
const SUPPRESSION_MARKERS: &[&str] = &[
"@ts-ignore",
"@ts-expect-error",
"@ts-nocheck",
"eslint-disable",
"noqa",
"type: ignore",
];
const HANDLER_METHODS: &[&str] = &["GET", "POST", "PUT", "PATCH", "DELETE"];
pub struct WriteRequest {
pub file_path: String,
pub content: String,
}
#[must_use]
pub fn check_write(manifest: &Manifest, request: &WriteRequest) -> CheckResult {
let started = std::time::Instant::now();
let mut violations = Vec::new();
violations.extend(check_protected_path(manifest, request));
violations.extend(check_suppressions(manifest, request));
violations.extend(check_boundary_validation(manifest, request));
CheckResult {
decision: if violations.is_empty() {
Decision::Allow
} else {
Decision::Block
},
violations,
duration_ms: started.elapsed().as_secs_f64() * 1000.0,
}
}
#[must_use]
pub fn check_mutation_without_content(
manifest: &Manifest,
file_path: &str,
tool: &str,
) -> CheckResult {
let started = std::time::Instant::now();
let mut violations = check_mutation_path_rules(manifest, file_path);
if let Some(mapping) = manifest.mapping_for(file_path) {
if let Some(requirement) = mapping.require.as_deref() {
violations.push(content_unavailable_violation(file_path, tool, requirement));
}
}
CheckResult {
decision: if violations.is_empty() {
Decision::Allow
} else {
Decision::Block
},
violations,
duration_ms: started.elapsed().as_secs_f64() * 1000.0,
}
}
#[must_use]
pub fn check_mutation_path_rules(manifest: &Manifest, file_path: &str) -> Vec<Violation> {
check_protected_path(
manifest,
&WriteRequest {
file_path: file_path.to_owned(),
content: String::new(),
},
)
}
pub enum Synthesis {
Content(String),
Refused(String),
}
#[must_use]
pub fn synthesize(on_disk: Option<&str>, edits: &[crate::edits::Replacement]) -> Synthesis {
if edits.is_empty() {
return Synthesis::Refused(
"the mutation carries no reconstructable edit operations".to_owned(),
);
}
let Some(content) = on_disk else {
return Synthesis::Refused(
"the target file could not be read, so there is nothing to apply the edits to"
.to_owned(),
);
};
match crate::edits::apply_edits(content, edits) {
Ok(result) => Synthesis::Content(result),
Err(error) => Synthesis::Refused(error.to_string()),
}
}
#[must_use]
pub fn content_unavailable_violation(path: &str, tool: &str, requirement: &str) -> Violation {
let blocked = if requirement == "boundary-validation" {
RULE_UNVALIDATED_INPUT
} else {
requirement
};
Violation {
file: path.to_owned(),
line: 1,
rule: RULE_CONTENT_UNAVAILABLE.to_owned(),
contract: None,
fix_hint: format!(
"`{tool}` carries no file content, so `{blocked}` could not be evaluated for \
this path. This is an INTERIM-CONSERVATIVE refusal (F48 Phase A): the gate \
refuses rather than allowing a content rule it could not check. Re-issue the \
change as a Write carrying the full file content, and the rule will be \
evaluated normally."
),
suggestions: vec![
"Re-issue as Write with the complete file content.".to_owned(),
"Content synthesis (F48 Phase B) is live for EVERY edit tool: Claude's \
Edit/MultiEdit, auggie's str-replace-editor, opencode's edit, hermes' \
patch and codex's apply_patch hunks. A refusal here therefore means the \
RECONSTRUCTION failed — see the reason above — not that synthesis is \
unavailable. Two families reconstruct on narrower terms: hermes needs an \
EXACT unique match because its own matcher is fuzzy, and a codex hunk \
needs a unique context because its `@@` scope header is a locator this \
gate does not resolve."
.to_owned(),
],
severity: Severity::Error,
}
}
const BUILTIN_PROTECTED_PREFIX: &str = "pushkin/";
fn check_protected_path(manifest: &Manifest, request: &WriteRequest) -> Vec<Violation> {
let builtin = request.file_path.starts_with(BUILTIN_PROTECTED_PREFIX);
if !builtin && !manifest.is_protected(&request.file_path) {
return Vec::new();
}
vec![Violation {
file: request.file_path.clone(),
line: 1,
rule: RULE_PROTECTED_PATH.to_owned(),
contract: None,
fix_hint: "This path is part of Pushkin's own gate surface and may not be edited \
by agents. If the change is genuinely required, a human must make it."
.to_owned(),
suggestions: vec!["Ask the human operator to apply this change manually.".to_owned()],
severity: Severity::Error,
}]
}
#[must_use]
pub fn protected_path_bypass_violation(path: &str) -> Violation {
Violation {
file: path.to_owned(),
line: 1,
rule: RULE_PROTECTED_PATH.to_owned(),
contract: None,
fix_hint: "An agent was denied a write to this protected path, and it is staged \
anyway — the edit reached the tree through a surface the write gate \
never saw. A human must own this change."
.to_owned(),
suggestions: vec![
format!("git restore --staged --worktree {path}"),
"Or, if the change is genuinely required, apply it yourself and commit with \
--no-verify."
.to_owned(),
],
severity: Severity::Error,
}
}
#[must_use]
pub fn read_only_violation(path: &str) -> Violation {
Violation {
file: path.to_owned(),
line: 1,
rule: RULE_READ_ONLY_PATH.to_owned(),
contract: None,
fix_hint: "This file is committed under a read-only path (N10: committed suites \
are read-only). Agents may add NEW files here; a committed file only \
a human may change."
.to_owned(),
suggestions: vec![
"Author a new file for new coverage, or ask the human operator to apply \
this edit manually."
.to_owned(),
],
severity: Severity::Error,
}
}
#[must_use]
pub fn raw_read_violation(path: &str, tool: Option<&str>) -> Violation {
let destination = tool.unwrap_or("the repository's retrieval tool");
Violation {
file: path.to_owned(),
line: 1,
rule: RULE_RAW_READ.to_owned(),
contract: None,
fix_hint: format!(
"Whole-file reads of this path are gated. Ask {destination} for the \
code you need, or re-issue this read with an explicit offset and \
limit naming the range you are about to work on."
),
suggestions: vec![
format!("Retrieve it: {destination}"),
"Or read a range: Read(file_path, offset, limit).".to_owned(),
],
severity: Severity::Error,
}
}
fn check_suppressions(manifest: &Manifest, request: &WriteRequest) -> Vec<Violation> {
if manifest.mapping_for(&request.file_path).is_none() {
return Vec::new();
}
let mut violations = Vec::new();
for (index, line_text) in request.content.lines().enumerate() {
let Some(marker) = SUPPRESSION_MARKERS.iter().find(|m| line_text.contains(**m)) else {
continue;
};
violations.push(Violation {
file: request.file_path.clone(),
line: to_line_number(index),
rule: RULE_NEW_SUPPRESSION.to_owned(),
contract: None,
fix_hint: format!(
"Remove the suppression comment ('{marker}') and fix the underlying issue instead."
),
suggestions: vec![
"Fix the reported type/lint error rather than silencing it.".to_owned()
],
severity: Severity::Error,
});
}
violations
}
fn check_boundary_validation(manifest: &Manifest, request: &WriteRequest) -> Vec<Violation> {
let Some(mapping) = manifest.mapping_for(&request.file_path) else {
return Vec::new();
};
if mapping.require.as_deref() != Some("boundary-validation") {
return Vec::new();
}
let Some(handler_line) = find_handler_line(&request.content) else {
return Vec::new();
};
if parses_with_contract(&request.content) {
return Vec::new();
}
let contract = mapping.contracts.first().map(|c| c.as_str().to_owned());
let schema_name = schema_symbol(contract.as_deref());
vec![Violation {
file: request.file_path.clone(),
line: handler_line,
rule: RULE_UNVALIDATED_INPUT.to_owned(),
contract: contract.clone(),
fix_hint: format!(
"Parse the request body with {schema_name} (contract '{}') before use — e.g. \
const body = {schema_name}.parse(await req.json()); — fix and retry the write.",
contract.as_deref().unwrap_or("unknown")
),
suggestions: vec![
format!("import {{ {schema_name} }} from \"contracts/user.zod\""),
format!("contract_show {}", contract.as_deref().unwrap_or("unknown")),
],
severity: Severity::Error,
}]
}
fn schema_symbol(contract: Option<&str>) -> String {
let name = contract.unwrap_or("unknown");
let mut pascal = String::new();
for part in name.split(['-', '_']) {
let mut chars = part.chars();
if let Some(first) = chars.next() {
pascal.extend(first.to_uppercase());
pascal.push_str(chars.as_str());
}
}
format!("{pascal}CreateSchema")
}
fn find_handler_line(content: &str) -> Option<u32> {
for (index, line_text) in content.lines().enumerate() {
let is_export = line_text.contains("export");
let is_function = line_text.contains("function") || line_text.contains("async function");
if is_export
&& is_function
&& HANDLER_METHODS
.iter()
.any(|method| line_text.contains(method))
{
return Some(to_line_number(index));
}
}
None
}
fn parses_with_contract(content: &str) -> bool {
content.lines().any(|line_text| {
let Some(schema_pos) = line_text.find("Schema") else {
return false;
};
let rest = &line_text[schema_pos..];
rest.contains(".parse") || rest.contains(".safeParse")
})
}
fn to_line_number(index: usize) -> u32 {
u32::try_from(index)
.unwrap_or(u32::MAX - 1)
.saturating_add(1)
}