discoclaw 0.8.2 → 0.8.3

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.
@@ -19,6 +19,7 @@ Core instructions live in `CLAUDE.md` at the repo root.
19
19
  | **Architecture / system overview** | `architecture.md` |
20
20
  | **Tool capabilities / browser automation** | `tools.md` |
21
21
  | **Voice system (STT/TTS, audio pipeline, actions)** | `voice.md` |
22
+ | **Automations / cron / scheduled tasks** | `automations.md` |
22
23
  | **Forge/plan standing constraints** | `project.md` *(auto-loaded by forge)* |
23
24
  | **Plan & Forge commands** | `plan-and-forge.md` *(in docs/, not .context/)* |
24
25
  | **Engineering lessons / compound learnings** | `docs/compound-lessons.md` *(single checked-in durable artifact; lives in `docs/`, not `.context/`; human/developer reference, not auto-loaded into agent context)* |
@@ -41,6 +42,7 @@ Core instructions live in `CLAUDE.md` at the repo root.
41
42
  - **bot-setup.md** — One-time bot creation and invite guide
42
43
  - **tools.md** — Available tools: browser automation (agent-browser), escalation ladder, CDP connect, security guardrails
43
44
  - **voice.md** — Voice subsystem: module map, audio data flow, key patterns (barge-in, allowlist gating), wiring sequence, dependencies, config reference
45
+ - **automations.md** — Cron & scheduled tasks: job lifecycle, trigger types, primitives (state, silent, routing, chaining), safety rails, config reference
44
46
  - **project.md** — Standing constraints auto-loaded by forge drafter and auditor
45
47
  - **docs/plan-and-forge.md** — Canonical reference for `!plan` and `!forge` commands (lives in `docs/`, not `.context/` — human/developer reference, not auto-loaded into agent context)
46
48
  - **docs/compound-lessons.md** — Single checked-in durable artifact for distilled engineering lessons from audits, forge runs, and repeated workflow failures
@@ -0,0 +1,87 @@
1
+ # automations.md — Cron & Scheduled Tasks
2
+
3
+ Quick-reference for automation behavior. For full primitive docs see
4
+ [docs/cron.md](../docs/cron.md); for worked examples and copy-pasteable recipes
5
+ see [docs/cron-patterns.md](../docs/cron-patterns.md).
6
+
7
+ ## System Overview
8
+
9
+ Cron jobs are defined as forum threads in a dedicated Discord forum channel.
10
+ The scheduler registers in-process timers (via `croner`); on each tick the
11
+ executor assembles a prompt, invokes the AI runtime, and posts output to a
12
+ target channel.
13
+
14
+ Source: `src/cron/` — scheduler, executor, parser, forum sync, run stats,
15
+ job lock, chain, tag map.
16
+
17
+ ## Job Lifecycle
18
+
19
+ | Thread state | Job state |
20
+ |--------------|-----------|
21
+ | Active | Registered and running |
22
+ | Archived | Paused (unregistered) |
23
+ | Unarchived | Resumed (re-registered) |
24
+ | Deleted | Removed; stats cleaned on next startup |
25
+
26
+ Jobs must be created through the bot (`cronCreate` action), not by manually
27
+ creating forum threads.
28
+
29
+ ## Trigger Types
30
+
31
+ - **`schedule`** — standard 5-field cron expression
32
+ - **`webhook`** — external HTTP POST (requires `DISCOCLAW_WEBHOOK_ENABLED=true`)
33
+ - **`manual`** — explicit `cronTrigger` action only
34
+
35
+ ## Key Primitives
36
+
37
+ | Primitive | Purpose |
38
+ |-----------|---------|
39
+ | `{{state}}` / `<cron-state>` | Persistent per-job key-value state across runs |
40
+ | `silent` mode | Suppress posting when output is a sentinel (`HEARTBEAT_OK` or `[]`) |
41
+ | `routingMode: "json"` | Multi-channel dispatch via JSON array of `{channel, content}` |
42
+ | `allowedActions` | Restrict which Discord action types a job may emit |
43
+ | `chain` | Fire downstream jobs on success, forwarding state via `__upstream` |
44
+ | `model` | Per-job model tier override (`fast` / `capable` / `deep`) |
45
+
46
+ ## Safety Rails
47
+
48
+ - Cron jobs **cannot emit cron actions** — hard-coded, not configurable.
49
+ - Overlap guard: one execution per job at a time; concurrent ticks are skipped.
50
+ - Chain depth limit: **10** (prevents runaway cascades).
51
+ - Cycle detection at write time (BFS reachability check).
52
+ - `allowedActions` narrows only — cannot grant permissions the global config denies.
53
+
54
+ ## State Essentials
55
+
56
+ - `<cron-state>` **replaces** the full state object (not a merge).
57
+ - `{{state}}` expands to `{}` on first run or after a reset.
58
+ - Reset state: `cronUpdate` with `state: "{}"`.
59
+ - The executor injects a "Persistent State" section capped at 4 000 chars.
60
+
61
+ ## Configuration
62
+
63
+ | Variable | Default | Purpose |
64
+ |----------|---------|---------|
65
+ | `DISCOCLAW_CRON_ENABLED` | `true` | Enable the cron subsystem |
66
+ | `DISCOCLAW_CRON_FORUM` | — | Forum channel ID (auto-created if unset) |
67
+ | `DISCOCLAW_CRON_EXEC_MODEL` | `capable` | Default model tier for execution |
68
+ | `DISCOCLAW_CRON_MODEL` | `fast` | Model tier for definition parsing |
69
+ | `DISCOCLAW_CRON_AUTO_TAG` | `true` | Auto-tag cron forum threads |
70
+ | `DISCOCLAW_CRON_STATS_DIR` | — | Override stats storage directory |
71
+ | `DISCOCLAW_CRON_TAG_MAP` | — | Override tag map file path |
72
+ | `DISCOCLAW_WEBHOOK_ENABLED` | `false` | Enable the webhook server |
73
+ | `DISCOCLAW_WEBHOOK_CONFIG` | — | Path to webhook config JSON |
74
+
75
+ ## Common Patterns (cheat sheet)
76
+
77
+ | Pattern | Key technique |
78
+ |---------|---------------|
79
+ | Stateful polling | `{{state}}` cursor + `<cron-state>` update |
80
+ | Silent monitoring | `silent: true` + `HEARTBEAT_OK` sentinel |
81
+ | Multi-channel fan-out | `routingMode: "json"` |
82
+ | Chained pipelines | `chain` field + `__upstream.state` handoff |
83
+ | Accumulation / rollup | State counter resets on cadence boundary |
84
+ | Webhook-triggered | Config file + HMAC-SHA256 verification |
85
+ | Gated actions | `allowedActions` for least-privilege |
86
+
87
+ See [docs/cron-patterns.md](../docs/cron-patterns.md) for full examples of each.
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Guards against prototype-pollution via dynamic property names.
3
+ *
4
+ * Any code path that uses an external string (route param, JSON key, etc.)
5
+ * as a plain-object property name should reject these values first.
6
+ */
7
+ const PROTO_POLLUTION_KEYS = new Set([
8
+ '__proto__',
9
+ 'constructor',
10
+ 'prototype',
11
+ ]);
12
+ /** Returns `true` when `key` is a prototype-pollution property name. */
13
+ export function isUnsafeKey(key) {
14
+ return PROTO_POLLUTION_KEYS.has(key);
15
+ }
16
+ /**
17
+ * Look up `key` on `obj` only when the key is safe and is an own property.
18
+ * Returns `undefined` for unsafe keys or missing properties — never walks
19
+ * the prototype chain.
20
+ */
21
+ export function safeGet(obj, key) {
22
+ if (isUnsafeKey(key))
23
+ return undefined;
24
+ return Object.hasOwn(obj, key) ? obj[key] : undefined;
25
+ }
26
+ /**
27
+ * Return a shallow copy of `obj` with any prototype-pollution keys removed.
28
+ * Useful for sanitising parsed JSON before it becomes a lookup table.
29
+ */
30
+ export function stripUnsafeKeys(obj) {
31
+ const clean = Object.create(null);
32
+ for (const key of Object.keys(obj)) {
33
+ if (!isUnsafeKey(key)) {
34
+ clean[key] = obj[key];
35
+ }
36
+ }
37
+ return clean;
38
+ }
@@ -0,0 +1,60 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { isUnsafeKey, safeGet, stripUnsafeKeys } from './safe-key.js';
3
+ describe('isUnsafeKey', () => {
4
+ it.each(['__proto__', 'constructor', 'prototype'])('returns true for %s', (key) => {
5
+ expect(isUnsafeKey(key)).toBe(true);
6
+ });
7
+ it.each(['github', 'alerts', '', 'proto', '__proto', 'CONSTRUCTOR'])('returns false for %s', (key) => {
8
+ expect(isUnsafeKey(key)).toBe(false);
9
+ });
10
+ });
11
+ describe('safeGet', () => {
12
+ const obj = { a: 1, b: 2 };
13
+ it('returns the value for a safe, present key', () => {
14
+ expect(safeGet(obj, 'a')).toBe(1);
15
+ });
16
+ it('returns undefined for a safe but missing key', () => {
17
+ expect(safeGet(obj, 'z')).toBeUndefined();
18
+ });
19
+ it('returns undefined for __proto__ even when set as own property', () => {
20
+ const withProto = Object.create(null);
21
+ withProto['__proto__'] = 42;
22
+ expect(safeGet(withProto, '__proto__')).toBeUndefined();
23
+ });
24
+ it('returns undefined for constructor', () => {
25
+ expect(safeGet(obj, 'constructor')).toBeUndefined();
26
+ });
27
+ it('returns undefined for prototype', () => {
28
+ expect(safeGet(obj, 'prototype')).toBeUndefined();
29
+ });
30
+ it('does not walk the prototype chain', () => {
31
+ const parent = { inherited: 99 };
32
+ const child = Object.create(parent);
33
+ expect(safeGet(child, 'inherited')).toBeUndefined();
34
+ });
35
+ });
36
+ describe('stripUnsafeKeys', () => {
37
+ it('removes __proto__, constructor, and prototype keys', () => {
38
+ const input = {
39
+ github: 'ok',
40
+ __proto__: 'bad',
41
+ constructor: 'bad',
42
+ prototype: 'bad',
43
+ alerts: 'ok',
44
+ };
45
+ // JSON.parse puts __proto__ as own property; replicate with Object.defineProperty
46
+ Object.defineProperty(input, '__proto__', { value: 'bad', enumerable: true, configurable: true });
47
+ const result = stripUnsafeKeys(input);
48
+ expect(Object.keys(result)).toEqual(['github', 'alerts']);
49
+ expect(result['github']).toBe('ok');
50
+ expect(result['alerts']).toBe('ok');
51
+ });
52
+ it('returns a null-prototype object', () => {
53
+ const result = stripUnsafeKeys({ a: 1 });
54
+ expect(Object.getPrototypeOf(result)).toBeNull();
55
+ });
56
+ it('handles an empty object', () => {
57
+ const result = stripUnsafeKeys({});
58
+ expect(Object.keys(result)).toEqual([]);
59
+ });
60
+ });
package/dist/config.js CHANGED
@@ -1,4 +1,4 @@
1
- import { parseAllowBotIds, parseAllowChannelIds, parseAllowUserIds } from './discord/allowlist.js';
1
+ import { isAllowlisted, parseAllowBotIds, parseAllowChannelIds, parseAllowUserIds } from './discord/allowlist.js';
2
2
  import { parseDashboardTrustedHosts } from './dashboard/options.js';
3
3
  export const KNOWN_TOOLS = new Set([
4
4
  'Bash', 'Read', 'Write', 'Edit', 'Glob', 'Grep', 'WebSearch', 'WebFetch', 'Pipeline', 'Step',
@@ -9,6 +9,14 @@ export const DEFAULT_DISCORD_ACTIONS_DEFER_MAX_DEPTH = 4;
9
9
  export const DEFAULT_DISCORD_ACTIONS_LOOP_MIN_INTERVAL_SECONDS = 60;
10
10
  export const DEFAULT_DISCORD_ACTIONS_LOOP_MAX_INTERVAL_SECONDS = 86400;
11
11
  export const DEFAULT_DISCORD_ACTIONS_LOOP_MAX_CONCURRENT = 5;
12
+ /**
13
+ * Check whether a Discord user is authorized for config-mutating actions.
14
+ * Uses the DISCORD_ALLOW_USER_IDS allowlist as the sole authorization source.
15
+ * Fails closed: returns false when the allowlist is empty.
16
+ */
17
+ export function isAuthorizedUser(config, userId) {
18
+ return isAllowlisted(config.allowUserIds, userId);
19
+ }
12
20
  function parseBoolean(env, name, defaultValue) {
13
21
  const raw = env[name];
14
22
  if (raw == null || raw.trim() === '')
@@ -0,0 +1,20 @@
1
+ import { isConfigAuthorized } from './allowlist.js';
2
+ import { CONFIG_UNAUTHORIZED_ERROR } from './replies.js';
3
+ import { CONFIG_MUTATING_ACTION_TYPES } from './actions-config.js';
4
+ /**
5
+ * Fail-fast authorization check for config-mutating actions.
6
+ *
7
+ * Returns an error result when the requester is not authorized;
8
+ * returns null when the action is allowed (either non-config or authorized).
9
+ * Missing requester identity denies by default.
10
+ *
11
+ * The protected action set is defined in actions-config.ts
12
+ * (CONFIG_MUTATING_ACTION_TYPES) so coverage is co-located with action definitions.
13
+ */
14
+ export function checkConfigAuthorization(actionType, requesterId, allowUserIds) {
15
+ if (!CONFIG_MUTATING_ACTION_TYPES.has(actionType))
16
+ return null;
17
+ if (isConfigAuthorized(allowUserIds, requesterId))
18
+ return null;
19
+ return { ok: false, error: CONFIG_UNAUTHORIZED_ERROR };
20
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Strip action categories that require a trusted requester context.
3
+ *
4
+ * Used by cron executor and other automated paths where the requester
5
+ * may not be an allowlisted user. Config is included because config
6
+ * mutations (modelSet, modelReset) must only run for allowlisted users.
7
+ */
8
+ export function withoutRequesterGatedActionFlags(flags) {
9
+ return {
10
+ ...flags,
11
+ channels: false,
12
+ messaging: false,
13
+ guild: false,
14
+ moderation: false,
15
+ polls: false,
16
+ config: false,
17
+ };
18
+ }
@@ -8,6 +8,19 @@ const CONFIG_TYPE_MAP = {
8
8
  workspaceWarnings: true,
9
9
  };
10
10
  export const CONFIG_ACTION_TYPES = new Set(Object.keys(CONFIG_TYPE_MAP));
11
+ /**
12
+ * Config action types that mutate bot state and require requester authorization
13
+ * against DISCORD_ALLOW_USER_IDS. Read-only queries (modelShow, workspaceWarnings)
14
+ * are intentionally excluded.
15
+ *
16
+ * All role-scoped mutations — chat, voice, imagegen, fast, cron, forge-drafter,
17
+ * forge-auditor, plan-run, summary, cron-exec — flow through modelSet/modelReset,
18
+ * so this set covers every config-mutation path.
19
+ */
20
+ export const CONFIG_MUTATING_ACTION_TYPES = new Set([
21
+ 'modelSet',
22
+ 'modelReset',
23
+ ]);
11
24
  // ---------------------------------------------------------------------------
12
25
  // Role → field mapping
13
26
  // ---------------------------------------------------------------------------
@@ -2,6 +2,7 @@ import { AttachmentBuilder } from 'discord.js';
2
2
  import { resolveChannel, findChannelRaw, describeChannelType } from './action-utils.js';
3
3
  import { NO_MENTIONS } from './allowed-mentions.js';
4
4
  import { downloadMessageImages, downloadImageUrl } from './image-download.js';
5
+ import { validateGeminiModelId } from '../gemini-model-validation.js';
5
6
  const IMAGEGEN_TYPE_MAP = {
6
7
  generateImage: true,
7
8
  };
@@ -16,6 +17,8 @@ const GPT_IMAGE_VALID_SIZES = new Set(['1024x1024', '1024x1792', '1792x1024', 'a
16
17
  const GEMINI_VALID_SIZES = new Set(['1:1', '3:4', '4:3', '9:16', '16:9']);
17
18
  const VALID_QUALITY = new Set(['standard', 'hd']);
18
19
  const DISCORD_MAX_CONTENT = 2000;
20
+ // Re-export so existing test imports continue to work.
21
+ export { validateGeminiModelId } from '../gemini-model-validation.js';
19
22
  // Progress UX
20
23
  export const TYPING_INTERVAL_MS = 8_000;
21
24
  export const DOT_CYCLE_INTERVAL_MS = 3_000;
@@ -95,6 +98,9 @@ async function callOpenAI(prompt, model, size, quality, apiKey, baseUrl, signal)
95
98
  return { ok: true, b64: imageItem.b64_json };
96
99
  }
97
100
  async function callGemini(prompt, model, size, geminiApiKey, signal) {
101
+ const v = validateGeminiModelId(model);
102
+ if (!v.ok)
103
+ return { ok: false, error: v.error };
98
104
  const url = `https://generativelanguage.googleapis.com/v1beta/models/${model}:predict`;
99
105
  const body = {
100
106
  instances: [{ prompt }],
@@ -144,6 +150,9 @@ async function callGemini(prompt, model, size, geminiApiKey, signal) {
144
150
  return { ok: true, b64 };
145
151
  }
146
152
  async function callGeminiNative(prompt, model, geminiApiKey, sourceImage, signal) {
153
+ const v = validateGeminiModelId(model);
154
+ if (!v.ok)
155
+ return { ok: false, error: v.error };
147
156
  const url = `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent`;
148
157
  const parts = [];
149
158
  if (sourceImage) {
@@ -251,6 +260,12 @@ export async function executeImagegenAction(action, ctx, imagegenCtx) {
251
260
  const provider = resolveProvider(model, action.provider);
252
261
  const defaultSize = provider === 'gemini' ? DEFAULT_SIZE_GEMINI : DEFAULT_SIZE_OPENAI;
253
262
  const size = action.size ?? defaultSize;
263
+ // Validate Gemini model ID before it can reach any URL interpolation
264
+ if (provider === 'gemini') {
265
+ const mv = validateGeminiModelId(model);
266
+ if (!mv.ok)
267
+ return { ok: false, error: mv.error };
268
+ }
254
269
  // Per-provider size validation
255
270
  if (provider === 'gemini') {
256
271
  if (!model.startsWith('gemini-') && !GEMINI_VALID_SIZES.has(size)) {
@@ -1,6 +1,6 @@
1
1
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
2
  import { ChannelType } from 'discord.js';
3
- import { executeImagegenAction, IMAGEGEN_ACTION_TYPES, imagegenActionsPromptSection, resolveDefaultModel, resolveProvider, TYPING_INTERVAL_MS, DOT_CYCLE_INTERVAL_MS, REQUEST_TIMEOUT_MS, } from './actions-imagegen.js';
3
+ import { executeImagegenAction, IMAGEGEN_ACTION_TYPES, imagegenActionsPromptSection, resolveDefaultModel, resolveProvider, validateGeminiModelId, TYPING_INTERVAL_MS, DOT_CYCLE_INTERVAL_MS, REQUEST_TIMEOUT_MS, } from './actions-imagegen.js';
4
4
  import { buildTieredDiscordActionsPromptSection, parseDiscordActions } from './actions.js';
5
5
  import { buildUnavailableActionTypesNotice } from './output-common.js';
6
6
  vi.mock('./image-download.js', async (importOriginal) => {
@@ -769,6 +769,118 @@ describe('generateImage — Gemini Native', () => {
769
769
  expect(fetch).not.toHaveBeenCalled();
770
770
  });
771
771
  });
772
+ // ---------------------------------------------------------------------------
773
+ // Gemini model ID validation
774
+ // ---------------------------------------------------------------------------
775
+ describe('validateGeminiModelId', () => {
776
+ it.each([
777
+ 'imagen-4.0-generate-001',
778
+ 'imagen-4.0-fast-generate-001',
779
+ 'imagen-4.0-ultra-generate-001',
780
+ 'gemini-3.1-flash-image-preview',
781
+ 'gemini-3-pro-image-preview',
782
+ 'dall-e-3',
783
+ 'gpt-image-1',
784
+ 'my_custom_model.v2',
785
+ ])('accepts valid model ID: %s', (model) => {
786
+ expect(validateGeminiModelId(model)).toEqual({ ok: true });
787
+ });
788
+ it.each([
789
+ ['../other-model', 'path traversal with ../'],
790
+ ['model/../secret', 'embedded traversal'],
791
+ ['model/subpath', 'slash in model'],
792
+ ['model:extra', 'colon in model'],
793
+ ['model%2F..', 'percent-encoded slash'],
794
+ ['', 'empty string'],
795
+ [' model', 'leading space'],
796
+ ['model name', 'space in model'],
797
+ ['model\ttab', 'tab in model'],
798
+ ['model\nline', 'newline in model'],
799
+ ['.hidden', 'leading dot'],
800
+ ['-start', 'leading hyphen'],
801
+ ['_start', 'leading underscore'],
802
+ ['a'.repeat(129), 'exceeds max length'],
803
+ ])('rejects invalid model ID: %s (%s)', (model) => {
804
+ const result = validateGeminiModelId(model);
805
+ expect(result.ok).toBe(false);
806
+ if (!result.ok)
807
+ expect(result.error).toContain('invalid Gemini model identifier');
808
+ });
809
+ });
810
+ describe('generateImage — Gemini model validation', () => {
811
+ beforeEach(() => {
812
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue(makeGeminiSuccessResponse()));
813
+ });
814
+ afterEach(() => {
815
+ vi.unstubAllGlobals();
816
+ });
817
+ it('rejects traversal payload before any network call (:predict branch)', async () => {
818
+ const ch = makeMockChannel({ name: 'art' });
819
+ const ctx = makeCtx([ch]);
820
+ const result = await executeImagegenAction({ type: 'generateImage', prompt: 'A mountain', channel: '#art', model: '../evil-model', provider: 'gemini', size: '1:1' }, ctx, makeImagegenCtx({ geminiApiKey: 'gemini-key' }));
821
+ expect(result.ok).toBe(false);
822
+ if (!result.ok)
823
+ expect(result.error).toContain('invalid Gemini model identifier');
824
+ expect(fetch).not.toHaveBeenCalled();
825
+ });
826
+ it('rejects traversal payload before any network call (:generateContent branch)', async () => {
827
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue(makeGeminiNativeSuccessResponse()));
828
+ const ch = makeMockChannel({ name: 'art' });
829
+ const ctx = makeCtx([ch]);
830
+ const result = await executeImagegenAction({ type: 'generateImage', prompt: 'A mountain', channel: '#art', model: 'gemini-../../evil', provider: 'gemini' }, ctx, makeImagegenCtx({ geminiApiKey: 'gemini-key' }));
831
+ expect(result.ok).toBe(false);
832
+ if (!result.ok)
833
+ expect(result.error).toContain('invalid Gemini model identifier');
834
+ expect(fetch).not.toHaveBeenCalled();
835
+ });
836
+ it.each([
837
+ 'model/with/slashes',
838
+ 'model:with:colons',
839
+ 'model%2Fencoded',
840
+ '../traversal',
841
+ 'model\nnewline',
842
+ ])('rejects reserved/special character model "%s" without network call', async (badModel) => {
843
+ const ch = makeMockChannel({ name: 'art' });
844
+ const ctx = makeCtx([ch]);
845
+ const result = await executeImagegenAction({ type: 'generateImage', prompt: 'A mountain', channel: '#art', model: badModel, provider: 'gemini', size: '1:1' }, ctx, makeImagegenCtx({ geminiApiKey: 'gemini-key' }));
846
+ expect(result.ok).toBe(false);
847
+ if (!result.ok)
848
+ expect(result.error).toContain('invalid Gemini model identifier');
849
+ expect(fetch).not.toHaveBeenCalled();
850
+ });
851
+ it('accepts valid imagen model and reaches network layer', async () => {
852
+ const ch = makeMockChannel({ name: 'art' });
853
+ const ctx = makeCtx([ch]);
854
+ const result = await executeImagegenAction({ type: 'generateImage', prompt: 'A mountain', channel: '#art', model: 'imagen-4.0-generate-001', size: '1:1' }, ctx, makeImagegenCtx({ geminiApiKey: 'gemini-key' }));
855
+ expect(result.ok).toBe(true);
856
+ expect(fetch).toHaveBeenCalled();
857
+ });
858
+ it('accepts valid gemini native model and reaches network layer', async () => {
859
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue(makeGeminiNativeSuccessResponse()));
860
+ const ch = makeMockChannel({ name: 'art' });
861
+ const ctx = makeCtx([ch]);
862
+ const result = await executeImagegenAction({ type: 'generateImage', prompt: 'A mountain', channel: '#art', model: 'gemini-3.1-flash-image-preview' }, ctx, makeImagegenCtx({ geminiApiKey: 'gemini-key' }));
863
+ expect(result.ok).toBe(true);
864
+ expect(fetch).toHaveBeenCalled();
865
+ });
866
+ it('does not validate model for OpenAI provider (no Gemini URL involved)', async () => {
867
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue(makeSuccessResponse()));
868
+ const ch = makeMockChannel({ name: 'art' });
869
+ const ctx = makeCtx([ch]);
870
+ // A model with slashes would be invalid for Gemini, but should pass for OpenAI
871
+ const result = await executeImagegenAction({ type: 'generateImage', prompt: 'A mountain', channel: '#art', model: 'dall-e-3' }, ctx, makeImagegenCtx({ apiKey: 'openai-key' }));
872
+ expect(result.ok).toBe(true);
873
+ expect(fetch).toHaveBeenCalled();
874
+ });
875
+ it('preserves fallback behavior when model is omitted (Gemini default)', async () => {
876
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue(makeGeminiNativeSuccessResponse()));
877
+ const ch = makeMockChannel({ id: 'origin-ch', name: 'general' });
878
+ const ctx = makeCtx([ch]);
879
+ const result = await executeImagegenAction({ type: 'generateImage', prompt: 'A mountain' }, ctx, makeImagegenCtx({ geminiApiKey: 'gemini-key' }));
880
+ expect(result.ok).toBe(true);
881
+ expect(fetch).toHaveBeenCalledWith(expect.stringContaining('gemini-3.1-flash-image-preview:generateContent'), expect.anything());
882
+ });
883
+ });
772
884
  describe('default model resolution', () => {
773
885
  afterEach(() => {
774
886
  vi.unstubAllGlobals();
@@ -17,20 +17,12 @@ import { IMAGEGEN_ACTION_TYPES, executeImagegenAction, imagegenActionsPromptSect
17
17
  import { VOICE_ACTION_TYPES, executeVoiceAction, voiceActionsPromptSection } from './actions-voice.js';
18
18
  import { SPAWN_ACTION_TYPES, executeSpawnActions, spawnActionsPromptSection } from './actions-spawn.js';
19
19
  import { describeDestructiveConfirmationRequirement } from './destructive-confirmation.js';
20
+ import { checkConfigAuthorization } from './action-dispatcher.js';
20
21
  import { computeMarkdownCodeRanges } from './markdown-code-ranges.js';
21
22
  import { parseCapsuleBlock } from './capsule.js';
22
23
  export { computeMarkdownCodeRanges } from './markdown-code-ranges.js';
24
+ export { withoutRequesterGatedActionFlags } from './action-flags.js';
23
25
  export const REQUESTER_MEMBER_DENY_ALL = { __requesterDenyAll: true };
24
- export function withoutRequesterGatedActionFlags(flags) {
25
- return {
26
- ...flags,
27
- channels: false,
28
- messaging: false,
29
- guild: false,
30
- moderation: false,
31
- polls: false,
32
- };
33
- }
34
26
  import { appendOutsideFence } from './output-utils.js';
35
27
  function buildImagegenSetupRequiredStub() {
36
28
  return [
@@ -372,6 +364,12 @@ export async function executeDiscordActions(actions, ctx, log, subs) {
372
364
  results.push(result);
373
365
  continue;
374
366
  }
367
+ // Fail-fast: deny config-mutating actions from unauthorized requesters.
368
+ const configAuthResult = checkConfigAuthorization(action.type, ctx.requesterId, ctx.allowUserIds);
369
+ if (configAuthResult) {
370
+ results.push(configAuthResult);
371
+ continue;
372
+ }
375
373
  if (CHANNEL_ACTION_TYPES.has(action.type)) {
376
374
  result = await executeChannelAction(action, ctx, requesterMember);
377
375
  }
@@ -514,6 +512,23 @@ export function buildDisplayResultLines(actions, results) {
514
512
  export function buildAllResultLines(results) {
515
513
  return results.map((r) => r.ok ? `Done: ${r.summary}` : `Failed: ${r.error}`);
516
514
  }
515
+ /**
516
+ * Cap a single result line to approximately `maxChars` characters.
517
+ * If truncated, appends a visible `...[truncated]` suffix.
518
+ */
519
+ export function capResultLine(line, maxChars = 1500) {
520
+ if (line.length <= maxChars)
521
+ return line;
522
+ return `${line.slice(0, maxChars)}...[truncated]`;
523
+ }
524
+ /**
525
+ * Build result lines for follow-up prompts with per-line length capping.
526
+ * Each line is capped at `maxChars` characters to prevent oversized payloads
527
+ * from crowding out reasoning and action blocks in follow-up prompts.
528
+ */
529
+ export function buildCappedResultLines(results, maxChars = 1500) {
530
+ return buildAllResultLines(results).map((line) => capResultLine(line, maxChars));
531
+ }
517
532
  /**
518
533
  * Append display result lines to body text, automatically closing any
519
534
  * unclosed fenced code block so the results render outside the block.
@@ -356,7 +356,7 @@ describe('withoutRequesterGatedActionFlags', () => {
356
356
  forge: true,
357
357
  plan: true,
358
358
  memory: true,
359
- config: true,
359
+ config: false,
360
360
  defer: true,
361
361
  loop: true,
362
362
  imagegen: true,
@@ -44,3 +44,14 @@ export function isTrustedBot(allow, botId) {
44
44
  return false;
45
45
  return allow.has(botId);
46
46
  }
47
+ /**
48
+ * Check whether a requester is authorized for config-mutating actions.
49
+ * Fail closed: missing or empty allowlist, or missing requesterId, denies.
50
+ */
51
+ export function isConfigAuthorized(allowUserIds, requesterId) {
52
+ if (!requesterId)
53
+ return false;
54
+ if (!allowUserIds || allowUserIds.size === 0)
55
+ return false;
56
+ return allowUserIds.has(requesterId);
57
+ }
@@ -27,6 +27,7 @@ vi.mock('./actions.js', () => ({
27
27
  })),
28
28
  buildDisplayResultLines: vi.fn(() => ['Succeeded: general, random']),
29
29
  buildAllResultLines: vi.fn(() => ['Succeeded: general, random']),
30
+ buildCappedResultLines: vi.fn(() => ['Succeeded: general, random']),
30
31
  appendActionResults: vi.fn((body) => `${body}\n> general, random`),
31
32
  withoutRequesterGatedActionFlags: vi.fn((flags) => flags),
32
33
  }));
@@ -6,7 +6,7 @@ import { isAllowlisted, isTrustedBot } from './allowlist.js';
6
6
  import { KeyedQueue } from '../group-queue.js';
7
7
  import { ensureIndexedDiscordChannelContext, resolveDiscordChannelContext } from './channel-context.js';
8
8
  import { discordSessionKey } from './session-key.js';
9
- import { parseDiscordActions, executeDiscordActions, buildTieredDiscordActionsPromptSection, buildDisplayResultLines, buildAllResultLines, appendActionResults, withoutRequesterGatedActionFlags } from './actions.js';
9
+ import { parseDiscordActions, executeDiscordActions, buildTieredDiscordActionsPromptSection, buildDisplayResultLines, buildCappedResultLines, appendActionResults, withoutRequesterGatedActionFlags } from './actions.js';
10
10
  import { shouldTriggerFollowUp } from './action-categories.js';
11
11
  import { countPinnedMessages, normalizePinnedMessages } from './pinned-message-utils.js';
12
12
  import { buildForgeCompletionWatchdogDetail, buildForgeCrashWatchdogDetail, buildForgePostProcessingWatchdogDetail, } from './actions-forge.js';
@@ -2264,6 +2264,7 @@ export function createMessageCreateHandler(params, queue, statusRef) {
2264
2264
  guild: msg.guild,
2265
2265
  client: msg.client,
2266
2266
  requesterId: msg.author.id,
2267
+ allowUserIds: params.allowUserIds,
2267
2268
  channelId: msg.channelId,
2268
2269
  messageId: msg.id,
2269
2270
  threadParentId,
@@ -2751,6 +2752,8 @@ export function createMessageCreateHandler(params, queue, statusRef) {
2751
2752
  useNativeTextFallback: runtimeSupportsNativeThinkingStream(params.runtime.id),
2752
2753
  });
2753
2754
  hadTextFinal = false;
2755
+ let responseTruncated = false;
2756
+ let responseFinishReason;
2754
2757
  let currentFollowUpToken = null;
2755
2758
  let currentFollowUpRunId = null;
2756
2759
  // On follow-up iterations, send a new placeholder message.
@@ -3175,6 +3178,10 @@ export function createMessageCreateHandler(params, queue, statusRef) {
3175
3178
  else if (evt.type === 'image_data') {
3176
3179
  collectedImages.push(evt.image);
3177
3180
  }
3181
+ else if (evt.type === 'finish_metadata') {
3182
+ responseTruncated = evt.truncated;
3183
+ responseFinishReason = evt.finishReason;
3184
+ }
3178
3185
  }
3179
3186
  else {
3180
3187
  // Flat mode: stream text/final directly and append runtime signals.
@@ -3212,6 +3219,10 @@ export function createMessageCreateHandler(params, queue, statusRef) {
3212
3219
  else if (evt.type === 'image_data') {
3213
3220
  collectedImages.push(evt.image);
3214
3221
  }
3222
+ else if (evt.type === 'finish_metadata') {
3223
+ responseTruncated = evt.truncated;
3224
+ responseFinishReason = evt.finishReason;
3225
+ }
3215
3226
  }
3216
3227
  }
3217
3228
  }
@@ -3289,6 +3300,7 @@ export function createMessageCreateHandler(params, queue, statusRef) {
3289
3300
  guild: msg.guild,
3290
3301
  client: msg.client,
3291
3302
  requesterId: !isBotMessage ? msg.author.id : undefined,
3303
+ allowUserIds: params.allowUserIds,
3292
3304
  channelId: msg.channelId,
3293
3305
  messageId: msg.id,
3294
3306
  threadParentId,
@@ -3441,14 +3453,31 @@ export function createMessageCreateHandler(params, queue, statusRef) {
3441
3453
  if (shouldQueueFollowUp) {
3442
3454
  const token = buildFollowUpToken();
3443
3455
  const failureRetryPlaceholder = buildFailureRetryPlaceholder(actions, actionResults);
3444
- const followUpLines = buildAllResultLines(actionResults);
3456
+ const followUpLines = buildCappedResultLines(actionResults);
3445
3457
  const followUpSuffix = failureRetryPlaceholder
3446
3458
  ? `One or more actions failed. If you retry, explicitly tell the user what failed and whether the retry succeeded or failed. Do not announce success before the action confirms it.`
3447
3459
  : `Continue your analysis based on these results. If you need additional information, you may emit further query actions.`;
3448
- currentPrompt =
3449
- `[Auto-follow-up] Your previous response included Discord actions. Here are the results:\n\n` +
3450
- followUpLines.join('\n') +
3451
- `\n\n${followUpSuffix}`;
3460
+ // Build the follow-up prompt with original request context and truncation awareness.
3461
+ const followUpParts = [];
3462
+ // Original request summary so a reset session knows what task it is continuing.
3463
+ const originalRequest = userText.trim();
3464
+ if (originalRequest) {
3465
+ const cappedRequest = originalRequest.length > 300
3466
+ ? `${originalRequest.slice(0, 300)}...[truncated]`
3467
+ : originalRequest;
3468
+ followUpParts.push(`[Original request] ${cappedRequest}`);
3469
+ }
3470
+ // Truncation notice when the previous response was cut off by output limits.
3471
+ if (responseTruncated) {
3472
+ const reasonDetail = responseFinishReason ? ` (finishReason: ${responseFinishReason})` : '';
3473
+ followUpParts.push(`[Truncation notice] Your previous response was cut off by output token limits${reasonDetail}. ` +
3474
+ `Your earlier output may have ended before final reasoning or action blocks completed. ` +
3475
+ `Review the action results below and continue from where you left off.`);
3476
+ }
3477
+ followUpParts.push(`[Auto-follow-up] Your previous response included Discord actions. Here are the results:\n\n` +
3478
+ followUpLines.join('\n') +
3479
+ `\n\n${followUpSuffix}`);
3480
+ currentPrompt = followUpParts.join('\n\n');
3452
3481
  const followUpActionSection = buildTieredDiscordActionsPromptSection(actionFlags, params.botDisplayName);
3453
3482
  currentPrompt += '\n\n---\n' + followUpActionSection.prompt;
3454
3483
  const pendingLine = buildFollowUpLifecycleLine(token, 'pending');
@@ -1736,6 +1736,54 @@ function hashFileContent(filePath) {
1736
1736
  return '';
1737
1737
  }
1738
1738
  }
1739
+ /** Returns the current git branch name, or null if detached/unavailable. */
1740
+ export function gitCurrentBranch(cwd) {
1741
+ try {
1742
+ return execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], {
1743
+ cwd, env: localGitEnv(), encoding: 'utf-8', stdio: 'pipe',
1744
+ }).trim() || null;
1745
+ }
1746
+ catch {
1747
+ return null;
1748
+ }
1749
+ }
1750
+ /**
1751
+ * Validates that a staged commit won't catastrophically delete most of the repo.
1752
+ * Call after `git add` but before `git commit`.
1753
+ *
1754
+ * Returns null if safe, or an error message string if the commit should be rejected.
1755
+ */
1756
+ export function validateCommitSafety(cwd, preBranch) {
1757
+ const env = localGitEnv();
1758
+ // Check 1: Branch drift — did the branch change during phase execution?
1759
+ if (preBranch) {
1760
+ const currentBranch = gitCurrentBranch(cwd);
1761
+ if (currentBranch && currentBranch !== preBranch) {
1762
+ return `Branch changed during phase execution: expected "${preBranch}", got "${currentBranch}". ` +
1763
+ 'This may indicate git init or git checkout --orphan was run.';
1764
+ }
1765
+ }
1766
+ // Check 2: Deletion ratio — would this commit delete >50% of tracked files?
1767
+ try {
1768
+ const deletedRaw = execFileSync('git', ['diff', '--cached', '--diff-filter=D', '--name-only'], { cwd, env, encoding: 'utf-8', stdio: 'pipe' }).trim();
1769
+ const deletedCount = deletedRaw ? deletedRaw.split('\n').length : 0;
1770
+ if (deletedCount > 0) {
1771
+ // git ls-files returns the post-staging index (deletions already removed),
1772
+ // so add back deletedCount to get the original total.
1773
+ const remainingRaw = execFileSync('git', ['ls-files'], { cwd, env, encoding: 'utf-8', stdio: 'pipe' }).trim();
1774
+ const remainingCount = remainingRaw ? remainingRaw.split('\n').length : 0;
1775
+ const originalTotal = remainingCount + deletedCount;
1776
+ if (originalTotal > 0 && deletedCount / originalTotal > 0.5) {
1777
+ return `Commit would delete ${deletedCount} of ${originalTotal} tracked files (${Math.round(deletedCount / originalTotal * 100)}%). ` +
1778
+ 'This exceeds the 50% safety threshold.';
1779
+ }
1780
+ }
1781
+ }
1782
+ catch {
1783
+ // git command failed — skip this check rather than blocking
1784
+ }
1785
+ return null;
1786
+ }
1739
1787
  // ---------------------------------------------------------------------------
1740
1788
  // High-level runner
1741
1789
  // ---------------------------------------------------------------------------
@@ -1816,6 +1864,7 @@ export async function runNextPhase(phasesFilePath, planFilePath, opts, onProgres
1816
1864
  writePhasesFile(phasesFilePath, allPhases);
1817
1865
  // 6. Git snapshot (null = git command failed, skip modified-files tracking)
1818
1866
  const preSnapshot = isGitAvailable ? gitDiffNames(opts.projectCwd) : null;
1867
+ const preBranch = isGitAvailable ? gitCurrentBranch(opts.projectCwd) : null;
1819
1868
  // 7. Auto-revert on retry
1820
1869
  if (phase.status === 'failed' && phase.modifiedFiles && phase.failureHashes && isGitAvailable && preSnapshot) {
1821
1870
  // Note: we are re-reading phase from the old allPhases data (before status update).
@@ -2111,6 +2160,17 @@ export async function runNextPhase(phasesFilePath, planFilePath, opts, onProgres
2111
2160
  try {
2112
2161
  const env = localGitEnv();
2113
2162
  execFileSync('git', ['add', ...modifiedFiles], { cwd: opts.projectCwd, env, stdio: 'pipe' });
2163
+ // Safety gate: validate commit won't catastrophically delete files
2164
+ const safetyError = validateCommitSafety(opts.projectCwd, preBranch);
2165
+ if (safetyError) {
2166
+ execFileSync('git', ['reset'], { cwd: opts.projectCwd, env, stdio: 'pipe' });
2167
+ opts.log?.error({ phase: phase.id, safetyError }, 'plan-manager: commit blocked by safety check');
2168
+ // Mark phase as failed — a catastrophic deletion shouldn't graduate
2169
+ allPhases = updatePhaseStatus(allPhases, phase.id, 'failed', undefined, safetyError, null);
2170
+ writePhasesFile(phasesFilePath, allPhases);
2171
+ const failedPhase = allPhases.phases.find((p) => p.id === phase.id);
2172
+ return { result: 'failed', phase: failedPhase, output: '', error: safetyError };
2173
+ }
2114
2174
  const commitMsg = `${allPhases.planId} ${phase.id}: ${phase.title}`;
2115
2175
  execFileSync('git', ['commit', '-m', commitMsg], { cwd: opts.projectCwd, env, stdio: 'pipe' });
2116
2176
  // Capture commit hash
@@ -5,7 +5,7 @@ import os from 'node:os';
5
5
  import path from 'node:path';
6
6
  import { createHash } from 'node:crypto';
7
7
  import { execSync } from 'node:child_process';
8
- import { computeAuditConvergenceSignature, computePlanHash, extractFilePaths, groupFiles, extractChangeSpec, decomposePlan, serializePhases, deserializePhases, getNextPhase, selectRunnablePhase, resequenceKeepingDone, validatePhaseDependencies, updatePhaseStatus, checkStaleness, buildPhasePrompt, buildAuditFixPrompt, buildPostRunSummary, extractObjective, resolveProjectCwd, resolveContextFilePath, serializePhasesStateJson, deserializePhasesStateJson, writePhasesFile, readPhasesFile, executePhase, runNextPhase, } from './plan-manager.js';
8
+ import { computeAuditConvergenceSignature, computePlanHash, extractFilePaths, groupFiles, extractChangeSpec, decomposePlan, serializePhases, deserializePhases, getNextPhase, selectRunnablePhase, resequenceKeepingDone, validatePhaseDependencies, updatePhaseStatus, checkStaleness, buildPhasePrompt, buildAuditFixPrompt, buildPostRunSummary, extractObjective, resolveProjectCwd, resolveContextFilePath, serializePhasesStateJson, deserializePhasesStateJson, writePhasesFile, readPhasesFile, executePhase, runNextPhase, gitCurrentBranch, validateCommitSafety, } from './plan-manager.js';
9
9
  // ---------------------------------------------------------------------------
10
10
  // Helpers
11
11
  // ---------------------------------------------------------------------------
@@ -3406,3 +3406,89 @@ describe('buildPostRunSummary', () => {
3406
3406
  ]);
3407
3407
  });
3408
3408
  });
3409
+ // ---------------------------------------------------------------------------
3410
+ // gitCurrentBranch / validateCommitSafety
3411
+ // ---------------------------------------------------------------------------
3412
+ describe('gitCurrentBranch', () => {
3413
+ let tmpDir;
3414
+ beforeEach(async () => {
3415
+ tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'git-safety-'));
3416
+ const gitEnv = { ...process.env, GIT_DIR: undefined, GIT_WORK_TREE: undefined };
3417
+ execSync('git init', { cwd: tmpDir, env: gitEnv, stdio: 'pipe' });
3418
+ execSync('git config user.email "test@test.com"', { cwd: tmpDir, env: gitEnv, stdio: 'pipe' });
3419
+ execSync('git config user.name "Test"', { cwd: tmpDir, env: gitEnv, stdio: 'pipe' });
3420
+ fsSync.writeFileSync(path.join(tmpDir, 'README.md'), 'test');
3421
+ execSync('git add . && git commit -m "init"', { cwd: tmpDir, env: gitEnv, stdio: 'pipe' });
3422
+ });
3423
+ afterEach(async () => {
3424
+ await fs.rm(tmpDir, { recursive: true, force: true });
3425
+ });
3426
+ it('returns the current branch name', () => {
3427
+ const branch = gitCurrentBranch(tmpDir);
3428
+ // Default branch is typically "main" or "master"
3429
+ expect(typeof branch).toBe('string');
3430
+ expect(branch.length).toBeGreaterThan(0);
3431
+ });
3432
+ it('returns null for non-git directory', async () => {
3433
+ const nonGit = await fs.mkdtemp(path.join(os.tmpdir(), 'non-git-'));
3434
+ try {
3435
+ expect(gitCurrentBranch(nonGit)).toBeNull();
3436
+ }
3437
+ finally {
3438
+ await fs.rm(nonGit, { recursive: true, force: true });
3439
+ }
3440
+ });
3441
+ });
3442
+ describe('validateCommitSafety', () => {
3443
+ let tmpDir;
3444
+ const gitEnv = () => ({ ...process.env, GIT_DIR: undefined, GIT_WORK_TREE: undefined });
3445
+ beforeEach(async () => {
3446
+ tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'commit-safety-'));
3447
+ execSync('git init', { cwd: tmpDir, env: gitEnv(), stdio: 'pipe' });
3448
+ execSync('git config user.email "test@test.com"', { cwd: tmpDir, env: gitEnv(), stdio: 'pipe' });
3449
+ execSync('git config user.name "Test"', { cwd: tmpDir, env: gitEnv(), stdio: 'pipe' });
3450
+ // Create a repo with enough files to trigger ratio checks
3451
+ for (let i = 0; i < 20; i++) {
3452
+ fsSync.writeFileSync(path.join(tmpDir, `file-${i}.txt`), `content-${i}`);
3453
+ }
3454
+ execSync('git add . && git commit -m "init with 20 files"', { cwd: tmpDir, env: gitEnv(), stdio: 'pipe' });
3455
+ });
3456
+ afterEach(async () => {
3457
+ await fs.rm(tmpDir, { recursive: true, force: true });
3458
+ });
3459
+ it('returns null for safe commit (no deletions)', () => {
3460
+ // Stage a small change
3461
+ fsSync.writeFileSync(path.join(tmpDir, 'file-0.txt'), 'updated');
3462
+ execSync('git add file-0.txt', { cwd: tmpDir, env: gitEnv(), stdio: 'pipe' });
3463
+ const branch = gitCurrentBranch(tmpDir);
3464
+ expect(validateCommitSafety(tmpDir, branch)).toBeNull();
3465
+ });
3466
+ it('rejects commit that deletes >50% of files', () => {
3467
+ // Delete 15 of 20 files (75%)
3468
+ for (let i = 0; i < 15; i++) {
3469
+ fsSync.unlinkSync(path.join(tmpDir, `file-${i}.txt`));
3470
+ }
3471
+ execSync('git add -A', { cwd: tmpDir, env: gitEnv(), stdio: 'pipe' });
3472
+ const branch = gitCurrentBranch(tmpDir);
3473
+ const result = validateCommitSafety(tmpDir, branch);
3474
+ expect(result).not.toBeNull();
3475
+ expect(result).toContain('safety threshold');
3476
+ });
3477
+ it('allows commit that deletes <50% of files', () => {
3478
+ // Delete 5 of 20 files (25%)
3479
+ for (let i = 0; i < 5; i++) {
3480
+ fsSync.unlinkSync(path.join(tmpDir, `file-${i}.txt`));
3481
+ }
3482
+ execSync('git add -A', { cwd: tmpDir, env: gitEnv(), stdio: 'pipe' });
3483
+ const branch = gitCurrentBranch(tmpDir);
3484
+ expect(validateCommitSafety(tmpDir, branch)).toBeNull();
3485
+ });
3486
+ it('rejects when branch changed', () => {
3487
+ // Simulate branch drift by passing a different preBranch
3488
+ fsSync.writeFileSync(path.join(tmpDir, 'file-0.txt'), 'updated');
3489
+ execSync('git add file-0.txt', { cwd: tmpDir, env: gitEnv(), stdio: 'pipe' });
3490
+ const result = validateCommitSafety(tmpDir, 'some-other-branch');
3491
+ expect(result).not.toBeNull();
3492
+ expect(result).toContain('Branch changed');
3493
+ });
3494
+ });
@@ -850,6 +850,7 @@ function createReactionHandler(mode, params, queue, statusRef) {
850
850
  guild: msg.guild,
851
851
  client: msg.client,
852
852
  requesterId: user.id,
853
+ allowUserIds: params.allowUserIds,
853
854
  channelId: msg.channelId,
854
855
  messageId: msg.id,
855
856
  threadParentId,
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Consistent error message for unauthorized config mutation attempts.
3
+ * Distinct from validation errors so callers can distinguish auth failures.
4
+ */
5
+ export const CONFIG_UNAUTHORIZED_ERROR = 'Unauthorized: only allowlisted users can change bot configuration. This request was denied.';
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Shared Gemini model-identifier validation.
3
+ *
4
+ * Every code path that interpolates a model value into a Gemini REST URL
5
+ * (.../models/${model}:predict, :generateContent, :streamGenerateContent)
6
+ * must call {@link validateGeminiModelId} **before** constructing the URL.
7
+ *
8
+ * Only alphanumerics, hyphens, dots, and underscores are legal in a Gemini
9
+ * model path segment. Anything else (slashes, colons, percent-encoding,
10
+ * whitespace …) could alter the request URL.
11
+ */
12
+ const GEMINI_MODEL_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/;
13
+ export function validateGeminiModelId(model) {
14
+ if (GEMINI_MODEL_RE.test(model))
15
+ return { ok: true };
16
+ return {
17
+ ok: false,
18
+ error: `invalid Gemini model identifier "${model}"`,
19
+ };
20
+ }
@@ -482,6 +482,7 @@ export function createCliRuntime(strategy, opts) {
482
482
  let mergedStdout = '';
483
483
  let merged = '';
484
484
  let resultText = '';
485
+ let lastStopReason;
485
486
  let inToolUse = false;
486
487
  const stdoutLineBuf = new LineBuffer();
487
488
  let stderrBuffered = '';
@@ -556,6 +557,11 @@ export function createCliRuntime(strategy, opts) {
556
557
  if (evt) {
557
558
  parsedJsonLineCount++;
558
559
  lastJsonEventType = getJsonLineEventType(evt) ?? null;
560
+ // Capture stop_reason from result events for finish_metadata
561
+ const evtObj = evt;
562
+ if (typeof evtObj.stop_reason === 'string' && evtObj.stop_reason) {
563
+ lastStopReason = evtObj.stop_reason;
564
+ }
559
565
  }
560
566
  else {
561
567
  unparsableStdoutLineCount++;
@@ -916,6 +922,12 @@ export function createCliRuntime(strategy, opts) {
916
922
  emittedUserOutput = true;
917
923
  push({ type: 'text_final', text: final });
918
924
  }
925
+ const cliTruncated = lastStopReason === 'max_tokens' || lastStopReason === 'length';
926
+ push({
927
+ type: 'finish_metadata',
928
+ truncated: cliTruncated,
929
+ ...(lastStopReason ? { finishReason: lastStopReason } : {}),
930
+ });
919
931
  push({ type: 'done' });
920
932
  finished = true;
921
933
  wake();
@@ -243,6 +243,7 @@ describe('createCliRuntime', () => {
243
243
  }));
244
244
  expect(events).toEqual([
245
245
  { type: 'text_final', text: '' },
246
+ { type: 'finish_metadata', truncated: false },
246
247
  { type: 'done' },
247
248
  ]);
248
249
  });
@@ -261,6 +262,7 @@ describe('createCliRuntime', () => {
261
262
  }));
262
263
  expect(events).toEqual([
263
264
  { type: 'text_final', text: '' },
265
+ { type: 'finish_metadata', truncated: false },
264
266
  { type: 'done' },
265
267
  ]);
266
268
  });
@@ -1687,6 +1687,12 @@ function summarizeTraceEvent(event) {
1687
1687
  type: event.type,
1688
1688
  mediaType: event.image.mediaType,
1689
1689
  };
1690
+ case 'finish_metadata':
1691
+ return {
1692
+ type: event.type,
1693
+ truncated: event.truncated,
1694
+ ...(event.finishReason ? { finishReason: event.finishReason } : {}),
1695
+ };
1690
1696
  case 'done':
1691
1697
  return { type: event.type };
1692
1698
  }
@@ -3,6 +3,7 @@
3
3
  // Auth via GEMINI_API_KEY header. Zero startup overhead vs. the CLI adapter.
4
4
  import { splitSystemPrompt } from './openai-compat.js';
5
5
  import { createRuntimeErrorEvent } from './runtime-failure.js';
6
+ import { validateGeminiModelId } from '../gemini-model-validation.js';
6
7
  /** Extract the data payload from an SSE line, or undefined if not a data line. */
7
8
  function parseSSEData(line) {
8
9
  const trimmed = line.trim();
@@ -15,6 +16,15 @@ function parseSSEData(line) {
15
16
  return undefined;
16
17
  }
17
18
  export function createGeminiRestRuntime(opts) {
19
+ // Validate defaultModel eagerly so configuration errors surface at startup,
20
+ // not on the first request. Empty string is allowed (caller must always
21
+ // supply params.model); non-empty values must pass the model-ID check.
22
+ if (opts.defaultModel) {
23
+ const check = validateGeminiModelId(opts.defaultModel);
24
+ if (!check.ok) {
25
+ throw new Error(`gemini-rest: invalid defaultModel: ${check.error}`);
26
+ }
27
+ }
18
28
  const capabilities = new Set(['streaming_text']);
19
29
  const baseUrl = opts.baseUrl ?? 'https://generativelanguage.googleapis.com/v1beta';
20
30
  return {
@@ -24,6 +34,12 @@ export function createGeminiRestRuntime(opts) {
24
34
  invoke(params) {
25
35
  return (async function* () {
26
36
  const model = params.model || opts.defaultModel;
37
+ const modelCheck = validateGeminiModelId(model);
38
+ if (!modelCheck.ok) {
39
+ yield createRuntimeErrorEvent(`gemini-rest: ${modelCheck.error}`);
40
+ yield { type: 'done' };
41
+ return;
42
+ }
27
43
  const url = `${baseUrl}/models/${model}:streamGenerateContent?alt=sse`;
28
44
  const controller = new AbortController();
29
45
  let timer;
@@ -136,6 +152,12 @@ export function createGeminiRestRuntime(opts) {
136
152
  opts.log?.debug({ candidate: lastCandidate }, 'gemini-rest: full candidate on empty response');
137
153
  }
138
154
  yield { type: 'text_final', text: accumulated };
155
+ const geminiTruncated = lastFinishReason === 'MAX_TOKENS' || lastFinishReason === 'STOP_LIMIT';
156
+ yield {
157
+ type: 'finish_metadata',
158
+ truncated: geminiTruncated,
159
+ ...(lastFinishReason ? { finishReason: lastFinishReason } : {}),
160
+ };
139
161
  yield { type: 'done' };
140
162
  }
141
163
  catch (err) {
@@ -192,6 +192,70 @@ describe('Gemini REST runtime adapter', () => {
192
192
  await collectEvents(runtime.invoke({ prompt: 'test', model: '', cwd: '/tmp' }));
193
193
  expect(log.debug).toHaveBeenCalledWith({ candidate }, 'gemini-rest: full candidate on empty response');
194
194
  });
195
+ it('rejects model with path traversal characters', async () => {
196
+ globalThis.fetch = vi.fn();
197
+ const runtime = createGeminiRestRuntime({
198
+ apiKey: 'test-key',
199
+ defaultModel: 'gemini-2.5-flash',
200
+ });
201
+ const events = await collectEvents(runtime.invoke({ prompt: 'test', model: '../evil/path', cwd: '/tmp' }));
202
+ const error = events.find((e) => e.type === 'error');
203
+ expect(error).toBeDefined();
204
+ expect(error.message).toContain('invalid Gemini model identifier');
205
+ expect(events.at(-1)?.type).toBe('done');
206
+ expect(globalThis.fetch).not.toHaveBeenCalled();
207
+ });
208
+ it('rejects model with colons', async () => {
209
+ globalThis.fetch = vi.fn();
210
+ const runtime = createGeminiRestRuntime({
211
+ apiKey: 'test-key',
212
+ defaultModel: 'gemini-2.5-flash',
213
+ });
214
+ const events = await collectEvents(runtime.invoke({ prompt: 'test', model: 'model:inject', cwd: '/tmp' }));
215
+ const error = events.find((e) => e.type === 'error');
216
+ expect(error).toBeDefined();
217
+ expect(error.message).toContain('invalid Gemini model identifier');
218
+ expect(globalThis.fetch).not.toHaveBeenCalled();
219
+ });
220
+ it('rejects model with slashes', async () => {
221
+ globalThis.fetch = vi.fn();
222
+ const runtime = createGeminiRestRuntime({
223
+ apiKey: 'test-key',
224
+ defaultModel: 'gemini-2.5-flash',
225
+ });
226
+ const events = await collectEvents(runtime.invoke({ prompt: 'test', model: 'models/evil', cwd: '/tmp' }));
227
+ const error = events.find((e) => e.type === 'error');
228
+ expect(error).toBeDefined();
229
+ expect(globalThis.fetch).not.toHaveBeenCalled();
230
+ });
231
+ it('rejects empty model when defaultModel is also empty', async () => {
232
+ globalThis.fetch = vi.fn();
233
+ const runtime = createGeminiRestRuntime({
234
+ apiKey: 'test-key',
235
+ defaultModel: '',
236
+ });
237
+ const events = await collectEvents(runtime.invoke({ prompt: 'test', model: '', cwd: '/tmp' }));
238
+ const error = events.find((e) => e.type === 'error');
239
+ expect(error).toBeDefined();
240
+ expect(globalThis.fetch).not.toHaveBeenCalled();
241
+ });
242
+ it('accepts valid model identifiers with dots and hyphens', async () => {
243
+ globalThis.fetch = vi.fn().mockResolvedValue(makeSSEResponse([makeGeminiSSEData('ok')]));
244
+ const runtime = createGeminiRestRuntime({
245
+ apiKey: 'test-key',
246
+ defaultModel: 'gemini-2.5-flash',
247
+ });
248
+ const events = await collectEvents(runtime.invoke({ prompt: 'test', model: 'gemini-2.5-pro-preview-05-06', cwd: '/tmp' }));
249
+ expect(events.find((e) => e.type === 'error')).toBeUndefined();
250
+ expect(events.find((e) => e.type === 'text_final')).toBeDefined();
251
+ expect(globalThis.fetch).toHaveBeenCalledOnce();
252
+ });
253
+ it('throws at construction time if defaultModel is invalid', () => {
254
+ expect(() => createGeminiRestRuntime({ apiKey: 'test-key', defaultModel: '../evil' })).toThrow('invalid defaultModel');
255
+ });
256
+ it('allows empty defaultModel at construction time', () => {
257
+ expect(() => createGeminiRestRuntime({ apiKey: 'test-key', defaultModel: '' })).not.toThrow();
258
+ });
195
259
  it('supports custom baseUrl', async () => {
196
260
  globalThis.fetch = vi.fn().mockResolvedValue(makeSSEResponse([makeGeminiSSEData('ok')]));
197
261
  const runtime = createGeminiRestRuntime({
@@ -178,6 +178,12 @@ export function createOpenAICompatRuntime(opts) {
178
178
  if (content)
179
179
  yield { type: 'text_delta', text: content };
180
180
  yield { type: 'text_final', text: content };
181
+ const toolLoopFinishReason = choice?.finish_reason;
182
+ yield {
183
+ type: 'finish_metadata',
184
+ truncated: toolLoopFinishReason === 'length',
185
+ ...(toolLoopFinishReason ? { finishReason: toolLoopFinishReason } : {}),
186
+ };
181
187
  yield { type: 'done' };
182
188
  return;
183
189
  }
@@ -235,6 +241,7 @@ export function createOpenAICompatRuntime(opts) {
235
241
  ...tokenField,
236
242
  });
237
243
  let accumulated = '';
244
+ let streamFinishReason;
238
245
  const response = await fetchWithOpenAIBearerAuth({
239
246
  url,
240
247
  auth,
@@ -273,16 +280,25 @@ export function createOpenAICompatRuntime(opts) {
273
280
  return false;
274
281
  if (data === '[DONE]') {
275
282
  yield { type: 'text_final', text: accumulated };
283
+ yield {
284
+ type: 'finish_metadata',
285
+ truncated: streamFinishReason === 'length',
286
+ ...(streamFinishReason ? { finishReason: streamFinishReason } : {}),
287
+ };
276
288
  yield { type: 'done' };
277
289
  return true;
278
290
  }
279
291
  try {
280
292
  const parsed = JSON.parse(data);
281
- const content = parsed?.choices?.[0]?.delta?.content;
293
+ const choice = parsed?.choices?.[0];
294
+ const content = choice?.delta?.content;
282
295
  if (content) {
283
296
  accumulated += content;
284
297
  yield { type: 'text_delta', text: content };
285
298
  }
299
+ const chunkFinish = choice?.finish_reason;
300
+ if (chunkFinish)
301
+ streamFinishReason = chunkFinish;
286
302
  }
287
303
  catch {
288
304
  // Skip unparseable lines
@@ -322,6 +338,11 @@ export function createOpenAICompatRuntime(opts) {
322
338
  }
323
339
  // Stream ended without [DONE] — emit what we have
324
340
  yield { type: 'text_final', text: accumulated };
341
+ yield {
342
+ type: 'finish_metadata',
343
+ truncated: streamFinishReason === 'length',
344
+ ...(streamFinishReason ? { finishReason: streamFinishReason } : {}),
345
+ };
325
346
  yield { type: 'done' };
326
347
  }
327
348
  }
@@ -23,7 +23,9 @@ const TOOL_USE_NAME_LENGTH_GUIDANCE = 'Anthropic API error: MCP tool name too lo
23
23
  * receives an explicit boundary at each phase boundary.
24
24
  */
25
25
  export const PHASE_SAFETY_REMINDER = 'SAFETY (automated agent): Do not run rm -rf outside build artifact directories, ' +
26
- 'git push --force, git branch -D, DROP TABLE, or chmod 777. ' +
26
+ 'git push --force, git branch -D, git init, git checkout --orphan, ' +
27
+ 'git reset --hard <other-commit>, DROP TABLE, or chmod 777. ' +
28
+ 'Do not switch branches or create new branches during implementation phases. ' +
27
29
  'Do not write to .env, root-policy.ts, ~/.ssh/, or ~/.claude/ paths. ' +
28
30
  'If a task requires any of these, report it instead of executing.';
29
31
  const THINKING_PREVIEW_INTERVAL_MS = 3_000;
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Guards against prototype-pollution via dynamic route parameters.
3
+ *
4
+ * When a URL segment (e.g. `/webhook/:source`) is used as a plain-object
5
+ * property name, reserved keys like `__proto__` can trigger prototype
6
+ * pollution or uncaught exceptions. Call this before any object lookup
7
+ * keyed by a route parameter.
8
+ */
9
+ const RESERVED = new Set([
10
+ '__proto__',
11
+ 'constructor',
12
+ 'prototype',
13
+ ]);
14
+ /** Returns `true` when `key` is a reserved Object property name. */
15
+ export function isReservedObjectKey(key) {
16
+ return RESERVED.has(key);
17
+ }
@@ -7,6 +7,7 @@ import crypto from 'node:crypto';
7
7
  import http from 'node:http';
8
8
  import fs from 'node:fs/promises';
9
9
  import { executeCronJob } from '../cron/executor.js';
10
+ import { isUnsafeKey, stripUnsafeKeys } from '../config/safe-key.js';
10
11
  import { sanitizeExternalContent } from '../sanitize-external.js';
11
12
  // ---------------------------------------------------------------------------
12
13
  // Shared constants + config types
@@ -33,7 +34,7 @@ export async function loadWebhookConfig(configPath) {
33
34
  if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
34
35
  throw new Error('Webhook config must be a JSON object');
35
36
  }
36
- return parsed;
37
+ return stripUnsafeKeys(parsed);
37
38
  }
38
39
  let webhookJobCounter = 0;
39
40
  function buildWebhookJob(source, src, bodyText, guildId) {
@@ -113,6 +114,10 @@ export async function startWebhookServer(opts = {}) {
113
114
  respondWebhook(res, 400, 'Bad request');
114
115
  return;
115
116
  }
117
+ if (isUnsafeKey(source)) {
118
+ respondWebhook(res, 400, 'Bad request');
119
+ return;
120
+ }
116
121
  const src = config[source];
117
122
  if (!src) {
118
123
  log?.warn({ source }, 'webhook:unknown source');
@@ -164,6 +164,21 @@ describe('startWebhookServer HTTP routing', () => {
164
164
  const res = await makeRequest(port, { path: '/webhook/foo%ZZbar' });
165
165
  expect(res.status).toBe(400);
166
166
  });
167
+ it('returns 400 for __proto__ source (prototype pollution)', async () => {
168
+ const res = await makeRequest(port, { path: '/webhook/__proto__', body: '{}' });
169
+ expect(res.status).toBe(400);
170
+ expect(res.body.ok).toBe(false);
171
+ });
172
+ it('returns 400 for constructor source (prototype pollution)', async () => {
173
+ const res = await makeRequest(port, { path: '/webhook/constructor', body: '{}' });
174
+ expect(res.status).toBe(400);
175
+ expect(res.body.ok).toBe(false);
176
+ });
177
+ it('returns 400 for prototype source (prototype pollution)', async () => {
178
+ const res = await makeRequest(port, { path: '/webhook/prototype', body: '{}' });
179
+ expect(res.status).toBe(400);
180
+ expect(res.body.ok).toBe(false);
181
+ });
167
182
  it('returns 404 for an unknown source', async () => {
168
183
  const body = '{}';
169
184
  const res = await makeRequest(port, {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "discoclaw",
3
- "version": "0.8.2",
3
+ "version": "0.8.3",
4
4
  "description": "Personal AI orchestrator that turns Discord into a persistent workspace",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -73,7 +73,7 @@
73
73
  "youtube-transcript-plus": "^1.2.0"
74
74
  },
75
75
  "simple-git-hooks": {
76
- "pre-push": "pnpm build && pnpm test"
76
+ "pre-push": "bash scripts/pre-push-safety.sh && pnpm build && pnpm test"
77
77
  },
78
78
  "publishConfig": {
79
79
  "access": "public",
@@ -114,6 +114,17 @@ Your training data has a cutoff date. Anything that could have changed recently
114
114
 
115
115
  The cost of a quick web search is negligible. The cost of confidently declaring something doesn't exist -- when it dropped two days ago -- is your credibility.
116
116
 
117
+ ## Git Safety Rules
118
+
119
+ These rules apply to all git operations, including freeform "commit and PR" flows:
120
+
121
+ - **Never** run `git init` in the project directory
122
+ - **Never** run `git checkout --orphan` — this replaces the entire repo tree
123
+ - **Never** run `git reset --hard` to a different commit without explicit user approval
124
+ - Before pushing, verify the diff is proportional to the task — a bug fix should not delete hundreds of files
125
+ - If the diff shows mass deletions unrelated to the task, **stop and report** instead of pushing
126
+ - Do not switch branches during implementation unless the task explicitly requires it
127
+
117
128
  ## Landing the Plane (Session Completion)
118
129
 
119
130
  Work is complete only when `git push` succeeds — local-only work is stranded work. If push fails, resolve and retry.