agent-gov-core 0.4.1 → 0.4.3

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,41 @@
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.4.3] — 2026-05-22
6
+
7
+ Third Gemini-inspection round caught one confirmed bug, one disguised-as-suggestion bug, and three feature opportunities. Both bugs fixed here; the feature work is queued for v0.5.0.
8
+
9
+ ### Added
10
+ - `Finding.salientKey?: string` — optional discriminator that participates in the fingerprint hash. Set this when a single (kind, file, line) site can produce multiple distinct findings (e.g. two suspicious imports on the same line, two MCP servers in the same JSON object). Without it, the meta-reviewer would dedupe them into one. Stable values only — package name, server name, rule id; not timestamps or counters.
11
+ - `CreateFindingSpec.salientKey` — pass-through to the new field.
12
+ - Schema gained `salientKey` under properties (still optional, schema's `additionalProperties: false` updated to permit it).
13
+
14
+ ### Fixed
15
+ - `fingerprintFinding` now includes `salientKey` in the hash. Two distinct findings of the same kind on the same line with different `salientKey` values now produce different fingerprints. Backwards-compatible: findings without `salientKey` still produce stable, identical fingerprints to v0.4.2 for the (kind, file, line, column) tuple.
16
+ - `lineOfTomlKey` now tracks multi-line basic (`"""`) and literal (`'''`) string state and skips key matching on lines that fall inside one. Previously a decoy key inside a multi-line string value could be matched as if it were a real assignment — confirmed bug with sharper reproduction than Gemini's first-round example.
17
+
18
+ ### Tests
19
+ - 7 new regression cases. 102 total (up from 95). Covers salientKey discrimination, backwards-compat for fingerprints without salientKey, validateFinding type check, decoy-in-`"""`, decoy-in-`'''`, single-line `"""..."""` (must NOT enter multiline state), and a plain-TOML sanity check that the fix doesn't over-correct.
20
+
21
+ ## [0.4.2] — 2026-05-22
22
+
23
+ External code review (Gemini, second pass) caught four correctness bugs and one source-cleanliness issue. All five fixed here.
24
+
25
+ ### Fixed
26
+ - `lineOfJsonKey` and `lineOfJsonStringValue` now JSON-encode the search input before building the regex. A caller passing the *decoded* value (e.g. `C:\Temp` from a Windows-path field) now correctly locates the JSON source bytes (`"C:\\Temp"`) instead of returning 0. Affects CapabilityEcho's `package-scripts` detector for scripts containing quotes/backslashes.
27
+ - `lineOfJsonKey` and `lineOfJsonStringValue` now scan over `stripJsonComments(text)` instead of raw text. A commented-out `"command": "fake"` no longer shadows the real key on a later line. The strip is position-preserving so returned line numbers still reference the original source.
28
+ - `getCommandHead` now strips wrapper flags (`sudo -E`, `env -i`) after recognizing a wrapper, so `sudo -E curl ...` returns `curl` instead of `-E`. SessionTrail/CapabilityEcho shell detectors no longer miss wrapped curl/wget invocations. Known limitation: short flags taking a value (`sudo -u user curl`) still misclassify as the value — documented and pinned by test.
29
+ - TOML parser now rejects a standard table header (`[items]`) that follows an array-of-tables header (`[[items]]`) with a `Cannot redefine array-of-tables` error. Previously the standard table silently descended into the array's last entry, letting writes leak into `items[0]`. Spec compliance fix.
30
+ - TOML inline-table parser now rejects duplicate keys with `Duplicate key in inline table: ...`. Previously `server = { host = "a", host = "b" }` parsed as `{ host: "b" }` — the standard-table guard wasn't mirrored on inline tables. Spec compliance fix.
31
+
32
+ ### Changed
33
+ - Source cleanup: the two `keys.join` calls in `src/toml.ts` now use a named `PATH_KEY_SEPARATOR = ''` constant instead of literal NUL bytes embedded in the source. Same runtime behavior (NUL as the delimiter, which is illegal in TOML keys so collision-proof), but `rg`/`grep` no longer treat the file as binary and `file(1)` reports it as proper text.
34
+ - README: `rankSeverity` doc corrected — was `none=0…critical=4`, actually `low=1, medium=2, high=3, critical=4`. The schema has no `none` severity.
35
+ - README: `normalizeMcpCommand` signature and behavior description corrected — was listing a non-existent `serverUrl` field and claiming "resolves npx/uvx invocations" which doesn't happen. Now accurately lists: drops neutral confirm flags, strips Windows executable suffixes, sorts non-neutral flags alphabetically, preserves positional argument order, includes env + cwd in identity.
36
+
37
+ ### Added
38
+ - 7 new regression tests: encoded-value lookup, commented-out shadow, wrapper-flag unwrap (+ edge-case pin), AOT-vs-table mixing, inline-table duplicate keys.
39
+
5
40
  ## [0.4.1] — 2026-05-22
6
41
 
7
42
  ### Fixed
package/README.md CHANGED
@@ -66,7 +66,7 @@ The JSON schema at [`schemas/finding.schema.json`](./schemas/finding.schema.json
66
66
  - `isSeverity(v)`, `isToolKind(v)`, `isNamespacedKind(v)` — type guards
67
67
  - `kind(tool, name)` — build a namespaced kind without hand-assembling the dotted string
68
68
  - `createFinding({tool, name, severity, message, ...})` — convenience constructor that calls `kind()` and `fingerprintFinding()` for you
69
- - `fingerprintFinding(finding)` — 16-character hex hash of `(kind, file, line, column)`. Stable across runs and message rewordings, so a meta-reviewer can dedupe
69
+ - `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
70
  - `validateFinding(value)` — runtime check against `schemas/finding.schema.json`, returns `{ ok, errors[] }`
71
71
 
72
72
  ### Config readers
@@ -81,14 +81,14 @@ The JSON schema at [`schemas/finding.schema.json`](./schemas/finding.schema.json
81
81
  - `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.
82
82
 
83
83
  ### MCP command normalization
84
- - `normalizeMcpCommand({ command, args, url, serverUrl, env, cwd })` — canonical identity string for an MCP server entry. Drops neutral flags (`-y`, `--yes`), resolves npx/uvx invocations, includes env+cwd in the identity. Used to dedupe `mcp_command_mismatch` false positives when servers are equivalent but syntactically different (`npx -y foo@1.2.3` vs `npx foo@1.2.3`).
84
+ - `normalizeMcpCommand({ command, args, url, env, cwd })` — canonical identity string for an MCP server entry. Drops neutral confirm flags (`-y`, `--yes`), strips Windows executable suffixes (`.cmd`, `.exe`, `.bat`, `.ps1`), sorts non-neutral flags alphabetically, preserves positional argument order, and includes env + cwd in the identity. Used to dedupe `mcp_command_mismatch` false positives when servers are equivalent but syntactically different (`npx -y foo@1.2.3` vs `npx foo@1.2.3`). Does not interpret what npx/uvx invocations resolve to at runtime — that's outside the substrate's scope.
85
85
 
86
86
  ### Shell tokenization
87
87
  - `tokenizeShell(command)` — quote-aware split on `;`, `|`, `&&`, `||` plus trivial obfuscation neutralization (`c""url` → `curl`, `c\\url` → `curl`)
88
88
  - `getCommandHead(subcommand)` — extract the leading verb after tokenization
89
89
 
90
90
  ### GitHub Action helpers
91
- - `rankSeverity(s)` — numeric rank `none=0 critical=4`
91
+ - `rankSeverity(s)` — numeric rank `low=1, medium=2, high=3, critical=4` (matches the schema's closed severity enum; there is no `none`)
92
92
  - `passesSeverityThreshold(s, threshold)`, `anyAtOrAbove(findings, threshold)` — fail-on plumbing
93
93
  - `emitFindingAnnotation(f)` — render a Finding as a `::warning file=…,line=…,title=…::…` GitHub workflow annotation
94
94
 
package/dist/finding.d.ts CHANGED
@@ -26,6 +26,16 @@ export interface Finding {
26
26
  location?: FindingLocation;
27
27
  /** Stable identifier for dedupe across runs. Recommended: hash of (kind, location, salient fields). */
28
28
  fingerprint?: string;
29
+ /**
30
+ * Optional discriminator that participates in the fingerprint hash. Set this
31
+ * when a single (kind, file, line) site can legitimately host multiple distinct
32
+ * findings — e.g. two suspicious imports on the same line, two MCP servers in
33
+ * the same JSON object, two npm dependencies declared in one package.json line.
34
+ * Without it, the meta-reviewer would dedupe them into one. Use a stable value
35
+ * that doesn't drift across reruns (package name, server name, rule id) — not
36
+ * a timestamp or counter.
37
+ */
38
+ salientKey?: string;
29
39
  /** Optional structured metadata; downstream meta-reviewers may inspect it. */
30
40
  data?: Record<string, unknown>;
31
41
  }
@@ -57,6 +67,12 @@ export interface CreateFindingSpec {
57
67
  detail?: string;
58
68
  location?: FindingLocation;
59
69
  data?: Record<string, unknown>;
70
+ /**
71
+ * See {@link Finding.salientKey}. Pass when the same (kind, file, line) site
72
+ * can produce multiple distinct findings that must not collapse to one
73
+ * fingerprint.
74
+ */
75
+ salientKey?: string;
60
76
  /** Optional explicit fingerprint. If omitted, {@link fingerprintFinding} is computed. */
61
77
  fingerprint?: string;
62
78
  }
package/dist/finding.js CHANGED
@@ -62,6 +62,8 @@ export function createFinding(spec) {
62
62
  finding.detail = spec.detail;
63
63
  if (spec.location !== undefined)
64
64
  finding.location = spec.location;
65
+ if (spec.salientKey !== undefined)
66
+ finding.salientKey = spec.salientKey;
65
67
  if (spec.data !== undefined)
66
68
  finding.data = spec.data;
67
69
  finding.fingerprint = spec.fingerprint ?? fingerprintFinding(finding);
@@ -98,6 +100,10 @@ export function fingerprintFinding(finding) {
98
100
  fileNormalized,
99
101
  finding.location?.line ?? '',
100
102
  finding.location?.column ?? '',
103
+ // salientKey lets multiple distinct findings at the same (kind, file, line)
104
+ // site keep separate fingerprints. Empty string when absent so the hash
105
+ // shape is stable across findings that don't need a discriminator.
106
+ finding.salientKey ?? '',
101
107
  ];
102
108
  return createHash('sha256').update(parts.join('|')).digest('hex').slice(0, 16);
103
109
  }
@@ -109,6 +115,7 @@ const FINDING_ALLOWED_KEYS = new Set([
109
115
  'detail',
110
116
  'location',
111
117
  'fingerprint',
118
+ 'salientKey',
112
119
  'data',
113
120
  ]);
114
121
  const LOCATION_ALLOWED_KEYS = new Set(['file', 'line', 'column', 'endLine', 'endColumn']);
@@ -145,6 +152,9 @@ export function validateFinding(value) {
145
152
  if (v.fingerprint !== undefined && typeof v.fingerprint !== 'string') {
146
153
  errors.push('fingerprint must be a string when present');
147
154
  }
155
+ if (v.salientKey !== undefined && typeof v.salientKey !== 'string') {
156
+ errors.push('salientKey must be a string when present');
157
+ }
148
158
  if (v.data !== undefined && (v.data === null || typeof v.data !== 'object' || Array.isArray(v.data))) {
149
159
  errors.push('data must be an object when present');
150
160
  }
@@ -11,12 +11,24 @@ export interface ByteRange {
11
11
  /** Exclusive end offset. */
12
12
  end: number;
13
13
  }
14
- /** 1-based line number for the first occurrence of `"key"` followed by `:`. */
14
+ /**
15
+ * 1-based line number for the first occurrence of `"key"` followed by `:`.
16
+ *
17
+ * The key is JSON-encoded before matching so keys containing backslashes or
18
+ * quotes (rare but legal) are located in the source bytes. The scan ignores
19
+ * lines inside JSONC `//` and `/* *\/` comments so a commented-out `"key":`
20
+ * does not shadow the real one.
21
+ */
15
22
  export declare function lineOfJsonKey(text: string, key: string, scope?: ByteRange): number;
16
23
  /**
17
24
  * 1-based line number for the first JSON string value equal to `value`.
18
25
  * If `scope` is supplied (a byte range), only matches inside that range count —
19
26
  * this is the fix for the multi-server-ambiguity bug.
27
+ *
28
+ * The value is JSON-encoded before matching so values containing backslashes
29
+ * (e.g. Windows paths like `C:\Temp` written as `"C:\\Temp"` in JSON) are
30
+ * located correctly. The scan ignores JSONC comments so a commented-out
31
+ * matching value does not shadow the real one.
20
32
  */
21
33
  export declare function lineOfJsonStringValue(text: string, value: string, scope?: ByteRange): number;
22
34
  /**
package/dist/locators.js CHANGED
@@ -5,19 +5,41 @@
5
5
  * All returned line numbers are 1-based. `0` is reserved for "not found"; callers
6
6
  * generally treat that as "fall back to file-level annotation".
7
7
  */
8
- /** 1-based line number for the first occurrence of `"key"` followed by `:`. */
8
+ import { stripJsonComments } from './jsonc.js';
9
+ /**
10
+ * 1-based line number for the first occurrence of `"key"` followed by `:`.
11
+ *
12
+ * The key is JSON-encoded before matching so keys containing backslashes or
13
+ * quotes (rare but legal) are located in the source bytes. The scan ignores
14
+ * lines inside JSONC `//` and `/* *\/` comments so a commented-out `"key":`
15
+ * does not shadow the real one.
16
+ */
9
17
  export function lineOfJsonKey(text, key, scope) {
10
- const needle = `"${escapeForRegex(key)}"\\s*:`;
11
- return findLineByRegex(text, new RegExp(needle), scope);
18
+ const encoded = jsonEncodeForRegex(key);
19
+ return findLineByRegex(text, new RegExp(`"${encoded}"\\s*:`), scope);
12
20
  }
13
21
  /**
14
22
  * 1-based line number for the first JSON string value equal to `value`.
15
23
  * If `scope` is supplied (a byte range), only matches inside that range count —
16
24
  * this is the fix for the multi-server-ambiguity bug.
25
+ *
26
+ * The value is JSON-encoded before matching so values containing backslashes
27
+ * (e.g. Windows paths like `C:\Temp` written as `"C:\\Temp"` in JSON) are
28
+ * located correctly. The scan ignores JSONC comments so a commented-out
29
+ * matching value does not shadow the real one.
17
30
  */
18
31
  export function lineOfJsonStringValue(text, value, scope) {
19
- const needle = `"${escapeForRegex(value)}"`;
20
- return findLineByRegex(text, new RegExp(needle), scope);
32
+ const encoded = jsonEncodeForRegex(value);
33
+ return findLineByRegex(text, new RegExp(`"${encoded}"`), scope);
34
+ }
35
+ /**
36
+ * Convert a string to the form it would appear in JSON source bytes, then
37
+ * regex-escape. `JSON.stringify('C:\\Temp')` yields `'"C:\\\\Temp"'` — slice
38
+ * off the surrounding quotes to get the inner byte sequence.
39
+ */
40
+ function jsonEncodeForRegex(input) {
41
+ const jsonBody = JSON.stringify(input).slice(1, -1);
42
+ return escapeForRegex(jsonBody);
21
43
  }
22
44
  /**
23
45
  * 1-based line number for a TOML key. Supports dotted keys (`a.b.c`) — the
@@ -42,9 +64,19 @@ export function lineOfTomlKey(text, dottedKey, scope) {
42
64
  let inTargetTable = prefix.length === 0;
43
65
  let currentTable = [];
44
66
  const targetHeader = prefix.join('.');
67
+ // Track multi-line basic (`"""`) and literal (`'''`) string state. A leaf-key
68
+ // pattern can otherwise match against decoy text inside a multi-line string
69
+ // value — see lineOfTomlKey regression tests.
70
+ let inMultilineString = null;
45
71
  for (let i = 0; i < lines.length; i++) {
46
72
  const lineNumber = i + 1;
47
73
  const raw = lines[i];
74
+ const stateAtLineStart = inMultilineString;
75
+ inMultilineString = updateMultilineStringState(raw, inMultilineString);
76
+ // If we entered this line inside a multi-line string, never match. The key
77
+ // pattern there is part of a string literal, not a real assignment.
78
+ if (stateAtLineStart !== null)
79
+ continue;
48
80
  const trimmed = raw.trim();
49
81
  const headerMatch = /^\[\[?\s*([^\]]+?)\s*\]\]?\s*(#.*)?$/.exec(trimmed);
50
82
  if (headerMatch) {
@@ -71,6 +103,43 @@ export function lineOfTomlKey(text, dottedKey, scope) {
71
103
  }
72
104
  return 0;
73
105
  }
106
+ /**
107
+ * Walk a line and update multi-line string state. Each unescaped occurrence of
108
+ * `"""` toggles basic-multiline; each `'''` toggles literal-multiline; the
109
+ * other delimiter is inert while we're inside the first. Returns the state at
110
+ * end-of-line so the next iteration knows whether it's inside a string.
111
+ */
112
+ function updateMultilineStringState(line, current) {
113
+ let state = current;
114
+ let pos = 0;
115
+ while (pos <= line.length - 3) {
116
+ const window = line.substr(pos, 3);
117
+ if (state === null) {
118
+ if (window === '"""') {
119
+ state = '"""';
120
+ pos += 3;
121
+ continue;
122
+ }
123
+ if (window === "'''") {
124
+ state = "'''";
125
+ pos += 3;
126
+ continue;
127
+ }
128
+ }
129
+ else if (state === '"""' && window === '"""') {
130
+ state = null;
131
+ pos += 3;
132
+ continue;
133
+ }
134
+ else if (state === "'''" && window === "'''") {
135
+ state = null;
136
+ pos += 3;
137
+ continue;
138
+ }
139
+ pos++;
140
+ }
141
+ return state;
142
+ }
74
143
  function scopeLineFilter(text, scope) {
75
144
  if (!scope)
76
145
  return () => true;
@@ -79,7 +148,12 @@ function scopeLineFilter(text, scope) {
79
148
  return (line) => line >= startLine && line <= endLine;
80
149
  }
81
150
  function findLineByRegex(text, regex, scope) {
82
- const haystack = scope ? text.slice(scope.start, scope.end) : text;
151
+ // stripJsonComments is position-preserving: it replaces comment bytes with
152
+ // spaces while leaving newlines intact. Offsets in the stripped text map
153
+ // 1:1 to offsets in the original text, so line numbers stay correct, but
154
+ // commented-out keys/values no longer match.
155
+ const searchable = stripJsonComments(text);
156
+ const haystack = scope ? searchable.slice(scope.start, scope.end) : searchable;
83
157
  const m = regex.exec(haystack);
84
158
  if (!m)
85
159
  return 0;
package/dist/shell.js CHANGED
@@ -127,15 +127,43 @@ export function getCommandHead(subcommand) {
127
127
  break;
128
128
  s = s.slice(m[0].length);
129
129
  }
130
- // Strip leading sudo / env wrappers
131
- const wrapperMatch = /^(sudo|nohup|env|exec|command|builtin)\s+(.*)$/.exec(s);
130
+ // Strip leading sudo / env wrappers, then also strip any wrapper flags
131
+ // (`sudo -E`, `env -i`) and embedded env vars (`env FOO=1 BAZ=qux curl`)
132
+ // before recursing. Without this, `sudo -E curl` would return `-E`.
133
+ const wrapperMatch = /^(sudo|nohup|env|exec|command|builtin|stdbuf|nice|ionice|setsid)\s+(.*)$/.exec(s);
132
134
  if (wrapperMatch) {
133
- return getCommandHead(wrapperMatch[2]);
135
+ return getCommandHead(stripWrapperPrefixes(wrapperMatch[2]));
134
136
  }
135
137
  // Now extract first token, honoring quoting and obfuscation neutralization.
136
138
  const head = readFirstToken(s);
137
139
  return deobfuscate(head);
138
140
  }
141
+ /**
142
+ * Consume any leading flags (`-x`, `--xxx`, `--xxx=value`) and env var
143
+ * assignments (`FOO=bar`) so the next recursion finds the real command. We
144
+ * intentionally do NOT consume a non-flag token after a short flag (so
145
+ * `sudo -u user curl` still misclassifies as `user` — a known edge case
146
+ * that we accept rather than maintain a per-wrapper flag database).
147
+ */
148
+ function stripWrapperPrefixes(input) {
149
+ let s = input.trimStart();
150
+ while (s.length > 0) {
151
+ if (s.startsWith('-')) {
152
+ const flagMatch = /^\S+\s*/.exec(s);
153
+ if (!flagMatch)
154
+ break;
155
+ s = s.slice(flagMatch[0].length);
156
+ continue;
157
+ }
158
+ const envMatch = /^([A-Za-z_][A-Za-z0-9_]*)=([^\s'"]*|"[^"]*"|'[^']*')\s+/.exec(s);
159
+ if (envMatch) {
160
+ s = s.slice(envMatch[0].length);
161
+ continue;
162
+ }
163
+ break;
164
+ }
165
+ return s;
166
+ }
139
167
  function readFirstToken(s) {
140
168
  let out = '';
141
169
  let i = 0;
package/dist/toml.js CHANGED
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-gov-core",
3
- "version": "0.4.1",
3
+ "version": "0.4.3",
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",
@@ -41,6 +41,10 @@
41
41
  }
42
42
  },
43
43
  "fingerprint": { "type": "string" },
44
+ "salientKey": {
45
+ "type": "string",
46
+ "description": "Optional discriminator that participates in the fingerprint hash. Set when a single (kind, file, line) site can produce multiple distinct findings (e.g. two suspicious imports on one line). Use a stable value — package name, server name, rule id — not a timestamp."
47
+ },
44
48
  "data": { "type": "object" }
45
49
  }
46
50
  }