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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -751,7 +751,7 @@ A Security run chooses its Harness from the command's `harness` word, then `[sec

Codex security sessions and their Resumes set `agents.max_concurrent_threads_per_session=8` and ask for fresh sub-agents with `fork_turns: "none"`, so the skill's verifiers stay independent.

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.
A Security run checks its gates in order: another **Pass** on the repository running on the machine, a **Ready issue**, a **Security finding** waiting for the **Day shift**, then an unchanged **Base branch** since the last completed Security audit. A draft advisory with no severity waits; closing it, publishing it or assigning severity ends that wait. On a private repository, an open finding issue with `needs-triage` waits until that label is removed or the issue is closed. The audited commit comes from the skill's own `run-metadata.json` under the audit root, with no separate state file. A skip exits `0`, starts no session, keeps no Command log and sends no Run notification; its reason goes into the repository's **Activity log** only when it changes, 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.

Expand Down
21 changes: 21 additions & 0 deletions src/github/advisories.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,27 @@ pub enum SecurityRecords {
}

impl SecurityRecords {
/// An unreproduced, untriaged finding still needs the Day shift's call.
pub fn waiting_for_day_shift(&self) -> bool {
match self {
Self::Advisories(records) => records
.iter()
.any(|record| record["state"] == "draft" && record["severity"].is_null()),
Self::Issues(records) => records.iter().any(|record| {
record["state"]
.as_str()
.is_some_and(|state| state.eq_ignore_ascii_case("open"))
&& record["labels"].as_array().is_some_and(|labels| {
labels.iter().any(|label| {
label["name"]
.as_str()
.is_some_and(|name| NEEDS_TRIAGE.is_named(name))
})
})
}),
}
}

pub fn remember(&mut self, record: Value) -> &Value {
let records = match self {
Self::Advisories(records) | Self::Issues(records) => records,
Expand Down
5 changes: 5 additions & 0 deletions src/launch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,11 @@ impl BaseBranch {
&self.name
}

/// The head on origin sampled while preparing this Base branch.
pub fn origin_commit(&self) -> &str {
&self.origin_commit
}

/// The `launch.pull` fast-forward toward the sampled origin commit,
/// only while the Base branch is still checked out as at preparation.
/// No run depends on this, so a failure is only a warning and local
Expand Down
35 changes: 34 additions & 1 deletion src/pass.rs
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,8 @@ pub trait Outside {
fn audit(&mut self, base: &str) -> (Result<crate::security::audit::Audited>, Option<PathBuf>);
/// Private advisory or issue records in every state.
fn security_records(&mut self) -> Result<SecurityRecords>;
/// Whether origin's prepared Base branch is the last completed audit's commit.
fn base_unchanged_since_security_audit(&mut self) -> Result<bool>;
/// Record one finding in the repository's private storage.
fn create_security_record(
&mut self,
Expand Down Expand Up @@ -209,6 +211,13 @@ impl Outside for LaunchAndGitHub<'_> {
GitHub::new().security_records(&self.repo.slug())
}

fn base_unchanged_since_security_audit(&mut self) -> Result<bool> {
Ok(
crate::security::audit::last_commit(self.repo, self.base.name())?.as_deref()
== Some(self.base.origin_commit()),
)
}

fn create_security_record(
&mut self,
records: &SecurityRecords,
Expand Down Expand Up @@ -295,6 +304,8 @@ mod in_memory {
SecurityAudit(String),
/// It listed private advisories in every state.
AdvisoryList,
/// It compared the Base branch with the completed audit history.
AuditHistory,
/// It recorded this fingerprint privately.
CreateAdvisory(String),
/// It recorded that it started work: on this issue, for a Pickup
Expand Down Expand Up @@ -341,6 +352,8 @@ mod in_memory {
audit_findings: Vec<DraftAdvisory>,
audit_error: Option<String>,
advisories: Vec<Value>,
finding_issues: Option<Vec<Value>>,
unchanged_base: bool,
/// The Architecture review session's final message, if it has one,
/// or the cause it fails with.
review: Result<Option<String>, String>,
Expand Down Expand Up @@ -369,6 +382,8 @@ mod in_memory {
audit_findings: Vec::new(),
audit_error: None,
advisories: Vec::new(),
finding_issues: None,
unchanged_base: false,
review: Ok(None),
session_log: None,
ending: None,
Expand Down Expand Up @@ -463,6 +478,16 @@ mod in_memory {
self
}

pub fn finding_issues(mut self, issues: Vec<Value>) -> Self {
self.finding_issues = Some(issues);
self
}

pub fn unchanged_base(mut self) -> Self {
self.unchanged_base = true;
self
}

/// Have the Architecture review session end with `final_message`.
pub fn reviewed(mut self, final_message: &str) -> Self {
self.review = Ok(Some(final_message.to_string()));
Expand Down Expand Up @@ -626,7 +651,15 @@ mod in_memory {

fn security_records(&mut self) -> Result<SecurityRecords> {
self.calls.push(Call::AdvisoryList);
Ok(SecurityRecords::Advisories(self.advisories.clone()))
Ok(match &self.finding_issues {
Some(issues) => SecurityRecords::Issues(issues.clone()),
None => SecurityRecords::Advisories(self.advisories.clone()),
})
}

fn base_unchanged_since_security_audit(&mut self) -> Result<bool> {
self.calls.push(Call::AuditHistory);
Ok(self.unchanged_base)
}

fn create_security_record(
Expand Down
Loading
Loading