1use crate::envelope::{CheckResult, Decision, Severity, Violation};
7use crate::manifest::Manifest;
8
9pub const RULE_UNVALIDATED_INPUT: &str = "contract.boundary.unvalidated_input";
10pub const RULE_NEW_SUPPRESSION: &str = "pushkin.suppression.new";
11pub const RULE_PROTECTED_PATH: &str = "pushkin.protected_path";
12pub const RULE_READ_ONLY_PATH: &str = "pushkin.read_only_path";
13pub const RULE_RAW_READ: &str = "pushkin.retrieval.raw_read";
14pub const RULE_CONTENT_UNAVAILABLE: &str = "pushkin.content_unavailable";
21
22const SUPPRESSION_MARKERS: &[&str] = &[
23 "@ts-ignore",
24 "@ts-expect-error",
25 "@ts-nocheck",
26 "eslint-disable",
27 "noqa",
28 "type: ignore",
29];
30
31const HANDLER_METHODS: &[&str] = &["GET", "POST", "PUT", "PATCH", "DELETE"];
32
33pub struct WriteRequest {
34 pub file_path: String,
35 pub content: String,
36}
37
38#[must_use]
39pub fn check_write(manifest: &Manifest, request: &WriteRequest) -> CheckResult {
40 let started = std::time::Instant::now();
41 let mut violations = Vec::new();
42
43 violations.extend(check_protected_path(manifest, request));
44 violations.extend(check_suppressions(manifest, request));
45 violations.extend(check_boundary_validation(manifest, request));
46
47 CheckResult {
48 decision: if violations.is_empty() {
49 Decision::Allow
50 } else {
51 Decision::Block
52 },
53 violations,
54 duration_ms: started.elapsed().as_secs_f64() * 1000.0,
55 }
56}
57
58#[must_use]
73pub fn check_mutation_without_content(
74 manifest: &Manifest,
75 file_path: &str,
76 tool: &str,
77) -> CheckResult {
78 let started = std::time::Instant::now();
79 let mut violations = check_mutation_path_rules(manifest, file_path);
80
81 if let Some(mapping) = manifest.mapping_for(file_path) {
84 if let Some(requirement) = mapping.require.as_deref() {
85 violations.push(content_unavailable_violation(file_path, tool, requirement));
86 }
87 }
88
89 CheckResult {
90 decision: if violations.is_empty() {
91 Decision::Allow
92 } else {
93 Decision::Block
94 },
95 violations,
96 duration_ms: started.elapsed().as_secs_f64() * 1000.0,
97 }
98}
99
100#[must_use]
105pub fn check_mutation_path_rules(manifest: &Manifest, file_path: &str) -> Vec<Violation> {
106 check_protected_path(
107 manifest,
108 &WriteRequest {
109 file_path: file_path.to_owned(),
110 content: String::new(),
111 },
112 )
113}
114
115pub enum Synthesis {
117 Content(String),
119 Refused(String),
122}
123
124#[must_use]
136pub fn synthesize(on_disk: Option<&str>, edits: &[crate::edits::Replacement]) -> Synthesis {
137 if edits.is_empty() {
138 return Synthesis::Refused(
139 "the mutation carries no reconstructable edit operations".to_owned(),
140 );
141 }
142 let Some(content) = on_disk else {
143 return Synthesis::Refused(
144 "the target file could not be read, so there is nothing to apply the edits to"
145 .to_owned(),
146 );
147 };
148 match crate::edits::apply_edits(content, edits) {
149 Ok(result) => Synthesis::Content(result),
150 Err(error) => Synthesis::Refused(error.to_string()),
151 }
152}
153
154#[must_use]
158pub fn content_unavailable_violation(path: &str, tool: &str, requirement: &str) -> Violation {
159 let blocked = if requirement == "boundary-validation" {
160 RULE_UNVALIDATED_INPUT
161 } else {
162 requirement
163 };
164 Violation {
165 file: path.to_owned(),
166 line: 1,
167 rule: RULE_CONTENT_UNAVAILABLE.to_owned(),
168 contract: None,
169 fix_hint: format!(
170 "`{tool}` carries no file content, so `{blocked}` could not be evaluated for \
171 this path. This is an INTERIM-CONSERVATIVE refusal (F48 Phase A): the gate \
172 refuses rather than allowing a content rule it could not check. Re-issue the \
173 change as a Write carrying the full file content, and the rule will be \
174 evaluated normally."
175 ),
176 suggestions: vec![
177 "Re-issue as Write with the complete file content.".to_owned(),
178 "Content synthesis (F48 Phase B) is live for EVERY edit tool: Claude's \
179 Edit/MultiEdit, auggie's str-replace-editor, opencode's edit, hermes' \
180 patch and codex's apply_patch hunks. A refusal here therefore means the \
181 RECONSTRUCTION failed — see the reason above — not that synthesis is \
182 unavailable. Two families reconstruct on narrower terms: hermes needs an \
183 EXACT unique match because its own matcher is fuzzy, and a codex hunk \
184 needs a unique context because its `@@` scope header is a locator this \
185 gate does not resolve."
186 .to_owned(),
187 ],
188 severity: Severity::Error,
189 }
190}
191
192const BUILTIN_PROTECTED_PREFIX: &str = "pushkin/";
196
197fn check_protected_path(manifest: &Manifest, request: &WriteRequest) -> Vec<Violation> {
198 let builtin = request.file_path.starts_with(BUILTIN_PROTECTED_PREFIX);
199 if !builtin && !manifest.is_protected(&request.file_path) {
200 return Vec::new();
201 }
202 vec![Violation {
203 file: request.file_path.clone(),
204 line: 1,
205 rule: RULE_PROTECTED_PATH.to_owned(),
206 contract: None,
207 fix_hint: "This path is part of Pushkin's own gate surface and may not be edited \
208 by agents. If the change is genuinely required, a human must make it."
209 .to_owned(),
210 suggestions: vec!["Ask the human operator to apply this change manually.".to_owned()],
211 severity: Severity::Error,
212 }]
213}
214
215#[must_use]
225pub fn protected_path_bypass_violation(path: &str) -> Violation {
226 Violation {
227 file: path.to_owned(),
228 line: 1,
229 rule: RULE_PROTECTED_PATH.to_owned(),
230 contract: None,
231 fix_hint: "An agent was denied a write to this protected path, and it is staged \
232 anyway — the edit reached the tree through a surface the write gate \
233 never saw. A human must own this change."
234 .to_owned(),
235 suggestions: vec![
236 format!("git restore --staged --worktree {path}"),
237 "Or, if the change is genuinely required, apply it yourself and commit with \
238 --no-verify."
239 .to_owned(),
240 ],
241 severity: Severity::Error,
242 }
243}
244
245#[must_use]
251pub fn read_only_violation(path: &str) -> Violation {
252 Violation {
253 file: path.to_owned(),
254 line: 1,
255 rule: RULE_READ_ONLY_PATH.to_owned(),
256 contract: None,
257 fix_hint: "This file is committed under a read-only path (N10: committed suites \
258 are read-only). Agents may add NEW files here; a committed file only \
259 a human may change."
260 .to_owned(),
261 suggestions: vec![
262 "Author a new file for new coverage, or ask the human operator to apply \
263 this edit manually."
264 .to_owned(),
265 ],
266 severity: Severity::Error,
267 }
268}
269
270#[must_use]
278pub fn raw_read_violation(path: &str, tool: Option<&str>) -> Violation {
279 let destination = tool.unwrap_or("the repository's retrieval tool");
280 Violation {
281 file: path.to_owned(),
282 line: 1,
283 rule: RULE_RAW_READ.to_owned(),
284 contract: None,
285 fix_hint: format!(
286 "Whole-file reads of this path are gated. Ask {destination} for the \
287 code you need, or re-issue this read with an explicit offset and \
288 limit naming the range you are about to work on."
289 ),
290 suggestions: vec![
291 format!("Retrieve it: {destination}"),
292 "Or read a range: Read(file_path, offset, limit).".to_owned(),
293 ],
294 severity: Severity::Error,
295 }
296}
297
298fn check_suppressions(manifest: &Manifest, request: &WriteRequest) -> Vec<Violation> {
299 if manifest.mapping_for(&request.file_path).is_none() {
300 return Vec::new();
301 }
302 let mut violations = Vec::new();
303 for (index, line_text) in request.content.lines().enumerate() {
304 let Some(marker) = SUPPRESSION_MARKERS.iter().find(|m| line_text.contains(**m)) else {
305 continue;
306 };
307 violations.push(Violation {
308 file: request.file_path.clone(),
309 line: to_line_number(index),
310 rule: RULE_NEW_SUPPRESSION.to_owned(),
311 contract: None,
312 fix_hint: format!(
313 "Remove the suppression comment ('{marker}') and fix the underlying issue instead."
314 ),
315 suggestions: vec![
316 "Fix the reported type/lint error rather than silencing it.".to_owned()
317 ],
318 severity: Severity::Error,
319 });
320 }
321 violations
322}
323
324fn check_boundary_validation(manifest: &Manifest, request: &WriteRequest) -> Vec<Violation> {
325 let Some(mapping) = manifest.mapping_for(&request.file_path) else {
326 return Vec::new();
327 };
328 if mapping.require.as_deref() != Some("boundary-validation") {
329 return Vec::new();
330 }
331 let Some(handler_line) = find_handler_line(&request.content) else {
332 return Vec::new();
333 };
334 if parses_with_contract(&request.content) {
335 return Vec::new();
336 }
337 let contract = mapping.contracts.first().map(|c| c.as_str().to_owned());
338 let schema_name = schema_symbol(contract.as_deref());
339 vec![Violation {
340 file: request.file_path.clone(),
341 line: handler_line,
342 rule: RULE_UNVALIDATED_INPUT.to_owned(),
343 contract: contract.clone(),
344 fix_hint: format!(
345 "Parse the request body with {schema_name} (contract '{}') before use — e.g. \
346 const body = {schema_name}.parse(await req.json()); — fix and retry the write.",
347 contract.as_deref().unwrap_or("unknown")
348 ),
349 suggestions: vec![
350 format!("import {{ {schema_name} }} from \"contracts/user.zod\""),
351 format!("contract_show {}", contract.as_deref().unwrap_or("unknown")),
352 ],
353 severity: Severity::Error,
354 }]
355}
356
357fn schema_symbol(contract: Option<&str>) -> String {
361 let name = contract.unwrap_or("unknown");
362 let mut pascal = String::new();
363 for part in name.split(['-', '_']) {
364 let mut chars = part.chars();
365 if let Some(first) = chars.next() {
366 pascal.extend(first.to_uppercase());
367 pascal.push_str(chars.as_str());
368 }
369 }
370 format!("{pascal}CreateSchema")
371}
372
373fn find_handler_line(content: &str) -> Option<u32> {
374 for (index, line_text) in content.lines().enumerate() {
375 let is_export = line_text.contains("export");
376 let is_function = line_text.contains("function") || line_text.contains("async function");
377 if is_export
378 && is_function
379 && HANDLER_METHODS
380 .iter()
381 .any(|method| line_text.contains(method))
382 {
383 return Some(to_line_number(index));
384 }
385 }
386 None
387}
388
389fn parses_with_contract(content: &str) -> bool {
390 content.lines().any(|line_text| {
391 let Some(schema_pos) = line_text.find("Schema") else {
392 return false;
393 };
394 let rest = &line_text[schema_pos..];
395 rest.contains(".parse") || rest.contains(".safeParse")
396 })
397}
398
399fn to_line_number(index: usize) -> u32 {
400 u32::try_from(index)
401 .unwrap_or(u32::MAX - 1)
402 .saturating_add(1)
403}