@skwid138/opencode-command-normalizer 0.1.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/LICENSE +21 -0
- package/README.md +107 -0
- package/SECURITY.md +31 -0
- package/dist/index.d.ts +32 -0
- package/dist/index.js +283 -0
- package/package.json +65 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Hunter Hodnett
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# opencode-command-normalizer
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/@skwid138/opencode-command-normalizer)
|
|
4
|
+
[](https://github.com/skwid138/opencode-command-normalizer/actions/workflows/ci.yml)
|
|
5
|
+
[](LICENSE)
|
|
6
|
+
|
|
7
|
+
Command normalization plugin for opencode permission matching.
|
|
8
|
+
|
|
9
|
+
## Why
|
|
10
|
+
|
|
11
|
+
opencode expands configured permission patterns such as `~/tool.sh` to absolute
|
|
12
|
+
home paths when it loads configuration, but bash commands are matched against the
|
|
13
|
+
raw command text. That means a command typed with a home-form `argv0` can miss an
|
|
14
|
+
otherwise anchored allow rule unless the config falls back to broad wildcard
|
|
15
|
+
patterns.
|
|
16
|
+
|
|
17
|
+
This plugin rewrites only command-node `argv0` home forms to the equivalent
|
|
18
|
+
absolute path before opencode's permission matcher sees the command. It is meant
|
|
19
|
+
to let anchored absolute permission rules work without unsafe leading wildcards.
|
|
20
|
+
|
|
21
|
+
## Install
|
|
22
|
+
|
|
23
|
+
```sh
|
|
24
|
+
npm install @skwid138/opencode-command-normalizer
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Register it in opencode using the singular `plugin` config key in tuple form:
|
|
28
|
+
|
|
29
|
+
```jsonc
|
|
30
|
+
{
|
|
31
|
+
"plugin": [
|
|
32
|
+
[
|
|
33
|
+
"@skwid138/opencode-command-normalizer",
|
|
34
|
+
{
|
|
35
|
+
"roots": ["~/workspace/tools"],
|
|
36
|
+
"expandBraceHome": false
|
|
37
|
+
}
|
|
38
|
+
]
|
|
39
|
+
]
|
|
40
|
+
}
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Install the bare package name. The package also exposes `./server` for loaders
|
|
44
|
+
that resolve that subpath, but user installation should use
|
|
45
|
+
`@skwid138/opencode-command-normalizer`.
|
|
46
|
+
|
|
47
|
+
## Configuration
|
|
48
|
+
|
|
49
|
+
Options:
|
|
50
|
+
|
|
51
|
+
| Option | Type | Default | Description |
|
|
52
|
+
| --- | --- | --- | --- |
|
|
53
|
+
| `roots` | `string[]` | unset | Optional blast-radius limiter. Expanded `argv0` values outside configured roots are left unchanged. |
|
|
54
|
+
| `expandBraceHome` | `boolean` | `false` | Enables `${HOME}` and `${HOME}/...` expansion at command-node starts. |
|
|
55
|
+
| `homedir` | `string` | OS home directory | Test/advanced override for home expansion. |
|
|
56
|
+
| `auditLogPath` | `string` | data-home audit log | Absolute path override for the NDJSON audit log. Relative paths are ignored. |
|
|
57
|
+
| `debugLogPath` | `string` | data-home debug log | Absolute path override for debug messages. Relative paths are ignored. |
|
|
58
|
+
|
|
59
|
+
Default logs resolve lazily at plugin startup. If `XDG_DATA_HOME` is set to a
|
|
60
|
+
truthy absolute path, logs live below that directory; otherwise they live below
|
|
61
|
+
`$HOME/.local/share`. The subfolder intentionally keeps the historical name
|
|
62
|
+
`opencode/permission-audit-plugin` for continuity, even though the package is now
|
|
63
|
+
named command-normalizer.
|
|
64
|
+
|
|
65
|
+
`auditLogPath` and `debugLogPath` overrides must be absolute paths. Relative
|
|
66
|
+
override values are ignored and the defaults are used instead, preserving the
|
|
67
|
+
plugin's fail-open posture for malformed configuration.
|
|
68
|
+
|
|
69
|
+
## How it works
|
|
70
|
+
|
|
71
|
+
The plugin hooks `tool.execute.before` for bash tool executions and mutates
|
|
72
|
+
`output.args.command` before opencode permission matching. It expands only home
|
|
73
|
+
forms in `argv0` position at command-node starts:
|
|
74
|
+
|
|
75
|
+
- `~`
|
|
76
|
+
- `~/...`
|
|
77
|
+
- `$HOME`
|
|
78
|
+
- `$HOME/...`
|
|
79
|
+
- optionally `${HOME}` and `${HOME}/...` when `expandBraceHome: true`
|
|
80
|
+
|
|
81
|
+
Command nodes are split at shell separators such as `&&`, `||`, pipes,
|
|
82
|
+
semicolons, ampersands, and newlines while respecting quotes. The plugin returns
|
|
83
|
+
the original command unchanged for uncertain shell shapes such as heredocs,
|
|
84
|
+
command substitution, arithmetic expansion, process substitution, unbalanced
|
|
85
|
+
quotes, leading environment assignments, grouped commands, quoted `argv0`, and
|
|
86
|
+
dot-dot `argv0` values when roots are configured.
|
|
87
|
+
|
|
88
|
+
## Development
|
|
89
|
+
|
|
90
|
+
```sh
|
|
91
|
+
npm install
|
|
92
|
+
npm test
|
|
93
|
+
npm run build
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
`npm run build` emits declarations and verifies the public TypeScript surface.
|
|
97
|
+
|
|
98
|
+
## Security
|
|
99
|
+
|
|
100
|
+
This package is a normalization aid, not a policy engine. It should only rewrite
|
|
101
|
+
to the path the shell would produce for home expansion, and it intentionally
|
|
102
|
+
bails out instead of guessing on ambiguous shell syntax. See [SECURITY.md](SECURITY.md)
|
|
103
|
+
for the command-rewrite scope and fail-open philosophy.
|
|
104
|
+
|
|
105
|
+
## License
|
|
106
|
+
|
|
107
|
+
MIT
|
package/SECURITY.md
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# Security
|
|
2
|
+
|
|
3
|
+
`@skwid138/opencode-command-normalizer` rewrites bash command text before
|
|
4
|
+
opencode's permission matcher sees it. Its scope is intentionally narrow:
|
|
5
|
+
|
|
6
|
+
- It handles bash tool executions only.
|
|
7
|
+
- It rewrites command-node `argv0` home forms only.
|
|
8
|
+
- It rewrites to the same absolute path the shell would use for home expansion.
|
|
9
|
+
- It never expands home forms in argument position.
|
|
10
|
+
|
|
11
|
+
## Bail philosophy
|
|
12
|
+
|
|
13
|
+
The plugin should prefer leaving a command unchanged over guessing. It bails out
|
|
14
|
+
on uncertain shell shapes such as heredocs, command substitution, arithmetic
|
|
15
|
+
expansion, process substitution, unbalanced quotes, grouped commands, leading
|
|
16
|
+
environment assignments, quoted `argv0`, and dot-dot `argv0` values when roots
|
|
17
|
+
are configured.
|
|
18
|
+
|
|
19
|
+
Leaving the command unchanged means opencode's normal permission rules still
|
|
20
|
+
decide whether to allow, deny, or ask.
|
|
21
|
+
|
|
22
|
+
## Fail-open intent
|
|
23
|
+
|
|
24
|
+
Malformed plugin options disable rewriting instead of blocking command execution.
|
|
25
|
+
Audit and debug logging failures are swallowed or logged best-effort, so logging
|
|
26
|
+
cannot change command execution. Relative audit/debug log overrides are ignored
|
|
27
|
+
and replaced with default absolute paths.
|
|
28
|
+
|
|
29
|
+
The plugin must not become a second policy engine. Policy remains in opencode's
|
|
30
|
+
configured permissions; this package only normalizes command spelling so anchored
|
|
31
|
+
rules can match consistently.
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { Plugin } from "@opencode-ai/plugin";
|
|
2
|
+
export type CanonicalizeOptions = {
|
|
3
|
+
roots?: string[];
|
|
4
|
+
expandBraceHome?: boolean;
|
|
5
|
+
homedir?: string;
|
|
6
|
+
};
|
|
7
|
+
export type AuditRecord = {
|
|
8
|
+
ts: string;
|
|
9
|
+
sessionID: string;
|
|
10
|
+
agent: string | null;
|
|
11
|
+
callID: string;
|
|
12
|
+
command_node_text: string;
|
|
13
|
+
};
|
|
14
|
+
export type AuditContext = {
|
|
15
|
+
sessionID: string;
|
|
16
|
+
agent: string | null;
|
|
17
|
+
callID: string;
|
|
18
|
+
appendRecord: (record: AuditRecord) => Promise<void>;
|
|
19
|
+
debug: (message: string) => Promise<void>;
|
|
20
|
+
};
|
|
21
|
+
export declare function resolveDefaultLogPaths(env: Record<string, string | undefined>, homedir?: string): {
|
|
22
|
+
audit: string;
|
|
23
|
+
debug: string;
|
|
24
|
+
};
|
|
25
|
+
export declare function splitCommandNodes(command: string): string[];
|
|
26
|
+
export declare function canonicalize(command: string, opts?: CanonicalizeOptions): string;
|
|
27
|
+
export declare function canonicalizeAndAudit(command: string, opts: CanonicalizeOptions, context: AuditContext): Promise<string>;
|
|
28
|
+
export declare const PermissionCanonicalizerPlugin: Plugin;
|
|
29
|
+
declare const _default: {
|
|
30
|
+
server: Plugin;
|
|
31
|
+
};
|
|
32
|
+
export default _default;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
import { appendFile, mkdir } from "node:fs/promises";
|
|
2
|
+
import { homedir as osHomedir } from "node:os";
|
|
3
|
+
import { dirname, isAbsolute, join } from "node:path";
|
|
4
|
+
export function resolveDefaultLogPaths(env, homedir = osHomedir()) {
|
|
5
|
+
const xdg = env.XDG_DATA_HOME;
|
|
6
|
+
const base = xdg && isAbsolute(xdg) ? xdg : join(homedir, ".local", "share");
|
|
7
|
+
const dir = join(base, "opencode", "permission-audit-plugin");
|
|
8
|
+
return { audit: join(dir, "audit.log"), debug: join(dir, "debug.log") };
|
|
9
|
+
}
|
|
10
|
+
function hasUnbalancedQuotes(command) {
|
|
11
|
+
let quote = null;
|
|
12
|
+
let escaped = false;
|
|
13
|
+
for (const char of command) {
|
|
14
|
+
if (escaped) {
|
|
15
|
+
escaped = false;
|
|
16
|
+
continue;
|
|
17
|
+
}
|
|
18
|
+
if (quote === '"' && char === "\\") {
|
|
19
|
+
escaped = true;
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
if (quote) {
|
|
23
|
+
if (char === quote)
|
|
24
|
+
quote = null;
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
if (char === "'" || char === '"')
|
|
28
|
+
quote = char;
|
|
29
|
+
}
|
|
30
|
+
return quote !== null || escaped;
|
|
31
|
+
}
|
|
32
|
+
function hasGlobalBailShape(command) {
|
|
33
|
+
return (hasUnbalancedQuotes(command) ||
|
|
34
|
+
command.includes("<<") ||
|
|
35
|
+
command.includes("$((") ||
|
|
36
|
+
command.includes("$(") ||
|
|
37
|
+
command.includes("`") ||
|
|
38
|
+
command.includes("<(") ||
|
|
39
|
+
command.includes(">("));
|
|
40
|
+
}
|
|
41
|
+
function separatorLength(command, index) {
|
|
42
|
+
const two = command.slice(index, index + 2);
|
|
43
|
+
if (two === "&&" || two === "||" || two === "|&")
|
|
44
|
+
return 2;
|
|
45
|
+
const one = command[index];
|
|
46
|
+
if (one === "|" || one === ";" || one === "&" || one === "\n")
|
|
47
|
+
return 1;
|
|
48
|
+
return 0;
|
|
49
|
+
}
|
|
50
|
+
function commandSegments(command) {
|
|
51
|
+
const segments = [];
|
|
52
|
+
let quote = null;
|
|
53
|
+
let escaped = false;
|
|
54
|
+
let start = 0;
|
|
55
|
+
for (let index = 0; index < command.length; index += 1) {
|
|
56
|
+
const char = command[index];
|
|
57
|
+
if (escaped) {
|
|
58
|
+
escaped = false;
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
if (quote === '"' && char === "\\") {
|
|
62
|
+
escaped = true;
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
if (quote) {
|
|
66
|
+
if (char === quote)
|
|
67
|
+
quote = null;
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
if (char === "'" || char === '"') {
|
|
71
|
+
quote = char;
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
const length = separatorLength(command, index);
|
|
75
|
+
if (length > 0) {
|
|
76
|
+
segments.push({ text: command.slice(start, index), start, end: index });
|
|
77
|
+
index += length - 1;
|
|
78
|
+
start = index + 1;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
if (quote !== null || escaped)
|
|
82
|
+
return null;
|
|
83
|
+
segments.push({ text: command.slice(start), start, end: command.length });
|
|
84
|
+
return segments;
|
|
85
|
+
}
|
|
86
|
+
export function splitCommandNodes(command) {
|
|
87
|
+
return (commandSegments(command) ?? [{ text: command, start: 0, end: command.length }])
|
|
88
|
+
.map((segment) => segment.text.trim())
|
|
89
|
+
.filter(Boolean);
|
|
90
|
+
}
|
|
91
|
+
function expandHomeForm(argv0, home, expandBraceHome) {
|
|
92
|
+
if (argv0 === "~")
|
|
93
|
+
return home;
|
|
94
|
+
if (argv0.startsWith("~/"))
|
|
95
|
+
return `${home}${argv0.slice(1)}`;
|
|
96
|
+
if (argv0 === "$HOME")
|
|
97
|
+
return home;
|
|
98
|
+
if (argv0.startsWith("$HOME/"))
|
|
99
|
+
return `${home}${argv0.slice("$HOME".length)}`;
|
|
100
|
+
if (expandBraceHome && argv0 === "${HOME}")
|
|
101
|
+
return home;
|
|
102
|
+
if (expandBraceHome && argv0.startsWith("${HOME}/"))
|
|
103
|
+
return `${home}${argv0.slice("${HOME}".length)}`;
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
function normalizeRoot(root, home, expandBraceHome) {
|
|
107
|
+
const expanded = expandHomeForm(root, home, expandBraceHome) ?? root;
|
|
108
|
+
if (!expanded.startsWith("/") || expanded.split("/").includes(".."))
|
|
109
|
+
return null;
|
|
110
|
+
return expanded.replace(/\/+$/, "");
|
|
111
|
+
}
|
|
112
|
+
function isUnderRoot(path, root) {
|
|
113
|
+
return path === root || path.startsWith(`${root}/`);
|
|
114
|
+
}
|
|
115
|
+
function rewriteSegment(segment, options) {
|
|
116
|
+
const leading = segment.match(/^\s*/)?.[0] ?? "";
|
|
117
|
+
const rest = segment.slice(leading.length);
|
|
118
|
+
if (!rest)
|
|
119
|
+
return segment;
|
|
120
|
+
if (rest.startsWith("(") || rest.startsWith("{"))
|
|
121
|
+
return null;
|
|
122
|
+
if (rest.startsWith("'") || rest.startsWith('"'))
|
|
123
|
+
return null;
|
|
124
|
+
if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(rest))
|
|
125
|
+
return null;
|
|
126
|
+
const argv0Match = rest.match(/^\S+/);
|
|
127
|
+
if (!argv0Match)
|
|
128
|
+
return segment;
|
|
129
|
+
const argv0 = argv0Match[0];
|
|
130
|
+
if (argv0.includes("'") || argv0.includes('"'))
|
|
131
|
+
return null;
|
|
132
|
+
if (options.normalizedRoots && argv0.includes(".."))
|
|
133
|
+
return null;
|
|
134
|
+
const expanded = expandHomeForm(argv0, options.homedir, options.expandBraceHome);
|
|
135
|
+
if (!expanded)
|
|
136
|
+
return segment;
|
|
137
|
+
if (options.normalizedRoots && !options.normalizedRoots.some((root) => isUnderRoot(expanded, root))) {
|
|
138
|
+
return segment;
|
|
139
|
+
}
|
|
140
|
+
return `${leading}${expanded}${rest.slice(argv0.length)}`;
|
|
141
|
+
}
|
|
142
|
+
export function canonicalize(command, opts = {}) {
|
|
143
|
+
if (hasGlobalBailShape(command))
|
|
144
|
+
return command;
|
|
145
|
+
const home = opts.homedir ?? osHomedir();
|
|
146
|
+
const expandBraceHome = opts.expandBraceHome ?? false;
|
|
147
|
+
const normalizedRoots = opts.roots
|
|
148
|
+
? opts.roots.map((root) => normalizeRoot(root, home, expandBraceHome))
|
|
149
|
+
: null;
|
|
150
|
+
if (normalizedRoots?.some((root) => root === null))
|
|
151
|
+
return command;
|
|
152
|
+
const segments = commandSegments(command);
|
|
153
|
+
if (!segments)
|
|
154
|
+
return command;
|
|
155
|
+
const settings = {
|
|
156
|
+
homedir: home,
|
|
157
|
+
expandBraceHome,
|
|
158
|
+
roots: opts.roots ?? [],
|
|
159
|
+
normalizedRoots: normalizedRoots,
|
|
160
|
+
};
|
|
161
|
+
let rewritten = "";
|
|
162
|
+
let cursor = 0;
|
|
163
|
+
for (const segment of segments) {
|
|
164
|
+
const replacement = rewriteSegment(segment.text, settings);
|
|
165
|
+
if (replacement === null)
|
|
166
|
+
return command;
|
|
167
|
+
rewritten += command.slice(cursor, segment.start) + replacement;
|
|
168
|
+
cursor = segment.end;
|
|
169
|
+
}
|
|
170
|
+
rewritten += command.slice(cursor);
|
|
171
|
+
return rewritten;
|
|
172
|
+
}
|
|
173
|
+
export async function canonicalizeAndAudit(command, opts, context) {
|
|
174
|
+
const canonical = canonicalize(command, opts);
|
|
175
|
+
const auditNodes = canonical === command && hasGlobalBailShape(command) ? [command] : splitCommandNodes(canonical);
|
|
176
|
+
const records = auditNodes.map((node) => ({
|
|
177
|
+
ts: new Date().toISOString(),
|
|
178
|
+
sessionID: context.sessionID,
|
|
179
|
+
agent: context.agent,
|
|
180
|
+
callID: context.callID,
|
|
181
|
+
// Keep the exact node text the plugin saw. The Python join intentionally
|
|
182
|
+
// does not strip trailing redirections from this field because opencode's
|
|
183
|
+
// AST-to-permission-pattern behavior is not specified tightly enough to do
|
|
184
|
+
// that without risking a wrong-agent attribution.
|
|
185
|
+
command_node_text: node,
|
|
186
|
+
}));
|
|
187
|
+
for (const record of records) {
|
|
188
|
+
try {
|
|
189
|
+
await context.appendRecord(record);
|
|
190
|
+
}
|
|
191
|
+
catch (error) {
|
|
192
|
+
await context.debug(`permission audit append failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return canonical;
|
|
196
|
+
}
|
|
197
|
+
function parseOptions(options) {
|
|
198
|
+
const defaults = resolveDefaultLogPaths(process.env);
|
|
199
|
+
if (options === undefined) {
|
|
200
|
+
return { enabled: true, canonicalizeOptions: {}, auditLogPath: defaults.audit, debugLogPath: defaults.debug };
|
|
201
|
+
}
|
|
202
|
+
if (typeof options !== "object" || options === null || Array.isArray(options)) {
|
|
203
|
+
return { enabled: false, canonicalizeOptions: {}, auditLogPath: defaults.audit, debugLogPath: defaults.debug };
|
|
204
|
+
}
|
|
205
|
+
const raw = options;
|
|
206
|
+
if (raw.roots !== undefined && (!Array.isArray(raw.roots) || !raw.roots.every((root) => typeof root === "string"))) {
|
|
207
|
+
return { enabled: false, canonicalizeOptions: {}, auditLogPath: defaults.audit, debugLogPath: defaults.debug };
|
|
208
|
+
}
|
|
209
|
+
if (raw.expandBraceHome !== undefined && typeof raw.expandBraceHome !== "boolean") {
|
|
210
|
+
return { enabled: false, canonicalizeOptions: {}, auditLogPath: defaults.audit, debugLogPath: defaults.debug };
|
|
211
|
+
}
|
|
212
|
+
const home = typeof raw.homedir === "string" ? raw.homedir : osHomedir();
|
|
213
|
+
const expandBraceHome = raw.expandBraceHome ?? false;
|
|
214
|
+
if (raw.roots?.some((root) => normalizeRoot(root, home, expandBraceHome) === null)) {
|
|
215
|
+
return { enabled: false, canonicalizeOptions: {}, auditLogPath: defaults.audit, debugLogPath: defaults.debug };
|
|
216
|
+
}
|
|
217
|
+
return {
|
|
218
|
+
enabled: true,
|
|
219
|
+
canonicalizeOptions: {
|
|
220
|
+
roots: raw.roots,
|
|
221
|
+
expandBraceHome: raw.expandBraceHome,
|
|
222
|
+
homedir: raw.homedir,
|
|
223
|
+
},
|
|
224
|
+
auditLogPath: typeof raw.auditLogPath === "string" && isAbsolute(raw.auditLogPath) ? raw.auditLogPath : defaults.audit,
|
|
225
|
+
debugLogPath: typeof raw.debugLogPath === "string" && isAbsolute(raw.debugLogPath) ? raw.debugLogPath : defaults.debug,
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
export const PermissionCanonicalizerPlugin = async (_ctx, options) => {
|
|
229
|
+
const parsed = parseOptions(options);
|
|
230
|
+
const sessionAgents = new Map();
|
|
231
|
+
async function debug(message) {
|
|
232
|
+
try {
|
|
233
|
+
await mkdir(dirname(parsed.debugLogPath), { recursive: true });
|
|
234
|
+
await appendFile(parsed.debugLogPath, `${new Date().toISOString()} ${message}\n`, "utf8");
|
|
235
|
+
}
|
|
236
|
+
catch {
|
|
237
|
+
// Debug logging must never affect command execution.
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
if (!parsed.enabled) {
|
|
241
|
+
await debug("permission canonicalizer disabled due to malformed config");
|
|
242
|
+
return {};
|
|
243
|
+
}
|
|
244
|
+
await mkdir(dirname(parsed.auditLogPath), { recursive: true }).catch((error) => debug(`audit directory init failed: ${error}`));
|
|
245
|
+
await mkdir(dirname(parsed.debugLogPath), { recursive: true }).catch(() => undefined);
|
|
246
|
+
function rememberAgent(input) {
|
|
247
|
+
if (typeof input !== "object" || input === null)
|
|
248
|
+
return;
|
|
249
|
+
const record = input;
|
|
250
|
+
if (typeof record.sessionID === "string" && typeof record.agent === "string") {
|
|
251
|
+
sessionAgents.set(record.sessionID, record.agent);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
return {
|
|
255
|
+
"chat.params": async (input) => {
|
|
256
|
+
rememberAgent(input);
|
|
257
|
+
},
|
|
258
|
+
"chat.message": async (input) => {
|
|
259
|
+
rememberAgent(input);
|
|
260
|
+
},
|
|
261
|
+
"tool.execute.before": async (input, output) => {
|
|
262
|
+
const toolInput = input;
|
|
263
|
+
const toolOutput = output;
|
|
264
|
+
if (toolInput.tool !== "bash" || typeof toolOutput.args?.command !== "string")
|
|
265
|
+
return;
|
|
266
|
+
const original = toolOutput.args.command;
|
|
267
|
+
const sessionID = typeof toolInput.sessionID === "string" ? toolInput.sessionID : "";
|
|
268
|
+
const callID = typeof toolInput.callID === "string" ? toolInput.callID : "";
|
|
269
|
+
const canonical = await canonicalizeAndAudit(original, parsed.canonicalizeOptions, {
|
|
270
|
+
sessionID,
|
|
271
|
+
callID,
|
|
272
|
+
agent: sessionAgents.get(sessionID) ?? null,
|
|
273
|
+
appendRecord: async (record) => {
|
|
274
|
+
await appendFile(parsed.auditLogPath, `${JSON.stringify(record)}\n`, "utf8");
|
|
275
|
+
},
|
|
276
|
+
debug,
|
|
277
|
+
});
|
|
278
|
+
toolOutput.args.command = canonical;
|
|
279
|
+
await debug(canonical === original ? `pass-through: ${original}` : `rewrite: ${original} -> ${canonical}`);
|
|
280
|
+
},
|
|
281
|
+
};
|
|
282
|
+
};
|
|
283
|
+
export default { server: PermissionCanonicalizerPlugin };
|
package/package.json
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@skwid138/opencode-command-normalizer",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Command normalization plugin for opencode permission matching",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": {
|
|
8
|
+
"import": "./dist/index.js",
|
|
9
|
+
"types": "./dist/index.d.ts"
|
|
10
|
+
},
|
|
11
|
+
"./server": {
|
|
12
|
+
"import": "./dist/index.js"
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"dist",
|
|
17
|
+
"LICENSE",
|
|
18
|
+
"README.md",
|
|
19
|
+
"SECURITY.md"
|
|
20
|
+
],
|
|
21
|
+
"engines": {
|
|
22
|
+
"node": ">=22"
|
|
23
|
+
},
|
|
24
|
+
"scripts": {
|
|
25
|
+
"build": "tsc -p tsconfig.build.json",
|
|
26
|
+
"test": "vitest run",
|
|
27
|
+
"prepublishOnly": "npm run build",
|
|
28
|
+
"prepare": "husky || true"
|
|
29
|
+
},
|
|
30
|
+
"peerDependencies": {
|
|
31
|
+
"@opencode-ai/plugin": "^1.15.0"
|
|
32
|
+
},
|
|
33
|
+
"devDependencies": {
|
|
34
|
+
"@commitlint/cli": "^21.0.2",
|
|
35
|
+
"@commitlint/config-conventional": "^21.0.2",
|
|
36
|
+
"@opencode-ai/plugin": "^1.15.0",
|
|
37
|
+
"@opencode-ai/sdk": "^1.15.0",
|
|
38
|
+
"@types/node": "^22.0.0",
|
|
39
|
+
"husky": "^9.1.7",
|
|
40
|
+
"semantic-release": "^25.0.0",
|
|
41
|
+
"typescript": "^6.0.3",
|
|
42
|
+
"vitest": "^4.1.7"
|
|
43
|
+
},
|
|
44
|
+
"publishConfig": {
|
|
45
|
+
"access": "public"
|
|
46
|
+
},
|
|
47
|
+
"repository": {
|
|
48
|
+
"type": "git",
|
|
49
|
+
"url": "https://github.com/skwid138/opencode-command-normalizer.git"
|
|
50
|
+
},
|
|
51
|
+
"homepage": "https://github.com/skwid138/opencode-command-normalizer",
|
|
52
|
+
"bugs": {
|
|
53
|
+
"url": "https://github.com/skwid138/opencode-command-normalizer/issues"
|
|
54
|
+
},
|
|
55
|
+
"license": "MIT",
|
|
56
|
+
"keywords": [
|
|
57
|
+
"opencode",
|
|
58
|
+
"opencode-plugin",
|
|
59
|
+
"plugin",
|
|
60
|
+
"permission",
|
|
61
|
+
"bash",
|
|
62
|
+
"canonicalize",
|
|
63
|
+
"security"
|
|
64
|
+
]
|
|
65
|
+
}
|