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.
- package/.github/workflows/ci.yml +53 -0
- package/.test-output-run-abstain/guardian.report.json +8 -0
- package/CHANGELOG.md +602 -0
- package/CONTRIBUTING.md +28 -0
- package/LICENSE +21 -0
- package/README.md +205 -0
- package/SECURITY.md +150 -0
- package/dist/cli.js +384 -0
- package/dist/cli.js.map +1 -0
- package/dist/engine/analyze.js +294 -0
- package/dist/engine/analyze.js.map +1 -0
- package/dist/engine/async-exec.js +314 -0
- package/dist/engine/async-exec.js.map +1 -0
- package/dist/engine/auto-apply.js +424 -0
- package/dist/engine/auto-apply.js.map +1 -0
- package/dist/engine/context-enhancer.js +141 -0
- package/dist/engine/context-enhancer.js.map +1 -0
- package/dist/engine/debug.js +77 -0
- package/dist/engine/debug.js.map +1 -0
- package/dist/engine/eval.js +437 -0
- package/dist/engine/eval.js.map +1 -0
- package/dist/engine/github.js +191 -0
- package/dist/engine/github.js.map +1 -0
- package/dist/engine/mcp.js +217 -0
- package/dist/engine/mcp.js.map +1 -0
- package/dist/engine/patch_options.js +474 -0
- package/dist/engine/patch_options.js.map +1 -0
- package/dist/engine/run.js +124 -0
- package/dist/engine/run.js.map +1 -0
- package/dist/engine/util.js +167 -0
- package/dist/engine/util.js.map +1 -0
- package/dist/ui/dashboard.js +81 -0
- package/dist/ui/dashboard.js.map +1 -0
- package/docs/ARCHITECTURE.md +292 -0
- package/docs/Logo.png +0 -0
- package/docs/screenshots/05-hypothesis-dashboard.png +0 -0
- package/docs/screenshots/07-patch-spectrum.png +0 -0
- package/docs/screenshots/final-demo.gif +0 -0
- package/examples/demo-failure/.github/workflows/ci.yml +23 -0
- package/examples/demo-failure/README.md +93 -0
- package/examples/demo-failure/package.json +9 -0
- package/examples/demo-failure/test/require-api-url.js +10 -0
- package/jest.config.cjs +35 -0
- package/package.json +39 -0
- package/prompts/analysis.v2.txt +62 -0
- package/prompts/debug.followup.v1.txt +18 -0
- package/prompts/patch.options.v1.txt +47 -0
- package/prompts/patch.simple.v1.txt +12 -0
- package/prompts/quality.v1.txt +25 -0
- package/schemas/analysis.schema.json +65 -0
- package/schemas/patch_options.schema.json +23 -0
- package/schemas/quality.schema.json +12 -0
- package/src/cli.ts +417 -0
- package/src/engine/analyze.ts +412 -0
- package/src/engine/async-exec.ts +384 -0
- package/src/engine/auto-apply.ts +516 -0
- package/src/engine/context-enhancer.ts +176 -0
- package/src/engine/debug.ts +91 -0
- package/src/engine/eval.ts +546 -0
- package/src/engine/github.ts +223 -0
- package/src/engine/mcp.ts +267 -0
- package/src/engine/patch_options.ts +604 -0
- package/src/engine/run.ts +154 -0
- package/src/engine/util.ts +195 -0
- package/src/ui/dashboard.ts +90 -0
- package/test-sdk.mjs +51 -0
- package/tests/auto_heal_branch_safety.test.ts +76 -0
- package/tests/github_redaction_failclosed.test.ts +24 -0
- package/tests/mocks/copilot-sdk.mock.ts +15 -0
- package/tests/quality_guard_regression_matrix.test.ts +432 -0
- package/tests/run_abstain_policy.test.ts +83 -0
- 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
|
+
}
|