@ashlr/hub 2.2.0 → 3.0.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/CHANGELOG.md +55 -0
- package/README.md +59 -2
- package/dist/api/plugin.d.ts +1 -0
- package/dist/cli/backlog.js +2 -0
- package/dist/cli/backlog.js.map +1 -1
- package/dist/cli/digest.d.ts +6 -0
- package/dist/cli/digest.js +127 -2
- package/dist/cli/digest.js.map +1 -1
- package/dist/cli/eval-fixtures.d.ts +18 -0
- package/dist/cli/eval-fixtures.js +38 -0
- package/dist/cli/eval-fixtures.js.map +1 -0
- package/dist/cli/eval.d.ts +56 -0
- package/dist/cli/eval.js +283 -0
- package/dist/cli/eval.js.map +1 -0
- package/dist/cli/fleet.d.ts +27 -0
- package/dist/cli/fleet.js +222 -0
- package/dist/cli/fleet.js.map +1 -0
- package/dist/cli/goal.d.ts +15 -0
- package/dist/cli/goal.js +91 -0
- package/dist/cli/goal.js.map +1 -0
- package/dist/cli/help.js +10 -3
- package/dist/cli/help.js.map +1 -1
- package/dist/cli/inbox.d.ts +6 -0
- package/dist/cli/inbox.js +183 -8
- package/dist/cli/inbox.js.map +1 -1
- package/dist/cli/index.js +42 -0
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/loop.d.ts +16 -0
- package/dist/cli/loop.js +66 -0
- package/dist/cli/loop.js.map +1 -0
- package/dist/cli/mcp.d.ts +12 -0
- package/dist/cli/mcp.js +148 -0
- package/dist/cli/mcp.js.map +1 -1
- package/dist/cli/onboard.d.ts +16 -0
- package/dist/cli/onboard.js +71 -0
- package/dist/cli/onboard.js.map +1 -1
- package/dist/cli/plugins.js +9 -8
- package/dist/cli/plugins.js.map +1 -1
- package/dist/cli/pulse.d.ts +10 -0
- package/dist/cli/pulse.js +261 -0
- package/dist/cli/pulse.js.map +1 -1
- package/dist/cli/run.js +21 -3
- package/dist/cli/run.js.map +1 -1
- package/dist/cli/stack.d.ts +34 -0
- package/dist/cli/stack.js +221 -0
- package/dist/cli/stack.js.map +1 -0
- package/dist/cli/update.js +12 -5
- package/dist/cli/update.js.map +1 -1
- package/dist/core/daemon/loop.d.ts +10 -5
- package/dist/core/daemon/loop.js +167 -37
- package/dist/core/daemon/loop.js.map +1 -1
- package/dist/core/env-bridge.js +3 -2
- package/dist/core/env-bridge.js.map +1 -1
- package/dist/core/fleet/automerge-pass.d.ts +33 -0
- package/dist/core/fleet/automerge-pass.js +61 -0
- package/dist/core/fleet/automerge-pass.js.map +1 -0
- package/dist/core/fleet/quota.d.ts +70 -0
- package/dist/core/fleet/quota.js +198 -0
- package/dist/core/fleet/quota.js.map +1 -0
- package/dist/core/fleet/router.d.ts +46 -0
- package/dist/core/fleet/router.js +121 -0
- package/dist/core/fleet/router.js.map +1 -0
- package/dist/core/fleet/self.d.ts +58 -0
- package/dist/core/fleet/self.js +187 -0
- package/dist/core/fleet/self.js.map +1 -0
- package/dist/core/fleet/status.d.ts +54 -0
- package/dist/core/fleet/status.js +157 -0
- package/dist/core/fleet/status.js.map +1 -0
- package/dist/core/foundry/provenance.d.ts +78 -0
- package/dist/core/foundry/provenance.js +172 -0
- package/dist/core/foundry/provenance.js.map +1 -0
- package/dist/core/git.d.ts +13 -0
- package/dist/core/git.js +26 -0
- package/dist/core/git.js.map +1 -1
- package/dist/core/inbox/merge.d.ts +151 -0
- package/dist/core/inbox/merge.js +858 -0
- package/dist/core/inbox/merge.js.map +1 -0
- package/dist/core/inbox/store.js +10 -1
- package/dist/core/inbox/store.js.map +1 -1
- package/dist/core/integrations/markdown.d.ts +66 -0
- package/dist/core/integrations/markdown.js +132 -0
- package/dist/core/integrations/markdown.js.map +1 -0
- package/dist/core/integrations/secrets.d.ts +37 -0
- package/dist/core/integrations/secrets.js +67 -0
- package/dist/core/integrations/secrets.js.map +1 -0
- package/dist/core/integrations/stack.d.ts +47 -0
- package/dist/core/integrations/stack.js +107 -0
- package/dist/core/integrations/stack.js.map +1 -0
- package/dist/core/mcp-native-engineer.d.ts +123 -0
- package/dist/core/mcp-native-engineer.js +936 -0
- package/dist/core/mcp-native-engineer.js.map +1 -0
- package/dist/core/mcp-native.js +20 -42
- package/dist/core/mcp-native.js.map +1 -1
- package/dist/core/mcp-registry.js +2 -0
- package/dist/core/mcp-registry.js.map +1 -1
- package/dist/core/observability/codex-source.d.ts +53 -0
- package/dist/core/observability/codex-source.js +390 -0
- package/dist/core/observability/codex-source.js.map +1 -0
- package/dist/core/observability/estimate.d.ts +12 -0
- package/dist/core/observability/estimate.js +26 -0
- package/dist/core/observability/estimate.js.map +1 -1
- package/dist/core/observability/limits.d.ts +52 -0
- package/dist/core/observability/limits.js +316 -0
- package/dist/core/observability/limits.js.map +1 -0
- package/dist/core/observability/usage-source.js +3 -1
- package/dist/core/observability/usage-source.js.map +1 -1
- package/dist/core/portfolio/scanners.d.ts +1 -0
- package/dist/core/portfolio/scanners.js +40 -1
- package/dist/core/portfolio/scanners.js.map +1 -1
- package/dist/core/providers.js +4 -0
- package/dist/core/providers.js.map +1 -1
- package/dist/core/quality/health.js +1 -0
- package/dist/core/quality/health.js.map +1 -1
- package/dist/core/run/agent-loop.d.ts +8 -0
- package/dist/core/run/agent-loop.js +24 -14
- package/dist/core/run/agent-loop.js.map +1 -1
- package/dist/core/run/engine-registry.d.ts +48 -0
- package/dist/core/run/engine-registry.js +190 -0
- package/dist/core/run/engine-registry.js.map +1 -0
- package/dist/core/run/engines.d.ts +17 -3
- package/dist/core/run/engines.js +63 -44
- package/dist/core/run/engines.js.map +1 -1
- package/dist/core/run/learned-router.d.ts +121 -0
- package/dist/core/run/learned-router.js +374 -0
- package/dist/core/run/learned-router.js.map +1 -0
- package/dist/core/run/model-manager.js +3 -3
- package/dist/core/run/model-manager.js.map +1 -1
- package/dist/core/run/model-profile.d.ts +67 -0
- package/dist/core/run/model-profile.js +132 -0
- package/dist/core/run/model-profile.js.map +1 -0
- package/dist/core/run/orchestrator.d.ts +1 -1
- package/dist/core/run/orchestrator.js +642 -510
- package/dist/core/run/orchestrator.js.map +1 -1
- package/dist/core/run/prompts/budget.d.ts +25 -0
- package/dist/core/run/prompts/budget.js +51 -0
- package/dist/core/run/prompts/budget.js.map +1 -0
- package/dist/core/run/prompts/index.d.ts +14 -0
- package/dist/core/run/prompts/index.js +59 -0
- package/dist/core/run/prompts/index.js.map +1 -0
- package/dist/core/run/prompts/layers.d.ts +26 -0
- package/dist/core/run/prompts/layers.js +73 -0
- package/dist/core/run/prompts/layers.js.map +1 -0
- package/dist/core/run/prompts/roles.d.ts +23 -0
- package/dist/core/run/prompts/roles.js +48 -0
- package/dist/core/run/prompts/roles.js.map +1 -0
- package/dist/core/run/prompts/types.d.ts +38 -0
- package/dist/core/run/prompts/types.js +5 -0
- package/dist/core/run/prompts/types.js.map +1 -0
- package/dist/core/run/provider-client.d.ts +6 -24
- package/dist/core/run/provider-client.js +125 -61
- package/dist/core/run/provider-client.js.map +1 -1
- package/dist/core/run/router.js +8 -0
- package/dist/core/run/router.js.map +1 -1
- package/dist/core/run/sandboxed-engine.d.ts +66 -0
- package/dist/core/run/sandboxed-engine.js +262 -0
- package/dist/core/run/sandboxed-engine.js.map +1 -0
- package/dist/core/run/verify-commands.d.ts +83 -0
- package/dist/core/run/verify-commands.js +234 -0
- package/dist/core/run/verify-commands.js.map +1 -0
- package/dist/core/run/verify.d.ts +31 -1
- package/dist/core/run/verify.js +48 -0
- package/dist/core/run/verify.js.map +1 -1
- package/dist/core/sandbox/audit.d.ts +27 -0
- package/dist/core/sandbox/audit.js +34 -0
- package/dist/core/sandbox/audit.js.map +1 -1
- package/dist/core/sandbox/confine.d.ts +134 -0
- package/dist/core/sandbox/confine.js +370 -0
- package/dist/core/sandbox/confine.js.map +1 -0
- package/dist/core/types.d.ts +227 -5
- package/dist/core/web/api.js +77 -28
- package/dist/core/web/api.js.map +1 -1
- package/dist/core/web/control.d.ts +116 -0
- package/dist/core/web/control.js +307 -0
- package/dist/core/web/control.js.map +1 -0
- package/dist/core/web/public/app.js +512 -6
- package/dist/core/web/public/styles.css +468 -0
- package/package.json +1 -1
|
@@ -0,0 +1,858 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* core/inbox/merge.ts — M47: tiered-trust merge-to-main gate.
|
|
3
|
+
*
|
|
4
|
+
* ╔══════════════════════════════════════════════════════════════════════════╗
|
|
5
|
+
* ║ SECURITY MODEL — autonomous merge to the DEFAULT BRANCH (main). ║
|
|
6
|
+
* ║ ║
|
|
7
|
+
* ║ This is the ONLY module that may advance a proposal's diff onto the ║
|
|
8
|
+
* ║ repository's default branch WITHOUT an interactive human approve. Because ║
|
|
9
|
+
* ║ that is the highest-trust outward action in the system, autoMergeProposal ║
|
|
10
|
+
* ║ runs a STRICT chain of gates and refuses (mutating nothing) the instant ║
|
|
11
|
+
* ║ any gate fails. Gates, in order: ║
|
|
12
|
+
* ║ ║
|
|
13
|
+
* ║ 1. cfg.foundry.autoMerge.enabled === true (DEFAULT DISABLED). ║
|
|
14
|
+
* ║ 2. proposal exists, kind ∈ {patch,pr}, non-empty diff. ║
|
|
15
|
+
* ║ 3. kill switch OFF + assertMayMutate(repo) (enrollment). On refusal we ║
|
|
16
|
+
* ║ DO NOT burn proposal state — it stays approvable/pending. ║
|
|
17
|
+
* ║ 4. evaluateMergeAuthority: engineTier === 'frontier' AND {engine,model} ║
|
|
18
|
+
* ║ ∈ cfg.foundry.mergeAuthority. A ':default' engineModel (no concrete ║
|
|
19
|
+
* ║ model) is ALWAYS rejected — only pinned, vetted models may merge. ║
|
|
20
|
+
* ║ 5. classifyRisk(proposal) ≤ cfg.foundry.autoMerge.maxRisk (default low). ║
|
|
21
|
+
* ║ 6. verifyProposal: apply the diff to an ISOLATED temp worktree off the ║
|
|
22
|
+
* ║ default branch and run EVERY detected verify command — ALL must pass. ║
|
|
23
|
+
* ║ Verify commands are detected from the BASE tree BEFORE the diff is ║
|
|
24
|
+
* ║ applied, so a diff CANNOT rewrite which commands run (H1a). A diff ║
|
|
25
|
+
* ║ touching any build/CI/manifest path (package.json, lockfiles, ║
|
|
26
|
+
* ║ .github/*, Dockerfile, CI configs, .npmrc, Makefile, …) is REFUSED ║
|
|
27
|
+
* ║ outright — regardless of allowWithoutVerification (H1b). ║
|
|
28
|
+
* ║ No commands detected ⇒ fail-closed unless allowWithoutVerification. ║
|
|
29
|
+
* ║ ║
|
|
30
|
+
* ║ Only after ALL gates pass do we mutate. Mutation paths: ║
|
|
31
|
+
* ║ - REMOTE (preferred): open a PR to the default branch and best-effort ║
|
|
32
|
+
* ║ `gh pr merge --squash --admin`. Server-side branch protection / CI ║
|
|
33
|
+
* ║ remain the outer safety net. gh failures NEVER throw. ║
|
|
34
|
+
* ║ - LOCAL (fallback): merge the branch into the default branch via a ║
|
|
35
|
+
* ║ dedicated temp worktree (`git merge --no-ff`). We REFUSE the local ║
|
|
36
|
+
* ║ path when the default branch is the one checked out in the user's ║
|
|
37
|
+
* ║ working tree (would race their index/HEAD) — the branch is left for a ║
|
|
38
|
+
* ║ manual merge. ║
|
|
39
|
+
* ║ ║
|
|
40
|
+
* ║ RESIDUAL RISK (documented): the LOCAL merge path has no code-review or CI ║
|
|
41
|
+
* ║ gate beyond our own verifyProposal — it is the conservative fallback and ║
|
|
42
|
+
* ║ should be enabled only on trusted, enrolled repos. The REMOTE/PR path is ║
|
|
43
|
+
* ║ STRONGLY preferred because it inherits the host's branch protection and ║
|
|
44
|
+
* ║ required-checks. The filesystem is not jailed; verify commands run real ║
|
|
45
|
+
* ║ project scripts (npm/tsc) — only enable auto-merge where that is safe. ║
|
|
46
|
+
* ║ ║
|
|
47
|
+
* ║ PROVENANCE TRUST (H3, residual — tracked follow-up M47.1): the merge ║
|
|
48
|
+
* ║ authority gate (Gate 4) trusts proposal.engineTier / proposal.engineModel ║
|
|
49
|
+
* ║ as read from the on-disk proposal record. The agent MCP surface ║
|
|
50
|
+
* ║ (ashlr_inbox_propose) CANNOT set these fields (verified — they are stamped ║
|
|
51
|
+
* ║ server-side, not from agent input), so a remote agent cannot self-elevate. ║
|
|
52
|
+
* ║ A LOCAL / in-process writer with filesystem access to ~/.ashlr/inbox, ║
|
|
53
|
+
* ║ however, COULD forge a record claiming frontier authority. Full mitigation ║
|
|
54
|
+
* ║ is HMAC-signed provenance binding the {tier,model} to a hash of the diff ║
|
|
55
|
+
* ║ (so a forged record cannot be paired with an arbitrary diff); it is a ║
|
|
56
|
+
* ║ tracked follow-up (M47.1) and intentionally NOT implemented here. The ║
|
|
57
|
+
* ║ residual is bounded by the DEFAULT-DISABLED posture: auto-merge is off ║
|
|
58
|
+
* ║ unless an operator explicitly enables it on an enrolled, trusted repo. ║
|
|
59
|
+
* ║ ║
|
|
60
|
+
* ║ INVARIANTS: every public fn here NEVER throws; every refusal mutates ║
|
|
61
|
+
* ║ nothing (no branch, no status change); every outcome is audited; no ║
|
|
62
|
+
* ║ secrets are logged. Node builtins only. ║
|
|
63
|
+
* ╚══════════════════════════════════════════════════════════════════════════╝
|
|
64
|
+
*/
|
|
65
|
+
import { execFileSync } from 'node:child_process';
|
|
66
|
+
import { existsSync, mkdirSync, writeFileSync, unlinkSync } from 'node:fs';
|
|
67
|
+
import { homedir } from 'node:os';
|
|
68
|
+
import { join } from 'node:path';
|
|
69
|
+
import { randomBytes } from 'node:crypto';
|
|
70
|
+
import { loadProposal, setStatus } from './store.js';
|
|
71
|
+
import { assertMayMutate, killSwitchOn } from '../sandbox/policy.js';
|
|
72
|
+
import { audit } from '../sandbox/audit.js';
|
|
73
|
+
import { isRepo, getGitStatus, getRemoteOrg, defaultBranch } from '../git.js';
|
|
74
|
+
import { createPr } from '../integrations/github.js';
|
|
75
|
+
import { scrubSecrets } from '../knowledge/index.js';
|
|
76
|
+
import { isSelfTargetProposal, guardSafetyTests } from '../fleet/self.js';
|
|
77
|
+
import { verifyProvenance } from '../foundry/provenance.js';
|
|
78
|
+
import { detectVerifyCommands, runVerifyCommand, } from '../run/verify-commands.js';
|
|
79
|
+
// ---------------------------------------------------------------------------
|
|
80
|
+
// Constants
|
|
81
|
+
// ---------------------------------------------------------------------------
|
|
82
|
+
/** Branch prefix for auto-merge staging branches. NEVER touch user branches. */
|
|
83
|
+
const MERGE_BRANCH_PREFIX = 'ashlr/merge/';
|
|
84
|
+
/** Timeout for git operations (ms). */
|
|
85
|
+
const GIT_TIMEOUT = 30_000;
|
|
86
|
+
// ---------------------------------------------------------------------------
|
|
87
|
+
// git helpers — arg arrays, no shell
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
/** Run a git command in `cwd`. Throws on failure. */
|
|
90
|
+
function gitRun(cwd, args) {
|
|
91
|
+
return execFileSync('git', args, {
|
|
92
|
+
cwd,
|
|
93
|
+
timeout: GIT_TIMEOUT,
|
|
94
|
+
stdio: 'pipe',
|
|
95
|
+
encoding: 'utf8',
|
|
96
|
+
}).trim();
|
|
97
|
+
}
|
|
98
|
+
/** Run a git command, returning null on any error (never throws). */
|
|
99
|
+
function gitTry(cwd, args) {
|
|
100
|
+
try {
|
|
101
|
+
return gitRun(cwd, args);
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
/** Write `content` to a temp file under ~/.ashlr/tmp; caller cleans it up. */
|
|
108
|
+
function writeTmpFile(content) {
|
|
109
|
+
const dir = join(homedir(), '.ashlr', 'tmp');
|
|
110
|
+
if (!existsSync(dir))
|
|
111
|
+
mkdirSync(dir, { recursive: true });
|
|
112
|
+
const p = join(dir, `merge-${randomBytes(6).toString('hex')}.diff`);
|
|
113
|
+
writeFileSync(p, content, 'utf8');
|
|
114
|
+
return p;
|
|
115
|
+
}
|
|
116
|
+
/** Total ordering for risk comparison: low < medium < high. */
|
|
117
|
+
const RISK_ORDER = { low: 0, medium: 1, high: 2 };
|
|
118
|
+
/**
|
|
119
|
+
* Extract the changed file paths from a unified diff by reading every
|
|
120
|
+
* `+++ b/<path>` header. Strips the `b/` prefix and ignores `/dev/null`
|
|
121
|
+
* (deletions). Returns an empty array when no headers are present.
|
|
122
|
+
*/
|
|
123
|
+
function changedFilesFromDiff(diff) {
|
|
124
|
+
const files = [];
|
|
125
|
+
for (const line of diff.split('\n')) {
|
|
126
|
+
if (!line.startsWith('+++ '))
|
|
127
|
+
continue;
|
|
128
|
+
let p = line.slice(4).trim();
|
|
129
|
+
// Strip a trailing tab-timestamp some tools emit: "+++ b/x\t2026-..."
|
|
130
|
+
const tab = p.indexOf('\t');
|
|
131
|
+
if (tab >= 0)
|
|
132
|
+
p = p.slice(0, tab);
|
|
133
|
+
if (p === '/dev/null')
|
|
134
|
+
continue;
|
|
135
|
+
if (p.startsWith('b/'))
|
|
136
|
+
p = p.slice(2);
|
|
137
|
+
if (p.startsWith('a/'))
|
|
138
|
+
p = p.slice(2);
|
|
139
|
+
if (p)
|
|
140
|
+
files.push(p);
|
|
141
|
+
}
|
|
142
|
+
return files;
|
|
143
|
+
}
|
|
144
|
+
/** Lowercase basename of a path (no directory). */
|
|
145
|
+
function baseName(p) {
|
|
146
|
+
const slash = p.lastIndexOf('/');
|
|
147
|
+
return (slash >= 0 ? p.slice(slash + 1) : p).toLowerCase();
|
|
148
|
+
}
|
|
149
|
+
/** True when the path is documentation-only (markdown / LICENSE). */
|
|
150
|
+
function isDocFile(p) {
|
|
151
|
+
const b = baseName(p);
|
|
152
|
+
return b.endsWith('.md') || b === 'license' || b.startsWith('license.');
|
|
153
|
+
}
|
|
154
|
+
/** True when the path is a lockfile or pure dependency manifest lock. */
|
|
155
|
+
function isLockFile(p) {
|
|
156
|
+
const b = baseName(p);
|
|
157
|
+
return (b === 'package-lock.json' ||
|
|
158
|
+
b === 'pnpm-lock.yaml' ||
|
|
159
|
+
b === 'yarn.lock' ||
|
|
160
|
+
b === 'bun.lockb' ||
|
|
161
|
+
b.endsWith('.lock'));
|
|
162
|
+
}
|
|
163
|
+
/** True when the path lives in a test directory or is a test/spec file. */
|
|
164
|
+
function isTestFile(p) {
|
|
165
|
+
const lower = p.toLowerCase();
|
|
166
|
+
if (lower.startsWith('test/') ||
|
|
167
|
+
lower.startsWith('tests/') ||
|
|
168
|
+
lower.includes('/test/') ||
|
|
169
|
+
lower.includes('/tests/') ||
|
|
170
|
+
lower.includes('/__tests__/')) {
|
|
171
|
+
return true;
|
|
172
|
+
}
|
|
173
|
+
return /\.(test|spec)\.[cm]?[jt]sx?$/.test(lower);
|
|
174
|
+
}
|
|
175
|
+
/** True for config-ish files (json/yaml/toml/ini + .github/*). */
|
|
176
|
+
function isConfigFile(p) {
|
|
177
|
+
const lower = p.toLowerCase();
|
|
178
|
+
if (lower.startsWith('.github/'))
|
|
179
|
+
return true;
|
|
180
|
+
const b = baseName(p);
|
|
181
|
+
return (b.endsWith('.json') ||
|
|
182
|
+
b.endsWith('.yml') ||
|
|
183
|
+
b.endsWith('.yaml') ||
|
|
184
|
+
b.endsWith('.toml') ||
|
|
185
|
+
b.endsWith('.ini') ||
|
|
186
|
+
b.endsWith('.cfg') ||
|
|
187
|
+
b.endsWith('.conf'));
|
|
188
|
+
}
|
|
189
|
+
/** True for executable source code (the high-trust surface). */
|
|
190
|
+
function isSourceFile(p) {
|
|
191
|
+
const lower = p.toLowerCase();
|
|
192
|
+
return /\.(ts|tsx|js|jsx|cjs|mjs|py|rb|go|rs|java|kt|c|h|cc|cpp|hpp|cs|php|swift|sh|bash)$/.test(lower);
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* True when the path is a build / CI / dependency-manifest file — the class of
|
|
196
|
+
* files that can change WHICH verify commands run or execute arbitrary code on
|
|
197
|
+
* the host (npm lifecycle scripts, CI pipelines, container builds). These are
|
|
198
|
+
* unconditionally HIGH risk (H2) and are REFUSED outright by verifyProposal
|
|
199
|
+
* (H1b) because no in-process verify can be trusted once they change.
|
|
200
|
+
*/
|
|
201
|
+
function isBuildOrCiOrManifest(p) {
|
|
202
|
+
const lower = p.toLowerCase();
|
|
203
|
+
const b = baseName(p);
|
|
204
|
+
// Any path under .github/ (workflows, actions, dependabot, …).
|
|
205
|
+
if (lower.startsWith('.github/'))
|
|
206
|
+
return true;
|
|
207
|
+
// Any path under .circleci/.
|
|
208
|
+
if (lower.startsWith('.circleci/'))
|
|
209
|
+
return true;
|
|
210
|
+
// Top-level CI / manifest / tooling configs.
|
|
211
|
+
if (b === 'package.json' ||
|
|
212
|
+
b === 'package-lock.json' ||
|
|
213
|
+
b === 'pnpm-lock.yaml' ||
|
|
214
|
+
b === 'yarn.lock' ||
|
|
215
|
+
b === 'bun.lockb' ||
|
|
216
|
+
b === '.gitlab-ci.yml' ||
|
|
217
|
+
b === '.npmrc' ||
|
|
218
|
+
b === '.yarnrc' ||
|
|
219
|
+
b === 'makefile' ||
|
|
220
|
+
b === 'dockerfile') {
|
|
221
|
+
return true;
|
|
222
|
+
}
|
|
223
|
+
// *.Dockerfile (e.g. app.Dockerfile, prod.dockerfile).
|
|
224
|
+
if (b.endsWith('.dockerfile'))
|
|
225
|
+
return true;
|
|
226
|
+
return false;
|
|
227
|
+
}
|
|
228
|
+
/** True when the path looks like an auth / security-sensitive surface. */
|
|
229
|
+
function isSecuritySensitive(p) {
|
|
230
|
+
const lower = p.toLowerCase();
|
|
231
|
+
return (/(^|\/)(auth|authn|authz|security|secret|secrets|credential|credentials|crypto|token|password|policy|sandbox|permission|permissions|rbac|acl|login|session)(s)?([./_-]|$)/.test(lower) || /\.(pem|key|p12|pfx)$/.test(lower));
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* Classify the merge risk of a proposal from its diff + kind. PURE.
|
|
235
|
+
*
|
|
236
|
+
* low — ONLY docs (*.md / LICENSE), lockfiles (*.lock / package-lock.json),
|
|
237
|
+
* and/or test-only files.
|
|
238
|
+
* medium — config files (*.json/*.yml/*.toml/.github/*) and/or small source
|
|
239
|
+
* changes (a single source file with a modest diff).
|
|
240
|
+
* high — any source change touching an auth/security path, a large diff
|
|
241
|
+
* (many files or many changed lines), or anything we can't classify
|
|
242
|
+
* as low/medium. Empty / unparsable diffs are treated as HIGH
|
|
243
|
+
* (fail-safe — an unknown change is the most dangerous kind).
|
|
244
|
+
*/
|
|
245
|
+
export function classifyRisk(proposal) {
|
|
246
|
+
const diff = proposal.diff ?? '';
|
|
247
|
+
const files = changedFilesFromDiff(diff);
|
|
248
|
+
// An empty or unparsable diff is the most dangerous: fail to HIGH.
|
|
249
|
+
if (files.length === 0)
|
|
250
|
+
return 'high';
|
|
251
|
+
// ── H2: dangerous-file classes are unconditionally HIGH (checked FIRST, ──────
|
|
252
|
+
// before any medium/low logic could under-rate them) ────────────────────────
|
|
253
|
+
// - build / CI / dependency-manifest files (package.json, lockfiles,
|
|
254
|
+
// .github/*, Dockerfile, .gitlab-ci.yml, .circleci/*, .npmrc/.yarnrc,
|
|
255
|
+
// Makefile) — can change which verify commands run or exec host code.
|
|
256
|
+
// NOTE: lockfiles were previously classified LOW; they are now HIGH.
|
|
257
|
+
// - shell scripts (*.sh / *.bash) — run arbitrary host code.
|
|
258
|
+
// - any auth / secret / credential / security surface.
|
|
259
|
+
const isShellScript = (p) => /\.(sh|bash)$/.test(p.toLowerCase());
|
|
260
|
+
if (files.some(isBuildOrCiOrManifest) ||
|
|
261
|
+
files.some(isShellScript) ||
|
|
262
|
+
files.some(isSecuritySensitive)) {
|
|
263
|
+
return 'high';
|
|
264
|
+
}
|
|
265
|
+
// Count added/removed body lines (exclude the +++/--- headers) to gauge size.
|
|
266
|
+
let changedLines = 0;
|
|
267
|
+
for (const line of diff.split('\n')) {
|
|
268
|
+
if (line.startsWith('+++') || line.startsWith('---'))
|
|
269
|
+
continue;
|
|
270
|
+
if (line.startsWith('+') || line.startsWith('-'))
|
|
271
|
+
changedLines++;
|
|
272
|
+
}
|
|
273
|
+
const LARGE_FILES = 10;
|
|
274
|
+
const LARGE_LINES = 400;
|
|
275
|
+
if (files.length > LARGE_FILES || changedLines > LARGE_LINES)
|
|
276
|
+
return 'high';
|
|
277
|
+
const sourceFiles = files.filter(isSourceFile);
|
|
278
|
+
// ── LOW: every file is docs / lockfile / test ──────────────────────────────
|
|
279
|
+
const allLowTrust = files.every((f) => isDocFile(f) || isLockFile(f) || isTestFile(f));
|
|
280
|
+
if (allLowTrust)
|
|
281
|
+
return 'low';
|
|
282
|
+
// ── HIGH: any non-trivial source change ────────────────────────────────────
|
|
283
|
+
// A single small source file is allowed at MEDIUM; more than one source file,
|
|
284
|
+
// or a sizeable single-file change, escalates to HIGH.
|
|
285
|
+
if (sourceFiles.length > 0) {
|
|
286
|
+
const SMALL_SRC_LINES = 40;
|
|
287
|
+
if (sourceFiles.length === 1 && changedLines <= SMALL_SRC_LINES) {
|
|
288
|
+
return 'medium';
|
|
289
|
+
}
|
|
290
|
+
return 'high';
|
|
291
|
+
}
|
|
292
|
+
// ── At this point there are NO source files and the diff is not purely
|
|
293
|
+
// low-trust (docs/lock/test). It is config and/or unknown text files. ────────
|
|
294
|
+
// - Every file recognised as config / doc / lock / test → MEDIUM.
|
|
295
|
+
// - Any UNrecognised file (not source, not config, not low-trust — e.g. a
|
|
296
|
+
// binary asset or an unknown dotfile) → HIGH (fail-safe: we can't reason
|
|
297
|
+
// about it, so we don't auto-merge it at low/medium trust).
|
|
298
|
+
const allRecognised = files.every((f) => isConfigFile(f) || isDocFile(f) || isLockFile(f) || isTestFile(f));
|
|
299
|
+
return allRecognised ? 'medium' : 'high';
|
|
300
|
+
}
|
|
301
|
+
/**
|
|
302
|
+
* Decide whether a proposal carries frontier merge-authority. PURE.
|
|
303
|
+
*
|
|
304
|
+
* Authorized ONLY when BOTH hold:
|
|
305
|
+
* - proposal.engineTier === 'frontier'; AND
|
|
306
|
+
* - cfg.foundry.mergeAuthority contains an entry whose reconstructed
|
|
307
|
+
* `${engine}:${model}` exactly equals proposal.engineModel.
|
|
308
|
+
*
|
|
309
|
+
* REJECTS when:
|
|
310
|
+
* - engineModel is missing, or ends in ':default' (no concrete model pinned);
|
|
311
|
+
* - there is no mergeAuthority config / no matching entry;
|
|
312
|
+
* - the tier is not 'frontier'.
|
|
313
|
+
*/
|
|
314
|
+
export function evaluateMergeAuthority(proposal, cfg) {
|
|
315
|
+
if (proposal.engineTier !== 'frontier') {
|
|
316
|
+
// M51 tri-tier: authority never leaks upward. 'mid' (strong open models) is
|
|
317
|
+
// branch-eligible but NEVER merge-authority for main; 'local' is
|
|
318
|
+
// proposal-only. Only 'frontier' may reach main.
|
|
319
|
+
const tier = proposal.engineTier ?? 'unset';
|
|
320
|
+
const note = tier === 'mid'
|
|
321
|
+
? " — 'mid' is branch-eligible but never merge-authority for main"
|
|
322
|
+
: '';
|
|
323
|
+
return {
|
|
324
|
+
authorized: false,
|
|
325
|
+
reason: `engineTier is '${tier}', not 'frontier' — only frontier backends carry merge authority${note}`,
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
const engineModel = proposal.engineModel;
|
|
329
|
+
if (!engineModel) {
|
|
330
|
+
return {
|
|
331
|
+
authorized: false,
|
|
332
|
+
reason: 'proposal has no engineModel — cannot verify merge authority',
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
if (engineModel.endsWith(':default')) {
|
|
336
|
+
return {
|
|
337
|
+
authorized: false,
|
|
338
|
+
reason: `engineModel '${engineModel}' has no concrete model pinned (':default') — merge authority requires a vetted model`,
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
const authority = cfg.foundry?.mergeAuthority;
|
|
342
|
+
if (!authority || authority.length === 0) {
|
|
343
|
+
return {
|
|
344
|
+
authorized: false,
|
|
345
|
+
reason: 'cfg.foundry.mergeAuthority is empty — no backend is authorized to merge',
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
const match = authority.some((e) => `${e.engine}:${e.model}` === engineModel);
|
|
349
|
+
if (!match) {
|
|
350
|
+
return {
|
|
351
|
+
authorized: false,
|
|
352
|
+
reason: `engineModel '${engineModel}' is not in cfg.foundry.mergeAuthority`,
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
return {
|
|
356
|
+
authorized: true,
|
|
357
|
+
reason: `frontier backend '${engineModel}' is authorized to merge`,
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
export function mergeTargetForTier(tier) {
|
|
361
|
+
if (tier === 'frontier')
|
|
362
|
+
return 'main';
|
|
363
|
+
if (tier === 'mid')
|
|
364
|
+
return 'branch';
|
|
365
|
+
return 'none';
|
|
366
|
+
}
|
|
367
|
+
/**
|
|
368
|
+
* M56: decide whether a MID-tier proposal may auto-apply to a BRANCH (never
|
|
369
|
+
* main). PURE. Authorized ONLY when ALL hold:
|
|
370
|
+
* - cfg.foundry.autoMerge.midToBranch === true (a separate, DEFAULT-OFF flag,
|
|
371
|
+
* so enabling main auto-merge does not implicitly enable the branch path); AND
|
|
372
|
+
* - mergeTargetForTier(engineTier) === 'branch' (i.e. engineTier === 'mid'); AND
|
|
373
|
+
* - engineModel is a concrete, vetted model (present, not ':default').
|
|
374
|
+
* Grants BRANCH authority only — a mid proposal can never reach main regardless
|
|
375
|
+
* of cfg.foundry.mergeAuthority contents.
|
|
376
|
+
*/
|
|
377
|
+
export function evaluateBranchAuthority(proposal, cfg) {
|
|
378
|
+
if (cfg.foundry?.autoMerge?.midToBranch !== true) {
|
|
379
|
+
return {
|
|
380
|
+
authorized: false,
|
|
381
|
+
reason: 'mid→branch auto-apply is disabled (cfg.foundry.autoMerge.midToBranch !== true)',
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
if (mergeTargetForTier(proposal.engineTier) !== 'branch') {
|
|
385
|
+
return {
|
|
386
|
+
authorized: false,
|
|
387
|
+
reason: `engineTier '${proposal.engineTier ?? 'unset'}' is not branch-eligible (only 'mid' is)`,
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
const engineModel = proposal.engineModel;
|
|
391
|
+
if (!engineModel || engineModel.endsWith(':default')) {
|
|
392
|
+
return {
|
|
393
|
+
authorized: false,
|
|
394
|
+
reason: `engineModel '${engineModel ?? 'unset'}' has no concrete model pinned`,
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
return { authorized: true, reason: `mid backend '${engineModel}' may auto-apply to a branch (never main)` };
|
|
398
|
+
}
|
|
399
|
+
// ===========================================================================
|
|
400
|
+
// 3) defaultBranch — re-exported from git.ts (see contract)
|
|
401
|
+
// ===========================================================================
|
|
402
|
+
// `defaultBranch` is imported above (from ../git.js) and re-exported here so the
|
|
403
|
+
// M47 surface is self-contained.
|
|
404
|
+
export { defaultBranch };
|
|
405
|
+
/**
|
|
406
|
+
* Apply the proposal's diff to a THROWAWAY temp worktree branched off the
|
|
407
|
+
* default branch's head, then run every detected verify command (typecheck /
|
|
408
|
+
* test / lint). ALL must pass for ok:true. The worktree is always removed.
|
|
409
|
+
*
|
|
410
|
+
* Fail-closed on detection: when NO verify commands are found, returns ok:false
|
|
411
|
+
* UNLESS cfg.foundry.autoMerge.allowWithoutVerification === true.
|
|
412
|
+
*
|
|
413
|
+
* NEVER throws — any error resolves to ok:false with a detail.
|
|
414
|
+
*/
|
|
415
|
+
export async function verifyProposal(proposal, cfg) {
|
|
416
|
+
const repo = proposal.repo;
|
|
417
|
+
if (!repo || !isRepo(repo)) {
|
|
418
|
+
return { ok: false, ran: [], detail: `not a git repository: ${repo ?? '(none)'}` };
|
|
419
|
+
}
|
|
420
|
+
const diff = proposal.diff ?? '';
|
|
421
|
+
if (!diff.trim()) {
|
|
422
|
+
return { ok: false, ran: [], detail: 'proposal has no diff to verify' };
|
|
423
|
+
}
|
|
424
|
+
// ── M54: self-improvement may never self-disarm ─────────────────────────────
|
|
425
|
+
// When a proposal targets ashlr-hub's OWN source, REFUSE before any
|
|
426
|
+
// verification if its diff would delete or weaken a safety/invariant test.
|
|
427
|
+
// The guard runs FIRST so a weakening diff never even reaches the verify
|
|
428
|
+
// worktree. (The full self-eval — suite green flag-off AND flag-on — is
|
|
429
|
+
// enforced by the gated auto-merge pass via fleet/self.selfEvalParity.)
|
|
430
|
+
if (isSelfTargetProposal(proposal, cfg)) {
|
|
431
|
+
const guard = guardSafetyTests(diff);
|
|
432
|
+
if (guard.weakened) {
|
|
433
|
+
return { ok: false, ran: [], detail: `self-target guard: ${guard.reason}` };
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
// ── H1b: manifest / build / CI guard — REFUSE regardless of ──────────────────
|
|
437
|
+
// allowWithoutVerification. A diff that touches package.json, a lockfile,
|
|
438
|
+
// .github/*, a Dockerfile, CI configs, .npmrc/.yarnrc, or a Makefile can change
|
|
439
|
+
// which commands run (or run arbitrary host code via npm lifecycle / CI), so no
|
|
440
|
+
// in-process verification can be trusted — it must go to manual review.
|
|
441
|
+
const changed = changedFilesFromDiff(diff);
|
|
442
|
+
if (changed.some(isBuildOrCiOrManifest)) {
|
|
443
|
+
return {
|
|
444
|
+
ok: false,
|
|
445
|
+
ran: [],
|
|
446
|
+
detail: 'diff touches build/CI/manifest files — manual review required',
|
|
447
|
+
};
|
|
448
|
+
}
|
|
449
|
+
const base = defaultBranch(repo);
|
|
450
|
+
// Resolve the head commit of the default branch WITHOUT touching the user tree.
|
|
451
|
+
const baseHead = gitTry(repo, ['rev-parse', '--verify', `refs/heads/${base}`]) ??
|
|
452
|
+
gitTry(repo, ['rev-parse', '--verify', `refs/remotes/origin/${base}`]) ??
|
|
453
|
+
gitTry(repo, ['rev-parse', 'HEAD']);
|
|
454
|
+
if (!baseHead) {
|
|
455
|
+
return { ok: false, ran: [], detail: `could not resolve head of default branch '${base}'` };
|
|
456
|
+
}
|
|
457
|
+
const tmpBranch = `ashlr/verify/${randomBytes(6).toString('hex')}`;
|
|
458
|
+
const tmpDir = join(homedir(), '.ashlr', 'tmp', `vwt-${randomBytes(6).toString('hex')}`);
|
|
459
|
+
// Create the isolated worktree on a scratch branch off the default-branch head.
|
|
460
|
+
try {
|
|
461
|
+
gitRun(repo, ['worktree', 'add', '-b', tmpBranch, tmpDir, baseHead]);
|
|
462
|
+
}
|
|
463
|
+
catch (err) {
|
|
464
|
+
gitTry(repo, ['worktree', 'prune']);
|
|
465
|
+
return {
|
|
466
|
+
ok: false,
|
|
467
|
+
ran: [],
|
|
468
|
+
detail: `git worktree add failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
469
|
+
};
|
|
470
|
+
}
|
|
471
|
+
let patchFile = null;
|
|
472
|
+
const ran = [];
|
|
473
|
+
try {
|
|
474
|
+
// ── H1a: detect verify commands from the BASE tree BEFORE applying the ─────
|
|
475
|
+
// diff, so the diff CANNOT rewrite which commands run (e.g. a diff that sets
|
|
476
|
+
// package.json scripts.test to "true" can no longer self-certify). The
|
|
477
|
+
// manifest guard above already refuses package.json/lockfile/CI changes, but
|
|
478
|
+
// base-tree detection is the defense-in-depth that makes self-certification
|
|
479
|
+
// structurally impossible regardless of which files the diff touches.
|
|
480
|
+
const commands = detectVerifyCommands(tmpDir);
|
|
481
|
+
if (commands.length === 0) {
|
|
482
|
+
const allow = cfg.foundry?.autoMerge?.allowWithoutVerification === true;
|
|
483
|
+
return {
|
|
484
|
+
ok: allow,
|
|
485
|
+
ran: [],
|
|
486
|
+
detail: allow
|
|
487
|
+
? 'no verify commands detected; allowWithoutVerification=true → passing'
|
|
488
|
+
: 'no verify commands detected and allowWithoutVerification=false → fail-closed',
|
|
489
|
+
};
|
|
490
|
+
}
|
|
491
|
+
// Apply the diff to the worktree ONLY AFTER capturing the base-derived
|
|
492
|
+
// command list, then run those (immutable) commands against the patched tree.
|
|
493
|
+
patchFile = writeTmpFile(diff);
|
|
494
|
+
try {
|
|
495
|
+
gitRun(tmpDir, ['apply', '--index', patchFile]);
|
|
496
|
+
}
|
|
497
|
+
catch (err) {
|
|
498
|
+
return {
|
|
499
|
+
ok: false,
|
|
500
|
+
ran: [],
|
|
501
|
+
detail: `git apply failed in verify worktree: ${err instanceof Error ? err.message : String(err)}`,
|
|
502
|
+
};
|
|
503
|
+
}
|
|
504
|
+
for (const vc of commands) {
|
|
505
|
+
ran.push(vc);
|
|
506
|
+
const res = runVerifyCommand(vc, tmpDir, cfg);
|
|
507
|
+
if (!res.ok) {
|
|
508
|
+
return {
|
|
509
|
+
ok: false,
|
|
510
|
+
ran,
|
|
511
|
+
detail: `verify '${vc.kind}' failed (exit ${res.exitCode}): ${res.command}`,
|
|
512
|
+
};
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
return {
|
|
516
|
+
ok: true,
|
|
517
|
+
ran,
|
|
518
|
+
detail: `all ${ran.length} verify command(s) passed: ${ran.map((c) => c.kind).join(', ')}`,
|
|
519
|
+
};
|
|
520
|
+
}
|
|
521
|
+
catch (err) {
|
|
522
|
+
return {
|
|
523
|
+
ok: false,
|
|
524
|
+
ran,
|
|
525
|
+
detail: `verifyProposal error: ${err instanceof Error ? err.message : String(err)}`,
|
|
526
|
+
};
|
|
527
|
+
}
|
|
528
|
+
finally {
|
|
529
|
+
if (patchFile) {
|
|
530
|
+
try {
|
|
531
|
+
unlinkSync(patchFile);
|
|
532
|
+
}
|
|
533
|
+
catch {
|
|
534
|
+
/* ignore */
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
gitTry(repo, ['worktree', 'remove', '--force', tmpDir]);
|
|
538
|
+
gitTry(repo, ['worktree', 'prune']);
|
|
539
|
+
gitTry(repo, ['branch', '-D', tmpBranch]);
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
/** Build the staging branch holding the proposal's diff, off the default branch.
|
|
543
|
+
* Returns the branch name on success, or null + detail on failure. */
|
|
544
|
+
function buildMergeBranch(repo, id, diff, base) {
|
|
545
|
+
const branch = `${MERGE_BRANCH_PREFIX}${id}`;
|
|
546
|
+
const baseHead = gitTry(repo, ['rev-parse', '--verify', `refs/heads/${base}`]) ??
|
|
547
|
+
gitTry(repo, ['rev-parse', '--verify', `refs/remotes/origin/${base}`]) ??
|
|
548
|
+
gitTry(repo, ['rev-parse', 'HEAD']);
|
|
549
|
+
if (!baseHead) {
|
|
550
|
+
return { branch: null, detail: `could not resolve head of default branch '${base}'` };
|
|
551
|
+
}
|
|
552
|
+
const tmpDir = join(homedir(), '.ashlr', 'tmp', `mwt-${randomBytes(6).toString('hex')}`);
|
|
553
|
+
try {
|
|
554
|
+
gitRun(repo, ['worktree', 'add', '-b', branch, tmpDir, baseHead]);
|
|
555
|
+
}
|
|
556
|
+
catch (err) {
|
|
557
|
+
gitTry(repo, ['worktree', 'prune']);
|
|
558
|
+
return {
|
|
559
|
+
branch: null,
|
|
560
|
+
detail: `git worktree add failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
561
|
+
};
|
|
562
|
+
}
|
|
563
|
+
let patchFile = null;
|
|
564
|
+
let detail = '';
|
|
565
|
+
let ok = false;
|
|
566
|
+
try {
|
|
567
|
+
patchFile = writeTmpFile(diff);
|
|
568
|
+
try {
|
|
569
|
+
gitRun(tmpDir, ['apply', '--index', patchFile]);
|
|
570
|
+
gitRun(tmpDir, ['commit', '--no-verify', '-m', `ashlr: auto-merge proposal ${id}`]);
|
|
571
|
+
ok = true;
|
|
572
|
+
detail = `staged on ${branch}`;
|
|
573
|
+
}
|
|
574
|
+
catch (err) {
|
|
575
|
+
detail = `apply/commit failed: ${err instanceof Error ? err.message : String(err)}`;
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
finally {
|
|
579
|
+
if (patchFile) {
|
|
580
|
+
try {
|
|
581
|
+
unlinkSync(patchFile);
|
|
582
|
+
}
|
|
583
|
+
catch {
|
|
584
|
+
/* ignore */
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
gitTry(repo, ['worktree', 'remove', '--force', tmpDir]);
|
|
588
|
+
gitTry(repo, ['worktree', 'prune']);
|
|
589
|
+
if (!ok && branch.startsWith(MERGE_BRANCH_PREFIX)) {
|
|
590
|
+
gitTry(repo, ['branch', '-D', branch]);
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
return ok ? { branch, detail } : { branch: null, detail };
|
|
594
|
+
}
|
|
595
|
+
/** Merge `branch` into the default branch locally via a dedicated worktree.
|
|
596
|
+
* REFUSES when the default branch is checked out in the user's main tree. */
|
|
597
|
+
function mergeLocally(repo, branch, base) {
|
|
598
|
+
// Guard: never operate on the default branch if it is the user's checked-out
|
|
599
|
+
// working tree — a worktree add of an already-checked-out branch would fail,
|
|
600
|
+
// and merging into it would race their index/HEAD.
|
|
601
|
+
const status = getGitStatus(repo);
|
|
602
|
+
if (status && status.branch === base) {
|
|
603
|
+
return {
|
|
604
|
+
ok: false,
|
|
605
|
+
detail: `default branch '${base}' is checked out in the working tree — refusing local merge; branch '${branch}' left for manual merge`,
|
|
606
|
+
};
|
|
607
|
+
}
|
|
608
|
+
// H6: also refuse a DETACHED HEAD sitting on the base commit. getGitStatus
|
|
609
|
+
// reports branch === 'HEAD' when detached; if that detached HEAD is the same
|
|
610
|
+
// commit as the default branch, merging into base would advance a ref the user
|
|
611
|
+
// is actively standing on (same race as the checked-out-branch case above).
|
|
612
|
+
if (status && status.branch === 'HEAD') {
|
|
613
|
+
const headSha = gitTry(repo, ['rev-parse', 'HEAD']);
|
|
614
|
+
const baseSha = gitTry(repo, ['rev-parse', '--verify', `refs/heads/${base}`]) ??
|
|
615
|
+
gitTry(repo, ['rev-parse', base]);
|
|
616
|
+
if (headSha && baseSha && headSha === baseSha) {
|
|
617
|
+
return {
|
|
618
|
+
ok: false,
|
|
619
|
+
detail: `repo is in detached HEAD on the default branch commit (${base} @ ${headSha.slice(0, 8)}) — refusing local merge; branch '${branch}' left for manual merge`,
|
|
620
|
+
};
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
const tmpDir = join(homedir(), '.ashlr', 'tmp', `mergewt-${randomBytes(6).toString('hex')}`);
|
|
624
|
+
// Check out the default branch into a dedicated worktree so we never touch the
|
|
625
|
+
// user's tree, merge the staging branch, then the worktree's branch ref (base)
|
|
626
|
+
// advances in the shared object store.
|
|
627
|
+
try {
|
|
628
|
+
gitRun(repo, ['worktree', 'add', tmpDir, base]);
|
|
629
|
+
}
|
|
630
|
+
catch (err) {
|
|
631
|
+
gitTry(repo, ['worktree', 'prune']);
|
|
632
|
+
return {
|
|
633
|
+
ok: false,
|
|
634
|
+
detail: `git worktree add for '${base}' failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
635
|
+
};
|
|
636
|
+
}
|
|
637
|
+
let ok = false;
|
|
638
|
+
let detail = '';
|
|
639
|
+
try {
|
|
640
|
+
gitRun(tmpDir, ['merge', '--no-ff', '--no-edit', branch]);
|
|
641
|
+
const head = gitTry(tmpDir, ['rev-parse', 'HEAD']);
|
|
642
|
+
ok = true;
|
|
643
|
+
detail = `merged '${branch}' into '${base}'${head ? ` @ ${head.slice(0, 8)}` : ''} (local, not pushed)`;
|
|
644
|
+
}
|
|
645
|
+
catch (err) {
|
|
646
|
+
detail = `git merge --no-ff failed: ${err instanceof Error ? err.message : String(err)}`;
|
|
647
|
+
// Abort any partial merge so the default branch is left clean.
|
|
648
|
+
gitTry(tmpDir, ['merge', '--abort']);
|
|
649
|
+
}
|
|
650
|
+
finally {
|
|
651
|
+
gitTry(repo, ['worktree', 'remove', '--force', tmpDir]);
|
|
652
|
+
gitTry(repo, ['worktree', 'prune']);
|
|
653
|
+
}
|
|
654
|
+
return { ok, detail };
|
|
655
|
+
}
|
|
656
|
+
/**
|
|
657
|
+
* Autonomously merge an approved frontier proposal to the default branch,
|
|
658
|
+
* subject to the full gate chain documented in the module header. NEVER throws;
|
|
659
|
+
* every refusal returns { ok:false, merged:false, reason } and mutates nothing.
|
|
660
|
+
*/
|
|
661
|
+
export async function autoMergeProposal(id, cfg) {
|
|
662
|
+
const refuse = (reason, repo = null) => {
|
|
663
|
+
audit({
|
|
664
|
+
action: 'inbox:auto-merge',
|
|
665
|
+
repo,
|
|
666
|
+
sandboxId: id,
|
|
667
|
+
summary: `refused: ${reason}`,
|
|
668
|
+
result: 'refused',
|
|
669
|
+
});
|
|
670
|
+
return { ok: false, merged: false, reason };
|
|
671
|
+
};
|
|
672
|
+
try {
|
|
673
|
+
// ── Gate 1: feature must be enabled (DEFAULT DISABLED) ───────────────────
|
|
674
|
+
if (cfg.foundry?.autoMerge?.enabled !== true) {
|
|
675
|
+
return refuse('auto-merge disabled (cfg.foundry.autoMerge.enabled !== true)');
|
|
676
|
+
}
|
|
677
|
+
// ── Gate 2: proposal must exist, be a mergeable kind with a diff ─────────
|
|
678
|
+
const proposal = loadProposal(id);
|
|
679
|
+
if (!proposal)
|
|
680
|
+
return refuse(`proposal not found: ${id}`);
|
|
681
|
+
if (proposal.kind !== 'patch' && proposal.kind !== 'pr') {
|
|
682
|
+
return refuse(`proposal kind '${proposal.kind}' is not mergeable (need patch|pr)`, proposal.repo);
|
|
683
|
+
}
|
|
684
|
+
const diff = proposal.diff ?? '';
|
|
685
|
+
if (!diff.trim()) {
|
|
686
|
+
return refuse('proposal has no diff to merge', proposal.repo);
|
|
687
|
+
}
|
|
688
|
+
const repo = proposal.repo;
|
|
689
|
+
if (!repo)
|
|
690
|
+
return refuse('proposal has no repo');
|
|
691
|
+
// ── Gate 3: kill switch + enrollment (DO NOT burn state on refusal) ──────
|
|
692
|
+
if (killSwitchOn()) {
|
|
693
|
+
return refuse('autonomy kill switch is ON', repo);
|
|
694
|
+
}
|
|
695
|
+
try {
|
|
696
|
+
assertMayMutate(repo);
|
|
697
|
+
}
|
|
698
|
+
catch (err) {
|
|
699
|
+
return refuse(err instanceof Error ? err.message : String(err), repo);
|
|
700
|
+
}
|
|
701
|
+
// ── Gate 4: frontier merge authority ─────────────────────────────────────
|
|
702
|
+
// M51/M56: frontier → main (evaluateMergeAuthority); mid → branch/PR ONLY
|
|
703
|
+
// (evaluateBranchAuthority, a separate default-off flag); local → proposal-only.
|
|
704
|
+
const target = mergeTargetForTier(proposal.engineTier);
|
|
705
|
+
const toMain = target === 'main';
|
|
706
|
+
const authority = target === 'main'
|
|
707
|
+
? evaluateMergeAuthority(proposal, cfg)
|
|
708
|
+
: target === 'branch'
|
|
709
|
+
? evaluateBranchAuthority(proposal, cfg)
|
|
710
|
+
: { authorized: false, reason: `engineTier '${proposal.engineTier ?? 'unset'}' is proposal-only (local)` };
|
|
711
|
+
if (!authority.authorized) {
|
|
712
|
+
return refuse(`merge authority denied: ${authority.reason}`, repo);
|
|
713
|
+
}
|
|
714
|
+
// Gate 4.5 (H3 / M47.1): signed provenance. The authority gate above trusts
|
|
715
|
+
// engineTier/engineModel as read from the on-disk record; a local writer
|
|
716
|
+
// could forge those fields. Re-verify the HMAC binding {engineModel,
|
|
717
|
+
// engineTier, diffHash} (signed at producer time by the sandboxed engine
|
|
718
|
+
// with the host-local key) and FAIL CLOSED on any mismatch — so a forged
|
|
719
|
+
// record cannot claim frontier merge-authority.
|
|
720
|
+
const provenance = verifyProvenance(proposal);
|
|
721
|
+
if (!provenance.ok) {
|
|
722
|
+
return refuse(`provenance check failed: ${provenance.reason}`, repo);
|
|
723
|
+
}
|
|
724
|
+
// ── Gate 5: risk class ≤ maxRisk ────────────────────────────────────────
|
|
725
|
+
const maxRisk = cfg.foundry?.autoMerge?.maxRisk ?? 'low';
|
|
726
|
+
const risk = classifyRisk(proposal);
|
|
727
|
+
if (RISK_ORDER[risk] > RISK_ORDER[maxRisk]) {
|
|
728
|
+
return refuse(`risk class '${risk}' exceeds maxRisk '${maxRisk}'`, repo);
|
|
729
|
+
}
|
|
730
|
+
// ── Gate 6: full verification in an isolated worktree ────────────────────
|
|
731
|
+
const verify = await verifyProposal(proposal, cfg);
|
|
732
|
+
if (!verify.ok) {
|
|
733
|
+
return refuse(`verification failed: ${verify.detail}`, repo);
|
|
734
|
+
}
|
|
735
|
+
// ── ACTION: stage the diff on a branch off the default branch ────────────
|
|
736
|
+
const base = defaultBranch(repo);
|
|
737
|
+
const staged = buildMergeBranch(repo, id, diff, base);
|
|
738
|
+
if (!staged.branch) {
|
|
739
|
+
return refuse(`could not stage merge branch: ${staged.detail}`, repo);
|
|
740
|
+
}
|
|
741
|
+
const branch = staged.branch;
|
|
742
|
+
// Decide the merge path: REMOTE (PR) when configured AND a github remote
|
|
743
|
+
// exists; otherwise LOCAL.
|
|
744
|
+
const wantRemote = cfg.foundry?.autoMerge?.pushToRemote === true;
|
|
745
|
+
const hasGithub = getRemoteOrg(repo).org !== null;
|
|
746
|
+
let merged = false;
|
|
747
|
+
let branchApplied = false; // M56: mid-tier applied to a branch/PR (never main)
|
|
748
|
+
let reason = '';
|
|
749
|
+
let prUrl;
|
|
750
|
+
if (wantRemote && hasGithub) {
|
|
751
|
+
// H4: PUSH the staging branch to origin BEFORE opening the PR. createPr
|
|
752
|
+
// points the PR head at `branch`, which the host cannot see unless we push
|
|
753
|
+
// it first. Wrapped never-throw + audited; if the push fails we DO NOT
|
|
754
|
+
// claim merged — we refuse so no PR is opened against a missing head.
|
|
755
|
+
const pushed = gitTry(repo, ['push', '-u', 'origin', branch]) !== null;
|
|
756
|
+
audit({
|
|
757
|
+
action: 'inbox:auto-merge',
|
|
758
|
+
repo,
|
|
759
|
+
sandboxId: id,
|
|
760
|
+
summary: `push staging branch '${branch}' to origin: ${pushed ? 'ok' : 'failed'}`,
|
|
761
|
+
result: pushed ? 'ok' : 'error',
|
|
762
|
+
});
|
|
763
|
+
if (!pushed) {
|
|
764
|
+
return {
|
|
765
|
+
ok: false,
|
|
766
|
+
merged: false,
|
|
767
|
+
reason: 'failed to push staging branch',
|
|
768
|
+
};
|
|
769
|
+
}
|
|
770
|
+
// H5: scrub secret-shaped tokens from the PR title/body before they leave
|
|
771
|
+
// the host, and cap length (title ≤120, body ≤4000) to avoid leaking or
|
|
772
|
+
// bloating the outward PR payload.
|
|
773
|
+
const safeTitle = scrubSecrets(`ashlr auto-merge: ${proposal.title}`).slice(0, 120);
|
|
774
|
+
const safeBody = scrubSecrets(`${proposal.summary}\n\n` +
|
|
775
|
+
`Auto-merged by ashlr M47 (risk=${risk}, ${authority.reason}). ` +
|
|
776
|
+
`Verified: ${verify.detail}.`).slice(0, 4000);
|
|
777
|
+
// PREFERRED path — open a PR to the default branch; branch protection / CI
|
|
778
|
+
// on the host remain the outer safety net.
|
|
779
|
+
const pr = await createPr(repo, {
|
|
780
|
+
title: safeTitle,
|
|
781
|
+
body: safeBody,
|
|
782
|
+
base,
|
|
783
|
+
head: branch,
|
|
784
|
+
});
|
|
785
|
+
if (!pr.ok) {
|
|
786
|
+
reason = `staged on ${branch} but PR creation failed: ${pr.detail}`;
|
|
787
|
+
}
|
|
788
|
+
else {
|
|
789
|
+
prUrl = pr.url ?? undefined;
|
|
790
|
+
// Best-effort admin squash-merge. NEVER throw — branch protection /
|
|
791
|
+
// missing perms simply leave the PR open for a human.
|
|
792
|
+
// M56: only a frontier (toMain) proposal is ever squash-merged to main.
|
|
793
|
+
// A mid-tier proposal opens a PR and STOPS — a human merges it.
|
|
794
|
+
let mergeNote = toMain ? 'PR opened' : 'PR opened for review (mid-tier — never merged to main)';
|
|
795
|
+
if (toMain && prUrl) {
|
|
796
|
+
try {
|
|
797
|
+
execFileSync('gh', ['pr', 'merge', '--squash', '--admin', prUrl], {
|
|
798
|
+
cwd: repo,
|
|
799
|
+
timeout: GIT_TIMEOUT,
|
|
800
|
+
stdio: 'pipe',
|
|
801
|
+
encoding: 'utf8',
|
|
802
|
+
env: { ...process.env, GH_NO_UPDATE_NOTIFIER: '1', NO_COLOR: '1' },
|
|
803
|
+
});
|
|
804
|
+
merged = true;
|
|
805
|
+
mergeNote = 'PR opened and squash-merged';
|
|
806
|
+
}
|
|
807
|
+
catch {
|
|
808
|
+
mergeNote = 'PR opened (auto-merge deferred to host gates)';
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
else if (!toMain && prUrl) {
|
|
812
|
+
branchApplied = true;
|
|
813
|
+
}
|
|
814
|
+
reason = `${mergeNote}${prUrl ? `: ${prUrl}` : ''}`;
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
else if (toMain) {
|
|
818
|
+
// LOCAL fallback — conservative; refuses if default branch is checked out.
|
|
819
|
+
const local = mergeLocally(repo, branch, base);
|
|
820
|
+
merged = local.ok;
|
|
821
|
+
reason = local.detail;
|
|
822
|
+
}
|
|
823
|
+
else {
|
|
824
|
+
// M56: mid-tier with no PR host — leave the staged branch for review;
|
|
825
|
+
// NEVER merge to main locally.
|
|
826
|
+
branchApplied = true;
|
|
827
|
+
reason = `staged branch for review (mid-tier — never merged to main): ${branch}`;
|
|
828
|
+
}
|
|
829
|
+
// Persist outcome on success only (a non-merge leaves the proposal as-is so
|
|
830
|
+
// it can be retried or hand-merged; the staging branch remains for review).
|
|
831
|
+
// M56: success is a main-merge OR a mid-tier branch/PR application. Either
|
|
832
|
+
// marks the proposal 'applied' so the pass does not re-open a PR every tick.
|
|
833
|
+
const success = merged || branchApplied;
|
|
834
|
+
if (success) {
|
|
835
|
+
setStatus(id, 'applied', reason);
|
|
836
|
+
}
|
|
837
|
+
const result = {
|
|
838
|
+
ok: success,
|
|
839
|
+
merged,
|
|
840
|
+
...(branchApplied ? { branched: true } : {}),
|
|
841
|
+
reason,
|
|
842
|
+
...(prUrl ? { prUrl } : {}),
|
|
843
|
+
};
|
|
844
|
+
audit({
|
|
845
|
+
action: 'inbox:auto-merge',
|
|
846
|
+
repo,
|
|
847
|
+
sandboxId: id,
|
|
848
|
+
summary: `proposal ${id} auto-merge ${merged ? 'MERGED' : branchApplied ? 'BRANCHED (PR)' : 'not merged'}: ${reason}`,
|
|
849
|
+
result: success ? 'ok' : 'error',
|
|
850
|
+
});
|
|
851
|
+
return result;
|
|
852
|
+
}
|
|
853
|
+
catch (err) {
|
|
854
|
+
// Belt-and-suspenders: the orchestrator must never throw out.
|
|
855
|
+
return refuse(`unexpected error: ${err instanceof Error ? err.message : String(err)}`);
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
//# sourceMappingURL=merge.js.map
|