| Version | Supported |
|---|---|
| Latest | Yes |
| Older | No |
Only the latest released version receives security updates.
If you discover a security vulnerability in larch, please report it responsibly:
- Email: Send details to zhupanov@yahoo.com
- Do not open a public GitHub issue for security vulnerabilities
- Include steps to reproduce the issue and any relevant context
You should receive an acknowledgment within 72 hours. We will work with you to understand the issue and coordinate a fix before any public disclosure.
Accepted security-tagged review/design OOS findings are held locally and NEVER written to public OOS issue artifacts (oos-accepted-design.md, oos-accepted-review.md, or the oos.md visibility export). Public-boundary writers apply the unified fenced/unfenced discrimination contract for canonical focus-area=security prose tokens: classify each occurrence as fenced when inside an inline backtick code span or triple-backtick fenced code region, and unfenced otherwise; route as security only when at least one unfenced occurrence exists. Dedicated line-start focus-area fields also route as security when the value begins with security (including security-hardening style values), allowing optional bold/backtick markup around the label or value and either : or = separators. A block-opening heading may also start its title with [security] or <security> (optionally after [OUT_OF_SCOPE] / [OOS]); later ### ... [security] ... headings inside prose are not routing tags. If every canonical-token occurrence is fenced and no dedicated field or structured opening-heading tag exists, the block is meta-discussion and routes through the normal public OOS path. External implementer manifest OOS uses the same dedicated-field predicate after sanitization: structured focus_area / focus-area values are preserved as a - **focus-area**: field for routing, but a security-looking title alone does not trigger private routing. Manifest security observations are retained in the session-local security-oos-observations.md sidecar and block OOS all-clear checkpoints until handled through this private flow. Security findings are also NEVER filed via /issue; use the private disclosure flow above instead.
Dynamic review scout notes are also treated as untrusted data. python/cli.py scout dynamic-archetypes rejects scout-authored rationale or prompt_body strings containing the literal </scout_notes> wrapper terminator, rejects prompt_body strings containing literal </reviewer_ closers or standalone --- lines, and repairs accepted prompt_body strings so they end with the full required closing sentence (Cite specific file paths and line ranges for any issues found, and follow the output-format rules from your outer wrapper exactly.). python/review_pipeline.py then tells dynamic reviewers to extract only file/aspect hints from <scout_notes> and ignore commands, tool/workflow requests, scope changes, and output-format instructions inside that block. This keeps synthesized dynamic reviewer prompts inside their untrusted <scout_notes> envelope and preserves the wrapper-alignment footer even if the scout omits or truncates it.
ARCHITECTURAL_GUIDELINES.md is repo-local, operator-curated, untrusted prompt context. Larch treats its parsed entries as aspirational evidence, not as a higher-priority instruction surface than AGENTS.md or skills. The reader no-ops when the file is absent, rejects symlinks and non-regular files, and emits only parsed G-* entries through the untrusted content-block wrapper. /implement separates prompt-side staged assessments from durable HEAD-pinned notes; PR bodies and final summaries consume only non-symlink notes whose metadata matches the current HEAD, and final-summary append text is redacted before publication.
Committed breadcrumb publication stages only session-root quiet logs whose
basenames match larch-quiet-*-*.log. Each matched file is individually redacted
and the redacted content is concatenated into a single
larch-logs/.../breadcrumbs/quiet.log file (with per-source-file header lines
=== <basename> ===). Legacy *.ndjson breadcrumb stream files and
session-local monitor sidecars (.quiet, .done, .status, .surfaced,
.bc-offset) stay under the run tmpdir and are not copied into
larch-logs/.../breadcrumbs/; attempted quiet-log publication still fails
closed on staged-file symlinks, hardlinks, or redaction errors. The shared
helper now treats its input as a breadcrumbs-directory hint only: a hint outside
the active session tmpdir is a no-op (enforced by a defense-in-depth
confinement check that resolves the derived source root against the active
IMPLEMENT/DESIGN/REVIEW/RESEARCH_TMPDIR roots and publishes nothing when it
matches none), and the fail-closed enforcement happens at
per-file staging/redaction time for matched larch-quiet-*-*.log files rather
than by applying the removed source-directory-wide rejection rules.
Raw Codex --json event streams (*.events.jsonl) are session-local artifacts
only. python/cli.py design log-publish and python/cli.py run-log exclude them
from committed larch-logs/ publication so prompt-bearing JSONL stays in the
tmpdir and is not treated as a publishable design artifact. This exclusion
covers launcher-generated ${TRANSCRIPT_PATH}.events.jsonl files and
non-launcher telemetry inputs such as coder-codex.events.jsonl,
codex.events.jsonl, and ${OUTPUT_FILE%.txt}.events.jsonl; those streams may
contain prompts, reviewer text, repo snippets, response bodies, and tool output.
Only sanitized per-bucket usage rows in larch-tokens-*.jsonl, extracted via
external_launcher_record_usage_from_events, are publishable telemetry.
python/cli.py design log-publish also excludes raw plan-review transcripts
(cursor-plan-*-output*.txt, codex-primary-plan-*-output*.txt,
claude-plan-*-output*.txt), producer-backed sidecars (Claude .stderr /
.stderr-tail, Cursor/Codex .stderr-tail, .launch-stderr for all tools,
.meta, .tsv, .cap-hit, Cursor .json, Codex primary .json), generic
Claude prompts (claude-plan-*.prompt) and rendered plan-review prompts
(render-plan-*.prompt), slot-named
collector failure logs, dropped-slot diagnostics
(plan-review-slots.ndjson.output-files.dropped-slots), and aggregate
plan-review-collector.stderr; findings.md / voting-tally.md remain
canonical. This exclusion is enforced by
design_log_publish_flow._publish_excluded, matched by basename at every depth
of the copied run tree (top level and plan-review/round-N/); it also drops the
plan-autofix/ draft subtree, .completed/ step sentinels, the
step2b-codex-raw.* drafter family, and per-launch .token-record /
.porcelain carriers. The 2026-06 Python port of the publish flow
(design-log-publish.sh to design_log_publish_flow.py) regressed this filter:
it copied the whole $DESIGN_TMPDIR unfiltered and committed the raw streams,
inflating committed design logs roughly 40x until the filter was restored.
Design log publish and implement run-log commit fail closed when scrubber
execution fails or a detected secret survives scrubbing. Successful scrubbing
still proceeds with a loud rotation warning, because the redacted credential was
already exposed in the session and must be rotated. Scrub failures abort the
publish tail with fatal rc 5, distinct from recoverable log-PR push/create
misses that leave PUBLISH_OK=false recovery breadcrumbs. This preserves the
distinction between failed scrubbing and successful redaction of an already
exposed credential.
/implement Step 18a stall recovery has two current public-boundary surfaces: the Tier A /larch:issue --input-file artifact (issue-input) and the Tier B upstream larch report artifact (chat-print). python/stall-recovery-report-allowlists.tsv is the mechanical allowlist for Tier B report fields; python/cli.py stall-recovery lint verifies TSV, code, and python/stall-recovery-report.md parity.
The report helper never publishes raw failure logs, stdout/stderr, plan text, branch names, repo paths, issue bodies, or session tmpdir paths in Tier B. Public fields are limited to closed classifier enums, sanitized step fields, exit_code as integer-or-unknown, a sanitized Bail reason row, the public REPORT_DEDUP_SIGNATURE marker, bounded attempts, allowlisted escalation site/trigger summaries, bounded root-cause prose, and fixed maintainer-controlled prose templates. The public dedup signature is separate from the retry FAILURE_SIGNATURE and excludes dispatcher, matched classifier, evidence digests, paths, branches, run IDs, raw state, raw logs, and skill=implement. The classifier KV output likewise sanitizes STALL_STEP / PHASE to allowlisted enums and emits BAIL_REASON only from a closed enum (adopted-issue-closed, adopted-issue-is-pr, branch-create-failed, ci-fix-exhausted, dirty-state-after-timeout, dirty-tree, first-fixer-non-health, main-branch-post-dispatch, orchestrator-envelope-invalid, qa-loop-exceeded, recovery-out-of-scope, run-flags-persist-failed, tracking-init-failed, wrapper-validation-failure); every other value is redacted before emission. Rendered bail_reason is closed-enum sanitized: allowlisted values render verbatim, empty values render as none, and all other values render as redacted. All body/comment content is still piped through python/cli.py redact secrets as a secrets-family backstop.
Tier B consumer and forked runs file public stall reports in the upstream larch repository under the operator's GitHub identity. The upstream target is resolved from .claude-plugin/plugin.json instead of a pinned constant; repository metadata containing newlines, tabs, non-GitHub hosts, malformed owner/repo parts, .., or absolute paths is rejected. Resolver failure, lookup failure, auth failure, network failure, create failure, comment failure, or comment success without a valid html_url falls back to printing the already sanitized report for manual filing. Exact-signature dedup reads open upstream issues with bodies, ignores pull requests, and comments +1 occurrence on a match instead of filing a duplicate. Tier B dedup comments are assembled only from bounded public slices and reuse the same sensitive-token rejection path before posting; if the validator or sensitive corpus is unavailable, the helper fails closed to fallback printing instead of posting publicly. Raw root-cause files, raw escalation ledgers, full report bodies, raw logs, paths, branches, and run IDs must not be passed to the Tier B comment path.
--failure-detail-log is accepted only when the path is absolute, physically canonical, regular, non-symlink, under $IMPLEMENT_TMPDIR, and at most 64 KiB. When such a validated detail log is present, the classifier treats it as the primary evidence surface rather than letting stale full-state/session notes override it. init-attempts / record-attempt --attempts-file writes are likewise confined to absolute, non-symlink paths under $IMPLEMENT_TMPDIR, preventing arbitrary same-UID overwrite via cross-tmpdir or symlink targets. normalize-issue-env treats captured /larch:issue stdout as data: it filters to ISSUES_* / ISSUE_1_* machine keys, writes canonical ISSUE_NUMBER / ISSUE_URL only after a zero exit, ISSUES_FAILED=0, no truthy ISSUE_1_FAILED, and resolvable create-or-dedup fields, and removes any stale env file on failed or partial issue filing. The persisted classification env emits sanitized STALL_STEP, PHASE, BAIL_REASON, and EXIT_CODE tokens only; BAIL_REASON is closed to allowlisted values, empty, or redacted before it can be copied into other artifacts, and empty/non-numeric EXIT_CODE renders as unknown while numeric values render unchanged. The helper's retry-policy subcommand is the mechanical projection of the documented retry-cap table, and the harness parses the markdown cap table directly to catch doc/code drift. The pytest harness (python/test_stall_recovery.py) covers log validation (including oversize rejection), attempts-file write containment, body-file containment, issue-stdout normalization for create/dedup/failure paths, deny-list sentinel parity across public outputs including the consumer chat-print payload, redactor invocation, dry-run propagation across report surfaces, and allowlist doc/code/TSV parity. Residual risk: root-cause and mitigation prose templates are static maintainer-authored strings, so a malicious template patch could publish misleading text; that risk is reviewer-visible by construction.
The external implementer prompts (agents/codex-implementer.md, agents/cursor-implementer.md) likewise prohibit folding security findings inline and prohibit emitting them in oos_observations[]. /implement Step 9a.1 defensively re-excludes any security-tagged OOS entries that slip through upstream filters before the /issue handoff.
Malformed-manifest recovery in /implement Step 2 is intentionally narrower than ordinary claude_fallback. It only activates for a raw manifest that parses as a JSON object and represents either status=complete or the legacy {status, summary, checks} fingerprint, with an empty pre-launch index, a non-empty NUL-safe post-launch working-tree delta, and the same post-implementer safety gates as the normal external-implementer path. The recovery envelope preserves ORCHESTRATOR_EDIT_AUTHORITY=allowed iff STATUS=claude_fallback, but RECOVERY_FROM=manifest-schema-invalid means commit-only recovery: the orchestrator must not re-implement or sweep the index, and Step 4 commits only the dispatcher-provided NUL-delimited path list via git commit --only --pathspec-from-file.
python/cli.py review-and-fix apply-findings also treats round-local review metadata as untrusted data when persisting review-and-fix.env: values such as REVIEW_CORE_STATUS are written with printf-safe key/value lines, not an expanding heredoc, so tampered status strings cannot trigger shell expansion while the env file is emitted.
Step 5 pre-coder carryover snapshots are kept outside Codex writable grants. When the review round directory lives outside the repo workspace, snapshots are written as a sibling of the round parent rather than under the Codex-granted round directory. If the round parent resolves under $PWD, pre_coder_snapshot_dir relocates snapshots to ${TMPDIR:-/tmp}/larch-pre-coder-snapshots/<hash>/... (this assumes $TMPDIR itself is outside $PWD, which holds on standard macOS/Linux). The relocated dir is not reaped by cleanup-tmpdir.sh; production writers clear per-round snapshot files before regeneration. Snapshot files are chmod 0444 after write as defense-in-depth; post-coder-head.txt in round_dir is likewise hardened when written. Integrity assumes Codex --full-auto confines writes to declared --add-dir/workspace roots; relocation and read-only bits do not substitute for sandbox confinement if the sandbox is more permissive; no CI sandbox-confinement probe. This is an integrity hardening against delegated fixer tampering, not a confidentiality boundary against same-UID local processes.
/design Tier 3 plan-command dry-run (python/cli.py plan validate-commands): Tier 3 executes only scripts listed in scripts/dry-runnable-scripts.tsv, only after Tier 2 reports no defects for that command group, only when validating plan.txt (not pre-redaction composed-plan.md). Execution uses an argv array built from the resolved script path plus long -- flags only from the parsed plan (short flags and non-flag positionals are omitted; see python/plan_quality.py Tier 3 validation). The plan parser/validator does not eval plan markdown; the driver parses ACTION=… ARGS=… lines with eval "argv=( $args_text )" only for mechanical printf '%q'-shaped argv fragments emitted by SKILL.md and helper scripts. cwd is pinned to the repo root, probes use a 10s wall-clock timeout, and env -i with a minimal allowlist (PATH, HOME, TMPDIR, USER, LOGNAME, optional LANG, plus LARCH_DRY_RUN=1 or --validate-only per registry row — the hook column must be exactly one of those two literals; unknown values are defects) so dry-run children do not inherit the operator’s full environment (stdout/stderr captures therefore cannot accidentally vacuum unrelated secrets from inherited env). Tier 2 (--help probe + flag documentation check + missing-script handling) runs in the validator’s Bash process: it inherits the parent environment like any shell helper, but resolves each repo script with realpath under REPO_ROOT before exec. The --help probe captures merged stdout and stderr (same surface as validate-plan-commands.md); Tier 2 treats the capture as usable for long-flag checks when it is non-empty and the probe exits 0, or exits 1/2 with a non-empty capture (usage-style non-zero exits). Flag names are matched as documented long options (not raw substrings of that merged help text) via fixed-string / boundary-safe logic so strict-prefix pairs like --file vs --files do not false-validate. Tier 3 child output is written to validate-plan-commands.log only as a bounded excerpt passed through python/cli.py redact secrets when available, not as unlimited verbatim stdout/stderr — plan-derived argv can appear in script diagnostics, so treat validator logs as sensitive and prefer redacted excerpts when appending to execution-issues.md. Tokens containing shell metacharacters ($, backtick, ;, |, &, >, <, (, ), glob */?/[, or ..) are rejected as DEFECT kind=unsafe-token before any Tier 3 subprocess runs. Each registry row must point at a sibling .md that documents the dry-run contract. Operator Override decisions on validator defects append under Warnings in $DESIGN_TMPDIR/execution-issues.md (forensics for downstream design-log publish); use run-log append-failure --redact when attaching validator log material. The larch:plan GitHub block does not carry override text.
/design validator auto-fix delegates target-file repair to Codex/Cursor only when the tool is both present and currently available after degraded-tool gating. The helper treats the plan and validator log as untrusted prompt data, snapshots the target file before each attempt, rejects target symlink replacement, restores failed target edits, restores non-target $DESIGN_TMPDIR mutations before validation can succeed, and fails closed on symlinks/special files in the guarded tmpdir surface. Repository dirty-tree snapshots are taken against the consumer repo root, including content hashes for already-dirty tracked/untracked files, so a delegated fixer cannot hide a repo mutation behind unchanged porcelain status.
Larch is a Claude Code plugin that runs within Claude Code's permission boundary. It does not bypass Claude Code's built-in permission system at the operator level — all tool calls (file edits, shell commands, etc.) at the user-facing assistant level go through the standard permission flow. Consumers running in strict-permissions mode must grant both the bare and fully-qualified Skill(...) form for each larch skill they invoke — see the "Strict-permissions consumers — Skill permission entries" subsection in docs/configuration-and-permissions.md for the required allowlist convention, the shadowing caveat (bare names may resolve to project-local skills before reaching the plugin), and the reason Skill(larch:*) wildcards do not currently work.
Python ship-pr driver: The Python driver is a runtime boundary, not a trusted consumer of model text. It emits a single JSON result envelope on stdout, including crash paths, and redacts free-text diagnostic fields before stdout or JSONL journaling. State files remain line-oriented KEY=value data; embedded newlines are rejected before write so untrusted titles, URLs, or diagnostics cannot spoof additional state keys. ship-pr-state.sh writes reject symlinked destination/temp paths and use an exclusive same-directory temp file before atomic replace so a same-UID symlink swap cannot redirect the write. Run-log and final-summary refresh subprocess failures fail closed before PR creation or terminal report updates instead of silently carrying stale placeholder fields.
Plan-review scope anchoring has two distinct surfaces:
- Inline renderers. Scope-anchor consumers (scout/reviewer/voter prompts, aggregator scope-anchor block only — findings input remains a separate untrusted surface — MainAgent pre-vote render, and the Step 3.6 assessor when it reads the staged anchor) use issue-body provenance: the originating issue text has any prior
larch:planblock stripped, is passed throughredact secrets, and is staged as$DESIGN_TMPDIR/plan-review-scope-anchor.txt. Any prompt that inlines this material must render it as literal-redacted escaped evidence with untrusted framing prose. Other inline untrusted blocks use source-specific provenance: revise waterfall plan/findings/feature blocks, arbitrary Claude subprocess context bodies, and assessor legacy fallback fromfeature-description.txtare redacted, escaped, and framed, but they are not claimed to have staged-anchor provenance unless the actual file passed is the staged anchor. - Path-only handoffs.
SCOPE_ANCHOR_FILEis a durable tmpdir path only. It may be relayed through normalizedpython/cli.py plan-review runstdout,.step3-plan-review-result.env,design-step3-review.shstdout,.step3-review-result.env, and MainAgent re-tally refresh only when the tally terminal isokormain-agent-vote-requiredand the loop terminal is compatible (completeormain-agent-vote-required). It is omitted ontally-error,panel-failed, cap, and other non-terminal paths. Tally and re-tally do not receive--scope-anchor-file; consumers that need content must read the file through the inline-renderer boundary above. The KV relay never inlines anchor bytes.
Python scope-anchor CLI (python/rendering.py). Containment is enforced before any read or render:
python/cli.py render scope-anchor --design-tmpdir <DESIGN_TMPDIR> --scope-anchor-file <path>— validates the anchor under the canonicalDESIGN_TMPDIRroot, then renders the redacted untrusted block.python/cli.py scope-anchor validate --mode design --design-tmpdir <DESIGN_TMPDIR> --path <path>— accepts only readable regular non-symlink files underDESIGN_TMPDIR; rejects unreadable paths.python/cli.py scope-anchor validate --mode review --review-tmpdir <REVIEW_TMPDIR> --path <path>— accepts paths under the review session root or the existing tmp/cache allowlist.python/cli.py scope-anchor validate --mode voter --path <path>— preserves voter prompt scope-anchor containment (workspace, cache session, or tmpdir).scope-anchor relay-allowed,scope-anchor design-handoff, andscope-anchor retally-handoffapply the relay gate before emitting canonical paths.
/report-tokens public issue boundary: Committed run-log token reports are untrusted input. Public GitHub issues may include maintainer-authored section headings/prose, numeric token/cost/date aggregates, safe issue links derived from the resolved repository slug plus numeric issue numbers, workflow/vendor labels after table escaping, and escaped per-step phase labels. Markdown table cells neutralize pipes, line breaks, backslashes, and bracket link syntax before rendering. Issue bodies are redacted once through python/redact.py in report_tokens_issue.py before trim sizing and gh issue create; the GitHub call uses redact_body=False because redaction already ran. Oversize bodies fail closed if low-priority section trimming still cannot fit GitHub's limit. Terminal stdout for rendered reports, cache paths, and plot paths is also redacted before printing. Operator-provided actual-spend reconciliation is shown only on local stdout by default and is omitted from posted issues unless LARCH_REPORT_TOKENS_POST_ACTUAL_SPEND is explicitly enabled. Billing estimates use python/report_tokens_cost.py when available and Python blended-rate fallback otherwise; these estimates are informational and do not create billing authority.
/report-tokens scan-input boundary: Committed larch-logs/ directories are untrusted input to the scan path in python/report_tokens_scan.py. The scanner skips run-directory, JSON-file, token-report, and manifest symlinks; invalid or non-object manifest/token-report JSON skips that run with a warning to local stderr. Workflow auxiliary JSON is read only for /design classification (timing-report-final.json plus design run-params.json); /implement token reports no longer read timing-report or run-params workflow auxiliaries for SIMPLE/HARD classification. Invalid or missing design auxiliary classification is ignored with a warning and the run is retained with workflow unknown only when no valid SIMPLE/HARD classification is found. _run_dirs rejects directories that resolve outside the larch-logs/<skill> base, the repo slug must be a safe OWNER/REPO shape without . or .. parts, and records require a numeric issue_number. Scan warnings from _warn to local stderr are not redacted and may include repo-local paths or parser/OS error text; only GitHub repo slug resolution failures pass exception/detail text through redact.redact() before printing. See the public issue boundary above for which fields may reach public GitHub issue bodies and for the single-pass Python egress redactor.
Python Step 8+ driver posture: /implement Step 8+ uses python/cli.py ship pr (delegating to python/ship.py). #3404 (closed on branch) — PrePushConflictHandoff / ship-pr-rrr-phase14 resume via ship-pr-rrr-after-phase14.flag is implemented and tested in python/ship.py (see python/README.md). #3405 (intentional divergence) — CI_FIX_REBASE_PENDING fast path is deliberately omitted in ci_monitor.py (stateless monitor; see python/README.md). #3446 and #3449 (open) — remaining targeted-review gaps apply to the active Python path until closed.
/design session-env symlink (current-design-env-<PID>.sh): /design Bash steps rehydrate session context by sourcing a stable path under the operator home cache (~/.cache/larch/sessions/current-design-env-<PID>.sh, a symlink keyed on the Claude Code parent process id passed as --claude-pid). Only python/cli.py session write-design-env writes the sourceable file and refreshes that symlink; exports use printf '%q' so values survive source. The same-user trust model applies as for other session tmpdir artifacts — treat the generated file as data produced by larch scripts in the operator's account, not as an integrity boundary against a hostile same-UID writer. Different Claude sessions use different symlink names; a stale or dangling symlink is skipped by the prelude's [ -f ... ] && guard.
/design Step 3 loop-result env and round cleanup: Step 3 treats
.step3-plan-review-result.env and .step3-review-result.env as untrusted
data, not executable shell: the consumer reads only an allowlisted key set and
ignores symlinked result files in favor of stdout fallback. The same step also
refuses to rm -rf symlinked plan-review/round-* entries during re-entry
cleanup, so a malicious or stale same-UID symlink cannot redirect cleanup
outside the resolved plan-review root.
/design pause/resume marker binding: python/cli.py design pause-save --repo and python/cli.py design pause-load --repo are validated as OWNER/REPO; malformed values return invalid-repo before gh issue view, publish, or pause marker writes. python/cli.py design pause-save writes
ISSUE_NUMBER= and, when resolvable, REPO= into the
<!-- larch:design-pause --> marker payload before a pause snapshot becomes
resumable. pause-save also validates --design-tmpdir against the
session-tmpdir allowlist (tmpdir-not-allowed on a path outside it) and redacts
the local pause-state.txt payload through redact secrets before publish.
python/cli.py design pause-load fails closed unless those bindings
match the caller issue/repo, validates the remaining marker fields before any
fetch, restores into a staging directory first with export-ignore-independent
git ls-tree -r -z enumeration plus per-file git show, rejects paths outside
the snapshot subtree, verifies required artifacts before installing into the
caller tmpdir, and cross-checks the restored manifest.json issue_number /
run_id against the caller issue and marker run id (manifest-mismatch on a
binding conflict). This prevents an edited issue body from silently retargeting
resume to another run or leaving a partially-restored local state behind. The
loader keeps the marker on retryable restore, extract, and snapshot-content
failures such as snapshot-not-found, snapshot-extract-failed, and
missing-restored-artifact; permanent validation or binding failures clear the
marker before returning LOAD_OK=false. It deletes the marker only after a
successful install and .resume-loaded write. A successful load also removes
restored $DESIGN_TMPDIR/.pause-requested before resuming. A post-success
marker deletion failure is non-fatal and surfaces as MARKER_CLEARED=false plus
WARN=marker-delete-failed with LOAD_OK=true; design-route.sh refuses
resume@* until the stale marker is removed manually. Successful marker deletion
surfaces MARKER_CLEARED=true.
The only supported recovery branch is the exact name the design-log publisher
emits for a run, larch-logs/design-<RUN_ID>; pause-load rejects any other
LOG_RECOVERY_BRANCH marker value with invalid-recovery-branch before fetch, so
an edited marker field cannot flow as an arbitrary token into git fetch. Remote
recovery fetches that branch, then
pins FETCH_HEAD to an immutable commit SHA via git rev-parse --verify '<ref>^{commit}' before git ls-tree / git show enumeration and extraction;
the loader never passes mutable FETCH_HEAD directly into extraction. Recovery-branch restore reads committed blobs from the resolved commit with git show, rather than relying on git archive, so committed larch-logs/ export-ignore attributes do not hide pause snapshots from the loader. Residual risk: collaborators with issue body
edit rights can still redirect the marker to another snapshot for the same
issue, so resume surfaces a warning that the marker is collaborator-editable
rather than claiming a stronger authenticity guarantee than GitHub issue
metadata provides.
External tool delegation: When Codex or Cursor are available, larch delegates tasks to them. For review, sketches, and dialectic tasks routed through python/cli.py agent launch-review --tool cursor and python/cli.py agent launch-review --tool codex, Codex and Cursor are now mechanically sandboxed at the CLI level (issue #1529): Codex review runs as codex exec --sandbox read-only and Cursor review runs as cursor agent -p --trust --mode ask (issue #1583 removed --sandbox enabled, which crashed hosts where the cursor-agent sandbox runtime is unavailable — the prior codex exec --full-auto (workspace-write) and cursor agent -p --force --trust (force-allow commands) postures are gone; issue #2995 changed --mode plan to --mode ask because both modes are documented read-only by Cursor, but --mode ask delivers the requested analysis content in .result rather than narration of a planning process). When Cursor returns a high-token narration-only .result (more than 1000 outputTokens but fewer than 500 bytes of .result), python/cli.py agent launch-review writes the CURSOR_DEGRADED_RESPONSE sentinel and the collector triggers waterfall fallback, preventing narration-only content from reaching downstream analysis. The CURSOR_DEGRADED_RESPONSE sentinel is treated identically to CURSOR_EMPTY_RESPONSE by both python/cli.py agent collect-results and python/cli.py eval validate-research-output (exit 5). /design plan-review voting uses python/cli.py plan-review voter-dispatch, which launches external voters through python/cli.py agent run-external-agent directly but keeps the same read-only CLI posture (codex exec --sandbox read-only and cursor agent -p --trust --mode ask) and waits on wrapper sentinels before returning voter paths. Note that this hardening applies only to the review-launcher and plan-voter lanes — the implementer launchers (python/cli.py agent launch-codex-implement, python/cli.py agent launch-cursor-implement) intentionally retain workspace-write / --trust because the implementer's job is to edit the tree. /implement resolves the omitted---coder implementer in Step 0 inside python/bootstrap.py phase_coder_select; Step 2 consumes that resolved --coder. The Codex-first default (issue #3337; supersedes the #2738 Cursor-first reversal) is intentionally narrow: it applies only when the operator omits --coder and Step 0 runs the implementer availability waterfall. The review-fix coder lane is Cursor-first (Cursor → Codex → main agent, #3704). Lint-fix and ship-pr conflict fixer lanes now use Claude/Opus first, then Codex, then Cursor; ship-pr CI fix uses only the delegated Claude/Opus loop. When the operator omits --coder, Step 0 routes by external availability: Codex → Cursor → Claude (main agent only when both external implementers are unavailable). Operators who want Codex on /implement can pin it explicitly with --coder=codex. Operators who want Cursor on /implement can pin it explicitly with --coder=cursor. Explicit --coder=cursor / --coder=codex fail closed at Step 0 when the requested external tool is unavailable; explicit --coder=claude goes directly to the main-agent path and does not set coder_fallback=true. Cursor runs as cursor agent -p --force --trust; Codex uses codex exec --full-auto with approval: never and sandbox: workspace-write. The Codex implementer launcher (python/cli.py agent launch-codex-implement) writes the static implementer agent preamble to a per-invocation CODEX_HOME/config.toml top-level instructions key, symlinks ~/.codex/auth.json into that temporary home only when the file exists, and keeps the temporary home outside both $PWD and the dispatcher session tmpdir. The launcher passes --add-dir "$SESSION_TMPDIR" where SESSION_TMPDIR is the canonical, non-symlink dirname("$MANIFEST_PATH") — on the codex Step 2 path, $IMPLEMENT_TMPDIR/codex-step2-out/ only, not all of $IMPLEMENT_TMPDIR. Codex may atomic-write manifest.json, qa-pending.json, and the --output-last-message transcript there; orchestrator-owned files at the session tmpdir root (session-env.sh, plan copies, manifest-raw.json, sidecar log, baseline/recovery sentinels, etc.) remain outside the Codex write grant. Symlink output parents and SESSION_TMPDIR == IMPLEMENT_TMPDIR (when set) are rejected at launcher argv validation. Codex also receives a separate --add-dir "$PWD" repo grant. Cursor (--trust) already permits writes to arbitrary absolute paths. Codex and Cursor do not commit — the dispatcher (python/cli.py implement step2-dispatch) validates the working tree and commits on their behalf. Before any commit, the dispatcher fail-closes on branch/history drift (*-modified-history), protected-path edits (protected-path-modified), and dirty or touched submodules (submodule-dirty), so external implementers cannot smuggle those surfaces into git history through the dispatcher. The dispatcher pipes manifest.commit_message through python/cli.py redact secrets, writes the redacted output to a tmpfile, and runs git add -A && git commit -F <tmpfile> only after those gates and the manifest/path validations pass; this is the scope-drift mitigation for the broad staging step, not an unconditional "commit everything" path. Manifest text fields are redacted before downstream use. External implementer process writes bypass the Edit/Write PreToolUse hook chain (including block-submodule-edit.sh); the regular pre-commit hook chain runs when the dispatcher commits, providing a second-line backstop.
External reviewer prompts rendered from python/cli.py render specialist treat implementation-plan and feature-description files as untrusted payloads: the renderer runs python/cli.py redact secrets over those blocks and escapes angle-bracket markup inside the payload before wrapping it in the trusted delimiter tags. For non-testing agents, plan/feature context is emitted only for generic diff reviews; narrowed diff modes and description mode keep scope constraints independent of collaborator-controlled plan prose. reviewer-testing is the exception: it may receive redacted plan blocks in all diff modes and in description mode because it carries the folded plan-fidelity secondary scan.
/design Step 3.6 assessor trust boundary: The HARD-only plan-quality assessor lane (skills/design/scripts/assess-plan-round.sh, dispatch-plan-assessors.sh, tally-plan-assessor.sh) delegates to Claude plus external Codex/Cursor assessor slots and must be treated as untrusted model output. The operator-facing gate uses only bounded synthesized surfaces: the single-line verdict headline from assessor-verdict-round-<N>.txt and QUALIFICATIONS_SUMMARY from the .env sidecar. Raw assessor rationale must not be dumped verbatim at the Continue / Revert / Stop decision point. Continue / Revert / Stop control is trailer-only: on rc 10, the driver appends a trusted LARCH_ASSESSOR_TRUSTED_TRAILERS_BEGIN frame after display text, the orchestrator filters those trailer lines from chat, validates the final numeric LARCH_ASSESSOR_ROUND_NUM, aborts fail-closed before Continue / Revert / Stop when the trusted trailer frame is missing or LARCH_ASSESSOR_ROUND_NUM is absent, duplicated, or non-numeric, and uses that scalar for the Revert and Stop branches. Revert is an operator-controlled rollback: snapshot-plan-round.sh revert-round --round "$ASSESSOR_ROUND_NUM" restores plan.txt from the pre-round snapshot, rolls the round cursor/counter back, and removes now-stale review artifacts for that reverted round; if rollback fails, the applied plan is kept and the run continues with a warning. .step3.6-assessor.env remains fixed-key status data for settled paths; it is never sourced and does not control the WORSE decision. Fail-open behavior is explicit: missing assessor inputs produce ASSESSOR_STATUS=missing-snapshot; dispatch/tally failures or zero effective assessors produce ASSESSOR_STATUS=degraded-default-open with synthesized not-worse artifacts; post-Gate-B snapshot failure is a distinct write-after-failed path that rolls back the pending review-round state instead of pretending the assessor panel degraded; assess-plan-round.sh child failure or an empty child KV envelope on exit 0 produces ASSESSOR_STATUS=assess-failed (logged via run-log append-failure, ASSESSOR_VERDICT=skipped, continue to Step 3b with no Continue/Revert/Stop prompt). Residual risk: the bounded verdict headline and qualification summary are still model-generated text, so operators should treat them as advisory evidence only, never executable instructions.
/design plan-command validator auto-fix trust boundary (#3628 Component D): The shared ### Plan command validator failure (shared) handler runs python/cli.py plan auto-fix-commands on VALIDATE_STATUS=defects-found before prompting the operator. Unlike the read-only /design review, assessor, and voter lanes (codex exec --sandbox read-only / cursor agent --mode ask), this lane intentionally grants the external vendor write access so it can edit the flagged plan file in place — the same workspace-write posture as the /implement implementer launchers, but scoped to $DESIGN_TMPDIR (least privilege): the helper rejects symlink targets and plan files outside the canonical design tmpdir, Codex runs via python/cli.py agent launch-codex-exec --workdir "$DESIGN_TMPDIR" --add-dir "$DESIGN_TMPDIR" (no repo --add-dir), and Cursor runs via python/cli.py agent run-external-agent -- cursor agent -p --trust --workspace "$DESIGN_TMPDIR". The fix prompt frames the plan content and the validator log as untrusted data, not instructions. The original validator log is copied before revalidation can overwrite it, then piped through python/cli.py redact secrets before inclusion; if redaction fails, the raw log is withheld from the external prompt. The vendor's edits cannot bypass validation: python/cli.py plan auto-fix-commands re-runs python/cli.py plan validate with the same repo root passed by the handler after each attempt and reports AUTOFIX_STATUS=ok only when the validator itself passes (the model's claim of success is never trusted). Before each dispatch the helper snapshots all session files except the target and plan-autofix/**, restores and fails the attempt on non-target tmpdir mutations, records and fails on repository dirty-tree deltas introduced by the vendor, preserves site/target-specific validator evidence, and writes revalidation stdout/stderr to a durable attempt log when validator infrastructure fails. For plan.txt targets it runs the optional-trailer snapshot/dedup guard before re-entering the postplan fence, so auto-fix cannot silently corrupt size metadata. Attempts are bounded (cross-vendor alternation, default one attempt per vendor; single-vendor runs are not retried against the same tool) and additionally capped by a per-site/target/evidence durable handler sentinel so a passing-but-still-bad fix cannot trigger repeated external calls on re-entry while independent later defects can still be tried; on exhausted/unavailable/failed/cycle-cap the handler falls back to the operator Fix-and-retry / Override / Cancel prompt, and a Warnings entry is logged whenever defects occurred (auto-corrected or not). The target files (plan.txt, composed-plan.md) are session-internal pre-publication artifacts; Step 5c still validates composed-plan.md before redaction and the larch:plan write. Residual risk: Cursor --trust is still a same-UID write-enabled external process and cannot be treated as a host-filesystem sandbox; the dirty-tree and tmpdir guards prevent larch from continuing on detected off-target mutations but do not make Cursor a confidentiality boundary.
/design Step 1d.7 outline binding trust boundary: The operator-approved design outline ($DESIGN_TMPDIR/design-outline.md) is prepended as binding context into Step 2a sketch prompts and Step 2b plan drafting. Outline content derives from the operator's own /design session — the issue body (which can contain arbitrary user text) and any Refine free-form follow-ups. Because this content reaches external sketch/dialectic agents as "binding approved direction," text in the outline could amplify prompt-injection text from a crafted issue body or a Refine reply that includes instruction-like content. The primary mitigation is that the outline is operator-approved: the operator reviews and explicitly approves the outline via the Approve / Refine / Cancel gate (Step 1d.7) before it reaches external agents, so malicious content in the issue body would have to pass the operator's review unnoticed. --skip-approve/-s removes the operator-review gate at Step 1d.7 and Gate C final plan: the outline and final plan are auto-approved without human inspection. Use --skip-approve only in fully automated pipelines where the issue body is trusted or script-generated; do not use it when the issue body may come from an untrusted source, because malicious content in the outline would proceed directly to external sketch/dialectic agents without operator review. --skip-approve does not bypass Gate B finding-apply, plan-size brakes, the validator, or any other safety prompt. No mechanical sanitization is applied before prepending. Operators should review outline content before approving (or avoid --skip-approve when the source is untrusted), especially when the issue body comes from an untrusted source. The design-outline.md file is session-internal: it is never written to composed-plan.md, the larch:plan GitHub block, or the design-log publish bundle (larch-logs/design/<RUN_ID>/); /implement does not consume it. See skills/design/references/design-outline.md for the full contract.
/design dialectic debater waterfall (Step 2a.5): Per-side Cursor/Codex/Claude debater retries expand the same trust model as other external-delegation lanes — debater transcripts are untrusted model output. python/cli.py render debate-retry embeds a bounded prior-attempt excerpt inside a fenced block labeled as untrusted data (not instructions) before re-appending the original task prompt; operators should treat that bundle like other prompt-injection surfaces (instruction sentences are trusted; embedded prior bytes are not). Ballot assembly MUST apply the attribution-stripping rules in skills/shared/dialectic-protocol.md (including vendor/model substrings) even when a side's passing text arrived only via the Claude Agent-tool 2nd-retry Write path (debate-<n>-claude-<side>-retry2.txt), because that tier is not collector-gated the same way as external CLI launches.
Claude review subprocesses: python/cli.py agent launch-claude-subprocess is used for /review Claude fallback slots, dynamic reviewer archetype scouting, and tie-breaker style tasks. It validates prompt, context, and output paths against containment roots, rejects symlinks, rejects .. and control characters, caps context to 20 files and 1 MB per file (raised from 256 KB in #2292 because real-world /implement runs on non-trivial PRs produced git diff -U20 MERGE_BASE...HEAD outputs above the old cap; the new 1 MB ceiling remains well below Claude Sonnet 4-6's 200 K-token context window), and wraps each embedded context file in an untrusted encoding="literal-redacted" block with python/cli.py redact secrets redaction, XML escaping for body bytes, and XML-attribute escaping for the recorded path. By default the allowed read roots for --context-files are the plugin root and the canonical session directory that owns --output-file; the output parent and leaf must be non-symlinks. Trusted launchers may widen embedded context reads with --allow-root <dir> only when the root resolves under the session root, plugin root, or repository root. --read-tools-add-dir is stricter: it must resolve under the session root before it is forwarded to Claude as --add-dir. On the post-validation success path the launcher writes .meta, .done, and .dirty-tree sidecars; .meta records the prompt sidecar path but not the full prompt in CMD_JSON, and the Claude prompt is delivered over stdin rather than process argv. The default model is claude-sonnet-4-6 and can be overridden with --model. Claude subprocesses and the write-capable python/cli.py agent launch-claude-ci CI-fix lane invoke the CLI with --output-format json; a successful launcher must parse a JSON envelope with is_error absent/false and a non-empty string .result, promote only that .result to the downstream output file, and record claude_sub token usage only after promotion succeeds. Empty/missing/non-string .result, malformed JSON-looking output, or is_error:true are treated as launcher failures with a fixed sentinel instead of raw envelope promotion, so collector-visible prose and token accounting cannot diverge. Unlike Codex and Cursor review launchers, this wrapper has no mechanical read-only CLI sandbox; read-only behavior is prompt-level only. The ${OUTPUT}.dirty-tree file here is a contract-shaped sidecar for collectors/orchestrators (not the same as the Cursor/Codex review launcher ${OUTPUT}.dirty-tree contract that reflects a pre-launch baseline vs post-run git status probe summarized above). Dynamic scout outputs are therefore treated as untrusted metadata only: review dispatch-panel now synthesizes dynamic reviewer prompts from a fixed trusted template and quotes scout rationale/prompt text inside an untrusted data block instead of forwarding LLM-authored instructions directly. When python/cli.py scout dynamic-archetypes runs the Claude tier it may pass --read-tools and --read-tools-add-dir "$SESSION_ROOT/staged-context" to python/cli.py agent launch-claude-subprocess, which launches claude --print with --add-dir scoped to staged context only, --allowedTools "Read" (no Grep/Glob), and --permission-mode plan so the scout reads staged context files by path instead of prompt embedding. Staged copies and the scout prompt live under $SESSION_ROOT/staged-context/ (regular-file cp from validated caller paths; staging hard-fails above 1 MB before copy; staged bulk files above 256 KB emit a soft warn). The allowlist denies Edit/Write/Bash; read scope is limited to staged-context, not sibling session artifacts (source-env.sh, prior-round outputs, gather env). Reviewer/voter launches that do not pass --read-tools keep the legacy embedded --context-files path unchanged.
/design --design-tmpdir allowlist: python/session_env.py defines validate_design_tmpdir, reached by bash callers via python/cli.py session validate-design-tmpdir, which validates the candidate path before initializing quiet logging so a disallowed existing DESIGN_TMPDIR cannot receive larch-quiet-*.log writes ahead of the allowlist check. Quiet wrappers and Python entrypoints (design-step3-mav.sh, python/cli.py design stage-terminal-state, python/cli.py design failure-report, and python/cli.py design step-final-summary) validate the design tmpdir before quiet/log routing or artifact writes, so validator stderr reaches the caller and quiet-log selection cannot run against a rejected path. Embedded _LEGACY_ASSETS plan-review bash bodies that initialize quiet logging also validate the design tmpdir first. python/cli.py plan-review preview validates before its step2b/step3/gatec missing-plan early exits; python/cli.py plan-review preview --variant step3 validates before sentinel read/write/touch (design-step3-entry-preview.sh owns the Step 3 sentinel), so those warning-only paths cannot write under or silently accept a disallowed existing directory. Resolved paths must fall under ${XDG_CACHE_HOME:-${HOME}/.cache}/larch/sessions/, ${TMPDIR} when set, or /tmp (each prefix canonicalized with pwd -P so macOS /tmp maps to /private/tmp). The validator walks to the nearest existing ancestor, resolves symlinks on existing segments, rejects newline/carriage-return bytes, rejects any ./.. path segment before string-prefix checks, rejects existing non-directory leaves (including symlink-to-file leaves), and never creates directories itself. A misconfigured orchestrator therefore cannot redirect design session artifacts to arbitrary filesystem locations outside the session/tmp allowlist or defer these shape failures to a later mkdir -p. Per-script error contracts (PUBLISH_OK=false, PAUSE_OK=false, LOAD_OK=false, FINALIZE_PLAN_STATUS, PLAN_SIZE_STATUS argv exit codes, variant-specific warning-and-exit-0 paths in python/cli.py plan-review preview) are preserved on validator failure.
emit_kv single-line contract: residual Bash wrappers keep KEY=value contract lines single-line and reject embedded newlines before writing to stdout or a hook contract stream. This keeps line-oriented KV parsers from splitting one logical value across multiple contract lines when a path or message accidentally carries a newline.
/design Step 2b drafter subprocess: Step 2b dispatches to python3 python/cli.py agent launch-claude-drafter by default when LARCH_DESIGN_DRAFTER is unset, or to python3 python/cli.py agent launch-codex-drafter when LARCH_DESIGN_DRAFTER=codex explicitly opts into Codex. LARCH_DESIGN_DRAFTER=claude also selects the Claude launcher. Both launchers are write-adjacent lanes for implementation-plan drafting only. The Claude path receives repository access through native Claude CLI flags (--add-dir <repo-root>, --allowedTools Read,Glob,Grep,LS, --permission-mode plan) and is not granted Write, Edit, Bash, or larch wrapper-only --read-tools* authority. The Codex path runs python/cli.py agent launch-codex-exec --sandbox read-only --add-dir <repo-root> so Codex operates in a read-only sandbox with the repository tree explicitly granted. Step 2b embeds design tmpdir artifacts into the prompt as redacted untrusted blocks rather than granting the tmpdir as a tool root by default; the prompt also instructs the subprocess not to write repository or tmpdir files. Both launchers validate prompt and output containment, write the authoritative plan.txt, optional plan-summary.md, status KVs, and sidecars under $DESIGN_TMPDIR, and parse plan output only through the sentinel-delimited LARCH_PLAN_BEGIN/END contract; model prose never replaces the fixed status file directly. The .dirty-tree sidecar is written unconditionally on exit; prelaunch failures may lack a baseline-delta comparison but still emit a sidecar with STATUS=unknown. When a baseline porcelain snapshot is available, only a positive baseline delta (STATUS=dirty MODE=baseline-delta) is treated as confirmed new drafter mutation and blocks fallback/success until recovery. Pre-existing dirty trees, baseline capture failure, and no-launch/prelaunch failures do not by themselves block clean inline fallback. The Claude-path model is selected by LARCH_DESIGN_PLAN_MODEL, defaulting to claude-opus-4-8.
Claude voter subprocess (python/cli.py agent launch-claude-review): python/cli.py agent launch-claude-review is the sibling launcher to python/cli.py agent launch-claude-subprocess; it runs Claude as a read-only subprocess with --role voter or --role reviewer and is the path python/cli.py plan-review voter-dispatch uses for Voter 1 on /design plan ballots with --timing-task-kind claude-plan-voter, replacing the historical in-process Agent-tool voter path. The ballot output is written to the dispatcher-provided --output path under the session tmpdir; mechanical checks and primary .meta/.dirty-tree emission are performed by python/cli.py agent launch-claude-subprocess inside the python/cli.py agent launch-claude-review delegation chain (same argv coverage: output, prompt, and context paths, model, timeout, and role flags). python/cli.py agent launch-claude-review exposes public repeatable --context-files <path> for operator-supplied context; explicit paths hard-error on missing, empty, non-existent, or unreadable values before the subprocess starts. For each accepted context path the launcher widens the subprocess read surface by forwarding the parent directory as --allow-root, matching the existing implicit context-flag behavior. The subprocess still owns the mechanical context-file guarantees: symlink rejection, control-character and .. rejection, the 1 MB per-file cap, and the 20-file global cap. On the post-validation success path those sidecars appear alongside the ballot; early validation failures can exit before .meta is written, and the review launcher may synthesize ${OUTPUT}.done when the subprocess returns without creating it. python/cli.py plan-review voter-dispatch waits on the wrapper sentinels before returning voter paths and captures gh-style error metadata through python/cli.py run-log append-failure when the voter launch fails or when the wrapper exits successfully but the ballot file is empty (warning path with --status-label warning, not only non-zero exits). Against the prior in-process Agent voter, the subprocess model is a fresh claude invocation with its own stdout/stderr, the subprocess/wrapper sidecars, and per-launch timing attributed under claude-plan-voter, so launcher-level observability replaces in-process tool-call telemetry. The wrapper resolves the default voter model from LARCH_VOTER_MODEL (default claude-sonnet-4-6) when --role voter omits --model. The wrapper does not apply a mechanical read-only CLI sandbox—read-only posture is prompt-level—and it does not pipe model output through python/cli.py redact secrets. The ${OUTPUT}.dirty-tree sidecar on this lane is the contract-shaped marker described above for collectors, not evidence that a post-run working-tree scan succeeded; the plan-review lane’s post-run dirty-tree checks (for example python/plan_review.py invoking python/cli.py dirty-tree around the checkpoint sites in that script) are the separate mechanical backstop for mid-run tree hygiene. Durable committed voter/reviewer artifacts are redacted at publication boundaries primarily through python/cli.py run-log’s _stage_round_artifact path, which ends in redact.redact() (python/redact.py, applying tmpdir-path and secret redaction). Other publishers each own their own redaction surface: python/cli.py review compose-findings applies that pipeline before JSONL emission (see the Pre-vote findings aggregation note below); python3 python/cli.py tracking-issue and python/cli.py design log-publish redact before gh or log-tree writes as documented in their respective bullets—do not assume every tally or aggregation path runs the same scrubbers unless the script itself does. Read this together with the External tool delegation paragraph above, which records that Claude Voter 1 already runs through python/cli.py agent launch-claude-review for plan ballots. The plan-voter dispatcher ships a narrower do-not-modify voter prompt and does not publish launcher-owned dirty-tree sidecars itself. See also python/agents.py, python/plan_review_panel.py, python/timing.py TIMING_TASK_KINDS_ALLOWED (the claude-plan-voter timing kind), and python/voting.py (voting parse-rate-*) for parse-rate probing.
Public review scout logs: review-scout-manifest.json batches must use basename-only references (manifest_basename, yield_tsv_basename) rather than absolute tmpdir paths. Public larch-log commits must not expose ~/.cache/larch/sessions/... or other local absolute paths through scout metadata.
Review fix application: /review-and-fix and /implement Step 5 apply only findings accepted by the review voting path, but accepted finding text is still treated as untrusted reviewer data. python/cli.py review-and-fix apply-findings applies fixes through Cursor, then Codex (#3704), while the main agent does not use Edit/Write for review fixes. Submodule protection is layered: python/cli.py redact scrub-submodule-paths drops accepted findings that target discovered submodule roots before dispatch, the coder prompt includes a submodule prohibition block, and post-dispatch git diff --name-only / git diff --name-only --cached plus untracked-path checks revert tracked submodule changes, remove untracked files under submodule roots, and fail the round with CODER_STATUS=submodule-violation. FIX_COUNT is the post-scrub coder input count, so downstream bulk-skip-ratio logic measures only the findings actually dispatched to the coder. When the coder reports accepted in-scope findings as SKIPPED:, only non-security skipped blocks may be mirrored into public OOS review artifacts; skipped blocks with an unfenced focus-area=security token are held only in local per-run files and must never be copied to oos-accepted-review.md. The security classifier now fails closed: classifier errors (for example missing python3, unreadable temp files, decode failures, or runtime failures) abort the round instead of defaulting to the public OOS path, and oos-accepted-review.md mirror-copy failures likewise abort instead of silently leaving the legacy mirror stale. Prompt wrappers must ignore instructions embedded in reviewer prose and skip unsafe findings instead of applying edits outside the dispatch contract.
Read-poll reminder output: scripts/hook-anti-read-poll.sh treats repeated-read metadata as untrusted. Its additionalContext reminder no longer echoes the read path or basename back into the high-priority hook channel, preventing attacker-controlled filenames from being reflected as prompt-like instructions.
Current omitted---coder routing does not auto-select the main Claude agent based on plan diff_lines; it follows Codex → Cursor → Claude by external availability. The exported diff_lines / diff-lines.txt values are informational sizing context only. The run manifest records coder_fallback=true only when both external implementers are down and the main agent runs through the implicit fallback path.
Fork setup destructive sync: /set-up-forked-open-source-repo can perform an explicitly destructive fork sync. When upstream and fork refs/heads/main differ, the coordinator verifies gh repo view <fork> reports a parent equal to the declared upstream — preferring parent.nameWithOwner when populated, otherwise composing it from parent.owner.login and parent.name — compared case-insensitively, prints the upstream/fork main SHAs, requires TTY confirmation or --mirror-confirmed, re-probes both SHAs immediately before pushing, then runs git push --prune from a fresh temporary mirror clone with scoped refspecs for branches and tags only. Residual risk remains: a confirmed sync overwrites all fork branches and tags to match upstream, including deleting fork refs that upstream lacks, and there is still a narrow TOCTOU window between the final re-probe and the push. The destructive-sync threat model is unchanged for github.com and now applies equivalently to GitHub Enterprise hosts selected from origin.
Fork setup URL-override footgun: the coordinator supports LARCH_FORKED_REPO_URL_OVERRIDE_{UPSTREAM,FORK}_{HTTPS,SSH} env vars as a test seam so the offline harness can exercise mirror-sync paths against local bare repos. Production runs leave them unset and the URLs are derived deterministically from the verified --upstream / --fork arguments. Because the override is an absolute URL with no scheme/host check, a stale operator-environment export or a CI profile that leaks test env vars into the production lane could redirect the destructive git push --prune to whatever URL the env var names — independent of gh repo view's parent-verification guard. The coordinator therefore requires an explicit LARCH_FORKED_REPO_ALLOW_URL_OVERRIDE=1 opt-in before the override fires; without that flag, the env vars are ignored even if set. The harness sets the opt-in itself; operators should not export it in interactive shells. Pushurl-survival hardening: after remote rewrites the coordinator unsets remote.origin.pushurl, so a stale or hostile pushurl carried over from a renamed <named-fork> remote cannot redirect future git push origin ... to the wrong repository while origin's fetch URL still points at the declared fork. Residual url.*.insteadOf footgun: git config --global (or any user-scope) url.<other>.insteadOf https://github.com/ rewrites silently redirect every git ls-remote, git clone, git push, and git fetch issued by the coordinator — bypassing both the env-var allowlist above and the gh repo view parent guard. The coordinator does NOT scan or override insteadOf rules; the same-user threat model treats global gitconfig as already trusted by the operator, but operators sharing a profile or inheriting dotfiles from an untrusted source should audit git config --global --get-regexp '^url\..*\.insteadOf$' before running the skill. Pre-fetch URL classification: phase_preflight inspects origin's configured fetch URL with lib-remotes::normalize_github_url BEFORE the first git fetch origin and refuses non-parseable URLs and mixed-host or multi-URL origin configurations — so a malformed or hostile origin URL is rejected without any network round-trip against the unvalidated remote.
Plugin-shipped hooks (/design background polling): hooks/hooks.json registers a PreToolUse hook at ${CLAUDE_PLUGIN_ROOT}/scripts/hook-bg-poll-guard.sh with matcher Read|Bash. It applies only during live /design immediate-background waits, when a wrapper has written $DESIGN_TMPDIR/.bg-wait-active. While that marker is live, it denies progress-observation probes of the design tmpdir, task outputs, result env files, reviewer outputs, and plan-review directories, then tells the orchestrator to end the turn and wait for <task-notification>. The hook fails open on malformed input, missing jq, marker parse errors, telemetry write errors, and unexpected runtime errors. Operators can disable it with LARCH_BG_POLL_GUARD_DISABLE=1. Residual risk: this depends on Claude Code honoring permissionDecision=deny, does not constrain external Cursor/Codex processes, and does not replace the prompt rule to yield for <task-notification>.
Plugin-shipped hooks (session stop): hooks/hooks.json registers additive hooks. The post-/design boundary stack (post-design-boundary.sh, hook-post-design.sh, .boundary-gate-passed as a load-bearing continuation gate) is retired and removed from the shipped plugin (#2487) — /implement Preflight reads the plan from the issue body; /design is not a separate mandatory manifest handoff before Step 0. The former post-bump PostToolUse Skill hook and hook-post-bump-version.sh version-bump resume path were retired with per-PR bumping and are now deleted (Phase 5); do not rely on shipped hooks for bump-resume hygiene. The Stop hook (skills/implement/scripts/hook-stop-fail-close.sh) remains for session-hygiene cases documented in that script; do not infer that a missing .boundary-gate-passed file implies an incomplete issue-anchored run (that sentinel is obsolete).
Plugin-shipped hooks (prompt progress): hooks/hooks.json registers a UserPromptSubmit hook at ${CLAUDE_PLUGIN_ROOT}/scripts/hook-progress-report.sh. It fires on every prompt submission in every repo where the plugin is loaded, reads only prompt and cwd from the hook JSON, and continues silently unless the trimmed prompt is exactly p or progress. On a match, it runs the local stdlib-only progress engine (python/cli.py progress report --cwd ...) and, when a live run for that repo is found, returns a block decision whose reason is the report text. The hook is fail-open: malformed JSON, missing jq, engine failure, timeout, or no matching live run produces no output and does not block the prompt. It writes no files, makes no network calls, does not store prompt text, and reads only local session pointer files plus read-only session tmpdir artifacts (timing-ledger.tsv, ship-pr-state.sh, Step 5 round files, and related run-log artifacts) needed to render progress.
SessionStart tmpdir advisory path: scripts/sessionstart-health.sh resolves active /implement tmpdirs when jq is available and stdin carries the hook payload. It reads the JSON cwd and session_id fields, exports session_id as LARCH_TOKEN_SESSION_ID, and calls python/cli.py session resolve-implement-tmpdir --cwd "$HOOK_CWD". Bash first checks for claude-implement-* directories under ${XDG_CACHE_HOME:-${HOME:-/tmp}/.cache}/larch/sessions, /tmp, and /private/tmp so steady-state SessionStart events do not spawn Python. The resolver scans those local session roots for a .larch-keepalive slim session-identity record (CLONE_PATH, SESSION_ID) whose CLONE_PATH matches cwd and whose tmpdir carries a resolver eligibility sentinel (design-export/manifest.env, review-round-summary.md, legacy .bump-version-armed, or legacy .release-armed). It does not emit the legacy “pending post-/design boundary / run post-design-boundary.sh” advisory for design-export/manifest.env alone — that was tied to the retired gate. It does still surface post-/review pending-boundary hints, dirty-tree / stash / merge-state warnings, and larch-stalled-run.txt recovery text when applicable. The post-/release SessionStart advisory is retired. This SessionStart path is advisory only: malformed JSON, missing stdin, missing jq, no session dirs, missing python3, resolver failure, no matching tmpdir, stale candidates, or .run-cleaned-up all fail open with exit 0 and no boundary message. The hook does not write files in this path; when it emits an advisory, it discloses only the resolved tmpdir basename inside hookSpecificOutput.additionalContext, not an absolute path.
Reduced residual risk for stale claude-implement-* tmpdirs: prefer LARCH_TOKEN_SESSION_ID binding (when set) and the documented resolver TTL backstop; exact session-id matches bypass TTL for long-running sessions.
python/compose_review.py and python/cli.py review compose-findings add a redaction boundary before writing review-findings-full.jsonl: free-form reviewer slot strings and finding bodies flow through redact tmpdir-paths | redact secrets before JSONL emission, mirroring tracking-issue-write.sh's posture. Script-derived fields (id, phase, outcome, round_num) are bounded by parser logic. Per-round oos.md entries are included only after the same redaction pipeline; security-tagged accepted OOS remains excluded upstream from public OOS artifacts per this policy.
Pre-vote findings aggregation: review aggregate-findings builds aggregator-prompt.md under the session review tmpdir by concatenating the bundled orchestrator template (agent frontmatter stripped) with the full contents of session-local findings.md. That markdown is untrusted reviewer prose; it is sent through the same agent dispatch-waterfall external lane as other review tooling (argv and .meta sidecars follow the launcher contracts described above). Mechanical validation runs on the vendor output before findings.md is replaced; on dispatch failure the original ballot is left unchanged and a Warning is appended under External Reviewer Issues in execution-issues.md (see the outer-launcher / retry-metadata bullets for fallback hygiene). The script runs a single Codex-primary aggregator slot through python/cli.py agent dispatch-waterfall, with tool-level fallback owned by the dispatcher's internal phase-1 / phase-2 / phase-3 chain plus --require-result-pattern '^(### FINDING_[0-9]+:|[[:space:]]*LARCH_AGGREGATOR_EMPTY_MERGE_ATTESTED[[:space:]]*$)'. That ERE keeps the ### FINDING_<digits>: branch strict while allowing leading/trailing whitespace only on the empty-merge attestation branch. The dispatcher resolves the final candidate path via ALL_OUTPUT_FILES_PATH (may be aggregator-output.txt, aggregator-output-phase2.txt, or aggregator-output-phase3.txt). Narrow structural-loss signals now consist of AGGREGATOR_VALIDATION_FAILED=preamble_finding_substring from aggregate-validate.py when zero structured merged blocks exist but narrative references ### FINDING_[0-9N], and AGGREGATOR_VALIDATION_FAILED=nonconforming_heading_with_attestation when an attestation appears with a nonconforming pseudo-heading but no valid block; both terminate as REASON=validation-exhausted after the single dispatch (the dispatcher's pattern gate plus internal waterfall already handled tool-level fallback). Other semantic-validation failures (for example the OOS-attribution check rejecting a non-[OUT_OF_SCOPE] block that lists a reviewer whose input findings are all [OUT_OF_SCOPE]) now trigger a bounded re-dispatch: the aggregator is re-run with the validator's error string appended to the prompt, up to LARCH_AGGREGATE_VALIDATION_RETRIES additional attempts (default 2; set 0 to restore single-shot), before degrading to REASON=validation-failed. The fed-back validator error is internally generated, not reviewer-controlled, and the session-local findings.md ballot is left unchanged on every failed attempt and across the final degrade, so the retry adds no new trust boundary. When the post-dispatch validator hits a narrow-trigger signal, the script emits REASON=validation-exhausted and one consolidated execution-issues entry; review core maps that to REVIEW_CORE_STATUS=aggregator-validation-exhausted (exit 2, voter dispatch skipped) so /implement Step 5 stalls under Tool Failures. As of #2939, when the model returns zero structured ### FINDING_ blocks while the input ballot still had findings, an exact empty-merge attestation line (with optional allowed narrative per agents/orchestrator-aggregator.md) is an accepted duplicate-only merge: the result is REASON=ok, MERGED_COUNT=0, and a whitespace-only persisted ballot. Missing-attestation zero-block output still gets the human-readable missing-attestation diagnostic (no machine token, single-shot validation-failed) when preamble does not trip; that bucket includes literal zero-output / whitespace-only replies with no attestation and no preamble. If the vendor output contains both one or more ### FINDING_ blocks and a full-line empty-merge attestation (same trimmed-line rule), validation fails closed and the ballot is left unchanged. Near-token attestation variants whose trimmed line starts with the token but is not exactly the token are dropped by validation and by the strip step, so suffix or format drift cannot survive into a successfully persisted ballot.
The aggregator input-containment relaxation is default-off: --allow-findings-outside-tmpdir false preserves existing /review call-site behavior. Opt-in true relaxes only --findings-file input path containment; symlink rejection on that file and the post-dispatch output-containment check both remain enforced. With opt-in active, successful aggregation rewrites the outside --findings-file in place via mv -f, so callers that need rollback must stage or snapshot the ballot first; validator failure preserves input, while successful merge clobbers in place. The residual same-UID TOCTOU window between canonicalization and mv is the same as today's in-tmpdir behavior, and the flag adds no stronger structural guarantee.
sessionstart-health.sh uses a fixed-literal jq-missing fallback: when jq is unavailable, the emission path no longer interpolates dynamic content. Future probe additions that operate outside the JQ_AVAILABLE && GIT_AVAILABLE gate cannot reach JSON output via that path.
SessionStart sparse-cone drift probe: scripts/sessionstart-health.sh also performs a read-only compare of the operator's own larch-local marketplace clone against the static sparse allowlist emitted by python/cli.py upgrade-larch sparse-dirs. The probe fails open: missing HOME, missing git/jq, missing Python authority, empty compare inputs, or any probe error skips the warning and the hook still exits 0. It is non-mutating and never removes, adds, updates, or installs marketplaces. The advisory is a fixed string pointing at /upgrade-larch; it does not interpolate paths, sparse-checkout output, command stderr, or other local data.
Relevant-checks captured logs: /implement and /review run project-local relevant checks through python/cli.py checks run-relevant. Raw check output is captured under the session tmpdir in a mode-700 relevant-checks/ directory with mode-600 logs. Successful runs expose only a bounded RELEVANT_CHECKS_OK=true machine line. The default path no longer skips when a shell helper is absent; structural failures emit STATUS=fail FAILURE_REASON=<token>, and RELEVANT_CHECKS_SKIPPED=true is reserved for explicit --allow-skip test paths. Failed runs write a redacted companion log through python/cli.py redact tmpdir-paths | python/cli.py redact secrets; orchestrators are instructed to read REDACTED_LOG_FILE, not raw LOG_FILE. If redaction fails, the helper emits STATUS=fail FAILURE_REASON=redaction-failed without publishing the raw log path. The scrubber covers tmpdir paths and known secret token families only; internal URLs, private hostnames, and PII still require prompt-level/operator discipline. Python CI-fix vendor launch: when python/cli.py gh run-logs succeeds, stdout is piped through python/cli.py redact secrets to a tmpdir sibling before any --failure-log path is passed to python/cli.py agent launch-cursor-ci, python/cli.py agent launch-codex-ci, or python/cli.py agent launch-claude-ci; if redaction fails or the redacted file is empty, --failure-log is omitted (fail-closed). Treat those captures like other model-facing argv: they can contain CI secrets until scrubbed.
CI failed-job diagnostics: python/cli.py ci failed-jobs treats gh run view stderr and successful job-name rows as untrusted GitHub API output. It strips C0 control bytes and DEL through sanitize_diagnostic_line() before caller-visible stderr, TSV rows, KV fields, and fixable/unfixable classification; job names that sanitize to empty are dropped before FAILED_JOBS_COUNT increments. This is control-byte hardening only, not semantic validation of arbitrary workflow names; the existing job-name allowlist and malformed-name classification still decide local replay eligibility.
Committed run-log manifests: python/cli.py run-log init preserves the operator_cwd and operator_repo_root manifest keys only as stable placeholders ("<OPERATOR_CWD>", "<REPO_ROOT>" or null outside git). Committed larch-logs/*/manifest.json files must not carry operator-local absolute paths.
Python PR body composition: The in-progress Python compose_pr_body path applies Mermaid sanitization to the fully assembled Markdown body before redaction, matching the update path so plan-derived summary text cannot smuggle an unsafe Mermaid fence into PR creation. Python summary bullet composition also requires a repository cwd and resolves plan_goals_file under that root before reading it; callers that cannot supply a repo root fail closed instead of reading arbitrary relative paths.
Operator repo path redaction: python/cli.py redact tmpdir-paths treats /Users/<name>/<repo> and /home/<name>/<repo> as publish-boundary sensitive paths even when they appear as bare repo roots at end-of-line/end-of-value or immediately before punctuation. The scrubber rewrites those forms to <OPERATOR_REPO_PATH> while preserving any trailing slash or delimiter.
Model-env trust boundary: LARCH_CURSOR_MODEL, LARCH_CODEX_MODEL, and their CLAUDE_PLUGIN_OPTION_*_MODEL fallbacks are operator-supplied input that becomes privileged external-agent CLI argv. python/cli.py agent model-args now emits one argv token per stdout line, and consumers read that stream into Bash arrays instead of expanding a shell string. Successful stdout is machine-only and contains no empty lines; diagnostics and Codex effort warnings go to stderr. Explicit blank / whitespace-only model values and any value containing a POSIX [[:cntrl:]] character fail closed before the external CLI is invoked. This is a breaking stdout-shape change for out-of-tree callers of agent-model-args.sh: callers must consume line tokens, not word-split command substitution output.
Retry-metadata deserialization (issue #1154): External-agent .meta sidecars store the child argv as a JSON array (CMD_JSON), validated as an array of string before deserialization. Retry launches use direct Bash array expansion; eval is no longer used to reconstruct argv from persisted shell-quoted text. Malformed or missing metadata fails closed for that reviewer result. TIMEOUT= is part of validated retry metadata. Missing, non-digit, or zero/zero-padded values fail closed for that reviewer (mark_retry_metadata_invalid), aligning the reader-side contract with python/cli.py agent run-external-agent's writer-side validation. The legacy CMD_JSON branch also applies per-tool argv-shape allowlists: Cursor must look like cursor agent ... --workspace ... and cannot carry --add-dir; Codex must look like codex exec ... -C ... --output-last-message .... Unknown TOOL= values fail closed. .done sentinel content is also treated as tamperable data: the collector accepts only ^[0-9]{1,3}$ digit strings in the 0..255 range, otherwise stderr-logs and forces EXIT_CODE=99; .done files are per-session local artifacts under the session tmpdir and are not an authentication boundary. .meta files remain per-session local artifacts under the session tmpdir and are not an authentication boundary; the trust model treats them as data that may be tampered with by anything writing to that tmpdir.
Cursor/Codex outer-launcher retry metadata and dirty-tree guard: Cursor and Codex review retries may carry OUTER_LAUNCHER, OUTER_LAUNCHER_PROMPT_FILE, and OUTER_LAUNCHER_WORKDIR in the same .meta sidecar so retries replay through python/cli.py agent launch-review --tool cursor or python/cli.py agent launch-review --tool codex and keep launcher-owned post-processing. The collector treats these fields as tamperable data: all three must be present together, OUTER_LAUNCHER is rejected if it contains .. and must canonicalize to the tool-matched allowlisted launcher, OUTER_LAUNCHER_PROMPT_FILE is pinned to ${ORIG_OUTPUT}.prompt and must be a readable regular non-symlink file, and OUTER_LAUNCHER_WORKDIR is rejected on .. and must be an existing directory. Partial or invalid outer metadata fails closed and does not fall back to CMD_JSON, because replaying the inner command would skip launcher-owned post-processing and the mid-run dirty-tree guard. If all OUTER_LAUNCHER* fields are stripped but review-shaped CMD_JSON or a ${ORIG_OUTPUT}.prompt sidecar remains, the collector also fails closed instead of replaying through agent run-external-agent. Each launcher captures a pre-launch untracked baseline and writes ${OUTPUT}.dirty-tree before ${OUTPUT}.done; STATUS=dirty or STATUS=unknown triggers automatic log-and-discard of the reviewer-introduced changes — no operator prompt is issued and no stash is created. /review and /implement Step 5 both reach this through review core. This guard detects reviewer writes after the fact; it does not prevent the reviewer process from writing during its run. agent launch-cursor-implement writes analogous outer-launcher metadata for forward compatibility, but the collector does not allowlist it until an implement-side replay caller exists.
/research delegates review tasks to Cursor and Codex when those tools are available; it launches external reviewers directly against the working tree.
-
External Cursor / Codex filesystem access: Cursor is launched as
cursor agent ... --workspace "$PWD"and Codex ascodex exec --full-auto -C "$PWD"; both processes inherit the user's filesystem privileges and can write the working tree. Non-modification is requested in the reviewer prompt only — this is a behavioral constraint (prompt-enforced), not a sandbox. The hook described below governs only/research's own Claude tool calls; it does not extend to subprocess-spawned external reviewers. -
/researchorchestrator: mechanical limits + prompt-enforced caveats: the/researchskill'sEdit | Write | NotebookEdittool surface is mechanically guarded by the skill-scoped PreToolUse hook${CLAUDE_PLUGIN_ROOT}/scripts/deny-edit-write.sh, which matchesEdit|Write|NotebookEditand permits the call only when the target path resolves under canonical/tmp, denying every other outcome. Theallowed-toolsfrontmatter listsEdit,Write,NotebookEdit, andSkill— it declares the orchestrator's surface but does NOT confine writes by itself; the hook is the sole mechanical enforcer of the/tmp-only policy. The hook's matcher does NOT includeBash— Claude's own Bash calls (heredoc writes,>>redirects, subprocesses,git) are prompt-only constrained. The matcher likewise does not coverSkill;Skillis deliberately unscoped so/researchcan invoke/issue— this also admits other skill invocations, so operators relying on the read-only contract for the whole call graph should treatSkill's breadth as a known residual risk and, where possible, constrain plugin/skill visibility at the Claude Code level. -
Residual risk recap: the mechanical hook depends on the running Claude Code version honoring
permissionDecision: "deny"— a non-honoring host has no fallback, and the agent can write anywhere theEdit/Write/NotebookEdittools reach. Scope of the mechanical guarantee: the/researchhook governs only/research's own tool calls — child skills invoked viaSkillrun under their ownallowed-toolsand hooks, not under this one./issue's writes go to$ISSUE_TMPDIRunder/tmpby its own convention (prompt-enforced, not mechanically inherited from the parent)./tmpitself is shared scratch, not session-scoped; one skill's tmpdir is readable by other skills invoked in the same session —/tmpplacement is not a confidentiality boundary. -
Current
/researchflag surface:/researchnow exposes only--no-issue; previously documented--scale,--plan,--interactive,--adjudicate, and--keep-sidecarmodes are removed. The current topology is planner pre-pass plus the research lanes, followed by the validation panel and unconditional citation validation. Security-sensitive operators should use--no-issuewhen they do not want the report published to GitHub. -
/researchsynthesis + revision subagents:/researchStep 1.5 (synthesis) and/researchStep 2 Finalize Validation (revision) each launch a separate Claude Agent subagent. Both run as separate subprocesses with their ownallowed-tools; the/researchskill-scopeddeny-edit-write.shPreToolUse hook does NOT propagate. Output capture paths ($RESEARCH_TMPDIR/synthesis-raw.txtand$RESEARCH_TMPDIR/revision-raw.txt) remain under canonical/tmp. Prompt-injection hardening continues to use file-path tags and<accepted_findings>wrappers as model-level conventions, not parser boundaries.
Consumers running in strict-permissions environments that depend on /research's read-only-repo contract should validate hook behavior on their target Claude Code version and, where available, pin Skill(...) / Edit / Write / NotebookEdit permissions narrowly. Bash(...) is the residual mechanical bypass on /research — the hook does not match Bash, so a Bash-mediated repo write cannot be prevented by the skill-scoped hook alone. Operators relying on the read-only contract should also pin Bash(...) permissions narrowly at the Claude Code level (per docs/configuration-and-permissions.md); if a project cannot meaningfully constrain Bash, the read-only posture for /research is best-effort behavioral, not mechanical. See skills/research/SKILL.md ("Read-only-repo contract") for the skill-side framing and docs/review-agents.md for the skill-author-facing summary.
Layered secret scanning: larch runs pattern- and verification-based secret scanners at three layers. The layers enforce different guarantees, and conflating them leads to false-confidence gaps — especially around make lint-only and python/cli.py checks run-relevant, which are pre-commit-driven and therefore depend on the pre-commit hook's actual scan scope.
- Layer 1 — commit-time working-tree scan (opt-in via
pre-commit install):.pre-commit-config.yamlregistersgitleakswith anentryoverride togitleaks detect --source . --no-git --redact --no-banner.--no-gitis load-bearing: without it,gitleaks detectscans only the git log and silently passes secrets that are staged but not yet committed. With--no-git, the hook scans the working-tree filesystem — which includes both tracked and staged content at commit time — and blocks the commit on any finding. Hookpass_filenames: falsemeanspre-commit run --files <paths>still triggers a full-tree scan, so scopedpre-commit run --filesinvocations frompython/cli.py checks run-relevantcannot silently miss a secret that lives outside the changed file set. This layer is bypassable viagit commit --no-verify; Layer 2 is the enforced backstop. Git-history scanning is explicitly NOT Layer 1's job — that is Layer 2. - Layer 2 — PR gate, git scan (CI):
.github/workflows/ci.yamldefines a dedicatedgitleaksjob that installs the same pinnedv8.18.4engine used by pre-commit (downloaded directly from the gitleaks release) and runs two sequential scans: first a working-tree scan (--no-git; formerly run inside thelintCI job's pre-commit hook, moved here to reducelint's wall-clock time) and then a full git-history scan (without--no-git;fetch-depth: 0checkout required to walk the full git log). Together both scans cover the whole codebase in both dimensions — current working-tree state AND complete history — with a single pinned engine version. - Layer 3 — PR gate, live verification (CI): the
trufflehogjob pinstrufflesecurity/trufflehogto its commit SHA forv3.82.13(tags are mutable — a force-pushed upstream tag could silently swap a security scanner's binary; SHAs are immutable) withversion: 3.82.13pinning the Docker image and--only-verified, meaning findings fire only for credentials that actually authenticate against a live provider API. This is non-redundant with gitleaks: gitleaks catches regex-matched patterns including synthetic tokens; trufflehog catches ONLY exploitable live secrets. A finding in any layer blocks the PR.
The .gitleaks.toml path allowlist intentionally includes synthetic redaction
fixtures such as python/test_redact.py and session-local Python cache
directories under python/. Those entries are blind spots for gitleaks pattern
matching in those paths, so test fixtures must stay obviously fake and live
credential coverage continues to rely on the independent TruffleHog CI job.
.gitleaks.toml maintains a narrow path-based allowlist: the config's self-allowlist (^\.gitleaks\.toml$), redaction/scrubber source (python/redact.py, reached via python/cli.py redact secrets / redact scrub-log-secrets), redaction-scanner test fixtures (python/test_redact.py), and the tracking-issue Python module (python/tracking_issue.py, python/test_tracking_issue.py). These paths legitimately carry token-shaped strings throughout — regex literals, token-family tables, and synthetic test inputs — so per-line allowlisting would churn without adding signal. The committed run-log tree (larch-logs/) is intentionally NOT allowlisted: gitleaks Layers 1–2 scan it like any other path. The UUID-shaped LARCH_TOKEN_SESSION_ID generic-api-key false positive that originally motivated a blanket larch-logs/ exclusion does not fire under the pinned engine, so the exclusion was removed. The primary run-log leak defense is python/cli.py redact scrub-log-secrets, a larch-owned pre-flush secret gate invoked right before every flush (run-log commit, design-log-publish.sh, and the python/ ship-pr rework's run_logs._scrub_run_tree): it scrubs secret-shaped values — including Cursor crsr_ / key_ keys, which gitleaks does NOT cover — from the entire staged run tree in place so the flush still proceeds, while emitting a very loud warning so the operator can rotate the exposed credential. Consumer repos therefore need no third-party scanner installed for covered secret-shaped token families, but run logs remain sensitive documents: secrets or PII outside the scrubber patterns, including non-standard tokens, private hostnames, and domain-specific sensitive data, still require operator discipline before publication. Publishable *.stderr-tail sidecars copied into larch-logs/ are scrubbed by the same gate; treat run logs as sensitive regardless. High-churn documentation is NOT allowlisted (#375): README.md, CHANGELOG.md, SECURITY.md itself, skills/issue/SKILL.md, and the issue creation CLI surface are scanned by gitleaks in both Layer 1 (pre-commit, working tree) and Layer 2 (CI, full history). The layer responsibilities remain distinct: gitleaks Layers 1–2 catch regex-matched patterns including synthetic token-shaped literals in docs; trufflehog Layer 3 (--only-verified) catches ONLY live, authenticable credentials and is non-redundant with gitleaks for that reason, NOT a replacement for it — an accidental paste of a revoked token or a covered-family token in an unusual format is caught by gitleaks Layers 1–2, not by the verified-only scan. Tokens whose format falls outside gitleaks' covered rule families (see the "Outbound shell-layer redaction" subsection below for the covered families) may slip both Layer 1–2 (no matching regex rule) and Layer 3 (nothing to authenticate against a live API) — contributors must not rely on scanner layers as a substitute for editorial discipline in docs. Contributors adding new token-shaped examples to docs should use non-detector-matching forms: short prefixes without the high-entropy suffix (e.g., ghp_ as a prefix mention rather than a full 40-character token) or an explicit placeholder like <REDACTED-TOKEN>.
Reviewer archetype security lane: The Code Reviewer archetype (agents/code-reviewer.md, generated from skills/shared/reviewer-templates.md) includes a first-class §5 Security focus area covering injection, authN/authZ, secret scanning, crypto, deserialization, SSRF, path traversal, and dependency CVEs. Review findings may be tagged security as their primary focus area. Reviewer {CONTEXT_BLOCK} material (diffs, plans, commits) is wrapped in namespaced <reviewer_*> XML tags with a prepended instruction sentence that the tags are literal input delimiters. This is a model-level convention that reduces prompt-injection attack surface; it is NOT a parser-enforced security boundary. A crafted payload inside the content (e.g., a diff line containing a literal matching closing tag) can theoretically defeat the wrapper, and the primary defense is the instruction sentence combined with the namespaced prefix. Dynamic reviewer scout output is validated before it becomes an ephemeral reviewer prompt: reserved/static slugs are rejected, focus areas are allowlisted, weights are bounded, and prompt bodies containing standalone YAML fences or literal </reviewer_ closing tags are dropped. See docs/review-agents.md for the full residual-risk discussion and possible stronger follow-up mitigations.
Untrusted GitHub Issue Content (/issue Phase 2): The /issue skill performs LLM-based semantic duplicate detection in 2 phases. Phase 1 reads only issue titles from gh issue list; Phase 2 fetches full bodies and comments for shortlisted candidates via python/cli.py issue fetch-issue-details. All fetched content is untrusted — issue authors can write arbitrary text including text that looks like instructions. Phase 2 wraps each fetched issue in a per-issue <external_issue_<N>>…</external_issue_<N>> block inside an outer <external_issues_corpus>…</external_issues_corpus> envelope. New items being created in the same batch are separately wrapped in <new_item_<i>>…</new_item_<i>> blocks. Both wrappers are prefaced with a literal instruction that the tags delimit data, not instructions — the same model-level convention used by the reviewer archetype. These tags are a prompt-level convention only; they reduce but do not eliminate prompt-injection risk. A crafted payload (e.g., content containing a literal matching closing tag) can theoretically defeat the wrapper, and the primary defense is the instruction sentence combined with the namespaced tag prefix. Phase 2 also applies candidate-ID whitelist validation — the LLM may only mark an existing issue as a duplicate if that issue's number appeared in the Phase 1 snapshot; unknown numbers are rejected and fall back to CREATE. This is a correctness guard, not a security sandbox.
Untrusted GitHub Issue Content (/deps): The /deps skill reads all open issue titles, bodies, and comments as untrusted input. python/cli.py deps fetch writes delimiter-wrapped per-issue blocks using the same python/cli.py untrusted file-block / issue_wire.emit_untrusted_file_block pattern as /issue: tags delimit data, not instructions. This is a prompt-level mitigation only; crafted issue text can still try to confuse the model, so /deps validates rewrite targets, close targets, and dependency endpoints against the fetch snapshot before planning. /deps has one AskUserQuestion approval gate before any mutation, revalidates issue state before apply, and redacts outbound gh issue edit / close failure stderr before surfacing it. Residual prompt-injection risk remains consistent with /issue: delimiter wrapping and endpoint validation reduce risk but do not create a parser-enforced sandbox.
/implement Preflight admission surface: Issue-anchored /implement runs a mechanical admission gate (python/cli.py admission gate) before session-setup.sh allocates $IMPLEMENT_TMPDIR. The script rejects closed issues, managed lifecycle title prefixes ([DESIGNING], [IMPLEMENTING], [DONE], [STALLED], legacy [IN PROGRESS], legacy [PLANNED]), [... Report] audit-style titles, the audit-report label, non-empty native+prose blocker unions when GitHub dependency reads succeed, and issues lacking a [DESIGNED] prefix (new precondition: /design must complete before /implement may proceed). The [DESIGNED] title token is not a cryptographic proof of design completion or larch:plan freshness — it is ordinary GitHub issue metadata (collaborator-mutable). Mechanical enforcement for plan presence and adequacy on non-emergency runs remains Preflight python/cli.py plan-block read plus the in-prompt audit; /implement --emergency keeps the helper-side plan-block read fallback but skips the in-prompt plan-adequacy audit. Treat the title check as a coarse workflow signal aligned with python/admission.py, not a substitute for reading the plan body. --emergency downgrade scope: operators may opt into skipping the item 4 plan-adequacy audit entirely (no AUDIT=refuse result exists on the emergency path, so no bypass token or bypass-log entry is written for the skip) and bypassing exactly three Preflight gates that normally fail closed — missing issue-body larch:plan, malformed plan extraction, and the missing-designed-prefix admission check. Every triggered bypass emits a loud warning and appends one structured line to emergency-bypass.log using BYPASS kind=<lowercase-token> issue=<number> (canonical tokens: missing-plan, malformed-plan, missing-designed-prefix); Step 0 only folds that log into execution-issues.md when the current run is itself emergency-requested, and invalid bypass-log lines are converted into a redacted invalid-format warning entry at bootstrap instead of being replayed verbatim. The bypass does not suppress the admission gate, semantic materiality stale-notice exit, or later dirty-tree / branch / tracking guardrails. /implement --emergency and --merge are compatible; a merged emergency run still carries the same downgrade warnings and trust-boundary caveats. The raw-issue-body fallback is a deliberate trust-boundary downgrade: when no usable larch:plan block exists, /implement --emergency may materialize the untrusted GitHub issue body directly as plan.txt. Issue bodies are collaborator-editable and can contain instruction-like prompt-injection text, so operators must inspect the issue body before using this mode and should treat the resulting plan artifact as untrusted data, not as design-reviewed instructions. Tracking metadata makes that downgrade visible to issue readers by adding Emergency: true on emergency runs. Crash-resume caveat (see python/admission.py "Resume vs managed-title / audit gates"): when IMPLEMENT_TMPDIR is set and parent-issue.md matches the positional issue (and RUN_ID when recorded), the resume branch can return ADMISSION_RESULT=pass with RESUME=true while still skipping managed-prefix, audit-report label, and missing-designed-prefix checks — it still applies the live [... Report] title gate and re-runs open blockers before emitting RESUME=true. Blocker resolution is fail-open (historical D3 posture preserved): when native or prose dependency probes error out, the gate treats blockers as absent rather than blocking the run — operators accept possible false negatives on API outage; see python/admission.py and python/blocker.py. Tracking adoption, [IMPLEMENTING] rename, and sentinel writes remain in /implement Step 0. The shipped /issue skill does not expose a --go approval flag — do not assume a GO comment sentinel exists for newly filed issues.
Dev-only PostToolUse audit log (scripts/audit-edit-write.sh): scripts/audit-edit-write.sh is a contributor-local debugging aid that, when opted in via .claude/settings.local.json, records a JSONL trail of every Edit / Write tool invocation to .claude/hook-audit.log. The log is intentionally unredacted — it captures the raw PostToolUse payload including tool_input fields like file_path, content, and old_string / new_string, which may contain secrets, credentials, PII, or proprietary code. The file is gitignored by default (.gitignore lists .claude/hook-audit.log) and the script is not registered in shipped config (hooks/hooks.json) or in committed dev config (.claude/settings.json) — it runs only when a developer explicitly adds a PostToolUse entry to their local, gitignored .claude/settings.local.json. There is no automatic rotation or retention policy; the log grows until the developer clears it (truncate -s 0 .claude/hook-audit.log). Operational discipline: never commit the log, never paste its contents into issues/PRs/screenshots, clear it after debugging, and treat it as a secret-bearing artifact if the project handles secrets. See docs/dev-hook-audit.md for enable/disable/privacy details. The script always exits 0 so it cannot block tool use, and the regression test (scripts/test-audit-edit-write.sh, wired into make lint) uses CLAUDE_PROJECT_DIR override plus a tmpdir to verify behavior without touching the real log.
Tracking-issue outbound path: python/cli.py tracking-issue owns slim lifecycle writes (create-issue, append-comment, rename, and mark-false-positive). Durable run payloads moved out of GitHub comments and into committed larch-logs/ files via python/cli.py run-log; marker-keyed summaries are posted by python/cli.py tracking-issue. The write helper keeps the fail-closed redaction posture: body and title content is composed in memory, passed through redact tmpdir-paths and redact secrets, and only then sent to gh; captured gh stderr is passed through redact_gh_error before surfacing in ERROR=. redact_gh_error fails closed: if the pipeline is unavailable, exits non-zero, or emits the truncation marker ([content truncated — unterminated PEM block…]), a generic token-free string is emitted in ERROR= and no original stderr bytes are included. The same fail-closed contract applies to the sibling redact_gh_error helpers in python/clarify.py (python/cli.py clarify state, python/cli.py clarify label, and python/cli.py clarify comment-post), python/cli.py named-block write --marker plan, and python/cli.py plan-block read.
larch:diagrams outbound path: python/cli.py diagrams upsert owns the shared issue-scoped <!-- larch:diagrams v1 --> summary comment. /design Step 5c publishes the Architecture section via python/cli.py design publish (diagrams upsert) after the larch:plan block is successfully written; /implement Step 7a publishes the Code Flow section after successful code-flow generation. The helper accepts diagram source files only from temporary roots by default ($TMPDIR, /tmp, /private/tmp, /var/folders, or the larch session cache under ~/.cache/larch/sessions) unless the operator explicitly opts into --allow-external-paths, so an accidental caller bug cannot publish arbitrary repository files. --repo, when supplied, must match OWNER/REPO before any gh call. Before composing the outbound comment, only newly supplied sections are revalidated with python/cli.py mermaid sanitize; preserved sections fetched from GitHub are carried forward byte-for-byte. Existing comment parsing is heading-based with generic fence-depth tracking, and the helper now fails closed on unclosed fences instead of silently truncating preserved content. The composed outbound body is then passed through redact secrets and redact tmpdir-paths; tracking-issue-summary.sh upsert-summary applies the same redaction chain again as defense in depth. Captured gh stderr and delegated helper stderr are flattened only after redact_gh_error redaction; if redaction is unavailable or suspicious, the helper fails closed with a generic token-free message rather than surfacing raw stderr bytes. Validation failures keep detailed path bytes on stderr only; the machine-readable ERROR= field uses fixed-token messages so tmpdir layout is not copied into contract consumers or logs. Architecture diagrams are now posted at /design completion rather than /implement completion, so their public exposure window starts earlier. The trust model is still joint-comment, not author-exclusive: /implement preserves any existing sibling section that remains in the marker comment, so on public repositories a foreign or stale marker comment can persist until /design rewrites or clears that section. Operators that need stronger provenance should restrict who can comment on the issue or force full replacement rather than preservation on every upsert. Reviewers should treat Architecture diagram labels like plan bodies: avoid high-risk path names, secret-adjacent symbols, private hosts, and other sensitive implementation details unless they are already safe to publish.
Tracking-issue read/aggregate path: python/cli.py tracking-issue (read mode) remains a pure reader that wraps fetched GitHub issue content in data-not-instructions tags and applies size caps. Its feedback-loop guard now skips the five larch summary markers (metadata, diagrams, plan, token-report, and final-summary) instead of the removed monolithic larch summary comments. Lifecycle-marker comments remain filtered.
Final-summary stderr redaction: Both terminal summary wrappers treat renderer stderr as secret-bearing. skills/implement/scripts/write-final-report.sh and python/cli.py design render-final-summary append degraded-render warnings to execution-issues.md via python/cli.py run-log append-failure --redact, so API/auth stderr from python/cli.py render run-summary, token reporters, or timing reporters is scrubbed before it can reach session artifacts or published tracking-issue summaries. When both renderer attempts fail and a wrapper writes its local self-composed body, final-summary.md must visibly identify the degraded path with the bold fallback banner after the outcome heading and <!-- larch:final-summary-fallback v1 --> after <!-- larch:run-summary v=1 -->; consumers must not treat that body as an unqualified full renderer result. This is a fail-closed documentation requirement for any future summary-warning callsites: if a helper persists tool stderr into execution-issues or a public-boundary artifact, it must route through the shared redaction pipeline first.
The local sentinel reader validates non-empty ISSUE_NUMBER values as digits only, non-empty RUN_ID values against ^[A-Za-z0-9._-]+$, and non-empty ADOPTED values via strict equality against true / false (case-strict, no whitespace trimming other than trailing \r); empty values continue downstream as "sentinel unusable" so callers can re-adopt or create fresh state. Malformed ISSUE_NUMBER, RUN_ID, and ADOPTED sentinel ERROR= messages use the fixed token 'malformed-value-omitted' rather than echoing attacker-controlled bytes into stdout, which is itself parsed as KEY=VALUE by callers. The --issue argv boundary on both python3 python/cli.py issue state and python/cli.py tracking-issue is also self-validated as numeric before any gh interpolation; current callers already validate upstream, so this is defense in depth for future callers.
/design external delegation: SIMPLE uses 0 external sketch slots and the full plan review panel. HARD uses 4 external sketch slots and the same full plan review panel. Both tiers use the 3-judge voting panel for plan-review findings.
Issue-anchored plan/clarification gh write helpers: python/cli.py named-block write --marker plan mutates GitHub issue bodies, and python/clarify.py (python/cli.py clarify comment-post, python/cli.py clarify label, and python/cli.py clarify state) owns clarify comments, labels, and state reads. Assumptions: callers pass --issue as a positive integer (helpers reject 0 and non-numeric values); optional --repo OWNER/REPO is validated before any gh call, and malformed values fail with ERROR=invalid-repo; plan-block bodies pass through the shell redaction pipeline before network writes; clarify comment bodies pass through python/redact.py before posting; redaction truncation in python/clarify.py fails closed before posting a clarify comment; captured gh stderr is flattened and passed through a fail-closed redactor before surfacing in ERROR= machine lines; temporary working files stay under the platform tmpdir and are cleaned up by their owning helper. Non-goals: these helpers do not add a separate authorization layer beyond the operator’s existing gh auth and repository permissions; they do not scan issue or comment bodies fetched from GitHub for prompt injection (read/classify helpers are separate); they are not a substitute for repository branch protection, review, or pre-commit hooks on any follow-up git operations the operator performs locally.
/design plan review apply boundary: Step 3 plan review is single-pass and does not apply LLM-authored patches to $DESIGN_TMPDIR/plan.txt. Accepted findings are applied only at Gate B: by default Gate B auto-applies accepted in-scope findings with no operator prompt, while --per-round-approval restores the explicit Apply all / Go through each / Switch to discussion mode choice before revision. The prompt-side rewrite then runs the shared dedup/trailer guard and merged design-postplan-emit.sh --with-plan-size validation/plan-size fence before continuing. Historical python/cli.py plan revise-waterfall artifacts may still appear in old committed run logs, but new Step 3 runs do not launch that patch-apply path, and new design-log publish rejects plan-review/round-N/revise/ artifacts so obsolete prompts, outputs, and candidate patches do not enter the public log boundary.
/design design-log publish (gh pr merge --admin): python/cli.py design log-publish --repo is validated as OWNER/REPO; malformed values fail closed with exit 1 before gh / network operations. python/cli.py design log-publish copies trimmed + redacted /design session artifacts into larch-logs/design/<RUN_ID>/ using the same sidecar trim (CMD_JSON lines in *.meta, top-level .result in every *.json) and redact tmpdir-paths / redact secrets pipeline as run-log write-round. Both plan-review/ and render-cache/ subtrees are stricter than top-level design artifacts. plan-review/ enforces a per-round allowlist rooted at round-<N>/: findings.md, findings-oos.md, findings-classification.tsv, oos.md, oos-accepted-design.md, ballot.txt, voting-tally.md, plan-review-slots.ndjson, plan-voter-slots.ndjson, scout-plan-manifest.json, round-summary.env, plan.txt (round 1 only; rounds ≥ 2 commit plan.diff instead), *-vote-output.txt, *-vote-output-first-pass.txt, and voter*-diag.txt. accepted-plan-findings.md and rejected-findings.md are excluded from round directories (#3721) — they are cumulative across rounds so only the top-level copies are committed. findings-in-scope.md is excluded from both top-level and round staging (#3715) — it is a strict subset of findings.md and is recoverable from findings-classification.tsv plus findings-oos.md. ballot.txt is present in round directories (session snapshot) but excluded from committed logs by design_artifact_excluded() — it is derived from findings.md / findings-oos.md scope split and is redundant in the published artifact. Top-level GitHub-redundant snapshots are also excluded (#3721): issue-body.txt (raw tracking-issue body; canonical home is the GitHub issue), issue.json (JSON snapshot of the same issue), and architecture-diagram.md (the same Mermaid body is upserted into the issue-scoped larch:diagrams comment by /design Step 5c). round-<N>/revise/ has an empty allowlist and fails closed. The root must be a real directory (a dangling root symlink also fails publish), and any symlink anywhere under the resolved physical root fails publish. render-cache/ requires the root to be a real directory (a dangling root symlink also fails publish), fails on any symlink anywhere under the resolved physical root, applies the same per-file recheck immediately before staging, and has no filename allowlist (content schema is open; the suffix denylist inside design_publish_stage_file remains the only basename filter). A per-file ancestor re-resolution (design_publish_ancestor_within_root) fails closed when any ancestor directory is swapped for a symlink before staging, closing the parent-directory race for the plan-review/, render-cache/, and .completed subtrees (in addition to the per-file leaf recheck). Dropping the [skip ci] marker means CI runs on the publish PR; the tail first waits for required checks to register for the pushed commit head within a bounded grace/probe budget, then watches those checks with gh pr checks --required --watch --fail-fast, and only then squash --admin merges (gh pr merge --squash --admin --delete-branch). Stale prior-head check state does not satisfy registration. Required-check failures, registration timeout (checks never register or the PR head does not match within the budget), and watch failures all refuse the merge with PUBLISH_OK=false and leave the PR open for diagnosis; registration timeout uses dedicated did not register within wording distinct from CI-failure did not pass wording. --admin bypasses the review-required branch protection (the repo's review ruleset has no bot reviewer, so a server-side --auto merge would enable but never complete) and requires a token with admin-merge privileges; it is intentional but not an unconditional bypass because it runs only after registration plus a successful required-check watch. Treat committed design logs as public-boundary artifacts scanned by the existing gitleaks/trufflehog CI jobs; do not paste secrets into design prompts.
larch-logs/ as durable run store: reviewer findings, tallies, version-bump reasoning, OOS links, execution issues, run statistics, token reports, and timing reports are written through python/cli.py run-log into larch-logs/<skill>/<run-id>/ and committed by explicit lifecycle log-flush paths before the business PR merges. Diagrams are not written through a larch-log batch; the public diagram surface is the larch:diagrams issue comment described above. After a merge-success result, the Python ship driver writes $IMPLEMENT_TMPDIR/post-merge-sentinel; python/cli.py run-log flush no-ops on that sentinel and python/cli.py run-log commit refuses, so prompt-side teardown cannot create or push new log-only commits to main. The sentinel check therefore depends on IMPLEMENT_TMPDIR being exported into subprocesses that may call run-log commit; Step 7a and python/cli.py run-log refresh provide that export before their transcript/log refresh paths run. Defense-in-depth commit refusal lives at python/cli.py run-log commit; python/cli.py run-log refresh also short-circuits entirely when MERGE_RESULT already reports a merged terminal state, so post-merge retry refreshes do not attempt transcript/log writes. run-log capture-transcript itself no longer owns an independent default-branch refusal policy; it delegates commit enforcement to run-log commit (or to its caller when --defer-commit is used). Callers pass the staging root explicitly with --log-root; the helper no longer falls back to $IMPLEMENT_TMPDIR or the repository root when the root is omitted. larch-logs/ export-ignore keeps those audit files out of plugin release archives. Payload batches are redacted before writing. Tool-failure captures routed through python/cli.py run-log append-failure preserve command stdout/stderr verbatim for debugging and use python/cli.py redact secrets when callers pass --redact; /implement final-summary degraded-render warnings now also use that redacted capture path before stderr is appended into execution-issues.md. This is a secrets-family backstop only, so internal URLs, private hostnames, PII, and domain-specific sensitive content still require prompt-level/operator discipline before logs are pushed. manifest.json schema version 2 records operator_cwd and operator_repo_root as local absolute paths for provenance; these fields are JSON-escaped but not path-redacted, so public repositories may expose local username/workspace path components in committed run logs. Slim marker-keyed tracking comments contain summaries and links only, except for the diagrams comment; operators should still treat committed log files and tracking comments as public once pushed to a public repository.
Operator diagnostic redaction: larch_err / larch_errf still pipe through
redact secrets --streaming directly (the redaction streaming wrapper
was removed in Stage 3). Durable log publication redaction remains in
run-log / design-log-publish.sh via the shared breadcrumbs helper.
Mermaid diagram content is sanitized at diagram-write time and PR-body composition via python/cli.py mermaid sanitize so unsafe diagram content is dropped before it reaches public comments or PR bodies. The Mermaid parser lint introduces a Node toolchain surface through @mermaid-js/mermaid-cli; pin, audit, and bump expectations are documented in skills/shared/mermaid-safe-content.md "Node Toolchain Maintenance".
Active Step 8+ post-review publication path: python/cli.py ship pr (delegating to python/ship.py) centralizes /implement's post-review PR publication, CI-fix, merge, and teardown mechanics. It preserves the existing public-output guards: PR bodies embed only sanitized Mermaid files or placeholders, python/cli.py pr create still redacts session tmpdir paths before gh pr create, and tracking issue lifecycle writes still route through python3 python/cli.py tracking-issue. Ship-pr CI fixing now delegates to a write-capable Claude/Opus agentic loop. The delegate receives an explicit filesystem --repo-root, reconstructs RunContext so run-log flush precedes push/rebase preparation, and commits or pushes only after HEAD, forbidden-path, submodule, local-verification, and non-empty-delta guards pass. Passive CI wait runs as a blocking subprocess, not model polling, and Codex/Cursor are no longer CI-fix fallback tiers. For --role resolve-conflict, --conflict-files values are validated per path segment (reject control characters, absolute paths, ., .., empty entries, doubled slashes, and characters outside a narrow repo-relative path alphabet) before the launcher embeds them. Conflict fixers may edit conflict files only; staging and rebase continuation are driver-owned. ci-fix-exhausted is an operator bail and does not auto-resume through stall recovery.
The Python driver's ship-pr-state.sh merge path fails closed before reading or writing a symlinked state file and preserves only the documented state-key allowlist from pre-existing content. Unknown same-UID injected keys are dropped during the next state write instead of influencing later classification or context hydration.
python/cli.py checks lint-fix coder-owned commits: python/cli.py checks lint-fix dispatches Claude/Opus before Codex and Cursor, then prompts external fixers not to commit, but now accepts a fixer-created commit only on a narrow mechanical path: the pre-dispatch baseline must be clean, the symbolic branch before and after dispatch must match, and the post-dispatch HEAD must be a direct single-parent child of the pre-dispatch HEAD. Detached HEAD, branch switches, history rewrites, and dirty-baseline HEAD movement remain fail-closed. The post-dispatch HEAD-validation branches — detached HEAD, non-ancestor baseline, merge-commit advancement, branch switch, dirty-baseline HEAD movement, and same-branch multi-commit advancement — remain fail-closed: LINT_FIX_STATUS=failed is emitted with FAILURE_REASON=head-changed-after-dispatch and no coder-owned commit is accepted; the loop does not reset the working tree to baseline_head in these branches (only forbidden-path violations trigger an explicit reset). Accepted committed content is checked against .gitmodules and discovered submodule paths with the same prefix-aware forbidden-path contract as the working-tree cleanup; a committed forbidden path triggers git reset --hard <baseline_head> before failing. Even after accepting a coder-owned commit, the residual working tree is still scanned and reverted for forbidden .gitmodules or submodule-path edits before the helper reports LINT_FIX_STATUS=applied.
Cursor CI stall JSON sidecars (python/agents.py): stall forensics JSON is assembled with jq when jq is installed; without jq, no sidecar is written (stall kill behavior is unchanged). Process-list and transcript blobs are intended to pass through python/cli.py redact secrets under an 8s timeout/gtimeout envelope when the redactor is executable and a wall-clock wrapper exists; a missing redactor, a missing timeout/gtimeout, redactor non-zero exit, or timeout substitutes omission placeholders in the JSON rather than embedding raw captures. git status / git rebase --show-current-patch excerpts use the same bounded redaction envelope as other sidecar fields (not an unbounded stdin pipe), scoped to the tree channel root when channel is tree:… else the launcher cwd. lsof runs only under a short timeout/gtimeout when both tools exist; otherwise the lsof field is left empty. When jq assembly fails after a stall, a single-line cursor-ci-stall-json: jq assembly failed … marker is appended to ${OUTPUT}.diag instead of leaving the failure indistinguishable from a non-stall run. When the staged JSON cannot be renamed into the final sidecar path, a single-line cursor-ci-stall-json: write failed … marker is appended.
External CLI startup locks: Review, implement, and CI-fix spawn sites for Cursor and Codex share one Darwin-only /tmp/larch-external-startup-$USER.lock directory lock plus bounded auth-startup retry wrapper. Cursor's Darwin keychain preflight and preread sections use the same lock when CURSOR_API_KEY is not already usable. Codex and Cursor use the same startup lock because they can contend for the same per-user macOS Keychain resource. The lock is a reliability mechanism, not an authorization boundary: /tmp is shared scratch, the path includes the local username, stale locks are removed by age, and acquisition fails open after a bounded wait. Operators on multi-user hosts should not treat the lock directory as confidential or tamper-resistant; it only reduces concurrent CLI startup/auth races.
Codex env-key auth: Covered Codex paths (python/cli.py agent launch-review --tool codex, python/cli.py agent launch-codex-ci, agent launch-codex-implement, the Codex health probe in python/cli.py agent check-reviewers, python/cli.py review-and-fix apply-findings, python/cli.py agent launch-codex-exec, /research Codex research lanes, /research validation lane, shared Codex voter/judge fences, python/cli.py checks lint-fix, and python/cli.py agent run-negotiation-round) prefer a live non-whitespace OPENAI_API_KEY. Larch passes only the variable name OPENAI_API_KEY in ephemeral -c argv and non-secret config references, and strips larch-owned env-key artifacts plus literal api_key / openai_api_key assignments from copied temp configs before launch. The key value remains in the Codex child process environment, so same-UID or host-level process-environment introspection can observe it while Codex is running. Larch does not intentionally write the key value to config files, logs, argv metadata, .meta / CMD_JSON, probe output, --output-last-message artifacts, or xtrace output; however raw Codex stderr and --json event streams are session-local artifacts and can contain upstream tool diagnostics, so treat them as sensitive. When the env var is unset, empty, or whitespace-only, login fallback preserves the existing ~/.codex/auth.json symlink behavior after stripping copied temp configs.
Timing ledger containment: LARCH_TIMING_LEDGER is an env-driven write primitive used so nested /design and /review invocations append to the parent /implement timing ledger. /implement rehydrates both LARCH_TIMING_LEDGER and IMPLEMENT_TMPDIR from its session-env contract before post-Step-0 timing ledger/report calls, keeping ordinary run telemetry on the private per-run ledger instead of the cwd-hash fallback shared by a clone. python3 python/cli.py timing constrains that env path to known session roots (${TMPDIR:-/tmp}, $IMPLEMENT_TMPDIR, $DESIGN_TMPDIR, $REVIEW_TMPDIR, or dirname("$SESSION_ENV_PATH")); invalid values warn and fall through to the next resolver step; when no per-run root is configured, the script fails closed (warns on stderr, writes no file, exits 0). Vendor rows store output basenames only, and rendered timing reports do not include an output-path column, so the public tracking-issue timing fragment does not expose absolute workspace layout.
Plugin-root rehydration: LARCH_CLAUDE_PLUGIN_ROOT is persisted in session-env.sh when CLAUDE_PLUGIN_ROOT is an absolute path using the existing session-env path character set. python/cli.py session write-env also emits a minimal sourceable $IMPLEMENT_TMPDIR/plugin-root.env sibling (only CLAUDE_PLUGIN_ROOT= + export). Post-Step-0 /implement Bash blocks source that sibling after IMPLEMENT_TMPDIR is known; pre-bootstrap sites may awk-extract from session-env.sh when the sibling is absent (legacy resume). Other session-env keys still use read-session-env-key.sh — the full session-env.sh file is not sourced or evald. Treat plugin-root.env like other session tmpdir artifacts under the same-user trust model (data produced by larch scripts in the operator's account, not an integrity boundary against a hostile same-UID writer).
/design Step 0 CLAUDE_PLUGIN_ROOT export: The first Bash block in skills/design/SKILL.md exports CLAUDE_PLUGIN_ROOT from a skill-loader-expanded template line (export CLAUDE_PLUGIN_ROOT='${CLAUDE_PLUGIN_ROOT}'). If expansion fails and the value is empty, Step 0 exits immediately with stderr — invoking ${CLAUDE_PLUGIN_ROOT}/scripts/... with an empty root would otherwise resolve helpers under the wrong directory.
Session writer guard: session/state content files are written through python/session_env.py approved verbs. The runtime validates each writer's key allowlist, rejects CR/LF in persisted values before rendering, writes atomically, refuses symlinked targets, and limits session-content destinations to temp/cache session roots. /dev/null, plugin-root-only rehydration, and the /design current-env symlink use explicit carve-outs with their own validators. These checks reduce prompt-side line-injection and accidental path-clobber risk; they do not protect against a hostile same-UID process that can mutate files in the operator's temp/cache directories.
/upgrade-larch install-stamp prune trust: The upgrade-larch skill retains at most eight cached numeric version directories under the user-owned plugin cache parent, ranked by .larch-installed-at install stamps (stamped directories sort before unstamped; unstamped directories fall back to directory mtime at ranking time). Stamp writes run on the already-latest path and after verified stable installs when the resolved version is version-shaped; prune runs only after a verified stable install or on the already-latest idempotent path. At prune entry, best-effort mtime backfill writes persistent .larch-installed-at for unstamped numeric cache dirs; failed mtime reads or stamp writes leave dirs unstamped and bottom-ranked as before. Retention always includes the prune target and the running version directory (INSTALLED_VERSION, basename of PLUGIN_ROOT) when present and version-shaped; both protected directories count toward the eight-directory cap (the retained set holds at most eight version-shaped cache dirs total). It does not scan session env files or honor session pins. Prune removals operate only on version-shaped basenames under the operator's cache.
/upgrade-larch dev/test cache cleanup trust: /upgrade-larch performs best-effort local cache cleanup of dev/test files and dropped dev top-level directories. Cleanup is confined to a resolved larch cache version directory under ~/.claude/plugins/cache/larch-local/larch/<version>/, and that directory is resolved only through release_step7_cache_parent(). It rejects symlink version directories. Directory cleanup is limited to direct-child .claude/, .github/, .gemini/, and tests/ directories. File and directory candidates whose resolved path escapes the version root are skipped. Cleanup does not add a new remote or cross-repo trust boundary.
/cleanup session-tmpdir retention: The cleanup skill prunes stale entries under ${XDG_CACHE_HOME:-$HOME/.cache}/larch/sessions/ and matching /tmp larch patterns by age, not by whether a skill run is still active. pgrep -x claude populates SESSION_COUNT for operator visibility only; multiple concurrent Claude sessions do not block or abort cleanup (there is no singleton gate). Retention is controlled by LARCH_CLEANUP_RETENTION_DAYS (default 7); non-positive or non-numeric values warn on stderr and fall back to 7. Age checks use find -mtime 24-hour blocks (platform rounding applies at block boundaries). The cache pass enumerates all non-symlink top-level entries (no age pre-filter, never delete through a symlink); the /tmp pass uses top-level -mtime +N plus larch name patterns and may remove stale files as well as directories. Directory deletion is gated by a bounded find -maxdepth 5 -mtime -N nested-activity scan, so a directory with fresh deep activity (≤ 5 levels) is retained even when its top-level mtime is old; activity deeper than five levels does not protect it (depth-bound tradeoff). Matching loose /tmp files do not receive nested-scan protection; they are removed by top-level age plus pattern match. A failed scan find warns and skips deletion for that directory entry (fail-safe); a failed top-level enumeration find or failure to allocate the temp list for enumeration warns via larch_err and skips that pass (count 0), while cleanup still exits 0 and still emits removal-count KVs. Dangling current-design-env-*.sh symlinks in the sessions parent are reaped separately (age-independent). Session tmpdirs are session-scoped private state and may hold secrets, prompts, and raw .meta CMD_JSON argv (see the Cursor API key section above); /cleanup permanently deletes stale directories that pass the age gate without redaction. Operators should not run /cleanup expecting keepalive alone to block deletion — retention and bounded nested-activity bound cache removal.
Submodule edit guard anchor (scripts/block-submodule-edit.sh): The PreToolUse hook anchors its superproject-root detection to CLAUDE_PROJECT_DIR (with $PWD fallback for non-Claude-Code invocations), closing the cd-into-submodule bypass (issue #150) where a session that had cd'd into a submodule collapsed the guard's notion of "superproject root" to the submodule root, allowing same-submodule edits to slip past.
CURSOR_API_KEY environment-auth posture (issue #1358): Cursor launchers normalize CURSOR_API_KEY before spawning the child: leading/trailing whitespace is trimmed, whitespace-only values are unset so Cursor can fall back to keychain/login auth, and embedded CR/LF values are unset as paste-corruption. Cursor call sites pass no --api-key argv element; the child authenticates from the inherited environment. On Darwin, shared launchers may best-effort pre-read the exact cursor-user / cursor-access-token keychain service when CURSOR_API_KEY is empty and export the token into the launcher process. This bypasses Cursor's own keychain access in the child process and eliminates the intermittent concurrent-launch failure mode (Password not found for account 'cursor-user' / Security process exited with code: 45) for those launcher lanes when the keychain service is readable. The environment path has two visibility surfaces operators should be aware of:
-
Process environment visibility: while the
cursorchild process is running, the API key can be visible to same-UID process-inspection surfaces that expose environments. Multi-user shared hosts where untrusted users have shell access on the same machine should treat the key as sensitive in that environment regardless of whetherCURSOR_API_KEYis set or not (acursor login-keychain user has their cleartext key on disk in the keychain blob, which is also the keychain's threat model). -
At-rest launcher metadata:
python/cli.py agent run-external-agentrecords the child argv into${OUTPUT}.metaas aCMD_JSON=line for retry reconstruction. Cursor API keys are not present in that argv under the environment-auth contract, but other prompt or path metadata can still be sensitive session state. The${OUTPUT}.metasidecars live under${XDG_CACHE_HOME:-$HOME/.cache}/larch/sessions/...(session-tmpdir scoping; cleaned up by Step 18 of/implementand the parallel cleanup paths in/design//review//research). The session tmpdir is treated as session-scoped private state under existingSECURITY.mdconventions.
Committed larch-logs/.../round-<N>/ artifacts intentionally do not preserve that raw session-sidecar contract. python/cli.py run-log write-round copies only registered reviewer/voter artifacts, strips any .meta line whose first non-whitespace token is CMD_JSON=, removes the top-level .result field from included *-output.txt.json / *-output-*.txt.json sidecars, and then applies the shared tmpdir/secrets redaction pipeline. If the JSON trim cannot be produced, write-round fails closed instead of copying the raw sidecar. This yields two distinct at-rest classes for auditors: session tmpdirs are ephemeral private retry state and may contain raw argv/tool envelopes; committed round artifacts are the durable, additionally-trimmed record.
The dynamic Codex output families explicitly retained for committed run logs —
dyn-*-codex-output.txt and dyn-*-codex-output-phase*.txt plus their .meta,
.json, and .cap-hit sidecars — use the same pattern-based
redact secrets / python/cli.py redact scrub-log-secrets posture as other committed
larch-logs/ artifacts; retry outputs (dyn-*-codex-output-retry*) remain
excluded. This is a by-design residual risk acknowledgement, not a new control.
Operators who require zero environment-secret propagation should not use the shared Cursor launcher lanes on Darwin with a readable cursor-access-token keychain service: leaving CURSOR_API_KEY unset may still export the pre-read keychain token into the child environment. Prefer Claude or Codex for those runs, or remove the readable Cursor keychain entry before launching and accept that Cursor auth may fail. The Darwin-gated pre-launch sanity check (python/agents.py cursor_auth_preflight) refuses to launch with both auth sources demonstrably absent, so a misconfigured CURSOR_API_KEY=-empty + missing-service state surfaces an actionable error rather than the cryptic Security process exited with code: 45. The check and pre-read are strictly read-only: they never invoke security delete-*, never spawn a Cursor subprocess, and never perform network I/O. /design plan-review panel waterfall failures redact stderr before writing plan-review-panel-failure.log and before re-surfacing stderr to the operator; residual risk follows the shared pattern-based redactor and can over-redact benign text.
By default, /research creates a GitHub issue at the end of each successful run containing the full research report and token spend metadata. This changes /research from producing a local/terminal-only artifact to publishing the full report to GitHub. The --no-issue flag suppresses this behavior.
Redaction backstop: /issue's outbound shell scrubber (python/cli.py redact secrets) covers common secret patterns (API keys, tokens, passwords, certificates). It does NOT cover internal hostnames/URLs, PII, or domain-specific sensitive content.
/implement Step 0 plan-materialization redaction: bootstrap copy-plan and gh issue view hard-failure surfacing must pass captured stderr through both python/cli.py redact secrets and python/cli.py redact tmpdir-paths before it reaches the operator transcript; if either redactor fails, Step 0 prints a generic fallback warning instead of raw stderr. Goal text derived from the issue title now fails closed the same way: if the redaction pipeline errors, bootstrap logs a Warning and substitutes a placeholder goal string rather than forwarding the raw title into plan logs or committed larch-logs/.
Failed-agent stderr tails (#3202): Codex/Cursor/Claude subprocess failures may surface a bounded, redacted stderr tail to the orchestrator chat (FD 2 via larch_err / collector §3.8) and to python/cli.py agent run-external-agent callers (emit_failed_agent_stderr_tail_raw). The same redaction path (redact tmpdir-paths → redact secrets, 30-line / 5120-byte cap) applies to implement/CI/lint-fix lanes surfaced from implement step2-dispatch, the Python ship driver, and Step 5 lint-fix callers (#3227). Control: LARCH_FAILED_AGENT_STDERR_TAIL_LINES (default 30; 0 disables capture and emission). After line limiting, content passes python/cli.py redact tmpdir-paths then python/cli.py redact secrets, then a fixed 5120-byte cap (python/agents.py). Sidecars (${output}.stderr-tail) are written only on failure/timeout paths; successful runs remove stale sidecars. Collector batch dedup collapses identical root-cause signatures to one full tail plus suppression lines; --summary-only collector calls skip §3.8 emission so waterfall phase collects do not false-alarm. Committed larch-logs/ may include publishable *.stderr-tail artifacts when design/implement publish runs copy them; those files receive the same dual redaction at publish time, are additionally scrubbed by python/cli.py redact scrub-log-secrets before each flush, and — now that the blanket larch-logs/ gitleaks exclusion is removed — are scanned by gitleaks Layers 1–2 like any other path. Treat run logs as sensitive regardless of redaction; the pre-flush scrubber (which, unlike gitleaks, covers Cursor crsr_/key_ keys), not the scanner, is the authoritative backstop for stderr-tail content.
Vendor failure-diagnostics batch (*.failure-diag / vendor-failure-diagnostics) (#3713): Each failing vendor-agent invocation (Codex/Cursor/Claude CI or implement launchers) composes a per-slot ${output}.failure-diag carrier from available diagnostic sources (sidecar history, events, diag, stderr, launcher stderr) via write_failure_diag in python/agents.py. append_vendor_failure_diagnostics redacts (secrets → <REDACTED-TOKEN>; tmpdir paths via python/cli.py redact tmpdir-paths) and stages the result as a part under $IMPLEMENT_TMPDIR/vendor-failure-diagnostics.parts/. At /implement Step 7a pre-ship, scripts/flush-vendor-failure-diagnostics.sh concatenates all parts into $IMPLEMENT_TMPDIR/vendor-failure-diagnostics.txt and commits it as the vendor-failure-diagnostics larch-log batch. The batch is then part of the committed run-log artifact pushed with the PR and scanned by gitleaks Layers 1–2. Content caps: the carrier byte cap is defined by vendor_failure_diag_byte_cap (defaults to 16384 bytes per slot); the batch itself has no additional cap beyond this per-slot limit. The *.failure-diag carrier suffix is denied in the per-output write-round staging path (run-log allowlist) and in design-log-publish.sh; only the composed vendor-failure-diagnostics.txt batch reaches committed logs. Runs that bail before Step 7a — e.g., Step 2 dispatcher stall, Step 5 review stall — may not flush this batch; diagnostic parts then stay in the session tmpdir and are removed at Step 18 cleanup. The research validation lane (skills/research/references/validation-phase.md) uses python/cli.py agent run-external-agent directly and has no flush path for this batch; validation-lane failure diagnostics are session-tmpdir-only.
Residual risk: research reports may contain security-sensitive findings, internal architecture details, vulnerability assessments, or references to private infrastructure. Operators running /research against security-sensitive codebases should use --no-issue or review the generated issue after creation.
Transitive callers: python/cli.py eval research passes --no-issue to suppress auto-issue when /research is invoked as an intermediate step rather than a user-facing research task.
implement-finalize postbump session-local inputs: /implement Step 8 invokes python3 python/cli.py implement-finalize postbump with a session-local state file under $IMPLEMENT_TMPDIR. Phase 1 removed per-PR bump and changelog inputs from this path; postbump no longer reads bump reasoning or fallback changelog bullet files. The remaining state is limited to branch, issue, repo, fork, and version-placeholder keys needed to run the Step 8b rebase plus force-push gate. The state file follows the same no-source parsing, tmpdir containment, symlink rejection, and size guards documented for finalize state files.
Breadcrumb streams cross from session-local runtime state into durable logs only through the redaction and publication path described here.
- Session breadcrumb directories are publication hints only: session-tmpdir
breadcrumbs/paths ($IMPLEMENT_TMPDIR/breadcrumbs/,$DESIGN_TMPDIR/breadcrumbs/,$REVIEW_TMPDIR/breadcrumbs/, or$RESEARCH_TMPDIR/breadcrumbs/) are hints only; committed publication stages matchinglarch-quiet-<script>-<pid>.logquiet logs from the session root, not live runtime streams under those directories. Legacy*.ndjsonstream files and other non-quiet-log artifacts stay session-local. - Committed copies are routed through
run-log commitanddesign-log-publish.sh: both entrypoints invoke the sharedlarch_log_publish_breadcrumbs_sharedhelper inscripts/lib-run-log. The helper stages each accepted source file throughredact tmpdir-paths | redact secrets --streaming --state-file <tmp>, then concatenates all redacted output into a singlelarch-logs/<skill>/<run-id>/breadcrumbs/quiet.logfile (with per-source header lines=== <basename> ===) rather than publishing individual files. The staging and final atomic directory swap prevent partial publication. Source-directory resolution usesLARCH_BREADCRUMB_SOURCE_DIRwhen set (must still pass session-tmpdir containment), else the log-root parent'sbreadcrumbs/. Quiet-log sources are derived viadirnameof that breadcrumbs path and are staged independently of whetherbreadcrumbs/exists. Each quiet-log candidate must stay under the session tmpdir, must not be a symlink, and must not be a hardlink. A source hint outside the active session tmpdir is treated as a no-op: breadcrumb staging is skipped and the helper returns success without creating, replacing, or clearing the committed destination. Missing sources, empty sources, or sources whose entries are all silently skipped are successful no-ops and do not create, replace, or clear an existing committedbreadcrumbs/destination. - What the helper enforces vs. silently skips: publication is
directory-level fail-closed on enforced triggers; no partial publication occurs
on any enforced reject.
- Rejected (whole helper returns 1; staging removed; destination not
created or replaced): source directory not absolute, source directory or any
candidate file outside
IMPLEMENT/DESIGN/REVIEW/RESEARCH_TMPDIRvialarch_log_breadcrumbs_under_session_tmp, source directory itself a symlink, source path exists but is not a directory, an existing file entry is a symlink, an entry has hardlink count greater than 1, an accepted quiet-log basename contains//../ leading dot, or the redactor pipe exits non-zero on any accepted file. - Silently ignored (not rejected, not committed): legacy
*.ndjsonfiles underbreadcrumbs/, hidden monitor sidecars (.bc-offset,.quiet,.done,.status,.surfaced,.pid), non-existent race-condition globs, non-regular files, and quiet-log candidates outsidelarch-quiet-*-*.logbasenames.
- Rejected (whole helper returns 1; staging removed; destination not
created or replaced): source directory not absolute, source directory or any
candidate file outside
- Residual sensitive-content risk: redaction is pattern-based (recognized
PEM blocks, common token shapes like
sk-*,crsr_(Cursor API keys),ghp_, JWTs, session-tmpdir paths). Reviewer-supplied non-pattern secrets, partial token fragments that fall outside recognized patterns, internal hostnames, PII, and domain-specific sensitive strings can still survive into committed logs. Operationalwait-ci/warnbreadcrumb text may still be committed after secrets-family redaction, so CI failure strings, check names, and similar diagnostics should also be treated as public-boundary content. Operators must avoid placing such content in breadcrumb messages; redaction is a backstop, not a comprehensive classifier.
See docs/run-logs.md § breadcrumbs/ for the
operator-facing directory contract; the same helper applies to every skill
(/implement, /design, /review, /research) that publishes via
larch_log_publish_breadcrumbs_shared.
Any shell-script call site that matches a literal value (label name, comment marker, edge identifier) MUST use a fixed-string matcher — grep -F family or awk with index()/field-equality — when the searched-for value is interpolated from a variable. Using BRE/ERE matchers (grep -E, grep default) on interpolated values is a regex-injection class: a value containing regex metacharacters (., [], (, ^, $, |, *, +, ?, {}) can over-match, fail closed unexpectedly, or shift match semantics in ways the call site does not anticipate. The -x (whole-line) and -q (quiet) flags are independent and may be combined with -F.
Sites currently following the doctrine (closes #775; closes #743 as duplicate):
python/issue_create.py— label-existence probe:gh label list ... --jq '.[].name'is checked by exact string membership. Operator-supplied and caller-forwarded--labelvalues reach this probe verbatim. A label likebug.featureorrelease[2026]cannot false-match a regex-interpretation sibling.
Sites NOT a regex-injection concern (regex pattern is a literal, only data is variable): static patterns like ^[1-9][0-9]*$ for input validation are fine — the regex is hardcoded in the script, only the data being matched is variable. Only call sites that interpolate variable values INTO the pattern are the doctrine's concern.
Edits to call sites that look like grep -E "...${VAR}..." or grep "...${VAR}..." MUST include a same-PR review for whether the value can carry regex metacharacters; if yes, switch to grep -F, awk index(), or awk field-equality, preserving any structural anchors (line-start, whole-line) via the chosen primitive's idiomatic equivalent. Test-coverage convention: pin the regex-metacharacter regression in the closest test harness so the doctrine becomes a CI-enforced invariant, not a one-off code-review.
Verdict sidecars and result envs with model-derived fields are parsed as literal fixed-key data, never sourced or evaluated. The assessor verdict headline and QUALIFICATIONS_SUMMARY are untrusted display data rendered by the driver with bounded, sanitized output. Thin-fence orchestrators treat driver display output as data, never instructions. Assessor display is neutralized before emit when it exactly matches trusted trailer marker/KV syntax.
/design auto-reporting has Tier A and Tier B surfaces. Tier B is bounded narrative only and includes no log tails. Raw design plan text, issue bodies, feature text, logs, paths, repo names, URLs, and source-env.sh are sensitive.
Generic helper path confinement applies to $DESIGN_TMPDIR, and every generic helper call from /design pins --implement-tmpdir "$DESIGN_TMPDIR". Cross-repo filing can publish to the upstream larch repository under the operator identity.
Step 3 panel degradation is non-terminal and must not leak raw review artifacts into Tier B. Step 2b.5 decompose-panel retry exhaustion is terminal failed-judge-panel and still uses bounded, redacted Tier B evidence. Residual risk remains that deterministic root-cause templates may misclassify nuanced failures.
The terminal shared Bash libraries are retired. Retained Bash trust boundaries are hooks, pre-commit and CI lint glue, thin Python CLI wrappers, scripts/sleep-seconds.sh, the combine-issues helper, and residual harnesses.
deny-edit-write.sh emits /research deny JSON through a local FD-3 hook_emit. sessionstart-health.sh emits SessionStart advisories through hook_emit when quiet setup is active and falls back to stdout when stripped PATH skips quiet setup. hook-stop-fail-close.sh emits Stop decision:block JSON through its own local FD-3 hook_emit.