Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 24 additions & 4 deletions .agents/commands/pr.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,22 +11,42 @@ When activated, create a pull request for the current branch:
- Ensure we're not on `main` (abort if so)
- Run `git log main..HEAD --oneline` to see commits to include

2. **Push the branch** (if not already pushed):
2. **Move completed plan to done** (if applicable):
- Extract an issue/feature token from the branch name using the first rule that matches:
- If the branch (or the portion after a `type/` prefix) starts with digits, use the leading numeric sequence (e.g., `fix/896-buffer-import` → `896`, `123-foo` → `123`)
- Otherwise, if the branch is `type/slug`, use the full kebab-case slug after the slash (e.g., `feature/add-new-widget` → `add-new-widget`)
- Otherwise, use the full branch name (e.g., `fix-typo` → `fix-typo`)
- Search `plans/todo/` for files whose **filename** contains the token (case-insensitive substring match). Do not fall back to scanning file contents.
- If **no match**: skip this step
- If **multiple matches**: list them and skip — require human disambiguation rather than guessing
- If **exactly one match**: inspect its checklist items
- If the file has zero `- [ ]` / `- [x]` items, treat it as "no checklist" and skip the move
- If any `- [ ]` items remain unchecked, leave it in `plans/todo/`
- Only when every checklist item is `- [x]`: `mkdir -p plans/done && git mv plans/todo/<file> plans/done/<file>` and commit `docs: move completed plan to plans/done/`

3. **Push the branch** (if not already pushed):
- Run `git push -u origin <branch-name>`

3. **Generate PR title and body**:
4. **Check for related issues**:
- Look at the branch name for issue numbers (e.g., `fix/896-buffer-import` references #896)
- Check commit messages for issue references
- Run `gh issue list --state open --limit 20` to see recent open issues that might be related
- If the PR resolves an issue, note it for the body

5. **Generate PR title and body**:
- Title: Use conventional commit format based on the primary change (e.g., `feat: add tree-sitter Java pattern matching`)
- Keep the title under 72 characters
- Body should include:
- **Summary**: Brief description of what this PR does
- **Changes**: Bullet list of key changes
- **Testing**: How to test the changes (e.g., `cargo test`, manual CLI usage)
- **Issue references**: Add `Fixes #XXX` or `Closes #XXX` for any issues this PR resolves (these will auto-close the issues when merged)

4. **Create the PR**:
6. **Create the PR**:
```bash
gh pr create --title "<title>" --body "<body>" --base main --assignee @me
```

5. **Report the PR URL** to the user
7. **Report the PR URL** to the user

If the user provides arguments (e.g., `/pr "Custom title"`), use that as the PR title instead of generating one.
1 change: 1 addition & 0 deletions docs/CEDAR_SUPPORT.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,3 +135,4 @@ land on its own merits, not on a wait-for-feedback gate.

- **2026-05-03** — memo drafted alongside the user-facing rename from "Rego for OPA" to "Policy as Code (PaC)." No code changes proposed yet; doc captures the investigation while context is fresh.
- **2026-05-06** — Phase A landed (`feat: add Cedar as a second policy engine`). Phase B's "wait for users" gate and "JSON-schema break needs a major-version bump" gate dropped: the `*_stub` → `policy_outputs` migration is in Phase B's scope and ships with a deserialization shim, so it doesn't need a coordinated version cut.
- **2026-05-06** — Phase B landed (#71). `Finding::rego_stub` / `cedar_stub` collapsed into `policy_outputs: Vec<PolicyOutput>` (legacy fields fold in via a `FindingShim`). `PatternRule::rego_template` / `cedar_template` collapsed into `policy_templates: Vec<PolicyTemplate>` (legacy `[rule.rego_template]` / `[rule.cedar_template]` blocks parse alongside the new `[[rule.policy_templates]]` array). `PolicyGenerator` trait introduced in `src/policy.rs` with `RegoGenerator` / `CedarGenerator` impls; `extract::execute` is now generic over `&dyn PolicyGenerator`, and the MCP `suggest_policy` / `validate_policy` tools dispatch through the trait. JSON output shape changed: `rego_stub` / `cedar_stub` replaced by `policy_outputs`; MCP `get_rule` / `list_rules` now expose `policy_templates` and `template_engines` instead of `rego_template` / `has_rego_template`.
53 changes: 53 additions & 0 deletions src/cedar/generator.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
//! [`PolicyGenerator`] implementation for Cedar. Thin adapter over the
//! engine-specific `templates`, `validator`, and `grouping` modules.

use std::collections::HashMap;
use std::path::Path;

use crate::policy::{PolicyFile, PolicyGenerator, ValidationResult};
use crate::types::{AuthCategory, Confidence, Finding, PolicyEngine};

pub struct CedarGenerator;

impl PolicyGenerator for CedarGenerator {
fn engine(&self) -> PolicyEngine {
PolicyEngine::Cedar
}

fn validate(&self, policy: &str) -> ValidationResult {
let r = super::validator::validate_cedar(policy);
ValidationResult {
valid: r.valid,
error: r.error,
}
}

fn render_template(&self, template: &str, vars: &HashMap<String, String>) -> String {
super::templates::render_template(template, vars)
}

fn default_stub(&self, category: AuthCategory, snippet: &str) -> String {
super::templates::generate_default_stub(category, snippet)
}

fn wrap_by_confidence(&self, body: &str, confidence: Confidence) -> String {
super::templates::apply_confidence_wrapping(body, confidence)
}

fn group_and_generate(
&self,
findings: &[Finding],
policy_prefix: &str,
output_dir: &Path,
) -> Vec<PolicyFile> {
super::grouping::group_findings(findings, policy_prefix, output_dir)
.into_iter()
.map(|f| PolicyFile {
label: f.label,
output_path: f.output_path,
content: f.content,
finding_count: f.finding_count,
})
.collect()
}
}
35 changes: 26 additions & 9 deletions src/cedar/grouping.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};

use crate::types::Finding;
use crate::types::{Finding, PolicyEngine};

use super::templates;

Expand Down Expand Up @@ -130,11 +130,21 @@ fn build_cedar_content(source_file: &Path, findings: &[&Finding]) -> String {
};
lines.push(format!("// Original: {}", truncated.trim()));

let stub = match &finding.cedar_stub {
Some(s) => s.clone(),
None => templates::generate_default_stub(finding.category, &finding.code_snippet),
// The extract pipeline pre-fills `policy_output(Cedar)` for every
// finding before calling into grouping (see
// `commands::extract::run_extract`), so we treat its presence as
// an invariant and skip findings without one rather than silently
// synthesizing a default — that would mask a missing pre-fill.
let Some(stub) = finding.policy_output(PolicyEngine::Cedar) else {
tracing::warn!(
finding_id = %finding.id,
file = %finding.file.display(),
line = finding.line_start,
"skipping finding without Cedar policy output; header counts will overstate coverage"
);
continue;
};
Comment thread
coderabbitai[bot] marked this conversation as resolved.
let wrapped = templates::apply_confidence_wrapping(&stub, finding.confidence);
let wrapped = templates::apply_confidence_wrapping(stub, finding.confidence);
for line in wrapped.lines() {
lines.push(line.to_string());
}
Expand All @@ -149,8 +159,11 @@ mod tests {
use super::*;
use crate::types::*;

/// Mirrors the pre-fill `commands::extract::run_extract` performs
/// before invoking `group_findings`: every finding lands here with a
/// rendered Cedar stub already attached.
fn finding(file: &str, snippet: &str, category: AuthCategory) -> Finding {
Finding {
let mut f = Finding {
id: "x".into(),
file: PathBuf::from(file),
line_start: 10,
Expand All @@ -161,11 +174,15 @@ mod tests {
confidence: Confidence::High,
description: "test".into(),
pattern_rule: Some("test-rule".into()),
rego_stub: None,
cedar_stub: None,
policy_outputs: vec![],
pass: ScanPass::Structural,
surface: Surface::Backend,
}
};
f.set_policy_output(
PolicyEngine::Cedar,
templates::generate_default_stub(f.category, &f.code_snippet),
);
f
}

#[test]
Expand Down
2 changes: 2 additions & 0 deletions src/cedar/mod.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
pub mod generator;
pub mod grouping;
pub mod templates;
pub mod validator;

pub use generator::CedarGenerator;
pub use grouping::group_findings;
pub use templates::render_template;
15 changes: 1 addition & 14 deletions src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -165,20 +165,7 @@ pub struct ExtractArgs {
pub min_confidence: Option<Confidence>,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
pub enum PolicyEngine {
Rego,
Cedar,
}

impl std::fmt::Display for PolicyEngine {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
PolicyEngine::Rego => write!(f, "rego"),
PolicyEngine::Cedar => write!(f, "cedar"),
}
}
}
pub use crate::types::PolicyEngine;

// -- Report --

Expand Down
140 changes: 50 additions & 90 deletions src/commands/extract.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,10 @@ use std::path::Path;

use serde::Deserialize;

use crate::cli::{ExtractArgs, PolicyEngine};
use crate::cli::ExtractArgs;
use crate::config::ZiftConfig;
use crate::error::{Result, ZiftError};
use crate::policy::{PolicyGenerator, generator_for};
use crate::types::Finding;

/// Minimal struct for deserializing scan output — we only need the findings.
Expand Down Expand Up @@ -46,92 +47,40 @@ pub fn execute(args: ExtractArgs, config: ZiftConfig) -> Result<()> {
return Ok(());
}

match args.engine {
PolicyEngine::Rego => extract_rego(&mut findings, policy_prefix, &output_dir),
PolicyEngine::Cedar => extract_cedar(&mut findings, policy_prefix, &output_dir),
}
}

fn extract_rego(findings: &mut [Finding], policy_prefix: &str, output_dir: &Path) -> Result<()> {
use crate::rego::{self, templates};

for finding in findings.iter_mut() {
if finding.rego_stub.is_none() {
finding.rego_stub = Some(templates::generate_default_stub(
finding.category,
&finding.code_snippet,
));
}
}

let rego_files = rego::group_findings(findings, policy_prefix, output_dir);

// Canonicalize once before writing any files. Each generated file shares
// the same `output_dir`, so canonicalizing per-file inside the loop was
// redundant work and a stray `output_dir` rename mid-loop would break
// anyway.
std::fs::create_dir_all(output_dir)?;
let canonical_output_dir = output_dir.canonicalize().map_err(|e| {
ZiftError::General(format!(
"failed to resolve output dir '{}': {e}",
output_dir.display()
))
})?;

let mut total_files = 0;
let mut validation_warnings = 0;
for rego_file in &rego_files {
let validation = rego::validator::validate_rego(&rego_file.content);
let status = if validation.valid { "OK" } else { "WARN" };

write_policy_file(
&rego_file.output_path,
&rego_file.content,
&canonical_output_dir,
)?;
total_files += 1;
eprintln!(
" [{status}] {} ({} findings) → {}",
rego_file.package_name,
rego_file.finding_count,
rego_file.output_path.display(),
);
if let Some(err) = validation.error {
eprintln!(" ⚠ Rego parse warning: {err}");
validation_warnings += 1;
}
}

eprintln!(
"\nGenerated {total_files} Rego files from {} findings.",
findings.len(),
);
if validation_warnings > 0 {
eprintln!(
"{validation_warnings} file(s) have Rego syntax warnings — review before deploying.",
);
}
Ok(())
run_extract(
generator_for(args.engine),
&mut findings,
policy_prefix,
&output_dir,
)
}

fn extract_cedar(findings: &mut [Finding], policy_prefix: &str, output_dir: &Path) -> Result<()> {
use crate::cedar::{self, templates};

// Mirror of the Rego pre-fill: if a finding doesn't carry a
// cedar_stub yet (e.g. it came from an older scan, or its rule has no
// `cedar_template` block), synthesize one from the category default.
// This keeps Cedar coverage at 100% — no rule produces zero output.
/// Engine-agnostic extract pipeline. Pre-fills any missing per-engine
/// policy output on each finding (so Cedar/Rego coverage stays at 100%
/// even when a rule has no template), groups findings into output files,
/// validates each file with the engine's parser, and writes them under a
/// canonicalised `output_dir`.
fn run_extract(
generator: &dyn PolicyGenerator,
findings: &mut [Finding],
policy_prefix: &str,
output_dir: &Path,
) -> Result<()> {
let engine = generator.engine();
let engine_name = engine.human_name();
for finding in findings.iter_mut() {
if finding.cedar_stub.is_none() {
finding.cedar_stub = Some(templates::generate_default_stub(
finding.category,
&finding.code_snippet,
));
if finding.policy_output(engine).is_none() {
let stub = generator.default_stub(finding.category, &finding.code_snippet);
finding.set_policy_output(engine, stub);
}
}

let cedar_files = cedar::group_findings(findings, policy_prefix, output_dir);
let policy_files = generator.group_and_generate(findings, policy_prefix, output_dir);

// Canonicalize once before writing any files. Each generated file
// shares the same `output_dir`, so canonicalizing per-file inside
// the loop was redundant work and a stray `output_dir` rename
// mid-loop would break anyway.
std::fs::create_dir_all(output_dir)?;
let canonical_output_dir = output_dir.canonicalize().map_err(|e| {
ZiftError::General(format!(
Expand All @@ -142,35 +91,35 @@ fn extract_cedar(findings: &mut [Finding], policy_prefix: &str, output_dir: &Pat

let mut total_files = 0;
let mut validation_warnings = 0;
for cedar_file in &cedar_files {
let validation = cedar::validator::validate_cedar(&cedar_file.content);
for policy_file in &policy_files {
let validation = generator.validate(&policy_file.content);
let status = if validation.valid { "OK" } else { "WARN" };

write_policy_file(
&cedar_file.output_path,
&cedar_file.content,
&policy_file.output_path,
&policy_file.content,
&canonical_output_dir,
)?;
total_files += 1;
eprintln!(
" [{status}] {} ({} findings) → {}",
cedar_file.label,
cedar_file.finding_count,
cedar_file.output_path.display(),
policy_file.label,
policy_file.finding_count,
policy_file.output_path.display(),
);
if let Some(err) = validation.error {
eprintln!(" ⚠ Cedar parse warning: {err}");
eprintln!(" ⚠ {engine_name} parse warning: {err}");
validation_warnings += 1;
}
}

eprintln!(
"\nGenerated {total_files} Cedar files from {} findings.",
"\nGenerated {total_files} {engine_name} files from {} findings.",
findings.len(),
);
if validation_warnings > 0 {
eprintln!(
"{validation_warnings} file(s) have Cedar syntax warnings — review before deploying.",
"{validation_warnings} file(s) have {engine_name} syntax warnings — review before deploying.",
);
}
Ok(())
Expand Down Expand Up @@ -202,6 +151,17 @@ fn write_policy_file(output_path: &Path, content: &str, canonical_output_dir: &P
canonical_output_dir.display()
)));
}
// Reject existing symlinks at the leaf — `fs::write` would follow them
// and escape the canonicalised parent containment. Use `symlink_metadata`
// so we inspect the link itself rather than its target.
if let Ok(meta) = std::fs::symlink_metadata(output_path)
&& meta.file_type().is_symlink()
{
return Err(ZiftError::General(format!(
"refusing to write through symlink at output path '{}'",
output_path.display()
)));
}
std::fs::write(output_path, content)?;
Ok(())
}
Expand Down
Loading