opencode-workflow-guard 1.1.5 → 1.3.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 +6 -3
- package/package.json +1 -1
- package/src/lib/audit.ts +110 -0
- package/src/lib/review.ts +45 -0
- package/src/lib/state.ts +210 -0
- package/src/lib/types.ts +75 -0
- package/src/lib/utils.ts +179 -0
- package/src/lib/verify.ts +169 -0
- package/src/lib/worktree.ts +216 -0
- package/src/policies/boundary.ts +248 -0
- package/src/policies/changelog.ts +80 -0
- package/src/policies/completion.ts +90 -0
- package/src/policies/destructive.ts +76 -0
- package/src/policies/docs.ts +53 -0
- package/src/policies/git.ts +288 -0
- package/src/policies/interpreter.ts +28 -0
- package/src/policies/mcp.ts +25 -0
- package/src/policies/secrets.ts +133 -0
- package/src/policies/shell-safety.ts +128 -0
- package/src/policies/tamper.ts +86 -0
- package/src/policies/todo.ts +106 -0
- package/src/workflow-guard-ui.ts +59 -3
- package/src/workflow-guard.ts +416 -2450
package/README.md
CHANGED
|
@@ -38,11 +38,13 @@ npm run test:all # Run full verification suite (typecheck + unit + live e2e)
|
|
|
38
38
|
```
|
|
39
39
|
opencode-workflow-guard/
|
|
40
40
|
├── src/ # Production plugin sources
|
|
41
|
-
│ ├── workflow-guard.ts #
|
|
42
|
-
│
|
|
41
|
+
│ ├── workflow-guard.ts # Server plugin entrypoint (hook orchestrator & public exports)
|
|
42
|
+
│ ├── workflow-guard-ui.ts # TUI companion prompt badge
|
|
43
|
+
│ ├── policies/ # Per-policy implementations (task gate, git, secrets, shell-safety, ...)
|
|
44
|
+
│ └── lib/ # Engine services (state, verify, audit, review, worktree, utils, types)
|
|
43
45
|
├── test/ # Test suites
|
|
44
46
|
│ ├── test.mts # 320+ in-memory unit & adversarial tests
|
|
45
|
-
│ └── test-e2e.mts #
|
|
47
|
+
│ └── test-e2e.mts # npm tarball resolution + live runtime OpenCode loader tests
|
|
46
48
|
├── docs/ # In-depth documentation & guides
|
|
47
49
|
│ ├── installation.md # Full setup options, worktrees & permissions
|
|
48
50
|
│ ├── policies.md # Detailed 23-policy reference & overrides
|
|
@@ -77,6 +79,7 @@ opencode-workflow-guard/
|
|
|
77
79
|
| **21** | **Documentation Synchronization** | Verifies documentation is updated when introducing new public features or policies. |
|
|
78
80
|
| **22** | **TTY Hang Guard** | Blocks interactive editors (`vim`, `nano`), pagers (`less`), and commands missing non-interactive flags (`npm init` without `-y`). |
|
|
79
81
|
| **23** | **Package Supply-Chain Hygiene** | Blocks destructive `npm audit fix --force`, global unversioned installs (`npm i -g`), and direct CLI `npm publish`. |
|
|
82
|
+
| **24** | **Completion Claims vs Evidence** | When the final response asserts completion/passing tests, the claim is compared against recorded verification evidence; failing, stale, or missing evidence is journaled (observability, never blocks). Also enriches `todowrite`'s tool description with the finalization-gate note via `tool.definition`. |
|
|
80
83
|
|
|
81
84
|
👉 **For complete policy specifications and override rules, see [docs/policies.md](docs/policies.md).**
|
|
82
85
|
|
package/package.json
CHANGED
package/src/lib/audit.ts
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import type { AuditEntry, VerifyResult } from "./types.ts";
|
|
5
|
+
import { asRecord } from "./utils.ts";
|
|
6
|
+
|
|
7
|
+
const AUDIT_DIR = join(
|
|
8
|
+
process.env.XDG_STATE_HOME ?? join(homedir(), ".local", "state"),
|
|
9
|
+
"opencode",
|
|
10
|
+
"workflow-guard",
|
|
11
|
+
);
|
|
12
|
+
const AUDIT_FILE = join(AUDIT_DIR, "workflow-guard.jsonl");
|
|
13
|
+
const VERIFY_CACHE_FILE = join(AUDIT_DIR, "last-verify.json");
|
|
14
|
+
|
|
15
|
+
export function getAuditFilePath(): string {
|
|
16
|
+
return AUDIT_FILE;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function getVerifyCacheFilePath(): string {
|
|
20
|
+
return VERIFY_CACHE_FILE;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Persists passing verification evidence to disk so session restarts
|
|
25
|
+
* or multi-agent handoffs retain valid verification state.
|
|
26
|
+
*/
|
|
27
|
+
export function persistVerifyCache(verifyData: NonNullable<VerifyResult>): void {
|
|
28
|
+
try {
|
|
29
|
+
mkdirSync(AUDIT_DIR, { recursive: true });
|
|
30
|
+
writeFileSync(VERIFY_CACHE_FILE, JSON.stringify(verifyData, null, 2), "utf8");
|
|
31
|
+
} catch {}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Loads durable verification evidence from disk if present and fresh.
|
|
36
|
+
*/
|
|
37
|
+
export function loadVerifyCache(): VerifyResult | undefined {
|
|
38
|
+
try {
|
|
39
|
+
if (!existsSync(VERIFY_CACHE_FILE)) return undefined;
|
|
40
|
+
const raw = readFileSync(VERIFY_CACHE_FILE, "utf8");
|
|
41
|
+
const data = JSON.parse(raw);
|
|
42
|
+
if (data && typeof data.command === "string" && typeof data.timestamp === "number") {
|
|
43
|
+
return data;
|
|
44
|
+
}
|
|
45
|
+
} catch {}
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function getRecentAuditEntries(limit = 10): AuditEntry[] {
|
|
50
|
+
try {
|
|
51
|
+
if (!existsSync(AUDIT_FILE)) return [];
|
|
52
|
+
const lines = readFileSync(AUDIT_FILE, "utf8").trim().split("\n");
|
|
53
|
+
const entries: AuditEntry[] = [];
|
|
54
|
+
for (let i = lines.length - 1; i >= 0 && entries.length < limit; i--) {
|
|
55
|
+
const line = lines[i]?.trim();
|
|
56
|
+
if (line) {
|
|
57
|
+
try {
|
|
58
|
+
entries.push(JSON.parse(line));
|
|
59
|
+
} catch {}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return entries;
|
|
63
|
+
} catch {
|
|
64
|
+
return [];
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function audit(entry: AuditEntry): void {
|
|
69
|
+
try {
|
|
70
|
+
mkdirSync(AUDIT_DIR, { recursive: true });
|
|
71
|
+
appendFileSync(AUDIT_FILE, JSON.stringify(entry) + "\n", "utf8");
|
|
72
|
+
} catch {
|
|
73
|
+
// Logging must never break the guard.
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function logDecision(
|
|
78
|
+
tool: string,
|
|
79
|
+
input: unknown,
|
|
80
|
+
context: { sessionID?: string } | undefined,
|
|
81
|
+
reason: string | undefined,
|
|
82
|
+
evidence?: AuditEntry["evidence"],
|
|
83
|
+
): void {
|
|
84
|
+
audit({
|
|
85
|
+
ts: new Date().toISOString(),
|
|
86
|
+
sessionID: context?.sessionID,
|
|
87
|
+
tool,
|
|
88
|
+
decision: reason ? "block" : "allow",
|
|
89
|
+
reason,
|
|
90
|
+
input: summarizeInput(input),
|
|
91
|
+
evidence,
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function summarizeInput(input: unknown): unknown {
|
|
96
|
+
const record = asRecord(input);
|
|
97
|
+
if (!record) return typeof input === "string" ? input.slice(0, 200) : input;
|
|
98
|
+
if (typeof record.response === "string") {
|
|
99
|
+
return {
|
|
100
|
+
sessionID: record.sessionID,
|
|
101
|
+
permissionID: record.permissionID,
|
|
102
|
+
response: record.response,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
if (typeof record.command === "string") return { command: record.command.slice(0, 200) };
|
|
106
|
+
if (typeof record.filePath === "string") return { filePath: record.filePath };
|
|
107
|
+
if (typeof record.path === "string") return { path: record.path };
|
|
108
|
+
if (typeof record.patchText === "string") return { patchText: record.patchText.slice(0, 200) };
|
|
109
|
+
return { keys: Object.keys(record) };
|
|
110
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import {
|
|
2
|
+
recordReviewResult,
|
|
3
|
+
getLastReviewResult,
|
|
4
|
+
resetReviewState,
|
|
5
|
+
lastReview,
|
|
6
|
+
sessionReviews,
|
|
7
|
+
} from "./state.ts";
|
|
8
|
+
|
|
9
|
+
export { recordReviewResult, getLastReviewResult, resetReviewState, lastReview, sessionReviews };
|
|
10
|
+
|
|
11
|
+
export function buildReviewRubric(diffText: string, taskPrompt?: string): string {
|
|
12
|
+
return [
|
|
13
|
+
"# Secondary Review Agent Quality Gate",
|
|
14
|
+
"",
|
|
15
|
+
"Evaluate this code change independently with fresh context across these 5 core axes:",
|
|
16
|
+
"",
|
|
17
|
+
"### 1. Test Integrity & Truthfulness (CRITICAL)",
|
|
18
|
+
"- Are test assertions testing real behavioral outcomes rather than trivial passes (e.g. `expect(true).toBe(true)`)?",
|
|
19
|
+
"- Were existing tests disabled, bypassed, or weakened?",
|
|
20
|
+
"- Are edge cases and error paths covered?",
|
|
21
|
+
"",
|
|
22
|
+
"### 2. Task Completeness & Intent Alignment",
|
|
23
|
+
"- Does the implementation genuinely satisfy the user request without shortcut stubs (`// TODO`, `throw new Error('not implemented')`)?",
|
|
24
|
+
"- Does it introduce regressions in surrounding code?",
|
|
25
|
+
"",
|
|
26
|
+
"### 3. Code Cleanliness & Hygiene",
|
|
27
|
+
"- Is there any orphaned dead code, commented-out code blocks, or temporary debugging logs?",
|
|
28
|
+
"- Is the logic straightforward and free of unnecessary cognitive complexity?",
|
|
29
|
+
"",
|
|
30
|
+
"### 4. Security & Safety Boundaries",
|
|
31
|
+
"- Are there any hardcoded secrets, unprotected tokens, or unvalidated user inputs?",
|
|
32
|
+
"- Does the code respect workspace confinement and safe environment practices?",
|
|
33
|
+
"",
|
|
34
|
+
"### 5. Platform & Architecture Fit (GitHub & Azure DevOps)",
|
|
35
|
+
"- Does the change fit established repository patterns and CI/CD pipelines?",
|
|
36
|
+
"",
|
|
37
|
+
taskPrompt ? `### User Request / Context:\n${taskPrompt}\n` : "",
|
|
38
|
+
"### Code Diff Under Review:",
|
|
39
|
+
"```diff",
|
|
40
|
+
diffText.slice(0, 30_000),
|
|
41
|
+
"```",
|
|
42
|
+
"",
|
|
43
|
+
"Provide your verdict: `[APPROVE]` or `[REQUEST_CHANGES]` with concise, actionable findings.",
|
|
44
|
+
].join("\n");
|
|
45
|
+
}
|
package/src/lib/state.ts
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import { realpathSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join, resolve } from "node:path";
|
|
3
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
4
|
+
import { persistVerifyCache } from "./audit.ts";
|
|
5
|
+
import { snipVerifyOutput, getCurrentGitCommitHash, getGitStatusSummary } from "./verify.ts";
|
|
6
|
+
import type {
|
|
7
|
+
TodoSdkClient,
|
|
8
|
+
ProjectConfig,
|
|
9
|
+
VerifyResult,
|
|
10
|
+
ReviewResult,
|
|
11
|
+
} from "./types.ts";
|
|
12
|
+
|
|
13
|
+
export let workspaceRoot = process.cwd();
|
|
14
|
+
export let workspaceRootReal = workspaceRoot;
|
|
15
|
+
interface RuntimeState {
|
|
16
|
+
workspaceRoot: string;
|
|
17
|
+
workspaceRootReal: string;
|
|
18
|
+
sdkClient: TodoSdkClient | undefined;
|
|
19
|
+
projectConfig: ProjectConfig;
|
|
20
|
+
}
|
|
21
|
+
const runtimeState = new AsyncLocalStorage<RuntimeState>();
|
|
22
|
+
|
|
23
|
+
try {
|
|
24
|
+
workspaceRootReal = realpathSync(workspaceRoot);
|
|
25
|
+
} catch {}
|
|
26
|
+
|
|
27
|
+
export function setWorkspaceRoot(root: string): void {
|
|
28
|
+
workspaceRoot = root;
|
|
29
|
+
try {
|
|
30
|
+
workspaceRootReal = realpathSync(root);
|
|
31
|
+
} catch {
|
|
32
|
+
workspaceRootReal = root;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function getWorkspaceRoot(): string {
|
|
37
|
+
return runtimeState.getStore()?.workspaceRoot ?? workspaceRoot;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function getWorkspaceRootReal(): string {
|
|
41
|
+
return runtimeState.getStore()?.workspaceRootReal ?? workspaceRootReal;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export let sdkClient: TodoSdkClient | undefined;
|
|
45
|
+
|
|
46
|
+
export function setSdkClient(client: unknown): void {
|
|
47
|
+
sdkClient = client as TodoSdkClient | undefined;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function getSdkClient(): TodoSdkClient | undefined {
|
|
51
|
+
return runtimeState.getStore()?.sdkClient ?? sdkClient;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function runWithRuntimeState<T>(root: string, client: unknown, fn: () => T): T {
|
|
55
|
+
let realRoot = root;
|
|
56
|
+
try {
|
|
57
|
+
realRoot = realpathSync(root);
|
|
58
|
+
} catch {}
|
|
59
|
+
return runtimeState.run(
|
|
60
|
+
{
|
|
61
|
+
workspaceRoot: root,
|
|
62
|
+
workspaceRootReal: realRoot,
|
|
63
|
+
sdkClient: client as TodoSdkClient | undefined,
|
|
64
|
+
projectConfig: loadProjectConfig(root),
|
|
65
|
+
},
|
|
66
|
+
fn,
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export let lastMutationTimestamp = 0;
|
|
71
|
+
export let mutationCount = 0;
|
|
72
|
+
export const sessionMutationTimestamps = new Map<string, number>();
|
|
73
|
+
export const sessionMutationCounts = new Map<string, number>();
|
|
74
|
+
|
|
75
|
+
export let lastVerify: VerifyResult | undefined;
|
|
76
|
+
export const sessionVerifyResults = new Map<string, NonNullable<VerifyResult>>();
|
|
77
|
+
|
|
78
|
+
export let lastReview: ReviewResult | undefined;
|
|
79
|
+
export const sessionReviews = new Map<string, ReviewResult>();
|
|
80
|
+
|
|
81
|
+
export function recordMutation(sessionID?: string): void {
|
|
82
|
+
lastMutationTimestamp = Date.now();
|
|
83
|
+
mutationCount++;
|
|
84
|
+
if (sessionID) {
|
|
85
|
+
sessionMutationTimestamps.set(sessionID, lastMutationTimestamp);
|
|
86
|
+
sessionMutationCounts.set(sessionID, (sessionMutationCounts.get(sessionID) ?? 0) + 1);
|
|
87
|
+
sessionVerifyResults.delete(sessionID);
|
|
88
|
+
}
|
|
89
|
+
if (sessionID) sessionReviews.delete(sessionID);
|
|
90
|
+
if (!lastReview?.targetSessionID || lastReview.targetSessionID === sessionID) {
|
|
91
|
+
lastReview = undefined;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function getMutationCount(sessionID?: string): number {
|
|
96
|
+
if (sessionID && sessionMutationCounts.has(sessionID)) {
|
|
97
|
+
return sessionMutationCounts.get(sessionID) ?? 0;
|
|
98
|
+
}
|
|
99
|
+
return mutationCount;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function getLastMutationTimestamp(): number {
|
|
103
|
+
return lastMutationTimestamp;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function getLastVerifyResult(): typeof lastVerify {
|
|
107
|
+
return lastVerify;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function recordVerifyResult(
|
|
111
|
+
command: string,
|
|
112
|
+
result: { passed: boolean; output: string; durationMs?: number },
|
|
113
|
+
sessionID?: string,
|
|
114
|
+
root = getWorkspaceRoot(),
|
|
115
|
+
): void {
|
|
116
|
+
lastVerify = {
|
|
117
|
+
command,
|
|
118
|
+
passed: result.passed,
|
|
119
|
+
output: snipVerifyOutput(result.output, result.passed),
|
|
120
|
+
timestamp: Date.now(),
|
|
121
|
+
durationMs: result.durationMs,
|
|
122
|
+
commitHash: getCurrentGitCommitHash(root),
|
|
123
|
+
gitStatus: getGitStatusSummary(root),
|
|
124
|
+
workspaceRoot: resolve(root),
|
|
125
|
+
};
|
|
126
|
+
if (sessionID && lastVerify) sessionVerifyResults.set(sessionID, lastVerify);
|
|
127
|
+
if (lastVerify.passed) {
|
|
128
|
+
persistVerifyCache(lastVerify);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function setLastVerifyResult(result: NonNullable<VerifyResult>): void {
|
|
133
|
+
lastVerify = result;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function resetVerifyState(): void {
|
|
137
|
+
lastMutationTimestamp = 0;
|
|
138
|
+
mutationCount = 0;
|
|
139
|
+
lastVerify = undefined;
|
|
140
|
+
sessionMutationTimestamps.clear();
|
|
141
|
+
sessionMutationCounts.clear();
|
|
142
|
+
sessionVerifyResults.clear();
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function recordReviewResult(
|
|
146
|
+
reviewer: string,
|
|
147
|
+
summary: string,
|
|
148
|
+
passed: boolean,
|
|
149
|
+
targetSessionID?: string,
|
|
150
|
+
workspace?: string,
|
|
151
|
+
): void {
|
|
152
|
+
lastReview = {
|
|
153
|
+
reviewer,
|
|
154
|
+
summary: summary.slice(-4000),
|
|
155
|
+
passed,
|
|
156
|
+
timestamp: Date.now(),
|
|
157
|
+
targetSessionID,
|
|
158
|
+
workspace,
|
|
159
|
+
};
|
|
160
|
+
if (targetSessionID) sessionReviews.set(targetSessionID, lastReview);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export function getLastReviewResult(): typeof lastReview {
|
|
164
|
+
return lastReview;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function resetReviewState(): void {
|
|
168
|
+
lastReview = undefined;
|
|
169
|
+
sessionReviews.clear();
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export let cachedProjectConfig: ProjectConfig | undefined;
|
|
173
|
+
|
|
174
|
+
export function loadProjectConfig(root: string): ProjectConfig {
|
|
175
|
+
const candidates = [
|
|
176
|
+
join(root, ".opencode", "workflow-guard.json"),
|
|
177
|
+
join(root, ".opencode", "workflow-guard.jsonc"),
|
|
178
|
+
join(root, "workflow-guard.json"),
|
|
179
|
+
join(root, "workflow-guard.jsonc"),
|
|
180
|
+
];
|
|
181
|
+
for (const candidate of candidates) {
|
|
182
|
+
try {
|
|
183
|
+
const raw = readFileSync(candidate, "utf8");
|
|
184
|
+
return JSON.parse(raw);
|
|
185
|
+
} catch {}
|
|
186
|
+
}
|
|
187
|
+
return {};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export function reloadProjectConfig(root: string): void {
|
|
191
|
+
cachedProjectConfig = loadProjectConfig(root);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export function getProjectConfig(root: string): ProjectConfig {
|
|
195
|
+
const active = runtimeState.getStore();
|
|
196
|
+
if (active?.workspaceRoot === root) return active.projectConfig;
|
|
197
|
+
return cachedProjectConfig ?? loadProjectConfig(root);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export function isReviewRequired(root: string): boolean {
|
|
201
|
+
if (process.env.WORKFLOW_GUARD_REQUIRE_REVIEW === "1") return true;
|
|
202
|
+
const cfg = getProjectConfig(root);
|
|
203
|
+
return cfg.requireReview === true;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export function isDocumentationRequired(root: string): boolean {
|
|
207
|
+
if (process.env.WORKFLOW_GUARD_REQUIRE_DOCS === "1") return true;
|
|
208
|
+
const cfg = getProjectConfig(root);
|
|
209
|
+
return cfg.requireDocumentation === true;
|
|
210
|
+
}
|
package/src/lib/types.ts
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
export interface TodoItem {
|
|
2
|
+
content?: unknown;
|
|
3
|
+
status?: unknown;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export interface TodoSdkClient {
|
|
7
|
+
session?: {
|
|
8
|
+
todo?: (opts: { path: { id: string } }) => Promise<{ data?: unknown }>;
|
|
9
|
+
get?: (opts: { path: { id: string } }) => Promise<{ data?: { parentID?: unknown } }>;
|
|
10
|
+
};
|
|
11
|
+
tui?: {
|
|
12
|
+
showToast?: (opts: { body: { title?: string; message: string; variant?: string } }) => Promise<unknown>;
|
|
13
|
+
};
|
|
14
|
+
lsp?: {
|
|
15
|
+
status?: () => Promise<{ data?: unknown }>;
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface ProjectConfig {
|
|
20
|
+
protectedBranches?: string[];
|
|
21
|
+
verifyCommand?: string;
|
|
22
|
+
requireReview?: boolean;
|
|
23
|
+
requireDocumentation?: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface VerifyResult {
|
|
27
|
+
passed: boolean;
|
|
28
|
+
command: string;
|
|
29
|
+
output: string;
|
|
30
|
+
timestamp: number;
|
|
31
|
+
durationMs?: number;
|
|
32
|
+
commitHash?: string;
|
|
33
|
+
gitStatus?: string;
|
|
34
|
+
workspaceRoot?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface ReviewResult {
|
|
38
|
+
passed: boolean;
|
|
39
|
+
reviewer: string;
|
|
40
|
+
summary: string;
|
|
41
|
+
timestamp: number;
|
|
42
|
+
targetSessionID?: string;
|
|
43
|
+
workspace?: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface AuditEntry {
|
|
47
|
+
ts: string;
|
|
48
|
+
sessionID?: string;
|
|
49
|
+
tool: string;
|
|
50
|
+
decision: "allow" | "block";
|
|
51
|
+
reason?: string;
|
|
52
|
+
input?: unknown;
|
|
53
|
+
evidence?: {
|
|
54
|
+
mutation?: boolean;
|
|
55
|
+
targetPath?: string;
|
|
56
|
+
verification?: {
|
|
57
|
+
command: string;
|
|
58
|
+
passed: boolean;
|
|
59
|
+
fresh: boolean;
|
|
60
|
+
durationMs?: number;
|
|
61
|
+
};
|
|
62
|
+
allowLive?: boolean;
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface GitInvocation {
|
|
67
|
+
repoDir: string;
|
|
68
|
+
rest: string;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface ShellMutation {
|
|
72
|
+
kind: "redirect" | "command";
|
|
73
|
+
target?: string;
|
|
74
|
+
what: string;
|
|
75
|
+
}
|
package/src/lib/utils.ts
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { getSdkClient } from "./state.ts";
|
|
2
|
+
|
|
3
|
+
export function asRecord(value: unknown): Record<string, unknown> | undefined {
|
|
4
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
5
|
+
return undefined;
|
|
6
|
+
}
|
|
7
|
+
return value as Record<string, unknown>;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function extractCommands(input: unknown): string[] {
|
|
11
|
+
if (typeof input === "string") {
|
|
12
|
+
return [input];
|
|
13
|
+
}
|
|
14
|
+
const record = asRecord(input);
|
|
15
|
+
if (!record) {
|
|
16
|
+
return [];
|
|
17
|
+
}
|
|
18
|
+
const commands: string[] = [];
|
|
19
|
+
if (typeof record.command === "string") commands.push(record.command);
|
|
20
|
+
if (Array.isArray(record.commands)) {
|
|
21
|
+
for (const c of record.commands) {
|
|
22
|
+
if (typeof c === "string") commands.push(c);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return commands;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function normalize(cmd: string): string {
|
|
29
|
+
return cmd.replace(/\s+/g, " ");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function shellWords(command: string): string[] {
|
|
33
|
+
return (command.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) ?? []).map((word) =>
|
|
34
|
+
word.replace(/^(?:"([\s\S]*)"|'([\s\S]*)')$/, "$1$2"),
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function splitShellSegments(command: string): string[] {
|
|
39
|
+
const segments: string[] = [];
|
|
40
|
+
let current = "";
|
|
41
|
+
let quote: "'" | '"' | undefined;
|
|
42
|
+
let escaped = false;
|
|
43
|
+
for (const char of command) {
|
|
44
|
+
if (escaped) {
|
|
45
|
+
current += char;
|
|
46
|
+
escaped = false;
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
if (char === "\\" && quote !== "'") {
|
|
50
|
+
current += char;
|
|
51
|
+
escaped = true;
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
if (char === "'" || char === '"') {
|
|
55
|
+
if (!quote) quote = char;
|
|
56
|
+
else if (quote === char) quote = undefined;
|
|
57
|
+
current += char;
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
if (!quote && /[\n|;&]/.test(char)) {
|
|
61
|
+
if (current.trim()) segments.push(current.trim());
|
|
62
|
+
current = "";
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
current += char;
|
|
66
|
+
}
|
|
67
|
+
if (current.trim()) segments.push(current.trim());
|
|
68
|
+
return segments;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function unwrapShellWords(command: string): string[] {
|
|
72
|
+
const words = shellWords(command.trim());
|
|
73
|
+
let i = 0;
|
|
74
|
+
while (i < words.length) {
|
|
75
|
+
if (words[i] === "command") {
|
|
76
|
+
i++;
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
if (words[i] === "sudo") {
|
|
80
|
+
i++;
|
|
81
|
+
const valueOptions = new Set([
|
|
82
|
+
"-u",
|
|
83
|
+
"--user",
|
|
84
|
+
"-g",
|
|
85
|
+
"--group",
|
|
86
|
+
"-h",
|
|
87
|
+
"--host",
|
|
88
|
+
"-p",
|
|
89
|
+
"--prompt",
|
|
90
|
+
"-C",
|
|
91
|
+
"--close-from",
|
|
92
|
+
"-R",
|
|
93
|
+
"--chroot",
|
|
94
|
+
"-D",
|
|
95
|
+
"--chdir",
|
|
96
|
+
]);
|
|
97
|
+
while (i < words.length && words[i]!.startsWith("-")) {
|
|
98
|
+
const option = words[i++]!;
|
|
99
|
+
if (valueOptions.has(option) && i < words.length) i++;
|
|
100
|
+
}
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
if (words[i] === "env") {
|
|
104
|
+
i++;
|
|
105
|
+
const valueOptions = new Set([
|
|
106
|
+
"-u",
|
|
107
|
+
"--unset",
|
|
108
|
+
"-C",
|
|
109
|
+
"--chdir",
|
|
110
|
+
"-S",
|
|
111
|
+
"--split-string",
|
|
112
|
+
]);
|
|
113
|
+
while (i < words.length) {
|
|
114
|
+
const word = words[i]!;
|
|
115
|
+
if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(word)) {
|
|
116
|
+
i++;
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
if (!word.startsWith("-")) break;
|
|
120
|
+
i++;
|
|
121
|
+
if (valueOptions.has(word) && i < words.length) i++;
|
|
122
|
+
}
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(words[i]!)) {
|
|
126
|
+
i++;
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
break;
|
|
130
|
+
}
|
|
131
|
+
return words.slice(i);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function unwrapShellCommand(command: string): string {
|
|
135
|
+
return unwrapShellWords(command).join(" ");
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export const SENSITIVE_ENV_KEYS = [
|
|
139
|
+
"GITHUB_TOKEN",
|
|
140
|
+
"GH_TOKEN",
|
|
141
|
+
"OPENAI_API_KEY",
|
|
142
|
+
"ANTHROPIC_API_KEY",
|
|
143
|
+
"OPENAI_KEY",
|
|
144
|
+
"KUBECONFIG",
|
|
145
|
+
"NPM_TOKEN",
|
|
146
|
+
"DOCKER_AUTH",
|
|
147
|
+
"GOOGLE_APPLICATION_CREDENTIALS",
|
|
148
|
+
"GCLOUD_AUTH",
|
|
149
|
+
"AZURE_CREDENTIALS",
|
|
150
|
+
"SLACK_TOKEN",
|
|
151
|
+
];
|
|
152
|
+
export const SENSITIVE_ENV_RE =
|
|
153
|
+
/^(AWS_|KUBE|OPENAI|ANTHROPIC|GH_|GITHUB_|GOOGLE_|GCP_|AZURE_|SLACK_|NPM_|DOCKER_|KUBECONFIG)/;
|
|
154
|
+
|
|
155
|
+
export function getCleanEnv(): Record<string, string> {
|
|
156
|
+
const env: Record<string, string> = { ...(process.env as Record<string, string>) };
|
|
157
|
+
for (const key of SENSITIVE_ENV_KEYS) {
|
|
158
|
+
delete env[key];
|
|
159
|
+
}
|
|
160
|
+
for (const key of Object.keys(env)) {
|
|
161
|
+
if (SENSITIVE_ENV_RE.test(key)) {
|
|
162
|
+
delete env[key];
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return env;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export async function showBlockToast(message: string): Promise<void> {
|
|
169
|
+
try {
|
|
170
|
+
const client = getSdkClient();
|
|
171
|
+
await client?.tui?.showToast?.({
|
|
172
|
+
body: {
|
|
173
|
+
title: "Workflow Guard Blocked",
|
|
174
|
+
message: message.slice(0, 180),
|
|
175
|
+
variant: "warning",
|
|
176
|
+
},
|
|
177
|
+
});
|
|
178
|
+
} catch {}
|
|
179
|
+
}
|