Everything about how Layne behaves on a given repo lives in config/layne.json. Change it, restart both server and worker, and the new behavior takes effect.
$global is a special key that sets organization-wide defaults. Every repository Layne scans inherits these values. A per-repo entry only needs to specify what differs - everything else falls back to $global.
{
"$global": {
"mode": "changed_files",
"contextLines": 8,
"timeoutMinutes": 10,
"semgrep": {
"enabled": true,
"extraArgs": ["--config", "auto"]
},
"trufflehog": {
"enabled": true,
"extraArgs": []
},
"trigger": {
"on": "pull_request"
},
"labels": {
"onFailure": ["needs-security-review"],
"removeOnFailure": ["security-ok"],
"onSuccess": ["security-ok"],
"removeOnSuccess": ["needs-security-review"],
"onException": ["security-exception-used"],
"removeOnException": ["needs-security-review"]
},
"notifications": {
"rocketchat": {
"enabled": true,
"webhookUrl": "$ROCKETCHAT_WEBHOOK_URL"
}
},
"comment": {
"enabled": false,
"template": null
},
"exceptionApprovers": {
"users": ["security-lead"],
"teams": ["acme/security-team"]
}
}
}Overrides are keyed by "owner/repo". A repository with no entry - or whose entry omits a tool block - gets the global defaults:
| Tool | Default behavior |
|---|---|
| Semgrep | Enabled - semgrep scan --config auto --json <files> |
| Trufflehog | Enabled - trufflehog filesystem --json --no-update <files> |
| Claude | Disabled - must opt in per repo; requires ANTHROPIC_API_KEY |
| Pi Agent | Disabled - must opt in per repo; requires a configured provider and the corresponding provider credentials in the environment |
See the individual scanner pages for full configuration options:
And for notifications and comments:
Not all keys merge the same way when a per-repo entry overrides $global. The reason is intentional - some blocks like labels and trigger are semantically atomic (a partial label config makes no sense), while scanner blocks are designed to be tweaked one key at a time without repeating everything.
| Key | How per-repo overrides $global |
|---|---|
mode, contextLines, timeoutMinutes |
Per-repo value replaces global value |
semgrep, trufflehog, claude, piAgent |
Merged at the key level - per-repo values overwrite matching keys, unset keys inherit from global |
trigger |
Full replacement - per-repo trigger replaces the global block entirely |
labels |
Full replacement - per-repo labels replaces the global block entirely |
notifications |
Per-notifier-key - per-repo rocketchat replaces global rocketchat; a per-repo slack entry stacks alongside a global rocketchat entry |
comment |
Merged at the key level - per-repo values overwrite matching keys, unset keys inherit from global |
exceptionApprovers |
Full replacement - per-repo exceptionApprovers replaces the global block entirely |
Controls how much of each changed file the scanners analyze.
{
"$global": {
"mode": "diff_only",
"contextLines": 8
}
}| Value | Behavior |
|---|---|
"changed_files" |
(default) Each scanner receives the full content of every file touched by the PR. Findings anywhere in those files are reported. |
"diff_only" |
A projected copy of each file is built containing only the changed hunks plus contextLines lines of surrounding context (blank lines preserve line numbers). Scanners receive the projected copy. After scanning, findings are filtered to lines that fall within the actual changed ranges. |
diff_only reduces noise and cost for large files where only a few lines changed. The tradeoff is that pre-existing issues in unchanged sections of the file are not reported.
:::warning Trufflehog in diff_only mode
Secrets that exist only in unchanged lines of a file will not appear in scan results. If full secret coverage is critical, set mode: "changed_files" for those repositories, or keep the global default as changed_files and only switch specific repos to diff_only.
:::
Number of surrounding lines to include around each changed hunk when mode is "diff_only". Adjacent expanded hunks are merged into one region.
- Default:
8 - Ignored when
modeis"changed_files"
Hard time limit for a single scan job. If the limit is reached, the job is rethrown so BullMQ can retry it. The Check Run is only marked as failed on the final attempt.
- Default:
10 - Accepts any positive integer
Raise this for large monorepos where scanners may take a long time, or lower it to fail fast on repos that should scan quickly.
{
"$global": {
"timeoutMinutes": 10
},
"org/monorepo": {
"timeoutMinutes": 25
}
}Use diff_only globally, fall back to full scan for a compliance-critical repo:
{
"$global": {
"mode": "diff_only",
"contextLines": 8
},
"org/compliance-repo": {
"mode": "changed_files"
}
}Tighter context window for a high-volume monorepo:
{
"org/monorepo": {
"mode": "diff_only",
"contextLines": 3
}
}By default Layne scans every pull request immediately when it is opened, synchronised, or reopened (pull_request trigger). This may be the right choice for private or internal repositories where all contributors are trusted and every PR is worth scanning.
For public repositories, two problems arise:
1. Scanning unapproved contributions. Your GitHub organization may require maintainer approval before running Actions for first-time external contributors. The pull_request event fires regardless - meaning Layne scans spam PRs, bot noise, and low-effort contributions that may never be reviewed.
2. Wasted spend on failing code. A PR that breaks CI within minutes is unlikely to merge. Scanning it burns Semgrep CPU time and - most importantly, if you're using Claude or another AI provider - credits on a result no one will act on.
The workflow_run and workflow_job triggers solve both by deferring the scan until after CI has run. You only scan code that cleared your quality gate.
Long story short, you can choose between the following:
on |
Behavior |
|---|---|
pull_request |
(default) Scan fires immediately on opened, synchronize, and reopened |
workflow_run |
Scan fires when the named CI workflow completes with a matching conclusion |
workflow_job |
Scan fires when the named CI job completes with a matching conclusion |
workflow_run:
{
"owner/repo": {
"trigger": {
"on": "workflow_run",
"workflow": "Tests Done",
"conclusions": ["success"]
}
}
}workflow_job:
{
"owner/repo": {
"trigger": {
"on": "workflow_job",
"job": "security-gate",
"conclusions": ["success"]
}
}
}| Key | Type | Default | Description |
|---|---|---|---|
on |
"pull_request" | "workflow_run" | "workflow_job" |
"pull_request" |
When to trigger the scan |
workflow |
string | (none) | Workflow name to watch. Required when on is "workflow_run" |
job |
string | (none) | Job name to watch. Required when on is "workflow_job" |
conclusions |
string[] | ["success"] |
Workflow/job conclusions that trigger the scan |
Both workflow_run and workflow_job follow the same two-stage pattern:
- On
pull_request- Layne caches PR metadata in Redis (7-day TTL) and creates askippedCheck Run so the deferral is visible in the PR status UI. No scan is enqueued yet. - On the trigger event completing - When the named workflow or job finishes with a matching conclusion, Layne looks up the cached PR metadata and enqueues the scan. If the cache is cold (e.g. Layne was offline when the PR was opened), it falls back to the GitHub API.
:::warning If the watched workflow or job is renamed or removed, Layne never receives the trigger event and the scan never runs - silently. To fail closed rather than open, make Layne's Check Run a required status check in branch protection. A missing check blocks merging and the absence is immediately visible. :::
Scan only after CI passes:
{
"owner/repo": {
"trigger": {
"on": "workflow_run",
"workflow": "Tests Done"
}
}
}Scan after a specific job (finer-grained):
{
"owner/repo": {
"trigger": {
"on": "workflow_job",
"job": "security-gate"
}
}
}Scan regardless of CI result (code is worth scanning even if tests fail):
{
"owner/repo": {
"trigger": {
"on": "workflow_run",
"workflow": "Tests Done",
"conclusions": ["success", "failure"]
}
}
}Apply a deferred trigger globally:
{
"$global": {
"trigger": {
"on": "workflow_run",
"workflow": "CI"
}
}
}Layne can automatically add and remove GitHub labels on a PR based on the scan result. Labels are applied after the Check Run is completed - label errors never affect the scan result.
{
"$global": {
"labels": {
"onFailure": ["needs-security-review"],
"removeOnFailure": ["security-ok"],
"onSuccess": ["security-ok"],
"removeOnSuccess": ["needs-security-review"]
}
}
}| Key | When applied | Description |
|---|---|---|
onFailure |
Scan conclusion is failure |
Labels to add to the PR |
removeOnFailure |
Scan conclusion is failure |
Labels to remove from the PR |
onSuccess |
Scan conclusion is success |
Labels to add to the PR |
removeOnSuccess |
Scan conclusion is success |
Labels to remove from the PR |
All four keys are optional. Omitting a key is a no-op.
When an exception approval is used, you can configure a label to be added or removed:
{
"$global": {
"labels": {
"onException": ["security-exception-used"],
"removeOnException": ["needs-security-review"]
}
}
}| Key | When applied | Description |
|---|---|---|
onException |
Exception approved despite findings | Labels to add to the PR |
removeOnException |
Exception approved despite findings | Labels to remove from the PR |
If a label listed in onFailure, onSuccess, or onException does not exist on the repository, Layne creates it automatically with a neutral gray color (#ededed). You do not need to pre-create labels.
Configure specific users or teams who can approve PRs that would otherwise fail. See Exception Approvals for full documentation.
{
"$global": {
"exceptionApprovers": {
"users": ["security-lead"],
"teams": ["acme/security-team"]
}
}
}| Key | Type | Description |
|---|---|---|
users |
string[] | GitHub usernames who can approve exceptions |
teams |
string[] | GitHub team slugs (format: org/team-slug) whose members can approve |
Per-repo exceptionApprovers replaces the global block entirely (not merged key-by-key).