holo-codex 0.1.1 → 0.1.2
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 +11 -0
- package/docs/release-checklist.md +29 -3
- package/package.json +3 -2
- package/plugins/autonomous-pr-loop/.codex-plugin/plugin.json +1 -1
- package/plugins/autonomous-pr-loop/.mcp.json +2 -4
- package/plugins/autonomous-pr-loop/core/cli.ts +72 -0
- package/plugins/autonomous-pr-loop/core/hook-policy.ts +230 -23
- package/plugins/autonomous-pr-loop/core/storage.ts +5 -5
- package/plugins/autonomous-pr-loop/core/types.ts +2 -0
- package/plugins/autonomous-pr-loop/core/workflow-board.ts +78 -14
- package/plugins/autonomous-pr-loop/hooks/dist/permission-request.js +4 -4
- package/plugins/autonomous-pr-loop/hooks/dist/post-compact.js +4 -4
- package/plugins/autonomous-pr-loop/hooks/dist/post-tool-use.js +4 -4
- package/plugins/autonomous-pr-loop/hooks/dist/pre-compact.js +4 -4
- package/plugins/autonomous-pr-loop/hooks/dist/pre-tool-use.js +191 -27
- package/plugins/autonomous-pr-loop/hooks/dist/session-start.js +4 -4
- package/plugins/autonomous-pr-loop/hooks/dist/stop.js +4 -4
- package/plugins/autonomous-pr-loop/hooks/dist/user-prompt-submit.js +4 -4
- package/plugins/autonomous-pr-loop/mcp-server/dist/index.js +10551 -0
- package/plugins/autonomous-pr-loop/mcp-server/src/index.ts +6 -3
- package/plugins/autonomous-pr-loop/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.1.2
|
|
4
|
+
|
|
5
|
+
- Added the manual GitHub Actions Release workflow for npm Trusted Publishing, dry-run tarball validation, and registry install smoke.
|
|
6
|
+
- Added audited maintainer override support for lifecycle gates so verified release and merge operations can proceed with recorded evidence.
|
|
7
|
+
- Relaxed the hook allowlist for maintainer-owned commands after verified evidence is present.
|
|
8
|
+
- Hardened Codex hook and MCP startup compatibility for current Codex configurations.
|
|
9
|
+
- Fixed dashboard Cleanup status consistency so sidebar substages and the main cleanup checklist use the same cleanup evidence.
|
|
10
|
+
- Added the `next_issue_selected` cleanup checklist item to keep cleanup progress complete and visible.
|
|
11
|
+
- Validated the release path with the existing GitHub Actions Release workflow, tarball checks, and dashboard smoke requirements.
|
|
12
|
+
- Kept #16, #17, #18, and #19 as follow-up improvements outside this release-prep PR.
|
|
13
|
+
|
|
3
14
|
## 0.1.1
|
|
4
15
|
|
|
5
16
|
- Fixed the bundled Codex plugin hooks schema for newer Codex config parsers.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# HOLO-Codex npm Release Checklist
|
|
2
2
|
|
|
3
|
-
Use this checklist for npm releases such as `v0.1.
|
|
3
|
+
Use this checklist for npm releases such as `v0.1.2`. Replace `VERSION` with the package version being released.
|
|
4
4
|
|
|
5
5
|
The public source release remains available at `https://github.com/tizerluo/HOLO-Codex`. The npm package is `holo-codex` and installs the stable `agent-loop` CLI. Compatibility identifiers remain unchanged: `agent-loop`, `.agent-loop/`, `autonomous-pr-loop`, and `plugins/autonomous-pr-loop/`.
|
|
6
6
|
|
|
@@ -12,7 +12,7 @@ Run from the release checkout:
|
|
|
12
12
|
pnpm install --frozen-lockfile
|
|
13
13
|
pnpm build:hooks
|
|
14
14
|
pnpm lint
|
|
15
|
-
pnpm
|
|
15
|
+
pnpm exec vitest run --no-file-parallelism --maxWorkers=1
|
|
16
16
|
npm pack --ignore-scripts --dry-run --json
|
|
17
17
|
npm view holo-codex name version dist-tags --json || true
|
|
18
18
|
```
|
|
@@ -53,6 +53,32 @@ The extracted `hooks.json` must have a top-level `hooks` object and must not hav
|
|
|
53
53
|
|
|
54
54
|
## Publish
|
|
55
55
|
|
|
56
|
+
### GitHub Actions CD
|
|
57
|
+
|
|
58
|
+
For `v0.1.2` and later, prefer the manual Release workflow:
|
|
59
|
+
|
|
60
|
+
1. Configure npm Trusted Publishing for package `holo-codex`:
|
|
61
|
+
- Provider: GitHub Actions
|
|
62
|
+
- Repository: `tizerluo/HOLO-Codex`
|
|
63
|
+
- Workflow file: `release.yml`
|
|
64
|
+
- Allowed action: `npm publish`
|
|
65
|
+
- Environment: leave blank unless the workflow is later changed to use one.
|
|
66
|
+
2. Run the `Release` workflow from `main` with:
|
|
67
|
+
- `version`: the exact `package.json` version
|
|
68
|
+
- `tag`: blank to use `v<version>`, or an explicit tag
|
|
69
|
+
- `dry_run`: `true` first
|
|
70
|
+
3. Inspect the dry-run artifact and logs.
|
|
71
|
+
4. Re-run with `dry_run: false` to publish with npm provenance and create the GitHub Release.
|
|
72
|
+
|
|
73
|
+
The workflow uses GitHub OIDC (`id-token: write`) instead of a long-lived npm token. It runs on Node 24 and installs npm `^11.5.1` in both validation and publish jobs, satisfying the npm Trusted Publishing floor of Node 22.14.0+ and npm 11.5.1+.
|
|
74
|
+
Dry runs may use an already-published version to test the workflow shape; real releases fail if the npm version or Git tag already exists.
|
|
75
|
+
Dry runs validate inputs, run the stable Vitest suite, pack the tarball, verify hook schema, smoke the packed tarball, and upload the release candidate. The workflow checks existing npm versions through retried registry HTTP status codes instead of parsing npm CLI error text. The publish job re-verifies the downloaded tarball integrity against `holo-pack.json` before `npm publish`.
|
|
76
|
+
Dry runs do not execute `npm publish`, so the first `dry_run: false` run is the first real Trusted Publishing/OIDC validation.
|
|
77
|
+
After a real publish, the workflow creates the Git tag and GitHub Release before the registry install smoke. That keeps the release recoverable even if npm registry propagation makes the smoke temporarily fail.
|
|
78
|
+
The manual fallback below is intentionally separate from Trusted Publishing. It may not create provenance unless npm supports it in the local environment and the maintainer explicitly chooses that path.
|
|
79
|
+
|
|
80
|
+
### Manual fallback
|
|
81
|
+
|
|
56
82
|
Confirm npm authentication without printing tokens:
|
|
57
83
|
|
|
58
84
|
```bash
|
|
@@ -129,7 +155,7 @@ Expected result: hooks and binding registry restore from the snapshot, non-agent
|
|
|
129
155
|
After publish and post-publish smoke pass:
|
|
130
156
|
|
|
131
157
|
```bash
|
|
132
|
-
VERSION=0.1.
|
|
158
|
+
VERSION=0.1.2
|
|
133
159
|
git tag "v$VERSION"
|
|
134
160
|
git push origin "v$VERSION"
|
|
135
161
|
gh release create "v$VERSION" \
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "holo-codex",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "Human On Loop Codex control plane for observable, recoverable Codex workflow loops.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "tizerluo",
|
|
@@ -60,7 +60,8 @@
|
|
|
60
60
|
"scripts": {
|
|
61
61
|
"agent-loop": "tsx plugins/autonomous-pr-loop/scripts/agent-loop.ts",
|
|
62
62
|
"build:hooks": "esbuild plugins/autonomous-pr-loop/hooks/pre-tool-use.ts plugins/autonomous-pr-loop/hooks/post-tool-use.ts plugins/autonomous-pr-loop/hooks/user-prompt-submit.ts plugins/autonomous-pr-loop/hooks/stop.ts plugins/autonomous-pr-loop/hooks/session-start.ts plugins/autonomous-pr-loop/hooks/pre-compact.ts plugins/autonomous-pr-loop/hooks/post-compact.ts plugins/autonomous-pr-loop/hooks/permission-request.ts --bundle --platform=node --format=esm --outdir=plugins/autonomous-pr-loop/hooks/dist",
|
|
63
|
-
"
|
|
63
|
+
"build:mcp": "esbuild plugins/autonomous-pr-loop/mcp-server/src/index.ts --bundle --platform=node --format=esm --outfile=plugins/autonomous-pr-loop/mcp-server/dist/index.js",
|
|
64
|
+
"prepack": "pnpm build:hooks && pnpm build:mcp",
|
|
64
65
|
"test": "vitest run",
|
|
65
66
|
"lint": "tsc --noEmit"
|
|
66
67
|
},
|
|
@@ -142,6 +142,9 @@ export async function runAgentLoopCli(
|
|
|
142
142
|
if (command === "approve-gate") {
|
|
143
143
|
return approveGate(targetRepoRoot, filtered, json, localeOverride);
|
|
144
144
|
}
|
|
145
|
+
if (command === "maintainer-override") {
|
|
146
|
+
return maintainerOverride(targetRepoRoot, filtered, json);
|
|
147
|
+
}
|
|
145
148
|
if (command === "evidence") {
|
|
146
149
|
return evidence(targetRepoRoot, filtered, json, localeOverride);
|
|
147
150
|
}
|
|
@@ -189,6 +192,7 @@ function helpResult(json: boolean, usage = "agent-loop <command> [options]"): Cl
|
|
|
189
192
|
"hooks",
|
|
190
193
|
"local",
|
|
191
194
|
"approve-gate",
|
|
195
|
+
"maintainer-override",
|
|
192
196
|
"dashboard",
|
|
193
197
|
"evidence",
|
|
194
198
|
"delivery"
|
|
@@ -218,6 +222,7 @@ function commandHelpUsage(command: string): string | undefined {
|
|
|
218
222
|
hooks: "agent-loop hooks install-router|bind|list|doctor|unbind [--session SESSION_ID] [--run RUN_ID] [--json]",
|
|
219
223
|
local: "agent-loop local install|rollback|doctor|snapshots [--repo /path/to/repo] [--snapshot PATH] [--json]",
|
|
220
224
|
"approve-gate": "agent-loop approve-gate <gate-id> --note \"...\" [--next-state STATE] [--json]",
|
|
225
|
+
"maintainer-override": "agent-loop maintainer-override approve --scope publish|merge --reason \"...\" [--ttl-minutes N] [--run RUN_ID] [--json]",
|
|
221
226
|
dashboard: "agent-loop dashboard [--host 127.0.0.1] [--port 0] [--json]",
|
|
222
227
|
evidence: "agent-loop evidence append --stage STAGE --summary \"...\" [--run RUN_ID] [--substage ID] [--actor ACTOR] [--status STATUS] [--source SOURCE] [--ref REF] [--artifact ID] [--json]",
|
|
223
228
|
delivery: "agent-loop delivery bind|stage [options] [--json]"
|
|
@@ -240,7 +245,9 @@ const OPTIONS_WITH_VALUES = new Set([
|
|
|
240
245
|
"--port",
|
|
241
246
|
"--issue",
|
|
242
247
|
"--ref",
|
|
248
|
+
"--reason",
|
|
243
249
|
"--run",
|
|
250
|
+
"--scope",
|
|
244
251
|
"--source",
|
|
245
252
|
"--stage",
|
|
246
253
|
"--status",
|
|
@@ -249,6 +256,7 @@ const OPTIONS_WITH_VALUES = new Set([
|
|
|
249
256
|
"--substage",
|
|
250
257
|
"--summary",
|
|
251
258
|
"--title",
|
|
259
|
+
"--ttl-minutes",
|
|
252
260
|
"--url",
|
|
253
261
|
"--worker"
|
|
254
262
|
]);
|
|
@@ -920,6 +928,70 @@ function approveGate(repoRoot: string, args: string[], json: boolean, localeOver
|
|
|
920
928
|
}
|
|
921
929
|
}
|
|
922
930
|
|
|
931
|
+
function maintainerOverride(repoRoot: string, args: string[], json: boolean): CliResult {
|
|
932
|
+
const subcommand = args[1];
|
|
933
|
+
const scope = optionArg(args, "--scope");
|
|
934
|
+
const reason = optionArg(args, "--reason");
|
|
935
|
+
const ttlMinutes = maintainerOverrideTtlMinutes(args);
|
|
936
|
+
if (subcommand !== "approve") {
|
|
937
|
+
throw new AgentLoopError("unknown_command", "Usage: agent-loop maintainer-override approve --scope publish|merge --reason \"...\" [--ttl-minutes N] [--run RUN_ID]");
|
|
938
|
+
}
|
|
939
|
+
if (scope !== "publish" && scope !== "merge") {
|
|
940
|
+
throw new AgentLoopError("invalid_config", "maintainer-override approve requires --scope publish|merge.");
|
|
941
|
+
}
|
|
942
|
+
if (!reason || reason.trim().length === 0) {
|
|
943
|
+
throw new AgentLoopError("invalid_config", "maintainer-override approve requires --reason.");
|
|
944
|
+
}
|
|
945
|
+
const storage = new SqliteAgentLoopStorage(statePath(repoRoot));
|
|
946
|
+
try {
|
|
947
|
+
const runId = optionArg(args, "--run") ?? storage.getCurrentRun()?.id;
|
|
948
|
+
if (!runId) {
|
|
949
|
+
throw new AgentLoopError("invalid_config", "maintainer-override approve requires an active run or --run.");
|
|
950
|
+
}
|
|
951
|
+
if (!storage.getRun(runId)) {
|
|
952
|
+
throw new AgentLoopError("invalid_config", `maintainer-override run ${runId} was not found.`);
|
|
953
|
+
}
|
|
954
|
+
const actor = process.env.USER ?? process.env.LOGNAME ?? "unknown";
|
|
955
|
+
const expiresAt = new Date(Date.now() + ttlMinutes * 60_000).toISOString();
|
|
956
|
+
const details = {
|
|
957
|
+
scope,
|
|
958
|
+
reason,
|
|
959
|
+
actor,
|
|
960
|
+
source: "cli",
|
|
961
|
+
expiresAt,
|
|
962
|
+
ttlMinutes
|
|
963
|
+
};
|
|
964
|
+
const decision = storage.appendDecision({
|
|
965
|
+
runId,
|
|
966
|
+
kind: "maintainer_override_approved",
|
|
967
|
+
message: `Maintainer override approved for ${scope}.`,
|
|
968
|
+
details
|
|
969
|
+
});
|
|
970
|
+
const event = storage.appendEvent({
|
|
971
|
+
runId,
|
|
972
|
+
kind: "maintainer_override_approved",
|
|
973
|
+
message: `Maintainer override approved for ${scope}.`,
|
|
974
|
+
payload: details
|
|
975
|
+
});
|
|
976
|
+
return ok(json, { ok: true, decision, event, scope, expiresAt }, [
|
|
977
|
+
`maintainer override: ${scope}`,
|
|
978
|
+
`expires: ${expiresAt}`,
|
|
979
|
+
`reason: ${reason}`
|
|
980
|
+
]);
|
|
981
|
+
} finally {
|
|
982
|
+
storage.close();
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
function maintainerOverrideTtlMinutes(args: string[]): number {
|
|
987
|
+
const value = optionArg(args, "--ttl-minutes") ?? "30";
|
|
988
|
+
const ttl = Number(value);
|
|
989
|
+
if (!Number.isInteger(ttl) || ttl < 1 || ttl > 120) {
|
|
990
|
+
throw new AgentLoopError("invalid_config", "maintainer-override --ttl-minutes must be an integer from 1 to 120.");
|
|
991
|
+
}
|
|
992
|
+
return ttl;
|
|
993
|
+
}
|
|
994
|
+
|
|
923
995
|
function gateState(details: unknown): string | undefined {
|
|
924
996
|
if (typeof details !== "object" || details === null || Array.isArray(details)) return undefined;
|
|
925
997
|
const state = (details as { state?: unknown }).state;
|
|
@@ -19,6 +19,7 @@ export interface HookPolicyInput {
|
|
|
19
19
|
isWorker?: boolean;
|
|
20
20
|
protectedPaths?: string[];
|
|
21
21
|
storage?: AgentLoopStorage;
|
|
22
|
+
runId?: string;
|
|
22
23
|
}
|
|
23
24
|
|
|
24
25
|
export interface HookPolicyDecision {
|
|
@@ -28,8 +29,19 @@ export interface HookPolicyDecision {
|
|
|
28
29
|
blockedCommand: string;
|
|
29
30
|
nextAction: string;
|
|
30
31
|
reason: string;
|
|
32
|
+
auditDetails?: Record<string, unknown>;
|
|
31
33
|
}
|
|
32
34
|
|
|
35
|
+
type MaintainerOverrideScope = "publish" | "merge";
|
|
36
|
+
|
|
37
|
+
interface ActiveMaintainerOverride {
|
|
38
|
+
decisionId: string;
|
|
39
|
+
scope: MaintainerOverrideScope;
|
|
40
|
+
expiresAt: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const REQUIRED_PUBLISH_EVIDENCE_SUBSTAGES = ["lint", "full_tests", "gitnexus_detect"] as const;
|
|
44
|
+
|
|
33
45
|
/** Normalize a Codex PreToolUse hook payload into an argv-like command. */
|
|
34
46
|
export function commandFromHookPayload(payload: unknown): HookCommand | undefined {
|
|
35
47
|
if (!isRecord(payload)) {
|
|
@@ -50,7 +62,12 @@ export function commandFromHookPayload(payload: unknown): HookCommand | undefine
|
|
|
50
62
|
|
|
51
63
|
/** Evaluate a hook command without spawning subprocesses. */
|
|
52
64
|
export function evaluateHookPolicy(input: HookPolicyInput): HookPolicyDecision {
|
|
53
|
-
const
|
|
65
|
+
const normalized = normalizeCommand(input.command);
|
|
66
|
+
const shellControl = shellControlPolicy(normalized);
|
|
67
|
+
if (shellControl) {
|
|
68
|
+
return deny(renderCommand(normalized), shellControl, "policy_violation", "Run one allowlisted command at a time without shell control operators.");
|
|
69
|
+
}
|
|
70
|
+
const command = unwrapCommand(normalized);
|
|
54
71
|
const blockedCommand = renderCommand(command);
|
|
55
72
|
const destructive = destructivePolicy(command);
|
|
56
73
|
if (destructive) {
|
|
@@ -67,10 +84,25 @@ export function evaluateHookPolicy(input: HookPolicyInput): HookPolicyDecision {
|
|
|
67
84
|
if (protectedPath) {
|
|
68
85
|
return deny(blockedCommand, protectedPath, "policy_violation", "Remove protected path changes from the command.");
|
|
69
86
|
}
|
|
70
|
-
const gate = gatedLifecyclePolicy(command, input.storage);
|
|
87
|
+
const gate = gatedLifecyclePolicy(command, input.storage, input.runId);
|
|
71
88
|
if (gate) {
|
|
72
89
|
return deny(blockedCommand, gate.policy, gate.gate, gate.nextAction);
|
|
73
90
|
}
|
|
91
|
+
const override = activeMaintainerOverride(input.storage, lifecycleOverrideScope(command), input.runId);
|
|
92
|
+
if (override && matchesHookAllowlist(command)) {
|
|
93
|
+
return {
|
|
94
|
+
allow: true,
|
|
95
|
+
matchedPolicy: `maintainer_override:${override.scope}`,
|
|
96
|
+
blockedCommand,
|
|
97
|
+
nextAction: "Continue.",
|
|
98
|
+
reason: `Maintainer override ${override.decisionId} allows ${blockedCommand} until ${override.expiresAt}.`,
|
|
99
|
+
auditDetails: {
|
|
100
|
+
overrideDecisionId: override.decisionId,
|
|
101
|
+
overrideScope: override.scope,
|
|
102
|
+
overrideExpiresAt: override.expiresAt
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
}
|
|
74
106
|
if (!matchesHookAllowlist(command)) {
|
|
75
107
|
return deny(blockedCommand, "command_not_in_hook_allowlist", "policy_violation", "Use agent-loop MCP/CLI control surfaces or an allowlisted read/check command.");
|
|
76
108
|
}
|
|
@@ -123,7 +155,13 @@ export function evaluatePreToolUseHook(payload: unknown, repoRoot?: string): Hoo
|
|
|
123
155
|
try {
|
|
124
156
|
const config = loadConfig(route.binding.repoRoot).config;
|
|
125
157
|
storage = new SqliteAgentLoopStorage(statePath(route.binding.repoRoot));
|
|
126
|
-
const decision = evaluateHookPolicy({
|
|
158
|
+
const decision = evaluateHookPolicy({
|
|
159
|
+
repoRoot: route.binding.repoRoot,
|
|
160
|
+
command,
|
|
161
|
+
storage,
|
|
162
|
+
...(route.binding.runId ? { runId: route.binding.runId } : {}),
|
|
163
|
+
protectedPaths: config.protectedPaths
|
|
164
|
+
});
|
|
127
165
|
recordHookDecision(storage, decision, route.binding.runId);
|
|
128
166
|
return decision;
|
|
129
167
|
} catch (error) {
|
|
@@ -147,10 +185,8 @@ export function toCodexHookResponse(decision: HookPolicyDecision): Record<string
|
|
|
147
185
|
return { continue: true };
|
|
148
186
|
}
|
|
149
187
|
return {
|
|
150
|
-
decision: "
|
|
151
|
-
|
|
152
|
-
continue: false,
|
|
153
|
-
stopReason: decision.reason,
|
|
188
|
+
decision: "block",
|
|
189
|
+
reason: decision.reason,
|
|
154
190
|
systemMessage: formatHookMessage(decision)
|
|
155
191
|
};
|
|
156
192
|
}
|
|
@@ -166,6 +202,7 @@ function recordHookDecision(storage: AgentLoopStorage, decision: HookPolicyDecis
|
|
|
166
202
|
allow: decision.allow,
|
|
167
203
|
matchedPolicy: decision.matchedPolicy,
|
|
168
204
|
...(decision.gate ? { gate: decision.gate } : {}),
|
|
205
|
+
...(decision.auditDetails ? { auditDetails: decision.auditDetails } : {}),
|
|
169
206
|
nextAction: decision.nextAction,
|
|
170
207
|
commandLength: command.length,
|
|
171
208
|
commandSha256: createHash("sha256").update(command).digest("hex"),
|
|
@@ -175,9 +212,11 @@ function recordHookDecision(storage: AgentLoopStorage, decision: HookPolicyDecis
|
|
|
175
212
|
}
|
|
176
213
|
|
|
177
214
|
function routeErrorDecision(command: HookCommand, reason: string): HookPolicyDecision {
|
|
178
|
-
const
|
|
215
|
+
const baseCommand = normalizeCommand(command);
|
|
216
|
+
const shellControl = shellControlPolicy(baseCommand);
|
|
217
|
+
const normalized = shellControl ? baseCommand : unwrapCommand(baseCommand);
|
|
179
218
|
const blockedCommand = renderCommand(normalized);
|
|
180
|
-
const destructive = destructivePolicy(normalized);
|
|
219
|
+
const destructive = shellControl ?? destructivePolicy(normalized);
|
|
181
220
|
if (destructive || lifecycleCommand(normalized)) {
|
|
182
221
|
return deny(
|
|
183
222
|
blockedCommand,
|
|
@@ -196,9 +235,11 @@ function routeErrorDecision(command: HookCommand, reason: string): HookPolicyDec
|
|
|
196
235
|
}
|
|
197
236
|
|
|
198
237
|
function routeSessionMismatchDecision(command: HookCommand, reason: string): HookPolicyDecision {
|
|
199
|
-
const
|
|
238
|
+
const baseCommand = normalizeCommand(command);
|
|
239
|
+
const shellControl = shellControlPolicy(baseCommand);
|
|
240
|
+
const normalized = shellControl ? baseCommand : unwrapCommand(baseCommand);
|
|
200
241
|
const blockedCommand = renderCommand(normalized);
|
|
201
|
-
const destructive = destructivePolicy(normalized);
|
|
242
|
+
const destructive = shellControl ?? destructivePolicy(normalized);
|
|
202
243
|
if (destructive || lifecycleCommand(normalized)) {
|
|
203
244
|
return deny(
|
|
204
245
|
blockedCommand,
|
|
@@ -222,7 +263,7 @@ function lifecycleCommand(command: HookCommand): boolean {
|
|
|
222
263
|
command.file === "gh" && command.args[0] === "pr" && ["create", "ready", "merge"].includes(command.args[1] ?? "");
|
|
223
264
|
}
|
|
224
265
|
|
|
225
|
-
function gatedLifecyclePolicy(command: HookCommand, storage?: AgentLoopStorage): { policy: string; gate: AgentLoopGateKind; nextAction: string } | undefined {
|
|
266
|
+
function gatedLifecyclePolicy(command: HookCommand, storage?: AgentLoopStorage, runId?: string): { policy: string; gate: AgentLoopGateKind; nextAction: string } | undefined {
|
|
226
267
|
const args = stripGitGlobalOptions(command.args);
|
|
227
268
|
const lifecycleCommand = command.file === "git" && args[0] === "commit" ||
|
|
228
269
|
command.file === "git" && args[0] === "push" ||
|
|
@@ -238,22 +279,24 @@ function gatedLifecyclePolicy(command: HookCommand, storage?: AgentLoopStorage):
|
|
|
238
279
|
};
|
|
239
280
|
}
|
|
240
281
|
const current = storage.getCurrentStatus();
|
|
241
|
-
const
|
|
242
|
-
|
|
282
|
+
const run = runId ? storage.getRun(runId) : current.run;
|
|
283
|
+
const state = run?.currentState;
|
|
284
|
+
const override = activeMaintainerOverride(storage, lifecycleOverrideScope(command), runId);
|
|
285
|
+
if (command.file === "git" && (args[0] === "commit" || args[0] === "push") && state !== "COMMIT_PUSH_PR" && !override) {
|
|
243
286
|
return {
|
|
244
287
|
policy: "commit_push_state_gate",
|
|
245
288
|
gate: current.gate?.kind ?? "policy_violation",
|
|
246
289
|
nextAction: "Resume agent-loop until COMMIT_PUSH_PR owns publishing."
|
|
247
290
|
};
|
|
248
291
|
}
|
|
249
|
-
if (command.file === "git" && (args[0] === "commit" || args[0] === "push") && !publishPrerequisitesSatisfied(storage)) {
|
|
292
|
+
if (command.file === "git" && (args[0] === "commit" || args[0] === "push") && !publishPrerequisitesSatisfied(storage, runId)) {
|
|
250
293
|
return {
|
|
251
294
|
policy: "commit_push_prerequisite_gate",
|
|
252
295
|
gate: "policy_violation",
|
|
253
296
|
nextAction: "Run SELF_CHECK and GitNexus detect_changes through agent-loop before publishing."
|
|
254
297
|
};
|
|
255
298
|
}
|
|
256
|
-
if (command.file === "gh" && command.args[0] === "pr" && command.args[1] === "merge" && state !== "MERGE") {
|
|
299
|
+
if (command.file === "gh" && command.args[0] === "pr" && command.args[1] === "merge" && state !== "MERGE" && !override) {
|
|
257
300
|
return {
|
|
258
301
|
policy: "merge_state_gate",
|
|
259
302
|
gate: current.gate?.kind ?? "merge_requires_confirmation",
|
|
@@ -263,6 +306,45 @@ function gatedLifecyclePolicy(command: HookCommand, storage?: AgentLoopStorage):
|
|
|
263
306
|
return undefined;
|
|
264
307
|
}
|
|
265
308
|
|
|
309
|
+
function lifecycleOverrideScope(command: HookCommand): MaintainerOverrideScope | undefined {
|
|
310
|
+
const args = stripGitGlobalOptions(command.args);
|
|
311
|
+
if (command.file === "git" && (args[0] === "commit" || args[0] === "push")) {
|
|
312
|
+
return "publish";
|
|
313
|
+
}
|
|
314
|
+
if (command.file === "gh" && command.args[0] === "pr" && command.args[1] === "merge") {
|
|
315
|
+
return "merge";
|
|
316
|
+
}
|
|
317
|
+
return undefined;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function activeMaintainerOverride(storage: AgentLoopStorage | undefined, scope: MaintainerOverrideScope | undefined, runId?: string): ActiveMaintainerOverride | undefined {
|
|
321
|
+
if (!storage || !scope) {
|
|
322
|
+
return undefined;
|
|
323
|
+
}
|
|
324
|
+
const run = runId ? storage.getRun(runId) : storage.getCurrentRun();
|
|
325
|
+
if (!run) {
|
|
326
|
+
return undefined;
|
|
327
|
+
}
|
|
328
|
+
return storage.listDecisions(run.id)
|
|
329
|
+
.map((decision) => {
|
|
330
|
+
const details = objectDetails(decision.details);
|
|
331
|
+
const overrideScope = stringValue(details?.scope);
|
|
332
|
+
const expiresAt = stringValue(details?.expiresAt);
|
|
333
|
+
if (decision.kind !== "maintainer_override_approved" || !overrideScope || !expiresAt) {
|
|
334
|
+
return undefined;
|
|
335
|
+
}
|
|
336
|
+
if (overrideScope !== scope) {
|
|
337
|
+
return undefined;
|
|
338
|
+
}
|
|
339
|
+
const expiresAtMs = Date.parse(expiresAt);
|
|
340
|
+
if (!Number.isFinite(expiresAtMs) || expiresAtMs <= Date.now()) {
|
|
341
|
+
return undefined;
|
|
342
|
+
}
|
|
343
|
+
return { decisionId: decision.id, scope, expiresAt };
|
|
344
|
+
})
|
|
345
|
+
.find((override): override is ActiveMaintainerOverride => override !== undefined);
|
|
346
|
+
}
|
|
347
|
+
|
|
266
348
|
function destructivePolicy(command: HookCommand): string | undefined {
|
|
267
349
|
const args = stripGitGlobalOptions(command.args);
|
|
268
350
|
if (command.file === "git" && args[0] === "reset" && args.includes("--hard")) {
|
|
@@ -271,7 +353,11 @@ function destructivePolicy(command: HookCommand): string | undefined {
|
|
|
271
353
|
if (command.file === "git" && args[0] === "clean" && args.some((arg) => /^-.*f/.test(arg))) {
|
|
272
354
|
return "destructive_git_clean";
|
|
273
355
|
}
|
|
274
|
-
if (command.file === "git" && args[0] === "push" && args.some((arg) =>
|
|
356
|
+
if (command.file === "git" && args[0] === "push" && args.some((arg) =>
|
|
357
|
+
["-f", "-d", "--force", "--force-with-lease", "--mirror", "--delete"].includes(arg) ||
|
|
358
|
+
arg.startsWith("+") ||
|
|
359
|
+
/^:[^:]+/.test(arg)
|
|
360
|
+
)) {
|
|
275
361
|
return "destructive_git_force_push";
|
|
276
362
|
}
|
|
277
363
|
if (command.file === "gh" && command.args[0] === "repo" && command.args[1] === "delete") {
|
|
@@ -304,24 +390,33 @@ function protectedPathPolicy(command: HookCommand, protectedPaths: string[]): st
|
|
|
304
390
|
|
|
305
391
|
function matchesHookAllowlist(command: HookCommand): boolean {
|
|
306
392
|
const args = stripGitGlobalOptions(command.args);
|
|
393
|
+
if (command.file === "rg" && matchesRipgrepAllowlist(command.args) || isApplyPatchCommand(command)) {
|
|
394
|
+
return true;
|
|
395
|
+
}
|
|
307
396
|
if (command.file === "git") {
|
|
308
397
|
return args[0] === "status" ||
|
|
309
398
|
args[0] === "branch" && args[1] === "--show-current" ||
|
|
310
399
|
args[0] === "rev-parse" ||
|
|
311
400
|
args[0] === "diff" ||
|
|
401
|
+
["log", "show"].includes(args[0] ?? "") ||
|
|
402
|
+
args[0] === "grep" && matchesGitGrepAllowlist(args.slice(1)) ||
|
|
403
|
+
args[0] === "switch" && args.length === 2 && typeof args[1] === "string" && !args[1].startsWith("-") ||
|
|
312
404
|
args[0] === "add" && args[1] === "--" ||
|
|
313
405
|
args[0] === "commit" && args[1] === "-m" ||
|
|
314
|
-
args[0] === "push" && args
|
|
406
|
+
args[0] === "push" && matchesGitPushAllowlist(args.slice(1));
|
|
315
407
|
}
|
|
316
408
|
if (command.file === "gh") {
|
|
317
409
|
return command.args[0] === "auth" && command.args[1] === "status" ||
|
|
318
|
-
command.args[0] === "pr" && ["list", "view"].includes(command.args[1] ?? "") ||
|
|
410
|
+
command.args[0] === "pr" && ["list", "view", "checks"].includes(command.args[1] ?? "") ||
|
|
411
|
+
command.args[0] === "pr" && command.args[1] === "merge" && matchesGhPrMergeAllowlist(command.args.slice(2)) ||
|
|
319
412
|
command.args[0] === "api" && command.args[1] === "graphql";
|
|
320
413
|
}
|
|
321
414
|
if (command.file === "pnpm") {
|
|
322
415
|
return command.args[0] === "test" ||
|
|
323
416
|
command.args[0] === "lint" ||
|
|
324
|
-
command.args[0] === "
|
|
417
|
+
command.args[0] === "build:hooks" ||
|
|
418
|
+
command.args[0] === "build:mcp" ||
|
|
419
|
+
command.args[0] === "agent-loop" && matchesAgentLoopAllowlist(command.args.slice(1));
|
|
325
420
|
}
|
|
326
421
|
if (command.file === "npx") {
|
|
327
422
|
return command.args[0] === "gitnexus" &&
|
|
@@ -333,6 +428,87 @@ function matchesHookAllowlist(command: HookCommand): boolean {
|
|
|
333
428
|
return false;
|
|
334
429
|
}
|
|
335
430
|
|
|
431
|
+
function matchesRipgrepAllowlist(args: string[]): boolean {
|
|
432
|
+
return !args.some((arg) => arg === "--pre" || arg.startsWith("--pre="));
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
function matchesGitGrepAllowlist(args: string[]): boolean {
|
|
436
|
+
return !args.some((arg) =>
|
|
437
|
+
arg === "-O" ||
|
|
438
|
+
arg.startsWith("-O") ||
|
|
439
|
+
arg === "--open-files-in-pager" ||
|
|
440
|
+
arg.startsWith("--open-files-in-pager=")
|
|
441
|
+
);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
function matchesGitPushAllowlist(args: string[]): boolean {
|
|
445
|
+
return args.length >= 3 &&
|
|
446
|
+
args[0] === "-u" &&
|
|
447
|
+
args.every((arg) => !["-f", "-d", "--force", "--force-with-lease", "--mirror", "--delete"].includes(arg) && !arg.startsWith("+") && !/^:[^:]+/.test(arg));
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function matchesGhPrMergeAllowlist(args: string[]): boolean {
|
|
451
|
+
const allowedFlags = new Set(["--merge", "--squash", "--rebase", "--body", "--subject"]);
|
|
452
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
453
|
+
const arg = args[index] ?? "";
|
|
454
|
+
if (["--admin", "--auto", "--delete-branch", "-d"].includes(arg)) {
|
|
455
|
+
return false;
|
|
456
|
+
}
|
|
457
|
+
if (arg.startsWith("--") && !allowedFlags.has(arg)) {
|
|
458
|
+
return false;
|
|
459
|
+
}
|
|
460
|
+
if ((arg === "--body" || arg === "--subject") && args[index + 1]) {
|
|
461
|
+
index += 1;
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
return args.some((arg) => ["--merge", "--squash", "--rebase"].includes(arg));
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
function isApplyPatchCommand(command: HookCommand): boolean {
|
|
468
|
+
return command.file === "apply_patch" || command.raw?.startsWith("*** Begin Patch") === true;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
function matchesAgentLoopAllowlist(args: string[]): boolean {
|
|
472
|
+
if (["status", "doctor", "logs", "observe", "timeline", "workers", "stop"].includes(args[0] ?? "")) {
|
|
473
|
+
return true;
|
|
474
|
+
}
|
|
475
|
+
if (args[0] === "local") {
|
|
476
|
+
return args[1] === "doctor";
|
|
477
|
+
}
|
|
478
|
+
if (args[0] === "hooks") {
|
|
479
|
+
return ["doctor", "list"].includes(args[1] ?? "");
|
|
480
|
+
}
|
|
481
|
+
if (args[0] === "delivery") {
|
|
482
|
+
return ["bind", "stage"].includes(args[1] ?? "");
|
|
483
|
+
}
|
|
484
|
+
if (args[0] === "evidence") {
|
|
485
|
+
return args[1] === "append";
|
|
486
|
+
}
|
|
487
|
+
if (args[0] === "maintainer-override") {
|
|
488
|
+
return args[1] === "approve";
|
|
489
|
+
}
|
|
490
|
+
return false;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
function shellControlPolicy(command: HookCommand): string | undefined {
|
|
494
|
+
if (isApplyPatchCommand(command)) {
|
|
495
|
+
return undefined;
|
|
496
|
+
}
|
|
497
|
+
if (command.raw && hasShellControlOperator(command.raw)) {
|
|
498
|
+
return "shell_control_operator_forbidden";
|
|
499
|
+
}
|
|
500
|
+
if (command.file === "env") {
|
|
501
|
+
const index = command.args.findIndex((arg) => !arg.includes("="));
|
|
502
|
+
if (index >= 0) {
|
|
503
|
+
return shellControlPolicy({ file: basename(command.args[index] ?? ""), args: command.args.slice(index + 1) });
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
if ((command.file === "sh" || command.file === "bash") && command.args[0] === "-c" && command.args[1] && hasShellControlOperator(command.args[1])) {
|
|
507
|
+
return "shell_control_operator_forbidden";
|
|
508
|
+
}
|
|
509
|
+
return undefined;
|
|
510
|
+
}
|
|
511
|
+
|
|
336
512
|
function deny(
|
|
337
513
|
blockedCommand: string,
|
|
338
514
|
matchedPolicy: string,
|
|
@@ -385,6 +561,10 @@ function tokenizeCommand(command: string): HookCommand {
|
|
|
385
561
|
return { file: basename(file), args, raw: command };
|
|
386
562
|
}
|
|
387
563
|
|
|
564
|
+
function hasShellControlOperator(value: string): boolean {
|
|
565
|
+
return /&&|\|\||[;|<>\n\r]/.test(value);
|
|
566
|
+
}
|
|
567
|
+
|
|
388
568
|
function stripGitGlobalOptions(args: string[]): string[] {
|
|
389
569
|
const result = [...args];
|
|
390
570
|
while (result.length > 0) {
|
|
@@ -406,12 +586,35 @@ function stripGitGlobalOptions(args: string[]): string[] {
|
|
|
406
586
|
return result;
|
|
407
587
|
}
|
|
408
588
|
|
|
409
|
-
function publishPrerequisitesSatisfied(storage: AgentLoopStorage): boolean {
|
|
410
|
-
const run = storage.getCurrentRun();
|
|
589
|
+
function publishPrerequisitesSatisfied(storage: AgentLoopStorage, runId?: string): boolean {
|
|
590
|
+
const run = runId ? storage.getRun(runId) : storage.getCurrentRun();
|
|
411
591
|
if (!run) {
|
|
412
592
|
return false;
|
|
413
593
|
}
|
|
414
|
-
|
|
594
|
+
if (storage.hasRunCheck(run.id, "self_check") && storage.hasRunCheck(run.id, "gitnexus_detect_changes")) {
|
|
595
|
+
return true;
|
|
596
|
+
}
|
|
597
|
+
return publishWorkflowEvidenceSatisfied(storage, run.id);
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
function publishWorkflowEvidenceSatisfied(storage: AgentLoopStorage, runId: string): boolean {
|
|
601
|
+
const completed = new Set<string>();
|
|
602
|
+
for (const event of storage.listEvents(200)) {
|
|
603
|
+
const payload = objectDetails(event.payload);
|
|
604
|
+
if (
|
|
605
|
+
event.runId !== runId ||
|
|
606
|
+
event.kind !== "workflow_stage_evidence" ||
|
|
607
|
+
stringValue(payload?.stageId) !== "verify" ||
|
|
608
|
+
stringValue(payload?.status) !== "done"
|
|
609
|
+
) {
|
|
610
|
+
continue;
|
|
611
|
+
}
|
|
612
|
+
const substageId = stringValue(payload?.substageId);
|
|
613
|
+
if (substageId) {
|
|
614
|
+
completed.add(substageId);
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
return REQUIRED_PUBLISH_EVIDENCE_SUBSTAGES.every((substageId) => completed.has(substageId));
|
|
415
618
|
}
|
|
416
619
|
|
|
417
620
|
function basename(value: string): string {
|
|
@@ -421,3 +624,7 @@ function basename(value: string): string {
|
|
|
421
624
|
function stringValue(value: unknown): string | undefined {
|
|
422
625
|
return typeof value === "string" && value.length > 0 ? value : undefined;
|
|
423
626
|
}
|
|
627
|
+
|
|
628
|
+
function objectDetails(value: unknown): Record<string, unknown> | undefined {
|
|
629
|
+
return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record<string, unknown> : undefined;
|
|
630
|
+
}
|
|
@@ -602,6 +602,9 @@ export class SqliteAgentLoopStorage implements AgentLoopStorage {
|
|
|
602
602
|
}
|
|
603
603
|
|
|
604
604
|
const run = this.getRun(runId);
|
|
605
|
+
if (!run) {
|
|
606
|
+
throw new AgentLoopError("storage_error", `Run not found: ${runId}`);
|
|
607
|
+
}
|
|
605
608
|
this.db
|
|
606
609
|
.prepare(
|
|
607
610
|
`insert into states (run_id, status, state, version, payload_json, created_at)
|
|
@@ -1562,7 +1565,7 @@ export class SqliteAgentLoopStorage implements AgentLoopStorage {
|
|
|
1562
1565
|
return row.user_version;
|
|
1563
1566
|
}
|
|
1564
1567
|
|
|
1565
|
-
|
|
1568
|
+
getRun(runId: string): AgentLoopRun | undefined {
|
|
1566
1569
|
const row = this.db
|
|
1567
1570
|
.prepare(
|
|
1568
1571
|
`select id, status, current_state, version, branch, worktree_clean, started_at, stopped_at, created_at, updated_at
|
|
@@ -1570,10 +1573,7 @@ export class SqliteAgentLoopStorage implements AgentLoopStorage {
|
|
|
1570
1573
|
where id = ?`
|
|
1571
1574
|
)
|
|
1572
1575
|
.get(runId) as RunRow | undefined;
|
|
1573
|
-
|
|
1574
|
-
throw new AgentLoopError("storage_error", `Run not found: ${runId}`);
|
|
1575
|
-
}
|
|
1576
|
-
return fromRunRow(row);
|
|
1576
|
+
return row ? fromRunRow(row) : undefined;
|
|
1577
1577
|
}
|
|
1578
1578
|
|
|
1579
1579
|
private getActiveRun(): AgentLoopRun | undefined {
|