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";
13/// F75 — a `Write`/`Edit` to a `pushkin.toml` anywhere but the governing
14/// location. Unwaivable, like the protected- and read-only-path rules: the
15/// manifest is the file that defines what a waiver even is. The CLI owns the
16/// predicate (which path is the governing manifest is a resolution question);
17/// the rule text lives here beside its siblings.
18pub const RULE_NESTED_MANIFEST: &str = "pushkin.nested_manifest";
19pub const RULE_RAW_READ: &str = "pushkin.retrieval.raw_read";
20/// F48 Phase A — a content-requiring rule could not be evaluated because the
21/// mutation carried no content. DISTINCT from `RULE_UNVALIDATED_INPUT` on
22/// purpose: an event log must be able to separate "we could not look" from
23/// "we looked and it was wrong", and Phase B's exit evidence depends on that
24/// separation. Waivable, like its contract-rule sibling — the posture forbids
25/// a SILENT allow, not a loud recorded exception.
26pub 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/// F48 Phase A — the pipeline for a MUTATION that carries no content
65/// (`Edit`'s `old_string`/`new_string`, `MultiEdit`'s `edits[]`). Sibling of
66/// `check_write`, and deliberately not a special case inside it: the two take
67/// different inputs and answer different questions.
68///
69/// Path-decidable rules run exactly as they do for a write, because they never
70/// needed content — a glob is a glob. Content-requiring rules cannot run at
71/// all, so they deny under `RULE_CONTENT_UNAVAILABLE` rather than being
72/// skipped. **Skipping was the defect** (F48): `check_write` with an empty
73/// content string silently passes `check_suppressions` and
74/// `check_boundary_validation`, because neither finds anything to object to in
75/// zero bytes, so a content-absent mutation used to look clean.
76///
77/// The `read_only_paths` half is the CLI's, as always — core stays free of git.
78#[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    // A mapping declaring a content requirement is the trigger: the rule
88    // exists for this path, and this payload cannot satisfy it either way.
89    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/// The path-decidable half of a mutation's verdict — the rules that never
107/// needed content. Split out so F48 Phase B can run them BEFORE attempting a
108/// reconstruction: a path rule must fire whatever the edits would have
109/// produced, and must never be masked by a reconstruction failure.
110#[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
121/// F48 Phase B — the outcome of trying to reconstruct a post-edit file.
122pub enum Synthesis {
123    /// The file was read and the edits applied; judge this content.
124    Content(String),
125    /// No faithful reconstruction was possible. The string explains why, and
126    /// the caller must fall back to the interim refusal — never to an allow.
127    Refused(String),
128}
129
130/// F48 Phase B — reconstruct the file `edits` would produce from `on_disk`.
131///
132/// Pure with respect to the filesystem: the caller reads the file (core stays
133/// free of I/O) and passes what it found, or `None` when it could not be read.
134/// A create-shaped edit against a file that does not exist is `Refused`, not an
135/// empty-content write — the replacement text is not the file.
136///
137/// **Every failure is `Refused`.** That is the whole safety property: Phase A
138/// refused when there was no content, and Phase B refuses when there is no
139/// FAITHFUL content. Returning a best-effort string here is precisely how
140/// fail-closed becomes false-allow.
141#[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/// The interim-conservative refusal. Names the tool, the rule that could not
161/// be evaluated, and the remediation — a deny an agent cannot act on is just
162/// an obstacle.
163#[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
198/// `pushkin/` (waivers, adapter configs, bindings) is gate surface by
199/// construction (integration doc §7: "agents cannot waive"), independent of
200/// what the manifest lists.
201const 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/// The protected-path violation, raised by the pre-commit floor when the
222/// event log shows an agent was denied this exact path and it is staged
223/// anyway. Built here so the rule id and wording stay beside their
224/// siblings; the CLI owns the evidence predicate, since core stays free of
225/// git and the filesystem.
226///
227/// The prose differs from the write-time deny on purpose: by the time the
228/// floor sees it the edit already exists in the tree, so the instruction is
229/// to unstage rather than to not write.
230#[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/// The violation for an agent write to a COMMITTED file under a
252/// `read_only_paths` glob. Built here so the rule id and wording live
253/// beside their siblings, but raised by the CLI layer, which owns the
254/// committed-in-HEAD predicate — core stays free of git and the
255/// filesystem.
256#[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/// F75 — the violation for an agent write to a `pushkin.toml` that is not the
277/// governing manifest. Built here so the rule id and wording live beside their
278/// siblings; the CLI owns the predicate, since which path is the governing
279/// manifest is a resolution question and core stays free of the filesystem.
280///
281/// The prose says WHY the nested file is refused — that it does not govern, and
282/// where a manifest belongs — rather than "you may not". An agent that reads only
283/// the fix hint should not conclude it needs to try harder (charter req 3); a
284/// future feature that wants to read a nested manifest should meet the argument
285/// (req 4).
286#[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/// SPIKE — the violation for an UNBOUNDED agent read of a file under a
309/// `retrieval_paths` glob. Sibling of `read_only_violation`: built here so
310/// the rule id and wording sit beside the others, raised by the CLI layer,
311/// which owns the read-shape predicate.
312///
313/// `tool` is whatever the manifest declares, so the prose redirects to the
314/// repo's chosen index rather than a vendor baked into this crate.
315#[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
395/// Phase 0 parity: `UserCreateSchema` for contract `user`. Phase 3's
396/// symbol-level mapper generalizes this; until then the convention is
397/// `<PascalCase contract>CreateSchema`.
398fn 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}