agent-gov-core 0.7.0 → 0.7.1

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 CHANGED
@@ -2,6 +2,36 @@
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.1] — 2026-05-22
6
+
7
+ Contract-hardening patch. Two external inspection rounds (Gemini + Cody) surfaced five P0/P1 contract bugs in shipped code and one packaging fix. All addressed here. No new features; no new public exports.
8
+
9
+ ### Fixed (correctness)
10
+
11
+ - **`applyExceptions` is now order-independent.** Previously the FIRST matching rule won — a stale expired rule listed before a broader active rule would incorrectly surface the finding as expired instead of being suppressed by the active rule. Now ALL matching rules are collected; the finding is suppressed when any matching rule is active, and only re-surfaces (with downgrade) when every matching rule has expired.
12
+ - **`mergeFindings` rejects tool/finding mismatches.** Previously a forged `scope_trail` report containing `policy_mesh.*` findings would merge silently with wrong provenance. `validateReport` already rejected this; the merge path was more permissive. Now mismatches land in `invalidFindings[]` while the rest of the report still passes through.
13
+ - **`normalizeMcpCommand` no longer collides on whitespace/delimiter args.** `['a b']` and `['a', 'b']` previously produced the same canonical `args=a b` because of space-joining. Same shape for env: `{A:'1|B=2'}` and `{A:'1', B:'2'}` collided under pipe-joining. Both now use JSON encoding so distinct inputs produce distinct canonicals. **This changes the canonical-string format**; PolicyMesh's `mcp_command_mismatch` will now correctly detect previously-conflated MCP configs.
14
+ - **`createReport` clamps a downward-rating override upward to the implied max.** Previously `createReport({rating: 'low', findings: [critical-finding]})` returned a report that `validateReport` would then reject — the constructor and validator disagreed. Now createReport's output always round-trips through validateReport. Upward overrides (rating > implied) are still honored.
15
+ - **`applyExceptions` pathPrefix now normalizes Windows backslashes and requires segment boundaries.** A finding with `src\app.ts` (Windows) now matches a `src/` prefix; a prefix `src/app` no longer over-suppresses `src/application.ts` (the match must land on a `/` boundary or be the exact path).
16
+
17
+ ### Changed (visible to consumers)
18
+
19
+ - **MCP canonical-string format**: `args` and `env` now serialize as JSON. Existing PolicyMesh test fixtures may need updates if they pin the exact canonical (most don't — they pin server-identity-equivalence). Golden tests in `test/golden.test.mjs` updated to the new format.
20
+
21
+ ### Packaging
22
+ - `docs/` directory is now included in the npm tarball. The README's link to `docs/INTEROP-OTEL.md` no longer 404s on the npm landing page.
23
+
24
+ ### Cleanup
25
+ - `candidateTool` in `merge.ts` now delegates to `isToolKind` from `finding.ts` instead of carrying a hardcoded tool-list regex. Removes the fourth lockstep duplication of the ToolKind enum.
26
+
27
+ ### Tests
28
+ - 230 total, up from 220. 10 new regression cases: order-independent exception application, all-expired downgrade chain, Windows-backslash path normalization, segment-aware prefix boundary, mergeFindings tool-mismatch rejection, MCP args whitespace collision, MCP env delimiter collision, MCP env order-independence under JSON encoding, createReport rating clamp, createReport round-trip-validates contract.
29
+
30
+ ### Skipped vs Cursor inspection
31
+ - Gemini #3 (secret-pattern boundary anchors): proposed fix didn't actually fix the example given (`my-transaction-id-AIza<35>` has `-` as boundary character, so a boundary anchor still allows the match). Held for further design.
32
+ - Gemini #4 (hex token vs `GITHUB_SHA`): operationally rare given current consumer scanning paths; document-only follow-up.
33
+ - Cursor's README/package.json description / CONTRIBUTING module list refresh: pending follow-up doc PR.
34
+
5
35
  ## [0.7.0] — 2026-05-22
6
36
 
7
37
  **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).
@@ -41,35 +41,59 @@ export function applyExceptions(findings, exceptions, now = new Date()) {
41
41
  let suppressed = 0;
42
42
  let expired = 0;
43
43
  for (const finding of findings) {
44
- const match = findMatchingException(finding, exceptions);
45
- if (!match) {
44
+ // Collect ALL matching rules — order independence is required by contract.
45
+ // A finding is suppressed when any matching rule is active; only when
46
+ // every matching rule has expired does the finding re-surface as expired.
47
+ // Previously the first match won, so a stale rule listed before an
48
+ // active broader rule incorrectly surfaced expired alerts.
49
+ const matches = findAllMatchingExceptions(finding, exceptions);
50
+ if (matches.length === 0) {
46
51
  result.push(finding);
47
52
  continue;
48
53
  }
49
- if (match.expires && isExpired(match.expires, now)) {
50
- result.push(downgradeExpired(finding, match));
51
- expired++;
52
- }
53
- else {
54
+ const activeMatch = matches.find((m) => !m.expires || !isExpired(m.expires, now));
55
+ if (activeMatch) {
54
56
  suppressed++;
57
+ continue;
55
58
  }
59
+ // Every matching rule has expired. Use the first match for reason text.
60
+ result.push(downgradeExpired(finding, matches[0]));
61
+ expired++;
56
62
  }
57
63
  return { findings: result, suppressed, expired };
58
64
  }
59
- function findMatchingException(finding, exceptions) {
65
+ function findAllMatchingExceptions(finding, exceptions) {
66
+ const out = [];
60
67
  for (const exc of exceptions) {
61
68
  if (exc.kind !== finding.kind)
62
69
  continue;
63
70
  if (exc.salientKey !== undefined && exc.salientKey !== finding.salientKey)
64
71
  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;
72
+ if (exc.pathPrefix !== undefined && !pathPrefixMatches(finding.location?.file, exc.pathPrefix))
73
+ continue;
74
+ out.push(exc);
71
75
  }
72
- return undefined;
76
+ return out;
77
+ }
78
+ /**
79
+ * Segment-aware path-prefix match. Normalizes Windows backslashes to forward
80
+ * slashes on BOTH sides so a finding's `src\app.ts` matches a `src/` prefix.
81
+ * Requires the prefix match to land on a segment boundary OR be the exact
82
+ * full path — so prefix `src/app` does NOT match `src/application.ts`.
83
+ */
84
+ function pathPrefixMatches(file, prefix) {
85
+ if (!file)
86
+ return false;
87
+ const fileNorm = file.replace(/\\/g, '/');
88
+ const prefixNorm = prefix.replace(/\\/g, '/');
89
+ if (!fileNorm.startsWith(prefixNorm))
90
+ return false;
91
+ // Exact match, prefix ends with `/`, or next char is `/` — all valid boundaries.
92
+ if (fileNorm.length === prefixNorm.length)
93
+ return true;
94
+ if (prefixNorm.endsWith('/'))
95
+ return true;
96
+ return fileNorm[prefixNorm.length] === '/';
73
97
  }
74
98
  function isExpired(expires, now) {
75
99
  const parsed = new Date(expires);
package/dist/mcp.js CHANGED
@@ -29,15 +29,20 @@ export function normalizeMcpCommand(spec) {
29
29
  parts.push(`cmd=${normalizeExecutable(spec.command)}`);
30
30
  }
31
31
  const args = spec.args ?? [];
32
- parts.push(`args=${canonicalizeArgs(args).join(' ')}`);
32
+ // JSON-encode the canonicalized args so a token containing whitespace
33
+ // (`['a b']`) doesn't collide with two tokens (`['a', 'b']`). Both would
34
+ // previously serialize to `args=a b` and PolicyMesh would treat genuinely
35
+ // different MCP commands as the same server.
36
+ parts.push(`args=${JSON.stringify(canonicalizeArgs(args))}`);
33
37
  if (spec.cwd) {
34
38
  parts.push(`cwd=${normalizePath(spec.cwd)}`);
35
39
  }
36
40
  if (spec.env) {
37
- const env = Object.entries(spec.env)
38
- .map(([k, v]) => `${k}=${v}`)
39
- .sort();
40
- parts.push(`env=${env.join('|')}`);
41
+ // JSON-encode sorted (key, value) pairs so a value containing `|` or `=`
42
+ // (`{A: '1|B=2'}`) doesn't collide with multiple entries (`{A: '1', B: '2'}`).
43
+ // Sorted by key for order-independence.
44
+ const env = Object.entries(spec.env).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
45
+ parts.push(`env=${JSON.stringify(env)}`);
41
46
  }
42
47
  return parts.join('\n');
43
48
  }
package/dist/merge.js CHANGED
@@ -70,6 +70,22 @@ export function mergeFindings(reports, opts = {}) {
70
70
  });
71
71
  continue;
72
72
  }
73
+ // Cross-check: a finding's tool must match the envelope's tool. Otherwise
74
+ // the merge would attribute a foreign-tool finding to this report's
75
+ // source provenance, breaking the meta-reviewer's audit trail.
76
+ // validateReport enforces this strictly; the merge path was previously
77
+ // more permissive — which let a forged report through.
78
+ if (finding.tool !== report.tool) {
79
+ invalidFindings.push({
80
+ reportIndex: i,
81
+ findingIndex: j,
82
+ tool: report.tool,
83
+ errors: [
84
+ `finding.tool '${finding.tool}' does not match report.tool '${report.tool}'`,
85
+ ],
86
+ });
87
+ continue;
88
+ }
73
89
  if (rankSeverity(finding.severity) < thresholdRank) {
74
90
  droppedBelowThreshold++;
75
91
  continue;
@@ -121,9 +137,10 @@ function candidateTool(value) {
121
137
  if (value === null || typeof value !== 'object')
122
138
  return undefined;
123
139
  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;
140
+ // Defer to isToolKind from finding.ts — the single source of truth for the
141
+ // ToolKind enum. Avoids a hardcoded regex drifting from the TS union, the
142
+ // schema, and TOOL_KINDS.
143
+ return isToolKind(t) ? t : undefined;
127
144
  }
128
145
  /**
129
146
  * Envelope-only structural check. Unlike `validateReport`, this does NOT
package/dist/report.js CHANGED
@@ -18,10 +18,22 @@ export const REPORT_SCHEMA_VERSION = '1.0';
18
18
  * });
19
19
  */
20
20
  export function createReport(spec) {
21
+ // Rating policy: caller-supplied rating is honored only when it's at or
22
+ // above the implied max severity. Otherwise it's clamped upward to the
23
+ // implied max so createReport never returns a report that validateReport
24
+ // would reject. Upward overrides (rating > implied) are still allowed —
25
+ // a tool may legitimately escalate by policy.
26
+ const impliedRating = maxSeverity(spec.findings);
27
+ const supplied = spec.rating;
28
+ const rating = supplied === undefined
29
+ ? impliedRating
30
+ : severityRank(supplied) >= severityRank(impliedRating)
31
+ ? supplied
32
+ : impliedRating;
21
33
  const report = {
22
34
  schemaVersion: REPORT_SCHEMA_VERSION,
23
35
  tool: spec.tool,
24
- rating: spec.rating ?? maxSeverity(spec.findings),
36
+ rating,
25
37
  findings: spec.findings,
26
38
  };
27
39
  if (spec.toolVersion !== undefined)
@@ -0,0 +1,64 @@
1
+ # Interop: OpenTelemetry GenAI Semantic Conventions
2
+
3
+ `agent-gov-core` and the [OpenTelemetry GenAI semantic conventions](https://opentelemetry.io/docs/specs/semconv/gen-ai/) solve adjacent problems:
4
+
5
+ | | OpenTelemetry GenAI | agent-gov-core |
6
+ |---|---|---|
7
+ | **Domain** | Runtime trace observability | Static-analysis governance findings |
8
+ | **Question answered** | What did the agent do? | What's wrong with what the agent did (or might do)? |
9
+ | **Unit of work** | Span / trace | Finding / Report |
10
+ | **Lifetime** | Real-time, ephemeral | Persisted, reviewable in PR |
11
+ | **Audience** | SREs, on-call engineers | Code reviewers, security teams |
12
+
13
+ They're complementary. A team running OTel-instrumented agents can pair runtime traces with governance findings against the same conversation, then correlate by ID across the two systems.
14
+
15
+ ## Recommended cross-walk
16
+
17
+ | OpenTelemetry `gen_ai.*` attribute | agent-gov-core field | Notes |
18
+ |---|---|---|
19
+ | `gen_ai.conversation.id` | `Report.conversationId` | Same string — pass through directly. v0.6.0 added `conversationId` as an optional `Report` field for this purpose. |
20
+ | `gen_ai.agent.name` | `Report.tool` | Loose match — OTel's "agent name" is whatever the application calls it. Our `tool` is one of five governance tools. If a consumer emits both, the OTel agent name is the *subject*, our tool is the *reviewer*. |
21
+ | `gen_ai.workflow.name` | `MergedReport` (no field today) | When `mergeFindings` rolls up N tool reports for one PR/conversation, that's structurally a workflow. We don't carry a workflow name field yet — a future `MergedReport.workflowName` could match. |
22
+ | `gen_ai.operation.name` | n/a | OTel has `create_agent`, `invoke_agent`, `invoke_workflow`. We're not a tracer; we don't emit operation spans. |
23
+ | `error.type` | `ConfigParseError.name` / Finding `data.errorType` | OTel's `error.type` is stable across all of OTel and stays the right field name for any error class identifier we surface to observability consumers. |
24
+ | `gen_ai.tool.definitions` | The data ScopeTrail / PolicyMesh *parse from* `.mcp.json` etc. | We extract this; OTel emits it as a span attribute. Same content, different transport. |
25
+ | `gen_ai.usage.*tokens` | n/a | Runtime telemetry, not governance. |
26
+ | `gen_ai.input.messages` / `gen_ai.output.messages` | n/a | Runtime telemetry. SessionTrail reviews *transcripts*, not active message streams. |
27
+
28
+ ## Why we don't adopt the OTel namespace ourselves
29
+
30
+ 1. **Different shape.** OTel attributes are flat key-value pairs on a span. Our `Finding` is a structured object with severity, location, and a namespaced `kind`. Forcing one onto the other loses information either way.
31
+ 2. **Different stability lifecycle.** OTel GenAI attributes are marked `Development` (their pre-stable tier) and may still churn. Our schema needs to freeze at v1.0 with explicit semver guarantees for consumer tools.
32
+ 3. **Different validation contract.** OTel attributes are "best effort, observability tools must tolerate missing fields." Our schema is strict (`additionalProperties: false`) because consumer detectors depend on field presence.
33
+
34
+ `Report.conversationId` is the one bridge field — same string on both sides, no transform, opt-in.
35
+
36
+ ## How to bridge in practice
37
+
38
+ ```ts
39
+ // In a consumer tool that also emits OTel traces:
40
+ import { trace } from '@opentelemetry/api';
41
+ import { createReport, mergeFindings } from 'agent-gov-core';
42
+
43
+ const span = trace.getActiveSpan();
44
+ const conversationId = span?.spanContext().traceState?.get('conversation.id');
45
+
46
+ const report = createReport({
47
+ tool: 'scope_trail',
48
+ conversationId, // ← OTel's gen_ai.conversation.id, same value
49
+ findings: collectedFindings,
50
+ });
51
+ ```
52
+
53
+ Now an observability backend correlating by `conversation.id` can pull both the OTel traces (what the agent did) and the governance report (what was risky about it) for the same agent session.
54
+
55
+ ## Future considerations
56
+
57
+ - **`MergedReport.workflowName`** — would map to `gen_ai.workflow.name`. Useful when the meta-reviewer is invoked across multiple tool runs that share a workflow context (e.g. a multi-PR review).
58
+ - **OTel span emission from the meta-reviewer** — `mergeFindings` could optionally emit a span with `gen_ai.operation.name = "review_workflow"` and findings as span events. Held for v1.x — current `mergeFindings` deliberately has no observability dependencies.
59
+
60
+ ## References
61
+
62
+ - [OpenTelemetry GenAI semantic conventions](https://opentelemetry.io/docs/specs/semconv/gen-ai/)
63
+ - [Agent spans specifically](https://opentelemetry.io/docs/specs/semconv/gen-ai/gen-ai-agent-spans/)
64
+ - [`error.type` general convention](https://opentelemetry.io/docs/specs/semconv/attributes-registry/error/)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-gov-core",
3
- "version": "0.7.0",
3
+ "version": "0.7.1",
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",
@@ -21,6 +21,7 @@
21
21
  "dist/**/*.js",
22
22
  "dist/**/*.d.ts",
23
23
  "schemas",
24
+ "docs",
24
25
  "LICENSE",
25
26
  "README.md",
26
27
  "CHANGELOG.md"