Sent by a Security run, before any unit
Publish the fix for this reproduced Security finding on Base branch `<base>`.
Read the private record at <private record URL> and the record below.
-Follow the completed reproduction's fix size: single stages one Ticket; spec publishes a Spec with Tickets using `thirdshift-to-spec` and `thirdshift-to-tickets`.
-On a public repository with a single fix, stage the proposed public issue text instead of creating the public issue directly: do not run `gh issue create` yourself. thirdshift validates the staged text and creates the `needs-triage` top issue from it. On a private repository, reuse the finding's issue as the top issue and preserve its body and evidence; add the bigger fix's Tickets as its native sub-issues. A spec still publishes its issues itself, as before.
+Follow the completed reproduction's fix size: single stages one Ticket; spec stages a Spec with Tickets, drafted with `thirdshift-to-spec` and `thirdshift-to-tickets`.
+On a public repository, stage the proposed public issue text instead of creating any public issue directly: do not run `gh issue create` yourself. thirdshift validates the staged text and creates the `needs-triage` top issue and its Tickets from it. On a private repository, reuse the finding's issue as the top issue and preserve its body and evidence; add the bigger fix's Tickets as its native sub-issues. Only a private bigger fix publishes its issues itself.
Every new issue is terse: it 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. This overrides the skills' templates.
-A Spec's Tickets must be new, open, labelled `ready-for-agent`, linked as native sub-issues with their native blocking links, and have no sub-issues of their own. Read the links back before finishing.
+A Spec's Tickets must be new, open, labelled `ready-for-agent`, linked as native sub-issues with their native blocking links, and have no sub-issues of their own. On a public repository thirdshift makes those links from the staged text; read the staged blocking edges back before finishing.
This session changes no repository source, commits and pushes nothing, opens no pull request, and does not implement the fix. thirdshift checks every issue, marks the top issue ready, labels all fix issues security-fix and dispatches its Run or Spec run.
End your final message for a single public fix with exactly:
Proposed public issue:
Title: <title>
Body:
<body>
-For a spec, end with exactly `Security fix Spec: <Issue URL>`, naming the top issue.
+For a public spec, end with exactly:
+Proposed public spec:
+Title: <spec title>
+Body:
+<spec body>
+Ticket: <ticket 1 title>
+Body:
+<ticket 1 body>
+Blocked by: none
+Ticket: <ticket 2 title>
+Body:
+<ticket 2 body>
+Blocked by: 1
+with each later Ticket naming the 1-based positions of the Tickets that block it, or none. For a private bigger fix, end with exactly `Security fix Spec: <Issue URL>`, naming the top issue.
Private record:
<recorded Security finding>
diff --git a/src/github.rs b/src/github.rs
index d7a918b..4a01614 100644
--- a/src/github.rs
+++ b/src/github.rs
@@ -177,6 +177,55 @@ impl GitHub {
])
}
+ /// The numeric database id of `issue`, for API calls that take one
+ /// instead of the issue number (sub-issues, dependencies).
+ fn issue_db_id(&self, issue: &IssueUrl) -> Result {
+ let json = self.gh_json(&[
+ "api",
+ &format!("repos/{}/issues/{}", issue.repo_slug(), issue.number),
+ "--jq",
+ ".id",
+ ])?;
+ json.as_u64()
+ .context("gh api did not return the issue's database id")
+ }
+
+ /// Link `child` as a native sub-issue of `parent`.
+ pub fn add_sub_issue(&self, parent: &IssueUrl, child: &IssueUrl) -> Result<()> {
+ let child_id = self.issue_db_id(child)?;
+ self.post_by_id(
+ parent,
+ &format!("issues/{}/sub_issues", parent.number),
+ "sub_issue_id",
+ child_id,
+ )
+ }
+
+ /// Add a native "blocked by" edge: `ticket` is blocked by `blocker`.
+ pub fn add_blocked_by(&self, ticket: &IssueUrl, blocker: &IssueUrl) -> Result<()> {
+ let blocker_id = self.issue_db_id(blocker)?;
+ self.post_by_id(
+ ticket,
+ &format!("issues/{}/dependencies/blocked_by", ticket.number),
+ "issue_id",
+ blocker_id,
+ )
+ }
+
+ /// `POST` one database-id field to the `endpoint` below
+ /// `repos//`: the shared shape behind issue links that take
+ /// an id instead of an issue number.
+ fn post_by_id(&self, issue: &IssueUrl, endpoint: &str, field: &str, id: u64) -> Result<()> {
+ self.gh(&[
+ "api",
+ "--method",
+ "POST",
+ &format!("repos/{}/{}", issue.repo_slug(), endpoint),
+ "-F",
+ &format!("{field}={id}"),
+ ])
+ }
+
/// Every open issue labelled `label` in the repository `repo`, an
/// `owner/repo`, newest first. They come from GitHub's issue list, not its
/// search, whose index can be a while behind an issue just opened.
diff --git a/src/prompt.rs b/src/prompt.rs
index b0d8001..a76397e 100644
--- a/src/prompt.rs
+++ b/src/prompt.rs
@@ -360,19 +360,22 @@ pub fn security_reproduction(commit: &str, finding: &str, test: &std::path::Path
pub const SECURITY_FIX_LINE: &str = "Security fix Ticket: ";
pub const SECURITY_FIX_SPEC_LINE: &str = "Security fix Spec: ";
pub const STAGED_PROPOSAL_MARKER: &str = "Proposed public issue:";
+pub const STAGED_SPEC_MARKER: &str = "Proposed public spec:";
pub const STAGED_TITLE_MARKER: &str = "Title:";
pub const STAGED_BODY_MARKER: &str = "Body:";
+pub const STAGED_TICKET_MARKER: &str = "Ticket:";
+pub const STAGED_BLOCKED_BY_MARKER: &str = "Blocked by:";
pub fn security_fix(base: &str, url: &str, finding: &str) -> String {
format!(
"Publish the fix for this reproduced Security finding on Base branch `{base}`.\n\
Read the private record at {url} and the record below.\n\
- Follow the completed reproduction's fix size: single stages one Ticket; spec publishes a Spec with Tickets using `thirdshift-to-spec` and `thirdshift-to-tickets`.\n\
- On a public repository with a single fix, stage the proposed public issue text instead of creating the public issue directly: do not run `gh issue create` yourself. thirdshift validates the staged text and creates the `needs-triage` top issue from it. On a private repository, reuse the finding's issue as the top issue and preserve its body and evidence; add the bigger fix's Tickets as its native sub-issues. A spec still publishes its issues itself, as before.\n\
+ Follow the completed reproduction's fix size: single stages one Ticket; spec stages a Spec with Tickets, drafted with `thirdshift-to-spec` and `thirdshift-to-tickets`.\n\
+ On a public repository, stage the proposed public issue text instead of creating any public issue directly: do not run `gh issue create` yourself. thirdshift validates the staged text and creates the `needs-triage` top issue and its Tickets from it. On a private repository, reuse the finding's issue as the top issue and preserve its body and evidence; add the bigger fix's Tickets as its native sub-issues. Only a private bigger fix publishes its issues itself.\n\
Every new issue is terse: it 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. This overrides the skills' templates.\n\
- A Spec's Tickets must be new, open, labelled `ready-for-agent`, linked as native sub-issues with their native blocking links, and have no sub-issues of their own. Read the links back before finishing.\n\
+ A Spec's Tickets must be new, open, labelled `ready-for-agent`, linked as native sub-issues with their native blocking links, and have no sub-issues of their own. On a public repository thirdshift makes those links from the staged text; read the staged blocking edges back before finishing.\n\
This session changes no repository source, commits and pushes nothing, opens no pull request, and does not implement the fix. thirdshift checks every issue, marks the top issue ready, labels all fix issues security-fix and dispatches its Run or Spec run.\n\
- End your final message for a single public fix with exactly:\n{STAGED_PROPOSAL_MARKER}\n{STAGED_TITLE_MARKER} \n{STAGED_BODY_MARKER}\n \nFor a spec, end with exactly `{SECURITY_FIX_SPEC_LINE}`, naming the top issue.\n\n\
+ End your final message for a single public fix with exactly:\n{STAGED_PROPOSAL_MARKER}\n{STAGED_TITLE_MARKER} \n{STAGED_BODY_MARKER}\n \nFor a public spec, end with exactly:\n{STAGED_SPEC_MARKER}\n{STAGED_TITLE_MARKER} \n{STAGED_BODY_MARKER}\n\n{STAGED_TICKET_MARKER} \n{STAGED_BODY_MARKER}\n\n{STAGED_BLOCKED_BY_MARKER} none\n{STAGED_TICKET_MARKER} \n{STAGED_BODY_MARKER}\n\n{STAGED_BLOCKED_BY_MARKER} 1\nwith each later Ticket naming the 1-based positions of the Tickets that block it, or none. For a private bigger fix, end with exactly `{SECURITY_FIX_SPEC_LINE}`, naming the top issue.\n\n\
Private record:\n{finding}\n\n{HEADLESS}"
)
}
diff --git a/src/security/fixing.rs b/src/security/fixing.rs
index f8e98fe..ab60079 100644
--- a/src/security/fixing.rs
+++ b/src/security/fixing.rs
@@ -75,11 +75,12 @@ pub fn publish(
let message =
sessions.run_to_final_message(Purpose::Security, "security-fix-publishing", &prompt)?;
let github = GitHub::new();
- // A single public fix stages its proposed text for validation;
- // thirdshift creates the issue from it, and the session has no
- // independent public-issue path. Anything else keeps the
- // session-created issue path until staged specs land.
- let issue = if size == FixSize::Single && record.private_issue_number().is_none() {
+ // A public fix stages its proposed text for validation;
+ // thirdshift creates the issues from it, and the session has no
+ // independent public-issue path. A private bigger fix keeps the
+ // session-created issue path: its issues stay private with the
+ // repository.
+ let issue = if record.private_issue_number().is_none() {
// The session has no independent public-issue path: anything
// created during its window is a side-effect leak outside the
// validated staged path. Close it to stop further interaction,
@@ -98,16 +99,20 @@ pub fn publish(
"the Security fix publishing session created public issue(s) outside the staged path: {numbers}"
);
}
- let Some((title, body)) = message.as_deref().and_then(parse_staged_proposal) else {
- bail!(
- "the Security fix publishing session did not stage its proposed public issue text"
- );
- };
- // Validate the staged text before any public issue exists;
- // a rejection blocks creation, so no public issue leaks
- // private write-up text.
- record.check_public_fix_text(&format!("{title}\n\n{body}"), url)?;
- github.create_issue_in(repo, &title, &body, &[NEEDS_TRIAGE])?
+ if size == FixSize::Single {
+ let Some((title, body)) = message.as_deref().and_then(parse_staged_proposal) else {
+ bail!(
+ "the Security fix publishing session did not stage its proposed public issue text"
+ );
+ };
+ // Validate the staged text before any public issue exists;
+ // a rejection blocks creation, so no public issue leaks
+ // private write-up text.
+ record.check_public_fix_text(&format!("{title}\n\n{body}"), url)?;
+ github.create_issue_in(repo, &title, &body, &[NEEDS_TRIAGE])?
+ } else {
+ publish_staged_spec(&github, repo, record, url, message.as_deref())?
+ }
} else {
let line = message
.as_deref()
@@ -189,6 +194,147 @@ pub fn publish(
})
}
+/// One staged Spec Ticket the publishing session proposed: its title, its
+/// body, and the 0-based indices of the Tickets it is blocked by.
+struct StagedTicket {
+ title: String,
+ body: String,
+ blocked_by: Vec,
+}
+
+/// The public Spec the publishing session staged for validation: its
+/// proposed top-issue `(title, body)` and its Tickets, instead of
+/// session-created public issues.
+struct StagedSpec {
+ title: String,
+ body: String,
+ tickets: Vec,
+}
+
+/// The staged Spec the publishing session proposed, with every Ticket's
+/// blocking edges as 0-based indices. `None` when the final message holds
+/// no well-formed staged spec: no marker, no top title or body, no Ticket,
+/// an empty title or body, or a blocking edge that is not `none` or a list
+/// of 1-based Ticket positions naming another Ticket.
+fn parse_staged_spec_proposal(message: &str) -> Option {
+ let (_, staged) = message.rsplit_once(prompt::STAGED_SPEC_MARKER)?;
+ let staged = staged.trim_start_matches(['\n', '\r']);
+ let (title_line, rest) = staged.split_once('\n')?;
+ let title = title_line.strip_prefix(prompt::STAGED_TITLE_MARKER)?.trim();
+ let rest = rest.strip_prefix(prompt::STAGED_BODY_MARKER)?;
+ let (spec_body, tickets_text) = match rest.split_once(prompt::STAGED_TICKET_MARKER) {
+ Some((body, tickets)) => (body, Some(tickets)),
+ None => (rest, None),
+ };
+ let tickets_text = tickets_text?;
+ if title.is_empty() || spec_body.trim().is_empty() {
+ return None;
+ }
+ let mut tickets = Vec::new();
+ for section in tickets_text.split(prompt::STAGED_TICKET_MARKER) {
+ let (title_line, rest) = section.split_once('\n')?;
+ let title = title_line.trim();
+ if title.is_empty() {
+ return None;
+ }
+ let rest = rest.strip_prefix(prompt::STAGED_BODY_MARKER)?;
+ let (body, blocked) = rest.split_once(prompt::STAGED_BLOCKED_BY_MARKER)?;
+ if body.trim().is_empty() {
+ return None;
+ }
+ tickets.push((title.to_string(), body.trim().to_string(), blocked));
+ }
+ if tickets.is_empty() {
+ return None;
+ }
+ let count = tickets.len();
+ let mut parsed = Vec::with_capacity(count);
+ for (index, (title, body, blocked)) in tickets.into_iter().enumerate() {
+ parsed.push(StagedTicket {
+ title,
+ body,
+ blocked_by: parse_blocked_by(blocked, index, count)?,
+ });
+ }
+ Some(StagedSpec {
+ title: title.to_string(),
+ body: spec_body.trim().to_string(),
+ tickets: parsed,
+ })
+}
+
+/// The `Blocked by:` trailer of one staged Ticket as 0-based indices into
+/// its Spec's Tickets: `none` is empty, else comma-separated 1-based
+/// positions. Reading stops at the next Ticket section or the message end,
+/// so only the trailer's first line counts. `None` unless every position
+/// names another Ticket of the `count` staged.
+fn parse_blocked_by(blocked: &str, index: usize, count: usize) -> Option> {
+ let line = blocked.lines().next().unwrap_or_default().trim();
+ let line = line.trim_end_matches(['.', ';']);
+ if line.eq_ignore_ascii_case("none") {
+ return Some(Vec::new());
+ }
+ let mut edges = Vec::new();
+ for position in line
+ .split(',')
+ .map(str::trim)
+ .filter(|edge| !edge.is_empty())
+ {
+ let position: usize = position.parse().ok()?;
+ if position == 0 || position > count || position - 1 == index {
+ return None;
+ }
+ let edge = position - 1;
+ if !edges.contains(&edge) {
+ edges.push(edge);
+ }
+ }
+ if edges.is_empty() {
+ return None;
+ }
+ Some(edges)
+}
+
+/// Validate the staged Spec proposal and create its public issues: the
+/// `needs-triage` top issue and one `ready-for-agent` Ticket per staged
+/// Ticket, linked as native sub-issues with their staged blocking edges.
+/// Every staged text is validated before any public issue exists, so a
+/// rejection creates nothing and leaks no private write-up text.
+fn publish_staged_spec(
+ github: &GitHub,
+ repo: &Repo,
+ record: &SecurityRecord,
+ url: &str,
+ message: Option<&str>,
+) -> Result {
+ let Some(staged) = message.and_then(parse_staged_spec_proposal) else {
+ bail!("the Security fix publishing session did not stage its proposed public spec text");
+ };
+ record.check_public_fix_text(&format!("{}\n\n{}", staged.title, staged.body), url)?;
+ for ticket in &staged.tickets {
+ record.check_public_fix_text(&format!("{}\n\n{}", ticket.title, ticket.body), url)?;
+ }
+ let spec = github.create_issue_in(repo, &staged.title, &staged.body, &[NEEDS_TRIAGE])?;
+ let mut tickets = Vec::with_capacity(staged.tickets.len());
+ for ticket in &staged.tickets {
+ tickets.push(github.create_issue_in(
+ repo,
+ &ticket.title,
+ &ticket.body,
+ &[READY_FOR_AGENT],
+ )?);
+ }
+ for ticket in &tickets {
+ github.add_sub_issue(&spec, ticket)?;
+ }
+ for (ticket, staged) in tickets.iter().zip(&staged.tickets) {
+ for edge in &staged.blocked_by {
+ github.add_blocked_by(ticket, &tickets[*edge])?;
+ }
+ }
+ Ok(spec)
+}
+
/// The single public fix the publishing session staged for validation:
/// its proposed `(title, body)`, instead of a session-created public issue.
fn parse_staged_proposal(message: &str) -> Option<(String, String)> {
@@ -232,4 +378,37 @@ mod tests {
let message = "Security fix Ticket: https://github.com/acme/widgets/issues/8\n";
assert!(parse_staged_proposal(message).is_none());
}
+
+ #[test]
+ fn staged_spec_parses_top_issue_and_tickets_with_blocking() {
+ let message = "Reasoning\nProposed public spec:\nTitle: Bound accepted input\nBody:\nBound input across storage and transport. Private record: https://example.invalid/advisory\nTicket: Bound storage input\nBody:\nBound storage input. Private record: https://example.invalid/advisory\nBlocked by: none\nTicket: Bound transport input\nBody:\nBound transport input. Private record: https://example.invalid/advisory\nBlocked by: 1\n";
+ let spec = parse_staged_spec_proposal(message).expect("staged spec parses");
+ assert_eq!(spec.title, "Bound accepted input");
+ assert!(spec.body.contains("storage and transport"));
+ assert_eq!(spec.tickets.len(), 2);
+ assert_eq!(spec.tickets[0].title, "Bound storage input");
+ assert!(spec.tickets[0].blocked_by.is_empty());
+ assert_eq!(spec.tickets[1].title, "Bound transport input");
+ assert_eq!(spec.tickets[1].blocked_by, vec![0]);
+ }
+
+ #[test]
+ fn staged_spec_rejects_a_bare_url_final_line() {
+ let message = "Security fix Spec: https://github.com/acme/widgets/issues/8\n";
+ assert!(parse_staged_spec_proposal(message).is_none());
+ }
+
+ #[test]
+ fn staged_spec_rejects_bad_blocking_edges() {
+ for message in [
+ "Proposed public spec:\nTitle: Bound accepted input\nBody:\nBound input. Private record: https://example.invalid/advisory\nTicket: Only ticket\nBody:\nOnly ticket. Private record: https://example.invalid/advisory\nBlocked by: 1\n",
+ "Proposed public spec:\nTitle: Bound accepted input\nBody:\nBound input. Private record: https://example.invalid/advisory\nTicket: Only ticket\nBody:\nOnly ticket. Private record: https://example.invalid/advisory\nBlocked by: 2\n",
+ "Proposed public spec:\nTitle: Bound accepted input\nBody:\nBound input. Private record: https://example.invalid/advisory\n",
+ ] {
+ assert!(
+ parse_staged_spec_proposal(message).is_none(),
+ "bad staged spec parses: {message}"
+ );
+ }
+ }
}
diff --git a/tests/fakes/gh.rs b/tests/fakes/gh.rs
index 21bd3dd..1b83ed9 100644
--- a/tests/fakes/gh.rs
+++ b/tests/fakes/gh.rs
@@ -53,6 +53,12 @@
//! `gh api --method DELETE repos//issues//labels/