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
13 changes: 9 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,7 @@ parallel = 3 # how many Tickets a Spec run runs at once; default 3
limit = 3 # how many open issues labelled in-progress stop a Pickup run taking another; default 3

[security]
fix = false # Security runs may fix reproduced findings; default false
harness = "" # the Harness a Security run uses unless its command names one; default blank, for harness.default or Claude Code

[harness]
Expand Down Expand Up @@ -735,29 +736,33 @@ On a repository that also has a [Pickup line](#a-pickup-run-on-a-schedule), put

## 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:
`thirdshift secure` starts a **Security run**, report-only by default: 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). After recording findings, it tries to reproduce each untriaged one in a fresh throwaway worktree at the recorded audited commit, and keeps the outcome, severity when reproduced, notes and test in the private record. It preserves findings already triaged by the Day shift. It dispatches no fix and never commits, pushes, publishes or closes an advisory; the fix-related options are reserved for later Security run work.
The command takes the Harness, Model and Effort words and the shared Pass words (`merge`, `no-merge`, `base-fix`, `no-base-fix`, `parallel`, `email`, `no-email`, `security-fix` and `no-security-fix`, with or without dashes). After recording findings, it tries to reproduce each untriaged one in a fresh throwaway worktree at the recorded audited commit, and keeps the outcome, severity when reproduced, notes and test in the private record. It preserves findings already triaged by the Day shift. It never publishes or closes an advisory.

Fixing is off by default. `security-fix` on the command or `fix = true` under `[security]` allows one fix; `no-security-fix` overrides the setting. Both words are accepted on every command that starts Runs and passed to those Runs. With fixing allowed, a Security run takes the most severe reproduced finding still awaiting a fix before auditing, with ties in private-record order. After an audit reproduces findings, it goes on to the most severe one.

A publishing session reads the private record and publishes one terse **Ticket**, labelled `needs-triage`, saying only what the fix changes and linking the record. thirdshift checks the Ticket, swaps `needs-triage` for `ready-for-agent`, adds `security-fix` (creating the label when missing), records the Ticket link privately and dispatches its **Run**. The Security run ends as that Run ends, including a **Merge run** when asked by the command or the User config. Every fix is a single Ticket for now; the write-up and proof-of-concept stay in the private record.

`email`, optionally followed by an address, or `email.always` in the User config asks for one **Run notification** when the Security run ends, whether the audit succeeded, failed or was interrupted. `no-email` overrides the default. The notification says how the audit ended and lists each finding it recorded or matched to an existing private record: its severity when known, title and private link. It includes no finding write-up, trace or evidence, since it passes through Resend. Detailed failure causes stay in the local logs; the notification gives the audit's status and log paths. A recording failure still lists the records reached before it failed. A skipped Security run sends none. The usual address and Resend API key checks run before the skip checks or any work; a failed send is a warning and never changes the run's outcome.

A Security run chooses its Harness from the command's `harness` word, then `[security] harness` in the User config, then `harness.default`, then Claude Code. A blank or missing Security setting keeps that default. Model and Effort come from the chosen Harness's own `[harness.<name>]` section, with command words taking precedence. For example, `harness claude` overrides `[security] harness = "codex"` and uses `[harness.claude]`.

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 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.
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. An unreproduced draft advisory with no severity waits; closing it, publishing it or assigning severity ends that wait. A reproduced finding waits while fixing is disabled until its record is closed or published, or its fix Ticket is closed. 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.

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.
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. On a private repository whose advisory endpoint is unavailable, the private record is an issue labelled `security-finding` and `needs-triage`. It is never replaced by a public finding issue.

## Pickup runs

Expand Down
1 change: 1 addition & 0 deletions prompts/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ With `harness codex`, each session runs `codex exec --json --dangerously-bypass-
| 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) |
| Security reproduction | After a Security audit, tries to reproduce one recorded finding in a fresh throwaway worktree at its audited commit. The test is copied into the private record only after a complete outcome. | [security-reproduction.md](security-reproduction.md) |
| Conflict Repair | Starts a Repair session when merging the Base branch into the Issue branch leaves conflicts. | [conflict-repair.md](conflict-repair.md) |
| Security fix publishing | With fixing allowed, publishes one terse Ticket for the most severe reproduced finding. thirdshift checks and marks it ready before dispatching its Run. | [security-fix.md](security-fix.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) |
| CI-fix Repair | Starts a Repair session when CI fails on the pull request's head commit, listing each failed check. | [ci-fix-repair.md](ci-fix-repair.md) |
Expand Down
20 changes: 20 additions & 0 deletions prompts/security-fix.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<!-- 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 fix publishing

With fixing allowed, publishes one terse Ticket for the most severe reproduced finding. thirdshift checks and marks it ready before dispatching its Run.

```
Publish the fix for this reproduced Security finding on Base branch `<base>`.
Read the private record at <private record URL> and the record below.
Publish exactly one new, terse Ticket in this repository, labelled `needs-triage`. Create that label if missing.
The Ticket says only what the fix changes and links the private record. It carries none of the write-up, trace, evidence, reproduction notes, proof-of-concept test or exploit details, even paraphrased. Keep those in the private record.
For now every fix is a single Ticket, even if the reproduction suggested a Spec. Publish no Spec or sub-issues.
This session changes no repository source, commits and pushes nothing, opens no pull request, and does not implement the fix. thirdshift checks the Ticket, marks it ready, labels it security-fix and dispatches its Run.
End your final message with exactly `Security fix Ticket: <Issue URL>`.

Private record:
<recorded Security finding>

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.
```
18 changes: 18 additions & 0 deletions site/prompts/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,24 @@ <h3 id="prompt-conflict-repair-title">Conflict Repair</h3>

Resolve the conflicts, finish the merge, and push <mark class="ph">&lt;branch&gt;</mark>. Do not rebase or force-push.

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-fix" aria-labelledby="prompt-security-fix-title">
<h3 id="prompt-security-fix-title">Security fix publishing</h3>
<p>With fixing allowed, publishes one terse Ticket for the most severe reproduced finding. thirdshift checks and marks it ready before dispatching its Run.</p>
<p class="sent-by">Sent by a Security run, before any unit</p>
<pre class="job-text"><code>Publish the fix for this reproduced Security finding on Base branch `<mark class="ph">&lt;base&gt;</mark>`.
Read the private record at &lt;private record URL&gt; and the record below.
Publish exactly one new, terse Ticket in this repository, labelled `needs-triage`. Create that label if missing.
The Ticket says only what the fix changes and links the private record. It carries none of the write-up, trace, evidence, reproduction notes, proof-of-concept test or exploit details, even paraphrased. Keep those in the private record.
For now every fix is a single Ticket, even if the reproduction suggested a Spec. Publish no Spec or sub-issues.
This session changes no repository source, commits and pushes nothing, opens no pull request, and does not implement the fix. thirdshift checks the Ticket, marks it ready, labels it security-fix and dispatches its Run.
End your final message with exactly `Security fix Ticket: <mark class="ph">&lt;Issue URL&gt;</mark>`.

Private record:
&lt;recorded Security finding&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>
Expand Down
17 changes: 16 additions & 1 deletion src/args.rs
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ pub struct PassArgs {
pub base: Option<String>,
/// The flags it shares with a Run: `email` and `no-email` for its own
/// Run notification, the rest for a run the Pass dispatches. The
/// report-only Security run reserves them until those stages are built.
/// Security run also uses `security-fix` for its own choice of work.
pub flags: Flags,
}

Expand Down Expand Up @@ -246,6 +246,18 @@ fn take_flag<'a>(
"no-base-fix" | "--no-base-fix" => {
ask_once(&mut flags.base_fix, BaseFixAsk::Forbid, arg, BASE_FIX_FLAGS)?
}
"security-fix" | "--security-fix" => ask_once(
&mut flags.security_fix,
crate::security::FixAsk::Allow,
arg,
SECURITY_FIX_FLAGS,
)?,
"no-security-fix" | "--no-security-fix" => ask_once(
&mut flags.security_fix,
crate::security::FixAsk::Forbid,
arg,
SECURITY_FIX_FLAGS,
)?,
"harness" | "--harness" => ask_harness(&mut flags.harness.harness, arg, rest.next())?,
"model" | "--model" => {
let model = &mut flags.harness.model_and_effort.model;
Expand All @@ -263,6 +275,7 @@ fn take_flag<'a>(
const MERGE_FLAGS: &str = "merge and no-merge";
const EMAIL_FLAGS: &str = "email and no-email";
const BASE_FIX_FLAGS: &str = "base-fix and no-base-fix";
const SECURITY_FIX_FLAGS: &str = "security-fix and no-security-fix";

/// Record in `given` what the flag `arg` asked for: the same kind of ask
/// twice is a repeated argument, and a different one contradicts the first,
Expand Down Expand Up @@ -724,6 +737,7 @@ mod tests {
let all = PassArgs {
base: Some("develop".to_string()),
flags: Flags {
security_fix: None,
goal: Some(Goal::Merged),
email: Some(NotificationAsk::Send(Some("me@example.com".to_string()))),
parallel: NonZeroUsize::new(2),
Expand Down Expand Up @@ -778,6 +792,7 @@ mod tests {
let cautious = PassArgs {
base: None,
flags: Flags {
security_fix: None,
goal: Some(Goal::ReadyForReview),
email: Some(NotificationAsk::Skip),
parallel: None,
Expand Down
Loading
Loading