@de-otio/repo-aegis-core 0.2.0
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/dist/age.d.ts +32 -0
- package/dist/age.d.ts.map +1 -0
- package/dist/age.js +98 -0
- package/dist/age.js.map +1 -0
- package/dist/audit-log.d.ts +50 -0
- package/dist/audit-log.d.ts.map +1 -0
- package/dist/audit-log.js +183 -0
- package/dist/audit-log.js.map +1 -0
- package/dist/audit-log.test.d.ts +2 -0
- package/dist/audit-log.test.d.ts.map +1 -0
- package/dist/audit-log.test.js +181 -0
- package/dist/audit-log.test.js.map +1 -0
- package/dist/deny-set.d.ts +43 -0
- package/dist/deny-set.d.ts.map +1 -0
- package/dist/deny-set.js +165 -0
- package/dist/deny-set.js.map +1 -0
- package/dist/deny-set.test.d.ts +2 -0
- package/dist/deny-set.test.d.ts.map +1 -0
- package/dist/deny-set.test.js +155 -0
- package/dist/deny-set.test.js.map +1 -0
- package/dist/exceptions.d.ts +96 -0
- package/dist/exceptions.d.ts.map +1 -0
- package/dist/exceptions.js +143 -0
- package/dist/exceptions.js.map +1 -0
- package/dist/exit-codes.d.ts +4 -0
- package/dist/exit-codes.d.ts.map +1 -0
- package/dist/exit-codes.js +6 -0
- package/dist/exit-codes.js.map +1 -0
- package/dist/first-touch.d.ts +57 -0
- package/dist/first-touch.d.ts.map +1 -0
- package/dist/first-touch.js +112 -0
- package/dist/first-touch.js.map +1 -0
- package/dist/import-graph.test.d.ts +2 -0
- package/dist/import-graph.test.d.ts.map +1 -0
- package/dist/import-graph.test.js +210 -0
- package/dist/import-graph.test.js.map +1 -0
- package/dist/index.d.ts +37 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +68 -0
- package/dist/index.js.map +1 -0
- package/dist/lock.d.ts +22 -0
- package/dist/lock.d.ts.map +1 -0
- package/dist/lock.js +86 -0
- package/dist/lock.js.map +1 -0
- package/dist/lock.test.d.ts +2 -0
- package/dist/lock.test.d.ts.map +1 -0
- package/dist/lock.test.js +125 -0
- package/dist/lock.test.js.map +1 -0
- package/dist/paths.d.ts +22 -0
- package/dist/paths.d.ts.map +1 -0
- package/dist/paths.js +46 -0
- package/dist/paths.js.map +1 -0
- package/dist/paths.test.d.ts +2 -0
- package/dist/paths.test.d.ts.map +1 -0
- package/dist/paths.test.js +78 -0
- package/dist/paths.test.js.map +1 -0
- package/dist/redaction.d.ts +29 -0
- package/dist/redaction.d.ts.map +1 -0
- package/dist/redaction.js +48 -0
- package/dist/redaction.js.map +1 -0
- package/dist/redaction.test.d.ts +2 -0
- package/dist/redaction.test.d.ts.map +1 -0
- package/dist/redaction.test.js +67 -0
- package/dist/redaction.test.js.map +1 -0
- package/dist/regex-safety.d.ts +87 -0
- package/dist/regex-safety.d.ts.map +1 -0
- package/dist/regex-safety.js +322 -0
- package/dist/regex-safety.js.map +1 -0
- package/dist/regex-safety.test.d.ts +2 -0
- package/dist/regex-safety.test.d.ts.map +1 -0
- package/dist/regex-safety.test.js +149 -0
- package/dist/regex-safety.test.js.map +1 -0
- package/dist/registry-mutate.d.ts +35 -0
- package/dist/registry-mutate.d.ts.map +1 -0
- package/dist/registry-mutate.js +149 -0
- package/dist/registry-mutate.js.map +1 -0
- package/dist/registry-mutate.test.d.ts +2 -0
- package/dist/registry-mutate.test.d.ts.map +1 -0
- package/dist/registry-mutate.test.js +96 -0
- package/dist/registry-mutate.test.js.map +1 -0
- package/dist/registry.d.ts +64 -0
- package/dist/registry.d.ts.map +1 -0
- package/dist/registry.js +120 -0
- package/dist/registry.js.map +1 -0
- package/dist/registry.test.d.ts +2 -0
- package/dist/registry.test.d.ts.map +1 -0
- package/dist/registry.test.js +316 -0
- package/dist/registry.test.js.map +1 -0
- package/dist/remote-url.d.ts +18 -0
- package/dist/remote-url.d.ts.map +1 -0
- package/dist/remote-url.js +66 -0
- package/dist/remote-url.js.map +1 -0
- package/dist/remote-url.test.d.ts +2 -0
- package/dist/remote-url.test.d.ts.map +1 -0
- package/dist/remote-url.test.js +116 -0
- package/dist/remote-url.test.js.map +1 -0
- package/dist/render.d.ts +54 -0
- package/dist/render.d.ts.map +1 -0
- package/dist/render.js +182 -0
- package/dist/render.js.map +1 -0
- package/dist/render.test.d.ts +2 -0
- package/dist/render.test.d.ts.map +1 -0
- package/dist/render.test.js +152 -0
- package/dist/render.test.js.map +1 -0
- package/dist/repo.d.ts +40 -0
- package/dist/repo.d.ts.map +1 -0
- package/dist/repo.js +214 -0
- package/dist/repo.js.map +1 -0
- package/dist/repo.test.d.ts +2 -0
- package/dist/repo.test.d.ts.map +1 -0
- package/dist/repo.test.js +234 -0
- package/dist/repo.test.js.map +1 -0
- package/dist/scan.d.ts +103 -0
- package/dist/scan.d.ts.map +1 -0
- package/dist/scan.js +436 -0
- package/dist/scan.js.map +1 -0
- package/dist/scan.test.d.ts +2 -0
- package/dist/scan.test.d.ts.map +1 -0
- package/dist/scan.test.js +437 -0
- package/dist/scan.test.js.map +1 -0
- package/dist/schemas.d.ts +50 -0
- package/dist/schemas.d.ts.map +1 -0
- package/dist/schemas.js +190 -0
- package/dist/schemas.js.map +1 -0
- package/dist/secret-markers.d.ts +34 -0
- package/dist/secret-markers.d.ts.map +1 -0
- package/dist/secret-markers.js +118 -0
- package/dist/secret-markers.js.map +1 -0
- package/dist/secret-markers.test.d.ts +2 -0
- package/dist/secret-markers.test.d.ts.map +1 -0
- package/dist/secret-markers.test.js +154 -0
- package/dist/secret-markers.test.js.map +1 -0
- package/dist/trust-boundary.d.ts +33 -0
- package/dist/trust-boundary.d.ts.map +1 -0
- package/dist/trust-boundary.js +77 -0
- package/dist/trust-boundary.js.map +1 -0
- package/dist/trust-boundary.test.d.ts +2 -0
- package/dist/trust-boundary.test.d.ts.map +1 -0
- package/dist/trust-boundary.test.js +170 -0
- package/dist/trust-boundary.test.js.map +1 -0
- package/dist/types.d.ts +47 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +8 -0
- package/dist/types.js.map +1 -0
- package/dist/working-tree.d.ts +38 -0
- package/dist/working-tree.d.ts.map +1 -0
- package/dist/working-tree.js +133 -0
- package/dist/working-tree.js.map +1 -0
- package/dist/working-tree.test.d.ts +2 -0
- package/dist/working-tree.test.d.ts.map +1 -0
- package/dist/working-tree.test.js +162 -0
- package/dist/working-tree.test.js.map +1 -0
- package/package.json +40 -0
- package/src/age.ts +113 -0
- package/src/audit-log.test.ts +222 -0
- package/src/audit-log.ts +215 -0
- package/src/deny-set.test.ts +208 -0
- package/src/deny-set.ts +231 -0
- package/src/exceptions.ts +134 -0
- package/src/exit-codes.ts +5 -0
- package/src/first-touch.ts +172 -0
- package/src/import-graph.test.ts +239 -0
- package/src/index.ts +191 -0
- package/src/lock.test.ts +151 -0
- package/src/lock.ts +88 -0
- package/src/paths.test.ts +94 -0
- package/src/paths.ts +55 -0
- package/src/redaction.test.ts +81 -0
- package/src/redaction.ts +49 -0
- package/src/regex-safety.test.ts +194 -0
- package/src/regex-safety.ts +349 -0
- package/src/registry-mutate.test.ts +134 -0
- package/src/registry-mutate.ts +185 -0
- package/src/registry.test.ts +460 -0
- package/src/registry.ts +178 -0
- package/src/remote-url.test.ts +121 -0
- package/src/remote-url.ts +78 -0
- package/src/render.test.ts +206 -0
- package/src/render.ts +215 -0
- package/src/repo.test.ts +275 -0
- package/src/repo.ts +245 -0
- package/src/scan.test.ts +580 -0
- package/src/scan.ts +531 -0
- package/src/schemas.ts +207 -0
- package/src/secret-markers.test.ts +183 -0
- package/src/secret-markers.ts +145 -0
- package/src/trust-boundary.test.ts +198 -0
- package/src/trust-boundary.ts +98 -0
- package/src/types.ts +55 -0
- package/src/working-tree.test.ts +193 -0
- package/src/working-tree.ts +130 -0
package/dist/schemas.js
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
// Copyright (C) 2026 Richard Myers and contributors.
|
|
3
|
+
// Single source of truth for on-disk schema definitions.
|
|
4
|
+
//
|
|
5
|
+
// All YAML configuration files repo-aegis reads at runtime — the
|
|
6
|
+
// engagement registry, the per-repo `.repo-aegis.yml` override, the
|
|
7
|
+
// classify rules file, the scanner queries file — flow through one of
|
|
8
|
+
// the schemas below. The `parse-and-narrow` helpers wrap zod's parse
|
|
9
|
+
// with the canonical CLI error type (RegistryParseError /
|
|
10
|
+
// RepoOverrideError / ad-hoc) so callers don't have to translate
|
|
11
|
+
// ZodError shape themselves.
|
|
12
|
+
//
|
|
13
|
+
// Why centralise here: previously each callsite hand-rolled a series of
|
|
14
|
+
// `typeof obj.foo !== "string"` checks. Adding a field meant editing
|
|
15
|
+
// the parser, the type, and the validator separately and hoping they
|
|
16
|
+
// stayed in sync. With zod, the schema *is* the type *is* the
|
|
17
|
+
// validator.
|
|
18
|
+
import { z } from "zod";
|
|
19
|
+
/**
|
|
20
|
+
* Format a ZodError into the multi-line "human-readable" string we used
|
|
21
|
+
* to build by hand in the per-callsite validators. The output preserves
|
|
22
|
+
* the substrings that existing tests pin on (e.g. "must be", "missing",
|
|
23
|
+
* "reserved").
|
|
24
|
+
*
|
|
25
|
+
* For deeply nested issues we render `engagements[3].markers: ...`
|
|
26
|
+
* style paths — matches what the previous bespoke loops emitted.
|
|
27
|
+
*/
|
|
28
|
+
export function formatZodError(err, kind) {
|
|
29
|
+
const lines = [];
|
|
30
|
+
for (const issue of err.issues) {
|
|
31
|
+
const path = issue.path
|
|
32
|
+
.map((seg, i) => {
|
|
33
|
+
if (typeof seg === "number")
|
|
34
|
+
return `[${seg}]`;
|
|
35
|
+
return i === 0 ? String(seg) : `.${String(seg)}`;
|
|
36
|
+
})
|
|
37
|
+
.join("");
|
|
38
|
+
const where = path === "" ? kind : path;
|
|
39
|
+
lines.push(`${where}: ${issue.message}`);
|
|
40
|
+
}
|
|
41
|
+
return lines.length === 1 ? lines[0] : lines.join("; ");
|
|
42
|
+
}
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
// Engagement registry (~/.config/repo-aegis/engagements.yaml)
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
const ALWAYS_BLOCK_RESERVED_ID_LITERAL = "_always";
|
|
47
|
+
/**
|
|
48
|
+
* Allowed shape for a GitHub org name, used in `personalOrgs` and
|
|
49
|
+
* `engagements[*].githubOrgs`. Mirrors GitHub's own org-name constraint:
|
|
50
|
+
* lowercase alphanumerics and hyphens, starting with an alphanumeric.
|
|
51
|
+
*
|
|
52
|
+
* Schema rejects uppercase. Callers writing into the registry (e.g.
|
|
53
|
+
* `engagements add --github-org`) must lowercase input before persisting;
|
|
54
|
+
* the gate-time read path trusts the schema (no runtime case-folding).
|
|
55
|
+
*/
|
|
56
|
+
export const ORG_NAME_REGEX = /^[a-z0-9][a-z0-9-]*$/;
|
|
57
|
+
const orgNameSchema = z
|
|
58
|
+
.string()
|
|
59
|
+
.min(1, "org name must be non-empty")
|
|
60
|
+
.regex(ORG_NAME_REGEX, {
|
|
61
|
+
message: "org name must be lowercase, start with [a-z0-9], and contain only [a-z0-9-]",
|
|
62
|
+
});
|
|
63
|
+
const engagementSchema = z
|
|
64
|
+
.object({
|
|
65
|
+
id: z
|
|
66
|
+
.string()
|
|
67
|
+
.min(1, "missing or empty 'id'")
|
|
68
|
+
.refine(v => v !== ALWAYS_BLOCK_RESERVED_ID_LITERAL, {
|
|
69
|
+
message: `engagement id "${ALWAYS_BLOCK_RESERVED_ID_LITERAL}" is reserved; ` +
|
|
70
|
+
`use the top-level 'always_block:' field for org-wide markers`,
|
|
71
|
+
}),
|
|
72
|
+
name: z.string({ message: "missing string 'name'" }),
|
|
73
|
+
started: z.string().nullable().optional(),
|
|
74
|
+
ended: z.string().nullable().optional(),
|
|
75
|
+
reposActive: z.array(z.string()).optional(),
|
|
76
|
+
markers: z.array(z.string(), { message: "missing 'markers' list" }),
|
|
77
|
+
notes: z.string().optional(),
|
|
78
|
+
/**
|
|
79
|
+
* GitHub orgs that map to this engagement. Phase 1 of the zero-config
|
|
80
|
+
* onboarding work: a repo whose origin's org appears here is auto-
|
|
81
|
+
* classified as `customer-coupled` with this engagement attached.
|
|
82
|
+
* Schema-level constraints: each entry must match {@link ORG_NAME_REGEX};
|
|
83
|
+
* cross-engagement uniqueness and disjointness with `personalOrgs` are
|
|
84
|
+
* enforced by the registry-level superRefine.
|
|
85
|
+
*/
|
|
86
|
+
githubOrgs: z.array(orgNameSchema).optional(),
|
|
87
|
+
})
|
|
88
|
+
.passthrough(); // Forward-compat: unknown sibling fields are kept, not rejected.
|
|
89
|
+
export const registryFileSchema = z
|
|
90
|
+
.object({
|
|
91
|
+
schemaVersion: z
|
|
92
|
+
.number({ message: "'schemaVersion' must be a number" })
|
|
93
|
+
.optional(),
|
|
94
|
+
always_block: z
|
|
95
|
+
.array(z.string({ message: "'always_block' entries must be strings" }), {
|
|
96
|
+
message: "'always_block' must be a list of patterns",
|
|
97
|
+
})
|
|
98
|
+
.optional(),
|
|
99
|
+
/**
|
|
100
|
+
* Top-level orgs the user owns / treats as public. A repo whose
|
|
101
|
+
* origin's org is here is auto-classified as `public-eligible`.
|
|
102
|
+
* Disjoint from any engagement's `githubOrgs`.
|
|
103
|
+
*/
|
|
104
|
+
personalOrgs: z.array(orgNameSchema).optional(),
|
|
105
|
+
engagements: z.array(engagementSchema, { message: "'engagements' must be a list" }),
|
|
106
|
+
})
|
|
107
|
+
.passthrough()
|
|
108
|
+
// Cross-field validation: org names must be unique across the
|
|
109
|
+
// (personalOrgs ∪ Σ engagements[*].githubOrgs) union, and within
|
|
110
|
+
// personalOrgs itself. Same-string overlap is a fail-closed parse error.
|
|
111
|
+
.superRefine((data, ctx) => {
|
|
112
|
+
const personalOrgs = data.personalOrgs ?? [];
|
|
113
|
+
// 1. personalOrgs internal duplicates.
|
|
114
|
+
const personalSeen = new Map();
|
|
115
|
+
for (let i = 0; i < personalOrgs.length; i++) {
|
|
116
|
+
const org = personalOrgs[i];
|
|
117
|
+
if (org === undefined)
|
|
118
|
+
continue;
|
|
119
|
+
const prev = personalSeen.get(org);
|
|
120
|
+
if (prev !== undefined) {
|
|
121
|
+
ctx.addIssue({
|
|
122
|
+
code: z.ZodIssueCode.custom,
|
|
123
|
+
path: ["personalOrgs", i],
|
|
124
|
+
message: `duplicate org "${org}" (also at personalOrgs[${prev}])`,
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
else {
|
|
128
|
+
personalSeen.set(org, i);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
const personalSet = new Set(personalOrgs);
|
|
132
|
+
// 2. cross-engagement uniqueness + disjointness with personalOrgs.
|
|
133
|
+
const orgToEng = new Map();
|
|
134
|
+
for (let i = 0; i < data.engagements.length; i++) {
|
|
135
|
+
const eng = data.engagements[i];
|
|
136
|
+
if (eng === undefined)
|
|
137
|
+
continue;
|
|
138
|
+
const orgs = eng.githubOrgs ?? [];
|
|
139
|
+
for (let j = 0; j < orgs.length; j++) {
|
|
140
|
+
const org = orgs[j];
|
|
141
|
+
if (org === undefined)
|
|
142
|
+
continue;
|
|
143
|
+
if (personalSet.has(org)) {
|
|
144
|
+
ctx.addIssue({
|
|
145
|
+
code: z.ZodIssueCode.custom,
|
|
146
|
+
path: ["engagements", i, "githubOrgs", j],
|
|
147
|
+
message: `org "${org}" appears in personalOrgs and ` +
|
|
148
|
+
`engagements[${i}=${eng.id}].githubOrgs; ` +
|
|
149
|
+
`the two are mutually exclusive`,
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
const prev = orgToEng.get(org);
|
|
153
|
+
if (prev !== undefined) {
|
|
154
|
+
ctx.addIssue({
|
|
155
|
+
code: z.ZodIssueCode.custom,
|
|
156
|
+
path: ["engagements", i, "githubOrgs", j],
|
|
157
|
+
message: `org "${org}" is also in ` +
|
|
158
|
+
`engagements[${prev.engIdx}=${prev.engId}].githubOrgs; ` +
|
|
159
|
+
`an org maps to at most one engagement`,
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
else {
|
|
163
|
+
orgToEng.set(org, { engIdx: i, engId: eng.id, orgIdx: j });
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
// ---------------------------------------------------------------------------
|
|
169
|
+
// Per-repo override (.repo-aegis.yml)
|
|
170
|
+
// ---------------------------------------------------------------------------
|
|
171
|
+
export const REPO_CLASSES = [
|
|
172
|
+
"public-eligible",
|
|
173
|
+
"private-strict",
|
|
174
|
+
"customer-coupled",
|
|
175
|
+
"scratch",
|
|
176
|
+
];
|
|
177
|
+
export const repoOverrideSchema = z
|
|
178
|
+
.object({
|
|
179
|
+
class: z.enum(REPO_CLASSES).optional(),
|
|
180
|
+
engagements: z
|
|
181
|
+
.array(z.string().min(1, "'engagements' entries must be non-empty strings"))
|
|
182
|
+
.optional(),
|
|
183
|
+
})
|
|
184
|
+
.passthrough();
|
|
185
|
+
// Note: schemas for files only loaded by `cli` (classify rules) or
|
|
186
|
+
// `scan` (query files) live in those packages, not here. They use the
|
|
187
|
+
// shared `formatZodError` helper exported above for consistent error
|
|
188
|
+
// rendering, but the schemas themselves are package-private — moving
|
|
189
|
+
// them here would inflate core's public surface for no gain.
|
|
190
|
+
//# sourceMappingURL=schemas.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"schemas.js","sourceRoot":"","sources":["../src/schemas.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
export type SecretMarkerKind = "PEM_HEADER" | "PEM_AS_HEX" | "JWT" | "GITHUB_TOKEN";
|
|
2
|
+
export interface SecretMarkerHit {
|
|
3
|
+
/** Which family of secret was detected. */
|
|
4
|
+
kind: SecretMarkerKind;
|
|
5
|
+
/** Byte offset of the match within the scanned text. */
|
|
6
|
+
offset: number;
|
|
7
|
+
/** Length of the match in bytes. */
|
|
8
|
+
length: number;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Scan a single string for any secret-shaped pattern. Returns one hit
|
|
12
|
+
* per match (a single PEM that's also matched as PEM-as-hex via
|
|
13
|
+
* pre-encoding will produce two hits — that's fine; the hook
|
|
14
|
+
* deduplicates by kind on output).
|
|
15
|
+
*
|
|
16
|
+
* The scanned input is bounded by the caller (the Bash-output hook
|
|
17
|
+
* caps at a few MB). This function does not enforce its own size cap
|
|
18
|
+
* because callers vary in tolerance.
|
|
19
|
+
*
|
|
20
|
+
* The function NEVER returns the matched substring — only the kind,
|
|
21
|
+
* offset, and length. By construction, a hit cannot leak the secret
|
|
22
|
+
* back through the result type.
|
|
23
|
+
*/
|
|
24
|
+
export declare function scanForSecrets(text: string): SecretMarkerHit[];
|
|
25
|
+
/**
|
|
26
|
+
* Summarise hits as a redacted preview suitable for echoing back to
|
|
27
|
+
* the user / agent. Returns the kinds present and the total count;
|
|
28
|
+
* never includes any byte of matched content.
|
|
29
|
+
*/
|
|
30
|
+
export declare function summariseHits(hits: SecretMarkerHit[]): {
|
|
31
|
+
kinds: SecretMarkerKind[];
|
|
32
|
+
count: number;
|
|
33
|
+
};
|
|
34
|
+
//# sourceMappingURL=secret-markers.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"secret-markers.d.ts","sourceRoot":"","sources":["../src/secret-markers.ts"],"names":[],"mappings":"AAyBA,MAAM,MAAM,gBAAgB,GACxB,YAAY,GACZ,YAAY,GACZ,KAAK,GACL,cAAc,CAAC;AAEnB,MAAM,WAAW,eAAe;IAC9B,2CAA2C;IAC3C,IAAI,EAAE,gBAAgB,CAAC;IACvB,wDAAwD;IACxD,MAAM,EAAE,MAAM,CAAC;IACf,oCAAoC;IACpC,MAAM,EAAE,MAAM,CAAC;CAChB;AAsDD;;;;;;;;;;;;;GAaG;AACH,wBAAgB,cAAc,CAAC,IAAI,EAAE,MAAM,GAAG,eAAe,EAAE,CAyB9D;AAED;;;;GAIG;AACH,wBAAgB,aAAa,CAAC,IAAI,EAAE,eAAe,EAAE,GAAG;IACtD,KAAK,EAAE,gBAAgB,EAAE,CAAC;IAC1B,KAAK,EAAE,MAAM,CAAC;CACf,CAGA"}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
// Copyright (C) 2026 Richard Myers and contributors.
|
|
3
|
+
//
|
|
4
|
+
// Universal secret-shaped patterns. Unlike the engagement-scoped deny
|
|
5
|
+
// set in `deny-set.ts`, these patterns are not configurable: they cover
|
|
6
|
+
// shapes that should never appear in Bash tool output regardless of
|
|
7
|
+
// which customer the developer is engaged with.
|
|
8
|
+
//
|
|
9
|
+
// Detection is deliberately conservative — false-positive risk on
|
|
10
|
+
// generic strings is acceptable because the consequence (a hook
|
|
11
|
+
// flagging a tool result so the agent can surface it for rotation) is
|
|
12
|
+
// cheap. False *negatives* are expensive (a real secret reaches the
|
|
13
|
+
// transcript). When in doubt, add another pattern.
|
|
14
|
+
//
|
|
15
|
+
// What we deliberately do NOT match:
|
|
16
|
+
// - Hostnames or IP addresses (out of scope; not "secrets" by shape).
|
|
17
|
+
// - High-entropy generic blobs (UUIDs, base64-encoded data) — too
|
|
18
|
+
// much noise; rely on shape-specific patterns instead.
|
|
19
|
+
// - Customer-specific identifiers — those belong in the per-engagement
|
|
20
|
+
// deny set, not here.
|
|
21
|
+
//
|
|
22
|
+
// Pattern hardening: every regex is anchored to a recognisable shape
|
|
23
|
+
// (a literal prefix and a length-bounded body). No greedy `.*` quantifiers
|
|
24
|
+
// over potentially unbounded input. We compile once at module load.
|
|
25
|
+
// Limit single-pattern body lengths to defend against pathological
|
|
26
|
+
// input. 16 KB is generous for any real PEM / token / JWT shape.
|
|
27
|
+
const MAX_BODY = 16 * 1024;
|
|
28
|
+
const PATTERNS = [
|
|
29
|
+
// PEM headers in any common form: RSA, DSA, EC, ED25519, generic
|
|
30
|
+
// "PRIVATE KEY", and the corresponding ENCRYPTED variant. The
|
|
31
|
+
// closing dashes anchor the match without needing to capture the
|
|
32
|
+
// body, so a partial PEM (truncated by `head`/`tail`) still trips.
|
|
33
|
+
{
|
|
34
|
+
kind: "PEM_HEADER",
|
|
35
|
+
re: /-----BEGIN (?:RSA |DSA |EC |OPENSSH |ENCRYPTED |PGP )?PRIVATE KEY-----/g,
|
|
36
|
+
},
|
|
37
|
+
// PEM-as-ASCII-hex. The macOS keychain returns
|
|
38
|
+
// `security find-generic-password -w` as hex when the value contains
|
|
39
|
+
// newlines, so any PEM round-trips as a long hex string. Match the
|
|
40
|
+
// hex of `-----BEGIN ` (the prefix common to every PEM header):
|
|
41
|
+
// `-----BEGIN ` => `2D2D2D2D2D424547494E20` (case-insensitive on hex).
|
|
42
|
+
{
|
|
43
|
+
kind: "PEM_AS_HEX",
|
|
44
|
+
re: /(?:2D){5}424547494E20/gi,
|
|
45
|
+
},
|
|
46
|
+
// JWT shape. Three URL-safe-base64 segments separated by dots, each
|
|
47
|
+
// at least 8 chars. The first segment must start with `eyJ` (the
|
|
48
|
+
// unencrypted base64 of `{"`), which is the universal JWT header
|
|
49
|
+
// prefix. Bounded body length per segment to defend against
|
|
50
|
+
// catastrophic backtracking (although the inner class is bounded
|
|
51
|
+
// already).
|
|
52
|
+
{
|
|
53
|
+
kind: "JWT",
|
|
54
|
+
re: /\beyJ[A-Za-z0-9_-]{8,4096}\.eyJ[A-Za-z0-9_-]{8,4096}\.[A-Za-z0-9_-]{8,4096}/g,
|
|
55
|
+
},
|
|
56
|
+
// GitHub token shapes: PAT (`ghp_`), OAuth (`gho_`), App user
|
|
57
|
+
// (`ghu_`), App installation (`ghs_`), refresh (`ghr_`), and the
|
|
58
|
+
// newer fine-grained PAT prefix `github_pat_`. All use a fixed
|
|
59
|
+
// suffix character class plus length range. Tokens shorter than 36
|
|
60
|
+
// chars after the prefix are unlikely to be live tokens but still
|
|
61
|
+
// worth flagging — false positives here cost nothing.
|
|
62
|
+
{
|
|
63
|
+
kind: "GITHUB_TOKEN",
|
|
64
|
+
re: /\b(?:gh[pousr]_[A-Za-z0-9]{36,255}|github_pat_[A-Za-z0-9_]{20,255})\b/g,
|
|
65
|
+
},
|
|
66
|
+
];
|
|
67
|
+
/**
|
|
68
|
+
* Scan a single string for any secret-shaped pattern. Returns one hit
|
|
69
|
+
* per match (a single PEM that's also matched as PEM-as-hex via
|
|
70
|
+
* pre-encoding will produce two hits — that's fine; the hook
|
|
71
|
+
* deduplicates by kind on output).
|
|
72
|
+
*
|
|
73
|
+
* The scanned input is bounded by the caller (the Bash-output hook
|
|
74
|
+
* caps at a few MB). This function does not enforce its own size cap
|
|
75
|
+
* because callers vary in tolerance.
|
|
76
|
+
*
|
|
77
|
+
* The function NEVER returns the matched substring — only the kind,
|
|
78
|
+
* offset, and length. By construction, a hit cannot leak the secret
|
|
79
|
+
* back through the result type.
|
|
80
|
+
*/
|
|
81
|
+
export function scanForSecrets(text) {
|
|
82
|
+
const hits = [];
|
|
83
|
+
for (const { kind, re } of PATTERNS) {
|
|
84
|
+
// RegExp objects with /g flag are stateful via lastIndex; reset
|
|
85
|
+
// before each scan so concurrent callers don't trip over each
|
|
86
|
+
// other (we use a fresh exec loop per call, not iterators).
|
|
87
|
+
re.lastIndex = 0;
|
|
88
|
+
let m;
|
|
89
|
+
let iter = 0;
|
|
90
|
+
while ((m = re.exec(text)) !== null) {
|
|
91
|
+
const matched = m[0];
|
|
92
|
+
// Defensive: cap match length tracked. If a single match somehow
|
|
93
|
+
// exceeds MAX_BODY, record only the first MAX_BODY bytes' worth
|
|
94
|
+
// and advance lastIndex past it.
|
|
95
|
+
const length = Math.min(matched.length, MAX_BODY);
|
|
96
|
+
hits.push({ kind, offset: m.index, length });
|
|
97
|
+
if (matched.length === 0)
|
|
98
|
+
re.lastIndex++; // safety against zero-width
|
|
99
|
+
// Bail out of pathological-input loops: 1000 hits of the same
|
|
100
|
+
// pattern is far more than any legitimate output produces.
|
|
101
|
+
if (++iter > 1000)
|
|
102
|
+
break;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
// Sort by offset so the caller can report hits in source order.
|
|
106
|
+
hits.sort((a, b) => a.offset - b.offset);
|
|
107
|
+
return hits;
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Summarise hits as a redacted preview suitable for echoing back to
|
|
111
|
+
* the user / agent. Returns the kinds present and the total count;
|
|
112
|
+
* never includes any byte of matched content.
|
|
113
|
+
*/
|
|
114
|
+
export function summariseHits(hits) {
|
|
115
|
+
const kinds = Array.from(new Set(hits.map(h => h.kind))).sort();
|
|
116
|
+
return { kinds, count: hits.length };
|
|
117
|
+
}
|
|
118
|
+
//# sourceMappingURL=secret-markers.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"secret-markers.js","sourceRoot":"","sources":["../src/secret-markers.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"secret-markers.test.d.ts","sourceRoot":"","sources":["../src/secret-markers.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
// Copyright (C) 2026 Richard Myers and contributors.
|
|
3
|
+
import { describe, it } from "node:test";
|
|
4
|
+
import assert from "node:assert/strict";
|
|
5
|
+
import { scanForSecrets, summariseHits, } from "./secret-markers.js";
|
|
6
|
+
// Test fixtures intentionally do NOT contain real secrets — these are
|
|
7
|
+
// shape-matching strings that any plausible regex would also match.
|
|
8
|
+
// Keep them well-known dummies so accidentally publishing the test
|
|
9
|
+
// fixtures stays harmless.
|
|
10
|
+
const DUMMY_PEM_HEADER = "-----BEGIN RSA PRIVATE KEY-----";
|
|
11
|
+
const DUMMY_PEM_OPENSSH = "-----BEGIN OPENSSH PRIVATE KEY-----";
|
|
12
|
+
const DUMMY_PEM_GENERIC = "-----BEGIN PRIVATE KEY-----";
|
|
13
|
+
const DUMMY_PEM_AS_HEX = "2D2D2D2D2D424547494E2052534120505249564154"; // hex of "-----BEGIN RSA PRIVAT" (truncated)
|
|
14
|
+
const DUMMY_GHS = "ghs_" + "A".repeat(36);
|
|
15
|
+
const DUMMY_GHP = "ghp_" + "z".repeat(40);
|
|
16
|
+
const DUMMY_GHO = "gho_" + "1".repeat(36);
|
|
17
|
+
const DUMMY_GHU = "ghu_" + "9".repeat(36);
|
|
18
|
+
const DUMMY_GHR = "ghr_" + "x".repeat(36);
|
|
19
|
+
const DUMMY_GH_PAT = "github_pat_" + "Z".repeat(40);
|
|
20
|
+
// Three-segment URL-safe base64, header starts with "eyJ"
|
|
21
|
+
const DUMMY_JWT = "eyJ" + "Q".repeat(20) + "." +
|
|
22
|
+
"eyJ" + "R".repeat(20) + "." +
|
|
23
|
+
"S".repeat(40);
|
|
24
|
+
describe("scanForSecrets — PEM headers", () => {
|
|
25
|
+
it("matches RSA private key header", () => {
|
|
26
|
+
const hits = scanForSecrets(`prefix ${DUMMY_PEM_HEADER} suffix`);
|
|
27
|
+
assert.equal(hits.length, 1);
|
|
28
|
+
assert.equal(hits[0].kind, "PEM_HEADER");
|
|
29
|
+
});
|
|
30
|
+
it("matches OPENSSH variant", () => {
|
|
31
|
+
const hits = scanForSecrets(DUMMY_PEM_OPENSSH);
|
|
32
|
+
assert.equal(hits.length, 1);
|
|
33
|
+
assert.equal(hits[0].kind, "PEM_HEADER");
|
|
34
|
+
});
|
|
35
|
+
it("matches generic PRIVATE KEY (no algorithm prefix)", () => {
|
|
36
|
+
const hits = scanForSecrets(DUMMY_PEM_GENERIC);
|
|
37
|
+
assert.equal(hits.length, 1);
|
|
38
|
+
assert.equal(hits[0].kind, "PEM_HEADER");
|
|
39
|
+
});
|
|
40
|
+
it("matches multiple headers in one input", () => {
|
|
41
|
+
const text = `${DUMMY_PEM_HEADER}\nbody\n-----END RSA PRIVATE KEY-----\n${DUMMY_PEM_HEADER}`;
|
|
42
|
+
const hits = scanForSecrets(text);
|
|
43
|
+
const headerHits = hits.filter(h => h.kind === "PEM_HEADER");
|
|
44
|
+
assert.equal(headerHits.length, 2);
|
|
45
|
+
});
|
|
46
|
+
it("does not match the matching END marker (only BEGIN)", () => {
|
|
47
|
+
const hits = scanForSecrets("-----END RSA PRIVATE KEY-----");
|
|
48
|
+
assert.equal(hits.length, 0);
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
describe("scanForSecrets — PEM as ASCII hex", () => {
|
|
52
|
+
it("matches the hex of '-----BEGIN '", () => {
|
|
53
|
+
const hits = scanForSecrets(DUMMY_PEM_AS_HEX);
|
|
54
|
+
const hexHits = hits.filter(h => h.kind === "PEM_AS_HEX");
|
|
55
|
+
assert.equal(hexHits.length, 1);
|
|
56
|
+
});
|
|
57
|
+
it("is case-insensitive on hex digits", () => {
|
|
58
|
+
const upper = DUMMY_PEM_AS_HEX.toUpperCase();
|
|
59
|
+
const lower = DUMMY_PEM_AS_HEX.toLowerCase();
|
|
60
|
+
assert.equal(scanForSecrets(upper).filter(h => h.kind === "PEM_AS_HEX").length, 1);
|
|
61
|
+
assert.equal(scanForSecrets(lower).filter(h => h.kind === "PEM_AS_HEX").length, 1);
|
|
62
|
+
});
|
|
63
|
+
it("ignores arbitrary hex strings that are not PEM headers", () => {
|
|
64
|
+
const hits = scanForSecrets("deadbeef".repeat(50));
|
|
65
|
+
assert.equal(hits.filter(h => h.kind === "PEM_AS_HEX").length, 0);
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
describe("scanForSecrets — GitHub tokens", () => {
|
|
69
|
+
for (const [label, dummy] of [
|
|
70
|
+
["ghs_", DUMMY_GHS],
|
|
71
|
+
["ghp_", DUMMY_GHP],
|
|
72
|
+
["gho_", DUMMY_GHO],
|
|
73
|
+
["ghu_", DUMMY_GHU],
|
|
74
|
+
["ghr_", DUMMY_GHR],
|
|
75
|
+
["github_pat_", DUMMY_GH_PAT],
|
|
76
|
+
]) {
|
|
77
|
+
it(`matches ${label} prefix`, () => {
|
|
78
|
+
const hits = scanForSecrets(`auth: ${dummy}`);
|
|
79
|
+
assert.equal(hits.length, 1, `expected one hit for ${label}`);
|
|
80
|
+
assert.equal(hits[0].kind, "GITHUB_TOKEN");
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
it("does not match short prefix-only fragments", () => {
|
|
84
|
+
assert.equal(scanForSecrets("ghs_short").length, 0);
|
|
85
|
+
assert.equal(scanForSecrets("ghp_").length, 0);
|
|
86
|
+
});
|
|
87
|
+
it("does not match unrelated `gh` strings", () => {
|
|
88
|
+
assert.equal(scanForSecrets("github.com").length, 0);
|
|
89
|
+
assert.equal(scanForSecrets("ghosts").length, 0);
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
describe("scanForSecrets — JWT shape", () => {
|
|
93
|
+
it("matches a 3-segment JWT", () => {
|
|
94
|
+
const hits = scanForSecrets(`Authorization: Bearer ${DUMMY_JWT}`);
|
|
95
|
+
assert.equal(hits.filter(h => h.kind === "JWT").length, 1);
|
|
96
|
+
});
|
|
97
|
+
it("does not match a 2-segment string", () => {
|
|
98
|
+
const twoSeg = "eyJabc.eyJdef";
|
|
99
|
+
assert.equal(scanForSecrets(twoSeg).filter(h => h.kind === "JWT").length, 0);
|
|
100
|
+
});
|
|
101
|
+
it("does not match base64 blobs without the eyJ prefix", () => {
|
|
102
|
+
const fake = "abcdef.ghijkl.mnopqr";
|
|
103
|
+
assert.equal(scanForSecrets(fake).filter(h => h.kind === "JWT").length, 0);
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
describe("scanForSecrets — clean inputs", () => {
|
|
107
|
+
it("returns no hits on empty string", () => {
|
|
108
|
+
assert.deepEqual(scanForSecrets(""), []);
|
|
109
|
+
});
|
|
110
|
+
it("returns no hits on plain prose", () => {
|
|
111
|
+
const text = "This is a normal log message about installing the GitHub App " +
|
|
112
|
+
"for engagement acme. The app slug is acme-alice-bot. No secrets here.";
|
|
113
|
+
assert.deepEqual(scanForSecrets(text), []);
|
|
114
|
+
});
|
|
115
|
+
it("returns no hits on a public key (only private)", () => {
|
|
116
|
+
const text = "-----BEGIN PUBLIC KEY-----\nblah\n-----END PUBLIC KEY-----";
|
|
117
|
+
assert.deepEqual(scanForSecrets(text), []);
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
describe("scanForSecrets — never returns matched bytes", () => {
|
|
121
|
+
it("hit objects only carry kind/offset/length", () => {
|
|
122
|
+
const hits = scanForSecrets(`${DUMMY_PEM_HEADER} ${DUMMY_GHS}`);
|
|
123
|
+
for (const h of hits) {
|
|
124
|
+
const keys = Object.keys(h).sort();
|
|
125
|
+
assert.deepEqual(keys, ["kind", "length", "offset"]);
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
describe("scanForSecrets — multiple kinds in one input", () => {
|
|
130
|
+
it("reports one hit per pattern, sorted by offset", () => {
|
|
131
|
+
const text = `start ${DUMMY_PEM_HEADER} middle ${DUMMY_GHS} end ${DUMMY_JWT}`;
|
|
132
|
+
const hits = scanForSecrets(text);
|
|
133
|
+
assert.ok(hits.length >= 3);
|
|
134
|
+
for (let i = 1; i < hits.length; i++) {
|
|
135
|
+
assert.ok(hits[i].offset >= hits[i - 1].offset, "hits must be sorted by offset");
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
describe("summariseHits", () => {
|
|
140
|
+
it("returns kinds + count, never bytes", () => {
|
|
141
|
+
const hits = [
|
|
142
|
+
{ kind: "PEM_HEADER", offset: 0, length: 30 },
|
|
143
|
+
{ kind: "GITHUB_TOKEN", offset: 50, length: 40 },
|
|
144
|
+
{ kind: "PEM_HEADER", offset: 100, length: 30 },
|
|
145
|
+
];
|
|
146
|
+
const s = summariseHits(hits);
|
|
147
|
+
assert.deepEqual(s.kinds, ["GITHUB_TOKEN", "PEM_HEADER"]);
|
|
148
|
+
assert.equal(s.count, 3);
|
|
149
|
+
});
|
|
150
|
+
it("handles empty input", () => {
|
|
151
|
+
assert.deepEqual(summariseHits([]), { kinds: [], count: 0 });
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
//# sourceMappingURL=secret-markers.test.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"secret-markers.test.js","sourceRoot":"","sources":["../src/secret-markers.test.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { type RepoClass } from "./repo.js";
|
|
2
|
+
import { type Registry } from "./registry.js";
|
|
3
|
+
export interface TrustBoundary {
|
|
4
|
+
/** Set of GitHub orgs that span this repo's trust boundary. */
|
|
5
|
+
orgs: Set<string>;
|
|
6
|
+
/**
|
|
7
|
+
* True when `orgs` came from the git remote because no
|
|
8
|
+
* classification could supply orgs. The hook surfaces this as
|
|
9
|
+
* `DEST_UNCLASSIFIED` when the destination tree is in this state.
|
|
10
|
+
*/
|
|
11
|
+
fromRemoteFallback: boolean;
|
|
12
|
+
/** The repo's resolved class (private-strict if unclassified). */
|
|
13
|
+
class: RepoClass;
|
|
14
|
+
/** True if class came from explicit git config / `.repo-aegis.yml`. */
|
|
15
|
+
classExplicit: boolean;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Compute the trust boundary for a working tree against a registry.
|
|
19
|
+
*
|
|
20
|
+
* Reads `git config repo-aegis.class` / `.repo-aegis.yml` for the
|
|
21
|
+
* working tree (via {@link readRepoConfig}), pulls the relevant
|
|
22
|
+
* `githubOrgs` arrays out of the registry, and falls back to the
|
|
23
|
+
* remote URL only when the classification supplies no orgs.
|
|
24
|
+
*/
|
|
25
|
+
export declare function computeTrustBoundary(workingTree: string, registry: Registry): TrustBoundary;
|
|
26
|
+
/**
|
|
27
|
+
* Two trust boundaries overlap iff their org sets share at least one
|
|
28
|
+
* element. Two empty sets do NOT overlap — that's the "neither side
|
|
29
|
+
* has any signal" case, which the policy layer treats as
|
|
30
|
+
* "scan-with-warning" rather than silently allowing.
|
|
31
|
+
*/
|
|
32
|
+
export declare function trustBoundariesOverlap(a: TrustBoundary, b: TrustBoundary): boolean;
|
|
33
|
+
//# sourceMappingURL=trust-boundary.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"trust-boundary.d.ts","sourceRoot":"","sources":["../src/trust-boundary.ts"],"names":[],"mappings":"AAkBA,OAAO,EAAkB,KAAK,SAAS,EAAE,MAAM,WAAW,CAAC;AAC3D,OAAO,EAAE,KAAK,QAAQ,EAAE,MAAM,eAAe,CAAC;AAG9C,MAAM,WAAW,aAAa;IAC5B,+DAA+D;IAC/D,IAAI,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;IAClB;;;;OAIG;IACH,kBAAkB,EAAE,OAAO,CAAC;IAC5B,kEAAkE;IAClE,KAAK,EAAE,SAAS,CAAC;IACjB,uEAAuE;IACvE,aAAa,EAAE,OAAO,CAAC;CACxB;AAED;;;;;;;GAOG;AACH,wBAAgB,oBAAoB,CAClC,WAAW,EAAE,MAAM,EACnB,QAAQ,EAAE,QAAQ,GACjB,aAAa,CAqCf;AAED;;;;;GAKG;AACH,wBAAgB,sBAAsB,CAAC,CAAC,EAAE,aAAa,EAAE,CAAC,EAAE,aAAa,GAAG,OAAO,CAIlF"}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
// Copyright (C) 2026 Richard Myers and contributors.
|
|
3
|
+
//
|
|
4
|
+
// Trust-boundary computation for the path-aware PostToolUse hook.
|
|
5
|
+
//
|
|
6
|
+
// Two working trees are in the same trust boundary if their derived
|
|
7
|
+
// org sets overlap. The org set for a working tree is:
|
|
8
|
+
//
|
|
9
|
+
// (engagements[*].githubOrgs of every engagement the repo is
|
|
10
|
+
// allow'd into)
|
|
11
|
+
// ∪ (personalOrgs, if class === public-eligible)
|
|
12
|
+
// ∪ (remote-origin org, if the above is empty)
|
|
13
|
+
//
|
|
14
|
+
// The classification is the source of truth (per design open question
|
|
15
|
+
// #3): a fork's remote may belong to one org while the classification
|
|
16
|
+
// declares the canonical engagement-org mapping. The remote is only
|
|
17
|
+
// consulted as a last-resort fallback for completely unclassified repos.
|
|
18
|
+
import { readRepoConfig } from "./repo.js";
|
|
19
|
+
import { getRemoteOrg } from "./working-tree.js";
|
|
20
|
+
/**
|
|
21
|
+
* Compute the trust boundary for a working tree against a registry.
|
|
22
|
+
*
|
|
23
|
+
* Reads `git config repo-aegis.class` / `.repo-aegis.yml` for the
|
|
24
|
+
* working tree (via {@link readRepoConfig}), pulls the relevant
|
|
25
|
+
* `githubOrgs` arrays out of the registry, and falls back to the
|
|
26
|
+
* remote URL only when the classification supplies no orgs.
|
|
27
|
+
*/
|
|
28
|
+
export function computeTrustBoundary(workingTree, registry) {
|
|
29
|
+
const repo = readRepoConfig(workingTree);
|
|
30
|
+
const orgs = new Set();
|
|
31
|
+
// Engagement-derived orgs.
|
|
32
|
+
for (const engId of repo.engagements) {
|
|
33
|
+
const eng = registry.engagements.find(e => e.id === engId);
|
|
34
|
+
if (!eng)
|
|
35
|
+
continue;
|
|
36
|
+
for (const org of eng.githubOrgs ?? []) {
|
|
37
|
+
orgs.add(org.toLowerCase());
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
// public-eligible repos sit on every personal org. The model says
|
|
41
|
+
// "this repo is not customer-coupled, so anything in personalOrgs
|
|
42
|
+
// is its peer."
|
|
43
|
+
if (repo.class === "public-eligible") {
|
|
44
|
+
for (const org of registry.personalOrgs ?? []) {
|
|
45
|
+
orgs.add(org.toLowerCase());
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
let fromRemoteFallback = false;
|
|
49
|
+
if (orgs.size === 0) {
|
|
50
|
+
const remoteOrg = getRemoteOrg(workingTree);
|
|
51
|
+
if (remoteOrg !== null) {
|
|
52
|
+
orgs.add(remoteOrg);
|
|
53
|
+
fromRemoteFallback = true;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return {
|
|
57
|
+
orgs,
|
|
58
|
+
fromRemoteFallback,
|
|
59
|
+
class: repo.class,
|
|
60
|
+
classExplicit: repo.classExplicit,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Two trust boundaries overlap iff their org sets share at least one
|
|
65
|
+
* element. Two empty sets do NOT overlap — that's the "neither side
|
|
66
|
+
* has any signal" case, which the policy layer treats as
|
|
67
|
+
* "scan-with-warning" rather than silently allowing.
|
|
68
|
+
*/
|
|
69
|
+
export function trustBoundariesOverlap(a, b) {
|
|
70
|
+
if (a.orgs.size === 0 || b.orgs.size === 0)
|
|
71
|
+
return false;
|
|
72
|
+
for (const o of a.orgs)
|
|
73
|
+
if (b.orgs.has(o))
|
|
74
|
+
return true;
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
//# sourceMappingURL=trust-boundary.js.map
|