shraga 0.1.157 → 0.1.159

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.
@@ -10,6 +10,8 @@ import { rewriteSlackMentions } from './slack/mention-rewrite.ts';
10
10
  import { PROTECTED_DATA_MESSAGE, writesProtectedData } from './security/enforce.ts';
11
11
  import { getOffload, type OffloadSettings } from './shraga-config.ts';
12
12
  import { heavyCommandDenyMessage, heavyLocalCommand } from './offload.ts';
13
+ import { wholeDiskSearch, wholeDiskSearchMessage } from './disk-search-guard.ts';
14
+ import { DATA_DIR } from './paths.ts';
13
15
 
14
16
  /** Patterns that indicate a long-running script the model should background. */
15
17
  const LONG_RUNNING_PATTERNS = [
@@ -198,6 +200,24 @@ export const denyHeavyLocalMedia = (cfg: OffloadSettings): HookCallback => async
198
200
  };
199
201
  };
200
202
 
203
+ /** Refuse searches rooted at / or the bare home dir (Bash find/grep -r/mdfind, or the Grep/Glob tools' `path`). */
204
+ export const denyWholeDiskSearch: HookCallback = async (input) => {
205
+ if (input.hook_event_name !== 'PreToolUse') return {};
206
+ const { tool_name, tool_input } = input as PreToolUseHookInput;
207
+ const ti = (tool_input ?? {}) as Record<string, unknown>;
208
+ const what = tool_name === 'Bash' ? wholeDiskSearch(String(ti.command ?? ''))
209
+ : typeof ti.path === 'string' && wholeDiskSearch(`find ${ti.path}`) ? `${tool_name} ${ti.path}` : null;
210
+ if (!what) return {};
211
+ console.log(`[hooks] Denied whole-disk search: ${what}`);
212
+ return {
213
+ hookSpecificOutput: {
214
+ hookEventName: 'PreToolUse' as const,
215
+ permissionDecision: 'deny' as const,
216
+ permissionDecisionReason: wholeDiskSearchMessage(what, DATA_DIR),
217
+ },
218
+ };
219
+ };
220
+
201
221
  /** Build the hooks map to pass into SDK query() options.
202
222
  * `exemptBashCommand` is the one command the turn was explicitly told to run in the foreground
203
223
  * (a `bash` schedule's command) — see forceBackgroundForScripts. */
@@ -205,6 +225,7 @@ export function buildHooks(opts?: { exemptBashCommand?: string; offload?: Offloa
205
225
  const offload = opts && 'offload' in opts ? opts.offload : getOffload();
206
226
  return {
207
227
  PreToolUse: [
228
+ { matcher: 'Bash|Grep|Glob', hooks: [denyWholeDiskSearch] },
208
229
  ...(offload ? [{ matcher: 'Bash', hooks: [denyHeavyLocalMedia(offload)] }] : []),
209
230
  { matcher: 'Bash', hooks: [forceBackgroundForScripts(opts?.exemptBashCommand, opts?.durableJobs), ...(opts?.durableJobs ? [denyNativeBackground] : [])] },
210
231
  { matcher: 'mcp__mcp-slack-use__post_slack_.*', hooks: [resolveSlackMentions] },
@@ -182,6 +182,10 @@ function formatEventBlock(e: EventContext): string {
182
182
  return `An external event triggered this automation.\nSource: ${e.source}\nEvent data:\n\`\`\`json\n${body}\n\`\`\``;
183
183
  }
184
184
 
185
+ /** Prefix of every session id a scheduled run mints — the marker that a session belongs to the scheduler. */
186
+ export const SCHEDULED_SESSION_PREFIX = 'sched-';
187
+ export const isScheduledSession = (sessionId: string): boolean => sessionId.startsWith(SCHEDULED_SESSION_PREFIX);
188
+
185
189
  export async function runSchedule(
186
190
  schedule: Schedule,
187
191
  onEvent: (ev: object) => void,
@@ -192,7 +196,7 @@ export async function runSchedule(
192
196
  ): Promise<ScheduleRunSummary> {
193
197
  const now = Date.now();
194
198
  // Resume reuses the interrupted run's session (same convo) instead of spawning a new one.
195
- const sessionId = resume ? resume.sessionId : `sched-${schedule.id}-${now}`;
199
+ const sessionId = resume ? resume.sessionId : `${SCHEDULED_SESSION_PREFIX}${schedule.id}-${now}`;
196
200
  const abortController = new AbortController();
197
201
  registerRun(sessionId, abortController);
198
202
 
@@ -326,6 +330,7 @@ export async function runSchedule(
326
330
  abortController,
327
331
  onPermissionRequest,
328
332
  foregroundBashCommand: allowedCmd ?? undefined,
333
+ scheduled: true,
329
334
  })) {
330
335
  onEvent({ type: 'session_stream', sessionId, event: ev });
331
336
  if (ev.type === 'text_delta') {
@@ -44,6 +44,8 @@ function isFullEntry(entry: McpEntry): entry is McpFullEntry {
44
44
 
45
45
  export interface AgentSettings {
46
46
  model?: string;
47
+ /** Model for scheduled (unattended) runs whose prompt names no `[model:…]`. Unset = `model`. */
48
+ scheduleModel?: string;
47
49
  engine?: string;
48
50
  systemPrompt?: string;
49
51
  allowedTools?: string[];
@@ -320,7 +320,9 @@ export function buildSkillIndexBlock(): string {
320
320
  const tag = defaultNames.has(name) ? ' (default)' : '';
321
321
  lines.push(`- **${name}**${tag}: ${desc}${triggers}${expiry}`);
322
322
  }
323
- return `<available-skills>\nSkills available in data/skills/. Use Read to load full skill content when needed.\n${lines.join('\n')}\n</available-skills>`;
323
+ // ABSOLUTE: the agent's cwd is the app dir, not DATA_DIR, so a relative "data/skills/" (or a skill's own
324
+ // `scripts/…`) resolved nowhere and agents fell back to `find /` (3-7 min per call).
325
+ return `<available-skills>\nSkills live in ${SKILLS_DIR}/ — skill X is ${SKILLS_DIR}/X.md, and its bundled files (scripts/, reference/) are under ${SKILLS_DIR}/X/. A relative path in a skill lives under the data dir ${DATA_DIR}/: look in the skill's folder ${SKILLS_DIR}/X/, then ${DATA_DIR}/workspace/, then ${DATA_DIR}/ (skills/… is relative to ${DATA_DIR}/). If you must search, search inside ${DATA_DIR} only — never the whole disk. Use Read to load full skill content when needed.\n${lines.join('\n')}\n</available-skills>`;
324
326
  }
325
327
 
326
328
  /* ── Trigger matching ─────────────────────────────────────────────────────────