opencode-workflow-guard 1.5.1 → 1.7.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/README.md +15 -6
- package/bin/opencode-workflow-guard.mjs +59 -0
- package/package.json +7 -2
- package/src/lib/audit.ts +82 -7
- package/src/lib/checkpoint.ts +308 -0
- package/src/lib/custom-tools.ts +198 -0
- package/src/lib/guard-dispatcher.ts +478 -0
- package/src/lib/project-config.ts +68 -0
- package/src/lib/project-memory.ts +46 -3
- package/src/lib/shell.ts +399 -0
- package/src/lib/state.ts +23 -102
- package/src/lib/tool-lifecycle.ts +63 -0
- package/src/lib/tool-outcomes.ts +64 -0
- package/src/lib/types.ts +36 -0
- package/src/lib/utils.ts +13 -380
- package/src/lib/verify.ts +69 -8
- package/src/policies/boundary.ts +3 -3
- package/src/policies/changelog.ts +2 -2
- package/src/policies/continuation.ts +5 -0
- package/src/policies/destructive.ts +2 -1
- package/src/policies/file-claims.ts +56 -0
- package/src/policies/git.ts +1 -1
- package/src/policies/post-edit-validation.ts +76 -0
- package/src/policies/secrets.ts +1 -1
- package/src/policies/stale-write.ts +70 -0
- package/src/policies/tamper.ts +1 -1
- package/src/policies/todo.ts +25 -15
- package/src/workflow-guard-ui.ts +75 -0
- package/src/workflow-guard.ts +171 -1118
package/README.md
CHANGED
|
@@ -12,7 +12,13 @@ OpenCode plugin enforcing workflow discipline, agent focus, and deterministic sa
|
|
|
12
12
|
|
|
13
13
|
### 1. Installation
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
Run the setup command to register both the server guard and TUI companion globally:
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
npx opencode-workflow-guard setup
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
For server-only setup, add the package to your project's `opencode.json` or global `~/.config/opencode/opencode.json`:
|
|
16
22
|
|
|
17
23
|
```json
|
|
18
24
|
{
|
|
@@ -23,9 +29,9 @@ Add to your project's `opencode.json` or global `~/.config/opencode/opencode.jso
|
|
|
23
29
|
}
|
|
24
30
|
```
|
|
25
31
|
|
|
26
|
-
*Requires OpenCode >= 1.18.* See [docs/installation.md](docs/installation.md) for full setup options
|
|
32
|
+
*Requires OpenCode >= 1.18.* Restart OpenCode after setup. See [docs/installation.md](docs/installation.md) for full setup options.
|
|
27
33
|
|
|
28
|
-
For the optional TUI badge, configure `"opencode-workflow-guard"` in `tui.json
|
|
34
|
+
For the optional TUI badge, configure `"opencode-workflow-guard/tui"` in `tui.json`. The package root is the server plugin; TUI plugin specs are resolved as written and must use the exported `./tui` entrypoint. Do not place the TUI module under the server `plugins/` directory.
|
|
29
35
|
|
|
30
36
|
### 2. Verification
|
|
31
37
|
|
|
@@ -43,7 +49,7 @@ WORKFLOW_GUARD_LIVE_E2E=1 npm run test:install # Also run model-driven OpenCode
|
|
|
43
49
|
The plugin enforces **24 deterministic policy rules** across four main pillars:
|
|
44
50
|
|
|
45
51
|
* **Branch & History Protection:** Enforces feature-branch workflows (blocks edits/commits on `main`/`master`), hard-blocks direct or forced pushes to protected branches, enforces mergeability pre-flight checks, and requires PR changelogs / changesets.
|
|
46
|
-
* **Task & Verification Discipline:** Gates file mutations on active `todowrite` tasks with subagent inheritance, blocks silent task deletion, enforces fresh test verification evidence before task completion, and journals completion claims vs. verification mismatches.
|
|
52
|
+
* **Task & Verification Discipline:** Gates file mutations on active `todowrite` tasks with subagent inheritance, blocks concurrent direct edits to the same canonical file across sessions, requires same-session fresh reads before `edit`/`write` can replace existing files, blocks silent task deletion, enforces fresh test verification evidence before task completion, and journals completion claims vs. verification mismatches.
|
|
47
53
|
* **Secrets & Workspace Confinement:** Confines edit tools and recognized shell/git mutations within the workspace root (including symlink and `../` escape checks), scrubs sensitive environment variables in agent subshells, and redacts `.env` reads with safe schema masks. Arbitrary executables are not OS-sandboxed; use containers or filesystem isolation when hard confinement is required.
|
|
48
54
|
* **Destructive CLI & Environment Safety:** Intercepts destructive cloud/database/infrastructure commands, stops script laundering and interpreter evasion, blocks TTY hangs (`vim`, `nano`, `sudo`), and guards against dangerous package manager flags.
|
|
49
55
|
|
|
@@ -57,6 +63,7 @@ For complete policy specifications and override rules, see [docs/policies.md](do
|
|
|
57
63
|
|---|---|
|
|
58
64
|
| [**Policy Reference**](docs/policies.md) | Comprehensive specification of all 24 enforced policies, invariants, and override semantics |
|
|
59
65
|
| [**Installation & Configuration**](docs/installation.md) | Setup options, global vs. local install, worktree isolation, and project configuration |
|
|
66
|
+
| [**Managed Deployment**](docs/managed-deployment.md) | Administrator-managed OpenCode policy, platform locations, and startup diagnostics |
|
|
60
67
|
| [**Troubleshooting**](docs/troubleshooting.md) | Diagnosing policy blocks, common false positives, and emergency override procedures |
|
|
61
68
|
| [**Testing Architecture**](docs/testing.md) | Test harness design, adversarial regression matrix, and CI verification |
|
|
62
69
|
| [**Contributing Guide**](CONTRIBUTING.md) | Development workflow, coding conventions, test requirements, and changeset PR rules |
|
|
@@ -74,6 +81,8 @@ For complete policy specifications and override rules, see [docs/policies.md](do
|
|
|
74
81
|
| `guard_audit` | View recent audit log entries recorded in `workflow-guard.jsonl` |
|
|
75
82
|
| `guard_review_rubric` | Generate the 5-axis secondary review rubric for the current branch diff |
|
|
76
83
|
| `record_review` | Record a secondary reviewer subagent's verdict evaluated against the review rubric |
|
|
84
|
+
| `guard_review_followups` | List durable local P2/P3 review findings that remain open across sessions |
|
|
85
|
+
| `guard_review_followup_resolve` | Resolve a durable review finding after its underlying issue is fixed and verified |
|
|
77
86
|
| `guard_worktree_create` | Create an isolated git worktree directory for concurrent subagent execution |
|
|
78
87
|
| `guard_worktree_cleanup` | Snapshot-commit remaining changes and remove an isolated worktree directory |
|
|
79
88
|
| `learning_profile` | Inspect the evidence-based local learner profile (when learning mode is enabled) |
|
|
@@ -86,13 +95,13 @@ For complete policy specifications and override rules, see [docs/policies.md](do
|
|
|
86
95
|
|
|
87
96
|
### Experimental Socratic Learning
|
|
88
97
|
|
|
89
|
-
Learning mode is opt-in and advisory: it never blocks tool calls or weakens deterministic guardrails. Enable it
|
|
98
|
+
Learning mode is opt-in and advisory: it never blocks tool calls or weakens deterministic guardrails. Enable it with `learning: true` in `.opencode/workflow-guard.json[c]` or through `/guard-options`; `WORKFLOW_GUARD_LEARNING=1` in the user environment also enables it regardless of project configuration. `maxLearningInterventions` defaults to 3 per session and can be set to `0` to suppress checkpoints.
|
|
90
99
|
|
|
91
100
|
The learner profile is global to the local user and follows the XDG data convention: `$XDG_DATA_HOME/opencode/workflow-guard/learner-profile.json`, or `~/.local/share/opencode/workflow-guard/learner-profile.json` when `XDG_DATA_HOME` is unset. It stores distilled concept evidence, session/project provenance, and progression (`exposed` through `critique`), not conversation transcripts or numeric grades. Unobserved concepts remain unknown rather than being labeled knowledge gaps. Back up or delete this file independently from project repositories as desired.
|
|
92
101
|
|
|
93
102
|
### Project Memory
|
|
94
103
|
|
|
95
|
-
Project memory keeps durable working knowledge between sessions without treating conversation history as project truth. Facts, decisions, constraints, and lessons are stored in a local SQLite/FTS5 index under `$XDG_DATA_HOME/opencode/workflow-guard/project-memory/`, or `~/.local/share/opencode/workflow-guard/project-memory/` when `XDG_DATA_HOME` is unset. Git repositories are identified from their common Git directory, so linked worktrees share the same local project index.
|
|
104
|
+
Project memory keeps durable working knowledge between sessions without treating conversation history as project truth. It is enabled by default and can be disabled with `projectMemory: false` or through `/guard-options`. Facts, decisions, constraints, and lessons are stored in a local SQLite/FTS5 index under `$XDG_DATA_HOME/opencode/workflow-guard/project-memory/`, or `~/.local/share/opencode/workflow-guard/project-memory/` when `XDG_DATA_HOME` is unset. Git repositories are identified from their common Git directory, so linked worktrees share the same local project index.
|
|
96
105
|
|
|
97
106
|
The local index is private working memory. Nothing from it is committed automatically. `project_memory_export` promotes only the explicitly selected current record IDs to `.opencode/memory/project-memory.jsonl`, a human-readable portable representation that another clone can import. The plugin adds `.opencode/memory/` to the clone-local `.git/info/exclude` by default; teams that deliberately want to version promoted knowledge can remove that exclusion or force-add the JSONL file. SQLite databases themselves should not be committed.
|
|
98
107
|
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { parse, modify, applyEdits } from "jsonc-parser";
|
|
7
|
+
|
|
8
|
+
const SERVER_SPEC = "opencode-workflow-guard";
|
|
9
|
+
const TUI_SPEC = "opencode-workflow-guard/tui";
|
|
10
|
+
|
|
11
|
+
function configHome() {
|
|
12
|
+
return process.env.XDG_CONFIG_HOME || join(homedir(), ".config");
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function existingConfig(base, name) {
|
|
16
|
+
const json = join(base, name + ".json");
|
|
17
|
+
const jsonc = join(base, name + ".jsonc");
|
|
18
|
+
return existsSync(json) ? json : existsSync(jsonc) ? jsonc : json;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function preparePluginUpdate(path, schema, spec) {
|
|
22
|
+
const source = existsSync(path) ? readFileSync(path, "utf8") : `{
|
|
23
|
+
"$schema": "${schema}"
|
|
24
|
+
}\n`;
|
|
25
|
+
const errors = [];
|
|
26
|
+
const config = parse(source, errors, { allowTrailingComma: true, disallowComments: false });
|
|
27
|
+
if (errors.length > 0 || !config || typeof config !== "object" || Array.isArray(config)) {
|
|
28
|
+
throw new Error(`Cannot update invalid config: ${path}`);
|
|
29
|
+
}
|
|
30
|
+
if (config.plugin !== undefined && !Array.isArray(config.plugin)) {
|
|
31
|
+
throw new Error(`Cannot update ${path}: "plugin" must be an array`);
|
|
32
|
+
}
|
|
33
|
+
const plugins = config.plugin ?? [];
|
|
34
|
+
if (plugins.some((entry) => entry === spec || (Array.isArray(entry) && entry[0] === spec))) return undefined;
|
|
35
|
+
return applyEdits(source, modify(source, ["plugin", plugins.length], spec, {
|
|
36
|
+
formattingOptions: { insertSpaces: true, tabSize: 2 },
|
|
37
|
+
}));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (process.argv[2] !== "setup" || process.argv.length > 3) {
|
|
41
|
+
console.error("Usage: opencode-workflow-guard setup");
|
|
42
|
+
process.exitCode = 1;
|
|
43
|
+
} else {
|
|
44
|
+
try {
|
|
45
|
+
const base = join(configHome(), "opencode");
|
|
46
|
+
const serverPath = existingConfig(base, "opencode");
|
|
47
|
+
const tuiPath = existingConfig(base, "tui");
|
|
48
|
+
const serverUpdate = preparePluginUpdate(serverPath, "https://opencode.ai/config.json", SERVER_SPEC);
|
|
49
|
+
const tuiUpdate = preparePluginUpdate(tuiPath, "https://opencode.ai/tui.json", TUI_SPEC);
|
|
50
|
+
mkdirSync(base, { recursive: true });
|
|
51
|
+
if (serverUpdate !== undefined) writeFileSync(serverPath, serverUpdate);
|
|
52
|
+
if (tuiUpdate !== undefined) writeFileSync(tuiPath, tuiUpdate);
|
|
53
|
+
console.log(`Workflow Guard configured in ${serverPath} and ${tuiPath}.`);
|
|
54
|
+
console.log("Restart OpenCode to load both plugins.");
|
|
55
|
+
} catch (error) {
|
|
56
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
57
|
+
process.exitCode = 1;
|
|
58
|
+
}
|
|
59
|
+
}
|
package/package.json
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-workflow-guard",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.7.0",
|
|
4
4
|
"description": "OpenCode plugin enforcing workflow discipline through deterministic tool.execute.before hooks",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"main": "src/workflow-guard.ts",
|
|
8
|
+
"bin": {
|
|
9
|
+
"opencode-workflow-guard": "./bin/opencode-workflow-guard.mjs"
|
|
10
|
+
},
|
|
8
11
|
"exports": {
|
|
9
12
|
".": "./src/workflow-guard.ts",
|
|
10
13
|
"./server": "./src/workflow-guard.ts",
|
|
@@ -18,6 +21,7 @@
|
|
|
18
21
|
"url": "git+https://github.com/ultus-net/opencode-workflow-guard.git"
|
|
19
22
|
},
|
|
20
23
|
"files": [
|
|
24
|
+
"bin/",
|
|
21
25
|
"src/",
|
|
22
26
|
"README.md",
|
|
23
27
|
"LICENSE",
|
|
@@ -41,7 +45,8 @@
|
|
|
41
45
|
},
|
|
42
46
|
"dependencies": {
|
|
43
47
|
"@opencode-ai/plugin": "^1.18.21",
|
|
44
|
-
"@opentui/solid": "^0.5.6"
|
|
48
|
+
"@opentui/solid": "^0.5.6",
|
|
49
|
+
"jsonc-parser": "^3.3.1"
|
|
45
50
|
},
|
|
46
51
|
"devDependencies": {
|
|
47
52
|
"@changesets/cli": "^3.0.1",
|
package/src/lib/audit.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { appendFileSync, existsSync, mkdirSync, openSync, readSync, closeSync, statSync, readFileSync, writeFileSync } from "node:fs";
|
|
1
|
+
import { appendFileSync, chmodSync, existsSync, mkdirSync, openSync, readSync, closeSync, renameSync, statSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
2
3
|
import { homedir } from "node:os";
|
|
3
4
|
import { join } from "node:path";
|
|
4
5
|
import type { AuditEntry, VerifyResult } from "./types.ts";
|
|
@@ -11,6 +12,11 @@ const AUDIT_DIR = join(
|
|
|
11
12
|
);
|
|
12
13
|
const AUDIT_FILE = join(AUDIT_DIR, "workflow-guard.jsonl");
|
|
13
14
|
const VERIFY_CACHE_FILE = join(AUDIT_DIR, "last-verify.json");
|
|
15
|
+
const VERIFY_HISTORY_FILE = join(AUDIT_DIR, "verify-history.jsonl");
|
|
16
|
+
const MAX_AUDIT_BYTES = 4 * 1024 * 1024;
|
|
17
|
+
const RETAIN_AUDIT_BYTES = 2 * 1024 * 1024;
|
|
18
|
+
const MAX_VERIFY_HISTORY_BYTES = 1024 * 1024;
|
|
19
|
+
const RETAIN_VERIFY_HISTORY_BYTES = 512 * 1024;
|
|
14
20
|
|
|
15
21
|
export function getAuditFilePath(): string {
|
|
16
22
|
return AUDIT_FILE;
|
|
@@ -20,6 +26,65 @@ export function getVerifyCacheFilePath(): string {
|
|
|
20
26
|
return VERIFY_CACHE_FILE;
|
|
21
27
|
}
|
|
22
28
|
|
|
29
|
+
export function getVerifyHistoryFilePath(): string {
|
|
30
|
+
return VERIFY_HISTORY_FILE;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function appendBoundedJsonl(path: string, value: unknown, maxBytes: number, retainBytes: number): void {
|
|
34
|
+
appendFileSync(path, JSON.stringify(value) + "\n", { encoding: "utf8", mode: 0o600 });
|
|
35
|
+
chmodSync(path, 0o600);
|
|
36
|
+
const stat = statSync(path);
|
|
37
|
+
if (stat.size <= maxBytes) return;
|
|
38
|
+
const fd = openSync(path, "r");
|
|
39
|
+
const size = Math.min(retainBytes, stat.size);
|
|
40
|
+
const buffer = Buffer.alloc(size);
|
|
41
|
+
try {
|
|
42
|
+
readSync(fd, buffer, 0, size, stat.size - size);
|
|
43
|
+
} finally {
|
|
44
|
+
closeSync(fd);
|
|
45
|
+
}
|
|
46
|
+
const text = buffer.toString("utf8");
|
|
47
|
+
const firstNewline = text.indexOf("\n");
|
|
48
|
+
const retained = firstNewline >= 0 ? text.slice(firstNewline + 1) : "";
|
|
49
|
+
const temp = `${path}.${process.pid}.tmp`;
|
|
50
|
+
writeFileSync(temp, retained, { encoding: "utf8", mode: 0o600 });
|
|
51
|
+
renameSync(temp, path);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function persistVerifyHistory(verifyData: NonNullable<VerifyResult>): void {
|
|
55
|
+
try {
|
|
56
|
+
mkdirSync(AUDIT_DIR, { recursive: true });
|
|
57
|
+
if (existsSync(VERIFY_HISTORY_FILE)) {
|
|
58
|
+
const legacy = readFileSync(VERIFY_HISTORY_FILE, "utf8").split("\n").some((line) => {
|
|
59
|
+
if (!line.trim()) return false;
|
|
60
|
+
try {
|
|
61
|
+
const value = JSON.parse(line) as { command?: unknown; output?: unknown };
|
|
62
|
+
return typeof value.command !== "string" || !value.command.startsWith("sha256:") || typeof value.output !== "string" || !value.output.startsWith("sha256:");
|
|
63
|
+
} catch {
|
|
64
|
+
return true;
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
if (legacy) writeFileSync(VERIFY_HISTORY_FILE, "", { encoding: "utf8", mode: 0o600 });
|
|
68
|
+
}
|
|
69
|
+
appendBoundedJsonl(VERIFY_HISTORY_FILE, {
|
|
70
|
+
...verifyData,
|
|
71
|
+
command: `sha256:${createHash("sha256").update(verifyData.command).digest("hex")}`,
|
|
72
|
+
output: `sha256:${createHash("sha256").update(verifyData.output).digest("hex")}`,
|
|
73
|
+
}, MAX_VERIFY_HISTORY_BYTES, RETAIN_VERIFY_HISTORY_BYTES);
|
|
74
|
+
} catch {}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function getRecentVerifyHistory(limit = 10): NonNullable<VerifyResult>[] {
|
|
78
|
+
try {
|
|
79
|
+
if (!existsSync(VERIFY_HISTORY_FILE)) return [];
|
|
80
|
+
return readFileSync(VERIFY_HISTORY_FILE, "utf8").trim().split("\n").slice(-Math.max(0, limit)).reverse().flatMap((line) => {
|
|
81
|
+
try { return [JSON.parse(line) as NonNullable<VerifyResult>]; } catch { return []; }
|
|
82
|
+
});
|
|
83
|
+
} catch {
|
|
84
|
+
return [];
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
23
88
|
/**
|
|
24
89
|
* Persists passing verification evidence to disk so session restarts
|
|
25
90
|
* or multi-agent handoffs retain valid verification state.
|
|
@@ -27,7 +92,8 @@ export function getVerifyCacheFilePath(): string {
|
|
|
27
92
|
export function persistVerifyCache(verifyData: NonNullable<VerifyResult>): void {
|
|
28
93
|
try {
|
|
29
94
|
mkdirSync(AUDIT_DIR, { recursive: true });
|
|
30
|
-
writeFileSync(VERIFY_CACHE_FILE, JSON.stringify(verifyData, null, 2), "utf8");
|
|
95
|
+
writeFileSync(VERIFY_CACHE_FILE, JSON.stringify(verifyData, null, 2), { encoding: "utf8", mode: 0o600 });
|
|
96
|
+
chmodSync(VERIFY_CACHE_FILE, 0o600);
|
|
31
97
|
} catch {}
|
|
32
98
|
}
|
|
33
99
|
|
|
@@ -87,7 +153,7 @@ export function getRecentAuditEntries(limit = 10): AuditEntry[] {
|
|
|
87
153
|
export function audit(entry: AuditEntry): void {
|
|
88
154
|
try {
|
|
89
155
|
mkdirSync(AUDIT_DIR, { recursive: true });
|
|
90
|
-
|
|
156
|
+
appendBoundedJsonl(AUDIT_FILE, entry, MAX_AUDIT_BYTES, RETAIN_AUDIT_BYTES);
|
|
91
157
|
} catch {
|
|
92
158
|
// Logging must never break the guard.
|
|
93
159
|
}
|
|
@@ -96,15 +162,17 @@ export function audit(entry: AuditEntry): void {
|
|
|
96
162
|
export function logDecision(
|
|
97
163
|
tool: string,
|
|
98
164
|
input: unknown,
|
|
99
|
-
context: { sessionID?: string } | undefined,
|
|
165
|
+
context: { sessionID?: string; callID?: string } | undefined,
|
|
100
166
|
reason: string | undefined,
|
|
101
167
|
evidence?: AuditEntry["evidence"],
|
|
102
168
|
): void {
|
|
103
169
|
audit({
|
|
104
170
|
ts: new Date().toISOString(),
|
|
105
171
|
sessionID: context?.sessionID,
|
|
172
|
+
callID: context?.callID,
|
|
106
173
|
tool,
|
|
107
174
|
decision: reason ? "block" : "allow",
|
|
175
|
+
phase: "decision",
|
|
108
176
|
reason,
|
|
109
177
|
input: summarizeInput(input),
|
|
110
178
|
evidence,
|
|
@@ -113,7 +181,7 @@ export function logDecision(
|
|
|
113
181
|
|
|
114
182
|
export function summarizeInput(input: unknown): unknown {
|
|
115
183
|
const record = asRecord(input);
|
|
116
|
-
if (!record) return typeof input === "string" ? input
|
|
184
|
+
if (!record) return typeof input === "string" ? summarizeSensitiveText(input) : input;
|
|
117
185
|
if (typeof record.response === "string") {
|
|
118
186
|
return {
|
|
119
187
|
sessionID: record.sessionID,
|
|
@@ -121,9 +189,16 @@ export function summarizeInput(input: unknown): unknown {
|
|
|
121
189
|
response: record.response,
|
|
122
190
|
};
|
|
123
191
|
}
|
|
124
|
-
if (typeof record.command === "string") return { command: record.command
|
|
192
|
+
if (typeof record.command === "string") return { command: summarizeSensitiveText(record.command) };
|
|
125
193
|
if (typeof record.filePath === "string") return { filePath: record.filePath };
|
|
126
194
|
if (typeof record.path === "string") return { path: record.path };
|
|
127
|
-
if (typeof record.patchText === "string") return { patchText: record.patchText
|
|
195
|
+
if (typeof record.patchText === "string") return { patchText: summarizeSensitiveText(record.patchText) };
|
|
128
196
|
return { keys: Object.keys(record) };
|
|
129
197
|
}
|
|
198
|
+
|
|
199
|
+
function summarizeSensitiveText(value: string): { bytes: number; sha256: string } {
|
|
200
|
+
return {
|
|
201
|
+
bytes: Buffer.byteLength(value, "utf8"),
|
|
202
|
+
sha256: createHash("sha256").update(value).digest("hex"),
|
|
203
|
+
};
|
|
204
|
+
}
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { execFileSync } from "node:child_process";
|
|
3
|
+
import { closeSync, existsSync, mkdirSync, mkdtempSync, openSync, readFileSync, renameSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { dirname, join, resolve } from "node:path";
|
|
6
|
+
import { getGitWorktreeFingerprint } from "./verify.ts";
|
|
7
|
+
import { getCleanGitEnv } from "./worktree.ts";
|
|
8
|
+
|
|
9
|
+
export interface RecoveryCheckpoint {
|
|
10
|
+
sessionID: string;
|
|
11
|
+
run: number;
|
|
12
|
+
ref: string;
|
|
13
|
+
kind: "commit" | "stash";
|
|
14
|
+
createdAt: number;
|
|
15
|
+
endFingerprint?: string;
|
|
16
|
+
endAt?: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface CheckpointStore {
|
|
20
|
+
workspace: string;
|
|
21
|
+
checkpoints: RecoveryCheckpoint[];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const MAX_RECOVERY_CHECKPOINTS = 100;
|
|
25
|
+
|
|
26
|
+
const gitIdentityEnv = {
|
|
27
|
+
GIT_AUTHOR_NAME: "OpenCode Workflow Guard",
|
|
28
|
+
GIT_AUTHOR_EMAIL: "workflow-guard@localhost",
|
|
29
|
+
GIT_COMMITTER_NAME: "OpenCode Workflow Guard",
|
|
30
|
+
GIT_COMMITTER_EMAIL: "workflow-guard@localhost",
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
function git(workspace: string, args: string[], env: NodeJS.ProcessEnv = {}): string {
|
|
34
|
+
if (gitForTesting) return gitForTesting(workspace, args, env);
|
|
35
|
+
return execFileSync("git", ["-C", workspace, ...args], {
|
|
36
|
+
encoding: "utf8",
|
|
37
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
38
|
+
env: { ...getCleanGitEnv(), ...gitIdentityEnv, ...env },
|
|
39
|
+
}).trim();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
let gitForTesting: ((workspace: string, args: string[], env?: NodeJS.ProcessEnv) => string) | undefined;
|
|
43
|
+
|
|
44
|
+
export function setCheckpointGitForTesting(runner?: typeof gitForTesting): void {
|
|
45
|
+
gitForTesting = runner;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function gitDir(workspace: string): string {
|
|
49
|
+
const value = git(workspace, ["rev-parse", "--path-format=absolute", "--git-dir"]);
|
|
50
|
+
return resolve(workspace, value);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function metadataPath(workspace: string): string {
|
|
54
|
+
return join(gitDir(workspace), "workflow-guard", "recovery-checkpoints.json");
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function isRecoveryCheckpoint(value: unknown): value is RecoveryCheckpoint {
|
|
58
|
+
if (!value || typeof value !== "object") return false;
|
|
59
|
+
const entry = value as Record<string, unknown>;
|
|
60
|
+
return typeof entry.sessionID === "string"
|
|
61
|
+
&& Number.isInteger(entry.run) && (entry.run as number) >= 0
|
|
62
|
+
&& typeof entry.ref === "string" && entry.ref.length > 0
|
|
63
|
+
&& (entry.kind === "commit" || entry.kind === "stash")
|
|
64
|
+
&& typeof entry.createdAt === "number" && Number.isFinite(entry.createdAt)
|
|
65
|
+
&& (entry.endFingerprint === undefined || typeof entry.endFingerprint === "string")
|
|
66
|
+
&& (entry.endAt === undefined || (typeof entry.endAt === "number" && Number.isFinite(entry.endAt)));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function loadStore(workspace: string): CheckpointStore {
|
|
70
|
+
const path = metadataPath(workspace);
|
|
71
|
+
if (!existsSync(path)) return { workspace: resolve(workspace), checkpoints: [] };
|
|
72
|
+
try {
|
|
73
|
+
const value = JSON.parse(readFileSync(path, "utf8")) as CheckpointStore;
|
|
74
|
+
if (value.workspace === resolve(workspace) && Array.isArray(value.checkpoints) && value.checkpoints.every(isRecoveryCheckpoint)) return value;
|
|
75
|
+
} catch {}
|
|
76
|
+
throw new Error("Recovery checkpoint metadata is corrupt; refusing to overwrite existing recovery information.");
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function saveStore(workspace: string, store: CheckpointStore): void {
|
|
80
|
+
const path = metadataPath(workspace);
|
|
81
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
82
|
+
const temp = `${path}.${process.pid}.${randomUUID()}.tmp`;
|
|
83
|
+
writeFileSync(temp, JSON.stringify(store, null, 2));
|
|
84
|
+
renameSync(temp, path);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function withStoreLock<T>(workspace: string, action: () => T): T {
|
|
88
|
+
const path = metadataPath(workspace);
|
|
89
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
90
|
+
const lockPath = `${path}.lock`;
|
|
91
|
+
let lock: number;
|
|
92
|
+
try {
|
|
93
|
+
lock = openSync(lockPath, "wx", 0o600);
|
|
94
|
+
} catch {
|
|
95
|
+
throw new Error("Recovery checkpoint metadata is busy; refusing an unsafe concurrent update.");
|
|
96
|
+
}
|
|
97
|
+
try {
|
|
98
|
+
writeFileSync(lock, String(process.pid));
|
|
99
|
+
return action();
|
|
100
|
+
} finally {
|
|
101
|
+
closeSync(lock);
|
|
102
|
+
try { unlinkSync(lockPath); } catch {}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function updateStore<T>(workspace: string, update: (store: CheckpointStore) => T): T {
|
|
107
|
+
return withStoreLock(workspace, () => {
|
|
108
|
+
const store = loadStore(workspace);
|
|
109
|
+
const result = update(store);
|
|
110
|
+
saveStore(workspace, store);
|
|
111
|
+
return result;
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function pruneCheckpoints(workspace: string, store: CheckpointStore): void {
|
|
116
|
+
if (store.checkpoints.length <= MAX_RECOVERY_CHECKPOINTS) return;
|
|
117
|
+
const sorted = [...store.checkpoints].sort((a, b) => a.createdAt - b.createdAt);
|
|
118
|
+
const remove = sorted.slice(0, sorted.length - MAX_RECOVERY_CHECKPOINTS);
|
|
119
|
+
const removed = new Set(remove.map((entry) => `${entry.sessionID}\0${entry.run}`));
|
|
120
|
+
for (const entry of remove) {
|
|
121
|
+
try { git(workspace, ["update-ref", "-d", privateRef(entry.sessionID, entry.run)]); } catch {}
|
|
122
|
+
}
|
|
123
|
+
store.checkpoints = store.checkpoints.filter((entry) => !removed.has(`${entry.sessionID}\0${entry.run}`));
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function recoveryBoundaryFingerprint(workspace: string): string | undefined {
|
|
127
|
+
const worktree = getGitWorktreeFingerprint(workspace);
|
|
128
|
+
if (!worktree) return undefined;
|
|
129
|
+
try {
|
|
130
|
+
return createHash("sha256").update(git(workspace, ["rev-parse", "HEAD"])).update("\0").update(worktree).digest("hex");
|
|
131
|
+
} catch {
|
|
132
|
+
return undefined;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function privateRef(sessionID: string, run: number): string {
|
|
137
|
+
const session = createHash("sha256").update(sessionID).digest("hex");
|
|
138
|
+
return `refs/workflow-guard/checkpoints/${session}/${run}`;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function commitTree(workspace: string, tree: string, parents: string[], message: string): string {
|
|
142
|
+
const args = ["commit-tree", tree];
|
|
143
|
+
for (const parent of parents) args.push("-p", parent);
|
|
144
|
+
args.push("-m", message);
|
|
145
|
+
return git(workspace, args);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function captureUntracked(workspace: string): string | undefined {
|
|
149
|
+
const paths = execFileSync("git", ["-C", workspace, "ls-files", "--others", "--exclude-standard", "-z"], {
|
|
150
|
+
encoding: "buffer",
|
|
151
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
152
|
+
env: getCleanGitEnv(),
|
|
153
|
+
});
|
|
154
|
+
if (paths.length === 0) return undefined;
|
|
155
|
+
const temp = mkdtempSync(join(tmpdir(), "workflow-guard-checkpoint-"));
|
|
156
|
+
try {
|
|
157
|
+
const index = join(temp, "index");
|
|
158
|
+
const pathspec = join(temp, "paths");
|
|
159
|
+
writeFileSync(pathspec, paths);
|
|
160
|
+
const env = { GIT_INDEX_FILE: index };
|
|
161
|
+
git(workspace, ["add", "--force", `--pathspec-from-file=${pathspec}`, "--pathspec-file-nul"], env);
|
|
162
|
+
const tree = git(workspace, ["write-tree"], env);
|
|
163
|
+
return commitTree(workspace, tree, [], "untracked files on workflow-guard checkpoint");
|
|
164
|
+
} finally {
|
|
165
|
+
rmSync(temp, { recursive: true, force: true });
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export function createRecoveryCheckpoint(workspace: string, sessionID: string, run: number): RecoveryCheckpoint | undefined {
|
|
170
|
+
try {
|
|
171
|
+
const root = resolve(workspace);
|
|
172
|
+
return withStoreLock(root, () => {
|
|
173
|
+
const store = loadStore(root);
|
|
174
|
+
const existing = store.checkpoints.find((entry) => entry.sessionID === sessionID && entry.run === run);
|
|
175
|
+
if (existing) return existing;
|
|
176
|
+
const head = git(root, ["rev-parse", "HEAD"]);
|
|
177
|
+
const message = `workflow-guard checkpoint session=${sessionID} run=${run}`;
|
|
178
|
+
const tracked = git(root, ["stash", "create", message]);
|
|
179
|
+
const untracked = captureUntracked(root);
|
|
180
|
+
let ref = tracked || head;
|
|
181
|
+
let kind: RecoveryCheckpoint["kind"] = tracked ? "stash" : "commit";
|
|
182
|
+
if (untracked) {
|
|
183
|
+
let tree: string;
|
|
184
|
+
let base: string;
|
|
185
|
+
let indexParent: string;
|
|
186
|
+
if (tracked) {
|
|
187
|
+
tree = git(root, ["rev-parse", `${tracked}^{tree}`]);
|
|
188
|
+
base = git(root, ["rev-parse", `${tracked}^1`]);
|
|
189
|
+
indexParent = git(root, ["rev-parse", `${tracked}^2`]);
|
|
190
|
+
} else {
|
|
191
|
+
base = head;
|
|
192
|
+
tree = git(root, ["rev-parse", `${head}^{tree}`]);
|
|
193
|
+
indexParent = commitTree(root, tree, [head], `index on ${message}`);
|
|
194
|
+
}
|
|
195
|
+
ref = commitTree(root, tree, [base, indexParent, untracked], message);
|
|
196
|
+
kind = "stash";
|
|
197
|
+
}
|
|
198
|
+
git(root, ["update-ref", privateRef(sessionID, run), ref]);
|
|
199
|
+
const checkpoint: RecoveryCheckpoint = { sessionID, run, ref, kind, createdAt: Date.now() };
|
|
200
|
+
store.checkpoints.push(checkpoint);
|
|
201
|
+
pruneCheckpoints(root, store);
|
|
202
|
+
saveStore(root, store);
|
|
203
|
+
return checkpoint;
|
|
204
|
+
});
|
|
205
|
+
} catch {
|
|
206
|
+
return undefined;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export function finalizeRecoveryCheckpoint(workspace: string, sessionID: string, run: number): boolean {
|
|
211
|
+
try {
|
|
212
|
+
const endFingerprint = recoveryBoundaryFingerprint(workspace);
|
|
213
|
+
if (!endFingerprint) return false;
|
|
214
|
+
return updateStore(workspace, (store) => {
|
|
215
|
+
const checkpoint = store.checkpoints.find((entry) => entry.sessionID === sessionID && entry.run === run);
|
|
216
|
+
if (!checkpoint) return false;
|
|
217
|
+
checkpoint.endFingerprint = endFingerprint;
|
|
218
|
+
checkpoint.endAt = Date.now();
|
|
219
|
+
return true;
|
|
220
|
+
});
|
|
221
|
+
} catch {
|
|
222
|
+
return false;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export function listRecoveryCheckpoints(workspace: string, sessionID: string): RecoveryCheckpoint[] {
|
|
227
|
+
try {
|
|
228
|
+
return loadStore(workspace).checkpoints.filter((entry) => entry.sessionID === sessionID);
|
|
229
|
+
} catch {
|
|
230
|
+
return [];
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export function nextRecoveryRun(workspace: string, sessionID: string): number {
|
|
235
|
+
const runs = listRecoveryCheckpoints(workspace, sessionID).map((entry) => entry.run);
|
|
236
|
+
return runs.length ? Math.max(...runs) + 1 : 1;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
export function restoreRecoveryCheckpoint(
|
|
240
|
+
workspace: string,
|
|
241
|
+
sessionID: string,
|
|
242
|
+
run: number,
|
|
243
|
+
): { ok: boolean; error?: string } {
|
|
244
|
+
const root = resolve(workspace);
|
|
245
|
+
try {
|
|
246
|
+
return withStoreLock(root, () => {
|
|
247
|
+
let transactionRef: string | undefined;
|
|
248
|
+
let originalHead: string | undefined;
|
|
249
|
+
try {
|
|
250
|
+
const store = loadStore(root);
|
|
251
|
+
const checkpoint = store.checkpoints.find((entry) => entry.sessionID === sessionID && entry.run === run);
|
|
252
|
+
if (!checkpoint) return { ok: false, error: `No recovery checkpoint exists for run ${run}.` };
|
|
253
|
+
if (!checkpoint.endFingerprint) return { ok: false, error: `Run ${run} has not reached an idle recovery boundary.` };
|
|
254
|
+
if (recoveryBoundaryFingerprint(root) !== checkpoint.endFingerprint) {
|
|
255
|
+
return { ok: false, error: "Workspace changed after this run reached its recovery boundary; refusing to overwrite intervening work." };
|
|
256
|
+
}
|
|
257
|
+
if (!checkpoint.endAt || store.checkpoints.some((entry) => entry.sessionID !== sessionID && (!entry.endAt || entry.endAt >= checkpoint.createdAt))) {
|
|
258
|
+
return { ok: false, error: "Another session ran at or after this checkpoint; refusing cross-session recovery." };
|
|
259
|
+
}
|
|
260
|
+
git(root, ["cat-file", "-e", `${checkpoint.ref}^{commit}`]);
|
|
261
|
+
originalHead = git(root, ["rev-parse", "HEAD"]);
|
|
262
|
+
const transaction = `refs/workflow-guard/restore-transactions/${randomUUID()}`;
|
|
263
|
+
const beforeStash = (() => { try { return git(root, ["rev-parse", "refs/stash"]); } catch { return ""; } })();
|
|
264
|
+
git(root, ["stash", "push", "--include-untracked", "--message", "workflow-guard restore transaction"]);
|
|
265
|
+
const afterStash = (() => { try { return git(root, ["rev-parse", "refs/stash"]); } catch { return ""; } })();
|
|
266
|
+
if (afterStash && afterStash !== beforeStash) {
|
|
267
|
+
git(root, ["update-ref", transaction, afterStash]);
|
|
268
|
+
transactionRef = transaction;
|
|
269
|
+
git(root, ["stash", "drop", "stash@{0}"]);
|
|
270
|
+
}
|
|
271
|
+
git(root, ["clean", "-fd"]);
|
|
272
|
+
if (checkpoint.kind === "stash") {
|
|
273
|
+
const base = git(root, ["rev-parse", `${checkpoint.ref}^1`]);
|
|
274
|
+
git(root, ["reset", "--hard", base]);
|
|
275
|
+
git(root, ["stash", "apply", "--index", checkpoint.ref]);
|
|
276
|
+
} else {
|
|
277
|
+
git(root, ["reset", "--hard", checkpoint.ref]);
|
|
278
|
+
}
|
|
279
|
+
if (transactionRef) git(root, ["update-ref", "-d", transactionRef]);
|
|
280
|
+
return { ok: true };
|
|
281
|
+
} catch (error) {
|
|
282
|
+
let rollbackError: unknown;
|
|
283
|
+
if (originalHead) {
|
|
284
|
+
try {
|
|
285
|
+
git(root, ["reset", "--hard", originalHead]);
|
|
286
|
+
if (transactionRef) {
|
|
287
|
+
git(root, ["clean", "-fd"]);
|
|
288
|
+
git(root, ["stash", "apply", "--index", transactionRef]);
|
|
289
|
+
try {
|
|
290
|
+
if (git(root, ["rev-parse", "refs/stash"]) === git(root, ["rev-parse", transactionRef])) {
|
|
291
|
+
git(root, ["stash", "drop", "stash@{0}"]);
|
|
292
|
+
}
|
|
293
|
+
} catch {}
|
|
294
|
+
git(root, ["update-ref", "-d", transactionRef]);
|
|
295
|
+
}
|
|
296
|
+
} catch (error) {
|
|
297
|
+
rollbackError = error;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
const primary = error instanceof Error ? error.message : String(error);
|
|
301
|
+
const rollback = rollbackError instanceof Error ? rollbackError.message : rollbackError === undefined ? undefined : String(rollbackError);
|
|
302
|
+
return { ok: false, error: rollback ? `${primary} Recovery rollback also failed: ${rollback}` : primary };
|
|
303
|
+
}
|
|
304
|
+
});
|
|
305
|
+
} catch (error) {
|
|
306
|
+
return { ok: false, error: error instanceof Error ? error.message : String(error) };
|
|
307
|
+
}
|
|
308
|
+
}
|