opencode-workflow-guard 1.13.6 → 1.14.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/docs/policies.md +1 -0
- package/package.json +1 -1
- package/src/lib/guard-dispatcher.ts +2 -1
- package/src/lib/live-control-plane.ts +74 -0
- package/src/lib/state.ts +15 -0
- package/src/lib/types.ts +9 -0
- package/src/policies/tamper.ts +24 -5
- package/src/workflow-guard.ts +2 -0
- package/test/test.mts +37 -0
package/docs/policies.md
CHANGED
|
@@ -64,6 +64,7 @@ It is a policy and enforcement layer, not an agent harness. Policies may constra
|
|
|
64
64
|
- Quoted data spans are not shell syntax: pattern matching runs on the quote-stripped residue, where redirect targets are honored quoted or not and quote-concatenated paths keep normalizing. A quoted span containing a guarded path is never treated as a redirect or command verb; unquoted redirects into guarded paths stay blocked.
|
|
65
65
|
- Plan files under `.opencode/plans/` are exempt: opencode's plan mode writes agent-authored plan markdown there, and plans are content, not configuration. Everything else under `.opencode/` (including the directory itself) stays protected.
|
|
66
66
|
- **Read-only access is allowed** (`cat`, `less`, `grep`, `head`, `tail` on config files) - only modification attempts trigger the guard.
|
|
67
|
+
- **Live control-plane facts (`liveControlPlanePaths`, opt-in):** declare the absolute paths of the live control plane (for example `~/.config/opencode`, or the guard's own install) in `.opencode/workflow-guard.json` or via `WORKFLOW_GUARD_LIVE_CONTROL_PLANE_PATHS` (comma-separated). When set, config-path protection classifies by runtime consumption instead of filename segments: only targets that resolve under a live root are protected (symlink-aware), so copying **from** a config path into a sanctioned workspace draft is no longer blocked (the copy destination is what matters) and versioned drafts such as a project `.opencode/` or a dotfiles `.config/opencode` are editable. Roots must be absolute (`~`/`$HOME` are expanded); an unusable root rejects the whole set and falls back to the legacy fail-closed segment matching. Unset keeps the legacy behavior.
|
|
67
68
|
|
|
68
69
|
### 7. Feature-Branch Workflow
|
|
69
70
|
- When the workspace git repository is on `main` or `master`, all file mutations and history-changing git commands (`commit`, `merge`, `rebase`, `cherry-pick`, `revert`, `apply`, `am`, `reset`, `restore`, `stash pop`, `update-ref`, `filter-branch`, `branch -D/-M`) are blocked.
|
package/package.json
CHANGED
|
@@ -5,6 +5,7 @@ import { isEvidenceFresh, reviewEvidence, verificationEvidence } from "./evidenc
|
|
|
5
5
|
import { findGitRoot, isSameGitRepo, projectRootKey } from "./project-config.ts";
|
|
6
6
|
import {
|
|
7
7
|
getLastReviewResult,
|
|
8
|
+
getLiveControlPlaneRoots,
|
|
8
9
|
getSdkClient,
|
|
9
10
|
getWorkspaceRoot,
|
|
10
11
|
isDocumentationRequired,
|
|
@@ -343,7 +344,7 @@ export async function guardToolCallImpl(
|
|
|
343
344
|
return block("git", "branch_base_behind", `Blocked: ${behindCheck.reason}`);
|
|
344
345
|
}
|
|
345
346
|
}
|
|
346
|
-
if (isSettingsTamper(command)) {
|
|
347
|
+
if (isSettingsTamper(command, getLiveControlPlaneRoots(currentRoot))) {
|
|
347
348
|
logPolicyBlock(`[workflow-guard] blocked settings tamper: ${command.slice(0, 120)}`);
|
|
348
349
|
return block("tamper", "settings_tamper", PROTECTED_PATH_REASON);
|
|
349
350
|
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { realpathSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Expands a leading `~`/`~user`/`$HOME`/`${HOME}` in a declared path and
|
|
7
|
+
* rejects any remaining unresolved `$VARIABLE` (returns undefined so callers
|
|
8
|
+
* fail closed rather than treating an unresolvable root as absent).
|
|
9
|
+
*/
|
|
10
|
+
export function expandLivePath(value: string): string | undefined {
|
|
11
|
+
const trimmed = value.trim().replace(/^["']|["']$/g, "");
|
|
12
|
+
if (!trimmed) return undefined;
|
|
13
|
+
const home = process.env.HOME || homedir();
|
|
14
|
+
let out = trimmed;
|
|
15
|
+
if (out === "~") {
|
|
16
|
+
out = home;
|
|
17
|
+
} else if (out.startsWith("~/") || out.startsWith("~\\")) {
|
|
18
|
+
out = join(home, out.slice(2));
|
|
19
|
+
} else if (/^~[A-Za-z0-9_.-]+(?:\/|\\|$)/.test(out)) {
|
|
20
|
+
const parts = out.slice(1).split(/[/\\]/);
|
|
21
|
+
out = join(dirname(home), parts[0]!, ...parts.slice(1));
|
|
22
|
+
} else if (/^\$(?:HOME|\{HOME\})(?=$|[/\\])/.test(out)) {
|
|
23
|
+
out = out.replace(/^\$(?:HOME|\{HOME\})/, home);
|
|
24
|
+
}
|
|
25
|
+
if (out.includes("$")) return undefined;
|
|
26
|
+
return out;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Normalizes the host-declared live control-plane roots. A root is usable only
|
|
31
|
+
* when it expands (no unresolved `$VAR`) and is absolute; on ANY unusable root
|
|
32
|
+
* the whole set is rejected (undefined) so classification falls back to the
|
|
33
|
+
* fail-closed legacy segment matching rather than trusting partial facts.
|
|
34
|
+
*/
|
|
35
|
+
export function normalizeLiveControlPlaneRoots(
|
|
36
|
+
values: readonly string[] | undefined,
|
|
37
|
+
): readonly string[] | undefined {
|
|
38
|
+
if (!values || values.length === 0) return undefined;
|
|
39
|
+
const roots: string[] = [];
|
|
40
|
+
for (const value of values) {
|
|
41
|
+
const expanded = expandLivePath(value);
|
|
42
|
+
if (expanded === undefined || !isAbsolute(expanded)) return undefined;
|
|
43
|
+
roots.push(resolve(expanded));
|
|
44
|
+
}
|
|
45
|
+
return roots;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function realpathWithMissingTail(path: string): string | undefined {
|
|
49
|
+
let ancestor = path;
|
|
50
|
+
while (true) {
|
|
51
|
+
try {
|
|
52
|
+
return resolve(realpathSync(ancestor), relative(ancestor, path));
|
|
53
|
+
} catch {
|
|
54
|
+
const parent = dirname(ancestor);
|
|
55
|
+
if (parent === ancestor) return undefined;
|
|
56
|
+
ancestor = parent;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** True when `candidate` is, or is nested under, any root (root realpath-aware). */
|
|
62
|
+
export function pathWithinAnyRoot(candidate: string, roots: readonly string[]): boolean {
|
|
63
|
+
return roots.some((root) => {
|
|
64
|
+
const realRoot = realpathWithMissingTail(resolve(root)) ?? resolve(root);
|
|
65
|
+
const rel = relative(realRoot, candidate);
|
|
66
|
+
return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Symlink-aware variant: resolves `candidate`'s existing ancestor before matching. */
|
|
71
|
+
export function realpathWithinAnyRoot(candidate: string, roots: readonly string[]): boolean {
|
|
72
|
+
const real = realpathWithMissingTail(candidate);
|
|
73
|
+
return real !== undefined && real !== candidate && pathWithinAnyRoot(real, roots);
|
|
74
|
+
}
|
package/src/lib/state.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { AsyncLocalStorage } from "node:async_hooks";
|
|
|
4
4
|
import { getReviewCacheFilePath, loadReviewCache, persistReviewCache, persistVerifyCache, persistVerifyHistory } from "./audit.ts";
|
|
5
5
|
import { findGitRoot, getCachedProjectConfig, isSameGitRepo, loadProjectConfig, projectRootKey } from "./project-config.ts";
|
|
6
6
|
import { snipVerifyOutput, getCurrentGitCommitHash, getGitStatusSummary, getGitWorktreeFingerprint } from "./verify.ts";
|
|
7
|
+
import { normalizeLiveControlPlaneRoots } from "./live-control-plane.ts";
|
|
7
8
|
import type {
|
|
8
9
|
TodoSdkClient,
|
|
9
10
|
ProjectConfig,
|
|
@@ -305,6 +306,20 @@ export function getProjectConfig(root: string): ProjectConfig {
|
|
|
305
306
|
return getCachedProjectConfig(root) ?? loadProjectConfig(root);
|
|
306
307
|
}
|
|
307
308
|
|
|
309
|
+
/**
|
|
310
|
+
* The host-declared live control-plane roots: `WORKFLOW_GUARD_LIVE_CONTROL_PLANE_PATHS`
|
|
311
|
+
* (comma-separated) overrides, else project config `liveControlPlanePaths`.
|
|
312
|
+
* Returns undefined when unset or when any declared root is unusable, so
|
|
313
|
+
* callers fall back to the legacy fail-closed segment matching.
|
|
314
|
+
*/
|
|
315
|
+
export function getLiveControlPlaneRoots(root: string = getWorkspaceRoot()): readonly string[] | undefined {
|
|
316
|
+
const env = process.env.WORKFLOW_GUARD_LIVE_CONTROL_PLANE_PATHS;
|
|
317
|
+
if (env && env.trim()) {
|
|
318
|
+
return normalizeLiveControlPlaneRoots(env.split(",").map((value) => value.trim()).filter(Boolean));
|
|
319
|
+
}
|
|
320
|
+
return normalizeLiveControlPlaneRoots(getProjectConfig(root).liveControlPlanePaths);
|
|
321
|
+
}
|
|
322
|
+
|
|
308
323
|
export function isReviewRequired(root: string): boolean {
|
|
309
324
|
const env = process.env.WORKFLOW_GUARD_REQUIRE_REVIEW?.toLowerCase();
|
|
310
325
|
if (env === "0" || env === "false" || env === "off") return false;
|
package/src/lib/types.ts
CHANGED
|
@@ -35,6 +35,15 @@ export interface ProjectConfig {
|
|
|
35
35
|
}>;
|
|
36
36
|
requireReview?: boolean;
|
|
37
37
|
requireDocumentation?: boolean;
|
|
38
|
+
/**
|
|
39
|
+
* Absolute paths of the LIVE control plane (runtime `~/.config/opencode`,
|
|
40
|
+
* the guard's own install). When set, config-path protection classifies by
|
|
41
|
+
* runtime consumption instead of filename segments: only targets under a
|
|
42
|
+
* live root are protected, while versioned drafts (project `.opencode/`,
|
|
43
|
+
* dotfiles `.config/opencode`, worktrees) are not. Unset keeps the legacy
|
|
44
|
+
* fail-closed segment matching.
|
|
45
|
+
*/
|
|
46
|
+
liveControlPlanePaths?: string[];
|
|
38
47
|
recoveryCheckpoints?: boolean;
|
|
39
48
|
projectMemory?: boolean;
|
|
40
49
|
learning?: boolean;
|
package/src/policies/tamper.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { realpathSync } from "node:fs";
|
|
2
2
|
import { basename, relative, resolve } from "node:path";
|
|
3
|
-
import { getWorkspaceRoot } from "../lib/state.ts";
|
|
3
|
+
import { getLiveControlPlaneRoots, getWorkspaceRoot } from "../lib/state.ts";
|
|
4
4
|
import { decodeShellEscapes, prepareRedirectResidue } from "../lib/shell.ts";
|
|
5
|
+
import { pathWithinAnyRoot, realpathWithinAnyRoot } from "../lib/live-control-plane.ts";
|
|
5
6
|
|
|
6
7
|
export const PROTECTED_PATH_REASON =
|
|
7
8
|
"Blocked: modifying Open" +
|
|
@@ -56,19 +57,37 @@ export function isCollaborationInvocation(segment: string): boolean {
|
|
|
56
57
|
return COLLABORATION_INVOCATION_PATTERNS.some((re) => re.test(segment));
|
|
57
58
|
}
|
|
58
59
|
|
|
59
|
-
export function isSettingsTamper(command: string): boolean {
|
|
60
|
+
export function isSettingsTamper(command: string, liveControlPlaneRoots?: readonly string[]): boolean {
|
|
61
|
+
// When the host declares its live control-plane roots, path-shaped tamper is
|
|
62
|
+
// decided by the mutation TARGET's runtime location (`isProtectedPath`), not
|
|
63
|
+
// by matching any operand's raw text. That stops a copy/move SOURCE under a
|
|
64
|
+
// config path from being flagged while the destination is a sanctioned draft
|
|
65
|
+
// (finding F3). The opencode CLI verb patterns still apply. Callers that scan
|
|
66
|
+
// written content (edit payloads) pass no roots and keep the legacy
|
|
67
|
+
// fail-closed path matching.
|
|
68
|
+
const factMode = liveControlPlaneRoots !== undefined && liveControlPlaneRoots.length > 0;
|
|
60
69
|
return command.split(/[\n|;&]+/).some((s) => {
|
|
61
70
|
if (isCollaborationInvocation(s)) return false;
|
|
62
|
-
const residue = normalizeGlobPathEvasion(normalizeShellEvasion(prepareRedirectResidue(s)));
|
|
63
71
|
const flattened = normalizeGlobPathEvasion(normalizeShellEvasion(s));
|
|
64
|
-
|
|
72
|
+
if (VERB_PATTERNS.some((re) => re.test(flattened))) return true;
|
|
73
|
+
if (factMode) return false;
|
|
74
|
+
const residue = normalizeGlobPathEvasion(normalizeShellEvasion(prepareRedirectResidue(s)));
|
|
75
|
+
return PATH_PATTERNS.some((re) => re.test(residue));
|
|
65
76
|
});
|
|
66
77
|
}
|
|
67
78
|
|
|
68
|
-
export function isProtectedPath(
|
|
79
|
+
export function isProtectedPath(
|
|
80
|
+
targetPath: string,
|
|
81
|
+
liveControlPlaneRoots: readonly string[] | undefined = getLiveControlPlaneRoots(),
|
|
82
|
+
): boolean {
|
|
69
83
|
if (!targetPath) return false;
|
|
70
84
|
const root = getWorkspaceRoot();
|
|
71
85
|
const resolved = resolve(root, targetPath);
|
|
86
|
+
if (liveControlPlaneRoots && liveControlPlaneRoots.length > 0) {
|
|
87
|
+
// Fact mode: only the LIVE control plane is protected; versioned drafts
|
|
88
|
+
// (project .opencode/, dotfiles .config/opencode, worktrees) are not.
|
|
89
|
+
return pathWithinAnyRoot(resolved, liveControlPlaneRoots) || realpathWithinAnyRoot(resolved, liveControlPlaneRoots);
|
|
90
|
+
}
|
|
72
91
|
const matches = (path: string): boolean => {
|
|
73
92
|
const base = basename(path);
|
|
74
93
|
const lower = path.toLowerCase();
|
package/src/workflow-guard.ts
CHANGED
|
@@ -66,6 +66,7 @@ import {
|
|
|
66
66
|
sessionMutationTimestamps,
|
|
67
67
|
sessionVerifyResults,
|
|
68
68
|
getProjectConfig,
|
|
69
|
+
getLiveControlPlaneRoots,
|
|
69
70
|
getOperationProfile,
|
|
70
71
|
isRecoveryCheckpointsEnabled,
|
|
71
72
|
isReviewRequired,
|
|
@@ -88,6 +89,7 @@ import { findGitRoot, isSameGitRepo, loadProjectConfig, reloadProjectConfig, str
|
|
|
88
89
|
export {
|
|
89
90
|
setWorkspaceRoot,
|
|
90
91
|
getWorkspaceRoot,
|
|
92
|
+
getLiveControlPlaneRoots,
|
|
91
93
|
setSdkClient,
|
|
92
94
|
getLastMutationTimestamp,
|
|
93
95
|
getMutationCount,
|
package/test/test.mts
CHANGED
|
@@ -3915,6 +3915,43 @@ check("project-memory initialization failure leaves core guard hooks active", ty
|
|
|
3915
3915
|
if (prevDataHome === undefined) delete process.env.XDG_DATA_HOME;
|
|
3916
3916
|
else process.env.XDG_DATA_HOME = prevDataHome;
|
|
3917
3917
|
|
|
3918
|
+
console.log("- live control-plane paths (tier port: config-path facts) -");
|
|
3919
|
+
const liveBase = mkdtempSync(join(tmpdir(), "wg-lcp-base-"));
|
|
3920
|
+
const livePlane = join(liveBase, ".config", "open" + "code");
|
|
3921
|
+
mkdirSync(livePlane, { recursive: true });
|
|
3922
|
+
const lcpRoot = mkdtempSync(join(tmpdir(), "wg-lcp-root-"));
|
|
3923
|
+
mkdirSync(join(lcpRoot, ".opencode"), { recursive: true });
|
|
3924
|
+
const ocJson = "open" + "code.json";
|
|
3925
|
+
const ocJsonc = "open" + "code.jsonc";
|
|
3926
|
+
setWorkspaceRoot(root);
|
|
3927
|
+
|
|
3928
|
+
// Legacy (no live roots configured): the copy SOURCE under a config path alone
|
|
3929
|
+
// blocks the command — the F3 false positive, pinned as current behavior.
|
|
3930
|
+
check("legacy: cp FROM a .config/opencode source is blocked without live roots", blocked(await call("bash", { command: `cp ${livePlane}/agent/x.md ./draft.md` }, { sessionID: "s-active" })));
|
|
3931
|
+
|
|
3932
|
+
// Declare the live control plane via project config, then reload.
|
|
3933
|
+
writeFileSync(join(lcpRoot, ".opencode", "workflow-guard.json"), JSON.stringify({ liveControlPlanePaths: [livePlane] }));
|
|
3934
|
+
reloadProjectConfig(lcpRoot);
|
|
3935
|
+
setWorkspaceRoot(lcpRoot);
|
|
3936
|
+
|
|
3937
|
+
// With facts: the same copy is allowed — the SOURCE is no longer matched and
|
|
3938
|
+
// the destination is a sanctioned workspace draft (F3 fixed).
|
|
3939
|
+
check("facts: cp FROM live config into a workspace draft is allowed", !(await call("bash", { command: `cp ${livePlane}/agent/x.md ./draft.md` }, { sessionID: "s-active" })));
|
|
3940
|
+
// The live destination stays protected.
|
|
3941
|
+
check("facts: cp INTO the live config is blocked", blocked(await call("bash", { command: `cp ./draft.md ${livePlane}/agent/x.md` }, { sessionID: "s-active" })));
|
|
3942
|
+
check("facts: redirect into the live config is blocked", blocked(await call("bash", { command: `echo x > ${livePlane}/${ocJsonc}` }, { sessionID: "s-active" })));
|
|
3943
|
+
// A config-shaped draft inside the workspace is allowed.
|
|
3944
|
+
check("facts: write to a project .opencode draft is allowed", !(await call("write", { filePath: join(lcpRoot, ".opencode", ocJson), content: "{}" }, { sessionID: "s-active" })));
|
|
3945
|
+
|
|
3946
|
+
// An unusable declared root rejects the whole fact set -> fail-closed legacy.
|
|
3947
|
+
writeFileSync(join(lcpRoot, ".opencode", "workflow-guard.json"), JSON.stringify({ liveControlPlanePaths: ["relative/dir"] }));
|
|
3948
|
+
reloadProjectConfig(lcpRoot);
|
|
3949
|
+
check("facts: an unusable declared root falls back to legacy segment matching", blocked(await call("write", { filePath: join(lcpRoot, ".opencode", ocJson), content: "{}" }, { sessionID: "s-active" })));
|
|
3950
|
+
|
|
3951
|
+
setWorkspaceRoot(root);
|
|
3952
|
+
rmSync(lcpRoot, { recursive: true, force: true });
|
|
3953
|
+
rmSync(liveBase, { recursive: true, force: true });
|
|
3954
|
+
|
|
3918
3955
|
rmSync(root, { recursive: true, force: true });
|
|
3919
3956
|
if (prevLive !== undefined) process.env.WORKFLOW_GUARD_ALLOW_LIVE = prevLive;
|
|
3920
3957
|
console.log(`\n${pass} passed, ${fail} failed`);
|