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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 37 additions & 1 deletion crosslink/resources/container/entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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) ---
Expand Down
70 changes: 70 additions & 0 deletions crosslink/src/commands/kickoff/helpers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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).
Expand Down
35 changes: 35 additions & 0 deletions crosslink/src/commands/kickoff/launch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -623,6 +629,7 @@ pub(super) fn launch_container(
model: &str,
allowed_tools: &str,
timeout: Duration,
protected_doc_rel: Option<&Path>,
) -> Result<String> {
let runtime_cmd = match runtime {
ContainerMode::Docker => "docker",
Expand Down Expand Up @@ -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());
Expand Down
42 changes: 42 additions & 0 deletions crosslink/src/commands/kickoff/monitor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down Expand Up @@ -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 => {
Expand Down
36 changes: 36 additions & 0 deletions crosslink/src/commands/kickoff/prompt.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 <path>` 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
Expand Down Expand Up @@ -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 <path>` 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/<slug>.plan.json` and renders estimated subtasks, assumptions,
Expand Down
72 changes: 71 additions & 1 deletion crosslink/src/commands/kickoff/run.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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)?;

Expand Down Expand Up @@ -212,6 +222,7 @@ pub fn run(
opts.model,
&allowed_tools,
opts.timeout,
protected_doc_rel.as_deref(),
)?;

if opts.quiet {
Expand Down Expand Up @@ -246,3 +257,62 @@ pub fn run(

Ok(compact_name)
}

/// Resolve a `--doc <path>` 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<PathBuf> {
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(())
}
Loading
Loading