@mnaimfaizy/agent-kit 0.0.0-stage → 1.0.0-alpha.12

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.
@@ -0,0 +1,346 @@
1
+ // The rulesets docs/repository-hardening.md sets up, which keep the Claude
2
+ // GitHub App token away from the default branch, release branches and
3
+ // release tags. Each gap is a warning: the flows still run without them.
4
+ //
5
+ // A rule only counts when no one the App can act as is on the ruleset's
6
+ // bypass list: rulesets stack, so an actor on one ruleset's bypass list is
7
+ // still held by any other ruleset with the same rule that does not list it.
8
+
9
+ import { stripComment } from "../yaml-subset.mjs";
10
+ import { result } from "./result.mjs";
11
+
12
+ const HARDENING = "https://github.com/mnaimfaizy/agent-kit/blob/main/docs/repository-hardening.md";
13
+
14
+ /**
15
+ * fnmatch with FNM_PATHNAME, the matching GitHub uses for ruleset ref
16
+ * patterns: `*` and `?` stay within one path segment, a double star
17
+ * followed by a slash matches zero or more whole segments, `[...]` is a character class (`[!...]` or `[^...]`
18
+ * negates it), and `\` escapes the next character.
19
+ */
20
+ export function fnmatchToRegExp(pattern) {
21
+ let re = "";
22
+ let i = 0;
23
+ const esc = (c) => c.replace(/[.*+?^${}()|[\]\\/]/g, "\\$&");
24
+ while (i < pattern.length) {
25
+ const c = pattern[i];
26
+ if (c === "*" && pattern[i + 1] === "*" && pattern[i + 2] === "/") {
27
+ re += "(?:[^/]*/)*";
28
+ i += 3;
29
+ } else if (c === "*" && pattern[i + 1] === "*") {
30
+ re += "[^/]*";
31
+ i += 2;
32
+ } else if (c === "*") {
33
+ re += "[^/]*";
34
+ i += 1;
35
+ } else if (c === "?") {
36
+ re += "[^/]";
37
+ i += 1;
38
+ } else if (c === "\\" && i + 1 < pattern.length) {
39
+ re += esc(pattern[i + 1]);
40
+ i += 2;
41
+ } else if (c === "[") {
42
+ const close = pattern.indexOf("]", i + 2);
43
+ if (close === -1) {
44
+ re += "\\[";
45
+ i += 1;
46
+ continue;
47
+ }
48
+ let body = pattern.slice(i + 1, close);
49
+ let negate = false;
50
+ if (body.startsWith("!") || body.startsWith("^")) {
51
+ negate = true;
52
+ body = body.slice(1);
53
+ }
54
+ const cls = body.replace(/[\\\]^]/g, "\\$&");
55
+ re += negate ? `(?!/)[^${cls}]` : `[${cls}]`;
56
+ i = close + 1;
57
+ } else {
58
+ re += esc(c);
59
+ i += 1;
60
+ }
61
+ }
62
+ return new RegExp(`^${re}$`);
63
+ }
64
+
65
+ /** Whether a ruleset's `conditions.ref_name` covers `ref`. */
66
+ export function coversRef(ruleset, ref, { defaultBranch } = {}) {
67
+ // A ruleset with a pattern doctor cannot interpret is not counted at all.
68
+ if (unreadablePatterns(ruleset).length) return false;
69
+ const cond = ruleset.conditions?.ref_name ?? {};
70
+ const matches = (p) =>
71
+ p === "~ALL" ||
72
+ (p === "~DEFAULT_BRANCH" && defaultBranch && ref === `refs/heads/${defaultBranch}`) ||
73
+ fnmatchToRegExp(p).test(ref);
74
+ return (cond.include ?? []).some(matches) && !(cond.exclude ?? []).some(matches);
75
+ }
76
+
77
+ /** The ref patterns of a ruleset that do not compile (an invalid range such as `[z-a]`). */
78
+ export function unreadablePatterns(ruleset) {
79
+ const cond = ruleset.conditions?.ref_name ?? {};
80
+ return [...(cond.include ?? []), ...(cond.exclude ?? [])].filter((p) => {
81
+ if (typeof p !== "string") return true;
82
+ if (p === "~ALL" || p === "~DEFAULT_BRANCH") return false;
83
+ try {
84
+ fnmatchToRegExp(p);
85
+ return false;
86
+ } catch {
87
+ return true;
88
+ }
89
+ });
90
+ }
91
+
92
+ /**
93
+ * Note the rulesets of `target` that were not counted because a pattern could
94
+ * not be interpreted. Rulesets only add restrictions, so a check that holds
95
+ * without them stays a pass; one that does not is already a warning.
96
+ */
97
+ function withPatternNotes(res, rulesets, target) {
98
+ const notes = rulesets
99
+ .filter((r) => r.target === target)
100
+ .flatMap((r) =>
101
+ unreadablePatterns(r).map(
102
+ (p) =>
103
+ `${label(r)}: pattern ${JSON.stringify(p)} could not be interpreted, so it was not counted.`,
104
+ ),
105
+ );
106
+ if (notes.length === 0) return res;
107
+ return { ...res, details: [...notes, ...res.details] };
108
+ }
109
+
110
+ /**
111
+ * Active rulesets with their full rules and bypass lists. `bypass_actors` is
112
+ * only returned by the single-ruleset endpoint, and only to an admin, which
113
+ * the local checks require. Throws GhError on any API failure.
114
+ */
115
+ export function loadRulesets(github, repo) {
116
+ const ids = github.getLines(`repos/${repo}/rulesets?includes_parents=true`, ".[].id");
117
+ return ids
118
+ .map((id) => github.getJson(`repos/${repo}/rulesets/${Number(id)}`))
119
+ .filter((r) => r.enforcement === "active");
120
+ }
121
+
122
+ // Base repository roles as ruleset bypass actors (docs/repository-hardening.md).
123
+ const ADMIN_ROLE = 5;
124
+ const MAINTAIN_ROLE = 2;
125
+ const ROLE_NAMES = { 1: "Read", 2: "Maintain", 3: "Triage", 4: "Write", 5: "Admin" };
126
+
127
+ /**
128
+ * Bypass actors that undo the ruleset's protection against the Claude App:
129
+ * any GitHub App (the Claude App is an `Integration`), any repository role
130
+ * below Maintain (the Write role, or a custom role), and, on a branch
131
+ * ruleset, deploy keys. Admin, Maintain, teams and organization admins are
132
+ * the intended bypass list; a DeployKey bypass on a tag ruleset is the
133
+ * documented personal-account pattern.
134
+ */
135
+ export function riskyBypassActors(ruleset) {
136
+ // Only an admin sees bypass lists, and an organization ruleset's may be
137
+ // hidden even then: an unseen list is never assumed to be clean.
138
+ if (!Array.isArray(ruleset.bypass_actors)) return ["a bypass list not visible to this login"];
139
+ const risky = [];
140
+ for (const actor of ruleset.bypass_actors) {
141
+ const type = actor.actor_type;
142
+ if (type === "Integration") {
143
+ risky.push(`a GitHub App (Integration ${actor.actor_id})`);
144
+ } else if (type === "RepositoryRole" && ![ADMIN_ROLE, MAINTAIN_ROLE].includes(actor.actor_id)) {
145
+ const name = ROLE_NAMES[actor.actor_id];
146
+ risky.push(name ? `the ${name} role` : `custom repository role ${actor.actor_id}`);
147
+ } else if (type === "DeployKey" && ruleset.target !== "tag") {
148
+ risky.push("deploy keys");
149
+ }
150
+ }
151
+ return risky;
152
+ }
153
+
154
+ const hasRule = (ruleset, type) => (ruleset.rules ?? []).some((x) => x.type === type);
155
+ const label = (r) => `"${r.name ?? `ruleset ${r.id}`}"`;
156
+
157
+ /**
158
+ * For one rule type: the covering rulesets that have it, and whether any of
159
+ * them has a clean bypass list.
160
+ */
161
+ function ruleStatus(covering, type) {
162
+ const withRule = covering.filter((r) => hasRule(r, type));
163
+ const clean = withRule.filter((r) => riskyBypassActors(r).length === 0);
164
+ const bypassed = withRule
165
+ .filter((r) => !clean.includes(r))
166
+ .map((r) => `${label(r)} lets ${riskyBypassActors(r).join(", ")} bypass its ${type} rule.`);
167
+ return { present: withRule.length > 0, held: clean.length > 0, bypassed };
168
+ }
169
+
170
+ const BYPASS_ADVICE =
171
+ "Keep the bypass list to Admin and Maintain: never a GitHub App, the Write role, or a team the App acts through.";
172
+
173
+ function updateRule(rulesets, { defaultBranch }) {
174
+ const ID = "ruleset-update";
175
+ const TITLE = "Default branch has an update rule";
176
+ const recipe = `Recipe: ${HARDENING}#1-default-branch-an-update-rule`;
177
+ const ref = `refs/heads/${defaultBranch}`;
178
+ const covering = rulesets.filter(
179
+ (r) => r.target === "branch" && coversRef(r, ref, { defaultBranch }),
180
+ );
181
+ const s = ruleStatus(covering, "update");
182
+ if (s.held) return result(ID, TITLE, "pass", `${defaultBranch} has an active update rule.`);
183
+ if (s.present) {
184
+ return result(ID, TITLE, "warn", `The update rule on ${defaultBranch} can be bypassed.`, {
185
+ details: [...s.bypassed, BYPASS_ADVICE, recipe],
186
+ });
187
+ }
188
+ return result(ID, TITLE, "warn", `No active update rule on ${defaultBranch}.`, {
189
+ details: [
190
+ "A required pull request with zero approvals does not stop the Claude App merging its own pull request; an update rule does.",
191
+ recipe,
192
+ ],
193
+ });
194
+ }
195
+
196
+ // Release-branch names a creation rule should cover; any one is enough.
197
+ const RELEASE_BRANCH_REFS = [
198
+ "refs/heads/release/1.0.0",
199
+ "refs/heads/releases/1.0.0",
200
+ "refs/heads/release-1.0.0",
201
+ ];
202
+
203
+ function creationRule(rulesets) {
204
+ const ID = "ruleset-creation";
205
+ const TITLE = "Release branches have a creation rule";
206
+ const recipe = `Recipe: ${HARDENING}#2-release-branches-a-creation-rule`;
207
+ const branchRulesets = rulesets.filter((r) => r.target === "branch");
208
+ const covering = branchRulesets.filter((r) =>
209
+ RELEASE_BRANCH_REFS.some((ref) => coversRef(r, ref)),
210
+ );
211
+ const s = ruleStatus(covering, "creation");
212
+ if (s.held) {
213
+ return result(ID, TITLE, "pass", "An active creation rule covers release branches.");
214
+ }
215
+ if (s.present) {
216
+ return result(ID, TITLE, "warn", "Every release-branch creation rule can be bypassed.", {
217
+ details: [...s.bypassed, BYPASS_ADVICE, recipe],
218
+ });
219
+ }
220
+ const elsewhere = ruleStatus(branchRulesets, "creation").present;
221
+ return result(
222
+ ID,
223
+ TITLE,
224
+ "warn",
225
+ elsewhere
226
+ ? "A branch creation rule exists, but none covers a release/* branch."
227
+ : "No active branch ruleset has a creation rule.",
228
+ {
229
+ details: [
230
+ "If you cut releases from branches, stop anyone outside the bypass list creating one.",
231
+ `Checked against ${RELEASE_BRANCH_REFS.join(", ")}.`,
232
+ recipe,
233
+ ],
234
+ },
235
+ );
236
+ }
237
+
238
+ const TAG_RULES = ["creation", "update", "deletion"];
239
+
240
+ // A stable and a pre-release tag: the v* ruleset must cover both.
241
+ const TAG_REFS = ["refs/tags/v1.0.0", "refs/tags/v1.0.0-alpha.1"];
242
+
243
+ function tagRuleset(rulesets) {
244
+ const ID = "ruleset-tags";
245
+ const TITLE = "Release tags have a v* tag ruleset";
246
+ const recipe = `Recipe: ${HARDENING}#3-release-tags-a-v-tag-ruleset`;
247
+ const problems = [];
248
+ const details = new Set();
249
+ for (const ref of TAG_REFS) {
250
+ const covering = rulesets.filter((r) => r.target === "tag" && coversRef(r, ref));
251
+ if (covering.length === 0) {
252
+ problems.push(`no active tag ruleset covers ${ref.slice("refs/tags/".length)}`);
253
+ continue;
254
+ }
255
+ const statuses = TAG_RULES.map((type) => [type, ruleStatus(covering, type)]);
256
+ const missing = statuses.filter(([, st]) => !st.present).map(([t]) => t);
257
+ const bypassed = statuses.filter(([, st]) => st.present && !st.held);
258
+ const tag = ref.slice("refs/tags/".length);
259
+ if (missing.length) problems.push(`${tag} lacks: ${missing.join(", ")}`);
260
+ if (bypassed.length) {
261
+ problems.push(`${tag} can be bypassed: ${bypassed.map(([t]) => t).join(", ")}`);
262
+ for (const [, st] of bypassed) for (const d of st.bypassed) details.add(d);
263
+ details.add(BYPASS_ADVICE);
264
+ }
265
+ }
266
+ if (problems.length) {
267
+ const unique = [...new Set(problems)];
268
+ return result(ID, TITLE, "warn", `The v* tag ruleset: ${unique.join("; ")}.`, {
269
+ details: [...details, recipe],
270
+ });
271
+ }
272
+ return result(
273
+ ID,
274
+ TITLE,
275
+ "pass",
276
+ "An active tag ruleset blocks creating, moving and deleting v* tags, pre-releases included.",
277
+ );
278
+ }
279
+
280
+ export function checkUpdateRule(rulesets, opts) {
281
+ return withPatternNotes(updateRule(rulesets, opts), rulesets, "branch");
282
+ }
283
+
284
+ export function checkCreationRule(rulesets) {
285
+ return withPatternNotes(creationRule(rulesets), rulesets, "branch");
286
+ }
287
+
288
+ export function checkTagRuleset(rulesets) {
289
+ return withPatternNotes(tagRuleset(rulesets), rulesets, "tag");
290
+ }
291
+
292
+ /**
293
+ * Workflows that push tags with GITHUB_TOKEN: a `git push` of tags, with no
294
+ * SSH deploy key in the same file.
295
+ */
296
+ export function workflowsPushingTagsWithToken(files) {
297
+ return files
298
+ .filter(({ text }) => {
299
+ const body = String(text)
300
+ .replace(/\r\n?/g, "\n")
301
+ .split("\n")
302
+ .filter((l) => !l.trimStart().startsWith("#"))
303
+ .map(stripComment)
304
+ .join("\n");
305
+ const pushes = body
306
+ .split("\n")
307
+ .some(
308
+ (l) =>
309
+ /\bgit\b.*\bpush\b/.test(l) &&
310
+ /--tags|--follow-tags|refs\/tags\/|\bTAG\b|\btag\b/i.test(l),
311
+ );
312
+ const tagged = /\bgit\b[^\n]*\btag\b/.test(body) && /\bgit\b[^\n]*\bpush\b/.test(body);
313
+ const ssh = /GIT_SSH_COMMAND|git@github\.com:|ssh:\/\//.test(body);
314
+ return (pushes || tagged) && !ssh;
315
+ })
316
+ .map((f) => f.path);
317
+ }
318
+
319
+ export function checkPersonalTagPush({ ownerType, files }) {
320
+ const ID = "release-tag-push";
321
+ const TITLE = "Release tags can be pushed under the tag ruleset";
322
+ if (ownerType !== "User") {
323
+ return result(
324
+ ID,
325
+ TITLE,
326
+ "pass",
327
+ "Owned by an organization: the GitHub Actions app can be a ruleset bypass actor.",
328
+ );
329
+ }
330
+ const offenders = workflowsPushingTagsWithToken(files);
331
+ if (offenders.length === 0)
332
+ return result(ID, TITLE, "pass", "No workflow pushes tags with GITHUB_TOKEN.");
333
+ return result(
334
+ ID,
335
+ TITLE,
336
+ "warn",
337
+ "A personal-account repository whose workflow pushes tags with GITHUB_TOKEN.",
338
+ {
339
+ details: [
340
+ ...offenders,
341
+ "On a personal account the GitHub Actions app cannot bypass a tag ruleset, so these pushes are refused once v* tags are protected.",
342
+ `Fix: ${HARDENING}#personal-account-repositories-pushing-release-tags`,
343
+ ],
344
+ },
345
+ );
346
+ }
@@ -0,0 +1,252 @@
1
+ // Secrets and repository variables, worked out from the Consumer's Callers.
2
+ // A secret is required when the reusable workflow at the pinned tag declares
3
+ // the secret it is passed to as `required: true`; otherwise it is optional.
4
+ // A variable is optional when every read of it falls back with `||`.
5
+
6
+ import { secretNames } from "../callers.mjs";
7
+ import { parseWorkflowCallSecrets } from "../workflow-call.mjs";
8
+ import { byHand, command, NAME_PATTERN, result } from "./result.mjs";
9
+
10
+ /** GitHub provides `secrets.GITHUB_TOKEN`; no `GITHUB_*` secret can be set by hand. */
11
+ const isProvided = (name) => /^github_/i.test(name);
12
+
13
+ /** Caller parts doctor could not read fully: reported, never assumed fine. */
14
+ function unreadable(callers) {
15
+ const out = [];
16
+ for (const caller of callers) {
17
+ if (caller.parseIncomplete) out.push(`${caller.path}: ${caller.parseIncomplete}`);
18
+ for (const job of caller.jobs) {
19
+ if (job.secretsUnsupported) {
20
+ out.push(
21
+ `${caller.path}:${job.name}: ${job.secretsUnsupported}; its secrets were not checked.`,
22
+ );
23
+ }
24
+ }
25
+ }
26
+ return out;
27
+ }
28
+
29
+ /** The variables every Caller reads. Needs no tag. */
30
+ export function deriveVariables(callers) {
31
+ const variables = new Map();
32
+ for (const caller of callers) {
33
+ for (const v of caller.variables) {
34
+ const prev = variables.get(v.name);
35
+ variables.set(v.name, {
36
+ name: v.name,
37
+ optional: prev ? prev.optional && v.optional : v.optional,
38
+ where: [...(prev?.where ?? []), caller.path],
39
+ });
40
+ }
41
+ }
42
+ return [...variables.values()];
43
+ }
44
+
45
+ /**
46
+ * @param {{ path: string, jobs: object[], variables: { name: string, optional: boolean }[] }[]} callers
47
+ * @param {(workflowFile: string) => string | null} readReusable reads .github/workflows/<file> at the pinned tag
48
+ */
49
+ export function deriveRequirements(callers, readReusable) {
50
+ const secrets = new Map();
51
+ const problems = [];
52
+ const specs = new Map();
53
+ const specFor = (file) => {
54
+ if (!specs.has(file)) {
55
+ const text = readReusable(file);
56
+ specs.set(file, text === null ? null : parseWorkflowCallSecrets(text));
57
+ }
58
+ return specs.get(file);
59
+ };
60
+ const addSecret = (names, required, where) => {
61
+ // A GITHUB_* secret is always set, so an expression that falls back to one is too.
62
+ if (names.some(isProvided)) return;
63
+ const key = names.join(" || ");
64
+ const prev = secrets.get(key);
65
+ secrets.set(key, {
66
+ names,
67
+ required: Boolean(prev?.required || required),
68
+ where: [...(prev?.where ?? []), where],
69
+ });
70
+ };
71
+
72
+ for (const caller of callers) {
73
+ for (const job of caller.jobs) {
74
+ const where = `${caller.path}:${job.name}`;
75
+ if (job.secretsUnsupported) continue;
76
+ const spec = specFor(job.uses.file);
77
+ if (spec === null) {
78
+ problems.push(`${where}: ${job.uses.file} is not a reusable workflow at the pinned tag`);
79
+ continue;
80
+ }
81
+ if (job.secrets === "inherit") {
82
+ for (const { name, required } of spec.values()) {
83
+ addSecret([name.toUpperCase()], required, where);
84
+ }
85
+ continue;
86
+ }
87
+ const passed = new Set(Object.keys(job.secrets).map((k) => k.toLowerCase()));
88
+ for (const [input, value] of Object.entries(job.secrets)) {
89
+ const names = secretNames(value);
90
+ if (names.length === 0) continue;
91
+ const declared = spec.get(input.toLowerCase());
92
+ if (!declared) {
93
+ problems.push(
94
+ `${where}: passes secret ${JSON.stringify(input)}, which ${job.uses.file} does not declare`,
95
+ );
96
+ continue;
97
+ }
98
+ addSecret(names, declared.required, `${where} (${input})`);
99
+ }
100
+ for (const { name, required } of spec.values()) {
101
+ if (required && !passed.has(name.toLowerCase())) {
102
+ problems.push(
103
+ `${where}: does not pass required secret ${JSON.stringify(name)} to ${job.uses.file}`,
104
+ );
105
+ }
106
+ }
107
+ }
108
+ }
109
+ return {
110
+ secrets: [...secrets.values()],
111
+ variables: deriveVariables(callers),
112
+ problems,
113
+ unsupported: unreadable(callers),
114
+ };
115
+ }
116
+
117
+ function listNames(github, repo, kind, ownerType) {
118
+ const key = kind === "secret" ? "secrets" : "variables";
119
+ const names = new Set(github.getLines(`repos/${repo}/actions/${key}`, `.${key}[].name`));
120
+ let orgNote = null;
121
+ if (ownerType === "Organization") {
122
+ try {
123
+ for (const n of github.getLines(
124
+ `repos/${repo}/actions/organization-${key}`,
125
+ `.${key}[].name`,
126
+ )) {
127
+ names.add(n);
128
+ }
129
+ } catch {
130
+ orgNote = `Organization ${key} could not be read; one reported missing may be set at the organization.`;
131
+ }
132
+ }
133
+ return { names: new Set([...names].map((n) => n.toUpperCase())), orgNote };
134
+ }
135
+
136
+ /** `gh secret set` / `gh variable set` for a valid name, or a by-hand note. */
137
+ function setCommand(kind, name, repo, extra = []) {
138
+ if (!NAME_PATTERN.test(name)) return { note: byHand(`${kind} named`, name) };
139
+ return { cmd: command("gh", kind, "set", name, "--repo", repo, ...extra) };
140
+ }
141
+
142
+ function splitFixes(items) {
143
+ return {
144
+ fix: items.filter((i) => i.cmd).map((i) => i.cmd),
145
+ notes: items.filter((i) => i.note).map((i) => i.note),
146
+ };
147
+ }
148
+
149
+ export function checkSecrets({ github, repo, ownerType, requirements }) {
150
+ const ID = "secrets";
151
+ const TITLE = "Secrets the Callers pass are set";
152
+ const { problems, unsupported = [] } = requirements;
153
+ let listed;
154
+ try {
155
+ listed = listNames(github, repo, "secret", ownerType);
156
+ } catch (err) {
157
+ // Problems found in the Caller files need no API call: they still fail.
158
+ if (problems.length) {
159
+ return result(ID, TITLE, "fail", `${problems.length} Caller problem(s) with secrets.`, {
160
+ details: [
161
+ ...problems,
162
+ ...unsupported,
163
+ `Could not list repository secrets, so unset secrets were not checked: ${err.message}`,
164
+ ],
165
+ });
166
+ }
167
+ return result(ID, TITLE, "skip", `Could not list repository secrets: ${err.message}`, {
168
+ details: unsupported,
169
+ });
170
+ }
171
+ const isSet = (x) => x.names.some((n) => listed.names.has(n.toUpperCase()));
172
+ const missingRequired = requirements.secrets.filter((x) => x.required && !isSet(x));
173
+ const missingOptional = requirements.secrets.filter((x) => !x.required && !isSet(x));
174
+ const describe = (x) => `${x.names.join(" or ")} (${x.where.join(", ")})`;
175
+ // No --body: gh prompts for the value, so it never lands in shell history.
176
+ const { fix, notes } = splitFixes(
177
+ [...missingRequired, ...missingOptional].map((x) => setCommand("secret", x.names[0], repo)),
178
+ );
179
+ const details = [
180
+ ...problems,
181
+ ...missingRequired.map((x) => `required, not set: ${describe(x)}`),
182
+ ...missingOptional.map((x) => `optional, not set: ${describe(x)}`),
183
+ ...unsupported,
184
+ ...notes,
185
+ ];
186
+ if (listed.orgNote && (missingRequired.length || missingOptional.length)) {
187
+ details.push(listed.orgNote);
188
+ }
189
+ if (missingRequired.length || problems.length) {
190
+ const summary = missingRequired.length
191
+ ? `${missingRequired.length} required secret(s) missing.`
192
+ : `${problems.length} Caller problem(s) with secrets.`;
193
+ return result(ID, TITLE, "fail", summary, { details, fix });
194
+ }
195
+ if (missingOptional.length) {
196
+ return result(
197
+ ID,
198
+ TITLE,
199
+ "warn",
200
+ `${missingOptional.length} optional secret(s) referenced but not set.`,
201
+ { details, fix },
202
+ );
203
+ }
204
+ if (unsupported.length) {
205
+ return result(
206
+ ID,
207
+ TITLE,
208
+ "skip",
209
+ `${unsupported.length} Caller part(s) doctor could not read; their secrets were not checked.`,
210
+ { details: unsupported },
211
+ );
212
+ }
213
+ return result(
214
+ ID,
215
+ TITLE,
216
+ "pass",
217
+ `All ${requirements.secrets.length} secret(s) the Callers pass are set.`,
218
+ );
219
+ }
220
+
221
+ export function checkVariables({ github, repo, ownerType, requirements }) {
222
+ const ID = "variables";
223
+ const TITLE = "Repository variables the Callers read are set";
224
+ let listed;
225
+ try {
226
+ listed = listNames(github, repo, "variable", ownerType);
227
+ } catch (err) {
228
+ return result(ID, TITLE, "skip", `Could not list repository variables: ${err.message}`);
229
+ }
230
+ const required = requirements.variables.filter((v) => !v.optional && !isProvided(v.name));
231
+ const missing = required.filter((v) => !listed.names.has(v.name.toUpperCase()));
232
+ if (missing.length === 0) {
233
+ return result(ID, TITLE, "pass", `All ${required.length} required variable(s) are set.`);
234
+ }
235
+ const killSwitch = (name) => /_ENABLED$/.test(name);
236
+ const details = missing.map(
237
+ (v) =>
238
+ `not set: ${v.name} (${v.where.join(", ")})` +
239
+ (killSwitch(v.name) ? " — the Kill switch: the flow never runs until it is set to true" : ""),
240
+ );
241
+ if (listed.orgNote) details.push(listed.orgNote);
242
+ // The Kill switch takes true; for anything else, gh prompts for the value.
243
+ const { fix, notes } = splitFixes(
244
+ missing.map((v) =>
245
+ setCommand("variable", v.name, repo, killSwitch(v.name) ? ["--body", "true"] : []),
246
+ ),
247
+ );
248
+ return result(ID, TITLE, "fail", `${missing.length} required variable(s) missing.`, {
249
+ details: [...details, ...notes],
250
+ fix,
251
+ });
252
+ }
package/src/cli.mjs ADDED
@@ -0,0 +1,97 @@
1
+ // Argument parsing and dispatch. Exit codes: 0 ok, 1 a check failed (or a
2
+ // warning under --strict, or the tag was refused), 2 usage error.
3
+
4
+ import { readFileSync } from "node:fs";
5
+ import { parseArgs } from "node:util";
6
+ import { formatReport, runDoctor } from "./doctor.mjs";
7
+ import { REPO_PATTERN, sanitizeLine } from "./checks/result.mjs";
8
+ import { systemRunner } from "./exec.mjs";
9
+
10
+ export const VERSION = JSON.parse(
11
+ readFileSync(new URL("../package.json", import.meta.url), "utf8"),
12
+ ).version;
13
+
14
+ const USAGE = `Usage: agent-kit <command> [options]
15
+
16
+ Commands:
17
+ doctor Read-only checks of this repository against the Agent-kit tag
18
+ its Callers pin. Never changes anything.
19
+
20
+ Options for doctor:
21
+ --ci Check only Kit-owned paths and uses: pins (no gh needed).
22
+ Used automatically when gh has no admin login on the repo.
23
+ --strict Treat warnings as failures.
24
+ --repo <o/r> The GitHub repository to check (default: gh's choice for
25
+ the current directory).
26
+
27
+ -h, --help Show this help.
28
+ -v, --version Show the version.
29
+
30
+ init and upgrade are not available yet.`;
31
+
32
+ /**
33
+ * @param {string[]} argv
34
+ * @param {{ cwd: string, stdout: (s: string) => void, stderr: (s: string) => void, runner?: import("./exec.mjs").Runner, openTag?: Function }} io
35
+ * @returns {number} exit code
36
+ */
37
+ export function main(argv, io) {
38
+ const { cwd, runner = systemRunner, openTag } = io;
39
+ // Anything doctor prints can echo its arguments or file contents: sanitize every line.
40
+ const clean = (text) => String(text).split("\n").map(sanitizeLine).join("\n");
41
+ const stdout = (text) => io.stdout(clean(text));
42
+ const stderr = (text) => io.stderr(clean(text));
43
+ let parsed;
44
+ try {
45
+ parsed = parseArgs({
46
+ args: argv,
47
+ allowPositionals: true,
48
+ strict: true,
49
+ options: {
50
+ ci: { type: "boolean" },
51
+ strict: { type: "boolean" },
52
+ repo: { type: "string" },
53
+ help: { type: "boolean", short: "h" },
54
+ version: { type: "boolean", short: "v" },
55
+ },
56
+ });
57
+ } catch (err) {
58
+ stderr(`${err.message}\n\n${USAGE}`);
59
+ return 2;
60
+ }
61
+ const { values, positionals } = parsed;
62
+ if (values.version) {
63
+ stdout(VERSION);
64
+ return 0;
65
+ }
66
+ const [command, ...rest] = positionals;
67
+ if (values.help || !command) {
68
+ (command || values.help ? stdout : stderr)(USAGE);
69
+ return values.help ? 0 : 2;
70
+ }
71
+ if (command === "init" || command === "upgrade") {
72
+ stderr(`agent-kit ${command} is not available in ${VERSION} yet; it ships in a later release.`);
73
+ return 2;
74
+ }
75
+ if (command !== "doctor") {
76
+ stderr(`Unknown command: ${command}\n\n${USAGE}`);
77
+ return 2;
78
+ }
79
+ if (rest.length) {
80
+ stderr(`doctor takes no arguments, got: ${rest.join(" ")}`);
81
+ return 2;
82
+ }
83
+ if (values.repo !== undefined && !REPO_PATTERN.test(values.repo)) {
84
+ stderr(`--repo must be owner/name, got: ${JSON.stringify(values.repo)}`);
85
+ return 2;
86
+ }
87
+ const report = runDoctor({
88
+ cwd,
89
+ runner,
90
+ ci: Boolean(values.ci),
91
+ strict: Boolean(values.strict),
92
+ repo: values.repo,
93
+ ...(openTag ? { openTag } : {}),
94
+ });
95
+ stdout(formatReport(report, { version: VERSION }));
96
+ return report.exitCode;
97
+ }