Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
71d4f28
Keep a private project board in sync with the triaged advisories
alexandre-daubois Oct 28, 2026
885d860
Put the triage comment and the ranking inputs on the board items
alexandre-daubois Oct 28, 2026
5445850
Score a report that needs untrusted data in a developer argument as r…
alexandre-daubois Oct 28, 2026
21a309d
Score the deployment a policy exclusion describes, whatever the exclu…
alexandre-daubois Oct 28, 2026
183b673
Call the lower priority "When possible", which reads less like never
alexandre-daubois Oct 28, 2026
5ec3443
Name the component of each report and group its duplicates under the …
alexandre-daubois Oct 29, 2026
d3b9640
Call the duplicate group field Original report
alexandre-daubois Oct 29, 2026
ca56cee
Let the model name the component, so shared code goes to the SAPI or …
alexandre-daubois Oct 29, 2026
18b3676
Create the board items as issues of a private repository, with duplic…
alexandre-daubois Oct 29, 2026
67e96d5
Find an issue a failed run left off the board instead of creating it …
alexandre-daubois Oct 29, 2026
450d59c
Write the issues with a token of their own that cannot reach the advi…
alexandre-daubois Oct 29, 2026
23bcb9c
Add the issues to the project with the token that sees them
alexandre-daubois Oct 29, 2026
64a43ee
Recognise an issue item by its marker and let the main token read the…
alexandre-daubois Oct 29, 2026
f375d65
List the project items through GraphQL, since the REST listing hides …
alexandre-daubois Oct 29, 2026
f09765f
Triage again an advisory whose item has no priority yet
alexandre-daubois Oct 29, 2026
ecb6cbf
Harden the issue items after review
alexandre-daubois Oct 29, 2026
c27d6f4
Drop the severity together with the invalid vector it rates
alexandre-daubois Oct 29, 2026
5427a07
Leave the advisories without comment by default and count the failed …
alexandre-daubois Oct 29, 2026
80cd17b
Retry what a stopped run left pending and harden the board after review
alexandre-daubois Oct 29, 2026
8cfeda0
Give a reattached issue the state of its advisory
alexandre-daubois Oct 29, 2026
18a351d
Nest a duplicate under the oldest report of its chain that has an iss…
alexandre-daubois Oct 30, 2026
a6cc593
Use a made-up name for the private issues repository in the tests
alexandre-daubois Oct 30, 2026
4a19197
Gather the duplicates of an original without issue under the oldest o…
alexandre-daubois Oct 30, 2026
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
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

[![CI](https://github.com/php/tamis/actions/workflows/ci.yml/badge.svg)](https://github.com/php/tamis/actions/workflows/ci.yml)

tamis does a first-pass triage of private vulnerability reports on GitHub. When a new security advisory arrives, an LLM agent investigates it against the repository and tamis posts the findings as a comment on the advisory.
tamis does a first-pass triage of private vulnerability reports on GitHub. When a new security advisory arrives, an LLM agent investigates it against the repository and tamis puts the findings on a private project board, or posts them as a comment on the advisory with `comment: true`.

For each report, tamis:

Expand Down Expand Up @@ -36,7 +36,7 @@ The agent runs in an isolated container driven by [harness](https://github.com/a

## Before installing

GitHub cannot post confidential advisory comments yet, so the reporter reads the comment. tamis also sends the report to the model provider you choose. Check that your disclosure policy allows both.
GitHub cannot post confidential advisory comments yet, so with `comment: true` the reporter reads the comment. tamis also sends the report to the model provider you choose. Check that your disclosure policy allows both.

## Quick start

Expand Down Expand Up @@ -91,6 +91,8 @@ jobs:
bot-login: your-app-slug[bot]
# List every branch your project supports. Each ref must exist in the checkout.
references: master origin/release-branch
# Without a project board, post the triage on the advisory instead.
comment: true
```

Pin the commit of a [release](https://github.com/php/tamis/releases) tag, since the action then runs the attested binary of that release instead of building tamis from source. Set `since` to the installation date so tamis leaves existing advisories alone. Keep the workflow repository private, since the action uploads the reports there as artifacts kept for seven days. The [setup guide](docs/setup.md) covers source branches and provider spending limits.
Expand Down
18 changes: 17 additions & 1 deletion action.yml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
name: Tamis Security
description: First-pass LLM triage of new GitHub security advisories, posted as an advisory comment.
description: First-pass LLM triage of new GitHub security advisories, on a private project board or as an advisory comment.
author: Alexandre Daubois
branding:
icon: shield
Expand Down Expand Up @@ -39,6 +39,18 @@ inputs:
runner-image:
description: Image the agent runs in, instead of the tamis agent image pinned by digest and checked against its build attestation. It needs the agent CLI, git, curl and harness-proxy, and is not checked.
required: false
project:
description: Private organisation project kept in sync with the triaged advisories, as `org/number`. The token needs the organisation "Projects" permission in read and write.
required: false
issues-repository:
description: Private repository the board items are created in as issues, so duplicates become sub-issues of their original report. Drafts when unset. tamis refuses a public repository and the repository of the advisories. The token needs the "Issues" permission in read on it, `issues-token` writes the issues.
required: false
issues-token:
description: GitHub App installation token for `issues-repository` only, with the "Issues" and organisation "Projects" permissions in read and write. tamis refuses it when it also reaches `repository`, so a bug cannot publish a report there.
required: false
comment:
description: Post the triage as a comment on the advisory. Off by default, every triage then only reaches `project`, which must be set.
default: "false"
references:
description: Whitespace-separated local git references, including each supported release branch. The first is analysed, the cited code is located on all of them. All must have been fetched.
required: true
Expand Down Expand Up @@ -125,6 +137,10 @@ runs:
DAYBREAK: ${{ inputs.daybreak }}
OUTPUT_DIR: ${{ steps.reports.outputs.path }}
REFERENCES: ${{ inputs.references }}
PROJECT: ${{ inputs.project }}
ISSUES_REPOSITORY: ${{ inputs.issues-repository }}
ISSUES_TOKEN: ${{ inputs.issues-token }}
COMMENT: ${{ inputs.comment }}
- name: Store private reports
if: always() && steps.triage.outcome != 'skipped' && steps.reports.outcome == 'success'
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
Expand Down
24 changes: 20 additions & 4 deletions cmd/tamis/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,10 +45,12 @@ func main() {

func run(ctx context.Context, log *slog.Logger) error {
cfg := triage.Config{
Repo: os.Getenv("REPOSITORY"),
Advisory: os.Getenv("ADVISORY"),
RepoPath: os.Getenv("REPO_PATH"),
BotLogin: os.Getenv("BOT_LOGIN"),
Repo: os.Getenv("REPOSITORY"),
Advisory: os.Getenv("ADVISORY"),
RepoPath: os.Getenv("REPO_PATH"),
BotLogin: os.Getenv("BOT_LOGIN"),
Project: os.Getenv("PROJECT"),
IssuesRepository: os.Getenv("ISSUES_REPOSITORY"),
}
if cfg.Repo == "" {
cfg.Repo = os.Getenv("GITHUB_REPOSITORY")
Expand All @@ -73,10 +75,24 @@ func run(ctx context.Context, log *slog.Logger) error {
if token == "" {
return errors.New("GH_TOKEN is required")
}
if cfg.IssuesRepository != "" {
issues := os.Getenv("ISSUES_TOKEN")
if issues == "" || issues == token {
return errors.New("ISSUES_REPOSITORY needs an ISSUES_TOKEN of its own, which only reaches that repository")
}
cfg.Issues = github.New(issues)
}
eng, agent, model, err := newEngine(os.Getenv("ENGINE"), os.Getenv("MODEL"), os.Getenv("LLM_API_KEY"))
if err != nil {
return err
}
comment := false
if v := os.Getenv("COMMENT"); v != "" {
if comment, err = strconv.ParseBool(v); err != nil {
return fmt.Errorf("COMMENT: %w", err)
}
}
cfg.NoComment = !comment
daybreak := false
if v := os.Getenv("DAYBREAK"); v != "" {
if daybreak, err = strconv.ParseBool(v); err != nil {
Expand Down
5 changes: 4 additions & 1 deletion cmd/tamis/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,10 +65,13 @@ func TestRunValidatesConfiguration(t *testing.T) {
"invalid daybreak": {map[string]string{"REPOSITORY": "o/r", "SINCE": "2026-10-22", "GH_TOKEN": "t", "LLM_API_KEY": "k", "DAYBREAK": "maybe"}, "DAYBREAK"},
"daybreak needs openai": {map[string]string{"REPOSITORY": "o/r", "SINCE": "2026-10-22", "GH_TOKEN": "t", "LLM_API_KEY": "k", "DAYBREAK": "true"}, "DAYBREAK requires ENGINE=openai"},
"output required": {map[string]string{"REPOSITORY": "o/r", "SINCE": "2026-10-22", "GH_TOKEN": "t", "LLM_API_KEY": "k"}, "OUTPUT_DIR is required"},
"issues token required": {map[string]string{"REPOSITORY": "o/r", "SINCE": "2026-10-22", "GH_TOKEN": "t", "LLM_API_KEY": "k", "ISSUES_REPOSITORY": "o/r-security"}, "ISSUES_REPOSITORY needs an ISSUES_TOKEN of its own"},
"issues token reused": {map[string]string{"REPOSITORY": "o/r", "SINCE": "2026-10-22", "GH_TOKEN": "t", "LLM_API_KEY": "k", "ISSUES_REPOSITORY": "o/r-security", "ISSUES_TOKEN": "t"}, "ISSUES_REPOSITORY needs an ISSUES_TOKEN of its own"},
"invalid comment": {map[string]string{"REPOSITORY": "o/r", "SINCE": "2026-10-22", "GH_TOKEN": "t", "LLM_API_KEY": "k", "COMMENT": "maybe"}, "COMMENT"},
"references required": {map[string]string{"REPOSITORY": "o/r", "SINCE": "2026-10-22", "GH_TOKEN": "t", "LLM_API_KEY": "k", "OUTPUT_DIR": t.TempDir()}, "REFERENCES must list"},
} {
t.Run(name, func(t *testing.T) {
for _, k := range []string{"REPOSITORY", "GITHUB_REPOSITORY", "ADVISORY", "REPO_PATH", "BOT_LOGIN", "DAYBREAK", "SINCE", "GH_TOKEN", "ENGINE", "MODEL", "LLM_API_KEY", "OUTPUT_DIR", "REFERENCES"} {
for _, k := range []string{"REPOSITORY", "GITHUB_REPOSITORY", "ADVISORY", "REPO_PATH", "BOT_LOGIN", "DAYBREAK", "SINCE", "GH_TOKEN", "ENGINE", "MODEL", "LLM_API_KEY", "OUTPUT_DIR", "REFERENCES", "PROJECT", "ISSUES_REPOSITORY", "ISSUES_TOKEN", "COMMENT"} {
t.Setenv(k, tc.env[k])
}

Expand Down
6 changes: 5 additions & 1 deletion docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,15 @@
| `engine` | `claude` | `claude` for Claude Code and the Anthropic API, `openai` for Codex and the OpenAI API. |
| `model` | `claude-opus-5-5` or `gpt-5.5` | Model of the reader, the duplicate search and the exact confirmation. `gpt-6.1-sol` with a Codex access token. |
| `daybreak` | `false` | Turns on OpenAI's Daybreak cyber access program in Codex. Needs `engine: openai` and an API key whose organisation OpenAI approved for it. |
| `comment` | `false` | Post the triage as a comment on the advisory. Off, every triage only reaches `project`, which must then be set, and the board records the failed attempts in `Attempts`. |
| `project` | | Private organisation project kept in sync with the advisories, as `org/number`. See [the project board](setup.md#5-project-board). |
| `issues-repository` | | Private repository the board items are created in as issues, so duplicates become sub-issues of their original report. Drafts when unset. See [the project board](setup.md#5-project-board). |
| `issues-token` | | Installation token for `issues-repository` only, with the "Issues" and organisation "Projects" permissions in read and write. Required with `issues-repository`. |
| `runner-image` | tamis agent image, pinned by digest | Image the agent runs in. It must contain the agent CLI, git, curl and `harness-proxy`. The action skips the attestation check for a custom image. |

The action refuses to run from a public workflow repository or from a `uses:` that is not a full commit SHA. Update that SHA through a reviewed pull request.

Outside GitHub Actions, tamis reads the same settings from environment variables: `LLM_API_KEY`, `GH_TOKEN`, `BOT_LOGIN`, `REPOSITORY`, `REPO_PATH`, `SINCE`, `ADVISORY`, `ENGINE`, `MODEL`, `DAYBREAK`, `RUNNER_IMAGE` and `REFERENCES`. `OUTPUT_DIR` must name a private directory for the reports. The action creates it in the runner's temporary storage and uploads it to the workflow repository with a seven-day retention.
Outside GitHub Actions, tamis reads the same settings from environment variables: `LLM_API_KEY`, `GH_TOKEN`, `BOT_LOGIN`, `REPOSITORY`, `REPO_PATH`, `SINCE`, `ADVISORY`, `ENGINE`, `MODEL`, `DAYBREAK`, `RUNNER_IMAGE`, `REFERENCES`, `PROJECT`, `ISSUES_REPOSITORY`, `ISSUES_TOKEN` and `COMMENT`. `OUTPUT_DIR` must name a private directory for the reports. The action creates it in the runner's temporary storage and uploads it to the workflow repository with a seven-day retention.

## Source branches

Expand Down
2 changes: 1 addition & 1 deletion docs/how-it-works.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ Each run goes through four stages. Two models take part, and no model sees both

## 1. Selection

tamis lists the repository's advisories and keeps those in the `triage` state created after `since`, or only the one given in `advisory`. It skips an advisory when a comment from `bot-login` holds the tamis marker, and ignores a marker posted by any other account.
tamis lists the repository's advisories and keeps those in the `triage` state created after `since`, or only the one given in `advisory`. It skips an advisory when a comment from `bot-login` holds the tamis marker, and ignores a marker posted by any other account. With a [project board](setup.md#5-project-board), an advisory in `triage` that has a tamis comment but no item is triaged once more for the board, without a new comment. With `comment` left at `false`, the item takes the place of the comments: tamis skips an advisory whose item has a priority other than `Analysis failed, retrying`, and counts the failed attempts in `Attempts`.

A failed triage posts a comment that says so, with its own marker, and tamis retries it on the next runs. After three failures, the comment asks a maintainer to review the report and tamis stops retrying. tamis triages an advisory once, so an edit to the report afterwards starts no new triage.

Expand Down
66 changes: 65 additions & 1 deletion docs/setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,72 @@ Keep `persist-credentials: false` on the checkout so no token lands in `.git/con

## 4. First run

Trigger the workflow by hand with the `advisory` input set to a test GHSA. tamis triages that advisory whatever its state and date, and posts the comment on it.
Trigger the workflow by hand with the `advisory` input set to a test GHSA. tamis triages that advisory whatever its state and date, and puts it on the board, or posts the comment on it with `comment: true`.

Download the private `tamis-reports` artifact and check that the comment names embargoed advisories only as exact duplicates. Then let the schedule take over. Artifact access follows the permissions of the private repository, so grant it to the security team only.

GitHub Actions has no trigger for security advisories, so the schedule polls every 15 minutes. A run with no new advisory neither starts the agent nor calls the model.

## 5. Project board

tamis can keep a private organisation project in sync with the advisories, one draft item each, so the security team sorts them by priority. Give the App the organisation "Projects" permission in read and write, create a private project and add these fields by hand:

| Field | Type | Options, in this order |
|---|---|---|
| `GHSA` | Text | |
| `Duplicate of` | Text | |
| `Duplicates` | Number | |
| `Reported` | Date | |
| `Priority` | Single select | `Look first`, `When possible`, `Duplicate`, `Analysis failed`, `Analysis failed, retrying`, `Analysis pending` |
| `Severity` | Single select | `Critical`, `High`, `Medium`, `Low` |
| `State` | Single select | `triage`, `draft`, `published`, `closed`, `withdrawn` |
| `Policy exclusion` | Single select | `Yes`, `No` |
| `Located` | Single select | `Yes`, `No` |
| `Component` | Text | |
| `Original report` | Text | |
| `Attempts` | Number | |

Then set `project` to `your-org/NUMBER`. When the project is public, or a field or an option is missing or renamed, tamis leaves the board out of the run, still triages and comments with `comment: true` and skips every advisory without it, and fails the run so the error shows.

The code sets `Priority`:

- `Look first` for a critical or high severity, a faulty statement found in the repository and no policy exclusion,
- `When possible` otherwise,
- `Duplicate` behind the oldest high-confidence exact duplicate the comment shows, which `Duplicate of` names,
- `Analysis pending` while tamis triages again an advisory it commented before the board, `Analysis failed, retrying` after a failed attempt with another to come, and `Analysis failed` once tamis gives up. The draft body then explains the status instead of holding the triage comment.

The model estimates `Severity` from its CVSS vector and ignores the one the reporter claims. `Duplicates` counts the items that name the advisory in `Duplicate of`. `Component` is the directory of the extension, SAPI or subsystem the model names, such as `sapi/fpm` for FastCGI code a report reaches through FPM, once tamis checks the repository has that directory. Without one, tamis takes the directory of the faulty statement two levels deep, and leaves it empty when the statement was not located. `Original report` holds the oldest report of a group of duplicates, on that report and on each of its duplicates, so a view grouped by it shows each original with its duplicates.

At the end of each run, tamis copies the state of every advisory to `State`, recounts `Duplicates` and ranks every triage again from `Severity`, `Policy exclusion`, `Located` and `Duplicate of`, so a new priority rule reaches the board with a release. None of it calls the model. A `Priority` changed by hand comes back at the next run, while a change to one of those four fields lasts. The REST API cannot archive items, so filter the views on `State` instead, for example a table on `State:triage,draft` sorted by `Priority` then `Reported`.

An advisory in the `triage` state that tamis commented but that has no item, for example one triaged before the board existed, is triaged again without a new comment. When that triage fails, the item gets `Analysis failed` and the advisory is not retried. Deleting an item by hand triggers that triage again on the next run.

With `issues-repository` set, for example to `your-org/your-project-security`, new items are issues of that repository instead of drafts, and each duplicate becomes a sub-issue of its `Original report`. tamis closes an issue when its advisory is published, closed or withdrawn. Everyone with access to that repository reads the reports, and each new issue notifies its watchers. tamis refuses a public repository and the repository of the advisories, and then leaves the board out of the run.

tamis writes the issues and adds them to the project with `issues-token`, a second installation token that reaches the issues repository only, and refuses it when it also reaches `repository`. `github-token` updates the fields of the items, so it reads the issues repository too, but keep its "Issues" permission at read, so neither token can open an issue on the project:

```yaml
- uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # v3.3.0
id: app
with:
client-id: ${{ vars.TAMIS_CLIENT_ID }}
private-key: ${{ secrets.TAMIS_PRIVATE_KEY }}
owner: your-org
repositories: your-project,your-project-security
permission-repository-advisories: write
permission-organization-projects: write
permission-issues: read
- uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # v3.3.0
id: issues
with:
client-id: ${{ vars.TAMIS_CLIENT_ID }}
private-key: ${{ secrets.TAMIS_PRIVATE_KEY }}
owner: your-org
repositories: your-project-security
permission-issues: write
permission-organization-projects: write
```

Then pass `issues-token: ${{ steps.issues.outputs.token }}`. The App needs the "Issues" permission in read and write and an installation on the issues repository.

With `comment` left at `false`, tamis posts nothing on the advisories. The item of an advisory says it was triaged, `Attempts` counts its failed attempts in place of the failure comments, and deleting an item triages its advisory again on the next run.
Loading
Loading