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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ npm run clean rm -rf dist
- **Paths**: never `split("/")`, `pop()` for basename, `startsWith("/")` for absolute, or hardcode `/` as a separator in regex / string literals. Use `path.basename`, `path.dirname`, `path.join`, `path.isAbsolute`, `path.resolve`. For glob matching against file paths, normalize `\` → `/` first (see `lib/config.ts:matchesAny`).
- **POSIX-only assertions** (file mode `0o600`, `chmod`, etc.): guard with `if (process.platform === "win32") return;`. NTFS doesn't honour Unix mode bits.
- **Test setup**: when comparing against `tmpdir()`-derived paths, `realpathSync()` first — macOS resolves `/var/folders/...` → `/private/var/folders/...`, Windows resolves 8.3 short names (`RUNNER~1` → `runneradmin`).
- **NEVER assert on a full absolute path that mixes a `tmpdir()` prefix with output from an external process** (`git`, child procs, etc.). On Windows CI, `realpathSync(tmpdir())` is NOT a reliable 8.3 expander — it can keep `RUNNER~1` while the external tool returns the long form `runneradmin`, so `expect(out).toContain(join(repo, "a.ts"))` fails even though the paths refer to the same file (this has now bitten the suite twice). Instead assert on the **separator-normalized relative suffix**: e.g. `const has = (out, rel) => (out ?? []).some(p => p.replace(/\\/g, "/").endsWith("/" + rel)); expect(has(out, "a.ts")).toBe(true);`. Same applies to any path the code builds via `path.join(<git/tool output>, …)`.
- **Build/scripts**: never use bash-only constructs (`while read`, `${var#prefix}`, `find ... -exec`). Write a Node script under `scripts/` instead — see `scripts/copy-assets.js`.

### Testing
Expand Down
9 changes: 9 additions & 0 deletions docs/claude-md/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,15 @@ Target selection:
| `verification.auto` | `boolean` | true | rerender | Automatic-enforcement sub-toggle (only meaningful when verification.enable !== false). Inverse semantics — opt out with false for "assist mode": the /ironbee-verify command + MCP servers stay installed so the agent can verify manually, but nothing is enforced — no Stop gate, non-blocking (soft) PreToolUse hooks, and the skill/rule are omitted. true → full enforce mode. |
| `verification.enable` | `boolean` | true | rerender | Master enforcement switch. Inverse semantics — verification is the core feature, opt out with false. When false, IronBee runs monitoring-only: no enforcement hooks, no skill/rule, no MCP servers; only session/activity/tool_call events flow to the collector. |

### Verification context

| Key | Type | Default | Artifacts | Description |
| --- | --- | --- | --- | --- |
| `verificationContext.commitDepth` | `number` | 1 | — | Number of recent commits included on the git source (uncommitted ∪ last N commits). 0 = uncommitted only. Ignored when source is "actions". |
| `verificationContext.enable` | `boolean` | true | — | Master switch for path-scoped verification context. When a cycle begins, the first devtools tool call injects the .ironbee/VERIFICATION.md files on/above the changed paths (author-written area guidance) into the agent's context via the host's additionalContext channel. Advisory only (does not gate). Inverse semantics — default-on, opt out with false. Not artifact-affecting (read live). |
| `verificationContext.maxBytes` | `number` | 65536 (64 KB) | — | Aggregate byte cap on the injected context string. When exceeded, the least-specific (root-side) docs are dropped first (leaf-first truncation priority). |
| `verificationContext.source` | `string` | git | — | Changed-path source. "git" (default) = working tree + last N commits, with an actions.jsonl fallback when not a git repo. "actions" = IronBee's own file_change events only (no git subprocess). |

### Browser cycle

| Key | Type | Default | Artifacts | Description |
Expand Down
4 changes: 3 additions & 1 deletion docs/claude-md/project-structure.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ src/
│ ├── tool-use-stash.ts # generic PreToolUse → PostToolUse stash; tmp-backed so OS reclaims orphans (used by Write to remember existsSync across the call boundary)
│ ├── file-diff.ts # diffLineCounts (LCS) + countLines — used by clear-verdict for file_change line stats
│ ├── required-tools.ts # alwaysRequired + evidencePaths satisfier — pure logic for the multi-cycle gate
│ ├── verification-context.ts # path-scoped verification guidance: collect changed paths (git + actions fallback) → resolve hierarchical .ironbee/VERIFICATION.md → build injectable string (leaf-first cap); buildVerificationContextOnceForCycle = per-cycle dedup used by require-verification (Claude/Codex) + track-action (Cursor). Spec: docs/verification-context-design.md
│ └── actions.ts # session action logger — actions.jsonl read/write
├── clients/
│ ├── base.ts # IClient interface
Expand Down Expand Up @@ -168,7 +169,8 @@ src/
│ ├── verify-gate.ts # Stop → runVerifyGate + decision:block+reason for continuation prompt + Stop-as-checkpoint session_end synthesis (deterministic id keyed on session_id; backend dedup latest-wins — covers missing SessionEnd hook event)
│ └── activity-end.ts # Stop (monitoring mode) → activity_end + Stop-as-checkpoint session_end synthesis
├── lib/
│ ├── config.ts # Config loader (global + project, glob pattern matching, getActiveCycles, getRequiredToolsConfig, MCP entry builders)
│ ├── config.ts # Config loader (global + project, glob pattern matching, getActiveCycles, getRequiredToolsConfig, MCP entry builders, verificationContext accessors + isIgnoredVerifyPath)
│ ├── git.ts # Minimal git helpers — getChangedPaths(projectDir, commitDepth): absolute changed paths (working tree + last N commits) or null when not a repo; execFileSync, -z NUL output, cross-platform. Consumed by hooks/core/verification-context.ts
│ ├── event.ts # Generic Event base + EventType const + hierarchical marker interfaces + SessionStatusEvent (statusline snapshot) — every emitted event extends this
│ ├── logger.ts # Log level control + session file logging
│ ├── output.ts # Colored console output helpers (picocolors)
Expand Down
11 changes: 11 additions & 0 deletions docs/claude-md/verification.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,17 @@ User-defined content keeps each file alive — additional MCP servers, custom ho

**Detection of monitoring sessions** (used by `ironbee status` / `ironbee verify`): zero `verification_requested` events. (Not zero `verification_start` — an enforce-mode session where the agent never edited code also has zero of those, but Stop hook still emits `verification_requested(allow)`.) **Assist-mode sessions also have zero `verification_requested`** — assist's Stop hook is `activity-end`, not `verify-gate`, so no gate marker is emitted. From this signal alone, an assist session is indistinguishable from a monitoring one; assist sessions still emit `verification_start` / `verdict_write` when the agent runs a manual `/ironbee-verify` cycle, which is the positive signal that distinguishes them.

## Path-Scoped Verification Context (`.ironbee/VERIFICATION.md`)
**Canonical spec: [`docs/verification-context-design.md`](../verification-context-design.md).** Summary:

Teams author area-specific verification guidance in `.ironbee/VERIFICATION.md` files placed in any directory. When a verification cycle begins, IronBee injects the guidance relevant to **what changed this cycle** into the agent's context via the host's formal `additionalContext` channel — advisory only, never gates.

- **Resolution**: for each changed path, walk its dir up to `projectDir` (inclusive), collecting every `.ironbee/VERIFICATION.md`; dedup; merge root→leaf. Missing intermediate levels are skipped.
- **Change source** (`verificationContext.source`): `git` (default — `git status` working tree ∪ last `commitDepth` commits, default 1; `actions.jsonl` fallback when not a git repo) or `actions` (IronBee's own `file_change` events). `ignoredVerifyPatterns` filters the set.
- **Injection point**: the FIRST devtools (`bdt_*`/`ndt_*`/`bedt_*`) tool call of each cycle, deduped per `verificationId` (`state.json:contextInjectedVerificationId`). Claude/Codex → `require-verification` (PreToolUse) `hookSpecificOutput.additionalContext`; Cursor → `track-action` (PostToolUse) `additional_context` (Cursor's preToolUse can't inject on allow). `track-action-monitor` (monitor mode) never injects.
- **Config** (`verificationContext.*`, not artifact-affecting — read live): `enable` (default true, inverse), `source`, `commitDepth`, `maxBytes` (default 64 KB; truncation drops least-specific docs first).
- **Modes**: enforce + assist inject; monitor does not.

## Verification Flow (verify-gate, multi-cycle)
**Canonical spec: [`docs/flow.md`](../flow.md)** — end-to-end runtime flow: hook fire order, per-hook allow/deny branches, multi-cycle gate decision tree, cycle lifecycle, state machine, cleanup paths. The summary below is a quick reference; when in doubt, the spec wins.

Expand Down
2 changes: 2 additions & 0 deletions docs/flow.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,8 @@ Each hook's full job: trigger, reads, writes, allow/deny branches.

`mcpServer` is the routed server (`browser-devtools` for `bdt_*`, `node-devtools` for `ndt_*`). Recording enforcement is **browser-only** — `ndt_*` tools bypass the recording check entirely (CLAUDE.md "Recording Enforcement" §2).

**Path-scoped verification context (additionalContext)**: on the ALLOW path, the FIRST devtools call of a verification cycle also injects author-written `.ironbee/VERIFICATION.md` guidance — resolved hierarchically for the changed paths (git working tree + last N commits, actions.jsonl fallback) — via the host's formal `additionalContext` channel (`hookSpecificOutput.additionalContext` on Claude/Codex). Deduped per `verificationId` (`state.json:contextInjectedVerificationId`, stamped even when empty so git isn't re-run per call). Advisory only — never blocks. Disabled by `verificationContext.enable: false`. On **Cursor** the channel moves to `track-action` (PostToolUse `additional_context`) because Cursor's `preToolUse` can't inject on allow. See `docs/verification-context-design.md`.

### 4.4 `require-verdict` — `PreToolUse` (edit matcher)

**Matchers**: Claude `Write|Edit`, Cursor `Write|StrReplace|Delete`.
Expand Down
18 changes: 18 additions & 0 deletions src/clients/claude/hooks/require-verification.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import { getActiveVerificationId, getActiveTraceId, getActiveActivityId, isRecor
import { resolveProjectName } from "../../../hooks/core/actions";
import { startActivity } from "../../../hooks/core/activity";
import { startVerification, VerificationStartResult } from "../../../hooks/core/verification-lifecycle";
import { buildVerificationContextOnceForCycle } from "../../../hooks/core/verification-context";
import { loadConfig } from "../../../lib/config";
import { logger, setLogFile } from "../../../lib/logger";
import { extractMcpServerName } from "../util";
Expand All @@ -52,6 +53,7 @@ interface ClaudePreToolUseOutput {
hookEventName: string;
permissionDecision?: string;
updatedInput?: Record<string, unknown>;
additionalContext?: string;
};
}

Expand Down Expand Up @@ -191,6 +193,22 @@ Then use the verification tools for the active cycle(s) — bdt_* for browser, n
},
};

// Path-scoped verification context: on the first devtools call of this
// cycle, inject the relevant `.ironbee/VERIFICATION.md` guidance via the
// formal `additionalContext` channel (deduped per verificationId). Advisory
// only — never blocks. No-op when disabled / nothing matches / already
// injected for this cycle.
const injectedContext: string = buildVerificationContextOnceForCycle({
projectDir,
sessionId,
sessionDir,
activeVerificationId: effectiveVerificationId,
config,
});
if (injectedContext.length > 0 && output.hookSpecificOutput) {
output.hookSpecificOutput.additionalContext = injectedContext;
}

process.stdout.write(JSON.stringify(output));
process.exit(0);
}
33 changes: 26 additions & 7 deletions src/clients/codex/hooks/require-verification.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {
} from "../../../hooks/core/session-state";
import { resolveProjectName } from "../../../hooks/core/actions";
import { startVerification, VerificationStartResult } from "../../../hooks/core/verification-lifecycle";
import { buildVerificationContextOnceForCycle } from "../../../hooks/core/verification-context";
import { loadConfig } from "../../../lib/config";
import { logger, setLogFile } from "../../../lib/logger";
import { readStdin } from "../../../lib/stdin";
Expand Down Expand Up @@ -159,13 +160,31 @@ Then use the verification tools for the active cycle(s) — mcp__browser-devtool
}
updatedInput._metadata = metadata;

process.stdout.write(JSON.stringify({
hookSpecificOutput: {
hookEventName: "PreToolUse",
permissionDecision: "allow",
updatedInput,
},
}));
// Path-scoped verification context via Codex's `additionalContext` (source-
// verified to be honored on PreToolUse allow). Same per-cycle dedup + core
// as Claude. Advisory only.
const hookSpecificOutput: {
hookEventName: string;
permissionDecision: string;
updatedInput: Record<string, unknown>;
additionalContext?: string;
} = {
hookEventName: "PreToolUse",
permissionDecision: "allow",
updatedInput,
};
const injectedContext: string = buildVerificationContextOnceForCycle({
projectDir,
sessionId,
sessionDir,
activeVerificationId: effectiveVerificationId,
config,
});
if (injectedContext.length > 0) {
hookSpecificOutput.additionalContext = injectedContext;
}

process.stdout.write(JSON.stringify({ hookSpecificOutput }));
logger.debug(`require-verification: allowed ${toolName} with _metadata`);
process.exit(0);
}
33 changes: 31 additions & 2 deletions src/clients/cursor/hooks/track-action.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,8 @@

import { appendAction, baseFields, ToolCallAction } from "../../../hooks/core/actions";
import { setRecordingActive, getActiveActivityId, getActiveVerificationId, getActiveTraceId } from "../../../hooks/core/session-state";
import { isJobQueueEnabled } from "../../../lib/config";
import { buildVerificationContextOnceForCycle } from "../../../hooks/core/verification-context";
import { isJobQueueEnabled, loadConfig } from "../../../lib/config";
import { logger, setLogFile } from "../../../lib/logger";
import { writeAndExit } from "../../../lib/output";
import { readStdin } from "../../../lib/stdin";
Expand Down Expand Up @@ -326,7 +327,35 @@ export async function run(projectDir: string): Promise<void> {
}
}

writeAndExit(JSON.stringify({}), 0);
// Path-scoped verification context. Cursor's preToolUse can't inject on the
// allow path (agent_message is deny-only), so the injection lives here in
// postToolUse via `additional_context` — fired on the first devtools call of
// the cycle (deduped per verificationId). Non-devtools tools never trigger
// a build. `track-action-monitor` (monitoring mode) is intentionally left
// without this branch — monitor never injects.
const output: { additional_context?: string } = {};
if (isOurDevToolsTool) {
// Belt-and-suspenders: this is the only loadConfig call on Cursor's
// devtools track-action path, and loadConfig throws on a malformed
// config layer. The injection must never break the hook (the tool_call
// is already persisted above), so swallow everything → no context.
try {
const injectedContext: string = buildVerificationContextOnceForCycle({
projectDir,
sessionId,
sessionDir,
activeVerificationId: verificationId,
config: loadConfig(projectDir),
});
if (injectedContext.length > 0) {
output.additional_context = injectedContext;
}
} catch (e: unknown) {
logger.debug(`track-action: verification-context injection skipped: ${e instanceof Error ? e.message : e}`);
}
}

writeAndExit(JSON.stringify(output), 0);
}

/**
Expand Down
Loading
Loading