Skip to content

refactor(runtime): make sandbox tool hooks and runner capabilities runtime-neutral - #6355

Merged
waynesun09 merged 1 commit into
mainfrom
runtime-neutral-sandbox-hooks
Aug 21, 2026
Merged

refactor(runtime): make sandbox tool hooks and runner capabilities runtime-neutral#6355
waynesun09 merged 1 commit into
mainfrom
runtime-neutral-sandbox-hooks

Conversation

@waynesun09

@waynesun09 waynesun09 commented Aug 19, 2026

Copy link
Copy Markdown
Member

Summary

Makes the sandbox tool hooks and the runner's runtime-specific behaviour runtime-neutral so that OpenCode (#1260), Cursor CLI (#6319), pi and any future runtime can reuse the existing Tirith/SSRF/canary/redaction hooks instead of re-implementing or silently dropping them. No behaviour change for the Claude runtime.

Related Issue

Fixes #6354

Changes

  • security: ClaudeSandboxHooksSandboxHookConfig (SandboxHookConfigFromHarness). New HookPlan() returns the runtime-neutral wiring — ordered HookGroup{Phase, Tools, Scripts} — and GenerateClaudeSettings is now rendered from it, so Claude's settings.json and any other runtime's adapter cannot diverge. Generated JSON is byte-identical (existing golden tests unchanged).
  • runtime: ClaudeHooksBootstrapSandboxHooksBootstrap; installHookScripts(sandbox, dir, cfg) (creates the dir, uploads + chmods the scripts anywhere) and appendHookEnv factored out of installClaudeHooks. New optional capability interfaces DebugLogNamer (+ DebugLogNameFor, default agent-debug.log) and ContextBridger (+ NeedsClaudeMDBridge), implemented by ClaudeRuntime.
  • runner (run.go): runtime is resolved before the fetch service starts so SkillDestDir is rt.ConfigDir()+"/skills" instead of the Claude constant; CLAUDE.md→AGENTS.md bridge is gated on ContextBridger instead of rt.Name() == "claude"; debug-log artifact name comes from DebugLogNameFor(tx); --debug help text is runtime-neutral. backendFromConfigFile is a pure config read, so the reorder only makes runtime-resolution failures surface before the fetch service is started.
  • docs: docs/runtimes.md — sandbox hook contract v1 (files, wiring plan, stdin/stdout/exit wire protocol as consumed by the scripts, fail modes split blocking-vs-sanitizing, env incl. FULLSEND_CANARY_TOKEN), runtime-specific config-key support matrix (also Track Cursor CLI as a supported agent runtime #6319's last AC), updated interface table and matrix rows; ADR 0090 (single decision: the hooks contract); architecture.md pointer + open-question annotation; cli-internals.md help text.
  • Local-run finding (Sandbox tool hooks are never loaded: settings.json is written to /sandbox/workspace/.claude but Claude Code runs from /sandbox/workspace/<repo> #6358, pre-existing, not fixed here): the --debug log of the local run shows Claude Code registering 0 hooks — it probes $CLAUDE_CONFIG_DIR/settings.json, managed settings and <repo>/.claude/settings.json, never /sandbox/workspace/.claude/settings.json where the runner writes the hook wiring (CLI cwd is /sandbox/workspace/<repo>). Reproduced locally on 2.1.235: parent-dir settings → hook not invoked; cwd settings → PreToolUse blocks (exit 1 + {"decision":"block"} works). One-flag fix (--settings <file>) proposed in Sandbox tool hooks are never loaded: settings.json is written to /sandbox/workspace/.claude but Claude Code runs from /sandbox/workspace/<repo> #6358; kept out of this refactor PR. The security matrix Claude column now says "wired; not loaded (Sandbox tool hooks are never loaded: settings.json is written to /sandbox/workspace/.claude but Claude Code runs from /sandbox/workspace/<repo> #6358)" for every hook row instead of ✓, and the contract has a "Claude Code caveats" bullet.
  • Review follow-up (PostToolUse sandbox hooks read tool_result but Claude Code sends tool_response — sanitizers are inert under Claude Code #6357): while verifying the wire protocol against the Claude Code hooks reference we found a pre-existing mismatch — Claude Code sends PostToolUse output as tool_response (scripts read tool_result), replaces output via hookSpecificOutput.updatedToolOutput, and runs matching hooks in parallel — so the PostToolUse sanitizers are wired but not effective under Claude Code today. Not this PR's regression and not fixed here (behaviour change, separate PR); the contract section carries an explicit caveat and the security matrix marks those Claude cells "wired, effectiveness unverified (PostToolUse sandbox hooks read tool_result but Claude Code sends tool_response — sanitizers are inert under Claude Code #6357)". Also from review: installHookScripts shell-quotes paths and uses security.SandboxHooksDir, DebugLogNameFor(rt, tx) checks both backend components, WantsClaudeMDBridge naming, HookPlan↔HookFiles all-enabled invariant test.

Deliberately not in this PR: ValidRuntimes() (still claude, dummy), any pi/opencode runtime code, and relaxing validModelName (/-less) — each noted in the docs as follow-ups.

Only intentional non-Claude behaviour delta: a runtime without DebugLogNamer now gets agent-debug.log as its local --debug artifact name (previously always claude-debug.log); Claude keeps claude-debug.log.

Testing

  • make lint passes (staged, pre-commit + ADR linters + markdown links)
  • Tests added/updated: HookPlan order/consistency + all-disabled, SandboxHookConfig Tirith defaults, hooks bootstrap with fake openshell (scripts uploaded/chmod'ed, settings.json still installed, hook env appended; no hooks without the extension; custom dir; upload/exec/chmod failure paths), DebugLogNameFor / NeedsClaudeMDBridge, newHarnessBootstrap round-trip
  • go test ./internal/{runtime,security,cli,fetchsvc,harness,config}/... green; go vet ./... clean
  • Patch coverage on HEAD (go tool cover -func): new/changed functions 80–100% (installHookScripts 81%, appendHookEnv 90%, HookPlan/GenerateClaudeSettings/capability helpers 100%); installClaudeHooks 65% only because of pre-existing, unchanged temp-file error branches
  • CI: behaviour, e2e, functional-tests, codecov/patch all green on the first push
  • Local end-to-end run with the branch-built CLI: fullsend run triage --forge github (fresh clone of fullsend-ai/agents, throwaway issue on a personal repo, Vertex SA creds, --debug --keep-sandbox --no-post-script) — sandbox bootstrapped with fullsend cross-compiled from this branch, all 7 hook scripts under /sandbox/workspace/.claude/hooks/ (-rwx), settings.json rendered from HookPlan (same matchers/order as before — and, as it turns out, not loaded by the CLI: Sandbox tool hooks are never loaded: settings.json is written to /sandbox/workspace/.claude but Claude Code runs from /sandbox/workspace/<repo> #6358), CLAUDE.md bridge injected, GitHub pre-flight OK, 17-turn Claude run produced a schema-valid agent-result.json, transcript + claude-debug.log extracted, output scan ran. Sandbox deleted afterwards.
  • make e2e-test (e2e/admin) not run locally — it exercises admin install/uninstall, not the fullsend run path; CI's e2e job passed
  • Note: TestDummyRuntime_Bootstrap / TestDummyRuntime_ClearIterationArtifacts fail on my machine identically on pristine origin/main (a local OpenShell gateway is running, so exec on a nonexistent sandbox does not error) — environmental, unrelated

Checklist

  • PR title follows Conventional Commits (refactor(runtime), no ! — internal rename, no user-visible change)
  • Commits are signed off (DCO) — human-directed session
  • I wrote this contribution myself and can explain all changes in it

@waynesun09
waynesun09 requested a review from a team as a code owner August 19, 2026 00:43
@qodo-code-review

Copy link
Copy Markdown

PR Summary by Qodo

Make sandbox hooks and runner capabilities runtime-neutral

✨ Enhancement 🧪 Tests 📝 Documentation 🕐 40+ Minutes

Grey Divider

AI Description

• Defines a shared hook plan and bootstrap contract reusable across agent runtimes.
• Replaces Claude-specific runner branches with optional runtime capabilities and runtime-owned
 paths.
• Preserves Claude behavior while documenting and testing the cross-runtime security contract.
Diagram

graph TD
  HC["Harness config"] --> BI["Bootstrap input"] --> RA["Runtime adapter"] --> HP["Hook plan"] --> HS["Hook scripts"]
  HP --> CS["Claude settings"]
  RN["Runner"] --> RA
  RN --> CP["Optional capabilities"]
  RA -. implements .-> CP
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Expand the core Runtime interface
  • ➕ Provides compile-time enforcement for every runtime capability.
  • ➕ Makes supported behavior immediately visible on each implementation.
  • ➖ Forces runtimes to implement irrelevant methods.
  • ➖ Creates breaking interface churn whenever another optional capability appears.
2. Introduce a universal host-side tool proxy
  • ➕ Centralizes hook execution outside runtime-specific adapters.
  • ➕ Could enforce identical interception semantics across all runtimes.
  • ➖ Requires a substantially larger architectural change.
  • ➖ Must proxy runtime-specific tool protocols and preserve result-rewrite semantics.
  • ➖ Would delay support for near-term OpenCode, Cursor, and pi integrations.
3. Keep runtime-specific hook implementations
  • ➕ Allows each runtime to use fully native configuration and terminology.
  • ➕ Avoids defining a canonical cross-runtime hook vocabulary.
  • ➖ Duplicates security wiring and ordering logic.
  • ➖ Risks silent feature gaps and behavioral divergence between runtimes.

Recommendation: Keep the PR's shared scripts, canonical HookPlan, and thin runtime adapters. This preserves proven Claude behavior while giving future runtimes one security contract; optional capability interfaces are preferable to expanding Runtime for behavior that only some backends need. A universal proxy may become attractive later, but it is disproportionate to this incremental refactor.

Files changed (15) +738 / -166

Enhancement (2) +121 / -0
runtime.goAdd optional runner capability interfaces +42/-0

Add optional runner capability interfaces

• Introduces DebugLogNamer and ContextBridger with safe helper functions. Runtimes without explicit debug naming receive agent-debug.log, while context bridging remains opt-in.

internal/runtime/runtime.go

sandbox_hooks_bootstrap.goAdd runtime-neutral hook bootstrap utilities +79/-0

Add runtime-neutral hook bootstrap utilities

• Defines SandboxHooksBootstrap and reusable helpers for creating arbitrary hook directories, uploading executable scripts, and appending Tirith environment settings.

internal/runtime/sandbox_hooks_bootstrap.go

Refactor (5) +190 / -139
bootstrap_input.goCarry runtime-neutral sandbox hook configuration +3/-3

Carry runtime-neutral sandbox hook configuration

• Renames the bootstrap hook payload and accessor to SandboxHookConfig. Harness security settings now expose the SandboxHooksBootstrap extension without referring to Claude.

internal/cli/bootstrap_input.go

run.goRemove Claude-specific decisions from runner orchestration +35/-29

Remove Claude-specific decisions from runner orchestration

• Resolves the runtime before starting the fetch service so skills use the runtime config directory. Uses optional capabilities for CLAUDE.md bridging and debug artifact names, and makes debug help text runtime-neutral.

internal/cli/run.go

claude.goImplement neutral capabilities in ClaudeRuntime +15/-43

Implement neutral capabilities in ClaudeRuntime

• Implements debug-log naming and context-bridge capabilities while adopting SandboxHooksBootstrap. Refactors Claude hook installation to use shared script upload and environment helpers without changing settings generation.

internal/runtime/claude.go

hooks.goMake HookPlan the canonical security wiring source +91/-64

Make HookPlan the canonical security wiring source

• Introduces runtime-neutral hook phases and ordered hook groups covering enabled scripts and tool matchers. Claude settings are now rendered from HookPlan, while HookFiles and feature checks consume SandboxHookConfig.

internal/security/hooks.go

sandbox_hooks.goDefine runtime-neutral sandbox hook configuration +46/-0

Define runtime-neutral sandbox hook configuration

• Replaces the Claude-named configuration wrapper with SandboxHookConfig derived from harness settings. Preserves Tirith severity and required-state defaults.

internal/security/sandbox_hooks.go

Tests (4) +308 / -19
bootstrap_input_test.goVerify the neutral bootstrap extension and payload +13/-4

Verify the neutral bootstrap extension and payload

• Updates type assertions for SandboxHooksBootstrap and verifies Tirith settings, skills, and plugins pass through the generated bootstrap input.

internal/cli/bootstrap_input_test.go

capabilities_test.goTest runtime capability defaults and overrides +36/-0

Test runtime capability defaults and overrides

• Covers Claude and fallback debug-log names, custom and empty names, registry-resolved transcript handlers, and context-bridge capability detection.

internal/runtime/capabilities_test.go

sandbox_hooks_bootstrap_test.goExercise shared hook installation and failure handling +166/-0

Exercise shared hook installation and failure handling

• Tests Claude bootstrap integration, custom installation directories, omitted hook extensions, Tirith environment behavior, and upload, exec, and chmod failures using fake OpenShell commands.

internal/runtime/sandbox_hooks_bootstrap_test.go

hooks_test.goValidate hook plan ordering and consistency +93/-15

Validate hook plan ordering and consistency

• Migrates existing tests to SandboxHookConfig and adds coverage for default plan order, all-disabled behavior, HookPlan-to-HookFiles consistency, and Tirith defaults.

internal/security/hooks_test.go

Documentation (4) +119 / -8
0090-runtime-neutral-sandbox-hooks-contract.mdRecord the runtime-neutral sandbox hook architecture +85/-0

Record the runtime-neutral sandbox hook architecture

• Documents the decision to treat sandbox hooks as a shared runtime contract. Defines adapter responsibilities, optional runner capabilities, security implications, and expected consequences for future runtimes.

docs/ADRs/0090-runtime-neutral-sandbox-hooks-contract.md

architecture.mdReference runtime-neutral bootstrap capabilities +1/-1

Reference runtime-neutral bootstrap capabilities

• Updates the architecture overview to reference SandboxHooksBootstrap, ADR 0090, and optional runtime capabilities instead of Claude-specific bootstrap behavior.

docs/architecture.md

cli-internals.mdGeneralize the debug flag description +1/-1

Generalize the debug flag description

• Changes the CLI command tree description from Claude Code debug logging to agent runtime debug logging.

docs/guides/dev/cli-internals.md

runtimes.mdDefine the cross-runtime hooks and capability contract +32/-6

Define the cross-runtime hooks and capability contract

• Documents hook files, ordered wiring, wire protocol, environment variables, and runtime adapter obligations. Adds runtime capability and config-key matrices, including runtime-owned skill destinations, debug artifacts, and context bridging.

docs/runtimes.md

@github-actions

github-actions Bot commented Aug 19, 2026

Copy link
Copy Markdown

Site preview

Preview: https://44bf2a18-site.fullsend-ai.workers.dev

Commit: 6cf88b156836d88cac4fccb755ca24eb2fe44abe

@fullsend-ai-review

fullsend-ai-review Bot commented Aug 19, 2026

Copy link
Copy Markdown

🤖 Finished Review · ✅ Success · Started 12:44 AM UTC · Completed 12:59 AM UTC

Commit: feaa4ec · View workflow run →

@codecov

codecov Bot commented Aug 19, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 93.22034% with 8 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
internal/runtime/sandbox_hooks_bootstrap.go 76.47% 5 Missing and 3 partials ⚠️

📢 Thoughts on this report? Let us know!

@qodo-code-review

qodo-code-review Bot commented Aug 19, 2026

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (1) 📘 Rule violations (0) 📜 Skill insights (2)

Grey Divider


Action required

1. Decision bundles runner contracts 📜 Skill insight ⚙ Maintainability
Description
The ADR combines the sandbox hook contract with a separate decision about runner capability
interfaces and harness-key translation. These independent decisions must be split so the ADR records
exactly one architectural decision.
Code

docs/ADRs/0090-runtime-neutral-sandbox-hooks-contract.md[R68-72]

+- Runtime-specific runner behaviour is expressed through small optional
+  capability interfaces (`DebugLogNamer`, `ContextBridger`) that the runner
+  type-asserts, never through `Name()` comparisons or Claude constants. Each
+  runtime owns the translation of harness keys (`model`, `effort`, `plugins`,
+  agent `tools:`) and documents it in the runtime-specific config-key matrix.
Relevance

●● Moderate

ADR structure feedback is mixed; splitting these related runtime decisions is subjective without a
close precedent.

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
ADR 0090 first declares sandbox hooks a runtime-neutral contract, then independently requires
optional runner interfaces and runtime-owned harness-key translation. Those are distinct
architectural decisions in one Decision section.

docs/ADRs/0090-runtime-neutral-sandbox-hooks-contract.md[49-72]
Skill: writing-adrs

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
ADR 0090 records both the sandbox hook contract and separate runner capability/configuration decisions.

## Issue Context
PR Compliance 1062089 requires each ADR to record exactly one decision. Keep ADR 0090 focused on the sandbox hook contract and move the `DebugLogNamer`, `ContextBridger`, and harness-key translation decision into a separate ADR.

## Fix Focus Areas
- docs/ADRs/0090-runtime-neutral-sandbox-hooks-contract.md[49-72]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended

2. Contract lacks normative specification ✗ Dismissed 📜 Skill insight ⚙ Maintainability
Description
The ADR defines field-level hook wiring and points the wire protocol to docs/runtimes.md rather
than a versioned specification under docs/normative/. This leaves a detailed compatibility
contract in mutable narrative documentation.
Code

docs/ADRs/0090-runtime-neutral-sandbox-hooks-contract.md[R54-60]

+- The hook scripts (`internal/security/hooks/*.py`) are the portable
+  artifact. `security.HookFiles` returns them and `security.HookPlan` returns
+  their wiring — ordered `HookGroup{Phase, Tools, Scripts}` entries in
+  PreToolUse/PostToolUse phases with Claude tool names as the canonical
+  vocabulary. Claude's `GenerateClaudeSettings` is rendered from `HookPlan`
+  so the two cannot diverge. The stdin/stdout/exit-code wire protocol is
+  documented in [runtimes.md](../runtimes.md#sandbox-hook-contract).
Relevance

●●● Strong

Recent precedent accepts moving field- and byte-level compatibility contracts into versioned
normative specifications.

PR-#5961

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
ADR 0090 exposes HookGroup{Phase, Tools, Scripts} and canonical phase/tool values, while
docs/runtimes.md specifies JSON fields, stdout responses, exit codes, ordering, and size limits.
Rule 1525847 requires such field- and byte-level contracts to live under
docs/normative/<topic>/v<major>/.

docs/ADRs/0090-runtime-neutral-sandbox-hooks-contract.md[54-60]
docs/runtimes.md[49-56]
Skill: writing-adrs

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The runtime hook contract is defined through inline fields and a detailed protocol in `docs/runtimes.md` instead of a versioned normative specification.

## Issue Context
The contract includes hook phases, tool matching, script ordering, JSON input/output fields, exit behavior, and compatibility requirements. Create a versioned document under `docs/normative/` and make the ADR and runtime guide link to it.

## Fix Focus Areas
- docs/ADRs/0090-runtime-neutral-sandbox-hooks-contract.md[54-60]
- docs/runtimes.md[49-56]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


3. Runtime debug name ignored 🐞 Bug ≡ Correctness
Description
The runner passes only the transcript handler to DebugLogNameFor, despite DebugLogNamer
explicitly allowing implementation by either the runtime or transcript handler. A backend whose
runtime alone provides the capability silently writes agent-debug.log instead of its requested
artifact name.
Code

internal/cli/run.go[1604]

+			debugLogName := agentruntime.DebugLogNameFor(tx)
Relevance

●●● Strong

The runner omits one explicitly supported capability provider; similar runtime event and metrics
gaps were accepted as correctness fixes.

PR-#3186
PR-#6147

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
Backend keeps Runtime and Transcripts as separate values, and the new interface documentation
says either may implement DebugLogNamer. However, DebugLogNameFor checks only the single
supplied value, while the runner supplies only tx; registry entries currently reuse one runtime
object as both components, explaining why the existing Claude test passes without covering the
split-backend case.

internal/cli/run.go[1602-1606]
internal/runtime/runtime.go[80-112]
internal/runtime/registry.go[10-22]
internal/runtime/capabilities_test.go[16-28]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The runner resolves the debug-log artifact name from only `backend.Transcripts`, so a `DebugLogNamer` implemented solely by `backend.Runtime` is ignored. Update capability resolution to inspect both backend components while retaining the default for implementations that provide no non-empty name.

## Issue Context
`Backend` stores the runtime and transcript handler separately, while the capability contract permits either to implement `DebugLogNamer`. Current registry entries use the same concrete object for both fields, which masks the issue.

## Fix Focus Areas
- internal/cli/run.go[1602-1606]
- internal/runtime/runtime.go[92-112]
- internal/runtime/capabilities_test.go[16-28]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


4. Resolved question remains open 📜 Skill insight ⚙ Maintainability
Description
The updated architecture section now specifies the harness/runtime bootstrap interface but leaves
the corresponding question listed as open. The question should be marked resolved or partially
resolved with an ADR 0090 link.
Code

docs/architecture.md[181]

+- The `fullsend run` runner delegates in-sandbox agent execution to a `runtime.Runtime` interface; production orgs default to Claude Code. Runtime selection is configured in `defaults.runtime` on the org `config.yaml` and resolved via `runtime.ResolveFromConfig()`. A **dummy** runtime executes scripted operations in the real OpenShell sandbox for behaviour tests (inference removed). Bootstrap uses a portable `BootstrapInput` interface with optional extensions such as `SandboxHooksBootstrap` for the runtime-neutral sandbox tool hooks ([ADR 0090](ADRs/0090-runtime-neutral-sandbox-hooks-contract.md)); runtimes declare further capabilities through small optional interfaces (`DebugLogNamer`, `ContextBridger`) rather than `Name()` checks in the runner. Transcript and debug artifact handling use a separate `TranscriptHandler` interface. See [runtimes.md](runtimes.md) for the per-runtime security feature matrix required when adding a new backend.
Relevance

●●● Strong

Recent architecture reviews accepted correcting stale documentation and linking implementation
decisions to ADRs.

PR-#5945
PR-#5328

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The changed implementation decision identifies BootstrapInput, SandboxHooksBootstrap,
DebugLogNamer, and ContextBridger as the harness/runtime interfaces, but line 192 still asks
what the interface between the harness and runtime is. Rule 1062101 requires questions resolved or
partially resolved by an accepted ADR to be annotated accordingly.

docs/architecture.md[179-192]
docs/ADRs/0090-runtime-neutral-sandbox-hooks-contract.md[61-72]
Skill: writing-adrs

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The accepted ADR and updated architecture bullet define part of the harness/runtime interface, but the architecture document still asks what that interface is.

## Issue Context
Annotate the open question as partially resolved by `BootstrapInput`, `SandboxHooksBootstrap`, and the capability interfaces in ADR 0090, while preserving any genuinely unresolved aspects.

## Fix Focus Areas
- docs/architecture.md[181-192]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Context sources
✅ Compliance rules (platform): 56 rules

Grey Divider

Tip of the day
💡 Did you know, you can tweak Display preferences with a live preview to see your comment before it ships

More tips ↗ | Customize Qodo ↗ | Qodo docs ↗

Grey Divider

Qodo Logo

Comment thread docs/ADRs/0090-runtime-neutral-sandbox-hooks-contract.md Outdated
Comment thread docs/ADRs/0090-runtime-neutral-sandbox-hooks-contract.md
Comment thread docs/architecture.md
Comment thread internal/cli/run.go Outdated
@fullsend-ai-review

fullsend-ai-review Bot commented Aug 19, 2026

Copy link
Copy Markdown

Review

Findings

Low

  • [naming-consistency] internal/runtime/runtime.goDebugLogNameFor accepts ...any as a variadic parameter, which is an outlier in this codebase where interfaces like Runtime, TranscriptHandler, and BootstrapInput use concrete types. The callers in run.go pass (rt, tx) — both known interface types — so a narrower signature like DebugLogNameFor(rt Runtime, tx TranscriptHandler) with internal type assertions would be more idiomatic. The current signature works correctly and is well-tested; this is a style observation.
Previous run

Review

Findings

Low

  • [documentation-coherence] docs/runtimes.md — The runtime-specific config-key support matrix adds an OpenCode column that shows only dashes (not implemented). While this accurately reflects the stub runtime's current state (Track opencode as a supported agent runtime #1260), adding a brief note or issue reference would help future implementers understand what needs to be filled in when the OpenCode runtime is implemented.

Labels: PR refactors sandbox hook infrastructure in the runner component and touches security hook wiring code

fullsend-ai-review[bot]

This comment was marked as outdated.

@fullsend-ai-review fullsend-ai-review Bot added ready-for-merge All reviewers approved — ready to merge component/runner Agent runner behavior and lifecycle security Security threat model and related concerns go Pull requests that update go code labels Aug 19, 2026
@waynesun09
waynesun09 force-pushed the runtime-neutral-sandbox-hooks branch from feaa4ec to d448661 Compare August 19, 2026 01:19
@fullsend-ai-review

fullsend-ai-review Bot commented Aug 19, 2026

Copy link
Copy Markdown

🤖 Review · ⚠️ Cancelled · Started 1:21 AM UTC · Ended 1:32 AM UTC

Commit: d448661 · View workflow run →

…ntime-neutral

Every new agent runtime (opencode #1260, cursor #6319, pi) tripped over the
same Claude-specific seams: sandbox tool hooks were only installable through
a ClaudeHooksBootstrap type assertion with the wiring living solely in the
generated Claude settings.json, and the runner branched on
rt.Name() == "claude" and hardcoded Claude artifact paths.

- security: ClaudeSandboxHooks -> SandboxHookConfig; add HookPlan() as the
  single runtime-neutral wiring source (phase, tools, ordered scripts) and
  render GenerateClaudeSettings from it so they cannot diverge.
- runtime: ClaudeHooksBootstrap -> SandboxHooksBootstrap; factor
  installHookScripts (any directory) and appendHookEnv out of
  installClaudeHooks; add optional capability interfaces DebugLogNamer and
  ContextBridger with helpers, implemented by ClaudeRuntime.
- runner: resolve the runtime before starting the fetch service so the skill
  destination comes from rt.ConfigDir(); gate the CLAUDE.md bridge on
  ContextBridger; name the debug-log artifact via DebugLogNameFor;
  runtime-neutral --debug help text.
- docs: sandbox hook contract (files, wiring plan, wire protocol),
  runtime-specific config-key support matrix, updated interface table in
  docs/runtimes.md; ADR 0090; architecture.md pointer.

No behaviour change for the Claude runtime: same scripts, same settings.json,
same claude-debug.log and CLAUDE.md bridge (golden tests unchanged).

Docs record two pre-existing Claude Code gaps found while verifying the contract
(settings.json not loaded from /sandbox/workspace/.claude — #6358; PostToolUse
tool_response vs tool_result + parallel hooks — #6357) instead of a blanket ✓.

Refs #6354

Assisted-by: Claude (fix), Grok (review)
Signed-off-by: Wayne Sun <gsun@redhat.com>
@fullsend-ai-review

fullsend-ai-review Bot commented Aug 19, 2026

Copy link
Copy Markdown

🤖 Finished Review · ✅ Success · Started 1:33 AM UTC · Completed 1:48 AM UTC

Commit: 6cf88b1 · View workflow run →

Comment thread internal/runtime/runtime.go

@ralphbean ralphbean left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

awesome!

@maruiz93

Copy link
Copy Markdown
Contributor

The contract and HookPlan abstraction look solid. I dug into the OpenCode repo to verify the assumed wiring mechanisms, and wanted to share what I found for future implementers:

OpenCode V1 has tool.execute.before/tool.execute.after plugin hooks that cover most of the contract:

  • Output rewriting works — mutable output object, changes flow through
  • Hooks run sequentially (ordered for...of loop), so the suppress→unicode→redact chain is preserved
  • Hooks receive input.tool for filtering (though no built-in pattern matching — self-filtering required)
  • Hooks fail closed on error (throw prevents tool execution)

Two gaps worth noting:

  1. No blocking channeltool.execute.before can mutate args but has no { decision: "block" } return path. The only way to prevent execution is to throw, which surfaces as a tool error to the LLM rather than a structured denial. All PreToolUse hooks (Tirith, SSRF, canary, allowlist) rely on blocking. Functionally it works (execution is prevented), but the agent sees a generic error instead of a reason.
  2. Tool name casing — OpenCode uses lowercase (bash, read) vs Claude's capitalized names. Already tracked in Tool allowlist: diagnose un-normalized (non-Claude-vocabulary) tool names from runtime adapters #608.

OpenCode V2 (packages/core/) currently has no tool execution hooks. The V2 plugin PluginContext has no tool domain. Their PLAN.md describes ctx.tool.hook("execute.before", ...) as a planned feature (migration step 6), but it's not implemented. The specs/v2/tools.md line "no tool-specific plugin hook is needed" refers to tool registration, not execution interception. Tool replacement via Tools.Service.register() can't wrap existing tools because the executor is opaque.

This matters for the security model: the hooks are defense-in-depth that should be enforced by the runtime, not opted into by the agent. A runtime without enforceable tool interception can't meet the contract. Consider making this explicit in ADR 0090 or runtimes.md — not just "a runtime that cannot MUST record the absence" but that a runtime without enforceable interception should not run in production until it does.

None of this blocks this PR — the contract is correct regardless of wiring mechanism, and V1 is a viable starting point. Just flagging for the OpenCode integration work.

Merged via the queue into main with commit 07c6568 Aug 21, 2026
78 checks passed
@waynesun09
waynesun09 deleted the runtime-neutral-sandbox-hooks branch August 21, 2026 20:31
@fullsend-ai-retro

fullsend-ai-retro Bot commented Aug 21, 2026

Copy link
Copy Markdown

🤖 Finished Retro · ✅ Success · Started 8:33 PM UTC · Completed 8:54 PM UTC

Commit: 6cf88b1 · View workflow run →

waynesun09 added a commit that referenced this pull request Aug 21, 2026
Sandbox tool hooks (Tirith, SSRF, canary, secret redaction, unicode
normalization, context suppression, tool allowlist) were silently never
loaded: the hook wiring was written to /sandbox/workspace/.claude/settings.json
but Claude Code, started from /sandbox/workspace/<repo>, only reads project
settings from <cwd>/.claude/.

- Move hook scripts to {CLAUDE_CONFIG_DIR}/hooks/ and the wiring to
  {CLAUDE_CONFIG_DIR}/hooks.json — runner-owned, outside the agent-writable
  workspace tree (security.SandboxHooksDir / SandboxHooksSettings).
- buildRunCommand appends --settings {hooks.json} when RunParams.
  HooksSettingsPath is set; run.go sets it when harness security is enabled.
  --settings merges with (and takes precedence over) project/local settings,
  so plugin state in {CLAUDE_CONFIG_DIR}/settings.json is unaffected.
- Rename GenerateClaudeSettings -> GenerateHooksConfig (still rendered from
  HookPlan) and claudeSettings -> hooksConfig to match the new artifact.
- Add e2e behaviour scenario hooks-loaded.feature: SSRF PreToolUse hook
  blocks a metadata-endpoint fetch end-to-end, guarding the "wired but
  silently not loaded" regression class.
- Golden tests for --settings presence/absence/quoting; docs (runtimes.md
  layout + matrix + contract, cli-internals.md, architecture.md) updated;
  ADR 0090 consequence annotated as fixed.

Rebased over #6355 (runtime-neutral sandbox hooks contract) — reapplied on
top of SandboxHookConfig/installHookScripts/HookPlan. Original implementation
by the fullsend code agent in this PR's earlier history.

Verified locally with a branch-built CLI (fullsend run triage --debug):
hook_registered telemetry + 108 hook executions in claude-debug.log, versus
"Registered 0 hooks" on main before this fix.

Closes #6358

Assisted-by: Claude
Signed-off-by: Wayne Sun <gsun@redhat.com>
waynesun09 added a commit that referenced this pull request Aug 21, 2026
Sandbox tool hooks (Tirith, SSRF, canary, secret redaction, unicode
normalization, context suppression, tool allowlist) were silently never
loaded: the hook wiring was written to /sandbox/workspace/.claude/settings.json
but Claude Code, started from /sandbox/workspace/<repo>, only reads project
settings from <cwd>/.claude/.

- Move hook scripts to {CLAUDE_CONFIG_DIR}/hooks/ and the wiring to
  {CLAUDE_CONFIG_DIR}/hooks.json — runner-owned, outside the agent-writable
  workspace tree (security.SandboxHooksDir / SandboxHooksSettings).
- buildRunCommand appends --settings {hooks.json} when RunParams.
  HooksSettingsPath is set; run.go sets it when harness security is enabled.
  --settings merges with (and takes precedence over) project/local settings,
  so plugin state in {CLAUDE_CONFIG_DIR}/settings.json is unaffected.
- Rename GenerateClaudeSettings -> GenerateHooksConfig (still rendered from
  HookPlan) and claudeSettings -> hooksConfig to match the new artifact.
- Add e2e behaviour scenario hooks-loaded.feature: SSRF PreToolUse hook
  blocks a metadata-endpoint fetch end-to-end, guarding the "wired but
  silently not loaded" regression class.
- Golden tests for --settings presence/absence/quoting; docs (runtimes.md
  layout + matrix + contract, cli-internals.md, architecture.md) updated;
  ADR 0090 consequence annotated as fixed.

Rebased over #6355 (runtime-neutral sandbox hooks contract) — reapplied on
top of SandboxHookConfig/installHookScripts/HookPlan. Original implementation
by the fullsend code agent in this PR's earlier history.

Verified locally with a branch-built CLI (fullsend run triage --debug):
hook_registered telemetry + 108 hook executions in claude-debug.log, versus
"Registered 0 hooks" on main before this fix.

Closes #6358

Assisted-by: Claude
Signed-off-by: Wayne Sun <gsun@redhat.com>
waynesun09 added a commit that referenced this pull request Aug 21, 2026
Sandbox tool hooks (Tirith, SSRF, canary, secret redaction, unicode
normalization, context suppression, tool allowlist) were silently never
loaded: the hook wiring was written to /sandbox/workspace/.claude/settings.json
but Claude Code, started from /sandbox/workspace/<repo>, only reads project
settings from <cwd>/.claude/.

- Move hook scripts to {CLAUDE_CONFIG_DIR}/hooks/ and the wiring to
  {CLAUDE_CONFIG_DIR}/hooks.json — runner-owned, outside the agent-writable
  workspace tree (security.SandboxHooksDir / SandboxHooksSettings).
- buildRunCommand appends --settings {hooks.json} when RunParams.
  HooksSettingsPath is set; run.go sets it when harness security is enabled.
  --settings merges with (and takes precedence over) project/local settings,
  so plugin state in {CLAUDE_CONFIG_DIR}/settings.json is unaffected.
- Rename GenerateClaudeSettings -> GenerateHooksConfig (still rendered from
  HookPlan) and claudeSettings -> hooksConfig to match the new artifact.
- Add e2e behaviour scenario hooks-loaded.feature: SSRF PreToolUse hook
  blocks a metadata-endpoint fetch end-to-end, guarding the "wired but
  silently not loaded" regression class.
- Golden tests for --settings presence/absence/quoting; docs (runtimes.md
  layout + matrix + contract, cli-internals.md, architecture.md) updated;
  ADR 0090 consequence annotated as fixed.

Rebased over #6355 (runtime-neutral sandbox hooks contract) — reapplied on
top of SandboxHookConfig/installHookScripts/HookPlan. Original implementation
by the fullsend code agent in this PR's earlier history.

Verified locally with a branch-built CLI (fullsend run triage --debug):
hook_registered telemetry + 108 hook executions in claude-debug.log, versus
"Registered 0 hooks" on main before this fix.

Closes #6358

Assisted-by: Claude
Signed-off-by: Wayne Sun <gsun@redhat.com>
@fullsend-ai-retro

Copy link
Copy Markdown

Retro: PR #6355 — runtime-neutral sandbox hooks

Workflow shape: Issue #6354 → triage (run 32201426478) → human-authored PR #6355 → two review runs (32202300496, 32205257411) → two human approvals → merge.

What went well:

Review quality gap — evidence for existing issues:

  • The fullsend review agent surfaced only LOW-severity findings (naming-consistency, documentation-coherence), while qodo found a real bug: DebugLogNameFor was called with only the transcript handler, silently ignoring any DebugLogNamer implementation on the runtime. Qodo also caught ADR structural issues (bundling multiple decisions) and a stale open-question annotation — all at MEDIUM or HIGH severity. This is additional evidence for Review agent approved initial commit with zero findings despite multiple correctness bugs #4305 (review agent approves with zero correctness findings despite detectable bugs) and Review agent correctness sub-agent should construct concrete impact examples before assigning severity to edge-case findings #5264 (correctness sub-agent should construct concrete impact examples before assigning severity). The correctness dimension ran on both review passes but did not surface these findings.
  • The review agent approved the PR and applied ready-for-merge on both runs. While the bug was ultimately fixed by the author in response to qodo's finding, the fullsend review agent's independent approval without catching it is a review quality gap.

Post-merge functional test failure is a flaky infrastructure issue — the same SHA passed in the merge queue but failed on the main push, and the same 4-second crash pattern reproduces on unrelated branches. See proposal below.

Proposals filed

Proposals skipped (target repo not allowed)

File manually or update create_issues.allow_targets in config.yaml:

  • Correctness sub-agent should verify callers pass all relevant sources to variadic interface-dispatch functions (fullsend-ai/agents)

waynesun09 added a commit that referenced this pull request Aug 21, 2026
Sandbox tool hooks (Tirith, SSRF, canary, secret redaction, unicode
normalization, context suppression, tool allowlist) were silently never
loaded: the hook wiring was written to /sandbox/workspace/.claude/settings.json
but Claude Code, started from /sandbox/workspace/<repo>, only reads project
settings from <cwd>/.claude/.

- Move hook scripts to {CLAUDE_CONFIG_DIR}/hooks/ and the wiring to
  {CLAUDE_CONFIG_DIR}/hooks.json — runner-owned, outside the agent-writable
  workspace tree (security.SandboxHooksDir / SandboxHooksSettings).
- buildRunCommand appends --settings {hooks.json} when RunParams.
  HooksSettingsPath is set; run.go sets it when harness security is enabled.
  --settings merges with (and takes precedence over) project/local settings,
  so plugin state in {CLAUDE_CONFIG_DIR}/settings.json is unaffected.
- Rename GenerateClaudeSettings -> GenerateHooksConfig (still rendered from
  HookPlan) and claudeSettings -> hooksConfig to match the new artifact.
- Add e2e behaviour scenario hooks-loaded.feature: SSRF PreToolUse hook
  blocks a metadata-endpoint fetch end-to-end, guarding the "wired but
  silently not loaded" regression class.
- Golden tests for --settings presence/absence/quoting; docs (runtimes.md
  layout + matrix + contract, cli-internals.md, architecture.md) updated;
  ADR 0090 consequence annotated as fixed.

Rebased over #6355 (runtime-neutral sandbox hooks contract) — reapplied on
top of SandboxHookConfig/installHookScripts/HookPlan. Original implementation
by the fullsend code agent in this PR's earlier history.

Verified locally with a branch-built CLI (fullsend run triage --debug):
hook_registered telemetry + 108 hook executions in claude-debug.log, versus
"Registered 0 hooks" on main before this fix.

Closes #6358

Assisted-by: Claude
Signed-off-by: Wayne Sun <gsun@redhat.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

component/runner Agent runner behavior and lifecycle go Pull requests that update go code ready-for-merge All reviewers approved — ready to merge security Security threat model and related concerns

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Runtime-neutral foundation: sandbox hooks contract, runner de-Claude, per-runtime config-key matrix

3 participants