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_NESTED_MANIFEST: &str = "pushkin.nested_manifest";
19pub const RULE_RAW_READ: &str = "pushkin.retrieval.raw_read";
20pub const RULE_CONTENT_UNAVAILABLE: &str = "pushkin.content_unavailable";
27
28const SUPPRESSION_MARKERS: &[&str] = &[
29 "@ts-ignore",
30 "@ts-expect-error",
31 "@ts-nocheck",
32 "eslint-disable",
33 "noqa",
34 "type: ignore",
35];
36
37const HANDLER_METHODS: &[&str] = &["GET", "POST", "PUT", "PATCH", "DELETE"];
38
39pub struct WriteRequest {
40 pub file_path: String,
41 pub content: String,
42}
43
44#[must_use]
45pub fn check_write(manifest: &Manifest, request: &WriteRequest) -> CheckResult {
46 let started = std::time::Instant::now();
47 let mut violations = Vec::new();
48
49 violations.extend(check_protected_path(manifest, request));
50 violations.extend(check_suppressions(manifest, request));
51 violations.extend(check_boundary_validation(manifest, request));
52
53 CheckResult {
54 decision: if violations.is_empty() {
55 Decision::Allow
56 } else {
57 Decision::Block
58 },
59 violations,
60 duration_ms: started.elapsed().as_secs_f64() * 1000.0,
61 }
62}
63
64#[must_use]
79pub fn check_mutation_without_content(
80 manifest: &Manifest,
81 file_path: &str,
82 tool: &str,
83) -> CheckResult {
84 let started = std::time::Instant::now();
85 let mut violations = check_mutation_path_rules(manifest, file_path);
86
87 if let Some(mapping) = manifest.mapping_for(file_path) {
90 if let Some(requirement) = mapping.require.as_deref() {
91 violations.push(content_unavailable_violation(file_path, tool, requirement));
92 }
93 }
94
95 CheckResult {
96 decision: if violations.is_empty() {
97 Decision::Allow
98 } else {
99 Decision::Block
100 },
101 violations,
102 duration_ms: started.elapsed().as_secs_f64() * 1000.0,
103 }
104}
105
106#[must_use]
111pub fn check_mutation_path_rules(manifest: &Manifest, file_path: &str) -> Vec<Violation> {
112 check_protected_path(
113 manifest,
114 &WriteRequest {
115 file_path: file_path.to_owned(),
116 content: String::new(),
117 },
118 )
119}
120
121pub enum Synthesis {
123 Content(String),
125 Refused(String),
128}
129
130#[must_use]
142pub fn synthesize(on_disk: Option<&str>, edits: &[crate::edits::Replacement]) -> Synthesis {
143 if edits.is_empty() {
144 return Synthesis::Refused(
145 "the mutation carries no reconstructable edit operations".to_owned(),
146 );
147 }
148 let Some(content) = on_disk else {
149 return Synthesis::Refused(
150 "the target file could not be read, so there is nothing to apply the edits to"
151 .to_owned(),
152 );
153 };
154 match crate::edits::apply_edits(content, edits) {
155 Ok(result) => Synthesis::Content(result),
156 Err(error) => Synthesis::Refused(error.to_string()),
157 }
158}
159
160#[must_use]
164pub fn content_unavailable_violation(path: &str, tool: &str, requirement: &str) -> Violation {
165 let blocked = if requirement == "boundary-validation" {
166 RULE_UNVALIDATED_INPUT
167 } else {
168 requirement
169 };
170 Violation {
171 file: path.to_owned(),
172 line: 1,
173 rule: RULE_CONTENT_UNAVAILABLE.to_owned(),
174 contract: None,
175 fix_hint: format!(
176 "`{tool}` carries no file content, so `{blocked}` could not be evaluated for \
177 this path. This is an INTERIM-CONSERVATIVE refusal (F48 Phase A): the gate \
178 refuses rather than allowing a content rule it could not check. Re-issue the \
179 change as a Write carrying the full file content, and the rule will be \
180 evaluated normally."
181 ),
182 suggestions: vec![
183 "Re-issue as Write with the complete file content.".to_owned(),
184 "Content synthesis (F48 Phase B) is live for EVERY edit tool: Claude's \
185 Edit/MultiEdit, auggie's str-replace-editor, opencode's edit, hermes' \
186 patch and codex's apply_patch hunks. A refusal here therefore means the \
187 RECONSTRUCTION failed — see the reason above — not that synthesis is \
188 unavailable. Two families reconstruct on narrower terms: hermes needs an \
189 EXACT unique match because its own matcher is fuzzy, and a codex hunk \
190 needs a unique context because its `@@` scope header is a locator this \
191 gate does not resolve."
192 .to_owned(),
193 ],
194 severity: Severity::Error,
195 }
196}
197
198const BUILTIN_PROTECTED_PREFIX: &str = "pushkin/";
202
203fn check_protected_path(manifest: &Manifest, request: &WriteRequest) -> Vec<Violation> {
204 let builtin = request.file_path.starts_with(BUILTIN_PROTECTED_PREFIX);
205 if !builtin && !manifest.is_protected(&request.file_path) {
206 return Vec::new();
207 }
208 vec![Violation {
209 file: request.file_path.clone(),
210 line: 1,
211 rule: RULE_PROTECTED_PATH.to_owned(),
212 contract: None,
213 fix_hint: "This path is part of Pushkin's own gate surface and may not be edited \
214 by agents. If the change is genuinely required, a human must make it."
215 .to_owned(),
216 suggestions: vec!["Ask the human operator to apply this change manually.".to_owned()],
217 severity: Severity::Error,
218 }]
219}
220
221#[must_use]
231pub fn protected_path_bypass_violation(path: &str) -> Violation {
232 Violation {
233 file: path.to_owned(),
234 line: 1,
235 rule: RULE_PROTECTED_PATH.to_owned(),
236 contract: None,
237 fix_hint: "An agent was denied a write to this protected path, and it is staged \
238 anyway — the edit reached the tree through a surface the write gate \
239 never saw. A human must own this change."
240 .to_owned(),
241 suggestions: vec![
242 format!("git restore --staged --worktree {path}"),
243 "Or, if the change is genuinely required, apply it yourself and commit with \
244 --no-verify."
245 .to_owned(),
246 ],
247 severity: Severity::Error,
248 }
249}
250
251#[must_use]
257pub fn read_only_violation(path: &str) -> Violation {
258 Violation {
259 file: path.to_owned(),
260 line: 1,
261 rule: RULE_READ_ONLY_PATH.to_owned(),
262 contract: None,
263 fix_hint: "This file is committed under a read-only path (N10: committed suites \
264 are read-only). Agents may add NEW files here; a committed file only \
265 a human may change."
266 .to_owned(),
267 suggestions: vec![
268 "Author a new file for new coverage, or ask the human operator to apply \
269 this edit manually."
270 .to_owned(),
271 ],
272 severity: Severity::Error,
273 }
274}
275
276#[must_use]
287pub fn nested_manifest_violation(path: &str) -> Violation {
288 Violation {
289 file: path.to_owned(),
290 line: 1,
291 rule: RULE_NESTED_MANIFEST.to_owned(),
292 contract: None,
293 fix_hint: "This is a pushkin.toml, but not the manifest that governs this repository, \
294 so it controls nothing — and a nested manifest is not read on purpose \
295 (F73): allowing it would let an agent write the rules it is judged by. A \
296 manifest belongs at the repository root, or is named explicitly by the \
297 PUSHKIN_MANIFEST environment variable. This rule is unwaivable."
298 .to_owned(),
299 suggestions: vec![
300 "Put the manifest at the repository root, or point PUSHKIN_MANIFEST at it. If a \
301 nested manifest is genuinely required, a human must own that decision."
302 .to_owned(),
303 ],
304 severity: Severity::Error,
305 }
306}
307
308#[must_use]
316pub fn raw_read_violation(path: &str, tool: Option<&str>) -> Violation {
317 let destination = tool.unwrap_or("the repository's retrieval tool");
318 Violation {
319 file: path.to_owned(),
320 line: 1,
321 rule: RULE_RAW_READ.to_owned(),
322 contract: None,
323 fix_hint: format!(
324 "Whole-file reads of this path are gated. Ask {destination} for the \
325 code you need, or re-issue this read with an explicit offset and \
326 limit naming the range you are about to work on."
327 ),
328 suggestions: vec![
329 format!("Retrieve it: {destination}"),
330 "Or read a range: Read(file_path, offset, limit).".to_owned(),
331 ],
332 severity: Severity::Error,
333 }
334}
335
336fn check_suppressions(manifest: &Manifest, request: &WriteRequest) -> Vec<Violation> {
337 if manifest.mapping_for(&request.file_path).is_none() {
338 return Vec::new();
339 }
340 let mut violations = Vec::new();
341 for (index, line_text) in request.content.lines().enumerate() {
342 let Some(marker) = SUPPRESSION_MARKERS.iter().find(|m| line_text.contains(**m)) else {
343 continue;
344 };
345 violations.push(Violation {
346 file: request.file_path.clone(),
347 line: to_line_number(index),
348 rule: RULE_NEW_SUPPRESSION.to_owned(),
349 contract: None,
350 fix_hint: format!(
351 "Remove the suppression comment ('{marker}') and fix the underlying issue instead."
352 ),
353 suggestions: vec![
354 "Fix the reported type/lint error rather than silencing it.".to_owned()
355 ],
356 severity: Severity::Error,
357 });
358 }
359 violations
360}
361
362fn check_boundary_validation(manifest: &Manifest, request: &WriteRequest) -> Vec<Violation> {
363 let Some(mapping) = manifest.mapping_for(&request.file_path) else {
364 return Vec::new();
365 };
366 if mapping.require.as_deref() != Some("boundary-validation") {
367 return Vec::new();
368 }
369 let Some(handler_line) = find_handler_line(&request.content) else {
370 return Vec::new();
371 };
372 if parses_with_contract(&request.content) {
373 return Vec::new();
374 }
375 let contract = mapping.contracts.first().map(|c| c.as_str().to_owned());
376 let schema_name = schema_symbol(contract.as_deref());
377 vec![Violation {
378 file: request.file_path.clone(),
379 line: handler_line,
380 rule: RULE_UNVALIDATED_INPUT.to_owned(),
381 contract: contract.clone(),
382 fix_hint: format!(
383 "Parse the request body with {schema_name} (contract '{}') before use — e.g. \
384 const body = {schema_name}.parse(await req.json()); — fix and retry the write.",
385 contract.as_deref().unwrap_or("unknown")
386 ),
387 suggestions: vec![
388 format!("import {{ {schema_name} }} from \"contracts/user.zod\""),
389 format!("contract_show {}", contract.as_deref().unwrap_or("unknown")),
390 ],
391 severity: Severity::Error,
392 }]
393}
394
395fn schema_symbol(contract: Option<&str>) -> String {
399 let name = contract.unwrap_or("unknown");
400 let mut pascal = String::new();
401 for part in name.split(['-', '_']) {
402 let mut chars = part.chars();
403 if let Some(first) = chars.next() {
404 pascal.extend(first.to_uppercase());
405 pascal.push_str(chars.as_str());
406 }
407 }
408 format!("{pascal}CreateSchema")
409}
410
411fn find_handler_line(content: &str) -> Option<u32> {
412 for (index, line_text) in content.lines().enumerate() {
413 let is_export = line_text.contains("export");
414 let is_function = line_text.contains("function") || line_text.contains("async function");
415 if is_export
416 && is_function
417 && HANDLER_METHODS
418 .iter()
419 .any(|method| line_text.contains(method))
420 {
421 return Some(to_line_number(index));
422 }
423 }
424 None
425}
426
427fn parses_with_contract(content: &str) -> bool {
428 content.lines().any(|line_text| {
429 let Some(schema_pos) = line_text.find("Schema") else {
430 return false;
431 };
432 let rest = &line_text[schema_pos..];
433 rest.contains(".parse") || rest.contains(".safeParse")
434 })
435}
436
437fn to_line_number(index: usize) -> u32 {
438 u32::try_from(index)
439 .unwrap_or(u32::MAX - 1)
440 .saturating_add(1)
441}