enhance(#3908): the scanners distinguish an empty diff from one they could not compute (#3937)

* feat(#3908): the scanners distinguish an empty diff from one they could not compute

collect_files ended 2>/dev/null || true, which destroyed the evidence three ways: the redirect discarded git's diagnostic, the pipe replaced git's status with grep's, and || true forced success regardless. Four distinct conditions - an established-empty diff, a bad ref, no repository, and a repository with no commits - all reported clean, and a secret scanner reporting clean because git failed is indistinguishable from an all-clear to any gate consuming it.

git now runs separately from the filter so its status and diagnostic both survive. An established-empty diff exits NO_INPUT; a scope that could not be established exits UNAVAILABLE; the usage sites move off 2 to USAGE. || true is retained on the filter alone, where it is correct: a diff of only images is empty, not failed.

Codes are sourced from a generated shell fragment rather than written into three scripts, so a re-allocation cannot desync them, and a missing fragment fails loudly instead of falling back to literals. The security workflow is updated in the same change: without it, a docs-only PR would newly fail the job.

* fix(#3908): keep scanner stderr out of the file list, and drop try/finally from test bodies

Capturing git and find output with 2>&1 was right for the failure path but wrong for the success path: a warning emitted alongside a successful diff flowed into the file list and was treated as a filename. stderr is now captured separately, forwarded as a warning on success and as the diagnostic on failure, and never folded into the list.

Also converts the control tests' try/finally blocks to t.after(), which CONTRIBUTING bans inside a test body because it masks failures.

* chore(#3908): backfill changeset pr number

* docs(#3908): record the scanners' four-outcome exit contract

SECURITY.md is root-level, so the docs gate correctly held: a Changed fragment owes a file under docs/. The contract also belongs where the feature is described, as REQ-SCAN-INJ-05.

docs/FEATURES.md is GENERATED from per-feature fragments (#3840) - the first edit went into the generated file and gen-features --check caught it, which is the same edit-the-output drift this epic exists to close. The fragment is the source; FEATURES.md is regenerated.

---------

Co-authored-by: sim <sim@local>
This commit is contained in:
Tom Boucher
2026-08-27 13:11:13 -04:00
committed by GitHub
parent 7f3119a29f
commit 1e67ec9737
31 changed files with 681 additions and 78 deletions

View File

@@ -0,0 +1,5 @@
---
type: Changed
pr: 3937
---
**A security scanner that cannot compute a diff now fails instead of reporting clean** — `secret-scan`, `base64-scan` and `prompt-injection-scan` previously exited 0 for a bad ref, a missing repository, or a repository with no commits, which is indistinguishable from a genuine all-clear to any CI gate. They now distinguish four outcomes: scanned clean, nothing was in scope, could not establish scope, and findings. The security workflow treats nothing-in-scope as a pass and could-not-scan as a failure. (#3908)

View File

@@ -61,26 +61,62 @@ jobs:
- name: Dependency integrity gate
run: node scripts/check-npm-integrity.cjs
# ADR-3889 Phase 4 (#3908): the scanners now distinguish "ran, zero
# files in scope" (NO_INPUT, e.g. a docs/image-only PR) from "could not
# establish scope" (UNAVAILABLE, e.g. a bad ref or unreadable input) —
# previously both collapsed to a silent exit 0. NO_INPUT must still
# PASS this job (nothing was in scope to scan); UNAVAILABLE must FAIL
# it (the scan never actually ran). Each step sources the generated
# exit-code fragment rather than hardcoding the NO_INPUT integer, and
# only recodes exactly that one outcome to 0 — every other exit code
# (0, UNAVAILABLE, findings) passes through unchanged.
- name: Prompt injection scan
env:
BASE_REF: ${{ github.base_ref }}
run: |
chmod +x scripts/prompt-injection-scan.sh
. gsd-core/bin/shared/exit-codes.sh
set +e
scripts/prompt-injection-scan.sh --diff "origin/$BASE_REF"
code=$?
set -e
if [ "$code" -eq "$EXIT_NO_INPUT" ]; then
echo "prompt-injection-scan: NO_INPUT — nothing in scope, treated as pass"
exit 0
fi
exit "$code"
- name: Base64 obfuscation scan
env:
BASE_REF: ${{ github.base_ref }}
run: |
chmod +x scripts/base64-scan.sh
. gsd-core/bin/shared/exit-codes.sh
set +e
scripts/base64-scan.sh --diff "origin/$BASE_REF"
code=$?
set -e
if [ "$code" -eq "$EXIT_NO_INPUT" ]; then
echo "base64-scan: NO_INPUT — nothing in scope, treated as pass"
exit 0
fi
exit "$code"
- name: Secret scan
env:
BASE_REF: ${{ github.base_ref }}
run: |
chmod +x scripts/secret-scan.sh
. gsd-core/bin/shared/exit-codes.sh
set +e
scripts/secret-scan.sh --diff "origin/$BASE_REF"
code=$?
set -e
if [ "$code" -eq "$EXIT_NO_INPUT" ]; then
echo "secret-scan: NO_INPUT — nothing in scope, treated as pass"
exit 0
fi
exit "$code"
- name: Secret scan exclusion lint
run: |

View File

@@ -72,6 +72,20 @@ The `--strict` flag:
If `--strict` finds findings that default mode does not, those findings represent either (a) an entry that should have been annotated and renewed, or (b) an actual secret that was only hidden by a stale exclusion. In both cases: investigate, remediate, and update the exclusion annotation.
### Exit codes
`secret-scan.sh`, `base64-scan.sh`, and `prompt-injection-scan.sh` share one contract, registered in `gsd-core/bin/shared/exit-codes.json` (ADR-3889):
| Code | Meaning |
|--:|---|
| `0` | Clean — files were scanned, no findings. |
| `1` | Findings detected. |
| `64` (`USAGE`) | Bad argv — unknown mode, or a `--file`/`--dir` target that does not exist. |
| `66` (`NO_INPUT`) | Ran; the scope was established and is genuinely empty (e.g. a diff touching only image files, or an all-docs PR). Not a failure. |
| `69` (`UNAVAILABLE`) | Could not establish scope — a nonexistent `--diff` ref, running outside a git repository, a repository with no commits, or an unreadable `--dir`. Distinct from `NO_INPUT`: the scanner never actually ran. |
CI (`.github/workflows/security-scan.yml`) treats `0` and `66` as passing steps and `1`/`69` as failing steps — a scan that could not run is a build failure, not a silent clean pass.
References:
- GitGuardian exclusion annotation convention: https://docs.gitguardian.com/internal-repositories-monitoring/integrations/cli/secrets
- CNCF Security TAG threat-model exception lifecycle: https://github.com/cncf/tag-security/blob/main/community/working-groups/threat-modeling/templates/threats.md

View File

@@ -2341,6 +2341,16 @@ v1 supports **directory-prefix matching only, not glob syntax**: no glob engine
- REQ-SCAN-INJ-02: Live hooks MUST detect known injection patterns (instruction override, role manipulation, system-prompt extraction, fake message boundaries). Base64-decode scanning is a CI-time control (`scripts/base64-scan.sh`), not a live hook — live hooks match a base64-exfiltration phrase regex only, they do not decode.
- REQ-SCAN-INJ-03: ~~Scanner MUST apply entropy analysis~~ — Entropy analysis (`scanEntropyAnomalies`) was removed in #2198 as dead code (zero production callers; live hooks do not perform entropy analysis). This requirement is deferred pending a maintainable live implementation.
- REQ-SCAN-INJ-04: Scanner MUST remain advisory-only — detection is logged, not blocking
- REQ-SCAN-INJ-05: A scanner that could not establish its file list MUST NOT report clean (#3908). The CI scanners (`prompt-injection-scan.sh`, `base64-scan.sh`, `secret-scan.sh`) distinguish four outcomes rather than collapsing them into exit 0:
| Outcome | Exit | Meaning |
|---|---|---|
| scanned, no findings | `0` | files were in scope and none matched |
| findings | `1` | the scan's own verdict |
| nothing in scope | `NO_INPUT` | the diff resolved and was genuinely empty — e.g. a docs-only PR |
| could not scan | `UNAVAILABLE` | the file list was never established: a bad ref, no repository, or a repository with no commits |
Codes come from the exit-code registry ([ADR-3889](adr/3889-process-exit-contract.md)), sourced from `gsd-core/bin/shared/exit-codes.sh`, never written into the scripts. Every one is non-zero, so a caller written `if ! scanner; then` behaves identically for a clean scan and trips for everything else — this can turn a false green red, never a red green. `.github/workflows/security-scan.yml` treats *nothing in scope* as a pass and *could not scan* as a failure; previously the latter passed silently, having scanned nothing.
---

View File

@@ -14,3 +14,13 @@ group: v1.34.0 Features
- REQ-SCAN-INJ-02: Live hooks MUST detect known injection patterns (instruction override, role manipulation, system-prompt extraction, fake message boundaries). Base64-decode scanning is a CI-time control (`scripts/base64-scan.sh`), not a live hook — live hooks match a base64-exfiltration phrase regex only, they do not decode.
- REQ-SCAN-INJ-03: ~~Scanner MUST apply entropy analysis~~ — Entropy analysis (`scanEntropyAnomalies`) was removed in #2198 as dead code (zero production callers; live hooks do not perform entropy analysis). This requirement is deferred pending a maintainable live implementation.
- REQ-SCAN-INJ-04: Scanner MUST remain advisory-only — detection is logged, not blocking
- REQ-SCAN-INJ-05: A scanner that could not establish its file list MUST NOT report clean (#3908). The CI scanners (`prompt-injection-scan.sh`, `base64-scan.sh`, `secret-scan.sh`) distinguish four outcomes rather than collapsing them into exit 0:
| Outcome | Exit | Meaning |
|---|---|---|
| scanned, no findings | `0` | files were in scope and none matched |
| findings | `1` | the scan's own verdict |
| nothing in scope | `NO_INPUT` | the diff resolved and was genuinely empty — e.g. a docs-only PR |
| could not scan | `UNAVAILABLE` | the file list was never established: a bad ref, no repository, or a repository with no commits |
Codes come from the exit-code registry ([ADR-3889](adr/3889-process-exit-contract.md)), sourced from `gsd-core/bin/shared/exit-codes.sh`, never written into the scripts. Every one is non-zero, so a caller written `if ! scanner; then` behaves identically for a clean scan and trips for everything else — this can turn a false green red, never a red green. `.github/workflows/security-scan.yml` treats *nothing in scope* as a pass and *could not scan* as a failure; previously the latter passed silently, having scanned nothing.

View File

@@ -0,0 +1,20 @@
#!/bin/sh
# GENERATED FILE — DO NOT EDIT BY HAND.
# Source of truth: gsd-core/bin/shared/exit-codes.json. Regenerate with:
# node scripts/gen-exit-code-registry.cjs --write
#
# One `export EXIT_<NAME>=<code>` per gsd-core/bin/shared/exit-codes.json
# entry (ADR-3889 §2, #3905/#3906/#3908). POSIX sh, safe under `set -u`:
# sourcing this file only ever ASSIGNS variables, never reads one, so it
# cannot trip an unset-variable check regardless of the caller's existing
# environment.
#
# Usage (from a scanner under scripts/):
# . "$(dirname "$0")/../gsd-core/bin/shared/exit-codes.sh"
# exit "$EXIT_UNAVAILABLE"
export EXIT_HOOK_DENY=2
export EXIT_USAGE=64
export EXIT_NO_INPUT=66
export EXIT_UNAVAILABLE=69
export EXIT_INTERNAL=70
export EXIT_DEGRADED=80

View File

@@ -9,12 +9,29 @@
# scripts/base64-scan.sh --file path/to/file # Scan a single file
# scripts/base64-scan.sh --dir agents/ # Scan all files in a directory
#
# Exit codes:
# 0 = clean
# 1 = findings detected
# 2 = usage error
# Exit codes (ADR-3889, #3908 — registered in gsd-core/bin/shared/exit-codes.json):
# 0 = clean
# 1 = findings detected
# $EXIT_USAGE (64) = usage error (bad argv, missing --file/--dir target)
# $EXIT_NO_INPUT (66) = ran; scope established; zero files in scope (genuinely empty)
# $EXIT_UNAVAILABLE (69) = could not establish scope (bad ref, not a repo, unreadable dir)
set -euo pipefail
# ─── Exit-code registry (ADR-3889, #3908) ────────────────────────────────────
# Resolved relative to THIS script's location, not the caller's cwd. Loud,
# non-zero failure if the fragment is missing — never fall back to a guessed
# literal integer, and never let a missing registry silently degrade to
# exit 0.
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
EXIT_CODES_SH="$SCRIPT_DIR/../gsd-core/bin/shared/exit-codes.sh"
if [[ ! -f "$EXIT_CODES_SH" ]]; then
echo "base64-scan: FATAL: exit-code registry not found at $EXIT_CODES_SH" >&2
echo " Regenerate with: node scripts/gen-exit-code-registry.cjs --write" >&2
exit 1
fi
# shellcheck disable=SC1090
. "$EXIT_CODES_SH"
# ── Locale hardening (#116) ───────────────────────────────────────────────────
# BSD tr (macOS) treats input bytes as multi-byte characters under any UTF-8
# locale. When the input to `tr -cd '[:print:]'` contains bytes that are not
@@ -180,7 +197,31 @@ collect_files() {
case "$mode" in
--diff)
local base="${1:-origin/main}"
git diff --name-only --diff-filter=ACMR "$base"...HEAD 2>/dev/null \
# Run git separately from the filter pipe so its OWN exit status (not
# grep's) decides whether the diff could be established. stdout and
# stderr are captured SEPARATELY (never merged with `2>&1`) so that a
# warning git writes to stderr on an otherwise successful diff can
# never be mistaken for a filename in the file list. On failure the
# captured stderr is emitted as the diagnostic; on success it is
# forwarded as a warning, never folded into the file list.
# `|| true` on the filter below is CORRECT (not gratuitous): `grep -v`
# exits 1 when every line is filtered out (e.g. an all-images diff),
# which is a legitimate empty result, not a failure to run.
local raw status err_file
err_file=$(mktemp)
raw=$(git diff --name-only --diff-filter=ACMR "$base"...HEAD 2>"$err_file")
status=$?
if (( status != 0 )); then
cat "$err_file" >&2
rm -f "$err_file"
exit "$EXIT_UNAVAILABLE"
fi
if [[ -s "$err_file" ]]; then
echo "Warning: git diff emitted stderr output:" >&2
cat "$err_file" >&2
fi
rm -f "$err_file"
printf '%s\n' "$raw" \
| grep -vE '\.(png|jpg|jpeg|gif|ico|woff|woff2|ttf|eot|otf|zip|tar|gz|pdf)$' || true
;;
--file)
@@ -188,24 +229,41 @@ collect_files() {
echo "$1"
else
echo "Error: file not found: $1" >&2
exit 2
exit "$EXIT_USAGE"
fi
;;
--dir)
local dir="$1"
if [[ ! -d "$dir" ]]; then
echo "Error: directory not found: $dir" >&2
exit 2
exit "$EXIT_USAGE"
fi
find "$dir" -type f ! -path '*/node_modules/*' ! -path '*/.git/*' ! -path '*/dist/*' \
! -name '*.png' ! -name '*.jpg' ! -name '*.gif' ! -name '*.woff*' 2>/dev/null || true
# Same treatment as --diff: a `find` that fails (e.g. permission
# denied) must not be reported as an empty directory, and stdout/stderr
# are captured separately so a stderr warning never enters the file list.
local raw status err_file
err_file=$(mktemp)
raw=$(find "$dir" -type f ! -path '*/node_modules/*' ! -path '*/.git/*' ! -path '*/dist/*' \
! -name '*.png' ! -name '*.jpg' ! -name '*.gif' ! -name '*.woff*' 2>"$err_file")
status=$?
if (( status != 0 )); then
cat "$err_file" >&2
rm -f "$err_file"
exit "$EXIT_UNAVAILABLE"
fi
if [[ -s "$err_file" ]]; then
echo "Warning: find emitted stderr output:" >&2
cat "$err_file" >&2
fi
rm -f "$err_file"
printf '%s\n' "$raw"
;;
--stdin)
cat
;;
*)
echo "Usage: $0 --diff [base] | --file <path> | --dir <path> | --stdin" >&2
exit 2
exit "$EXIT_USAGE"
;;
esac
}
@@ -309,7 +367,7 @@ extract_and_check_blobs() {
main() {
if [[ $# -eq 0 ]]; then
echo "Usage: $0 --diff [base] | --file <path> | --dir <path>" >&2
exit 2
exit "$EXIT_USAGE"
fi
load_ignorelist
@@ -321,8 +379,12 @@ main() {
files=$(collect_files "$mode" "$@")
if [[ -z "$files" ]]; then
# collect_files already exited (UNAVAILABLE/USAGE) for anything that
# could not establish scope. Reaching here with an empty result means
# scope WAS established and is genuinely empty (e.g. an all-images
# diff) — that is NO_INPUT, not a silent clean pass.
echo "base64-scan: no files to scan"
exit 0
exit "$EXIT_NO_INPUT"
fi
local total=0

View File

@@ -1,6 +1,6 @@
#!/usr/bin/env node
/**
* gen-exit-code-registry.cjs — generates THREE byte-identical/derived
* gen-exit-code-registry.cjs — generates FOUR byte-identical/derived
* artifacts from the declaration at gsd-core/bin/shared/exit-codes.json:
* - gsd-core/bin/lib/exit-code-registry.cjs (tsc-adjacent build tree)
* - scripts/lib/exit-code-registry.cjs (committed, for scripts/
@@ -12,28 +12,34 @@
* against the shape the .cjs artifacts above actually export — generated
* from the SAME ENTRY_FIELD_TYPES table serializeRegistry() uses, so the
* two can never independently drift).
* - gsd-core/bin/shared/exit-codes.sh (POSIX sh, safe under
* `set -u`: one `export EXIT_<NAME>=<code>` per entry, sourced by the
* bash scanners under scripts/ so a shell caller never re-invents a
* literal exit-code integer — ADR-3889 Phase 4, #3908).
*
* ADR-3889 ("One exit-code registry — 0 and 1 are free, everything else is
* allocated") Phase 1 (#3905) built the single-output allocator; Phase 2
* (#3906) added the second .cjs emission so scripts/ has its own committed
* copy instead of reaching into gitignored build output, and a follow-up
* closed the review finding that the .d.cts was hand-maintained with no gate
* by generating it here too.
* copy instead of reaching into gitignored build output; a follow-up closed
* the review finding that the .d.cts was hand-maintained with no gate by
* generating it here too; Phase 4 (#3908) added the shell fragment so the
* three bash scanners can source symbolic names instead of hardcoding
* integers.
*
* The two .cjs artifacts are byte-identical: serializeRegistry() only encodes
* the DECLARATION path (for the banner comment), never the output path, so
* one generated string is written to both locations unchanged. The .d.cts is
* a separate, smaller derivation (a structural type, not a per-entry table)
* but is generated and --check-gated exactly the same way.
* one generated string is written to both locations unchanged. The .d.cts
* and .sh artifacts are separate, smaller derivations but are generated and
* --check-gated exactly the same way.
*
* Nothing in this script emits a registered exit code itself; wiring
* consumers onto the registry is separate work.
*
* Usage:
* node scripts/gen-exit-code-registry.cjs # same as --write
* node scripts/gen-exit-code-registry.cjs --write # write all three artifacts
* node scripts/gen-exit-code-registry.cjs --write # write all four artifacts
* node scripts/gen-exit-code-registry.cjs --check # exit 1 if ANY committed artifact is stale
* node scripts/gen-exit-code-registry.cjs --declaration <path> --out <path> --scripts-out <path> --dts-out <path> # override for tests
* node scripts/gen-exit-code-registry.cjs --declaration <path> --out <path> --scripts-out <path> --dts-out <path> --sh-out <path> # override for tests
* node scripts/gen-exit-code-registry.cjs --json # emit ONE JSON report on stdout instead of human prose
*/
@@ -47,6 +53,7 @@ const DEFAULT_DECLARATION_PATH = path.join(REPO_ROOT, 'gsd-core', 'bin', 'shared
const DEFAULT_OUTPUT_PATH = path.join(REPO_ROOT, 'gsd-core', 'bin', 'lib', 'exit-code-registry.cjs');
const DEFAULT_SCRIPTS_OUTPUT_PATH = path.join(REPO_ROOT, 'scripts', 'lib', 'exit-code-registry.cjs');
const DEFAULT_DTS_OUTPUT_PATH = path.join(REPO_ROOT, 'src', 'exit-code-registry.d.cts');
const DEFAULT_SH_OUTPUT_PATH = path.join(REPO_ROOT, 'gsd-core', 'bin', 'shared', 'exit-codes.sh');
/**
* Single source of the entry field list (name -> TS type), in emission order.
@@ -82,14 +89,15 @@ const REASON = Object.freeze({
});
const USAGE_MESSAGE = [
'Usage: node scripts/gen-exit-code-registry.cjs [--write|--check] [--declaration <path>] [--out <path>] [--scripts-out <path>] [--dts-out <path>] [--json]',
'Usage: node scripts/gen-exit-code-registry.cjs [--write|--check] [--declaration <path>] [--out <path>] [--scripts-out <path>] [--dts-out <path>] [--sh-out <path>] [--json]',
' (no flag) same as --write',
' --write write all three generated registry artifacts',
' --write write all four generated registry artifacts',
' --check exit 1 if ANY committed artifact is stale',
' --declaration override the declaration path (default: gsd-core/bin/shared/exit-codes.json)',
' --out override the primary output artifact path (default: gsd-core/bin/lib/exit-code-registry.cjs)',
' --scripts-out override the secondary output artifact path (default: scripts/lib/exit-code-registry.cjs)',
' --dts-out override the ambient type declaration path (default: src/exit-code-registry.d.cts)',
' --sh-out override the shell-sourceable fragment path (default: gsd-core/bin/shared/exit-codes.sh)',
' --json emit ONE JSON report ({ok, reason, context, detail?}) on stdout instead of human-readable prose',
].join('\n');
@@ -435,6 +443,42 @@ function serializeDts(declarationPath) {
].join('\n');
}
/**
* Generate the shell-sourceable fragment: one `export EXIT_<NAME>=<code>`
* line per declared entry, POSIX sh, safe under `set -u` (a sourced file that
* only ever ASSIGNS variables can never trip an unset-variable check,
* regardless of what the caller's shell had in scope beforehand).
*
* Consumed by the bash scanners under scripts/ via `. gsd-core/bin/shared/
* exit-codes.sh` (ADR-3889 Phase 4, #3908) so a shell caller resolves a
* symbolic name instead of hardcoding a literal integer that can silently
* drift from the registry.
*/
function serializeSh(entries, declarationPath) {
const relDeclaration = path.relative(REPO_ROOT, declarationPath).split(path.sep).join('/');
const banner = [
'#!/bin/sh',
'# GENERATED FILE — DO NOT EDIT BY HAND.',
`# Source of truth: ${relDeclaration}. Regenerate with:`,
'# node scripts/gen-exit-code-registry.cjs --write',
'#',
'# One `export EXIT_<NAME>=<code>` per gsd-core/bin/shared/exit-codes.json',
'# entry (ADR-3889 §2, #3905/#3906/#3908). POSIX sh, safe under `set -u`:',
'# sourcing this file only ever ASSIGNS variables, never reads one, so it',
'# cannot trip an unset-variable check regardless of the caller\'s existing',
'# environment.',
'#',
'# Usage (from a scanner under scripts/):',
'# . "$(dirname "$0")/../gsd-core/bin/shared/exit-codes.sh"',
'# exit "$EXIT_UNAVAILABLE"',
'',
].join('\n');
const lines = entries.map((e) => `export EXIT_${e.name}=${e.code}`);
return banner + lines.join('\n') + '\n';
}
/**
* Load, validate, and serialize the declaration in one step.
* @returns {{ok:true,content:string}|{ok:false,reason:string,message:string}}
@@ -446,7 +490,7 @@ function buildRegistryContent(declarationPath) {
const validated = validateEntries(loaded.entries);
if (!validated.ok) return validated;
return { ok: true, content: serializeRegistry(loaded.entries, declarationPath) };
return { ok: true, content: serializeRegistry(loaded.entries, declarationPath), entries: loaded.entries };
}
function printFail(result) {
@@ -487,7 +531,7 @@ function emitOk(reason, humanMessage, json) {
console.log(humanMessage);
}
function doWrite(declarationPath, outPath, scriptsOutPath, dtsPath, json) {
function doWrite(declarationPath, outPath, scriptsOutPath, dtsPath, shPath, json) {
const result = buildRegistryContent(declarationPath);
if (!result.ok) {
emitFail(result, json);
@@ -503,10 +547,13 @@ function doWrite(declarationPath, outPath, scriptsOutPath, dtsPath, json) {
const dtsContent = serializeDts(declarationPath);
fs.mkdirSync(path.dirname(dtsPath), { recursive: true });
fs.writeFileSync(dtsPath, dtsContent, 'utf8');
const shContent = serializeSh(result.entries, declarationPath);
fs.mkdirSync(path.dirname(shPath), { recursive: true });
fs.writeFileSync(shPath, shContent, 'utf8');
emitOk(
REASON.OK,
`ok gen-exit-code-registry: wrote ${outPath}\nok gen-exit-code-registry: wrote ${scriptsOutPath}\n`
+ `ok gen-exit-code-registry: wrote ${dtsPath}`,
+ `ok gen-exit-code-registry: wrote ${dtsPath}\nok gen-exit-code-registry: wrote ${shPath}`,
json,
);
return 0;
@@ -542,23 +589,25 @@ function checkOneArtifact(artifactLabel, artifactPath, content) {
}
/**
* --check verifies ALL THREE committed artifacts against the same freshly
* --check verifies ALL FOUR committed artifacts against the same freshly
* generated content and fails naming which one drifted (or is missing) if
* any does. Checked in a fixed order (primary, secondary, dts) so a
* any does. Checked in a fixed order (primary, secondary, dts, sh) so a
* single-artifact failure is always reported deterministically.
*/
function doCheck(declarationPath, outPath, scriptsOutPath, dtsPath, json) {
function doCheck(declarationPath, outPath, scriptsOutPath, dtsPath, shPath, json) {
const result = buildRegistryContent(declarationPath);
if (!result.ok) {
emitFail(result, json);
return 1;
}
const dtsContent = serializeDts(declarationPath);
const shContent = serializeSh(result.entries, declarationPath);
const artifacts = [
['primary', outPath, result.content],
['secondary', scriptsOutPath, result.content],
['dts', dtsPath, dtsContent],
['sh', shPath, shContent],
];
for (const [artifactLabel, artifactPath, content] of artifacts) {
const checked = checkOneArtifact(artifactLabel, artifactPath, content);
@@ -572,14 +621,15 @@ function doCheck(declarationPath, outPath, scriptsOutPath, dtsPath, json) {
REASON.OK,
`ok gen-exit-code-registry: ${outPath} matches ${declarationPath}\n`
+ `ok gen-exit-code-registry: ${scriptsOutPath} matches ${declarationPath}\n`
+ `ok gen-exit-code-registry: ${dtsPath} matches ${declarationPath}`,
+ `ok gen-exit-code-registry: ${dtsPath} matches ${declarationPath}\n`
+ `ok gen-exit-code-registry: ${shPath} matches ${declarationPath}`,
json,
);
return 0;
}
/**
* @returns {{mode:'write'|'check', declarationPath:?string, outPath:?string, scriptsOutPath:?string, dtsPath:?string, json:boolean}}
* @returns {{mode:'write'|'check', declarationPath:?string, outPath:?string, scriptsOutPath:?string, dtsPath:?string, shPath:?string, json:boolean}}
*/
function parseArgs(argv) {
let mode = null;
@@ -587,6 +637,7 @@ function parseArgs(argv) {
let outPath = null;
let scriptsOutPath = null;
let dtsPath = null;
let shPath = null;
let json = false;
for (let i = 0; i < argv.length; i++) {
@@ -622,12 +673,18 @@ function parseArgs(argv) {
dtsPath = value;
} else if (arg.startsWith('--dts-out=')) {
dtsPath = arg.slice('--dts-out='.length);
} else if (arg === '--sh-out') {
const value = argv[++i];
if (value === undefined) throw new Error('--sh-out requires a value');
shPath = value;
} else if (arg.startsWith('--sh-out=')) {
shPath = arg.slice('--sh-out='.length);
} else {
throw new Error(`unrecognized argument: ${arg}`);
}
}
return { mode: mode || 'write', declarationPath, outPath, scriptsOutPath, dtsPath, json };
return { mode: mode || 'write', declarationPath, outPath, scriptsOutPath, dtsPath, shPath, json };
}
function main() {
@@ -649,10 +706,11 @@ function main() {
const outPath = args.outPath || DEFAULT_OUTPUT_PATH;
const scriptsOutPath = args.scriptsOutPath || DEFAULT_SCRIPTS_OUTPUT_PATH;
const dtsPath = args.dtsPath || DEFAULT_DTS_OUTPUT_PATH;
const shPath = args.shPath || DEFAULT_SH_OUTPUT_PATH;
return args.mode === 'check'
? doCheck(declarationPath, outPath, scriptsOutPath, dtsPath, args.json)
: doWrite(declarationPath, outPath, scriptsOutPath, dtsPath, args.json);
? doCheck(declarationPath, outPath, scriptsOutPath, dtsPath, shPath, args.json)
: doWrite(declarationPath, outPath, scriptsOutPath, dtsPath, shPath, args.json);
}
if (require.main === module) process.exitCode = main();
@@ -664,6 +722,7 @@ module.exports = {
DEFAULT_OUTPUT_PATH,
DEFAULT_SCRIPTS_OUTPUT_PATH,
DEFAULT_DTS_OUTPUT_PATH,
DEFAULT_SH_OUTPUT_PATH,
ENTRY_FIELD_TYPES,
isAllocatableCode,
bandFor,
@@ -672,6 +731,7 @@ module.exports = {
loadDeclaration,
serializeRegistry,
serializeDts,
serializeSh,
buildRegistryContent,
parseArgs,
main,

View File

@@ -6,12 +6,29 @@
# scripts/prompt-injection-scan.sh --file path/to/file # Scan a single file
# scripts/prompt-injection-scan.sh --dir agents/ # Scan all files in a directory
#
# Exit codes:
# 0 = clean
# 1 = findings detected
# 2 = usage error
# Exit codes (ADR-3889, #3908 — registered in gsd-core/bin/shared/exit-codes.json):
# 0 = clean
# 1 = findings detected
# $EXIT_USAGE (64) = usage error (bad argv, missing --file/--dir target)
# $EXIT_NO_INPUT (66) = ran; scope established; zero files in scope (genuinely empty)
# $EXIT_UNAVAILABLE (69) = could not establish scope (bad ref, not a repo, unreadable dir)
set -euo pipefail
# ─── Exit-code registry (ADR-3889, #3908) ────────────────────────────────────
# Resolved relative to THIS script's location, not the caller's cwd. Loud,
# non-zero failure if the fragment is missing — never fall back to a guessed
# literal integer, and never let a missing registry silently degrade to
# exit 0.
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
EXIT_CODES_SH="$SCRIPT_DIR/../gsd-core/bin/shared/exit-codes.sh"
if [[ ! -f "$EXIT_CODES_SH" ]]; then
echo "prompt-injection-scan: FATAL: exit-code registry not found at $EXIT_CODES_SH" >&2
echo " Regenerate with: node scripts/gen-exit-code-registry.cjs --write" >&2
exit 1
fi
# shellcheck disable=SC1090
. "$EXIT_CODES_SH"
# ─── Patterns ────────────────────────────────────────────────────────────────
# Each pattern is a POSIX extended regex. Keep alphabetized by category.
#
@@ -175,33 +192,73 @@ collect_files() {
case "$mode" in
--diff)
local base="${1:-origin/main}"
# Get changed files in the diff, filter to scannable extensions
git diff --name-only --diff-filter=ACMR "$base"...HEAD 2>/dev/null \
| grep -E '\.(md|cjs|js|json|yml|yaml|sh)$' || true
# Run git separately from the filter pipe so its OWN exit status (not
# grep's) decides whether the diff could be established. stdout and
# stderr are captured SEPARATELY (never merged with `2>&1`) so that a
# warning git writes to stderr on an otherwise successful diff can
# never be mistaken for a filename in the file list. On failure the
# captured stderr is emitted as the diagnostic; on success it is
# forwarded as a warning, never folded into the file list.
# `|| true` on the filter below is CORRECT (not gratuitous): `grep -E`
# exits 1 when nothing matches the scannable extensions (e.g. a diff
# touching only non-scannable file types), which is a legitimate empty
# result, not a failure to run.
local raw status err_file
err_file=$(mktemp)
raw=$(git diff --name-only --diff-filter=ACMR "$base"...HEAD 2>"$err_file")
status=$?
if (( status != 0 )); then
cat "$err_file" >&2
rm -f "$err_file"
exit "$EXIT_UNAVAILABLE"
fi
if [[ -s "$err_file" ]]; then
echo "Warning: git diff emitted stderr output:" >&2
cat "$err_file" >&2
fi
rm -f "$err_file"
printf '%s\n' "$raw" | grep -E '\.(md|cjs|js|json|yml|yaml|sh)$' || true
;;
--file)
if [[ -f "$1" ]]; then
echo "$1"
else
echo "Error: file not found: $1" >&2
exit 2
exit "$EXIT_USAGE"
fi
;;
--dir)
local dir="$1"
if [[ ! -d "$dir" ]]; then
echo "Error: directory not found: $dir" >&2
exit 2
exit "$EXIT_USAGE"
fi
find "$dir" -type f \( -name '*.md' -o -name '*.cjs' -o -name '*.js' -o -name '*.json' -o -name '*.yml' -o -name '*.yaml' -o -name '*.sh' \) \
! -path '*/node_modules/*' ! -path '*/.git/*' ! -path '*/dist/*' 2>/dev/null || true
# Same treatment as --diff: a `find` that fails (e.g. permission
# denied) must not be reported as an empty directory, and stdout/stderr
# are captured separately so a stderr warning never enters the file list.
local raw status err_file
err_file=$(mktemp)
raw=$(find "$dir" -type f \( -name '*.md' -o -name '*.cjs' -o -name '*.js' -o -name '*.json' -o -name '*.yml' -o -name '*.yaml' -o -name '*.sh' \) \
! -path '*/node_modules/*' ! -path '*/.git/*' ! -path '*/dist/*' 2>"$err_file")
status=$?
if (( status != 0 )); then
cat "$err_file" >&2
rm -f "$err_file"
exit "$EXIT_UNAVAILABLE"
fi
if [[ -s "$err_file" ]]; then
echo "Warning: find emitted stderr output:" >&2
cat "$err_file" >&2
fi
rm -f "$err_file"
printf '%s\n' "$raw"
;;
--stdin)
cat
;;
*)
echo "Usage: $0 --diff [base] | --file <path> | --dir <path> | --stdin" >&2
exit 2
exit "$EXIT_USAGE"
;;
esac
}
@@ -240,7 +297,7 @@ scan_file() {
main() {
if [[ $# -eq 0 ]]; then
echo "Usage: $0 --diff [base] | --file <path> | --dir <path>" >&2
exit 2
exit "$EXIT_USAGE"
fi
local mode="$1"
@@ -250,8 +307,12 @@ main() {
files=$(collect_files "$mode" "$@")
if [[ -z "$files" ]]; then
# collect_files already exited (UNAVAILABLE/USAGE) for anything that
# could not establish scope. Reaching here with an empty result means
# scope WAS established and is genuinely empty — that is NO_INPUT, not a
# silent clean pass.
echo "prompt-injection-scan: no files to scan"
exit 0
exit "$EXIT_NO_INPUT"
fi
local total=0

View File

@@ -17,10 +17,12 @@
# This flag does not change secret-detection logic — only which
# exclusions are applied.
#
# Exit codes:
# 0 = clean
# 1 = findings detected
# 2 = usage error
# Exit codes (ADR-3889, #3908 — registered in gsd-core/bin/shared/exit-codes.json):
# 0 = clean
# 1 = findings detected
# $EXIT_USAGE (64) = usage error (bad argv, missing --file/--dir target)
# $EXIT_NO_INPUT (66) = ran; scope established; zero files in scope (genuinely empty)
# $EXIT_UNAVAILABLE (69) = could not establish scope (bad ref, not a repo, unreadable dir)
#
# Annotation format for .secretscanignore (required for --strict compliance):
# # allow: <pattern> reason="..." owner="..." expires="YYYY-MM-DD" [rule-id="..."]
@@ -39,6 +41,21 @@
# cannot permanently hide secrets. See SECURITY.md for the audit runbook.
set -euo pipefail
# ─── Exit-code registry (ADR-3889, #3908) ────────────────────────────────────
# Resolved relative to THIS script's location, not the caller's cwd, so this
# works regardless of where the scanner is invoked from. Loud, non-zero
# failure if the fragment is missing — never fall back to a guessed literal
# integer, and never let a missing registry silently degrade to exit 0.
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
EXIT_CODES_SH="$SCRIPT_DIR/../gsd-core/bin/shared/exit-codes.sh"
if [[ ! -f "$EXIT_CODES_SH" ]]; then
echo "secret-scan: FATAL: exit-code registry not found at $EXIT_CODES_SH" >&2
echo " Regenerate with: node scripts/gen-exit-code-registry.cjs --write" >&2
exit 1
fi
# shellcheck disable=SC1090
. "$EXIT_CODES_SH"
# ─── Global mode flag ─────────────────────────────────────────────────────────
STRICT_MODE=false
@@ -235,7 +252,31 @@ collect_files() {
case "$mode" in
--diff)
local base="${1:-origin/main}"
git diff --name-only --diff-filter=ACMR "$base"...HEAD 2>/dev/null \
# Run git separately from the filter pipe so its OWN exit status (not
# grep's) decides whether the diff could be established. stdout and
# stderr are captured SEPARATELY (never merged with `2>&1`) so that a
# warning git writes to stderr on an otherwise successful diff can
# never be mistaken for a filename in the file list. On failure the
# captured stderr is emitted as the diagnostic; on success it is
# forwarded as a warning, never folded into the file list.
# `|| true` on the filter below is CORRECT (not gratuitous): `grep -v`
# exits 1 when every line is filtered out (e.g. an all-images diff),
# which is a legitimate empty result, not a failure to run.
local raw status err_file
err_file=$(mktemp)
raw=$(git diff --name-only --diff-filter=ACMR "$base"...HEAD 2>"$err_file")
status=$?
if (( status != 0 )); then
cat "$err_file" >&2
rm -f "$err_file"
exit "$EXIT_UNAVAILABLE"
fi
if [[ -s "$err_file" ]]; then
echo "Warning: git diff emitted stderr output:" >&2
cat "$err_file" >&2
fi
rm -f "$err_file"
printf '%s\n' "$raw" \
| grep -vE '\.(png|jpg|jpeg|gif|ico|woff|woff2|ttf|eot|otf|zip|tar|gz|pdf)$' || true
;;
--file)
@@ -243,24 +284,41 @@ collect_files() {
echo "$1"
else
echo "Error: file not found: $1" >&2
exit 2
exit "$EXIT_USAGE"
fi
;;
--dir)
local dir="$1"
if [[ ! -d "$dir" ]]; then
echo "Error: directory not found: $dir" >&2
exit 2
exit "$EXIT_USAGE"
fi
find "$dir" -type f ! -path '*/node_modules/*' ! -path '*/.git/*' ! -path '*/dist/*' \
! -name '*.png' ! -name '*.jpg' ! -name '*.gif' ! -name '*.woff*' 2>/dev/null || true
# Same treatment as --diff: a `find` that fails (e.g. permission
# denied) must not be reported as an empty directory, and stdout/stderr
# are captured separately so a stderr warning never enters the file list.
local raw status err_file
err_file=$(mktemp)
raw=$(find "$dir" -type f ! -path '*/node_modules/*' ! -path '*/.git/*' ! -path '*/dist/*' \
! -name '*.png' ! -name '*.jpg' ! -name '*.gif' ! -name '*.woff*' 2>"$err_file")
status=$?
if (( status != 0 )); then
cat "$err_file" >&2
rm -f "$err_file"
exit "$EXIT_UNAVAILABLE"
fi
if [[ -s "$err_file" ]]; then
echo "Warning: find emitted stderr output:" >&2
cat "$err_file" >&2
fi
rm -f "$err_file"
printf '%s\n' "$raw"
;;
--stdin)
cat
;;
*)
echo "Usage: $0 --diff [base] | --file <path> | --dir <path> | --stdin" >&2
exit 2
exit "$EXIT_USAGE"
;;
esac
}
@@ -300,7 +358,7 @@ scan_file() {
main() {
if [[ $# -eq 0 ]]; then
echo "Usage: $0 --diff [base] | --file <path> | --dir <path> [--strict]" >&2
exit 2
exit "$EXIT_USAGE"
fi
# Parse --strict flag first (may appear anywhere in argv)
@@ -316,7 +374,7 @@ main() {
if [[ $# -eq 0 ]]; then
echo "Usage: $0 --diff [base] | --file <path> | --dir <path> [--strict]" >&2
exit 2
exit "$EXIT_USAGE"
fi
load_ignorelist
@@ -328,8 +386,12 @@ main() {
files=$(collect_files "$mode" "$@")
if [[ -z "$files" ]]; then
# collect_files already exited (UNAVAILABLE/USAGE) for anything that
# could not establish scope. Reaching here with an empty result means
# scope WAS established and is genuinely empty (e.g. an all-images
# diff) — that is NO_INPUT, not a silent clean pass.
echo "secret-scan: no files to scan"
exit 0
exit "$EXIT_NO_INPUT"
fi
local total=0

View File

@@ -55,16 +55,19 @@ function makeEntry(overrides) {
/**
* #3906 (ADR-3889 Phase 2): the generator now emits THREE artifacts — a
* primary (gsd-core/bin/lib), a secondary (scripts/lib), and the ambient
* `.d.cts` type declaration (src/exit-code-registry.d.cts). Every existing
* call site below only overrides the PRIMARY path via `--out`; without
* matching `--scripts-out`/`--dts-out` overrides, a `--write` here would
* clobber the real committed `scripts/lib/exit-code-registry.cjs` and
* `src/exit-code-registry.d.cts` — dangerous since test files in this repo
* run in parallel. Rather than touch every call site, this single seam
* derives co-located, per-call-unique secondary/dts paths from whatever
* `.d.cts` type declaration (src/exit-code-registry.d.cts). #3908 (Phase 4)
* added a FOURTH: the shell-sourceable fragment (gsd-core/bin/shared/
* exit-codes.sh). Every existing call site below only overrides the PRIMARY
* path via `--out`; without matching `--scripts-out`/`--dts-out`/`--sh-out`
* overrides, a `--write` here would clobber the real committed
* `scripts/lib/exit-code-registry.cjs`, `src/exit-code-registry.d.cts`, and
* `gsd-core/bin/shared/exit-codes.sh` — dangerous since test files in this
* repo run in parallel. Rather than touch every call site, this single seam
* derives co-located, per-call-unique secondary/dts/sh paths from whatever
* `--out` value the test already supplies, whenever the caller has not
* already supplied its own `--scripts-out`/`--dts-out`. Calls with no
* explicit `--out` (the "real committed set" checks) are left untouched.
* already supplied its own `--scripts-out`/`--dts-out`/`--sh-out`. Calls
* with no explicit `--out` (the "real committed set" checks) are left
* untouched.
*/
function ensureScriptsOut(args) {
const outIdx = args.indexOf('--out');
@@ -73,6 +76,7 @@ function ensureScriptsOut(args) {
const extra = [];
if (!args.includes('--scripts-out')) extra.push('--scripts-out', `${outValue}.secondary.cjs`);
if (!args.includes('--dts-out')) extra.push('--dts-out', `${outValue}.d.cts`);
if (!args.includes('--sh-out')) extra.push('--sh-out', `${outValue}.sh`);
return extra.length === 0 ? args : [...args, ...extra];
}

View File

@@ -44,6 +44,7 @@
"gsd-core/bin/shared/config-defaults.manifest.json",
"gsd-core/bin/shared/config-schema.manifest.json",
"gsd-core/bin/shared/exit-codes.json",
"gsd-core/bin/shared/exit-codes.sh",
"gsd-core/bin/shared/model-catalog.json",
"gsd-core/bin/shared/runtime-aliases.manifest.json",
"gsd-core/bin/verify-reapply-patches.cjs",

View File

@@ -115,6 +115,7 @@
"gsd-core/bin/shared/config-defaults.manifest.json",
"gsd-core/bin/shared/config-schema.manifest.json",
"gsd-core/bin/shared/exit-codes.json",
"gsd-core/bin/shared/exit-codes.sh",
"gsd-core/bin/shared/model-catalog.json",
"gsd-core/bin/shared/runtime-aliases.manifest.json",
"gsd-core/bin/verify-reapply-patches.cjs",

View File

@@ -115,6 +115,7 @@
"gsd-core/bin/shared/config-defaults.manifest.json",
"gsd-core/bin/shared/config-schema.manifest.json",
"gsd-core/bin/shared/exit-codes.json",
"gsd-core/bin/shared/exit-codes.sh",
"gsd-core/bin/shared/model-catalog.json",
"gsd-core/bin/shared/runtime-aliases.manifest.json",
"gsd-core/bin/verify-reapply-patches.cjs",

View File

@@ -44,6 +44,7 @@
"gsd-core/bin/shared/config-defaults.manifest.json",
"gsd-core/bin/shared/config-schema.manifest.json",
"gsd-core/bin/shared/exit-codes.json",
"gsd-core/bin/shared/exit-codes.sh",
"gsd-core/bin/shared/model-catalog.json",
"gsd-core/bin/shared/runtime-aliases.manifest.json",
"gsd-core/bin/verify-reapply-patches.cjs",

View File

@@ -46,6 +46,7 @@
"gsd-core/bin/shared/config-defaults.manifest.json",
"gsd-core/bin/shared/config-schema.manifest.json",
"gsd-core/bin/shared/exit-codes.json",
"gsd-core/bin/shared/exit-codes.sh",
"gsd-core/bin/shared/model-catalog.json",
"gsd-core/bin/shared/runtime-aliases.manifest.json",
"gsd-core/bin/verify-reapply-patches.cjs",

View File

@@ -115,6 +115,7 @@
"gsd-core/bin/shared/config-defaults.manifest.json",
"gsd-core/bin/shared/config-schema.manifest.json",
"gsd-core/bin/shared/exit-codes.json",
"gsd-core/bin/shared/exit-codes.sh",
"gsd-core/bin/shared/model-catalog.json",
"gsd-core/bin/shared/runtime-aliases.manifest.json",
"gsd-core/bin/verify-reapply-patches.cjs",

View File

@@ -80,6 +80,7 @@
"gsd-core/bin/shared/config-defaults.manifest.json",
"gsd-core/bin/shared/config-schema.manifest.json",
"gsd-core/bin/shared/exit-codes.json",
"gsd-core/bin/shared/exit-codes.sh",
"gsd-core/bin/shared/model-catalog.json",
"gsd-core/bin/shared/runtime-aliases.manifest.json",
"gsd-core/bin/verify-reapply-patches.cjs",

View File

@@ -45,6 +45,7 @@
"gsd-core/bin/shared/config-defaults.manifest.json",
"gsd-core/bin/shared/config-schema.manifest.json",
"gsd-core/bin/shared/exit-codes.json",
"gsd-core/bin/shared/exit-codes.sh",
"gsd-core/bin/shared/model-catalog.json",
"gsd-core/bin/shared/runtime-aliases.manifest.json",
"gsd-core/bin/verify-reapply-patches.cjs",

View File

@@ -44,6 +44,7 @@
"gsd-core/bin/shared/config-defaults.manifest.json",
"gsd-core/bin/shared/config-schema.manifest.json",
"gsd-core/bin/shared/exit-codes.json",
"gsd-core/bin/shared/exit-codes.sh",
"gsd-core/bin/shared/model-catalog.json",
"gsd-core/bin/shared/runtime-aliases.manifest.json",
"gsd-core/bin/verify-reapply-patches.cjs",

View File

@@ -44,6 +44,7 @@
"gsd-core/bin/shared/config-defaults.manifest.json",
"gsd-core/bin/shared/config-schema.manifest.json",
"gsd-core/bin/shared/exit-codes.json",
"gsd-core/bin/shared/exit-codes.sh",
"gsd-core/bin/shared/model-catalog.json",
"gsd-core/bin/shared/runtime-aliases.manifest.json",
"gsd-core/bin/verify-reapply-patches.cjs",

View File

@@ -115,6 +115,7 @@
"gsd-core/bin/shared/config-defaults.manifest.json",
"gsd-core/bin/shared/config-schema.manifest.json",
"gsd-core/bin/shared/exit-codes.json",
"gsd-core/bin/shared/exit-codes.sh",
"gsd-core/bin/shared/model-catalog.json",
"gsd-core/bin/shared/runtime-aliases.manifest.json",
"gsd-core/bin/verify-reapply-patches.cjs",

View File

@@ -45,6 +45,7 @@
"gsd-core/bin/shared/config-defaults.manifest.json",
"gsd-core/bin/shared/config-schema.manifest.json",
"gsd-core/bin/shared/exit-codes.json",
"gsd-core/bin/shared/exit-codes.sh",
"gsd-core/bin/shared/model-catalog.json",
"gsd-core/bin/shared/runtime-aliases.manifest.json",
"gsd-core/bin/verify-reapply-patches.cjs",

View File

@@ -81,6 +81,7 @@
"gsd-core/bin/shared/config-defaults.manifest.json",
"gsd-core/bin/shared/config-schema.manifest.json",
"gsd-core/bin/shared/exit-codes.json",
"gsd-core/bin/shared/exit-codes.sh",
"gsd-core/bin/shared/model-catalog.json",
"gsd-core/bin/shared/runtime-aliases.manifest.json",
"gsd-core/bin/verify-reapply-patches.cjs",

View File

@@ -115,6 +115,7 @@
"gsd-core/bin/shared/config-defaults.manifest.json",
"gsd-core/bin/shared/config-schema.manifest.json",
"gsd-core/bin/shared/exit-codes.json",
"gsd-core/bin/shared/exit-codes.sh",
"gsd-core/bin/shared/model-catalog.json",
"gsd-core/bin/shared/runtime-aliases.manifest.json",
"gsd-core/bin/verify-reapply-patches.cjs",

View File

@@ -11,6 +11,7 @@
"gsd-core/bin/shared/config-defaults.manifest.json",
"gsd-core/bin/shared/config-schema.manifest.json",
"gsd-core/bin/shared/exit-codes.json",
"gsd-core/bin/shared/exit-codes.sh",
"gsd-core/bin/shared/model-catalog.json",
"gsd-core/bin/shared/runtime-aliases.manifest.json",
"gsd-core/bin/verify-reapply-patches.cjs",

View File

@@ -44,6 +44,7 @@
"gsd-core/bin/shared/config-defaults.manifest.json",
"gsd-core/bin/shared/config-schema.manifest.json",
"gsd-core/bin/shared/exit-codes.json",
"gsd-core/bin/shared/exit-codes.sh",
"gsd-core/bin/shared/model-catalog.json",
"gsd-core/bin/shared/runtime-aliases.manifest.json",
"gsd-core/bin/verify-reapply-patches.cjs",

View File

@@ -44,6 +44,7 @@
"gsd-core/bin/shared/config-defaults.manifest.json",
"gsd-core/bin/shared/config-schema.manifest.json",
"gsd-core/bin/shared/exit-codes.json",
"gsd-core/bin/shared/exit-codes.sh",
"gsd-core/bin/shared/model-catalog.json",
"gsd-core/bin/shared/runtime-aliases.manifest.json",
"gsd-core/bin/verify-reapply-patches.cjs",

View File

@@ -44,6 +44,7 @@
"gsd-core/bin/shared/config-defaults.manifest.json",
"gsd-core/bin/shared/config-schema.manifest.json",
"gsd-core/bin/shared/exit-codes.json",
"gsd-core/bin/shared/exit-codes.sh",
"gsd-core/bin/shared/model-catalog.json",
"gsd-core/bin/shared/runtime-aliases.manifest.json",
"gsd-core/bin/verify-reapply-patches.cjs",

View File

@@ -115,6 +115,7 @@
"gsd-core/bin/shared/config-defaults.manifest.json",
"gsd-core/bin/shared/config-schema.manifest.json",
"gsd-core/bin/shared/exit-codes.json",
"gsd-core/bin/shared/exit-codes.sh",
"gsd-core/bin/shared/model-catalog.json",
"gsd-core/bin/shared/runtime-aliases.manifest.json",
"gsd-core/bin/verify-reapply-patches.cjs",

View File

@@ -32,14 +32,17 @@
// Migrating these to a parsed IR would add ceremony without changing
// what is verified — the strings ARE the typed surface.
const { describe, test } = require('node:test');
const { describe, test, before, after } = require('node:test');
const assert = require('node:assert/strict');
const { execFileSync, spawnSync } = require('child_process');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { cleanup } = require('./helpers.cjs');
const { cleanup, createTempGitProject } = require('./helpers.cjs');
const { runHook } = require('./helpers/process-seam.cjs');
const { gitOrThrow, GIT_FIXTURE_TIMEOUT_MS } = require('./helpers/git-fixture.cjs');
const { HOOK_FANOUT_TIMEOUT_MS } = require('./helpers/timeouts.cjs');
const PROJECT_ROOT = path.join(__dirname, '..');
const SCRIPTS = {
@@ -47,6 +50,10 @@ const SCRIPTS = {
base64: path.join(PROJECT_ROOT, 'scripts', 'base64-scan.sh'),
secret: path.join(PROJECT_ROOT, 'scripts', 'secret-scan.sh'),
};
// ADR-3889 (#3908): the generated exit-code registry — codes are resolved
// via exitCodeFor(), never hardcoded, so this suite stays correct if the
// registry's integers ever change.
const { exitCodeFor } = require('../gsd-core/bin/lib/exit-code-registry.cjs');
// Helper: create a temp file with given content, run scanner, return { status, stdout, stderr }
const IS_WINDOWS = process.platform === 'win32';
@@ -189,7 +196,7 @@ describe('prompt-injection-scan.sh', { skip: IS_WINDOWS }, () => {
assert.equal(result.status, 0);
});
test('exits 2 on missing arguments', () => {
test('exits USAGE on missing arguments', () => {
try {
execFileSync(SCRIPTS.injection, [], {
encoding: 'utf-8',
@@ -198,7 +205,7 @@ describe('prompt-injection-scan.sh', { skip: IS_WINDOWS }, () => {
});
assert.fail('Should have exited non-zero');
} catch (err) {
assert.equal(err.status, 2);
assert.equal(err.status, exitCodeFor('USAGE'));
}
});
});
@@ -280,7 +287,7 @@ describe('base64-scan.sh', { skip: IS_WINDOWS }, () => {
assert.equal(result.status, 0);
});
test('exits 2 on missing arguments', () => {
test('exits USAGE on missing arguments', () => {
try {
execFileSync(SCRIPTS.base64, [], {
encoding: 'utf-8',
@@ -289,7 +296,7 @@ describe('base64-scan.sh', { skip: IS_WINDOWS }, () => {
});
assert.fail('Should have exited non-zero');
} catch (err) {
assert.equal(err.status, 2);
assert.equal(err.status, exitCodeFor('USAGE'));
}
});
@@ -492,7 +499,7 @@ describe('secret-scan.sh', { skip: IS_WINDOWS }, () => {
assert.equal(result.status, 0);
});
test('exits 2 on missing arguments', () => {
test('exits USAGE on missing arguments', () => {
try {
execFileSync(SCRIPTS.secret, [], {
encoding: 'utf-8',
@@ -501,7 +508,240 @@ describe('secret-scan.sh', { skip: IS_WINDOWS }, () => {
});
assert.fail('Should have exited non-zero');
} catch (err) {
assert.equal(err.status, 2);
assert.equal(err.status, exitCodeFor('USAGE'));
}
});
});
// ─── Exit-Code Contract (ADR-3889 Phase 4, #3908) ──────────────────────────
//
// Drives the real scripts through tests/helpers/process-seam.cjs's `runHook`
// (never a hand-rolled spawnSync — a review blocker on P3). Every repo
// fixture is a throwaway temp git repo built via
// createTempGitProject/gitOrThrow; nothing here depends on, or mutates, this
// repo's own git state, since test files in this suite run in parallel.
//
// The "shared" describes assert the SAME code across all three scanners for
// input classes that collapse identically regardless of scanner-specific
// extension filtering (a bad ref, no repo, no commits, an established-empty
// diff, an all-images diff, and every usage error). The "controls" describe
// is load-bearing: without a "files changed, no findings" case per scanner,
// an implementation that returns UNAVAILABLE unconditionally would satisfy
// every assertion above it.
describe('scanner exit-code contract', { skip: IS_WINDOWS }, () => {
const SCANNERS = [
['secret-scan', SCRIPTS.secret],
['base64-scan', SCRIPTS.base64],
['prompt-injection-scan', SCRIPTS.injection],
];
function runScanner(scriptPath, args, opts = {}) {
return runHook(scriptPath, args, { interpreter: 'bash', timeoutMs: HOOK_FANOUT_TIMEOUT_MS, ...opts });
}
describe('shared exit-code classes (identical across all three scanners)', () => {
let repo;
before(() => { repo = createTempGitProject('gsd-scan-shared-'); });
after(() => { cleanup(repo); });
for (const [name, scriptPath] of SCANNERS) {
test(`${name}: nonexistent --diff ref -> UNAVAILABLE, git diagnostic on stderr`, () => {
const result = runScanner(scriptPath, ['--diff', 'refs/heads/does-not-exist-xyz'], { cwd: repo });
assert.equal(result.exitCode, exitCodeFor('UNAVAILABLE'));
assert.ok(result.stderr.length > 0, 'git\'s own diagnostic must survive on stderr');
});
test(`${name}: --file with a nonexistent path -> USAGE`, () => {
const result = runScanner(scriptPath, ['--file', path.join(repo, 'does-not-exist.md')], { cwd: repo });
assert.equal(result.exitCode, exitCodeFor('USAGE'));
});
test(`${name}: --dir with a nonexistent path -> USAGE`, () => {
const result = runScanner(scriptPath, ['--dir', path.join(repo, 'does-not-exist-dir')], { cwd: repo });
assert.equal(result.exitCode, exitCodeFor('USAGE'));
});
test(`${name}: unknown mode -> USAGE`, () => {
const result = runScanner(scriptPath, ['--bogus-mode'], { cwd: repo });
assert.equal(result.exitCode, exitCodeFor('USAGE'));
});
test(`${name}: no argv at all -> USAGE`, () => {
const result = runScanner(scriptPath, [], { cwd: repo });
assert.equal(result.exitCode, exitCodeFor('USAGE'));
});
}
});
describe('outside a git repository -> UNAVAILABLE', () => {
let nonRepoDir;
before(() => { nonRepoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-scan-norepo-')); });
after(() => { cleanup(nonRepoDir); });
for (const [name, scriptPath] of SCANNERS) {
test(name, () => {
const result = runScanner(scriptPath, ['--diff', 'origin/next'], { cwd: nonRepoDir });
assert.equal(result.exitCode, exitCodeFor('UNAVAILABLE'));
});
}
});
describe('repo with no commits -> UNAVAILABLE', () => {
let emptyRepo;
before(() => {
emptyRepo = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-scan-nocommit-'));
gitOrThrow(['init'], { cwd: emptyRepo, timeoutMs: GIT_FIXTURE_TIMEOUT_MS });
});
after(() => { cleanup(emptyRepo); });
for (const [name, scriptPath] of SCANNERS) {
test(name, () => {
const result = runScanner(scriptPath, ['--diff', 'origin/next'], { cwd: emptyRepo });
assert.equal(result.exitCode, exitCodeFor('UNAVAILABLE'));
});
}
});
describe('established-empty diff (base === HEAD) -> NO_INPUT', () => {
let repo;
before(() => {
repo = createTempGitProject('gsd-scan-emptydiff-');
gitOrThrow(['branch', 'base-branch'], { cwd: repo, timeoutMs: GIT_FIXTURE_TIMEOUT_MS });
});
after(() => { cleanup(repo); });
for (const [name, scriptPath] of SCANNERS) {
test(name, () => {
const result = runScanner(scriptPath, ['--diff', 'base-branch'], { cwd: repo });
assert.equal(result.exitCode, exitCodeFor('NO_INPUT'));
});
}
});
describe('all-images diff -> NO_INPUT (not a failure, not UNAVAILABLE)', () => {
let repo;
before(() => {
repo = createTempGitProject('gsd-scan-images-');
gitOrThrow(['branch', 'base-branch'], { cwd: repo, timeoutMs: GIT_FIXTURE_TIMEOUT_MS });
fs.writeFileSync(path.join(repo, 'pic.png'), 'fake png bytes');
gitOrThrow(['add', '-A'], { cwd: repo, timeoutMs: GIT_FIXTURE_TIMEOUT_MS });
gitOrThrow(['commit', '-m', 'add image only'], { cwd: repo, timeoutMs: GIT_FIXTURE_TIMEOUT_MS });
});
after(() => { cleanup(repo); });
for (const [name, scriptPath] of SCANNERS) {
test(name, () => {
const result = runScanner(scriptPath, ['--diff', 'base-branch'], { cwd: repo });
assert.equal(
result.exitCode, exitCodeFor('NO_INPUT'),
`expected NO_INPUT — stdout: ${result.stdout} stderr: ${result.stderr}`,
);
assert.notEqual(result.exitCode, 1, `${name} must not report the all-images diff as a failure`);
});
}
});
// ── Controls ───────────────────────────────────────────────────────────
describe('controls: clean scan / findings scan / mixed diff still work', () => {
test('secret-scan: clean scan with a real file still exits 0', (t) => {
const repo = createTempGitProject('gsd-scan-clean-secret-');
t.after(() => cleanup(repo));
gitOrThrow(['branch', 'base-branch'], { cwd: repo, timeoutMs: GIT_FIXTURE_TIMEOUT_MS });
fs.writeFileSync(path.join(repo, 'code.txt'), 'clean text content\n');
gitOrThrow(['add', '-A'], { cwd: repo, timeoutMs: GIT_FIXTURE_TIMEOUT_MS });
gitOrThrow(['commit', '-m', 'add clean file'], { cwd: repo, timeoutMs: GIT_FIXTURE_TIMEOUT_MS });
const result = runScanner(SCRIPTS.secret, ['--diff', 'base-branch'], { cwd: repo });
assert.equal(result.exitCode, 0, result.stdout + result.stderr);
});
test('secret-scan: a diff WITH a real secret still reports findings (exit 1)', (t) => {
const repo = createTempGitProject('gsd-scan-findings-secret-');
t.after(() => cleanup(repo));
gitOrThrow(['branch', 'base-branch'], { cwd: repo, timeoutMs: GIT_FIXTURE_TIMEOUT_MS });
const key = ['AKIA', 'IOSFODNN7EXAMPLE'].join('');
fs.writeFileSync(path.join(repo, 'secret.txt'), `aws_key = "${key}"\n`);
gitOrThrow(['add', '-A'], { cwd: repo, timeoutMs: GIT_FIXTURE_TIMEOUT_MS });
gitOrThrow(['commit', '-m', 'add secret'], { cwd: repo, timeoutMs: GIT_FIXTURE_TIMEOUT_MS });
const result = runScanner(SCRIPTS.secret, ['--diff', 'base-branch'], { cwd: repo });
assert.equal(result.exitCode, 1, result.stdout + result.stderr);
assert.ok(result.stdout.includes('FAIL'));
});
test('prompt-injection-scan: mixed images+code diff scans the code file (exit 0, clean)', (t) => {
const repo = createTempGitProject('gsd-scan-mixed-');
t.after(() => cleanup(repo));
gitOrThrow(['branch', 'base-branch'], { cwd: repo, timeoutMs: GIT_FIXTURE_TIMEOUT_MS });
fs.writeFileSync(path.join(repo, 'pic.png'), 'fake png bytes');
fs.writeFileSync(path.join(repo, 'clean.md'), '# Clean docs\n');
gitOrThrow(['add', '-A'], { cwd: repo, timeoutMs: GIT_FIXTURE_TIMEOUT_MS });
gitOrThrow(['commit', '-m', 'mixed'], { cwd: repo, timeoutMs: GIT_FIXTURE_TIMEOUT_MS });
const result = runScanner(SCRIPTS.injection, ['--diff', 'base-branch'], { cwd: repo });
assert.equal(result.exitCode, 0, result.stdout + result.stderr);
});
test('--file / --dir / --stdin controls still scan and pass on clean content', (t) => {
const repo = createTempGitProject('gsd-scan-modes-');
t.after(() => cleanup(repo));
const cleanFile = path.join(repo, 'clean.md');
fs.writeFileSync(cleanFile, '# Clean docs\n');
assert.equal(runScanner(SCRIPTS.injection, ['--file', cleanFile]).exitCode, 0);
assert.equal(runScanner(SCRIPTS.injection, ['--dir', repo]).exitCode, 0);
const stdinResult = runScanner(SCRIPTS.injection, ['--stdin'], { input: '# clean\n' });
assert.equal(stdinResult.exitCode, 0);
});
});
describe('--dir unreadable -> UNAVAILABLE', () => {
let parent, locked;
before(() => {
parent = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-scan-unreadable-'));
locked = path.join(parent, 'locked');
fs.mkdirSync(locked);
fs.chmodSync(locked, 0o000);
});
after(() => {
try { fs.chmodSync(locked, 0o755); } catch { /* best effort, for cleanup() below */ }
cleanup(parent);
});
for (const [name, scriptPath] of SCANNERS) {
test(name, (t) => {
// Root (and some CI/Docker images running as root) bypasses mode
// bits entirely — a bare `return` here would be a silent PASS, so
// this is an explicit t.skip() instead.
if (typeof process.getuid === 'function' && process.getuid() === 0) {
t.skip('running as root — mode bits do not restrict access');
return;
}
const result = runScanner(scriptPath, ['--dir', locked]);
assert.equal(result.exitCode, exitCodeFor('UNAVAILABLE'));
});
}
});
describe('missing exit-codes.sh -> loud non-zero, never 0', () => {
// Isolated copy of the scanner in a throwaway tree whose gsd-core/bin/
// shared/ directory has no exit-codes.sh — never touches the real
// committed file, so this is safe under parallel test-file execution.
function isolatedCopyWithNoRegistry(scriptPath) {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-scan-noregistry-'));
fs.mkdirSync(path.join(root, 'scripts'), { recursive: true });
fs.mkdirSync(path.join(root, 'gsd-core', 'bin', 'shared'), { recursive: true });
const dest = path.join(root, 'scripts', path.basename(scriptPath));
fs.copyFileSync(scriptPath, dest);
fs.chmodSync(dest, 0o755);
return { root, dest };
}
for (const [name, scriptPath] of SCANNERS) {
test(name, (t) => {
const { root, dest } = isolatedCopyWithNoRegistry(scriptPath);
t.after(() => cleanup(root));
const result = runScanner(dest, ['--diff', 'origin/next']);
assert.ok(Number.isInteger(result.exitCode), `expected a numeric exit code, got ${result.exitCode}`);
assert.notEqual(result.exitCode, 0, 'a missing exit-code registry must never silently exit 0');
});
}
});
});