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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,16 @@ Web pages, emails, issues, documents, tool results and MCP tool descriptions can

**Ready:** a core with adapters for **Claude Code**, **Codex CLI**, **Kimi Code**, **DeepSeek Harness** and **Gemini CLI**, a gateway for **MCP hosts**, and middleware for **LangChain** agents.

**Measured:** 1725 tests · 25 pinned attack vectors · 9 legitimate documents · 2 runtime dependencies.
**Measured:** 1736 tests · 25 pinned attack vectors · 9 legitimate documents · 2 runtime dependencies.

**AgentDojo** (a public benchmark of 97 agent tasks with injected attacks): with Cordon, an agent that obeys every injection got 0 attacks through on all four suites; without it, 39–100% succeeded. Utility depends on the policy: 97 of 97 tasks in interactive mode at 0.25–1.6 questions per task, 14–75% per suite on a strict autonomous policy. On a live model that follows injections, Laguna S 2.1, 0 of 80 attacks got through against 30 of 80 undefended. The methodology and where Cordon loses are in [docs/agentdojo.md](docs/agentdojo.md).

**Site:** [cordon.aifrontier.tech](https://cordon.aifrontier.tech/en/) · **Lab:** [AI Frontier](https://aifrontier.tech) · **Telegram:** [Gorilla Under Hood](https://t.me/gorilla_under_hood)

**Install:** [Claude Code](docs/install.md) · [Codex CLI](docs/install-codex.md) · [Kimi Code](docs/install-kimi.md) · [DeepSeek Harness](docs/install-deepseek.md) · [Gemini CLI](docs/install-gemini.md) · [MCP hosts](docs/install-mcp.md) · [LangChain](docs/install-langchain.md)

**Example workflow:** [Local GitHub PR review with owner-controlled publication and merge](docs/pr-review-workflow.md).

> ⚠️ **Early development.** Live runs so far:
> - **Claude Code 2.1.236**, with all four events.
> - **The MCP gateway with Codex CLI as the host.** Without Cordon, Codex sent a ticket summary to the address the ticket planted. Through the gateway, that send was refused.
Expand Down Expand Up @@ -136,7 +138,7 @@ npm install @ilyautov/cordon
npx cordon scan README.md
```

Node 22 or newer is required. Cordon makes no network requests and no model calls. For an organization, deploy the hooks through Claude Code managed settings so a repository cannot switch them off ([docs/enterprise.md](docs/enterprise.md)).
Node 22 or newer is required. Cordon's gate makes no network requests and no model calls. The opt-in PR review example uses a local agent and GitHub API. For an organization, deploy the hooks through Claude Code managed settings so a repository cannot switch them off ([docs/enterprise.md](docs/enterprise.md)).

## What this is NOT

Expand Down
4 changes: 3 additions & 1 deletion README.ru.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,16 @@

**Готово:** ядро и адаптеры для **Claude Code**, **Codex CLI**, **Kimi Code**, **DeepSeek Harness** и **Gemini CLI**, гейтвей для **MCP-хостов** и мидлварь для агентов на **LangChain**.

**Измерено:** 1725 тестов · 25 закреплённых векторов атаки · 9 легитимных документов · 2 рантайм-зависимости.
**Измерено:** 1736 тестов · 25 закреплённых векторов атаки · 9 легитимных документов · 2 рантайм-зависимости.

**AgentDojo** (открытый бенчмарк из 97 задач агента с внедрёнными атаками): агент, выполняющий любую инъекцию, с Cordon не провёл ни одной атаки ни на одном из четырёх наборов; без него проходило 39–100%. Полезность зависит от политики: 97 задач из 97 в интерактивном режиме при 0,25–1,6 вопроса на задачу и 14–75% по наборам на строгой автономной политике. На живой модели, которая выполняет инъекции, Laguna S 2.1, прошло 0 атак из 80 против 30 из 80 без защиты. Методика и то, где Cordon проигрывает, описаны в [docs/agentdojo.md](docs/agentdojo.md).

**Сайт:** [cordon.aifrontier.tech](https://cordon.aifrontier.tech/ru/) · **Лаборатория:** [AI Frontier](https://aifrontier.tech) · **Telegram:** [Gorilla Under Hood](https://t.me/gorilla_under_hood)

**Установка:** [Claude Code](docs/install.md) · [Codex CLI](docs/install-codex.md) · [Kimi Code](docs/install-kimi.md) · [DeepSeek Harness](docs/install-deepseek.md) · [Gemini CLI](docs/install-gemini.md) · [MCP-хосты](docs/install-mcp.md) · [LangChain](docs/install-langchain.md)

**Пример:** [локальная проверка GitHub PR с отдельным решением владельца о публикации и merge](docs/pr-review-workflow.md).

> ⚠️ **Ранняя разработка.** Живые прогоны на сегодня:
> - **Claude Code 2.1.236**, все четыре события.
> - **MCP-гейтвей с Codex CLI в роли хоста.** Без Cordon Codex отправил сводку тикета на адрес, подброшенный тикетом. Через гейтвей эта отправка была отклонена.
Expand Down
Binary file modified assets/social-preview.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion assets/social-preview.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
76 changes: 76 additions & 0 deletions docs/pr-review-workflow.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
# Local GitHub PR review boundary

This is an opt-in reference workflow. It runs Codex locally, keeps PR-authored metadata in MCP tool results, and leaves publication and merge to deterministic owner commands. It does not run a model in GitHub Actions.

## What crosses each boundary

| Surface | Content | Authority |
| --- | --- | --- |
| Human prompt | A fixed instruction and PR number | Review only |
| `read_pr` | Live GitHub title and body at a pinned head | Untrusted source text |
| `read_diff` | Live GitHub diff, checked against the head before and after the read | Untrusted source text; a SHA and diff digest enter the ledger |
| `propose_merge` | Exact PR number, after `read_diff` | Records a proposal only |
| `comment` | Fixed text with PR number and SHA | Posts no model output or PR-authored text |
| `merge` | SHA-bound evidence and one-use owner approval | Calls GitHub with `sha` as a final compare-and-swap |

The agent is started in an empty read-only work directory with shell, browser, apps, plugins and web search disabled. Its only PR tools are the three above. Cordon handles the prompt, tool calls and tool results; the runner checks that those hooks actually fired. The runner removes `GH_TOKEN` and `GITHUB_TOKEN` from the agent environment; the narrow MCP server uses the local `gh auth` credential. Use a separate OS account or container for the agent when the owner approval directory must be protected from an agent process with broader local capabilities. The read-only Codex sandbox and tool list alone do not establish that OS boundary.

## Local review

Build the library and committed hook bundle first:

```bash
npm ci
npm run build
```

For one PR, pin its current head and choose a ledger path outside the agent work directory:

```bash
repo=OWNER/REPO
pr=12
sha=$(gh api "repos/$repo/pulls/$pr" --jq .head.sha)
ledger=$(mktemp /tmp/cordon-pr-review.XXXXXX)
node scripts/pr-review-local.mjs "$repo" "$pr" "$sha" "$ledger"
```

The runner exits nonzero if the agent does not read the real diff, a hook is missing, or the run has no final answer. Its JSON event stream is the agent's private review output; do not post that stream to GitHub. The ledger contains only the repository, PR number, head SHA, diff digest and merge proposal. Keep it owner-controlled. A successful diff tool call proves a diff was fetched and returned, not that the model understood it.

## Trusted quality check

The merge command requires a protected GitHub Actions workflow named `PR quality gate`. Dispatch it from `main`, have it check the exact candidate SHA as data, and have that workflow alone publish `cordon-pr/quality` to the candidate commit after the check. Pin its numeric workflow ID in `CORDON_PR_TRUSTED_WORKFLOW_ID`; set `CORDON_PR_QUALITY_CONTEXT` if the status context has a different name. The command checks the latest status for that context at the exact SHA, the GitHub Actions bot as creator, the run URL, repository, workflow ID, `workflow_dispatch` on `main`, and successful conclusion. No other workflow should have `statuses: write` permission. Never execute candidate code in a job that holds a write token.

The check's content is repository-specific. This integration verifies the evidence contract; it does not supply a universal code-quality or XSS detector. A status from a workflow that merely reports success without checking the candidate gives no quality assurance.

## Owner actions

Inspect the real diff, the review output, the quality workflow and the SHA yourself. Then, from an owner process that the agent cannot invoke or write to:

```bash
export CORDON_PR_TRUSTED_WORKFLOW_ID=123
approval_dir=/private/owner/pr-approvals
node dist/pr-workflow/cli.js approve "$repo" "$pr" "$sha" "$ledger" "$approval_dir"
node dist/pr-workflow/cli.js check "$repo" "$pr" "$sha" "$ledger" "$approval_dir"
```

Approval is bound to the repository, PR and SHA, expires after one hour, and is consumed once. `approve` first requires the diff read, proposal and trusted quality check. If the head changes, start a new review. `check` reports the gate decision without consuming approval. These commands do not publish or merge.

Optional publication uses a fixed comment, never the model's answer:

```bash
node dist/pr-workflow/cli.js comment "$repo" "$pr" "$sha" "$ledger"
```

Only an explicit owner-run `merge` performs the GitHub merge, after another head check and atomic approval consumption:

```bash
node dist/pr-workflow/cli.js merge "$repo" "$pr" "$sha" "$ledger" "$approval_dir"
```

The GitHub merge API also receives `sha`, so a force push between the final read and request cannot merge a different head. GitHub branch protection should independently require the quality check and reviews. The comment endpoint has no SHA precondition; its fixed text names the reviewed SHA so it remains accurate if the PR moves afterward.

## Scope of the current check

The local runner was exercised on three disposable PRs at fixed heads: a clean layout change, a PR with an injected instruction in its title, and a PR whose description contained a forged diff while its actual diff changed `innerHTML`. In all three runs the agent called `read_pr` and `read_diff`, the hook log contained prompt, pre-tool and post-tool events, and no run published or merged. The title instruction produced no diagnostic canary or native command; the forged-description run reported the actual `innerHTML` problem. These are three observations, not a general attack-success rate or a production deployment.

GitHub API references: [merge a PR with an expected SHA](https://docs.github.com/en/rest/pulls/pulls#merge-a-pull-request), [commit statuses](https://docs.github.com/en/rest/commits/statuses), [workflow runs](https://docs.github.com/en/rest/actions/workflow-runs).
42 changes: 42 additions & 0 deletions scripts/pr-review-hook.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
// A failed hook would otherwise be treated as permission to continue by Codex.
import { spawnSync } from 'node:child_process'
import { appendFileSync, readFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'

const bundle = join(dirname(fileURLToPath(import.meta.url)), '../plugin/dist/cli.js')
let kind = 'PreToolUse'
function refuse() {
const response = kind === 'PostToolUse'
? { decision: 'block', reason: 'PR review hook failed' }
: { hookSpecificOutput: { hookEventName: kind, permissionDecision: 'deny',
permissionDecisionReason: 'PR review hook failed' } }
process.stdout.write(JSON.stringify(response))
process.exitCode = 2
}

try {
const input = readFileSync(0, 'utf8')
const event = JSON.parse(input)
kind = event?.hook_event_name ?? kind
const result = spawnSync(process.execPath, [bundle, 'hook', '--harness', 'codex'], {
input, encoding: 'utf8', timeout: 4_000,
})
const answer = JSON.parse(result.stdout ?? '')
const decision = answer?.hookSpecificOutput?.permissionDecision ?? answer?.decision ?? null
if (result.error || ![0, 2].includes(result.status) ||
typeof answer !== 'object' || answer === null || Array.isArray(answer) ||
(result.status === 2 && !['deny', 'block'].includes(decision))) refuse()
else {
if (!process.env.CORDON_PR_HOOK_LOG) throw new Error('missing hook evidence path')
appendFileSync(process.env.CORDON_PR_HOOK_LOG, JSON.stringify({
kind, tool: typeof event?.tool_name === 'string' ? event.tool_name : null,
status: result.status, decision,
}) + '\n', { mode: 0o600 })
process.stdout.write(result.stdout)
if (result.stderr) process.stderr.write(result.stderr)
process.exitCode = result.status
}
} catch {
refuse()
}
97 changes: 97 additions & 0 deletions scripts/pr-review-local.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
// Run the review with only the fixed PR MCP tools and Cordon hooks.
// The local agent gets no shell, token environment variable, or approval directory.
import { spawnSync } from 'node:child_process'
import { mkdtempSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { fixedReviewPrompt } from '../dist/pr-workflow/review.js'
import { readReviewEvents, validateTarget } from '../dist/pr-workflow/store.js'

const root = join(dirname(fileURLToPath(import.meta.url)), '..')
const [repo, numberText, headSha, ledger] = process.argv.slice(2)
const target = { repo: repo ?? '', number: Number(numberText), headSha: headSha ?? '' }
validateTarget(target)
if (!ledger) throw new Error('usage: node scripts/pr-review-local.mjs <repo> <number> <sha> <ledger>')

const temp = mkdtempSync(join(tmpdir(), 'cordon-pr-local-'))
const work = join(temp, 'work')
const cordonHome = join(temp, 'cordon-home')
const hookLog = join(temp, 'hook-events.jsonl')
mkdirSync(work, { mode: 0o700 })
mkdirSync(cordonHome, { mode: 0o700 })
writeFileSync(join(cordonHome, 'policy.yaml'), [
'mode: autonomous',
`task: Review PR number ${target.number} at the pinned head and report findings`,
'profile:',
' effects: [read, summarize, update]',
'tools:',
' mcp__pr__read_pr: [read]',
' mcp__pr__read_diff: [read]',
' mcp__pr__propose_merge: [update]',
'toolsReturn:',
' mcp__pr__read_pr: source',
' mcp__pr__read_diff: source',
'arguments:',
' mcp__pr__propose_merge:',
' pr: destination',
'destinations:',
` - "${target.number}"`,
'notify:',
` file: ${join(cordonHome, 'events.jsonl')}`,
'',
].join('\n'), { mode: 0o600 })

const quote = (value) => "'" + value.replaceAll("'", "'\\''") + "'"
const hookCommand = quote(process.execPath) + ' ' + quote(join(root, 'scripts/pr-review-hook.mjs'))
const hook = (matcher) => '[{' + (matcher === null ? '' : 'matcher=' + JSON.stringify(matcher) + ',') +
'hooks=[{type="command",command=' + JSON.stringify(hookCommand) + ',timeout=5}]}]'
const toml = (value) => JSON.stringify(value)
const args = [
'exec', '--json', '--ephemeral', '--ignore-user-config', '--skip-git-repo-check',
'--strict-config', '--color', 'never', '--sandbox', 'read-only',
'--enable', 'hooks', '--dangerously-bypass-hook-trust',
'--disable', 'shell_tool', '--disable', 'apps', '--disable', 'browser_use',
'--disable', 'computer_use', '--disable', 'multi_agent_v2', '--disable', 'memories',
'--disable', 'plugins', '--disable', 'remote_plugin',
'-c', 'hooks.UserPromptSubmit=' + hook(null),
'-c', 'hooks.PreToolUse=' + hook('*'),
'-c', 'hooks.PostToolUse=' + hook('*'),
'-c', 'approval_policy="never"',
'-c', 'web_search="disabled"',
'-c', 'mcp_servers.pr.command=' + toml(process.execPath),
'-c', 'mcp_servers.pr.args=' + toml([join(root, 'dist/pr-workflow/cli.js'),
'serve', repo, String(target.number), headSha, ledger]),
'-c', 'mcp_servers.pr.required=true',
'-c', 'mcp_servers.pr.tools.read_pr.approval_mode="approve"',
'-c', 'mcp_servers.pr.tools.read_diff.approval_mode="approve"',
'-c', 'mcp_servers.pr.tools.propose_merge.approval_mode="approve"',
'-C', work, fixedReviewPrompt(target.number),
]
const agentEnv = { ...process.env, CORDON_HOME: cordonHome, CORDON_PR_HOOK_LOG: hookLog }
delete agentEnv.GH_TOKEN
delete agentEnv.GITHUB_TOKEN
const result = spawnSync('codex', args, {
cwd: work, env: agentEnv, encoding: 'utf8',
timeout: 180_000, maxBuffer: 16 * 1024 * 1024,
})
process.stdout.write(result.stdout ?? '')
process.stderr.write(result.stderr ?? '')
process.stderr.write(`\nReview evidence: ${ledger}\nHook evidence: ${hookLog}\n`)
if (result.error) throw result.error
const events = (result.stdout ?? '').split('\n').filter(Boolean).map((line) => JSON.parse(line))
const calls = events.filter((row) => row.type === 'item.completed' && row.item?.type === 'mcp_tool_call')
const hooks = readFileSync(hookLog, 'utf8').split('\n').filter(Boolean).map((line) => JSON.parse(line))
const readDiff = calls.some((row) => row.item.tool === 'read_diff' && row.item.status === 'completed')
const diffEvidence = readReviewEvents(ledger).some((event) => event.kind === 'diff-read' &&
event.repo === repo && event.number === target.number && event.headSha === headSha)
const hooksCovered = hooks.some((row) => row.kind === 'UserPromptSubmit' && row.status === 0) &&
calls.every((call) => hooks.some((row) => row.kind === 'PreToolUse' && row.status === 0 &&
row.tool === `mcp__pr__${call.item.tool}`) && hooks.some((row) => row.kind === 'PostToolUse' &&
row.tool === `mcp__pr__${call.item.tool}` && (row.status === 0 ||
(row.status === 2 && row.decision === 'block'))))
const finalAnswer = events.some((row) => row.type === 'item.completed' && row.item?.type === 'agent_message')
if (result.status !== 0 || !readDiff || !diffEvidence || !hooksCovered || !finalAnswer) {
process.stderr.write('PR review incomplete: missing diff, hook, or final-answer evidence\n')
process.exitCode = 1
}
Loading
Loading