opencode-workflow-guard 1.13.3 → 1.13.4
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 +2 -1
- package/package.json +1 -1
- package/src/lib/shell.ts +18 -0
- package/src/policies/boundary.ts +10 -3
- package/src/policies/tamper.ts +16 -8
- package/test/test.mts +5 -0
package/docs/policies.md
CHANGED
|
@@ -60,7 +60,8 @@ It is a policy and enforcement layer, not an agent harness. Policies may constra
|
|
|
60
60
|
- Direct edits of the same paths through the `edit`/`write`/`apply_patch` tools
|
|
61
61
|
- Shell commands invoking `opencode auth`, `opencode config`, `opencode permission`, or `opencode run --auto`
|
|
62
62
|
- Evasion-normalized: quote-concatenation (`open''code.json`), escapes (`open\c\ode`), and glob wildcards (`opencode.jso?`) are stripped before matching.
|
|
63
|
-
- Collaboration commands (`gh issue`, `gh pr`, `glab issue`, `glab pr`, `az repos pr`) and document content mentioning guarded paths are not tampering: quoted arguments are command data, not shell syntax
|
|
63
|
+
- Collaboration commands (`gh issue`, `gh pr`, `glab issue`, `glab pr`, `az repos pr`) and document content mentioning guarded paths are not tampering: quoted arguments are command data, not shell syntax, and the redirect heuristic applies to shell commands only (write/edit content is guarded by the target path check instead).
|
|
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.
|
|
64
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.
|
|
65
66
|
- **Read-only access is allowed** (`cat`, `less`, `grep`, `head`, `tail` on config files) - only modification attempts trigger the guard.
|
|
66
67
|
|
package/package.json
CHANGED
package/src/lib/shell.ts
CHANGED
|
@@ -75,6 +75,24 @@ export function decodeShellEscapes(text: string): string {
|
|
|
75
75
|
.replace(/\\([0-3][0-7]{2})/g, (_, oct) => String.fromCharCode(parseInt(oct, 8)));
|
|
76
76
|
}
|
|
77
77
|
|
|
78
|
+
// Reduce a shell segment to the syntax that actually executes: redirect
|
|
79
|
+
// targets are honored whether quoted or not (a quoted redirect target is
|
|
80
|
+
// still a real file), while SELF-CONTAINED quoted spans preceded by
|
|
81
|
+
// whitespace are command data and can never be shell redirects or
|
|
82
|
+
// operators. Quote spans glued to bare text are shell concatenation inside
|
|
83
|
+
// a single word (e.g. open''code.json) and are kept for the normalizer to
|
|
84
|
+
// flatten. A quoted span at segment start is NOT stripped: it can be a
|
|
85
|
+
// quoted command word ("opencode" auth) and must stay visible to verb
|
|
86
|
+
// patterns.
|
|
87
|
+
export function prepareRedirectResidue(segment: string): string {
|
|
88
|
+
const withTargets = segment
|
|
89
|
+
.replace(/(>>?)\s*'([^']*)'/g, "$1 $2")
|
|
90
|
+
.replace(/(>>?)\s*"([^"]*)"/g, '$1 $2');
|
|
91
|
+
return withTargets
|
|
92
|
+
.replace(/\s'[^']*'(?=$|[\s)>;&|])/g, " ")
|
|
93
|
+
.replace(/\s"[^"]*"(?=$|[\s)>;&|])/g, " ");
|
|
94
|
+
}
|
|
95
|
+
|
|
78
96
|
export function shellWords(command: string): string[] {
|
|
79
97
|
const words: string[] = [];
|
|
80
98
|
let word = "";
|
package/src/policies/boundary.ts
CHANGED
|
@@ -7,7 +7,7 @@ import {
|
|
|
7
7
|
getWorkspaceRootReal,
|
|
8
8
|
recordMutation,
|
|
9
9
|
} from "../lib/state.ts";
|
|
10
|
-
import { shellWords, unwrapShellCommand } from "../lib/shell.ts";
|
|
10
|
+
import { prepareRedirectResidue, shellWords, unwrapShellCommand } from "../lib/shell.ts";
|
|
11
11
|
import { isCollaborationInvocation, isProtectedPath, PROTECTED_PATH_REASON } from "./tamper.ts";
|
|
12
12
|
import { isSecretPath, secretIn } from "./secrets.ts";
|
|
13
13
|
import { onProtectedBranch, branchGuardReason } from "./git.ts";
|
|
@@ -129,10 +129,14 @@ export function teeTargetsIn(segment: string): string[] {
|
|
|
129
129
|
|
|
130
130
|
function redirectMutationsIn(segment: string): ShellMutation[] {
|
|
131
131
|
const mutations: ShellMutation[] = [];
|
|
132
|
+
// Redirect detection runs on the quote-stripped residue: quoted data
|
|
133
|
+
// spans are command data and their ">" characters are not redirects,
|
|
134
|
+
// while redirect targets keep their value whether quoted or not.
|
|
132
135
|
// The `(?!=)` lookahead after the op rejects comparison operators (`>=`,
|
|
133
136
|
// `==`) so they cannot match as a redirect op with `=` as its target.
|
|
137
|
+
const residue = prepareRedirectResidue(segment);
|
|
134
138
|
const redirectRe = /(?:^|[\s>]|(?<=[^\s"']))([0-9]*&?>>?&?(?!=))\s*["']?([^\s>&|;"']+)/g;
|
|
135
|
-
for (const redirectMatch of
|
|
139
|
+
for (const redirectMatch of residue.matchAll(redirectRe)) {
|
|
136
140
|
if (!redirectMatch[1] || !redirectMatch[2]) continue;
|
|
137
141
|
const op = redirectMatch[1];
|
|
138
142
|
const target = redirectMatch[2];
|
|
@@ -314,7 +318,10 @@ export function secretSourceInFilesystemCommand(segment: string): string | undef
|
|
|
314
318
|
// Remove single- and double-quoted spans from a shell segment. Used to
|
|
315
319
|
// analyze the residue of collaboration invocations: quoted arguments are
|
|
316
320
|
// command data and can never be shell redirects, while unquoted redirects
|
|
317
|
-
// keep receiving full validation.
|
|
321
|
+
// keep receiving full validation. NOTE: unlike shell.ts's
|
|
322
|
+
// prepareRedirectResidue, this strips EVERY span including redirect
|
|
323
|
+
// targets and glued concatenations - only use it for collaboration
|
|
324
|
+
// segments where all quoted content is command data.
|
|
318
325
|
function stripQuotedSpans(segment: string): string {
|
|
319
326
|
return segment.replace(/'[^'\n]*'/g, " ").replace(/"[^"\n]*"/g, " ");
|
|
320
327
|
}
|
package/src/policies/tamper.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { realpathSync } from "node:fs";
|
|
2
2
|
import { basename, relative, resolve } from "node:path";
|
|
3
3
|
import { getWorkspaceRoot } from "../lib/state.ts";
|
|
4
|
-
import { decodeShellEscapes } from "../lib/shell.ts";
|
|
4
|
+
import { decodeShellEscapes, prepareRedirectResidue } from "../lib/shell.ts";
|
|
5
5
|
|
|
6
6
|
export const PROTECTED_PATH_REASON =
|
|
7
7
|
"Blocked: modifying Open" +
|
|
@@ -23,6 +23,15 @@ export const SETTINGS_TAMPER_PATTERNS: RegExp[] = [
|
|
|
23
23
|
new RegExp("(?:" + "^|\\s)(?:op" + "encode)\\s+(?:run\\s+)?--auto\\b", "i"),
|
|
24
24
|
];
|
|
25
25
|
|
|
26
|
+
// Patterns 1-4 match redirect operators and mutation verbs writing into
|
|
27
|
+
// guarded paths: they run on the quote-stripped residue (quoted data spans
|
|
28
|
+
// are command data and their ">" characters are not redirects). Patterns
|
|
29
|
+
// 5-6 match the opencode CLI verbs themselves: those run on the
|
|
30
|
+
// quote-FLATTENED text, because a quoted command word or an eval payload
|
|
31
|
+
// still executes the verb.
|
|
32
|
+
const PATH_PATTERNS = SETTINGS_TAMPER_PATTERNS.slice(0, 4);
|
|
33
|
+
const VERB_PATTERNS = SETTINGS_TAMPER_PATTERNS.slice(4);
|
|
34
|
+
|
|
26
35
|
export function normalizeShellEvasion(text: string): string {
|
|
27
36
|
return decodeShellEscapes(text)
|
|
28
37
|
.replace(/'([^']*)'/g, "$1")
|
|
@@ -48,13 +57,12 @@ export function isCollaborationInvocation(segment: string): boolean {
|
|
|
48
57
|
}
|
|
49
58
|
|
|
50
59
|
export function isSettingsTamper(command: string): boolean {
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
);
|
|
60
|
+
return command.split(/[\n|;&]+/).some((s) => {
|
|
61
|
+
if (isCollaborationInvocation(s)) return false;
|
|
62
|
+
const residue = normalizeGlobPathEvasion(normalizeShellEvasion(prepareRedirectResidue(s)));
|
|
63
|
+
const flattened = normalizeGlobPathEvasion(normalizeShellEvasion(s));
|
|
64
|
+
return PATH_PATTERNS.some((re) => re.test(residue)) || VERB_PATTERNS.some((re) => re.test(flattened));
|
|
65
|
+
});
|
|
58
66
|
}
|
|
59
67
|
|
|
60
68
|
export function isProtectedPath(targetPath: string): boolean {
|
package/test/test.mts
CHANGED
|
@@ -539,6 +539,11 @@ check("allow gh issue create mentioning .opencode paths (not tamper)", !(await s
|
|
|
539
539
|
check("compound collaboration + tamper still blocked", blocked(await shell(`gh issue create --repo example/proj --title t && echo x > open${"code.json"}`)));
|
|
540
540
|
check("collaboration command with real redirect to global config still blocked", blocked(await shell(`gh issue list > /var/home/x/.config/open${"code"}/opencode.json`)));
|
|
541
541
|
check("collaboration command with redirect outside workspace still blocked", blocked(await shell(`gh pr create --title t > /tmp/wg-escape-probe`)));
|
|
542
|
+
check("allow command with guarded-path text in quoted data (not tamper)", !(await shell(`echo "flow: x > ${ocDir}" > notes.md`)));
|
|
543
|
+
check("block unquoted redirect into .opencode", blocked(await shell(`echo x > .open${"code"}/y`)));
|
|
544
|
+
check("block redirect with quoted real target", blocked(await shell(`echo x > ".open${"code"}/opencode.json"`)));
|
|
545
|
+
check("block quoted command word running the CLI auth flow", blocked(await shell(`"open${"code"}" auth login`)));
|
|
546
|
+
check("block eval of a quoted CLI auth payload", blocked(await shell(`eval 'open${"code"} auth login'`)));
|
|
542
547
|
|
|
543
548
|
console.log("- Policy 6: tamper via edit tools (path protection) -");
|
|
544
549
|
check("block edit of project opencode.json", blocked(await call("edit", { filePath: join(root, "opencode.json"), oldString: "a", newString: "b" }, { sessionID: "s-active" })));
|