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
2 changes: 2 additions & 0 deletions packages/guardrails/profile/agents/ship.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ permission:
"git branch*": allow
"gh pr checks*": allow
"gh pr view*": allow
"gh pr merge * --admin*": deny
"gh pr merge * --auto*": deny
"gh pr merge*": allow
Comment on lines +19 to 21
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The deny rules for gh pr merge flags are ineffective as written: permission evaluation uses the last matching rule, and "gh pr merge*": allow comes after the deny entries, overriding them for --admin/--auto merges. Also, the patterns "gh pr merge * --admin*" / "... * --auto*" can be bypassed by putting the flag first (e.g., gh pr merge --admin 123). Move the deny rules after the allow rule (or narrow the allow rule), and use patterns that match the flag in any position (e.g., without requiring an extra arg before the flag).

Suggested change
"gh pr merge * --admin*": deny
"gh pr merge * --auto*": deny
"gh pr merge*": allow
"gh pr merge*": allow
"gh pr merge*--admin*": deny
"gh pr merge*--auto*": deny

Copilot uses AI. Check for mistakes.
"gh pr list*": allow
"gh api *": ask
Expand Down
67 changes: 41 additions & 26 deletions packages/guardrails/profile/plugins/guardrail.ts
Original file line number Diff line number Diff line change
Expand Up @@ -486,16 +486,21 @@
}

function parseFindings(raw: string) {
// Count only actionable findings — exclude negations like "No CRITICAL issues found"
// Match structured finding patterns: "[CRITICAL]", "**CRITICAL**", "CRITICAL:", "- CRITICAL"
// Exclude negations, injected context lines, and prose mentions
const lines = raw.split("\n")
let critical = 0, high = 0, medium = 0, low = 0
for (const line of lines) {
const neg = /\b(no|zero|0|none|without|aren't|isn't|not)\b/i.test(line)
if (neg) continue
if (/\bCRITICAL\b/i.test(line)) critical++
if (/\bHIGH\b/i.test(line)) high++
if (/\bMEDIUM\b/i.test(line)) medium++
if (/\bLOW\b/i.test(line)) low++
const trimmed = line.trim()
// Skip negation lines
if (/\b(no|zero|0|none|without|aren't|isn't|not)\b/i.test(trimmed)) continue
// Skip injected guardrail context lines (e.g., "CRITICAL=0 HIGH=0")
if (/CRITICAL=\d|HIGH=\d|checklist|Guardrail mode/i.test(trimmed)) continue
// Match structured patterns: leading marker or bracketed/bold severity
Comment on lines +494 to +499
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

parseFindings() skips any line containing the word "not" (and other negation tokens). This will incorrectly ignore legitimate findings like "- CRITICAL: do not ..." or "HIGH: not safe ...", causing undercounting of findings and weakening review gates. Consider tightening negation detection to only match explicit summary lines (e.g., lines that start with "No/Zero/None" + severity), or run the structured severity match first and only apply negation filtering when the line is a negated summary.

Copilot uses AI. Check for mistakes.
if (/^[\s\-*]*\[?CRITICAL\]?[\s:*]/i.test(trimmed) || /^\*\*CRITICAL\*\*/i.test(trimmed)) critical++
if (/^[\s\-*]*\[?HIGH\]?[\s:*]/i.test(trimmed) || /^\*\*HIGH\*\*/i.test(trimmed)) high++
if (/^[\s\-*]*\[?MEDIUM\]?[\s:*]/i.test(trimmed) || /^\*\*MEDIUM\*\*/i.test(trimmed)) medium++
if (/^[\s\-*]*\[?LOW\]?[\s:*]/i.test(trimmed) || /^\*\*LOW\*\*/i.test(trimmed)) low++
}
return { critical, high, medium, low, total: critical + high + medium + low }
}
Expand Down Expand Up @@ -526,11 +531,18 @@
return
}
const findings = parseFindings(result.text)
const attempts = num(data.workflow_review_attempts) + 1
if (attempts >= 3) {
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The review-attempt cap looks off by one: attempts is incremented before the check, and the code blocks when attempts >= 3. That allows only 2 completed review cycles even though the workflow prompt says "max 3 cycles". If the intent is to allow up to 3 attempts, block only when attempts > 3 (or >= 4), and still persist the incremented count consistently.

Suggested change
if (attempts >= 3) {
if (attempts > 3) {

Copilot uses AI. Check for mistakes.
await mark({ auto_review_in_progress: false, workflow_phase: "blocked", workflow_review_attempts: attempts })
await seen("auto_review.max_attempts", { attempts })
return
}
await mark({
auto_review_in_progress: false,
auto_review_session: made.data.id,
review_state: "done",
reviewed: true,
workflow_review_attempts: attempts,
review_at: new Date().toISOString(),
edits_since_review: 0,
review_critical_count: findings.critical,
Expand Down Expand Up @@ -790,21 +802,21 @@
out: { args: Record<string, unknown> },
) => {
const file = pick(out.args ?? item.args)
if (file && (item.tool === "read" || item.tool === "edit" || item.tool === "write" || item.tool === "apply_patch")) {
if (file && (item.tool === "read" || MUTATING_TOOLS.has(item.tool))) {
const err = deny(file, item.tool === "read" ? "read" : "edit")
if (err) {
await mark({ last_block: item.tool, last_file: rel(input.worktree, file), last_reason: err })
throw new Error(text(err))
}
}
if (item.tool === "edit" || item.tool === "write" || item.tool === "apply_patch") {
if (MUTATING_TOOLS.has(item.tool)) {
const err = await version(out.args ?? {})
if (err) {
await mark({ last_block: item.tool, last_file: file ? rel(input.worktree, file) : "", last_reason: err })
throw new Error(text(err))
}
}
if ((item.tool === "edit" || item.tool === "write" || item.tool === "apply_patch") && file && code(file)) {
if ((MUTATING_TOOLS.has(item.tool)) && file && code(file)) {
const count = await budget()
if (count >= 4) {
const budgetData = await stash(state)
Expand Down Expand Up @@ -1528,12 +1540,15 @@
}

// Workflow phase transitions (auto-pipeline orchestration)
// Only transition when the pipeline is active — prevents state pollution from manual operations
if (item.tool === "bash") {
const cmd = str(item.args?.command)
const output = str(out.output)
const currentPhase = str(bashData.workflow_phase)

Check failure on line 1547 in packages/guardrails/profile/plugins/guardrail.ts

View workflow job for this annotation

GitHub Actions / unit (linux)

ReferenceError: bashData is not defined

at <anonymous> (/home/runner/work/opencode/opencode/packages/guardrails/profile/plugins/guardrail.ts:1547:34)

Check failure on line 1547 in packages/guardrails/profile/plugins/guardrail.ts

View workflow job for this annotation

GitHub Actions / unit (linux)

ReferenceError: bashData is not defined

at <anonymous> (/home/runner/work/opencode/opencode/packages/guardrails/profile/plugins/guardrail.ts:1547:34)

Check failure on line 1547 in packages/guardrails/profile/plugins/guardrail.ts

View workflow job for this annotation

GitHub Actions / unit (linux)

ReferenceError: bashData is not defined

at <anonymous> (/home/runner/work/opencode/opencode/packages/guardrails/profile/plugins/guardrail.ts:1547:34)

Check failure on line 1547 in packages/guardrails/profile/plugins/guardrail.ts

View workflow job for this annotation

GitHub Actions / unit (linux)

ReferenceError: bashData is not defined

at <anonymous> (/home/runner/work/opencode/opencode/packages/guardrails/profile/plugins/guardrail.ts:1547:34)
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bashData is referenced here but is not defined in the tool.execute.after hook scope (only data is). This will fail typecheck / runtime. Use the data already loaded in this hook, or fetch a fresh snapshot with await stash(state) inside this block before reading workflow_phase.

Suggested change
const currentPhase = str(bashData.workflow_phase)
const currentPhase = str(data.workflow_phase)

Copilot uses AI. Check for mistakes.
const pipelineActive = currentPhase && currentPhase !== "idle" && currentPhase !== "done" && currentPhase !== "blocked"

// PR creation detection
if (/\bgh\s+pr\s+create\b/.test(cmd) && output.includes("github.com")) {
// PR creation detection — only when implementing
if (pipelineActive && currentPhase === "implementing" && /\bgh\s+pr\s+create\b/.test(cmd) && output.includes("github.com")) {
const prUrl = output.trim().match(/https:\/\/github\.com\/[^\s]+/)
if (prUrl) {
await mark({ workflow_phase: "testing", workflow_pr_url: prUrl[0] })
Expand All @@ -1542,8 +1557,8 @@
}
}

// Test execution detection
if (/\b(bun\s+test|pytest|go\s+test|npm\s+test|vitest|jest)\b/.test(cmd)) {
// Test execution detection — only when testing
if (pipelineActive && currentPhase === "testing" && /\b(bun\s+test|pytest|go\s+test|npm\s+test|vitest|jest)\b/.test(cmd)) {
const testExit = out.metadata?.exitCode ?? (output.includes("FAIL") ? 1 : 0)
if (testExit === 0 && !output.includes("FAIL")) {
await mark({ workflow_phase: "reviewing", tests_executed: true })
Expand All @@ -1552,19 +1567,17 @@
}
}

// Merge detection
if (/\bgh\s+pr\s+merge\b/.test(cmd) && !output.includes("error") && !output.includes("failed")) {
// Merge detection — only when shipping
if (pipelineActive && currentPhase === "shipping" && /\bgh\s+pr\s+merge\b/.test(cmd) && !output.includes("error") && !output.includes("failed")) {
await mark({ workflow_phase: "done" })
await seen("workflow.merged", {})
out.output += "\n\n[WORKFLOW] Merge complete. Create follow-up issues for any discovered problems."
}

// CI check detection
if (/\bgh\s+pr\s+checks\b/.test(cmd)) {
const allPass = !output.includes("fail") && !output.includes("pending")
if (allPass) {
await mark({ ci_green: true })
}
// CI check detection — any active phase
if (pipelineActive && /\bgh\s+pr\s+checks\b/.test(cmd)) {
const allPass = !output.toLowerCase().includes("fail") && !output.toLowerCase().includes("pending")
await mark({ ci_green: allPass })
}

// Issue creation tracking
Expand Down Expand Up @@ -1823,10 +1836,12 @@
`[WORKFLOW] Phase: ${phase}. Pipeline: implement→test→review→fix→ship. Complete autonomously. Do not stop at PR creation.`
)
}
// Follow-up issue guidance
out.system.push(
"When you discover problems outside the current scope, create follow-up issues: `gh issue create --title '<desc>' --body '<details>' --label 'tech-debt'`. Do NOT fix out-of-scope problems inline."
)
// Follow-up issue guidance — only during active pipeline
if (phase && phase !== "idle" && phase !== "done") {
out.system.push(
"When you discover problems outside the current scope, create follow-up issues: `gh issue create --title '<desc>' --body '<details>' --label 'tech-debt'`. Do NOT fix out-of-scope problems inline."
)
}
Comment on lines +1839 to +1844
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment says follow-up issue guidance is shown only during an "active pipeline", but the condition includes the "blocked" phase (it only excludes idle/done). If blocked should not be treated as active, align this condition with the pipelineActive logic (exclude "blocked" too).

Copilot uses AI. Check for mistakes.
},
}
}
2 changes: 1 addition & 1 deletion packages/guardrails/profile/plugins/team.ts
Original file line number Diff line number Diff line change
Expand Up @@ -338,7 +338,7 @@ async function merge(dir: string, item: string, run: string, id: string) {
if (files.length > 0) {
await git(dir, ["add", "--", ...files])
const commitMsg = `chore(team): apply worker changes from task ${id}`
const commit = await git(dir, ["commit", "-m", commitMsg, "--no-verify"])
const commit = await git(dir, ["commit", "-m", commitMsg])
if (commit.code !== 0 && !commit.err.includes("nothing to commit")) {
verification.issues.push(`Auto-commit failed: ${commit.err || commit.out}`)
}
Expand Down
Loading