copilot-guardian 0.2.5

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 (72) hide show
  1. package/.github/workflows/ci.yml +53 -0
  2. package/.test-output-run-abstain/guardian.report.json +8 -0
  3. package/CHANGELOG.md +602 -0
  4. package/CONTRIBUTING.md +28 -0
  5. package/LICENSE +21 -0
  6. package/README.md +205 -0
  7. package/SECURITY.md +150 -0
  8. package/dist/cli.js +384 -0
  9. package/dist/cli.js.map +1 -0
  10. package/dist/engine/analyze.js +294 -0
  11. package/dist/engine/analyze.js.map +1 -0
  12. package/dist/engine/async-exec.js +314 -0
  13. package/dist/engine/async-exec.js.map +1 -0
  14. package/dist/engine/auto-apply.js +424 -0
  15. package/dist/engine/auto-apply.js.map +1 -0
  16. package/dist/engine/context-enhancer.js +141 -0
  17. package/dist/engine/context-enhancer.js.map +1 -0
  18. package/dist/engine/debug.js +77 -0
  19. package/dist/engine/debug.js.map +1 -0
  20. package/dist/engine/eval.js +437 -0
  21. package/dist/engine/eval.js.map +1 -0
  22. package/dist/engine/github.js +191 -0
  23. package/dist/engine/github.js.map +1 -0
  24. package/dist/engine/mcp.js +217 -0
  25. package/dist/engine/mcp.js.map +1 -0
  26. package/dist/engine/patch_options.js +474 -0
  27. package/dist/engine/patch_options.js.map +1 -0
  28. package/dist/engine/run.js +124 -0
  29. package/dist/engine/run.js.map +1 -0
  30. package/dist/engine/util.js +167 -0
  31. package/dist/engine/util.js.map +1 -0
  32. package/dist/ui/dashboard.js +81 -0
  33. package/dist/ui/dashboard.js.map +1 -0
  34. package/docs/ARCHITECTURE.md +292 -0
  35. package/docs/Logo.png +0 -0
  36. package/docs/screenshots/05-hypothesis-dashboard.png +0 -0
  37. package/docs/screenshots/07-patch-spectrum.png +0 -0
  38. package/docs/screenshots/final-demo.gif +0 -0
  39. package/examples/demo-failure/.github/workflows/ci.yml +23 -0
  40. package/examples/demo-failure/README.md +93 -0
  41. package/examples/demo-failure/package.json +9 -0
  42. package/examples/demo-failure/test/require-api-url.js +10 -0
  43. package/jest.config.cjs +35 -0
  44. package/package.json +39 -0
  45. package/prompts/analysis.v2.txt +62 -0
  46. package/prompts/debug.followup.v1.txt +18 -0
  47. package/prompts/patch.options.v1.txt +47 -0
  48. package/prompts/patch.simple.v1.txt +12 -0
  49. package/prompts/quality.v1.txt +25 -0
  50. package/schemas/analysis.schema.json +65 -0
  51. package/schemas/patch_options.schema.json +23 -0
  52. package/schemas/quality.schema.json +12 -0
  53. package/src/cli.ts +417 -0
  54. package/src/engine/analyze.ts +412 -0
  55. package/src/engine/async-exec.ts +384 -0
  56. package/src/engine/auto-apply.ts +516 -0
  57. package/src/engine/context-enhancer.ts +176 -0
  58. package/src/engine/debug.ts +91 -0
  59. package/src/engine/eval.ts +546 -0
  60. package/src/engine/github.ts +223 -0
  61. package/src/engine/mcp.ts +267 -0
  62. package/src/engine/patch_options.ts +604 -0
  63. package/src/engine/run.ts +154 -0
  64. package/src/engine/util.ts +195 -0
  65. package/src/ui/dashboard.ts +90 -0
  66. package/test-sdk.mjs +51 -0
  67. package/tests/auto_heal_branch_safety.test.ts +76 -0
  68. package/tests/github_redaction_failclosed.test.ts +24 -0
  69. package/tests/mocks/copilot-sdk.mock.ts +15 -0
  70. package/tests/quality_guard_regression_matrix.test.ts +432 -0
  71. package/tests/run_abstain_policy.test.ts +83 -0
  72. package/tsconfig.json +17 -0
@@ -0,0 +1,154 @@
1
+ import path from "node:path";
2
+ import chalk from "chalk";
3
+
4
+ import { analyzeRun } from "./analyze.js";
5
+ import { generatePatchOptions } from "./patch_options.js";
6
+ import { ensureDir, writeJson, writeText } from "./util.js";
7
+
8
+ export type RunFlags = {
9
+ showReasoning?: boolean;
10
+ showOptions?: boolean;
11
+ strategy?: "conservative" | "balanced" | "aggressive";
12
+ outDir?: string;
13
+ maxLogChars?: number;
14
+ fast?: boolean;
15
+ };
16
+
17
+ type AbstainDecision = {
18
+ abstain: boolean;
19
+ classification: "NOT_PATCHABLE" | "PATCHABLE";
20
+ reason: string;
21
+ signals: string[];
22
+ };
23
+
24
+ function detectForcedAbstain(ctx: any): AbstainDecision {
25
+ const step = String(ctx?.step || "");
26
+ const summary = String(ctx?.logSummary || "");
27
+ const excerptTail = String(ctx?.logExcerpt || "").slice(-6000);
28
+ const haystack = `${step}\n${summary}\n${excerptTail}`.toLowerCase();
29
+ const strongSignals: string[] = [];
30
+ const weakSignals: string[] = [];
31
+
32
+ const checks: Array<{ label: string; pattern: RegExp; strength: "strong" | "weak" }> = [
33
+ { label: "auth_401_403", strength: "strong", pattern: /\b(401|403)\b|unauthorized|forbidden|bad credentials|authentication failed/ },
34
+ { label: "github_token_permission", strength: "strong", pattern: /resource not accessible by integration|insufficient permissions|github_token permission|token does not have permission/ },
35
+ { label: "rate_limit", strength: "strong", pattern: /rate limit exceeded|api rate limit|secondary rate limit/ },
36
+ { label: "runner_unavailable", strength: "weak", pattern: /runner offline|no runners? available|waiting for a runner to pick up this job/ },
37
+ { label: "service_unavailable", strength: "weak", pattern: /service unavailable|502 bad gateway|503 service unavailable|temporarily unavailable/ },
38
+ { label: "permission_denied_generic", strength: "weak", pattern: /\bpermission denied\b/ }
39
+ ];
40
+
41
+ for (const check of checks) {
42
+ if (!check.pattern.test(haystack)) continue;
43
+ if (check.strength === "strong") strongSignals.push(check.label);
44
+ else weakSignals.push(check.label);
45
+ }
46
+
47
+ const shouldAbstain = strongSignals.length >= 1 || weakSignals.length >= 2;
48
+ const signals = [...strongSignals, ...weakSignals];
49
+
50
+ if (shouldAbstain) {
51
+ return {
52
+ abstain: true,
53
+ classification: "NOT_PATCHABLE",
54
+ reason: "Failure appears to be auth/permission/infra related; forcing abstain for safety.",
55
+ signals
56
+ };
57
+ }
58
+
59
+ return {
60
+ abstain: false,
61
+ classification: "PATCHABLE",
62
+ reason: "",
63
+ signals: []
64
+ };
65
+ }
66
+
67
+ export async function runGuardian(repo: string, runId: number, flags: RunFlags) {
68
+ const outDir = flags.outDir || path.join(process.cwd(), ".copilot-guardian");
69
+ ensureDir(outDir);
70
+
71
+ console.log(chalk.bold.cyan('\n=== Copilot Guardian Analysis ===\n'));
72
+ console.log(chalk.dim(`Repository: ${repo}`));
73
+ console.log(chalk.dim(`Run ID: ${runId}`));
74
+ console.log(chalk.dim(`Output: ${outDir}\n`));
75
+
76
+ const { analysisPath, analysis, ctx } = await analyzeRun(
77
+ repo,
78
+ runId,
79
+ outDir,
80
+ flags.maxLogChars,
81
+ {
82
+ fast: Boolean(flags.fast)
83
+ }
84
+ );
85
+
86
+ // Default: generate options only if requested (for speed)
87
+ let patchIndex: any | undefined;
88
+ if (flags.showOptions) {
89
+ const abstainDecision = detectForcedAbstain(ctx);
90
+ if (abstainDecision.abstain) {
91
+ const abstainReport = {
92
+ timestamp: new Date().toISOString(),
93
+ repo,
94
+ run_id: runId,
95
+ classification: abstainDecision.classification,
96
+ reason: abstainDecision.reason,
97
+ signals: abstainDecision.signals,
98
+ recommended_actions: [
99
+ "Escalate to human operator for auth/permissions/infra remediation.",
100
+ "Do not auto-apply patches for this failure class.",
101
+ "Re-run Guardian after infrastructure/auth issue is resolved."
102
+ ]
103
+ };
104
+ writeJson(path.join(outDir, "abstain.report.json"), abstainReport);
105
+ patchIndex = { timestamp: new Date().toISOString(), results: [], abstain: abstainReport };
106
+ console.log(chalk.yellow('\n[!] Forced ABSTAIN triggered.'));
107
+ console.log(chalk.dim(` Classification: ${abstainReport.classification}`));
108
+ console.log(chalk.dim(` Signals: ${abstainReport.signals.join(", ")}`));
109
+ console.log(chalk.dim(` Saved: ${path.join(outDir, "abstain.report.json")}\n`));
110
+ } else {
111
+ const { index } = await generatePatchOptions(analysis, outDir, {
112
+ fast: Boolean(flags.fast)
113
+ });
114
+ patchIndex = index;
115
+ const allNoGo = index?.results?.length > 0 && index.results.every((r: any) => r.verdict === "NO_GO");
116
+ if (allNoGo) {
117
+ const currentMax = Number.isFinite(flags.maxLogChars) ? Number(flags.maxLogChars) : 12000;
118
+ const suggestedMax = Math.min(currentMax * 3, 120000);
119
+ console.log(chalk.yellow('\n[!] All strategies are NO_GO.'));
120
+ console.log(chalk.dim(` Suggestion: re-run diagnosis with wider logs (--max-log-chars ${suggestedMax}).`));
121
+ console.log(chalk.dim(` Example: copilot-guardian run --repo ${repo} --run-id ${runId} --show-options --max-log-chars ${suggestedMax}\n`));
122
+ }
123
+ }
124
+ }
125
+
126
+ // Save a small summary report
127
+ const report = {
128
+ timestamp: new Date().toISOString(),
129
+ repo,
130
+ runId,
131
+ analysisPath,
132
+ patchIndexPath: flags.showOptions ? path.join(outDir, "patch_options.json") : null,
133
+ redacted: true
134
+ };
135
+ writeJson(path.join(outDir, "guardian.report.json"), report);
136
+
137
+ console.log(chalk.green.bold('\n=== Guardian Complete ==='));
138
+ console.log(chalk.dim(`All artifacts saved to: ${outDir}`));
139
+ console.log(chalk.dim('\nGenerated files:'));
140
+ console.log(chalk.dim(' - analysis.json (multi-hypothesis results)'));
141
+ console.log(chalk.dim(' - reasoning_trace.json (audit trail)'));
142
+ console.log(chalk.dim(' - copilot.analysis.raw.txt (full Copilot response)'));
143
+ if (flags.showOptions) {
144
+ if (patchIndex?.abstain) {
145
+ console.log(chalk.dim(' - abstain.report.json (forced abstain classification)'));
146
+ } else {
147
+ console.log(chalk.dim(' - fix.*.patch (3 patch strategies)'));
148
+ console.log(chalk.dim(' - patch_options.json (quality review results)'));
149
+ }
150
+ }
151
+ console.log("");
152
+
153
+ return { analysis, patchIndex, outDir, ctx };
154
+ }
@@ -0,0 +1,195 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+
4
+ import Ajv2020Module from "ajv/dist/2020.js";
5
+ import addFormatsModule from "ajv-formats";
6
+
7
+ // ESM default export handling
8
+ const Ajv2020 = (Ajv2020Module as any).default || Ajv2020Module;
9
+ const addFormats = (addFormatsModule as any).default || addFormatsModule;
10
+
11
+ function findPackageRoot(startDir: string): string | undefined {
12
+ let current = path.resolve(startDir);
13
+ while (true) {
14
+ const packageJson = path.join(current, "package.json");
15
+ const promptsDir = path.join(current, "prompts");
16
+ const schemasDir = path.join(current, "schemas");
17
+ if (fs.existsSync(packageJson) && fs.existsSync(promptsDir) && fs.existsSync(schemasDir)) {
18
+ return current;
19
+ }
20
+ const parent = path.dirname(current);
21
+ if (parent === current) break;
22
+ current = parent;
23
+ }
24
+ return undefined;
25
+ }
26
+
27
+ function resolvePackageRoot(): string {
28
+ const candidates: string[] = [];
29
+ if (typeof __dirname !== "undefined") candidates.push(__dirname);
30
+ if (typeof process.argv?.[1] === "string" && process.argv[1].trim()) {
31
+ candidates.push(path.dirname(path.resolve(process.argv[1])));
32
+ }
33
+ candidates.push(process.cwd());
34
+
35
+ for (const candidate of candidates) {
36
+ const resolved = findPackageRoot(candidate);
37
+ if (resolved) return resolved;
38
+ }
39
+ return process.cwd();
40
+ }
41
+
42
+ export const PACKAGE_ROOT = resolvePackageRoot();
43
+
44
+ export type ExecOptions = {
45
+ cwd?: string;
46
+ input?: string;
47
+ env?: NodeJS.ProcessEnv;
48
+ stdio?: "pipe" | "inherit";
49
+ };
50
+
51
+ export function ensureDir(dir: string): void {
52
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
53
+ }
54
+
55
+ export function loadText(p: string): string {
56
+ return fs.readFileSync(p, "utf8");
57
+ }
58
+
59
+ export function writeText(p: string, content: string): void {
60
+ ensureDir(path.dirname(p));
61
+ fs.writeFileSync(p, content, "utf8");
62
+ }
63
+
64
+ export function writeJson(p: string, obj: unknown): void {
65
+ writeText(p, JSON.stringify(obj, null, 2));
66
+ }
67
+
68
+ export function redactSecrets(text: string): string {
69
+ const patterns: RegExp[] = [
70
+ // GitHub tokens
71
+ /ghp_[a-zA-Z0-9]{36}/g,
72
+ /gho_[a-zA-Z0-9]{36}/g,
73
+ /github_pat_[a-zA-Z0-9_]{82}/g,
74
+ /ghs_[a-zA-Z0-9]{36}/g,
75
+ // Bearer tokens
76
+ /Bearer\s+[A-Za-z0-9\-._~+/]+=*/g,
77
+ // OpenAI keys
78
+ /sk-[a-zA-Z0-9]{48}/g,
79
+ // Generic secrets (key=value pattern)
80
+ /(token|password|secret|api_key|apikey|auth)\s*[:=]\s*['"]?[^\s'"]+['"]?/gi,
81
+ // AWS keys
82
+ /AKIA[0-9A-Z]{16}/g,
83
+ // Private keys
84
+ /-----BEGIN\s+(RSA|DSA|EC|OPENSSH)?\s*PRIVATE\s+KEY-----/g,
85
+ ];
86
+ // NOTE: Removed over-aggressive 40+ char alphanumeric pattern (S5 fix)
87
+ // It was redacting git SHAs, npm hashes, and other diagnostic data
88
+
89
+ let redacted = text;
90
+ for (const pattern of patterns) {
91
+ redacted = redacted.replace(pattern, "***REDACTED***");
92
+ }
93
+ return redacted;
94
+ }
95
+
96
+ export function findResidualSecrets(text: string): string[] {
97
+ const checks: Array<{ label: string; pattern: RegExp }> = [
98
+ { label: "github_token_classic", pattern: /gh[pousr]_[A-Za-z0-9]{20,}/g },
99
+ { label: "github_token_fine_grained", pattern: /github_pat_[A-Za-z0-9_]{30,}/g },
100
+ { label: "openai_key", pattern: /\bsk-[A-Za-z0-9]{20,}\b/g },
101
+ { label: "bearer_token", pattern: /Bearer\s+[A-Za-z0-9\-._~+/=]{16,}/g },
102
+ { label: "aws_access_key", pattern: /\bAKIA[0-9A-Z]{16}\b/g },
103
+ { label: "private_key", pattern: /-----BEGIN\s+(?:RSA|DSA|EC|OPENSSH)?\s*PRIVATE\s+KEY-----/g },
104
+ { label: "jwt", pattern: /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/g }
105
+ ];
106
+
107
+ const found = new Set<string>();
108
+ for (const check of checks) {
109
+ if (check.pattern.test(text)) {
110
+ found.add(check.label);
111
+ }
112
+ }
113
+ return Array.from(found);
114
+ }
115
+
116
+ export function extractJsonObject(text: string): string {
117
+ // Try to find JSON in various formats:
118
+ // 1. Pure JSON (starts with {)
119
+ // 2. JSON in markdown code block (```json ... ```)
120
+ // 3. JSON after prose text
121
+
122
+ // First, try to extract from markdown code block
123
+ const codeBlockMatch = text.match(/```(?:json)?\s*(\{[\s\S]*?\})\s*```/);
124
+ if (codeBlockMatch) {
125
+ return codeBlockMatch[1].trim();
126
+ }
127
+
128
+ // S4 FIX: Use non-greedy matching with balanced brace counting
129
+ // Find first { and match to its balanced closing }
130
+ const startIdx = text.indexOf('{');
131
+ if (startIdx === -1) {
132
+ // No JSON found - provide helpful error
133
+ const preview = text.substring(0, 200).replace(/\n/g, ' ');
134
+ throw new Error(
135
+ `No JSON object found in Copilot response.\n` +
136
+ `Response preview: "${preview}..."\n` +
137
+ `Hint: Copilot may have returned prose instead of JSON. Check copilot.*.raw.txt file.`
138
+ );
139
+ }
140
+
141
+ let depth = 0;
142
+ let inString = false;
143
+ let escape = false;
144
+
145
+ for (let i = startIdx; i < text.length; i++) {
146
+ const char = text[i];
147
+
148
+ if (escape) {
149
+ escape = false;
150
+ continue;
151
+ }
152
+
153
+ if (char === '\\' && inString) {
154
+ escape = true;
155
+ continue;
156
+ }
157
+
158
+ if (char === '"' && !escape) {
159
+ inString = !inString;
160
+ continue;
161
+ }
162
+
163
+ if (!inString) {
164
+ if (char === '{') depth++;
165
+ else if (char === '}') {
166
+ depth--;
167
+ if (depth === 0) {
168
+ return text.slice(startIdx, i + 1);
169
+ }
170
+ }
171
+ }
172
+ }
173
+
174
+ // Fallback: return from first { to end (may be truncated JSON)
175
+ throw new Error("Unbalanced JSON object in Copilot response - missing closing brace");
176
+ }
177
+
178
+ export function validateJson(data: unknown, schemaPath: string): void {
179
+ const ajv = new Ajv2020({ allErrors: true, strict: false });
180
+ addFormats(ajv);
181
+ const schema = JSON.parse(loadText(schemaPath));
182
+ const validate = ajv.compile(schema);
183
+ const ok = validate(data);
184
+ if (!ok) {
185
+ const errors = (validate.errors || [])
186
+ .map((e: { instancePath?: string; message?: string }) => `${e.instancePath || "(root)"}: ${e.message}`)
187
+ .join("\n");
188
+ throw new Error(`Schema validation failed (${schemaPath}):\n${errors}`);
189
+ }
190
+ }
191
+
192
+ export function clampText(s: string, maxChars: number): string {
193
+ if (s.length <= maxChars) return s;
194
+ return s.slice(0, maxChars) + "\n... [truncated]";
195
+ }
@@ -0,0 +1,90 @@
1
+ import chalk from "chalk";
2
+
3
+ // ASCII-safe block characters for cross-platform compatibility
4
+ const BLOCK_FULL = '#';
5
+ const BLOCK_LIGHT = '-';
6
+
7
+ function riskRank(level: string): number {
8
+ if (level === "low") return 0;
9
+ if (level === "medium") return 1;
10
+ return 2;
11
+ }
12
+
13
+ function bar(p: number, width = 10): string {
14
+ const filled = Math.max(0, Math.min(width, Math.round(p * width)));
15
+ return BLOCK_FULL.repeat(filled) + BLOCK_LIGHT.repeat(width - filled);
16
+ }
17
+
18
+ export function renderHeader(repo: string, runId: number): void {
19
+ console.log("\n" + chalk.bold.bgCyan.black(" COPILOT-GUARDIAN ") + "\n");
20
+ console.log(`${chalk.bold("Repo:")} ${repo}`);
21
+ console.log(`${chalk.bold("Run:")} ${runId}`);
22
+ }
23
+
24
+ export function renderSummary(analysis: any): void {
25
+ console.log("\n" + chalk.bold.bgRed(" [X] FAILURE DIAGNOSIS ") + "\n");
26
+ console.log(`${chalk.bold("Root cause:")} ${analysis.diagnosis.root_cause}`);
27
+ console.log(`${chalk.bold("Selected:")} ${analysis.diagnosis.selected_hypothesis_id} (${analysis.diagnosis.confidence_score})`);
28
+ console.log(`${chalk.bold("Intent:")} ${analysis.patch_plan.intent}`);
29
+ }
30
+
31
+ export function renderHypotheses(hypotheses: any[]): void {
32
+ console.log("\n" + chalk.bold.bgMagenta.white(" MULTI-HYPOTHESIS REASONING ") + "\n");
33
+
34
+ for (const h of hypotheses) {
35
+ const pct = Math.round((h.confidence ?? 0) * 100);
36
+ const b = bar(h.confidence ?? 0);
37
+
38
+ console.log(`${chalk.bold.cyan(h.id)} ${chalk.bold.white(h.title)}`);
39
+ console.log(` Confidence: ${chalk.cyan(b)} ${chalk.cyan.bold(pct + "%")}`);
40
+ console.log(` Evidence: ${chalk.dim((h.evidence?.[0] || "").slice(0, 140))}`);
41
+ if (h.next_check) {
42
+ console.log(` Next check: ${chalk.yellow((h.next_check || "").slice(0, 140))}`);
43
+ }
44
+ console.log("");
45
+ }
46
+
47
+ // Summary line
48
+ const selected = hypotheses.find(h => h.confidence === Math.max(...hypotheses.map(x => x.confidence ?? 0)));
49
+ if (selected) {
50
+ console.log(chalk.green.bold(`[SELECTED] ${selected.id}: ${selected.title}`));
51
+ console.log(chalk.dim(`Confidence: ${Math.round((selected.confidence ?? 0) * 100)}% - Highest among all hypotheses\n`));
52
+ }
53
+ }
54
+
55
+ export function renderPatchSpectrum(index: any): void {
56
+ console.log("\n" + chalk.bold.bgYellow.black(" PATCH SPECTRUM ") + "\n");
57
+
58
+ for (const r of index.results) {
59
+ const badge = r.verdict === "GO" ? chalk.bgGreen.black(" GO ") : chalk.bgRed.white(" NO-GO ");
60
+
61
+ const risk = r.risk_level === "low" ? chalk.green("low") : r.risk_level === "medium" ? chalk.yellow("medium") : chalk.red("high");
62
+
63
+ const slopIndicator = r.slop_score > 0.5 ? chalk.red(` [SLOP: ${Math.round(r.slop_score * 100)}%]`) : "";
64
+
65
+ console.log(`${chalk.bold(String(r.label).padEnd(15))} ${badge} risk=${risk}${slopIndicator}`);
66
+ console.log(` patch: ${chalk.dim(r.patchPath)}`);
67
+ if (r.files && r.files.length > 0) {
68
+ console.log(` files: ${chalk.dim(r.files.join(', '))}`);
69
+ }
70
+ if (r.summary) console.log(` note: ${chalk.dim(String(r.summary).slice(0, 160))}`);
71
+ console.log("");
72
+ }
73
+
74
+ // Summary recommendation
75
+ const goStrategies = index.results.filter((r: any) => r.verdict === 'GO');
76
+ if (goStrategies.length > 0) {
77
+ const sorted = [...goStrategies].sort((a: any, b: any) => {
78
+ const riskDelta = riskRank(String(a?.risk_level)) - riskRank(String(b?.risk_level));
79
+ if (riskDelta !== 0) return riskDelta;
80
+ const slopA = Number.isFinite(Number(a?.slop_score)) ? Number(a.slop_score) : 1;
81
+ const slopB = Number.isFinite(Number(b?.slop_score)) ? Number(b.slop_score) : 1;
82
+ return slopA - slopB;
83
+ });
84
+ const recommended = sorted[0];
85
+ console.log(chalk.green.bold(`[RECOMMENDED] ${recommended.label}`));
86
+ console.log(chalk.dim(`Reason: Lowest risk and lowest slop among GO strategies\n`));
87
+ } else {
88
+ console.log(chalk.yellow.bold('[!] No GO strategies available - all flagged for slop or high risk\n'));
89
+ }
90
+ }
package/test-sdk.mjs ADDED
@@ -0,0 +1,51 @@
1
+ // Test script for Copilot SDK integration
2
+ import { CopilotClient } from '@github/copilot-sdk';
3
+
4
+ async function testSdk() {
5
+ console.log('[*] Testing Copilot SDK integration...\n');
6
+
7
+ try {
8
+ console.log('[>] Creating CopilotClient...');
9
+ const client = new CopilotClient({
10
+ autoStart: true,
11
+ useLoggedInUser: true,
12
+ });
13
+
14
+ console.log('[>] Starting client...');
15
+ await client.start();
16
+ console.log('[+] Client started successfully!\n');
17
+
18
+ console.log('[>] Creating session with gpt-4o...');
19
+ const session = await client.createSession({
20
+ model: 'gpt-4o',
21
+ });
22
+ console.log('[+] Session created: ' + session.sessionId + '\n');
23
+
24
+ console.log('[>] Sending test prompt...');
25
+ const testPrompt = 'Respond with exactly: "SDK TEST SUCCESSFUL"';
26
+
27
+ const response = await session.sendAndWait({ prompt: testPrompt }, 30000);
28
+
29
+ console.log('\n[+] Response received:');
30
+ console.log('---');
31
+ console.log(response?.data?.content || '(no content)');
32
+ console.log('---\n');
33
+
34
+ console.log('[>] Cleaning up session...');
35
+ await session.destroy();
36
+
37
+ console.log('[>] Stopping client...');
38
+ await client.stop();
39
+
40
+ console.log('\n[*] SDK TEST COMPLETE - ALL SYSTEMS OPERATIONAL');
41
+ console.log('[*] copilot-guardian is ready to use Copilot SDK!');
42
+
43
+ } catch (error) {
44
+ console.error('\n[-] SDK TEST FAILED:');
45
+ console.error(error.message);
46
+ console.error('\nFull error:', error);
47
+ process.exit(1);
48
+ }
49
+ }
50
+
51
+ testSdk();
@@ -0,0 +1,76 @@
1
+ import {
2
+ ensureAutoHealBranch,
3
+ rerunLatestRunForCommit
4
+ } from '../src/engine/auto-apply';
5
+ import { execAsync } from '../src/engine/async-exec';
6
+
7
+ jest.mock('../src/engine/async-exec', () => ({
8
+ execAsync: jest.fn()
9
+ }));
10
+
11
+ const mockedExecAsync = execAsync as jest.MockedFunction<typeof execAsync>;
12
+
13
+ describe('auto-heal branch safety helpers', () => {
14
+ beforeEach(() => {
15
+ jest.clearAllMocks();
16
+ });
17
+
18
+ test('creates safe branch before patching flow when direct push is disabled', async () => {
19
+ mockedExecAsync
20
+ .mockResolvedValueOnce('main\n') // rev-parse
21
+ .mockResolvedValueOnce(''); // checkout -b
22
+
23
+ const ctx = await ensureAutoHealBranch(999, {
24
+ directPush: false,
25
+ baseBranch: 'main',
26
+ suffix: '12345678'
27
+ });
28
+
29
+ expect(mockedExecAsync).toHaveBeenNthCalledWith(1, 'git', ['rev-parse', '--abbrev-ref', 'HEAD']);
30
+ expect(mockedExecAsync).toHaveBeenNthCalledWith(2, 'git', ['checkout', '-b', 'guardian/run-999-12345678']);
31
+ expect(ctx.createdSafeBranch).toBe(true);
32
+ expect(ctx.pushBranch).toBe('guardian/run-999-12345678');
33
+ expect(ctx.baseBranch).toBe('main');
34
+ });
35
+
36
+ test('does not create safe branch when direct push is enabled', async () => {
37
+ mockedExecAsync.mockResolvedValueOnce('main\n');
38
+
39
+ const ctx = await ensureAutoHealBranch(1000, {
40
+ directPush: true,
41
+ baseBranch: 'main'
42
+ });
43
+
44
+ expect(mockedExecAsync).toHaveBeenCalledTimes(1);
45
+ expect(ctx.createdSafeBranch).toBe(false);
46
+ expect(ctx.pushBranch).toBe('main');
47
+ });
48
+
49
+ test('reruns latest run for commit when available', async () => {
50
+ mockedExecAsync
51
+ .mockResolvedValueOnce(JSON.stringify([{ databaseId: 321, status: 'completed', conclusion: 'failure' }]))
52
+ .mockResolvedValueOnce('');
53
+
54
+ const runId = await rerunLatestRunForCommit('owner/repo', 'abc123');
55
+ expect(runId).toBe(321);
56
+ expect(mockedExecAsync).toHaveBeenNthCalledWith(1, 'gh', [
57
+ 'run',
58
+ 'list',
59
+ '--repo',
60
+ 'owner/repo',
61
+ '--commit',
62
+ 'abc123',
63
+ '--limit',
64
+ '1',
65
+ '--json',
66
+ 'databaseId,status,conclusion'
67
+ ]);
68
+ expect(mockedExecAsync).toHaveBeenNthCalledWith(2, 'gh', [
69
+ 'run',
70
+ 'rerun',
71
+ '321',
72
+ '--repo',
73
+ 'owner/repo'
74
+ ]);
75
+ });
76
+ });
@@ -0,0 +1,24 @@
1
+ import { fetchRunContext } from '../src/engine/github';
2
+ import { ghAsync } from '../src/engine/async-exec';
3
+
4
+ jest.mock('../src/engine/async-exec', () => ({
5
+ ghAsync: jest.fn()
6
+ }));
7
+
8
+ const mockedGhAsync = ghAsync as jest.MockedFunction<typeof ghAsync>;
9
+
10
+ describe('fetchRunContext redaction fail-closed', () => {
11
+ beforeEach(() => {
12
+ jest.clearAllMocks();
13
+ });
14
+
15
+ test('aborts when residual secret patterns remain after redaction', async () => {
16
+ const jwtLike = 'eyJabcdefgh12.eyJijklmnop34.eyJqrstuvwx56';
17
+ mockedGhAsync.mockResolvedValue(`Build log line with suspicious token ${jwtLike}`);
18
+
19
+ await expect(fetchRunContext('owner/repo', 1001, 12000)).rejects.toThrow(
20
+ /Redaction fail-closed/
21
+ );
22
+ expect(mockedGhAsync).toHaveBeenCalledTimes(1);
23
+ });
24
+ });
@@ -0,0 +1,15 @@
1
+ type SessionResponse = {
2
+ content?: string;
3
+ };
4
+
5
+ type Session = {
6
+ sendAndWait: (input: { prompt: string; mode: string }, timeoutMs?: number) => Promise<SessionResponse>;
7
+ };
8
+
9
+ export class CopilotClient {
10
+ async startSession(): Promise<Session> {
11
+ return {
12
+ sendAndWait: async () => ({ content: '' })
13
+ };
14
+ }
15
+ }