@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 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.6';
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 answer = merge(event, outcomes);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@veda-hq/sarathi",
3
- "version": "0.5.6",
3
+ "version": "0.5.7",
4
4
  "description": "Runs a project's Claude Code hooks from one entry per event, with kavach's guards built in, and carries the sarathi plugin's agents.",
5
5
  "keywords": [
6
6
  "coding agents",