@coderifts/agent-hooks 0.2.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +18 -0
- package/README.md +37 -12
- package/claude-code/hook.mjs +41 -56
- package/claude-code/marketplace-entry.json +1 -1
- package/contract-write.mjs +731 -0
- package/gate.js +71 -21
- package/openclaw.plugin.json +1 -1
- package/package.json +2 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.3.0 — 2026-10-03
|
|
4
|
+
|
|
5
|
+
### Changed
|
|
6
|
+
|
|
7
|
+
- **One decision function with `coderifts claude-hook` and the CodeRifts mod.** `contract-write.mjs` (a byte copy of `@coderifts/contract-path`'s, with `contract-write.sha256` beside it) decides which path is a contract file and of what type, the text an Edit or MultiEdit leaves, and what a Bash command writes. `gate.js` and `claude-code/hook.mjs` keep no list of their own.
|
|
8
|
+
- **Artifact types are the change-set surface's own:** a `.proto` is sent as `grpc` and an MCP manifest or tool list as `mcp_manifest` (they were `protobuf` and `mcp`, which the surface does not analyze). `classifyPath` returns the new types; `ARTIFACT_TYPES` is gone.
|
|
9
|
+
- **Bash, by write target.** A shell write to a named contract file is still denied. A read of one now passes (`cat openapi.yaml > /tmp/x` was denied before). A write that can reach a contract file without naming it (`find … -exec sed -i`, `xargs`, a glob, `rm -rf <dir>`, `git stash pop`, `git reset --hard`, a pipe into `sh`) now **asks** when a contract file is under its reach.
|
|
10
|
+
- **Edit:** `replace_all` and the empty-`old_string` create form follow Claude Code; an edit that does not apply says which `old_string` was not found.
|
|
11
|
+
- **Two more limits** on every refusal: that the hook was running (`disableAllHooks` in any settings file, `--safe-mode`, `--bare`), and that a contract generated from source code was checked.
|
|
12
|
+
|
|
13
|
+
## 0.2.2 — 2026-10-03
|
|
14
|
+
|
|
15
|
+
### Changed
|
|
16
|
+
|
|
17
|
+
- **Every refusal names two more limits.** `DOES_NOT_PROVE` (gate.js), carried on every refusal and approval request, and inherited by the shell refusal, now also says:
|
|
18
|
+
- that a refusal by a hook installed outside managed settings is not final: since Claude Code 2.1.287 a user-installed mod runs before it, can answer the call so the hook never runs, and can approve a call the hook blocked;
|
|
19
|
+
- that a timed-out hook does not block the call: the fail-closed path covers an error inside the hook, not Claude Code's hook timeout. This holds for this hook only when `CODERIFTS_TIMEOUT_MS` is at least the hook timeout; the default 5000 ms aborts inside the 8 s timeout the snippet sets.
|
|
20
|
+
|
|
3
21
|
## Unreleased — 2026-09-27
|
|
4
22
|
|
|
5
23
|
### Fixed
|
package/README.md
CHANGED
|
@@ -46,6 +46,10 @@ Does not prove:
|
|
|
46
46
|
- that the bytes finally written are the bytes checked — nothing here locks the file between this answer and the write
|
|
47
47
|
- that the other tool calls in this run were checked — each call is judged alone
|
|
48
48
|
- that a contract artifact this gate does not recognise was seen at all
|
|
49
|
+
- that the call was refused when this hook is installed outside managed settings — since Claude Code 2.1.287 a user-installed mod runs before it, can answer the call so the hook never runs, and can approve a call the hook blocked; only a hook in managed settings is final, and a plugin hook runs after the mods even when managed settings force-enable the plugin
|
|
50
|
+
- that a call was refused when the hook ran out of time — the fail-closed path covers an error inside the hook, not Claude Code's hook timeout: a timed-out PreToolUse command hook does not block the call (true for this hook only when CODERIFTS_TIMEOUT_MS is at least the hook timeout)
|
|
51
|
+
- that the hook was running — disableAllHooks in any settings file (the project's .claude/settings.json included, which a shell command can rewrite unless the sandbox protects it), --safe-mode or --bare turns it off
|
|
52
|
+
- that a contract generated from source code was checked — a contract generated from source code (annotations, decorators, a build step) changes when the source changes; the hooks see the source edit, not the contract; the required check sees the generated contract
|
|
49
53
|
```
|
|
50
54
|
|
|
51
55
|
## Fail-closed
|
|
@@ -67,22 +71,43 @@ lets the write through. That host behaviour cannot be fixed in this package.
|
|
|
67
71
|
## Shell writes (Claude Code)
|
|
68
72
|
|
|
69
73
|
The Claude Code matcher is `Write|Edit|MultiEdit|Bash`. A shell command cannot be gated — the
|
|
70
|
-
hook never sees the bytes it would leave — so the hook
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
74
|
+
hook never sees the bytes it would leave — so the hook reads what the command **writes**, with the
|
|
75
|
+
decision function `coderifts claude-hook` and the CodeRifts mod use too (`contract-write.mjs`, a
|
|
76
|
+
copy of `@coderifts/contract-path`'s):
|
|
77
|
+
|
|
78
|
+
- **A write to a named contract file is denied** (a redirect into it, `tee`, `sed -i`/`perl -i`,
|
|
79
|
+
the destination of `cp`/`install`/`ln`/`rsync`, `mv`/`rm`, `git checkout|restore -- <file>`,
|
|
80
|
+
`--out <file>`, `open(<file>, "w")` in inline Python or Node, `curl -o`), with a pointer to the
|
|
81
|
+
Write or Edit tool, where the gate does see the change. Nothing is sent to CodeRifts for a Bash call.
|
|
82
|
+
- **A write that can reach a contract file without naming it asks**: `find <dir> -exec sed -i …`
|
|
83
|
+
or `-delete`, `xargs` into a writer, a glob, `rm -rf <dir>`, `git stash pop`, `git reset --hard`,
|
|
84
|
+
`git checkout -- .`, inline code that walks a directory, a pipe into `sh`, a target the command
|
|
85
|
+
builds from a variable it does not set. It asks only when a contract file is under that
|
|
86
|
+
directory; `find . -name '*.pyc' -delete` and `rm -rf build` pass.
|
|
87
|
+
- **A read passes**, even of a contract file: `cat openapi.yaml > /tmp/copy`, `cp openapi.yaml /tmp/`,
|
|
88
|
+
`git diff`, `grep`, `oasdiff`.
|
|
89
|
+
|
|
90
|
+
Measured on 2026-10-03 on two corpora: the 22 benign forms of the Skillkeel tamper corpus pass; 12 of
|
|
91
|
+
its 83 tamper cases ask, each a write that reaches the working tree without naming a file (`rm -rf .`,
|
|
92
|
+
`git reset --hard`, `git clean -fdx`, `git checkout -- .`, `xargs rm`, a pipe into `sh`); of our 16
|
|
93
|
+
contract-file-write cases, the 11 that change a contract are stopped and the 5 benign ones go through.
|
|
94
|
+
|
|
95
|
+
An ask does not weaken a deny: on Claude Code 2.1.288 a settings `deny` rule held over this hook's
|
|
96
|
+
`ask` and over an `allow` (measured in `bypassPermissions` mode). The earlier report that a hook ask
|
|
97
|
+
overrode a deny rule (anthropics/claude-code #39344) did not reproduce.
|
|
98
|
+
|
|
99
|
+
It reads the command text; it does not parse shell or run it. A command shape it does not recognise
|
|
100
|
+
passes — a script that writes a contract file itself (`node scripts/generate.js`), a variable it
|
|
101
|
+
cannot resolve to a name — and every shell refusal says so:
|
|
82
102
|
|
|
83
103
|
Does not prove:
|
|
84
104
|
- that a contract file written by a shell command this hook did not recognise was seen at all
|
|
85
105
|
|
|
106
|
+
The sandbox closes what the text cannot: with `sandbox.filesystem.denyWrite` listing the contract
|
|
107
|
+
paths, every sandboxed command and its child processes are refused the write (measured on macOS:
|
|
108
|
+
`python -c`, `find -exec sed -i`, `xargs cp`, also in `bypassPermissions` mode; on Linux a wildcard
|
|
109
|
+
entry is skipped, so list concrete paths). The Write and Edit tools stay with this hook.
|
|
110
|
+
|
|
86
111
|
## Edit and MultiEdit
|
|
87
112
|
|
|
88
113
|
`new_string` is a fragment, not the file. The gate reads the file on disk and sends it with the
|
package/claude-code/hook.mjs
CHANGED
|
@@ -8,32 +8,22 @@
|
|
|
8
8
|
* block → JSON deny (reason includes Does not prove)
|
|
9
9
|
* transport/parse/throw → exit 2 + stderr (the host is fail-open otherwise)
|
|
10
10
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* -
|
|
14
|
-
*
|
|
11
|
+
* Which call touches a contract file is decided by contract-write.mjs, the one decision function
|
|
12
|
+
* `coderifts claude-hook` and the CodeRifts mod run too (0.3.0, 2026-10-03):
|
|
13
|
+
* - Edit/MultiEdit carry an edit, not a body; the after file is the one on disk with it applied.
|
|
14
|
+
* - Bash is not gated (the after bytes are unknown). A command that writes a named contract file
|
|
15
|
+
* is denied with a pointer to Write/Edit, where the gate does see it; one that can write a
|
|
16
|
+
* directory holding a contract without naming it (find -exec, xargs, a glob, a tree-wide git
|
|
17
|
+
* restore, an interpreter, a pipe into sh) asks.
|
|
15
18
|
*/
|
|
16
19
|
import { resolve } from "node:path";
|
|
17
20
|
import { pathToFileURL } from "node:url";
|
|
18
|
-
import {
|
|
19
|
-
|
|
20
|
-
/** One Claude Code edit applied to `text`; null when old_string is not there to replace. */
|
|
21
|
-
function applyOne(text, { old_string: from, new_string: to, replace_all: all } = {}) {
|
|
22
|
-
if (typeof from !== "string" || typeof to !== "string") return null;
|
|
23
|
-
if (from === "") return text === "" ? to : null; // Claude Code's "create via Edit" shape
|
|
24
|
-
if (!text.includes(from)) return null;
|
|
25
|
-
return all ? text.split(from).join(to) : text.replace(from, () => to);
|
|
26
|
-
}
|
|
21
|
+
import { afterText, decideToolCall } from "../contract-write.mjs";
|
|
22
|
+
import { createGate, DOES_NOT_PROVE, hostIo } from "../gate.js";
|
|
27
23
|
|
|
28
|
-
/** The file after an Edit (`params` is the edit) or a MultiEdit (`params.edits`, in order). */
|
|
24
|
+
/** The file after an Edit (`params` is the edit) or a MultiEdit (`params.edits`, in order): contract-write's afterText. */
|
|
29
25
|
export function applyEdits(params, before) {
|
|
30
|
-
|
|
31
|
-
let text = before;
|
|
32
|
-
for (const e of edits) {
|
|
33
|
-
text = applyOne(text, e);
|
|
34
|
-
if (text === null) return null;
|
|
35
|
-
}
|
|
36
|
-
return text;
|
|
26
|
+
return afterText(Array.isArray(params.edits) ? "MultiEdit" : "Edit", params, before);
|
|
37
27
|
}
|
|
38
28
|
|
|
39
29
|
/** Claude Code Write/Edit/MultiEdit → the path/content keys `resolveTarget` reads. */
|
|
@@ -43,46 +33,36 @@ export const CLAUDE_TOOL_SHAPES = Object.freeze({
|
|
|
43
33
|
MultiEdit: { path: "file_path", apply: applyEdits },
|
|
44
34
|
});
|
|
45
35
|
|
|
46
|
-
/*
|
|
47
|
-
* A write shape in a shell command. Not a parser and not a gate: it only decides whether a command
|
|
48
|
-
* that already NAMES a recognised contract file may be changing it. A miss is named in the refusal
|
|
49
|
-
* text (and the README); a false hit costs one redirect to the Write/Edit tool.
|
|
50
|
-
*/
|
|
51
|
-
const SHELL_WRITE = [
|
|
52
|
-
/>/, // any redirect, incl. >> and heredoc targets
|
|
53
|
-
/\btee\b/,
|
|
54
|
-
/\b(sed|perl|ruby)\b[^|;&]*\s-[a-zA-Z]*i/,
|
|
55
|
-
/\b(cp|mv|rm|truncate|install|ln|rsync|dd|patch|unzip|tar)\b/,
|
|
56
|
-
/\bgit\s+(checkout|restore|apply|am|reset|stash|mv|rm)\b/,
|
|
57
|
-
/\bopen\s*\([^)]*,\s*['"][wax+]/,
|
|
58
|
-
/\b(writeFile(Sync)?|write_text|write_bytes)\b/,
|
|
59
|
-
/\b(curl|wget)\b[^|;&]*\s-(o|O)\b/,
|
|
60
|
-
];
|
|
61
|
-
|
|
62
|
-
/** Tokens a shell would split on, then the ones this gate recognises as a contract path. */
|
|
63
|
-
function contractPathsIn(command) {
|
|
64
|
-
const tokens = command.split(/[\s;|&<>()'"`=,]+/).filter(Boolean);
|
|
65
|
-
return [...new Set(tokens.filter((t) => classifyPath(t)))];
|
|
66
|
-
}
|
|
67
|
-
|
|
68
36
|
const SHELL_DOES_NOT_PROVE = [
|
|
69
37
|
"that a contract file written by a shell command this hook did not recognise was seen at all",
|
|
70
38
|
...DOES_NOT_PROVE.slice(1),
|
|
71
39
|
];
|
|
72
40
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
const
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
41
|
+
/** contract-write's Bash decision as this hook's text: refusal or ask, each with what it proves. */
|
|
42
|
+
export async function shellDecision(command, io) {
|
|
43
|
+
const d = await decideToolCall({ tool: "Bash", input: { command } }, io);
|
|
44
|
+
if (d.action === "pass") return null;
|
|
45
|
+
const named = d.action === "refuse";
|
|
46
|
+
const lines = [
|
|
47
|
+
named
|
|
48
|
+
? `CodeRifts cannot see a contract change made by a shell command (${d.paths.map((p) => p.path).join(", ")}).`
|
|
49
|
+
: `CodeRifts cannot see what this shell command writes under ${d.scopes.map((s) => (s === "." ? "the working tree" : s)).join(", ")}, and a contract file is there.`,
|
|
50
|
+
named
|
|
51
|
+
? `Use the Write or Edit tool for contract files, so the change is checked before it lands.`
|
|
52
|
+
: `Use the Write or Edit tool for contract files, or confirm that this command leaves them alone.`,
|
|
53
|
+
named
|
|
54
|
+
? `Proves: only that this command names a recognised contract file with a write shape.`
|
|
55
|
+
: `Proves: only that this command has a write shape (${d.by.join(", ")}) that can reach a contract file without naming it.`,
|
|
83
56
|
`Does not prove:`,
|
|
84
57
|
...SHELL_DOES_NOT_PROVE.map((l) => ` - ${l}`),
|
|
85
|
-
]
|
|
58
|
+
];
|
|
59
|
+
return { decision: named ? "deny" : "ask", text: lines.join("\n") };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Kept for 0.2.x callers: the refusal text for a command, or null (named writes only). */
|
|
63
|
+
export async function shellContractWrite(command, io = hostIo(process.cwd())) {
|
|
64
|
+
const r = await shellDecision(command, io);
|
|
65
|
+
return r && r.decision === "deny" ? r.text : null;
|
|
86
66
|
}
|
|
87
67
|
|
|
88
68
|
const ASK_TITLES = new Set([
|
|
@@ -151,13 +131,18 @@ export async function runClaudeHook(stdinText, { env = process.env, deps } = {})
|
|
|
151
131
|
}
|
|
152
132
|
|
|
153
133
|
if (payload.tool_name === "Bash") {
|
|
154
|
-
|
|
155
|
-
|
|
134
|
+
try {
|
|
135
|
+
const r = await shellDecision(String(payload.tool_input?.command ?? ""), hostIo(payload.cwd, deps?.readFile, deps?.listDir));
|
|
136
|
+
return r ? jsonDecision(r.decision, r.text) : emptyPass();
|
|
137
|
+
} catch (err) {
|
|
138
|
+
return failClosed(`CodeRifts Claude hook failed on a Bash call (${String(err?.message ?? err)}). Not knowing is not permission.`);
|
|
139
|
+
}
|
|
156
140
|
}
|
|
157
141
|
|
|
158
142
|
const event = {
|
|
159
143
|
toolName: payload.tool_name,
|
|
160
144
|
params: payload.tool_input ?? {},
|
|
145
|
+
cwd: payload.cwd,
|
|
161
146
|
};
|
|
162
147
|
|
|
163
148
|
try {
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
"repo": "coderifts/agent-hooks"
|
|
6
6
|
},
|
|
7
7
|
"description": "Host adapters for CodeRifts: Claude Code PreToolUse asks before Write/Edit of OpenAPI, AsyncAPI, GraphQL, protobuf, or MCP manifest files. Not the GitHub Action (coderifts/contract-gate) and not the guard library (@coderifts/agent-guard).",
|
|
8
|
-
"version": "0.
|
|
8
|
+
"version": "0.3.0",
|
|
9
9
|
"author": {
|
|
10
10
|
"name": "CodeRifts",
|
|
11
11
|
"email": "peter@coderifts.com"
|
|
@@ -0,0 +1,731 @@
|
|
|
1
|
+
// @coderifts/contract-path · contract-write — does this tool call touch an API contract file?
|
|
2
|
+
//
|
|
3
|
+
// ONE decision function for the three local entry points (2026-10-03, CC-2 T2): the agent-hooks
|
|
4
|
+
// Claude Code hook (claude-code/hook.mjs), the CLI's `coderifts claude-hook`, and the CodeRifts mod
|
|
5
|
+
// (agent/hooks/register.js). Before this file each had its own path list, its own Edit apply and its
|
|
6
|
+
// own Bash rule, and they disagreed: the CLI did not read Bash at all, agent-hooks passed a shell
|
|
7
|
+
// write that did not name the file (`find api -exec sed -i …`), the mod read Write/Edit/MultiEdit
|
|
8
|
+
// only, and the three sent three different artifact types for the same `.proto`.
|
|
9
|
+
//
|
|
10
|
+
// What it decides, for one call:
|
|
11
|
+
// Write / Edit / MultiEdit the contract file it touches, and the text before and after
|
|
12
|
+
// Bash, a named contract a write shape on a command that names a contract file → refuse,
|
|
13
|
+
// with a pointer to Write/Edit, where the after text is seen
|
|
14
|
+
// Bash, no name a write shape that can reach a directory holding a contract
|
|
15
|
+
// without naming the file (find -exec, xargs, a glob, a tree-wide
|
|
16
|
+
// git restore, an interpreter, an obfuscated command) → ask
|
|
17
|
+
//
|
|
18
|
+
// What it does not do: parse shell. The Bash rules read the command text, so a command they do not
|
|
19
|
+
// recognise passes; the required check on the pull request is the guarantee, and every refusal
|
|
20
|
+
// says so. A hook is feedback, not the boundary.
|
|
21
|
+
//
|
|
22
|
+
// Pure: no imports, no I/O. Each entry point passes what it read (the file, a directory listing)
|
|
23
|
+
// in `io`. That is what lets the mod load it (a hooks module imports only files of its own plugin),
|
|
24
|
+
// and what lets one parity test drive all three.
|
|
25
|
+
//
|
|
26
|
+
// CANONICAL SOURCE: coderifts-app packages/contract-path/contract-write.mjs. The copies are
|
|
27
|
+
// written by scripts/generate-contract-write-copies.js, byte for byte (the CommonJS twin is a
|
|
28
|
+
// mechanical transform), and its --check fails on any difference. Do not edit a copy.
|
|
29
|
+
|
|
30
|
+
export const CONTRACT_WRITE_VERSION = '1.1.0';
|
|
31
|
+
|
|
32
|
+
/** What a shell write to a named contract file gets, and what an unnamed one gets. */
|
|
33
|
+
export const SHELL_NAMED_DECISION = 'refuse';
|
|
34
|
+
export const SHELL_UNNAMED_DECISION = 'ask';
|
|
35
|
+
|
|
36
|
+
/** How far the walk goes under a scope before it stops looking (see `findUnder`). */
|
|
37
|
+
export const LISTING_LIMITS = Object.freeze({ depth: 8, entries: 5000 });
|
|
38
|
+
|
|
39
|
+
// ---- paths -----------------------------------------------------------------------------------
|
|
40
|
+
|
|
41
|
+
/** The @coderifts/contract-path list (index.cjs), word for word; test/contract-write.test.js holds them equal. */
|
|
42
|
+
export const CONTRACT_EXT = /\.(ya?ml|json|graphql|gql|proto)$/i;
|
|
43
|
+
|
|
44
|
+
export function looksLikeContractPath(p) {
|
|
45
|
+
const s = String(p || '').toLowerCase();
|
|
46
|
+
if (s.includes('node_modules/') || s.includes('vendor/')) return false;
|
|
47
|
+
return CONTRACT_EXT.test(s) && (s.includes('openapi') || s.includes('swagger') || s.includes('asyncapi')
|
|
48
|
+
|| s.endsWith('.graphql') || s.endsWith('.gql') || s.endsWith('.proto') || s.includes('mcp'));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function typeForPath(p) {
|
|
52
|
+
const s = String(p).toLowerCase();
|
|
53
|
+
if (s.endsWith('.graphql') || s.endsWith('.gql')) return 'graphql';
|
|
54
|
+
if (s.endsWith('.proto')) return 'grpc';
|
|
55
|
+
if (s.includes('asyncapi')) return 'asyncapi';
|
|
56
|
+
if (s.includes('mcp')) return 'mcp_manifest';
|
|
57
|
+
return 'openapi';
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/*
|
|
61
|
+
* Two names the hooks gated before this file and the list above does not carry: the CLI's agent
|
|
62
|
+
* tool schemas (`*tool-schema*.json`) and agent-hooks' MCP tool lists (`tools.json`,
|
|
63
|
+
* `tools.wire.v1.json`). Kept, so no entry point gates less than it did; the required check's own
|
|
64
|
+
* list (index.cjs) is unchanged.
|
|
65
|
+
*/
|
|
66
|
+
const HOOK_EXTRAS = Object.freeze([
|
|
67
|
+
[/(^|\/)[^/]*tool-schema[^/]*\.json$/i, 'agent_tools'],
|
|
68
|
+
[/(^|\/)tools\.(wire\.v1\.)?json$/i, 'mcp_manifest'],
|
|
69
|
+
]);
|
|
70
|
+
|
|
71
|
+
/** `a/./b/../c` → `a/c`; backslashes become slashes; a leading `./` goes. */
|
|
72
|
+
export function normalizePath(p) {
|
|
73
|
+
const s = String(p || '').replace(/\\/g, '/');
|
|
74
|
+
const abs = s.startsWith('/');
|
|
75
|
+
const out = [];
|
|
76
|
+
for (const seg of s.split('/')) {
|
|
77
|
+
if (seg === '' || seg === '.') continue;
|
|
78
|
+
if (seg === '..' && out.length && out[out.length - 1] !== '..') out.pop();
|
|
79
|
+
else if (seg !== '..' || !abs) out.push(seg);
|
|
80
|
+
}
|
|
81
|
+
return (abs ? '/' : '') + out.join('/');
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** The path relative to `cwd` when it is inside it, otherwise the normalized path. */
|
|
85
|
+
export function relativePath(cwd, p) {
|
|
86
|
+
const file = normalizePath(p);
|
|
87
|
+
const root = normalizePath(cwd || '');
|
|
88
|
+
if (root && file.startsWith(`${root}/`)) return file.slice(root.length + 1);
|
|
89
|
+
return file;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function globToRegExp(pattern) {
|
|
93
|
+
let out = '^';
|
|
94
|
+
const src = normalizePath(pattern);
|
|
95
|
+
for (let i = 0; i < src.length;) {
|
|
96
|
+
if (src.startsWith('**/', i)) { out += '(?:.*/)?'; i += 3; } else if (src.startsWith('**', i)) { out += '.*'; i += 2; } else if (src[i] === '*') { out += '[^/]*'; i += 1; } else if (src[i] === '?') { out += '[^/]'; i += 1; } else { out += src[i].replace(/[.+^${}()|[\]\\]/g, '\\$&'); i += 1; }
|
|
97
|
+
}
|
|
98
|
+
return new RegExp(`${out}$`, 'i');
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** A `.coderifts.yml` schema entry or a `coderifts.specPath` value: an exact path, a tail, or a glob. */
|
|
102
|
+
export function matchesPattern(rel, pattern) {
|
|
103
|
+
const p = normalizePath(pattern);
|
|
104
|
+
if (!p) return false;
|
|
105
|
+
if (/[*?]/.test(p)) return globToRegExp(p).test(rel);
|
|
106
|
+
return rel === p || rel.endsWith(`/${p}`);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* The contract type of a path, or null when it is not a contract file.
|
|
111
|
+
* `named` (always gated) and `excluded` (never) are the project's own lists; excluded wins.
|
|
112
|
+
*/
|
|
113
|
+
export function contractType(p, { named = [], excluded = [] } = {}) {
|
|
114
|
+
const rel = normalizePath(p);
|
|
115
|
+
if (!rel) return null;
|
|
116
|
+
if (excluded.some((x) => matchesPattern(rel, x))) return null;
|
|
117
|
+
for (const [re, type] of HOOK_EXTRAS) if (re.test(rel) && !/(^|\/)(node_modules|vendor)\//i.test(rel)) return type;
|
|
118
|
+
if (looksLikeContractPath(rel)) return typeForPath(rel);
|
|
119
|
+
if (named.some((x) => matchesPattern(rel, x))) return typeForPath(rel);
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// ---- Write / Edit / MultiEdit ----------------------------------------------------------------
|
|
124
|
+
|
|
125
|
+
const FILE_TOOLS = Object.freeze({ write: 'Write', edit: 'Edit', multiedit: 'MultiEdit', multi_edit: 'MultiEdit' });
|
|
126
|
+
|
|
127
|
+
/** First present value among the snake_case and camelCase spellings hosts use. */
|
|
128
|
+
function field(obj, ...keys) {
|
|
129
|
+
for (const k of keys) if (obj && obj[k] !== undefined && obj[k] !== null) return obj[k];
|
|
130
|
+
return undefined;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** `Write`, `Edit`, `MultiEdit`, `Bash`, or null for a tool this module does not read. */
|
|
134
|
+
export function toolKind(name) {
|
|
135
|
+
const s = String(name || '');
|
|
136
|
+
if (s === 'Bash' || s.toLowerCase() === 'bash') return 'Bash';
|
|
137
|
+
return FILE_TOOLS[s.toLowerCase()] || null;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function filePathOf(input) {
|
|
141
|
+
const p = field(input, 'file_path', 'filePath', 'path', 'target_file', 'targetFile');
|
|
142
|
+
return typeof p === 'string' && p !== '' ? p : null;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function applyOne(text, edit) {
|
|
146
|
+
const from = field(edit, 'old_string', 'oldString');
|
|
147
|
+
const to = field(edit, 'new_string', 'newString');
|
|
148
|
+
if (typeof from !== 'string' || typeof to !== 'string') return null;
|
|
149
|
+
if (from === '') return text === '' ? to : null; // Claude Code's "create with Edit"
|
|
150
|
+
if (!text.includes(from)) return null;
|
|
151
|
+
return field(edit, 'replace_all', 'replaceAll') === true ? text.split(from).join(to) : text.replace(from, () => to);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** The file after the call, or null when the call does not apply to `before` (never guessed). */
|
|
155
|
+
export function afterText(tool, input, before) {
|
|
156
|
+
const kind = toolKind(tool);
|
|
157
|
+
if (kind === 'Write') {
|
|
158
|
+
const c = field(input, 'content', 'contents');
|
|
159
|
+
return typeof c === 'string' ? c : null;
|
|
160
|
+
}
|
|
161
|
+
if (kind !== 'Edit' && kind !== 'MultiEdit') return null;
|
|
162
|
+
const edits = kind === 'MultiEdit' ? field(input, 'edits', 'Edits') : [input];
|
|
163
|
+
if (!Array.isArray(edits) || edits.length === 0) return null;
|
|
164
|
+
let text = before;
|
|
165
|
+
for (const e of edits) {
|
|
166
|
+
text = applyOne(text, e);
|
|
167
|
+
if (text === null) return null;
|
|
168
|
+
}
|
|
169
|
+
return text;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Why `afterText` returned null, in words a person can act on. */
|
|
173
|
+
export function afterFailure(tool, input, before) {
|
|
174
|
+
const kind = toolKind(tool);
|
|
175
|
+
if (kind === 'Write') return 'its content is missing or not a string';
|
|
176
|
+
if (kind !== 'Edit' && kind !== 'MultiEdit') return `${String(tool)} is not a file tool`;
|
|
177
|
+
const edits = kind === 'MultiEdit' ? field(input, 'edits', 'Edits') : [input];
|
|
178
|
+
if (!Array.isArray(edits) || edits.length === 0) return 'its edits are missing or empty';
|
|
179
|
+
let text = before;
|
|
180
|
+
for (let i = 0; i < edits.length; i += 1) {
|
|
181
|
+
const at = kind === 'MultiEdit' ? ` (edits[${i}])` : '';
|
|
182
|
+
const from = field(edits[i], 'old_string', 'oldString');
|
|
183
|
+
if (typeof from !== 'string' || typeof field(edits[i], 'new_string', 'newString') !== 'string') return `old_string/new_string missing${at}`;
|
|
184
|
+
if (from === '' && text !== '') return `an empty old_string creates a file, and this one is not empty${at}`;
|
|
185
|
+
if (from !== '' && !text.includes(from)) return `old_string not found in the file${at}`;
|
|
186
|
+
text = applyOne(text, edits[i]);
|
|
187
|
+
}
|
|
188
|
+
return 'it does not apply';
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// ---- Bash ------------------------------------------------------------------------------------
|
|
192
|
+
//
|
|
193
|
+
// One question per command: which paths can it write? Each rule below names the write TARGETS of
|
|
194
|
+
// one command shape — a redirect, tee, `sed -i`, the destination of cp / install / ln / rsync,
|
|
195
|
+
// every argument of mv / rm, a git pathspec, an `--out` value, the paths in an interpreter's
|
|
196
|
+
// inline code. A target that is a contract file is a named write (refused, with a pointer to
|
|
197
|
+
// Write/Edit). A target that is a directory, a glob, or unknown (`.`, an expansion) is a scope:
|
|
198
|
+
// it asks when the entry point's listing shows a contract file under it. A path the command only
|
|
199
|
+
// reads (`cat openapi.yaml > /tmp/x`, `cp openapi.yaml /tmp/`) is not a target.
|
|
200
|
+
|
|
201
|
+
const COPY_CMDS = new Set(['cp', 'install', 'ln', 'rsync']);
|
|
202
|
+
const REMOVE_CMDS = new Set(['rm', 'truncate', 'shred', 'unlink', 'mv']);
|
|
203
|
+
const INPLACE_CMDS = new Set(['sed', 'perl', 'ruby']);
|
|
204
|
+
const SHELLS = new Set(['sh', 'bash', 'zsh', 'dash']);
|
|
205
|
+
const INTERPRETERS = new Set(['python', 'python3', 'node', 'ruby', 'perl', 'deno', 'bun']);
|
|
206
|
+
const PREFIXES = new Set(['sudo', 'command', 'exec', 'time', 'nohup', 'env', 'nice', 'do', 'then', 'else', 'elif', '{', '(', '!']);
|
|
207
|
+
const OUT_FLAGS = /^--(out|output|outfile|out-file|output-file|write)(=(.*))?$/;
|
|
208
|
+
const UNKNOWN = '.';
|
|
209
|
+
|
|
210
|
+
const HEREDOC = /(?<!<)<<(?!<)-?\s*(['"]?)([A-Za-z_][A-Za-z0-9_]*)\1/g;
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Heredoc bodies are a command's stdin, not commands: `cat > notes.md <<'EOF'` followed by prose
|
|
214
|
+
* must not be read as shell. Returns the command without the bodies, and the bodies in order.
|
|
215
|
+
*/
|
|
216
|
+
function splitHeredocs(command) {
|
|
217
|
+
const lines = String(command).split('\n');
|
|
218
|
+
const kept = [];
|
|
219
|
+
const bodies = [];
|
|
220
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
221
|
+
kept.push(lines[i]);
|
|
222
|
+
for (const m of lines[i].matchAll(HEREDOC)) {
|
|
223
|
+
const body = [];
|
|
224
|
+
i += 1;
|
|
225
|
+
while (i < lines.length && lines[i].trim() !== m[2]) { body.push(lines[i]); i += 1; }
|
|
226
|
+
bodies.push(body.join('\n'));
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
return { text: kept.join('\n'), bodies };
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** Words of one command, with simple quoting honoured (no expansion). */
|
|
233
|
+
function words(segment) {
|
|
234
|
+
const out = [];
|
|
235
|
+
const re = /'([^']*)'|"((?:\\.|[^"\\])*)"|(\S+)/g;
|
|
236
|
+
let m;
|
|
237
|
+
while ((m = re.exec(segment)) !== null) out.push(m[1] ?? m[2] ?? m[3]);
|
|
238
|
+
return out;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Redirect targets of one command, read outside quotes: `> f`, `>> f`, `>| f`, `&> f`, `2> f`.
|
|
243
|
+
* `>&2` / `2>&1` are descriptors, not paths.
|
|
244
|
+
*/
|
|
245
|
+
function redirects(text) {
|
|
246
|
+
const out = [];
|
|
247
|
+
let quote = null;
|
|
248
|
+
for (let i = 0; i < text.length; i += 1) {
|
|
249
|
+
const ch = text[i];
|
|
250
|
+
if (quote) { if (ch === quote) quote = null; continue; }
|
|
251
|
+
if (ch === '\'' || ch === '"') { quote = ch; continue; }
|
|
252
|
+
if (ch !== '>') continue;
|
|
253
|
+
let j = i + 1;
|
|
254
|
+
if (text[j] === '>' || text[j] === '|') j += 1;
|
|
255
|
+
if (text[j] === '&') { i = j; continue; }
|
|
256
|
+
while (text[j] === ' ' || text[j] === '\t') j += 1;
|
|
257
|
+
const m = /^('[^']*'|"[^"]*"|[^\s;|&<>()]+)/.exec(text.slice(j));
|
|
258
|
+
if (m) { out.push(m[1].replace(/^['"]|['"]$/g, '')); i = j + m[1].length - 1; }
|
|
259
|
+
}
|
|
260
|
+
return out;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** Split on ; && || | & and newlines, outside quotes. Each part keeps the part piped into it. */
|
|
264
|
+
function segments(command, bodies = []) {
|
|
265
|
+
const parts = [];
|
|
266
|
+
let cur = '';
|
|
267
|
+
let quote = null;
|
|
268
|
+
let pipedFrom = null;
|
|
269
|
+
const cut = (piped) => { parts.push({ text: cur, pipedFrom }); cur = ''; pipedFrom = piped ? parts.length - 1 : null; };
|
|
270
|
+
for (let i = 0; i < command.length; i += 1) {
|
|
271
|
+
const ch = command[i];
|
|
272
|
+
if (quote) { cur += ch; if (ch === quote) quote = null; continue; }
|
|
273
|
+
if (ch === '\'' || ch === '"') { quote = ch; cur += ch; continue; }
|
|
274
|
+
const two = command.slice(i, i + 2);
|
|
275
|
+
if (two === '&&' || two === '||') { cut(false); i += 1; continue; }
|
|
276
|
+
if (ch === '|' && command[i - 1] !== '>') { cut(true); continue; }
|
|
277
|
+
if (ch === ';' || ch === '\n' || (ch === '&' && command[i - 1] !== '>' && command[i + 1] !== '>')) { cut(false); continue; }
|
|
278
|
+
cur += ch;
|
|
279
|
+
}
|
|
280
|
+
cut(false);
|
|
281
|
+
let next = 0;
|
|
282
|
+
return parts
|
|
283
|
+
.map((p) => {
|
|
284
|
+
const count = [...p.text.matchAll(HEREDOC)].length;
|
|
285
|
+
const stdin = bodies.slice(next, next + count).join('\n');
|
|
286
|
+
next += count;
|
|
287
|
+
const bare = p.text.replace(HEREDOC, ' ').replace(/\d*>&\d*-?/g, ' ').replace(/\d?>>?\|?\s*('[^']*'|"[^"]*"|[^\s;|&<>()]+)/g, ' ').replace(/&>\s*\S+/g, ' ');
|
|
288
|
+
return { ...p, stdin, argv: argvOf(words(bare)) };
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** Drop leading VAR=value assignments and sudo/env-style prefixes. */
|
|
293
|
+
function argvOf(ws) {
|
|
294
|
+
let i = 0;
|
|
295
|
+
while (i < ws.length && (/^[A-Za-z_][A-Za-z0-9_]*=/.test(ws[i]) || PREFIXES.has(ws[i]))) i += 1;
|
|
296
|
+
return ws.slice(i);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const isFlag = (w) => /^-/.test(w);
|
|
300
|
+
const baseOf = (cmd) => String(cmd || '').split('/').pop();
|
|
301
|
+
const operands = (ws) => ws.filter((w) => w !== '' && !isFlag(w));
|
|
302
|
+
|
|
303
|
+
/** A path the shell expands ($VAR, $(…), `…`) can be anything: unknown. */
|
|
304
|
+
const expanded = (t) => (/[`]|\$\(/.test(t) ? UNKNOWN : t);
|
|
305
|
+
|
|
306
|
+
/** The files `sed -i` / `perl -pi` / `ruby -i` edit: the operands, less the script when it is the first one. */
|
|
307
|
+
function inplaceTargets(rest) {
|
|
308
|
+
if (!rest.some((w) => /^-[a-zA-Z]*i/.test(w) || w === '--in-place' || w.startsWith('--in-place='))) return [];
|
|
309
|
+
const files = [];
|
|
310
|
+
let scriptGiven = false;
|
|
311
|
+
for (let i = 0; i < rest.length; i += 1) {
|
|
312
|
+
const w = rest[i];
|
|
313
|
+
if (w === '-e' || w === '-f' || w === '--expression' || w === '--file' || /^-[a-zA-Z]*[ef]$/.test(w) && !/^-[a-zA-Z]*i$/.test(w)) { scriptGiven = true; i += 1; continue; }
|
|
314
|
+
if (w === '' || isFlag(w)) continue;
|
|
315
|
+
files.push(w);
|
|
316
|
+
}
|
|
317
|
+
return (scriptGiven ? files : files.slice(1)).map(expanded);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** find's reach as globs: each root, narrowed by -name / -iname / -path when present. */
|
|
321
|
+
function findScopes(rest) {
|
|
322
|
+
const roots = [];
|
|
323
|
+
for (const w of rest) {
|
|
324
|
+
if (isFlag(w) || w === '(' || w === '!') break;
|
|
325
|
+
roots.push(expanded(w));
|
|
326
|
+
}
|
|
327
|
+
const starts = roots.length ? roots : [UNKNOWN];
|
|
328
|
+
const names = [];
|
|
329
|
+
const paths = [];
|
|
330
|
+
rest.forEach((w, i) => {
|
|
331
|
+
if ((w === '-name' || w === '-iname') && rest[i + 1]) names.push(rest[i + 1]);
|
|
332
|
+
if ((w === '-path' || w === '-ipath' || w === '-wholename') && rest[i + 1]) paths.push(rest[i + 1]);
|
|
333
|
+
});
|
|
334
|
+
if (!names.length && !paths.length) return starts;
|
|
335
|
+
return [...starts.flatMap((r) => names.map((n) => `${r}/**/${n}`)), ...paths];
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** The argv find runs with -exec / -execdir / -ok / -okdir, if any. */
|
|
339
|
+
function findExec(rest) {
|
|
340
|
+
const at = rest.findIndex((w) => /^-(exec|execdir|ok|okdir)$/.test(w));
|
|
341
|
+
if (at < 0) return null;
|
|
342
|
+
const end = rest.findIndex((w, i) => i > at && (w === ';' || w === '\\;' || w === '+'));
|
|
343
|
+
return rest.slice(at + 1, end < 0 ? undefined : end);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/** The argv xargs runs (after its own options). */
|
|
347
|
+
function xargsExec(rest) {
|
|
348
|
+
let i = 0;
|
|
349
|
+
while (i < rest.length && isFlag(rest[i])) i += /^-[IdLnPsE]$/.test(rest[i]) ? 2 : 1;
|
|
350
|
+
return rest.slice(i);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/** What xargs is fed: find's reach, echo's words, a lister's paths; else the working tree. */
|
|
354
|
+
function xargsScopes(from) {
|
|
355
|
+
if (!from || !from.argv.length) return [UNKNOWN];
|
|
356
|
+
const [cmd, ...rest] = from.argv;
|
|
357
|
+
const base = baseOf(cmd);
|
|
358
|
+
if (base === 'find') return findScopes(rest);
|
|
359
|
+
if (base === 'echo' || base === 'printf') return operands(rest).map(expanded);
|
|
360
|
+
const listed = operands(rest).filter((w) => w === '.' || w.includes('/') || /[*?]/.test(w));
|
|
361
|
+
return listed.length ? listed.map(expanded) : [UNKNOWN];
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/** Does this argv, run by find -exec or xargs on unseen paths, write them? */
|
|
365
|
+
function writesArguments(argv) {
|
|
366
|
+
const [cmd, ...rest] = argv;
|
|
367
|
+
const base = baseOf(cmd);
|
|
368
|
+
if (COPY_CMDS.has(base) || REMOVE_CMDS.has(base) || base === 'tee' || base === 'patch') return true;
|
|
369
|
+
if (INPLACE_CMDS.has(base) && rest.some((w) => /^-[a-zA-Z]*i/.test(w))) return true;
|
|
370
|
+
if (SHELLS.has(base) && rest.includes('-c')) return true;
|
|
371
|
+
if (INTERPRETERS.has(base) && rest.some((w) => /^-[a-zA-Z]*[ce]$/.test(w))) return true; // inline code; running a script is not read as a write
|
|
372
|
+
if (base === 'git' && ['checkout', 'restore', 'rm', 'mv'].includes(rest[0])) return true;
|
|
373
|
+
return false;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/*
|
|
377
|
+
* git writes the working tree from the index or a commit: checkout / restore / rm / mv with a
|
|
378
|
+
* pathspec, reset --hard, stash pop / apply, clean, apply, am. A branch switch, merge, pull or
|
|
379
|
+
* rebase also moves contract files, and so does every other way a commit lands; those are left to
|
|
380
|
+
* the required check, which sees the commit (asking on every `git pull` teaches people to click
|
|
381
|
+
* through).
|
|
382
|
+
*/
|
|
383
|
+
function gitTargets(rest) {
|
|
384
|
+
const [sub, ...args] = rest;
|
|
385
|
+
const dd = args.indexOf('--');
|
|
386
|
+
const pathspec = (dd >= 0 ? args.slice(dd + 1) : operands(args).filter((w) => w === '.' || w.includes('/') || /[*?]/.test(w) || /\.[a-z]+$/i.test(w))).map(expanded);
|
|
387
|
+
if (['checkout', 'restore', 'rm', 'mv'].includes(sub)) return pathspec;
|
|
388
|
+
if (sub === 'reset') return args.includes('--hard') ? [UNKNOWN] : [];
|
|
389
|
+
if (sub === 'stash') return args[0] === 'pop' || args[0] === 'apply' ? [UNKNOWN] : [];
|
|
390
|
+
if (sub === 'clean' || sub === 'apply' || sub === 'am') return pathspec.length ? pathspec : [UNKNOWN];
|
|
391
|
+
return [];
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/*
|
|
395
|
+
* The write calls of inline code, and where each one writes: its path argument as a string
|
|
396
|
+
* literal, or a name bound to one earlier in the same code (`p = 'x.json'` … `open(p, 'w')`).
|
|
397
|
+
* A write call whose target is neither is unknown (`.`); so is code that walks a directory.
|
|
398
|
+
*/
|
|
399
|
+
const WRITE_CALLS = Object.freeze([
|
|
400
|
+
/\bopen\s*\(\s*([^,()]+?)\s*,\s*(?:mode\s*=\s*)?['"][wax+]/g,
|
|
401
|
+
/\b(?:Path|pathlib\.Path)\s*\(\s*([^()]+?)\s*\)\s*\.(?:write_text|write_bytes|unlink|touch)\s*\(/g,
|
|
402
|
+
// \b: a match starts only where a word starts, so a long run of word characters is scanned once (no O(n²)).
|
|
403
|
+
/\b([A-Za-z_][A-Za-z0-9_]*)\s*\.(?:write_text|write_bytes)\s*\(/g,
|
|
404
|
+
/\b(?:fs\.)?(?:writeFileSync|writeFile|appendFileSync|appendFile|rmSync|unlinkSync|truncateSync)\s*\(\s*([^,()]+?)\s*[,)]/g,
|
|
405
|
+
/\bFile\.write\s*\(\s*([^,()]+?)\s*,/g,
|
|
406
|
+
/\bos\.(?:remove|unlink)\s*\(\s*([^,()]+?)\s*\)/g,
|
|
407
|
+
/\b(?:shutil\.(?:copy2?|copyfile|move)|os\.(?:rename|replace)|(?:fs\.)?(?:copyFileSync|renameSync))\s*\(\s*[^,()]+?\s*,\s*([^,()]+?)\s*[,)]/g,
|
|
408
|
+
/\bshutil\.rmtree\s*\(\s*([^,()]+?)\s*[,)]/g,
|
|
409
|
+
]);
|
|
410
|
+
const WRITE_ANY = /\bopen\s*\([^\n]{0,200}?['"][wax]\+?['"]\s*[,)]|\.write_(text|bytes)\s*\(|\b(writeFileSync|writeFile|appendFileSync|copyFileSync|renameSync|rmSync|unlinkSync)\s*\(|\bFile\.write\s*\(|\bos\.(remove|unlink|rename|replace)\s*\(|\bshutil\.(copy2?|copyfile|move|rmtree)\s*\(/;
|
|
411
|
+
// Every pattern above is bounded or anchored at a word start: a 100 KB heredoc is read in linear time.
|
|
412
|
+
const INTERPRETER_WALK = /os\.walk|glob\.|rglob|\.glob\(|readdirSync|Dir\.glob|Find::|globSync|os\.listdir/;
|
|
413
|
+
|
|
414
|
+
/** A write call's argument as a path: a literal, a name bound to a literal, or unknown. */
|
|
415
|
+
function argPath(arg, code) {
|
|
416
|
+
const a = String(arg).trim();
|
|
417
|
+
const lit = /^(?:[rbuf]{0,2})(['"])([^'"]*)\1$/.exec(a);
|
|
418
|
+
if (lit) return lit[2];
|
|
419
|
+
if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(a)) {
|
|
420
|
+
const bound = new RegExp(`(?:^|[\\s;(,])(?:const |let |var )?${a}\\s*=\\s*(?:(?:pathlib\\.)?Path\\(\\s*)?(?:[rbuf]{0,2})(['"])([^'"\\n]*)\\1`).exec(code);
|
|
421
|
+
if (bound) return bound[2];
|
|
422
|
+
}
|
|
423
|
+
return UNKNOWN;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/** Inline code (-c / -e) or code on stdin (a heredoc): where its write calls write. */
|
|
427
|
+
function interpreterTargets(rest, stdin) {
|
|
428
|
+
const flag = rest.findIndex((w) => /^-[a-zA-Z]*[ce]$/.test(w));
|
|
429
|
+
const code = flag >= 0 ? String(rest[flag + 1] ?? '') : (operands(rest).length === 0 || rest.includes('-') ? stdin : '');
|
|
430
|
+
if (!code) return [];
|
|
431
|
+
// Text the code carries as data (a triple-quoted block, a template literal) is not code that runs.
|
|
432
|
+
const live = code.replace(/"""[\s\S]*?"""|'''[\s\S]*?'''|`[^`]*`/g, '""');
|
|
433
|
+
const targets = WRITE_CALLS.flatMap((re) => [...live.matchAll(re)].map((m) => argPath(m[1], live)));
|
|
434
|
+
// A write call whose argument the patterns above could not read (`open(os.path.join(r, f), 'w')`)
|
|
435
|
+
// still writes somewhere: unknown.
|
|
436
|
+
const unread = WRITE_ANY.test(live) && targets.length === 0;
|
|
437
|
+
if (!targets.length && !unread) return [];
|
|
438
|
+
return INTERPRETER_WALK.test(live) || unread ? [...targets, UNKNOWN] : targets;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/** The output of --out / --output / --output-file … on any command. */
|
|
442
|
+
function outFlagTargets(rest) {
|
|
443
|
+
const out = [];
|
|
444
|
+
rest.forEach((w, i) => {
|
|
445
|
+
const m = OUT_FLAGS.exec(w);
|
|
446
|
+
if (m) out.push(m[3] !== undefined ? m[3] : String(rest[i + 1] ?? ''));
|
|
447
|
+
});
|
|
448
|
+
return out.filter(Boolean).map(expanded);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/** The write targets of one command (argv after redirects are taken out). */
|
|
452
|
+
function commandTargets(seg, all, depth) {
|
|
453
|
+
const [cmd, ...rest] = seg.argv;
|
|
454
|
+
const base = baseOf(cmd);
|
|
455
|
+
const out = outFlagTargets(rest);
|
|
456
|
+
if (!cmd) return out;
|
|
457
|
+
if (base === 'eval') return [UNKNOWN];
|
|
458
|
+
if (SHELLS.has(base)) {
|
|
459
|
+
const at = rest.indexOf('-c');
|
|
460
|
+
if (at >= 0) return depth < 2 ? shellWriteTargets(String(rest[at + 1] ?? ''), depth + 1) : [UNKNOWN];
|
|
461
|
+
return seg.pipedFrom !== null || seg.stdin ? [UNKNOWN] : out; // `… | sh`, `sh <<EOF` run unseen text
|
|
462
|
+
}
|
|
463
|
+
if (base === 'tee') return [...out, ...operands(rest).map(expanded)];
|
|
464
|
+
if (COPY_CMDS.has(base)) {
|
|
465
|
+
const t = rest.findIndex((w) => w === '-t' || w === '--target-directory');
|
|
466
|
+
const ops = operands(rest);
|
|
467
|
+
return [...out, ...(t >= 0 && rest[t + 1] ? [rest[t + 1]] : ops.slice(-1)).map(expanded)];
|
|
468
|
+
}
|
|
469
|
+
if (REMOVE_CMDS.has(base)) return [...out, ...operands(rest).map(expanded)];
|
|
470
|
+
if (INPLACE_CMDS.has(base)) {
|
|
471
|
+
const inplace = inplaceTargets(rest);
|
|
472
|
+
if (inplace.length || base === 'sed') return [...out, ...inplace];
|
|
473
|
+
return [...out, ...interpreterTargets(rest, seg.stdin)];
|
|
474
|
+
}
|
|
475
|
+
if (base === 'patch') { const ops = operands(rest); return [...out, ...(ops.length ? ops.map(expanded) : [UNKNOWN])]; }
|
|
476
|
+
if (base === 'dd') return [...out, ...rest.filter((w) => w.startsWith('of=')).map((w) => expanded(w.slice(3)))];
|
|
477
|
+
if (base === 'curl') return [...out, ...rest.flatMap((w, i) => (w === '-o' ? [expanded(String(rest[i + 1] ?? ''))] : []))];
|
|
478
|
+
if (base === 'wget') return [...out, ...rest.flatMap((w, i) => (w === '-O' ? [expanded(String(rest[i + 1] ?? ''))] : []))];
|
|
479
|
+
if (base === 'git') return [...out, ...gitTargets(rest)];
|
|
480
|
+
if ((base === 'tar' && /^-?[a-zA-Z]*x/.test(rest[0] || '')) || base === 'unzip') {
|
|
481
|
+
const at = rest.findIndex((w) => w === '-C' || w === '-d' || w === '--directory');
|
|
482
|
+
return [...out, at >= 0 && rest[at + 1] ? expanded(rest[at + 1]) : UNKNOWN];
|
|
483
|
+
}
|
|
484
|
+
if (base === 'find') {
|
|
485
|
+
const exec = findExec(rest);
|
|
486
|
+
return rest.includes('-delete') || (exec && writesArguments(exec)) ? [...out, ...findScopes(rest)] : out;
|
|
487
|
+
}
|
|
488
|
+
if (base === 'xargs') return writesArguments(xargsExec(rest)) ? [...out, ...xargsScopes(seg.pipedFrom === null ? null : all[seg.pipedFrom])] : out;
|
|
489
|
+
if (INTERPRETERS.has(base)) return [...out, ...interpreterTargets(rest, seg.stdin)];
|
|
490
|
+
return out;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/**
|
|
494
|
+
* Every path a command can write, as it is written in the command: a file, a directory, a glob,
|
|
495
|
+
* or `.` when the command does not say (an expansion, eval, a pipe into sh, a tree-wide git
|
|
496
|
+
* restore). A redirect into /dev/* is not a write to the tree.
|
|
497
|
+
*/
|
|
498
|
+
export function shellWriteTargets(command, depth = 0) {
|
|
499
|
+
return [...new Set(shellWrites(command, depth).map((w) => w.target))];
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/** The same, with the command shape that writes each target (`redirect`, `rm`, `git stash`, …). */
|
|
503
|
+
export function shellWrites(command, depth = 0) {
|
|
504
|
+
const { text, bodies } = splitHeredocs(String(command || ''));
|
|
505
|
+
const segs = segments(text, bodies);
|
|
506
|
+
const vars = {};
|
|
507
|
+
let dir = '';
|
|
508
|
+
const writes = [];
|
|
509
|
+
for (const seg of segs) {
|
|
510
|
+
for (const [, k] of seg.text.matchAll(/(?:^|\s)([A-Za-z_][A-Za-z0-9_]*)=["']?\$\(mktemp\b/g)) vars[k] = '/tmp/mktemp'; // a fresh temp dir is outside the tree
|
|
511
|
+
const loop = /^\s*for\s+([A-Za-z_][A-Za-z0-9_]*)\s+in\s+(.+?)\s*$/.exec(seg.text);
|
|
512
|
+
if (loop && !/[$`*?]/.test(loop[2])) vars[loop[1]] = words(loop[2]); // `for r in a b; do … $r …`
|
|
513
|
+
for (const [, k, v] of seg.text.matchAll(/(?:^|\s)([A-Za-z_][A-Za-z0-9_]*)=('[^']*'|"[^"$`]*"|[^\s;|&$`'"]+)/g)) vars[k] = v.replace(/^['"]|['"]$/g, '');
|
|
514
|
+
const by = baseOf(seg.argv[0]) === 'git' ? `git ${seg.argv[1] ?? ''}`.trim() : baseOf(seg.argv[0]);
|
|
515
|
+
const own = [
|
|
516
|
+
...redirects(seg.text).map((t) => ({ target: t, by: 'redirect' })),
|
|
517
|
+
...commandTargets(seg, segs, depth).map((t) => ({ target: t, by })),
|
|
518
|
+
];
|
|
519
|
+
for (const w of own) {
|
|
520
|
+
for (const t of resolveVars(w.target, vars)) {
|
|
521
|
+
const target = within(dir, t);
|
|
522
|
+
if (target && !target.startsWith('/dev/')) writes.push({ target, by: w.by });
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
if (seg.argv[0] === 'cd') dir = within(dir, resolveVars(String(seg.argv[1] ?? '~'), vars)[0] ?? '') || dir;
|
|
526
|
+
}
|
|
527
|
+
return writes;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
/** `$f` / `${f}` with a value assigned earlier in the same command; anything else stays unknown. */
|
|
531
|
+
function resolveVars(t, vars) {
|
|
532
|
+
let forms = [String(t)];
|
|
533
|
+
for (const [k, v] of Object.entries(vars)) {
|
|
534
|
+
const re = new RegExp(`\\$\\{?${k}\\}?(?![A-Za-z0-9_])`, 'g');
|
|
535
|
+
forms = forms.flatMap((f) => (re.test(f) ? (Array.isArray(v) ? v : [v]).map((x) => f.replace(re, () => x)) : [f]));
|
|
536
|
+
}
|
|
537
|
+
return forms.map((f) => {
|
|
538
|
+
const r = expanded(f);
|
|
539
|
+
if (!/\$/.test(r)) return r;
|
|
540
|
+
// `> "$LOG/run.txt"`: whatever $LOG is, a file named run.txt is not a contract file.
|
|
541
|
+
const name = r.split('/').pop();
|
|
542
|
+
if (!/\$/.test(name) && /\.[A-Za-z0-9]+$/.test(name) && !CONTRACT_EXT.test(name)) return '';
|
|
543
|
+
return UNKNOWN;
|
|
544
|
+
});
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/** A target as seen from the directory a `cd` earlier in the command moved to. */
|
|
548
|
+
function within(dir, t) {
|
|
549
|
+
if (!t || !dir) return t;
|
|
550
|
+
if (t.startsWith('/') || t.startsWith('~')) return t;
|
|
551
|
+
if (t === UNKNOWN) return dir;
|
|
552
|
+
return `${dir.replace(/\/$/, '')}/${t}`;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/**
|
|
556
|
+
* The home directory a `~` stands for. Read from the working directory (`/Users/<name>/…`,
|
|
557
|
+
* `/home/<name>/…`), not from the environment: the mod may not read one, and every entry point
|
|
558
|
+
* has to resolve `~` the same way. A tree outside those two answers null.
|
|
559
|
+
*/
|
|
560
|
+
export function homeOf(cwd) {
|
|
561
|
+
const m = /^(\/Users\/[^/]+|\/home\/[^/]+)(\/|$)/.exec(normalizePath(cwd || ''));
|
|
562
|
+
return m ? m[1] : null;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/** A target relative to the working tree, `.` for an ancestor of it, null outside it. */
|
|
566
|
+
export function scopeInTree(target, { cwd = '' } = {}) {
|
|
567
|
+
let t = String(target);
|
|
568
|
+
if (t === '~' || t.startsWith('~/')) {
|
|
569
|
+
const home = homeOf(cwd);
|
|
570
|
+
if (!home) return UNKNOWN; // a home that cannot be read from the tree may hold it
|
|
571
|
+
t = home + t.slice(1);
|
|
572
|
+
}
|
|
573
|
+
if (!t.startsWith('/')) return normalizePath(t) || UNKNOWN;
|
|
574
|
+
const abs = normalizePath(t);
|
|
575
|
+
const root = normalizePath(cwd);
|
|
576
|
+
if (!root) return null;
|
|
577
|
+
if (abs === root || root.startsWith(`${abs === '/' ? '' : abs}/`)) return UNKNOWN;
|
|
578
|
+
if (abs.startsWith(`${root}/`)) return abs.slice(root.length + 1);
|
|
579
|
+
return null;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
/** Does the listing under `scope` hold a contract file? A glob scope is matched against its directory's listing. */
|
|
583
|
+
export function contractUnder(scope, listing, opts = {}) {
|
|
584
|
+
const s = normalizePath(scope) || UNKNOWN;
|
|
585
|
+
const files = Array.isArray(listing) ? listing : [];
|
|
586
|
+
if (/[*?]/.test(s)) return files.some((f) => globToRegExp(s).test(normalizePath(f)) && contractType(f, opts));
|
|
587
|
+
return files.some((f) => contractType(f, opts));
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
/** The directory an entry point lists for a scope: a glob's parent, `.` for the working tree. */
|
|
591
|
+
export function listingRoot(scope) {
|
|
592
|
+
const s = normalizePath(scope) || UNKNOWN;
|
|
593
|
+
if (!/[*?]/.test(s)) return s;
|
|
594
|
+
const head = s.split('/');
|
|
595
|
+
const at = head.findIndex((p) => /[*?]/.test(p));
|
|
596
|
+
return head.slice(0, at).join('/') || UNKNOWN;
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
/** Directories the walk does not enter: no contract file there is gated (node_modules, vendor) or it is not the tree (.git). */
|
|
600
|
+
const SKIP_DIRS = new Set(['.git', 'node_modules', 'vendor']);
|
|
601
|
+
|
|
602
|
+
/**
|
|
603
|
+
* Walk `root` breadth-first with the host's one-level reader and stop at the first path `match`
|
|
604
|
+
* accepts. `listDir(dir)` → [{ name, kind: 'file' | 'directory' }], or null when `dir` is not a
|
|
605
|
+
* directory. Returns { found, complete }: complete is false when a limit stopped the walk, and the
|
|
606
|
+
* caller then treats the scope as holding a contract (not knowing is not a pass).
|
|
607
|
+
*/
|
|
608
|
+
export async function findUnder(listDir, root, match, limits = LISTING_LIMITS) {
|
|
609
|
+
const start = normalizePath(root) || UNKNOWN;
|
|
610
|
+
const top = await listDir(start);
|
|
611
|
+
if (!Array.isArray(top)) return { found: start !== UNKNOWN && match(start) ? start : null, complete: true };
|
|
612
|
+
let queue = [{ dir: start, entries: top, depth: 0 }];
|
|
613
|
+
let seen = 0;
|
|
614
|
+
while (queue.length) {
|
|
615
|
+
const next = [];
|
|
616
|
+
for (const { dir, entries, depth } of queue) {
|
|
617
|
+
for (const e of [...entries].sort((a, b) => String(a.name).localeCompare(String(b.name)))) {
|
|
618
|
+
seen += 1;
|
|
619
|
+
if (seen > limits.entries) return { found: null, complete: false };
|
|
620
|
+
const rel = dir === UNKNOWN ? String(e.name) : `${dir}/${e.name}`;
|
|
621
|
+
if (e.kind === 'directory') {
|
|
622
|
+
if (SKIP_DIRS.has(e.name)) continue;
|
|
623
|
+
if (depth + 1 > limits.depth) return { found: null, complete: false };
|
|
624
|
+
const sub = await listDir(rel);
|
|
625
|
+
if (Array.isArray(sub)) next.push({ dir: rel, entries: sub, depth: depth + 1 });
|
|
626
|
+
} else if (match(rel)) {
|
|
627
|
+
return { found: rel, complete: true };
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
queue = next;
|
|
632
|
+
}
|
|
633
|
+
return { found: null, complete: true };
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
// ---- the decision ------------------------------------------------------------------------------
|
|
637
|
+
|
|
638
|
+
/**
|
|
639
|
+
* One tool call → what the entry point does with it.
|
|
640
|
+
*
|
|
641
|
+
* { action: 'pass' } not a contract call this module reads
|
|
642
|
+
* { action: 'gate', path, rel, type, before, after } send before/after to CodeRifts
|
|
643
|
+
* { action: 'refuse', reason, why, … } refuse locally: a shell write to a named
|
|
644
|
+
* contract file, an edit that does not
|
|
645
|
+
* apply, a contract file it cannot read
|
|
646
|
+
* { action: 'ask', reason, why, scopes } a shell write that can reach a contract
|
|
647
|
+
* file without naming it
|
|
648
|
+
*
|
|
649
|
+
* io: { cwd, named, excluded,
|
|
650
|
+
* readFile(path) → string | null when missing; throws when unreadable,
|
|
651
|
+
* listDir(dir) → [{ name, kind }] for one level (relative to cwd), null when not a directory;
|
|
652
|
+
* the walk (findUnder, LISTING_LIMITS) is this module's, so every entry
|
|
653
|
+
* point looks as far as the others,
|
|
654
|
+
* listFiles(dir) → (instead of listDir) every file path under dir, for a host that has them }
|
|
655
|
+
* The readers may be async. With neither lister, a shell scope is assumed to hold a contract.
|
|
656
|
+
*/
|
|
657
|
+
export async function decideToolCall(call, io = {}) {
|
|
658
|
+
const kind = toolKind(call && (call.tool ?? call.tool_name));
|
|
659
|
+
const input = (call && (call.input ?? call.tool_input)) || {};
|
|
660
|
+
const opts = { named: io.named || [], excluded: io.excluded || [] };
|
|
661
|
+
if (kind === 'Bash') return decideShell(String(field(input, 'command') ?? ''), io, opts);
|
|
662
|
+
if (!kind) return { action: 'pass' };
|
|
663
|
+
const filePath = filePathOf(input);
|
|
664
|
+
if (!filePath) return { action: 'pass' };
|
|
665
|
+
const rel = relativePath(io.cwd, filePath);
|
|
666
|
+
const type = contractType(rel, opts);
|
|
667
|
+
if (!type) return { action: 'pass' };
|
|
668
|
+
let before;
|
|
669
|
+
try {
|
|
670
|
+
before = (await (io.readFile ? io.readFile(filePath) : null)) ?? '';
|
|
671
|
+
} catch (err) {
|
|
672
|
+
return { action: 'refuse', reason: 'unreadable', rel, type, why: `${rel} is a contract file and could not be read (${String((err && err.message) || err)})` };
|
|
673
|
+
}
|
|
674
|
+
const after = afterText(kind, input, before);
|
|
675
|
+
if (after === null) {
|
|
676
|
+
return { action: 'refuse', reason: 'edit_does_not_apply', rel, type, why: `the ${kind} does not apply to ${rel} as it is on disk (${afterFailure(kind, input, before)}), so there is no after text to check` };
|
|
677
|
+
}
|
|
678
|
+
return { action: 'gate', path: filePath, rel, type, before, after };
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
/** Is there a contract file under `scope`? Unknown (no reader, a limit hit) counts as yes. */
|
|
682
|
+
async function mayHoldContract(scope, io, opts) {
|
|
683
|
+
const glob = /[*?]/.test(scope) ? globToRegExp(normalizePath(scope)) : null;
|
|
684
|
+
const match = (rel) => Boolean(contractType(rel, opts)) && (!glob || glob.test(normalizePath(rel)));
|
|
685
|
+
// A reader that throws is a scope this module could not look into: it counts as holding a contract.
|
|
686
|
+
try {
|
|
687
|
+
if (io.listDir) {
|
|
688
|
+
const r = await findUnder(io.listDir, listingRoot(scope), match);
|
|
689
|
+
return Boolean(r.found) || !r.complete;
|
|
690
|
+
}
|
|
691
|
+
if (io.listFiles) {
|
|
692
|
+
const listing = await io.listFiles(listingRoot(scope));
|
|
693
|
+
return !Array.isArray(listing) || contractUnder(scope, listing, opts);
|
|
694
|
+
}
|
|
695
|
+
} catch {
|
|
696
|
+
return true;
|
|
697
|
+
}
|
|
698
|
+
return true;
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
async function decideShell(command, io, opts) {
|
|
702
|
+
const named = [];
|
|
703
|
+
const scopes = new Map();
|
|
704
|
+
for (const { target, by } of shellWrites(command)) {
|
|
705
|
+
const inTree = scopeInTree(target, io);
|
|
706
|
+
if (inTree === null) continue;
|
|
707
|
+
const type = inTree !== UNKNOWN && !/[*?]/.test(inTree) ? contractType(inTree, opts) : null;
|
|
708
|
+
if (type) named.push({ path: target, type, by });
|
|
709
|
+
else scopes.set(inTree, [...(scopes.get(inTree) || []), by]);
|
|
710
|
+
}
|
|
711
|
+
if (named.length) {
|
|
712
|
+
return {
|
|
713
|
+
action: SHELL_NAMED_DECISION,
|
|
714
|
+
reason: 'shell_names_contract',
|
|
715
|
+
paths: named,
|
|
716
|
+
why: `this shell command writes ${named.map((n) => `${n.path} (${n.by})`).join(', ')}, and a shell write cannot be checked: the after text is not known. Use the Write or Edit tool for contract files`,
|
|
717
|
+
};
|
|
718
|
+
}
|
|
719
|
+
const reached = [];
|
|
720
|
+
for (const [scope, bys] of scopes) {
|
|
721
|
+
if (await mayHoldContract(scope, io, opts)) reached.push({ scope, by: [...new Set(bys)] });
|
|
722
|
+
}
|
|
723
|
+
if (!reached.length) return { action: 'pass' };
|
|
724
|
+
return {
|
|
725
|
+
action: SHELL_UNNAMED_DECISION,
|
|
726
|
+
reason: 'shell_may_write_contract',
|
|
727
|
+
scopes: reached.map((r) => r.scope),
|
|
728
|
+
by: [...new Set(reached.flatMap((r) => r.by))],
|
|
729
|
+
why: `this shell command can write files under ${reached.map((r) => `${r.scope === UNKNOWN ? 'the working tree' : r.scope} (${r.by.join(', ')})`).join(', ')} without naming them, and a contract file is there. Use the Write or Edit tool for contract files, or confirm that this command leaves them alone`,
|
|
730
|
+
};
|
|
731
|
+
}
|
package/gate.js
CHANGED
|
@@ -4,24 +4,14 @@
|
|
|
4
4
|
* `index.js` is the OpenClaw entry; everything decidable lives here.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import { readFile } from "node:fs/promises";
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
* "the whole change was checked".
|
|
16
|
-
*/
|
|
17
|
-
export const ARTIFACT_TYPES = Object.freeze([
|
|
18
|
-
[/(^|\/)openapi[^/]*\.(ya?ml|json)$/i, "openapi"],
|
|
19
|
-
[/(^|\/)swagger[^/]*\.(ya?ml|json)$/i, "openapi"],
|
|
20
|
-
[/(^|\/)asyncapi[^/]*\.(ya?ml|json)$/i, "asyncapi"],
|
|
21
|
-
[/\.graphql$|\.gql$/i, "graphql"],
|
|
22
|
-
[/\.proto$/i, "protobuf"],
|
|
23
|
-
[/(^|\/)(mcp|tools)\.(wire\.v1\.)?json$/i, "mcp"],
|
|
24
|
-
]);
|
|
7
|
+
import { readFile, readdir } from "node:fs/promises";
|
|
8
|
+
import { isAbsolute, resolve } from "node:path";
|
|
9
|
+
// The one decision function this hook, `coderifts claude-hook` and the CodeRifts mod share
|
|
10
|
+
// (2026-10-03): which path is a contract and of what type, the after text of an Edit, what a shell
|
|
11
|
+
// command writes. A byte copy of @coderifts/contract-path's contract-write.mjs, written by the app's
|
|
12
|
+
// scripts/generate-contract-write-copies.js; contract-write.sha256 beside it is checked by
|
|
13
|
+
// test/contract-write-copy.test.js.
|
|
14
|
+
import { contractType, decideToolCall, toolKind } from "./contract-write.mjs";
|
|
25
15
|
|
|
26
16
|
/** Tools whose params carry a path and a new file body. */
|
|
27
17
|
export const DEFAULT_TOOL_SHAPES = Object.freeze({
|
|
@@ -31,9 +21,15 @@ export const DEFAULT_TOOL_SHAPES = Object.freeze({
|
|
|
31
21
|
str_replace_editor: { path: "path", content: "new_str" },
|
|
32
22
|
});
|
|
33
23
|
|
|
24
|
+
/**
|
|
25
|
+
* The contract type of a path (contract-write's list: OpenAPI/Swagger, AsyncAPI, GraphQL, protobuf,
|
|
26
|
+
* MCP manifests and tool lists, agent tool schemas), or null. Until 0.3.0 this file kept its own
|
|
27
|
+
* list and sent `protobuf` and `mcp`, which the change-set surface does not analyze; the types are
|
|
28
|
+
* now the surface's own (`grpc`, `mcp_manifest`). What is not on the list is not gated, and the
|
|
29
|
+
* refusal text says so.
|
|
30
|
+
*/
|
|
34
31
|
export function classifyPath(p) {
|
|
35
|
-
|
|
36
|
-
return null;
|
|
32
|
+
return contractType(p);
|
|
37
33
|
}
|
|
38
34
|
|
|
39
35
|
/**
|
|
@@ -47,6 +43,13 @@ export const DOES_NOT_PROVE = Object.freeze([
|
|
|
47
43
|
"that the bytes finally written are the bytes checked — nothing here locks the file between this answer and the write",
|
|
48
44
|
"that the other tool calls in this run were checked — each call is judged alone",
|
|
49
45
|
"that a contract artifact this gate does not recognise was seen at all",
|
|
46
|
+
"that the call was refused when this hook is installed outside managed settings — since Claude Code 2.1.287 a user-installed mod runs before it, can answer the call so the hook never runs, and can approve a call the hook blocked; only a hook in managed settings is final, and a plugin hook runs after the mods even when managed settings force-enable the plugin",
|
|
47
|
+
"that a call was refused when the hook ran out of time — the fail-closed path covers an error inside the hook, not Claude Code's hook timeout: a timed-out PreToolUse command hook does not block the call (true for this hook only when CODERIFTS_TIMEOUT_MS is at least the hook timeout)",
|
|
48
|
+
// 0.3.0 (2026-10-03), measured on Claude Code 2.1.288 with a probe plugin: disableAllHooks from
|
|
49
|
+
// --settings and from the project's .claude/settings.json, --safe-mode and --bare each stopped a
|
|
50
|
+
// plugin's PreToolUse hook; a shell command rewrote .claude/settings.json unless the sandbox was on.
|
|
51
|
+
"that the hook was running — disableAllHooks in any settings file (the project's .claude/settings.json included, which a shell command can rewrite unless the sandbox protects it), --safe-mode or --bare turns it off",
|
|
52
|
+
"that a contract generated from source code was checked — a contract generated from source code (annotations, decorators, a build step) changes when the source changes; the hooks see the source edit, not the contract; the required check sees the generated contract",
|
|
50
53
|
]);
|
|
51
54
|
|
|
52
55
|
/**
|
|
@@ -73,7 +76,7 @@ export function blockText(op, why) {
|
|
|
73
76
|
}
|
|
74
77
|
|
|
75
78
|
/** One JSON-RPC POST. No session handshake, no SDK — measured to be all the surface needs. */
|
|
76
|
-
async function askCodeRifts({ endpoint, apiKey, timeoutMs, operation, artifact }) {
|
|
79
|
+
export async function askCodeRifts({ endpoint, apiKey, timeoutMs, operation, artifact }) {
|
|
77
80
|
const controller = new AbortController();
|
|
78
81
|
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
79
82
|
try {
|
|
@@ -221,6 +224,48 @@ export function resolveTarget(event, shapes) {
|
|
|
221
224
|
return null;
|
|
222
225
|
}
|
|
223
226
|
|
|
227
|
+
/** The host readers contract-write takes: a missing file is null, an unreadable one throws. */
|
|
228
|
+
export function hostIo(cwd, read = (p) => readFile(p, "utf8"), list = defaultListDir) {
|
|
229
|
+
const abs = (p) => (isAbsolute(p) ? p : resolve(cwd || process.cwd(), p));
|
|
230
|
+
return {
|
|
231
|
+
cwd: cwd || process.cwd(),
|
|
232
|
+
readFile: async (p) => {
|
|
233
|
+
try {
|
|
234
|
+
return await read(abs(p));
|
|
235
|
+
} catch (err) {
|
|
236
|
+
if (err && err.code === "ENOENT") return null;
|
|
237
|
+
throw err;
|
|
238
|
+
}
|
|
239
|
+
},
|
|
240
|
+
listDir: (dir) => list(abs(dir)),
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
async function defaultListDir(dir) {
|
|
245
|
+
try {
|
|
246
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
247
|
+
return entries.map((e) => ({ name: e.name, kind: e.isDirectory() ? "directory" : "file" }));
|
|
248
|
+
} catch {
|
|
249
|
+
return null;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
async function gateClaudeCall(event, { read, call, endpoint, apiKey, timeoutMs, operation }) {
|
|
254
|
+
const d = await decideToolCall({ tool: event.toolName, input: event.params ?? {} }, hostIo(event.cwd, read));
|
|
255
|
+
if (d.action === "pass") return undefined;
|
|
256
|
+
if (d.action !== "gate") {
|
|
257
|
+
return ask("CodeRifts gate could not read the proposed change", `${d.why}. It will not pass a change it has not seen.`);
|
|
258
|
+
}
|
|
259
|
+
const outcome = await call({
|
|
260
|
+
endpoint,
|
|
261
|
+
apiKey,
|
|
262
|
+
timeoutMs,
|
|
263
|
+
operation,
|
|
264
|
+
artifact: { id: d.path, type: d.type, before: d.before, after: d.after },
|
|
265
|
+
});
|
|
266
|
+
return decide(outcome, { operation, path: d.path });
|
|
267
|
+
}
|
|
268
|
+
|
|
224
269
|
/** The handler, with its I/O injected so a test can drive it without a network or a disk. */
|
|
225
270
|
export function createGate(config = {}, deps = {}) {
|
|
226
271
|
const endpoint = config.endpoint ?? "https://app.coderifts.com/mcp";
|
|
@@ -232,6 +277,11 @@ export function createGate(config = {}, deps = {}) {
|
|
|
232
277
|
const read = deps.readFile ?? ((p) => readFile(p, "utf8"));
|
|
233
278
|
|
|
234
279
|
return async function beforeToolCall(event) {
|
|
280
|
+
// A Claude Code file tool (Write / Edit / MultiEdit): contract-write decides the path, the type
|
|
281
|
+
// and the after text, as the CLI hook and the mod do.
|
|
282
|
+
const claudeTool = toolKind(event.toolName);
|
|
283
|
+
if (claudeTool && claudeTool !== "Bash") return gateClaudeCall(event, { read, call, endpoint, apiKey, timeoutMs, operation });
|
|
284
|
+
|
|
235
285
|
const target = resolveTarget(event, shapes);
|
|
236
286
|
// Not a contract artifact this gate recognises. Staying out of the way is not a fail-open: the
|
|
237
287
|
// gate never claimed this call, and DOES_NOT_PROVE says as much on every refusal it does make.
|
package/openclaw.plugin.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@coderifts/agent-hooks",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Ask CodeRifts before an agent writes a contract artifact (OpenClaw before_tool_call and Claude Code PreToolUse).",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"homepage": "https://github.com/coderifts/agent-hooks#readme",
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
"files": [
|
|
17
17
|
"index.js",
|
|
18
18
|
"gate.js",
|
|
19
|
+
"contract-write.mjs",
|
|
19
20
|
"openclaw.plugin.json",
|
|
20
21
|
"README.md",
|
|
21
22
|
"CHANGELOG.md",
|