@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.
Files changed (42) hide show
  1. package/bin/adl +2 -2
  2. package/bin/git +89 -8
  3. package/bin/lean-ctx-observe-strip +76 -3
  4. package/bin/reconcile +175 -5
  5. package/bin/validate-manifest +19 -1
  6. package/package.json +1 -1
  7. package/packages/agent-hooks/src/deploy/deploy.ts +98 -18
  8. package/packages/agent-hooks/src/deploy/doctor.ts +61 -11
  9. package/packages/agent-hooks/src/deploy/json-merge.ts +430 -18
  10. package/packages/agent-hooks/src/deploy/lock.ts +36 -2
  11. package/packages/agent-hooks/src/lean-ctx/env.ts +17 -0
  12. package/packages/agent-hooks/src/lean-ctx/mcp.ts +4 -2
  13. package/packages/agent-settings/assets/lean-ctx/config.toml +3 -0
  14. package/packages/agent-settings/assets/lean-ctx/gitignore +4 -0
  15. package/packages/agent-settings/assets/lean-ctx/layout.toml +2 -0
  16. package/packages/agent-settings/src/deploy/agents-deploy.ts +10 -5
  17. package/packages/agent-settings/src/deploy/doctor.ts +39 -3
  18. package/packages/agent-settings/src/deploy/lean-ctx-deploy.ts +104 -12
  19. package/packages/agent-settings/src/deploy/lean-ctx-merge.ts +580 -0
  20. package/packages/agent-settings/src/deploy/lock.ts +4 -1
  21. package/packages/agent-settings/src/deploy/mcp-deploy.ts +9 -6
  22. package/packages/agent-settings/src/deploy/settings.ts +6 -3
  23. package/packages/agent-skills/dist/deploy.d.ts +2 -0
  24. package/packages/agent-skills/dist/deploy.d.ts.map +1 -1
  25. package/packages/agent-skills/dist/deploy.js +15 -8
  26. package/packages/agent-skills/dist/deploy.js.map +1 -1
  27. package/packages/agent-skills/dist/manifest.d.ts +1 -0
  28. package/packages/agent-skills/dist/manifest.d.ts.map +1 -1
  29. package/packages/agent-skills/dist/manifest.js +6 -2
  30. package/packages/agent-skills/dist/manifest.js.map +1 -1
  31. package/packages/agent-skills/src/deploy.ts +15 -8
  32. package/packages/agent-skills/src/manifest.ts +5 -2
  33. package/packages/azdo/src/azdo-repo-doctor.ts +412 -95
  34. package/packages/azdo/src/lib/graph-identity.ts +190 -3
  35. package/packages/fleet/jobs/git-maintain/src/main.ts +305 -45
  36. package/packages/fleet/manifest.json +2 -0
  37. package/packages/fleet/manifest.schema.json +9 -0
  38. package/packages/repo-controls/README.md +13 -6
  39. package/packages/repo-controls/assets/adl-config.default.toml +1 -0
  40. package/packages/repo-controls/assets/version-bump.sh +6 -1
  41. package/packages/repo-controls/src/cli.ts +6 -1
  42. 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 a service principal's descriptor by display name)
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 { fetchJson, resolveAgentDescriptor, resolveIdentityByDisplayName } from "./lib/graph-identity.ts";
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 resolveAgentDescriptor (Graph Subject Query) live in
99
- // ./lib/graph-identity.ts — this file only adds its own orgBaseUrl() (the
100
- // accesscontrollists host); vsspsBaseUrl() moved with the Graph helpers.
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
- "agent-reviewer": { allow: ["pullRequestContribute"], deny: ["genericContribute"] },
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
- * tags/branches and complete PRs on the repo, and needs PolicyExempt only
178
- * on `integration` (so a version-bump commit made by CI itself is not
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", "forcePush", "pullRequestContribute"] } as {
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 repo-scope permissions the release flow needs from
191
- * whichever build service identity actually runs the pipeline — the real
192
- * failure this check exists for was a 403 on the third one.
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
- required: ["genericContribute", "policyExempt", "pullRequestContribute"] as const,
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(policies: readonly PolicyConfiguration[], drifts: RepoDoctorDrift[]): void {
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
- if (Boolean(settings.creatorVoteCounts) !== expected.creatorVoteCounts)
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
- async function fetchEffectivePermissions(
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<Map<string, { allow: number; deny: number }>> {
615
+ ): Promise<EffectivePermissionsResult> {
470
616
  const byDescriptor = new Map<string, { allow: number; deny: number }>();
471
- if (descriptors.length === 0) return byDescriptor;
472
- const result = await fetchAccessControlLists(namespaceId, token, descriptors);
473
- for (const acl of result.value ?? []) {
474
- for (const [descriptor, ace] of Object.entries(acl.acesDictionary ?? {})) {
475
- byDescriptor.set(descriptor, {
476
- allow: ace.extendedInfo?.effectiveAllow ?? ace.allow ?? 0,
477
- deny: ace.extendedInfo?.effectiveDeny ?? ace.deny ?? 0,
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
- return byDescriptor;
630
+ const unresolved = descriptors.filter((descriptor) => !byDescriptor.has(descriptor));
631
+ return { byDescriptor, unresolved };
482
632
  }
483
633
 
484
- /** Resolves every agent's Graph descriptor, reporting an unresolved name as its own drift rather than throwing. */
485
- async function resolveAgentDescriptors(drifts: RepoDoctorDrift[]): Promise<Map<string, string>> {
486
- const descriptorByAgent = new Map<string, string>();
487
- for (const name of Object.keys(REPO_STANDARD.permissions.agents)) {
488
- const descriptor = await resolveAgentDescriptor(name);
489
- if (!descriptor) {
490
- drifts.push({
491
- id: `permissions.${name}.not-found`,
492
- message: `could not resolve a Graph descriptor for '${name}' (Graph subject query returned no exact display-name match)`,
493
- });
494
- continue;
495
- }
496
- descriptorByAgent.set(name, descriptor);
497
- }
498
- return descriptorByAgent;
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 ((ace.allow & GIT_REPO_PERMISSION_BIT[bitName]) === 0) {
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 ((ace.allow & GIT_REPO_PERMISSION_BIT[bitName]) !== 0) {
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
- checkAgentBits(name, effective.get(descriptor) ?? { allow: 0, deny: 0 }, expected, drifts);
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.startsWith("Microsoft.TeamFoundation.ServiceIdentity") && descriptor.includes(projectId))
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 = (await fetchEffectivePermissions(namespaceId, repoToken, [descriptor])).get(descriptor) ?? {
615
- allow: 0,
616
- deny: 0,
617
- };
618
- for (const bitName of REPO_STANDARD.permissions.buildService.repo.allow) {
619
- if ((repoEffective.allow & GIT_REPO_PERMISSION_BIT[bitName]) === 0) {
620
- drifts.push({
621
- id: `permissions.build-service.missing-${bitName}`,
622
- message: `the project build service does not effectively have ${bitName} on this repository`,
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 = (await fetchEffectivePermissions(namespaceId, integrationToken, [descriptor])).get(
878
+ const integrationEffective = resolveEffectiveAce(
879
+ await fetchEffectivePermissions(namespaceId, integrationToken, [descriptor]),
629
880
  descriptor,
630
- ) ?? { allow: 0, deny: 0 };
631
- for (const bitName of REPO_STANDARD.permissions.buildService.integrationRef.allow) {
632
- if ((integrationEffective.allow & GIT_REPO_PERMISSION_BIT[bitName]) === 0) {
633
- drifts.push({
634
- id: `permissions.build-service.missing-${bitName}-integration`,
635
- message: `the project build service does not effectively have ${bitName} on refs/heads/integration`,
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 = (await fetchEffectivePermissions(namespaceId, mainToken, [descriptor])).get(descriptor) ?? {
642
- allow: 0,
643
- deny: 0,
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.allow & GIT_REPO_PERMISSION_BIT[bitName]) !== 0) {
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 the
697
- * three REPO_STANDARD.permissions.buildServiceRelease.required bits on the
698
- * repository token. Every failure mode is its own loud, distinct drift — an
699
- * unresolvable identity, an ACL the calling agent cannot read, and each
700
- * missing permission named with its fix path — never a silent pass.
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
- let effective: { allow: number; deny: number };
731
- try {
732
- effective = (await fetchEffectivePermissions(namespaceId, repoToken, [descriptor])).get(descriptor) ?? {
733
- allow: 0,
734
- deny: 0,
735
- };
736
- } catch (error) {
737
- const detail = error instanceof Error ? error.message : String(error);
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}.acl-read-forbidden`,
740
- message: `could not read the Git Repositories security ACL for '${displayName}' on '${repository}' — the calling agent identity may lack rights to read repository security, so this cannot be confirmed either way: ${detail}`,
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
- for (const bitName of REPO_STANDARD.permissions.buildServiceRelease.required) {
746
- if ((effective.allow & GIT_REPO_PERMISSION_BIT[bitName]) !== 0) continue;
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}' — the release pipeline will 403 the way agent-identities build 4897 did. Fix: ${releaseFixPath(repository, displayName, bitName)}`,
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(REPO_STANDARD.permissions.namespaceId, projectId, repositoryId, drifts);
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 };