@amsterdamdatalabs/adl-agent-control-linux-x64 0.1.6 → 0.1.8

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 (41) hide show
  1. package/bin/adl +2 -2
  2. package/bin/lean-ctx-observe-strip +76 -3
  3. package/bin/reconcile +460 -240
  4. package/bin/validate-manifest +36 -16
  5. package/package.json +1 -1
  6. package/packages/agent-hooks/src/deploy/deploy.ts +98 -18
  7. package/packages/agent-hooks/src/deploy/doctor.ts +61 -11
  8. package/packages/agent-hooks/src/deploy/json-merge.ts +430 -18
  9. package/packages/agent-hooks/src/deploy/lock.ts +36 -2
  10. package/packages/agent-hooks/src/lean-ctx/env.ts +17 -0
  11. package/packages/agent-hooks/src/lean-ctx/mcp.ts +4 -2
  12. package/packages/agent-settings/assets/lean-ctx/config.toml +3 -0
  13. package/packages/agent-settings/assets/lean-ctx/gitignore +4 -0
  14. package/packages/agent-settings/assets/lean-ctx/layout.toml +2 -0
  15. package/packages/agent-settings/src/deploy/agents-deploy.ts +10 -5
  16. package/packages/agent-settings/src/deploy/doctor.ts +39 -3
  17. package/packages/agent-settings/src/deploy/lean-ctx-deploy.ts +104 -12
  18. package/packages/agent-settings/src/deploy/lean-ctx-merge.ts +580 -0
  19. package/packages/agent-settings/src/deploy/lock.ts +4 -1
  20. package/packages/agent-settings/src/deploy/mcp-deploy.ts +9 -6
  21. package/packages/agent-settings/src/deploy/settings.ts +6 -3
  22. package/packages/agent-skills/dist/deploy.d.ts +2 -0
  23. package/packages/agent-skills/dist/deploy.d.ts.map +1 -1
  24. package/packages/agent-skills/dist/deploy.js +15 -8
  25. package/packages/agent-skills/dist/deploy.js.map +1 -1
  26. package/packages/agent-skills/dist/manifest.d.ts +1 -0
  27. package/packages/agent-skills/dist/manifest.d.ts.map +1 -1
  28. package/packages/agent-skills/dist/manifest.js +6 -2
  29. package/packages/agent-skills/dist/manifest.js.map +1 -1
  30. package/packages/agent-skills/src/deploy.ts +15 -8
  31. package/packages/agent-skills/src/manifest.ts +5 -2
  32. package/packages/azdo/src/azdo-repo-doctor.ts +401 -91
  33. package/packages/azdo/src/lib/graph-identity.ts +190 -3
  34. package/packages/fleet/jobs/git-maintain/src/main.ts +305 -45
  35. package/packages/fleet/manifest.json +6 -4
  36. package/packages/fleet/manifest.schema.json +15 -5
  37. package/packages/repo-controls/README.md +13 -6
  38. package/packages/repo-controls/assets/adl-config.default.toml +1 -0
  39. package/packages/repo-controls/src/cli.ts +6 -1
  40. package/packages/repo-controls/src/gitignore-block.ts +17 -0
  41. 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"],
@@ -187,12 +227,28 @@ export const REPO_STANDARD = {
187
227
  },
188
228
  /**
189
229
  * 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.
230
+ * the exact three permissions the release flow needs from whichever
231
+ * build service identity actually runs the pipeline — the real failure
232
+ * this check exists for was a 403 on `pullRequestContribute`.
233
+ *
234
+ * `policyExempt` ("Bypass policies when pushing") is checked at the
235
+ * `integration` REF token, never the repo-scope token — verified live
236
+ * 2026-09-30 against adl-agent-control: the Infrastructure-AzDo Build
237
+ * Service demonstrably bypassed `integration`'s own policies to push
238
+ * `chore: bump version 0.1.6 to 0.1.7 ***NO_CI***` (c966e6a) straight to
239
+ * that protected branch, yet its repo-scope effective allow bitmask
240
+ * (16430) does not include PolicyExempt (128) at all — only its
241
+ * `refs/heads/integration`-scope effective allow (16558) does. Checking
242
+ * `policyExempt` at repo scope produced a false
243
+ * `permissions.release.<slug>.missing-policyExempt` finding that
244
+ * contradicted this observed reality. `genericContribute` and
245
+ * `pullRequestContribute` stay checked at repo scope — both bits ARE
246
+ * present there for real, and PR operations are a repo-level, not
247
+ * branch-level, permission.
193
248
  */
194
249
  buildServiceRelease: {
195
- required: ["genericContribute", "policyExempt", "pullRequestContribute"] as const,
250
+ repoScoped: ["genericContribute", "pullRequestContribute"] as const,
251
+ integrationRefScoped: ["policyExempt"] as const,
196
252
  },
197
253
  },
198
254
  } as const;
@@ -219,7 +275,7 @@ export const GIT_REPO_PERMISSION_UI_LABEL: Record<PermissionBitName, string> = {
219
275
  interface GitRepositoryResponse {
220
276
  id: string;
221
277
  defaultBranch?: string;
222
- project?: { id?: string };
278
+ project?: { id?: string; name?: string };
223
279
  }
224
280
 
225
281
  interface GitRefsResponse {
@@ -265,6 +321,39 @@ interface AccessControlListsResponse {
265
321
  }>;
266
322
  }
267
323
 
324
+ /**
325
+ * Tarun, 2026-09-30: the strict main-branch rules below — the build service
326
+ * must not have PolicyExempt (bypass) on refs/heads/main
327
+ * (`permissions.build-service.has-policyExempt-main`) and main's
328
+ * minimum-reviewer policy must have creatorVoteCounts=false
329
+ * (`main.minimum-reviewers-misconfigured: creatorVoteCounts must be false`)
330
+ * — apply ONLY to repositories in the Azure DevOps project "Enact".
331
+ * Repositories in "Infrastructure-AzDo" (the small internal tool repos:
332
+ * adl-agent-control, agent-identities, …) keep their existing settings and
333
+ * must not be flagged for these two; every other check in this file stays
334
+ * exactly as is, for every project. Real evidence this fix responds to:
335
+ * `adl repo doctor --repo agent-identities` (Infrastructure-AzDo) reported
336
+ * both findings before this change.
337
+ *
338
+ * Scoped from the REPOSITORY'S OWN project, as Git Repositories — Get
339
+ * Repository's response names it (`repoInfo.project.name`) — NEVER
340
+ * `resolveProject()`/cwd/env (independent review, 2026-09-30: cwd/env name
341
+ * the project the CALLING checkout happens to sit in, which can differ from
342
+ * the project the repository being CHECKED actually lives in; the doctor
343
+ * already resolves the repository by name against whichever project cwd/env
344
+ * picked, but the compliance decision must follow the checked repository,
345
+ * not the caller's own working directory). Compared case-insensitively,
346
+ * since Azure DevOps project names are case-preserving but not
347
+ * case-sensitive for identity purposes. No environment variable controls
348
+ * this; a per-repository override, if one is ever genuinely needed, belongs
349
+ * in that repository's own adl-config.toml / manifest, never an env var.
350
+ */
351
+ export const STRICT_MAIN_BRANCH_PROJECT = "Enact";
352
+
353
+ function strictMainBranchRulesApply(project: string): boolean {
354
+ return project.toLowerCase() === STRICT_MAIN_BRANCH_PROJECT.toLowerCase();
355
+ }
356
+
268
357
  export interface RepoDoctorDrift {
269
358
  id: string;
270
359
  message: string;
@@ -367,7 +456,11 @@ function checkMainRequiredReviewer(policies: readonly PolicyConfiguration[], dri
367
456
  });
368
457
  }
369
458
 
370
- function checkMainMinimumReviewers(policies: readonly PolicyConfiguration[], drifts: RepoDoctorDrift[]): void {
459
+ function checkMainMinimumReviewers(
460
+ policies: readonly PolicyConfiguration[],
461
+ strictMainBranchRules: boolean,
462
+ drifts: RepoDoctorDrift[],
463
+ ): void {
371
464
  const minReviewer = enabledPolicyOfType(policies, POLICY_TYPE.minimumReviewers);
372
465
  if (!minReviewer) {
373
466
  drifts.push({
@@ -382,7 +475,9 @@ function checkMainMinimumReviewers(policies: readonly PolicyConfiguration[], dri
382
475
  if (minReviewer.isBlocking !== expected.isBlocking) problems.push(`isBlocking must be ${expected.isBlocking}`);
383
476
  if ((settings.minimumApproverCount ?? 0) < expected.minimumApproverCount)
384
477
  problems.push(`minimumApproverCount must be at least ${expected.minimumApproverCount}`);
385
- if (Boolean(settings.creatorVoteCounts) !== expected.creatorVoteCounts)
478
+ // Enact-only (STRICT_MAIN_BRANCH_PROJECT above): a repo outside Enact keeps
479
+ // its own creatorVoteCounts setting, flagged neither way.
480
+ if (strictMainBranchRules && Boolean(settings.creatorVoteCounts) !== expected.creatorVoteCounts)
386
481
  problems.push(`creatorVoteCounts must be ${expected.creatorVoteCounts}`);
387
482
  if (Boolean(settings.resetOnSourcePush) !== expected.resetOnSourcePush)
388
483
  problems.push(`resetOnSourcePush must be ${expected.resetOnSourcePush}`);
@@ -437,10 +532,11 @@ function checkMainCommentsAndBuild(
437
532
  function checkMainPolicies(
438
533
  policies: readonly PolicyConfiguration[],
439
534
  hasPipeline: boolean,
535
+ strictMainBranchRules: boolean,
440
536
  drifts: RepoDoctorDrift[],
441
537
  ): void {
442
538
  checkMainRequiredReviewer(policies, drifts);
443
- checkMainMinimumReviewers(policies, drifts);
539
+ checkMainMinimumReviewers(policies, strictMainBranchRules, drifts);
444
540
  checkMainMergeStrategy(policies, drifts);
445
541
  checkMainCommentsAndBuild(policies, hasPipeline, drifts);
446
542
  }
@@ -462,50 +558,128 @@ async function fetchAccessControlLists(
462
558
  return fetchJson<AccessControlListsResponse>(url, "Access control lists query");
463
559
  }
464
560
 
465
- async function fetchEffectivePermissions(
561
+ /**
562
+ * Comfortably under every documented and empirically observed URL-length
563
+ * limit for this API (independent review, 2026-09-30: "keep the
564
+ * query-string size bounded" — an identity's transitive group-membership
565
+ * count is unbounded in principle, and three agents' descriptor sets get
566
+ * combined into one call). `fetchEffectivePermissions()` below chunks
567
+ * automatically rather than ever sending one unbounded `descriptors` list —
568
+ * never a silent truncation: a failed chunk's fetch throws, same as a
569
+ * failed single-descriptor call always did.
570
+ */
571
+ export const MAX_DESCRIPTORS_PER_ACL_QUERY = 50;
572
+
573
+ function chunk<T>(items: readonly T[], size: number): T[][] {
574
+ const chunks: T[][] = [];
575
+ for (let i = 0; i < items.length; i += size) chunks.push(items.slice(i, i + size));
576
+ return chunks;
577
+ }
578
+
579
+ export interface EffectivePermissionsResult {
580
+ byDescriptor: Map<string, { allow: number; deny: number }>;
581
+ /**
582
+ * Requested descriptors that never ended up in `byDescriptor` — either
583
+ * ABSENT from every chunk's `acesDictionary` entirely, or present but with
584
+ * NO `extendedInfo` key at all (checked via `'extendedInfo' in ace`, which
585
+ * distinguishes "the key is not there" from "the key is `{}`"). Both mean
586
+ * the effective bits could not actually be confirmed for that descriptor
587
+ * at this token. An entry that HAS `extendedInfo`, even `extendedInfo: {}`
588
+ * (a real, normal shape: the server computed it and every field happened
589
+ * to be zero/omitted), is NOT unresolved — it lands in `byDescriptor` with
590
+ * allow/deny defaulted to 0 exactly as before (independent review,
591
+ * 2026-09-30, LOW: "an entry that HAS extendedInfo with zero-valued
592
+ * fields omitted is normal and stays as today"). The caller MUST check
593
+ * this list and raise its own loud, distinct finding naming the identity
594
+ * and the token — never silently default an unresolved descriptor to
595
+ * `{allow: 0, deny: 0}`, which previously made a real "cannot confirm"
596
+ * look identical to "confirmed: nothing granted" (e.g. the Enact
597
+ * build-service refs/heads/main PolicyExempt DENY check silently PASSED
598
+ * when its own ACL entry was missing, instead of failing loudly).
599
+ */
600
+ unresolved: string[];
601
+ }
602
+
603
+ /** 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. */
604
+ export async function fetchEffectivePermissions(
466
605
  namespaceId: string,
467
606
  token: string,
468
607
  descriptors: readonly string[],
469
- ): Promise<Map<string, { allow: number; deny: number }>> {
608
+ ): Promise<EffectivePermissionsResult> {
470
609
  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
- });
610
+ if (descriptors.length === 0) return { byDescriptor, unresolved: [] };
611
+ for (const batch of chunk(descriptors, MAX_DESCRIPTORS_PER_ACL_QUERY)) {
612
+ const result = await fetchAccessControlLists(namespaceId, token, batch);
613
+ for (const acl of result.value ?? []) {
614
+ for (const [descriptor, ace] of Object.entries(acl.acesDictionary ?? {})) {
615
+ if (!("extendedInfo" in ace)) continue; // see EffectivePermissionsResult.unresolved's doc comment
616
+ byDescriptor.set(descriptor, {
617
+ allow: ace.extendedInfo?.effectiveAllow ?? ace.allow ?? 0,
618
+ deny: ace.extendedInfo?.effectiveDeny ?? ace.deny ?? 0,
619
+ });
620
+ }
479
621
  }
480
622
  }
481
- return byDescriptor;
623
+ const unresolved = descriptors.filter((descriptor) => !byDescriptor.has(descriptor));
624
+ return { byDescriptor, unresolved };
482
625
  }
483
626
 
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;
627
+ /**
628
+ * Look up `descriptor`'s effective ACE in `effective`, or push a loud,
629
+ * distinct `permissions.<idFragment>.acl-entry-missing<idSuffix>` finding
630
+ * naming `description` and `token` and return undefined — the caller must
631
+ * then skip whatever bit checks it was about to run for this identity/token
632
+ * pair, never fall back to `{allow: 0, deny: 0}` (see
633
+ * `EffectivePermissionsResult.unresolved`'s doc comment for why that
634
+ * silently hid real forbidden-bit findings).
635
+ */
636
+ function resolveEffectiveAce(
637
+ effective: EffectivePermissionsResult,
638
+ descriptor: string,
639
+ idFragment: string,
640
+ idSuffix: string,
641
+ description: string,
642
+ tokenLabel: string,
643
+ drifts: RepoDoctorDrift[],
644
+ ): { allow: number; deny: number } | undefined {
645
+ const ace = effective.byDescriptor.get(descriptor);
646
+ if (ace) return ace;
647
+ drifts.push({
648
+ id: `permissions.${idFragment}.acl-entry-missing${idSuffix}`,
649
+ 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"`,
650
+ });
651
+ return undefined;
652
+ }
653
+
654
+ /**
655
+ * `ace.allow` bits with `ace.deny` bits removed — Azure DevOps: an explicit
656
+ * Deny always beats an Allow FOR THE SAME EFFECTIVE RESULT. `ace` here is
657
+ * always the SERVER's own already-combined `effectiveAllow`/`effectiveDeny`
658
+ * for ONE descriptor at ONE token (see `checkAgentPermissions()`'s doc
659
+ * comment for why this file never reconstructs that combination itself from
660
+ * several descriptors — the server already does it correctly, including
661
+ * token-level precedence a client-side union cannot see).
662
+ */
663
+ function effectiveAllowBits(ace: { allow: number; deny: number }): number {
664
+ return ace.allow & ~ace.deny;
499
665
  }
500
666
 
667
+ /** The ONE agent-permission check scoped to Enact (Tarun, 2026-09-30) — see `REPO_STANDARD.permissions.agents`'s own doc comment above for why. */
668
+ const ENACT_ONLY_AGENT_DENY: { readonly name: string; readonly bit: PermissionBitName } = {
669
+ name: "agent-reviewer",
670
+ bit: "genericContribute",
671
+ };
672
+
501
673
  function checkAgentBits(
502
674
  name: string,
503
675
  ace: { allow: number; deny: number },
504
676
  expected: { allow: readonly PermissionBitName[]; deny: readonly PermissionBitName[] },
677
+ strictMainBranchRules: boolean,
505
678
  drifts: RepoDoctorDrift[],
506
679
  ): void {
680
+ const effective = effectiveAllowBits(ace);
507
681
  for (const bitName of expected.allow) {
508
- if ((ace.allow & GIT_REPO_PERMISSION_BIT[bitName]) === 0) {
682
+ if ((effective & GIT_REPO_PERMISSION_BIT[bitName]) === 0) {
509
683
  drifts.push({
510
684
  id: `permissions.${name}.missing-${bitName}`,
511
685
  message: `${name} does not effectively have ${bitName} on this repository`,
@@ -513,7 +687,10 @@ function checkAgentBits(
513
687
  }
514
688
  }
515
689
  for (const bitName of expected.deny) {
516
- if ((ace.allow & GIT_REPO_PERMISSION_BIT[bitName]) !== 0) {
690
+ if (!strictMainBranchRules && name === ENACT_ONLY_AGENT_DENY.name && bitName === ENACT_ONLY_AGENT_DENY.bit) {
691
+ continue; // Tarun, 2026-09-30: leave agent-reviewer's permissions as they are outside Enact
692
+ }
693
+ if ((effective & GIT_REPO_PERMISSION_BIT[bitName]) !== 0) {
517
694
  drifts.push({
518
695
  id: `permissions.${name}.has-${bitName}`,
519
696
  message: `${name} effectively has ${bitName} on this repository, which the standard forbids`,
@@ -522,9 +699,59 @@ function checkAgentBits(
522
699
  }
523
700
  }
524
701
 
702
+ /**
703
+ * Resolves every agent's own LEGACY descriptor — see
704
+ * `resolveAgentLegacyDescriptor()`'s doc comment in lib/graph-identity.ts
705
+ * for the full live evidence this is built on (agent-tarun/agent-vivek/
706
+ * agent-reviewer hold no direct ACE at all in this org; their real
707
+ * Contribute/Contribute-to-pull-requests bits come entirely from group
708
+ * membership, and the Access Control Lists Query API cannot evaluate a raw
709
+ * Graph descriptor at all). `not-found` (Graph could not resolve the
710
+ * display name) and `expansion-failed` (Graph resolved it, but Identities —
711
+ * ExpandedUp could not) are reported as DISTINCT drifts rather than one
712
+ * generic failure — each names a different hop and a different fix.
713
+ * Identity lookups are cached across all three agents inside
714
+ * lib/graph-identity.ts (resetLegacyDescriptorCacheForTests()).
715
+ */
716
+ async function resolveAgentDescriptors(drifts: RepoDoctorDrift[]): Promise<Map<string, string>> {
717
+ const descriptorByAgent = new Map<string, string>();
718
+ for (const name of Object.keys(REPO_STANDARD.permissions.agents)) {
719
+ const resolved = await resolveAgentLegacyDescriptor(name);
720
+ if ("error" in resolved) {
721
+ const message =
722
+ resolved.error === "not-found"
723
+ ? `could not resolve a Graph descriptor for '${name}' (Graph subject query returned no exact display-name match)`
724
+ : `resolved a Graph descriptor for '${name}' but could not expand it to a legacy identity (Identities — Read Identities with queryMembership=ExpandedUp returned no match)`;
725
+ drifts.push({ id: `permissions.${name}.${resolved.error}`, message });
726
+ continue;
727
+ }
728
+ descriptorByAgent.set(name, resolved.descriptor);
729
+ }
730
+ return descriptorByAgent;
731
+ }
732
+
733
+ /**
734
+ * Independent review, 2026-09-30 (second pass — design correction): an
735
+ * earlier version of this check queried EACH agent's own descriptor AND
736
+ * every group it transitively belongs to, then unioned their allow/deny
737
+ * bits client-side. That is wrong in general: the security service's own
738
+ * per-descriptor evaluation already combines group membership WITH
739
+ * token-level inheritance using real precedence (an explicit Allow at the
740
+ * repo-scope token can override an inherited Deny that came down from the
741
+ * project-scope token) — live-verified on agent-tarun's real legacy
742
+ * descriptor (`effectiveAllow: 82038` with no direct ACE anywhere, i.e.
743
+ * fully server-computed from its group). A client-side union of
744
+ * separately-queried group ACEs cannot see WHICH token level a bit came
745
+ * from and can therefore disagree with the server: a false `missing-*`,
746
+ * or worse, silently hiding a real forbidden bit the server would report.
747
+ * The fix is to query the ACL with ONLY each agent's own legacy descriptor
748
+ * and trust ITS `effectiveAllow`/`effectiveDeny` directly — never to
749
+ * reconstruct that answer from parts.
750
+ */
525
751
  async function checkAgentPermissions(
526
752
  projectId: string | undefined,
527
753
  repositoryId: string,
754
+ strictMainBranchRules: boolean,
528
755
  drifts: RepoDoctorDrift[],
529
756
  ): Promise<void> {
530
757
  if (!projectId) {
@@ -544,7 +771,9 @@ async function checkAgentPermissions(
544
771
  for (const [name, expected] of Object.entries(REPO_STANDARD.permissions.agents)) {
545
772
  const descriptor = descriptorByAgent.get(name);
546
773
  if (!descriptor) continue; // already reported above
547
- checkAgentBits(name, effective.get(descriptor) ?? { allow: 0, deny: 0 }, expected, drifts);
774
+ const ace = resolveEffectiveAce(effective, descriptor, name, "", name, "this repository", drifts);
775
+ if (!ace) continue; // already reported by resolveEffectiveAce()
776
+ checkAgentBits(name, ace, expected, strictMainBranchRules, drifts);
548
777
  }
549
778
  }
550
779
 
@@ -580,8 +809,7 @@ async function resolveBuildServiceDescriptor(namespaceId: string, projectId: str
580
809
  const result = await fetchAccessControlLists(namespaceId, `repoV2/${projectId}`);
581
810
  for (const acl of result.value ?? []) {
582
811
  for (const descriptor of Object.keys(acl.acesDictionary ?? {})) {
583
- if (descriptor.startsWith("Microsoft.TeamFoundation.ServiceIdentity") && descriptor.includes(projectId))
584
- return descriptor;
812
+ if (isProjectBuildServiceDescriptor(descriptor, projectId)) return descriptor;
585
813
  }
586
814
  }
587
815
  return undefined;
@@ -597,6 +825,7 @@ async function checkBuildServicePermissions(
597
825
  namespaceId: string,
598
826
  projectId: string | undefined,
599
827
  repositoryId: string,
828
+ strictMainBranchRules: boolean,
600
829
  drifts: RepoDoctorDrift[],
601
830
  ): Promise<void> {
602
831
  if (!projectId) return; // already reported by checkAgentPermissions()
@@ -610,40 +839,73 @@ async function checkBuildServicePermissions(
610
839
  return;
611
840
  }
612
841
 
842
+ // Deny-aware throughout (effectiveAllowBits()'s own doc comment): a
843
+ // single descriptor can itself carry both an allow and a deny
844
+ // bit (effectiveAllow/effectiveDeny are independent fields on the same
845
+ // ACE) — the build service's own group-membership situation is different
846
+ // from the agents' (live evidence: it holds direct ACEs everywhere this
847
+ // function checks, no group expansion needed), but the SAME deny-wins
848
+ // arithmetic applies regardless of where the bits came from.
613
849
  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
- });
850
+ const repoEffective = resolveEffectiveAce(
851
+ await fetchEffectivePermissions(namespaceId, repoToken, [descriptor]),
852
+ descriptor,
853
+ "build-service",
854
+ "",
855
+ "the project build service",
856
+ "this repository",
857
+ drifts,
858
+ );
859
+ if (repoEffective) {
860
+ for (const bitName of REPO_STANDARD.permissions.buildService.repo.allow) {
861
+ if ((effectiveAllowBits(repoEffective) & GIT_REPO_PERMISSION_BIT[bitName]) === 0) {
862
+ drifts.push({
863
+ id: `permissions.build-service.missing-${bitName}`,
864
+ message: `the project build service does not effectively have ${bitName} on this repository`,
865
+ });
866
+ }
624
867
  }
625
868
  }
626
869
 
627
870
  const integrationToken = `${repoToken}/refs/heads/${refNameHexUtf16Le("integration")}`;
628
- const integrationEffective = (await fetchEffectivePermissions(namespaceId, integrationToken, [descriptor])).get(
871
+ const integrationEffective = resolveEffectiveAce(
872
+ await fetchEffectivePermissions(namespaceId, integrationToken, [descriptor]),
629
873
  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
- });
874
+ "build-service",
875
+ "-integration",
876
+ "the project build service",
877
+ "refs/heads/integration",
878
+ drifts,
879
+ );
880
+ if (integrationEffective) {
881
+ for (const bitName of REPO_STANDARD.permissions.buildService.integrationRef.allow) {
882
+ if ((effectiveAllowBits(integrationEffective) & GIT_REPO_PERMISSION_BIT[bitName]) === 0) {
883
+ drifts.push({
884
+ id: `permissions.build-service.missing-${bitName}-integration`,
885
+ message: `the project build service does not effectively have ${bitName} on refs/heads/integration`,
886
+ });
887
+ }
637
888
  }
638
889
  }
639
890
 
891
+ // Enact-only (STRICT_MAIN_BRANCH_PROJECT above): a repo outside Enact keeps
892
+ // whatever PolicyExempt the build service already has on main, flagged
893
+ // neither way — skip the ACL read entirely rather than fetch-and-ignore.
894
+ if (!strictMainBranchRules) return;
895
+
640
896
  const mainToken = `${repoToken}/refs/heads/${refNameHexUtf16Le("main")}`;
641
- const mainEffective = (await fetchEffectivePermissions(namespaceId, mainToken, [descriptor])).get(descriptor) ?? {
642
- allow: 0,
643
- deny: 0,
644
- };
897
+ const mainEffective = resolveEffectiveAce(
898
+ await fetchEffectivePermissions(namespaceId, mainToken, [descriptor]),
899
+ descriptor,
900
+ "build-service",
901
+ "-main",
902
+ "the project build service",
903
+ "refs/heads/main",
904
+ drifts,
905
+ );
906
+ if (!mainEffective) return;
645
907
  for (const bitName of REPO_STANDARD.permissions.buildService.mainRef.deny) {
646
- if ((mainEffective.allow & GIT_REPO_PERMISSION_BIT[bitName]) !== 0) {
908
+ if ((effectiveAllowBits(mainEffective) & GIT_REPO_PERMISSION_BIT[bitName]) !== 0) {
647
909
  drifts.push({
648
910
  id: `permissions.build-service.has-${bitName}-main`,
649
911
  message: `the project build service effectively has ${bitName} on refs/heads/main, which the standard forbids`,
@@ -672,6 +934,16 @@ function releaseFixPath(repository: string, identityDisplayName: string, bitName
672
934
  return `Project settings → Repositories → \`${repository}\` → Security → \`${identityDisplayName}\` → \`${GIT_REPO_PERMISSION_UI_LABEL[bitName]}\` = Allow`;
673
935
  }
674
936
 
937
+ /** 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). */
938
+ function releaseFixPathForRef(
939
+ repository: string,
940
+ ref: string,
941
+ identityDisplayName: string,
942
+ bitName: PermissionBitName,
943
+ ): string {
944
+ return `Repos → \`${repository}\` → Branches → \`${ref}\` → ⋯ → Branch security → \`${identityDisplayName}\` → \`${GIT_REPO_PERMISSION_UI_LABEL[bitName]}\` = Allow`;
945
+ }
946
+
675
947
  /**
676
948
  * Which build service identity actually runs the pipeline, per Build —
677
949
  * General Settings — Get's `enforceJobAuthScope` (file header) — "Limit job
@@ -693,15 +965,21 @@ async function fetchEnforceJobAuthScope(c: AdoClient): Promise<boolean | undefin
693
965
 
694
966
  /**
695
967
  * 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.
968
+ * display name (Identities — Read Identities, file header), then check
969
+ * `buildServiceRelease.repoScoped` on the repository token AND
970
+ * `buildServiceRelease.integrationRefScoped` on the `integration` ref token
971
+ * — NOT the same token for all three (see REPO_STANDARD.permissions.
972
+ * buildServiceRelease's own doc comment: PolicyExempt is a per-BRANCH grant
973
+ * in this org, verified live against real bypass-on-push evidence, and
974
+ * checking it at repo scope produced a false "missing" finding). Every
975
+ * failure mode is its own loud, distinct drift — an unresolvable identity,
976
+ * an ACL the calling agent cannot read, and each missing permission named
977
+ * with its fix path — never a silent pass.
701
978
  */
702
979
  async function checkOneBuildServiceCanRelease(
703
980
  namespaceId: string,
704
981
  repoToken: string,
982
+ integrationRefToken: string,
705
983
  repository: string,
706
984
  displayName: string,
707
985
  drifts: RepoDoctorDrift[],
@@ -727,26 +1005,41 @@ async function checkOneBuildServiceCanRelease(
727
1005
  return;
728
1006
  }
729
1007
 
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);
1008
+ // LOW, independent review 2026-09-30: name WHICH token failed — "the ACL"
1009
+ // alone left a human unable to tell a repo-scope 403 from a
1010
+ // refs/heads/integration-scope one without re-running this by hand.
1011
+ async function effectiveAt(token: string, tokenLabel: string): Promise<{ allow: number; deny: number } | undefined> {
1012
+ let effective: EffectivePermissionsResult;
1013
+ try {
1014
+ effective = await fetchEffectivePermissions(namespaceId, token, [descriptor!]);
1015
+ } catch (error) {
1016
+ const detail = error instanceof Error ? error.message : String(error);
1017
+ drifts.push({
1018
+ id: `permissions.release.${slug}.acl-read-forbidden`,
1019
+ 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}`,
1020
+ });
1021
+ return undefined;
1022
+ }
1023
+ return resolveEffectiveAce(effective, descriptor!, `release.${slug}`, "", `'${displayName}'`, tokenLabel, drifts);
1024
+ }
1025
+
1026
+ const repoEffective = await effectiveAt(repoToken, "repo-scope");
1027
+ if (!repoEffective) return;
1028
+ for (const bitName of REPO_STANDARD.permissions.buildServiceRelease.repoScoped) {
1029
+ if ((effectiveAllowBits(repoEffective) & GIT_REPO_PERMISSION_BIT[bitName]) !== 0) continue;
738
1030
  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}`,
1031
+ id: `permissions.release.${slug}.missing-${bitName}`,
1032
+ 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
1033
  });
742
- return;
743
1034
  }
744
1035
 
745
- for (const bitName of REPO_STANDARD.permissions.buildServiceRelease.required) {
746
- if ((effective.allow & GIT_REPO_PERMISSION_BIT[bitName]) !== 0) continue;
1036
+ const integrationEffective = await effectiveAt(integrationRefToken, "refs/heads/integration");
1037
+ if (!integrationEffective) return;
1038
+ for (const bitName of REPO_STANDARD.permissions.buildServiceRelease.integrationRefScoped) {
1039
+ if ((effectiveAllowBits(integrationEffective) & GIT_REPO_PERMISSION_BIT[bitName]) !== 0) continue;
747
1040
  drifts.push({
748
1041
  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)}`,
1042
+ 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
1043
  });
751
1044
  }
752
1045
  }
@@ -779,8 +1072,9 @@ async function checkBuildServiceCanRelease(
779
1072
  : [projectScoped, collectionScoped];
780
1073
 
781
1074
  const repoToken = `repoV2/${projectId}/${repositoryId}`;
1075
+ const integrationRefToken = `${repoToken}/refs/heads/${refNameHexUtf16Le("integration")}`;
782
1076
  for (const displayName of displayNames) {
783
- await checkOneBuildServiceCanRelease(namespaceId, repoToken, repository, displayName, drifts);
1077
+ await checkOneBuildServiceCanRelease(namespaceId, repoToken, integrationRefToken, repository, displayName, drifts);
784
1078
  }
785
1079
  }
786
1080
 
@@ -825,14 +1119,30 @@ export async function repoDoctorCheck(options: RepoDoctorOptions = {}): Promise<
825
1119
  });
826
1120
  }
827
1121
 
1122
+ const repositoryProjectName = repoInfo.project?.name;
1123
+ if (!repositoryProjectName) {
1124
+ drifts.push({
1125
+ id: "branches.no-project-name",
1126
+ message:
1127
+ "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",
1128
+ });
1129
+ }
1130
+ const strictMainBranchRules = repositoryProjectName ? strictMainBranchRulesApply(repositoryProjectName) : false;
1131
+
828
1132
  const hasPipeline = await repositoryHasAPipeline(c, repositoryId);
829
1133
  const integrationPolicies = await fetchBranchPolicies(c, repositoryId, "integration");
830
1134
  const mainPolicies = await fetchBranchPolicies(c, repositoryId, "main");
831
1135
  checkIntegrationPolicies(integrationPolicies, hasPipeline, drifts);
832
- checkMainPolicies(mainPolicies, hasPipeline, drifts);
1136
+ checkMainPolicies(mainPolicies, hasPipeline, strictMainBranchRules, drifts);
833
1137
 
834
- await checkAgentPermissions(projectId, repositoryId, drifts);
835
- await checkBuildServicePermissions(REPO_STANDARD.permissions.namespaceId, projectId, repositoryId, drifts);
1138
+ await checkAgentPermissions(projectId, repositoryId, strictMainBranchRules, drifts);
1139
+ await checkBuildServicePermissions(
1140
+ REPO_STANDARD.permissions.namespaceId,
1141
+ projectId,
1142
+ repositoryId,
1143
+ strictMainBranchRules,
1144
+ drifts,
1145
+ );
836
1146
  await checkBuildServiceCanRelease(c, REPO_STANDARD.permissions.namespaceId, projectId, repositoryId, repository, drifts);
837
1147
 
838
1148
  return { repository, compliant: drifts.length === 0, drifts };