@lannguyensi/harness 0.41.0 → 0.42.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 +31 -0
- package/README.md +9 -0
- package/dist/cli/adopt/derive.d.ts +15 -0
- package/dist/cli/adopt/derive.js +34 -7
- package/dist/cli/adopt/derive.js.map +1 -1
- package/dist/cli/adopt/index.d.ts +30 -0
- package/dist/cli/adopt/index.js +23 -3
- package/dist/cli/adopt/index.js.map +1 -1
- package/dist/cli/apply/generate-settings.d.ts +14 -1
- package/dist/cli/apply/generate-settings.js +7 -3
- package/dist/cli/apply/generate-settings.js.map +1 -1
- package/dist/cli/approve/stdin-report.js +2 -0
- package/dist/cli/approve/stdin-report.js.map +1 -1
- package/dist/cli/doctor/claude-mcp.d.ts +50 -0
- package/dist/cli/doctor/claude-mcp.js +170 -0
- package/dist/cli/doctor/claude-mcp.js.map +1 -0
- package/dist/cli/doctor/format.js +37 -0
- package/dist/cli/doctor/format.js.map +1 -1
- package/dist/cli/doctor/index.d.ts +10 -0
- package/dist/cli/doctor/index.js +24 -0
- package/dist/cli/doctor/index.js.map +1 -1
- package/dist/cli/doctor/npm-bin-path.js +42 -0
- package/dist/cli/doctor/npm-bin-path.js.map +1 -1
- package/dist/cli/doctor/types.d.ts +11 -0
- package/dist/cli/doctor/types.js.map +1 -1
- package/dist/cli/index.js +253 -5
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/init/agent-tasks-auth.js +32 -0
- package/dist/cli/init/agent-tasks-auth.js.map +1 -1
- package/dist/cli/init/composer.js +3 -9
- package/dist/cli/init/composer.js.map +1 -1
- package/dist/cli/init/dependencies.js +21 -0
- package/dist/cli/init/dependencies.js.map +1 -1
- package/dist/cli/init/detect.d.ts +20 -3
- package/dist/cli/init/detect.js +34 -36
- package/dist/cli/init/detect.js.map +1 -1
- package/dist/cli/init/interactive.d.ts +33 -0
- package/dist/cli/init/interactive.js +399 -5
- package/dist/cli/init/interactive.js.map +1 -1
- package/dist/cli/init/profiles.d.ts +1 -1
- package/dist/cli/init/profiles.js +1 -1
- package/dist/cli/init/templates.d.ts +1 -1
- package/dist/cli/init/templates.js +64 -5
- package/dist/cli/init/templates.js.map +1 -1
- package/dist/cli/main.js +10 -0
- package/dist/cli/main.js.map +1 -1
- package/dist/cli/pack/hook-post-merge-gate-record.d.ts +30 -0
- package/dist/cli/pack/hook-post-merge-gate-record.js +153 -0
- package/dist/cli/pack/hook-post-merge-gate-record.js.map +1 -0
- package/dist/cli/pack/hook-post-merge-gate.d.ts +28 -0
- package/dist/cli/pack/hook-post-merge-gate.js +244 -0
- package/dist/cli/pack/hook-post-merge-gate.js.map +1 -0
- package/dist/cli/pack/hook-solution-acceptance-writeguard.js +148 -2
- package/dist/cli/pack/hook-solution-acceptance-writeguard.js.map +1 -1
- package/dist/cli/pack/understanding-report-schema-hint.js +1 -1
- package/dist/cli/pack/understanding-report-schema-hint.js.map +1 -1
- package/dist/cli/record/index.d.ts +64 -0
- package/dist/cli/record/index.js +211 -0
- package/dist/cli/record/index.js.map +1 -0
- package/dist/cli/session-start/index.d.ts +7 -0
- package/dist/cli/session-start/index.js +133 -38
- package/dist/cli/session-start/index.js.map +1 -1
- package/dist/cli/session-start/toolchain-parity.d.ts +149 -0
- package/dist/cli/session-start/toolchain-parity.js +618 -0
- package/dist/cli/session-start/toolchain-parity.js.map +1 -0
- package/dist/cli/uninstall/index.d.ts +73 -1
- package/dist/cli/uninstall/index.js +134 -2
- package/dist/cli/uninstall/index.js.map +1 -1
- package/dist/io/claude-mcp.d.ts +258 -0
- package/dist/io/claude-mcp.js +544 -0
- package/dist/io/claude-mcp.js.map +1 -0
- package/dist/policies/ledger-client.d.ts +34 -1
- package/dist/policies/ledger-client.js +21 -16
- package/dist/policies/ledger-client.js.map +1 -1
- package/dist/policy-packs/builtin/post-merge-gate-runtime.d.ts +195 -0
- package/dist/policy-packs/builtin/post-merge-gate-runtime.js +290 -0
- package/dist/policy-packs/builtin/post-merge-gate-runtime.js.map +1 -0
- package/dist/policy-packs/builtin/post-merge-gate.d.ts +56 -0
- package/dist/policy-packs/builtin/post-merge-gate.js +291 -0
- package/dist/policy-packs/builtin/post-merge-gate.js.map +1 -0
- package/dist/policy-packs/builtin/solution-acceptance-runtime.js +28 -13
- package/dist/policy-packs/builtin/solution-acceptance-runtime.js.map +1 -1
- package/dist/policy-packs/registry.d.ts +1 -1
- package/dist/policy-packs/registry.js +12 -0
- package/dist/policy-packs/registry.js.map +1 -1
- package/dist/runtime/git-context.d.ts +50 -0
- package/dist/runtime/git-context.js +108 -1
- package/dist/runtime/git-context.js.map +1 -1
- package/dist/runtime/hermetic-spawn-guard.d.ts +25 -0
- package/dist/runtime/hermetic-spawn-guard.js +136 -0
- package/dist/runtime/hermetic-spawn-guard.js.map +1 -0
- package/dist/runtime/ledger-writer.d.ts +27 -0
- package/dist/runtime/ledger-writer.js +46 -0
- package/dist/runtime/ledger-writer.js.map +1 -0
- package/dist/runtime/read-only-bash.js +118 -0
- package/dist/runtime/read-only-bash.js.map +1 -1
- package/dist/schema/index.d.ts +41 -0
- package/dist/schema/index.js +5 -0
- package/dist/schema/index.js.map +1 -1
- package/dist/schema/toolchain-parity.d.ts +18 -0
- package/dist/schema/toolchain-parity.js +35 -0
- package/dist/schema/toolchain-parity.js.map +1 -0
- package/package.json +4 -2
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
// Shared runtime constants + helpers for the `post-merge-gate` policy pack.
|
|
2
|
+
//
|
|
3
|
+
// The pack itself (`post-merge-gate.ts`) only emits hooks + the audit-copy
|
|
4
|
+
// instructions. The actual enforcement lives in two CLI verbs — `harness
|
|
5
|
+
// pack hook post-merge-gate-record` (producer, PostToolUse) and `harness
|
|
6
|
+
// pack hook post-merge-gate` (blocker, PreToolUse) — both under `src/cli/
|
|
7
|
+
// pack/`. This module is the small shared surface they pull from: the
|
|
8
|
+
// merged-tag format, the curated mutation matcher, the escape allowlist,
|
|
9
|
+
// and a couple of small payload/command helpers.
|
|
10
|
+
//
|
|
11
|
+
// Binding decisions this module encodes (see
|
|
12
|
+
// .ai/runs/2026-07-23-post-merge-gate/03-decisions.md, BINDING):
|
|
13
|
+
//
|
|
14
|
+
// - The merged fact is keyed on an EXACT branch-tip sha match, not
|
|
15
|
+
// ancestry. `gh pr merge` merges the PR on the remote side; it does
|
|
16
|
+
// NOT move the local branch's tip. So the local tip observed by the
|
|
17
|
+
// PostToolUse producer, right after a successful merge, IS the exact
|
|
18
|
+
// commit that got merged. The blocker denies only while the current
|
|
19
|
+
// tip still equals that recorded value — a new local commit moves the
|
|
20
|
+
// tip and the gate falls silent (deliberate: that is legitimate
|
|
21
|
+
// continued work, not stale post-merge state), and a recycled branch
|
|
22
|
+
// name with a different tip never matches either. No expiry /
|
|
23
|
+
// freshness window is needed as a result (squash-fest, offline).
|
|
24
|
+
//
|
|
25
|
+
// - Deny scope v1 is a CURATED command list (git commit / add / push /
|
|
26
|
+
// merge / rebase / cherry-pick / revert / reset / stash pop|apply, gh
|
|
27
|
+
// pr create / merge), not "every Bash call" — mirrors
|
|
28
|
+
// branch-protection.ts's documented v1 scope-cut (see its "Out of
|
|
29
|
+
// scope" section). Read-only git (status/log/diff/branch) and
|
|
30
|
+
// unrelated shell are never touched.
|
|
31
|
+
//
|
|
32
|
+
// - The escape allowlist is checked FIRST in the blocker, unconditionally,
|
|
33
|
+
// before manifest load or any ledger query — see hook-post-merge-gate.ts.
|
|
34
|
+
// This module only supplies the matcher; the ordering guarantee lives
|
|
35
|
+
// in the hook, not here.
|
|
36
|
+
//
|
|
37
|
+
// - Fails OPEN (allows) when the ledger is unreachable, unlike
|
|
38
|
+
// branch-protection's fail-closed posture: without the ledger,
|
|
39
|
+
// "merged" and "not merged" are indistinguishable, and fail-closed
|
|
40
|
+
// here would block every feature branch's git history forever. This
|
|
41
|
+
// pack is advisory-strength against a hostile agent, same honesty as
|
|
42
|
+
// the other `requires.ledger_tag`-style gates
|
|
43
|
+
// (docs/okf/evidence-ledger-trust-boundary.md).
|
|
44
|
+
import { findGitEntry, resolveCommonDir, resolveOriginHeadBase } from "../../runtime/git-context.js";
|
|
45
|
+
export const PACK_NAME = "post-merge-gate";
|
|
46
|
+
/**
|
|
47
|
+
* Content prefix the producer writes and the blocker substring-matches.
|
|
48
|
+
* The full recorded content is
|
|
49
|
+
* `${MERGED_TAG_PREFIX}:${repo}:${branch}:${sha}[ pr:<n>] at:<iso>`; the
|
|
50
|
+
* blocker only ever tests for the `${MERGED_TAG_PREFIX}:${repo}:${branch}:
|
|
51
|
+
* ${sha}` prefix (an exact 40-hex-char sha), so the trailing `pr:` / `at:`
|
|
52
|
+
* tokens are audit decoration, not part of the match key.
|
|
53
|
+
*/
|
|
54
|
+
export const MERGED_TAG_PREFIX = "post-merge-gate:merged";
|
|
55
|
+
/** The exact substring the blocker tests ledger entries for. */
|
|
56
|
+
export function mergedTagMatchKey(repo, branch, sha) {
|
|
57
|
+
return `${MERGED_TAG_PREFIX}:${repo}:${branch}:${sha}`;
|
|
58
|
+
}
|
|
59
|
+
/** Build the full fact content the producer writes (match key + audit decoration). */
|
|
60
|
+
export function buildMergedTagContent(args) {
|
|
61
|
+
const prPart = args.pr ? ` pr:${args.pr}` : "";
|
|
62
|
+
return `${mergedTagMatchKey(args.repo, args.branch, args.sha)}${prPart} at:${args.whenIso}`;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Producer trigger: does this Bash command look like a `gh pr merge`
|
|
66
|
+
* invocation? Same anchoring convention as `DEFAULT_PUSH_BASH_RE`
|
|
67
|
+
* (solution-acceptance-runtime.ts): tolerates a leading `cd … &&`, inline
|
|
68
|
+
* `VAR=val` assignments, and chaining. Known gap, same class as that
|
|
69
|
+
* regex's own documented residual: a heredoc / `sh -c` / `eval`
|
|
70
|
+
* indirection defeats this match before the producer ever runs — not
|
|
71
|
+
* attempted to close here (the MCP merge path, `pull_requests_merge`, is
|
|
72
|
+
* the other documented gap; see the pack's instructions.md).
|
|
73
|
+
*/
|
|
74
|
+
export const GH_PR_MERGE_BASH_RE = /(?:^|\n|;|\||&&|\()\s*(?:\w+=\S+\s+)*gh\s+pr\s+merge\b/;
|
|
75
|
+
/**
|
|
76
|
+
* Blocker deny-scope v1 (03-decisions.md): the curated mutation command
|
|
77
|
+
* list. Deliberately NOT "every Bash command" — read-only git
|
|
78
|
+
* (status/log/diff/branch) and unrelated shell stay unaffected. Same
|
|
79
|
+
* anchoring convention as `GH_PR_MERGE_BASH_RE`.
|
|
80
|
+
*/
|
|
81
|
+
export const CURATED_MUTATION_BASH_RE = /(?:^|\n|;|\||&&|\()\s*(?:\w+=\S+\s+)*(?:git(?:\s+-C\s+\S+)?\s+(?:commit|add|push|merge|rebase|cherry-pick|revert|reset|stash\s+(?:pop|apply))\b|gh\s+pr\s+(?:create|merge)\b)/;
|
|
82
|
+
/**
|
|
83
|
+
* Escape allowlist, git verbs (03-decisions.md): the exact recovery path
|
|
84
|
+
* the deny message recommends, plus read-only stash inspection. Checked
|
|
85
|
+
* BEFORE the curated-mutation match and before any manifest/ledger access
|
|
86
|
+
* — see hook-post-merge-gate.ts for the ordering guarantee.
|
|
87
|
+
*/
|
|
88
|
+
export const ESCAPE_GIT_BASH_RE = /(?:^|\n|;|\||&&|\()\s*(?:\w+=\S+\s+)*git(?:\s+-C\s+\S+)?\s+(?:switch\b|checkout\b|pull\b|fetch\b|branch\s+-(?:d|D)\b|stash\s+(?:list|show)\b)/;
|
|
89
|
+
/**
|
|
90
|
+
* Escape allowlist, harness verbs (03-decisions.md): any invocation of
|
|
91
|
+
* harness's own CLI (recovery commands like `harness session-start
|
|
92
|
+
* branch-check`, diagnostics, a future self-check) always passes,
|
|
93
|
+
* regardless of spelling — mirrors the `npx` / absolute-path /
|
|
94
|
+
* `./node_modules/.bin` robustness the `deny-kill-switch-bash` regex in
|
|
95
|
+
* `src/cli/init/templates.ts` already established for the same class of
|
|
96
|
+
* bypass concern.
|
|
97
|
+
*/
|
|
98
|
+
export const ESCAPE_HARNESS_BASH_RE = /(?:^|\n|;|\||&&|\()\s*(?:\w+=\S+\s+)*(?:npx\s+|\S*\/)?harness\b/;
|
|
99
|
+
/** True when `command` matches any escape pattern. Checked first, unconditionally. */
|
|
100
|
+
export function isEscapeCommand(command) {
|
|
101
|
+
return ESCAPE_GIT_BASH_RE.test(command) || ESCAPE_HARNESS_BASH_RE.test(command);
|
|
102
|
+
}
|
|
103
|
+
/** True when `command` matches the curated v1 deny-scope. */
|
|
104
|
+
export function isCuratedMutationCommand(command) {
|
|
105
|
+
return CURATED_MUTATION_BASH_RE.test(command);
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Defensive `tool_output.exit_code` reader for the PostToolUse Bash
|
|
109
|
+
* payload — Contract A. Returns `null` on ANY shape other than a plain
|
|
110
|
+
* finite number — missing field, a differently-named variant, a string,
|
|
111
|
+
* etc. — so the producer's "no fact on anything but a confirmed success"
|
|
112
|
+
* rule (03-decisions.md) degrades to "no fact" rather than guessing.
|
|
113
|
+
*
|
|
114
|
+
* PAYLOAD REALITY (2026-07, follow-up to 03-decisions.md): live
|
|
115
|
+
* verification against a real Claude Code 2.1.218 native install (a
|
|
116
|
+
* `claude -p --settings` dump-hook capture, 19/19 fired events) found NO
|
|
117
|
+
* `tool_output` field at all — the field is `tool_response`, shaped
|
|
118
|
+
* `{ stdout, stderr, interrupted, isImage, noOutputExpected }` with no
|
|
119
|
+
* exit-code equivalent (see `tests/fixtures/post-merge-gate/
|
|
120
|
+
* real-posttooluse-payload-2.1.218.json`, a verbatim capture). The
|
|
121
|
+
* `tool_output.stdout/stderr/exit_code` shape this function reads
|
|
122
|
+
* evidently describes a DIFFERENT — newer, or differently-shimmed —
|
|
123
|
+
* Claude Code contract than 2.1.218's, not a documentation error to
|
|
124
|
+
* "fix" here: Contract A is left exactly as it was (any install that
|
|
125
|
+
* DOES send `tool_output.exit_code` still works unchanged), and Contract
|
|
126
|
+
* B below is the fallback that makes the producer fire on 2.1.218 at
|
|
127
|
+
* all. See `resolveMergeConfirmation`.
|
|
128
|
+
*/
|
|
129
|
+
export function extractExitCode(toolOutput) {
|
|
130
|
+
if (typeof toolOutput !== "object" || toolOutput === null)
|
|
131
|
+
return null;
|
|
132
|
+
const ec = toolOutput["exit_code"];
|
|
133
|
+
return typeof ec === "number" && Number.isFinite(ec) ? ec : null;
|
|
134
|
+
}
|
|
135
|
+
// Matches the first bare integer following `gh pr merge` — audit
|
|
136
|
+
// decoration only (the deny/allow decision never depends on this).
|
|
137
|
+
const PR_NUMBER_RE = /gh\s+pr\s+merge\b[^\n;|&]*?(\d+)\b/;
|
|
138
|
+
/** Best-effort PR number extraction from a `gh pr merge` command string. */
|
|
139
|
+
export function extractPrNumber(command) {
|
|
140
|
+
const m = PR_NUMBER_RE.exec(command);
|
|
141
|
+
return m ? m[1] : null;
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Contract B success-text matcher (2.1.218 payload-reality follow-up).
|
|
145
|
+
* `gh pr merge`'s own past-tense confirmation sentence, verbatim across
|
|
146
|
+
* all three merge methods.
|
|
147
|
+
*
|
|
148
|
+
* SOURCE (verified against the installed `gh` binary, v2.94.0,
|
|
149
|
+
* `pkg/cmd/pr/merge/merge.go` lines 369-376): the success line is built
|
|
150
|
+
* as `infof("%s %s pull request %s#%d (%s)", icon, action,
|
|
151
|
+
* ghrepo.FullName(baseRepo), pr.Number, pr.Title)` — i.e. the repo
|
|
152
|
+
* FULLNAME sits directly between "pull request" and the bare `#<n>`,
|
|
153
|
+
* GLUED to the `#` with no space (`%s#%d`, not `%s #%d`):
|
|
154
|
+
*
|
|
155
|
+
* ✓ Squashed and merged pull request owner/repo#65 (Some title)
|
|
156
|
+
* ✓ Rebased and merged pull request owner/repo#65 (Some title)
|
|
157
|
+
* ✓ Merged pull request owner/repo#65 (Some title)
|
|
158
|
+
*
|
|
159
|
+
* `infof` writes to `gh`'s STDERR (the informational-message channel,
|
|
160
|
+
* distinct from `gh`'s own stdout, which is reserved for machine-
|
|
161
|
+
* consumable output) — another reason this matcher tests the CONCATENATED
|
|
162
|
+
* `stdout`+`stderr`, not stdout alone. `icon` is a bare `✓` (or `!` for
|
|
163
|
+
* warnings) with no ANSI color codes when `gh` detects a non-TTY output
|
|
164
|
+
* stream, which a hook-captured Bash hijacks it into.
|
|
165
|
+
*
|
|
166
|
+
* `[^\s#]*` between "pull request" and `#` accepts BOTH that real
|
|
167
|
+
* `owner/repo#<n>` shape AND a bare `#<n>` (no fullname) — the latter is
|
|
168
|
+
* not `gh`'s actual current wording but is accepted defensively as a
|
|
169
|
+
* documentation-shape / future-`gh`-version tolerance, at zero matching-
|
|
170
|
+
* surface cost (repo fullnames never contain whitespace or `#`, so the
|
|
171
|
+
* character class cannot itself widen what counts as a match beyond
|
|
172
|
+
* "some non-space run immediately before the PR number").
|
|
173
|
+
*
|
|
174
|
+
* Still deliberately narrow overall (conservative — false negatives are
|
|
175
|
+
* the safe direction here, false positives are not):
|
|
176
|
+
* - exact past-tense phrase, not a bare "merged" substring — excludes
|
|
177
|
+
* `gh pr merge --auto`'s pending text (real wording, same source
|
|
178
|
+
* file: "Pull request owner/repo#65 will be automatically merged via
|
|
179
|
+
* squash when all requirements are met" — capitalized standalone
|
|
180
|
+
* "Pull request", no "Squashed/Rebased and merged" or standalone
|
|
181
|
+
* "Merged" immediately precedes it), and excludes the already-merged
|
|
182
|
+
* warning (real wording: "Pull request owner/repo#65 was already
|
|
183
|
+
* merged" — word order is reversed: "Pull request ... was already
|
|
184
|
+
* merged", not "Merged pull request ...").
|
|
185
|
+
* - case-sensitive, matching gh's own capitalization exactly (no `/i`)
|
|
186
|
+
* — narrower matching surface, same conservative direction.
|
|
187
|
+
* - `\b` word boundaries on both ends so a partial-word coincidence
|
|
188
|
+
* (e.g. a hypothetical "ReMerged") cannot match.
|
|
189
|
+
*
|
|
190
|
+
* KNOWN GAP (documented, not attempted to close here): this is coupled
|
|
191
|
+
* to `gh`'s current wording. A future `gh` release that rephrases its
|
|
192
|
+
* success sentence makes this matcher — and therefore the whole
|
|
193
|
+
* Contract-B path — silently, fail-safely inert (no fact written, same
|
|
194
|
+
* as any other unmatched shape; never a false "merged" record). See the
|
|
195
|
+
* pack's instructions.md / docs/policy-packs/post-merge-gate.md "Known
|
|
196
|
+
* gaps" for the operator-facing verification path.
|
|
197
|
+
*/
|
|
198
|
+
export const GH_MERGE_SUCCESS_RE = /\b(?:Squashed and merged|Rebased and merged|Merged)\s+pull request\s+[^\s#]*#(\d+)\b/;
|
|
199
|
+
/**
|
|
200
|
+
* Contract B confirmation: does `toolResponse` carry a real Claude Code
|
|
201
|
+
* 2.1.218-shaped Bash result (`{ stdout, stderr, interrupted, ... }`)
|
|
202
|
+
* whose `stdout`+`stderr` contain a `gh pr merge` success sentence?
|
|
203
|
+
*
|
|
204
|
+
* `interrupted` must be the LITERAL boolean `false` — missing, `true`,
|
|
205
|
+
* or any other value all fail closed (no match), the same fail-safe
|
|
206
|
+
* direction as Contract A's exit_code check. `stdout`/`stderr` default
|
|
207
|
+
* to `""` when absent or non-string (never throws), and are joined with
|
|
208
|
+
* a newline before matching so a phrase cannot accidentally form by
|
|
209
|
+
* concatenating the tail of one stream with the head of the other.
|
|
210
|
+
*/
|
|
211
|
+
export function matchGhMergeSuccessText(toolResponse) {
|
|
212
|
+
if (typeof toolResponse !== "object" || toolResponse === null)
|
|
213
|
+
return { matched: false };
|
|
214
|
+
const tr = toolResponse;
|
|
215
|
+
if (tr["interrupted"] !== false)
|
|
216
|
+
return { matched: false };
|
|
217
|
+
const stdout = typeof tr["stdout"] === "string" ? tr["stdout"] : "";
|
|
218
|
+
const stderr = typeof tr["stderr"] === "string" ? tr["stderr"] : "";
|
|
219
|
+
const combined = `${stdout}\n${stderr}`;
|
|
220
|
+
const match = GH_MERGE_SUCCESS_RE.exec(combined);
|
|
221
|
+
if (!match)
|
|
222
|
+
return { matched: false };
|
|
223
|
+
return { matched: true, pr: match[1] ?? null };
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* Dual-contract merge confirmation (2.1.218 payload-reality follow-up).
|
|
227
|
+
* Tries Contract A (`tool_output.exit_code`, unchanged) first; falls
|
|
228
|
+
* back to Contract B (`tool_response` + a matching `gh` success
|
|
229
|
+
* sentence) only when Contract A yields no verdict at all.
|
|
230
|
+
*
|
|
231
|
+
* BINDING ordering decision — Contract A wins whenever it resolves to
|
|
232
|
+
* ANY definite verdict, success OR failure: a well-formed, non-zero
|
|
233
|
+
* `exit_code` is Contract A's own explicit failure signal and short-
|
|
234
|
+
* circuits WITHOUT consulting Contract B, so a coincidental gh success
|
|
235
|
+
* phrase sitting in a sibling `tool_response` field (the hypothetical
|
|
236
|
+
* "both present" shape) can never override an authoritative failure.
|
|
237
|
+
* Contract B is consulted only when `exit_code` is entirely unresolvable
|
|
238
|
+
* (`extractExitCode` returns `null` — missing field, wrong shape, or the
|
|
239
|
+
* field genuinely absent as on 2.1.218).
|
|
240
|
+
*/
|
|
241
|
+
export function resolveMergeConfirmation(toolOutput, toolResponse, command) {
|
|
242
|
+
const exitCode = extractExitCode(toolOutput);
|
|
243
|
+
if (exitCode === 0) {
|
|
244
|
+
return {
|
|
245
|
+
confirmed: true,
|
|
246
|
+
contract: "exit_code",
|
|
247
|
+
pr: extractPrNumber(command),
|
|
248
|
+
reason: "tool_output.exit_code === 0 (Contract A)",
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
if (exitCode !== null) {
|
|
252
|
+
return {
|
|
253
|
+
confirmed: false,
|
|
254
|
+
contract: "none",
|
|
255
|
+
pr: null,
|
|
256
|
+
reason: `tool_output.exit_code is ${exitCode}, not 0 (Contract A reports failure; Contract B not consulted)`,
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
const ghSuccess = matchGhMergeSuccessText(toolResponse);
|
|
260
|
+
if (ghSuccess.matched) {
|
|
261
|
+
return {
|
|
262
|
+
confirmed: true,
|
|
263
|
+
contract: "gh_success_text",
|
|
264
|
+
pr: extractPrNumber(command) ?? ghSuccess.pr,
|
|
265
|
+
reason: "tool_response carries a confirmed gh merge success sentence (Contract B)",
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
return {
|
|
269
|
+
confirmed: false,
|
|
270
|
+
contract: "none",
|
|
271
|
+
pr: null,
|
|
272
|
+
reason: "no confirming tool_output.exit_code (Contract A) and no matching gh merge success text in tool_response (Contract B)",
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
/**
|
|
276
|
+
* Best-effort default-branch name for the deny message's `git switch
|
|
277
|
+
* <default>` line. Offline, reusing the same `origin/HEAD` resolution
|
|
278
|
+
* `harness record review --base` uses (now shared via
|
|
279
|
+
* `runtime/git-context.ts`, see that module's header). Returns `null`
|
|
280
|
+
* when `cwd` is not in a git work tree, or the repo has no resolvable
|
|
281
|
+
* `origin/HEAD` (e.g. no remote) — the caller falls back to a generic
|
|
282
|
+
* placeholder in that case; this is advisory text, never a gate input.
|
|
283
|
+
*/
|
|
284
|
+
export function resolveDefaultBranchName(cwd) {
|
|
285
|
+
const gitDir = findGitEntry(cwd)?.gitDir;
|
|
286
|
+
if (!gitDir)
|
|
287
|
+
return null;
|
|
288
|
+
return resolveOriginHeadBase(resolveCommonDir(gitDir));
|
|
289
|
+
}
|
|
290
|
+
//# sourceMappingURL=post-merge-gate-runtime.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"post-merge-gate-runtime.js","sourceRoot":"","sources":["../../../src/policy-packs/builtin/post-merge-gate-runtime.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import type { PolicyPack, PolicyUx } from "../../schema/index.js";
|
|
3
|
+
import { type Runtime } from "../runtime.js";
|
|
4
|
+
import type { PackContribution } from "../types.js";
|
|
5
|
+
import { PACK_NAME } from "./post-merge-gate-runtime.js";
|
|
6
|
+
export { PACK_NAME };
|
|
7
|
+
/**
|
|
8
|
+
* Zod schema for this pack's `config:` block. Strict by design so
|
|
9
|
+
* typo'd keys fail loud at lint time (mirrors sibling packs). `ux` is
|
|
10
|
+
* the only operator-tunable key today — the curated mutation list and
|
|
11
|
+
* escape allowlist are fixed in v1 (03-decisions.md); a config override
|
|
12
|
+
* for either can land as a follow-up if operators need it.
|
|
13
|
+
*/
|
|
14
|
+
export declare const configSchema: z.ZodObject<{
|
|
15
|
+
ux: z.ZodOptional<z.ZodObject<{
|
|
16
|
+
cannot: z.ZodString;
|
|
17
|
+
required: z.ZodArray<z.ZodString, "many">;
|
|
18
|
+
run: z.ZodArray<z.ZodString, "many">;
|
|
19
|
+
}, "strict", z.ZodTypeAny, {
|
|
20
|
+
cannot: string;
|
|
21
|
+
required: string[];
|
|
22
|
+
run: string[];
|
|
23
|
+
}, {
|
|
24
|
+
cannot: string;
|
|
25
|
+
required: string[];
|
|
26
|
+
run: string[];
|
|
27
|
+
}>>;
|
|
28
|
+
}, "strict", z.ZodTypeAny, {
|
|
29
|
+
ux?: {
|
|
30
|
+
cannot: string;
|
|
31
|
+
required: string[];
|
|
32
|
+
run: string[];
|
|
33
|
+
} | undefined;
|
|
34
|
+
}, {
|
|
35
|
+
ux?: {
|
|
36
|
+
cannot: string;
|
|
37
|
+
required: string[];
|
|
38
|
+
run: string[];
|
|
39
|
+
} | undefined;
|
|
40
|
+
}>;
|
|
41
|
+
/**
|
|
42
|
+
* Shipped default `config.ux` for this pack. Canonical source for the
|
|
43
|
+
* `full` init template and `harness pack reseed` (mirrors
|
|
44
|
+
* branch-protection's `defaultUx`): a future wording fix lands here once
|
|
45
|
+
* and reaches both a fresh `harness init --template full` and an
|
|
46
|
+
* operator running `harness pack reseed post-merge-gate` against an
|
|
47
|
+
* already-installed manifest. `${DEFAULT_BRANCH}` is always populated by
|
|
48
|
+
* the blocker (falling back to the literal placeholder `<default-branch>`
|
|
49
|
+
* when it cannot resolve `origin/HEAD`), never left as an unresolved
|
|
50
|
+
* template var.
|
|
51
|
+
*/
|
|
52
|
+
export declare function defaultUx(): PolicyUx;
|
|
53
|
+
export declare function resolve(pack: PolicyPack, runtime?: Runtime): {
|
|
54
|
+
contribution: PackContribution;
|
|
55
|
+
warnings: string[];
|
|
56
|
+
};
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
// Builtin Policy Pack: `post-merge-gate`.
|
|
2
|
+
//
|
|
3
|
+
// Catches the "kept working on a branch after it was already merged"
|
|
4
|
+
// pattern: an agent (or operator) merges a PR via `gh pr merge`, then
|
|
5
|
+
// keeps committing / pushing / rebasing on the SAME local checkout
|
|
6
|
+
// without switching back to the default branch first — silently
|
|
7
|
+
// building on top of a commit that is already integrated, which either
|
|
8
|
+
// produces a confusing duplicate-merge PR or loses the new work off the
|
|
9
|
+
// side of a branch nobody looks at again.
|
|
10
|
+
//
|
|
11
|
+
// Mechanics, mirroring `branch-protection` (see that pack's header for
|
|
12
|
+
// the identical two-hook shape):
|
|
13
|
+
//
|
|
14
|
+
// 1. PostToolUse producer (`harness pack hook post-merge-gate-record`)
|
|
15
|
+
// watches every Bash call and, ONLY when the command matched
|
|
16
|
+
// `gh pr merge` AND the merge is CONFIRMED, writes a
|
|
17
|
+
// `post-merge-gate:merged:<repo>:<branch>:<sha>` fact (plus PR
|
|
18
|
+
// number and timestamp, audit-only) to the evidence ledger — `sha`
|
|
19
|
+
// being the LOCAL branch tip observed right after the merge, which
|
|
20
|
+
// is the exact commit that got merged (`gh pr merge` merges on the
|
|
21
|
+
// remote side; it does not move the local tip). Confirmation is
|
|
22
|
+
// DUAL-CONTRACT (payload-reality follow-up, 2026-07): a `tool_output.
|
|
23
|
+
// exit_code === 0` (Contract A) is NOT reliably present — live
|
|
24
|
+
// verification against a real Claude Code 2.1.218 install found the
|
|
25
|
+
// PostToolUse Bash payload carries no `tool_output` / exit-code field
|
|
26
|
+
// at all, only `tool_response` (`{ stdout, stderr, interrupted,
|
|
27
|
+
// isImage, noOutputExpected }`) — so a `gh pr merge` success sentence
|
|
28
|
+
// match against `tool_response` (Contract B) is the fallback that
|
|
29
|
+
// makes the producer fire at all on that install. See
|
|
30
|
+
// `resolveMergeConfirmation` in post-merge-gate-runtime.ts for the
|
|
31
|
+
// exact dual-contract / "Contract A wins" ordering. Payload reads are
|
|
32
|
+
// defensive throughout: an unexpected shape on either contract writes
|
|
33
|
+
// nothing.
|
|
34
|
+
//
|
|
35
|
+
// 2. PreToolUse blocker (`harness pack hook post-merge-gate`) checks a
|
|
36
|
+
// CURATED list of history-mutating commands (see
|
|
37
|
+
// post-merge-gate-runtime.ts for the exact list) and denies them
|
|
38
|
+
// when the CURRENT branch tip still equals a recorded merged tip
|
|
39
|
+
// for this repo+branch — an exact sha match, not ancestry, so a new
|
|
40
|
+
// commit (moving the tip) or a recycled branch name (a different
|
|
41
|
+
// tip) both fall silently outside the gate. An ESCAPE ALLOWLIST
|
|
42
|
+
// (git switch/checkout/pull/fetch, git branch -d/-D, git stash
|
|
43
|
+
// list/show, any `harness ...` command) is checked FIRST, before
|
|
44
|
+
// any manifest load or ledger query, so the recovery path can never
|
|
45
|
+
// be starved by an unrelated failure.
|
|
46
|
+
//
|
|
47
|
+
// Fails OPEN when the ledger is unreachable (unlike branch-protection's
|
|
48
|
+
// fail-closed posture): without the ledger, "merged" and "not merged"
|
|
49
|
+
// are indistinguishable, and fail-closed here would block ordinary git
|
|
50
|
+
// history work on every branch whenever grounding-mcp hiccups. This pack
|
|
51
|
+
// is advisory-strength against a hostile agent, like the other
|
|
52
|
+
// `requires.ledger_tag`-style gates (docs/okf/evidence-ledger-trust-boundary.md);
|
|
53
|
+
// see post-merge-gate-runtime.ts's header for the full rationale.
|
|
54
|
+
//
|
|
55
|
+
// No auto-switch: the blocker only ever recommends the recovery commands
|
|
56
|
+
// in its deny message, never runs them (operator/agent decides).
|
|
57
|
+
//
|
|
58
|
+
// Enabled per-installation via `harness pack add post-merge-gate`. The
|
|
59
|
+
// `full` init template wires it with `enabled: false` (opt-in — a fresh,
|
|
60
|
+
// higher-risk gate; see templates.ts for the rationale), unlike
|
|
61
|
+
// branch-protection's default-enabled precedent.
|
|
62
|
+
//
|
|
63
|
+
// Claude Code only in v1 (no Codex adapter): both hooks match on the
|
|
64
|
+
// `Bash` tool name and classify by command text, a mechanism that has
|
|
65
|
+
// no established Codex-adapter parity yet (mirrors `solution-acceptance`,
|
|
66
|
+
// which ships with no Codex variant either). `resolve()` warns if wired
|
|
67
|
+
// under `runtime: "codex"`.
|
|
68
|
+
import { z } from "zod";
|
|
69
|
+
import { PolicyUxSchema } from "../../schema/policies.js";
|
|
70
|
+
import { DEFAULT_RUNTIME } from "../runtime.js";
|
|
71
|
+
import { CURATED_MUTATION_BASH_RE, ESCAPE_GIT_BASH_RE, ESCAPE_HARNESS_BASH_RE, GH_MERGE_SUCCESS_RE, GH_PR_MERGE_BASH_RE, MERGED_TAG_PREFIX, PACK_NAME, } from "./post-merge-gate-runtime.js";
|
|
72
|
+
export { PACK_NAME };
|
|
73
|
+
/**
|
|
74
|
+
* Zod schema for this pack's `config:` block. Strict by design so
|
|
75
|
+
* typo'd keys fail loud at lint time (mirrors sibling packs). `ux` is
|
|
76
|
+
* the only operator-tunable key today — the curated mutation list and
|
|
77
|
+
* escape allowlist are fixed in v1 (03-decisions.md); a config override
|
|
78
|
+
* for either can land as a follow-up if operators need it.
|
|
79
|
+
*/
|
|
80
|
+
export const configSchema = z
|
|
81
|
+
.object({
|
|
82
|
+
ux: PolicyUxSchema.optional(),
|
|
83
|
+
})
|
|
84
|
+
.strict();
|
|
85
|
+
/**
|
|
86
|
+
* Shipped default `config.ux` for this pack. Canonical source for the
|
|
87
|
+
* `full` init template and `harness pack reseed` (mirrors
|
|
88
|
+
* branch-protection's `defaultUx`): a future wording fix lands here once
|
|
89
|
+
* and reaches both a fresh `harness init --template full` and an
|
|
90
|
+
* operator running `harness pack reseed post-merge-gate` against an
|
|
91
|
+
* already-installed manifest. `${DEFAULT_BRANCH}` is always populated by
|
|
92
|
+
* the blocker (falling back to the literal placeholder `<default-branch>`
|
|
93
|
+
* when it cannot resolve `origin/HEAD`), never left as an unresolved
|
|
94
|
+
* template var.
|
|
95
|
+
*/
|
|
96
|
+
export function defaultUx() {
|
|
97
|
+
return {
|
|
98
|
+
cannot: "You cannot run ${TOOL_NAME} on branch ${BRANCH} yet — its current tip was already merged.",
|
|
99
|
+
required: [
|
|
100
|
+
"a branch tip that is not sitting at an already-merged commit (switch off `${BRANCH}`, or move its tip with a new commit)",
|
|
101
|
+
],
|
|
102
|
+
run: [
|
|
103
|
+
"git switch ${DEFAULT_BRANCH}",
|
|
104
|
+
"git pull --ff-only",
|
|
105
|
+
"git branch -d ${BRANCH} # optional cleanup",
|
|
106
|
+
],
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
const HOOK_NAME_PREFIX = `policy-pack:${PACK_NAME}`;
|
|
110
|
+
const PRODUCER_COMMAND = "harness pack hook post-merge-gate-record";
|
|
111
|
+
const BLOCKER_COMMAND = "harness pack hook post-merge-gate";
|
|
112
|
+
function buildHooks() {
|
|
113
|
+
return [
|
|
114
|
+
{
|
|
115
|
+
name: `${HOOK_NAME_PREFIX}:post-tool-use`,
|
|
116
|
+
event: "PostToolUse",
|
|
117
|
+
match: "Bash",
|
|
118
|
+
command: PRODUCER_COMMAND,
|
|
119
|
+
blocking: false,
|
|
120
|
+
budget_ms: 5000,
|
|
121
|
+
description: "Producer: on a `gh pr merge` Bash call that exited 0, write `post-merge-gate:merged:<repo>:<branch>:<sha>` to the evidence ledger. Non-blocking; any failure just leaves the gate silent for that merge.",
|
|
122
|
+
},
|
|
123
|
+
{
|
|
124
|
+
name: `${HOOK_NAME_PREFIX}:pre-tool-use`,
|
|
125
|
+
event: "PreToolUse",
|
|
126
|
+
match: "Bash",
|
|
127
|
+
command: BLOCKER_COMMAND,
|
|
128
|
+
blocking: "hard",
|
|
129
|
+
budget_ms: 5000,
|
|
130
|
+
description: "Blocker: deny curated history-mutating Bash commands (git commit/add/push/merge/rebase/cherry-pick/revert/reset/stash pop|apply, gh pr create/merge) when the current branch tip matches a recorded merged tip. An escape allowlist (git switch/checkout/pull/fetch, git branch -d/-D, git stash list/show, any `harness ...` command) is checked first, unconditionally. Fails open when the ledger is unreachable.",
|
|
131
|
+
},
|
|
132
|
+
];
|
|
133
|
+
}
|
|
134
|
+
function buildInstructions(pack, runtime) {
|
|
135
|
+
const description = pack.description?.trim() ?? "";
|
|
136
|
+
return `# Policy Pack: ${PACK_NAME}
|
|
137
|
+
|
|
138
|
+
> Operator audit copy. This pack denies curated history-mutating Bash
|
|
139
|
+
> commands on a branch whose tip was already merged via \`gh pr merge\`,
|
|
140
|
+
> closing the loop on the "kept working on a merged branch" incident
|
|
141
|
+
> pattern.
|
|
142
|
+
|
|
143
|
+
## Runtime
|
|
144
|
+
|
|
145
|
+
${runtime}${runtime === "codex" ? " (UNSUPPORTED — see \"Known gaps\" below; both hooks assume the Claude Code Bash tool surface)" : ""}
|
|
146
|
+
|
|
147
|
+
## Trigger + signals
|
|
148
|
+
|
|
149
|
+
1. \`PostToolUse\` producer (\`${PRODUCER_COMMAND}\`, blocking: false) on
|
|
150
|
+
\`Bash\`: matches \`${GH_PR_MERGE_BASH_RE.source}\` against the command
|
|
151
|
+
text. Fires only on a CONFIRMED merge, via either of two contracts
|
|
152
|
+
(dual-contract, payload-reality follow-up — real Claude Code shims
|
|
153
|
+
have shipped both shapes in the wild):
|
|
154
|
+
- **Contract A** — \`tool_output.exit_code\` is the number \`0\`.
|
|
155
|
+
- **Contract B** — \`tool_response\` is present with
|
|
156
|
+
\`interrupted === false\` AND its \`stdout\`+\`stderr\` contain \`gh pr
|
|
157
|
+
merge\`'s own past-tense success sentence: \`${GH_MERGE_SUCCESS_RE.source}\`
|
|
158
|
+
(covers all three merge methods: Squashed / Rebased / plain Merged).
|
|
159
|
+
Verified against the installed \`gh\` binary (v2.94.0,
|
|
160
|
+
\`pkg/cmd/pr/merge/merge.go\` lines 369-376): the line is built as
|
|
161
|
+
\`infof("%s %s pull request %s#%d (%s)", icon, action,
|
|
162
|
+
ghrepo.FullName(baseRepo), pr.Number, pr.Title)\` — the repo
|
|
163
|
+
fullname sits between "pull request" and the PR number, GLUED to
|
|
164
|
+
\`#\` with no space (e.g. \`Squashed and merged pull request
|
|
165
|
+
owner/repo#65 (title)\`), and \`infof\` writes to \`gh\`'s STDERR,
|
|
166
|
+
which is why this contract checks the concatenated
|
|
167
|
+
\`stdout\`+\`stderr\`, not stdout alone.
|
|
168
|
+
Contract A wins whenever it resolves to ANY definite verdict, success
|
|
169
|
+
OR failure — a well-formed non-zero \`exit_code\` short-circuits
|
|
170
|
+
WITHOUT consulting Contract B. Contract B is tried only when Contract
|
|
171
|
+
A's \`exit_code\` is entirely unresolvable (missing field, wrong
|
|
172
|
+
shape, or genuinely absent — as verified live against Claude Code
|
|
173
|
+
2.1.218, whose real PostToolUse Bash payload carries NO \`tool_output\`
|
|
174
|
+
/ no exit-code field at all, only \`tool_response\`; the
|
|
175
|
+
\`tool_output.exit_code\` shape appears to describe a different,
|
|
176
|
+
possibly newer, Claude Code contract). Any other shape on either
|
|
177
|
+
contract writes NO fact; fail-safe against a false "merged" record.
|
|
178
|
+
On a confirmed match, records
|
|
179
|
+
\`${MERGED_TAG_PREFIX}:<repo>:<branch>:<sha>[ pr:<n>] at:<iso>\` to the
|
|
180
|
+
evidence ledger, where \`<sha>\` is the LOCAL branch tip observed right
|
|
181
|
+
after the merge (the exact commit that got merged — \`gh pr merge\`
|
|
182
|
+
merges remote-side and does not itself move the local tip). \`<n>\`
|
|
183
|
+
prefers extraction from the \`gh pr merge\` command itself, falling
|
|
184
|
+
back to the number captured out of the Contract-B success sentence.
|
|
185
|
+
|
|
186
|
+
2. \`PreToolUse\` blocker (\`${BLOCKER_COMMAND}\`, blocking: hard) on
|
|
187
|
+
\`Bash\`: checked in this order —
|
|
188
|
+
a. **Escape allowlist first, unconditionally** (before any manifest
|
|
189
|
+
load or ledger query): \`${ESCAPE_GIT_BASH_RE.source}\` OR
|
|
190
|
+
\`${ESCAPE_HARNESS_BASH_RE.source}\` — always allowed.
|
|
191
|
+
b. **Curated mutation match**: \`${CURATED_MUTATION_BASH_RE.source}\`
|
|
192
|
+
— commands that don't match this pass through untouched.
|
|
193
|
+
c. **Ledger check**: denies only when the current branch tip exactly
|
|
194
|
+
equals a recorded \`${MERGED_TAG_PREFIX}:<repo>:<branch>:<sha>\` fact
|
|
195
|
+
for this repo+branch. A new commit moves the tip (gate falls
|
|
196
|
+
silent — that's legitimate continued work); a recycled branch name
|
|
197
|
+
has a different tip (no false positive). No freshness window is
|
|
198
|
+
needed as a result.
|
|
199
|
+
|
|
200
|
+
## Escape hatches
|
|
201
|
+
|
|
202
|
+
- **Recovery path** (named in every deny message): \`git switch
|
|
203
|
+
<default-branch>\`, then \`git pull --ff-only\`, then optionally
|
|
204
|
+
\`git branch -d <branch>\` to clean up the merged local branch.
|
|
205
|
+
- **Read-only / inspection commands** (\`git switch\`/\`checkout\`,
|
|
206
|
+
\`pull\`/\`fetch\`, \`branch -d\`/\`-D\`, \`stash list\`/\`show\`, and any
|
|
207
|
+
\`harness ...\` invocation) always pass, independent of ledger
|
|
208
|
+
reachability — the gate can never itself become the reason the
|
|
209
|
+
recovery path is unavailable.
|
|
210
|
+
|
|
211
|
+
## Known gaps (documented, not attempted in v1)
|
|
212
|
+
|
|
213
|
+
- **MCP merge path**: \`mcp__agent-tasks__pull_requests_merge\` is NOT a
|
|
214
|
+
producer trigger. Only the \`gh pr merge\` Bash surface is watched;
|
|
215
|
+
merges done through the agent-tasks MCP tool leave no
|
|
216
|
+
\`${MERGED_TAG_PREFIX}\` fact, so the blocker never fires for that
|
|
217
|
+
branch. Left as a documented gap (spec decision), not silently faked
|
|
218
|
+
as covered.
|
|
219
|
+
- **Regex-vs-shell-eval residual**: same class of gap as every other
|
|
220
|
+
\`bash_match\`-style matcher in this codebase (branch-protection,
|
|
221
|
+
solution-acceptance, the kill-switch deny policies) — a heredoc,
|
|
222
|
+
\`sh -c\`, \`eval\`, or similarly indirected command defeats the TRIGGER
|
|
223
|
+
match before either hook ever runs. Not attempted to close here.
|
|
224
|
+
- **Curated scope, not every Bash command**: destructive non-git
|
|
225
|
+
commands on a just-merged branch (e.g. hand-editing files outside
|
|
226
|
+
git's own history primitives) are out of v1 scope, mirroring
|
|
227
|
+
branch-protection's documented scope-cut.
|
|
228
|
+
- **Chained-escape bypass**: escape-first is checked against the WHOLE
|
|
229
|
+
command string, not per-clause, so a curated mutation chained with an
|
|
230
|
+
escape verb (e.g. \`git commit -am x && git switch main\`) is fully
|
|
231
|
+
ALLOWED — the escape match wins for the entire compound command. Same
|
|
232
|
+
accepted class as the regex-vs-shell-eval residual above (\`eval\`,
|
|
233
|
+
heredoc, \`sh -c\`); not attempted to close here, and deliberate: the
|
|
234
|
+
alternative (per-clause splitting) would need a real shell parser.
|
|
235
|
+
- **Inverted trust-boundary residual**: a merged fact written by any
|
|
236
|
+
means OTHER than the producer (e.g. directly via
|
|
237
|
+
\`mcp__agent-grounding__ledger_add\`) that happens to exactly match the
|
|
238
|
+
CURRENT \`repo:branch:sha\` triple denies curated mutations on that
|
|
239
|
+
branch — recoverable via any escape command or by making a new commit
|
|
240
|
+
(which moves the tip and the fact no longer matches). A fact for a
|
|
241
|
+
different \`repo:branch:sha\` triple is inert; this is availability-only
|
|
242
|
+
(a spurious deny, always escapable), never a privilege the agent gains,
|
|
243
|
+
consistent with docs/okf/evidence-ledger-trust-boundary.md.
|
|
244
|
+
- **Contract B is coupled to \`gh\`'s current wording**: \`${GH_MERGE_SUCCESS_RE.source}\`
|
|
245
|
+
matches \`gh\`'s OWN past-tense success sentence, verbatim, as verified
|
|
246
|
+
against the installed \`gh\` v2.94.0 source
|
|
247
|
+
(\`pkg/cmd/pr/merge/merge.go\` lines 369-376). A future \`gh\` release
|
|
248
|
+
that rephrases that sentence makes
|
|
249
|
+
Contract B silently, FAIL-SAFELY inert on installs that never send
|
|
250
|
+
\`tool_output.exit_code\` (Contract A stays available wherever it's
|
|
251
|
+
actually sent) — never a false "merged" record, but the blocker also
|
|
252
|
+
never fires for that merge. Not attempted to close here (would need a
|
|
253
|
+
\`gh\` wire-format contract, e.g. \`--json\`, out of this pack's Bash-text
|
|
254
|
+
matching design). Verification path after upgrading \`gh\`: merge a
|
|
255
|
+
throwaway PR and check for a fresh \`${MERGED_TAG_PREFIX}\` fact in the
|
|
256
|
+
evidence ledger.
|
|
257
|
+
- **No Codex adapter**: see "Runtime" above.
|
|
258
|
+
|
|
259
|
+
## Fail posture
|
|
260
|
+
|
|
261
|
+
Fails OPEN (allows, with a stderr warning) when the evidence ledger is
|
|
262
|
+
unreachable — deliberately the OPPOSITE of branch-protection's
|
|
263
|
+
fail-closed posture, because without the ledger "merged" and "not
|
|
264
|
+
merged" are indistinguishable, and fail-closed here would block ordinary
|
|
265
|
+
git history work on every branch whenever grounding-mcp hiccups. This
|
|
266
|
+
pack is advisory-strength against a hostile agent, like the other
|
|
267
|
+
\`requires.ledger_tag\`-style gates
|
|
268
|
+
(docs/okf/evidence-ledger-trust-boundary.md).
|
|
269
|
+
|
|
270
|
+
## Pack metadata
|
|
271
|
+
${description ? `\n> ${description.replace(/\n/g, "\n> ")}\n` : ""}
|
|
272
|
+
- Source: \`builtin\`
|
|
273
|
+
- Pack: \`${PACK_NAME}\`
|
|
274
|
+
- Runtime: \`${runtime}\`
|
|
275
|
+
`;
|
|
276
|
+
}
|
|
277
|
+
export function resolve(pack, runtime = DEFAULT_RUNTIME) {
|
|
278
|
+
const hooks = buildHooks();
|
|
279
|
+
const files = [
|
|
280
|
+
{
|
|
281
|
+
relativePath: `policy-packs/${PACK_NAME}/instructions.md`,
|
|
282
|
+
content: buildInstructions(pack, runtime),
|
|
283
|
+
},
|
|
284
|
+
];
|
|
285
|
+
const warnings = [];
|
|
286
|
+
if (runtime === "codex") {
|
|
287
|
+
warnings.push(`policy_packs[${pack.name}]: post-merge-gate has no Codex adapter in v1; hooks are wired assuming the Claude Code Bash tool surface and will not fire correctly under a Codex runtime.`);
|
|
288
|
+
}
|
|
289
|
+
return { contribution: { hooks, files }, warnings };
|
|
290
|
+
}
|
|
291
|
+
//# sourceMappingURL=post-merge-gate.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"post-merge-gate.js","sourceRoot":"","sources":["../../../src/policy-packs/builtin/post-merge-gate.ts"],"names":[],"mappings":"AAAA,0CAA0C;AAC1C,EAAE;AACF,qEAAqE;AACrE,sEAAsE;AACtE,mEAAmE;AACnE,gEAAgE;AAChE,uEAAuE;AACvE,wEAAwE;AACxE,0CAA0C;AAC1C,EAAE;AACF,uEAAuE;AACvE,iCAAiC;AACjC,EAAE;AACF,yEAAyE;AACzE,kEAAkE;AAClE,0DAA0D;AAC1D,oEAAoE;AACpE,wEAAwE;AACxE,wEAAwE;AACxE,wEAAwE;AACxE,qEAAqE;AACrE,2EAA2E;AAC3E,oEAAoE;AACpE,yEAAyE;AACzE,2EAA2E;AAC3E,qEAAqE;AACrE,2EAA2E;AAC3E,uEAAuE;AACvE,2DAA2D;AAC3D,wEAAwE;AACxE,2EAA2E;AAC3E,2EAA2E;AAC3E,gBAAgB;AAChB,EAAE;AACF,yEAAyE;AACzE,sDAAsD;AACtD,sEAAsE;AACtE,sEAAsE;AACtE,yEAAyE;AACzE,sEAAsE;AACtE,qEAAqE;AACrE,oEAAoE;AACpE,sEAAsE;AACtE,yEAAyE;AACzE,2CAA2C;AAC3C,EAAE;AACF,wEAAwE;AACxE,sEAAsE;AACtE,uEAAuE;AACvE,yEAAyE;AACzE,+DAA+D;AAC/D,kFAAkF;AAClF,kEAAkE;AAClE,EAAE;AACF,yEAAyE;AACzE,iEAAiE;AACjE,EAAE;AACF,uEAAuE;AACvE,yEAAyE;AACzE,gEAAgE;AAChE,iDAAiD;AACjD,EAAE;AACF,qEAAqE;AACrE,sEAAsE;AACtE,0EAA0E;AAC1E,wEAAwE;AACxE,4BAA4B;AAE5B,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AACxB,OAAO,EAAE,cAAc,EAAE,MAAM,0BAA0B,CAAC;AAE1D,OAAO,EAAE,eAAe,EAAgB,MAAM,eAAe,CAAC;AAE9D,OAAO,EACL,wBAAwB,EACxB,kBAAkB,EAClB,sBAAsB,EACtB,mBAAmB,EACnB,mBAAmB,EACnB,iBAAiB,EACjB,SAAS,GACV,MAAM,8BAA8B,CAAC;AAEtC,OAAO,EAAE,SAAS,EAAE,CAAC;AAErB;;;;;;GAMG;AACH,MAAM,CAAC,MAAM,YAAY,GAAG,CAAC;KAC1B,MAAM,CAAC;IACN,EAAE,EAAE,cAAc,CAAC,QAAQ,EAAE;CAC9B,CAAC;KACD,MAAM,EAAE,CAAC;AAEZ;;;;;;;;;;GAUG;AACH,MAAM,UAAU,SAAS;IACvB,OAAO;QACL,MAAM,EACJ,2FAA2F;QAC7F,QAAQ,EAAE;YACR,0HAA0H;SAC3H;QACD,GAAG,EAAE;YACH,8BAA8B;YAC9B,oBAAoB;YACpB,6CAA6C;SAC9C;KACF,CAAC;AACJ,CAAC;AAED,MAAM,gBAAgB,GAAG,eAAe,SAAS,EAAE,CAAC;AAEpD,MAAM,gBAAgB,GAAG,0CAA0C,CAAC;AACpE,MAAM,eAAe,GAAG,mCAAmC,CAAC;AAE5D,SAAS,UAAU;IACjB,OAAO;QACL;YACE,IAAI,EAAE,GAAG,gBAAgB,gBAAgB;YACzC,KAAK,EAAE,aAAa;YACpB,KAAK,EAAE,MAAM;YACb,OAAO,EAAE,gBAAgB;YACzB,QAAQ,EAAE,KAAK;YACf,SAAS,EAAE,IAAI;YACf,WAAW,EACT,0MAA0M;SAC7M;QACD;YACE,IAAI,EAAE,GAAG,gBAAgB,eAAe;YACxC,KAAK,EAAE,YAAY;YACnB,KAAK,EAAE,MAAM;YACb,OAAO,EAAE,eAAe;YACxB,QAAQ,EAAE,MAAM;YAChB,SAAS,EAAE,IAAI;YACf,WAAW,EACT,sZAAsZ;SACzZ;KACF,CAAC;AACJ,CAAC;AAED,SAAS,iBAAiB,CAAC,IAAgB,EAAE,OAAgB;IAC3D,MAAM,WAAW,GAAG,IAAI,CAAC,WAAW,EAAE,IAAI,EAAE,IAAI,EAAE,CAAC;IACnD,OAAO,kBAAkB,SAAS;;;;;;;;;EASlC,OAAO,GAAG,OAAO,KAAK,OAAO,CAAC,CAAC,CAAC,gGAAgG,CAAC,CAAC,CAAC,EAAE;;;;iCAItG,gBAAgB;yBACxB,mBAAmB,CAAC,MAAM;;;;;;;oDAOC,mBAAmB,CAAC,MAAM;;;;;;;;;;;;;;;;;;;;;;OAsBvE,iBAAiB;;;;;;;+BAOO,eAAe;;;iCAGb,kBAAkB,CAAC,MAAM;UAChD,sBAAsB,CAAC,MAAM;sCACD,wBAAwB,CAAC,MAAM;;;4BAGzC,iBAAiB;;;;;;;;;;;;;;;;;;;;;;MAsBvC,iBAAiB;;;;;;;;;;;;;;;;;;;;;;;;;;;;6DA4BsC,mBAAmB,CAAC,MAAM;;;;;;;;;;;yCAW9C,iBAAiB;;;;;;;;;;;;;;;;EAgBxD,WAAW,CAAC,CAAC,CAAC,OAAO,WAAW,CAAC,OAAO,CAAC,KAAK,EAAE,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE;;YAEtD,SAAS;eACN,OAAO;CACrB,CAAC;AACF,CAAC;AAED,MAAM,UAAU,OAAO,CACrB,IAAgB,EAChB,UAAmB,eAAe;IAElC,MAAM,KAAK,GAAG,UAAU,EAAE,CAAC;IAC3B,MAAM,KAAK,GAA2B;QACpC;YACE,YAAY,EAAE,gBAAgB,SAAS,kBAAkB;YACzD,OAAO,EAAE,iBAAiB,CAAC,IAAI,EAAE,OAAO,CAAC;SAC1C;KACF,CAAC;IACF,MAAM,QAAQ,GAAa,EAAE,CAAC;IAC9B,IAAI,OAAO,KAAK,OAAO,EAAE,CAAC;QACxB,QAAQ,CAAC,IAAI,CACX,gBAAgB,IAAI,CAAC,IAAI,8JAA8J,CACxL,CAAC;IACJ,CAAC;IACD,OAAO,EAAE,YAAY,EAAE,EAAE,KAAK,EAAE,KAAK,EAAE,EAAE,QAAQ,EAAE,CAAC;AACtD,CAAC"}
|