holo-codex 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/plugins/marketplace.json +20 -0
- package/CONTRIBUTING.md +54 -0
- package/LICENSE +21 -0
- package/README.md +215 -0
- package/README.zh-CN.md +215 -0
- package/SECURITY.md +39 -0
- package/assets/brand/README.md +35 -0
- package/assets/brand/holo-codex-icon.svg +28 -0
- package/assets/brand/holo-codex-lockup.svg +49 -0
- package/assets/brand/holo-codex-mark.svg +33 -0
- package/assets/brand/holo-codex-plugin-card.png +0 -0
- package/assets/brand/holo-codex-plugin-card.svg +81 -0
- package/assets/brand/holo-codex-readme-hero.png +0 -0
- package/assets/brand/holo-codex-readme-hero.svg +140 -0
- package/assets/brand/holo-codex-social-preview.png +0 -0
- package/assets/brand/holo-codex-social-preview.svg +130 -0
- package/assets/brand/holo-codex-wordmark-options.svg +52 -0
- package/docs/checklists/agent-loop-first-delivery-audit.md +129 -0
- package/docs/examples/generic-loop-repo-hygiene.md +168 -0
- package/docs/install.md +190 -0
- package/docs/local-release-readiness.md +206 -0
- package/docs/release-checklist.md +144 -0
- package/docs/self-bootstrap.md +150 -0
- package/docs/trust-and-safety.md +45 -0
- package/package.json +83 -0
- package/plugins/autonomous-pr-loop/.codex-plugin/plugin.json +17 -0
- package/plugins/autonomous-pr-loop/.mcp.json +13 -0
- package/plugins/autonomous-pr-loop/bin/agent-loop.mjs +31 -0
- package/plugins/autonomous-pr-loop/core/artifacts.ts +164 -0
- package/plugins/autonomous-pr-loop/core/autonomy-policy.ts +206 -0
- package/plugins/autonomous-pr-loop/core/ci.ts +131 -0
- package/plugins/autonomous-pr-loop/core/cli-i18n.ts +123 -0
- package/plugins/autonomous-pr-loop/core/cli.ts +1413 -0
- package/plugins/autonomous-pr-loop/core/command-runner.ts +446 -0
- package/plugins/autonomous-pr-loop/core/command.ts +47 -0
- package/plugins/autonomous-pr-loop/core/config-editor.ts +140 -0
- package/plugins/autonomous-pr-loop/core/config.ts +293 -0
- package/plugins/autonomous-pr-loop/core/controller-host.ts +19 -0
- package/plugins/autonomous-pr-loop/core/dashboard-server.ts +536 -0
- package/plugins/autonomous-pr-loop/core/delivery-work-item.ts +217 -0
- package/plugins/autonomous-pr-loop/core/doctor.ts +335 -0
- package/plugins/autonomous-pr-loop/core/errors.ts +82 -0
- package/plugins/autonomous-pr-loop/core/gate-recovery.ts +176 -0
- package/plugins/autonomous-pr-loop/core/gates.ts +26 -0
- package/plugins/autonomous-pr-loop/core/generic-lifecycle.ts +399 -0
- package/plugins/autonomous-pr-loop/core/git.ts +213 -0
- package/plugins/autonomous-pr-loop/core/github.ts +269 -0
- package/plugins/autonomous-pr-loop/core/gitnexus.ts +90 -0
- package/plugins/autonomous-pr-loop/core/happy.ts +42 -0
- package/plugins/autonomous-pr-loop/core/hook-capture.ts +115 -0
- package/plugins/autonomous-pr-loop/core/hook-events.ts +22 -0
- package/plugins/autonomous-pr-loop/core/hook-installation.ts +85 -0
- package/plugins/autonomous-pr-loop/core/hook-observer.ts +84 -0
- package/plugins/autonomous-pr-loop/core/hook-policy.ts +423 -0
- package/plugins/autonomous-pr-loop/core/hook-router.ts +452 -0
- package/plugins/autonomous-pr-loop/core/index.ts +32 -0
- package/plugins/autonomous-pr-loop/core/local-install.ts +778 -0
- package/plugins/autonomous-pr-loop/core/locale.ts +60 -0
- package/plugins/autonomous-pr-loop/core/loop-shapes.ts +190 -0
- package/plugins/autonomous-pr-loop/core/mcp-controller.ts +1479 -0
- package/plugins/autonomous-pr-loop/core/notification-feed.ts +263 -0
- package/plugins/autonomous-pr-loop/core/plan-parser.ts +206 -0
- package/plugins/autonomous-pr-loop/core/plugin-paths.ts +32 -0
- package/plugins/autonomous-pr-loop/core/policy.ts +65 -0
- package/plugins/autonomous-pr-loop/core/pr-lifecycle.ts +464 -0
- package/plugins/autonomous-pr-loop/core/pr-selector.ts +284 -0
- package/plugins/autonomous-pr-loop/core/profiles.ts +439 -0
- package/plugins/autonomous-pr-loop/core/redaction.ts +17 -0
- package/plugins/autonomous-pr-loop/core/repo-root.ts +22 -0
- package/plugins/autonomous-pr-loop/core/review-comments.ts +77 -0
- package/plugins/autonomous-pr-loop/core/scope-guard.ts +179 -0
- package/plugins/autonomous-pr-loop/core/state-machine.ts +828 -0
- package/plugins/autonomous-pr-loop/core/state-types.ts +130 -0
- package/plugins/autonomous-pr-loop/core/storage.ts +2527 -0
- package/plugins/autonomous-pr-loop/core/types.ts +567 -0
- package/plugins/autonomous-pr-loop/core/worker-events.ts +412 -0
- package/plugins/autonomous-pr-loop/core/worker-policy.ts +72 -0
- package/plugins/autonomous-pr-loop/core/worker-prompts.ts +182 -0
- package/plugins/autonomous-pr-loop/core/worker.ts +809 -0
- package/plugins/autonomous-pr-loop/core/workflow-board.ts +1515 -0
- package/plugins/autonomous-pr-loop/hooks/dist/permission-request.js +2462 -0
- package/plugins/autonomous-pr-loop/hooks/dist/post-compact.js +2462 -0
- package/plugins/autonomous-pr-loop/hooks/dist/post-tool-use.js +2462 -0
- package/plugins/autonomous-pr-loop/hooks/dist/pre-compact.js +2462 -0
- package/plugins/autonomous-pr-loop/hooks/dist/pre-tool-use.js +3460 -0
- package/plugins/autonomous-pr-loop/hooks/dist/session-start.js +2462 -0
- package/plugins/autonomous-pr-loop/hooks/dist/stop.js +2462 -0
- package/plugins/autonomous-pr-loop/hooks/dist/user-prompt-submit.js +2462 -0
- package/plugins/autonomous-pr-loop/hooks/hooks.json +106 -0
- package/plugins/autonomous-pr-loop/hooks/observe-runner.ts +25 -0
- package/plugins/autonomous-pr-loop/hooks/permission-request.ts +4 -0
- package/plugins/autonomous-pr-loop/hooks/post-compact.ts +4 -0
- package/plugins/autonomous-pr-loop/hooks/post-tool-use.ts +4 -0
- package/plugins/autonomous-pr-loop/hooks/pre-compact.ts +4 -0
- package/plugins/autonomous-pr-loop/hooks/pre-tool-use.ts +44 -0
- package/plugins/autonomous-pr-loop/hooks/session-start.ts +4 -0
- package/plugins/autonomous-pr-loop/hooks/stop.ts +4 -0
- package/plugins/autonomous-pr-loop/hooks/user-prompt-submit.ts +4 -0
- package/plugins/autonomous-pr-loop/mcp-server/src/index.ts +87 -0
- package/plugins/autonomous-pr-loop/mcp-server/src/tools.ts +205 -0
- package/plugins/autonomous-pr-loop/package.json +9 -0
- package/plugins/autonomous-pr-loop/schemas/config.schema.json +74 -0
- package/plugins/autonomous-pr-loop/schemas/marketplace.schema.json +46 -0
- package/plugins/autonomous-pr-loop/schemas/plugin.schema.json +32 -0
- package/plugins/autonomous-pr-loop/schemas/state.schema.json +19 -0
- package/plugins/autonomous-pr-loop/schemas/worker-event.schema.json +19 -0
- package/plugins/autonomous-pr-loop/schemas/worker-result.schema.json +58 -0
- package/plugins/autonomous-pr-loop/scripts/agent-loop.ts +44 -0
- package/plugins/autonomous-pr-loop/skills/autonomous-pr-loop/SKILL.md +26 -0
- package/plugins/autonomous-pr-loop/skills/autonomous-pr-loop/agents/openai.yaml +6 -0
- package/plugins/autonomous-pr-loop/ui/index.html +26 -0
- package/plugins/autonomous-pr-loop/ui/public/favicon.svg +7 -0
- package/plugins/autonomous-pr-loop/ui/src/api.ts +639 -0
- package/plugins/autonomous-pr-loop/ui/src/app.tsx +238 -0
- package/plugins/autonomous-pr-loop/ui/src/components/ActivityBadge.tsx +31 -0
- package/plugins/autonomous-pr-loop/ui/src/components/BrandMark.tsx +36 -0
- package/plugins/autonomous-pr-loop/ui/src/components/Collapsible.tsx +6 -0
- package/plugins/autonomous-pr-loop/ui/src/components/CommandPreview.tsx +15 -0
- package/plugins/autonomous-pr-loop/ui/src/components/ConfigEditor.tsx +389 -0
- package/plugins/autonomous-pr-loop/ui/src/components/EmptyState.tsx +10 -0
- package/plugins/autonomous-pr-loop/ui/src/components/ErrorState.tsx +12 -0
- package/plugins/autonomous-pr-loop/ui/src/components/List.tsx +7 -0
- package/plugins/autonomous-pr-loop/ui/src/components/MetricRow.tsx +6 -0
- package/plugins/autonomous-pr-loop/ui/src/components/ResponsiveTable.tsx +65 -0
- package/plugins/autonomous-pr-loop/ui/src/components/RiskBadge.tsx +10 -0
- package/plugins/autonomous-pr-loop/ui/src/components/StatusBadge.tsx +29 -0
- package/plugins/autonomous-pr-loop/ui/src/components/TopMetric.tsx +10 -0
- package/plugins/autonomous-pr-loop/ui/src/fixtures.ts +1152 -0
- package/plugins/autonomous-pr-loop/ui/src/i18n.ts +1105 -0
- package/plugins/autonomous-pr-loop/ui/src/main.tsx +14 -0
- package/plugins/autonomous-pr-loop/ui/src/pages/CommandCenter.tsx +470 -0
- package/plugins/autonomous-pr-loop/ui/src/pages/CommandCenterParts.tsx +276 -0
- package/plugins/autonomous-pr-loop/ui/src/pages/agent-timeline/AgentTimelineView.tsx +73 -0
- package/plugins/autonomous-pr-loop/ui/src/pages/artifact-viewer/ArtifactViewer.tsx +44 -0
- package/plugins/autonomous-pr-loop/ui/src/pages/dry-run-preview/DryRunPreview.tsx +66 -0
- package/plugins/autonomous-pr-loop/ui/src/pages/event-ledger/EventLedger.tsx +17 -0
- package/plugins/autonomous-pr-loop/ui/src/pages/gate-center/GateCenter.tsx +34 -0
- package/plugins/autonomous-pr-loop/ui/src/pages/mission-control/MissionControl.tsx +104 -0
- package/plugins/autonomous-pr-loop/ui/src/pages/mission-control/WorkflowBoard.tsx +577 -0
- package/plugins/autonomous-pr-loop/ui/src/pages/notifications/NotificationsView.tsx +30 -0
- package/plugins/autonomous-pr-loop/ui/src/pages/plan-navigator/PlanNavigator.tsx +19 -0
- package/plugins/autonomous-pr-loop/ui/src/pages/policy-config/PolicyConfig.tsx +22 -0
- package/plugins/autonomous-pr-loop/ui/src/pages/pr-inbox/PrInbox.tsx +26 -0
- package/plugins/autonomous-pr-loop/ui/src/pages/recovery-center/RecoveryCenter.tsx +125 -0
- package/plugins/autonomous-pr-loop/ui/src/pages/scope-guard/ScopeGuard.tsx +16 -0
- package/plugins/autonomous-pr-loop/ui/src/pages/worker-runs/WorkerRuns.tsx +39 -0
- package/plugins/autonomous-pr-loop/ui/src/styles.css +2673 -0
- package/plugins/autonomous-pr-loop/ui/src/theme.ts +57 -0
- package/tsconfig.json +18 -0
|
@@ -0,0 +1,446 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { promisify } from "node:util";
|
|
4
|
+
import { writeArtifact } from "./artifacts.js";
|
|
5
|
+
import type { CommandPlan, CommandRunResult } from "./state-types.js";
|
|
6
|
+
import type { AgentLoopConfig, AgentLoopStorage } from "./types.js";
|
|
7
|
+
|
|
8
|
+
const execFileAsync = promisify(execFile);
|
|
9
|
+
|
|
10
|
+
interface RunnerOptions {
|
|
11
|
+
repoRoot: string;
|
|
12
|
+
storage: AgentLoopStorage;
|
|
13
|
+
runId: string;
|
|
14
|
+
config: AgentLoopConfig;
|
|
15
|
+
signal?: AbortSignal | undefined;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Execute structured command plans through an argv allowlist and denylist policy. */
|
|
19
|
+
export class CommandRunner {
|
|
20
|
+
constructor(private readonly options: RunnerOptions) {}
|
|
21
|
+
|
|
22
|
+
async run(plan: CommandPlan, dryRun: boolean): Promise<CommandRunResult> {
|
|
23
|
+
const started = Date.now();
|
|
24
|
+
const policy = evaluatePolicy(plan);
|
|
25
|
+
if (!policy.allowed) {
|
|
26
|
+
const reason = policy.reason ?? "Command rejected.";
|
|
27
|
+
const result = this.result(plan, dryRun, false, 126, "", reason, started, false, [], reason);
|
|
28
|
+
this.recordCommandResult(result, "policy_violation");
|
|
29
|
+
return result;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (dryRun) {
|
|
33
|
+
const result = this.result(plan, true, true, 0, "", "", started, false, []);
|
|
34
|
+
this.recordCommandResult(result, "command_dry_run");
|
|
35
|
+
return result;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
try {
|
|
39
|
+
const output = await execFileAsync(plan.file, plan.args, {
|
|
40
|
+
cwd: plan.cwd,
|
|
41
|
+
shell: false,
|
|
42
|
+
timeout: plan.timeoutMs ?? this.options.config.commandTimeoutMs,
|
|
43
|
+
maxBuffer: Math.max((plan.outputLimitBytes ?? this.options.config.commandOutputLimitBytes) * 4, 1_048_576),
|
|
44
|
+
signal: this.options.signal
|
|
45
|
+
});
|
|
46
|
+
const result = this.result(
|
|
47
|
+
plan,
|
|
48
|
+
false,
|
|
49
|
+
true,
|
|
50
|
+
0,
|
|
51
|
+
output.stdout,
|
|
52
|
+
output.stderr,
|
|
53
|
+
started,
|
|
54
|
+
false,
|
|
55
|
+
[]
|
|
56
|
+
);
|
|
57
|
+
this.recordCommandResult(result, "command_executed");
|
|
58
|
+
return result;
|
|
59
|
+
} catch (error) {
|
|
60
|
+
const typed = error as {
|
|
61
|
+
code?: number | string;
|
|
62
|
+
killed?: boolean;
|
|
63
|
+
signal?: string;
|
|
64
|
+
stdout?: string;
|
|
65
|
+
stderr?: string;
|
|
66
|
+
message?: string;
|
|
67
|
+
};
|
|
68
|
+
const outputLimited = typed.code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER" ||
|
|
69
|
+
typed.message?.toLowerCase().includes("maxbuffer") === true;
|
|
70
|
+
const timedOut = !outputLimited && (typed.code === "ETIMEDOUT" || typed.killed === true || typed.signal === "SIGTERM");
|
|
71
|
+
const result = this.result(
|
|
72
|
+
plan,
|
|
73
|
+
false,
|
|
74
|
+
true,
|
|
75
|
+
typeof typed.code === "number" ? typed.code : timedOut ? 124 : outputLimited ? 1 : 1,
|
|
76
|
+
typed.stdout ?? "",
|
|
77
|
+
typed.stderr ?? typed.message ?? "",
|
|
78
|
+
started,
|
|
79
|
+
timedOut,
|
|
80
|
+
[],
|
|
81
|
+
outputLimited ? "Command output exceeded maxBuffer." : undefined
|
|
82
|
+
);
|
|
83
|
+
this.recordCommandResult(result, timedOut ? "command_timeout" : outputLimited ? "command_output_limit" : "command_failed");
|
|
84
|
+
return result;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
private result(
|
|
89
|
+
plan: CommandPlan,
|
|
90
|
+
dryRun: boolean,
|
|
91
|
+
allowed: boolean,
|
|
92
|
+
exitCode: number,
|
|
93
|
+
stdout: string,
|
|
94
|
+
stderr: string,
|
|
95
|
+
started: number,
|
|
96
|
+
timedOut: boolean,
|
|
97
|
+
artifactIds: string[],
|
|
98
|
+
rejectionReason?: string
|
|
99
|
+
): CommandRunResult {
|
|
100
|
+
return {
|
|
101
|
+
plan,
|
|
102
|
+
dryRun,
|
|
103
|
+
allowed,
|
|
104
|
+
exitCode,
|
|
105
|
+
stdout,
|
|
106
|
+
stderr,
|
|
107
|
+
durationMs: Date.now() - started,
|
|
108
|
+
timedOut,
|
|
109
|
+
artifactIds,
|
|
110
|
+
...(rejectionReason ? { rejectionReason } : {})
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
private recordCommandResult(result: CommandRunResult, kind: string): void {
|
|
115
|
+
const limit = result.plan.outputLimitBytes ?? this.options.config.commandOutputLimitBytes;
|
|
116
|
+
const output = `stdout:\n${result.stdout}\n\nstderr:\n${result.stderr}`;
|
|
117
|
+
const artifactIds: string[] = [...result.artifactIds];
|
|
118
|
+
let stdout = truncate(result.stdout, limit);
|
|
119
|
+
let stderr = truncate(result.stderr, limit);
|
|
120
|
+
if (Buffer.byteLength(output) > limit) {
|
|
121
|
+
const artifact = writeArtifact(
|
|
122
|
+
this.options.repoRoot,
|
|
123
|
+
this.options.storage,
|
|
124
|
+
this.options.runId,
|
|
125
|
+
"command-output",
|
|
126
|
+
`${result.plan.id}.txt`,
|
|
127
|
+
output
|
|
128
|
+
);
|
|
129
|
+
artifactIds.push(artifact.id);
|
|
130
|
+
result.artifactIds.push(artifact.id);
|
|
131
|
+
stdout = truncate(result.stdout, Math.floor(limit / 2));
|
|
132
|
+
stderr = truncate(result.stderr, Math.floor(limit / 2));
|
|
133
|
+
}
|
|
134
|
+
this.options.storage.appendEvent({
|
|
135
|
+
runId: this.options.runId,
|
|
136
|
+
kind,
|
|
137
|
+
message: `${result.plan.file} ${result.plan.args.join(" ")}`.trim(),
|
|
138
|
+
payload: {
|
|
139
|
+
plan: result.plan,
|
|
140
|
+
exitCode: result.exitCode,
|
|
141
|
+
stdout,
|
|
142
|
+
stderr,
|
|
143
|
+
durationMs: result.durationMs,
|
|
144
|
+
timedOut: result.timedOut,
|
|
145
|
+
allowed: result.allowed,
|
|
146
|
+
dryRun: result.dryRun,
|
|
147
|
+
rejectionReason: result.rejectionReason
|
|
148
|
+
},
|
|
149
|
+
artifactIds
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Build a command plan with a stable random id. */
|
|
155
|
+
export function commandPlan(
|
|
156
|
+
file: string,
|
|
157
|
+
args: string[],
|
|
158
|
+
cwd: string,
|
|
159
|
+
purpose: string,
|
|
160
|
+
options: { timeoutMs?: number; outputLimitBytes?: number } = {}
|
|
161
|
+
): CommandPlan {
|
|
162
|
+
return {
|
|
163
|
+
id: randomUUID(),
|
|
164
|
+
file,
|
|
165
|
+
args,
|
|
166
|
+
cwd,
|
|
167
|
+
purpose,
|
|
168
|
+
...options
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Return whether a command plan may execute under PR B policy. */
|
|
173
|
+
export function evaluatePolicy(plan: Pick<CommandPlan, "file" | "args">): {
|
|
174
|
+
allowed: boolean;
|
|
175
|
+
reason?: string;
|
|
176
|
+
} {
|
|
177
|
+
if (matchesDenylist(plan)) {
|
|
178
|
+
return { allowed: false, reason: "Command denied by destructive command policy." };
|
|
179
|
+
}
|
|
180
|
+
if (!matchesAllowlist(plan)) {
|
|
181
|
+
return { allowed: false, reason: "Command is not in the PR B allowlist." };
|
|
182
|
+
}
|
|
183
|
+
return { allowed: true };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function matchesAllowlist(plan: Pick<CommandPlan, "file" | "args">): boolean {
|
|
187
|
+
if ([
|
|
188
|
+
["git", "status", "--short", "--branch"],
|
|
189
|
+
["git", "branch", "--show-current"],
|
|
190
|
+
["git", "rev-parse", "--is-inside-work-tree"],
|
|
191
|
+
["gh", "auth", "status"],
|
|
192
|
+
["codex", "--version"],
|
|
193
|
+
["npx", "gitnexus", "--version"],
|
|
194
|
+
["pnpm", "--version"]
|
|
195
|
+
].some(([file, ...args]) => plan.file === file && sameArgs(plan.args, args))) {
|
|
196
|
+
return true;
|
|
197
|
+
}
|
|
198
|
+
if (plan.file === "git") {
|
|
199
|
+
return matchesGitAllowlist(plan.args);
|
|
200
|
+
}
|
|
201
|
+
if (plan.file === "gh") {
|
|
202
|
+
return matchesGhAllowlist(plan.args);
|
|
203
|
+
}
|
|
204
|
+
if (plan.file === "npx" && plan.args[0] === "gitnexus") {
|
|
205
|
+
return ["status", "analyze", "detect_changes", "impact"].includes(plan.args[1] ?? "");
|
|
206
|
+
}
|
|
207
|
+
if (plan.file === "pnpm") {
|
|
208
|
+
return plan.args.length === 1 && (plan.args[0] === "lint" || plan.args[0] === "test");
|
|
209
|
+
}
|
|
210
|
+
if (plan.file === "npm") {
|
|
211
|
+
return plan.args.length === 2 && plan.args[0] === "run" && (plan.args[1] === "lint" || plan.args[1] === "test");
|
|
212
|
+
}
|
|
213
|
+
if (plan.file === "yarn") {
|
|
214
|
+
return plan.args.length === 1 && (plan.args[0] === "lint" || plan.args[0] === "test");
|
|
215
|
+
}
|
|
216
|
+
if (plan.file === "bun") {
|
|
217
|
+
return plan.args.length === 2 && plan.args[0] === "run" && (plan.args[1] === "lint" || plan.args[1] === "test");
|
|
218
|
+
}
|
|
219
|
+
if (plan.file === "codex") {
|
|
220
|
+
return matchesCodexAllowlist(plan.args);
|
|
221
|
+
}
|
|
222
|
+
return false;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function matchesDenylist(plan: Pick<CommandPlan, "file" | "args">): boolean {
|
|
226
|
+
const args = stripGitGlobalOptions(plan.args);
|
|
227
|
+
if (plan.file === "git") {
|
|
228
|
+
if (args[0] === "reset" && args.includes("--hard")) {
|
|
229
|
+
return true;
|
|
230
|
+
}
|
|
231
|
+
if (args[0] === "clean" && args.some((arg) => /^-.*f/.test(arg))) {
|
|
232
|
+
return true;
|
|
233
|
+
}
|
|
234
|
+
if (args[0] === "rebase") {
|
|
235
|
+
return true;
|
|
236
|
+
}
|
|
237
|
+
if (args[0] === "push" && args.some((arg) => arg === "-f" || arg === "--force" || arg === "--force-with-lease")) {
|
|
238
|
+
return true;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
if (plan.file === "rm") {
|
|
242
|
+
return args.some((arg) => arg.startsWith("-") && arg.includes("r") && arg.includes("f"));
|
|
243
|
+
}
|
|
244
|
+
if (plan.file === "gh" && args[0] === "repo" && args[1] === "delete") {
|
|
245
|
+
return true;
|
|
246
|
+
}
|
|
247
|
+
if (plan.file === "codex") {
|
|
248
|
+
return args.includes("danger-full-access") ||
|
|
249
|
+
args.includes("--dangerously-bypass-approvals-and-sandbox");
|
|
250
|
+
}
|
|
251
|
+
return false;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function matchesCodexAllowlist(args: string[]): boolean {
|
|
255
|
+
const fresh = parseCodexBaseArgs(args);
|
|
256
|
+
if (!fresh) {
|
|
257
|
+
return false;
|
|
258
|
+
}
|
|
259
|
+
const trailing = args.slice(fresh.nextIndex);
|
|
260
|
+
if (trailing.length === 0) {
|
|
261
|
+
return true;
|
|
262
|
+
}
|
|
263
|
+
return trailing.length === 3 &&
|
|
264
|
+
trailing[0] === "resume" &&
|
|
265
|
+
isOptionValue(trailing[1]) &&
|
|
266
|
+
typeof trailing[2] === "string" &&
|
|
267
|
+
trailing[2].length > 0;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function parseCodexBaseArgs(args: string[]): { nextIndex: number } | undefined {
|
|
271
|
+
if (args.length < 10 || args[0] !== "exec") {
|
|
272
|
+
return undefined;
|
|
273
|
+
}
|
|
274
|
+
const cwd = optionValue(args, "-C");
|
|
275
|
+
const sandbox = optionValue(args, "-s");
|
|
276
|
+
const outputSchema = optionValue(args, "--output-schema");
|
|
277
|
+
const outputLastMessage = optionValue(args, "--output-last-message");
|
|
278
|
+
if (!cwd || !outputSchema || !outputLastMessage || sandbox !== "read-only" && sandbox !== "workspace-write") {
|
|
279
|
+
return undefined;
|
|
280
|
+
}
|
|
281
|
+
const expected = [
|
|
282
|
+
"exec",
|
|
283
|
+
"-C",
|
|
284
|
+
cwd,
|
|
285
|
+
"-s",
|
|
286
|
+
sandbox,
|
|
287
|
+
"--json",
|
|
288
|
+
"--output-schema",
|
|
289
|
+
outputSchema,
|
|
290
|
+
"--output-last-message",
|
|
291
|
+
outputLastMessage
|
|
292
|
+
];
|
|
293
|
+
if (!sameArgs(args.slice(0, expected.length), expected)) {
|
|
294
|
+
return undefined;
|
|
295
|
+
}
|
|
296
|
+
const nextIndex = expected.length;
|
|
297
|
+
if (args[nextIndex] === "--ephemeral") {
|
|
298
|
+
return { nextIndex: nextIndex + 1 };
|
|
299
|
+
}
|
|
300
|
+
return { nextIndex };
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function matchesGitAllowlist(args: string[]): boolean {
|
|
304
|
+
if (hasGitWorkingTreeOverride(args)) {
|
|
305
|
+
return false;
|
|
306
|
+
}
|
|
307
|
+
const stripped = stripGitGlobalOptions(args);
|
|
308
|
+
if (stripped[0] === "checkout") {
|
|
309
|
+
return stripped.length === 2 || (stripped.length === 3 && stripped[1] === "-b");
|
|
310
|
+
}
|
|
311
|
+
if (stripped[0] === "pull") {
|
|
312
|
+
return stripped.length === 4 && stripped[1] === "--ff-only" && stripped[2] === "origin";
|
|
313
|
+
}
|
|
314
|
+
if (stripped[0] === "status") {
|
|
315
|
+
return sameArgs(stripped, ["status", "--short"]) ||
|
|
316
|
+
sameArgs(stripped, ["status", "--short", "--branch"]) ||
|
|
317
|
+
sameArgs(stripped, ["status", "--porcelain=v1", "--untracked-files=all"]);
|
|
318
|
+
}
|
|
319
|
+
if (stripped[0] === "branch") {
|
|
320
|
+
return sameArgs(stripped, ["branch", "--show-current"]);
|
|
321
|
+
}
|
|
322
|
+
if (stripped[0] === "rev-parse") {
|
|
323
|
+
return stripped.length === 2 || sameArgs(stripped, ["rev-parse", "--is-inside-work-tree"]) ||
|
|
324
|
+
(stripped.length === 3 && stripped[1] === "--verify");
|
|
325
|
+
}
|
|
326
|
+
if (stripped[0] === "diff") {
|
|
327
|
+
return sameArgs(stripped, ["diff", "--name-only"]) ||
|
|
328
|
+
sameArgs(stripped, ["diff", "--cached", "--quiet"]) ||
|
|
329
|
+
(stripped.length === 3 && stripped[1] === "--name-only");
|
|
330
|
+
}
|
|
331
|
+
if (stripped[0] === "add") {
|
|
332
|
+
return stripped.length >= 3 && stripped[1] === "--";
|
|
333
|
+
}
|
|
334
|
+
if (stripped[0] === "commit") {
|
|
335
|
+
return stripped.length === 3 && stripped[1] === "-m";
|
|
336
|
+
}
|
|
337
|
+
if (stripped[0] === "push") {
|
|
338
|
+
return stripped.length === 4 && stripped[1] === "-u" && stripped[2] === "origin";
|
|
339
|
+
}
|
|
340
|
+
if (stripped[0] === "ls-remote") {
|
|
341
|
+
return stripped.length === 4 && stripped[1] === "--heads" && stripped[2] === "origin";
|
|
342
|
+
}
|
|
343
|
+
return false;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function matchesGhAllowlist(args: string[]): boolean {
|
|
347
|
+
if (sameArgs(args, ["auth", "status"])) {
|
|
348
|
+
return true;
|
|
349
|
+
}
|
|
350
|
+
if (args[0] === "pr" && args[1] === "list") {
|
|
351
|
+
return args.length === 6 && args[2] === "--head" && args[4] === "--json";
|
|
352
|
+
}
|
|
353
|
+
if (args[0] === "pr" && args[1] === "view") {
|
|
354
|
+
return args.length === 5 && args[3] === "--json";
|
|
355
|
+
}
|
|
356
|
+
if (args[0] === "pr" && args[1] === "create") {
|
|
357
|
+
return args.length === 11 &&
|
|
358
|
+
args[2] === "--draft" &&
|
|
359
|
+
args[3] === "--title" &&
|
|
360
|
+
args[5] === "--body" &&
|
|
361
|
+
args[7] === "--head" &&
|
|
362
|
+
args[9] === "--base";
|
|
363
|
+
}
|
|
364
|
+
if (args[0] === "pr" && args[1] === "comment") {
|
|
365
|
+
return args.length === 5 && args[3] === "--body";
|
|
366
|
+
}
|
|
367
|
+
if (args[0] === "pr" && args[1] === "ready") {
|
|
368
|
+
return args.length === 3;
|
|
369
|
+
}
|
|
370
|
+
if (args[0] === "pr" && args[1] === "merge") {
|
|
371
|
+
return args.length === 4 && args[3] === "--merge";
|
|
372
|
+
}
|
|
373
|
+
if (args[0] === "api" && args[1] === "graphql") {
|
|
374
|
+
return args.length === 10 &&
|
|
375
|
+
args[2] === "-f" &&
|
|
376
|
+
startsWith(args[3], "query=") &&
|
|
377
|
+
args[4] === "-F" &&
|
|
378
|
+
startsWith(args[5], "owner=") &&
|
|
379
|
+
args[6] === "-F" &&
|
|
380
|
+
startsWith(args[7], "name=") &&
|
|
381
|
+
args[8] === "-F" &&
|
|
382
|
+
startsWith(args[9], "number=");
|
|
383
|
+
}
|
|
384
|
+
return false;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function startsWith(value: string | undefined, prefix: string): boolean {
|
|
388
|
+
return value?.startsWith(prefix) ?? false;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function optionValue(args: string[], option: string): string | undefined {
|
|
392
|
+
const index = args.indexOf(option);
|
|
393
|
+
const value = index >= 0 ? args[index + 1] : undefined;
|
|
394
|
+
return isOptionValue(value) ? value : undefined;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function isOptionValue(value: string | undefined): value is string {
|
|
398
|
+
return typeof value === "string" && value.length > 0 && !value.startsWith("-");
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function hasGitWorkingTreeOverride(args: string[]): boolean {
|
|
402
|
+
return args.some((arg) => arg === "-C" || arg === "--git-dir" || arg === "--work-tree" ||
|
|
403
|
+
arg.startsWith("--git-dir=") || arg.startsWith("--work-tree="));
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function stripGitGlobalOptions(args: string[]): string[] {
|
|
407
|
+
const result = [...args];
|
|
408
|
+
while (result.length > 0) {
|
|
409
|
+
const first = result[0];
|
|
410
|
+
if (first === "-C" || first === "--git-dir" || first === "--work-tree") {
|
|
411
|
+
result.splice(0, 2);
|
|
412
|
+
continue;
|
|
413
|
+
}
|
|
414
|
+
if (first?.startsWith("--git-dir=") || first?.startsWith("--work-tree=")) {
|
|
415
|
+
result.shift();
|
|
416
|
+
continue;
|
|
417
|
+
}
|
|
418
|
+
break;
|
|
419
|
+
}
|
|
420
|
+
return result;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
function sameArgs(actual: string[], expected: string[]): boolean {
|
|
424
|
+
return actual.length === expected.length && expected.every((arg, index) => actual[index] === arg);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
function truncate(value: string, limit: number): string {
|
|
428
|
+
const buffer = Buffer.from(value);
|
|
429
|
+
if (buffer.byteLength <= limit) {
|
|
430
|
+
return value;
|
|
431
|
+
}
|
|
432
|
+
if (limit <= 0) {
|
|
433
|
+
return "[truncated]";
|
|
434
|
+
}
|
|
435
|
+
return `${utf8Prefix(buffer, limit)}\n[truncated]`;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
function utf8Prefix(buffer: Buffer, limit: number): string {
|
|
439
|
+
let end = Math.min(limit, buffer.byteLength);
|
|
440
|
+
let value = buffer.subarray(0, end).toString("utf8");
|
|
441
|
+
while (end > 0 && value.endsWith("\uFFFD")) {
|
|
442
|
+
end -= 1;
|
|
443
|
+
value = buffer.subarray(0, end).toString("utf8");
|
|
444
|
+
}
|
|
445
|
+
return value;
|
|
446
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
|
|
3
|
+
export interface CommandResult {
|
|
4
|
+
ok: boolean;
|
|
5
|
+
stdout: string;
|
|
6
|
+
stderr: string;
|
|
7
|
+
combined: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Run a local command and capture stdout/stderr without throwing. */
|
|
11
|
+
export function runCommand(file: string, args: string[], cwd: string): CommandResult {
|
|
12
|
+
try {
|
|
13
|
+
const stdout = execFileSync(file, args, {
|
|
14
|
+
cwd,
|
|
15
|
+
encoding: "utf8",
|
|
16
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
17
|
+
}).trim();
|
|
18
|
+
return { ok: true, stdout, stderr: "", combined: stdout };
|
|
19
|
+
} catch (error) {
|
|
20
|
+
const typed = error as { stdout?: Buffer | string; stderr?: Buffer | string; message?: string };
|
|
21
|
+
const stdout = Buffer.isBuffer(typed.stdout)
|
|
22
|
+
? typed.stdout.toString("utf8")
|
|
23
|
+
: (typed.stdout ?? "").toString();
|
|
24
|
+
const stderr = Buffer.isBuffer(typed.stderr)
|
|
25
|
+
? typed.stderr.toString("utf8")
|
|
26
|
+
: (typed.stderr ?? typed.message ?? "").toString();
|
|
27
|
+
return {
|
|
28
|
+
ok: false,
|
|
29
|
+
stdout: stdout.trim(),
|
|
30
|
+
stderr: stderr.trim(),
|
|
31
|
+
combined: `${stdout}\n${stderr}`.trim()
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Redact credentials and owner/repo details from remote URLs before diagnostic output. */
|
|
37
|
+
export function redactRemote(remote: string): string {
|
|
38
|
+
if (remote.includes("github.com")) {
|
|
39
|
+
return "github.com/<owner>/<repo>";
|
|
40
|
+
}
|
|
41
|
+
try {
|
|
42
|
+
const parsed = new URL(remote);
|
|
43
|
+
return `${parsed.protocol}//${parsed.host}/<redacted>`;
|
|
44
|
+
} catch {
|
|
45
|
+
return "<redacted-remote>";
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { existsSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { configPath, loadConfig, statePath, validateConfig } from "./config.js";
|
|
4
|
+
import { AgentLoopError } from "./errors.js";
|
|
5
|
+
import { SqliteAgentLoopStorage } from "./storage.js";
|
|
6
|
+
import type { AgentLoopConfig } from "./types.js";
|
|
7
|
+
|
|
8
|
+
export interface ConfigEditorSnapshot {
|
|
9
|
+
path: string;
|
|
10
|
+
hash: string;
|
|
11
|
+
mtimeMs: number;
|
|
12
|
+
config: AgentLoopConfig;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface ConfigDiffEntry {
|
|
16
|
+
field: string;
|
|
17
|
+
before: unknown;
|
|
18
|
+
after: unknown;
|
|
19
|
+
risk: "low" | "high";
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface SaveConfigInput {
|
|
23
|
+
nextConfig: AgentLoopConfig;
|
|
24
|
+
expectedHash: string;
|
|
25
|
+
note?: string;
|
|
26
|
+
confirmationToken?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Read config with a content hash so dashboard saves cannot silently overwrite edits. */
|
|
30
|
+
export function readConfigForEdit(repoRoot: string): ConfigEditorSnapshot {
|
|
31
|
+
const loaded = loadConfig(repoRoot);
|
|
32
|
+
const raw = readFileSync(loaded.path, "utf8");
|
|
33
|
+
return {
|
|
34
|
+
path: loaded.path,
|
|
35
|
+
hash: sha256(raw),
|
|
36
|
+
mtimeMs: statSync(loaded.path).mtimeMs,
|
|
37
|
+
config: loaded.config
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Compute a stable field-level diff for policy review before save. */
|
|
42
|
+
export function diffConfig(before: AgentLoopConfig, after: AgentLoopConfig): ConfigDiffEntry[] {
|
|
43
|
+
return Object.keys({ ...before, ...after })
|
|
44
|
+
.filter((field) => JSON.stringify(before[field as keyof AgentLoopConfig]) !== JSON.stringify(after[field as keyof AgentLoopConfig]))
|
|
45
|
+
.map((field) => ({
|
|
46
|
+
field,
|
|
47
|
+
before: before[field as keyof AgentLoopConfig],
|
|
48
|
+
after: after[field as keyof AgentLoopConfig],
|
|
49
|
+
risk: highRiskFields.has(field) ? "high" : "low"
|
|
50
|
+
}));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface SaveConfigResult {
|
|
54
|
+
config: AgentLoopConfig;
|
|
55
|
+
diff: ConfigDiffEntry[];
|
|
56
|
+
snapshot: ConfigEditorSnapshot;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Validate and save dashboard config changes with policy notes and audit events. */
|
|
60
|
+
export function saveConfigEdit(repoRoot: string, input: SaveConfigInput): SaveConfigResult {
|
|
61
|
+
const snapshot = readConfigForEdit(repoRoot);
|
|
62
|
+
if (snapshot.hash !== input.expectedHash) {
|
|
63
|
+
throw new AgentLoopError("invalid_config", "Config changed on disk; reload before saving.");
|
|
64
|
+
}
|
|
65
|
+
const config = validateConfig(input.nextConfig);
|
|
66
|
+
const diff = diffConfig(snapshot.config, config);
|
|
67
|
+
assertPolicySaveAllowed(diff, config, input.note, input.confirmationToken);
|
|
68
|
+
writeFileSync(configPath(repoRoot), `${JSON.stringify(config, null, 2)}\n`);
|
|
69
|
+
auditConfigSave(repoRoot, diff, input.note);
|
|
70
|
+
return { config, diff, snapshot: readConfigForEdit(repoRoot) };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function assertPolicySaveAllowed(
|
|
74
|
+
diff: ConfigDiffEntry[],
|
|
75
|
+
config: AgentLoopConfig,
|
|
76
|
+
note: string | undefined,
|
|
77
|
+
confirmationToken: string | undefined
|
|
78
|
+
): void {
|
|
79
|
+
const hasHighRisk = diff.some((entry) => entry.risk === "high");
|
|
80
|
+
if (hasHighRisk && !note?.trim()) {
|
|
81
|
+
throw new AgentLoopError("invalid_config", "High-risk policy changes require an operator note.");
|
|
82
|
+
}
|
|
83
|
+
if (requiresExplicitConfirmation(diff) && confirmationToken?.trim() !== "CONFIRM") {
|
|
84
|
+
throw new AgentLoopError("invalid_config", "Dangerous policy changes require confirmation token CONFIRM.");
|
|
85
|
+
}
|
|
86
|
+
if (!config.gitnexusRequired && !note?.trim()) {
|
|
87
|
+
throw new AgentLoopError("invalid_config", "Disabling GitNexus required needs a note.");
|
|
88
|
+
}
|
|
89
|
+
if (config.reviewHandling === "fix_scoped_and_carry_forward" && !config.carryoverTarget?.trim()) {
|
|
90
|
+
throw new AgentLoopError("invalid_config", "Carryover review handling requires a carryover target.");
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function requiresExplicitConfirmation(diff: ConfigDiffEntry[]): boolean {
|
|
95
|
+
return diff.some((entry) =>
|
|
96
|
+
(entry.field === "mergeMode" && entry.after === "conditional") ||
|
|
97
|
+
(entry.field === "requireReviewApproval" && entry.after === false)
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function auditConfigSave(repoRoot: string, diff: ConfigDiffEntry[], note: string | undefined): void {
|
|
102
|
+
if (!existsSync(statePath(repoRoot))) {
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
const storage = new SqliteAgentLoopStorage(statePath(repoRoot));
|
|
106
|
+
try {
|
|
107
|
+
storage.writeRepoConfig(loadConfig(repoRoot).config);
|
|
108
|
+
const run = storage.getCurrentRun();
|
|
109
|
+
const message = `Dashboard config changed ${diff.length} field(s).`;
|
|
110
|
+
storage.appendEvent({
|
|
111
|
+
...(run ? { runId: run.id } : {}),
|
|
112
|
+
kind: "config_changed",
|
|
113
|
+
message,
|
|
114
|
+
payload: { diff, note: note ?? "" }
|
|
115
|
+
});
|
|
116
|
+
if (run) {
|
|
117
|
+
storage.appendDecision({
|
|
118
|
+
runId: run.id,
|
|
119
|
+
kind: "config_changed",
|
|
120
|
+
message,
|
|
121
|
+
details: { diff, note: note ?? "" }
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
} finally {
|
|
125
|
+
storage.close();
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function sha256(value: string): string {
|
|
130
|
+
return createHash("sha256").update(value).digest("hex");
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const highRiskFields = new Set([
|
|
134
|
+
"mergeMode",
|
|
135
|
+
"requireReviewApproval",
|
|
136
|
+
"gitnexusRequired",
|
|
137
|
+
"protectedPaths",
|
|
138
|
+
"reviewHandling",
|
|
139
|
+
"carryoverTarget"
|
|
140
|
+
]);
|