@atbash/atbash-openclaw 0.1.19-dev.0 → 0.1.19-dev.2
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 +28 -0
- package/README.md +87 -16
- package/dist/config.js +28 -0
- package/dist/event-normalizer.d.ts +54 -0
- package/dist/event-normalizer.js +253 -0
- package/dist/event-normalizer.test.d.ts +1 -0
- package/dist/event-normalizer.test.js +282 -0
- package/dist/index.js +5 -2
- package/openclaw.plugin.json +6 -1
- package/package.json +16 -6
package/LICENSE
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
Atbash CLI — Proprietary Software License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Atbash,
|
|
4
|
+
All rights reserved.
|
|
5
|
+
|
|
6
|
+
Redistribution and use in source and binary forms, with or without
|
|
7
|
+
modification, are permitted provided that the following conditions are met:
|
|
8
|
+
|
|
9
|
+
1. Redistributions of source code must retain the above copyright notice, this
|
|
10
|
+
list of conditions and the following disclaimer.
|
|
11
|
+
2. Redistributions in binary form must reproduce the above copyright notice,
|
|
12
|
+
this list of conditions and the following disclaimer in the documentation
|
|
13
|
+
and/or other materials provided with the distribution.
|
|
14
|
+
|
|
15
|
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
|
|
16
|
+
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
|
|
17
|
+
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
|
18
|
+
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
|
|
19
|
+
ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
|
|
20
|
+
(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
|
|
21
|
+
LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
|
|
22
|
+
ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
|
23
|
+
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
|
|
24
|
+
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
|
25
|
+
|
|
26
|
+
The views and conclusions contained in the software and documentation are those
|
|
27
|
+
of the authors and should not be interpreted as representing official policies,
|
|
28
|
+
either expressed or implied, Atbash.
|
package/README.md
CHANGED
|
@@ -7,9 +7,11 @@ If you're not using OpenClaw and want to use ATBASH from your own code, install
|
|
|
7
7
|
## Install
|
|
8
8
|
|
|
9
9
|
```bash
|
|
10
|
-
openclaw plugins install @atbash/atbash-openclaw
|
|
10
|
+
openclaw plugins install @atbash/atbash-openclaw@0.1.19-dev.1
|
|
11
11
|
```
|
|
12
12
|
|
|
13
|
+
**The judge endpoint and chain ids are compiled into the build**, so the version selects the environment. A `-dev.N` version targets the development environment. `openclaw plugins list` shows which you have. No configuration repoints a build afterwards — the wrong one loads cleanly, fires its hook, and then fails every judge call because the agent does not exist on the chain that build targets. Organization names are not unique across environments, so a resolving org name is not proof the build is right.
|
|
14
|
+
|
|
13
15
|
The plugin signs audit transactions locally with your agent's secp256k1 private key. The key never leaves your machine; only signed bytes plus the corresponding public key are transmitted.
|
|
14
16
|
|
|
15
17
|
## Get an agent key
|
|
@@ -34,56 +36,114 @@ Contact the ATBASH team to register an agent and obtain a key pair.
|
|
|
34
36
|
|
|
35
37
|
## Configure it in OpenClaw
|
|
36
38
|
|
|
37
|
-
|
|
39
|
+
`openclaw plugins install` already recorded where the plugin lives, enabled it, and — if you keep a
|
|
40
|
+
`plugins.allow` list — added `atbash-openclaw` to it. Never hand-write the install directory: OpenClaw has
|
|
41
|
+
moved it between releases and records whichever it used, which is what it loads from. `openclaw plugins list`
|
|
42
|
+
shows the current state.
|
|
43
|
+
|
|
44
|
+
All that is left is your configuration. Open `~/.openclaw/openclaw.json` and add, keeping the plugins
|
|
45
|
+
already there:
|
|
38
46
|
|
|
39
47
|
```json
|
|
40
48
|
{
|
|
41
49
|
"plugins": {
|
|
42
|
-
"allow": [
|
|
43
|
-
"openclaw"
|
|
44
|
-
],
|
|
45
|
-
"load": {
|
|
46
|
-
"paths": [
|
|
47
|
-
"/Users/<your-username>/.openclaw/extensions/openclaw"
|
|
48
|
-
]
|
|
49
|
-
},
|
|
50
50
|
"entries": {
|
|
51
|
-
"openclaw": {
|
|
51
|
+
"atbash-openclaw": {
|
|
52
52
|
"enabled": true,
|
|
53
53
|
"config": {
|
|
54
54
|
"enabled": true,
|
|
55
55
|
"enforceDecision": true,
|
|
56
|
-
"chromiaSecretPath": "~/.config/atbash/guard-client-key"
|
|
56
|
+
"chromiaSecretPath": "~/.config/atbash/guard-client-key",
|
|
57
|
+
"orgName": "<your org name>"
|
|
57
58
|
},
|
|
58
59
|
"hooks": {
|
|
59
60
|
"allowConversationAccess": true,
|
|
60
61
|
"allowPromptInjection": true
|
|
62
|
+
}
|
|
61
63
|
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
### Advanced configuration
|
|
70
|
+
|
|
71
|
+
The snippet below shows every supported config field with inline comments. Remove fields you don't need — all are optional except `orgName` (required when your org is on a private chain).
|
|
72
|
+
|
|
73
|
+
```jsonc
|
|
74
|
+
{
|
|
75
|
+
"plugins": {
|
|
76
|
+
"entries": {
|
|
77
|
+
"atbash-openclaw": {
|
|
78
|
+
"enabled": true,
|
|
79
|
+
"config": {
|
|
80
|
+
"enabled": true,
|
|
81
|
+
"enforceDecision": true,
|
|
82
|
+
"chromiaSecretPath": "~/.config/atbash/guard-client-key",
|
|
83
|
+
"orgName": "<your-org-name>",
|
|
84
|
+
"debug": false,
|
|
85
|
+
|
|
86
|
+
// --- Memory guard (optional) ---
|
|
87
|
+
// Root directory used to locate the memory pointer file and MEMORY.md.
|
|
88
|
+
"memoryWorkspaceDir": "~/.openclaw/workspace",
|
|
89
|
+
// Or set an explicit path to MEMORY.md (overrides memoryWorkspaceDir):
|
|
90
|
+
// "memoryFilePath": "~/.openclaw/workspace/MEMORY.md",
|
|
91
|
+
// How long (ms) to trust the local memory-chain cache before re-fetching.
|
|
92
|
+
"memorySyncTTLMs": 30000,
|
|
93
|
+
// Block memory reads when a rolled-back version scores below this (1–10).
|
|
94
|
+
// Default 1 = warn only. Raise to e.g. 3 to refuse poisoned rollbacks.
|
|
95
|
+
"memoryRollbackMinScore": 1,
|
|
96
|
+
// Additional path substrings to classify as memory (extends SDK built-ins).
|
|
97
|
+
// "memoryPathPatterns": ["/my-agent/memory/"],
|
|
98
|
+
|
|
99
|
+
// --- Self-hosted judge (optional, advanced) ---
|
|
100
|
+
// "judgeEndpoint": "https://your-judge.example.com/api/v1/judge",
|
|
101
|
+
// "judgeEndpointPolicy": "self-hosted",
|
|
102
|
+
// "judgeVerifyPubKey": "<66-hex-char-secp256k1-pubkey>"
|
|
103
|
+
},
|
|
104
|
+
"hooks": {
|
|
105
|
+
"allowConversationAccess": true,
|
|
106
|
+
"allowPromptInjection": true
|
|
107
|
+
}
|
|
62
108
|
}
|
|
63
109
|
}
|
|
64
110
|
}
|
|
65
111
|
}
|
|
66
112
|
```
|
|
67
113
|
|
|
114
|
+
The plugin id is `atbash-openclaw` — it comes from the `id` field in this package's `openclaw.plugin.json`,
|
|
115
|
+
not from the npm package name. A `plugins.entries` key that does not match the id is not an error: the
|
|
116
|
+
plugin loads unconfigured and audits nothing.
|
|
117
|
+
|
|
68
118
|
### Config fields
|
|
69
119
|
|
|
70
120
|
| Field | Type | Default | What it does |
|
|
71
121
|
|---|---|---|---|
|
|
72
122
|
| `enabled` | bool | `true` | Master switch. `false` = plugin returns immediately. |
|
|
73
|
-
| `enforceDecision` | bool | `true` |
|
|
123
|
+
| `enforceDecision` | bool | `true` | Fail-closed switch. When `true` (default), verdicts of `BLOCK`, `HOLD`, `ERROR`, and any unrecognized verdict block the tool call. When `false`, the plugin runs fail-open: all verdicts allow the tool call through and the outcome is only logged — useful for monitoring without enforcement. |
|
|
74
124
|
| `chromiaSecretPath` | string | `~/.config/atbash/guard-client-key` | Path to the agent key file. Supports `~/`. |
|
|
125
|
+
| `orgName` | string | — | Organization the agent was onboarded under, spelled as the dashboard shows it. The plugin resolves which chain to query from this, so it is required when the org is on a private chain. |
|
|
75
126
|
| `debug` | bool | `false` | When `true`, logs a one-line probe for every `before_tool_call` showing `toolName`, top-level event/ctx/args keys, and whether the call was classified as a memory write. Useful for verifying memory-write classifier coverage against real traffic. Logs shape only — argument values are never printed. |
|
|
127
|
+
| `judgeEndpoint` | string | — | Override the judge URL. Leave unset to use the compiled-in default for the installed environment (prod or dev). |
|
|
128
|
+
| `judgeEndpointPolicy` | `"default"` \| `"self-hosted"` | `"default"` | Set to `"self-hosted"` when pointing at your own judge; requires `judgeVerifyPubKey`. |
|
|
129
|
+
| `judgeVerifyPubKey` | string | — | 66-char secp256k1 compressed pubkey used to verify the self-hosted judge response signature. Required when `judgeEndpointPolicy` is `"self-hosted"`. |
|
|
130
|
+
| `memoryPathPatterns` | string[] | SDK defaults | Extra path substrings the memory classifier uses to identify memory-shaped files (e.g. `"/my-agent/memory/"`). Extends, does not replace, the SDK's built-in list. |
|
|
131
|
+
| `memoryWorkspaceDir` | string | `process.cwd()` | Workspace root used to locate the memory pointer file and `MEMORY.md`. Supports `~/`. |
|
|
132
|
+
| `memoryFilePath` | string | `<memoryWorkspaceDir>/MEMORY.md` | Explicit path to the agent's MEMORY.md. Overrides the `memoryWorkspaceDir`-relative default. Supports `~/`. |
|
|
133
|
+
| `memorySyncTTLMs` | integer | `30000` | How long (ms) to trust the locally cached memory chain pointer before re-fetching from the chain. Lower values increase freshness at the cost of more chain queries. |
|
|
134
|
+
| `memoryRollbackMinScore` | integer (1–10) | `1` | Block memory reads when a rolled-back version's safety score is below this threshold. Default `1` means warn only, never block. Raise to e.g. `3` to refuse reading poisoned rollbacks. |
|
|
135
|
+
| `judgeTimeoutMs` | integer | — | Max milliseconds to wait for a judge or memory-guard response before treating it as a timeout. When triggered, the plugin applies the same fail-closed/open logic as any other error (`enforceDecision` controls the outcome). Unset means the plugin waits as long as the host allows. Set to e.g. `12000` when the host has a hard hook deadline and you want the plugin to fail gracefully instead of being killed mid-await. |
|
|
76
136
|
|
|
77
137
|
## Updating
|
|
78
138
|
|
|
79
139
|
```bash
|
|
80
|
-
openclaw plugins update
|
|
140
|
+
openclaw plugins update atbash-openclaw
|
|
81
141
|
```
|
|
82
142
|
|
|
83
143
|
## Uninstalling
|
|
84
144
|
|
|
85
145
|
```bash
|
|
86
|
-
openclaw plugins uninstall
|
|
146
|
+
openclaw plugins uninstall atbash-openclaw
|
|
87
147
|
```
|
|
88
148
|
|
|
89
149
|
The agent key file at `~/.config/atbash/guard-client-key` is **not** removed — delete it manually if you want to retire the agent identity.
|
|
@@ -111,11 +171,22 @@ The agent's own tool execution is unaffected — only the data sent to the judge
|
|
|
111
171
|
## Troubleshooting
|
|
112
172
|
|
|
113
173
|
**Tool calls aren't being audited.**
|
|
114
|
-
Check `enabled: true` is set both on the entry (`plugins.entries
|
|
174
|
+
Check `enabled: true` is set both on the entry (`plugins.entries["atbash-openclaw"].enabled`) and inside `config` (`config.enabled`).
|
|
115
175
|
|
|
116
176
|
**Every tool call is blocked, even safe ones.**
|
|
117
177
|
The plugin is fail-closed: any pipeline error blocks. Look for `[atbash] …` warnings in the OpenClaw log. Most often: missing key, expired key, or restricted egress.
|
|
118
178
|
|
|
179
|
+
**Plugin loads but config is not applied (audit runs on defaults).**
|
|
180
|
+
The `plugins.entries` key must be exactly `"atbash-openclaw"` — the plugin's id from its `openclaw.plugin.json`. Former names (`"openclaw"`, `"atbash-plugin"`) are silently ignored; the plugin logs a warning at startup but otherwise runs unconfigured. Check the OpenClaw startup log for `[atbash] found config under former plugin id` and move the settings block to `plugins.entries["atbash-openclaw"]`.
|
|
181
|
+
|
|
182
|
+
## Maintainer release contract
|
|
183
|
+
|
|
184
|
+
Development packages are released only through the protected **Publish development package** GitHub workflow. The workflow requires the `npm` environment, the exact `PUBLISH` confirmation, and npm trusted-publisher configuration for this repository and workflow. It never accepts a registry token from package source.
|
|
185
|
+
|
|
186
|
+
The release guard refuses a dirty checkout, reads the live package and SDK tags, chooses a collision-free prerelease, copies only Git-tracked files into a temporary staging tree, pins the exact SDK version there, installs and tests from the staged lockfile, cleans and rebuilds `dist`, validates the tarball, rechecks registry state, publishes, and verifies the resulting `dev` tag. The tracked source manifest is never rewritten by a release.
|
|
187
|
+
|
|
188
|
+
Because this repository is private, npm provenance statements are not supported. Trusted publishing still removes long-lived npm tokens; configure the protected npm environment and trusted publisher before enabling the workflow.
|
|
189
|
+
|
|
119
190
|
## License
|
|
120
191
|
|
|
121
192
|
Proprietary — all rights reserved. See [LICENSE](https://atbash.ai/license). Commercial licensing inquiries: contact the Atbash team.
|
package/dist/config.js
CHANGED
|
@@ -1,5 +1,33 @@
|
|
|
1
1
|
const PLUGIN_ID = "atbash-openclaw";
|
|
2
|
+
/**
|
|
3
|
+
* Ids this plugin shipped under before it was renamed. OpenClaw matches
|
|
4
|
+
* `plugins.entries` keys against the manifest id, so an entry left under one of
|
|
5
|
+
* these is inert — no error, no config, and the plugin runs on defaults. That
|
|
6
|
+
* silence is what makes the rename expensive, so we say something instead.
|
|
7
|
+
*/
|
|
8
|
+
const FORMER_PLUGIN_IDS = ["openclaw", "atbash-plugin"];
|
|
2
9
|
/** @internal */
|
|
3
10
|
export function readPluginConfig(api) {
|
|
4
11
|
return api.config?.plugins?.entries?.[PLUGIN_ID]?.config ?? {};
|
|
5
12
|
}
|
|
13
|
+
/**
|
|
14
|
+
* Warn when configuration is found under a former plugin id.
|
|
15
|
+
*
|
|
16
|
+
* @internal
|
|
17
|
+
*/
|
|
18
|
+
export function warnOnFormerIdConfig(api) {
|
|
19
|
+
const plugins = api.config?.plugins;
|
|
20
|
+
const entries = plugins?.entries ?? {};
|
|
21
|
+
const allow = Array.isArray(plugins?.allow) ? plugins.allow : [];
|
|
22
|
+
const stale = FORMER_PLUGIN_IDS.filter((id) => entries[id] !== undefined || allow.includes(id));
|
|
23
|
+
if (stale.length === 0)
|
|
24
|
+
return;
|
|
25
|
+
const configured = entries[PLUGIN_ID]?.config !== undefined;
|
|
26
|
+
api.logger?.warn?.(`[atbash] found config under former plugin id${stale.length > 1 ? "s" : ""} ` +
|
|
27
|
+
`${stale.map((id) => `"${id}"`).join(", ")} — this plugin's id is "${PLUGIN_ID}" ` +
|
|
28
|
+
`and those entries are ignored` +
|
|
29
|
+
(configured
|
|
30
|
+
? ". Remove them to avoid confusion."
|
|
31
|
+
: `. No "${PLUGIN_ID}" config found, so the plugin is running on defaults — ` +
|
|
32
|
+
`move your settings under "${PLUGIN_ID}".`), { formerIds: stale, configured });
|
|
33
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Normalize openclaw tool events into the shape the SDK's memory classifier
|
|
3
|
+
* expects, so patch-style tools like `apply_patch` route through the memory
|
|
4
|
+
* guard instead of the generic tool-call audit.
|
|
5
|
+
*
|
|
6
|
+
* The SDK classifier reads:
|
|
7
|
+
* - `event.toolName` in ["write", "edit", "multiedit"] (case-insensitive)
|
|
8
|
+
* - `event.params.file_path` (or `.path`, `.filename`, `.target`, `.file`)
|
|
9
|
+
* - `event.params.new_string` (or `.newText`, `.replacement`, `.content`)
|
|
10
|
+
*
|
|
11
|
+
* `apply_patch` events use `params.input` holding a raw OpenAI-style patch:
|
|
12
|
+
*
|
|
13
|
+
* *** Begin Patch
|
|
14
|
+
* *** Update File: /Users/…/MEMORY.md
|
|
15
|
+
* @@
|
|
16
|
+
* - old line
|
|
17
|
+
* + new line
|
|
18
|
+
* *** End Patch
|
|
19
|
+
*
|
|
20
|
+
* Which the classifier cannot introspect. This module parses the patch header,
|
|
21
|
+
* extracts the target file, and emits a synthetic `edit` (or `write` for
|
|
22
|
+
* `*** Add File`) event with the standard fields populated. Multi-file patches
|
|
23
|
+
* scan ALL file headers and normalize to the FIRST memory-shaped file found —
|
|
24
|
+
* so a non-memory file appearing before MEMORY.md in the patch doesn't hide it.
|
|
25
|
+
*
|
|
26
|
+
* Events the normalizer doesn't recognize are returned unchanged.
|
|
27
|
+
*/
|
|
28
|
+
interface OpenClawEvent {
|
|
29
|
+
toolName?: string;
|
|
30
|
+
tool_name?: string;
|
|
31
|
+
params?: Record<string, unknown>;
|
|
32
|
+
args?: Record<string, unknown>;
|
|
33
|
+
arguments?: Record<string, unknown>;
|
|
34
|
+
[k: string]: unknown;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* If `event` is a patch-style tool call that touches a memory-shaped file,
|
|
38
|
+
* return a synthetic event the classifier can read. Otherwise return null.
|
|
39
|
+
*
|
|
40
|
+
* Multi-file patches are scanned in order; the first memory-shaped file
|
|
41
|
+
* encountered is used — a non-memory file appearing first does not hide a
|
|
42
|
+
* MEMORY.md that follows it.
|
|
43
|
+
*/
|
|
44
|
+
export declare function normalizeApplyPatch(event: unknown): OpenClawEvent | null;
|
|
45
|
+
export declare function normalizeShellExec(event: unknown): OpenClawEvent | null;
|
|
46
|
+
/**
|
|
47
|
+
* Apply every event normalizer in sequence. Returns the input unchanged when
|
|
48
|
+
* no normalizer matches. Pass the result to `handleBeforeToolCall` so the
|
|
49
|
+
* memory classifier sees a canonical shape. For the judge path, pass the
|
|
50
|
+
* original (un-normalized) event to `mapEventToInput` — the judge should log
|
|
51
|
+
* the real tool name and args, not the synthetic shape.
|
|
52
|
+
*/
|
|
53
|
+
export declare function normalizeEvent(event: unknown): unknown;
|
|
54
|
+
export {};
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Normalize openclaw tool events into the shape the SDK's memory classifier
|
|
3
|
+
* expects, so patch-style tools like `apply_patch` route through the memory
|
|
4
|
+
* guard instead of the generic tool-call audit.
|
|
5
|
+
*
|
|
6
|
+
* The SDK classifier reads:
|
|
7
|
+
* - `event.toolName` in ["write", "edit", "multiedit"] (case-insensitive)
|
|
8
|
+
* - `event.params.file_path` (or `.path`, `.filename`, `.target`, `.file`)
|
|
9
|
+
* - `event.params.new_string` (or `.newText`, `.replacement`, `.content`)
|
|
10
|
+
*
|
|
11
|
+
* `apply_patch` events use `params.input` holding a raw OpenAI-style patch:
|
|
12
|
+
*
|
|
13
|
+
* *** Begin Patch
|
|
14
|
+
* *** Update File: /Users/…/MEMORY.md
|
|
15
|
+
* @@
|
|
16
|
+
* - old line
|
|
17
|
+
* + new line
|
|
18
|
+
* *** End Patch
|
|
19
|
+
*
|
|
20
|
+
* Which the classifier cannot introspect. This module parses the patch header,
|
|
21
|
+
* extracts the target file, and emits a synthetic `edit` (or `write` for
|
|
22
|
+
* `*** Add File`) event with the standard fields populated. Multi-file patches
|
|
23
|
+
* scan ALL file headers and normalize to the FIRST memory-shaped file found —
|
|
24
|
+
* so a non-memory file appearing before MEMORY.md in the patch doesn't hide it.
|
|
25
|
+
*
|
|
26
|
+
* Events the normalizer doesn't recognize are returned unchanged.
|
|
27
|
+
*/
|
|
28
|
+
/* ── shared memory-path detection ─────────────────────────────────────── */
|
|
29
|
+
/**
|
|
30
|
+
* File-path substrings that mark a memory-shaped target. Kept independent of
|
|
31
|
+
* the SDK classifier's list because the normalizers run BEFORE the classifier —
|
|
32
|
+
* if we synthesize a read/write event for a non-memory path, the guard would
|
|
33
|
+
* see the wrong tool name. Only synthesize when we can confirm the path is
|
|
34
|
+
* memory-shaped; otherwise pass the event through unchanged.
|
|
35
|
+
*/
|
|
36
|
+
const MEMORY_PATH_TOKENS = [
|
|
37
|
+
"memory.md",
|
|
38
|
+
"dreams.md",
|
|
39
|
+
"claude.md",
|
|
40
|
+
"agents.md",
|
|
41
|
+
"/.openclaw/",
|
|
42
|
+
"/.claude/projects/",
|
|
43
|
+
"/memory/",
|
|
44
|
+
];
|
|
45
|
+
function pathLooksLikeMemory(path) {
|
|
46
|
+
const lower = path.replace(/\\/g, "/").toLowerCase();
|
|
47
|
+
return MEMORY_PATH_TOKENS.some((tok) => lower.includes(tok));
|
|
48
|
+
}
|
|
49
|
+
/* ── apply_patch normalization ─────────────────────────────────────────── */
|
|
50
|
+
const PATCH_FILE_HEADER_RE = /^\*\*\* (Update|Add|Delete) File:\s*(.+?)\s*$/mg;
|
|
51
|
+
/**
|
|
52
|
+
* Extract `+` lines from the section of a multi-file patch that belongs to
|
|
53
|
+
* `filePath`, stopping at the next file header. Returns joined new content.
|
|
54
|
+
*/
|
|
55
|
+
function extractNewContentForFile(input, filePath) {
|
|
56
|
+
const lines = input.split("\n");
|
|
57
|
+
let collecting = false;
|
|
58
|
+
const newLines = [];
|
|
59
|
+
for (const line of lines) {
|
|
60
|
+
const headerMatch = /^\*\*\* (?:Update|Add|Delete) File:\s*(.+?)\s*$/.exec(line);
|
|
61
|
+
if (headerMatch) {
|
|
62
|
+
collecting = headerMatch[1] === filePath;
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
if (!collecting)
|
|
66
|
+
continue;
|
|
67
|
+
if (line.startsWith("*** ") || line.startsWith("@@"))
|
|
68
|
+
continue;
|
|
69
|
+
if (line.startsWith("+"))
|
|
70
|
+
newLines.push(line.slice(1));
|
|
71
|
+
}
|
|
72
|
+
return newLines.join("\n");
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* If `event` is a patch-style tool call that touches a memory-shaped file,
|
|
76
|
+
* return a synthetic event the classifier can read. Otherwise return null.
|
|
77
|
+
*
|
|
78
|
+
* Multi-file patches are scanned in order; the first memory-shaped file
|
|
79
|
+
* encountered is used — a non-memory file appearing first does not hide a
|
|
80
|
+
* MEMORY.md that follows it.
|
|
81
|
+
*/
|
|
82
|
+
export function normalizeApplyPatch(event) {
|
|
83
|
+
if (!event || typeof event !== "object")
|
|
84
|
+
return null;
|
|
85
|
+
const ev = event;
|
|
86
|
+
const toolName = (ev.toolName ?? ev.tool_name ?? "").toString().toLowerCase();
|
|
87
|
+
if (toolName !== "apply_patch")
|
|
88
|
+
return null;
|
|
89
|
+
const params = ev.params ?? ev.args ?? ev.arguments ?? {};
|
|
90
|
+
const input = params.input;
|
|
91
|
+
if (typeof input !== "string" || input.length === 0)
|
|
92
|
+
return null;
|
|
93
|
+
// Normalize CRLF so path extraction and content splitting work on all platforms.
|
|
94
|
+
const normalized = input.replace(/\r\n/g, "\n");
|
|
95
|
+
// Scan all file headers; find the first memory-shaped non-delete target.
|
|
96
|
+
const re = new RegExp(PATCH_FILE_HEADER_RE.source, "mg");
|
|
97
|
+
let verb;
|
|
98
|
+
let filePath;
|
|
99
|
+
let match;
|
|
100
|
+
while ((match = re.exec(normalized)) !== null) {
|
|
101
|
+
const [, v, fp] = match;
|
|
102
|
+
if (v === "Delete")
|
|
103
|
+
continue;
|
|
104
|
+
if (pathLooksLikeMemory(fp)) {
|
|
105
|
+
verb = v;
|
|
106
|
+
filePath = fp;
|
|
107
|
+
break;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
if (!verb || !filePath)
|
|
111
|
+
return null;
|
|
112
|
+
const newContent = extractNewContentForFile(normalized, filePath);
|
|
113
|
+
const synthesizedTool = verb === "Add" ? "write" : "edit";
|
|
114
|
+
return {
|
|
115
|
+
...ev,
|
|
116
|
+
toolName: synthesizedTool,
|
|
117
|
+
params: {
|
|
118
|
+
...params,
|
|
119
|
+
file_path: filePath,
|
|
120
|
+
// "write" reads `content`; "edit" reads `new_string`. Populate both so
|
|
121
|
+
// whichever key the classifier picks first finds the content.
|
|
122
|
+
content: newContent,
|
|
123
|
+
new_string: newContent,
|
|
124
|
+
},
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
/* ── shell-exec normalization ──────────────────────────────────────────── */
|
|
128
|
+
/** Strip surrounding single or double quotes; return the raw string otherwise. */
|
|
129
|
+
function unquote(s) {
|
|
130
|
+
const t = s.trim();
|
|
131
|
+
if ((t.startsWith('"') && t.endsWith('"')) || (t.startsWith("'") && t.endsWith("'"))) {
|
|
132
|
+
return t.slice(1, -1);
|
|
133
|
+
}
|
|
134
|
+
return t;
|
|
135
|
+
}
|
|
136
|
+
/** Any shell metachar that turns a "simple command + file" into something we
|
|
137
|
+
* can't reliably parse: pipes, redirects (other than the write shape we
|
|
138
|
+
* handle explicitly), command chaining, subshells, backgrounding, expansion.
|
|
139
|
+
* When present we bail out so the normal judge sees the raw exec. */
|
|
140
|
+
const SHELL_UNSAFE = /[|;&`$]|<\(|\)/;
|
|
141
|
+
function hasUnsafeShellMetachars(command) {
|
|
142
|
+
return SHELL_UNSAFE.test(command);
|
|
143
|
+
}
|
|
144
|
+
/** Shell-read shapes: `cat|head|tail|less|more|grep [args] <path>` and
|
|
145
|
+
* `sed <expr> <path>` (no `-i`). Returns the extracted path or null. */
|
|
146
|
+
function extractShellReadPath(command) {
|
|
147
|
+
if (hasUnsafeShellMetachars(command))
|
|
148
|
+
return null;
|
|
149
|
+
const trimmed = command.trim();
|
|
150
|
+
// grep <pattern> <path> — path is the last unquoted arg
|
|
151
|
+
const grep = /^grep\b(?:\s+-\S+)*\s+\S+\s+(.+)$/.exec(trimmed);
|
|
152
|
+
if (grep)
|
|
153
|
+
return unquote(grep[1]);
|
|
154
|
+
// sed WITHOUT -i is a read
|
|
155
|
+
const sed = /^sed\b(?!\s+-i\b)(?:\s+-\S+)*\s+\S+\s+(.+)$/.exec(trimmed);
|
|
156
|
+
if (sed)
|
|
157
|
+
return unquote(sed[1]);
|
|
158
|
+
// cat / less / more / head [-n N] / tail [-n N] — path is the last arg
|
|
159
|
+
const simpleRead = /^(?:cat|less|more|head|tail)\b(?:\s+-\S+(?:\s+\S+)?)*\s+(.+)$/.exec(trimmed);
|
|
160
|
+
if (simpleRead)
|
|
161
|
+
return unquote(simpleRead[1]);
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
/** Shell-write shapes. Returns {path, content} or null. */
|
|
165
|
+
function extractShellWrite(command) {
|
|
166
|
+
// Writes explicitly use `>`/`>>`, so a metachar-guard that rejects `>` would
|
|
167
|
+
// also reject every real write. Instead reject only the metachars that mean
|
|
168
|
+
// "there's more than one command here": pipes, `;`, `&&`, `||`, subshells.
|
|
169
|
+
if (/[|;&`$]|<\(|\)/.test(command))
|
|
170
|
+
return null;
|
|
171
|
+
const trimmed = command.trim();
|
|
172
|
+
// echo/printf "..." > path (or >> path)
|
|
173
|
+
const redirect = /^(?:echo|printf)\s+(.+?)\s*>>?\s*(\S+)\s*$/.exec(trimmed);
|
|
174
|
+
if (redirect) {
|
|
175
|
+
return { content: unquote(redirect[1]), path: unquote(redirect[2]) };
|
|
176
|
+
}
|
|
177
|
+
// tee [-a] path — content arrives on stdin, unknown; scanner gets empty content
|
|
178
|
+
const tee = /^tee\b(?:\s+-\S+)*\s+(\S+)\s*$/.exec(trimmed);
|
|
179
|
+
if (tee)
|
|
180
|
+
return { content: "", path: unquote(tee[1]) };
|
|
181
|
+
// sed -i '<expr>' path — in-place edit; the expression is the intent
|
|
182
|
+
const sedInplace = /^sed\s+-i(?:\s+-\S+)*\s+(\S+)\s+(\S+)\s*$/.exec(trimmed);
|
|
183
|
+
if (sedInplace)
|
|
184
|
+
return { content: unquote(sedInplace[1]), path: unquote(sedInplace[2]) };
|
|
185
|
+
// awk -i inplace ... path — same shape
|
|
186
|
+
const awkInplace = /^awk\s+-i\s+inplace\b.*\s+(\S+)\s*$/.exec(trimmed);
|
|
187
|
+
if (awkInplace)
|
|
188
|
+
return { content: "", path: unquote(awkInplace[1]) };
|
|
189
|
+
// cp/mv src dest — source content is unknown; scanner gets empty content so
|
|
190
|
+
// the classifier routes it through the memory guard for policy gating even
|
|
191
|
+
// though we can't scan the incoming content.
|
|
192
|
+
const cpMv = /^(?:cp|mv)\b(?:\s+-\S+)*\s+\S+\s+(\S+)\s*$/.exec(trimmed);
|
|
193
|
+
if (cpMv)
|
|
194
|
+
return { content: "", path: unquote(cpMv[1]) };
|
|
195
|
+
return null;
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* If `event` is a shell-exec running an unambiguous read or write against a
|
|
199
|
+
* memory-shaped path, return a synthetic `read`/`write` event so the memory
|
|
200
|
+
* classifier can route it. Otherwise return null.
|
|
201
|
+
*
|
|
202
|
+
* Deliberately narrow: pipes, subshells, here-docs, multi-line scripts, and
|
|
203
|
+
* commands whose path can't be lifted out are left as `exec` for the normal
|
|
204
|
+
* judge to evaluate. Better to miss a rare shape than to misclassify a
|
|
205
|
+
* non-memory command as memory.
|
|
206
|
+
*/
|
|
207
|
+
const SHELL_EXEC_TOOL_NAMES = new Set(["exec", "bash", "shell", "run_command", "run_bash"]);
|
|
208
|
+
export function normalizeShellExec(event) {
|
|
209
|
+
if (!event || typeof event !== "object")
|
|
210
|
+
return null;
|
|
211
|
+
const ev = event;
|
|
212
|
+
const toolName = (ev.toolName ?? ev.tool_name ?? "").toString().toLowerCase();
|
|
213
|
+
if (!SHELL_EXEC_TOOL_NAMES.has(toolName))
|
|
214
|
+
return null;
|
|
215
|
+
const params = ev.params ?? ev.args ?? ev.arguments ?? {};
|
|
216
|
+
const p = params;
|
|
217
|
+
// Different runtimes use different key names for the shell command.
|
|
218
|
+
const rawCommand = p.command ?? p.cmd ?? p.script;
|
|
219
|
+
const command = typeof rawCommand === "string" ? rawCommand : null;
|
|
220
|
+
if (!command || command.length === 0)
|
|
221
|
+
return null;
|
|
222
|
+
const write = extractShellWrite(command);
|
|
223
|
+
if (write && pathLooksLikeMemory(write.path)) {
|
|
224
|
+
return {
|
|
225
|
+
...ev,
|
|
226
|
+
toolName: "write",
|
|
227
|
+
params: {
|
|
228
|
+
...p,
|
|
229
|
+
file_path: write.path,
|
|
230
|
+
content: write.content,
|
|
231
|
+
},
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
const readPath = extractShellReadPath(command);
|
|
235
|
+
if (readPath && pathLooksLikeMemory(readPath)) {
|
|
236
|
+
return {
|
|
237
|
+
...ev,
|
|
238
|
+
toolName: "read",
|
|
239
|
+
params: { ...p, path: readPath },
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
return null;
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* Apply every event normalizer in sequence. Returns the input unchanged when
|
|
246
|
+
* no normalizer matches. Pass the result to `handleBeforeToolCall` so the
|
|
247
|
+
* memory classifier sees a canonical shape. For the judge path, pass the
|
|
248
|
+
* original (un-normalized) event to `mapEventToInput` — the judge should log
|
|
249
|
+
* the real tool name and args, not the synthetic shape.
|
|
250
|
+
*/
|
|
251
|
+
export function normalizeEvent(event) {
|
|
252
|
+
return normalizeApplyPatch(event) ?? normalizeShellExec(event) ?? event;
|
|
253
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { normalizeApplyPatch, normalizeEvent, normalizeShellExec } from "./event-normalizer";
|
|
3
|
+
const UPDATE_PATCH = [
|
|
4
|
+
"*** Begin Patch",
|
|
5
|
+
"*** Update File: /Users/m4/.openclaw/workspace/MEMORY.md",
|
|
6
|
+
"@@",
|
|
7
|
+
"-old line",
|
|
8
|
+
"+new line",
|
|
9
|
+
"*** End Patch",
|
|
10
|
+
].join("\n");
|
|
11
|
+
const ADD_PATCH = [
|
|
12
|
+
"*** Begin Patch",
|
|
13
|
+
"*** Add File: /Users/m4/.openclaw/workspace/NEW.md",
|
|
14
|
+
"+first",
|
|
15
|
+
"+second",
|
|
16
|
+
"*** End Patch",
|
|
17
|
+
].join("\n");
|
|
18
|
+
const DELETE_PATCH = [
|
|
19
|
+
"*** Begin Patch",
|
|
20
|
+
"*** Delete File: /Users/m4/.openclaw/workspace/GONE.md",
|
|
21
|
+
"*** End Patch",
|
|
22
|
+
].join("\n");
|
|
23
|
+
describe("normalizeApplyPatch", () => {
|
|
24
|
+
it("turns an Update File patch into an edit event with the target path", () => {
|
|
25
|
+
const out = normalizeApplyPatch({
|
|
26
|
+
toolName: "apply_patch",
|
|
27
|
+
params: { input: UPDATE_PATCH },
|
|
28
|
+
});
|
|
29
|
+
expect(out?.toolName).toBe("edit");
|
|
30
|
+
expect(out?.params?.file_path).toBe("/Users/m4/.openclaw/workspace/MEMORY.md");
|
|
31
|
+
expect(out?.params?.new_string).toBe("new line");
|
|
32
|
+
});
|
|
33
|
+
it("turns an Add File patch into a write event with content", () => {
|
|
34
|
+
const out = normalizeApplyPatch({
|
|
35
|
+
toolName: "apply_patch",
|
|
36
|
+
params: { input: ADD_PATCH },
|
|
37
|
+
});
|
|
38
|
+
expect(out?.toolName).toBe("write");
|
|
39
|
+
expect(out?.params?.file_path).toBe("/Users/m4/.openclaw/workspace/NEW.md");
|
|
40
|
+
expect(out?.params?.content).toBe("first\nsecond");
|
|
41
|
+
});
|
|
42
|
+
it("returns null for a Delete File patch (not a memory write)", () => {
|
|
43
|
+
const out = normalizeApplyPatch({
|
|
44
|
+
toolName: "apply_patch",
|
|
45
|
+
params: { input: DELETE_PATCH },
|
|
46
|
+
});
|
|
47
|
+
expect(out).toBeNull();
|
|
48
|
+
});
|
|
49
|
+
it("returns null for a non-apply_patch tool", () => {
|
|
50
|
+
expect(normalizeApplyPatch({ toolName: "edit", params: { file_path: "/x" } })).toBeNull();
|
|
51
|
+
});
|
|
52
|
+
it("returns null for apply_patch with no parseable header", () => {
|
|
53
|
+
expect(normalizeApplyPatch({ toolName: "apply_patch", params: { input: "garbage" } })).toBeNull();
|
|
54
|
+
});
|
|
55
|
+
it("preserves other event fields on the normalized output", () => {
|
|
56
|
+
const out = normalizeApplyPatch({
|
|
57
|
+
toolName: "apply_patch",
|
|
58
|
+
params: { input: UPDATE_PATCH },
|
|
59
|
+
runId: "r-1",
|
|
60
|
+
toolCallId: "tc-1",
|
|
61
|
+
});
|
|
62
|
+
expect(out?.runId).toBe("r-1");
|
|
63
|
+
expect(out?.toolCallId).toBe("tc-1");
|
|
64
|
+
});
|
|
65
|
+
it("finds a memory file that is NOT first in a multi-file patch", () => {
|
|
66
|
+
const multiPatch = [
|
|
67
|
+
"*** Begin Patch",
|
|
68
|
+
"*** Update File: /tmp/scratch.txt",
|
|
69
|
+
"@@",
|
|
70
|
+
"-old",
|
|
71
|
+
"+new",
|
|
72
|
+
"*** Update File: /Users/m4/.openclaw/workspace/MEMORY.md",
|
|
73
|
+
"@@",
|
|
74
|
+
"-old memory",
|
|
75
|
+
"+poisoned memory",
|
|
76
|
+
"*** End Patch",
|
|
77
|
+
].join("\n");
|
|
78
|
+
const out = normalizeApplyPatch({
|
|
79
|
+
toolName: "apply_patch",
|
|
80
|
+
params: { input: multiPatch },
|
|
81
|
+
});
|
|
82
|
+
expect(out?.toolName).toBe("edit");
|
|
83
|
+
expect(out?.params?.file_path).toBe("/Users/m4/.openclaw/workspace/MEMORY.md");
|
|
84
|
+
expect(out?.params?.new_string).toBe("poisoned memory");
|
|
85
|
+
});
|
|
86
|
+
it("returns null when no file in the patch is memory-shaped", () => {
|
|
87
|
+
const nonMemoryPatch = [
|
|
88
|
+
"*** Begin Patch",
|
|
89
|
+
"*** Update File: /tmp/a.txt",
|
|
90
|
+
"@@",
|
|
91
|
+
"+line",
|
|
92
|
+
"*** Update File: /tmp/b.txt",
|
|
93
|
+
"@@",
|
|
94
|
+
"+line",
|
|
95
|
+
"*** End Patch",
|
|
96
|
+
].join("\n");
|
|
97
|
+
expect(normalizeApplyPatch({
|
|
98
|
+
toolName: "apply_patch",
|
|
99
|
+
params: { input: nonMemoryPatch },
|
|
100
|
+
})).toBeNull();
|
|
101
|
+
});
|
|
102
|
+
it("handles CRLF line endings in patch input", () => {
|
|
103
|
+
const crlfPatch = [
|
|
104
|
+
"*** Begin Patch",
|
|
105
|
+
"*** Update File: /Users/m4/.openclaw/workspace/MEMORY.md",
|
|
106
|
+
"@@",
|
|
107
|
+
"-old line",
|
|
108
|
+
"+new line",
|
|
109
|
+
"*** End Patch",
|
|
110
|
+
].join("\r\n");
|
|
111
|
+
const out = normalizeApplyPatch({
|
|
112
|
+
toolName: "apply_patch",
|
|
113
|
+
params: { input: crlfPatch },
|
|
114
|
+
});
|
|
115
|
+
expect(out?.toolName).toBe("edit");
|
|
116
|
+
expect(out?.params?.file_path).toBe("/Users/m4/.openclaw/workspace/MEMORY.md");
|
|
117
|
+
expect(out?.params?.new_string).toBe("new line");
|
|
118
|
+
});
|
|
119
|
+
it("extracts content only from the matched memory file section, not the whole patch", () => {
|
|
120
|
+
const multiPatch = [
|
|
121
|
+
"*** Begin Patch",
|
|
122
|
+
"*** Update File: /tmp/scratch.txt",
|
|
123
|
+
"@@",
|
|
124
|
+
"+non-memory content",
|
|
125
|
+
"*** Update File: /Users/m4/.openclaw/workspace/MEMORY.md",
|
|
126
|
+
"@@",
|
|
127
|
+
"+memory content",
|
|
128
|
+
"*** End Patch",
|
|
129
|
+
].join("\n");
|
|
130
|
+
const out = normalizeApplyPatch({
|
|
131
|
+
toolName: "apply_patch",
|
|
132
|
+
params: { input: multiPatch },
|
|
133
|
+
});
|
|
134
|
+
expect(out?.params?.new_string).toBe("memory content");
|
|
135
|
+
expect(out?.params?.new_string).not.toContain("non-memory content");
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
describe("normalizeEvent", () => {
|
|
139
|
+
it("passes unrecognized events through unchanged", () => {
|
|
140
|
+
const ev = { toolName: "read", params: { path: "/x" } };
|
|
141
|
+
expect(normalizeEvent(ev)).toBe(ev);
|
|
142
|
+
});
|
|
143
|
+
it("applies the apply_patch normalizer when it matches", () => {
|
|
144
|
+
const out = normalizeEvent({
|
|
145
|
+
toolName: "apply_patch",
|
|
146
|
+
params: { input: UPDATE_PATCH },
|
|
147
|
+
});
|
|
148
|
+
expect(out.toolName).toBe("edit");
|
|
149
|
+
expect(out.params.file_path).toBe("/Users/m4/.openclaw/workspace/MEMORY.md");
|
|
150
|
+
});
|
|
151
|
+
it("applies the shell-exec normalizer when it matches", () => {
|
|
152
|
+
const out = normalizeEvent({
|
|
153
|
+
toolName: "exec",
|
|
154
|
+
params: { command: "cat /Users/m4/.openclaw/workspace/MEMORY.md" },
|
|
155
|
+
});
|
|
156
|
+
expect(out.toolName).toBe("read");
|
|
157
|
+
expect(out.params.path).toBe("/Users/m4/.openclaw/workspace/MEMORY.md");
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
describe("normalizeShellExec — reads", () => {
|
|
161
|
+
const MEM = "/Users/m4/.openclaw/workspace/MEMORY.md";
|
|
162
|
+
it.each([
|
|
163
|
+
["cat", `cat ${MEM}`],
|
|
164
|
+
["less", `less ${MEM}`],
|
|
165
|
+
["more", `more ${MEM}`],
|
|
166
|
+
["head -n 20", `head -n 20 ${MEM}`],
|
|
167
|
+
["tail -n 5", `tail -n 5 ${MEM}`],
|
|
168
|
+
["grep with pattern", `grep foo ${MEM}`],
|
|
169
|
+
["sed without -i", `sed 's/x/y/' ${MEM}`],
|
|
170
|
+
])("turns `%s` into a read event", (_label, command) => {
|
|
171
|
+
const out = normalizeShellExec({ toolName: "exec", params: { command } });
|
|
172
|
+
expect(out?.toolName).toBe("read");
|
|
173
|
+
expect(out?.params?.path).toBe(MEM);
|
|
174
|
+
});
|
|
175
|
+
it("returns null when the path is not memory-shaped", () => {
|
|
176
|
+
expect(normalizeShellExec({ toolName: "exec", params: { command: "cat /etc/hosts" } })).toBeNull();
|
|
177
|
+
});
|
|
178
|
+
it("returns null for pipes / subshells (unambiguous shapes only)", () => {
|
|
179
|
+
expect(normalizeShellExec({
|
|
180
|
+
toolName: "exec",
|
|
181
|
+
params: { command: `cat ${MEM} | grep foo` },
|
|
182
|
+
})).toBeNull();
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
describe("normalizeShellExec — writes", () => {
|
|
186
|
+
const MEM = "/Users/m4/.openclaw/workspace/MEMORY.md";
|
|
187
|
+
it("turns `echo \"...\" > path` into a write event with content", () => {
|
|
188
|
+
const out = normalizeShellExec({
|
|
189
|
+
toolName: "exec",
|
|
190
|
+
params: { command: `echo "poison" > ${MEM}` },
|
|
191
|
+
});
|
|
192
|
+
expect(out?.toolName).toBe("write");
|
|
193
|
+
expect(out?.params?.file_path).toBe(MEM);
|
|
194
|
+
expect(out?.params?.content).toBe("poison");
|
|
195
|
+
});
|
|
196
|
+
it("turns `echo ... >> path` (append) into a write event", () => {
|
|
197
|
+
const out = normalizeShellExec({
|
|
198
|
+
toolName: "exec",
|
|
199
|
+
params: { command: `echo "more" >> ${MEM}` },
|
|
200
|
+
});
|
|
201
|
+
expect(out?.toolName).toBe("write");
|
|
202
|
+
expect(out?.params?.file_path).toBe(MEM);
|
|
203
|
+
expect(out?.params?.content).toBe("more");
|
|
204
|
+
});
|
|
205
|
+
it("turns `tee path` into a write event with empty content", () => {
|
|
206
|
+
const out = normalizeShellExec({
|
|
207
|
+
toolName: "exec",
|
|
208
|
+
params: { command: `tee ${MEM}` },
|
|
209
|
+
});
|
|
210
|
+
expect(out?.toolName).toBe("write");
|
|
211
|
+
expect(out?.params?.file_path).toBe(MEM);
|
|
212
|
+
expect(out?.params?.content).toBe("");
|
|
213
|
+
});
|
|
214
|
+
it("turns `sed -i ... path` into a write event carrying the sed expression", () => {
|
|
215
|
+
const out = normalizeShellExec({
|
|
216
|
+
toolName: "exec",
|
|
217
|
+
params: { command: `sed -i 's/x/y/' ${MEM}` },
|
|
218
|
+
});
|
|
219
|
+
expect(out?.toolName).toBe("write");
|
|
220
|
+
expect(out?.params?.file_path).toBe(MEM);
|
|
221
|
+
expect(out?.params?.content).toBe("s/x/y/");
|
|
222
|
+
});
|
|
223
|
+
it("returns null when the write path is not memory-shaped", () => {
|
|
224
|
+
expect(normalizeShellExec({
|
|
225
|
+
toolName: "exec",
|
|
226
|
+
params: { command: `echo "x" > /tmp/scratch.txt` },
|
|
227
|
+
})).toBeNull();
|
|
228
|
+
});
|
|
229
|
+
it("returns null for a non-exec tool", () => {
|
|
230
|
+
expect(normalizeShellExec({
|
|
231
|
+
toolName: "write",
|
|
232
|
+
params: { file_path: MEM, content: "x" },
|
|
233
|
+
})).toBeNull();
|
|
234
|
+
});
|
|
235
|
+
it.each(["bash", "shell", "run_command", "run_bash"])("normalizes `%s` tool name as well as exec", (toolName) => {
|
|
236
|
+
const out = normalizeShellExec({
|
|
237
|
+
toolName,
|
|
238
|
+
params: { command: `echo "x" > ${MEM}` },
|
|
239
|
+
});
|
|
240
|
+
expect(out?.toolName).toBe("write");
|
|
241
|
+
});
|
|
242
|
+
it("reads the command from params.cmd when params.command is absent", () => {
|
|
243
|
+
const out = normalizeShellExec({
|
|
244
|
+
toolName: "exec",
|
|
245
|
+
params: { cmd: `echo "x" > ${MEM}` },
|
|
246
|
+
});
|
|
247
|
+
expect(out?.toolName).toBe("write");
|
|
248
|
+
expect(out?.params?.file_path).toBe(MEM);
|
|
249
|
+
});
|
|
250
|
+
it("reads the command from params.script when params.command is absent", () => {
|
|
251
|
+
const out = normalizeShellExec({
|
|
252
|
+
toolName: "bash",
|
|
253
|
+
params: { script: `echo "x" > ${MEM}` },
|
|
254
|
+
});
|
|
255
|
+
expect(out?.toolName).toBe("write");
|
|
256
|
+
expect(out?.params?.file_path).toBe(MEM);
|
|
257
|
+
});
|
|
258
|
+
it("turns `cp src MEMORY.md` into a write event with empty content", () => {
|
|
259
|
+
const out = normalizeShellExec({
|
|
260
|
+
toolName: "exec",
|
|
261
|
+
params: { command: `cp /attacker/payload ${MEM}` },
|
|
262
|
+
});
|
|
263
|
+
expect(out?.toolName).toBe("write");
|
|
264
|
+
expect(out?.params?.file_path).toBe(MEM);
|
|
265
|
+
expect(out?.params?.content).toBe("");
|
|
266
|
+
});
|
|
267
|
+
it("turns `mv src MEMORY.md` into a write event with empty content", () => {
|
|
268
|
+
const out = normalizeShellExec({
|
|
269
|
+
toolName: "exec",
|
|
270
|
+
params: { command: `mv /tmp/staged ${MEM}` },
|
|
271
|
+
});
|
|
272
|
+
expect(out?.toolName).toBe("write");
|
|
273
|
+
expect(out?.params?.file_path).toBe(MEM);
|
|
274
|
+
expect(out?.params?.content).toBe("");
|
|
275
|
+
});
|
|
276
|
+
it("returns null for cp/mv to a non-memory path", () => {
|
|
277
|
+
expect(normalizeShellExec({
|
|
278
|
+
toolName: "exec",
|
|
279
|
+
params: { command: "cp /a/b /tmp/c.txt" },
|
|
280
|
+
})).toBeNull();
|
|
281
|
+
});
|
|
282
|
+
});
|
package/dist/index.js
CHANGED
|
@@ -1,2 +1,5 @@
|
|
|
1
|
-
"use strict";var
|
|
2
|
-
`);
|
|
1
|
+
"use strict";var y=Object.defineProperty;var L=Object.getOwnPropertyDescriptor;var N=Object.getOwnPropertyNames;var I=Object.prototype.hasOwnProperty;var v=(t,e)=>{for(var n in e)y(t,n,{get:e[n],enumerable:!0})},D=(t,e,n,r)=>{if(e&&typeof e=="object"||typeof e=="function")for(let o of N(e))!I.call(t,o)&&o!==n&&y(t,o,{get:()=>e[o],enumerable:!(r=L(e,o))||r.enumerable});return t};var j=t=>D(y({},"__esModule",{value:!0}),t);var ee={};v(ee,{default:()=>$});module.exports=j(ee);var R=require("os"),m=require("@atbash/sdk");var b="atbash-openclaw",M=["openclaw","atbash-plugin"];function E(t){return t.config?.plugins?.entries?.[b]?.config??{}}function x(t){let e=t.config?.plugins,n=e?.entries??{},r=Array.isArray(e?.allow)?e.allow:[],o=M.filter(a=>n[a]!==void 0||r.includes(a));if(o.length===0)return;let s=n[b]?.config!==void 0;t.logger?.warn?.(`[atbash] found config under former plugin id${o.length>1?"s":""} ${o.map(a=>`"${a}"`).join(", ")} \u2014 this plugin's id is "${b}" and those entries are ignored`+(s?". Remove them to avoid confusion.":`. No "${b}" config found, so the plugin is running on defaults \u2014 move your settings under "${b}".`),{formerIds:o,configured:s})}var F={"0xdac17f958d2ee523a2206206994597c13d831ec7":"USDT","0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48":"USDC","0x6b175474e89094c44da98b954eedeac495271d0f":"DAI","0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2":"WETH","0x2260fac5e5542a773aa44fbcfedf7c193bc2c599":"WBTC","0x1f9840a85d5af5bf1d1762f925bdaddc4201f984":"UNI","0x514910771af9ca656af840dff83e8264ecf986ca":"LINK","0x7fc66500c84a76ad7e9c93437bfc5ac33e2ddae9":"AAVE","0xb0df6379ba1692841965a0745ac1bd3046d79ba3":"ATBASH","0xe9c094187219d9a29382c1a36fc43619c9257777":"ATBASH","0xf8b071428558c657a7a9aa1c43e152e75dd77777":"ATBASH"},A=/\b(0x[a-fA-F0-9]{40})\b/,O=/(0x[a-fA-F0-9]{40})/,C=/\b(transfer|send|swap|approve|erc20)\b/i;function H(t){if(!t)return"ETH";let e=t.toLowerCase(),n=O.exec(e);return n?F[n[1].toLowerCase()]??"other":e==="c60"||e==="eth"?"ETH":e==="atbash"?"ATBASH":e==="usdt"||e==="tether"?"USDT":e==="usdc"?"USDC":"other"}function w(t,...e){for(let n of e){let r=t[n];if(typeof r=="string"&&r.trim())return r.trim()}}function W(t,e){let n=e!==null&&typeof e=="object"&&!Array.isArray(e)?e:void 0,r=n?w(n,"command","cmd","shell_command"):void 0,o=C.test(t),s=r?C.test(r):!1,a=r?A.test(r):!1;if(!o&&!s&&!a)return;let c=l=>r?new RegExp(`--${l}[= ]+(\\S+)`).exec(r)?.[1]:void 0,d=(n?w(n,"token","token_address","asset","currency"):void 0)??c("token");if(!d&&r){let l=A.exec(r);l&&(d=l[1])}let g=(n?w(n,"to","recipient","destination"):void 0)??c("to")??c("recipient"),h=(n?w(n,"amount","value","qty"):void 0)??c("amount"),f=H(d??""),u=r??t;return{operation:/\bswap\b/i.test(u)?"swap":/\b(approve|erc20)\b/i.test(u)?"approve":"transfer",asset:f,amount:h,recipient_status:g?"external":"unspecified",note:"canonicalized pre-judge; raw 0x addresses omitted (redacted upstream)"}}function T(t,e){let n=t??{},r=n.toolName??e.tool?.name??e.toolName??e.name??"unknown",o=n.params??e.params??n.args??e.args??n.arguments??e.arguments,s=JSON.stringify({tool_name:r});return{toolName:r,args:o,context:s,resolved:W(r,o)}}var B=["memory.md","dreams.md","claude.md","agents.md","/.openclaw/","/.claude/projects/","/memory/"];function S(t){let e=t.replace(/\\/g,"/").toLowerCase();return B.some(n=>e.includes(n))}var U=/^\*\*\* (Update|Add|Delete) File:\s*(.+?)\s*$/mg;function z(t,e){let n=t.split(`
|
|
2
|
+
`),r=!1,o=[];for(let s of n){let a=/^\*\*\* (?:Update|Add|Delete) File:\s*(.+?)\s*$/.exec(s);if(a){r=a[1]===e;continue}r&&(s.startsWith("*** ")||s.startsWith("@@")||s.startsWith("+")&&o.push(s.slice(1)))}return o.join(`
|
|
3
|
+
`)}function K(t){if(!t||typeof t!="object")return null;let e=t;if((e.toolName??e.tool_name??"").toString().toLowerCase()!=="apply_patch")return null;let r=e.params??e.args??e.arguments??{},o=r.input;if(typeof o!="string"||o.length===0)return null;let s=o.replace(/\r\n/g,`
|
|
4
|
+
`),a=new RegExp(U.source,"mg"),c,d,g;for(;(g=a.exec(s))!==null;){let[,u,i]=g;if(u!=="Delete"&&S(i)){c=u,d=i;break}}if(!c||!d)return null;let h=z(s,d);return{...e,toolName:c==="Add"?"write":"edit",params:{...r,file_path:d,content:h,new_string:h}}}function p(t){let e=t.trim();return e.startsWith('"')&&e.endsWith('"')||e.startsWith("'")&&e.endsWith("'")?e.slice(1,-1):e}var G=/[|;&`$]|<\(|\)/;function V(t){return G.test(t)}function q(t){if(V(t))return null;let e=t.trim(),n=/^grep\b(?:\s+-\S+)*\s+\S+\s+(.+)$/.exec(e);if(n)return p(n[1]);let r=/^sed\b(?!\s+-i\b)(?:\s+-\S+)*\s+\S+\s+(.+)$/.exec(e);if(r)return p(r[1]);let o=/^(?:cat|less|more|head|tail)\b(?:\s+-\S+(?:\s+\S+)?)*\s+(.+)$/.exec(e);return o?p(o[1]):null}function J(t){if(/[|;&`$]|<\(|\)/.test(t))return null;let e=t.trim(),n=/^(?:echo|printf)\s+(.+?)\s*>>?\s*(\S+)\s*$/.exec(e);if(n)return{content:p(n[1]),path:p(n[2])};let r=/^tee\b(?:\s+-\S+)*\s+(\S+)\s*$/.exec(e);if(r)return{content:"",path:p(r[1])};let o=/^sed\s+-i(?:\s+-\S+)*\s+(\S+)\s+(\S+)\s*$/.exec(e);if(o)return{content:p(o[1]),path:p(o[2])};let s=/^awk\s+-i\s+inplace\b.*\s+(\S+)\s*$/.exec(e);if(s)return{content:"",path:p(s[1])};let a=/^(?:cp|mv)\b(?:\s+-\S+)*\s+\S+\s+(\S+)\s*$/.exec(e);return a?{content:"",path:p(a[1])}:null}var Y=new Set(["exec","bash","shell","run_command","run_bash"]);function X(t){if(!t||typeof t!="object")return null;let e=t,n=(e.toolName??e.tool_name??"").toString().toLowerCase();if(!Y.has(n))return null;let o=e.params??e.args??e.arguments??{},s=o.command??o.cmd??o.script,a=typeof s=="string"?s:null;if(!a||a.length===0)return null;let c=J(a);if(c&&S(c.path))return{...e,toolName:"write",params:{...o,file_path:c.path,content:c.content}};let d=q(a);return d&&S(d)?{...e,toolName:"read",params:{...o,path:d}}:null}function _(t){return K(t)??X(t)??t}function P(t){return t.replace(/^~(?=\/|$)/,(0,R.homedir)())}function k(t,e){return Promise.race([t,new Promise((n,r)=>setTimeout(()=>r(new Error(`judge timed out after ${e}ms`)),e))])}function Q(t){return t.memoryWorkspaceDir?P(t.memoryWorkspaceDir):process.cwd()}function Z(t){if(t.judgeEndpoint)return t.judgeEndpointPolicy==="self-hosted"?{policy:"self-hosted",endpoint:t.judgeEndpoint,verifyPubKey:t.judgeVerifyPubKey??""}:{policy:"default",endpoint:t.judgeEndpoint}}function $(t){let e=t,n=E(e);if(x(e),n.enabled===!1){e.logger?.info?.("[atbash] plugin disabled via config");return}(0,m.setupTelemetry)({enabled:!0,source:"plugin:openclaw"}),process.once("beforeExit",()=>(0,m.shutdownTelemetry)()),process.once("SIGINT",()=>(0,m.shutdownTelemetry)().finally(()=>process.exit(0))),process.once("SIGTERM",()=>(0,m.shutdownTelemetry)().finally(()=>process.exit(0)));let r;try{r=m.Atbash.fromConfig({judge:Z(n),keyPath:n.chromiaSecretPath,orgName:n.orgName,failClosed:n.enforceDecision!==!1,logger:e.logger})}catch(a){let c=a instanceof Error?a.message:String(a);throw e.logger?.warn?.("[atbash] init failed",{error:c}),a}let o=n.enforceDecision!==!1;e.logger?.info?.("[atbash] plugin loaded",{enforceDecision:o});let s=(0,m.createMemoryGuardManager)({auth:r.auth,workspaceDir:Q(n),memoryFilePath:n.memoryFilePath?P(n.memoryFilePath):void 0,ttlMs:n.memorySyncTTLMs,rollbackMinScore:n.memoryRollbackMinScore,memoryPathPatterns:n.memoryPathPatterns,judgeEndpoint:n.judgeEndpoint,judgeVerifyPubKey:n.judgeVerifyPubKey,orgName:n.orgName,enforce:o,debug:!!n.debug,hostLogger:e.logger});if(s.runBootProbe().catch(a=>{e.logger?.warn?.("[atbash] boot probe failed",{error:a instanceof Error?a.message:String(a)})}),!e.on){e.logger?.warn?.("[atbash] on() API not available");return}e.on("before_tool_call",async(a,c)=>{let d=_(a),g=n.judgeTimeoutMs,h;try{let i=s.handleBeforeToolCall(d,c);h=await(g?k(i,g):i)}catch(i){let l=i instanceof Error?i.message:String(i);return e.logger?.warn?.("[atbash] memory guard error",{error:l}),i instanceof m.MemoryIntegrityError||o?{block:!0,blockReason:`Memory guard error: ${l}`,allow:!1,reason:`Memory guard error: ${l}`}:{allow:!0}}if(h)return h;let f;try{let i=r.auditToolCall(T(a,c));f=await(g?k(i,g):i)}catch(i){let l=i instanceof Error?i.message:String(i);return e.logger?.warn?.("[atbash] unexpected error",{error:l}),o?{block:!0,blockReason:`Atbash unavailable: ${l}`,allow:!1,reason:`Atbash unavailable: ${l}`}:{allow:!0}}let u=f.reason??"";switch(f.verdict){case"BLOCK":return e.logger?.warn?.("[atbash] BLOCK",{reason:u}),o?{block:!0,blockReason:u,allow:!1,reason:u}:{allow:!0};case"HOLD":{let i=["Action held for operator review. The agent will not be jailed \u2014 please approve or reject this request from the Atbash dashboard, then ask the agent to try again.",`Reason: ${u}`];f.toolCallId&&i.push(`Tool Call ID: ${f.toolCallId}`);let l=i.join(`
|
|
5
|
+
`);return e.logger?.warn?.("[atbash] HOLD",{reason:u,toolCallId:f.toolCallId}),o?{block:!0,blockReason:l,allow:!1,reason:l}:{allow:!0}}case"ERROR":return e.logger?.warn?.("[atbash] ERROR",{reason:u}),o?{block:!0,blockReason:u,allow:!1,reason:u}:{allow:!0};case"ALLOW":if(f.allow===!0)return e.logger?.info?.("[atbash] ALLOW",{reason:u}),{allow:!0};break}{let i=String(f.verdict);if(e.logger?.warn?.("[atbash] unusable decision",{verdict:i,allow:f.allow,reason:u}),!o)return{allow:!0};let l=`Blocked (unusable Atbash decision ${i}): ${u}`;return{block:!0,blockReason:l,allow:!1,reason:l}}})}
|
package/openclaw.plugin.json
CHANGED
|
@@ -24,7 +24,8 @@
|
|
|
24
24
|
"memoryWorkspaceDir": { "type": "string" },
|
|
25
25
|
"memoryFilePath": { "type": "string" },
|
|
26
26
|
"memorySyncTTLMs": { "type": "integer", "minimum": 0, "default": 30000 },
|
|
27
|
-
"memoryRollbackMinScore": { "type": "integer", "minimum": 1, "maximum": 10, "default": 1 }
|
|
27
|
+
"memoryRollbackMinScore": { "type": "integer", "minimum": 1, "maximum": 10, "default": 1 },
|
|
28
|
+
"judgeTimeoutMs": { "type": "integer", "minimum": 1000 }
|
|
28
29
|
}
|
|
29
30
|
},
|
|
30
31
|
"uiHints": {
|
|
@@ -69,6 +70,10 @@
|
|
|
69
70
|
"memoryRollbackMinScore": {
|
|
70
71
|
"label": "Rollback Score Threshold",
|
|
71
72
|
"placeholder": "Block memory reads when a rolled-back version scores below this (1-10). Default 1 = never block."
|
|
73
|
+
},
|
|
74
|
+
"judgeTimeoutMs": {
|
|
75
|
+
"label": "Judge Timeout (ms)",
|
|
76
|
+
"placeholder": "e.g. 12000 — max ms to wait for a judge response before failing. Unset = no plugin-level limit."
|
|
72
77
|
}
|
|
73
78
|
}
|
|
74
79
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@atbash/atbash-openclaw",
|
|
3
|
-
"version": "0.1.19-dev.
|
|
3
|
+
"version": "0.1.19-dev.2",
|
|
4
4
|
"description": "OpenClaw ATBASH tool-audit plugin. Thin adapter that maps OpenClaw's before_tool_call hook onto @atbash/sdk.",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -11,10 +11,13 @@
|
|
|
11
11
|
"LICENSE"
|
|
12
12
|
],
|
|
13
13
|
"scripts": {
|
|
14
|
-
"build": "tsc && esbuild dist/index.js --bundle --platform=node --external:postchain-client --external:@atbash/sdk --minify --allow-overwrite --outfile=dist/index.js",
|
|
14
|
+
"build": "node scripts/clean.mjs && tsc && esbuild dist/index.js --bundle --platform=node --external:postchain-client --external:@atbash/sdk --minify --allow-overwrite --outfile=dist/index.js",
|
|
15
15
|
"typecheck": "tsc --noEmit",
|
|
16
|
-
"
|
|
17
|
-
"release
|
|
16
|
+
"test": "tsc --noEmit && vitest run && node test/build.mjs && node --test test/verdict-handling.test.mjs test/safety-guard-doc.test.mjs",
|
|
17
|
+
"test:release": "node --test test/release-guard.test.mjs",
|
|
18
|
+
"verify:package": "node scripts/release.mjs --verify-current",
|
|
19
|
+
"release": "node scripts/release.mjs --channel latest",
|
|
20
|
+
"release:dev": "node scripts/release.mjs --channel dev"
|
|
18
21
|
},
|
|
19
22
|
"keywords": [
|
|
20
23
|
"openclaw",
|
|
@@ -27,17 +30,24 @@
|
|
|
27
30
|
],
|
|
28
31
|
"author": "atbash",
|
|
29
32
|
"license": "LICENSED",
|
|
33
|
+
"repository": {
|
|
34
|
+
"type": "git",
|
|
35
|
+
"url": "https://github.com/Atbash-Ai/atbash-openclaw-plugin",
|
|
36
|
+
"directory": "openclaw"
|
|
37
|
+
},
|
|
30
38
|
"openclaw": {
|
|
31
39
|
"extensions": [
|
|
32
40
|
"./dist/index.js"
|
|
33
41
|
]
|
|
34
42
|
},
|
|
35
43
|
"dependencies": {
|
|
36
|
-
"@atbash/sdk": "0.
|
|
44
|
+
"@atbash/sdk": "0.15.1-dev.0"
|
|
37
45
|
},
|
|
38
46
|
"devDependencies": {
|
|
39
47
|
"@types/node": "^25.7.0",
|
|
40
48
|
"esbuild": "^0.24.0",
|
|
41
|
-
"
|
|
49
|
+
"semver": "7.8.5",
|
|
50
|
+
"typescript": "^5.9.3",
|
|
51
|
+
"vitest": "^4.1.6"
|
|
42
52
|
}
|
|
43
53
|
}
|