orch-cli 0.91.8

Orch, the agent orchestration engine: runs coding agents on tasks and cron jobs
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
//! Secret leakage prevention tests.
//!
//! These tests verify that task artifacts (runner.sh, prompt-sys.md, prompt-msg.md)
//! do NOT contain secrets like GH_TOKEN, GITHUB_TOKEN, or private keys.
//!
//! Run with:
//! ```bash
//! cargo test --test secret_leak
//! ```

use std::collections::HashMap;
use std::path::PathBuf;

/// Secret patterns that should NOT appear in artifacts.
/// These are intentionally broad to catch any token-like strings.
const FORBIDDEN_PATTERNS: &[(&str, &str)] = &[
    // GitHub tokens with GH_TOKEN assignment (catches ghp_, gho_, ghu_, ghs_, ghr_ prefixes)
    // Using non-raw strings to handle quote escaping properly
    ("GH_TOKEN env", "GH_TOKEN\\s*=\\s*[\"']?gh[pousr]_"),
    ("GITHUB_TOKEN env", "GITHUB_TOKEN\\s*=\\s*[\"']?gh[pousr]_"),
    // GitHub PAT
    ("GitHub PAT", "github_pat_[A-Za-z0-9_]{22,}"),
    // Generic GitHub token
    ("GitHub token", "gh[pousr]_[A-Za-z0-9_]{36,}"),
    // AWS keys
    ("AWS key", "AKIA[0-9A-Z]{16}"),
    // Private keys
    (
        "Private key header",
        "-----BEGIN (RSA |EC |DSA |OPENSSH )?PRIVATE KEY-----",
    ),
    // API keys
    ("OpenAI key", "sk-[A-Za-z0-9\\-]{20,}"),
    ("Anthropic key", "sk-ant-[A-Za-z0-9\\-]{20,}"),
];

/// Scan content for forbidden secret patterns.
fn scan_for_secrets(content: &str) -> Vec<(usize, String, String)> {
    let mut findings = Vec::new();

    for (line_num, line) in content.lines().enumerate() {
        for (name, pattern) in FORBIDDEN_PATTERNS {
            let regex = regex::Regex::new(pattern).expect("invalid regex pattern");
            if regex.is_match(line) {
                findings.push((line_num + 1, (*name).to_string(), line.trim().to_string()));
            }
        }
    }

    findings
}

/// Assert that no secrets are present in the given files.
fn assert_no_secrets_in_files(files: &[(PathBuf, String)]) {
    let mut all_violations = Vec::new();

    for (path, content) in files {
        let violations = scan_for_secrets(content);
        for (line, pattern_name, snippet) in violations {
            all_violations.push((path.clone(), line, pattern_name, snippet));
        }
    }

    if !all_violations.is_empty() {
        let mut msg = String::from("Secret leakage detected in task artifacts:\n\n");
        for (path, line, pattern, snippet) in all_violations {
            msg.push_str(&format!(
                "  File: {}\n  Line: {}\n  Pattern: {}\n  Snippet: {}\n\n",
                path.display(),
                line,
                pattern,
                &snippet[..snippet.len().min(100)]
            ));
        }
        panic!("{}", msg);
    }
}

/// Create a temporary HOME directory for isolated testing.
fn setup_temp_home() -> (tempfile::TempDir, PathBuf) {
    let temp = tempfile::tempdir().expect("failed to create temp dir");
    let home = temp.path().join("home");
    std::fs::create_dir_all(&home).expect("failed to create home dir");
    std::env::set_var("HOME", &home);
    (temp, home)
}

mod unit_tests {
    use super::*;

    /// Test that scan_for_secrets correctly detects patterns.
    #[test]
    fn test_scan_detects_github_token() {
        let content = r#"
export GH_TOKEN="ghp_abcdefghijklmnopqrstuvwxyz1234"
"#;
        let findings = scan_for_secrets(content);
        assert!(!findings.is_empty(), "Should detect GH_TOKEN with ghp_");
        assert!(findings.iter().any(|(_, name, _)| name == "GH_TOKEN env"));
    }

    /// Test that scan_for_secrets detects private keys.
    #[test]
    fn test_scan_detects_private_key() {
        let content = r#"
-----BEGIN RSA PRIVATE KEY-----
MIIEpAIBAAKCAQEA0Z3VS5JJcds3xfn/ygWyF8PbnGy0AHB7MqK8k7f5l2EckKlw
-----END RSA PRIVATE KEY-----
"#;
        let findings = scan_for_secrets(content);
        assert!(!findings.is_empty(), "Should detect private key");
        assert!(findings
            .iter()
            .any(|(_, name, _)| name == "Private key header"));
    }

    /// Test that scan_for_secrets ignores safe content.
    #[test]
    fn test_scan_ignores_safe_content() {
        let content = r#"
# Normal task instructions
Fix the bug in src/main.rs
Add tests for the new feature
"#;
        let findings = scan_for_secrets(content);
        assert!(findings.is_empty(), "Should not flag safe content");
    }

    /// Test that prompt files don't contain secrets when generated.
    /// This simulates the actual artifact generation process.
    #[test]
    fn test_prompt_files_no_secrets() {
        let (_temp, _home) = setup_temp_home();

        // Simulate the content that would be written to prompt files
        let sys_prompt = r#"# Task Instructions

You are an AI assistant helping with a software engineering task.

## Guidelines
- Write clean, maintainable code
- Follow existing patterns in the codebase
- Add tests for new functionality

## Task
Fix the bug in the authentication module."#;

        let msg_prompt = r#"# Task #123: Fix auth bug

The authentication module has a bug where tokens are not validated correctly.

## Expected Behavior
Tokens should be validated against the database.

## Current Behavior
Tokens are accepted without validation."#;

        // Create temp files to test
        let attempt_dir = _home.join(".orch/state/owner/repo/tasks/123/attempts/1");
        std::fs::create_dir_all(&attempt_dir).unwrap();

        let sys_file = attempt_dir.join("prompt-sys.md");
        let msg_file = attempt_dir.join("prompt-msg.md");

        std::fs::write(&sys_file, sys_prompt).unwrap();
        std::fs::write(&msg_file, msg_prompt).unwrap();

        // Verify no secrets in files
        let files = vec![
            (sys_file, sys_prompt.to_string()),
            (msg_file, msg_prompt.to_string()),
        ];
        assert_no_secrets_in_files(&files);
    }

    /// Test that a runner script without GH_TOKEN doesn't trigger false positives
    /// on safe patterns that might look similar.
    #[test]
    fn test_safe_patterns_not_flagged() {
        let content = r#"#!/usr/bin/env bash
set -euo pipefail

# These are safe patterns that should NOT be flagged:
export TASK_ID="123"
export GIT_AUTHOR_NAME="Test User"
export GIT_COMMITTER_EMAIL="test@example.com"

# Comments mentioning tokens in documentation context:
# The GH_TOKEN environment variable is used for authentication
# See: https://docs.github.com/en/authentication

# Variable names without actual values:
echo "$GH_TOKEN"  # This references an env var, doesn't leak it
echo "${GITHUB_TOKEN:-}"  # This references an env var with default

cd /work/dir
claude -p --system prompt-sys.md < prompt-msg.md
"#;

        let findings = scan_for_secrets(content);
        assert!(
            findings.is_empty(),
            "Should not flag safe patterns: {:?}",
            findings
        );
    }
}

mod integration_tests {
    use super::*;

    /// Test that build_runner_script handles environment tokens correctly.
    ///
    /// This test verifies that when GH_TOKEN is set in the environment,
    /// the runner script contains the export line (which is expected behavior
    /// for the current implementation), but the prompt files do NOT contain
    /// the token value.
    ///
    /// IMPORTANT: This documents current behavior. The runner.sh exports GH_TOKEN
    /// to make it available to the agent, but the token should NOT appear in
    /// prompt files or other artifacts that might be logged/committed.
    #[test]
    fn test_runner_script_with_env_token() {
        let (_temp, home) = setup_temp_home();

        // Set GH_TOKEN in environment (simulating real deployment)
        let test_token = "ghp_test1234567890abcdefghijklmnopqrstuvwxyz12";
        std::env::set_var("GH_TOKEN", test_token);

        // Create a minimal agent invocation
        let attempt_dir = home.join(".orch/state/owner/repo/tasks/456/attempts/1");
        std::fs::create_dir_all(&attempt_dir).unwrap();

        let sys_file = attempt_dir.join("prompt-sys.md");
        let msg_file = attempt_dir.join("prompt-msg.md");

        // Write prompt files (these should NEVER contain the token)
        let sys_prompt = "System prompt without secrets".to_string();
        let msg_prompt = "Message prompt without secrets".to_string();

        std::fs::write(&sys_file, &sys_prompt).unwrap();
        std::fs::write(&msg_file, &msg_prompt).unwrap();

        // Simulate what build_runner_script does: write the token export line
        let runner_script = format!(
            r#"#!/usr/bin/env bash
set -euo pipefail

export GH_TOKEN="{}"
export GIT_AUTHOR_NAME="Test User"
export GIT_AUTHOR_EMAIL="test@example.com"
"#,
            test_token
        );

        let runner_file = attempt_dir.join("runner.sh");
        std::fs::write(&runner_file, &runner_script).unwrap();

        // Verify prompt files have no secrets
        let prompt_files = vec![
            (sys_file.clone(), sys_prompt),
            (msg_file.clone(), msg_prompt),
        ];
        assert_no_secrets_in_files(&prompt_files);

        // Clean up env var
        std::env::remove_var("GH_TOKEN");
    }

    /// Test that when no GH_TOKEN is in environment, the runner script
    /// doesn't contain hardcoded fallback tokens.
    #[test]
    fn test_runner_script_without_env_token() {
        let (_temp, home) = setup_temp_home();

        // Ensure no GH_TOKEN in environment
        std::env::remove_var("GH_TOKEN");
        std::env::remove_var("GITHUB_TOKEN");

        let attempt_dir = home.join(".orch/state/owner/repo/tasks/789/attempts/1");
        std::fs::create_dir_all(&attempt_dir).unwrap();

        // Simulate a runner script generated when no token is available
        let runner_script = r#"#!/usr/bin/env bash
set -euo pipefail

# No GH_TOKEN available - agent will need to authenticate via other means
export GIT_AUTHOR_NAME="Test User"
export GIT_AUTHOR_EMAIL="test@example.com"

# Source user private file if it exists
[ -f "$HOME/.private" ] && source "$HOME/.private"
"#;

        let runner_file = attempt_dir.join("runner.sh");
        std::fs::write(&runner_file, runner_script).unwrap();

        // Verify the script has no hardcoded tokens
        let files = vec![(runner_file, runner_script.to_string())];
        assert_no_secrets_in_files(&files);
    }

    /// Test comprehensive artifact scanning - simulates a full task attempt
    /// directory with all files that could contain secrets.
    #[test]
    fn test_full_attempt_directory_scan() {
        let (_temp, home) = setup_temp_home();

        let attempt_dir = home.join(".orch/state/owner/repo/tasks/999/attempts/1");
        std::fs::create_dir_all(&attempt_dir).unwrap();

        // Create all the files that would be in an attempt directory
        let files = vec![
            (
                "prompt-sys.md",
                r#"# System Prompt

You are a helpful AI assistant.

## Task
Fix the authentication bug in src/auth.rs."#,
            ),
            (
                "prompt-msg.md",
                r#"# Task #999

The auth module needs fixing."#,
            ),
            (
                "runner.sh",
                r#"#!/usr/bin/env bash
set -euo pipefail

export PATH="/opt/homebrew/bin:$PATH"
export GIT_AUTHOR_NAME="Test User"
export GIT_COMMITTER_NAME="Test User"
export GIT_AUTHOR_EMAIL="test@example.com"
export GIT_COMMITTER_EMAIL="test@example.com"
export TASK_ID="999"
export OUTPUT_FILE="/output.json"
unset CLAUDECODE

cd "/work/dir"

# Run agent
RESPONSE=$(claude -p --system prompt-sys.md < prompt-msg.md) || CMD_STATUS=$?
CMD_STATUS=${CMD_STATUS:-0}
printf '%s' "$RESPONSE" > "$OUTPUT_FILE"
echo "$CMD_STATUS" > exit.txt
exit $CMD_STATUS
"#,
            ),
            ("output.json", r#"{"status":"done","summary":"Fixed auth"}"#),
            ("exit.txt", "0"),
        ];

        let mut file_contents = Vec::new();
        for (filename, content) in &files {
            let path = attempt_dir.join(filename);
            std::fs::write(&path, content).unwrap();
            file_contents.push((path, (*content).to_string()));
        }

        // Scan all files for secrets
        assert_no_secrets_in_files(&file_contents);
    }

    /// Test that template rendering doesn't leak secrets.
    /// This simulates what happens in build_system_prompt and build_agent_message.
    #[test]
    fn test_template_rendering_no_leaks() {
        let mut vars = HashMap::new();
        vars.insert("ROLE".to_string(), "developer".to_string());
        vars.insert("TASK_ID".to_string(), "123".to_string());
        vars.insert("TASK_TITLE".to_string(), "Fix bug".to_string());
        vars.insert("TASK_BODY".to_string(), "The bug is in main.rs".to_string());

        // These should never contain actual secrets
        let template_output = format!(
            r#"Role: {}
Task: {} - {}
Description: {}
"#,
            vars.get("ROLE").unwrap(),
            vars.get("TASK_ID").unwrap(),
            vars.get("TASK_TITLE").unwrap(),
            vars.get("TASK_BODY").unwrap(),
        );

        let findings = scan_for_secrets(&template_output);
        assert!(
            findings.is_empty(),
            "Template output should not contain secrets"
        );
    }
}

/// Test using the actual security module from the crate.
/// This verifies that our patterns align with the main security scanner.
#[test]
fn test_security_module_integration() {
    // Import the crate's security module
    // Note: This requires the crate to expose security module or we run as integration test

    let content_with_secrets = r#"
export GH_TOKEN=ghp_abcdefghijklmnopqrstuvwxyz123456
-----BEGIN RSA PRIVATE KEY-----
MIIEpAIBAAKCAQEA0Z3VS5JJcds3xfn/ygWyF8PbnGy0AHB7MqK8k7f5l2EckKlw
"#;

    let clean_content = r#"
# Normal task instructions
Fix the bug in src/main.rs
Add tests for the new feature
"#;

    // Our scanner should detect secrets
    let secret_findings = scan_for_secrets(content_with_secrets);
    assert!(
        !secret_findings.is_empty(),
        "Should detect secrets in content"
    );

    // Our scanner should not flag clean content
    let clean_findings = scan_for_secrets(clean_content);
    assert!(clean_findings.is_empty(), "Should not flag clean content");
}