typeclaw 0.8.0 → 0.9.1

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.
Files changed (96) hide show
  1. package/README.md +6 -6
  2. package/package.json +5 -3
  3. package/scripts/require-parallel.ts +41 -0
  4. package/src/agent/index.ts +55 -6
  5. package/src/agent/live-sessions.ts +34 -0
  6. package/src/agent/plugin-tools.ts +2 -0
  7. package/src/agent/session-meta.ts +21 -2
  8. package/src/agent/subagent-completion-reminder.ts +89 -0
  9. package/src/agent/subagents.ts +75 -15
  10. package/src/agent/system-prompt.ts +10 -8
  11. package/src/agent/tools/channel-reply.ts +47 -7
  12. package/src/agent/tools/channel-send.ts +43 -11
  13. package/src/agent/tools/runtime-notice.ts +41 -0
  14. package/src/bundled-plugins/explorer/explorer.ts +2 -2
  15. package/src/bundled-plugins/guard/index.ts +14 -1
  16. package/src/bundled-plugins/guard/policies/managed-config.ts +43 -13
  17. package/src/bundled-plugins/guard/policies/memory-retrieval-cache-write.ts +37 -0
  18. package/src/bundled-plugins/guard/policies/memory-topics-delete.ts +67 -0
  19. package/src/bundled-plugins/guard/policies/memory-topics-write.ts +33 -0
  20. package/src/bundled-plugins/guard/policies/non-workspace-write.ts +8 -2
  21. package/src/bundled-plugins/guard/policy.ts +7 -0
  22. package/src/bundled-plugins/memory/README.md +76 -62
  23. package/src/bundled-plugins/memory/append-tool.ts +3 -2
  24. package/src/bundled-plugins/memory/citation-superset.ts +49 -11
  25. package/src/bundled-plugins/memory/citations.ts +19 -8
  26. package/src/bundled-plugins/memory/delete-tool.ts +57 -0
  27. package/src/bundled-plugins/memory/dreaming-state.ts +1 -1
  28. package/src/bundled-plugins/memory/dreaming.ts +364 -146
  29. package/src/bundled-plugins/memory/frontmatter.ts +165 -0
  30. package/src/bundled-plugins/memory/index.ts +257 -16
  31. package/src/bundled-plugins/memory/injection-plan.ts +15 -0
  32. package/src/bundled-plugins/memory/load-memory.ts +102 -103
  33. package/src/bundled-plugins/memory/load-shards.ts +156 -0
  34. package/src/bundled-plugins/memory/memory-logger.ts +16 -15
  35. package/src/bundled-plugins/memory/memory-retrieval.ts +111 -0
  36. package/src/bundled-plugins/memory/migration.ts +353 -1
  37. package/src/bundled-plugins/memory/paths.ts +42 -0
  38. package/src/bundled-plugins/memory/search-tool.ts +232 -0
  39. package/src/bundled-plugins/memory/secret-detector.ts +2 -2
  40. package/src/bundled-plugins/memory/shard-snapshot.ts +51 -0
  41. package/src/bundled-plugins/memory/slug.ts +59 -0
  42. package/src/bundled-plugins/memory/stream-io.ts +110 -1
  43. package/src/bundled-plugins/memory/strength.ts +3 -3
  44. package/src/bundled-plugins/memory/topics.ts +70 -16
  45. package/src/bundled-plugins/security/index.ts +24 -0
  46. package/src/bundled-plugins/security/permissions.ts +4 -0
  47. package/src/bundled-plugins/security/policies/cron-promotion.ts +349 -0
  48. package/src/bundled-plugins/security/policies/git-exfil.ts +2 -0
  49. package/src/bundled-plugins/security/policies/prompt-injection.ts +3 -0
  50. package/src/bundled-plugins/security/policies/role-promotion.ts +419 -0
  51. package/src/bundled-plugins/security/policies/system-prompt-leak.ts +1 -0
  52. package/src/channels/adapters/kakaotalk-attachment.ts +7 -17
  53. package/src/channels/adapters/kakaotalk-classify.ts +4 -1
  54. package/src/channels/adapters/kakaotalk.ts +65 -38
  55. package/src/channels/adapters/slack-bot-classify.ts +2 -27
  56. package/src/channels/index.ts +5 -0
  57. package/src/channels/router.ts +320 -22
  58. package/src/channels/subagent-completion-bridge.ts +84 -0
  59. package/src/cli/builtins.ts +1 -0
  60. package/src/cli/index.ts +1 -0
  61. package/src/cli/init.ts +122 -14
  62. package/src/cli/inspect.ts +151 -0
  63. package/src/cron/consumer.ts +1 -1
  64. package/src/init/dockerfile.ts +268 -4
  65. package/src/init/hatching.ts +5 -6
  66. package/src/init/kakaotalk-auth.ts +6 -47
  67. package/src/init/validate-api-key.ts +121 -0
  68. package/src/inspect/index.ts +213 -0
  69. package/src/inspect/label.ts +50 -0
  70. package/src/inspect/live.ts +221 -0
  71. package/src/inspect/render.ts +163 -0
  72. package/src/inspect/replay.ts +295 -0
  73. package/src/inspect/session-list.ts +160 -0
  74. package/src/inspect/types.ts +110 -0
  75. package/src/plugin/hooks.ts +23 -1
  76. package/src/plugin/index.ts +2 -0
  77. package/src/plugin/manager.ts +1 -1
  78. package/src/plugin/registry.ts +1 -1
  79. package/src/plugin/types.ts +10 -0
  80. package/src/run/channel-session-factory.ts +7 -1
  81. package/src/run/index.ts +103 -21
  82. package/src/secrets/kakao-renewal.ts +3 -47
  83. package/src/server/index.ts +241 -60
  84. package/src/shared/index.ts +3 -0
  85. package/src/shared/protocol.ts +49 -0
  86. package/src/skills/typeclaw-channel-kakaotalk/SKILL.md +9 -9
  87. package/src/skills/typeclaw-claude-code/SKILL.md +57 -39
  88. package/src/skills/typeclaw-claude-code/references/stop-hook.md +2 -0
  89. package/src/skills/typeclaw-claude-code/references/tmux-driving.md +102 -16
  90. package/src/skills/typeclaw-config/SKILL.md +1 -1
  91. package/src/skills/typeclaw-cron/SKILL.md +1 -1
  92. package/src/skills/typeclaw-memory/SKILL.md +16 -163
  93. package/src/skills/typeclaw-permissions/SKILL.md +2 -2
  94. package/src/skills/typeclaw-plugins/SKILL.md +25 -14
  95. package/src/test-helpers/wait-for.ts +7 -1
  96. package/typeclaw.schema.json +15 -1
@@ -1,10 +1,16 @@
1
1
  import { Type } from '@mariozechner/pi-ai'
2
2
  import { defineTool } from '@mariozechner/pi-coding-agent'
3
3
 
4
- import { isNoReplySignal, isUpstreamEmptyResponseSentinel, type ChannelRouter } from '@/channels/router'
4
+ import {
5
+ containsKimiToolDelimiter,
6
+ isNoReplySignal,
7
+ isUpstreamEmptyResponseSentinel,
8
+ type ChannelRouter,
9
+ } from '@/channels/router'
5
10
  import type { AdapterId } from '@/channels/schema'
6
11
 
7
12
  import { type ChannelToolLogger, consoleChannelLogger, formatChannelToolFailure } from './channel-log'
13
+ import { fenceRuntimeNotice } from './runtime-notice'
8
14
 
9
15
  export type ChannelReplyOrigin = {
10
16
  adapter: AdapterId
@@ -98,6 +104,15 @@ export function createChannelReplyTool({
98
104
  }
99
105
  }
100
106
 
107
+ const kimiLeakError = kimiToolCallLeakError(text)
108
+ if (kimiLeakError) {
109
+ logger.warn(formatChannelToolFailure('channel_reply', kimiLeakError))
110
+ return {
111
+ content: [{ type: 'text' as const, text: `channel_reply denied: ${kimiLeakError}` }],
112
+ details: { ok: false, error: kimiLeakError },
113
+ }
114
+ }
115
+
101
116
  const result = await router.send({
102
117
  adapter: origin.adapter,
103
118
  workspace: origin.workspace,
@@ -148,14 +163,24 @@ export function createChannelReplyTool({
148
163
  }),
149
164
  )
150
165
  : ''
166
+ const body = hint ? `${baseText}${hint}` : baseText
151
167
  return {
152
- content: [{ type: 'text' as const, text: hint ? `${baseText}${hint}` : baseText }],
168
+ content: [{ type: 'text' as const, text: `${TOOL_RESULT_PREFIX}${body}` }],
153
169
  details,
154
170
  }
155
171
  },
156
172
  })
157
173
  }
158
174
 
175
+ // Tool results reach the model as USER-role messages (OpenAI / Anthropic
176
+ // tool-API contract — the engine cannot tag them as system). Without this
177
+ // marker a persona-rich model reads its own echo as a fresh user inbound
178
+ // and replies to itself. Observed in production: Kimi K2 on KakaoTalk
179
+ // re-invoked after a successful send saw only the echo as new context
180
+ // and hallucinated a goodbye trigger from it. Mirrored verbatim in
181
+ // channel-send.ts so both tools share one greppable marker.
182
+ export const TOOL_RESULT_PREFIX = '[system: tool result, not a user message] '
183
+
159
184
  export const ECHO_MAX_CHARS = 500
160
185
 
161
186
  export function renderEcho(text: string): string {
@@ -211,12 +236,27 @@ function upstreamEmptyResponseSentinelError(text: string | undefined): string {
211
236
  )
212
237
  }
213
238
 
239
+ function kimiToolCallLeakError(text: string | undefined): string {
240
+ if (text === undefined) return ''
241
+ if (!containsKimiToolDelimiter(text)) return ''
242
+ return (
243
+ 'refusing to forward raw provider tool-call control tokens; these are chat-template ' +
244
+ 'delimiters that should have been parsed into a real tool call upstream. ' +
245
+ 'Re-issue the intended channel reply as plain user-visible text only.'
246
+ )
247
+ }
248
+
214
249
  // Mirror of the same hint used by channel_send. Kept identical so the model
215
- // sees the same yield signal regardless of which tool it picked.
250
+ // sees the same yield signal regardless of which tool it picked. The body
251
+ // is wrapped via `fenceRuntimeNotice` (in `./runtime-notice`) so persona-rich
252
+ // models cannot read the trailing prose as a chat instruction and reply to
253
+ // it in-character. See that helper's comment for the failure mode that
254
+ // motivated the framing.
216
255
  function consecutiveSendHint(countAfterSend: number): string {
217
256
  if (countAfterSend <= 1) return ''
218
- if (countAfterSend === 2) {
219
- return 'this is your 2nd consecutive message in this conversation; continue only if the reply genuinely needs splitting.'
220
- }
221
- return `${countAfterSend}th consecutive message with no user reply; end your turn now unless the user explicitly asked for a multi-step response.`
257
+ const body =
258
+ countAfterSend === 2
259
+ ? 'this is your 2nd consecutive message in this conversation; continue only if the reply genuinely needs splitting.'
260
+ : `${countAfterSend}th consecutive message with no user reply; end your turn now unless the user explicitly asked for a multi-step response.`
261
+ return fenceRuntimeNotice(body)
222
262
  }
@@ -1,11 +1,17 @@
1
1
  import { Type } from '@mariozechner/pi-ai'
2
2
  import { defineTool } from '@mariozechner/pi-coding-agent'
3
3
 
4
- import { isNoReplySignal, isUpstreamEmptyResponseSentinel, type ChannelRouter } from '@/channels/router'
4
+ import {
5
+ containsKimiToolDelimiter,
6
+ isNoReplySignal,
7
+ isUpstreamEmptyResponseSentinel,
8
+ type ChannelRouter,
9
+ } from '@/channels/router'
5
10
  import { ADAPTER_IDS, type AdapterId } from '@/channels/schema'
6
11
 
7
12
  import { type ChannelToolLogger, consoleChannelLogger, formatChannelToolFailure } from './channel-log'
8
- import { renderOutboundEcho } from './channel-reply'
13
+ import { renderOutboundEcho, TOOL_RESULT_PREFIX } from './channel-reply'
14
+ import { fenceRuntimeNotice } from './runtime-notice'
9
15
 
10
16
  export type ChannelSendOrigin = {
11
17
  adapter: AdapterId
@@ -121,6 +127,15 @@ export function createChannelSendTool({ router, origin, logger = consoleChannelL
121
127
  }
122
128
  }
123
129
 
130
+ const kimiLeakError = kimiToolCallLeakError(bodyText)
131
+ if (kimiLeakError) {
132
+ logger.warn(formatChannelToolFailure('channel_send', kimiLeakError))
133
+ return {
134
+ content: [{ type: 'text' as const, text: `channel_send denied: ${kimiLeakError}` }],
135
+ details: { ok: false, error: kimiLeakError },
136
+ }
137
+ }
138
+
124
139
  const result = await router.send({
125
140
  adapter,
126
141
  workspace: params.workspace,
@@ -163,9 +178,9 @@ export function createChannelSendTool({ router, origin, logger = consoleChannelL
163
178
  })
164
179
  if (threadMismatch) hints.push(threadMismatch)
165
180
  }
166
- const responseText = hints.length > 0 ? `${baseText}${hints.join(' ')}` : baseText
181
+ const body = hints.length > 0 ? `${baseText}${hints.join('')}` : baseText
167
182
  return {
168
- content: [{ type: 'text' as const, text: responseText }],
183
+ content: [{ type: 'text' as const, text: `${TOOL_RESULT_PREFIX}${body}` }],
169
184
  details,
170
185
  }
171
186
  },
@@ -181,6 +196,11 @@ export function createChannelSendTool({ router, origin, logger = consoleChannelL
181
196
  //
182
197
  // Only fires when the origin had a thread to begin with — channel-root
183
198
  // sessions can't have a "missing thread" problem.
199
+ //
200
+ // Body is fenced via `fenceRuntimeNotice` for the same reason the
201
+ // consecutive-send hint is — see that helper's comment for the failure
202
+ // mode (Kimi-K2.x reading trailing tool-result prose as a chat instruction
203
+ // and replying to it in-character).
184
204
  function threadMismatchHint(
185
205
  origin: ChannelSendOrigin | undefined,
186
206
  sent: { adapter: AdapterId; workspace: string; chat: string; thread: string | undefined },
@@ -191,10 +211,10 @@ function threadMismatchHint(
191
211
  if (origin.adapter !== sent.adapter) return ''
192
212
  if (origin.workspace !== sent.workspace) return ''
193
213
  if (origin.chat !== sent.chat) return ''
194
- return (
214
+ return fenceRuntimeNotice(
195
215
  `note: this session's origin thread is ${JSON.stringify(origin.thread)} but you posted to channel root. ` +
196
- `if breaking out of the thread was intentional, ignore this; otherwise prefer \`channel_reply\` ` +
197
- `or pass \`thread: ${JSON.stringify(origin.thread)}\` on your next channel_send.`
216
+ `if breaking out of the thread was intentional, ignore this; otherwise prefer \`channel_reply\` ` +
217
+ `or pass \`thread: ${JSON.stringify(origin.thread)}\` on your next channel_send.`,
198
218
  )
199
219
  }
200
220
 
@@ -233,16 +253,28 @@ function upstreamEmptyResponseSentinelError(text: string | undefined): string {
233
253
  )
234
254
  }
235
255
 
256
+ function kimiToolCallLeakError(text: string | undefined): string {
257
+ if (text === undefined) return ''
258
+ if (!containsKimiToolDelimiter(text)) return ''
259
+ return (
260
+ 'refusing to forward raw provider tool-call control tokens; these are chat-template ' +
261
+ 'delimiters that should have been parsed into a real tool call upstream. ' +
262
+ 'Re-issue the intended channel send as plain user-visible text only.'
263
+ )
264
+ }
265
+
236
266
  // Returns a behavioral hint to nudge the model toward yielding when it has
237
267
  // been the only voice in the conversation for several messages. The router
238
268
  // increments its counter AFTER router.send returns, so a count of 1 means
239
269
  // "this is the second consecutive bot message in this chat:thread" — which
240
270
  // is the first count where a hint is warranted. Empty string at count <= 1
241
271
  // preserves the original tool-result text for the common single-reply case.
272
+ // Mirror of channel-reply.ts; body wrapped via `fenceRuntimeNotice`.
242
273
  function consecutiveSendHint(countAfterSend: number): string {
243
274
  if (countAfterSend <= 1) return ''
244
- if (countAfterSend === 2) {
245
- return 'this is your 2nd consecutive message in this conversation; continue only if the reply genuinely needs splitting.'
246
- }
247
- return `${countAfterSend}th consecutive message with no user reply; end your turn now unless the user explicitly asked for a multi-step response.`
275
+ const body =
276
+ countAfterSend === 2
277
+ ? 'this is your 2nd consecutive message in this conversation; continue only if the reply genuinely needs splitting.'
278
+ : `${countAfterSend}th consecutive message with no user reply; end your turn now unless the user explicitly asked for a multi-step response.`
279
+ return fenceRuntimeNotice(body)
248
280
  }
@@ -0,0 +1,41 @@
1
+ // Wraps a runtime-emitted notice body in canonical SYSTEM MESSAGE framing so
2
+ // persona-rich models cannot read the prose as a chat instruction from a
3
+ // human and respond to it in-character.
4
+ //
5
+ // The failure mode this exists to prevent: tool results reach the model as
6
+ // USER-role messages (provider tool-call contract — engines cannot tag them
7
+ // as system). The `TOOL_RESULT_PREFIX` already marks each result's leading
8
+ // position, but trailing natural-language hints (the consecutive-send nudge
9
+ // is the canonical case) still parse as conversational prose, and Kimi-K2.x
10
+ // has been observed in production responding to those hints in-character —
11
+ // an apology directly addressed at the human ("sorry for talking so much,
12
+ // I'll be quieter next time") when the only stimulus in the prompt was the
13
+ // router's "Nth consecutive message; end your turn now" hint. Four
14
+ // consecutive in-character replies to fenced-prose runtime hints in a
15
+ // single drain iteration is the observed shape.
16
+ //
17
+ // Framing convention is the same shape `composeTurnPrompt` uses for the
18
+ // loop-guard block in `router.ts` — bracketed marker, fence rules, and
19
+ // explicit "Do not acknowledge or reply to this notice" closer. The
20
+ // loop-guard block has been in production against Kimi for months without
21
+ // the misread we observed on the consecutive-send hint, which is why we
22
+ // reuse the exact same shape here.
23
+ //
24
+ // Applied unconditionally (not model-gated): the cost is ~40 tokens per
25
+ // hint emission, paid only on consecutive sends (where the hint is already
26
+ // firing), and the framing is safe for every model — well-behaved models
27
+ // read it and move on. Gating by model family would have required a
28
+ // traits table for one defense and would still need extending the moment
29
+ // a second model family exhibited the same misread, so we accept the
30
+ // universal cost in exchange for never having to remember to add a new
31
+ // family to a list.
32
+ export function fenceRuntimeNotice(body: string): string {
33
+ return (
34
+ '\n\n---\n' +
35
+ '**[SYSTEM MESSAGE — not from a human]**\n\n' +
36
+ body +
37
+ '\n\nThis is an automated signal from the channel router, not a message ' +
38
+ 'from anyone in the chat. **Do not acknowledge or reply to this notice.**\n' +
39
+ '---'
40
+ )
41
+ }
@@ -9,7 +9,7 @@ You are STRICTLY PROHIBITED from:
9
9
  - Creating, modifying, or deleting files
10
10
  - Using bash for: mkdir, touch, rm, cp, mv, git add, git commit, npm install, pip install, or any write operation
11
11
  - Starting long-running background processes
12
- - Writing to MEMORY.md, sessions/, workspace/, or any other runtime-managed path
12
+ - Writing to memory/topics/, memory/streams/, sessions/, workspace/, or any other runtime-managed path
13
13
  - Spawning further subagents — you are at the end of the delegation chain
14
14
 
15
15
  Your role is EXCLUSIVELY to search and analyze existing local state.
@@ -32,7 +32,7 @@ The agent folder is mounted at \`/agent\` inside the container. Search the narro
32
32
 
33
33
  1. **Codebase** — \`/agent/\` root and subdirs (excluding the runtime-managed paths below). Source files, docs, identity files (\`IDENTITY.md\`, \`SOUL.md\`, \`USER.md\`, \`AGENTS.md\`).
34
34
  2. **Sessions** — \`/agent/sessions/*.jsonl\` — conversation transcripts. Each line is a JSON event (user message, tool call, tool result, assistant message). Filename pattern \`\${ISO_TIMESTAMP}_\${UUID}.jsonl\`. \`grep\` works directly on the JSONL.
35
- 3. **Memory** — \`/agent/MEMORY.md\` (long-term consolidated memory) and \`/agent/memory/yyyy-MM-dd.jsonl\` (daily fragment streams written by the memory-logger subagent). \`memory/.dreaming-state.json\` tracks the dreaming watermark. Do NOT edit any of these — they are runtime-owned.
35
+ 3. **Memory** — \`/agent/memory/topics/*.md\` (long-term topic shards) and \`/agent/memory/streams/yyyy-MM-dd.jsonl\` (daily fragment streams written by the memory-logger subagent). \`memory/.dreaming-state.json\` tracks the dreaming watermark. Do NOT edit any of these — they are runtime-owned.
36
36
  4. **Muscle-memory skills** — \`/agent/memory/skills/<name>/SKILL.md\` — procedures the dreaming subagent distilled from repeated work.
37
37
  5. **User-installed skills** — \`/agent/.agents/skills/<name>/SKILL.md\` — hand-authored or downloaded skills.
38
38
  6. **Workspace** — \`/agent/workspace/\` — the agent's free-write zone. Drafts, scratch work, generated artifacts.
@@ -2,6 +2,7 @@ import { definePlugin } from '@/plugin'
2
2
 
3
3
  import {
4
4
  checkManagedConfigGuard,
5
+ checkMemoryTopicsDeleteGuard,
5
6
  checkNonWorkspaceWriteGuard,
6
7
  checkSkillAuthoringGuard,
7
8
  checkUncommittedChangesAdvice,
@@ -23,7 +24,19 @@ export default definePlugin({
23
24
  agentDir: ctx.agentDir,
24
25
  })
25
26
  if (skillResult) return skillResult
26
- return checkNonWorkspaceWriteGuard({ tool: event.tool, args: event.args, agentDir: ctx.agentDir })
27
+ const memoryTopicsDeleteResult = checkMemoryTopicsDeleteGuard({
28
+ tool: event.tool,
29
+ args: event.args,
30
+ agentDir: ctx.agentDir,
31
+ origin: event.origin,
32
+ })
33
+ if (memoryTopicsDeleteResult) return memoryTopicsDeleteResult
34
+ return checkNonWorkspaceWriteGuard({
35
+ tool: event.tool,
36
+ args: event.args,
37
+ agentDir: ctx.agentDir,
38
+ origin: event.origin,
39
+ })
27
40
  },
28
41
  'tool.after': async (event, ctx) => {
29
42
  await checkUncommittedChangesAdvice({
@@ -39,19 +39,31 @@ export async function checkManagedConfigGuard(options: {
39
39
  }
40
40
  }
41
41
 
42
+ // Oracle PR #305 findings #5 and #6: identity-based managed-file
43
+ // detection. The earlier shape compared `basename(realpath(target))` to
44
+ // the managed-file list, which missed two attacks: (5) a symlink at
45
+ // agent root `typeclaw.json -> workspace/tc.json` realpathed to a name
46
+ // outside the managed list, and (6) on case-insensitive filesystems,
47
+ // `TYPECLAW.JSON` addresses the same file as `typeclaw.json` but
48
+ // basename string-equality missed the casing variant.
49
+ //
50
+ // New shape: for each managed-file name, compute the canonical agent-
51
+ // root path and compare against the target. We accept if EITHER the
52
+ // lexical paths match OR they realpath to the same file. Branch (a)
53
+ // covers symlinks and case-aliased filesystems; branch (b) keeps the
54
+ // canonical lexical name authoritative even before the file exists
55
+ // (first-init writes).
42
56
  async function resolveManagedTarget(agentDir: string, targetPath: string): Promise<{ file: ManagedFile } | undefined> {
43
57
  const resolvedAgentDir = path.resolve(agentDir)
44
- const realAgentDir = await resolveRealIntendedPath(resolvedAgentDir)
45
- const realTargetPath = await resolveRealIntendedPath(targetPath)
46
-
47
- if (path.dirname(realTargetPath) !== realAgentDir) return undefined
48
-
49
- const basename = path.basename(realTargetPath)
50
- return isManagedFile(basename) ? { file: basename } : undefined
51
- }
52
-
53
- function isManagedFile(basename: string): basename is ManagedFile {
54
- return MANAGED_FILES.has(basename as ManagedFile)
58
+ const resolvedTarget = path.resolve(targetPath)
59
+ for (const file of MANAGED_FILES) {
60
+ const canonical = path.join(resolvedAgentDir, file)
61
+ if (canonical === resolvedTarget) return { file }
62
+ const realCanonical = await resolveRealIntendedPath(canonical)
63
+ const realTarget = await resolveRealIntendedPath(resolvedTarget)
64
+ if (realCanonical === realTarget) return { file }
65
+ }
66
+ return undefined
55
67
  }
56
68
 
57
69
  function validateManagedContent(file: ManagedFile, content: string): { ok: true } | { ok: false; reason: string } {
@@ -81,6 +93,20 @@ async function intendedContent(
81
93
  return blockReason(tool, targetPath, 'edit calls must include an edits array')
82
94
  }
83
95
 
96
+ // Oracle PR #305 finding #4: refuse multi-edit on managed files to
97
+ // avoid simulator-vs-pi divergence. The canonical workflow for
98
+ // typeclaw.json / cron.json is read + modify in memory + write the
99
+ // whole file back; multi-edit is not required and the divergence
100
+ // would let an attacker validate a different final file here than
101
+ // the one pi actually writes.
102
+ if (edits.length > 1) {
103
+ return blockReason(
104
+ tool,
105
+ targetPath,
106
+ 'multi-edit calls on managed files are refused — use `write` with full content instead',
107
+ )
108
+ }
109
+
84
110
  let content: string
85
111
  try {
86
112
  content = await readFile(targetPath, 'utf8')
@@ -100,10 +126,14 @@ async function intendedContent(
100
126
  if (oldText.length === 0) {
101
127
  return blockReason(tool, targetPath, 'edit oldText must not be empty')
102
128
  }
103
- if (!content.includes(oldText)) {
129
+ const firstIdx = content.indexOf(oldText)
130
+ if (firstIdx === -1) {
104
131
  return blockReason(tool, targetPath, 'edit oldText was not found in existing file')
105
132
  }
106
- content = content.replace(oldText, newText)
133
+ if (content.indexOf(oldText, firstIdx + 1) !== -1) {
134
+ return blockReason(tool, targetPath, 'edit oldText is not unique in the existing file')
135
+ }
136
+ content = content.slice(0, firstIdx) + newText + content.slice(firstIdx + oldText.length)
107
137
  }
108
138
  return { content }
109
139
  }
@@ -0,0 +1,37 @@
1
+ import path from 'node:path'
2
+
3
+ import type { SessionOrigin } from '@/agent/session-origin'
4
+ import type { SecuritySeverity } from '@/bundled-plugins/security/permissions'
5
+
6
+ export const GUARD_MEMORY_RETRIEVAL_CACHE_WRITE = 'memoryRetrievalCacheWrite'
7
+ export const GUARD_MEMORY_RETRIEVAL_CACHE_WRITE_SEVERITY: SecuritySeverity = 'low'
8
+
9
+ const SESSION_ID_REGEX = /^[A-Za-z0-9._-]{1,128}$/
10
+
11
+ export async function isMemoryRetrievalCacheWriteAllowed(options: {
12
+ tool: string
13
+ args: Record<string, unknown>
14
+ agentDir: string
15
+ origin?: SessionOrigin
16
+ }): Promise<boolean> {
17
+ const { tool, args, agentDir, origin } = options
18
+ if (tool !== 'write') return false
19
+ if (origin?.kind !== 'subagent' || origin.subagent !== 'memory-retrieval') return false
20
+
21
+ const rawPath = args.path
22
+ if (typeof rawPath !== 'string') return false
23
+
24
+ const targetPath = path.resolve(agentDir, rawPath)
25
+ const expectedDir = path.resolve(agentDir, 'memory', '.retrieval-cache')
26
+ const relative = path.relative(expectedDir, targetPath)
27
+ if (relative === '' || relative.startsWith('..') || path.isAbsolute(relative)) return false
28
+
29
+ const parts = relative.split(path.sep).filter(Boolean)
30
+ if (parts.length !== 1) return false
31
+
32
+ const fileName = parts[0]!
33
+ if (!fileName.endsWith('.md')) return false
34
+
35
+ const sessionId = fileName.slice(0, -3)
36
+ return SESSION_ID_REGEX.test(sessionId)
37
+ }
@@ -0,0 +1,67 @@
1
+ import path from 'node:path'
2
+
3
+ import type { SessionOrigin } from '@/agent/session-origin'
4
+ import { SLUG_REGEX } from '@/bundled-plugins/memory/slug'
5
+ import type { SecuritySeverity } from '@/bundled-plugins/security/permissions'
6
+
7
+ import type { GuardBlock } from '../policy'
8
+
9
+ export const GUARD_MEMORY_TOPICS_DELETE = 'memoryTopicsDelete'
10
+
11
+ export const GUARD_MEMORY_TOPICS_DELETE_SEVERITY: SecuritySeverity = 'medium'
12
+
13
+ export function checkMemoryTopicsDeleteGuard(options: {
14
+ tool: string
15
+ args: Record<string, unknown>
16
+ agentDir: string
17
+ origin?: SessionOrigin
18
+ }): GuardBlock | undefined {
19
+ const { tool, args, agentDir, origin } = options
20
+
21
+ if (tool !== 'delete_topic_shard') return undefined
22
+
23
+ const rawPath = args.path
24
+ if (typeof rawPath !== 'string') {
25
+ return block(tool, 'path argument must be a string')
26
+ }
27
+
28
+ if (origin?.kind !== 'subagent' || origin.subagent !== 'dreaming') {
29
+ return block(tool, 'only the dreaming subagent may delete topic shards')
30
+ }
31
+
32
+ if (rawPath.includes('\\')) {
33
+ return block(tool, 'path must use POSIX separators under memory/topics/')
34
+ }
35
+
36
+ const targetPath = path.resolve(agentDir, rawPath)
37
+ const topicsDir = path.resolve(agentDir, 'memory', 'topics')
38
+ const relative = path.relative(topicsDir, targetPath)
39
+
40
+ if (relative === '' || relative.startsWith('..') || path.isAbsolute(relative)) {
41
+ return block(tool, `path must be a direct child of memory/topics/: ${targetPath}`)
42
+ }
43
+
44
+ const parts = relative.split(path.sep).filter(Boolean)
45
+ if (parts.length !== 1) {
46
+ return block(tool, `path must be a single .md file inside memory/topics/: ${targetPath}`)
47
+ }
48
+
49
+ const fileName = parts[0]!
50
+ if (!fileName.endsWith('.md')) {
51
+ return block(tool, `path must be a single .md file inside memory/topics/: ${targetPath}`)
52
+ }
53
+
54
+ const slug = fileName.slice(0, -3)
55
+ if (!SLUG_REGEX.test(slug)) {
56
+ return block(tool, `slug must match ${SLUG_REGEX}: ${slug}`)
57
+ }
58
+
59
+ return undefined
60
+ }
61
+
62
+ function block(tool: string, reason: string): GuardBlock {
63
+ return {
64
+ block: true,
65
+ reason: `Guard \`${GUARD_MEMORY_TOPICS_DELETE}\` blocked ${tool}: ${reason}.`,
66
+ }
67
+ }
@@ -0,0 +1,33 @@
1
+ import path from 'node:path'
2
+
3
+ import type { SessionOrigin } from '@/agent/session-origin'
4
+ import { SLUG_REGEX } from '@/bundled-plugins/memory/slug'
5
+
6
+ export async function isMemoryTopicsWriteAllowed(options: {
7
+ tool: string
8
+ args: Record<string, unknown>
9
+ agentDir: string
10
+ origin?: SessionOrigin
11
+ }): Promise<boolean> {
12
+ if (options.tool !== 'write') return false
13
+
14
+ const { origin } = options
15
+ if (!origin || origin.kind !== 'subagent' || origin.subagent !== 'dreaming') return false
16
+
17
+ const rawPath = options.args.path
18
+ if (typeof rawPath !== 'string') return false
19
+
20
+ const target = path.resolve(options.agentDir, rawPath)
21
+ const expectedDir = path.resolve(options.agentDir, 'memory', 'topics')
22
+ const rel = path.relative(expectedDir, target)
23
+ if (rel === '' || rel.startsWith('..') || path.isAbsolute(rel)) return false
24
+
25
+ const parts = rel.split(path.sep).filter(Boolean)
26
+ const fileName = parts[0]
27
+ if (parts.length !== 1 || !fileName || !fileName.endsWith('.md')) return false
28
+
29
+ const slug = fileName.slice(0, -3)
30
+ if (!SLUG_REGEX.test(slug)) return false
31
+
32
+ return true
33
+ }
@@ -1,7 +1,11 @@
1
1
  import { realpath } from 'node:fs/promises'
2
2
  import path from 'node:path'
3
3
 
4
+ import type { SessionOrigin } from '@/agent/session-origin'
5
+
4
6
  import { ACKNOWLEDGE_GUARDS, type GuardBlock, isGuardAcknowledged } from '../policy'
7
+ import { isMemoryRetrievalCacheWriteAllowed } from './memory-retrieval-cache-write'
8
+ import { isMemoryTopicsWriteAllowed } from './memory-topics-write'
5
9
  import { isSkillAuthoringAllowed } from './skill-authoring'
6
10
 
7
11
  export const GUARD_NON_WORKSPACE_WRITE = 'nonWorkspaceWrite'
@@ -9,7 +13,6 @@ export const GUARD_NON_WORKSPACE_WRITE = 'nonWorkspaceWrite'
9
13
  const AGENT_ROOT_WRITE_ALLOWLIST = new Set([
10
14
  'AGENTS.md',
11
15
  'IDENTITY.md',
12
- 'MEMORY.md',
13
16
  'SOUL.md',
14
17
  'USER.md',
15
18
  'cron.json',
@@ -28,8 +31,9 @@ export async function checkNonWorkspaceWriteGuard(options: {
28
31
  tool: string
29
32
  args: Record<string, unknown>
30
33
  agentDir: string
34
+ origin?: SessionOrigin
31
35
  }): Promise<GuardBlock | undefined> {
32
- const { tool, args, agentDir } = options
36
+ const { tool, args, agentDir, origin } = options
33
37
  if (tool !== 'write' && tool !== 'edit') return undefined
34
38
 
35
39
  const rawPath = args.path
@@ -42,6 +46,8 @@ export async function checkNonWorkspaceWriteGuard(options: {
42
46
  resolveRealIntendedPath(workspacePath),
43
47
  ])
44
48
  if (await isSkillAuthoringAllowed({ tool, args, agentDir })) return undefined
49
+ if (await isMemoryRetrievalCacheWriteAllowed({ tool, args, agentDir, origin })) return undefined
50
+ if (await isMemoryTopicsWriteAllowed({ tool, args, agentDir, origin })) return undefined
45
51
  if (await isAllowedAgentRootWrite(agentDir, targetPath, realTargetPath)) return undefined
46
52
  if (isInside(realWorkspacePath, realTargetPath)) return undefined
47
53
  if (isGuardAcknowledged(args, GUARD_NON_WORKSPACE_WRITE)) return undefined
@@ -16,4 +16,11 @@ export {
16
16
  checkSkillAuthoringGuard,
17
17
  isSkillAuthoringAllowed,
18
18
  } from './policies/skill-authoring'
19
+ export { GUARD_MEMORY_TOPICS_DELETE, checkMemoryTopicsDeleteGuard } from './policies/memory-topics-delete'
20
+ export {
21
+ GUARD_MEMORY_RETRIEVAL_CACHE_WRITE,
22
+ GUARD_MEMORY_RETRIEVAL_CACHE_WRITE_SEVERITY,
23
+ isMemoryRetrievalCacheWriteAllowed,
24
+ } from './policies/memory-retrieval-cache-write'
25
+ export { isMemoryTopicsWriteAllowed } from './policies/memory-topics-write'
19
26
  export { GUARD_UNCOMMITTED_CHANGES, checkUncommittedChangesAdvice } from './policies/uncommitted-changes'