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.
- package/README.md +6 -6
- package/package.json +5 -3
- package/scripts/require-parallel.ts +41 -0
- package/src/agent/index.ts +55 -6
- package/src/agent/live-sessions.ts +34 -0
- package/src/agent/plugin-tools.ts +2 -0
- package/src/agent/session-meta.ts +21 -2
- package/src/agent/subagent-completion-reminder.ts +89 -0
- package/src/agent/subagents.ts +75 -15
- package/src/agent/system-prompt.ts +10 -8
- package/src/agent/tools/channel-reply.ts +47 -7
- package/src/agent/tools/channel-send.ts +43 -11
- package/src/agent/tools/runtime-notice.ts +41 -0
- package/src/bundled-plugins/explorer/explorer.ts +2 -2
- package/src/bundled-plugins/guard/index.ts +14 -1
- package/src/bundled-plugins/guard/policies/managed-config.ts +43 -13
- package/src/bundled-plugins/guard/policies/memory-retrieval-cache-write.ts +37 -0
- package/src/bundled-plugins/guard/policies/memory-topics-delete.ts +67 -0
- package/src/bundled-plugins/guard/policies/memory-topics-write.ts +33 -0
- package/src/bundled-plugins/guard/policies/non-workspace-write.ts +8 -2
- package/src/bundled-plugins/guard/policy.ts +7 -0
- package/src/bundled-plugins/memory/README.md +76 -62
- package/src/bundled-plugins/memory/append-tool.ts +3 -2
- package/src/bundled-plugins/memory/citation-superset.ts +49 -11
- package/src/bundled-plugins/memory/citations.ts +19 -8
- package/src/bundled-plugins/memory/delete-tool.ts +57 -0
- package/src/bundled-plugins/memory/dreaming-state.ts +1 -1
- package/src/bundled-plugins/memory/dreaming.ts +364 -146
- package/src/bundled-plugins/memory/frontmatter.ts +165 -0
- package/src/bundled-plugins/memory/index.ts +257 -16
- package/src/bundled-plugins/memory/injection-plan.ts +15 -0
- package/src/bundled-plugins/memory/load-memory.ts +102 -103
- package/src/bundled-plugins/memory/load-shards.ts +156 -0
- package/src/bundled-plugins/memory/memory-logger.ts +16 -15
- package/src/bundled-plugins/memory/memory-retrieval.ts +111 -0
- package/src/bundled-plugins/memory/migration.ts +353 -1
- package/src/bundled-plugins/memory/paths.ts +42 -0
- package/src/bundled-plugins/memory/search-tool.ts +232 -0
- package/src/bundled-plugins/memory/secret-detector.ts +2 -2
- package/src/bundled-plugins/memory/shard-snapshot.ts +51 -0
- package/src/bundled-plugins/memory/slug.ts +59 -0
- package/src/bundled-plugins/memory/stream-io.ts +110 -1
- package/src/bundled-plugins/memory/strength.ts +3 -3
- package/src/bundled-plugins/memory/topics.ts +70 -16
- package/src/bundled-plugins/security/index.ts +24 -0
- package/src/bundled-plugins/security/permissions.ts +4 -0
- package/src/bundled-plugins/security/policies/cron-promotion.ts +349 -0
- package/src/bundled-plugins/security/policies/git-exfil.ts +2 -0
- package/src/bundled-plugins/security/policies/prompt-injection.ts +3 -0
- package/src/bundled-plugins/security/policies/role-promotion.ts +419 -0
- package/src/bundled-plugins/security/policies/system-prompt-leak.ts +1 -0
- package/src/channels/adapters/kakaotalk-attachment.ts +7 -17
- package/src/channels/adapters/kakaotalk-classify.ts +4 -1
- package/src/channels/adapters/kakaotalk.ts +65 -38
- package/src/channels/adapters/slack-bot-classify.ts +2 -27
- package/src/channels/index.ts +5 -0
- package/src/channels/router.ts +320 -22
- package/src/channels/subagent-completion-bridge.ts +84 -0
- package/src/cli/builtins.ts +1 -0
- package/src/cli/index.ts +1 -0
- package/src/cli/init.ts +122 -14
- package/src/cli/inspect.ts +151 -0
- package/src/cron/consumer.ts +1 -1
- package/src/init/dockerfile.ts +268 -4
- package/src/init/hatching.ts +5 -6
- package/src/init/kakaotalk-auth.ts +6 -47
- package/src/init/validate-api-key.ts +121 -0
- package/src/inspect/index.ts +213 -0
- package/src/inspect/label.ts +50 -0
- package/src/inspect/live.ts +221 -0
- package/src/inspect/render.ts +163 -0
- package/src/inspect/replay.ts +295 -0
- package/src/inspect/session-list.ts +160 -0
- package/src/inspect/types.ts +110 -0
- package/src/plugin/hooks.ts +23 -1
- package/src/plugin/index.ts +2 -0
- package/src/plugin/manager.ts +1 -1
- package/src/plugin/registry.ts +1 -1
- package/src/plugin/types.ts +10 -0
- package/src/run/channel-session-factory.ts +7 -1
- package/src/run/index.ts +103 -21
- package/src/secrets/kakao-renewal.ts +3 -47
- package/src/server/index.ts +241 -60
- package/src/shared/index.ts +3 -0
- package/src/shared/protocol.ts +49 -0
- package/src/skills/typeclaw-channel-kakaotalk/SKILL.md +9 -9
- package/src/skills/typeclaw-claude-code/SKILL.md +57 -39
- package/src/skills/typeclaw-claude-code/references/stop-hook.md +2 -0
- package/src/skills/typeclaw-claude-code/references/tmux-driving.md +102 -16
- package/src/skills/typeclaw-config/SKILL.md +1 -1
- package/src/skills/typeclaw-cron/SKILL.md +1 -1
- package/src/skills/typeclaw-memory/SKILL.md +16 -163
- package/src/skills/typeclaw-permissions/SKILL.md +2 -2
- package/src/skills/typeclaw-plugins/SKILL.md +25 -14
- package/src/test-helpers/wait-for.ts +7 -1
- 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 {
|
|
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:
|
|
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
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
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 {
|
|
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
|
|
181
|
+
const body = hints.length > 0 ? `${baseText}${hints.join('')}` : baseText
|
|
167
182
|
return {
|
|
168
|
-
content: [{ type: 'text' as const, text:
|
|
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
|
-
|
|
197
|
-
|
|
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
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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
|
|
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/
|
|
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
|
-
|
|
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
|
|
45
|
-
const
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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'
|