From 8fd3c72dabce1c82f91f5bbb83ce81bf7e522f6c Mon Sep 17 00:00:00 2001 From: Doll Date: Mon, 11 May 2026 12:26:11 -0500 Subject: [PATCH 1/2] fix(kickoff): macOS Keychain auth fallback + protect --doc from agent rewrites (#729, GH#580) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Bug 1 — macOS Keychain auth gap The container entrypoint only handled `/host-auth/.credentials.json`. On macOS the claude CLI keeps OAuth in Keychain, so the file doesn't exist and the agent boots unauthenticated. - `entrypoint.sh` now resolves auth via a fallback chain: credentials.json → `/host-auth/*.env` (sourced) → `CLAUDE_CODE_OAUTH_TOKEN` / `ANTHROPIC_API_KEY` env passthrough. Emits a clear warning to stderr when no source resolves instead of failing silently. - `launch_container` forwards those two env vars to the runtime via `-e NAME` (no value), so tokens stay out of `ps` listings. ## Bug 2 — agent rewrote the design doc passed via --doc The kickoff agent edited the canonical design file mid-run, dropping OPEN-question markers and reshaping headings. Defense in depth: - Prompt: KICKOFF.md gains a "Design Document — Canonical Input" stanza when `--doc` is supplied. - chmod 0444 on the worktree copy of the doc at launch time. - Container `:ro` overlay mount of the doc on top of the writable workspace mount. - Launch-time breadcrumb (`.kickoff-doc.json`) records rel_path + SHA-256; `crosslink kickoff status` / `report` re-hashes on the way out and emits a Mismatch line + tracing::error! on drift. ## Tests 5 new unit tests cover the integrity verdict matrix (NotProtected / Match / Mismatch / Missing-deleted / Missing-malformed). Existing exclude-pattern tests updated for the new `.kickoff-doc.json` entry. Full suite: 2808 passed, 0 failed. Co-Authored-By: Claude Opus 4.7 (1M context) --- crosslink/resources/container/entrypoint.sh | 38 ++++++- crosslink/src/commands/kickoff/helpers.rs | 70 +++++++++++++ crosslink/src/commands/kickoff/launch.rs | 35 +++++++ crosslink/src/commands/kickoff/monitor.rs | 42 ++++++++ crosslink/src/commands/kickoff/prompt.rs | 36 +++++++ crosslink/src/commands/kickoff/run.rs | 73 +++++++++++++- crosslink/src/commands/kickoff/tests.rs | 106 +++++++++++++++++++- crosslink/src/commands/kickoff/types.rs | 16 +++ 8 files changed, 412 insertions(+), 4 deletions(-) diff --git a/crosslink/resources/container/entrypoint.sh b/crosslink/resources/container/entrypoint.sh index 2c441d16a..f8cf43f46 100644 --- a/crosslink/resources/container/entrypoint.sh +++ b/crosslink/resources/container/entrypoint.sh @@ -16,12 +16,48 @@ if [ -n "${HOST_UID:-}" ] && [ "$(id -u agent)" != "$HOST_UID" ]; then fi # --- Auth setup --- -# Copy credentials from read-only host mount into writable config dir. +# Resolve auth in priority order so macOS hosts (which keep claude OAuth in +# Keychain rather than ~/.claude/.credentials.json) can still hand a token +# to the container. See GH#580. mkdir -p /home/agent/.claude +AUTH_RESOLVED="" + +# 1. Credentials file from host mount (Linux claude CLI: ~/.claude/.credentials.json). if [ -f /host-auth/.credentials.json ]; then cp /host-auth/.credentials.json /home/agent/.claude/.credentials.json chown agent:agent /home/agent/.claude/.credentials.json chmod 600 /home/agent/.claude/.credentials.json + AUTH_RESOLVED="credentials-file" +fi + +# 2. Env files from host mount. Any `/host-auth/*.env` is sourced so callers can +# drop a `kickoff.env` containing CLAUDE_CODE_OAUTH_TOKEN / ANTHROPIC_API_KEY. +shopt -s nullglob +for env_file in /host-auth/*.env; do + # shellcheck disable=SC1090 + set -a + source "$env_file" + set +a + if [ -z "$AUTH_RESOLVED" ]; then + AUTH_RESOLVED="env-file:$(basename "$env_file")" + fi +done +shopt -u nullglob + +# 3. Direct env passthrough — `crosslink kickoff run` forwards these via `-e` +# when set on the host, so an `export CLAUDE_CODE_OAUTH_TOKEN=...` on macOS +# flows through without needing any on-disk file. +if [ -n "${CLAUDE_CODE_OAUTH_TOKEN:-}" ] || [ -n "${ANTHROPIC_API_KEY:-}" ]; then + AUTH_RESOLVED="${AUTH_RESOLVED:-env-passthrough}" +fi + +if [ -z "$AUTH_RESOLVED" ]; then + echo "[crosslink-entrypoint] WARNING: no auth source resolved." >&2 + echo " Tried: /host-auth/.credentials.json, /host-auth/*.env, CLAUDE_CODE_OAUTH_TOKEN, ANTHROPIC_API_KEY." >&2 + echo " On macOS (OAuth stored in Keychain), export CLAUDE_CODE_OAUTH_TOKEN on the host" >&2 + echo " before running 'crosslink kickoff run', or drop it into ~/.claude/kickoff.env." >&2 +else + echo "[crosslink-entrypoint] Auth resolved via: $AUTH_RESOLVED" fi # --- Git config (written to agent's home as root, owned by agent) --- diff --git a/crosslink/src/commands/kickoff/helpers.rs b/crosslink/src/commands/kickoff/helpers.rs index fea7dce4b..ab403ac04 100644 --- a/crosslink/src/commands/kickoff/helpers.rs +++ b/crosslink/src/commands/kickoff/helpers.rs @@ -303,6 +303,7 @@ pub(crate) const KICKOFF_EXCLUDE_PATTERNS: &[&str] = &[ ".kickoff-status", ".kickoff-slug", ".kickoff-metadata.json", + ".kickoff-doc.json", "PLAN_KICKOFF.md", ".kickoff-plan.json", ".kickoff-criteria.json", @@ -317,6 +318,75 @@ pub(crate) fn missing_exclude_patterns(existing_content: &str) -> Vec<&'static s .collect() } +/// Outcome of comparing the on-disk design doc against the launch-time hash. +/// +/// Returned by [`verify_protected_doc`]; consumed by `monitor::report` / +/// `monitor::status` so they can warn loudly when the agent rewrote the +/// canonical input it was given via `--doc`. See GH#580. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum DocIntegrity { + /// No `.kickoff-doc.json` breadcrumb — `--doc` wasn't used. Nothing to check. + NotProtected, + /// SHA-256 of the current file matches the recorded launch-time hash. + Match { rel_path: String }, + /// The current file's SHA-256 differs from the recorded hash. The agent + /// — or some other writer — modified the canonical design doc. + Mismatch { + rel_path: String, + expected: String, + actual: String, + }, + /// The breadcrumb exists but the on-disk doc has gone missing or could + /// not be read. Indicates an outright deletion or rename. + Missing { rel_path: String, reason: String }, +} + +/// Compare the worktree's design doc against the hash recorded at launch. +/// +/// Reads `.kickoff-doc.json` (written by `kickoff run` when `--doc` was used), +/// re-hashes the file it points at, and returns a structured verdict. Any I/O +/// or parse failure short of "breadcrumb missing entirely" surfaces as +/// `DocIntegrity::Missing` so callers can render a clear message. +pub(crate) fn verify_protected_doc(worktree_dir: &Path) -> DocIntegrity { + let breadcrumb_path = worktree_dir.join(".kickoff-doc.json"); + let Ok(raw) = std::fs::read_to_string(&breadcrumb_path) else { + return DocIntegrity::NotProtected; + }; + let breadcrumb: KickoffDocBreadcrumb = match serde_json::from_str(&raw) { + Ok(b) => b, + Err(e) => { + return DocIntegrity::Missing { + rel_path: ".kickoff-doc.json".to_string(), + reason: format!("malformed breadcrumb: {e}"), + }; + } + }; + + let doc_path = worktree_dir.join(&breadcrumb.rel_path); + let content = match std::fs::read_to_string(&doc_path) { + Ok(c) => c, + Err(e) => { + return DocIntegrity::Missing { + rel_path: breadcrumb.rel_path, + reason: format!("cannot read on-disk doc: {e}"), + }; + } + }; + let actual = super::pipeline::compute_doc_hash(&content); + + if actual == breadcrumb.doc_hash { + DocIntegrity::Match { + rel_path: breadcrumb.rel_path, + } + } else { + DocIntegrity::Mismatch { + rel_path: breadcrumb.rel_path, + expected: breadcrumb.doc_hash, + actual, + } + } +} + /// Derive a tmux session name from a compact name (or legacy slug). /// /// New format: uses the compact name directly (already ≤64 chars). diff --git a/crosslink/src/commands/kickoff/launch.rs b/crosslink/src/commands/kickoff/launch.rs index 87bbbb12b..e175dc1d8 100644 --- a/crosslink/src/commands/kickoff/launch.rs +++ b/crosslink/src/commands/kickoff/launch.rs @@ -615,6 +615,12 @@ pub(super) fn launch_local( } /// Launch the agent in a Docker or Podman container. +/// +/// `protected_doc_rel`, when `Some`, is the worktree-relative path of the design +/// document passed via `--doc`. It is overlay-bind-mounted read-only on top of +/// the writable workspace mount so the agent physically cannot edit the +/// canonical design input. See GH#580. +#[allow(clippy::too_many_arguments)] pub(super) fn launch_container( runtime: &ContainerMode, worktree_dir: &Path, @@ -623,6 +629,7 @@ pub(super) fn launch_container( model: &str, allowed_tools: &str, timeout: Duration, + protected_doc_rel: Option<&Path>, ) -> Result { let runtime_cmd = match runtime { ContainerMode::Docker => "docker", @@ -686,6 +693,34 @@ pub(super) fn launch_container( ]); } + // Forward Claude auth env vars from the host when set. Using the + // `-e NAME` form (no value) tells the runtime to pull the value from + // the parent process env, so tokens don't appear in `ps`. macOS hosts + // — where the Keychain holds the OAuth credential rather than + // `~/.claude/.credentials.json` — rely on this passthrough. See GH#580. + for var in ["CLAUDE_CODE_OAUTH_TOKEN", "ANTHROPIC_API_KEY"] { + if std::env::var(var).is_ok_and(|v| !v.is_empty()) { + args.push("-e".to_string()); + args.push(var.to_string()); + } + } + + // Overlay-bind the design doc read-only so the agent cannot rewrite the + // canonical `--doc` input. Mounting a single file on top of a writable + // parent mount is supported by both docker and podman. See GH#580. + if let Some(rel) = protected_doc_rel { + let host_doc = worktree_dir.join(rel); + if host_doc.is_file() { + let container_path = format!("/workspaces/repo/{}", rel.display()); + args.push("-v".to_string()); + args.push(format!( + "{}:{}:ro", + host_doc.to_string_lossy(), + container_path + )); + } + } + // Image and command args.push(image.to_string()); args.push("bash".to_string()); diff --git a/crosslink/src/commands/kickoff/monitor.rs b/crosslink/src/commands/kickoff/monitor.rs index 6032b5a06..8d5a59893 100644 --- a/crosslink/src/commands/kickoff/monitor.rs +++ b/crosslink/src/commands/kickoff/monitor.rs @@ -114,9 +114,40 @@ pub fn status(crosslink_dir: &Path, agent: &str) -> Result<()> { } } + // Surface design-doc integrity. If `--doc` was used at launch we recorded + // a SHA-256 in `.kickoff-doc.json`; comparing it to the on-disk file + // catches the case where the agent rewrote the canonical input. GH#580. + print_doc_integrity(&worktree_dir); + Ok(()) } +/// Print a one-line summary of design-doc integrity status. +/// +/// Silent when `--doc` wasn't used (no `.kickoff-doc.json` breadcrumb). +/// Otherwise prints a Doc-integrity line — green-ish for match, an explicit +/// warning header for mismatch or missing-doc cases. +fn print_doc_integrity(worktree_dir: &Path) { + match verify_protected_doc(worktree_dir) { + DocIntegrity::NotProtected => {} + DocIntegrity::Match { rel_path } => { + println!("Doc: {rel_path} (sha256 matches launch hash)"); + } + DocIntegrity::Mismatch { + rel_path, + expected, + actual, + } => { + println!("Doc: {rel_path} — MISMATCH: canonical design doc was modified"); + println!(" expected {expected}"); + println!(" actual {actual}"); + } + DocIntegrity::Missing { rel_path, reason } => { + println!("Doc: {rel_path} — UNAVAILABLE: {reason}"); + } + } +} + /// Discover all kickoff agents by scanning worktrees, tmux sessions, and Docker containers. /// /// Shared discovery logic used by both `list` and `cleanup`. @@ -805,6 +836,17 @@ pub fn report(crosslink_dir: &Path, agent: &str, format: ReportFormat) -> Result for w in validate_kickoff_report(&r) { tracing::warn!("{}", w); } + // Surface design-doc integrity (GH#580) alongside report warnings. + if let DocIntegrity::Mismatch { + rel_path, + expected, + actual, + } = verify_protected_doc(&worktree_dir) + { + tracing::error!( + "design doc {rel_path} was modified during the run (expected {expected}, actual {actual})" + ); + } print!("{}", format_report_table(&r)); } ReportFormat::Markdown => { diff --git a/crosslink/src/commands/kickoff/prompt.rs b/crosslink/src/commands/kickoff/prompt.rs index 0ce7e5954..d9141ed11 100644 --- a/crosslink/src/commands/kickoff/prompt.rs +++ b/crosslink/src/commands/kickoff/prompt.rs @@ -263,6 +263,13 @@ these, ask the user to run it manually: if let Some(escalation) = super::super::design_doc::build_open_questions_escalation(doc) { prompt.push_str(&escalation); } + // When the doc came from an on-disk path (i.e. `--doc ` rather + // than an inline description), state plainly that the file is + // canonical input and must not be edited. Pairs with the chmod 0444 + // + read-only bind mount applied by `kickoff run`. See GH#580. + if let Some(path) = opts.doc_path { + prompt.push_str(&build_canonical_doc_stanza(path)); + } } // Inject plan context if a prior gap analysis exists for this design doc @@ -295,6 +302,35 @@ these, ask the user to run it manually: prompt } +/// Build the "## Design Document — Canonical Input" stanza. +/// +/// Surfaced in KICKOFF.md whenever `--doc ` is provided so the agent is +/// told, in-prompt, that the design file is read-only input. The file system +/// also gets chmod 0444 and (in container mode) a read-only bind mount — +/// this stanza is the prompt-level leg of that defense. See GH#580. +fn build_canonical_doc_stanza(doc_path: &str) -> String { + format!( + r" +## Design Document — Canonical Input + +The design document at `{doc_path}` was passed via `--doc` and is **canonical, +read-only input** to this kickoff run. + +- **Do not edit** this file. Its sections, headings, OPEN-question markers, + and surrounding prose are deliberate and may be referenced by reviewers. +- The full content is already inlined above; you do not need to re-read the + file to act on it. +- The file is mounted read-only inside the container (and chmod'd 0444 in + the worktree). A post-run SHA-256 check compares the on-disk hash to a + launch-time snapshot; mismatches will be flagged in `crosslink kickoff + report` / `status`. +- If you believe the design needs to change, surface the proposed delta in + your final report or in a crosslink comment on the issue. Do not rewrite + the source. +" + ) +} + /// Build a "## Plan Context" section from a prior gap analysis JSON file. /// /// Reads `.design/.plan.json` and renders estimated subtasks, assumptions, diff --git a/crosslink/src/commands/kickoff/run.rs b/crosslink/src/commands/kickoff/run.rs index 3b5b18787..08f95f3fc 100644 --- a/crosslink/src/commands/kickoff/run.rs +++ b/crosslink/src/commands/kickoff/run.rs @@ -1,6 +1,6 @@ // E-ana tablet — kickoff run: main entry point for `crosslink kickoff run` use anyhow::{bail, Context, Result}; -use std::path::Path; +use std::path::{Path, PathBuf}; use crate::db::Database; use crate::shared_writer::SharedWriter; @@ -135,6 +135,16 @@ pub fn run( .context("Failed to write .kickoff-metadata.json")?; } + // 6d. Protect the canonical design doc passed via `--doc` from agent edits. + // Writes a `.kickoff-doc.json` breadcrumb (consumed by post-run + // validation in monitor::report) and applies chmod 0444 so even + // non-container kickoffs flag accidental rewrites. The container + // mode adds a read-only overlay mount on top. See GH#580. + let protected_doc_rel = resolve_worktree_relative_doc(opts.doc_path, &root); + if let Some(rel) = protected_doc_rel.as_deref() { + protect_design_doc(&worktree_dir, rel)?; + } + // 7. Exclude kickoff files from git exclude_kickoff_files(&worktree_dir)?; @@ -212,6 +222,7 @@ pub fn run( opts.model, &allowed_tools, opts.timeout, + protected_doc_rel.as_deref(), )?; if opts.quiet { @@ -246,3 +257,63 @@ pub fn run( Ok(compact_name) } + +/// Resolve a `--doc ` CLI argument to a path relative to the repo root. +/// +/// Returns `None` when the doc lies outside the repo or cannot be canonicalized +/// (e.g. the user passed a path that doesn't exist on disk yet). The container +/// `:ro` mount and the breadcrumb both need the worktree-relative form because +/// the worktree mirrors the repo's directory structure. +fn resolve_worktree_relative_doc(doc_path: Option<&str>, repo_root: &Path) -> Option { + let raw = doc_path?; + let candidate = Path::new(raw); + let absolute = if candidate.is_absolute() { + candidate.to_path_buf() + } else { + std::env::current_dir().ok()?.join(candidate) + }; + let canonical = absolute.canonicalize().ok()?; + let canonical_root = repo_root.canonicalize().ok()?; + canonical + .strip_prefix(&canonical_root) + .ok() + .map(Path::to_path_buf) +} + +/// Stage the design doc as a protected canonical input inside the worktree. +/// +/// Writes `.kickoff-doc.json` (so post-run validation can detect drift) and +/// applies chmod 0444 to the doc itself. Both steps are best-effort: if the +/// worktree doesn't carry the doc yet — e.g. fresh design that wasn't +/// committed — there's nothing to protect and we return Ok(()). +fn protect_design_doc(worktree_dir: &Path, rel: &Path) -> Result<()> { + let worktree_doc = worktree_dir.join(rel); + if !worktree_doc.is_file() { + return Ok(()); + } + + let content = std::fs::read_to_string(&worktree_doc) + .with_context(|| format!("Failed to read design doc at {}", worktree_doc.display()))?; + let doc_hash = super::pipeline::compute_doc_hash(&content); + + let breadcrumb = KickoffDocBreadcrumb { + rel_path: rel.to_string_lossy().into_owned(), + doc_hash, + }; + let json = serde_json::to_string_pretty(&breadcrumb) + .context("Failed to serialize kickoff doc breadcrumb")?; + std::fs::write(worktree_dir.join(".kickoff-doc.json"), json) + .context("Failed to write .kickoff-doc.json")?; + + // chmod 0444 is advisory — a determined agent can flip it back — but it + // pairs with the KICKOFF.md instruction and the post-run hash check to + // make accidental rewrites loud rather than silent. + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let _ = + std::fs::set_permissions(&worktree_doc, std::fs::Permissions::from_mode(0o444)); + } + + Ok(()) +} diff --git a/crosslink/src/commands/kickoff/tests.rs b/crosslink/src/commands/kickoff/tests.rs index 50e0f457f..cde07788f 100644 --- a/crosslink/src/commands/kickoff/tests.rs +++ b/crosslink/src/commands/kickoff/tests.rs @@ -380,6 +380,7 @@ fn test_missing_exclude_patterns_empty_file() { ".kickoff-status", ".kickoff-slug", ".kickoff-metadata.json", + ".kickoff-doc.json", "PLAN_KICKOFF.md", ".kickoff-plan.json", ".kickoff-criteria.json", @@ -403,7 +404,7 @@ fn test_missing_exclude_patterns_one_present() { #[test] fn test_missing_exclude_patterns_all_present() { let patterns = missing_exclude_patterns( - "KICKOFF.md\n.kickoff-status\n.kickoff-slug\n.kickoff-metadata.json\nPLAN_KICKOFF.md\n.kickoff-plan.json\n.kickoff-criteria.json\n.kickoff-report.json\n", + "KICKOFF.md\n.kickoff-status\n.kickoff-slug\n.kickoff-metadata.json\n.kickoff-doc.json\nPLAN_KICKOFF.md\n.kickoff-plan.json\n.kickoff-criteria.json\n.kickoff-report.json\n", ); assert!(patterns.is_empty()); } @@ -411,11 +412,112 @@ fn test_missing_exclude_patterns_all_present() { #[test] fn test_missing_exclude_patterns_with_whitespace() { let patterns = missing_exclude_patterns( - " KICKOFF.md \n .kickoff-status \n .kickoff-slug \n .kickoff-metadata.json \n PLAN_KICKOFF.md \n .kickoff-plan.json \n .kickoff-criteria.json \n .kickoff-report.json \n", + " KICKOFF.md \n .kickoff-status \n .kickoff-slug \n .kickoff-metadata.json \n .kickoff-doc.json \n PLAN_KICKOFF.md \n .kickoff-plan.json \n .kickoff-criteria.json \n .kickoff-report.json \n", ); assert!(patterns.is_empty()); } +// ==================== Design-doc integrity (GH#580) ==================== + +#[test] +fn test_verify_protected_doc_not_protected_without_breadcrumb() { + let tmp = tempfile::tempdir().unwrap(); + // No .kickoff-doc.json present → NotProtected. + assert!(matches!( + verify_protected_doc(tmp.path()), + DocIntegrity::NotProtected + )); +} + +#[test] +fn test_verify_protected_doc_match_on_unchanged_doc() { + let tmp = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(tmp.path().join(".design")).unwrap(); + let doc_rel = ".design/foo.md"; + let body = "# Foo design\n\nContents.\n"; + std::fs::write(tmp.path().join(doc_rel), body).unwrap(); + + let breadcrumb = KickoffDocBreadcrumb { + rel_path: doc_rel.to_string(), + doc_hash: super::pipeline::compute_doc_hash(body), + }; + std::fs::write( + tmp.path().join(".kickoff-doc.json"), + serde_json::to_string(&breadcrumb).unwrap(), + ) + .unwrap(); + + match verify_protected_doc(tmp.path()) { + DocIntegrity::Match { rel_path } => assert_eq!(rel_path, doc_rel), + other => panic!("expected Match, got {other:?}"), + } +} + +#[test] +fn test_verify_protected_doc_mismatch_on_edited_doc() { + let tmp = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(tmp.path().join(".design")).unwrap(); + let doc_rel = ".design/foo.md"; + let original = "# Foo design\n\nOriginal contents.\n"; + let modified = "# Foo design\n\nAgent rewrote this section.\n"; + + let breadcrumb = KickoffDocBreadcrumb { + rel_path: doc_rel.to_string(), + doc_hash: super::pipeline::compute_doc_hash(original), + }; + std::fs::write( + tmp.path().join(".kickoff-doc.json"), + serde_json::to_string(&breadcrumb).unwrap(), + ) + .unwrap(); + // On-disk file diverges from the recorded hash. + std::fs::write(tmp.path().join(doc_rel), modified).unwrap(); + + match verify_protected_doc(tmp.path()) { + DocIntegrity::Mismatch { + rel_path, + expected, + actual, + } => { + assert_eq!(rel_path, doc_rel); + assert_eq!(expected, super::pipeline::compute_doc_hash(original)); + assert_eq!(actual, super::pipeline::compute_doc_hash(modified)); + } + other => panic!("expected Mismatch, got {other:?}"), + } +} + +#[test] +fn test_verify_protected_doc_missing_when_doc_deleted() { + let tmp = tempfile::tempdir().unwrap(); + let doc_rel = ".design/foo.md"; + // Write breadcrumb but never create the doc itself. + let breadcrumb = KickoffDocBreadcrumb { + rel_path: doc_rel.to_string(), + doc_hash: super::pipeline::compute_doc_hash("placeholder"), + }; + std::fs::write( + tmp.path().join(".kickoff-doc.json"), + serde_json::to_string(&breadcrumb).unwrap(), + ) + .unwrap(); + + match verify_protected_doc(tmp.path()) { + DocIntegrity::Missing { rel_path, .. } => assert_eq!(rel_path, doc_rel), + other => panic!("expected Missing, got {other:?}"), + } +} + +#[test] +fn test_verify_protected_doc_missing_on_malformed_breadcrumb() { + let tmp = tempfile::tempdir().unwrap(); + std::fs::write(tmp.path().join(".kickoff-doc.json"), "not json").unwrap(); + assert!(matches!( + verify_protected_doc(tmp.path()), + DocIntegrity::Missing { .. } + )); +} + #[test] fn test_build_allowed_tools_thorough() { let conventions = ProjectConventions { diff --git a/crosslink/src/commands/kickoff/types.rs b/crosslink/src/commands/kickoff/types.rs index c186fdbb6..7186585e7 100644 --- a/crosslink/src/commands/kickoff/types.rs +++ b/crosslink/src/commands/kickoff/types.rs @@ -61,6 +61,22 @@ pub struct KickoffMetadata { pub timeout_secs: u64, } +/// Breadcrumb written at launch when `--doc ` is supplied +/// (`.kickoff-doc.json`). +/// +/// Carries the worktree-relative path and the SHA-256 of the canonical +/// content as captured at launch time. Consumed by post-run validation in +/// `monitor::report` / `monitor::status` to detect whether the agent +/// rewrote the design doc it was supposed to treat as read-only input. +/// See GH#580. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct KickoffDocBreadcrumb { + /// Path of the protected design doc, relative to the worktree root. + pub rel_path: String, + /// `sha256:` of the design doc's content at launch time. + pub doc_hash: String, +} + /// Options for `crosslink kickoff run`. pub struct KickoffOpts<'a> { pub description: &'a str, From ca09107641306875eab058aceb699518770d8d78 Mon Sep 17 00:00:00 2001 From: Doll Date: Mon, 11 May 2026 12:31:42 -0500 Subject: [PATCH 2/2] style(kickoff): apply rustfmt to protect_design_doc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pure formatting — rejoins a let binding that fits on one line. No behavior change. Verified `cargo fmt --check` clean and `cargo clippy --lib --bin crosslink -- -D warnings` clean afterwards. Co-Authored-By: Claude Opus 4.7 (1M context) --- crosslink/src/commands/kickoff/run.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/crosslink/src/commands/kickoff/run.rs b/crosslink/src/commands/kickoff/run.rs index 08f95f3fc..6a46052cd 100644 --- a/crosslink/src/commands/kickoff/run.rs +++ b/crosslink/src/commands/kickoff/run.rs @@ -311,8 +311,7 @@ fn protect_design_doc(worktree_dir: &Path, rel: &Path) -> Result<()> { #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; - let _ = - std::fs::set_permissions(&worktree_doc, std::fs::Permissions::from_mode(0o444)); + let _ = std::fs::set_permissions(&worktree_doc, std::fs::Permissions::from_mode(0o444)); } Ok(())