typeclaw 0.4.0 → 0.5.0
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/package.json +1 -1
- package/src/agent/auth.ts +4 -2
- package/src/agent/index.ts +16 -28
- package/src/agent/model-fallback.ts +127 -0
- package/src/agent/tools/curl-impersonate.ts +300 -0
- package/src/agent/tools/ddg.ts +13 -88
- package/src/agent/tools/webfetch/fetch.ts +105 -2
- package/src/agent/tools/webfetch/tool.ts +4 -0
- package/src/bundled-plugins/agent-browser/shim.ts +47 -0
- package/src/bundled-plugins/backup/subagents.ts +2 -0
- package/src/bundled-plugins/memory/README.md +49 -12
- package/src/bundled-plugins/memory/citation-superset.ts +63 -0
- package/src/bundled-plugins/memory/dreaming.ts +105 -17
- package/src/bundled-plugins/memory/index.ts +2 -2
- package/src/bundled-plugins/memory/memory-logger.ts +45 -26
- package/src/bundled-plugins/memory/strength.ts +127 -0
- package/src/bundled-plugins/memory/topics.ts +75 -0
- package/src/bundled-plugins/security/index.ts +87 -43
- package/src/bundled-plugins/security/permissions.ts +36 -0
- package/src/bundled-plugins/security/policies/git-exfil.ts +20 -0
- package/src/bundled-plugins/security/policies/outbound-secret-scan.ts +12 -0
- package/src/bundled-plugins/security/policies/prompt-injection.ts +23 -3
- package/src/bundled-plugins/security/policies/secret-exfil-bash.ts +7 -0
- package/src/bundled-plugins/security/policies/secret-exfil-read.ts +6 -0
- package/src/bundled-plugins/security/policies/session-search-secrets.ts +9 -0
- package/src/bundled-plugins/security/policies/ssrf.ts +6 -0
- package/src/bundled-plugins/security/policies/system-prompt-leak.ts +7 -0
- package/src/channels/adapters/github/index.ts +87 -3
- package/src/channels/router.ts +194 -28
- package/src/channels/types.ts +3 -1
- package/src/cli/init.ts +146 -42
- package/src/cli/model.ts +10 -2
- package/src/cli/oauth-callbacks.ts +49 -0
- package/src/cli/provider.ts +3 -20
- package/src/config/config.ts +59 -24
- package/src/config/models-mutation.ts +42 -8
- package/src/config/providers-mutation.ts +12 -8
- package/src/container/start.ts +18 -1
- package/src/cron/consumer.ts +129 -43
- package/src/init/dockerfile.ts +109 -3
- package/src/init/hatching.ts +2 -2
- package/src/init/index.ts +14 -3
- package/src/init/oauth-login.ts +17 -3
- package/src/permissions/builtins.ts +29 -7
- package/src/permissions/permissions.ts +24 -7
- package/src/plugin/define.ts +2 -0
- package/src/plugin/manager.ts +14 -0
- package/src/plugin/types.ts +6 -0
- package/src/run/index.ts +2 -1
- package/src/skills/typeclaw-memory/SKILL.md +25 -15
- package/src/skills/typeclaw-permissions/SKILL.md +35 -17
- package/src/tui/index.ts +35 -3
- package/src/usage/report.ts +15 -12
- package/typeclaw.schema.json +57 -25
|
@@ -12,8 +12,8 @@ import { createDreamingSubagent, type DreamingPayload } from './dreaming'
|
|
|
12
12
|
import { createMemoryLoggerSubagent, type MemoryLoggerPayload } from './memory-logger'
|
|
13
13
|
import { runMigration } from './migration'
|
|
14
14
|
|
|
15
|
-
const DEFAULT_IDLE_MS =
|
|
16
|
-
const DEFAULT_BUFFER_BYTES =
|
|
15
|
+
const DEFAULT_IDLE_MS = 60_000
|
|
16
|
+
const DEFAULT_BUFFER_BYTES = 500_000
|
|
17
17
|
const MIN_BUFFER_BYTES = 10_000
|
|
18
18
|
// 30-minute default. Fires short-circuit before any LLM call when nothing
|
|
19
19
|
// sits past the watermark (`dreaming.ts` handler returns when
|
|
@@ -58,9 +58,9 @@ export function isMemoryLoggerPayload(value: unknown): value is MemoryLoggerPayl
|
|
|
58
58
|
|
|
59
59
|
export const MEMORY_LOGGER_SYSTEM_PROMPT = `You are typeclaw's memory-extraction subagent.
|
|
60
60
|
|
|
61
|
-
Your job is to read a session transcript and capture, as fragments,
|
|
61
|
+
Your job is to read a session transcript and capture, as fragments, only the durable operational facts a future agent in a future session would concretely need — explicit user instructions, stable identity/role/tool facts, decisions with reasoning, reproducible workarounds, contradictions or violations of existing memory. You write zero or more fragments to today's memory stream file. Then you exit. Most runs produce zero or one fragment; that is the expected output, not a failure.
|
|
62
62
|
|
|
63
|
-
A separate \`dreaming\` subagent runs later. It consolidates your fragments into long-term memory, dedupes, drops near-duplicates, resolves contradictions, and decides what generalizes. **
|
|
63
|
+
A separate \`dreaming\` subagent runs later. It consolidates your fragments into long-term memory, dedupes, drops near-duplicates, resolves contradictions, and decides what generalizes. **Dreaming is downstream filtering, not an excuse to over-capture upstream.** Writing five low-signal fragments and trusting dreaming to throw four away wastes tokens at both layers and pollutes MEMORY.md in the interim. Be selective here.
|
|
64
64
|
|
|
65
65
|
You have exactly four tools: \`read\`, \`find_entry\`, \`append\`, and the watermark-advance tool. You cannot run shell commands, overwrite files, or edit existing content.
|
|
66
66
|
|
|
@@ -78,41 +78,52 @@ Typical flow with a watermark:
|
|
|
78
78
|
|
|
79
79
|
Never write the same watermark id you were given as input. If the transcript has no new entries past the watermark, evaluate the entries you can see, then advance the watermark to the latest \`id\` in the transcript (which is on line \`totalLines\` from \`find_entry\`'s reply). The whole point of the watermark is to move forward each run.
|
|
80
80
|
|
|
81
|
-
# Capture philosophy: when in doubt,
|
|
81
|
+
# Capture philosophy: when in doubt, SKIP
|
|
82
82
|
|
|
83
|
-
|
|
83
|
+
Most transcript content is **not** memorable. Conversations, group chat banter, casual reactions, one-off questions, and routine tool usage are the substrate of a session — they are not facts a future agent needs to inherit. The default is to skip.
|
|
84
84
|
|
|
85
|
-
|
|
85
|
+
Most runs should produce **zero or one** fragment. Two or more fragments is the exception, justified only when the transcript actually contains multiple unrelated durable facts. A run that produces five-plus fragments is almost always over-writing.
|
|
86
86
|
|
|
87
|
-
|
|
87
|
+
The watermark advances even with zero fragments via the watermark-advance tool, so skipping costs nothing. A wrong-skip is recoverable: if the same fact recurs in a later session, you will see it again and can capture it then — recurrence is itself the strongest signal that something is worth remembering.
|
|
88
|
+
|
|
89
|
+
You do **not** need to articulate how a future agent will use a fragment. But you DO need to be able to name a concrete future situation where ignoring this fragment would cause a real problem. If you cannot name that situation in one sentence, skip.
|
|
88
90
|
|
|
89
91
|
The two failure modes:
|
|
90
92
|
|
|
91
|
-
- **
|
|
92
|
-
- **
|
|
93
|
+
- **Over-writing into noise.** Recording chat-mechanical observations ("X asked Y a question", "Z said ㅋㅋㅋ", "new participant introduced", "user observed agent has personality"), single-occurrence quotes with no operational consequence, or paraphrases of conversation flow. This is the dominant failure mode in practice. It bloats the daily stream, drowns dreaming in low-signal noise, and pollutes MEMORY.md.
|
|
94
|
+
- **Under-writing.** Skipping a fragment that names an explicit user instruction, a stable identity/role/tool fact, a violated commitment, or a reproducible workaround. Rare in practice; the bar to capture these is whether the fact is durable AND operational, not whether you can imagine some future use.
|
|
93
95
|
|
|
94
|
-
|
|
96
|
+
When unsure, skip. Recurrence will surface real patterns.
|
|
95
97
|
|
|
96
98
|
# What to capture
|
|
97
99
|
|
|
98
|
-
|
|
100
|
+
The bar is high. A fragment is worth writing only when ALL of these hold:
|
|
101
|
+
|
|
102
|
+
1. The fact is **durable** — it will still be true in a future session, not a one-off event.
|
|
103
|
+
2. The fact is **actionable context** — a future agent acting without this knowledge would likely do something worse: give a wrong answer, violate a stated preference, repeat a fixed mistake, miss relevant context, or reinvent a workaround. Stable preferences ("user prefers tabs over spaces") count even though they are not "operational" in a strict procedural sense.
|
|
104
|
+
3. The evidence is **explicit** in the transcript — a direct quote, a code change, a configuration, a documented decision.
|
|
105
|
+
|
|
106
|
+
Capture-worthy categories:
|
|
99
107
|
|
|
100
|
-
- **
|
|
101
|
-
- **
|
|
102
|
-
- **
|
|
103
|
-
- **
|
|
104
|
-
- **Contradictions of existing memory.** The user changed their mind,
|
|
105
|
-
- **Violations of existing memory.**
|
|
106
|
-
- **
|
|
107
|
-
- **Observable user reactions, framed as observations.** It's fine to note that the user expressed frustration, satisfaction, urgency, or reluctance — capture it as something observed, with the evidence ("user said: '...'"). Don't claim to know motives; just record what was visible. Dreaming decides if a pattern is real.
|
|
108
|
-
- **Reusable knowledge produced this session.** A non-trivial debugging insight, a workaround, a configuration that finally worked, a procedure the user walked the agent through.
|
|
108
|
+
- **Explicit operating rules the user just gave the agent.** "Always X." "Never Y." "From now on do Z." Direct instructions to the agent itself, not statements about other people.
|
|
109
|
+
- **Stable identity/role/tool facts that will keep mattering.** "User's project repo is X." "User runs Y on Z." Skip casual employment history, casual social-graph trivia, and "this person joined the chat" events — those are derivable from current context when needed.
|
|
110
|
+
- **Decisions with reasoning.** "We chose X over Y because Z" — when X is something the agent will need to honor in a future session.
|
|
111
|
+
- **Reproducible workarounds and non-trivial debugging insights.** Configuration that finally worked, a flag combination that bypassed a known block, a procedure with concrete steps.
|
|
112
|
+
- **Contradictions of existing memory.** The user changed their mind, an old commitment no longer applies. Name the prior memory that is superseded.
|
|
113
|
+
- **Violations of existing memory.** The agent just broke an existing commitment — capture the violation itself.
|
|
114
|
+
- **Corrections the user made to the agent.** Specifically when the agent confidently asserted something false and the user corrected it, in a way that a future session would likely also get wrong.
|
|
109
115
|
|
|
110
|
-
# What to skip
|
|
116
|
+
# What to skip (anti-patterns — these come up constantly)
|
|
111
117
|
|
|
112
|
-
- **
|
|
113
|
-
- **
|
|
114
|
-
- **
|
|
115
|
-
- **
|
|
118
|
+
- **Conversational mechanics.** "X asked Y a question." "Z said hello." "Participant A reacted with ㅋㅋㅋ / 👍 / lol." "User tested the agent's response time." None of this is memory.
|
|
119
|
+
- **Single-occurrence casual reactions.** "User observed the agent has personality." "Group chat member is amused by the bot." Wait for recurrence; if it never recurs, it was never memory.
|
|
120
|
+
- **Group-chat membership events.** "X invited Y to chat Z." "New participant joined." This is derivable from the current channel context and changes constantly.
|
|
121
|
+
- **Casual social-graph trivia.** "X used to work at Y." "Z is a friend of W." Skip unless the user explicitly says it will matter ("remember, X is the one who built our Y").
|
|
122
|
+
- **Latency / performance pings.** "User asked how fast the agent responded." Not memory.
|
|
123
|
+
- **The agent's own first-person observations.** "The agent admitted it does not know its model." "The agent replied in character." Skip — the agent is not memorable to itself.
|
|
124
|
+
- **Re-derivable facts.** Anything obvious from the current session's system prompt, MEMORY.md, AGENTS.md, or the channel context.
|
|
125
|
+
- **Speculation untethered to a quote.** If you cannot point at a specific transcript line, do not write it.
|
|
126
|
+
- **Multi-fragment expansions of one event.** One event produces at most one fragment. Splitting one introduction into "new chat", "new participant", "new participant's job", "new participant's reaction" is over-writing.
|
|
116
127
|
|
|
117
128
|
# Never quote secret values
|
|
118
129
|
|
|
@@ -135,7 +146,7 @@ Before reading the transcript, read \`MEMORY.md\` and the current \`memory/yyyy-
|
|
|
135
146
|
- **Notice violations.** If existing memory contains a commitment the agent just broke, that's a high-value fragment.
|
|
136
147
|
- **Avoid pure restatement.** If a fact is already in MEMORY.md word-for-word, don't write the same fragment again. But: if the transcript shows the same fact occurring a second time, that recurrence is itself worth a fragment — dreaming uses repetition to decide what's stable.
|
|
137
148
|
|
|
138
|
-
|
|
149
|
+
Dedup byte-equivalent restatements, not meaningful recurrence. Do not write a fragment that is a near-copy of one already in MEMORY.md or today's stream. But when the transcript shows the same durable preference, pattern, workaround, or commitment recurring in a NEW session or on a NEW day, write a concise recurrence fragment anchored to the new evidence — even if the underlying fact is already known. The dreaming subagent uses distinct-day recurrence to promote tentative facts to confident ones; refusing to write the second or third occurrence starves that signal. The bar is "did the recurrence happen in a meaningfully new context", not "is the fact already on disk".
|
|
139
150
|
|
|
140
151
|
The \`append\` tool refuses byte-equivalent fragments within the same daily stream — if your fragment's topic+body is identical to one already in today's file (modulo whitespace), the tool will reject it and you must rewrite. Two reasonable rewrites: (1) skip the fragment entirely, (2) frame the new occurrence explicitly as "this is the second time today" with a different topic. Do not retry an identical fragment with a different \`entry=\` hoping it will land — content-equality, not marker-equality, is what's checked.
|
|
141
152
|
|
|
@@ -269,8 +280,16 @@ export function createMemoryLoggerSubagent(
|
|
|
269
280
|
customTools: [findEntryTool, appendTool, advanceWatermarkTool],
|
|
270
281
|
payloadSchema: memoryLoggerPayloadSchema,
|
|
271
282
|
inFlightKey: (payload) => payload.agentDir,
|
|
283
|
+
// 768 KB read budget. Sized to cover one full buffer-trip cycle:
|
|
284
|
+
// ~30 KB MEMORY.md + ~50 KB today's stream + up to `DEFAULT_BUFFER_BYTES`
|
|
285
|
+
// (500 KB) of unread transcript chunk, with margin for re-reads. A
|
|
286
|
+
// smaller budget (the prior 256 KB) systematically exhausted on
|
|
287
|
+
// buffer-trip spawns once `bufferBytes` exceeded ~200 KB — the
|
|
288
|
+
// subagent would advance `bytesAtLastRun` to the full transcript size
|
|
289
|
+
// on completion, orphaning the unread tail until another full
|
|
290
|
+
// `bufferBytes` of growth arrived.
|
|
272
291
|
toolResultBudget: {
|
|
273
|
-
maxTotalBytes:
|
|
292
|
+
maxTotalBytes: 768 * 1024,
|
|
274
293
|
toolNames: ['read'],
|
|
275
294
|
exhaustedMessage: memoryLoggerExhaustedMessage,
|
|
276
295
|
},
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// Strength signals for MEMORY.md topics, derived mechanically from citations.
|
|
2
|
+
//
|
|
3
|
+
// What "strength" means here is structural, not semantic — we measure how
|
|
4
|
+
// many times and over how many distinct days a topic has been reinforced by
|
|
5
|
+
// observation fragments. The reasoning lives in dreaming.ts's system prompt;
|
|
6
|
+
// this file only produces the numbers the prompt will reference.
|
|
7
|
+
//
|
|
8
|
+
// Why distinct days matters more than raw citation count: five fragments on
|
|
9
|
+
// one day == one debugging session that mentioned the same thing five times
|
|
10
|
+
// (a transient burst). Five fragments across five days == a recurring fact
|
|
11
|
+
// the user keeps coming back to (a stable signal). The promotion ladder in
|
|
12
|
+
// the dreaming subagent's prompt is gated on distinct-days, not count, for
|
|
13
|
+
// exactly this reason — see the "spacing effect" note in the PR description.
|
|
14
|
+
//
|
|
15
|
+
// All numbers here are deterministic. The same MEMORY.md parsed against the
|
|
16
|
+
// same `today` always yields the same TopicStrength list. There is no LLM
|
|
17
|
+
// involvement at this layer; the subagent receives these numbers as ground
|
|
18
|
+
// truth and uses them to decide what to merge or demote.
|
|
19
|
+
|
|
20
|
+
import { parseTopics, type Topic } from './topics'
|
|
21
|
+
|
|
22
|
+
export type TopicStrength = {
|
|
23
|
+
heading: string
|
|
24
|
+
citationCount: number
|
|
25
|
+
distinctDays: number
|
|
26
|
+
// ISO date (yyyy-MM-dd) of the most recent citation, or null when the
|
|
27
|
+
// topic has zero citations. Null is distinct from "very old": a topic with
|
|
28
|
+
// no citations at all is a different shape than one whose last citation
|
|
29
|
+
// was a year ago, and the subagent should treat them differently (the
|
|
30
|
+
// former is a typo or a manual edit; the latter is a decayed-but-real
|
|
31
|
+
// topic).
|
|
32
|
+
lastReinforcedDate: string | null
|
|
33
|
+
// Whole-day delta from today to lastReinforcedDate. Null when
|
|
34
|
+
// lastReinforcedDate is null. Negative values are clamped to 0 (a citation
|
|
35
|
+
// dated in the future is treated as "today" — the only way this happens
|
|
36
|
+
// is a clock skew between memory-logger and the dreaming run, and the
|
|
37
|
+
// subagent shouldn't be punished for the runtime's confusion).
|
|
38
|
+
daysSinceLastReinforced: number | null
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function computeTopicStrengths(memoryText: string, today: string): TopicStrength[] {
|
|
42
|
+
const topics = parseTopics(memoryText)
|
|
43
|
+
return topics.map((topic) => computeOneTopicStrength(topic, today))
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function computeOneTopicStrength(topic: Topic, today: string): TopicStrength {
|
|
47
|
+
const citationCount = topic.citations.length
|
|
48
|
+
const distinctDates = new Set(topic.citations.map((c) => c.date))
|
|
49
|
+
const distinctDays = distinctDates.size
|
|
50
|
+
const lastReinforcedDate = pickLatestDate([...distinctDates])
|
|
51
|
+
const daysSinceLastReinforced = lastReinforcedDate ? daysBetween(today, lastReinforcedDate) : null
|
|
52
|
+
return {
|
|
53
|
+
heading: topic.heading,
|
|
54
|
+
citationCount,
|
|
55
|
+
distinctDays,
|
|
56
|
+
lastReinforcedDate,
|
|
57
|
+
daysSinceLastReinforced,
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function pickLatestDate(dates: readonly string[]): string | null {
|
|
62
|
+
if (dates.length === 0) return null
|
|
63
|
+
let latest = dates[0]!
|
|
64
|
+
for (let i = 1; i < dates.length; i++) {
|
|
65
|
+
const candidate = dates[i]!
|
|
66
|
+
if (candidate.localeCompare(latest) > 0) latest = candidate
|
|
67
|
+
}
|
|
68
|
+
return latest
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Whole-day delta in UTC between two yyyy-MM-dd strings. Date.UTC parses each
|
|
72
|
+
// date as midnight UTC, so the difference is always an integer count of
|
|
73
|
+
// 86_400_000ms windows regardless of timezone or DST. Returns 0 for invalid
|
|
74
|
+
// inputs (treats the topic as "fresh" rather than throwing — defensive
|
|
75
|
+
// because both inputs are produced by the runtime, but a corrupted MEMORY.md
|
|
76
|
+
// citation date is the kind of thing we want to fail open on).
|
|
77
|
+
function daysBetween(today: string, earlier: string): number {
|
|
78
|
+
const todayMs = parseIsoDateUtc(today)
|
|
79
|
+
const earlierMs = parseIsoDateUtc(earlier)
|
|
80
|
+
if (todayMs === null || earlierMs === null) return 0
|
|
81
|
+
const deltaDays = Math.floor((todayMs - earlierMs) / 86_400_000)
|
|
82
|
+
return deltaDays < 0 ? 0 : deltaDays
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function parseIsoDateUtc(date: string): number | null {
|
|
86
|
+
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(date)
|
|
87
|
+
if (!match) return null
|
|
88
|
+
const year = Number.parseInt(match[1]!, 10)
|
|
89
|
+
const month = Number.parseInt(match[2]!, 10)
|
|
90
|
+
const day = Number.parseInt(match[3]!, 10)
|
|
91
|
+
const ms = Date.UTC(year, month - 1, day)
|
|
92
|
+
return Number.isFinite(ms) ? ms : null
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Render the strength signals as a markdown table the dreaming subagent can
|
|
96
|
+
// read at the top of its user prompt. Returns an empty string when the
|
|
97
|
+
// topic list is empty so the caller can prepend it unconditionally.
|
|
98
|
+
//
|
|
99
|
+
// Column choices: heading first because it's the human-recognizable handle;
|
|
100
|
+
// `cites` and `days` are short enough to align nicely; `last` carries the
|
|
101
|
+
// date itself so the subagent can compare to today without re-doing the
|
|
102
|
+
// arithmetic. Headings are truncated to keep the table readable when a
|
|
103
|
+
// topic was given a long sentence-shaped heading — the citation count is
|
|
104
|
+
// still accurate, only the display label is shortened.
|
|
105
|
+
export function renderTopicStrengthsTable(strengths: readonly TopicStrength[]): string {
|
|
106
|
+
if (strengths.length === 0) return ''
|
|
107
|
+
const rows = strengths.map((s) => ({
|
|
108
|
+
heading: truncateHeading(s.heading || '(untitled)'),
|
|
109
|
+
cites: String(s.citationCount),
|
|
110
|
+
days: String(s.distinctDays),
|
|
111
|
+
last: s.lastReinforcedDate ?? '—',
|
|
112
|
+
ageDays: s.daysSinceLastReinforced === null ? '—' : String(s.daysSinceLastReinforced),
|
|
113
|
+
}))
|
|
114
|
+
const lines = ['| topic | cites | days | last reinforced | age (d) |', '| --- | ---: | ---: | --- | ---: |']
|
|
115
|
+
for (const row of rows) {
|
|
116
|
+
lines.push(`| ${row.heading} | ${row.cites} | ${row.days} | ${row.last} | ${row.ageDays} |`)
|
|
117
|
+
}
|
|
118
|
+
return lines.join('\n')
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const HEADING_MAX_CHARS = 60
|
|
122
|
+
|
|
123
|
+
function truncateHeading(heading: string): string {
|
|
124
|
+
const escaped = heading.replace(/\|/g, '\\|')
|
|
125
|
+
if (escaped.length <= HEADING_MAX_CHARS) return escaped
|
|
126
|
+
return `${escaped.slice(0, HEADING_MAX_CHARS - 1)}…`
|
|
127
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// Topic-aware parser for MEMORY.md. The dreaming subagent writes MEMORY.md as
|
|
2
|
+
// a flat list of level-2 topic headings (`## <topic>`), each followed by a
|
|
3
|
+
// conclusion paragraph and a `fragments:` bullet list of citations. The
|
|
4
|
+
// citation parser in citations.ts is global (every citation in the file);
|
|
5
|
+
// this module attributes citations to their owning topic so the dreaming
|
|
6
|
+
// subagent can see per-topic strength signals (citation count, distinct
|
|
7
|
+
// reinforcement days, recency) on its next run.
|
|
8
|
+
//
|
|
9
|
+
// Format assumptions match what dreaming.ts's DREAMING_SYSTEM_PROMPT teaches:
|
|
10
|
+
// - First line is `# Memory` (an h1). Treated as a non-topic header.
|
|
11
|
+
// - Topics are h2s (`## <topic>`). Anything below an h2 and above the next
|
|
12
|
+
// h2 (or EOF) belongs to that topic.
|
|
13
|
+
// - Citations in a topic's body — wherever they appear, bullet-list or
|
|
14
|
+
// inline prose — count toward that topic's strength.
|
|
15
|
+
// - Content above the first h2 (e.g. preamble after `# Memory`) is
|
|
16
|
+
// attributed to no topic and its citations are dropped from the per-topic
|
|
17
|
+
// aggregation. parseCitations from citations.ts still picks them up if
|
|
18
|
+
// anything downstream needs the global view.
|
|
19
|
+
//
|
|
20
|
+
// The parser is intentionally permissive: it never throws on malformed
|
|
21
|
+
// MEMORY.md. A subagent that writes a header with no body or a topic with no
|
|
22
|
+
// citations still parses cleanly with an empty `citations` array. The
|
|
23
|
+
// strength layer then treats those topics as "weak" — which is the right
|
|
24
|
+
// behavior, since they ARE weak.
|
|
25
|
+
|
|
26
|
+
import { type Citation, parseCitations } from './citations'
|
|
27
|
+
|
|
28
|
+
export type Topic = {
|
|
29
|
+
// The heading text after `## ` with surrounding whitespace trimmed. Empty
|
|
30
|
+
// string is allowed (`## ` with no title) so a malformed write still
|
|
31
|
+
// round-trips through the parser; the strength layer surfaces empty
|
|
32
|
+
// headings as themselves so the subagent can clean them up.
|
|
33
|
+
heading: string
|
|
34
|
+
// Citations attached to this topic, deduplicated per `(date, fragmentId)`.
|
|
35
|
+
// The dedupe happens inside parseCitations (which returns a Set of ids per
|
|
36
|
+
// date), so a fragment cited twice in one topic — once in inline prose,
|
|
37
|
+
// once in the fragments: block — counts only once toward strength signals.
|
|
38
|
+
// Order is by date insertion in parseCitations, not by appearance in the
|
|
39
|
+
// topic body; consumers that need appearance order should re-parse.
|
|
40
|
+
citations: Citation[]
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const HEADING_LEVEL_2 = /^##\s+(.*)$/
|
|
44
|
+
|
|
45
|
+
// Split MEMORY.md into ordered topics with their citations attached. Returns
|
|
46
|
+
// an empty array when no `## ` heading appears.
|
|
47
|
+
export function parseTopics(text: string): Topic[] {
|
|
48
|
+
const lines = text.split('\n')
|
|
49
|
+
const topics: Topic[] = []
|
|
50
|
+
let current: { heading: string; body: string[] } | undefined
|
|
51
|
+
|
|
52
|
+
const flush = (): void => {
|
|
53
|
+
if (!current) return
|
|
54
|
+
const bodyText = current.body.join('\n')
|
|
55
|
+
const grouped = parseCitations(bodyText)
|
|
56
|
+
const citations: Citation[] = []
|
|
57
|
+
for (const [date, ids] of grouped) {
|
|
58
|
+
for (const fragmentId of ids) citations.push({ date, fragmentId })
|
|
59
|
+
}
|
|
60
|
+
topics.push({ heading: current.heading, citations })
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
for (const line of lines) {
|
|
64
|
+
const match = HEADING_LEVEL_2.exec(line)
|
|
65
|
+
if (match) {
|
|
66
|
+
flush()
|
|
67
|
+
current = { heading: (match[1] ?? '').trim(), body: [] }
|
|
68
|
+
continue
|
|
69
|
+
}
|
|
70
|
+
if (current) current.body.push(line)
|
|
71
|
+
}
|
|
72
|
+
flush()
|
|
73
|
+
|
|
74
|
+
return topics
|
|
75
|
+
}
|
|
@@ -1,54 +1,88 @@
|
|
|
1
1
|
import { definePlugin } from '@/plugin'
|
|
2
2
|
|
|
3
|
-
import { SECURITY_PERMISSIONS } from './permissions'
|
|
4
|
-
import type { SecurityPermission } from './permissions'
|
|
5
|
-
import {
|
|
6
|
-
|
|
3
|
+
import { HIGH_TIER_PER_GUARD_PERMISSIONS, SECURITY_PERMISSIONS, SEVERITY_PERMISSION } from './permissions'
|
|
4
|
+
import type { SecurityPermission, SecuritySeverity } from './permissions'
|
|
5
|
+
import {
|
|
6
|
+
GUARD_GIT_EXFIL_SEVERITY,
|
|
7
|
+
GUARD_GIT_REMOTE_TAINTED_SEVERITY,
|
|
8
|
+
checkGitExfilGuard,
|
|
9
|
+
checkGitRemoteTaintedGuard,
|
|
10
|
+
recordGitRemoteTaintIfAny,
|
|
11
|
+
} from './policies/git-exfil'
|
|
12
|
+
import { GUARD_OUTBOUND_SECRET_SEVERITY, checkOutboundSecretGuard } from './policies/outbound-secret-scan'
|
|
7
13
|
import { applyPromptInjectionDefense } from './policies/prompt-injection'
|
|
8
14
|
import { clearSessionTaints } from './policies/remote-taint-state'
|
|
9
|
-
import { checkSecretExfilBashGuard } from './policies/secret-exfil-bash'
|
|
10
|
-
import { checkSecretExfilReadGuard } from './policies/secret-exfil-read'
|
|
11
|
-
import {
|
|
12
|
-
|
|
13
|
-
|
|
15
|
+
import { GUARD_SECRET_EXFIL_BASH_SEVERITY, checkSecretExfilBashGuard } from './policies/secret-exfil-bash'
|
|
16
|
+
import { GUARD_SECRET_EXFIL_READ_SEVERITY, checkSecretExfilReadGuard } from './policies/secret-exfil-read'
|
|
17
|
+
import {
|
|
18
|
+
GUARD_SESSION_SEARCH_SECRETS_SEVERITY,
|
|
19
|
+
checkSessionSearchSecretsGuard,
|
|
20
|
+
} from './policies/session-search-secrets'
|
|
21
|
+
import { GUARD_SSRF_SEVERITY, checkSsrfGuard } from './policies/ssrf'
|
|
22
|
+
import { GUARD_SYSTEM_PROMPT_LEAK_SEVERITY, checkSystemPromptLeakGuard } from './policies/system-prompt-leak'
|
|
14
23
|
import type { SecurityBlock } from './policy'
|
|
15
24
|
|
|
16
|
-
export {
|
|
25
|
+
export {
|
|
26
|
+
HIGH_TIER_PER_GUARD_PERMISSIONS,
|
|
27
|
+
SECURITY_PERMISSIONS,
|
|
28
|
+
type SecurityPermission,
|
|
29
|
+
type SecuritySeverity,
|
|
30
|
+
SEVERITY_PERMISSION,
|
|
31
|
+
} from './permissions'
|
|
17
32
|
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
33
|
+
// Per-guard permission strings only — tier strings are deliberately
|
|
34
|
+
// absent. Block messages name the per-guard permission AND the tier
|
|
35
|
+
// permission separately (see withPermissionHint); the per-guard hint
|
|
36
|
+
// table answers "which roles carry THIS specific bypass by default."
|
|
37
|
+
type PerGuardSecurityPermission = Exclude<
|
|
38
|
+
SecurityPermission,
|
|
39
|
+
| typeof SECURITY_PERMISSIONS.bypassLow
|
|
40
|
+
| typeof SECURITY_PERMISSIONS.bypassMedium
|
|
41
|
+
| typeof SECURITY_PERMISSIONS.bypassHigh
|
|
42
|
+
>
|
|
43
|
+
|
|
44
|
+
// The satisfies clause forces exhaustive coverage of per-guard
|
|
45
|
+
// permissions at compile time — adding a new SECURITY_PERMISSIONS entry
|
|
46
|
+
// (other than a new tier string) without a hint here is a type error,
|
|
47
|
+
// not a silent fallback.
|
|
26
48
|
const BYPASS_ROLE_HINT = {
|
|
27
|
-
[SECURITY_PERMISSIONS.bypassSecretExfilBash]:
|
|
28
|
-
|
|
49
|
+
[SECURITY_PERMISSIONS.bypassSecretExfilBash]:
|
|
50
|
+
'only owner has it by default (medium tier; trusted does NOT carry this — operators can grant `security.bypass.secretExfilBash` explicitly in roles.trusted.permissions[] if they want the pre-PR ergonomics back)',
|
|
51
|
+
[SECURITY_PERMISSIONS.bypassGitExfil]:
|
|
52
|
+
'NOBODY has it by default — high tier requires per-call ack from every role, including owner. Operators can grant `security.bypass.gitExfil` explicitly in roles.<role>.permissions[] to re-open the auto-bypass for one role.',
|
|
29
53
|
[SECURITY_PERMISSIONS.bypassGitRemoteTainted]:
|
|
30
|
-
'
|
|
31
|
-
[SECURITY_PERMISSIONS.bypassSecretExfilRead]: 'only owner has it by default',
|
|
32
|
-
[SECURITY_PERMISSIONS.bypassSsrf]: 'only owner has it by default',
|
|
33
|
-
[SECURITY_PERMISSIONS.bypassSessionSearchSecrets]: 'only owner has it by default',
|
|
34
|
-
[SECURITY_PERMISSIONS.bypassSystemPromptLeak]:
|
|
35
|
-
|
|
36
|
-
|
|
54
|
+
'NOBODY has it by default — high tier requires per-call ack from every role. Even an operator-granted `security.bypass.gitExfil` does NOT bypass this second-step taint check (the recorder still fires for the first step, so the push is still gated).',
|
|
55
|
+
[SECURITY_PERMISSIONS.bypassSecretExfilRead]: 'only owner has it by default (medium tier)',
|
|
56
|
+
[SECURITY_PERMISSIONS.bypassSsrf]: 'only owner has it by default (medium tier)',
|
|
57
|
+
[SECURITY_PERMISSIONS.bypassSessionSearchSecrets]: 'only owner has it by default (medium tier)',
|
|
58
|
+
[SECURITY_PERMISSIONS.bypassSystemPromptLeak]:
|
|
59
|
+
'NOBODY has it by default — high tier requires per-call ack from every role, including owner.',
|
|
60
|
+
[SECURITY_PERMISSIONS.bypassOutboundSecret]:
|
|
61
|
+
'NOBODY has it by default — high tier requires per-call ack from every role, including owner. The audience-leak rule: even owner posting to a public channel must not silently include credentials.',
|
|
62
|
+
} as const satisfies Record<PerGuardSecurityPermission, string>
|
|
37
63
|
|
|
38
64
|
function withPermissionHint(
|
|
39
65
|
result: SecurityBlock | undefined,
|
|
40
|
-
permission:
|
|
66
|
+
permission: PerGuardSecurityPermission,
|
|
67
|
+
severity: SecuritySeverity,
|
|
41
68
|
): SecurityBlock | undefined {
|
|
42
69
|
if (!result) return result
|
|
43
|
-
const
|
|
70
|
+
const perGuardHint = BYPASS_ROLE_HINT[permission]
|
|
71
|
+
const tierPerm = SEVERITY_PERMISSION[severity]
|
|
44
72
|
return {
|
|
45
73
|
block: true,
|
|
46
|
-
reason: `${result.reason} Or run as a role carrying \`${permission}\` (${
|
|
74
|
+
reason: `${result.reason} Or run as a role carrying \`${permission}\` (${perGuardHint}) or the tier permission \`${tierPerm}\`; see the \`typeclaw-permissions\` skill.`,
|
|
47
75
|
}
|
|
48
76
|
}
|
|
49
77
|
|
|
50
78
|
export default definePlugin({
|
|
51
79
|
permissions: Object.values(SECURITY_PERMISSIONS),
|
|
80
|
+
// High-tier per-guard strings AND the `security.bypass.high` tier
|
|
81
|
+
// string itself are excluded from the owner-wildcard expansion. Owner
|
|
82
|
+
// still has the wildcard sentinel (so future low/medium plugin-
|
|
83
|
+
// contributed bypasses keep auto-flowing to owner), but audience-leak
|
|
84
|
+
// guards require either per-call ack or an explicit operator grant.
|
|
85
|
+
ownerWildcardExclusions: [...HIGH_TIER_PER_GUARD_PERMISSIONS, SECURITY_PERMISSIONS.bypassHigh],
|
|
52
86
|
plugin: async (ctx) => ({
|
|
53
87
|
hooks: {
|
|
54
88
|
'session.prompt': async (event) => {
|
|
@@ -56,68 +90,78 @@ export default definePlugin({
|
|
|
56
90
|
},
|
|
57
91
|
'tool.before': async (event) => {
|
|
58
92
|
const can = (perm: string) => ctx.permissions.has(event.origin, perm)
|
|
93
|
+
const canBypass = (severity: SecuritySeverity, perGuardPerm: string): boolean =>
|
|
94
|
+
can(SEVERITY_PERMISSION[severity]) || can(perGuardPerm)
|
|
59
95
|
|
|
60
96
|
// Taint-recording runs FIRST, independently of the gitExfil guard.
|
|
61
97
|
// The gitRemoteTainted defense depends on it. We pass through
|
|
62
|
-
// `permittedBypass` for actors who can skip gitExfil via
|
|
63
|
-
//
|
|
64
|
-
//
|
|
65
|
-
// change must be remembered
|
|
98
|
+
// `permittedBypass` for actors who can skip gitExfil (via either the
|
|
99
|
+
// per-guard permission or the medium-tier permission) so the
|
|
100
|
+
// recorder still fires for them — an acked or permission-bypassed
|
|
101
|
+
// command will actually run, so its remote change must be remembered.
|
|
66
102
|
recordGitRemoteTaintIfAny({
|
|
67
103
|
tool: event.tool,
|
|
68
104
|
args: event.args,
|
|
69
105
|
sessionId: event.sessionId,
|
|
70
|
-
permittedBypass:
|
|
106
|
+
permittedBypass: canBypass(GUARD_GIT_EXFIL_SEVERITY, SECURITY_PERMISSIONS.bypassGitExfil),
|
|
71
107
|
})
|
|
72
108
|
|
|
73
109
|
const checks: (SecurityBlock | undefined)[] = [
|
|
74
|
-
|
|
110
|
+
canBypass(GUARD_GIT_REMOTE_TAINTED_SEVERITY, SECURITY_PERMISSIONS.bypassGitRemoteTainted)
|
|
75
111
|
? undefined
|
|
76
112
|
: withPermissionHint(
|
|
77
113
|
checkGitRemoteTaintedGuard({ tool: event.tool, args: event.args, sessionId: event.sessionId }),
|
|
78
114
|
SECURITY_PERMISSIONS.bypassGitRemoteTainted,
|
|
115
|
+
GUARD_GIT_REMOTE_TAINTED_SEVERITY,
|
|
79
116
|
),
|
|
80
|
-
|
|
117
|
+
canBypass(GUARD_SECRET_EXFIL_BASH_SEVERITY, SECURITY_PERMISSIONS.bypassSecretExfilBash)
|
|
81
118
|
? undefined
|
|
82
119
|
: withPermissionHint(
|
|
83
120
|
checkSecretExfilBashGuard({ tool: event.tool, args: event.args }),
|
|
84
121
|
SECURITY_PERMISSIONS.bypassSecretExfilBash,
|
|
122
|
+
GUARD_SECRET_EXFIL_BASH_SEVERITY,
|
|
85
123
|
),
|
|
86
|
-
|
|
124
|
+
canBypass(GUARD_GIT_EXFIL_SEVERITY, SECURITY_PERMISSIONS.bypassGitExfil)
|
|
87
125
|
? undefined
|
|
88
126
|
: withPermissionHint(
|
|
89
127
|
checkGitExfilGuard({ tool: event.tool, args: event.args, sessionId: event.sessionId }),
|
|
90
128
|
SECURITY_PERMISSIONS.bypassGitExfil,
|
|
129
|
+
GUARD_GIT_EXFIL_SEVERITY,
|
|
91
130
|
),
|
|
92
|
-
|
|
131
|
+
canBypass(GUARD_SECRET_EXFIL_READ_SEVERITY, SECURITY_PERMISSIONS.bypassSecretExfilRead)
|
|
93
132
|
? undefined
|
|
94
133
|
: withPermissionHint(
|
|
95
134
|
checkSecretExfilReadGuard({ tool: event.tool, args: event.args }),
|
|
96
135
|
SECURITY_PERMISSIONS.bypassSecretExfilRead,
|
|
136
|
+
GUARD_SECRET_EXFIL_READ_SEVERITY,
|
|
97
137
|
),
|
|
98
|
-
|
|
138
|
+
canBypass(GUARD_SSRF_SEVERITY, SECURITY_PERMISSIONS.bypassSsrf)
|
|
99
139
|
? undefined
|
|
100
140
|
: withPermissionHint(
|
|
101
141
|
checkSsrfGuard({ tool: event.tool, args: event.args }),
|
|
102
142
|
SECURITY_PERMISSIONS.bypassSsrf,
|
|
143
|
+
GUARD_SSRF_SEVERITY,
|
|
103
144
|
),
|
|
104
|
-
|
|
145
|
+
canBypass(GUARD_SESSION_SEARCH_SECRETS_SEVERITY, SECURITY_PERMISSIONS.bypassSessionSearchSecrets)
|
|
105
146
|
? undefined
|
|
106
147
|
: withPermissionHint(
|
|
107
148
|
checkSessionSearchSecretsGuard({ tool: event.tool, args: event.args }),
|
|
108
149
|
SECURITY_PERMISSIONS.bypassSessionSearchSecrets,
|
|
150
|
+
GUARD_SESSION_SEARCH_SECRETS_SEVERITY,
|
|
109
151
|
),
|
|
110
|
-
|
|
152
|
+
canBypass(GUARD_SYSTEM_PROMPT_LEAK_SEVERITY, SECURITY_PERMISSIONS.bypassSystemPromptLeak)
|
|
111
153
|
? undefined
|
|
112
154
|
: withPermissionHint(
|
|
113
155
|
checkSystemPromptLeakGuard({ tool: event.tool, args: event.args }),
|
|
114
156
|
SECURITY_PERMISSIONS.bypassSystemPromptLeak,
|
|
157
|
+
GUARD_SYSTEM_PROMPT_LEAK_SEVERITY,
|
|
115
158
|
),
|
|
116
|
-
|
|
159
|
+
canBypass(GUARD_OUTBOUND_SECRET_SEVERITY, SECURITY_PERMISSIONS.bypassOutboundSecret)
|
|
117
160
|
? undefined
|
|
118
161
|
: withPermissionHint(
|
|
119
162
|
checkOutboundSecretGuard({ tool: event.tool, args: event.args }),
|
|
120
163
|
SECURITY_PERMISSIONS.bypassOutboundSecret,
|
|
164
|
+
GUARD_OUTBOUND_SECRET_SEVERITY,
|
|
121
165
|
),
|
|
122
166
|
]
|
|
123
167
|
for (const result of checks) {
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
export type SecuritySeverity = 'low' | 'medium' | 'high'
|
|
2
|
+
|
|
1
3
|
export const SECURITY_PERMISSIONS = {
|
|
2
4
|
bypassSecretExfilBash: 'security.bypass.secretExfilBash',
|
|
3
5
|
bypassGitExfil: 'security.bypass.gitExfil',
|
|
@@ -7,6 +9,40 @@ export const SECURITY_PERMISSIONS = {
|
|
|
7
9
|
bypassSystemPromptLeak: 'security.bypass.systemPromptLeak',
|
|
8
10
|
bypassOutboundSecret: 'security.bypass.outboundSecret',
|
|
9
11
|
bypassGitRemoteTainted: 'security.bypass.gitRemoteTainted',
|
|
12
|
+
// Severity-tier bypasses. Tiers classify guards on a two-axis policy:
|
|
13
|
+
// high — bypass sends data to a third-party audience outside the
|
|
14
|
+
// operator's control loop (channel readers, remote git host).
|
|
15
|
+
// NO role auto-bypasses; ack required from every role.
|
|
16
|
+
// medium — bypass produces silent attacker-favorable state in model
|
|
17
|
+
// context (env dump, .env contents, IAM creds, secret-shaped
|
|
18
|
+
// session-search hits). Owner bypasses, trusted does not.
|
|
19
|
+
// low — bypass produces a noisy, immediately-recoverable side
|
|
20
|
+
// effect. Owner and trusted bypass. No inhabitants today.
|
|
21
|
+
// Per-guard permissions above continue to work as explicit grants —
|
|
22
|
+
// `tool.before` accepts EITHER the tier OR the per-guard string (OR
|
|
23
|
+
// check). This lets operators knowingly re-open a single high-tier
|
|
24
|
+
// guard for one role without widening the whole tier.
|
|
25
|
+
bypassLow: 'security.bypass.low',
|
|
26
|
+
bypassMedium: 'security.bypass.medium',
|
|
27
|
+
bypassHigh: 'security.bypass.high',
|
|
10
28
|
} as const
|
|
11
29
|
|
|
12
30
|
export type SecurityPermission = (typeof SECURITY_PERMISSIONS)[keyof typeof SECURITY_PERMISSIONS]
|
|
31
|
+
|
|
32
|
+
export const SEVERITY_PERMISSION: Record<SecuritySeverity, string> = {
|
|
33
|
+
low: SECURITY_PERMISSIONS.bypassLow,
|
|
34
|
+
medium: SECURITY_PERMISSIONS.bypassMedium,
|
|
35
|
+
high: SECURITY_PERMISSIONS.bypassHigh,
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Per-guard permission strings whose guards are classified `high`. The
|
|
39
|
+
// owner-wildcard expander excludes these so the wildcard sentinel does
|
|
40
|
+
// not auto-grant high-tier bypass to owner. Operators who explicitly
|
|
41
|
+
// want to re-open a high-tier bypass for owner (or any role) can still
|
|
42
|
+
// add the per-guard string to that role's `permissions[]` by hand.
|
|
43
|
+
export const HIGH_TIER_PER_GUARD_PERMISSIONS: readonly string[] = [
|
|
44
|
+
SECURITY_PERMISSIONS.bypassGitExfil,
|
|
45
|
+
SECURITY_PERMISSIONS.bypassGitRemoteTainted,
|
|
46
|
+
SECURITY_PERMISSIONS.bypassOutboundSecret,
|
|
47
|
+
SECURITY_PERMISSIONS.bypassSystemPromptLeak,
|
|
48
|
+
]
|