typeclaw 0.4.0 → 0.5.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 (56) hide show
  1. package/package.json +1 -1
  2. package/src/agent/auth.ts +4 -2
  3. package/src/agent/index.ts +16 -28
  4. package/src/agent/model-fallback.ts +127 -0
  5. package/src/agent/tools/curl-impersonate.ts +300 -0
  6. package/src/agent/tools/ddg.ts +13 -88
  7. package/src/agent/tools/webfetch/fetch.ts +105 -2
  8. package/src/agent/tools/webfetch/tool.ts +4 -0
  9. package/src/bundled-plugins/agent-browser/shim.ts +47 -0
  10. package/src/bundled-plugins/backup/subagents.ts +2 -0
  11. package/src/bundled-plugins/memory/README.md +49 -12
  12. package/src/bundled-plugins/memory/citation-superset.ts +63 -0
  13. package/src/bundled-plugins/memory/dreaming.ts +105 -17
  14. package/src/bundled-plugins/memory/index.ts +2 -2
  15. package/src/bundled-plugins/memory/memory-logger.ts +45 -26
  16. package/src/bundled-plugins/memory/strength.ts +127 -0
  17. package/src/bundled-plugins/memory/topics.ts +75 -0
  18. package/src/bundled-plugins/security/index.ts +87 -43
  19. package/src/bundled-plugins/security/permissions.ts +36 -0
  20. package/src/bundled-plugins/security/policies/git-exfil.ts +20 -0
  21. package/src/bundled-plugins/security/policies/outbound-secret-scan.ts +12 -0
  22. package/src/bundled-plugins/security/policies/prompt-injection.ts +23 -3
  23. package/src/bundled-plugins/security/policies/secret-exfil-bash.ts +7 -0
  24. package/src/bundled-plugins/security/policies/secret-exfil-read.ts +6 -0
  25. package/src/bundled-plugins/security/policies/session-search-secrets.ts +9 -0
  26. package/src/bundled-plugins/security/policies/ssrf.ts +6 -0
  27. package/src/bundled-plugins/security/policies/system-prompt-leak.ts +7 -0
  28. package/src/channels/adapters/github/index.ts +87 -3
  29. package/src/channels/router.ts +194 -28
  30. package/src/channels/types.ts +3 -1
  31. package/src/cli/channel.ts +2 -45
  32. package/src/cli/init.ts +148 -87
  33. package/src/cli/model.ts +12 -3
  34. package/src/cli/oauth-callbacks.ts +49 -0
  35. package/src/cli/provider.ts +3 -20
  36. package/src/cli/ui.ts +95 -0
  37. package/src/config/config.ts +59 -24
  38. package/src/config/models-mutation.ts +42 -8
  39. package/src/config/providers-mutation.ts +12 -8
  40. package/src/container/start.ts +18 -1
  41. package/src/cron/consumer.ts +129 -43
  42. package/src/init/dockerfile.ts +221 -3
  43. package/src/init/hatching.ts +2 -2
  44. package/src/init/index.ts +47 -3
  45. package/src/init/oauth-login.ts +17 -3
  46. package/src/permissions/builtins.ts +29 -7
  47. package/src/permissions/permissions.ts +24 -7
  48. package/src/plugin/define.ts +2 -0
  49. package/src/plugin/manager.ts +14 -0
  50. package/src/plugin/types.ts +6 -0
  51. package/src/run/index.ts +2 -1
  52. package/src/skills/typeclaw-memory/SKILL.md +25 -15
  53. package/src/skills/typeclaw-permissions/SKILL.md +35 -17
  54. package/src/tui/index.ts +35 -3
  55. package/src/usage/report.ts +15 -12
  56. 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 = 10_000
16
- const DEFAULT_BUFFER_BYTES = 100_000
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, everything memorable about what happened facts about the user, the project, decisions made, explicit user preferences, patterns, surprises, anything that could plausibly matter to a future agent in a future session. You write zero or more fragments to today's memory stream file. Then you exit.
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. **You are the additive layer; dreaming is the filter.** This division of labor is the whole point: capture broadly here, and let dreaming throw away what doesn't last.
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, capture
81
+ # Capture philosophy: when in doubt, SKIP
82
82
 
83
- The cost of a missing memory is high a future agent repeats a mistake, asks a question already answered, or violates a commitment it should have inherited. The cost of a redundant memory is low dreaming will collapse it.
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
- So: when in doubt, capture. A slightly redundant fragment is far cheaper than a missed one.
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
- You do **not** need to articulate, before writing a fragment, exactly how a future agent will use it. Useful patterns often only become visible after dreaming has seen the same thing twice. Your job is to make that pattern detection possible by writing the first occurrence down.
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
- - **Under-writing.** Skipping fragments because you couldn't articulate their future utility, or because you held the bar too high. The agent repeats mistakes that the transcript could have prevented.
92
- - **Over-writing into pure noise.** Recording trivially re-derivable facts (e.g. "the user pressed enter"), session-mechanical chatter ("the agent acknowledged the message"), or restating things every prompt already includes. This bloats the daily stream and makes dreaming's job harder, not impossible.
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
- Aim well clear of pure noise; otherwise lean toward capture.
96
+ When unsure, skip. Recurrence will surface real patterns.
95
97
 
96
98
  # What to capture
97
99
 
98
- Anything from the transcript that fits one of these is worth a fragment. This is a starting list, not a closed set:
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
- - **Stable facts about the user, project, or environment.** Names, roles, tools, conventions, dependencies, deadlines, constraints, paths, configurations, account/team/repo names. Even ones mentioned in passing.
101
- - **Decisions and their reasoning.** "We chose X over Y because Z." The why is often more valuable than the what.
102
- - **Explicit commitments and operating rules.** Things the user directly told the agent to always/never do. Style guides. Workflow preferences. House conventions. Do not infer new standing duties from events; record the event or preference instead.
103
- - **Patterns that recurred or were named.** "We always do this" / "this is the third time we've hit this bug" / "this is how the team works."
104
- - **Contradictions of existing memory.** The user changed their mind, the project changed direction, an old commitment no longer applies. Write the new state and name the prior memory it supersedes.
105
- - **Violations of existing memory.** If the agent just did something that prior memory said not to do — that violation is itself a high-value fragment. Capture it.
106
- - **Surprises and corrections.** Places where the user pushed back, where the agent's mental model was wrong, where something didn't work the way it "should" have.
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
- - **Mechanical session noise.** Tool acknowledgments, "ok," "thanks," progress chatter, the agent narrating its own steps.
113
- - **Things every session prompt already includes.** Don't re-record what's in MEMORY.md verbatim, what's in AGENTS.md, or what's hardcoded into the agent's system prompt.
114
- - **Trivially re-derivable facts.** "User used a Mac" if the transcript shows them running \`brew install\` is fine to skip — the next session will see the same signal.
115
- - **Pure speculation untethered to evidence.** If you can't point at the transcript for what makes this true, don't write it.
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
- Light dedup, not strict dedup. When unsure whether something is "already known," err on writing it. Dreaming will collapse duplicates.
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: 256 * 1024,
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 { checkGitExfilGuard, checkGitRemoteTaintedGuard, recordGitRemoteTaintIfAny } from './policies/git-exfil'
6
- import { checkOutboundSecretGuard } from './policies/outbound-secret-scan'
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 { checkSessionSearchSecretsGuard } from './policies/session-search-secrets'
12
- import { checkSsrfGuard } from './policies/ssrf'
13
- import { checkSystemPromptLeakGuard } from './policies/system-prompt-leak'
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 { SECURITY_PERMISSIONS, type SecurityPermission } from './permissions'
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
- // Maps each security bypass permission to a one-line hint about which
19
- // built-in roles carry it. The `satisfies` clause is load-bearing: it
20
- // forces exhaustive coverage of `SecurityPermission` at compile time, so
21
- // adding a new `SECURITY_PERMISSIONS` entry without a hint here is a type
22
- // error rather than a silent fallback to the inaccurate default. `owner`
23
- // always carries every `security.bypass.*` via the wildcard expansion in
24
- // builtins.ts, so the hint must mention owner even for permissions where
25
- // it's the only carrier.
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]: 'owner and trusted have it by default',
28
- [SECURITY_PERMISSIONS.bypassGitExfil]: 'owner and trusted have it by default',
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
- 'only owner has it by default (trusted intentionally does not, so the two-step taint defense still fires)',
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]: 'only owner has it by default',
35
- [SECURITY_PERMISSIONS.bypassOutboundSecret]: 'only owner has it by default',
36
- } as const satisfies Record<SecurityPermission, string>
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: SecurityPermission,
66
+ permission: PerGuardSecurityPermission,
67
+ severity: SecuritySeverity,
41
68
  ): SecurityBlock | undefined {
42
69
  if (!result) return result
43
- const hint = BYPASS_ROLE_HINT[permission]
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}\` (${hint}); see the \`typeclaw-permissions\` skill.`,
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 permission
63
- // so the recorder still fires for them (an acked or
64
- // permission-bypassed command will actually run, so its remote
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: can(SECURITY_PERMISSIONS.bypassGitExfil),
106
+ permittedBypass: canBypass(GUARD_GIT_EXFIL_SEVERITY, SECURITY_PERMISSIONS.bypassGitExfil),
71
107
  })
72
108
 
73
109
  const checks: (SecurityBlock | undefined)[] = [
74
- can(SECURITY_PERMISSIONS.bypassGitRemoteTainted)
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
- can(SECURITY_PERMISSIONS.bypassSecretExfilBash)
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
- can(SECURITY_PERMISSIONS.bypassGitExfil)
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
- can(SECURITY_PERMISSIONS.bypassSecretExfilRead)
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
- can(SECURITY_PERMISSIONS.bypassSsrf)
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
- can(SECURITY_PERMISSIONS.bypassSessionSearchSecrets)
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
- can(SECURITY_PERMISSIONS.bypassSystemPromptLeak)
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
- can(SECURITY_PERMISSIONS.bypassOutboundSecret)
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
+ ]