@amsterdamdatalabs/adl-agent-control-linux-x64 0.1.7 → 0.1.9
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/bin/adl +2 -2
- package/bin/git +89 -8
- package/bin/lean-ctx-observe-strip +76 -3
- package/bin/reconcile +175 -5
- package/bin/validate-manifest +19 -1
- package/package.json +1 -1
- package/packages/agent-hooks/src/deploy/deploy.ts +98 -18
- package/packages/agent-hooks/src/deploy/doctor.ts +61 -11
- package/packages/agent-hooks/src/deploy/json-merge.ts +430 -18
- package/packages/agent-hooks/src/deploy/lock.ts +36 -2
- package/packages/agent-hooks/src/lean-ctx/env.ts +17 -0
- package/packages/agent-hooks/src/lean-ctx/mcp.ts +4 -2
- package/packages/agent-settings/assets/lean-ctx/config.toml +3 -0
- package/packages/agent-settings/assets/lean-ctx/gitignore +4 -0
- package/packages/agent-settings/assets/lean-ctx/layout.toml +2 -0
- package/packages/agent-settings/src/deploy/agents-deploy.ts +10 -5
- package/packages/agent-settings/src/deploy/doctor.ts +39 -3
- package/packages/agent-settings/src/deploy/lean-ctx-deploy.ts +104 -12
- package/packages/agent-settings/src/deploy/lean-ctx-merge.ts +580 -0
- package/packages/agent-settings/src/deploy/lock.ts +4 -1
- package/packages/agent-settings/src/deploy/mcp-deploy.ts +9 -6
- package/packages/agent-settings/src/deploy/settings.ts +6 -3
- package/packages/agent-skills/dist/deploy.d.ts +2 -0
- package/packages/agent-skills/dist/deploy.d.ts.map +1 -1
- package/packages/agent-skills/dist/deploy.js +15 -8
- package/packages/agent-skills/dist/deploy.js.map +1 -1
- package/packages/agent-skills/dist/manifest.d.ts +1 -0
- package/packages/agent-skills/dist/manifest.d.ts.map +1 -1
- package/packages/agent-skills/dist/manifest.js +6 -2
- package/packages/agent-skills/dist/manifest.js.map +1 -1
- package/packages/agent-skills/src/deploy.ts +15 -8
- package/packages/agent-skills/src/manifest.ts +5 -2
- package/packages/azdo/src/azdo-repo-doctor.ts +412 -95
- package/packages/azdo/src/lib/graph-identity.ts +190 -3
- package/packages/fleet/jobs/git-maintain/src/main.ts +305 -45
- package/packages/fleet/manifest.json +2 -0
- package/packages/fleet/manifest.schema.json +9 -0
- package/packages/repo-controls/README.md +13 -6
- package/packages/repo-controls/assets/adl-config.default.toml +1 -0
- package/packages/repo-controls/assets/version-bump.sh +6 -1
- package/packages/repo-controls/src/cli.ts +6 -1
- package/packages/repo-controls/src/install.ts +88 -6
|
@@ -31,10 +31,32 @@
|
|
|
31
31
|
* values below are the same ones already read live and recorded in
|
|
32
32
|
* ADL-ISMS-Policies/docs/scripts/enact-agent-git-permissions.py and
|
|
33
33
|
* .../docs/evidence/vanta-documents/100-.../azure-devops-git-security-namespace.json)
|
|
34
|
-
* - Graph — Subject Query (resolve
|
|
34
|
+
* - Graph — Subject Query (resolve agent-tarun/agent-vivek/agent-reviewer's
|
|
35
|
+
* descriptor by display name — each is an Azure AD SERVICE PRINCIPAL
|
|
36
|
+
* ("aadsp") subject, verified live 2026-09-30; subjectKind must include
|
|
37
|
+
* 'ServicePrincipal' or the query returns no match at all, which is
|
|
38
|
+
* exactly what the permissions.<agent>.not-found drift below reported
|
|
39
|
+
* before this fix)
|
|
35
40
|
* POST https://vssps.dev.azure.com/{org}/_apis/graph/subjectquery?api-version=7.1-preview.1
|
|
36
|
-
* body: { query: <displayName>, subjectKind: ['User'] }
|
|
41
|
+
* body: { query: <displayName>, subjectKind: ['User', 'ServicePrincipal'] }
|
|
37
42
|
* https://learn.microsoft.com/en-us/rest/api/azure/devops/graph/subject-query/query
|
|
43
|
+
* - Identities — Read Identities, `subjectDescriptors` + `queryMembership=ExpandedUp`
|
|
44
|
+
* (an agent's own LEGACY descriptor — verified live 2026-09-30:
|
|
45
|
+
* agent-tarun/agent-vivek/agent-reviewer hold NO direct ACE anywhere on
|
|
46
|
+
* adl-agent-control or agent-identities; their real Contribute /
|
|
47
|
+
* Contribute-to-pull-requests bits come entirely from group membership,
|
|
48
|
+
* which Access Control Lists Query below can only expand server-side
|
|
49
|
+
* when given a LEGACY descriptor, never a raw Graph one — and that
|
|
50
|
+
* server-side expansion already correctly combines group membership
|
|
51
|
+
* WITH token-level inheritance, which is why this checker queries the
|
|
52
|
+
* ACL with ONLY the agent's own legacy descriptor and trusts its
|
|
53
|
+
* `effectiveAllow`/`effectiveDeny` directly, never a client-side union
|
|
54
|
+
* across separately-queried group descriptors — see
|
|
55
|
+
* lib/graph-identity.ts's `resolveLegacyDescriptor()` doc comment,
|
|
56
|
+
* "SECOND REVIEW", for the full live evidence and why an earlier
|
|
57
|
+
* per-group-union attempt was wrong.)
|
|
58
|
+
* GET https://vssps.dev.azure.com/{org}/_apis/identities?subjectDescriptors=<descriptor>&queryMembership=ExpandedUp&api-version=7.1
|
|
59
|
+
* https://learn.microsoft.com/en-us/rest/api/azure/devops/ims/identities/read-identities
|
|
38
60
|
* - Identities — Read Identities (resolve a BUILD SERVICE's descriptor by
|
|
39
61
|
* exact display name — a build service is not a Graph 'User'/'Group'
|
|
40
62
|
* subject, so Subject Query above cannot find it; used only by
|
|
@@ -81,7 +103,12 @@
|
|
|
81
103
|
|
|
82
104
|
import { resolveAgentIdentity } from "./lib/agent-identity.ts";
|
|
83
105
|
import { AdoClient, resolveOrg, resolveProject, resolveRepository } from "./lib/client.ts";
|
|
84
|
-
import {
|
|
106
|
+
import {
|
|
107
|
+
fetchJson,
|
|
108
|
+
isProjectBuildServiceDescriptor,
|
|
109
|
+
resolveAgentLegacyDescriptor,
|
|
110
|
+
resolveIdentityByDisplayName,
|
|
111
|
+
} from "./lib/graph-identity.ts";
|
|
85
112
|
|
|
86
113
|
/** Treat blank and whitespace-only env overrides as unset, like lib/client.ts's own `configured()`. */
|
|
87
114
|
function configured(value: string | undefined): string | undefined {
|
|
@@ -95,9 +122,10 @@ function orgBaseUrl(): string {
|
|
|
95
122
|
}
|
|
96
123
|
|
|
97
124
|
// fetchJson/postJson (bearer-token GET/POST against an arbitrary Azure DevOps
|
|
98
|
-
// host) and
|
|
99
|
-
// ./lib/graph-identity.ts — this file only adds its own
|
|
100
|
-
// accesscontrollists host); vsspsBaseUrl() moved with the
|
|
125
|
+
// host) and resolveAgentLegacyDescriptor (Graph Subject Query + Identities —
|
|
126
|
+
// ExpandedUp) live in ./lib/graph-identity.ts — this file only adds its own
|
|
127
|
+
// orgBaseUrl() (the accesscontrollists host); vsspsBaseUrl() moved with the
|
|
128
|
+
// Graph helpers.
|
|
101
129
|
|
|
102
130
|
// -------------------------------------------------------------------------
|
|
103
131
|
// The standard, in one place.
|
|
@@ -157,6 +185,18 @@ export const REPO_STANDARD = {
|
|
|
157
185
|
// repeats a bit name or a token shape in two places.
|
|
158
186
|
permissions: {
|
|
159
187
|
namespaceId: GIT_REPOSITORIES_NAMESPACE_ID,
|
|
188
|
+
/**
|
|
189
|
+
* Tarun, 2026-09-30: "leave [agent-reviewer's] permissions as they are
|
|
190
|
+
* and stop flagging it outside Enact." agent-reviewer's own
|
|
191
|
+
* `genericContribute` DENY check below (the Reader-only / no-Contribute
|
|
192
|
+
* rule) is scoped to the Enact project only — the SAME
|
|
193
|
+
* `STRICT_MAIN_BRANCH_PROJECT` mechanism (`repoInfo.project.name`,
|
|
194
|
+
* case-insensitive) the main-branch rules already use, enforced in
|
|
195
|
+
* `checkAgentBits()`'s own `ENACT_ONLY_AGENT_DENY` check just below.
|
|
196
|
+
* Every OTHER agent-permission check here — agent-tarun's and
|
|
197
|
+
* agent-vivek's full allow/deny sets, and agent-reviewer's own required
|
|
198
|
+
* `pullRequestContribute` — stays unconditional, for every project.
|
|
199
|
+
*/
|
|
160
200
|
agents: {
|
|
161
201
|
"agent-tarun": {
|
|
162
202
|
allow: ["genericContribute", "pullRequestContribute"],
|
|
@@ -166,7 +206,9 @@ export const REPO_STANDARD = {
|
|
|
166
206
|
allow: ["genericContribute", "pullRequestContribute"],
|
|
167
207
|
deny: ["forcePush", "policyExempt", "pullRequestBypassPolicy"],
|
|
168
208
|
},
|
|
169
|
-
|
|
209
|
+
// Tarun, 2026-09-30: no agent gets force push — agent-reviewer included,
|
|
210
|
+
// in every project (only its genericContribute deny is Enact-only).
|
|
211
|
+
"agent-reviewer": { allow: ["pullRequestContribute"], deny: ["genericContribute", "forcePush"] },
|
|
170
212
|
} as Record<string, { allow: readonly PermissionBitName[]; deny: readonly PermissionBitName[] }>,
|
|
171
213
|
/**
|
|
172
214
|
* The project's build service identity (display name `Build\<projectId>`,
|
|
@@ -174,12 +216,17 @@ export const REPO_STANDARD = {
|
|
|
174
216
|
* — Tarun, 2026-09-27, cross-checked against
|
|
175
217
|
* /Users/tarunrana/Documents/ADL-ISMS-Policies/.scratchpad/ci_build_service_grant.py,
|
|
176
218
|
* which grants exactly these bits on adl-agent-control). CI needs to push
|
|
177
|
-
*
|
|
178
|
-
*
|
|
219
|
+
* and complete PRs on the repo, and needs PolicyExempt only on
|
|
220
|
+
* `integration` (so a version-bump commit made by CI itself is not
|
|
179
221
|
* blocked by its own policies) — never on `main`.
|
|
222
|
+
*
|
|
223
|
+
* NOT forcePush (Tarun, 2026-09-30): the bump is a plain fast-forward
|
|
224
|
+
* `git push origin HEAD:integration`; agent-identities' build service has
|
|
225
|
+
* no ForcePush and bumped 0.4.2 → 0.4.3 fine. The grant script's
|
|
226
|
+
* ForcePush has no consumer, so force push is required of no identity.
|
|
180
227
|
*/
|
|
181
228
|
buildService: {
|
|
182
|
-
repo: { allow: ["genericContribute", "
|
|
229
|
+
repo: { allow: ["genericContribute", "pullRequestContribute"] } as {
|
|
183
230
|
allow: readonly PermissionBitName[];
|
|
184
231
|
},
|
|
185
232
|
integrationRef: { allow: ["policyExempt"] } as { allow: readonly PermissionBitName[] },
|
|
@@ -187,12 +234,28 @@ export const REPO_STANDARD = {
|
|
|
187
234
|
},
|
|
188
235
|
/**
|
|
189
236
|
* checkBuildServiceCanRelease() (file header, "checkBuildServiceCanRelease()"):
|
|
190
|
-
* the exact three
|
|
191
|
-
*
|
|
192
|
-
*
|
|
237
|
+
* the exact three permissions the release flow needs from whichever
|
|
238
|
+
* build service identity actually runs the pipeline — the real failure
|
|
239
|
+
* this check exists for was a 403 on `pullRequestContribute`.
|
|
240
|
+
*
|
|
241
|
+
* `policyExempt` ("Bypass policies when pushing") is checked at the
|
|
242
|
+
* `integration` REF token, never the repo-scope token — verified live
|
|
243
|
+
* 2026-09-30 against adl-agent-control: the Infrastructure-AzDo Build
|
|
244
|
+
* Service demonstrably bypassed `integration`'s own policies to push
|
|
245
|
+
* `chore: bump version 0.1.6 to 0.1.7 ***NO_CI***` (c966e6a) straight to
|
|
246
|
+
* that protected branch, yet its repo-scope effective allow bitmask
|
|
247
|
+
* (16430) does not include PolicyExempt (128) at all — only its
|
|
248
|
+
* `refs/heads/integration`-scope effective allow (16558) does. Checking
|
|
249
|
+
* `policyExempt` at repo scope produced a false
|
|
250
|
+
* `permissions.release.<slug>.missing-policyExempt` finding that
|
|
251
|
+
* contradicted this observed reality. `genericContribute` and
|
|
252
|
+
* `pullRequestContribute` stay checked at repo scope — both bits ARE
|
|
253
|
+
* present there for real, and PR operations are a repo-level, not
|
|
254
|
+
* branch-level, permission.
|
|
193
255
|
*/
|
|
194
256
|
buildServiceRelease: {
|
|
195
|
-
|
|
257
|
+
repoScoped: ["genericContribute", "pullRequestContribute"] as const,
|
|
258
|
+
integrationRefScoped: ["policyExempt"] as const,
|
|
196
259
|
},
|
|
197
260
|
},
|
|
198
261
|
} as const;
|
|
@@ -219,7 +282,7 @@ export const GIT_REPO_PERMISSION_UI_LABEL: Record<PermissionBitName, string> = {
|
|
|
219
282
|
interface GitRepositoryResponse {
|
|
220
283
|
id: string;
|
|
221
284
|
defaultBranch?: string;
|
|
222
|
-
project?: { id?: string };
|
|
285
|
+
project?: { id?: string; name?: string };
|
|
223
286
|
}
|
|
224
287
|
|
|
225
288
|
interface GitRefsResponse {
|
|
@@ -265,6 +328,39 @@ interface AccessControlListsResponse {
|
|
|
265
328
|
}>;
|
|
266
329
|
}
|
|
267
330
|
|
|
331
|
+
/**
|
|
332
|
+
* Tarun, 2026-09-30: the strict main-branch rules below — the build service
|
|
333
|
+
* must not have PolicyExempt (bypass) on refs/heads/main
|
|
334
|
+
* (`permissions.build-service.has-policyExempt-main`) and main's
|
|
335
|
+
* minimum-reviewer policy must have creatorVoteCounts=false
|
|
336
|
+
* (`main.minimum-reviewers-misconfigured: creatorVoteCounts must be false`)
|
|
337
|
+
* — apply ONLY to repositories in the Azure DevOps project "Enact".
|
|
338
|
+
* Repositories in "Infrastructure-AzDo" (the small internal tool repos:
|
|
339
|
+
* adl-agent-control, agent-identities, …) keep their existing settings and
|
|
340
|
+
* must not be flagged for these two; every other check in this file stays
|
|
341
|
+
* exactly as is, for every project. Real evidence this fix responds to:
|
|
342
|
+
* `adl repo doctor --repo agent-identities` (Infrastructure-AzDo) reported
|
|
343
|
+
* both findings before this change.
|
|
344
|
+
*
|
|
345
|
+
* Scoped from the REPOSITORY'S OWN project, as Git Repositories — Get
|
|
346
|
+
* Repository's response names it (`repoInfo.project.name`) — NEVER
|
|
347
|
+
* `resolveProject()`/cwd/env (independent review, 2026-09-30: cwd/env name
|
|
348
|
+
* the project the CALLING checkout happens to sit in, which can differ from
|
|
349
|
+
* the project the repository being CHECKED actually lives in; the doctor
|
|
350
|
+
* already resolves the repository by name against whichever project cwd/env
|
|
351
|
+
* picked, but the compliance decision must follow the checked repository,
|
|
352
|
+
* not the caller's own working directory). Compared case-insensitively,
|
|
353
|
+
* since Azure DevOps project names are case-preserving but not
|
|
354
|
+
* case-sensitive for identity purposes. No environment variable controls
|
|
355
|
+
* this; a per-repository override, if one is ever genuinely needed, belongs
|
|
356
|
+
* in that repository's own adl-config.toml / manifest, never an env var.
|
|
357
|
+
*/
|
|
358
|
+
export const STRICT_MAIN_BRANCH_PROJECT = "Enact";
|
|
359
|
+
|
|
360
|
+
function strictMainBranchRulesApply(project: string): boolean {
|
|
361
|
+
return project.toLowerCase() === STRICT_MAIN_BRANCH_PROJECT.toLowerCase();
|
|
362
|
+
}
|
|
363
|
+
|
|
268
364
|
export interface RepoDoctorDrift {
|
|
269
365
|
id: string;
|
|
270
366
|
message: string;
|
|
@@ -367,7 +463,11 @@ function checkMainRequiredReviewer(policies: readonly PolicyConfiguration[], dri
|
|
|
367
463
|
});
|
|
368
464
|
}
|
|
369
465
|
|
|
370
|
-
function checkMainMinimumReviewers(
|
|
466
|
+
function checkMainMinimumReviewers(
|
|
467
|
+
policies: readonly PolicyConfiguration[],
|
|
468
|
+
strictMainBranchRules: boolean,
|
|
469
|
+
drifts: RepoDoctorDrift[],
|
|
470
|
+
): void {
|
|
371
471
|
const minReviewer = enabledPolicyOfType(policies, POLICY_TYPE.minimumReviewers);
|
|
372
472
|
if (!minReviewer) {
|
|
373
473
|
drifts.push({
|
|
@@ -382,7 +482,9 @@ function checkMainMinimumReviewers(policies: readonly PolicyConfiguration[], dri
|
|
|
382
482
|
if (minReviewer.isBlocking !== expected.isBlocking) problems.push(`isBlocking must be ${expected.isBlocking}`);
|
|
383
483
|
if ((settings.minimumApproverCount ?? 0) < expected.minimumApproverCount)
|
|
384
484
|
problems.push(`minimumApproverCount must be at least ${expected.minimumApproverCount}`);
|
|
385
|
-
|
|
485
|
+
// Enact-only (STRICT_MAIN_BRANCH_PROJECT above): a repo outside Enact keeps
|
|
486
|
+
// its own creatorVoteCounts setting, flagged neither way.
|
|
487
|
+
if (strictMainBranchRules && Boolean(settings.creatorVoteCounts) !== expected.creatorVoteCounts)
|
|
386
488
|
problems.push(`creatorVoteCounts must be ${expected.creatorVoteCounts}`);
|
|
387
489
|
if (Boolean(settings.resetOnSourcePush) !== expected.resetOnSourcePush)
|
|
388
490
|
problems.push(`resetOnSourcePush must be ${expected.resetOnSourcePush}`);
|
|
@@ -437,10 +539,11 @@ function checkMainCommentsAndBuild(
|
|
|
437
539
|
function checkMainPolicies(
|
|
438
540
|
policies: readonly PolicyConfiguration[],
|
|
439
541
|
hasPipeline: boolean,
|
|
542
|
+
strictMainBranchRules: boolean,
|
|
440
543
|
drifts: RepoDoctorDrift[],
|
|
441
544
|
): void {
|
|
442
545
|
checkMainRequiredReviewer(policies, drifts);
|
|
443
|
-
checkMainMinimumReviewers(policies, drifts);
|
|
546
|
+
checkMainMinimumReviewers(policies, strictMainBranchRules, drifts);
|
|
444
547
|
checkMainMergeStrategy(policies, drifts);
|
|
445
548
|
checkMainCommentsAndBuild(policies, hasPipeline, drifts);
|
|
446
549
|
}
|
|
@@ -462,50 +565,128 @@ async function fetchAccessControlLists(
|
|
|
462
565
|
return fetchJson<AccessControlListsResponse>(url, "Access control lists query");
|
|
463
566
|
}
|
|
464
567
|
|
|
465
|
-
|
|
568
|
+
/**
|
|
569
|
+
* Comfortably under every documented and empirically observed URL-length
|
|
570
|
+
* limit for this API (independent review, 2026-09-30: "keep the
|
|
571
|
+
* query-string size bounded" — an identity's transitive group-membership
|
|
572
|
+
* count is unbounded in principle, and three agents' descriptor sets get
|
|
573
|
+
* combined into one call). `fetchEffectivePermissions()` below chunks
|
|
574
|
+
* automatically rather than ever sending one unbounded `descriptors` list —
|
|
575
|
+
* never a silent truncation: a failed chunk's fetch throws, same as a
|
|
576
|
+
* failed single-descriptor call always did.
|
|
577
|
+
*/
|
|
578
|
+
export const MAX_DESCRIPTORS_PER_ACL_QUERY = 50;
|
|
579
|
+
|
|
580
|
+
function chunk<T>(items: readonly T[], size: number): T[][] {
|
|
581
|
+
const chunks: T[][] = [];
|
|
582
|
+
for (let i = 0; i < items.length; i += size) chunks.push(items.slice(i, i + size));
|
|
583
|
+
return chunks;
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
export interface EffectivePermissionsResult {
|
|
587
|
+
byDescriptor: Map<string, { allow: number; deny: number }>;
|
|
588
|
+
/**
|
|
589
|
+
* Requested descriptors that never ended up in `byDescriptor` — either
|
|
590
|
+
* ABSENT from every chunk's `acesDictionary` entirely, or present but with
|
|
591
|
+
* NO `extendedInfo` key at all (checked via `'extendedInfo' in ace`, which
|
|
592
|
+
* distinguishes "the key is not there" from "the key is `{}`"). Both mean
|
|
593
|
+
* the effective bits could not actually be confirmed for that descriptor
|
|
594
|
+
* at this token. An entry that HAS `extendedInfo`, even `extendedInfo: {}`
|
|
595
|
+
* (a real, normal shape: the server computed it and every field happened
|
|
596
|
+
* to be zero/omitted), is NOT unresolved — it lands in `byDescriptor` with
|
|
597
|
+
* allow/deny defaulted to 0 exactly as before (independent review,
|
|
598
|
+
* 2026-09-30, LOW: "an entry that HAS extendedInfo with zero-valued
|
|
599
|
+
* fields omitted is normal and stays as today"). The caller MUST check
|
|
600
|
+
* this list and raise its own loud, distinct finding naming the identity
|
|
601
|
+
* and the token — never silently default an unresolved descriptor to
|
|
602
|
+
* `{allow: 0, deny: 0}`, which previously made a real "cannot confirm"
|
|
603
|
+
* look identical to "confirmed: nothing granted" (e.g. the Enact
|
|
604
|
+
* build-service refs/heads/main PolicyExempt DENY check silently PASSED
|
|
605
|
+
* when its own ACL entry was missing, instead of failing loudly).
|
|
606
|
+
*/
|
|
607
|
+
unresolved: string[];
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
/** Exported for testing: proves the chunking guard above merges every chunk's result, including the last one, rather than sending — or silently dropping — one unbounded query. */
|
|
611
|
+
export async function fetchEffectivePermissions(
|
|
466
612
|
namespaceId: string,
|
|
467
613
|
token: string,
|
|
468
614
|
descriptors: readonly string[],
|
|
469
|
-
): Promise<
|
|
615
|
+
): Promise<EffectivePermissionsResult> {
|
|
470
616
|
const byDescriptor = new Map<string, { allow: number; deny: number }>();
|
|
471
|
-
if (descriptors.length === 0) return byDescriptor;
|
|
472
|
-
const
|
|
473
|
-
|
|
474
|
-
for (const
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
617
|
+
if (descriptors.length === 0) return { byDescriptor, unresolved: [] };
|
|
618
|
+
for (const batch of chunk(descriptors, MAX_DESCRIPTORS_PER_ACL_QUERY)) {
|
|
619
|
+
const result = await fetchAccessControlLists(namespaceId, token, batch);
|
|
620
|
+
for (const acl of result.value ?? []) {
|
|
621
|
+
for (const [descriptor, ace] of Object.entries(acl.acesDictionary ?? {})) {
|
|
622
|
+
if (!("extendedInfo" in ace)) continue; // see EffectivePermissionsResult.unresolved's doc comment
|
|
623
|
+
byDescriptor.set(descriptor, {
|
|
624
|
+
allow: ace.extendedInfo?.effectiveAllow ?? ace.allow ?? 0,
|
|
625
|
+
deny: ace.extendedInfo?.effectiveDeny ?? ace.deny ?? 0,
|
|
626
|
+
});
|
|
627
|
+
}
|
|
479
628
|
}
|
|
480
629
|
}
|
|
481
|
-
|
|
630
|
+
const unresolved = descriptors.filter((descriptor) => !byDescriptor.has(descriptor));
|
|
631
|
+
return { byDescriptor, unresolved };
|
|
482
632
|
}
|
|
483
633
|
|
|
484
|
-
/**
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
634
|
+
/**
|
|
635
|
+
* Look up `descriptor`'s effective ACE in `effective`, or push a loud,
|
|
636
|
+
* distinct `permissions.<idFragment>.acl-entry-missing<idSuffix>` finding
|
|
637
|
+
* naming `description` and `token` and return undefined — the caller must
|
|
638
|
+
* then skip whatever bit checks it was about to run for this identity/token
|
|
639
|
+
* pair, never fall back to `{allow: 0, deny: 0}` (see
|
|
640
|
+
* `EffectivePermissionsResult.unresolved`'s doc comment for why that
|
|
641
|
+
* silently hid real forbidden-bit findings).
|
|
642
|
+
*/
|
|
643
|
+
function resolveEffectiveAce(
|
|
644
|
+
effective: EffectivePermissionsResult,
|
|
645
|
+
descriptor: string,
|
|
646
|
+
idFragment: string,
|
|
647
|
+
idSuffix: string,
|
|
648
|
+
description: string,
|
|
649
|
+
tokenLabel: string,
|
|
650
|
+
drifts: RepoDoctorDrift[],
|
|
651
|
+
): { allow: number; deny: number } | undefined {
|
|
652
|
+
const ace = effective.byDescriptor.get(descriptor);
|
|
653
|
+
if (ace) return ace;
|
|
654
|
+
drifts.push({
|
|
655
|
+
id: `permissions.${idFragment}.acl-entry-missing${idSuffix}`,
|
|
656
|
+
message: `could not confirm ${description}'s effective permissions on ${tokenLabel} — its Access Control Lists Query entry was missing from the response (or had no extendedInfo at all), never treated as "no permission"`,
|
|
657
|
+
});
|
|
658
|
+
return undefined;
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
/**
|
|
662
|
+
* `ace.allow` bits with `ace.deny` bits removed — Azure DevOps: an explicit
|
|
663
|
+
* Deny always beats an Allow FOR THE SAME EFFECTIVE RESULT. `ace` here is
|
|
664
|
+
* always the SERVER's own already-combined `effectiveAllow`/`effectiveDeny`
|
|
665
|
+
* for ONE descriptor at ONE token (see `checkAgentPermissions()`'s doc
|
|
666
|
+
* comment for why this file never reconstructs that combination itself from
|
|
667
|
+
* several descriptors — the server already does it correctly, including
|
|
668
|
+
* token-level precedence a client-side union cannot see).
|
|
669
|
+
*/
|
|
670
|
+
function effectiveAllowBits(ace: { allow: number; deny: number }): number {
|
|
671
|
+
return ace.allow & ~ace.deny;
|
|
499
672
|
}
|
|
500
673
|
|
|
674
|
+
/** The ONE agent-permission check scoped to Enact (Tarun, 2026-09-30) — see `REPO_STANDARD.permissions.agents`'s own doc comment above for why. */
|
|
675
|
+
const ENACT_ONLY_AGENT_DENY: { readonly name: string; readonly bit: PermissionBitName } = {
|
|
676
|
+
name: "agent-reviewer",
|
|
677
|
+
bit: "genericContribute",
|
|
678
|
+
};
|
|
679
|
+
|
|
501
680
|
function checkAgentBits(
|
|
502
681
|
name: string,
|
|
503
682
|
ace: { allow: number; deny: number },
|
|
504
683
|
expected: { allow: readonly PermissionBitName[]; deny: readonly PermissionBitName[] },
|
|
684
|
+
strictMainBranchRules: boolean,
|
|
505
685
|
drifts: RepoDoctorDrift[],
|
|
506
686
|
): void {
|
|
687
|
+
const effective = effectiveAllowBits(ace);
|
|
507
688
|
for (const bitName of expected.allow) {
|
|
508
|
-
if ((
|
|
689
|
+
if ((effective & GIT_REPO_PERMISSION_BIT[bitName]) === 0) {
|
|
509
690
|
drifts.push({
|
|
510
691
|
id: `permissions.${name}.missing-${bitName}`,
|
|
511
692
|
message: `${name} does not effectively have ${bitName} on this repository`,
|
|
@@ -513,7 +694,10 @@ function checkAgentBits(
|
|
|
513
694
|
}
|
|
514
695
|
}
|
|
515
696
|
for (const bitName of expected.deny) {
|
|
516
|
-
if (
|
|
697
|
+
if (!strictMainBranchRules && name === ENACT_ONLY_AGENT_DENY.name && bitName === ENACT_ONLY_AGENT_DENY.bit) {
|
|
698
|
+
continue; // Tarun, 2026-09-30: leave agent-reviewer's permissions as they are outside Enact
|
|
699
|
+
}
|
|
700
|
+
if ((effective & GIT_REPO_PERMISSION_BIT[bitName]) !== 0) {
|
|
517
701
|
drifts.push({
|
|
518
702
|
id: `permissions.${name}.has-${bitName}`,
|
|
519
703
|
message: `${name} effectively has ${bitName} on this repository, which the standard forbids`,
|
|
@@ -522,9 +706,59 @@ function checkAgentBits(
|
|
|
522
706
|
}
|
|
523
707
|
}
|
|
524
708
|
|
|
709
|
+
/**
|
|
710
|
+
* Resolves every agent's own LEGACY descriptor — see
|
|
711
|
+
* `resolveAgentLegacyDescriptor()`'s doc comment in lib/graph-identity.ts
|
|
712
|
+
* for the full live evidence this is built on (agent-tarun/agent-vivek/
|
|
713
|
+
* agent-reviewer hold no direct ACE at all in this org; their real
|
|
714
|
+
* Contribute/Contribute-to-pull-requests bits come entirely from group
|
|
715
|
+
* membership, and the Access Control Lists Query API cannot evaluate a raw
|
|
716
|
+
* Graph descriptor at all). `not-found` (Graph could not resolve the
|
|
717
|
+
* display name) and `expansion-failed` (Graph resolved it, but Identities —
|
|
718
|
+
* ExpandedUp could not) are reported as DISTINCT drifts rather than one
|
|
719
|
+
* generic failure — each names a different hop and a different fix.
|
|
720
|
+
* Identity lookups are cached across all three agents inside
|
|
721
|
+
* lib/graph-identity.ts (resetLegacyDescriptorCacheForTests()).
|
|
722
|
+
*/
|
|
723
|
+
async function resolveAgentDescriptors(drifts: RepoDoctorDrift[]): Promise<Map<string, string>> {
|
|
724
|
+
const descriptorByAgent = new Map<string, string>();
|
|
725
|
+
for (const name of Object.keys(REPO_STANDARD.permissions.agents)) {
|
|
726
|
+
const resolved = await resolveAgentLegacyDescriptor(name);
|
|
727
|
+
if ("error" in resolved) {
|
|
728
|
+
const message =
|
|
729
|
+
resolved.error === "not-found"
|
|
730
|
+
? `could not resolve a Graph descriptor for '${name}' (Graph subject query returned no exact display-name match)`
|
|
731
|
+
: `resolved a Graph descriptor for '${name}' but could not expand it to a legacy identity (Identities — Read Identities with queryMembership=ExpandedUp returned no match)`;
|
|
732
|
+
drifts.push({ id: `permissions.${name}.${resolved.error}`, message });
|
|
733
|
+
continue;
|
|
734
|
+
}
|
|
735
|
+
descriptorByAgent.set(name, resolved.descriptor);
|
|
736
|
+
}
|
|
737
|
+
return descriptorByAgent;
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
/**
|
|
741
|
+
* Independent review, 2026-09-30 (second pass — design correction): an
|
|
742
|
+
* earlier version of this check queried EACH agent's own descriptor AND
|
|
743
|
+
* every group it transitively belongs to, then unioned their allow/deny
|
|
744
|
+
* bits client-side. That is wrong in general: the security service's own
|
|
745
|
+
* per-descriptor evaluation already combines group membership WITH
|
|
746
|
+
* token-level inheritance using real precedence (an explicit Allow at the
|
|
747
|
+
* repo-scope token can override an inherited Deny that came down from the
|
|
748
|
+
* project-scope token) — live-verified on agent-tarun's real legacy
|
|
749
|
+
* descriptor (`effectiveAllow: 82038` with no direct ACE anywhere, i.e.
|
|
750
|
+
* fully server-computed from its group). A client-side union of
|
|
751
|
+
* separately-queried group ACEs cannot see WHICH token level a bit came
|
|
752
|
+
* from and can therefore disagree with the server: a false `missing-*`,
|
|
753
|
+
* or worse, silently hiding a real forbidden bit the server would report.
|
|
754
|
+
* The fix is to query the ACL with ONLY each agent's own legacy descriptor
|
|
755
|
+
* and trust ITS `effectiveAllow`/`effectiveDeny` directly — never to
|
|
756
|
+
* reconstruct that answer from parts.
|
|
757
|
+
*/
|
|
525
758
|
async function checkAgentPermissions(
|
|
526
759
|
projectId: string | undefined,
|
|
527
760
|
repositoryId: string,
|
|
761
|
+
strictMainBranchRules: boolean,
|
|
528
762
|
drifts: RepoDoctorDrift[],
|
|
529
763
|
): Promise<void> {
|
|
530
764
|
if (!projectId) {
|
|
@@ -544,7 +778,9 @@ async function checkAgentPermissions(
|
|
|
544
778
|
for (const [name, expected] of Object.entries(REPO_STANDARD.permissions.agents)) {
|
|
545
779
|
const descriptor = descriptorByAgent.get(name);
|
|
546
780
|
if (!descriptor) continue; // already reported above
|
|
547
|
-
|
|
781
|
+
const ace = resolveEffectiveAce(effective, descriptor, name, "", name, "this repository", drifts);
|
|
782
|
+
if (!ace) continue; // already reported by resolveEffectiveAce()
|
|
783
|
+
checkAgentBits(name, ace, expected, strictMainBranchRules, drifts);
|
|
548
784
|
}
|
|
549
785
|
}
|
|
550
786
|
|
|
@@ -580,8 +816,7 @@ async function resolveBuildServiceDescriptor(namespaceId: string, projectId: str
|
|
|
580
816
|
const result = await fetchAccessControlLists(namespaceId, `repoV2/${projectId}`);
|
|
581
817
|
for (const acl of result.value ?? []) {
|
|
582
818
|
for (const descriptor of Object.keys(acl.acesDictionary ?? {})) {
|
|
583
|
-
if (descriptor
|
|
584
|
-
return descriptor;
|
|
819
|
+
if (isProjectBuildServiceDescriptor(descriptor, projectId)) return descriptor;
|
|
585
820
|
}
|
|
586
821
|
}
|
|
587
822
|
return undefined;
|
|
@@ -597,6 +832,7 @@ async function checkBuildServicePermissions(
|
|
|
597
832
|
namespaceId: string,
|
|
598
833
|
projectId: string | undefined,
|
|
599
834
|
repositoryId: string,
|
|
835
|
+
strictMainBranchRules: boolean,
|
|
600
836
|
drifts: RepoDoctorDrift[],
|
|
601
837
|
): Promise<void> {
|
|
602
838
|
if (!projectId) return; // already reported by checkAgentPermissions()
|
|
@@ -610,40 +846,73 @@ async function checkBuildServicePermissions(
|
|
|
610
846
|
return;
|
|
611
847
|
}
|
|
612
848
|
|
|
849
|
+
// Deny-aware throughout (effectiveAllowBits()'s own doc comment): a
|
|
850
|
+
// single descriptor can itself carry both an allow and a deny
|
|
851
|
+
// bit (effectiveAllow/effectiveDeny are independent fields on the same
|
|
852
|
+
// ACE) — the build service's own group-membership situation is different
|
|
853
|
+
// from the agents' (live evidence: it holds direct ACEs everywhere this
|
|
854
|
+
// function checks, no group expansion needed), but the SAME deny-wins
|
|
855
|
+
// arithmetic applies regardless of where the bits came from.
|
|
613
856
|
const repoToken = `repoV2/${projectId}/${repositoryId}`;
|
|
614
|
-
const repoEffective = (
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
857
|
+
const repoEffective = resolveEffectiveAce(
|
|
858
|
+
await fetchEffectivePermissions(namespaceId, repoToken, [descriptor]),
|
|
859
|
+
descriptor,
|
|
860
|
+
"build-service",
|
|
861
|
+
"",
|
|
862
|
+
"the project build service",
|
|
863
|
+
"this repository",
|
|
864
|
+
drifts,
|
|
865
|
+
);
|
|
866
|
+
if (repoEffective) {
|
|
867
|
+
for (const bitName of REPO_STANDARD.permissions.buildService.repo.allow) {
|
|
868
|
+
if ((effectiveAllowBits(repoEffective) & GIT_REPO_PERMISSION_BIT[bitName]) === 0) {
|
|
869
|
+
drifts.push({
|
|
870
|
+
id: `permissions.build-service.missing-${bitName}`,
|
|
871
|
+
message: `the project build service does not effectively have ${bitName} on this repository`,
|
|
872
|
+
});
|
|
873
|
+
}
|
|
624
874
|
}
|
|
625
875
|
}
|
|
626
876
|
|
|
627
877
|
const integrationToken = `${repoToken}/refs/heads/${refNameHexUtf16Le("integration")}`;
|
|
628
|
-
const integrationEffective = (
|
|
878
|
+
const integrationEffective = resolveEffectiveAce(
|
|
879
|
+
await fetchEffectivePermissions(namespaceId, integrationToken, [descriptor]),
|
|
629
880
|
descriptor,
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
881
|
+
"build-service",
|
|
882
|
+
"-integration",
|
|
883
|
+
"the project build service",
|
|
884
|
+
"refs/heads/integration",
|
|
885
|
+
drifts,
|
|
886
|
+
);
|
|
887
|
+
if (integrationEffective) {
|
|
888
|
+
for (const bitName of REPO_STANDARD.permissions.buildService.integrationRef.allow) {
|
|
889
|
+
if ((effectiveAllowBits(integrationEffective) & GIT_REPO_PERMISSION_BIT[bitName]) === 0) {
|
|
890
|
+
drifts.push({
|
|
891
|
+
id: `permissions.build-service.missing-${bitName}-integration`,
|
|
892
|
+
message: `the project build service does not effectively have ${bitName} on refs/heads/integration`,
|
|
893
|
+
});
|
|
894
|
+
}
|
|
637
895
|
}
|
|
638
896
|
}
|
|
639
897
|
|
|
898
|
+
// Enact-only (STRICT_MAIN_BRANCH_PROJECT above): a repo outside Enact keeps
|
|
899
|
+
// whatever PolicyExempt the build service already has on main, flagged
|
|
900
|
+
// neither way — skip the ACL read entirely rather than fetch-and-ignore.
|
|
901
|
+
if (!strictMainBranchRules) return;
|
|
902
|
+
|
|
640
903
|
const mainToken = `${repoToken}/refs/heads/${refNameHexUtf16Le("main")}`;
|
|
641
|
-
const mainEffective = (
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
904
|
+
const mainEffective = resolveEffectiveAce(
|
|
905
|
+
await fetchEffectivePermissions(namespaceId, mainToken, [descriptor]),
|
|
906
|
+
descriptor,
|
|
907
|
+
"build-service",
|
|
908
|
+
"-main",
|
|
909
|
+
"the project build service",
|
|
910
|
+
"refs/heads/main",
|
|
911
|
+
drifts,
|
|
912
|
+
);
|
|
913
|
+
if (!mainEffective) return;
|
|
645
914
|
for (const bitName of REPO_STANDARD.permissions.buildService.mainRef.deny) {
|
|
646
|
-
if ((mainEffective
|
|
915
|
+
if ((effectiveAllowBits(mainEffective) & GIT_REPO_PERMISSION_BIT[bitName]) !== 0) {
|
|
647
916
|
drifts.push({
|
|
648
917
|
id: `permissions.build-service.has-${bitName}-main`,
|
|
649
918
|
message: `the project build service effectively has ${bitName} on refs/heads/main, which the standard forbids`,
|
|
@@ -672,6 +941,16 @@ function releaseFixPath(repository: string, identityDisplayName: string, bitName
|
|
|
672
941
|
return `Project settings → Repositories → \`${repository}\` → Security → \`${identityDisplayName}\` → \`${GIT_REPO_PERMISSION_UI_LABEL[bitName]}\` = Allow`;
|
|
673
942
|
}
|
|
674
943
|
|
|
944
|
+
/** Same as releaseFixPath() but for a bit that is granted per-BRANCH (verified live 2026-09-30: PolicyExempt on this org's build service lives on refs/heads/integration's own security page, never the repo-root one). */
|
|
945
|
+
function releaseFixPathForRef(
|
|
946
|
+
repository: string,
|
|
947
|
+
ref: string,
|
|
948
|
+
identityDisplayName: string,
|
|
949
|
+
bitName: PermissionBitName,
|
|
950
|
+
): string {
|
|
951
|
+
return `Repos → \`${repository}\` → Branches → \`${ref}\` → ⋯ → Branch security → \`${identityDisplayName}\` → \`${GIT_REPO_PERMISSION_UI_LABEL[bitName]}\` = Allow`;
|
|
952
|
+
}
|
|
953
|
+
|
|
675
954
|
/**
|
|
676
955
|
* Which build service identity actually runs the pipeline, per Build —
|
|
677
956
|
* General Settings — Get's `enforceJobAuthScope` (file header) — "Limit job
|
|
@@ -693,15 +972,21 @@ async function fetchEnforceJobAuthScope(c: AdoClient): Promise<boolean | undefin
|
|
|
693
972
|
|
|
694
973
|
/**
|
|
695
974
|
* One identity's answer to "can it release": resolve its descriptor by exact
|
|
696
|
-
* display name (Identities — Read Identities, file header), then check
|
|
697
|
-
*
|
|
698
|
-
*
|
|
699
|
-
*
|
|
700
|
-
*
|
|
975
|
+
* display name (Identities — Read Identities, file header), then check
|
|
976
|
+
* `buildServiceRelease.repoScoped` on the repository token AND
|
|
977
|
+
* `buildServiceRelease.integrationRefScoped` on the `integration` ref token
|
|
978
|
+
* — NOT the same token for all three (see REPO_STANDARD.permissions.
|
|
979
|
+
* buildServiceRelease's own doc comment: PolicyExempt is a per-BRANCH grant
|
|
980
|
+
* in this org, verified live against real bypass-on-push evidence, and
|
|
981
|
+
* checking it at repo scope produced a false "missing" finding). Every
|
|
982
|
+
* failure mode is its own loud, distinct drift — an unresolvable identity,
|
|
983
|
+
* an ACL the calling agent cannot read, and each missing permission named
|
|
984
|
+
* with its fix path — never a silent pass.
|
|
701
985
|
*/
|
|
702
986
|
async function checkOneBuildServiceCanRelease(
|
|
703
987
|
namespaceId: string,
|
|
704
988
|
repoToken: string,
|
|
989
|
+
integrationRefToken: string,
|
|
705
990
|
repository: string,
|
|
706
991
|
displayName: string,
|
|
707
992
|
drifts: RepoDoctorDrift[],
|
|
@@ -727,26 +1012,41 @@ async function checkOneBuildServiceCanRelease(
|
|
|
727
1012
|
return;
|
|
728
1013
|
}
|
|
729
1014
|
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
1015
|
+
// LOW, independent review 2026-09-30: name WHICH token failed — "the ACL"
|
|
1016
|
+
// alone left a human unable to tell a repo-scope 403 from a
|
|
1017
|
+
// refs/heads/integration-scope one without re-running this by hand.
|
|
1018
|
+
async function effectiveAt(token: string, tokenLabel: string): Promise<{ allow: number; deny: number } | undefined> {
|
|
1019
|
+
let effective: EffectivePermissionsResult;
|
|
1020
|
+
try {
|
|
1021
|
+
effective = await fetchEffectivePermissions(namespaceId, token, [descriptor!]);
|
|
1022
|
+
} catch (error) {
|
|
1023
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
1024
|
+
drifts.push({
|
|
1025
|
+
id: `permissions.release.${slug}.acl-read-forbidden`,
|
|
1026
|
+
message: `could not read the Git Repositories security ACL for '${displayName}' on '${repository}' (${tokenLabel} token) — the calling agent identity may lack rights to read repository security, so this cannot be confirmed either way: ${detail}`,
|
|
1027
|
+
});
|
|
1028
|
+
return undefined;
|
|
1029
|
+
}
|
|
1030
|
+
return resolveEffectiveAce(effective, descriptor!, `release.${slug}`, "", `'${displayName}'`, tokenLabel, drifts);
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
const repoEffective = await effectiveAt(repoToken, "repo-scope");
|
|
1034
|
+
if (!repoEffective) return;
|
|
1035
|
+
for (const bitName of REPO_STANDARD.permissions.buildServiceRelease.repoScoped) {
|
|
1036
|
+
if ((effectiveAllowBits(repoEffective) & GIT_REPO_PERMISSION_BIT[bitName]) !== 0) continue;
|
|
738
1037
|
drifts.push({
|
|
739
|
-
id: `permissions.release.${slug}.
|
|
740
|
-
message: `
|
|
1038
|
+
id: `permissions.release.${slug}.missing-${bitName}`,
|
|
1039
|
+
message: `'${displayName}' does not effectively have ${GIT_REPO_PERMISSION_UI_LABEL[bitName]} on '${repository}' — the release pipeline will 403 the way agent-identities build 4897 did. Fix: ${releaseFixPath(repository, displayName, bitName)}`,
|
|
741
1040
|
});
|
|
742
|
-
return;
|
|
743
1041
|
}
|
|
744
1042
|
|
|
745
|
-
|
|
746
|
-
|
|
1043
|
+
const integrationEffective = await effectiveAt(integrationRefToken, "refs/heads/integration");
|
|
1044
|
+
if (!integrationEffective) return;
|
|
1045
|
+
for (const bitName of REPO_STANDARD.permissions.buildServiceRelease.integrationRefScoped) {
|
|
1046
|
+
if ((effectiveAllowBits(integrationEffective) & GIT_REPO_PERMISSION_BIT[bitName]) !== 0) continue;
|
|
747
1047
|
drifts.push({
|
|
748
1048
|
id: `permissions.release.${slug}.missing-${bitName}`,
|
|
749
|
-
message: `'${displayName}' does not effectively have ${GIT_REPO_PERMISSION_UI_LABEL[bitName]} on '${repository}' —
|
|
1049
|
+
message: `'${displayName}' does not effectively have ${GIT_REPO_PERMISSION_UI_LABEL[bitName]} on 'refs/heads/integration' of '${repository}' — its own version-bump commit would be blocked by integration's own policies. Fix: ${releaseFixPathForRef(repository, "integration", displayName, bitName)}`,
|
|
750
1050
|
});
|
|
751
1051
|
}
|
|
752
1052
|
}
|
|
@@ -779,8 +1079,9 @@ async function checkBuildServiceCanRelease(
|
|
|
779
1079
|
: [projectScoped, collectionScoped];
|
|
780
1080
|
|
|
781
1081
|
const repoToken = `repoV2/${projectId}/${repositoryId}`;
|
|
1082
|
+
const integrationRefToken = `${repoToken}/refs/heads/${refNameHexUtf16Le("integration")}`;
|
|
782
1083
|
for (const displayName of displayNames) {
|
|
783
|
-
await checkOneBuildServiceCanRelease(namespaceId, repoToken, repository, displayName, drifts);
|
|
1084
|
+
await checkOneBuildServiceCanRelease(namespaceId, repoToken, integrationRefToken, repository, displayName, drifts);
|
|
784
1085
|
}
|
|
785
1086
|
}
|
|
786
1087
|
|
|
@@ -825,14 +1126,30 @@ export async function repoDoctorCheck(options: RepoDoctorOptions = {}): Promise<
|
|
|
825
1126
|
});
|
|
826
1127
|
}
|
|
827
1128
|
|
|
1129
|
+
const repositoryProjectName = repoInfo.project?.name;
|
|
1130
|
+
if (!repositoryProjectName) {
|
|
1131
|
+
drifts.push({
|
|
1132
|
+
id: "branches.no-project-name",
|
|
1133
|
+
message:
|
|
1134
|
+
"Git Repositories — Get Repository response did not include a project name; cannot determine whether the Enact-only main-branch rules (STRICT_MAIN_BRANCH_PROJECT) apply to this repository",
|
|
1135
|
+
});
|
|
1136
|
+
}
|
|
1137
|
+
const strictMainBranchRules = repositoryProjectName ? strictMainBranchRulesApply(repositoryProjectName) : false;
|
|
1138
|
+
|
|
828
1139
|
const hasPipeline = await repositoryHasAPipeline(c, repositoryId);
|
|
829
1140
|
const integrationPolicies = await fetchBranchPolicies(c, repositoryId, "integration");
|
|
830
1141
|
const mainPolicies = await fetchBranchPolicies(c, repositoryId, "main");
|
|
831
1142
|
checkIntegrationPolicies(integrationPolicies, hasPipeline, drifts);
|
|
832
|
-
checkMainPolicies(mainPolicies, hasPipeline, drifts);
|
|
1143
|
+
checkMainPolicies(mainPolicies, hasPipeline, strictMainBranchRules, drifts);
|
|
833
1144
|
|
|
834
|
-
await checkAgentPermissions(projectId, repositoryId, drifts);
|
|
835
|
-
await checkBuildServicePermissions(
|
|
1145
|
+
await checkAgentPermissions(projectId, repositoryId, strictMainBranchRules, drifts);
|
|
1146
|
+
await checkBuildServicePermissions(
|
|
1147
|
+
REPO_STANDARD.permissions.namespaceId,
|
|
1148
|
+
projectId,
|
|
1149
|
+
repositoryId,
|
|
1150
|
+
strictMainBranchRules,
|
|
1151
|
+
drifts,
|
|
1152
|
+
);
|
|
836
1153
|
await checkBuildServiceCanRelease(c, REPO_STANDARD.permissions.namespaceId, projectId, repositoryId, repository, drifts);
|
|
837
1154
|
|
|
838
1155
|
return { repository, compliant: drifts.length === 0, drifts };
|