Skip to content

feat: [de-fork S3] HardPolicy kill gate — non-bypassable DDL denies at every dispatcher#1018

Open
anandgupta42 wants to merge 4 commits into
mainfrom
defork/s3-hardpolicy
Open

feat: [de-fork S3] HardPolicy kill gate — non-bypassable DDL denies at every dispatcher#1018
anandgupta42 wants to merge 4 commits into
mainfrom
defork/s3-hardpolicy

Conversation

@anandgupta42

@anandgupta42 anandgupta42 commented Jul 19, 2026

Copy link
Copy Markdown
Contributor

PINEAPPLE

Summary

Stage S3 of the de-fork spike — the kill gate. Enforces non-bypassable hard denies (sql_execute DDL: DROP DATABASE/DROP SCHEMA/TRUNCATE; bash DDL) at every model-invoked tool-execution dispatcher, relocating today's scattered per-tool safety checks into one audited chokepoint. Behavior-preserving — these operations already block; S3 consolidates and closes a bypass.

  • NEW packages/opencode/src/altimate/policy/hard-policy.ts — pure, synchronous, total HardPolicy.check(): malformed/missing args return a deny (policy_internal_error), never throw, never implicit-allow. Emits a structured audit record on every call — this (not the trace) is the enforcement oracle the tests assert against.
  • HardPolicy.check() inserted before tool.execute at every dispatcher from the S2 route matrix: D1 (registry loop) + D2 (MCP loop) + D6 (direct Task dispatch) in session/prompt.ts, D3/D4 in session/tools.ts, D5 (BatchTool inner dispatch) in tool/batch.ts. Fail-closed init via assertInitialized() at the app-runtime composition seam.
  • Closes a real bypass: the fork's resolvers were discarding tool.execute.before's return value, so a plugin hook that mutated args (e.g. rewrote a benign SELECT into DROP DATABASE) executed the mutation unchecked. Every site now captures the post-hook final args and checks those. The batch inner dispatch checks the inner tool ID + args (not toolID="batch"), closing the nested-dispatch gap.
  • On deny: returns a hard_policy_denied tool-error result (or throws in batch) — execute and tool.execute.after are both skipped.

Scope preserved: bash DDL patterns are byte-identical to today's agent.ts safety table; the sql check reuses sql-execute.ts's classifier + error string. rm -rf / git push --force / git reset --hard correctly stay ask-tier (not promoted to hard deny).

Also includes route-sentinels.test.ts made line-number- and arg-agnostic (matches args or S3's finalArgs) so it passes with or without S3 applied — shared identically with PR #1014, mergeable in any order.

Review

Two independent adversarial reviews (a Claude reviewer + the orchestrator's own trace of every .execute() site) found no bypass via malformed args, mutated args, or allow-all permission config, and confirmed all 7 safety properties (coverage, final-args, total-function, deny-terminal, config-independence, fail-closed init, behavior-preservation).

Test Plan

  • bun test test/altimate/defork/hardpolicy.test.ts: 19 pass / 0 fail — the bypass matrix across D1–D6: DDL denied + not executed at every route, near-miss controls allowed (proves rules aren't over-broad), allow-all config still denies, malformed args fail closed, deny is terminal, mutated-args checked on the post-hook value (audit finalArgsDigest asserted).
  • Always-allow red-proof: stubbing check() to always-allow fails every dispatcher-integration test.
  • bunx tsc --noEmit -p packages/opencode: clean for the changed files.

Checklist

  • Tests added/updated
  • Documentation updated (spec §S3 + route matrix)
  • CHANGELOG updated — N/A (internal safety guardrail)

Closes #1017


This is a security boundary and the spike's decision checkpoint — held for human go/no-go; do not auto-merge.


Summary by cubic

Adds a centralized kill gate (HardPolicy) that enforces non-bypassable denies for destructive SQL/Bash DDL across all tool-execution dispatchers, with audited decisions. Follow-up fixes close whitespace and MCP-prefix bypasses, harden audit hashing, and make tool-call metadata reflect post-hook args. Closes #1017.

  • New Features

    • New packages/opencode/src/altimate/policy/hard-policy.ts with pure, sync, total HardPolicy.check() that emits an audit record on every call.
    • Fail-closed init via HardPolicy.assertInitialized() in packages/opencode/src/effect/app-runtime.ts.
    • Enforcement added before execute in all routes; on deny, return hard_policy_denied (or throw in batch) and skip execute/tool.execute.after.
  • Bug Fixes

    • Closed bypasses: MCP-prefixed governed keys now resolve via _-suffix matching; bash DDL matcher trims leading/trailing whitespace before checks.
    • Audit hardened: finalArgsDigest is a SHA-256 of stable-stringified args; stable stringify now encodes Map/Set; audit sink wrapped in try/catch and log trimming is amortized O(1).
    • Final-args fidelity: post-hook args are used for HardPolicy.check(), execute, audit, and ctx.metadata; batch checks the inner tool ID + args.

Written for commit 348e930. Summary will update on new commits.

Review in cubic

Summary by CodeRabbit

  • New Features

    • Added centralized, fail-closed hard policy enforcement blocking dangerous SQL and Bash actions (DROP DATABASE/SCHEMA, TRUNCATE) across native, MCP, batch, and task execution.
    • Policy checks now use post-hook arguments, with consistent “Blocked by policy” results when denied.
    • Introduced deterministic audit logging for every decision and an optional audit sink; enforcement initialization is validated at module startup.
  • Tests

    • Added comprehensive coverage for allow/deny behavior, audit/oracle expectations, post-hook argument handling, MCP/batch/task enforcement, and execution-route sentinels.

## Summary

Stage **S3** of the de-fork spike — **the kill gate**. Enforces **non-bypassable hard denies** (`sql_execute` DDL: `DROP DATABASE`/`DROP SCHEMA`/`TRUNCATE`; bash DDL) at **every model-invoked tool-execution dispatcher**, relocating today's scattered per-tool safety checks into one audited chokepoint. **Behavior-preserving** — these operations already block; S3 consolidates and closes a bypass.

- **NEW `packages/opencode/src/altimate/policy/hard-policy.ts`** — pure, synchronous, **total** `HardPolicy.check()`: malformed/missing args return a deny (`policy_internal_error`), never throw, never implicit-allow. Emits a structured **audit record** on every call — this (not the trace) is the enforcement oracle the tests assert against.
- **`HardPolicy.check()` inserted before `tool.execute` at every dispatcher** from the S2 route matrix: D1 (registry loop) + D2 (MCP loop) + D6 (direct Task dispatch) in `session/prompt.ts`, D3/D4 in `session/tools.ts`, D5 (BatchTool inner dispatch) in `tool/batch.ts`. Fail-closed init via `assertInitialized()` at the app-runtime composition seam.
- **Closes a real bypass:** the fork's resolvers were *discarding* `tool.execute.before`'s return value, so a plugin hook that mutated args (e.g. rewrote a benign `SELECT` into `DROP DATABASE`) executed the mutation **unchecked**. Every site now captures the post-hook **final args** and checks *those*. The batch inner dispatch checks the **inner** tool ID + args (not `toolID="batch"`), closing the nested-dispatch gap.
- **On deny:** returns a `hard_policy_denied` tool-error result (or throws in batch) — `execute` and `tool.execute.after` are both skipped.

**Scope preserved:** bash DDL patterns are byte-identical to today's `agent.ts` safety table; the sql check reuses `sql-execute.ts`'s classifier + error string. `rm -rf` / `git push --force` / `git reset --hard` correctly stay **ask-tier** (not promoted to hard deny).

Also includes `route-sentinels.test.ts` made **line-number- and arg-agnostic** (matches `args` or S3's `finalArgs`) so it passes with or without S3 applied — shared identically with PR #1014, mergeable in any order.

### Review
Two independent adversarial reviews (a Claude reviewer + the orchestrator's own trace of every `.execute()` site) found **no bypass** via malformed args, mutated args, or allow-all permission config, and confirmed all 7 safety properties (coverage, final-args, total-function, deny-terminal, config-independence, fail-closed init, behavior-preservation).

## Test Plan
- `bun test test/altimate/defork/hardpolicy.test.ts`: **19 pass / 0 fail** — the bypass matrix across D1–D6: DDL denied + not executed at every route, near-miss controls allowed (proves rules aren't over-broad), allow-all config still denies, malformed args fail closed, deny is terminal, mutated-args checked on the post-hook value (audit `finalArgsDigest` asserted).
- Always-allow red-proof: stubbing `check()` to always-allow fails every dispatcher-integration test.
- `bunx tsc --noEmit -p packages/opencode`: clean for the changed files.

## Checklist
- [x] Tests added/updated
- [x] Documentation updated (spec §S3 + route matrix)
- [ ] CHANGELOG updated — N/A (internal safety guardrail)

Closes #1017

---
_This is a security boundary and the spike's decision checkpoint — **held for human go/no-go; do not auto-merge.**_

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Claude Code Review

This repository is configured for manual code reviews. Comment @claude review for a one-time review, or @claude review always to subscribe this PR to a review on every future push.

Tip: disable this comment in your organization's Code Review settings.

@github-actions

Copy link
Copy Markdown

This PR doesn't fully meet our contributing guidelines and PR template.

What needs to be fixed:

  • PR description is missing required template sections. Please use the PR template.

Please edit this PR description to address the above within 2 hours, or it will be automatically closed.

If you believe this was flagged incorrectly, please let a maintainer know.

@coderabbitai

coderabbitai Bot commented Jul 19, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Changes

HardPolicy enforcement

Layer / File(s) Summary
Policy contract and fail-closed evaluation
packages/opencode/src/altimate/policy/hard-policy.ts, packages/opencode/src/effect/app-runtime.ts
Adds validated SQL and bash DDL rules, synchronous fail-closed checks, deterministic argument digests, audit records, and initialization enforcement.
Dispatcher enforcement and final arguments
packages/opencode/src/session/prompt.ts, packages/opencode/src/session/tools.ts, packages/opencode/src/tool/batch.ts
Checks post-hook arguments before native, MCP, task, and batch execution, returning or propagating terminal policy denials.
Policy and dispatcher behavior tests
packages/opencode/test/altimate/defork/hardpolicy.test.ts
Covers policy semantics, audit emission, post-hook mutations, registry and MCP denial, batch denial, and task execution behavior.
Execution route sentinel coverage
packages/opencode/test/altimate/defork/route-sentinels.test.ts
Verifies registry, session, batch, and task execution routes and pins their dispatch chokepoints structurally.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Model
  participant Plugin
  participant HardPolicy
  participant ToolDispatcher
  Model->>Plugin: tool.execute.before(args)
  Plugin-->>Model: finalArgs
  Model->>HardPolicy: check(finalArgs)
  alt denied
    HardPolicy-->>Model: policy denial
  else allowed
    HardPolicy-->>Model: allow
    Model->>ToolDispatcher: execute(finalArgs)
  end
Loading

Possibly related issues

Possibly related PRs

Poem

A bunny guards the SQL gate,
“Drop commands must now wait!”
Hooks may change the args in flight,
The final form is checked just right.
Audit crumbs hop down the trail,
And blocked tools leave no tale.

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 38.10% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Description check ⚠️ Warning The description is off-template: it omits the required sections and doesn't provide the repo's issue, type, verification, or checklist fields. Rewrite it to match the template with Issue, Type of change, What does this PR do?, verification, screenshots/recordings if needed, and checklist items.
✅ Passed checks (3 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The PR implements the S3 objectives: central HardPolicy, fail-closed init, post-hook final-args checks, terminal denials, and bypass-matrix tests.
Out of Scope Changes check ✅ Passed The added tests and sentinels are tied to dispatcher coverage and bypass prevention, with no clearly unrelated code changes evident.
Title check ✅ Passed The title clearly summarizes the main change: adding a non-bypassable HardPolicy gate for destructive DDL.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch defork/s3-hardpolicy

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

Copy link
Copy Markdown

👋 This PR was automatically closed by our quality checks.

Common reasons:

  • New GitHub account with limited contribution history
  • PR description doesn't meet our guidelines
  • Contribution appears to be AI-generated without meaningful review

If you believe this was a mistake, please open an issue explaining your intended contribution and a maintainer will help you.

1 similar comment
@github-actions

Copy link
Copy Markdown

👋 This PR was automatically closed by our quality checks.

Common reasons:

  • New GitHub account with limited contribution history
  • PR description doesn't meet our guidelines
  • Contribution appears to be AI-generated without meaningful review

If you believe this was a mistake, please open an issue explaining your intended contribution and a maintainer will help you.

decision = { allow: false, ruleID: INTERNAL_ERROR_RULE_ID, safeReason: INTERNAL_ERROR_SAFE_REASON }
}

emitAudit({

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

WARNING: emitAudit({...}) is outside the try/catch in check(), so a throwing auditSink callback (or an unlikely push/shift failure) propagates out and breaks the module's stated "TOTAL: never throws" contract.

The dispatchers call HardPolicy.check() without a surrounding try/catch (e.g. session/prompt.ts:1634, session/tools.ts:105, tool/batch.ts:110), so an audit-side throw surfaces as an unexpected tool error instead of the clean hard_policy_denied result the chokepoint is supposed to return. auditSink is null by default in production so the live risk is low, but the contract should not rely on that. Wrapping this emitAudit({...}) call in its own try { ... } catch { /* audit failure must not affect the decision */ } keeps the already-computed decision returned regardless of audit-side failures.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.


export interface AuditRecord {
ruleID: string
dispatcher: Source

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

SUGGESTION: The dispatcher field is redundant with source.

In emitAudit both dispatcher and source are populated from the same local variable (lines 325-327), and no test ever asserts on dispatcher. Since Input.source is the canonical field name and is what every dispatcher passes, dispatcher can be dropped from AuditRecord (and the emitter) without losing any information.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.


function emitAudit(record: AuditRecord): void {
auditLog.push(record)
if (auditLog.length > MAX_AUDIT_LOG) auditLog.shift()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

SUGGESTION: auditLog.shift() is O(n) on every emit once the log is full.

check() runs on every tool execution. Once auditLog.length > 1000, every subsequent call pays an O(1000) shift() just to make room. A bounded ring buffer (index + overwrite) or a batched trim (if (overflow > 0) auditLog.splice(0, overflow) once per push, or every N pushes) would make the cap O(1) amortized. Minor, but it is permanent overhead on the tool-execution hot path with no production reader of the log.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

@kilo-code-bot

kilo-code-bot Bot commented Jul 19, 2026

Copy link
Copy Markdown

Code Review Summary

Status: 1 Issue Found | Recommendation: Merge

Incremental review of commit 348e930 (review round 3 follow-up). The new commit closes two real correctness gaps without introducing new issues: matchBashDdl now trims args.command before the ^-anchored Wildcard.match (verified in util/wildcard.ts:18 — untrimmed leading whitespace would have bypassed DROP DATABASE *), and stableStringify now serializes Map/Set contents under distinguishing tags so distinct collections no longer collide to {} in the audit digest. The tools.ts ctx relocation is behavior-equivalent for session/call IDs (both still resolve to input.session.id / options.toolCallId); its only semantic effect is that ctx.metadata() tool-call records now reflect the post-hook finalArgs. New tests cover both fixes.

Overview

Severity Count
CRITICAL 0
WARNING 0
SUGGESTION 1
Issue Details (click to expand)

SUGGESTION

File Line Issue
packages/opencode/src/altimate/policy/hard-policy.ts 48 AuditRecord.dispatcher is always equal to AuditRecord.source (both populated from the same source local in emitAudit at line 380); one is redundant. Not on a line changed by this commit — re-verified, still applies.
Files Reviewed (3 files)
  • packages/opencode/src/altimate/policy/hard-policy.ts — bash whitespace trim (closes ^-anchor bypass) + Map/Set digest collision fix; clean
  • packages/opencode/src/session/tools.tsctx relocated to post-hook so ctx.metadata() records finalArgs; behavior-equivalent; clean
  • packages/opencode/test/altimate/defork/hardpolicy.test.ts — new tests for the whitespace + Map/Set fixes; clean

Fix these issues in Kilo Cloud

Previous Review Summaries (3 snapshots, latest commit 3ac7b68)

Current summary above is authoritative. Previous snapshots are kept for context only.

Previous review (commit 3ac7b68)

Status: 1 Issue Found | Recommendation: Merge

Incremental review of commit 2884d7e (the prior 412ed1d review's follow-up). The new commit resolves two of the three previously-raised findings: emitAudit now wraps the externally-installed auditSink in try/catch (restoring the "never throws" contract) and replaces the per-emit shift() with a batched splice() (O(1) amortized trim). No new issues were introduced in the changed code.

Overview

Severity Count
CRITICAL 0
WARNING 0
SUGGESTION 1
Issue Details (click to expand)

SUGGESTION

File Line Issue
packages/opencode/src/altimate/policy/hard-policy.ts 47 AuditRecord.dispatcher is always equal to AuditRecord.source (both populated from the same local in emitAudit); one is redundant. Carried forward — not in this commit's changed lines.
Files Reviewed (2 files)
  • packages/opencode/src/altimate/policy/hard-policy.tsemitAudit hardened (sink isolated from enforcement; batched trim); 1 carried-forward suggestion on an unchanged line
  • packages/opencode/test/altimate/defork/route-sentinels.test.ts — D3/D4 latent guard broadened to scan the full production src tree; clean

Fix these issues in Kilo Cloud

Previous review (commit 2884d7e)

Status: 1 Issue Found | Recommendation: Merge

Incremental review of commit 2884d7e (the prior 412ed1d review's follow-up). The new commit resolves two of the three previously-raised findings: emitAudit now wraps the externally-installed auditSink in try/catch (restoring the "never throws" contract) and replaces the per-emit shift() with a batched splice() (O(1) amortized trim). No new issues were introduced in the changed code.

Overview

Severity Count
CRITICAL 0
WARNING 0
SUGGESTION 1
Issue Details (click to expand)

SUGGESTION

File Line Issue
packages/opencode/src/altimate/policy/hard-policy.ts 47 AuditRecord.dispatcher is always equal to AuditRecord.source (both populated from the same local in emitAudit); one is redundant. Carried forward — not in this commit's changed lines.
Files Reviewed (2 files)
  • packages/opencode/src/altimate/policy/hard-policy.tsemitAudit hardened (sink isolated from enforcement; batched trim); 1 carried-forward suggestion on an unchanged line
  • packages/opencode/test/altimate/defork/route-sentinels.test.ts — D3/D4 latent guard broadened to scan the full production src tree; clean

Fix these issues in Kilo Cloud

Previous review (commit 412ed1d)

Status: 3 Issues Found | Recommendation: Address before merge

Overview

Severity Count
CRITICAL 0
WARNING 1
SUGGESTION 2
Issue Details (click to expand)

WARNING

File Line Issue
packages/opencode/src/altimate/policy/hard-policy.ts 323 emitAudit({...}) is outside check()'s try/catch; a throwing auditSink breaks the documented "never throws" contract and propagates to dispatchers that don't catch it.

SUGGESTION

File Line Issue
packages/opencode/src/altimate/policy/hard-policy.ts 47 AuditRecord.dispatcher is always equal to AuditRecord.source; one is redundant.
packages/opencode/src/altimate/policy/hard-policy.ts 218 auditLog.shift() is O(n) on every emit once the 1000-entry cap fills; ring buffer / batched trim would be O(1) amortized.
Files Reviewed (7 files)
  • packages/opencode/src/altimate/policy/hard-policy.ts — 3 issues (new HardPolicy chokepoint module)
  • packages/opencode/src/effect/app-runtime.ts — fail-closed assertInitialized() at composition seam; clean
  • packages/opencode/src/session/prompt.ts — D1/D2/D6 dispatchers route through HardPolicy.check() on post-hook final args; clean
  • packages/opencode/src/session/tools.ts — D3/D4 dispatchers route through HardPolicy.check() on post-hook final args; clean
  • packages/opencode/src/tool/batch.ts — D5 BatchTool inner dispatch checks the inner tool ID + validated params; clean
  • packages/opencode/test/altimate/defork/hardpolicy.test.ts — 19 dispatcher-matrix tests; clean
  • packages/opencode/test/altimate/defork/route-sentinels.test.ts — S2 route sentinels; clean

Security boundary assessment

The PR's core claim — HardPolicy.check() is reachable on the post-tool.execute.before final args at every model-invoked tool-execution dispatcher (D1–D6), deny skips both execute and tool.execute.after, malformed args fail closed, ungoverned toolIDs allow — holds under review. No bypass found via mutated args, allow-all permission, or batched/nested dispatch. The findings above are contract/perf/polish issues in the new module, not enforcement gaps.

Fix these issues in Kilo Cloud


Reviewed by glm-5.2 · Input: 62K · Output: 12.1K · Cached: 834.8K

Review guidance: REVIEW.md from base branch main

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🧹 Nitpick comments (1)
packages/opencode/test/altimate/defork/hardpolicy.test.ts (1)

904-908: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Prefer the FileSystem.FileSystem Effect service over raw fs/promises.

NodeFileSystem is already composed into this file's test layer, so writeText can yield FileSystem.FileSystem and use fs.writeFileString inside Effect.gen instead of dynamically importing fs/promises within Effect.promise.

As per coding guidelines: "Prefer FileSystem.FileSystem instead of raw fs/promises for effectful file I/O".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/opencode/test/altimate/defork/hardpolicy.test.ts` around lines 904 -
908, Update writeText to use the composed FileSystem.FileSystem service via
Effect.gen, yielding the service and calling its writeFileString method; remove
the dynamic fs/promises import and Effect.promise implementation while
preserving the existing path and content inputs.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/opencode/src/altimate/policy/hard-policy.ts`:
- Around line 216-220: Update emitAudit to isolate the auditSink(record)
invocation from callers by catching and suppressing any exception it raises.
Preserve auditLog recording and trimming, and ensure check() retains its
never-throws contract even when a sink installed through setAuditSink fails.

In `@packages/opencode/src/session/prompt.ts`:
- Around line 1726-1736: Update the policy-deny return object in the prompt
handling flow to include empty content and attachments arrays, matching the
result shape used by session tools. Preserve the existing title, output,
metadata, and denial values.

In `@packages/opencode/test/altimate/defork/route-sentinels.test.ts`:
- Around line 149-152: Update the D3/D4 “SessionTools.resolve dispatches a tool
when invoked directly” test to import and use tmpdir from fixture/fixture.ts,
create a per-test await using tmp fixture, and pass tmp.path to Instance.provide
instead of the hardcoded /tmp directory.

---

Nitpick comments:
In `@packages/opencode/test/altimate/defork/hardpolicy.test.ts`:
- Around line 904-908: Update writeText to use the composed
FileSystem.FileSystem service via Effect.gen, yielding the service and calling
its writeFileString method; remove the dynamic fs/promises import and
Effect.promise implementation while preserving the existing path and content
inputs.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: afdc53b5-9023-413e-b9ee-e140d07944de

📥 Commits

Reviewing files that changed from the base of the PR and between 8a50ec7 and 412ed1d.

📒 Files selected for processing (7)
  • packages/opencode/src/altimate/policy/hard-policy.ts
  • packages/opencode/src/effect/app-runtime.ts
  • packages/opencode/src/session/prompt.ts
  • packages/opencode/src/session/tools.ts
  • packages/opencode/src/tool/batch.ts
  • packages/opencode/test/altimate/defork/hardpolicy.test.ts
  • packages/opencode/test/altimate/defork/route-sentinels.test.ts

Comment thread packages/opencode/src/altimate/policy/hard-policy.ts
Comment on lines +1726 to +1736
return {
title: "Blocked by policy",
output: policyDecision.safeReason,
metadata: {
error: policyDecision.safeReason,
hard_policy_denied: true,
code: "hard_policy_denied",
ruleID: policyDecision.ruleID,
success: false,
},
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Confirm MCP tool consumers read `.content` off the execute() result in prompt.ts
rg -nP -C3 '\.content\b' packages/opencode/src/session/prompt.ts

Repository: AltimateAI/altimate-code

Length of output: 1149


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- prompt.ts around deny branch ---'
sed -n '1718,1745p' packages/opencode/src/session/prompt.ts

echo
echo '--- tools.ts relevant section ---'
sed -n '170,215p' packages/opencode/src/session/tools.ts

Repository: AltimateAI/altimate-code

Length of output: 3301


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- prompt.ts execute signature / return shape ---'
rg -n -C4 'return \{|type .*execute|execute\(' packages/opencode/src/session/prompt.ts

echo
echo '--- prompt.ts direct .content / .attachments consumers around execute result ---'
rg -n -C3 'result\.(content|attachments)|attachments:|content:' packages/opencode/src/session/prompt.ts

echo
echo '--- tool result type references in session files ---'
rg -n -C2 'content: \[\]|attachments: \[\]|output: .*safeReason|hard_policy_denied' packages/opencode/src/session

Repository: AltimateAI/altimate-code

Length of output: 14361


Add empty content and attachments on MCP policy deny
packages/opencode/src/session/prompt.ts:1726-1736 should match session/tools.ts and return content: [] and attachments: [] here; otherwise callers reading result.content can get undefined.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/opencode/src/session/prompt.ts` around lines 1726 - 1736, Update the
policy-deny return object in the prompt handling flow to include empty content
and attachments arrays, matching the result shape used by session tools.
Preserve the existing title, output, metadata, and denial values.

Comment on lines +149 to +152
test("D3/D4: SessionTools.resolve dispatches a tool when invoked directly", async () => {
await Instance.provide({
directory: "/tmp",
fn: async () => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Use tmpdir() instead of the hardcoded /tmp directory.

Unlike the D1/D5 tests in this same file, this test provides Instance with a shared, hardcoded /tmp, which has no automatic cleanup and can collide with other runs. Prefer await using tmp = await tmpdir() and pass tmp.path.

Based on learnings: for brand-new test files under packages/opencode/test/altimate/, follow the documented convention — import tmpdir from fixture/fixture.ts and use await using tmp = await tmpdir() with per-test scoping; do not suppress the tmpdir() fixture suggestion for newly added files in this directory.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/opencode/test/altimate/defork/route-sentinels.test.ts` around lines
149 - 152, Update the D3/D4 “SessionTools.resolve dispatches a tool when invoked
directly” test to import and use tmpdir from fixture/fixture.ts, create a
per-test await using tmp fixture, and pass tmp.path to Instance.provide instead
of the hardcoded /tmp directory.

Source: Learnings

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 412ed1ddf7

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

import { Wildcard } from "@/util/wildcard"
import { classifyAndCheck } from "@/altimate/tools/sql-classify"

export namespace HardPolicy {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Use the required flat module shape

The applicable packages/opencode/AGENTS.md explicitly says, “Do not use export namespace Foo { ... },” because this shape prevents tree-shaking and breaks Node's native TypeScript runner. This newly introduced module should expose flat top-level declarations and end with the prescribed export * as HardPolicy from "./hard-policy" self-reexport instead.

Useful? React with 👍 / 👎.

return out
}
try {
return JSON.stringify(normalize(value)) ?? "null"

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Redact tool arguments before retaining audit records

For calls such as write with .env contents or bash with an authorization token, this stores the first 2,000 characters of the raw JSON arguments—not a digest—and the module retains up to 1,000 such records globally through the exported getAuditLog(). Truncation does not protect secrets near the beginning of the payload; hash or explicitly redact sensitive argument values before placing them in the audit log.

Useful? React with 👍 / 👎.

Comment on lines +1634 to +1637
const policyDecision = HardPolicy.check({
toolID: item.id,
source: "native",
args: finalArgs,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Record plugin registry calls with the plugin source

When item.registrySource === "external", which ToolRegistry.fromPlugin() assigns to user and third-party plugin tools, this records the call as native; the same hard-coded value also appears in session/tools.ts. Consequently the declared plugin source is never emitted and the security audit cannot distinguish plugin execution from built-ins, so derive the policy source from item.registrySource in both registry loops.

Useful? React with 👍 / 👎.

Comment on lines +216 to +219
function emitAudit(record: AuditRecord): void {
auditLog.push(record)
if (auditLog.length > MAX_AUDIT_LOG) auditLog.shift()
if (auditSink) auditSink(record)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Isolate audit sink failures from policy decisions

If a callback installed through setAuditSink() throws, this exception escapes emitAudit() after check()'s catch block, so the advertised total, never-throwing policy function instead aborts the dispatcher without returning its decision. Wrap sink invocation failures so a faulty audit observer cannot disrupt every subsequent tool execution.

Useful? React with 👍 / 👎.

Comment on lines +1718 to +1721
const policyDecision = HardPolicy.check({
toolID: key,
source: "mcp",
args: finalArgs,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Check the unprefixed MCP tool name

For every real MCP server, MCP.tools() constructs keys as <sanitized-client>_<sanitized-tool> in src/mcp/index.ts:1032-1035, but the policy rules only govern the exact IDs bash and sql_execute. Passing the flattened key here therefore means a server such as warehouse exposing sql_execute is checked as warehouse_sql_execute, receives an allow decision, and executes destructive DDL; use the original MCP tool name or remove the known client prefix before matching. The D4 test misses this because its mock supplies the production-impossible bare key bash.

Useful? React with 👍 / 👎.

Comment on lines +1641 to +1643
if (!policyDecision.allow) {
return {
title: "Blocked by policy",

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Surface policy denials as tool errors

When a built-in bash or sql_execute call is denied, returning this ordinary tool value produces an AI SDK tool-result, which session/processor.ts:258-275 unconditionally stores with status: "completed"; only a rejected execution produces the tool-error branch and an error state. Consequently the UI, transcript, and status-based consumers treat a blocked destructive call as successfully completed despite the metadata, unlike the pre-existing SQL and permission denials that threw; reject with the safe policy error instead.

Useful? React with 👍 / 👎.

Comment on lines +291 to +294
toolID = typeof input.toolID === "string" ? input.toolID : "unknown"
source = input.source
sessionID = typeof input.sessionID === "string" ? input.sessionID : ""
callID = input.callID

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Reject malformed policy input before treating it as ungoverned

For an object-shaped but malformed call such as HardPolicy.check({} as any) or one with a non-string toolID, this substitutes "unknown", finds no rules, and returns { allow: true }. That contradicts the stated fail-closed behavior for malformed top-level input and means a dispatcher regression that omits the tool ID silently bypasses the gate; validate required fields such as toolID, source, and sessionID and route invalid values through the internal-error deny.

Useful? React with 👍 / 👎.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

9 issues found across 7 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="packages/opencode/src/session/tools.ts">

<violation number="1" location="packages/opencode/src/session/tools.ts:113">
P3: The HardPolicy deny branch for native tools skips `stampRegistryToolSource`, so denied tool-call results won't have `metadata.source` set (unlike the allow path and the MCP deny branch). This can make the UI badge for hard-policy-denied native tool calls render incorrectly or fall back to a default classification.</violation>
</file>

<file name="packages/opencode/src/altimate/policy/hard-policy.ts">

<violation number="1" location="packages/opencode/src/altimate/policy/hard-policy.ts:27">
P2: This file uses `export namespace HardPolicy { … }` for module organization, which the project conventions explicitly prohibit. Per AGENTS.md: *"Do not use `export namespace Foo { ... }` for module organization. It is not standard ESM, it prevents tree-shaking, and it breaks Node's native TypeScript runner."* The documented alternative is flat top-level exports combined with a self-reexport at the bottom of the file (e.g., `export * as HardPolicy from "./hard-policy"`). This matters for all consumers: anything importing from this file pulls in the entire namespace and prevents tree-shaking from eliminating unused members.

Consider restructuring to use the standard pattern, matching the rest of the codebase.</violation>

<violation number="2" location="packages/opencode/src/altimate/policy/hard-policy.ts:291">
P1: If `input.toolID` is missing or not a string, HardPolicy silently treats it as the ungoverned tool "unknown" and allows the call, instead of failing closed like the rest of the chokepoint does for malformed args. For a security kill-gate whose whole purpose is fail-closed behavior, this is an unintended fail-open path — consider throwing (so it's caught and converted to `policy_internal_error` deny) when `toolID` isn't a non-empty string.</violation>

<violation number="3" location="packages/opencode/src/altimate/policy/hard-policy.ts:292">
P2: In `check()`, the `source` field from `input` is assigned directly without a type guard or fallback, while adjacent fields (`toolID`, `sessionID`) are validated with fallback values. The declared `Input` type constraints provide compile-time safety, but since this is a TOTAL function designed to never throw on any malformed input — and the `catch` block explicitly handles internal failures — the `source` value should be validated at runtime too.

If a caller passes `source: undefined` or an unexpected value through a type bypass, it flows unchecked into the audit record's `dispatcher` field. The audit log is the designated security oracle for this module, so recording a corrupted or missing source value reduces the audit trail's reliability.</violation>

<violation number="4" location="packages/opencode/src/altimate/policy/hard-policy.ts:295">
P3: finalArgsDigest fully stringifies+sorts `input.args` on every single tool dispatch (governed or not) before truncating the result, adding non-trivial overhead on the hot path for tools with large payloads (e.g. file writes, long SQL). Consider gating the digest computation to only governed toolIDs, or truncating/sampling before the full recursive normalize.</violation>

<violation number="5" location="packages/opencode/src/altimate/policy/hard-policy.ts:325">
P2: `dispatcher` and `source` in the emitted AuditRecord are always identical values, so the audit trail can't actually differentiate the D1–D6 call sites the PR claims to verify — consider passing a distinct per-call-site identifier (e.g. "D1", "D2"...) as `dispatcher` if that granularity is meant to be test-observable.</violation>
</file>

<file name="packages/opencode/test/altimate/defork/route-sentinels.test.ts">

<violation number="1" location="packages/opencode/test/altimate/defork/route-sentinels.test.ts:151">
P3: Unlike the D1 and D5 tests in this same file, this test passes a hardcoded `/tmp` to `Instance.provide`, which has no automatic cleanup and can collide with parallel test runs. Consider using `await using tmp = await tmpdir()` and passing `tmp.path` for consistency and isolation.</violation>

<violation number="2" location="packages/opencode/test/altimate/defork/route-sentinels.test.ts:224">
P3: Test uses `permission: {}` (bare object) for `agent.permission`, which is inconsistent with D1's documented practice and the `PermissionV1.Ruleset` type (array of rules). The D1 test at line 95 uses `permission: []` with an explanatory comment that `{}` crashes `Wildcard.match()` via `Permission.evaluate()` in `Skill.available`. Currently works because all downstream services (ToolRegistry, Permission) are mocked — but removing those mocks later or adding a new code path that reads `agent.permission` via `Permission.merge`/`evaluate` would silently fail with no test-preparation surface. Use `permission: []` for consistency and defensive setup.</violation>
</file>

<file name="packages/opencode/src/tool/batch.ts">

<violation number="1" location="packages/opencode/src/tool/batch.ts:117">
P2: Unlike the other HardPolicy checkpoints (D1–D4/D6), the batch dispatcher (D5) surfaces a denial only as a plain error message and drops `ruleID`/`hard_policy_denied` from the batch result metadata. Any downstream code (UI, telemetry, tests) that inspects `metadata.hard_policy_denied`/`ruleID` to detect a hard-policy deny — as the other dispatchers now support — won't see it for batched tool calls, making denial detection for batch results string-matching-dependent instead of structured.</violation>
</file>

Tip: cubic can generate docs of your entire codebase and keep them up to date. Try it here.

Re-trigger cubic

if (!isRecord(input as unknown)) {
throw new Error("hard-policy: input is not an object")
}
toolID = typeof input.toolID === "string" ? input.toolID : "unknown"

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1: If input.toolID is missing or not a string, HardPolicy silently treats it as the ungoverned tool "unknown" and allows the call, instead of failing closed like the rest of the chokepoint does for malformed args. For a security kill-gate whose whole purpose is fail-closed behavior, this is an unintended fail-open path — consider throwing (so it's caught and converted to policy_internal_error deny) when toolID isn't a non-empty string.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/opencode/src/altimate/policy/hard-policy.ts, line 291:

<comment>If `input.toolID` is missing or not a string, HardPolicy silently treats it as the ungoverned tool "unknown" and allows the call, instead of failing closed like the rest of the chokepoint does for malformed args. For a security kill-gate whose whole purpose is fail-closed behavior, this is an unintended fail-open path — consider throwing (so it's caught and converted to `policy_internal_error` deny) when `toolID` isn't a non-empty string.</comment>

<file context>
@@ -0,0 +1,337 @@
+      if (!isRecord(input as unknown)) {
+        throw new Error("hard-policy: input is not an object")
+      }
+      toolID = typeof input.toolID === "string" ? input.toolID : "unknown"
+      source = input.source
+      sessionID = typeof input.sessionID === "string" ? input.sessionID : ""
</file context>


emitAudit({
ruleID: decision.allow ? "" : decision.ruleID,
dispatcher: source,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: dispatcher and source in the emitted AuditRecord are always identical values, so the audit trail can't actually differentiate the D1–D6 call sites the PR claims to verify — consider passing a distinct per-call-site identifier (e.g. "D1", "D2"...) as dispatcher if that granularity is meant to be test-observable.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/opencode/src/altimate/policy/hard-policy.ts, line 325:

<comment>`dispatcher` and `source` in the emitted AuditRecord are always identical values, so the audit trail can't actually differentiate the D1–D6 call sites the PR claims to verify — consider passing a distinct per-call-site identifier (e.g. "D1", "D2"...) as `dispatcher` if that granularity is meant to be test-observable.</comment>

<file context>
@@ -0,0 +1,337 @@
+
+    emitAudit({
+      ruleID: decision.allow ? "" : decision.ruleID,
+      dispatcher: source,
+      toolID,
+      source,
</file context>

sessionID: ctx.sessionID,
callID: partID,
})
if (!policyDecision.allow) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: Unlike the other HardPolicy checkpoints (D1–D4/D6), the batch dispatcher (D5) surfaces a denial only as a plain error message and drops ruleID/hard_policy_denied from the batch result metadata. Any downstream code (UI, telemetry, tests) that inspects metadata.hard_policy_denied/ruleID to detect a hard-policy deny — as the other dispatchers now support — won't see it for batched tool calls, making denial detection for batch results string-matching-dependent instead of structured.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/opencode/src/tool/batch.ts, line 117:

<comment>Unlike the other HardPolicy checkpoints (D1–D4/D6), the batch dispatcher (D5) surfaces a denial only as a plain error message and drops `ruleID`/`hard_policy_denied` from the batch result metadata. Any downstream code (UI, telemetry, tests) that inspects `metadata.hard_policy_denied`/`ruleID` to detect a hard-policy deny — as the other dispatchers now support — won't see it for batched tool calls, making denial detection for batch results string-matching-dependent instead of structured.</comment>

<file context>
@@ -100,6 +104,20 @@ export const BatchTool = Tool.define("batch", {
+          sessionID: ctx.sessionID,
+          callID: partID,
+        })
+        if (!policyDecision.allow) {
+          throw new Error(policyDecision.safeReason)
+        }
</file context>

throw new Error("hard-policy: input is not an object")
}
toolID = typeof input.toolID === "string" ? input.toolID : "unknown"
source = input.source

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: In check(), the source field from input is assigned directly without a type guard or fallback, while adjacent fields (toolID, sessionID) are validated with fallback values. The declared Input type constraints provide compile-time safety, but since this is a TOTAL function designed to never throw on any malformed input — and the catch block explicitly handles internal failures — the source value should be validated at runtime too.

If a caller passes source: undefined or an unexpected value through a type bypass, it flows unchecked into the audit record's dispatcher field. The audit log is the designated security oracle for this module, so recording a corrupted or missing source value reduces the audit trail's reliability.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/opencode/src/altimate/policy/hard-policy.ts, line 292:

<comment>In `check()`, the `source` field from `input` is assigned directly without a type guard or fallback, while adjacent fields (`toolID`, `sessionID`) are validated with fallback values. The declared `Input` type constraints provide compile-time safety, but since this is a TOTAL function designed to never throw on any malformed input — and the `catch` block explicitly handles internal failures — the `source` value should be validated at runtime too.

If a caller passes `source: undefined` or an unexpected value through a type bypass, it flows unchecked into the audit record's `dispatcher` field. The audit log is the designated security oracle for this module, so recording a corrupted or missing source value reduces the audit trail's reliability.</comment>

<file context>
@@ -0,0 +1,337 @@
+        throw new Error("hard-policy: input is not an object")
+      }
+      toolID = typeof input.toolID === "string" ? input.toolID : "unknown"
+      source = input.source
+      sessionID = typeof input.sessionID === "string" ? input.sessionID : ""
+      callID = input.callID
</file context>

import { Wildcard } from "@/util/wildcard"
import { classifyAndCheck } from "@/altimate/tools/sql-classify"

export namespace HardPolicy {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: This file uses export namespace HardPolicy { … } for module organization, which the project conventions explicitly prohibit. Per AGENTS.md: "Do not use export namespace Foo { ... } for module organization. It is not standard ESM, it prevents tree-shaking, and it breaks Node's native TypeScript runner." The documented alternative is flat top-level exports combined with a self-reexport at the bottom of the file (e.g., export * as HardPolicy from "./hard-policy"). This matters for all consumers: anything importing from this file pulls in the entire namespace and prevents tree-shaking from eliminating unused members.

Consider restructuring to use the standard pattern, matching the rest of the codebase.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/opencode/src/altimate/policy/hard-policy.ts, line 27:

<comment>This file uses `export namespace HardPolicy { … }` for module organization, which the project conventions explicitly prohibit. Per AGENTS.md: *"Do not use `export namespace Foo { ... }` for module organization. It is not standard ESM, it prevents tree-shaking, and it breaks Node's native TypeScript runner."* The documented alternative is flat top-level exports combined with a self-reexport at the bottom of the file (e.g., `export * as HardPolicy from "./hard-policy"`). This matters for all consumers: anything importing from this file pulls in the entire namespace and prevents tree-shaking from eliminating unused members.

Consider restructuring to use the standard pattern, matching the rest of the codebase.</comment>

<file context>
@@ -0,0 +1,337 @@
+import { Wildcard } from "@/util/wildcard"
+import { classifyAndCheck } from "@/altimate/tools/sql-classify"
+
+export namespace HardPolicy {
+  // ---------------------------------------------------------------------------------
+  // Public types
</file context>

callID: ctx.callID,
})
if (!policyDecision.allow) {
return {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P3: The HardPolicy deny branch for native tools skips stampRegistryToolSource, so denied tool-call results won't have metadata.source set (unlike the allow path and the MCP deny branch). This can make the UI badge for hard-policy-denied native tool calls render incorrectly or fall back to a default classification.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/opencode/src/session/tools.ts, line 113:

<comment>The HardPolicy deny branch for native tools skips `stampRegistryToolSource`, so denied tool-call results won't have `metadata.source` set (unlike the allow path and the MCP deny branch). This can make the UI badge for hard-policy-denied native tool calls render incorrectly or fall back to a default classification.</comment>

<file context>
@@ -89,12 +92,39 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: {
+              callID: ctx.callID,
+            })
+            if (!policyDecision.allow) {
+              return {
+                title: "Blocked by policy",
+                output: policyDecision.safeReason,
</file context>

source = input.source
sessionID = typeof input.sessionID === "string" ? input.sessionID : ""
callID = input.callID
digest = finalArgsDigest(input.args)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P3: finalArgsDigest fully stringifies+sorts input.args on every single tool dispatch (governed or not) before truncating the result, adding non-trivial overhead on the hot path for tools with large payloads (e.g. file writes, long SQL). Consider gating the digest computation to only governed toolIDs, or truncating/sampling before the full recursive normalize.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/opencode/src/altimate/policy/hard-policy.ts, line 295:

<comment>finalArgsDigest fully stringifies+sorts `input.args` on every single tool dispatch (governed or not) before truncating the result, adding non-trivial overhead on the hot path for tools with large payloads (e.g. file writes, long SQL). Consider gating the digest computation to only governed toolIDs, or truncating/sampling before the full recursive normalize.</comment>

<file context>
@@ -0,0 +1,337 @@
+      source = input.source
+      sessionID = typeof input.sessionID === "string" ? input.sessionID : ""
+      callID = input.callID
+      digest = finalArgsDigest(input.args)
+
+      assertInitialized()
</file context>

}

const input: any = {
agent: { name: "build", mode: "primary", permission: {}, options: {} },

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P3: Test uses permission: {} (bare object) for agent.permission, which is inconsistent with D1's documented practice and the PermissionV1.Ruleset type (array of rules). The D1 test at line 95 uses permission: [] with an explanatory comment that {} crashes Wildcard.match() via Permission.evaluate() in Skill.available. Currently works because all downstream services (ToolRegistry, Permission) are mocked — but removing those mocks later or adding a new code path that reads agent.permission via Permission.merge/evaluate would silently fail with no test-preparation surface. Use permission: [] for consistency and defensive setup.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/opencode/test/altimate/defork/route-sentinels.test.ts, line 224:

<comment>Test uses `permission: {}` (bare object) for `agent.permission`, which is inconsistent with D1's documented practice and the `PermissionV1.Ruleset` type (array of rules). The D1 test at line 95 uses `permission: []` with an explanatory comment that `{}` crashes `Wildcard.match()` via `Permission.evaluate()` in `Skill.available`. Currently works because all downstream services (ToolRegistry, Permission) are mocked — but removing those mocks later or adding a new code path that reads `agent.permission` via `Permission.merge`/`evaluate` would silently fail with no test-preparation surface. Use `permission: []` for consistency and defensive setup.</comment>

<file context>
@@ -0,0 +1,346 @@
+      }
+
+      const input: any = {
+        agent: { name: "build", mode: "primary", permission: {}, options: {} },
+        model: { providerID: "test", api: { id: "test-model" } },
+        session: { id: "ses_fake", permission: [] },
</file context>


test("D3/D4: SessionTools.resolve dispatches a tool when invoked directly", async () => {
await Instance.provide({
directory: "/tmp",

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P3: Unlike the D1 and D5 tests in this same file, this test passes a hardcoded /tmp to Instance.provide, which has no automatic cleanup and can collide with parallel test runs. Consider using await using tmp = await tmpdir() and passing tmp.path for consistency and isolation.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/opencode/test/altimate/defork/route-sentinels.test.ts, line 151:

<comment>Unlike the D1 and D5 tests in this same file, this test passes a hardcoded `/tmp` to `Instance.provide`, which has no automatic cleanup and can collide with parallel test runs. Consider using `await using tmp = await tmpdir()` and passing `tmp.path` for consistency and isolation.</comment>

<file context>
@@ -0,0 +1,346 @@
+
+test("D3/D4: SessionTools.resolve dispatches a tool when invoked directly", async () => {
+  await Instance.provide({
+    directory: "/tmp",
+    fn: async () => {
+      const invalidInfo = await toolInfo(InvalidTool)
</file context>

@dev-punia-altimate dev-punia-altimate left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🤖 Code Review — OpenCodeReview (Gemini) — 6 finding(s)

  • 6 anchored to a line (posted inline when the comment stream is on)
  • 0 without a line anchor
All findings (full text)

1. packages/opencode/src/session/tools.ts (L112-L124)

[🟠 MEDIUM] When HardPolicy.check blocks the tool execution and returns early, the tool.execute.after plugin hook is completely skipped.

While it's true the tool itself wasn't executed, any plugins that hooked into tool.execute.before (and perhaps started a span, locked a resource, or initialized state) will never receive the corresponding tool.execute.after event to clean up or log the blocked outcome. If the intention is to completely short-circuit, this might be fine, but typically a paired before/after hook design expects after to be called with the error or blocked result. Please verify if plugins need to be notified of the hard policy denial.

2. packages/opencode/src/session/tools.ts (L94-L103)

[🟠 MEDIUM] You correctly ensured that HardPolicy.check and the tool's execute function use finalArgs. However, ctx is initialized before the plugin hook using the original args.

const ctx = context(args, options)

Inside the context factory, args is captured in a closure and used when the tool updates its metadata (e.g., input: args in updateToolCall). This means the system state/UI will record the original unmutated args as the tool's input, even though it actually executed with finalArgs.

If the system state should reflect the mutated arguments, you should create ctx after the hook (using sessionID: input.session.id and callID: options.toolCallId directly for the hook input), or update the context factory to use the latest args.

3. packages/opencode/src/session/tools.ts (L169-L178)

[🟠 MEDIUM] Similarly for MCP tools, ctx captures the original args before the tool.execute.before hook can mutate them. Any calls to ctx.metadata(...) during execution will save the original args to the tool call state rather than finalArgs.

Consider moving const ctx = context(finalArgs, opts) to after the hook, using input.session.id for the hook's sessionID parameter.

4. packages/opencode/src/altimate/policy/hard-policy.ts (L109-L110)

[🔴 HIGH] The Wildcard.match function typically expects a strict prefix match since it compiles patterns with a ^ anchor. Because the BASH_DDL_PATTERNS start directly with the command (e.g., "DROP DATABASE *"), a user could bypass this hard policy by simply prepending whitespace (e.g., " DROP DATABASE prod").

To prevent this easy bypass, consider trimming the command string before matching.

Suggested change:

    const command = args.command.trim()
    return BASH_DDL_PATTERNS.some((pattern) => Wildcard.match(command, pattern))

5. packages/opencode/src/altimate/policy/hard-policy.ts (L254-L258)

[🟠 MEDIUM] If input.args happens to contain non-plain objects like Map or Set (e.g., mutated by a tool.execute.before hook), Object.keys() will return an empty array, and they will be stringified as {}. This could lead to identical audit digests for different inputs.

Although tool inputs are typically plain JSON objects, consider handling Map and Set or falling back to a more robust serialization if they are expected.

Suggested change:

      if (Array.isArray(input)) return input.map(normalize)
      if (input instanceof Map) return normalize(Object.fromEntries(input))
      if (input instanceof Set) return normalize(Array.from(input))
      const record = input as Record<string, unknown>
      const out: Record<string, unknown> = {}
      for (const key of Object.keys(record).sort()) out[key] = normalize(record[key])
      return out

6. packages/opencode/src/altimate/policy/hard-policy.ts (L288-L292)

[🔴 HIGH] If input.toolID is missing or not a string (e.g., due to a caller bug), it defaults to "unknown". Since "unknown" is an ungoverned tool ID, the policy will unconditionally allow the execution. This represents a fail-open behavior on malformed top-level input, which contradicts the fail-closed design contract stated in the comments.

Consider throwing an error here instead of defaulting to "unknown" to ensure any such caller bugs result in a secure deny.

Suggested change:

      if (!isRecord(input as unknown)) {
        throw new Error("hard-policy: input is not an object")
      }
      if (typeof input.toolID !== "string") {
        throw new Error("hard-policy: input missing string 'toolID'")
      }
      toolID = input.toolID
      source = input.source

Comment on lines +112 to +124
if (!policyDecision.allow) {
return {
title: "Blocked by policy",
output: policyDecision.safeReason,
metadata: {
error: policyDecision.safeReason,
hard_policy_denied: true,
code: "hard_policy_denied",
ruleID: policyDecision.ruleID,
success: false,
},
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[🟠 MEDIUM] When HardPolicy.check blocks the tool execution and returns early, the tool.execute.after plugin hook is completely skipped.

While it's true the tool itself wasn't executed, any plugins that hooked into tool.execute.before (and perhaps started a span, locked a resource, or initialized state) will never receive the corresponding tool.execute.after event to clean up or log the blocked outcome. If the intention is to completely short-circuit, this might be fine, but typically a paired before/after hook design expects after to be called with the error or blocked result. Please verify if plugins need to be notified of the hard policy denial.

Comment thread packages/opencode/src/session/tools.ts Outdated
Comment on lines 94 to 103
const ctx = context(args, options)
yield* plugin.trigger(
// altimate_change start — HardPolicy enforcement (S3)
// upstream_fix: plugin.trigger's returned output was previously discarded, so a
// tool.execute.before hook that mutated `args` had no effect on what actually
// ran — HardPolicy and execute must see the SAME final, post-hook args.
const hookOutput = yield* plugin.trigger(
"tool.execute.before",
{ tool: item.id, sessionID: ctx.sessionID, callID: ctx.callID },
{ args },
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[🟠 MEDIUM] You correctly ensured that HardPolicy.check and the tool's execute function use finalArgs. However, ctx is initialized before the plugin hook using the original args.

const ctx = context(args, options)

Inside the context factory, args is captured in a closure and used when the tool updates its metadata (e.g., input: args in updateToolCall). This means the system state/UI will record the original unmutated args as the tool's input, even though it actually executed with finalArgs.

If the system state should reflect the mutated arguments, you should create ctx after the hook (using sessionID: input.session.id and callID: options.toolCallId directly for the hook input), or update the context factory to use the latest args.

Comment thread packages/opencode/src/session/tools.ts Outdated
Comment on lines 169 to 178
const ctx = context(args, opts)
yield* plugin.trigger(
// altimate_change start — HardPolicy enforcement (S3)
// upstream_fix: plugin.trigger's returned output was previously discarded, so a
// tool.execute.before hook that mutated `args` had no effect on what actually
// ran — HardPolicy and execute must see the SAME final, post-hook args.
const hookOutput = yield* plugin.trigger(
"tool.execute.before",
{ tool: key, sessionID: ctx.sessionID, callID: opts.toolCallId },
{ args },
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[🟠 MEDIUM] Similarly for MCP tools, ctx captures the original args before the tool.execute.before hook can mutate them. Any calls to ctx.metadata(...) during execution will save the original args to the tool call state rather than finalArgs.

Consider moving const ctx = context(finalArgs, opts) to after the hook, using input.session.id for the hook's sessionID parameter.

Comment on lines +109 to +110
const command = args.command
return BASH_DDL_PATTERNS.some((pattern) => Wildcard.match(command, pattern))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[🔴 HIGH] The Wildcard.match function typically expects a strict prefix match since it compiles patterns with a ^ anchor. Because the BASH_DDL_PATTERNS start directly with the command (e.g., "DROP DATABASE *"), a user could bypass this hard policy by simply prepending whitespace (e.g., " DROP DATABASE prod").

To prevent this easy bypass, consider trimming the command string before matching.

Suggested change:

Suggested change
const command = args.command
return BASH_DDL_PATTERNS.some((pattern) => Wildcard.match(command, pattern))
const command = args.command.trim()
return BASH_DDL_PATTERNS.some((pattern) => Wildcard.match(command, pattern))

Comment on lines +254 to +258
if (Array.isArray(input)) return input.map(normalize)
const record = input as Record<string, unknown>
const out: Record<string, unknown> = {}
for (const key of Object.keys(record).sort()) out[key] = normalize(record[key])
return out

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[🟠 MEDIUM] If input.args happens to contain non-plain objects like Map or Set (e.g., mutated by a tool.execute.before hook), Object.keys() will return an empty array, and they will be stringified as {}. This could lead to identical audit digests for different inputs.

Although tool inputs are typically plain JSON objects, consider handling Map and Set or falling back to a more robust serialization if they are expected.

Suggested change:

Suggested change
if (Array.isArray(input)) return input.map(normalize)
const record = input as Record<string, unknown>
const out: Record<string, unknown> = {}
for (const key of Object.keys(record).sort()) out[key] = normalize(record[key])
return out
if (Array.isArray(input)) return input.map(normalize)
if (input instanceof Map) return normalize(Object.fromEntries(input))
if (input instanceof Set) return normalize(Array.from(input))
const record = input as Record<string, unknown>
const out: Record<string, unknown> = {}
for (const key of Object.keys(record).sort()) out[key] = normalize(record[key])
return out

Comment on lines +288 to +292
if (!isRecord(input as unknown)) {
throw new Error("hard-policy: input is not an object")
}
toolID = typeof input.toolID === "string" ? input.toolID : "unknown"
source = input.source

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[🔴 HIGH] If input.toolID is missing or not a string (e.g., due to a caller bug), it defaults to "unknown". Since "unknown" is an ungoverned tool ID, the policy will unconditionally allow the execution. This represents a fail-open behavior on malformed top-level input, which contradicts the fail-closed design contract stated in the comments.

Consider throwing an error here instead of defaulting to "unknown" to ensure any such caller bugs result in a secure deny.

Suggested change:

Suggested change
if (!isRecord(input as unknown)) {
throw new Error("hard-policy: input is not an object")
}
toolID = typeof input.toolID === "string" ? input.toolID : "unknown"
source = input.source
if (!isRecord(input as unknown)) {
throw new Error("hard-policy: input is not an object")
}
if (typeof input.toolID !== "string") {
throw new Error("hard-policy: input missing string 'toolID'")
}
toolID = input.toolID
source = input.source

…throws" contract

- `emitAudit` now wraps the external `auditSink(record)` call in try/catch: a
  throwing installed sink must never propagate out of `check()`, which is
  contractually total. The decision is already computed and returned regardless
  of audit outcome (kilo-code-bot + CodeRabbit, both flagged).
- Replaced the O(n) `auditLog.shift()`-per-call with a batched splice
  (O(1) amortized) — `check()` runs on the tool-execution hot path (kilo-code-bot).
- Latent-guard test now scans the entire production src tree for
  `SessionTools.resolve(` callers (matches the S2 PR's version so the shared
  file merges identically in any order).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

👋 This PR was automatically closed by our quality checks.

Common reasons:

  • New GitHub account with limited contribution history
  • PR description doesn't meet our guidelines
  • Contribution appears to be AI-generated without meaningful review

If you believe this was a mistake, please open an issue explaining your intended contribution and a maintainer will help you.

2 similar comments
@github-actions

Copy link
Copy Markdown

👋 This PR was automatically closed by our quality checks.

Common reasons:

  • New GitHub account with limited contribution history
  • PR description doesn't meet our guidelines
  • Contribution appears to be AI-generated without meaningful review

If you believe this was a mistake, please open an issue explaining your intended contribution and a maintainer will help you.

@github-actions

Copy link
Copy Markdown

👋 This PR was automatically closed by our quality checks.

Common reasons:

  • New GitHub account with limited contribution history
  • PR description doesn't meet our guidelines
  • Contribution appears to be AI-generated without meaningful review

If you believe this was a mistake, please open an issue explaining your intended contribution and a maintainer will help you.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

2 issues found across 2 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="packages/opencode/src/altimate/policy/hard-policy.ts">

<violation number="1" location="packages/opencode/src/altimate/policy/hard-policy.ts:227">
P2: The try/catch here only guards against synchronous throws from `auditSink`. Because the sink's declared type (`(record) => void`) is structurally compatible with async functions returning `Promise<void>` in TypeScript, an async sink that rejects would bypass this catch and cause an unhandled rejection, undermining the 'never breaks enforcement' goal for that case.</violation>
</file>

<file name="packages/opencode/test/altimate/defork/route-sentinels.test.ts">

<violation number="1" location="packages/opencode/test/altimate/defork/route-sentinels.test.ts:136">
P3: The isComment heuristic misclassifies lines where a `/* ... */` block comment is followed by actual code on the same line. If someone writes `/* TODO */ SessionTools.resolve(args)`, `t.startsWith("/*")` is true so `isComment = true` and the line is skipped — but the code after the block comment IS executing code, making this a false negative that could silently miss a real caller. The detection treats `t.startsWith("/*")` as a whole-line comment indicator, which is only correct when the `/*` starts a line-spanning or end-of-line comment, not an inline block comment followed by code.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

// The decision is already computed and returned regardless of audit outcome.
if (auditSink) {
try {
auditSink(record)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: The try/catch here only guards against synchronous throws from auditSink. Because the sink's declared type ((record) => void) is structurally compatible with async functions returning Promise<void> in TypeScript, an async sink that rejects would bypass this catch and cause an unhandled rejection, undermining the 'never breaks enforcement' goal for that case.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/opencode/src/altimate/policy/hard-policy.ts, line 227:

<comment>The try/catch here only guards against synchronous throws from `auditSink`. Because the sink's declared type (`(record) => void`) is structurally compatible with async functions returning `Promise<void>` in TypeScript, an async sink that rejects would bypass this catch and cause an unhandled rejection, undermining the 'never breaks enforcement' goal for that case.</comment>

<file context>
@@ -215,8 +215,20 @@ export namespace HardPolicy {
+    // The decision is already computed and returned regardless of audit outcome.
+    if (auditSink) {
+      try {
+        auditSink(record)
+      } catch {
+        // Intentionally swallowed: audit-side failure cannot affect enforcement.
</file context>

if (!src.includes("SessionTools.resolve(")) continue
src.split("\n").forEach((line, i) => {
const t = line.trim()
const isComment = t.startsWith("//") || t.startsWith("*") || t.startsWith("/*")

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P3: The isComment heuristic misclassifies lines where a /* ... */ block comment is followed by actual code on the same line. If someone writes /* TODO */ SessionTools.resolve(args), t.startsWith("/*") is true so isComment = true and the line is skipped — but the code after the block comment IS executing code, making this a false negative that could silently miss a real caller. The detection treats t.startsWith("/*") as a whole-line comment indicator, which is only correct when the /* starts a line-spanning or end-of-line comment, not an inline block comment followed by code.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/opencode/test/altimate/defork/route-sentinels.test.ts, line 136:

<comment>The isComment heuristic misclassifies lines where a `/* ... */` block comment is followed by actual code on the same line. If someone writes `/* TODO */ SessionTools.resolve(args)`, `t.startsWith("/*")` is true so `isComment = true` and the line is skipped — but the code after the block comment IS executing code, making this a false negative that could silently miss a real caller. The detection treats `t.startsWith("/*")` as a whole-line comment indicator, which is only correct when the `/*` starts a line-spanning or end-of-line comment, not an inline block comment followed by code.</comment>

<file context>
@@ -120,30 +120,26 @@ test("D1: resolveTools dispatch chokepoint is present and unique", () => {
+    if (!src.includes("SessionTools.resolve(")) continue
+    src.split("\n").forEach((line, i) => {
+      const t = line.trim()
+      const isComment = t.startsWith("//") || t.startsWith("*") || t.startsWith("/*")
+      if (!isComment && line.includes("SessionTools.resolve(")) callers.push(`${rel}:${i + 1}`)
+    })
</file context>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 2884d7e721

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +280 to +281
const str = stableStringify(args)
return str.length > MAX_DIGEST_LENGTH ? str.slice(0, MAX_DIGEST_LENGTH) : str

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Bound serialization before truncating the audit digest

For tools with large arguments, such as write with a multi-megabyte payload or a large batch call, stableStringify() first recursively copies the entire argument graph and builds the complete JSON string; only afterward is it sliced to 2,000 characters. Because this runs synchronously for every tool dispatch, the advertised digest bound does not limit CPU or peak memory and can stall or exhaust the process on otherwise valid inputs; enforce size/depth limits while traversing or use a bounded/streaming digest.

Useful? React with 👍 / 👎.

"tool.execute.after",
{
tool: "task",
callID: part.id,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Record the task call ID rather than the part ID

On the direct-subtask path, the tool part is created with distinct id and callID values, and the execution context below correctly uses part.callID; recording part.id here therefore makes every task audit record impossible to correlate with the corresponding tool call by call ID. Use part.callID, as the other dispatchers do, so the audit trail retains its stated correlation semantics.

Useful? React with 👍 / 👎.

// altimate_change end
// altimate_change start — v1.17.9: Tool.Def.execute returns an Effect
const result = await AppRuntime.runPromise(item.execute(args, ctx))
const result = await AppRuntime.runPromise(item.execute(finalArgs, ctx))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Persist the post-hook arguments that are actually executed

When a tool.execute.before hook replaces output.args with a new object, this now executes finalArgs, but the tool context was created earlier from the original args and its state update still records input: args (context() at lines 1562-1584); the processor likewise began the tool part with the model-supplied input. The session transcript and UI can therefore show benign/original arguments while the tool and HardPolicy audit use different replacement arguments, so update the stored tool input to the post-hook value before dispatch.

Useful? React with 👍 / 👎.

const INTERNAL_ERROR_RULE_ID = "policy_internal_error"
const INTERNAL_ERROR_SAFE_REASON = "Policy could not be evaluated; denying for safety."

export function check(input: Input): Decision {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Gate direct debug-agent tool executions

The repository-wide dispatcher search also finds packages/opencode/src/cli/cmd/debug/agent.handler.ts:45-60, where altimate debug agent <name> --tool <id> resolves a registry tool and invokes tool.execute(params, toolCtx) directly without calling this policy. Consequently even a debug invocation of governed tools such as bash or sql_execute produces no HardPolicy audit record, so the audit log cannot serve as the claimed oracle for every execution route; invoke HardPolicy.check() at that dispatcher before execution.

Useful? React with 👍 / 👎.

…ew P1s)

Two P1 review findings on the HardPolicy chokepoint:

- MCP-prefix bypass (prompt.ts D2 / tools.ts D4 path): real MCP tools are
  registered with a flattened id `<sanitized-client>_<sanitized-tool>` (see
  `MCP.tools()` in `src/mcp/index.ts`), but `RULES` are keyed by the bare
  governed id (`sql_execute`, `bash`). A warehouse server exposing its own
  `sql_execute` therefore arrived as `warehouse_sql_execute`, matched no rule,
  and was allowed to run `DROP DATABASE`. Add `resolveGovernedKey()`: for the
  `mcp` source ONLY, resolve a governed key that is a full `_`-delimited suffix
  of the flattened id. Native/plugin/batch/task ids keep strict exact-match, so
  this only ADDS coverage for MCP — it never un-governs an exact match. The
  audit record still retains the original flattened id for forensics. (The old
  D4 mock used the production-impossible bare key `bash`, hiding this.)

- Audit arg retention: `finalArgsDigest` stored the first 2000 chars of the raw
  stable-JSON args. Tool args routinely carry secrets (`write` of `.env`
  contents, `bash` with an auth token) and the audit log is retained (up to
  MAX_AUDIT_LOG, readable via `getAuditLog()` / an installed sink), so
  truncation did not protect secrets near the start of the payload. Digest is
  now a SHA-256 hex over the stable-stringified args — non-reversible, no
  plaintext retained, correlation property preserved. Export `digestArgs()` so
  the D3 mutation tests recompute the expected digest instead of substring-
  matching the (now-hashed) value.

Tests: +3 Section-A unit tests (MCP-flattened deny for sql_execute/bash;
mcp-only scoping — native suffix + partial `mysql_execute` stay allowed; digest
is 64-hex and contains no plaintext). D3 mutation/allow digest assertions
rewritten to compare against `digestArgs(...)`. 22 pass, typecheck clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

👋 This PR was automatically closed by our quality checks.

Common reasons:

  • New GitHub account with limited contribution history
  • PR description doesn't meet our guidelines
  • Contribution appears to be AI-generated without meaningful review

If you believe this was a mistake, please open an issue explaining your intended contribution and a maintainer will help you.

1 similar comment
@github-actions

Copy link
Copy Markdown

👋 This PR was automatically closed by our quality checks.

Common reasons:

  • New GitHub account with limited contribution history
  • PR description doesn't meet our guidelines
  • Contribution appears to be AI-generated without meaningful review

If you believe this was a mistake, please open an issue explaining your intended contribution and a maintainer will help you.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

1 issue found across 2 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="packages/opencode/src/altimate/policy/hard-policy.ts">

<violation number="1" location="packages/opencode/src/altimate/policy/hard-policy.ts:203">
P2: resolveGovernedKey's suffix match (`toolID.endsWith("_" + governed)`) treats any MCP tool whose flattened id ends in `_bash` or `_sql_execute` as the governed bash/sql_execute tool, not just re-exposed same-named tools. If such an unrelated tool's args don't have the expected `command`/`query` string field, matchBashDdl/matchSqlDdl throws and check() fails closed with `policy_internal_error`, silently blocking a legitimate, unrelated MCP tool call. Consider requiring an exact tool-name segment match (e.g. splitting on the known client-name boundary) rather than a bare string suffix.</violation>
</file>

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

// a full `_`-delimited suffix of the flattened id. Native/plugin/batch/task tools keep
// strict exact-match (their ids are not client-prefixed), so this only ever ADDS coverage
// for the mcp source; it never un-governs a tool that exact-match already caught.
function resolveGovernedKey(toolID: string, source: Source): string | undefined {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: resolveGovernedKey's suffix match (toolID.endsWith("_" + governed)) treats any MCP tool whose flattened id ends in _bash or _sql_execute as the governed bash/sql_execute tool, not just re-exposed same-named tools. If such an unrelated tool's args don't have the expected command/query string field, matchBashDdl/matchSqlDdl throws and check() fails closed with policy_internal_error, silently blocking a legitimate, unrelated MCP tool call. Consider requiring an exact tool-name segment match (e.g. splitting on the known client-name boundary) rather than a bare string suffix.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/opencode/src/altimate/policy/hard-policy.ts, line 203:

<comment>resolveGovernedKey's suffix match (`toolID.endsWith("_" + governed)`) treats any MCP tool whose flattened id ends in `_bash` or `_sql_execute` as the governed bash/sql_execute tool, not just re-exposed same-named tools. If such an unrelated tool's args don't have the expected `command`/`query` string field, matchBashDdl/matchSqlDdl throws and check() fails closed with `policy_internal_error`, silently blocking a legitimate, unrelated MCP tool call. Consider requiring an exact tool-name segment match (e.g. splitting on the known client-name boundary) rather than a bare string suffix.</comment>

<file context>
@@ -191,6 +192,23 @@ export namespace HardPolicy {
+  // a full `_`-delimited suffix of the flattened id. Native/plugin/batch/task tools keep
+  // strict exact-match (their ids are not client-prefixed), so this only ever ADDS coverage
+  // for the mcp source; it never un-governs a tool that exact-match already caught.
+  function resolveGovernedKey(toolID: string, source: Source): string | undefined {
+    if (rulesByTool.has(toolID)) return toolID
+    if (source !== "mcp") return undefined
</file context>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 3ac7b68324

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +206 to +207
for (const governed of rulesByTool.keys()) {
if (toolID.endsWith(`_${governed}`)) return governed

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Resolve MCP tools using the actual client-name boundary

For MCP identifiers, suffix matching cannot distinguish the client portion from the tool name. For example, src/mcp/index.ts flattens client warehouse_sql plus tool execute into warehouse_sql_execute; this branch misclassifies it as the governed sql_execute tool and rejects every invocation lacking a query field with policy_internal_error. A tool such as explain_sql_execute is similarly over-governed. Preserve the original MCP tool name or strip the exact sanitized client prefix available at the dispatcher instead of accepting any governed suffix.

Useful? React with 👍 / 👎.

@dev-punia-altimate dev-punia-altimate left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🤖 Code Review — OpenCodeReview (Gemini) — 3 finding(s)

  • 3 anchored to a line (posted inline when the comment stream is on)
  • 0 without a line anchor
All findings (full text)

1. packages/opencode/src/altimate/policy/hard-policy.ts (L24)

[🟠 MEDIUM] This import introduces a strict dependency on Node.js/Bun. If HardPolicy (or any module importing it) is executed in a non-Node environment like a browser, Edge runtime, or a VS Code web extension, this will cause a runtime crash.

Consider using a cross-platform alternative, such as a pure JS implementation of SHA-256 (e.g., crypto-js), using the Web Crypto API (if asynchronous execution is permissible), or injecting the hashing function dynamically based on the environment.

2. packages/opencode/src/altimate/policy/hard-policy.ts (L203-L210)

[🔴 HIGH] The suffix matching loop here depends on the Map insertion order. If there are overlapping suffixes (for instance, rules for both execute and sql_execute), and execute was inserted into RULES first, an MCP tool ID like warehouse_sql_execute would incorrectly match execute instead of sql_execute.

To avoid this vulnerability, sort the governed keys by length in descending order, ensuring the most specific suffix matches first.

Suggested change:

  function resolveGovernedKey(toolID: string, source: Source): string | undefined {
    if (rulesByTool.has(toolID)) return toolID
    if (source !== "mcp") return undefined
    
    // Sort keys by length descending to ensure the most specific suffix matches first
    const governedKeys = Array.from(rulesByTool.keys()).sort((a, b) => b.length - a.length)
    for (const governed of governedKeys) {
      if (toolID.endsWith(`_${governed}`)) return governed
    }
    return undefined
  }

3. packages/opencode/src/altimate/policy/hard-policy.ts (L243-L249)

[🔴 HIGH] The try/catch block only handles synchronous errors. If an externally provided auditSink returns a rejected Promise (e.g., if it's an async function), the rejection will silently bypass the catch block. This leads to an unhandled promise rejection, which can crash the Node.js process.

Safely catch potential Promise rejections to ensure failures on the audit side cannot affect enforcement.

Suggested change:

    if (auditSink) {
      try {
        const result = auditSink(record)
        if (result && typeof (result as any).catch === "function") {
          (result as any).catch(() => {})
        }
      } catch {
        // Intentionally swallowed: audit-side failure cannot affect enforcement.
      }
    }

// absent execute span does NOT prove enforcement. Tests must assert against the audit
// log and execute-not-called counters.

import { createHash } from "node:crypto"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[🟠 MEDIUM] This import introduces a strict dependency on Node.js/Bun. If HardPolicy (or any module importing it) is executed in a non-Node environment like a browser, Edge runtime, or a VS Code web extension, this will cause a runtime crash.

Consider using a cross-platform alternative, such as a pure JS implementation of SHA-256 (e.g., crypto-js), using the Web Crypto API (if asynchronous execution is permissible), or injecting the hashing function dynamically based on the environment.

Comment on lines +203 to +210
function resolveGovernedKey(toolID: string, source: Source): string | undefined {
if (rulesByTool.has(toolID)) return toolID
if (source !== "mcp") return undefined
for (const governed of rulesByTool.keys()) {
if (toolID.endsWith(`_${governed}`)) return governed
}
return undefined
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[🔴 HIGH] The suffix matching loop here depends on the Map insertion order. If there are overlapping suffixes (for instance, rules for both execute and sql_execute), and execute was inserted into RULES first, an MCP tool ID like warehouse_sql_execute would incorrectly match execute instead of sql_execute.

To avoid this vulnerability, sort the governed keys by length in descending order, ensuring the most specific suffix matches first.

Suggested change:

Suggested change
function resolveGovernedKey(toolID: string, source: Source): string | undefined {
if (rulesByTool.has(toolID)) return toolID
if (source !== "mcp") return undefined
for (const governed of rulesByTool.keys()) {
if (toolID.endsWith(`_${governed}`)) return governed
}
return undefined
}
function resolveGovernedKey(toolID: string, source: Source): string | undefined {
if (rulesByTool.has(toolID)) return toolID
if (source !== "mcp") return undefined
// Sort keys by length descending to ensure the most specific suffix matches first
const governedKeys = Array.from(rulesByTool.keys()).sort((a, b) => b.length - a.length)
for (const governed of governedKeys) {
if (toolID.endsWith(`_${governed}`)) return governed
}
return undefined
}

Comment on lines +243 to +249
if (auditSink) {
try {
auditSink(record)
} catch {
// Intentionally swallowed: audit-side failure cannot affect enforcement.
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[🔴 HIGH] The try/catch block only handles synchronous errors. If an externally provided auditSink returns a rejected Promise (e.g., if it's an async function), the rejection will silently bypass the catch block. This leads to an unhandled promise rejection, which can crash the Node.js process.

Safely catch potential Promise rejections to ensure failures on the audit side cannot affect enforcement.

Suggested change:

Suggested change
if (auditSink) {
try {
auditSink(record)
} catch {
// Intentionally swallowed: audit-side failure cannot affect enforcement.
}
}
if (auditSink) {
try {
const result = auditSink(record)
if (result && typeof (result as any).catch === "function") {
(result as any).catch(() => {})
}
} catch {
// Intentionally swallowed: audit-side failure cannot affect enforcement.
}
}

…udit/ctx fidelity

Human review (dev-punia-altimate) + bot findings on the HardPolicy chokepoint:

- HIGH — bash DDL whitespace bypass: Wildcard.match compiles each pattern with a
  leading `^` anchor, so `"  DROP DATABASE prod"` (or a leading tab/newline) slipped
  past `"DROP DATABASE *"` and defeated the hard deny. matchBashDdl now trims the
  command first (leading/trailing whitespace is never semantically meaningful here).
  +1 test over several whitespace-prefixed DDL forms.

- MEDIUM — audit digest Map/Set collision: Object.keys(new Map()) is [], so distinct
  Maps/Sets (a tool.execute.before hook could inject one) both stringified to `{}` and
  collided to the same digest. stableStringify now serializes their contents under a
  distinguishing tag. +1 test.

- MEDIUM — tool-call metadata recorded pre-hook args: `ctx` was built from the caller's
  original `args` before the tool.execute.before hook, so ctx.metadata()'s
  updateToolCall recorded the ORIGINAL args while HardPolicy and execute used the
  post-hook finalArgs. Both dispatch sites (native + MCP) now build `ctx` from finalArgs
  AFTER the hook, passing input.session.id / toolCallId directly to the hook input (both
  independent of args). UI/state now reflects what actually ran.

- MEDIUM — tool.execute.after on hard deny: documented that skipping the after-hook on a
  policy deny is intentional (the tool never executed; synthesizing an after-event would
  hand plugins a result they'd treat as a real post-execute outcome). The security
  invariant holds regardless.

24 pass, typecheck clean. Markers clean (changes within altimate_change blocks).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

👋 This PR was automatically closed by our quality checks.

Common reasons:

  • New GitHub account with limited contribution history
  • PR description doesn't meet our guidelines
  • Contribution appears to be AI-generated without meaningful review

If you believe this was a mistake, please open an issue explaining your intended contribution and a maintainer will help you.

@github-actions

Copy link
Copy Markdown

👋 This PR was automatically closed by our quality checks.

Common reasons:

  • New GitHub account with limited contribution history
  • PR description doesn't meet our guidelines
  • Contribution appears to be AI-generated without meaningful review

If you believe this was a mistake, please open an issue explaining your intended contribution and a maintainer will help you.

1 similar comment
@github-actions

Copy link
Copy Markdown

👋 This PR was automatically closed by our quality checks.

Common reasons:

  • New GitHub account with limited contribution history
  • PR description doesn't meet our guidelines
  • Contribution appears to be AI-generated without meaningful review

If you believe this was a mistake, please open an issue explaining your intended contribution and a maintainer will help you.

@anandgupta42

Copy link
Copy Markdown
Contributor Author

Review comments addressed (2 rounds)

Thanks to @dev-punia-altimate, @chatgpt-codex-connector, and @cubic-dev-ai.

Round 2 — 3ac7b68324

  • P1 — MCP-prefix DDL bypass: rules keyed sql_execute/bash, but MCP tools arrive flattened as <client>_<tool> (e.g. warehouse_sql_execute) → no rule matched → DDL ran. Added resolveGovernedKey() (mcp-source only, full _-delimited suffix; native keeps exact-match).
  • P1 — audit arg retention: finalArgsDigest stored raw truncated args (could hold secrets); now a SHA-256 hash, correlation preserved.

Round 3 — 348e930cf9

  • HIGH (@dev-punia-altimate — bash whitespace bypass): Wildcard.match anchors at ^, so " DROP DATABASE prod" slipped past "DROP DATABASE *". matchBashDdl now trims first. +test.
  • MEDIUM — Map/Set digest collision: Object.keys(new Map()) is [], so distinct Maps/Sets both stringified to {} and collided; now serialized distinctly. +test.
  • MEDIUM — tool-call metadata recorded pre-hook args: both dispatch sites now build ctx from the post-hook finalArgs (passing input.session.id/toolCallId directly to the hook), so the UI/state reflects what actually ran.
  • MEDIUM — tool.execute.after on hard deny: documented that skipping it is intentional (the tool never executed; the security invariant holds regardless).

Deferred (with rationale): the export namespace → flat-export convention (P1). No test monkeypatches HardPolicy, so it's safe to convert — but export namespace is the established pattern across 9 sibling src/altimate/ modules, so converting this one file alone breaks local consistency for a marginal tree-shaking gain on an always-fully-used module. Better as a subtree-wide follow-up.

24 pass, typecheck clean, markers clean.

@chatgpt-codex-connector

Copy link
Copy Markdown

To use Codex here, create an environment for this repo.

@dev-punia-altimate

Copy link
Copy Markdown
Contributor

🤖 Code Review — OpenCodeReview (Gemini) — No Issues Found

No supported files changed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

De-fork spike S3: HardPolicy kill gate (non-bypassable DDL denies at every dispatcher)

2 participants