@jeger-ai/opengantry 2.2.2 → 2.2.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/README.md CHANGED
@@ -60,14 +60,15 @@ Developing this repository from source: `npm ci && npm run build` — see [gapma
60
60
  | **Regulated / ISO-aligned workflows** | SOD, mission authorization, and Git-native trace — see [`docs/COMPLIANCE-ISO.md`](docs/COMPLIANCE-ISO.md) (27001 change control, 42001 AI governance) |
61
61
  | **Faster recovery from failure** | Stable `GXT_*` error codes, `gapman verify --fix`, role output via `--audience worker\|teacher\|verifier` |
62
62
 
63
- **Protocol maturity:** substrate law **v0.5.0**; **`gapman` v2.2.2** — Time-to-Scaffold public benchmark, measured LOC matrix. Current npm publish: **v2.2.2**. See [.gitagent/teacher/RUNTIME.md](.gitagent/teacher/RUNTIME.md).
63
+ **Protocol maturity:** substrate law **v0.5.0**; **`gapman` v2.2.3** — declarative trusted automation policy for low-risk bot maintenance. Current npm publish: **v2.2.3**. See [.gitagent/teacher/RUNTIME.md](.gitagent/teacher/RUNTIME.md).
64
64
 
65
65
  ## Release timeline (latest first)
66
66
 
67
- Current npm release in this repository: **`gapman` v2.2.2**. Use [`docs/ADOPTION.md`](docs/ADOPTION.md) for the ordered runbook.
67
+ Current npm release in this repository: **`gapman` v2.2.3**. Use [`docs/ADOPTION.md`](docs/ADOPTION.md) for the ordered runbook.
68
68
 
69
69
  | Release | Highlights |
70
70
  |---------|------------|
71
+ | **v2.2.3** | Declarative `trusted_automation` policy in `.gitagent/config.json` — repository-legislated bot maintenance bypass with `max_net_loc <= 5` ([#92](https://github.com/jeger-ai/opengantry/issues/92)) |
71
72
  | **v2.2.2** | Time-to-Scaffold benchmark — `npm run examples:benchmark`, measured LOC matrix, adoption discovery ([#79](https://github.com/jeger-ai/opengantry/issues/79)) |
72
73
  | **v2.2.1** | Thermo remediation — unified `NormalizedVerifyFailure` contract across JSON/human/context-feed; race-safe remediation snapshot writes; canonical `verify-presentation` entrypoint |
73
74
  | **v2.2.0** | `gapman context-feed`, `gapman audit-rigor`, `virtual_capture` ephemeral virtualization ([#68](https://github.com/jeger-ai/opengantry/issues/68)), product positioning, docs quality ([#66](https://github.com/jeger-ai/opengantry/issues/66)–[#69](https://github.com/jeger-ai/opengantry/issues/69), [#76](https://github.com/jeger-ai/opengantry/issues/76)) |
@@ -1,4 +1,4 @@
1
1
  /** Generated by scripts/gen-version.mjs — do not edit */
2
- export declare const CLI_VERSION = "2.2.2";
2
+ export declare const CLI_VERSION = "2.2.3";
3
3
  export declare const NPM_PACKAGE_NAME = "@jeger-ai/opengantry";
4
4
  //# sourceMappingURL=version.gen.d.ts.map
@@ -1,4 +1,4 @@
1
1
  /** Generated by scripts/gen-version.mjs — do not edit */
2
- export const CLI_VERSION = "2.2.2";
2
+ export const CLI_VERSION = "2.2.3";
3
3
  export const NPM_PACKAGE_NAME = "@jeger-ai/opengantry";
4
4
  //# sourceMappingURL=version.gen.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jeger-ai/opengantry",
3
- "version": "2.2.2",
3
+ "version": "2.2.3",
4
4
  "description": "OpenGantry GXT substrate + gapman CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,15 @@
1
+ {
2
+ "trusted_automation": {
3
+ "rules": [
4
+ {
5
+ "id": "workflow-dependency-bumps",
6
+ "allowed_actors": [
7
+ "dependabot[bot]@users.noreply.github.com"
8
+ ],
9
+ "allowed_paths": [".github/workflows/**"],
10
+ "allowed_structural_changes": ["workflow_version_pin"],
11
+ "max_net_loc": 5
12
+ }
13
+ ]
14
+ }
15
+ }
@@ -3,7 +3,8 @@
3
3
  # PR governance (pull_request only): mission PRs must target the repo default branch
4
4
  # (override: repository variable GXT_INTEGRATION_BRANCH when integration branch differs).
5
5
  # MSN policy (pull_request only): commits touching MSN-enforced paths must have
6
- # subject [MSN-NNNN] (four digits) or a valid gxt-bypass git note.
6
+ # subject [MSN-NNNN] (four digits), a valid gxt-bypass git note, or satisfy
7
+ # repository-declared trusted_automation policy (.gitagent/config.json).
7
8
  # Mission purity: scripts/verify-pr-missions.sh — one [MSN-NNNN] per PR commit range.
8
9
  # Paths = fixed substrate (.gitagent/, WORKER_LOG.md, .githooks/, gxt-validate.yml)
9
10
  # + all MANIFEST skills[].tmvc_roots (Node via scripts/gxt-manifest-lib.mjs).
@@ -36,7 +37,7 @@ jobs:
36
37
  manifest:
37
38
  runs-on: ubuntu-latest
38
39
  steps:
39
- - uses: actions/checkout@v6
40
+ - uses: actions/checkout@v7
40
41
  with:
41
42
  fetch-depth: 0
42
43
 
@@ -74,7 +75,7 @@ jobs:
74
75
  needs: pr_governance
75
76
  runs-on: ubuntu-latest
76
77
  steps:
77
- - uses: actions/checkout@v6
78
+ - uses: actions/checkout@v7
78
79
  with:
79
80
  fetch-depth: 0
80
81
  ref: ${{ github.event.pull_request.head.sha }}
@@ -99,7 +100,7 @@ jobs:
99
100
  npm_pack:
100
101
  runs-on: ubuntu-latest
101
102
  steps:
102
- - uses: actions/checkout@v6
103
+ - uses: actions/checkout@v7
103
104
 
104
105
  - uses: actions/setup-node@v6
105
106
  with:
@@ -117,7 +118,7 @@ jobs:
117
118
  needs: pr_governance
118
119
  runs-on: ubuntu-latest
119
120
  steps:
120
- - uses: actions/checkout@v6
121
+ - uses: actions/checkout@v7
121
122
  with:
122
123
  fetch-depth: 0
123
124
  ref: ${{ github.event.pull_request.head.sha }}
@@ -136,7 +137,7 @@ jobs:
136
137
  needs: pr_governance
137
138
  runs-on: ubuntu-latest
138
139
  steps:
139
- - uses: actions/checkout@v6
140
+ - uses: actions/checkout@v7
140
141
  with:
141
142
  fetch-depth: 0
142
143
  ref: ${{ github.event.pull_request.head.sha }}
@@ -10,6 +10,7 @@
10
10
  { "targetPath": ".cursor/hooks/gxt-session-start.sh", "mode": "managed_strict", "executable": true, "tags": ["cursor"] },
11
11
  { "targetPath": ".cursor/mcp.json", "mode": "managed_strict", "tags": ["cursor"] },
12
12
  { "targetPath": ".cursor/rules/opengantry-gxt-substrate.mdc", "mode": "managed_strict", "tags": ["cursor"] },
13
+ { "targetPath": ".gitagent/config.json", "mode": "scaffold_only", "tags": ["core"] },
13
14
  { "targetPath": ".gitagent/foreman/BYPASS.sha256", "mode": "scaffold_only", "tags": ["core"] },
14
15
  { "targetPath": ".gitagent/foreman/MANIFEST.json", "mode": "scaffold_only", "tags": ["core"] },
15
16
  { "targetPath": ".gitagent/foreman/SUBSTRATE.version.json", "mode": "scaffold_only", "tags": ["core"] },
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "schema_version": "1",
3
- "opengantry_version": "2.2.2",
3
+ "opengantry_version": "2.2.3",
4
4
  "asset_catalog": "integrations/asset-catalog.json",
5
5
  "integrations": {
6
6
  "cursor": {
@@ -1,13 +1,18 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * Node-only MANIFEST helpers for validate-gxt.sh (no jq dependency).
3
+ * Node-only MANIFEST + trusted automation policy helpers for validate-gxt.sh (no jq).
4
4
  * Usage:
5
5
  * node scripts/gxt-manifest-lib.mjs prefixes [repoRoot]
6
6
  * node scripts/gxt-manifest-lib.mjs validate-manifest [repoRoot]
7
7
  * node scripts/gxt-manifest-lib.mjs validate-bypass-note (JSON on stdin)
8
+ * node scripts/gxt-manifest-lib.mjs eval-commit <repoRoot> <commitSha>
9
+ * node scripts/gxt-manifest-lib.mjs eval-range <repoRoot> <baseSha> <headSha>
8
10
  */
9
11
  import fs from "node:fs";
10
12
  import path from "node:path";
13
+ import { execFileSync } from "node:child_process";
14
+ import { createRequire } from "node:module";
15
+ import { fileURLToPath } from "node:url";
11
16
 
12
17
  const FIXED_MSN_PREFIXES = [
13
18
  ".gitagent/",
@@ -16,11 +21,38 @@ const FIXED_MSN_PREFIXES = [
16
21
  ".github/workflows/gxt-validate.yml",
17
22
  ];
18
23
 
24
+ const HARD_MAX_NET_LOC = 5;
25
+ const ALLOWED_STRUCTURAL_CHANGES = new Set(["workflow_version_pin"]);
26
+ const CONFIG_REL = ".gitagent/config.json";
27
+
19
28
  export function repoRootFromArg(arg) {
20
29
  if (arg?.trim()) return path.resolve(arg.trim());
21
30
  return process.cwd();
22
31
  }
23
32
 
33
+ function git(repoRoot, args) {
34
+ return execFileSync("git", args, { cwd: repoRoot, encoding: "utf8" }).trimEnd();
35
+ }
36
+
37
+ function normalizePath(p) {
38
+ return p.replace(/\\/g, "/");
39
+ }
40
+
41
+ function resolveYamlParser(repoRoot) {
42
+ const candidates = [
43
+ path.join(repoRoot, "node_modules/yaml"),
44
+ path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "node_modules/yaml"),
45
+ ];
46
+ for (const dir of candidates) {
47
+ const pkg = path.join(dir, "package.json");
48
+ if (fs.existsSync(pkg)) {
49
+ const req = createRequire(pkg);
50
+ return req("yaml");
51
+ }
52
+ }
53
+ throw new Error("gxt-manifest-lib: yaml package not found (run npm ci)");
54
+ }
55
+
24
56
  export function readManifest(repoRoot) {
25
57
  const rel = ".gitagent/foreman/MANIFEST.json";
26
58
  const abs = path.join(repoRoot, rel);
@@ -36,6 +68,81 @@ export function readManifest(repoRoot) {
36
68
  return parsed;
37
69
  }
38
70
 
71
+ export function loadTrustedAutomationRules(repoRoot) {
72
+ const abs = path.join(repoRoot, CONFIG_REL);
73
+ if (!fs.existsSync(abs)) {
74
+ return [];
75
+ }
76
+ let parsed;
77
+ try {
78
+ parsed = JSON.parse(fs.readFileSync(abs, "utf8"));
79
+ } catch (e) {
80
+ throw new Error(
81
+ `gxt-manifest-lib: invalid JSON in ${CONFIG_REL}: ${e instanceof Error ? e.message : String(e)}`,
82
+ );
83
+ }
84
+ const block = parsed?.trusted_automation;
85
+ if (!block) {
86
+ return [];
87
+ }
88
+ if (!Array.isArray(block.rules) || block.rules.length === 0) {
89
+ return [];
90
+ }
91
+ const rules = [];
92
+ for (const [i, rule] of block.rules.entries()) {
93
+ if (!rule || typeof rule !== "object") {
94
+ throw new Error(`gxt-manifest-lib: trusted_automation.rules[${i}] must be an object`);
95
+ }
96
+ if (typeof rule.id !== "string" || !rule.id.trim()) {
97
+ throw new Error(`gxt-manifest-lib: trusted_automation.rules[${i}] missing id`);
98
+ }
99
+ if (!Array.isArray(rule.allowed_actors) || rule.allowed_actors.length === 0) {
100
+ throw new Error(`gxt-manifest-lib: rule ${rule.id} requires non-empty allowed_actors`);
101
+ }
102
+ if (!Array.isArray(rule.allowed_paths) || rule.allowed_paths.length === 0) {
103
+ throw new Error(`gxt-manifest-lib: rule ${rule.id} requires non-empty allowed_paths`);
104
+ }
105
+ if (!Array.isArray(rule.allowed_structural_changes) || rule.allowed_structural_changes.length === 0) {
106
+ throw new Error(`gxt-manifest-lib: rule ${rule.id} requires allowed_structural_changes`);
107
+ }
108
+ for (const change of rule.allowed_structural_changes) {
109
+ if (!ALLOWED_STRUCTURAL_CHANGES.has(change)) {
110
+ throw new Error(`gxt-manifest-lib: rule ${rule.id} has unsupported structural change: ${change}`);
111
+ }
112
+ }
113
+ if (typeof rule.max_net_loc !== "number" || !Number.isInteger(rule.max_net_loc) || rule.max_net_loc < 1) {
114
+ throw new Error(`gxt-manifest-lib: rule ${rule.id} max_net_loc must be a positive integer`);
115
+ }
116
+ if (rule.max_net_loc > HARD_MAX_NET_LOC) {
117
+ throw new Error(`gxt-manifest-lib: rule ${rule.id} max_net_loc exceeds hard cap ${HARD_MAX_NET_LOC}`);
118
+ }
119
+ rules.push({
120
+ id: rule.id,
121
+ allowed_actors: rule.allowed_actors.map((a) => String(a).trim().toLowerCase()),
122
+ allowed_paths: rule.allowed_paths.map((p) => normalizePath(String(p))),
123
+ allowed_structural_changes: new Set(rule.allowed_structural_changes),
124
+ max_net_loc: rule.max_net_loc,
125
+ });
126
+ }
127
+ return rules;
128
+ }
129
+
130
+ export function matchGlob(pattern, filePath) {
131
+ const normalized = normalizePath(filePath);
132
+ const re = new RegExp(
133
+ `^${normalizePath(pattern)
134
+ .replace(/[.+^${}()|[\]\\]/g, "\\$&")
135
+ .replace(/\*\*/g, "<<<GLOBSTAR>>>")
136
+ .replace(/\*/g, "[^/]*")
137
+ .replace(/<<<GLOBSTAR>>>/g, ".*")}$`,
138
+ );
139
+ return re.test(normalized);
140
+ }
141
+
142
+ export function pathMatchesAllowed(filePath, allowedPaths) {
143
+ return allowedPaths.some((pattern) => matchGlob(pattern, filePath));
144
+ }
145
+
39
146
  export function listMsnEnforcedPrefixes(repoRoot) {
40
147
  const manifest = readManifest(repoRoot);
41
148
  const skills = manifest.skills;
@@ -49,11 +156,22 @@ export function listMsnEnforcedPrefixes(repoRoot) {
49
156
  const out = new Set();
50
157
  for (const p of [...FIXED_MSN_PREFIXES, ...roots]) {
51
158
  if (typeof p !== "string" || !p.trim()) continue;
52
- out.add(p.replace(/\\/g, "/"));
159
+ out.add(normalizePath(p));
53
160
  }
54
161
  return [...out];
55
162
  }
56
163
 
164
+ export function isMsnEnforcedPath(filePath, prefixes) {
165
+ const p = normalizePath(filePath);
166
+ for (const prefix of prefixes) {
167
+ if (!prefix) continue;
168
+ if (p === prefix) return true;
169
+ if (prefix.endsWith("/") && p.startsWith(prefix)) return true;
170
+ if (!prefix.endsWith("/") && p.startsWith(`${prefix}/`)) return true;
171
+ }
172
+ return false;
173
+ }
174
+
57
175
  export function validateManifestStructure(repoRoot) {
58
176
  const m = readManifest(repoRoot);
59
177
  if (typeof m.schema_version !== "string" || m.schema_version.length === 0) {
@@ -101,6 +219,274 @@ export function validateBypassNoteJson(raw) {
101
219
  );
102
220
  }
103
221
 
222
+ function parseNumstat(output) {
223
+ let total = 0;
224
+ for (const line of output.split("\n")) {
225
+ if (!line.trim()) continue;
226
+ const [adds, dels] = line.split("\t");
227
+ if (adds === "-" || dels === "-") continue;
228
+ total += Number(adds) + Number(dels);
229
+ }
230
+ return total;
231
+ }
232
+
233
+ function gitNumstat(repoRoot, args) {
234
+ try {
235
+ return parseNumstat(git(repoRoot, args));
236
+ } catch {
237
+ return 0;
238
+ }
239
+ }
240
+
241
+ function commitChangedFiles(repoRoot, commitSha) {
242
+ const out = git(repoRoot, ["diff-tree", "--no-commit-id", "--name-only", "-r", commitSha]);
243
+ return out ? out.split("\n").filter(Boolean).map(normalizePath) : [];
244
+ }
245
+
246
+ function rangeChangedFiles(repoRoot, baseSha, headSha) {
247
+ const out = git(repoRoot, ["diff", "--name-only", "--diff-filter=ACMRT", `${baseSha}...${headSha}`]);
248
+ return out ? out.split("\n").filter(Boolean).map(normalizePath) : [];
249
+ }
250
+
251
+ function commitAuthorEmail(repoRoot, commitSha) {
252
+ return git(repoRoot, ["log", "-1", "--format=%ae", commitSha]).toLowerCase();
253
+ }
254
+
255
+ function objectKeysEqual(a, b) {
256
+ if (Array.isArray(a) || Array.isArray(b)) {
257
+ return Array.isArray(a) && Array.isArray(b) && a.length === b.length;
258
+ }
259
+ if (a === null || b === null || typeof a !== "object" || typeof b !== "object") {
260
+ return typeof a === typeof b;
261
+ }
262
+ const keysA = Object.keys(a).sort();
263
+ const keysB = Object.keys(b).sort();
264
+ return keysA.length === keysB.length && keysA.every((k, i) => k === keysB[i]);
265
+ }
266
+
267
+ function collectStructuralDiffs(before, after, keyPath = []) {
268
+ const diffs = [];
269
+ if (Array.isArray(before) && Array.isArray(after)) {
270
+ for (let i = 0; i < before.length; i++) {
271
+ diffs.push(...collectStructuralDiffs(before[i], after[i], [...keyPath, String(i)]));
272
+ }
273
+ return diffs;
274
+ }
275
+ if (before !== null && after !== null && typeof before === "object" && typeof after === "object") {
276
+ const keys = Object.keys(before);
277
+ for (const key of keys) {
278
+ const childPath = [...keyPath, key];
279
+ const bv = before[key];
280
+ const av = after[key];
281
+ if (bv !== null && av !== null && typeof bv === "object" && typeof av === "object") {
282
+ diffs.push(...collectStructuralDiffs(bv, av, childPath));
283
+ } else if (bv !== av) {
284
+ diffs.push({ path: childPath, before: bv, after: av });
285
+ }
286
+ }
287
+ return diffs;
288
+ }
289
+ if (before !== after) {
290
+ diffs.push({ path: keyPath, before, after });
291
+ }
292
+ return diffs;
293
+ }
294
+
295
+ function isWorkflowVersionPinChange(before, after) {
296
+ if (typeof before !== "string" || typeof after !== "string") return false;
297
+ const usesRe = /^([^@]+@)(.+)$/;
298
+ const bm = before.match(usesRe);
299
+ const am = after.match(usesRe);
300
+ return Boolean(bm && am && bm[1] === am[1] && bm[2] !== am[2]);
301
+ }
302
+
303
+ function structuresMatch(before, after) {
304
+ if (Array.isArray(before) && Array.isArray(after)) {
305
+ if (before.length !== after.length) return false;
306
+ return before.every((item, i) => structuresMatch(item, after[i]));
307
+ }
308
+ if (before !== null && after !== null && typeof before === "object" && typeof after === "object") {
309
+ if (!objectKeysEqual(before, after)) return false;
310
+ return Object.keys(before).every((k) => structuresMatch(before[k], after[k]));
311
+ }
312
+ return true;
313
+ }
314
+
315
+ function workflowVersionPinOnly(beforeContent, afterContent, yaml) {
316
+ let beforeDoc;
317
+ let afterDoc;
318
+ try {
319
+ beforeDoc = yaml.parse(beforeContent);
320
+ afterDoc = yaml.parse(afterContent);
321
+ } catch {
322
+ return false;
323
+ }
324
+ if (!structuresMatch(beforeDoc, afterDoc)) {
325
+ return false;
326
+ }
327
+ const diffs = collectStructuralDiffs(beforeDoc, afterDoc);
328
+ if (diffs.length === 0) return true;
329
+ return diffs.every((d) => {
330
+ const lastKey = d.path[d.path.length - 1];
331
+ return lastKey === "uses" && isWorkflowVersionPinChange(d.before, d.after);
332
+ });
333
+ }
334
+
335
+ function readBlobAt(repoRoot, commitSha, filePath) {
336
+ try {
337
+ return git(repoRoot, ["show", `${commitSha}:${filePath}`]);
338
+ } catch {
339
+ return null;
340
+ }
341
+ }
342
+
343
+ function validateStructuralChanges(repoRoot, files, rule, baseSha, headSha, mode) {
344
+ if (!rule.allowed_structural_changes.has("workflow_version_pin")) {
345
+ return { ok: false, reason: `rule ${rule.id}: no matching structural change policy` };
346
+ }
347
+ const yaml = resolveYamlParser(repoRoot);
348
+ const workflowFiles = files.filter((f) => /\.ya?ml$/i.test(f));
349
+ for (const file of workflowFiles) {
350
+ let beforeContent;
351
+ let afterContent;
352
+ if (mode === "commit") {
353
+ const parent = git(repoRoot, ["rev-parse", `${headSha}^`]);
354
+ beforeContent = readBlobAt(repoRoot, parent, file);
355
+ afterContent = readBlobAt(repoRoot, headSha, file);
356
+ } else {
357
+ beforeContent = readBlobAt(repoRoot, baseSha, file);
358
+ afterContent = readBlobAt(repoRoot, headSha, file);
359
+ }
360
+ if (afterContent === null) continue;
361
+ if (beforeContent === null) {
362
+ return { ok: false, reason: `rule ${rule.id}: new file not allowed (${file})` };
363
+ }
364
+ if (!workflowVersionPinOnly(beforeContent, afterContent, yaml)) {
365
+ return { ok: false, reason: `rule ${rule.id}: structural change outside workflow_version_pin (${file})` };
366
+ }
367
+ }
368
+ return { ok: true, reason: "" };
369
+ }
370
+
371
+ function findMatchingRule(rules, actorEmail) {
372
+ const actor = actorEmail.toLowerCase();
373
+ return rules.filter((rule) => rule.allowed_actors.includes(actor));
374
+ }
375
+
376
+ function evaluateAgainstRule(repoRoot, rule, files, actorEmail, netLoc, structuralCtx) {
377
+ if (!rule.allowed_actors.includes(actorEmail.toLowerCase())) {
378
+ return { eligible: false, reason: `actor ${actorEmail} not in rule ${rule.id} allowed_actors` };
379
+ }
380
+ if (files.length === 0) {
381
+ return { eligible: false, reason: "no changed files" };
382
+ }
383
+ for (const file of files) {
384
+ if (!pathMatchesAllowed(file, rule.allowed_paths)) {
385
+ return { eligible: false, reason: `path ${file} outside rule ${rule.id} allowed_paths` };
386
+ }
387
+ }
388
+ if (netLoc > rule.max_net_loc) {
389
+ return {
390
+ eligible: false,
391
+ reason: `net_loc ${netLoc} exceeds rule ${rule.id} max_net_loc ${rule.max_net_loc}`,
392
+ };
393
+ }
394
+ const structural = validateStructuralChanges(
395
+ repoRoot,
396
+ files,
397
+ rule,
398
+ structuralCtx.baseSha,
399
+ structuralCtx.headSha,
400
+ structuralCtx.mode,
401
+ );
402
+ if (!structural.ok) {
403
+ return { eligible: false, reason: structural.reason };
404
+ }
405
+ return {
406
+ eligible: true,
407
+ reason: `TRUSTED-AUTOMATION-OK: rule=${rule.id} actor=${actorEmail} paths=${files.join(",")} net_loc=${netLoc}`,
408
+ };
409
+ }
410
+
411
+ /**
412
+ * Evaluate a single commit for trusted automation eligibility.
413
+ * @returns {{ eligible: boolean, reason: string }}
414
+ */
415
+ export function evaluateTrustedAutomationCommit(repoRoot, commitSha) {
416
+ const rules = loadTrustedAutomationRules(repoRoot);
417
+ if (rules.length === 0) {
418
+ return { eligible: false, reason: "no trusted_automation policy configured" };
419
+ }
420
+ const prefixes = listMsnEnforcedPrefixes(repoRoot);
421
+ const files = commitChangedFiles(repoRoot, commitSha).filter((f) => isMsnEnforcedPath(f, prefixes));
422
+ if (files.length === 0) {
423
+ return { eligible: false, reason: "commit does not touch MSN-enforced paths" };
424
+ }
425
+ const actorEmail = commitAuthorEmail(repoRoot, commitSha);
426
+ const matchingRules = findMatchingRule(rules, actorEmail);
427
+ if (matchingRules.length === 0) {
428
+ return { eligible: false, reason: `actor ${actorEmail} not covered by trusted_automation policy` };
429
+ }
430
+ const netLoc = gitNumstat(repoRoot, ["show", "--numstat", "--format=", commitSha]);
431
+ let lastReason = `commit ${commitSha} failed trusted_automation policy checks`;
432
+ for (const rule of matchingRules) {
433
+ const result = evaluateAgainstRule(repoRoot, rule, files, actorEmail, netLoc, {
434
+ mode: "commit",
435
+ baseSha: null,
436
+ headSha: commitSha,
437
+ });
438
+ if (result.eligible) return result;
439
+ lastReason = result.reason;
440
+ }
441
+ return { eligible: false, reason: lastReason };
442
+ }
443
+
444
+ /**
445
+ * Evaluate a PR commit range for trusted automation eligibility.
446
+ * @returns {{ eligible: boolean, reason: string }}
447
+ */
448
+ export function evaluateTrustedAutomationRange(repoRoot, baseSha, headSha) {
449
+ const rules = loadTrustedAutomationRules(repoRoot);
450
+ if (rules.length === 0) {
451
+ return { eligible: false, reason: "no trusted_automation policy configured" };
452
+ }
453
+ const prefixes = listMsnEnforcedPrefixes(repoRoot);
454
+ const allFiles = rangeChangedFiles(repoRoot, baseSha, headSha);
455
+ const enforcedFiles = allFiles.filter((f) => isMsnEnforcedPath(f, prefixes));
456
+ if (enforcedFiles.length === 0) {
457
+ return { eligible: false, reason: "range does not touch MSN-enforced paths" };
458
+ }
459
+ const commits = git(repoRoot, ["rev-list", "--no-merges", `${baseSha}..${headSha}`])
460
+ .split("\n")
461
+ .filter(Boolean);
462
+ for (const commit of commits) {
463
+ const files = commitChangedFiles(repoRoot, commit).filter((f) => isMsnEnforcedPath(f, prefixes));
464
+ if (files.length === 0) continue;
465
+ const actorEmail = commitAuthorEmail(repoRoot, commit);
466
+ const commitResult = evaluateTrustedAutomationCommit(repoRoot, commit);
467
+ if (!commitResult.eligible) {
468
+ return {
469
+ eligible: false,
470
+ reason: `commit ${commit.slice(0, 7)} failed: ${commitResult.reason}`,
471
+ };
472
+ }
473
+ }
474
+ const netLoc = gitNumstat(repoRoot, ["diff", "--numstat", `${baseSha}...${headSha}`]);
475
+ const actorEmail = commits.length > 0 ? commitAuthorEmail(repoRoot, commits[commits.length - 1]) : "";
476
+ const matchingRules = findMatchingRule(rules, actorEmail);
477
+ let lastReason = "PR diff failed trusted_automation policy checks";
478
+ for (const rule of matchingRules) {
479
+ const result = evaluateAgainstRule(repoRoot, rule, enforcedFiles, actorEmail, netLoc, {
480
+ mode: "range",
481
+ baseSha,
482
+ headSha,
483
+ });
484
+ if (result.eligible) return result;
485
+ lastReason = result.reason;
486
+ }
487
+ return { eligible: false, reason: lastReason };
488
+ }
489
+
104
490
  function main() {
105
491
  const cmd = process.argv[2];
106
492
  const repoRoot = repoRootFromArg(process.argv[3]);
@@ -122,9 +508,38 @@ function main() {
122
508
  const stdin = fs.readFileSync(0, "utf8");
123
509
  process.exit(validateBypassNoteJson(stdin) ? 0 : 1);
124
510
  }
511
+ case "eval-commit": {
512
+ const commitSha = process.argv[4];
513
+ if (!commitSha) {
514
+ console.error("Usage: gxt-manifest-lib.mjs eval-commit <repoRoot> <commitSha>");
515
+ process.exit(2);
516
+ }
517
+ const result = evaluateTrustedAutomationCommit(repoRoot, commitSha);
518
+ if (result.eligible) {
519
+ console.error(result.reason);
520
+ process.exit(0);
521
+ }
522
+ console.error(`TRUSTED-AUTOMATION-DENY: ${result.reason}`);
523
+ process.exit(1);
524
+ }
525
+ case "eval-range": {
526
+ const baseSha = process.argv[4];
527
+ const headSha = process.argv[5];
528
+ if (!baseSha || !headSha) {
529
+ console.error("Usage: gxt-manifest-lib.mjs eval-range <repoRoot> <baseSha> <headSha>");
530
+ process.exit(2);
531
+ }
532
+ const result = evaluateTrustedAutomationRange(repoRoot, baseSha, headSha);
533
+ if (result.eligible) {
534
+ console.error(result.reason);
535
+ process.exit(0);
536
+ }
537
+ console.error(`TRUSTED-AUTOMATION-DENY: ${result.reason}`);
538
+ process.exit(1);
539
+ }
125
540
  default:
126
541
  console.error(
127
- "Usage: gxt-manifest-lib.mjs <prefixes|validate-manifest|validate-bypass-note> [repoRoot]",
542
+ "Usage: gxt-manifest-lib.mjs <prefixes|validate-manifest|validate-bypass-note|eval-commit|eval-range> [args]",
128
543
  );
129
544
  process.exit(2);
130
545
  }
@@ -120,6 +120,10 @@ cmd_msn() {
120
120
  if commit_has_gxt_bypass_note "$commit"; then
121
121
  continue
122
122
  fi
123
+ if eval_out="$(node "$GXT_MANIFEST_LIB" eval-commit "$ROOT" "$commit" 2>&1)"; then
124
+ echo "$eval_out" >&2
125
+ continue
126
+ fi
123
127
  echo "MSN check FAILED: commit $commit touches MSN-enforced paths but subject does not start with [MSN-NNNN] and has no gxt-bypass git note" >&2
124
128
  echo " subject: $subject" >&2
125
129
  echo " hint: run gapman verify --break-glass --reason \"...\" with GXT_BYPASS_SECRET, push refs/notes/gxt-bypass" >&2
@@ -109,8 +109,14 @@ mapfile -t CHANGED_MISSIONS < <(
109
109
  )
110
110
 
111
111
  if [[ "$needs_mission" -eq 1 && ${#CHANGED_MISSIONS[@]} -eq 0 ]]; then
112
+ if eval_out="$(node "$GXT_MANIFEST_LIB" eval-range "$ROOT" "$BASE_SHA" "$HEAD_SHA" 2>&1)"; then
113
+ echo "$eval_out" >&2
114
+ echo "verify-pr-missions: trusted automation policy satisfied — mission file not required"
115
+ exit 0
116
+ fi
112
117
  echo "verify-pr-missions FAILED: diff touches MSN-enforced paths but no mission file under ${MISSIONS_PREFIX}" >&2
113
118
  echo " Fix: gapman legislate \"<intent>\" --msn MSN-NNNN --skill-key gapman && include mission YAML in this PR" >&2
119
+ echo " Or: declare eligible automation in .gitagent/config.json trusted_automation (fail-closed by default)" >&2
114
120
  exit 1
115
121
  fi
116
122