From 8e6f10ca4c1bc2cf24d029e5b06dc7a13c253edf Mon Sep 17 00:00:00 2001 From: Yvette Carlisle Date: Mon, 25 May 2026 12:12:54 +0800 Subject: [PATCH] {"schema":"decodex/commit/1","summary":"Add public Linear text guard","authority":"XY-519"} --- apps/decodex/src/agent/app_server.rs | 3 +- apps/decodex/src/agent/json_rpc.rs | 2 +- apps/decodex/src/agent/tracker_tool_bridge.rs | 79 +---- .../tests/mutation/dispatch.rs | 14 +- .../tests/mutation/progress.rs | 39 +++ apps/decodex/src/manual.rs | 3 +- apps/decodex/src/orchestrator/execution.rs | 3 +- apps/decodex/src/orchestrator/prompting.rs | 4 + apps/decodex/src/orchestrator/selection.rs | 7 +- .../tests/intake/run_and_prompting.rs | 1 + .../tests/intake/workflow_reload.rs | 5 + .../tests/recovery/terminal_failures.rs | 4 +- .../src/orchestrator/tests/runtime/failure.rs | 8 +- apps/decodex/src/tracker.rs | 12 + apps/decodex/src/tracker/public_text.rs | 292 ++++++++++++++++++ apps/decodex/src/tracker/records.rs | 94 +++++- docs/spec/linear-execution-ledger.md | 16 + docs/spec/tracker-tools.md | 6 + 18 files changed, 506 insertions(+), 86 deletions(-) create mode 100644 apps/decodex/src/tracker/public_text.rs diff --git a/apps/decodex/src/agent/app_server.rs b/apps/decodex/src/agent/app_server.rs index aa42fa58d..293d4b96a 100644 --- a/apps/decodex/src/agent/app_server.rs +++ b/apps/decodex/src/agent/app_server.rs @@ -210,8 +210,7 @@ impl AppServerCapabilityPreflightFailure { pub(crate) fn terminal_next_action(&self, recovery_gate: &str) -> String { format!( - "inspect Codex app-server preflight blocker `{}`, repair the local Codex config/model/provider/skills/plugin/MCP state, restart `decodex serve`, {recovery_gate}", - self.blocker_summary() + "inspect the Codex app-server preflight status, repair the local Codex runtime configuration, restart `decodex serve`, {recovery_gate}" ) } diff --git a/apps/decodex/src/agent/json_rpc.rs b/apps/decodex/src/agent/json_rpc.rs index 525e8cb63..86b54d7be 100644 --- a/apps/decodex/src/agent/json_rpc.rs +++ b/apps/decodex/src/agent/json_rpc.rs @@ -170,7 +170,7 @@ impl AppServerHomePreflightFailure { pub(crate) fn terminal_next_action(&self, recovery_gate: &str) -> String { format!( - "inspect the local `decodex serve` HOME and app-server Codex home resolution, keep CODEX_HOME/CODEX_SQLITE_HOME shared instead of per-account, restart `decodex serve`, {recovery_gate}" + "inspect the local Decodex and Codex home sharing, restart `decodex serve`, {recovery_gate}" ) } } diff --git a/apps/decodex/src/agent/tracker_tool_bridge.rs b/apps/decodex/src/agent/tracker_tool_bridge.rs index 4bb649ed3..6f4cef0cf 100644 --- a/apps/decodex/src/agent/tracker_tool_bridge.rs +++ b/apps/decodex/src/agent/tracker_tool_bridge.rs @@ -6,7 +6,7 @@ use std::{ env, error::Error, fmt::{Display, Formatter}, - path::{Component, PathBuf}, + path::{Path, PathBuf}, process::Command, }; @@ -20,7 +20,7 @@ use crate::{ github, prelude::eyre, state::StateStore, - tracker::{IssueTracker, TrackerIssue}, + tracker::{IssueTracker, TrackerIssue, public_text}, workflow::WorkflowDocument, }; @@ -68,17 +68,14 @@ pub(crate) trait DynamicToolHandler { pub(crate) trait PullRequestInspector { fn inspect_pull_request( &self, - cwd: &std::path::Path, + cwd: &Path, pr_url: &str, github_token: &str, ) -> std::result::Result; } pub(crate) trait LocalRepoInspector { - fn inspect_local_repo( - &self, - cwd: &std::path::Path, - ) -> std::result::Result; + fn inspect_local_repo(&self, cwd: &Path) -> std::result::Result; } #[derive(Clone, Debug, Eq, PartialEq, Serialize)] @@ -509,7 +506,7 @@ struct GhPullRequestInspector; impl PullRequestInspector for GhPullRequestInspector { fn inspect_pull_request( &self, - cwd: &std::path::Path, + cwd: &Path, pr_url: &str, github_token: &str, ) -> std::result::Result { @@ -561,10 +558,7 @@ impl PullRequestInspector for GhPullRequestInspector { struct LocalGitRepoInspector; impl LocalRepoInspector for LocalGitRepoInspector { - fn inspect_local_repo( - &self, - cwd: &std::path::Path, - ) -> std::result::Result { + fn inspect_local_repo(&self, cwd: &Path) -> std::result::Result { let head_oid = run_command_for_stdout("git", &["rev-parse", "HEAD"], cwd, "inspect lane HEAD")?; let default_branch = resolve_lane_default_branch(cwd)?; @@ -910,7 +904,7 @@ fn resolve_review_handoff_github_token( fn run_command_for_stdout( command: &str, args: &[&str], - cwd: &std::path::Path, + cwd: &Path, purpose: &str, ) -> std::result::Result { let output = Command::new(command) @@ -941,7 +935,7 @@ fn run_command_for_stdout( Ok(value.to_owned()) } -fn resolve_lane_default_branch(cwd: &std::path::Path) -> std::result::Result { +fn resolve_lane_default_branch(cwd: &Path) -> std::result::Result { if let Some(default_branch) = resolve_lane_default_branch_from_local_cache(cwd)? { return Ok(default_branch); } @@ -962,7 +956,7 @@ fn resolve_lane_default_branch(cwd: &std::path::Path) -> std::result::Result std::result::Result, String> { let remote_probe = Command::new("git") .args(["ls-remote", "--symref", "origin", "HEAD"]) @@ -986,7 +980,7 @@ fn resolve_lane_default_branch_from_remote( } fn resolve_lane_default_branch_from_local_cache( - cwd: &std::path::Path, + cwd: &Path, ) -> std::result::Result, String> { let symbolic_ref = Command::new("git") .args(["symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD"]) @@ -1062,58 +1056,7 @@ fn parse_github_remote_with_authority(remote_url: &str) -> std::result::Result<& } fn validate_public_comment_body(body: &str) -> Result<(), String> { - for line in body.lines() { - let Some((field_name, value)) = extract_structured_field(line) else { - continue; - }; - - if field_name == "worktree_path" { - validate_repo_relative_path(value, field_name)?; - - continue; - } - if field_name.ends_with("_path") { - return Err(format!( - "Unsupported structured field `{field_name}` in public issue comments." - )); - } - } - - Ok(()) -} - -fn extract_structured_field(line: &str) -> Option<(&str, &str)> { - let trimmed = line.trim(); - let trimmed = trimmed.strip_prefix("- ").unwrap_or(trimmed); - let (key, value) = trimmed.split_once(':')?; - - Some((key.trim(), value.trim().trim_matches('`'))) -} - -fn validate_repo_relative_path(path: &str, field_name: &str) -> Result<(), String> { - if path.is_empty() { - return Err(format!("`{field_name}` must not be empty.")); - } - if path.starts_with('/') || path.starts_with("~/") || has_drive_root_prefix(path) { - return Err(format!("`{field_name}` must be repository-relative, not `{path}`.")); - } - - let components = std::path::Path::new(path).components(); - - if components.into_iter().any(|component| matches!(component, Component::ParentDir)) { - return Err(format!("`{field_name}` must stay within the repository, not `{path}`.")); - } - - Ok(()) -} - -fn has_drive_root_prefix(path: &str) -> bool { - let bytes = path.as_bytes(); - - bytes.len() >= 3 - && bytes[0].is_ascii_alphabetic() - && bytes[1] == b':' - && matches!(bytes[2], b'\\' | b'/') + public_text::validate_public_comment_body(body) } fn normalize_summary(summary: &str) -> String { diff --git a/apps/decodex/src/agent/tracker_tool_bridge/tests/mutation/dispatch.rs b/apps/decodex/src/agent/tracker_tool_bridge/tests/mutation/dispatch.rs index fd3805b9b..9ded72662 100644 --- a/apps/decodex/src/agent/tracker_tool_bridge/tests/mutation/dispatch.rs +++ b/apps/decodex/src/agent/tracker_tool_bridge/tests/mutation/dispatch.rs @@ -784,7 +784,19 @@ fn rejects_public_comments_with_sensitive_or_unknown_paths() { ), ( "decodex run failed and will retry\n\n- worktree_path: `C:/absolute/path/to/repo/.worktrees/DEC-1`", - "`worktree_path` must be repository-relative, not `C:/absolute/path/to/repo/.worktrees/DEC-1`.", + "`body` must be public/team-visible text; host-local paths are not allowed.", + ), + ( + "decodex run failed and will retry\n\nMissing credential GITHUB_PAT_Y.", + "`body` must be public/team-visible text; credential-like names are not allowed.", + ), + ( + "decodex run failed and will retry\n\nSelected account user@example.com.", + "`body` must be public/team-visible text; private identity details are not allowed.", + ), + ( + "decodex run failed and will retry\n\ncodex.github-identity resolved to a private route.", + "`body` must be public/team-visible text; local identity or account-routing details are not allowed.", ), ] { let tracker = FakeTracker::new(); diff --git a/apps/decodex/src/agent/tracker_tool_bridge/tests/mutation/progress.rs b/apps/decodex/src/agent/tracker_tool_bridge/tests/mutation/progress.rs index d2cf581e2..80e1f6f34 100644 --- a/apps/decodex/src/agent/tracker_tool_bridge/tests/mutation/progress.rs +++ b/apps/decodex/src/agent/tracker_tool_bridge/tests/mutation/progress.rs @@ -195,3 +195,42 @@ fn progress_checkpoint_retries_do_not_duplicate_same_ledger_event() { assert!(second.success); assert_eq!(tracker.comments.borrow().len(), 1); } + +#[test] +fn progress_checkpoint_rejects_private_public_text_before_write() { + let tracker = FakeTracker::new(); + let issue = sample_issue(); + let workflow = sample_workflow(); + let temp_dir = TempDir::new().expect("tempdir should create"); + let pull_request_inspector = FakePullRequestInspector::new(Vec::new()); + let local_repo_inspector = FakeLocalRepoInspector::new(vec![Ok(sample_local_repo())]); + let bridge = TrackerToolBridge::with_review_handoff_for_test( + &tracker, + &issue, + &workflow, + sample_review_context_in(temp_dir.path()), + &pull_request_inspector, + &local_repo_inspector, + ); + let response = DynamicToolHandler::handle_call( + &bridge, + ISSUE_PROGRESS_CHECKPOINT_TOOL_NAME, + serde_json::json!({ + "phase": "implementing", + "focus": "Inspected /Users/example/code/private checkout.", + "next_action": "Continue implementation.", + "blockers": [], + "evidence": ["Missing GITHUB_PAT_Y was observed."], + "head_sha": sample_local_repo().head_oid + }), + ); + + assert!(!response.success); + assert!( + response + .content_items + .iter() + .any(|item| matches!(item, DynamicToolContentItem::InputText { text } if text.contains("public/team-visible"))) + ); + assert!(tracker.comments.borrow().is_empty()); +} diff --git a/apps/decodex/src/manual.rs b/apps/decodex/src/manual.rs index 888e6d83e..ae59a346f 100644 --- a/apps/decodex/src/manual.rs +++ b/apps/decodex/src/manual.rs @@ -1255,7 +1255,8 @@ fn apply_closeout( branch_name, landed_change_record, )? { - prepared.tracker.create_comment( + tracker::create_public_comment( + &prepared.tracker, prepared.issue.id.as_str(), format!( "decodex land completed\n\n- pr_url: `{pr_url}`\n- merge_commit: `{merge_commit}`\n- branch: `{branch_name}`\n- landed_change: `{landed_change_record}`" diff --git a/apps/decodex/src/orchestrator/execution.rs b/apps/decodex/src/orchestrator/execution.rs index 3561e7bf3..7bb67503b 100644 --- a/apps/decodex/src/orchestrator/execution.rs +++ b/apps/decodex/src/orchestrator/execution.rs @@ -1044,7 +1044,8 @@ where "Run failed and remains retryable." ); - tracker.create_comment( + tracker::create_public_comment( + tracker, &issue_run.issue.id, &format_retry_comment(RetryComment { run_id: &issue_run.run_id, diff --git a/apps/decodex/src/orchestrator/prompting.rs b/apps/decodex/src/orchestrator/prompting.rs index 3dc2dcfd0..1fc9cb9c1 100644 --- a/apps/decodex/src/orchestrator/prompting.rs +++ b/apps/decodex/src/orchestrator/prompting.rs @@ -1,3 +1,6 @@ +pub(crate) const TRACKER_PUBLIC_TEXT_BOUNDARY_INSTRUCTION: &str = + "Tracker public text boundary\n- Linear tracker text is public/team-visible. Do not include local host paths, routed identity details, account details, credential-like names, private config paths, tokens, or secrets in issue comments, progress checkpoints, review summaries, closeout summaries, blockers, evidence, verification, failed commands, or raw errors.\n- Use public collaboration identifiers when needed: PR URLs, issue identifiers, branch names, commit SHAs, and repository-relative paths."; + const PROMPT_ONLY_INTERNAL_REVIEW_INSTRUCTION: &str = "Review your work repeatedly and fix any logic bugs until no new issues are found."; @@ -63,6 +66,7 @@ where sections.push(String::from( "Commit contract\n- When you create a local commit for this lane, use a single-line `decodex/commit/1` JSON commit message.\n- Required fields: `schema`, `summary`, and `authority`.\n- `authority` must be the authoritative Linear issue identifier for this lane.\n- Optional fields: `related` and `breaking`.\n- Do not encode landing mode, CI status, closeout state, or other process-state fields in the commit message.", )); + sections.push(String::from(TRACKER_PUBLIC_TEXT_BOUNDARY_INSTRUCTION)); if let Some(recovery_context) = build_retry_recovery_context(issue_run.dispatch_mode) { sections.push(recovery_context); diff --git a/apps/decodex/src/orchestrator/selection.rs b/apps/decodex/src/orchestrator/selection.rs index 67a888dbf..708179cde 100644 --- a/apps/decodex/src/orchestrator/selection.rs +++ b/apps/decodex/src/orchestrator/selection.rs @@ -207,14 +207,11 @@ fn terminal_failure_comment_details( "inspect the worktree and app-server activity for the stalled lane, resolve the blocker manually, {recovery_gate}" ), ) - } else if let Some(credentials_failure) = - error.downcast_ref::() - { + } else if error.downcast_ref::().is_some() { ( "github_credentials_unavailable", format!( - "configure `{}` for the routed GitHub identity, verify noninteractive Git credentials, {recovery_gate}", - credentials_failure.token_env_var + "repair GitHub authentication for this lane, verify noninteractive Git access, {recovery_gate}" ), ) } else if let Some(app_server_failure) = diff --git a/apps/decodex/src/orchestrator/tests/intake/run_and_prompting.rs b/apps/decodex/src/orchestrator/tests/intake/run_and_prompting.rs index f5701897b..bdee17ca3 100644 --- a/apps/decodex/src/orchestrator/tests/intake/run_and_prompting.rs +++ b/apps/decodex/src/orchestrator/tests/intake/run_and_prompting.rs @@ -525,6 +525,7 @@ fn developer_instructions_trim_workflow_body_and_preserve_required_guidance() { assert!(instructions.contains("Keep pre-edit discovery bounded")); assert!(instructions.contains("Do not browse upstream references")); assert!(instructions.contains("Tracker tool contract")); + assert!(instructions.contains("Linear tracker text is public/team-visible")); assert!(instructions.contains("You own issue-scoped tracker writes for `PUB-101`.")); assert!(instructions.contains("Decodex already records the run-start Linear ledger")); assert!(!instructions.contains("started work on run")); diff --git a/apps/decodex/src/orchestrator/tests/intake/workflow_reload.rs b/apps/decodex/src/orchestrator/tests/intake/workflow_reload.rs index 12d76d97a..c81f64685 100644 --- a/apps/decodex/src/orchestrator/tests/intake/workflow_reload.rs +++ b/apps/decodex/src/orchestrator/tests/intake/workflow_reload.rs @@ -1,3 +1,5 @@ +use orchestrator::TRACKER_PUBLIC_TEXT_BOUNDARY_INSTRUCTION; + #[test] fn daemon_workflow_reload_keeps_last_known_good_on_same_path_failure() { let (_temp_dir, config, workflow) = temp_project_layout(); @@ -171,6 +173,9 @@ fn expected_developer_instructions( sections.push(String::from( "Commit contract\n- When you create a local commit for this lane, use a single-line `decodex/commit/1` JSON commit message.\n- Required fields: `schema`, `summary`, and `authority`.\n- `authority` must be the authoritative Linear issue identifier for this lane.\n- Optional fields: `related` and `breaking`.\n- Do not encode landing mode, CI status, closeout state, or other process-state fields in the commit message.", )); + sections.push(String::from( + TRACKER_PUBLIC_TEXT_BOUNDARY_INSTRUCTION, + )); sections.push(format!( "Tracker tool contract\n- You own issue-scoped tracker writes for `{issue}`.\n- At the start of execution, call `{transition_tool}` to move the issue to `{in_progress}`. Decodex already records the run-start Linear ledger, so do not add a separate start comment.\n- Update `{progress_checkpoint_tool}` whenever the execution phase, focus, next action, blockers, evidence, or verification state changes materially.\n- Follow the repo-native bounded review method from `WORKFLOW.md`: review the actual current diff and branch state, run both the requirements pass and the adversarial reviewer pass, fix only the smallest coherent owned batch, rerun verification, and re-read `HEAD` before deciding the next normalized review status.\n- Every time the repo-native bounded review method produces a result for the current head, call `{review_checkpoint_tool}` with that normalized status, the exact current `HEAD` SHA, and any concise evidence items.\n- Treat failures from repo-native `canonicalize_commands`, `verify_commands`, or tracked rewrites left by that repo gate as continued repair by default: keep fixing the lane and rerun the gate instead of taking `manual_attention` unless the blocker is clearly toolchain, environment, or operator-owned.\n- When the implementation is ready, commit the lane, push branch `{branch}`, and create or update a non-draft PR titled `{pr_title}` for that branch.\n- Call `{review_handoff_tool}` only after the latest `{review_checkpoint_tool}` for this handoff phase and current `HEAD` is `clean`. Then call `{terminal_finalize_tool}` with path `review_handoff`.\n- If you determine the issue needs human attention, add label `{needs_attention}` with `{label_tool}`, explain the exact observed blocker in a comment, including the failed command and raw error when available, and then call `{terminal_finalize_tool}` with path `manual_attention`. Do not speculate about capabilities you did not directly verify. Do not call `{review_handoff_tool}` in that case; `decodex` will stop the lane as a human-required failure without automatic retry.\n- Do not move the issue directly to `{success}` with `{transition_tool}`. `decodex` will complete the success writeback only after its own validation passes.\n- Do not report the run as complete or treat `{progress_checkpoint_tool}` as terminal completion until `{terminal_finalize_tool}` succeeds.{continuation_guidance}\n- Never write to any other issue.", diff --git a/apps/decodex/src/orchestrator/tests/recovery/terminal_failures.rs b/apps/decodex/src/orchestrator/tests/recovery/terminal_failures.rs index bed138aa3..107408dd6 100644 --- a/apps/decodex/src/orchestrator/tests/recovery/terminal_failures.rs +++ b/apps/decodex/src/orchestrator/tests/recovery/terminal_failures.rs @@ -289,14 +289,14 @@ fn app_server_failures_skip_retry_and_require_attention() { "skills/list returned no enabled skills.", )), "app_server_runtime_preflight_failed", - "repair the local Codex config/model/provider/skills/plugin/MCP state", + "repair the local Codex runtime configuration", ); assert_app_server_failure_requires_attention( Report::new(AppServerHomePreflightFailure::resolution_failed(String::from( "app_server_preflight_failed: HOME is not set, so Decodex cannot resolve the shared Codex home for app-server dispatch.", ))), "app_server_codex_home_preflight_failed", - "keep CODEX_HOME/CODEX_SQLITE_HOME shared instead of per-account", + "inspect the local Decodex and Codex home sharing", ); assert_app_server_failure_requires_attention( Report::new(AppServerHomePreflightFailure::initialize_mismatch( diff --git a/apps/decodex/src/orchestrator/tests/runtime/failure.rs b/apps/decodex/src/orchestrator/tests/runtime/failure.rs index 60cd93a64..07440d05b 100644 --- a/apps/decodex/src/orchestrator/tests/runtime/failure.rs +++ b/apps/decodex/src/orchestrator/tests/runtime/failure.rs @@ -441,14 +441,14 @@ fn app_server_terminal_failures_preserve_specific_error_classes() { "configured model was not present in model/list.", )), "app_server_runtime_preflight_failed", - "repair the local Codex config/model/provider/skills/plugin/MCP state", + "repair the local Codex runtime configuration", ), ( Report::new(AppServerHomePreflightFailure::resolution_failed(String::from( "app_server_preflight_failed: HOME is not set, so Decodex cannot resolve the shared Codex home for app-server dispatch.", ))), "app_server_codex_home_preflight_failed", - "keep CODEX_HOME/CODEX_SQLITE_HOME shared instead of per-account", + "inspect the local Decodex and Codex home sharing", ), ( Report::new(AppServerHomePreflightFailure::initialize_mismatch( @@ -739,8 +739,8 @@ fn missing_agent_git_credentials_stop_without_retry() { assert_eq!(credentials_error.token_env_var, missing_env_var); assert_eq!(error_class, "github_credentials_unavailable"); - assert!(next_action.contains("configure")); - assert!(next_action.contains(&missing_env_var)); + assert!(next_action.contains("repair GitHub authentication")); + assert!(!next_action.contains(&missing_env_var)); } #[test] diff --git a/apps/decodex/src/tracker.rs b/apps/decodex/src/tracker.rs index e0ee99ea4..ef55fc502 100644 --- a/apps/decodex/src/tracker.rs +++ b/apps/decodex/src/tracker.rs @@ -1,4 +1,5 @@ pub(crate) mod linear; +pub(crate) mod public_text; pub(crate) mod records; use std::slice; @@ -229,6 +230,7 @@ where T: IssueTracker + ?Sized, { records::validate_linear_execution_event_record(record).map_err(|error| eyre::eyre!(error))?; + public_text::validate_public_comment_body(body).map_err(|error| eyre::eyre!(error))?; let comments = tracker.list_comments(issue_id)?; @@ -248,6 +250,15 @@ where Ok(true) } +pub(crate) fn create_public_comment(tracker: &T, issue_id: &str, body: &str) -> Result<()> +where + T: IssueTracker + ?Sized, +{ + public_text::validate_public_comment_body(body).map_err(|error| eyre::eyre!(error))?; + + tracker.create_comment(issue_id, body) +} + pub(crate) fn create_linear_execution_event_comment_without_remote_scan( tracker: &T, issue_id: &str, @@ -258,6 +269,7 @@ where T: IssueTracker + ?Sized, { records::validate_linear_execution_event_record(record).map_err(|error| eyre::eyre!(error))?; + public_text::validate_public_comment_body(body).map_err(|error| eyre::eyre!(error))?; let comment_body = records::append_structured_comment_record(body, record)?; diff --git a/apps/decodex/src/tracker/public_text.rs b/apps/decodex/src/tracker/public_text.rs new file mode 100644 index 000000000..6e4d7fe66 --- /dev/null +++ b/apps/decodex/src/tracker/public_text.rs @@ -0,0 +1,292 @@ +use std::path::{Component, Path}; + +const SENSITIVE_PHRASES: &[&str] = &[ + "account=", + "account_fingerprint", + "account fingerprint", + "api key", + "auth token", + "codex.github-identity", + "codex.linear-workspace", + "credential", + "github identity", + "github-identity", + "linear workspace", + "linear-workspace", + "process_start_identity", + "routed identity", + "selected account", + "token=", +]; +const HOST_PATH_PREFIXES: &[&str] = &[ + "/home/", + "/private/", + "/root/", + "/tmp/", + "/users/", + "/var/folders/", + "/volumes/", + "file:///", +]; +const CREDENTIAL_MARKERS: &[&str] = &[ + "API_KEY", + "AUTH_JSON", + "CREDENTIAL", + "GITHUB_PAT", + "LINEAR_API_KEY", + "PASSWD", + "PASSWORD", + "SECRET", + "TOKEN", +]; +const PRIVATE_CONFIG_FILES: &[&str] = &["auth.json", "accounts.jsonl"]; +const PRIVATE_ENV_VAR_TOKENS: &[&str] = &["CODEX_HOME", "CODEX_SQLITE_HOME"]; + +pub(crate) fn validate_public_text_field(field_name: &str, value: &str) -> Result<(), String> { + if let Some(reason) = public_text_violation(value) { + return Err(format!("`{field_name}` must be public/team-visible text; {reason}.")); + } + + Ok(()) +} + +pub(crate) fn validate_public_text_items( + field_name: &str, + values: &[String], +) -> Result<(), String> { + for value in values { + validate_public_text_field(field_name, value)?; + } + + Ok(()) +} + +pub(crate) fn validate_public_comment_body(body: &str) -> Result<(), String> { + validate_public_text_field("body", body)?; + + for line in body.lines() { + let Some((field_name, value)) = extract_structured_field(line) else { + continue; + }; + + if field_name == "worktree_path" { + validate_repo_relative_path(value, field_name)?; + + continue; + } + if field_name.ends_with("_path") { + return Err(format!( + "Unsupported structured field `{field_name}` in public issue comments." + )); + } + } + + Ok(()) +} + +fn public_text_violation(value: &str) -> Option<&'static str> { + if contains_host_path(value) { + return Some("host-local paths are not allowed"); + } + if contains_credential_like_name(value) { + return Some("credential-like names are not allowed"); + } + if contains_private_env_var_token(value) { + return Some("private environment variable names are not allowed"); + } + if contains_email_address(value) { + return Some("private identity details are not allowed"); + } + if contains_sensitive_phrase(value) { + return Some("local identity or account-routing details are not allowed"); + } + + None +} + +fn contains_host_path(value: &str) -> bool { + let lower = value.to_ascii_lowercase(); + + lower.contains("~/") + || lower.contains("~\\") + || HOST_PATH_PREFIXES.iter().any(|prefix| lower.contains(prefix)) + || contains_windows_absolute_path(value) +} + +fn contains_windows_absolute_path(value: &str) -> bool { + value.as_bytes().windows(3).enumerate().any(|(index, window)| { + let preceded_by_separator = + index == 0 || !value.as_bytes()[index - 1].is_ascii_alphanumeric(); + + preceded_by_separator + && window[0].is_ascii_alphabetic() + && window[1] == b':' + && matches!(window[2], b'\\' | b'/') + }) +} + +fn contains_credential_like_name(value: &str) -> bool { + value.split(is_token_separator).any(is_credential_like_token) +} + +fn is_token_separator(character: char) -> bool { + !(character.is_ascii_alphanumeric() || character == '_' || character == '-') +} + +fn is_credential_like_token(token: &str) -> bool { + if token.is_empty() { + return false; + } + + let has_name_shape = token.contains('_') || token.contains('-') || is_all_caps_token(token); + + if !has_name_shape { + return false; + } + + let normalized = token.replace('-', "_").to_ascii_uppercase(); + + CREDENTIAL_MARKERS.iter().any(|marker| { + normalized == *marker + || normalized.starts_with(&format!("{marker}_")) + || normalized.ends_with(&format!("_{marker}")) + || normalized.contains(&format!("_{marker}_")) + }) +} + +fn is_all_caps_token(token: &str) -> bool { + let mut has_letter = false; + + for character in token.chars() { + if character.is_ascii_lowercase() { + return false; + } + if character.is_ascii_alphabetic() { + has_letter = true; + } + } + + has_letter +} + +fn contains_private_env_var_token(value: &str) -> bool { + value.split(is_token_separator).any(|token| { + let normalized = token.to_ascii_uppercase(); + + PRIVATE_ENV_VAR_TOKENS.iter().any(|env_var| normalized == *env_var) + }) +} + +fn contains_email_address(value: &str) -> bool { + value.split_whitespace().any(is_email_like_token) +} + +fn is_email_like_token(token: &str) -> bool { + let token = token.trim_matches(|character: char| { + matches!(character, '`' | '\'' | '"' | '<' | '>' | '(' | ')' | '[' | ']' | ',' | ';' | '.') + }); + let Some((local, domain)) = token.split_once('@') else { + return false; + }; + + !local.is_empty() && domain.contains('.') && !domain.starts_with('.') && !domain.ends_with('.') +} + +fn contains_sensitive_phrase(value: &str) -> bool { + let lower = value.to_ascii_lowercase(); + + SENSITIVE_PHRASES.iter().any(|phrase| lower.contains(phrase)) + || PRIVATE_CONFIG_FILES.iter().any(|file_name| lower.contains(file_name)) +} + +fn extract_structured_field(line: &str) -> Option<(&str, &str)> { + let trimmed = line.trim(); + let trimmed = trimmed.strip_prefix("- ").unwrap_or(trimmed); + let (key, value) = trimmed.split_once(':')?; + + Some((key.trim(), value.trim().trim_matches('`'))) +} + +fn validate_repo_relative_path(path: &str, field_name: &str) -> Result<(), String> { + if path.is_empty() { + return Err(format!("`{field_name}` must not be empty.")); + } + if path.starts_with('/') || path.starts_with("~/") || has_drive_root_prefix(path) { + return Err(format!("`{field_name}` must be repository-relative, not `{path}`.")); + } + if Path::new(path).components().any(|component| matches!(component, Component::ParentDir)) { + return Err(format!("`{field_name}` must stay within the repository, not `{path}`.")); + } + + Ok(()) +} + +fn has_drive_root_prefix(path: &str) -> bool { + let bytes = path.as_bytes(); + + bytes.len() >= 3 + && bytes[0].is_ascii_alphabetic() + && bytes[1] == b':' + && matches!(bytes[2], b'\\' | b'/') +} + +#[cfg(test)] +mod tests { + use crate::tracker::public_text::{self}; + + #[test] + fn accepts_public_collaboration_identifiers() { + for value in [ + "PR https://github.com/hack-ink/decodex/pull/42 is ready.", + "Branch y/decodex-xy-519 reached commit 0123456789abcdef0123456789abcdef01234567.", + "Issue XY-519 updated docs/spec/runtime.md and .worktrees/XY-519.", + ] { + public_text::validate_public_text_field("summary", value) + .unwrap_or_else(|error| panic!("public value should validate: {error}")); + } + } + + #[test] + fn rejects_leakage_shaped_public_text() { + for value in [ + "Local checkout was /Users/example/code/repo.", + "Read ~/.codex/auth.json for the selected account.", + "Windows path C:\\Users\\example\\repo was present.", + "Missing GITHUB_PAT_Y blocked the push.", + "Selected account was account=...e4919e.", + "Missing API key for tracker writes.", + "CODEX_HOME pointed at private configuration.", + "codex.github-identity was routed to a private person.", + "Selected account user@example.com was active.", + ] { + let error = public_text::validate_public_text_field("evidence", value) + .expect_err("leakage-shaped value should be rejected"); + + assert!(error.contains("public/team-visible")); + } + } + + #[test] + fn validates_public_comment_structured_paths() { + public_text::validate_public_comment_body( + "decodex run failed and will retry\n\n- worktree_path: `.worktrees/DEC-1`", + ) + .expect("repo-relative worktree path should be public"); + + for (body, expected_error) in [ + ( + "decodex run failed and will retry\n\n- worktree_path: `/absolute/path/to/repo/.worktrees/DEC-1`", + "`worktree_path` must be repository-relative", + ), + ( + "decodex run failed and will retry\n\n- unexpected_path: `.worktrees/DEC-1`", + "Unsupported structured field `unexpected_path`", + ), + ] { + let error = public_text::validate_public_comment_body(body) + .expect_err("private or unsupported comment path should be rejected"); + + assert!(error.contains(expected_error), "{error}"); + } + } +} diff --git a/apps/decodex/src/tracker/records.rs b/apps/decodex/src/tracker/records.rs index df2919de2..cd0692bc2 100644 --- a/apps/decodex/src/tracker/records.rs +++ b/apps/decodex/src/tracker/records.rs @@ -3,7 +3,7 @@ use std::path::{Component, Path}; use serde::{Deserialize, Serialize, de::DeserializeOwned}; use serde_json::Error; -use crate::tracker::TrackerComment; +use crate::tracker::{TrackerComment, public_text}; #[cfg(test)] pub(crate) const REVIEW_HANDOFF_RECORD_TYPE: &str = "review-handoff-record/1"; @@ -201,6 +201,7 @@ pub(crate) fn validate_linear_execution_event_record( ) -> Result<(), String> { validate_linear_execution_event_envelope(record)?; validate_linear_execution_event_fields(record)?; + validate_linear_execution_event_public_text(record)?; if let Some(worktree_path) = record.worktree_path.as_deref() { validate_repo_relative_path(worktree_path, "worktree_path")?; @@ -231,6 +232,33 @@ pub(crate) fn parse_linear_execution_event_record( .filter(|record| validate_linear_execution_event_record(record).is_ok()) } +fn validate_linear_execution_event_public_text( + record: &LinearExecutionEventRecord, +) -> Result<(), String> { + for (field_name, value) in [ + ("summary", record.summary.as_deref()), + ("focus", record.focus.as_deref()), + ("next_action", record.next_action.as_deref()), + ("failed_command", record.failed_command.as_deref()), + ("raw_error", record.raw_error.as_deref()), + ] { + if let Some(value) = value { + public_text::validate_public_text_field(field_name, value)?; + } + } + for (field_name, values) in [ + ("blockers", record.blockers.as_ref()), + ("evidence", record.evidence.as_ref()), + ("verification", record.verification.as_ref()), + ] { + if let Some(values) = values { + public_text::validate_public_text_items(field_name, values)?; + } + } + + Ok(()) +} + fn parse_structured_comment(body: &str) -> Option where T: DeserializeOwned, @@ -434,3 +462,67 @@ fn has_drive_root_prefix(path: &str) -> bool { && bytes[1] == b':' && matches!(bytes[2], b'\\' | b'/') } + +#[cfg(test)] +mod tests { + use crate::tracker::records::{LinearExecutionEventIdentity, LinearExecutionEventRecord}; + + fn progress_record() -> LinearExecutionEventRecord { + let mut record = LinearExecutionEventRecord::new( + LinearExecutionEventIdentity { + service_id: "decodex", + issue_id: "issue-id", + issue_identifier: "XY-519", + run_id: "xy-519-attempt-1", + attempt_number: 1, + }, + "progress_checkpoint", + String::from("2026-05-25T00:00:00Z"), + "anchor", + ); + + record.phase = Some(String::from("implementing")); + record.focus = + Some(String::from("Keep PR https://github.com/hack-ink/decodex/pull/42 reviewable.")); + record.next_action = Some(String::from( + "Update apps/decodex/src/tracker/records.rs on branch y/decodex-xy-519.", + )); + record.blockers = Some(Vec::new()); + record.evidence = Some(vec![String::from("Issue XY-519 remains scoped.")]); + + record + } + + #[test] + fn validates_public_progress_checkpoint_text() { + super::validate_linear_execution_event_record(&progress_record()) + .expect("public collaboration identifiers should validate"); + } + + #[test] + fn rejects_private_progress_checkpoint_text() { + for (field_name, private_text) in [ + ("focus", "Inspect /Users/example/code/private checkout."), + ("next_action", "Read codex.github-identity before pushing."), + ("blockers", "Missing LINEAR_API_KEY_HACKINK prevented tracker write."), + ("evidence", "Selected account user@example.com was active."), + ("verification", "Checked C:\\Users\\example\\repo."), + ] { + let mut record = progress_record(); + + match field_name { + "focus" => record.focus = Some(String::from(private_text)), + "next_action" => record.next_action = Some(String::from(private_text)), + "blockers" => record.blockers = Some(vec![String::from(private_text)]), + "evidence" => record.evidence = Some(vec![String::from(private_text)]), + "verification" => record.verification = Some(vec![String::from(private_text)]), + _ => unreachable!("test field names are exhaustive"), + } + + let error = super::validate_linear_execution_event_record(&record) + .expect_err("private free-text should not serialize"); + + assert!(error.contains("public/team-visible")); + } + } +} diff --git a/docs/spec/linear-execution-ledger.md b/docs/spec/linear-execution-ledger.md index c36135e2b..2012add02 100644 --- a/docs/spec/linear-execution-ledger.md +++ b/docs/spec/linear-execution-ledger.md @@ -68,6 +68,22 @@ Consumers must ignore prose outside the fenced JSON object except for display. Producers must not put secrets, access tokens, absolute host paths, or local user names in ledger records. +## Public text baseline + +Linear comments are public/team-visible tracker text. Before Decodex serializes a new +Linear execution event, free-text fields such as `summary`, `focus`, `next_action`, +`blockers`, `evidence`, `verification`, `failed_command`, and `raw_error` must pass the +baseline public-text guard. The guard rejects known structured leakage shapes, +including host-local paths, routed identity configuration details, credential-like +names, private account details, private config file names, emails, tokens, and secrets. + +The guard is a baseline structural stop, not the final privacy boundary. Full runtime +evidence, local identity routing, account state, and high-frequency diagnostics must +stay in the local runtime database, operator-only evidence files, logs, or short-lived +activity markers. Linear records should continue to use public collaboration +identifiers such as PR URLs, issue identifiers, branch names, commit SHAs, and +repository-relative paths. + ## Record envelope All field names are snake_case. diff --git a/docs/spec/tracker-tools.md b/docs/spec/tracker-tools.md index 6d67d5f57..3a73c481f 100644 --- a/docs/spec/tracker-tools.md +++ b/docs/spec/tracker-tools.md @@ -124,6 +124,12 @@ In either invalid case, `decodex` must fail the attempt rather than infer which [`linear-execution-ledger.md`](./linear-execution-ledger.md). - Structured comment fields such as `worktree_path` must use repository-relative paths; absolute host paths should be rejected before writing to the tracker. +- `issue_comment` and `issue_progress_checkpoint` text is public/team-visible. Before + either tool writes to Linear, Decodex must reject known leakage-shaped text such as + host-local paths, routed identity details, credential-like names, private account + details, private config file names, emails, tokens, or secrets. This baseline guard + does not replace the longer-term local-private ledger boundary; detailed runtime + evidence remains local/operator-only. - Dynamic tool names must satisfy the `codex app-server` identifier restriction `^[a-zA-Z0-9_-]+$`; dotted names are invalid. ## Failure handling