Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@
publishing to npm.
- Lockfile-pinned offline GitHub Actions schema/semantic validation, immutable action
policy, negative fixtures, and validator provenance/update guidance.
- Additive per-control security capability/enabled results with sanitized recovery,
deterministic enabled/disabled/omitted/403/404/partial fixtures, and preserved legacy
`state` booleans/`ok` semantics.

### Changed

Expand All @@ -29,6 +32,8 @@
- All mutating dry runs now avoid both credential resolution and HTTP, including PR
linking/merge and branch deletion plans.
- Coverage gates now require at least 80% lines and 70% branches.
- The workflow validator stays directly executable with Node while remaining importable
by the Windows Vitest suite.

## 0.6.0 - 2026-07-01

Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,9 @@ This package now covers the smallest high-value GitHub admin workflow end to end
retain their requested limit and return `truncated` when more results exist
- internal GitHub requests use a 10-second default timeout, retry bounded read-only
transient failures, and surface sanitized structured recovery metadata
- security verification keeps legacy `state` booleans and `ok` while adding per-control
capability, enabled-state, sanitized reason/status, and bounded recovery results; masked
404 responses remain `unknown` rather than being treated as proof of unsupported state

## PR discipline workflow

Expand Down Expand Up @@ -246,6 +249,7 @@ github_delete_comment({
- [Examples and integration recipes](docs/INTEGRATIONS.md)
- [Performance baseline](docs/PERFORMANCE.md)
- [Offline GitHub Actions validation and pin review](docs/WORKFLOW_VALIDATION.md)
- [Security-control capability taxonomy and legacy compatibility](docs/SECURITY_CAPABILITY_RESULTS.md)
- [Release and changelog workflow](docs/RELEASING.md)
- [Roadmap](docs/ROADMAP.md) and [backlog governance](docs/BACKLOG.md)

Expand Down
3 changes: 3 additions & 0 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,9 @@ When adding or changing a tool:
is a manual integration check and must use a disposable repository.
- Register new tools through `src/tools.ts` and confirm public exports through
`src/index.ts` when programmatic access is intended.
- Treat security capability diagnostics as an additive contract: retain legacy booleans
and `ok`, emit only sanitized closed reasons/status, and keep masked 404 capability
unknown as recorded in [`SECURITY_CAPABILITY_RESULTS.md`](SECURITY_CAPABILITY_RESULTS.md).

## Validation boundary

Expand Down
8 changes: 8 additions & 0 deletions docs/OPERATIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,10 @@ The package intentionally emits structured results rather than application logs
| Request timed out | GitHub did not answer within the bounded timeout | Verify GitHub Status and network access, then repeat a read or run a fresh dry run before retrying a mutation |
| Rate limit exhausted | Structured error has `rateLimit.remaining: 0` | Wait until `rateLimit.resetAt` or the bounded `retryAfterSeconds` signal; do not rotate credentials merely to evade policy |
| Pagination reports `truncated` | Maximum pages/items or a repeated Link target stopped traversal | Narrow the query or inspect the truncation reason; correctness-sensitive mutations refuse to treat truncated verification as complete |
| Security control is `disabled` | GitHub explicitly reported an available control as off | Review repository policy, then use `github_configure_security` only if the control is required |
| Security control is `unavailable` | The repository response omitted the control | Review plan, organization policy, and caller visibility; omission is not proof about every plan or policy |
| Security control is `forbidden` | GitHub returned 403 for that read | Grant only the minimum repository administration or security-manager permission needed, then retry |
| Security control is `unknown` after 404 | Repository/control was not found or GitHub masked an authorization failure | Confirm `owner/name` and caller access; do not infer unsupported or disabled state from 404 |

## Debugging and observability boundaries

Expand All @@ -52,4 +56,8 @@ The package intentionally emits structured results rather than application logs

`GitHubApiError` exposes `status`, `requestId`, `documentationUrl`, `rateLimit` (`limit`, `remaining`, `resetAt`, `retryAfterSeconds`), `retryable`, `method`, and a sanitized `path`. Response text is reduced to GitHub's message/documentation fields, authorization-shaped values are redacted, and surfaced text is capped. The package never logs errors automatically; callers decide where sanitized structured details may be recorded.

Security verification exposes only closed reason/status values, numeric 403/404 status,
and static recovery text for expected capability failures. It does not copy raw GitHub
failure messages into the result. See [the capability-result decision](SECURITY_CAPABILITY_RESULTS.md).

Escalate repeated or ambiguous failures with the tool name, sanitized status/message, target resource type, and exact recovery steps already attempted.
52 changes: 52 additions & 0 deletions docs/SECURITY_CAPABILITY_RESULTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# Security-control capability results

## Decision

Security verification reports a per-control result in `controls` while retaining the
legacy `state` booleans and top-level `ok`. The additive result separates whether a
control is observable from whether it is enabled:

| `status` | `capability` | `enabled` | Meaning |
| --- | --- | --- | --- |
| `enabled` | `available` | `true` | GitHub reported the control enabled. |
| `disabled` | `available` | `false` | GitHub reported the control disabled. |
| `unavailable` | `unavailable` | `null` | The repository response omitted this control; it is unavailable to this read, not proven absent from every plan or policy. |
| `forbidden` | `unknown` | `null` | GitHub returned 403, so the caller cannot determine capability or state. |
| `unknown` | `unknown` | `null` | GitHub returned a masked/not-found 404 or an unexpected response shape. |

Each result also has a closed `reason` value. Expected 403/404 failures expose only a
numeric `httpStatus` and a static recovery action; raw GitHub response messages are not
copied into verification output.

## Conservative 404 treatment

GitHub can mask authorization failures as 404. Therefore `not_found_or_masked` never
means "unsupported" or "disabled" in the additive result. Confirm the repository
reference and caller access before drawing a plan, policy, or feature-availability
conclusion. Live read-only evidence must use the same qualification.

## Legacy compatibility

`state` remains the six-boolean object returned to existing callers. `ok` remains the
logical AND of those booleans; it is not redefined around the new taxonomy. Existing
successful endpoint responses remain enabled unless they explicitly contain
`enabled: false`, omitted analysis fields remain `false`, and endpoint 404s remain
`false`. A handled 403 has no earlier successful-result equivalent and is represented
conservatively as `false` in `state` while `controls` records `forbidden`/`unknown`.

New callers should use `controls` for diagnostics and use `state`/`ok` only when they
need the compatibility contract.

## Recovery

Recovery text is deliberately bounded and static:

1. For `disabled`, review repository policy and enable the control only if required.
2. For `unavailable`, review repository plan, organization policy, and caller visibility.
3. For `forbidden`, grant only the minimum repository administration or
security-manager permission needed for the read, then retry.
4. For `not_found_or_masked`, confirm `owner/name` and caller access without assuming
the feature is unsupported.

Verification is read-only. Use `github_configure_security` separately and deliberately
when a policy-approved state change is required.
1 change: 0 additions & 1 deletion scripts/verify-workflows.mjs
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
#!/usr/bin/env node
import { readdir, readFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
Expand Down
172 changes: 152 additions & 20 deletions src/security.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { getGitHubClient } from "./http.js";
import { getGitHubClient, type GitHubClient } from "./http.js";
import type { GitHubSecurityControlsInput, GitHubVerifySecurityControlsInput } from "./types.js";
import { parseRepo } from "./repo-ref.js";

Expand All @@ -11,6 +11,29 @@ export interface RepositorySecurityState {
dependabotSecurityUpdates: boolean;
}

export type RepositorySecurityCapability = "available" | "unavailable" | "unknown";
export type RepositorySecurityControlStatus = "enabled" | "disabled" | "unavailable" | "forbidden" | "unknown";
export type RepositorySecurityControlReason = "reported" | "omitted" | "forbidden" | "not_found_or_masked" | "unexpected_response";

export interface RepositorySecurityControlResult {
capability: RepositorySecurityCapability;
enabled: boolean | null;
status: RepositorySecurityControlStatus;
reason: RepositorySecurityControlReason;
httpStatus?: 403 | 404;
recovery?: string;
}

export type RepositorySecurityControlResults = {
[K in keyof RepositorySecurityState]: RepositorySecurityControlResult;
};

const DISABLED_RECOVERY = "Review repository policy, then enable this control in GitHub settings only if required.";
const UNAVAILABLE_RECOVERY = "Review the repository plan, organization policy, and caller visibility before attempting to enable this control.";
const FORBIDDEN_RECOVERY = "Verify the caller has the minimum required repository administration or security-manager permission, then retry the read.";
const MASKED_NOT_FOUND_RECOVERY = "Confirm the repository reference and caller access; GitHub may mask authorization failures as 404, so do not infer that the control is unsupported.";
const UNEXPECTED_RECOVERY = "Retry the read and compare GitHub's response with the documented security-control shape before changing the control.";

export async function configureRepositorySecurity(input: GitHubSecurityControlsInput) {
const requested = normalizeRequested(input);
if (input.dryRun) {
Expand Down Expand Up @@ -44,28 +67,50 @@ export async function configureRepositorySecurity(input: GitHubSecurityControlsI
);
}
const verification = await verifyRepositorySecurity({ repo: input.repo });
const matches = Object.entries(requested).every(([key, value]) => value === undefined || verification.state[key as keyof RepositorySecurityState] === value);
return { updated: true, verified: matches, dryRun: false, repo: input.repo, requested, state: verification.state };
const matches = Object.entries(requested).every(([key, value]) => {
if (value === undefined) return true;
const control = verification.controls[key as keyof RepositorySecurityState];
return control.capability === "available" && control.enabled === value;
});
return {
updated: true,
verified: matches,
dryRun: false,
repo: input.repo,
requested,
state: verification.state,
controls: verification.controls,
recovery: verification.recovery,
};
}

export async function verifyRepositorySecurity(input: GitHubVerifySecurityControlsInput) {
const ref = parseRepo(input.repo);
const client = await getGitHubClient();
const repo = await client.request(`/repos/${ref.owner}/${ref.name}`) as any;
const [privateReporting, automatedFixes] = await Promise.all([
readEnabledEndpoint(client, `/repos/${ref.owner}/${ref.name}/private-vulnerability-reporting`),
readEnabledEndpoint(client, `/repos/${ref.owner}/${ref.name}/automated-security-fixes`),
const basePath = `/repos/${ref.owner}/${ref.name}`;
const [repositoryAnalysis, privateReporting, automatedFixes] = await Promise.all([
readRepositoryAnalysis(client, basePath),
readEnabledEndpoint(client, `${basePath}/private-vulnerability-reporting`),
readEnabledEndpoint(client, `${basePath}/automated-security-fixes`),
]);
const analysis = repo.security_and_analysis ?? {};
const state: RepositorySecurityState = {
const controls: RepositorySecurityControlResults = {
privateVulnerabilityReporting: privateReporting,
secretScanning: enabled(analysis.secret_scanning),
pushProtection: enabled(analysis.secret_scanning_push_protection),
nonProviderPatterns: enabled(analysis.secret_scanning_non_provider_patterns),
validityChecks: enabled(analysis.secret_scanning_validity_checks),
secretScanning: readAnalysisControl(repositoryAnalysis, "secret_scanning"),
pushProtection: readAnalysisControl(repositoryAnalysis, "secret_scanning_push_protection"),
nonProviderPatterns: readAnalysisControl(repositoryAnalysis, "secret_scanning_non_provider_patterns"),
validityChecks: readAnalysisControl(repositoryAnalysis, "secret_scanning_validity_checks"),
dependabotSecurityUpdates: automatedFixes,
};
return { ok: Object.values(state).every(Boolean), repo: input.repo, state };
const state: RepositorySecurityState = {
privateVulnerabilityReporting: legacyEnabled(controls.privateVulnerabilityReporting),
secretScanning: legacyEnabled(controls.secretScanning),
pushProtection: legacyEnabled(controls.pushProtection),
nonProviderPatterns: legacyEnabled(controls.nonProviderPatterns),
validityChecks: legacyEnabled(controls.validityChecks),
dependabotSecurityUpdates: legacyEnabled(controls.dependabotSecurityUpdates),
};
const recovery = [...new Set(Object.values(controls).flatMap((control) => control.recovery ? [control.recovery] : []))];
return { ok: Object.values(state).every(Boolean), repo: input.repo, state, controls, recovery };
}

function normalizeRequested(input: GitHubSecurityControlsInput): Partial<RepositorySecurityState> {
Expand All @@ -79,16 +124,103 @@ function normalizeRequested(input: GitHubSecurityControlsInput): Partial<Reposit
};
}

function enabled(value: any): boolean {
return value?.status === "enabled";
type RepositoryAnalysisRead =
| { kind: "reported"; analysis: Record<string, unknown> }
| { kind: "failure"; result: RepositorySecurityControlResult };

async function readRepositoryAnalysis(client: GitHubClient, path: string): Promise<RepositoryAnalysisRead> {
try {
const repository = await client.request(path) as { security_and_analysis?: unknown };
const analysis = isRecord(repository.security_and_analysis) ? repository.security_and_analysis : {};
return { kind: "reported", analysis };
} catch (error) {
const result = expectedFailureResult(error);
if (result) return { kind: "failure", result };
throw error;
}
}

async function readEnabledEndpoint(client: Awaited<ReturnType<typeof getGitHubClient>>, path: string): Promise<boolean> {
function readAnalysisControl(source: RepositoryAnalysisRead, key: string): RepositorySecurityControlResult {
if (source.kind === "failure") return { ...source.result };
const value = source.analysis[key];
if (value === undefined) {
return {
capability: "unavailable",
enabled: null,
status: "unavailable",
reason: "omitted",
recovery: UNAVAILABLE_RECOVERY,
};
}
if (isRecord(value) && (value.status === "enabled" || value.status === "disabled")) {
return reportedResult(value.status === "enabled");
}
return {
capability: "unknown",
enabled: null,
status: "unknown",
reason: "unexpected_response",
recovery: UNEXPECTED_RECOVERY,
};
}

async function readEnabledEndpoint(client: GitHubClient, path: string): Promise<RepositorySecurityControlResult> {
try {
const result = await client.request(path) as any;
return result?.enabled !== false;
const result = await client.request(path) as { enabled?: unknown } | undefined;
// Preserve the legacy endpoint rule: any successful response is enabled unless it explicitly reports false.
return reportedResult(result?.enabled !== false);
} catch (error) {
if (typeof error === "object" && error !== null && "status" in error && (error as { status?: number }).status === 404) return false;
const result = expectedFailureResult(error);
if (result) return result;
throw error;
}
}

function reportedResult(enabled: boolean): RepositorySecurityControlResult {
return {
capability: "available",
enabled,
status: enabled ? "enabled" : "disabled",
reason: "reported",
...(enabled ? {} : { recovery: DISABLED_RECOVERY }),
};
}

function expectedFailureResult(error: unknown): RepositorySecurityControlResult | undefined {
const status = errorStatus(error);
if (status === 403) {
return {
capability: "unknown",
enabled: null,
status: "forbidden",
reason: "forbidden",
httpStatus: 403,
recovery: FORBIDDEN_RECOVERY,
};
}
if (status === 404) {
return {
capability: "unknown",
enabled: null,
Comment on lines +201 to +204

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve Dependabot’s disabled 404

For dependabotSecurityUpdates: false, GitHub documents GET /repos/{owner}/{repo}/automated-security-fixes returning 404 when Dependabot security updates are not enabled (https://docs.github.com/rest/repos/repos#check-if-dependabot-security-updates-are-enabled-for-a-repository). Because endpoint reads now share this 404 branch, a successful DELETE is converted to enabled: null/unknown instead of disabled; then configureRepositorySecurity can never mark that requested disable as verified. Please special-case the automated-security-fixes 404 as the documented disabled state where appropriate.

Useful? React with 👍 / 👎.

status: "unknown",
reason: "not_found_or_masked",
httpStatus: 404,
recovery: MASKED_NOT_FOUND_RECOVERY,
};
}
return undefined;
}

function errorStatus(error: unknown): number | undefined {
return typeof error === "object" && error !== null && "status" in error
? (error as { status?: number }).status
: undefined;
}

function legacyEnabled(result: RepositorySecurityControlResult): boolean {
return result.enabled === true;
}

function isRecord(value: unknown): value is Record<string, any> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
19 changes: 17 additions & 2 deletions src/tool-domains/security.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,13 @@ export function registerSecurityTools(pi: ExtensionAPI): void {
}, { additionalProperties: false }),
async execute(_id, params) {
const result = await configureRepositorySecurity(params);
const text = result.dryRun
? `Dry run: would configure security for ${params.repo}`
: result.verified
? `Repository security configured for ${params.repo}`
: `Repository security configuration could not be fully verified for ${params.repo}.${formatRecovery("recovery" in result ? result.recovery : undefined)}`;
return {
content: [{ type: "text", text: result.dryRun ? `Dry run: would configure security for ${params.repo}` : `Repository security configured for ${params.repo}` }],
content: [{ type: "text", text }],
details: result,
};
},
Expand All @@ -35,9 +40,19 @@ export function registerSecurityTools(pi: ExtensionAPI): void {
async execute(_id, params) {
const result = await verifyRepositorySecurity(params);
return {
content: [{ type: "text", text: result.ok ? `Repository security verified for ${params.repo}` : `Repository security gaps found for ${params.repo}` }],
content: [{
type: "text",
text: result.ok
? `Repository security verified for ${params.repo}`
: `Repository security gaps found for ${params.repo}.${formatRecovery(result.recovery)}`,
}],
details: result,
};
},
});
}

function formatRecovery(recovery: readonly string[] | undefined): string {
if (!recovery?.length) return " Recovery: review the per-control result before changing repository settings.";
return ` Recovery: ${recovery.join(" ")}`;
}
16 changes: 16 additions & 0 deletions tests/fixtures/security/disabled.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
"name": "disabled",
"repository": {
"status": 200,
"body": {
"security_and_analysis": {
"secret_scanning": { "status": "disabled" },
"secret_scanning_push_protection": { "status": "disabled" },
"secret_scanning_non_provider_patterns": { "status": "disabled" },
"secret_scanning_validity_checks": { "status": "disabled" }
}
}
},
"privateVulnerabilityReporting": { "status": 200, "body": { "enabled": false } },
"dependabotSecurityUpdates": { "status": 200, "body": { "enabled": false } }
}
Loading