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
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -727,6 +727,26 @@ A skip exits `0` and sends no email, even with Run notifications on, so the sche

On a repository that also has a [Pickup line](#a-pickup-run-on-a-schedule), put `--plan-only` on the Weeding line instead: the Architect run publishes the Architect plan and marks it ready, and a Pickup run builds it as an ordinary Ready issue, under the [Claim limit](#the-claim-limit), and merges it with `merge.always`, as in the [whole crontab](#a-pickup-run-on-a-schedule) below.

## Security runs

`thirdshift secure` starts a report-only **Security run**: it audits the **Base branch** at its head on `origin` and records each **Security finding** privately as a draft repository security advisory. Start it from a clone on the Base branch, or name that branch from any checkout:

```sh
thirdshift secure
thirdshift secure base main harness claude
thirdshift secure base main harness codex model gpt-6.1-sol effort high
```

The command takes the Harness, Model and Effort words and the shared Pass words (`merge`, `no-merge`, `base-fix`, `no-base-fix`, `parallel`, `email` and `no-email`, with or without dashes). This first step dispatches no fix and sends no Run notification; those options are reserved for later Security run work. It reproduces and fixes nothing, and never commits, pushes, publishes or closes an advisory.

A Security run is skipped while another **Pass** on the repository is running on the machine, or while the repository has a **Ready issue**. A skip exits `0`, starts no session and keeps no Command log; its reason goes into the repository's **Activity log**, with the usual `activity.quiet_skips` behavior. Before starting work, thirdshift checks the chosen Harness and that **Node.js** is on `PATH`, since the embedded skill's report validators require it.

The Security audit runs in a throwaway worktree detached at origin's Base branch head, leaving the Launch directory and local work untouched, including when `launch.pull` is set. Its Session prompt runs `thirdshift-security-audit` in full audit mode with the `quick` profile, auditing the whole repository except vendored and third-party code. It names a `SECURITY.md` or conventional threat-model document when present, reads compatible earlier runs and ends `incomplete` instead of asking for input.

Each audit keeps a new output directory under `<logs.dir>/<owner>/<repo>/audits/`, outside the worktree. The audit artifacts and private advisory descriptions contain evidence; keep the logs directory private. thirdshift runs both embedded validators before recording anything. A missing final line, an incomplete session or an invalid report fails the run and names its Session log. Command logs are in `commands/secure/`; Session logs are named `secure-<stamp>-security-audit.jsonl`; the Activity log records the start and ending as for other Passes.

A finding's title becomes the advisory summary. Its description preserves its write-up, trace, evidence and validation plan, with the fingerprint and audited commit. thirdshift claims no severity, CWE or affected version range. It uses the package in the repository's manifest, or the `other` ecosystem when none is identified. Matching fingerprints in any advisory state are kept rather than recorded again. Rejected candidates stay in the local report and produce no advisory. Draft advisories require GitHub repository security manager or administrator access. Private-repository fallback is later work; this command fails if the advisory endpoint is unavailable, without publishing a finding as an issue.

## Pickup runs

`thirdshift pickup` starts a **Pickup run**: one pass, with no **Issue URL**, that takes the lowest-numbered **Ready issue** in the repository and runs it, so that an issue you have already marked ready needs no command typed for it. Start it from a clone of the repository, on the **Base branch**, or name the Base branch with `base <branch>`:
Expand Down
1 change: 1 addition & 0 deletions prompts/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ With `harness codex`, each session runs `codex exec --json --dangerously-bypass-
| Continuation, with an open pull request | Starts the implement session when the Run is a Continuation of an Issue branch whose pull request is open. | [continuation-pr.md](continuation-pr.md) |
| Spec review | In a Spec run, starts the Spec review once every Ticket has landed on the Spec branch, before the Spec PR, a draft until then, is marked ready. | [spec-review.md](spec-review.md) |
| Architecture review | Starts the Architecture review, the session an Architect run opens with, in a worktree at the head of the Base branch. The line naming the focus is left out when the command gives none. | [architecture-review.md](architecture-review.md) |
| Security audit | Starts the report-only Security audit in a throwaway worktree at origin's Base branch head. The threat-model line is included when a conventional document exists; artifacts stay under the repository's audit root. | [security-audit.md](security-audit.md) |
| Conflict Repair | Starts a Repair session when merging the Base branch into the Issue branch leaves conflicts. | [conflict-repair.md](conflict-repair.md) |
| Conflict Repair, on Foreign commits | In a Merge run, starts a Repair session when merging Foreign commits from the Issue branch on origin into the local one leaves conflicts. | [foreign-conflict-repair.md](foreign-conflict-repair.md) |
| Review Repair | In a Merge run, starts a Repair session once Foreign commits are merged into the Issue branch, to review them from the head the Run last knew as its own before they can be merged. | [review-repair.md](review-repair.md) |
Expand Down
21 changes: 21 additions & 0 deletions prompts/security-audit.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<!-- Generated by src/prompts_page.rs from the prompts in src/prompt.rs and their titles and when sentences in src/prompts_page.rs; don't edit. Regenerate with UPDATE_PROMPTS=1 cargo test prompts_page -->

# Security audit

Starts the report-only Security audit in a throwaway worktree at origin's Base branch head. The threat-model line is included when a conventional document exists; artifacts stay under the repository's audit root.

```
/thirdshift-security-audit
Use the `thirdshift-security-audit` skill in full audit mode with report artifacts and the `quick` profile.
Audit the whole repository at commit `<audited commit>`, the head of Base branch `<base>` on origin. All vendored and third-party code are out of scope.
Read the repository's threat-model document `SECURITY.md`.
Output directory: `<output directory>`.
Audit root: `<audit root>`.
Read compatible earlier runs under the audit root before planning this audit.
This session commits, pushes, opens and publishes nothing, changes no repository source, and touches no deployed site. It reproduces and fixes nothing.
If a decision or missing prerequisite would require asking, record run_status incomplete with the reason, and end incomplete rather than asking.
Write the skill's report artifacts and run-metadata.json; mark run_status complete only when all required artifacts are written. Run both skill validators before finishing.
End your final message with exactly `Security audit: complete` after writing valid artifacts, or `Security audit: incomplete` when incomplete.

You run headless: nobody is watching, and ending your turn ends the session. Run tests and other long commands in the foreground, raising the command's timeout if needed. If a command is moved to the background, wait for that task by its own task id or output file, never by process names or patterns (`pgrep`, `ps | grep`, and the like): other sessions on this machine run the same commands. Never end your turn while a background task you depend on is still running: ending the turn kills it. Before ending your turn, stop every background task you no longer need, by its task id (with the `TaskStop` tool, if you have it): a task still running when your turn ends is taken as work you were waiting on.
```
19 changes: 19 additions & 0 deletions site/prompts/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,25 @@ <h3 id="prompt-architecture-review-title">Architecture review</h3>
Architecture review idea: &lt;URL of the issue you filed&gt;
Architecture review already filed: &lt;URL of the open issue that already covers it&gt;

You run headless: nobody is watching, and ending your turn ends the session. Run tests and other long commands in the foreground, raising the command's timeout if needed. If a command is moved to the background, wait for that task by its own task id or output file, never by process names or patterns (`pgrep`, `ps | grep`, and the like): other sessions on this machine run the same commands. Never end your turn while a background task you depend on is still running: ending the turn kills it. Before ending your turn, stop every background task you no longer need, by its task id (with the `TaskStop` tool, if you have it): a task still running when your turn ends is taken as work you were waiting on.
</code></pre>
</article>
<article class="job-sheet" id="prompt-security-audit" aria-labelledby="prompt-security-audit-title">
<h3 id="prompt-security-audit-title">Security audit</h3>
<p>Starts the report-only Security audit in a throwaway worktree at origin's Base branch head. The threat-model line is included when a conventional document exists; artifacts stay under the repository's audit root.</p>
<p class="sent-by">Sent by a Security run, before any unit</p>
<pre class="job-text"><code>/thirdshift-security-audit
Use the `thirdshift-security-audit` skill in full audit mode with report artifacts and the `quick` profile.
Audit the whole repository at commit `&lt;audited commit&gt;`, the head of Base branch `<mark class="ph">&lt;base&gt;</mark>` on origin. All vendored and third-party code are out of scope.
Read the repository's threat-model document `SECURITY.md`.
Output directory: `&lt;output directory&gt;`.
Audit root: `&lt;audit root&gt;`.
Read compatible earlier runs under the audit root before planning this audit.
This session commits, pushes, opens and publishes nothing, changes no repository source, and touches no deployed site. It reproduces and fixes nothing.
If a decision or missing prerequisite would require asking, record run_status incomplete with the reason, and end incomplete rather than asking.
Write the skill's report artifacts and run-metadata.json; mark run_status complete only when all required artifacts are written. Run both skill validators before finishing.
End your final message with exactly `Security audit: complete` after writing valid artifacts, or `Security audit: incomplete` when incomplete.

You run headless: nobody is watching, and ending your turn ends the session. Run tests and other long commands in the foreground, raising the command's timeout if needed. If a command is moved to the background, wait for that task by its own task id or output file, never by process names or patterns (`pgrep`, `ps | grep`, and the like): other sessions on this machine run the same commands. Never end your turn while a background task you depend on is still running: ending the turn kills it. Before ending your turn, stop every background task you no longer need, by its task id (with the `TaskStop` tool, if you have it): a task still running when your turn ends is taken as work you were waiting on.
</code></pre>
</article>
Expand Down
4 changes: 2 additions & 2 deletions src/architect.rs
Original file line number Diff line number Diff line change
Expand Up @@ -227,7 +227,7 @@ pub fn run(
} = match launch::start(base) {
Ok(Start::Clear(launch)) => launch,
Ok(Start::AlreadyRunning(running)) => {
logs::skipped(Pass::ArchitectRun, &running.0, &running);
logs::skipped(Pass::Architect, &running.0, &running);
return Outcome::Skipped(Skipped::AlreadyRunning(running));
}
Err(error) => return failed(error.into()),
Expand Down Expand Up @@ -285,7 +285,7 @@ fn run_through(outside: &mut impl Outside, architect_run: &ArchitectRun) -> Outc
match gates(outside) {
Ok(Decision::GoAhead) => {}
Ok(Decision::Skip(skipped)) => {
outside.skipped(Pass::ArchitectRun, &skipped);
outside.skipped(Pass::Architect, &skipped);
return Outcome::Skipped(skipped);
}
Err(error) => return failed(error.into()),
Expand Down
32 changes: 17 additions & 15 deletions src/args.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@ pub enum Command {
/// `email-test`, with the address it was given, if any.
EmailTest(Option<String>),
Architect(ArchitectArgs),
Pickup(PickupArgs),
Pickup(PassArgs),
Secure(PassArgs),
Run(RunArgs),
}

Expand All @@ -49,15 +50,15 @@ pub struct ArchitectArgs {
pub plan_only: bool,
}

/// A Pickup run's arguments.
/// A Pickup run or Security run's shared Pass arguments.
#[derive(Debug, PartialEq, Eq)]
pub struct PickupArgs {
pub struct PassArgs {
/// The Base branch `base <branch>` named, if given; without it, the
/// branch checked out in the Launch directory is the Base branch.
pub base: Option<String>,
/// The flags it shares with a Run: `email` and `no-email` for its own
/// Run notification, the rest for the Spec run or Run the Ready issue is
/// dispatched as.
/// Run notification, the rest for a run the Pass dispatches. The
/// report-only Security run reserves them until those stages are built.
pub flags: Flags,
}

Expand All @@ -77,7 +78,7 @@ pub struct RunArgs {
}

/// Parse the arguments after the program name. `help`, `version`, `update`,
/// `setup`, `email-test`, `architect` and `pickup` are commands only as the
/// `setup`, `email-test`, `architect`, `pickup` and `secure` are commands only as the
/// first argument. Otherwise it is a Run: one Issue URL, with each Run flag at
/// most once, before or after it.
/// `email` may be followed by the address to send the Run notification to,
Expand Down Expand Up @@ -105,7 +106,8 @@ pub fn parse(args: &[String]) -> Result<Command> {
};
}
Some("architect") => return parse_architect(&args[1..]).map(Command::Architect),
Some("pickup") => return parse_pickup(&args[1..]).map(Command::Pickup),
Some("pickup") => return parse_pass(&args[1..], "pickup").map(Command::Pickup),
Some("secure") => return parse_pass(&args[1..], "secure").map(Command::Secure),
_ => {}
}
let mut issue = None;
Expand Down Expand Up @@ -178,18 +180,18 @@ fn parse_architect(args: &[String]) -> Result<ArchitectArgs> {
})
}

/// Parse the arguments after `pickup`: its flags, each at most once, in any
/// Parse the arguments after `pickup` or `secure`: shared flags, at most once, in any
/// order, and nothing else. `base` must be followed by the Base branch.
fn parse_pickup(args: &[String]) -> Result<PickupArgs> {
fn parse_pass(args: &[String], command: &str) -> Result<PassArgs> {
let mut base = None;
let mut flags = Flags::default();
let mut args = args.iter().peekable();
while let Some(arg) = args.next() {
if !take_pass_flag(&mut base, &mut flags, arg, &mut args)? {
bail!("unexpected argument after pickup: {arg}");
bail!("unexpected argument after {command}: {arg}");
}
}
Ok(PickupArgs { base, flags })
Ok(PassArgs { base, flags })
}

/// Take a Pass's shared words: its Base branch and the flags for its
Expand Down Expand Up @@ -704,7 +706,7 @@ mod tests {
}

/// The Pickup run `args` parse to.
fn pickup_args(args: &[&str]) -> PickupArgs {
fn pickup_args(args: &[&str]) -> PassArgs {
match parse_strs(args) {
Ok(Command::Pickup(pickup_args)) => pickup_args,
Ok(_) => panic!("{args:?}: not a Pickup run"),
Expand All @@ -714,12 +716,12 @@ mod tests {

#[test]
fn pickup_takes_each_of_its_flags_with_or_without_dashes_in_any_order() {
let none = PickupArgs {
let none = PassArgs {
base: None,
flags: Flags::default(),
};
assert_eq!(pickup_args(&["pickup"]), none);
let all = PickupArgs {
let all = PassArgs {
base: Some("develop".to_string()),
flags: Flags {
goal: Some(Goal::Merged),
Expand Down Expand Up @@ -773,7 +775,7 @@ mod tests {
] {
assert_eq!(pickup_args(&args), all, "{args:?}");
}
let cautious = PickupArgs {
let cautious = PassArgs {
base: None,
flags: Flags {
goal: Some(Goal::ReadyForReview),
Expand Down
2 changes: 2 additions & 0 deletions src/command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ pub enum Ending {
review: Result<Reviewed, FailedRun>,
dispatched: Option<Ended>,
},
/// A Security run completed its audit, or failed.
Security(Result<crate::security::Recorded, FailedRun>),
/// A Pass was skipped before any work.
Skipped(Skip),
}
Expand Down
9 changes: 8 additions & 1 deletion src/github.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ use crate::issue::IssueUrl;
use crate::labels::{Label, Labels};
use crate::process::{self, Control, Interruption};

mod advisories;
pub use advisories::{DraftAdvisory, Package};

#[cfg(test)]
mod execution_tests;

Expand Down Expand Up @@ -614,9 +617,13 @@ impl GitHub {
.with_context(|| format!("{command} returned invalid JSON"))
}
fn output(&self, args: &[&str]) -> Result<Output> {
self.output_with_input(args, None)
}

fn output_with_input(&self, args: &[&str], input: Option<&[u8]>) -> Result<Output> {
process::output(
Command::new("gh").args(args),
None,
input,
Control {
name: "gh",
interruption: self.interruption,
Expand Down
73 changes: 73 additions & 0 deletions src/github/advisories.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
//! Private advisory operations. Publishing and closing belong to the Day shift.

use anyhow::{Context, Result, bail};
use serde::Serialize;
use serde_json::{Value, json};

use super::GitHub;

#[derive(Clone, Debug, Serialize)]
pub struct Package {
pub ecosystem: String,
pub name: Option<String>,
}

#[derive(Clone, Debug)]
pub struct DraftAdvisory {
pub fingerprint: String,
pub summary: String,
pub description: String,
pub package: Package,
}

impl DraftAdvisory {
pub fn already_recorded(&self, advisories: &[Value]) -> bool {
let marker = format!("Fingerprint: `{}`", self.fingerprint);
advisories.iter().any(|advisory| {
advisory["description"]
.as_str()
.is_some_and(|description| description.lines().any(|line| line == marker))
})
}
}

impl GitHub {
/// Every state and every page, including closed and published records.
pub fn security_advisories(&self, repo: &str) -> Result<Vec<Value>> {
self.gh_api_items(
&format!("repos/{repo}/security-advisories?per_page=100"),
"",
)
}

/// The create endpoint creates a draft. No severity or version claim.
pub fn create_security_advisory(&self, repo: &str, draft: &DraftAdvisory) -> Result<Value> {
let body = serde_json::to_vec(&json!({
"summary": draft.summary,
"description": draft.description,
"severity": null,
"cwe_ids": [],
"vulnerabilities": [{"package": draft.package, "vulnerable_version_range": null}]
}))?;
let output = self.output_with_input(
&[
"api",
"--method",
"POST",
&format!("repos/{repo}/security-advisories"),
"--input",
"-",
],
Some(&body),
)?;
if !output.status.success() {
// API errors can echo private evidence, so do not relay them.
bail!(
"creating a draft repository security advisory failed ({})",
output.status
);
}
serde_json::from_slice(&output.stdout)
.context("creating a draft advisory returned invalid JSON")
}
}
Loading
Loading