@veda-hq/sarathi 0.5.6 → 0.5.7
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/README.md +16 -0
- package/dist/cli.js +38 -5
- package/dist/config.js +29 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -71,6 +71,22 @@ sarathi for every event the list names, with a matcher that lets every
|
|
|
71
71
|
handler fire and a timeout at least the longest handler's. Run it in the
|
|
72
72
|
project's tests.
|
|
73
73
|
|
|
74
|
+
### Forbidden variables
|
|
75
|
+
|
|
76
|
+
Some switches turn a session's tooling off from the environment, where no
|
|
77
|
+
committed file can see them: `CLAUDE_CODE_DISABLE_ADVISOR_TOOL=1` turns the
|
|
78
|
+
advisor off whatever the settings say. The hook list's top-level `forbidEnv`
|
|
79
|
+
names each such variable with why:
|
|
80
|
+
|
|
81
|
+
```json
|
|
82
|
+
{ "forbidEnv": { "CLAUDE_CODE_DISABLE_ADVISOR_TOOL": "this project works with the advisor on" } }
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
While one is set (anything but empty, `0` or `false`), `sarathi hook` refuses
|
|
86
|
+
every `PreToolUse` call before any handler runs, and at `SessionStart` and
|
|
87
|
+
`UserPromptSubmit` tells both Claude and the user why and how to fix it. Other
|
|
88
|
+
events are left alone. It acts only on events the settings route to sarathi.
|
|
89
|
+
|
|
74
90
|
## The plugin
|
|
75
91
|
|
|
76
92
|
`sarathi@veda`, from this repository's marketplace, carries two agents:
|
package/dist/cli.js
CHANGED
|
@@ -9,10 +9,10 @@
|
|
|
9
9
|
*/
|
|
10
10
|
import { readFileSync } from 'node:fs';
|
|
11
11
|
import { join, resolve } from 'node:path';
|
|
12
|
-
import { ConfigError, compileMatcher, entriesFor, loadConfig, longestTimeout, settingsMatcher } from './config.js';
|
|
12
|
+
import { ConfigError, compileMatcher, entriesFor, forbiddenSet, loadConfig, longestTimeout, settingsMatcher } from './config.js';
|
|
13
13
|
import { merge } from './merge.js';
|
|
14
14
|
import { runAll } from './run.js';
|
|
15
|
-
export const VERSION = '0.5.
|
|
15
|
+
export const VERSION = '0.5.7';
|
|
16
16
|
const USAGE = `usage: sarathi hook <Event> [--config <file>]
|
|
17
17
|
sarathi check [--config <file>] [--settings <file>]
|
|
18
18
|
sarathi --version
|
|
@@ -61,6 +61,31 @@ export function matchTarget(event) {
|
|
|
61
61
|
}
|
|
62
62
|
return '';
|
|
63
63
|
}
|
|
64
|
+
/** Events where a forbidden variable is told to the session and the user. */
|
|
65
|
+
const WARNED_EVENTS = new Set(['SessionStart', 'UserPromptSubmit']);
|
|
66
|
+
/**
|
|
67
|
+
* What sarathi answers, beside the handlers, when the session sets a variable
|
|
68
|
+
* the project forbids: PreToolUse refuses, SessionStart and UserPromptSubmit
|
|
69
|
+
* warn both Claude and the user, other events say nothing.
|
|
70
|
+
*/
|
|
71
|
+
export function forbidOutcome(event, set) {
|
|
72
|
+
if (set.length === 0)
|
|
73
|
+
return undefined;
|
|
74
|
+
const lines = set.map(([name, why]) => `${name} is set, and this project forbids it: ${why}`);
|
|
75
|
+
const fix = `Unset ${set.map(([name]) => name).join(' and ')} and start a new session; until then sarathi refuses every tool call.`;
|
|
76
|
+
const base = { label: 'sarathi forbidEnv', timedOut: false, failClosed: true };
|
|
77
|
+
if (GUARDED_EVENTS.has(event))
|
|
78
|
+
return { ...base, code: 2, stdout: '', stderr: `BLOCKED: sarathi: ${lines.join('; ')}. ${fix}\n` };
|
|
79
|
+
if (!WARNED_EVENTS.has(event))
|
|
80
|
+
return undefined;
|
|
81
|
+
const message = `sarathi: ${lines.join('; ')}. ${fix}`;
|
|
82
|
+
return {
|
|
83
|
+
...base,
|
|
84
|
+
code: 0,
|
|
85
|
+
stdout: JSON.stringify({ systemMessage: message, hookSpecificOutput: { hookEventName: event, additionalContext: message } }),
|
|
86
|
+
stderr: '',
|
|
87
|
+
};
|
|
88
|
+
}
|
|
64
89
|
async function hook(io, options) {
|
|
65
90
|
const event = options.positional[1];
|
|
66
91
|
if (event === undefined || options.positional.length > 2) {
|
|
@@ -89,11 +114,19 @@ async function hook(io, options) {
|
|
|
89
114
|
io.err(`${refuse ? 'BLOCKED: ' : ''}sarathi cannot use its hook list: ${message}. No ${event} handler ran.\n`);
|
|
90
115
|
return refuse ? 2 : 1;
|
|
91
116
|
}
|
|
117
|
+
const forbidden = forbidOutcome(event, forbiddenSet(config, io.env));
|
|
118
|
+
if (forbidden?.code === 2) {
|
|
119
|
+
io.err(forbidden.stderr);
|
|
120
|
+
return 2;
|
|
121
|
+
}
|
|
92
122
|
const entries = entriesFor(config, event, matchTarget(parsed));
|
|
93
|
-
if (entries.length === 0)
|
|
123
|
+
if (entries.length === 0 && forbidden === undefined)
|
|
94
124
|
return 0;
|
|
95
|
-
const outcomes = await (io.run ?? runAll)(entries, config, input, io.cwd);
|
|
96
|
-
const
|
|
125
|
+
const outcomes = entries.length === 0 ? [] : await (io.run ?? runAll)(entries, config, input, io.cwd);
|
|
126
|
+
const merged = merge(event, forbidden === undefined ? outcomes : [forbidden, ...outcomes]);
|
|
127
|
+
// A handler that blocks drops every JSON answer, the warning among them; keep it in the reason.
|
|
128
|
+
const warning = forbidden === undefined ? undefined : JSON.parse(forbidden.stdout).systemMessage;
|
|
129
|
+
const answer = merged.code === 2 && warning !== undefined ? { ...merged, stderr: `${warning}\n${merged.stderr}` } : merged;
|
|
97
130
|
if (answer.stdout !== '')
|
|
98
131
|
io.out(answer.stdout);
|
|
99
132
|
if (answer.stderr !== '')
|
package/dist/config.js
CHANGED
|
@@ -10,7 +10,8 @@ import { readFileSync } from 'node:fs';
|
|
|
10
10
|
import { isAbsolute, resolve } from 'node:path';
|
|
11
11
|
export const DEFAULT_TIMEOUT_SECONDS = 60;
|
|
12
12
|
const ENTRY_KEYS = new Set(['matcher', 'command', 'guard', 'timeout', 'failClosed']);
|
|
13
|
-
const TOP_KEYS = new Set(['$schema', 'kavach', 'hooks']);
|
|
13
|
+
const TOP_KEYS = new Set(['$schema', 'kavach', 'hooks', 'forbidEnv']);
|
|
14
|
+
const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
14
15
|
const KAVACH_KEYS = new Set(['config', 'python']);
|
|
15
16
|
const PLAIN = /^[\w|-]+$/;
|
|
16
17
|
export class ConfigError extends Error {
|
|
@@ -103,8 +104,23 @@ export function parseConfig(text, path, root) {
|
|
|
103
104
|
throw new ConfigError(`${path} ${event}: must be a list of entries`);
|
|
104
105
|
hooks[event] = list.map((entry, index) => parseEntry(entry, `${path} ${event}[${index}]`));
|
|
105
106
|
}
|
|
107
|
+
// Only an absent key means none: `null` is malformed, not empty.
|
|
108
|
+
const forbidRaw = Object.hasOwn(raw, 'forbidEnv') ? raw.forbidEnv : {};
|
|
109
|
+
if (!isRecord(forbidRaw))
|
|
110
|
+
throw new ConfigError(`${path}: "forbidEnv" must be an object of variable names`);
|
|
111
|
+
const forbidden = [];
|
|
112
|
+
for (const [name, why] of Object.entries(forbidRaw)) {
|
|
113
|
+
if (!ENV_NAME.test(name))
|
|
114
|
+
throw new ConfigError(`${path} forbidEnv: "${name}" is not an environment variable name`);
|
|
115
|
+
if (typeof why !== 'string' || why.trim() === '')
|
|
116
|
+
throw new ConfigError(`${path} forbidEnv ${name}: give why it is forbidden`);
|
|
117
|
+
forbidden.push([name, why]);
|
|
118
|
+
}
|
|
119
|
+
// fromEntries defines own properties, so a name like `__proto__` is kept, not sent to the prototype setter.
|
|
120
|
+
const forbidEnv = Object.fromEntries(forbidden);
|
|
106
121
|
const fromRoot = (value) => (isAbsolute(value) ? value : resolve(root, value));
|
|
107
122
|
return {
|
|
123
|
+
forbidEnv,
|
|
108
124
|
path,
|
|
109
125
|
root,
|
|
110
126
|
kavach: {
|
|
@@ -145,6 +161,18 @@ export function settingsMatcher(config, event) {
|
|
|
145
161
|
}
|
|
146
162
|
return [...names].sort().join('|');
|
|
147
163
|
}
|
|
164
|
+
/** Is a variable set, as a switch reads it? Absent, empty, `0` and `false` are off. */
|
|
165
|
+
export function isOn(value) {
|
|
166
|
+
return value !== undefined && !['', '0', 'false'].includes(value.trim().toLowerCase());
|
|
167
|
+
}
|
|
168
|
+
/** The forbidden variables `env` sets, each with why. */
|
|
169
|
+
export function forbiddenSet(config, env) {
|
|
170
|
+
// Only the environment's own string values: `toString` is a legal name an unset variable must not inherit.
|
|
171
|
+
return Object.entries(config.forbidEnv).filter(([name]) => {
|
|
172
|
+
const value = Object.hasOwn(env, name) ? env[name] : undefined;
|
|
173
|
+
return typeof value === 'string' && isOn(value);
|
|
174
|
+
});
|
|
175
|
+
}
|
|
148
176
|
/** The longest timeout among an event's entries, in seconds. */
|
|
149
177
|
export function longestTimeout(config, event) {
|
|
150
178
|
return Math.max(0, ...(config.hooks[event] ?? []).map(entry => entry.timeout ?? DEFAULT_TIMEOUT_SECONDS));
|
package/package.json
CHANGED