pushkin-core 0.2.0

Core envelope, manifest, pipeline, and waiver types for the pushkin write-gate
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
//! The deterministic per-write validation pipeline (spec §8.2, Phase 1
//! subset: contract conformance heuristic, suppression detection, protected
//! paths), driven by the manifest instead of Phase 0's hardcoded settings.
//! Rule IDs and decisions are conformance-locked to the Phase 0 Bun spike.

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";
/// F48 Phase A — a content-requiring rule could not be evaluated because the
/// mutation carried no content. DISTINCT from `RULE_UNVALIDATED_INPUT` on
/// purpose: an event log must be able to separate "we could not look" from
/// "we looked and it was wrong", and Phase B's exit evidence depends on that
/// separation. Waivable, like its contract-rule sibling — the posture forbids
/// a SILENT allow, not a loud recorded exception.
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,
    }
}

/// F48 Phase A — the pipeline for a MUTATION that carries no content
/// (`Edit`'s `old_string`/`new_string`, `MultiEdit`'s `edits[]`). Sibling of
/// `check_write`, and deliberately not a special case inside it: the two take
/// different inputs and answer different questions.
///
/// Path-decidable rules run exactly as they do for a write, because they never
/// needed content — a glob is a glob. Content-requiring rules cannot run at
/// all, so they deny under `RULE_CONTENT_UNAVAILABLE` rather than being
/// skipped. **Skipping was the defect** (F48): `check_write` with an empty
/// content string silently passes `check_suppressions` and
/// `check_boundary_validation`, because neither finds anything to object to in
/// zero bytes, so a content-absent mutation used to look clean.
///
/// The `read_only_paths` half is the CLI's, as always — core stays free of git.
#[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);

    // A mapping declaring a content requirement is the trigger: the rule
    // exists for this path, and this payload cannot satisfy it either way.
    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,
    }
}

/// The path-decidable half of a mutation's verdict — the rules that never
/// needed content. Split out so F48 Phase B can run them BEFORE attempting a
/// reconstruction: a path rule must fire whatever the edits would have
/// produced, and must never be masked by a reconstruction failure.
#[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(),
        },
    )
}

/// F48 Phase B — the outcome of trying to reconstruct a post-edit file.
pub enum Synthesis {
    /// The file was read and the edits applied; judge this content.
    Content(String),
    /// No faithful reconstruction was possible. The string explains why, and
    /// the caller must fall back to the interim refusal — never to an allow.
    Refused(String),
}

/// F48 Phase B — reconstruct the file `edits` would produce from `on_disk`.
///
/// Pure with respect to the filesystem: the caller reads the file (core stays
/// free of I/O) and passes what it found, or `None` when it could not be read.
/// A create-shaped edit against a file that does not exist is `Refused`, not an
/// empty-content write — the replacement text is not the file.
///
/// **Every failure is `Refused`.** That is the whole safety property: Phase A
/// refused when there was no content, and Phase B refuses when there is no
/// FAITHFUL content. Returning a best-effort string here is precisely how
/// fail-closed becomes false-allow.
#[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()),
    }
}

/// The interim-conservative refusal. Names the tool, the rule that could not
/// be evaluated, and the remediation — a deny an agent cannot act on is just
/// an obstacle.
#[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,
    }
}

/// `pushkin/` (waivers, adapter configs, bindings) is gate surface by
/// construction (integration doc §7: "agents cannot waive"), independent of
/// what the manifest lists.
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,
    }]
}

/// The protected-path violation, raised by the pre-commit floor when the
/// event log shows an agent was denied this exact path and it is staged
/// anyway. Built here so the rule id and wording stay beside their
/// siblings; the CLI owns the evidence predicate, since core stays free of
/// git and the filesystem.
///
/// The prose differs from the write-time deny on purpose: by the time the
/// floor sees it the edit already exists in the tree, so the instruction is
/// to unstage rather than to not write.
#[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,
    }
}

/// The violation for an agent write to a COMMITTED file under a
/// `read_only_paths` glob. Built here so the rule id and wording live
/// beside their siblings, but raised by the CLI layer, which owns the
/// committed-in-HEAD predicate — core stays free of git and the
/// filesystem.
#[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,
    }
}

/// SPIKE — the violation for an UNBOUNDED agent read of a file under a
/// `retrieval_paths` glob. Sibling of `read_only_violation`: built here so
/// the rule id and wording sit beside the others, raised by the CLI layer,
/// which owns the read-shape predicate.
///
/// `tool` is whatever the manifest declares, so the prose redirects to the
/// repo's chosen index rather than a vendor baked into this crate.
#[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,
    }]
}

/// Phase 0 parity: `UserCreateSchema` for contract `user`. Phase 3's
/// symbol-level mapper generalizes this; until then the convention is
/// `<PascalCase contract>CreateSchema`.
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)
}