Skip to main content

pushkin_core/
pipeline.rs

1//! The deterministic per-write validation pipeline (spec §8.2, Phase 1
2//! subset: contract conformance heuristic, suppression detection, protected
3//! paths), driven by the manifest instead of Phase 0's hardcoded settings.
4//! Rule IDs and decisions are conformance-locked to the Phase 0 Bun spike.
5
6use 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";
14/// F48 Phase A — a content-requiring rule could not be evaluated because the
15/// mutation carried no content. DISTINCT from `RULE_UNVALIDATED_INPUT` on
16/// purpose: an event log must be able to separate "we could not look" from
17/// "we looked and it was wrong", and Phase B's exit evidence depends on that
18/// separation. Waivable, like its contract-rule sibling — the posture forbids
19/// a SILENT allow, not a loud recorded exception.
20pub 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/// F48 Phase A — the pipeline for a MUTATION that carries no content
59/// (`Edit`'s `old_string`/`new_string`, `MultiEdit`'s `edits[]`). Sibling of
60/// `check_write`, and deliberately not a special case inside it: the two take
61/// different inputs and answer different questions.
62///
63/// Path-decidable rules run exactly as they do for a write, because they never
64/// needed content — a glob is a glob. Content-requiring rules cannot run at
65/// all, so they deny under `RULE_CONTENT_UNAVAILABLE` rather than being
66/// skipped. **Skipping was the defect** (F48): `check_write` with an empty
67/// content string silently passes `check_suppressions` and
68/// `check_boundary_validation`, because neither finds anything to object to in
69/// zero bytes, so a content-absent mutation used to look clean.
70///
71/// The `read_only_paths` half is the CLI's, as always — core stays free of git.
72#[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    // A mapping declaring a content requirement is the trigger: the rule
82    // exists for this path, and this payload cannot satisfy it either way.
83    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/// The path-decidable half of a mutation's verdict — the rules that never
101/// needed content. Split out so F48 Phase B can run them BEFORE attempting a
102/// reconstruction: a path rule must fire whatever the edits would have
103/// produced, and must never be masked by a reconstruction failure.
104#[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
115/// F48 Phase B — the outcome of trying to reconstruct a post-edit file.
116pub enum Synthesis {
117    /// The file was read and the edits applied; judge this content.
118    Content(String),
119    /// No faithful reconstruction was possible. The string explains why, and
120    /// the caller must fall back to the interim refusal — never to an allow.
121    Refused(String),
122}
123
124/// F48 Phase B — reconstruct the file `edits` would produce from `on_disk`.
125///
126/// Pure with respect to the filesystem: the caller reads the file (core stays
127/// free of I/O) and passes what it found, or `None` when it could not be read.
128/// A create-shaped edit against a file that does not exist is `Refused`, not an
129/// empty-content write — the replacement text is not the file.
130///
131/// **Every failure is `Refused`.** That is the whole safety property: Phase A
132/// refused when there was no content, and Phase B refuses when there is no
133/// FAITHFUL content. Returning a best-effort string here is precisely how
134/// fail-closed becomes false-allow.
135#[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/// The interim-conservative refusal. Names the tool, the rule that could not
155/// be evaluated, and the remediation — a deny an agent cannot act on is just
156/// an obstacle.
157#[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
192/// `pushkin/` (waivers, adapter configs, bindings) is gate surface by
193/// construction (integration doc §7: "agents cannot waive"), independent of
194/// what the manifest lists.
195const 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/// The protected-path violation, raised by the pre-commit floor when the
216/// event log shows an agent was denied this exact path and it is staged
217/// anyway. Built here so the rule id and wording stay beside their
218/// siblings; the CLI owns the evidence predicate, since core stays free of
219/// git and the filesystem.
220///
221/// The prose differs from the write-time deny on purpose: by the time the
222/// floor sees it the edit already exists in the tree, so the instruction is
223/// to unstage rather than to not write.
224#[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/// The violation for an agent write to a COMMITTED file under a
246/// `read_only_paths` glob. Built here so the rule id and wording live
247/// beside their siblings, but raised by the CLI layer, which owns the
248/// committed-in-HEAD predicate — core stays free of git and the
249/// filesystem.
250#[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/// SPIKE — the violation for an UNBOUNDED agent read of a file under a
271/// `retrieval_paths` glob. Sibling of `read_only_violation`: built here so
272/// the rule id and wording sit beside the others, raised by the CLI layer,
273/// which owns the read-shape predicate.
274///
275/// `tool` is whatever the manifest declares, so the prose redirects to the
276/// repo's chosen index rather than a vendor baked into this crate.
277#[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
357/// Phase 0 parity: `UserCreateSchema` for contract `user`. Phase 3's
358/// symbol-level mapper generalizes this; until then the convention is
359/// `<PascalCase contract>CreateSchema`.
360fn 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}