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
4 changes: 4 additions & 0 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,9 @@ inputs:
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
packs:
description: Whitespace-separated built-in guidance packs added to the agent's method, such as `php-src`. None by default.
required: false

runs:
# The agent runs in its own hardened container, so tamis itself runs on the runner, next to Docker.
Expand Down Expand Up @@ -141,6 +144,7 @@ runs:
ISSUES_REPOSITORY: ${{ inputs.issues-repository }}
ISSUES_TOKEN: ${{ inputs.issues-token }}
COMMENT: ${{ inputs.comment }}
PACKS: ${{ inputs.packs }}
- name: Store private reports
if: always() && steps.triage.outcome != 'skipped' && steps.reports.outcome == 'success'
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
Expand Down
9 changes: 8 additions & 1 deletion cmd/tamis/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import (
"github.com/php/tamis/internal/sandbox"
"github.com/php/tamis/internal/setup"
"github.com/php/tamis/internal/triage"
"github.com/php/tamis/skill"
)

// defaultCodexTokenModel is Codex's default with a ChatGPT account, which does not serve the API models.
Expand Down Expand Up @@ -85,6 +86,7 @@ func initProject(ctx context.Context, args []string, out io.Writer) error {
provider := fs.String("engine", "claude", "LLM provider, claude or openai")
appName := fs.String("app-name", "", `name of the GitHub App, "<org>-tamis" by default`)
withBoard := fs.Bool("board", false, "also create a private project board and fill it instead of commenting")
packs := fs.String("packs", "", "whitespace-separated guidance packs added to the agent's method, such as php-src")
err := fs.Parse(args)
if errors.Is(err, flag.ErrHelp) {
return nil
Expand All @@ -95,14 +97,17 @@ func initProject(ctx context.Context, args []string, out io.Writer) error {
org, _, _ := strings.Cut(*repository, "/")
refs := strings.Fields(*references)
if org == "" || len(refs) == 0 {
return errors.New("usage: tamis init -repository org/name -references \"main ...\" [-runner name] [-engine claude|openai] [-app-name name] [-board]")
return errors.New("usage: tamis init -repository org/name -references \"main ...\" [-runner name] [-engine claude|openai] [-app-name name] [-board] [-packs \"php-src ...\"]")
}
if len(refs) > 10 {
return errors.New("-references must list at most ten references")
}
if *provider != "claude" && *provider != "openai" {
return fmt.Errorf("unknown engine %q, expected claude or openai", *provider)
}
if _, err := skill.Packs(strings.Fields(*packs)); err != nil {
return err
}
token, apiKey := os.Getenv("GH_TOKEN"), os.Getenv("LLM_API_KEY")
if token == "" || apiKey == "" {
return errors.New("GH_TOKEN and LLM_API_KEY are required")
Expand All @@ -116,6 +121,7 @@ func initProject(ctx context.Context, args []string, out io.Writer) error {
AppName: cmp.Or(*appName, org+"-tamis"),
Since: time.Now().UTC().Format(time.DateOnly),
Board: *withBoard,
Packs: strings.Fields(*packs),
}
app := func(ctx context.Context, org string, manifest map[string]any) (string, error) {
return setup.AppCode(ctx, org, manifest, func(u string) {
Expand Down Expand Up @@ -154,6 +160,7 @@ func run(ctx context.Context, log *slog.Logger) error {
BotLogin: os.Getenv("BOT_LOGIN"),
Project: os.Getenv("PROJECT"),
IssuesRepository: os.Getenv("ISSUES_REPOSITORY"),
Packs: strings.Fields(os.Getenv("PACKS")),
}
if cfg.Repo == "" {
cfg.Repo = os.Getenv("GITHUB_REPOSITORY")
Expand Down
3 changes: 2 additions & 1 deletion cmd/tamis/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ func TestRunValidatesConfiguration(t *testing.T) {
"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", "PROJECT", "ISSUES_REPOSITORY", "ISSUES_TOKEN", "COMMENT"} {
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", "PACKS"} {
t.Setenv(k, tc.env[k])
}

Expand Down Expand Up @@ -114,6 +114,7 @@ func TestInitValidatesArguments(t *testing.T) {
"unknown engine": {[]string{"-repository", "acme/project", "-references", "main", "-engine", "gemini"}, "t", "k", `unknown engine "gemini"`},
"no token": {[]string{"-repository", "acme/project", "-references", "main"}, "", "k", "GH_TOKEN and LLM_API_KEY are required"},
"no key": {[]string{"-repository", "acme/project", "-references", "main"}, "t", "", "GH_TOKEN and LLM_API_KEY are required"},
"unknown pack": {[]string{"-repository", "acme/project", "-references", "main", "-packs", "php-src nope"}, "t", "k", `unknown pack "nope"`},
"unknown flag": {[]string{"-repo", "acme/project"}, "t", "k", "flag provided but not defined: -repo"},
} {
t.Run(name, func(t *testing.T) {
Expand Down
11 changes: 10 additions & 1 deletion docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
| `api-key` | | Required. API key of the model provider, or a Codex access token with `engine: openai`. |
| `github-token` | | Required. Token able to read and comment the repository advisories. |
| `references` | | Required, at most ten. Whitespace-separated local git references, one per supported branch. tamis analyses the first and locates the cited code on all of them. A missing reference fails the analysis. |
| `packs` | | Whitespace-separated [guidance packs](#guidance-packs) added to the agent's method, such as `php-src`. An unknown pack fails the run. |
| `since` | | Triage only advisories created after this date, `2026-10-22` or RFC 3339. Required with `comment: true` unless `advisory` is set. Without it, every advisory in triage goes on the board. |
| `advisory` | | Triage this GHSA only, whatever its state and creation date. |
| `bot-login` | Owner of the token | Login tamis posts as, such as `my-app[bot]`. Required with a GitHub App token. |
Expand All @@ -23,7 +24,7 @@

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`, `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.
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`, `COMMENT` and `PACKS`. `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 Expand Up @@ -67,6 +68,14 @@ In the workflow, keep a `timeout-minutes` of at least 240 and a single concurren

tamis appends a `.github/tamis.md` file from the project to the agent's instructions. Use it for what the model cannot guess, such as the scope, the public API, how the project scores CVSS and which configurations are realistic.

## Guidance packs

A pack is guidance shipped with tamis for one project or ecosystem, such as how its security team classifies reports and scores CVSS. tamis appends the packs named in `packs` to the agent's method, in that order, before the project's `.github/tamis.md`. They live in [`skill/packs`](../skill/packs), one Markdown file per pack:

| Pack | For |
|---|---|
| `php-src` | php-src and the PHP security classification |

## Security policy

The agent checks the report against the files that define what the project treats as a security issue. tamis looks for them among the tracked files of the repository root, `.github/` and `docs/`:
Expand Down
5 changes: 3 additions & 2 deletions docs/setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ gh auth refresh -s project,workflow
GH_TOKEN=$(gh auth token) LLM_API_KEY=sk-... tamis init -repository your-org/your-project -references "master origin/release-branch"
```

It opens your browser to confirm the GitHub App, then creates the private `your-org/tamis-runner` repository with its `tamis` environment, the App credentials and the provider key as secrets, and the workflow pinned to the latest release. `-runner` names another repository, `-engine openai` takes an OpenAI key, `-app-name` replaces the `your-org-tamis` App name and `-board` creates the project board instead of posting comments. Install the App from the address it prints, then go on with [the first run](#4-first-run). The sections below do the same by hand.
It opens your browser to confirm the GitHub App, then creates the private `your-org/tamis-runner` repository with its `tamis` environment, the App credentials and the provider key as secrets, and the workflow pinned to the latest release. `-runner` names another repository, `-engine openai` takes an OpenAI key, `-app-name` replaces the `your-org-tamis` App name, `-board` creates the project board instead of posting comments, and `-packs php-src` turns on the [guidance packs](configuration.md#guidance-packs) of the project. Install the App from the address it prints, then go on with [the first run](#4-first-run). The sections below do the same by hand.

## 1. Create the GitHub App

Expand Down Expand Up @@ -89,6 +89,7 @@ The command prints the project as `your-org/NUMBER`. It creates a private projec
| `Policy exclusion` | Single select | `Yes`, `No` |
| `Located` | Single select | `Yes`, `No` |
| `Component` | Text | |
| `Upstream` | Text | |
| `Original report` | Text | |
| `Attempts` | Number | |

Expand All @@ -101,7 +102,7 @@ The code sets `Priority`:
- `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`, or with `issues-repository` the sub-issues its issue gets, the duplicates of its duplicates included. `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.
The model estimates `Severity` from the security policy and its CVSS vector, and ignores the one the reporter claims. A policy or a guidance pack can set it below the vector's rating, as the `php-src` pack does for crashes. `Duplicates` counts the items that name the advisory in `Duplicate of`, or with `issues-repository` the sub-issues its issue gets, the duplicates of its duplicates included. `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. `Upstream` names the project the faulty code is bundled from, such as `lexbor` in php-src, when the fix belongs to that project, and stays empty for the repository's own code. A project created before `Upstream` existed needs that field before upgrading tamis: `gh project field-create NUMBER --owner your-org --name Upstream --data-type TEXT`. `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`.

Expand Down
9 changes: 7 additions & 2 deletions internal/board/board.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ const (
fieldExcluded = "Policy exclusion"
fieldLocated = "Located"
fieldComponent = "Component"
fieldUpstream = "Upstream"
fieldGroup = "Original report"
fieldAttempts = "Attempts"
)
Expand All @@ -58,6 +59,7 @@ var layout = []struct {
{fieldExcluded, "single_select", []string{"Yes", "No"}},
{fieldLocated, "single_select", []string{"Yes", "No"}},
{fieldComponent, "text", nil},
{fieldUpstream, "text", nil},
{fieldGroup, "text", nil},
{fieldAttempts, "number", nil},
}
Expand Down Expand Up @@ -103,7 +105,7 @@ func Create(ctx context.Context, gh Creator, org, title string) (string, error)
triage := github.ProjectView{
Name: "Triage",
Filter: "State:triage,draft",
Fields: []int64{ids["Title"], ids[fieldPriority], ids[fieldSeverity], ids[fieldComponent], ids[fieldDuplicates], ids[fieldReported]},
Fields: []int64{ids["Title"], ids[fieldPriority], ids[fieldSeverity], ids[fieldComponent], ids[fieldUpstream], ids[fieldDuplicates], ids[fieldReported]},
SortBy: []int64{ids[fieldPriority], ids[fieldReported]},
}
if err := gh.CreateProjectView(ctx, org, number, triage); err != nil {
Expand Down Expand Up @@ -181,6 +183,8 @@ type Triage struct {
Excluded, Located bool
// Component is the directory the model named, else the one of the located faulty statement, two levels deep.
Component string
// Upstream is the project the faulty code is bundled from, empty when it is the repository's own.
Upstream string
}

// Open loads the fields and items of project, given as "org/number". With issues set, new items are issues of
Expand Down Expand Up @@ -347,7 +351,7 @@ func (b *Board) Has(ghsa string) bool {
// TriageOf keeps what the board needs of r, with the oldest high-confidence exact duplicate the comment shows.
func TriageOf(r report.Report) Triage {
t := Triage{Severity: r.Severity, Excluded: len(r.PolicyExclusions) > 0, Located: r.Located}
t.Component = r.Component
t.Component, t.Upstream = r.Component, r.Upstream
if dir := path.Dir(r.Location.Path); t.Component == "" && r.Located && dir != "." {
parts := strings.Split(dir, "/")
t.Component = strings.Join(parts[:min(len(parts), 2)], "/")
Expand Down Expand Up @@ -478,6 +482,7 @@ func (b *Board) put(ctx context.Context, a github.Advisory, priority string, t *
b.option(fieldExcluded, excluded),
b.option(fieldLocated, located),
{ID: b.ids[fieldComponent], Value: text(kept.Component)},
{ID: b.ids[fieldUpstream], Value: text(kept.Upstream)},
{ID: b.ids[fieldAttempts], Value: numberValue(attempts)},
}
if err := b.gh.UpdateProjectItem(ctx, b.org, b.number, it.id, values); err != nil {
Expand Down
Loading
Loading