@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 +3 -2
- package/dist/cli/lib/version.gen.d.ts +1 -1
- package/dist/cli/lib/version.gen.js +1 -1
- package/package.json +1 -1
- package/templates/.gitagent/config.json +15 -0
- package/templates/.github/workflows/gxt-validate.yml +7 -6
- package/templates/integrations/asset-catalog.json +1 -0
- package/templates/integrations/compatibility.json +1 -1
- package/templates/scripts/gxt-manifest-lib.mjs +418 -3
- package/templates/scripts/validate-gxt.sh +4 -0
- package/templates/scripts/verify-pr-missions.sh +6 -0
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.
|
|
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.
|
|
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)) |
|
package/package.json
CHANGED
|
@@ -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)
|
|
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@
|
|
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@
|
|
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@
|
|
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@
|
|
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@
|
|
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,13 +1,18 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
* Node-only MANIFEST helpers for validate-gxt.sh (no jq
|
|
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
|
|
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> [
|
|
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
|
|