osborn 0.9.217 → 0.9.218

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.
@@ -59,12 +59,23 @@ export declare const NAMED_AGENTS: {
59
59
  description: string;
60
60
  tools: string[];
61
61
  grounded: boolean;
62
+ policy: {
63
+ write: "anywhere";
64
+ };
65
+ reminder: string;
62
66
  model: string;
63
67
  prompt: string;
64
68
  };
65
69
  tester: {
66
70
  description: string;
67
71
  tools: string[];
72
+ policy: {
73
+ write: {
74
+ extensions: RegExp;
75
+ label: string;
76
+ matchBasename: boolean;
77
+ };
78
+ };
68
79
  model: string;
69
80
  prompt: string;
70
81
  };
@@ -78,6 +89,12 @@ export declare const NAMED_AGENTS: {
78
89
  reviewer: {
79
90
  description: string;
80
91
  tools: string[];
92
+ policy: {
93
+ write: {
94
+ extensions: RegExp;
95
+ label: string;
96
+ };
97
+ };
81
98
  model: string;
82
99
  prompt: string;
83
100
  };
@@ -468,6 +468,10 @@ export const NAMED_AGENTS = {
468
468
  ].join(' '),
469
469
  tools: ['Read', 'Write', 'Edit', 'MultiEdit', 'Bash', 'Glob', 'Grep', 'NotebookRead', 'NotebookEdit'],
470
470
  grounded: true, // applyGrounding() injects the osborn-recall command
471
+ policy: { write: 'anywhere' }, // sole writer — unrestricted; funnels through canUseTool
472
+ // Soft behavior via the composable `reminder` seam → SDK criticalSystemReminder_EXPERIMENTAL.
473
+ // Pinned into the writer's system prompt as a hard-to-ignore reminder.
474
+ reminder: 'BEFORE you write implementation code: make sure a test exists for the behavior you are about to change. If none exists, say so explicitly in your report so the tester can cover it — the tester is the agent that writes tests. NEVER weaken, skip, or delete a test to make your change pass.',
471
475
  model: 'opus',
472
476
  prompt: [
473
477
  'You are Osborn\'s writer agent. You execute file changes with a verify-first approach.',
@@ -520,6 +524,8 @@ export const NAMED_AGENTS = {
520
524
  'NOT GROUNDED: no session index access — runs tests with fresh eyes, adversarial validation.',
521
525
  ].join(' '),
522
526
  tools: ['Bash', 'Read', 'Glob', 'Grep', 'Write', 'Edit'],
527
+ // Fail-closed: may ONLY write test files (basename .test/.spec.[jt]sx?).
528
+ policy: { write: { extensions: /\.(test|spec)\.[jt]sx?$/, label: 'test', matchBasename: true } },
523
529
  model: 'sonnet',
524
530
  prompt: [
525
531
  'You are Osborn\'s tester agent. Your job is running tests and builds, then reporting results.',
@@ -669,6 +675,8 @@ export const NAMED_AGENTS = {
669
675
  'NOT GROUNDED: no session index access — reviews with fresh eyes for unbiased adversarial check.',
670
676
  ].join(' '),
671
677
  tools: ['Read', 'Glob', 'Grep', 'Bash', 'Write', 'Edit'],
678
+ // Fail-closed: may ONLY write documentation files.
679
+ policy: { write: { extensions: /\.(md|markdown|mdx|txt|rst|adoc)$/i, label: 'documentation' } },
672
680
  model: 'sonnet',
673
681
  prompt: [
674
682
  'You are Osborn\'s reviewer agent. You are the VERIFY step in a generator-verifier loop.',
@@ -836,6 +844,59 @@ export function applyGrounding(agents, sessionId, workingDir) {
836
844
  }
837
845
  return out;
838
846
  }
847
+ const DEFAULT_WRITE_POLICY = 'workspace';
848
+ /** Path is inside the per-session sandbox workspace. */
849
+ function isWorkspacePath(filePath) {
850
+ return !!filePath && (filePath.includes('/osb/') ||
851
+ filePath.includes('.osborn/sessions/') ||
852
+ filePath.includes('.osborn/research/'));
853
+ }
854
+ /** Effective write policy for the acting agent (null agentType = main orchestrator). */
855
+ function resolveWritePolicy(agentType, roster) {
856
+ const def = agentType ? roster?.[agentType] : null;
857
+ return def?.policy?.write ?? DEFAULT_WRITE_POLICY;
858
+ }
859
+ /**
860
+ * Decide a Write/Edit/MultiEdit against a policy. Pure — no side effects.
861
+ * 'allow' → let it fall through (→ canUseTool workspace auto-approve, or {}).
862
+ * 'defer' → PreToolUse returns permissionDecision:'ask' (canUseTool decides).
863
+ * 'deny' → hard block with reason.
864
+ */
865
+ function decideWrite(policy, filePath) {
866
+ if (policy === 'anywhere')
867
+ return { decision: 'defer' };
868
+ if (policy === 'workspace') {
869
+ if (filePath && !isWorkspacePath(filePath)) {
870
+ return { decision: 'deny', reason: 'Research mode: writes restricted to session workspace.' };
871
+ }
872
+ return { decision: 'allow' };
873
+ }
874
+ // Extension whitelist — fail closed (empty/unknown path denied).
875
+ const target = policy.matchBasename ? (filePath ? basename(resolve(filePath)) : '') : filePath;
876
+ if (!filePath || !policy.extensions.test(target)) {
877
+ const reason = filePath
878
+ ? `Write denied: ${filePath} is not a ${policy.label} file. This agent may only write ${policy.label} files.`
879
+ : 'Write denied: could not determine target file path. Failing closed.';
880
+ return { decision: 'deny', reason };
881
+ }
882
+ return { decision: 'defer' };
883
+ }
884
+ /**
885
+ * Strip/map behavior meta-fields so the roster is a clean AgentDefinition set
886
+ * for the SDK: drop `policy` (enforced in-process by the write-gate) and map
887
+ * `reminder` → criticalSystemReminder_EXPERIMENTAL. NEVER mutates the input.
888
+ */
889
+ function finalizeRoster(agents) {
890
+ const out = {};
891
+ for (const [name, agent] of Object.entries(agents)) {
892
+ const { policy, reminder, ...rest } = agent;
893
+ if (reminder && !rest.criticalSystemReminder_EXPERIMENTAL) {
894
+ rest.criticalSystemReminder_EXPERIMENTAL = reminder;
895
+ }
896
+ out[name] = rest;
897
+ }
898
+ return out;
899
+ }
839
900
  const RESEARCH_TOOLS = [
840
901
  'Read', 'Write', 'Edit', 'Glob', 'Grep',
841
902
  'Bash', 'WebSearch', 'WebFetch',
@@ -1955,6 +2016,10 @@ class ClaudeLLMStream extends llm.LLMStream {
1955
2016
  ? getSessionWorkspace(this.#opts.workingDirectory, sessionId)
1956
2017
  : null;
1957
2018
  const allowedTools = this.#opts.allowedTools || [];
2019
+ // Roster the PreToolUse write-gate reads policy from — the PRE-strip view
2020
+ // (retains `policy`), so DB-backed custom agents (set_agents) get their
2021
+ // write policy enforced too. The SDK receives the finalized copy (agents:).
2022
+ const enforcementRoster = this.#opts.agents ?? NAMED_AGENTS;
1958
2023
  const sdkOptions = {
1959
2024
  cwd: this.#opts.workingDirectory,
1960
2025
  permissionMode: this.#opts.permissionMode,
@@ -2072,54 +2137,29 @@ class ClaudeLLMStream extends llm.LLMStream {
2072
2137
  }
2073
2138
  console.log(`🔧 Tool call ${turnToolCallCount}/${TOOL_CALL_BUDGET}: ${toolName}`);
2074
2139
  }
2075
- // Write/Edit/MultiEdit access control
2140
+ // Write/Edit/MultiEdit access control — DATA-DRIVEN by each agent's
2141
+ // declarative policy.write (see AgentWritePolicy). Replaces the old
2142
+ // hardcoded per-role branches; DB-backed custom agents get enforced too.
2076
2143
  if (toolName === 'Write' || toolName === 'Edit' || toolName === 'MultiEdit') {
2077
- // Writer sub-agent gets full write access everywhere
2078
- console.log('verifying agent_type', agentType);
2079
- // Writer agent: no longer auto-approved — falls through to canUseTool for permission dialog
2080
- if (agentType === 'writer') {
2081
- console.log(`✍️ Writer agent: deferring to canUseTool for permission`);
2082
- this.#eventEmitter.emit('tool_use', { name: toolName, input: toolInput, agentRole: agentType || 'main' });
2083
- return { hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'ask' } };
2084
- }
2085
- // Reviewer agent: ONLY documentation-extension files allowed — fail closed
2086
- if (agentType === 'reviewer') {
2087
- const reviewerPath = String(toolInput.file_path || '');
2088
- const DOC_EXTENSIONS = /\.(md|markdown|mdx|txt|rst|adoc)$/i;
2089
- if (!reviewerPath || !DOC_EXTENSIONS.test(reviewerPath)) {
2090
- const reason = reviewerPath
2091
- ? `Reviewer write denied: ${reviewerPath} is not a documentation file (.md/.markdown/.mdx/.txt/.rst/.adoc). Reviewer may only write documentation.`
2092
- : 'Reviewer write denied: could not determine target file path. Failing closed.';
2093
- console.log(`🚫 Reviewer write blocked: ${reviewerPath || '(no path)'} — not a doc extension`);
2094
- this.#eventEmitter.emit('tool_blocked', { name: toolName, reason });
2095
- return { hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny' }, reason };
2096
- }
2097
- console.log(`📝 Reviewer doc write allowed: ${reviewerPath}`);
2098
- return { hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'ask' } };
2144
+ const filePath = String(toolInput.file_path || '');
2145
+ const policy = resolveWritePolicy(agentType, enforcementRoster);
2146
+ const { decision, reason } = decideWrite(policy, filePath);
2147
+ console.log(`🔎 Write gate: agent=${agentType ?? 'main'} policy=${JSON.stringify(policy)} path="${filePath || '(none)'}" → ${decision}`);
2148
+ if (decision === 'deny') {
2149
+ this.#eventEmitter.emit('tool_blocked', { name: toolName, reason });
2150
+ return { hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny' }, reason };
2099
2151
  }
2100
- // Tester agent: ONLY test files allowed — fail closed
2101
- if (agentType === 'tester') {
2102
- const testerPath = String(toolInput.file_path || '');
2103
- const STRICT_TEST_FILE = /\.(test|spec)\.[jt]sx?$/;
2104
- const resolvedBase = testerPath ? basename(resolve(testerPath)) : '';
2105
- if (!testerPath || !STRICT_TEST_FILE.test(resolvedBase)) {
2106
- const reason = testerPath
2107
- ? `Tester write denied: ${testerPath} is not a test file (basename must match .test.ts/tsx/js/jsx or .spec.ts/tsx/js/jsx). Tester may only write test files.`
2108
- : 'Tester write denied: could not determine target file path. Failing closed.';
2109
- console.log(`🚫 Tester write blocked: ${testerPath || '(no path)'} — not a test file`);
2110
- this.#eventEmitter.emit('tool_blocked', { name: toolName, reason });
2111
- return { hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny' }, reason };
2152
+ if (decision === 'defer') {
2153
+ // Writer (write:'anywhere') surfaces its write in the live trace
2154
+ // before the permission dialog; adversarial agents (reviewer/tester)
2155
+ // do NOT emit here — parity with the prior hardcoded branches.
2156
+ if (policy === 'anywhere') {
2157
+ this.#eventEmitter.emit('tool_use', { name: toolName, input: toolInput, agentRole: agentType || 'main' });
2112
2158
  }
2113
- console.log(`🧪 Tester test-file write allowed: ${testerPath}`);
2114
2159
  return { hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'ask' } };
2115
2160
  }
2116
- // All other agents (main, researcher, reasoner, etc.): workspace only
2117
- const filePath = String(toolInput.file_path || '');
2118
- if (filePath && !filePath.includes('/osb/') && !filePath.includes('.osborn/sessions/') && !filePath.includes('.osborn/research/')) {
2119
- console.log(`🚫 Research mode: blocked write to ${filePath} (agent_type: ${agentType ?? 'main'})`);
2120
- this.#eventEmitter.emit('tool_blocked', { name: toolName, reason: 'Research mode: writes restricted to session workspace' });
2121
- return { hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny' }, reason: 'Research mode: writes restricted to session workspace.' };
2122
- }
2161
+ // decision === 'allow' → fall through to the shared tail (emits
2162
+ // tool_use, returns {} → canUseTool workspace auto-approve).
2123
2163
  }
2124
2164
  console.log(`🔧 Claude: ${toolName}`);
2125
2165
  this.#eventEmitter.emit('tool_use', { name: toolName, input: toolInput, agentRole: agentType || 'main' });
@@ -2513,9 +2553,9 @@ class ClaudeLLMStream extends llm.LLMStream {
2513
2553
  // opts.agents is undefined — the ?? NAMED_AGENTS fallback would skip
2514
2554
  // the override entirely. Explicitly apply applyTurbo(NAMED_AGENTS)
2515
2555
  // so built-in agents always get FAST_MODEL when turbo is on.
2516
- agents: applyGrounding(this.#llmRef.turbo
2556
+ agents: finalizeRoster(applyGrounding(this.#llmRef.turbo
2517
2557
  ? applyTurbo(this.#opts.agents ?? NAMED_AGENTS, true)
2518
- : (this.#opts.agents ?? NAMED_AGENTS), this.#sessionId, this.#opts.workingDirectory),
2558
+ : (this.#opts.agents ?? NAMED_AGENTS), this.#sessionId, this.#opts.workingDirectory)),
2519
2559
  };
2520
2560
  // Run Claude Agent SDK query() and stream results
2521
2561
  let hasOutput = false;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "osborn",
3
- "version": "0.9.217",
3
+ "version": "0.9.218",
4
4
  "description": "Voice AI coding assistant - local agent that connects to Osborn frontend",
5
5
  "type": "module",
6
6
  "bin": {