agent-gov-core 0.5.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +50 -0
- package/README.md +46 -2
- package/dist/exceptions.d.ts +83 -0
- package/dist/exceptions.js +129 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +4 -0
- package/dist/merge.d.ts +91 -0
- package/dist/merge.js +154 -0
- package/dist/report.d.ts +85 -0
- package/dist/report.js +156 -0
- package/dist/secrets.d.ts +67 -0
- package/dist/secrets.js +81 -0
- package/package.json +3 -2
- package/schemas/report.schema.json +55 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,56 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project will be documented here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). Under v1.0, minor versions may include breaking changes — see [CONTRIBUTING.md](./CONTRIBUTING.md#backwards-compatibility) for the rules.
|
|
4
4
|
|
|
5
|
+
## [0.7.0] — 2026-05-22
|
|
6
|
+
|
|
7
|
+
**The pre-v1.0 consolidation release.** Bundles everything that was queued for v0.6.0 (report envelope + merge layer + OTel GenAI interop) plus two universal detectors promoted from consumer repos: `matchSecret` (from PolicyMesh) and `applyExceptions` (unifying PolicyMesh's `subject` and TaskBound's `allow_paths` shapes).
|
|
8
|
+
|
|
9
|
+
No breaking changes to the v0.5.0 surface — additive minor bump. One npm publish covers all of it.
|
|
10
|
+
|
|
11
|
+
This is the last release before v1.0 freeze. The remaining gate is consumer-side: at least one tool wiring `generateWorkflowSummary` end-to-end, then v1.0 with semver guarantees on the contract pinned by the golden tests.
|
|
12
|
+
|
|
13
|
+
### Added — Report envelope
|
|
14
|
+
- `Report` interface — canonical multi-tool envelope with `schemaVersion`, `tool`, `rating`, optional `toolVersion`/`runId`/`conversationId`/`baseRef`/`headRef`, `findings: Finding[]`, and tool-specific extension `data`.
|
|
15
|
+
- `Report.conversationId` (optional) — agent session / PR review / thread identifier. Matches OpenTelemetry's `gen_ai.conversation.id` semantic convention so a consumer can pass the same string into both governance reports and OTel traces, then correlate them downstream.
|
|
16
|
+
- `REPORT_SCHEMA_VERSION` const (`'1.0'`).
|
|
17
|
+
- `schemas/report.schema.json` — JSON schema for the envelope, exposed via the package's `./schemas/report.schema.json` export.
|
|
18
|
+
- `createReport({tool, findings, ...})` — convenience constructor; sets `schemaVersion` and computes `rating` from max finding severity (unless overridden).
|
|
19
|
+
- `maxSeverity(findings)` — helper that returns `'none' | Severity` across a finding list.
|
|
20
|
+
- `validateReport(value)` — strict envelope check that also validates each contained finding and flags cross-field inconsistencies (e.g. rating below implied max).
|
|
21
|
+
|
|
22
|
+
### Added — Merge layer
|
|
23
|
+
- `mergeFindings(reports, opts?)` — combine N tool reports into one normalized `MergedReport`:
|
|
24
|
+
- Deduplicates by `Finding.fingerprint`. Default policy: keep highest severity; `duplicatePolicy: 'first'` keeps the first occurrence.
|
|
25
|
+
- Optional severity `threshold` drops findings below the requested level into a counted `droppedBelowThreshold` field.
|
|
26
|
+
- Aggregates rating from the surviving findings, not source ratings — so threshold filtering correctly demotes the merged rating.
|
|
27
|
+
- Sorts findings by severity, highest first.
|
|
28
|
+
- Propagates `conversationId` to the merged report iff every source agrees. Cross-conversation mixing leaves the field intentionally empty so a meta-reviewer can detect misuse.
|
|
29
|
+
- **Never silently drops bad data**: malformed envelopes go to `invalidReports[]`, individual malformed findings go to `invalidFindings[]`. A single bad finding in a tool's report doesn't poison the rest of that report.
|
|
30
|
+
- `MergeOptions`, `MergeSource` (with optional `conversationId`), `MergedReport` (with optional `conversationId`), `InvalidReport`, `InvalidFinding` types.
|
|
31
|
+
|
|
32
|
+
### Added — OpenTelemetry GenAI interop
|
|
33
|
+
- `docs/INTEROP-OTEL.md` — explicit cross-walk between `agent-gov-core` types and OTel's [`gen_ai.*` semantic conventions](https://opentelemetry.io/docs/specs/semconv/gen-ai/). Maps `Report.conversationId` ↔ `gen_ai.conversation.id`, documents why we adopt one bridge field and not the whole namespace, and shows a paired-emission pattern for orgs running OTel-instrumented agents alongside governance tools.
|
|
34
|
+
|
|
35
|
+
### Added — Hardcoded secret detection (promoted from PolicyMesh)
|
|
36
|
+
- `matchSecret(value, options?)` — scans a string for provider-prefix credentials and returns `{ provider }` (never the literal credential). Built-in patterns: Anthropic, OpenAI (sk- + sk-proj-), GitHub (PAT + classic), Slack, AWS, Google, GitLab, npm, Docker, Stripe, plus a length-restricted hex token pattern gated to env/header context to avoid commit-SHA false positives.
|
|
37
|
+
- `MatchSecretOptions.envOrHeaderContext` — opt-in flag for the hex token pattern.
|
|
38
|
+
- `SECRET_PATTERNS` — exported read-only constant table; golden-tested so additions are non-breaking but removals require a major bump.
|
|
39
|
+
- `SecretMatch` type.
|
|
40
|
+
- `env:VAR` references are never flagged (Codex notation for env-var lookups).
|
|
41
|
+
|
|
42
|
+
### Added — Exception baselines (promoted + unified from PolicyMesh + TaskBound)
|
|
43
|
+
- `applyExceptions(findings, exceptions, now?)` — suppress (or downgrade-on-expiry) findings matched by `kind` + optional `salientKey` + optional `pathPrefix`. PolicyMesh's `.policymesh-exceptions.json` shape and TaskBound's `.taskbound.yml` `ignore_kinds`/`allow_paths` shape both map cleanly onto this unified primitive.
|
|
44
|
+
- Expired exceptions don't silently drop — they re-surface with severity downgraded to `'low'` and an `[EXPIRED WHITELIST]` message prefix so stale baselines stay visible. Reason text propagates to `finding.data.exceptionReason`.
|
|
45
|
+
- `validateException(value)` — runtime check for well-formed exception entries.
|
|
46
|
+
- `Exception`, `ApplyExceptionsResult` types.
|
|
47
|
+
|
|
48
|
+
### Tests
|
|
49
|
+
- 57 new cases. 220 total (up from 163). Breakdown:
|
|
50
|
+
- Report: 17 (schemaVersion pinning, rating derivation, explicit-rating override, validateReport accepting/rejecting envelope-level errors, finding-tool consistency, unknown property rejection, downgrade-allowed/upgrade-flagged rating consistency, conversationId passthrough + type check).
|
|
51
|
+
- Merge: 14 (empty input, cross-tool combine, fingerprint dedup with `highest_severity` and `first` policies, salientKey-disambiguated findings stay separate, threshold filtering, malformed report → invalidReports, malformed finding → invalidFindings, severity-sorted output, aggregate-rating-reflects-survivors, source provenance, conversationId agreement/disagreement/partial-coverage propagation).
|
|
52
|
+
- Secrets: 11 (each provider class detected, env: refs never flagged, empty/short input ignored, hex token gated to env/header context, non-hex 40-char string rejected, never-leak-literal contract, golden-pinned `SECRET_PATTERNS` provider set).
|
|
53
|
+
- Exceptions: 15 (empty input identity, suppress by kind/salientKey/pathPrefix, perpetual-active when no expires, expired surfacing with downgrade/prefix/reason, non-matching kind/salientKey passthrough, pathPrefix without location.file safely non-matches, malformed expires treated as never-expires, future expires stays active, validateException accept/reject paths).
|
|
54
|
+
|
|
5
55
|
## [0.5.0] — 2026-05-22
|
|
6
56
|
|
|
7
57
|
Three additive features completing the queue from Gemini's third inspection round, plus five correctness fixes from a deep code-level inspection done before publish. No breaking changes — existing exports and call signatures unchanged.
|
package/README.md
CHANGED
|
@@ -33,7 +33,7 @@ const finding = createFinding({
|
|
|
33
33
|
// finding.fingerprint === '<stable 16-char hex>'
|
|
34
34
|
```
|
|
35
35
|
|
|
36
|
-
`createFinding` calls `kind()` to build the namespaced kind, validates the slug shape, and computes a stable `fingerprintFinding(finding)` hash of `(kind, file, line, column)`.
|
|
36
|
+
`createFinding` calls `kind()` to build the namespaced kind, validates the slug shape, and computes a stable `fingerprintFinding(finding)` hash of `(kind, file, line, column, salientKey?)`. Pass `salientKey` when two distinct findings can legitimately fire at the same `(kind, file, line)` site (e.g. two suspicious imports on one line) so the meta-reviewer doesn't collapse them into one.
|
|
37
37
|
|
|
38
38
|
### Validate findings from disk
|
|
39
39
|
|
|
@@ -54,6 +54,28 @@ for (const f of report.findings) {
|
|
|
54
54
|
}
|
|
55
55
|
```
|
|
56
56
|
|
|
57
|
+
### Merge reports across tools (the meta-reviewer pipeline)
|
|
58
|
+
|
|
59
|
+
A cross-tool meta-reviewer ingests JSON reports from N tools, dedupes findings by fingerprint, applies a severity threshold, and rolls up an aggregate rating. The library ships this as `mergeFindings`:
|
|
60
|
+
|
|
61
|
+
```ts
|
|
62
|
+
import { mergeFindings } from 'agent-gov-core';
|
|
63
|
+
import { readFileSync } from 'node:fs';
|
|
64
|
+
|
|
65
|
+
const reports = [
|
|
66
|
+
JSON.parse(readFileSync('scopetrail-report.json', 'utf8')),
|
|
67
|
+
JSON.parse(readFileSync('policymesh-report.json', 'utf8')),
|
|
68
|
+
JSON.parse(readFileSync('capabilityecho-report.json', 'utf8')),
|
|
69
|
+
];
|
|
70
|
+
|
|
71
|
+
const merged = mergeFindings(reports, { threshold: 'medium' });
|
|
72
|
+
console.log(`Merged rating: ${merged.rating}`);
|
|
73
|
+
console.log(`${merged.findings.length} unique findings across ${merged.sources.length} tools`);
|
|
74
|
+
console.log(`Dropped ${merged.droppedBelowThreshold} below threshold; collapsed ${merged.duplicateCollapsed} duplicates`);
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Malformed reports go to `merged.invalidReports`; malformed individual findings go to `merged.invalidFindings` — neither is silently dropped, so a meta-reviewer can surface what went wrong.
|
|
78
|
+
|
|
57
79
|
### Schema is the contract
|
|
58
80
|
|
|
59
81
|
The JSON schema at [`schemas/finding.schema.json`](./schemas/finding.schema.json) is the single source of truth for the dotted-kind shape, the closed `tool` enum, and the location fields. Any tool emitting unprefixed kinds will fail validation. See [CONTRIBUTING.md](./CONTRIBUTING.md#the-finding-schema-is-the-contract) for how the TypeScript types and JSON schema are kept in lockstep.
|
|
@@ -69,6 +91,23 @@ The JSON schema at [`schemas/finding.schema.json`](./schemas/finding.schema.json
|
|
|
69
91
|
- `fingerprintFinding(finding)` — 16-character hex hash of `(kind, file, line, column, salientKey?)`. Stable across runs and message rewordings, so a meta-reviewer can dedupe. Pass `salientKey` (since v0.4.3) when multiple distinct findings can fire at the same site
|
|
70
92
|
- `validateFinding(value)` — runtime check against `schemas/finding.schema.json`, returns `{ ok, errors[] }`
|
|
71
93
|
|
|
94
|
+
### Hardcoded secret detection (since v0.7.0)
|
|
95
|
+
- `matchSecret(value, options?)` — scans for provider-prefix credentials (Anthropic, OpenAI, GitHub, AWS, Slack, Google, GitLab, npm, Docker, Stripe, plus env/header-gated hex tokens). Returns `{ provider }` — **never the literal credential**. Pass `envOrHeaderContext: true` only when scanning env/header values.
|
|
96
|
+
- `SECRET_PATTERNS` — read-only constant; the active provider set is pinned by golden tests so additions stay non-breaking.
|
|
97
|
+
|
|
98
|
+
### Exception baselines (since v0.7.0)
|
|
99
|
+
- `applyExceptions(findings, exceptions, now?)` — suppress findings matched by `kind` + optional `salientKey` + optional `pathPrefix`. Expired exceptions re-surface the finding with severity downgraded to `'low'` and an `[EXPIRED WHITELIST]` prefix so stale baselines stay visible.
|
|
100
|
+
- `validateException(value)` — runtime check for well-formed exception entries loaded from JSON/YAML.
|
|
101
|
+
|
|
102
|
+
### Report envelope and merge (since v0.6.0)
|
|
103
|
+
- `Report` — canonical multi-tool envelope wrapping a `Finding[]` with `schemaVersion`, `tool`, `rating`, optional `toolVersion`/`runId`/`conversationId`/`baseRef`/`headRef`, and tool-specific extension data in `data`
|
|
104
|
+
- `Report.conversationId` — opt-in session identifier matching OpenTelemetry's [`gen_ai.conversation.id`](https://opentelemetry.io/docs/specs/semconv/gen-ai/) so governance findings and runtime traces can correlate by the same string. See [docs/INTEROP-OTEL.md](./docs/INTEROP-OTEL.md) for the full cross-walk.
|
|
105
|
+
- `REPORT_SCHEMA_VERSION` — current envelope version (`'1.0'`)
|
|
106
|
+
- `createReport({tool, findings, ...})` — sets `schemaVersion` and derives `rating` from max finding severity
|
|
107
|
+
- `maxSeverity(findings)` — returns `'none' | Severity`, used by `createReport`
|
|
108
|
+
- `validateReport(value)` — strict envelope check including each finding; returns `{ ok, errors[] }`
|
|
109
|
+
- `mergeFindings(reports, opts?)` — combine N tool reports, dedupe by fingerprint, apply threshold, roll up rating; preserves both invalid envelopes and invalid findings separately so nothing is silently dropped. Propagates `conversationId` to the merged report iff every source agrees on it.
|
|
110
|
+
|
|
72
111
|
### Config readers
|
|
73
112
|
- `readJsonObjectWithSource(path)` — JSONC reader, string-aware comment + trailing-comma stripping, position-preserving. Returns `{ value, json, text, parseError? }`. When the underlying parser provides a byte offset, `parseError` is a `ConfigParseError` carrying `line`/`column`/`rawOffset` instead of a raw `Error`.
|
|
74
113
|
- `stripJsonComments(text)` — same logic exposed for in-memory text
|
|
@@ -83,7 +122,12 @@ The JSON schema at [`schemas/finding.schema.json`](./schemas/finding.schema.json
|
|
|
83
122
|
- `lineOfTomlKey(text, dottedKey, scope?)` — 1-based line of a TOML key, optionally scoped to a byte range. Use scope to disambiguate `[[array]]`-of-tables entries that share the same leaf key.
|
|
84
123
|
|
|
85
124
|
### MCP command normalization
|
|
86
|
-
- `normalizeMcpCommand({ command, args, url, env, cwd })` — canonical identity string for an MCP server entry.
|
|
125
|
+
- `normalizeMcpCommand({ command, args, url, env, cwd })` — canonical identity string for an MCP server entry. Used to dedupe `mcp_command_mismatch` false positives when servers are equivalent but syntactically different across machines / config files. Does not interpret what npx/uvx invocations resolve to at runtime — that's outside the substrate's scope.
|
|
126
|
+
- Drops neutral confirm flags (`-y`, `--yes`) so `npx -y foo` and `npx foo` collapse to the same identity.
|
|
127
|
+
- Strips Windows executable suffixes (`.cmd`, `.exe`, `.bat`, `.ps1`) and case-folds Windows-shaped paths — `NPX.CMD`, `npx.cmd`, and `npx` are all the same executable on Windows.
|
|
128
|
+
- For known runtimes (`node`, `npx`, `python`, `bash`, etc.), drops the directory portion of absolute paths so `/usr/bin/node`, `/usr/local/bin/node`, and `node` produce identical identity. Custom scripts at absolute paths keep their full path.
|
|
129
|
+
- Treats common boolean flags (`--verbose`, `--quiet`, `--debug`, `--help`, `--version`, `--force`, `--dry-run`, `--json`, etc.) as standalone instead of greedily pairing them with the next positional argument.
|
|
130
|
+
- Sorts non-neutral `--key value` flag pairs alphabetically, preserves positional argument order, includes env + cwd in the identity.
|
|
87
131
|
|
|
88
132
|
### Shell tokenization
|
|
89
133
|
- `tokenizeShell(command)` — quote-aware split on `;`, `|`, `&&`, `||` plus trivial obfuscation neutralization (`c""url` → `curl`, `c\\url` → `curl`)
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Exception baselines — the "we know about this, suppress it for now" mechanism
|
|
3
|
+
* that PolicyMesh (`.policymesh-exceptions.json`) and TaskBound (`.taskbound.yml`
|
|
4
|
+
* `ignore_kinds` / `allow_paths`) both invented separately. Lifted into the
|
|
5
|
+
* substrate so all five tools share one shape and one expiry contract.
|
|
6
|
+
*
|
|
7
|
+
* Two design choices worth flagging:
|
|
8
|
+
*
|
|
9
|
+
* 1. Expired exceptions DON'T silently drop. They re-surface the original
|
|
10
|
+
* finding with severity downgraded to `'low'` and an `[EXPIRED WHITELIST]`
|
|
11
|
+
* prefix on the message. The point of exception baselines is to make stale
|
|
12
|
+
* suppression visible, not to grow a graveyard of permanent ignores.
|
|
13
|
+
*
|
|
14
|
+
* 2. Match keys are `kind` (required) plus optional `salientKey` and
|
|
15
|
+
* `pathPrefix` narrowing. Subject/path matching from the two consumers
|
|
16
|
+
* maps cleanly: PolicyMesh's `subject` is now `salientKey`; TaskBound's
|
|
17
|
+
* `allow_paths` entries map to `pathPrefix` exceptions on the relevant
|
|
18
|
+
* finding kind.
|
|
19
|
+
*/
|
|
20
|
+
import type { Finding } from './finding.js';
|
|
21
|
+
/**
|
|
22
|
+
* A single exception rule. Suppresses (or downgrades, when expired) findings
|
|
23
|
+
* whose `kind` matches and — if either narrower is set — whose `salientKey`
|
|
24
|
+
* or `location.file` prefix also matches.
|
|
25
|
+
*/
|
|
26
|
+
export interface Exception {
|
|
27
|
+
/** Required: exact match against `Finding.kind`. */
|
|
28
|
+
kind: string;
|
|
29
|
+
/**
|
|
30
|
+
* Optional: exact match against `Finding.salientKey`. Use this to scope an
|
|
31
|
+
* exception to one specific finding instance at a site that produces
|
|
32
|
+
* multiple distinct findings (e.g. one of several suspicious packages on
|
|
33
|
+
* the same import line).
|
|
34
|
+
*/
|
|
35
|
+
salientKey?: string;
|
|
36
|
+
/**
|
|
37
|
+
* Optional: only match findings whose `location.file` starts with this
|
|
38
|
+
* string. Use to scope an exception to a directory subtree without listing
|
|
39
|
+
* every file individually (TaskBound's `allow_paths` use case).
|
|
40
|
+
*/
|
|
41
|
+
pathPrefix?: string;
|
|
42
|
+
/**
|
|
43
|
+
* Optional ISO 8601 date (YYYY-MM-DD or full timestamp). When the current
|
|
44
|
+
* date is past `expires`, matched findings re-surface with severity
|
|
45
|
+
* downgraded to `'low'` and an `[EXPIRED WHITELIST]` message prefix.
|
|
46
|
+
*/
|
|
47
|
+
expires?: string;
|
|
48
|
+
/** Optional free-text rationale, preserved on expired findings via `data.exceptionReason`. */
|
|
49
|
+
reason?: string;
|
|
50
|
+
}
|
|
51
|
+
export interface ApplyExceptionsResult {
|
|
52
|
+
/** Findings after exceptions applied: survivors + downgraded expired entries. */
|
|
53
|
+
findings: Finding[];
|
|
54
|
+
/** Count of findings suppressed by an active (non-expired) exception. */
|
|
55
|
+
suppressed: number;
|
|
56
|
+
/** Count of findings surfaced as expired (downgraded + prefixed). */
|
|
57
|
+
expired: number;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Apply a set of exceptions to a finding list. Returns the post-filter
|
|
61
|
+
* list along with counts so a meta-reviewer can report how many findings
|
|
62
|
+
* the baseline suppressed.
|
|
63
|
+
*
|
|
64
|
+
* @example
|
|
65
|
+
* import { applyExceptions } from 'agent-gov-core';
|
|
66
|
+
*
|
|
67
|
+
* const result = applyExceptions(findings, [
|
|
68
|
+
* { kind: 'capability_echo.high_capability_dep_added', salientKey: 'puppeteer', expires: '2026-06-01', reason: 'browser-tests rollout' },
|
|
69
|
+
* { kind: 'task_bound.out_of_scope_file', pathPrefix: 'tools/internal/', reason: 'internal tooling refactor' },
|
|
70
|
+
* ]);
|
|
71
|
+
* console.log(`${result.suppressed} suppressed, ${result.expired} expired`);
|
|
72
|
+
*/
|
|
73
|
+
export declare function applyExceptions(findings: readonly Finding[], exceptions: readonly Exception[], now?: Date): ApplyExceptionsResult;
|
|
74
|
+
/**
|
|
75
|
+
* Validate that an unknown value is a well-formed `Exception` shape. Useful
|
|
76
|
+
* when consumers load exceptions from JSON/YAML and want to surface parse-
|
|
77
|
+
* level errors as findings rather than crash.
|
|
78
|
+
*/
|
|
79
|
+
export declare function validateException(value: unknown): {
|
|
80
|
+
ok: boolean;
|
|
81
|
+
errors: string[];
|
|
82
|
+
};
|
|
83
|
+
//# sourceMappingURL=exceptions.d.ts.map
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Exception baselines — the "we know about this, suppress it for now" mechanism
|
|
3
|
+
* that PolicyMesh (`.policymesh-exceptions.json`) and TaskBound (`.taskbound.yml`
|
|
4
|
+
* `ignore_kinds` / `allow_paths`) both invented separately. Lifted into the
|
|
5
|
+
* substrate so all five tools share one shape and one expiry contract.
|
|
6
|
+
*
|
|
7
|
+
* Two design choices worth flagging:
|
|
8
|
+
*
|
|
9
|
+
* 1. Expired exceptions DON'T silently drop. They re-surface the original
|
|
10
|
+
* finding with severity downgraded to `'low'` and an `[EXPIRED WHITELIST]`
|
|
11
|
+
* prefix on the message. The point of exception baselines is to make stale
|
|
12
|
+
* suppression visible, not to grow a graveyard of permanent ignores.
|
|
13
|
+
*
|
|
14
|
+
* 2. Match keys are `kind` (required) plus optional `salientKey` and
|
|
15
|
+
* `pathPrefix` narrowing. Subject/path matching from the two consumers
|
|
16
|
+
* maps cleanly: PolicyMesh's `subject` is now `salientKey`; TaskBound's
|
|
17
|
+
* `allow_paths` entries map to `pathPrefix` exceptions on the relevant
|
|
18
|
+
* finding kind.
|
|
19
|
+
*/
|
|
20
|
+
const EXPIRED_PREFIX = '[EXPIRED WHITELIST] ';
|
|
21
|
+
const EXPIRED_DOWNGRADE = 'low';
|
|
22
|
+
/**
|
|
23
|
+
* Apply a set of exceptions to a finding list. Returns the post-filter
|
|
24
|
+
* list along with counts so a meta-reviewer can report how many findings
|
|
25
|
+
* the baseline suppressed.
|
|
26
|
+
*
|
|
27
|
+
* @example
|
|
28
|
+
* import { applyExceptions } from 'agent-gov-core';
|
|
29
|
+
*
|
|
30
|
+
* const result = applyExceptions(findings, [
|
|
31
|
+
* { kind: 'capability_echo.high_capability_dep_added', salientKey: 'puppeteer', expires: '2026-06-01', reason: 'browser-tests rollout' },
|
|
32
|
+
* { kind: 'task_bound.out_of_scope_file', pathPrefix: 'tools/internal/', reason: 'internal tooling refactor' },
|
|
33
|
+
* ]);
|
|
34
|
+
* console.log(`${result.suppressed} suppressed, ${result.expired} expired`);
|
|
35
|
+
*/
|
|
36
|
+
export function applyExceptions(findings, exceptions, now = new Date()) {
|
|
37
|
+
if (exceptions.length === 0) {
|
|
38
|
+
return { findings: [...findings], suppressed: 0, expired: 0 };
|
|
39
|
+
}
|
|
40
|
+
const result = [];
|
|
41
|
+
let suppressed = 0;
|
|
42
|
+
let expired = 0;
|
|
43
|
+
for (const finding of findings) {
|
|
44
|
+
const match = findMatchingException(finding, exceptions);
|
|
45
|
+
if (!match) {
|
|
46
|
+
result.push(finding);
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
if (match.expires && isExpired(match.expires, now)) {
|
|
50
|
+
result.push(downgradeExpired(finding, match));
|
|
51
|
+
expired++;
|
|
52
|
+
}
|
|
53
|
+
else {
|
|
54
|
+
suppressed++;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return { findings: result, suppressed, expired };
|
|
58
|
+
}
|
|
59
|
+
function findMatchingException(finding, exceptions) {
|
|
60
|
+
for (const exc of exceptions) {
|
|
61
|
+
if (exc.kind !== finding.kind)
|
|
62
|
+
continue;
|
|
63
|
+
if (exc.salientKey !== undefined && exc.salientKey !== finding.salientKey)
|
|
64
|
+
continue;
|
|
65
|
+
if (exc.pathPrefix !== undefined) {
|
|
66
|
+
const file = finding.location?.file;
|
|
67
|
+
if (!file || !file.startsWith(exc.pathPrefix))
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
return exc;
|
|
71
|
+
}
|
|
72
|
+
return undefined;
|
|
73
|
+
}
|
|
74
|
+
function isExpired(expires, now) {
|
|
75
|
+
const parsed = new Date(expires);
|
|
76
|
+
if (Number.isNaN(parsed.getTime()))
|
|
77
|
+
return false;
|
|
78
|
+
return parsed.getTime() < now.getTime();
|
|
79
|
+
}
|
|
80
|
+
function downgradeExpired(finding, exc) {
|
|
81
|
+
const downgraded = {
|
|
82
|
+
...finding,
|
|
83
|
+
severity: EXPIRED_DOWNGRADE,
|
|
84
|
+
message: EXPIRED_PREFIX + finding.message,
|
|
85
|
+
};
|
|
86
|
+
if (exc.reason !== undefined) {
|
|
87
|
+
downgraded.data = { ...(finding.data ?? {}), exceptionReason: exc.reason };
|
|
88
|
+
}
|
|
89
|
+
return downgraded;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Validate that an unknown value is a well-formed `Exception` shape. Useful
|
|
93
|
+
* when consumers load exceptions from JSON/YAML and want to surface parse-
|
|
94
|
+
* level errors as findings rather than crash.
|
|
95
|
+
*/
|
|
96
|
+
export function validateException(value) {
|
|
97
|
+
const errors = [];
|
|
98
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
|
99
|
+
return { ok: false, errors: ['exception must be a plain object'] };
|
|
100
|
+
}
|
|
101
|
+
const v = value;
|
|
102
|
+
if (typeof v.kind !== 'string' || v.kind.length === 0) {
|
|
103
|
+
errors.push('kind must be a non-empty string');
|
|
104
|
+
}
|
|
105
|
+
if (v.salientKey !== undefined && typeof v.salientKey !== 'string') {
|
|
106
|
+
errors.push('salientKey must be a string when present');
|
|
107
|
+
}
|
|
108
|
+
if (v.pathPrefix !== undefined && typeof v.pathPrefix !== 'string') {
|
|
109
|
+
errors.push('pathPrefix must be a string when present');
|
|
110
|
+
}
|
|
111
|
+
if (v.expires !== undefined) {
|
|
112
|
+
if (typeof v.expires !== 'string') {
|
|
113
|
+
errors.push('expires must be an ISO 8601 string when present');
|
|
114
|
+
}
|
|
115
|
+
else if (Number.isNaN(new Date(v.expires).getTime())) {
|
|
116
|
+
errors.push('expires must be a parseable date (e.g. "2026-12-31" or full ISO timestamp)');
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
if (v.reason !== undefined && typeof v.reason !== 'string') {
|
|
120
|
+
errors.push('reason must be a string when present');
|
|
121
|
+
}
|
|
122
|
+
const allowed = new Set(['kind', 'salientKey', 'pathPrefix', 'expires', 'reason']);
|
|
123
|
+
for (const key of Object.keys(v)) {
|
|
124
|
+
if (!allowed.has(key))
|
|
125
|
+
errors.push(`unknown property: ${key}`);
|
|
126
|
+
}
|
|
127
|
+
return { ok: errors.length === 0, errors };
|
|
128
|
+
}
|
|
129
|
+
//# sourceMappingURL=exceptions.js.map
|
package/dist/index.d.ts
CHANGED
|
@@ -5,6 +5,14 @@ export { readJsonObjectWithSource, stripJsonComments } from './jsonc.js';
|
|
|
5
5
|
export type { TomlObjectWithSource } from './toml.js';
|
|
6
6
|
export { readTomlObject, parseToml } from './toml.js';
|
|
7
7
|
export { ConfigParseError, lineColumnOfOffset } from './parse-error.js';
|
|
8
|
+
export type { Report, CreateReportSpec, ReportValidationResult } from './report.js';
|
|
9
|
+
export { REPORT_SCHEMA_VERSION, createReport, maxSeverity, validateReport, } from './report.js';
|
|
10
|
+
export type { MergeOptions, MergeSource, InvalidReport, InvalidFinding, MergedReport, } from './merge.js';
|
|
11
|
+
export { mergeFindings } from './merge.js';
|
|
12
|
+
export type { SecretMatch, MatchSecretOptions } from './secrets.js';
|
|
13
|
+
export { matchSecret, SECRET_PATTERNS } from './secrets.js';
|
|
14
|
+
export type { Exception, ApplyExceptionsResult } from './exceptions.js';
|
|
15
|
+
export { applyExceptions, validateException } from './exceptions.js';
|
|
8
16
|
export type { ByteRange } from './locators.js';
|
|
9
17
|
export { lineOfJsonKey, lineOfJsonStringValue, lineOfTomlKey, } from './locators.js';
|
|
10
18
|
export type { McpCommandSpec } from './mcp.js';
|
package/dist/index.js
CHANGED
|
@@ -2,6 +2,10 @@ export { SEVERITIES, TOOL_KINDS, isSeverity, isToolKind, isNamespacedKind, kind,
|
|
|
2
2
|
export { readJsonObjectWithSource, stripJsonComments } from './jsonc.js';
|
|
3
3
|
export { readTomlObject, parseToml } from './toml.js';
|
|
4
4
|
export { ConfigParseError, lineColumnOfOffset } from './parse-error.js';
|
|
5
|
+
export { REPORT_SCHEMA_VERSION, createReport, maxSeverity, validateReport, } from './report.js';
|
|
6
|
+
export { mergeFindings } from './merge.js';
|
|
7
|
+
export { matchSecret, SECRET_PATTERNS } from './secrets.js';
|
|
8
|
+
export { applyExceptions, validateException } from './exceptions.js';
|
|
5
9
|
export { lineOfJsonKey, lineOfJsonStringValue, lineOfTomlKey, } from './locators.js';
|
|
6
10
|
export { normalizeMcpCommand } from './mcp.js';
|
|
7
11
|
export { tokenizeShell, tokenizeShellDeep, getCommandHead } from './shell.js';
|
package/dist/merge.d.ts
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { type Finding, type Severity, type ToolKind } from './finding.js';
|
|
2
|
+
export interface MergeOptions {
|
|
3
|
+
/**
|
|
4
|
+
* Lower bound for findings included in the merged output. Anything below
|
|
5
|
+
* this severity is dropped from `findings` (but still counted in
|
|
6
|
+
* `droppedBelowThreshold`). Defaults to `'low'` (include everything).
|
|
7
|
+
*/
|
|
8
|
+
threshold?: Severity;
|
|
9
|
+
/**
|
|
10
|
+
* When two reports contribute findings with the same fingerprint, the
|
|
11
|
+
* default keeps the one with the higher severity. Set this to `'first'`
|
|
12
|
+
* to keep the first report's finding instead. Default: `'highest_severity'`.
|
|
13
|
+
*/
|
|
14
|
+
duplicatePolicy?: 'highest_severity' | 'first';
|
|
15
|
+
}
|
|
16
|
+
export interface MergeSource {
|
|
17
|
+
tool: ToolKind;
|
|
18
|
+
toolVersion?: string;
|
|
19
|
+
/** Conversation ID declared by this source, if any. */
|
|
20
|
+
conversationId?: string;
|
|
21
|
+
/** Number of findings in this source report (BEFORE dedup or threshold filtering). */
|
|
22
|
+
findingCount: number;
|
|
23
|
+
/** Aggregate rating reported by the source. */
|
|
24
|
+
rating: 'none' | Severity;
|
|
25
|
+
}
|
|
26
|
+
export interface InvalidReport {
|
|
27
|
+
/** Index into the input `reports` array. */
|
|
28
|
+
index: number;
|
|
29
|
+
/** Tool name from the malformed report, if recoverable. */
|
|
30
|
+
tool?: ToolKind;
|
|
31
|
+
errors: string[];
|
|
32
|
+
}
|
|
33
|
+
export interface InvalidFinding {
|
|
34
|
+
/** Originating tool's report index. */
|
|
35
|
+
reportIndex: number;
|
|
36
|
+
/** Index of the finding within that report's `findings` array. */
|
|
37
|
+
findingIndex: number;
|
|
38
|
+
/** Tool name from the report. */
|
|
39
|
+
tool: ToolKind;
|
|
40
|
+
errors: string[];
|
|
41
|
+
}
|
|
42
|
+
export interface MergedReport {
|
|
43
|
+
schemaVersion: '1.0';
|
|
44
|
+
/** Per-tool provenance for the reports that fed into this merge. */
|
|
45
|
+
sources: MergeSource[];
|
|
46
|
+
/** Aggregate rating across all surviving findings. */
|
|
47
|
+
rating: 'none' | Severity;
|
|
48
|
+
/**
|
|
49
|
+
* Conversation ID shared by all valid source reports — set iff every source
|
|
50
|
+
* declared the same `conversationId`. When sources disagree (or some lack the
|
|
51
|
+
* field), this is omitted so a meta-reviewer can detect cross-conversation
|
|
52
|
+
* mixing.
|
|
53
|
+
*/
|
|
54
|
+
conversationId?: string;
|
|
55
|
+
/** Deduped findings, sorted by severity (highest first). */
|
|
56
|
+
findings: Finding[];
|
|
57
|
+
/** Count of findings dropped because their severity was below `threshold`. */
|
|
58
|
+
droppedBelowThreshold: number;
|
|
59
|
+
/** Count of finding pairs collapsed via fingerprint dedup. */
|
|
60
|
+
duplicateCollapsed: number;
|
|
61
|
+
/** Reports rejected by envelope validation. */
|
|
62
|
+
invalidReports: InvalidReport[];
|
|
63
|
+
/** Individual findings rejected by finding validation. */
|
|
64
|
+
invalidFindings: InvalidFinding[];
|
|
65
|
+
/** Severity counts across the surviving findings. */
|
|
66
|
+
severityCounts: Record<Severity, number>;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Merge N reports from different tools into one normalized report. Validates
|
|
70
|
+
* each input report and each finding, deduplicates by fingerprint, applies an
|
|
71
|
+
* optional severity threshold, and rolls up the aggregate rating.
|
|
72
|
+
*
|
|
73
|
+
* Invalid reports / findings are NOT silently dropped — they're collected in
|
|
74
|
+
* `invalidReports` and `invalidFindings` so a meta-reviewer can surface them
|
|
75
|
+
* to the user instead of letting bad data disappear.
|
|
76
|
+
*
|
|
77
|
+
* @example
|
|
78
|
+
* import { readFileSync } from 'node:fs';
|
|
79
|
+
* import { mergeFindings } from 'agent-gov-core';
|
|
80
|
+
*
|
|
81
|
+
* const reports = [
|
|
82
|
+
* JSON.parse(readFileSync('scopetrail-report.json', 'utf8')),
|
|
83
|
+
* JSON.parse(readFileSync('policymesh-report.json', 'utf8')),
|
|
84
|
+
* JSON.parse(readFileSync('capabilityecho-report.json', 'utf8')),
|
|
85
|
+
* ];
|
|
86
|
+
* const merged = mergeFindings(reports, { threshold: 'medium' });
|
|
87
|
+
* console.log(`Merged rating: ${merged.rating}`);
|
|
88
|
+
* console.log(`${merged.findings.length} unique findings across ${merged.sources.length} tools`);
|
|
89
|
+
*/
|
|
90
|
+
export declare function mergeFindings(reports: readonly unknown[], opts?: MergeOptions): MergedReport;
|
|
91
|
+
//# sourceMappingURL=merge.d.ts.map
|
package/dist/merge.js
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { SEVERITIES, TOOL_KINDS, isToolKind, validateFinding } from './finding.js';
|
|
2
|
+
import { REPORT_SCHEMA_VERSION, maxSeverity } from './report.js';
|
|
3
|
+
import { rankSeverity } from './action.js';
|
|
4
|
+
/**
|
|
5
|
+
* Merge N reports from different tools into one normalized report. Validates
|
|
6
|
+
* each input report and each finding, deduplicates by fingerprint, applies an
|
|
7
|
+
* optional severity threshold, and rolls up the aggregate rating.
|
|
8
|
+
*
|
|
9
|
+
* Invalid reports / findings are NOT silently dropped — they're collected in
|
|
10
|
+
* `invalidReports` and `invalidFindings` so a meta-reviewer can surface them
|
|
11
|
+
* to the user instead of letting bad data disappear.
|
|
12
|
+
*
|
|
13
|
+
* @example
|
|
14
|
+
* import { readFileSync } from 'node:fs';
|
|
15
|
+
* import { mergeFindings } from 'agent-gov-core';
|
|
16
|
+
*
|
|
17
|
+
* const reports = [
|
|
18
|
+
* JSON.parse(readFileSync('scopetrail-report.json', 'utf8')),
|
|
19
|
+
* JSON.parse(readFileSync('policymesh-report.json', 'utf8')),
|
|
20
|
+
* JSON.parse(readFileSync('capabilityecho-report.json', 'utf8')),
|
|
21
|
+
* ];
|
|
22
|
+
* const merged = mergeFindings(reports, { threshold: 'medium' });
|
|
23
|
+
* console.log(`Merged rating: ${merged.rating}`);
|
|
24
|
+
* console.log(`${merged.findings.length} unique findings across ${merged.sources.length} tools`);
|
|
25
|
+
*/
|
|
26
|
+
export function mergeFindings(reports, opts = {}) {
|
|
27
|
+
const threshold = opts.threshold ?? 'low';
|
|
28
|
+
const duplicatePolicy = opts.duplicatePolicy ?? 'highest_severity';
|
|
29
|
+
const thresholdRank = rankSeverity(threshold);
|
|
30
|
+
const sources = [];
|
|
31
|
+
const invalidReports = [];
|
|
32
|
+
const invalidFindings = [];
|
|
33
|
+
// fingerprint → Finding chosen so far
|
|
34
|
+
const dedupe = new Map();
|
|
35
|
+
let droppedBelowThreshold = 0;
|
|
36
|
+
let duplicateCollapsed = 0;
|
|
37
|
+
for (let i = 0; i < reports.length; i++) {
|
|
38
|
+
const candidate = reports[i];
|
|
39
|
+
// Structural envelope check — does NOT recurse into individual findings.
|
|
40
|
+
// A report with some malformed findings is still partially mergeable; we
|
|
41
|
+
// collect the bad ones into `invalidFindings` and pass through the good
|
|
42
|
+
// ones. Only a structurally broken envelope (wrong tool, missing array,
|
|
43
|
+
// etc.) gets rejected wholesale.
|
|
44
|
+
const envelope = validateReportEnvelope(candidate);
|
|
45
|
+
if (!envelope.ok) {
|
|
46
|
+
const tool = candidateTool(candidate);
|
|
47
|
+
invalidReports.push({ index: i, tool, errors: envelope.errors });
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
const report = candidate;
|
|
51
|
+
const source = {
|
|
52
|
+
tool: report.tool,
|
|
53
|
+
findingCount: report.findings.length,
|
|
54
|
+
rating: report.rating,
|
|
55
|
+
};
|
|
56
|
+
if (report.toolVersion !== undefined)
|
|
57
|
+
source.toolVersion = report.toolVersion;
|
|
58
|
+
if (report.conversationId !== undefined)
|
|
59
|
+
source.conversationId = report.conversationId;
|
|
60
|
+
sources.push(source);
|
|
61
|
+
for (let j = 0; j < report.findings.length; j++) {
|
|
62
|
+
const finding = report.findings[j];
|
|
63
|
+
const findingCheck = validateFinding(finding);
|
|
64
|
+
if (!findingCheck.ok) {
|
|
65
|
+
invalidFindings.push({
|
|
66
|
+
reportIndex: i,
|
|
67
|
+
findingIndex: j,
|
|
68
|
+
tool: report.tool,
|
|
69
|
+
errors: findingCheck.errors,
|
|
70
|
+
});
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
if (rankSeverity(finding.severity) < thresholdRank) {
|
|
74
|
+
droppedBelowThreshold++;
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
// Dedupe by fingerprint. Fall back to the finding's structural identity
|
|
78
|
+
// when fingerprint is missing — though by v0.5.0 it should always be
|
|
79
|
+
// populated by `createFinding`.
|
|
80
|
+
const key = finding.fingerprint ?? `${finding.kind}|${finding.location?.file ?? ''}|${finding.location?.line ?? ''}|${finding.salientKey ?? ''}`;
|
|
81
|
+
const existing = dedupe.get(key);
|
|
82
|
+
if (existing === undefined) {
|
|
83
|
+
dedupe.set(key, finding);
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
duplicateCollapsed++;
|
|
87
|
+
if (duplicatePolicy === 'highest_severity') {
|
|
88
|
+
if (rankSeverity(finding.severity) > rankSeverity(existing.severity)) {
|
|
89
|
+
dedupe.set(key, finding);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
// 'first' policy: keep existing — do nothing
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
const findings = Array.from(dedupe.values()).sort((a, b) => rankSeverity(b.severity) - rankSeverity(a.severity));
|
|
96
|
+
const severityCounts = { low: 0, medium: 0, high: 0, critical: 0 };
|
|
97
|
+
for (const f of findings)
|
|
98
|
+
severityCounts[f.severity]++;
|
|
99
|
+
// Propagate conversationId iff every source agrees. When sources disagree
|
|
100
|
+
// or some lack the field, leave it undefined — silent unification of cross-
|
|
101
|
+
// conversation reports would hide a meta-reviewer misuse.
|
|
102
|
+
const conversationIds = sources.map((s) => s.conversationId);
|
|
103
|
+
const allSame = conversationIds.length > 0
|
|
104
|
+
&& conversationIds.every((id) => id !== undefined && id === conversationIds[0]);
|
|
105
|
+
const merged = {
|
|
106
|
+
schemaVersion: '1.0',
|
|
107
|
+
sources,
|
|
108
|
+
rating: maxSeverity(findings),
|
|
109
|
+
findings,
|
|
110
|
+
droppedBelowThreshold,
|
|
111
|
+
duplicateCollapsed,
|
|
112
|
+
invalidReports,
|
|
113
|
+
invalidFindings,
|
|
114
|
+
severityCounts,
|
|
115
|
+
};
|
|
116
|
+
if (allSame)
|
|
117
|
+
merged.conversationId = conversationIds[0];
|
|
118
|
+
return merged;
|
|
119
|
+
}
|
|
120
|
+
function candidateTool(value) {
|
|
121
|
+
if (value === null || typeof value !== 'object')
|
|
122
|
+
return undefined;
|
|
123
|
+
const t = value.tool;
|
|
124
|
+
return typeof t === 'string' && /^(scope_trail|policy_mesh|capability_echo|task_bound|session_trail)$/.test(t)
|
|
125
|
+
? t
|
|
126
|
+
: undefined;
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Envelope-only structural check. Unlike `validateReport`, this does NOT
|
|
130
|
+
* recurse into individual findings — that's done separately by mergeFindings
|
|
131
|
+
* so a single bad finding doesn't poison the rest of the report.
|
|
132
|
+
*/
|
|
133
|
+
function validateReportEnvelope(value) {
|
|
134
|
+
const errors = [];
|
|
135
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
|
136
|
+
return { ok: false, errors: ['report must be a plain object'] };
|
|
137
|
+
}
|
|
138
|
+
const v = value;
|
|
139
|
+
if (v.schemaVersion !== REPORT_SCHEMA_VERSION) {
|
|
140
|
+
errors.push(`schemaVersion must be '${REPORT_SCHEMA_VERSION}'`);
|
|
141
|
+
}
|
|
142
|
+
if (!isToolKind(v.tool)) {
|
|
143
|
+
errors.push(`tool must be one of: ${TOOL_KINDS.join(', ')}`);
|
|
144
|
+
}
|
|
145
|
+
const ratingValues = new Set(['none', ...SEVERITIES]);
|
|
146
|
+
if (typeof v.rating !== 'string' || !ratingValues.has(v.rating)) {
|
|
147
|
+
errors.push(`rating must be one of: none, ${SEVERITIES.join(', ')}`);
|
|
148
|
+
}
|
|
149
|
+
if (!Array.isArray(v.findings)) {
|
|
150
|
+
errors.push('findings must be an array');
|
|
151
|
+
}
|
|
152
|
+
return { ok: errors.length === 0, errors };
|
|
153
|
+
}
|
|
154
|
+
//# sourceMappingURL=merge.js.map
|
package/dist/report.d.ts
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { type Finding, type Severity, type ToolKind } from './finding.js';
|
|
2
|
+
/** Canonical envelope version. */
|
|
3
|
+
export declare const REPORT_SCHEMA_VERSION: "1.0";
|
|
4
|
+
/**
|
|
5
|
+
* Canonical multi-tool report envelope. Wraps `Finding[]` with provenance,
|
|
6
|
+
* rating, and optional tool-specific extension data so a cross-tool
|
|
7
|
+
* meta-reviewer can ingest reports from N tools through one shape.
|
|
8
|
+
*/
|
|
9
|
+
export interface Report {
|
|
10
|
+
schemaVersion: typeof REPORT_SCHEMA_VERSION;
|
|
11
|
+
tool: ToolKind;
|
|
12
|
+
toolVersion?: string;
|
|
13
|
+
runId?: string;
|
|
14
|
+
/**
|
|
15
|
+
* Identifier for the agent session, PR review, or thread this run belongs to.
|
|
16
|
+
* Distinct from `runId` (which identifies *this* tool run): one conversation
|
|
17
|
+
* can produce many runs. Matches OpenTelemetry's `gen_ai.conversation.id`
|
|
18
|
+
* semantic convention — if a consumer also emits OTel traces about the same
|
|
19
|
+
* agent session, pass the same string here and downstream tooling can cross-
|
|
20
|
+
* reference governance findings with the traces.
|
|
21
|
+
*
|
|
22
|
+
* @see https://opentelemetry.io/docs/specs/semconv/gen-ai/
|
|
23
|
+
*/
|
|
24
|
+
conversationId?: string;
|
|
25
|
+
baseRef?: string;
|
|
26
|
+
headRef?: string;
|
|
27
|
+
/** Aggregate severity. `'none'` iff findings is empty or all below threshold. */
|
|
28
|
+
rating: 'none' | Severity;
|
|
29
|
+
findings: Finding[];
|
|
30
|
+
/** Tool-specific extension data (PolicyMesh `effectiveUnion`, CapabilityEcho `surfaceSummary`, etc). */
|
|
31
|
+
data?: Record<string, unknown>;
|
|
32
|
+
}
|
|
33
|
+
export interface CreateReportSpec {
|
|
34
|
+
tool: ToolKind;
|
|
35
|
+
toolVersion?: string;
|
|
36
|
+
runId?: string;
|
|
37
|
+
/** See {@link Report.conversationId}. */
|
|
38
|
+
conversationId?: string;
|
|
39
|
+
baseRef?: string;
|
|
40
|
+
headRef?: string;
|
|
41
|
+
findings: Finding[];
|
|
42
|
+
data?: Record<string, unknown>;
|
|
43
|
+
/**
|
|
44
|
+
* Explicit rating override. When omitted, `rating` is computed as the
|
|
45
|
+
* maximum severity across `findings` (or `'none'` if empty).
|
|
46
|
+
*/
|
|
47
|
+
rating?: 'none' | Severity;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Build a {@link Report} with `schemaVersion` set and `rating` derived from
|
|
51
|
+
* the maximum finding severity (unless overridden). This is the recommended
|
|
52
|
+
* way to produce a report — sets the envelope version correctly and computes
|
|
53
|
+
* the rating consistently with other tools.
|
|
54
|
+
*
|
|
55
|
+
* @example
|
|
56
|
+
* const report = createReport({
|
|
57
|
+
* tool: 'scope_trail',
|
|
58
|
+
* toolVersion: '0.1.18',
|
|
59
|
+
* baseRef: 'abc123',
|
|
60
|
+
* headRef: 'def456',
|
|
61
|
+
* findings: [finding1, finding2],
|
|
62
|
+
* data: { mcpServers: [...] },
|
|
63
|
+
* });
|
|
64
|
+
*/
|
|
65
|
+
export declare function createReport(spec: CreateReportSpec): Report;
|
|
66
|
+
/**
|
|
67
|
+
* Maximum severity across a finding list. Returns `'none'` for empty input.
|
|
68
|
+
* Used by {@link createReport} when no explicit rating is supplied.
|
|
69
|
+
*/
|
|
70
|
+
export declare function maxSeverity(findings: readonly Finding[]): 'none' | Severity;
|
|
71
|
+
export interface ReportValidationResult {
|
|
72
|
+
ok: boolean;
|
|
73
|
+
errors: string[];
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Runtime check that a value conforms to the canonical Report envelope.
|
|
77
|
+
* Aggregates errors across all findings — a single malformed finding does
|
|
78
|
+
* not short-circuit the rest of the envelope check.
|
|
79
|
+
*
|
|
80
|
+
* @example
|
|
81
|
+
* const result = validateReport(JSON.parse(reportJson));
|
|
82
|
+
* if (!result.ok) console.error(result.errors.join('\n'));
|
|
83
|
+
*/
|
|
84
|
+
export declare function validateReport(value: unknown): ReportValidationResult;
|
|
85
|
+
//# sourceMappingURL=report.d.ts.map
|
package/dist/report.js
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { SEVERITIES, TOOL_KINDS, isSeverity, isToolKind, validateFinding, } from './finding.js';
|
|
2
|
+
/** Canonical envelope version. */
|
|
3
|
+
export const REPORT_SCHEMA_VERSION = '1.0';
|
|
4
|
+
/**
|
|
5
|
+
* Build a {@link Report} with `schemaVersion` set and `rating` derived from
|
|
6
|
+
* the maximum finding severity (unless overridden). This is the recommended
|
|
7
|
+
* way to produce a report — sets the envelope version correctly and computes
|
|
8
|
+
* the rating consistently with other tools.
|
|
9
|
+
*
|
|
10
|
+
* @example
|
|
11
|
+
* const report = createReport({
|
|
12
|
+
* tool: 'scope_trail',
|
|
13
|
+
* toolVersion: '0.1.18',
|
|
14
|
+
* baseRef: 'abc123',
|
|
15
|
+
* headRef: 'def456',
|
|
16
|
+
* findings: [finding1, finding2],
|
|
17
|
+
* data: { mcpServers: [...] },
|
|
18
|
+
* });
|
|
19
|
+
*/
|
|
20
|
+
export function createReport(spec) {
|
|
21
|
+
const report = {
|
|
22
|
+
schemaVersion: REPORT_SCHEMA_VERSION,
|
|
23
|
+
tool: spec.tool,
|
|
24
|
+
rating: spec.rating ?? maxSeverity(spec.findings),
|
|
25
|
+
findings: spec.findings,
|
|
26
|
+
};
|
|
27
|
+
if (spec.toolVersion !== undefined)
|
|
28
|
+
report.toolVersion = spec.toolVersion;
|
|
29
|
+
if (spec.runId !== undefined)
|
|
30
|
+
report.runId = spec.runId;
|
|
31
|
+
if (spec.conversationId !== undefined)
|
|
32
|
+
report.conversationId = spec.conversationId;
|
|
33
|
+
if (spec.baseRef !== undefined)
|
|
34
|
+
report.baseRef = spec.baseRef;
|
|
35
|
+
if (spec.headRef !== undefined)
|
|
36
|
+
report.headRef = spec.headRef;
|
|
37
|
+
if (spec.data !== undefined)
|
|
38
|
+
report.data = spec.data;
|
|
39
|
+
return report;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Maximum severity across a finding list. Returns `'none'` for empty input.
|
|
43
|
+
* Used by {@link createReport} when no explicit rating is supplied.
|
|
44
|
+
*/
|
|
45
|
+
export function maxSeverity(findings) {
|
|
46
|
+
let best = 'none';
|
|
47
|
+
for (const f of findings) {
|
|
48
|
+
if (severityRank(f.severity) > severityRank(best))
|
|
49
|
+
best = f.severity;
|
|
50
|
+
}
|
|
51
|
+
return best;
|
|
52
|
+
}
|
|
53
|
+
function severityRank(s) {
|
|
54
|
+
if (s === 'none')
|
|
55
|
+
return 0;
|
|
56
|
+
if (s === 'low')
|
|
57
|
+
return 1;
|
|
58
|
+
if (s === 'medium')
|
|
59
|
+
return 2;
|
|
60
|
+
if (s === 'high')
|
|
61
|
+
return 3;
|
|
62
|
+
return 4;
|
|
63
|
+
}
|
|
64
|
+
const REPORT_ALLOWED_KEYS = new Set([
|
|
65
|
+
'schemaVersion',
|
|
66
|
+
'tool',
|
|
67
|
+
'toolVersion',
|
|
68
|
+
'runId',
|
|
69
|
+
'conversationId',
|
|
70
|
+
'baseRef',
|
|
71
|
+
'headRef',
|
|
72
|
+
'rating',
|
|
73
|
+
'findings',
|
|
74
|
+
'data',
|
|
75
|
+
]);
|
|
76
|
+
const RATING_VALUES = new Set(['none', ...SEVERITIES]);
|
|
77
|
+
/**
|
|
78
|
+
* Runtime check that a value conforms to the canonical Report envelope.
|
|
79
|
+
* Aggregates errors across all findings — a single malformed finding does
|
|
80
|
+
* not short-circuit the rest of the envelope check.
|
|
81
|
+
*
|
|
82
|
+
* @example
|
|
83
|
+
* const result = validateReport(JSON.parse(reportJson));
|
|
84
|
+
* if (!result.ok) console.error(result.errors.join('\n'));
|
|
85
|
+
*/
|
|
86
|
+
export function validateReport(value) {
|
|
87
|
+
const errors = [];
|
|
88
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
|
89
|
+
return { ok: false, errors: ['report must be a plain object'] };
|
|
90
|
+
}
|
|
91
|
+
const v = value;
|
|
92
|
+
if (v.schemaVersion !== REPORT_SCHEMA_VERSION) {
|
|
93
|
+
errors.push(`schemaVersion must be '${REPORT_SCHEMA_VERSION}'`);
|
|
94
|
+
}
|
|
95
|
+
if (!isToolKind(v.tool)) {
|
|
96
|
+
errors.push(`tool must be one of: ${TOOL_KINDS.join(', ')}`);
|
|
97
|
+
}
|
|
98
|
+
if (typeof v.rating !== 'string' || !RATING_VALUES.has(v.rating)) {
|
|
99
|
+
errors.push(`rating must be one of: none, ${SEVERITIES.join(', ')}`);
|
|
100
|
+
}
|
|
101
|
+
if (!Array.isArray(v.findings)) {
|
|
102
|
+
errors.push('findings must be an array');
|
|
103
|
+
}
|
|
104
|
+
else {
|
|
105
|
+
for (let i = 0; i < v.findings.length; i++) {
|
|
106
|
+
const f = validateFinding(v.findings[i]);
|
|
107
|
+
if (!f.ok) {
|
|
108
|
+
errors.push(`findings[${i}]: ${f.errors.join('; ')}`);
|
|
109
|
+
}
|
|
110
|
+
else if (isToolKind(v.tool) && v.findings[i].tool !== v.tool) {
|
|
111
|
+
errors.push(`findings[${i}].tool ('${v.findings[i].tool}') does not match report.tool ('${v.tool}')`);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
if (v.toolVersion !== undefined && typeof v.toolVersion !== 'string') {
|
|
116
|
+
errors.push('toolVersion must be a string when present');
|
|
117
|
+
}
|
|
118
|
+
if (v.runId !== undefined && typeof v.runId !== 'string') {
|
|
119
|
+
errors.push('runId must be a string when present');
|
|
120
|
+
}
|
|
121
|
+
if (v.conversationId !== undefined && typeof v.conversationId !== 'string') {
|
|
122
|
+
errors.push('conversationId must be a string when present');
|
|
123
|
+
}
|
|
124
|
+
if (v.baseRef !== undefined && typeof v.baseRef !== 'string') {
|
|
125
|
+
errors.push('baseRef must be a string when present');
|
|
126
|
+
}
|
|
127
|
+
if (v.headRef !== undefined && typeof v.headRef !== 'string') {
|
|
128
|
+
errors.push('headRef must be a string when present');
|
|
129
|
+
}
|
|
130
|
+
if (v.data !== undefined && (v.data === null || typeof v.data !== 'object' || Array.isArray(v.data))) {
|
|
131
|
+
errors.push('data must be an object when present');
|
|
132
|
+
}
|
|
133
|
+
for (const key of Object.keys(v)) {
|
|
134
|
+
if (!REPORT_ALLOWED_KEYS.has(key))
|
|
135
|
+
errors.push(`unknown property: ${key}`);
|
|
136
|
+
}
|
|
137
|
+
// Cross-field consistency: rating should be at or above the max finding severity.
|
|
138
|
+
// We don't *enforce* this strictly (a tool may downgrade by policy) but flag a
|
|
139
|
+
// genuine inconsistency where the rating is BELOW what the findings imply.
|
|
140
|
+
if (Array.isArray(v.findings) &&
|
|
141
|
+
typeof v.rating === 'string' &&
|
|
142
|
+
RATING_VALUES.has(v.rating)) {
|
|
143
|
+
const findingsOk = v.findings.every((f) => validateFinding(f).ok);
|
|
144
|
+
if (findingsOk) {
|
|
145
|
+
const implied = maxSeverity(v.findings);
|
|
146
|
+
if (severityRank(v.rating) < severityRank(implied)) {
|
|
147
|
+
errors.push(`rating '${v.rating}' is below the maximum finding severity '${implied}'`);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
// Ensure isSeverity-style check on rating when not 'none' for callers that
|
|
152
|
+
// need a tighter type than the wider RATING_VALUES set.
|
|
153
|
+
void isSeverity;
|
|
154
|
+
return { ok: errors.length === 0, errors };
|
|
155
|
+
}
|
|
156
|
+
//# sourceMappingURL=report.js.map
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hardcoded credential detection.
|
|
3
|
+
*
|
|
4
|
+
* Scans strings for provider-prefix tokens (Anthropic, OpenAI, GitHub, AWS,
|
|
5
|
+
* Slack, Google, GitLab, npm, Docker, Stripe) plus a length-restricted hex
|
|
6
|
+
* pattern that only fires in env/header context (a bare hex blob in a
|
|
7
|
+
* positional command argument is indistinguishable from a commit SHA).
|
|
8
|
+
*
|
|
9
|
+
* Contract: the literal credential is NEVER returned in any field. Callers
|
|
10
|
+
* receive only the provider name plus the pattern that matched (provider
|
|
11
|
+
* label only — not the regex). This is the same contract PolicyMesh shipped
|
|
12
|
+
* the detector under, lifted into the substrate so every governance tool
|
|
13
|
+
* uses one source of truth for "what does a hardcoded credential look like."
|
|
14
|
+
*
|
|
15
|
+
* @example
|
|
16
|
+
* import { matchSecret } from 'agent-gov-core';
|
|
17
|
+
*
|
|
18
|
+
* matchSecret('sk-ant-abcdefghijklmnopqrstuv');
|
|
19
|
+
* // → { provider: 'Anthropic' }
|
|
20
|
+
*
|
|
21
|
+
* matchSecret('env:OPENAI_API_KEY');
|
|
22
|
+
* // → undefined (env var reference, not a literal)
|
|
23
|
+
*
|
|
24
|
+
* matchSecret('a'.repeat(40), { envOrHeaderContext: true });
|
|
25
|
+
* // → undefined (only A-F0-9 are hex; not a hex token)
|
|
26
|
+
*/
|
|
27
|
+
export interface SecretMatch {
|
|
28
|
+
/** Human-readable provider name. The literal credential is NEVER included. */
|
|
29
|
+
provider: string;
|
|
30
|
+
}
|
|
31
|
+
export interface MatchSecretOptions {
|
|
32
|
+
/**
|
|
33
|
+
* When `true`, patterns flagged `envOrHeaderOnly` are eligible. Set this
|
|
34
|
+
* only when scanning env values or HTTP header values — never when scanning
|
|
35
|
+
* a joined launch command (positional args often contain commit SHAs that
|
|
36
|
+
* would false-positive against a bare hex token pattern).
|
|
37
|
+
*/
|
|
38
|
+
envOrHeaderContext?: boolean;
|
|
39
|
+
}
|
|
40
|
+
interface SecretPattern {
|
|
41
|
+
provider: string;
|
|
42
|
+
regex: RegExp;
|
|
43
|
+
/** See {@link MatchSecretOptions.envOrHeaderContext}. */
|
|
44
|
+
envOrHeaderOnly?: boolean;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Built-in provider patterns. Conservative — only shapes whose prefix
|
|
48
|
+
* unambiguously identifies a credential class. The bare hex pattern is gated
|
|
49
|
+
* to env/header context to avoid commit-SHA false positives.
|
|
50
|
+
*
|
|
51
|
+
* Stable as of v0.7.0 — additions are non-breaking, removals or shape changes
|
|
52
|
+
* require a major bump (the golden compatibility tests in `test/golden.test.mjs`
|
|
53
|
+
* pin the current provider set).
|
|
54
|
+
*/
|
|
55
|
+
export declare const SECRET_PATTERNS: readonly Readonly<SecretPattern>[];
|
|
56
|
+
/**
|
|
57
|
+
* Scan `value` for a hardcoded provider credential. Returns the matched
|
|
58
|
+
* provider name (never the literal credential) or `undefined` when nothing
|
|
59
|
+
* matches.
|
|
60
|
+
*
|
|
61
|
+
* Set `options.envOrHeaderContext` to `true` only when scanning env values
|
|
62
|
+
* or HTTP header values — that enables the more permissive hex-token pattern
|
|
63
|
+
* which would false-positive on positional command arguments.
|
|
64
|
+
*/
|
|
65
|
+
export declare function matchSecret(value: string, options?: MatchSecretOptions): SecretMatch | undefined;
|
|
66
|
+
export {};
|
|
67
|
+
//# sourceMappingURL=secrets.d.ts.map
|
package/dist/secrets.js
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hardcoded credential detection.
|
|
3
|
+
*
|
|
4
|
+
* Scans strings for provider-prefix tokens (Anthropic, OpenAI, GitHub, AWS,
|
|
5
|
+
* Slack, Google, GitLab, npm, Docker, Stripe) plus a length-restricted hex
|
|
6
|
+
* pattern that only fires in env/header context (a bare hex blob in a
|
|
7
|
+
* positional command argument is indistinguishable from a commit SHA).
|
|
8
|
+
*
|
|
9
|
+
* Contract: the literal credential is NEVER returned in any field. Callers
|
|
10
|
+
* receive only the provider name plus the pattern that matched (provider
|
|
11
|
+
* label only — not the regex). This is the same contract PolicyMesh shipped
|
|
12
|
+
* the detector under, lifted into the substrate so every governance tool
|
|
13
|
+
* uses one source of truth for "what does a hardcoded credential look like."
|
|
14
|
+
*
|
|
15
|
+
* @example
|
|
16
|
+
* import { matchSecret } from 'agent-gov-core';
|
|
17
|
+
*
|
|
18
|
+
* matchSecret('sk-ant-abcdefghijklmnopqrstuv');
|
|
19
|
+
* // → { provider: 'Anthropic' }
|
|
20
|
+
*
|
|
21
|
+
* matchSecret('env:OPENAI_API_KEY');
|
|
22
|
+
* // → undefined (env var reference, not a literal)
|
|
23
|
+
*
|
|
24
|
+
* matchSecret('a'.repeat(40), { envOrHeaderContext: true });
|
|
25
|
+
* // → undefined (only A-F0-9 are hex; not a hex token)
|
|
26
|
+
*/
|
|
27
|
+
/**
|
|
28
|
+
* Built-in provider patterns. Conservative — only shapes whose prefix
|
|
29
|
+
* unambiguously identifies a credential class. The bare hex pattern is gated
|
|
30
|
+
* to env/header context to avoid commit-SHA false positives.
|
|
31
|
+
*
|
|
32
|
+
* Stable as of v0.7.0 — additions are non-breaking, removals or shape changes
|
|
33
|
+
* require a major bump (the golden compatibility tests in `test/golden.test.mjs`
|
|
34
|
+
* pin the current provider set).
|
|
35
|
+
*/
|
|
36
|
+
export const SECRET_PATTERNS = [
|
|
37
|
+
{ provider: 'Anthropic', regex: /sk-ant-[A-Za-z0-9_-]{20,}/ },
|
|
38
|
+
{ provider: 'OpenAI', regex: /sk-proj-[A-Za-z0-9_-]{20,}/ },
|
|
39
|
+
{ provider: 'OpenAI', regex: /sk-(?!ant-|proj-)[A-Za-z0-9]{32,}/ },
|
|
40
|
+
{ provider: 'GitHub', regex: /gh[pousr]_[A-Za-z0-9]{36,}/ },
|
|
41
|
+
{ provider: 'GitHub', regex: /github_pat_[A-Za-z0-9_]{20,}/ },
|
|
42
|
+
{ provider: 'Slack', regex: /xox[abprs]-[A-Za-z0-9-]{20,}/ },
|
|
43
|
+
{ provider: 'AWS', regex: /AKIA[0-9A-Z]{16}/ },
|
|
44
|
+
{ provider: 'Google', regex: /AIza[0-9A-Za-z_-]{35}/ },
|
|
45
|
+
{ provider: 'GitLab', regex: /glpat-[A-Za-z0-9_-]{20,}/ },
|
|
46
|
+
{ provider: 'npm', regex: /npm_[A-Za-z0-9]{36}/ },
|
|
47
|
+
{ provider: 'Docker', regex: /dckr_pat_[A-Za-z0-9_-]{20,}/ },
|
|
48
|
+
{ provider: 'Stripe', regex: /(?:sk|rk)_(?:live|test)_[A-Za-z0-9]{20,}/ },
|
|
49
|
+
// env/header context only — see comment block at top of file.
|
|
50
|
+
{ provider: 'Hex token', regex: /(?:^|[^A-Fa-f0-9])([A-Fa-f0-9]{40,})(?:$|[^A-Fa-f0-9])/, envOrHeaderOnly: true },
|
|
51
|
+
];
|
|
52
|
+
/**
|
|
53
|
+
* Prefix marking an environment-variable reference. Values starting with
|
|
54
|
+
* `env:` are not literal credentials — they're a reference resolved at
|
|
55
|
+
* runtime by the consuming tool (Codex notation). Skipped during scanning.
|
|
56
|
+
*/
|
|
57
|
+
const ENV_REFERENCE_PREFIX = 'env:';
|
|
58
|
+
/**
|
|
59
|
+
* Scan `value` for a hardcoded provider credential. Returns the matched
|
|
60
|
+
* provider name (never the literal credential) or `undefined` when nothing
|
|
61
|
+
* matches.
|
|
62
|
+
*
|
|
63
|
+
* Set `options.envOrHeaderContext` to `true` only when scanning env values
|
|
64
|
+
* or HTTP header values — that enables the more permissive hex-token pattern
|
|
65
|
+
* which would false-positive on positional command arguments.
|
|
66
|
+
*/
|
|
67
|
+
export function matchSecret(value, options = {}) {
|
|
68
|
+
if (!value)
|
|
69
|
+
return undefined;
|
|
70
|
+
if (value.startsWith(ENV_REFERENCE_PREFIX))
|
|
71
|
+
return undefined;
|
|
72
|
+
for (const pattern of SECRET_PATTERNS) {
|
|
73
|
+
if (pattern.envOrHeaderOnly && !options.envOrHeaderContext)
|
|
74
|
+
continue;
|
|
75
|
+
if (pattern.regex.test(value)) {
|
|
76
|
+
return { provider: pattern.provider };
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return undefined;
|
|
80
|
+
}
|
|
81
|
+
//# sourceMappingURL=secrets.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-gov-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "Shared primitives for the AI-agent governance suite: Finding schema, JSONC/TOML readers, line locators, MCP command normalization, shell tokenization, and GitHub Action helpers.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -14,7 +14,8 @@
|
|
|
14
14
|
"types": "./dist/test-utils.d.ts",
|
|
15
15
|
"import": "./dist/test-utils.js"
|
|
16
16
|
},
|
|
17
|
-
"./schemas/finding.schema.json": "./schemas/finding.schema.json"
|
|
17
|
+
"./schemas/finding.schema.json": "./schemas/finding.schema.json",
|
|
18
|
+
"./schemas/report.schema.json": "./schemas/report.schema.json"
|
|
18
19
|
},
|
|
19
20
|
"files": [
|
|
20
21
|
"dist/**/*.js",
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://github.com/Conalh/agent-gov-core/schemas/report.schema.json",
|
|
4
|
+
"title": "Report",
|
|
5
|
+
"description": "Canonical multi-tool report envelope emitted by tools in the AI-agent governance suite. Wraps a `Finding[]` with provenance, rating, and optional tool-specific extension data so a cross-tool meta-reviewer can ingest reports from N tools through one shape.",
|
|
6
|
+
"type": "object",
|
|
7
|
+
"required": ["schemaVersion", "tool", "rating", "findings"],
|
|
8
|
+
"additionalProperties": false,
|
|
9
|
+
"properties": {
|
|
10
|
+
"schemaVersion": {
|
|
11
|
+
"type": "string",
|
|
12
|
+
"const": "1.0",
|
|
13
|
+
"description": "Envelope schema version. Currently '1.0'. Future incompatible envelope changes require a major bump on this field; readers must reject unknown major versions."
|
|
14
|
+
},
|
|
15
|
+
"tool": {
|
|
16
|
+
"type": "string",
|
|
17
|
+
"enum": ["scope_trail", "policy_mesh", "capability_echo", "task_bound", "session_trail"],
|
|
18
|
+
"description": "Originating tool. Matches the `Finding.tool` enum."
|
|
19
|
+
},
|
|
20
|
+
"toolVersion": {
|
|
21
|
+
"type": "string",
|
|
22
|
+
"description": "Semver of the emitting tool (e.g. '0.1.18'). Optional; helps a meta-reviewer attribute findings to a specific release."
|
|
23
|
+
},
|
|
24
|
+
"runId": {
|
|
25
|
+
"type": "string",
|
|
26
|
+
"description": "Unique identifier for this run. Optional; useful when merging reports from concurrent runs or comparing runs over time."
|
|
27
|
+
},
|
|
28
|
+
"conversationId": {
|
|
29
|
+
"type": "string",
|
|
30
|
+
"description": "Identifier for the agent session, PR review, or thread this run belongs to. Distinct from runId (one conversation can produce many runs). Matches OpenTelemetry's gen_ai.conversation.id semantic convention — if a consumer also emits OTel traces about the same agent session, pass the same string here."
|
|
31
|
+
},
|
|
32
|
+
"baseRef": {
|
|
33
|
+
"type": "string",
|
|
34
|
+
"description": "Git base ref for diff-mode tools (ScopeTrail, CapabilityEcho, TaskBound). Omit for non-diff tools (PolicyMesh, SessionTrail)."
|
|
35
|
+
},
|
|
36
|
+
"headRef": {
|
|
37
|
+
"type": "string",
|
|
38
|
+
"description": "Git head ref for diff-mode tools. Omit for non-diff tools."
|
|
39
|
+
},
|
|
40
|
+
"rating": {
|
|
41
|
+
"type": "string",
|
|
42
|
+
"enum": ["none", "low", "medium", "high", "critical"],
|
|
43
|
+
"description": "Aggregate severity for this report. 'none' iff `findings` is empty or all findings fell below the tool's fail-on threshold."
|
|
44
|
+
},
|
|
45
|
+
"findings": {
|
|
46
|
+
"type": "array",
|
|
47
|
+
"items": { "$ref": "./finding.schema.json" },
|
|
48
|
+
"description": "Findings emitted by this tool run."
|
|
49
|
+
},
|
|
50
|
+
"data": {
|
|
51
|
+
"type": "object",
|
|
52
|
+
"description": "Tool-specific extension data (e.g. PolicyMesh's `effectiveUnion`, CapabilityEcho's `surfaceSummary`, ScopeTrail's `scopeMatchCount`). Opaque to a cross-tool meta-reviewer but preserved so each tool's UX can read its own extensions back from a merged report."
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|